@ubean/vue 0.2.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/index.js ADDED
@@ -0,0 +1,1073 @@
1
+ import { KeepAlive, Suspense, Transition, computed, createCommentVNode, defineAsyncComponent, defineComponent, h, inject, nextTick, onErrorCaptured, provide, reactive, ref, shallowRef, watch } from "vue";
2
+ import { RouterLink, RouterView, useRoute } from "vue-router";
3
+ //#region src/cache-views.ts
4
+ /**
5
+ * Page cache (keep-alive) store — a module-level singleton.
6
+ *
7
+ * ubean uses flat single-layer layout, so all page components are direct
8
+ * children of a single layout's `<RouterView>`. A `<keep-alive :include="...">`
9
+ * wraps that `<RouterView>`; the `include` list is the reactive array exposed
10
+ * here. Caching is keyed by the page's **route name**, which is injected as the
11
+ * component `name` at render time (see `createLayoutWrapper` in `app.ts`).
12
+ *
13
+ * In addition to the `include` list, an `exclude` list is also maintained.
14
+ * `<keep-alive :exclude="...">` skips caching for matched names even if they
15
+ * appear in `include`. This is useful for the "reload current page" pattern:
16
+ * temporarily push a route name into `exclude`, wait a tick, then remove it —
17
+ * the cached instance is pruned and the page remounts fresh on next visit.
18
+ *
19
+ * Usage:
20
+ * // declarative (compile-time): enable caching for a page
21
+ * definePage({ cache: true })
22
+ *
23
+ * // runtime: toggle caching imperatively
24
+ * import { enablePageCache, disablePageCache } from '@ubean/client';
25
+ * enablePageCache('DashboardIndex');
26
+ * disablePageCache('DashboardIndex');
27
+ *
28
+ * // reload a cached page (clears its instance, forces remount)
29
+ * import { resetRouteCache } from '@ubean/client';
30
+ * await resetRouteCache('DashboardIndex');
31
+ */
32
+ const _g$1 = globalThis;
33
+ const _cachedViewNames = _g$1.__ubeanCachedViewNames ??= ref([]);
34
+ const _excludedViewNames = _g$1.__ubeanExcludedViewNames ??= ref([]);
35
+ const _cacheEnabled = _g$1.__ubeanCacheEnabled ??= ref(true);
36
+ const _wrapperRegistry = _g$1.__ubeanWrapperRegistry ??= /* @__PURE__ */ new Map();
37
+ /**
38
+ * Composable for reactive access to the page cache store.
39
+ *
40
+ * Safe to call inside any component `setup()`; the returned computeds are
41
+ * tracked by the rendering effect so `<keep-alive :include>` stays in sync.
42
+ */
43
+ function useCacheViews() {
44
+ return {
45
+ cachedViews: computed(() => _cacheEnabled.value ? _cachedViewNames.value : []),
46
+ cachedViewNames: computed(() => _cachedViewNames.value),
47
+ excludedViews: computed(() => _excludedViewNames.value),
48
+ enabled: computed(() => _cacheEnabled.value),
49
+ enable: () => {
50
+ _cacheEnabled.value = true;
51
+ },
52
+ disable: () => {
53
+ _cacheEnabled.value = false;
54
+ },
55
+ add: (name) => {
56
+ if (name && !_cachedViewNames.value.includes(name)) _cachedViewNames.value = [..._cachedViewNames.value, name];
57
+ },
58
+ remove: (name) => {
59
+ _cachedViewNames.value = _cachedViewNames.value.filter((n) => n !== name);
60
+ },
61
+ has: (name) => _cachedViewNames.value.includes(name),
62
+ clear: () => {
63
+ _cachedViewNames.value = [];
64
+ },
65
+ addExclude: (name) => {
66
+ if (name && !_excludedViewNames.value.includes(name)) _excludedViewNames.value = [..._excludedViewNames.value, name];
67
+ },
68
+ removeExclude: (name) => {
69
+ _excludedViewNames.value = _excludedViewNames.value.filter((n) => n !== name);
70
+ },
71
+ hasExclude: (name) => _excludedViewNames.value.includes(name),
72
+ clearExclude: () => {
73
+ _excludedViewNames.value = [];
74
+ }
75
+ };
76
+ }
77
+ /** Enable keep-alive caching for a specific page (by route name). */
78
+ function enablePageCache(name) {
79
+ if (name && !_cachedViewNames.value.includes(name)) _cachedViewNames.value = [..._cachedViewNames.value, name];
80
+ }
81
+ /** Disable keep-alive caching for a specific page (by route name). */
82
+ function disablePageCache(name) {
83
+ _cachedViewNames.value = _cachedViewNames.value.filter((n) => n !== name);
84
+ }
85
+ /**
86
+ * Temporarily exclude a page from keep-alive (forces its cached instance
87
+ * to be pruned on next render). Pair with `includePageCache()` to restore.
88
+ *
89
+ * Useful for the "reload current page" pattern: push the route name into
90
+ * the exclude list, wait a tick, then remove it — the cached instance is
91
+ * pruned and the page remounts fresh on next render.
92
+ */
93
+ function excludePageCache(name) {
94
+ if (name && !_excludedViewNames.value.includes(name)) _excludedViewNames.value = [..._excludedViewNames.value, name];
95
+ }
96
+ /** Remove a route name from the cache exclude list (restores caching). */
97
+ function includePageCache(name) {
98
+ _excludedViewNames.value = _excludedViewNames.value.filter((n) => n !== name);
99
+ }
100
+ /** Whether a specific page is currently excluded from cache. */
101
+ function isPageExcluded(name) {
102
+ return _excludedViewNames.value.includes(name);
103
+ }
104
+ /**
105
+ * Reload a cached page by pruning its keep-alive instance.
106
+ *
107
+ * Semantics (mirrors `routeStore.resetRouteCache` in soybean-unify):
108
+ * 1. Remove the route name from the include list — the cached instance is
109
+ * pruned immediately, and when the user navigates away the instance is
110
+ * destroyed instead of being re-cached.
111
+ * 2. The original `meta.cache` declaration is restored automatically on the
112
+ * next navigation away from the page (hooked by `createUbeanRouter`'s
113
+ * `afterEach`), so the NEXT visit mounts a fresh instance which is
114
+ * cached again as declared.
115
+ *
116
+ * Note: pruning cannot take effect while the user stays on the page —
117
+ * `<KeepAlive>` re-caches the active instance on every render while its name
118
+ * is in the include list. To force an in-place remount of the current page,
119
+ * use `reloadPage(name)` instead (bumps the render key).
120
+ *
121
+ * @param name Route name. If omitted, this is a no-op.
122
+ * @param delay Ignored — kept for signature compatibility with the previous
123
+ * exclude→include implementation.
124
+ */
125
+ async function resetRouteCache(name, _delay) {
126
+ const target = name;
127
+ if (!target) return;
128
+ if (!_cachedViewNames.value.includes(target)) return;
129
+ disablePageCache(target);
130
+ _pendingReincludes.add(target);
131
+ }
132
+ /**
133
+ * Route names whose cache declaration should be restored once the user has
134
+ * navigated away from the page (populated by `resetRouteCache`).
135
+ */
136
+ const _pendingReincludes = /* @__PURE__ */ new Set();
137
+ /**
138
+ * Restore cache declarations for pages whose cache was reset via
139
+ * `resetRouteCache` once they are no longer the active page.
140
+ *
141
+ * @internal — called from `createUbeanRouter`'s `afterEach` hook.
142
+ */
143
+ function _flushPendingReincludes(currentPageName) {
144
+ if (_pendingReincludes.size === 0) return;
145
+ for (const name of _pendingReincludes) if (name !== currentPageName) {
146
+ enablePageCache(name);
147
+ _pendingReincludes.delete(name);
148
+ }
149
+ }
150
+ /**
151
+ * Invalidate cached page instance(s).
152
+ *
153
+ * - With a `name`: removes that page from the cache (its instance is pruned
154
+ * on next navigation).
155
+ * - Without a `name`: clears all cached pages.
156
+ *
157
+ * Note: the page will be re-cached on next visit if its `definePage({ cache: true })`
158
+ * is still in effect or `enablePageCache(name)` is called again.
159
+ */
160
+ function invalidatePageCache(name) {
161
+ if (name) disablePageCache(name);
162
+ else _cachedViewNames.value = [];
163
+ }
164
+ /** Whether a specific page is currently cached. */
165
+ function isPageCached(name) {
166
+ return _cachedViewNames.value.includes(name);
167
+ }
168
+ /** Whether page caching is globally enabled. */
169
+ function isCacheEnabled() {
170
+ return _cacheEnabled.value;
171
+ }
172
+ /** Reactive ref of cached route names — for advanced use cases. */
173
+ function getCachedViewNames() {
174
+ return _cachedViewNames;
175
+ }
176
+ /** Reactive ref of excluded route names — for advanced use cases. */
177
+ function getExcludedViewNames() {
178
+ return _excludedViewNames;
179
+ }
180
+ /** Reactive ref of the global cache-enabled flag. */
181
+ function getCacheEnabled() {
182
+ return _cacheEnabled;
183
+ }
184
+ /**
185
+ * Seed the cache include list from route metadata.
186
+ *
187
+ * Called once during app bootstrap (`createUbeanClientApp`) to honor pages that
188
+ * declared `definePage({ cache: true })`. Routes without `meta.cache` are
189
+ * left untouched so runtime toggling remains possible.
190
+ */
191
+ function initCachedViewsFromRoutes(routes) {
192
+ const initial = [];
193
+ for (const route of routes) if (route.meta?.cache === true && typeof route.name === "string" && route.name) {
194
+ if (!initial.includes(route.name)) initial.push(route.name);
195
+ }
196
+ if (initial.length > 0) {
197
+ const existing = new Set(_cachedViewNames.value);
198
+ for (const name of initial) if (!existing.has(name)) existing.add(name);
199
+ _cachedViewNames.value = [...existing];
200
+ }
201
+ }
202
+ /**
203
+ * Get or create a named wrapper component for a page.
204
+ *
205
+ * The wrapper carries the route name as its component `name`, which is what
206
+ * `<keep-alive :include>` matches against. The original page component is
207
+ * rendered inside the wrapper. When the original changes (HMR in dev), the
208
+ * wrapper is rebuilt so the new component takes effect.
209
+ *
210
+ * @internal — used by `createLayoutWrapper` in `app.ts`.
211
+ */
212
+ function getNamedPageWrapper(routeName, original) {
213
+ const cached = _wrapperRegistry.get(routeName);
214
+ if (cached && cached.original === original) return cached.wrapper;
215
+ const wrapper = defineComponent({
216
+ name: routeName,
217
+ setup() {
218
+ return () => h(original);
219
+ }
220
+ });
221
+ _wrapperRegistry.set(routeName, {
222
+ wrapper,
223
+ original
224
+ });
225
+ return wrapper;
226
+ }
227
+ /**
228
+ * Clear the wrapper registry. Mainly useful in tests or when the entire
229
+ * route table is rebuilt.
230
+ *
231
+ * @internal
232
+ */
233
+ function resetNamedPageWrappers() {
234
+ _wrapperRegistry.clear();
235
+ }
236
+ //#endregion
237
+ //#region src/page-runtime.ts
238
+ /**
239
+ * Global page runtime config — animation mode + reload signal.
240
+ *
241
+ * These module-level singletons mirror the patterns found in soybean-unify's
242
+ * `themeStore.pageAnimateMode` and `appStore.reloadSignal`:
243
+ *
244
+ * - `pageAnimateMode` is a global transition name applied to every
245
+ * `<PageView />` unless overridden by the `transition` prop or
246
+ * `route.meta.transition`. Set to `'none'` (or empty string) to disable
247
+ * transitions globally.
248
+ *
249
+ * - `reloadSignal` is a counter that increments each time `reloadPage()` is
250
+ * called. `<PageView />` watches this counter (via `:key`) and remounts
251
+ * the current page when it changes — for cached pages the keep-alive
252
+ * instance is pruned and rebuilt around the remount so it is fresh.
253
+ *
254
+ * These are intentionally framework-provided singletons (not pinia stores)
255
+ * because ubean ships without a state management library and the values are
256
+ * globally unique by nature.
257
+ */
258
+ const _g = globalThis;
259
+ const _pageTransitionName = _g.__ubeanPageTransitionName ??= ref("");
260
+ const _reloadCounter = _g.__ubeanReloadCounter ??= ref(0);
261
+ /**
262
+ * Composable for reactive access to the global page transition name.
263
+ *
264
+ * Used by `<PageView />` as the fallback transition name when neither the
265
+ * `transition` prop nor `route.meta.transition` is set.
266
+ *
267
+ * Layout components can also read this to display a settings UI:
268
+ *
269
+ * ```ts
270
+ * const transition = usePageTransition();
271
+ * // transition.name.value === 'fade-slide'
272
+ * transition.set('zoom-fadein');
273
+ * transition.clear();
274
+ * ```
275
+ */
276
+ function usePageTransition() {
277
+ return {
278
+ name: computed(() => _pageTransitionName.value),
279
+ set: (name) => {
280
+ _pageTransitionName.value = name === "none" ? "" : name;
281
+ },
282
+ clear: () => {
283
+ _pageTransitionName.value = "";
284
+ },
285
+ enabled: computed(() => _pageTransitionName.value !== "")
286
+ };
287
+ }
288
+ /** Imperatively set the global page transition name. */
289
+ function setPageTransition(name) {
290
+ _pageTransitionName.value = name === "none" ? "" : name;
291
+ }
292
+ /** Imperatively clear the global page transition name (disables transitions). */
293
+ function clearPageTransition() {
294
+ _pageTransitionName.value = "";
295
+ }
296
+ /** Reactive ref of the global transition name — for advanced use cases. */
297
+ function getPageTransitionName() {
298
+ return _pageTransitionName;
299
+ }
300
+ /**
301
+ * Composable for reactive access to the page reload signal.
302
+ *
303
+ * `<PageView />` uses the returned `counter` as part of its render key, so
304
+ * each `reload()` call forces the current page to remount. For cached pages
305
+ * the stale keep-alive instance is pruned around the remount (see
306
+ * `_remountWithFreshCache`), so the remount is fresh while the cache
307
+ * declaration stays in effect — equivalent to the
308
+ * `v-if="appStore.reloadSignal"` pattern in soybean-unify's layout-content.
309
+ *
310
+ * Usage from any component:
311
+ *
312
+ * ```ts
313
+ * const { reload } = useReloadSignal();
314
+ * async function onReloadClick() {
315
+ * await reload(); // reloads the current page
316
+ * }
317
+ * ```
318
+ *
319
+ * @param routeNameGetter Optional function returning the current route name.
320
+ * If provided (and `reload()` is called without an
321
+ * explicit name), used to prune/rebuild that page's
322
+ * cache around the remount. If omitted, `reload()`
323
+ * only bumps the counter.
324
+ */
325
+ /**
326
+ * Internal flag — while `true`, `<PageView>` forces its `<Transition>` mode
327
+ * to `'default'` (defensive: the reload sequence below never enters the
328
+ * leave path, but if a stray re-render does, 'default' mode produces no
329
+ * hollow placeholders).
330
+ *
331
+ * @internal — consumed by `components.ts` (same package).
332
+ */
333
+ const _reloading = _g.__ubeanReloading ??= ref(false);
334
+ /**
335
+ * Internal blank flag — while `true`, `<PageView>`'s `<KeepAlive>` slot
336
+ * renders a comment vnode instead of the page. Drives the reload sequence
337
+ * in `_remountWithFreshCache`.
338
+ *
339
+ * @internal — consumed by `components.ts` (same package).
340
+ */
341
+ const _reloadBlank = _g.__ubeanReloadBlank ??= ref(false);
342
+ /**
343
+ * Per-page reload counts — ONLY the reloaded page's key changes, so other
344
+ * pages' KeepAlive cache entries (keyed by vnode key) stay valid across an
345
+ * unrelated page's reload. (A global counter in the key would invalidate
346
+ * every page's cache on any reload.)
347
+ *
348
+ * @internal — consumed by `components.ts` (same package).
349
+ */
350
+ const _pageReloadCounts = _g.__ubeanPageReloadCounts ??= reactive(/* @__PURE__ */ new Map());
351
+ /**
352
+ * Internal reload driver — remounts the current page fresh via a brief
353
+ * blank window (the `v-if="reloadSignal"` pattern from soybean-unify):
354
+ *
355
+ * 1. Enter reload mode (Transition falls back to 'default' mode).
356
+ * 2. Blank the KeepAlive slot — the old page instance deactivates
357
+ * (cached) or unmounts; KeepAlive renders a comment. A comment inner
358
+ * child makes `<Transition>` skip its leave branch entirely, so Vue
359
+ * never produces an `emptyPlaceholder` hollow KeepAlive (children=null)
360
+ * whose patch crashes `updateSlots` (`Cannot read properties of null
361
+ * (reading '_')` — vue@3.5.41, same family as vuejs/core#10771).
362
+ * 3. Prune the stale old-key cache entry via an include toggle
363
+ * (disable→enable) — safe while the KeepAlive child is blank: include
364
+ * changes only affect the cache set, never a hollow-vnode patch.
365
+ * 4. Bump the page's reload count (new key) and un-blank in the same
366
+ * flush — the fresh page mounts; the enter transition plays. The
367
+ * global counter also bumps so `useReloadSignal().counter` consumers
368
+ * stay in sync.
369
+ * 5. Leave reload mode.
370
+ *
371
+ * Without a `name` there is no way to target a page's key, so the call is
372
+ * signal-only (global counter bump, no remount).
373
+ */
374
+ async function _remountWithFreshCache(name) {
375
+ if (!name) {
376
+ _reloadCounter.value++;
377
+ return;
378
+ }
379
+ _reloading.value = true;
380
+ _reloadBlank.value = true;
381
+ await nextTick();
382
+ if (isPageCached(name)) {
383
+ disablePageCache(name);
384
+ await nextTick();
385
+ enablePageCache(name);
386
+ await nextTick();
387
+ }
388
+ _pageReloadCounts.set(name, (_pageReloadCounts.get(name) ?? 0) + 1);
389
+ _reloadCounter.value++;
390
+ _reloadBlank.value = false;
391
+ await nextTick();
392
+ _reloading.value = false;
393
+ }
394
+ function useReloadSignal(routeNameGetter) {
395
+ return {
396
+ counter: computed(() => _reloadCounter.value),
397
+ reloading: computed(() => _reloading.value),
398
+ reload: async (routeName, duration = 300) => {
399
+ _reloading.value = true;
400
+ try {
401
+ await _remountWithFreshCache(routeName ?? routeNameGetter?.());
402
+ await new Promise((resolve) => setTimeout(resolve, duration));
403
+ } finally {
404
+ _reloading.value = false;
405
+ }
406
+ }
407
+ };
408
+ }
409
+ /**
410
+ * Imperatively trigger a page reload.
411
+ *
412
+ * - `name`: route name to reload (REQUIRED for an actual remount). The page
413
+ * is blanked, its stale keep-alive entry pruned, and a fresh instance
414
+ * mounts under a new key — while its cache declaration stays in effect.
415
+ * Only THIS page's key changes: other pages' keep-alive entries survive.
416
+ * If omitted, the call is signal-only (the global counter bumps for
417
+ * `useReloadSignal().counter` consumers; nothing remounts).
418
+ * - `duration`: milliseconds to wait for transition/cleanup. Defaults to 300.
419
+ */
420
+ async function reloadPage(name, duration = 300) {
421
+ _reloading.value = true;
422
+ try {
423
+ await _remountWithFreshCache(name);
424
+ await new Promise((resolve) => setTimeout(resolve, duration));
425
+ } finally {
426
+ _reloading.value = false;
427
+ }
428
+ }
429
+ /** Reactive ref of the reload counter — for advanced use cases. */
430
+ function getReloadCounter() {
431
+ return _reloadCounter;
432
+ }
433
+ /** Whether a reload is currently in progress. */
434
+ function isReloading() {
435
+ return _reloading.value;
436
+ }
437
+ //#endregion
438
+ //#region src/view-transitions.ts
439
+ const _global = globalThis;
440
+ let _typesSupported = null;
441
+ function supportsTransitionTypes() {
442
+ if (_typesSupported !== null) return _typesSupported;
443
+ if (!supportsViewTransitions()) {
444
+ _typesSupported = false;
445
+ return false;
446
+ }
447
+ try {
448
+ let calledWithObject = false;
449
+ const doc = _global.document;
450
+ const orig = doc.startViewTransition;
451
+ doc.startViewTransition = function(opts) {
452
+ if (opts && typeof opts === "object" && "update" in opts) calledWithObject = true;
453
+ return {
454
+ finished: Promise.resolve(),
455
+ ready: Promise.resolve(),
456
+ updateCallbackDone: Promise.resolve(),
457
+ skipTransition() {}
458
+ };
459
+ };
460
+ try {
461
+ doc.startViewTransition({
462
+ update: () => {},
463
+ types: ["test"]
464
+ });
465
+ } catch {}
466
+ doc.startViewTransition = orig;
467
+ _typesSupported = calledWithObject;
468
+ } catch {
469
+ _typesSupported = false;
470
+ }
471
+ return _typesSupported;
472
+ }
473
+ function supportsViewTransitions() {
474
+ if (typeof _global.document === "undefined") return false;
475
+ return typeof _global.document.startViewTransition === "function";
476
+ }
477
+ async function withViewTransition(callback, options = {}) {
478
+ const { enabled = true, types } = options;
479
+ if (!enabled || !supportsViewTransitions()) return callback();
480
+ const doc = _global.document;
481
+ const useTypesApi = types && types.length > 0 && supportsTransitionTypes();
482
+ let result;
483
+ let callbackError;
484
+ let callbackFailed = false;
485
+ const updateCallback = async () => {
486
+ try {
487
+ result = await callback();
488
+ } catch (e) {
489
+ callbackFailed = true;
490
+ callbackError = e;
491
+ throw e;
492
+ }
493
+ };
494
+ let transition;
495
+ if (useTypesApi) transition = doc.startViewTransition({
496
+ update: updateCallback,
497
+ types
498
+ });
499
+ else transition = doc.startViewTransition(updateCallback);
500
+ try {
501
+ await transition.finished;
502
+ } catch (err) {
503
+ if (callbackFailed) throw callbackError;
504
+ throw err;
505
+ }
506
+ if (callbackFailed) throw callbackError;
507
+ return result;
508
+ }
509
+ function getNavigationType() {
510
+ try {
511
+ const nav = _global.navigation;
512
+ if (nav?.currentEntry && nav?.transitionType) return nav.transitionType;
513
+ } catch {}
514
+ return "push";
515
+ }
516
+ function useViewTransitionState(name) {
517
+ return { style: `view-transition-name: ${name};` };
518
+ }
519
+ //#endregion
520
+ //#region src/components.ts
521
+ /**
522
+ * @ubean/vue — lean components & plugin.
523
+ *
524
+ * Hard boundary of this module (and the whole package):
525
+ * - dependencies are `vue` + `vue-router` ONLY — no `@ubean/*`, no `node:*`,
526
+ * no `@unhead/vue`
527
+ * - no i18n: `Link` renders paths verbatim unless a localizer is provided
528
+ * via `LOCALIZE_PATH_KEY` (the framework runtime `@ubean/client` provides
529
+ * the reactive `localizePath`; lean SPAs simply don't)
530
+ * - no islands directive: the `v-client` directive is a framework concern
531
+ * (`@ubean/client` registers it inside its app factories)
532
+ */
533
+ /**
534
+ * Injection keys (exported for advanced use cases — e.g. testing utilities
535
+ * that need to provide the page/transition context outside the plugin).
536
+ */
537
+ const PAGE_KEY = Symbol("ubean-page");
538
+ const TRANSITION_KEY = Symbol("ubean-transition");
539
+ const SSR_KEY = Symbol("ubean-ssr");
540
+ const LOADING_KEY = Symbol("ubean-loading");
541
+ const ERROR_KEY = Symbol("ubean-error");
542
+ /**
543
+ * Module-level reload placeholder — a single comment vnode reused across
544
+ * renders while `_reloadBlank` is active (see the KeepAlive slot in
545
+ * `PageView` for why it must be a comment, not null).
546
+ */
547
+ const _reloadPlaceholder = createCommentVNode("ubean-reload");
548
+ /** Dev-only one-shot flag for the out-in degradation warning. */
549
+ let _warnedOutIn = false;
550
+ /**
551
+ * Injectable path localizer (optional i18n bridge). The framework runtime
552
+ * (`@ubean/client`) provides the reactive `localizePath`; when absent,
553
+ * `Link` renders paths verbatim — this package never imports i18n itself.
554
+ */
555
+ const LOCALIZE_PATH_KEY = Symbol("ubean-localize-path");
556
+ const LAYOUT_CHAIN_KEY = Symbol("ubean-layout-chain");
557
+ /**
558
+ * LayoutChainRenderer — recursively renders a chain of nested layout
559
+ * components (framework factories build the chain; lean SPAs may use it
560
+ * directly for nested layouts via render functions).
561
+ */
562
+ const LayoutChainRenderer = defineComponent({
563
+ name: "UbeanLayoutChain",
564
+ props: {
565
+ components: {
566
+ type: Array,
567
+ required: true
568
+ },
569
+ depth: {
570
+ type: Number,
571
+ default: 0
572
+ }
573
+ },
574
+ setup(props) {
575
+ provide(LAYOUT_CHAIN_KEY, {
576
+ get components() {
577
+ return props.components;
578
+ },
579
+ get depth() {
580
+ return props.depth;
581
+ }
582
+ });
583
+ return () => {
584
+ const comp = props.components[props.depth];
585
+ if (!comp) return h(PageView);
586
+ return h(comp);
587
+ };
588
+ }
589
+ });
590
+ /**
591
+ * ErrorBoundary — catches rendering, async resolution, and setup errors
592
+ * from descendant components. When an error is caught, renders the
593
+ * configured `errorComponent` instead of the slot content. The error state
594
+ * resets automatically on route change.
595
+ */
596
+ const ErrorBoundary = defineComponent({
597
+ name: "UbeanErrorBoundary",
598
+ props: { component: {
599
+ type: [Object, Function],
600
+ default: null
601
+ } },
602
+ setup(props, { slots }) {
603
+ const error = shallowRef(null);
604
+ const route = useRoute();
605
+ watch(() => route.fullPath, () => {
606
+ error.value = null;
607
+ });
608
+ onErrorCaptured((err) => {
609
+ error.value = err;
610
+ return false;
611
+ });
612
+ return () => {
613
+ if (error.value && props.component) return h(props.component, { error: error.value });
614
+ return slots.default?.();
615
+ };
616
+ }
617
+ });
618
+ /**
619
+ * PageView — renders the matched route page wrapped with `<Transition>` and
620
+ * `<KeepAlive>`. Layouts use `<PageView />` (instead of `<slot />`) as the
621
+ * page outlet.
622
+ *
623
+ * Equivalents of soybean-unify's layout-content pattern:
624
+ * - `pageAnimateMode` → `transition` prop + `route.meta.transition` + global `usePageTransition()`
625
+ * - `cachedRoutes` / `excludeCachedRoutes` → `useCacheViews().cachedViews` / `excludedViews`
626
+ * - `reloadSignal` → `useReloadSignal().counter` (incremented by `reloadPage()`)
627
+ *
628
+ * Props:
629
+ * - `transition`: transition name (string) or `false` to disable.
630
+ * Priority: prop > `route.meta.transition` > global `usePageTransition()`.
631
+ * - `reloadKey`: a reactive key — changing it forces remount (reload).
632
+ * When omitted, falls back to `route.fullPath` + per-page reload count.
633
+ * - `mode`: Vue `<Transition>` mode. Defaults to `'default'` (cross-fade —
634
+ * old page plays its leave while the new one enters, both driven through
635
+ * KeepAlive's delayLeave). `'out-in'` is DEGRADED to `'default'` with a
636
+ * dev warning: vue@3.5.41's `out-in` renders an `emptyPlaceholder` hollow
637
+ * KeepAlive on page swaps, which crashes hand-written render chains
638
+ * (`updateSlots` reading `children._`) and corrupts keep-alive cache
639
+ * state on compiled chains (vuejs/core#10771 family). Revisit when the
640
+ * upstream fix lands.
641
+ *
642
+ * On SSR (via `SSR_KEY`), KeepAlive/Transition/Suspense/ErrorBoundary are
643
+ * skipped (client-only concepts).
644
+ */
645
+ const PageView = defineComponent({
646
+ name: "PageView",
647
+ props: {
648
+ transition: {
649
+ type: [String, Boolean],
650
+ default: void 0
651
+ },
652
+ reloadKey: {
653
+ type: [String, Number],
654
+ default: void 0
655
+ },
656
+ mode: {
657
+ type: String,
658
+ default: "default"
659
+ }
660
+ },
661
+ setup(props) {
662
+ const { cachedViews, excludedViews } = useCacheViews();
663
+ const globalTransition = usePageTransition();
664
+ const ssr = inject(SSR_KEY, false);
665
+ const loadingComp = inject(LOADING_KEY, null);
666
+ const errorComp = inject(ERROR_KEY, null);
667
+ const layoutChain = inject(LAYOUT_CHAIN_KEY, null);
668
+ const route = useRoute();
669
+ watch(() => route.fullPath, () => {
670
+ _flushPendingReincludes(route.meta?.pageName ?? (typeof route.name === "string" ? route.name : void 0));
671
+ }, { flush: "post" });
672
+ return () => {
673
+ if (layoutChain && layoutChain.depth < layoutChain.components.length - 1) return h(LayoutChainRenderer, {
674
+ components: layoutChain.components,
675
+ depth: layoutChain.depth + 1
676
+ });
677
+ return h(RouterView, null, { default: ({ Component, route: $route }) => {
678
+ if (!Component) return null;
679
+ if (ssr) return Component;
680
+ const rawComp = Component.type;
681
+ const pageName = $route.meta?.pageName;
682
+ const comp = pageName ? getNamedPageWrapper(pageName, rawComp) : rawComp;
683
+ const componentProps = Component.props || {};
684
+ const routeTransition = $route.meta?.transition;
685
+ const transitionName = props.transition === false ? void 0 : props.transition || routeTransition || globalTransition.name.value || void 0;
686
+ const pageReloadCount = pageName ? _pageReloadCounts.get(pageName) ?? 0 : 0;
687
+ const pageVNode = h(comp, {
688
+ ...componentProps,
689
+ key: props.reloadKey ?? `${$route.fullPath}#${pageReloadCount}`
690
+ });
691
+ const keepAliveProps = { include: cachedViews.value };
692
+ if (excludedViews.value.length > 0) keepAliveProps.exclude = excludedViews.value;
693
+ const keepAliveVNode = h(KeepAlive, keepAliveProps, { default: () => _reloadBlank.value ? _reloadPlaceholder : pageVNode });
694
+ const contentVNode = !ssr && loadingComp ? h(Suspense, null, {
695
+ default: () => keepAliveVNode,
696
+ fallback: () => h(loadingComp)
697
+ }) : keepAliveVNode;
698
+ const transitionVNode = h(Transition, {
699
+ name: transitionName ?? "",
700
+ mode: _reloading.value || props.mode === "out-in" ? "default" : props.mode,
701
+ appear: !!transitionName
702
+ }, { default: () => contentVNode });
703
+ if (props.mode === "out-in" && import.meta.env?.DEV && !_warnedOutIn) {
704
+ _warnedOutIn = true;
705
+ console.warn("[PageView] mode=\"out-in\" is degraded to \"default\": vue@3.5.41 out-in + KeepAlive trips emptyPlaceholder bugs (updateSlots crash on hand-written chains, cache corruption on compiled chains). Using cross-fade instead.");
706
+ }
707
+ if (!ssr && errorComp) return h(ErrorBoundary, { component: errorComp }, { default: () => transitionVNode });
708
+ return transitionVNode;
709
+ } });
710
+ };
711
+ }
712
+ });
713
+ /**
714
+ * SlotView — renders a named parallel route slot. Parallel routes are
715
+ * registered as Vue Router named views on the matched route record;
716
+ * `<SlotView name="analytics" />` resolves the slot component from the
717
+ * current route (deepest matched record exposing the slot wins).
718
+ *
719
+ * Resolution is independent of RouterView depth chaining, so SlotView works
720
+ * both as a sibling of `<PageView />` and inside the default view page
721
+ * component (a nested `<RouterView>` there would look at depth + 1 and
722
+ * never find the named view).
723
+ */
724
+ const SlotView = defineComponent({
725
+ name: "SlotView",
726
+ props: { name: {
727
+ type: String,
728
+ required: true
729
+ } },
730
+ setup(props) {
731
+ const route = useRoute();
732
+ const asyncWrappers = /* @__PURE__ */ new Map();
733
+ const resolveSlot = () => {
734
+ for (let i = route.matched.length - 1; i >= 0; i--) {
735
+ const components = route.matched[i].components;
736
+ if (components && props.name in components) return components[props.name];
737
+ }
738
+ return null;
739
+ };
740
+ return () => {
741
+ const comp = resolveSlot();
742
+ if (!comp) return createCommentVNode(`slot-view:${props.name}`);
743
+ const fn = comp;
744
+ if (typeof comp === "function" && !fn.render && !fn.setup) {
745
+ let wrapper = asyncWrappers.get(props.name);
746
+ if (!wrapper) {
747
+ wrapper = defineAsyncComponent(() => comp());
748
+ asyncWrappers.set(props.name, wrapper);
749
+ }
750
+ return h(wrapper);
751
+ }
752
+ return h(comp);
753
+ };
754
+ }
755
+ });
756
+ /**
757
+ * Link — internal links render via RouterLink; external links (`http*`,
758
+ * `//`, `#`) render as native `<a target="_blank" rel="noopener noreferrer">`.
759
+ *
760
+ * Path localization is opt-in via `LOCALIZE_PATH_KEY` (provided by the
761
+ * framework runtime); lean SPAs render paths verbatim.
762
+ */
763
+ const Link = defineComponent({
764
+ name: "Link",
765
+ props: {
766
+ to: {
767
+ type: [String, Object],
768
+ default: void 0
769
+ },
770
+ href: {
771
+ type: String,
772
+ default: void 0
773
+ },
774
+ replace: {
775
+ type: Boolean,
776
+ default: false
777
+ },
778
+ prefetch: {
779
+ type: Boolean,
780
+ default: false
781
+ },
782
+ activeClass: {
783
+ type: String,
784
+ default: "router-link-active"
785
+ },
786
+ exactActiveClass: {
787
+ type: String,
788
+ default: "router-link-exact-active"
789
+ },
790
+ noActiveClass: {
791
+ type: Boolean,
792
+ default: false
793
+ }
794
+ },
795
+ setup(props, { slots, attrs }) {
796
+ const localize = inject(LOCALIZE_PATH_KEY, null);
797
+ const resolvedTo = computed(() => {
798
+ const path = props.to ?? props.href ?? "";
799
+ if (typeof path === "string" && (path.startsWith("http") || path.startsWith("//") || path.startsWith("#"))) return path;
800
+ return localize ? localize(path) : path;
801
+ });
802
+ const isExternal = computed(() => {
803
+ const path = String(resolvedTo.value);
804
+ return path.startsWith("http") || path.startsWith("//") || path.startsWith("#");
805
+ });
806
+ return () => {
807
+ if (isExternal.value) return h("a", {
808
+ ...attrs,
809
+ href: resolvedTo.value,
810
+ target: "_blank",
811
+ rel: "noopener noreferrer"
812
+ }, slots.default ? slots.default() : void 0);
813
+ return h(RouterLink, {
814
+ ...attrs,
815
+ to: resolvedTo.value,
816
+ replace: props.replace,
817
+ activeClass: props.noActiveClass ? "" : props.activeClass,
818
+ exactActiveClass: props.noActiveClass ? "" : props.exactActiveClass
819
+ }, slots.default);
820
+ };
821
+ }
822
+ });
823
+ const _EMPTY_PAGE_DATA = {};
824
+ /**
825
+ * `usePage()` — 读取 `PAGE_KEY` 注入的页面数据(精简版:仅 pageData)。
826
+ *
827
+ * 路由驱动的 url/params/query 请使用 vue-router 的 `useRoute()`;
828
+ * 导航请使用 vue-router 的 `useRouter()`(push/replace 自带
829
+ * RouteNamedMap 泛型推导)。
830
+ *
831
+ * 未提供 `PAGE_KEY` 时返回共享的空对象(字段均为 undefined)。
832
+ */
833
+ function usePage() {
834
+ return inject(PAGE_KEY, null) ?? _EMPTY_PAGE_DATA;
835
+ }
836
+ /** `useViewTransition()` — whether native View Transitions are available/enabled. */
837
+ function useViewTransition() {
838
+ const opts = inject(TRANSITION_KEY, { enabled: true });
839
+ return {
840
+ enabled: opts.enabled !== false && supportsViewTransitions(),
841
+ supports: supportsViewTransitions(),
842
+ options: opts
843
+ };
844
+ }
845
+ const ubeanVue = { install(app, options) {
846
+ app.component("Link", Link);
847
+ app.component("PageView", PageView);
848
+ app.component("SlotView", SlotView);
849
+ if (options?.routes) initCachedViewsFromRoutes(options.routes);
850
+ } };
851
+ //#endregion
852
+ //#region src/define-page.ts
853
+ /**
854
+ * Macro to declare a client page.
855
+ *
856
+ * used in `<script setup>` of SFC.
857
+ *
858
+ * @param options Page options.
859
+ *
860
+ * @example
861
+ * ```ts
862
+ * definePage({
863
+ * name: 'UserProfile',
864
+ * path: '/u/:id',
865
+ * cache: true,
866
+ * transition: 'fade',
867
+ * layout: 'admin',
868
+ * requiresAuth: true,
869
+ * meta: { custom: 'any' }
870
+ * });
871
+ * ```
872
+ */
873
+ function definePage(options) {}
874
+ //#endregion
875
+ //#region src/router-location.ts
876
+ function resolveRoute(to, routeMap) {
877
+ if (typeof to === "string") {
878
+ if (to.startsWith("/") || to.startsWith("http") || to.startsWith("#")) return to;
879
+ return `/${to.replace(/^\//, "")}`;
880
+ }
881
+ let path = to.path || "/";
882
+ if (to.name && routeMap) {
883
+ const route = routeMap[to.name];
884
+ if (route) path = route.route;
885
+ }
886
+ if (to.params) for (const [key, value] of Object.entries(to.params)) path = path.replace(`:${key}`, String(value));
887
+ if (to.query) {
888
+ const qs = Object.entries(to.query).filter(([, v]) => v !== void 0 && v !== null).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`).join("&");
889
+ if (qs) path += (path.includes("?") ? "&" : "?") + qs;
890
+ }
891
+ if (to.hash) path += to.hash.startsWith("#") ? to.hash : `#${to.hash}`;
892
+ return path;
893
+ }
894
+ function isActiveRoute(currentPath, targetHref, exact = false) {
895
+ if (targetHref === "/" || targetHref === "") return currentPath === "/";
896
+ if (exact) return currentPath === targetHref || currentPath === `${targetHref}/`;
897
+ return currentPath === targetHref || currentPath.startsWith(`${targetHref}/`) || currentPath.startsWith(`${targetHref}?`);
898
+ }
899
+ //#endregion
900
+ //#region src/matchers.ts
901
+ /**
902
+ * 全局 matcher 注册表(进程单例)。
903
+ *
904
+ * server / client 运行时各自维护一份;dev 模式下 HMR 不会自动清理注册表,
905
+ * 但 `defineMatcher` 同名覆盖,因此重新加载 matcher 文件会自动更新函数引用。
906
+ */
907
+ const matcherRegistry = /* @__PURE__ */ new Map();
908
+ /**
909
+ * 定义并注册一个命名 route matcher。
910
+ *
911
+ * @param name matcher 名称,对应 `[paramName=name]` 中的 `name`
912
+ * @param fn matcher 函数,接收参数字符串值,返回 falsy 表示不匹配
913
+ * @returns 传入的 `fn`,便于链式/导出使用
914
+ *
915
+ * @example
916
+ * ```ts
917
+ * // src/matchers/numeric.ts
918
+ * import { defineMatcher } from '@ubean/vue';
919
+ * export default defineMatcher('numeric', (value) => /^\d+$/.test(value));
920
+ * ```
921
+ */
922
+ function defineMatcher(name, fn) {
923
+ if (typeof name !== "string" || name.length === 0) throw new TypeError(`[ubean] defineMatcher: name must be a non-empty string, got: ${typeof name}`);
924
+ if (typeof fn !== "function") throw new TypeError(`[ubean] defineMatcher: fn must be a function, got: ${typeof fn}`);
925
+ matcherRegistry.set(name, fn);
926
+ return fn;
927
+ }
928
+ /**
929
+ * 按名称获取已注册的 matcher。未注册时返回 `undefined`。
930
+ */
931
+ function getMatcher(name) {
932
+ return matcherRegistry.get(name);
933
+ }
934
+ /**
935
+ * 判断指定名称的 matcher 是否已注册。
936
+ */
937
+ function hasMatcher(name) {
938
+ return matcherRegistry.has(name);
939
+ }
940
+ /**
941
+ * 获取所有已注册 matcher 的名称列表(主要用于调试 / DevTools)。
942
+ */
943
+ function listMatcherNames() {
944
+ return [...matcherRegistry.keys()];
945
+ }
946
+ /**
947
+ * 清空所有已注册的 matcher。
948
+ *
949
+ * **仅供测试使用** —— 应用代码不应调用,避免误删其他模块注册的 matcher。
950
+ */
951
+ function clearMatchers() {
952
+ matcherRegistry.clear();
953
+ }
954
+ /**
955
+ * 校验一组路由参数是否通过对应的 matcher。
956
+ *
957
+ * @param matchers `ScannedPage.matchers` —— `{ paramName: matcherName }` 映射
958
+ * @param params 当前请求解析出的 `{ paramName: value }`
959
+ * @returns `true` 表示所有 matcher 通过(或无 matcher 需要校验);
960
+ * `false` 表示至少一个 matcher 拒绝,该路由不应匹配
961
+ *
962
+ * 行为细节:
963
+ * - 若 `matchers` 为空 / undefined,直接返回 `true`(无校验需求)
964
+ * - 若 matcher 名对应的函数未注册,**返回 `false`**(保守策略)
965
+ * - 若参数不存在于 `params` 中,视为校验失败返回 `false`
966
+ * - matcher 函数抛异常时,捕获并视为不匹配
967
+ */
968
+ function validateParams(matchers, params) {
969
+ if (!matchers || Object.keys(matchers).length === 0) return true;
970
+ for (const [paramName, matcherName] of Object.entries(matchers)) {
971
+ const value = params[paramName];
972
+ if (value === void 0 || value === null) return false;
973
+ const values = Array.isArray(value) ? value : [value];
974
+ const matcherFn = getMatcher(matcherName);
975
+ if (!matcherFn) return false;
976
+ for (const v of values) try {
977
+ if (!matcherFn(v)) return false;
978
+ } catch {
979
+ return false;
980
+ }
981
+ }
982
+ return true;
983
+ }
984
+ /**
985
+ * 创建 Vue Router `beforeEach` 导航守卫,用于客户端校验动态路由 matchers。
986
+ *
987
+ * 路由记录的 `meta.matchers` 字段(由 route generator 自动注入)记录了
988
+ * `{ paramName: matcherName }` 映射。守卫读取该字段,调用 `validateParams`
989
+ * 校验 `to.params`;失败时跳转到 404 路由(若存在)或取消导航。
990
+ *
991
+ * **用法**:
992
+ *
993
+ * ```ts
994
+ * import { createMatcherGuard } from '@ubean/vue';
995
+ *
996
+ * const router = createRouter({ history: createWebHistory(), routes });
997
+ * router.beforeEach(createMatcherGuard());
998
+ * ```
999
+ *
1000
+ * **设计说明**:
1001
+ * - 守卫是**可选的**:不调用 `createMatcherGuard()` 时,客户端不做 matcher
1002
+ * 校验,完全依赖服务端 Hono 中间件拦截(适用于 SSR 应用)。
1003
+ * - 对于纯 SPA 应用(`ssr: false`),强烈建议注册此守卫,否则客户端导航到
1004
+ * `/users/abc`(应当被 `[id=numeric]` 拒绝)会渲染页面而非 404。
1005
+ */
1006
+ function createMatcherGuard(options = {}) {
1007
+ const notFoundRouteName = options.notFoundRouteName ?? "NotFound";
1008
+ const onReject = options.onReject;
1009
+ return (to) => {
1010
+ const matchers = to.meta?.matchers;
1011
+ if (!matchers || Object.keys(matchers).length === 0) return;
1012
+ const params = to.params;
1013
+ if (!validateParams(matchers, params)) {
1014
+ onReject?.({
1015
+ path: to.path,
1016
+ params: to.params,
1017
+ matchers
1018
+ });
1019
+ return { name: notFoundRouteName };
1020
+ }
1021
+ };
1022
+ }
1023
+ //#endregion
1024
+ //#region src/head.ts
1025
+ /**
1026
+ * 将静态 `PageHead` push 进 head 实例(falsy 字段自动跳过)。
1027
+ * 与 SSR 侧 `pushPageHead`(@ubean/ssr)语义一致,保证双端同构。
1028
+ */
1029
+ function pushPageHead(head, pageHead) {
1030
+ const input = {};
1031
+ if (pageHead.title !== void 0) input.title = pageHead.title;
1032
+ if (pageHead.meta !== void 0) input.meta = pageHead.meta;
1033
+ if (pageHead.link !== void 0) input.link = pageHead.link;
1034
+ if (pageHead.script !== void 0) input.script = pageHead.script;
1035
+ if (pageHead.htmlAttrs !== void 0) input.htmlAttrs = pageHead.htmlAttrs;
1036
+ if (pageHead.bodyAttrs !== void 0) input.bodyAttrs = pageHead.bodyAttrs;
1037
+ head.push(input);
1038
+ }
1039
+ /**
1040
+ * SPA 页面级 head 守卫:导航完成后读取 `route.meta.head` 并 push 进 head 实例。
1041
+ *
1042
+ * ```ts
1043
+ * import { createRouter, createWebHistory } from 'vue-router';
1044
+ * import { setupPageHeadGuard, createPageHead } from '@ubean/vue';
1045
+ *
1046
+ * const head = await createPageHead(); // 按需加载 @unhead/vue
1047
+ * const router = createRouter({ history: createWebHistory(), routes });
1048
+ * setupPageHeadGuard(router, head); // 也可传任何已有 unhead 实例
1049
+ * ```
1050
+ *
1051
+ * 初始导航同样触发(vue-router 首次导航完成后 afterEach 执行)。
1052
+ */
1053
+ function setupPageHeadGuard(router, head) {
1054
+ router.afterEach((to) => {
1055
+ const pageHead = to.meta.head;
1056
+ if (pageHead) pushPageHead(head, pageHead);
1057
+ });
1058
+ }
1059
+ /**
1060
+ * 按需创建 head 实例(懒加载 `@unhead/vue`,optional peer dependency)。
1061
+ *
1062
+ * 项目已自带 unhead 实例时无需调用 —— 直接把实例传给
1063
+ * `setupPageHeadGuard` 即可,零额外加载。
1064
+ */
1065
+ async function createPageHead() {
1066
+ try {
1067
+ const mod = await import("@unhead/vue");
1068
+ if (typeof mod.createHead === "function") return mod.createHead();
1069
+ } catch {}
1070
+ throw new Error("[ubean/vue] createPageHead() requires `@unhead/vue` to be installed (optional peer dependency)");
1071
+ }
1072
+ //#endregion
1073
+ export { ERROR_KEY, ErrorBoundary, LAYOUT_CHAIN_KEY, LOADING_KEY, LOCALIZE_PATH_KEY, LayoutChainRenderer, Link, PAGE_KEY, PageView, SSR_KEY, SlotView, TRANSITION_KEY, clearMatchers, clearPageTransition, createMatcherGuard, createPageHead, ubeanVue as default, ubeanVue, defineMatcher, definePage, disablePageCache, enablePageCache, excludePageCache, getCacheEnabled, getCachedViewNames, getExcludedViewNames, getMatcher, getNamedPageWrapper, getNavigationType, getPageTransitionName, getReloadCounter, hasMatcher, includePageCache, initCachedViewsFromRoutes, invalidatePageCache, isActiveRoute, isCacheEnabled, isPageCached, isPageExcluded, isReloading, listMatcherNames, pushPageHead, reloadPage, resetNamedPageWrappers, resetRouteCache, resolveRoute, setPageTransition, setupPageHeadGuard, supportsViewTransitions, useCacheViews, usePage, usePageTransition, useReloadSignal, useViewTransition, useViewTransitionState, validateParams, withViewTransition };