What this session did

read-thru started the session as a folder of files inside a personal workspace. It ends it as a published MIT repo and a Claude Code skill, make-read-thru, that an agent invokes to turn code into a guide a reviewer can vouch for.

Three moves got it there. First it graduated: renamed readthrough โ†’ read-thru, de-coupled from the workspace, pushed to its own GitHub repo, wired back in as a submodule. Then it was repackaged as a skill โ€” the 411-line gen.py split into a documented package, a one-command CLI added, the authoring API lightened, and bundled references and golden examples written. Finally it moved to live where a skill belongs: .claude/skills/make-read-thru, the repo and the skill one and the same directory.

This guide walks the code that resulted. Read it top to bottom and you should be able to vouch for how the skill works โ€” and it was built with the skill itself.

The public surface

An author touches one thing: the helpers re-exported from read_thru. The package is split along clean seams โ€” a model, a markdown subset, code rendering, page assembly, a CLI โ€” but the surface stays small and flat.

Five modules behind one import; the DSL an author actually uses.

read_thru/__init__.py ยท 39 lines
read_thru/__init__.py39 lines ยท Python
โ‹ฏ 22 lines hidden (lines 1โ€“22)
1"""read-thru โ€” turn a codebase into a self-contained, code-level reading guide.
2 
3Author a content module that builds a list of :class:`Section`s out of block
4helpers, then render it to one standalone HTML file:
5 
6 from read_thru import Section, prose, code, callout, table, build
7 
8 SECTIONS = [
9 Section("What changed", [
10 prose("A short narrative."),
11 code("src/thing.py", peek=[(10, 40)]),
12 callout("why", "The reason this shape was chosen."),
13 ]),
14 ]
15 
16The CLI (`read-thru build content.py`) sets the engine paths and calls
17:func:`build` for you; see :mod:`read_thru.cli`.
18"""
19 
20from __future__ import annotations
21 
22from . import config
23from .code import code, diagram
24from .markdown import callout, md, prose, raw, table
25from .model import Section
26from .page import build
27 
28__all__ = [
29 "Section",
30 "prose",
31 "callout",
32 "table",
33 "code",
34 "diagram",
35 "raw",
36 "md",
37 "build",
38 "config",

A Section, with its ceremony made optional

The old Section demanded an id, an act, a number, and a technique on every entry. That fit a 50-section epic; it was dead weight for a four-section PR. Now only title and blocks are required. The id defaults to a slug of the title, so anchors stay stable across rebuilds, and the rest default to empty.

A deterministic slug, then every structural field optional.

read_thru/model.py ยท 61 lines
read_thru/model.py61 lines ยท Python
โ‹ฏ 19 lines hidden (lines 1โ€“19)
1"""The :class:`Section` โ€” one unit of the reading guide.
2 
3A section is a titled run of pre-rendered block strings (from ``prose``,
4``code``, ``callout``, ``table``, ``diagram``, ``raw``). Only ``title`` and
5``blocks`` are required; the rest is optional ceremony that richer guides use:
6an ``act`` to group sections in the table of contents, a ``num`` and
7``technique`` badge in the eyebrow, a ``subtitle``, and ``files`` chips. When no
8section sets ``act``, the TOC renders as a flat list (see :mod:`read_thru.page`).
9"""
10 
11from __future__ import annotations
12 
13import html
14import re
15from dataclasses import dataclass, field
16 
17from .markdown import _inline
18 
19 
20def _slug(text: str) -> str:
21 """A URL/id-safe slug from a title: lowercase, non-alphanumerics to hyphens."""
22 return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") or "section"
23 
24 
25@dataclass
26class Section:
27 title: str
28 blocks: list[str] = field(default_factory=list)
29 id: str = "" # anchor id; defaults to a slug of `title`
30 technique: str = "" # focus-technique badge (optional)
31 act: str = "" # TOC group, e.g. "Act I" / "Prologue" (optional)
32 num: str = "" # display number, e.g. "1.3" (optional)
33 subtitle: str = ""
34 files: list[str] = field(default_factory=list)
35 
36 def __post_init__(self) -> None:
37 if not self.id:
38 self.id = _slug(self.title)
โ‹ฏ 23 lines hidden (lines 39โ€“61)
39 
40 def html(self) -> str:
41 files = ""
42 if self.files:
43 chips = "".join(f'<code class="filechip">{html.escape(f)}</code>'
44 for f in self.files)
45 files = f'<div class="sec-files">{chips}</div>'
46 sub = f'<p class="sec-sub">{_inline(self.subtitle)}</p>' if self.subtitle else ""
47 badge = (f'<span class="technique">๐ŸŽฌ {html.escape(self.technique)}</span>'
48 if self.technique else "")
49 num = (f'<span class="sec-num">{html.escape(self.num)}</span>'
50 if self.num else "")
51 act = (f'<span class="sec-act">{html.escape(self.act)}</span>'
52 if self.act else "")
53 eyebrow = (f'<div class="sec-eyebrow">{act}{num}{badge}</div>'
54 if (act or num or badge) else "")
55 body = "\n".join(self.blocks)
56 return (
57 f'<section class="sec" id="{self.id}">'
58 f'<div class="sec-head">{eyebrow}'
59 f"<h2>{_inline(self.title)}</h2>{sub}{files}</div>"
60 f'<div class="sec-body">{body}</div></section>'
61 )

A small, safe markdown subset โ€” and a portability fix

Authored prose is never trusted as HTML; everything outside inline-code spans is escaped exactly once. The subset is deliberately tiny. The list-marker patterns below also carry a real fix from this session: they used to live inside the f-strings that build each <li>, and a backslash inside an f-string expression is a syntax error before Python 3.12. Widening support to 3.10 surfaced it.

Markers compiled once, out of the f-strings โ€” valid back to 3.10.

read_thru/markdown.py ยท 135 lines
read_thru/markdown.py135 lines ยท Python
โ‹ฏ 21 lines hidden (lines 1โ€“21)
1"""A small, safe markdown subset plus the prose/callout/table block helpers.
2 
3The renderer never trusts authored text to be HTML; everything outside fenced
4inline-code spans is escaped exactly once. The supported surface is deliberately
5tiny: paragraphs, ``-``/``*`` and ``1.`` lists, blockquotes, ``###``/``####``
6headings, ``---`` rules, and the inline forms below.
7"""
8 
9from __future__ import annotations
10 
11import html
12import re
13 
14# โ”€โ”€ Inline markdown forms โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
15_INLINE_CODE = re.compile(r"`([^`]+)`")
16_BOLD = re.compile(r"\*\*([^*]+)\*\*")
17_ITAL = re.compile(r"(?<![\w*])\*([^*\n]+)\*(?![\w*])")
18_LINK = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
19_KBD = re.compile(r"\+\+([^+]+)\+\+")
20# List-item markers, compiled out of the f-strings below (a backslash inside an
21# f-string expression is a syntax error before Python 3.12).
22_UL_ITEM = re.compile(r"^\s*[-*] ")
23_OL_ITEM = re.compile(r"^\s*\d+\. ")
โ‹ฏ 40 lines hidden (lines 24โ€“63)
24 
25 
26def _inline(text: str) -> str:
27 """Render inline markdown to HTML, escaping everything outside code spans
28 exactly once (code spans are escaped separately and never re-processed)."""
29 out: list[str] = []
30 pos = 0
31 for m in _INLINE_CODE.finditer(text):
32 out.append(_inline_nocode(text[pos:m.start()]))
33 out.append("<code>" + html.escape(m.group(1), quote=False) + "</code>")
34 pos = m.end()
35 out.append(_inline_nocode(text[pos:]))
36 return "".join(out)
37 
38 
39def _inline_nocode(text: str) -> str:
40 """Escape, then apply links, bold, kbd, and italics โ€” in that order."""
41 text = html.escape(text, quote=False)
42 text = _LINK.sub(r'<a href="\2" target="_blank" rel="noopener">\1</a>', text)
43 text = _BOLD.sub(r"<strong>\1</strong>", text)
44 text = _KBD.sub(r"<kbd>\1</kbd>", text)
45 text = _ITAL.sub(r"<em>\1</em>", text)
46 return text
47 
48 
49def md(text: str) -> str:
50 """Render the markdown subset (paragraphs, lists, blockquotes, headings,
51 horizontal rules) to HTML."""
52 text = text.strip("\n")
53 blocks = re.split(r"\n\s*\n", text)
54 html_blocks: list[str] = []
55 for block in blocks:
56 lines = [ln.rstrip() for ln in block.split("\n")]
57 first = lines[0].strip()
58 if first == "---":
59 html_blocks.append("<hr/>")
60 elif first.startswith("#### "):
61 html_blocks.append(f"<h4>{_inline(first[5:])}</h4>")
62 elif first.startswith("### "):
63 html_blocks.append(f"<h3>{_inline(first[4:])}</h3>")
64 elif all(_UL_ITEM.match(ln) for ln in lines if ln.strip()):
65 items = "".join(
66 f"<li>{_inline(_UL_ITEM.sub('', ln))}</li>"
67 for ln in lines if ln.strip()
68 )
69 html_blocks.append(f"<ul>{items}</ul>")
70 elif all(_OL_ITEM.match(ln) for ln in lines if ln.strip()):
71 items = "".join(
72 f"<li>{_inline(_OL_ITEM.sub('', ln))}</li>"
73 for ln in lines if ln.strip()
โ‹ฏ 62 lines hidden (lines 74โ€“135)
74 )
75 html_blocks.append(f"<ol>{items}</ol>")
76 elif all(ln.strip().startswith(">") for ln in lines if ln.strip()):
77 inner = " ".join(
78 re.sub(r"^\s*>\s?", "", ln) for ln in lines if ln.strip()
79 )
80 html_blocks.append(f"<blockquote>{_inline(inner)}</blockquote>")
81 else:
82 html_blocks.append(f"<p>{_inline(' '.join(lines))}</p>")
83 return "\n".join(html_blocks)
84 
85 
86# โ”€โ”€ Block helpers (each returns an HTML string) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
87def prose(text: str) -> str:
88 """A flowing prose block (the markdown subset)."""
89 return f'<div class="prose">{md(text)}</div>'
90 
91 
92def raw(html_str: str) -> str:
93 """Pass-through raw HTML, for the rare hand-authored block."""
94 return html_str
95 
96 
97_CALLOUTS = {
98 "why": ("๐Ÿงญ", "Why it's built this way"),
99 "insight": ("๐Ÿ’ก", "Insight"),
100 "security": ("๐Ÿ”’", "Security"),
101 "gotcha": ("โš ๏ธ", "Gotcha"),
102 "counter": ("๐Ÿงช", "Counterfactual"),
103 "principle": ("โš–๏ธ", "Principle"),
104 "trace": ("๐Ÿ”ฌ", "Trace"),
105 "note": ("๐Ÿ“", "Note"),
107 
108 
109def callout(kind: str, text: str, title: str | None = None) -> str:
110 """A highlighted aside. ``kind`` selects an icon/title from ``_CALLOUTS``
111 (why, insight, security, gotcha, counter, principle, trace, note)."""
112 icon, default_title = _CALLOUTS.get(kind, ("๐Ÿ“", "Note"))
113 title = title or default_title
114 return (
115 f'<aside class="callout c-{kind}">'
116 f'<div class="callout-h"><span class="callout-i">{icon}</span>'
117 f'<span class="callout-t">{html.escape(title)}</span></div>'
118 f'<div class="callout-b">{md(text)}</div></aside>'
119 )
120 
121 
122def table(headers: list[str], rows: list[list[str]], caption: str | None = None,
123 klass: str = "") -> str:
124 """An HTML table. Cells and headers run through inline markdown."""
125 thead = "".join(f"<th>{_inline(h)}</th>" for h in headers)
126 body = "".join(
127 "<tr>" + "".join(f"<td>{_inline(c)}</td>" for c in row) + "</tr>"
128 for row in rows
129 )
130 cap = f"<figcaption>{_inline(caption)}</figcaption>" if caption else ""
131 return (
132 f'<figure class="tablewrap {klass}"><table>'
133 f"<thead><tr>{thead}</tr></thead><tbody>{body}</tbody></table>"
134 f"{cap}</figure>"
135 )

Verbatim source, foldable

This is the heart. Each file is highlighted by Pygments, then split so that every source line becomes its own row โ€” and no <span> ever crosses a line boundary, which is what makes per-line folding and deep-linking possible.

Tokens that span newlines are split; one HTML string per line.

read_thru/code.py ยท 166 lines
read_thru/code.py166 lines ยท Python
โ‹ฏ 33 lines hidden (lines 1โ€“33)
1"""Verbatim source rendering: Pygments highlighting split into foldable
2per-line rows (GitHub-PR style), plus the inline-SVG ``diagram`` helper.
3 
4Every source line lands in the DOM as its own row; runs of folded lines collapse
5into ``<details>`` stubs but are never dropped, so the completeness contract in
6:func:`read_thru.build` can assert that a file rendered in full.
7"""
8 
9from __future__ import annotations
10 
11import html
12import re
13 
14from pygments import lex
15from pygments.lexers import get_lexer_by_name, get_lexer_for_filename
16from pygments.token import STANDARD_TYPES
17 
18from . import config
19from .markdown import _inline, md
20 
21 
22# โ”€โ”€ Token โ†’ CSS class (per-line, multi-line-token safe) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
23def _css_class(ttype) -> str:
24 """Map a Pygments token type to its short CSS class, walking up to parent
25 token types when the exact type has no standard class."""
26 t = ttype
27 cls = STANDARD_TYPES.get(t)
28 while cls is None and t.parent is not None:
29 t = t.parent
30 cls = STANDARD_TYPES.get(t)
31 return cls or ""
32 
33 
34def _highlight_lines(source: str, lexer) -> list[str]:
35 """Return one HTML string per source line. Tokens spanning newlines are
36 split so no ``<span>`` ever crosses a line boundary."""
37 lines: list[str] = [""]
38 for ttype, value in lex(source, lexer):
39 cls = _css_class(ttype)
40 parts = value.split("\n")
41 for i, part in enumerate(parts):
42 if i:
43 lines.append("")
44 if part:
45 esc = html.escape(part, quote=False)
46 lines[-1] += f'<span class="{cls}">{esc}</span>' if cls else esc
47 # `lex` yields a trailing newline โ†’ drop the empty final element.
48 if lines and lines[-1] == "":
49 lines.pop()
50 return lines
51 
52 
โ‹ฏ 114 lines hidden (lines 53โ€“166)
53def _ranges_to_set(ranges: list[tuple[int, int]]) -> set[int]:
54 """Flatten inclusive (start, end) line ranges into a set of line numbers."""
55 out: set[int] = set()
56 for a, b in ranges:
57 out.update(range(a, b + 1))
58 return out
59 
60 
61def diagram(name: str, caption: str | None = None, klass: str = "") -> str:
62 """Embed a pre-rendered diagram ``<name>.svg`` from ``config.SVG_DIR`` inline."""
63 svg = (config.SVG_DIR / f"{name}.svg").read_text()
64 # Strip the XML prolog if present; keep the <svg> root.
65 svg = re.sub(r"^<\?xml[^>]*\?>\s*", "", svg)
66 cap = f"<figcaption>{_inline(caption)}</figcaption>" if caption else ""
67 return f'<figure class="diagram {klass}">{svg}{cap}</figure>'
68 
69 
70def code(path: str, *, lang: str | None = None,
71 fold: list[tuple[int, int]] | None = None,
72 peek: list[tuple[int, int]] | None = None,
73 spotlight: list[tuple[int, int]] | None = None,
74 collapsed: bool = False,
75 title: str | None = None,
76 note: str | None = None,
77 logical: str | None = None) -> str:
78 """Render a source file as a foldable, highlighted code block.
79 
80 path: path relative to ``config.SOURCE_ROOT`` (the code being explained).
81 lang: force a Pygments lexer by name; otherwise inferred from the filename.
82 fold: line ranges collapsed by default into a stub.
83 peek: if given, fold EVERYTHING except these ranges (spotlight a slice).
84 spotlight: line ranges to visually emphasise.
85 collapsed: start the whole block collapsed.
86 title: header label (defaults to ``path``).
87 note: a short markdown note shown above the block.
88 logical: completeness-contract key + line-id namespace (defaults to ``path``).
89 """
90 fp = (config.SOURCE_ROOT / path)
91 source = fp.read_text()
92 raw_lines = source.split("\n")
93 if raw_lines and raw_lines[-1] == "":
94 raw_lines.pop()
95 n = len(raw_lines)
96 
97 if lang:
98 lexer = get_lexer_by_name(lang)
99 else:
100 try:
101 lexer = get_lexer_for_filename(fp.name, source)
102 except Exception:
103 lexer = get_lexer_by_name("text")
104 hlines = _highlight_lines(source, lexer)
105 while len(hlines) < n:
106 hlines.append("")
107 hlines = hlines[:n]
108 
109 if peek:
110 keep = _ranges_to_set(peek)
111 fold_set = {i for i in range(1, n + 1) if i not in keep}
112 else:
113 fold_set = _ranges_to_set(fold or [])
114 spot_set = _ranges_to_set(spotlight or [])
115 
116 key = re.sub(r"[^A-Za-z0-9]+", "-", (logical or path)).strip("-")
117 config.RENDERED[logical or path] = n
118 
119 def row(i: int) -> str:
120 cls = "row" + (" spot" if i in spot_set else "")
121 return (
122 f'<div class="{cls}" id="L-{key}-{i}">'
123 f'<a class="ln" href="#L-{key}-{i}">{i}</a>'
124 f'<span class="cl">{hlines[i-1] or "&nbsp;"}</span></div>'
125 )
126 
127 # Build the row stream, grouping folded runs into <details> stubs.
128 parts: list[str] = []
129 i = 1
130 while i <= n:
131 if i in fold_set:
132 j = i
133 while j + 1 <= n and (j + 1) in fold_set:
134 j += 1
135 count = j - i + 1
136 folded = "".join(row(k) for k in range(i, j + 1))
137 label = (f"โ‹ฏ {count} line{'s' if count != 1 else ''} hidden "
138 f"(lines {i}โ€“{j})")
139 parts.append(
140 f'<details class="fold"><summary>{label}</summary>'
141 f'<div class="foldrows">{folded}</div></details>'
142 )
143 i = j + 1
144 else:
145 parts.append(row(i))
146 i += 1
147 rows_html = "".join(parts)
148 
149 head_title = title or path
150 note_html = f'<div class="code-note">{md(note)}</div>' if note else ""
151 body = (
152 f'<div class="code-head">'
153 f'<span class="code-path">{html.escape(head_title)}</span>'
154 f'<span class="code-meta">{n} lines ยท {lexer.name}</span>'
155 f'<button class="code-toggle" data-act="expand">expand all</button>'
156 f"</div>"
157 f'<div class="code-body hl">{rows_html}</div>'
158 )
159 open_attr = "" if collapsed else " open"
160 return (
161 f'<div class="codefile" data-key="{key}">{note_html}'
162 f'<details class="codewrap"{open_attr}>'
163 f'<summary class="code-summary">{html.escape(head_title)} '
164 f'<span class="muted">ยท {n} lines</span></summary>'
165 f"{body}</details></div>"
166 )

Rows that an author folds are never dropped โ€” they collapse into a <details> stub that still holds every hidden line. That is the contract the build later checks: present, just folded.

Folded runs grouped into a labelled stub; the lines stay in the DOM.

read_thru/code.py ยท 166 lines
read_thru/code.py166 lines ยท Python
โ‹ฏ 127 lines hidden (lines 1โ€“127)
1"""Verbatim source rendering: Pygments highlighting split into foldable
2per-line rows (GitHub-PR style), plus the inline-SVG ``diagram`` helper.
3 
4Every source line lands in the DOM as its own row; runs of folded lines collapse
5into ``<details>`` stubs but are never dropped, so the completeness contract in
6:func:`read_thru.build` can assert that a file rendered in full.
7"""
8 
9from __future__ import annotations
10 
11import html
12import re
13 
14from pygments import lex
15from pygments.lexers import get_lexer_by_name, get_lexer_for_filename
16from pygments.token import STANDARD_TYPES
17 
18from . import config
19from .markdown import _inline, md
20 
21 
22# โ”€โ”€ Token โ†’ CSS class (per-line, multi-line-token safe) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
23def _css_class(ttype) -> str:
24 """Map a Pygments token type to its short CSS class, walking up to parent
25 token types when the exact type has no standard class."""
26 t = ttype
27 cls = STANDARD_TYPES.get(t)
28 while cls is None and t.parent is not None:
29 t = t.parent
30 cls = STANDARD_TYPES.get(t)
31 return cls or ""
32 
33 
34def _highlight_lines(source: str, lexer) -> list[str]:
35 """Return one HTML string per source line. Tokens spanning newlines are
36 split so no ``<span>`` ever crosses a line boundary."""
37 lines: list[str] = [""]
38 for ttype, value in lex(source, lexer):
39 cls = _css_class(ttype)
40 parts = value.split("\n")
41 for i, part in enumerate(parts):
42 if i:
43 lines.append("")
44 if part:
45 esc = html.escape(part, quote=False)
46 lines[-1] += f'<span class="{cls}">{esc}</span>' if cls else esc
47 # `lex` yields a trailing newline โ†’ drop the empty final element.
48 if lines and lines[-1] == "":
49 lines.pop()
50 return lines
51 
52 
53def _ranges_to_set(ranges: list[tuple[int, int]]) -> set[int]:
54 """Flatten inclusive (start, end) line ranges into a set of line numbers."""
55 out: set[int] = set()
56 for a, b in ranges:
57 out.update(range(a, b + 1))
58 return out
59 
60 
61def diagram(name: str, caption: str | None = None, klass: str = "") -> str:
62 """Embed a pre-rendered diagram ``<name>.svg`` from ``config.SVG_DIR`` inline."""
63 svg = (config.SVG_DIR / f"{name}.svg").read_text()
64 # Strip the XML prolog if present; keep the <svg> root.
65 svg = re.sub(r"^<\?xml[^>]*\?>\s*", "", svg)
66 cap = f"<figcaption>{_inline(caption)}</figcaption>" if caption else ""
67 return f'<figure class="diagram {klass}">{svg}{cap}</figure>'
68 
69 
70def code(path: str, *, lang: str | None = None,
71 fold: list[tuple[int, int]] | None = None,
72 peek: list[tuple[int, int]] | None = None,
73 spotlight: list[tuple[int, int]] | None = None,
74 collapsed: bool = False,
75 title: str | None = None,
76 note: str | None = None,
77 logical: str | None = None) -> str:
78 """Render a source file as a foldable, highlighted code block.
79 
80 path: path relative to ``config.SOURCE_ROOT`` (the code being explained).
81 lang: force a Pygments lexer by name; otherwise inferred from the filename.
82 fold: line ranges collapsed by default into a stub.
83 peek: if given, fold EVERYTHING except these ranges (spotlight a slice).
84 spotlight: line ranges to visually emphasise.
85 collapsed: start the whole block collapsed.
86 title: header label (defaults to ``path``).
87 note: a short markdown note shown above the block.
88 logical: completeness-contract key + line-id namespace (defaults to ``path``).
89 """
90 fp = (config.SOURCE_ROOT / path)
91 source = fp.read_text()
92 raw_lines = source.split("\n")
93 if raw_lines and raw_lines[-1] == "":
94 raw_lines.pop()
95 n = len(raw_lines)
96 
97 if lang:
98 lexer = get_lexer_by_name(lang)
99 else:
100 try:
101 lexer = get_lexer_for_filename(fp.name, source)
102 except Exception:
103 lexer = get_lexer_by_name("text")
104 hlines = _highlight_lines(source, lexer)
105 while len(hlines) < n:
106 hlines.append("")
107 hlines = hlines[:n]
108 
109 if peek:
110 keep = _ranges_to_set(peek)
111 fold_set = {i for i in range(1, n + 1) if i not in keep}
112 else:
113 fold_set = _ranges_to_set(fold or [])
114 spot_set = _ranges_to_set(spotlight or [])
115 
116 key = re.sub(r"[^A-Za-z0-9]+", "-", (logical or path)).strip("-")
117 config.RENDERED[logical or path] = n
118 
119 def row(i: int) -> str:
120 cls = "row" + (" spot" if i in spot_set else "")
121 return (
122 f'<div class="{cls}" id="L-{key}-{i}">'
123 f'<a class="ln" href="#L-{key}-{i}">{i}</a>'
124 f'<span class="cl">{hlines[i-1] or "&nbsp;"}</span></div>'
125 )
126 
127 # Build the row stream, grouping folded runs into <details> stubs.
128 parts: list[str] = []
129 i = 1
130 while i <= n:
131 if i in fold_set:
132 j = i
133 while j + 1 <= n and (j + 1) in fold_set:
134 j += 1
135 count = j - i + 1
136 folded = "".join(row(k) for k in range(i, j + 1))
137 label = (f"โ‹ฏ {count} line{'s' if count != 1 else ''} hidden "
138 f"(lines {i}โ€“{j})")
139 parts.append(
140 f'<details class="fold"><summary>{label}</summary>'
141 f'<div class="foldrows">{folded}</div></details>'
142 )
143 i = j + 1
144 else:
145 parts.append(row(i))
โ‹ฏ 21 lines hidden (lines 146โ€“166)
146 i += 1
147 rows_html = "".join(parts)
148 
149 head_title = title or path
150 note_html = f'<div class="code-note">{md(note)}</div>' if note else ""
151 body = (
152 f'<div class="code-head">'
153 f'<span class="code-path">{html.escape(head_title)}</span>'
154 f'<span class="code-meta">{n} lines ยท {lexer.name}</span>'
155 f'<button class="code-toggle" data-act="expand">expand all</button>'
156 f"</div>"
157 f'<div class="code-body hl">{rows_html}</div>'
158 )
159 open_attr = "" if collapsed else " open"
160 return (
161 f'<div class="codefile" data-key="{key}">{note_html}'
162 f'<details class="codewrap"{open_attr}>'
163 f'<summary class="code-summary">{html.escape(head_title)} '
164 f'<span class="muted">ยท {n} lines</span></summary>'
165 f"{body}</details></div>"
166 )

The table of contents adapts

The sidebar reads the sections it's given. If any one sets an act, the entries group under act headings the way the froot guide does. If none do โ€” the normal case for a PR or a snippet โ€” the list is flat, with no empty group chrome.

One branch on whether any section declares an act.

read_thru/page.py ยท 122 lines
read_thru/page.py122 lines ยท Python
โ‹ฏ 25 lines hidden (lines 1โ€“25)
1"""Page assembly: table of contents, Pygments theme CSS, and the self-contained
2:func:`build` that inlines fonts, styles, JS, and every section into one file.
3"""
4 
5from __future__ import annotations
6 
7import html
8import re
9 
10from pygments.formatters.html import HtmlFormatter
11 
12from . import config
13from .model import Section
14 
15 
16# โ”€โ”€ Table of contents โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
17def _toc_item(s: Section) -> str:
18 num = f'<span class="toc-num">{html.escape(s.num)}</span>' if s.num else ""
19 return (
20 f'<li class="toc-item" data-target="{s.id}">'
21 f'<a href="#{s.id}">{num}'
22 f'<span class="toc-name">{html.escape(s.title)}</span></a></li>'
23 )
24 
25 
26def _toc(sections: list[Section], toc_title: str) -> str:
27 """Render the sidebar TOC. Sections are grouped under their ``act`` headings
28 when any section sets one; otherwise the list is flat."""
29 out = [f'<nav id="toc"><div class="toc-title">{html.escape(toc_title)}</div>']
30 out.append('<input id="toc-filter" placeholder="filter sectionsโ€ฆ" autocomplete="off"/>')
31 out.append('<ul class="toc-list">')
32 if any(s.act for s in sections):
33 groups: list[tuple[str, list[Section]]] = []
34 for s in sections:
35 if not groups or groups[-1][0] != s.act:
36 groups.append((s.act, []))
37 groups[-1][1].append(s)
38 for act, secs in groups:
39 out.append(f'<li class="toc-act">{html.escape(act)}</li>')
40 out.extend(_toc_item(s) for s in secs)
41 else:
42 out.extend(_toc_item(s) for s in sections)
43 out.append("</ul></nav>")
44 return "".join(out)
45 
46 
โ‹ฏ 76 lines hidden (lines 47โ€“122)
47# โ”€โ”€ Pygments theme CSS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
48def _style_defs(style: str, scope: str) -> str:
49 """Token color rules for a Pygments style, scoped to ``scope`` โ€” with the
50 base-selector rule (which would force its own container background/color)
51 stripped, so our own --code-bg / ink theming stays in control."""
52 defs = HtmlFormatter(style=style).get_style_defs(scope)
53 defs = re.sub(r"(?m)^" + re.escape(scope) + r"\s*\{[^}]*\}\n?", "", defs)
54 return defs
55 
56 
57def _pygments_styles() -> str:
58 light = _style_defs("tango", ".theme-light .hl")
59 try:
60 dark = _style_defs("one-dark", ".theme-dark .hl")
61 except Exception:
62 dark = _style_defs("monokai", ".theme-dark .hl")
63 return light + "\n" + dark
64 
65 
66# โ”€โ”€ The build โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
67def build(sections: list[Section], all_files: list[str], *,
68 title: str = "A code-level reading",
69 toc_title: str | None = None) -> None:
70 """Render the sections to a single self-contained HTML file at ``config.OUT``.
71 
72 title: the page <title> (browser tab / bookmarks).
73 toc_title: heading shown atop the table of contents; defaults to ``title``.
74 
75 After writing, the completeness contract reports any file in ``all_files``
76 that no ``code()`` call rendered, and the total code-row count.
77 """
78 css = (config.ASSETS_DIR / "style.css").read_text()
79 js = (config.ASSETS_DIR / "app.js").read_text()
80 fonts_path = config.ASSETS_DIR / "fonts.css"
81 fonts = fonts_path.read_text() if fonts_path.exists() else ""
82 pyg = _pygments_styles()
83 toc = _toc(sections, toc_title or title)
84 body = "\n".join(s.html() for s in sections)
85 doc = f"""<!doctype html>
86<html lang="en" class="theme-light">
87<head>
88<meta charset="utf-8"/>
89<meta name="viewport" content="width=device-width, initial-scale=1"/>
90<title>{html.escape(title)}</title>
91<style>
92{fonts}
93{pyg}
94{css}
95</style>
96</head>
97<body>
98<div id="progress"></div>
99<button id="menu-btn" aria-label="menu">โ˜ฐ</button>
100<button id="theme-btn" aria-label="theme">โ—</button>
101{toc}
102<main id="main">
103{body}
104</main>
105<script>
106{js}
107</script>
108</body>
109</html>"""
110 config.OUT.write_text(doc)
111 
112 # Completeness contract: every declared file must be fully rendered.
113 missing = [f for f in all_files if f not in config.RENDERED]
114 print(f"Wrote {config.OUT} ({len(doc):,} bytes, {len(sections)} sections)")
115 if missing:
116 print(f"!! MISSING {len(missing)} files from the doc:")
117 for m in missing:
118 print(f" - {m}")
119 elif all_files:
120 total = sum(config.RENDERED.values())
121 print(f"OK: all {len(all_files)} files rendered "
122 f"({total:,} code rows present).")

The completeness contract

A whole-repo guide makes a promise: every file, in full. build keeps it honest. Pass the list of files that must appear and it reports any that no code() call rendered, plus the total number of code rows emitted.

Write the file, then check every declared file actually rendered.

read_thru/page.py ยท 122 lines
read_thru/page.py122 lines ยท Python
โ‹ฏ 109 lines hidden (lines 1โ€“109)
1"""Page assembly: table of contents, Pygments theme CSS, and the self-contained
2:func:`build` that inlines fonts, styles, JS, and every section into one file.
3"""
4 
5from __future__ import annotations
6 
7import html
8import re
9 
10from pygments.formatters.html import HtmlFormatter
11 
12from . import config
13from .model import Section
14 
15 
16# โ”€โ”€ Table of contents โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
17def _toc_item(s: Section) -> str:
18 num = f'<span class="toc-num">{html.escape(s.num)}</span>' if s.num else ""
19 return (
20 f'<li class="toc-item" data-target="{s.id}">'
21 f'<a href="#{s.id}">{num}'
22 f'<span class="toc-name">{html.escape(s.title)}</span></a></li>'
23 )
24 
25 
26def _toc(sections: list[Section], toc_title: str) -> str:
27 """Render the sidebar TOC. Sections are grouped under their ``act`` headings
28 when any section sets one; otherwise the list is flat."""
29 out = [f'<nav id="toc"><div class="toc-title">{html.escape(toc_title)}</div>']
30 out.append('<input id="toc-filter" placeholder="filter sectionsโ€ฆ" autocomplete="off"/>')
31 out.append('<ul class="toc-list">')
32 if any(s.act for s in sections):
33 groups: list[tuple[str, list[Section]]] = []
34 for s in sections:
35 if not groups or groups[-1][0] != s.act:
36 groups.append((s.act, []))
37 groups[-1][1].append(s)
38 for act, secs in groups:
39 out.append(f'<li class="toc-act">{html.escape(act)}</li>')
40 out.extend(_toc_item(s) for s in secs)
41 else:
42 out.extend(_toc_item(s) for s in sections)
43 out.append("</ul></nav>")
44 return "".join(out)
45 
46 
47# โ”€โ”€ Pygments theme CSS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
48def _style_defs(style: str, scope: str) -> str:
49 """Token color rules for a Pygments style, scoped to ``scope`` โ€” with the
50 base-selector rule (which would force its own container background/color)
51 stripped, so our own --code-bg / ink theming stays in control."""
52 defs = HtmlFormatter(style=style).get_style_defs(scope)
53 defs = re.sub(r"(?m)^" + re.escape(scope) + r"\s*\{[^}]*\}\n?", "", defs)
54 return defs
55 
56 
57def _pygments_styles() -> str:
58 light = _style_defs("tango", ".theme-light .hl")
59 try:
60 dark = _style_defs("one-dark", ".theme-dark .hl")
61 except Exception:
62 dark = _style_defs("monokai", ".theme-dark .hl")
63 return light + "\n" + dark
64 
65 
66# โ”€โ”€ The build โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
67def build(sections: list[Section], all_files: list[str], *,
68 title: str = "A code-level reading",
69 toc_title: str | None = None) -> None:
70 """Render the sections to a single self-contained HTML file at ``config.OUT``.
71 
72 title: the page <title> (browser tab / bookmarks).
73 toc_title: heading shown atop the table of contents; defaults to ``title``.
74 
75 After writing, the completeness contract reports any file in ``all_files``
76 that no ``code()`` call rendered, and the total code-row count.
77 """
78 css = (config.ASSETS_DIR / "style.css").read_text()
79 js = (config.ASSETS_DIR / "app.js").read_text()
80 fonts_path = config.ASSETS_DIR / "fonts.css"
81 fonts = fonts_path.read_text() if fonts_path.exists() else ""
82 pyg = _pygments_styles()
83 toc = _toc(sections, toc_title or title)
84 body = "\n".join(s.html() for s in sections)
85 doc = f"""<!doctype html>
86<html lang="en" class="theme-light">
87<head>
88<meta charset="utf-8"/>
89<meta name="viewport" content="width=device-width, initial-scale=1"/>
90<title>{html.escape(title)}</title>
91<style>
92{fonts}
93{pyg}
94{css}
95</style>
96</head>
97<body>
98<div id="progress"></div>
99<button id="menu-btn" aria-label="menu">โ˜ฐ</button>
100<button id="theme-btn" aria-label="theme">โ—</button>
101{toc}
102<main id="main">
103{body}
104</main>
105<script>
106{js}
107</script>
108</body>
109</html>"""
110 config.OUT.write_text(doc)
111 
112 # Completeness contract: every declared file must be fully rendered.
113 missing = [f for f in all_files if f not in config.RENDERED]
114 print(f"Wrote {config.OUT} ({len(doc):,} bytes, {len(sections)} sections)")
115 if missing:
116 print(f"!! MISSING {len(missing)} files from the doc:")
117 for m in missing:
118 print(f" - {m}")
119 elif all_files:
120 total = sum(config.RENDERED.values())
121 print(f"OK: all {len(all_files)} files rendered "
122 f"({total:,} code rows present).")

One command

Before, every guide needed a hand-written build.py that set three module globals and called build(). Now the CLI does the wiring. The one subtlety it gets right: SECTIONS is built when the content module is imported, and each code() reads a file off SOURCE_ROOT as it runs โ€” so the source root must be set before the import, not after.

Resolve paths, then import the content, then render.

read_thru/cli.py ยท 144 lines
read_thru/cli.py144 lines ยท Python
โ‹ฏ 87 lines hidden (lines 1โ€“87)
1"""The ``read-thru`` command line.
2 
3Two subcommands:
4 
5 read-thru new [path] scaffold a starter content module
6 read-thru build content.py [options] render a content module to one HTML file
7 
8``build`` loads the content module (so it can ``from read_thru import ...``),
9points the engine at the code being explained, and calls
10:func:`read_thru.build`. The only thing an author writes is the content module
11โ€” a list of ``SECTIONS`` plus an optional ``TITLE``/``TOC_TITLE``/``ALL_FILES``.
12"""
13 
14from __future__ import annotations
15 
16import argparse
17import importlib.util
18import subprocess
19import sys
20from pathlib import Path
21 
22from . import build, config
23 
24_STARTER = '''"""read-thru content. Edit SECTIONS, then build:
25 
26 read-thru build {name} --source <repo-root>
27 
28Docs: references/dsl.md (block helpers), references/depth-and-scope.md (sizing).
29"""
30from read_thru import Section, prose, code, callout, table # noqa: F401
31 
32TITLE = "My change โ€” a code-level reading"
33 
34SECTIONS = [
35 Section("Overview", [
36 prose("What this change is and why it matters. Lead with the point; "
37 "keep it tight and concrete."),
38 ]),
39 Section("The key file", [
40 prose("What to look at here, and what to notice about it."),
41 code("path/to/file.py", peek=[(1, 40)]),
42 callout("why", "Why it is built this way."),
43 ]),
45 
46# Whole-repo guides only: list every file you intend to render in full, and the
47# build will error if any is missing and report the total code-row count.
48# ALL_FILES = ["path/to/file.py"]
49'''
50 
51 
52def _git_root(start: Path) -> Path | None:
53 """The git top-level containing ``start``, or None if not in a repo."""
54 try:
55 r = subprocess.run(
56 ["git", "-C", str(start), "rev-parse", "--show-toplevel"],
57 capture_output=True, text=True, check=False,
58 )
59 if r.returncode == 0:
60 return Path(r.stdout.strip())
61 except Exception:
62 pass
63 return None
64 
65 
66def _load_content(path: Path):
67 """Import a content module from a file path, with its directory on sys.path
68 so it can import sibling helpers."""
69 spec = importlib.util.spec_from_file_location(path.stem, path)
70 if spec is None or spec.loader is None:
71 raise SystemExit(f"read-thru: cannot load content module {path}")
72 mod = importlib.util.module_from_spec(spec)
73 sys.path.insert(0, str(path.parent))
74 spec.loader.exec_module(mod)
75 return mod
76 
77 
78def _cmd_new(args: argparse.Namespace) -> int:
79 out = Path(args.path)
80 if out.exists() and not args.force:
81 raise SystemExit(f"read-thru: {out} exists (use --force to overwrite)")
82 out.write_text(_STARTER.format(name=out.name))
83 print(f"Wrote starter content to {out}")
84 print("Edit SECTIONS, then: read-thru build " + str(out))
85 return 0
86 
87 
88def _cmd_build(args: argparse.Namespace) -> int:
89 content = Path(args.content).resolve()
90 if not content.exists():
91 raise SystemExit(f"read-thru: content module not found: {content}")
92 
93 # Resolve paths BEFORE importing the content module โ€” SECTIONS is built at
94 # import time and its code() calls read files relative to SOURCE_ROOT.
95 source = Path(args.source).resolve() if args.source else (
96 _git_root(Path.cwd()) or Path.cwd())
97 svg_dir = (Path(args.svg_dir).resolve() if args.svg_dir
98 else content.parent / "svg")
99 config.SOURCE_ROOT = source
100 config.SVG_DIR = svg_dir
101 
102 mod = _load_content(content)
103 sections = getattr(mod, "SECTIONS", None)
104 if not sections:
105 raise SystemExit(f"read-thru: {content} defines no SECTIONS")
106 all_files = list(getattr(mod, "ALL_FILES", []) or [])
107 title = args.title or getattr(mod, "TITLE", None) or "A code-level reading"
108 toc_title = args.toc_title or getattr(mod, "TOC_TITLE", None)
109 
110 out = Path(args.out).resolve() if args.out else (
111 Path.cwd() / f"{content.stem}.html")
112 config.OUT = out
113 
114 build(sections, all_files, title=title, toc_title=toc_title)
โ‹ฏ 30 lines hidden (lines 115โ€“144)
115 return 0
116 
117 
118def main(argv: list[str] | None = None) -> int:
119 p = argparse.ArgumentParser(
120 prog="read-thru",
121 description="Turn a codebase into a self-contained code-level reading guide.",
122 )
123 sub = p.add_subparsers(dest="cmd", required=True)
124 
125 pn = sub.add_parser("new", help="scaffold a starter content module")
126 pn.add_argument("path", nargs="?", default="content.py",
127 help="where to write the starter (default: content.py)")
128 pn.add_argument("--force", action="store_true", help="overwrite if it exists")
129 pn.set_defaults(func=_cmd_new)
130 
131 pb = sub.add_parser("build", help="render a content module to one HTML file")
132 pb.add_argument("content", help="path to the content module (defines SECTIONS)")
133 pb.add_argument("--source", help="root that code() paths resolve against "
134 "(default: the git root of the cwd, else the cwd)")
135 pb.add_argument("--out", help="output HTML path (default: <content>.html)")
136 pb.add_argument("--title", help="page <title> (default: module TITLE or generic)")
137 pb.add_argument("--toc-title", dest="toc_title",
138 help="heading atop the TOC (default: the title)")
139 pb.add_argument("--svg-dir", dest="svg_dir",
140 help="pre-rendered diagram SVGs (default: <content dir>/svg)")
141 pb.set_defaults(func=_cmd_build)
142 
143 args = p.parse_args(argv)
144 return args.func(args)

The agent contract

What makes this a skill and not just a library is the frontmatter below. The description is the whole triggering mechanism โ€” it tells Claude when to reach for the skill, phrased to fire on "explain this PR" even when no one says the word "read-thru".

Name + a deliberately pushy description: the skill's entire trigger.

SKILL.md ยท 98 lines
SKILL.md98 lines ยท YAML
1---
2name: make-read-thru
3description: >-
4 Generate a self-contained HTML reading guide for code: authored narrative
5 interleaved with verbatim, foldable, syntax-highlighted source, callouts, and
6 tables โ€” one standalone file, no network. Use this whenever opening or
7 reviewing a PR (produce a guide of the PR's content and link it from the PR),
8 or when asked to explain, walk through, document, or "read through" a change,
9 a file or snippet, a module, or a whole codebase so a reviewer can vouch for
10 it. Reach for it even when the user doesn't say "read-thru" โ€” any "help me
11 explain/review this code/PR/repo to someone" task qualifies.
12---
โ‹ฏ 86 lines hidden (lines 13โ€“98)
13 
14# make-read-thru
15 
16You turn code into a guide a reviewer can read top to bottom and come away able
17to **vouch** for it: the narrative explains *why*, and every line of the
18**verbatim source** is right there, foldable. Output is one self-contained HTML
19file (embedded fonts, inline CSS/JS) โ€” open it anywhere, offline.
20 
21The interface is two steps: **author a content module, then run one command.**
22 
23## 1. Author the content module
24 
25Write a Python module that defines `SECTIONS` (and optionally `TITLE`). Scaffold
26a starter:
27 
28```sh
29read-thru new doc.py
30```
31 
32Build `SECTIONS` out of block helpers โ€” `prose`, `code`, `callout`, `table`
33(`diagram` is optional). The one that carries the guide is `code`:
34 
35```python
36from read_thru import Section, prose, code, callout
37 
38TITLE = "What changed โ€” a code-level reading"
39 
40SECTIONS = [
41 Section("Overview", [ prose("The change in a few tight sentences: what, why, blast radius.") ]),
42 Section("The key file", [
43 prose("What to notice here and why it's safe."),
44 code("src/thing.py", peek=[(12, 40)]), # spotlight the lines that matter, fold the rest
45 callout("why", "The reason a reviewer would ask about, answered from the code."),
46 ]),
48```
49 
50Full block + `Section` reference: **[`references/dsl.md`](./references/dsl.md)**.
51 
52## 2. Build
53 
54Run the engine via `uv` from this skill's own directory (so it's installed once,
55no PATH setup). Replace `<SKILL_DIR>` with the path to this skill:
56 
57```sh
58uv run --project "<SKILL_DIR>" read-thru build doc.py --source <repo-root> --out guide.html
59```
60 
61`--source` defaults to the git root of the cwd, `--out` to `<doc>.html`, the
62title to the module's `TITLE`. So inside the repo you're documenting it's often
63just `... read-thru build doc.py`.
64 
65## Pick a scope and depth
66 
67Same command for all three; only what you write differs. Size the guide to the
68change โ€” **smallest tier that lets a reviewer vouch.** Details + word/section
69budgets: **[`references/depth-and-scope.md`](./references/depth-and-scope.md)**.
70 
71- **Snippet / file** โ€” a few sections; `code(path, peek=[(a,b)])` on the ranges that matter.
72- **PR (the common case)** โ€” `git diff --name-only <base>...<head>` to find changed
73 files; one section each at HEAD + just enough context; a `callout` where a
74 reviewer is skeptical. ~3โ€“8 sections.
75- **Whole repo** โ€” plan an arc, set `ALL_FILES` to enforce the completeness
76 contract, optionally a lite cut. Follow **[`references/process.md`](./references/process.md)**.
77 
78## Make it trustworthy
79 
80A guide is only worth it if the reader can trust it. Two things earn that:
81 
821. **Tight prose.** Short, varied sentences; sparse em-dashes; plain words. Lead
83 with the point. (Optional gate: `extras/lint_prose.py`.)
842. **Fact-check every claim against the source.** Re-read the lines each section
85 cites and fix any wrong line range, over-claim, or absolute ("never", "only").
86 For repo-scale guides, fan out one skeptic per section โ€” see `references/process.md`.
87 
88## Calibrate against the goldens
89 
90Read these before authoring โ€” they set the target for content, prose, and depth:
91 
92- **[`examples/pr-sample/`](./examples/pr-sample/)** โ€” a tight PR guide. Read
93 `content.py`; open `sample.html` to see the rendered result. **Start here.**
94- **[`examples/froot-uv-pr/`](./examples/froot-uv-pr/)** โ€” a meatier real PR.
95- **[`examples/froot/`](./examples/froot/)** โ€” the whole-repo exemplar.
96 
97The guide is a single self-contained HTML file. What you do with it next โ€”
98open it, attach it, publish it somewhere โ€” is up to the caller.

Everything heavier lives one level down, loaded only when needed: the methodology in references/process.md, the DSL in references/dsl.md, the sizing tiers in references/depth-and-scope.md, and three golden examples to calibrate against. The skill body stays short on purpose.

What makes it a skill

The engine was always generic. Turning it into a skill was less about code than about contract: a tight SKILL.md that says when to use it and how, references that hold the depth, goldens that show the bar, and an interface an agent can drive in two steps โ€” write a content module, run one command.

One thing was deliberately removed. An earlier draft taught the skill how to deploy a guide to a personal static site and link it from a PR. That's a workspace concern, not read-thru's job, so it now lives only in the workspace's own instructions. The skill produces one self-contained HTML file and stops there. What you do with it next is up to you โ€” including, for instance, reading this.