tosijs-ui 1.17.0-beta.2 → 1.17.0-beta.4

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/CHANGELOG.md CHANGED
@@ -1,6 +1,41 @@
1
1
  # Changelog
2
2
 
3
- ## 1.17.0-beta.2
3
+ ## 1.17.0-beta.4
4
+
5
+ > Prerelease, published under the `beta` dist-tag: install with `tosijs-ui@beta`. `latest` stays
6
+ > on 1.16.41. Fixes from Learn 3D and from tosijs-native's first site build.
7
+
8
+ - **Every doc page can be scrolled by keyboard, and has a main landmark (#3575).** A page of
9
+ plain prose had nothing to tab to inside its scrolling content, so a keyboard user could not
10
+ scroll it, which axe reports as `scrollable-region-focusable` (Learn 3D: 71 of 126 pages). The
11
+ content scroller is now focusable, and it is the page's main landmark, which the doc site did not
12
+ have at all: a screen reader's "jump to main content" now works. A doc browser embedded in
13
+ another page stays focusable without claiming that page's main landmark.
14
+ - **`buildSite` says it needs `happy-dom` before it starts (#3600).** Without it, the build died
15
+ partway through with "Cannot find package 'happy-dom'" from a file inside tosijs-ui. It now
16
+ stops before writing anything and prints the install command. `happy-dom` stays an optional
17
+ peer: a project that only uses the components never needs it.
18
+ - **TJS's own doc comments, `/# … #/`, become documentation (#3601).** In `.tjs` and `.ajs`
19
+ files they are read in source order with any `/*#` blocks. tjs-lang finds them, so its grammar
20
+ applies exactly; that needs a tjs-lang that exports `findDocCommentSpans` (requested). With an
21
+ older one, the build names each file whose `/#` comments it could not read, instead of
22
+ dropping the page silently. `.ajs` files are now scraped for `/*#` blocks too.
23
+
24
+ ### 1.17.0-beta.3 (prerelease detail)
25
+
26
+ > Prerelease, published under the `beta` dist-tag: install with `tosijs-ui@beta`. `latest` stays
27
+ > on 1.16.41. beta.2 was staged and never published; its notes follow.
28
+
29
+ - **The axe audit waits for a page marked `aria-busy`, and one slow page no longer ends it.** A
30
+ page that is still loading (a 3D scene fetching large models, say) marks an element
31
+ `aria-busy="true"`, the standard ARIA signal, and is waited for up to 30 seconds; other pages
32
+ are audited once they have stopped adding elements, or after five seconds. A page that never
33
+ loads, or is still busy at 30 seconds, is listed as not fully checked, and the build makes no
34
+ claim about it. Before, each page load waited for the network to go idle, so a page that kept
35
+ downloading for 30 seconds, or that polled, threw and ended the audit of every page. The doc
36
+ site reference shows a component setting and clearing `aria-busy`.
37
+
38
+ ### 1.17.0-beta.2 (prerelease detail)
4
39
 
5
40
  > Prerelease, published under the `beta` dist-tag: install with `tosijs-ui@beta`. `latest` stays
6
41
  > on 1.16.41. Fixes from Learn 3D's first run of beta.1.
@@ -1699,7 +1699,17 @@ export function createDocBrowser(options) {
1699
1699
  // The sidenav has just introduced itself; give it the layout the doc asked for.
1700
1700
  applyFullScreen();
1701
1701
  },
1702
- }, navContent, div({
1702
+ }, navContent,
1703
+ /*
1704
+ The page's main landmark, and focusable: a page of plain prose has nothing else to tab
1705
+ to, so a keyboard user could not scroll it at all (WCAG 2.1.1; axe
1706
+ scrollable-region-focusable, #3575). A role, not a <main>, so styles written against the
1707
+ div still apply. An embedded browser (`memory` routing) is part of someone else's page,
1708
+ so it does not claim that page's main landmark.
1709
+ */
1710
+ div({
1711
+ ...(routing === 'memory' ? {} : { role: 'main' }),
1712
+ tabindex: 0,
1703
1713
  style: {
1704
1714
  position: 'relative',
1705
1715
  overflowY: 'scroll',
@@ -10,7 +10,7 @@ tosijs-ui. When either is missing, or Chromium is not installed, it says which a
10
10
  NOT_RUN_EXIT, so the build can say the audit did not run instead of reporting a pass.
11
11
 
12
12
  bun a11y-audit-cli.ts <payload.json> # payload = { outputDir, basePath, pages }
13
- stdout: one JSON line, { violations: AxeFinding[] }
13
+ stdout: one JSON line, { violations: AxeFinding[], unsettled: { page, reason }[] }
14
14
 
15
15
  Build-time only. Never import this from browser code.
16
16
  */
@@ -19,7 +19,7 @@ import { createRequire } from 'module';
19
19
  import { existsSync } from 'fs';
20
20
  import { NOT_RUN_EXIT, AXE_TAGS } from './a11y-audit.js';
21
21
  const payloadPath = process.argv[2];
22
- const { outputDir, basePath, pages } = JSON.parse(await Bun.file(payloadPath).text());
22
+ const { outputDir, basePath, pages, slowPageMs } = JSON.parse(await Bun.file(payloadPath).text());
23
23
  const notRun = (why) => {
24
24
  console.error(why);
25
25
  process.exit(NOT_RUN_EXIT);
@@ -69,7 +69,12 @@ Pages are audited a few at a time, each in its own tab: one at a time took Learn
69
69
  to ~73s (#3585). Results are kept in page order, so the report reads the same either way.
70
70
  */
71
71
  const CONCURRENCY = 4;
72
+ /** How long a page may take to fire `load`, and to finish being `aria-busy`. */
73
+ const SLOW_PAGE_MS = slowPageMs ?? 30_000;
74
+ /** How long an un-busy page may keep adding elements before it is audited as it stands. */
75
+ const QUIET_CAP_MS = 5_000;
72
76
  const perPage = [];
77
+ const unsettled = [];
73
78
  try {
74
79
  const context = await browser.newContext();
75
80
  /*
@@ -83,9 +88,32 @@ try {
83
88
  while (next < pages.length) {
84
89
  const index = next++;
85
90
  const pagePath = pages[index];
86
- await page.goto(`http://localhost:${server.port}${prefix}${pagePath}`, {
87
- waitUntil: 'networkidle',
88
- });
91
+ /*
92
+ One slow or broken page must not end the audit of every other page, and must not pass
93
+ unremarked either: it is listed as not fully checked.
94
+ */
95
+ try {
96
+ await page.goto(`http://localhost:${server.port}${prefix}${pagePath}`, {
97
+ waitUntil: 'load',
98
+ timeout: SLOW_PAGE_MS,
99
+ });
100
+ }
101
+ catch (error) {
102
+ const message = error.message.split('\n')[0];
103
+ if (!/timeout/i.test(message)) {
104
+ unsettled.push({ page: pagePath, reason: `not audited: ${message}` });
105
+ perPage[index] = [];
106
+ continue;
107
+ }
108
+ unsettled.push({
109
+ page: pagePath,
110
+ reason: `did not finish loading in ${SLOW_PAGE_MS / 1000}s; audited as it stood`,
111
+ });
112
+ }
113
+ // Best effort: a page that polls or streams never goes idle, and that is not a defect.
114
+ await page
115
+ .waitForLoadState('networkidle', { timeout: 10_000 })
116
+ .catch(() => undefined);
89
117
  // Audit the page a reader uses: hydrated when the bundle loads, static if it does not.
90
118
  if (await page.$('tosi-doc-system'))
91
119
  await page
@@ -94,26 +122,42 @@ try {
94
122
  })
95
123
  .catch(() => undefined);
96
124
  /*
97
- Then until no element has been added or removed for half a second (at most five): live examples render
98
- after hydration, and a fixed pause audited a busy machine's pages half-built, so the
99
- findings changed from run to run.
125
+ Then until the page has settled: no element added or removed for half a second, and
126
+ nothing marked `aria-busy="true"`. A fixed pause audited a busy machine's pages
127
+ half-built. A page that is still loading (a 3D scene fetching its models, say) says so with
128
+ aria-busy, which screen readers also announce, and is waited for up to SLOW_PAGE_MS; one
129
+ that merely keeps changing is audited after QUIET_CAP_MS.
100
130
  */
101
- await page.evaluate(() => new Promise((resolve) => {
102
- let timer = setTimeout(done, 500);
103
- const deadline = setTimeout(done, 5000);
131
+ const settled = await page.evaluate(({ quietCap, busyCap }) => new Promise((resolve) => {
132
+ const start = performance.now();
133
+ let last = start;
104
134
  const observer = new MutationObserver(() => {
105
- clearTimeout(timer);
106
- timer = setTimeout(done, 500);
135
+ last = performance.now();
107
136
  });
108
- function done() {
109
- observer.disconnect();
110
- clearTimeout(timer);
111
- clearTimeout(deadline);
112
- resolve();
113
- }
114
137
  // Elements arriving, not attributes: an animation changes a style forever.
115
138
  observer.observe(document, { subtree: true, childList: true });
116
- }));
139
+ const tick = () => {
140
+ const now = performance.now();
141
+ const busy = !!document.querySelector('[aria-busy="true"]');
142
+ const outcome = now - last >= 500 && !busy
143
+ ? 'settled'
144
+ : !busy && now - start >= quietCap
145
+ ? 'changing'
146
+ : now - start >= busyCap
147
+ ? 'busy'
148
+ : '';
149
+ if (!outcome)
150
+ return void setTimeout(tick, 100);
151
+ observer.disconnect();
152
+ resolve(outcome);
153
+ };
154
+ tick();
155
+ }), { quietCap: QUIET_CAP_MS, busyCap: SLOW_PAGE_MS });
156
+ if (settled === 'busy')
157
+ unsettled.push({
158
+ page: pagePath,
159
+ reason: `still aria-busy after ${SLOW_PAGE_MS / 1000}s; audited as it stood`,
160
+ });
117
161
  await page.addScriptTag({ path: axePath });
118
162
  const found = await page.evaluate(async (tags) => {
119
163
  const result = await window.axe.run(document, {
@@ -136,4 +180,4 @@ finally {
136
180
  server.stop(true);
137
181
  }
138
182
  const violations = perPage.flat();
139
- console.log(JSON.stringify({ violations }));
183
+ console.log(JSON.stringify({ violations, unsettled }));
@@ -11,9 +11,15 @@ export interface AxeFinding {
11
11
  /** a CSS selector for each element that fails the rule */
12
12
  targets: string[];
13
13
  }
14
+ /** A page the audit could not check fully: it never loaded, or never stopped being busy. */
15
+ export interface UnsettledPage {
16
+ page: string;
17
+ reason: string;
18
+ }
14
19
  export type AxeAuditResult = {
15
20
  ran: true;
16
21
  violations: AxeFinding[];
22
+ unsettled: UnsettledPage[];
17
23
  } | {
18
24
  ran: false;
19
25
  reason: string;
@@ -25,6 +31,8 @@ export declare function auditBuiltSite(opts: {
25
31
  pages: string[];
26
32
  /** the project Playwright and axe-core are resolved from (default: the working directory) */
27
33
  projectDir?: string;
34
+ /** how long a page may take to load, or stay `aria-busy="true"` (default 30s) */
35
+ slowPageMs?: number;
28
36
  }): Promise<AxeAuditResult>;
29
37
  /** The report: one line per rule per page, worst impact first. */
30
38
  export declare function formatAxeFindings(violations: AxeFinding[]): string;
@@ -25,6 +25,7 @@ export async function auditBuiltSite(opts) {
25
25
  outputDir: path.resolve(opts.outputDir),
26
26
  basePath: opts.basePath ?? '',
27
27
  pages: opts.pages,
28
+ slowPageMs: opts.slowPageMs,
28
29
  }));
29
30
  // `--no-install`: without it Bun resolves a package the project does not have from its global
30
31
  // cache, so "not installed" would never be said and the audit would use a Playwright nobody chose.
@@ -42,8 +43,10 @@ export async function auditBuiltSite(opts) {
42
43
  return { ran: false, reason: stderr.trim() };
43
44
  const line = stdout.trim().split('\n').pop() ?? '';
44
45
  try {
45
- if (code === 0)
46
- return { ran: true, ...JSON.parse(line) };
46
+ if (code === 0) {
47
+ const { violations, unsettled = [] } = JSON.parse(line);
48
+ return { ran: true, violations, unsettled };
49
+ }
47
50
  }
48
51
  catch {
49
52
  // fall through: a run that printed no result did not run
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The install command for each build peer that cannot be resolved, or none.
3
+ * `resolve` is injectable for tests.
4
+ */
5
+ export declare function missingBuildPeers(moduleDir: string, resolve?: (name: string, from: string) => string): string[];
6
+ export declare function missingBuildPeersMessage(missing: string[]): string;
@@ -0,0 +1,43 @@
1
+ /*
2
+ Optional peers a site BUILD cannot do without (#3600).
3
+
4
+ `happy-dom` is an optional peer of tosijs-ui because a project that only uses the components
5
+ never needs it, and npm installs a required peer whether or not it is used. But `buildSite`
6
+ does need it: the theme stylesheet is generated with tosijs imported under a DOM shim. Without
7
+ it the build died several steps in with "Cannot find package 'happy-dom'" from a file inside
8
+ tosijs-ui, which says nothing about whose job it is to install it. So the build checks first,
9
+ and says what to run.
10
+ */
11
+ import { readFileSync } from 'fs';
12
+ import * as path from 'path';
13
+ import { tosijsUiDir } from './codemirror-copies.js';
14
+ /** What a site build requires, resolved from tosijs-ui's own location (where it is imported). */
15
+ const BUILD_PEERS = ['happy-dom'];
16
+ /**
17
+ * The install command for each build peer that cannot be resolved, or none.
18
+ * `resolve` is injectable for tests.
19
+ */
20
+ export function missingBuildPeers(moduleDir, resolve = Bun.resolveSync) {
21
+ let ranges = {};
22
+ try {
23
+ const pkg = JSON.parse(readFileSync(path.join(tosijsUiDir(moduleDir), 'package.json'), 'utf8'));
24
+ ranges = pkg.peerDependencies ?? {};
25
+ }
26
+ catch {
27
+ // no manifest found: name the package without a range
28
+ }
29
+ return BUILD_PEERS.filter((name) => {
30
+ try {
31
+ resolve(name, moduleDir);
32
+ return false;
33
+ }
34
+ catch {
35
+ return true;
36
+ }
37
+ }).map((name) => (ranges[name] ? `${name}@${ranges[name]}` : name));
38
+ }
39
+ export function missingBuildPeersMessage(missing) {
40
+ return (`\n🛑 Building a site needs ${missing.join(', ')}, an optional peer of tosijs-ui ` +
41
+ `(a project that only uses the components does not).\n` +
42
+ ` bun add -d ${missing.map((m) => `'${m}'`).join(' ')}\n`);
43
+ }
@@ -143,5 +143,9 @@ export declare function parseFrontmatter(content: string): {
143
143
  body: string;
144
144
  };
145
145
  export declare const SCRAPED_SOURCE_EXTENSIONS: string[];
146
+ type DocSpans = (source: string) => Array<[number, number]>;
147
+ /** Tests only: stand in for tjs-lang's scanner (`undefined` restores the lookup). */
148
+ export declare function setTjsDocCommentScanner(scan: DocSpans | null | undefined): void;
146
149
  export declare function extractDocs(options: ExtractDocsOptions): Doc[];
147
150
  export declare function saveDocsJSON(docs: Doc[], outputPath: string): void;
151
+ export {};
@@ -30,8 +30,13 @@ whatever metadata the doc declares (`pin`, `order`, `parent`, `layout`, `descrip
30
30
  ## What is collected
31
31
 
32
32
  - **`.md` files**, whole. YAML frontmatter is read and removed.
33
- - **`/*#` … `*‎/` blocks** in `.ts`, `.tjs`, `.js` and `.css` files, as markdown. The first heading
34
- is the page title. A block counts only when `/*#` starts a line.
33
+ - **`/*#` … `*‎/` blocks** in `.ts`, `.tjs`, `.ajs`, `.js` and `.css` files, as markdown. The
34
+ first heading is the page title. A block counts only when `/*#` starts a line.
35
+ - **TJS doc comments, `/#` … `#/`**, in `.tjs` and `.ajs` files, in source order with any `/*#`
36
+ blocks. They are found by tjs-lang itself, so they follow its grammar exactly: multi-line, and
37
+ a doc comment nested inside one is quoted as part of it. This needs a tjs-lang that exports
38
+ `findDocCommentSpans`; with an older one, the build names each file whose `/#` comments it
39
+ could not read.
35
40
  - **Files whose name starts with `_`** are skipped: use the prefix for drafts and fragments.
36
41
 
37
42
  Metadata blocks (`<!--{ … }-->` in markdown, `/*{ … }*‎/` in code), nav order, sections and
@@ -40,6 +45,7 @@ which code fences run live are described under [Doc format](/doc-site-system/#do
40
45
  /*{ "parent": "Documentation System", "order": 60, "description": "extractDocs() collects markdown files and doc comments from source code into one docs.json corpus, the input to the doc browser and static site." }*/
41
46
  // TODO CLI options
42
47
  import * as fs from 'fs';
48
+ import { createRequire } from 'module';
43
49
  import * as path from 'path';
44
50
  import { truncationWarnings, formatTruncationWarnings, } from './truncated-doc.js';
45
51
  import { pinnedSort } from '../nav-tree.js';
@@ -230,9 +236,47 @@ ends it — the same trap tosijs-ui#70 records for doc blocks, hit again while w
230
236
 
231
237
  See `warnUnscrapedDocBlocks` below: a missing extension should never again be invisible.
232
238
  */
233
- export const SCRAPED_SOURCE_EXTENSIONS = ['.ts', '.js', '.tjs', '.css'];
239
+ export const SCRAPED_SOURCE_EXTENSIONS = ['.ts', '.js', '.tjs', '.ajs', '.css'];
234
240
  /** Files that look documented but are not scraped — reported once at the end of a walk. */
235
241
  const unscrapedDocBlocks = [];
242
+ /*
243
+ TJS's own doc comment, `/# … #/` (#3601): multi-line, nestable, and the convention in `.tjs` and
244
+ `.ajs` sources. Its grammar belongs to tjs-lang, which finds the spans over a literal-masked view
245
+ (a raw scan reads a regex `/#/` and a later string `"#/"` as one comment). So this does not keep a
246
+ second copy: it asks the installed tjs-lang, and when that tjs-lang does not export the scanner,
247
+ the build names the files whose doc comments it could not read rather than dropping them.
248
+ */
249
+ const TJS_DOC_EXTENSIONS = ['.tjs', '.ajs'];
250
+ // An opener at the start of a line that its own line does not close: a single-line `/# x #/` is
251
+ // a regex, never a doc comment.
252
+ const TJS_DOC_OPENER = /^[ \t]*\/#(?![^\n]*#\/)/m;
253
+ let tjsDocSpans;
254
+ function tjsDocCommentSpans() {
255
+ if (tjsDocSpans !== undefined)
256
+ return tjsDocSpans;
257
+ tjsDocSpans = null;
258
+ // The project's tjs-lang, the one its .tjs files are written for.
259
+ const projectModule = createRequire(path.resolve('package.json'));
260
+ for (const entry of ['tjs-lang/lang', 'tjs-lang']) {
261
+ try {
262
+ const found = projectModule(entry).findDocCommentSpans;
263
+ if (typeof found === 'function') {
264
+ tjsDocSpans = found;
265
+ break;
266
+ }
267
+ }
268
+ catch {
269
+ // not installed, or no such entry: try the next
270
+ }
271
+ }
272
+ return tjsDocSpans;
273
+ }
274
+ /** Tests only: stand in for tjs-lang's scanner (`undefined` restores the lookup). */
275
+ export function setTjsDocCommentScanner(scan) {
276
+ tjsDocSpans = scan;
277
+ }
278
+ /** `.tjs`/`.ajs` files with doc comments the installed tjs-lang could not read. */
279
+ const unreadTjsDocComments = [];
236
280
  /**
237
281
  * Does an UNSCRAPED file appear to carry a doc block?
238
282
  *
@@ -385,9 +429,41 @@ walked) {
385
429
  // before the slash). This keeps a `/*#` that appears inside a // comment,
386
430
  // a string, or otherwise mid-line from being scraped as a spurious doc.
387
431
  // m[1] is the block without its leading indentation.
388
- const docs = [...content.matchAll(/^[ \t]*(\/\*#[\s\S]+?\*\/)/gm)].map((m) => m[1]);
432
+ const blocks = [
433
+ ...content.matchAll(/^[ \t]*(\/\*#[\s\S]+?\*\/)/gm),
434
+ ].map((m) => ({
435
+ at: m.index + m[0].length - m[1].length,
436
+ block: m[1],
437
+ }));
438
+ const docs = blocks.map(({ block }) => block.substring(3, block.length - 2).trim());
439
+ // TJS doc comments, in source order with any `/*#` blocks.
440
+ if (TJS_DOC_EXTENSIONS.includes(path.extname(file)) &&
441
+ TJS_DOC_OPENER.test(content)) {
442
+ const scan = tjsDocCommentSpans();
443
+ if (!scan) {
444
+ unreadTjsDocComments.push(path.relative(process.cwd(), filePath));
445
+ }
446
+ else {
447
+ const spans = scan(content);
448
+ // Outermost only: a nested doc comment is being quoted, not published.
449
+ const outer = spans.filter(([a, b]) => !spans.some(([c, d]) => c < a && b <= d));
450
+ const all = [
451
+ ...blocks.map(({ at }, i) => ({ at, text: docs[i] })),
452
+ ...outer.map(([a, b]) => ({
453
+ at: a,
454
+ // The span may or may not include its delimiters; the text never does.
455
+ text: content
456
+ .slice(a, b)
457
+ .replace(/^\/#/, '')
458
+ .replace(/#\/$/, '')
459
+ .trim(),
460
+ })),
461
+ ].sort((x, y) => x.at - y.at);
462
+ docs.splice(0, docs.length, ...all.map((d) => d.text));
463
+ }
464
+ }
389
465
  if (docs.length) {
390
- const markdown = docs.map((s) => s.substring(3, s.length - 2).trim());
466
+ const markdown = docs;
391
467
  // A doc block that ended early drops the rest of the documentation silently, and
392
468
  // turns the code after it back into source — which surfaces as parse errors
393
469
  // pointing at prose rather than at the delimiter responsible (#70).
@@ -444,6 +520,14 @@ walked) {
444
520
  can act on without going looking. The fix is nearly always one entry in
445
521
  SCRAPED_SOURCE_EXTENSIONS.
446
522
  */
523
+ if (unreadTjsDocComments.length) {
524
+ console.warn(`\n⚠️ ${unreadTjsDocComments.length} file(s) have TJS doc comments (/# … #/) that this ` +
525
+ `build cannot read, so they have no page from them:\n` +
526
+ unreadTjsDocComments.map((f) => ` ${f}`).join('\n') +
527
+ `\n Reading them needs findDocCommentSpans from tjs-lang, which the installed ` +
528
+ `tjs-lang does not export. Until it does, write those blocks as /*# … */.\n`);
529
+ unreadTjsDocComments.length = 0;
530
+ }
447
531
  if (unscrapedDocBlocks.length) {
448
532
  const list = unscrapedDocBlocks.map((f) => ` ${f}`).join('\n');
449
533
  console.warn(`\n⚠️ ${unscrapedDocBlocks.length} file(s) look documented but are not scraped, so ` +
@@ -31,6 +31,7 @@ export function registerSiteDialects(config) {
31
31
  import { namedBooks, partitionByBook, DEFAULT_BOOK } from '../book-target.js';
32
32
  import { EPUB_IMAGE_EXIT, attachCovers, listEpubVolumes, printCoverManifestUrl, VOLUME_MANIFEST, renderEpubDownloads, } from './epub-volumes.js';
33
33
  import { buildSlugMap, pathForSlug } from '../routing.js';
34
+ import { missingBuildPeers, missingBuildPeersMessage } from './build-peers.js';
34
35
  import { assetStampInputs, computeAssetStamp, missingStampInputWarning, } from './asset-stamp.js';
35
36
  import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, } from 'fs';
36
37
  import { tmpdir } from 'os';
@@ -416,6 +417,12 @@ export async function buildSite(config, opts = {}) {
416
417
  }))) {
417
418
  return false;
418
419
  }
420
+ // A peer the build needs, checked before anything is deleted or written (#3600).
421
+ const missingPeers = missingBuildPeers(import.meta.dir);
422
+ if (missingPeers.length) {
423
+ console.error(missingBuildPeersMessage(missingPeers));
424
+ return false;
425
+ }
419
426
  // Dependency-audit gate. Runs on the initial build only — the dev server runs its
420
427
  // own synchronous audit just before binding the port, and `opts.skipAudit` keeps
421
428
  // watch rebuilds off the network. Fails the build (before the destructive rm -rf)
@@ -1706,12 +1713,25 @@ export async function buildSite(config, opts = {}) {
1706
1713
  console.warn(`\n⚠️ Accessibility: the axe audit did not run, so this build makes no claim\n` +
1707
1714
  ` about what it would find. ${audit.reason}\n`);
1708
1715
  }
1709
- else if (audit.violations.length) {
1710
- misconfigured(config.strict, `\n⚠️ Accessibility: axe found ${audit.violations.length} WCAG 2.2 AA failure(s) in the built pages:\n` +
1711
- `${formatAxeFindings(audit.violations)}\n`);
1712
- }
1713
1716
  else {
1714
- console.log(`♿ axe: no WCAG 2.2 AA failures in ${pages.length} page(s)`);
1717
+ // A page the audit could not check fully is said, never counted as clean.
1718
+ if (audit.unsettled.length)
1719
+ console.warn(`\n⚠️ Accessibility: ${audit.unsettled.length} page(s) were not fully checked, so ` +
1720
+ `this build makes no claim about them. A page that is still loading should mark ` +
1721
+ `itself aria-busy="true"; the audit waits up to 30s for it:\n` +
1722
+ audit.unsettled
1723
+ .map((u) => ` ${u.page} ${u.reason}`)
1724
+ .join('\n') +
1725
+ '\n');
1726
+ const checked = pages.length - audit.unsettled.length;
1727
+ if (audit.violations.length)
1728
+ misconfigured(config.strict, `\n⚠️ Accessibility: axe found ${audit.violations.length} WCAG 2.2 AA failure(s) in the built pages:\n` +
1729
+ `${formatAxeFindings(audit.violations)}\n`);
1730
+ else
1731
+ console.log(`♿ axe: no WCAG 2.2 AA failures in ${checked} fully checked page(s)` +
1732
+ (audit.unsettled.length
1733
+ ? ` (${audit.unsettled.length} not fully checked, above)`
1734
+ : ''));
1715
1735
  }
1716
1736
  }
1717
1737
  console.timeEnd('build');