@adukiorg/anza 0.2.0 → 0.2.3

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.
Files changed (83) hide show
  1. package/CHANGELOG.md +90 -4
  2. package/README.md +97 -133
  3. package/bin/anza/anza-linux-arm64 +0 -0
  4. package/bin/anza/anza-linux-x64 +0 -0
  5. package/bin/anza/anza-macos-arm64 +0 -0
  6. package/bin/anza/anza-macos-x64 +0 -0
  7. package/bin/anza/anza-windows-x64.exe +0 -0
  8. package/bin/anza/find.js +35 -0
  9. package/bin/anza/index.js +34 -0
  10. package/bin/anza/launch.js +19 -0
  11. package/bin/common/index.js +7 -0
  12. package/bin/common/logs.js +62 -0
  13. package/bin/create/copy.js +18 -0
  14. package/bin/create/index.js +45 -0
  15. package/bin/create/run.js +210 -0
  16. package/bin/create/write.js +19 -0
  17. package/importmap.json +4 -0
  18. package/package.json +16 -10
  19. package/src/core/offline/{usage.md → notes/usage.md} +11 -1
  20. package/src/core/router/boot.js +82 -0
  21. package/src/core/router/cascade.js +76 -0
  22. package/src/core/router/container.js +63 -72
  23. package/src/core/router/graph.js +144 -0
  24. package/src/core/router/index.js +12 -2
  25. package/src/core/router/intercept.js +26 -7
  26. package/src/core/router/lca.js +58 -0
  27. package/src/core/router/match.js +49 -36
  28. package/src/core/router/notes/audit-old.md +887 -0
  29. package/src/core/router/notes/audti.md +773 -0
  30. package/src/core/router/notes/tasks.md +473 -0
  31. package/src/core/router/{usage.md → notes/usage.md} +57 -35
  32. package/src/core/router/sync/tab.js +6 -4
  33. package/src/core/router/transitions.js +35 -8
  34. package/src/core/router/trie.js +130 -0
  35. package/src/core/security/{usage.md → notes/usage.md} +1 -2
  36. package/src/core/storage/{usage.md → notes/usage.md} +6 -6
  37. package/src/core/theme/index.js +78 -0
  38. package/src/core/ui/define/index.js +2 -1
  39. package/src/core/ui/define/orchestrator.js +10 -4
  40. package/src/core/ui/defs/dock.js +134 -0
  41. package/src/core/ui/defs/index.js +20 -0
  42. package/src/core/ui/defs/page.js +89 -0
  43. package/src/core/ui/defs/part.js +28 -0
  44. package/src/core/ui/defs/spec.js +96 -0
  45. package/src/core/ui/defs/view.js +23 -0
  46. package/src/core/ui/index.js +16 -3
  47. package/src/core/ui/notes/definations.md +979 -0
  48. package/src/tokens/index.css +1 -0
  49. package/src/tokens/semantic/contrast.css +18 -0
  50. package/src/tokens/semantic/transitions.css +32 -0
  51. package/types/core/platform/index.d.ts +39 -10
  52. package/types/core/router/index.d.ts +9 -0
  53. package/types/core/theme/index.d.ts +18 -0
  54. package/types/core/ui/index.d.ts +11 -0
  55. package/types/index.d.ts +1 -0
  56. package/bin/anza.js +0 -63
  57. package/bin/create.js +0 -150
  58. package/src/core/api/plan.md +0 -209
  59. package/src/core/events/missing.md +0 -103
  60. package/src/core/events/plan.md +0 -177
  61. package/src/core/offline/missing.md +0 -89
  62. package/src/core/offline/plan.md +0 -143
  63. package/src/core/platform/missing.md +0 -119
  64. package/src/core/platform/platform.d.ts +0 -88
  65. package/src/core/router/missing.md +0 -716
  66. package/src/core/router/outlet.js +0 -139
  67. package/src/core/router/plan.md +0 -370
  68. package/src/core/security/missing.md +0 -97
  69. package/src/core/state/missing.md +0 -165
  70. package/src/core/storage/missing.md +0 -165
  71. package/src/core/storage/plan.md +0 -69
  72. package/src/core/ui/implementation.md +0 -170
  73. package/src/core/ui/plan.md +0 -510
  74. package/src/core/ui/ui.types.md +0 -890
  75. /package/src/core/animations/{usage.md → notes/usage.md} +0 -0
  76. /package/src/core/api/{usage.md → notes/usage.md} +0 -0
  77. /package/src/core/events/{usage.md → notes/usage.md} +0 -0
  78. /package/src/core/platform/{usage.md → notes/usage.md} +0 -0
  79. /package/src/core/state/{usage.md → notes/usage.md} +0 -0
  80. /package/src/core/ui/{usage.md → notes/usage.md} +0 -0
  81. /package/src/core/ui/{watch.md → notes/watch.md} +0 -0
  82. /package/src/core/workers/{plan.md → notes/plan.md} +0 -0
  83. /package/src/core/workers/{usage.md → notes/usage.md} +0 -0
