@seanyao/roll 2026.521.2 → 2026.522.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 +12 -0
- package/README.md +1 -0
- package/bin/roll +913 -48
- package/lib/__pycache__/roll-loop-status.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/roll-help.py +1 -0
- package/lib/roll-loop-status.py +37 -3
- package/lib/roll_render.py +20 -1
- package/lib/slides-render.py +488 -0
- package/lib/slides-validate.py +169 -0
- package/package.json +1 -1
- package/skills/roll-.changelog/SKILL.md +19 -17
- package/skills/roll-.dream/SKILL.md +1 -1
- package/skills/roll-deck/SKILL.md +136 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/roll-help.py
CHANGED
|
@@ -51,6 +51,7 @@ PROJECT = [
|
|
|
51
51
|
("ci", "[--wait]", "show or wait for current commit's CI status", "查看 / 等待 CI 状态", False),
|
|
52
52
|
("release", "", "run the release script (human-only)", "执行发版脚本(仅人工)", False),
|
|
53
53
|
("review-pr", "<number>", "AI-powered code review for a PR", "AI 代码评审", False),
|
|
54
|
+
("slides", "build <slug>", "render a deck.md to HTML and open in browser", "渲染 deck.md 为 HTML 并打开", False),
|
|
54
55
|
]
|
|
55
56
|
|
|
56
57
|
MACHINE = [
|
package/lib/roll-loop-status.py
CHANGED
|
@@ -153,7 +153,8 @@ def load_backlog(project_root: Optional[Path] = None) -> Dict[str, str]:
|
|
|
153
153
|
# ════════════════════════════════════════════════════════════════════════════
|
|
154
154
|
# Cycle aggregation — group events by cycle label; attach cron + story id
|
|
155
155
|
# ════════════════════════════════════════════════════════════════════════════
|
|
156
|
-
_STORY_ID_PAT = re.compile(r"\b([A-Z]
|
|
156
|
+
_STORY_ID_PAT = re.compile(r"\b([A-Z]+(?:-[A-Z]+)*-\d+)\b")
|
|
157
|
+
_PR_NUM_PAT = re.compile(r"/pull/(\d+)")
|
|
157
158
|
|
|
158
159
|
def _extract_story_id(ev_detail: str) -> Optional[str]:
|
|
159
160
|
if not ev_detail:
|
|
@@ -161,6 +162,23 @@ def _extract_story_id(ev_detail: str) -> Optional[str]:
|
|
|
161
162
|
m = _STORY_ID_PAT.search(ev_detail)
|
|
162
163
|
return m.group(1) if m else None
|
|
163
164
|
|
|
165
|
+
def _extract_pr_num(url: str) -> Optional[int]:
|
|
166
|
+
if not url:
|
|
167
|
+
return None
|
|
168
|
+
m = _PR_NUM_PAT.search(url)
|
|
169
|
+
return int(m.group(1)) if m else None
|
|
170
|
+
|
|
171
|
+
def _normalize_pr_outcome(raw: str) -> str:
|
|
172
|
+
"""US-VIEW-011: 3-state PR landing tracker.
|
|
173
|
+
|
|
174
|
+
Legacy events wrote 'ok' at PR creation; treat as 'open' so old rows
|
|
175
|
+
don't render as an unknown state. New events emit 'open' (PR created),
|
|
176
|
+
'merged' (auto-merge landed), or 'closed' (PR closed without merge).
|
|
177
|
+
"""
|
|
178
|
+
if raw in ("merged", "closed", "open"):
|
|
179
|
+
return raw
|
|
180
|
+
return "open"
|
|
181
|
+
|
|
164
182
|
def normalize_cycle_label(lbl: str) -> str:
|
|
165
183
|
"""Strip the 'loop/cycle-' branch-name prefix so pr events bucket with
|
|
166
184
|
their cycle_start/end siblings (Bug A — see plan §3)."""
|
|
@@ -196,6 +214,12 @@ def aggregate(events: List[Dict[str, Any]], cron: List[Dict[str, Any]]) -> List[
|
|
|
196
214
|
elif stage == "pr":
|
|
197
215
|
cy["pr"] = detail
|
|
198
216
|
cy["pr_ts"] = e["_ts"] # used to match cron-log lines (inner cycle done)
|
|
217
|
+
# US-VIEW-011: capture PR # and landing outcome. Later pr events
|
|
218
|
+
# win (open → merged/closed finalization in cycle_end path).
|
|
219
|
+
pr_num = _extract_pr_num(detail)
|
|
220
|
+
if pr_num is not None:
|
|
221
|
+
cy["pr_num"] = pr_num
|
|
222
|
+
cy["pr_outcome"] = _normalize_pr_outcome(e.get("outcome", ""))
|
|
199
223
|
sid = _extract_story_id(detail) or _extract_story_id(lbl)
|
|
200
224
|
if sid and not cy.get("story"):
|
|
201
225
|
cy["story"] = sid
|
|
@@ -388,7 +412,7 @@ def load_pr_merges_from_git(days: int) -> Dict[str, Dict[str, Any]]:
|
|
|
388
412
|
result: Dict[str, Dict[str, Any]] = {}
|
|
389
413
|
label_re = re.compile(r"loop/cycle-([A-Za-z0-9-]+)")
|
|
390
414
|
pr_re = re.compile(r"#(\d+)")
|
|
391
|
-
story_re = re.compile(r"\b([A-Z]
|
|
415
|
+
story_re = re.compile(r"\b([A-Z]+(?:-[A-Z]+)*-\d+)\b")
|
|
392
416
|
for chunk in out.split("<<<END>>>"):
|
|
393
417
|
chunk = chunk.strip()
|
|
394
418
|
if not chunk:
|
|
@@ -422,6 +446,12 @@ def repair_orphan_cycles_from_git(cycles: List[Dict[str, Any]], git_merges: Dict
|
|
|
422
446
|
cy["outcome"] = "done"
|
|
423
447
|
if m["pr"] and not cy.get("pr"):
|
|
424
448
|
cy["pr"] = f"https://github.com/seanyao/roll/pull/{m['pr']}"
|
|
449
|
+
# US-VIEW-011: a merge commit in git proves the PR landed.
|
|
450
|
+
# Promote pr_outcome to 'merged' even when no terminal pr event
|
|
451
|
+
# was emitted (older cycles, missed runs, events truncation).
|
|
452
|
+
if m["pr"]:
|
|
453
|
+
cy["pr_num"] = int(m["pr"])
|
|
454
|
+
cy["pr_outcome"] = "merged"
|
|
425
455
|
# Fill stories when our existing sources didn't carry them. Filter
|
|
426
456
|
# to ones that actually appear in BACKLOG so we don't pull in stray
|
|
427
457
|
# tokens from the merge body (PR numbers, file paths, etc.).
|
|
@@ -532,7 +562,11 @@ def rollup_for_day(day_cycles: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
|
532
562
|
r["duration_s"] += cy["duration_s"]
|
|
533
563
|
if cy.get("tokens"):
|
|
534
564
|
r["tokens"] += cy["tokens"]
|
|
535
|
-
|
|
565
|
+
# US-VIEW-011: rollup only counts cycles whose PR actually merged.
|
|
566
|
+
# Backward compat: rows where pr_outcome is missing but pr URL exists
|
|
567
|
+
# (no `pr` event after the writer upgrade ran for that cycle) are
|
|
568
|
+
# treated conservatively as open — they shouldn't inflate merged count.
|
|
569
|
+
if cy.get("pr_outcome") == "merged":
|
|
536
570
|
r["prs"] += 1
|
|
537
571
|
if cy.get("cost_list") is not None:
|
|
538
572
|
r["cost"] += cy["cost_list"]
|
package/lib/roll_render.py
CHANGED
|
@@ -273,6 +273,7 @@ def day_band(day_key: str, n_total: int, n_failed: int, now: datetime, *,
|
|
|
273
273
|
|
|
274
274
|
def cycle_row(cy: Dict[str, Any], backlog: Dict[str, str]) -> None:
|
|
275
275
|
outcome = cy.get("outcome", "done")
|
|
276
|
+
pr_outcome = cy.get("pr_outcome")
|
|
276
277
|
glyph_c, glyph = {
|
|
277
278
|
"done": ("green", "✓"),
|
|
278
279
|
"ok": ("green", "✓"),
|
|
@@ -280,6 +281,11 @@ def cycle_row(cy: Dict[str, Any], backlog: Dict[str, str]) -> None:
|
|
|
280
281
|
"running": ("purple", "⏵"),
|
|
281
282
|
"idle": ("muted", "·"),
|
|
282
283
|
}.get(outcome, ("muted", "·"))
|
|
284
|
+
# US-VIEW-011: a completed cycle whose PR was closed without merging is
|
|
285
|
+
# a "wasted run" — flip the green ✓ to an amber ⊘ so it can't be
|
|
286
|
+
# mistaken for a real delivery when scanning the dashboard.
|
|
287
|
+
if outcome in ("done", "ok") and pr_outcome == "closed":
|
|
288
|
+
glyph_c, glyph = "amber", "⊘"
|
|
283
289
|
time_str = cy["start"].astimezone().strftime("%H:%M")
|
|
284
290
|
cr = cy.get("cron") or {}
|
|
285
291
|
# duration prefers the explicit cy["duration_s"] (computed from event
|
|
@@ -318,6 +324,19 @@ def cycle_row(cy: Dict[str, Any], backlog: Dict[str, str]) -> None:
|
|
|
318
324
|
# when terminal is < 100 cols (cost / story IDs are higher-priority).
|
|
319
325
|
show_model = COLS >= 100
|
|
320
326
|
model_seg = c("muted", pad(model_label, 11)) + " " if show_model else ""
|
|
327
|
+
# US-VIEW-011: PR landing marker after the story id(s).
|
|
328
|
+
# merged → "#NN ✓" green
|
|
329
|
+
# closed → "#NN ↩" amber (paired with ⊘ glyph above)
|
|
330
|
+
# open → "#NN …" dim (still landing; auto-merge or human pending)
|
|
331
|
+
pr_marker = ""
|
|
332
|
+
pr_num = cy.get("pr_num")
|
|
333
|
+
if pr_num is not None and pr_outcome:
|
|
334
|
+
mark_c, mark_sym = {
|
|
335
|
+
"merged": ("green", "✓"),
|
|
336
|
+
"closed": ("amber", "↩"),
|
|
337
|
+
"open": ("dim", "…"),
|
|
338
|
+
}.get(pr_outcome, ("dim", "…"))
|
|
339
|
+
pr_marker = " " + c(mark_c, f"#{pr_num} {mark_sym}")
|
|
321
340
|
inner = (
|
|
322
341
|
" " + c(glyph_c, glyph, bold=True) + " " +
|
|
323
342
|
c(time_c, pad(time_str, 5), bold=(outcome == "fail")) + " " +
|
|
@@ -325,7 +344,7 @@ def cycle_row(cy: Dict[str, Any], backlog: Dict[str, str]) -> None:
|
|
|
325
344
|
c("muted", pad(tok, 6, "r")) + " " +
|
|
326
345
|
model_seg +
|
|
327
346
|
c("muted", pad(cost, 7, "r")) + " " +
|
|
328
|
-
c(sid_c, ids_str, bold=True)
|
|
347
|
+
c(sid_c, ids_str, bold=True) + pr_marker
|
|
329
348
|
)
|
|
330
349
|
# Subtle red bg on failure rows so a fail can't be missed at a glance.
|
|
331
350
|
if outcome == "fail" and USE_COLOR:
|
|
@@ -0,0 +1,488 @@
|
|
|
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
|
+
# A slide section starts at a line matching `^## Slide \d+` and continues
|
|
103
|
+
# until the next such line or EOF.
|
|
104
|
+
_SLIDE_HEADER_RE = re.compile(r"^##\s+Slide\s+(\d+)\s*$")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_slides(body: str) -> list[dict]:
|
|
108
|
+
"""
|
|
109
|
+
Walk the body and split it into slide dicts.
|
|
110
|
+
|
|
111
|
+
Each slide dict has keys: number (int), title_en, title_zh, body_en,
|
|
112
|
+
body_zh, evidence (list[str]). Missing keys are left absent so that
|
|
113
|
+
validation can report them.
|
|
114
|
+
"""
|
|
115
|
+
lines = body.splitlines()
|
|
116
|
+
slides: list[dict] = []
|
|
117
|
+
cur: dict | None = None
|
|
118
|
+
cur_lines: list[str] = []
|
|
119
|
+
for line in lines:
|
|
120
|
+
m = _SLIDE_HEADER_RE.match(line)
|
|
121
|
+
if m:
|
|
122
|
+
if cur is not None:
|
|
123
|
+
_populate_slide(cur, cur_lines)
|
|
124
|
+
slides.append(cur)
|
|
125
|
+
cur = {"number": int(m.group(1))}
|
|
126
|
+
cur_lines = []
|
|
127
|
+
else:
|
|
128
|
+
if cur is not None:
|
|
129
|
+
cur_lines.append(line)
|
|
130
|
+
if cur is not None:
|
|
131
|
+
_populate_slide(cur, cur_lines)
|
|
132
|
+
slides.append(cur)
|
|
133
|
+
return slides
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _populate_slide(slide: dict, content_lines: list[str]) -> None:
|
|
137
|
+
"""
|
|
138
|
+
Parse the lines following a `## Slide N` header into the slide dict.
|
|
139
|
+
|
|
140
|
+
Grammar (subset):
|
|
141
|
+
key: "value" -> scalar
|
|
142
|
+
key: | -> block literal, takes indented
|
|
143
|
+
line one lines until the indent drops.
|
|
144
|
+
line two
|
|
145
|
+
evidence: -> list, takes `- item` lines
|
|
146
|
+
- one.md:1
|
|
147
|
+
- two.md:7
|
|
148
|
+
"""
|
|
149
|
+
i = 0
|
|
150
|
+
n = len(content_lines)
|
|
151
|
+
while i < n:
|
|
152
|
+
raw = content_lines[i]
|
|
153
|
+
stripped = raw.strip()
|
|
154
|
+
if not stripped:
|
|
155
|
+
i += 1
|
|
156
|
+
continue
|
|
157
|
+
if ":" not in raw:
|
|
158
|
+
i += 1
|
|
159
|
+
continue
|
|
160
|
+
key, _, val = raw.partition(":")
|
|
161
|
+
key = key.strip()
|
|
162
|
+
val = val.strip()
|
|
163
|
+
|
|
164
|
+
if val == "|":
|
|
165
|
+
# Block literal: gather lines until the indent drops below the
|
|
166
|
+
# indent of the first non-blank line. Strip exactly that common
|
|
167
|
+
# indent from every line so the body markdown starts at column 0.
|
|
168
|
+
block: list[str] = []
|
|
169
|
+
common_indent: int | None = None
|
|
170
|
+
i += 1
|
|
171
|
+
while i < n:
|
|
172
|
+
bl = content_lines[i]
|
|
173
|
+
if bl.strip() == "":
|
|
174
|
+
block.append("")
|
|
175
|
+
i += 1
|
|
176
|
+
continue
|
|
177
|
+
indent = len(bl) - len(bl.lstrip(" "))
|
|
178
|
+
if common_indent is None:
|
|
179
|
+
if indent == 0:
|
|
180
|
+
# No indent at all → block literal is empty.
|
|
181
|
+
break
|
|
182
|
+
common_indent = indent
|
|
183
|
+
elif indent < common_indent:
|
|
184
|
+
break
|
|
185
|
+
block.append(bl[common_indent:])
|
|
186
|
+
i += 1
|
|
187
|
+
while block and block[-1] == "":
|
|
188
|
+
block.pop()
|
|
189
|
+
slide[key] = "\n".join(block) + "\n" if block else ""
|
|
190
|
+
elif val == "":
|
|
191
|
+
# Could be `evidence:` list or an empty scalar.
|
|
192
|
+
list_items: list[str] = []
|
|
193
|
+
j = i + 1
|
|
194
|
+
while j < n:
|
|
195
|
+
bl = content_lines[j]
|
|
196
|
+
if bl.strip() == "":
|
|
197
|
+
j += 1
|
|
198
|
+
continue
|
|
199
|
+
if bl.lstrip().startswith("- "):
|
|
200
|
+
list_items.append(bl.lstrip()[2:].strip())
|
|
201
|
+
j += 1
|
|
202
|
+
continue
|
|
203
|
+
break
|
|
204
|
+
if list_items:
|
|
205
|
+
slide[key] = list_items
|
|
206
|
+
i = j
|
|
207
|
+
else:
|
|
208
|
+
slide[key] = ""
|
|
209
|
+
i += 1
|
|
210
|
+
else:
|
|
211
|
+
slide[key] = _coerce_scalar(val)
|
|
212
|
+
i += 1
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ─────────────────────────── Mustache subset ─────────────────────────────────
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# Match either {{{raw}}} or {{tag}} (which may be #section, ^inverted, /close).
|
|
219
|
+
# {{{...}}} must be tried first because of greedy match.
|
|
220
|
+
_MU_RE = re.compile(r"\{\{\{(\w+)\}\}\}|\{\{([#^/]?)\s*(\w+)\s*\}\}")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def mustache(template: str, context: dict) -> str:
|
|
224
|
+
"""
|
|
225
|
+
Render a Mustache-subset template against `context`.
|
|
226
|
+
|
|
227
|
+
Implementation is a simple stack-based parser: scan tokens left to right;
|
|
228
|
+
keep a "section stack" of (kind, key, sub_template_start) frames. When a
|
|
229
|
+
closing tag matches the top frame, render the captured sub-template once
|
|
230
|
+
per item (for `#`) or once if falsy (for `^`).
|
|
231
|
+
"""
|
|
232
|
+
out: list[str] = []
|
|
233
|
+
pos = 0
|
|
234
|
+
# We'll walk the template; when we see {{#x}} or {{^x}} we collect tokens
|
|
235
|
+
# until the matching {{/x}}, accounting for nested same-name sections.
|
|
236
|
+
stack: list[tuple[str, str, int]] = [] # (kind, key, start_pos_in_out)
|
|
237
|
+
# We need to re-render section bodies with possibly multiple contexts, so
|
|
238
|
+
# we capture the raw substring between {{#x}} and {{/x}} and recurse.
|
|
239
|
+
def render_chunk(chunk: str, ctx_stack: list[dict]) -> str:
|
|
240
|
+
buf: list[str] = []
|
|
241
|
+
i = 0
|
|
242
|
+
while i < len(chunk):
|
|
243
|
+
m = _MU_RE.search(chunk, i)
|
|
244
|
+
if not m:
|
|
245
|
+
buf.append(chunk[i:])
|
|
246
|
+
break
|
|
247
|
+
buf.append(chunk[i : m.start()])
|
|
248
|
+
raw_key = m.group(1)
|
|
249
|
+
sigil = m.group(2)
|
|
250
|
+
tag_key = m.group(3)
|
|
251
|
+
if raw_key:
|
|
252
|
+
# {{{raw}}}
|
|
253
|
+
buf.append(str(_lookup(ctx_stack, raw_key)))
|
|
254
|
+
i = m.end()
|
|
255
|
+
continue
|
|
256
|
+
if sigil == "":
|
|
257
|
+
# {{var}} escaped
|
|
258
|
+
buf.append(_html.escape(str(_lookup(ctx_stack, tag_key)), quote=True))
|
|
259
|
+
i = m.end()
|
|
260
|
+
continue
|
|
261
|
+
if sigil == "#" or sigil == "^":
|
|
262
|
+
# Find matching close.
|
|
263
|
+
close_idx = _find_close(chunk, m.end(), tag_key)
|
|
264
|
+
inner = chunk[m.end() : close_idx]
|
|
265
|
+
val = _lookup(ctx_stack, tag_key)
|
|
266
|
+
if sigil == "#":
|
|
267
|
+
if isinstance(val, list):
|
|
268
|
+
for item in val:
|
|
269
|
+
sub_ctx = item if isinstance(item, dict) else {".": item}
|
|
270
|
+
buf.append(render_chunk(inner, ctx_stack + [sub_ctx]))
|
|
271
|
+
elif val:
|
|
272
|
+
sub_ctx = val if isinstance(val, dict) else {}
|
|
273
|
+
buf.append(render_chunk(inner, ctx_stack + [sub_ctx]))
|
|
274
|
+
# else: render nothing
|
|
275
|
+
else: # ^
|
|
276
|
+
is_empty_list = isinstance(val, list) and len(val) == 0
|
|
277
|
+
if (not val) or is_empty_list:
|
|
278
|
+
buf.append(render_chunk(inner, ctx_stack))
|
|
279
|
+
# Skip past the closing tag.
|
|
280
|
+
close_end = chunk.find("}}", close_idx) + 2
|
|
281
|
+
i = close_end
|
|
282
|
+
continue
|
|
283
|
+
if sigil == "/":
|
|
284
|
+
# Stray close — treat as literal (shouldn't happen with balanced templates).
|
|
285
|
+
buf.append(m.group(0))
|
|
286
|
+
i = m.end()
|
|
287
|
+
continue
|
|
288
|
+
return "".join(buf)
|
|
289
|
+
|
|
290
|
+
return render_chunk(template, [context])
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _lookup(ctx_stack: list[dict], key: str):
|
|
294
|
+
"""Walk the context stack from innermost to outermost; return '' if not
|
|
295
|
+
found so missing keys render as empty strings (matching Mustache spec)."""
|
|
296
|
+
for ctx in reversed(ctx_stack):
|
|
297
|
+
if isinstance(ctx, dict) and key in ctx:
|
|
298
|
+
return ctx[key]
|
|
299
|
+
return ""
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _find_close(chunk: str, start: int, key: str) -> int:
|
|
303
|
+
"""
|
|
304
|
+
Locate the start index of the matching {{/key}} closer, supporting nested
|
|
305
|
+
sections of the same name. Returns the index of the `{` in `{{/key}}`.
|
|
306
|
+
"""
|
|
307
|
+
depth = 1
|
|
308
|
+
i = start
|
|
309
|
+
while i < len(chunk):
|
|
310
|
+
m = _MU_RE.search(chunk, i)
|
|
311
|
+
if not m:
|
|
312
|
+
break
|
|
313
|
+
if m.group(1): # {{{raw}}} — skip
|
|
314
|
+
i = m.end()
|
|
315
|
+
continue
|
|
316
|
+
sigil = m.group(2)
|
|
317
|
+
tag_key = m.group(3)
|
|
318
|
+
if (sigil == "#" or sigil == "^") and tag_key == key:
|
|
319
|
+
depth += 1
|
|
320
|
+
elif sigil == "/" and tag_key == key:
|
|
321
|
+
depth -= 1
|
|
322
|
+
if depth == 0:
|
|
323
|
+
return m.start()
|
|
324
|
+
i = m.end()
|
|
325
|
+
raise ValueError(f"unclosed Mustache section: {{{{#{key}}}}}")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ──────────────────────── minimal markdown -> HTML ───────────────────────────
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def render_markdown(src: str) -> str:
|
|
332
|
+
"""
|
|
333
|
+
Render a small subset of markdown to HTML.
|
|
334
|
+
|
|
335
|
+
First tries the optional `markdown` library if installed; falls back to a
|
|
336
|
+
minimal pure-stdlib renderer supporting headings, bullet lists, paragraphs,
|
|
337
|
+
inline **bold**, *italic*, `code`, and [text](url) links.
|
|
338
|
+
"""
|
|
339
|
+
try:
|
|
340
|
+
import markdown as _md # type: ignore
|
|
341
|
+
|
|
342
|
+
return _md.markdown(src, extensions=[])
|
|
343
|
+
except Exception:
|
|
344
|
+
return _minimal_markdown(src)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _minimal_markdown(src: str) -> str:
|
|
348
|
+
lines = src.splitlines()
|
|
349
|
+
out: list[str] = []
|
|
350
|
+
in_list = False
|
|
351
|
+
para: list[str] = []
|
|
352
|
+
|
|
353
|
+
def flush_para() -> None:
|
|
354
|
+
nonlocal para
|
|
355
|
+
if para:
|
|
356
|
+
text = " ".join(p.strip() for p in para if p.strip())
|
|
357
|
+
if text:
|
|
358
|
+
out.append("<p>" + _inline(text) + "</p>")
|
|
359
|
+
para = []
|
|
360
|
+
|
|
361
|
+
def flush_list() -> None:
|
|
362
|
+
nonlocal in_list
|
|
363
|
+
if in_list:
|
|
364
|
+
out.append("</ul>")
|
|
365
|
+
in_list = False
|
|
366
|
+
|
|
367
|
+
for raw in lines:
|
|
368
|
+
line = raw.rstrip()
|
|
369
|
+
if not line.strip():
|
|
370
|
+
flush_para()
|
|
371
|
+
flush_list()
|
|
372
|
+
continue
|
|
373
|
+
# Heading
|
|
374
|
+
m = re.match(r"^(#{1,6})\s+(.*)$", line)
|
|
375
|
+
if m:
|
|
376
|
+
flush_para()
|
|
377
|
+
flush_list()
|
|
378
|
+
level = len(m.group(1))
|
|
379
|
+
out.append(f"<h{level}>{_inline(m.group(2))}</h{level}>")
|
|
380
|
+
continue
|
|
381
|
+
# Bullet list
|
|
382
|
+
m = re.match(r"^[-*]\s+(.*)$", line)
|
|
383
|
+
if m:
|
|
384
|
+
flush_para()
|
|
385
|
+
if not in_list:
|
|
386
|
+
out.append("<ul>")
|
|
387
|
+
in_list = True
|
|
388
|
+
out.append(f"<li>{_inline(m.group(1))}</li>")
|
|
389
|
+
continue
|
|
390
|
+
# Otherwise accumulate paragraph
|
|
391
|
+
flush_list()
|
|
392
|
+
para.append(line)
|
|
393
|
+
|
|
394
|
+
flush_para()
|
|
395
|
+
flush_list()
|
|
396
|
+
return "\n".join(out)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _inline(text: str) -> str:
|
|
400
|
+
"""Inline markdown: bold, italic, code, links. Order matters: code first
|
|
401
|
+
(to protect contents from other rules), then links, then bold, then italic.
|
|
402
|
+
"""
|
|
403
|
+
# Code spans: `...` (no HTML escape inside, but escape special chars).
|
|
404
|
+
def code_sub(m):
|
|
405
|
+
return "<code>" + _html.escape(m.group(1)) + "</code>"
|
|
406
|
+
|
|
407
|
+
text = re.sub(r"`([^`]+)`", code_sub, text)
|
|
408
|
+
# Links: [text](url)
|
|
409
|
+
text = re.sub(
|
|
410
|
+
r"\[([^\]]+)\]\(([^)]+)\)",
|
|
411
|
+
lambda m: f'<a href="{_html.escape(m.group(2), quote=True)}">{m.group(1)}</a>',
|
|
412
|
+
text,
|
|
413
|
+
)
|
|
414
|
+
# Bold: **x** (greedy-safe via non-greedy)
|
|
415
|
+
text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
|
|
416
|
+
# Italic: *x* (after bold so we don't eat the inner)
|
|
417
|
+
text = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)", r"<em>\1</em>", text)
|
|
418
|
+
return text
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
# ───────────────────────────── render_deck ──────────────────────────────────
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def render_deck(deck_path: Path, template_path: Path) -> str:
|
|
425
|
+
"""
|
|
426
|
+
High-level entry point — read deck.md + template, return rendered HTML.
|
|
427
|
+
"""
|
|
428
|
+
src = deck_path.read_text(encoding="utf-8")
|
|
429
|
+
fm, body = parse_frontmatter(src)
|
|
430
|
+
slides = parse_slides(body)
|
|
431
|
+
|
|
432
|
+
# Pre-render body_en / body_zh markdown into HTML strings so the template
|
|
433
|
+
# can drop them in via {{{body_en_html}}}.
|
|
434
|
+
for slide in slides:
|
|
435
|
+
slide["body_en_html"] = render_markdown(slide.get("body_en", ""))
|
|
436
|
+
slide["body_zh_html"] = render_markdown(slide.get("body_zh", ""))
|
|
437
|
+
# Provide an `evidence` flag for inverted-section use in templates.
|
|
438
|
+
if "evidence" not in slide:
|
|
439
|
+
slide["evidence"] = []
|
|
440
|
+
|
|
441
|
+
context: dict = dict(fm)
|
|
442
|
+
context["slides"] = slides
|
|
443
|
+
# Convenience: an `empty` key that's always false-ish — templates can
|
|
444
|
+
# use {{^empty}}...{{/empty}} as an "always render" wrapper if needed.
|
|
445
|
+
context.setdefault("empty", [])
|
|
446
|
+
|
|
447
|
+
template = template_path.read_text(encoding="utf-8")
|
|
448
|
+
return mustache(template, context)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def main(argv: list[str]) -> int:
|
|
452
|
+
if len(argv) < 3:
|
|
453
|
+
print(
|
|
454
|
+
"usage: slides-render.py <deck.md> <template.html> [out.html]\n"
|
|
455
|
+
"用法: slides-render.py <deck.md> <template.html> [out.html]",
|
|
456
|
+
file=sys.stderr,
|
|
457
|
+
)
|
|
458
|
+
return 1
|
|
459
|
+
|
|
460
|
+
deck = Path(argv[1])
|
|
461
|
+
tpl = Path(argv[2])
|
|
462
|
+
out_path = Path(argv[3]) if len(argv) >= 4 else None
|
|
463
|
+
|
|
464
|
+
if not deck.is_file():
|
|
465
|
+
print(f"[slides-render] deck not found: {deck}", file=sys.stderr)
|
|
466
|
+
print(f"[slides-render] 未找到 deck 文件:{deck}", file=sys.stderr)
|
|
467
|
+
return 1
|
|
468
|
+
if not tpl.is_file():
|
|
469
|
+
print(f"[slides-render] template not found: {tpl}", file=sys.stderr)
|
|
470
|
+
print(f"[slides-render] 未找到模板文件:{tpl}", file=sys.stderr)
|
|
471
|
+
return 2
|
|
472
|
+
|
|
473
|
+
try:
|
|
474
|
+
html_out = render_deck(deck, tpl)
|
|
475
|
+
except (ValueError, KeyError) as e:
|
|
476
|
+
print(f"[slides-render] render error: {e}", file=sys.stderr)
|
|
477
|
+
print(f"[slides-render] 渲染错误:{e}", file=sys.stderr)
|
|
478
|
+
return 3
|
|
479
|
+
|
|
480
|
+
if out_path:
|
|
481
|
+
out_path.write_text(html_out, encoding="utf-8")
|
|
482
|
+
else:
|
|
483
|
+
sys.stdout.write(html_out)
|
|
484
|
+
return 0
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
if __name__ == "__main__":
|
|
488
|
+
sys.exit(main(sys.argv))
|