diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8fbc9d1..cb0ad6d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -239,6 +239,13 @@ python3 docs/verify-manual.py
CI runs the same verification and regenerates the site/man page — a broken
manual won't get published, but running it locally saves a round trip.
+Write doc-headers as plain text — no backticks. `-a/--all`,
+`__fish_config_op_aliases` and `~/.config/fish/config.fish` are typed
+bare, because the header is also read as-is by `config-help` and by
+anyone opening the file. `docs/codespans.py` adds the inline code spans
+the docs site wants when it renders, so the SSOT never carries them; see
+`docs/site/README.md` for which shapes it recognises.
+
## Testing
```fish
diff --git a/docs/build-manual.py b/docs/build-manual.py
index e2ab213..e74802b 100644
--- a/docs/build-manual.py
+++ b/docs/build-manual.py
@@ -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""
+@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 = {
diff --git a/docs/codespans.py b/docs/codespans.py
new file mode 100644
index 0000000..a14d58a
--- /dev/null
+++ b/docs/codespans.py
@@ -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,
+`` 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"(?``+.+?``+|`[^`\n]*`)"
+ r"|(?P\[[^\]\n]*\]\([^)\n]*\))"
+ r"|(?P[A-Za-z][\w+.-]*://\S+|[\w.-]+@[\w.-]+(?::\S+)?)"
+ r"|(?P?[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{BEFORE}{chain_link}(?:\s*{ARROW}\s*{chain_link})+{AFTER})"
+ # `cargo, starship, uv, zoxide`: a run of nothing but tool names.
+ + rf"|(?P{BEFORE}{name}(?:,\s*{name})+"
+ + rf"(?:,?\s+(?:and|or)\s+{name})?{AFTER})"
+ # `-a/--all`: slash-joined atoms, each wrapped on its own.
+ + rf"|(?P{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 = ""
+CELL_SPLIT_RE = re.compile(r"(? 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,
+ `` 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)
+ )
diff --git a/docs/site/README.md b/docs/site/README.md
index 350bbc1..a7d25c6 100644
--- a/docs/site/README.md
+++ b/docs/site/README.md
@@ -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 `` 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)
diff --git a/docs/verify-manual.py b/docs/verify-manual.py
index 7873a6f..a5db59f 100644
--- a/docs/verify-manual.py
+++ b/docs/verify-manual.py
@@ -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",
+ "",
+ "",
+ ]
+ )
+ 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("