@event4u/agent-config 16.4.0 → 16.5.0
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/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +40 -191
- package/README.md +1 -1
- package/dist/agent-src/commands/design.md +1 -1
- package/dist/agent-src/guidelines/design-antipatterns.md +1 -1
- package/dist/agent-src/rules/token-budget-discipline.md +2 -2
- package/dist/agent-src/skills/accessibility-auditor/data/aria-patterns.csv +2 -0
- package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +3 -3
- package/dist/agent-src/skills/canvas-design/SKILL.md +4 -2
- package/dist/agent-src/skills/design-intelligence/references/integration-mapping.md +13 -10
- package/dist/agent-src/skills/fe-design/references/design-patterns.md +49 -2
- package/dist/agent-src/skills/frontend-render-security/SKILL.md +9 -0
- package/dist/agent-src/skills/iconography/SKILL.md +4 -2
- package/dist/agent-src/skills/image-provider-routing/SKILL.md +16 -5
- package/dist/agent-src/skills/logo-generation/SKILL.md +12 -8
- package/dist/agent-src/skills/svg-authoring/SKILL.md +318 -0
- package/dist/agent-src/skills/wireframe/SKILL.md +5 -2
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/commands/uiRender.js +201 -4
- package/dist/cli/commands/uiRenderAsset.js +1308 -0
- package/dist/cli/commands/uiRenderAssetCompare.js +752 -0
- package/dist/cli/commands/uiRenderAssetGrid.js +265 -0
- package/dist/cli/commands/uiRenderAssetGrounds.js +323 -0
- package/dist/cli/commands/uiRenderAssetIsolation.js +85 -0
- package/dist/cli/commands/uiRenderAssetTypes.js +145 -0
- package/dist/cli/main.js +47 -1
- package/dist/cli/registry.js +1 -1
- package/dist/cli-delegate/{chunk-MSUL5NYP.js → chunk-4DBG5K7M.js} +1 -1
- package/dist/cli-delegate/{chunk-AED2VQGM.js → chunk-TMWYUOK4.js} +10 -1
- package/dist/cli-delegate/cmd_conformance.js +395 -214
- package/dist/cli-delegate/cmd_handoff.js +2 -2
- package/dist/cli-delegate/cmd_session_recycle.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +47 -22
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +6 -6
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +19 -15
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +13 -9
- package/dist/hooks/dispatch.js +1073 -808
- package/dist/hooks/dispatch.sha256 +1 -1
- package/dist/hooks/svg_rules.chunk.cjs +1626 -0
- package/dist/hooks/svg_rules.chunk.cjs.sha256 +1 -0
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +931 -735
- package/dist/scripts/_lib/scan_scope.js +105 -0
- package/dist/scripts/_lib/svg_dom.js +284 -0
- package/dist/scripts/_lib/svg_geometry.js +477 -0
- package/dist/scripts/_lib/svg_motion.js +179 -0
- package/dist/scripts/_lib/svg_rules.js +1337 -0
- package/dist/scripts/_lib/svg_urls.js +67 -0
- package/dist/scripts/lint_svg_asset.js +324 -0
- package/docs/guidelines/design-antipatterns.md +1 -1
- package/package.json +4 -3
- package/src/config/gate-violation-baselines.json +3 -3
- package/src/config/routing-coverage-seed.json +9 -2
- package/src/scripts/_lib/svg_dom.ts +318 -0
- package/src/scripts/_lib/svg_geometry.ts +480 -0
- package/src/scripts/_lib/svg_motion.ts +208 -0
- package/src/scripts/_lib/svg_rules.ts +1453 -0
- package/src/scripts/_lib/svg_urls.ts +72 -0
- package/src/scripts/_lib/verification_command.ts +59 -0
- package/src/scripts/_lib/verify_clause.ts +27 -17
- package/src/scripts/hooks/block_speaking_inbox_dir.ts +16 -1
- package/src/scripts/hooks/design_pass_hook.ts +157 -3
- package/src/scripts/hooks/svg_obligation.ts +497 -0
- package/src/scripts/hooks/svg_rules_loader.ts +133 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +5 -3
- package/src/scripts/lint_consolidation_lineage.ts +6 -3
- package/src/scripts/lint_design_quality.ts +60 -12
- package/src/scripts/lint_motion_authority_drift.ts +27 -0
- package/src/scripts/lint_svg_asset.ts +365 -0
|
@@ -0,0 +1,1337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* svg_rules — the SVG rule set, with no command-line surface attached.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `lint_svg_asset.ts` for a measured reason, not a tidy one, and
|
|
5
|
+
* the routing it was split FOR IS NOW LIVE.
|
|
6
|
+
*
|
|
7
|
+
* THIS PARAGRAPH USED TO SAY THE OPPOSITE, and it was false the moment the
|
|
8
|
+
* routing landed — finding 10 of that change's own completion review, which is
|
|
9
|
+
* the cheapest kind of defect to leave behind and the most expensive to read: a
|
|
10
|
+
* reader opening this file to decide whether it sits on a hot path was told it
|
|
11
|
+
* does not.
|
|
12
|
+
*
|
|
13
|
+
* It does. `hooks/design_pass_hook.ts` routes every `.svg` write here through
|
|
14
|
+
* `hooks/svg_rules_loader.ts`, and `package.json`'s `build:hook-chunks` emits
|
|
15
|
+
* this module as `dist/hooks/svg_rules.chunk.cjs` for exactly that. The chunk
|
|
16
|
+
* is why it is live without being expensive: step 1.4 of
|
|
17
|
+
* `road-to-svg-with-a-witness` imported these rules STATICALLY into the one
|
|
18
|
+
* bundle every hook event loads — 30,924 bytes, 12,931 past the ceiling — and
|
|
19
|
+
* an independent council refused it 2/2. Loaded as a chunk the same rules cost
|
|
20
|
+
* 2,128 bytes in that bundle and are `require`d only for a path ending in
|
|
21
|
+
* `.svg`.
|
|
22
|
+
*
|
|
23
|
+
* So: this module is on the `.svg` write path and on no other. Anything added
|
|
24
|
+
* here is paid by an SVG write and by nothing else — a budget, not a licence.
|
|
25
|
+
* Its other consumers are `lint_svg_asset` and the fixture report.
|
|
26
|
+
*
|
|
27
|
+
* `lint_svg_asset.ts` is now the CLI over this module and re-exports it, so
|
|
28
|
+
* every existing import keeps resolving.
|
|
29
|
+
*/
|
|
30
|
+
import { attrsOf, parseDocument, tagEnd } from './svg_dom.js';
|
|
31
|
+
import { cssUnescape, isActiveUrl, localName, rawAttrs, resolvedUrl, svgPrefixes, URL_ATTRS } from './svg_urls.js';
|
|
32
|
+
import { elementExtent, pathDataError, pathExtent, pointsExtent, staticBox, tokenisePath, } from './svg_geometry.js';
|
|
33
|
+
export { elementExtent, pathDataError, pathExtent, pointsExtent, staticBox, tokenisePath };
|
|
34
|
+
import { SMIL_TAGS, declarationsOf, firstSmilLine, hasCssAnimation, hasReducedMotionQuery, hasSmilMarkup, readCssRules, smilVerdict, } from './svg_motion.js';
|
|
35
|
+
export const DELIVERY_MODES = ['inline', 'document', 'img', 'css-bg'];
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// A deliberately small scanner
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
/**
|
|
40
|
+
* Elements in document order.
|
|
41
|
+
*
|
|
42
|
+
* Not a conformant XML parser and not trying to be: it skips comments, CDATA
|
|
43
|
+
* and the prolog, and reads tag names plus attributes. Everything the rules
|
|
44
|
+
* below need is attribute-level or text-level, and pulling in a real parser
|
|
45
|
+
* would make a dependency-free linter depend on one.
|
|
46
|
+
*/
|
|
47
|
+
export function parseElements(content) {
|
|
48
|
+
const out = [];
|
|
49
|
+
const lineOf = lineIndexer(content);
|
|
50
|
+
let i = 0;
|
|
51
|
+
while (i < content.length) {
|
|
52
|
+
const lt = content.indexOf('<', i);
|
|
53
|
+
if (lt === -1)
|
|
54
|
+
break;
|
|
55
|
+
// Comments, CDATA and the prolog carry no elements.
|
|
56
|
+
if (content.startsWith('<!--', lt)) {
|
|
57
|
+
const end = content.indexOf('-->', lt + 4);
|
|
58
|
+
i = end === -1 ? content.length : end + 3;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (content.startsWith('<![CDATA[', lt)) {
|
|
62
|
+
const end = content.indexOf(']]>', lt + 9);
|
|
63
|
+
i = end === -1 ? content.length : end + 3;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (content.startsWith('<?', lt)) {
|
|
67
|
+
// A PROCESSING INSTRUCTION ENDS AT `?>`, NEVER AT THE FIRST `>`.
|
|
68
|
+
//
|
|
69
|
+
// Finding 3 of the third completion review, reproduced by running
|
|
70
|
+
// `scanSvg`: this branch shared the DOCTYPE's first-`>` search, so
|
|
71
|
+
// `<?xml-stylesheet href="a.css?x=1><script/>"?>` — a legal PI
|
|
72
|
+
// whose quoted pseudo-attribute contains a `>` — ended early and
|
|
73
|
+
// the remainder was parsed as markup. `<script/>` then became a
|
|
74
|
+
// real element and the file was reported
|
|
75
|
+
// `svg-safety-active-content` at severity `error`, on a document
|
|
76
|
+
// that contains no script. Under finding 4's static-only path that
|
|
77
|
+
// false error is a BLOCKING refusal, so the two compound.
|
|
78
|
+
//
|
|
79
|
+
// `svg_dom.parseDocument` always did this correctly; the two
|
|
80
|
+
// scanners disagreeing about where the prolog ends is what the
|
|
81
|
+
// module header below now says plainly instead of claiming they
|
|
82
|
+
// are one scan.
|
|
83
|
+
const end = content.indexOf('?>', lt + 2);
|
|
84
|
+
i = end === -1 ? content.length : end + 2;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (content.startsWith('<!', lt)) {
|
|
88
|
+
// A DOCTYPE may carry a bracketed internal subset; skip past it.
|
|
89
|
+
const bracket = content.indexOf('[', lt);
|
|
90
|
+
const close = content.indexOf('>', lt);
|
|
91
|
+
if (bracket !== -1 && close !== -1 && bracket < close) {
|
|
92
|
+
const endBracket = content.indexOf(']', bracket);
|
|
93
|
+
const after = endBracket === -1 ? close : content.indexOf('>', endBracket);
|
|
94
|
+
i = after === -1 ? content.length : after + 1;
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
i = close === -1 ? content.length : close + 1;
|
|
98
|
+
}
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const gt = tagEnd(content, lt);
|
|
102
|
+
if (gt === -1)
|
|
103
|
+
break;
|
|
104
|
+
const raw = content.slice(lt, gt + 1);
|
|
105
|
+
const closing = raw.startsWith('</');
|
|
106
|
+
const selfClosing = raw.endsWith('/>');
|
|
107
|
+
const nameMatch = /^<\/?\s*([A-Za-z_][\w.:-]*)/.exec(raw);
|
|
108
|
+
if (!nameMatch) {
|
|
109
|
+
i = gt + 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
out.push({
|
|
113
|
+
name: (nameMatch[1] ?? '').toLowerCase(),
|
|
114
|
+
attrs: closing ? {} : attrsOf(raw),
|
|
115
|
+
line: lineOf(lt),
|
|
116
|
+
start: lt,
|
|
117
|
+
selfClosing,
|
|
118
|
+
closing,
|
|
119
|
+
raw,
|
|
120
|
+
});
|
|
121
|
+
i = gt + 1;
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
/** End of the tag, honouring quoted attribute values that may contain `>`. */
|
|
126
|
+
/**
|
|
127
|
+
* Blank out every XML comment, preserving offsets and line breaks.
|
|
128
|
+
*
|
|
129
|
+
* Finding 2.3 of `road-to-svg-rules-in-the-dispatch-path`, from the R2
|
|
130
|
+
* completion review of the parent change: `parseElements` skips comments and
|
|
131
|
+
* the content-level regex rules did not, so the two disagreed about what the
|
|
132
|
+
* file contains. A commented `url(#x)` marked a `<defs>` entry used, and a
|
|
133
|
+
* commented `@media (prefers-reduced-motion)` suppressed the
|
|
134
|
+
* no-reduced-motion rule — in both directions a finding was lost to markup
|
|
135
|
+
* that does not render.
|
|
136
|
+
*
|
|
137
|
+
* Every character inside `<!--` … `-->` becomes a space, the delimiters
|
|
138
|
+
* included, and newlines are kept. That keeps every offset and every line
|
|
139
|
+
* number identical to the raw text, so a rule can report a position in the
|
|
140
|
+
* masked string and have it mean the same thing in the file. An unterminated
|
|
141
|
+
* comment masks to end of input, which is what a browser does with it too.
|
|
142
|
+
*
|
|
143
|
+
* NOT applied to `parseIgnores`, deliberately: the inline
|
|
144
|
+
* `svg-asset-disable` markers LIVE in comments, so masking them would make every one of them silently stop
|
|
145
|
+
* working. The raw text is the right input there and the masked text is the
|
|
146
|
+
* right input everywhere else.
|
|
147
|
+
*/
|
|
148
|
+
export function maskComments(content) {
|
|
149
|
+
if (!content.includes('<!--'))
|
|
150
|
+
return content;
|
|
151
|
+
const out = content.split('');
|
|
152
|
+
let i = 0;
|
|
153
|
+
while (i < out.length) {
|
|
154
|
+
const start = content.indexOf('<!--', i);
|
|
155
|
+
if (start === -1)
|
|
156
|
+
break;
|
|
157
|
+
const close = content.indexOf('-->', start + 4);
|
|
158
|
+
const end = close === -1 ? content.length : close + 3;
|
|
159
|
+
for (let j = start; j < end; j += 1) {
|
|
160
|
+
if (out[j] !== '\n' && out[j] !== '\r')
|
|
161
|
+
out[j] = ' ';
|
|
162
|
+
}
|
|
163
|
+
i = end;
|
|
164
|
+
}
|
|
165
|
+
return out.join('');
|
|
166
|
+
}
|
|
167
|
+
function lineIndexer(content) {
|
|
168
|
+
const starts = [0];
|
|
169
|
+
for (let i = 0; i < content.length; i++)
|
|
170
|
+
if (content[i] === '\n')
|
|
171
|
+
starts.push(i + 1);
|
|
172
|
+
return (offset) => {
|
|
173
|
+
let lo = 0;
|
|
174
|
+
let hi = starts.length - 1;
|
|
175
|
+
while (lo < hi) {
|
|
176
|
+
const mid = (lo + hi + 1) >> 1;
|
|
177
|
+
if (starts[mid] <= offset)
|
|
178
|
+
lo = mid;
|
|
179
|
+
else
|
|
180
|
+
hi = mid - 1;
|
|
181
|
+
}
|
|
182
|
+
return lo + 1;
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/** The text between an opening and its matching closing tag, for simple leaves. */
|
|
186
|
+
function textOf(content, elements, index) {
|
|
187
|
+
const el = elements[index];
|
|
188
|
+
if (!el || el.selfClosing)
|
|
189
|
+
return '';
|
|
190
|
+
const openEnd = content.indexOf('>', el.start);
|
|
191
|
+
const closeTag = `</${el.name}`;
|
|
192
|
+
const closeAt = content.toLowerCase().indexOf(closeTag, openEnd);
|
|
193
|
+
if (openEnd === -1 || closeAt === -1)
|
|
194
|
+
return '';
|
|
195
|
+
return content.slice(openEnd + 1, closeAt);
|
|
196
|
+
}
|
|
197
|
+
export function readViewBox(el) {
|
|
198
|
+
const raw = el?.attrs['viewbox'];
|
|
199
|
+
if (!raw)
|
|
200
|
+
return null;
|
|
201
|
+
const parts = raw.trim().split(/[\s,]+/).map(Number);
|
|
202
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n)))
|
|
203
|
+
return null;
|
|
204
|
+
const [x, y, w, h] = parts;
|
|
205
|
+
if (w <= 0 || h <= 0)
|
|
206
|
+
return null;
|
|
207
|
+
return { x, y, w, h };
|
|
208
|
+
}
|
|
209
|
+
/** Every `#id` this file points at, from `href`/`xlink:href` and from `url(#…)`. */
|
|
210
|
+
export function referencedIds(content, elements) {
|
|
211
|
+
const ids = new Set();
|
|
212
|
+
for (const el of elements) {
|
|
213
|
+
for (const key of ['href', 'xlink:href']) {
|
|
214
|
+
const v = el.attrs[key];
|
|
215
|
+
if (v && v.startsWith('#'))
|
|
216
|
+
ids.add(v.slice(1));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const urlRe = /url\(\s*['"]?#([^)'"\s]+)/g;
|
|
220
|
+
let m;
|
|
221
|
+
while ((m = urlRe.exec(content)) !== null)
|
|
222
|
+
ids.add(m[1]);
|
|
223
|
+
// `begin="other.end"` and `<set attributeName=…>` name elements too.
|
|
224
|
+
const beginRe = /\b(?:begin|end)\s*=\s*"([A-Za-z_][\w.:-]*)\./g;
|
|
225
|
+
while ((m = beginRe.exec(content)) !== null)
|
|
226
|
+
ids.add(m[1]);
|
|
227
|
+
return ids;
|
|
228
|
+
}
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// Rule family — structure
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
function ruleMissingXmlns(elements, file) {
|
|
233
|
+
const root = elements.find((el) => el.name === 'svg' && !el.closing);
|
|
234
|
+
if (!root)
|
|
235
|
+
return [];
|
|
236
|
+
if (root.attrs['xmlns'])
|
|
237
|
+
return [];
|
|
238
|
+
return [
|
|
239
|
+
{
|
|
240
|
+
rule: 'svg-structure-missing-xmlns',
|
|
241
|
+
file,
|
|
242
|
+
line: root.line,
|
|
243
|
+
snippet: 'root <svg> has no xmlns — the file does not render as a standalone document or through <img>',
|
|
244
|
+
severity: 'error',
|
|
245
|
+
},
|
|
246
|
+
];
|
|
247
|
+
}
|
|
248
|
+
/** Is this element inside a `<defs>`, `<symbol>`, `<clipPath>`, `<mask>` or `<marker>`? */
|
|
249
|
+
function buildContainerDepth(elements) {
|
|
250
|
+
const stack = [];
|
|
251
|
+
const out = new Map();
|
|
252
|
+
elements.forEach((el, idx) => {
|
|
253
|
+
if (el.closing) {
|
|
254
|
+
if (stack[stack.length - 1] === el.name)
|
|
255
|
+
stack.pop();
|
|
256
|
+
out.set(idx, [...stack]);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
out.set(idx, [...stack]);
|
|
260
|
+
if (!el.selfClosing)
|
|
261
|
+
stack.push(el.name);
|
|
262
|
+
});
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
const NON_RENDERED_CONTAINERS = new Set(['defs', 'symbol', 'clippath', 'mask', 'marker', 'pattern']);
|
|
266
|
+
function ruleOutsideViewBox(elements, file) {
|
|
267
|
+
const root = elements.find((el) => el.name === 'svg' && !el.closing);
|
|
268
|
+
const vb = readViewBox(root);
|
|
269
|
+
if (!vb)
|
|
270
|
+
return [];
|
|
271
|
+
const ancestry = buildContainerDepth(elements);
|
|
272
|
+
const findings = [];
|
|
273
|
+
elements.forEach((el, idx) => {
|
|
274
|
+
if (el.closing)
|
|
275
|
+
return;
|
|
276
|
+
const chain = ancestry.get(idx) ?? [];
|
|
277
|
+
if (chain.some((c) => NON_RENDERED_CONTAINERS.has(c)))
|
|
278
|
+
return;
|
|
279
|
+
// A transform anywhere up the chain moves the box; staticBox already
|
|
280
|
+
// refuses an element with its own, and this refuses an inherited one.
|
|
281
|
+
if (chain.length && hasTransformedAncestor(elements, idx))
|
|
282
|
+
return;
|
|
283
|
+
const box = staticBox(el);
|
|
284
|
+
if (!box)
|
|
285
|
+
return;
|
|
286
|
+
const outside = box.maxX <= vb.x || box.minX >= vb.x + vb.w || box.maxY <= vb.y || box.minY >= vb.y + vb.h;
|
|
287
|
+
if (!outside)
|
|
288
|
+
return;
|
|
289
|
+
findings.push({
|
|
290
|
+
rule: 'svg-structure-outside-viewbox',
|
|
291
|
+
file,
|
|
292
|
+
line: el.line,
|
|
293
|
+
snippet: `<${el.name}> box (${box.minX},${box.minY})-(${box.maxX},${box.maxY}) lies entirely outside ` +
|
|
294
|
+
`viewBox ${vb.x} ${vb.y} ${vb.w} ${vb.h} — it will never paint`,
|
|
295
|
+
severity: 'error',
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
return findings;
|
|
299
|
+
}
|
|
300
|
+
function hasTransformedAncestor(elements, index) {
|
|
301
|
+
const stack = [];
|
|
302
|
+
for (let i = 0; i < index; i++) {
|
|
303
|
+
const el = elements[i];
|
|
304
|
+
if (el.closing) {
|
|
305
|
+
if (stack[stack.length - 1]?.name === el.name)
|
|
306
|
+
stack.pop();
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (!el.selfClosing)
|
|
310
|
+
stack.push(el);
|
|
311
|
+
}
|
|
312
|
+
return stack.some((el) => Boolean(el.attrs['transform']));
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Path-data grammar check.
|
|
316
|
+
*
|
|
317
|
+
* Reports only data that no conformant parser could accept — a command letter
|
|
318
|
+
* followed by the wrong number of coordinates, or a stray token. A path whose
|
|
319
|
+
* numbers are odd but well-formed is a design question, not a parse error.
|
|
320
|
+
*/
|
|
321
|
+
function ruleUnparseablePath(elements, file) {
|
|
322
|
+
const findings = [];
|
|
323
|
+
for (const el of elements) {
|
|
324
|
+
if (el.closing)
|
|
325
|
+
continue;
|
|
326
|
+
const d = el.attrs['d'];
|
|
327
|
+
if (d === undefined)
|
|
328
|
+
continue;
|
|
329
|
+
const err = pathDataError(d);
|
|
330
|
+
if (err) {
|
|
331
|
+
findings.push({
|
|
332
|
+
rule: 'svg-structure-unparseable-path',
|
|
333
|
+
file,
|
|
334
|
+
line: el.line,
|
|
335
|
+
snippet: `<${el.name} d="…"> ${err}`,
|
|
336
|
+
severity: 'error',
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return findings;
|
|
341
|
+
}
|
|
342
|
+
function ruleUnusedDefs(content, elements, file) {
|
|
343
|
+
const used = referencedIds(content, elements);
|
|
344
|
+
const ancestry = buildContainerDepth(elements);
|
|
345
|
+
const findings = [];
|
|
346
|
+
elements.forEach((el, idx) => {
|
|
347
|
+
if (el.closing)
|
|
348
|
+
return;
|
|
349
|
+
const id = el.attrs['id'];
|
|
350
|
+
if (!id)
|
|
351
|
+
return;
|
|
352
|
+
const chain = ancestry.get(idx) ?? [];
|
|
353
|
+
if (!chain.includes('defs'))
|
|
354
|
+
return;
|
|
355
|
+
// A `<symbol>` is a sprite-sheet entry addressed from a HOST page, so
|
|
356
|
+
// the file cannot see its own reference. Exempt by construction —
|
|
357
|
+
// flagging it would red every sprite sheet in existence.
|
|
358
|
+
if (el.name === 'symbol')
|
|
359
|
+
return;
|
|
360
|
+
if (used.has(id))
|
|
361
|
+
return;
|
|
362
|
+
findings.push({
|
|
363
|
+
rule: 'svg-structure-unused-defs',
|
|
364
|
+
file,
|
|
365
|
+
line: el.line,
|
|
366
|
+
snippet: `<${el.name} id="${id}"> sits in <defs> and nothing in this file references it`,
|
|
367
|
+
severity: 'warning',
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
return findings;
|
|
371
|
+
}
|
|
372
|
+
function ruleDanglingReference(content, elements, file) {
|
|
373
|
+
const defined = new Set();
|
|
374
|
+
for (const el of elements)
|
|
375
|
+
if (!el.closing && el.attrs['id'])
|
|
376
|
+
defined.add(el.attrs['id']);
|
|
377
|
+
const findings = [];
|
|
378
|
+
const lineOf = lineIndexer(content);
|
|
379
|
+
for (const el of elements) {
|
|
380
|
+
if (el.closing)
|
|
381
|
+
continue;
|
|
382
|
+
for (const key of ['href', 'xlink:href']) {
|
|
383
|
+
const v = el.attrs[key];
|
|
384
|
+
if (!v || !v.startsWith('#'))
|
|
385
|
+
continue;
|
|
386
|
+
const id = v.slice(1);
|
|
387
|
+
if (defined.has(id))
|
|
388
|
+
continue;
|
|
389
|
+
findings.push({
|
|
390
|
+
rule: 'svg-structure-dangling-reference',
|
|
391
|
+
file,
|
|
392
|
+
line: el.line,
|
|
393
|
+
snippet: `<${el.name} ${key}="${v}"> points at an id this file does not define`,
|
|
394
|
+
severity: 'error',
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const urlRe = /url\(\s*['"]?#([^)'"\s]+)/g;
|
|
399
|
+
let m;
|
|
400
|
+
while ((m = urlRe.exec(content)) !== null) {
|
|
401
|
+
const id = m[1];
|
|
402
|
+
if (defined.has(id))
|
|
403
|
+
continue;
|
|
404
|
+
findings.push({
|
|
405
|
+
rule: 'svg-structure-dangling-reference',
|
|
406
|
+
file,
|
|
407
|
+
line: lineOf(m.index),
|
|
408
|
+
snippet: `url(#${id}) points at an id this file does not define`,
|
|
409
|
+
severity: 'error',
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
return dedupe(findings);
|
|
413
|
+
}
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
// Rule family — safety, graded per delivery mode (step 1.5)
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
/**
|
|
418
|
+
* What a delivery mode can execute.
|
|
419
|
+
*
|
|
420
|
+
* `inline` and `document` run script in an origin that matters, so active
|
|
421
|
+
* content is an ERROR there. `img` and `css-bg` run nothing and load nothing,
|
|
422
|
+
* so the same file is `inert` — reported, never called clean, because the same
|
|
423
|
+
* bytes can be inlined tomorrow with no edit to the file (decision E12).
|
|
424
|
+
*/
|
|
425
|
+
export function safetySeverity(mode) {
|
|
426
|
+
return mode === 'img' || mode === 'css-bg' ? 'inert' : 'error';
|
|
427
|
+
}
|
|
428
|
+
// An UNQUOTED value counts. Finding 1 of the fifth completion review: this
|
|
429
|
+
// fallback exists because the attribute parser only sees quoted values, and it
|
|
430
|
+
// required a quote itself — so `onload=alert(1)`, which every browser runs,
|
|
431
|
+
// produced zero findings while `onload="alert(1)"` produced an error. The
|
|
432
|
+
// safety family missing the unquoted spelling is the one miss that matters,
|
|
433
|
+
// because an attacker writes whichever spelling is not checked.
|
|
434
|
+
const ACTIVE_ATTR_RE = /\son[a-z]+\s*=\s*(["']|[^\s>"'])/i;
|
|
435
|
+
/** Elements that run or embed foreign content when the graphic is inlined. */
|
|
436
|
+
const ACTIVE_ELEMENTS = new Set(['script', 'foreignobject', 'iframe', 'embed', 'object']);
|
|
437
|
+
function ruleActiveContent(content, elements, file, mode) {
|
|
438
|
+
const severity = safetySeverity(mode);
|
|
439
|
+
const findings = [];
|
|
440
|
+
const lineOf = lineIndexer(content);
|
|
441
|
+
const prefixes = svgPrefixes(content);
|
|
442
|
+
for (const el of elements) {
|
|
443
|
+
if (el.closing)
|
|
444
|
+
continue;
|
|
445
|
+
const local = localName(el.name, prefixes);
|
|
446
|
+
// SMIL that writes an active URL into an href is the same href, later.
|
|
447
|
+
const smil = local === 'set' || local === 'animate' ? rawAttrs(el.raw) : null;
|
|
448
|
+
const animatesHref = smil !== null && /^(xlink:)?href$/i.test(resolvedUrl(smil['attributename'] ?? ''));
|
|
449
|
+
if (animatesHref &&
|
|
450
|
+
['to', 'from', 'by', 'values'].some((k) => (smil[k] ?? '').split(';').some(isActiveUrl))) {
|
|
451
|
+
findings.push({
|
|
452
|
+
rule: 'svg-safety-active-content',
|
|
453
|
+
file,
|
|
454
|
+
line: el.line,
|
|
455
|
+
snippet: `<${el.name} attributeName="href"> animates in an active URL; delivery mode "${mode}" grades it ${severity}`,
|
|
456
|
+
severity,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
if (ACTIVE_ELEMENTS.has(local)) {
|
|
460
|
+
findings.push({
|
|
461
|
+
rule: 'svg-safety-active-content',
|
|
462
|
+
file,
|
|
463
|
+
line: el.line,
|
|
464
|
+
snippet: `<${el.name}> is active content; delivery mode "${mode}" grades it ${severity}`,
|
|
465
|
+
severity,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
for (const [key, value] of Object.entries(el.attrs)) {
|
|
469
|
+
if (/^on[a-z]+$/.test(key)) {
|
|
470
|
+
findings.push({
|
|
471
|
+
rule: 'svg-safety-active-content',
|
|
472
|
+
file,
|
|
473
|
+
line: el.line,
|
|
474
|
+
snippet: `<${el.name} ${key}="…"> is an event handler; delivery mode "${mode}" grades it ${severity}`,
|
|
475
|
+
severity,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
if (/^javascript:/i.test(resolvedUrl(value)) || (URL_ATTRS.has(key) && isActiveUrl(value))) {
|
|
479
|
+
findings.push({
|
|
480
|
+
rule: 'svg-safety-active-content',
|
|
481
|
+
file,
|
|
482
|
+
line: el.line,
|
|
483
|
+
snippet: `<${el.name} ${key}="${resolvedUrl(value).slice(0, 16)}…"> is an active URL; delivery mode "${mode}" grades it ${severity}`,
|
|
484
|
+
severity,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
// A handler written on an element the scanner did not turn into attributes
|
|
490
|
+
// (an unquoted value, say) still has to be seen.
|
|
491
|
+
if (!findings.length && ACTIVE_ATTR_RE.test(content)) {
|
|
492
|
+
const idx = content.search(ACTIVE_ATTR_RE);
|
|
493
|
+
findings.push({
|
|
494
|
+
rule: 'svg-safety-active-content',
|
|
495
|
+
file,
|
|
496
|
+
line: lineOf(idx),
|
|
497
|
+
snippet: `an on* event handler attribute is present; delivery mode "${mode}" grades it ${severity}`,
|
|
498
|
+
severity,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
return dedupe(findings);
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* An internal DTD subset or an entity declaration.
|
|
505
|
+
*
|
|
506
|
+
* A BARE public DOCTYPE is clean and deliberately so — exported assets carry
|
|
507
|
+
* one routinely and it is harmless. What is not harmless is the bracketed
|
|
508
|
+
* subset, which is where billion-laughs and external-entity payloads live.
|
|
509
|
+
*/
|
|
510
|
+
function ruleInternalSubset(content, file) {
|
|
511
|
+
const lineOf = lineIndexer(content);
|
|
512
|
+
const findings = [];
|
|
513
|
+
const entity = /<!ENTITY\b/i.exec(content);
|
|
514
|
+
if (entity) {
|
|
515
|
+
findings.push({
|
|
516
|
+
rule: 'svg-safety-internal-subset',
|
|
517
|
+
file,
|
|
518
|
+
line: lineOf(entity.index),
|
|
519
|
+
snippet: '<!ENTITY declaration — an entity-expansion surface; a bare public DOCTYPE is clean, this is not',
|
|
520
|
+
severity: 'error',
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
const doctype = /<!DOCTYPE[^>[]*\[/i.exec(content);
|
|
524
|
+
if (doctype) {
|
|
525
|
+
findings.push({
|
|
526
|
+
rule: 'svg-safety-internal-subset',
|
|
527
|
+
file,
|
|
528
|
+
line: lineOf(doctype.index),
|
|
529
|
+
snippet: 'DOCTYPE carries an internal subset "[…]" — strip it; a bare public DOCTYPE is clean',
|
|
530
|
+
severity: 'error',
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
return dedupe(findings);
|
|
534
|
+
}
|
|
535
|
+
const EXTERNAL_URL_RE = /^(?:https?:|ftp:|\/\/)/i;
|
|
536
|
+
function ruleExternalReference(content, elements, file, mode) {
|
|
537
|
+
const severity = safetySeverity(mode);
|
|
538
|
+
const findings = [];
|
|
539
|
+
const lineOf = lineIndexer(content);
|
|
540
|
+
for (const el of elements) {
|
|
541
|
+
if (el.closing)
|
|
542
|
+
continue;
|
|
543
|
+
for (const key of ['href', 'xlink:href', 'src']) {
|
|
544
|
+
const v = el.attrs[key];
|
|
545
|
+
if (v && EXTERNAL_URL_RE.test(resolvedUrl(v))) {
|
|
546
|
+
findings.push({
|
|
547
|
+
rule: 'svg-safety-external-reference',
|
|
548
|
+
file,
|
|
549
|
+
line: el.line,
|
|
550
|
+
snippet: `<${el.name} ${key}="${v.slice(0, 60)}"> loads from outside the file; mode "${mode}" grades it ${severity}`,
|
|
551
|
+
severity,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
const urlRe = /url\(\s*['"]?((?:https?:|ftp:|\/\/)[^)'"\s]+)/gi;
|
|
557
|
+
let m;
|
|
558
|
+
while ((m = urlRe.exec(content)) !== null) {
|
|
559
|
+
findings.push({
|
|
560
|
+
rule: 'svg-safety-external-reference',
|
|
561
|
+
file,
|
|
562
|
+
line: lineOf(m.index),
|
|
563
|
+
snippet: `url(${(m[1] ?? '').slice(0, 60)}) loads from outside the file; mode "${mode}" grades it ${severity}`,
|
|
564
|
+
severity,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
// `@import "https://…"` names its target without `url()`. The target is
|
|
568
|
+
// resolved before the scheme test — XML references and CSS escapes both
|
|
569
|
+
// reach the stylesheet loader decoded.
|
|
570
|
+
const importRe = /@import\s+(?:url\(\s*)?(['"])([^'"]*)\1/gi;
|
|
571
|
+
while ((m = importRe.exec(content)) !== null) {
|
|
572
|
+
const target = resolvedUrl(cssUnescape(resolvedUrl(m[2] ?? '')));
|
|
573
|
+
if (!EXTERNAL_URL_RE.test(target))
|
|
574
|
+
continue;
|
|
575
|
+
findings.push({
|
|
576
|
+
rule: 'svg-safety-external-reference',
|
|
577
|
+
file,
|
|
578
|
+
line: lineOf(m.index),
|
|
579
|
+
snippet: `@import "${target.slice(0, 60)}" loads from outside the file; mode "${mode}" grades it ${severity}`,
|
|
580
|
+
severity,
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
return dedupe(findings);
|
|
584
|
+
}
|
|
585
|
+
// ---------------------------------------------------------------------------
|
|
586
|
+
// Rule family — vector honesty (defect D3)
|
|
587
|
+
// ---------------------------------------------------------------------------
|
|
588
|
+
/** Geometry elements that ARE vector drawing. `rect`/`circle` count (D3's false red). */
|
|
589
|
+
export const VECTOR_GEOMETRY = new Set([
|
|
590
|
+
'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'text', 'textpath', 'use',
|
|
591
|
+
]);
|
|
592
|
+
/**
|
|
593
|
+
* Replaces the presence-of-`<path>` check (`logo-generation/SKILL.md:53-55`),
|
|
594
|
+
* which was false-green and false-red at once: a raster beside one dummy path
|
|
595
|
+
* passed, and a mark built from `rect`/`circle` — the very thing that file
|
|
596
|
+
* recommends — failed.
|
|
597
|
+
*/
|
|
598
|
+
export function vectorVerdict(elements) {
|
|
599
|
+
let raster = 0;
|
|
600
|
+
let vector = 0;
|
|
601
|
+
for (const el of elements) {
|
|
602
|
+
if (el.closing)
|
|
603
|
+
continue;
|
|
604
|
+
if (el.name === 'image') {
|
|
605
|
+
// EVERY `<image>` counts, whatever it points at. Finding 7 of the
|
|
606
|
+
// fifth completion review: both arms of the `if` added 1, so the
|
|
607
|
+
// test and the regex behind it decided nothing — a dead constant
|
|
608
|
+
// and a dead branch dressed as a discrimination, which is the
|
|
609
|
+
// class this change has now found five times under other names.
|
|
610
|
+
// The comment on the else arm was the correct rule all along: an
|
|
611
|
+
// `<image>` of unknown type is still not vector geometry, and an
|
|
612
|
+
// href this scanner cannot classify is exactly the case where
|
|
613
|
+
// guessing "vector" would be the unsafe answer.
|
|
614
|
+
raster += 1;
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
if (el.name === 'foreignobject') {
|
|
618
|
+
raster += 1;
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
if (VECTOR_GEOMETRY.has(el.name))
|
|
622
|
+
vector += 1;
|
|
623
|
+
}
|
|
624
|
+
if (raster > 0) {
|
|
625
|
+
return {
|
|
626
|
+
trueVector: false,
|
|
627
|
+
rasterElements: raster,
|
|
628
|
+
vectorElements: vector,
|
|
629
|
+
reason: `carries ${raster} raster/foreignObject element(s) — not a true vector whatever else it contains`,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
return {
|
|
633
|
+
trueVector: vector > 0,
|
|
634
|
+
rasterElements: 0,
|
|
635
|
+
vectorElements: vector,
|
|
636
|
+
reason: vector > 0
|
|
637
|
+
? `${vector} vector geometry element(s), no embedded raster`
|
|
638
|
+
: 'no painted geometry at all',
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
function ruleEmbeddedRaster(elements, file) {
|
|
642
|
+
const verdict = vectorVerdict(elements);
|
|
643
|
+
if (verdict.rasterElements === 0)
|
|
644
|
+
return [];
|
|
645
|
+
const first = elements.find((el) => !el.closing && (el.name === 'image' || el.name === 'foreignobject'));
|
|
646
|
+
return [
|
|
647
|
+
{
|
|
648
|
+
rule: 'svg-vector-embedded-raster',
|
|
649
|
+
file,
|
|
650
|
+
line: first?.line ?? 1,
|
|
651
|
+
snippet: `${verdict.reason}; ${verdict.vectorElements} vector element(s) beside it does not make it one`,
|
|
652
|
+
severity: 'error',
|
|
653
|
+
},
|
|
654
|
+
];
|
|
655
|
+
}
|
|
656
|
+
// ---------------------------------------------------------------------------
|
|
657
|
+
// Rule family — accessibility
|
|
658
|
+
// ---------------------------------------------------------------------------
|
|
659
|
+
/**
|
|
660
|
+
* Ids that collide the moment two graphics are inlined on one page (R7: two
|
|
661
|
+
* inline graphics both using `id="title"` made the accessibility tree name
|
|
662
|
+
* both after the first).
|
|
663
|
+
*/
|
|
664
|
+
export const GENERIC_IDS = new Set([
|
|
665
|
+
'title', 'desc', 'a', 'b', 'c', 'g', 'gradient', 'grad', 'mask', 'clip', 'clippath',
|
|
666
|
+
'shadow', 'icon', 'path', 'path1', 'layer1', 'layer_1', 'svg', 'defs', 'filter', 'glow',
|
|
667
|
+
]);
|
|
668
|
+
function ruleGenericId(elements, file) {
|
|
669
|
+
const findings = [];
|
|
670
|
+
for (const el of elements) {
|
|
671
|
+
if (el.closing)
|
|
672
|
+
continue;
|
|
673
|
+
const id = el.attrs['id'];
|
|
674
|
+
if (!id)
|
|
675
|
+
continue;
|
|
676
|
+
if (!GENERIC_IDS.has(id.toLowerCase()))
|
|
677
|
+
continue;
|
|
678
|
+
findings.push({
|
|
679
|
+
rule: 'svg-a11y-generic-id',
|
|
680
|
+
file,
|
|
681
|
+
line: el.line,
|
|
682
|
+
snippet: `id="${id}" collides with any second graphic inlined on the same page — namespace it`,
|
|
683
|
+
severity: 'warning',
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
return findings;
|
|
687
|
+
}
|
|
688
|
+
/** Is `elements[index]` a direct child of the root `<svg>`? */
|
|
689
|
+
/**
|
|
690
|
+
* Is the element at `index` a direct child of the root graphic?
|
|
691
|
+
*
|
|
692
|
+
* Counts open/close depth over the flat list. Step 3.1 of
|
|
693
|
+
* `road-to-svg-review-followups` moved the AUTHORITY for this question onto
|
|
694
|
+
* the parse tree ({@link directChildrenOfRoot}); this stays as the flat
|
|
695
|
+
* reading it is cross-checked against, because a depth counter and a tree
|
|
696
|
+
* agreeing on every fixture is evidence the tree is right, while replacing one
|
|
697
|
+
* with the other is evidence of nothing.
|
|
698
|
+
*/
|
|
699
|
+
function isDirectChildOfRoot(elements, index) {
|
|
700
|
+
let depth = 0;
|
|
701
|
+
for (let i = 0; i < index; i++) {
|
|
702
|
+
const el = elements[i];
|
|
703
|
+
if (el.closing)
|
|
704
|
+
depth -= 1;
|
|
705
|
+
else if (!el.selfClosing)
|
|
706
|
+
depth += 1;
|
|
707
|
+
}
|
|
708
|
+
return depth === 1;
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* The root graphic's direct element children, from the parse tree.
|
|
712
|
+
*
|
|
713
|
+
* Parentage decided by ancestry rather than by a depth count: the two agree on
|
|
714
|
+
* a well-formed file and the tree is the one that stays right on a file that
|
|
715
|
+
* is not, because a stray closing tag moves a depth counter and does not move
|
|
716
|
+
* an ancestor chain.
|
|
717
|
+
*/
|
|
718
|
+
export function directChildrenOfRoot(doc) {
|
|
719
|
+
const root = doc.elements.find((el) => el.name === 'svg');
|
|
720
|
+
if (root === undefined)
|
|
721
|
+
return [];
|
|
722
|
+
return root.children.filter((n) => n.kind === 'element');
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Well-formedness, reported rather than assumed.
|
|
726
|
+
*
|
|
727
|
+
* Step 3.1. `<g><path></g></path>` carries both tags, both spelled correctly,
|
|
728
|
+
* and is not a tree — which is precisely what a flat element list cannot see
|
|
729
|
+
* and what a pattern over the raw text cannot decide. A file that does not
|
|
730
|
+
* parse is a FINDING, never a skipped file: a skip reads as clean in every
|
|
731
|
+
* count that matters.
|
|
732
|
+
*
|
|
733
|
+
* Capped at the first five errors. One mistake inside a deep document can
|
|
734
|
+
* produce an error per remaining tag, and a wall of them buries the first,
|
|
735
|
+
* which is the only one a reader can act on.
|
|
736
|
+
*/
|
|
737
|
+
/**
|
|
738
|
+
* A gradient whose stops are all the same colour is a flat fill in costume.
|
|
739
|
+
*
|
|
740
|
+
* Step 3.1 of `road-to-svg-from-a-live-session`. A rejected 195 KB trace
|
|
741
|
+
* carried 355 gradient definitions, nine of them with identical stops, and the
|
|
742
|
+
* static witness reported one finding: a missing accessible name. Two external
|
|
743
|
+
* reviews counted the gradients by hand. This is cheap to decide from the
|
|
744
|
+
* bytes and nothing was deciding it.
|
|
745
|
+
*
|
|
746
|
+
* WARNING, not an error. A degenerate gradient renders correctly; it costs
|
|
747
|
+
* bytes and makes a file harder to edit, which is a reading rather than a
|
|
748
|
+
* defect — and the cost family above it is deliberately ungated for the same
|
|
749
|
+
* reason.
|
|
750
|
+
*
|
|
751
|
+
* Colour comparison is textual after whitespace and case normalisation, so
|
|
752
|
+
* `#FFF` and `#ffffff` are NOT reported as identical. That is the conservative
|
|
753
|
+
* direction: a false negative costs a reading, a false positive costs trust in
|
|
754
|
+
* every other finding beside it.
|
|
755
|
+
*/
|
|
756
|
+
function ruleDegenerateGradient(elements, file) {
|
|
757
|
+
const findings = [];
|
|
758
|
+
const open = [];
|
|
759
|
+
for (const el of elements) {
|
|
760
|
+
if (el.name === 'lineargradient' || el.name === 'radialgradient') {
|
|
761
|
+
if (el.closing) {
|
|
762
|
+
const g = open.pop();
|
|
763
|
+
if (!g || g.stops.length < 2)
|
|
764
|
+
continue;
|
|
765
|
+
const first = g.stops[0];
|
|
766
|
+
const same = g.stops.every((s) => s.color === first.color && s.opacity === first.opacity);
|
|
767
|
+
if (!same)
|
|
768
|
+
continue;
|
|
769
|
+
findings.push({
|
|
770
|
+
rule: 'svg-structure-degenerate-gradient',
|
|
771
|
+
file,
|
|
772
|
+
line: g.el.line,
|
|
773
|
+
snippet: `<${g.el.name} id="${g.el.attrs['id'] ?? ''}"> has ${String(g.stops.length)} stops, all ` +
|
|
774
|
+
`${first.color || '(inherited)'} at opacity ${first.opacity} — a flat fill in a gradient's ` +
|
|
775
|
+
'clothes, which costs bytes and makes the file harder to edit',
|
|
776
|
+
severity: 'warning',
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
else if (!el.selfClosing) {
|
|
780
|
+
open.push({ el, stops: [] });
|
|
781
|
+
}
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
if (el.name === 'stop' && !el.closing) {
|
|
785
|
+
const host = open[open.length - 1];
|
|
786
|
+
if (!host)
|
|
787
|
+
continue;
|
|
788
|
+
const style = el.attrs['style'] ?? '';
|
|
789
|
+
const fromStyle = /stop-color\s*:\s*([^;]+)/i.exec(style)?.[1];
|
|
790
|
+
const opacityFromStyle = /stop-opacity\s*:\s*([^;]+)/i.exec(style)?.[1];
|
|
791
|
+
host.stops.push({
|
|
792
|
+
color: (fromStyle ?? el.attrs['stop-color'] ?? '').trim().toLowerCase(),
|
|
793
|
+
opacity: (opacityFromStyle ?? el.attrs['stop-opacity'] ?? '1').trim(),
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
return findings;
|
|
798
|
+
}
|
|
799
|
+
function ruleWellFormed(doc, file) {
|
|
800
|
+
if (doc.errors.length === 0)
|
|
801
|
+
return [];
|
|
802
|
+
const shown = doc.errors.slice(0, 5);
|
|
803
|
+
const more = doc.errors.length - shown.length;
|
|
804
|
+
return shown.map((e, i) => ({
|
|
805
|
+
rule: 'svg-structure-not-well-formed',
|
|
806
|
+
file,
|
|
807
|
+
line: e.line,
|
|
808
|
+
snippet: `${e.message} (column ${String(e.column)})` +
|
|
809
|
+
(i === shown.length - 1 && more > 0 ? ` — and ${String(more)} further error(s)` : ''),
|
|
810
|
+
severity: 'error',
|
|
811
|
+
}));
|
|
812
|
+
}
|
|
813
|
+
function rootNameState(content, doc, elements) {
|
|
814
|
+
const rootIdx = elements.findIndex((el) => el.name === 'svg' && !el.closing);
|
|
815
|
+
const root = rootIdx === -1 ? undefined : elements[rootIdx];
|
|
816
|
+
// A DIRECT child of the root only. The first `<title>` ANYWHERE was wrong
|
|
817
|
+
// in both directions: a chart's nested series title read as the graphic's
|
|
818
|
+
// name, producing a false `svg-a11y-double-name` beside an `aria-label`,
|
|
819
|
+
// and hiding a genuinely unnamed graphic behind a descendant's title.
|
|
820
|
+
const titleIdx = elements.findIndex((el, i) => el.name === 'title' && !el.closing && rootIdx !== -1 && isDirectChildOfRoot(elements, i));
|
|
821
|
+
// Step 3.1: the PARSE TREE decides parentage, and the depth count above is
|
|
822
|
+
// kept as the reading it is checked against. The two agree on every
|
|
823
|
+
// well-formed file; where they disagree the file is not well-formed, which
|
|
824
|
+
// `svg-structure-not-well-formed` already reports, and the tree is the one
|
|
825
|
+
// that is still right — a stray closing tag moves a depth counter and does
|
|
826
|
+
// not move an ancestor chain.
|
|
827
|
+
const titleIsDirectChild = directChildrenOfRoot(doc).some((n) => n.name === 'title');
|
|
828
|
+
const hasTitle = titleIsDirectChild && titleIdx !== -1 && textOf(content, elements, titleIdx).trim().length > 0;
|
|
829
|
+
// `aria-labelledby="x"` + `<title id="x">` is the CANONICAL pattern, not a
|
|
830
|
+
// collision: the attribute POINTS AT the title rather than competing with
|
|
831
|
+
// it. Counting the pair as two names reds the one shape the accessibility
|
|
832
|
+
// guidance actually asks for, which is the false red that gets a linter
|
|
833
|
+
// switched off (Risk 1).
|
|
834
|
+
const titleIds = new Set(elements
|
|
835
|
+
.filter((el) => el.name === 'title' && !el.closing && el.attrs['id'])
|
|
836
|
+
.map((el) => el.attrs['id']));
|
|
837
|
+
const labelledBy = (root?.attrs['aria-labelledby'] ?? '').trim();
|
|
838
|
+
const labelledByTargets = labelledBy.split(/\s+/).filter(Boolean);
|
|
839
|
+
const labelledByOwnTitle = labelledByTargets.length > 0 && labelledByTargets.every((id) => titleIds.has(id));
|
|
840
|
+
return {
|
|
841
|
+
root,
|
|
842
|
+
hidden: (root?.attrs['aria-hidden'] ?? '').toLowerCase() === 'true',
|
|
843
|
+
hasTitle,
|
|
844
|
+
hasLabel: Boolean(root?.attrs['aria-label']?.trim()),
|
|
845
|
+
hasLabelledBy: labelledBy.length > 0,
|
|
846
|
+
labelledByOwnTitle,
|
|
847
|
+
hasRoleImg: (root?.attrs['role'] ?? '').toLowerCase() === 'img',
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
function ruleAccessibleName(s, file) {
|
|
851
|
+
if (!s.root)
|
|
852
|
+
return [];
|
|
853
|
+
// A graphic declared decorative is correct with no name — that is the
|
|
854
|
+
// other half of the pattern, not a gap in it.
|
|
855
|
+
if (s.hidden)
|
|
856
|
+
return [];
|
|
857
|
+
if (s.hasTitle || s.hasLabel || s.hasLabelledBy)
|
|
858
|
+
return [];
|
|
859
|
+
return [
|
|
860
|
+
{
|
|
861
|
+
rule: 'svg-a11y-no-accessible-name',
|
|
862
|
+
file,
|
|
863
|
+
line: s.root.line,
|
|
864
|
+
snippet: 'graphic has no accessible name and is not aria-hidden="true" — add role="img" with a <title>, ' +
|
|
865
|
+
'or declare it decorative',
|
|
866
|
+
severity: 'error',
|
|
867
|
+
},
|
|
868
|
+
];
|
|
869
|
+
}
|
|
870
|
+
function ruleDoubleName(s, file) {
|
|
871
|
+
if (!s.root)
|
|
872
|
+
return [];
|
|
873
|
+
// A labelledby that resolves to this graphic's own <title> is the SAME
|
|
874
|
+
// name reached through a reference, so it is not counted twice.
|
|
875
|
+
const labelledByIsSeparate = s.hasLabelledBy && !s.labelledByOwnTitle;
|
|
876
|
+
const names = [s.hasLabel, labelledByIsSeparate, s.hasTitle].filter(Boolean).length;
|
|
877
|
+
if (names < 2)
|
|
878
|
+
return [];
|
|
879
|
+
return [
|
|
880
|
+
{
|
|
881
|
+
rule: 'svg-a11y-double-name',
|
|
882
|
+
file,
|
|
883
|
+
line: s.root.line,
|
|
884
|
+
snippet: 'graphic declares more than one accessible name (aria-label / aria-labelledby / <title>) — ' +
|
|
885
|
+
'only one wins and which one is renderer-dependent',
|
|
886
|
+
severity: 'error',
|
|
887
|
+
},
|
|
888
|
+
];
|
|
889
|
+
}
|
|
890
|
+
// ---------------------------------------------------------------------------
|
|
891
|
+
// Rule family — motion. Every rule binds per element or per selector (R9).
|
|
892
|
+
// ---------------------------------------------------------------------------
|
|
893
|
+
function ruleKeySplinesRange(elements, file) {
|
|
894
|
+
const findings = [];
|
|
895
|
+
for (const el of elements) {
|
|
896
|
+
if (el.closing)
|
|
897
|
+
continue;
|
|
898
|
+
const raw = el.attrs['keysplines'];
|
|
899
|
+
if (!raw)
|
|
900
|
+
continue;
|
|
901
|
+
const nums = raw.split(/[;\s,]+/).filter(Boolean).map(Number);
|
|
902
|
+
const bad = nums.filter((n) => Number.isFinite(n) && (n < 0 || n > 1));
|
|
903
|
+
if (!bad.length)
|
|
904
|
+
continue;
|
|
905
|
+
findings.push({
|
|
906
|
+
rule: 'svg-motion-keysplines-range',
|
|
907
|
+
file,
|
|
908
|
+
line: el.line,
|
|
909
|
+
snippet: `<${el.name} keySplines="${raw}"> holds ${bad.join(', ')} outside [0,1] — SMIL rejects the ` +
|
|
910
|
+
'whole animation rather than clamping it, so the element never moves',
|
|
911
|
+
severity: 'error',
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
return findings;
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* `transform-origin` without `transform-box`.
|
|
918
|
+
*
|
|
919
|
+
* PER ELEMENT and PER SELECTOR, which is the R9 correction: a file-scoped
|
|
920
|
+
* "does `transform-box` appear anywhere" test passes a file whose wrong
|
|
921
|
+
* element sits beside a correct twin, and the fixture plants exactly that.
|
|
922
|
+
*/
|
|
923
|
+
function ruleTransformBox(content, elements, file) {
|
|
924
|
+
const findings = [];
|
|
925
|
+
for (const el of elements) {
|
|
926
|
+
if (el.closing)
|
|
927
|
+
continue;
|
|
928
|
+
const style = el.attrs['style'] ?? '';
|
|
929
|
+
const decls = declarationsOf(style);
|
|
930
|
+
const origin = decls['transform-origin'] ?? el.attrs['transform-origin'];
|
|
931
|
+
if (!origin)
|
|
932
|
+
continue;
|
|
933
|
+
if (isOriginDefault(origin))
|
|
934
|
+
continue;
|
|
935
|
+
const box = decls['transform-box'] ?? el.attrs['transform-box'];
|
|
936
|
+
if (box)
|
|
937
|
+
continue;
|
|
938
|
+
findings.push({
|
|
939
|
+
rule: 'svg-motion-transform-box',
|
|
940
|
+
file,
|
|
941
|
+
line: el.line,
|
|
942
|
+
snippet: `<${el.name}> sets transform-origin: ${origin} with no transform-box — in SVG the origin ` +
|
|
943
|
+
'resolves against the view box, not the element, so the pivot lands somewhere else',
|
|
944
|
+
severity: 'error',
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
for (const rule of readCssRules(content)) {
|
|
948
|
+
const decls = declarationsOf(rule.body);
|
|
949
|
+
const origin = decls['transform-origin'];
|
|
950
|
+
if (!origin || isOriginDefault(origin))
|
|
951
|
+
continue;
|
|
952
|
+
if (decls['transform-box'])
|
|
953
|
+
continue;
|
|
954
|
+
findings.push({
|
|
955
|
+
rule: 'svg-motion-transform-box',
|
|
956
|
+
file,
|
|
957
|
+
line: rule.line,
|
|
958
|
+
snippet: `selector "${rule.selector}" sets transform-origin: ${origin} with no transform-box — ` +
|
|
959
|
+
'the pivot resolves against the view box',
|
|
960
|
+
severity: 'error',
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
return dedupe(findings);
|
|
964
|
+
}
|
|
965
|
+
function isOriginDefault(value) {
|
|
966
|
+
const v = value.trim().toLowerCase();
|
|
967
|
+
return v === '0 0' || v === '0px 0px' || v === '0' || v === 'left top' || v === '0% 0%';
|
|
968
|
+
}
|
|
969
|
+
function ruleNoReducedMotion(content, elements, file) {
|
|
970
|
+
if (!hasSmilMarkup(content) && !hasCssAnimation(content))
|
|
971
|
+
return [];
|
|
972
|
+
if (hasReducedMotionQuery(content))
|
|
973
|
+
return [];
|
|
974
|
+
const line = hasSmilMarkup(content)
|
|
975
|
+
? firstSmilLine(content)
|
|
976
|
+
: (elements.find((el) => !el.closing)?.line ?? 1);
|
|
977
|
+
return [
|
|
978
|
+
{
|
|
979
|
+
rule: 'svg-motion-no-reduced-motion',
|
|
980
|
+
file,
|
|
981
|
+
line,
|
|
982
|
+
snippet: 'the file animates and carries no prefers-reduced-motion route at all — a viewer who asked ' +
|
|
983
|
+
'the OS to stop motion still gets it',
|
|
984
|
+
severity: 'error',
|
|
985
|
+
},
|
|
986
|
+
];
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* SMIL whose only reduced-motion route is CSS (R4).
|
|
990
|
+
*
|
|
991
|
+
* Measured: under emulated `reduce`, a page-level `svg * { animation: none
|
|
992
|
+
* !important }` left an `<animate>` moving `cx` from 20.2 to 76.8 in 700 ms.
|
|
993
|
+
* CSS `animation` does not reach SMIL, so a file that believes it is covered
|
|
994
|
+
* is covered by nothing. The reading itself lives in `_lib/svg_motion.ts`, so
|
|
995
|
+
* DQ4's inline half (step 1.3) cannot drift away from this one.
|
|
996
|
+
*/
|
|
997
|
+
function ruleSmilCssOnlyBranch(content, file) {
|
|
998
|
+
if (smilVerdict(content) !== 'css-only')
|
|
999
|
+
return [];
|
|
1000
|
+
return [
|
|
1001
|
+
{
|
|
1002
|
+
rule: 'svg-motion-smil-css-only',
|
|
1003
|
+
file,
|
|
1004
|
+
line: firstSmilLine(content),
|
|
1005
|
+
snippet: 'this file animates with SMIL and its only reduced-motion route is a CSS declaration — ' +
|
|
1006
|
+
'CSS animation/transition properties do not reach SMIL, so the animation keeps running',
|
|
1007
|
+
severity: 'error',
|
|
1008
|
+
},
|
|
1009
|
+
];
|
|
1010
|
+
}
|
|
1011
|
+
const PATH_COMMAND_RE = /[a-zA-Z]/g;
|
|
1012
|
+
/**
|
|
1013
|
+
* An `<animate attributeName="d">` whose `values` entries are not the same
|
|
1014
|
+
* shape. SVG morphs a path by interpolating coordinate lists, so two values
|
|
1015
|
+
* with a different command count or a different command sequence do not
|
|
1016
|
+
* interpolate — the browser snaps instead of morphing, or refuses outright.
|
|
1017
|
+
*/
|
|
1018
|
+
function ruleDMorphMismatch(elements, file) {
|
|
1019
|
+
const findings = [];
|
|
1020
|
+
for (const el of elements) {
|
|
1021
|
+
if (el.closing)
|
|
1022
|
+
continue;
|
|
1023
|
+
if (!SMIL_TAGS.has(el.name))
|
|
1024
|
+
continue;
|
|
1025
|
+
if ((el.attrs['attributename'] ?? '').toLowerCase() !== 'd')
|
|
1026
|
+
continue;
|
|
1027
|
+
const raw = el.attrs['values'];
|
|
1028
|
+
if (!raw)
|
|
1029
|
+
continue;
|
|
1030
|
+
const entries = raw.split(';').map((s) => s.trim()).filter(Boolean);
|
|
1031
|
+
if (entries.length < 2)
|
|
1032
|
+
continue;
|
|
1033
|
+
const shapes = entries.map((e) => (e.match(PATH_COMMAND_RE) ?? []).join('').toUpperCase());
|
|
1034
|
+
const first = shapes[0];
|
|
1035
|
+
const differing = shapes.findIndex((s) => s !== first);
|
|
1036
|
+
if (differing === -1)
|
|
1037
|
+
continue;
|
|
1038
|
+
findings.push({
|
|
1039
|
+
rule: 'svg-motion-d-morph-mismatch',
|
|
1040
|
+
file,
|
|
1041
|
+
line: el.line,
|
|
1042
|
+
snippet: `<${el.name} attributeName="d"> value ${differing + 1} has command sequence "${shapes[differing]}" ` +
|
|
1043
|
+
`against "${first}" in value 1 — a path morph interpolates coordinates, not commands`,
|
|
1044
|
+
severity: 'error',
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
return findings;
|
|
1048
|
+
}
|
|
1049
|
+
// ---------------------------------------------------------------------------
|
|
1050
|
+
// Composition
|
|
1051
|
+
// ---------------------------------------------------------------------------
|
|
1052
|
+
function dedupe(findings) {
|
|
1053
|
+
const seen = new Set();
|
|
1054
|
+
const out = [];
|
|
1055
|
+
for (const f of findings) {
|
|
1056
|
+
const key = `${f.rule}::${f.file}::${f.line}::${f.snippet}`;
|
|
1057
|
+
if (seen.has(key))
|
|
1058
|
+
continue;
|
|
1059
|
+
seen.add(key);
|
|
1060
|
+
out.push(f);
|
|
1061
|
+
}
|
|
1062
|
+
return out;
|
|
1063
|
+
}
|
|
1064
|
+
/** Every static finding for one file's bytes. */
|
|
1065
|
+
export function scanSvg(content, relPath, opts = {}) {
|
|
1066
|
+
const mode = opts.delivery ?? 'inline';
|
|
1067
|
+
const elements = parseElements(content);
|
|
1068
|
+
// ONE parse, and ONE name state. Finding 10 of the third completion
|
|
1069
|
+
// review, re-measured because the previous round's disposition was taken
|
|
1070
|
+
// on the wrong regime: over the seven small real fixtures the duplicate
|
|
1071
|
+
// parse is 0.03% of the dispatch, which is why it was accepted then — but
|
|
1072
|
+
// on a 760 KB file `parseDocument` is 8.2 ms against a 163 ms `scanSvg`,
|
|
1073
|
+
// so two redundant parses plus a recomputed name state are about 10% of a
|
|
1074
|
+
// pass that now runs on every `.svg` write. The document and the state are
|
|
1075
|
+
// passed rather than cached: a module-level cache in a hook is shared
|
|
1076
|
+
// mutable state, and the only thing that needed fixing was recomputation
|
|
1077
|
+
// inside one call.
|
|
1078
|
+
const doc = parseDocument(content);
|
|
1079
|
+
const nameState = rootNameState(content, doc, elements);
|
|
1080
|
+
// Finding 2.3: the content-level rules read the SAME file the element
|
|
1081
|
+
// parser reads. `maskComments` keeps every offset, so a line number taken
|
|
1082
|
+
// here still names the right line in the raw text.
|
|
1083
|
+
const text = maskComments(content);
|
|
1084
|
+
const raw = [
|
|
1085
|
+
...ruleWellFormed(doc, relPath),
|
|
1086
|
+
// No `text` argument: the rule reads elements only, and comment
|
|
1087
|
+
// skipping already happened inside `parseElements`. Passing the masked
|
|
1088
|
+
// text told a reader the parameter was load-bearing (finding 12);
|
|
1089
|
+
// `no-unused-vars` could not catch it because two later parameters were
|
|
1090
|
+
// used.
|
|
1091
|
+
...ruleDegenerateGradient(elements, relPath),
|
|
1092
|
+
...ruleMissingXmlns(elements, relPath),
|
|
1093
|
+
...ruleOutsideViewBox(elements, relPath),
|
|
1094
|
+
...ruleUnparseablePath(elements, relPath),
|
|
1095
|
+
...ruleUnusedDefs(text, elements, relPath),
|
|
1096
|
+
...ruleDanglingReference(text, elements, relPath),
|
|
1097
|
+
...ruleActiveContent(text, elements, relPath, mode),
|
|
1098
|
+
...ruleInternalSubset(text, relPath),
|
|
1099
|
+
...ruleExternalReference(text, elements, relPath, mode),
|
|
1100
|
+
...ruleEmbeddedRaster(elements, relPath),
|
|
1101
|
+
...ruleGenericId(elements, relPath),
|
|
1102
|
+
...ruleAccessibleName(nameState, relPath),
|
|
1103
|
+
...ruleDoubleName(nameState, relPath),
|
|
1104
|
+
...ruleKeySplinesRange(elements, relPath),
|
|
1105
|
+
...ruleTransformBox(text, elements, relPath),
|
|
1106
|
+
...ruleNoReducedMotion(text, elements, relPath),
|
|
1107
|
+
...ruleSmilCssOnlyBranch(text, relPath),
|
|
1108
|
+
...ruleDMorphMismatch(elements, relPath),
|
|
1109
|
+
];
|
|
1110
|
+
// RAW, not masked — the inline `svg-asset-disable` markers live in comments.
|
|
1111
|
+
const ignores = parseIgnores(content.split('\n'));
|
|
1112
|
+
return dedupe(raw).filter((f) => !isIgnored(ignores, f));
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Inline SVG inside a host HTML page.
|
|
1116
|
+
*
|
|
1117
|
+
* Each `<svg>…</svg>` is scanned on its own so a per-element rule keeps its
|
|
1118
|
+
* own line numbers, and the id rules then run once ACROSS the graphics —
|
|
1119
|
+
* which is the whole of R7: two graphics are each individually fine and
|
|
1120
|
+
* collide only together.
|
|
1121
|
+
*/
|
|
1122
|
+
/**
|
|
1123
|
+
* The TOP-LEVEL `<svg>` elements of a host document, by offset.
|
|
1124
|
+
*
|
|
1125
|
+
* Depth-counted, not matched. Finding 3 of the fifth completion review: the
|
|
1126
|
+
* non-greedy `/<svg\b[\s\S]*?<\/svg\s*>/` ended at the FIRST `</svg>`, which
|
|
1127
|
+
* on a graphic containing a nested `<svg>` — legal, and how a sprite or a
|
|
1128
|
+
* symbol is embedded — is the inner one. The slice was then a truncated
|
|
1129
|
+
* fragment, so the host reported a FALSE `svg-structure-not-well-formed` for
|
|
1130
|
+
* an unclosed root AND missed everything after the inner close, including a
|
|
1131
|
+
* real `<script>` that the same bytes report correctly when scanned flat. A
|
|
1132
|
+
* false structure error and a missed active-content error, from one regex.
|
|
1133
|
+
*
|
|
1134
|
+
* A nested graphic is part of its parent and is not returned separately: the
|
|
1135
|
+
* rules read the whole thing, exactly as a browser does.
|
|
1136
|
+
*/
|
|
1137
|
+
/**
|
|
1138
|
+
* Blank the BODY of every `<script>` and `<style>`, offsets and newlines kept.
|
|
1139
|
+
*
|
|
1140
|
+
* Finding 1 of the review of this branch, and it is a regression this branch
|
|
1141
|
+
* introduced: emitting an unterminated graphic made `topLevelGraphics` count
|
|
1142
|
+
* `<svg …>` wherever it appears — including inside a JavaScript string, where
|
|
1143
|
+
* it is text rather than markup. A host containing NO SVG element at all, but
|
|
1144
|
+
* mentioning one in a script, went from zero findings to five, four of them in
|
|
1145
|
+
* the blocking families the static witness counts. Fail-CLOSED rather than
|
|
1146
|
+
* fail-open, so it refuses a valid document instead of passing a bad one, but
|
|
1147
|
+
* a gate that refuses a correct turn is the failure this change has spent six
|
|
1148
|
+
* rounds removing.
|
|
1149
|
+
*
|
|
1150
|
+
* The tags survive; only what is between them is blanked. `ruleActiveContent`
|
|
1151
|
+
* keys on the element NAME, so a real `<script>` inside a graphic is still
|
|
1152
|
+
* found — masking its body hides nothing the rules read.
|
|
1153
|
+
*/
|
|
1154
|
+
function maskScriptBodies(content) {
|
|
1155
|
+
const out = content.split('');
|
|
1156
|
+
const re = /<(script|style)\b[^>]*>/gi;
|
|
1157
|
+
let m;
|
|
1158
|
+
while ((m = re.exec(content)) !== null) {
|
|
1159
|
+
const bodyStart = m.index + m[0].length;
|
|
1160
|
+
const close = content.toLowerCase().indexOf(`</${m[1].toLowerCase()}`, bodyStart);
|
|
1161
|
+
const bodyEnd = close === -1 ? content.length : close;
|
|
1162
|
+
for (let i = bodyStart; i < bodyEnd; i += 1) {
|
|
1163
|
+
if (out[i] !== '\n' && out[i] !== '\r')
|
|
1164
|
+
out[i] = ' ';
|
|
1165
|
+
}
|
|
1166
|
+
re.lastIndex = bodyEnd;
|
|
1167
|
+
}
|
|
1168
|
+
return out.join('');
|
|
1169
|
+
}
|
|
1170
|
+
function topLevelGraphics(text) {
|
|
1171
|
+
const out = [];
|
|
1172
|
+
const tagRe = /<(\/?)svg\b[^>]*?(\/?)>/gi;
|
|
1173
|
+
let depth = 0;
|
|
1174
|
+
let start = -1;
|
|
1175
|
+
let t;
|
|
1176
|
+
while ((t = tagRe.exec(text)) !== null) {
|
|
1177
|
+
const closing = t[1] === '/';
|
|
1178
|
+
const selfClosing = t[2] === '/';
|
|
1179
|
+
if (closing) {
|
|
1180
|
+
if (depth === 0)
|
|
1181
|
+
continue;
|
|
1182
|
+
depth -= 1;
|
|
1183
|
+
if (depth === 0 && start !== -1) {
|
|
1184
|
+
out.push({ start, end: t.index + t[0].length });
|
|
1185
|
+
start = -1;
|
|
1186
|
+
}
|
|
1187
|
+
continue;
|
|
1188
|
+
}
|
|
1189
|
+
if (selfClosing) {
|
|
1190
|
+
// An empty graphic at top level is still a graphic.
|
|
1191
|
+
if (depth === 0)
|
|
1192
|
+
out.push({ start: t.index, end: t.index + t[0].length });
|
|
1193
|
+
continue;
|
|
1194
|
+
}
|
|
1195
|
+
if (depth === 0)
|
|
1196
|
+
start = t.index;
|
|
1197
|
+
depth += 1;
|
|
1198
|
+
}
|
|
1199
|
+
// AN UNTERMINATED GRAPHIC IS STILL A GRAPHIC, to end of input.
|
|
1200
|
+
//
|
|
1201
|
+
// Finding 1 of the sixth completion review, and it FAILS OPEN, which is
|
|
1202
|
+
// why it is the one that matters: a host whose inline `<svg>` is never
|
|
1203
|
+
// closed was dropped whole, so `scanHostHtml` returned zero findings — a
|
|
1204
|
+
// planted `<script>` included — `staticWitness` then reported `errors: 0`,
|
|
1205
|
+
// and that is exactly the condition `dischargeFor` uses to discharge the
|
|
1206
|
+
// BLOCKING turn-end obligation on the static-only path. A malformed file
|
|
1207
|
+
// discharged a gate that a well-formed one with the same script does not.
|
|
1208
|
+
//
|
|
1209
|
+
// NOT a regression of the depth counter that replaced the regex: the old
|
|
1210
|
+
// `/<svg\b[\s\S]*?<\/svg\s*>/` required a closing tag and matched nothing
|
|
1211
|
+
// here either. The rewrite preserved the hole rather than opening it, and
|
|
1212
|
+
// the honest version of that sentence is worth more than the flattering
|
|
1213
|
+
// one. Emitting the fragment hands `scanSvg` a document whose root is
|
|
1214
|
+
// never closed — it reports that AND everything inside it, which is both
|
|
1215
|
+
// answers the reader needs.
|
|
1216
|
+
if (depth > 0 && start !== -1)
|
|
1217
|
+
out.push({ start, end: text.length });
|
|
1218
|
+
return out;
|
|
1219
|
+
}
|
|
1220
|
+
export function scanHostHtml(content, relPath, opts = {}) {
|
|
1221
|
+
const mode = opts.delivery ?? 'inline';
|
|
1222
|
+
const findings = [];
|
|
1223
|
+
const lineOf = lineIndexer(content);
|
|
1224
|
+
const graphics = [];
|
|
1225
|
+
// Finding 2.3 on the host side: a graphic inside an HTML comment is not on
|
|
1226
|
+
// the page, so it is not scanned and its ids do not collide with anything.
|
|
1227
|
+
// Masking preserves offsets, so `lineOf` — built from the raw text — still
|
|
1228
|
+
// answers correctly for the graphics that survive.
|
|
1229
|
+
const hostText = maskScriptBodies(maskComments(content));
|
|
1230
|
+
for (const m of topLevelGraphics(hostText)) {
|
|
1231
|
+
// The MATCH is found in the masked text — a graphic inside an HTML
|
|
1232
|
+
// comment is not on the page — but the BODY is sliced from the raw
|
|
1233
|
+
// text at the same offsets.
|
|
1234
|
+
//
|
|
1235
|
+
// Finding 16 of this change's completion review: handing the masked
|
|
1236
|
+
// slice to `scanSvg` meant its own `parseIgnores` saw every
|
|
1237
|
+
// `svg-asset-disable` marker blanked, which is precisely the failure
|
|
1238
|
+
// `maskComments`' doc warns about. Suppression survived only because
|
|
1239
|
+
// this function re-filters on absolute lines at the end — the inner
|
|
1240
|
+
// pass was inert, and silently so, which made the outer re-filter
|
|
1241
|
+
// load-bearing without anything saying it was.
|
|
1242
|
+
graphics.push({ body: content.slice(m.start, m.end), offset: m.start });
|
|
1243
|
+
}
|
|
1244
|
+
for (const g of graphics) {
|
|
1245
|
+
const baseLine = lineOf(g.offset) - 1;
|
|
1246
|
+
// Two families are dropped for an inline graphic, each for a reason
|
|
1247
|
+
// the slice itself cannot see:
|
|
1248
|
+
// · the host supplies the namespace, so a missing xmlns is correct;
|
|
1249
|
+
// · the reduced-motion ROUTE may live in the host's own <style> or
|
|
1250
|
+
// in a <picture> wrapper, and this slice ends at </svg>. Judging
|
|
1251
|
+
// the route from inside the slice would red every correctly
|
|
1252
|
+
// covered inline animation. DQ4 owns the inline case (step 1.3)
|
|
1253
|
+
// and reads the whole document, which is where that answer is.
|
|
1254
|
+
for (const f of scanSvg(g.body, relPath, { delivery: mode })) {
|
|
1255
|
+
if (f.rule === 'svg-structure-missing-xmlns')
|
|
1256
|
+
continue;
|
|
1257
|
+
if (f.rule === 'svg-motion-no-reduced-motion')
|
|
1258
|
+
continue;
|
|
1259
|
+
if (f.rule === 'svg-motion-smil-css-only')
|
|
1260
|
+
continue;
|
|
1261
|
+
findings.push({ ...f, line: f.line + baseLine });
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
// Cross-graphic id collisions — the finding R7 named.
|
|
1265
|
+
const seen = new Map();
|
|
1266
|
+
for (const g of graphics) {
|
|
1267
|
+
const baseLine = lineOf(g.offset) - 1;
|
|
1268
|
+
for (const el of parseElements(g.body)) {
|
|
1269
|
+
if (el.closing)
|
|
1270
|
+
continue;
|
|
1271
|
+
const id = el.attrs['id'];
|
|
1272
|
+
if (!id)
|
|
1273
|
+
continue;
|
|
1274
|
+
const key = id.toLowerCase();
|
|
1275
|
+
if (seen.has(key)) {
|
|
1276
|
+
findings.push({
|
|
1277
|
+
// ITS OWN RULE ID. Finding 4 of the fifth completion
|
|
1278
|
+
// review: this error shared `svg-a11y-generic-id` with the
|
|
1279
|
+
// single-graphic WARNING about a generic name, so one
|
|
1280
|
+
// `svg-asset-disable svg-a11y-generic-id` — a reasonable
|
|
1281
|
+
// thing to write when you have deliberately named
|
|
1282
|
+
// something `icon` — silenced a real collision between two
|
|
1283
|
+
// graphics as well. Two findings, two severities, two
|
|
1284
|
+
// remedies; one id was one too few.
|
|
1285
|
+
rule: 'svg-a11y-duplicate-id',
|
|
1286
|
+
file: relPath,
|
|
1287
|
+
line: el.line + baseLine,
|
|
1288
|
+
snippet: `id="${id}" is already used by another inline graphic on this page (line ${seen.get(key)}) — ` +
|
|
1289
|
+
'the accessibility tree names both after the first',
|
|
1290
|
+
severity: 'error',
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
else {
|
|
1294
|
+
seen.set(key, el.line + baseLine);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
const ignores = parseIgnores(content.split('\n'));
|
|
1299
|
+
return dedupe(findings).filter((f) => !isIgnored(ignores, f));
|
|
1300
|
+
}
|
|
1301
|
+
export function parseIgnores(lines) {
|
|
1302
|
+
const ignores = new Map();
|
|
1303
|
+
const slot = (rule) => {
|
|
1304
|
+
const found = ignores.get(rule) ?? { lines: new Set(), wholeFile: false };
|
|
1305
|
+
ignores.set(rule, found);
|
|
1306
|
+
return found;
|
|
1307
|
+
};
|
|
1308
|
+
const add = (rule, line) => {
|
|
1309
|
+
slot(rule).lines.add(line);
|
|
1310
|
+
};
|
|
1311
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1312
|
+
const line = lines[i] ?? '';
|
|
1313
|
+
const next = /svg-asset-disable-next-line\s+([\w-]+)/i.exec(line);
|
|
1314
|
+
if (next?.[1])
|
|
1315
|
+
add(next[1].toLowerCase(), i + 2);
|
|
1316
|
+
const same = /svg-asset-disable-line\s+([\w-]+)/i.exec(line);
|
|
1317
|
+
if (same?.[1])
|
|
1318
|
+
add(same[1].toLowerCase(), i + 1);
|
|
1319
|
+
const whole = /svg-asset-disable\s+([\w-]+)/i.exec(line);
|
|
1320
|
+
if (whole?.[1] && !/-next-line|-line\s/.test(line)) {
|
|
1321
|
+
slot(whole[1].toLowerCase()).wholeFile = true;
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
return ignores;
|
|
1325
|
+
}
|
|
1326
|
+
function isIgnored(ignores, finding) {
|
|
1327
|
+
// The safety family is not suppressible from inside the file it judges: the
|
|
1328
|
+
// markers are the scanned file's own bytes, and a file that silences its
|
|
1329
|
+
// own script zeroes the error count a blocking gate discharges on.
|
|
1330
|
+
if (finding.rule.startsWith('svg-safety-'))
|
|
1331
|
+
return false;
|
|
1332
|
+
const hit = ignores.get(finding.rule.toLowerCase());
|
|
1333
|
+
if (hit === undefined)
|
|
1334
|
+
return false;
|
|
1335
|
+
return hit.wholeFile || hit.lines.has(finding.line);
|
|
1336
|
+
}
|
|
1337
|
+
//# sourceMappingURL=svg_rules.js.map
|