@@ -1,121 +1,109 @@
1
1
  /**
2
2
  * src/core/router/container.js
3
3
  *
4
- * Dynamic Container Registry for Advanced Topologies (v2).
5
- * Maintains a real-time, non-blocking map of actively mounted DOM layout containers.
6
- * Employs WeakRef and FinalizationRegistry for perfect GC safety, and idle
7
- * MutationObservers for standard HTML fallback tracking.
4
+ * Container registry facade. Delegates storage to the hierarchical graph
5
+ * (graph.js) while preserving the historical public API and the CSS-selector
6
+ * fallback for containers that are plain DOM elements rather than registered
7
+ * docks.
8
8
  *
9
- * Source: advance2.md
9
+ * Source: tasks.md Phase 3, advance2.md
10
10
  */
11
11
 
12
- // string -> WeakRef<HTMLElement>
13
- const containerNodeMap = new Map();
12
+ import { add, remove, element as graphElement, get, clear } from './graph.js';
14
13
 
15
- // Passive safety net: prune stale map entries after GC
16
- const cleanupRegistry = typeof FinalizationRegistry !== 'undefined'
17
- ? new FinalizationRegistry((name) => {
18
- if (containerNodeMap.get(name)?.deref() === undefined) {
19
- containerNodeMap.delete(name);
20
- }
21
- })
22
- : { register() {}, unregister() {} };
23
-
24
- let observer;
14
+ // Selectors we are still waiting to appear in the DOM.
25
15
  const observedSelectors = new Set();
16
+ let observer;
26
17
 
27
18
  /**
28
- * Boots the MutationObserver at idle priority if standard selectors are tracked.
19
+ * Boots the MutationObserver to discover standard (non-dock) containers that
20
+ * match a tracked CSS selector. Uses requestIdleCallback with a 100ms timeout
21
+ * so it still fires under sustained animation load, falling back to
22
+ * requestAnimationFrame where idle callbacks are unavailable (RT bug 8.1).
29
23
  */
