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 +49 -0
- package/README.md +1 -1
- package/dist/data-table.d.ts +3 -4
- package/dist/data-table.js +34 -34
- package/dist/doc-browser.js +16 -25
- package/dist/doc-system/doc-title.d.ts +5 -0
- package/dist/doc-system/doc-title.js +10 -0
- package/dist/doc-system/site/a11y-audit-cli.js +5 -8
- package/dist/doc-system/site/a11y-audit.d.ts +8 -0
- package/dist/doc-system/site/a11y-audit.js +50 -13
- package/dist/doc-system/site/bounded-child.d.ts +10 -0
- package/dist/doc-system/site/bounded-child.js +44 -0
- package/dist/doc-system/site/config-fingerprint.d.ts +21 -0
- package/dist/doc-system/site/config-fingerprint.js +43 -0
- package/dist/doc-system/site/dev-server.js +11 -22
- package/dist/doc-system/site/epub-a11y.d.ts +13 -4
- package/dist/doc-system/site/epub-a11y.js +67 -21
- package/dist/doc-system/site/generate-site.js +1 -8
- package/dist/doc-system/site/make-llms-txt.js +3 -1
- package/dist/doc-system/site/orchestrator.js +38 -8
- package/dist/iife.js +104 -104
- package/dist/iife.js.map +6 -6
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +32 -32
- package/package.json +2 -1
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.
|
|
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
|
package/dist/data-table.d.ts
CHANGED
|
@@ -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
|
-
|
|
506
|
-
private drawnEdge;
|
|
507
|
-
private widthForEdge;
|
|
506
|
+
private pinElasticThrough;
|
|
508
507
|
private isElastic;
|
|
509
508
|
private layoutColumns;
|
|
510
509
|
setColumnWidths(): void;
|
package/dist/data-table.js
CHANGED
|
@@ -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
|
|
1004
|
-
columns
|
|
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
|
|
3095
|
-
let anchorWidth =
|
|
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 =
|
|
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
|
-
|
|
3668
|
-
|
|
3669
|
-
the
|
|
3670
|
-
|
|
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
|
-
|
|
3673
|
-
const
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
if (this.
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
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);
|
package/dist/doc-browser.js
CHANGED
|
@@ -1011,7 +1011,14 @@ export function createDocBrowser(options) {
|
|
|
1011
1011
|
const scrollToHashExample = () => {
|
|
1012
1012
|
if (memoryRouting)
|
|
1013
1013
|
return;
|
|
1014
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
if (
|
|
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 {
|
|
13
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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,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
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
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
|
|
33
|
-
* the one the docs recommend: Ace brings Electron and Puppeteer, whose advisories would
|
|
34
|
-
* fail the project's own dependency audit (#3577). It is the adopter's, never a
|
|
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>;
|