@fusion.dev/helix-diagnostics 0.135.0 → 0.137.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 +138 -12
- package/dist/analyze.d.ts +33 -3
- package/dist/analyze.js +50 -2
- package/dist/config.d.ts +71 -0
- package/dist/config.js +126 -0
- package/dist/console.d.ts +28 -5
- package/dist/console.js +60 -21
- package/dist/identity.d.ts +34 -0
- package/dist/identity.js +42 -0
- package/dist/index.d.ts +46 -10
- package/dist/index.js +68 -8
- package/dist/manifest.d.ts +15 -0
- package/dist/manifest.js +12 -1
- package/dist/report.d.ts +194 -2
- package/dist/report.js +17 -1
- package/dist/rules.d.ts +34 -0
- package/dist/rules.js +76 -0
- package/dist/usage.d.ts +50 -0
- package/dist/usage.js +147 -0
- package/dist/version.d.ts +9 -0
- package/dist/version.js +1 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -9,28 +9,154 @@ npm install --save-dev @fusion.dev/helix-diagnostics
|
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
```js
|
|
12
|
-
import { diagnose, report } from '@fusion.dev/helix-diagnostics'
|
|
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' }
|
|
13
15
|
import headless from '@fusion.dev/helix-headless/custom-elements.json' with { type: 'json' }
|
|
14
16
|
|
|
15
|
-
|
|
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
31
|
```
|
|
17
32
|
|
|
18
|
-
`diagnose` needs at least one manifest and says so
|
|
19
|
-
none, every part name is unknown and every finding would be noise.
|
|
20
|
-
|
|
21
|
-
|
|
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.
|
|
22
39
|
|
|
23
40
|
None of these produces an error anywhere. They are detectable only against the
|
|
24
41
|
custom elements manifest, which every Helix package already publishes, so the
|
|
25
42
|
diagnostic cannot drift from what is true.
|
|
26
43
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
44
|
+
## What a report holds
|
|
45
|
+
|
|
46
|
+
A report is one run, in one shape for every sink, versioned by `schema`:
|
|
47
|
+
|
|
48
|
+
- **`run`**: a new `id`, when it began (`at`), how long it took, where it ran
|
|
49
|
+
(`environment`, as you declare it), and the `page`, as a pattern by default:
|
|
50
|
+
`/orders/1042` reads `/orders/:id`.
|
|
51
|
+
- **`project`**: the name and tags your configuration gives. The key is never
|
|
52
|
+
written into a report.
|
|
53
|
+
- **`versions`**: the collector's and the rules', the Helix versions loaded on
|
|
54
|
+
the page (two means two copies), and the version each manifest describes.
|
|
55
|
+
- **`coverage`**: how many stylesheets and shadow roots were read, and which
|
|
56
|
+
sheets could not be. A cross-origin stylesheet throws on `.cssRules`, so a run
|
|
57
|
+
that read three sheets of nineteen returns the same empty findings as one that
|
|
58
|
+
read all nineteen. Read `coverage` before you believe `findings`.
|
|
59
|
+
- **`findings`**: each with its kind, a severity, the place that set the
|
|
60
|
+
severity, the tag and its family, a `category` (`styling`, `markup`,
|
|
61
|
+
`integrity`, or `guideline`), an `owner` (`consumer`, or `helix` for a fault
|
|
62
|
+
in the library), a `fingerprint` that stays the same from one run to the next
|
|
63
|
+
for the same fault, and the rule's own text as evidence.
|
|
64
|
+
- **`usage`**: the Helix elements on the page by tag, the axes set on them
|
|
65
|
+
(`emphasis`, `material`, `separation`, and `tone`), the named slots filled,
|
|
66
|
+
the elements Helix renders inside other Helix elements, and the parts and
|
|
67
|
+
hooks your stylesheets use, with the values the hooks are set to. With a
|
|
68
|
+
watcher it counts every element that rendered since `watch()` began,
|
|
69
|
+
including ones since removed, such as a closed dialog. Without one it counts
|
|
70
|
+
the elements on the page at the moment of the run. Elements named `helix-*`
|
|
71
|
+
that no manifest lists, such as your own, are counted apart as `unlisted`.
|
|
72
|
+
- **`configuration`**: the settings the run was made under, each override in
|
|
73
|
+
effect, and how many findings each override turned off, by kind.
|
|
74
|
+
- **`withheld`**: the categories your configuration turned off for sending,
|
|
75
|
+
and which place turned each off.
|
|
76
|
+
|
|
77
|
+
## What the console shows
|
|
78
|
+
|
|
79
|
+
`report` writes one group per run, headed by the counts. It opens when there
|
|
80
|
+
is something to act on, and collapses to one line when the run is clean. Each
|
|
81
|
+
kind of entry goes to its own console method, so the browser's level filter
|
|
82
|
+
works on it:
|
|
83
|
+
|
|
84
|
+
| Entry | Console method |
|
|
85
|
+
| --- | --- |
|
|
86
|
+
| A finding at `error` | `console.error` |
|
|
87
|
+
| A finding at `warn` | `console.warn` |
|
|
88
|
+
| A finding at `info` | `console.info` |
|
|
89
|
+
| Stylesheets that could not be read | `console.warn` |
|
|
90
|
+
| Coverage, and findings turned off | `console.info` |
|
|
91
|
+
| Usage | `console.debug`, hidden at the default level |
|
|
92
|
+
|
|
93
|
+
## Finding kinds
|
|
94
|
+
|
|
95
|
+
| Kind | Severity | Means |
|
|
96
|
+
| --- | --- | --- |
|
|
97
|
+
| `dead-part` | error | A `::part()` names a part the element does not publish |
|
|
98
|
+
| `dead-hook` | error | A custom property is set where nothing answers it |
|
|
99
|
+
| `unforwarded-part` | warn | The headless tier publishes the part and the styled tier does not forward it, which is a gap in Helix |
|
|
100
|
+
| `unknown-tag` | error | A rule names a tag no manifest lists |
|
|
101
|
+
| `duplicate-registration` | error | Two copies of Helix are on the page |
|
|
102
|
+
| `manifest-skew` | error | An element renders a part its manifest does not list, so the manifest is from another version. It replaces every other finding, since each would be judged against the wrong version. Turned off, the page is judged as usual |
|
|
103
|
+
|
|
104
|
+
## Configure it
|
|
105
|
+
|
|
106
|
+
Every setting can be given in three places, and a later place wins, field by
|
|
107
|
+
field: the defaults this package ships, your project's file, and the call.
|
|
108
|
+
The page cannot read files, so import your file and pass it as `file`:
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
import project from './helix-diagnostics.json' with { type: 'json' }
|
|
112
|
+
|
|
113
|
+
diagnose({
|
|
114
|
+
environment: 'development',
|
|
115
|
+
file: project,
|
|
116
|
+
rules: { 'unforwarded-part': 'off' },
|
|
117
|
+
tiers,
|
|
118
|
+
})
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
```json
|
|
122
|
+
{
|
|
123
|
+
"project": { "name": "storefront", "tags": ["web"] },
|
|
124
|
+
"rules": { "dead-hook": { "severity": "warn", "tags": ["helix-card"] } },
|
|
125
|
+
"console": { "findings": "warn", "usage": "off" },
|
|
126
|
+
"send": { "performance": false },
|
|
127
|
+
"detail": { "evidence": "full", "hookValues": true, "pages": "patterns" },
|
|
128
|
+
"environments": { "development": true, "ci": true, "production": false }
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
| Setting | Takes | Default |
|
|
133
|
+
| --- | --- | --- |
|
|
134
|
+
| `rules.<kind>` | `error`, `warn`, `info`, `off`, or `{ severity, tags }` | see Finding kinds |
|
|
135
|
+
| `console.findings` | `error`, `warn`, `info`, or `off`: the lowest severity printed | `info` |
|
|
136
|
+
| `console.coverage` | `info` or `off` | `info` |
|
|
137
|
+
| `console.usage` | `debug` or `off` | `debug` |
|
|
138
|
+
| `send.<category>` | `true` or `false`, for `findings`, `usage`, `coverage`, `configuration`, `runtime`, `performance`, and `assist` | all on but `performance` |
|
|
139
|
+
| `detail.evidence` | `full`, `selector`, or `none` | `full` |
|
|
140
|
+
| `detail.hookValues` | `true` or `false` | `true` |
|
|
141
|
+
| `detail.pages` | `patterns`, `full` (the path, never the query), or `none` | `patterns` |
|
|
142
|
+
| `environments.<name>` | `true` or `false`; `production` takes `{ sample }` from 0 to 1 | development and CI on |
|
|
143
|
+
| `project` | `key`, `name`, and `tags` | none |
|
|
144
|
+
|
|
145
|
+
`send`, `detail`, and `environments` decide what a sending sink sends, and
|
|
146
|
+
nothing here sends yet: they are recorded in every report, so a dashboard
|
|
147
|
+
knows what a project withheld. A setting or a value that does not exist
|
|
148
|
+
throws, and names the closest that does.
|
|
149
|
+
|
|
150
|
+
Development only. It walks every stylesheet in the page, and it sends nothing
|
|
151
|
+
anywhere.
|
|
152
|
+
|
|
153
|
+
## Versions
|
|
31
154
|
|
|
32
|
-
|
|
33
|
-
|
|
155
|
+
`@fusion.dev/helix-core` lists its version on the page as it loads, and every
|
|
156
|
+
manifest built from 0.137 names the package and version it describes. Where
|
|
157
|
+
both sides say, a manifest from another version is one `manifest-skew`
|
|
158
|
+
finding in place of all others. Where either is silent, the check falls back
|
|
159
|
+
to a part rendering that the manifest does not list.
|
|
34
160
|
|
|
35
161
|
[Documentation](https://fusion.dev/helix)
|
|
36
162
|
|
package/dist/analyze.d.ts
CHANGED
|
@@ -18,7 +18,14 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import type { Answered, Located } from './collect.js';
|
|
20
20
|
import { type Published } from './manifest.js';
|
|
21
|
-
import type {
|
|
21
|
+
import type { Raw } from './report.js';
|
|
22
|
+
/** Every `helix-*` tag a selector mentions, in source order. */
|
|
23
|
+
export declare const targets: (selector: string) => string[];
|
|
24
|
+
/** Every `::part(a b)` in a selector, with the tag it hangs off. */
|
|
25
|
+
export declare const parts: (selector: string) => Array<{
|
|
26
|
+
names: string[];
|
|
27
|
+
tag: string;
|
|
28
|
+
}>;
|
|
22
29
|
/**
|
|
23
30
|
* The findings in one page's rules.
|
|
24
31
|
*
|
|
@@ -26,7 +33,7 @@ import type { Finding } from './report.js';
|
|
|
26
33
|
* unforwarded part is phrased: the part exists, another tier publishes it, and
|
|
27
34
|
* the one being styled did not forward it.
|
|
28
35
|
*/
|
|
29
|
-
export declare const analyze: (rules: Located[], tags: Map<string, Published>, from?: Document, answered?: Answered) =>
|
|
36
|
+
export declare const analyze: (rules: Located[], tags: Map<string, Published>, from?: Document, answered?: Answered) => Raw[];
|
|
30
37
|
/**
|
|
31
38
|
* Two copies of Helix on one page, which is not a CSS fault and is the most
|
|
32
39
|
* expensive one on the list.
|
|
@@ -44,4 +51,27 @@ export declare const analyze: (rules: Located[], tags: Map<string, Published>, f
|
|
|
44
51
|
* version old enough to predate the warning. That case reports what it can see,
|
|
45
52
|
* which is that a live element is not an instance of its own registered class.
|
|
46
53
|
*/
|
|
47
|
-
export declare const registrations: (from?: Document, known?: () => string[]) =>
|
|
54
|
+
export declare const registrations: (from?: Document, known?: () => string[]) => Raw[];
|
|
55
|
+
/**
|
|
56
|
+
* Evidence that the manifest is from another version of Helix than the page
|
|
57
|
+
* runs: an element rendering a part its manifest entry does not list.
|
|
58
|
+
*
|
|
59
|
+
* **Read from what the elements render, because nothing records a version.**
|
|
60
|
+
* Neither the manifest nor the running code carries one. What a matching
|
|
61
|
+
* manifest guarantees is that every part an element renders is listed, since
|
|
62
|
+
* both are built from the same source, so a rendered part the manifest lacks
|
|
63
|
+
* means the two were built from different source. The reverse proves nothing:
|
|
64
|
+
* an element renders some parts only in some states.
|
|
65
|
+
*
|
|
66
|
+
* **One finding replaces all the others** (see `diagnose`). A part renamed
|
|
67
|
+
* between versions reads as dead in every rule that styles it, and a hundred
|
|
68
|
+
* findings that are each the same mistake are worse than one that names it.
|
|
69
|
+
*/
|
|
70
|
+
export declare const skew: (tags: Map<string, Published>, from: Document) => Raw[];
|
|
71
|
+
/**
|
|
72
|
+
* A manifest from another version, read from the numbers where both sides
|
|
73
|
+
* have one: the version a manifest describes, against the versions of core
|
|
74
|
+
* loaded on the page. Definite where `skew` above infers, and the check
|
|
75
|
+
* `diagnose` makes first.
|
|
76
|
+
*/
|
|
77
|
+
export declare const versionSkew: (manifests: Record<string, string>, seen: string[]) => Raw[];
|
package/dist/analyze.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { nearest } from './manifest.js';
|
|
2
|
-
const targets = (selector) => {
|
|
2
|
+
export const targets = (selector) => {
|
|
3
3
|
const found = new Set();
|
|
4
4
|
for (const match of selector.matchAll(/\bhelix-[a-z0-9]+(?:-[a-z0-9]+)*/g)) {
|
|
5
5
|
found.add(match[0]);
|
|
6
6
|
}
|
|
7
7
|
return [...found];
|
|
8
8
|
};
|
|
9
|
-
const parts = (selector) => {
|
|
9
|
+
export const parts = (selector) => {
|
|
10
10
|
const found = [];
|
|
11
11
|
for (const match of selector.matchAll(/(helix-[a-z0-9-]+)((?:[^\s>+~,]*))::part\(\s*([^)]*?)\s*\)/g)) {
|
|
12
12
|
const names = (match[3] ?? '').split(/\s+/).filter(Boolean);
|
|
@@ -55,6 +55,7 @@ export const analyze = (rules, tags, from, answered = new Map()) => {
|
|
|
55
55
|
message: `\`${tag}\` is in no manifest passed to the collector. Either the tag is misspelled, or the manifest is from a different version of Helix than the page.`,
|
|
56
56
|
origin: origin(located),
|
|
57
57
|
subject: tag,
|
|
58
|
+
tag,
|
|
58
59
|
});
|
|
59
60
|
continue;
|
|
60
61
|
}
|
|
@@ -71,6 +72,7 @@ export const analyze = (rules, tags, from, answered = new Map()) => {
|
|
|
71
72
|
message: `\`${tag}\` does not publish \`${part}\`, though \`${elsewhere[0]}\` does. The rule matches nothing until the part is forwarded, which is a gap in Helix rather than in this CSS.`,
|
|
72
73
|
origin: origin(located),
|
|
73
74
|
subject: `${tag}::part(${part})`,
|
|
75
|
+
tag,
|
|
74
76
|
});
|
|
75
77
|
continue;
|
|
76
78
|
}
|
|
@@ -83,6 +85,7 @@ export const analyze = (rules, tags, from, answered = new Map()) => {
|
|
|
83
85
|
: `\`${tag}\` publishes no part named \`${part}\`, so this rule matches nothing. Published: ${[...element.parts.keys()].sort().join(', ') || 'none'}.`,
|
|
84
86
|
origin: origin(located),
|
|
85
87
|
subject: `${tag}::part(${part})`,
|
|
88
|
+
tag,
|
|
86
89
|
});
|
|
87
90
|
}
|
|
88
91
|
}
|
|
@@ -131,6 +134,7 @@ export const analyze = (rules, tags, from, answered = new Map()) => {
|
|
|
131
134
|
: `\`${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.`,
|
|
132
135
|
origin: origin(located),
|
|
133
136
|
subject: `${selector} { ${property} }`,
|
|
137
|
+
tag,
|
|
134
138
|
});
|
|
135
139
|
}
|
|
136
140
|
}
|
|
@@ -149,6 +153,7 @@ export const registrations = (from = document, known = () => []) => {
|
|
|
149
153
|
kind: 'duplicate-registration',
|
|
150
154
|
message: `\`${tag}\` is registered twice by different classes, so this page holds two copies of Helix (${how}). The first registration keeps the tag: every element of that name is built from it whatever the second copy exports, which makes \`instanceof\` against the second copy false and leaves its fixes out of the DOM. Deduplicate the dependency.`,
|
|
151
155
|
subject: tag,
|
|
156
|
+
tag,
|
|
152
157
|
});
|
|
153
158
|
};
|
|
154
159
|
for (const tag of known()) {
|
|
@@ -169,3 +174,46 @@ export const registrations = (from = document, known = () => []) => {
|
|
|
169
174
|
}
|
|
170
175
|
return findings;
|
|
171
176
|
};
|
|
177
|
+
export const skew = (tags, from) => {
|
|
178
|
+
const findings = [];
|
|
179
|
+
const said = new Set();
|
|
180
|
+
const visit = (root, depth) => {
|
|
181
|
+
if (depth > 32) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
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
|
+
}
|
|
203
|
+
}
|
|
204
|
+
visit(shadow, depth + 1);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
visit(from, 0);
|
|
208
|
+
return findings;
|
|
209
|
+
};
|
|
210
|
+
export const versionSkew = (manifests, seen) => seen.length === 0
|
|
211
|
+
? []
|
|
212
|
+
: Object.entries(manifests)
|
|
213
|
+
.filter(([, version]) => !seen.includes(version))
|
|
214
|
+
.map(([name, version]) => ({
|
|
215
|
+
kind: 'manifest-skew',
|
|
216
|
+
message: `The manifest passed in for \`${name}\` describes ${version}, and the page runs Helix ${seen.join(' and ')}. Every other finding would be judged against the wrong version, so none are reported. Pass the \`custom-elements.json\` from the installed packages.`,
|
|
217
|
+
subject: `${name}@${version}`,
|
|
218
|
+
tag: '',
|
|
219
|
+
}));
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type Category, type Configuration, type Layer, type Report } from './report.js';
|
|
2
|
+
import { type Rules } from './rules.js';
|
|
3
|
+
/** Everything a project can set. Every field is optional; see `DEFAULTS`. */
|
|
4
|
+
export interface Config {
|
|
5
|
+
/** The lowest level each category prints at in the console, or `off`. */
|
|
6
|
+
console?: Partial<Configuration['console']>;
|
|
7
|
+
/** How much detail goes with what is sent. */
|
|
8
|
+
detail?: Partial<Configuration['detail']>;
|
|
9
|
+
/** Where sending happens. Production takes `{ sample }`, from 0 to 1. */
|
|
10
|
+
environments?: Partial<Configuration['environments']>;
|
|
11
|
+
/**
|
|
12
|
+
* The project: its key, which opts it in to sending, and its own name and
|
|
13
|
+
* labels. `tags` are free, and `fusion` marks Fusion's own projects.
|
|
14
|
+
*/
|
|
15
|
+
project?: {
|
|
16
|
+
key?: string;
|
|
17
|
+
name?: string;
|
|
18
|
+
tags?: string[];
|
|
19
|
+
};
|
|
20
|
+
/** Severities by kind; see `Rules`. */
|
|
21
|
+
rules?: Rules;
|
|
22
|
+
/** Each category, sent or withheld. Nothing is sent without a key. */
|
|
23
|
+
send?: Partial<Record<Category, boolean>>;
|
|
24
|
+
}
|
|
25
|
+
/** The shipped defaults. */
|
|
26
|
+
export declare const DEFAULTS: {
|
|
27
|
+
readonly console: {
|
|
28
|
+
readonly coverage: 'info';
|
|
29
|
+
readonly findings: 'info';
|
|
30
|
+
readonly usage: 'debug';
|
|
31
|
+
};
|
|
32
|
+
readonly detail: {
|
|
33
|
+
readonly evidence: 'full';
|
|
34
|
+
readonly hookValues: true;
|
|
35
|
+
readonly pages: 'patterns';
|
|
36
|
+
};
|
|
37
|
+
readonly environments: {
|
|
38
|
+
readonly ci: true;
|
|
39
|
+
readonly development: true;
|
|
40
|
+
readonly production: false;
|
|
41
|
+
};
|
|
42
|
+
readonly send: {
|
|
43
|
+
readonly assist: true;
|
|
44
|
+
readonly configuration: true;
|
|
45
|
+
readonly coverage: true;
|
|
46
|
+
readonly findings: true;
|
|
47
|
+
readonly performance: false;
|
|
48
|
+
readonly runtime: true;
|
|
49
|
+
readonly usage: true;
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
/** Throws on a setting that names nothing, or a value it cannot take. */
|
|
53
|
+
export declare const checkConfig: (config: Config | undefined, layer: Exclude<Layer, 'shipped'>) => void;
|
|
54
|
+
/** The settings in effect, from the defaults, the file, and the call. */
|
|
55
|
+
export interface Resolved {
|
|
56
|
+
configuration: Omit<Configuration, 'overrides' | 'silenced'>;
|
|
57
|
+
project: Report['project'];
|
|
58
|
+
/** Each category's sending, and which place decided it. */
|
|
59
|
+
send: Record<Category, {
|
|
60
|
+
decidedBy: Layer;
|
|
61
|
+
on: boolean;
|
|
62
|
+
}>;
|
|
63
|
+
}
|
|
64
|
+
/** The three places, merged, with a later one winning field by field. */
|
|
65
|
+
export declare const resolveConfig: (file: Config | undefined, call: Config | undefined) => Resolved;
|
|
66
|
+
/**
|
|
67
|
+
* A page's path as a pattern: each segment that names one record, a number,
|
|
68
|
+
* a UUID, or a long run of hex or mixed letters and digits, becomes `:id`.
|
|
69
|
+
* `/orders/1042/items/9f8e7d6c5b4a` reads `/orders/:id/items/:id`.
|
|
70
|
+
*/
|
|
71
|
+
export declare const pagePattern: (path: string) => string;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { nearest } from './manifest.js';
|
|
2
|
+
import { CATEGORIES } from './report.js';
|
|
3
|
+
import { checkRules } from './rules.js';
|
|
4
|
+
export const DEFAULTS = {
|
|
5
|
+
console: { coverage: 'info', findings: 'info', usage: 'debug' },
|
|
6
|
+
detail: { evidence: 'full', hookValues: true, pages: 'patterns' },
|
|
7
|
+
environments: { ci: true, development: true, production: false },
|
|
8
|
+
send: {
|
|
9
|
+
assist: true,
|
|
10
|
+
configuration: true,
|
|
11
|
+
coverage: true,
|
|
12
|
+
findings: true,
|
|
13
|
+
performance: false,
|
|
14
|
+
runtime: true,
|
|
15
|
+
usage: true,
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
const ALLOWED = {
|
|
19
|
+
console: {
|
|
20
|
+
coverage: ['info', 'off'],
|
|
21
|
+
findings: ['error', 'warn', 'info', 'off'],
|
|
22
|
+
usage: ['debug', 'off'],
|
|
23
|
+
},
|
|
24
|
+
detail: {
|
|
25
|
+
evidence: ['full', 'selector', 'none'],
|
|
26
|
+
hookValues: [true, false],
|
|
27
|
+
pages: ['patterns', 'full', 'none'],
|
|
28
|
+
},
|
|
29
|
+
environments: { ci: [true, false], development: [true, false], production: 'sample' },
|
|
30
|
+
project: { key: 'string', name: 'string', tags: 'tags' },
|
|
31
|
+
send: Object.fromEntries(CATEGORIES.map((category) => [category, [true, false]])),
|
|
32
|
+
};
|
|
33
|
+
const shown = (value) => typeof value === 'string' ? `\`${value}\`` : String(value);
|
|
34
|
+
export const checkConfig = (config, layer) => {
|
|
35
|
+
if (config === undefined) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (typeof config !== 'object' || config === null || Array.isArray(config)) {
|
|
39
|
+
throw new Error(`The ${layer} is not a configuration object.`);
|
|
40
|
+
}
|
|
41
|
+
for (const [section, value] of Object.entries(config)) {
|
|
42
|
+
if (section === 'rules') {
|
|
43
|
+
checkRules(value, layer);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const allowed = ALLOWED[section];
|
|
47
|
+
if (!allowed) {
|
|
48
|
+
const close = nearest(section, [...Object.keys(ALLOWED), 'rules']);
|
|
49
|
+
throw new Error(`The ${layer} sets \`${section}\`, which is no setting.` +
|
|
50
|
+
(close ? ` The closest is \`${close}\`.` : ''));
|
|
51
|
+
}
|
|
52
|
+
for (const [name, one] of Object.entries(value)) {
|
|
53
|
+
const takes = allowed[name];
|
|
54
|
+
if (takes === undefined) {
|
|
55
|
+
const close = nearest(name, Object.keys(allowed));
|
|
56
|
+
throw new Error(`The ${layer} sets \`${section}.${name}\`, which is no setting.` +
|
|
57
|
+
(close ? ` The closest is \`${section}.${close}\`.` : ''));
|
|
58
|
+
}
|
|
59
|
+
const fits = takes === 'string'
|
|
60
|
+
? typeof one === 'string'
|
|
61
|
+
: takes === 'tags'
|
|
62
|
+
? 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);
|
|
71
|
+
if (!fits) {
|
|
72
|
+
const expected = takes === 'string'
|
|
73
|
+
? 'a string'
|
|
74
|
+
: takes === 'tags'
|
|
75
|
+
? 'a list of strings'
|
|
76
|
+
: takes === 'sample'
|
|
77
|
+
? '`false`, or `{ sample }` from 0 to 1'
|
|
78
|
+
: takes.map(shown).join(', ');
|
|
79
|
+
throw new Error(`The ${layer} sets \`${section}.${name}\` to \`${JSON.stringify(one)}\`. It takes ${expected}.`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
export const resolveConfig = (file, call) => {
|
|
85
|
+
checkConfig(file, 'file');
|
|
86
|
+
checkConfig(call, 'argument');
|
|
87
|
+
const merged = (section, base) => ({
|
|
88
|
+
...base,
|
|
89
|
+
...(file?.[section] ?? {}),
|
|
90
|
+
...(call?.[section] ?? {}),
|
|
91
|
+
});
|
|
92
|
+
const send = Object.fromEntries(CATEGORIES.map((category) => {
|
|
93
|
+
const fromCall = call?.send?.[category];
|
|
94
|
+
const fromFile = file?.send?.[category];
|
|
95
|
+
return [
|
|
96
|
+
category,
|
|
97
|
+
fromCall !== undefined
|
|
98
|
+
? { decidedBy: 'argument', on: fromCall }
|
|
99
|
+
: fromFile !== undefined
|
|
100
|
+
? { decidedBy: 'file', on: fromFile }
|
|
101
|
+
: { decidedBy: 'shipped', on: DEFAULTS.send[category] },
|
|
102
|
+
];
|
|
103
|
+
}));
|
|
104
|
+
const project = { ...(file?.project ?? {}), ...(call?.project ?? {}) };
|
|
105
|
+
return {
|
|
106
|
+
configuration: {
|
|
107
|
+
console: merged('console', { ...DEFAULTS.console }),
|
|
108
|
+
detail: merged('detail', { ...DEFAULTS.detail }),
|
|
109
|
+
environments: merged('environments', { ...DEFAULTS.environments }),
|
|
110
|
+
},
|
|
111
|
+
project: {
|
|
112
|
+
...(project.name === undefined ? {} : { name: project.name }),
|
|
113
|
+
tags: project.tags ?? [],
|
|
114
|
+
},
|
|
115
|
+
send,
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
export const pagePattern = (path) => path
|
|
119
|
+
.split('/')
|
|
120
|
+
.map((segment) => /^\d+$/.test(segment) ||
|
|
121
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment) ||
|
|
122
|
+
/^[0-9a-f]{8,}$/i.test(segment) ||
|
|
123
|
+
(segment.length >= 12 && /\d/.test(segment) && /^[A-Za-z0-9_-]+$/.test(segment))
|
|
124
|
+
? ':id'
|
|
125
|
+
: segment)
|
|
126
|
+
.join('/');
|
package/dist/console.d.ts
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The default sink: the console,
|
|
2
|
+
* The default sink: the console, at the level each entry deserves.
|
|
3
|
+
*
|
|
4
|
+
* **Each kind of entry has its own console level**, so the browser's level
|
|
5
|
+
* filter and a test runner's capture treat each as what it is:
|
|
6
|
+
*
|
|
7
|
+
* | Entry | Level | Why |
|
|
8
|
+
* | --- | --- | --- |
|
|
9
|
+
* | A finding at `error` | `console.error` | The CSS does nothing where it was written |
|
|
10
|
+
* | A finding at `warn` | `console.warn` | Worth fixing, and the page works |
|
|
11
|
+
* | A finding at `info` | `console.info` | Advice |
|
|
12
|
+
* | Unreadable stylesheets | `console.warn` | The run could not see part of the page |
|
|
13
|
+
* | Coverage, overrides, a clean run | `console.info` | What the run looked at |
|
|
14
|
+
* | Usage | `console.debug` | For whoever studies usage, hidden at the default level |
|
|
15
|
+
*
|
|
16
|
+
* **All of it in one group**, headed by the counts, so a page with other logs
|
|
17
|
+
* keeps a run together and a clean run collapses to one line.
|
|
3
18
|
*
|
|
4
19
|
* **Coverage is printed on a clean run too.** "No findings" from a collector
|
|
5
20
|
* that read three sheets of nineteen is not a pass, and the only way to stop
|
|
@@ -7,9 +22,17 @@
|
|
|
7
22
|
* single time.
|
|
8
23
|
*
|
|
9
24
|
* A sink and not a policy. It writes and returns; a CI sink writing JSON and an
|
|
10
|
-
* OTLP exporter are the same `Report` consumed differently
|
|
11
|
-
* all three agreeing by construction rather than by our diligence.
|
|
25
|
+
* OTLP exporter are the same `Report` consumed differently.
|
|
12
26
|
*/
|
|
13
27
|
import type { Report } from './report.js';
|
|
14
|
-
/**
|
|
15
|
-
export
|
|
28
|
+
/** The console methods this sink calls. A test hands in its own. */
|
|
29
|
+
export type ConsoleSink = Pick<Console, 'debug' | 'error' | 'group' | 'groupCollapsed' | 'groupEnd' | 'info' | 'warn'>;
|
|
30
|
+
/**
|
|
31
|
+
* Writes a report and hands it back, so a caller can chain a second sink.
|
|
32
|
+
*
|
|
33
|
+
* **Each category prints as the project's `console` setting says**: findings
|
|
34
|
+
* at or above a severity, coverage and usage on or off. The heading counts
|
|
35
|
+
* every finding whatever is printed under it, and the ones the rules turned
|
|
36
|
+
* off, so a quiet console never reads as a clean page.
|
|
37
|
+
*/
|
|
38
|
+
export declare const report: (result: Report, to?: ConsoleSink) => Report;
|
package/dist/console.js
CHANGED
|
@@ -1,30 +1,69 @@
|
|
|
1
|
-
const
|
|
2
|
-
const
|
|
1
|
+
const entry = (finding) => {
|
|
2
|
+
const by = finding.decidedBy === 'shipped' ? '' : ` (${finding.severity}, set by the ${finding.decidedBy})`;
|
|
3
|
+
const lines = [`${finding.kind}${by}: ${finding.subject}`, finding.message];
|
|
3
4
|
if (finding.origin) {
|
|
4
|
-
|
|
5
|
-
out.push(` ${finding.origin.cssText}`);
|
|
5
|
+
lines.push(`in ${finding.origin.sheet}, rule ${finding.origin.index}`, finding.origin.cssText);
|
|
6
6
|
}
|
|
7
|
-
return
|
|
7
|
+
return lines.join('\n');
|
|
8
8
|
};
|
|
9
|
+
const counted = (count, noun) => `${count} ${noun}${count === 1 ? '' : 's'}`;
|
|
10
|
+
const RANK = { error: 3, info: 1, warn: 2 };
|
|
9
11
|
export const report = (result, to = console) => {
|
|
10
|
-
const { coverage, findings } = result;
|
|
12
|
+
const { configuration, coverage, findings } = result;
|
|
13
|
+
const levels = configuration.console;
|
|
14
|
+
const bySeverity = (severity) => findings.filter((finding) => finding.severity === severity).length;
|
|
15
|
+
const errors = bySeverity('error');
|
|
16
|
+
const warnings = bySeverity('warn');
|
|
17
|
+
const off = configuration.silenced.reduce((total, one) => total + one.count, 0);
|
|
11
18
|
const missed = coverage.sheets - coverage.read;
|
|
12
|
-
|
|
13
|
-
(
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
19
|
+
const counts = [
|
|
20
|
+
counted(errors, 'error'),
|
|
21
|
+
counted(warnings, 'warning'),
|
|
22
|
+
`${bySeverity('info')} info`,
|
|
23
|
+
...(off > 0 ? [`${off} turned off`] : []),
|
|
24
|
+
];
|
|
25
|
+
const heading = findings.length === 0 && off === 0
|
|
26
|
+
? 'helix diagnostics: no findings'
|
|
27
|
+
: `helix diagnostics: ${counts.join(', ')}`;
|
|
28
|
+
if (errors + warnings > 0 || missed > 0) {
|
|
29
|
+
to.group(heading);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
to.groupCollapsed(heading);
|
|
33
|
+
}
|
|
34
|
+
if (levels.coverage !== 'off') {
|
|
35
|
+
to.info(`read ${coverage.read} of ${counted(coverage.sheets, 'stylesheet')} across ${counted(coverage.roots, 'shadow root')}` +
|
|
36
|
+
(result.run.page ? ` on ${result.run.page}` : ''));
|
|
37
|
+
}
|
|
38
|
+
if (missed > 0) {
|
|
39
|
+
to.warn([
|
|
40
|
+
`${counted(missed, 'stylesheet')} could not be read, so ${missed === 1 ? 'it was' : 'they were'} not checked:`,
|
|
41
|
+
...coverage.unreadable.map((sheet) => `${sheet.sheet} (${sheet.reason})`),
|
|
42
|
+
].join('\n'));
|
|
43
|
+
}
|
|
44
|
+
if (off > 0 && levels.coverage !== 'off') {
|
|
45
|
+
to.info(configuration.silenced
|
|
46
|
+
.map((one) => `${counted(one.count, one.kind)} turned off by the ${one.layer}`)
|
|
47
|
+
.join('\n'));
|
|
48
|
+
}
|
|
49
|
+
if (levels.findings !== 'off') {
|
|
50
|
+
const floor = RANK[levels.findings];
|
|
51
|
+
for (const finding of findings) {
|
|
52
|
+
if (RANK[finding.severity] >= floor) {
|
|
53
|
+
to[finding.severity](entry(finding));
|
|
54
|
+
}
|
|
27
55
|
}
|
|
28
56
|
}
|
|
57
|
+
if (levels.usage !== 'off') {
|
|
58
|
+
const { counted: how, hooks, parts, tags, unlisted } = result.usage;
|
|
59
|
+
const sum = (counts) => Object.values(counts).reduce((total, one) => total + Object.keys(one).length, 0);
|
|
60
|
+
const elements = Object.values(tags).reduce((total, one) => total + one, 0);
|
|
61
|
+
const others = Object.keys(unlisted).length;
|
|
62
|
+
const versions = result.versions.helix;
|
|
63
|
+
to.debug(`usage (${how}): ${counted(Object.keys(tags).length, 'tag')} in ${counted(elements, 'element')}, ${counted(sum(parts), 'part')} styled, ${counted(sum(hooks), 'hook')} set` +
|
|
64
|
+
(others > 0 ? `, and ${counted(others, 'helix- tag')} no manifest lists` : '') +
|
|
65
|
+
(versions.length > 0 ? `; Helix ${versions.join(' and ')}` : ''));
|
|
66
|
+
}
|
|
67
|
+
to.groupEnd();
|
|
29
68
|
return result;
|
|
30
69
|
};
|