diff --git a/apps/api/package.json b/apps/api/package.json index d3f92660e..7ae2db06e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "api", - "version": "2.0.0-canary.9", + "version": "2.0.0-canary.12", "private": true, "type": "module", "scripts": { diff --git a/apps/web/content/docs/dev/configuration.mdx b/apps/web/content/docs/dev/configuration.mdx index 3b692e0ef..5b24d7a55 100644 --- a/apps/web/content/docs/dev/configuration.mdx +++ b/apps/web/content/docs/dev/configuration.mdx @@ -90,6 +90,7 @@ of those modules, and that is the part worth knowing: | `src/plugin-routes.gen.ts` | the plugin's `src/routes.ts` | per route, behind `lazy()` | | `src/admin-nav.gen.ts` | the plugin's `admin/nav` | with the AdminCP shell | | `src/content-registry.gen.ts` | the plugin's `admin/content` | behind a dynamic `import()` in `src/router.tsx` | +| `src/package-messages.gen.ts` | the factory's `localeFiles` | per request, one locale at a time | One literal import per configured plugin, written at build time. So a content type's editing screen - a Tiptap field, a form layout, a table cell - arrives @@ -99,12 +100,13 @@ whole editing stack rides along with every public page. See [Plugin routes](/docs/dev/plugins/routes) and [Plugin frontend modules](/docs/dev/content-engine/plugin-registration). - + The `messages` a factory carries is the plugin's own locale barrel, which loads its JSON with `import('./en.json', { with: { type: 'json' } })` - a specifier - no bundler follows. VitNode reads translations from - `src/locales/packages.ts` instead, so add a line there per language the plugin - ships. [Languages & Localization](/docs/dev/i18n) has the detail. + no bundler follows. The factory's `localeFiles` is the same list spelled as + package subpaths, and that is what your app's translations are loaded from: + your build writes the loaders into `src/package-messages.gen.ts` for you. + [Languages & Localization](/docs/dev/i18n) has the detail. @@ -127,7 +129,7 @@ import '@tanstack/react-start/server-only' import { buildServerConfig } from '@vitnode/core/vitnode.config' import { appMessages } from '@/locales/app' -import { packageMessages } from '@/locales/packages' +import { packageMessages } from '@/package-messages.gen' import { vitNodeConfig } from '@/vitnode.config' export const vitNodeServerConfig = buildServerConfig({ @@ -145,7 +147,7 @@ the document shell read. Hand the whole thing to the loader: export const loadIntlMessages = createIntlMessagesLoader(vitNodeServerConfig) ``` -`packageMessages` is one line per language a package ships, and `messages` is +`packageMessages` is generated from the plugins you configured, and `messages` is where you reword a string a package translates differently to how you want it. [Languages & Localization](/docs/dev/i18n) covers both. diff --git a/apps/web/content/docs/dev/fetcher.mdx b/apps/web/content/docs/dev/fetcher.mdx index c6bb2c722..ae520bf85 100644 --- a/apps/web/content/docs/dev/fetcher.mdx +++ b/apps/web/content/docs/dev/fetcher.mdx @@ -24,16 +24,31 @@ choose a transport. ### Define it in your plugin Keep this in one plugin file. Features import `notesApi`; they never set up a -module reference themselves. +module reference themselves. `create-vitnode-app --plugin` writes this file for +you—the shape below is what it generates. ```ts title="plugins/site-notes/src/api/client.ts" -import type { notesModule } from "../api/notes.module" +import type { ApiClient } from "@vitnode/core/tanstack/fetcher" import { createApiClient } from "@vitnode/core/tanstack/fetcher" -export const notesApi = createApiClient("@acme/site-notes") +import type { notesModule } from "./modules/notes/notes.module" + +export const notesApi: ApiClient = + createApiClient("@acme/site-notes") ``` + + `import type` is what keeps Hono and your handlers out of the browser bundle—a + value import would ship the whole API to every visitor, and nothing would fail + to compile. + + The `ApiClient` annotation is what keeps your plugin's `.d.ts` small. Without + it, declaration emit resolves the client's type in full and writes every route + the module serves into your build output: 200KB for a single route, and every + app that installs the plugin type-checks it. + + diff --git a/apps/web/content/docs/dev/i18n/index.mdx b/apps/web/content/docs/dev/i18n/index.mdx index 02d7dec12..db144292a 100644 --- a/apps/web/content/docs/dev/i18n/index.mdx +++ b/apps/web/content/docs/dev/i18n/index.mdx @@ -67,16 +67,16 @@ Because `buildConfig` keeps those codes as literal types, `'de'` is now part of A `() => import('./de.json')` reads a file out of a package's build output, so it is the one part of i18n that must never reach a browser. Two files own it, and both are registered through the **server-only** config: -| File | Holds | -| :------------------------ | :--------------------------------------------------- | -| `src/locales/packages.ts` | one loader per language each installed package ships | -| `src/locales/app.ts` | your own rewordings, merged last | +| File | Holds | Who writes it | +| :---------------------------- | :--------------------------------------------------- | :------------ | +| `src/package-messages.gen.ts` | one loader per language each installed package ships | your build | +| `src/locales/app.ts` | your own rewordings, merged last | you | ```ts title="apps/web/src/vitnode.server.config.ts" export const vitNodeServerConfig = buildServerConfig({ config: vitNodeConfig, // the locale list above messages: appMessages, // src/locales/app.ts - packageMessages, // src/locales/packages.ts + packageMessages, // src/package-messages.gen.ts }) ``` @@ -86,20 +86,39 @@ export const vitNodeServerConfig = buildServerConfig({ writes to the right file for you. -Adding a language to a package that ships it needs one line in `src/locales/packages.ts`: +### The generated half -```ts title="apps/web/src/locales/packages.ts" -[CORE.pluginId]: { - en: async () => await import('@vitnode/core/locales/en.json'), - de: async () => await import('@vitnode/core/locales/de.json'), // [!code ++] -}, +Registering a plugin is the whole step. Every VitNode build reads the `plugins` in your `vitnode.config.ts`, takes the `localeFiles` each factory declares, and writes `src/package-messages.gen.ts` - core's own languages plus one block per plugin: + +```ts title="apps/web/src/package-messages.gen.ts" +export const packageMessages: Record = { + '@vitnode/core': { + en: async () => await import('@vitnode/core/locales/en.json'), + }, + '@acme/blog': { + en: async () => await import('@acme/blog/locales/en.json'), + }, +} ``` +Don't edit it - it is rewritten on every `dev` and `build`, which is why it sits in your `.gitignore`. Every specifier is a literal because that is the only kind a bundler can resolve: `import(pkg + '/locales/' + locale + '.json')` resolves to nothing. Every loader stays dynamic, so a language's JSON is a chunk of its own and the server loads only the locale a request asked for. + + + `@vitnode/core` and the plugins in this repository ship `en` and nothing else. + Every other language is the install's own, which is what the next section is + for - and it is why a language you add is a file in **your** app rather than a + pull request against a package. + + --- ## Overriding Strings -To customize existing text from core or a third-party plugin, add an override file in `apps/web/src/locales/{pluginId}/{locale}.json` and register it in `src/locales/app.ts`: +Your own translations live in `apps/web/src/locales/{pluginId}/{locale}.json`, registered in `src/locales/app.ts`. The same file does both jobs: a whole language a package does not ship, and a reword of a string it does. + +One file per package per language, holding that package's web **and** email strings together - an app keeps them in one tree where a package ships two, so the copy in an email cannot drift from the copy on the page. + +To reword something core already says: ```json title="apps/web/src/locales/@vitnode/core/en.json" { @@ -122,15 +141,35 @@ export const appMessages: AppMessagesMap = { Because your app overrides are merged last, only the keys you specify are overwritten. Everything else continues to fall back to the package defaults. +A whole language looks exactly the same, because it is the same mechanism - this repository's own Polish is a pair of files nobody's `node_modules` contains: + +```ts title="apps/web/src/locales/app.ts" +export const appMessages: AppMessagesMap = { + pl: { + '@vitnode/blog': async () => await import('./@vitnode/blog/pl.json'), + '@vitnode/core': async () => await import('./@vitnode/core/pl.json'), + }, +} +``` + +If your app also serves the API, register the same map there so emails speak the language too - `i18n.messages` in `vitnode.api.config.ts`: + +```ts title="apps/web/src/vitnode.api.config.ts" +export const vitNodeApiConfig = buildApiConfig({ + i18n: { ...vitNodeConfig.i18n, messages: appMessages }, // [!code highlight] + // ... +}) +``` + --- ## Translation Architecture -| Source | Role | Order | -| :------------------- | :------------------------------------------------- | :---------------------- | -| `@vitnode/core` | Base strings for auth, admin shells, and dialogs | Base layer | -| **Plugins** | Domain strings declared in `plugins/*/src/locales` | Second layer | -| **Host Application** | Custom overrides in `apps/web/src/locales` | Highest priority (wins) | +| Source | Role | Order | +| :------------------- | :---------------------------------------------------------- | :---------------------- | +| `@vitnode/core` | Base strings for auth, admin shells, and dialogs | Base layer | +| **Plugins** | Domain strings declared in `plugins/*/src/locales` | Second layer | +| **Host Application** | Your own languages and rewordings in `apps/web/src/locales` | Highest priority (wins) | Missing keys automatically fall back to `defaultLocale` (`en`), preventing raw key paths from displaying in production. diff --git a/apps/web/content/docs/dev/i18n/server.mdx b/apps/web/content/docs/dev/i18n/server.mdx index d348eb484..52f7cb710 100644 --- a/apps/web/content/docs/dev/i18n/server.mdx +++ b/apps/web/content/docs/dev/i18n/server.mdx @@ -149,7 +149,25 @@ export default function WelcomeEmail({ i18n }: DefaultTemplateEmailProps) { A plugin owns its languages, and it splits them the same way the framework does: frontend strings in `src/locales/`, server strings (emails) in `src/locales/api/`. Each tree gets a barrel and is registered with the matching config - the frontend tree with `buildPlugin` in `config.tsx`, the server tree with `buildApiPlugin` in `config.api.ts`. -Most plugins render nothing server-side, so they ship only the frontend tree and register `messages` in `config.tsx` alone. Add the `api/` tree only when your plugin sends email: +Most plugins render nothing server-side, so they ship only the frontend tree. That one is registered twice, and the two halves are not interchangeable: + +```ts title="plugins/{your_plugin}/src/config.tsx" +import messages from './locales' + +export const yourPlugin = () => + buildPlugin({ + pluginId: CONFIG_PLUGIN.pluginId, + localeFiles: { + // [!code ++:2] + en: '@acme/your-plugin/locales/en.json', + }, + messages, + }) +``` + +`messages` is your own barrel, whose `import('./en.json')` is relative to your build output - right for anything running inside your package, and a specifier no host bundler can follow. `localeFiles` is the same list written as the subpaths your `package.json` exports, and it is what an app's translations are actually loaded from: the app's build turns it into literal imports in `src/package-messages.gen.ts`. Ship both, and keep them in step. + +Add the `api/` tree only when your plugin sends email: ```ts title="plugins/{your_plugin}/src/locales/api/index.ts" import type { LocaleMessagesMap } from '@vitnode/core/lib/i18n/types' @@ -172,7 +190,7 @@ export const yourApiPlugin = () => }) ``` -Adding a language later is a new file plus one line in the barrel - apps pick it up on their next install, and can translate your plugin without forking it by dropping a file in their own `src/locales/{your_plugin}/`. +Adding a language later is a new file plus one line in each list that names it - apps pick it up on their next install, and can translate your plugin without forking it by dropping a file in their own `src/locales/{your_plugin}/`. ## Typing the keys diff --git a/apps/web/content/docs/dev/plugins/api/modules.mdx b/apps/web/content/docs/dev/plugins/api/modules.mdx index 8563c15cb..3d3329966 100644 --- a/apps/web/content/docs/dev/plugins/api/modules.mdx +++ b/apps/web/content/docs/dev/plugins/api/modules.mdx @@ -10,6 +10,12 @@ Start with [a plugin](/docs/dev/plugins/create), not a host endpoint. A module groups the plugin's Hono routes under one URL prefix and gives OpenAPI a tidy place to describe them. + + `create-vitnode-app --plugin` writes a `hello` module, its `config.api.ts` and + a page that calls it. Read on for what each piece does—then rename them, or add + a second module beside them. + + {/* Image prompt: Dark-theme API ownership diagram. A Site notes plugin contains a Hono route, notes module, and config.api file; the app API configuration composes the plugin once. Show resulting GET endpoint, 1600x900. */} @@ -71,6 +77,10 @@ export const siteNotesApiPlugin = () => }) ``` +This file, and not `config.tsx`: the API config reaches your handlers, database +and secrets, while `config.tsx` is read by the browser build. A module +registered in the wrong one is shipped to every visitor. + diff --git a/apps/web/content/docs/dev/plugins/breadcrumbs.mdx b/apps/web/content/docs/dev/plugins/breadcrumbs.mdx deleted file mode 100644 index 528aa3f5f..000000000 --- a/apps/web/content/docs/dev/plugins/breadcrumbs.mdx +++ /dev/null @@ -1,118 +0,0 @@ ---- -title: Breadcrumbs -description: Contribute one localized crumb per plugin route and let VitNode assemble the trail for public pages and AdminCP screens. -icon: Milestone ---- - -VitNode renders a breadcrumb trail in both the AdminCP header and the public -site layout. **Every matched route contributes one crumb**, parent to child: - -```text -Home / Catalog / Products / Laptops / MacBook Pro -``` - -So a route says what it is called and nothing else. VitNode owns the separators, -the `nav` and `aria-current` semantics, and the locale-aware link to each -route's own URL—a plugin never builds a router link, and never restates the -crumbs of the layouts above it. - -## Quick start - -### A static, translated crumb - -Declare a component on `definePluginRoute`. It renders inside the message -namespaces the route declared, so `useTranslations` just works: - -```tsx title="plugins/catalog/src/pages/products-layout.tsx" -import { definePluginRoute } from '@vitnode/core/routing' -import { useTranslations } from 'use-intl' - -function ProductsBreadcrumb() { - const t = useTranslations('@acme/catalog') - - return t('breadcrumbs.products') -} - -// [!code ++:3] -export const route = definePluginRoute({ - breadcrumb: ProductsBreadcrumb, -}) -``` - -### A crumb read from the loader - -The crumb is handed **its own** match's data, so a dynamic route can name itself -with what it fetched—no second request, and no guessing from the URL: - -```tsx title="plugins/catalog/src/pages/category-layout.tsx" -import type { PluginRouteBreadcrumbProps } from '@vitnode/core/routing' -import { definePluginRoute } from '@vitnode/core/routing' - -interface Category { - name: string -} - -function CategoryBreadcrumb({ - loaderData, -}: PluginRouteBreadcrumbProps) { - return loaderData.name -} - -export const route = definePluginRoute({ - load: async ({ params }) => await fetchCategory(params.categorySlug), - breadcrumb: CategoryBreadcrumb, -}) -``` - -`PluginRouteBreadcrumbProps` carries `loaderData`, `params` and -`search`—the same three names the loader, `head` and the page component receive. - -### Leaving a route out - -A route that declares no `breadcrumb` contributes nothing, and its parents' crumbs -stay exactly where they were. `false` says the same thing on purpose, which is -worth doing when a page's frame already names the screen: - -```tsx title="plugins/catalog/src/pages/products-index-page.tsx" -export const route = definePluginRoute({ - breadcrumb: false, // [!code ++] -}) -``` - -## Rules - -| Declaration | What the trail does | -| ---------------------- | ------------------------------------------------------- | -| A component | One crumb, given this route's loader data and params | -| `false` | This route is left out; its parents' crumbs remain | -| Nothing at all | The same, said by omission | -| The last crumb | Rendered as the current page, not as a link | -| Every other crumb | A locale-aware link to that route's own URL | - - - An AdminCP screen's trail is named by the sidebar this administrator can - actually see, so a plugin that adds a nav entry gets its label for free—in - every language. Keep the route, the sidebar entry and the translations in the - same package. - - - - A crumb returns text or an element. Do not render a ``, a - separator, or a link: the shell draws the trail *above* the page outlet and - needs each crumb as one item so it can put them in one navigation landmark. - - -## Learn More - - - - - diff --git a/apps/web/content/docs/dev/plugins/create.mdx b/apps/web/content/docs/dev/plugins/create.mdx index 61bfdff00..45a75b045 100644 --- a/apps/web/content/docs/dev/plugins/create.mdx +++ b/apps/web/content/docs/dev/plugins/create.mdx @@ -4,7 +4,12 @@ description: Scaffold a VitNode plugin, register its package in your host app, a icon: PackagePlus --- -import { DatabaseIcon, LayoutDashboardIcon, RouteIcon } from 'lucide-react' +import { + BoxIcon, + DatabaseIcon, + LayoutDashboardIcon, + RouteIcon, +} from 'lucide-react' import { Tab, Tabs } from 'fumadocs-ui/components/tabs' A plugin is the starting point for a VitNode feature. It keeps routes, API @@ -41,60 +46,10 @@ npm create vitnode-app@canary -- --plugin -The CLI creates `plugins/site-notes`, adds it as a workspace dependency, and -gives it a route, locale, and config skeleton. It does **not** enable the -feature for the host—that explicit switch is next. - - - - -### Keep the route in the plugin - -The generated `routes.ts` is the public contract. Add another `page()` here when -the plugin needs another URL; never copy its page into `apps/web/src/routes`. - -```ts title="plugins/site-notes/src/routes.ts" -import { definePluginRoutes, lazy, page } from '@vitnode/core/routing' - -export const routes = definePluginRoutes([ - // [!code ++:3] - page('/site-notes', { - component: lazy(() => import('./pages/home-page')), - }), -]) -``` - - - - -### Register the plugin with the host - -Import the plugin factory in the host config and add it to `plugins`: - -```ts title="apps/web/src/vitnode.config.ts" -import { siteNotesPlugin } from '@acme/site-notes/config' // [!code ++] -import { buildConfig } from '@vitnode/core/vitnode.config' - -export const vitNodeConfig = buildConfig({ - plugins: [ - siteNotesPlugin(), // [!code ++] - ], -}) -``` - -That is the only composition step - the factory carries the plugin's routes, -content types and AdminCP navigation, and the feature stays in its package. Your -build reads this list and generates one literal import per plugin for each of -those, so a page or an editing screen loads with the route that needs it rather -than with the config. See [Configuration](/docs/dev/configuration). - -Its translations need one more line, in `src/locales/packages.ts` - see -[Languages & Localization](/docs/dev/i18n). - -### Run it and visit the route +### Run/Restart it and visit the route @@ -112,10 +67,20 @@ npm run dev -Open `http://localhost:3000/site-notes`. The page comes from the plugin, gets -its own chunk, and never moves house. Tiny victory dance optional. + + + +### Check the generated page + +Visit the route printed in the console, such as `http://localhost:3000/site-notes`. You should see a page with a title and a link to the plugin's README. -{/* Image prompt: Split-screen developer tutorial image. Left shows a plugin folder with routes.ts, locale, and pages files. Right shows the resulting /site-notes page in a VitNode app. Dark theme, precise code-like labels, 1600x900. */} +import { ImgDocs } from '@/components/fumadocs/img' +import generatedPageImage from './generated-page-image.png' + + @@ -129,6 +94,12 @@ its own chunk, and never moves house. Tiny victory dance optional. description="Add nested layouts, dynamic URLs, loaders, metadata, and guards." href="/docs/dev/plugins/routes" /> + } + title="API modules" + description="Add another endpoint beside the generated one, with Zod-validated inputs." + href="/docs/dev/plugins/api/modules" + /> } title="Database models" diff --git a/apps/web/content/docs/dev/plugins/generated-page-image.png b/apps/web/content/docs/dev/plugins/generated-page-image.png new file mode 100644 index 000000000..b9c7d213c Binary files /dev/null and b/apps/web/content/docs/dev/plugins/generated-page-image.png differ diff --git a/apps/web/content/docs/dev/plugins/meta.json b/apps/web/content/docs/dev/plugins/meta.json index f07d9e1fc..abc2f98b3 100644 --- a/apps/web/content/docs/dev/plugins/meta.json +++ b/apps/web/content/docs/dev/plugins/meta.json @@ -3,5 +3,5 @@ "description": "Build installable VitNode plugins for pages, APIs, data, AdminCP screens, and translations", "icon": "Plug", "defaultOpen": true, - "pages": ["create", "routes", "api", "admin", "breadcrumbs", "..."] + "pages": ["create", "routes", "breadcrumbs", "api", "admin", "..."] } diff --git a/apps/web/content/docs/dev/plugins/routes.mdx b/apps/web/content/docs/dev/plugins/routes.mdx deleted file mode 100644 index 1340aa445..000000000 --- a/apps/web/content/docs/dev/plugins/routes.mdx +++ /dev/null @@ -1,435 +0,0 @@ ---- -title: Plugin Routes -description: Declare plugin-owned URLs as a nested route tree with lazy pages, loaders, metadata, messages, and breadcrumbs. -icon: Map ---- - -import { DatabaseIcon, LayoutDashboardIcon, MilestoneIcon } from 'lucide-react' -import { Tab, Tabs } from 'fumadocs-ui/components/tabs' - -Start by [creating a plugin](/docs/dev/plugins/create). A plugin's `src/routes.ts` -is its promise to the app: which URLs it owns, and which module renders each one. -The host turns that promise into lazy TanStack Start routes—no copied page files, -no drama. - -{/* Image prompt: Dark-theme developer diagram: a plugin routes.ts tree (layout → index → dynamic page) on the left, each node pointing at a lazily loaded page chunk on the right, then into a TanStack Start route inside the app shell. Emphasize “plugin owns feature”, “one chunk per page”, “host composes”. Clean labels, 1600x900. */} - - - - -### Declare the URL in the plugin - -```ts title="plugins/site-notes/src/routes.ts" -import { definePluginRoutes, lazy, page } from '@vitnode/core/routing' - -export const routes = definePluginRoutes([ - // [!code ++:3] - page('/notes/:slug', { - component: lazy(() => import('./pages/note-page')), - }), -]) -``` - -Use `:slug` for dynamic segments. VitNode converts it to TanStack Start's -internal `$slug` spelling while keeping your plugin portable. - - - - -### Keep behavior beside the page - -```tsx title="plugins/site-notes/src/pages/note-page.tsx" -import type { PluginRoutePageProps } from '@vitnode/core/routing' -import { definePluginRoute } from '@vitnode/core/routing' - -interface Note { - title: string -} - -// [!code ++:8] -export const route = definePluginRoute({ - load: async ({ params }) => ({ title: `Note: ${params.slug}` }), - head: ({ loaderData }) => ({ - description: 'A note delivered by the Site notes plugin.', - title: loaderData?.title, - }), -}) - -const NotePage = ({ loaderData }: PluginRoutePageProps) => ( -
-

{loaderData.title}

-
-) - -export default NotePage -``` - -
- - -### Run the plugin route - - - -```bash tab="bun" -bun dev -``` - -```bash tab="pnpm" -pnpm dev -``` - -```bash tab="npm" -npm run dev -``` - - - -Visit `http://localhost:3000/notes/hello`. The page's code, data, and SEO stay -with the feature that needs them. A surprisingly polite route. - - -
- -## What `lazy(() => import('./pages/note-page'))` means - -It names the module VitNode loads **when the route is needed**—on a navigation, -or a moment earlier when the visitor hovers a link and the router preloads it. - -Nothing about that import runs while your app boots. `lazy` stores the callback; -Vite reads the literal `import()` inside it at build time and Rollup gives that -page a chunk of its own. So `routes.ts` stays a few lines of data the app can -hold cheaply, and a visitor downloads a page only if they open it. - - - Importing the component at the top of `routes.ts` would put it in the initial - bundle of *every* page on the site, and route-level splitting would be gone. - VitNode refuses it in the types and again at build time, with the replacement - in the message: - -```ts -import NotePage from './pages/note-page' - -page('/notes/:slug', { - component: NotePage, // [!code --] - component: lazy(() => import('./pages/note-page')), // [!code ++] -}) -``` - - - -Keep the `import()` literal. A specifier built from a variable is not something -a bundler can follow, so the page never gets a chunk and the build cannot tell -you the module is missing: - -```ts -page('/notes/:slug', { - component: lazy(() => import(`./pages/${slug}-page`)), // [!code --] - component: lazy(() => import('./pages/note-page')), // [!code ++] -}) -``` - -## Nest routes with `layout()` and `index()` - -A `layout()` renders a frame around its `children` and claims no URL of its own. -`index()` is the route that renders at the layout's own URL. Every path inside a -layout is **relative** to it, so moving a subtree is one edit: - -```ts title="plugins/catalog/src/routes.ts" -import { - definePluginRoutes, - index, - layout, - lazy, - page, -} from '@vitnode/core/routing' - -export const routes = definePluginRoutes([ - layout('/catalog', { - component: lazy(() => import('./pages/catalog-layout')), - messages: ['@acme/catalog'], - children: [ - page('dashboard', { - component: lazy(() => import('./pages/dashboard-page')), - }), - - layout('products', { - component: lazy(() => import('./pages/products-layout')), - children: [ - index({ - component: lazy(() => import('./pages/products-index-page')), - }), - - layout(':categorySlug', { - component: lazy(() => import('./pages/category-layout')), - children: [ - index({ - component: lazy(() => import('./pages/category-index-page')), - }), - - page(':productId', { - component: lazy(() => import('./pages/product-page')), - }), - ], - }), - ], - }), - ], - }), -]) -``` - -That tree serves `/catalog/dashboard`, `/catalog/products`, -`/catalog/products/laptops` and `/catalog/products/laptops/42`, and a page opens -inside every frame above it. - -| Rule | What VitNode does | -| ------------------------ | ------------------------------------------------------------- | -| Top-level path | Absolute: `page('/catalog', …)` | -| Nested path | Relative: `page('dashboard', …)` joins onto its parent | -| `index()` | The child at the layout's exact URL—no path of its own | -| Layout with no `children`| A build error: nothing could ever render it | -| Route ids | Derived by VitNode while flattening. You never write one | - -A layout's frame is a component with `children`: - -```tsx title="plugins/catalog/src/pages/catalog-layout.tsx" -const CatalogLayout = ({ children }: { children: React.ReactNode }) => ( -
-

Catalog

- {children} -
-) - -export default CatalogLayout -``` - -`children`, not an ``: a plugin layout that imported a router's outlet -could only be installed into one kind of app. - -## Choose the route shape - -| Need | Add to the tree | -| -------------------- | -------------------------------------------------------------- | -| Public feature page | `page('/notes', { component })`—`area` defaults to `main` | -| Staff screen | `area: 'admin'` and a full path such as `/admin/notes` | -| Signed-in visitor | `requires: 'authenticated'` | -| Shared frame | `layout()` with `children` | -| Translated strings | `messages: ['@acme/catalog']` | -| URL-as-state | `search: productsSearchSchema` | - -## An AdminCP route - -`area: 'admin'` picks the shell—the sidebar, the breadcrumb area, the command -palette, and the admin session guard. It never changes the path, so write the -`/admin/…` URL in full: - -```ts title="plugins/site-notes/src/routes.ts" -page('/admin/notes', { - area: 'admin', // [!code ++] - component: lazy(() => import('./pages/admin-notes-page')), - messages: ['@acme/site-notes.admin'], -}) -``` - -`area` belongs to top-level routes only. Everything inside a layout renders in -the shell that layout renders in, and `requires` is refused in the admin -area—the AdminCP has its own session, and a staff permission gates the page's -*content*. See [AdminCP pages](/docs/dev/plugins/admin). - -## Route messages - -`messages` lists the translation namespaces the route renders. VitNode warms -them **alongside** the page's chunk instead of after it, which is the whole -reason they are declared on the route rather than inside the module: - -```ts -layout('/catalog', { - component: lazy(() => import('./pages/catalog-layout')), - messages: ['@acme/catalog'], // [!code ++] - children: [index({ component: lazy(() => import('./pages/index-page')) })], -}) -``` - -A route inherits every namespace its layouts declare, so naming them once on the -frame is enough for the whole subtree. Inside the module, read them with -`use-intl`: - -```tsx -import { useTranslations } from 'use-intl' - -const CatalogIndexPage = () => { - const t = useTranslations('@acme/catalog') - - return

{t('index.intro')}

-} -``` - -See [namespaces](/docs/dev/i18n/namespaces) for how a namespace is named and -where its JSON lives. - -## `search` is the one eager field - -TanStack Router validates a URL's query string **while it matches the URL**, -before any chunk is fetched. A schema inside the lazy page module would arrive -too late, so a route declares it in `routes.ts`: - -```ts title="plugins/catalog/src/routes.ts" -import { productsSearchSchema } from './pages/products-search' - -page('/catalog/products', { - component: lazy(() => import('./pages/products-page')), - search: productsSearchSchema, // [!code ++] -}) -``` - -```ts title="plugins/catalog/src/pages/products-search.ts" -export interface ProductsSearch { - page: number -} - -export const productsSearchSchema = ( - input: Record, -): ProductsSearch => { - const parsed = Number.parseInt(String(input.page ?? ''), 10) - - // Total, never throwing: the router calls this on whatever somebody pasted. - return { page: Number.isFinite(parsed) ? Math.max(parsed, 1) : 1 } -} -``` - -The page then gets a typed `search` and a `navigate` that changes it: - -```tsx title="plugins/catalog/src/pages/products-page.tsx" -import type { PluginRoutePageProps } from '@vitnode/core/routing' - -import type { ProductsSearch } from './products-search' - -const ProductsPage = ({ - navigate, - search, -}: PluginRoutePageProps) => ( - -) - -export default ProductsPage -``` - -TypeScript checks the two halves against each other: the schema has to return -what the page says it reads, even though the page itself is lazy. - - - `search` is a function, so it lives in `routes.ts`—which the app imports - statically. Everything that file imports is in the initial bundle with it, so - keep the schema module small: no React, no component, no import of the page it - belongs to. - - Declare it only for a screen whose URL *is* its state—a paginated list whose - `?page=999` has to be clamped, a filter whose links must be typed. For a page - that merely reads a parameter, use the module's own lazy `parseSearch` - instead; it normalises in the loader and adds nothing to the initial bundle. - - -## Dynamic breadcrumbs - -Every matched route contributes **one crumb**, parent to child, and VitNode owns -the separators, the accessibility semantics, and the locale-aware links. A crumb -returns a label: - -```tsx title="plugins/catalog/src/pages/product-page.tsx" -import type { - PluginRouteBreadcrumbProps, - PluginRoutePageProps, -} from '@vitnode/core/routing' -import { definePluginRoute } from '@vitnode/core/routing' - -interface Product { - description: string - name: string -} - -function ProductBreadcrumb({ loaderData }: PluginRouteBreadcrumbProps) { - return loaderData.name -} - -export const route = definePluginRoute({ - load: async ({ params }) => - await fetchProduct({ - categorySlug: params.categorySlug, - productId: params.productId, - }), - - head: ({ loaderData }) => ({ - description: loaderData?.description, - title: loaderData?.name, - }), - - breadcrumb: ProductBreadcrumb, -}) - -export default function ProductPage({ - loaderData, -}: PluginRoutePageProps) { - return ( -
-

{loaderData.name}

-

{loaderData.description}

-
- ) -} -``` - -With the catalog tree above, that renders `Catalog / Products / Laptops / -MacBook Pro`—each crumb from the route that owns it. See -[breadcrumbs](/docs/dev/plugins/breadcrumbs) for static crumbs, `breadcrumb: -false`, and how the trail is assembled. - - - Use host routes only for shells, docs, or site-wide infrastructure. A product - page belongs in its plugin, even when it starts life as one brave little URL. - - -## How the app picks this up - -The `vitnode:plugin-routes` Vite plugin reads the plugins in -`src/vitnode.config.ts`, imports each one's `routes` module in Node, validates -and flattens every tree, refuses two routes that claim one URL—including one of -the app's own—and writes a single `src/plugin-routes.gen.ts`: - -```ts title="apps/web/src/plugin-routes.gen.ts" -import { routes as pluginRoutes0 } from '@acme/catalog/routes' - -export const pluginRouteSources = [ - { pluginId: '@acme/catalog', routes: pluginRoutes0 }, -] as const satisfies readonly PluginRouteDeclarationSource[] -``` - -That is the only generated file, it names no page module, and it is committed -like any other generated artefact. Your pages stay in your package's own -`dist`, one chunk each. - - - } - title="Load data" - description="Use plugin loaders with cache-aware data and query state." - href="/docs/dev/data-loading" - /> - } - title="Breadcrumbs" - description="Contribute one crumb per route, translated or read from a loader." - href="/docs/dev/plugins/breadcrumbs" - /> - } - title="AdminCP pages" - description="Mount a plugin screen in the staff-only Admin Control Panel." - href="/docs/dev/plugins/admin" - /> - diff --git a/apps/web/content/docs/dev/routing/breadcrumbs.mdx b/apps/web/content/docs/dev/routing/breadcrumbs.mdx new file mode 100644 index 000000000..eedbcae2d --- /dev/null +++ b/apps/web/content/docs/dev/routing/breadcrumbs.mdx @@ -0,0 +1,168 @@ +--- +title: Breadcrumbs +description: Contribute one localized crumb per plugin route and let VitNode assemble the trail for public pages and AdminCP screens. +icon: Milestone +--- + +import { DatabaseIcon, LayoutDashboardIcon, MapIcon } from 'lucide-react' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +VitNode renders breadcrumb trails automatically in both the public site header and the AdminCP shell. **A route joins the trail by declaring a crumb**, and the trail reads parent to child: + +```text +Home / Notes / Getting Started +``` + +A route declares only what its own crumb should display. VitNode manages the rest: accessible `