@markless/router 0.5.0 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markless/router",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Markless router: client-side navigation with SSR streaming and build-time preload maps.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -64,11 +64,11 @@
64
64
  "pathe": "^2.0.3",
65
65
  "satteri": "0.6.3",
66
66
  "ufo": "^1.6.4",
67
- "@markless/bundler": "0.5.0",
68
- "@markless/web": "0.5.0"
67
+ "@markless/web": "0.7.0",
68
+ "@markless/bundler": "0.7.0"
69
69
  },
70
70
  "devDependencies": {
71
- "@markless/analyzer": "0.5.0"
71
+ "@markless/analyzer": "0.7.0"
72
72
  },
73
73
  "peerDependencies": {
74
74
  "typescript": "^5.9.3",
@@ -3,17 +3,24 @@ import type { ModulePreloadInput } from '@markless/web/render-to-string';
3
3
 
4
4
  export interface FragmentNavigationConfig {
5
5
  readonly entry: string;
6
+ readonly entryImports: readonly string[];
6
7
  readonly linkAttribute: string;
7
8
  readonly prefetchAttribute: string;
8
9
  readonly replaceAttribute: string;
9
10
  readonly scrollAttribute: string;
10
11
  readonly routeScriptType: string;
12
+ readonly servedHtmlAttributesProperty: string;
11
13
  readonly retireProperty: string;
12
14
  readonly streamExecutorGlobal: string;
13
15
  readonly streamedArmSelector: string;
14
- readonly requestHeader: string;
16
+ readonly requestPrefix: string;
15
17
  readonly regionEndMarker: string;
16
18
  readonly appendMarker: string;
19
+ // Close and open the page's nodes when the document's own element is the container; what is outside is the document's.
20
+ readonly pageEndComment: string;
21
+ readonly pageStartComment: string;
22
+ // The container's own attributes, never carried from the leaving page's element to the next one.
23
+ readonly containerAttributes: readonly [container: string, paths: string];
17
24
  // 'document' apps swap the whole body: their shell renders from the request URL.
18
25
  readonly documentLoads: boolean;
19
26
  // A mouse fetches after resting this long on a link; press, focus and touch fetch at once.
@@ -23,7 +30,7 @@ export interface FragmentNavigationConfig {
23
30
  readonly prefetchConnections: number;
24
31
  // Paths the server answers with endpoints, not pages: never fetched before a click or as a fragment.
25
32
  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.
33
+ // At most this many visible-link fragments are fetched at idle on fast connections, in document order; 0 keeps HTML to intent only.
27
34
  readonly idleFragments: number;
28
35
  readonly idleWithoutConnectionInfo: boolean;
29
36
  // false: nothing is fetched before a click.
@@ -64,9 +71,11 @@ export type FragmentNavigationState = {
64
71
  readonly prefetch: (url: URL) => void;
65
72
  readonly parse: (url: string, text: string) => FragmentFetched;
66
73
  readonly idleDownloads: boolean;
67
- // Schedules the idle tier: at idle, download the landing code and a few fragments of the page links in view.
74
+ // Schedules the idle tier: at idle, fetch a few page fragments of the links in view; their code waits for intent.
68
75
  readonly rescan: () => void;
69
76
  readonly scrolled: Set<Element>;
77
+ // Code the intent started preloading, by absolute href, settling once each file has arrived.
78
+ readonly arriving: Map<string, Promise<unknown>>;
70
79
  };
71
80
 
72
81
  export type FragmentScroll =
@@ -167,12 +176,15 @@ function regionOf(state: FragmentNavigationState, doc: Document): Element | null
167
176
  (
168
177
  doc.querySelector(`script[type="${state.config.routeScriptType}"]`) ??
169
178
  doc.querySelector('script[data-markless-router-link-resumer]')
170
- )?.closest('[data-async-container]') ?? null
179
+ )?.closest(`[${state.config.containerAttributes[0]}]`) ?? null
171
180
  );
172
181
  }
173
182
 