30
24
  function ensureObserver() {
31
- if (observer || typeof window === 'undefined' || typeof requestIdleCallback === 'undefined') return;
25
+ if (observer || typeof window === 'undefined' || typeof MutationObserver === 'undefined') return;
32
26
 
33
- requestIdleCallback(() => {
27
+ const attach = () => {
28
+ if (observer) return;
34
29
  observer = new MutationObserver(() => {
35
- let activeObservationNeeded = false;
30
+ let stillWaiting = false;
36
31
  for (const selector of observedSelectors) {
37
- if (!containerNodeMap.get(selector)?.deref()) {
32
+ if (!graphElement(selector)) {
38
33
  const el = document.querySelector(selector);
39
- if (el) {
40
- registerContainer(selector, el);
41
- } else {
42
- activeObservationNeeded = true;
43
- }
34
+ if (el) registerContainer(selector, el);
35
+ else stillWaiting = true;
44
36
  }
45
37
  }
46
-
47
- // RT-06: Automatically disconnect MutationObserver if all observed selectors are resolved
48
- if (!activeObservationNeeded && observer) {
38
+ if (!stillWaiting && observer) {
49
39
  observer.disconnect();
50
40
  observer = null;
51
41
  }
52
42
  });
53
-
54
43
  observer.observe(document.body, { childList: true, subtree: true });
55
- });
44
+ };
45
+
46
+ if (typeof requestIdleCallback !== 'undefined') {
47
+ requestIdleCallback(attach, { timeout: 100 });
48
+ } else {
49
+ requestAnimationFrame(attach);
50
+ }
56
51
  }
57
52
 
58
53
  /**
59
54
  * Registers a layout container as actively mounted in the DOM.
60
- * @param {string} name - The unique identifier/selector of the container.
61
- * @param {HTMLElement} element - The DOM element instance.
55
+ *
56
+ * @param {string} name - unique registry key (or CSS selector).
57
+ * @param {HTMLElement} element - the DOM element instance.
58
+ * @param {string} [parent='body'] - parent container key in the graph.
62
59
  */
63
- export function registerContainer(name, element) {
64
- const existing = containerNodeMap.get(name)?.deref();
65
- if (existing && existing !== element) {
66
- throw new Error(`ContainerError: Singleton violation — '${name}' is already mounted. A second instance cannot register while the first is active.`);
67
- }
68
-
69
- containerNodeMap.set(name, new WeakRef(element));
70
- cleanupRegistry.register(element, name);
60
+ export function registerContainer(name, element, parent = 'body') {
61
+ add(name, element, parent);
71
62
  }
72
63
 
73
64
  /**
74
65
  * Unregisters a layout container when it is removed from the DOM.
75
- * @param {string} name - The unique identifier/selector of the container.
76
- * @param {HTMLElement} element - The DOM element instance (used for safety check).
66
+ *
67
+ * @param {string} name - the registry key.
68
+ * @param {HTMLElement} [element] - element guard for the safety check.
77
69
  */
78
70
  export function unregisterContainer(name, element) {
79
- const existing = containerNodeMap.get(name)?.deref();
80
- if (!element || existing === element) {
81
- containerNodeMap.delete(name);
82
- if (existing) {
83
- try { cleanupRegistry.unregister(existing); } catch(e) {}
84
- }
85
- }
71
+ remove(name, element);
86
72
  }
87
73
 
88
74
  /**
89
75
  * Retrieves an active layout container by name.
90
- * @param {string} name - The unique identifier of the container.
91
- * @returns {HTMLElement|undefined} The active container element, or undefined if not mounted.
76
+ *
77
+ * A plain key (e.g. 'main') is resolved only through the graph. A CSS selector
78
+ * (starts with '#', '.', '[', or contains a combinator) additionally resolves
79
+ * against the document and self-registers on first hit.
80
+ *
81
+ * @param {string} name - the registry key or selector.
82
+ * @returns {HTMLElement|undefined} the element, or undefined if not mounted.
92
83
  */
93
84
  export function getContainer(name) {
94
- let el = containerNodeMap.get(name)?.deref();
85
+ let el = graphElement(name);
86
+ if (el) return el;
95
87
 
96
- // A plain registry key (e.g. 'main') is only ever looked up in the registry.
97
- // A CSS selector (starts with '#', '.', '[', or contains a combinator) is
98
- // resolved against the document. This removes the ambiguity where 'main'
99
- // could mean a registry key or `querySelector('main')`.
100
- if (!el && isSelector(name) && typeof document !== 'undefined') {
88
+ if (isSelector(name) && typeof document !== 'undefined') {
101
89
  try {
102
90
  el = document.querySelector(name);
103
91
  if (el) {
104
92
  registerContainer(name, el);
105
- } else {
106
- if (!observedSelectors.has(name)) {
107
- observedSelectors.add(name);
108
- }
109
- ensureObserver();
93
+ return el;
110
94
  }
111
- } catch (err) {
112
- // Invalid selector string, ignore.
95
+ if (!observedSelectors.has(name)) observedSelectors.add(name);
96
+ ensureObserver();
97
+ } catch (_) {
98
+ // Invalid selector string — ignore.
113
99
  }
100
+ return el ?? undefined;
114
101
  }
115
102
 
116
- // Warn on ambiguous names that look like they could be either a key or selector
117
- if (!el && typeof name === 'string' && !isSelector(name) && typeof document !== 'undefined') {
118
- const queryResult = document.querySelector(name);
103
+ // Warn when a plain key shadows a real DOM element of the same tag name.
104
+ if (typeof name === 'string' && typeof document !== 'undefined') {
105
+ let queryResult = null;
106
+ try { queryResult = document.querySelector(name); } catch (_) {}
119
107
  if (queryResult) {
120
108
  console.warn(
121
109
  `[Router] Container name "${name}" is ambiguous: it exists in the DOM as a selector ` +
@@ -125,7 +113,7 @@ export function getContainer(name) {
125
113
  }
126
114
  }
127
115
 
128
- return el;
116
+ return el ?? undefined;
129
117
  }
130
118
 
131
119
  /** Heuristic: does this string look like a CSS selector rather than a key? */
@@ -134,13 +122,16 @@ function isSelector(name) {
134
122
  }
135
123
 
136
124
  /**
137
- * Clears the entire container registry.
125
+ * Clears the entire container registry and stops selector observation.
138
126
  */
139
127
  export function clearContainers() {
140
- containerNodeMap.clear();
128
+ clear();
141
129
  observedSelectors.clear();
142
130
  if (observer) {
143
131
  observer.disconnect();
144
132
  observer = null;
145
133
  }
146
134
  }
135
+
136
+ // Re-export the graph node accessor for modules that need topology directly.
137
+ export { get as getNode };
@@ -0,0 +1,144 @@
1
+ /**
2
+ * src/core/router/graph.js
3
+ *
4
+ * Hierarchical container graph. Replaces the flat name->WeakRef registry with a
5
+ * tree that knows parent/child relationships and depth, enabling lowest-common-
6
+ * ancestor traversal (lca.js) and sequential cascade mounting (cascade.js).
7
+ *
8
+ * Every node holds a WeakRef to its element so an unmounted container can be
9
+ * garbage-collected without a manual unregister. A FinalizationRegistry prunes
10
+ * stale nodes. The virtual root 'body' always exists.
11
+ *
12
+ * Source: tasks.md Phase 3
13
+ */
14
+
15
+ class Node {
16
+ constructor(name, ref, parent) {
17
+ this.name = name; // registry key, e.g. 'main' | 'sidebar'
18
+ this.ref = ref; // WeakRef<Element> | null (null for virtual root)
19
+ this.parent = parent; // Node | null
20
+ this.children = new Set(); // Set<Node>
21
+ this.depth = parent ? parent.depth + 1 : 0;
22
+ }
23
+
24
+ /** @returns {boolean} true while the referenced element is still alive. */
25
+ alive() {
26
+ return this.ref ? this.ref.deref() !== undefined : true;
27
+ }
28
+ }
29
+
30
+ const nodes = new Map();
31
+ const root = new Node('body', null, null);
32
+ nodes.set('body', root);
33
+
34
+ // Explicit unregistrations. element() returns undefined for a name in this set
35
+ // so a just-unmounted container is not confused with a GC'd one (RT bug 8.2).
36
+ const gone = new Set();
37
+
38
+ // Prune stale nodes after their element is collected.
39
+ const finalizer = typeof FinalizationRegistry !== 'undefined'
40
+ ? new FinalizationRegistry((name) => {
41
+ const node = nodes.get(name);
42
+ if (node && !node.alive()) detach(name);
43
+ })
44
+ : { register() {}, unregister() {} };
45
+
46
+ /** Removes a node from the tree, reparenting its children to its parent. */
47
+ function detach(name) {
48
+ const node = nodes.get(name);
49
+ if (!node || node === root) return;
50
+ node.parent?.children.delete(node);
51
+ for (const child of node.children) {
52
+ child.parent = node.parent;
53
+ child.depth = child.parent ? child.parent.depth + 1 : 0;
54
+ node.parent?.children.add(child);
55
+ }
56
+ nodes.delete(name);
57
+ }
58
+
59
+ /**
60
+ * Inserts (or re-points) a container node under a parent.
61
+ *
62
+ * @param {string} name - unique registry key.
63
+ * @param {Element} el - the container element.
64
+ * @param {string} [parent='body'] - parent registry key.
65
+ * @returns {Node} the inserted node.
66
+ */
67
+ export function add(name, el, parent = 'body') {
68
+ gone.delete(name);
69
+
70
+ const existing = nodes.get(name);
71
+ if (existing && existing !== root) {
72
+ const prev = existing.ref?.deref();
73
+ if (prev && prev !== el) {
74
+ throw new Error(`ContainerError: Singleton violation — '${name}' is already mounted. A second instance cannot register while the first is active.`);
75
+ }
76
+ // Same element re-registering (e.g. HMR): refresh the ref and return.
77
+ existing.ref = new WeakRef(el);
78
+ return existing;
79
+ }
80
+
81
+ const parentNode = nodes.get(parent) ?? root;
82
+ const node = new Node(name, new WeakRef(el), parentNode);
83
+ parentNode.children.add(node);
84
+ nodes.set(name, node);
85
+ finalizer.register(el, name);
86
+ return node;
87
+ }
88
+
89
+ /**
90
+ * Removes a container node. Marks the name as explicitly gone for one macrotask
91
+ * so a lookup during teardown does not fall back to a stale resolution.
92
+ *
93
+ * @param {string} name - registry key.
94
+ * @param {Element} [el] - element guard; if given, only removes when it matches.
95
+ */
96
+ export function remove(name, el) {
97
+ const node = nodes.get(name);
98
+ if (!node || node === root) return;
99
+
100
+ const current = node.ref?.deref();
101
+ if (el && current && current !== el) return;
102
+
103
+ if (current) {
104
+ try { finalizer.unregister(current); } catch (_) {}
105
+ }
106
+ detach(name);
107
+
108
+ gone.add(name);
109
+ if (typeof setTimeout !== 'undefined') {
110
+ setTimeout(() => gone.delete(name), 0);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * @param {string} name - registry key.
116
+ * @returns {Node|null} the node, or null if absent.
117
+ */
118
+ export function get(name) {
119
+ return nodes.get(name) ?? null;
120
+ }
121
+
122
+ /**
123
+ * Resolves a node's live element.
124
+ *
125
+ * @param {string} name - registry key.
126
+ * @returns {Element|null|undefined} element, null if absent,
127
+ * or undefined if the name was just explicitly removed.
128
+ */
129
+ export function element(name) {
130
+ if (gone.has(name)) return undefined;
131
+ const node = nodes.get(name);
132
+ if (!node) return null;
133
+ return node.ref ? (node.ref.deref() ?? null) : null;
134
+ }
135
+
136
+ /** Resets the graph to root-only. */
137
+ export function clear() {
138
+ nodes.clear();
139
+ root.children.clear();
140
+ gone.clear();
141
+ nodes.set('body', root);
142
+ }
143
+
144
+ export { Node, root };
@@ -48,8 +48,6 @@ import {
48
48
 
49
49
  import { cache, prefetch } from './cache.js';
50
50
 
51
- import './outlet.js';
52
-
53
51
  export const router = {
54
52
  // Registration and boundary hooks
55
53
  register,
@@ -110,6 +108,18 @@ export const router = {
110
108
 
111
109
  // Auto-bootstrap client-side navigation listeners on client load
112
110
  if (typeof window !== 'undefined') {
111
+ // Expose the router globally so non-module scripts, devtools, and definition
112
+ // helpers (page/dock) can reach it without importing. Non-enumerable and
113
+ // non-configurable so it cannot be accidentally clobbered or redefined.
114
+ if (!('router' in window)) {
115
+ Object.defineProperty(window, 'router', {
116
+ value: router,
117
+ writable: false,
118
+ enumerable: false,
119
+ configurable: false
120
+ });
121
+ }
122
+
113
123
  registerNavigator(navigate);
114
124
  setup();
115
125
  setupTabSync(router);
@@ -12,6 +12,8 @@ import { match } from './match.js';
12
12
  import { transitions } from './transitions.js';
13
13
  import { getContainer } from './container.js';
14
14
  import { isCallback, runCallback } from './handler.js';
15
+ import { boot, reset as resetBoot } from './boot.js';
16
+ import { ensure } from './cascade.js';
15
17
 
16
18
  let guards = [];
17
19
  let notFoundHandler = null;
@@ -147,11 +149,24 @@ export function setup() {
147
149
  return;
148
150
  }
149
151
 
150
- // Strict Layout Resolution Guard: prevent blind mounts if required container is inactive
151
- if (routeMatch?.route?.meta?.container) {
152
- const containerName = routeMatch.route.meta.container;
153
- if (!getContainer(containerName)) {
154
- const err = new Error(`RouteError: Required layout container '${containerName}' is not active in the DOM.`);
152
+ // Layout resolution: ensure the route's container chain is mounted.
153
+ // Routes declare an ordered `via` chain (root-to-leaf) or a single
154
+ // `container`. Missing containers are mounted via cascade rather than
155
+ // throwing — a hard refresh on a deep route can now build its own
156
+ // layout instead of erroring out.
157
+ if (routeMatch) {
158
+ const meta = routeMatch.route?.meta ?? {};
159
+ const chain = Array.isArray(meta.via) && meta.via.length
160
+ ? meta.via
161
+ : (meta.container ? [meta.container] : []);
162
+
163
+ try {
164
+ for (let i = 0; i < chain.length; i++) {
165
+ if (!getContainer(chain[i])) {
166
+ await ensure(chain[i], chain[i - 1] ?? 'body');
167
+ }
168
+ }
169
+ } catch (err) {
155
170
  emit('error', { error: err, url: destination.url, route: routeMatch.route, phase: 'container' });
156
171
  throw err;
157
172
  }
@@ -244,8 +259,11 @@ export function setup() {
244
259
  window.navigation.addEventListener('navigatesuccess', successListener);
245
260
  window.navigation.addEventListener('navigateerror', errorListener);
246
261
 
247
- // Trigger initial on-boot matching and emit initial events once setup is completed
248
- Promise.resolve().then(async () => {
262
+ // Trigger initial on-boot matching once the DOM is parsed and every gated
263
+ // prerequisite (element definitions registered via boot.gate) has settled.
264
+ // Deferring this past DOMContentLoaded is what survives a hard refresh on a
265
+ // deep route — the first match no longer races element registration.
266
+ boot(async () => {
249
267
  const url = window.navigation.currentEntry?.url || window.location.href;
250
268
  const routeMatch = await match(url);
251
269
  if (routeMatch) {
@@ -284,6 +302,7 @@ export function destroy() {
284
302
 
285
303
  guards = [];
286
304
  notFoundHandler = null;
305
+ resetBoot();
287
306
 
288
307
  for (const set of Object.values(listeners)) {
289
308
  set.clear();
@@ -0,0 +1,58 @@
1
+ /**
2
+ * src/core/router/lca.js
3
+ *
4
+ * Lowest common ancestor over the container graph. Given two container names,
5
+ * computes the deepest node that is an ancestor of both — the pivot for a
6
+ * cross-branch navigation (everything below it on the source side unmounts,
7
+ * everything below it on the target side mounts).
8
+ *
9
+ * O(d) where d is tree depth. For real UI trees d rarely exceeds 5.
10
+ *
11
+ * Source: tasks.md Phase 4
12
+ */
13
+
14
+ import { get } from './graph.js';
15
+
16
+ /**
17
+ * @param {string} a - first container name.
18
+ * @param {string} b - second container name.
19
+ * @returns {import('./graph.js').Node|null} the lowest common ancestor, or null.
20
+ */
21
+ export function lca(a, b) {
22
+ let na = get(a);
23
+ let nb = get(b);
24
+ if (!na || !nb) return null;
25
+
26
+ // Phase 1: equalize depth.
27
+ while (na.depth > nb.depth) na = na.parent;
28
+ while (nb.depth > na.depth) nb = nb.parent;
29
+
30
+ // Phase 2: walk up in tandem until the pointers meet.
31
+ while (na && nb && na !== nb) {
32
+ na = na.parent;
33
+ nb = nb.parent;
34
+ }
35
+
36
+ return na ?? null;
37
+ }
38
+
39
+ /**
40
+ * Ordered node path from the common ancestor down to `to`.
41
+ *
42
+ * @param {string} from - source container name.
43
+ * @param {string} to - target container name.
44
+ * @returns {import('./graph.js').Node[]|null} ancestor-first node chain, or null.
45
+ */
46
+ export function path(from, to) {
47
+ const ancestor = lca(from, to);
48
+ if (!ancestor) return null;
49
+
50
+ const segments = [];
51
+ let current = get(to);
52
+ while (current && current !== ancestor) {
53
+ segments.unshift(current);
54
+ current = current.parent;
55
+ }
56
+ segments.unshift(ancestor);
57
+ return segments;
58
+ }
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { guard } from '../platform/index.js';
11
11
  import { resolveTag } from './handler.js';
12
+ import { insert as trieInsert, find as trieFind, clear as trieClear } from './trie.js';
12
13
 
13
14
  const routes = [];
14
15
  // Resolve Pattern class at module load if available natively (RT-03)
@@ -42,12 +43,18 @@ function getSpecificity(patternStr) {
42
43
  * Registers a route mapping.
43
44
  */
44
45
  export function register(patternStr, handler, meta = {}) {
45
- routes.push({
46
+ const route = {
46
47
  patternStr,
47
48
  handler,
48
49
  meta,
49
50
  pattern: null
50
- });
51
+ };
52
+ routes.push(route);
53
+
54
+ // Index in the radix trie for O(k) matching. Patterns the trie cannot
55
+ // express (regex groups, modifiers, absolute URLs) stay on the URLPattern
56
+ // scan below — trieInsert returns false for those.
57
+ trieInsert(patternStr, route);
51
58
 
52
59
  // Sort routes by specificity (descending) and length (longer first) at registration time (RT-04)
53
60
  routes.sort((a, b) => {
@@ -66,6 +73,15 @@ export async function match(url) {
66
73
  const P = Pattern || (await getURLPattern());
67
74
  const targetUrl = new URL(url, globalThis.location?.href || 'http://localhost');
68
75
 
76
+ // Fast path: O(k) radix-trie lookup on the pathname. Covers the common case
77
+ // of plain/:param/* patterns without compiling or executing a URLPattern.
78
+ const trieHit = trieFind(targetUrl.pathname);
79
+ if (trieHit) {
80
+ return finalize(trieHit.route, trieHit.params, targetUrl, null);
81
+ }
82
+
83
+ // Fallback: URLPattern scan for patterns the trie cannot express
84
+ // (regex groups, modifiers, absolute-URL patterns).
69
85
  for (const route of routes) {
70
86
  if (!route.pattern) {
71
87
  if (route.patternStr.startsWith('http://') || route.patternStr.startsWith('https://')) {
@@ -77,51 +93,48 @@ export async function match(url) {
77
93
 
78
94
  const result = route.pattern.exec(targetUrl.href);
79
95
  if (result) {
80
- // Resolve to a tag without ever invoking callback handlers (see handler.js).
81
- const tag = await resolveTag(route.handler);
82
-
83
- const query = Object.fromEntries(targetUrl.searchParams.entries());
84
- const hash = targetUrl.hash;
85
-
86
- const chain = [];
87
- let currentRoute = route;
88
- while (currentRoute) {
89
- const currentTag = await resolveTag(currentRoute.handler);
90
- chain.unshift({
91
- route: currentRoute,
92
- tag: currentTag,
93
- params: result.pathname.groups || {}
94
- });
95
-
96
- const parentPattern = currentRoute.meta?.parent;
97
- if (parentPattern) {
98
- const parentRoute = routes.find(r => r.patternStr === parentPattern);
99
- currentRoute = parentRoute;
100
- } else {
101
- currentRoute = null;
102
- }
103
- }
104
-
105
- return {
106
- route,
107
- tag,
108
- params: result.pathname.groups || {},
109
- query,
110
- hash,
111
- chain,
112
- result
113
- };
96
+ return finalize(route, result.pathname.groups || {}, targetUrl, result);
114
97
  }
115
98
  }
116
99
 
117
100
  return null;
118
101
  }
119
102
 
103
+ /**
104
+ * Builds the full match result (tag, query, hash, parent chain) for a matched
105
+ * route. The parent chain walk is cycle-guarded so a misconfigured
106
+ * A→B→A parent loop breaks instead of hanging (RT bug 8.3).
107
+ */
108
+ async function finalize(route, params, targetUrl, result) {
109
+ const tag = await resolveTag(route.handler);
110
+ const query = Object.fromEntries(targetUrl.searchParams.entries());
111
+ const hash = targetUrl.hash;
112
+
113
+ const chain = [];
114
+ const visited = new Set();
115
+ let currentRoute = route;
116
+ while (currentRoute) {
117
+ if (visited.has(currentRoute.patternStr)) break; // cycle detected
118
+ visited.add(currentRoute.patternStr);
119
+
120
+ const currentTag = await resolveTag(currentRoute.handler);
121
+ chain.unshift({ route: currentRoute, tag: currentTag, params });
122
+
123
+ const parentPattern = currentRoute.meta?.parent;
124
+ currentRoute = parentPattern
125
+ ? routes.find(r => r.patternStr === parentPattern)
126
+ : null;
127
+ }
128
+
129
+ return { route, tag, params, query, hash, chain, result };
130
+ }
131
+
120
132
  /**
121
133
  * Clears the route registry.
122
134
  */
123
135
  export function clear() {
124
136
  routes.length = 0;
137
+ trieClear();
125
138
  }
126
139
 
127
140
  /**