Merge pull request 'feat(docs): Starlight asides and file-tree conversions (site-build-only)' (#78) from starlight-asides-and-filetree into main
Generate documentation / build-docs (push) Successful in 3m7s

Reviewed-on: #78
This commit was merged in pull request #78.
This commit is contained in:
2026-07-27 01:39:33 +00:00
4 changed files with 289 additions and 34 deletions
+90 -8
View File
@@ -314,6 +314,26 @@ def _as_ruled_table(para: list[str]) -> str | None:
return "\n".join(out)
TREE_ROOT_RE = re.compile(r"^[~$][\w./{}-]*/$")
TREE_BRANCH_RE = re.compile(r"^[├└]──\s*(\S+)\s*(.*)$")
def _as_file_tree(para: list[str]) -> str | None:
"""Render a hand-drawn box-drawing tree as a Starlight <FileTree>, else None."""
if len(para) < 2 or not TREE_ROOT_RE.match(para[0].strip()):
return None
branches = []
for line in para[1:]:
m = TREE_BRANCH_RE.match(line)
if not m:
return None
branches.append(m.groups())
out = ["<FileTree>", f"- {para[0].strip()}"]
out += [f" - {name} {desc}".rstrip() for name, desc in branches]
out.append("</FileTree>")
return "\n".join(out)
def _render_para(para: list[str], entry_name: str | None, deeper: bool) -> str:
"""Render one paragraph of a former indented block.
@@ -335,7 +355,7 @@ def _render_para(para: list[str], entry_name: str | None, deeper: bool) -> str:
title, body = m.group(1), para[1:]
info = f'fish title="{title}"' if title else "fish"
return f"```{info}\n" + "\n".join(body) + "\n```"
table = _as_ruled_table(para) or _as_table(para)
table = _as_ruled_table(para) or _as_table(para) or _as_file_tree(para)
if table is not None:
return table
return "\n".join(INDENT + line for line in para)
@@ -385,20 +405,54 @@ def _prettify_block(block: list[str], entry_name: str | None) -> str:
return "\n\n".join(chunk for chunk in out if chunk.strip())
ASIDE_LABELS: dict[str, tuple[str, str, str | None]] = {
"NOTE": ("note", "Note", None),
"IMPORTANT": ("note", "Important", "star"),
"TIP": ("tip", "Tip", None),
"HINT": ("tip", "Hint", "question-circle"),
"WARNING": ("caution", "Warning", "warning"),
"CAUTION": ("caution", "Caution", None),
"DANGER": ("danger", "Danger", None),
}
ASIDE_RE = re.compile(rf"^({'|'.join(ASIDE_LABELS)}):\s*(.*)$")
def _as_aside(para: list[str]) -> str | None:
"""Render a `LABEL: ...` flat paragraph as a Starlight <Aside>, else None."""
m = ASIDE_RE.match(para[0])
if not m:
return None
label, rest = m.groups()
aside_type, title, icon = ASIDE_LABELS[label]
body = "\n".join(([rest] if rest else []) + para[1:])
attrs = f'type="{aside_type}" title="{title}"'
if icon:
attrs += f' icon="{icon}"'
return f"<Aside {attrs}>\n{body}\n</Aside>"
def prettify(body: str, entry_name: str | None = None) -> str:
"""Rewrite a body's indented code blocks for the website.
"""Rewrite a body's indented code blocks and labeled asides 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.
SSOT, where the indented form and the `LABEL:` text are exactly what
pandoc/`config-help` want.
"""
out: list[str] = []
block: list[str] = []
flat: list[str] = []
in_fence = False
def flush_flat() -> None:
out.append(_as_aside(flat) or "\n".join(flat))
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)):
if flat:
flush_flat()
flat.clear()
block.append(line)
continue
if block:
@@ -407,8 +461,16 @@ def prettify(body: str, entry_name: str | None = None) -> str:
out.append(_prettify_block(block, entry_name))
out.append("")
block = []
out.append(line)
if in_fence or not line.strip():
if flat:
flush_flat()
flat.clear()
out.append(line)
else:
flat.append(line)
if flat:
flush_flat()
if block:
while block and not block[-1].strip():
block.pop()
@@ -532,6 +594,24 @@ def _split_entries(body: str) -> tuple[str, list[tuple[str, str]]]:
return intro, entries
ASTRO_ASIDE_COMPONENTS = {"<Aside": "Aside", "<FileTree": "FileTree"}
def _write_prettified(target: Path, fm: dict, content: str) -> None:
"""Write a prettified page, promoting to .mdx when it needs a component import.
A page stays .md (prettify()'s default, no imports) unless its rendered
content actually contains an <Aside> or <FileTree> — the only two
components a prettified (non-hand-built) page can contain.
"""
needed = [name for marker, name in ASTRO_ASIDE_COMPONENTS.items() if marker in content]
if needed:
imports = f"import {{ {', '.join(needed)} }} from '@astrojs/starlight/components';\n\n"
target = target.with_suffix(".mdx")
content = imports + content
target.write_text(mt.serialize(fm, content))
def build_site(root: Path, out: Path) -> list[dict]:
"""Write the Starlight content tree. Returns the sidebar structure."""
if out.exists():
@@ -554,7 +634,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), prettify(body)))
_write_prettified(target, _page_fm(fm), prettify(body))
if rel.name != "index.md":
sidebar.append({"label": fm["title"], "link": "/" + rel.stem + "/"})
continue
@@ -571,7 +651,7 @@ 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), prettify(body)))
_write_prettified(target, _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.
@@ -596,8 +676,10 @@ def build_site(root: Path, out: Path) -> list[dict]:
entry_fm = {"title": title}
if desc:
entry_fm["description"] = desc
(cat_dir / f"{entry_slug}.md").write_text(
mt.serialize(entry_fm, prettify(entry_body, title.split()[0]))
_write_prettified(
cat_dir / f"{entry_slug}.md",
entry_fm,
prettify(entry_body, title.split()[0]),
)
href = f"/{slug_dir}/{category}/{entry_slug}/"
links.append({"label": title, "link": href})
+5 -11
View File
@@ -2603,17 +2603,11 @@ For an interactive alternative to setting these variables by hand, run
config-settings — a full-screen TUI that flips any category (including C5
logging) on or off, per session or universally. See its entry in Section 5.
Notes:
- Command shadows (rm, cat, ls, ...) react immediately; conf.d-level
components (bindings, prompt, abbreviations, hooks) take effect in
new shells.
- With aliases disabled, rm falls back to bare `command rm` — files
are deleted permanently, not trashed.
- Disabled integration commands (spwin, tab, split, hist, logs,
upgrade) print an error naming the variable that disabled them.
- On CachyOS, the distro fish config's own aliases, history override,
and bang-bang bindings are stripped per category as well.
NOTE:
- Command shadows (rm, cat, ls, ...) react immediately; conf.d-level components (bindings, prompt, abbreviations, hooks) take effect in new shells.
- With aliases disabled, rm falls back to bare `command rm` — files are deleted permanently, not trashed.
- Disabled integration commands (spwin, tab, split, hist, logs, upgrade) print an error naming the variable that disabled them.
- On CachyOS, the distro fish config's own aliases, history override, and bang-bang bindings are stripped per category as well.
### Component Reference
+5 -11
View File
@@ -123,17 +123,11 @@ For an interactive alternative to setting these variables by hand, run
config-settings — a full-screen TUI that flips any category (including C5
logging) on or off, per session or universally. See its entry in Section 5.
Notes:
- Command shadows (rm, cat, ls, ...) react immediately; conf.d-level
components (bindings, prompt, abbreviations, hooks) take effect in
new shells.
- With aliases disabled, rm falls back to bare `command rm` — files
are deleted permanently, not trashed.
- Disabled integration commands (spwin, tab, split, hist, logs,
upgrade) print an error naming the variable that disabled them.
- On CachyOS, the distro fish config's own aliases, history override,
and bang-bang bindings are stripped per category as well.
NOTE:
- Command shadows (rm, cat, ls, ...) react immediately; conf.d-level components (bindings, prompt, abbreviations, hooks) take effect in new shells.
- With aliases disabled, rm falls back to bare `command rm` — files are deleted permanently, not trashed.
- Disabled integration commands (spwin, tab, split, hist, logs, upgrade) print an error naming the variable that disabled them.
- On CachyOS, the distro fish config's own aliases, history override, and bang-bang bindings are stripped per category as well.
### Component Reference
+189 -4
View File
@@ -538,16 +538,201 @@ def test_prettify_titles_paths_and_commented_examples():
)
def test_as_aside_converts_a_single_line_label():
"""A `LABEL: text` line becomes a titled <Aside> with the label's type."""
import build_manual
out = build_manual._as_aside(["TIP: Use `-h` on any function for its help text."])
assert out == (
'<Aside type="tip" title="Tip">\n'
"Use `-h` on any function for its help text.\n"
"</Aside>"
), f"unexpected aside output:\n{out}"
def test_as_aside_converts_a_label_and_list():
"""A label line immediately followed by a list becomes one aside body."""
import build_manual
out = build_manual._as_aside(
[
"NOTE:",
" - Command shadows react immediately.",
" - conf.d-level components take effect in new shells.",
]
)
assert out == (
'<Aside type="note" title="Note">\n'
" - Command shadows react immediately.\n"
" - conf.d-level components take effect in new shells.\n"
"</Aside>"
), f"unexpected aside output:\n{out}"
def test_as_aside_adds_icon_only_where_types_collide():
"""WARNING and CAUTION share the `caution` type; only WARNING gets an icon."""
import build_manual
warning = build_manual._as_aside(["WARNING: Deletes files permanently."])
caution = build_manual._as_aside(["CAUTION: Slow on large trees."])
assert 'icon="warning"' in warning, f"WARNING lost its icon override:\n{warning}"
assert "icon=" not in caution, f"CAUTION should use its type's default icon:\n{caution}"
def test_as_aside_rejects_non_label_paragraphs():
"""Only the closed set of 7 labels triggers an aside; other WORD: prose does not."""
import build_manual
cases = {
"Example prefix": ["Example:", "rm file.txt"],
"sentence with a colon": ["Options: pass one of the flags below."],
"lowercase label": ["note: this should not become an aside"],
}
for label, para in cases.items():
assert build_manual._as_aside(para) is None, f"{label} was wrongly asided"
def test_prettify_flat_paragraphs_unchanged_when_not_labeled():
"""Restructuring prettify()'s loop to buffer flat lines must not alter output for ordinary prose."""
import build_manual
body = "\n".join(
[
"## Heading",
"",
"First line of a paragraph",
"continued on a second line.",
"",
"A second paragraph.",
]
)
assert build_manual.prettify(body) == body, "flat prose was altered by the aside buffering"
def test_prettify_converts_a_flat_note_paragraph_to_an_aside():
"""A `NOTE:` paragraph surrounded by ordinary prose becomes an <Aside>, prose is untouched."""
import build_manual
body = "\n".join(
[
"## Heading",
"",
"Ordinary prose before.",
"",
"NOTE: Something worth calling out.",
"",
"Ordinary prose after.",
]
)
out = build_manual.prettify(body)
assert "## Heading" in out
assert "Ordinary prose before." in out
assert "Ordinary prose after." in out
assert (
'<Aside type="note" title="Note">\nSomething worth calling out.\n</Aside>' in out
), f"note paragraph was not converted:\n{out}"
def test_as_file_tree_converts_a_box_drawing_tree():
"""A root path plus ├──/└── branches becomes a Starlight <FileTree>."""
import build_manual
para = [
"$__fish_user_dots_path/",
"├── secrets.fish API keys, tokens, passwords, personal identifiers",
"└── local.fish Machine-specific paths, env vars, and sourcing secrets",
]
out = build_manual._as_file_tree(para)
assert out == (
"<FileTree>\n"
"- $__fish_user_dots_path/\n"
" - secrets.fish API keys, tokens, passwords, personal identifiers\n"
" - local.fish Machine-specific paths, env vars, and sourcing secrets\n"
"</FileTree>"
), f"unexpected file tree output:\n{out}"
def test_as_file_tree_rejects_non_trees():
"""Returning None is always safe for anything that isn't a root+branches shape."""
import build_manual
cases = {
"no root slash": ["$__fish_user_dots_path", "├── secrets.fish"],
"single line": ["$__fish_user_dots_path/"],
"non-branch second line": ["$__fish_user_dots_path/", "secrets.fish"],
}
for label, para in cases.items():
assert build_manual._as_file_tree(para) is None, f"{label} was wrongly treed"
def test_as_file_tree_rejects_deeper_trees():
"""A tree with a second-level (indented) branch line is not detected — falls through to verbatim rendering."""
import build_manual
para = [
"~/proj/",
"├── src/",
"│ └── main.fish",
"└── README.md",
]
assert build_manual._as_file_tree(para) is None
def test_customization_notes_render_as_aside():
"""The real 07-customization.md NOTE paragraph converts to one intact <Aside>."""
import build_manual
path = Path(__file__).parent / "manual" / "07-customization.md"
_, body = mt.parse(path)
out = build_manual.prettify(body)
assert out.count('<Aside type="note" title="Note">') == 1, f"expected exactly one Note aside:\n{out}"
aside = out.split('<Aside type="note" title="Note">')[1].split("</Aside>")[0]
assert aside.count(" - ") == 4, f"expected exactly 4 bullets inside the aside:\n{aside}"
assert "- Command shadows (rm, cat, ls, ...) react immediately" in aside
assert "- With aliases disabled, rm falls back to bare `command rm`" in aside
assert "- Disabled integration commands (spwin, tab, split, hist, logs, upgrade)" in aside
assert "- On CachyOS, the distro fish config's own aliases" in aside
def test_site_promotes_pages_with_asides_or_filetrees_to_mdx():
"""A page whose rendered content contains <Aside> or <FileTree> is written as .mdx."""
import build_manual
docs = Path(__file__).parent
with tempfile.TemporaryDirectory() as d:
out = Path(d)
build_manual.build_site(docs / "manual", out)
assert (out / "10-personalization.mdx").exists(), "FileTree page was not promoted to .mdx"
assert not (out / "10-personalization.md").exists(), "old .md sibling was left behind"
assert (out / "11-viewing-this-manual.mdx").exists(), "Aside page (NOTE) was not promoted to .mdx"
assert (out / "07-customization.mdx").exists(), "Aside page (rewritten NOTE) was not promoted to .mdx"
assert (out / "01-configuration-variables.md").exists(), "plain page was wrongly promoted to .mdx"
assert not (out / "01-configuration-variables.mdx").exists(), "plain page should stay .md"
text = (out / "10-personalization.mdx").read_text()
assert "import { FileTree } from '@astrojs/starlight/components';" in text
text2 = (out / "11-viewing-this-manual.mdx").read_text()
assert "import { Aside } from '@astrojs/starlight/components';" in text2
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"
forbidden = ("```", "<Aside", "<FileTree", ":::")
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"
)
text = path.read_text()
for marker in forbidden:
assert marker not in text, (
f"{path.name} contains {marker!r}: prettify must run at "
"site-build time, never be written back to the SSOT"
)
def test_sidebar_has_no_duplicate_functions_entry():