@fusion.dev/helix-diagnostics 0.138.0 → 0.140.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 +210 -34
- package/dist/analyze.d.ts +14 -3
- package/dist/analyze.js +76 -86
- package/dist/ci.js +14 -0
- package/dist/collect.d.ts +2 -1
- package/dist/collect.js +3 -15
- package/dist/config.d.ts +24 -0
- package/dist/config.js +42 -12
- package/dist/console.d.ts +11 -1
- package/dist/console.js +46 -5
- package/dist/diagnose.d.ts +75 -0
- package/dist/diagnose.js +93 -0
- package/dist/identity.d.ts +6 -0
- package/dist/identity.js +1 -0
- package/dist/index.d.ts +5 -77
- package/dist/index.js +4 -74
- package/dist/init.d.ts +87 -0
- package/dist/init.js +337 -0
- package/dist/manifest.d.ts +41 -3
- package/dist/manifest.js +79 -40
- package/dist/overlay.d.ts +12 -0
- package/dist/overlay.js +300 -0
- package/dist/page.d.ts +25 -0
- package/dist/page.js +21 -0
- package/dist/performance.d.ts +34 -0
- package/dist/performance.js +121 -0
- package/dist/report.d.ts +86 -0
- package/dist/runtime.d.ts +31 -0
- package/dist/runtime.js +39 -0
- package/dist/schedule.d.ts +57 -0
- package/dist/schedule.js +84 -0
- package/dist/usage.d.ts +34 -4
- package/dist/usage.js +38 -16
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/dist/manifest.js
CHANGED
|
@@ -1,42 +1,75 @@
|
|
|
1
|
+
export const lintOf = (manifest, name) => {
|
|
2
|
+
const tags = {};
|
|
3
|
+
for (const module of manifest.modules ?? []) {
|
|
4
|
+
const family = /^src\/([^/]+)\//.exec(module.path ?? '')?.[1];
|
|
5
|
+
for (const declaration of module.declarations ?? []) {
|
|
6
|
+
const tag = declaration.tagName;
|
|
7
|
+
if (!tag) {
|
|
8
|
+
continue;
|
|
9
|
+
}
|
|
10
|
+
const entry = tags[tag] ?? {
|
|
11
|
+
attributes: {},
|
|
12
|
+
family,
|
|
13
|
+
hooks: [],
|
|
14
|
+
parts: [],
|
|
15
|
+
written: [],
|
|
16
|
+
};
|
|
17
|
+
tags[tag] = entry;
|
|
18
|
+
for (const member of declaration.members ?? []) {
|
|
19
|
+
if (member.readonly && !member.static && member.kind === 'field') {
|
|
20
|
+
entry.written.push(member.name, member.name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
for (const attribute of declaration.attributes ?? []) {
|
|
24
|
+
const values = allowed(attribute.type?.text);
|
|
25
|
+
entry.attributes[attribute.name] = values ? [...values] : null;
|
|
26
|
+
}
|
|
27
|
+
entry.parts.push(...(declaration.cssParts ?? []).map((part) => part.name));
|
|
28
|
+
entry.hooks.push(...(declaration.cssProperties ?? []).map((hook) => hook.name));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
for (const entry of Object.values(tags)) {
|
|
32
|
+
entry.written = [...new Set(entry.written)];
|
|
33
|
+
entry.parts = [...new Set(entry.parts)];
|
|
34
|
+
entry.hooks = [...new Set(entry.hooks)];
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
package: manifest.helix?.package ?? name,
|
|
38
|
+
tags,
|
|
39
|
+
...(manifest.helix?.version ? { version: manifest.helix.version } : {}),
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
export const lintFor = (tier) => 'lint' in tier ? tier.lint : lintOf(tier.manifest, tier.name);
|
|
1
43
|
export const index = (tiers) => {
|
|
2
44
|
const tags = new Map();
|
|
3
45
|
for (const tier of tiers) {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
for (const [field, into] of [
|
|
32
|
-
[declaration.cssParts, element.parts],
|
|
33
|
-
[declaration.cssProperties, element.hooks],
|
|
34
|
-
]) {
|
|
35
|
-
for (const entry of field ?? []) {
|
|
36
|
-
const publishers = into.get(entry.name) ?? new Set();
|
|
37
|
-
publishers.add(tier.name);
|
|
38
|
-
into.set(entry.name, publishers);
|
|
39
|
-
}
|
|
46
|
+
const lint = lintFor(tier);
|
|
47
|
+
for (const [tag, entry] of Object.entries(lint.tags)) {
|
|
48
|
+
let element = tags.get(tag);
|
|
49
|
+
if (!element) {
|
|
50
|
+
element = {
|
|
51
|
+
attributes: new Map(),
|
|
52
|
+
family: entry.family,
|
|
53
|
+
hooks: new Map(),
|
|
54
|
+
parts: new Map(),
|
|
55
|
+
written: new Set(),
|
|
56
|
+
};
|
|
57
|
+
tags.set(tag, element);
|
|
58
|
+
}
|
|
59
|
+
for (const name of entry.written) {
|
|
60
|
+
element.written.add(name);
|
|
61
|
+
}
|
|
62
|
+
for (const [name, values] of Object.entries(entry.attributes)) {
|
|
63
|
+
element.attributes.set(name, values ? new Set(values) : null);
|
|
64
|
+
}
|
|
65
|
+
for (const [names, into] of [
|
|
66
|
+
[entry.parts, element.parts],
|
|
67
|
+
[entry.hooks, element.hooks],
|
|
68
|
+
]) {
|
|
69
|
+
for (const name of names) {
|
|
70
|
+
const publishers = into.get(name) ?? new Set();
|
|
71
|
+
publishers.add(lint.package);
|
|
72
|
+
into.set(name, publishers);
|
|
40
73
|
}
|
|
41
74
|
}
|
|
42
75
|
}
|
|
@@ -57,9 +90,9 @@ const allowed = (type) => {
|
|
|
57
90
|
export const versionsOf = (tiers) => {
|
|
58
91
|
const versions = {};
|
|
59
92
|
for (const tier of tiers) {
|
|
60
|
-
const
|
|
61
|
-
if (version) {
|
|
62
|
-
versions[
|
|
93
|
+
const lint = lintFor(tier);
|
|
94
|
+
if (lint.version) {
|
|
95
|
+
versions[lint.package] = lint.version;
|
|
63
96
|
}
|
|
64
97
|
}
|
|
65
98
|
return versions;
|
|
@@ -83,8 +116,14 @@ const within = (from, to, ceiling) => {
|
|
|
83
116
|
let previous = Array.from({ length: to.length + 1 }, (_, column) => column);
|
|
84
117
|
for (let row = 1; row <= from.length; row += 1) {
|
|
85
118
|
const current = [row];
|
|
119
|
+
let least = row;
|
|
86
120
|
for (let column = 1; column <= to.length; column += 1) {
|
|
87
|
-
|
|
121
|
+
const cell = Math.min((previous[column] ?? 0) + 1, (current[column - 1] ?? 0) + 1, (previous[column - 1] ?? 0) + (from[row - 1] === to[column - 1] ? 0 : 1));
|
|
122
|
+
current[column] = cell;
|
|
123
|
+
least = Math.min(least, cell);
|
|
124
|
+
}
|
|
125
|
+
if (least > ceiling) {
|
|
126
|
+
return undefined;
|
|
88
127
|
}
|
|
89
128
|
previous = current;
|
|
90
129
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Report } from './report.js';
|
|
2
|
+
/** What a running instance does with the overlay. */
|
|
3
|
+
export interface Overlay {
|
|
4
|
+
/** The element, so a change it makes to the page is known for its own. */
|
|
5
|
+
readonly element: HTMLElement;
|
|
6
|
+
/** Removes it from the page. */
|
|
7
|
+
remove(): void;
|
|
8
|
+
/** Shows a run: the counts on the badge and the findings in the panel. */
|
|
9
|
+
show(run: Report): void;
|
|
10
|
+
}
|
|
11
|
+
/** Puts the overlay on `page`. */
|
|
12
|
+
export declare const overlayOn: (page: Document) => Overlay;
|
package/dist/overlay.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { matchable } from './analyze.js';
|
|
2
|
+
import { OVERLAY } from './page.js';
|
|
3
|
+
import { shownRuntime } from './runtime.js';
|
|
4
|
+
const STYLE = `
|
|
5
|
+
:host {
|
|
6
|
+
all: initial;
|
|
7
|
+
--ink: #111113;
|
|
8
|
+
--muted: #55555c;
|
|
9
|
+
--ground: #ffffff;
|
|
10
|
+
--raised: #f4f4f5;
|
|
11
|
+
--edge: #d4d4d8;
|
|
12
|
+
--error: #b42318;
|
|
13
|
+
--warn: #8a5a00;
|
|
14
|
+
--info: #1d4ed8;
|
|
15
|
+
color-scheme: light dark;
|
|
16
|
+
font: 13px/1.45 system-ui, sans-serif;
|
|
17
|
+
inset-block-end: 16px;
|
|
18
|
+
inset-inline-end: 16px;
|
|
19
|
+
position: fixed;
|
|
20
|
+
z-index: 2147483647;
|
|
21
|
+
}
|
|
22
|
+
@media (prefers-color-scheme: dark) {
|
|
23
|
+
:host {
|
|
24
|
+
--ink: #f4f4f5;
|
|
25
|
+
--muted: #a1a1aa;
|
|
26
|
+
--ground: #18181b;
|
|
27
|
+
--raised: #27272a;
|
|
28
|
+
--edge: #3f3f46;
|
|
29
|
+
--error: #f97066;
|
|
30
|
+
--warn: #fdb022;
|
|
31
|
+
--info: #84adff;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
[hidden] { display: none !important; }
|
|
35
|
+
.stack {
|
|
36
|
+
align-items: flex-end;
|
|
37
|
+
display: flex;
|
|
38
|
+
flex-direction: column;
|
|
39
|
+
gap: 8px;
|
|
40
|
+
}
|
|
41
|
+
button {
|
|
42
|
+
background: var(--ground);
|
|
43
|
+
border: 1px solid var(--edge);
|
|
44
|
+
border-radius: 999px;
|
|
45
|
+
color: var(--ink);
|
|
46
|
+
cursor: pointer;
|
|
47
|
+
font: inherit;
|
|
48
|
+
}
|
|
49
|
+
button:focus-visible, li:focus-visible {
|
|
50
|
+
outline: 2px solid var(--info);
|
|
51
|
+
outline-offset: 2px;
|
|
52
|
+
}
|
|
53
|
+
.badge {
|
|
54
|
+
align-items: center;
|
|
55
|
+
box-shadow: 0 2px 10px rgb(0 0 0 / 0.18);
|
|
56
|
+
display: flex;
|
|
57
|
+
gap: 8px;
|
|
58
|
+
padding: 6px 12px;
|
|
59
|
+
}
|
|
60
|
+
.dot {
|
|
61
|
+
block-size: 8px;
|
|
62
|
+
border-radius: 50%;
|
|
63
|
+
inline-size: 8px;
|
|
64
|
+
}
|
|
65
|
+
.panel {
|
|
66
|
+
background: var(--ground);
|
|
67
|
+
border: 1px solid var(--edge);
|
|
68
|
+
border-radius: 10px;
|
|
69
|
+
box-shadow: 0 8px 30px rgb(0 0 0 / 0.24);
|
|
70
|
+
color: var(--ink);
|
|
71
|
+
display: flex;
|
|
72
|
+
flex-direction: column;
|
|
73
|
+
inline-size: min(440px, calc(100vw - 32px));
|
|
74
|
+
max-block-size: min(70vh, 560px);
|
|
75
|
+
overflow: hidden;
|
|
76
|
+
}
|
|
77
|
+
header {
|
|
78
|
+
align-items: center;
|
|
79
|
+
border-block-end: 1px solid var(--edge);
|
|
80
|
+
display: flex;
|
|
81
|
+
gap: 8px;
|
|
82
|
+
padding: 10px 12px;
|
|
83
|
+
}
|
|
84
|
+
h2 { font-size: 14px; font-weight: 600; margin: 0; }
|
|
85
|
+
.counts { color: var(--muted); flex: 1; margin: 0; }
|
|
86
|
+
.close { border-radius: 6px; padding: 2px 8px; }
|
|
87
|
+
ol { list-style: none; margin: 0; overflow: auto; padding: 0; }
|
|
88
|
+
li {
|
|
89
|
+
border-block-end: 1px solid var(--edge);
|
|
90
|
+
display: grid;
|
|
91
|
+
gap: 4px;
|
|
92
|
+
padding: 10px 12px;
|
|
93
|
+
}
|
|
94
|
+
li:last-child { border-block-end: none; }
|
|
95
|
+
.kind { font-weight: 600; }
|
|
96
|
+
.error { color: var(--error); }
|
|
97
|
+
.warn { color: var(--warn); }
|
|
98
|
+
.info { color: var(--info); }
|
|
99
|
+
.message code {
|
|
100
|
+
background: var(--raised);
|
|
101
|
+
border-radius: 3px;
|
|
102
|
+
font: 12px/1.4 ui-monospace, monospace;
|
|
103
|
+
padding: 1px 4px;
|
|
104
|
+
white-space: nowrap;
|
|
105
|
+
}
|
|
106
|
+
.subject, pre {
|
|
107
|
+
font: 12px/1.4 ui-monospace, monospace;
|
|
108
|
+
margin: 0;
|
|
109
|
+
overflow-wrap: anywhere;
|
|
110
|
+
white-space: pre-wrap;
|
|
111
|
+
}
|
|
112
|
+
pre {
|
|
113
|
+
background: var(--raised);
|
|
114
|
+
border-radius: 6px;
|
|
115
|
+
padding: 6px 8px;
|
|
116
|
+
}
|
|
117
|
+
.where { color: var(--muted); font-size: 12px; }
|
|
118
|
+
.outline {
|
|
119
|
+
border: 2px dashed var(--error);
|
|
120
|
+
border-radius: 4px;
|
|
121
|
+
pointer-events: none;
|
|
122
|
+
position: fixed;
|
|
123
|
+
}
|
|
124
|
+
`;
|
|
125
|
+
const WORDS = {
|
|
126
|
+
error: ['error', 'errors'],
|
|
127
|
+
info: ['info', 'info'],
|
|
128
|
+
warn: ['warning', 'warnings'],
|
|
129
|
+
};
|
|
130
|
+
const counted = (count, severity) => `${count} ${WORDS[severity][count === 1 ? 0 : 1]}`;
|
|
131
|
+
const make = (tag, name, text) => {
|
|
132
|
+
const element = document.createElement(tag);
|
|
133
|
+
if (name) {
|
|
134
|
+
element.className = name;
|
|
135
|
+
}
|
|
136
|
+
if (text !== undefined) {
|
|
137
|
+
element.textContent = text;
|
|
138
|
+
}
|
|
139
|
+
return element;
|
|
140
|
+
};
|
|
141
|
+
const worded = (message) => {
|
|
142
|
+
const out = make('span', 'message');
|
|
143
|
+
message.split('`').forEach((piece, at) => {
|
|
144
|
+
if (piece === '') {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
out.append(at % 2 === 1 ? make('code', undefined, piece) : document.createTextNode(piece));
|
|
148
|
+
});
|
|
149
|
+
return out;
|
|
150
|
+
};
|
|
151
|
+
const locate = (finding, page) => {
|
|
152
|
+
const markup = /^<([a-z][a-z0-9-]*) ([^\s=>]+)(?:="(.*)")?>$/.exec(finding.subject);
|
|
153
|
+
let selector;
|
|
154
|
+
if (markup) {
|
|
155
|
+
const [, tag, name, value] = markup;
|
|
156
|
+
selector =
|
|
157
|
+
value === undefined
|
|
158
|
+
? `${tag}[${CSS.escape(name ?? '')}]`
|
|
159
|
+
: `${tag}[${CSS.escape(name ?? '')}="${CSS.escape(value)}"]`;
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
selector = matchable(finding.subject.split(' { ')[0] ?? '');
|
|
163
|
+
}
|
|
164
|
+
if (!selector) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
return page.querySelector(selector);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
const define = () => {
|
|
175
|
+
if (customElements.get(OVERLAY)) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
customElements.define(OVERLAY, class extends HTMLElement {
|
|
179
|
+
});
|
|
180
|
+
};
|
|
181
|
+
export const overlayOn = (page) => {
|
|
182
|
+
define();
|
|
183
|
+
const element = page.createElement(OVERLAY);
|
|
184
|
+
const root = element.attachShadow({ mode: 'open' });
|
|
185
|
+
const style = make('style');
|
|
186
|
+
style.textContent = STYLE;
|
|
187
|
+
const badge = make('button', 'badge');
|
|
188
|
+
badge.type = 'button';
|
|
189
|
+
badge.hidden = true;
|
|
190
|
+
badge.setAttribute('aria-expanded', 'false');
|
|
191
|
+
badge.setAttribute('aria-controls', 'panel');
|
|
192
|
+
const dot = make('span', 'dot');
|
|
193
|
+
const label = make('span');
|
|
194
|
+
badge.append(dot, label);
|
|
195
|
+
const panel = make('section', 'panel');
|
|
196
|
+
panel.id = 'panel';
|
|
197
|
+
panel.hidden = true;
|
|
198
|
+
panel.setAttribute('aria-label', 'Helix diagnostics');
|
|
199
|
+
const header = make('header');
|
|
200
|
+
const title = make('h2', undefined, 'Helix diagnostics');
|
|
201
|
+
const summary = make('p', 'counts');
|
|
202
|
+
const close = make('button', 'close', '×');
|
|
203
|
+
close.type = 'button';
|
|
204
|
+
close.setAttribute('aria-label', 'Close Helix diagnostics');
|
|
205
|
+
header.append(title, summary, close);
|
|
206
|
+
const list = make('ol');
|
|
207
|
+
panel.append(header, list);
|
|
208
|
+
const outline = make('div', 'outline');
|
|
209
|
+
outline.hidden = true;
|
|
210
|
+
const stack = make('div', 'stack');
|
|
211
|
+
stack.append(panel, badge);
|
|
212
|
+
root.append(style, stack, outline);
|
|
213
|
+
(page.body ?? page.documentElement).append(element);
|
|
214
|
+
const open = (value) => {
|
|
215
|
+
panel.hidden = !value;
|
|
216
|
+
badge.setAttribute('aria-expanded', String(value));
|
|
217
|
+
if (!value) {
|
|
218
|
+
outline.hidden = true;
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
badge.addEventListener('click', () => open(panel.hasAttribute('hidden')));
|
|
222
|
+
close.addEventListener('click', () => {
|
|
223
|
+
open(false);
|
|
224
|
+
badge.focus();
|
|
225
|
+
});
|
|
226
|
+
element.addEventListener('keydown', (event) => {
|
|
227
|
+
if (event.key === 'Escape' && !panel.hidden) {
|
|
228
|
+
open(false);
|
|
229
|
+
badge.focus();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
const point = (target, severity) => {
|
|
233
|
+
if (!target) {
|
|
234
|
+
outline.hidden = true;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const box = target.getBoundingClientRect();
|
|
238
|
+
outline.style.borderColor = `var(--${severity})`;
|
|
239
|
+
outline.style.insetBlockStart = `${box.top - 3}px`;
|
|
240
|
+
outline.style.insetInlineStart = `${box.left - 3}px`;
|
|
241
|
+
outline.style.blockSize = `${box.height + 2}px`;
|
|
242
|
+
outline.style.inlineSize = `${box.width + 2}px`;
|
|
243
|
+
outline.hidden = false;
|
|
244
|
+
};
|
|
245
|
+
const follow = (item, target, severity) => {
|
|
246
|
+
item.addEventListener('mouseenter', () => point(target(), severity));
|
|
247
|
+
item.addEventListener('focus', () => point(target(), severity));
|
|
248
|
+
item.addEventListener('mouseleave', () => point(null));
|
|
249
|
+
item.addEventListener('blur', () => point(null));
|
|
250
|
+
};
|
|
251
|
+
const show = (run) => {
|
|
252
|
+
const findings = run.findings;
|
|
253
|
+
const runtime = shownRuntime(run.runtime);
|
|
254
|
+
const of = (severity) => findings.filter((one) => one.severity === severity).length;
|
|
255
|
+
const failed = runtime.some((one) => one.level === 'error');
|
|
256
|
+
const worst = of('error') > 0 || failed ? 'error' : of('warn') > 0 || runtime.length > 0 ? 'warn' : 'info';
|
|
257
|
+
const counts = [
|
|
258
|
+
...['error', 'warn', 'info']
|
|
259
|
+
.filter((severity) => of(severity) > 0)
|
|
260
|
+
.map((severity) => counted(of(severity), severity)),
|
|
261
|
+
...(runtime.length > 0 ? [`${runtime.length} runtime`] : []),
|
|
262
|
+
].join(', ');
|
|
263
|
+
const total = findings.length + runtime.length;
|
|
264
|
+
badge.hidden = total === 0;
|
|
265
|
+
if (total === 0) {
|
|
266
|
+
open(false);
|
|
267
|
+
}
|
|
268
|
+
dot.style.background = `var(--${worst})`;
|
|
269
|
+
label.textContent = counts;
|
|
270
|
+
badge.setAttribute('aria-label', `Helix diagnostics: ${counts}`);
|
|
271
|
+
summary.textContent = run.run.page ? `${counts} on ${run.run.page}` : counts;
|
|
272
|
+
list.replaceChildren(...findings.map((finding) => {
|
|
273
|
+
const item = make('li');
|
|
274
|
+
item.tabIndex = 0;
|
|
275
|
+
const head = make('span', 'kind');
|
|
276
|
+
head.append(make('span', finding.severity, `${finding.severity} `), document.createTextNode(finding.kind));
|
|
277
|
+
item.append(head, make('code', 'subject', finding.subject), worded(finding.message));
|
|
278
|
+
if (finding.origin) {
|
|
279
|
+
item.append(make('span', 'where', `in ${finding.origin.sheet}`), make('pre', undefined, finding.origin.cssText));
|
|
280
|
+
}
|
|
281
|
+
follow(item, () => locate(finding, page), finding.severity);
|
|
282
|
+
return item;
|
|
283
|
+
}), ...runtime.map((one) => {
|
|
284
|
+
const item = make('li');
|
|
285
|
+
item.tabIndex = 0;
|
|
286
|
+
const head = make('span', 'kind');
|
|
287
|
+
head.append(make('span', one.level, `${one.level} `), document.createTextNode(one.kind));
|
|
288
|
+
item.append(head);
|
|
289
|
+
if (one.tag) {
|
|
290
|
+
item.append(make('code', 'subject', `<${one.tag}>`));
|
|
291
|
+
}
|
|
292
|
+
item.append(worded(one.message), make('span', 'where', one.count === 1
|
|
293
|
+
? 'Reported by Helix as the page ran'
|
|
294
|
+
: `Reported by Helix ${one.count} times as the page ran`));
|
|
295
|
+
follow(item, () => (one.tag ? page.querySelector(one.tag) : null), one.level);
|
|
296
|
+
return item;
|
|
297
|
+
}));
|
|
298
|
+
};
|
|
299
|
+
return { element, remove: () => element.remove(), show };
|
|
300
|
+
};
|
package/dist/page.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything on a page, gathered in one walk.
|
|
3
|
+
*
|
|
4
|
+
* **One walk for every check in a run.** The stylesheets, the usage, the
|
|
5
|
+
* attributes, the version evidence, and the duplicate check each walked the
|
|
6
|
+
* whole page, shadow roots included, before this: five walks a run, which a
|
|
7
|
+
* run by hand never feels and a run after every change would.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The tag of the overlay `initDiagnostics()` puts on a page: the one element
|
|
11
|
+
* diagnostics adds, which it leaves out of everything it counts and reads.
|
|
12
|
+
*/
|
|
13
|
+
export declare const OVERLAY = "helix-diagnostics-overlay";
|
|
14
|
+
/** Every element on a page and every shadow root, from one walk. */
|
|
15
|
+
export interface Page {
|
|
16
|
+
/**
|
|
17
|
+
* Every element, the document's and every shadow root's, each shadow
|
|
18
|
+
* root's elements following the host it belongs to.
|
|
19
|
+
*/
|
|
20
|
+
elements: Element[];
|
|
21
|
+
/** Every shadow root, nested ones included. */
|
|
22
|
+
roots: ShadowRoot[];
|
|
23
|
+
}
|
|
24
|
+
/** Walks `from` once, shadow roots included. */
|
|
25
|
+
export declare const pageOf: (from: ParentNode) => Page;
|
package/dist/page.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const OVERLAY = 'helix-diagnostics-overlay';
|
|
2
|
+
const DEPTH = 32;
|
|
3
|
+
export const pageOf = (from) => {
|
|
4
|
+
const elements = [];
|
|
5
|
+
const roots = [];
|
|
6
|
+
const visit = (root, depth) => {
|
|
7
|
+
if (depth > DEPTH) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
for (const element of root.querySelectorAll('*')) {
|
|
11
|
+
elements.push(element);
|
|
12
|
+
const shadow = element.shadowRoot;
|
|
13
|
+
if (shadow) {
|
|
14
|
+
roots.push(shadow);
|
|
15
|
+
visit(shadow, depth + 1);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
visit(from, 0);
|
|
20
|
+
return { elements, roots };
|
|
21
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { PerformanceReport } from './report.js';
|
|
2
|
+
/** The key `@fusion.dev/helix-core` hands each first update's timing to. */
|
|
3
|
+
export declare const RENDER_SLOT: unique symbol;
|
|
4
|
+
/** A measure as the page holds it, until a run reads it. */
|
|
5
|
+
export interface Measurer {
|
|
6
|
+
/** The measures so far. A copy: measuring goes on after it is read. */
|
|
7
|
+
results(): PerformanceReport;
|
|
8
|
+
/** Stops measuring, and gives the slot back to whatever held it before. */
|
|
9
|
+
stop(): void;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The tag of the Helix element a node belongs to: the node itself, or the
|
|
13
|
+
* nearest Helix element above it, through shadow roots.
|
|
14
|
+
*/
|
|
15
|
+
export declare const helixOf: (node: Node | null | undefined) => string | undefined;
|
|
16
|
+
/** The value at a fraction of the way through sorted numbers, by nearest rank. */
|
|
17
|
+
export declare const percentile: (sorted: readonly number[], fraction: number) => number;
|
|
18
|
+
/** A layout-shift entry's part, as this reads it. */
|
|
19
|
+
interface Shift {
|
|
20
|
+
hadRecentInput?: boolean;
|
|
21
|
+
sources?: ReadonlyArray<{
|
|
22
|
+
node?: Node | null | undefined;
|
|
23
|
+
}>;
|
|
24
|
+
value: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Shares one shift's score out among the Helix elements its sources belong
|
|
28
|
+
* to, an equal share each. A shift a reader caused, by typing or pressing, is
|
|
29
|
+
* no fault of the page's, which is how the browser scores it too.
|
|
30
|
+
*/
|
|
31
|
+
export declare const shares: (shift: Shift) => Map<string, number>;
|
|
32
|
+
/** Starts measuring, from now on, and the layout shift and long tasks the browser kept from before. */
|
|
33
|
+
export declare const measure: () => Measurer;
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { OVERLAY } from './page.js';
|
|
2
|
+
export const RENDER_SLOT = Symbol.for('fusion.dev/helix/render');
|
|
3
|
+
const KEPT = 1000;
|
|
4
|
+
export const helixOf = (node) => {
|
|
5
|
+
let at = node;
|
|
6
|
+
while (at) {
|
|
7
|
+
if (at.nodeType === 1) {
|
|
8
|
+
const tag = at.localName;
|
|
9
|
+
if (tag.startsWith('helix-') && tag !== OVERLAY) {
|
|
10
|
+
return tag;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
at = at.parentNode ?? at.host ?? null;
|
|
14
|
+
}
|
|
15
|
+
return undefined;
|
|
16
|
+
};
|
|
17
|
+
export const percentile = (sorted, fraction) => sorted.length === 0
|
|
18
|
+
? 0
|
|
19
|
+
: (sorted[Math.min(sorted.length - 1, Math.ceil(fraction * sorted.length) - 1)] ?? 0);
|
|
20
|
+
const tenth = (value) => Math.round(value * 10) / 10;
|
|
21
|
+
const thousandth = (value) => Math.round(value * 1000) / 1000;
|
|
22
|
+
export const shares = (shift) => {
|
|
23
|
+
const out = new Map();
|
|
24
|
+
const sources = shift.sources ?? [];
|
|
25
|
+
if (shift.hadRecentInput || sources.length === 0) {
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
const each = shift.value / sources.length;
|
|
29
|
+
for (const source of sources) {
|
|
30
|
+
const tag = helixOf(source.node);
|
|
31
|
+
if (tag) {
|
|
32
|
+
out.set(tag, (out.get(tag) ?? 0) + each);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
};
|
|
37
|
+
const supports = (type) => typeof PerformanceObserver === 'function' &&
|
|
38
|
+
(PerformanceObserver.supportedEntryTypes ?? []).includes(type);
|
|
39
|
+
export const measure = () => {
|
|
40
|
+
const gathered = new Map();
|
|
41
|
+
const of = (tag) => {
|
|
42
|
+
let one = gathered.get(tag);
|
|
43
|
+
if (!one) {
|
|
44
|
+
one = { durations: [], shift: 0, spans: [] };
|
|
45
|
+
gathered.set(tag, one);
|
|
46
|
+
}
|
|
47
|
+
return one;
|
|
48
|
+
};
|
|
49
|
+
const long = [];
|
|
50
|
+
const store = globalThis;
|
|
51
|
+
const before = store[RENDER_SLOT];
|
|
52
|
+
let live = true;
|
|
53
|
+
const listener = (element, timing) => {
|
|
54
|
+
if (live && element?.localName !== OVERLAY) {
|
|
55
|
+
const one = of(element.localName);
|
|
56
|
+
one.durations.push(timing.duration);
|
|
57
|
+
if (one.spans.length < KEPT) {
|
|
58
|
+
one.spans.push({ end: timing.start + timing.duration, start: timing.start });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (typeof before === 'function') {
|
|
62
|
+
before(element, timing);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
store[RENDER_SLOT] = listener;
|
|
66
|
+
const observers = [];
|
|
67
|
+
const observe = (type, take) => {
|
|
68
|
+
if (!supports(type)) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const observer = new PerformanceObserver((list) => {
|
|
73
|
+
for (const entry of list.getEntries()) {
|
|
74
|
+
take(entry);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
observer.observe({ buffered: true, type });
|
|
78
|
+
observers.push(observer);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const layoutShift = observe('layout-shift', (entry) => {
|
|
86
|
+
for (const [tag, share] of shares(entry)) {
|
|
87
|
+
of(tag).shift += share;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
const longTasks = observe('longtask', (entry) => {
|
|
91
|
+
long.push({ end: entry.startTime + entry.duration, start: entry.startTime });
|
|
92
|
+
});
|
|
93
|
+
return {
|
|
94
|
+
results: () => {
|
|
95
|
+
const tags = {};
|
|
96
|
+
for (const [tag, one] of [...gathered].sort(([a], [b]) => (a < b ? -1 : 1))) {
|
|
97
|
+
const sorted = [...one.durations].sort((a, b) => a - b);
|
|
98
|
+
tags[tag] = {
|
|
99
|
+
longTasks: long.filter((task) => one.spans.some((span) => span.start < task.end && span.end > task.start)).length,
|
|
100
|
+
render: {
|
|
101
|
+
count: sorted.length,
|
|
102
|
+
max: tenth(sorted.at(-1) ?? 0),
|
|
103
|
+
p50: tenth(percentile(sorted, 0.5)),
|
|
104
|
+
p95: tenth(percentile(sorted, 0.95)),
|
|
105
|
+
},
|
|
106
|
+
shift: thousandth(one.shift),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return { measured: { layoutShift, longTasks }, tags };
|
|
110
|
+
},
|
|
111
|
+
stop: () => {
|
|
112
|
+
live = false;
|
|
113
|
+
for (const observer of observers) {
|
|
114
|
+
observer.disconnect();
|
|
115
|
+
}
|
|
116
|
+
if (store[RENDER_SLOT] === listener) {
|
|
117
|
+
store[RENDER_SLOT] = before;
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
};
|