@ubean/vue

Lean Vue client kernel & page-routing owner for ubean — plugin-first page routing outlet, page cache (keep-alive), transitions, reload signal, definePage macro + file-routing Vite plugin (/vite: multi-dir scan, reuse routes, markdown & page-head opt-in).
7,720
Install
npm install @ubean/vue
Latest Version:0.7.0
Vue:3
Last Updated:Oct 22, 2026
Links: npm
Maintainer: honghuangdc

@ubean/vue

English | 中文

Lean Vue client kernel & page-routing owner for ubean.

@ubean/vue owns everything about page routing in the ubean ecosystem: file-based route scanning, virtual-module generation, physical route file generation, page cache (keep-alive), page transitions, reload signal, and the definePage macro. It ships as a Vue plugin (no app factory, no router factory) and depends on vue + vue-router only at runtime.

import { createApp } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';
import { ubeanVue } from '@ubean/vue';
import { routes } from 'virtual:ubean-vue-routes'; // generated by @ubean/vue/vite

const router = createRouter({ history: createWebHistory(), routes });
const app = createApp(App);
app.use(router);
app.use(ubeanVue, { routes }); // registers Link/PageView/SlotView/ClientOnly + seeds page cache
app.mount('#app');

Table of Contents

Package Positioning

@ubean/vue is the lean client kernel and the single owner of page-routing logic in ubean:

Layer Responsibility
@ubean/vue (this package) Page routing: scanning, virtual modules, physical route file generator, matchers, head guard, page cache, transitions, reload — pure client-side
@ubean/scan (aggregator) Depends on @ubean/vue; layers server routing (rou3), API route scanning, SSR page handling on top
@ubean/client (framework runtime) App factories (createUbeanClientApp / createUbeanSSRApp), unhead/SEO, i18n, islands, data layer

Included:

  • ubeanVue Vue plugin (the only wiring entry — plugin-first, no factory)
  • <PageView> / <Link> / <SlotView> / <LayoutChainRenderer> / <ErrorBoundary> / <ClientOnly>
  • Page cache (keep-alive) store with imperative + declarative APIs
  • Page transitions & reload signal (per-page remount with fresh cache)
  • View Transitions helpers (feature detection + wrapper)
  • definePage client macro (compile-time extracted, runtime no-op)
  • Dynamic route matchers ([param=matcher] syntax + registry + guard)
  • Page-level head (setupPageHeadGuard / lazy createPageHead)
  • File-routing Vite plugin (/vite subpath): multi-dir scan, reuse routes, special pages, parallel routes (@slotName/), markdown & head opt-in
  • Physical route file generator (/generator subpath): routes.ts + imports.ts + typed-router.d.ts (file mode)

NOT included (framework runtime @ubean/client concerns):

  • App factories (createUbeanClientApp / createUbeanSSRApp)
  • unhead / <Head> / SEO (only the opt-in head guard lives here)
  • i18n (Link renders paths verbatim unless a localizer is provided via LOCALIZE_PATH_KEY)
  • Islands (v-client directive), SSR state helpers, auto-import presets

