@wxxb789/anc 0.0.2 → 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.
@@ -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
+ }