@seanyao/roll 0.5.0 → 2.602.1
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.
- package/CHANGELOG.md +717 -0
- package/LICENSE +21 -0
- package/README.md +65 -165
- package/bin/dream-test-quality-scan +110 -0
- package/bin/roll +14897 -815
- package/conventions/config.yaml +17 -1
- package/conventions/global/AGENTS.md +146 -100
- package/conventions/global/CLAUDE.md +1 -21
- package/conventions/global/GEMINI.md +8 -22
- package/conventions/global/project_rules.md +9 -0
- package/conventions/templates/backend-service/AGENTS.md +30 -81
- package/conventions/templates/backend-service/GEMINI.md +3 -3
- package/conventions/templates/backend-service/project_rules.md +16 -0
- package/conventions/templates/cli/AGENTS.md +31 -58
- package/conventions/templates/cli/CLAUDE.md +3 -5
- package/conventions/templates/cli/GEMINI.md +3 -3
- package/conventions/templates/cli/project_rules.md +16 -0
- package/conventions/templates/frontend-only/AGENTS.md +29 -64
- package/conventions/templates/frontend-only/GEMINI.md +3 -3
- package/conventions/templates/frontend-only/project_rules.md +14 -0
- package/conventions/templates/fullstack/AGENTS.md +31 -79
- package/conventions/templates/fullstack/CLAUDE.md +1 -1
- package/conventions/templates/fullstack/GEMINI.md +3 -3
- package/conventions/templates/fullstack/project_rules.md +15 -0
- package/lib/README.md +42 -0
- package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop-fmt.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop_unstick.cpython-314.pyc +0 -0
- package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
- package/lib/__pycache__/prices_fetcher.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll_git.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
- package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
- package/lib/agent_usage/README.md +49 -0
- package/lib/agent_usage/__init__.py +108 -0
- package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/openai.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/pi.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/pi_emit.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/qwen.cpython-314.pyc +0 -0
- package/lib/agent_usage/gemini.py +127 -0
- package/lib/agent_usage/kimi.py +278 -0
- package/lib/agent_usage/kimi_emit.py +123 -0
- package/lib/agent_usage/openai.py +126 -0
- package/lib/agent_usage/pi.py +200 -0
- package/lib/agent_usage/pi_emit.py +135 -0
- package/lib/agent_usage/qwen.py +128 -0
- package/lib/backfill-pi-usage.py +243 -0
- package/lib/changelog_audit.py +155 -0
- package/lib/changelog_generate.py +263 -0
- package/lib/context_feed_budget.sh +194 -0
- package/lib/github_sync.py +876 -0
- package/lib/i18n/README.md +54 -0
- package/lib/i18n/agent.sh +75 -0
- package/lib/i18n/alert.sh +20 -0
- package/lib/i18n/backlog.sh +96 -0
- package/lib/i18n/brief.sh +5 -0
- package/lib/i18n/changelog.sh +5 -0
- package/lib/i18n/ci.sh +15 -0
- package/lib/i18n/debug.sh +0 -0
- package/lib/i18n/doctor.sh +44 -0
- package/lib/i18n/dream.sh +0 -0
- package/lib/i18n/init.sh +91 -0
- package/lib/i18n/lang.sh +10 -0
- package/lib/i18n/loop.sh +140 -0
- package/lib/i18n/migrate.sh +74 -0
- package/lib/i18n/offboard.sh +31 -0
- package/lib/i18n/onboard.sh +0 -0
- package/lib/i18n/peer.sh +41 -0
- package/lib/i18n/peer_help.sh +25 -0
- package/lib/i18n/peer_reset.sh +7 -0
- package/lib/i18n/peer_status.sh +5 -0
- package/lib/i18n/prices.sh +3 -0
- package/lib/i18n/prices_refresh.sh +17 -0
- package/lib/i18n/prices_show.sh +7 -0
- package/lib/i18n/propose.sh +0 -0
- package/lib/i18n/release.sh +0 -0
- package/lib/i18n/research.sh +0 -0
- package/lib/i18n/review_pr.sh +0 -0
- package/lib/i18n/sentinel.sh +0 -0
- package/lib/i18n/setup.sh +3 -0
- package/lib/i18n/shared.sh +157 -0
- package/lib/i18n/skills/roll-brief.sh +47 -0
- package/lib/i18n/skills/roll-build.sh +97 -0
- package/lib/i18n/skills/roll-design.sh +18 -0
- package/lib/i18n/skills/roll-fix.sh +53 -0
- package/lib/i18n/skills/roll-loop.sh +28 -0
- package/lib/i18n/skills/roll-onboard.sh +33 -0
- package/lib/i18n/skills_catalog.sh +30 -0
- package/lib/i18n/slides.sh +3 -0
- package/lib/i18n/slides_build.sh +38 -0
- package/lib/i18n/slides_delete.sh +19 -0
- package/lib/i18n/slides_list.sh +14 -0
- package/lib/i18n/slides_logs.sh +12 -0
- package/lib/i18n/slides_new.sh +15 -0
- package/lib/i18n/slides_preview.sh +14 -0
- package/lib/i18n/slides_templates.sh +7 -0
- package/lib/i18n/status.sh +21 -0
- package/lib/i18n/update.sh +24 -0
- package/lib/i18n.sh +211 -0
- package/lib/loop-exit-summary.py +393 -0
- package/lib/loop-fmt.py +589 -0
- package/lib/loop_pick_agent.py +316 -0
- package/lib/loop_result_eval.py +469 -0
- package/lib/loop_unstick.py +180 -0
- package/lib/model_prices.py +186 -0
- package/lib/prices/README.md +35 -0
- package/lib/prices/snapshot-2026-05-22.json +22 -0
- package/lib/prices/snapshot-2026-05-23-deepseek.json +15 -0
- package/lib/prices/snapshot-2026-05-23-kimi.json +14 -0
- package/lib/prices_fetcher.py +285 -0
- package/lib/roll-backlog.py +225 -0
- package/lib/roll-brief.py +286 -0
- package/lib/roll-help.py +158 -0
- package/lib/roll-home.py +556 -0
- package/lib/roll-init.py +156 -0
- package/lib/roll-loop-status.py +1683 -0
- package/lib/roll-loop-story.py +191 -0
- package/lib/roll-onboard-render.py +378 -0
- package/lib/roll-peer.py +252 -0
- package/lib/roll-plan-validate.py +386 -0
- package/lib/roll-setup.py +102 -0
- package/lib/roll-status.py +367 -0
- package/lib/roll_git.py +41 -0
- package/lib/roll_render.py +414 -0
- package/lib/slides/components/README.md +123 -0
- package/lib/slides/components/cards-2.html +9 -0
- package/lib/slides/components/cards-3.html +9 -0
- package/lib/slides/components/cards-4.html +9 -0
- package/lib/slides/components/compare.html +22 -0
- package/lib/slides/components/highlight.html +9 -0
- package/lib/slides/components/pipeline.html +12 -0
- package/lib/slides/components/plain.html +7 -0
- package/lib/slides/components/quote.html +4 -0
- package/lib/slides/components/timeline.html +9 -0
- package/lib/slides/templates/introduction-v3.html +571 -0
- package/lib/slides/templates/pitch.html +0 -0
- package/lib/slides-render.py +778 -0
- package/lib/slides-validate.py +357 -0
- package/lib/test_quality_gate.py +143 -0
- package/package.json +8 -7
- package/skills/roll-.changelog/SKILL.md +406 -33
- package/skills/roll-.clarify/SKILL.md +5 -2
- package/skills/roll-.dream/SKILL.md +374 -0
- package/skills/roll-.echo/SKILL.md +5 -2
- package/skills/roll-.qa/SKILL.md +57 -3
- package/skills/roll-.review/SKILL.md +42 -3
- package/skills/roll-brief/SKILL.md +209 -0
- package/skills/roll-build/SKILL.md +308 -63
- package/skills/roll-debug/SKILL.md +341 -162
- package/skills/roll-debug/injectable-bb.js +263 -0
- package/skills/roll-deck/SKILL.md +296 -0
- package/skills/roll-design/ENGINEERING_CHECKLIST.md +1 -1
- package/skills/roll-design/SKILL.md +727 -94
- package/skills/roll-doc/SKILL.md +595 -0
- package/skills/roll-doctor/SKILL.md +192 -0
- package/skills/roll-fix/SKILL.md +149 -32
- package/skills/{roll-jot → roll-idea}/SKILL.md +18 -10
- package/skills/roll-loop/SKILL.md +578 -0
- package/skills/roll-notes/SKILL.md +103 -0
- package/skills/roll-onboard/SKILL.md +234 -0
- package/skills/roll-peer/SKILL.md +336 -0
- package/skills/roll-propose/SKILL.md +157 -0
- package/skills/roll-review-pr/SKILL.md +58 -0
- package/skills/roll-sentinel/SKILL.md +11 -2
- package/skills/roll-spar/SKILL.md +8 -6
- package/template/.github/workflows/ci.yml +5 -2
- package/template/AGENTS.md +20 -74
- package/skills/roll-research/SKILL.md +0 -307
- package/skills/roll-research/references/schema.json +0 -162
- package/skills/roll-research/scripts/md_to_pdf.py +0 -289
- package/tools/roll-fetch/SKILL.md +0 -182
- package/tools/roll-fetch/package.json +0 -15
- package/tools/roll-fetch/smart-web-fetch.js +0 -558
- package/tools/roll-probe/SKILL.md +0 -84
- /package/template/{BACKLOG.md → .roll/backlog.md} +0 -0
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
US-DECK-002: deck.md -> HTML renderer.
|
|
4
|
+
|
|
5
|
+
Reads a `deck.md` file (YAML-ish frontmatter + per-slide sections), reads a
|
|
6
|
+
Mustache-style template, and writes a self-contained HTML document to stdout.
|
|
7
|
+
|
|
8
|
+
Zero new dependencies — Python stdlib only. The "YAML" frontmatter and
|
|
9
|
+
per-slide block parsers handle only the subset of YAML used by the deck.md
|
|
10
|
+
schema (scalar key/value pairs, `key: |` block literal bodies, and a
|
|
11
|
+
`evidence:` list of `- item` lines). Anything beyond that subset is out of
|
|
12
|
+
scope on purpose.
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
python3 slides-render.py <deck.md> <template.html> [out.html]
|
|
16
|
+
|
|
17
|
+
If no out path is given, the rendered HTML is written to stdout.
|
|
18
|
+
|
|
19
|
+
Exit codes:
|
|
20
|
+
0 render succeeded
|
|
21
|
+
1 deck.md missing or unreadable
|
|
22
|
+
2 template missing or unreadable
|
|
23
|
+
3 parse / render error
|
|
24
|
+
|
|
25
|
+
Supported Mustache subset (documented for users):
|
|
26
|
+
|
|
27
|
+
{{var}} HTML-escaped substitution
|
|
28
|
+
{{{var}}} Raw substitution (no escape)
|
|
29
|
+
{{#section}}...{{/section}}
|
|
30
|
+
If `section` is a list, render the body once
|
|
31
|
+
per item with the item dict pushed onto the
|
|
32
|
+
context stack. If `section` is truthy non-list,
|
|
33
|
+
render the body once with the same context.
|
|
34
|
+
{{^section}}...{{/section}}
|
|
35
|
+
Inverted section — render the body iff
|
|
36
|
+
`section` is missing, falsy, or an empty list.
|
|
37
|
+
|
|
38
|
+
Explicitly NOT supported: partials ({{>name}}), lambdas, set delimiters
|
|
39
|
+
({{=<% %>=}}), and triple-mustache with HTML pass-through inside sections
|
|
40
|
+
(use {{{var}}} on simple keys only).
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import html as _html
|
|
46
|
+
import re
|
|
47
|
+
import sys
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ──────────────────────────── deck.md parser ────────────────────────────────
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_frontmatter(src: str) -> tuple[dict, str]:
|
|
55
|
+
"""
|
|
56
|
+
Split a deck.md source into (frontmatter dict, body text).
|
|
57
|
+
|
|
58
|
+
The frontmatter is delimited by a leading `---` line and a trailing `---`
|
|
59
|
+
line. Inside, each non-blank line is a `key: value` pair. Quoted values
|
|
60
|
+
have their wrapping quotes stripped. Integer-looking values are coerced
|
|
61
|
+
to int.
|
|
62
|
+
"""
|
|
63
|
+
lines = src.splitlines()
|
|
64
|
+
if not lines or lines[0].strip() != "---":
|
|
65
|
+
raise ValueError("deck.md must start with a '---' frontmatter delimiter")
|
|
66
|
+
end = None
|
|
67
|
+
for i in range(1, len(lines)):
|
|
68
|
+
if lines[i].strip() == "---":
|
|
69
|
+
end = i
|
|
70
|
+
break
|
|
71
|
+
if end is None:
|
|
72
|
+
raise ValueError("deck.md frontmatter missing closing '---' delimiter")
|
|
73
|
+
|
|
74
|
+
fm: dict = {}
|
|
75
|
+
for raw in lines[1:end]:
|
|
76
|
+
if not raw.strip() or raw.lstrip().startswith("#"):
|
|
77
|
+
continue
|
|
78
|
+
if ":" not in raw:
|
|
79
|
+
raise ValueError(f"frontmatter line not a key:value pair: {raw!r}")
|
|
80
|
+
key, _, val = raw.partition(":")
|
|
81
|
+
fm[key.strip()] = _coerce_scalar(val.strip())
|
|
82
|
+
|
|
83
|
+
body = "\n".join(lines[end + 1 :])
|
|
84
|
+
return fm, body
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _coerce_scalar(v: str):
|
|
88
|
+
"""Strip wrapping quotes; coerce int-looking values to int."""
|
|
89
|
+
if len(v) >= 2 and v[0] == v[-1] and v[0] in ("'", '"'):
|
|
90
|
+
return v[1:-1]
|
|
91
|
+
if v.lower() == "true":
|
|
92
|
+
return True
|
|
93
|
+
if v.lower() == "false":
|
|
94
|
+
return False
|
|
95
|
+
try:
|
|
96
|
+
return int(v)
|
|
97
|
+
except ValueError:
|
|
98
|
+
pass
|
|
99
|
+
return v
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# Keys whose `- item` children are always flat scalar strings, never coerced
|
|
103
|
+
# into mappings even when an item contains a colon (e.g. `evidence:` citations
|
|
104
|
+
# like `- TODO: see README.md:1`).
|
|
105
|
+
_SCALAR_LIST_KEYS = ("evidence",)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# A slide section starts at a line matching `^## Slide \d+` and continues
|
|
109
|
+
# until the next such line or EOF.
|
|
110
|
+
_SLIDE_HEADER_RE = re.compile(r"^##\s+Slide\s+(\d+)\s*$")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def parse_slides(body: str) -> list[dict]:
|
|
114
|
+
"""
|
|
115
|
+
Walk the body and split it into slide dicts.
|
|
116
|
+
|
|
117
|
+
Each slide dict has keys: number (int), title_en, title_zh, body_en,
|
|
118
|
+
body_zh, evidence (list[str]). Missing keys are left absent so that
|
|
119
|
+
validation can report them.
|
|
120
|
+
"""
|
|
121
|
+
lines = body.splitlines()
|
|
122
|
+
slides: list[dict] = []
|
|
123
|
+
cur: dict | None = None
|
|
124
|
+
cur_lines: list[str] = []
|
|
125
|
+
for line in lines:
|
|
126
|
+
m = _SLIDE_HEADER_RE.match(line)
|
|
127
|
+
if m:
|
|
128
|
+
if cur is not None:
|
|
129
|
+
_populate_slide(cur, cur_lines)
|
|
130
|
+
slides.append(cur)
|
|
131
|
+
cur = {"number": int(m.group(1))}
|
|
132
|
+
cur_lines = []
|
|
133
|
+
else:
|
|
134
|
+
if cur is not None:
|
|
135
|
+
cur_lines.append(line)
|
|
136
|
+
if cur is not None:
|
|
137
|
+
_populate_slide(cur, cur_lines)
|
|
138
|
+
slides.append(cur)
|
|
139
|
+
return slides
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _populate_slide(slide: dict, content_lines: list[str]) -> None:
|
|
143
|
+
"""
|
|
144
|
+
Parse the lines following a `## Slide N` header into the slide dict.
|
|
145
|
+
|
|
146
|
+
Grammar (subset):
|
|
147
|
+
key: "value" -> scalar
|
|
148
|
+
key: | -> block literal, takes indented
|
|
149
|
+
line one lines until the indent drops.
|
|
150
|
+
line two
|
|
151
|
+
evidence: -> list, takes `- item` lines
|
|
152
|
+
- one.md:1
|
|
153
|
+
- two.md:7
|
|
154
|
+
"""
|
|
155
|
+
i = 0
|
|
156
|
+
n = len(content_lines)
|
|
157
|
+
while i < n:
|
|
158
|
+
raw = content_lines[i]
|
|
159
|
+
stripped = raw.strip()
|
|
160
|
+
if not stripped:
|
|
161
|
+
i += 1
|
|
162
|
+
continue
|
|
163
|
+
if ":" not in raw:
|
|
164
|
+
i += 1
|
|
165
|
+
continue
|
|
166
|
+
key, _, val = raw.partition(":")
|
|
167
|
+
key = key.strip()
|
|
168
|
+
val = val.strip()
|
|
169
|
+
|
|
170
|
+
if val == "|":
|
|
171
|
+
# Block literal: gather lines until the indent drops below the
|
|
172
|
+
# indent of the first non-blank line. Strip exactly that common
|
|
173
|
+
# indent from every line so the body markdown starts at column 0.
|
|
174
|
+
block: list[str] = []
|
|
175
|
+
common_indent: int | None = None
|
|
176
|
+
i += 1
|
|
177
|
+
while i < n:
|
|
178
|
+
bl = content_lines[i]
|
|
179
|
+
if bl.strip() == "":
|
|
180
|
+
block.append("")
|
|
181
|
+
i += 1
|
|
182
|
+
continue
|
|
183
|
+
indent = len(bl) - len(bl.lstrip(" "))
|
|
184
|
+
if common_indent is None:
|
|
185
|
+
if indent == 0:
|
|
186
|
+
# No indent at all → block literal is empty.
|
|
187
|
+
break
|
|
188
|
+
common_indent = indent
|
|
189
|
+
elif indent < common_indent:
|
|
190
|
+
break
|
|
191
|
+
block.append(bl[common_indent:])
|
|
192
|
+
i += 1
|
|
193
|
+
while block and block[-1] == "":
|
|
194
|
+
block.pop()
|
|
195
|
+
slide[key] = "\n".join(block) + "\n" if block else ""
|
|
196
|
+
elif val == "":
|
|
197
|
+
# A bare `key:` introduces an indented child block, which may be:
|
|
198
|
+
# - a list of scalars (`- one.md:1`) -> list[str]
|
|
199
|
+
# - a list of mappings (`- title_en: "..."`) -> list[dict]
|
|
200
|
+
# - a nested mapping (` left_title_en: …`) -> dict
|
|
201
|
+
# or, if no indented child follows, an empty scalar.
|
|
202
|
+
key_indent = len(raw) - len(raw.lstrip(" "))
|
|
203
|
+
block, j = _collect_indented_block(content_lines, i + 1, key_indent)
|
|
204
|
+
if block:
|
|
205
|
+
# `evidence` is always a flat list of free-form citation
|
|
206
|
+
# strings (e.g. `- TODO: see README.md:1`), so it must never be
|
|
207
|
+
# coerced into a mapping by the generic block parser.
|
|
208
|
+
if key in _SCALAR_LIST_KEYS:
|
|
209
|
+
slide[key] = _parse_scalar_list(block)
|
|
210
|
+
else:
|
|
211
|
+
slide[key] = _parse_block(block)
|
|
212
|
+
i = j
|
|
213
|
+
else:
|
|
214
|
+
slide[key] = ""
|
|
215
|
+
i += 1
|
|
216
|
+
else:
|
|
217
|
+
slide[key] = _coerce_scalar(val)
|
|
218
|
+
i += 1
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _collect_indented_block(
|
|
222
|
+
lines: list[str], start: int, parent_indent: int
|
|
223
|
+
) -> tuple[list[str], int]:
|
|
224
|
+
"""
|
|
225
|
+
Gather lines that belong to the indented child block of a `key:` at
|
|
226
|
+
`parent_indent`. A line belongs to the block if it is blank or indented
|
|
227
|
+
strictly deeper than the parent key. Returns (block_lines, next_index).
|
|
228
|
+
|
|
229
|
+
Trailing blank lines are dropped so an all-blank block reads as empty.
|
|
230
|
+
"""
|
|
231
|
+
block: list[str] = []
|
|
232
|
+
j = start
|
|
233
|
+
n = len(lines)
|
|
234
|
+
while j < n:
|
|
235
|
+
bl = lines[j]
|
|
236
|
+
if bl.strip() == "":
|
|
237
|
+
block.append("")
|
|
238
|
+
j += 1
|
|
239
|
+
continue
|
|
240
|
+
indent = len(bl) - len(bl.lstrip(" "))
|
|
241
|
+
if indent <= parent_indent:
|
|
242
|
+
break
|
|
243
|
+
block.append(bl)
|
|
244
|
+
j += 1
|
|
245
|
+
while block and block[-1] == "":
|
|
246
|
+
block.pop()
|
|
247
|
+
return block, j
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _parse_block(block: list[str]):
|
|
251
|
+
"""
|
|
252
|
+
Parse an indented YAML-subset block into a list or a dict.
|
|
253
|
+
|
|
254
|
+
- If the first non-blank line starts with `- `, the block is a sequence.
|
|
255
|
+
Each `- ` opens a new item; a scalar follows `- ` directly (list of
|
|
256
|
+
scalars) or a `key: value` does (list of mappings, with subsequent
|
|
257
|
+
deeper-indented `key: value` lines folded into the same item).
|
|
258
|
+
- Otherwise the block is a mapping of `key: value` / `key:` (nested)
|
|
259
|
+
entries.
|
|
260
|
+
|
|
261
|
+
Scalars are coerced via `_coerce_scalar`. Nested `key:` with no inline
|
|
262
|
+
value recurse into another block (supports `compare`'s `left_items:`).
|
|
263
|
+
"""
|
|
264
|
+
# Find the indentation of the block's own top level (first non-blank line).
|
|
265
|
+
first = next((b for b in block if b.strip() != ""), "")
|
|
266
|
+
base_indent = len(first) - len(first.lstrip(" "))
|
|
267
|
+
|
|
268
|
+
if first.lstrip().startswith("- "):
|
|
269
|
+
return _parse_sequence(block, base_indent)
|
|
270
|
+
return _parse_mapping(block, base_indent)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _parse_sequence(block: list[str], base_indent: int) -> list:
|
|
274
|
+
"""Parse a `- ...` sequence at `base_indent` into list[str] | list[dict]."""
|
|
275
|
+
items: list = []
|
|
276
|
+
i = 0
|
|
277
|
+
n = len(block)
|
|
278
|
+
while i < n:
|
|
279
|
+
line = block[i]
|
|
280
|
+
if line.strip() == "":
|
|
281
|
+
i += 1
|
|
282
|
+
continue
|
|
283
|
+
stripped = line.lstrip()
|
|
284
|
+
# Only treat a `- ` at the sequence's own indent as an item start.
|
|
285
|
+
indent = len(line) - len(stripped)
|
|
286
|
+
if indent == base_indent and stripped.startswith("- "):
|
|
287
|
+
# Strip the dash and ALL following spaces; `content_col` is the
|
|
288
|
+
# column where the item's first key actually begins, which equals
|
|
289
|
+
# the indent of the item's continuation lines in well-formed YAML.
|
|
290
|
+
after_dash = stripped[1:]
|
|
291
|
+
rest = after_dash.lstrip(" ")
|
|
292
|
+
content_col = indent + 1 + (len(after_dash) - len(rest))
|
|
293
|
+
if ":" in rest and not _looks_like_scalar(rest):
|
|
294
|
+
# List of mappings. Re-indent the inline key to `content_col`
|
|
295
|
+
# so the sub-block has one consistent base indent regardless of
|
|
296
|
+
# how the author spaced the `- ` (fixes hardcoded-indent
|
|
297
|
+
# mis-parse).
|
|
298
|
+
sub = [(" " * content_col) + rest]
|
|
299
|
+
i += 1
|
|
300
|
+
while i < n:
|
|
301
|
+
bl = block[i]
|
|
302
|
+
if bl.strip() == "":
|
|
303
|
+
sub.append("")
|
|
304
|
+
i += 1
|
|
305
|
+
continue
|
|
306
|
+
bi = len(bl) - len(bl.lstrip(" "))
|
|
307
|
+
if bi <= base_indent:
|
|
308
|
+
break
|
|
309
|
+
sub.append(bl)
|
|
310
|
+
i += 1
|
|
311
|
+
items.append(_parse_mapping(sub, content_col))
|
|
312
|
+
else:
|
|
313
|
+
items.append(_coerce_scalar(rest.strip()))
|
|
314
|
+
i += 1
|
|
315
|
+
else:
|
|
316
|
+
# Defensive: ignore stray lines that don't open an item.
|
|
317
|
+
i += 1
|
|
318
|
+
return items
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _parse_mapping(block: list[str], base_indent: int | None = None) -> dict:
|
|
322
|
+
"""Parse `key: value` / `key:` (nested / block-literal) lines into a dict.
|
|
323
|
+
|
|
324
|
+
`base_indent` defaults to the indent of the block's first non-blank line so
|
|
325
|
+
callers need not compute it; only top-level keys at that indent are read,
|
|
326
|
+
deeper lines are folded into the value of the key they belong to.
|
|
327
|
+
"""
|
|
328
|
+
out: dict = {}
|
|
329
|
+
i = 0
|
|
330
|
+
n = len(block)
|
|
331
|
+
if base_indent is None:
|
|
332
|
+
first = next((b for b in block if b.strip() != ""), "")
|
|
333
|
+
base_indent = len(first) - len(first.lstrip(" "))
|
|
334
|
+
while i < n:
|
|
335
|
+
line = block[i]
|
|
336
|
+
if line.strip() == "" or ":" not in line:
|
|
337
|
+
i += 1
|
|
338
|
+
continue
|
|
339
|
+
indent = len(line) - len(line.lstrip(" "))
|
|
340
|
+
if indent != base_indent:
|
|
341
|
+
# Belongs to a deeper structure handled by recursion; skip.
|
|
342
|
+
i += 1
|
|
343
|
+
continue
|
|
344
|
+
key, _, val = line.partition(":")
|
|
345
|
+
key = key.strip()
|
|
346
|
+
val = val.strip()
|
|
347
|
+
if val == "|":
|
|
348
|
+
block_lines, j = _collect_indented_block(block, i + 1, base_indent)
|
|
349
|
+
out[key] = _dedent_block_literal(block_lines)
|
|
350
|
+
i = j
|
|
351
|
+
elif val == "":
|
|
352
|
+
child, j = _collect_indented_block(block, i + 1, base_indent)
|
|
353
|
+
if child:
|
|
354
|
+
if key in _SCALAR_LIST_KEYS:
|
|
355
|
+
out[key] = _parse_scalar_list(child)
|
|
356
|
+
else:
|
|
357
|
+
out[key] = _parse_block(child)
|
|
358
|
+
i = j
|
|
359
|
+
else:
|
|
360
|
+
out[key] = ""
|
|
361
|
+
i += 1
|
|
362
|
+
else:
|
|
363
|
+
out[key] = _coerce_scalar(val)
|
|
364
|
+
i += 1
|
|
365
|
+
return out
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _dedent_block_literal(block_lines: list[str]) -> str:
|
|
369
|
+
"""Strip the common leading indent of a `key: |` block literal and join the
|
|
370
|
+
lines, matching the top-level block-literal handling in `_populate_slide`."""
|
|
371
|
+
out: list[str] = []
|
|
372
|
+
common_indent: int | None = None
|
|
373
|
+
for bl in block_lines:
|
|
374
|
+
if bl.strip() == "":
|
|
375
|
+
out.append("")
|
|
376
|
+
continue
|
|
377
|
+
indent = len(bl) - len(bl.lstrip(" "))
|
|
378
|
+
if common_indent is None:
|
|
379
|
+
common_indent = indent
|
|
380
|
+
elif indent < common_indent:
|
|
381
|
+
common_indent = indent
|
|
382
|
+
if common_indent is None:
|
|
383
|
+
return ""
|
|
384
|
+
for bl in block_lines:
|
|
385
|
+
out.append(bl[common_indent:] if bl.strip() != "" else "")
|
|
386
|
+
while out and out[-1] == "":
|
|
387
|
+
out.pop()
|
|
388
|
+
return "\n".join(out) + "\n" if out else ""
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _parse_scalar_list(block: list[str]) -> list:
|
|
392
|
+
"""Parse a `- item` sequence as a flat list of scalar strings, regardless of
|
|
393
|
+
whether an item contains a colon (used for `evidence:` citations)."""
|
|
394
|
+
items: list = []
|
|
395
|
+
for line in block:
|
|
396
|
+
s = line.strip()
|
|
397
|
+
if s.startswith("- "):
|
|
398
|
+
items.append(_coerce_scalar(s[2:].strip()))
|
|
399
|
+
return items
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _looks_like_scalar(rest: str) -> bool:
|
|
403
|
+
"""A `- ` item is a scalar (not a mapping) when the text before the first
|
|
404
|
+
colon looks like a value rather than a bare key — e.g. a path `README.md:42`
|
|
405
|
+
where the colon is part of the value. Heuristic: it's a mapping only when
|
|
406
|
+
the segment before the colon is a bare identifier (word chars / underscore)
|
|
407
|
+
and is immediately followed by a space or end-of-string."""
|
|
408
|
+
head, sep, tail = rest.partition(":")
|
|
409
|
+
if not sep:
|
|
410
|
+
return True
|
|
411
|
+
head = head.strip()
|
|
412
|
+
if not head or not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", head):
|
|
413
|
+
return True
|
|
414
|
+
# `key:value` with no space is treated as a scalar (e.g. `README.md:42`
|
|
415
|
+
# never reaches here since `.` breaks the identifier rule, but a bare
|
|
416
|
+
# `word:7` would — require a space after the colon for a mapping).
|
|
417
|
+
if tail and not tail.startswith(" ") and not tail == "":
|
|
418
|
+
return True
|
|
419
|
+
return False
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# ─────────────────────────── Mustache subset ─────────────────────────────────
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# Match either {{{raw}}} or {{tag}} (which may be #section, ^inverted, /close).
|
|
426
|
+
# {{{...}}} must be tried first because of greedy match.
|
|
427
|
+
_MU_RE = re.compile(r"\{\{\{(\w+)\}\}\}|\{\{([#^/]?)\s*(\w+)\s*\}\}")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def mustache(template: str, context: dict) -> str:
|
|
431
|
+
"""
|
|
432
|
+
Render a Mustache-subset template against `context`.
|
|
433
|
+
|
|
434
|
+
Implementation is a simple stack-based parser: scan tokens left to right;
|
|
435
|
+
keep a "section stack" of (kind, key, sub_template_start) frames. When a
|
|
436
|
+
closing tag matches the top frame, render the captured sub-template once
|
|
437
|
+
per item (for `#`) or once if falsy (for `^`).
|
|
438
|
+
"""
|
|
439
|
+
out: list[str] = []
|
|
440
|
+
pos = 0
|
|
441
|
+
# We'll walk the template; when we see {{#x}} or {{^x}} we collect tokens
|
|
442
|
+
# until the matching {{/x}}, accounting for nested same-name sections.
|
|
443
|
+
stack: list[tuple[str, str, int]] = [] # (kind, key, start_pos_in_out)
|
|
444
|
+
# We need to re-render section bodies with possibly multiple contexts, so
|
|
445
|
+
# we capture the raw substring between {{#x}} and {{/x}} and recurse.
|
|
446
|
+
def render_chunk(chunk: str, ctx_stack: list[dict]) -> str:
|
|
447
|
+
buf: list[str] = []
|
|
448
|
+
i = 0
|
|
449
|
+
while i < len(chunk):
|
|
450
|
+
m = _MU_RE.search(chunk, i)
|
|
451
|
+
if not m:
|
|
452
|
+
buf.append(chunk[i:])
|
|
453
|
+
break
|
|
454
|
+
buf.append(chunk[i : m.start()])
|
|
455
|
+
raw_key = m.group(1)
|
|
456
|
+
sigil = m.group(2)
|
|
457
|
+
tag_key = m.group(3)
|
|
458
|
+
if raw_key:
|
|
459
|
+
# {{{raw}}}
|
|
460
|
+
buf.append(str(_lookup(ctx_stack, raw_key)))
|
|
461
|
+
i = m.end()
|
|
462
|
+
continue
|
|
463
|
+
if sigil == "":
|
|
464
|
+
# {{var}} escaped
|
|
465
|
+
buf.append(_html.escape(str(_lookup(ctx_stack, tag_key)), quote=True))
|
|
466
|
+
i = m.end()
|
|
467
|
+
continue
|
|
468
|
+
if sigil == "#" or sigil == "^":
|
|
469
|
+
# Find matching close.
|
|
470
|
+
close_idx = _find_close(chunk, m.end(), tag_key)
|
|
471
|
+
inner = chunk[m.end() : close_idx]
|
|
472
|
+
val = _lookup(ctx_stack, tag_key)
|
|
473
|
+
if sigil == "#":
|
|
474
|
+
if isinstance(val, list):
|
|
475
|
+
for item in val:
|
|
476
|
+
sub_ctx = item if isinstance(item, dict) else {".": item}
|
|
477
|
+
buf.append(render_chunk(inner, ctx_stack + [sub_ctx]))
|
|
478
|
+
elif val:
|
|
479
|
+
sub_ctx = val if isinstance(val, dict) else {}
|
|
480
|
+
buf.append(render_chunk(inner, ctx_stack + [sub_ctx]))
|
|
481
|
+
# else: render nothing
|
|
482
|
+
else: # ^
|
|
483
|
+
is_empty_list = isinstance(val, list) and len(val) == 0
|
|
484
|
+
if (not val) or is_empty_list:
|
|
485
|
+
buf.append(render_chunk(inner, ctx_stack))
|
|
486
|
+
# Skip past the closing tag.
|
|
487
|
+
close_end = chunk.find("}}", close_idx) + 2
|
|
488
|
+
i = close_end
|
|
489
|
+
continue
|
|
490
|
+
if sigil == "/":
|
|
491
|
+
# Stray close — treat as literal (shouldn't happen with balanced templates).
|
|
492
|
+
buf.append(m.group(0))
|
|
493
|
+
i = m.end()
|
|
494
|
+
continue
|
|
495
|
+
return "".join(buf)
|
|
496
|
+
|
|
497
|
+
return render_chunk(template, [context])
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _lookup(ctx_stack: list[dict], key: str):
|
|
501
|
+
"""Walk the context stack from innermost to outermost; return '' if not
|
|
502
|
+
found so missing keys render as empty strings (matching Mustache spec)."""
|
|
503
|
+
for ctx in reversed(ctx_stack):
|
|
504
|
+
if isinstance(ctx, dict) and key in ctx:
|
|
505
|
+
return ctx[key]
|
|
506
|
+
return ""
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _find_close(chunk: str, start: int, key: str) -> int:
|
|
510
|
+
"""
|
|
511
|
+
Locate the start index of the matching {{/key}} closer, supporting nested
|
|
512
|
+
sections of the same name. Returns the index of the `{` in `{{/key}}`.
|
|
513
|
+
"""
|
|
514
|
+
depth = 1
|
|
515
|
+
i = start
|
|
516
|
+
while i < len(chunk):
|
|
517
|
+
m = _MU_RE.search(chunk, i)
|
|
518
|
+
if not m:
|
|
519
|
+
break
|
|
520
|
+
if m.group(1): # {{{raw}}} — skip
|
|
521
|
+
i = m.end()
|
|
522
|
+
continue
|
|
523
|
+
sigil = m.group(2)
|
|
524
|
+
tag_key = m.group(3)
|
|
525
|
+
if (sigil == "#" or sigil == "^") and tag_key == key:
|
|
526
|
+
depth += 1
|
|
527
|
+
elif sigil == "/" and tag_key == key:
|
|
528
|
+
depth -= 1
|
|
529
|
+
if depth == 0:
|
|
530
|
+
return m.start()
|
|
531
|
+
i = m.end()
|
|
532
|
+
raise ValueError(f"unclosed Mustache section: {{{{#{key}}}}}")
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
# ──────────────────────── minimal markdown -> HTML ───────────────────────────
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def render_markdown(src: str) -> str:
|
|
539
|
+
"""
|
|
540
|
+
Render a small subset of markdown to HTML.
|
|
541
|
+
|
|
542
|
+
First tries the optional `markdown` library if installed; falls back to a
|
|
543
|
+
minimal pure-stdlib renderer supporting headings, bullet lists, paragraphs,
|
|
544
|
+
inline **bold**, *italic*, `code`, and [text](url) links.
|
|
545
|
+
"""
|
|
546
|
+
try:
|
|
547
|
+
import markdown as _md # type: ignore
|
|
548
|
+
|
|
549
|
+
return _md.markdown(src, extensions=[])
|
|
550
|
+
except Exception:
|
|
551
|
+
return _minimal_markdown(src)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _minimal_markdown(src: str) -> str:
|
|
555
|
+
lines = src.splitlines()
|
|
556
|
+
out: list[str] = []
|
|
557
|
+
in_list = False
|
|
558
|
+
para: list[str] = []
|
|
559
|
+
|
|
560
|
+
def flush_para() -> None:
|
|
561
|
+
nonlocal para
|
|
562
|
+
if para:
|
|
563
|
+
text = " ".join(p.strip() for p in para if p.strip())
|
|
564
|
+
if text:
|
|
565
|
+
out.append("<p>" + _inline(text) + "</p>")
|
|
566
|
+
para = []
|
|
567
|
+
|
|
568
|
+
def flush_list() -> None:
|
|
569
|
+
nonlocal in_list
|
|
570
|
+
if in_list:
|
|
571
|
+
out.append("</ul>")
|
|
572
|
+
in_list = False
|
|
573
|
+
|
|
574
|
+
for raw in lines:
|
|
575
|
+
line = raw.rstrip()
|
|
576
|
+
if not line.strip():
|
|
577
|
+
flush_para()
|
|
578
|
+
flush_list()
|
|
579
|
+
continue
|
|
580
|
+
# Heading
|
|
581
|
+
m = re.match(r"^(#{1,6})\s+(.*)$", line)
|
|
582
|
+
if m:
|
|
583
|
+
flush_para()
|
|
584
|
+
flush_list()
|
|
585
|
+
level = len(m.group(1))
|
|
586
|
+
out.append(f"<h{level}>{_inline(m.group(2))}</h{level}>")
|
|
587
|
+
continue
|
|
588
|
+
# Bullet list
|
|
589
|
+
m = re.match(r"^[-*]\s+(.*)$", line)
|
|
590
|
+
if m:
|
|
591
|
+
flush_para()
|
|
592
|
+
if not in_list:
|
|
593
|
+
out.append("<ul>")
|
|
594
|
+
in_list = True
|
|
595
|
+
out.append(f"<li>{_inline(m.group(1))}</li>")
|
|
596
|
+
continue
|
|
597
|
+
# Otherwise accumulate paragraph
|
|
598
|
+
flush_list()
|
|
599
|
+
para.append(line)
|
|
600
|
+
|
|
601
|
+
flush_para()
|
|
602
|
+
flush_list()
|
|
603
|
+
return "\n".join(out)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _inline(text: str) -> str:
|
|
607
|
+
"""Inline markdown: bold, italic, code, links. Order matters: code first
|
|
608
|
+
(to protect contents from other rules), then links, then bold, then italic.
|
|
609
|
+
"""
|
|
610
|
+
# Code spans: `...` (no HTML escape inside, but escape special chars).
|
|
611
|
+
def code_sub(m):
|
|
612
|
+
return "<code>" + _html.escape(m.group(1)) + "</code>"
|
|
613
|
+
|
|
614
|
+
text = re.sub(r"`([^`]+)`", code_sub, text)
|
|
615
|
+
# Links: [text](url)
|
|
616
|
+
text = re.sub(
|
|
617
|
+
r"\[([^\]]+)\]\(([^)]+)\)",
|
|
618
|
+
lambda m: f'<a href="{_html.escape(m.group(2), quote=True)}">{m.group(1)}</a>',
|
|
619
|
+
text,
|
|
620
|
+
)
|
|
621
|
+
# Bold: **x** (greedy-safe via non-greedy)
|
|
622
|
+
text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
|
|
623
|
+
# Italic: *x* (after bold so we don't eat the inner)
|
|
624
|
+
text = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)", r"<em>\1</em>", text)
|
|
625
|
+
return text
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
# ──────────────────────────── layout routing ────────────────────────────────
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
# The default layout when a slide omits `layout:` — preserves the pre-layout
|
|
632
|
+
# behaviour (a plain lang-en / lang-zh body block).
|
|
633
|
+
DEFAULT_LAYOUT = "plain"
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
class LayoutResolver:
|
|
637
|
+
"""
|
|
638
|
+
Map a slide's `layout` name to its component partial path.
|
|
639
|
+
|
|
640
|
+
Partials live in `lib/slides/components/<layout>.html` next to this module.
|
|
641
|
+
The resolver does not read the file — it only computes and validates the
|
|
642
|
+
path so callers get a clear `Unknown layout: ...` error before any I/O.
|
|
643
|
+
"""
|
|
644
|
+
|
|
645
|
+
def __init__(self, components_dir: Path | None = None) -> None:
|
|
646
|
+
if components_dir is None:
|
|
647
|
+
components_dir = Path(__file__).resolve().parent / "slides" / "components"
|
|
648
|
+
self.components_dir = components_dir
|
|
649
|
+
|
|
650
|
+
def available(self) -> list[str]:
|
|
651
|
+
"""Return the sorted layout names backed by a `<name>.html` partial."""
|
|
652
|
+
if not self.components_dir.is_dir():
|
|
653
|
+
return []
|
|
654
|
+
names = [
|
|
655
|
+
p.stem
|
|
656
|
+
for p in self.components_dir.glob("*.html")
|
|
657
|
+
]
|
|
658
|
+
# Locale-independent ordering: plain (the default/fallback) first, then
|
|
659
|
+
# the rest in plain ASCII sort so the error message is deterministic
|
|
660
|
+
# across CI locales.
|
|
661
|
+
rest = sorted(n for n in names if n != DEFAULT_LAYOUT)
|
|
662
|
+
head = [DEFAULT_LAYOUT] if DEFAULT_LAYOUT in names else []
|
|
663
|
+
return head + rest
|
|
664
|
+
|
|
665
|
+
def resolve(self, layout: str) -> Path:
|
|
666
|
+
"""
|
|
667
|
+
Return the partial path for `layout`, or raise ValueError with the list
|
|
668
|
+
of available layouts when no matching partial file exists.
|
|
669
|
+
|
|
670
|
+
Layout names are restricted to `[a-z0-9-]+` so a malicious or malformed
|
|
671
|
+
`layout:` field (e.g. `../../etc/passwd`) can never escape
|
|
672
|
+
`components_dir` — anything else is reported as an unknown layout.
|
|
673
|
+
"""
|
|
674
|
+
if not re.fullmatch(r"[a-z0-9-]+", layout):
|
|
675
|
+
avail = ", ".join(self.available())
|
|
676
|
+
raise ValueError(f"Unknown layout: {layout}; available: {avail}")
|
|
677
|
+
path = self.components_dir / f"{layout}.html"
|
|
678
|
+
if not path.is_file():
|
|
679
|
+
avail = ", ".join(self.available())
|
|
680
|
+
raise ValueError(f"Unknown layout: {layout}; available: {avail}")
|
|
681
|
+
return path
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def render_slide_inner(slide: dict, resolver: LayoutResolver) -> str:
|
|
685
|
+
"""
|
|
686
|
+
Resolve the slide's layout to a partial, render that partial against the
|
|
687
|
+
slide dict, and return the inner HTML to inject into the main template's
|
|
688
|
+
slide slot.
|
|
689
|
+
|
|
690
|
+
The result is stripped of the partial's leading `<!-- ... -->` doc comment
|
|
691
|
+
and of surrounding blank lines so that, for the default `plain` layout, the
|
|
692
|
+
output is byte-identical to the pre-layout template body block.
|
|
693
|
+
"""
|
|
694
|
+
layout = slide.get("layout") or DEFAULT_LAYOUT
|
|
695
|
+
partial_path = resolver.resolve(layout)
|
|
696
|
+
partial = partial_path.read_text(encoding="utf-8")
|
|
697
|
+
# Drop a leading HTML doc comment (the `<!-- layout: requires ... -->`
|
|
698
|
+
# header every partial carries) so it never leaks into the rendered deck.
|
|
699
|
+
partial = re.sub(
|
|
700
|
+
r"\A\s*<!--.*?-->(?:\s*\n)?", "", partial, count=1, flags=re.DOTALL
|
|
701
|
+
)
|
|
702
|
+
rendered = mustache(partial, slide)
|
|
703
|
+
return rendered.strip("\n")
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
# ───────────────────────────── render_deck ──────────────────────────────────
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def render_deck(deck_path: Path, template_path: Path) -> str:
|
|
710
|
+
"""
|
|
711
|
+
High-level entry point — read deck.md + template, return rendered HTML.
|
|
712
|
+
"""
|
|
713
|
+
src = deck_path.read_text(encoding="utf-8")
|
|
714
|
+
fm, body = parse_frontmatter(src)
|
|
715
|
+
slides = parse_slides(body)
|
|
716
|
+
|
|
717
|
+
resolver = LayoutResolver()
|
|
718
|
+
|
|
719
|
+
# Pre-render body_en / body_zh markdown into HTML strings so the template
|
|
720
|
+
# can drop them in via {{{body_en_html}}}.
|
|
721
|
+
for slide in slides:
|
|
722
|
+
slide["body_en_html"] = render_markdown(slide.get("body_en", ""))
|
|
723
|
+
slide["body_zh_html"] = render_markdown(slide.get("body_zh", ""))
|
|
724
|
+
# Provide an `evidence` flag for inverted-section use in templates.
|
|
725
|
+
if "evidence" not in slide:
|
|
726
|
+
slide["evidence"] = []
|
|
727
|
+
# US-DECK-018: route each slide through its layout partial and inject
|
|
728
|
+
# the result via the main template's {{{slide_inner_html}}} slot.
|
|
729
|
+
slide["slide_inner_html"] = render_slide_inner(slide, resolver)
|
|
730
|
+
|
|
731
|
+
context: dict = dict(fm)
|
|
732
|
+
context["slides"] = slides
|
|
733
|
+
# Convenience: an `empty` key that's always false-ish — templates can
|
|
734
|
+
# use {{^empty}}...{{/empty}} as an "always render" wrapper if needed.
|
|
735
|
+
context.setdefault("empty", [])
|
|
736
|
+
|
|
737
|
+
template = template_path.read_text(encoding="utf-8")
|
|
738
|
+
return mustache(template, context)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def main(argv: list[str]) -> int:
|
|
742
|
+
if len(argv) < 3:
|
|
743
|
+
print(
|
|
744
|
+
"usage: slides-render.py <deck.md> <template.html> [out.html]\n"
|
|
745
|
+
"用法: slides-render.py <deck.md> <template.html> [out.html]",
|
|
746
|
+
file=sys.stderr,
|
|
747
|
+
)
|
|
748
|
+
return 1
|
|
749
|
+
|
|
750
|
+
deck = Path(argv[1])
|
|
751
|
+
tpl = Path(argv[2])
|
|
752
|
+
out_path = Path(argv[3]) if len(argv) >= 4 else None
|
|
753
|
+
|
|
754
|
+
if not deck.is_file():
|
|
755
|
+
print(f"[slides-render] deck not found: {deck}", file=sys.stderr)
|
|
756
|
+
print(f"[slides-render] 未找到 deck 文件:{deck}", file=sys.stderr)
|
|
757
|
+
return 1
|
|
758
|
+
if not tpl.is_file():
|
|
759
|
+
print(f"[slides-render] template not found: {tpl}", file=sys.stderr)
|
|
760
|
+
print(f"[slides-render] 未找到模板文件:{tpl}", file=sys.stderr)
|
|
761
|
+
return 2
|
|
762
|
+
|
|
763
|
+
try:
|
|
764
|
+
html_out = render_deck(deck, tpl)
|
|
765
|
+
except (ValueError, KeyError) as e:
|
|
766
|
+
print(f"[slides-render] render error: {e}", file=sys.stderr)
|
|
767
|
+
print(f"[slides-render] 渲染错误:{e}", file=sys.stderr)
|
|
768
|
+
return 3
|
|
769
|
+
|
|
770
|
+
if out_path:
|
|
771
|
+
out_path.write_text(html_out, encoding="utf-8")
|
|
772
|
+
else:
|
|
773
|
+
sys.stdout.write(html_out)
|
|
774
|
+
return 0
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
if __name__ == "__main__":
|
|
778
|
+
sys.exit(main(sys.argv))
|