Adds a GitHub social icon to the docs site header alongside the existing Gitea one, and documents in the README's Contributing section that git.rootiest.dev is the base repo while the GitHub copy is a one-way mirror, so forks/issues/PRs should go through Gitea. Adds Testing, Contributing, Attribution, and License sections to the manual/man page/site, sourced directly from README.md via a new `<!-- README: Heading -->` placeholder mechanism in build-manual.py, so the README stays the single source of truth for those sections instead of a hand-maintained copy drifting out of sync. Also converts docs/manual/00-table-of-contents.md from a hand-typed list to a generated one (mt.walk()-driven), fixing a numbering drift where Components Reference was omitted and every section after it was off by one relative to its own manTitle.
1007 lines
38 KiB
Python
1007 lines
38 KiB
Python
#!/usr/bin/env python3
|
|
# Copyright (C) 2026 Rootiest
|
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
"""Generate publishable artifacts from the docs/manual SSOT.
|
|
|
|
--concat one ordered markdown document for pandoc / config-help
|
|
--site Starlight content tree + sidebar.json
|
|
"""
|
|
|
|
import argparse
|
|
import functools
|
|
import json
|
|
import re
|
|
import shutil
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import codespans
|
|
import manualtools as mt
|
|
import generate_component_registry
|
|
|
|
DOCS = Path(__file__).parent
|
|
MANUAL = DOCS / "manual"
|
|
FUNCTIONS = DOCS.parent / "functions"
|
|
COMPLETIONS = DOCS.parent / "completions"
|
|
README = DOCS.parent / "README.md"
|
|
REPO_BLOB_BASE = "https://git.rootiest.dev/rootiest/fish-config/src/branch/main/"
|
|
SLUG_DIR = "reference"
|
|
|
|
# File-tree branches whose real directory contents get listed inline on the
|
|
# Starlight site (never in the plain-text manual/man page, since only the
|
|
# --site path runs box-drawing trees through _as_file_tree).
|
|
EXPANDABLE_TREE_DIRS = {"functions/": FUNCTIONS, "completions/": COMPLETIONS}
|
|
|
|
|
|
def _is_function_page(path: Path, root: Path) -> bool:
|
|
"""True for a Section 5 category stub (not its index)."""
|
|
rel = path.relative_to(root)
|
|
return bool(rel.parts) and rel.parts[0].endswith("-functions") and rel.name != "index.md"
|
|
|
|
|
|
def _entry_slug(title: str) -> str:
|
|
"""The site's page slug for an entry heading."""
|
|
return re.sub(r"[^\w-]+", "-", title.strip().lower()).strip("-")
|
|
|
|
|
|
def _entry_link(name: str, functions: dict) -> str:
|
|
"""Link a dependency name to its entry page; plain code span if unknown."""
|
|
fn = functions.get(name)
|
|
if not fn:
|
|
return f"`{name}`"
|
|
category = re.sub(r"^\d+-", "", fn["CATEGORY"][0])
|
|
return f"[`{name}`](/{SLUG_DIR}/{category}/{_entry_slug(name)}/)"
|
|
|
|
|
|
def _with_entries(body: str, path: Path, entries: dict) -> str:
|
|
"""Append this category's generated `## name` entries to its stub body."""
|
|
generated = entries.get(path.stem, [])
|
|
if not generated:
|
|
return body
|
|
blocks = [f"## {name}\n\n{entry}" for name, entry in generated]
|
|
return "\n\n".join(([body] if body.strip() else []) + blocks)
|
|
|
|
|
|
def _with_abbreviations(body: str, abbrs: dict[str, list[dict]]) -> str:
|
|
"""Inject generated abbreviation tables into the document placeholders."""
|
|
rendered_abbrs = {}
|
|
for cat, items in abbrs.items():
|
|
lines_cat = [" Abbreviation Description", " ───────────────────────────────────────────────────────────────────"]
|
|
for abbr in items:
|
|
name = abbr["name"]
|
|
desc = abbr["desc"]
|
|
|
|
# Left-pad description to ensure at least 2 spaces for cell split
|
|
name_part = name.ljust(16)
|
|
if len(name_part) < len(name) + 2:
|
|
name_part = name + " "
|
|
|
|
lines_cat.append(f" {name_part}{desc}")
|
|
rendered_abbrs[cat] = "\n".join(lines_cat)
|
|
|
|
for cat, table in rendered_abbrs.items():
|
|
placeholder = f"<!-- GENERATED: {cat} -->"
|
|
body = body.replace(placeholder, table)
|
|
|
|
return body
|
|
|
|
|
|
TOC_PLACEHOLDER = "<!-- GENERATED: toc -->"
|
|
TOC_SKIP_STEMS = {"index"}
|
|
|
|
|
|
def _build_toc(root: Path) -> str:
|
|
"""Render the section list for docs/manual/00-table-of-contents.md.
|
|
|
|
Walks the same tree `build_concat` does, so it can never drift from the
|
|
man page's actual section order. `index.md` and the `00-*` front-matter
|
|
pages (Name, Synopsis, this page) sit before section 1 and are excluded,
|
|
same as any `man: false` page (currently only 404).
|
|
"""
|
|
lines: list[str] = []
|
|
n = 0
|
|
for path, depth in mt.walk(root):
|
|
rel = path.relative_to(root)
|
|
# len(rel.parts) == 1 means a root-level file, not a directory's own
|
|
# index page (e.g. 04-abbreviations/index.md), which must keep its
|
|
# own numbered line even though its stem is also "index".
|
|
if depth == 0 and len(rel.parts) == 1 and (rel.stem in TOC_SKIP_STEMS or rel.stem.startswith("00-")):
|
|
continue
|
|
fm, _ = mt.parse(path)
|
|
if not fm.get("man", True):
|
|
continue
|
|
title = fm.get("title", path.stem)
|
|
if depth == 0:
|
|
n += 1
|
|
lines.append(f" {n}. {title}")
|
|
else:
|
|
lines.append(f" - {title}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _with_toc(body: str, root: Path) -> str:
|
|
"""Inject the `<!-- GENERATED: toc -->` placeholder with the built section list."""
|
|
return body.replace(TOC_PLACEHOLDER, _build_toc(root)) if TOC_PLACEHOLDER in body else body
|
|
|
|
|
|
README_LINK_RE = re.compile(r"\]\((?!https?://|#|mailto:)([^)]+)\)")
|
|
README_FENCE_RE = re.compile(r"```[^\n]*\n(.*?)```\n?", re.DOTALL)
|
|
README_PLACEHOLDER_RE = re.compile(r"<!-- README: (.+?) -->")
|
|
|
|
|
|
def _rewrite_repo_links(text: str) -> str:
|
|
"""Point a README-relative link (`CONTRIBUTING.md`, `LICENSE`) at its file on Gitea."""
|
|
return README_LINK_RE.sub(lambda m: f"]({REPO_BLOB_BASE}{m.group(1)})", text)
|
|
|
|
|
|
def _defence(text: str) -> str:
|
|
"""Rewind a README fenced code block into the manual's indented-block form.
|
|
|
|
`docs/manual` bodies are authored man-page style (4-space indent), never
|
|
fenced: `codespans`/pandoc pair backticks per line, and a fence line's
|
|
triple backtick throws that count off. README.md is ordinary markdown
|
|
and fences its examples, so an injected section is converted back.
|
|
"""
|
|
def repl(m: re.Match) -> str:
|
|
block = m.group(1).rstrip("\n")
|
|
return "\n".join(" " + line for line in block.split("\n")) + "\n"
|
|
|
|
return README_FENCE_RE.sub(repl, text)
|
|
|
|
|
|
@functools.lru_cache(maxsize=1)
|
|
def _readme_sections() -> dict[str, str]:
|
|
"""Split README.md into {H2 heading: body}, links rewritten to point at the repo.
|
|
|
|
Lets a manual stub pull one README section in verbatim via a
|
|
`<!-- README: <Heading> -->` placeholder, so the README stays the single
|
|
source of truth for sections that describe the repo itself rather than
|
|
the shell config (Testing, Contributing, Attribution, License).
|
|
"""
|
|
sections: dict[str, str] = {}
|
|
heading: str | None = None
|
|
lines: list[str] = []
|
|
for line in README.read_text().split("\n") + ["## "]:
|
|
if line.startswith("## "):
|
|
if heading is not None:
|
|
body = "\n".join(lines).strip()
|
|
if body.endswith("---"):
|
|
body = body[:-3].rstrip()
|
|
sections[heading] = _defence(_rewrite_repo_links(body))
|
|
heading = line[3:].strip()
|
|
lines = []
|
|
else:
|
|
lines.append(line)
|
|
return sections
|
|
|
|
|
|
def _with_readme(body: str) -> str:
|
|
"""Inject `<!-- README: Heading -->` placeholders with that README section's body."""
|
|
return README_PLACEHOLDER_RE.sub(lambda m: _readme_sections().get(m.group(1), ""), body)
|
|
|
|
|
|
def build_concat(root: Path) -> str:
|
|
"""Concatenate the manual into one ordered markdown document.
|
|
|
|
Each file contributes `# {manTitle or title}` at a level matching its
|
|
depth, and its body headings are demoted by the same amount.
|
|
|
|
`root / "_pandoc.yml"` (if present) holds the original document's
|
|
pandoc metadata block (title/section/header/date/author) as raw text,
|
|
with no frontmatter fences and no Astro-visible frontmatter key. When
|
|
present, its contents are re-emitted byte-for-byte as the leading
|
|
`---`-fenced block, ahead of every heading.
|
|
|
|
Bodies go through `codespans` here so a token is typeset the same way
|
|
in every output: `tmux` and `local.fish` are wrapped on the site by
|
|
that pass, and without it the man page marked only what the SSOT
|
|
happened to backtick by hand. Section 5 is unaffected -- its entries
|
|
arrive as indented verbatim blocks, which `codespans` leaves alone and
|
|
pandoc already sets in a monospace font.
|
|
|
|
Only bodies are passed: the pandoc metadata block above is not prose
|
|
and must survive byte-for-byte.
|
|
"""
|
|
entries = build_entries(mt.parse_functions(FUNCTIONS))
|
|
chunks: list[str] = []
|
|
pandoc_path = root / "_pandoc.yml"
|
|
if pandoc_path.exists():
|
|
raw = pandoc_path.read_text().rstrip("\n")
|
|
chunks.append(f"---\n{raw}\n---")
|
|
for path, depth in mt.walk(root):
|
|
fm, body = mt.parse(path)
|
|
if not fm.get("man", True):
|
|
continue
|
|
heading = fm.get("manTitle") or fm.get("title", path.stem)
|
|
chunks.append("#" * (depth + 1) + " " + heading)
|
|
if _is_function_page(path, root):
|
|
body = _with_entries(body, path, entries)
|
|
elif "04-abbreviations" in path.parts:
|
|
abbrs = mt.parse_abbreviations(DOCS.parent / "conf.d")
|
|
body = _with_abbreviations(body, abbrs)
|
|
body = _with_readme(body)
|
|
body = _with_toc(body, root)
|
|
if body:
|
|
body = re.sub(r"<LinkButton.*?</LinkButton>\n*", "", body, flags=re.DOTALL)
|
|
body = re.sub(r"<CardGrid.*?</CardGrid>\n*", "", body, flags=re.DOTALL)
|
|
body = re.sub(r"\[([^\]]+)\]\(/[^)]+\)", r"\1", body)
|
|
body = codespans.add_code_spans(body, _code_vocabulary())
|
|
chunks.append(mt.shift_headings(body, depth))
|
|
return "\n\n".join(chunks) + "\n"
|
|
|
|
|
|
SENTENCE_RE = re.compile(r"^(.+?[.!?])(\s|$)", re.S)
|
|
PIPELINE_KEYS = ("man", "site", "manTitle", "helpKeywords")
|
|
JSX_ATTR_ESCAPES = (
|
|
("&", "&"),
|
|
('"', """),
|
|
("<", "<"),
|
|
("{", "{"),
|
|
)
|
|
|
|
|
|
def _jsx_attr_escape(value: str) -> str:
|
|
"""Escape a string for safe use inside a quoted JSX attribute value.
|
|
|
|
`&` must go first so escaping later characters doesn't double-escape
|
|
the ampersands it introduces. `"` closes the attribute early; `<` and
|
|
`{` are otherwise-live MDX/JSX syntax that must not be interpreted.
|
|
"""
|
|
for char, escape in JSX_ATTR_ESCAPES:
|
|
value = value.replace(char, escape)
|
|
return value
|
|
|
|
|
|
def _first_sentence(body: str) -> str:
|
|
"""Extract a one-line description from the start of an entry body.
|
|
|
|
The `Synopsis:` block is skipped whole — label line plus its
|
|
deeper-indented continuation lines. It restates the calling convention,
|
|
which the card already shows as its title, so using one as the card
|
|
description wastes the line.
|
|
|
|
Source prose is hard-wrapped, so the leading paragraph is unwrapped
|
|
before the sentence match — otherwise a card truncates at the first
|
|
line break, mid-clause.
|
|
"""
|
|
para: list[str] = []
|
|
in_fence = False
|
|
syn_indent: int | None = None
|
|
for raw in body.split("\n"):
|
|
line = raw.strip()
|
|
indent = len(raw) - len(raw.lstrip())
|
|
if syn_indent is not None:
|
|
if line and indent <= syn_indent:
|
|
syn_indent = None
|
|
else:
|
|
continue
|
|
if line.startswith("```"):
|
|
in_fence = not in_fence
|
|
if para:
|
|
break
|
|
continue
|
|
if in_fence:
|
|
continue
|
|
if not line or line.startswith(("#", "|", "-", "*", ">")):
|
|
if para:
|
|
break
|
|
continue
|
|
if line.startswith("Synopsis:"):
|
|
syn_indent = indent
|
|
continue
|
|
para.append(line)
|
|
if not para:
|
|
return ""
|
|
text = " ".join(para)
|
|
m = SENTENCE_RE.match(text)
|
|
return (m.group(1) if m else text)[:160]
|
|
|
|
|
|
# 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 docker 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
|
|
fish_default_key_bindings fish_vi_key_bindings fish_config
|
|
""".split()
|
|
)
|
|
|
|
SYNOPSIS_PREFIX = "Synopsis:"
|
|
EXAMPLE_PREFIX = "Example:"
|
|
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
|
|
|
|
|
|
# A lone indented line that's just a path ending in a known extension —
|
|
# e.g. pointing at where a file lives — reads better as a titled snippet
|
|
# than an unhighlighted grey slab.
|
|
PATH_LINE_RE = re.compile(r"^[~$][\w./{}-]*\.\w+$")
|
|
|
|
# A leading "# in local.fish" / "# local.fish" comment names the file an
|
|
# example belongs to; promote it to the fence title instead of leaving it
|
|
# as a literal comment inside the code.
|
|
FILENAME_COMMENT_RE = re.compile(r"^#\s*(?:in\s+)?([$~\w./-]+\.\w+)\s*$")
|
|
|
|
CELL_SPLIT = re.compile(r"\s{2,}")
|
|
|
|
# A rule line under a header row — the "Component Reference" tables'
|
|
# authoring convention (header, dashes, data rows all at the same indent,
|
|
# no ":"-terminated label). Either one solid run of dashes, or (RST-style)
|
|
# one dash run per column, gapped the same way CELL_SPLIT splits cells.
|
|
RULE_CELL_RE = re.compile(r"^[─\-]{3,}$")
|
|
|
|
|
|
def _cell(text: str, code: bool) -> str:
|
|
"""Render one table cell. `|` must be escaped even inside a code span."""
|
|
text = text.strip().replace("|", r"\|")
|
|
return f"`{text}`" if code and text else text
|
|
|
|
|
|
def _as_table(para: list[str]) -> str | None:
|
|
"""Render an aligned two-column block as a markdown table, else None.
|
|
|
|
Option and subcommand tables are the one thing in this manual that is
|
|
genuinely tabular, and the indented-code fallback renders them as a grey
|
|
slab. Everything else stays in that fallback: returning None is always
|
|
safe, so every check here is free to be conservative.
|
|
|
|
The rows must form one contiguous indented run, optionally introduced by
|
|
a label line (`Options:`) and closed by a sentence. Lines indented deeper
|
|
than the run are wrapped descriptions and fold into the row above.
|
|
"""
|
|
starts = [i for i, ln in enumerate(para) if ln.startswith(" ")]
|
|
if len(starts) < 2 or starts != list(range(starts[0], starts[-1] + 1)):
|
|
return None
|
|
head = para[: starts[0]]
|
|
body = para[starts[0] : starts[-1] + 1]
|
|
tail = para[starts[-1] + 1 :]
|
|
if head and not head[-1].rstrip().endswith(":"):
|
|
return None # a head that isn't a label means mixed content
|
|
|
|
indent = min(len(ln) - len(ln.lstrip()) for ln in body)
|
|
rows: list[list[str]] = []
|
|
for line in body:
|
|
if len(line) - len(line.lstrip()) > indent and rows:
|
|
rows[-1][1] += " " + line.strip()
|
|
continue
|
|
parts = CELL_SPLIT.split(line.strip(), 1)
|
|
if len(parts) != 2 or not parts[1].strip():
|
|
return None # not column-aligned; a numbered list, or prose
|
|
rows.append([parts[0], parts[1].strip()])
|
|
if len(rows) < 2:
|
|
return None
|
|
if any("<" in value or "{" in value for _, value in rows):
|
|
return None # live markdown in the prose column
|
|
|
|
out = [line.strip() for line in head]
|
|
out += ["| | |", "|---|---|"]
|
|
out += [f"| {_cell(k, True)} | {_cell(v, False)} |" for k, v in rows]
|
|
out += [line.strip() for line in tail]
|
|
return "\n".join(out)
|
|
|
|
|
|
def _as_ruled_table(para: list[str]) -> str | None:
|
|
"""Render a header + solid-rule + rows block as an N-column table, else None.
|
|
|
|
This is the "Component Reference" tables' convention: header row, a
|
|
dashed rule, then data rows at the same indent (no ":"-label, no extra
|
|
nesting — the two things _as_table looks for). A row that splits into
|
|
just one cell is a word-wrapped continuation of the row above; anything
|
|
else that doesn't match the header's column count is a source alignment
|
|
bug, so bail out to the code-block fallback rather than guess.
|
|
"""
|
|
if len(para) < 4:
|
|
return None
|
|
rule_cells = CELL_SPLIT.split(para[1].strip())
|
|
if not all(RULE_CELL_RE.match(cell) for cell in rule_cells):
|
|
return None
|
|
header = CELL_SPLIT.split(para[0].strip())
|
|
n = len(header)
|
|
if n < 2:
|
|
return None
|
|
rows: list[list[str]] = []
|
|
for line in para[2:]:
|
|
parts = CELL_SPLIT.split(line.strip(), n - 1)
|
|
if len(parts) == n:
|
|
rows.append(parts)
|
|
elif len(parts) == 1 and rows:
|
|
rows[-1][-1] += " " + parts[0].strip()
|
|
else:
|
|
return None
|
|
if len(rows) < 2:
|
|
return None
|
|
|
|
# Unlike _as_table's prose column, these tables legitimately contain
|
|
# placeholders like <session> or brace globs — code-span protects them
|
|
# instead of rejecting the whole table.
|
|
def cell(text: str, code: bool) -> str:
|
|
needs_protection = ("<" in text or "{" in text) and "`" not in text
|
|
return _cell(text, code or needs_protection)
|
|
|
|
out = [f"| {' | '.join(header)} |", "|" + "|".join(["---"] * n) + "|"]
|
|
for row in rows:
|
|
cells = [cell(row[0], True)] + [cell(c, False) for c in row[1:]]
|
|
out.append(f"| {' | '.join(cells)} |")
|
|
return "\n".join(out)
|
|
|
|
|
|
TREE_ROOT_RE = re.compile(r"^[~$][\w./{}-]*/$")
|
|
TREE_BRANCH_RE = re.compile(r"^([│ \t]*)[├└]──\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()}"]
|
|
for prefix, name, desc in branches:
|
|
depth = len(prefix.replace('\t', ' ')) // 4
|
|
indent = " " * (depth + 1)
|
|
out.append(f"{indent}- {name} {desc}".rstrip())
|
|
expand_dir = EXPANDABLE_TREE_DIRS.get(name)
|
|
if expand_dir is not None and expand_dir.is_dir():
|
|
child_indent = " " * (depth + 2)
|
|
for entry in sorted((p.name for p in expand_dir.iterdir() if p.is_file()), key=str.lower):
|
|
out.append(f"{child_indent}- {entry}")
|
|
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.
|
|
|
|
`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 len(para) == 1 and PATH_LINE_RE.match(para[0].strip()):
|
|
path = para[0].strip()
|
|
name = path.rsplit("/", 1)[-1]
|
|
return f'```fish title="{name}"\n{path}\n```'
|
|
if _is_shell(para, entry_name):
|
|
body = para
|
|
title = None
|
|
m = FILENAME_COMMENT_RE.match(para[0].strip())
|
|
if m:
|
|
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) or _as_file_tree(para)
|
|
if table is not None:
|
|
return table
|
|
# MDX (used for any page that also carries an <Aside> or <FileTree>)
|
|
# has no indented-code-block syntax — a plain 4-space-indented block
|
|
# silently renders as flowed paragraph text there, collapsing every
|
|
# line break. A fenced block works in both MDX and plain Markdown, so
|
|
# it's the only fallback that's safe regardless of which one a given
|
|
# page ends up promoted to.
|
|
return "```text\n" + "\n".join(para) + "\n```"
|
|
|
|
|
|
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()]
|
|
# A multi-line synopsis is authored aligned under the first line;
|
|
# keep the whole thing in one fence rather than orphaning the rest.
|
|
while lines and lines[0].startswith(" "):
|
|
synopsis.append(lines.pop(0).strip())
|
|
# A "Usage" title (Starlight's filename-title convention, repurposed
|
|
# as a label) makes the synopsis read as a snippet of the function
|
|
# it documents rather than a bare command example.
|
|
info = 'fish title="Usage"' if entry_name else "fish"
|
|
out.append(f"```{info}\n" + "\n".join(synopsis) + "\n```")
|
|
|
|
para: list[str] = []
|
|
for line in lines + [""]:
|
|
if line.strip():
|
|
para.append(line)
|
|
continue
|
|
if para:
|
|
if para[0].strip() == EXAMPLE_PREFIX:
|
|
example = para[1:]
|
|
if example and _is_shell(example, entry_name):
|
|
body = "\n".join(example)
|
|
out.append(f'```fish title="Examples"\n{body}\n```')
|
|
else:
|
|
deeper = any(line.startswith(" ") for line in example)
|
|
out.append(_render_para(example, entry_name, deeper))
|
|
else:
|
|
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())
|
|
|
|
|
|
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>"
|
|
|
|
|
|
@functools.lru_cache(maxsize=1)
|
|
def _code_vocabulary() -> codespans.Vocabulary:
|
|
"""The command names codespans may wrap, read from the repo once."""
|
|
return codespans.vocabulary(DOCS.parent)
|
|
|
|
|
|
def prettify(body: str, entry_name: str | None = None) -> str:
|
|
"""Rewrite a body's indented code blocks and labeled asides for the website.
|
|
|
|
The block and aside rewrites are site-only: the man page and
|
|
`config-help` keep reading the untouched SSOT, where the indented form
|
|
and the `LABEL:` text are exactly what pandoc/`config-help` want.
|
|
|
|
The inline code spans added last are not site-only. `-a/--all` and
|
|
`__fish_config_op_aliases` are authored bare so the `functions/*.fish`
|
|
headers stay readable as plain text, and the backticks every output
|
|
wants are put on here rather than in the SSOT -- `build_concat()` runs
|
|
the same pass for the man page and `config-help`.
|
|
"""
|
|
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:
|
|
while block and not block[-1].strip():
|
|
block.pop()
|
|
out.append(_prettify_block(block, entry_name))
|
|
out.append("")
|
|
block = []
|
|
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()
|
|
out.append(_prettify_block(block, entry_name))
|
|
return codespans.add_code_spans("\n".join(out), _code_vocabulary())
|
|
|
|
|
|
ENTRY_HEADS = {
|
|
"ARGUMENTS": "Arguments:",
|
|
"EXIT STATUS": "Exit Status:",
|
|
"RETURNS": "Returns:",
|
|
"NOTES": "Notes:",
|
|
}
|
|
|
|
|
|
def render_entry(fn: dict[str, list[str]], used_by: list[str], link=None) -> str:
|
|
"""Render one parsed function header as a manual entry body.
|
|
|
|
Emits the same man-page shape Section 5 was authored in — one 4-space
|
|
indented block opening with `Synopsis:` — so `prettify` keeps handling it
|
|
for the site and pandoc keeps handling it for the man page, with no
|
|
special case on either side.
|
|
|
|
`link` maps a function name to its markdown link, or is None for the man
|
|
page, where a URL in the middle of a sentence is noise.
|
|
"""
|
|
out: list[str] = []
|
|
syn = fn.get("SYNOPSIS", [])
|
|
if syn:
|
|
pad = " " * len(SYNOPSIS_PREFIX + " ")
|
|
out.append(f"{SYNOPSIS_PREFIX} {syn[0]}")
|
|
out += [pad + line for line in syn[1:]]
|
|
out.append("")
|
|
for line in fn.get("DESCRIPTION", []):
|
|
out.append(line)
|
|
for label, head in ENTRY_HEADS.items():
|
|
body = fn.get(label)
|
|
if not body:
|
|
continue
|
|
out += ["", head] + [" " + line for line in body]
|
|
if fn.get("EXAMPLE"):
|
|
out += ["", EXAMPLE_PREFIX] + fn["EXAMPLE"]
|
|
|
|
block = "\n".join((INDENT + line).rstrip() for line in out)
|
|
|
|
def names(raw: list[str]) -> list[str]:
|
|
return [n for n in re.split(r"[,\s]+", " ".join(raw)) if n]
|
|
|
|
refs = []
|
|
for label, values in (
|
|
("Dependencies", names(fn.get("DEPENDENCIES", []))),
|
|
("Used by", sorted(used_by)),
|
|
):
|
|
if values:
|
|
rendered = ", ".join(link(v) if link else f"`{v}`" for v in values)
|
|
refs.append(f"**{label}:** {rendered}")
|
|
if refs:
|
|
block += "\n\n" + "\n\n".join(refs)
|
|
return block
|
|
|
|
|
|
def build_entries(functions: dict[str, dict], link=None) -> dict[str, list[tuple[str, str]]]:
|
|
"""Group rendered entries by category stem, ordered by function name.
|
|
|
|
The `Used by` reverse index is computed here in one pass rather than
|
|
authored: a bidirectional link maintained by hand drifts the moment one
|
|
side is edited.
|
|
"""
|
|
used_by: dict[str, list[str]] = {}
|
|
for name, fn in functions.items():
|
|
for dep in re.split(r"[,\s]+", " ".join(fn.get("DEPENDENCIES", []))):
|
|
if dep in functions:
|
|
used_by.setdefault(dep, []).append(name)
|
|
|
|
out: dict[str, list[tuple[str, str]]] = {}
|
|
for name in sorted(functions):
|
|
fn = functions[name]
|
|
body = render_entry(fn, used_by.get(name, []), link)
|
|
out.setdefault(fn["CATEGORY"][0], []).append((name, body))
|
|
return 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}
|
|
|
|
|
|
def _split_entries(body: str) -> tuple[str, list[tuple[str, str]]]:
|
|
"""Split a category body into (intro, [(entry title, entry body)]).
|
|
|
|
Fence-aware: an H2-looking line (`## ...`) inside a fenced code block
|
|
(tracked the same way as `manualtools.shift_headings`) is treated as
|
|
ordinary body text, not an entry boundary.
|
|
"""
|
|
lines = body.split("\n")
|
|
heading_re = re.compile(r"^## (.+)$")
|
|
boundaries: list[tuple[int, str]] = []
|
|
in_fence = False
|
|
for i, line in enumerate(lines):
|
|
if mt.FENCE_RE.match(line):
|
|
in_fence = not in_fence
|
|
continue
|
|
if not in_fence:
|
|
m = heading_re.match(line)
|
|
if m:
|
|
boundaries.append((i, m.group(1)))
|
|
|
|
if not boundaries:
|
|
return body.strip(), []
|
|
|
|
intro = "\n".join(lines[: boundaries[0][0]]).strip()
|
|
entries = []
|
|
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)
|
|
# 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
|
|
|
|
|
|
|
|
def _inject_subheading_cards(body: str) -> str:
|
|
"""Extract `## Heading`s and inject a CardGrid after the intro."""
|
|
headings = []
|
|
for line in body.splitlines():
|
|
m = re.match(r"^##\s+(.+)$", line)
|
|
if m:
|
|
headings.append(m.group(1).strip())
|
|
|
|
if len(headings) < 2:
|
|
return body
|
|
|
|
parts = body.split("\n## ", 1)
|
|
if len(parts) != 2:
|
|
return body
|
|
|
|
intro = parts[0].strip()
|
|
rest = "## " + parts[1]
|
|
|
|
cards = []
|
|
for title in headings:
|
|
safe_title = _jsx_attr_escape(title)
|
|
slug = re.sub(r"[^\w\s-]", "", title.lower())
|
|
slug = re.sub(r"[-\s]+", "-", slug).strip("-")
|
|
cards.append(f' <LinkCard title="{safe_title}" href="#{slug}" />')
|
|
|
|
cardgrid = "<CardGrid>\n" + "\n".join(cards) + "\n</CardGrid>\n\n"
|
|
|
|
return f"{intro}\n\n{cardgrid}{rest}"
|
|
|
|
ASTRO_ASIDE_COMPONENTS = {"<Aside": "Aside", "<FileTree": "FileTree", "<LinkButton": "LinkButton", "<CardGrid": "CardGrid", "<LinkCard": "LinkCard"}
|
|
|
|
|
|
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():
|
|
shutil.rmtree(out)
|
|
out.mkdir(parents=True)
|
|
|
|
functions = mt.parse_functions(FUNCTIONS)
|
|
entries = build_entries(functions, link=lambda n: _entry_link(n, functions))
|
|
|
|
sidebar: list[dict] = [{"label": "Home", "link": "/"}]
|
|
standard_groups: dict = {}
|
|
functions_group: dict = {}
|
|
functions_index_target = None
|
|
functions_index_fm = None
|
|
functions_index_body = None
|
|
functions_cards = []
|
|
for path, _depth in mt.walk(root):
|
|
fm, body = mt.parse(path)
|
|
if not fm.get("site", True):
|
|
continue
|
|
|
|
rel = path.relative_to(root)
|
|
is_function_dir = rel.parts and rel.parts[0].endswith("-functions")
|
|
|
|
if not is_function_dir:
|
|
target = out / rel
|
|
if "04-abbreviations" in path.parts:
|
|
abbrs = mt.parse_abbreviations(DOCS.parent / "conf.d")
|
|
body = _with_abbreviations(body, abbrs)
|
|
body = _with_readme(body)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
body = _inject_subheading_cards(body)
|
|
_write_prettified(target, _page_fm(fm), prettify(body))
|
|
|
|
if len(rel.parts) > 1:
|
|
group_dir = rel.parts[0]
|
|
if rel.name == "index.md":
|
|
group = {
|
|
"label": fm["title"],
|
|
"collapsed": True,
|
|
"items": [{"label": "Categories", "link": f"/{group_dir}/"}]
|
|
}
|
|
standard_groups[group_dir] = group
|
|
sidebar.append(group)
|
|
elif rel.name != "404.md":
|
|
if group_dir in standard_groups:
|
|
standard_groups[group_dir]["items"].append({
|
|
"label": fm["title"],
|
|
"link": f"/{group_dir}/{rel.stem}/"
|
|
})
|
|
else:
|
|
sidebar.append({"label": fm["title"], "link": f"/{group_dir}/{rel.stem}/"})
|
|
else:
|
|
if rel.name not in ("index.md", "404.md"):
|
|
sidebar.append({"label": fm["title"], "link": "/" + rel.stem + "/"})
|
|
continue
|
|
|
|
# Section 5: category index page keeps its slot; entries explode.
|
|
#
|
|
# Deliberately NOT "functions": Cloudflare Pages reserves a top-level
|
|
# `functions/` directory in the deploy output for Pages Functions
|
|
# (server-side handlers) and silently drops it from the static-asset
|
|
# upload. The pages build fine and never arrive — every entry 404s in
|
|
# production while working locally. test_site_avoids_reserved_dir
|
|
# guards this.
|
|
slug_dir = SLUG_DIR
|
|
if rel.name == "index.md":
|
|
functions_index_target = out / slug_dir / "index.md"
|
|
functions_index_fm = fm
|
|
functions_index_body = body
|
|
functions_cards = []
|
|
|
|
# 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": "Categories", "link": f"/{slug_dir}/"}],
|
|
}
|
|
sidebar.append(functions_group)
|
|
continue
|
|
|
|
category = re.sub(r"^\d+-", "", rel.stem)
|
|
cat_dir = out / slug_dir / category
|
|
cat_dir.mkdir(parents=True, exist_ok=True)
|
|
intro, page_entries = _split_entries(_with_entries(body, path, entries))
|
|
|
|
cat_title = fm["title"]
|
|
cat_desc = fm.get("description", "")
|
|
safe_title = _jsx_attr_escape(cat_title)
|
|
safe_desc = _jsx_attr_escape(cat_desc)
|
|
href = f"/{slug_dir}/{category}/"
|
|
functions_cards.append(
|
|
f' <LinkCard title="{safe_title}" href="{href}"'
|
|
+ (f' description="{safe_desc}"' if cat_desc else "")
|
|
+ " />"
|
|
)
|
|
|
|
cards = []
|
|
links = []
|
|
for title, entry_body in page_entries:
|
|
entry_slug = _entry_slug(title)
|
|
desc = _first_sentence(entry_body)
|
|
entry_fm = {"title": title}
|
|
if desc:
|
|
entry_fm["description"] = desc
|
|
_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})
|
|
safe_title = _jsx_attr_escape(title)
|
|
safe_desc = _jsx_attr_escape(desc)
|
|
cards.append(
|
|
f' <LinkCard title="{safe_title}" href="{href}"'
|
|
+ (f' description="{safe_desc}"' if desc else "")
|
|
+ " />"
|
|
)
|
|
|
|
overview = (
|
|
"import { CardGrid, LinkCard } from '@astrojs/starlight/components';\n\n"
|
|
+ (f"{intro}\n\n" if intro else "")
|
|
+ "<CardGrid>\n"
|
|
+ "\n".join(cards)
|
|
+ "\n</CardGrid>\n"
|
|
)
|
|
(cat_dir / "index.mdx").write_text(mt.serialize(_page_fm(fm), overview))
|
|
|
|
functions_group.setdefault("items", []).append(
|
|
{
|
|
"label": fm["title"],
|
|
"collapsed": True,
|
|
"items": [
|
|
{"label": f"{fm['title']} Overview", "link": f"/{slug_dir}/{category}/"},
|
|
*links,
|
|
],
|
|
}
|
|
)
|
|
|
|
if functions_index_target:
|
|
overview_content = (
|
|
(f"{functions_index_body}\n\n" if functions_index_body.strip() else "")
|
|
+ "<CardGrid>\n"
|
|
+ "\n".join(functions_cards)
|
|
+ "\n</CardGrid>\n"
|
|
)
|
|
_write_prettified(functions_index_target, _page_fm(functions_index_fm), prettify(overview_content))
|
|
|
|
return sidebar
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument("--concat", action="store_true", help="emit the pandoc document")
|
|
ap.add_argument("--site", action="store_true", help="emit the Starlight content tree")
|
|
ap.add_argument("-o", "--output", type=Path, help="write to PATH instead of stdout")
|
|
args = ap.parse_args()
|
|
|
|
if not (args.concat or args.site):
|
|
ap.error("nothing to do: pass --concat and/or --site")
|
|
|
|
generate_component_registry.main()
|
|
|
|
if args.site:
|
|
src = DOCS / "site" / "src"
|
|
out = src / "content" / "docs"
|
|
sidebar = build_site(MANUAL, out)
|
|
(src / "sidebar.json").write_text(json.dumps(sidebar, indent=2) + "\n")
|
|
print(f"wrote site content to {out} ({len(sidebar)} sidebar entries)")
|
|
|
|
if args.concat:
|
|
text = build_concat(MANUAL)
|
|
if args.output:
|
|
args.output.write_text(text)
|
|
print(f"wrote {args.output}")
|
|
else:
|
|
sys.stdout.write(text)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
raise SystemExit(main())
|