@ultimat3/render 22.7.0 → 22.8.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/CLAUDE.md +6 -2
- package/package.json +10 -8
- package/src/errors.ts +18 -0
- package/src/hydrate.ts +5 -1
- package/src/index.ts +49 -1
- package/src/modes.ts +5 -5
- package/src/navigation-cache.ts +93 -0
- package/src/navigation-dom-fixture.ts +418 -0
- package/src/navigation-dom.ts +146 -0
- package/src/navigation-entry.ts +7 -0
- package/src/navigation-fetch.ts +77 -0
- package/src/navigation-rules.ts +306 -0
- package/src/navigation-swap.ts +297 -0
- package/src/navigation-tags.ts +38 -0
- package/src/navigation.ts +480 -0
- package/src/route.ts +21 -1
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Just enough DOM for the client router's unit tests, written for them the way `hydrate-replay`'s
|
|
3
|
+
* fakes are: elements with attributes and children, the selectors the router uses (tag, `[a]`,
|
|
4
|
+
* `[a="v"]`, `[a~="v"]`, `:not([src])`, comma lists), documents that adopt and swap bodies, a
|
|
5
|
+
* window with history, location and timers, and a `DOMParser` that answers a fetched string with a
|
|
6
|
+
* document the test built. What a browser DOES with a document is proven in Chrome
|
|
7
|
+
* (`packages/cli/e2e/client-navigation-*.e2e.test.ts`); this is the branch coverage beside it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
type Attrs = Readonly<Record<string, string>>;
|
|
11
|
+
|
|
12
|
+
/** One compound selector: a tag and attribute conditions, optionally excluding a `src`. */
|
|
13
|
+
interface Simple {
|
|
14
|
+
readonly tag: string;
|
|
15
|
+
readonly conditions: readonly { name: string; op: string | undefined; value: string }[];
|
|
16
|
+
readonly notSrc: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const parse = (selector: string): readonly Simple[] =>
|
|
20
|
+
selector.split(',').map((part) => {
|
|
21
|
+
const text = part.trim();
|
|
22
|
+
const notSrc = text.includes(':not([src])');
|
|
23
|
+
const clean = text.replace(':not([src])', '');
|
|
24
|
+
const tag = /^[a-z0-9]*/i.exec(clean)?.[0] ?? '';
|
|
25
|
+
const conditions = [...clean.matchAll(/\[([\w:-]+)(?:(~?=)"?([^"\]]*)"?)?\]/g)].map((m) => ({
|
|
26
|
+
name: m[1] ?? '',
|
|
27
|
+
op: m[2],
|
|
28
|
+
value: m[3] ?? '',
|
|
29
|
+
}));
|
|
30
|
+
return { tag: tag.toUpperCase(), conditions, notSrc };
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const matches = (el: FakeElement, selector: string): boolean =>
|
|
34
|
+
parse(selector).some(
|
|
35
|
+
(simple) =>
|
|
36
|
+
(simple.tag === '' || simple.tag === el.tagName) &&
|
|
37
|
+
!(simple.notSrc && el.hasAttribute('src')) &&
|
|
38
|
+
simple.conditions.every(({ name, op, value }) => {
|
|
39
|
+
const said = el.getAttribute(name);
|
|
40
|
+
if (said === null) return false;
|
|
41
|
+
if (op === undefined) return true;
|
|
42
|
+
if (op === '~=') return said.split(/\s+/).includes(value);
|
|
43
|
+
return said === value;
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
export class FakeElement {
|
|
48
|
+
readonly tagName: string;
|
|
49
|
+
readonly attrs = new Map<string, string>();
|
|
50
|
+
children: FakeElement[] = [];
|
|
51
|
+
parentElement: FakeElement | null = null;
|
|
52
|
+
ownerDocument: FakeDocument;
|
|
53
|
+
text = '';
|
|
54
|
+
style: Record<string, string> = {};
|
|
55
|
+
nonce = '';
|
|
56
|
+
async = true;
|
|
57
|
+
onload: (() => void) | null = null;
|
|
58
|
+
onerror: (() => void) | null = null;
|
|
59
|
+
__x?: Promise<unknown>;
|
|
60
|
+
clicks = 0;
|
|
61
|
+
|
|
62
|
+
constructor(
|
|
63
|
+
doc: FakeDocument,
|
|
64
|
+
tag: string,
|
|
65
|
+
attrs: Attrs = {},
|
|
66
|
+
content: readonly FakeElement[] | string = [],
|
|
67
|
+
) {
|
|
68
|
+
this.ownerDocument = doc;
|
|
69
|
+
this.tagName = tag.toUpperCase();
|
|
70
|
+
for (const [name, value] of Object.entries(attrs)) this.attrs.set(name, value);
|
|
71
|
+
if (typeof content === 'string') this.text = content;
|
|
72
|
+
else for (const child of content) this.append(child);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get id(): string {
|
|
76
|
+
return this.getAttribute('id') ?? '';
|
|
77
|
+
}
|
|
78
|
+
getAttribute(name: string): string | null {
|
|
79
|
+
return this.attrs.get(name) ?? null;
|
|
80
|
+
}
|
|
81
|
+
setAttribute(name: string, value: string): void {
|
|
82
|
+
this.attrs.set(name, String(value));
|
|
83
|
+
}
|
|
84
|
+
removeAttribute(name: string): void {
|
|
85
|
+
this.attrs.delete(name);
|
|
86
|
+
}
|
|
87
|
+
hasAttribute(name: string): boolean {
|
|
88
|
+
return this.attrs.has(name);
|
|
89
|
+
}
|
|
90
|
+
getAttributeNames(): string[] {
|
|
91
|
+
return [...this.attrs.keys()];
|
|
92
|
+
}
|
|
93
|
+
get attributes(): { name: string; value: string }[] {
|
|
94
|
+
return [...this.attrs].map(([name, value]) => ({ name, value }));
|
|
95
|
+
}
|
|
96
|
+
get textContent(): string {
|
|
97
|
+
return this.text + this.children.map((child) => child.textContent).join('');
|
|
98
|
+
}
|
|
99
|
+
set textContent(value: string) {
|
|
100
|
+
this.text = value;
|
|
101
|
+
this.children = [];
|
|
102
|
+
}
|
|
103
|
+
get className(): string {
|
|
104
|
+
return this.getAttribute('class') ?? '';
|
|
105
|
+
}
|
|
106
|
+
get outerHTML(): string {
|
|
107
|
+
const attrs = [...this.attrs].map(([n, v]) => ` ${n}="${v}"`).join('');
|
|
108
|
+
const tag = this.tagName.toLowerCase();
|
|
109
|
+
return `<${tag}${attrs}>${this.text}${this.children.map((c) => c.outerHTML).join('')}</${tag}>`;
|
|
110
|
+
}
|
|
111
|
+
// The properties the router reads and writes on links, scripts and anchors.
|
|
112
|
+
get href(): string {
|
|
113
|
+
return new URL(this.getAttribute('href') ?? '', this.ownerDocument.baseURI).href;
|
|
114
|
+
}
|
|
115
|
+
set href(value: string) {
|
|
116
|
+
this.setAttribute('href', value);
|
|
117
|
+
}
|
|
118
|
+
set rel(value: string) {
|
|
119
|
+
this.setAttribute('rel', value);
|
|
120
|
+
}
|
|
121
|
+
get target(): string {
|
|
122
|
+
return this.getAttribute('target') ?? '';
|
|
123
|
+
}
|
|
124
|
+
set download(value: string) {
|
|
125
|
+
this.setAttribute('download', value);
|
|
126
|
+
}
|
|
127
|
+
get type(): string {
|
|
128
|
+
return this.getAttribute('type') ?? '';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
get connected(): boolean {
|
|
132
|
+
let at: FakeElement | null = this;
|
|
133
|
+
while (at.parentElement !== null) at = at.parentElement;
|
|
134
|
+
return at === this.ownerDocument.documentElement;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A `src` script or a stylesheet link inserted into the live document "loads". */
|
|
138
|
+
private loaded(): void {
|
|
139
|
+
if (!this.connected) return;
|
|
140
|
+
if (this.tagName === 'SCRIPT')
|
|
141
|
+
this.ownerDocument.ran.push(this.getAttribute('src') ?? this.text);
|
|
142
|
+
if (this.tagName === 'SCRIPT' || this.tagName === 'LINK') {
|
|
143
|
+
const failed = (this.getAttribute('src') ?? this.getAttribute('href') ?? '').includes('fail');
|
|
144
|
+
queueMicrotask(() => (failed ? this.onerror?.() : this.onload?.()));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
append(...nodes: FakeElement[]): void {
|
|
149
|
+
for (const node of nodes) {
|
|
150
|
+
node.remove();
|
|
151
|
+
node.parentElement = this;
|
|
152
|
+
node.adopt(this.ownerDocument);
|
|
153
|
+
this.children.push(node);
|
|
154
|
+
node.loaded();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
remove(): void {
|
|
158
|
+
const parent = this.parentElement;
|
|
159
|
+
if (parent === null) return;
|
|
160
|
+
parent.children = parent.children.filter((child) => child !== this);
|
|
161
|
+
this.parentElement = null;
|
|
162
|
+
}
|
|
163
|
+
replaceWith(node: FakeElement): void {
|
|
164
|
+
const parent = this.parentElement;
|
|
165
|
+
if (parent === null) return;
|
|
166
|
+
node.remove();
|
|
167
|
+
const index = parent.children.indexOf(this);
|
|
168
|
+
parent.children.splice(index, 1, node);
|
|
169
|
+
node.parentElement = parent;
|
|
170
|
+
this.parentElement = null;
|
|
171
|
+
node.adopt(parent.ownerDocument);
|
|
172
|
+
node.loaded();
|
|
173
|
+
}
|
|
174
|
+
replaceChild(next: FakeElement, old: FakeElement): void {
|
|
175
|
+
old.replaceWith(next);
|
|
176
|
+
}
|
|
177
|
+
adopt(doc: FakeDocument): void {
|
|
178
|
+
this.ownerDocument = doc;
|
|
179
|
+
for (const child of this.children) child.adopt(doc);
|
|
180
|
+
}
|
|
181
|
+
contains(node: FakeElement): boolean {
|
|
182
|
+
for (let at: FakeElement | null = node; at !== null; at = at.parentElement) {
|
|
183
|
+
if (at === this) return true;
|
|
184
|
+
}
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
private descendants(): FakeElement[] {
|
|
188
|
+
return this.children.flatMap((child) => [child, ...child.descendants()]);
|
|
189
|
+
}
|
|
190
|
+
querySelectorAll(selector: string): FakeElement[] {
|
|
191
|
+
return this.descendants().filter((el) => matches(el, selector));
|
|
192
|
+
}
|
|
193
|
+
querySelector(selector: string): FakeElement | null {
|
|
194
|
+
return this.querySelectorAll(selector)[0] ?? null;
|
|
195
|
+
}
|
|
196
|
+
closest(selector: string): FakeElement | null {
|
|
197
|
+
for (let at: FakeElement | null = this; at !== null; at = at.parentElement) {
|
|
198
|
+
if (matches(at, selector)) return at;
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
focus(): void {
|
|
203
|
+
this.ownerDocument.activeElement = this;
|
|
204
|
+
}
|
|
205
|
+
click(): void {
|
|
206
|
+
this.clicks += 1;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** A form the router's `instanceof HTMLFormElement` accepts once installed as that global. */
|
|
211
|
+
export class FakeForm extends FakeElement {
|
|
212
|
+
fields: [string, string | File][] = [];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export class FakeDocument extends EventTarget {
|
|
216
|
+
readonly documentElement: FakeElement;
|
|
217
|
+
baseURI = 'https://app.test/';
|
|
218
|
+
activeElement: FakeElement | null = null;
|
|
219
|
+
/** Every script that "ran": its `src`, or its text for an inline one. */
|
|
220
|
+
readonly ran: string[] = [];
|
|
221
|
+
startViewTransition?: (update: () => void) => {
|
|
222
|
+
updateCallbackDone: Promise<void>;
|
|
223
|
+
ready: Promise<void>;
|
|
224
|
+
finished: Promise<void>;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
constructor(head: readonly FakeElement[] = [], body: readonly FakeElement[] = [], lang = 'en') {
|
|
228
|
+
super();
|
|
229
|
+
this.documentElement = new FakeElement(this, 'html', { lang });
|
|
230
|
+
this.documentElement.append(new FakeElement(this, 'head'), new FakeElement(this, 'body'));
|
|
231
|
+
this.head.append(...head.map((el) => this.take(el)));
|
|
232
|
+
this.body.append(...body.map((el) => this.take(el)));
|
|
233
|
+
}
|
|
234
|
+
private take(el: FakeElement): FakeElement {
|
|
235
|
+
el.adopt(this);
|
|
236
|
+
return el;
|
|
237
|
+
}
|
|
238
|
+
get head(): FakeElement {
|
|
239
|
+
return this.documentElement.children.find((c) => c.tagName === 'HEAD') as FakeElement;
|
|
240
|
+
}
|
|
241
|
+
get body(): FakeElement {
|
|
242
|
+
return this.documentElement.children.find((c) => c.tagName === 'BODY') as FakeElement;
|
|
243
|
+
}
|
|
244
|
+
get title(): string {
|
|
245
|
+
return this.head.querySelector('title')?.textContent ?? '';
|
|
246
|
+
}
|
|
247
|
+
set title(value: string) {
|
|
248
|
+
const el = this.head.querySelector('title');
|
|
249
|
+
if (el === null) this.head.append(new FakeElement(this, 'title', {}, value));
|
|
250
|
+
else el.textContent = value;
|
|
251
|
+
}
|
|
252
|
+
createElement(tag: string): FakeElement {
|
|
253
|
+
return new FakeElement(this, tag);
|
|
254
|
+
}
|
|
255
|
+
adoptNode(node: FakeElement): FakeElement {
|
|
256
|
+
node.remove();
|
|
257
|
+
node.adopt(this);
|
|
258
|
+
return node;
|
|
259
|
+
}
|
|
260
|
+
getElementById(id: string): FakeElement | null {
|
|
261
|
+
return this.documentElement.querySelectorAll('[id]').find((el) => el.id === id) ?? null;
|
|
262
|
+
}
|
|
263
|
+
querySelector(selector: string): FakeElement | null {
|
|
264
|
+
return this.documentElement.querySelector(selector);
|
|
265
|
+
}
|
|
266
|
+
querySelectorAll(selector: string): FakeElement[] {
|
|
267
|
+
return this.documentElement.querySelectorAll(selector);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** `h('a', { href: '/b' }, 'b')` — builds an element owned by a scratch document until adopted. */
|
|
272
|
+
const scratch = new FakeDocument();
|
|
273
|
+
export const h = (
|
|
274
|
+
tag: string,
|
|
275
|
+
attrs: Attrs = {},
|
|
276
|
+
content: readonly FakeElement[] | string = [],
|
|
277
|
+
): FakeElement =>
|
|
278
|
+
tag === 'form'
|
|
279
|
+
? new FakeForm(scratch, tag, attrs, content)
|
|
280
|
+
: new FakeElement(scratch, tag, attrs, content);
|
|
281
|
+
|
|
282
|
+
/** The head an opted-in document carries, plus whatever the page adds. */
|
|
283
|
+
export const routerHead = (
|
|
284
|
+
title: string,
|
|
285
|
+
extra: readonly FakeElement[] = [],
|
|
286
|
+
meta: { surface?: string; build?: string; scope?: string } = {},
|
|
287
|
+
): FakeElement[] => [
|
|
288
|
+
h('title', {}, title),
|
|
289
|
+
h('meta', { name: 'ultimate-navigation', content: meta.surface ?? 'web:app' }),
|
|
290
|
+
h('meta', { name: 'x-ultimate-build', content: meta.build ?? 'b1' }),
|
|
291
|
+
...(meta.scope === undefined ? [] : [h('meta', { name: 'ultimate-scope', content: meta.scope })]),
|
|
292
|
+
...extra,
|
|
293
|
+
];
|
|
294
|
+
|
|
295
|
+
interface HistoryEntry {
|
|
296
|
+
state: unknown;
|
|
297
|
+
url: string;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** A window: location, history (with a back/forward that fires `popstate`), timers, `fetch`. */
|
|
301
|
+
export class FakeWindow extends EventTarget {
|
|
302
|
+
document: FakeDocument;
|
|
303
|
+
readonly assigned: string[] = [];
|
|
304
|
+
readonly entries: HistoryEntry[];
|
|
305
|
+
index = 0;
|
|
306
|
+
scrollX = 0;
|
|
307
|
+
scrollY = 0;
|
|
308
|
+
reducedMotion = false;
|
|
309
|
+
navigator: { connection?: { saveData?: boolean; effectiveType?: string } } = {};
|
|
310
|
+
readonly DOMParser = true;
|
|
311
|
+
readonly BroadcastChannel = true;
|
|
312
|
+
fetch: (url: string, init?: RequestInit) => Promise<Response>;
|
|
313
|
+
__xNavigation?: unknown;
|
|
314
|
+
|
|
315
|
+
constructor(doc: FakeDocument, url: string, fetch: FakeWindow['fetch']) {
|
|
316
|
+
super();
|
|
317
|
+
this.document = doc;
|
|
318
|
+
this.entries = [{ state: null, url }];
|
|
319
|
+
this.fetch = fetch;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
readonly location = {
|
|
323
|
+
get href(): string {
|
|
324
|
+
return self.entries[self.index]?.url ?? '';
|
|
325
|
+
},
|
|
326
|
+
assign: (url: string): void => {
|
|
327
|
+
this.assigned.push(url);
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
readonly history = {
|
|
332
|
+
scrollRestoration: 'auto',
|
|
333
|
+
get state(): unknown {
|
|
334
|
+
return self.entries[self.index]?.state ?? null;
|
|
335
|
+
},
|
|
336
|
+
pushState: (state: unknown, _title: string, url?: string): void => {
|
|
337
|
+
this.entries.splice(this.index + 1);
|
|
338
|
+
this.entries.push({ state, url: url ?? this.location.href });
|
|
339
|
+
this.index += 1;
|
|
340
|
+
},
|
|
341
|
+
replaceState: (state: unknown, _title: string, url?: string): void => {
|
|
342
|
+
this.entries[this.index] = { state, url: url ?? this.location.href };
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
/** Back or forward, as a browser does it: the entry moves, then `popstate` fires. */
|
|
347
|
+
go(delta: number): void {
|
|
348
|
+
this.index += delta;
|
|
349
|
+
this.dispatchEvent(Object.assign(new Event('popstate'), { state: this.history.state }));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
scrollTo(options: { left: number; top: number }): void {
|
|
353
|
+
this.scrollX = options.left;
|
|
354
|
+
this.scrollY = options.top;
|
|
355
|
+
}
|
|
356
|
+
matchMedia(): { matches: boolean } {
|
|
357
|
+
return { matches: this.reducedMotion };
|
|
358
|
+
}
|
|
359
|
+
setTimeout(fn: () => void, ms?: number): number {
|
|
360
|
+
return globalThis.setTimeout(fn, ms) as unknown as number;
|
|
361
|
+
}
|
|
362
|
+
clearTimeout(id: number | undefined): void {
|
|
363
|
+
globalThis.clearTimeout(id);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// `location` and `history` are object literals whose getters need the window; `self` is set per
|
|
368
|
+
// instance by `fakeWindow`, one window per test.
|
|
369
|
+
let self: FakeWindow;
|
|
370
|
+
|
|
371
|
+
/** The window a test drives; installs the three globals the router reaches for by name. */
|
|
372
|
+
export function fakeWindow(doc: FakeDocument, url: string, fetch: FakeWindow['fetch']): FakeWindow {
|
|
373
|
+
self = new FakeWindow(doc, url, fetch);
|
|
374
|
+
const globals = globalThis as unknown as Record<string, unknown>;
|
|
375
|
+
globals['DOMParser'] = FakeParser;
|
|
376
|
+
globals['HTMLFormElement'] = FakeForm;
|
|
377
|
+
globals['FormData'] = FakeFormData;
|
|
378
|
+
return self;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Documents a `DOMParser` answers, keyed by the HTML string a fake `fetch` returned. */
|
|
382
|
+
export const parsed = new Map<string, FakeDocument>();
|
|
383
|
+
|
|
384
|
+
class FakeParser {
|
|
385
|
+
parseFromString(html: string): FakeDocument {
|
|
386
|
+
const doc = parsed.get(html);
|
|
387
|
+
if (doc === undefined) throw new TypeError(`no fake document registered for ${html}`);
|
|
388
|
+
return doc;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const RealFormData = globalThis.FormData;
|
|
393
|
+
class FakeFormData extends RealFormData {
|
|
394
|
+
constructor(form?: FakeForm, submitter?: FakeElement | null) {
|
|
395
|
+
super();
|
|
396
|
+
for (const [name, value] of form?.fields ?? []) this.append(name, value);
|
|
397
|
+
const name = submitter?.getAttribute('name');
|
|
398
|
+
if (name !== undefined && name !== null)
|
|
399
|
+
this.append(name, submitter?.getAttribute('value') ?? '');
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** A registered page: `answer('/b', doc)` is what the fake fetch returns for `/b`. */
|
|
404
|
+
export function htmlAnswer(key: string, doc: FakeDocument, init: ResponseInit = {}): Response {
|
|
405
|
+
parsed.set(key, doc);
|
|
406
|
+
return new Response(key, {
|
|
407
|
+
...init,
|
|
408
|
+
headers: {
|
|
409
|
+
'content-type': 'text/html; charset=utf-8',
|
|
410
|
+
...(init.headers as Record<string, string>),
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Lets queued microtasks and a few macrotasks run — a swap awaits loads, transitions, scripts. */
|
|
416
|
+
export const settle = async (ms = 5): Promise<void> => {
|
|
417
|
+
for (let i = 0; i < 5; i += 1) await new Promise((resolve) => setTimeout(resolve, ms));
|
|
418
|
+
};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client router's small DOM helpers: reading a link or a form into the facts the rules take,
|
|
3
|
+
* the view transition, where focus lands, the live region, and handing a non-page answer to the
|
|
4
|
+
* browser. Split from `navigation.ts` so that file is the controller and nothing else.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { LinkFacts } from './navigation-rules';
|
|
8
|
+
import { NAVIGATION_RELOAD_ATTRIBUTE } from './navigation-rules';
|
|
9
|
+
|
|
10
|
+
/** A link as the rules read it; `event` absent for a hover, which is no click at all. */
|
|
11
|
+
export function linkFacts(
|
|
12
|
+
win: Window,
|
|
13
|
+
anchor: HTMLAnchorElement | HTMLAreaElement,
|
|
14
|
+
event?: MouseEvent,
|
|
15
|
+
): LinkFacts {
|
|
16
|
+
return {
|
|
17
|
+
href: anchor.href,
|
|
18
|
+
current: win.location.href,
|
|
19
|
+
button: event?.button ?? 0,
|
|
20
|
+
modified:
|
|
21
|
+
event !== undefined && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey),
|
|
22
|
+
defaultPrevented: event?.defaultPrevented ?? false,
|
|
23
|
+
target: anchor.target,
|
|
24
|
+
download: anchor.hasAttribute('download'),
|
|
25
|
+
rel: anchor.getAttribute('rel') ?? '',
|
|
26
|
+
reload: anchor.hasAttribute(NAVIGATION_RELOAD_ATTRIBUTE),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function anchorOf(target: EventTarget | null): HTMLAnchorElement | HTMLAreaElement | null {
|
|
31
|
+
const el = (target as Element | null)?.closest?.('a[href],area[href]') ?? null;
|
|
32
|
+
// An SVG `<a>`'s `href` is an animated string, not a URL: the browser keeps those.
|
|
33
|
+
return el !== null && typeof (el as HTMLAnchorElement).href === 'string'
|
|
34
|
+
? (el as HTMLAnchorElement)
|
|
35
|
+
: null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A field as a GET query carries it: a file by its name, as the browser does. */
|
|
39
|
+
export const fieldText = (value: unknown): string =>
|
|
40
|
+
value instanceof File ? value.name : String(value);
|
|
41
|
+
|
|
42
|
+
/** The submitted fields, the submitter's own name/value included, as the browser would send. */
|
|
43
|
+
export function formFields(form: HTMLFormElement, submitter: HTMLElement | null): FormData {
|
|
44
|
+
try {
|
|
45
|
+
return new FormData(form, submitter);
|
|
46
|
+
} catch {
|
|
47
|
+
return new FormData(form);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const hush = (): void => undefined;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The swap inside a view transition when the browser has one and motion is welcome. A transition
|
|
55
|
+
* the browser skips rejects `ready` and `finished`; both are answered here, so a navigation never
|
|
56
|
+
* leaves an unhandled rejection behind. A throw inside `apply` rejects `updateCallbackDone`, which
|
|
57
|
+
* the caller awaits and turns into a full load.
|
|
58
|
+
*/
|
|
59
|
+
export async function transition(win: Window, apply: () => void): Promise<void> {
|
|
60
|
+
const doc = win.document as Document & {
|
|
61
|
+
startViewTransition?: (update: () => void) => {
|
|
62
|
+
updateCallbackDone: Promise<void>;
|
|
63
|
+
ready: Promise<void>;
|
|
64
|
+
finished: Promise<void>;
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
const still = win.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true;
|
|
68
|
+
if (doc.startViewTransition === undefined || still) {
|
|
69
|
+
apply();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const running = doc.startViewTransition(apply);
|
|
73
|
+
running.ready.catch(hush);
|
|
74
|
+
running.finished.catch(hush);
|
|
75
|
+
await running.updateCallbackDone;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Where a keyboard or screen-reader user lands: the page's `<main>`, else its first heading. */
|
|
79
|
+
export function focusMain(doc: Document): void {
|
|
80
|
+
const target = doc.querySelector<HTMLElement>('main') ?? doc.querySelector<HTMLElement>('h1');
|
|
81
|
+
if (target === null) return;
|
|
82
|
+
if (!target.hasAttribute('tabindex')) target.setAttribute('tabindex', '-1');
|
|
83
|
+
target.focus({ preventScroll: true });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A polite live region carried from body to body; styled through the CSSOM, which CSP allows. */
|
|
87
|
+
export function announcer(doc: Document): HTMLElement {
|
|
88
|
+
const region = doc.createElement('div');
|
|
89
|
+
region.setAttribute('aria-live', 'polite');
|
|
90
|
+
region.setAttribute('aria-atomic', 'true');
|
|
91
|
+
Object.assign(region.style, {
|
|
92
|
+
position: 'absolute',
|
|
93
|
+
width: '1px',
|
|
94
|
+
height: '1px',
|
|
95
|
+
overflow: 'hidden',
|
|
96
|
+
clipPath: 'inset(50%)',
|
|
97
|
+
whiteSpace: 'nowrap',
|
|
98
|
+
});
|
|
99
|
+
doc.body.append(region);
|
|
100
|
+
return region;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The file name a `Content-Disposition` names — `filename*` (RFC 8187) first, then `filename` — or
|
|
105
|
+
* `''` for the browser to choose. Never throws: a malformed escape falls back, and no path
|
|
106
|
+
* separator survives, so a header cannot name a place to write.
|
|
107
|
+
*/
|
|
108
|
+
export function dispositionName(disposition: string): string {
|
|
109
|
+
const encoded = /filename\*\s*=\s*(?:[\w-]+)?'[^']*'([^;]+)/i.exec(disposition)?.[1];
|
|
110
|
+
let name = /filename\s*=\s*"([^"]*)"|filename\s*=\s*([^;]+)/i.exec(disposition);
|
|
111
|
+
let out = name?.[1] ?? name?.[2]?.trim() ?? '';
|
|
112
|
+
if (encoded !== undefined) {
|
|
113
|
+
try {
|
|
114
|
+
out = decodeURIComponent(encoded.trim());
|
|
115
|
+
} catch {
|
|
116
|
+
// A malformed escape: the plain `filename` (or none) stands.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
name = null;
|
|
120
|
+
return out.replace(/[\\/]/g, '_');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Whether the server asked for the bytes to be saved rather than shown. */
|
|
124
|
+
export const isAttachment = (disposition: string): boolean => /^\s*attachment/i.test(disposition);
|
|
125
|
+
|
|
126
|
+
/** How long a saved file's blob URL outlives the click that saved it. */
|
|
127
|
+
const REVOKE_AFTER_MS = 60_000;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* An answer that is not a page, handed to the browser from the bytes ALREADY received — the
|
|
131
|
+
* route ran once, and asking again would run it twice (a download recorded as two). Saved under
|
|
132
|
+
* its own name when the server said `attachment`; otherwise shown, as a navigation would.
|
|
133
|
+
*/
|
|
134
|
+
export function handOver(win: Window, body: Blob, disposition: string): void {
|
|
135
|
+
const url = URL.createObjectURL(body);
|
|
136
|
+
if (isAttachment(disposition)) {
|
|
137
|
+
const link = win.document.createElement('a');
|
|
138
|
+
link.href = url;
|
|
139
|
+
link.download = dispositionName(disposition);
|
|
140
|
+
link.click();
|
|
141
|
+
win.setTimeout(() => URL.revokeObjectURL(url), REVOKE_AFTER_MS);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// The document that holds the URL is leaving; the browser frees it with the document.
|
|
145
|
+
win.location.assign(url);
|
|
146
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// `@ultimat3/render/navigation` — the client router's script, ONE classic script per opted-in
|
|
2
|
+
// document, built and served by `@ultimat3/cli` the way realtime's page boot is. Importing it IS
|
|
3
|
+
// starting it: a document that did not opt in carries no `ultimate-navigation` and is left alone.
|
|
4
|
+
|
|
5
|
+
import { startNavigation } from './navigation';
|
|
6
|
+
|
|
7
|
+
startNavigation();
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client router's one request: the headers the server's navigation gate reads (purpose, the
|
|
3
|
+
* document's `<app>:<surface>`, its principal, its build on a GET), `redirect: 'manual'` so no
|
|
4
|
+
* redirect is followed behind the router's back, and the answer read ONCE into an `Answer` — the
|
|
5
|
+
* page's text, or a non-page's bytes to hand over (a prefetch's are dropped unread).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { CLIENT_BUILD_META, CLIENT_SCOPE_META } from '@ultimat3/core/page';
|
|
9
|
+
import {
|
|
10
|
+
NAVIGATION_HEADER,
|
|
11
|
+
NAVIGATION_LOCATION_HEADER,
|
|
12
|
+
NAVIGATION_META,
|
|
13
|
+
NAVIGATION_SCOPE_HEADER,
|
|
14
|
+
NAVIGATION_SURFACE_HEADER,
|
|
15
|
+
} from './navigation-rules';
|
|
16
|
+
|
|
17
|
+
/** One fetched answer, read once — a prefetch and the click after it share it. */
|
|
18
|
+
export interface Answer {
|
|
19
|
+
readonly status: number;
|
|
20
|
+
readonly opaqueRedirect: boolean;
|
|
21
|
+
/** `x-ultimate-location`, resolved against the requested URL. */
|
|
22
|
+
readonly location: string | null;
|
|
23
|
+
readonly contentType: string;
|
|
24
|
+
readonly build: string | null;
|
|
25
|
+
readonly html: string | null;
|
|
26
|
+
readonly noStore: boolean;
|
|
27
|
+
/** A non-page answer's bytes, kept to hand over — never for a prefetch. */
|
|
28
|
+
readonly body: Blob | null;
|
|
29
|
+
readonly disposition: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const metaOf = (doc: Document, name: string): string | null =>
|
|
33
|
+
doc.querySelector(`meta[name="${name}"]`)?.getAttribute('content') ?? null;
|
|
34
|
+
|
|
35
|
+
export function fetchDocument(
|
|
36
|
+
win: Window,
|
|
37
|
+
doc: Document,
|
|
38
|
+
url: string,
|
|
39
|
+
purpose: 'soft' | 'prefetch',
|
|
40
|
+
init: RequestInit = {},
|
|
41
|
+
): Promise<Answer> {
|
|
42
|
+
const headers: Record<string, string> = {
|
|
43
|
+
accept: 'text/html,application/xhtml+xml',
|
|
44
|
+
[NAVIGATION_HEADER]: purpose,
|
|
45
|
+
[NAVIGATION_SURFACE_HEADER]: metaOf(doc, NAVIGATION_META) ?? '',
|
|
46
|
+
};
|
|
47
|
+
const scope = metaOf(doc, CLIENT_SCOPE_META);
|
|
48
|
+
if (scope !== null) headers[NAVIGATION_SCOPE_HEADER] = scope;
|
|
49
|
+
// GET only: a skewed GET is refused before any route runs; a POST is never refused mid-submit.
|
|
50
|
+
const build = metaOf(doc, CLIENT_BUILD_META);
|
|
51
|
+
if ((init.method ?? 'GET') === 'GET' && build !== null) headers[CLIENT_BUILD_META] = build;
|
|
52
|
+
return win
|
|
53
|
+
.fetch(url, { ...init, credentials: 'same-origin', redirect: 'manual', headers })
|
|
54
|
+
.then(async (response) => {
|
|
55
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
56
|
+
const opaqueRedirect = response.type === 'opaqueredirect';
|
|
57
|
+
const isHtml = /^\s*text\/html\b/i.test(contentType);
|
|
58
|
+
const html = !opaqueRedirect && isHtml ? await response.text() : null;
|
|
59
|
+
let body: Blob | null = null;
|
|
60
|
+
if (!opaqueRedirect && !isHtml && response.status !== 204) {
|
|
61
|
+
if (purpose === 'prefetch') await response.body?.cancel();
|
|
62
|
+
else body = await response.blob();
|
|
63
|
+
}
|
|
64
|
+
const location = response.headers.get(NAVIGATION_LOCATION_HEADER);
|
|
65
|
+
return {
|
|
66
|
+
status: response.status,
|
|
67
|
+
opaqueRedirect,
|
|
68
|
+
location: location === null ? null : new URL(location, url).href,
|
|
69
|
+
contentType,
|
|
70
|
+
build: response.headers.get(CLIENT_BUILD_META),
|
|
71
|
+
html,
|
|
72
|
+
noStore: /\bno-store\b/i.test(response.headers.get('cache-control') ?? ''),
|
|
73
|
+
body,
|
|
74
|
+
disposition: response.headers.get('content-disposition') ?? '',
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
}
|