@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,427 @@
1
+ import { CODES, EXIT, KsbError, ROOT_PATH } from './errors.js'
2
+
3
+ /**
4
+ * The `[gates]` layer of `lint` (CONTRACT F7b H1, SPEC §5.4).
5
+ *
6
+ * The six retired gates disagreed about numbers, and the disagreement was not a
7
+ * mistake anybody made: 「body line-height 上限」 is 1.55 in `check.py`, 1.6 in
8
+ * `render-design-html.md` and 1.70 in book's checklist because those are three
9
+ * *design languages*, not three opinions about one language. An SDK that serves
10
+ * all three cannot pick one and be right twice.
11
+ *
12
+ * So the threshold moves to where the design language already lives — the
13
+ * template package's own `manifest.toml`:
14
+ *
15
+ * ```toml
16
+ * [gates]
17
+ * bodyLineHeightMax = 1.55
18
+ * allowItalic = false
19
+ * ```
20
+ *
21
+ * Two consequences are load-bearing:
22
+ *
23
+ * - **Undeclared means the rule does not run.** Not "runs with a default": a
24
+ * default here would be this file quietly picking one of the three numbers
25
+ * after all, and every package that never opted in would start failing for a
26
+ * rule its author never agreed to.
27
+ * - **An unknown key is refused by name.** A `[gates]` typo that is silently
28
+ * ignored leaves a package believing it is gated when nothing is running —
29
+ * the worst of the three states, because it is the one nobody checks.
30
+ *
31
+ * Dependency direction is unchanged (AGENTS §3.1): this module is pure `core`.
32
+ * It is *read* by the delivery layer to validate a manifest, and it never reads
33
+ * a manifest itself — the caller hands it the already-parsed table.
34
+ */
35
+
36
+ /** Verbatim CONTRACT constant — the manifest section these rules are declared in. */
37
+ export const GATES_SECTION = 'gates'
38
+
39
+ const declarationError = (message) =>
40
+ new KsbError({
41
+ code: CODES.GATE_DECLARATION_INVALID,
42
+ message,
43
+ path: 'doc.meta.template',
44
+ exitCode: EXIT.VALIDATION,
45
+ })
46
+
47
+ const finding = (code, message) => ({ path: ROOT_PATH, code, message })
48
+
49
+ /** A colour literal, in either of the two spellings CSS actually carries. */
50
+ const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
51
+
52
+ /** `#abc` and `#AABBCC` are the same colour; the blocklist must not care which. */
53
+ const normaliseHex = (hex) => {
54
+ const body = hex.slice(1).toLowerCase()
55
+ return body.length === 3 ? `#${body[0]}${body[0]}${body[1]}${body[1]}${body[2]}${body[2]}` : `#${body}`
56
+ }
57
+
58
+ /**
59
+ * `line-height: 1.62` — **unitless only**, and deliberately so.
60
+ *
61
+ * `1.5rem`, `24px` and `150%` are not comparable to a unitless ratio without
62
+ * knowing the font-size, and the collected implementation compared them anyway
63
+ * (`check.py` L160 captures `150` out of `150%` and then tests it against 1.55).
64
+ * The lookahead is what keeps a unit out: a digit run followed by `r`, `p` or
65
+ * `%` simply does not match, so a rule that cannot be judged is not judged.
66
+ */
67
+ const LINE_HEIGHT_RE = /line-height\s*:\s*([0-9]*\.?[0-9]+)\s*(?=[;}!]|$|\s)/gi
68
+
69
+ /**
70
+ * A selector that puts a declaration on a heading — `h2`, `.card > h3`, `h1, h2`.
71
+ * One definition, read by both the heading gate and the body line-height gate:
72
+ * 「這是不是標題」 answered two slightly different ways is how a body ceiling and
73
+ * a heading ceiling end up disagreeing about the same block.
74
+ */
75
+ const HEADING_SELECTOR_RE = /(^|[\s,>+~(])h[1-6]\b/i
76
+
77
+ /**
78
+ * The ceiling is a **body** ceiling, so headings are excluded by selector.
79
+ *
80
+ * The collected implementation said the same thing with a substring test on the
81
+ * declaration's own line (`check.py` L720-727 skips a line containing
82
+ * `headline`, `title` or `display`). Selector-awareness is the same intent
83
+ * expressed where the fact actually lives: a `line-height` inside `h1 { … }` is
84
+ * a heading's, whatever the block happens to be named, and a `.headline` class
85
+ * that is not a heading element no longer buys silence it never earned.
86
+ *
87
+ * Reading `cssBlocks` rather than `css` is deliberate and has one visible
88
+ * consequence: inline `style=` attributes carry no selector, so they are out of
89
+ * scope. A rule that cannot tell whose line-height it is looking at should not
90
+ * be calling it 「正文」 in the failure message.
91
+ */
92
+ const checkLineHeight = (max, regions) => {
93
+ const over = new Set()
94
+ for (const { selector, body } of regions.cssBlocks) {
95
+ if (HEADING_SELECTOR_RE.test(selector)) continue
96
+ for (const m of body.matchAll(LINE_HEIGHT_RE)) {
97
+ const value = Number(m[1])
98
+ if (Number.isFinite(value) && value > max) over.add(value)
99
+ }
100
+ }
101
+ if (over.size === 0) return []
102
+ const listed = [...over].sort((a, b) => a - b).join('、')
103
+ return [
104
+ finding(
105
+ CODES.GATE_LINE_HEIGHT,
106
+ `產物的正文 line-height ${listed} 超過模板包宣告的上限 ${max}` +
107
+ `(\`[gates] bodyLineHeightMax\`);比較為嚴格大於,等於上限即通過`,
108
+ ),
109
+ ]
110
+ }
111
+
112
+ /** `font-weight: 700`; `bold` is folded to 700, `bolder` is relative and skipped. */
113
+ const FONT_WEIGHT_RE = /font-weight\s*:\s*(\d{3}|bold)\b/gi
114
+
115
+ const checkHeadingWeight = (max, regions) => {
116
+ const over = new Set()
117
+ for (const { selector, body } of regions.cssBlocks) {
118
+ if (!HEADING_SELECTOR_RE.test(selector)) continue
119
+ for (const m of body.matchAll(FONT_WEIGHT_RE)) {
120
+ const weight = m[1].toLowerCase() === 'bold' ? 700 : Number(m[1])
121
+ if (weight > max) over.add(weight)
122
+ }
123
+ }
124
+ if (over.size === 0) return []
125
+ const listed = [...over].sort((a, b) => a - b).join('、')
126
+ return [
127
+ finding(
128
+ CODES.GATE_HEADING_WEIGHT,
129
+ `產物的標題規則 font-weight ${listed} 超過模板包宣告的上限 ${max}` +
130
+ `(\`[gates] headingWeightMax\`);\`bold\` 折算 700,相對值 \`bolder\` 無法靜態判定故不判`,
131
+ ),
132
+ ]
133
+ }
134
+
135
+ /** One `box-shadow` declaration's value, up to the `;` or the closing brace. */
136
+ const BOX_SHADOW_RE = /box-shadow\s*:\s*([^;}]+)/gi
137
+
138
+ /**
139
+ * A colour function's arguments, removed before lengths are counted.
140
+ *
141
+ * `rgba(43, 255, 181, .5)` carries four numbers that are not lengths, and the
142
+ * first version of this gate counted them. Stripping is safer than a smarter
143
+ * length pattern: whatever colour syntax arrives next, its digits leave with it.
144
+ */
145
+ const COLOR_FUNCTION_RE = /[a-z-]+\([^()]*\)/gi
146
+
147
+ /** `#333333` is six digits that are not a length. Same reason as above. */
148
+ const HEX_COLOR_RE = /#[0-9a-fA-F]{3,8}\b/g
149
+
150
+ /**
151
+ * A CSS length: `18px`, `-2px`, `.5px`, and a **unitless `0`**, which is legal
152
+ * everywhere a length is and is what real stylesheets actually write.
153
+ * Missing it is what made `0 0 18px rgba(…)` read as「只有一個 length」.
154
+ */
155
+ const LENGTH_TOKEN_RE = /(-?\d*\.?\d+)(px)?(?=\s|$)/gi
156
+
157
+ /** Everything in a shadow that is not a length, removed before counting. */
158
+ const lengthsOf = (shadow) =>
159
+ [
160
+ ...shadow
161
+ .replace(COLOR_FUNCTION_RE, ' ')
162
+ .replace(HEX_COLOR_RE, ' ')
163
+ .matchAll(LENGTH_TOKEN_RE),
164
+ ].map((m) => Number(m[1]))
165
+
166
+ /**
167
+ * The blur of one shadow, or `undefined` when it cannot be told.
168
+ *
169
+ * `undefined` is a real answer here, not a failure to get one: 「判不出來就不判」
170
+ * is the same clause `var()` already gets, and the alternative — defaulting to
171
+ * 0 — is precisely the bug this replaces. A two-length shadow (`2px 4px #000`)
172
+ * therefore goes unjudged even though CSS would give it blur 0; that is a
173
+ * deliberate under-reach, matching the collected implementation, whose pattern
174
+ * `(-?\d+px\s+){1,2}(\d+)px` (`check.py` L161) equally requires a third length
175
+ * before it says anything. Under-reaching is the safe direction for a gate that
176
+ * runs against artifacts nobody wrote by hand.
177
+ */
178
+ const blurOf = (shadow) => {
179
+ if (/var\(/i.test(shadow)) return undefined
180
+ const lengths = lengthsOf(shadow)
181
+ if (lengths.length < 3) return undefined
182
+ // CSS order: offset-x offset-y blur [spread].
183
+ const [offsetX, offsetY, blur] = lengths
184
+ // A shadow with no offset is a glow or a ring (`0 0 0 8px` is the focus-ring
185
+ // idiom), not a drop shadow — and 「無硬陰影」 is a statement about drop
186
+ // shadows. Judging a ring by a blur floor is a category error, and the
187
+ // collected implementation never judged one either: its pattern needs the
188
+ // offsets to carry `px`, which `0 0 …` does not.
189
+ if (offsetX === 0 && offsetY === 0) return undefined
190
+ return blur
191
+ }
192
+
193
+ const checkShadowBlur = (min, regions) => {
194
+ const under = new Set()
195
+ for (const m of regions.css.matchAll(BOX_SHADOW_RE)) {
196
+ const value = m[1]
197
+ if (/^\s*none\b/i.test(value)) continue
198
+ // A comma-separated list is *several* shadows. Reading it as one and taking
199
+ // the third length was how `0 1px 2px #000, 0 4px 16px #000` passed: the
200
+ // hard 2px edge was invisible behind the soft one that followed it.
201
+ for (const shadow of value.replace(COLOR_FUNCTION_RE, (c) => c.replace(/,/g, ';')).split(',')) {
202
+ const blur = blurOf(shadow.replace(/;/g, ','))
203
+ if (blur !== undefined && blur < min) under.add(blur)
204
+ }
205
+ }
206
+ if (under.size === 0) return []
207
+ const listed = [...under].sort((a, b) => a - b).join('、')
208
+ return [
209
+ finding(
210
+ CODES.GATE_SHADOW_BLUR,
211
+ `產物的 box-shadow 模糊半徑 ${listed}px 低於模板包宣告的下限 ${min}px` +
212
+ `(\`[gates] shadowBlurMinPx\`);逗號清單逐枚判;零偏移的光暈/描邊環、` +
213
+ `length 不足三個、含 \`var()\` 者皆無法判定故不判`,
214
+ ),
215
+ ]
216
+ }
217
+
218
+ const ITALIC_RE = /font-style\s*:\s*italic/i
219
+
220
+ const checkItalic = (allowed, regions) => {
221
+ if (allowed) return []
222
+ if (!ITALIC_RE.test(regions.css)) return []
223
+ return [
224
+ finding(
225
+ CODES.GATE_ITALIC,
226
+ '產物含 `font-style: italic`,但模板包宣告 `[gates] allowItalic = false`',
227
+ ),
228
+ ]
229
+ }
230
+
231
+ const SVG_RGBA_RE = /rgba\s*\(/i
232
+
233
+ /**
234
+ * `rgba()` inside a figure — a gate, not a core rule (CONTRACT R10).
235
+ *
236
+ * It was collected as core first, on book #7b's reason: the PDF/PPTX export
237
+ * chain flattens alpha, so what the figure looks like on screen is not what
238
+ * comes out. That reason is real and it is also **conditional** — it only bites
239
+ * a product somebody is going to export. The first real skinned product proved
240
+ * the point: `common-dev/sand-table`'s `x-tacmap` / `x-causal-history` figures
241
+ * carry seventeen distinct `rgba()` glow and shadow forms ported from the old
242
+ * Python renderer, and that sand table is consumed as HTML and never exported.
243
+ * A rule that reds a product the user has already accepted is a rule that is
244
+ * wrong about its own scope (CONTRACT H4「誤報即規則錯,不是產物錯」).
245
+ */
246
+ const checkSvgRgba = (_banned, regions) => {
247
+ if (!SVG_RGBA_RE.test(regions.svg)) return []
248
+ return [
249
+ finding(
250
+ CODES.SVG_RGBA,
251
+ '產物的 `<svg>` 內含 `rgba()`,但模板包宣告 `[gates] svgRgbaBan = true`' +
252
+ ';理由是 export pdf/pptx 這條路上圖的 alpha 會被壓平——' +
253
+ '純 HTML 消費、不走匯出的產物不必宣告這一道',
254
+ ),
255
+ ]
256
+ }
257
+
258
+ const HEX_IN_CSS_RE = /#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/g
259
+
260
+ const checkBannedHex = (banned, regions) => {
261
+ const blocked = new Set(banned.map(normaliseHex))
262
+ const hits = new Set()
263
+ for (const m of regions.css.matchAll(HEX_IN_CSS_RE)) {
264
+ const hex = normaliseHex(`#${m[1]}`)
265
+ if (blocked.has(hex)) hits.add(hex)
266
+ }
267
+ if (hits.size === 0) return []
268
+ return [
269
+ finding(
270
+ CODES.GATE_BANNED_HEX,
271
+ `產物的 CSS 含模板包自列的禁用色 ${[...hits].sort().join('、')}` +
272
+ '(`[gates] bannedHex`);色票應走模板包自己的 token,而不是裸色碼',
273
+ ),
274
+ ]
275
+ }
276
+
277
+ /**
278
+ * Every declarable gate, as data — the single source `lint --rules`, the
279
+ * manifest validator and the linter all read.
280
+ *
281
+ * `off` is what "declared, but this value turns the rule off" means: a boolean
282
+ * gate set to its permissive value is *declared* (so it is not a typo) and
283
+ * still runs nothing.
284
+ */
285
+ export const GATE_SPECS = Object.freeze([
286
+ Object.freeze({
287
+ key: 'bodyLineHeightMax',
288
+ type: 'positiveNumber',
289
+ code: CODES.GATE_LINE_HEIGHT,
290
+ scope: 'css',
291
+ pattern: LINE_HEIGHT_RE.source,
292
+ note: '正文 line-height 不得超過模板包宣告的上限(嚴格大於才算違規)',
293
+ check: checkLineHeight,
294
+ }),
295
+ Object.freeze({
296
+ key: 'headingWeightMax',
297
+ type: 'positiveNumber',
298
+ code: CODES.GATE_HEADING_WEIGHT,
299
+ scope: 'css',
300
+ pattern: FONT_WEIGHT_RE.source,
301
+ note: '標題選擇器(h1–h6)內的 font-weight 不得超過模板包宣告的上限',
302
+ check: checkHeadingWeight,
303
+ }),
304
+ Object.freeze({
305
+ key: 'shadowBlurMinPx',
306
+ type: 'positiveNumber',
307
+ code: CODES.GATE_SHADOW_BLUR,
308
+ scope: 'css',
309
+ pattern: BOX_SHADOW_RE.source,
310
+ note: 'box-shadow 的模糊半徑不得低於模板包宣告的下限(擋硬陰影)',
311
+ check: checkShadowBlur,
312
+ }),
313
+ Object.freeze({
314
+ key: 'allowItalic',
315
+ type: 'boolean',
316
+ code: CODES.GATE_ITALIC,
317
+ scope: 'css',
318
+ pattern: ITALIC_RE.source,
319
+ note: '宣告 false 時,產物不得含 font-style: italic;宣告 true 等於不跑這條',
320
+ check: checkItalic,
321
+ off: (value) => value === true,
322
+ }),
323
+ Object.freeze({
324
+ key: 'svgRgbaBan',
325
+ type: 'boolean',
326
+ code: CODES.SVG_RGBA,
327
+ scope: 'svg',
328
+ pattern: SVG_RGBA_RE.source,
329
+ note: '宣告 true 時,`<svg>` 子樹內不得出現 rgba()(匯出會壓平 alpha);宣告 false 等於不跑這條',
330
+ check: checkSvgRgba,
331
+ off: (value) => value === false,
332
+ }),
333
+ Object.freeze({
334
+ key: 'bannedHex',
335
+ type: 'hexList',
336
+ code: CODES.GATE_BANNED_HEX,
337
+ scope: 'css',
338
+ pattern: HEX_IN_CSS_RE.source,
339
+ note: '產物 CSS 不得出現模板包自列的禁用色碼(#rgb 與 #rrggbb 視為同色)',
340
+ check: checkBannedHex,
341
+ off: (value) => value.length === 0,
342
+ }),
343
+ ])
344
+
345
+ /** `gates.<key>` — what `lint --rules` prints as a gate rule's declaration site. */
346
+ export const declarationSiteOf = (key) => `${GATES_SECTION}.${key}`
347
+
348
+ const SPEC_BY_KEY = new Map(GATE_SPECS.map((spec) => [spec.key, spec]))
349
+
350
+ /** Every gate a manifest may declare, in listing order. */
351
+ export const gateKeys = () => GATE_SPECS.map((spec) => spec.key)
352
+
353
+ const isTable = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
354
+
355
+ const TYPE_CHECK = Object.freeze({
356
+ positiveNumber: (value) => typeof value === 'number' && Number.isFinite(value) && value > 0,
357
+ boolean: (value) => typeof value === 'boolean',
358
+ hexList: (value) => Array.isArray(value) && value.every((v) => typeof v === 'string' && HEX_RE.test(v)),
359
+ })
360
+
361
+ const TYPE_WORDING = Object.freeze({
362
+ positiveNumber: '正數',
363
+ boolean: '布林值(true/false)',
364
+ hexList: '`#rgb` 或 `#rrggbb` 形色碼的字串陣列',
365
+ })
366
+
367
+ /**
368
+ * Validate a manifest's `[gates]` table, loudly (CONTRACT F7b H1).
369
+ *
370
+ * @param {unknown} gates the raw `gates` value as the TOML reader produced it
371
+ * @param {string} where `<namespace>/<name>`, for the message
372
+ * @returns {Readonly<Record<string, unknown>>} the declared gates, `{}` when absent
373
+ * @throws {KsbError} KSB_GATE_DECLARATION_INVALID, naming the offending key
374
+ */
375
+ export function validateGateDeclaration(gates, where) {
376
+ if (gates === undefined) return Object.freeze({})
377
+ if (!isTable(gates)) {
378
+ throw declarationError(
379
+ `模板包 ${where} 的 \`[${GATES_SECTION}]\` 必須是具名表,得到 ${JSON.stringify(gates)}。`,
380
+ )
381
+ }
382
+ for (const [key, value] of Object.entries(gates)) {
383
+ const spec = SPEC_BY_KEY.get(key)
384
+ if (spec === undefined) {
385
+ throw declarationError(
386
+ `模板包 ${where} 的 \`[${GATES_SECTION}]\` 宣告了不存在的閘門 \`${key}\`;` +
387
+ `可宣告的閘門:${gateKeys().join('、')}。` +
388
+ '未知宣告一律拒收——默默忽略它,包就會以為自己有一道從來沒跑過的閘門。',
389
+ )
390
+ }
391
+ if (!TYPE_CHECK[spec.type](value)) {
392
+ throw declarationError(
393
+ `模板包 ${where} 的 \`[${GATES_SECTION}] ${key}\` 必須是${TYPE_WORDING[spec.type]},` +
394
+ `得到 ${JSON.stringify(value)}。`,
395
+ )
396
+ }
397
+ }
398
+ return Object.freeze({ ...gates })
399
+ }
400
+
401
+ /**
402
+ * Run every gate the package declared against one artifact's scan regions.
403
+ *
404
+ * @param {Record<string, unknown>} gates the validated declaration ({} when none)
405
+ * @param {{css: string, cssBlocks: Array<{selector: string, body: string}>}} regions
406
+ * @returns {Array<{path: string, code: string, message: string}>}
407
+ */
408
+ export function lintGates(gates, regions) {
409
+ if (!isTable(gates)) return []
410
+ return GATE_SPECS.flatMap((spec) => {
411
+ const value = gates[spec.key]
412
+ if (value === undefined) return []
413
+ if (spec.off?.(value)) return []
414
+ return spec.check(value, regions)
415
+ })
416
+ }
417
+
418
+ /** The gate rules as `lint --rules` rows, each carrying its declaration site. */
419
+ export function gateRuleListing() {
420
+ return GATE_SPECS.map((spec) => ({
421
+ code: spec.code,
422
+ scope: spec.scope,
423
+ pattern: spec.pattern,
424
+ note: spec.note,
425
+ gate: declarationSiteOf(spec.key),
426
+ }))
427
+ }