@kamishibai/sdk 0.1.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 (136) hide show
  1. package/README.md +192 -0
  2. package/package.json +54 -0
  3. package/src/blocks/board.js +210 -0
  4. package/src/blocks/callout.js +63 -0
  5. package/src/blocks/code.js +28 -0
  6. package/src/blocks/deck.js +76 -0
  7. package/src/blocks/diagram.js +265 -0
  8. package/src/blocks/element.js +51 -0
  9. package/src/blocks/graph.js +264 -0
  10. package/src/blocks/grid.js +156 -0
  11. package/src/blocks/index.js +106 -0
  12. package/src/blocks/list.js +50 -0
  13. package/src/blocks/placement.js +119 -0
  14. package/src/blocks/prose.js +28 -0
  15. package/src/blocks/quote.js +25 -0
  16. package/src/blocks/raw.js +47 -0
  17. package/src/blocks/registry.js +158 -0
  18. package/src/blocks/schema-parts.js +19 -0
  19. package/src/blocks/section.js +53 -0
  20. package/src/blocks/slide.js +104 -0
  21. package/src/blocks/stat.js +83 -0
  22. package/src/blocks/table.js +50 -0
  23. package/src/blocks/timeline.js +80 -0
  24. package/src/cli/commands/close.js +51 -0
  25. package/src/cli/commands/comments.js +73 -0
  26. package/src/cli/commands/debug.js +34 -0
  27. package/src/cli/commands/example.js +22 -0
  28. package/src/cli/commands/export.js +10 -0
  29. package/src/cli/commands/init.js +53 -0
  30. package/src/cli/commands/lint.js +93 -0
  31. package/src/cli/commands/list.js +54 -0
  32. package/src/cli/commands/open.js +31 -0
  33. package/src/cli/commands/promote.js +38 -0
  34. package/src/cli/commands/render.js +31 -0
  35. package/src/cli/commands/replay.js +49 -0
  36. package/src/cli/commands/schema.js +10 -0
  37. package/src/cli/commands/serve.js +178 -0
  38. package/src/cli/commands/setup.js +64 -0
  39. package/src/cli/commands/snapshot.js +29 -0
  40. package/src/cli/commands/templates.js +75 -0
  41. package/src/cli/deliver.js +54 -0
  42. package/src/cli/emit.js +29 -0
  43. package/src/cli/format.js +153 -0
  44. package/src/cli/index.js +365 -0
  45. package/src/cli/registry.js +18 -0
  46. package/src/core/blocks.js +147 -0
  47. package/src/core/diagram.js +282 -0
  48. package/src/core/errors.js +156 -0
  49. package/src/core/example.js +109 -0
  50. package/src/core/ir.js +62 -0
  51. package/src/core/lint-gates.js +427 -0
  52. package/src/core/lint.js +281 -0
  53. package/src/core/scan.js +84 -0
  54. package/src/core/schema.js +88 -0
  55. package/src/core/spec-check.js +33 -0
  56. package/src/core/validate.js +44 -0
  57. package/src/core/version.js +18 -0
  58. package/src/core/vocabulary.js +140 -0
  59. package/src/delivery/atomic.js +71 -0
  60. package/src/delivery/comments.js +180 -0
  61. package/src/delivery/home.js +70 -0
  62. package/src/delivery/open.js +31 -0
  63. package/src/delivery/project.js +141 -0
  64. package/src/delivery/read.js +109 -0
  65. package/src/delivery/run.js +95 -0
  66. package/src/delivery/scaffold-blueprints.js +728 -0
  67. package/src/delivery/store.js +219 -0
  68. package/src/delivery/template-extensions.js +183 -0
  69. package/src/delivery/template-format.js +112 -0
  70. package/src/delivery/template-package.js +376 -0
  71. package/src/delivery/template-promote.js +240 -0
  72. package/src/delivery/template-scaffold.js +181 -0
  73. package/src/delivery/templates.js +192 -0
  74. package/src/delivery/toml.js +195 -0
  75. package/src/delivery/write.js +35 -0
  76. package/src/export/browser.js +130 -0
  77. package/src/export/index.js +96 -0
  78. package/src/export/pdf.js +25 -0
  79. package/src/export/png.js +40 -0
  80. package/src/export/pptx.js +48 -0
  81. package/src/export/slides.js +33 -0
  82. package/src/export/snapshot.js +33 -0
  83. package/src/layouts/article.js +103 -0
  84. package/src/layouts/canvas.js +144 -0
  85. package/src/layouts/card.js +128 -0
  86. package/src/layouts/deck.js +88 -0
  87. package/src/layouts/index.js +90 -0
  88. package/src/layouts/one-page.js +161 -0
  89. package/src/layouts/registry.js +251 -0
  90. package/src/layouts/resume.js +172 -0
  91. package/src/layouts/template-index.js +78 -0
  92. package/src/parser/artifact.js +38 -0
  93. package/src/parser/container.js +103 -0
  94. package/src/parser/index.js +223 -0
  95. package/src/parser/tokens.js +265 -0
  96. package/src/render/board-filter.client.js +80 -0
  97. package/src/render/compile.js +29 -0
  98. package/src/render/context.js +98 -0
  99. package/src/render/element.js +32 -0
  100. package/src/render/fonts.js +129 -0
  101. package/src/render/graph-hover.client.js +148 -0
  102. package/src/render/html.js +52 -0
  103. package/src/render/index.js +241 -0
  104. package/src/render/measure.js +60 -0
  105. package/src/render/placement.js +136 -0
  106. package/src/render/playback.client.js +74 -0
  107. package/src/render/scale-to-fit.client.js +136 -0
  108. package/src/render/scale.js +41 -0
  109. package/src/render/skeleton.js +131 -0
  110. package/src/render/ssr.js +24 -0
  111. package/src/render/styles.js +56 -0
  112. package/src/render/templates.js +191 -0
  113. package/src/serve/daemon.js +117 -0
  114. package/src/serve/overlay.js +213 -0
  115. package/src/serve/protocol.js +36 -0
  116. package/src/serve/server.js +264 -0
  117. package/templates/kami/cards/components.js +40 -0
  118. package/templates/kami/cards/index.js +25 -0
  119. package/templates/kami/cards/manifest.js +67 -0
  120. package/templates/kami/cards/styles.css +389 -0
  121. package/templates/kami/long-form/components.js +102 -0
  122. package/templates/kami/long-form/index.js +25 -0
  123. package/templates/kami/long-form/manifest.js +87 -0
  124. package/templates/kami/long-form/styles.css +481 -0
  125. package/templates/kami/one-page/components.js +48 -0
  126. package/templates/kami/one-page/index.js +27 -0
  127. package/templates/kami/one-page/manifest.js +65 -0
  128. package/templates/kami/one-page/styles.css +375 -0
  129. package/templates/kami/resume/components.js +51 -0
  130. package/templates/kami/resume/index.js +27 -0
  131. package/templates/kami/resume/manifest.js +65 -0
  132. package/templates/kami/resume/styles.css +424 -0
  133. package/templates/kami/slides/components.js +41 -0
  134. package/templates/kami/slides/index.js +26 -0
  135. package/templates/kami/slides/manifest.js +64 -0
  136. package/templates/kami/slides/styles.css +406 -0
