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.
- revision_engine/__init__.py +3 -0
- revision_engine/anchors.py +163 -0
- revision_engine/build.py +268 -0
- revision_engine/cli.py +159 -0
- revision_engine/config.py +159 -0
- revision_engine/diagrams.py +108 -0
- revision_engine/errors.py +18 -0
- revision_engine/highlight.py +62 -0
- revision_engine/intrinsics/__init__.py +34 -0
- revision_engine/intrinsics/arm.yaml +22 -0
- revision_engine/intrinsics/intel.yaml +42 -0
- revision_engine/intrinsics/riscv.yaml +28 -0
- revision_engine/lesson.py +64 -0
- revision_engine/markdown_ext.py +285 -0
- revision_engine/narration.py +276 -0
- revision_engine/render.py +119 -0
- revision_engine/serve.py +153 -0
- revision_engine/sources.py +61 -0
- revision_engine/static/app.js +292 -0
- revision_engine/static/styles.css +160 -0
- revision_engine/templates/lesson.html.j2 +89 -0
- revision_engine/templates/redirect.html.j2 +5 -0
- revision_engine-0.2.2.dist-info/METADATA +134 -0
- revision_engine-0.2.2.dist-info/RECORD +27 -0
- revision_engine-0.2.2.dist-info/WHEEL +4 -0
- revision_engine-0.2.2.dist-info/entry_points.txt +3 -0
- revision_engine-0.2.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Named code regions: load from anchors.yaml and resolve to concrete line ranges."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from revision_engine.config import SLUG_RE
|
|
13
|
+
from revision_engine.errors import BuildError
|
|
14
|
+
from revision_engine.sources import SourceFile
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class AnchorSpec:
|
|
19
|
+
name: str
|
|
20
|
+
file: str
|
|
21
|
+
from_: str | None
|
|
22
|
+
to: str | None
|
|
23
|
+
match: str | None
|
|
24
|
+
lines: tuple[int, int] | None
|
|
25
|
+
variant: bool
|
|
26
|
+
label: str
|
|
27
|
+
within: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Anchor:
|
|
32
|
+
name: str
|
|
33
|
+
file: str
|
|
34
|
+
start: int
|
|
35
|
+
end: int
|
|
36
|
+
variant: bool
|
|
37
|
+
label: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_lines(value: Any, name: str, where: str) -> tuple[int, int]:
|
|
41
|
+
if isinstance(value, str):
|
|
42
|
+
m = re.fullmatch(r"\s*(\d+)\s*-\s*(\d+)\s*", value)
|
|
43
|
+
if not m:
|
|
44
|
+
raise BuildError(f"{where}: anchor {name!r}: lines must look like '88-92'")
|
|
45
|
+
a, b = int(m.group(1)), int(m.group(2))
|
|
46
|
+
elif isinstance(value, list) and len(value) == 2 and all(isinstance(v, int) for v in value):
|
|
47
|
+
a, b = value
|
|
48
|
+
elif isinstance(value, int):
|
|
49
|
+
a = b = value
|
|
50
|
+
else:
|
|
51
|
+
raise BuildError(f"{where}: anchor {name!r}: lines must look like '88-92'")
|
|
52
|
+
if a < 1 or b < a:
|
|
53
|
+
raise BuildError(f"{where}: anchor {name!r}: lines {a}-{b} is not a valid range")
|
|
54
|
+
return (a, b)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_anchors(path: Path, default_file: str, where: str) -> dict[str, AnchorSpec]:
|
|
58
|
+
if not path.is_file():
|
|
59
|
+
return {}
|
|
60
|
+
try:
|
|
61
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
62
|
+
except yaml.YAMLError as e:
|
|
63
|
+
raise BuildError(f"{where}: invalid YAML: {e}") from e
|
|
64
|
+
if not isinstance(data, dict):
|
|
65
|
+
raise BuildError(f"{where}: top level must be a mapping of anchor name to definition")
|
|
66
|
+
|
|
67
|
+
specs: dict[str, AnchorSpec] = {}
|
|
68
|
+
for name, body in data.items():
|
|
69
|
+
if not isinstance(name, str) or not SLUG_RE.match(name):
|
|
70
|
+
raise BuildError(f"{where}: {name!r} is not a slug (lowercase letters, digits, hyphens)")
|
|
71
|
+
if not isinstance(body, dict):
|
|
72
|
+
raise BuildError(f"{where}: anchor {name!r} must be a mapping")
|
|
73
|
+
forms = [k for k in ("from", "match", "lines") if k in body]
|
|
74
|
+
if len(forms) != 1 or ("to" in body and "from" not in body):
|
|
75
|
+
raise BuildError(f"{where}: anchor {name!r} must use exactly one of from/to, match, lines")
|
|
76
|
+
if "from" in body and "to" not in body:
|
|
77
|
+
raise BuildError(f"{where}: anchor {name!r} has 'from' but no 'to'")
|
|
78
|
+
for key in ("from", "to", "match", "file", "label", "within"):
|
|
79
|
+
if key in body and not isinstance(body[key], str):
|
|
80
|
+
raise BuildError(f"{where}: anchor {name!r}: {key} must be a string")
|
|
81
|
+
if "to" in body:
|
|
82
|
+
try:
|
|
83
|
+
re.compile(body["to"])
|
|
84
|
+
except re.error as e:
|
|
85
|
+
raise BuildError(f"{where}: anchor {name!r}: 'to' is not a valid regex: {e}") from e
|
|
86
|
+
variant = body.get("variant", False)
|
|
87
|
+
if not isinstance(variant, bool):
|
|
88
|
+
raise BuildError(f"{where}: anchor {name!r}: variant must be true or false")
|
|
89
|
+
specs[name] = AnchorSpec(
|
|
90
|
+
name=name,
|
|
91
|
+
file=body.get("file", default_file).replace(chr(92), "/"),
|
|
92
|
+
from_=body.get("from"),
|
|
93
|
+
to=body.get("to"),
|
|
94
|
+
match=body.get("match"),
|
|
95
|
+
lines=_parse_lines(body["lines"], name, where) if "lines" in body else None,
|
|
96
|
+
variant=variant,
|
|
97
|
+
label=body.get("label", name),
|
|
98
|
+
within=body.get("within"),
|
|
99
|
+
)
|
|
100
|
+
return specs
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _scope(spec: AnchorSpec) -> str:
|
|
104
|
+
return f" within {spec.within!r}" if spec.within else ""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _single_line(needle: str, source: SourceFile, spec: AnchorSpec, what: str, bounds: tuple[int, int]) -> int:
|
|
108
|
+
lo, hi = bounds
|
|
109
|
+
hits = [i + 1 for i in range(lo - 1, hi) if needle in source.lines[i]]
|
|
110
|
+
if not hits:
|
|
111
|
+
raise BuildError(f"anchor {spec.name!r} in {source.path}: no line contains {needle!r}{_scope(spec)} ({what})")
|
|
112
|
+
if len(hits) > 1:
|
|
113
|
+
listing = "; ".join(f"line {n}: {source.lines[n - 1].strip()}" for n in hits)
|
|
114
|
+
raise BuildError(
|
|
115
|
+
f"anchor {spec.name!r} in {source.path}: {what} {needle!r} matches {len(hits)} lines{_scope(spec)}, "
|
|
116
|
+
f"need exactly one: {listing}"
|
|
117
|
+
)
|
|
118
|
+
return hits[0]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def resolve(spec: AnchorSpec, source: SourceFile, bounds: tuple[int, int] | None = None) -> Anchor:
|
|
122
|
+
"""Resolve one spec; ``bounds`` (1-based, inclusive) limits the search to an enclosing anchor."""
|
|
123
|
+
n = len(source.lines)
|
|
124
|
+
lo, hi = bounds or (1, n)
|
|
125
|
+
if spec.lines is not None:
|
|
126
|
+
start, end = spec.lines
|
|
127
|
+
if end > n:
|
|
128
|
+
raise BuildError(f"anchor {spec.name!r} in {source.path}: lines {start}-{end} exceed file length {n}")
|
|
129
|
+
if bounds and (start < lo or end > hi):
|
|
130
|
+
raise BuildError(
|
|
131
|
+
f"anchor {spec.name!r} in {source.path}: lines {start}-{end} fall outside {spec.within!r} ({lo}-{hi})"
|
|
132
|
+
)
|
|
133
|
+
elif spec.match is not None:
|
|
134
|
+
start = end = _single_line(spec.match, source, spec, "match", (lo, hi))
|
|
135
|
+
else:
|
|
136
|
+
assert spec.from_ is not None and spec.to is not None
|
|
137
|
+
start = _single_line(spec.from_, source, spec, "from", (lo, hi))
|
|
138
|
+
pattern = re.compile(spec.to)
|
|
139
|
+
end = next((i + 1 for i in range(start, hi) if pattern.search(source.lines[i])), None)
|
|
140
|
+
if end is None:
|
|
141
|
+
raise BuildError(
|
|
142
|
+
f"anchor {spec.name!r} in {source.path}: no line after line {start}{_scope(spec)} matches to={spec.to!r}"
|
|
143
|
+
)
|
|
144
|
+
return Anchor(name=spec.name, file=source.path, start=start, end=end, variant=spec.variant, label=spec.label)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def resolve_all(specs: dict[str, AnchorSpec], sources: dict[str, SourceFile]) -> dict[str, Anchor]:
|
|
148
|
+
out: dict[str, Anchor] = {}
|
|
149
|
+
for name, spec in specs.items():
|
|
150
|
+
if spec.file not in sources:
|
|
151
|
+
raise BuildError(f"anchor {name!r} refers to file {spec.file!r} which is not loaded")
|
|
152
|
+
bounds = None
|
|
153
|
+
if spec.within is not None:
|
|
154
|
+
if spec.within not in out:
|
|
155
|
+
raise BuildError(
|
|
156
|
+
f"anchor {name!r} is within {spec.within!r}, which is not defined earlier in anchors.yaml"
|
|
157
|
+
)
|
|
158
|
+
outer = out[spec.within]
|
|
159
|
+
if outer.file != spec.file:
|
|
160
|
+
raise BuildError(f"anchor {name!r} is within {spec.within!r} but they name different files")
|
|
161
|
+
bounds = (outer.start, outer.end)
|
|
162
|
+
out[name] = resolve(spec, sources[spec.file], bounds)
|
|
163
|
+
return out
|
revision_engine/build.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Orchestrate the seven build stages into a static site. Pure: same tree, same bytes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from revision_engine.anchors import Anchor, load_anchors, resolve_all
|
|
13
|
+
from revision_engine.config import (
|
|
14
|
+
Chapter,
|
|
15
|
+
Curriculum,
|
|
16
|
+
LessonRef,
|
|
17
|
+
ProjectConfig,
|
|
18
|
+
load_curriculum,
|
|
19
|
+
load_project_config,
|
|
20
|
+
)
|
|
21
|
+
from revision_engine.diagrams import render_dot, stamp_anchors, unlink_anchors
|
|
22
|
+
from revision_engine.errors import BuildError, Report
|
|
23
|
+
from revision_engine.highlight import highlight_source, token_css
|
|
24
|
+
from revision_engine.intrinsics import lookup
|
|
25
|
+
from revision_engine.lesson import LessonDoc, load_lesson
|
|
26
|
+
from revision_engine.markdown_ext import RenderContext, render_markdown
|
|
27
|
+
from revision_engine.narration import check_cues, load_cues, load_narration
|
|
28
|
+
from revision_engine.render import (
|
|
29
|
+
NavChapter,
|
|
30
|
+
NavLesson,
|
|
31
|
+
PageModel,
|
|
32
|
+
Variant,
|
|
33
|
+
render_page,
|
|
34
|
+
render_redirect,
|
|
35
|
+
site_index,
|
|
36
|
+
write_assets,
|
|
37
|
+
)
|
|
38
|
+
from revision_engine.sources import file_url, git_commit, read_source, short
|
|
39
|
+
|
|
40
|
+
VIDEO_SUFFIXES = (".mp4", ".webm", ".ogv")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class BuildResult:
|
|
45
|
+
output: Path
|
|
46
|
+
pages: list[Path]
|
|
47
|
+
report: Report
|
|
48
|
+
commit: str
|
|
49
|
+
unlinked: list[tuple[str, int, str]] = field(default_factory=list) # (lesson.md path, line, span text)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class _LessonBuild:
|
|
54
|
+
ref: LessonRef
|
|
55
|
+
chapter: Chapter
|
|
56
|
+
doc: LessonDoc
|
|
57
|
+
where: str
|
|
58
|
+
anchors: dict[str, Anchor]
|
|
59
|
+
code_html: str
|
|
60
|
+
lesson_html: str
|
|
61
|
+
media: str | None
|
|
62
|
+
cues: list[dict[str, Any]] | None
|
|
63
|
+
unlinked: list[tuple[str, int, str]]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _rel(cfg: ProjectConfig, p: Path) -> str:
|
|
67
|
+
return p.relative_to(cfg.root).as_posix()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _check_output(cfg: ProjectConfig, output: Path) -> None:
|
|
71
|
+
"""Refuse to wipe the repository itself or any folder above it."""
|
|
72
|
+
output = output.resolve()
|
|
73
|
+
if output == cfg.root or output in cfg.root.parents:
|
|
74
|
+
raise BuildError("output folder must be inside the repository and not the repository itself")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _build_lesson(cfg: ProjectConfig, chapter: Chapter, ref: LessonRef, report: Report) -> _LessonBuild:
|
|
78
|
+
where = _rel(cfg, ref.path)
|
|
79
|
+
doc = load_lesson(ref.path / "lesson.md", f"{where}/lesson.md")
|
|
80
|
+
|
|
81
|
+
specs = load_anchors(ref.path / "anchors.yaml", doc.file, f"{where}/anchors.yaml")
|
|
82
|
+
files = sorted({doc.file} | {s.file for s in specs.values()})
|
|
83
|
+
sources = {f: read_source(cfg.root, f) for f in files}
|
|
84
|
+
anchors = resolve_all(specs, sources)
|
|
85
|
+
# Version one shows one file per lesson. Every consumer of the anchor set (fragments,
|
|
86
|
+
# variants, cues, diagram nodes) would otherwise box foreign line numbers onto this file.
|
|
87
|
+
for name, a in anchors.items():
|
|
88
|
+
if a.file != doc.file:
|
|
89
|
+
raise BuildError(
|
|
90
|
+
f"{where}/anchors.yaml: anchor {name!r} resolves in {a.file!r} but this lesson shows {doc.file!r}"
|
|
91
|
+
)
|
|
92
|
+
if doc.start is not None and doc.start not in anchors:
|
|
93
|
+
raise BuildError(f"{where}/lesson.md: start anchor {doc.start!r} is not defined in anchors.yaml")
|
|
94
|
+
|
|
95
|
+
diagrams: dict[str, str] = {}
|
|
96
|
+
diagrams_dir = ref.path / "diagrams"
|
|
97
|
+
if diagrams_dir.is_dir():
|
|
98
|
+
for dot in sorted(diagrams_dir.glob("*.dot")):
|
|
99
|
+
dwhere = f"{where}/diagrams/{dot.name}"
|
|
100
|
+
svg, unlinked = unlink_anchors(stamp_anchors(render_dot(dot, dwhere), set(anchors)))
|
|
101
|
+
for node in unlinked:
|
|
102
|
+
report.warn(f"{dwhere}: node {node!r} is an anchor; its link is removed")
|
|
103
|
+
diagrams[dot.stem] = svg
|
|
104
|
+
|
|
105
|
+
ctx = RenderContext(
|
|
106
|
+
anchors={name: a.file for name, a in anchors.items()},
|
|
107
|
+
diagrams=diagrams,
|
|
108
|
+
lookup=lookup,
|
|
109
|
+
report=report,
|
|
110
|
+
where=f"{where}/lesson.md",
|
|
111
|
+
lesson_file=doc.file,
|
|
112
|
+
line_offset=doc.body_line - 1,
|
|
113
|
+
)
|
|
114
|
+
rendered = render_markdown(doc.body, ctx)
|
|
115
|
+
|
|
116
|
+
media: str | None = None
|
|
117
|
+
cues: list[dict[str, Any]] | None = None
|
|
118
|
+
narration_path = ref.path / "narration.yaml"
|
|
119
|
+
cues_path = ref.path / "cues.json"
|
|
120
|
+
if narration_path.is_file():
|
|
121
|
+
if not cues_path.is_file():
|
|
122
|
+
raise BuildError(f"{where}: narration.yaml present but cues.json missing; run 'rv2 narrate {where}'")
|
|
123
|
+
narration = load_narration(narration_path, f"{where}/narration.yaml")
|
|
124
|
+
data = load_cues(cues_path, f"{where}/cues.json")
|
|
125
|
+
check_cues(narration, data, where)
|
|
126
|
+
media = data["media"]
|
|
127
|
+
cues = data["segments"]
|
|
128
|
+
elif doc.media:
|
|
129
|
+
media = doc.media
|
|
130
|
+
if cues_path.is_file():
|
|
131
|
+
cues = load_cues(cues_path, f"{where}/cues.json")["segments"]
|
|
132
|
+
|
|
133
|
+
if media is not None:
|
|
134
|
+
media_path = (ref.path / media).resolve()
|
|
135
|
+
lesson_root = ref.path.resolve()
|
|
136
|
+
if Path(media).is_absolute() or lesson_root not in media_path.parents:
|
|
137
|
+
raise BuildError(f"{where}: media path {media!r} must be a relative path inside the lesson folder")
|
|
138
|
+
if not media_path.is_file():
|
|
139
|
+
raise BuildError(f"{where}: media file {media!r} not found")
|
|
140
|
+
for i, seg in enumerate(cues or [], start=1):
|
|
141
|
+
for name in seg.get("show", []):
|
|
142
|
+
if name not in anchors:
|
|
143
|
+
raise BuildError(f"{where}/cues.json: segment {i} shows unknown anchor {name!r}")
|
|
144
|
+
if "diagram" in seg and seg["diagram"] not in diagrams:
|
|
145
|
+
raise BuildError(f"{where}/cues.json: segment {i} refers to unknown diagram {seg['diagram']!r}")
|
|
146
|
+
if "node" in seg:
|
|
147
|
+
svg = diagrams.get(seg.get("diagram", ""), "")
|
|
148
|
+
if f'data-node="{seg["node"]}"' not in svg:
|
|
149
|
+
raise BuildError(
|
|
150
|
+
f"{where}/cues.json: segment {i} refers to node {seg['node']!r} "
|
|
151
|
+
f"which is not in diagram {seg.get('diagram')!r}"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return _LessonBuild(
|
|
155
|
+
ref=ref,
|
|
156
|
+
chapter=chapter,
|
|
157
|
+
doc=doc,
|
|
158
|
+
where=where,
|
|
159
|
+
anchors=anchors,
|
|
160
|
+
code_html=highlight_source(sources[doc.file]),
|
|
161
|
+
lesson_html=rendered.html,
|
|
162
|
+
media=media,
|
|
163
|
+
cues=cues,
|
|
164
|
+
unlinked=[(ctx.where, line, text) for line, text in rendered.unlinked_code],
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _nav(curriculum: Curriculum, current: LessonRef) -> tuple[NavChapter, ...]:
|
|
169
|
+
return tuple(
|
|
170
|
+
NavChapter(
|
|
171
|
+
slug=ch.slug,
|
|
172
|
+
title=ch.title,
|
|
173
|
+
lessons=tuple(
|
|
174
|
+
NavLesson(le.slug, le.title, f"{ch.slug}/{le.slug}/", le is current) for le in ch.lessons
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
for ch in curriculum.chapters
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _media_kind(media: str | None) -> str | None:
|
|
182
|
+
if media is None:
|
|
183
|
+
return None
|
|
184
|
+
return "video" if media.lower().endswith(VIDEO_SUFFIXES) else "audio"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def build(
|
|
188
|
+
root: Path, output: Path | None = None, commit: str | None = None, check_only: bool = False
|
|
189
|
+
) -> BuildResult:
|
|
190
|
+
cfg = load_project_config(root)
|
|
191
|
+
curriculum = load_curriculum(cfg)
|
|
192
|
+
output = (output or cfg.output).resolve()
|
|
193
|
+
_check_output(cfg, output)
|
|
194
|
+
|
|
195
|
+
commit = commit or git_commit(cfg.root)
|
|
196
|
+
if commit is None:
|
|
197
|
+
raise BuildError("not a git repository; pass --commit <sha> to identify the source revision")
|
|
198
|
+
if not re.fullmatch(r"[0-9a-f]{40}", commit):
|
|
199
|
+
raise BuildError(f"commit must be a 40-character hex sha (got {commit!r})")
|
|
200
|
+
|
|
201
|
+
report = Report()
|
|
202
|
+
flat = [(ch, le) for ch in curriculum.chapters for le in ch.lessons]
|
|
203
|
+
built = [_build_lesson(cfg, ch, le, report) for ch, le in flat]
|
|
204
|
+
unlinked = [u for lb in built for u in lb.unlinked]
|
|
205
|
+
|
|
206
|
+
if check_only:
|
|
207
|
+
return BuildResult(output=output, pages=[], report=report, commit=commit, unlinked=unlinked)
|
|
208
|
+
|
|
209
|
+
if output.exists():
|
|
210
|
+
if any(output.iterdir()) and not (output / "site.json").is_file():
|
|
211
|
+
raise BuildError(
|
|
212
|
+
f"output folder {output} exists and was not written by rv2 (no site.json); "
|
|
213
|
+
"delete it yourself or choose another --output"
|
|
214
|
+
)
|
|
215
|
+
shutil.rmtree(output)
|
|
216
|
+
output.mkdir(parents=True)
|
|
217
|
+
assets = write_assets(output, token_css())
|
|
218
|
+
|
|
219
|
+
pages: list[Path] = []
|
|
220
|
+
for i, lb in enumerate(built):
|
|
221
|
+
ch, le = lb.chapter, lb.ref
|
|
222
|
+
prev_href = f"{flat[i - 1][0].slug}/{flat[i - 1][1].slug}/" if i > 0 else None
|
|
223
|
+
next_href = f"{flat[i + 1][0].slug}/{flat[i + 1][1].slug}/" if i + 1 < len(flat) else None
|
|
224
|
+
variants = tuple(Variant(a.name, a.label, a.start) for a in lb.anchors.values() if a.variant)
|
|
225
|
+
rv_data = {
|
|
226
|
+
"anchors": {name: {"start": a.start, "end": a.end} for name, a in sorted(lb.anchors.items())},
|
|
227
|
+
"start": lb.doc.start,
|
|
228
|
+
"cues": lb.cues,
|
|
229
|
+
"variants": [{"name": v.name, "label": v.label, "start": v.start} for v in variants],
|
|
230
|
+
}
|
|
231
|
+
model = PageModel(
|
|
232
|
+
site_title=cfg.site.title,
|
|
233
|
+
root="../../",
|
|
234
|
+
assets=assets,
|
|
235
|
+
chapter_title=ch.title,
|
|
236
|
+
lesson_title=lb.doc.title,
|
|
237
|
+
summary=lb.doc.summary,
|
|
238
|
+
nav=_nav(curriculum, le),
|
|
239
|
+
prev_href=prev_href,
|
|
240
|
+
next_href=next_href,
|
|
241
|
+
file_path=lb.doc.file,
|
|
242
|
+
commit_short=short(commit),
|
|
243
|
+
file_url=file_url(cfg.repo, commit, lb.doc.file),
|
|
244
|
+
code_html=lb.code_html,
|
|
245
|
+
variants=variants,
|
|
246
|
+
lesson_html=lb.lesson_html,
|
|
247
|
+
media_url=lb.media,
|
|
248
|
+
media_kind=_media_kind(lb.media),
|
|
249
|
+
cues=lb.cues,
|
|
250
|
+
rv_data=rv_data,
|
|
251
|
+
)
|
|
252
|
+
page_dir = output / ch.slug / le.slug
|
|
253
|
+
page_dir.mkdir(parents=True)
|
|
254
|
+
page = page_dir / "index.html"
|
|
255
|
+
page.write_text(render_page(model), encoding="utf-8", newline="\n")
|
|
256
|
+
pages.append(page)
|
|
257
|
+
if lb.media:
|
|
258
|
+
dst = page_dir / lb.media
|
|
259
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
260
|
+
shutil.copyfile(le.path / lb.media, dst)
|
|
261
|
+
|
|
262
|
+
(output / "site.json").write_text(
|
|
263
|
+
json.dumps(site_index(cfg, curriculum), indent=2, sort_keys=True) + "\n", encoding="utf-8", newline="\n"
|
|
264
|
+
)
|
|
265
|
+
if flat:
|
|
266
|
+
first = f"{flat[0][0].slug}/{flat[0][1].slug}/"
|
|
267
|
+
(output / "index.html").write_text(render_redirect(first, cfg.site.title), encoding="utf-8", newline="\n")
|
|
268
|
+
return BuildResult(output=output, pages=pages, report=report, commit=commit, unlinked=unlinked)
|
revision_engine/cli.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Command-line entry point: rv2 build | check | serve | narrate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from revision_engine import __version__
|
|
10
|
+
from revision_engine.errors import BuildError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _parser() -> argparse.ArgumentParser:
|
|
14
|
+
p = argparse.ArgumentParser(prog="rv2", description="reVision static-site builder. Revision control, revisited.")
|
|
15
|
+
p.add_argument("--version", action="version", version=f"rv2 {__version__}")
|
|
16
|
+
sub = p.add_subparsers(dest="command", metavar="command")
|
|
17
|
+
|
|
18
|
+
def common(sp: argparse.ArgumentParser) -> None:
|
|
19
|
+
sp.add_argument("--root", type=Path, default=Path("."), help="repository root holding revision.yaml (default: .)")
|
|
20
|
+
sp.add_argument("--commit", help="40-char sha to label the build when not in a git checkout")
|
|
21
|
+
|
|
22
|
+
b = sub.add_parser("build", help="build the static site into the output folder")
|
|
23
|
+
common(b)
|
|
24
|
+
b.add_argument("--output", type=Path, help="override the output folder from revision.yaml")
|
|
25
|
+
|
|
26
|
+
c = sub.add_parser("check", help="run every build check without writing output")
|
|
27
|
+
common(c)
|
|
28
|
+
c.add_argument("--strict", action="store_true", help="exit 1 if the check reports any warning")
|
|
29
|
+
c.add_argument(
|
|
30
|
+
"--list-unlinked", action="store_true", help="list inline code spans that match no vendor intrinsic table"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
s = sub.add_parser("serve", help="build, serve on localhost, rebuild on change")
|
|
34
|
+
common(s)
|
|
35
|
+
s.add_argument("--port", type=int, default=8000)
|
|
36
|
+
|
|
37
|
+
n = sub.add_parser("narrate", help="synthesize a lesson's narration.yaml into audio + cues.json")
|
|
38
|
+
n.add_argument("lesson_dir", type=Path)
|
|
39
|
+
n.add_argument("--model", type=Path, help="path to a Piper voice model (.onnx); default: <voice>.onnx in $RV2_VOICES or ~/piper-voices")
|
|
40
|
+
n.add_argument(
|
|
41
|
+
"--voice",
|
|
42
|
+
help="voice name, not a path (default: narration.yaml's voice, then the lesson's front matter voice, "
|
|
43
|
+
"then the project's narration.voice)",
|
|
44
|
+
)
|
|
45
|
+
n.add_argument("--silent", action="store_true", help="use the deterministic silent voice (fixtures and tests)")
|
|
46
|
+
n.add_argument("--mp3", action="store_true", help="also transcode to MP3 with ffmpeg and point cues at it")
|
|
47
|
+
return p
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _report(result) -> None:
|
|
51
|
+
for w in result.report.warnings:
|
|
52
|
+
print(f"warning: {w}")
|
|
53
|
+
if result.report.warnings:
|
|
54
|
+
print(f"{len(result.report.warnings)} warning(s)")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _cmd_build(args) -> int:
|
|
58
|
+
from revision_engine.build import build
|
|
59
|
+
|
|
60
|
+
result = build(args.root, output=args.output, commit=args.commit)
|
|
61
|
+
print(f"built {len(result.pages)} pages at commit {result.commit[:7]} -> {result.output}")
|
|
62
|
+
_report(result)
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _cmd_check(args) -> int:
|
|
67
|
+
from revision_engine.build import build
|
|
68
|
+
|
|
69
|
+
result = build(args.root, commit=args.commit, check_only=True)
|
|
70
|
+
failed = args.strict and result.report.warnings
|
|
71
|
+
if not failed:
|
|
72
|
+
print(f"ok: all checks passed at commit {result.commit[:7]}")
|
|
73
|
+
if args.list_unlinked:
|
|
74
|
+
for where, line, text in result.unlinked:
|
|
75
|
+
print(f"unlinked: {where}:{line}: {text!r}")
|
|
76
|
+
if result.unlinked:
|
|
77
|
+
print(f"{len(result.unlinked)} unlinked code span(s)")
|
|
78
|
+
_report(result)
|
|
79
|
+
if failed:
|
|
80
|
+
print(f"error: --strict: {len(result.report.warnings)} warning(s)", file=sys.stderr)
|
|
81
|
+
return 1
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _cmd_serve(args) -> int:
|
|
86
|
+
from revision_engine.serve import serve
|
|
87
|
+
|
|
88
|
+
serve(args.root, port=args.port, commit=args.commit)
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _cmd_narrate(args) -> int:
|
|
93
|
+
from revision_engine.narration import PiperSynthesizer, SilentSynthesizer, narrate
|
|
94
|
+
|
|
95
|
+
if args.silent:
|
|
96
|
+
synth = SilentSynthesizer()
|
|
97
|
+
voice = args.voice or "silent-fixture"
|
|
98
|
+
else:
|
|
99
|
+
from revision_engine.narration import resolve_voice_model
|
|
100
|
+
|
|
101
|
+
voice, model = resolve_voice_model(args.lesson_dir, args.voice, args.model)
|
|
102
|
+
synth = PiperSynthesizer(model)
|
|
103
|
+
cues = narrate(args.lesson_dir, synth, voice=voice)
|
|
104
|
+
if args.mp3:
|
|
105
|
+
_transcode_mp3(args.lesson_dir, cues)
|
|
106
|
+
print(f"wrote {len(cues['segments'])} segments -> {args.lesson_dir / cues['media']}")
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _transcode_mp3(lesson_dir: Path, cues: dict) -> None:
|
|
111
|
+
import json
|
|
112
|
+
import shutil
|
|
113
|
+
import subprocess
|
|
114
|
+
|
|
115
|
+
if shutil.which("ffmpeg") is None:
|
|
116
|
+
raise BuildError("--mp3 needs ffmpeg on PATH")
|
|
117
|
+
wav = lesson_dir / cues["media"]
|
|
118
|
+
mp3 = wav.with_suffix(".mp3")
|
|
119
|
+
proc = subprocess.run(
|
|
120
|
+
["ffmpeg", "-y", "-loglevel", "error", "-i", str(wav), "-codec:a", "libmp3lame", "-q:a", "4", str(mp3)],
|
|
121
|
+
check=False,
|
|
122
|
+
)
|
|
123
|
+
if proc.returncode != 0:
|
|
124
|
+
raise BuildError("ffmpeg failed to transcode narration")
|
|
125
|
+
wav.unlink()
|
|
126
|
+
cues["media"] = f"media/{mp3.name}"
|
|
127
|
+
(lesson_dir / "cues.json").write_text(
|
|
128
|
+
json.dumps(cues, indent=2, sort_keys=True) + "\n", encoding="utf-8", newline="\n"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def main(argv: list[str] | None = None) -> int:
|
|
133
|
+
parser = _parser()
|
|
134
|
+
args = parser.parse_args(sys.argv[1:] if argv is None else argv)
|
|
135
|
+
if args.command is None:
|
|
136
|
+
parser.print_help(sys.stderr)
|
|
137
|
+
return 2
|
|
138
|
+
# A lesson's text can hold characters the console codec cannot encode (cp1252 on a
|
|
139
|
+
# redirected Windows stdout); escape them rather than crash mid-report. Line buffering
|
|
140
|
+
# keeps stdout ahead of a later stderr line when CI merges the two streams.
|
|
141
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
142
|
+
sys.stdout.reconfigure(errors="backslashreplace", line_buffering=True)
|
|
143
|
+
try:
|
|
144
|
+
if args.command == "build":
|
|
145
|
+
return _cmd_build(args)
|
|
146
|
+
if args.command == "check":
|
|
147
|
+
return _cmd_check(args)
|
|
148
|
+
if args.command == "serve":
|
|
149
|
+
return _cmd_serve(args)
|
|
150
|
+
if args.command == "narrate":
|
|
151
|
+
return _cmd_narrate(args)
|
|
152
|
+
except BuildError as e:
|
|
153
|
+
print(f"error: {e}", file=sys.stderr)
|
|
154
|
+
return 1
|
|
155
|
+
return 2
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__": # pragma: no cover
|
|
159
|
+
sys.exit(main())
|