@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.
Files changed (181) hide show
  1. package/CHANGELOG.md +717 -0
  2. package/LICENSE +21 -0
  3. package/README.md +65 -165
  4. package/bin/dream-test-quality-scan +110 -0
  5. package/bin/roll +14897 -815
  6. package/conventions/config.yaml +17 -1
  7. package/conventions/global/AGENTS.md +146 -100
  8. package/conventions/global/CLAUDE.md +1 -21
  9. package/conventions/global/GEMINI.md +8 -22
  10. package/conventions/global/project_rules.md +9 -0
  11. package/conventions/templates/backend-service/AGENTS.md +30 -81
  12. package/conventions/templates/backend-service/GEMINI.md +3 -3
  13. package/conventions/templates/backend-service/project_rules.md +16 -0
  14. package/conventions/templates/cli/AGENTS.md +31 -58
  15. package/conventions/templates/cli/CLAUDE.md +3 -5
  16. package/conventions/templates/cli/GEMINI.md +3 -3
  17. package/conventions/templates/cli/project_rules.md +16 -0
  18. package/conventions/templates/frontend-only/AGENTS.md +29 -64
  19. package/conventions/templates/frontend-only/GEMINI.md +3 -3
  20. package/conventions/templates/frontend-only/project_rules.md +14 -0
  21. package/conventions/templates/fullstack/AGENTS.md +31 -79
  22. package/conventions/templates/fullstack/CLAUDE.md +1 -1
  23. package/conventions/templates/fullstack/GEMINI.md +3 -3
  24. package/conventions/templates/fullstack/project_rules.md +15 -0
  25. package/lib/README.md +42 -0
  26. package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
  27. package/lib/__pycache__/loop-fmt.cpython-314.pyc +0 -0
  28. package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
  29. package/lib/__pycache__/loop_unstick.cpython-314.pyc +0 -0
  30. package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
  31. package/lib/__pycache__/prices_fetcher.cpython-314.pyc +0 -0
  32. package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
  33. package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
  34. package/lib/__pycache__/roll_git.cpython-314.pyc +0 -0
  35. package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
  36. package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
  37. package/lib/agent_usage/README.md +49 -0
  38. package/lib/agent_usage/__init__.py +108 -0
  39. package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
  40. package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
  41. package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
  42. package/lib/agent_usage/__pycache__/openai.cpython-314.pyc +0 -0
  43. package/lib/agent_usage/__pycache__/pi.cpython-314.pyc +0 -0
  44. package/lib/agent_usage/__pycache__/pi_emit.cpython-314.pyc +0 -0
  45. package/lib/agent_usage/__pycache__/qwen.cpython-314.pyc +0 -0
  46. package/lib/agent_usage/gemini.py +127 -0
  47. package/lib/agent_usage/kimi.py +278 -0
  48. package/lib/agent_usage/kimi_emit.py +123 -0
  49. package/lib/agent_usage/openai.py +126 -0
  50. package/lib/agent_usage/pi.py +200 -0
  51. package/lib/agent_usage/pi_emit.py +135 -0
  52. package/lib/agent_usage/qwen.py +128 -0
  53. package/lib/backfill-pi-usage.py +243 -0
  54. package/lib/changelog_audit.py +155 -0
  55. package/lib/changelog_generate.py +263 -0
  56. package/lib/context_feed_budget.sh +194 -0
  57. package/lib/github_sync.py +876 -0
  58. package/lib/i18n/README.md +54 -0
  59. package/lib/i18n/agent.sh +75 -0
  60. package/lib/i18n/alert.sh +20 -0
  61. package/lib/i18n/backlog.sh +96 -0
  62. package/lib/i18n/brief.sh +5 -0
  63. package/lib/i18n/changelog.sh +5 -0
  64. package/lib/i18n/ci.sh +15 -0
  65. package/lib/i18n/debug.sh +0 -0
  66. package/lib/i18n/doctor.sh +44 -0
  67. package/lib/i18n/dream.sh +0 -0
  68. package/lib/i18n/init.sh +91 -0
  69. package/lib/i18n/lang.sh +10 -0
  70. package/lib/i18n/loop.sh +140 -0
  71. package/lib/i18n/migrate.sh +74 -0
  72. package/lib/i18n/offboard.sh +31 -0
  73. package/lib/i18n/onboard.sh +0 -0
  74. package/lib/i18n/peer.sh +41 -0
  75. package/lib/i18n/peer_help.sh +25 -0
  76. package/lib/i18n/peer_reset.sh +7 -0
  77. package/lib/i18n/peer_status.sh +5 -0
  78. package/lib/i18n/prices.sh +3 -0
  79. package/lib/i18n/prices_refresh.sh +17 -0
  80. package/lib/i18n/prices_show.sh +7 -0
  81. package/lib/i18n/propose.sh +0 -0
  82. package/lib/i18n/release.sh +0 -0
  83. package/lib/i18n/research.sh +0 -0
  84. package/lib/i18n/review_pr.sh +0 -0
  85. package/lib/i18n/sentinel.sh +0 -0
  86. package/lib/i18n/setup.sh +3 -0
  87. package/lib/i18n/shared.sh +157 -0
  88. package/lib/i18n/skills/roll-brief.sh +47 -0
  89. package/lib/i18n/skills/roll-build.sh +97 -0
  90. package/lib/i18n/skills/roll-design.sh +18 -0
  91. package/lib/i18n/skills/roll-fix.sh +53 -0
  92. package/lib/i18n/skills/roll-loop.sh +28 -0
  93. package/lib/i18n/skills/roll-onboard.sh +33 -0
  94. package/lib/i18n/skills_catalog.sh +30 -0
  95. package/lib/i18n/slides.sh +3 -0
  96. package/lib/i18n/slides_build.sh +38 -0
  97. package/lib/i18n/slides_delete.sh +19 -0
  98. package/lib/i18n/slides_list.sh +14 -0
  99. package/lib/i18n/slides_logs.sh +12 -0
  100. package/lib/i18n/slides_new.sh +15 -0
  101. package/lib/i18n/slides_preview.sh +14 -0
  102. package/lib/i18n/slides_templates.sh +7 -0
  103. package/lib/i18n/status.sh +21 -0
  104. package/lib/i18n/update.sh +24 -0
  105. package/lib/i18n.sh +211 -0
  106. package/lib/loop-exit-summary.py +393 -0
  107. package/lib/loop-fmt.py +589 -0
  108. package/lib/loop_pick_agent.py +316 -0
  109. package/lib/loop_result_eval.py +469 -0
  110. package/lib/loop_unstick.py +180 -0
  111. package/lib/model_prices.py +186 -0
  112. package/lib/prices/README.md +35 -0
  113. package/lib/prices/snapshot-2026-05-22.json +22 -0
  114. package/lib/prices/snapshot-2026-05-23-deepseek.json +15 -0
  115. package/lib/prices/snapshot-2026-05-23-kimi.json +14 -0
  116. package/lib/prices_fetcher.py +285 -0
  117. package/lib/roll-backlog.py +225 -0
  118. package/lib/roll-brief.py +286 -0
  119. package/lib/roll-help.py +158 -0
  120. package/lib/roll-home.py +556 -0
  121. package/lib/roll-init.py +156 -0
  122. package/lib/roll-loop-status.py +1683 -0
  123. package/lib/roll-loop-story.py +191 -0
  124. package/lib/roll-onboard-render.py +378 -0
  125. package/lib/roll-peer.py +252 -0
  126. package/lib/roll-plan-validate.py +386 -0
  127. package/lib/roll-setup.py +102 -0
  128. package/lib/roll-status.py +367 -0
  129. package/lib/roll_git.py +41 -0
  130. package/lib/roll_render.py +414 -0
  131. package/lib/slides/components/README.md +123 -0
  132. package/lib/slides/components/cards-2.html +9 -0
  133. package/lib/slides/components/cards-3.html +9 -0
  134. package/lib/slides/components/cards-4.html +9 -0
  135. package/lib/slides/components/compare.html +22 -0
  136. package/lib/slides/components/highlight.html +9 -0
  137. package/lib/slides/components/pipeline.html +12 -0
  138. package/lib/slides/components/plain.html +7 -0
  139. package/lib/slides/components/quote.html +4 -0
  140. package/lib/slides/components/timeline.html +9 -0
  141. package/lib/slides/templates/introduction-v3.html +571 -0
  142. package/lib/slides/templates/pitch.html +0 -0
  143. package/lib/slides-render.py +778 -0
  144. package/lib/slides-validate.py +357 -0
  145. package/lib/test_quality_gate.py +143 -0
  146. package/package.json +8 -7
  147. package/skills/roll-.changelog/SKILL.md +406 -33
  148. package/skills/roll-.clarify/SKILL.md +5 -2
  149. package/skills/roll-.dream/SKILL.md +374 -0
  150. package/skills/roll-.echo/SKILL.md +5 -2
  151. package/skills/roll-.qa/SKILL.md +57 -3
  152. package/skills/roll-.review/SKILL.md +42 -3
  153. package/skills/roll-brief/SKILL.md +209 -0
  154. package/skills/roll-build/SKILL.md +308 -63
  155. package/skills/roll-debug/SKILL.md +341 -162
  156. package/skills/roll-debug/injectable-bb.js +263 -0
  157. package/skills/roll-deck/SKILL.md +296 -0
  158. package/skills/roll-design/ENGINEERING_CHECKLIST.md +1 -1
  159. package/skills/roll-design/SKILL.md +727 -94
  160. package/skills/roll-doc/SKILL.md +595 -0
  161. package/skills/roll-doctor/SKILL.md +192 -0
  162. package/skills/roll-fix/SKILL.md +149 -32
  163. package/skills/{roll-jot → roll-idea}/SKILL.md +18 -10
  164. package/skills/roll-loop/SKILL.md +578 -0
  165. package/skills/roll-notes/SKILL.md +103 -0
  166. package/skills/roll-onboard/SKILL.md +234 -0
  167. package/skills/roll-peer/SKILL.md +336 -0
  168. package/skills/roll-propose/SKILL.md +157 -0
  169. package/skills/roll-review-pr/SKILL.md +58 -0
  170. package/skills/roll-sentinel/SKILL.md +11 -2
  171. package/skills/roll-spar/SKILL.md +8 -6
  172. package/template/.github/workflows/ci.yml +5 -2
  173. package/template/AGENTS.md +20 -74
  174. package/skills/roll-research/SKILL.md +0 -307
  175. package/skills/roll-research/references/schema.json +0 -162
  176. package/skills/roll-research/scripts/md_to_pdf.py +0 -289
  177. package/tools/roll-fetch/SKILL.md +0 -182
  178. package/tools/roll-fetch/package.json +0 -15
  179. package/tools/roll-fetch/smart-web-fetch.js +0 -558
  180. package/tools/roll-probe/SKILL.md +0 -84
  181. /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))