@fusion.dev/helix-diagnostics 0.138.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 CHANGED
@@ -8,38 +8,140 @@ part a composed component never forwarded, two copies of the library on one page
8
8
  npm install --save-dev @fusion.dev/helix-diagnostics
9
9
  ```
10
10
 
11
+ ## Start it in your app
12
+
13
+ Call `initDiagnostics()` once, at your app's entry, before the app mounts:
14
+
11
15
  ```js
12
- import { diagnose, report, watch } from '@fusion.dev/helix-diagnostics'
13
- import { duplicateRegistrations } from '@fusion.dev/helix-core'
14
- import styled from '@fusion.dev/helix/custom-elements.json' with { type: 'json' }
15
- import headless from '@fusion.dev/helix-headless/custom-elements.json' with { type: 'json' }
16
-
17
- // Before the components load, so every element that renders is counted.
18
- const watcher = watch()
19
-
20
- // Later, once the page has rendered, and again after a route change.
21
- report(
22
- diagnose({
23
- duplicates: duplicateRegistrations,
24
- tiers: [
25
- { manifest: styled, name: '@fusion.dev/helix' },
26
- { manifest: headless, name: '@fusion.dev/helix-headless' },
27
- ],
28
- watcher,
29
- }),
30
- )
16
+ import { initDiagnostics } from '@fusion.dev/helix-diagnostics'
17
+
18
+ initDiagnostics({ environment: import.meta.env.MODE })
19
+ ```
20
+
21
+ It needs nothing from a framework, so the call is the same wherever Helix runs:
22
+
23
+ - `main.ts` in Vite, Vue, Svelte, or Solid;
24
+ - before `bootstrapApplication` in Angular, inside `ngZone.runOutsideAngular`
25
+ so its callbacks do not run change detection;
26
+ - `instrumentation-client.ts` in Next.js;
27
+ - a `<script type="module">` on a page with no bundler.
28
+
29
+ On a server, with no `document`, it does nothing, so it is safe in code that
30
+ server-side rendering also runs. A second call returns the first call's handle,
31
+ so React's strict mode and hot module replacement start nothing twice.
32
+
33
+ **What it does from there:**
34
+
35
+ - **Counts from the start**, every Helix element that renders, including ones
36
+ that go, such as a closed dialog.
37
+ - **Loads the lint data for the exact Helix version on the page**, from a public
38
+ npm CDN, about 18 KB compressed for each tier. Offline, or on a locked-down
39
+ network, pass it yourself:
40
+ `tiers: [{ lint }]` with `import lint from '@fusion.dev/helix/lint.json'`.
41
+ - **Runs itself**: after the page loads, after a navigation, and after the page
42
+ changes, once changes stop for a second, in an idle moment, and never sooner
43
+ than two seconds after the last run, nor sooner than twenty times its
44
+ duration. A page that never stops changing still runs within ten seconds.
45
+ Nothing runs in a hidden tab, or when nothing changed.
46
+ - **Prints each finding once per page**, in DevTools, at the levels your
47
+ `console` setting gives. The heading still counts every finding.
48
+ - **Shows the findings on the page**, in development. A badge in the corner
49
+ counts them and opens a list of each finding with its evidence. Hover over a
50
+ finding, or focus it, to outline the element it is about. The badge shows
51
+ only while the page has findings.
52
+ - **Hands each run to your app**: the `onReport` option, `subscribe()` on the
53
+ handle it returns, and a `helix-diagnostics` event on `document`.
54
+
55
+ **It never runs in production by accident.** With no `environment`, it runs only
56
+ on a local host: `localhost`, `127.0.0.1`, `[::1]`, `*.localhost`, and `*.test`.
57
+ Production runs only with `environment: 'production'` and
58
+ `environments: { production: { sample } }`, and there each run goes to your app
59
+ alone. A visitor sees nothing in the console or on the page.
60
+
61
+ **It never breaks your app.** It never throws. A setting that names nothing is
62
+ printed with the closest real one, and the handle does nothing. A run that
63
+ throws is printed, and three failures in a row stop it. A subscriber that
64
+ throws stops neither the others nor the console.
65
+
66
+ | Option | What it is |
67
+ | --- | --- |
68
+ | `environment` | `development`, `ci`, or `production`, as your build says it |
69
+ | `tiers` | Lint data or manifests to check against, where you pass them yourself |
70
+ | `cdn` | Where lint data loads from; `false` loads nothing |
71
+ | `page` | A function returning the page's path as your router names it, such as `/products/[id]` |
72
+ | `onReport` | Called with each run |
73
+ | `duplicates` | `duplicateRegistrations` from `@fusion.dev/helix-core` |
74
+ | `file` | Your project's `helix-diagnostics.json`, imported |
75
+ | Any setting below | `rules`, `console`, `sinks`, `send`, `detail`, `environments`, `project` |
76
+
77
+ The handle has `active`, `latest`, `run()`, `subscribe()`, and `stop()`. An
78
+ end-to-end test reaches it at `globalThis[Symbol.for('fusion.dev/helix/diagnostics')]`
79
+ to assert that a page has no findings.
80
+
81
+ The badge is one element, `helix-diagnostics-overlay`, with its own shadow root
82
+ and its own styles. Diagnostics leaves it out of everything it counts and reads.
83
+ It is never on a page in CI, so your screenshots never hold it. Turn it off in
84
+ development with `sinks: { overlay: false }`.
85
+
86
+ ## Print the findings in your terminal
87
+
88
+ A page cannot write to your terminal, so the Helix CLI has a command that
89
+ receives each run beside your dev server. Install the CLI, then start the
90
+ command in the project's directory:
91
+
92
+ ```sh
93
+ npm install --save-dev @fusion.dev/helix-cli
94
+ npx helix diagnostics
95
+ ```
96
+
97
+ Then turn the relay on where you call `initDiagnostics()`:
98
+
99
+ ```js
100
+ initDiagnostics({ environment: import.meta.env.MODE, sinks: { relay: true } })
101
+ ```
102
+
103
+ Each run that differs from the last one for its page is posted to
104
+ `http://127.0.0.1:7417`, and the command prints each finding the first time a
105
+ page has it. For a relay on another port, such as one started with
106
+ `--port 7500`, pass its address: `sinks: { relay: 'http://127.0.0.1:7500' }`.
107
+ The address is always on a local host.
108
+
109
+ - **It is off by default.** A browser prints a refused connection in DevTools
110
+ on every page load where nothing listens. With the relay on and the command
111
+ not running, one warning is printed in the console, and nothing is sent.
112
+ - **It serves your settings.** Where you pass no `file`, the page loads
113
+ `helix-diagnostics.json` from the directory the command runs in, so you do
114
+ not need to import it. Pass `--config <path>` for a file elsewhere. A file
115
+ that does not parse is not served.
116
+ - **It writes the CI file.** With `--out reports/helix-diagnostics.json`, it
117
+ keeps the latest run of every page there, in the shape `ciSink` writes.
118
+ - **It takes runs from pages on a local host only**: `localhost`, `127.0.0.1`,
119
+ `[::1]`, `*.localhost`, and `*.test`. It listens on `127.0.0.1`, so nothing
120
+ else on your network reaches it, and production pages never send to it.
121
+
122
+ ## Run it once, directly
123
+
124
+ `diagnose()` is one run, which `initDiagnostics()` makes for you. Call it
125
+ yourself in a test, a script, or anywhere you want a single report:
126
+
127
+ ```js
128
+ import { diagnose, report } from '@fusion.dev/helix-diagnostics'
129
+ import styled from '@fusion.dev/helix/lint.json' with { type: 'json' }
130
+ import headless from '@fusion.dev/helix-headless/lint.json' with { type: 'json' }
131
+
132
+ report(diagnose({ tiers: [{ lint: styled }, { lint: headless }] }))
31
133
  ```
