docs(site): add inline code spans to generated Starlight pages
CI / test (push) Successful in 1m4s
CI / build-docs (push) Successful in 3m57s

Function doc-headers are authored as plain text -- `config-help`,
`funcsave` and anyone opening the `.fish` file read them as-is -- so they
carry no backticks. The site inherited that and rendered `-a/--all` and
`__fish_config_op_aliases` as ordinary prose.

docs/codespans.py adds the spans at render time, as the last step of
prettify(), so only the site sees them; build_concat() (man page,
config-help) is byte-for-byte unchanged.

Recognised shapes: flags and flag pairs, `$vars`, SCREAMING_SNAKE env
vars, snake_case identifiers, paths and filenames, key chords, command
shadow chains (`ls->eza`), runs of tool names, whole command lines in a
table column of command lines, and known command names -- drawn from the
`_fdc_*` catalog in functions/_fish_deps_catalog.fish, the functions/
listing, and a standard-command list, minus the names that also read as
English.

Fenced blocks, existing code spans, headings, link targets, URLs,
component markup and <FileTree> bodies are passed through untouched, and
every rule bails out rather than guess.
This commit is contained in:
2026-08-31 20:04:19 -04:00
parent f7cfad559c
commit b754709f02
5 changed files with 758 additions and 5 deletions
+13 -2
View File
@@ -8,12 +8,14 @@
"""
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
@@ -484,12 +486,21 @@ def _as_aside(para: list[str]) -> str | None:
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.
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.
pandoc/`config-help` want. The same applies to the inline code spans
added last: `-a/--all` and `__fish_config_op_aliases` are authored bare
so the `functions/*.fish` headers stay readable as plain text, and the
backticks the site wants are put on here rather than in the SSOT.
"""
out: list[str] = []
block: list[str] = []
@@ -528,7 +539,7 @@ def prettify(body: str, entry_name: str | None = None) -> str:
while block and not block[-1].strip():
block.pop()
out.append(_prettify_block(block, entry_name))
return "\n".join(out)
return codespans.add_code_spans("\n".join(out), _code_vocabulary())
ENTRY_HEADS = {
+534
View File
@@ -0,0 +1,534 @@
#!/usr/bin/env python3
# Copyright (C) 2026 Rootiest
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Wrap code-shaped tokens in inline code spans for the Starlight site.
Section 5 is generated from the `functions/*.fish` comment headers, which
are read as plain text by `config-help`, by `funcsave`, and by anyone
opening the source file. Backticks there would be noise, so the headers
are authored without them -- and the site inherited that, rendering
`-a/--all` and `__fish_config_op_aliases` as ordinary prose.
This module closes that gap at render time: it walks the markdown a page
is about to be written as, finds the tokens whose shape only ever means
"code" (flags, `$vars`, snake_case identifiers, paths, key chords, known
command names) and wraps each one in a code span. The SSOT is never
touched, so the man page and `config-help` keep the plain-text form.
Everything here is conservative by construction: leaving a token alone is
always safe and wrapping the wrong one is not, so every rule bails out the
moment it is unsure. The regions that must never be rewritten -- fenced
blocks, existing code spans, link targets, URLs, JSX attributes,
`<FileTree>` bodies, headings -- are recognised first and passed through.
"""
import functools
import re
from pathlib import Path
FENCE_RE = re.compile(r"^\s*(```|~~~)")
# ---------------------------------------------------------------------------
# Vocabulary
# ---------------------------------------------------------------------------
# Commands a reader expects to see typeset as code. This is the *wide* list:
# it decides that a table column holds command lines (see _command_columns),
# where position already proves the name is a command. Wrapping a bare
# mention in running prose is gated on the strict tier below.
STANDARD_COMMANDS = frozenset(
"""
apk apt awk basename bash bat bg bind brew builtin cargo cat cd chmod
chown chsh cmp column cp curl cut date dd delta df diff dig dirname dnf
docker dpkg du echo emacs emerge env eza exec exit export fastfetch fd
fdisk fg fgrep file find fisher flatpak fzf gh git grep gzip head help
hexdump host hostname id ifconfig install ip jq jobs journalctl kill
killall kitten kitty last less ln locale ls lsblk lsd lsof make man
micro mkdir more mount mpv mv nano nc neofetch neovim netstat nix nl
nohup npm nproc nvim od open openssl pacman paru paste pgrep ping pip
pip3 pkill pr printf ps pwd python python3 readlink realpath rg rm rmdir
rpm rsync scp sed seq sh shutdown sleep snap sort source ssh stat
strings su sudo sync systemctl tac tail tar tee test time tldr tmux
touch tr trash tree type udisksctl umount uname uniq unzip uv vdir vi
vim vlc wait wc wezterm wget which who whoami wl-copy wl-paste xargs
xbps-install xclip xdg-open xsel yay yum yt-dlp zellij zip zoxide zsh
zypper
abbr alias and argparse begin block break case command complete contains
continue count else emit end eval false for function funcsave functions
history if math not or random read return set set_color status string
switch true while
""".split()
)
# Names that also read as ordinary English (or as this manual's own prose)
# often enough that a bare mention is not evidence of a command. They still
# take part in command-line and list detection, where position disambiguates
# -- they just never get wrapped on their own.
AMBIGUOUS_COMMANDS = frozenset(
"""
abbr alias all and at basename bat begin bg bind block branch break case
cat cd cheat cleanup clone column command complete contains continue copy
count cut date dd df dir dirname do docker du duf dust echo edit else emit
end env eval exec exit export false fc fg file find fish for free function
functions git go head help hist history host hostname id if in install ip
jobs join key kill last less link list ln lock locale log logs look ls make
man math micro more mount mv next no not note od open or ov p page paste
pkg poke ports pr ps pwd random read real replay return rm run screen sed
search seq set sh show sleep sort source spark split stat status string
strings su switch sync tab tac tail tar tee test time top touch tr trash
tree true type uniq upgrade view vi wait watch wc which while who write
yes zip
builtin fast function vdir
""".split()
)
# Extensions that make a bare `name.ext` token unambiguously a filename.
PATH_EXTENSIONS = (
"fish md mdx json jsonc toml yml yaml py sh bash zsh lua conf cfg ini "
"txt log list service socket desktop css scss ts js astro nix rasi 1"
).split()
# English function words. A candidate command line containing one is prose.
STOPWORDS = frozenset(
"""
a an the this that these those it its is are was were be been being am
to of in into on at by for from with without within about across after
before during over under again then than so such as and or but nor if
when while where which who whom whose why how all any both each few more
most other some only own same too very can will just should now via per
also either neither every no not
""".split()
)
_CATALOG_ARRAY_RE = re.compile(
r"set\s+-g\s+_fdc_(?:bins|cargo|pm)\s+((?:[^\n]*\\\n)*[^\n]*)"
)
def dependency_names(repo: Path) -> set[str]:
"""Every tool name in the `fish-deps` catalog (`_fdc_*` arrays).
`functions/_fish_deps_catalog.fish` is this repo's dependency database;
reading it here means a tool added there starts rendering as code with
no second list to keep in sync.
"""
path = repo / "functions" / "_fish_deps_catalog.fish"
if not path.exists():
return set()
names: set[str] = set()
for m in _CATALOG_ARRAY_RE.finditer(path.read_text(encoding="utf-8")):
for token in m.group(1).replace("\\\n", " ").split():
token = token.strip("\"'")
if token and re.fullmatch(r"[\w.@+-]+", token):
names.add(token)
return names
def function_names(repo: Path) -> set[str]:
"""Public function names, from the `functions/` directory listing.
Underscore-prefixed internals are skipped only because the snake_case
rule already covers them, and covers them everywhere -- including the
ones that have no file of their own.
"""
directory = repo / "functions"
if not directory.is_dir():
return set()
return {p.stem for p in directory.glob("*.fish") if not p.stem.startswith("_")}
class Vocabulary:
"""The command names the rules recognise, in two tiers.
`full` is every name we know of, used where position already proves a
token is a command (a command-line table cell, an arrow chain, a
comma-separated run). `strict` is the subset safe to wrap on sight in
running prose: `zoxide` yes, `find` no.
"""
__slots__ = ("full", "strict")
def __init__(self, names: set[str]):
# `and`, `or`, `not`, `if` … are fish builtins, but as vocabulary
# entries they turn every conjunction into a command name and break
# list and command-line detection. They are never worth wrapping.
self.full = frozenset(names) - STOPWORDS
self.strict = frozenset(
n
for n in names
if n not in AMBIGUOUS_COMMANDS
and (len(n) >= 3 or any(c.isdigit() for c in n))
)
def __eq__(self, other):
return (
isinstance(other, Vocabulary)
and self.full == other.full
and self.strict == other.strict
)
def __hash__(self):
return hash((self.full, self.strict))
def vocabulary(repo: Path) -> Vocabulary:
"""Build the command vocabulary from the repo plus the standard list."""
return Vocabulary(
set(STANDARD_COMMANDS) | dependency_names(repo) | function_names(repo)
)
EMPTY_VOCABULARY = Vocabulary(set())
# ---------------------------------------------------------------------------
# Token grammar
# ---------------------------------------------------------------------------
# A token may not start inside a word, a path, a code span, a history
# expansion, or a hyphenated compound: `` `zoxide` ``-backed must not see
# `-backed` as a flag, `and/or` must not see `/or` as a path, and `!-N` must
# not see `-N` as one either.
BEFORE = r"(?<![\w`$/\\~.=+!-])"
# Ruling out a trailing `/` keeps a partially-recognised slash run
# (`grep/cp/mv/wget`, where only `grep` is in the vocabulary) from being
# wrapped one limb at a time.
AFTER = r"(?![\w`/])"
_SEG = r"[\w.@+-]+"
# The last segment of a path may not end in `.`, so a sentence-final full
# stop stays outside the span. A segment that is nothing but dots (`..`,
# `...`) is the exception: there the dots are the segment.
_LAST = r"(?:[\w.@+-]*[\w@+-]|\.+)"
_EXT = "|".join(PATH_EXTENSIONS)
# Key chords: `Ctrl-R`, `Ctrl+Alt+F`. Both separators appear in the manual.
_MODIFIER = r"(?:Ctrl|Alt|Shift|Super|Meta|Cmd|Opt)"
_KEY = (
r"(?:F\d{1,2}|Tab|Enter|Return|Space|Esc|Escape|Backspace|Delete|Insert"
r"|Home|End|Up|Down|Left|Right|PgUp|PgDn|[A-Za-z0-9])"
)
KEYBIND = rf"{_MODIFIER}(?:[+-]{_MODIFIER})*[+-]{_KEY}"
# `$EDITOR`, `${var}`, `$XDG_CONFIG_HOME/aichat/roles/cli.md`.
VAR = rf"\$\{{?[A-Za-z_]\w*\}}?(?:(?:/{_SEG})*/{_LAST})?"
PATH = (
rf"(?:~|\.{{1,2}})/(?:{_SEG}/)*(?:{_LAST})?" # ~/… ./… ../…
rf"|/(?:{_SEG}/)+(?:{_LAST})?" # /etc/sudoers.d/nofail-toggle
rf"|(?:{_SEG}/)+[\w@+-][\w.@+-]*\.(?:{_EXT})" # conf.d/abbr.fish
rf"|[\w@+-][\w.@+-]*\.(?:{_EXT})" # config.fish
rf"|(?:{_SEG}\.)+{_SEG}/" # conf.d/
)
# `-a`, `--dry-run`, `--color=auto`. A bare `--` (this manual's ASCII em
# dash) never matches: a letter has to follow. A single-hyphen flag is
# capped at five characters and may not contain a hyphen, so a hyphenated
# compound continued across a conjunction ("filesystem-inspection and
# -modification") is not mistaken for one.
FLAG = r"--[A-Za-z][\w-]*(?:=[\w.,:/@+-]+)?|-[A-Za-z][A-Za-z0-9]{0,4}(?:=[\w.,:/@+-]+)?"
# `XDG_CONFIG_HOME`, `NO_TMUX=1`. An underscore is required, so ordinary
# acronyms (`URL`, `AGPL`, `TCP`) are never touched.
ENVVAR = r"[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+(?:=[\w.,:/@+-]+)?"
# snake_case: `__fish_config_op_aliases`, `_fdc_bins`, `fish_greeting`,
# `prompt_pwd`, `expand_bang_*`. An internal underscore is required, which
# is also what keeps `_emphasised_` markdown out of the match.
IDENT = r"_{0,2}[a-z][a-z0-9]*(?:_(?:[a-z0-9]+|\*))+"
# Regions that are already code, or are markup rather than prose. `url`
# also covers `git@host:owner/repo.git` and `ssh://…`, whose scheme would
# otherwise be read as a bare command name.
PROTECTED = (
r"(?P<code>``+.+?``+|`[^`\n]*`)"
r"|(?P<link>\[[^\]\n]*\]\([^)\n]*\))"
r"|(?P<url>[A-Za-z][\w+.-]*://\S+|[\w.-]+@[\w.-]+(?::\S+)?)"
r"|(?P<tag></?[A-Za-z][^>\n]*?/?>)"
)
ARROW = r"(?:->|→|=>)"
# Shortest comma run that reads as a list of tools rather than as prose.
MIN_RUN_NAMES = 3
# `, and` must be tried before a bare `,` so the conjunction is a separator
# and not an item.
RUN_SPLIT = r",?\s+(?:and|or)\s+|,\s*"
RUN_SPLIT_RE = re.compile(RUN_SPLIT)
# A command name inside a chain is followed by `->`, so the usual "no
# trailing hyphen" guard has to make room for exactly that.
_CMD_END = r"(?!\w)(?!-(?!>))"
def _alternation(names) -> str:
"""Regex alternation over names, longest first so `rg` can't beat `rga`."""
if not names:
return r"(?!)"
return "|".join(re.escape(n) for n in sorted(names, key=lambda s: (-len(s), s)))
def _atom(vocab: Vocabulary) -> str:
cmd = rf"(?:{_alternation(vocab.strict)})(?![\w-])"
return rf"(?:{KEYBIND}|{VAR}|{PATH}|{FLAG}|{ENVVAR}|{IDENT}|{cmd})"
@functools.lru_cache(maxsize=4)
def _scanner(vocab: Vocabulary) -> re.Pattern:
"""The single pass over a line: protected regions plus wrappable tokens."""
full = rf"(?:{_alternation(vocab.full)})"
chain_link = rf"(?:{full}{_CMD_END}|{VAR})"
name = rf"{full}(?![\w-])"
return re.compile(
PROTECTED
# `ls->eza, cat->bat`: a shadow chain. Position makes even an
# ambiguous name unmistakably a command here.
+ rf"|(?P<chain>{BEFORE}{chain_link}(?:\s*{ARROW}\s*{chain_link})+{AFTER})"
# `cargo, starship, uv, zoxide`: a run of nothing but tool names.
+ rf"|(?P<run>{BEFORE}{name}(?:,\s*{name})+"
+ rf"(?:,?\s+(?:and|or)\s+{name})?{AFTER})"
# `-a/--all`: slash-joined atoms, each wrapped on its own.
+ rf"|(?P<group>{BEFORE}{_atom(vocab)}(?:/{_atom(vocab)})*{AFTER})"
)
@functools.lru_cache(maxsize=4)
def _atom_re(vocab: Vocabulary) -> re.Pattern:
return re.compile(_atom(vocab))
# ---------------------------------------------------------------------------
# Table cells that are whole command lines
# ---------------------------------------------------------------------------
# The abbreviation tables' second column is an expansion, not a sentence:
# `sudo -s`, `cd ../..`, `journalctl -p 3 -xb`. Wrapping only the flag would
# leave a bare `sudo` in front of a code span; the cell wants to be one span.
#
# Whether a column holds command lines is decided for the column as a whole
# -- one cell is far too little evidence, as `zoxide frecency-based
# navigation` (prose, in a column of prose) and `docker context ls` (a
# command, in a column of commands) open identically.
CELL_TOKEN_RE = re.compile(r"^[\w$~./=:;@+*?%'\"-]+$")
CELL_NAME_RE = re.compile(r"^[a-z][\w.+-]*$")
CELL_OPERATORS = frozenset((r"\|", "|", "&&", "||", ">", ">>", "<", ";"))
MAX_CELL_TOKENS = 8
COMMAND_COLUMN_RATIO = 0.7
MIN_COMMAND_COLUMN_ROWS = 3
def _cell_tokens(cell: str) -> list[str] | None:
"""Tokenise a cell that could be a command line, or None if it can't be."""
text = cell.strip()
if not text or any(c in text for c in "`<([)]"):
return None
tokens = text.split()
if not (1 <= len(tokens) <= MAX_CELL_TOKENS):
return None
for token in tokens:
if token in CELL_OPERATORS:
continue
if not CELL_TOKEN_RE.match(token):
return None
if token[:1].isupper() or token.lower() in STOPWORDS:
return None
return tokens
def _is_command_cell(cell: str, vocab: Vocabulary) -> bool:
"""True when a cell in a command column really is one command line."""
tokens = _cell_tokens(cell)
if tokens is None:
return False
return tokens[0] in vocab.full or bool(CELL_NAME_RE.match(tokens[0]))
def _opens_with_command(cell: str, vocab: Vocabulary) -> bool:
"""The per-cell evidence the column vote is counted from."""
tokens = _cell_tokens(cell)
return tokens is not None and tokens[0] in vocab.full
# ---------------------------------------------------------------------------
# Line classification
# ---------------------------------------------------------------------------
HEADING_RE = re.compile(r"^\s{0,3}#{1,6}\s")
TABLE_ROW_RE = re.compile(r"^\s*\|.*\|\s*$")
TABLE_RULE_RE = re.compile(r"^\s*\|[\s:|-]+\|\s*$")
IMPORT_RE = re.compile(r"^\s*import\s")
FILE_TREE_OPEN = "<FileTree"
FILE_TREE_CLOSE = "</FileTree>"
CELL_SPLIT_RE = re.compile(r"(?<!\\)\|")
def _skip_line(line: str) -> bool:
"""True for a line that must be passed through untouched.
Headings are excluded because Starlight derives anchors -- and this
pipeline derives `LinkCard` hrefs -- from their text. A line opening
with `<` is component markup, whose attributes are JSX, not markdown.
"""
stripped = line.strip()
return bool(
not stripped
or HEADING_RE.match(line)
or IMPORT_RE.match(line)
or stripped.startswith("<")
or TABLE_RULE_RE.match(line)
)
def _row_cells(line: str) -> list[str]:
return CELL_SPLIT_RE.split(line)
def _command_columns(rows: list[str], vocab: Vocabulary) -> set[int]:
"""Which column indices of one table hold command lines rather than prose."""
votes: dict[int, list[int]] = {}
for line in rows:
if TABLE_RULE_RE.match(line):
continue
for index, cell in enumerate(_row_cells(line)):
if not cell.strip() or "`" in cell:
continue
votes.setdefault(index, []).append(_opens_with_command(cell, vocab))
return {
index
for index, seen in votes.items()
if len(seen) >= MIN_COMMAND_COLUMN_ROWS
and sum(seen) / len(seen) >= COMMAND_COLUMN_RATIO
}
# ---------------------------------------------------------------------------
# The pass
# ---------------------------------------------------------------------------
# Spans this pass creates are marked, not back-ticked, until the very end:
# adjacent ones are merged (`eza` `-l` `-a` -> `eza -l -a`), and only spans
# this pass created may take part in that.
MARK = "\x01"
MERGE_RE = re.compile(rf"{MARK} {MARK}")
def _mark(text: str) -> str:
return f"{MARK}{text}{MARK}"
def _wrap_atoms(text: str, atom_re: re.Pattern) -> str:
"""Mark each atom of a slash-joined group, keeping the separators.
Rescanning the group rather than capturing during the first match keeps
the grammar readable; the round-trip check makes that shortcut safe --
if the rescan disagrees with the original match, nothing is changed.
"""
wrapped = atom_re.sub(lambda m: _mark(m.group(0)), text)
if wrapped.replace(MARK, "") != text:
return text
return wrapped
def _wrap_split(text: str, separator: str) -> str:
"""Mark each item of a separated run, keeping the separators."""
parts = re.split(rf"({separator})", text)
return "".join(p if i % 2 else _mark(p) for i, p in enumerate(parts))
def _transform(text: str, scanner: re.Pattern, atom_re: re.Pattern, vocab: Vocabulary) -> str:
def repl(m: re.Match) -> str:
group = m.lastgroup
if group == "chain":
return _wrap_split(m.group(0), rf"\s*{ARROW}\s*")
if group == "run":
# A long run anchored by at least one unambiguous tool name is
# a list of commands; two names, one of them a word like
# `function`, is a sentence.
names = RUN_SPLIT_RE.split(m.group(0))
if len(names) < MIN_RUN_NAMES or not any(
n in vocab.strict for n in names
):
# Not a list after all -- hand the text back to the
# ordinary token rules rather than swallowing it.
return _wrap_atoms(m.group(0), atom_re)
return _wrap_split(m.group(0), RUN_SPLIT)
if group == "group":
return _wrap_atoms(m.group(0), atom_re)
return m.group(0)
return scanner.sub(repl, text)
def _transform_line(
line: str,
scanner: re.Pattern,
atom_re: re.Pattern,
vocab: Vocabulary,
command_columns: set[int],
) -> str:
if not command_columns:
return _transform(line, scanner, atom_re, vocab)
out = []
for index, cell in enumerate(_row_cells(line)):
if index in command_columns and _is_command_cell(cell, vocab):
body = cell.strip()
lead = cell[: len(cell) - len(cell.lstrip())]
trail = cell[len(cell.rstrip()) :]
out.append(f"{lead}{_mark(body)}{trail}")
else:
out.append(_transform(cell, scanner, atom_re, vocab))
return "|".join(out)
def _finish(line: str) -> str:
"""Merge abutting new spans, then turn the marks into backticks."""
return MERGE_RE.sub(" ", line).replace(MARK, "`")
def add_code_spans(text: str, vocab: Vocabulary = EMPTY_VOCABULARY) -> str:
"""Wrap code-shaped tokens in `text` in inline code spans.
`text` is a rendered page body (no frontmatter). Fenced blocks,
`<FileTree>` bodies, headings, component markup, existing code spans,
link targets and URLs are left exactly as they are.
"""
scanner = _scanner(vocab)
atom_re = _atom_re(vocab)
lines = text.split("\n")
eligible = [False] * len(lines)
in_fence = False
in_tree = False
for i, line in enumerate(lines):
if FENCE_RE.match(line):
in_fence = not in_fence
continue
if in_fence:
continue
if FILE_TREE_OPEN in line:
in_tree = True
if in_tree:
if FILE_TREE_CLOSE in line:
in_tree = False
continue
eligible[i] = not _skip_line(line)
# Command columns are a property of a whole table, so the contiguous
# runs of table rows are resolved before any line is rewritten.
columns: list[set[int]] = [set() for _ in lines]
start = None
for i, line in enumerate(lines + [""]):
is_row = i < len(lines) and eligible[i] and TABLE_ROW_RE.match(line)
if is_row and start is None:
start = i
elif not is_row and start is not None:
found = _command_columns(lines[start:i], vocab)
for j in range(start, i):
columns[j] = found
start = None
return "\n".join(
_finish(_transform_line(line, scanner, atom_re, vocab, columns[i]))
if eligible[i]
else line
for i, line in enumerate(lines)
)
+26
View File
@@ -20,6 +20,32 @@ python3 docs/build-manual.py --site
`docs/verify-manual.py` validates both sources before you build; run it
first if you've touched a header or a manual page.
## Inline code spans
Function headers are read as plain text (by `config-help`, by `funcsave`,
by anyone opening the `.fish` file), so they're authored without backticks
`-a/--all`, not `` `-a`/`--all` ``. `docs/codespans.py` puts the
backticks on at render time, as the last step of `prettify()`, so only the
site sees them.
It recognises flags, `$vars`, `SCREAMING_SNAKE` env vars, snake_case
identifiers (`__fish_config_op_aliases`, `fish_greeting`), paths and
filenames, key chords (`Ctrl-R`), shadow chains (`ls->eza`), runs of tool
names (`btop, dust, duf, …`), whole command lines in a table column of
command lines, and command names it knows — the `_fdc_*` catalog in
`functions/_fish_deps_catalog.fish`, the `functions/` directory listing,
and a standard-command list in the module.
Names that also read as English (`find`, `top`, `screen`) are listed in
`AMBIGUOUS_COMMANDS` and are never wrapped on sight; they still count
where position already proves they're a command. Add to that list rather
than removing a rule if a wrap ever reads wrong.
Fenced blocks, existing code spans, headings, link targets, URLs,
component markup, and `<FileTree>` bodies are never touched. Leaving a
token alone is always the safe outcome, so every rule bails out when it
isn't sure.
## llms.txt
The [`starlight-llms-txt`](https://www.npmjs.com/package/starlight-llms-txt)
+178 -3
View File
@@ -9,6 +9,7 @@ import sys
import tempfile
from pathlib import Path
import codespans
import manualtools as mt
# docs/build-manual.py follows this repo's hyphenated CLI-script naming
@@ -470,8 +471,8 @@ def test_prettify_splits_an_entry_block():
"option table was not converted to a markdown table"
)
assert (
"\nFalls back to /usr/bin/rm when trash is unavailable." in out
), "trailing prose stayed indented"
"\nFalls back to `/usr/bin/rm` when trash is unavailable." in out
), "trailing prose stayed indented (or lost its path code span)"
def test_as_table_converts_option_blocks():
@@ -895,7 +896,10 @@ def test_customization_notes_render_as_aside():
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 (
"- Disabled integration commands "
"(`spwin`, `tab`, `split`, `hist`, `logs`, `upgrade`)" in aside
)
assert "- On CachyOS, the distro fish config's own aliases" in aside
@@ -1235,6 +1239,177 @@ def test_committed_registry_matches_headers():
)
# ---------------------------------------------------------------------------
# codespans: inline code spans added at site-render time
# ---------------------------------------------------------------------------
_REPO = Path(__file__).parent.parent
def _spans(text: str) -> str:
return codespans.add_code_spans(text, codespans.vocabulary(_REPO))
def test_codespans_wraps_each_half_of_a_flag_pair():
"""`-a/--all` is the manual's usual way of naming a flag and its alias."""
got = _spans("Use -a/--all to include both, or -s/--stdout to print.")
assert got == "Use `-a`/`--all` to include both, or `-s`/`--stdout` to print.", got
def test_codespans_wraps_override_variables_and_snake_case():
got = _spans("Disabled via __fish_config_op_aliases; see _fdc_bins and fish_greeting.")
assert got == (
"Disabled via `__fish_config_op_aliases`; see `_fdc_bins` and `fish_greeting`."
), got
def test_codespans_wraps_paths_vars_env_and_key_chords():
cases = {
"Sourced from ~/.config/fish/config.fish.": (
"Sourced from `~/.config/fish/config.fish`."
),
"honoring $XDG_CONFIG_HOME/aichat/roles/cli.md.": (
"honoring `$XDG_CONFIG_HOME/aichat/roles/cli.md`."
),
"Launches with NO_TMUX=1 set.": "Launches with `NO_TMUX=1` set.",
"end the session with Ctrl-D or Ctrl+Alt+F.": (
"end the session with `Ctrl-D` or `Ctrl+Alt+F`."
),
}
for source, want in cases.items():
assert _spans(source) == want, f"{source!r} -> {_spans(source)!r}"
def test_codespans_leaves_existing_spans_and_fences_alone():
body = "\n".join(
[
"Already `--wrapped` here.",
"",
"```fish",
"rm -e --empty ~/.config/fish",
"```",
"",
"## --not-a-flag-heading",
"",
"<Aside type=\"note\" title=\"Note\">",
"See --verbose.",
"</Aside>",
]
)
got = _spans(body).split("\n")
assert got[0] == "Already `--wrapped` here.", got[0]
assert got[3] == "rm -e --empty ~/.config/fish", "a fenced line was rewritten"
assert got[6] == "## --not-a-flag-heading", "a heading was rewritten"
assert got[8].startswith("<Aside"), "component markup was rewritten"
assert got[9] == "See `--verbose`.", "aside body was not processed"
def test_codespans_leaves_file_tree_bodies_alone():
"""<FileTree> list items are filenames the component renders itself."""
body = "<FileTree>\n- ~/.config/fish/\n - config.fish\n</FileTree>"
assert _spans(body) == body
def test_codespans_leaves_links_and_urls_alone():
cases = [
"See [`fish-deps`](/reference/dependency-management/fish-deps/) for more.",
"Clone from ssh://git@host/owner/repo.git today.",
"Docs live at https://fish.rootiest.fyi/07-customization/ online.",
]
for source in cases:
assert _spans(source) == source, f"{source!r} -> {_spans(source)!r}"
def test_codespans_ignores_prose_that_only_looks_like_code():
"""Every one of these has bitten a looser version of the pattern set."""
cases = [
"the registry -- not the cache -- is rebuilt",
"the everyday filesystem-inspection and -modification shadows",
"expand !^, !*, !-N and !?str? abbreviations",
"grep/cp/mv/wget flag injection",
"resolved and/or rejected",
"a _really_ important caveat",
"the TCP and AGPL acronyms",
]
for source in cases:
assert _spans(source) == source, f"{source!r} -> {_spans(source)!r}"
def test_codespans_wraps_a_command_shadow_chain():
got = _spans("Falls back through ov -> bat -> man -> less -> cat.")
assert got == "Falls back through `ov` -> `bat` -> `man` -> `less` -> `cat`.", got
def test_codespans_wraps_long_runs_of_tool_names_only():
"""Two names, one of them an English word, is a sentence -- not a list."""
got = _spans("Supports paru, yay, pacman, apt, dnf, zypper, yum, brew, and pkg.")
assert got == (
"Supports `paru`, `yay`, `pacman`, `apt`, `dnf`, `zypper`, `yum`, "
"`brew`, and `pkg`."
), got
got = _spans("the custom rm function, trashy, or trash-cli")
assert got == "the custom rm function, `trashy`, or trash-cli", got
def test_codespans_wraps_whole_cells_only_in_command_columns():
"""A column of expansions becomes code; a column of prose stays prose."""
commands = "\n".join(
[
"| Abbreviation | Description |",
"|---|---|",
"| `..` | cd .. |",
"| `jctl` | journalctl -p 3 -xb |",
"| `sudu` | sudo -s |",
"| `kt` | kitty (Kitty only) |",
]
).split("\n")
got = _spans("\n".join(commands)).split("\n")
assert got[2] == "| `..` | `cd ..` |", got[2]
assert got[3] == "| `jctl` | `journalctl -p 3 -xb` |", got[3]
assert got[4] == "| `sudu` | `sudo -s` |", got[4]
assert got[5] == "| `kt` | `kitty` (Kitty only) |", got[5]
prose = "\n".join(
[
"| Command | Active behavior |",
"|---|---|",
"| `cd` | zoxide frecency-based navigation |",
"| `top` | btop resource monitor |",
"| `mkdir` | verbose path-tree display on creation |",
"| `history` | timestamps prepended to every entry |",
]
)
got = _spans(prose).split("\n")
assert got[2] == "| `cd` | `zoxide` frecency-based navigation |", got[2]
assert got[3] == "| `top` | `btop` resource monitor |", got[3]
def test_codespans_merges_abutting_new_spans():
"""One command line reads as one span, not as a row of adjacent ones."""
got = _spans("| `ls` | eza -l -a --icons --hyperlink | system ls |")
assert got == "| `ls` | `eza -l -a --icons --hyperlink` | system ls |", got
def test_codespans_vocabulary_comes_from_the_deps_catalog():
"""A tool added to _fish_deps_catalog.fish needs no second list."""
names = codespans.dependency_names(_REPO)
assert {"fzf", "zoxide", "prettyping"} <= names, sorted(names)[:20]
vocab = codespans.vocabulary(_REPO)
assert "zoxide" in vocab.strict, "an unambiguous tool name should be wrappable"
assert "find" not in vocab.strict, "an English word must not be wrappable on sight"
assert "find" in vocab.full, "…but it still counts as a command-line opener"
def test_codespans_is_site_only():
"""The concat (man page, config-help) keeps the plain-text form."""
import build_manual
text = build_manual.build_concat(Path(__file__).parent / "manual")
assert "-r/--resume" in text, "sanity: the bare flag-pair form is what's authored"
assert "`-r`/`--resume`" not in text, "code spans leaked into the man-page pipeline"
TESTS = [v for k, v in sorted(globals().items()) if k.startswith("test_")]