docs(site): add inline code spans to generated Starlight pages
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:
+13
-2
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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
@@ -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_")]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user