tosijs-ui 1.17.0 → 1.17.1

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,5 +1,54 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.17.1
4
+
5
+ > Fixes from the 1.17.0 review, plus elastic table columns that can be dragged narrower. Nothing
6
+ > to change on upgrade. **`dist/iife.js` is unchanged in size** (506.2 kB gzip).
7
+
8
+ ### Security
9
+
10
+ Both affect sites that turn on the accessibility mode in **1.17.0 and its betas**; no advisory is
11
+ filed, because each needs the build running and the reach is local. Upgrade if you build with
12
+ the accessibility mode on a shared machine or network.
13
+
14
+ - The accessibility audit's temporary server bound every interface, and its root check was a bare
15
+ prefix, so for the length of the audit a request like `/..%2fdocs-private/key.txt` was served a
16
+ sibling directory whose name starts with the output directory's. It now listens on localhost
17
+ only and contains paths with root + separator.
18
+ - The audit's payload and the altText corpus, which can hold hidden pages, were written to
19
+ predictable names in the shared temp directory and never removed. They now go in private
20
+ temporary directories removed when the build ends.
21
+
22
+ ### Added
23
+
24
+ - With a `dist-sizes.json` in the project root (`bun run sizes` writes one in this repo), the site
25
+ build prints the bundle's gzip size change since that baseline and the version it was recorded
26
+ at.
27
+
28
+ ### Fixed
29
+
30
+ - `<tosi-table>`: an elastic column can now be dragged narrower than the width it is drawn at.
31
+ The last elastic column's edge is the table's edge, so dragging it moved nothing and quietly
32
+ shrank its `width` to the minimum, and a middle one fought the columns beside it. A drag now
33
+ fixes the dragged column, and the elastic columns to its left, at the width they are drawn at
34
+ (it sets `width` and `elastic: false`), so the edge follows the pointer exactly. Elastic
35
+ columns to its right absorb the difference; with none, the space it gives up is left beside
36
+ the columns, painted with `--tosi-table-fill`.
37
+ - Under path or query routing, clicking a `/slug/#heading` link in a doc puts the hash in the URL
38
+ and scrolls to the heading. It pushed the page's URL without the hash and landed at the top.
39
+ - The accessibility mode's axe audit and DAISY Ace run with a time limit (the audit's worst case
40
+ plus a minute; Ace ten minutes). One that hangs is stopped and reported as not having run,
41
+ instead of hanging the build, and a build stopped with Ctrl-C or SIGTERM stops them too,
42
+ instead of leaving them and their browser running.
43
+ - The ePub accessibility check accepted any program named `ace` on PATH as DAISY Ace, so an
44
+ unrelated tool could decide whether a book claims WCAG conformance. It now accepts only an
45
+ `ace` inside a `@daisy/ace` package; another is skipped and named in the reason the check did
46
+ not run, and a real Ace later on PATH is still found.
47
+ - `llms.txt` link titles no longer carry the project suffix ("Menus — tosijs-ui" on every line).
48
+ - When a delegated build is refused because the dev server has a different site config, the
49
+ message now says that a refusal on every build means the config computes a value per process
50
+ (`Date.now()`, a random id, an environment variable one process lacks), and how to fix that.
51
+
3
52
  ## 1.17.0
4
53
 
5
54
  > Accessibility and SEO for doc sites, and the components themselves passing WCAG 2.2 AA. A doc
package/README.md CHANGED
@@ -53,7 +53,7 @@ The `tosijs-ui` iife build bundles `tosijs`, `tosijs-ui`, `marked` and — as of
53
53
  **CodeMirror** into a single minified javascript source file. You can access `xinjs` and
54
54
  `xinjsui` as globals which contain all the things exported by `tosijs` and `tosijs-ui`.
55
55
 
56
- > **Size (1.16.21):** ~484KB gzip, up from ~120KB in 1.6. Two things dominate it, and
56
+ > **Size (1.17.0):** ~506KB gzip, up from ~120KB in 1.6. Two things dominate it, and
57
57
  > `bun build --format iife` cannot
58
58
  > code-split, so `<tosi-code>`'s lazy CodeMirror import is flattened into this bundle
