From ab2f03213b390170b1597f8b20c1ab9833ad61ed Mon Sep 17 00:00:00 2001 From: rootiest Date: Sun, 26 Jul 2026 00:12:33 -0400 Subject: [PATCH] feat(docs-site): syntax-highlight examples and restyle the site The manual is authored man-page style: every synopsis, example, option table, and description sits in one 4-space-indented block. On the site that renders as a single unhighlighted grey slab, because an indented block declares no language. Split each block into its paragraphs at site-build time and classify them: synopsis and shell examples become ```fish fences, descriptions become real prose, and column-aligned reference tables keep their indentation. 175 blocks now highlight; the 412 lines of genuine tables are left alone. The transform is site-only. docs/manual/** keeps the indented form the pandoc man-page pipeline and config-help depend on, and a test enforces that no fence is ever written back to the SSOT. Also: - Point Expressive Code at the bundled Catppuccin Mocha/Latte themes so code blocks match the palette in catppuccin.css. - Build the functions sidebar group explicitly. `autogenerate` labelled it with the raw directory slug and republished the directory index as a child of the group it already titled, producing the duplicate "Functions Reference" row. - Skip `Synopsis:` lines when deriving card descriptions; they restated the calling convention the card already shows as its title. - Widen the palette: tinted heading levels, inline code, links, card hover accents, aside accents, and table headers. Fixes a bug where _split_entries stripped the leading indentation of an entry's first line, detaching `Synopsis:` from the block it opens. --- docs/build-manual.py | 178 +++++++++++++++++++++++++--- docs/site/astro.config.mjs | 10 ++ docs/site/src/styles/catppuccin.css | 120 +++++++++++++++++++ docs/verify-manual.py | 80 +++++++++++++ 4 files changed, 373 insertions(+), 15 deletions(-) diff --git a/docs/build-manual.py b/docs/build-manual.py index 19dc7e6..64cb612 100644 --- a/docs/build-manual.py +++ b/docs/build-manual.py @@ -71,16 +71,148 @@ def _jsx_attr_escape(value: str) -> str: def _first_sentence(body: str) -> str: - """Extract a one-line description from the start of an entry body.""" + """Extract a one-line description from the start of an entry body. + + `Synopsis:` lines are skipped: they restate the calling convention, + which the card already shows as its title, so using one as the card + description wastes the line. + """ for line in body.split("\n"): line = line.strip() if not line or line.startswith(("#", "```", "|", "-", "*", ">")): continue + if line.startswith("Synopsis:"): + continue m = SENTENCE_RE.match(line) return (m.group(1) if m else line)[:160] return "" +# Commands common enough in this manual that a block whose every line starts +# with one is certainly shell, not prose or a two-column reference table. +SHELL_HEADS = frozenset( + """ + abbr alias apt bg bind brew builtin cargo cat cd chmod code command cp curl + dnf echo end env exec export fg fish fisher for funcsave function git help + if jobs kitty ls man math mkdir mv nvim npm pacman paru pip pip3 pkg printf + python python3 rm set shutdown source string sudo switch systemctl test time + tmux touch trash type wget wezterm while yay zellij zypper + """.split() +) + +SYNOPSIS_PREFIX = "Synopsis:" +INDENT = " " + + +def _is_prose(para: list[str]) -> bool: + """True when a paragraph reads as sentences rather than as code or a table. + + Column-aligned reference tables are the main thing to keep out of a + syntax-highlighted fence, and internal runs of two-or-more spaces are + what distinguishes them from prose. `<` and `{` are excluded because + the emitted paragraph is live markdown, where both would be parsed. + """ + text = " ".join(para) + if "<" in text or "{" in text: + return False + if not para or para[-1].rstrip()[-1:] not in ".:": + return False + return all( + len(line.split()) >= 3 and " " not in line.strip() for line in para + ) + + +def _is_shell(para: list[str], entry_name: str | None) -> bool: + """True when every line of a paragraph looks like a shell command.""" + name_re = ( + re.compile(rf"(? str: + """Render one paragraph of a former indented block. + + `deeper` marks paragraphs carrying their own extra indentation — nested + option tables, whose alignment only survives inside a code block. + """ + if not deeper: + if _is_prose(para): + return "\n".join(line.strip() for line in para) + if _is_shell(para, entry_name): + body = "\n".join(para) + return f"```fish\n{body}\n```" + return "\n".join(INDENT + line for line in para) + + +def _prettify_block(block: list[str], entry_name: str | None) -> str: + """Convert one indented block into fenced code, prose, and tables. + + The manual is authored man-page style: every example, table, and + description sits in a single 4-space-indented block, which renders on + the site as one unhighlighted grey slab. Splitting a block into its + paragraphs recovers the structure the indentation flattened. + """ + lines = [line[len(INDENT) :] if line.startswith(INDENT) else line for line in block] + + out: list[str] = [] + if lines and lines[0].startswith(SYNOPSIS_PREFIX): + synopsis = lines.pop(0)[len(SYNOPSIS_PREFIX) :].strip() + out.append(f"```fish\n{synopsis}\n```") + + para: list[str] = [] + for line in lines + [""]: + if line.strip(): + para.append(line) + continue + if para: + deeper = any(line.startswith(" ") for line in para) + out.append(_render_para(para, entry_name, deeper)) + para = [] + return "\n\n".join(chunk for chunk in out if chunk.strip()) + + +def prettify(body: str, entry_name: str | None = None) -> str: + """Rewrite a body's indented code blocks for the website. + + Site-only: the man page and `config-help` keep reading the untouched + SSOT, where the indented form is exactly what pandoc wants. + """ + out: list[str] = [] + block: list[str] = [] + in_fence = False + + for line in body.split("\n"): + if mt.FENCE_RE.match(line): + in_fence = not in_fence + if not in_fence and (line.startswith(INDENT) or (not line.strip() and block)): + block.append(line) + continue + if block: + while block and not block[-1].strip(): + block.pop() + out.append(_prettify_block(block, entry_name)) + out.append("") + block = [] + out.append(line) + + if block: + while block and not block[-1].strip(): + block.pop() + out.append(_prettify_block(block, entry_name)) + return "\n".join(out) + + def _page_fm(fm: dict) -> dict: """Strip pipeline-only keys from frontmatter destined for the site.""" return {k: v for k, v in fm.items() if k not in PIPELINE_KEYS} @@ -114,7 +246,10 @@ def _split_entries(body: str) -> tuple[str, list[tuple[str, str]]]: for idx, (line_no, title) in enumerate(boundaries): start = line_no + 1 end = boundaries[idx + 1][0] if idx + 1 < len(boundaries) else len(lines) - entry_body = "\n".join(lines[start:end]).strip() + # Strip newlines only: a bare .strip() would eat the leading + # indentation of the entry's first line, detaching the `Synopsis:` + # line from the indented block it opens. + entry_body = "\n".join(lines[start:end]).strip("\n") entries.append((title.strip(), entry_body)) return intro, entries @@ -126,6 +261,7 @@ def build_site(root: Path, out: Path) -> list[dict]: out.mkdir(parents=True) sidebar: list[dict] = [] + functions_group: dict = {} for path, _depth in mt.walk(root): fm, body = mt.parse(path) if not fm.get("site", True): @@ -137,7 +273,7 @@ def build_site(root: Path, out: Path) -> list[dict]: if not is_function_dir: target = out / rel target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(mt.serialize(_page_fm(fm), body)) + target.write_text(mt.serialize(_page_fm(fm), prettify(body))) if rel.name != "index.md": sidebar.append({"label": fm["title"], "link": "/" + rel.stem + "/"}) continue @@ -147,17 +283,16 @@ def build_site(root: Path, out: Path) -> list[dict]: if rel.name == "index.md": target = out / slug_dir / "index.md" target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(mt.serialize(_page_fm(fm), body)) - sidebar.append( - { - "label": fm["title"], - "collapsed": True, - # Starlight >=0.39 rejects a bare `autogenerate` sibling - # of `label` on a top-level group (removed in v0.39.0); - # the autogenerate config must be nested inside `items`. - "items": [{"autogenerate": {"directory": slug_dir}}], - } - ) + target.write_text(mt.serialize(_page_fm(fm), prettify(body))) + # Built explicitly rather than by `autogenerate`, which labels + # each group with its raw directory slug and republishes this + # index as a child of the group it already titles. + functions_group = { + "label": fm["title"], + "collapsed": True, + "items": [{"label": "Overview", "link": f"/{slug_dir}/"}], + } + sidebar.append(functions_group) continue category = re.sub(r"^\d+-", "", rel.stem) @@ -166,6 +301,7 @@ def build_site(root: Path, out: Path) -> list[dict]: intro, entries = _split_entries(body) cards = [] + links = [] for title, entry_body in entries: entry_slug = re.sub(r"[^\w-]+", "-", title.strip().lower()).strip("-") desc = _first_sentence(entry_body) @@ -173,9 +309,10 @@ def build_site(root: Path, out: Path) -> list[dict]: if desc: entry_fm["description"] = desc (cat_dir / f"{entry_slug}.md").write_text( - mt.serialize(entry_fm, entry_body) + mt.serialize(entry_fm, prettify(entry_body, title.split()[0])) ) href = f"/{slug_dir}/{category}/{entry_slug}/" + links.append({"label": title, "link": href}) safe_title = _jsx_attr_escape(title) safe_desc = _jsx_attr_escape(desc) cards.append( @@ -193,6 +330,17 @@ def build_site(root: Path, out: Path) -> list[dict]: ) (cat_dir / "index.mdx").write_text(mt.serialize(_page_fm(fm), overview)) + functions_group.setdefault("items", []).append( + { + "label": fm["title"], + "collapsed": True, + "items": [ + {"label": "Overview", "link": f"/{slug_dir}/{category}/"}, + *links, + ], + } + ) + return sidebar diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index 440f98d..47546e5 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -16,6 +16,16 @@ export default defineConfig({ }, ], customCss: ['./src/styles/catppuccin.css'], + expressiveCode: { + // Shiki ships both Catppuccin flavours; Starlight picks by the + // reader's colour scheme, matching the palette in catppuccin.css. + themes: ['catppuccin-mocha', 'catppuccin-latte'], + styleOverrides: { + borderRadius: '0.4rem', + borderColor: 'var(--sl-color-gray-5)', + codeFontSize: '0.875rem', + }, + }, sidebar, }), ], diff --git a/docs/site/src/styles/catppuccin.css b/docs/site/src/styles/catppuccin.css index 7eaf590..8496ef4 100644 --- a/docs/site/src/styles/catppuccin.css +++ b/docs/site/src/styles/catppuccin.css @@ -1,5 +1,18 @@ /* Catppuccin Mocha (dark) / Latte (light) mapped onto Starlight tokens. */ :root { + --ctp-rosewater: #f5e0dc; + --ctp-pink: #f5c2e7; + --ctp-mauve: #cba6f7; + --ctp-red: #f38ba8; + --ctp-peach: #fab387; + --ctp-yellow: #f9e2af; + --ctp-green: #a6e3a1; + --ctp-teal: #94e2d5; + --ctp-sky: #89dceb; + --ctp-blue: #89b4fa; + --ctp-lavender: #b4befe; + --ctp-surface: #313244; + --sl-color-accent-low: #1e1e2e; --sl-color-accent: #89b4fa; --sl-color-accent-high: #b4befe; @@ -14,6 +27,19 @@ } :root[data-theme='light'] { + --ctp-rosewater: #dc8a78; + --ctp-pink: #ea76cb; + --ctp-mauve: #8839ef; + --ctp-red: #d20f39; + --ctp-peach: #fe640b; + --ctp-yellow: #df8e1d; + --ctp-green: #40a02b; + --ctp-teal: #179299; + --ctp-sky: #04a5e5; + --ctp-blue: #1e66f5; + --ctp-lavender: #7287fd; + --ctp-surface: #ccd0da; + --sl-color-accent-low: #dce0e8; --sl-color-accent: #1e66f5; --sl-color-accent-high: #7287fd; @@ -26,3 +52,97 @@ --sl-color-gray-6: #ccd0da; --sl-color-black: #eff1f5; } + +/* ── Headings ──────────────────────────────────────────────────────── */ +/* The manual is one long reference; tinting each level makes the + hierarchy scannable without relying on size alone. */ +.sl-markdown-content h1 { + color: var(--ctp-lavender); +} + +.sl-markdown-content h2 { + color: var(--ctp-mauve); + border-bottom: 1px solid var(--sl-color-gray-5); + padding-bottom: 0.2em; +} + +.sl-markdown-content h3 { + color: var(--ctp-blue); +} + +.sl-markdown-content h4 { + color: var(--ctp-teal); +} + +/* ── Inline code ───────────────────────────────────────────────────── */ +/* Function names, variables, and flags appear inline constantly; peach + separates them from prose the way the fenced blocks separate examples. */ +.sl-markdown-content :not(pre) > code { + color: var(--ctp-peach); + background: var(--ctp-surface); + border: 1px solid var(--sl-color-gray-5); + border-radius: 0.3em; + padding: 0.1em 0.35em; +} + +.sl-markdown-content a > code { + color: var(--ctp-sky); +} + +/* ── Links ─────────────────────────────────────────────────────────── */ +.sl-markdown-content a:not(:where(.not-content *)) { + color: var(--ctp-sky); + text-decoration-color: var(--sl-color-gray-4); +} + +.sl-markdown-content a:not(:where(.not-content *)):hover { + color: var(--ctp-teal); + text-decoration-color: currentColor; +} + +/* ── Cards ─────────────────────────────────────────────────────────── */ +/* Each function category is a grid of LinkCards; a hover accent makes the + grid feel navigable rather than like a wall of boxes. */ +.card { + border-color: var(--sl-color-gray-5); + transition: + border-color 150ms ease, + transform 150ms ease; +} + +.card:hover { + border-color: var(--ctp-mauve); + transform: translateY(-2px); +} + +.card .title { + color: var(--ctp-lavender); +} + +/* ── Asides ────────────────────────────────────────────────────────── */ +.starlight-aside--note { + --sl-color-asides-text-accent: var(--ctp-blue); +} + +.starlight-aside--tip { + --sl-color-asides-text-accent: var(--ctp-green); +} + +.starlight-aside--caution { + --sl-color-asides-text-accent: var(--ctp-yellow); +} + +.starlight-aside--danger { + --sl-color-asides-text-accent: var(--ctp-red); +} + +/* ── Site chrome ───────────────────────────────────────────────────── */ +.site-title { + color: var(--ctp-mauve); +} + +/* Table headers carry the accent so the many reference tables in the + manual read as structured data at a glance. */ +.sl-markdown-content th { + color: var(--ctp-yellow); +} diff --git a/docs/verify-manual.py b/docs/verify-manual.py index caa63a1..4ba56af 100644 --- a/docs/verify-manual.py +++ b/docs/verify-manual.py @@ -213,6 +213,86 @@ def test_site_build_produces_function_pages(): assert "title" in fm, f"{page.name} has no title" +def test_prettify_splits_an_entry_block(): + """A man-page-style entry becomes fenced code, prose, and a table.""" + import build_manual + + body = "\n".join( + [ + " Synopsis: rm [-e | args...]", + " Safe rm wrapper routing to trash:", + "", + " (no args) List current trash contents", + " -e/--empty Empty the trash", + "", + " Falls back to /usr/bin/rm when trash is unavailable.", + "", + " rm file.txt # moves to trash", + " rm -e # empty trash", + ] + ) + out = build_manual.prettify(body, "rm") + + assert "```fish\nrm [-e | args...]\n```" in out, "synopsis was not fenced as fish" + assert "```fish\nrm file.txt" in out, "examples were not fenced as fish" + assert out.count("```") == 4, f"expected exactly two fences, got:\n{out}" + assert "\nSafe rm wrapper routing to trash:" in out, "description stayed indented" + assert ( + "\n (no args) List current trash contents" in out + ), "option table lost its indentation" + assert ( + "\nFalls back to /usr/bin/rm when trash is unavailable." in out + ), "trailing prose stayed indented" + + +def test_prettify_leaves_reference_tables_alone(): + """Column-aligned blocks are data, not shell, and must not be fenced.""" + import build_manual + + table = " XDG_CONFIG_HOME ~/.config\n XDG_CACHE_HOME ~/.cache" + assert "```" not in build_manual.prettify(table), "a reference table got fenced" + + binds = " n / nv / neovim nvim\n e edit" + assert "```" not in build_manual.prettify(binds), "an abbreviation table got fenced" + + shell = " set -U __fish_user_dots_path /path/to/dots" + assert "```fish" in build_manual.prettify(shell), "a shell block was not fenced" + + +def test_prettify_is_site_only(): + """The SSOT keeps the indented form the man-page pipeline depends on.""" + import build_manual + + manual = Path(__file__).parent / "manual" + for path in manual.rglob("*.md"): + assert "```" not in path.read_text(), ( + f"{path.name} contains a fence: prettify must run at site-build " + "time, never be written back to the SSOT" + ) + + +def test_sidebar_has_no_duplicate_functions_entry(): + """The functions group must not also list itself as one of its children.""" + import build_manual + + docs = Path(__file__).parent + with tempfile.TemporaryDirectory() as d: + sidebar = build_manual.build_site(docs / "manual", Path(d)) + + groups = [e for e in sidebar if "items" in e] + assert len(groups) == 1, f"expected one sidebar group, got {len(groups)}" + group = groups[0] + labels = [item["label"] for item in group["items"]] + assert group["label"] not in labels[1:], ( + f"'{group['label']}' is repeated inside its own group: {labels}" + ) + assert labels[0] == "Overview", f"group should lead with Overview, got {labels[0]}" + assert not any( + "autogenerate" in item for item in group["items"] + ), "categories should be listed explicitly, not autogenerated from slugs" + assert len(labels) == 15, f"expected Overview + 14 categories, got {len(labels)}" + + TESTS = [v for k, v in sorted(globals().items()) if k.startswith("test_")]