tosijs-ui 1.16.35 → 1.16.37
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 +36 -0
- package/dist/doc-browser.d.ts +5 -0
- package/dist/doc-browser.js +41 -10
- package/dist/doc-system/doc-system.d.ts +2 -0
- package/dist/doc-system/doc-system.js +11 -0
- package/dist/doc-system/routing.d.ts +14 -1
- package/dist/doc-system/routing.js +38 -5
- package/dist/doc-system/site/dev-server.d.ts +5 -0
- package/dist/doc-system/site/dev-server.js +24 -2
- package/dist/doc-system/site/generate-site.d.ts +8 -0
- package/dist/doc-system/site/generate-site.js +57 -4
- package/dist/doc-system/site/orchestrator.js +1 -0
- package/dist/doc-system/site/site-config.d.ts +10 -0
- package/dist/iife.js +111 -111
- package/dist/iife.js.map +9 -8
- package/dist/live-example/component.js +4 -3
- package/dist/live-example/dev-served.d.ts +4 -0
- package/dist/live-example/dev-served.js +17 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.16.37
|
|
4
|
+
|
|
5
|
+
Doc-site test running, as a developer meets it. On a deployed site, tests still start off; the
|
|
6
|
+
only difference there is how a passing run looks to a reader who turns them on.
|
|
7
|
+
|
|
8
|
+
- **Safari no longer stalls the background test run.** The runner loads each page with tests in
|
|
9
|
+
a hidden frame, and parked that frame off-screen; WebKit gives an off-screen frame no
|
|
10
|
+
animation frames, tosijs renders on them, so every page waited out its 45-second deadline and
|
|
11
|
+
the header read "Running 0" for the whole run. The frame now sits inside the viewport, clipped
|
|
12
|
+
so nothing paints. The doc-test gate runs in WebKit for the first time, and passes.
|
|
13
|
+
- **Over the tunnel, tests run as they do on localhost.** They started off there, and turned on
|
|
14
|
+
ran only the page on screen; the dev server now marks the pages it serves, so any page it
|
|
15
|
+
served starts with tests on and runs the whole site in the background. That is every visitor
|
|
16
|
+
to a dev server, on the LAN or through a tunnel link, with or without an edit session: a
|
|
17
|
+
preview link opened on a phone runs every page's tests in hidden frames. Turning tests off
|
|
18
|
+
from the header menu is remembered per browser. A deployed site has no mark and still
|
|
19
|
+
defaults to off.
|
|
20
|
+
- **A pass no longer blanks the header's test widget.** It faded to nothing and left an empty
|
|
21
|
+
rectangle in the bar. It now turns grey and then translucent, all as a CSS filter over the
|
|
22
|
+
pass colour, and stays readable and clickable. Floating over a page with no header it still
|
|
23
|
+
fades out.
|
|
24
|
+
|
|
25
|
+
## 1.16.36
|
|
26
|
+
|
|
27
|
+
One additive site option. Nothing changes for a site that does not set it.
|
|
28
|
+
|
|
29
|
+
- **A page can be served at a second path (#3513).** `aliases: { readme: '' }` in the site
|
|
30
|
+
config also writes the root page to `/readme/`, with its stylesheet, bundle and `docs.json`
|
|
31
|
+
references worked out for that depth, and the browser opens it as the README. Before this a
|
|
32
|
+
host rewrite was the only way, and it served the page one level down with every relative
|
|
33
|
+
reference 404ing, so the page rendered but never hydrated. An alias page's canonical link
|
|
34
|
+
names the page's own path, aliases stay out of the sitemap and the nav, and relative images
|
|
35
|
+
and links in the page are rewritten to reach the same files. An alias is one lowercase path
|
|
36
|
+
segment with no dot; the build fails on one that is not, that takes a page's own path or a
|
|
37
|
+
static folder's, or that names no page.
|
|
38
|
+
|
|
3
39
|
## 1.16.35
|
|
4
40
|
|
|
5
41
|
Board follow-ups from 1.16.32 and 1.16.33, plus one tunnel feature. Nothing to change in an
|
package/dist/doc-browser.d.ts
CHANGED
|
@@ -102,6 +102,11 @@ export interface DocBrowserOptions {
|
|
|
102
102
|
* doc). Lets a host mount the browser already pointed at a specific doc.
|
|
103
103
|
*/
|
|
104
104
|
initialRoute?: string;
|
|
105
|
+
/**
|
|
106
|
+
* Path routing only: extra paths a doc is served at, `{ alias: slug }` (`''` is the root
|
|
107
|
+
* page). The site build passes its `aliases` here, so `/readme/` opens the README.
|
|
108
|
+
*/
|
|
109
|
+
aliases?: Record<string, string>;
|
|
105
110
|
/**
|
|
106
111
|
* Memory routing only: called with the current doc's slug whenever in-app
|
|
107
112
|
* navigation happens, so a host can reflect it (e.g. to an attribute).
|
package/dist/doc-browser.js
CHANGED
|
@@ -119,6 +119,7 @@ import { popMenu } from './menu.js';
|
|
|
119
119
|
import { codeEditor } from './code-editor.js';
|
|
120
120
|
import { tosiDiff } from './diff.js';
|
|
121
121
|
import { LAN_REFUSAL_ADVICE } from './live-example/source-access.js';
|
|
122
|
+
import { servedForDevelopment } from './live-example/dev-served.js';
|
|
122
123
|
/*
|
|
123
124
|
Does this doc contain a REAL ` ```test ` block, as opposed to mentioning one?
|
|
124
125
|
|
|
@@ -186,6 +187,18 @@ const testIndicatorStyleSpec = {
|
|
|
186
187
|
'70%': { opacity: '1', transform: 'scale(1.1)' },
|
|
187
188
|
'100%': { opacity: '0', transform: 'scale(0.9)', pointerEvents: 'none' },
|
|
188
189
|
},
|
|
190
|
+
/*
|
|
191
|
+
A pass in the header settles into a muted, monochrome badge rather than fading out. Faded,
|
|
192
|
+
it left an empty rectangle in the bar (the widget keeps its box), and the result was gone
|
|
193
|
+
for anyone who looked a moment late. Muted, it reads as "nothing to see" and stays a button.
|
|
194
|
+
All of it is a filter over the pass colour, never a new colour. Grey first, then faded: fading
|
|
195
|
+
while still green put a washed-out green on the header's colour.
|
|
196
|
+
*/
|
|
197
|
+
'@keyframes test-settle': {
|
|
198
|
+
'0%, 40%': { filter: 'grayscale(0) opacity(1)' },
|
|
199
|
+
'55%': { filter: 'grayscale(1) opacity(1)' },
|
|
200
|
+
'100%': { filter: 'grayscale(1) opacity(0.55)' },
|
|
201
|
+
},
|
|
189
202
|
// Hide when tests disabled
|
|
190
203
|
'body:not(.tests-enabled) .doc-link::after, body:not(.tests-enabled) .test-widget': {
|
|
191
204
|
display: 'none !important',
|
|
@@ -240,8 +253,19 @@ const testIndicatorStyleSpec = {
|
|
|
240
253
|
'.test-widget.-passed': {
|
|
241
254
|
_testBg: testColor.pass,
|
|
242
255
|
_testFg: testTextColor.pass,
|
|
256
|
+
animation: 'test-settle 3s ease-out forwards',
|
|
257
|
+
},
|
|
258
|
+
// Floating over the page (no header), it still gets out of the way: there is no gap to leave.
|
|
259
|
+
'.test-widget.-floating.-passed': {
|
|
243
260
|
animation: 'test-fade 3s ease-out forwards',
|
|
244
261
|
},
|
|
262
|
+
'@media (prefers-reduced-motion: reduce)': {
|
|
263
|
+
'.test-widget.-passed': {
|
|
264
|
+
animation: 'none',
|
|
265
|
+
filter: 'grayscale(1) opacity(0.55)',
|
|
266
|
+
},
|
|
267
|
+
'.test-widget.-floating.-passed': { opacity: '0', pointerEvents: 'none' },
|
|
268
|
+
},
|
|
245
269
|
'.test-widget.-failed': {
|
|
246
270
|
_testBg: testColor.fail,
|
|
247
271
|
_testFg: testTextColor.fail,
|
|
@@ -354,7 +378,7 @@ const logoMarkStyleSpec = {
|
|
|
354
378
|
// tosi() registry key (see stateKey below) and two browsers can't share state.
|
|
355
379
|
let docBrowserSeq = 0;
|
|
356
380
|
export function createDocBrowser(options) {
|
|
357
|
-
const { docs, context = {}, projectName = '', projectLinks = {}, logo, navSize = 200, minSize = 600, routing = 'query', initialRoute, onRouteChange, searchShortcut = 'mod+k', navbarLinks, contentElement, } = options;
|
|
381
|
+
const { docs, context = {}, projectName = '', projectLinks = {}, logo, navSize = 200, minSize = 600, routing = 'query', initialRoute, aliases = {}, onRouteChange, searchShortcut = 'mod+k', navbarLinks, contentElement, } = options;
|
|
358
382
|
// Memory routing is fully self-contained: it never reads or writes
|
|
359
383
|
// window.history/location, so an embedded or nested browser can't hijack the
|
|
360
384
|
// host page's URL (or recurse into it).
|
|
@@ -381,7 +405,7 @@ export function createDocBrowser(options) {
|
|
|
381
405
|
if (memoryRouting)
|
|
382
406
|
return initialRoute ? filenameForSlug(initialRoute) : '';
|
|
383
407
|
return routing === 'path'
|
|
384
|
-
? filenameForPath(document.location.pathname, slugMap)
|
|
408
|
+
? filenameForPath(document.location.pathname, slugMap, aliases)
|
|
385
409
|
: document.location.search !== ''
|
|
386
410
|
? document.location.search.substring(1).split('&')[0]
|
|
387
411
|
: '';
|
|
@@ -414,7 +438,7 @@ export function createDocBrowser(options) {
|
|
|
414
438
|
}
|
|
415
439
|
// Clean `/slug/` path form (only meaningful when slugs are in play).
|
|
416
440
|
if (!memoryRouting && routing !== 'query') {
|
|
417
|
-
const filename = known(filenameForPath(url.pathname, slugMap));
|
|
441
|
+
const filename = known(filenameForPath(url.pathname, slugMap, aliases));
|
|
418
442
|
if (filename)
|
|
419
443
|
return filename;
|
|
420
444
|
}
|
|
@@ -1762,7 +1786,7 @@ export function createDocBrowser(options) {
|
|
|
1762
1786
|
else {
|
|
1763
1787
|
testWidget.classList.add('-passed');
|
|
1764
1788
|
testWidget.classList.remove('-failed');
|
|
1765
|
-
testWidget.hidden = false; //
|
|
1789
|
+
testWidget.hidden = false; // settles into a muted badge (see `.test-widget.-passed`)
|
|
1766
1790
|
}
|
|
1767
1791
|
}
|
|
1768
1792
|
updateTestWidgetDisplay();
|
|
@@ -1938,9 +1962,15 @@ export function createDocBrowser(options) {
|
|
|
1938
1962
|
const currentFilename = String(app.currentDoc.filename);
|
|
1939
1963
|
/*
|
|
1940
1964
|
Create a hidden iframe that loads the full page. Keep a real layout size — layout-dependent
|
|
1941
|
-
example tests need one —
|
|
1942
|
-
|
|
1943
|
-
|
|
1965
|
+
example tests need one — and hide it with a clip that leaves nothing to paint, not opacity:
|
|
1966
|
+
Chromium (and haltija on top of it) still composites a 0-opacity layer at 0,0, which flashes
|
|
1967
|
+
as the frame navigates page to page.
|
|
1968
|
+
|
|
1969
|
+
INSIDE the viewport, though. It used to be parked at left:-10000px, and WebKit throttles a
|
|
1970
|
+
frame outside the viewport: its animation frames never came, tosijs renders on animation
|
|
1971
|
+
frames, so every page sat until its 45s deadline and Safari showed "Running 0" for the whole
|
|
1972
|
+
run. That was the "WebKit's iframe runner never signals" of the e2e lane, and it reached
|
|
1973
|
+
readers once the tunnel ran tests in the background.
|
|
1944
1974
|
|
|
1945
1975
|
THE SIZE IS OVERRIDABLE, because 800x600 was the only width the inline tier had ever run at.
|
|
1946
1976
|
A doc-system layout regression at phone width shipped in 1.12.3 and was found by a reader,
|
|
@@ -1959,7 +1989,7 @@ export function createDocBrowser(options) {
|
|
|
1959
1989
|
const vpWidth = Number(vp?.width) > 0 ? Number(vp?.width) : 800;
|
|
1960
1990
|
const vpHeight = Number(vp?.height) > 0 ? Number(vp?.height) : 600;
|
|
1961
1991
|
const testFrame = document.createElement('iframe');
|
|
1962
|
-
testFrame.style.cssText = `position: fixed; left:
|
|
1992
|
+
testFrame.style.cssText = `position: fixed; left: 0; top: 0; z-index: -1; clip-path: inset(100%); width: ${vpWidth}px; height: ${vpHeight}px; opacity: 0; pointer-events: none;`;
|
|
1963
1993
|
document.body.appendChild(testFrame);
|
|
1964
1994
|
// Listen for test results posted from the iframe
|
|
1965
1995
|
const messageHandler = (event) => {
|
|
@@ -2032,11 +2062,12 @@ export function createDocBrowser(options) {
|
|
|
2032
2062
|
void settleCurrentPage(currentFilename);
|
|
2033
2063
|
}
|
|
2034
2064
|
};
|
|
2035
|
-
// Run background tests when enabled (initially or when toggled on)
|
|
2065
|
+
// Run background tests when enabled (initially or when toggled on). Every page where a
|
|
2066
|
+
// developer is looking, the tunnel included; elsewhere only the page on screen.
|
|
2036
2067
|
const startBackgroundTests = () => {
|
|
2037
2068
|
if (!testManager.enabled.value)
|
|
2038
2069
|
return;
|
|
2039
|
-
if (
|
|
2070
|
+
if (servedForDevelopment()) {
|
|
2040
2071
|
setTimeout(runBackgroundTests, 1000);
|
|
2041
2072
|
}
|
|
2042
2073
|
else {
|
|
@@ -48,6 +48,8 @@ export declare class TosiDocSystem extends TosiDocSystem_base {
|
|
|
48
48
|
private initPrefs;
|
|
49
49
|
private initLocale;
|
|
50
50
|
private settingsButton;
|
|
51
|
+
/** The site's `aliases`, which the build writes into the config attribute (board #3513). */
|
|
52
|
+
private configAliases;
|
|
51
53
|
private parseLinks;
|
|
52
54
|
connectedCallback(): void;
|
|
53
55
|
render(): void;
|
|
@@ -485,6 +485,16 @@ export class TosiDocSystem extends withAttributes({
|
|
|
485
485
|
// ---- Lifecycle -----------------------------------------------------------
|
|
486
486
|
// Parse a declarative <ul class="..."> of <li><a href data-icon?>label</a> into
|
|
487
487
|
// LinkItem[]. Returns [] when the list is absent.
|
|
488
|
+
/** The site's `aliases`, which the build writes into the config attribute (board #3513). */
|
|
489
|
+
configAliases() {
|
|
490
|
+
try {
|
|
491
|
+
const { aliases } = JSON.parse(this.config || '{}');
|
|
492
|
+
return aliases && typeof aliases === 'object' ? aliases : undefined;
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
return undefined;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
488
498
|
parseLinks(selector) {
|
|
489
499
|
const list = this.querySelector(selector);
|
|
490
500
|
if (!list)
|
|
@@ -570,6 +580,7 @@ export class TosiDocSystem extends withAttributes({
|
|
|
570
580
|
this.browser = createDocBrowser({
|
|
571
581
|
docs: this.corpus,
|
|
572
582
|
routing: memory ? 'memory' : 'path',
|
|
583
|
+
aliases: memory ? undefined : this.configAliases(),
|
|
573
584
|
initialRoute: memory ? this.route || undefined : undefined,
|
|
574
585
|
onRouteChange: memory
|
|
575
586
|
? (slug) => {
|
|
@@ -55,8 +55,21 @@ export declare function slugForPath(pathname: string): string;
|
|
|
55
55
|
/**
|
|
56
56
|
* Resolve a browser location.pathname to a doc filename, using a slug map.
|
|
57
57
|
* Returns '' if nothing matches (caller falls back to the first/README doc).
|
|
58
|
+
*
|
|
59
|
+
* `aliases` ({ alias path -> slug }, the site config's `aliases`) are a doc's extra paths: a
|
|
60
|
+
* page's own slug wins, so an alias never takes a path from a page.
|
|
61
|
+
*/
|
|
62
|
+
export declare function filenameForPath(pathname: string, slugMap: Record<string, string>, aliases?: Record<string, string>): string;
|
|
63
|
+
/**
|
|
64
|
+
* Check a site's `aliases` against its slug map, and return the problems in words (empty when
|
|
65
|
+
* there are none). An alias is one lowercase path segment with no dot, takes no page's own
|
|
66
|
+
* path, and names a page.
|
|
67
|
+
*
|
|
68
|
+
* Lowercase because on a case-insensitive filesystem (macOS, Windows) writing `Guide/` replaces
|
|
69
|
+
* `guide/`, and a crawler at `/guide/` then got another page. No dot because every file the
|
|
70
|
+
* build writes at the root has one (`docs.json`, `sitemap.xml`, `llms.txt`).
|
|
58
71
|
*/
|
|
59
|
-
export declare function
|
|
72
|
+
export declare function aliasProblems(aliases: Record<string, string>, slugMap: Record<string, string>): string[];
|
|
60
73
|
/** Turn a human name/title into a slug: 'Form Components' -> 'form-components'. */
|
|
61
74
|
export declare function slugify(s: string): string;
|
|
62
75
|
/**
|
|
@@ -133,14 +133,47 @@ export function slugForPath(pathname) {
|
|
|
133
133
|
/**
|
|
134
134
|
* Resolve a browser location.pathname to a doc filename, using a slug map.
|
|
135
135
|
* Returns '' if nothing matches (caller falls back to the first/README doc).
|
|
136
|
+
*
|
|
137
|
+
* `aliases` ({ alias path -> slug }, the site config's `aliases`) are a doc's extra paths: a
|
|
138
|
+
* page's own slug wins, so an alias never takes a path from a page.
|
|
136
139
|
*/
|
|
137
|
-
export function filenameForPath(pathname, slugMap) {
|
|
140
|
+
export function filenameForPath(pathname, slugMap, aliases = {}) {
|
|
138
141
|
const slug = slugForPath(pathname);
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
+
const find = (wanted) => {
|
|
143
|
+
for (const [filename, docSlug] of Object.entries(slugMap)) {
|
|
144
|
+
if (docSlug === wanted)
|
|
145
|
+
return filename;
|
|
146
|
+
}
|
|
147
|
+
return '';
|
|
148
|
+
};
|
|
149
|
+
const own = find(slug);
|
|
150
|
+
return own || (Object.hasOwn(aliases, slug) ? find(aliases[slug]) : '');
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Check a site's `aliases` against its slug map, and return the problems in words (empty when
|
|
154
|
+
* there are none). An alias is one lowercase path segment with no dot, takes no page's own
|
|
155
|
+
* path, and names a page.
|
|
156
|
+
*
|
|
157
|
+
* Lowercase because on a case-insensitive filesystem (macOS, Windows) writing `Guide/` replaces
|
|
158
|
+
* `guide/`, and a crawler at `/guide/` then got another page. No dot because every file the
|
|
159
|
+
* build writes at the root has one (`docs.json`, `sitemap.xml`, `llms.txt`).
|
|
160
|
+
*/
|
|
161
|
+
export function aliasProblems(aliases, slugMap) {
|
|
162
|
+
const slugs = new Set(Object.values(slugMap));
|
|
163
|
+
const folded = new Set([...slugs].map((s) => s.toLowerCase()));
|
|
164
|
+
const problems = [];
|
|
165
|
+
for (const [alias, target] of Object.entries(aliases)) {
|
|
166
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(alias)) {
|
|
167
|
+
problems.push(`"${alias}" is not one lowercase path segment (a-z, 0-9, "_", "-")`);
|
|
168
|
+
}
|
|
169
|
+
else if (folded.has(alias)) {
|
|
170
|
+
problems.push(`"${alias}" is already a page's own path`);
|
|
171
|
+
}
|
|
172
|
+
if (!slugs.has(target)) {
|
|
173
|
+
problems.push(`"${alias}" names "${target}", which is no page's slug ('' is the root page)`);
|
|
174
|
+
}
|
|
142
175
|
}
|
|
143
|
-
return
|
|
176
|
+
return problems;
|
|
144
177
|
}
|
|
145
178
|
/** Turn a human name/title into a slug: 'Form Components' -> 'form-components'. */
|
|
146
179
|
export function slugify(s) {
|
|
@@ -31,6 +31,11 @@ import { isLoopbackAddressForAuth as isLoopbackAddress } from './dev-auth.js';
|
|
|
31
31
|
*
|
|
32
32
|
* `root + sep` rather than a bare `startsWith`, or `/srv/docs-evil` counts as inside `/srv/docs`.
|
|
33
33
|
*/
|
|
34
|
+
/**
|
|
35
|
+
* Put `snippet` before every script in `html`: just inside `<head>`, or else just after the
|
|
36
|
+
* doctype. Never before the doctype, which would put the page in quirks mode.
|
|
37
|
+
*/
|
|
38
|
+
export declare function injectFirst(html: string, snippet: string): string;
|
|
34
39
|
export declare function isUnderRoot(root: string, candidate: string): boolean;
|
|
35
40
|
export declare function resolveWatchPaths(config: {
|
|
36
41
|
docPaths?: string[];
|
|
@@ -28,6 +28,7 @@ import { assertSecretNotServed, ensureSessionSecret, readSessionSecret, sessionS
|
|
|
28
28
|
import { revocationList, startRevocationSweep } from './device-revocation.js';
|
|
29
29
|
import { resolveDataStore, resolveDataFile, dataContentProblem, listDataFiles, saveDataFile, dataHistory, readDataAt, isGitWorkTree, } from './data-store.js';
|
|
30
30
|
import { SOURCE_ACCESS_HEADER, SOURCE_ENDPOINT_SNIPPET, } from '../../live-example/source-access.js';
|
|
31
|
+
import { DEV_SERVER_SNIPPET } from '../../live-example/dev-served.js';
|
|
31
32
|
/**
|
|
32
33
|
* Every path the dev server watches for changes.
|
|
33
34
|
*
|
|
@@ -59,6 +60,20 @@ import { SOURCE_ACCESS_HEADER, SOURCE_ENDPOINT_SNIPPET, } from '../../live-examp
|
|
|
59
60
|
*
|
|
60
61
|
* `root + sep` rather than a bare `startsWith`, or `/srv/docs-evil` counts as inside `/srv/docs`.
|
|
61
62
|
*/
|
|
63
|
+
/**
|
|
64
|
+
* Put `snippet` before every script in `html`: just inside `<head>`, or else just after the
|
|
65
|
+
* doctype. Never before the doctype, which would put the page in quirks mode.
|
|
66
|
+
*/
|
|
67
|
+
export function injectFirst(html, snippet) {
|
|
68
|
+
const head = /<head(\s[^>]*)?>/i.exec(html);
|
|
69
|
+
if (head) {
|
|
70
|
+
const at = head.index + head[0].length;
|
|
71
|
+
return html.slice(0, at) + snippet + html.slice(at);
|
|
72
|
+
}
|
|
73
|
+
const doctype = /^\s*<!doctype[^>]*>/i.exec(html);
|
|
74
|
+
const at = doctype ? doctype[0].length : 0;
|
|
75
|
+
return html.slice(0, at) + snippet + html.slice(at);
|
|
76
|
+
}
|
|
62
77
|
export function isUnderRoot(root, candidate) {
|
|
63
78
|
const base = path.resolve(root);
|
|
64
79
|
const target = path.resolve(candidate);
|
|
@@ -1373,12 +1388,19 @@ export async function devServer(config, opts = {}) {
|
|
|
1373
1388
|
// deployed static site, which has none, is never asked.
|
|
1374
1389
|
(config.editableSources ? SOURCE_ENDPOINT_SNIPPET : '');
|
|
1375
1390
|
const encoding = negotiateEncoding(request?.headers.get('accept-encoding') ?? null);
|
|
1376
|
-
if (
|
|
1391
|
+
if (filePath.endsWith('.html')) {
|
|
1377
1392
|
const read = await Bun.file(filePath).text();
|
|
1378
1393
|
const html = asRoot ? rootRelativeUrls(read) : read;
|
|
1379
|
-
const
|
|
1394
|
+
const withExtras = html.includes('</body>')
|
|
1380
1395
|
? html.replace('</body>', `${extras}</body>`)
|
|
1381
1396
|
: html + extras;
|
|
1397
|
+
/*
|
|
1398
|
+
Tests on by default, every page, for whoever is looking through the dev server (the tunnel
|
|
1399
|
+
included); see dev-served.ts. FIRST in <head>, unlike the extras: it is read when the
|
|
1400
|
+
bundle evaluates, and on the iife path that is a classic script, which runs as it is
|
|
1401
|
+
parsed, before anything at the end of <body> exists.
|
|
1402
|
+
*/
|
|
1403
|
+
const injected = injectFirst(withExtras, DEV_SERVER_SNIPPET);
|
|
1382
1404
|
// Injected HTML is built per request, so it is compressed but never cached — the
|
|
1383
1405
|
// cache key would have to include the injected extras, and HTML is small anyway.
|
|
1384
1406
|
const body = encoding
|
|
@@ -6,6 +6,8 @@ declare global {
|
|
|
6
6
|
}
|
|
7
7
|
export interface GenerateSiteConfig {
|
|
8
8
|
docs: Doc[];
|
|
9
|
+
/** `SiteConfig.aliases`: { alias path -> slug } (board #3513) */
|
|
10
|
+
aliases?: Record<string, string>;
|
|
9
11
|
/**
|
|
10
12
|
* Mirrors `SiteConfig.liveExamples`. The static highlighter needs it so it skips the
|
|
11
13
|
* fences that will become live examples — a highlighted block hands the example markup
|
|
@@ -130,4 +132,10 @@ export declare function pageDepth(slug: string): number;
|
|
|
130
132
|
*/
|
|
131
133
|
export declare function relativeUrl(depth: number, p: string): string;
|
|
132
134
|
export declare function withStamp(url: string, stamp?: string): string;
|
|
135
|
+
/**
|
|
136
|
+
* Rewrite the relative `href`/`src` references in rendered page HTML, written for a page at
|
|
137
|
+
* `fromPath` ('/' or '/slug/'), so they reach the same files from a page at `depth`. Rooted
|
|
138
|
+
* (`/x`), external, `#`, `?` and `data:`-style references are left alone.
|
|
139
|
+
*/
|
|
140
|
+
export declare function rebaseRelativeRefs(html: string, fromPath: string, depth: number): string;
|
|
133
141
|
export declare function generateSite(config: GenerateSiteConfig): Promise<number>;
|
|
@@ -12,10 +12,11 @@ component (src/doc-system/*) so static and hydrated output agree.
|
|
|
12
12
|
*/
|
|
13
13
|
import { pageTitle } from '../doc-title.js';
|
|
14
14
|
import { highlightHtml } from '../highlight.js';
|
|
15
|
-
import { buildSlugMap, pathForSlug, rewriteDocLinks, withBase, wikilinkTargets, } from '../routing.js';
|
|
15
|
+
import { aliasProblems, buildSlugMap, pathForSlug, rewriteDocLinks, withBase, wikilinkTargets, } from '../routing.js';
|
|
16
16
|
import { buildNavTree, navOpenPath } from '../nav-tree.js';
|
|
17
17
|
import { renderDocMarkdown, docDescription, } from '../render.js';
|
|
18
18
|
import { bareNameLinks, deadInPageAnchors, missingWikilinks, misconfigured, } from './build-warnings.js';
|
|
19
|
+
import { existsSync } from 'fs';
|
|
19
20
|
const escapeAttr = (s) => s
|
|
20
21
|
.replace(/&/g, '&')
|
|
21
22
|
.replace(/</g, '<')
|
|
@@ -111,11 +112,14 @@ export function relativeUrl(depth, p) {
|
|
|
111
112
|
const rel = '../'.repeat(depth) + p.slice(1);
|
|
112
113
|
return rel === '' ? './' : rel;
|
|
113
114
|
}
|
|
114
|
-
async function pageHtml(doc, config, slugMap, configAttr, wikilinks
|
|
115
|
+
async function pageHtml(doc, config, slugMap, configAttr, wikilinks,
|
|
116
|
+
/** the slug this copy is written at: the doc's own, or an alias of it */
|
|
117
|
+
at = slugMap[doc.filename] ?? '') {
|
|
115
118
|
const { projectName = '', baseUrl = '', lang = 'en', favicon = '/favicon.svg', docsUrl = '/docs.json', scriptUrl = '/iife.js', hydrateUrl, stylesUrl = '/doc-system.css', assetStamp, docsStamp, localizedUrl = '/localized-strings.txt', basePath, headExtra = '', bakes, } = config;
|
|
116
119
|
// Functional URLs are emitted relative to THIS page's depth so the build is
|
|
117
120
|
// mount-agnostic (issue #25); metadata URLs below stay absolute via withBase.
|
|
118
|
-
const depth = pageDepth(
|
|
121
|
+
const depth = pageDepth(at);
|
|
122
|
+
const own = slugMap[doc.filename] ?? '';
|
|
119
123
|
const localizedAttr = config.localizedStrings
|
|
120
124
|
? ` localized="${escapeAttr(relativeUrl(depth, localizedUrl))}"`
|
|
121
125
|
: '';
|
|
@@ -142,7 +146,7 @@ async function pageHtml(doc, config, slugMap, configAttr, wikilinks) {
|
|
|
142
146
|
// Rewrite legacy `?filename` content links to clean `/slug/` paths so the
|
|
143
147
|
// static HTML is correct for no-JS readers and crawlers (the doc-browser also
|
|
144
148
|
// does this client-side after hydration).
|
|
145
|
-
const
|
|
149
|
+
const rendered = rewriteDocLinks(await highlightHtml(renderDocMarkdown(doc.text, {
|
|
146
150
|
bakes: bakes?.get(doc.filename),
|
|
147
151
|
wikilinks,
|
|
148
152
|
}),
|
|
@@ -151,6 +155,11 @@ async function pageHtml(doc, config, slugMap, configAttr, wikilinks) {
|
|
|
151
155
|
config.liveExamples ?? 'auto'), (filename) => slugMap[filename] !== undefined
|
|
152
156
|
? relativeUrl(depth, pathForSlug(slugMap[filename]))
|
|
153
157
|
: null);
|
|
158
|
+
// A copy at an alias: what the author wrote relative to the page's own path must still reach
|
|
159
|
+
// the same file (a README's `img/logo.png` is /img/logo.png, not /readme/img/logo.png).
|
|
160
|
+
const body = at === own
|
|
161
|
+
? rendered
|
|
162
|
+
: rebaseRelativeRefs(rendered, pathForSlug(own), depth);
|
|
154
163
|
const nav = navHtml(config.docs, slugMap, doc.filename, depth);
|
|
155
164
|
const navbar = linkListHtml('doc-navbar', config.navbarLinks);
|
|
156
165
|
const jsonLd = baseUrl
|
|
@@ -289,6 +298,19 @@ export function withStamp(url, stamp) {
|
|
|
289
298
|
return url;
|
|
290
299
|
return `${url}?v=${encodeURIComponent(stamp)}`;
|
|
291
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* Rewrite the relative `href`/`src` references in rendered page HTML, written for a page at
|
|
303
|
+
* `fromPath` ('/' or '/slug/'), so they reach the same files from a page at `depth`. Rooted
|
|
304
|
+
* (`/x`), external, `#`, `?` and `data:`-style references are left alone.
|
|
305
|
+
*/
|
|
306
|
+
export function rebaseRelativeRefs(html, fromPath, depth) {
|
|
307
|
+
return html.replace(/(\s(?:href|src|poster)=")([^"]*)"/g, (match, attr, ref) => {
|
|
308
|
+
if (!ref || /^(?:[a-z][a-z0-9+.-]*:|\/|#|\?)/i.test(ref))
|
|
309
|
+
return match;
|
|
310
|
+
const rooted = new URL(ref, `https://x${fromPath}`);
|
|
311
|
+
return `${attr}${relativeUrl(depth, rooted.pathname + rooted.search + rooted.hash)}"`;
|
|
312
|
+
});
|
|
313
|
+
}
|
|
292
314
|
export async function generateSite(config) {
|
|
293
315
|
const { docs, outputDir } = config;
|
|
294
316
|
const slugMap = buildSlugMap(docs);
|
|
@@ -303,8 +325,19 @@ export async function generateSite(config) {
|
|
|
303
325
|
}
|
|
304
326
|
seen.set(slug, doc.filename);
|
|
305
327
|
}
|
|
328
|
+
// A wrong alias fails the build like a `home` that names no page: it is a typo or a
|
|
329
|
+
// renamed page, and a site with a dead path is not one to ship quietly.
|
|
330
|
+
const aliases = config.aliases ?? {};
|
|
331
|
+
const problems = aliasProblems(aliases, slugMap);
|
|
332
|
+
if (problems.length) {
|
|
333
|
+
throw new Error(`generate-site: aliases are wrong:\n${problems
|
|
334
|
+
.map((p) => ` ${p}`)
|
|
335
|
+
.join('\n')}`);
|
|
336
|
+
}
|
|
306
337
|
const configAttr = escapeAttr(JSON.stringify({
|
|
307
338
|
projectName: config.projectName,
|
|
339
|
+
// The browser needs them too, or an alias page hydrates as whichever doc comes first.
|
|
340
|
+
...(Object.keys(aliases).length ? { aliases } : {}),
|
|
308
341
|
projectLinks: config.projectLinks,
|
|
309
342
|
logo: config.logo,
|
|
310
343
|
bookVolumes: config.bookVolumes,
|
|
@@ -341,6 +374,26 @@ export async function generateSite(config) {
|
|
|
341
374
|
}
|
|
342
375
|
count += 1;
|
|
343
376
|
}
|
|
377
|
+
/*
|
|
378
|
+
Each alias is the page again, written where the alias is served, so its relative references
|
|
379
|
+
(stylesheet, bundle, docs.json, nav links) resolve from there. A host rewrite to the original
|
|
380
|
+
page cannot do that: it serves the same bytes at a different depth and the page never
|
|
381
|
+
hydrates (board #3513). The canonical link stays the page's own path.
|
|
382
|
+
*/
|
|
383
|
+
for (const [alias, target] of Object.entries(aliases)) {
|
|
384
|
+
const doc = docs.find((d) => slugMap[d.filename] === target);
|
|
385
|
+
const dir = `${outputDir}/${alias}`;
|
|
386
|
+
// The output folder starts empty, so anything already here is a static file or folder, or
|
|
387
|
+
// one the build wrote (`tjs/`, `_chunks/`): an alias must not write into it.
|
|
388
|
+
if (existsSync(dir)) {
|
|
389
|
+
throw new Error(`generate-site: the alias "${alias}" names /${alias}/, which the site already has ` +
|
|
390
|
+
`(a static file or folder, or one the build writes); choose another name`);
|
|
391
|
+
}
|
|
392
|
+
await Bun.write(`${dir}/index.html`, await pageHtml(doc, config, slugMap, configAttr, wikilinks, alias));
|
|
393
|
+
if (hasMarkdownPage(doc, config)) {
|
|
394
|
+
await Bun.write(`${dir}/index.md`, doc.text.trimEnd() + '\n');
|
|
395
|
+
}
|
|
396
|
+
}
|
|
344
397
|
if (danglingWikilinks.length) {
|
|
345
398
|
misconfigured(config.strict, `\n⚠️ Wikilinks that reach no doc page. A target matches a page's slug (its file name)\n` +
|
|
346
399
|
` or its title; write [[slug|label]] to link one by file name, and an ordinary\n` +
|
|
@@ -1491,6 +1491,7 @@ export async function buildSite(config, opts = {}) {
|
|
|
1491
1491
|
const pageCount = await generateSite({
|
|
1492
1492
|
liveExamples: config.liveExamples,
|
|
1493
1493
|
docs,
|
|
1494
|
+
aliases: config.aliases,
|
|
1494
1495
|
outputDir: PUBLIC,
|
|
1495
1496
|
projectName: config.name,
|
|
1496
1497
|
bookVolumes: printCoverManifestUrl(config),
|
|
@@ -61,6 +61,16 @@ export interface SiteConfig {
|
|
|
61
61
|
* names no page. A doc can also claim the root itself with `{"home": true}`.
|
|
62
62
|
*/
|
|
63
63
|
home?: string;
|
|
64
|
+
/**
|
|
65
|
+
* Extra paths a page is served at, as `{ alias: slug }`; `''` is the root page. With
|
|
66
|
+
* `aliases: { readme: '' }` the README (the root page) is also written to `/readme/`, with its
|
|
67
|
+
* stylesheet, bundle and `docs.json` references computed for that depth, and the browser
|
|
68
|
+
* opens it as the README. Each alias page's canonical link names the page's own path, and
|
|
69
|
+
* aliases stay out of the sitemap and the nav; relative references in the page are rewritten
|
|
70
|
+
* to reach the same files. An alias is one lowercase path segment with no dot; the build fails
|
|
71
|
+
* on one that is not, that takes a page's own path or a static folder's, or that names no page.
|
|
72
|
+
*/
|
|
73
|
+
aliases?: Record<string, string>;
|
|
64
74
|
/**
|
|
65
75
|
* Directories to skip while scanning `docPaths` — by basename (skipped wherever it
|
|
66
76
|
* appears) or by path (skipped only at that location). Added to the built-in
|