59
59
  > whether or not the page uses an editor. **If you don't need `<tosi-code>`, use the ESM
@@ -15,7 +15,8 @@ export interface ColumnOptions {
15
15
  * Whether this column takes a share of the space left over when the columns add up to
16
16
  * less than the table's width. Elastic columns grow in proportion to their `width`, and
17
17
  * never shrink below it. Defaults to the table's `elastic`. A pinned column is never
18
- * elastic: its sticky offset is its width.
18
+ * elastic: its sticky offset is its width. Resizing a column by dragging sets this to
19
+ * `false` on it and on the elastic columns to its left, at the widths they were drawn at.
19
20
  */
20
21
  elastic?: boolean;
21
22
  sort?: false | 'ascending' | 'descending';
@@ -502,9 +503,7 @@ export declare class TosiTable extends TosiTable_base {
502
503
  private focusCell;
503
504
  private handleKeyNav;
504
505
  connectedCallback(): void;
505
- /** Where `col`'s right edge is drawn, from the left of the columns. */
506
- private drawnEdge;
507
- private widthForEdge;
506
+ private pinElasticThrough;
508
507
  private isElastic;
509
508
  private layoutColumns;
510
509
  setColumnWidths(): void;
@@ -1000,8 +1000,11 @@ creator) and every column is elastic unless it says `elastic: false`, which is t
1000
1000
  get it for columns inferred from the data. Pinned columns are never elastic. Elastic
1001
1001
  columns take precedence over `full-width-header`.
1002
1002
 
1003
- Dragging an elastic column's edge keeps the edge under the pointer; the other elastic
1004
- columns give up or take the difference.
1003
+ Dragging a column's edge resizes it to exactly where you drop it, narrower than its share
1004
+ included. The drag fixes that column, and any elastic columns to its left, at the width
1005
+ they were drawn at (it sets their `width` and `elastic: false`, as a resize always sets
1006
+ `width`): elastic columns to its right take up or give back the difference, and with none
1007
+ to its right, the space it gives up is left beside the columns.
1005
1008
 
1006
1009
  ### Filling the space beside the columns
1007
1010
 
@@ -3091,9 +3094,8 @@ export class TosiTable extends withAttributes({
3091
3094
  first drag on a fresh page into a text selection. See track-drag.ts, and #107.)
3092
3095
  */
3093
3096
  const prop = grabbed.prop;
3094
- let anchor = grabbed;
3095
- let anchorWidth = Number(grabbed.width);
3096
- let anchorEdge = this.drawnEdge(grabbed);
3097
+ let anchor;
3098
+ let anchorWidth = 0;
3097
3099
  let anchorDx = 0;
3098
3100
  const isTouchEvent = event.touches !== undefined;
3099
3101
  const touchIdentifier = isTouchEvent
@@ -3109,15 +3111,18 @@ export class TosiTable extends withAttributes({
3109
3111
  const live = this.columns.find((c) => c.prop === prop);
3110
3112
  if (!live)
3111
3113
  return true;
3114
+ // A click on the edge changes nothing: pin the columns on the first movement.
3115
+ if (anchor === undefined && dx === 0) {
3116
+ return event.type === 'mouseup' || undefined;
3117
+ }
3112
3118
  if (live !== anchor) {
3119
+ this.pinElasticThrough(live);
3120
+ // The drag measures from where it started; a replaced column rebases on this step.
3121
+ anchorDx = anchor === undefined ? 0 : dx;
3113
3122
  anchor = live;
3114
3123
  anchorWidth = Number(live.width);
3115
- anchorEdge = this.drawnEdge(live);
3116
- anchorDx = dx;
3117
3124
  }
3118
- const width = this.isElastic(live)
3119
- ? this.widthForEdge(live, anchorEdge + (dx - anchorDx))
3120
- : anchorWidth + (dx - anchorDx);
3125
+ const width = anchorWidth + (dx - anchorDx);
3121
3126
  live.width = width > this.minColumnWidth ? width : this.minColumnWidth;
3122
3127
  this.setColumnWidths();
3123
3128
  if (event.type === 'mouseup') {
@@ -3656,33 +3661,28 @@ export class TosiTable extends withAttributes({
3656
3661
  this.addEventListener('touchend', this.updateSelection);
3657
3662
  this.addEventListener('keydown', this.handleKeyNav);
3658
3663
  }
3659
- /** Where `col`'s right edge is drawn, from the left of the columns. */
3660
- drawnEdge(col) {
3661
- const cols = this.visibleColumns;
3662
- const { drawn } = this.layoutColumns(cols);
3663
- const index = cols.indexOf(col);
3664
- return drawn.slice(0, index + 1).reduce((sum, w) => sum + w, 0);
3665
- }
3666
3664
  /*
3667
- The width that draws an elastic column's right edge at `edge`, so a resize drag keeps the edge
3668
- under the pointer. An elastic column is drawn wider than its width and shares the leftover with
3669
- the others, so `width + dx` would move the edge by some fraction of dx. Its edge only ever
3670
- moves right as its width grows, so a bisection finds it.
3665
+ Before a drag resizes `col`, fix it and every elastic column to its left at the width they are
3666
+ drawn at. An elastic column's drawn width is not its width: the table's leftover space is shared
3667
+ out over the elastic columns, so the last one's right edge is the table's right edge whatever
3668
+ its width says, and dragging it narrower moved nothing (its width shrank to the minimum while
3669
+ it stayed drawn wide). Once they are fixed, the dragged edge is the sum of fixed widths and
3670
+ follows the pointer exactly; elastic columns to its right still share the leftover, and with
3671
+ none, the leftover is the space beside the columns. Nothing moves when the drag starts: the
3672
+ columns to the right are drawn at the same widths as before.
3671
3673
  */
3672
- widthForEdge(col, edge) {
3673
- const original = col.width;
3674
- let lo = this.minColumnWidth;
3675
- let hi = Math.max(lo, edge) * 64;
3676
- for (let i = 0; i < 40; i++) {
3677
- const mid = (lo + hi) / 2;
3678
- col.width = mid;
3679
- if (this.drawnEdge(col) < edge)
3680
- lo = mid;
3681
- else
3682
- hi = mid;
3674
+ pinElasticThrough(col) {
3675
+ const cols = this.visibleColumns;
3676
+ const index = cols.indexOf(col);
3677
+ if (index === -1)
3678
+ return;
3679
+ const { drawn } = this.layoutColumns(cols);
3680
+ for (let i = 0; i <= index; i++) {
3681
+ if (!this.isElastic(cols[i]))
3682
+ continue;
3683
+ cols[i].width = Math.round(drawn[i]);
3684
+ cols[i].elastic = false;
3683
3685
  }
3684
- col.width = original;
3685
- return Math.round(hi);
3686
3686
  }
3687
3687
  isElastic(col) {
3688
3688
  return !col.pinned && (col.elastic ?? this.elastic);
@@ -1011,7 +1011,14 @@ export function createDocBrowser(options) {
1011
1011
  const scrollToHashExample = () => {
1012
1012
  if (memoryRouting)
1013
1013
  return;
1014
- let hash = location.hash.replace(/^#/, '');
1014
+ scrollToTarget(location.hash);
1015
+ };
1016
+ /*
1017
+ Scroll to the element a `#hash` names in the content: the URL's hash on arrival (path and
1018
+ query routing), or a clicked link's (memory routing, which never touches the URL).
1019
+ */
1020
+ const scrollToTarget = (rawHash) => {
1021
+ let hash = rawHash.replace(/^#/, '');
1015
1022
  if (!hash)
1016
1023
  return;
1017
1024
  try {
@@ -1173,7 +1180,7 @@ export function createDocBrowser(options) {
1173
1180
  // User-initiated navigation: record the new location (history for path/query,
1174
1181
  // the onRouteChange callback for memory) and then render the doc. Every nav
1175
1182
  // click funnels through here so memory mode never touches window.history.
1176
- const go = (filename) => {
1183
+ const go = (filename, hash = '') => {
1177
1184
  // If the source editor is open with unsaved changes, confirm before leaving;
1178
1185
  // on confirm it discards + closes, otherwise navigation is aborted (false).
1179
1186
  if (!confirmLeaveEditor())
@@ -1182,7 +1189,8 @@ export function createDocBrowser(options) {
1182
1189
  onRouteChange?.(slugFor(filename));
1183
1190
  }
1184
1191
  else {
1185
- const href = hrefFor(filename);
1192
+ // The link's `#heading` goes into the URL, where showDoc's scrollToHashExample finds it.
1193
+ const href = hrefFor(filename) + hash;
1186
1194
  /*
1187
1195
  A link to the document already showing REPLACES the entry rather than pushing one, as a
1188
1196
  native same-URL navigation does. Pushing left a duplicate the reader had to Back
@@ -1194,10 +1202,13 @@ export function createDocBrowser(options) {
1194
1202
  may want to come Back from.
1195
1203
  */
1196
1204
  const same = filename === String(app.currentDoc.filename) &&
1197
- document.location.hash === '';
1205
+ document.location.hash === '' &&
1206
+ hash === '';
1198
1207
  window.history[same ? 'replaceState' : 'pushState']({ href }, '', href);
1199
1208
  }
1200
1209
  navigateTo(filename);
1210
+ if (memoryRouting)
1211
+ scrollToTarget(hash);
1201
1212
  return true;
1202
1213
  };
1203
1214
  // Intercept clicks on internal doc links anywhere in the rendered content so
@@ -1222,27 +1233,7 @@ export function createDocBrowser(options) {
1222
1233
  if (filename === null)
1223
1234
  return;
1224
1235
  event.preventDefault();
1225
- if (!go(filename))
1226
- return;
1227
- /*
1228
- `/slug/#heading`: path routing gets the scroll from the URL (scrollToHashExample); memory
1229
- routing never touches the URL, so it goes to the heading itself, once the doc has drawn.
1230
- */
1231
- const hash = new URL(anchor.href, document.location.href).hash.slice(1);
1232
- if (memoryRouting && hash) {
1233
- requestAnimationFrame(() => requestAnimationFrame(() => {
1234
- let id = hash;
1235
- try {
1236
- id = decodeURIComponent(hash);
1237
- }
1238
- catch {
1239
- // a stray % — look it up as written
1240
- }
1241
- docContent
1242
- .querySelector(`#${CSS.escape(id)}`)
1243
- ?.scrollIntoView({ block: 'start' });
1244
- }));
1245
- }
1236
+ go(filename, new URL(anchor.href, document.location.href).hash);
1246
1237
  });
1247
1238
  // ── Edit page source (Foundation B / #4) ──────────────────────────────────
1248
1239
  // The "view source" affordance opens a menu: edit the whole source file in a
@@ -17,3 +17,8 @@ export interface TitledDoc {
17
17
  * more specific label, so use it alone.
18
18
  */
19
19
  export declare function pageTitle(doc: TitledDoc, projectName?: string): string;
20
+ /**
21
+ * "Menus — tosijs-ui" → "Menus": a `headTitle` often carries the suffix a headline or an index
22
+ * entry must not (the structured data's headline; llms.txt's link titles, #3867).
23
+ */
24
+ export declare function withoutProjectSuffix(title: string, projectName: string | undefined): string;
@@ -49,3 +49,13 @@ export function pageTitle(doc, projectName) {
49
49
  return doc.title;
50
50
  return `${doc.title} — ${project}`;
51
51
  }
52
+ /**
53
+ * "Menus — tosijs-ui" → "Menus": a `headTitle` often carries the suffix a headline or an index
54
+ * entry must not (the structured data's headline; llms.txt's link titles, #3867).
55
+ */
56
+ export function withoutProjectSuffix(title, projectName) {
57
+ if (!projectName)
58
+ return title;
59
+ const escaped = projectName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
60
+ return (title.replace(new RegExp(`\\s+[—–|-]\\s+${escaped}\\s*$`, 'i'), '') || title);
61
+ }
@@ -17,7 +17,7 @@ Build-time only. Never import this from browser code.
17
17
  import * as path from 'path';
18
18
  import { createRequire } from 'module';
19
19
  import { existsSync } from 'fs';
20
- import { NOT_RUN_EXIT, AXE_TAGS } from './a11y-audit.js';
20
+ import { NOT_RUN_EXIT, AXE_TAGS, auditFileFor, } from './a11y-audit.js';
21
21
  const payloadPath = process.argv[2];
22
22
  const { outputDir, basePath, pages, slowPageMs } = JSON.parse(await Bun.file(payloadPath).text());
23
23
  const notRun = (why) => {
@@ -42,16 +42,13 @@ catch {
42
42
  }
43
43
  const root = path.resolve(outputDir);
44
44
  const prefix = (basePath || '').replace(/\/$/, '');
45
+ // Loopback only: the built site is nobody else's business while it is audited (#3861).
45
46
  const server = Bun.serve({
46
47
  port: 0,
48
+ hostname: 'localhost',
47
49
  fetch(request) {
48
- let pathname = decodeURIComponent(new URL(request.url).pathname);
49
- if (prefix && pathname.startsWith(prefix))
50
- pathname = pathname.slice(prefix.length) || '/';
51
- if (pathname.endsWith('/'))
52
- pathname += 'index.html';
53
- const file = path.join(root, pathname);
54
- if (!file.startsWith(root) || !existsSync(file))
50
+ const file = auditFileFor(root, prefix, new URL(request.url).pathname);
51
+ if (file === null || !existsSync(file))
55
52
  return new Response('not found', { status: 404 });
56
53
  return new Response(Bun.file(file));
57
54
  },
@@ -24,6 +24,14 @@ export type AxeAuditResult = {
24
24
  ran: false;
25
25
  reason: string;
26
26
  };
27
+ /**
28
+ * The file in `root` a request path names for the audit server, or null when it is not one.
29
+ *
30
+ * Containment is `root + sep`, not a bare prefix: `startsWith(root)` let `/..%2fdocs-private/x`
31
+ * serve a sibling directory whose name starts with the output dir's (#3861). A malformed
32
+ * percent-escape is not a file either.
33
+ */
34
+ export declare function auditFileFor(root: string, prefix: string, rawPathname: string): string | null;
27
35
  export declare function auditBuiltSite(opts: {
28
36
  outputDir: string;
29
37
  basePath?: string;
@@ -9,36 +9,73 @@ takes seconds per page; that is a release step, not something to repeat on each
9
9
  What it never does is report a pass it did not earn: when Playwright, axe-core or Chromium is
10
10
  missing, the result says the audit did not run and why, and the build prints that.
11
11
  */
12
- import { spawn } from 'bun';
13
- import { existsSync } from 'fs';
12
+ import { existsSync, mkdtempSync, rmSync } from 'fs';
13
+ import { runBounded } from './bounded-child.js';
14
14
  import { tmpdir } from 'os';
15
15
  import * as path from 'path';
16
16
  /** The child's exit code when it could not run at all (a missing package or browser). */
17
17
  export const NOT_RUN_EXIT = 3;
18
18
  /** axe-core's tags for WCAG 2.0, 2.1 and 2.2 at levels A and AA. */
19
19
  export const AXE_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'];
20
+ /**
21
+ * The file in `root` a request path names for the audit server, or null when it is not one.
22
+ *
23
+ * Containment is `root + sep`, not a bare prefix: `startsWith(root)` let `/..%2fdocs-private/x`
24
+ * serve a sibling directory whose name starts with the output dir's (#3861). A malformed
25
+ * percent-escape is not a file either.
26
+ */
27
+ export function auditFileFor(root, prefix, rawPathname) {
28
+ let pathname;
29
+ try {
30
+ pathname = decodeURIComponent(rawPathname);
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ if (prefix && pathname.startsWith(prefix))
36
+ pathname = pathname.slice(prefix.length) || '/';
37
+ if (pathname.endsWith('/'))
38
+ pathname += 'index.html';
39
+ const base = path.resolve(root);
40
+ const file = path.join(base, pathname);
41
+ return file === base || file.startsWith(base + path.sep) ? file : null;
42
+ }
20
43
  export async function auditBuiltSite(opts) {
21
44
  const cliTs = `${import.meta.dir}/a11y-audit-cli.ts`;
22
45
  const cli = existsSync(cliTs) ? cliTs : `${import.meta.dir}/a11y-audit-cli.js`;
23
- const payloadPath = path.join(tmpdir(), `tosijs-a11y-${process.pid}.json`);
46
+ // A private directory, removed afterwards: a predictable name in a shared /tmp could be
47
+ // planted, and the payloads were never cleaned up (#3862).
48
+ const workDir = mkdtempSync(path.join(tmpdir(), 'tosijs-a11y-'));
49
+ const payloadPath = path.join(workDir, 'payload.json');
24
50
  await Bun.write(payloadPath, JSON.stringify({
25
51
  outputDir: path.resolve(opts.outputDir),
26
52
  basePath: opts.basePath ?? '',
27
53
  pages: opts.pages,
28
54
  slowPageMs: opts.slowPageMs,
29
55
  }));
56
+ /*
57
+ The worst case, plus a minute: every page waits out the slow-page limit, four at a time. A
58
+ run past that is hung, and is killed rather than hanging the build (#3860).
59
+ */
60
+ const timeoutMs = 60_000 + Math.ceil(opts.pages.length / 4) * (opts.slowPageMs ?? 30_000) * 2;
30
61
  // `--no-install`: without it Bun resolves a package the project does not have from its global
31
62
  // cache, so "not installed" would never be said and the audit would use a Playwright nobody chose.
32
- const child = spawn(['bun', '--no-install', cli, payloadPath], {
33
- cwd: opts.projectDir,
34
- stdout: 'pipe',
35
- stderr: 'pipe',
36
- });
37
- const [stdout, stderr, code] = await Promise.all([
38
- new Response(child.stdout).text(),
39
- new Response(child.stderr).text(),
40
- child.exited,
41
- ]);
63
+ let run;
64
+ try {
65
+ run = await runBounded(['bun', '--no-install', cli, payloadPath], {
66
+ cwd: opts.projectDir,
67
+ timeoutMs,
68
+ });
69
+ }
70
+ finally {
71
+ rmSync(workDir, { recursive: true, force: true });
72
+ }
73
+ const { stdout, stderr, code } = run;
74
+ if (run.timedOut)
75
+ return {
76
+ ran: false,
77
+ reason: `the audit did not finish in ${Math.round(timeoutMs / 1000)}s and was stopped`,
78
+ };
42
79
  if (code === NOT_RUN_EXIT)
43
80
  return { ran: false, reason: stderr.trim() };
44
81
  const line = stdout.trim().split('\n').pop() ?? '';
@@ -0,0 +1,10 @@
1
+ export interface BoundedResult {
2
+ stdout: string;
3
+ stderr: string;
4
+ code: number | null;
5
+ timedOut: boolean;
6
+ }
7
+ export declare function runBounded(argv: string[], opts: {
8
+ cwd?: string;
9
+ timeoutMs: number;
10
+ }): Promise<BoundedResult>;
@@ -0,0 +1,44 @@
1
+ /*
2
+ Run a child process with a wall-clock limit, and take it down with the build.
3
+
4
+ The axe audit and DAISY Ace are long-running children that drive browsers (#3860). Awaited with
5
+ no limit, one that hung hung the build; and a build killed with Ctrl-C or SIGTERM left the child,
6
+ and the Chromium under it, running. This bounds both: the child is killed when the limit passes
7
+ (reported as `timedOut`), and when this process receives SIGINT or SIGTERM, after which the
8
+ signal is raised again so the build still ends the way it would have.
9
+ */
10
+ import { spawn } from 'bun';
11
+ export async function runBounded(argv, opts) {
12
+ const child = spawn(argv, {
13
+ cwd: opts.cwd,
14
+ stdout: 'pipe',
15
+ stderr: 'pipe',
16
+ });
17
+ let timedOut = false;
18
+ const timer = setTimeout(() => {
19
+ timedOut = true;
20
+ child.kill();
21
+ }, opts.timeoutMs);
22
+ const signals = ['SIGINT', 'SIGTERM'];
23
+ const onSignal = (signal) => {
24
+ child.kill();
25
+ for (const s of signals)
26
+ process.off(s, onSignal);
27
+ process.kill(process.pid, signal);
28
+ };
29
+ for (const s of signals)
30
+ process.on(s, onSignal);
31
+ try {
32
+ const [stdout, stderr, code] = await Promise.all([
33
+ new Response(child.stdout).text(),
34
+ new Response(child.stderr).text(),
35
+ child.exited,
36
+ ]);
37
+ return { stdout, stderr, code, timedOut };
38
+ }
39
+ finally {
40
+ clearTimeout(timer);
41
+ for (const s of signals)
42
+ process.off(s, onSignal);
43
+ }
44
+ }
@@ -1 +1,22 @@
1
1
  export declare function configFingerprint(config: unknown): string;
2
+ export interface BuildServerIdentity {
3
+ root: string;
4
+ pid: number;
5
+ config: string;
6
+ }
7
+ /**
8
+ * Why a dev server refuses a `/__build` request, as the 409 reply's body, or null to build.
9
+ *
10
+ * A request for another project's tree, or with a config fingerprint other than the one the
11
+ * server was launched with, is refused BEFORE anything is built (#3772, #3863).
12
+ */
13
+ export declare function buildRequestRefusal(params: URLSearchParams, server: BuildServerIdentity): Record<string, unknown> | null;
14
+ /**
15
+ * What a delegated build says when the server's reply does not prove it built THIS config, or
16
+ * null when it does. A server too old to send a fingerprint has already built with whatever
17
+ * config it was started with, so it cannot be trusted either.
18
+ */
19
+ export declare function staleConfigMessage(replyConfig: string | undefined, fingerprint: string, where: {
20
+ port: number;
21
+ pid: number;
22
+ }): string | null;
@@ -31,3 +31,46 @@ export function configFingerprint(config) {
31
31
  .digest('hex')
32
32
  .slice(0, 16);
33
33
  }
34
+ /**
35
+ * Why a dev server refuses a `/__build` request, as the 409 reply's body, or null to build.
36
+ *
37
+ * A request for another project's tree, or with a config fingerprint other than the one the
38
+ * server was launched with, is refused BEFORE anything is built (#3772, #3863).
39
+ */
40
+ export function buildRequestRefusal(params, server) {
41
+ const root = params.get('root');
42
+ if (root !== null && root !== server.root)
43
+ return {
44
+ ok: false,
45
+ detail: `this server builds ${server.root}, not ${root}`,
46
+ pid: server.pid,
47
+ root: server.root,
48
+ };
49
+ const config = params.get('config');
50
+ if (config !== null && config !== server.config)
51
+ return {
52
+ ok: false,
53
+ detail: `🛑 this dev server (pid ${server.pid}) was started with a different site config ` +
54
+ `(edited since, or loaded with a different environment), so it would build the ` +
55
+ `old one. Restart it, then build again. If this happens on every build, the config ` +
56
+ `computes a value that differs per process (Date.now(), a random id, an environment ` +
57
+ `variable one of the two lacks): compute it in a prebuild step instead, or pass it ` +
58
+ `in through the environment both processes share.`,
59
+ pid: server.pid,
60
+ root: server.root,
61
+ config: server.config,
62
+ };
63
+ return null;
64
+ }
65
+ /**
66
+ * What a delegated build says when the server's reply does not prove it built THIS config, or
67
+ * null when it does. A server too old to send a fingerprint has already built with whatever
68
+ * config it was started with, so it cannot be trusted either.
69
+ */
70
+ export function staleConfigMessage(replyConfig, fingerprint, where) {
71
+ if (replyConfig === fingerprint)
72
+ return null;
73
+ return `🛑 the dev server on ${where.port} is building ${replyConfig === undefined
74
+ ? 'with a version of tosijs-ui that cannot say which site config it has'
75
+ : 'a different site config from this one (edited since it started, or loaded with a different environment)'}. Restart it (pid ${where.pid}), then build again.\n`;
76
+ }
@@ -15,7 +15,7 @@ import { tmpdir } from 'os';
15
15
  import { $, spawn, gzipSync } from 'bun';
16
16
  import { brotliCompressSync, constants as zlibConstants } from 'node:zlib';
17
17
  import { buildSite } from './orchestrator.js';
18
- import { configFingerprint } from './config-fingerprint.js';
18
+ import { buildRequestRefusal, configFingerprint } from './config-fingerprint.js';
19
19
  import { preflight } from './preflight.js';
20
20
  import { auditDependencies, reportAudit } from './audit-guard.js';
21
21
  import { openDevBrowser } from './open-browser.js';
@@ -2470,27 +2470,16 @@ export async function devServer(config, opts = {}) {
2470
2470
  build used to write the old config's site and report success. The caller compares and
2471
2471
  refuses.
2472
2472
  */
2473
- const requestedRoot = new URL(request.url).searchParams.get('root');
2474
- if (requestedRoot !== null && requestedRoot !== path.resolve('./')) {
2475
- return new Response(JSON.stringify({
2476
- ok: false,
2477
- detail: `this server builds ${path.resolve('./')}, not ${requestedRoot}`,
2478
- pid: process.pid,
2479
- root: path.resolve('./'),
2480
- }), { status: 409, headers: { 'Content-Type': 'application/json' } });
2481
- }
2482
- const requestedConfig = new URL(request.url).searchParams.get('config');
2483
- if (requestedConfig !== null && requestedConfig !== launchedConfig) {
2484
- return new Response(JSON.stringify({
2485
- ok: false,
2486
- detail: `🛑 this dev server (pid ${process.pid}) was started with a different site config ` +
2487
- `(edited since, or loaded with a different environment), so it would build the ` +
2488
- `old one. Restart it, then build again.`,
2489
- pid: process.pid,
2490
- root: path.resolve('./'),
2491
- config: launchedConfig,
2492
- }), { status: 409, headers: { 'Content-Type': 'application/json' } });
2493
- }
2473
+ const refusal = buildRequestRefusal(new URL(request.url).searchParams, {
2474
+ root: path.resolve('./'),
2475
+ pid: process.pid,
2476
+ config: launchedConfig,
2477
+ });
2478
+ if (refusal)
2479
+ return new Response(JSON.stringify(refusal), {
2480
+ status: 409,
2481
+ headers: { 'Content-Type': 'application/json' },
2482
+ });
2494
2483
  const result = await requestBuild();
2495
2484
  return new Response(JSON.stringify({
2496
2485
  ...result,
@@ -28,12 +28,21 @@ export type AceResult = {
28
28
  };
29
29
  /** Why Ace did not run, when it is not installed anywhere. */
30
30
  export declare const ACE_NOT_INSTALLED = "DAISY Ace is not installed (npm install -g @daisy/ace, which keeps it out of your lockfile)";
31
+ /** Is `bin` DAISY Ace? Its real path sits inside a `@daisy/ace` package (#3864). */
32
+ export declare function isDaisyAce(bin: string): boolean;
31
33
  /**
32
- * Where `ace` is: the project's own `node_modules/.bin` first, then `PATH`. A global install is
33
- * the one the docs recommend: Ace brings Electron and Puppeteer, whose advisories would otherwise
34
- * fail the project's own dependency audit (#3577). It is the adopter's, never a dependency of
35
- * tosijs-ui.
34
+ * Where DAISY Ace is: the project's own `node_modules/.bin` first, then `PATH`. A global install
35
+ * is the one the docs recommend: Ace brings Electron and Puppeteer, whose advisories would
36
+ * otherwise fail the project's own dependency audit (#3577). It is the adopter's, never a
37
+ * dependency of tosijs-ui.
38
+ *
39
+ * Only DAISY Ace counts: an unrelated program named `ace` would otherwise decide whether a book
40
+ * claims conformance (#3864). `notAce` lists any `ace` found that is not it.
36
41
  */
42
+ export declare function locateAce(projectDir?: string, searchPath?: string): {
43
+ bin: string | null;
44
+ notAce: string[];
45
+ };
37
46
  export declare function findAce(projectDir?: string, searchPath?: string): string | null;
38
47
  /** Check a built `.epub` with DAISY Ace (`@daisy/ace`). */
39
48
  export declare function runAce(epubFile: string, projectDir?: string, searchPath?: string): Promise<AceResult>;