@davidsouther/jiffies 2026.4.1 → 2026.24.0
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/README.md +0 -3
- package/package.json +11 -6
- package/src/404.html +1 -1
- package/src/components/accordion.ts +25 -0
- package/src/components/alert.ts +47 -0
- package/src/components/card.ts +54 -0
- package/src/components/children.ts +11 -0
- package/src/components/form.ts +25 -0
- package/src/components/index.ts +22 -0
- package/src/components/link.ts +22 -0
- package/src/components/modal.ts +15 -0
- package/src/components/nav.ts +42 -0
- package/src/components/property.ts +32 -0
- package/src/components/tabs.ts +82 -0
- package/src/components/virtual_scroll.ts +1 -1
- package/src/dom/README.md +7 -2
- package/src/dom/SKILL.md +201 -0
- package/src/dom/dom.ts +185 -41
- package/src/dom/fc.ts +3 -2
- package/src/dom/form/form.app.ts +35 -41
- package/src/dom/form/form.ts +79 -10
- package/src/dom/form/index.html +2 -2
- package/src/dom/hydrate.ts +206 -0
- package/src/dom/navigation/index.ts +349 -0
- package/src/dom/render.ts +41 -0
- package/src/dom/svg.ts +6 -2
- package/src/fs_node.ts +2 -2
- package/src/log.ts +154 -2
- package/src/server/http/response.ts +6 -3
- package/src/server/http/sitemap.ts +10 -34
- package/src/server/http/static.ts +0 -2
- package/src/server/live-reload.ts +208 -0
- package/src/server/main.ts +14 -7
- package/src/server/ws/frame.ts +36 -0
- package/src/server/ws/handshake.ts +42 -0
- package/src/server/ws/index.ts +100 -0
- package/src/ssg/bundle.ts +85 -0
- package/src/ssg/copy-public.ts +44 -0
- package/src/ssg/discover.ts +143 -0
- package/src/ssg/main.ts +168 -0
- package/src/ssg/rewrite.ts +18 -0
- package/src/ssg/ssg.ts +134 -0
- package/src/components/test.ts +0 -5
- package/src/components/virtual_scroll.test.ts +0 -30
- package/src/context.test.ts +0 -58
- package/src/context.ts +0 -67
- package/src/diff.test.ts +0 -48
- package/src/dom/fc.test.ts +0 -43
- package/src/dom/form/form.test.ts +0 -0
- package/src/dom/html.test.ts +0 -74
- package/src/dom/observable.test.ts +0 -43
- package/src/dom/test.ts +0 -11
- package/src/equal.test.ts +0 -23
- package/src/flags.test.ts +0 -43
- package/src/flags.ts +0 -53
- package/src/fs.test.ts +0 -106
- package/src/fs_win.test.ts +0 -11
- package/src/generator.test.ts +0 -27
- package/src/index.html +0 -82
- package/src/is_browser.js +0 -1
- package/src/lock.test.ts +0 -17
- package/src/observable/observable.test.ts +0 -73
- package/src/pico/_variables.scss +0 -66
- package/src/pico/components/_accordion.scss +0 -112
- package/src/pico/components/_button-group.scss +0 -51
- package/src/pico/components/_card.scss +0 -47
- package/src/pico/components/_dropdown.scss +0 -203
- package/src/pico/components/_modal.scss +0 -181
- package/src/pico/components/_nav.scss +0 -79
- package/src/pico/components/_progress.scss +0 -70
- package/src/pico/components/_property.scss +0 -34
- package/src/pico/content/_button.scss +0 -152
- package/src/pico/content/_code.scss +0 -63
- package/src/pico/content/_embedded.scss +0 -0
- package/src/pico/content/_form-alt.scss +0 -276
- package/src/pico/content/_form.scss +0 -259
- package/src/pico/content/_misc.scss +0 -0
- package/src/pico/content/_table.scss +0 -28
- package/src/pico/content/_toggle.scss +0 -132
- package/src/pico/content/_typography.scss +0 -232
- package/src/pico/layout/_container.scss +0 -40
- package/src/pico/layout/_document.scss +0 -0
- package/src/pico/layout/_flex.scss +0 -46
- package/src/pico/layout/_grid.scss +0 -24
- package/src/pico/layout/_scroller.scss +0 -16
- package/src/pico/layout/_section.scss +0 -8
- package/src/pico/layout/_sectioning.scss +0 -55
- package/src/pico/pico.scss +0 -60
- package/src/pico/reset/_accessibility.scss +0 -34
- package/src/pico/reset/_button.scss +0 -17
- package/src/pico/reset/_code.scss +0 -15
- package/src/pico/reset/_document.scss +0 -48
- package/src/pico/reset/_embedded.scss +0 -39
- package/src/pico/reset/_form.scss +0 -97
- package/src/pico/reset/_misc.scss +0 -23
- package/src/pico/reset/_nav.scss +0 -5
- package/src/pico/reset/_progress.scss +0 -4
- package/src/pico/reset/_table.scss +0 -8
- package/src/pico/reset/_typography.scss +0 -25
- package/src/pico/themes/default/_colors.scss +0 -65
- package/src/pico/themes/default/_dark.scss +0 -148
- package/src/pico/themes/default/_light.scss +0 -149
- package/src/pico/themes/default/_styles.scss +0 -272
- package/src/pico/themes/default.scss +0 -34
- package/src/pico/utilities/_accessibility.scss +0 -3
- package/src/pico/utilities/_loading.scss +0 -52
- package/src/pico/utilities/_reduce-motion.scss +0 -27
- package/src/pico/utilities/_tooltip.scss +0 -101
- package/src/result.test.ts +0 -101
- package/src/scope/describe.ts +0 -81
- package/src/scope/display/console.ts +0 -26
- package/src/scope/display/dom.ts +0 -36
- package/src/scope/display/junit.ts +0 -64
- package/src/scope/execute.ts +0 -110
- package/src/scope/expect.ts +0 -169
- package/src/scope/fix.ts +0 -30
- package/src/scope/index.ts +0 -11
- package/src/scope/scope.ts +0 -21
- package/src/scope/state.ts +0 -13
- package/src/test.mjs +0 -33
- package/src/test_all.ts +0 -35
package/src/dom/form/form.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import type { Attrs, DenormChildren } from "../dom.ts";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
button,
|
|
4
|
+
fieldset,
|
|
5
|
+
form,
|
|
6
|
+
input,
|
|
7
|
+
label,
|
|
8
|
+
legend,
|
|
9
|
+
option,
|
|
10
|
+
select,
|
|
11
|
+
} from "../html.ts";
|
|
3
12
|
import type {
|
|
4
13
|
FormAttributes,
|
|
5
14
|
InputAttributes,
|
|
@@ -38,7 +47,19 @@ export const Select = (
|
|
|
38
47
|
...prepareOptions(attrs.options as string[], attrs.selected).map(Option),
|
|
39
48
|
),
|
|
40
49
|
);
|
|
41
|
-
|
|
50
|
+
// Sanctioned jiffies-css button variants. The default button needs no class.
|
|
51
|
+
export type ButtonVariant = "secondary" | "contrast" | "outline";
|
|
52
|
+
|
|
53
|
+
// Button emits button[type=button] so it never accidentally submits a form. The
|
|
54
|
+
// optional variant maps to the matching sanctioned jiffies-css class.
|
|
55
|
+
export const Button = (
|
|
56
|
+
variant?: ButtonVariant,
|
|
57
|
+
...children: DenormChildren[]
|
|
58
|
+
) =>
|
|
59
|
+
button(
|
|
60
|
+
variant ? { type: "button", class: variant } : { type: "button" },
|
|
61
|
+
...children,
|
|
62
|
+
);
|
|
42
63
|
|
|
43
64
|
const prepareOptions = (
|
|
44
65
|
attrs:
|
|
@@ -71,12 +92,60 @@ export const Dropdown = (
|
|
|
71
92
|
...attrs,
|
|
72
93
|
options: typeof options[0] === "string" ? options : options[0],
|
|
73
94
|
});
|
|
74
|
-
|
|
75
|
-
export
|
|
76
|
-
|
|
95
|
+
// A {value: label} map: option value (also the id/name stem) to display text.
|
|
96
|
+
export type ChoiceOptions = Record<string, string>;
|
|
97
|
+
|
|
98
|
+
// Derive a stable name/id stem from the legend text.
|
|
99
|
+
const slug = (text: string) =>
|
|
100
|
+
text
|
|
101
|
+
.toLowerCase()
|
|
102
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
103
|
+
.replace(/^-+|-+$/g, "");
|
|
104
|
+
|
|
105
|
+
// Shared builder for Radios/Checks/Switches: fieldset[role=group] > legend +
|
|
106
|
+
// (input[type] + label[for])* — the jiffies-css grouped-controls structure. The
|
|
107
|
+
// shared name groups the inputs; id/for pairs each input to its label.
|
|
108
|
+
const choiceGroup = (
|
|
109
|
+
type: "radio" | "checkbox",
|
|
110
|
+
legendText: string,
|
|
111
|
+
options: ChoiceOptions,
|
|
112
|
+
role?: "switch",
|
|
113
|
+
): HTMLFieldSetElement => {
|
|
114
|
+
const name = slug(legendText);
|
|
115
|
+
const children: DenormChildren[] = [legend(legendText)];
|
|
116
|
+
for (const [value, labelText] of Object.entries(options)) {
|
|
117
|
+
const id = `${name}-${value}`;
|
|
118
|
+
const box = input({ type, name, id, value });
|
|
119
|
+
if (role) {
|
|
120
|
+
box.setAttribute("role", role);
|
|
121
|
+
}
|
|
122
|
+
const lbl = label(labelText);
|
|
123
|
+
lbl.setAttribute("for", id);
|
|
124
|
+
children.push(box, lbl);
|
|
125
|
+
}
|
|
126
|
+
const group = fieldset(...children);
|
|
127
|
+
group.setAttribute("role", "group");
|
|
128
|
+
return group;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export const Radios = (legendText: string, options: ChoiceOptions) =>
|
|
132
|
+
choiceGroup("radio", legendText, options);
|
|
133
|
+
export const Checks = (legendText: string, options: ChoiceOptions) =>
|
|
134
|
+
choiceGroup("checkbox", legendText, options);
|
|
135
|
+
export const Switches = (legendText: string, options: ChoiceOptions) =>
|
|
136
|
+
choiceGroup("checkbox", legendText, options, "switch");
|
|
77
137
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
export const
|
|
81
|
-
|
|
82
|
-
|
|
138
|
+
// Single-item controls wrap the input in its label (label > input + text), the
|
|
139
|
+
// jiffies-css labelled-control pattern. type and role are fixed per variant.
|
|
140
|
+
export const Radio = (
|
|
141
|
+
labelText: string,
|
|
142
|
+
attrs: Omit<InputAttributes, "type"> = {},
|
|
143
|
+
) => Input({ ...attrs, type: "radio" }, labelText);
|
|
144
|
+
export const Checkbox = (
|
|
145
|
+
labelText: string,
|
|
146
|
+
attrs: Omit<InputAttributes, "type"> = {},
|
|
147
|
+
) => Input({ ...attrs, type: "checkbox" }, labelText);
|
|
148
|
+
export const Switch = (
|
|
149
|
+
labelText: string,
|
|
150
|
+
attrs: Omit<InputAttributes, "type" | "role"> = {},
|
|
151
|
+
) => Input({ ...attrs, type: "checkbox", role: "switch" }, labelText);
|
package/src/dom/form/index.html
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
<html lang="en-
|
|
2
|
+
<html lang="en-US">
|
|
3
3
|
<head>
|
|
4
4
|
<title>Jiffies Form</title>
|
|
5
5
|
<base href="/dom/form/" />
|
|
6
|
-
<link rel="stylesheet" href="https://unpkg.com/@
|
|
6
|
+
<link rel="stylesheet" href="https://unpkg.com/@davidsouther/jiffies-css/dist/index.css">
|
|
7
7
|
</head>
|
|
8
8
|
<body>
|
|
9
9
|
<script type="module">
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"use client"; // Hydrate runs entirely client side.
|
|
2
|
+
|
|
3
|
+
import { reconcileChildren } from "./dom.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Self-contained IIFE source for the capture stub. Embedded inline by the SSG
|
|
7
|
+
* build pass so events fired before the client bundle loads are queued in
|
|
8
|
+
* window.__hydrateQueue and replayed after hydration. No external references.
|
|
9
|
+
*/
|
|
10
|
+
export const captureStubSource = `(function(){
|
|
11
|
+
window.__hydrateQueue = window.__hydrateQueue || [];
|
|
12
|
+
var queue = window.__hydrateQueue;
|
|
13
|
+
var handler = function(event) {
|
|
14
|
+
var path = event.composedPath();
|
|
15
|
+
var unitEl = null;
|
|
16
|
+
for (var i = 0; i < path.length; i++) {
|
|
17
|
+
var node = path[i];
|
|
18
|
+
if (node instanceof Element && customElements.get(node.localName)) {
|
|
19
|
+
unitEl = node;
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (!unitEl) return;
|
|
24
|
+
var target = event.target;
|
|
25
|
+
var targetPath = [];
|
|
26
|
+
var cur = target;
|
|
27
|
+
while (cur !== unitEl) {
|
|
28
|
+
var parent = cur.parentNode;
|
|
29
|
+
var siblings = Array.from(parent.childNodes);
|
|
30
|
+
targetPath.unshift(siblings.indexOf(cur));
|
|
31
|
+
cur = parent;
|
|
32
|
+
}
|
|
33
|
+
queue.push({ unitEl: unitEl, type: event.type, targetPath: targetPath, init: { bubbles: event.bubbles, cancelable: event.cancelable } });
|
|
34
|
+
};
|
|
35
|
+
var types = ["click","input","change","submit","keydown"];
|
|
36
|
+
for (var t = 0; t < types.length; t++) {
|
|
37
|
+
document.addEventListener(types[t], handler, true);
|
|
38
|
+
}
|
|
39
|
+
})()`;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Serialize `units` to a JSON string safe for embedding in an HTML script tag
|
|
43
|
+
* (angle brackets and ampersands are Unicode-escaped).
|
|
44
|
+
*/
|
|
45
|
+
export function buildPayload(units: Record<string, unknown>[]): string {
|
|
46
|
+
return JSON.stringify(units)
|
|
47
|
+
.replace(/&/g, "\\u0026")
|
|
48
|
+
.replace(/</g, "\\u003c")
|
|
49
|
+
.replace(/>/g, "\\u003e");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Read the hydration payload from the `#__hydration` script element embedded
|
|
54
|
+
* by the SSG build step. Returns an empty array when the element is absent.
|
|
55
|
+
*/
|
|
56
|
+
export function readPayload(): Record<string, unknown>[] {
|
|
57
|
+
const el = window.document.getElementById("__hydration");
|
|
58
|
+
if (!el) return [];
|
|
59
|
+
return JSON.parse(el.textContent ?? "[]") as Record<string, unknown>[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Walk `root` depth-first, returning every element whose localName is a
|
|
64
|
+
* defined custom element. Does NOT descend into matched elements — each custom
|
|
65
|
+
* element owns its own subtree; inner elements will be reached by their
|
|
66
|
+
* parent's `el.update()` call, not by `start()`.
|
|
67
|
+
*/
|
|
68
|
+
function scanUnits(root: ParentNode): Element[] {
|
|
69
|
+
const results: Element[] = [];
|
|
70
|
+
const stack: Element[] = [...root.children].reverse();
|
|
71
|
+
while (stack.length > 0) {
|
|
72
|
+
const el = stack.pop() as Element;
|
|
73
|
+
if (customElements.get(el.localName)) {
|
|
74
|
+
results.push(el);
|
|
75
|
+
} else {
|
|
76
|
+
for (let i = el.children.length - 1; i >= 0; i--) {
|
|
77
|
+
stack.push(el.children[i] as Element);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return results;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Scan `root` for registered custom elements and schedule each for hydration.
|
|
86
|
+
* `customElements.whenDefined` resolves as a microtask even when the element
|
|
87
|
+
* is already defined. The callback clears server-rendered children then runs
|
|
88
|
+
* `el.update()`, which re-executes the element's render function and rebuilds
|
|
89
|
+
* its subtree. `root` defaults to `window.document.body`.
|
|
90
|
+
*/
|
|
91
|
+
export function start(root?: ParentNode): void {
|
|
92
|
+
const r = root ?? window.document.body;
|
|
93
|
+
const units = scanUnits(r);
|
|
94
|
+
const payload = readPayload();
|
|
95
|
+
units.forEach((el, index) => {
|
|
96
|
+
customElements.whenDefined(el.localName).then(() => {
|
|
97
|
+
el.replaceChildren();
|
|
98
|
+
el.update(payload[index]);
|
|
99
|
+
drainQueue(el);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Hydrate custom elements inside `root` without clearing their server-rendered
|
|
105
|
+
// children first. `el.update()` reconciles onto the existing DOM so attributes
|
|
106
|
+
// and listeners are grafted in place. Recurses after each element hydrates so
|
|
107
|
+
// parents are always processed before their nested custom elements.
|
|
108
|
+
function startHydrate(root: ParentNode): void {
|
|
109
|
+
for (const el of scanUnits(root)) {
|
|
110
|
+
customElements.whenDefined(el.localName).then(() => {
|
|
111
|
+
el.update();
|
|
112
|
+
startHydrate(el);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface HydrateQueueEntry {
|
|
118
|
+
unitEl: Element;
|
|
119
|
+
type: string;
|
|
120
|
+
targetPath: number[];
|
|
121
|
+
init: { bubbles: boolean; cancelable: boolean };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function getHydrateQueue(): HydrateQueueEntry[] {
|
|
125
|
+
const w = window as unknown as Record<string, unknown>;
|
|
126
|
+
w.__hydrateQueue ??= [];
|
|
127
|
+
return w.__hydrateQueue as HydrateQueueEntry[];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Install capture-phase listeners on `document` that intercept events
|
|
132
|
+
* targeting nodes inside un-hydrated custom elements and push descriptors into
|
|
133
|
+
* `window.__hydrateQueue`. `start()` drains the queue for each element after
|
|
134
|
+
* `el.update()` runs by calling `drainQueue`.
|
|
135
|
+
*/
|
|
136
|
+
export function installCaptureStub(): void {
|
|
137
|
+
const queue = getHydrateQueue();
|
|
138
|
+
const handler = (event: Event) => {
|
|
139
|
+
const path = event.composedPath() as Node[];
|
|
140
|
+
let unitEl: Element | null = null;
|
|
141
|
+
for (const node of path) {
|
|
142
|
+
if (node instanceof Element && customElements.get(node.localName)) {
|
|
143
|
+
unitEl = node;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (!unitEl) return;
|
|
148
|
+
const target = event.target as Node;
|
|
149
|
+
const targetPath: number[] = [];
|
|
150
|
+
let cur: Node = target;
|
|
151
|
+
while (cur !== unitEl) {
|
|
152
|
+
const parent = cur.parentNode as Node;
|
|
153
|
+
targetPath.unshift(
|
|
154
|
+
Array.from(parent.childNodes).indexOf(cur as ChildNode),
|
|
155
|
+
);
|
|
156
|
+
cur = parent;
|
|
157
|
+
}
|
|
158
|
+
queue.push({
|
|
159
|
+
unitEl,
|
|
160
|
+
type: event.type,
|
|
161
|
+
targetPath,
|
|
162
|
+
init: { bubbles: event.bubbles, cancelable: event.cancelable },
|
|
163
|
+
});
|
|
164
|
+
};
|
|
165
|
+
for (const type of ["click", "input", "change", "submit", "keydown"]) {
|
|
166
|
+
window.document.addEventListener(type, handler, true);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function drainQueue(el: Element): void {
|
|
171
|
+
const w = window as unknown as Record<string, unknown>;
|
|
172
|
+
const allEntries = (w.__hydrateQueue ?? []) as HydrateQueueEntry[];
|
|
173
|
+
const mine = allEntries.filter((e) => e.unitEl === el);
|
|
174
|
+
w.__hydrateQueue = allEntries.filter((e) => e.unitEl !== el);
|
|
175
|
+
for (const entry of mine) {
|
|
176
|
+
let node: Node = el;
|
|
177
|
+
let resolved = true;
|
|
178
|
+
for (const idx of entry.targetPath) {
|
|
179
|
+
const child = node.childNodes[idx];
|
|
180
|
+
if (!child) {
|
|
181
|
+
console.warn(
|
|
182
|
+
`hydrateQueue: path index ${idx} out of range, dropping queued ${entry.type}`,
|
|
183
|
+
);
|
|
184
|
+
resolved = false;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
node = child;
|
|
188
|
+
}
|
|
189
|
+
if (resolved) {
|
|
190
|
+
node.dispatchEvent(new Event(entry.type, entry.init));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Hydrate a server-rendered page: call `render` once, reconcile the result
|
|
197
|
+
* into `mount` without replacing existing DOM nodes, and graft event handlers
|
|
198
|
+
* onto kept server nodes. Custom-element boundaries are left for each element
|
|
199
|
+
* to hydrate itself. Use this instead of `start` when the full page tree is
|
|
200
|
+
* produced by a single render function rather than independent custom elements.
|
|
201
|
+
*/
|
|
202
|
+
export function hydrateRoot(mount: Element, render: () => Node | Node[]): void {
|
|
203
|
+
const fresh = [render()].flat() as Node[];
|
|
204
|
+
reconcileChildren(mount, fresh);
|
|
205
|
+
startHydrate(mount);
|
|
206
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"use client"; // The navigation runtime drives same-document transitions client side.
|
|
2
|
+
|
|
3
|
+
import { start } from "../hydrate.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Context describing a completed navigation, handed to every `onNavigate` hook
|
|
7
|
+
* (e.g. an analytics pageview). Built by `navigate()` after the head reconcile,
|
|
8
|
+
* so `title` reflects the destination and `url` is absolute.
|
|
9
|
+
*/
|
|
10
|
+
export interface NavigationContext {
|
|
11
|
+
/** Absolute destination URL of the navigation. */
|
|
12
|
+
url: URL;
|
|
13
|
+
/** document.title after the head reconcile for this navigation. */
|
|
14
|
+
title: string;
|
|
15
|
+
/**
|
|
16
|
+
* "first" on initial load; otherwise the navigation's entry kind —
|
|
17
|
+
* "push" | "traverse" | "replace" — mirrored from the NavigateEvent on the
|
|
18
|
+
* interception path. A direct `navigate(url)` with no event, and a "reload",
|
|
19
|
+
* both report "push".
|
|
20
|
+
*/
|
|
21
|
+
type: "first" | "push" | "traverse" | "replace";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A hook registered through `onNavigate`, run once per completed navigation. */
|
|
25
|
+
type NavigateCallback = (ctx: NavigationContext) => void;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Registered `onNavigate` hooks, fired in registration order after every
|
|
29
|
+
* completed navigation. Module-level state only: this module installs no
|
|
30
|
+
* listeners, touches no DOM, and never hydrates or calls `start()` at import
|
|
31
|
+
* time (M1 plan Step 1 invariant). The static `../hydrate.ts` import does pull
|
|
32
|
+
* in `../dom.ts`, whose jsdom bootstrap runs only in windowless Node and is
|
|
33
|
+
* skipped under a browser or jsdom where `window` already exists — so importing
|
|
34
|
+
* `./index.ts` is side-effect-free in those environments.
|
|
35
|
+
*/
|
|
36
|
+
const navigateCallbacks: NavigateCallback[] = [];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Register `cb` to run after every in-app navigation completes (body swapped and
|
|
40
|
+
* hydration scheduled). Invariant: callbacks fire exactly once per navigation,
|
|
41
|
+
* in registration order, with the navigation's `NavigationContext`. Used by
|
|
42
|
+
* shell code such as a GA `page_view`.
|
|
43
|
+
*/
|
|
44
|
+
export function onNavigate(cb: NavigateCallback): void {
|
|
45
|
+
navigateCallbacks.push(cb);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A hook registered through `onFirstLoad`, run once when the initial page hydrates. */
|
|
49
|
+
type FirstLoadCallback = (ctx: NavigationContext) => void;
|
|
50
|
+
|
|
51
|
+
/** Hooks awaiting the first load, fired once during bootstrap in registration order. */
|
|
52
|
+
const firstLoadCallbacks: FirstLoadCallback[] = [];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The first-load context, set by `bootstrap()` once the initial page hydrates.
|
|
56
|
+
* `undefined` until then. Retained so an `onFirstLoad` registered AFTER first load
|
|
57
|
+
* (e.g. a shell module that imports the runtime lazily) still receives the event.
|
|
58
|
+
*/
|
|
59
|
+
let firstLoadContext: NavigationContext | undefined;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Register `cb` to run once, when the initial page hydrates on first document load.
|
|
63
|
+
* Invariant: if registered BEFORE bootstrap, `cb` is queued and fired during
|
|
64
|
+
* bootstrap with the first-load context (`type: "first"`). If registered AFTER first
|
|
65
|
+
* load, `cb` is invoked immediately with the retained context, so a late
|
|
66
|
+
* registration never drops the initial event (design §1). Registering installs no
|
|
67
|
+
* listeners and touches no DOM (import-side-effect-free invariant).
|
|
68
|
+
*/
|
|
69
|
+
export function onFirstLoad(cb: FirstLoadCallback): void {
|
|
70
|
+
if (firstLoadContext) {
|
|
71
|
+
cb(firstLoadContext);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
firstLoadCallbacks.push(cb);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Attribute marking a one-time "shell" node in `<head>` (a theme bootstrap, an
|
|
79
|
+
* analytics tag): the build emits it, and the head reconciler preserves any node
|
|
80
|
+
* carrying it by identity across navigations so its inline script never re-runs.
|
|
81
|
+
*/
|
|
82
|
+
const SHELL_ATTR = "data-shell";
|
|
83
|
+
|
|
84
|
+
/** True for a head node the reconciler must preserve in place across navigations. */
|
|
85
|
+
function isShell(node: ChildNode): boolean {
|
|
86
|
+
return node instanceof Element && node.hasAttribute(SHELL_ATTR);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Abandon the same-document path for an ordinary full document load of `url`
|
|
91
|
+
* (design "Failure modes"). Returns `null` so a fetch caller can `return fullLoad(url)`
|
|
92
|
+
* to both trigger the load and signal "stop here" — the destination becomes a normal
|
|
93
|
+
* navigation, never a broken intermediate state.
|
|
94
|
+
*/
|
|
95
|
+
function fullLoad(url: URL): null {
|
|
96
|
+
window.location.assign(url.href);
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Fetch `url` and parse its body text into a detached Document via the global
|
|
102
|
+
* DOMParser. Returns the parsed document, or `null` when the same-document path
|
|
103
|
+
* must be abandoned for a full load: a non-2xx response or a network error falls
|
|
104
|
+
* back to `fullLoad(url)` so the caller aborts before reconciling. Invariant: a
|
|
105
|
+
* returned document is detached — its nodes must be adopted with
|
|
106
|
+
* `document.importNode` before insertion into the live document.
|
|
107
|
+
*/
|
|
108
|
+
async function fetchDocument(url: URL): Promise<Document | null> {
|
|
109
|
+
let response: Response;
|
|
110
|
+
try {
|
|
111
|
+
response = await fetch(url);
|
|
112
|
+
} catch {
|
|
113
|
+
return fullLoad(url); // network error
|
|
114
|
+
}
|
|
115
|
+
if (!response.ok) {
|
|
116
|
+
return fullLoad(url); // non-2xx
|
|
117
|
+
}
|
|
118
|
+
const html = await response.text();
|
|
119
|
+
return new DOMParser().parseFromString(html, "text/html");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Reconcile the live `<head>` against `destHead`. Preserves every existing
|
|
124
|
+
* `[data-shell]` node by identity; replaces all non-shell live nodes with
|
|
125
|
+
* `destHead`'s non-shell nodes (adopted into the live document). Applies the
|
|
126
|
+
* destination `<title>` via `document.title` and mirrors `<html lang>`. Leaves
|
|
127
|
+
* the destination `#__hydration` payload in place for the subsequent `start()`.
|
|
128
|
+
*/
|
|
129
|
+
function reconcileHead(destHead: HTMLHeadElement): void {
|
|
130
|
+
const liveHead = window.document.head;
|
|
131
|
+
|
|
132
|
+
// Drop every live per-page node, leaving the [data-shell] nodes untouched —
|
|
133
|
+
// preserved by identity so their inline scripts (theme, analytics) never re-run.
|
|
134
|
+
for (const node of [...liveHead.childNodes]) {
|
|
135
|
+
if (!isShell(node)) node.remove();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Adopt the destination's per-page nodes (title, metadata, the #__hydration
|
|
139
|
+
// payload) and append them. The destination's own shell nodes are dropped —
|
|
140
|
+
// the live ones already cover them.
|
|
141
|
+
for (const node of [...destHead.childNodes]) {
|
|
142
|
+
if (isShell(node)) continue;
|
|
143
|
+
liveHead.appendChild(window.document.importNode(node, true));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Apply <title> through document.title so it takes effect immediately, and
|
|
147
|
+
// mirror <html lang> from the destination when it differs.
|
|
148
|
+
const destRoot = destHead.ownerDocument.documentElement;
|
|
149
|
+
window.document.title = destHead.ownerDocument.title;
|
|
150
|
+
if (destRoot.lang && destRoot.lang !== window.document.documentElement.lang) {
|
|
151
|
+
window.document.documentElement.lang = destRoot.lang;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Replace the live `<body>` children with `destBody`'s children, adopted via
|
|
157
|
+
* `document.importNode`. New element instances replace the old ones — a child
|
|
158
|
+
* replacement, not an in-place patch — so the destination's island is a fresh
|
|
159
|
+
* node. Any `<script type="module">` rides along inert: a script inserted via
|
|
160
|
+
* the DOM never executes on its own, which is why `importPageModules` imports it
|
|
161
|
+
* explicitly. (`destBody` is typed `HTMLElement` because `Document.body` is.)
|
|
162
|
+
*/
|
|
163
|
+
function swapBody(destBody: HTMLElement): void {
|
|
164
|
+
const adopted = [...destBody.childNodes].map((node) =>
|
|
165
|
+
window.document.importNode(node, true),
|
|
166
|
+
);
|
|
167
|
+
window.document.body.replaceChildren(...adopted);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Matches each inline `import "<spec>";` statement, capturing the specifier. */
|
|
171
|
+
const IMPORT_STATEMENT = /import\s+["']([^"']+)["']\s*;?/g;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Extract every `import "<spec>";` specifier from `root`'s
|
|
175
|
+
* `<script type="module">` elements and dynamically import each, awaiting all.
|
|
176
|
+
* The build emits inline `import` statements (not `src` attributes, matching
|
|
177
|
+
* src/ssg/rewrite.ts), and a script node inserted via the DOM never executes on
|
|
178
|
+
* its own, so the runtime imports the specifiers itself. The ES module cache
|
|
179
|
+
* dedupes chunks already loaded this session.
|
|
180
|
+
*/
|
|
181
|
+
async function importPageModules(root: ParentNode): Promise<void> {
|
|
182
|
+
const specifiers: string[] = [];
|
|
183
|
+
for (const script of root.querySelectorAll('script[type="module"]')) {
|
|
184
|
+
for (const match of (script.textContent ?? "").matchAll(IMPORT_STATEMENT)) {
|
|
185
|
+
specifiers.push(match[1]);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
await Promise.all(specifiers.map((spec) => import(spec)));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Same-document transition hints the interceptor lifts off the `NavigateEvent`
|
|
193
|
+
* and threads into `navigate()` — the View-Transition guard reads a field only
|
|
194
|
+
* the interception path sees. `hasUAVisualTransition` is true when the browser
|
|
195
|
+
* already ran its own visual transition for this navigation (a cross- to
|
|
196
|
+
* same-document hand-off), so the runtime must NOT start a second one;
|
|
197
|
+
* `navigationType` is the entry kind, reported on the `NavigationContext`. A
|
|
198
|
+
* direct `navigate(url)` (no event) passes neither.
|
|
199
|
+
*/
|
|
200
|
+
interface NavigateOptions {
|
|
201
|
+
hasUAVisualTransition?: boolean;
|
|
202
|
+
navigationType?: "push" | "replace" | "traverse" | "reload";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The shared same-document core the Navigation API interceptor funnels into
|
|
207
|
+
* (design §2). Fetches the destination's built HTML, reconciles `<head>`, swaps
|
|
208
|
+
* `<body>` — inside a View Transition when one is available — imports the
|
|
209
|
+
* destination's page modules, hydrates, then fires `onNavigate`. Resolves when
|
|
210
|
+
* hydration has been scheduled and hooks have fired. If `fetchDocument` fell back
|
|
211
|
+
* to a full load (non-2xx or network error), it returns `null` and this aborts
|
|
212
|
+
* without reconciling, swapping, or firing hooks.
|
|
213
|
+
*/
|
|
214
|
+
export async function navigate(
|
|
215
|
+
url: string | URL,
|
|
216
|
+
options: NavigateOptions = {},
|
|
217
|
+
): Promise<void> {
|
|
218
|
+
const target = new URL(url, window.location.href);
|
|
219
|
+
const destination = await fetchDocument(target);
|
|
220
|
+
if (destination === null) return; // fell back to a full document load
|
|
221
|
+
reconcileHead(destination.head);
|
|
222
|
+
|
|
223
|
+
// Swap the body inside a same-document View Transition when the browser
|
|
224
|
+
// supports one AND has not already animated this navigation itself (design §2
|
|
225
|
+
// step 4). startViewTransition snapshots the live DOM, runs the swap callback
|
|
226
|
+
// to mutate it, then animates the before/after states; awaiting
|
|
227
|
+
// updateCallbackDone resumes once the swap has applied (the DOM is updated),
|
|
228
|
+
// before modules import and hydration run. Where startViewTransition is absent
|
|
229
|
+
// or the UA already transitioned (hasUAVisualTransition), the swap is applied
|
|
230
|
+
// directly — degraded to an abrupt replacement, never broken (design "Failure
|
|
231
|
+
// modes").
|
|
232
|
+
const doc = window.document;
|
|
233
|
+
const applySwap = () => swapBody(destination.body);
|
|
234
|
+
if (
|
|
235
|
+
typeof doc.startViewTransition === "function" &&
|
|
236
|
+
!options.hasUAVisualTransition
|
|
237
|
+
) {
|
|
238
|
+
await doc.startViewTransition(applySwap).updateCallbackDone;
|
|
239
|
+
} else {
|
|
240
|
+
applySwap();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
await importPageModules(window.document.body);
|
|
244
|
+
|
|
245
|
+
// Hydrate the swapped-in body: start() reads the destination #__hydration
|
|
246
|
+
// payload (placed by reconcileHead) and schedules each island's update().
|
|
247
|
+
start(window.document.body);
|
|
248
|
+
|
|
249
|
+
// Report the completed navigation. title reflects the reconciled <head>; url
|
|
250
|
+
// is the absolute target; type mirrors the navigation's entry kind (a "reload"
|
|
251
|
+
// or a direct navigate() with no event reports "push"). Hooks fire once each,
|
|
252
|
+
// in registration order.
|
|
253
|
+
const { navigationType } = options;
|
|
254
|
+
const context: NavigationContext = {
|
|
255
|
+
url: target,
|
|
256
|
+
title: window.document.title,
|
|
257
|
+
type:
|
|
258
|
+
navigationType === "push" ||
|
|
259
|
+
navigationType === "replace" ||
|
|
260
|
+
navigationType === "traverse"
|
|
261
|
+
? navigationType
|
|
262
|
+
: "push",
|
|
263
|
+
};
|
|
264
|
+
for (const cb of navigateCallbacks) cb(context);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Install the navigation interceptor. The Navigation API is the sole interception
|
|
269
|
+
* mechanism: it delivers one `navigate` event for every same-document candidate —
|
|
270
|
+
* link click, programmatic navigation, and back/forward — so a single listener
|
|
271
|
+
* replaces a click handler plus a `popstate` listener, and the API owns history
|
|
272
|
+
* and scroll restoration (so the core stays `history.pushState`-free).
|
|
273
|
+
*
|
|
274
|
+
* The Navigation API is Baseline as of Jan 2026; this project targets evergreen
|
|
275
|
+
* browsers. Where it is absent there is NO interception: links perform normal
|
|
276
|
+
* full-document navigations — degraded (no shared-runtime hydration) but never
|
|
277
|
+
* broken. That minimal alternative is the entire fallback.
|
|
278
|
+
*
|
|
279
|
+
* Called by `bootstrap()` after `start()` and before firing `onFirstLoad`. The
|
|
280
|
+
* listener is added only here, never at module top level, so importing this module
|
|
281
|
+
* stays side-effect-free. Types come from `@types/dom-navigation` (folded into
|
|
282
|
+
* lib.dom as of TS 6.0); see the tracking task in `docs/developer/TASKS.md`.
|
|
283
|
+
*/
|
|
284
|
+
function installInterceptor(): void {
|
|
285
|
+
if (!("navigation" in window)) return;
|
|
286
|
+
|
|
287
|
+
window.navigation.addEventListener("navigate", (event) => {
|
|
288
|
+
// Decline (let the browser navigate natively) when the API cannot intercept
|
|
289
|
+
// — cross-origin, etc. — or for a hash-only, download, or non-GET (form)
|
|
290
|
+
// navigation. Form submissions are out of scope (design §2, Summary).
|
|
291
|
+
if (!event.canIntercept) return;
|
|
292
|
+
if (event.hashChange) return;
|
|
293
|
+
if (event.downloadRequest !== null) return;
|
|
294
|
+
if (event.formData !== null) return;
|
|
295
|
+
|
|
296
|
+
// Claim the navigation: run the shared core as the same-document transition.
|
|
297
|
+
// navigate() fetches, reconciles <head>, swaps <body> (inside a View
|
|
298
|
+
// Transition when the browser offers one and did not already animate this
|
|
299
|
+
// navigation), imports the page module, hydrates, and fires onNavigate. The
|
|
300
|
+
// API commits the history entry. hasUAVisualTransition and navigationType are
|
|
301
|
+
// threaded through because only this interception path sees the event.
|
|
302
|
+
const { url } = event.destination;
|
|
303
|
+
event.intercept({
|
|
304
|
+
handler: () =>
|
|
305
|
+
navigate(url, {
|
|
306
|
+
hasUAVisualTransition: event.hasUAVisualTransition,
|
|
307
|
+
navigationType: event.navigationType,
|
|
308
|
+
}),
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Bootstrap route hydration for the initial document. Explicit entry — NOT run at
|
|
315
|
+
* import time (the module stays side-effect-free; tests and the M3 injected entry
|
|
316
|
+
* decide when this runs). On call it:
|
|
317
|
+
* 1. `start(window.document.body)` — hydrate the server-rendered initial islands
|
|
318
|
+
* in place (reads the initial `#__hydration` payload already in <head>).
|
|
319
|
+
* 2. `installInterceptor()` — register the Navigation API `navigate` listener to
|
|
320
|
+
* capture subsequent in-app navigations, between `start()` and `onFirstLoad`.
|
|
321
|
+
* 3. Build the first-load `NavigationContext`: `url` from `window.location.href`,
|
|
322
|
+
* `title` from `document.title`, `type: "first"`. Store it in `firstLoadContext`.
|
|
323
|
+
* 4. Fire every queued `onFirstLoad` callback once, in registration order.
|
|
324
|
+
* Invariant: `onFirstLoad` fires exactly once per bootstrap.
|
|
325
|
+
*/
|
|
326
|
+
export async function bootstrap(): Promise<void> {
|
|
327
|
+
// 1. Hydrate the server-rendered initial islands in place. start() reads the
|
|
328
|
+
// initial #__hydration payload already in <head> and schedules each update().
|
|
329
|
+
start(window.document.body);
|
|
330
|
+
|
|
331
|
+
// 2. Register the Navigation API interceptor so subsequent in-app navigations
|
|
332
|
+
// are captured. Between start() and firing onFirstLoad, per design §1.
|
|
333
|
+
installInterceptor();
|
|
334
|
+
|
|
335
|
+
// 3. Build and retain the first-load context (design §1). url is the initial
|
|
336
|
+
// location, title the current document title, type "first".
|
|
337
|
+
firstLoadContext = {
|
|
338
|
+
url: new URL(window.location.href),
|
|
339
|
+
title: window.document.title,
|
|
340
|
+
type: "first",
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
// 4. Fire every queued onFirstLoad callback once, in registration order, then
|
|
344
|
+
// clear the queue so the event fires exactly once per bootstrap. A callback
|
|
345
|
+
// registered AFTER this point fires immediately against firstLoadContext (see
|
|
346
|
+
// onFirstLoad), so a late registration never drops the initial event.
|
|
347
|
+
for (const cb of firstLoadCallbacks) cb(firstLoadContext);
|
|
348
|
+
firstLoadCallbacks.length = 0;
|
|
349
|
+
}
|