@markless/router 0.3.3 → 0.5.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/dist/debug-channel-CewnC9fH.js +2 -0
- package/dist/{debug-channel-CSzo-Zj8.js → debug-channel-Di6SppZv.js} +36 -17
- package/dist/fragment-navigation-CIiNnFFc.js +323 -0
- package/dist/index.d.ts +54 -33
- package/dist/index.js +50 -31
- package/dist/link-attributes-nY1Qkmx2.js +7 -0
- package/dist/protocol-constants-B4WxrZAS.js +26 -0
- package/dist/{request-files-C-0LLSsx.js → request-files-Ca8E0FOo.js} +3 -2
- package/dist/request-locals-BFPF6Vdm.js +28 -0
- package/dist/route-href-CGooO_g6.js +18 -0
- package/dist/{route-manifest-DPgGIyrS.js → route-manifest-Bw_nFohL.js} +9 -6
- package/dist/typescript-plugin.cjs +8 -5
- package/dist/typescript-plugin.d.ts +3 -11194
- package/dist/typescript-plugin.js +8 -5
- package/dist/vite/route-href.d.ts +2 -3
- package/dist/vite/route-href.js +1 -17
- package/dist/vite/runtime/create-route-discovery.d.ts +3 -4
- package/dist/vite/runtime/create-route-discovery.js +1 -1
- package/dist/vite/runtime/create-server-entry.d.ts +11 -5
- package/dist/vite/runtime/create-server-entry.js +354 -96
- package/dist/vite/runtime/mdx-route.d.ts +1097 -17
- package/dist/vite/runtime/mdx-route.js +1094 -31
- package/dist/vite.d.ts +10 -6
- package/dist/vite.js +782 -188
- package/package.json +7 -6
- package/src/vite/entries/client-entry.ts +10 -1
- package/src/vite/entries/fragment-entry.ts +543 -0
- package/src/vite/entries/resume-entry.ts +41 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markless/router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Markless router: client-side navigation with SSR streaming and build-time preload maps.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -57,21 +57,22 @@
|
|
|
57
57
|
"provenance": true
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@tsrx/
|
|
60
|
+
"@tsrx/yuku": "0.2.0",
|
|
61
61
|
"@virtualstate/navigation": "1.0.1-alpha.212",
|
|
62
62
|
"hast-util-to-html": "^9.0.5",
|
|
63
63
|
"nitro": "3.0.260429-beta",
|
|
64
64
|
"pathe": "^2.0.3",
|
|
65
65
|
"satteri": "0.6.3",
|
|
66
66
|
"ufo": "^1.6.4",
|
|
67
|
-
"@markless/bundler": "0.
|
|
68
|
-
"@markless/web": "0.
|
|
67
|
+
"@markless/bundler": "0.5.0",
|
|
68
|
+
"@markless/web": "0.5.0"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
|
-
"@markless/analyzer": "0.
|
|
71
|
+
"@markless/analyzer": "0.5.0"
|
|
72
72
|
},
|
|
73
73
|
"peerDependencies": {
|
|
74
|
-
"
|
|
74
|
+
"typescript": "^5.9.3",
|
|
75
|
+
"vite": "8.2.2"
|
|
75
76
|
},
|
|
76
77
|
"peerDependenciesMeta": {
|
|
77
78
|
"vite": {
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
startRouteUpdateRenderer,
|
|
11
11
|
} from '@markless/router';
|
|
12
12
|
import { preloadRouteModule } from 'virtual:markless-router/route-preloads';
|
|
13
|
+
import { documentNavigation, startLinkIntentPreloading } from 'virtual:markless-router/options';
|
|
13
14
|
|
|
14
15
|
const routeDiscovery = createRouteDiscovery(
|
|
15
16
|
import.meta.glob(['/pages/**/*.tsrx', '/pages/**/*.mdx'], {
|
|
@@ -20,8 +21,15 @@ const routeDiscovery = createRouteDiscovery(
|
|
|
20
21
|
export const pageModules = routeDiscovery.pageModuleLoaders;
|
|
21
22
|
export const routeFileIds = routeDiscovery.routeFileIds;
|
|
22
23
|
const routeManifest = buildRouteManifestFromFileIds(routeFileIds);
|
|
24
|
+
if (__MARKLESS_ROUTER_LINK_INTENT__ && documentNavigation === 'client') {
|
|
25
|
+
startLinkIntentPreloading(document, (url) => {
|
|
26
|
+
const match = matchRouteManifest(url.pathname, routeManifest);
|
|
27
|
+
if (match) preloadRouteModule(match.route.file);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
23
30
|
|
|
24
31
|
void __marklessRouterStartSpaNavigation({
|
|
32
|
+
documentNavigation,
|
|
25
33
|
pageModuleLoaders: pageModules,
|
|
26
34
|
preloadRouteModule,
|
|
27
35
|
routeFileIds,
|
|
@@ -35,7 +43,7 @@ export async function navigateMarklessRouterLink(input: {
|
|
|
35
43
|
}) {
|
|
36
44
|
const url = parseSameOriginUrl(input.href, window.location.href);
|
|
37
45
|
const match = url && matchRouteManifest(url.pathname, routeManifest);
|
|
38
|
-
if (!url || !match) {
|
|
46
|
+
if (!url || !match || (documentNavigation === 'document' && !url.hash.startsWith('#/'))) {
|
|
39
47
|
window.location.assign(input.href);
|
|
40
48
|
return;
|
|
41
49
|
}
|
|
@@ -43,6 +51,7 @@ export async function navigateMarklessRouterLink(input: {
|
|
|
43
51
|
startRouteUpdateRenderer(document);
|
|
44
52
|
preloadRouteModule(match.route.file);
|
|
45
53
|
await __marklessRouterStartSpaNavigation({
|
|
54
|
+
documentNavigation,
|
|
46
55
|
pageModuleLoaders: pageModules,
|
|
47
56
|
preloadRouteModule,
|
|
48
57
|
routeFileIds,
|
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
// The swap half of fragment navigation; the inline link bridge (vite/fragment-navigation.ts) fetches, this module commits.
|
|
2
|
+
import type { ModulePreloadInput } from '@markless/web/render-to-string';
|
|
3
|
+
|
|
4
|
+
export interface FragmentNavigationConfig {
|
|
5
|
+
readonly entry: string;
|
|
6
|
+
readonly linkAttribute: string;
|
|
7
|
+
readonly prefetchAttribute: string;
|
|
8
|
+
readonly replaceAttribute: string;
|
|
9
|
+
readonly scrollAttribute: string;
|
|
10
|
+
readonly routeScriptType: string;
|
|
11
|
+
readonly retireProperty: string;
|
|
12
|
+
readonly streamExecutorGlobal: string;
|
|
13
|
+
readonly streamedArmSelector: string;
|
|
14
|
+
readonly requestHeader: string;
|
|
15
|
+
readonly regionEndMarker: string;
|
|
16
|
+
readonly appendMarker: string;
|
|
17
|
+
// 'document' apps swap the whole body: their shell renders from the request URL.
|
|
18
|
+
readonly documentLoads: boolean;
|
|
19
|
+
// A mouse fetches after resting this long on a link; press, focus and touch fetch at once.
|
|
20
|
+
readonly hoverDwellMs: number;
|
|
21
|
+
readonly prefetchTtlMs: number;
|
|
22
|
+
// At most this many unused prefetches may still be streaming; starting another cancels the oldest.
|
|
23
|
+
readonly prefetchConnections: number;
|
|
24
|
+
// Paths the server answers with endpoints, not pages: never fetched before a click or as a fragment.
|
|
25
|
+
readonly endpointPrefixes: readonly string[];
|
|
26
|
+
// At most this many visible-link fragments are fetched at idle on fast connections, and only when the page shows no more than twice as many; 0 keeps HTML to intent only.
|
|
27
|
+
readonly idleFragments: number;
|
|
28
|
+
readonly idleWithoutConnectionInfo: boolean;
|
|
29
|
+
// false: nothing is fetched before a click.
|
|
30
|
+
readonly prefetch: boolean;
|
|
31
|
+
// Every page route's path, dynamic segments as ':' and a catch-all as '**': plain anchors to these navigate by fragment too.
|
|
32
|
+
readonly pages: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type FragmentRouteCode = Record<string, readonly ModulePreloadInput[]>;
|
|
36
|
+
|
|
37
|
+
export type FragmentFetched = {
|
|
38
|
+
readonly url: string;
|
|
39
|
+
readonly prefix: string;
|
|
40
|
+
readonly doc: Document;
|
|
41
|
+
readonly rest?: ReadableStreamDefaultReader<Uint8Array>;
|
|
42
|
+
readonly tail: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type FragmentPrefetch = {
|
|
46
|
+
readonly at: number;
|
|
47
|
+
readonly result: Promise<FragmentFetched>;
|
|
48
|
+
// The response is still arriving, so it holds a connection.
|
|
49
|
+
open: boolean;
|
|
50
|
+
readonly abort: () => void;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// What the inline bootstrap leaves on the document for the swap module (entries/fragment-entry.ts).
|
|
54
|
+
export type FragmentNavigationState = {
|
|
55
|
+
readonly config: FragmentNavigationConfig;
|
|
56
|
+
readonly routes: Record<string, readonly ModulePreloadInput[]>;
|
|
57
|
+
readonly prefetches: Map<string, FragmentPrefetch>;
|
|
58
|
+
readonly visited: Map<string, string>;
|
|
59
|
+
shown: string;
|
|
60
|
+
readonly eligible: (anchor: Element | null | undefined, forClick?: boolean) => URL | undefined;
|
|
61
|
+
readonly preloadCode: (url: URL, low: boolean) => void;
|
|
62
|
+
// speculative: a prefetch (low priority, `Purpose: prefetch`), not a click.
|
|
63
|
+
readonly request: (url: URL, speculative: boolean) => FragmentPrefetch;
|
|
64
|
+
readonly prefetch: (url: URL) => void;
|
|
65
|
+
readonly parse: (url: string, text: string) => FragmentFetched;
|
|
66
|
+
readonly idleDownloads: boolean;
|
|
67
|
+
// Schedules the idle tier: at idle, download the landing code and a few fragments of the page links in view.
|
|
68
|
+
readonly rescan: () => void;
|
|
69
|
+
readonly scrolled: Set<Element>;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type FragmentScroll =
|
|
73
|
+
| 'reset'
|
|
74
|
+
| 'manual'
|
|
75
|
+
| { readonly x: number; readonly y: number; readonly id?: string };
|
|
76
|
+
|
|
77
|
+
export type FragmentEntryModule = {
|
|
78
|
+
readonly navigate: (
|
|
79
|
+
d: Document,
|
|
80
|
+
url: URL,
|
|
81
|
+
mode: 'push' | 'replace' | 'traverse',
|
|
82
|
+
scroll: FragmentScroll,
|
|
83
|
+
) => Promise<void>;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
type FragmentHost = Document & { __marklessFragmentNav?: FragmentNavigationState };
|
|
87
|
+
type Session = {
|
|
88
|
+
navigation: number;
|
|
89
|
+
streaming?: AbortController;
|
|
90
|
+
// The history entry on screen, and the last scroll offset seen for every entry this tab visited.
|
|
91
|
+
current?: string;
|
|
92
|
+
readonly positions: Map<string, { readonly x: number; readonly y: number }>;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const sessions = new WeakMap<Document, Session>();
|
|
96
|
+
const parsedVisits = new WeakMap<FragmentNavigationState, Map<string, FragmentFetched>>();
|
|
97
|
+
|
|
98
|
+
function sessionFor(d: Document): Session {
|
|
99
|
+
let session = sessions.get(d);
|
|
100
|
+
if (session) return session;
|
|
101
|
+
const created: Session = { navigation: 0, positions: new Map() };
|
|
102
|
+
sessions.set(d, created);
|
|
103
|
+
const w = d.defaultView!;
|
|
104
|
+
w.addEventListener(
|
|
105
|
+
'scroll',
|
|
106
|
+
() => {
|
|
107
|
+
if (created.current)
|
|
108
|
+
created.positions.set(created.current, { x: w.scrollX, y: w.scrollY });
|
|
109
|
+
},
|
|
110
|
+
{ passive: true },
|
|
111
|
+
);
|
|
112
|
+
w.addEventListener('pagehide', () => {
|
|
113
|
+
if (created.current) saveScroll(w, created.current);
|
|
114
|
+
w.history.scrollRestoration = 'auto';
|
|
115
|
+
});
|
|
116
|
+
w.addEventListener('pageshow', (event) => {
|
|
117
|
+
if ((event as PageTransitionEvent).persisted && w.history.state?.__marklessFragment)
|
|
118
|
+
w.history.scrollRestoration = 'manual';
|
|
119
|
+
});
|
|
120
|
+
return created;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function saveScroll(w: Window, id: string): void {
|
|
124
|
+
w.history.replaceState(
|
|
125
|
+
{ ...w.history.state, __marklessFragment: { id, x: w.scrollX, y: w.scrollY } },
|
|
126
|
+
'',
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The entry the document landed on carries no id until it is left; naming it saves a history write per navigation.
|
|
131
|
+
const LANDING_ENTRY = '@landing';
|
|
132
|
+
|
|
133
|
+
const entryId = () => `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
|
134
|
+
|
|
135
|
+
const pageKey = (url: URL) => url.pathname + url.search;
|
|
136
|
+
|
|
137
|
+
function take(
|
|
138
|
+
state: FragmentNavigationState,
|
|
139
|
+
url: URL,
|
|
140
|
+
traverse: boolean,
|
|
141
|
+
now: number,
|
|
142
|
+
): FragmentPrefetch {
|
|
143
|
+
const key = pageKey(url);
|
|
144
|
+
const existing = state.prefetches.get(key);
|
|
145
|
+
state.prefetches.delete(key);
|
|
146
|
+
// Back and Forward restore the page as it was left, never waiting on a newer fetch of it.
|
|
147
|
+
const cached = traverse ? state.visited.get(key) : undefined;
|
|
148
|
+
if (cached !== undefined) {
|
|
149
|
+
existing?.abort();
|
|
150
|
+
const ready = parsedVisits.get(state)?.get(key);
|
|
151
|
+
parsedVisits.get(state)?.delete(key);
|
|
152
|
+
return {
|
|
153
|
+
at: now,
|
|
154
|
+
result: Promise.resolve(ready ?? state.parse(url.href, cached)),
|
|
155
|
+
open: false,
|
|
156
|
+
abort: () => {},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
if (existing && now - existing.at < state.config.prefetchTtlMs) return existing;
|
|
160
|
+
existing?.abort();
|
|
161
|
+
state.preloadCode(url, false);
|
|
162
|
+
return state.request(url, false);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function regionOf(state: FragmentNavigationState, doc: Document): Element | null {
|
|
166
|
+
return (
|
|
167
|
+
(
|
|
168
|
+
doc.querySelector(`script[type="${state.config.routeScriptType}"]`) ??
|
|
169
|
+
doc.querySelector('script[data-markless-router-link-resumer]')
|
|
170
|
+
)?.closest('[data-async-container]') ?? null
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function retire(state: FragmentNavigationState, root: Element): void {
|
|
175
|
+
for (const container of [root, ...root.querySelectorAll('[data-async-container]')]) {
|
|
176
|
+
const retireContainer = (container as unknown as Record<string, unknown>)[
|
|
177
|
+
state.config.retireProperty
|
|
178
|
+
];
|
|
179
|
+
if (typeof retireContainer !== 'function') continue;
|
|
180
|
+
try {
|
|
181
|
+
retireContainer();
|
|
182
|
+
} catch (error) {
|
|
183
|
+
reportAsync(error);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function reportAsync(error: unknown): void {
|
|
189
|
+
setTimeout(() => {
|
|
190
|
+
throw error;
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function execute(d: Document, script: HTMLScriptElement): void {
|
|
195
|
+
const type = script.type;
|
|
196
|
+
if (type && type !== 'module' && type !== 'text/javascript') return;
|
|
197
|
+
const clone = d.createElement('script');
|
|
198
|
+
for (const attribute of script.attributes) clone.setAttribute(attribute.name, attribute.value);
|
|
199
|
+
clone.textContent = script.textContent;
|
|
200
|
+
script.replaceWith(clone);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function loaded(w: Window, links: readonly HTMLLinkElement[]): Promise<unknown> {
|
|
204
|
+
if (links.length === 0) return Promise.resolve();
|
|
205
|
+
return Promise.race([
|
|
206
|
+
Promise.all(
|
|
207
|
+
links.map(
|
|
208
|
+
(link) =>
|
|
209
|
+
new Promise((resolve) => {
|
|
210
|
+
link.addEventListener('load', resolve, { once: true });
|
|
211
|
+
link.addEventListener('error', resolve, { once: true });
|
|
212
|
+
}),
|
|
213
|
+
),
|
|
214
|
+
),
|
|
215
|
+
new Promise((resolve) => w.setTimeout(resolve, 3000)),
|
|
216
|
+
]);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Existing head nodes stay where they are (moving a stylesheet re-applies it); new ones go in the destination's order.
|
|
220
|
+
async function mergeHead(d: Document, next: Document) {
|
|
221
|
+
const current = new Map<string, Element[]>();
|
|
222
|
+
for (const element of d.head.children) {
|
|
223
|
+
const list = current.get(element.outerHTML);
|
|
224
|
+
if (list) list.push(element);
|
|
225
|
+
else current.set(element.outerHTML, [element]);
|
|
226
|
+
}
|
|
227
|
+
const stylesheets: HTMLLinkElement[] = [];
|
|
228
|
+
const scripts: HTMLScriptElement[] = [];
|
|
229
|
+
let cursor: Element | null = null;
|
|
230
|
+
for (const element of Array.from(next.head.children)) {
|
|
231
|
+
const reused = current.get(element.outerHTML)?.shift();
|
|
232
|
+
const node = reused ?? d.adoptNode(element);
|
|
233
|
+
if (!reused) {
|
|
234
|
+
if (cursor) cursor.after(node);
|
|
235
|
+
else d.head.prepend(node);
|
|
236
|
+
if (node.localName === 'link' && (node as HTMLLinkElement).rel === 'stylesheet')
|
|
237
|
+
stylesheets.push(node as HTMLLinkElement);
|
|
238
|
+
// A kept inline script already ran on this document; only one the destination adds runs.
|
|
239
|
+
if (node.localName === 'script' && !(node as HTMLScriptElement).src)
|
|
240
|
+
scripts.push(node as HTMLScriptElement);
|
|
241
|
+
}
|
|
242
|
+
cursor = node;
|
|
243
|
+
}
|
|
244
|
+
const stale: Element[] = [];
|
|
245
|
+
for (const element of [...current.values()].flat()) {
|
|
246
|
+
const rel = element.localName === 'link' ? (element as HTMLLinkElement).rel : '';
|
|
247
|
+
if (rel === 'modulepreload' || rel === 'preload') continue;
|
|
248
|
+
if (rel === 'stylesheet' || element.localName === 'style') stale.push(element);
|
|
249
|
+
else element.remove();
|
|
250
|
+
}
|
|
251
|
+
await loaded(d.defaultView!, stylesheets);
|
|
252
|
+
return { stale, scripts };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Only attributes a server document set are the server's to remove: scripts own the rest (a theme class).
|
|
256
|
+
const serverHtmlAttributes = new WeakMap<Document, Set<string>>();
|
|
257
|
+
|
|
258
|
+
function syncHtmlAttributes(d: Document, next: Document): void {
|
|
259
|
+
const root = d.documentElement;
|
|
260
|
+
const incoming = next.documentElement;
|
|
261
|
+
for (const name of serverHtmlAttributes.get(d) ?? [])
|
|
262
|
+
if (!incoming.hasAttribute(name)) root.removeAttribute(name);
|
|
263
|
+
for (const attribute of incoming.attributes)
|
|
264
|
+
if (root.getAttribute(attribute.name) !== attribute.value)
|
|
265
|
+
root.setAttribute(attribute.name, attribute.value);
|
|
266
|
+
serverHtmlAttributes.set(d, new Set(incoming.getAttributeNames()));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const isContainer = (node: Node): boolean =>
|
|
270
|
+
node.nodeType === 1 && (node as Element).hasAttribute('data-async-container');
|
|
271
|
+
|
|
272
|
+
// A document app's shell has no runtime, so it is morphed in place: unchanged chrome keeps its nodes.
|
|
273
|
+
// Containers are always replaced whole: their DOM is the new page's resume payload's to locate.
|
|
274
|
+
function morphChildren(d: Document, from: Element, to: Element, fresh: Element[]): void {
|
|
275
|
+
let cursor = from.firstChild;
|
|
276
|
+
const skipContainers = () => {
|
|
277
|
+
while (cursor && isContainer(cursor)) {
|
|
278
|
+
const next: ChildNode | null = cursor.nextSibling;
|
|
279
|
+
cursor.remove();
|
|
280
|
+
cursor = next;
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
for (const incoming of Array.from(to.childNodes)) {
|
|
284
|
+
skipContainers();
|
|
285
|
+
if (
|
|
286
|
+
cursor &&
|
|
287
|
+
cursor.nodeType === incoming.nodeType &&
|
|
288
|
+
cursor.nodeName === incoming.nodeName &&
|
|
289
|
+
!isContainer(cursor) &&
|
|
290
|
+
!isContainer(incoming)
|
|
291
|
+
) {
|
|
292
|
+
const next = cursor.nextSibling;
|
|
293
|
+
morphNode(d, cursor, incoming, fresh);
|
|
294
|
+
cursor = next;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const node = d.adoptNode(incoming);
|
|
298
|
+
from.insertBefore(node, cursor);
|
|
299
|
+
if (node.nodeType === 1) fresh.push(node as Element);
|
|
300
|
+
}
|
|
301
|
+
while (cursor) {
|
|
302
|
+
const next = cursor.nextSibling;
|
|
303
|
+
cursor.remove();
|
|
304
|
+
cursor = next;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function morphNode(d: Document, from: ChildNode, to: ChildNode, fresh: Element[]): void {
|
|
309
|
+
if (from.nodeType !== 1) {
|
|
310
|
+
if ((from as CharacterData).data !== (to as CharacterData).data)
|
|
311
|
+
(from as CharacterData).data = (to as CharacterData).data;
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (from.isEqualNode(to)) return;
|
|
315
|
+
const element = from as Element;
|
|
316
|
+
const incoming = to as Element;
|
|
317
|
+
if (element.localName === 'script' || element.localName.includes('-')) {
|
|
318
|
+
const node = d.adoptNode(incoming);
|
|
319
|
+
element.replaceWith(node);
|
|
320
|
+
fresh.push(node);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
for (const name of element.getAttributeNames())
|
|
324
|
+
if (!incoming.hasAttribute(name)) element.removeAttribute(name);
|
|
325
|
+
for (const attribute of incoming.attributes)
|
|
326
|
+
if (element.getAttribute(attribute.name) !== attribute.value)
|
|
327
|
+
element.setAttribute(attribute.name, attribute.value);
|
|
328
|
+
morphChildren(d, element, incoming, fresh);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Scrolled boxes (a sidebar) keep their offset when the same box is there after the swap:
|
|
332
|
+
// at its old place in the tree, or else by its id when the next page wraps it differently.
|
|
333
|
+
type ScrolledBox = {
|
|
334
|
+
readonly path: string | undefined;
|
|
335
|
+
readonly id: string;
|
|
336
|
+
readonly kind: string;
|
|
337
|
+
readonly top: number;
|
|
338
|
+
readonly left: number;
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const boxKind = (element: Element) =>
|
|
342
|
+
`${element.localName}#${element.id}.${element.getAttribute('class') ?? ''}[${element.getAttribute('aria-label') ?? ''}]`;
|
|
343
|
+
|
|
344
|
+
function elementPath(root: Element, element: Element): string | undefined {
|
|
345
|
+
const steps: string[] = [];
|
|
346
|
+
for (let node: Element | null = element; node && node !== root; node = node.parentElement) {
|
|
347
|
+
const parent: Element | null = node.parentElement;
|
|
348
|
+
if (!parent) return undefined;
|
|
349
|
+
steps.push(`${[...parent.children].indexOf(node)}${node.localName}`);
|
|
350
|
+
}
|
|
351
|
+
return steps.reverse().join('/');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function elementAt(root: Element, path: string): Element | undefined {
|
|
355
|
+
let node: Element | undefined = root;
|
|
356
|
+
for (const step of path ? path.split('/') : []) {
|
|
357
|
+
const index = Number.parseInt(step, 10);
|
|
358
|
+
const child: Element | undefined = node?.children[index];
|
|
359
|
+
if (!child || `${index}${child.localName}` !== step) return undefined;
|
|
360
|
+
node = child;
|
|
361
|
+
}
|
|
362
|
+
return node;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function sameBox(root: Element, box: ScrolledBox): Element | undefined {
|
|
366
|
+
const placed = box.path === undefined ? undefined : elementAt(root, box.path);
|
|
367
|
+
if (placed) return placed;
|
|
368
|
+
const named = box.id ? root.ownerDocument.getElementById(box.id) : null;
|
|
369
|
+
return named && root.contains(named) && boxKind(named) === box.kind ? named : undefined;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Streamed @pending arms arrive after the region, one delimited piece each, and run exactly as on a landing.
|
|
373
|
+
async function streamAppends(
|
|
374
|
+
d: Document,
|
|
375
|
+
state: FragmentNavigationState,
|
|
376
|
+
fetched: FragmentFetched,
|
|
377
|
+
end: Node,
|
|
378
|
+
signal: AbortSignal,
|
|
379
|
+
): Promise<string | undefined> {
|
|
380
|
+
const marker = state.config.appendMarker;
|
|
381
|
+
const decoder = new TextDecoder();
|
|
382
|
+
let text = fetched.tail;
|
|
383
|
+
let all = fetched.tail;
|
|
384
|
+
const insert = (piece: string) => {
|
|
385
|
+
const parent = end.parentNode;
|
|
386
|
+
if (signal.aborted || !piece.trim() || !parent) return;
|
|
387
|
+
const range = d.createRange();
|
|
388
|
+
range.selectNodeContents(parent);
|
|
389
|
+
parent.insertBefore(range.createContextualFragment(piece), end);
|
|
390
|
+
};
|
|
391
|
+
const flush = (final: boolean) => {
|
|
392
|
+
const pieces = text.split(marker);
|
|
393
|
+
text = final ? '' : pieces.pop()!;
|
|
394
|
+
for (const piece of pieces) insert(piece);
|
|
395
|
+
};
|
|
396
|
+
if (fetched.rest) {
|
|
397
|
+
flush(false);
|
|
398
|
+
for (;;) {
|
|
399
|
+
const chunk = await fetched.rest.read();
|
|
400
|
+
if (signal.aborted) {
|
|
401
|
+
void fetched.rest.cancel().catch(() => {});
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
if (chunk.done) break;
|
|
405
|
+
const decoded = decoder.decode(chunk.value, { stream: true });
|
|
406
|
+
text += decoded;
|
|
407
|
+
all += decoded;
|
|
408
|
+
flush(false);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
flush(true);
|
|
412
|
+
end.parentNode?.removeChild(end);
|
|
413
|
+
return signal.aborted ? undefined : all;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export async function navigate(
|
|
417
|
+
d: Document,
|
|
418
|
+
url: URL,
|
|
419
|
+
mode: 'push' | 'replace' | 'traverse',
|
|
420
|
+
scroll: FragmentScroll,
|
|
421
|
+
): Promise<void> {
|
|
422
|
+
const state = (d as FragmentHost).__marklessFragmentNav;
|
|
423
|
+
const w = d.defaultView;
|
|
424
|
+
if (!state || !w) return;
|
|
425
|
+
const session = sessionFor(d);
|
|
426
|
+
const id = ++session.navigation;
|
|
427
|
+
session.streaming?.abort();
|
|
428
|
+
const fallback = (href: string) => {
|
|
429
|
+
if (mode === 'traverse') w.location.reload();
|
|
430
|
+
else w.location.assign(href);
|
|
431
|
+
};
|
|
432
|
+
const taken = take(state, url, mode === 'traverse', w.performance.now());
|
|
433
|
+
let fetched: FragmentFetched;
|
|
434
|
+
try {
|
|
435
|
+
fetched = await taken.result;
|
|
436
|
+
} catch {
|
|
437
|
+
if (id === session.navigation) fallback(url.href);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (id !== session.navigation) return taken.abort();
|
|
441
|
+
const next = fetched.doc;
|
|
442
|
+
const documentLoads = state.config.documentLoads;
|
|
443
|
+
const incoming = documentLoads ? next.body : regionOf(state, next);
|
|
444
|
+
const outgoing = documentLoads ? d.body : regionOf(state, d);
|
|
445
|
+
const scope = documentLoads ? d.body : outgoing?.parentElement;
|
|
446
|
+
if (!incoming || !outgoing || !scope) {
|
|
447
|
+
fallback(fetched.url);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const head = await mergeHead(d, next);
|
|
451
|
+
if (id !== session.navigation) return taken.abort();
|
|
452
|
+
let restore = scroll;
|
|
453
|
+
if (mode === 'traverse') {
|
|
454
|
+
const key = (typeof scroll === 'object' ? scroll.id : undefined) ?? LANDING_ENTRY;
|
|
455
|
+
const saved = session.positions.get(key);
|
|
456
|
+
if (saved) restore = saved;
|
|
457
|
+
session.current = key;
|
|
458
|
+
} else {
|
|
459
|
+
const leaving =
|
|
460
|
+
session.current ??
|
|
461
|
+
(w.history.state as { __marklessFragment?: { id?: string } } | null)?.__marklessFragment
|
|
462
|
+
?.id ??
|
|
463
|
+
LANDING_ENTRY;
|
|
464
|
+
session.positions.set(leaving, { x: w.scrollX, y: w.scrollY });
|
|
465
|
+
w.history.scrollRestoration = 'manual';
|
|
466
|
+
session.current = entryId();
|
|
467
|
+
const entry = { __marklessFragment: { id: session.current, x: 0, y: 0 } };
|
|
468
|
+
if (mode === 'push') w.history.pushState(entry, '', fetched.url);
|
|
469
|
+
else w.history.replaceState(entry, '', fetched.url);
|
|
470
|
+
}
|
|
471
|
+
const offsets: ScrolledBox[] = [];
|
|
472
|
+
for (const element of state.scrolled) {
|
|
473
|
+
if (!element.isConnected || !scope.contains(element)) continue;
|
|
474
|
+
if (element.scrollTop || element.scrollLeft)
|
|
475
|
+
offsets.push({
|
|
476
|
+
path: elementPath(scope, element),
|
|
477
|
+
id: element.id,
|
|
478
|
+
kind: boxKind(element),
|
|
479
|
+
top: element.scrollTop,
|
|
480
|
+
left: element.scrollLeft,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
state.scrolled.clear();
|
|
484
|
+
retire(state, outgoing);
|
|
485
|
+
(w as unknown as Record<string, unknown>)[state.config.streamExecutorGlobal] = undefined;
|
|
486
|
+
for (const element of d.querySelectorAll(state.config.streamedArmSelector)) element.remove();
|
|
487
|
+
state.shown = pageKey(new URL(w.location.href));
|
|
488
|
+
if (documentLoads) syncHtmlAttributes(d, next);
|
|
489
|
+
const fresh: Element[] = [];
|
|
490
|
+
let region: Element;
|
|
491
|
+
if (documentLoads) {
|
|
492
|
+
region = d.body;
|
|
493
|
+
morphChildren(d, region, incoming, fresh);
|
|
494
|
+
} else {
|
|
495
|
+
region = d.adoptNode(incoming);
|
|
496
|
+
outgoing.replaceWith(region);
|
|
497
|
+
fresh.push(region);
|
|
498
|
+
}
|
|
499
|
+
// 'instant': a `scroll-behavior: smooth` page would otherwise animate every landing.
|
|
500
|
+
for (const box of offsets)
|
|
501
|
+
sameBox(scope, box)?.scrollTo({ top: box.top, left: box.left, behavior: 'instant' });
|
|
502
|
+
const end = d.createComment('');
|
|
503
|
+
if (documentLoads) region.append(end);
|
|
504
|
+
else region.after(end);
|
|
505
|
+
for (const element of head.stale) element.remove();
|
|
506
|
+
if (next.title && d.title !== next.title) d.title = next.title;
|
|
507
|
+
for (const script of head.scripts) execute(d, script);
|
|
508
|
+
for (const element of fresh)
|
|
509
|
+
for (const script of element.localName === 'script'
|
|
510
|
+
? [element]
|
|
511
|
+
: element.querySelectorAll('script'))
|
|
512
|
+
execute(d, script as HTMLScriptElement);
|
|
513
|
+
const active = d.activeElement as HTMLElement | null;
|
|
514
|
+
if (active && active !== d.body && !region.contains(active)) active.blur?.();
|
|
515
|
+
(region.querySelector('[autofocus]') as HTMLElement | null)?.focus();
|
|
516
|
+
if (restore === 'reset') {
|
|
517
|
+
const target = url.hash ? d.getElementById(decodeURIComponent(url.hash.slice(1))) : null;
|
|
518
|
+
if (target) target.scrollIntoView({ behavior: 'instant' });
|
|
519
|
+
else w.scrollTo({ top: 0, left: 0, behavior: 'instant' });
|
|
520
|
+
} else if (restore !== 'manual')
|
|
521
|
+
w.scrollTo({ top: restore.y, left: restore.x, behavior: 'instant' });
|
|
522
|
+
d.dispatchEvent(new CustomEvent('marklessrouterrendered'));
|
|
523
|
+
state.rescan();
|
|
524
|
+
const controller = new AbortController();
|
|
525
|
+
session.streaming = controller;
|
|
526
|
+
const key = state.shown;
|
|
527
|
+
void streamAppends(d, state, fetched, end, controller.signal).then((rest) => {
|
|
528
|
+
if (rest === undefined) return;
|
|
529
|
+
state.visited.delete(key);
|
|
530
|
+
const text = `${fetched.prefix}${state.config.regionEndMarker}${rest}`;
|
|
531
|
+
state.visited.set(key, text);
|
|
532
|
+
while (state.visited.size > 8) state.visited.delete(state.visited.keys().next().value!);
|
|
533
|
+
const parsed = parsedVisits.get(state) ?? new Map<string, FragmentFetched>();
|
|
534
|
+
parsedVisits.set(state, parsed);
|
|
535
|
+
// Parsing runs no page code; done at idle, a Back to this page only swaps.
|
|
536
|
+
const idle = () => {
|
|
537
|
+
if (state.visited.get(key) === text) parsed.set(key, state.parse(fetched.url, text));
|
|
538
|
+
for (const other of parsed.keys()) if (!state.visited.has(other)) parsed.delete(other);
|
|
539
|
+
};
|
|
540
|
+
if (w.requestIdleCallback) w.requestIdleCallback(idle, { timeout: 2000 });
|
|
541
|
+
else w.setTimeout(idle, 200);
|
|
542
|
+
}, reportAsync);
|
|
543
|
+
}
|
|
@@ -1,26 +1,60 @@
|
|
|
1
|
-
const mdxPageModuleLoaders = import.meta.glob('/pages/**/*.mdx'
|
|
1
|
+
const mdxPageModuleLoaders = import.meta.glob('/pages/**/*.mdx', {
|
|
2
|
+
query: '?markless-resume',
|
|
3
|
+
});
|
|
2
4
|
const tsrxResumeModuleLoaders = import.meta.glob('/pages/**/*.tsrx', {
|
|
3
5
|
query: '?markless-resume',
|
|
4
6
|
});
|
|
7
|
+
const routeResumeModules = new WeakMap<() => Promise<unknown>, Promise<unknown>>();
|
|
5
8
|
|
|
6
9
|
export async function resumeContainerEvent(input: {
|
|
7
10
|
readonly root: ParentNode;
|
|
8
11
|
readonly [key: string]: unknown;
|
|
9
12
|
}) {
|
|
10
13
|
const file = routeFileFromRoot(input.root);
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
try {
|
|
15
|
+
const loadRouteResumeModule = file && routeResumeModuleLoader(file);
|
|
16
|
+
const routeResumeModule = loadRouteResumeModule
|
|
17
|
+
? await loadRouteModuleOnce(loadRouteResumeModule)
|
|
18
|
+
: undefined;
|
|
19
|
+
const resume = (routeResumeModule as RouteResumeModule | undefined)?.resumeContainerEvent;
|
|
20
|
+
if (typeof resume !== 'function') {
|
|
21
|
+
throw new Error(
|
|
22
|
+
`Markless Router could not resume route module: ${file ?? '<unknown>'}`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
await resume(input);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
const failure = `${error}`;
|
|
28
|
+
// A gesture needing a chunk a deploy removed reloads once; the failure names the chunk's hashed URL.
|
|
29
|
+
if (
|
|
30
|
+
import.meta.env.DEV ||
|
|
31
|
+
!input.event ||
|
|
32
|
+
!/imported module|script failed/.test(failure) ||
|
|
33
|
+
sessionStorage.mlChunkReload === failure
|
|
34
|
+
) {
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
sessionStorage.mlChunkReload = failure;
|
|
38
|
+
location.reload();
|
|
16
39
|
}
|
|
17
|
-
await resume(input);
|
|
18
40
|
}
|
|
19
41
|
|
|
20
42
|
interface RouteResumeModule {
|
|
21
43
|
readonly resumeContainerEvent?: (input: unknown) => unknown;
|
|
22
44
|
}
|
|
23
45
|
|
|
46
|
+
function loadRouteModuleOnce(load: () => Promise<unknown>): Promise<unknown> {
|
|
47
|
+
let pending = routeResumeModules.get(load);
|
|
48
|
+
if (!pending) {
|
|
49
|
+
pending = load().catch((error: unknown) => {
|
|
50
|
+
routeResumeModules.delete(load);
|
|
51
|
+
throw error;
|
|
52
|
+
});
|
|
53
|
+
routeResumeModules.set(load, pending);
|
|
54
|
+
}
|
|
55
|
+
return pending;
|
|
56
|
+
}
|
|
57
|
+
|
|
24
58
|
function routeResumeModuleLoader(file: string): (() => Promise<unknown>) | undefined {
|
|
25
59
|
const loaders = file.endsWith('.mdx') ? mdxPageModuleLoaders : tsrxResumeModuleLoaders;
|
|
26
60
|
return loaders[file] ?? loaders[`/${file}`];
|