@fusion.dev/helix-diagnostics 0.186.0 → 0.187.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 +7 -1
- package/dist/analyze.js +94 -1
- package/dist/identity.js +12 -0
- package/dist/manifest.d.ts +13 -0
- package/dist/manifest.js +11 -0
- package/dist/report.d.ts +2 -2
- package/dist/report.js +6 -0
- package/dist/rules.js +6 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -269,7 +269,13 @@ works on it:
|
|
|
269
269
|
| `unknown-slot` | error | A child names a slot its Helix element does not have, so it is not drawn. Found by `helix scan`, from the templates |
|
|
270
270
|
| `invalid-value` | error | An attribute with a closed set of values carries one outside it, so the element falls back to its default |
|
|
271
271
|
| `unforwarded-part` | warn | The headless tier publishes the part and the styled tier does not forward it, which is a gap in Helix |
|
|
272
|
-
| `unknown-tag` | error | A rule names a tag no manifest lists |
|
|
272
|
+
| `unknown-tag` | error | A rule or an element names a tag no manifest lists |
|
|
273
|
+
| `unknown-event` | error | A React `on` prop on a Helix element names an event the element does not dispatch, so the handler never runs. Found by `helix scan`, from the templates |
|
|
274
|
+
| `unimported-tag` | warn | An element is written and nothing in the project imports the entry that registers it, so the browser draws an unknown element. Found by `helix scan`, from the imports |
|
|
275
|
+
| `disabled-attribute` | error | A rule styles a form control's `[disabled]`, which a control disabled by a `<fieldset disabled>` does not carry. Write `:disabled`. Found by `helix scan` |
|
|
276
|
+
| `primitive-token` | error | A rule reads a `--_helix-` primitive, which is internal to `@fusion.dev/helix-styles`. Found by `helix scan` |
|
|
277
|
+
| `unknown-token` | error | A rule reads or sets a `--helix-` name the token set does not have, so it falls back silently. Found by `helix scan` |
|
|
278
|
+
| `unreflected-attribute` | warn | A rule selects a Helix element by an attribute it takes and never writes back, so it matches only markup a server wrote and nothing after a client navigation |
|
|
273
279
|
| `duplicate-registration` | error | Two copies of Helix are on the page |
|
|
274
280
|
| `manifest-skew` | error | The manifest is from another version than the page runs. It replaces every other finding, since each would be judged against the wrong version. Turned off, the page is judged as usual |
|
|
275
281
|
|
package/dist/analyze.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { nearest } from './manifest.js';
|
|
2
|
-
import { pageOf } from './page.js';
|
|
2
|
+
import { OVERLAY, pageOf } from './page.js';
|
|
3
3
|
export const targets = (selector) => {
|
|
4
4
|
const found = new Set();
|
|
5
5
|
for (const match of selector.matchAll(/(?<![\w.#-])helix-[a-z0-9]+(?:-[a-z0-9]+)*/g)) {
|
|
@@ -153,6 +153,67 @@ const loose = (located, answerers, from, used, onPage, listedBy, readers, sugges
|
|
|
153
153
|
}
|
|
154
154
|
return findings;
|
|
155
155
|
};
|
|
156
|
+
const ALWAYS_WRITTEN = new Set([
|
|
157
|
+
'class',
|
|
158
|
+
'dir',
|
|
159
|
+
'hidden',
|
|
160
|
+
'id',
|
|
161
|
+
'lang',
|
|
162
|
+
'part',
|
|
163
|
+
'role',
|
|
164
|
+
'slot',
|
|
165
|
+
'style',
|
|
166
|
+
'tabindex',
|
|
167
|
+
'title',
|
|
168
|
+
]);
|
|
169
|
+
const SELECTS = /([^\s>+~,()]*)\[\s*([a-zA-Z][\w-]*)\s*[~^|$*]?=?[^\]]*\]/g;
|
|
170
|
+
const unreflected = (selector, located, tags, from) => {
|
|
171
|
+
if (!from || !selector.includes('[')) {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
const found = [];
|
|
175
|
+
const seen = new Set();
|
|
176
|
+
for (const [, compound = '', attribute = ''] of selector.matchAll(SELECTS)) {
|
|
177
|
+
if (ALWAYS_WRITTEN.has(attribute) ||
|
|
178
|
+
attribute.startsWith('aria-') ||
|
|
179
|
+
attribute.startsWith('data-') ||
|
|
180
|
+
seen.has(attribute)) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const base = compound.replace(/\[[^\]]*\]/g, '').replace(/::?[\w-]+(\([^)]*\))?/g, '');
|
|
184
|
+
if (!base) {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
let reached;
|
|
188
|
+
try {
|
|
189
|
+
reached = [...from.querySelectorAll(base)];
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const tag = reached
|
|
195
|
+
.map((element) => element.localName)
|
|
196
|
+
.find((name) => {
|
|
197
|
+
const published = tags.get(name);
|
|
198
|
+
return (published?.reflected !== undefined &&
|
|
199
|
+
published.attributes.has(attribute) &&
|
|
200
|
+
!published.reflected.has(attribute) &&
|
|
201
|
+
!published.written.has(attribute));
|
|
202
|
+
});
|
|
203
|
+
if (!tag) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
seen.add(attribute);
|
|
207
|
+
found.push({
|
|
208
|
+
kind: 'unreflected-attribute',
|
|
209
|
+
message: `\`${selector}\` selects \`${tag}\` by \`${attribute}\`, which the element takes and never writes back to itself, so the rule matches only markup a server wrote: after a client navigation the property is set and the attribute is absent. Select a class or an attribute the element reflects, or set \`${attribute}\` as an attribute.`,
|
|
210
|
+
origin: origin(located),
|
|
211
|
+
subject: `${selector} [${attribute}]`,
|
|
212
|
+
tag,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return found;
|
|
216
|
+
};
|
|
156
217
|
const origin = (located) => ({
|
|
157
218
|
cssText: located.rule.cssText,
|
|
158
219
|
index: located.index,
|
|
@@ -197,6 +258,7 @@ export const analyze = (rules, tags, from, answered = new Map(), used = new Set(
|
|
|
197
258
|
if (!selector) {
|
|
198
259
|
continue;
|
|
199
260
|
}
|
|
261
|
+
findings.push(...unreflected(selector, located, tags, from));
|
|
200
262
|
for (const { names, tag } of selector.includes('helix-') ? parts(selector) : []) {
|
|
201
263
|
const element = tags.get(tag);
|
|
202
264
|
if (!element) {
|
|
@@ -402,12 +464,43 @@ const GLOBAL = new Set([
|
|
|
402
464
|
]);
|
|
403
465
|
const PREFIXES = ['aria-', 'data-', 'on', '_ngcontent-', '_nghost-', 'ng-', 'v-', 'x-', ':', '@'];
|
|
404
466
|
const HOOK_DISTANCE = 2;
|
|
467
|
+
const unknownTag = (tags, tag) => {
|
|
468
|
+
if (!tag.startsWith('helix-') || tag === OVERLAY) {
|
|
469
|
+
return undefined;
|
|
470
|
+
}
|
|
471
|
+
const bare = nearest(tag.slice('helix-'.length), [...tags.keys()]
|
|
472
|
+
.filter((name) => name.startsWith('helix-'))
|
|
473
|
+
.map((name) => name.slice('helix-'.length)));
|
|
474
|
+
const suggestion = bare === undefined ? undefined : `helix-${bare}`;
|
|
475
|
+
return {
|
|
476
|
+
expected: suggestion,
|
|
477
|
+
kind: 'unknown-tag',
|
|
478
|
+
message: suggestion
|
|
479
|
+
? `\`${tag}\` is in no manifest passed to the collector, so the browser draws it as an unknown element. The closest tag is \`${suggestion}\`.`
|
|
480
|
+
: `\`${tag}\` is in no manifest passed to the collector, so the browser draws it as an unknown element. Check the spelling, or load the package that publishes the tag.`,
|
|
481
|
+
subject: `<${tag}>`,
|
|
482
|
+
tag,
|
|
483
|
+
};
|
|
484
|
+
};
|
|
405
485
|
export const markup = (tags, from, page = pageOf(from)) => {
|
|
406
486
|
const found = new Map();
|
|
407
487
|
for (const element of page.elements) {
|
|
408
488
|
const host = element.getRootNode();
|
|
409
489
|
const composed = host instanceof ShadowRoot && host.host.localName.startsWith('helix-');
|
|
410
490
|
const published = composed ? undefined : tags.get(element.localName);
|
|
491
|
+
if (!(composed || published) && tags.size > 0) {
|
|
492
|
+
const raw = unknownTag(tags, element.localName);
|
|
493
|
+
if (raw) {
|
|
494
|
+
const key = `${raw.kind} ${raw.subject}`;
|
|
495
|
+
const seen = found.get(key);
|
|
496
|
+
if (seen) {
|
|
497
|
+
seen.count += 1;
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
found.set(key, { ...raw, count: 1 });
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
411
504
|
if (published) {
|
|
412
505
|
for (const { name, value } of Array.from(element.attributes)) {
|
|
413
506
|
if (GLOBAL.has(name) ||
|
package/dist/identity.js
CHANGED
|
@@ -1,24 +1,36 @@
|
|
|
1
1
|
const CATEGORY = {
|
|
2
2
|
'dead-hook': 'styling',
|
|
3
3
|
'dead-part': 'styling',
|
|
4
|
+
'disabled-attribute': 'styling',
|
|
4
5
|
'duplicate-registration': 'integrity',
|
|
5
6
|
'invalid-value': 'markup',
|
|
6
7
|
'manifest-skew': 'integrity',
|
|
8
|
+
'primitive-token': 'styling',
|
|
7
9
|
'unforwarded-part': 'styling',
|
|
10
|
+
'unimported-tag': 'integrity',
|
|
8
11
|
'unknown-attribute': 'markup',
|
|
12
|
+
'unknown-event': 'markup',
|
|
9
13
|
'unknown-slot': 'markup',
|
|
10
14
|
'unknown-tag': 'integrity',
|
|
15
|
+
'unknown-token': 'styling',
|
|
16
|
+
'unreflected-attribute': 'styling',
|
|
11
17
|
};
|
|
12
18
|
const OWNER = {
|
|
13
19
|
'dead-hook': 'consumer',
|
|
14
20
|
'dead-part': 'consumer',
|
|
21
|
+
'disabled-attribute': 'consumer',
|
|
15
22
|
'duplicate-registration': 'consumer',
|
|
16
23
|
'invalid-value': 'consumer',
|
|
17
24
|
'manifest-skew': 'consumer',
|
|
25
|
+
'primitive-token': 'consumer',
|
|
18
26
|
'unforwarded-part': 'helix',
|
|
27
|
+
'unimported-tag': 'consumer',
|
|
19
28
|
'unknown-attribute': 'consumer',
|
|
29
|
+
'unknown-event': 'consumer',
|
|
20
30
|
'unknown-slot': 'consumer',
|
|
21
31
|
'unknown-tag': 'consumer',
|
|
32
|
+
'unknown-token': 'consumer',
|
|
33
|
+
'unreflected-attribute': 'consumer',
|
|
22
34
|
};
|
|
23
35
|
export const normalize = (subject) => subject
|
|
24
36
|
.replace(/___[A-Za-z0-9_-]{5}\b/g, '')
|
package/dist/manifest.d.ts
CHANGED
|
@@ -36,9 +36,11 @@ export interface ManifestSource {
|
|
|
36
36
|
};
|
|
37
37
|
}>;
|
|
38
38
|
members?: Array<{
|
|
39
|
+
attribute?: string;
|
|
39
40
|
kind?: string;
|
|
40
41
|
name: string;
|
|
41
42
|
readonly?: boolean;
|
|
43
|
+
reflects?: boolean;
|
|
42
44
|
static?: boolean;
|
|
43
45
|
}>;
|
|
44
46
|
cssParts?: Array<{
|
|
@@ -85,6 +87,12 @@ export interface LintTag {
|
|
|
85
87
|
* lint data written before 0.165, which a reader takes as unknown.
|
|
86
88
|
*/
|
|
87
89
|
slots?: string[];
|
|
90
|
+
/**
|
|
91
|
+
* The attributes a property writes back to the element when it is set, so a
|
|
92
|
+
* selector on one matches however the property was set. Absent from lint data
|
|
93
|
+
* written before this field, which a reader takes as unknown.
|
|
94
|
+
*/
|
|
95
|
+
reflected?: string[];
|
|
88
96
|
/** The attributes the element writes on itself: one named for a read-only member. */
|
|
89
97
|
written: string[];
|
|
90
98
|
}
|
|
@@ -118,6 +126,11 @@ export interface Published {
|
|
|
118
126
|
/** The family, from the module's path: `button` for `src/button/root/button-root.ts`. */
|
|
119
127
|
family?: string | undefined;
|
|
120
128
|
hooks: Map<string, Set<string>>;
|
|
129
|
+
/**
|
|
130
|
+
* The attributes a property writes back when it is set. Absent where a tier's
|
|
131
|
+
* lint data predates the field, which says nothing either way.
|
|
132
|
+
*/
|
|
133
|
+
reflected?: Set<string> | undefined;
|
|
121
134
|
/**
|
|
122
135
|
* The attributes the element writes on itself: one named for a read-only
|
|
123
136
|
* member, as `helix-notifications` writes `unread`. Output, so never a
|
package/dist/manifest.js
CHANGED
|
@@ -12,11 +12,15 @@ export const lintOf = (manifest, name) => {
|
|
|
12
12
|
family,
|
|
13
13
|
hooks: [],
|
|
14
14
|
parts: [],
|
|
15
|
+
reflected: [],
|
|
15
16
|
slots: [],
|
|
16
17
|
written: [],
|
|
17
18
|
};
|
|
18
19
|
tags[tag] = entry;
|
|
19
20
|
for (const member of declaration.members ?? []) {
|
|
21
|
+
if (member.reflects && member.attribute) {
|
|
22
|
+
entry.reflected?.push(member.attribute);
|
|
23
|
+
}
|
|
20
24
|
if (member.readonly && !member.static && member.kind === 'field') {
|
|
21
25
|
entry.written.push(member.name, member.name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`));
|
|
22
26
|
}
|
|
@@ -35,6 +39,7 @@ export const lintOf = (manifest, name) => {
|
|
|
35
39
|
entry.parts = [...new Set(entry.parts)];
|
|
36
40
|
entry.hooks = [...new Set(entry.hooks)];
|
|
37
41
|
entry.slots = [...new Set(entry.slots)];
|
|
42
|
+
entry.reflected = [...new Set(entry.reflected)];
|
|
38
43
|
}
|
|
39
44
|
return {
|
|
40
45
|
package: manifest.helix?.package ?? name,
|
|
@@ -62,6 +67,12 @@ export const index = (tiers) => {
|
|
|
62
67
|
for (const name of entry.written) {
|
|
63
68
|
element.written.add(name);
|
|
64
69
|
}
|
|
70
|
+
if (entry.reflected) {
|
|
71
|
+
element.reflected ??= new Set();
|
|
72
|
+
for (const name of entry.reflected) {
|
|
73
|
+
element.reflected.add(name);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
65
76
|
for (const [name, values] of Object.entries(entry.attributes)) {
|
|
66
77
|
element.attributes.set(name, values ? new Set(values) : null);
|
|
67
78
|
}
|
package/dist/report.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* **A bare pass is a lie this collector is structurally capable of telling.**
|
|
6
6
|
* Cross-origin stylesheets throw on `.cssRules`, and a run that could not read
|
|
7
7
|
* half its input reports the same empty array as a run that read all of it.
|
|
8
|
-
* `fusion prose`'s `dialect` shipped in exactly that condition for weeks: green, and
|
|
8
|
+
* `fusion x prose`'s `dialect` shipped in exactly that condition for weeks: green, and
|
|
9
9
|
* incapable of being anything else. So `Report` has no `findings` that can be
|
|
10
10
|
* destructured away from the `coverage` that qualifies them, and the console
|
|
11
11
|
* sink prints coverage first whether or not anything was found.
|
|
@@ -97,7 +97,7 @@ export interface Finding {
|
|
|
97
97
|
tag: string;
|
|
98
98
|
}
|
|
99
99
|
/** Every fault class, stable across versions. */
|
|
100
|
-
export declare const KINDS: readonly ['dead-hook', 'dead-part', 'duplicate-registration', 'invalid-value', 'manifest-skew', 'unforwarded-part', 'unknown-attribute', 'unknown-slot', 'unknown-tag'];
|
|
100
|
+
export declare const KINDS: readonly ['dead-hook', 'dead-part', 'disabled-attribute', 'duplicate-registration', 'invalid-value', 'manifest-skew', 'primitive-token', 'unforwarded-part', 'unimported-tag', 'unknown-attribute', 'unknown-event', 'unknown-slot', 'unknown-tag', 'unknown-token', 'unreflected-attribute'];
|
|
101
101
|
/** A fault class. */
|
|
102
102
|
export type Kind = (typeof KINDS)[number];
|
|
103
103
|
/** How serious a finding is. */
|
package/dist/report.js
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
export const KINDS = [
|
|
2
2
|
'dead-hook',
|
|
3
3
|
'dead-part',
|
|
4
|
+
'disabled-attribute',
|
|
4
5
|
'duplicate-registration',
|
|
5
6
|
'invalid-value',
|
|
6
7
|
'manifest-skew',
|
|
8
|
+
'primitive-token',
|
|
7
9
|
'unforwarded-part',
|
|
10
|
+
'unimported-tag',
|
|
8
11
|
'unknown-attribute',
|
|
12
|
+
'unknown-event',
|
|
9
13
|
'unknown-slot',
|
|
10
14
|
'unknown-tag',
|
|
15
|
+
'unknown-token',
|
|
16
|
+
'unreflected-attribute',
|
|
11
17
|
];
|
|
12
18
|
export const CATEGORIES = [
|
|
13
19
|
'assist',
|
package/dist/rules.js
CHANGED
|
@@ -3,13 +3,19 @@ import { KINDS, } from './report.js';
|
|
|
3
3
|
export const SHIPPED_RULES = {
|
|
4
4
|
'dead-hook': 'error',
|
|
5
5
|
'dead-part': 'error',
|
|
6
|
+
'disabled-attribute': 'error',
|
|
6
7
|
'duplicate-registration': 'error',
|
|
7
8
|
'invalid-value': 'error',
|
|
8
9
|
'manifest-skew': 'error',
|
|
10
|
+
'primitive-token': 'error',
|
|
9
11
|
'unforwarded-part': 'warn',
|
|
12
|
+
'unimported-tag': 'warn',
|
|
10
13
|
'unknown-attribute': 'error',
|
|
14
|
+
'unknown-event': 'error',
|
|
11
15
|
'unknown-slot': 'error',
|
|
12
16
|
'unknown-tag': 'error',
|
|
17
|
+
'unknown-token': 'error',
|
|
18
|
+
'unreflected-attribute': 'warn',
|
|
13
19
|
};
|
|
14
20
|
const SEVERITIES = new Set(['error', 'info', 'off', 'warn']);
|
|
15
21
|
export const checkRules = (rules, layer) => {
|
package/dist/version.d.ts
CHANGED
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '0.
|
|
1
|
+
export const VERSION = '0.187.0';
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fusion.dev/helix-diagnostics",
|
|
3
3
|
"description": "Finds the Helix mistakes that are valid CSS: dead parts, misnamed hooks, two copies of the library.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.187.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ross Sheppard <ross.sheppard@fusion.dev>",
|
|
7
7
|
"repository": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"bugs": "https://github.com/project-fusion/fusion/issues",
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"@fusion.dev/helix-testing": "0.
|
|
36
|
+
"@fusion.dev/helix-testing": "0.187.0",
|
|
37
37
|
"@types/bun": "^1.4.2"
|
|
38
38
|
},
|
|
39
39
|
"homepage": "https://fusion.dev",
|