Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .Jules/palette.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
## 2024-05-24 - [Add smooth transitions for a tags but respect user motion preferences]
**Learning:** Adding CSS transitions (`transition: all`) can create unintended animations and performance issues. Also, animations can cause problems for users with vestibular motion disorders.
**Action:** When adding CSS transitions, explicitly specify properties (e.g., `background-color`, `outline-color`) instead of `transition: all`. Always include a `@media (prefers-reduced-motion: reduce)` override to set `transition: none` for accessibility.
## 2024-05-24 - [a ํƒœ๊ทธ์— ๋ถ€๋“œ๋Ÿฌ์šด ํŠธ๋žœ์ง€์…˜ ์ถ”๊ฐ€ ๋ฐ ์‚ฌ์šฉ์ž์˜ ๋ชจ์…˜ ์„ค์ • ์กด์ค‘]
**Learning:** CSS ํŠธ๋žœ์ง€์…˜(`transition: all`)์„ ์ถ”๊ฐ€ํ•˜๋ฉด ์˜๋„์น˜ ์•Š์€ ์• ๋‹ˆ๋ฉ”์ด์…˜๊ณผ ์„ฑ๋Šฅ ๋ฌธ์ œ๋ฅผ ์ผ์œผํ‚ฌ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋˜ํ•œ, ์• ๋‹ˆ๋ฉ”์ด์…˜์€ ์ „์ • ์šด๋™ ์žฅ์• ๊ฐ€ ์žˆ๋Š” ์‚ฌ์šฉ์ž์—๊ฒŒ ๋ฌธ์ œ๋ฅผ ์œ ๋ฐœํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
**Action:** CSS ํŠธ๋žœ์ง€์…˜์„ ์ถ”๊ฐ€ํ•  ๋•Œ `transition: all` ๋Œ€์‹  ์†์„ฑ(`background-color`, `outline-color` ๋“ฑ)์„ ๋ช…์‹œ์ ์œผ๋กœ ์ง€์ •ํ•˜์‹ญ์‹œ์˜ค. ์ ‘๊ทผ์„ฑ์„ ์œ„ํ•ด ํ•ญ์ƒ `@media (prefers-reduced-motion: reduce)` ์˜ค๋ฒ„๋ผ์ด๋“œ๋ฅผ ํฌํ•จํ•˜์—ฌ `transition: none`์œผ๋กœ ์„ค์ •ํ•˜์‹ญ์‹œ์˜ค.
## 2024-07-10 - ๋‹คํฌ ๋ชจ๋“œ ์ง€์› ๋ฐ ์ง€์—ญํ™” ์ผ๊ด€์„ฑ ํ™•๋ณด
**Learning:** `html4tree` CLI๋กœ ์ƒ์„ฑ๋˜๋Š” ์ •์  HTML์—์„œ ์‚ฌ์šฉ์ž๋Š” ๋„ค์ดํ‹ฐ๋ธŒ ๋‹คํฌ ๋ชจ๋“œ๋ฅผ ๊ธฐ๋Œ€ํ•˜๋ฉฐ(์ ‘๊ทผ์„ฑ, ๊ฐ€๋…์„ฑ ๋ฌธ์ œ), `<nav>` ๋ ˆ์ด๋ธ”("Directory listing")์ด ๋‹ค๋ฅธ UI์™€ ๋‹ค๋ฅด๊ฒŒ ์˜๋ฌธ์œผ๋กœ ๋˜์–ด ์žˆ์–ด ์Šคํฌ๋ฆฐ ๋ฆฌ๋” ํ™˜๊ฒฝ ๋“ฑ์—์„œ ์ง€์—ญํ™”(Localization) ์ผ๊ด€์„ฑ์„ ํ•ด์นฉ๋‹ˆ๋‹ค.
**Action:** `prefers-color-scheme: dark` ๋ฏธ๋””์–ด ์ฟผ๋ฆฌ๋ฅผ CSS์— ์ถ”๊ฐ€ํ•˜์—ฌ ๋„ค์ดํ‹ฐ๋ธŒ ๋‹คํฌ ๋ชจ๋“œ๋ฅผ ์ง€์›ํ•˜๊ณ , `<nav aria-label="Directory listing">`์„ `<nav aria-label="๋””๋ ‰ํ† ๋ฆฌ ๋ชฉ๋ก">`์œผ๋กœ ์ˆ˜์ •ํ•˜์—ฌ UI๋ฅผ ํ•œ๊ตญ์–ด๋กœ ํ†ต์ผํ–ˆ์Šต๋‹ˆ๋‹ค.
46 changes: 46 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: CI

