@wxxb789/anc 0.0.1 → 0.0.3
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 +63 -34
- package/bin/anc.mjs +120 -11
- package/package.json +8 -1
- package/scripts/copy-assets.js +141 -0
- package/scripts/init-repository.js +18 -0
- package/scripts/load-config.js +258 -5
- package/scripts/markdown-to-artifact.js +310 -6
- package/scripts/publish-media.js +468 -0
- package/scripts/resolve-links.js +318 -42
- package/scripts/scan-residue.js +17 -6
- package/scripts/verify-output-inventory.js +53 -0
- package/src/lib/content.js +22 -0
- package/src/lib/font-coverage.js +845 -0
- package/src/lib/markdown.js +257 -11
- package/src/lib/media-codec.js +355 -0
- package/src/lib/media-delivery.js +76 -0
- package/src/lib/media.js +1426 -0
- package/src/lib/mermaid-render.js +36 -0
- package/src/lib/schema.js +83 -8
- package/src/lib/svg-css.js +685 -0
- package/src/lib/svg-policy.js +1699 -0
- package/src/lib/svg-refusal.js +1 -0
- package/src/lib/svg-render.js +343 -0
- package/src/pages/notes/[slug].astro +10 -1
- package/src/pages/privacy.astro +9 -5
- package/src/styles/global.css +29 -0
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS policy for the static SVG subset: an AST, never a text search.
|
|
3
|
+
*
|
|
4
|
+
* Everything an SVG can ask CSS to fetch arrives through one of three doors —
|
|
5
|
+
* a `<style>` sheet, a `style` attribute, or a URL-bearing presentation
|
|
6
|
+
* attribute such as `fill="url(#grad)"` — and this module closes all three with
|
|
7
|
+
* the same check. `css-tree` parses the text, and every `Url` node it produces
|
|
8
|
+
* must be a same-document fragment.
|
|
9
|
+
*
|
|
10
|
+
* **Why the parser matters rather than a substring test.** CSS escapes are part
|
|
11
|
+
* of the grammar: measured against css-tree 3.2.1,
|
|
12
|
+
* `url("\6a avascript:alert(1)")` and `url("jav\61 script:alert(1)")` both
|
|
13
|
+
* parse to a `Url` node whose *decoded* value is `javascript:alert(1)`, and
|
|
14
|
+
* `url(http\3a //evil/x)` decodes to `http://evil/x`. The source bytes contain
|
|
15
|
+
* neither `javascript:` nor `http://`, so a scan for those literal strings
|
|
16
|
+
* reports nothing while a browser resolves the URL. The check below reads the
|
|
17
|
+
* decoded `Url.value`, which is the form the renderer acts on.
|
|
18
|
+
*
|
|
19
|
+
* The policy is fail-closed on construct: an at-rule (which is where `@import`
|
|
20
|
+
* and `@font-face` `src: url()` live), a `Raw` node (css-tree's fallback for
|
|
21
|
+
* text it could not parse, including a `var()` fallback argument), an unknown
|
|
22
|
+
* function, or any node type outside the static styling grammar is refused
|
|
23
|
+
* rather than passed through on the assumption the renderer and the checker
|
|
24
|
+
* read it the same way.
|
|
25
|
+
*/
|
|
26
|
+
import { lexer, parse, walk } from 'css-tree';
|
|
27
|
+
/** Node types a static declaration value or selector may contain. */
|
|
28
|
+
const STATIC_NODES = {
|
|
29
|
+
StyleSheet: true,
|
|
30
|
+
Rule: true,
|
|
31
|
+
SelectorList: true,
|
|
32
|
+
Selector: true,
|
|
33
|
+
TypeSelector: true,
|
|
34
|
+
ClassSelector: true,
|
|
35
|
+
IdSelector: true,
|
|
36
|
+
UniversalSelector: true,
|
|
37
|
+
AttributeSelector: true,
|
|
38
|
+
PseudoClassSelector: true,
|
|
39
|
+
PseudoElementSelector: true,
|
|
40
|
+
Combinator: true,
|
|
41
|
+
Nth: true,
|
|
42
|
+
WhiteSpace: true,
|
|
43
|
+
Comment: true,
|
|
44
|
+
Block: true,
|
|
45
|
+
Declaration: true,
|
|
46
|
+
DeclarationList: true,
|
|
47
|
+
Value: true,
|
|
48
|
+
Identifier: true,
|
|
49
|
+
Number: true,
|
|
50
|
+
Dimension: true,
|
|
51
|
+
Percentage: true,
|
|
52
|
+
String: true,
|
|
53
|
+
Hash: true,
|
|
54
|
+
Operator: true,
|
|
55
|
+
Ratio: true,
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Functions with no resource-loading meaning.
|
|
59
|
+
*
|
|
60
|
+
* Colour, arithmetic and transform functions are inert. Every function that can
|
|
61
|
+
* name an image or a font is absent on purpose: `url()` is handled as its own
|
|
62
|
+
* node, and `image()`, `image-set()`, `cross-fade()`, `element()`, `paint()`,
|
|
63
|
+
* `local()` and `attr()` are refused by falling outside this list. `var()` is
|
|
64
|
+
* refused too, because its fallback argument parses as a `Raw` node.
|
|
65
|
+
*/
|
|
66
|
+
const STATIC_FUNCTIONS = {
|
|
67
|
+
rgb: true,
|
|
68
|
+
rgba: true,
|
|
69
|
+
hsl: true,
|
|
70
|
+
hsla: true,
|
|
71
|
+
hwb: true,
|
|
72
|
+
lab: true,
|
|
73
|
+
lch: true,
|
|
74
|
+
oklab: true,
|
|
75
|
+
oklch: true,
|
|
76
|
+
color: true,
|
|
77
|
+
'color-mix': true,
|
|
78
|
+
'light-dark': true,
|
|
79
|
+
calc: true,
|
|
80
|
+
min: true,
|
|
81
|
+
max: true,
|
|
82
|
+
clamp: true,
|
|
83
|
+
round: true,
|
|
84
|
+
mod: true,
|
|
85
|
+
rem: true,
|
|
86
|
+
abs: true,
|
|
87
|
+
sign: true,
|
|
88
|
+
translate: true,
|
|
89
|
+
translatex: true,
|
|
90
|
+
translatey: true,
|
|
91
|
+
scale: true,
|
|
92
|
+
scalex: true,
|
|
93
|
+
scaley: true,
|
|
94
|
+
rotate: true,
|
|
95
|
+
skew: true,
|
|
96
|
+
skewx: true,
|
|
97
|
+
skewy: true,
|
|
98
|
+
matrix: true,
|
|
99
|
+
matrix3d: true,
|
|
100
|
+
perspective: true,
|
|
101
|
+
circle: true,
|
|
102
|
+
ellipse: true,
|
|
103
|
+
inset: true,
|
|
104
|
+
polygon: true,
|
|
105
|
+
path: true,
|
|
106
|
+
rect: true,
|
|
107
|
+
};
|
|
108
|
+
const SCHEME = /^\s*([A-Za-z][A-Za-z0-9+.-]*):/;
|
|
109
|
+
/**
|
|
110
|
+
* Classify one reference value.
|
|
111
|
+
*
|
|
112
|
+
* Exported because `href` and `xlink:href` are XML attributes rather than CSS,
|
|
113
|
+
* and the design requires them to get **identical** checks. One function means
|
|
114
|
+
* they cannot drift into two policies.
|
|
115
|
+
*
|
|
116
|
+
* The rules are ordered so the diagnostic names the sharpest fault: an embedded
|
|
117
|
+
* payload is not reported as a generic external URL, and a script URL is not
|
|
118
|
+
* reported as a network request.
|
|
119
|
+
*/
|
|
120
|
+
export function inspectReference(value, where) {
|
|
121
|
+
if (value.startsWith('#')) {
|
|
122
|
+
return value.length > 1 ? undefined : { reason: 'svg-external', detail: `${where}: an empty fragment reference` };
|
|
123
|
+
}
|
|
124
|
+
const scheme = SCHEME.exec(value)?.[1]?.toLowerCase();
|
|
125
|
+
if (scheme === 'data') {
|
|
126
|
+
return { reason: 'svg-data', detail: `${where}: embedded data: payload; ANC does not publish embedded assets` };
|
|
127
|
+
}
|
|
128
|
+
if (scheme === 'javascript' || scheme === 'vbscript' || scheme === 'livescript') {
|
|
129
|
+
return { reason: 'svg-script', detail: `${where}: script URL` };
|
|
130
|
+
}
|
|
131
|
+
if (scheme !== undefined) {
|
|
132
|
+
return { reason: 'svg-external', detail: `${where}: ${scheme}: reference; only a same-document fragment is allowed` };
|
|
133
|
+
}
|
|
134
|
+
return { reason: 'svg-external', detail: `${where}: relative reference; only a same-document fragment is allowed` };
|
|
135
|
+
}
|
|
136
|
+
/** One node, checked for resources and for static-only construct. */
|
|
137
|
+
function checkNode(node, where) {
|
|
138
|
+
if (node.type === 'Url') {
|
|
139
|
+
return inspectReference(node.value ?? '', where);
|
|
140
|
+
}
|
|
141
|
+
if (node.type === 'Function') {
|
|
142
|
+
const name = (node.name ?? '').toLowerCase();
|
|
143
|
+
if (STATIC_FUNCTIONS[name] !== true) {
|
|
144
|
+
return { reason: 'svg-css', detail: `${where}: unsupported CSS function ${name}()` };
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
if (node.type === 'Raw') {
|
|
149
|
+
return { reason: 'svg-css', detail: `${where}: CSS this policy cannot parse` };
|
|
150
|
+
}
|
|
151
|
+
if (node.type === 'Declaration') {
|
|
152
|
+
const property = node['property'];
|
|
153
|
+
if (typeof property === 'string') {
|
|
154
|
+
const decoded = decodeCssEscapes(property).toLowerCase();
|
|
155
|
+
if (LOCAL_REFERENCE_PROPERTIES[decoded] === true) {
|
|
156
|
+
const value = node['value'];
|
|
157
|
+
const parsed = typeof value === 'object' && value !== null ? value : undefined;
|
|
158
|
+
if (keywordValue(parsed) === 'inherit')
|
|
159
|
+
return localInheritanceFault(decoded, where);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (node.type === 'Atrule') {
|
|
164
|
+
const name = (node.name ?? '').toLowerCase();
|
|
165
|
+
return { reason: 'svg-css', detail: `${where}: @${name} is not part of the static subset` };
|
|
166
|
+
}
|
|
167
|
+
if (STATIC_NODES[node.type] !== true) {
|
|
168
|
+
return { reason: 'svg-css', detail: `${where}: unsupported CSS construct ${node.type}` };
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
function inspect(source, context, where) {
|
|
173
|
+
let ast;
|
|
174
|
+
try {
|
|
175
|
+
ast = parse(source, { context });
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
return {
|
|
179
|
+
reason: 'svg-css',
|
|
180
|
+
detail: `${where}: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
let refusal;
|
|
184
|
+
walk(ast, (node) => {
|
|
185
|
+
if (refusal === undefined)
|
|
186
|
+
refusal = checkNode(node, where);
|
|
187
|
+
});
|
|
188
|
+
return refusal;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The selector forms attribution understands.
|
|
192
|
+
*
|
|
193
|
+
* A stylesheet reference is counted per element its rule matches, so the policy
|
|
194
|
+
* must be able to decide that match from the element tree it records: local
|
|
195
|
+
* name, `id`, `class`, and the descendant and child combinators. Everything
|
|
196
|
+
* else — attribute selectors, pseudo-classes, pseudo-elements, sibling and
|
|
197
|
+
* column combinators, namespaces and escaped identifiers — has no recorded
|
|
198
|
+
* fact to decide it here and is refused rather than guessed at.
|
|
199
|
+
*/
|
|
200
|
+
const SELECTOR_FORMS = 'type, class, id and universal selectors joined by the descendant or child combinator';
|
|
201
|
+
function selectorFault(where, construct) {
|
|
202
|
+
return {
|
|
203
|
+
reason: 'svg-css',
|
|
204
|
+
detail: `${where}: ${construct} is not attributable to drawn elements; only ${SELECTOR_FORMS} can be matched`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/** Compile one selector, or name the construct that cannot be attributed. */
|
|
208
|
+
function compileSelector(node, where) {
|
|
209
|
+
if (node.type !== 'Selector') {
|
|
210
|
+
return { ok: false, refusal: selectorFault(where, `the selector form ${node.type}`) };
|
|
211
|
+
}
|
|
212
|
+
const compounds = [];
|
|
213
|
+
let current;
|
|
214
|
+
let combinator;
|
|
215
|
+
const open = () => {
|
|
216
|
+
const compound = { combinator, name: undefined, universal: false, id: undefined, classes: [] };
|
|
217
|
+
compounds.push(compound);
|
|
218
|
+
current = compound;
|
|
219
|
+
combinator = undefined;
|
|
220
|
+
return compound;
|
|
221
|
+
};
|
|
222
|
+
const plain = (name, construct) => {
|
|
223
|
+
const value = name ?? '';
|
|
224
|
+
if (value.includes('\\'))
|
|
225
|
+
return selectorFault(where, `an escaped identifier in ${construct}`);
|
|
226
|
+
return value;
|
|
227
|
+
};
|
|
228
|
+
for (const part of node.children?.toArray() ?? []) {
|
|
229
|
+
if (part.type === 'WhiteSpace' || part.type === 'Comment')
|
|
230
|
+
continue;
|
|
231
|
+
if (part.type === 'Combinator') {
|
|
232
|
+
if (current === undefined) {
|
|
233
|
+
return { ok: false, refusal: selectorFault(where, `the combinator ${JSON.stringify(part.name ?? '')} at the start of a selector`) };
|
|
234
|
+
}
|
|
235
|
+
const name = part.name ?? '';
|
|
236
|
+
if (name === '>' || name === ' ') {
|
|
237
|
+
combinator = name;
|
|
238
|
+
}
|
|
239
|
+
else if (name === '+') {
|
|
240
|
+
return { ok: false, refusal: selectorFault(where, "the adjacent-sibling combinator '+'") };
|
|
241
|
+
}
|
|
242
|
+
else if (name === '~') {
|
|
243
|
+
return { ok: false, refusal: selectorFault(where, "the general-sibling combinator '~'") };
|
|
244
|
+
}
|
|
245
|
+
else if (name === '||') {
|
|
246
|
+
return { ok: false, refusal: selectorFault(where, "the column combinator '||'") };
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
return { ok: false, refusal: selectorFault(where, `the combinator ${JSON.stringify(name)}`) };
|
|
250
|
+
}
|
|
251
|
+
current = undefined;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (part.type === 'TypeSelector' || part.type === 'UniversalSelector') {
|
|
255
|
+
const universal = part.type === 'UniversalSelector' || part.name === '*';
|
|
256
|
+
let typeName;
|
|
257
|
+
if (!universal) {
|
|
258
|
+
const value = plain(part.name, 'a type selector');
|
|
259
|
+
if (typeof value !== 'string')
|
|
260
|
+
return { ok: false, refusal: value };
|
|
261
|
+
if (value === '')
|
|
262
|
+
return { ok: false, refusal: selectorFault(where, 'an empty type selector') };
|
|
263
|
+
if (value.includes('|')) {
|
|
264
|
+
return { ok: false, refusal: selectorFault(where, 'a namespace-qualified type selector') };
|
|
265
|
+
}
|
|
266
|
+
typeName = value;
|
|
267
|
+
}
|
|
268
|
+
const compound = current ?? open();
|
|
269
|
+
if (compound.name !== undefined || compound.universal) {
|
|
270
|
+
return { ok: false, refusal: selectorFault(where, 'more than one type selector in one compound') };
|
|
271
|
+
}
|
|
272
|
+
if (universal)
|
|
273
|
+
compound.universal = true;
|
|
274
|
+
else
|
|
275
|
+
compound.name = typeName;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (part.type === 'ClassSelector') {
|
|
279
|
+
const value = plain(part.name, 'a class selector');
|
|
280
|
+
if (typeof value !== 'string')
|
|
281
|
+
return { ok: false, refusal: value };
|
|
282
|
+
if (value === '')
|
|
283
|
+
return { ok: false, refusal: selectorFault(where, 'an empty class selector') };
|
|
284
|
+
(current ?? open()).classes.push(value);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (part.type === 'IdSelector') {
|
|
288
|
+
const value = plain(part.name, 'an id selector');
|
|
289
|
+
if (typeof value !== 'string')
|
|
290
|
+
return { ok: false, refusal: value };
|
|
291
|
+
if (value === '')
|
|
292
|
+
return { ok: false, refusal: selectorFault(where, 'an empty id selector') };
|
|
293
|
+
const compound = current ?? open();
|
|
294
|
+
if (compound.id !== undefined) {
|
|
295
|
+
return { ok: false, refusal: selectorFault(where, 'more than one id selector in one compound') };
|
|
296
|
+
}
|
|
297
|
+
compound.id = value;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (part.type === 'AttributeSelector') {
|
|
301
|
+
return { ok: false, refusal: selectorFault(where, 'an attribute selector') };
|
|
302
|
+
}
|
|
303
|
+
if (part.type === 'PseudoClassSelector') {
|
|
304
|
+
return { ok: false, refusal: selectorFault(where, `the pseudo-class ':${part.name ?? ''}'`) };
|
|
305
|
+
}
|
|
306
|
+
if (part.type === 'PseudoElementSelector') {
|
|
307
|
+
return { ok: false, refusal: selectorFault(where, `the pseudo-element '::${part.name ?? ''}'`) };
|
|
308
|
+
}
|
|
309
|
+
return { ok: false, refusal: selectorFault(where, `the selector construct ${part.type}`) };
|
|
310
|
+
}
|
|
311
|
+
if (compounds.length === 0) {
|
|
312
|
+
return { ok: false, refusal: selectorFault(where, 'an empty selector') };
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
ok: true,
|
|
316
|
+
selector: compounds.map((compound) => ({
|
|
317
|
+
combinator: compound.combinator,
|
|
318
|
+
name: compound.universal ? undefined : compound.name,
|
|
319
|
+
id: compound.id,
|
|
320
|
+
classes: compound.classes,
|
|
321
|
+
})),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
/** Compile one selector list, preserving its alternatives in source order. */
|
|
325
|
+
function compileSelectorList(node, where) {
|
|
326
|
+
if (node === undefined || node.type !== 'SelectorList') {
|
|
327
|
+
return { ok: false, refusal: selectorFault(where, `the selector form ${node?.type ?? 'missing'}`) };
|
|
328
|
+
}
|
|
329
|
+
const selectors = [];
|
|
330
|
+
for (const part of node.children?.toArray() ?? []) {
|
|
331
|
+
if (part.type === 'WhiteSpace' || part.type === 'Comment')
|
|
332
|
+
continue;
|
|
333
|
+
const compiled = compileSelector(part, where);
|
|
334
|
+
if (!compiled.ok)
|
|
335
|
+
return compiled;
|
|
336
|
+
selectors.push(compiled.selector);
|
|
337
|
+
}
|
|
338
|
+
if (selectors.length === 0) {
|
|
339
|
+
return { ok: false, refusal: selectorFault(where, 'an empty selector list') };
|
|
340
|
+
}
|
|
341
|
+
return { ok: true, selectors };
|
|
342
|
+
}
|
|
343
|
+
/** Collect the same-document fragments named by `url()` anywhere under one node. */
|
|
344
|
+
function collectFragmentReferences(root, into) {
|
|
345
|
+
walk(root, (node) => {
|
|
346
|
+
if (node.type === 'Url' && typeof node.value === 'string' && node.value.startsWith('#') && node.value.length > 1) {
|
|
347
|
+
into.push(node.value.slice(1));
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* The declarations in one parsed block, each with the fragments its value names.
|
|
353
|
+
*
|
|
354
|
+
* The property name matters, not only the URL: inheritance is decided per
|
|
355
|
+
* inherited property, so a declaration with no URL (`fill="red"`) still has to
|
|
356
|
+
* be visible to the policy as the declaration that overrides an ancestor's
|
|
357
|
+
* `fill="url(#p)"`. A CSS-wide keyword that resolves to the parent's value is
|
|
358
|
+
* flagged instead of treated as a plain local value.
|
|
359
|
+
*/
|
|
360
|
+
function collectDeclarations(root) {
|
|
361
|
+
const declarations = [];
|
|
362
|
+
walk(root, (node) => {
|
|
363
|
+
if (node.type !== 'Declaration')
|
|
364
|
+
return;
|
|
365
|
+
const property = node['property'];
|
|
366
|
+
if (typeof property !== 'string')
|
|
367
|
+
return;
|
|
368
|
+
const references = [];
|
|
369
|
+
collectFragmentReferences(node, references);
|
|
370
|
+
const value = node['value'];
|
|
371
|
+
const parsed = typeof value === 'object' && value !== null ? value : undefined;
|
|
372
|
+
declarations.push({
|
|
373
|
+
property: decodeCssEscapes(property).toLowerCase(),
|
|
374
|
+
references,
|
|
375
|
+
defers: defersToParent(parsed),
|
|
376
|
+
suppresses: suppressesInheritedReference(property, parsed),
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
return declarations;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Keywords whose computed value comes from the parent for an inherited property.
|
|
383
|
+
*
|
|
384
|
+
* `revert` and `revert-layer` roll back to a lower origin; a presentation
|
|
385
|
+
* attribute is a lower origin, and the inherited value may be the result, so
|
|
386
|
+
* both are treated as deferring. `initial` is not here.
|
|
387
|
+
*/
|
|
388
|
+
const DEFERRING_KEYWORDS = {
|
|
389
|
+
inherit: true,
|
|
390
|
+
unset: true,
|
|
391
|
+
revert: true,
|
|
392
|
+
'revert-layer': true,
|
|
393
|
+
};
|
|
394
|
+
/**
|
|
395
|
+
* One CSS escape, hex form with its optional single terminator or literal form.
|
|
396
|
+
*
|
|
397
|
+
* `css-tree` keeps escapes in `property` and `Identifier.name` (`\69 nherit`
|
|
398
|
+
* stays spelled that way), while a CSS reader resolves it to `inherit`. The
|
|
399
|
+
* comparison below must see the decoded name or a host could spell a deferring
|
|
400
|
+
* keyword so the policy reads a plain local value and drops the inherited
|
|
401
|
+
* reference.
|
|
402
|
+
*/
|
|
403
|
+
const CSS_ESCAPE = /\\(?:([0-9a-fA-F]{1,6})[ \t\r\n\f]?|([\s\S]))/g;
|
|
404
|
+
function decodeCssEscapes(value) {
|
|
405
|
+
return value.replace(CSS_ESCAPE, (_match, hex, literal) => {
|
|
406
|
+
if (hex !== undefined) {
|
|
407
|
+
const codePoint = Number.parseInt(hex, 16);
|
|
408
|
+
if (codePoint === 0 || codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) {
|
|
409
|
+
return '\uFFFD';
|
|
410
|
+
}
|
|
411
|
+
return String.fromCodePoint(codePoint);
|
|
412
|
+
}
|
|
413
|
+
if (literal === '\n' || literal === '\r' || literal === '\f')
|
|
414
|
+
return '';
|
|
415
|
+
return literal ?? '';
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Reference properties this subset does not inherit, so an explicit `inherit`
|
|
420
|
+
* is refused rather than guessed at.
|
|
421
|
+
*
|
|
422
|
+
* `mask`, `filter` and `clip-path` take their computed value from the element
|
|
423
|
+
* that declares them. `inherit` asks for the parent's computed value instead;
|
|
424
|
+
* the expansion count would have to propagate a local slot to honour it, which
|
|
425
|
+
* is a second inheritance model this policy does not have. Refusing the
|
|
426
|
+
* keyword keeps the count honest and tells the author to write the declaration
|
|
427
|
+
* where it applies.
|
|
428
|
+
*/
|
|
429
|
+
const LOCAL_REFERENCE_PROPERTIES = {
|
|
430
|
+
filter: true,
|
|
431
|
+
'clip-path': true,
|
|
432
|
+
mask: true,
|
|
433
|
+
};
|
|
434
|
+
/** The colour functions whose arguments can be checked without a grammar. */
|
|
435
|
+
const LITERAL_COLOR_FUNCTIONS = {
|
|
436
|
+
rgb: true,
|
|
437
|
+
rgba: true,
|
|
438
|
+
hsl: true,
|
|
439
|
+
hsla: true,
|
|
440
|
+
};
|
|
441
|
+
/** Node types a literal colour function's argument list may contain. */
|
|
442
|
+
const LITERAL_COLOR_ARGUMENTS = {
|
|
443
|
+
Number: true,
|
|
444
|
+
Percentage: true,
|
|
445
|
+
Dimension: true,
|
|
446
|
+
Operator: true,
|
|
447
|
+
WhiteSpace: true,
|
|
448
|
+
Comment: true,
|
|
449
|
+
};
|
|
450
|
+
/**
|
|
451
|
+
* The identifier a value consists of, undecoded and lowercased, or `undefined`.
|
|
452
|
+
*
|
|
453
|
+
* Undecoded on purpose: an escaped spelling (`\6e one`) is not admitted to the
|
|
454
|
+
* suppression whitelist. The renderer would resolve it, but keeping the
|
|
455
|
+
* inherited reference is the safe direction and the whitelist stays exact.
|
|
456
|
+
*/
|
|
457
|
+
function rawKeyword(value) {
|
|
458
|
+
if (value?.type !== 'Value')
|
|
459
|
+
return undefined;
|
|
460
|
+
const parts = value.children?.toArray() ?? [];
|
|
461
|
+
if (parts.length !== 1)
|
|
462
|
+
return undefined;
|
|
463
|
+
const [only] = parts;
|
|
464
|
+
return only?.type === 'Identifier' && typeof only.name === 'string' ? only.name.toLowerCase() : undefined;
|
|
465
|
+
}
|
|
466
|
+
/** The CSS keyword a value consists of, decoded and lowercased. */
|
|
467
|
+
function keywordValue(value) {
|
|
468
|
+
const raw = rawKeyword(value);
|
|
469
|
+
return raw === undefined ? undefined : decodeCssEscapes(raw).toLowerCase();
|
|
470
|
+
}
|
|
471
|
+
/** Whether a parsed value is exactly one CSS-wide keyword that defers to the parent. */
|
|
472
|
+
function defersToParent(value) {
|
|
473
|
+
const keyword = keywordValue(value);
|
|
474
|
+
return keyword !== undefined && DEFERRING_KEYWORDS[keyword] === true;
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Whether the parser's own syntax resolves this node as `type`.
|
|
478
|
+
*
|
|
479
|
+
* This is css-tree's bundled CSS grammar, not a second colour table. It is used
|
|
480
|
+
* with `named-color` for the named keywords and with `color` for the shapes the
|
|
481
|
+
* whitelist has already restricted (a hash or one of four literal colour
|
|
482
|
+
* functions); it cannot admit a reference-bearing shape from those callers.
|
|
483
|
+
*/
|
|
484
|
+
function parserResolves(node, type) {
|
|
485
|
+
try {
|
|
486
|
+
return lexer.matchType(type, node).matched !== null;
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/** Whether a colour function's arguments are all literal components. */
|
|
493
|
+
function literalColorArguments(node) {
|
|
494
|
+
for (const part of node.children?.toArray() ?? []) {
|
|
495
|
+
if (LITERAL_COLOR_ARGUMENTS[part.type] !== true)
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Whether a declaration's value provably paints no document resource.
|
|
502
|
+
*
|
|
503
|
+
* **Invariant: a value may suppress an inherited reference only if it
|
|
504
|
+
* provably paints no resource; every other value keeps the reference.** The
|
|
505
|
+
* safe direction is over-counting, so the admitted set is only what can be
|
|
506
|
+
* checked structurally:
|
|
507
|
+
*
|
|
508
|
+
* - `fill` / `stroke`: `none`, `initial` (its initial value is none),
|
|
509
|
+
* `currentColor`, a hash the parser resolves as a colour, a colour keyword
|
|
510
|
+
* from the parser's `named-color` grammar, or an
|
|
511
|
+
* `rgb()`/`rgba()`/`hsl()`/`hsla()` call whose arguments are literals and
|
|
512
|
+
* whose result the parser resolves as a colour.
|
|
513
|
+
* - the marker properties (and the `marker` shorthand): `none` or `initial`.
|
|
514
|
+
* Their grammar admits only `none` or a marker reference, so no other value
|
|
515
|
+
* can be a proven paint.
|
|
516
|
+
*
|
|
517
|
+
* Escaped spellings of these keywords are not admitted: `rawKeyword` does not
|
|
518
|
+
* decode. `url(#…)`, a bare gradient name, an arithmetic function, an unknown
|
|
519
|
+
* keyword and anything unparseable all keep the inherited reference. A valid
|
|
520
|
+
* paint outside the whitelist — `transparent`, a system colour, `hwb()`,
|
|
521
|
+
* `lab()`, `color()` — also keeps it: this policy does not model which colour
|
|
522
|
+
* syntaxes the renderer installed, and a value it rejects falls back to the
|
|
523
|
+
* inherited paint. `cursor` is inherited by the policy's table but never
|
|
524
|
+
* suppresses: it cannot paint with a fragment in an SVG image, and keeping its
|
|
525
|
+
* reference closes the table against an inheritance route this policy forgot.
|
|
526
|
+
*/
|
|
527
|
+
function suppressesInheritedReference(property, value) {
|
|
528
|
+
const decoded = decodeCssEscapes(property).toLowerCase();
|
|
529
|
+
if (decoded === 'fill' || decoded === 'stroke') {
|
|
530
|
+
if (value?.type !== 'Value')
|
|
531
|
+
return false;
|
|
532
|
+
const parts = value.children?.toArray() ?? [];
|
|
533
|
+
if (parts.length !== 1)
|
|
534
|
+
return false;
|
|
535
|
+
const [only] = parts;
|
|
536
|
+
if (only === undefined)
|
|
537
|
+
return false;
|
|
538
|
+
if (only.type === 'Identifier') {
|
|
539
|
+
const keyword = rawKeyword(value);
|
|
540
|
+
if (keyword === 'none' || keyword === 'initial' || keyword === 'currentcolor')
|
|
541
|
+
return true;
|
|
542
|
+
return parserResolves(only, 'named-color');
|
|
543
|
+
}
|
|
544
|
+
if (only.type === 'Hash')
|
|
545
|
+
return parserResolves(only, 'color');
|
|
546
|
+
if (only.type === 'Function') {
|
|
547
|
+
if (LITERAL_COLOR_FUNCTIONS[only.name?.toLowerCase() ?? ''] !== true)
|
|
548
|
+
return false;
|
|
549
|
+
if (!literalColorArguments(only))
|
|
550
|
+
return false;
|
|
551
|
+
return parserResolves(only, 'color');
|
|
552
|
+
}
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
if (decoded === 'marker' || decoded.startsWith('marker-')) {
|
|
556
|
+
const keyword = rawKeyword(value);
|
|
557
|
+
return keyword === 'none' || keyword === 'initial';
|
|
558
|
+
}
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
/** The refusal for an explicit `inherit` on a local reference property. */
|
|
562
|
+
function localInheritanceFault(property, where) {
|
|
563
|
+
return {
|
|
564
|
+
reason: 'svg-css',
|
|
565
|
+
detail: `${where}: ${property}: inherit is refused because ${property} does not inherit; declare ${property} on the element it applies to`,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* One presentation attribute as a declaration: its fragments and whether its
|
|
570
|
+
* value is a deferring keyword. Parsed with the same context as
|
|
571
|
+
* {@link inspectPresentationAttribute}; the caller has already accepted it, so
|
|
572
|
+
* an unparseable value contributes no reference rather than a second refusal.
|
|
573
|
+
*/
|
|
574
|
+
export function presentationDeclaration(property, source) {
|
|
575
|
+
let ast;
|
|
576
|
+
try {
|
|
577
|
+
ast = parse(source, { context: 'value' });
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
return { property, references: [], defers: false, suppresses: false };
|
|
581
|
+
}
|
|
582
|
+
const references = [];
|
|
583
|
+
collectFragmentReferences(ast, references);
|
|
584
|
+
return {
|
|
585
|
+
property,
|
|
586
|
+
references,
|
|
587
|
+
defers: defersToParent(ast),
|
|
588
|
+
suppresses: suppressesInheritedReference(property, ast),
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Inspect a `<style>` element and return what it contributes to attribution.
|
|
593
|
+
*
|
|
594
|
+
* The static grammar check is the same one every other CSS door gets. Selector
|
|
595
|
+
* lists are compiled to the subset matching can decide, because a sheet's
|
|
596
|
+
* references are counted once per element the rule matches, not once per sheet.
|
|
597
|
+
* A selector form outside that subset is refused here rather than attributed to
|
|
598
|
+
* the root or silently dropped.
|
|
599
|
+
*/
|
|
600
|
+
export function analyzeStyleSheet(source) {
|
|
601
|
+
let ast;
|
|
602
|
+
try {
|
|
603
|
+
ast = parse(source, { context: 'stylesheet' });
|
|
604
|
+
}
|
|
605
|
+
catch (error) {
|
|
606
|
+
return {
|
|
607
|
+
ok: false,
|
|
608
|
+
refusal: {
|
|
609
|
+
reason: 'svg-css',
|
|
610
|
+
detail: `style element: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`,
|
|
611
|
+
},
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
let refusal;
|
|
615
|
+
walk(ast, (node) => {
|
|
616
|
+
if (refusal === undefined)
|
|
617
|
+
refusal = checkNode(node, 'style element');
|
|
618
|
+
});
|
|
619
|
+
if (refusal !== undefined)
|
|
620
|
+
return { ok: false, refusal };
|
|
621
|
+
const rules = [];
|
|
622
|
+
for (const node of ast.children?.toArray() ?? []) {
|
|
623
|
+
if (node.type === 'WhiteSpace' || node.type === 'Comment')
|
|
624
|
+
continue;
|
|
625
|
+
if (node.type !== 'Rule') {
|
|
626
|
+
return { ok: false, refusal: selectorFault('style element', `the top-level ${node.type}`) };
|
|
627
|
+
}
|
|
628
|
+
const selectors = compileSelectorList(node.prelude, 'style element');
|
|
629
|
+
if (!selectors.ok)
|
|
630
|
+
return selectors;
|
|
631
|
+
const declarations = node.block === undefined ? [] : collectDeclarations(node.block);
|
|
632
|
+
rules.push({ selectors: selectors.selectors, declarations });
|
|
633
|
+
}
|
|
634
|
+
return { ok: true, rules };
|
|
635
|
+
}
|
|
636
|
+
/** Check a `<style>` element's contents, including its selectors. */
|
|
637
|
+
export function inspectStyleSheet(source) {
|
|
638
|
+
const analysis = analyzeStyleSheet(source);
|
|
639
|
+
return analysis.ok ? undefined : analysis.refusal;
|
|
640
|
+
}
|
|
641
|
+
/** Check a `style` attribute's declaration list. */
|
|
642
|
+
export function inspectStyleAttribute(source) {
|
|
643
|
+
return inspect(source, 'declarationList', 'style attribute');
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* The declarations in a `style` attribute, each with its fragment references.
|
|
647
|
+
*
|
|
648
|
+
* Parsed with the same grammar and context as {@link inspectStyleAttribute}. The
|
|
649
|
+
* caller has already accepted the attribute, so an unparseable value contributes
|
|
650
|
+
* no declarations rather than a second refusal.
|
|
651
|
+
*/
|
|
652
|
+
export function declarationReferences(source) {
|
|
653
|
+
let ast;
|
|
654
|
+
try {
|
|
655
|
+
ast = parse(source, { context: 'declarationList' });
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
return [];
|
|
659
|
+
}
|
|
660
|
+
return collectDeclarations(ast);
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Check one URL-bearing presentation attribute.
|
|
664
|
+
*
|
|
665
|
+
* `fill`, `stroke`, `filter`, `mask`, `clip-path` and the marker properties all
|
|
666
|
+
* accept `url(...)`, and `cursor` accepts a URL list. Parsing the value rather
|
|
667
|
+
* than scanning it means an escaped scheme is decoded by the same grammar the
|
|
668
|
+
* renderer uses.
|
|
669
|
+
*/
|
|
670
|
+
export function inspectPresentationAttribute(name, source, where = `${name} attribute`) {
|
|
671
|
+
const property = decodeCssEscapes(name).toLowerCase();
|
|
672
|
+
if (LOCAL_REFERENCE_PROPERTIES[property] === true) {
|
|
673
|
+
let value;
|
|
674
|
+
try {
|
|
675
|
+
value = parse(source, { context: 'value' });
|
|
676
|
+
}
|
|
677
|
+
catch {
|
|
678
|
+
// `inspect` below reports the parse error with the same context.
|
|
679
|
+
value = undefined;
|
|
680
|
+
}
|
|
681
|
+
if (keywordValue(value) === 'inherit')
|
|
682
|
+
return localInheritanceFault(property, where);
|
|
683
|
+
}
|
|
684
|
+
return inspect(source, 'value', where);
|
|
685
|
+
}
|