Coverage for src/footman/markdown.py: 97%
122 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:38 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:38 +0000
1"""Render a task tree as markdown: one page, or a linked site of pages.
3Pure functions over manifest tree nodes — the same dicts `--json --list`
4emits — phrased through `footman._describe`, the module the `--help`
5renderer uses, so pages and help can never drift.
7Two flavors:
9- `plain` (the default) — CommonMark and pipe tables only. Safe verbatim
10 through `pymdownx.snippets` includes and pandoc → PDF/HTML.
11- `material` — opts into the extensions a zensical / mkdocs-material site
12 already enables: attr_list anchors on headings for stable deep links, and
13 an `!!! example` admonition for the synthesized invocation.
15`render_page` returns one document (headings start at *heading*, so a page
16can nest under a host site's own structure). `render_site` returns
17`{relative_path: content}` — one file per task, an `index.md` per group with
18relative links — ready to write into a docs tree.
19"""
21from __future__ import annotations
23import json
24from typing import Any
26from footman import _describe
28__all__ = ["globals_table", "render_page", "render_site"]
31def globals_table(*, prog: str = "fm") -> str:
32 """The runner's global options as a markdown pipe table.
34 Rendered straight from the CLI grammar (`split.GLOBALS`) — the same
35 rows, in the same order, with the same words `--help` prints — so a
36 docs page that regenerates this on each build can never drift from the
37 runner. *prog* fills the `{prog}` placeholders, so a branded CLI's docs
38 speak its own name.
39 """
40 from footman import split
42 rows = []
43 for name, alias, _kind, hint, help_text in split.GLOBALS:
44 main = f"`{name} {hint}`" if hint else f"`{name}`"
45 label = f"`{alias}`, {main}" if alias else main
46 rows.append((label, _cell(help_text.replace("{prog}", prog))))
47 width = max(len(label) for label, _ in rows)
48 lines = [
49 f"| {'option':<{width}} | effect |",
50 f"| {'-' * width} | ------ |",
51 ]
52 lines += [f"| {label:<{width}} | {effect} |" for label, effect in rows]
53 return "\n".join(lines) + "\n"
56def render_page(
57 tree: dict[str, Any],
58 *,
59 path: tuple[str, ...] = (),
60 heading: int = 1,
61 flavor: str = "plain",
62 prog: str = "fm",
63) -> str:
64 """One markdown document for the node at *path* (empty = whole tree)."""
65 kind, node = _resolve(tree, path)
66 if kind == "task":
67 parts = _task_page(list(path), node, heading, flavor, prog)
68 else:
69 parts = _group_page(list(path), node, heading, flavor, prog)
70 return "\n".join(parts).rstrip() + "\n"
73def render_site(
74 tree: dict[str, Any],
75 *,
76 path: tuple[str, ...] = (),
77 flavor: str = "plain",
78 prog: str = "fm",
79) -> dict[str, str]:
80 """A linked set of files for the node at *path*: `index.md` per group
81 (name, help, a table of children with relative links), one file per task.
82 Keys are POSIX-relative paths."""
83 kind, node = _resolve(tree, path)
84 if kind == "task":
85 name = path[-1]
86 page = render_page(tree, path=path, heading=1, flavor=flavor, prog=prog)
87 return {f"{name}.md": page}
88 files: dict[str, str] = {}
89 _site_group(list(path), node, "", files, flavor, prog)
90 return files
93# --- resolution ---------------------------------------------------------------
96def _resolve(tree: dict, path: tuple[str, ...]) -> tuple[str, dict]:
97 """Walk *path* to its node; ("task"|"group", node). Taught ValueError."""
98 node = tree
99 for i, name in enumerate(path):
100 if name in node["groups"]:
101 node = node["groups"][name]
102 elif i == len(path) - 1 and name in node["tasks"]:
103 return "task", node["tasks"][name]
104 else:
105 known = list(node["groups"]) + list(node["tasks"])
106 where = ".".join(path[:i]) or "the root"
107 raise ValueError(
108 f"no task or group named {name!r} under {where} "
109 f"(know: {', '.join(known) or 'nothing'})"
110 )
111 return "group", node
114# --- one task -----------------------------------------------------------------
117def _slug(path: list[str]) -> str:
118 return "-".join(path)
121def _cell(text: str) -> str:
122 """Make *text* safe inside a pipe-table cell."""
123 return text.replace("|", "\\|").replace("\n", " ").strip()
126def _h(level: int, text: str, path: list[str], flavor: str) -> str:
127 anchor = f" {{ #{_slug(path)} }}" if flavor == "material" and path else ""
128 return f"{'#' * min(level, 6)} {text}{anchor}"
131def _type_cell(p: dict) -> str:
132 if p["kind"] == "flag":
133 return "flag"
134 if p.get("mapping"): 134 ↛ 135line 134 didn't jump to line 135 because the condition on line 134 was never true
135 return "KEY=VALUE"
136 bits: list[str] = []
137 choices = p.get("choices")
138 if choices:
139 bits.append(" \\| ".join(f"`{c}`" for c in choices))
140 elif p.get("types"):
141 bits.append(" \\| ".join(p["types"]))
142 if p.get("multiple") or p.get("mapping"): 142 ↛ 143line 142 didn't jump to line 143 because the condition on line 142 was never true
143 bits.append("repeatable")
144 if p["kind"] == "variadic":
145 bits.append("variadic")
146 return ", ".join(bits)
149def _default_cell(p: dict) -> str:
150 # A positional argument is required by kind (a default would have made it
151 # an option — the grammar's load-bearing rule); flags/options say so.
152 if p.get("required") or p["kind"] == "argument":
153 return "*required*"
154 if "default" in p and p["default"] is not None:
155 return f"`{_cell(json.dumps(p['default']))}`"
156 return ""
159def _task_page(
160 path: list[str], task: dict, level: int, flavor: str, prog: str
161) -> list[str]:
162 title = " ".join(path) or prog
163 parts = [_h(level, title, path, flavor), ""]
164 if task["help"]: 164 ↛ 166line 164 didn't jump to line 166 because the condition on line 164 was always true
165 parts += [task["help"], ""]
166 if task.get("long"):
167 parts += [task["long"], ""]
168 if task.get("disabled"):
169 parts += [f"*Unavailable here: {task['disabled']}*", ""]
171 fragments = [f for p in task["params"] if (f := _describe.usage_fragment(p))]
172 usage = " ".join([prog, *path, *fragments])
173 parts += ["```text", usage, "```", ""]
175 if task["params"]:
176 parts += [
177 "| Parameter | Type | Default | Description |",
178 "| --- | --- | --- | --- |",
179 ]
180 for p in task["params"]:
181 label = f"`{_describe.param_label(p)}`"
182 doc = _cell(p.get("doc", ""))
183 parts.append(f"| {label} | {_type_cell(p)} | {_default_cell(p)} | {doc} |")
184 parts.append("")
186 invocation = _describe.example(path, task, prog)
187 if flavor == "material":
188 parts += [
189 "!!! example",
190 "",
191 " ```console",
192 f" $ {invocation}",
193 " ```",
194 "",
195 ]
196 else:
197 parts += [f"**Example:** `{invocation}`", ""]
198 return parts
201# --- one group, page mode -----------------------------------------------------
204def _group_page(
205 path: list[str], node: dict, level: int, flavor: str, prog: str
206) -> list[str]:
207 title = " ".join(path) if path else f"{prog} tasks"
208 parts = [_h(level, title, path, flavor), ""]
209 if node.get("help"):
210 parts += [node["help"], ""]
211 for name, task in node["tasks"].items():
212 parts += _task_page([*path, name], task, level + 1, flavor, prog)
213 for name, sub in node["groups"].items():
214 parts += _group_page([*path, name], sub, level + 1, flavor, prog)
215 return parts
218# --- site mode ----------------------------------------------------------------
221def _site_group(
222 path: list[str],
223 node: dict,
224 prefix: str,
225 files: dict[str, str],
226 flavor: str,
227 prog: str,
228) -> None:
229 title = " ".join(path) if path else f"{prog} tasks"
230 parts = [_h(1, title, path, flavor), ""]
231 if node.get("help"):
232 parts += [node["help"], ""]
233 rows = [
234 (f"[`{name}`]({name}.md)", _describe.task_line(task))
235 for name, task in node["tasks"].items()
236 ]
237 rows += [
238 (f"[`{name}`]({name}/index.md)", sub.get("help", ""))
239 for name, sub in node["groups"].items()
240 ]
241 if rows: 241 ↛ 245line 241 didn't jump to line 245 because the condition on line 241 was always true
242 parts += ["| Task | Description |", "| --- | --- |"]
243 parts += [f"| {link} | {_cell(text)} |" for link, text in rows]
244 parts.append("")
245 files[f"{prefix}index.md"] = "\n".join(parts).rstrip() + "\n"
247 for name, task in node["tasks"].items():
248 page = _task_page([*path, name], task, 1, flavor, prog)
249 files[f"{prefix}{name}.md"] = "\n".join(page).rstrip() + "\n"
250 for name, sub in node["groups"].items():
251 _site_group([*path, name], sub, f"{prefix}{name}/", files, flavor, prog)