Runtime dependency whitelist (main entry): vue + vue-router only. No @ubean/*, no node:*, no @unhead/vue static imports.

Install

pnpm add @ubean/vue vue vue-router
# build-time (file routing):
pnpm add -D vite @ubean/vue # the /vite & /generator subpaths are part of this package
# optional peers (loaded lazily, only when enabled):
pnpm add @ubean/markdown   # markdown pages
pnpm add @unhead/vue       # page-level head (SPA)

Quick Start

1. Vite config — the plugin scans your pages and generates virtual:ubean-vue-routes:

// vite.config.ts
import vue from '@vitejs/plugin-vue';
import { ubeanVueVite } from '@ubean/vue/vite';

export default {
  plugins: [vue(), ubeanVueVite()]
};

2. Bootstrap — create the router yourself, then install the plugin:

// src/main.ts
import { createApp } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';
import { ubeanVue } from '@ubean/vue';
import { routes } from 'virtual:ubean-vue-routes';
import App from './App.vue';

const router = createRouter({ history: createWebHistory(), routes });
const app = createApp(App);
app.use(router);
app.use(ubeanVue, { routes });
app.mount('#app');

3. App shell — <PageView /> is the page outlet (keep-alive / transition / reload all take effect there):

<!-- src/App.vue -->
<template>
  <nav>
    <Link to="/">Home</Link>
    <Link to="/about">About</Link>
  </nav>
  <main><PageView /></main>
</template>

A complete runnable example lives at examples/client-only-spa.

File-Based Routing

Conventions

Default page extensions are vue / tsx / jsx (override with the extensions option). .ts is not a page extension — script pages must use .tsx / .jsx or .vue. Layouts use vue / ts.

src/pages/
├── index.vue                 →  /                        Home
├── about.vue                 →  /about                   About
├── users/index.vue           →  /users                   Users
├── users/[id].vue            →  /users/:id               UsersId
├── users/[id=numeric].vue    →  /users/:id  + matcher    (see matchers)
├── blog/[...slug].vue        →  /blog/**:slug            BlogAllSlug (catch-all)
├── docs/[[page]].vue         →  /docs/:page?             DocsPageOptional
├── (marketing)/about.vue     →  /about                   About (group stripped)
├── settings.tsx              →  /settings                Settings (JSX page)
├── 404.vue                   →  catch-all + NotFound     (special page)
└── guide.md                  →  /guide                   Guide (markdown, opt-in)

Multiple pagesDir / layoutsDir are supported (first-seen-wins dedup, so folders can be layered).

  • Route names are derived from paths in PascalCase (users/[id] → UsersId).
  • definePage({ name, path }) can override both.
  • Route groups (name) never contribute to the URL or the route name.

definePage Macro

Declare page metadata at the top of <script setup> (or top-level in .tsx/.jsx/.reuse.ts/.meta.ts):

<script setup lang="ts">
import { definePage } from '@ubean/vue';

definePage({
  name: 'UserProfile', // override the file-derived route name
  path: '/u/:id', // override the file-derived path
  cache: true, // keep-alive page cache
  transition: 'fade', // page-level transition name
  layout: 'admin', // layout marker: name, array (outer→inner), or false
  requiresAuth: true, // auth marker → route.meta.requiresAuth
  routable: false, // keep in the page inventory, but register no route
  head: { title: 'Profile' }, // page head (requires plugin `head: true`)
  meta: { custom: 'any' } // arbitrary RouteMeta extensions
});
</script>

Dual semantics:

  • Build time (recommended): the /vite plugin extracts the argument object, merges it into the generated route table, and strips the call from the output (zero runtime cost).
  • Runtime fallback: without the plugin the function is a no-op — no errors, no side effects; pages register with file-derived paths/names. cache can be restored at runtime via enablePageCache(name).

Use meta: { middleware: [...] } and consume it in your own navigation guard.

Reuse Routes

.reuse.ts / .reuse.js are pure metadata files (they only contain definePage({ reuse })) that register a new route reusing another page's component:

// src/pages/about2.reuse.ts
definePage({ reuse: 'About' });
// → route /about2 renders the About page component
  • .reuse.vue is NOT part of the convention — a .vue file is a real component and is treated as a regular page (route /xxx.reuse).
  • Reuse routes that don't declare cache explicitly inherit the target's cache setting; declare cache: false to opt out.
  • The target must be a regular (non-reuse) page.

Meta-Only Pages (routable: false)

.meta.ts / .meta.js are pure metadata files — like .reuse.*, they contain only definePage(...) and no component. They exist for entries that belong in the page inventory but must not become routes: menu directories, external-link entries, iframe containers, or any host-specific concept.

// src/pages/plugin/index.meta.ts — the filename already says it: no `routable` needed
definePage({ meta: { menuType: 'directory', title: 'Plugins' } });
  • The .meta.* filename is routable: false — the field is normalized to false at scan time and may be omitted. Declaring routable: true on a .meta.* file throws at scan time (a metadata file with no component would produce a broken route).
  • .meta.* files may declare reuse — with reuse the entry is a reuse page (borrows the target component for a new URL); the .meta and .reuse suffixes are semantically equivalent.
  • .meta.vue is NOT the convention — it is a real component and is treated as a regular page.

routable: false is a general flag, not tied to .meta.*. Any page — component or metadata — may set it:

<!-- src/pages/hidden.vue — component stays on disk, but no route is registered -->
<script setup lang="ts">
definePage({ routable: false });
</script>

What routable: false removes vs. keeps:

Removed Kept
client route record (routes) component loader (_pageLoaders) — .reuse.* routes still borrow it
PageName / RouteNamedMap entries entry in pages (with routable / pageKind / pageMeta)
server route registration (Hono, SSR, SSG), prerender candidates definePage metadata, for hosts that build menus from the inventory

.meta.* entries get no loader at all (their module has no default export). The pages export carries routable, pageKind and the raw pageMeta, so an application can read its own menu semantics — e.g. treat meta.menuType === 'directory' as a nested menu group and meta.href as an external link.

Special Pages

Root-level files in a pages directory are wired to framework slots:

File Role
404.vue (or .tsx / .md) Vue Router catch-all /:pathMatch(.*)* named NotFound
loading.vue <Suspense> fallback while async pages load (via LOADING_KEY)
error.vue <ErrorBoundary> error component (via ERROR_KEY)

Only root-level files are special — users/404.vue remains a regular route at /users/404.

Parallel Routes

  • Parallel routes: @slotName/ directories register named views; render them with <SlotView name="slotName" />.
src/pages/dashboard/@analytics/index.vue   → parallel slot "analytics"

Intercepting routes are deliberately not implemented ((.)target/ / (..)target/ / (...)target/). The scanner errors on those marker segments instead of silently degrading them into literal paths like /feed/(.)photo/:id — see ADR-0010 and the rationale in src/scan-pages.ts. Build shareable dialogs with parallel routes plus your own navigation guard.

Dynamic Param Matchers

[param=matcherName] file syntax attaches a validation matcher to a dynamic segment:

src/pages/users/[id=numeric].vue  →  /users/:id + meta.matchers = { id: 'numeric' }

Register matchers with defineMatcher (see Route Matchers API) and enforce them with createMatcherGuard. Failed validation redirects to the NotFound route.

Markdown Pages (opt-in)

Enable with markdown: true (md + mdx), 'md', or 'mdx'. Requires @ubean/markdown (optional peer, loaded lazily only when enabled):

ubeanVueVite({ markdown: true });

Frontmatter maps onto page metadata (name / path / layout / cache / head):

---
name: Guide
layout: docs
cache: true
head:
  title: Guide
---

# Guide content

Route Output Modes

Both modes are owned by @ubean/vue:

Mode Output Consumers
Virtual (default) virtual:ubean-vue-routes in-memory module + typed-router.d.ts Lean SPAs; framework's virtual mode
Physical files routes.ts + imports.ts + typed-router.d.ts written to disk File mode — editable meta, import.meta.glob-free imports

The framework wires them via routing.mode in ubean.config.ts ('virtual' | 'file' | 'both', see @ubean/builder); lean SPAs use the /vite plugin directly and may call the /generator subpath manually.

Vite Plugin API

import { ubeanVueVite } from '@ubean/vue/vite';

ubeanVueVite({
  pagesDir: 'src/pages', // string | string[] (relative to project root or absolute)
  layoutsDir: 'src/layouts', // string | string[]
  extensions: undefined, // default ['vue', 'tsx', 'jsx']; markdown exts appended
  ignore: [], // extra glob ignore patterns
  generateTypes: true, // write typed-router.d.ts
  dtsDir: '.ubean', // dts output dir (relative to root or absolute)
  markdown: false, // true | 'md' | 'mdx'
  head: false // enable page-level head extraction
});

The plugin also exposes standalone helpers: scanPages, scanClientPages, extractSlotFromPath, generatePagesModuleSource, generateTypedRouter, generateVirtualModuleDts, stripDefinePageCalls, filePathToRoute, parseMatchers, stripRouteGroups, generateRouteName, generateLayoutName, extractDefinePage, extractDefinePageFromCode, extractCallObject, normalizePageHead.

Physical Route File Generator

The /generator subpath generates editable route files on disk (@ubean/vue/generator):

import { generateRouteFiles } from '@ubean/vue/generator';

const result = await generateRouteFiles(
  { pages, layouts },               // GeneratorScanInput — subset of ScanPagesResult
  {
    cwd: process.cwd(),
    srcDir: 'src',                  // base for the default @/ import paths
    outDir: 'src/router/_generated',
    // dtsPath: '…',                // default: <outDir>/typed-router.d.ts
    // generateRoutes / generateImports / generateDts: true,
    routeLazy: true,                // or (page) => boolean
    layoutLazy: true,
    getRouteMeta: page => ({ ... }),   // extra meta (definePage meta wins)
    getImportPath: page => `@/pages/${page.name}.vue`,
  }
);
// → { routesPath, importsPath, dtsPath, routeCount, layoutCount }

Produces 3 files under outDir:

  • routes.ts — flat RouteRecord[] (name / path / component(key into views) / layout / meta / cache / requiresAuth; reuse routes point component at their target)
  • imports.ts — views / layouts lazy-loader maps + categorized key types (RouteKey / RouteFileKey / RouteReuseKey / LayoutKey)
  • typed-router.d.ts — @ubean/scan module augmentation (RouteKey / RoutePathMap / RouteLayoutKey / ReuseRouteKey) + a vue-router/auto-routes RouteNamedMap declaration, which is what makes vue-router's own useRoute<Name>() infer path params

The RouteFileGenerator class is also exported for incremental use (new RouteFileGenerator(options).generate(scan)).

The dts still augments the @ubean/scan module — that is the framework's established type contract (framework code imports RouteKey etc. from @ubean/scan); the generator's physical location does not change the artifact. Lean SPAs use the virtual-mode typed-router.d.ts from the /vite plugin instead.

Virtual Module Surface

virtual:ubean-vue-routes exports:

Export Description
routes Vue Router RouteRecord[] (with meta.cache / transition / matchers / head etc.)
pages / layouts { [name]: () => Promise<Component> } lazy loaders
pageNames / layoutNames Name arrays (include NotFound when present)
defaultLayout Default layout name or null
resolvePageComponent(name) / resolveLayoutComponent(name) Component resolvers (reuse routes resolve their target)
loadingComponent / errorComponent Special-page components or null
resolveLoadingComponent() / resolveErrorComponent() Lazy resolvers
hasNotFoundPage() / hasErrorPage() Presence checks

Vue Plugin

app.use(ubeanVue, { routes });

Registers Link / PageView / SlotView / ClientOnly as global components and seeds the keep-alive include list from meta.cache: true declarations (initCachedViewsFromRoutes). This is the only wiring entry of the package (main subpath — distinct from the same-named Vite plugin in the /vite subpath).

Components

<PageView>

The page outlet. Wraps the matched component in <Transition> + <KeepAlive> (+ <Suspense> / <ErrorBoundary> when loading/error components are provided via injection keys).

Props:

  • transition: string | boolean — transition name, false disables. Priority: prop > route.meta.transition > global usePageTransition().
  • reloadKey: string | number — reactive key forcing remount; defaults to route.fullPath + per-page reload count.
  • mode: 'default' | 'out-in' | 'in-out' — defaults to 'default' (cross-fade). 'out-in' is degraded to 'default' with a dev warning due to a vue@3.5.x KeepAlive upstream bug.

On SSR (via SSR_KEY), KeepAlive/Transition/Suspense/ErrorBoundary are skipped.

<Link>

Internal links render via RouterLink; external links (http*, //, #) render as native <a target="_blank" rel="noopener noreferrer">.

Props: to (string or location object), href, replace, activeClass, exactActiveClass, noActiveClass, optional locale. Path localization is opt-in via LOCALIZE_PATH_KEY (the framework runtime provides it; lean SPAs render paths verbatim).

<SlotView>

<SlotView name="modal" /> renders the matched component for a parallel-route slot (nothing when no slot matches).

<LayoutChainRenderer>

Recursively renders a chain of nested layout components (framework factories build the chain; lean SPAs can use it directly via render functions).

<ErrorBoundary>

Catches render/async/setup errors from descendants and renders the configured error component; resets on route change.

<ClientOnly>

Renders its content ONLY on the client, after hydration. Useful for browser-only markup or template fragments (the islands v-client.* transform only applies to capitalized component tags).

<template>
  <!-- string fallback prop -->
  <ClientOnly fallback="loading…">
    <BrowserChart />
  </ClientOnly>

  <!-- fallback slot (reserve space to avoid CLS) -->
  <ClientOnly>
    <template #fallback>
      <div class="skeleton" />
    </template>
    <BrowserMap />
  </ClientOnly>

  <!-- plain fragments work too -->
  <ClientOnly>Window width: {{ width }}px</ClientOnly>
</template>

Hydration safety: the placeholder (fallback / nothing) is rendered during SSR AND during the client's hydration render; the real content is patched in right after mount. There is no typeof window branching, so no hydration mismatch warnings.

When to use what:

Scenario Use
Template fragments / plain HTML, same-tree rendering, fallback slots <ClientOnly>
Component-level deferred hydration strategies (idle/visible/media) islands v-client.*
Programmatic islands / client-only wrappers defineIsland / defineClientComponent (@ubean/islands)
File-level server/client split .server.vue / .client.vue

Notes: default-slot content is absent from the SSR HTML — don't use <ClientOnly> for SEO-critical content. <ClientOnly> inside an island placeholder's children is wiped when the island hydrates (islands hydrate via a separate app).

Composables

  • usePage<T>() — lean page-data access: returns the PAGE_KEY injected data (props / component / errors) as-is, or a shared empty object when nothing was provided. Route state (url / params / query / meta) is NOT included — use vue-router's useRoute() directly. The framework runtime (@ubean/client) layers its own route-aware usePage on top for the full PageObject protocol.
  • useRoute() / useRouter() — not shipped: import both from vue-router directly (its RouteNamedMap overloads come from the generated typed-router.d.ts); @ubean/vue deliberately provides no useRoute / navigateTo / redirectTo wrapper.
  • useCacheViews() — see Page Cache.
  • usePageTransition() — { name, set, clear, enabled } global transition name.
  • useReloadSignal() — { counter, reloading, reload(routeName?, duration?) }.
  • useViewTransition() — { enabled, supports, options } for native View Transitions.

Page Cache (keep-alive)

Declarative:

definePage({ cache: true }); // or route meta: { cache: true }

Imperative:

import {
  enablePageCache, // enablePageCache('DashboardIndex')
  disablePageCache, // remove from include list (prunes the instance)
  invalidatePageCache, // clear a page's (or all) cached instance(s)
  isPageCached,
  excludePageCache, // temporarily exclude (forces prune on next render)
  includePageCache,
  useCacheViews, // reactive store: cachedViews / excludedViews / enable / disable / ...
  resetRouteCache
} from '@ubean/vue';

Caching is keyed by the route name (route.meta.pageName), which the kernel injects as the component name at render time so <keep-alive :include> matches <script setup> SFCs.

Page Transitions & Reload

import { setPageTransition, useReloadSignal } from '@ubean/vue';

setPageTransition('fade-slide'); // global default; '' / 'none' disables
// per-page: definePage({ transition: 'fade' }) or route meta

const { reload } = useReloadSignal();
await reload('DashboardIndex'); // blank → prune stale cache → fresh remount

reloadPage(name) only bumps that page's key — other pages' keep-alive entries survive an unrelated reload.

View Transitions Helpers

import { supportsViewTransitions, withViewTransition, useViewTransitionState, getNavigationType } from '@ubean/vue';

if (supportsViewTransitions()) {
  await withViewTransition(
    async () => {
      /* mutate DOM/state */
    },
    { types: ['slide-left'] }
  );
}