on:
push:
branches: [master]
pull_request:
branches: [master]

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# Gradle 5.1.1 (wrapper) supports up to Java 11. Uses the runner image's
# bundled Temurin JDK instead of actions/setup-java to keep every
# workflow dependency hash-pinned (Scorecard Pinned-Dependencies).
- name: Use preinstalled Temurin JDK 11
run: |
echo "JAVA_HOME=$JAVA_HOME_11_X64" >> "$GITHUB_ENV"
echo "$JAVA_HOME_11_X64/bin" >> "$GITHUB_PATH"
- name: Build and test (includes jacoco coverage verification)
run: ./gradlew build --no-daemon
- name: Report coverage gaps on failure
if: failure()
run: |
python3 - <<'PY'
import xml.etree.ElementTree as ET
try:
tree = ET.parse('build/reports/jacoco/test/jacocoTestReport.xml')
except OSError:
print('no jacoco XML report found')
raise SystemExit(0)
for pkg in tree.iter('package'):
for sf in pkg.iter('sourcefile'):
missed = [l.get('nr') for l in sf.iter('line') if l.get('mi') != '0']
if missed:
print(f"{pkg.get('name')}/{sf.get('name')}: missed instructions on lines {', '.join(missed)}")
for cls in pkg.iter('class'):
for m in cls.iter('method'):
counter = next((c for c in m.iter('counter') if c.get('type') == 'INSTRUCTION'), None)
if counter is not None and counter.get('covered') == '0':
print(f"uncovered method: {cls.get('name')}.{m.get('name')}{m.get('desc')} (line {m.get('line')})")
PY
1 change: 1 addition & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@
## 2024-07-08 - URL Encoding String Allocation Bottleneck
**Learning:** `byte.toString(16).padStart(2, '0').toUpperCase()` inside a loop allocating up to 3 strings per reserved byte in a hot path causes significant GC pressure. This is a common but dangerous anti-pattern in Kotlin when processing large strings or numerous files in directory crawlers.
**Action:** Replace chained string operations with direct character mapping and bitwise operations (`ushr`, `and`) when building formatted hex output, which avoids intermediate string creation entirely. Ensure 100% branch coverage with test inputs spanning both < 10 and > 9 hex values.
## 2026-07-10 - Expensive OS stat calls before cheap in-memory checks\n**Learning:** In Kotlin/Java, checking file properties (like `isDirectory` or `isSymbolicLink`) via `java.nio.file.Files` requires allocating `Path` objects and performs expensive native OS stat calls. When processing file listings, always short-circuit filesystem checks by testing against exclusion lists (using cheap in-memory string operations) before calling methods that touch the filesystem.\n**Action:** Re-ordered conditionals to check `exclude` sets before invoking `Files.isDirectory` and `Files.isSymbolicLink`.
8 changes: 8 additions & 0 deletions .jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,11 @@
## 2024-06-25 - Directory Listing Navigation Landmark
**Learning:** Generated directory listings act as navigation regions, and screen readers benefit when the listing is announced separately from the page's main content.
**Action:** Wrap generated directory listing `<ul>` elements in `<nav aria-label="Directory listing">` while keeping the surrounding semantic `<main>` structure.

## 2024-07-09 - CLI HTML Typography and Dark Mode
**Learning:** Even statically generated directory listings need proper typography and color scheme support. Without a constrained max-width, line length becomes unreadable on large monitors. Without dark mode support (`prefers-color-scheme: dark`), opening the generated HTML page can cause sudden visual strain for users on dark-themed OS settings. In addition, when combining multiple media queries, `@media (prefers-color-scheme: dark)` should be placed at the end of the stylesheet to prevent flash of unstyled content (FOUC) and properly override base styles.
**Action:** Always include basic typography constraints (max-width, line-height, system font) and dark mode media queries (at the end of the CSS block) for any CLI tool that generates HTML output to ensure accessibility, visual comfort, and reliable styling prioritization.

