@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/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 = [
@@ -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]+-\d+)\b")
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]+-\d+)\b")
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
- if cy.get("pr") and cy["pr"].startswith("http"):
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"]
@@ -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))