@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.
- package/README.md +192 -0
- package/package.json +54 -0
- package/src/blocks/board.js +210 -0
- package/src/blocks/callout.js +63 -0
- package/src/blocks/code.js +28 -0
- package/src/blocks/deck.js +76 -0
- package/src/blocks/diagram.js +265 -0
- package/src/blocks/element.js +51 -0
- package/src/blocks/graph.js +264 -0
- package/src/blocks/grid.js +156 -0
- package/src/blocks/index.js +106 -0
- package/src/blocks/list.js +50 -0
- package/src/blocks/placement.js +119 -0
- package/src/blocks/prose.js +28 -0
- package/src/blocks/quote.js +25 -0
- package/src/blocks/raw.js +47 -0
- package/src/blocks/registry.js +158 -0
- package/src/blocks/schema-parts.js +19 -0
- package/src/blocks/section.js +53 -0
- package/src/blocks/slide.js +104 -0
- package/src/blocks/stat.js +83 -0
- package/src/blocks/table.js +50 -0
- package/src/blocks/timeline.js +80 -0
- package/src/cli/commands/close.js +51 -0
- package/src/cli/commands/comments.js +73 -0
- package/src/cli/commands/debug.js +34 -0
- package/src/cli/commands/example.js +22 -0
- package/src/cli/commands/export.js +10 -0
- package/src/cli/commands/init.js +53 -0
- package/src/cli/commands/lint.js +93 -0
- package/src/cli/commands/list.js +54 -0
- package/src/cli/commands/open.js +31 -0
- package/src/cli/commands/promote.js +38 -0
- package/src/cli/commands/render.js +31 -0
- package/src/cli/commands/replay.js +49 -0
- package/src/cli/commands/schema.js +10 -0
- package/src/cli/commands/serve.js +178 -0
- package/src/cli/commands/setup.js +64 -0
- package/src/cli/commands/snapshot.js +29 -0
- package/src/cli/commands/templates.js +75 -0
- package/src/cli/deliver.js +54 -0
- package/src/cli/emit.js +29 -0
- package/src/cli/format.js +153 -0
- package/src/cli/index.js +365 -0
- package/src/cli/registry.js +18 -0
- package/src/core/blocks.js +147 -0
- package/src/core/diagram.js +282 -0
- package/src/core/errors.js +156 -0
- package/src/core/example.js +109 -0
- package/src/core/ir.js +62 -0
- package/src/core/lint-gates.js +427 -0
- package/src/core/lint.js +281 -0
- package/src/core/scan.js +84 -0
- package/src/core/schema.js +88 -0
- package/src/core/spec-check.js +33 -0
- package/src/core/validate.js +44 -0
- package/src/core/version.js +18 -0
- package/src/core/vocabulary.js +140 -0
- package/src/delivery/atomic.js +71 -0
- package/src/delivery/comments.js +180 -0
- package/src/delivery/home.js +70 -0
- package/src/delivery/open.js +31 -0
- package/src/delivery/project.js +141 -0
- package/src/delivery/read.js +109 -0
- package/src/delivery/run.js +95 -0
- package/src/delivery/scaffold-blueprints.js +728 -0
- package/src/delivery/store.js +219 -0
- package/src/delivery/template-extensions.js +183 -0
- package/src/delivery/template-format.js +112 -0
- package/src/delivery/template-package.js +376 -0
- package/src/delivery/template-promote.js +240 -0
- package/src/delivery/template-scaffold.js +181 -0
- package/src/delivery/templates.js +192 -0
- package/src/delivery/toml.js +195 -0
- package/src/delivery/write.js +35 -0
- package/src/export/browser.js +130 -0
- package/src/export/index.js +96 -0
- package/src/export/pdf.js +25 -0
- package/src/export/png.js +40 -0
- package/src/export/pptx.js +48 -0
- package/src/export/slides.js +33 -0
- package/src/export/snapshot.js +33 -0
- package/src/layouts/article.js +103 -0
- package/src/layouts/canvas.js +144 -0
- package/src/layouts/card.js +128 -0
- package/src/layouts/deck.js +88 -0
- package/src/layouts/index.js +90 -0
- package/src/layouts/one-page.js +161 -0
- package/src/layouts/registry.js +251 -0
- package/src/layouts/resume.js +172 -0
- package/src/layouts/template-index.js +78 -0
- package/src/parser/artifact.js +38 -0
- package/src/parser/container.js +103 -0
- package/src/parser/index.js +223 -0
- package/src/parser/tokens.js +265 -0
- package/src/render/board-filter.client.js +80 -0
- package/src/render/compile.js +29 -0
- package/src/render/context.js +98 -0
- package/src/render/element.js +32 -0
- package/src/render/fonts.js +129 -0
- package/src/render/graph-hover.client.js +148 -0
- package/src/render/html.js +52 -0
- package/src/render/index.js +241 -0
- package/src/render/measure.js +60 -0
- package/src/render/placement.js +136 -0
- package/src/render/playback.client.js +74 -0
- package/src/render/scale-to-fit.client.js +136 -0
- package/src/render/scale.js +41 -0
- package/src/render/skeleton.js +131 -0
- package/src/render/ssr.js +24 -0
- package/src/render/styles.js +56 -0
- package/src/render/templates.js +191 -0
- package/src/serve/daemon.js +117 -0
- package/src/serve/overlay.js +213 -0
- package/src/serve/protocol.js +36 -0
- package/src/serve/server.js +264 -0
- package/templates/kami/cards/components.js +40 -0
- package/templates/kami/cards/index.js +25 -0
- package/templates/kami/cards/manifest.js +67 -0
- package/templates/kami/cards/styles.css +389 -0
- package/templates/kami/long-form/components.js +102 -0
- package/templates/kami/long-form/index.js +25 -0
- package/templates/kami/long-form/manifest.js +87 -0
- package/templates/kami/long-form/styles.css +481 -0
- package/templates/kami/one-page/components.js +48 -0
- package/templates/kami/one-page/index.js +27 -0
- package/templates/kami/one-page/manifest.js +65 -0
- package/templates/kami/one-page/styles.css +375 -0
- package/templates/kami/resume/components.js +51 -0
- package/templates/kami/resume/index.js +27 -0
- package/templates/kami/resume/manifest.js +65 -0
- package/templates/kami/resume/styles.css +424 -0
- package/templates/kami/slides/components.js +41 -0
- package/templates/kami/slides/index.js +26 -0
- package/templates/kami/slides/manifest.js +64 -0
- package/templates/kami/slides/styles.css +406 -0
package/src/core/lint.js
ADDED
|
@@ -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
|
+
}
|
package/src/core/scan.js
ADDED
|
@@ -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 (`<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
|
+
}
|