@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
|
@@ -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
|
+
}
|