32
134
 
33
- `diagnose` needs at least one manifest and says so instead of defaulting: with
34
- none, every part name is unknown and every finding would be noise. Pass both
35
- tiers when both are installed, since a part the headless tier publishes and the
36
- styled tier never forwarded is only visible with both. `report` is the console
37
- sink; the `Report` it returns is the same object, so you can send it somewhere
38
- else as well.
135
+ `diagnose` needs at least one tier and says so instead of defaulting: with none,
136
+ every part name is unknown and every finding would be noise. It takes lint data
137
+ or a full `custom-elements.json`. Pass both tiers when both are installed, since
138
+ a part the headless tier publishes and the styled tier never forwarded is only
139
+ visible with both. `report` is the console sink; the `Report` it returns is the
140
+ same object, so you can send it somewhere else as well.
39
141
 
40
- None of these produces an error anywhere. They are detectable only against the
41
- custom elements manifest, which every Helix package already publishes, so the
42
- diagnostic cannot drift from what is true.
142
+ None of these faults produces an error anywhere. They are detectable only against
143
+ the manifest, which every Helix package publishes, so the diagnostic cannot
144
+ drift from what is true.
43
145
 
44
146
  ## What a report holds
45
147
 
@@ -148,6 +250,8 @@ diagnose({
148
250
  | `console.findings` | `error`, `warn`, `info`, or `off`: the lowest severity printed | `info` |
149
251
  | `console.coverage` | `info` or `off` | `info` |
150
252
  | `console.usage` | `debug` or `off` | `debug` |
253
+ | `sinks.overlay` | `true` or `false`, for the badge in development | `true` |
254
+ | `sinks.relay` | `true`, `false`, or an address on a local host, such as `http://127.0.0.1:7417` | `false` |
151
255
  | `send.<category>` | `true` or `false`, for `findings`, `usage`, `coverage`, `configuration`, `runtime`, `performance`, and `assist` | all on but `performance` |
152
256
  | `detail.evidence` | `full`, `selector`, or `none` | `full` |
153
257
  | `detail.hookValues` | `true` or `false` | `true` |
@@ -155,13 +259,14 @@ diagnose({
155
259
  | `environments.<name>` | `true` or `false`; `production` takes `{ sample }` from 0 to 1 | development and CI on |
156
260
  | `project` | `key`, `name`, and `tags` | none |
157
261
 
158
- `send`, `detail`, and `environments` decide what a sending sink sends, and
159
- nothing here sends yet: they are recorded in every report, so a dashboard
160
- knows what a project withheld. A setting or a value that does not exist
161
- throws, and names the closest that does.
262
+ `send`, `detail`, and `environments` decide what the diagnostics service is
263
+ sent, and the service is not built yet. They are recorded in every report, so a
264
+ dashboard knows what a project withheld. The relay runs on your own machine and
265
+ receives each run whole. A setting or a value that does not exist throws, and
266
+ names the closest that does.
162
267
 
163
- Development only. It walks every stylesheet in the page, and it sends nothing
164
- anywhere.
268
+ **What the page requests**: the lint data, from `cdn`, once per page load, and
269
+ the relay, where `sinks.relay` is on. Nothing else leaves the page.
165
270
 
166
271
  ## In CI
167
272
 
package/dist/analyze.d.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import type { Answered, Located, Readers } from './collect.js';
20
20
  import { type Published } from './manifest.js';
21
+ import { type Page } from './page.js';
21
22
  import type { Raw } from './report.js';
22
23
  /** Every `helix-*` tag a selector mentions, in source order. */
23
24
  export declare const targets: (selector: string) => string[];
@@ -26,6 +27,16 @@ export declare const parts: (selector: string) => Array<{
26
27
  names: string[];
27
28
  tag: string;
28
29
  }>;
30
+ /**
31
+ * A selector made matchable now: pseudo-elements and the pseudo-classes of a
32
+ * moment (hover, focus) taken off, since a hook set on `.card:hover` reaches
33
+ * what `.card` matches. `null` where it cannot be judged from the page, as
34
+ * for `:host`, which only a component's own root matches.
35
+ *
36
+ * The longer pseudo-classes come first, so `:focus-visible` is taken whole
37
+ * and never read as `:focus` followed by `-visible`.
38
+ */
39
+ export declare const matchable: (selector: string) => string | null;
29
40
  /**
30
41
  * The findings in one page's rules.
31
42
  *
@@ -51,7 +62,7 @@ export declare const analyze: (rules: Located[], tags: Map<string, Published>, f
51
62
  * version old enough to predate the warning. That case reports what it can see,
52
63
  * which is that a live element is not an instance of its own registered class.
53
64
  */
54
- export declare const registrations: (from?: Document, known?: () => string[]) => Raw[];
65
+ export declare const registrations: (from?: Document, known?: () => string[], page?: Page) => Raw[];
55
66
  /**
56
67
  * Evidence that the manifest is from another version of Helix than the page
57
68
  * runs: an element rendering a part its manifest entry does not list.
@@ -67,7 +78,7 @@ export declare const registrations: (from?: Document, known?: () => string[]) =>
67
78
  * between versions reads as dead in every rule that styles it, and a hundred
68
79
  * findings that are each the same mistake are worse than one that names it.
69
80
  */
70
- export declare const skew: (tags: Map<string, Published>, from: Document) => Raw[];
81
+ export declare const skew: (tags: Map<string, Published>, from: Document, page?: Page) => Raw[];
71
82
  /**
72
83
  * A manifest from another version, read from the numbers where both sides
73
84
  * have one: the version a manifest describes, against the versions of core
@@ -84,4 +95,4 @@ export declare const versionSkew: (manifests: Record<string, string>, seen: stri
84
95
  * Helix's own markup. One finding per tag, attribute, and value, however many
85
96
  * elements carry it.
86
97
  */
87
- export declare const markup: (tags: Map<string, Published>, from: Document) => Raw[];
98
+ export declare const markup: (tags: Map<string, Published>, from: Document, page?: Page) => Raw[];
package/dist/analyze.js CHANGED
@@ -1,4 +1,5 @@
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
5
  for (const match of selector.matchAll(/(?<![\w.#-])helix-[a-z0-9]+(?:-[a-z0-9]+)*/g)) {
@@ -16,7 +17,7 @@ export const parts = (selector) => {
16
17
  }
17
18
  return found;
18
19
  };
19
- const matchable = (selector) => {
20
+ export const matchable = (selector) => {
20
21
  if (selector.includes(':host')) {
21
22
  return null;
22
23
  }
@@ -61,7 +62,7 @@ const reachesAnswer = (host, property, answering, readers, depth = 0) => {
61
62
  }
62
63
  return false;
63
64
  };
64
- const loose = (located, answerers, from, used, onPage, listedBy, readers) => {
65
+ const loose = (located, answerers, from, used, onPage, listedBy, readers, suggested) => {
65
66
  const findings = [];
66
67
  if (located.scope instanceof ShadowRoot && located.scope.host.localName.startsWith('helix-')) {
67
68
  return findings;
@@ -78,7 +79,11 @@ const loose = (located, answerers, from, used, onPage, listedBy, readers) => {
78
79
  if (used.has(property)) {
79
80
  continue;
80
81
  }
81
- const suggestion = nearest(property, answerers.keys(), HOOK_DISTANCE);
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
+ }
82
87
  if (!suggestion) {
83
88
  continue;
84
89
  }
@@ -152,6 +157,7 @@ export const analyze = (rules, tags, from, answered = new Map(), used = new Set(
152
157
  }
153
158
  }
154
159
  }
160
+ const suggested = new Map();
155
161
  const present = new Map();
156
162
  const onPage = (property, answering) => {
157
163
  let found = present.get(property);
@@ -213,7 +219,7 @@ export const analyze = (rules, tags, from, answered = new Map(), used = new Set(
213
219
  const mentioned = targets(selector).map((tag) => [tag, tags.get(tag)]);
214
220
  const known = mentioned.filter(([, element]) => element);
215
221
  if (known.length === 0) {
216
- findings.push(...loose(located, answeredBy, from, used, onPage, listedBy, readers));
222
+ findings.push(...loose(located, answeredBy, from, used, onPage, listedBy, readers, suggested));
217
223
  continue;
218
224
  }
219
225
  for (const property of Array.from(located.rule.style ?? [])) {
@@ -267,7 +273,7 @@ export const analyze = (rules, tags, from, answered = new Map(), used = new Set(
267
273
  }
268
274
  return findings;
269
275
  };
270
- export const registrations = (from = document, known = () => []) => {
276
+ export const registrations = (from = document, known = () => [], page = pageOf(from)) => {
271
277
  const findings = [];
272
278
  const registry = globalThis.customElements;
273
279
  const said = new Set();
@@ -289,7 +295,7 @@ export const registrations = (from = document, known = () => []) => {
289
295
  if (!registry) {
290
296
  return findings;
291
297
  }
292
- for (const element of from.querySelectorAll('*')) {
298
+ for (const element of page.elements) {
293
299
  const tag = element.localName;
294
300
  if (!tag.startsWith('helix-') || said.has(tag)) {
295
301
  continue;
@@ -301,37 +307,30 @@ export const registrations = (from = document, known = () => []) => {
301
307
  }
302
308
  return findings;
303
309
  };
304
- export const skew = (tags, from) => {
310
+ export const skew = (tags, from, page = pageOf(from)) => {
305
311
  const findings = [];
306
312
  const said = new Set();
307
- const visit = (root, depth) => {
308
- if (depth > 32) {
309
- return;
313
+ for (const element of page.elements) {
314
+ const shadow = element.shadowRoot;
315
+ if (!shadow) {
316
+ continue;
310
317
  }
311
- for (const element of root.querySelectorAll('*')) {
312
- const shadow = element.shadowRoot;
313
- if (!shadow) {
314
- continue;
315
- }
316
- const tag = element.localName;
317
- const published = tags.get(tag);
318
- if (published && !said.has(tag)) {
319
- const rendered = new Set([...shadow.querySelectorAll('[part]')].flatMap((node) => (node.getAttribute('part') ?? '').split(/\s+/).filter(Boolean)));
320
- const missing = [...rendered].filter((name) => !published.parts.has(name)).sort();
321
- if (missing.length > 0) {
322
- said.add(tag);
323
- findings.push({
324
- kind: 'manifest-skew',
325
- 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.`,
326
- subject: tag,
327
- tag,
328
- });
329
- }
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
+ });
330
331
  }
331
- visit(shadow, depth + 1);
332
332
  }
333
- };
334
- visit(from, 0);
333
+ }
335
334
  return findings;
336
335
  };
337
336
  export const versionSkew = (manifests, seen) => seen.length === 0
@@ -380,66 +379,57 @@ const GLOBAL = new Set([
380
379
  ]);
381
380
  const PREFIXES = ['aria-', 'data-', 'on', '_ngcontent-', '_nghost-', 'ng-', 'v-', 'x-', ':', '@'];
382
381
  const HOOK_DISTANCE = 2;
383
- export const markup = (tags, from) => {
382
+ export const markup = (tags, from, page = pageOf(from)) => {
384
383
  const found = new Map();
385
- const visit = (root, depth) => {
386
- if (depth > 32) {
387
- return;
388
- }
389
- for (const element of root.querySelectorAll('*')) {
390
- const host = element.getRootNode();
391
- const composed = host instanceof ShadowRoot && host.host.localName.startsWith('helix-');
392
- const published = composed ? undefined : tags.get(element.localName);
393
- if (published) {
394
- for (const { name, value } of Array.from(element.attributes)) {
395
- if (GLOBAL.has(name) ||
396
- PREFIXES.some((prefix) => name.startsWith(prefix)) ||
397
- published.written.has(name)) {
398
- continue;
399
- }
400
- const tag = element.localName;
401
- const allows = published.attributes.get(name);
402
- let raw;
403
- if (allows === undefined) {
404
- const suggestion = nearest(name, published.attributes.keys());
405
- raw = {
406
- expected: suggestion,
407
- kind: 'unknown-attribute',
408
- message: suggestion
409
- ? `\`${tag}\` takes no attribute \`${name}\`, so it does nothing. The closest it takes is \`${suggestion}\`.`
410
- : `\`${tag}\` takes no attribute \`${name}\`, so it does nothing.`,
411
- subject: `<${tag} ${name}>`,
412
- tag,
413
- };
414
- }
415
- else if (allows && value !== '' && !allows.has(value)) {
416
- const suggestion = nearest(value, allows);
417
- raw = {
418
- expected: suggestion,
419
- kind: 'invalid-value',
420
- message: `\`${tag}\`'s \`${name}\` takes ${[...allows].map((one) => `\`${one}\``).join(', ')}, so \`${value}\` falls back to the default.${suggestion ? ` The closest is \`${suggestion}\`.` : ''}`,
421
- subject: `<${tag} ${name}="${value}">`,
422
- tag,
423
- };
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;
424
425
  }
425
- if (raw) {
426
- const key = `${raw.kind} ${raw.subject}`;
427
- const seen = found.get(key);
428
- if (seen) {
429
- seen.count += 1;
430
- }
431
- else {
432
- found.set(key, { ...raw, count: 1 });
433
- }
426
+ else {
427
+ found.set(key, { ...raw, count: 1 });
434
428
  }
435
429
  }
436
430
  }
437
- if (element.shadowRoot) {
438
- visit(element.shadowRoot, depth + 1);
439
- }
440
431
  }
441
- };
442
- visit(from, 0);
432
+ }
443
433
  return [...found.values()].map(({ count, ...raw }) => ({
444
434
  ...raw,
445
435
  message: count > 1 ? `${raw.message} On ${count} elements.` : raw.message,
package/dist/collect.d.ts CHANGED
@@ -15,6 +15,7 @@
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 {
@@ -67,7 +68,7 @@ export type Readers = Map<string, Array<{
67
68
  * hundred passes over rules already read. A `Set` keyed on the sheet object
68
69
  * fixes the number and the cost together.
69
70
  */
70
- export declare const collect: (from?: Document) => {
71
+ export declare const collect: (from?: Document, page?: Page) => {
71
72
  answered: Answered;
72
73
  coverage: Coverage;
73
74
  readers: Readers;
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;
@@ -62,19 +63,7 @@ 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();
@@ -119,8 +108,7 @@ export const collect = (from = document) => {
119
108
  for (const sheet of from.adoptedStyleSheets ?? []) {
120
109
  take(sheet);
121
110
  }
122
- const shadows = [];
123
- roots(from, shadows);
111
+ const shadows = page.roots.filter((root) => root.host.localName !== OVERLAY);
124
112
  for (const root of shadows) {
125
113
  for (const sheet of Array.from(root.styleSheets ?? [])) {
126
114
  take(sheet, root.host);
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. */
package/dist/config.js CHANGED
@@ -14,6 +14,19 @@ export const DEFAULTS = {
14
14
  runtime: true,
15
15
  usage: true,
16
16
  },
17
+ sinks: { overlay: true, relay: false },
18
+ };
19
+ export const LOCAL = /^(localhost|127\.0\.0\.1|\[::1\]|.+\.localhost|.+\.test)$/;
20
+ const local = (address) => {
21
+ if (!/^https?:\/\/[^/?#@]+$/.test(address)) {
22
+ return false;
23
+ }
24
+ try {
25
+ return LOCAL.test(new URL(address).hostname);
26
+ }
27
+ catch {
28
+ return false;
29
+ }
17
30
  };
18
31
  const ALLOWED = {
19
32
  console: {
@@ -29,6 +42,7 @@ const ALLOWED = {
29
42
  environments: { ci: [true, false], development: [true, false], production: 'sample' },
30
43
  project: { key: 'string', name: 'string', tags: 'tags' },
31
44
  send: Object.fromEntries(CATEGORIES.map((category) => [category, [true, false]])),
45
+ sinks: { overlay: [true, false], relay: 'address' },
32
46
  };
33
47
  const shown = (value) => typeof value === 'string' ? `\`${value}\`` : String(value);
34
48
  export const checkConfig = (config, layer) => {
@@ -60,22 +74,26 @@ export const checkConfig = (config, layer) => {
60
74
  ? typeof one === 'string'
61
75
  : takes === 'tags'
62
76
  ? Array.isArray(one) && one.every((tag) => typeof tag === 'string')
63
- : takes === 'sample'
64
- ? one === false ||
65
- (typeof one === 'object' &&
66
- one !== null &&
67
- typeof one.sample === 'number' &&
68
- one.sample >= 0 &&
69
- one.sample <= 1)
70
- : takes.includes(one);
77
+ : takes === 'address'
78
+ ? typeof one === 'boolean' || (typeof one === 'string' && local(one))
79
+ : takes === 'sample'
80
+ ? one === false ||
81
+ (typeof one === 'object' &&
82
+ one !== null &&
83
+ typeof one.sample === 'number' &&
84
+ one.sample >= 0 &&
85
+ one.sample <= 1)
86
+ : takes.includes(one);
71
87
  if (!fits) {
72
88
  const expected = takes === 'string'
73
89
  ? 'a string'
74
90
  : takes === 'tags'
75
91
  ? 'a list of strings'
76
- : takes === 'sample'
77
- ? '`false`, or `{ sample }` from 0 to 1'
78
- : takes.map(shown).join(', ');
92
+ : takes === 'address'
93
+ ? '`true`, `false`, or an address on a local host, such as `http://127.0.0.1:7417`'
94
+ : takes === 'sample'
95
+ ? '`false`, or `{ sample }` from 0 to 1'
96
+ : takes.map(shown).join(', ');
79
97
  throw new Error(`The ${layer} sets \`${section}.${name}\` to \`${JSON.stringify(one)}\`. It takes ${expected}.`);
80
98
  }
81
99
  }
@@ -107,6 +125,7 @@ export const resolveConfig = (file, call) => {
107
125
  console: merged('console', { ...DEFAULTS.console }),
108
126
  detail: merged('detail', { ...DEFAULTS.detail }),
109
127
  environments: merged('environments', { ...DEFAULTS.environments }),
128
+ sinks: merged('sinks', { ...DEFAULTS.sinks }),
110
129
  },
111
130
  project: {
112
131
  ...(project.name === undefined ? {} : { name: project.name }),