@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/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(/\bhelix-[a-z0-9]+(?:-[a-z0-9]+)*/g)) {
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 reaches = (host, wanted, depth = 0) => {
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 !== host && wanted.has(node.localName)) {
54
+ if (answersAt(node, property, answering, readers)) {
25
55
  return true;
26
56
  }
27
- const root = node.shadowRoot;
28
- if (root) {
29
- for (const child of root.children) {
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?.includes('helix-')) {
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
- let inherits = false;
118
- for (const host of from.querySelectorAll(selector.replaceAll(/::part\([^)]*\)/g, ''))) {
119
- if (reaches(host, answerers)) {
120
- inherits = true;
121
- break;
122
- }
245
+ const plain = matchable(selector);
246
+ if (plain === null) {
247
+ continue;
123
248
  }
124
- if (inherits) {
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}\`, which is one character class away and is almost certainly what was meant.`
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 from.querySelectorAll('*')) {
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 visit = (root, depth) => {
181
- if (depth > 32) {
182
- return;
313
+ for (const element of page.elements) {
314
+ const shadow = element.shadowRoot;
315
+ if (!shadow) {
316
+ continue;
183
317
  }
184
- for (const element of root.querySelectorAll('*')) {
185
- const shadow = element.shadowRoot;
186
- if (!shadow) {
187
- continue;
188
- }
189
- const tag = element.localName;
190
- const published = tags.get(tag);
191
- if (published && !said.has(tag)) {
192
- const rendered = new Set([...shadow.querySelectorAll('[part]')].flatMap((node) => (node.getAttribute('part') ?? '').split(/\s+/).filter(Boolean)));
193
- const missing = [...rendered].filter((name) => !published.parts.has(name)).sort();
194
- if (missing.length > 0) {
195
- said.add(tag);
196
- findings.push({
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*(--[a-zA-Z0-9-]+)/g)) {
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 roots = (from, found, depth = 0) => {
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. */