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,159 @@
1
+ """Load and validate revision.yaml and curriculum.yaml into frozen dataclasses."""
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.errors import BuildError
13
+
14
+ SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
15
+ HOSTS = ("github", "gitlab")
16
+ DEFAULT_VOICE = "en_US-lessac-medium"
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class SiteConfig:
21
+ title: str
22
+ base_url: str
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class RepoConfig:
27
+ url: str
28
+ host: str
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class ProjectConfig:
33
+ root: Path
34
+ site: SiteConfig
35
+ repo: RepoConfig
36
+ content: Path
37
+ output: Path
38
+ voice: str
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class LessonRef:
43
+ slug: str
44
+ title: str
45
+ path: Path
46
+ type: str = "lesson"
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Chapter:
51
+ slug: str
52
+ title: str
53
+ lessons: tuple[LessonRef, ...]
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Curriculum:
58
+ chapters: tuple[Chapter, ...]
59
+
60
+
61
+ def _load_yaml(path: Path) -> Any:
62
+ if not path.is_file():
63
+ raise BuildError(f"{path.name} not found at {path}")
64
+ try:
65
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
66
+ except yaml.YAMLError as e:
67
+ raise BuildError(f"{path}: invalid YAML: {e}") from e
68
+ return {} if data is None else data
69
+
70
+
71
+ def _mapping(data: Any, where: str, file: str) -> dict:
72
+ if not isinstance(data, dict):
73
+ raise BuildError(f"{file}: {where} must be a mapping")
74
+ return data
75
+
76
+
77
+ def _require(m: dict, key: str, where: str, file: str) -> Any:
78
+ if key not in m or m[key] is None:
79
+ raise BuildError(f"{file}: {where}.{key} is required")
80
+ return m[key]
81
+
82
+
83
+ def _string(m: dict, key: str, where: str, file: str, default: str | None = None) -> str:
84
+ value = m.get(key, default) if default is not None else _require(m, key, where, file)
85
+ if not isinstance(value, str):
86
+ raise BuildError(f"{file}: {where}.{key} must be a string")
87
+ return value
88
+
89
+
90
+ def _slug(value: Any, where: str, file: str) -> str:
91
+ if not isinstance(value, str) or not SLUG_RE.match(value):
92
+ raise BuildError(f"{file}: {where}: {value!r} is not a slug (lowercase letters, digits, hyphens)")
93
+ return value
94
+
95
+
96
+ def load_project_config(root: Path) -> ProjectConfig:
97
+ root = root.resolve()
98
+ file = "revision.yaml"
99
+ data = _mapping(_load_yaml(root / file), "top level", file)
100
+
101
+ site = _mapping(_require(data, "site", "top level", file), "site", file)
102
+ repo = _mapping(_require(data, "repo", "top level", file), "repo", file)
103
+ host = _string(repo, "host", "repo", file)
104
+ if host not in HOSTS:
105
+ raise BuildError(f"{file}: repo.host must be one of: {', '.join(HOSTS)} (got {host!r})")
106
+
107
+ narration = _mapping(data.get("narration") or {}, "narration", file)
108
+
109
+ return ProjectConfig(
110
+ root=root,
111
+ site=SiteConfig(
112
+ title=_string(site, "title", "site", file),
113
+ base_url=_string(site, "base_url", "site", file, ""),
114
+ ),
115
+ repo=RepoConfig(url=_string(repo, "url", "repo", file), host=host),
116
+ content=root / _string(data, "content", "top level", file, "docs/revision"),
117
+ output=root / _string(data, "output", "top level", file, "public"),
118
+ voice=_string(narration, "voice", "narration", file, DEFAULT_VOICE),
119
+ )
120
+
121
+
122
+ def load_curriculum(cfg: ProjectConfig) -> Curriculum:
123
+ path = cfg.content / "curriculum.yaml"
124
+ file = path.relative_to(cfg.root).as_posix()
125
+ data = _mapping(_load_yaml(path), "top level", file)
126
+ raw_chapters = data.get("chapters")
127
+ if not isinstance(raw_chapters, list):
128
+ raise BuildError(f"{file}: chapters must be a list")
129
+
130
+ chapters: list[Chapter] = []
131
+ seen_chapters: set[str] = set()
132
+ for ci, raw in enumerate(raw_chapters):
133
+ where = f"chapters[{ci}]"
134
+ m = _mapping(raw, where, file)
135
+ slug = _slug(m.get("slug"), f"{where}.slug", file)
136
+ if slug in seen_chapters:
137
+ raise BuildError(f"{file}: duplicate chapter slug {slug!r}")
138
+ seen_chapters.add(slug)
139
+ raw_lessons = m.get("lessons")
140
+ if not isinstance(raw_lessons, list):
141
+ raise BuildError(f"{file}: {where}.lessons must be a list")
142
+ lessons: list[LessonRef] = []
143
+ seen_lessons: set[str] = set()
144
+ for li, lraw in enumerate(raw_lessons):
145
+ lwhere = f"{where}.lessons[{li}]"
146
+ lm = _mapping(lraw, lwhere, file)
147
+ lslug = _slug(lm.get("slug"), f"{lwhere}.slug", file)
148
+ if lslug in seen_lessons:
149
+ raise BuildError(f"{file}: duplicate lesson slug {lslug!r} in chapter {slug!r}")
150
+ seen_lessons.add(lslug)
151
+ ltype = _string(lm, "type", lwhere, file, "lesson")
152
+ if ltype != "lesson":
153
+ raise BuildError(f"{file}: {lwhere}.type: only 'lesson' is supported in this version")
154
+ lpath = (cfg.content / _string(lm, "path", lwhere, file)).resolve()
155
+ if not (lpath / "lesson.md").is_file():
156
+ raise BuildError(f"{file}: {lwhere}.path: {lpath / 'lesson.md'} not found")
157
+ lessons.append(LessonRef(slug=lslug, title=_string(lm, "title", lwhere, file), path=lpath, type=ltype))
158
+ chapters.append(Chapter(slug=slug, title=_string(m, "title", where, file), lessons=tuple(lessons)))
159
+ return Curriculum(chapters=tuple(chapters))
@@ -0,0 +1,108 @@
1
+ """Render Graphviz dot files to inline SVG and stamp node ids for the front end."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+ from revision_engine.errors import BuildError
12
+
13
+ _STRIP_RES = (
14
+ re.compile(r"<\?xml[^>]*\?>\s*"),
15
+ re.compile(r"<!DOCTYPE[^>]*>\s*"),
16
+ re.compile(r"<!--.*?-->\s*", re.S),
17
+ )
18
+ _NODE_RE = re.compile(r'<g id="([^"]+)" class="node"')
19
+ _SVG_OPEN_RE = re.compile(r"<svg\b")
20
+ # Quote-aware: render_dot unescapes ids, so a quoted attribute value may hold a raw '>'.
21
+ _G_OPEN_RE = re.compile(r'<g\b(?:[^>"]|"[^"]*")*>')
22
+ _CLASS_RE = re.compile(r'(?<![\w-])class="([^"]*)"')
23
+ _ANCHOR_ATTR_RE = re.compile(r'(?<![\w-])data-anchor="([^"]*)"')
24
+ _A_OPEN_RE = re.compile(r'<a\b(?:[^>"]|"[^"]*")*>')
25
+ _HAS_LINK_RE = re.compile(r'\s(?:xlink:)?href="')
26
+ _LINK_ATTR_RE = re.compile(r'\s(?:xlink:)?href="[^"]*"|\starget="[^"]*"')
27
+ _GROUP_KINDS = {"node", "edge", "cluster", "graph"}
28
+
29
+
30
+ def graphviz_version() -> str | None:
31
+ if shutil.which("dot") is None:
32
+ return None
33
+ out = subprocess.run(["dot", "-V"], capture_output=True, text=True, check=False)
34
+ m = re.search(r"version\s+(\S+)", out.stderr + out.stdout)
35
+ return m.group(1) if m else None
36
+
37
+
38
+ def render_dot(path: Path, where: str) -> str:
39
+ if shutil.which("dot") is None:
40
+ raise BuildError(f"{where}: graphviz 'dot' is not installed or not on PATH")
41
+ proc = subprocess.run(
42
+ ["dot", "-Tsvg", "-Gbgcolor=transparent", str(path)],
43
+ capture_output=True,
44
+ text=True,
45
+ encoding="utf-8",
46
+ check=False,
47
+ )
48
+ if proc.returncode != 0:
49
+ raise BuildError(f"{where}: graphviz failed: {proc.stderr.strip()}")
50
+ svg = proc.stdout
51
+ for rx in _STRIP_RES:
52
+ svg = rx.sub("", svg)
53
+ svg = svg.strip()
54
+
55
+ def node(m: re.Match) -> str:
56
+ # Graphviz entity-escapes ids (a hyphen becomes &#45;); restore the author's spelling.
57
+ name = html.unescape(m.group(1))
58
+ return f'<g id="{name}" class="node" data-node="{name}"'
59
+
60
+ svg = _NODE_RE.sub(node, svg)
61
+ svg = _SVG_OPEN_RE.sub('<svg class="rv-svg"', svg, count=1)
62
+ return svg
63
+
64
+
65
+ def stamp_anchors(svg: str, anchor_names: set[str]) -> str:
66
+ def repl(m: re.Match) -> str:
67
+ node = m.group(1)
68
+ if node in anchor_names:
69
+ return f'<g id="{node}" class="node rv-svg-anchor" data-node="{node}" data-anchor="{node}"'
70
+ return m.group(0)
71
+
72
+ return re.sub(r'<g id="([^"]+)" class="node" data-node="[^"]+"', repl, svg)
73
+
74
+
75
+ def unlink_anchors(svg: str) -> tuple[str, list[str]]:
76
+ """Strip links from anchored nodes: a click on an anchored node pins it, so a link inside is dead.
77
+
78
+ Returns the new SVG and the sorted names of the anchored nodes that held a link.
79
+ """
80
+ if 'data-anchor="' not in svg:
81
+ return svg, []
82
+ groups = []
83
+ for m in _G_OPEN_RE.finditer(svg):
84
+ cls = _CLASS_RE.search(m.group(0))
85
+ if cls and _GROUP_KINDS.intersection(cls.group(1).split()):
86
+ groups.append(m)
87
+ ends = [g.start() for g in groups[1:]] + [len(svg)]
88
+
89
+ def unlink(m: re.Match) -> str:
90
+ tag = m.group(0)
91
+ return _LINK_ATTR_RE.sub("", tag) if _HAS_LINK_RE.search(tag) else tag
92
+
93
+ out: list[str] = []
94
+ names: list[str] = []
95
+ pos = 0
96
+ for g, end in zip(groups, ends):
97
+ anchor = _ANCHOR_ATTR_RE.search(g.group(0))
98
+ if anchor is None:
99
+ continue
100
+ span = svg[g.end() : end]
101
+ new = _A_OPEN_RE.sub(unlink, span)
102
+ if new != span:
103
+ names.append(anchor.group(1))
104
+ out.append(svg[pos : g.end()])
105
+ out.append(new)
106
+ pos = end
107
+ out.append(svg[pos:])
108
+ return "".join(out), sorted(set(names))
@@ -0,0 +1,18 @@
1
+ """Error and warning types shared by every build stage."""
2
+
3
+
4
+ class BuildError(Exception):
5
+ """A content or configuration problem that must stop the build.
6
+
7
+ The message always names the file and, where possible, the key path or line.
8
+ """
9
+
10
+
11
+ class Report:
12
+ """Collects non-fatal warnings during a build for a summary at the end."""
13
+
14
+ def __init__(self) -> None:
15
+ self.warnings: list[str] = []
16
+
17
+ def warn(self, message: str) -> None:
18
+ self.warnings.append(message)
@@ -0,0 +1,62 @@
1
+ """Pygments highlighting emitted as one <div class="rv-line"> per source line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from pygments import highlight
8
+ from pygments.formatters import HtmlFormatter
9
+ from pygments.lexers import TextLexer, get_lexer_for_filename
10
+ from pygments.util import ClassNotFound
11
+
12
+ from revision_engine.sources import SourceFile
13
+
14
+ # With linespans set, Pygments emits `<span id="L-n">…\n</span>` per line: the newline sits
15
+ # inside the span, before its close tag, and tokens never cross a line boundary.
16
+ _LINE_RE = re.compile(r'<span id="L-(\d+)">(.*?)\n</span>', re.S)
17
+
18
+
19
+ def _lexer(path: str):
20
+ try:
21
+ return get_lexer_for_filename(path, stripnl=False, ensurenl=False)
22
+ except ClassNotFound:
23
+ return TextLexer(stripnl=False, ensurenl=False)
24
+
25
+
26
+ def highlight_source(source: SourceFile) -> str:
27
+ n = len(source.lines)
28
+ if n == 0:
29
+ return ""
30
+ formatter = HtmlFormatter(linespans="L", cssclass="rvhl", nowrap=False)
31
+ text = "\n".join(source.lines) + "\n"
32
+ html = highlight(text, _lexer(source.path), formatter)
33
+ inner = html[html.index("<pre>") + len("<pre>") : html.rindex("</pre>")]
34
+ matches = _LINE_RE.findall(inner)
35
+ if len(matches) != n:
36
+ raise AssertionError(f"highlighter produced {len(matches)} lines for {n} source lines in {source.path}")
37
+
38
+ out: list[str] = []
39
+ for i, (_, code) in enumerate(matches, start=1):
40
+ out.append(
41
+ f'<div class="rv-line" id="L{i}" data-line="{i}">'
42
+ f'<span class="rv-ln">{i}</span><span class="rv-code">{code}</span></div>'
43
+ )
44
+ return "\n".join(out) + "\n"
45
+
46
+
47
+ def _style_defs(style: str, selector: str) -> str:
48
+ """Pygments token rules under `selector`, without the container background rule."""
49
+ defs = HtmlFormatter(style=style).get_style_defs(selector)
50
+ kept = [line for line in defs.splitlines() if not re.match(re.escape(selector) + r"\s*\{", line)]
51
+ return "\n".join(kept)
52
+
53
+
54
+ def token_css() -> str:
55
+ light = _style_defs("default", ".rv-code")
56
+ dark_forced = _style_defs("github-dark", ':root[data-theme="dark"] .rv-code')
57
+ dark_auto = _style_defs("github-dark", ':root:not([data-theme="light"]) .rv-code')
58
+ return (
59
+ f"{light}\n"
60
+ f"@media (prefers-color-scheme: dark) {{\n{dark_auto}\n}}\n"
61
+ f"{dark_forced}\n"
62
+ )
@@ -0,0 +1,34 @@
1
+ """Map SIMD intrinsic names to vendor documentation URLs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import lru_cache
6
+ from importlib import resources
7
+
8
+ import yaml
9
+
10
+ _VENDORS = ("intel", "arm", "riscv")
11
+
12
+
13
+ @lru_cache(maxsize=1)
14
+ def _table() -> dict[str, str]:
15
+ table: dict[str, str] = {}
16
+ for vendor in _VENDORS:
17
+ text = resources.files(__package__).joinpath(f"{vendor}.yaml").read_text(encoding="utf-8")
18
+ data = yaml.safe_load(text)
19
+ for name in data.get("names") or ():
20
+ table[name] = data["url"].format(name=name)
21
+ # Vendors whose page can't be derived from the name carry one explicit URL per name.
22
+ for name, url in (data.get("urls") or {}).items():
23
+ table[str(name)] = str(url)
24
+ return table
25
+
26
+
27
+ def vendors() -> tuple[str, ...]:
28
+ return _VENDORS
29
+
30
+
31
+ def lookup(name: str) -> str | None:
32
+ if not name:
33
+ return None
34
+ return _table().get(name)
@@ -0,0 +1,22 @@
1
+ url: "https://developer.arm.com/architectures/instruction-sets/intrinsics/{name}"
2
+ names:
3
+ - vld1q_f32
4
+ - vst1q_f32
5
+ - vld2q_f32
6
+ - vst2q_f32
7
+ - vld4q_f32
8
+ - vdupq_n_f32
9
+ - vmulq_f32
10
+ - vaddq_f32
11
+ - vsubq_f32
12
+ - vadd_f32
13
+ - vmlaq_f32
14
+ - vmlsq_f32
15
+ - vfmaq_f32
16
+ - vfmsq_f32
17
+ - vpadd_f32
18
+ - vget_low_f32
19
+ - vget_high_f32
20
+ - vgetq_lane_f32
21
+ - vget_lane_f32
22
+ - vaddvq_f32
@@ -0,0 +1,42 @@
1
+ url: "https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#text={name}"
2
+ names:
3
+ - _mm_setzero_ps
4
+ - _mm_load_ps
5
+ - _mm_loadu_ps
6
+ - _mm_store_ps
7
+ - _mm_storeu_ps
8
+ - _mm_mul_ps
9
+ - _mm_add_ps
10
+ - _mm_add_ss
11
+ - _mm_addsub_ps
12
+ - _mm_or_ps
13
+ - _mm_moveldup_ps
14
+ - _mm_movehdup_ps
15
+ - _mm_hadd_ps
16
+ - _mm_dp_ps
17
+ - _mm_shuffle_ps
18
+ - _mm_movehl_ps
19
+ - _mm_cvtss_f32
20
+ - _mm256_setzero_ps
21
+ - _mm256_load_ps
22
+ - _mm256_loadu_ps
23
+ - _mm256_store_ps
24
+ - _mm256_storeu_ps
25
+ - _mm256_mul_ps
26
+ - _mm256_add_ps
27
+ - _mm256_addsub_ps
28
+ - _mm256_shuffle_ps
29
+ - _mm256_moveldup_ps
30
+ - _mm256_movehdup_ps
31
+ - _mm256_fmadd_ps
32
+ - _mm256_fmaddsub_ps
33
+ - _mm256_hadd_ps
34
+ - _mm256_extractf128_ps
35
+ - _mm256_castps256_ps128
36
+ - _mm512_setzero_ps
37
+ - _mm512_load_ps
38
+ - _mm512_loadu_ps
39
+ - _mm512_store_ps
40
+ - _mm512_storeu_ps
41
+ - _mm512_fmadd_ps
42
+ - _mm512_reduce_add_ps
@@ -0,0 +1,28 @@
1
+ # RISC-V Vector C intrinsics, ratified v1.0 reference (riscv-non-isa/riscv-rvv-intrinsic-doc,
2
+ # tag v1.0-ratified). Each name links to the section that lists it as a prototype; the section
3
+ # cannot be derived from the name, so every row carries its full URL. Rows checked 2026-10-03.
4
+ urls:
5
+ __riscv_vfadd: "https://docs.riscv.org/reference/vector-c-intrinsics/v1.0/overloaded_intrinsic_funcs/04_vector_floating-point_intrinsics.html#overloaded-vector-single-width-floating-point-add-subtract"
6
+ __riscv_vfadd_tu: "https://docs.riscv.org/reference/vector-c-intrinsics/v1.0/policy_funcs/overloaded_intrinsic_funcs/04_vector_floating-point_intrinsics.html#policy-variant-overloadedvector-single-width-floating-point-add-subtract"
7
+ __riscv_vfmacc: "https://docs.riscv.org/reference/vector-c-intrinsics/v1.0/overloaded_intrinsic_funcs/04_vector_floating-point_intrinsics.html#overloaded-vector-single-width-floating-point-fused-multiply-add"
8
+ __riscv_vfmacc_tu: "https://docs.riscv.org/reference/vector-c-intrinsics/v1.0/policy_funcs/overloaded_intrinsic_funcs/04_vector_floating-point_intrinsics.html#policy-variant-overloadedvector-single-width-floating-point-fused-multiply-add"
9
+ __riscv_vfmul: "https://docs.riscv.org/reference/vector-c-intrinsics/v1.0/overloaded_intrinsic_funcs/04_vector_floating-point_intrinsics.html#overloaded-vector-single-width-floating-point-multiply-divide"
10
+ __riscv_vfmv_f: "https://docs.riscv.org/reference/vector-c-intrinsics/v1.0/overloaded_intrinsic_funcs/07_vector_permutation_intrinsics.html#overloaded-integer-scalar-move"
11
+ __riscv_vfmv_s_f_f32m1: "https://docs.riscv.org/reference/vector-c-intrinsics/v1.0/intrinsic_funcs/07_vector_permutation_intrinsics.html#integer-scalar-move"
12
+ __riscv_vfmv_v_f_f32m2: "https://docs.riscv.org/reference/vector-c-intrinsics/v1.0/intrinsic_funcs/04_vector_floating-point_intrinsics.html#vector-floating-point-move"
13
+ __riscv_vfmv_v_f_f32m8: "https://docs.riscv.org/reference/vector-c-intrinsics/v1.0/intrinsic_funcs/04_vector_floating-point_intrinsics.html#vector-floating-point-move"
14
+ __riscv_vfneg: "https://docs.riscv.org/reference/vector-c-intrinsics/v1.0/overloaded_intrinsic_funcs/04_vector_floating-point_intrinsics.html#overloaded-vector-single-width-floating-point-add-subtract"
15
+ __riscv_vfnmsac: "https://docs.riscv.org/reference/vector-c-intrinsics/v1.0/overloaded_intrinsic_funcs/04_vector_floating-point_intrinsics.html#overloaded-vector-single-width-floating-point-fused-multiply-add"
16
+ __riscv_vfredusum: "https://docs.riscv.org/reference/vector-c-intrinsics/v1.0/overloaded_intrinsic_funcs/05_vector_reduction_operations.html#overloaded-vector-single-width-floating-point-reduction"
17
+ __riscv_vget_f32m1: "https://docs.riscv.org/reference/vector-c-intrinsics/v1.0/overloaded_intrinsic_funcs/08_miscellaneous_vector_utility_intrinsics.html#overloaded-vector-extraction"
18
+ __riscv_vget_f32m2: "https://docs.riscv.org/reference/vector-c-intrinsics/v1.0/overloaded_intrinsic_funcs/08_miscellaneous_vector_utility_intrinsics.html#overloaded-vector-extraction"
19
+ __riscv_vle32_v_f32m8: "https://docs.riscv.org/reference/vector-c-intrinsics/v1.0/intrinsic_funcs/00_vector_loads_and_stores_intrinsics.html#vector-unit-stride-load"
20
+ __riscv_vle64_v_u64m4: "https://docs.riscv.org/reference/vector-c-intrinsics/v1.0/intrinsic_funcs/00_vector_loads_and_stores_intrinsics.html#vector-unit-stride-load"
21
+ __riscv_vlseg2e32_v_f32m2x2: "https://docs.riscv.org/reference/vector-c-intrinsics/v1.0/intrinsic_funcs/01_vector_loads_and_stores_segment_intrinsics.html#vector-unit-stride-segment-load"
22
+ __riscv_vnsrl: "https://docs.riscv.org/reference/vector-c-intrinsics/v1.0/overloaded_intrinsic_funcs/02_vector_integer_arithmetic_intrinsics.html#overloaded-vector-narrowing-integer-right-shift"
23
+ __riscv_vreinterpret_f32m2: "https://docs.riscv.org/reference/vector-c-intrinsics/v1.0/overloaded_intrinsic_funcs/08_miscellaneous_vector_utility_intrinsics.html#overloaded-reinterpret-cast-conversion"
24
+ __riscv_vsetvl_e32m2: "https://docs.riscv.org/reference/vector-c-intrinsics/v1.0/intrinsic_funcs/08_miscellaneous_vector_utility_intrinsics.html#set-vl-and-vtype"
25
+ __riscv_vsetvl_e32m8: "https://docs.riscv.org/reference/vector-c-intrinsics/v1.0/intrinsic_funcs/08_miscellaneous_vector_utility_intrinsics.html#set-vl-and-vtype"
26
+ __riscv_vsetvlmax_e32m1: "https://docs.riscv.org/reference/vector-c-intrinsics/v1.0/intrinsic_funcs/08_miscellaneous_vector_utility_intrinsics.html#set-vl-to-vlmax-with-specific-vtype"
27
+ __riscv_vsetvlmax_e32m2: "https://docs.riscv.org/reference/vector-c-intrinsics/v1.0/intrinsic_funcs/08_miscellaneous_vector_utility_intrinsics.html#set-vl-to-vlmax-with-specific-vtype"
28
+ __riscv_vsetvlmax_e32m8: "https://docs.riscv.org/reference/vector-c-intrinsics/v1.0/intrinsic_funcs/08_miscellaneous_vector_utility_intrinsics.html#set-vl-to-vlmax-with-specific-vtype"
@@ -0,0 +1,64 @@
1
+ """Parse lesson.md: YAML front matter plus a Markdown body."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+
10
+ from revision_engine.errors import BuildError
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class LessonDoc:
15
+ title: str
16
+ summary: str
17
+ file: str
18
+ start: str | None
19
+ media: str | None
20
+ voice: str | None
21
+ body: str
22
+ body_line: int = 1 # 1-based line number in lesson.md where the body starts
23
+
24
+
25
+ def _opt_str(m: dict, key: str, where: str) -> str | None:
26
+ v = m.get(key)
27
+ if v is None:
28
+ return None
29
+ if not isinstance(v, str):
30
+ raise BuildError(f"{where}: front matter key {key!r} must be a string")
31
+ return v
32
+
33
+
34
+ def load_lesson(path: Path, where: str) -> LessonDoc:
35
+ text = path.read_text(encoding="utf-8").replace("\r\n", "\n")
36
+ if not text.startswith("---\n"):
37
+ raise BuildError(f"{where}: front matter must start with '---' on line 1")
38
+ end = text.find("\n---\n", 4)
39
+ if end == -1:
40
+ if text.endswith("\n---"):
41
+ end = len(text) - 4
42
+ else:
43
+ raise BuildError(f"{where}: front matter is not closed by a '---' line")
44
+ try:
45
+ meta = yaml.safe_load(text[4:end]) or {}
46
+ except yaml.YAMLError as e:
47
+ raise BuildError(f"{where}: front matter is not valid YAML: {e}") from e
48
+ if not isinstance(meta, dict):
49
+ raise BuildError(f"{where}: front matter must be a mapping")
50
+ for key in ("title", "file"):
51
+ if not isinstance(meta.get(key), str) or not meta[key]:
52
+ raise BuildError(f"{where}: front matter key {key!r} is required")
53
+ body = text[end + 5 :]
54
+ body_line = text[: end + 5].count("\n") + 1
55
+ return LessonDoc(
56
+ title=meta["title"],
57
+ summary=_opt_str(meta, "summary", where) or "",
58
+ file=meta["file"].replace("\\", "/"),
59
+ start=_opt_str(meta, "start", where),
60
+ media=_opt_str(meta, "media", where),
61
+ voice=_opt_str(meta, "voice", where),
62
+ body=body,
63
+ body_line=body_line,
64
+ )