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.
This commit is contained in:
2026-07-26 00:12:33 -04:00
parent 16e969ee15
commit ab2f03213b
4 changed files with 373 additions and 15 deletions
+163 -15
View File
@@ -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"(?<![\w-]){re.escape(entry_name)}(?![\w-])")
if entry_name
else None
)
for line in para:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if name_re and name_re.search(stripped):
continue
if stripped.split()[0].lstrip("$").rstrip(";") not in SHELL_HEADS:
return False
return True
def _render_para(para: list[str], entry_name: str | None, deeper: bool) -> 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
+10
View File
@@ -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,
}),
],
+120
View File
@@ -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);
}
+80
View File
@@ -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_")]