@adukiorg/anza 0.4.1 → 0.4.2
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/bin/anza/anza-linux-arm64 +0 -0
- package/bin/anza/anza-linux-x64 +0 -0
- package/bin/anza/anza-macos-arm64 +0 -0
- package/bin/anza/anza-macos-x64 +0 -0
- package/bin/anza/anza-windows-x64.exe +0 -0
- package/package.json +1 -1
- package/src/core/api/index.js +2 -2
- package/src/core/offline/sync.js +2 -2
- package/src/core/router/cascade.js +15 -20
- package/src/core/router/container.js +4 -0
- package/src/core/router/graph.js +40 -12
- package/src/core/router/index.js +7 -1
- package/src/core/router/intercept.js +400 -100
- package/src/core/router/match.js +30 -0
- package/src/core/ui/define/element.js +58 -8
- package/src/core/ui/define/orchestrator.js +8 -4
- package/src/core/ui/defs/dock.js +18 -1
- package/src/core/ui/defs/page.js +75 -29
- package/src/core/ui/defs/spec.js +81 -11
- package/src/elements/data/list/style.css +1 -1
- package/src/elements/data/table/index.js +2 -1
- package/src/elements/data/table/style.css +1 -1
- package/src/elements/feedback/alert/style.css +1 -1
- package/src/styles/base.css +22 -21
- package/src/tokens/index.css +1 -4
- package/src/tokens/primitives/colors.css +9 -64
- package/src/tokens/primitives/motion.css +6 -18
- package/src/tokens/primitives/spacing.css +7 -12
- package/src/tokens/primitives/typography.css +12 -42
- package/src/tokens/registered/colors.css +5 -96
- package/src/tokens/registered/dimensions.css +6 -19
- package/src/tokens/semantic/contrast.css +8 -36
- package/src/tokens/semantic/dark.css +13 -48
- package/src/tokens/semantic/light.css +13 -44
- package/src/tokens/semantic/transitions.css +22 -12
- package/CHANGELOG.md +0 -360
- package/src/tokens/primitives/radius.css +0 -16
- package/src/tokens/primitives/shadow.css +0 -34
- package/src/tokens/primitives/zindex.css +0 -18
- package/src/tokens/semantic/components.css +0 -123
|
@@ -8,16 +8,324 @@
|
|
|
8
8
|
* Source: doc 09 — Routing §2, §5, §9, §13
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { match } from './match.js';
|
|
12
|
-
import { transitions } from './transitions.js';
|
|
13
|
-
import { getContainer } from './container.js';
|
|
14
|
-
import { isCallback, runCallback } from './handler.js';
|
|
15
11
|
import { boot, reset as resetBoot } from './boot.js';
|
|
12
|
+
import { isCallback, runCallback } from './handler.js';
|
|
13
|
+
|
|
16
14
|
import { ensure } from './cascade.js';
|
|
15
|
+
import { getContainer } from './container.js';
|
|
16
|
+
import { get as graphGet } from './graph.js';
|
|
17
|
+
import { match } from './match.js';
|
|
18
|
+
import { specRegistry } from '../ui/define/state.js';
|
|
19
|
+
import { transitions } from './transitions.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Built-in minimal 404 HTML rendered when no user notfound is configured.
|
|
23
|
+
* Kept intentionally plain so it inherits the app's base typography.
|
|
24
|
+
*/
|
|
25
|
+
const DEFAULT_NOTFOUND_HTML = `
|
|
26
|
+
<div style="
|
|
27
|
+
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
|
28
|
+
min-height:60vh;gap:1rem;padding:2rem;text-align:center;font-family:inherit;
|
|
29
|
+
">
|
|
30
|
+
<span style="font-size:3rem;font-weight:800;opacity:.15;">404</span>
|
|
31
|
+
<p style="margin:0;font-size:1rem;opacity:.5;">Page not found</p>
|
|
32
|
+
<a href="/" style="font-size:.875rem;opacity:.6;text-decoration:none;">← Go home</a>
|
|
33
|
+
</div>
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Wraps a plain array with non-enumerable `first`, `last`, and named
|
|
38
|
+
* index getters. Mirrors spec.js makeAccessorArray for use in the router.
|
|
39
|
+
*
|
|
40
|
+
* @param {any[]} values
|
|
41
|
+
* @param {string[]} names
|
|
42
|
+
*/
|
|
43
|
+
function makeAccessorArray(values, names) {
|
|
44
|
+
const arr = [...values];
|
|
45
|
+
Object.defineProperties(arr, {
|
|
46
|
+
first: { get() { return arr[0] ?? null; }, enumerable: false },
|
|
47
|
+
last: { get() { return arr[arr.length - 1] ?? null; }, enumerable: false },
|
|
48
|
+
});
|
|
49
|
+
names.forEach((name, i) => {
|
|
50
|
+
if (!(name in arr)) {
|
|
51
|
+
Object.defineProperty(arr, name, {
|
|
52
|
+
get() { return arr[i] ?? null; },
|
|
53
|
+
enumerable: false,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
return arr;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Casts a URL string value to the declared type.
|
|
62
|
+
* @param {string} value
|
|
63
|
+
* @param {'string'|'number'} cast
|
|
64
|
+
*/
|
|
65
|
+
function castValue(value, cast) {
|
|
66
|
+
if (cast === 'number') {
|
|
67
|
+
const n = Number(value);
|
|
68
|
+
return Number.isNaN(n) ? value : n;
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Builds the typed params and query context arrays for a matched route.
|
|
75
|
+
*
|
|
76
|
+
* Reads the component's declared `params`/`query` contract from specRegistry.
|
|
77
|
+
* Falls back to the raw match params/query objects for undeclared segments.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} tag - custom element tag
|
|
80
|
+
* @param {object} rawParams - raw segment map from trie/URLPattern match
|
|
81
|
+
* @param {URL|string} url - the matched URL (or string)
|
|
82
|
+
* @returns {{ params: any[], query: any[], raw: URLSearchParams }}
|
|
83
|
+
*/
|
|
84
|
+
function buildRouteContext(tag, rawParams, url) {
|
|
85
|
+
const spec = specRegistry.get(tag);
|
|
86
|
+
const paramDecls = spec?.params ?? [];
|
|
87
|
+
const queryDecls = spec?.query ?? [];
|
|
88
|
+
|
|
89
|
+
// Build the URLSearchParams object from the URL.
|
|
90
|
+
let searchParams;
|
|
91
|
+
try {
|
|
92
|
+
const u = url instanceof URL ? url : new URL(url, globalThis.location?.href || 'http://localhost');
|
|
93
|
+
searchParams = u.searchParams;
|
|
94
|
+
} catch (_) {
|
|
95
|
+
searchParams = new URLSearchParams();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Build params array from declarations in order, casting as needed.
|
|
99
|
+
// Undeclared segments that appear in rawParams are appended at the end.
|
|
100
|
+
let params;
|
|
101
|
+
if (paramDecls.length > 0) {
|
|
102
|
+
const declared = new Set(paramDecls.map(d => d.name));
|
|
103
|
+
const declaredValues = paramDecls.map(({ name, cast }) => {
|
|
104
|
+
const raw = rawParams[name];
|
|
105
|
+
return raw !== undefined ? castValue(raw, cast) : null;
|
|
106
|
+
});
|
|
107
|
+
// Append undeclared segments as strings so they remain accessible by index.
|
|
108
|
+
const extra = Object.entries(rawParams)
|
|
109
|
+
.filter(([k]) => !declared.has(k))
|
|
110
|
+
.map(([, v]) => v);
|
|
111
|
+
const allValues = [...declaredValues, ...extra];
|
|
112
|
+
const allNames = [...paramDecls.map(d => d.name)];
|
|
113
|
+
params = makeAccessorArray(allValues, allNames);
|
|
114
|
+
} else {
|
|
115
|
+
// No contract declared: expose raw params as an ordered array (string values).
|
|
116
|
+
const entries = Object.entries(rawParams ?? {});
|
|
117
|
+
params = makeAccessorArray(entries.map(([, v]) => v), entries.map(([k]) => k));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Build query array from declarations in order, casting as needed.
|
|
121
|
+
let query;
|
|
122
|
+
if (queryDecls.length > 0) {
|
|
123
|
+
const declared = new Set(queryDecls.map(d => d.name));
|
|
124
|
+
const declaredValues = queryDecls.map(({ name, cast }) => {
|
|
125
|
+
const raw = searchParams.get(name);
|
|
126
|
+
return raw !== null ? castValue(raw, cast) : null;
|
|
127
|
+
});
|
|
128
|
+
// Extra undeclared query keys appended as strings.
|
|
129
|
+
const extra = [];
|
|
130
|
+
for (const [k, v] of searchParams.entries()) {
|
|
131
|
+
if (!declared.has(k)) extra.push(v);
|
|
132
|
+
}
|
|
133
|
+
const allValues = [...declaredValues, ...extra];
|
|
134
|
+
const allNames = [...queryDecls.map(d => d.name)];
|
|
135
|
+
query = makeAccessorArray(allValues, allNames);
|
|
136
|
+
} else {
|
|
137
|
+
// No contract: flat array of all query values in iteration order.
|
|
138
|
+
const entries = [...searchParams.entries()];
|
|
139
|
+
query = makeAccessorArray(entries.map(([, v]) => v), entries.map(([k]) => k));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { params, query, raw: searchParams };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Pushes params and query values reactively onto a live element instance
|
|
147
|
+
* without re-instantiating it. Used for layout-invariant param-only navigations.
|
|
148
|
+
*
|
|
149
|
+
* @param {HTMLElement} el - the live element
|
|
150
|
+
* @param {string} tag - for looking up the contract in specRegistry
|
|
151
|
+
* @param {object} rawParams - raw segment map
|
|
152
|
+
* @param {URL|string} url - the target URL
|
|
153
|
+
*/
|
|
154
|
+
function pushToElement(el, tag, rawParams, url) {
|
|
155
|
+
const spec = specRegistry.get(tag);
|
|
156
|
+
const paramDecls = spec?.params ?? [];
|
|
157
|
+
const queryDecls = spec?.query ?? [];
|
|
158
|
+
|
|
159
|
+
let searchParams;
|
|
160
|
+
try {
|
|
161
|
+
const u = url instanceof URL ? url : new URL(url, globalThis.location?.href || 'http://localhost');
|
|
162
|
+
searchParams = u.searchParams;
|
|
163
|
+
} catch (_) {
|
|
164
|
+
searchParams = new URLSearchParams();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Push path params.
|
|
168
|
+
for (const { name, cast } of paramDecls) {
|
|
169
|
+
const raw = rawParams[name];
|
|
170
|
+
if (raw !== undefined) el[name] = castValue(raw, cast);
|
|
171
|
+
}
|
|
172
|
+
// Push any undeclared params as strings.
|
|
173
|
+
const declaredNames = new Set(paramDecls.map(d => d.name));
|
|
174
|
+
for (const [k, v] of Object.entries(rawParams ?? {})) {
|
|
175
|
+
if (!declaredNames.has(k)) el[k] = v;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Push query params.
|
|
179
|
+
for (const { name, cast } of queryDecls) {
|
|
180
|
+
const raw = searchParams.get(name);
|
|
181
|
+
if (raw !== null) el[name] = castValue(raw, cast);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Async navigation pipeline — extracted from handler to prevent browser spinner.
|
|
187
|
+
* Runs fire-and-forget via queueMicrotask so the Navigation API handler resolves
|
|
188
|
+
* immediately, eliminating the loading indicator during async work.
|
|
189
|
+
*
|
|
190
|
+
* @param {NavigationEvent} event - The navigation event
|
|
191
|
+
* @param {boolean} precommitted - Whether precommitHandler ran (guards already checked)
|
|
192
|
+
*/
|
|
193
|
+
async function pipe(event, precommitted) {
|
|
194
|
+
const destination = event.destination;
|
|
195
|
+
let routeMatch = null;
|
|
196
|
+
try {
|
|
197
|
+
routeMatch = await match(destination.url);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
emit('error', { error: err, url: destination.url, route: null, phase: 'match' });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (routeMatch) {
|
|
204
|
+
for (const fn of transformers) {
|
|
205
|
+
try { fn(routeMatch); } catch (e) { console.error(e); }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let chain = [];
|
|
210
|
+
if (routeMatch) {
|
|
211
|
+
const meta = routeMatch.route?.meta ?? {};
|
|
212
|
+
chain = Array.isArray(meta.via) && meta.via.length
|
|
213
|
+
? meta.via
|
|
214
|
+
: (meta.container ? [meta.container] : []);
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
for (let i = 0; i < chain.length; i++) {
|
|
218
|
+
if (!getContainer(chain[i])) {
|
|
219
|
+
await ensure(chain[i], chain[i - 1] ?? 'main');
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
} catch (err) {
|
|
223
|
+
emit('error', { error: err, url: destination.url, route: routeMatch.route, phase: 'container' });
|
|
224
|
+
throw err;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!precommitted) {
|
|
229
|
+
for (const guardFn of guards) {
|
|
230
|
+
let redirectUrl;
|
|
231
|
+
try {
|
|
232
|
+
redirectUrl = await guardFn(destination, null);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
emit('error', { error: err, url: destination.url, route: routeMatch?.route ?? null, phase: 'guard' });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (redirectUrl) {
|
|
238
|
+
window.navigation.navigate(redirectUrl, { history: 'replace' });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
await transitions.run(async () => {
|
|
245
|
+
if (routeMatch) {
|
|
246
|
+
if (isCallback(routeMatch.route.handler)) {
|
|
247
|
+
try {
|
|
248
|
+
await runCallback(routeMatch.route.handler, routeMatch.params, event);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
emit('error', { error: err, url: destination.url, route: routeMatch.route, phase: 'handler' });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const ctx = buildRouteContext(routeMatch.tag, routeMatch.params, destination.url);
|
|
256
|
+
emit('found', {
|
|
257
|
+
tag: routeMatch.tag,
|
|
258
|
+
params: ctx.params,
|
|
259
|
+
query: ctx.query,
|
|
260
|
+
raw: ctx.raw,
|
|
261
|
+
hash: routeMatch.hash,
|
|
262
|
+
chain: routeMatch.chain,
|
|
263
|
+
via: chain,
|
|
264
|
+
container: chain.at(-1) ?? null,
|
|
265
|
+
url: destination.url,
|
|
266
|
+
direction: event.navigationType
|
|
267
|
+
});
|
|
268
|
+
} else {
|
|
269
|
+
emit('notfound', { url: destination.url });
|
|
270
|
+
if (notFoundHandler) {
|
|
271
|
+
await notFoundHandler(event);
|
|
272
|
+
} else {
|
|
273
|
+
renderNotFound('main');
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Renders the not-found fallback into the deepest currently-live container.
|
|
281
|
+
* Walks the graph from the deepest live node upward until it finds a mounted
|
|
282
|
+
* dock element, then swaps a notfound div into it.
|
|
283
|
+
*
|
|
284
|
+
* Each dock may expose a static `notfound` property (set by dock() when the
|
|
285
|
+
* user supplies a notfound config). The deepest configured one wins.
|
|
286
|
+
*
|
|
287
|
+
* @param {string} [activeContainer='main'] - hint: the last-active container.
|
|
288
|
+
*/
|
|
289
|
+
function renderNotFound(activeContainer = 'main') {
|
|
290
|
+
// Walk from the hinted container upward until we find a live element.
|
|
291
|
+
let name = activeContainer;
|
|
292
|
+
let host = null;
|
|
293
|
+
const visited = new Set();
|
|
294
|
+
while (name && !visited.has(name)) {
|
|
295
|
+
visited.add(name);
|
|
296
|
+
const el = getContainer(name);
|
|
297
|
+
if (el && el.isConnected) { host = el; break; }
|
|
298
|
+
const node = graphGet(name);
|
|
299
|
+
name = node?.parent?.name ?? null;
|
|
300
|
+
}
|
|
301
|
+
if (!host) {
|
|
302
|
+
// Last resort: use the root main element directly.
|
|
303
|
+
host = document.getElementById('main');
|
|
304
|
+
}
|
|
305
|
+
if (!host) return;
|
|
306
|
+
|
|
307
|
+
// Build the notfound element. Check for a user-defined template on the
|
|
308
|
+
// host element's class (set by dock() via the notfound config option).
|
|
309
|
+
const Cls = host.constructor;
|
|
310
|
+
const userHtml = Cls?.notfound ?? null;
|
|
311
|
+
const html = typeof userHtml === 'string' ? userHtml : DEFAULT_NOTFOUND_HTML;
|
|
312
|
+
|
|
313
|
+
const wrapper = document.createElement('div');
|
|
314
|
+
wrapper.setAttribute('role', 'status');
|
|
315
|
+
wrapper.setAttribute('aria-label', 'Page not found');
|
|
316
|
+
wrapper.innerHTML = html;
|
|
317
|
+
|
|
318
|
+
if (typeof host.swap === 'function') {
|
|
319
|
+
host.swap(wrapper);
|
|
320
|
+
} else {
|
|
321
|
+
host.replaceChildren(wrapper);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
17
324
|
|
|
18
325
|
let guards = [];
|
|
19
326
|
let notFoundHandler = null;
|
|
20
327
|
let ready = false;
|
|
328
|
+
let transformers = [];
|
|
21
329
|
|
|
22
330
|
// Module-level root slots — captured once in setup(), cleared in destroy().
|
|
23
331
|
let win = null;
|
|
@@ -89,6 +397,17 @@ export function addGuard(guardFn) {
|
|
|
89
397
|
};
|
|
90
398
|
}
|
|
91
399
|
|
|
400
|
+
/**
|
|
401
|
+
* Registers a global route transformer. Transformers can modify matched route metadata dynamically.
|
|
402
|
+
*/
|
|
403
|
+
export function addTransformer(fn) {
|
|
404
|
+
transformers.push(fn);
|
|
405
|
+
return () => {
|
|
406
|
+
const idx = transformers.indexOf(fn);
|
|
407
|
+
if (idx !== -1) transformers.splice(idx, 1);
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
92
411
|
/** Grouped guard API. */
|
|
93
412
|
export const guardsApi = {
|
|
94
413
|
add: addGuard,
|
|
@@ -128,20 +447,44 @@ export function setup() {
|
|
|
128
447
|
// throws if the element is absent at boot time.
|
|
129
448
|
if (mainEl) shell = new WeakRef(mainEl);
|
|
130
449
|
navListener = (event) => {
|
|
131
|
-
//
|
|
450
|
+
// Only intercept same-origin navigations that the browser would otherwise
|
|
451
|
+
// follow as a full page load. External links, file downloads, and
|
|
452
|
+
// same-document hash scrolls are intentionally left to the browser.
|
|
132
453
|
if (!event.canIntercept || event.hashChange || event.downloadRequest) {
|
|
133
454
|
return;
|
|
134
455
|
}
|
|
456
|
+
// Guard: only intercept same-origin URLs. Cross-origin clicks must
|
|
457
|
+
// navigate normally (open in browser / new tab).
|
|
458
|
+
try {
|
|
459
|
+
const dest = new URL(event.destination.url);
|
|
460
|
+
if (dest.origin !== location.origin) return;
|
|
461
|
+
} catch (_) { return; }
|
|
135
462
|
|
|
136
463
|
const url = event.destination.url;
|
|
137
464
|
let precommitted = false; // Scoped precommitted guard state (RT-02)
|
|
138
465
|
|
|
139
|
-
|
|
466
|
+
// Build intercept options. precommitHandler is only valid on cancelable
|
|
467
|
+
// events (Navigation API spec §4.3). Including it unconditionally throws
|
|
468
|
+
// InvalidStateError on non-cancelable navigations (e.g. back/forward).
|
|
469
|
+
const interceptOpts = {
|
|
470
|
+
focusReset: 'manual', // router owns focus — browser must not reset it
|
|
471
|
+
scroll: 'manual', // router owns scroll — browser must not reset it
|
|
140
472
|
/**
|
|
141
|
-
*
|
|
142
|
-
*
|
|
473
|
+
* Executes DOM mutations, layout changes, and provides fallbacks for Safari.
|
|
474
|
+
* Always provided — this is what prevents the browser from reloading.
|
|
143
475
|
*/
|
|
144
|
-
|
|
476
|
+
handler() {
|
|
477
|
+
// Resolve immediately — no browser spinner, no focus/scroll side effects.
|
|
478
|
+
// pipe() runs detached; the router's own emit() system handles success/error.
|
|
479
|
+
queueMicrotask(() => pipe(event, precommitted));
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
// Only attach precommitHandler on cancelable events (Navigation API §4.3).
|
|
484
|
+
// Non-cancelable navigations (e.g. browser back/forward in some browsers)
|
|
485
|
+
// throw InvalidStateError if precommitHandler is present.
|
|
486
|
+
if (event.cancelable) {
|
|
487
|
+
interceptOpts.precommitHandler = async function precommitHandler(controller) {
|
|
145
488
|
precommitted = true;
|
|
146
489
|
const destination = event.destination;
|
|
147
490
|
for (const guardFn of guards) {
|
|
@@ -151,96 +494,10 @@ export function setup() {
|
|
|
151
494
|
return;
|
|
152
495
|
}
|
|
153
496
|
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Executes DOM mutations, layout changes, and provides fallbacks for Safari.
|
|
158
|
-
*/
|
|
159
|
-
handler() {
|
|
160
|
-
(async () => {
|
|
161
|
-
const destination = event.destination;
|
|
162
|
-
let routeMatch = null;
|
|
163
|
-
try {
|
|
164
|
-
routeMatch = await match(destination.url);
|
|
165
|
-
} catch (err) {
|
|
166
|
-
emit('error', { error: err, url: destination.url, route: null, phase: 'match' });
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Layout resolution: ensure the route's container chain is mounted.
|
|
171
|
-
// Routes declare an ordered `via` chain (root-to-leaf) or a single
|
|
172
|
-
// `container`. Missing containers are mounted via cascade rather than
|
|
173
|
-
// throwing — a hard refresh on a deep route can now build its own
|
|
174
|
-
// layout instead of erroring out.
|
|
175
|
-
if (routeMatch) {
|
|
176
|
-
const meta = routeMatch.route?.meta ?? {};
|
|
177
|
-
const chain = Array.isArray(meta.via) && meta.via.length
|
|
178
|
-
? meta.via
|
|
179
|
-
: (meta.container ? [meta.container] : []);
|
|
180
|
-
|
|
181
|
-
try {
|
|
182
|
-
for (let i = 0; i < chain.length; i++) {
|
|
183
|
-
if (!getContainer(chain[i])) {
|
|
184
|
-
await ensure(chain[i], chain[i - 1] ?? 'main'); // was 'body'
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
} catch (err) {
|
|
188
|
-
emit('error', { error: err, url: destination.url, route: routeMatch.route, phase: 'container' });
|
|
189
|
-
throw err;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// Graceful Safari Fallback: Evaluate guards inside post-commit handler if precommit is ignored.
|
|
194
|
-
if (!precommitted) {
|
|
195
|
-
for (const guardFn of guards) {
|
|
196
|
-
let redirectUrl;
|
|
197
|
-
try {
|
|
198
|
-
redirectUrl = await guardFn(destination, null);
|
|
199
|
-
} catch (err) {
|
|
200
|
-
emit('error', { error: err, url: destination.url, route: routeMatch?.route ?? null, phase: 'guard' });
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
if (redirectUrl) {
|
|
204
|
-
window.navigation.navigate(redirectUrl, { history: 'replace' });
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
}
|
|
497
|
+
};
|
|
498
|
+
}
|
|
209
499
|
|
|
210
|
-
|
|
211
|
-
if (routeMatch) {
|
|
212
|
-
// Run callback handlers exactly once, here (never during match()).
|
|
213
|
-
if (isCallback(routeMatch.route.handler)) {
|
|
214
|
-
try {
|
|
215
|
-
await runCallback(routeMatch.route.handler, routeMatch.params, event);
|
|
216
|
-
} catch (err) {
|
|
217
|
-
emit('error', { error: err, url: destination.url, route: routeMatch.route, phase: 'handler' });
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
emit('found', {
|
|
223
|
-
tag: routeMatch.tag,
|
|
224
|
-
params: routeMatch.params,
|
|
225
|
-
query: routeMatch.query,
|
|
226
|
-
hash: routeMatch.hash,
|
|
227
|
-
chain: routeMatch.chain,
|
|
228
|
-
url: destination.url,
|
|
229
|
-
direction: event.navigationType
|
|
230
|
-
});
|
|
231
|
-
} else {
|
|
232
|
-
emit('notfound', { url: destination.url });
|
|
233
|
-
|
|
234
|
-
if (notFoundHandler) {
|
|
235
|
-
await notFoundHandler(event);
|
|
236
|
-
} else {
|
|
237
|
-
console.error(`Route matching failed and no not-found boundary handler was configured: ${destination.url}`);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
});
|
|
241
|
-
})();
|
|
242
|
-
}
|
|
243
|
-
});
|
|
500
|
+
event.intercept(interceptOpts);
|
|
244
501
|
};
|
|
245
502
|
|
|
246
503
|
successListener = () => {
|
|
@@ -286,17 +543,59 @@ export function setup() {
|
|
|
286
543
|
const url = window.navigation.currentEntry?.url || window.location.href;
|
|
287
544
|
const routeMatch = await match(url);
|
|
288
545
|
if (routeMatch) {
|
|
546
|
+
for (const fn of transformers) {
|
|
547
|
+
try { fn(routeMatch); } catch (e) { console.error(e); }
|
|
548
|
+
}
|
|
549
|
+
const meta = routeMatch.route?.meta ?? {};
|
|
550
|
+
const chain = Array.isArray(meta.via) && meta.via.length
|
|
551
|
+
? meta.via
|
|
552
|
+
: (meta.container ? [meta.container] : []);
|
|
553
|
+
|
|
554
|
+
// Layout resolution: ensure the route's container chain is mounted on boot.
|
|
555
|
+
// Retry once after a frame — a dock element may still be connecting on
|
|
556
|
+
// the first attempt (especially on hard refresh when layout docks are
|
|
557
|
+
// mounting concurrently with the boot gate settling).
|
|
558
|
+
const mountChain = async () => {
|
|
559
|
+
for (let i = 0; i < chain.length; i++) {
|
|
560
|
+
if (!getContainer(chain[i])) {
|
|
561
|
+
await ensure(chain[i], chain[i - 1] ?? 'main');
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
try {
|
|
566
|
+
await mountChain();
|
|
567
|
+
} catch (err) {
|
|
568
|
+
// Yield one animation frame and retry before giving up.
|
|
569
|
+
await new Promise(r => {
|
|
570
|
+
if (typeof requestAnimationFrame !== 'undefined') requestAnimationFrame(r);
|
|
571
|
+
else setTimeout(r, 16);
|
|
572
|
+
});
|
|
573
|
+
try {
|
|
574
|
+
await mountChain();
|
|
575
|
+
} catch (retryErr) {
|
|
576
|
+
// Both attempts failed — emit error but do NOT throw so the router
|
|
577
|
+
// can still attempt a notfound render rather than leaving a blank page.
|
|
578
|
+
emit('error', { error: retryErr, url, route: routeMatch.route, phase: 'container' });
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const ctx = buildRouteContext(routeMatch.tag, routeMatch.params, url);
|
|
289
584
|
emit('found', {
|
|
290
585
|
tag: routeMatch.tag,
|
|
291
|
-
params:
|
|
292
|
-
query:
|
|
586
|
+
params: ctx.params,
|
|
587
|
+
query: ctx.query,
|
|
588
|
+
raw: ctx.raw,
|
|
293
589
|
hash: routeMatch.hash,
|
|
294
590
|
chain: routeMatch.chain,
|
|
591
|
+
via: chain,
|
|
592
|
+
container: chain.at(-1) ?? null,
|
|
295
593
|
url,
|
|
296
594
|
direction: 'load'
|
|
297
595
|
});
|
|
298
596
|
} else {
|
|
299
597
|
emit('notfound', { url });
|
|
598
|
+
renderNotFound('main');
|
|
300
599
|
}
|
|
301
600
|
});
|
|
302
601
|
}
|
|
@@ -321,6 +620,7 @@ export function destroy() {
|
|
|
321
620
|
errorListener = null;
|
|
322
621
|
|
|
323
622
|
guards = [];
|
|
623
|
+
transformers = [];
|
|
324
624
|
notFoundHandler = null;
|
|
325
625
|
resetBoot();
|
|
326
626
|
|
package/src/core/router/match.js
CHANGED
|
@@ -39,10 +39,40 @@ function getSpecificity(patternStr) {
|
|
|
39
39
|
return 1; // Wildcard: low
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
function conflict(pathA, pathB) {
|
|
43
|
+
if (pathA === pathB) return false;
|
|
44
|
+
const segsA = pathA.split('/').filter(s => s.length > 0);
|
|
45
|
+
const segsB = pathB.split('/').filter(s => s.length > 0);
|
|
46
|
+
|
|
47
|
+
if (segsA.length !== segsB.length) return false;
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < segsA.length; i++) {
|
|
50
|
+
const sa = segsA[i];
|
|
51
|
+
const sb = segsB[i];
|
|
52
|
+
const isDynA = sa.startsWith(':') || sa === '*' || sa.includes('*');
|
|
53
|
+
const isDynB = sb.startsWith(':') || sb === '*' || sb.includes('*');
|
|
54
|
+
|
|
55
|
+
if (!isDynA && !isDynB && sa !== sb) {
|
|
56
|
+
return false; // static mismatch
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
42
63
|
/**
|
|
43
64
|
* Registers a route mapping.
|
|
44
65
|
*/
|
|
45
66
|
export function register(patternStr, handler, meta = {}) {
|
|
67
|
+
// Check conflicts with existing routes before registering
|
|
68
|
+
for (const r of routes) {
|
|
69
|
+
if (conflict(patternStr, r.patternStr)) {
|
|
70
|
+
console.warn(
|
|
71
|
+
`[anza] WARNING: Route pattern conflict: "${patternStr}" overlaps with "${r.patternStr}"`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
46
76
|
const route = {
|
|
47
77
|
patternStr,
|
|
48
78
|
handler,
|
|
@@ -2,7 +2,7 @@ import { BaseElement } from '../base.js';
|
|
|
2
2
|
import { scheduleFrame, yieldTask } from '../schedule.js';
|
|
3
3
|
import { router } from '../../router/index.js';
|
|
4
4
|
import { specRegistry, internalsMap, initializedMap, pendingUpdatesMap, updateScheduledMap, assetCache } from './state.js';
|
|
5
|
-
import { preloadResources } from './utils.js';
|
|
5
|
+
import { preloadResources, createTemplateFragment } from './utils.js';
|
|
6
6
|
import { createComponentContext } from './proxy.js';
|
|
7
7
|
|
|
8
8
|
// Platform lifecycle method names that must never be overridden by spec.methods.
|
|
@@ -63,12 +63,62 @@ export function element(tag, spec, base) {
|
|
|
63
63
|
? new URL(spec.template, base).href
|
|
64
64
|
: null;
|
|
65
65
|
|
|
66
|
-
//
|
|
66
|
+
// Defer resource preloading until first mount or HMR rebind for cold-start performance
|
|
67
|
+
const hasUrls = (styleUrls.length > 0) || (templateUrl !== null);
|
|
67
68
|
let resolved = null;
|
|
68
|
-
let resourcesPromise =
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
let resourcesPromise = null;
|
|
70
|
+
|
|
71
|
+
const getResources = () => {
|
|
72
|
+
if (!resourcesPromise) {
|
|
73
|
+
resourcesPromise = preloadResources(tag, styleUrls, templateUrl, spec.template, spec.style).then(res => {
|
|
74
|
+
resolved = res;
|
|
75
|
+
return res;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return resourcesPromise;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const isLazy = spec.lazy === true && hasUrls;
|
|
82
|
+
|
|
83
|
+
if (!isLazy) {
|
|
84
|
+
if (!hasUrls) {
|
|
85
|
+
// Compile synchronously immediately to support synchronous connectedCallback (unit tests and static components)
|
|
86
|
+
const supportsSheets =
|
|
87
|
+
typeof CSSStyleSheet !== 'undefined' &&
|
|
88
|
+
'adoptedStyleSheets' in Document.prototype &&
|
|
89
|
+
'adoptedStyleSheets' in ShadowRoot.prototype;
|
|
90
|
+
|
|
91
|
+
let templateNode = null;
|
|
92
|
+
let stylesheets = [];
|
|
93
|
+
let cssTextAcc = '';
|
|
94
|
+
|
|
95
|
+
if (spec.template) {
|
|
96
|
+
templateNode = createTemplateFragment(spec.template);
|
|
97
|
+
}
|
|
98
|
+
if (spec.style) {
|
|
99
|
+
const inlineStyles = Array.isArray(spec.style) ? spec.style : [spec.style];
|
|
100
|
+
for (const style of inlineStyles) {
|
|
101
|
+
if (supportsSheets) {
|
|
102
|
+
const sheet = new CSSStyleSheet();
|
|
103
|
+
sheet.replaceSync(style);
|
|
104
|
+
stylesheets.push(sheet);
|
|
105
|
+
} else {
|
|
106
|
+
cssTextAcc += style + '\n';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
resolved = {
|
|
111
|
+
templateNode,
|
|
112
|
+
stylesheets,
|
|
113
|
+
cssText: cssTextAcc.trim() ? cssTextAcc : null,
|
|
114
|
+
tagsDescriptor: null
|
|
115
|
+
};
|
|
116
|
+
resourcesPromise = Promise.resolve(resolved);
|
|
117
|
+
} else {
|
|
118
|
+
// Start eager preloading immediately
|
|
119
|
+
getResources();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
72
122
|
|
|
73
123
|
// Handle hot reloading of constructable stylesheets (one global listener per unique styleUrl - R-05)
|
|
74
124
|
if (styleUrls.length > 0 && typeof window !== 'undefined') {
|
|
@@ -175,7 +225,7 @@ export function element(tag, spec, base) {
|
|
|
175
225
|
// Wait for resolved resources to compile (synchronously if already cached)
|
|
176
226
|
let res = resolved;
|
|
177
227
|
if (!res) {
|
|
178
|
-
res = await
|
|
228
|
+
res = await getResources();
|
|
179
229
|
}
|
|
180
230
|
const { templateNode, stylesheets, cssText, tagsDescriptor } = res;
|
|
181
231
|
|
|
@@ -269,7 +319,7 @@ export function element(tag, spec, base) {
|
|
|
269
319
|
// 3. Clone new template
|
|
270
320
|
let res = resolved;
|
|
271
321
|
if (!res) {
|
|
272
|
-
res = await
|
|
322
|
+
res = await getResources();
|
|
273
323
|
}
|
|
274
324
|
const { templateNode, stylesheets, cssText, tagsDescriptor } = res;
|
|
275
325
|
|