revision-engine 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,285 @@
1
+ """Markdown rendering with the reVision extensions.
2
+
3
+ Extensions, all plain CommonMark syntax so other renderers degrade gracefully:
4
+ [phrase](@anchor) anchor phrase -> <span class="rv-anchor" data-anchor=...>
5
+ [name](!intrinsic) vendor link -> <a class="rv-intrinsic" href=...>
6
+ `name` (inline code) auto-linked when the name is in a vendor table, except inside another
7
+ link's text (Markdown or raw <a>), where it renders as plain <code>; an
8
+ anchor phrase is a <span>, so a span in one still links. Spans whose
9
+ name is in no table are recorded on RenderedLesson.unlinked_code
10
+ ![alt](diagrams/x.dot) inline pre-rendered SVG in a <figure class="rv-diagram">
11
+ ```quiz ... ``` page-local knowledge check widget; code spans in its text render as
12
+ plain <code>, never vendor-linked; choices are shuffled in the browser
13
+ unless a question sets `shuffle: false` (data-fixed-order) or has
14
+ two choices; any key outside _QUESTION_KEYS (or _QUIZ_KEYS, at quiz
15
+ level) fails the build
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import html
21
+ import posixpath
22
+ from dataclasses import dataclass, field
23
+ from typing import Callable
24
+
25
+ import yaml
26
+ from markdown_it import MarkdownIt
27
+ from markdown_it.common.utils import escapeHtml, isLinkClose, isLinkOpen
28
+ from markdown_it.rules_inline import backtick
29
+ from markdown_it.token import Token
30
+
31
+ from revision_engine.errors import BuildError, Report
32
+
33
+
34
+ @dataclass
35
+ class RenderContext:
36
+ anchors: dict[str, str]
37
+ diagrams: dict[str, str]
38
+ lookup: Callable[[str], str | None]
39
+ report: Report
40
+ where: str
41
+ lesson_file: str
42
+ line_offset: int = 0 # lines preceding the body in lesson.md (front matter), for file-relative error lines
43
+
44
+
45
+ @dataclass
46
+ class RenderedLesson:
47
+ html: str
48
+ used_anchors: list[str] = field(default_factory=list)
49
+ used_diagrams: list[str] = field(default_factory=list)
50
+ quiz_count: int = 0
51
+ unlinked_code: list[tuple[int, str]] = field(default_factory=list) # (file line, span text)
52
+
53
+
54
+ def _vendor_attrs(url: str) -> dict[str, str]:
55
+ return {"class": "rv-intrinsic", "href": url, "target": "_blank", "rel": "noopener"}
56
+
57
+
58
+ def _line(token: Token, fallback: int, ctx: RenderContext) -> int:
59
+ body_line = token.map[0] + 1 if token.map else fallback
60
+ return body_line + ctx.line_offset
61
+
62
+
63
+ def _link_text(children: list[Token], start: int) -> str:
64
+ parts: list[str] = []
65
+ for tok in children[start + 1 :]:
66
+ if tok.type == "link_close":
67
+ break
68
+ if tok.type in ("text", "code_inline"):
69
+ parts.append(tok.content)
70
+ return "".join(parts)
71
+
72
+
73
+ def _close(children: list[Token], open_index: int) -> Token:
74
+ for tok in children[open_index + 1 :]:
75
+ if tok.type == "link_close":
76
+ return tok
77
+ raise AssertionError("link_open without link_close")
78
+
79
+
80
+ def _rewrite_links(children: list[Token], line: int, ctx: RenderContext, out: RenderedLesson) -> None:
81
+ # A code span in another link's text must not render its own vendor <a> (no <a> inside an <a>):
82
+ # "code" renders it as plain <code>, "text" as its bare escaped text (the link itself became <code>).
83
+ # Markdown links don't nest. Raw inline <a ...> and </a> are separate html_inline tokens around the
84
+ # link text; an unclosed <a> runs to the end of the inline block (a browser would carry it further).
85
+ link_stamp: str | None = None
86
+ in_raw_a = False
87
+ for i, tok in enumerate(children):
88
+ if tok.type == "code_inline":
89
+ stamp = link_stamp or ("code" if in_raw_a else None)
90
+ if stamp:
91
+ tok.meta["rv_in_link"] = stamp
92
+ continue
93
+ if tok.type == "html_inline":
94
+ if isLinkOpen(tok.content):
95
+ in_raw_a = True
96
+ elif isLinkClose(tok.content):
97
+ in_raw_a = False
98
+ continue
99
+ if tok.type == "link_close":
100
+ link_stamp = None
101
+ continue
102
+ if tok.type != "link_open":
103
+ continue
104
+ link_stamp = "code"
105
+ href = tok.attrGet("href") or ""
106
+ if href.startswith("@"):
107
+ name = href[1:]
108
+ if name not in ctx.anchors:
109
+ raise BuildError(f"{ctx.where}:{line}: unknown anchor {name!r}")
110
+ if ctx.anchors[name] != ctx.lesson_file:
111
+ raise BuildError(
112
+ f"{ctx.where}:{line}: anchor {name!r} resolves in {ctx.anchors[name]!r} "
113
+ f"but this lesson shows {ctx.lesson_file!r}"
114
+ )
115
+ tok.tag = "span"
116
+ tok.attrs = {"class": "rv-anchor", "data-anchor": name, "tabindex": "0"}
117
+ if name not in out.used_anchors:
118
+ out.used_anchors.append(name)
119
+ _close(children, i).tag = "span"
120
+ link_stamp = None # an anchor phrase is a <span>, so a span in one still links
121
+ elif href == "!intrinsic":
122
+ name = _link_text(children, i)
123
+ url = ctx.lookup(name)
124
+ if url is None:
125
+ ctx.report.warn(f"{ctx.where}:{line}: intrinsic {name!r} is not in any vendor table")
126
+ tok.tag = "code"
127
+ tok.attrs = {}
128
+ _close(children, i).tag = "code"
129
+ link_stamp = "text"
130
+ else:
131
+ tok.attrs = _vendor_attrs(url)
132
+
133
+
134
+ # Quiz text accepts code spans only: raw HTML, emphasis, links, entities and backslash escapes show
135
+ # as written, and a code span is never a vendor link (an <a> inside a choice's <label> would take
136
+ # the click meant for the radio button).
137
+ _quiz_text = MarkdownIt("zero").enable("backticks").renderInline
138
+
139
+ # The keys _quiz_html reads; any other key fails the build, so a misspelled or misplaced
140
+ # `shuffle: false` cannot silently leave a question shuffled (#60).
141
+ _QUIZ_KEYS: tuple[str, ...] = ("questions",)
142
+ _QUESTION_KEYS: tuple[str, ...] = ("q", "choices", "answer", "shuffle")
143
+
144
+
145
+ def _unknown_keys(mapping: dict, accepted: tuple[str, ...]) -> list:
146
+ # Source order; keys may be non-strings (YAML 1.1 loads `on:` as True).
147
+ return [k for k in mapping if k not in accepted]
148
+
149
+
150
+ def _unknown_keys_message(what: str, keys: list, accepted: tuple[str, ...]) -> str:
151
+ noun = "unknown key" if len(keys) == 1 else "unknown keys"
152
+ return f"{what} has {noun} {', '.join(repr(k) for k in keys)} (accepted: {', '.join(accepted)})"
153
+
154
+
155
+ def _quiz_html(source: str, line: int, ctx: RenderContext) -> str:
156
+ try:
157
+ data = yaml.safe_load(source) or {}
158
+ except yaml.YAMLError as e:
159
+ raise BuildError(f"{ctx.where}:{line}: quiz block is not valid YAML: {e}") from e
160
+ if isinstance(data, dict):
161
+ unknown = _unknown_keys(data, _QUIZ_KEYS)
162
+ if unknown:
163
+ # #57 adds `shuffle` to _QUIZ_KEYS, which drops this hint; #57 and #66 add their keys there.
164
+ hint = "; 'shuffle' goes on each question" if "shuffle" in unknown else ""
165
+ raise BuildError(f"{ctx.where}:{line}: {_unknown_keys_message('quiz block', unknown, _QUIZ_KEYS)}{hint}")
166
+ questions = data.get("questions") if isinstance(data, dict) else None
167
+ if not isinstance(questions, list) or not questions:
168
+ raise BuildError(f"{ctx.where}:{line}: quiz block needs a non-empty 'questions' list")
169
+ # Browsers otherwise restore checked answers (Firefox after a reload, all three engines after Back; #46); the quiz
170
+ # forgets by design. Fixed here, not by form.reset() in app.js: restore timing may vary by engine (unmeasured).
171
+ parts = ['<section class="rv-quiz">', "<h2>Knowledge check</h2>", '<form autocomplete="off">']
172
+ for qi, q in enumerate(questions):
173
+ n = qi + 1
174
+ if isinstance(q, dict):
175
+ unknown = _unknown_keys(q, _QUESTION_KEYS)
176
+ if unknown:
177
+ raise BuildError(f"{ctx.where}:{line}: {_unknown_keys_message(f'quiz question {n}', unknown, _QUESTION_KEYS)}")
178
+ if not isinstance(q, dict) or not isinstance(q.get("q"), str):
179
+ raise BuildError(f"{ctx.where}:{line}: quiz question {n} needs a 'q' string")
180
+ choices = q.get("choices")
181
+ if not isinstance(choices, list) or len(choices) < 2:
182
+ raise BuildError(f"{ctx.where}:{line}: quiz question {n} needs at least two choices")
183
+ answer = q.get("answer")
184
+ if not isinstance(answer, int) or isinstance(answer, bool) or not 0 <= answer < len(choices):
185
+ raise BuildError(f"{ctx.where}:{line}: quiz question {n} answer must be an index into choices")
186
+ shuffle = q.get("shuffle", True)
187
+ if not isinstance(shuffle, bool):
188
+ raise BuildError(f"{ctx.where}:{line}: quiz question {n} shuffle must be true or false")
189
+ # `< 3` mirrors app.js (`labels.length < 3`); test_quiz_two_choice_threshold_matches_app_js pins it.
190
+ if q.get("shuffle") is True and len(choices) < 3:
191
+ ctx.report.warn(
192
+ f"{ctx.where}:{line}: quiz question {n} has two choices, which always keep their order; "
193
+ "shuffle: true has no effect"
194
+ )
195
+ fixed = "" if shuffle else " data-fixed-order"
196
+ parts.append(f'<fieldset class="rv-q" data-answer="{answer}"{fixed}><legend>{n}. {_quiz_text(q["q"])}</legend>')
197
+ for ci, choice in enumerate(choices):
198
+ parts.append(f'<label><input type="radio" name="q{qi}" value="{ci}"> {_quiz_text(str(choice))}</label>')
199
+ parts.append('<p class="rv-q-feedback" hidden></p></fieldset>')
200
+ parts += [
201
+ '<button type="submit">Check answers</button>',
202
+ '<p class="rv-quiz-score" hidden></p>',
203
+ "</form>",
204
+ "</section>",
205
+ ]
206
+ return "\n".join(parts) + "\n"
207
+
208
+
209
+ def render_markdown(body: str, ctx: RenderContext) -> RenderedLesson:
210
+ out = RenderedLesson(html="")
211
+ md = MarkdownIt("commonmark").enable("table")
212
+
213
+ # Stamp each code span with its line within the inline block, so the unlinked listing can
214
+ # report the span's own line (a span can sit several lines into a paragraph, or wrap).
215
+ def backticks(state, silent) -> bool:
216
+ pos, count = state.pos, len(state.tokens)
217
+ ok = backtick(state, silent)
218
+ if len(state.tokens) > count: # only a matched span pushes; push flushes pending text first
219
+ state.tokens[-1].meta["rv_line"] = state.src.count("\n", 0, pos)
220
+ return ok
221
+
222
+ md.inline.ruler.at("backticks", backticks)
223
+
224
+ def core_rewrite(state) -> None:
225
+ for block in state.tokens:
226
+ if block.type == "inline" and block.children:
227
+ line = _line(block, 1, ctx)
228
+ _rewrite_links(block.children, line, ctx, out)
229
+ # Top level only: alt-text tokens nest in image.children and render as plain text.
230
+ for child in block.children:
231
+ if child.type == "image":
232
+ child.map = block.map
233
+ elif child.type == "code_inline" and ctx.lookup(child.content) is None:
234
+ out.unlinked_code.append((line + child.meta.get("rv_line", 0), child.content))
235
+
236
+ md.core.ruler.push("rv_links", core_rewrite)
237
+
238
+ # add_render_rule binds each function as a method of the renderer, hence `self` first.
239
+ default_code_inline = md.renderer.rules.get("code_inline")
240
+ default_fence = md.renderer.rules.get("fence")
241
+ default_image = md.renderer.rules.get("image")
242
+
243
+ def code_inline(self, tokens, idx, options, env):
244
+ content = tokens[idx].content
245
+ in_link = tokens[idx].meta.get("rv_in_link")
246
+ if in_link == "text":
247
+ return escapeHtml(content)
248
+ code = default_code_inline(tokens, idx, options, env)
249
+ url = None if in_link else ctx.lookup(content)
250
+ if url is None:
251
+ return code
252
+ attrs = "".join(f' {k}="{escapeHtml(v)}"' for k, v in _vendor_attrs(url).items())
253
+ return f"<a{attrs}>{code}</a>"
254
+
255
+ def fence(self, tokens, idx, options, env):
256
+ tok = tokens[idx]
257
+ if tok.info.strip() == "quiz":
258
+ out.quiz_count += 1
259
+ return _quiz_html(tok.content, _line(tok, 1, ctx), ctx)
260
+ return default_fence(tokens, idx, options, env)
261
+
262
+ def image(self, tokens, idx, options, env):
263
+ tok = tokens[idx]
264
+ src = tok.attrGet("src") or ""
265
+ if src.endswith(".dot"):
266
+ stem = posixpath.splitext(posixpath.basename(src))[0]
267
+ line = _line(tok, 1, ctx)
268
+ if stem not in ctx.diagrams:
269
+ raise BuildError(f"{ctx.where}:{line}: diagram {stem!r} ({src}) not found")
270
+ if stem not in out.used_diagrams:
271
+ out.used_diagrams.append(stem)
272
+ alt = html.escape(tok.content)
273
+ return (
274
+ f'<figure class="rv-diagram" id="diagram-{stem}" data-diagram="{stem}">'
275
+ f"{ctx.diagrams[stem]}<figcaption>{alt}</figcaption></figure>"
276
+ )
277
+ return default_image(tokens, idx, options, env)
278
+
279
+ md.add_render_rule("code_inline", code_inline)
280
+ md.add_render_rule("fence", fence)
281
+ md.add_render_rule("image", image)
282
+
283
+ tokens = md.parse(body)
284
+ out.html = md.renderer.render(tokens, md.options, {})
285
+ return out
@@ -0,0 +1,276 @@
1
+ """Narration: authored segments -> synthesized audio + derived cue timings.
2
+
3
+ Authors write words, never timestamps. `narrate` derives start/end per segment from
4
+ the synthesized audio and records a hash of the words so `build` can detect drift.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import difflib
10
+ import hashlib
11
+ import io
12
+ import json
13
+ import os
14
+ import re
15
+ import shutil
16
+ import subprocess
17
+ import wave
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import Any, Protocol
21
+
22
+ import yaml
23
+
24
+ from revision_engine.config import DEFAULT_VOICE, _load_yaml
25
+ from revision_engine.errors import BuildError
26
+ from revision_engine.lesson import load_lesson
27
+
28
+ DEFAULT_PAUSE = 0.4
29
+ # A bare voice name: <voice>.onnx directly in the voices folder. Use only via VOICE_NAME.fullmatch.
30
+ VOICE_NAME = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.-]*")
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Segment:
35
+ text: str
36
+ show: tuple[str, ...]
37
+ diagram: str | None
38
+ node: str | None
39
+ pause: float
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Narration:
44
+ voice: str | None
45
+ segments: tuple[Segment, ...]
46
+
47
+
48
+ def load_narration(path: Path, where: str) -> Narration:
49
+ try:
50
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
51
+ except yaml.YAMLError as e:
52
+ raise BuildError(f"{where}: invalid YAML: {e}") from e
53
+ if not isinstance(data, dict):
54
+ raise BuildError(f"{where}: top level must be a mapping")
55
+ raw = data.get("segments")
56
+ if not isinstance(raw, list) or not raw:
57
+ raise BuildError(f"{where}: 'segments' must be a non-empty list")
58
+ voice = data.get("voice")
59
+ if voice is not None and not isinstance(voice, str):
60
+ raise BuildError(f"{where}: 'voice' must be a string")
61
+ segments: list[Segment] = []
62
+ for i, s in enumerate(raw, start=1):
63
+ if not isinstance(s, dict) or not isinstance(s.get("text"), str) or not s["text"].strip():
64
+ raise BuildError(f"{where}: segment {i} needs a 'text' string")
65
+ show = s.get("show", [])
66
+ if isinstance(show, str):
67
+ show = [show]
68
+ if not isinstance(show, list) or not all(isinstance(x, str) for x in show):
69
+ raise BuildError(f"{where}: segment {i}: 'show' must be an anchor name or a list of names")
70
+ for key in ("diagram", "node"):
71
+ if key in s and not isinstance(s[key], str):
72
+ raise BuildError(f"{where}: segment {i}: '{key}' must be a string")
73
+ pause = s.get("pause", DEFAULT_PAUSE)
74
+ if isinstance(pause, bool) or not isinstance(pause, (int, float)) or pause < 0:
75
+ raise BuildError(f"{where}: segment {i}: 'pause' must be a non-negative number of seconds")
76
+ segments.append(
77
+ Segment(
78
+ text=" ".join(s["text"].split()),
79
+ show=tuple(show),
80
+ diagram=s.get("diagram"),
81
+ node=s.get("node"),
82
+ pause=float(pause),
83
+ )
84
+ )
85
+ return Narration(voice=voice, segments=tuple(segments))
86
+
87
+
88
+ def text_hash(n: Narration) -> str:
89
+ payload = json.dumps([[s.text, s.pause] for s in n.segments], ensure_ascii=False, separators=(",", ":"))
90
+ return "sha256:" + hashlib.sha256(payload.encode("utf-8")).hexdigest()
91
+
92
+
93
+ class Synthesizer(Protocol):
94
+ def synthesize(self, text: str) -> tuple[int, bytes]:
95
+ """Return (sample_rate, 16-bit mono little-endian PCM) for one segment."""
96
+
97
+
98
+ class SilentSynthesizer:
99
+ """Deterministic stand-in: silence whose length grows with the text. For tests and fixtures."""
100
+
101
+ def __init__(self, rate: int = 8000, seconds_per_char: float = 0.05) -> None:
102
+ self.rate = rate
103
+ self.seconds_per_char = seconds_per_char
104
+
105
+ def synthesize(self, text: str) -> tuple[int, bytes]:
106
+ frames = int(round(len(text) * self.seconds_per_char * self.rate))
107
+ return self.rate, b"\x00\x00" * frames
108
+
109
+
110
+ class PiperSynthesizer:
111
+ """Runs the `piper` command with a voice model file; text on stdin, raw PCM on stdout."""
112
+
113
+ def __init__(self, model: Path) -> None:
114
+ if shutil.which("piper") is None:
115
+ raise BuildError("piper is not installed or not on PATH (pip install piper-tts)")
116
+ if not model.is_file():
117
+ raise BuildError(f"piper voice model not found: {model}")
118
+ self.model = model
119
+ self.rate = _piper_sample_rate(model)
120
+
121
+ def synthesize(self, text: str) -> tuple[int, bytes]:
122
+ proc = subprocess.run(
123
+ ["piper", "--model", str(self.model), "--output-raw"],
124
+ input=text.encode("utf-8"),
125
+ capture_output=True,
126
+ check=False,
127
+ )
128
+ if proc.returncode != 0:
129
+ raise BuildError(f"piper failed: {proc.stderr.decode('utf-8', 'replace').strip()}")
130
+ return self.rate, proc.stdout
131
+
132
+
133
+ def _piper_sample_rate(model: Path) -> int:
134
+ cfg = model.with_suffix(model.suffix + ".json")
135
+ if not cfg.is_file():
136
+ raise BuildError(f"piper voice config not found: {cfg}")
137
+ data = json.loads(cfg.read_text(encoding="utf-8"))
138
+ return int(data["audio"]["sample_rate"])
139
+
140
+
141
+ def _write_wav(path: Path, rate: int, pcm: bytes) -> None:
142
+ path.parent.mkdir(parents=True, exist_ok=True)
143
+ buf = io.BytesIO()
144
+ with wave.open(buf, "wb") as w:
145
+ w.setnchannels(1)
146
+ w.setsampwidth(2)
147
+ w.setframerate(rate)
148
+ w.writeframes(pcm)
149
+ path.write_bytes(buf.getvalue())
150
+
151
+
152
+ def narrate(lesson_dir: Path, synth: Synthesizer, voice: str, media_name: str = "narration.wav") -> dict:
153
+ where = f"{lesson_dir.name}/narration.yaml"
154
+ n = load_narration(lesson_dir / "narration.yaml", where)
155
+ rate: int | None = None
156
+ pcm = bytearray()
157
+ cues_segments: list[dict[str, Any]] = []
158
+ for seg in n.segments:
159
+ seg_rate, audio = synth.synthesize(seg.text)
160
+ if rate is None:
161
+ rate = seg_rate
162
+ elif seg_rate != rate:
163
+ raise BuildError(f"{where}: synthesizer changed sample rate mid-lesson ({rate} -> {seg_rate})")
164
+ pcm += b"\x00\x00" * int(round(seg.pause * rate))
165
+ start = len(pcm) / 2 / rate
166
+ pcm += audio
167
+ end = len(pcm) / 2 / rate
168
+ entry: dict[str, Any] = {"start": round(start, 3), "end": round(end, 3), "text": seg.text}
169
+ if seg.show:
170
+ entry["show"] = list(seg.show)
171
+ if seg.diagram:
172
+ entry["diagram"] = seg.diagram
173
+ if seg.node:
174
+ entry["node"] = seg.node
175
+ cues_segments.append(entry)
176
+ assert rate is not None
177
+ _write_wav(lesson_dir / "media" / media_name, rate, bytes(pcm))
178
+ cues = {
179
+ "media": f"media/{media_name}",
180
+ "voice": voice,
181
+ "text_hash": text_hash(n),
182
+ "segments": cues_segments,
183
+ }
184
+ (lesson_dir / "cues.json").write_text(
185
+ json.dumps(cues, indent=2, sort_keys=True) + "\n", encoding="utf-8", newline="\n"
186
+ )
187
+ return cues
188
+
189
+
190
+ def load_cues(path: Path, where: str) -> dict:
191
+ try:
192
+ data = json.loads(path.read_text(encoding="utf-8"))
193
+ except json.JSONDecodeError as e:
194
+ raise BuildError(f"{where}: invalid JSON: {e}") from e
195
+ if not isinstance(data, dict):
196
+ raise BuildError(f"{where}: top level must be an object")
197
+ for key in ("media", "text_hash"):
198
+ if not isinstance(data.get(key), str):
199
+ raise BuildError(f"{where}: '{key}' must be a string")
200
+ if not isinstance(data.get("segments"), list):
201
+ raise BuildError(f"{where}: 'segments' must be a list")
202
+ for i, s in enumerate(data["segments"], start=1):
203
+ if not isinstance(s, dict) or not all(isinstance(s.get(k), (int, float)) for k in ("start", "end")):
204
+ raise BuildError(f"{where}: segment {i} needs numeric 'start' and 'end'")
205
+ if not isinstance(s.get("text"), str):
206
+ raise BuildError(f"{where}: segment {i} needs a 'text' string")
207
+ return data
208
+
209
+
210
+ def check_cues(n: Narration, cues: dict, where: str) -> None:
211
+ if cues.get("text_hash") == text_hash(n):
212
+ return
213
+ old = [s["text"] for s in cues.get("segments", [])]
214
+ new = [s.text for s in n.segments]
215
+ diff = "\n".join(difflib.unified_diff(old, new, "cues.json", "narration.yaml", lineterm="", n=1))
216
+ raise BuildError(
217
+ f"{where}: narration.yaml changed since cues.json was generated; "
218
+ f"rerun 'rv2 narrate' for this lesson.\n{diff}"
219
+ )
220
+
221
+
222
+ def _project_voice(lesson_dir: Path) -> tuple[str, Path] | None:
223
+ """The ``narration.voice`` of the first revision.yaml found walking up from the lesson,
224
+ with the path of that revision.yaml."""
225
+ resolved = lesson_dir.resolve()
226
+ for parent in [resolved, *resolved.parents]:
227
+ cfg = parent / "revision.yaml"
228
+ if cfg.is_file():
229
+ data = _load_yaml(cfg)
230
+ narration = data.get("narration") if isinstance(data, dict) else None
231
+ voice = narration.get("voice") if isinstance(narration, dict) else None
232
+ return (voice, cfg) if isinstance(voice, str) else None
233
+ return None
234
+
235
+
236
+ def resolve_voice_model(lesson_dir: Path, cli_voice: str | None, cli_model: Path | None) -> tuple[str, Path]:
237
+ """Pick the voice name and Piper model for ``rv2 narrate``.
238
+
239
+ Name: --voice, then narration.yaml's ``voice``, then the lesson's front matter, then the
240
+ project's narration.voice, then the default. Model: --model if given, else ``<voice>.onnx``
241
+ in $RV2_VOICES or ~/piper-voices. The returned name is what cues.json records.
242
+
243
+ The name must be a bare voice name — ASCII letters, digits, ``_``, ``-`` and ``.``, not
244
+ starting with ``-`` or ``.`` (VOICE_NAME); anything else is refused before it is joined into
245
+ a path.
246
+ """
247
+ if cli_model is not None:
248
+ if not cli_model.is_file():
249
+ raise BuildError(f"piper voice model not found: {cli_model}")
250
+ return cli_voice or cli_model.name.removesuffix(".onnx"), cli_model
251
+ voice, where = cli_voice, "--voice"
252
+ if voice is None and (lesson_dir / "narration.yaml").is_file():
253
+ where = f"{lesson_dir.name}/narration.yaml"
254
+ voice = load_narration(lesson_dir / "narration.yaml", where).voice
255
+ if voice is None and (lesson_dir / "lesson.md").is_file():
256
+ where = f"{lesson_dir.name}/lesson.md"
257
+ voice = load_lesson(lesson_dir / "lesson.md", where).voice
258
+ if voice is None and (project := _project_voice(lesson_dir)) is not None:
259
+ voice, cfg = project
260
+ where = f"{cfg} (narration.voice)"
261
+ if voice is None:
262
+ voice, where = DEFAULT_VOICE, "default voice"
263
+ if not VOICE_NAME.fullmatch(voice):
264
+ raise BuildError(
265
+ f"{where}: voice {voice!r} is not a voice name: use only ASCII letters, digits, '_', '-' "
266
+ "and '.', not starting with '-' or '.'. Put <voice>.onnx directly in $RV2_VOICES (or "
267
+ "~/piper-voices), or pass --model to use a model file elsewhere"
268
+ )
269
+ voices_dir = Path(os.environ.get("RV2_VOICES") or Path.home() / "piper-voices")
270
+ model = voices_dir / f"{voice}.onnx"
271
+ if not model.is_file():
272
+ raise BuildError(
273
+ f"voice {voice!r}: no model at {model}. Download <voice>.onnx and .onnx.json from "
274
+ "https://huggingface.co/rhasspy/piper-voices into that folder, or pass --model."
275
+ )
276
+ return voice, model
@@ -0,0 +1,119 @@
1
+ """Turn per-lesson build products into HTML pages, hashed assets, and site.json."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from dataclasses import dataclass
8
+ from importlib import resources
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from jinja2 import Environment, PackageLoader, select_autoescape
13
+
14
+ from revision_engine.config import Curriculum, ProjectConfig
15
+
16
+ Assets = dict[str, str]
17
+
18
+ _env = Environment(
19
+ loader=PackageLoader("revision_engine", "templates"),
20
+ autoescape=select_autoescape(["html", "j2"]),
21
+ keep_trailing_newline=True,
22
+ )
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class NavLesson:
27
+ slug: str
28
+ title: str
29
+ href: str
30
+ current: bool
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class NavChapter:
35
+ slug: str
36
+ title: str
37
+ lessons: tuple[NavLesson, ...]
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Variant:
42
+ name: str
43
+ label: str
44
+ start: int
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class PageModel:
49
+ site_title: str
50
+ root: str
51
+ assets: Assets
52
+ chapter_title: str
53
+ lesson_title: str
54
+ summary: str
55
+ nav: tuple[NavChapter, ...]
56
+ prev_href: str | None
57
+ next_href: str | None
58
+ file_path: str
59
+ commit_short: str
60
+ file_url: str
61
+ code_html: str
62
+ variants: tuple[Variant, ...]
63
+ lesson_html: str
64
+ media_url: str | None
65
+ media_kind: str | None
66
+ cues: list[dict[str, Any]] | None
67
+ rv_data: dict[str, Any]
68
+
69
+
70
+ def _hashed_name(name: str, content: bytes) -> str:
71
+ stem, ext = name.rsplit(".", 1)
72
+ return f"{stem}.{hashlib.sha256(content).hexdigest()[:8]}.{ext}"
73
+
74
+
75
+ def write_assets(out: Path, token_css: str) -> Assets:
76
+ assets_dir = out / "assets"
77
+ assets_dir.mkdir(parents=True, exist_ok=True)
78
+ static = resources.files("revision_engine").joinpath("static")
79
+ files = {
80
+ "styles.css": static.joinpath("styles.css").read_bytes(),
81
+ "app.js": static.joinpath("app.js").read_bytes(),
82
+ "tokens.css": token_css.encode("utf-8"),
83
+ }
84
+ result: Assets = {}
85
+ for name, content in files.items():
86
+ hashed = _hashed_name(name, content)
87
+ (assets_dir / hashed).write_bytes(content)
88
+ result[name] = f"assets/{hashed}"
89
+ return result
90
+
91
+
92
+ def json_for_script(data: Any) -> str:
93
+ """JSON safe to embed inside <script type="application/json">."""
94
+ return json.dumps(data, sort_keys=True, separators=(",", ":")).replace("</", "<\\/")
95
+
96
+
97
+ def render_page(model: PageModel) -> str:
98
+ template = _env.get_template("lesson.html.j2")
99
+ return template.render(m=model, rv_data_json=json_for_script(model.rv_data))
100
+
101
+
102
+ def render_redirect(target_href: str, title: str) -> str:
103
+ return _env.get_template("redirect.html.j2").render(target=target_href, title=title)
104
+
105
+
106
+ def site_index(cfg: ProjectConfig, curriculum: Curriculum) -> dict:
107
+ return {
108
+ "title": cfg.site.title,
109
+ "chapters": [
110
+ {
111
+ "slug": ch.slug,
112
+ "title": ch.title,
113
+ "lessons": [
114
+ {"slug": le.slug, "title": le.title, "href": f"{ch.slug}/{le.slug}/"} for le in ch.lessons
115
+ ],
116
+ }
117
+ for ch in curriculum.chapters
118
+ ],
119
+ }