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) 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: def _render_para(para: list[str], entry_name: str | None, deeper: bool) -> str:
"""Render one paragraph of a former indented block. """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:] title, body = m.group(1), para[1:]
info = f'fish title="{title}"' if title else "fish" info = f'fish title="{title}"' if title else "fish"
return f"```{info}\n" + "\n".join(body) + "\n```" 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: if table is not None:
return table return table
return "\n".join(INDENT + line for line in para) 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()) 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: 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 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] = [] out: list[str] = []
block: list[str] = [] block: list[str] = []
flat: list[str] = []
in_fence = False in_fence = False
def flush_flat() -> None:
out.append(_as_aside(flat) or "\n".join(flat))
for line in body.split("\n"): for line in body.split("\n"):
if mt.FENCE_RE.match(line): if mt.FENCE_RE.match(line):
in_fence = not in_fence in_fence = not in_fence
if not in_fence and (line.startswith(INDENT) or (not line.strip() and block)): if not in_fence and (line.startswith(INDENT) or (not line.strip() and block)):
if flat:
flush_flat()
flat.clear()
block.append(line) block.append(line)
continue continue
if block: 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(_prettify_block(block, entry_name))
out.append("") out.append("")
block = [] 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: if block:
while block and not block[-1].strip(): while block and not block[-1].strip():
block.pop() block.pop()
@@ -532,6 +594,24 @@ def _split_entries(body: str) -> tuple[str, list[tuple[str, str]]]:
return intro, entries 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]: def build_site(root: Path, out: Path) -> list[dict]:
"""Write the Starlight content tree. Returns the sidebar structure.""" """Write the Starlight content tree. Returns the sidebar structure."""
if out.exists(): if out.exists():
@@ -554,7 +634,7 @@ def build_site(root: Path, out: Path) -> list[dict]:
if not is_function_dir: if not is_function_dir:
target = out / rel target = out / rel
target.parent.mkdir(parents=True, exist_ok=True) 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": if rel.name != "index.md":
sidebar.append({"label": fm["title"], "link": "/" + rel.stem + "/"}) sidebar.append({"label": fm["title"], "link": "/" + rel.stem + "/"})
continue continue
@@ -571,7 +651,7 @@ def build_site(root: Path, out: Path) -> list[dict]:
if rel.name == "index.md": if rel.name == "index.md":
target = out / slug_dir / "index.md" target = out / slug_dir / "index.md"
target.parent.mkdir(parents=True, exist_ok=True) 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 # Built explicitly rather than by `autogenerate`, which labels
# each group with its raw directory slug and republishes this # each group with its raw directory slug and republishes this
# index as a child of the group it already titles. # 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} entry_fm = {"title": title}
if desc: if desc:
entry_fm["description"] = desc entry_fm["description"] = desc
(cat_dir / f"{entry_slug}.md").write_text( _write_prettified(
mt.serialize(entry_fm, prettify(entry_body, title.split()[0])) cat_dir / f"{entry_slug}.md",
entry_fm,
prettify(entry_body, title.split()[0]),
) )
href = f"/{slug_dir}/{category}/{entry_slug}/" href = f"/{slug_dir}/{category}/{entry_slug}/"
links.append({"label": title, "link": href}) 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 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. logging) on or off, per session or universally. See its entry in Section 5.
Notes: NOTE:
- Command shadows (rm, cat, ls, ...) react immediately; conf.d-level components (bindings, prompt, abbreviations, hooks) take effect in new shells.
- Command shadows (rm, cat, ls, ...) react immediately; conf.d-level - With aliases disabled, rm falls back to bare `command rm` — files are deleted permanently, not trashed.
components (bindings, prompt, abbreviations, hooks) take effect in - Disabled integration commands (spwin, tab, split, hist, logs, upgrade) print an error naming the variable that disabled them.
new shells. - On CachyOS, the distro fish config's own aliases, history override, and bang-bang bindings are stripped per category as well.
- 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 ### 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 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. logging) on or off, per session or universally. See its entry in Section 5.
Notes: NOTE:
- Command shadows (rm, cat, ls, ...) react immediately; conf.d-level components (bindings, prompt, abbreviations, hooks) take effect in new shells.
- Command shadows (rm, cat, ls, ...) react immediately; conf.d-level - With aliases disabled, rm falls back to bare `command rm` — files are deleted permanently, not trashed.
components (bindings, prompt, abbreviations, hooks) take effect in - Disabled integration commands (spwin, tab, split, hist, logs, upgrade) print an error naming the variable that disabled them.
new shells. - On CachyOS, the distro fish config's own aliases, history override, and bang-bang bindings are stripped per category as well.
- 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 ### 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(): def test_prettify_is_site_only():
"""The SSOT keeps the indented form the man-page pipeline depends on.""" """The SSOT keeps the indented form the man-page pipeline depends on."""
import build_manual import build_manual
manual = Path(__file__).parent / "manual" manual = Path(__file__).parent / "manual"
forbidden = ("```", "<Aside", "<FileTree", ":::")
for path in manual.rglob("*.md"): for path in manual.rglob("*.md"):
assert "```" not in path.read_text(), ( text = path.read_text()
f"{path.name} contains a fence: prettify must run at site-build " for marker in forbidden:
"time, never be written back to the SSOT" 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(): def test_sidebar_has_no_duplicate_functions_entry():