@fusion.dev/helix-diagnostics 0.137.0 → 0.139.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/README.md +172 -36
- package/dist/analyze.d.ts +25 -4
- package/dist/analyze.js +268 -50
- package/dist/ci.d.ts +18 -0
- package/dist/ci.js +30 -0
- package/dist/collect.d.ts +20 -1
- package/dist/collect.js +32 -17
- package/dist/config.d.ts +17 -0
- package/dist/config.js +30 -11
- package/dist/console.d.ts +9 -1
- package/dist/console.js +13 -3
- package/dist/diagnose.d.ts +75 -0
- package/dist/diagnose.js +76 -0
- package/dist/identity.js +4 -0
- package/dist/index.d.ts +5 -77
- package/dist/index.js +4 -73
- package/dist/init.d.ts +87 -0
- package/dist/init.js +326 -0
- package/dist/manifest.d.ts +65 -4
- package/dist/manifest.js +92 -27
- package/dist/overlay.d.ts +12 -0
- package/dist/overlay.js +278 -0
- package/dist/page.d.ts +25 -0
- package/dist/page.js +21 -0
- package/dist/report.d.ts +8 -1
- package/dist/report.js +2 -0
- package/dist/rules.js +2 -0
- package/dist/schedule.d.ts +57 -0
- package/dist/schedule.js +84 -0
- package/dist/usage.d.ts +10 -2
- package/dist/usage.js +42 -30
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +6 -2
package/dist/analyze.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { nearest } from './manifest.js';
|
|
2
|
+
import { pageOf } from './page.js';
|
|
2
3
|
export const targets = (selector) => {
|
|
3
4
|
const found = new Set();
|
|
4
|
-
for (const match of selector.matchAll(
|
|
5
|
+
for (const match of selector.matchAll(/(?<![\w.#-])helix-[a-z0-9]+(?:-[a-z0-9]+)*/g)) {
|
|
5
6
|
found.add(match[0]);
|
|
6
7
|
}
|
|
7
8
|
return [...found];
|
|
@@ -16,38 +17,164 @@ export const parts = (selector) => {
|
|
|
16
17
|
}
|
|
17
18
|
return found;
|
|
18
19
|
};
|
|
19
|
-
const
|
|
20
|
+
export const matchable = (selector) => {
|
|
21
|
+
if (selector.includes(':host')) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const plain = selector
|
|
25
|
+
.replace(/::[\w-]+(\([^)]*\))?/g, '')
|
|
26
|
+
.replace(/:(focus-visible|focus-within|hover|focus|active|visited|target)(?![\w-])/g, '')
|
|
27
|
+
.trim();
|
|
28
|
+
return plain === '' ? null : plain;
|
|
29
|
+
};
|
|
30
|
+
const answersAt = (node, property, answering, readers) => {
|
|
31
|
+
if (answering.has(node.localName)) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
for (const { scope, selector } of readers.get(property) ?? []) {
|
|
35
|
+
if (scope !== undefined && node.getRootNode() !== scope) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const plain = matchable(selector);
|
|
39
|
+
try {
|
|
40
|
+
if (plain !== null && node.matches(plain)) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
};
|
|
49
|
+
const reachesAnswer = (host, property, answering, readers, depth = 0) => {
|
|
20
50
|
if (depth > 16) {
|
|
21
51
|
return false;
|
|
22
52
|
}
|
|
23
53
|
for (const node of [host, ...host.querySelectorAll('*')]) {
|
|
24
|
-
if (node
|
|
54
|
+
if (answersAt(node, property, answering, readers)) {
|
|
25
55
|
return true;
|
|
26
56
|
}
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (wanted.has(child.localName) || reaches(child, wanted, depth + 1)) {
|
|
31
|
-
return true;
|
|
32
|
-
}
|
|
57
|
+
for (const child of node.shadowRoot?.children ?? []) {
|
|
58
|
+
if (reachesAnswer(child, property, answering, readers, depth + 1)) {
|
|
59
|
+
return true;
|
|
33
60
|
}
|
|
34
61
|
}
|
|
35
62
|
}
|
|
36
63
|
return false;
|
|
37
64
|
};
|
|
65
|
+
const loose = (located, answerers, from, used, onPage, listedBy, readers, suggested) => {
|
|
66
|
+
const findings = [];
|
|
67
|
+
if (located.scope instanceof ShadowRoot && located.scope.host.localName.startsWith('helix-')) {
|
|
68
|
+
return findings;
|
|
69
|
+
}
|
|
70
|
+
const selector = located.rule.selectorText;
|
|
71
|
+
for (const property of Array.from(located.rule.style ?? [])) {
|
|
72
|
+
if (!property.startsWith('--') ||
|
|
73
|
+
property.startsWith('--helix-') ||
|
|
74
|
+
property.startsWith('--_')) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const answering = answerers.get(property);
|
|
78
|
+
if (!answering) {
|
|
79
|
+
if (used.has(property)) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
let suggestion = suggested.get(property);
|
|
83
|
+
if (!suggested.has(property)) {
|
|
84
|
+
suggestion = nearest(property, answerers.keys(), HOOK_DISTANCE);
|
|
85
|
+
suggested.set(property, suggestion);
|
|
86
|
+
}
|
|
87
|
+
if (!suggestion) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const tag = [...(listedBy.get(suggestion) ?? answerers.get(suggestion) ?? [])].sort()[0] ?? '';
|
|
91
|
+
findings.push({
|
|
92
|
+
expected: suggestion,
|
|
93
|
+
kind: 'dead-hook',
|
|
94
|
+
message: `\`${selector}\` sets \`${property}\`, which no Helix element answers and nothing on the page reads. \`${suggestion}\` is a hook \`${tag}\` answers, two edits or fewer away, and almost certainly what was meant.`,
|
|
95
|
+
origin: origin(located),
|
|
96
|
+
subject: `${selector} { ${property} }`,
|
|
97
|
+
tag,
|
|
98
|
+
});
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const scope = located.scope ?? from;
|
|
102
|
+
const plain = matchable(selector);
|
|
103
|
+
if (!scope || !from?.documentElement || plain === null) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
let matched;
|
|
107
|
+
try {
|
|
108
|
+
matched = [...scope.querySelectorAll(plain)];
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (matched.length === 0 || !onPage(property, answering)) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (matched.some((host) => reachesAnswer(host, property, answering, readers))) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const tags = [...answering].sort();
|
|
120
|
+
findings.push({
|
|
121
|
+
kind: 'dead-hook',
|
|
122
|
+
message: `\`${selector}\` sets \`${property}\`, and nothing under the ${matched.length === 1 ? 'element' : `${matched.length} elements`} it matches answers it, though something elsewhere on this page does. ${tags
|
|
123
|
+
.slice(0, 3)
|
|
124
|
+
.map((one) => `\`${one}\``)
|
|
125
|
+
.join(', ')} ${tags.length === 1 ? 'answers' : 'answer'} it. It targets the wrong element, or expects a descendant that is not there.`,
|
|
126
|
+
origin: origin(located),
|
|
127
|
+
subject: `${selector} { ${property} }`,
|
|
128
|
+
tag: tags[0] ?? '',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return findings;
|
|
132
|
+
};
|
|
38
133
|
const origin = (located) => ({
|
|
39
134
|
cssText: located.rule.cssText,
|
|
40
135
|
index: located.index,
|
|
41
136
|
sheet: located.sheet,
|
|
42
137
|
});
|
|
43
|
-
export const analyze = (rules, tags, from, answered = new Map()) => {
|
|
138
|
+
export const analyze = (rules, tags, from, answered = new Map(), used = new Set(), readers = new Map()) => {
|
|
44
139
|
const findings = [];
|
|
140
|
+
const answeredBy = new Map();
|
|
141
|
+
const answers = (property, tag) => {
|
|
142
|
+
const set = answeredBy.get(property) ?? new Set();
|
|
143
|
+
set.add(tag);
|
|
144
|
+
answeredBy.set(property, set);
|
|
145
|
+
};
|
|
146
|
+
const listedBy = new Map();
|
|
147
|
+
for (const [tag, element] of tags) {
|
|
148
|
+
for (const property of element.hooks.keys()) {
|
|
149
|
+
answers(property, tag);
|
|
150
|
+
listedBy.set(property, new Set([...(listedBy.get(property) ?? []), tag]));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (const [tag, reads] of answered) {
|
|
154
|
+
for (const property of reads) {
|
|
155
|
+
if (tags.has(tag)) {
|
|
156
|
+
answers(property, tag);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const suggested = new Map();
|
|
161
|
+
const present = new Map();
|
|
162
|
+
const onPage = (property, answering) => {
|
|
163
|
+
let found = present.get(property);
|
|
164
|
+
if (found === undefined) {
|
|
165
|
+
found = from?.documentElement
|
|
166
|
+
? reachesAnswer(from.documentElement, property, answering, readers)
|
|
167
|
+
: false;
|
|
168
|
+
present.set(property, found);
|
|
169
|
+
}
|
|
170
|
+
return found;
|
|
171
|
+
};
|
|
45
172
|
for (const located of rules) {
|
|
46
173
|
const selector = located.rule.selectorText;
|
|
47
|
-
if (!selector
|
|
174
|
+
if (!selector) {
|
|
48
175
|
continue;
|
|
49
176
|
}
|
|
50
|
-
for (const { names, tag } of parts(selector)) {
|
|
177
|
+
for (const { names, tag } of selector.includes('helix-') ? parts(selector) : []) {
|
|
51
178
|
const element = tags.get(tag);
|
|
52
179
|
if (!element) {
|
|
53
180
|
findings.push({
|
|
@@ -92,6 +219,7 @@ export const analyze = (rules, tags, from, answered = new Map()) => {
|
|
|
92
219
|
const mentioned = targets(selector).map((tag) => [tag, tags.get(tag)]);
|
|
93
220
|
const known = mentioned.filter(([, element]) => element);
|
|
94
221
|
if (known.length === 0) {
|
|
222
|
+
findings.push(...loose(located, answeredBy, from, used, onPage, listedBy, readers, suggested));
|
|
95
223
|
continue;
|
|
96
224
|
}
|
|
97
225
|
for (const property of Array.from(located.rule.style ?? [])) {
|
|
@@ -106,22 +234,27 @@ export const analyze = (rules, tags, from, answered = new Map()) => {
|
|
|
106
234
|
}
|
|
107
235
|
const answerers = new Set([...tags].filter(([, element]) => element.hooks.has(property)).map(([tag]) => tag));
|
|
108
236
|
const candidates = known.flatMap(([, element]) => [...(element?.hooks.keys() ?? [])]);
|
|
109
|
-
const suggestion = nearest(property, candidates);
|
|
110
|
-
if (answerers.size === 0 && !suggestion) {
|
|
237
|
+
const suggestion = nearest(property, candidates, HOOK_DISTANCE);
|
|
238
|
+
if ((answerers.size === 0 && !suggestion) || (answerers.size === 0 && used.has(property))) {
|
|
111
239
|
continue;
|
|
112
240
|
}
|
|
113
241
|
if (answerers.size > 0 && !suggestion) {
|
|
114
242
|
if (!from) {
|
|
115
243
|
continue;
|
|
116
244
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
inherits = true;
|
|
121
|
-
break;
|
|
122
|
-
}
|
|
245
|
+
const plain = matchable(selector);
|
|
246
|
+
if (plain === null) {
|
|
247
|
+
continue;
|
|
123
248
|
}
|
|
124
|
-
|
|
249
|
+
let matched;
|
|
250
|
+
try {
|
|
251
|
+
matched = [...(located.scope ?? from).querySelectorAll(plain)];
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (matched.length === 0 ||
|
|
257
|
+
matched.some((host) => reachesAnswer(host, property, answeredBy.get(property) ?? answerers, readers))) {
|
|
125
258
|
continue;
|
|
126
259
|
}
|
|
127
260
|
}
|
|
@@ -130,7 +263,7 @@ export const analyze = (rules, tags, from, answered = new Map()) => {
|
|
|
130
263
|
expected: suggestion,
|
|
131
264
|
kind: 'dead-hook',
|
|
132
265
|
message: suggestion
|
|
133
|
-
? `\`${tag}\` does not answer \`${property}\`. It answers \`${suggestion}\`,
|
|
266
|
+
? `\`${tag}\` does not answer \`${property}\`. It answers \`${suggestion}\`, two edits or fewer away, which is almost certainly what was meant.`
|
|
134
267
|
: `\`${tag}\` does not answer \`${property}\`, and nothing under it that does was found in the page. ${[...answerers].sort().slice(0, 3).join(', ')} answer it, so this either targets the wrong element or expects a descendant that is not there.`,
|
|
135
268
|
origin: origin(located),
|
|
136
269
|
subject: `${selector} { ${property} }`,
|
|
@@ -140,7 +273,7 @@ export const analyze = (rules, tags, from, answered = new Map()) => {
|
|
|
140
273
|
}
|
|
141
274
|
return findings;
|
|
142
275
|
};
|
|
143
|
-
export const registrations = (from = document, known = () => []) => {
|
|
276
|
+
export const registrations = (from = document, known = () => [], page = pageOf(from)) => {
|
|
144
277
|
const findings = [];
|
|
145
278
|
const registry = globalThis.customElements;
|
|
146
279
|
const said = new Set();
|
|
@@ -162,7 +295,7 @@ export const registrations = (from = document, known = () => []) => {
|
|
|
162
295
|
if (!registry) {
|
|
163
296
|
return findings;
|
|
164
297
|
}
|
|
165
|
-
for (const element of
|
|
298
|
+
for (const element of page.elements) {
|
|
166
299
|
const tag = element.localName;
|
|
167
300
|
if (!tag.startsWith('helix-') || said.has(tag)) {
|
|
168
301
|
continue;
|
|
@@ -174,37 +307,30 @@ export const registrations = (from = document, known = () => []) => {
|
|
|
174
307
|
}
|
|
175
308
|
return findings;
|
|
176
309
|
};
|
|
177
|
-
export const skew = (tags, from) => {
|
|
310
|
+
export const skew = (tags, from, page = pageOf(from)) => {
|
|
178
311
|
const findings = [];
|
|
179
312
|
const said = new Set();
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
313
|
+
for (const element of page.elements) {
|
|
314
|
+
const shadow = element.shadowRoot;
|
|
315
|
+
if (!shadow) {
|
|
316
|
+
continue;
|
|
183
317
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
kind: 'manifest-skew',
|
|
198
|
-
message: `\`${tag}\` renders ${missing.map((name) => `\`${name}\``).join(', ')}, which the manifest passed in does not list, so the manifest is from a different version of Helix than the page runs. Every other finding would be judged against the wrong version, so none are reported. Pass the \`custom-elements.json\` from the installed packages.`,
|
|
199
|
-
subject: tag,
|
|
200
|
-
tag,
|
|
201
|
-
});
|
|
202
|
-
}
|
|
318
|
+
const tag = element.localName;
|
|
319
|
+
const published = tags.get(tag);
|
|
320
|
+
if (published && !said.has(tag)) {
|
|
321
|
+
const rendered = new Set([...shadow.querySelectorAll('[part]')].flatMap((node) => (node.getAttribute('part') ?? '').split(/\s+/).filter(Boolean)));
|
|
322
|
+
const missing = [...rendered].filter((name) => !published.parts.has(name)).sort();
|
|
323
|
+
if (missing.length > 0) {
|
|
324
|
+
said.add(tag);
|
|
325
|
+
findings.push({
|
|
326
|
+
kind: 'manifest-skew',
|
|
327
|
+
message: `\`${tag}\` renders ${missing.map((name) => `\`${name}\``).join(', ')}, which the manifest passed in does not list, so the manifest is from a different version of Helix than the page runs. Every other finding would be judged against the wrong version, so none are reported. Pass the \`custom-elements.json\` from the installed packages.`,
|
|
328
|
+
subject: tag,
|
|
329
|
+
tag,
|
|
330
|
+
});
|
|
203
331
|
}
|
|
204
|
-
visit(shadow, depth + 1);
|
|
205
332
|
}
|
|
206
|
-
}
|
|
207
|
-
visit(from, 0);
|
|
333
|
+
}
|
|
208
334
|
return findings;
|
|
209
335
|
};
|
|
210
336
|
export const versionSkew = (manifests, seen) => seen.length === 0
|
|
@@ -217,3 +343,95 @@ export const versionSkew = (manifests, seen) => seen.length === 0
|
|
|
217
343
|
subject: `${name}@${version}`,
|
|
218
344
|
tag: '',
|
|
219
345
|
}));
|
|
346
|
+
const GLOBAL = new Set([
|
|
347
|
+
'accesskey',
|
|
348
|
+
'autocapitalize',
|
|
349
|
+
'autofocus',
|
|
350
|
+
'class',
|
|
351
|
+
'contenteditable',
|
|
352
|
+
'defer-hydration',
|
|
353
|
+
'dir',
|
|
354
|
+
'draggable',
|
|
355
|
+
'enterkeyhint',
|
|
356
|
+
'exportparts',
|
|
357
|
+
'hidden',
|
|
358
|
+
'id',
|
|
359
|
+
'inert',
|
|
360
|
+
'inputmode',
|
|
361
|
+
'is',
|
|
362
|
+
'itemid',
|
|
363
|
+
'itemprop',
|
|
364
|
+
'itemref',
|
|
365
|
+
'itemscope',
|
|
366
|
+
'itemtype',
|
|
367
|
+
'lang',
|
|
368
|
+
'nonce',
|
|
369
|
+
'part',
|
|
370
|
+
'popover',
|
|
371
|
+
'role',
|
|
372
|
+
'slot',
|
|
373
|
+
'spellcheck',
|
|
374
|
+
'style',
|
|
375
|
+
'tabindex',
|
|
376
|
+
'title',
|
|
377
|
+
'translate',
|
|
378
|
+
'writingsuggestions',
|
|
379
|
+
]);
|
|
380
|
+
const PREFIXES = ['aria-', 'data-', 'on', '_ngcontent-', '_nghost-', 'ng-', 'v-', 'x-', ':', '@'];
|
|
381
|
+
const HOOK_DISTANCE = 2;
|
|
382
|
+
export const markup = (tags, from, page = pageOf(from)) => {
|
|
383
|
+
const found = new Map();
|
|
384
|
+
for (const element of page.elements) {
|
|
385
|
+
const host = element.getRootNode();
|
|
386
|
+
const composed = host instanceof ShadowRoot && host.host.localName.startsWith('helix-');
|
|
387
|
+
const published = composed ? undefined : tags.get(element.localName);
|
|
388
|
+
if (published) {
|
|
389
|
+
for (const { name, value } of Array.from(element.attributes)) {
|
|
390
|
+
if (GLOBAL.has(name) ||
|
|
391
|
+
PREFIXES.some((prefix) => name.startsWith(prefix)) ||
|
|
392
|
+
published.written.has(name)) {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const tag = element.localName;
|
|
396
|
+
const allows = published.attributes.get(name);
|
|
397
|
+
let raw;
|
|
398
|
+
if (allows === undefined) {
|
|
399
|
+
const suggestion = nearest(name, published.attributes.keys());
|
|
400
|
+
raw = {
|
|
401
|
+
expected: suggestion,
|
|
402
|
+
kind: 'unknown-attribute',
|
|
403
|
+
message: suggestion
|
|
404
|
+
? `\`${tag}\` takes no attribute \`${name}\`, so it does nothing. The closest it takes is \`${suggestion}\`.`
|
|
405
|
+
: `\`${tag}\` takes no attribute \`${name}\`, so it does nothing.`,
|
|
406
|
+
subject: `<${tag} ${name}>`,
|
|
407
|
+
tag,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
else if (allows && value !== '' && !allows.has(value)) {
|
|
411
|
+
const suggestion = nearest(value, allows);
|
|
412
|
+
raw = {
|
|
413
|
+
expected: suggestion,
|
|
414
|
+
kind: 'invalid-value',
|
|
415
|
+
message: `\`${tag}\`'s \`${name}\` takes ${[...allows].map((one) => `\`${one}\``).join(', ')}, so \`${value}\` falls back to the default.${suggestion ? ` The closest is \`${suggestion}\`.` : ''}`,
|
|
416
|
+
subject: `<${tag} ${name}="${value}">`,
|
|
417
|
+
tag,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
if (raw) {
|
|
421
|
+
const key = `${raw.kind} ${raw.subject}`;
|
|
422
|
+
const seen = found.get(key);
|
|
423
|
+
if (seen) {
|
|
424
|
+
seen.count += 1;
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
found.set(key, { ...raw, count: 1 });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return [...found.values()].map(({ count, ...raw }) => ({
|
|
434
|
+
...raw,
|
|
435
|
+
message: count > 1 ? `${raw.message} On ${count} elements.` : raw.message,
|
|
436
|
+
}));
|
|
437
|
+
};
|
package/dist/ci.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Report, Severity } from './report.js';
|
|
2
|
+
/** What the CI sink writes, and what fails the job. */
|
|
3
|
+
export interface CiOptions {
|
|
4
|
+
/** Where the runs are written, as JSON. */
|
|
5
|
+
file: string;
|
|
6
|
+
/** The lowest severity that fails the job, or `never`. `error` by default. */
|
|
7
|
+
failOn?: Severity | 'never';
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Writes the runs, and says what failed.
|
|
11
|
+
*
|
|
12
|
+
* Returns the exit code the job should end with and the summary to print;
|
|
13
|
+
* the caller prints and exits, so a script can add its own lines first.
|
|
14
|
+
*/
|
|
15
|
+
export declare const ciSink: (runs: Report[], { file, failOn }: CiOptions) => {
|
|
16
|
+
code: 0 | 1;
|
|
17
|
+
summary: string;
|
|
18
|
+
};
|
package/dist/ci.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
const RANK = { error: 3, info: 1, warn: 2 };
|
|
4
|
+
const counted = (count, noun) => `${count} ${noun}${count === 1 ? '' : 's'}`;
|
|
5
|
+
export const ciSink = (runs, { file, failOn = 'error' }) => {
|
|
6
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
7
|
+
writeFileSync(file, `${JSON.stringify({ runs, schema: 1 }, null, 2)}\n`);
|
|
8
|
+
const faults = new Map();
|
|
9
|
+
for (const run of runs) {
|
|
10
|
+
for (const finding of run.findings) {
|
|
11
|
+
const fault = faults.get(finding.fingerprint) ?? { finding, pages: new Set() };
|
|
12
|
+
fault.pages.add(run.run.page ?? '(no page)');
|
|
13
|
+
faults.set(finding.fingerprint, fault);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const all = [...faults.values()];
|
|
17
|
+
const of = (severity) => all.filter(({ finding }) => finding.severity === severity).length;
|
|
18
|
+
const failing = failOn === 'never' ? [] : all.filter(({ finding }) => RANK[finding.severity] >= RANK[failOn]);
|
|
19
|
+
const unread = runs.reduce((total, run) => total + run.coverage.unreadable.length, 0);
|
|
20
|
+
const lines = [
|
|
21
|
+
`helix diagnostics: ${counted(runs.length, 'run')}, ${counted(of('error'), 'error')}, ${counted(of('warn'), 'warning')}, ${of('info')} info` +
|
|
22
|
+
(unread > 0 ? `, ${counted(unread, 'unreadable stylesheet')}` : '') +
|
|
23
|
+
`. Written to ${file}.`,
|
|
24
|
+
];
|
|
25
|
+
for (const { finding, pages } of failing) {
|
|
26
|
+
const where = [...pages].sort();
|
|
27
|
+
lines.push(` ${finding.severity} ${finding.kind}: ${finding.subject}`, ` ${finding.message}`, ` on ${where.slice(0, 5).join(', ')}${where.length > 5 ? `, and ${where.length - 5} more` : ''}`);
|
|
28
|
+
}
|
|
29
|
+
return { code: failing.length > 0 ? 1 : 0, summary: lines.join('\n') };
|
|
30
|
+
};
|
package/dist/collect.d.ts
CHANGED
|
@@ -15,11 +15,17 @@
|
|
|
15
15
|
* the only way to know. Each one becomes a `Coverage.unreadable` entry, so the
|
|
16
16
|
* hole is in the output rather than in the reader's assumptions.
|
|
17
17
|
*/
|
|
18
|
+
import { type Page } from './page.js';
|
|
18
19
|
import type { Coverage } from './report.js';
|
|
19
20
|
/** One rule, with where it came from. */
|
|
20
21
|
export interface Located {
|
|
21
22
|
index: number;
|
|
22
23
|
rule: CSSStyleRule;
|
|
24
|
+
/**
|
|
25
|
+
* Where the rule's selector matches: the document for a page's own sheet,
|
|
26
|
+
* and the shadow root of the first host to adopt it for a component's.
|
|
27
|
+
*/
|
|
28
|
+
scope?: Document | ShadowRoot | undefined;
|
|
23
29
|
sheet: string;
|
|
24
30
|
}
|
|
25
31
|
/**
|
|
@@ -40,6 +46,17 @@ export interface Located {
|
|
|
40
46
|
* the whole truth.
|
|
41
47
|
*/
|
|
42
48
|
export type Answered = Map<string, Set<string>>;
|
|
49
|
+
/**
|
|
50
|
+
* The rules outside Helix's components that read each custom property with
|
|
51
|
+
* `var()`, by selector and the root they apply in. An element one of them
|
|
52
|
+
* matches answers the property as surely as a Helix tag does: Helix's own
|
|
53
|
+
* base sheet reads every button hook for a plain `[data-button]` link, and an
|
|
54
|
+
* example reads `--chart-series-1` in its own `::part()` rules.
|
|
55
|
+
*/
|
|
56
|
+
export type Readers = Map<string, Array<{
|
|
57
|
+
scope: Document | ShadowRoot | undefined;
|
|
58
|
+
selector: string;
|
|
59
|
+
}>>;
|
|
43
60
|
/**
|
|
44
61
|
* Every readable rule in the page, and what was missed getting them.
|
|
45
62
|
*
|
|
@@ -51,8 +68,10 @@ export type Answered = Map<string, Set<string>>;
|
|
|
51
68
|
* hundred passes over rules already read. A `Set` keyed on the sheet object
|
|
52
69
|
* fixes the number and the cost together.
|
|
53
70
|
*/
|
|
54
|
-
export declare const collect: (from?: Document) => {
|
|
71
|
+
export declare const collect: (from?: Document, page?: Page) => {
|
|
55
72
|
answered: Answered;
|
|
56
73
|
coverage: Coverage;
|
|
74
|
+
readers: Readers;
|
|
57
75
|
rules: Located[];
|
|
76
|
+
used: Set<string>;
|
|
58
77
|
};
|
package/dist/collect.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { OVERLAY, pageOf } from './page.js';
|
|
1
2
|
const name = (sheet, seen, host) => {
|
|
2
3
|
if (sheet.href) {
|
|
3
4
|
return sheet.href;
|
|
@@ -23,7 +24,7 @@ const rules = (list, sheet, into, depth = 0, reads) => {
|
|
|
23
24
|
if ('selectorText' in rule) {
|
|
24
25
|
into.push({ index, rule: rule, sheet });
|
|
25
26
|
if (reads) {
|
|
26
|
-
for (const match of (rule.cssText ?? '').matchAll(/var\(\s*(--[
|
|
27
|
+
for (const match of (rule.cssText ?? '').matchAll(/var\(\s*(--[\w-]+)/g)) {
|
|
27
28
|
if (match[1]) {
|
|
28
29
|
reads.add(match[1]);
|
|
29
30
|
}
|
|
@@ -62,38 +63,44 @@ const read = (sheet, into, unreadable, labels, depth = 0, host, reads) => {
|
|
|
62
63
|
rules(list, label, into, depth, reads);
|
|
63
64
|
return true;
|
|
64
65
|
};
|
|
65
|
-
const
|
|
66
|
-
if (depth > 32) {
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
for (const element of from.querySelectorAll('*')) {
|
|
70
|
-
const root = element.shadowRoot;
|
|
71
|
-
if (root) {
|
|
72
|
-
found.push(root);
|
|
73
|
-
roots(root, found, depth + 1);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
};
|
|
77
|
-
export const collect = (from = document) => {
|
|
66
|
+
export const collect = (from = document, page = pageOf(from)) => {
|
|
78
67
|
const located = [];
|
|
79
68
|
const unreadable = [];
|
|
80
69
|
const seen = new Set();
|
|
81
70
|
const labels = new Map();
|
|
82
71
|
const answered = new Map();
|
|
72
|
+
const used = new Set();
|
|
73
|
+
const readers = new Map();
|
|
83
74
|
let readable = 0;
|
|
84
75
|
const take = (sheet, host) => {
|
|
85
76
|
if (!sheet || seen.has(sheet)) {
|
|
86
77
|
return;
|
|
87
78
|
}
|
|
88
79
|
seen.add(sheet);
|
|
89
|
-
let reads;
|
|
80
|
+
let reads = used;
|
|
90
81
|
if (host) {
|
|
91
82
|
reads = answered.get(host.localName) ?? new Set();
|
|
92
83
|
answered.set(host.localName, reads);
|
|
93
84
|
}
|
|
85
|
+
const before = located.length;
|
|
94
86
|
if (read(sheet, located, unreadable, labels, 0, host, reads)) {
|
|
95
87
|
readable += 1;
|
|
96
88
|
}
|
|
89
|
+
const scope = host ? (host.shadowRoot ?? undefined) : from;
|
|
90
|
+
const ours = !host?.localName.startsWith('helix-');
|
|
91
|
+
for (let at = before; at < located.length; at += 1) {
|
|
92
|
+
const one = located[at];
|
|
93
|
+
one.scope = scope;
|
|
94
|
+
if (ours && one.rule.selectorText) {
|
|
95
|
+
for (const match of (one.rule.cssText ?? '').matchAll(/var\(\s*(--[\w-]+)/g)) {
|
|
96
|
+
const property = match[1];
|
|
97
|
+
readers.set(property, [
|
|
98
|
+
...(readers.get(property) ?? []),
|
|
99
|
+
{ scope, selector: one.rule.selectorText },
|
|
100
|
+
]);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
97
104
|
};
|
|
98
105
|
for (const sheet of Array.from(from.styleSheets)) {
|
|
99
106
|
take(sheet);
|
|
@@ -101,8 +108,7 @@ export const collect = (from = document) => {
|
|
|
101
108
|
for (const sheet of from.adoptedStyleSheets ?? []) {
|
|
102
109
|
take(sheet);
|
|
103
110
|
}
|
|
104
|
-
const shadows =
|
|
105
|
-
roots(from, shadows);
|
|
111
|
+
const shadows = page.roots.filter((root) => root.host.localName !== OVERLAY);
|
|
106
112
|
for (const root of shadows) {
|
|
107
113
|
for (const sheet of Array.from(root.styleSheets ?? [])) {
|
|
108
114
|
take(sheet, root.host);
|
|
@@ -114,9 +120,18 @@ export const collect = (from = document) => {
|
|
|
114
120
|
take(owner.sheet, root.host);
|
|
115
121
|
}
|
|
116
122
|
}
|
|
123
|
+
for (const [tag, reads] of answered) {
|
|
124
|
+
if (!tag.startsWith('helix-')) {
|
|
125
|
+
for (const property of reads) {
|
|
126
|
+
used.add(property);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
117
130
|
return {
|
|
118
131
|
answered,
|
|
119
132
|
coverage: { read: readable, roots: shadows.length, sheets: seen.size, unreadable },
|
|
133
|
+
readers,
|
|
120
134
|
rules: located,
|
|
135
|
+
used,
|
|
121
136
|
};
|
|
122
137
|
};
|
package/dist/config.d.ts
CHANGED
|
@@ -21,6 +21,12 @@ export interface Config {
|
|
|
21
21
|
rules?: Rules;
|
|
22
22
|
/** Each category, sent or withheld. Nothing is sent without a key. */
|
|
23
23
|
send?: Partial<Record<Category, boolean>>;
|
|
24
|
+
/**
|
|
25
|
+
* Where `initDiagnostics()` hands each run, beside the console: the
|
|
26
|
+
* `overlay` on the page, and the `relay`, `helix diagnostics`, on or off or
|
|
27
|
+
* at an address of its own.
|
|
28
|
+
*/
|
|
29
|
+
sinks?: Partial<Configuration['sinks']>;
|
|
24
30
|
}
|
|
25
31
|
/** The shipped defaults. */
|
|
26
32
|
export declare const DEFAULTS: {
|
|
@@ -48,7 +54,18 @@ export declare const DEFAULTS: {
|
|
|
48
54
|
readonly runtime: true;
|
|
49
55
|
readonly usage: true;
|
|
50
56
|
};
|
|
57
|
+
readonly sinks: {
|
|
58
|
+
readonly overlay: true;
|
|
59
|
+
readonly relay: false;
|
|
60
|
+
};
|
|
51
61
|
};
|
|
62
|
+
/**
|
|
63
|
+
* A local host: `localhost`, `127.0.0.1`, `[::1]`, `*.localhost`, and `*.test`.
|
|
64
|
+
* A page with no declared environment runs only on one, and the relay is only
|
|
65
|
+
* ever at one, as `helix diagnostics` takes runs only from one: a relay at
|
|
66
|
+
* another address would be a second way to send, with no key and no `send`.
|
|
67
|
+
*/
|
|
68
|
+
export declare const LOCAL: RegExp;
|
|
52
69
|
/** Throws on a setting that names nothing, or a value it cannot take. */
|
|
53
70
|
export declare const checkConfig: (config: Config | undefined, layer: Exclude<Layer, 'shipped'>) => void;
|
|
54
71
|
/** The settings in effect, from the defaults, the file, and the call. */
|