@uniflowed/vite 0.0.0-alpha.14 → 0.0.0-alpha.16

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.
@@ -0,0 +1,234 @@
1
+ // @noflow
2
+ //
3
+ // Plain JavaScript: this module is served to the browser as text, by
4
+ // `./a11y.js`'s virtual module, before any transform has run on it.
5
+ //
6
+ // The accessibility audit `uf dev` runs against the page on screen.
7
+ //
8
+ // The second half of ubugeeei-prod/uf#511, and the half that is worth more: a
9
+ // violation found while the component is being written is a violation somebody
10
+ // can fix in the same minute, and one found in CI is a pull request that goes
11
+ // red an hour later. Same engine and same rule set as
12
+ // `expect(el).toHaveNoAxeViolations()` — `uf.config.js`'s `accessibility.axe`
13
+ // is read once and given to both — so the two cannot disagree about what
14
+ // "accessible" means.
15
+ //
16
+ // # It reports to the terminal rather than drawing an overlay
17
+ //
18
+ // `internal/diagnostics.js` records the decision and the reason, from #583: an
19
+ // overlay is a rectangle drawn over the page, and a rectangle drawn over the
20
+ // page is the last thing an *accessibility* report should be. It would cover
21
+ // the markup it is complaining about, it would change the layout the audit
22
+ // just measured, and it would need to be accessible itself. So a finding goes
23
+ // down the channel that already exists, `POST /__uf/diagnostic`, and comes out
24
+ // in the terminal beside every other thing `uf dev` has to say.
25
+ //
26
+ // # When it runs
27
+ //
28
+ // Once when the page has settled, and again after the DOM stops changing.
29
+ // Not on every mutation: a React render is hundreds of them, and axe walks the
30
+ // whole subtree each time. The debounce is what makes it a background job
31
+ // rather than a stutter, and the audit is skipped outright while the tab is
32
+ // hidden — a page nobody is looking at has nothing to report about.
33
+ //
34
+ // Development only. `./a11y.js` injects this module from `transformIndexHtml`,
35
+ // which is guarded on `isProduction`, and the module is not reachable from any
36
+ // entry a build follows.
37
+
38
+ import axe from "axe-core";
39
+
40
+ /**
41
+ * Whether the engine is mid-run, for every audit in the process.
42
+ *
43
+ * Module scope because `axe` is a singleton and holds a single-run lock of its
44
+ * own: asking it for a second run while one is going answers "Axe is already
45
+ * running" rather than a result, and a per-page flag would not see the other
46
+ * page's run. One engine, one lock.
47
+ */
48
+ let engineBusy = false;
49
+
50
+ /** Most violations one report names; the rest are counted. */
51
+ const MAX_VIOLATIONS_REPORTED = 8;
52
+
53
+ /** Longest excerpt of an element's markup a line quotes. */
54
+ const MAX_NODE_CHARS = 100;
55
+
56
+ /**
57
+ * Start auditing.
58
+ *
59
+ * `options` is generated by `./a11y.js` and carries the project's rule set,
60
+ * the endpoint to report to, and nothing a page could have chosen: everything
61
+ * here is decided by `uf.config.js` on the server before the module exists.
62
+ */
63
+ export function start(options) {
64
+ const endpoint = options.endpoint;
65
+ // How long the DOM has to stop moving before an audit runs. Decided by
66
+ // `./a11y.js` and written into this module's last line rather than kept here
67
+ // as a constant, so there is one number rather than two — and so a test can
68
+ // drive the loop without waiting three quarters of a second per assertion.
69
+ const settleMs = options.settleMs;
70
+ const runOptions = axeOptions(options.axe ?? {});
71
+ const floor = options.axe?.minImpact ?? null;
72
+ let timer = null;
73
+ // Whether `stop` has been called. A run already in flight cannot be
74
+ // cancelled, but nothing after it should act as though the page is still
75
+ // being watched.
76
+ let stopped = false;
77
+ // What the last report said, so a re-render that changes nothing the audit
78
+ // cares about does not print the same block again. A page under active
79
+ // editing re-renders constantly; a terminal that repeats itself every time
80
+ // is a terminal nobody reads.
81
+ let reported = "";
82
+
83
+ const audit = async () => {
84
+ if (stopped || document.hidden) return;
85
+ if (engineBusy) {
86
+ // Not a run to skip — a run to come back for. The timer that brought us
87
+ // here cleared itself before calling, so returning without scheduling
88
+ // again loses the mutation for good: nothing is left pending, and the
89
+ // DOM it settled into never gets audited. Rescheduling rather than
90
+ // queueing a flag also means the wait is another settle, which is right
91
+ // — the tree may still be moving — and it is the same answer whether the
92
+ // engine is busy for this page or for another one.
93
+ schedule();
94
+ return;
95
+ }
96
+ engineBusy = true;
97
+ try {
98
+ const results = await axe.run(document, runOptions);
99
+ // The engine takes as long as it takes, and `stop` can land in the
100
+ // middle of it. A caller that has said it is done is not expecting one
101
+ // more report a second later.
102
+ if (stopped) return;
103
+ const violations = (results?.violations ?? []).filter((violation) =>
104
+ atOrAbove(violation.impact, floor),
105
+ );
106
+ const summary = violations.map((violation) => violation.id).join(",");
107
+ if (summary === reported) return;
108
+ reported = summary;
109
+ if (violations.length > 0) report(endpoint, violations);
110
+ } catch (error) {
111
+ // An audit that throws is a bug in this file or in the engine, and it
112
+ // must not take the page with it: `uf dev` is for developing the
113
+ // application, not this. Reported once, at `info`, and then the run is
114
+ // over — `reported` is left as it was so the next settle tries again.
115
+ report(endpoint, null, error);
116
+ } finally {
117
+ engineBusy = false;
118
+ }
119
+ };
120
+
121
+ const schedule = () => {
122
+ if (timer != null) clearTimeout(timer);
123
+ timer = setTimeout(() => {
124
+ timer = null;
125
+ void audit();
126
+ }, settleMs);
127
+ };
128
+
129
+ const observer = new MutationObserver(schedule);
130
+ observer.observe(document.documentElement, {
131
+ subtree: true,
132
+ childList: true,
133
+ attributes: true,
134
+ });
135
+ document.addEventListener("visibilitychange", () => {
136
+ if (!document.hidden) schedule();
137
+ });
138
+ schedule();
139
+
140
+ // Returned so a caller that owns the page's lifetime can end the loop. The
141
+ // generated call in `./a11y.js` ignores it: a dev server's page is over when
142
+ // the document is, and nothing outlives that.
143
+ return () => {
144
+ stopped = true;
145
+ if (timer != null) clearTimeout(timer);
146
+ observer.disconnect();
147
+ };
148
+ }
149
+
150
+ /** The project's rule set in axe's own vocabulary. */
151
+ function axeOptions(settings) {
152
+ const options = {};
153
+ const tags = settings.tags ?? [];
154
+ if (tags.length > 0) options.runOnly = { type: "tag", values: [...tags] };
155
+ const disabled = settings.disabledRules ?? [];
156
+ if (disabled.length > 0) {
157
+ const rules = {};
158
+ for (const rule of disabled) rules[rule] = { enabled: false };
159
+ options.rules = rules;
160
+ }
161
+ return options;
162
+ }
163
+
164
+ /** Impacts weakest first, so a floor is a comparison rather than a match. */
165
+ const IMPACTS = ["minor", "moderate", "serious", "critical"];
166
+
167
+ /**
168
+ * Whether a violation clears the configured floor.
169
+ *
170
+ * A violation axe could not rate clears every floor: "we do not know how bad
171
+ * this is" is not a reason to hide it.
172
+ */
173
+ function atOrAbove(impact, floor) {
174
+ if (floor == null || impact == null) return true;
175
+ const at = IMPACTS.indexOf(impact);
176
+ return at === -1 || at >= IMPACTS.indexOf(floor);
177
+ }
178
+
179
+ /** One element, short enough to be one line of a terminal. */
180
+ function excerpt(html) {
181
+ const line = String(html ?? "")
182
+ .split("\n")[0]
183
+ .trim();
184
+ return line.length > MAX_NODE_CHARS ? `${line.slice(0, MAX_NODE_CHARS)}…` : line;
185
+ }
186
+
187
+ /**
188
+ * Send one report down the diagnostic channel.
189
+ *
190
+ * `keepalive` and a swallowed failure, because this is telemetry about the
191
+ * page rather than part of it: a dev server that has gone away must not turn
192
+ * into an unhandled rejection in the application being developed.
193
+ */
194
+ function report(endpoint, violations, error) {
195
+ const body =
196
+ violations == null
197
+ ? {
198
+ severity: "info",
199
+ message: `the accessibility audit could not run: ${String(error)}`,
200
+ }
201
+ : {
202
+ severity: "warn",
203
+ message: `${violations.length} accessibility ${
204
+ violations.length === 1 ? "violation" : "violations"
205
+ } on this page`,
206
+ detail: detailLines(violations),
207
+ };
208
+ void fetch(endpoint, {
209
+ method: "POST",
210
+ keepalive: true,
211
+ headers: { "content-type": "application/json" },
212
+ body: JSON.stringify({ ...body, url: window.location.href }),
213
+ }).then(
214
+ () => {},
215
+ () => {},
216
+ );
217
+ }
218
+
219
+ /** One line per rule, then the elements that broke it. */
220
+ function detailLines(violations) {
221
+ const lines = [];
222
+ for (const violation of violations.slice(0, MAX_VIOLATIONS_REPORTED)) {
223
+ const impact = violation.impact == null ? "" : ` (${violation.impact})`;
224
+ lines.push(`${violation.id}${impact} — ${violation.help}`);
225
+ for (const node of (violation.nodes ?? []).slice(0, 2)) {
226
+ lines.push(` ${excerpt(node.html)}`);
227
+ }
228
+ if (violation.helpUrl) lines.push(` ${violation.helpUrl}`);
229
+ }
230
+ if (violations.length > MAX_VIOLATIONS_REPORTED) {
231
+ lines.push(`…and ${violations.length - MAX_VIOLATIONS_REPORTED} more rules`);
232
+ }
233
+ return lines;
234
+ }
@@ -0,0 +1,100 @@
1
+ // @noflow
2
+ //
3
+ // Plain JavaScript: executed by the host that runs Vite, before any transform.
4
+ //
5
+ // Serving `./a11y-runtime.js` to the page `uf dev` renders.
6
+ //
7
+ // The same shape as `./refresh.js`, and deliberately so: a virtual module
8
+ // whose source is a file read from disk, and a tag added to every development
9
+ // document that imports it. Two mechanisms for "a module uf injects into the
10
+ // page" would be two things to keep working.
11
+ //
12
+ // What is different is the gate. Fast Refresh is always there; this is not.
13
+ // axe-core is an *optional* dependency — uf does not make a project that never
14
+ // audits carry a megabyte of rule definitions — so the injection is decided by
15
+ // asking the project's own `node_modules` whether the engine is installed. The
16
+ // question is answered on the server, once, before anything is injected, which
17
+ // is why a project without the engine gets no script, no failed import and no
18
+ // error in a console it did not open.
19
+
20
+ import { createRequire } from "node:module";
21
+ import path from "node:path";
22
+ import { readFileSync } from "node:fs";
23
+ import { fileURLToPath } from "node:url";
24
+
25
+ import { DIAGNOSTIC_ENDPOINT } from "./diagnostics.js";
26
+
27
+ /** Public URL the audit runtime is served from. */
28
+ export const AUDIT_PUBLIC_PATH = "/@uf-a11y";
29
+
30
+ /** The resolved id Vite hands back for it. */
31
+ export const AUDIT_RESOLVED_ID = "\0uf:a11y-audit";
32
+
33
+ const RUNTIME_SOURCE_PATH = fileURLToPath(new URL("./a11y-runtime.js", import.meta.url));
34
+
35
+ /**
36
+ * How long the DOM has to stop moving before an audit runs, in milliseconds.
37
+ *
38
+ * A React render is hundreds of mutations and axe walks the whole subtree, so
39
+ * without a window this would be a stutter rather than a background job. Long
40
+ * enough to sit out a render and short enough that somebody who has just saved
41
+ * a file hears about it while they are still looking at the page.
42
+ */
43
+ export const SETTLE_MS = 750;
44
+
45
+ /**
46
+ * Whether the project has axe-core.
47
+ *
48
+ * Resolved from the project root rather than from this package, because the
49
+ * dependency is the *project's* — `@uniflowed/vite` declares it as an optional
50
+ * peer, so it is installed beside the application and not beside the plugin.
51
+ *
52
+ * A failure to resolve is the ordinary answer, not an error: "no engine" is
53
+ * what most projects will say, and it is the reason this function exists.
54
+ */
55
+ export function auditAvailable(root) {
56
+ try {
57
+ createRequire(path.join(root, "package.json")).resolve("axe-core");
58
+ return true;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * The audit module's source, with the project's settings written into its
66
+ * last line.
67
+ *
68
+ * Generated rather than passed at runtime because the module is a *module*:
69
+ * there is nowhere for a caller to hand it arguments, and a global would be a
70
+ * second name to agree on. `JSON.stringify` of a value uf built from its own
71
+ * config — never from anything a page said — is what goes in.
72
+ */
73
+ export function auditRuntimeSource(settings) {
74
+ const options = {
75
+ endpoint: DIAGNOSTIC_ENDPOINT,
76
+ settleMs: SETTLE_MS,
77
+ axe: {
78
+ tags: settings?.tags ?? [],
79
+ disabledRules: settings?.disabledRules ?? [],
80
+ minImpact: settings?.minImpact ?? null,
81
+ },
82
+ };
83
+ return `${readFileSync(RUNTIME_SOURCE_PATH, "utf8")}\nstart(${JSON.stringify(options)});\n`;
84
+ }
85
+
86
+ /**
87
+ * The tag that loads it, or `null` when this project has no engine.
88
+ *
89
+ * `injectTo: "body"` rather than the head: the audit reads the rendered tree,
90
+ * so there is nothing for it to do until there is one, and a script in the
91
+ * head would only sit through the same wait with the parser stopped behind it.
92
+ */
93
+ export function auditTag(base, available) {
94
+ if (!available) return null;
95
+ return {
96
+ tag: "script",
97
+ attrs: { type: "module", src: `${base}${AUDIT_PUBLIC_PATH.slice(1)}` },
98
+ injectTo: "body",
99
+ };
100
+ }