## 2026-07-10 - ARIA Label Language Matching
**Learning:** Screen readers rely on the document's language attribute (`lang="ko"`) to select the appropriate pronunciation engine. If semantic descriptors like `aria-label` are provided in a language different from the document language (e.g., English text in a Korean document), they may be mispronounced or skipped entirely, breaking accessibility.
**Action:** Always ensure that `aria-label`, `alt` text, and other hidden semantic text strings match the language specified in the document's `<html lang="...">` tag for proper localization and screen reader compatibility.
38 changes: 38 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# AGENTS.md

Cross-agent conventions for this repo (Claude, Codex, Cursor, opencode, and any
other coding agent). html4tree is a Kotlin CLI (Gradle build) that generates
`index.html` directory listings.

<!-- BEGIN cwl-agent-guidance -->
## Agent guidance (CWL governance)

### Security & review gate

- Every PR runs a central, required **Security Scan** gate: `osv-scan` +
`dependency-review` (diff-scoped) and `trivy-fs` (repo-wide, CRITICAL/HIGH,
fixable). It runs on every PR base, **including stacked PRs**.
- A failing **`trivy-fs` is a REAL finding, not a flake.** Read the job log โ€” it
prints each finding's rule id / severity / file โ€” or the run's SARIF results.
Then **remediate**:
- Dependency vulnerability: bump the offending library in `build.gradle`
(e.g. the Kotlin stdlib or `clikt` version, or a transitive dep).
- Genuine false positive only: add a narrow, documented
`.trivyignore` / `.trivyignore.yaml` entry.
- This repo has no Dockerfile or k8s manifests today; if you add either,
fix image/misconfig findings at the source.
- Do **NOT** weaken or disable the gate. A local `trivy` scan with a stale DB
misses findings โ€” run `trivy --download-db-only` first, and scan the **merge
ref**, not just the PR head.
- The org `code_scanning` ruleset is intentionally **CodeQL-only** (multiple
code-scanning tools can't converge on one PR ref). Gating is by the Security
Scan **job result**, not the code_scanning rule โ€” don't add tools to that rule.

### Code exploration

- This repo has **no `.codegraph/` index**, so use normal search (grep/find,
Read) to locate and understand code. If a `.codegraph/` directory is later
added at the repo root, prefer CodeGraph โ€” `codegraph explore "<query>"` or the
code-review-graph MCP tools โ€” BEFORE grep/find, since it surfaces
callers/callees/impact that text search misses.
<!-- END cwl-agent-guidance -->
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# CHANGELOG

## [Unreleased]
### ์ถ”๊ฐ€
- Palette: ์ƒ์„ฑ๋œ HTML ํŒŒ์ผ์˜ ๊ฐ€๋…์„ฑ ํ–ฅ์ƒ ๋ฐ ์‹œ๊ฐ์  ํŽธ์•ˆํ•จ์„ ์œ„ํ•ด ๋‹คํฌ ๋ชจ๋“œ(`prefers-color-scheme: dark`) ์ง€์› ๋ฐ `body` ์š”์†Œ์˜ ๊ธฐ๋ณธ ํƒ€์ดํฌ๊ทธ๋ž˜ํ”ผ(์‹œ์Šคํ…œ ํฐํŠธ ์ ์šฉ, ์ค‘์•™ ์ •๋ ฌ ๋ฐ ์ตœ๋Œ€ ๋„ˆ๋น„ ์ œํ•œ) ์Šคํƒ€์ผ ์ถ”๊ฐ€.
48 changes: 36 additions & 12 deletions src/main/kotlin/html4tree/main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -123,16 +123,15 @@ fun process_ignore_file(curr_dir: File): Set<String> {
// ๋ณด์•ˆ ํ–ฅ์ƒ: ํŒŒ์ผ ํฌ๊ธฐ(1MB ์ œํ•œ) ๋ฐ ์ค„ ์ˆ˜(1000์ค„), ์ •๊ทœ์‹ ๊ธธ์ด(100์ž)๋ฅผ ์ œํ•œํ•˜์—ฌ ReDoS ๋ฐ ๋ฉ”๋ชจ๋ฆฌ ๊ณ ๊ฐˆ(OOM) ๋ฐฉ์ง€
if(ignore_file.isFile && !Files.isSymbolicLink(ignore_file.toPath()) && ignore_file.length() <= 1048576){
val ignored_regexes = mutableListOf<Regex>()
var patternCount = 0

ignore_file.useLines { lines ->
for ((lineIndex, it) in lines.withIndex()) {
if (lineIndex >= 1000 || patternCount >= 1000) break
// ์ค„ ์ˆ˜ ์ œํ•œ์ด ํŒจํ„ด ์ˆ˜๋„ ํ•จ๊ป˜ ์ƒํ•œ(์ค„๋‹น ์ตœ๋Œ€ 1๊ฐœ ํŒจํ„ด)ํ•˜๋ฏ€๋กœ ๋ณ„๋„ ํŒจํ„ด ์นด์šดํ„ฐ๋Š” ๋ถˆํ•„์š”
if (lineIndex >= 1000) break
val pattern = it.trim()
if (pattern.isNotEmpty() && pattern.length <= 100) {
try {
ignored_regexes.add(("^"+pattern+"$").toRegex())
patternCount++
} catch (_: IllegalArgumentException) {
}
}
Expand Down Expand Up @@ -176,6 +175,15 @@ fun process_dir(curr_dir: File){

val css = """
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 1rem;
color: #24292e;
background-color: #ffffff;
line-height: 1.5;
}
ul {
list-style-type: none;
padding-left: 0;
Expand All @@ -198,6 +206,19 @@ fun process_dir(curr_dir: File){
transition: none;
}
}
@media (prefers-color-scheme: dark) {
body {
color: #c9d1d9;
background-color: #0d1117;
}
a {
color: #58a6ff;
}
a:hover, a:focus-visible {
background-color: #161b22;
outline-color: #58a6ff;
}
}
</style>
"""

Expand All @@ -214,7 +235,7 @@ fun process_dir(curr_dir: File){
<body>
<main>
<h1>${curr_dir.getName().escapeHtml()}</h1>
<nav aria-label="Directory listing">
<nav aria-label="๋””๋ ‰ํ† ๋ฆฌ ๋ชฉ๋ก">
<ul>
<li><a style="display:block; width:100%" href="./.." aria-label="์ƒ์œ„ ๋””๋ ‰ํ† ๋ฆฌ๋กœ ์ด๋™"><span aria-hidden="true">&#x21B0;</span> ..</a></li>
"""
Expand All @@ -225,14 +246,17 @@ fun process_dir(curr_dir: File){
val dir_files: MutableList<File> = curr_dir.listFiles()?.toMutableList() ?: mutableListOf()
dir_files.sortWith(compareBy ({it.name}) )
dir_files.forEach {
val isLinkedDirectory = Files.isDirectory(it.toPath(), LinkOption.NOFOLLOW_LINKS)
if((it.getName() !in exclude) && (isLinkedDirectory || !it.isDirectory()) && !Files.isSymbolicLink(it.toPath())) {
val fileName = it.getName()
val encodedHref = if (isLinkedDirectory) { "./${fileName.urlEncodePath()}/" } else { "./${fileName.urlEncodePath()}" }
val ariaLabel = "${fileName} ${if (isLinkedDirectory) { "๋””๋ ‰ํ† ๋ฆฌ" } else { "ํŒŒ์ผ" }}".escapeHtml()
val icon = if (isLinkedDirectory) { "&#128193;" } else { "&rtrif;" }
l.append(""" <li><a style="display:block; width:100%" href="${encodedHref}" aria-label="${ariaLabel}"><span aria-hidden="true">${icon}</span> ${fileName.escapeHtml()}</a></li>""")
l.append('\n')
val fileName = it.getName()
// โšก Bolt Performance Optimization: Short-circuit string match before expensive OS filesystem calls
if (fileName !in exclude) {
val isLinkedDirectory = Files.isDirectory(it.toPath(), LinkOption.NOFOLLOW_LINKS)
if ((isLinkedDirectory || !it.isDirectory()) && !Files.isSymbolicLink(it.toPath())) {
val encodedHref = if (isLinkedDirectory) { "./${fileName.urlEncodePath()}/" } else { "./${fileName.urlEncodePath()}" }
val ariaLabel = "${fileName} ${if (isLinkedDirectory) { "๋””๋ ‰ํ† ๋ฆฌ" } else { "ํŒŒ์ผ" }}".escapeHtml()
val icon = if (isLinkedDirectory) { "&#128193;" } else { "&rtrif;" }
l.append(""" <li><a style="display:block; width:100%" href="${encodedHref}" aria-label="${ariaLabel}"><span aria-hidden="true">${icon}</span> ${fileName.escapeHtml()}</a></li>""")
l.append('\n')
}
}
}

Expand Down
22 changes: 21 additions & 1 deletion src/test/kotlin/html4tree/MainTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ class MainTest {
assertTrue(indexFile.exists())
val htmlContent = indexFile.readText()
assertTrue(htmlContent.contains("<html lang=\"ko\">"))
assertTrue(htmlContent.contains("<nav aria-label=\"Directory listing\">"))
assertTrue(htmlContent.contains("<nav aria-label=\"๋””๋ ‰ํ† ๋ฆฌ ๋ชฉ๋ก\">"))
assertTrue(htmlContent.contains("<main>"))
assertTrue(htmlContent.contains("</main>"))
assertTrue(htmlContent.contains("aria-label=\"์ƒ์œ„ ๋””๋ ‰ํ† ๋ฆฌ๋กœ ์ด๋™\""))
Expand All @@ -161,6 +161,26 @@ class MainTest {
assertFalse(htmlContent.contains("test.ignore"))
assertTrue(htmlContent.contains("Content-Security-Policy"))
assertTrue(htmlContent.contains("default-src 'none'; style-src 'unsafe-inline';"))
assertTrue(htmlContent.contains("max-width: 800px;"))
assertTrue(htmlContent.contains("@media (prefers-color-scheme: dark)"))
}

@Test
fun testWriteIndexFileCleansUpTempFileOnFailure() {
// Files.move cannot replace a non-empty directory, so this drives the
// exception path through write_index_file's finally block.
val indexDir = File(tempDir, "index.html")
indexDir.mkdir()
File(indexDir, "occupant.txt").writeText("keep")

assertFailsWith<Exception> {
write_index_file(tempDir, "content")
}

assertTrue(indexDir.isDirectory)
assertEquals("keep", File(indexDir, "occupant.txt").readText())
val leftoverTemp = tempDir.listFiles()?.filter { it.name.startsWith(".index-") } ?: emptyList()
assertTrue(leftoverTemp.isEmpty(), "temporary index file should be cleaned up on failure")
}

@Test
Expand Down
44 changes: 44 additions & 0 deletions src/test/kotlin/html4tree/UtilTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package html4tree
import org.junit.Test
import java.io.File
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNull

class UtilTest {
Expand Down Expand Up @@ -44,6 +45,29 @@ class UtilTest {
assertEquals("Entry(data=file1, level=0, next=null)", entry1.toString())
}

@Test
fun testEntryDataClassGeneratedMembers() {
val file1 = File("file1")
val entry = Entry(file1, 0, null)

assertEquals(entry, entry)
assertEquals(Entry(file1, 0, null).hashCode(), entry.hashCode())
assertNotEquals<Any>(entry, "not an entry")
assertNotEquals(entry, Entry(File("file2"), 0, null))
assertNotEquals(entry, Entry(file1, 1, null))
assertNotEquals(entry, Entry(file1, 0, Entry(file1, 1, null)))

val copied = entry.copy(level = 2)
assertEquals(file1, copied.data)
assertEquals(2, copied.level)
assertNull(copied.next)

val (data, level, next) = entry
assertEquals(file1, data)
assertEquals(0, level)
assertNull(next)
}

@Test
fun testLinkedListEntryDataClass() {
val file1 = File("file1")
Expand All @@ -54,6 +78,26 @@ class UtilTest {
assertEquals("LinkedListEntry(file=file1, level=0)", entry1.toString())
}

@Test
fun testLinkedListEntryDataClassGeneratedMembers() {
val file1 = File("file1")
val entry = LinkedListEntry(file1, 0)

assertEquals(entry, entry)
assertEquals(LinkedListEntry(file1, 0).hashCode(), entry.hashCode())
assertNotEquals<Any>(entry, "not an entry")
assertNotEquals(entry, LinkedListEntry(File("file2"), 0))
assertNotEquals(entry, LinkedListEntry(file1, 1))

val copied = entry.copy(level = 5)
assertEquals(file1, copied.file)
assertEquals(5, copied.level)

val (file, level) = entry
assertEquals(file1, file)
assertEquals(0, level)
}

@Test
fun testLinkedListPushExisting() {
val list = LinkedList()
Expand Down
Loading