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:
+163
-15
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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_")]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user