This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
html is a Go CLI that converts a Markdown file — or data piped on stdin (e.g. tree -d | html) — into a single self-contained, offline HTML document with GitHub-style rendering, syntax highlighting, and copy buttons, then opens it in the browser. Non-Markdown input (command output, logs, source, JSON) renders as faithful preformatted text instead of being mangled by the Markdown parser. Output is cached and only re-rendered when the source changes.
go build ./cmd/html/ # build
go install ./cmd/html # install to GOBIN or GOPATH/bin
html README.md # render + open in browser, prints cache path
html -n README.md # --no-open: render only, don't launch browser
html -f README.md # --force: re-render even if cache is fresh
html --safe untrusted.md # disable raw-HTML passthrough (safe mode)
tree -d | html # pipe stdin: auto-detected as Markdown or plain text
git diff | html -n # plain (preformatted), auto-detected — no mangling
cat README.md | html # Markdown, auto-detected (fenced code / table / setext)
ls -la | html -p # --plain/-p: force preformatted plain text
echo '# hi' | html -m -t Log # --markdown/-m force Markdown; --title/-t page title
html access.log # non-.md file → plain; .md/.markdown → Markdown
cat main.go | html # plain code is auto syntax-highlighted (chroma detect)
html data.json # files highlight by extension (.go/.json/.py/…)
git diff --color | html # ANSI colors preserved as styled spans
git diff --color | html --frame # --frame: wrap plain/ANSI output in a faux terminal-window "screenshot"
cat x.go | html -l go # --lang/-l forces a highlight language; -l text = rawModule path: github.com/dotcommander/html · Go 1.26.x.
go test ./... # all tests
go test -run TestRender_Smoke ./internal/render/ # single test
go vet ./...No .golangci.yml or Makefile exists. All tests use t.Parallel() (top-level and subtests), t.TempDir(), and t.Cleanup() — never defer. No testing.Short() gates, no build tags, no testdata/ golden files.
Test isolation: package TestMain functions set HTML_CACHE_DIR to temporary
directories, so tests do not read or write the real user cache.
Browser QA: run just qa-browser. The source-owned chromedp helper under
tools/chromedp-capture launches a disposable profile, captures explicit
desktop/mobile viewports, checks console errors, and records overflow evidence.
Linear pipeline with one standard-library flag command and optional user config loaded at the CLI boundary. A Markdown file flows:
cmd/html/main.go entrypoint (<20 lines), prints errors with "html:" prefix
└─ internal/cli/root.go standard flag wiring, metadata paths, and argument normalization
└─ internal/actions/run.go orchestration (stat → cache check → render → open)
├─ internal/cache/cache.go cache key, freshness, atomic write
├─ internal/render/
├─ render.go goldmark GFM pipeline + h1 title extraction
├─ report*.go report layouts and semantic component rendering
├─ page.go chroma CSS generation + HTML5 wrapper
└─ embed.go //go:embed assets/base.css + assets/copy.js
└─ internal/report/ analysis, report planning, and shared report types
actions.Run is the orchestrator: it validates and reads the source through
io.LimitReader(f, 32<<20), checks the content-aware cache fingerprint, renders,
and atomically writes the cache or stable output. It opens the result unless
--no-open. The fallback title is the source basename without extension.
render.Render(src, opts) parses once through a package-level goldmark singleton (render.go), then walks that AST for title and heading metadata. wrapPage inlines everything — baseCSS() + generated chroma CSS into one <style>, copyJS() into one <script> — producing a zero-external-resource document. Report mode analyzes and plans through internal/report/, then renders semantic components through internal/render/report*.go.
- Input: file or stdin —
actions.Runbranches onOptions.Stdin: a file is validated withos.Stat, mode-selected by extension (.md/.markdown→ Markdown, every other extension → plain), and validated by source digest plus renderer fingerprint; piped stdin is always read first (bounded by the same 32 MiB cap), content-type auto-detected (render.Detect), and cached bysha256(content).--plain/--markdownoverride detection;--title/-tsets the stdin page title (default"stdin"). Empty stdin and a non-piped invocation with no file argument both error. - Robust input-type detection —
render.Detect(detect.go) classifies a bounded scan window (64 KiB / 256 lines) as binary, Markdown, or plain. Normal document rendering refuses binary input (a NUL byte, or >10% non-text bytes) so a piped image/executable never renders as garbage; report mode is the exception and renders binary as a safe hex/ascii preview throughrender.RenderReport. Markdown requires a high-confidence structural signal (a fenced code block, a GFM table, a GFM task list, a setext heading, or an ATX heading followed by a blank line/EOF); weak inline cues (a# commentline,__dunder__, backticks,arr[i](x)reading as a link) are intentionally not decisive, so scripts, diffs, JSON/YAML, and logs stay plain. Trade-off: prose with only inline Markdown cues renders plain unless you pass-m. - Plain render mode —
render.RenderwithOptions.Plainset bypasses goldmark entirely (plain.go) and picks the most faithful body, all reusingwrapPage(theme/palette controls, width override, copy button): (1) ANSI-colored input →renderANSI(ansi.go) converts SGR sequences to inline-styled<span>s sogit diff --color/tree -Ckeep their colors; (2) otherwise a chroma lexer is chosen viapickLexer(explicitOptions.Lang, thenlexers.MatchonOptions.SourceNamefor files, then boundedlexers.Analyseof the content for stdin) and the source is syntax-highlighted with the same class-based formatter as Markdown code blocks — so the existinghighlightCSSstyles it, no new CSS; (3) otherwise raw HTML-escaped<pre><code class="language-plaintext">.Langoftext/none/plainforces raw. Output-affecting mode, language, theme, palette, and title fields fold into the fingerprint viacacheTag. - Terminal-window frame —
--frame(opt-in, plain-path only) wraps the plain/ANSI body in faux terminal chrome (title bar + traffic-light dots) viaterminalFrame(page.go), injectingassets/frame.cssintowrapPageonly whenrender.Options.Frameis set. It implies plain rendering —actions.RunWithResultforcesPlain=true, and the CLI rejects--framewith--markdownor any report flag. It is output-affecting, so it folds into the fingerprint as+frame. The Markdown path stays byte-identical because the frame markup is gated onopts.Frame, which is only ever true on the plain path; norenderSchemaVersionbump was needed — the newframe.cssasset already busts the fingerprint once. - Atomic cache writes —
cache.Writewrites to a temp file in the cache dir, thenos.Renames into place; a concurrent reader never sees a partial file. Cache dir is~/.config/html/cache/(deliberately notos.UserCacheDir()). - Cache key =
sha256(EvalSymlinks(Abs(path)))incache.PathFor— symlinks and../-relative spellings of the same file collapse to one key. Falls back toAbsifEvalSymlinkserrors. Stdin sources are keyed bysha256(content)(cache.PathForContent) instead — identical piped output reuses one entry. - Freshness =
cacheMtime >= srcMtimeincache.Fresh; a missing cache file returnsfalse, nil(not an error). - Class-based syntax highlighting — chroma is configured with
WithClasses(true), not inline styles, so themes are switchable via CSS.page.gogenerates CSS for both light and dark themes; the dark CSS is scoped under:root[data-theme="dark"]and each theme pair is memoized in async.Map. - GFM enabled via
extension.GFM— bundles Tables, Strikethrough, TaskList, and Linkify together; to toggle one you must decompose GFM into its constituent extensions. - Raw HTML passthrough — enabled by default for local trusted input, but can be disabled with
--safe(which builds goldmark withoutgoldmarkhtml.WithUnsafe(), see comment atrender.go). Do not point this tool at untrusted Markdown unless--safe. - Image inlining + self-containment boundary —
imageInliner(images.go) is a goldmark AST transformer that rewrites localdestinations to base64data:URIs, so a file-rendered document carries its images inline (≤10 MiB each; remote/data:/unknown-type/missing/oversize refs are left untouched — inlining never fails a render). Relative traversal, absolute outside paths, and symlinks that resolve outsideOptions.SourceDirare also left external; rendering andImageDependencyFingerprintshare this containment resolver. "Self-contained / zero-external-resource" scopes to render-time resources — CSS, JS, and these images load with zero network requests; it deliberately does not rewrite hyperlinks: a relative[](./page)stays as authoredhref="./page"(a navigation target, not a loaded resource — dead-on-click when opened from the cache dir, since a linked page can't be embedded). Stdin Markdown has no base directory (Options.SourceDir == ""), so its local image refs stay external (inlining is skipped,run.go); referenced in-tree image bytes/size/presence feed the dependency fingerprint so editing or adding the file invalidates the cache. - Title extraction walks the same parsed AST that is rendered, selecting the first
<h1>and flattening its*ast.Textand*ast.Stringdescendants. Emphasis, code, and link markup contribute their text; raw inline HTML is dropped.
- Assets are compiled in via
//go:embed(embed.go). Editingassets/base.cssorassets/copy.jsrequires a rebuild to take effect — no runtime override. - Cross-platform launcher — selected by
runtime.GOOS(openon macOS,starton Windows,xdg-openwithopenfallback elsewhere);open_commandconfig can override this selection. goldmark-highlighting/v2is pinned to a pseudo-version (v2.0.0-2023...) — that commit is the only published version; treat it as frozen.- Chroma theme names are hardcoded strings (
styles.Get("github")/"github-dark"inpage.go). Anilfrom a renamed/missing style would panic inWriteCSS; revalidate these names when bumping chroma. - Editing any embedded asset invalidates the cache.
render.Fingerprint()(fingerprint.go) hashes a schema version + every file inassets/+ the generated highlight CSS;cache.Freshcompares it via a<hash>.fpsidecar, so changingbase.css/copy.js/theme.js/headings.js/frame.cssforces a re-render even when the source mtime is unchanged. BumprenderSchemaVersionwhen changing renderer logic (e.g.wrapPagemarkup) that the asset bytes don't capture.
~/.config/html/config.json (loaded by internal/config). Missing file = current behavior (zero Config, no error); a malformed file fails the command with a clear html: config: … error. Loaded at the CLI boundary (internal/cli/root.go) and threaded through actions.Options → render.Options / open.Open. All fields optional:
{
"open_command": "firefox", // launcher command; "" = OS default (open/xdg-open/start)
"max_width": "60rem", // reader column CSS max-width (CSS length: 48rem, 800px, 90%…)
"default_theme": "dark", // "light" | "dark" | "auto" ("" = auto/system)
"default_palette": "blue", // "sepia" | "blue" | "green" | "rose" | "catppuccin" ("" = sepia)
"toc": true // override the automatic 4+-heading TOC; omit = automatic
}Output-affecting fields (max_width, default_theme, default_palette, toc) are folded into the cache fingerprint via render.Options.cacheTag, so a config change re-renders. open_command does not affect output and is intentionally excluded from the tag. Per the workspace rule (Go reads config, does not contain config), any new behavioral knob belongs here, not hardcoded.