Route Matchers API

import { defineMatcher, createMatcherGuard } from '@ubean/vue';

// src/matchers/numeric.ts
export default defineMatcher('numeric', value => /^\d+$/.test(value));

// router setup — matcher failure redirects to NotFound
router.beforeEach(createMatcherGuard(/* { notFoundRouteName?, onReject? } */));

Registry helpers: getMatcher(name), hasMatcher(name), listMatcherNames(), clearMatchers() (tests only), validateParams(matchers, params).

Page-Level Head (opt-in)

Enable extraction with head: true on the plugin, then:

import { createPageHead, setupPageHeadGuard } from '@ubean/vue';

const head = await createPageHead(); // lazily loads @unhead/vue (optional peer)
setupPageHeadGuard(router, head); // pushes route.meta.head after each navigation

Already have a unhead instance? Skip createPageHead() and pass the instance directly — PageHeadClient is a structural type ({ push }), so no third-party dependency enters the core.

Route Pure Functions

import { resolveRoute, isActiveRoute } from '@ubean/vue';

resolveRoute({ name: 'UsersId', params: { id: 7 }, query: { tab: 'info' } }, routeMap);
// → '/users/7?tab=info'
isActiveRoute('/users/7', '/users', false); // → true (prefix match)

Type Safety

  • The package augments vue-router's RouteMeta with cache / pageName / layout / requiresAuth / transition / head / matchers — projects get full type-safety without their own declare module block.
  • With generateTypes: true (default), the /vite plugin writes ubean-vue-routes.d.ts + typed-router.d.ts into dtsDir (default .ubean, same convention as the framework's auto-imports dts); the /generator subpath writes its own dts for file mode (see above).

Architecture Notes

  • Plugin-first: ubeanVue (main subpath, Vue plugin for app.use) and ubeanVueVite (/vite subpath, Vite plugin factory) are the two wiring entries — distinctly named, no more overloading. The kernel ships no app factory and no router factory — you create both with stock vue / vue-router APIs.
  • Runtime boundary: the main entry (@ubean/vue) must never statically import node:*, @ubean/*, @unhead/vue, vite, or tinyglobby (enforced by tests). Build-time-only code lives behind the /vite and /generator subpaths.
  • Lazy optional peers: @ubean/markdown (markdown pages) and @unhead/vue (head) are optional peers, dynamically imported only when their features are enabled.
  • Global singletons (cache store, transition name, reload counters) live on globalThis so duplicated module instances (dep re-optimization, HMR ?t= variants) share one state — prevents SSR hydration mismatches and KeepAlive cache misses.
  • Ownership: @ubean/scan (aggregator) depends on @ubean/vue — never the reverse. The aggregator re-exports page-routing types, functions, and the generator.

Related Packages

@ubean/i18n

i18n for ubean (vue-i18n / @intlify/core, compact locale routing, ALS)

12,022

Version History

Version Released License
0.7.0
0.6.9
0.6.8
0.6.7
0.6.6
Pre-releases (7)
Version Released License
0.6.1-beta.3
0.6.1-beta.2
0.6.0-beta.5
0.6.0-beta.4
0.6.0-beta.3