@@ -0,0 +1,281 @@
1
+ import { CODES, ROOT_PATH } from './errors.js'
2
+ import { validateIr } from './validate.js'
3
+ import { cssRuleBlocks, liveMarkup, styleBlocks, styleRegions, svgRegions } from './scan.js'
4
+ import { gateRuleListing, lintGates } from './lint-gates.js'
5
+
6
+ /**
7
+ * CONTRACT 外部資源禁形 — every artifact must be able to live offline.
8
+ * `scope` picks the region each rule is judged against (see ./scan.js).
9
+ *
10
+ * Exported as the single source of truth: the A3 test asserts against these
11
+ * very rules rather than transcribing its own copy, so the禁形 list can never
12
+ * drift between the linter and the criterion that motivates it.
13
+ */
14
+ export const EXTERNAL_RULES = Object.freeze([
15
+ { re: /<link\b[^>]*\bhref\s*=/i, code: CODES.EXTERNAL_STYLESHEET, what: '<link …href=', scope: 'markup' },
16
+ { re: /<script\b[^>]*\bsrc\s*=/i, code: CODES.EXTERNAL_SCRIPT, what: '<script …src=', scope: 'markup' },
17
+ { re: /<img\b[^>]*\bsrc\s*=\s*["']?http/i, code: CODES.EXTERNAL_IMAGE, what: '<img …src="http', scope: 'markup' },
18
+ { re: /@import/i, code: CODES.EXTERNAL_CSS_IMPORT, what: '@import', scope: 'css' },
19
+ { re: /url\(\s*["']?http/i, code: CODES.EXTERNAL_CSS_URL, what: 'url(http', scope: 'css' },
20
+ ])
21
+
22
+ /**
23
+ * CONTRACT 禁用字串 — strings whose mere presence means the artifact is
24
+ * *shipping* something it has no licence to ship.
25
+ *
26
+ * `scope` follows the same rule as EXTERNAL_RULES, and for the same reason
27
+ * (CONTRACT A3「內容文字…不在此限」): a font is redistributed by being
28
+ * *declared or embedded in CSS*, never by being named in body text. Scanning
29
+ * the raw document instead would fail any document that writes about the ban —
30
+ * the project's own SPEC.md, which records the TsangerJinKai02 licence NO-GO,
31
+ * is exactly such a document.
32
+ */
33
+ export const BANNED_STRINGS = Object.freeze([
34
+ { needle: 'TsangerJinKai', code: CODES.BANNED_FONT, why: '未取得再散布授權的字體', scope: 'css' },
35
+ ])
36
+
37
+ /**
38
+ * What a `scope` *means*, in one clause per region — the single wording behind
39
+ * both scoped surfaces.
40
+ *
41
+ * The benchmark (§3) watched an agent spend a `sed` and a `grep` on
42
+ * `./scan.js` just to learn whether the banned-font rule reads the whole
43
+ * document or only its CSS. Guessing wrong costs a rewrite, so verifying was
44
+ * rational; the fix is to stop making it necessary. These clauses are appended
45
+ * to every scoped failure message *and* returned by `lint --rules`, so the
46
+ * answer arrives whether the agent asks up front or only learns on failure.
47
+ *
48
+ * Wording derived from ./scan.js: `liveMarkup` keeps everything the browser
49
+ * will act on and drops the inert IR payload, and rendered content text is
50
+ * entity-escaped, so it can never match a tag form.
51
+ */
52
+ export const SCOPE_NOTES = Object.freeze({
53
+ markup: ';本規則只掃瀏覽器實際生效的 markup,內嵌 IR 與轉義後的正文不受限',
54
+ css: ';本規則只掃 CSS 區域,正文提及不受限',
55
+ svg: ';本規則只掃 `<svg>` 子樹,圖外的同形不受限',
56
+ })
57
+
58
+ /**
59
+ * CONTRACT F7b — the universal rules collected out of the six retired gates.
60
+ *
61
+ * They are a separate table from `EXTERNAL_RULES` for a reason that is pinned
62
+ * rather than stylistic: A3's five 禁形 are a **verbatim CONTRACT constant**
63
+ * (`test_a3_forbidden_forms_match_contract_verbatim` compares the `what` column
64
+ * to the contract line, in order), so that table is a quotation and cannot grow.
65
+ * These are the rules F7b judged universal — true of *every* artifact the SDK
66
+ * draws, whatever design language it wears — and each carries its own reason,
67
+ * because unlike the five they do not share one.
68
+ *
69
+ * What is deliberately **not** here is any form of the `rgba()` ban. The
70
+ * whole-file form (`check.py` inv 8) would have failed `kami/resume` on the day
71
+ * it landed, and the SVG-scoped form (book #7b) turned out not to be universal
72
+ * either: its damage model is 「export pdf/pptx 會把 alpha 壓平」, which only
73
+ * bites a product that is going to be exported. A sand table that lives its
74
+ * whole life as HTML never meets it, and the first real skinned product did in
75
+ * fact come back red on an artifact the user had already accepted. Applicability
76
+ * that varies with what the artifact is *for* is the definition of a gate, so
77
+ * `rgba()` lives in `[gates] svgRgbaBan`. See `docs/lint-consolidation.md`
78
+ * 衝突組 3 and CONTRACT R10.
79
+ *
80
+ * The four that remain are the ones whose damage model does **not** vary with
81
+ * use: an executing figure is a security surface and a dangling marker is a
82
+ * silently missing arrowhead, whoever is looking and wherever it is going.
83
+ */
84
+ export const SCAN_RULES = Object.freeze([
85
+ {
86
+ re: /<[a-zA-Z][\w-]*\b[^>]*\bsrc\s*=\s*["'](?!data:)/i,
87
+ code: CODES.EXTERNAL_ASSET_SRC,
88
+ what: 'src="…"(非 data:)',
89
+ why: '離線單檔沒有「檔案就在旁邊」這回事,相對路徑一離開這台機器就是破圖',
90
+ scope: 'markup',
91
+ },
92
+ {
93
+ re: /<script\b/i,
94
+ code: CODES.SVG_SCRIPT,
95
+ what: '<script',
96
+ why: '圖是靜態插畫;會執行的圖在匯出與離線檢視兩條路上都不成立',
97
+ scope: 'svg',
98
+ },
99
+ {
100
+ re: /\son[a-z]+\s*=\s*["']/i,
101
+ code: CODES.SVG_EVENT_HANDLER,
102
+ what: 'on…=',
103
+ why: '同上:互動歸 block 的播放層,不歸圖本身',
104
+ scope: 'svg',
105
+ },
106
+ {
107
+ re: /\b(?:xlink:)?href\s*=\s*["']?\s*javascript:/i,
108
+ code: CODES.SVG_JAVASCRIPT_URL,
109
+ what: 'href="javascript:',
110
+ why: '圖裡的可執行連結;離線產物會被當成一般網頁開啟,這條就是活的',
111
+ scope: 'svg',
112
+ },
113
+ ])
114
+
115
+ /**
116
+ * CONTRACT F7b — marker bijection, the one collected rule that is not a form.
117
+ *
118
+ * `marker-end="url(#arrow)"` pointing at a `<marker>` nobody defined does not
119
+ * fail, warn, or draw anything: the arrowhead is simply **not there**, and the
120
+ * diagram still looks like a diagram. That is the same silent-loss shape
121
+ * `KSB_BOARD_INVALID` (a card on an undeclared lane) and `KSB_GRAPH_INVALID`
122
+ * (an edge to an undeclared node) already exist to refuse, which is why it is
123
+ * core rather than a gate: no design language makes a missing arrowhead fine.
124
+ *
125
+ * The *other* half of the collected gate — an unused `<marker>` definition — is
126
+ * not collected. A definition nobody references costs bytes, not meaning, and
127
+ * a package may legitimately ship one skin's markers for several diagrams.
128
+ */
129
+ export const MARKER_RULE = Object.freeze({
130
+ code: CODES.SVG_MARKER_DANGLING,
131
+ scope: 'svg',
132
+ pattern: 'marker-(end|start|mid)="url(#id)" ↔ <marker id="id">',
133
+ why: '指不到的 marker 不會報錯,只會讓箭頭安靜地消失',
134
+ })
135
+
136
+ const MARKER_DEF_RE = /<marker\b[^>]*\bid\s*=\s*["']([^"']+)["']/gi
137
+ const MARKER_REF_RE = /\bmarker-(?:end|start|mid)\s*=\s*["']\s*url\(\s*#([^)\s"']+)\s*\)\s*["']/gi
138
+
139
+ /** Why an external-resource form is banned at all — one reason for all five. */
140
+ const EXTERNAL_REASON = '違反零外部請求'
141
+
142
+ const externalMessage = ({ what, scope }) =>
143
+ `產物含外部資源引用形 \`${what}\`,${EXTERNAL_REASON}${SCOPE_NOTES[scope]}`
144
+
145
+ const bannedMessage = ({ needle, why, scope }) =>
146
+ `產物含禁用字串 \`${needle}\`(${why})${SCOPE_NOTES[scope]}`
147
+
148
+ const scanMessage = ({ what, why, scope }) => `產物含 \`${what}\`:${why}${SCOPE_NOTES[scope]}`
149
+
150
+ /**
151
+ * The rule set as data, for `kamishibai lint --rules`.
152
+ *
153
+ * Derived from the frozen tables above rather than transcribed: a third
154
+ * copy would be free to drift from the linter, and a listing that disagrees
155
+ * with the gate is worse than no listing at all — it would be believed.
156
+ *
157
+ * F7b adds a fifth field, and only to the rules that need it: a gate rule
158
+ * carries `gate`, the `manifest.toml` key that turns it on. Without it the
159
+ * listing would show a rule that never fires on most artifacts and give the
160
+ * reader no way to learn why — which is the exact question `--rules` exists to
161
+ * answer without anybody reading `src/core/lint.js` (量測報告 §3 R1).
162
+ *
163
+ * @returns {Array<{code: string, scope: string, pattern: string, note: string, gate?: string}>}
164
+ */
165
+ export function lintRules() {
166
+ return [
167
+ ...EXTERNAL_RULES.map(({ code, scope, re }) => ({
168
+ code,
169
+ scope,
170
+ pattern: re.source,
171
+ note: `${EXTERNAL_REASON}${SCOPE_NOTES[scope]}`,
172
+ })),
173
+ ...BANNED_STRINGS.map(({ code, scope, needle, why }) => ({
174
+ code,
175
+ scope,
176
+ pattern: needle,
177
+ note: `${why}${SCOPE_NOTES[scope]}`,
178
+ })),
179
+ ...SCAN_RULES.map(({ code, scope, re, why }) => ({
180
+ code,
181
+ scope,
182
+ pattern: re.source,
183
+ note: `${why}${SCOPE_NOTES[scope]}`,
184
+ })),
185
+ {
186
+ code: MARKER_RULE.code,
187
+ scope: MARKER_RULE.scope,
188
+ pattern: MARKER_RULE.pattern,
189
+ note: `${MARKER_RULE.why}${SCOPE_NOTES[MARKER_RULE.scope]}`,
190
+ },
191
+ ...gateRuleListing().map((rule) => ({
192
+ ...rule,
193
+ note: `${rule.note}${SCOPE_NOTES[rule.scope]}`,
194
+ })),
195
+ ]
196
+ }
197
+
198
+ const finding = (code, message, path = ROOT_PATH) => ({ path, code, message })
199
+
200
+ /** The regions every scoped rule is judged against (see ./scan.js). */
201
+ const scanRegions = (html) => ({
202
+ markup: liveMarkup(html),
203
+ css: styleRegions(html),
204
+ svg: svgRegions(html),
205
+ // Not a `scope`: the selector-aware view the heading gate needs, computed
206
+ // once here so a gate never has to re-parse the document it was handed.
207
+ cssBlocks: cssRuleBlocks(styleBlocks(html)),
208
+ })
209
+
210
+ const lintExternalResources = (regions) =>
211
+ EXTERNAL_RULES.filter(({ re, scope }) => re.test(regions[scope])).map((rule) =>
212
+ finding(rule.code, externalMessage(rule)),
213
+ )
214
+
215
+ const lintBannedStrings = (regions) =>
216
+ BANNED_STRINGS.filter(({ needle, scope }) => regions[scope].includes(needle)).map((rule) =>
217
+ finding(rule.code, bannedMessage(rule)),
218
+ )
219
+
220
+ const lintScanRules = (regions) =>
221
+ SCAN_RULES.filter(({ re, scope }) => re.test(regions[scope])).map((rule) =>
222
+ finding(rule.code, scanMessage(rule)),
223
+ )
224
+
225
+ const lintMarkerIntegrity = (svg) => {
226
+ const defined = new Set([...svg.matchAll(MARKER_DEF_RE)].map((m) => m[1]))
227
+ const dangling = [
228
+ ...new Set(
229
+ [...svg.matchAll(MARKER_REF_RE)].map((m) => m[1]).filter((id) => !defined.has(id)),
230
+ ),
231
+ ]
232
+ if (dangling.length === 0) return []
233
+ return [
234
+ finding(
235
+ MARKER_RULE.code,
236
+ `產物的 SVG 引用了未定義的 marker ${dangling.map((id) => `\`#${id}\``).join('、')}` +
237
+ `:${MARKER_RULE.why}${SCOPE_NOTES[MARKER_RULE.scope]}`,
238
+ ),
239
+ ]
240
+ }
241
+
242
+ const lintEmbeddedIr = (payloads) => {
243
+ if (payloads.length === 0) {
244
+ return [finding(CODES.IR_MISSING, '產物缺少內嵌 IR(<script type="application/kamishibai+json">)')]
245
+ }
246
+ if (payloads.length > 1) {
247
+ return [finding(CODES.IR_DUPLICATE, `產物含 ${payloads.length} 份內嵌 IR,應恰為一份`)]
248
+ }
249
+ let ir
250
+ try {
251
+ ir = JSON.parse(payloads[0])
252
+ } catch (cause) {
253
+ return [finding(CODES.IR_UNPARSABLE, `內嵌 IR 不是合法 JSON:${cause.message}`)]
254
+ }
255
+ return validateIr(ir).errors
256
+ }
257
+
258
+ /**
259
+ * Lint a rendered artifact: offline rules + embedded-IR schema conformance +
260
+ * whatever gates the artifact's own template package declared.
261
+ *
262
+ * The caller supplies the already-extracted IR payloads **and** the already-read
263
+ * `[gates]` declaration so that this module stays in the dependency-free core
264
+ * layer (AGENTS §3.1). `gates` absent means no gate runs, which is also what an
265
+ * artifact drawn with a factory template gets — the five bundled templates
266
+ * declare none, and F7b deliberately did not give them defaults.
267
+ *
268
+ * @param {{html: string, irPayloads: string[], gates?: Record<string, unknown>}} input
269
+ * @returns {Array<{path: string, code: string, message: string}>} findings, empty when clean
270
+ */
271
+ export function lintArtifact({ html, irPayloads, gates }) {
272
+ const regions = scanRegions(html)
273
+ return [
274
+ ...lintExternalResources(regions),
275
+ ...lintBannedStrings(regions),
276
+ ...lintScanRules(regions),
277
+ ...lintMarkerIntegrity(regions.svg),
278
+ ...lintGates(gates ?? {}, regions),
279
+ ...lintEmbeddedIr(irPayloads),
280
+ ]
281
+ }
@@ -0,0 +1,84 @@
1
+ import { IR_SCRIPT_TYPE } from './ir.js'
2
+
3
+ /**
4
+ * Structural slicing of an artifact, so the offline rules judge *live markup*
5
+ * rather than content that merely talks about it (CONTRACT A3「內容文字…不在此限」).
6
+ *
7
+ * Two observations make this cheap and precise:
8
+ *
9
+ * 1. Tag forms (`<link href`, `<script src`, `<img src="http`) need a literal
10
+ * `<`. Rendered content text is entity-escaped (`&lt;script`), so it can
11
+ * never match — no scrubbing required, and a *raw island*, which is injected
12
+ * unescaped and really does fire, still matches exactly as it should.
13
+ * 2. CSS forms (`@import`, `url(http`) carry no `<`, so escaping does not
14
+ * neutralise them. They are only meaningful inside a CSS context, so they
15
+ * are scanned only there.
16
+ *
17
+ * The embedded IR is inert data, never executed, so it is excluded from both.
18
+ */
19
+
20
+ const IR_SCRIPT_RE = new RegExp(
21
+ `<script[^>]*type\\s*=\\s*["']${IR_SCRIPT_TYPE.replace('+', '\\+')}["'][^>]*>[\\s\\S]*?<\\/script>`,
22
+ 'gi',
23
+ )
24
+
25
+ const STYLE_BLOCK_RE = /<style\b[^>]*>([\s\S]*?)<\/style>/gi
26
+ const STYLE_ATTR_RE = /\sstyle\s*=\s*"([^"]*)"|\sstyle\s*=\s*'([^']*)'/gi
27
+
28
+ /** Markup that the browser will actually act on: everything but the IR payload. */
29
+ export function liveMarkup(html) {
30
+ return String(html ?? '').replace(IR_SCRIPT_RE, '')
31
+ }
32
+
33
+ /** Every CSS context in the artifact: <style> blocks plus inline style attributes. */
34
+ export function styleRegions(html) {
35
+ const live = liveMarkup(html)
36
+ const regions = []
37
+ for (const m of live.matchAll(STYLE_BLOCK_RE)) regions.push(m[1])
38
+ for (const m of live.matchAll(STYLE_ATTR_RE)) regions.push(m[1] ?? m[2] ?? '')
39
+ return regions.join('\n')
40
+ }
41
+
42
+ /**
43
+ * Every `<svg>` subtree in the artifact — the third region, added by F7b.
44
+ *
45
+ * A figure is judged by different rules than the page around it, and the six
46
+ * collected gates all say so in their own way: `rgba()` is ordinary CSS in a
47
+ * stylesheet and a lost alpha channel in an exported PDF; a `<script>` in the
48
+ * document body is how playback works and has no business inside an
49
+ * illustration. Scanning the whole document for either form would fail every
50
+ * artifact the SDK draws (`kami/resume`'s stylesheet really does use `rgba()`),
51
+ * which is why the collected form is the *scoped* one, never the whole-file one.
52
+ *
53
+ * Non-greedy, so a nested `<svg>` closes its parent early. That is the safe
54
+ * direction: the tail of the outer figure is then scanned as page markup, where
55
+ * every rule here is a strict subset of what the markup rules already allow.
56
+ */
57
+ const SVG_BLOCK_RE = /<svg\b[^>]*>[\s\S]*?<\/svg>/gi
58
+
59
+ export function svgRegions(html) {
60
+ return [...liveMarkup(html).matchAll(SVG_BLOCK_RE)].map((m) => m[0]).join('\n')
61
+ }
62
+
63
+ /** Just the `<style>` blocks — the only CSS that carries selectors. */
64
+ export function styleBlocks(html) {
65
+ return [...liveMarkup(html).matchAll(STYLE_BLOCK_RE)].map((m) => m[1]).join('\n')
66
+ }
67
+
68
+ /**
69
+ * `<style>` CSS as `{selector, body}` pairs, for the rules that must know *what
70
+ * a declaration applies to* (a `font-weight` ceiling means nothing until you
71
+ * know whether the block is a heading).
72
+ *
73
+ * The pattern matches innermost brace pairs, so a rule nested in `@media`
74
+ * yields the inner selector rather than the at-rule — which is what a reader
75
+ * expects and what the two collected implementations both failed to do.
76
+ */
77
+ const CSS_RULE_RE = /([^{}]+)\{([^{}]*)\}/g
78
+
79
+ export function cssRuleBlocks(css) {
80
+ return [...String(css ?? '').matchAll(CSS_RULE_RE)].map((m) => ({
81
+ selector: m[1].trim(),
82
+ body: m[2],
83
+ }))
84
+ }
@@ -0,0 +1,88 @@
1
+ import { blockModules, blockTypes } from '../blocks/index.js'
2
+ import { children, str } from '../blocks/schema-parts.js'
3
+ import { PLACEMENT_PROPERTIES } from '../blocks/placement.js'
4
+ import { META_KEY_ORDER } from './blocks.js'
5
+ import { IR_VERSION } from './ir.js'
6
+
7
+ export const SCHEMA_DIALECT = 'https://json-schema.org/draft/2020-12/schema'
8
+ export const SCHEMA_ID = 'https://kamishibai.dev/schema/ir-1.json'
9
+
10
+ /**
11
+ * One `if type is X then these fields` branch. The branch bodies are not
12
+ * written here: each block module carries its own, so registering a plugin
13
+ * block widens the schema without this file knowing the type exists (A4).
14
+ */
15
+ const whenType = (type, then) => ({
16
+ if: { required: ['type'], properties: { type: { const: type } } },
17
+ then,
18
+ })
19
+
20
+ const metaProperties = () => Object.fromEntries(META_KEY_ORDER.map((key) => [key, str]))
21
+
22
+ /**
23
+ * The IR JSON Schema (draft 2020-12). Single source of truth for `lint`,
24
+ * for the `schema` command, and — by SPEC §15.2 — for generated docs.
25
+ *
26
+ * Built fresh on every call rather than frozen at import: the block vocabulary
27
+ * can grow after load, and a schema cached before a plugin registered would
28
+ * reject the very block the renderer had just drawn.
29
+ */
30
+ export function irSchema() {
31
+ return {
32
+ $schema: SCHEMA_DIALECT,
33
+ $id: SCHEMA_ID,
34
+ title: 'Kamishibai IR',
35
+ description: '產物內嵌的 kamishibai IR 封包(SPEC §7.3)',
36
+ type: 'object',
37
+ required: ['irVersion', 'engine', 'template', 'doc', 'createdAt', 'generator'],
38
+ properties: {
39
+ irVersion: { ...str, const: IR_VERSION },
40
+ engine: str,
41
+ template: {
42
+ type: 'object',
43
+ required: ['namespace', 'name', 'version'],
44
+ properties: { namespace: str, name: str, version: str },
45
+ additionalProperties: false,
46
+ },
47
+ doc: { $ref: '#/$defs/doc' },
48
+ createdAt: str,
49
+ generator: str,
50
+ },
51
+ additionalProperties: false,
52
+ $defs: {
53
+ meta: {
54
+ type: 'object',
55
+ required: ['title'],
56
+ properties: metaProperties(),
57
+ additionalProperties: str,
58
+ },
59
+ doc: {
60
+ type: 'object',
61
+ required: ['id', 'type', 'meta', 'children'],
62
+ properties: {
63
+ id: str,
64
+ type: { const: 'doc' },
65
+ meta: { $ref: '#/$defs/meta' },
66
+ children,
67
+ },
68
+ unevaluatedProperties: false,
69
+ },
70
+ /**
71
+ * `col`/`row`/`colSpan`/`rowSpan` sit on the *block* rather than inside
72
+ * the `grid` branch on purpose (issue 14 裁決 1): a bounded 文體's blocks
73
+ * may carry coordinates without being wrapped in anything, and declaring
74
+ * them per type would mean editing twelve modules to add one vocabulary.
75
+ * Because `unevaluatedProperties` (not `additionalProperties`) closes this
76
+ * object, a property declared here is legal on every branch and nowhere
77
+ * else — a placement is still refused on a block that is not a block.
78
+ */
79
+ block: {
80
+ type: 'object',
81
+ required: ['id', 'type'],
82
+ properties: { id: str, type: { enum: blockTypes() }, ...PLACEMENT_PROPERTIES },
83
+ allOf: blockModules().map((mod) => whenType(mod.type, mod.schema)),
84
+ unevaluatedProperties: false,
85
+ },
86
+ },
87
+ }
88
+ }
@@ -0,0 +1,33 @@
1
+ import { blockModule } from '../blocks/index.js'
2
+ import { validationError } from './errors.js'
3
+ import { walkBlocks } from './blocks.js'
4
+
5
+ /**
6
+ * Run every block's own spec check over a document, before a byte is drawn.
7
+ *
8
+ * Some blocks carry a claim the JSON Schema cannot express — a `diagram` edge
9
+ * must point at a node that exists, and no schema keyword says that. Those
10
+ * checks belong to the block, so a module declares `validate(block) → string[]`
11
+ * and this gate turns the first non-empty answer into the usual `KSB_` error
12
+ * with a block path.
13
+ *
14
+ * Being generic is the point: it used to be a diagram-shaped call in the render
15
+ * pipeline, which meant a plugin block had no way to refuse its own broken
16
+ * spec — it could only draw nonsense. A picture that silently disagrees with
17
+ * the data it came from is the one failure a reader cannot detect.
18
+ *
19
+ * The failure code and wording come from the module as plain data, because the
20
+ * block layer imports nothing: what it means for a diagram to be undrawable is
21
+ * the diagram's sentence to write, not this file's.
22
+ *
23
+ * @throws {import('./errors.js').KsbError} exit 1, with the module's own code
24
+ */
25
+ export function assertBlockSpecs(doc) {
26
+ for (const { block, path } of walkBlocks(doc)) {
27
+ const mod = blockModule(block?.type)
28
+ if (typeof mod?.validate !== 'function') continue
29
+ const problems = mod.validate(block)
30
+ if (!Array.isArray(problems) || problems.length === 0) continue
31
+ throw validationError(mod.invalid.message(problems), mod.invalid.code, path)
32
+ }
33
+ }
@@ -0,0 +1,44 @@
1
+ import Ajv2020Module from 'ajv/dist/2020.js'
2
+ import { irSchema } from './schema.js'
3
+ import { CODES } from './errors.js'
4
+
5
+ const Ajv2020 = Ajv2020Module.default ?? Ajv2020Module
6
+
7
+ let validator = null
8
+
9
+ const compile = () => {
10
+ if (validator === null) {
11
+ const ajv = new Ajv2020({ allErrors: true, strict: false })
12
+ validator = ajv.compile(irSchema())
13
+ }
14
+ return validator
15
+ }
16
+
17
+ /** `/doc/children/0/html` → `doc.children[0].html` (SPEC §10.3: 錯誤指到 block path). */
18
+ export function pointerToBlockPath(pointer) {
19
+ if (typeof pointer !== 'string' || pointer.length === 0) return '$'
20
+ return pointer
21
+ .split('/')
22
+ .filter((part) => part.length > 0)
23
+ .reduce((acc, part) => {
24
+ const decoded = part.replace(/~1/g, '/').replace(/~0/g, '~')
25
+ if (/^\d+$/.test(decoded)) return `${acc}[${decoded}]`
26
+ return acc.length === 0 ? decoded : `${acc}.${decoded}`
27
+ }, '')
28
+ }
29
+
30
+ /**
31
+ * Validate an IR envelope against the schema.
32
+ * @returns {{valid: boolean, errors: Array<{path: string, code: string, message: string}>}}
33
+ */
34
+ export function validateIr(ir) {
35
+ const validate = compile()
36
+ const valid = validate(ir)
37
+ if (valid) return { valid: true, errors: [] }
38
+ const errors = (validate.errors ?? []).map((e) => ({
39
+ path: pointerToBlockPath(e.instancePath),
40
+ code: CODES.IR_SCHEMA,
41
+ message: `${e.message}${e.params?.allowedValues ? ` (${e.params.allowedValues.join(', ')})` : ''}`,
42
+ }))
43
+ return { valid: false, errors }
44
+ }
@@ -0,0 +1,18 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { fileURLToPath } from 'node:url'
3
+
4
+ const PKG_PATH = fileURLToPath(new URL('../../package.json', import.meta.url))
5
+
6
+ let cached = null
7
+
8
+ /** Engine (SDK) version, read once from the package manifest. */
9
+ export function engineVersion() {
10
+ if (cached === null) {
11
+ const pkg = JSON.parse(readFileSync(PKG_PATH, 'utf8'))
12
+ if (typeof pkg.version !== 'string' || pkg.version.length === 0) {
13
+ throw new Error('package.json is missing a version field')
14
+ }
15
+ cached = pkg.version
16
+ }
17
+ return cached
18
+ }