174
183
  function retire(state: FragmentNavigationState, root: Element): void {
175
- for (const container of [root, ...root.querySelectorAll('[data-async-container]')]) {
184
+ for (const container of [
185
+ root,
186
+ ...root.querySelectorAll(`[${state.config.containerAttributes[0]}]`),
187
+ ]) {
176
188
  const retireContainer = (container as unknown as Record<string, unknown>)[
177
189
  state.config.retireProperty
178
190
  ];
@@ -191,13 +203,157 @@ function reportAsync(error: unknown): void {
191
203
  });
192
204
  }
193
205
 
194
- function execute(d: Document, script: HTMLScriptElement): void {
195
- const type = script.type;
196
- if (type && type !== 'module' && type !== 'text/javascript') return;
206
+ // The JavaScript MIME types of the HTML spec, which a browser runs as classic scripts.
207
+ const JAVASCRIPT_TYPE =
208
+ /^(?:|(?:text|application)\/(?:x-)?(?:java|ecma)script|text\/(?:javascript1\.[0-5]|jscript|livescript))$/;
209
+
210
+ function scriptKind(script: HTMLScriptElement): 'classic' | 'module' | undefined {
211
+ const type = script.getAttribute('type');
212
+ const language = script.getAttribute('language');
213
+ const essence = (type ?? (language ? `text/${language}` : ''))
214
+ .split(';')[0]!
215
+ .trim()
216
+ .toLowerCase();
217
+ if (essence === 'module') return 'module';
218
+ if (!JAVASCRIPT_TYPE.test(essence) || script.hasAttribute('nomodule')) return undefined;
219
+ return 'classic';
220
+ }
221
+
222
+ // A parsed or adopted script never runs; a fresh element with its attributes and text does.
223
+ function runnable(d: Document, script: HTMLScriptElement): HTMLScriptElement {
197
224
  const clone = d.createElement('script');
198
225
  for (const attribute of script.attributes) clone.setAttribute(attribute.name, attribute.value);
199
226
  clone.textContent = script.textContent;
200
- script.replaceWith(clone);
227
+ return clone;
228
+ }
229
+
230
+ const settles = (script: HTMLScriptElement) =>
231
+ new Promise<unknown>((done) => {
232
+ script.addEventListener('load', done, { once: true });
233
+ script.addEventListener('error', done, { once: true });
234
+ });
235
+
236
+ // External files this document already ran: a page that includes one again keeps the loaded copy.
237
+ const ranFiles = new WeakMap<Document, Set<string>>();
238
+
239
+ function filesRan(d: Document): Set<string> {
240
+ let files = ranFiles.get(d);
241
+ if (!files) ranFiles.set(d, (files = new Set()));
242
+ for (const script of d.scripts) if (script.src && scriptKind(script)) files.add(script.src);
243
+ return files;
244
+ }
245
+
246
+ type LoadListener = {
247
+ readonly target: EventTarget;
248
+ readonly type: string;
249
+ readonly listener: EventListenerOrEventListenerObject;
250
+ };
251
+
252
+ // The document fired DOMContentLoaded and load already: listeners the page's scripts add are called once those scripts ran.
253
+ function captureLoadListeners(d: Document, w: Window, into: LoadListener[]): () => void {
254
+ const targets: Array<readonly [EventTarget, string]> = [];
255
+ if (d.readyState !== 'loading') targets.push([d, 'DOMContentLoaded']);
256
+ if (d.readyState === 'complete') targets.push([w, 'load']);
257
+ const restores = targets.map(([target, type]) => {
258
+ const own = Object.getOwnPropertyDescriptor(target, 'addEventListener');
259
+ const add = target.addEventListener;
260
+ target.addEventListener = function (
261
+ this: EventTarget,
262
+ kind: string,
263
+ listener: EventListenerOrEventListenerObject | null,
264
+ options?: boolean | AddEventListenerOptions,
265
+ ) {
266
+ if (kind === type && listener && this === target) into.push({ target, type, listener });
267
+ return add.call(this, kind, listener, options);
268
+ };
269
+ return () => {
270
+ if (own) Object.defineProperty(target, 'addEventListener', own);
271
+ else delete (target as { addEventListener?: unknown }).addEventListener;
272
+ };
273
+ });
274
+ return () => {
275
+ for (const restore of restores) restore();
276
+ };
277
+ }
278
+
279
+ function callLoadListeners(listeners: readonly LoadListener[], type: string): void {
280
+ const event = new Event(type);
281
+ for (const entry of listeners) {
282
+ if (entry.type !== type) continue;
283
+ try {
284
+ if (typeof entry.listener === 'function') entry.listener.call(entry.target, event);
285
+ else entry.listener.handleEvent(event);
286
+ } catch (error) {
287
+ reportAsync(error);
288
+ }
289
+ }
290
+ }
291
+
292
+ // A landing's order: blocking classic scripts one by one, async as they arrive, then deferred and module scripts, then load listeners.
293
+ async function runScripts(
294
+ d: Document,
295
+ scripts: readonly HTMLScriptElement[],
296
+ ran: Set<string>,
297
+ current: () => boolean,
298
+ ): Promise<void> {
299
+ const w = d.defaultView!;
300
+ const listeners: LoadListener[] = [];
301
+ const release = captureLoadListeners(d, w, listeners);
302
+ try {
303
+ const deferred: HTMLScriptElement[] = [];
304
+ for (const script of scripts) {
305
+ const kind = scriptKind(script);
306
+ if (!kind || !script.isConnected) continue;
307
+ const src = script.hasAttribute('src') ? script.src : '';
308
+ if (src && ran.has(src)) continue;
309
+ if (src) ran.add(src);
310
+ const async = script.hasAttribute('async') && (src !== '' || kind === 'module');
311
+ if (!async && (kind === 'module' || (src && script.hasAttribute('defer')))) {
312
+ deferred.push(script);
313
+ continue;
314
+ }
315
+ const clone = runnable(d, script);
316
+ if (async || !src) {
317
+ script.replaceWith(clone);
318
+ continue;
319
+ }
320
+ clone.async = false;
321
+ const done = settles(clone);
322
+ script.replaceWith(clone);
323
+ await done;
324
+ if (!current()) return;
325
+ }
326
+ let last: Promise<unknown> | undefined;
327
+ let tail: HTMLScriptElement | undefined;
328
+ for (const script of deferred) {
329
+ if (!script.isConnected) continue;
330
+ const clone = runnable(d, script);
331
+ clone.async = false;
332
+ last = clone.src ? settles(clone) : undefined;
333
+ tail = clone;
334
+ script.replaceWith(clone);
335
+ }
336
+ // An inline module fires no load event, so a marker queued after the last one says when it ran.
337
+ if (tail && !last) {
338
+ const key = `__marklessScriptsRan${Math.random().toString(36).slice(2)}`;
339
+ const host = w as unknown as Record<string, unknown>;
340
+ last = new Promise((done) => (host[key] = done));
341
+ const marker = d.createElement('script');
342
+ marker.type = 'module';
343
+ marker.async = false;
344
+ if (tail.nonce) marker.nonce = tail.nonce;
345
+ marker.textContent = `window.${key}();`;
346
+ d.head.append(marker);
347
+ await last;
348
+ delete host[key];
349
+ marker.remove();
350
+ } else await last;
351
+ if (!current()) return;
352
+ callLoadListeners(listeners, 'DOMContentLoaded');
353
+ callLoadListeners(listeners, 'load');
354
+ } finally {
355
+ release();
356
+ }
201
357
  }
202
358
 
203
359
  function loaded(w: Window, links: readonly HTMLLinkElement[]): Promise<unknown> {
@@ -235,9 +391,8 @@ async function mergeHead(d: Document, next: Document) {
235
391
  else d.head.prepend(node);
236
392
  if (node.localName === 'link' && (node as HTMLLinkElement).rel === 'stylesheet')
237
393
  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);
394
+ // A kept head script already ran on this document; only one the destination adds runs.
395
+ if (node.localName === 'script') scripts.push(node as HTMLScriptElement);
241
396
  }
242
397
  cursor = node;
243
398
  }
@@ -255,10 +410,13 @@ async function mergeHead(d: Document, next: Document) {
255
410
  // Only attributes a server document set are the server's to remove: scripts own the rest (a theme class).
256
411
  const serverHtmlAttributes = new WeakMap<Document, Set<string>>();
257
412
 
258
- function syncHtmlAttributes(d: Document, next: Document): void {
413
+ function syncHtmlAttributes(d: Document, next: Document, servedProperty: string): void {
259
414
  const root = d.documentElement;
260
415
  const incoming = next.documentElement;
261
- for (const name of serverHtmlAttributes.get(d) ?? [])
416
+ const served =
417
+ serverHtmlAttributes.get(d) ??
418
+ ((root as unknown as Record<string, readonly string[] | undefined>)[servedProperty] || []);
419
+ for (const name of served)
262
420
  if (!incoming.hasAttribute(name)) root.removeAttribute(name);
263
421
  for (const attribute of incoming.attributes)
264
422
  if (root.getAttribute(attribute.name) !== attribute.value)
@@ -266,12 +424,62 @@ function syncHtmlAttributes(d: Document, next: Document): void {
266
424
  serverHtmlAttributes.set(d, new Set(incoming.getAttributeNames()));
267
425
  }
268
426
 
427
+ // Set from the page's config at the start of every swap.
428
+ let containerAttribute = '';
429
+
269
430
  const isContainer = (node: Node): boolean =>
270
- node.nodeType === 1 && (node as Element).hasAttribute('data-async-container');
431
+ node.nodeType === 1 && (node as Element).hasAttribute(containerAttribute);
432
+
433
+ function pageEnd(host: Node, data: string): Comment | undefined {
434
+ for (let node = host.lastChild; node; node = node.previousSibling)
435
+ if (node.nodeType === 8 && (node as Comment).data === data) return node as Comment;
436
+ return undefined;
437
+ }
438
+
439
+ function pageStart(host: Node, data: string): Comment | undefined {
440
+ for (let node = host.firstChild; node; node = node.nextSibling)
441
+ if (node.nodeType === 8 && (node as Comment).data === data) return node as Comment;
442
+ return undefined;
443
+ }
444
+
445
+ function nodesBefore(d: Document, node: Node): DocumentFragment {
446
+ const nodes = d.createDocumentFragment();
447
+ while (node.previousSibling) nodes.prepend(node.previousSibling);
448
+ return nodes;
449
+ }
450
+
451
+ function nodesAfter(d: Document, node: Node): DocumentFragment {
452
+ const nodes = d.createDocumentFragment();
453
+ while (node.nextSibling) nodes.append(node.nextSibling);
454
+ return nodes;
455
+ }
456
+
457
+ const scriptsIn = (element: Element): Element[] =>
458
+ element.localName === 'script' ? [element] : [...element.querySelectorAll('script')];
459
+
460
+ const serverHostAttributes = new WeakMap<Document, Set<string>>();
461
+
462
+ // The incoming container is a fresh element; attributes a script set on the outgoing one stay.
463
+ function carryHostAttributes(
464
+ d: Document,
465
+ from: Element,
466
+ to: Element,
467
+ own: readonly string[],
468
+ ): void {
469
+ const server = serverHostAttributes.get(d);
470
+ serverHostAttributes.set(d, new Set(to.getAttributeNames()));
471
+ for (const attribute of from.attributes)
472
+ if (
473
+ !to.hasAttribute(attribute.name) &&
474
+ !server?.has(attribute.name) &&
475
+ !own.includes(attribute.name)
476
+ )
477
+ to.setAttribute(attribute.name, attribute.value);
478
+ }
271
479
 
272
480
  // A document app's shell has no runtime, so it is morphed in place: unchanged chrome keeps its nodes.
273
481
  // 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 {
482
+ function morphChildren(d: Document, from: Node, to: Node, fresh: Element[]): void {
275
483
  let cursor = from.firstChild;
276
484
  const skipContainers = () => {
277
485
  while (cursor && isContainer(cursor)) {
@@ -409,7 +617,7 @@ async function streamAppends(
409
617
  }
410
618
  }
411
619
  flush(true);
412
- end.parentNode?.removeChild(end);
620
+ if ((end as Comment).data !== state.config.pageEndComment) end.parentNode?.removeChild(end);
413
621
  return signal.aborted ? undefined : all;
414
622
  }
415
623
 
@@ -422,6 +630,7 @@ export async function navigate(
422
630
  const state = (d as FragmentHost).__marklessFragmentNav;
423
631
  const w = d.defaultView;
424
632
  if (!state || !w) return;
633
+ containerAttribute = state.config.containerAttributes[0];
425
634
  const session = sessionFor(d);
426
635
  const id = ++session.navigation;
427
636
  session.streaming?.abort();
@@ -442,12 +651,24 @@ export async function navigate(
442
651
  const documentLoads = state.config.documentLoads;
443
652
  const incoming = documentLoads ? next.body : regionOf(state, next);
444
653
  const outgoing = documentLoads ? d.body : regionOf(state, d);
445
- const scope = documentLoads ? d.body : outgoing?.parentElement;
654
+ const scope = documentLoads && !isContainer(d.body) ? d.body : outgoing?.parentElement;
446
655
  if (!incoming || !outgoing || !scope) {
447
656
  fallback(fetched.url);
448
657
  return;
449
658
  }
659
+ // The destination shows once its own code has arrived, so its first interaction never waits on a fetch.
660
+ const code = [...next.head.querySelectorAll<HTMLLinkElement>('link[rel="modulepreload"]')].map(
661
+ (link) => state.arriving.get(new URL(link.getAttribute('href') ?? '', d.baseURI).href),
662
+ );
663
+ for (const item of state.routes[url.pathname] ?? [])
664
+ code.push(
665
+ state.arriving.get(
666
+ new URL(typeof item === 'string' ? item : item.href, d.baseURI).href,
667
+ ),
668
+ );
669
+ const ranFiles = filesRan(d);
450
670
  const head = await mergeHead(d, next);
671
+ await Promise.race([Promise.all(code), new Promise((resolve) => w.setTimeout(resolve, 3000))]);
451
672
  if (id !== session.navigation) return taken.abort();
452
673
  let restore = scroll;
453
674
  if (mode === 'traverse') {
@@ -485,31 +706,67 @@ export async function navigate(
485
706
  (w as unknown as Record<string, unknown>)[state.config.streamExecutorGlobal] = undefined;
486
707
  for (const element of d.querySelectorAll(state.config.streamedArmSelector)) element.remove();
487
708
  state.shown = pageKey(new URL(w.location.href));
488
- if (documentLoads) syncHtmlAttributes(d, next);
489
- const fresh: Element[] = [];
709
+ if (documentLoads) syncHtmlAttributes(d, next, state.config.servedHtmlAttributesProperty);
490
710
  let region: Element;
491
- if (documentLoads) {
711
+ let end: Comment;
712
+ let scripts: Element[];
713
+ if (documentLoads && !isContainer(d.body)) {
714
+ const fresh: Element[] = [];
492
715
  region = d.body;
493
716
  morphChildren(d, region, incoming, fresh);
717
+ scripts = fresh.flatMap(scriptsIn);
718
+ region.append((end = d.createComment('')));
494
719
  } else {
495
720
  region = d.adoptNode(incoming);
496
- outgoing.replaceWith(region);
497
- fresh.push(region);
721
+ const leavingEnd = pageEnd(outgoing, state.config.pageEndComment);
722
+ if (leavingEnd) {
723
+ carryHostAttributes(d, outgoing, region, state.config.containerAttributes);
724
+ end =
725
+ pageEnd(region, state.config.pageEndComment) ??
726
+ region.appendChild(d.createComment(state.config.pageEndComment));
727
+ // The document's own nodes after the page stay; a document app morphs them to its next shell.
728
+ const focused = d.activeElement as HTMLElement | null;
729
+ const kept = nodesAfter(d, leavingEnd);
730
+ const arriving = nodesAfter(d, end);
731
+ const fresh: Element[] = [];
732
+ if (documentLoads) morphChildren(d, kept, arriving, fresh);
733
+ const leavingStart = pageStart(outgoing, state.config.pageStartComment);
734
+ let start = pageStart(region, state.config.pageStartComment);
735
+ if (leavingStart || start) {
736
+ start ??= region.insertBefore(
737
+ d.createComment(state.config.pageStartComment),
738
+ region.firstChild,
739
+ );
740
+ const keptBefore = leavingStart
741
+ ? nodesBefore(d, leavingStart)
742
+ : d.createDocumentFragment();
743
+ const arrivingBefore = nodesBefore(d, start);
744
+ if (documentLoads) morphChildren(d, keptBefore, arrivingBefore, fresh);
745
+ region.insertBefore(keptBefore, start);
746
+ }
747
+ scripts = [...scriptsIn(region), ...fresh.flatMap(scriptsIn)];
748
+ region.append(kept);
749
+ outgoing.replaceWith(region);
750
+ // The document's own nodes moved to the new element; focus inside them stays put.
751
+ if (focused && focused !== d.activeElement && region.contains(focused))
752
+ focused.focus({ preventScroll: true });
753
+ } else {
754
+ scripts = scriptsIn(region);
755
+ outgoing.replaceWith(region);
756
+ region.after((end = d.createComment('')));
757
+ }
498
758
  }
499
759
  // 'instant': a `scroll-behavior: smooth` page would otherwise animate every landing.
500
760
  for (const box of offsets)
501
761
  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
762
  for (const element of head.stale) element.remove();
506
763
  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);
764
+ const ran = runScripts(
765
+ d,
766
+ [...head.scripts, ...(scripts as HTMLScriptElement[])],
767
+ ranFiles,
768
+ () => id === session.navigation,
769
+ );
513
770
  const active = d.activeElement as HTMLElement | null;
514
771
  if (active && active !== d.body && !region.contains(active)) active.blur?.();
515
772
  (region.querySelector('[autofocus]') as HTMLElement | null)?.focus();
@@ -540,4 +797,5 @@ export async function navigate(
540
797
  if (w.requestIdleCallback) w.requestIdleCallback(idle, { timeout: 2000 });
541
798
  else w.setTimeout(idle, 200);
542
799
  }, reportAsync);
800
+ await ran;
543
801
  }