From b3afc8efb6a638cef03ed1480492464604c3b5c9 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Tue, 15 Sep 2026 00:07:17 +0200 Subject: [PATCH 1/4] refactor: Move routers to file routers.tsx in core --- .../content/docs/dev/routing/breadcrumbs.mdx | 11 +- apps/web/content/docs/dev/routing/meta.json | 9 +- .../content/docs/dev/routing/not-found.mdx | 19 +- apps/web/content/docs/dev/routing/routes.mdx | 306 +++++++++++++++++- apps/web/src/plugin-routes.gen.ts | 26 +- apps/web/src/router.tsx | 44 ++- .../copy-of-vitnode-app/root/src/router.tsx | 28 +- .../root/src/routes/_admin.tsx | 2 +- .../src/framework/plugin-routes/core.test.ts | 48 +++ .../src/framework/plugin-routes/core.ts | 12 + .../framework/plugin-routes/generate.test.ts | 17 +- .../src/framework/plugin-routes/generate.ts | 17 +- .../framework/plugin-routes/host-routes.ts | 2 +- .../src/framework/plugin-routes/index.ts | 1 + .../src/framework/plugin-routes/resolve.ts | 11 + .../src/framework/vite/plugin-routes.ts | 17 +- .../vitnode/src/pages/admin/advanced/cron.tsx | 32 ++ .../src/pages/admin/advanced/queue.tsx | 32 ++ .../src/pages/admin/advanced/search.tsx | 34 ++ packages/vitnode/src/pages/admin/content.tsx | 92 ++++++ packages/vitnode/src/pages/admin/debug.tsx | 32 ++ packages/vitnode/src/pages/admin/sign-in.tsx | 21 ++ .../src/pages/admin/staff/admins/create.tsx | 33 ++ .../src/pages/admin/staff/admins/edit.tsx | 38 +++ .../src/pages/admin/staff/admins/index.tsx | 38 +++ .../pages/admin/staff/moderators/create.tsx | 33 ++ .../src/pages/admin/staff/moderators/edit.tsx | 38 +++ .../pages/admin/staff/moderators/index.tsx | 38 +++ .../vitnode/src/pages/admin/system/files.tsx | 35 ++ .../src/pages/admin/system/integrations.tsx | 28 ++ .../vitnode/src/pages/admin/users/index.tsx | 32 ++ .../vitnode/src/pages/admin/users/roles.tsx | 32 ++ .../vitnode/src/pages/admin/users/user.tsx | 29 ++ packages/vitnode/src/pages/discover.tsx | 22 ++ packages/vitnode/src/pages/files.tsx | 33 ++ packages/vitnode/src/pages/login/index.tsx | 22 ++ .../src/pages/login/reset-password.tsx | 65 ++++ packages/vitnode/src/pages/login/sso.tsx | 26 ++ packages/vitnode/src/pages/register.tsx | 15 + packages/vitnode/src/pages/search.tsx | 23 ++ .../vitnode/src/pages/settings/devices.tsx | 34 ++ packages/vitnode/src/pages/settings/index.tsx | 35 ++ .../vitnode/src/pages/settings/layout.tsx | 16 + .../vitnode/src/pages/settings/security.tsx | 14 + packages/vitnode/src/pages/users/profile.tsx | 27 ++ packages/vitnode/src/routes.test.ts | 142 ++++++++ packages/vitnode/src/routes.tsx | 284 ++++++++++++++++ .../vitnode/src/routing/authoring.test-d.ts | 12 +- packages/vitnode/src/routing/authoring.ts | 25 +- packages/vitnode/src/routing/errors.ts | 1 + packages/vitnode/src/routing/flatten.ts | 45 +++ packages/vitnode/src/routing/index.ts | 5 + packages/vitnode/src/routing/manifest.test.ts | 40 +++ packages/vitnode/src/routing/manifest.ts | 15 +- packages/vitnode/src/routing/module.test.ts | 46 ++- packages/vitnode/src/routing/module.ts | 167 ++++++++-- packages/vitnode/src/routing/order.ts | 31 +- packages/vitnode/src/routing/path.test.ts | 44 ++- packages/vitnode/src/routing/path.ts | 125 ++++--- packages/vitnode/src/routing/tree.test.ts | 28 ++ packages/vitnode/src/routing/tree.ts | 21 ++ packages/vitnode/src/routing/types.ts | 15 +- .../vitnode/src/tanstack/admin/breadcrumb.tsx | 13 +- .../admin/content/registry-runtime.ts | 40 +++ .../vitnode/src/tanstack/admin/cron/route.tsx | 32 +- .../src/tanstack/admin/debug/route.tsx | 35 +- .../src/tanstack/admin/files/route.tsx | 32 +- packages/vitnode/src/tanstack/admin/index.ts | 6 +- .../src/tanstack/admin/integrations/route.tsx | 37 +-- .../src/tanstack/admin/queue/route.tsx | 32 +- .../src/tanstack/admin/roles/route.tsx | 39 +-- .../src/tanstack/admin/search-index/route.tsx | 37 +-- .../src/tanstack/admin/sign-in-route.tsx | 30 -- .../src/tanstack/admin/sign-in-search.ts | 21 ++ .../src/tanstack/admin/staff/create-route.tsx | 39 +-- .../src/tanstack/admin/staff/edit-route.tsx | 23 +- .../src/tanstack/admin/staff/navigation.ts | 19 ++ .../src/tanstack/admin/staff/route.tsx | 42 +-- .../src/tanstack/admin/users/detail-route.tsx | 28 +- .../src/tanstack/admin/users/route.tsx | 39 +-- packages/vitnode/src/tanstack/auth/index.ts | 2 +- .../vitnode/src/tanstack/auth/login-route.tsx | 60 +--- .../src/tanstack/auth/recovery-route.tsx | 23 +- .../vitnode/src/tanstack/auth/recovery.ts | 9 +- .../src/tanstack/auth/register-route.tsx | 9 +- .../vitnode/src/tanstack/auth/sso-route.tsx | 24 +- packages/vitnode/src/tanstack/files/route.tsx | 35 +- .../src/tanstack/plugin-routes/authoring.ts | 98 ++++++ .../src/tanstack/plugin-routes/components.tsx | 33 +- .../src/tanstack/plugin-routes/guard.ts | 27 ++ .../src/tanstack/plugin-routes/index.ts | 20 +- .../plugin-routes/mount-freshness.test.ts | 30 +- .../src/tanstack/plugin-routes/mount.tsx | 113 ++++++- .../plugin-routes/plugin-routes.test.ts | 183 +++++++++-- .../src/tanstack/plugin-routes/specs.ts | 8 +- .../tanstack/plugin-routes/translator.test.ts | 96 ++++++ .../src/tanstack/plugin-routes/translator.ts | 62 ++++ .../src/tanstack/profile/route.test.ts | 36 ++- .../vitnode/src/tanstack/profile/route.ts | 32 +- .../routes/admin/admin-routes.test.ts | 109 ------- .../src/tanstack/routes/admin/advanced.tsx | 192 ----------- .../src/tanstack/routes/admin/content.tsx | 103 ------ .../src/tanstack/routes/admin/index.tsx | 66 ---- .../src/tanstack/routes/admin/staff.tsx | 197 ----------- .../src/tanstack/routes/admin/system.tsx | 177 ---------- .../src/tanstack/routes/admin/users.tsx | 177 ---------- packages/vitnode/src/tanstack/routes/index.ts | 17 - .../vitnode/src/tanstack/routes/main/auth.tsx | 216 ------------- .../src/tanstack/routes/main/discovery.tsx | 79 ----- .../src/tanstack/routes/main/files.tsx | 68 ---- .../src/tanstack/routes/main/index.tsx | 122 ------- .../src/tanstack/routes/main/profile.tsx | 53 --- .../src/tanstack/routes/main/settings.tsx | 181 ----------- .../vitnode/src/tanstack/routes/main/sso.tsx | 42 --- .../tanstack/routes/root/admin-sign-in.tsx | 81 ----- .../src/tanstack/routes/root/index.tsx | 47 --- .../tanstack/routes/root/root-routes.test.ts | 92 ------ .../vitnode/src/tanstack/routes/root/types.ts | 6 - packages/vitnode/src/tanstack/routes/types.ts | 41 --- .../src/tanstack/search/discover-route.tsx | 37 +-- .../vitnode/src/tanstack/search/namespaces.ts | 10 + .../src/tanstack/search/search-route.tsx | 39 +-- .../src/tanstack/settings/breadcrumb.tsx | 20 ++ .../vitnode/src/tanstack/settings/route.ts | 129 -------- plugins/example/src/pages/browse-page.tsx | 10 +- 125 files changed, 3345 insertions(+), 2975 deletions(-) create mode 100644 packages/vitnode/src/framework/plugin-routes/core.test.ts create mode 100644 packages/vitnode/src/framework/plugin-routes/core.ts create mode 100644 packages/vitnode/src/pages/admin/advanced/cron.tsx create mode 100644 packages/vitnode/src/pages/admin/advanced/queue.tsx create mode 100644 packages/vitnode/src/pages/admin/advanced/search.tsx create mode 100644 packages/vitnode/src/pages/admin/content.tsx create mode 100644 packages/vitnode/src/pages/admin/debug.tsx create mode 100644 packages/vitnode/src/pages/admin/sign-in.tsx create mode 100644 packages/vitnode/src/pages/admin/staff/admins/create.tsx create mode 100644 packages/vitnode/src/pages/admin/staff/admins/edit.tsx create mode 100644 packages/vitnode/src/pages/admin/staff/admins/index.tsx create mode 100644 packages/vitnode/src/pages/admin/staff/moderators/create.tsx create mode 100644 packages/vitnode/src/pages/admin/staff/moderators/edit.tsx create mode 100644 packages/vitnode/src/pages/admin/staff/moderators/index.tsx create mode 100644 packages/vitnode/src/pages/admin/system/files.tsx create mode 100644 packages/vitnode/src/pages/admin/system/integrations.tsx create mode 100644 packages/vitnode/src/pages/admin/users/index.tsx create mode 100644 packages/vitnode/src/pages/admin/users/roles.tsx create mode 100644 packages/vitnode/src/pages/admin/users/user.tsx create mode 100644 packages/vitnode/src/pages/discover.tsx create mode 100644 packages/vitnode/src/pages/files.tsx create mode 100644 packages/vitnode/src/pages/login/index.tsx create mode 100644 packages/vitnode/src/pages/login/reset-password.tsx create mode 100644 packages/vitnode/src/pages/login/sso.tsx create mode 100644 packages/vitnode/src/pages/register.tsx create mode 100644 packages/vitnode/src/pages/search.tsx create mode 100644 packages/vitnode/src/pages/settings/devices.tsx create mode 100644 packages/vitnode/src/pages/settings/index.tsx create mode 100644 packages/vitnode/src/pages/settings/layout.tsx create mode 100644 packages/vitnode/src/pages/settings/security.tsx create mode 100644 packages/vitnode/src/pages/users/profile.tsx create mode 100644 packages/vitnode/src/routes.test.ts create mode 100644 packages/vitnode/src/routes.tsx create mode 100644 packages/vitnode/src/tanstack/admin/content/registry-runtime.ts create mode 100644 packages/vitnode/src/tanstack/admin/sign-in-search.ts create mode 100644 packages/vitnode/src/tanstack/admin/staff/navigation.ts create mode 100644 packages/vitnode/src/tanstack/plugin-routes/authoring.ts create mode 100644 packages/vitnode/src/tanstack/plugin-routes/translator.test.ts create mode 100644 packages/vitnode/src/tanstack/plugin-routes/translator.ts delete mode 100644 packages/vitnode/src/tanstack/routes/admin/admin-routes.test.ts delete mode 100644 packages/vitnode/src/tanstack/routes/admin/advanced.tsx delete mode 100644 packages/vitnode/src/tanstack/routes/admin/content.tsx delete mode 100644 packages/vitnode/src/tanstack/routes/admin/index.tsx delete mode 100644 packages/vitnode/src/tanstack/routes/admin/staff.tsx delete mode 100644 packages/vitnode/src/tanstack/routes/admin/system.tsx delete mode 100644 packages/vitnode/src/tanstack/routes/admin/users.tsx delete mode 100644 packages/vitnode/src/tanstack/routes/index.ts delete mode 100644 packages/vitnode/src/tanstack/routes/main/auth.tsx delete mode 100644 packages/vitnode/src/tanstack/routes/main/discovery.tsx delete mode 100644 packages/vitnode/src/tanstack/routes/main/files.tsx delete mode 100644 packages/vitnode/src/tanstack/routes/main/index.tsx delete mode 100644 packages/vitnode/src/tanstack/routes/main/profile.tsx delete mode 100644 packages/vitnode/src/tanstack/routes/main/settings.tsx delete mode 100644 packages/vitnode/src/tanstack/routes/main/sso.tsx delete mode 100644 packages/vitnode/src/tanstack/routes/root/admin-sign-in.tsx delete mode 100644 packages/vitnode/src/tanstack/routes/root/index.tsx delete mode 100644 packages/vitnode/src/tanstack/routes/root/root-routes.test.ts delete mode 100644 packages/vitnode/src/tanstack/routes/root/types.ts delete mode 100644 packages/vitnode/src/tanstack/routes/types.ts create mode 100644 packages/vitnode/src/tanstack/search/namespaces.ts create mode 100644 packages/vitnode/src/tanstack/settings/breadcrumb.tsx diff --git a/apps/web/content/docs/dev/routing/breadcrumbs.mdx b/apps/web/content/docs/dev/routing/breadcrumbs.mdx index 7f0f68de7..a72dcdd81 100644 --- a/apps/web/content/docs/dev/routing/breadcrumbs.mdx +++ b/apps/web/content/docs/dev/routing/breadcrumbs.mdx @@ -26,7 +26,7 @@ import { useTranslations } from 'use-intl' // [!code ++:7] export const route = definePluginRoute({ breadcrumb: () => { - const t = useTranslations('@acme/site-notes') + const t = useTranslations('@acme/site-notes.home') return t('title') }, @@ -59,6 +59,7 @@ import { definePluginRoute, type PluginRoutePageProps, } from '@vitnode/core/routing' +import { useTranslations } from 'use-intl' interface Note { content: string @@ -75,8 +76,12 @@ export const route = definePluginRoute({ head: ({ loaderData, params }) => ({ title: loaderData?.title ?? params.slug, }), - // [!code ++:1] - breadcrumb: ({ loaderData }) => loaderData.title, + // [!code ++:5] + breadcrumb: ({ loaderData }) => { + const t = useTranslations('@acme/site-notes.home') + + return `${t('note')}: ${loaderData.title}` + }, }) const NotePage = ({ loaderData }: PluginRoutePageProps) => { diff --git a/apps/web/content/docs/dev/routing/meta.json b/apps/web/content/docs/dev/routing/meta.json index 90c70a10b..b505c32eb 100644 --- a/apps/web/content/docs/dev/routing/meta.json +++ b/apps/web/content/docs/dev/routing/meta.json @@ -2,12 +2,5 @@ "title": "Routing", "description": "Claim a URL, name the page, and let the router handle locales", "icon": "Route", - "pages": [ - "routes", - "navigation", - "metadata", - "breadcrumbs", - "loading", - "not-found" - ] + "pages": ["routes", "navigation", "breadcrumbs", "loading", "not-found"] } diff --git a/apps/web/content/docs/dev/routing/not-found.mdx b/apps/web/content/docs/dev/routing/not-found.mdx index aacab14bd..f52af09bc 100644 --- a/apps/web/content/docs/dev/routing/not-found.mdx +++ b/apps/web/content/docs/dev/routing/not-found.mdx @@ -37,16 +37,23 @@ When thrown, TanStack Router catches the signal and halts page execution, render ### Unmatched URLs keep the main shell -When a visitor navigates to a non-existent URL, `withCoreMainRoutes` catches the route **inside the main shell** (`_main.tsx`). +When a visitor navigates to a non-existent URL, the main shell (`_main.tsx`) is +still matched, so the route is caught **inside** it. This ensures visitors never hit a blank, unstyled screen: they retain the main site header, navigation bar, and theme switcher, while search crawlers receive a real HTTP `404` status code: ```tsx title="apps/web/src/router.tsx" -const routeTree = withCoreMainRoutes(fileRouteTree, { - localeRouting, - mountUnder: mainShellRoute, - pageHead, -}) +const routeTree = withVitNodeRoutes( + fileRouteTree, + pluginRouteSpecs(pluginRouteSources), + { + mountUnder: { + admin: adminShellRoute, + blank: fileRouteTree, + main: mainShellRoute, + }, + }, +) ``` {/* Image prompt: VitNode 404 error page displayed inside the main site layout with header, 404 heading, localized description, and "Go back" / "Back to home" action buttons in dark theme, 1440x900. */} diff --git a/apps/web/content/docs/dev/routing/routes.mdx b/apps/web/content/docs/dev/routing/routes.mdx index 964a8a0cd..86718d296 100644 --- a/apps/web/content/docs/dev/routing/routes.mdx +++ b/apps/web/content/docs/dev/routing/routes.mdx @@ -4,7 +4,14 @@ description: Declare plugin-owned URLs as a nested route tree with lazy pages, l icon: Map --- -import { DatabaseIcon, LayoutDashboardIcon, MilestoneIcon } from 'lucide-react' +import { + DatabaseIcon, + LanguagesIcon, + LayoutDashboardIcon, + LoaderCircleIcon, + MilestoneIcon, + TriangleAlertIcon, +} from 'lucide-react' import { Tab, Tabs } from 'fumadocs-ui/components/tabs' import { ImgDocs } from '@/components/fumadocs/img' import generatedPageImage from './generated-page-image.png' @@ -24,16 +31,21 @@ import { definePluginRoutes, lazy, page } from '@vitnode/core/routing' export const routes = definePluginRoutes([ page('/notes', { component: lazy(() => import('./pages/note-page')), + messages: ['@acme/site-notes.home'], }), ]) ``` ```tsx title="plugins/site-notes/src/pages/note-page.tsx" +import { useTranslations } from 'use-intl' + const NotePage = () => { + const t = useTranslations('@acme/site-notes.home') + return (

- A note delivered by the Site notes plugin + {t('title')}

) @@ -42,6 +54,8 @@ const NotePage = () => { export default NotePage ``` +The `messages` array specifies which translation namespaces this page requires. A namespace must always be a **dotted path** into your plugin's message tree (such as `@acme/site-notes.home` rather than the bare plugin ID), allowing VitNode to only download the strings that this screen renders. For more details on message structure and limits, see the [Namespaces](/docs/dev/i18n/namespaces) and [Translating Pages](/docs/dev/i18n/pages) guides. + { export default NotePage ``` +### Localizing metadata + +`head` runs outside the React component tree—during SSR and on every +navigation—so hooks like `useTranslations` cannot reach it. Instead, `head` +receives `t`, a translator over the namespaces the route declared in `messages`: + +```ts title="plugins/site-notes/src/routes.ts" +page('/notes/:slug', { + component: lazy(() => import('./pages/note-page-slug')), + // [!code ++:1] + messages: ['@acme/site-notes.home'], +}) +``` + +```tsx title="plugins/site-notes/src/pages/note-page-slug.tsx" +import { definePluginRoute } from '@vitnode/core/routing' + +export const route = definePluginRoute({ + // [!code ++:4] + head: ({ params, t }) => ({ + description: t('@acme/site-notes.home.desc'), + title: `${t('@acme/site-notes.home.note')}: ${params.slug}`, + }), +}) +``` + +Keys are **full dotted paths**, namespace included. A component scopes itself +once with `useTranslations('@acme/site-notes.home')` and then asks for +`t('title')`; in `head` there is no component to scope, so the whole key is +spelled out. + +Values interpolate the same way they do in a component: + +```tsx +head: ({ t }) => ({ + title: t('@acme/site-notes.home.greeting', { name: 'Ada' }), +}) +``` + + + The route's namespaces are already fetched before `head` runs—the loader warms + them, and `head` runs after the loader—so translating metadata costs a cache + read, not a round trip. + + +`load` receives the same `t`, for a title that depends on data you are already +fetching: + +```tsx title="plugins/site-notes/src/pages/note-page-slug.tsx" +export const route = definePluginRoute({ + load: async ({ params, t }) => { + const note = await findNote(params.slug) + + return { heading: `${t('@acme/site-notes.home.note')}: ${note.title}` } + }, + head: ({ loaderData }) => ({ title: loaderData?.heading }), +}) +``` + + + Calling `t` on a route that declares no `messages` throws, naming the + namespace to add. Echoing the key back instead would ship + `@acme/site-notes.home.title` into a ``, where nothing would surface it + until it showed up in a search result. +</Callout> + ## Loaders Fetch data in `load`. It runs on the server during SSR and on the client for subsequent navigations. Data returned from `load` is automatically passed to both `head` (for dynamic metadata) and the page component as `loaderData`. @@ -201,6 +281,186 @@ export default NotePage {/* Image prompt: Browser preview showing the rendered note page with title and content in container, 1440x900. */} +## Breadcrumbs + +Declare a crumb directly in `definePluginRoute` to contribute to the public header or AdminCP breadcrumb trail. + +Because `breadcrumb` renders as a React component within the route's declared namespaces, `useTranslations` from `use-intl` works out of the box: + +```tsx title="plugins/site-notes/src/pages/notes-layout.tsx" +import { definePluginRoute } from '@vitnode/core/routing' +import { useTranslations } from 'use-intl' + +// [!code ++:7] +export const route = definePluginRoute({ + breadcrumb: () => { + const t = useTranslations('@acme/site-notes.home') + + return t('title') + }, +}) +``` + +For dynamic routes, the crumb receives `{ loaderData }` and can combine dynamic records with localized copy: + +```tsx title="plugins/site-notes/src/pages/note-page-slug.tsx" +import { definePluginRoute } from '@vitnode/core/routing' +import { useTranslations } from 'use-intl' + +export const route = definePluginRoute({ + load: async ({ params }): Promise<Note> => { + return { + content: 'Loaded securely from your plugin loader.', + title: params.slug, + } + }, + head: ({ loaderData, params }) => ({ + title: loaderData?.title ?? params.slug, + }), + // [!code ++:5] + breadcrumb: ({ loaderData }) => { + const t = useTranslations('@acme/site-notes.home') + + return `${t('note')}: ${loaderData.title}` + }, +}) +``` + +For nested trails, deferred crumbs, and AdminCP trails, see the [Breadcrumbs guide](/docs/dev/routing/breadcrumbs). + +## Loading states + +While a route's loader runs, VitNode displays its `pendingComponent`: + +```ts title="plugins/site-notes/src/routes.ts" +import { definePluginRoutes, lazy, page } from '@vitnode/core/routing' +// [!code ++:1] +import { TablePendingSkeleton } from '@vitnode/core/tanstack/pending' + +export const routes = definePluginRoutes([ + page('/notes', { + component: lazy(() => import('./pages/notes-page')), + // [!code ++:1] + pendingComponent: TablePendingSkeleton, + }), +]) +``` + +<Callout type="warn" title="A pending component is not code-split"> + A router draws it *before* the page's own chunk has arrived, so there is + nothing to wait for it—TanStack Router never splits a `pendingComponent`, and + neither does VitNode. It is imported outright into the initial bundle. Keep it + to a skeleton. +</Callout> + +VitNode ships pre-built skeleton shapes from `@vitnode/core/tanstack/pending`: `TablePendingSkeleton`, `FeedPendingSkeleton`, `FormPendingSkeleton`, `CardsPendingSkeleton`, `ProfilePendingSkeleton`, `AuthPendingSkeleton`, and `RoutePendingSpinner`. + +To customize skeleton props (such as `rows` or `className`), rename the file to `routes.tsx` and return a JSX element: + +```tsx title="plugins/site-notes/src/routes.tsx" +page('/notes', { + component: lazy(() => import('./pages/notes-page')), + // [!code ++:1] + pendingComponent: () => <FeedPendingSkeleton rows={4} />, +}) +``` + +If `pendingComponent` is omitted, the route falls back to the application's global `defaultPendingComponent`. Learn more in the [Loading States guide](/docs/dev/routing/loading). + +## Missing pages + +When a loader cannot find what the URL requested, throw `notFound()` from `@tanstack/react-router` to activate the route's `notFound` component: + +```tsx title="plugins/site-notes/src/pages/note-page-slug.tsx" +import { notFound } from '@tanstack/react-router' +import { definePluginRoute } from '@vitnode/core/routing' + +export const route = definePluginRoute({ + load: async ({ params }) => { + const note = await findNote(params.slug) + + // [!code ++:3] + if (!note) { + throw notFound() + } + + return note + }, + + // [!code ++:5] + notFound: () => ( + <div className="container mx-auto p-4"> + <p className="text-muted-foreground leading-relaxed">No such note.</p> + </div> + ), +}) +``` + +Unlike `pendingComponent`, `notFound` is part of the lazy page chunk and costs unvisited routes nothing. If omitted, the route falls through to the application's global `defaultNotFoundComponent`, preserving the application layout and navigation shell. Learn more in the [Errors & Not Found guide](/docs/dev/routing/not-found). + +## Catch-all routes + +A `*` segment matches every remaining segment of the URL, allowing a single route to own an entire subtree. It must be the final segment of a path and is only permitted on `page()` declarations (a layout ending in `*` would match before its child routes): + +```ts title="plugins/site-notes/src/routes.ts" +import { definePluginRoutes, lazy, page } from '@vitnode/core/routing' + +export const routes = definePluginRoutes([ + // [!code ++:3] + page('/notes/*', { + component: lazy(() => import('./pages/notes-catch-all')), + }), +]) +``` + +The page reads the matched trailing path from `params._splat`: + +```tsx title="plugins/site-notes/src/pages/notes-catch-all.tsx" +import type { PluginRoutePageProps } from '@vitnode/core/routing' + +const NotesCatchAll = ({ params }: PluginRoutePageProps) => { + const segments = (params._splat ?? '').split('/').filter(Boolean) + + return ( + <div className="container mx-auto max-w-3xl p-4"> + <p className="text-muted-foreground leading-relaxed"> + {segments.join(' / ')} + </p> + </div> + ) +} + +export default NotesCatchAll +``` + +## Route protection (requires) + +Protect pages or entire layouts by declaring `requires`. VitNode checks the visitor's authentication state before the route chunk downloads: + +```ts title="plugins/site-notes/src/routes.ts" +import { definePluginRoutes, lazy, page } from '@vitnode/core/routing' + +export const routes = definePluginRoutes([ + page('/notes/new', { + component: lazy(() => import('./pages/new-note-page')), + // [!code ++:1] + requires: 'authenticated', + }), +]) +``` + +| `requires` | Behavior when not satisfied | Typical use cases | +| :---------------- | :------------------------------------------------------------------------- | :---------------------------------------- | +| `'authenticated'` | Redirects guests to `/login` with `returnTo` set to the target path | Dashboards, note editors, user settings | +| `'guest'` | Redirects authenticated users away (to `/` or their post-auth destination) | Sign-in, account creation, password reset | +| `'admin-guest'` | Redirects staff with active admin sessions into the AdminCP | AdminCP sign-in screen (`/admin`) | + +<Callout type="info" title="AdminCP routes are pre-guarded"> + Routes with `area: 'admin'` are automatically placed behind the AdminCP staff + session guard and cannot declare `requires`. To restrict access to specific + staff roles or permissions, gate content inside the route component or loader. +</Callout> + ## Nested routes and layouts A `layout()` wraps child routes in a shared UI frame without claiming a URL segment of its own. Use `index()` to render a page at the layout's root path. @@ -219,6 +479,7 @@ import { export const routes = definePluginRoutes([ layout('/notes', { component: lazy(() => import('./pages/notes-layout')), + messages: ['@acme/site-notes.home'], children: [ index({ component: lazy(() => import('./pages/notes-index-page')), @@ -234,12 +495,16 @@ export const routes = definePluginRoutes([ A layout component renders `{children}`: ```tsx title="plugins/site-notes/src/pages/notes-layout.tsx" +import { useTranslations } from 'use-intl' + const NotesLayout = ({ children }: { children: React.ReactNode }) => { + const t = useTranslations('@acme/site-notes.home') + return ( <div className="container mx-auto flex max-w-4xl flex-col gap-6 p-4"> <header className="border-b pb-4"> <h1 className="text-2xl font-bold tracking-tight text-balance"> - Site Notes + {t('title')} </h1> </header> <main>{children}</main> @@ -296,12 +561,15 @@ const NotesPage = ({ navigate, search, }: PluginRoutePageProps<undefined, NotesSearch>) => ( - <button - onClick={() => void navigate({ search: { page: search.page + 1 } })} - type="button" - > - Next Page ({search.page}) - </button> + <div className="container mx-auto p-4"> + <button + className="inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90" + onClick={() => void navigate({ search: { page: search.page + 1 } })} + type="button" + > + Next Page ({search.page}) + </button> + </div> ) export default NotesPage @@ -318,7 +586,25 @@ export default NotesPage icon={<MilestoneIcon />} title="Breadcrumbs" description="Customize static and dynamic breadcrumbs across public and admin pages." - href="/docs/dev/plugins/breadcrumbs" + href="/docs/dev/routing/breadcrumbs" + /> + <Card + icon={<LoaderCircleIcon />} + title="Loading States" + description="Display debounced spinners and tailored skeleton fallbacks while pages stream." + href="/docs/dev/routing/loading" + /> + <Card + icon={<TriangleAlertIcon />} + title="Errors & Not Found" + description="Render localized 404 and 500 boundaries while keeping application layouts intact." + href="/docs/dev/routing/not-found" + /> + <Card + icon={<LanguagesIcon />} + title="Translations" + description="Stream localized message namespaces in parallel with route chunks using use-intl." + href="/docs/dev/i18n/pages" /> <Card icon={<LayoutDashboardIcon />} diff --git a/apps/web/src/plugin-routes.gen.ts b/apps/web/src/plugin-routes.gen.ts index 620ad3656..48fbf8915 100644 --- a/apps/web/src/plugin-routes.gen.ts +++ b/apps/web/src/plugin-routes.gen.ts @@ -3,26 +3,32 @@ // This file is generated by VitNode. Do not edit it, and do not format it. // // It is rewritten by the `vitnode:plugin-routes` Vite plugin on every -// `vite dev` and `vite build`, from one input: the plugins configured in -// `src/vitnode.config.ts`. Each one that exports a `routes` module is imported -// below, statically, because a route tree is small browser-safe data - a path, -// a shell, a message list, and one `lazy(() => import(...))` per page. +// `vite dev` and `vite build`, from two inputs: `@vitnode/core`, which every +// VitNode application gets, and the plugins configured in +// `src/vitnode.config.ts`. Each source that exports a `routes` module is +// imported below, statically, because a route tree is small browser-safe data - +// a path, a shell, a message list, and one `lazy(() => import(...))` per page. // // No page or layout module is named here. Each one is reached only through the -// literal `import()` inside its own plugin's `lazy()` call, which Vite follows -// at build time and Rollup gives a chunk of its own - so no plugin page is in -// the initial bundle and none is reached through a computed string. +// literal `import()` inside its own source's `lazy()` call, which Vite follows +// at build time and Rollup gives a chunk of its own - so no page is in the +// initial bundle and none is reached through a computed string. // -// Same plugin configuration in, same bytes out: the plugins are sorted by id. +// Same configuration in, same bytes out: the sources are sorted by id. import type { PluginRouteDeclarationSource } from '@vitnode/core/routing' -import { routes as pluginRoutes0 } from '@vitnode/example/routes' +import { routes as pluginRoutes0 } from '@vitnode/core/routes' +import { routes as pluginRoutes1 } from '@vitnode/example/routes' export const pluginRouteSources = [ { - pluginId: '@vitnode/example', + pluginId: '@vitnode/core', routes: pluginRoutes0, }, + { + pluginId: '@vitnode/example', + routes: pluginRoutes1, + }, ] as const satisfies readonly PluginRouteDeclarationSource[] diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 49e8611f1..c3bc7cc5d 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -9,44 +9,36 @@ import { ErrorActions, NotFound, } from '@vitnode/core/tanstack/layout' -import { pageHead } from '@vitnode/core/tanstack/metadata' import { RoutePendingSpinner } from '@vitnode/core/tanstack/pending' import { + configureContentRegistry, pluginRouteSpecs, - withPluginRoutes, + withVitNodeRoutes, } from '@vitnode/core/tanstack/plugin-routes' -import { - withCoreAdminRoutes, - withCoreMainRoutes, - withCoreRootRoutes, -} from '@vitnode/core/tanstack/routes' +// Imported for its side effect: this module calls `configureIntl`, which is +// what registers the locale rules every route's redirects are rewritten by. +import './lib/i18n' import { dehydrateDocsPage, hydrateDocsPage } from './docs/hydration' -import { localeRouting } from './lib/i18n' import { pluginRouteSources } from './plugin-routes.gen' import { Route as adminShellRoute } from './routes/_admin' import { Route as mainShellRoute } from './routes/_main' import { routeTree as fileRouteTree } from './routeTree.gen' -const loadContentRegistry = async () => - (await import('@/content-registry.gen')).contentRegistry +configureContentRegistry( + async () => (await import('@/content-registry.gen')).contentRegistry, +) -const routeTree = withCoreRootRoutes( - withCoreAdminRoutes( - withCoreMainRoutes( - withPluginRoutes(fileRouteTree, pluginRouteSpecs(pluginRouteSources), { - mountUnder: { - admin: adminShellRoute, - blank: fileRouteTree, - main: mainShellRoute, - }, - pageHead, - }), - { localeRouting, mountUnder: mainShellRoute, pageHead }, - ), - { loadContentRegistry, mountUnder: adminShellRoute, pageHead }, - ), - { localeRouting, mountUnder: fileRouteTree, pageHead }, +const routeTree = withVitNodeRoutes( + fileRouteTree, + pluginRouteSpecs(pluginRouteSources), + { + mountUnder: { + admin: adminShellRoute, + blank: fileRouteTree, + main: mainShellRoute, + }, + }, ) export function getRouter() { diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/router.tsx b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/router.tsx index 70a6f8d43..710365f89 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/router.tsx +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/router.tsx @@ -9,44 +9,32 @@ import { ErrorActions, NotFound, } from "@vitnode/core/tanstack/layout"; -import { pageHead } from "@vitnode/core/tanstack/metadata"; import { RoutePendingSpinner } from "@vitnode/core/tanstack/pending"; import { pluginRouteSpecs, withPluginRoutes, } from "@vitnode/core/tanstack/plugin-routes"; -import { - withCoreAdminRoutes, - withCoreMainRoutes, - withCoreRootRoutes, -} from "@vitnode/core/tanstack/routes"; -import { localeRouting } from "./lib/i18n"; +// Imported for its side effect: this module calls `configureIntl`, which is +// what registers the locale rules every route's redirects are rewritten by. +import "./lib/i18n"; import { pluginRouteSources } from "./plugin-routes.gen"; import { Route as adminShellRoute } from "./routes/_admin"; import { Route as mainShellRoute } from "./routes/_main"; import { routeTree as fileRouteTree } from "./routeTree.gen"; -const loadContentRegistry = async () => - (await import("./content-registry.gen")).contentRegistry; +configureContentRegistry( + async () => (await import("./content-registry.gen")).contentRegistry, +); -const routeTree = withCoreRootRoutes( - withCoreAdminRoutes( - withCoreMainRoutes( - withPluginRoutes(fileRouteTree, pluginRouteSpecs(pluginRouteSources), { +const routeTree = withVitNodeRoutes(fileRouteTree, pluginRouteSpecs(pluginRouteSources), { mountUnder: { admin: adminShellRoute, blank: fileRouteTree, main: mainShellRoute, }, pageHead, - }), - { localeRouting, mountUnder: mainShellRoute, pageHead }, - ), - { loadContentRegistry, mountUnder: adminShellRoute, pageHead }, - ), - { localeRouting, mountUnder: fileRouteTree, pageHead }, -); +}); export function getRouter() { const queryClient = createVitNodeQueryClient(); diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_admin.tsx b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_admin.tsx index f3c3af74a..d9a988704 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_admin.tsx +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_admin.tsx @@ -27,7 +27,7 @@ export const Route = createFileRoute("/_admin")({ // session, and `sanitizeAdminReturnTo` rejects `/admin` as a target. // // Cast because `/admin` is not in this router's type table: it is - // `@vitnode/core`'s code-based route now, mounted by `withCoreRootRoutes`, + // `@vitnode/core`'s own declared route now, mounted by `withVitNodeRoutes`, // and code-based routes are outside the generated tree's types. The // *runtime* is unaffected, and `ADMIN_ENTRY_PATH` is the package's own // constant - so the path and the sign-in route that serves it are still one diff --git a/packages/vitnode/src/framework/plugin-routes/core.test.ts b/packages/vitnode/src/framework/plugin-routes/core.test.ts new file mode 100644 index 000000000..7ba1a8dd5 --- /dev/null +++ b/packages/vitnode/src/framework/plugin-routes/core.test.ts @@ -0,0 +1,48 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { CORE_PLUGIN_ID, CORE_ROUTES_SPECIFIER } from "./core.js"; +import { assertPluginId } from "./resolve.js"; + +/** + * Core reaches the route registry as a *source*, the same way a plugin does, and + * these are the two halves of that: the id it is registered under, and the + * specifier its tree is imported from. + * + * Both are spelled out rather than derived, because the generator must not load + * an application's configuration to know them - core is not in it. + */ +describe("core as a route source", () => { + it("is registered under core's own package name", () => { + expect(CORE_PLUGIN_ID).toBe("@vitnode/core"); + }); + + /** + * The same `<pluginId>/routes` shape every plugin's tree is read from, so the + * resolver, the watcher and the generated import all treat core as one more + * source rather than as a special case they each have to remember. + */ + it("is imported from the same subpath a plugin's routes are", () => { + expect(CORE_ROUTES_SPECIFIER).toBe(`${CORE_PLUGIN_ID}/routes`); + }); + + /** + * Core's routes are prepended to every application's registry, so a plugin + * claiming this id would put two sources under one name. The duplicate check + * downstream would then blame the application's configuration for a source + * VitNode added itself, which is a confusing way to learn this. + */ + it("refuses a configured plugin that claims core's id", () => { + expect(() => + assertPluginId(CORE_PLUGIN_ID, "src/vitnode.config.ts"), + ).toThrow( + /claims the id "@vitnode\/core", which is core's own|configures a plugin with the id "@vitnode\/core"/, + ); + }); + + it("still accepts a plugin published under the same scope", () => { + expect(assertPluginId("@vitnode/blog", "src/vitnode.config.ts")).toBe( + "@vitnode/blog", + ); + }); +}); diff --git a/packages/vitnode/src/framework/plugin-routes/core.ts b/packages/vitnode/src/framework/plugin-routes/core.ts new file mode 100644 index 000000000..d13803483 --- /dev/null +++ b/packages/vitnode/src/framework/plugin-routes/core.ts @@ -0,0 +1,12 @@ +/** Core's own id, spelled here so the generator does not import app config. */ +export const CORE_PLUGIN_ID = "@vitnode/core"; + +/** + * Where core's own route tree is imported from. + * + * The same shape a plugin's routes module is reached by - `<pluginId>/routes` - + * and resolved the same way, through the package's `exports` map. Core is not a + * configured plugin, so this is the one route source in a generated + * `plugin-routes.gen.ts` that does not depend on an app's plugin list. + */ +export const CORE_ROUTES_SPECIFIER = `${CORE_PLUGIN_ID}/routes`; diff --git a/packages/vitnode/src/framework/plugin-routes/generate.test.ts b/packages/vitnode/src/framework/plugin-routes/generate.test.ts index 233957feb..25c0f0c9a 100644 --- a/packages/vitnode/src/framework/plugin-routes/generate.test.ts +++ b/packages/vitnode/src/framework/plugin-routes/generate.test.ts @@ -13,17 +13,18 @@ const HEADER = `/* eslint-disable */ // This file is generated by VitNode. Do not edit it, and do not format it. // // It is rewritten by the \`vitnode:plugin-routes\` Vite plugin on every -// \`vite dev\` and \`vite build\`, from one input: the plugins configured in -// \`src/vitnode.config.ts\`. Each one that exports a \`routes\` module is imported -// below, statically, because a route tree is small browser-safe data - a path, -// a shell, a message list, and one \`lazy(() => import(...))\` per page. +// \`vite dev\` and \`vite build\`, from two inputs: \`@vitnode/core\`, which every +// VitNode application gets, and the plugins configured in +// \`src/vitnode.config.ts\`. Each source that exports a \`routes\` module is +// imported below, statically, because a route tree is small browser-safe data - +// a path, a shell, a message list, and one \`lazy(() => import(...))\` per page. // // No page or layout module is named here. Each one is reached only through the -// literal \`import()\` inside its own plugin's \`lazy()\` call, which Vite follows -// at build time and Rollup gives a chunk of its own - so no plugin page is in -// the initial bundle and none is reached through a computed string. +// literal \`import()\` inside its own source's \`lazy()\` call, which Vite follows +// at build time and Rollup gives a chunk of its own - so no page is in the +// initial bundle and none is reached through a computed string. // -// Same plugin configuration in, same bytes out: the plugins are sorted by id. +// Same configuration in, same bytes out: the sources are sorted by id. import type { PluginRouteDeclarationSource } from '@vitnode/core/routing' diff --git a/packages/vitnode/src/framework/plugin-routes/generate.ts b/packages/vitnode/src/framework/plugin-routes/generate.ts index 5464a7344..117a7e6ca 100644 --- a/packages/vitnode/src/framework/plugin-routes/generate.ts +++ b/packages/vitnode/src/framework/plugin-routes/generate.ts @@ -13,17 +13,18 @@ const HEADER = `/* eslint-disable */ // This file is generated by VitNode. Do not edit it, and do not format it. // // It is rewritten by the \`vitnode:plugin-routes\` Vite plugin on every -// \`vite dev\` and \`vite build\`, from one input: the plugins configured in -// \`src/vitnode.config.ts\`. Each one that exports a \`routes\` module is imported -// below, statically, because a route tree is small browser-safe data - a path, -// a shell, a message list, and one \`lazy(() => import(...))\` per page. +// \`vite dev\` and \`vite build\`, from two inputs: \`@vitnode/core\`, which every +// VitNode application gets, and the plugins configured in +// \`src/vitnode.config.ts\`. Each source that exports a \`routes\` module is +// imported below, statically, because a route tree is small browser-safe data - +// a path, a shell, a message list, and one \`lazy(() => import(...))\` per page. // // No page or layout module is named here. Each one is reached only through the -// literal \`import()\` inside its own plugin's \`lazy()\` call, which Vite follows -// at build time and Rollup gives a chunk of its own - so no plugin page is in -// the initial bundle and none is reached through a computed string. +// literal \`import()\` inside its own source's \`lazy()\` call, which Vite follows +// at build time and Rollup gives a chunk of its own - so no page is in the +// initial bundle and none is reached through a computed string. // -// Same plugin configuration in, same bytes out: the plugins are sorted by id. +// Same configuration in, same bytes out: the sources are sorted by id. import type { PluginRouteDeclarationSource } from '${TYPES_SPECIFIER}' diff --git a/packages/vitnode/src/framework/plugin-routes/host-routes.ts b/packages/vitnode/src/framework/plugin-routes/host-routes.ts index b42c227da..2f501ff0f 100644 --- a/packages/vitnode/src/framework/plugin-routes/host-routes.ts +++ b/packages/vitnode/src/framework/plugin-routes/host-routes.ts @@ -84,7 +84,7 @@ const tokenPath = (tokens: Token[]): string => { * ## What it is for, and what it is not * * This is the *build-time* half of the plugin-versus-host collision check. The - * authoritative half runs where the real route tree exists - `withPluginRoutes` + * authoritative half runs where the real route tree exists - `withVitNodeRoutes` * walks it and refuses a plugin route that shadows an application URL - and it * cannot be wrong, because it is reading the router's own routes. This one can * only be *incomplete*, and is deliberately built so that incomplete is the only diff --git a/packages/vitnode/src/framework/plugin-routes/index.ts b/packages/vitnode/src/framework/plugin-routes/index.ts index 590dfd624..e77154d86 100644 --- a/packages/vitnode/src/framework/plugin-routes/index.ts +++ b/packages/vitnode/src/framework/plugin-routes/index.ts @@ -5,6 +5,7 @@ export type { } from "./compile.js"; export { compilePluginRoutes } from "./compile.js"; export { lazyImportSpecifier } from "./component-source.js"; +export { CORE_PLUGIN_ID, CORE_ROUTES_SPECIFIER } from "./core.js"; export { annotatePluginRouteError, PLUGIN_ROUTES_ERROR_PREFIX, diff --git a/packages/vitnode/src/framework/plugin-routes/resolve.ts b/packages/vitnode/src/framework/plugin-routes/resolve.ts index 2c4948268..4817d2789 100644 --- a/packages/vitnode/src/framework/plugin-routes/resolve.ts +++ b/packages/vitnode/src/framework/plugin-routes/resolve.ts @@ -2,6 +2,7 @@ import type { PackageMessagesSource } from "../package-messages/resolve.js"; import type { ResolvedPluginRoutesModule } from "./types.js"; import { localeFilesFromDeclaration } from "../package-messages/resolve.js"; +import { CORE_PLUGIN_ID } from "./core.js"; import { PLUGIN_ROUTES_ERROR_PREFIX as ERROR_PREFIX } from "./diagnostics.js"; const PLUGIN_ID_PATTERN = @@ -18,6 +19,16 @@ export const assertPluginId = (pluginId: string, source: string): string => { ); } + // Core's own routes are prepended to every app's registry, so a plugin + // claiming this id would be a second source under one name - and the duplicate + // check downstream would blame the app's configuration for something VitNode + // added. + if (pluginId === CORE_PLUGIN_ID) { + throw new Error( + `${ERROR_PREFIX} ${source} configures a plugin with the id ${JSON.stringify(CORE_PLUGIN_ID)}, which is core's own. Core's routes are registered by VitNode itself - give the plugin its own package name.`, + ); + } + return pluginId; }; diff --git a/packages/vitnode/src/framework/vite/plugin-routes.ts b/packages/vitnode/src/framework/vite/plugin-routes.ts index 295efbeb3..4767de628 100644 --- a/packages/vitnode/src/framework/vite/plugin-routes.ts +++ b/packages/vitnode/src/framework/vite/plugin-routes.ts @@ -32,6 +32,7 @@ import { } from "../package-messages"; import { compilePluginRoutes, + CORE_PLUGIN_ID, hostRoutePathsFromFiles, lazyImportSpecifier, pluginsFromLoadedConfig, @@ -129,6 +130,15 @@ const readPluginRoutes = async ( const file = resolvePackageFile(specifier); if (file === null) { + // Core always ships a routes module, so failing to resolve one means the + // package has not been built - which is worth saying outright rather than + // silently serving an application with none of its own screens. + if (pluginId === CORE_PLUGIN_ID) { + throw new Error( + `${ERROR_PREFIX} Could not resolve "${specifier}". Every VitNode application gets core's own routes from it, so this usually means @vitnode/core has not been built yet - run its \`build:plugins\` script.`, + ); + } + assertNoLegacyRouteManifest(pluginId, resolvePackageFile); return { source: { pluginId }, watch: null }; @@ -393,8 +403,13 @@ const discover = async ( plugins, relative(appRoot, paths.config), ); + // Core first, always, and never from the configured list: core is not a + // plugin, its screens are what makes an application a VitNode application, and + // an app that could forget to configure them would be an app with no `/login`. + // `assertPluginId` refuses a plugin that claims this id, so there is exactly + // one source under it. const loaded = await Promise.all( - pluginIds.map(async pluginId => + [CORE_PLUGIN_ID, ...pluginIds].map(async pluginId => readPluginRoutes(pluginId, resolvePackageFile), ), ); diff --git a/packages/vitnode/src/pages/admin/advanced/cron.tsx b/packages/vitnode/src/pages/admin/advanced/cron.tsx new file mode 100644 index 000000000..f2cffe91a --- /dev/null +++ b/packages/vitnode/src/pages/admin/advanced/cron.tsx @@ -0,0 +1,32 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminCronRouteData } from "@/tanstack/admin/cron/route"; +import type { CronRouteSearch } from "@/tanstack/admin/cron/route-search"; + +import { adminBreadcrumb } from "@/tanstack/admin/breadcrumb"; +import { loadAdminCronRoute } from "@/tanstack/admin/cron/route"; +import { cronRouteParams } from "@/tanstack/admin/cron/route-search"; +import { AdminCronRouteContent } from "@/tanstack/admin/cron/screen"; +import { defineAdminRoute } from "@/tanstack/plugin-routes"; + +const AdminCronPage = ({ + loaderData, + navigate, + search, +}: PluginRoutePageProps<AdminCronRouteData, CronRouteSearch>) => ( + <AdminCronRouteContent {...loaderData} navigate={navigate} search={search} /> +); + +export const route = defineAdminRoute<AdminCronRouteData, CronRouteSearch>({ + // `head` after `load`, always. + load: async ({ context, search, t }) => + await loadAdminCronRoute({ + ...context, + params: cronRouteParams(search), + t, + }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: adminBreadcrumb({ segments: ["core", "advanced", "cron"] }), +}); + +export default AdminCronPage; diff --git a/packages/vitnode/src/pages/admin/advanced/queue.tsx b/packages/vitnode/src/pages/admin/advanced/queue.tsx new file mode 100644 index 000000000..8733218e7 --- /dev/null +++ b/packages/vitnode/src/pages/admin/advanced/queue.tsx @@ -0,0 +1,32 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminQueueRouteData } from "@/tanstack/admin/queue/route"; +import type { QueueRouteSearch } from "@/tanstack/admin/queue/route-search"; + +import { adminBreadcrumb } from "@/tanstack/admin/breadcrumb"; +import { loadAdminQueueRoute } from "@/tanstack/admin/queue/route"; +import { queueRouteParams } from "@/tanstack/admin/queue/route-search"; +import { AdminQueueRouteContent } from "@/tanstack/admin/queue/screen"; +import { defineAdminRoute } from "@/tanstack/plugin-routes"; + +const AdminQueuePage = ({ + loaderData, + navigate, + search, +}: PluginRoutePageProps<AdminQueueRouteData, QueueRouteSearch>) => ( + <AdminQueueRouteContent {...loaderData} navigate={navigate} search={search} /> +); + +export const route = defineAdminRoute<AdminQueueRouteData, QueueRouteSearch>({ + // `head` after `load`, always. + load: async ({ context, search, t }) => + await loadAdminQueueRoute({ + ...context, + params: queueRouteParams(search), + t, + }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: adminBreadcrumb({ segments: ["core", "advanced", "queue"] }), +}); + +export default AdminQueuePage; diff --git a/packages/vitnode/src/pages/admin/advanced/search.tsx b/packages/vitnode/src/pages/admin/advanced/search.tsx new file mode 100644 index 000000000..e7594fcdb --- /dev/null +++ b/packages/vitnode/src/pages/admin/advanced/search.tsx @@ -0,0 +1,34 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminSearchIndexRouteData } from "@/tanstack/admin/search-index/route"; +import type { SearchIndexRouteSearch } from "@/tanstack/admin/search-index/route-search"; + +import { adminBreadcrumb } from "@/tanstack/admin/breadcrumb"; +import { loadAdminSearchIndexRoute } from "@/tanstack/admin/search-index/route"; +import { AdminSearchIndexRouteContent } from "@/tanstack/admin/search-index/screen"; +import { defineAdminRoute } from "@/tanstack/plugin-routes"; + +const AdminSearchIndexPage = ({ + loaderData, + navigate, + search, +}: PluginRoutePageProps<AdminSearchIndexRouteData, SearchIndexRouteSearch>) => ( + <AdminSearchIndexRouteContent + {...loaderData} + navigate={navigate} + search={search} + /> +); + +export const route = defineAdminRoute< + AdminSearchIndexRouteData, + SearchIndexRouteSearch +>({ + // `head` after `load`, always. + load: async ({ context, t }) => + await loadAdminSearchIndexRoute({ ...context, t }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: adminBreadcrumb({ segments: ["core", "advanced", "search"] }), +}); + +export default AdminSearchIndexPage; diff --git a/packages/vitnode/src/pages/admin/content.tsx b/packages/vitnode/src/pages/admin/content.tsx new file mode 100644 index 000000000..b8f9b00cc --- /dev/null +++ b/packages/vitnode/src/pages/admin/content.tsx @@ -0,0 +1,92 @@ +import { use } from "react"; + +import type { ContentFrontendRegistry } from "@/content/admin/registry"; +import type { PluginRoutePageProps } from "@/routing"; +import type { ContentListRouteSearch } from "@/tanstack/admin/content/route-search"; +import type { AdminRouteLoadContext } from "@/tanstack/plugin-routes"; + +import { ContentAdminBreadcrumbContent } from "@/tanstack/admin/content/breadcrumb"; +import { loadContentFormScreen } from "@/tanstack/admin/content/form/route"; +import { getContentRegistryLoader } from "@/tanstack/admin/content/registry-runtime"; +import { + contentRouteSegments, + loadContentAdminRoute, +} from "@/tanstack/admin/content/route"; +import { ContentAdminScreenContent } from "@/tanstack/admin/content/screen"; +import { + defineAdminRoute, + routeBreadcrumbGroup, +} from "@/tanstack/plugin-routes"; + +/** + * The registry, resolved once per module load and shared by the loader and the + * component. + * + * Deliberately *not* loader data: it carries every content type's editor fields + * and form layouts, so returning it would serialise the whole thing into the + * SSR payload. A promise the component `use()`s is the same thing the hand-built + * route did by resolving it beside the screen's own chunk. + */ +let registryPromise: Promise<ContentFrontendRegistry> | undefined; + +// Not `async`: `use()` suspends on promise *identity*, and an async wrapper +// would hand it a new promise on every render. +// eslint-disable-next-line @typescript-eslint/promise-function-async +const contentRegistry = (): Promise<ContentFrontendRegistry> => { + registryPromise ??= getContentRegistryLoader()(); + + return registryPromise; +}; + +type ContentPageData = Awaited<ReturnType<typeof loadContentPage>>; + +const loadContentPage = async ({ + context, + params, + search, +}: { + context: AdminRouteLoadContext; + params: Readonly<Record<string, string>>; + search: ContentListRouteSearch; +}) => { + const registry = await contentRegistry(); + const resolved = await loadContentAdminRoute({ + ...context, + registry, + search, + segments: contentRouteSegments(params._splat), + }); + + return { + ...resolved, + ...(await loadContentFormScreen({ ...context, registry, route: resolved })), + }; +}; + +const ContentAdminPage = ({ + loaderData, + navigate, + search, +}: PluginRoutePageProps<ContentPageData, ContentListRouteSearch>) => ( + <ContentAdminScreenContent + {...loaderData} + navigate={navigate} + registry={use(contentRegistry())} + search={search} + /> +); + +export const route = defineAdminRoute<ContentPageData, ContentListRouteSearch>({ + // `head` after `load`, always. + load: async ({ context, params, search }) => + await loadContentPage({ context, params, search }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: routeBreadcrumbGroup<ContentPageData>( + function ContentAdminBreadcrumb({ loaderData }) { + return <ContentAdminBreadcrumbContent {...loaderData} />; + }, + ), +}); + +export default ContentAdminPage; diff --git a/packages/vitnode/src/pages/admin/debug.tsx b/packages/vitnode/src/pages/admin/debug.tsx new file mode 100644 index 000000000..79ca183df --- /dev/null +++ b/packages/vitnode/src/pages/admin/debug.tsx @@ -0,0 +1,32 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminDebugRouteData } from "@/tanstack/admin/debug/route"; +import type { DebugRouteSearch } from "@/tanstack/admin/debug/route-search"; + +import { loadAdminDebugRoute } from "@/tanstack/admin/debug/route"; +import { debugLogsRouteParams } from "@/tanstack/admin/debug/route-search"; +import { AdminDebugRouteContent } from "@/tanstack/admin/debug/screen"; +import { defineAdminRoute } from "@/tanstack/plugin-routes"; + +const AdminDebugPage = ({ + loaderData, + navigate, + search, +}: PluginRoutePageProps<AdminDebugRouteData, DebugRouteSearch>) => ( + <AdminDebugRouteContent {...loaderData} navigate={navigate} search={search} /> +); + +export const route = defineAdminRoute<AdminDebugRouteData, DebugRouteSearch>({ + // `head` after `load`, always. + load: async ({ context, search, t }) => + await loadAdminDebugRoute({ + ...context, + params: debugLogsRouteParams(search), + t, + }), + head: ({ loaderData }) => ({ ...loaderData }), + + /** A developer screen, deliberately absent from the trail. */ + breadcrumb: null, +}); + +export default AdminDebugPage; diff --git a/packages/vitnode/src/pages/admin/sign-in.tsx b/packages/vitnode/src/pages/admin/sign-in.tsx new file mode 100644 index 000000000..f5ff8746b --- /dev/null +++ b/packages/vitnode/src/pages/admin/sign-in.tsx @@ -0,0 +1,21 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminSignInSearch } from "@/tanstack/admin/sign-in-search"; + +import { AdminSignInRouteContent } from "@/tanstack/admin/sign-in-screen"; +import { useAppNavigate } from "@/tanstack/auth/navigation"; +import { defineRoute } from "@/tanstack/plugin-routes"; + +const AdminSignInPage = ({ + search, +}: PluginRoutePageProps<undefined, AdminSignInSearch>) => ( + <AdminSignInRouteContent + navigate={useAppNavigate()} + returnTo={search.returnTo} + /> +); + +export const route = defineRoute<undefined, AdminSignInSearch>({ + head: ({ t }) => ({ title: t("core.global.login") }), +}); + +export default AdminSignInPage; diff --git a/packages/vitnode/src/pages/admin/staff/admins/create.tsx b/packages/vitnode/src/pages/admin/staff/admins/create.tsx new file mode 100644 index 000000000..b933ad137 --- /dev/null +++ b/packages/vitnode/src/pages/admin/staff/admins/create.tsx @@ -0,0 +1,33 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminStaffCreateRouteData } from "@/tanstack/admin/staff/create-route"; + +import { AdminStaffBreadcrumbContent } from "@/tanstack/admin/staff/breadcrumbs"; +import { loadAdminStaffCreateRoute } from "@/tanstack/admin/staff/create-route"; +import { AdminStaffCreateRouteContent } from "@/tanstack/admin/staff/create-screen"; +import { useStaffFormNavigate } from "@/tanstack/admin/staff/navigation"; +import { + defineAdminRoute, + routeBreadcrumbGroup, +} from "@/tanstack/plugin-routes"; + +const AdminsCreatePage = ({ + loaderData, +}: PluginRoutePageProps<AdminStaffCreateRouteData>) => ( + <AdminStaffCreateRouteContent + {...loaderData} + navigate={useStaffFormNavigate()} + /> +); + +export const route = defineAdminRoute<AdminStaffCreateRouteData>({ + // `head` after `load`, always. + load: async ({ context, t }) => + await loadAdminStaffCreateRoute({ ...context, t, type: "admin" }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: routeBreadcrumbGroup(function AdminsCreateBreadcrumb() { + return <AdminStaffBreadcrumbContent type="admin" />; + }), +}); + +export default AdminsCreatePage; diff --git a/packages/vitnode/src/pages/admin/staff/admins/edit.tsx b/packages/vitnode/src/pages/admin/staff/admins/edit.tsx new file mode 100644 index 000000000..f2fc230a0 --- /dev/null +++ b/packages/vitnode/src/pages/admin/staff/admins/edit.tsx @@ -0,0 +1,38 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminStaffEditRouteData } from "@/tanstack/admin/staff/edit-route"; + +import { AdminStaffBreadcrumbContent } from "@/tanstack/admin/staff/breadcrumbs"; +import { loadAdminStaffEditRoute } from "@/tanstack/admin/staff/edit-route"; +import { AdminStaffEditRouteContent } from "@/tanstack/admin/staff/edit-screen"; +import { useStaffFormNavigate } from "@/tanstack/admin/staff/navigation"; +import { + defineAdminRoute, + routeBreadcrumbGroup, +} from "@/tanstack/plugin-routes"; + +const AdminsEditPage = ({ + loaderData, +}: PluginRoutePageProps<AdminStaffEditRouteData>) => ( + <AdminStaffEditRouteContent + {...loaderData} + navigate={useStaffFormNavigate()} + /> +); + +export const route = defineAdminRoute<AdminStaffEditRouteData>({ + // `head` after `load`, always. + load: async ({ context, params, t }) => + await loadAdminStaffEditRoute({ + ...context, + id: params.id, + t, + type: "admin", + }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: routeBreadcrumbGroup(function AdminsEditBreadcrumb() { + return <AdminStaffBreadcrumbContent type="admin" />; + }), +}); + +export default AdminsEditPage; diff --git a/packages/vitnode/src/pages/admin/staff/admins/index.tsx b/packages/vitnode/src/pages/admin/staff/admins/index.tsx new file mode 100644 index 000000000..3b2490596 --- /dev/null +++ b/packages/vitnode/src/pages/admin/staff/admins/index.tsx @@ -0,0 +1,38 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminStaffRouteData } from "@/tanstack/admin/staff/route"; +import type { StaffRouteSearch } from "@/tanstack/admin/staff/route-search"; + +import { AdminStaffBreadcrumbContent } from "@/tanstack/admin/staff/breadcrumbs"; +import { loadAdminStaffRoute } from "@/tanstack/admin/staff/route"; +import { staffRouteParams } from "@/tanstack/admin/staff/route-search"; +import { AdminStaffRouteContent } from "@/tanstack/admin/staff/screen"; +import { + defineAdminRoute, + routeBreadcrumbGroup, +} from "@/tanstack/plugin-routes"; + +const AdminsPage = ({ + loaderData, + navigate, + search, +}: PluginRoutePageProps<AdminStaffRouteData, StaffRouteSearch>) => ( + <AdminStaffRouteContent {...loaderData} navigate={navigate} search={search} /> +); + +export const route = defineAdminRoute<AdminStaffRouteData, StaffRouteSearch>({ + // `head` after `load`, always. + load: async ({ context, search, t }) => + await loadAdminStaffRoute({ + ...context, + params: staffRouteParams(search), + t, + type: "admin", + }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: routeBreadcrumbGroup(function AdminsBreadcrumb() { + return <AdminStaffBreadcrumbContent type="admin" />; + }), +}); + +export default AdminsPage; diff --git a/packages/vitnode/src/pages/admin/staff/moderators/create.tsx b/packages/vitnode/src/pages/admin/staff/moderators/create.tsx new file mode 100644 index 000000000..260b40be5 --- /dev/null +++ b/packages/vitnode/src/pages/admin/staff/moderators/create.tsx @@ -0,0 +1,33 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminStaffCreateRouteData } from "@/tanstack/admin/staff/create-route"; + +import { AdminStaffBreadcrumbContent } from "@/tanstack/admin/staff/breadcrumbs"; +import { loadAdminStaffCreateRoute } from "@/tanstack/admin/staff/create-route"; +import { AdminStaffCreateRouteContent } from "@/tanstack/admin/staff/create-screen"; +import { useStaffFormNavigate } from "@/tanstack/admin/staff/navigation"; +import { + defineAdminRoute, + routeBreadcrumbGroup, +} from "@/tanstack/plugin-routes"; + +const ModeratorsCreatePage = ({ + loaderData, +}: PluginRoutePageProps<AdminStaffCreateRouteData>) => ( + <AdminStaffCreateRouteContent + {...loaderData} + navigate={useStaffFormNavigate()} + /> +); + +export const route = defineAdminRoute<AdminStaffCreateRouteData>({ + // `head` after `load`, always. + load: async ({ context, t }) => + await loadAdminStaffCreateRoute({ ...context, t, type: "moderator" }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: routeBreadcrumbGroup(function ModeratorsCreateBreadcrumb() { + return <AdminStaffBreadcrumbContent type="moderator" />; + }), +}); + +export default ModeratorsCreatePage; diff --git a/packages/vitnode/src/pages/admin/staff/moderators/edit.tsx b/packages/vitnode/src/pages/admin/staff/moderators/edit.tsx new file mode 100644 index 000000000..805e0f925 --- /dev/null +++ b/packages/vitnode/src/pages/admin/staff/moderators/edit.tsx @@ -0,0 +1,38 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminStaffEditRouteData } from "@/tanstack/admin/staff/edit-route"; + +import { AdminStaffBreadcrumbContent } from "@/tanstack/admin/staff/breadcrumbs"; +import { loadAdminStaffEditRoute } from "@/tanstack/admin/staff/edit-route"; +import { AdminStaffEditRouteContent } from "@/tanstack/admin/staff/edit-screen"; +import { useStaffFormNavigate } from "@/tanstack/admin/staff/navigation"; +import { + defineAdminRoute, + routeBreadcrumbGroup, +} from "@/tanstack/plugin-routes"; + +const ModeratorsEditPage = ({ + loaderData, +}: PluginRoutePageProps<AdminStaffEditRouteData>) => ( + <AdminStaffEditRouteContent + {...loaderData} + navigate={useStaffFormNavigate()} + /> +); + +export const route = defineAdminRoute<AdminStaffEditRouteData>({ + // `head` after `load`, always. + load: async ({ context, params, t }) => + await loadAdminStaffEditRoute({ + ...context, + id: params.id, + t, + type: "moderator", + }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: routeBreadcrumbGroup(function ModeratorsEditBreadcrumb() { + return <AdminStaffBreadcrumbContent type="moderator" />; + }), +}); + +export default ModeratorsEditPage; diff --git a/packages/vitnode/src/pages/admin/staff/moderators/index.tsx b/packages/vitnode/src/pages/admin/staff/moderators/index.tsx new file mode 100644 index 000000000..9c0b300fe --- /dev/null +++ b/packages/vitnode/src/pages/admin/staff/moderators/index.tsx @@ -0,0 +1,38 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminStaffRouteData } from "@/tanstack/admin/staff/route"; +import type { StaffRouteSearch } from "@/tanstack/admin/staff/route-search"; + +import { AdminStaffBreadcrumbContent } from "@/tanstack/admin/staff/breadcrumbs"; +import { loadAdminStaffRoute } from "@/tanstack/admin/staff/route"; +import { staffRouteParams } from "@/tanstack/admin/staff/route-search"; +import { AdminStaffRouteContent } from "@/tanstack/admin/staff/screen"; +import { + defineAdminRoute, + routeBreadcrumbGroup, +} from "@/tanstack/plugin-routes"; + +const ModeratorsPage = ({ + loaderData, + navigate, + search, +}: PluginRoutePageProps<AdminStaffRouteData, StaffRouteSearch>) => ( + <AdminStaffRouteContent {...loaderData} navigate={navigate} search={search} /> +); + +export const route = defineAdminRoute<AdminStaffRouteData, StaffRouteSearch>({ + // `head` after `load`, always. + load: async ({ context, search, t }) => + await loadAdminStaffRoute({ + ...context, + params: staffRouteParams(search), + t, + type: "moderator", + }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: routeBreadcrumbGroup(function ModeratorsBreadcrumb() { + return <AdminStaffBreadcrumbContent type="moderator" />; + }), +}); + +export default ModeratorsPage; diff --git a/packages/vitnode/src/pages/admin/system/files.tsx b/packages/vitnode/src/pages/admin/system/files.tsx new file mode 100644 index 000000000..7a6c3950a --- /dev/null +++ b/packages/vitnode/src/pages/admin/system/files.tsx @@ -0,0 +1,35 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminFilesRouteData } from "@/tanstack/admin/files/route"; +import type { AdminFilesRouteSearch } from "@/tanstack/admin/files/route-search"; + +import { adminBreadcrumb } from "@/tanstack/admin/breadcrumb"; +import { loadAdminFilesRoute } from "@/tanstack/admin/files/route"; +import { adminFilesRouteParams } from "@/tanstack/admin/files/route-search"; +import { AdminFilesRouteContent } from "@/tanstack/admin/files/screen"; +import { defineAdminRoute } from "@/tanstack/plugin-routes"; + +const AdminFilesPage = ({ + loaderData, + navigate, + search, +}: PluginRoutePageProps<AdminFilesRouteData, AdminFilesRouteSearch>) => ( + <AdminFilesRouteContent {...loaderData} navigate={navigate} search={search} /> +); + +export const route = defineAdminRoute< + AdminFilesRouteData, + AdminFilesRouteSearch +>({ + // `head` after `load`, always. + load: async ({ context, search, t }) => + await loadAdminFilesRoute({ + ...context, + params: adminFilesRouteParams(search), + t, + }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: adminBreadcrumb({ segments: ["core", "system", "files"] }), +}); + +export default AdminFilesPage; diff --git a/packages/vitnode/src/pages/admin/system/integrations.tsx b/packages/vitnode/src/pages/admin/system/integrations.tsx new file mode 100644 index 000000000..0e2f3baae --- /dev/null +++ b/packages/vitnode/src/pages/admin/system/integrations.tsx @@ -0,0 +1,28 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminIntegrationsRouteData } from "@/tanstack/admin/integrations/route"; + +import { adminBreadcrumb } from "@/tanstack/admin/breadcrumb"; +import { loadAdminIntegrationsRoute } from "@/tanstack/admin/integrations/route"; +import { AdminIntegrationsRouteContent } from "@/tanstack/admin/integrations/screen"; +import { defineAdminRoute } from "@/tanstack/plugin-routes"; + +const AdminIntegrationsPage = ({ + loaderData, +}: PluginRoutePageProps<AdminIntegrationsRouteData>) => ( + <AdminIntegrationsRouteContent {...loaderData} /> +); + +/** + * The heading's strings come from the loader, so the `<h1>` and the `<title>` + * are the same string by construction. + */ +export const route = defineAdminRoute<AdminIntegrationsRouteData>({ + // `head` after `load`, always. + load: async ({ context, t }) => + await loadAdminIntegrationsRoute({ ...context, t }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: adminBreadcrumb({ segments: ["core", "system", "integrations"] }), +}); + +export default AdminIntegrationsPage; diff --git a/packages/vitnode/src/pages/admin/users/index.tsx b/packages/vitnode/src/pages/admin/users/index.tsx new file mode 100644 index 000000000..742698c8a --- /dev/null +++ b/packages/vitnode/src/pages/admin/users/index.tsx @@ -0,0 +1,32 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminUsersRouteData } from "@/tanstack/admin/users/route"; +import type { UsersRouteSearch } from "@/tanstack/admin/users/route-search"; + +import { adminBreadcrumb } from "@/tanstack/admin/breadcrumb"; +import { loadAdminUsersRoute } from "@/tanstack/admin/users/route"; +import { usersRouteParams } from "@/tanstack/admin/users/route-search"; +import { AdminUsersRouteContent } from "@/tanstack/admin/users/screen"; +import { defineAdminRoute } from "@/tanstack/plugin-routes"; + +const AdminUsersPage = ({ + loaderData, + navigate, + search, +}: PluginRoutePageProps<AdminUsersRouteData, UsersRouteSearch>) => ( + <AdminUsersRouteContent {...loaderData} navigate={navigate} search={search} /> +); + +export const route = defineAdminRoute<AdminUsersRouteData, UsersRouteSearch>({ + // `head` after `load`, always. + load: async ({ context, search, t }) => + await loadAdminUsersRoute({ + ...context, + params: usersRouteParams(search), + t, + }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: adminBreadcrumb({ segments: ["core", "users"] }), +}); + +export default AdminUsersPage; diff --git a/packages/vitnode/src/pages/admin/users/roles.tsx b/packages/vitnode/src/pages/admin/users/roles.tsx new file mode 100644 index 000000000..335913539 --- /dev/null +++ b/packages/vitnode/src/pages/admin/users/roles.tsx @@ -0,0 +1,32 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminRolesRouteData } from "@/tanstack/admin/roles/route"; +import type { RolesRouteSearch } from "@/tanstack/admin/roles/route-search"; + +import { adminBreadcrumb } from "@/tanstack/admin/breadcrumb"; +import { loadAdminRolesRoute } from "@/tanstack/admin/roles/route"; +import { rolesRouteParams } from "@/tanstack/admin/roles/route-search"; +import { AdminRolesRouteContent } from "@/tanstack/admin/roles/screen"; +import { defineAdminRoute } from "@/tanstack/plugin-routes"; + +const AdminRolesPage = ({ + loaderData, + navigate, + search, +}: PluginRoutePageProps<AdminRolesRouteData, RolesRouteSearch>) => ( + <AdminRolesRouteContent {...loaderData} navigate={navigate} search={search} /> +); + +export const route = defineAdminRoute<AdminRolesRouteData, RolesRouteSearch>({ + // `head` after `load`, always. + load: async ({ context, search, t }) => + await loadAdminRolesRoute({ + ...context, + params: rolesRouteParams(search), + t, + }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: adminBreadcrumb({ segments: ["core", "users", "roles"] }), +}); + +export default AdminRolesPage; diff --git a/packages/vitnode/src/pages/admin/users/user.tsx b/packages/vitnode/src/pages/admin/users/user.tsx new file mode 100644 index 000000000..a06da0c20 --- /dev/null +++ b/packages/vitnode/src/pages/admin/users/user.tsx @@ -0,0 +1,29 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminUserRouteData } from "@/tanstack/admin/users/detail-route"; + +import { AdminUserBreadcrumbContent } from "@/tanstack/admin/users/detail-breadcrumb"; +import { loadAdminUserRoute } from "@/tanstack/admin/users/detail-route"; +import { AdminUserRouteContent } from "@/tanstack/admin/users/detail-screen"; +import { + defineAdminRoute, + routeBreadcrumbGroup, +} from "@/tanstack/plugin-routes"; + +const AdminUserPage = ({ + loaderData, +}: PluginRoutePageProps<AdminUserRouteData>) => ( + <AdminUserRouteContent {...loaderData} /> +); + +export const route = defineAdminRoute<AdminUserRouteData>({ + // `head` after `load`, always. + load: async ({ context, params, t }) => + await loadAdminUserRoute({ ...context, id: params.id, t }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: routeBreadcrumbGroup(function AdminUserBreadcrumb({ params }) { + return <AdminUserBreadcrumbContent params={params} />; + }), +}); + +export default AdminUserPage; diff --git a/packages/vitnode/src/pages/discover.tsx b/packages/vitnode/src/pages/discover.tsx new file mode 100644 index 000000000..82c8acade --- /dev/null +++ b/packages/vitnode/src/pages/discover.tsx @@ -0,0 +1,22 @@ +import type { PluginRoutePageProps } from "@/routing"; + +import type { DiscoverRouteData } from "../tanstack/search/discover-route"; + +import { defineRoute } from "../tanstack/plugin-routes"; +import { loadDiscoverRoute } from "../tanstack/search/discover-route"; +import { DiscoverRouteContent } from "../tanstack/search/discover-screen"; + +const DiscoverPage = ({ + loaderData, +}: PluginRoutePageProps<DiscoverRouteData>) => ( + <DiscoverRouteContent {...loaderData} /> +); + +export const route = defineRoute({ + // `head` after `load`, always: `loaderData` is inferred from `load`, and + // TypeScript reads an object literal's members in order. + load: async ({ context, t }) => await loadDiscoverRoute({ ...context, t }), + head: ({ loaderData }) => ({ robots: "index, follow", ...loaderData }), +}); + +export default DiscoverPage; diff --git a/packages/vitnode/src/pages/files.tsx b/packages/vitnode/src/pages/files.tsx new file mode 100644 index 000000000..f8cb8c4d7 --- /dev/null +++ b/packages/vitnode/src/pages/files.tsx @@ -0,0 +1,33 @@ +import type { PluginRoutePageProps } from "@/routing"; + +import type { MyFilesRouteData } from "../tanstack/files/route"; +import type { UncheckedMyFilesSearch } from "../tanstack/files/route-search"; + +import { loadMyFilesRoute } from "../tanstack/files/route"; +import { myFilesRouteParams } from "../tanstack/files/route-search"; +import { MyFilesRouteContent } from "../tanstack/files/screen"; +import { defineAuthenticatedRoute } from "../tanstack/plugin-routes"; + +const MyFilesPage = ({ + loaderData, + navigate, + search, +}: PluginRoutePageProps<MyFilesRouteData, UncheckedMyFilesSearch>) => ( + <MyFilesRouteContent {...loaderData} navigate={navigate} search={search} /> +); + +export const route = defineAuthenticatedRoute< + MyFilesRouteData, + UncheckedMyFilesSearch +>({ + // `head` after `load`, always. + load: async ({ context, search, t }) => + await loadMyFilesRoute({ + ...context, + params: myFilesRouteParams(search), + t, + }), + head: ({ loaderData }) => ({ robots: "noindex, nofollow", ...loaderData }), +}); + +export default MyFilesPage; diff --git a/packages/vitnode/src/pages/login/index.tsx b/packages/vitnode/src/pages/login/index.tsx new file mode 100644 index 000000000..06f846810 --- /dev/null +++ b/packages/vitnode/src/pages/login/index.tsx @@ -0,0 +1,22 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { LoginSearch } from "@/tanstack/auth/route-search"; + +import { loadLoginRoute } from "@/tanstack/auth/login-route"; +import { LoginRouteContent } from "@/tanstack/auth/login-screen"; +import { useAppNavigate } from "@/tanstack/auth/navigation"; +import { defineRoute } from "@/tanstack/plugin-routes"; + +const LoginPage = ({ + search, +}: PluginRoutePageProps<undefined, LoginSearch>) => ( + <LoginRouteContent navigate={useAppNavigate()} returnTo={search.returnTo} /> +); + +export const route = defineRoute<undefined, LoginSearch>({ + load: async ({ context }) => { + await loadLoginRoute(context); + }, + head: ({ t }) => ({ title: t("core.global.login") }), +}); + +export default LoginPage; diff --git a/packages/vitnode/src/pages/login/reset-password.tsx b/packages/vitnode/src/pages/login/reset-password.tsx new file mode 100644 index 000000000..9e32e6311 --- /dev/null +++ b/packages/vitnode/src/pages/login/reset-password.tsx @@ -0,0 +1,65 @@ +import { notFound } from "@tanstack/react-router"; + +import type { PluginRoutePageProps } from "@/routing"; +import type { PasswordResetSearch } from "@/tanstack/auth/recovery"; +import type { PasswordResetRouteData } from "@/tanstack/auth/recovery-route"; + +import { middlewareConfigQueryOptions } from "@/tanstack/auth/middleware-config"; +import { + passwordRecoveryAvailability, + PasswordRecoveryUnknownError, + passwordResetMode, +} from "@/tanstack/auth/recovery"; +import { loadPasswordResetRoute } from "@/tanstack/auth/recovery-route"; +import { + PasswordRecoveryNotFound, + PasswordResetRouteContent, +} from "@/tanstack/auth/recovery-screen"; +import { ErrorActions } from "@/tanstack/layout/error-actions"; +import { defineRoute } from "@/tanstack/plugin-routes"; + +const PasswordResetPage = ({ + loaderData, + search, +}: PluginRoutePageProps<PasswordResetRouteData, PasswordResetSearch>) => ( + <PasswordResetRouteContent + namespaces={loaderData.namespaces} + search={search} + /> +); + +export const route = defineRoute<PasswordResetRouteData, PasswordResetSearch>({ + /** + * The availability check runs first, and it is not a session guard - it asks + * whether this installation offers password recovery at all. It lived in + * `beforeLoad` when this route was built by hand; at the top of `load` it runs + * at the same point for the same reason, before anything is fetched. + */ + load: async ({ context, search }) => { + const availability = passwordRecoveryAvailability( + await context.queryClient.query({ + ...middlewareConfigQueryOptions(), + staleTime: "static", + }), + ); + + // Not a 404: the route exists, the API could not say whether the flow does. + if (availability === "unknown") throw new PasswordRecoveryUnknownError(); + + // TanStack Router's own control-flow signal, like `redirect()`. + // eslint-disable-next-line @typescript-eslint/only-throw-error + if (availability === "disabled") throw notFound(); + + return await loadPasswordResetRoute({ + ...context, + mode: passwordResetMode(search).mode, + }); + }, + head: ({ t }) => ({ title: t("core.auth.reset_password.title") }), + + notFound: function PasswordRecoveryNotFoundScreen() { + return <PasswordRecoveryNotFound actions={<ErrorActions />} />; + }, +}); + +export default PasswordResetPage; diff --git a/packages/vitnode/src/pages/login/sso.tsx b/packages/vitnode/src/pages/login/sso.tsx new file mode 100644 index 000000000..fdeb43985 --- /dev/null +++ b/packages/vitnode/src/pages/login/sso.tsx @@ -0,0 +1,26 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { SsoCallbackSearch } from "@/tanstack/auth/route-search"; + +import { loadSsoCallbackRoute } from "@/tanstack/auth/sso-route"; +import { SsoCallbackRouteContent } from "@/tanstack/auth/sso-screen"; +import { ErrorActions } from "@/tanstack/layout/error-actions"; +import { defineRoute } from "@/tanstack/plugin-routes"; + +const SsoCallbackPage = ({ + params, + search, +}: PluginRoutePageProps<undefined, SsoCallbackSearch>) => ( + <SsoCallbackRouteContent + errorActions={<ErrorActions />} + providerId={params.providerId} + search={search} + /> +); + +export const route = defineRoute<undefined, SsoCallbackSearch>({ + load: async ({ context }) => { + await loadSsoCallbackRoute(context); + }, +}); + +export default SsoCallbackPage; diff --git a/packages/vitnode/src/pages/register.tsx b/packages/vitnode/src/pages/register.tsx new file mode 100644 index 000000000..1617164db --- /dev/null +++ b/packages/vitnode/src/pages/register.tsx @@ -0,0 +1,15 @@ +import { useAppNavigate } from "../tanstack/auth/navigation"; +import { loadRegisterRoute } from "../tanstack/auth/register-route"; +import { RegisterRouteContent } from "../tanstack/auth/register-screen"; +import { defineRoute } from "../tanstack/plugin-routes"; + +const RegisterPage = () => <RegisterRouteContent navigate={useAppNavigate()} />; + +export const route = defineRoute<undefined>({ + load: async ({ context }) => { + await loadRegisterRoute(context); + }, + head: ({ t }) => ({ title: t("core.global.register") }), +}); + +export default RegisterPage; diff --git a/packages/vitnode/src/pages/search.tsx b/packages/vitnode/src/pages/search.tsx new file mode 100644 index 000000000..720681d5d --- /dev/null +++ b/packages/vitnode/src/pages/search.tsx @@ -0,0 +1,23 @@ +import type { PluginRoutePageProps } from "@/routing"; + +import type { SearchRouteSearch } from "../tanstack/search/route-search"; +import type { SearchRouteData } from "../tanstack/search/search-route"; + +import { defineRoute } from "../tanstack/plugin-routes"; +import { loadSearchRoute } from "../tanstack/search/search-route"; +import { SearchRouteContent } from "../tanstack/search/search-screen"; + +const SearchPage = ({ + loaderData, +}: PluginRoutePageProps<SearchRouteData, SearchRouteSearch>) => ( + <SearchRouteContent {...loaderData} /> +); + +export const route = defineRoute<SearchRouteData, SearchRouteSearch>({ + // `head` after `load`, always. + load: async ({ context, search, t }) => + await loadSearchRoute({ ...context, search: search.search, t }), + head: ({ loaderData }) => ({ robots: "index, follow", ...loaderData }), +}); + +export default SearchPage; diff --git a/packages/vitnode/src/pages/settings/devices.tsx b/packages/vitnode/src/pages/settings/devices.tsx new file mode 100644 index 000000000..e5fb08183 --- /dev/null +++ b/packages/vitnode/src/pages/settings/devices.tsx @@ -0,0 +1,34 @@ +import type { PluginRoutePageProps } from "@/routing"; + +import { DevicesPanelContent } from "@/tanstack/devices/panel"; +import { devicesQuery } from "@/tanstack/devices/query"; +import { defineAuthenticatedRoute } from "@/tanstack/plugin-routes"; +import { settingsBreadcrumb } from "@/tanstack/settings/breadcrumb"; + +interface DevicesData { + userId: number; +} + +const DevicesPage = ({ loaderData }: PluginRoutePageProps<DevicesData>) => ( + <DevicesPanelContent userId={loaderData.userId} /> +); + +export const route = defineAuthenticatedRoute<DevicesData>({ + load: async ({ context }) => { + const userId = context.auth.user.id; + + await context.queryClient.query({ + ...devicesQuery(userId), + staleTime: "static", + }); + + return { userId }; + }, + head: ({ t }) => ({ + title: `${t("core.auth.settings.nav.devices")} - ${t("core.auth.settings.title")}`, + }), + + breadcrumb: settingsBreadcrumb("devices"), +}); + +export default DevicesPage; diff --git a/packages/vitnode/src/pages/settings/index.tsx b/packages/vitnode/src/pages/settings/index.tsx new file mode 100644 index 000000000..ddf682a59 --- /dev/null +++ b/packages/vitnode/src/pages/settings/index.tsx @@ -0,0 +1,35 @@ +import type { PluginRoutePageProps } from "@/routing"; + +import { defineAuthenticatedRoute } from "@/tanstack/plugin-routes"; +import { userProfileQuery } from "@/tanstack/profile/query"; +import { OverviewSettings } from "@/tanstack/settings/overview"; +import { personalInfoPolicyQuery } from "@/tanstack/settings/personal-policy"; + +interface OverviewData { + nameCode: string; +} + +const OverviewPage = ({ loaderData }: PluginRoutePageProps<OverviewData>) => ( + <OverviewSettings nameCode={loaderData.nameCode} /> +); + +export const route = defineAuthenticatedRoute<OverviewData>({ + load: async ({ context }) => { + const { nameCode } = context.auth.user; + + await Promise.all([ + context.queryClient.query({ + ...userProfileQuery(nameCode), + staleTime: "static", + }), + context.queryClient.query(personalInfoPolicyQuery()), + ]); + + return { nameCode }; + }, + head: ({ t }) => ({ + title: `${t("core.auth.settings.nav.overview")} - ${t("core.auth.settings.title")}`, + }), +}); + +export default OverviewPage; diff --git a/packages/vitnode/src/pages/settings/layout.tsx b/packages/vitnode/src/pages/settings/layout.tsx new file mode 100644 index 000000000..0ab309167 --- /dev/null +++ b/packages/vitnode/src/pages/settings/layout.tsx @@ -0,0 +1,16 @@ +import { defineAuthenticatedRoute } from "@/tanstack/plugin-routes"; +import { settingsBreadcrumb } from "@/tanstack/settings/breadcrumb"; +import { SettingsLayoutContent } from "@/tanstack/settings/layout"; + +const SettingsLayout = ({ children }: { children: React.ReactNode }) => ( + <SettingsLayoutContent>{children}</SettingsLayoutContent> +); + +export const route = defineAuthenticatedRoute({ + head: () => ({ robots: "noindex, nofollow" }), + + /** The first crumb of the trail; each panel adds its own after it. */ + breadcrumb: settingsBreadcrumb(), +}); + +export default SettingsLayout; diff --git a/packages/vitnode/src/pages/settings/security.tsx b/packages/vitnode/src/pages/settings/security.tsx new file mode 100644 index 000000000..2621ee409 --- /dev/null +++ b/packages/vitnode/src/pages/settings/security.tsx @@ -0,0 +1,14 @@ +import { SecuritySettings } from "@/views/auth/settings/security/security"; + +import { defineAuthenticatedRoute } from "@/tanstack/plugin-routes"; +import { settingsBreadcrumb } from "@/tanstack/settings/breadcrumb"; + +export const route = defineAuthenticatedRoute({ + head: ({ t }) => ({ + title: `${t("core.auth.settings.nav.security")} - ${t("core.auth.settings.title")}`, + }), + + breadcrumb: settingsBreadcrumb("security"), +}); + +export default SecuritySettings; diff --git a/packages/vitnode/src/pages/users/profile.tsx b/packages/vitnode/src/pages/users/profile.tsx new file mode 100644 index 000000000..5a14e6b79 --- /dev/null +++ b/packages/vitnode/src/pages/users/profile.tsx @@ -0,0 +1,27 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { ProfileRouteData } from "@/tanstack/profile/route"; + +import { ErrorActions } from "@/tanstack/layout/error-actions"; +import { defineRoute } from "@/tanstack/plugin-routes"; +import { ProfileNotFound } from "@/tanstack/profile/not-found"; +import { loadProfileRoute } from "@/tanstack/profile/route"; +import { ProfileRouteContent } from "@/tanstack/profile/screen"; + +const ProfilePage = ({ + loaderData, +}: PluginRoutePageProps<ProfileRouteData>) => ( + <ProfileRouteContent nameCode={loaderData.nameCode} /> +); + +export const route = defineRoute<ProfileRouteData>({ + // `head` after `load`, always. + load: async ({ context, params, t }) => + await loadProfileRoute({ ...context, nameCode: params.nameCode, t }), + head: ({ loaderData }) => ({ robots: "index, follow", ...loaderData }), + + notFound: function ProfileRouteNotFound() { + return <ProfileNotFound actions={<ErrorActions />} />; + }, +}); + +export default ProfilePage; diff --git a/packages/vitnode/src/routes.test.ts b/packages/vitnode/src/routes.test.ts new file mode 100644 index 000000000..ab61f1627 --- /dev/null +++ b/packages/vitnode/src/routes.test.ts @@ -0,0 +1,142 @@ +// @vitest-environment node +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import type { PluginRoute } from "./routing"; + +import { routes } from "./routes"; +import { compilePluginRouteTrees, routeMatchKey } from "./routing"; + +const manifest = compilePluginRouteTrees([ + { pluginId: "@vitnode/core", routes }, +]).manifest; + +const pathsIn = (area: PluginRoute["area"]) => + manifest.filter(route => route.area === area).map(route => route.path); + +const source = readFileSync(join(__dirname, "routes.tsx"), "utf8"); + +/** + * Core's route tree, held to the promises its hand-built route modules used to + * make in three separate directories. + * + * Asserted against the *compiled manifest* rather than the file's text, which is + * what the directory this replaced could not do: a grep for `path:` can tell you + * a string appears, and this can tell you which URLs are actually claimed, by + * which shell, behind which guard. + */ +describe("core's route tree", () => { + it("compiles", () => { + expect(manifest.length).toBeGreaterThan(20); + }); + + /** + * The check the old `admin-routes.test.ts` made by reading source text: every + * AdminCP screen spells its full public URL, so a reader can take the URL from + * the address bar and grep their way to the route that answers it. + */ + it("spells every admin route's full path", () => { + for (const path of pathsIn("admin")) { + expect(path.startsWith("/admin/")).toBe(true); + } + }); + + /** + * `/admin/core` is the AdminCP's landing page and belongs to the application - + * the one admin URL core deliberately does not claim, so an app decides what + * its own dashboard says. + */ + it("leaves /admin/core to the application", () => { + expect(pathsIn("admin")).not.toContain("/admin/core"); + }); + + /** + * The AdminCP's sign-in page is the one core screen outside every shell: it is + * where somebody lands *because* they have no admin session, so it cannot + * render inside the shell that requires one. + */ + it("puts the AdminCP sign-in page outside every shell", () => { + expect(pathsIn("blank")).toEqual(["/admin"]); + expect(manifest.find(route => route.path === "/admin")?.requires).toBe( + "admin-guest", + ); + }); + + it("claims each URL exactly once", () => { + const keys = manifest.map( + route => `${route.kind} ${routeMatchKey(route.segments)}`, + ); + + expect(new Set(keys).size).toBe(keys.length); + }); + + /** One catch-all, and it is the Content Engine's. */ + it("declares exactly one catch-all", () => { + const splats = manifest.filter(route => + route.segments.some(segment => segment.kind === "splat"), + ); + + expect(splats.map(route => route.path)).toEqual(["/admin/content/*"]); + }); + + /** + * A guard is a declaration here rather than code in a route module, which is + * what lets one implementation answer for every route making the same promise. + */ + it("guards the screens that need a session, and only those", () => { + const guarded = Object.fromEntries( + manifest + .filter(route => route.requires !== null) + .map(route => [route.path, route.requires]), + ); + + expect(guarded).toEqual({ + "/admin": "admin-guest", + "/files": "authenticated", + "/login": "guest", + "/register": "guest", + "/settings": "authenticated", + }); + }); + + /** + * No admin route declares `requires`: the AdminCP has its own session, and a + * route in that area already renders behind the shell's guard. Per-screen + * staff permissions are checked in each loader instead, where the tuple lives. + */ + it("leaves the admin area's session to the AdminCP shell", () => { + for (const route of manifest) { + if (route.area === "admin") expect(route.requires).toBeNull(); + } + }); + + /** + * The tree is data a build tool reads in Node before any bundler runs. A page + * imported here rather than named through `lazy()` would be in the initial + * bundle, and a React import would make the file unreadable to that tool. + */ + it("reaches every screen through a lazy import and nothing else", () => { + const statically = [ + ...source.matchAll(/^import [\s\S]*?from "([^"]+)";$/gm), + ].map(([, specifier]) => specifier); + const lazily = [...source.matchAll(/import\("([^"]+)"\)/g)].map( + ([, specifier]) => specifier, + ); + + // What is imported up front is a search validator, a namespace list or the + // authoring vocabulary itself - values a route is made of. Nothing that + // renders. + expect( + statically.some(specifier => /-page$|-layout$/.test(specifier)), + ).toBe(false); + expect(statically).not.toContain("react"); + + // ...and every screen is behind one, in `src/pages/` - the same place a + // plugin keeps its own, so there is one answer to "where do pages live". + expect(lazily.length).toBe(manifest.length); + expect(lazily.every(specifier => specifier.startsWith("./pages/"))).toBe( + true, + ); + }); +}); diff --git a/packages/vitnode/src/routes.tsx b/packages/vitnode/src/routes.tsx new file mode 100644 index 000000000..9ba1d5239 --- /dev/null +++ b/packages/vitnode/src/routes.tsx @@ -0,0 +1,284 @@ +import { defineRoutes, index, layout, lazy, page } from "./routing"; +import { ADMIN_CRON_NAMESPACES } from "./tanstack/admin/cron/route"; +import { normalizeCronRouteSearch } from "./tanstack/admin/cron/route-search"; +import { ADMIN_DEBUG_NAMESPACES } from "./tanstack/admin/debug/route"; +import { normalizeDebugRouteSearch } from "./tanstack/admin/debug/route-search"; +import { ADMIN_FILES_NAMESPACES } from "./tanstack/admin/files/route"; +import { normalizeAdminFilesRouteSearch } from "./tanstack/admin/files/route-search"; +import { ADMIN_INTEGRATIONS_NAMESPACES } from "./tanstack/admin/integrations/route"; +import { ADMIN_QUEUE_NAMESPACES } from "./tanstack/admin/queue/route"; +import { normalizeQueueRouteSearch } from "./tanstack/admin/queue/route-search"; +import { ADMIN_ROLES_NAMESPACES } from "./tanstack/admin/roles/route"; +import { normalizeRolesRouteSearch } from "./tanstack/admin/roles/route-search"; +import { ADMIN_SEARCH_INDEX_NAMESPACES } from "./tanstack/admin/search-index/route"; +import { normalizeSearchIndexRouteSearch } from "./tanstack/admin/search-index/route-search"; +import { ADMIN_SIGN_IN_NAMESPACES } from "./tanstack/admin/sign-in-route"; +import { normalizeAdminSignInSearch } from "./tanstack/admin/sign-in-search"; +import { ADMIN_STAFF_CREATE_NAMESPACES } from "./tanstack/admin/staff/create-route"; +import { ADMIN_STAFF_EDIT_NAMESPACES } from "./tanstack/admin/staff/edit-route"; +import { ADMIN_STAFF_NAMESPACES } from "./tanstack/admin/staff/route"; +import { normalizeStaffRouteSearch } from "./tanstack/admin/staff/route-search"; +import { ADMIN_USER_NAMESPACES } from "./tanstack/admin/users/detail-route"; +import { ADMIN_USERS_NAMESPACES } from "./tanstack/admin/users/route"; +import { normalizeUsersRouteSearch } from "./tanstack/admin/users/route-search"; +import { LOGIN_NAMESPACES } from "./tanstack/auth/login-route"; +import { + normalizePasswordResetSearch, + PASSWORD_RESET_BASE_NAMESPACES, +} from "./tanstack/auth/recovery"; +import { REGISTER_NAMESPACES } from "./tanstack/auth/register-route"; +import { + normalizeLoginSearch, + normalizeSsoCallbackSearch, +} from "./tanstack/auth/route-search"; +import { SSO_CALLBACK_NAMESPACES } from "./tanstack/auth/sso-route"; +import { MY_FILES_NAMESPACES } from "./tanstack/files/route"; +import { normalizeMyFilesRouteSearch } from "./tanstack/files/route-search"; +import { + AuthPendingSkeleton, + CardsPendingSkeleton, + FeedPendingSkeleton, + FormPendingSkeleton, + ProfilePendingSkeleton, + TablePendingSkeleton, +} from "./tanstack/pending"; +import { + DISCOVER_NAMESPACES, + SEARCH_NAMESPACES, +} from "./tanstack/search/namespaces"; +import { normalizeSearchRouteSearch } from "./tanstack/search/route-search"; +import { PROFILE_NAMESPACES } from "./tanstack/profile/route"; +import { SETTINGS_NAMESPACES } from "./tanstack/settings/route"; + +/** + * Every URL `@vitnode/core` owns, as the same kind of declaration a plugin + * writes in its own `src/routes.tsx`. + * + * Browser-safe data and nothing else: a path, a shell, a guard, the skeleton to + * draw while it loads, and one `lazy(() => import(...))` per screen. That is + * what lets the build read this file in Node - it is imported by the Vite plugin + * while the config is loading - and what keeps every page in a chunk of its own. + * + * A `pendingComponent` is the one thing imported outright: a router draws it + * before the page's own chunk has arrived, so there is nothing to wait for it + * and TanStack Router never code-splits one. + * + * Core is not a configured plugin, so nothing in an app's `vitnode.config.ts` + * puts this tree in the registry; VitNode prepends it. See `CORE_PLUGIN_ID` in + * `framework/plugin-routes`. + */ +export const routes = defineRoutes([ + page("/discover", { + component: lazy(() => import("./pages/discover")), + messages: DISCOVER_NAMESPACES, + pendingComponent: FeedPendingSkeleton, + }), + + page("/search", { + component: lazy(() => import("./pages/search")), + messages: SEARCH_NAMESPACES, + pendingComponent: FeedPendingSkeleton, + search: normalizeSearchRouteSearch, + }), + + page("/users/:nameCode", { + component: lazy(() => import("./pages/users/profile")), + messages: PROFILE_NAMESPACES, + pendingComponent: ProfilePendingSkeleton, + }), + + page("/login", { + component: lazy(() => import("./pages/login/index")), + messages: LOGIN_NAMESPACES, + pendingComponent: AuthPendingSkeleton, + requires: "guest", + search: normalizeLoginSearch, + }), + + page("/register", { + component: lazy(() => import("./pages/register")), + messages: REGISTER_NAMESPACES, + pendingComponent: AuthPendingSkeleton, + requires: "guest", + }), + + page("/login/reset-password", { + component: lazy(() => import("./pages/login/reset-password")), + messages: PASSWORD_RESET_BASE_NAMESPACES, + pendingComponent: AuthPendingSkeleton, + search: normalizePasswordResetSearch, + }), + + page("/login/sso/:providerId", { + component: lazy(() => import("./pages/login/sso")), + messages: SSO_CALLBACK_NAMESPACES, + pendingComponent: AuthPendingSkeleton, + search: normalizeSsoCallbackSearch, + }), + + layout("/settings", { + component: lazy(() => import("./pages/settings/layout")), + // Declared rather than loaded inside `load`, because this frame's crumb + // renders these strings and a breadcrumb is drawn outside the page - so the + // runtime has to know the namespaces to wrap it in them. + messages: SETTINGS_NAMESPACES, + pendingComponent: () => ( + <FormPendingSkeleton className="container mx-auto" /> + ), + requires: "authenticated", + children: [ + index({ + component: lazy(() => import("./pages/settings/index")), + pendingComponent: FormPendingSkeleton, + }), + + page("security", { + component: lazy(() => import("./pages/settings/security")), + pendingComponent: FormPendingSkeleton, + }), + + page("devices", { + component: lazy(() => import("./pages/settings/devices")), + pendingComponent: () => <FeedPendingSkeleton rows={4} />, + }), + ], + }), + + page("/files", { + component: lazy(() => import("./pages/files")), + messages: MY_FILES_NAMESPACES, + pendingComponent: () => ( + <TablePendingSkeleton className="container mx-auto" /> + ), + requires: "authenticated", + search: normalizeMyFilesRouteSearch, + }), + page("/admin", { + area: "blank", + component: lazy(() => import("./pages/admin/sign-in")), + messages: ADMIN_SIGN_IN_NAMESPACES, + pendingComponent: AuthPendingSkeleton, + requires: "admin-guest", + search: normalizeAdminSignInSearch, + }), + + page("/admin/core/advanced/cron", { + area: "admin", + component: lazy(() => import("./pages/admin/advanced/cron")), + messages: ADMIN_CRON_NAMESPACES, + pendingComponent: TablePendingSkeleton, + search: normalizeCronRouteSearch, + }), + + page("/admin/core/advanced/queue", { + area: "admin", + component: lazy(() => import("./pages/admin/advanced/queue")), + messages: ADMIN_QUEUE_NAMESPACES, + pendingComponent: TablePendingSkeleton, + search: normalizeQueueRouteSearch, + }), + + page("/admin/core/advanced/search", { + area: "admin", + component: lazy(() => import("./pages/admin/advanced/search")), + messages: ADMIN_SEARCH_INDEX_NAMESPACES, + pendingComponent: TablePendingSkeleton, + search: normalizeSearchIndexRouteSearch, + }), + + page("/admin/core/debug", { + area: "admin", + component: lazy(() => import("./pages/admin/debug")), + messages: ADMIN_DEBUG_NAMESPACES, + pendingComponent: TablePendingSkeleton, + search: normalizeDebugRouteSearch, + }), + + page("/admin/core/system/files", { + area: "admin", + component: lazy(() => import("./pages/admin/system/files")), + messages: ADMIN_FILES_NAMESPACES, + pendingComponent: TablePendingSkeleton, + search: normalizeAdminFilesRouteSearch, + }), + + page("/admin/core/system/integrations", { + area: "admin", + component: lazy(() => import("./pages/admin/system/integrations")), + messages: ADMIN_INTEGRATIONS_NAMESPACES, + pendingComponent: CardsPendingSkeleton, + }), + + page("/admin/core/users", { + area: "admin", + component: lazy(() => import("./pages/admin/users/index")), + messages: ADMIN_USERS_NAMESPACES, + pendingComponent: TablePendingSkeleton, + search: normalizeUsersRouteSearch, + }), + + page("/admin/core/users/roles", { + area: "admin", + component: lazy(() => import("./pages/admin/users/roles")), + messages: ADMIN_ROLES_NAMESPACES, + pendingComponent: TablePendingSkeleton, + search: normalizeRolesRouteSearch, + }), + + page("/admin/core/users/:id", { + area: "admin", + component: lazy(() => import("./pages/admin/users/user")), + messages: ADMIN_USER_NAMESPACES, + pendingComponent: FormPendingSkeleton, + }), + + page("/admin/core/staff/admins", { + area: "admin", + component: lazy(() => import("./pages/admin/staff/admins/index")), + messages: ADMIN_STAFF_NAMESPACES, + pendingComponent: TablePendingSkeleton, + search: normalizeStaffRouteSearch, + }), + + page("/admin/core/staff/admins/create", { + area: "admin", + component: lazy(() => import("./pages/admin/staff/admins/create")), + messages: ADMIN_STAFF_CREATE_NAMESPACES, + pendingComponent: FormPendingSkeleton, + }), + + page("/admin/core/staff/admins/edit/:id", { + area: "admin", + component: lazy(() => import("./pages/admin/staff/admins/edit")), + messages: ADMIN_STAFF_EDIT_NAMESPACES, + pendingComponent: FormPendingSkeleton, + }), + + page("/admin/core/staff/moderators", { + area: "admin", + component: lazy(() => import("./pages/admin/staff/moderators/index")), + messages: ADMIN_STAFF_NAMESPACES, + pendingComponent: TablePendingSkeleton, + search: normalizeStaffRouteSearch, + }), + + page("/admin/core/staff/moderators/create", { + area: "admin", + component: lazy(() => import("./pages/admin/staff/moderators/create")), + messages: ADMIN_STAFF_CREATE_NAMESPACES, + pendingComponent: FormPendingSkeleton, + }), + + page("/admin/core/staff/moderators/edit/:id", { + area: "admin", + component: lazy(() => import("./pages/admin/staff/moderators/edit")), + messages: ADMIN_STAFF_EDIT_NAMESPACES, + pendingComponent: FormPendingSkeleton, + }), + + page("/admin/content/*", { + area: "admin", + component: lazy(() => import("./pages/admin/content")), + pendingComponent: TablePendingSkeleton, + }), +]); diff --git a/packages/vitnode/src/routing/authoring.test-d.ts b/packages/vitnode/src/routing/authoring.test-d.ts index 9d2f41759..c628b5ea5 100644 --- a/packages/vitnode/src/routing/authoring.test-d.ts +++ b/packages/vitnode/src/routing/authoring.test-d.ts @@ -96,9 +96,15 @@ describe("definePluginRoute", () => { }); }); - it("has no context type argument to bind", () => { - // `PluginRouteLoadArgs` is generic in the *search* only, so there is nowhere - // left to name a wider context even deliberately. + /** + * `PluginRouteLoadArgs` *is* generic in its context - that is how the framework + * layer describes the richer one a mounted route gets - but the parameter + * defaults to the public projection, and `definePluginRoute` pins it there. + * A plugin authoring through this door therefore has nowhere to name a wider + * context, which is the guarantee; the wider doors live in + * `@vitnode/core/tanstack/plugin-routes` and are named after what they promise. + */ + it("defaults its context to the public projection", () => { expectTypeOf< PluginRouteLoadArgs<{ section: string }>["context"] >().toEqualTypeOf<PluginRouteContext>(); diff --git a/packages/vitnode/src/routing/authoring.ts b/packages/vitnode/src/routing/authoring.ts index 489ed989a..c4edf2685 100644 --- a/packages/vitnode/src/routing/authoring.ts +++ b/packages/vitnode/src/routing/authoring.ts @@ -1,5 +1,6 @@ import type { - PluginRouteBreadcrumbProps, + PluginRouteBreadcrumbDeclaration, + PluginRouteContext, PluginRouteHead, PluginRouteHeadArgs, PluginRouteLoadArgs, @@ -9,18 +10,26 @@ import type { type UnknownLoaderData = "definePluginRoute: `loaderData` is typed only when `load` is declared ABOVE `head`"; -type AuthoredPluginRouteOptions<TData, TSearch> = Omit< - PluginRouteOptions<TData, TSearch>, +/** + * `PluginRouteOptions`, arranged so a plugin's `load` types its `head` and its + * breadcrumb. + * + * Exported for the framework layer's own authoring helpers, which bind + * `TContext` to the richer context they actually provide - see + * `@vitnode/core/tanstack/plugin-routes`. `definePluginRoute` below pins it to + * {@link PluginRouteContext}, so the plugin-facing door still promises exactly + * what every host guarantees and nothing more. + */ +export type AuthoredPluginRouteOptions<TData, TSearch, TContext> = Omit< + PluginRouteOptions<TData, TSearch, TContext>, "breadcrumb" | "head" | "load" > & { - breadcrumb?: - | false - | React.ComponentType<PluginRouteBreadcrumbProps<TData, NoInfer<TSearch>>>; + breadcrumb?: PluginRouteBreadcrumbDeclaration<TData, NoInfer<TSearch>>; head?: ( args: PluginRouteHeadArgs<NoInfer<TData>, NoInfer<TSearch>>, ) => PluginRouteHead; load?: ( - args: PluginRouteLoadArgs<NoInfer<TSearch>>, + args: PluginRouteLoadArgs<NoInfer<TSearch>, TContext>, ) => Promise<TData> | TData; }; @@ -28,5 +37,5 @@ export const definePluginRoute = < TData = UnknownLoaderData, TSearch = Record<string, never>, >( - options: AuthoredPluginRouteOptions<TData, TSearch>, + options: AuthoredPluginRouteOptions<TData, TSearch, PluginRouteContext>, ): PluginRouteOptions<TData, TSearch> => options; diff --git a/packages/vitnode/src/routing/errors.ts b/packages/vitnode/src/routing/errors.ts index 6a72a7941..6a2293a2a 100644 --- a/packages/vitnode/src/routing/errors.ts +++ b/packages/vitnode/src/routing/errors.ts @@ -11,6 +11,7 @@ export type PluginRouteErrorCode = | "invalid-parent-kind" | "invalid-parent-path" | "invalid-path" + | "invalid-pending" | "invalid-plugin-id" | "invalid-requires" | "invalid-search" diff --git a/packages/vitnode/src/routing/flatten.ts b/packages/vitnode/src/routing/flatten.ts index 3f4a6cefa..d0516ae46 100644 --- a/packages/vitnode/src/routing/flatten.ts +++ b/packages/vitnode/src/routing/flatten.ts @@ -20,6 +20,7 @@ export interface FlatPluginRoute { messages: string[]; parentId: null | string; path: string; + pendingComponent: null | React.FunctionComponent; requires: null | PluginRouteRequirement; routeId: string; search: null | PluginRouteSearchValidator; @@ -141,6 +142,33 @@ const readComponent = ( }); }; +/** + * A route's pending component, checked. + * + * The one declaration field a router reads *before* the route's module exists, + * which is why it is imported here rather than named through `lazy()`: there is + * nothing to wait for it. That also means it is in the initial bundle, so this + * refuses anything that is not a component rather than letting a stray value + * become a render-time crash. + */ +const readPendingComponent = ( + pendingComponent: unknown, + pluginId: string, + where: string, +): null | React.FunctionComponent => { + if (pendingComponent === undefined || pendingComponent === null) return null; + + if (typeof pendingComponent !== "function") { + return fail({ + code: "invalid-pending", + message: `${where} in ${pluginId} declares a \`pendingComponent\` that is not a component (got ${typeof pendingComponent}). Import the component and name it directly - a router draws it before this route's own chunk has loaded, so it cannot be \`lazy()\`.`, + pluginId, + }); + } + + return pendingComponent as React.FunctionComponent; +}; + const readSearch = ( search: unknown, kind: PluginRouteKind, @@ -284,6 +312,18 @@ const readNode = ({ }); } + // A catch-all swallows every remaining segment, so a layout ending in one + // leaves its children no URL to claim: the parent would match first and match + // everything. `page()` is the only shape a catch-all makes sense on. + if (declared.kind === "layout" && parsed.segments.at(-1)?.kind === "splat") { + return fail({ + code: "invalid-path", + message: `${where} in ${pluginId} is a layout whose path ends in "*". A catch-all matches every remaining segment, so nothing nested inside it could ever be reached - declare it as a page() instead.`, + path: parsed.path, + pluginId, + }); + } + const children = declared.children ?? []; if (declared.kind === "layout" && children.length === 0) { @@ -302,6 +342,11 @@ const readNode = ({ messages: readMessages(declared.messages, pluginId, where), parentId: parent === null ? null : parent.routeId, path: parsed.path, + pendingComponent: readPendingComponent( + declared.pendingComponent, + pluginId, + where, + ), requires: readRequires(declared.requires, area, pluginId, where), routeId: pluginRouteIdFor(declared.kind, parsed.path), search: readSearch(declared.search, declared.kind, pluginId, where), diff --git a/packages/vitnode/src/routing/index.ts b/packages/vitnode/src/routing/index.ts index bb6e77c19..987480666 100644 --- a/packages/vitnode/src/routing/index.ts +++ b/packages/vitnode/src/routing/index.ts @@ -1,3 +1,4 @@ +export type { AuthoredPluginRouteOptions } from "./authoring"; export { definePluginRoute } from "./authoring"; export type { PluginRouteErrorCode, PluginRouteErrorDetails } from "./errors"; export { PluginRouteError } from "./errors"; @@ -14,6 +15,8 @@ export { export type { CheckedPluginRouteModule, CheckedPluginRouteOptions, + PluginRouteBreadcrumbDeclaration, + PluginRouteBreadcrumbGroup, PluginRouteBreadcrumbProps, PluginRouteContext, PluginRouteHead, @@ -25,6 +28,7 @@ export type { PluginRoutePageModule, PluginRoutePageProps, PluginRouteRobots, + PluginRouteTranslator, } from "./module"; export { readPluginRouteModule } from "./module"; export { @@ -60,6 +64,7 @@ export type { } from "./tree"; export { definePluginRoutes, + defineRoutes, index, isPluginRouteDeclaration, isPluginRouteLazyComponent, diff --git a/packages/vitnode/src/routing/manifest.test.ts b/packages/vitnode/src/routing/manifest.test.ts index 205d1abeb..7c1b37db9 100644 --- a/packages/vitnode/src/routing/manifest.test.ts +++ b/packages/vitnode/src/routing/manifest.test.ts @@ -143,6 +143,46 @@ describe("normalising a declaration", () => { expect(route.area).toBe("admin"); }); + + /** + * A component, not a lazy specifier: a router draws it before the route's own + * chunk has arrived, so it is carried beside the manifest rather than in it - + * the manifest is data, and a component is not something data can hold. + */ + it("carries a route's pending component beside the manifest", () => { + const Pending = () => null; + const compiled = compilePluginRouteTrees([ + catalog( + page("/catalog", { component: lazyPage(), pendingComponent: Pending }), + ), + ]); + + expect(compiled.pendingComponents.get("@acme/catalog:page#/catalog")).toBe( + Pending, + ); + expect(compiled.manifest[0]).not.toHaveProperty("pendingComponent"); + }); + + it("holds only the routes that declared one", () => { + const compiled = compilePluginRouteTrees([ + catalog(page("/catalog", { component: lazyPage() })), + ]); + + expect(compiled.pendingComponents.size).toBe(0); + }); + + it("refuses a pending component that is not one", () => { + expect(() => + buildPluginRouteManifest([ + catalog( + page("/catalog", { + component: lazyPage(), + pendingComponent: "table" as never, + }), + ), + ]), + ).toThrow(/`pendingComponent` that is not a component \(got string\)/); + }); }); describe("ordering is decided by the paths, not by the registration order", () => { diff --git a/packages/vitnode/src/routing/manifest.ts b/packages/vitnode/src/routing/manifest.ts index f01ce57c0..f431e051e 100644 --- a/packages/vitnode/src/routing/manifest.ts +++ b/packages/vitnode/src/routing/manifest.ts @@ -31,6 +31,14 @@ export const pluginRouteId = (pluginId: string, routeId: string): string => export interface CompiledPluginRouteTrees { components: Map<string, PluginRouteLazyComponent>; manifest: PluginRouteManifest; + /** + * Each route's pending component, for the routes that declared one. + * + * A map rather than a field on {@link PluginRoute} for the same reason the + * lazy components are: the manifest is data, and a component is not something + * data can hold. + */ + pendingComponents: Map<string, React.FunctionComponent>; searchValidators: Map<string, PluginRouteSearchValidator>; } @@ -87,6 +95,7 @@ export const compilePluginRouteTrees = ( ): CompiledPluginRouteTrees => { const routes: PluginRoute[] = []; const components = new Map<string, PluginRouteLazyComponent>(); + const pendingComponents = new Map<string, React.FunctionComponent>(); const searchValidators = new Map<string, PluginRouteSearchValidator>(); const byId = new Map<string, PluginRoute>(); const byPath = new Map<string, PluginRoute>(); @@ -154,6 +163,10 @@ export const compilePluginRouteTrees = ( routes.push(route); components.set(route.id, flat.component); + if (flat.pendingComponent !== null) { + pendingComponents.set(route.id, flat.pendingComponent); + } + if (flat.search !== null) searchValidators.set(route.id, flat.search); } } @@ -166,7 +179,7 @@ export const compilePluginRouteTrees = ( // list by whatever mounts it, with this same function. buildPluginRouteGraph(manifest); - return { components, manifest, searchValidators }; + return { components, manifest, pendingComponents, searchValidators }; }; /** {@link compilePluginRouteTrees}, for a caller that only needs the data. */ diff --git a/packages/vitnode/src/routing/module.test.ts b/packages/vitnode/src/routing/module.test.ts index bf6c7832a..1f7ca653e 100644 --- a/packages/vitnode/src/routing/module.test.ts +++ b/packages/vitnode/src/routing/module.test.ts @@ -103,7 +103,7 @@ describe("readPluginRouteModule", () => { "p:page", ), ).toThrow( - /`route\.breadcrumb`, which must be a component or `false` \(got string\)/, + /`route\.breadcrumb`, which must be a component, a breadcrumbGroup\(\), or `false` \(got string\)/, ); }); @@ -114,10 +114,52 @@ describe("readPluginRouteModule", () => { "p:page", ), ).toThrow( - /`route\.breadcrumb`, which must be a component or `false` \(got object\)/, + /`route\.breadcrumb`, which must be a component, a breadcrumbGroup\(\), or `false` \(got object\)/, ); }); + /** + * A crumb whose answer is computed writes `null`, not `false`, and both mean + * the same thing to a trail. + */ + it("keeps a `route.breadcrumb` of null", () => { + const checked = readPluginRouteModule( + { default: Page, route: { breadcrumb: null } }, + "p:page", + ); + + expect(checked.route.breadcrumb).toBeNull(); + }); + + it("keeps a breadcrumb group", () => { + const group = () => null; + const checked = readPluginRouteModule( + { default: Page, route: { breadcrumb: { group } } }, + "p:page", + ); + + expect(checked.route.breadcrumb).toEqual({ group }); + }); + + it("keeps a `route.notFound` component", () => { + const notFound = () => null; + const checked = readPluginRouteModule( + { default: Page, route: { notFound } }, + "p:page", + ); + + expect(checked.route.notFound).toBe(notFound); + }); + + it("refuses a `route.notFound` that is not a component", () => { + expect(() => + readPluginRouteModule( + { default: Page, route: { notFound: "nope" } }, + "p:page", + ), + ).toThrow(/`route\.notFound`, which must be a function \(got string\)/); + }); + it("does not carry unknown members of `route` through", () => { const checked = readPluginRouteModule( // A plugin reaching for a TanStack route option gets nothing, silently - diff --git a/packages/vitnode/src/routing/module.ts b/packages/vitnode/src/routing/module.ts index 208a893ce..1f507fe19 100644 --- a/packages/vitnode/src/routing/module.ts +++ b/packages/vitnode/src/routing/module.ts @@ -10,17 +10,52 @@ export interface PluginRouteHead { title?: string; } +/** + * The least a route's `load` can count on, whoever mounts it. + * + * Deliberately small, and deliberately not the host's context: a route is handed + * a *projection*, so a field a host happens to carry does not become public API + * by accident - compiling today and arriving `undefined` on the next host. + * + * A framework layer widens this for the routes it mounts by passing its own + * context type as `TContext` - see `@vitnode/core/tanstack/plugin-routes`, which + * adds the query client every loader warms its data through, and the session a + * guarded route has already been checked against. + */ export interface PluginRouteContext { locale: string; } +/** + * Translates one of the route's declared messages. + * + * A plain function type, because nothing in `routing/` may reach into a + * framework layer - `boundaries.test.ts` holds that line. The runtime builds it + * from the namespaces the route declared in `messages`, which it has already + * fetched by the time either `load` or `head` runs. + * + * Keys are full dotted paths, the namespace included: + * `t("@acme/site-notes.home.title")`. A component writes + * `useTranslations("@acme/site-notes.home")` and then `t("title")`; here there is + * no component to scope, so the whole key is spelled out. + */ +export type PluginRouteTranslator = ( + key: string, + values?: Record<string, unknown>, +) => string; + /** What a plugin route's `load` is handed. */ -export interface PluginRouteLoadArgs<TSearch = unknown> { - context: PluginRouteContext; +export interface PluginRouteLoadArgs< + TSearch = unknown, + TContext = PluginRouteContext, +> { + context: TContext; /** The route's own dynamic segments, e.g. `{ slug: "hello" }`. */ params: Readonly<Record<string, string>>; /** Whatever `parseSearch` returned, or `{}` if the route declares none. */ search: TSearch; + /** Translates one of the namespaces this route declared in `messages`. */ + t: PluginRouteTranslator; } /** What a plugin route's `head` is handed. */ @@ -28,6 +63,14 @@ export interface PluginRouteHeadArgs<TData = unknown, TSearch = unknown> { loaderData?: TData; params: Readonly<Record<string, string>>; search: TSearch; + /** + * Translates one of the namespaces this route declared in `messages`. + * + * `head` runs outside the React tree, so `useTranslations` cannot reach it - + * this is the same strings by another door. The namespaces are already loaded + * by the time `head` runs, so nothing here waits on the network. + */ + t: PluginRouteTranslator; } export interface PluginRouteBreadcrumbProps< @@ -41,13 +84,62 @@ export interface PluginRouteBreadcrumbProps< search: TSearch; } -export interface PluginRouteOptions<TData = unknown, TSearch = unknown> { - breadcrumb?: - false | React.ComponentType<PluginRouteBreadcrumbProps<TData, TSearch>>; +/** + * A crumb that renders several items rather than one label. + * + * Declared structurally rather than imported from the breadcrumb model, because + * nothing in `routing/` may reach into a framework layer - see + * `boundaries.test.ts`. The two shapes are checked against each other where they + * meet, in `tanstack/plugin-routes/components.tsx`. + */ +export interface PluginRouteBreadcrumbGroup< + TData = unknown, + TSearch = unknown, +> { + /** + * A plain function type rather than `React.ComponentType`, so the props are + * contravariant and a group written against `unknown` loader data can be + * declared on a route that has some. `ComponentType` carries a `propTypes` + * field that is *co*variant in the props, which would make every such group a + * type error for no reason a reader could act on. + */ + group: ( + props: PluginRouteBreadcrumbProps<TData, TSearch>, + ) => null | React.ReactElement; +} + +/** + * What a route may say about its own crumb. + * + * `false` and `null` both mean "this route contributes nothing to the trail" and + * are accepted alike, because one of them is what an author writes when the + * answer is computed (`condition ? Crumb : null`) and the other is what they + * write when it is not. + */ +export type PluginRouteBreadcrumbDeclaration< + TData = unknown, + TSearch = unknown, +> = + | false + | null + | PluginRouteBreadcrumbGroup<TData, TSearch> + | React.ComponentType<PluginRouteBreadcrumbProps<TData, TSearch>>; + +export interface PluginRouteOptions< + TData = unknown, + TSearch = unknown, + TContext = PluginRouteContext, +> { + breadcrumb?: PluginRouteBreadcrumbDeclaration<TData, TSearch>; head?: (args: PluginRouteHeadArgs<TData, TSearch>) => PluginRouteHead; - load?: (args: PluginRouteLoadArgs<TSearch>) => Promise<TData> | TData; + load?: ( + args: PluginRouteLoadArgs<TSearch, TContext>, + ) => Promise<TData> | TData; + + /** Rendered when this route - or its loader - answers `notFound()`. */ + notFound?: React.ComponentType; parseSearch?: (input: unknown) => TSearch; } @@ -69,27 +161,46 @@ export interface PluginRoutePageProps< } /** A plugin route module that renders a page - `page()` or `index()`. */ -export interface PluginRoutePageModule<TData = unknown, TSearch = unknown> { +export interface PluginRoutePageModule< + TData = unknown, + TSearch = unknown, + TContext = PluginRouteContext, +> { default: React.FunctionComponent<PluginRoutePageProps<TData, TSearch>>; - route?: PluginRouteOptions<TData, TSearch>; + route?: PluginRouteOptions<TData, TSearch, TContext>; } /** A plugin route module that renders a frame - `layout()`. */ -export interface PluginRouteLayoutModule<TData = unknown, TSearch = unknown> { +export interface PluginRouteLayoutModule< + TData = unknown, + TSearch = unknown, + TContext = PluginRouteContext, +> { default: React.FunctionComponent< PluginRoutePageProps<TData, TSearch> & { children: React.ReactNode } >; - route?: PluginRouteOptions<TData, TSearch>; + route?: PluginRouteOptions<TData, TSearch, TContext>; } -export type PluginRouteModule<TData = unknown, TSearch = unknown> = - | PluginRouteLayoutModule<TData, TSearch> - | PluginRoutePageModule<TData, TSearch>; +export type PluginRouteModule< + TData = unknown, + TSearch = unknown, + TContext = PluginRouteContext, +> = + | PluginRouteLayoutModule<TData, TSearch, TContext> + | PluginRoutePageModule<TData, TSearch, TContext>; export interface CheckedPluginRouteOptions { - breadcrumb?: false | React.ComponentType<PluginRouteBreadcrumbProps<unknown>>; + breadcrumb?: PluginRouteBreadcrumbDeclaration; head?: (args: PluginRouteHeadArgs) => PluginRouteHead; - load?: (args: PluginRouteLoadArgs) => unknown; + /** + * The context is `unknown` here and only here: this is the *runtime's* view of + * a module it has just loaded and checked, and what the route was authored + * against - the public projection, or one of the narrower ones a guard earns - + * is a question the mount has already answered by the time it calls this. + */ + load?: (args: PluginRouteLoadArgs<unknown, unknown>) => unknown; + notFound?: React.ComponentType; parseSearch?: (input: unknown) => unknown; } @@ -107,9 +218,15 @@ const OPTION_KEYS = [ "breadcrumb", "head", "load", + "notFound", "parseSearch", ] as const satisfies readonly (keyof CheckedPluginRouteOptions)[]; +const isBreadcrumbGroup = ( + value: unknown, +): value is PluginRouteBreadcrumbGroup => + isRecord(value) && typeof value.group === "function"; + export const readPluginRouteModule = ( module: unknown, routeId: string, @@ -145,16 +262,26 @@ export const readPluginRouteModule = ( if (value === undefined) continue; - if (key === "breadcrumb" && value === false) { - options[key] = false; + if (key === "breadcrumb") { + if (value === false || value === null || isBreadcrumbGroup(value)) { + options[key] = value; + continue; + } + + if (typeof value !== "function") { + return fail( + "declares `route.breadcrumb`, which must be a component, a breadcrumbGroup(), or `false` (got " + + `${typeof value}).`, + ); + } + + options[key] = value; continue; } if (typeof value !== "function") { return fail( - key === "breadcrumb" - ? `declares \`route.breadcrumb\`, which must be a component or \`false\` (got ${typeof value}).` - : `declares \`route.${key}\`, which must be a function (got ${typeof value}).`, + `declares \`route.${key}\`, which must be a function (got ${typeof value}).`, ); } diff --git a/packages/vitnode/src/routing/order.ts b/packages/vitnode/src/routing/order.ts index 2aeaf16ed..1e4819071 100644 --- a/packages/vitnode/src/routing/order.ts +++ b/packages/vitnode/src/routing/order.ts @@ -1,5 +1,30 @@ import type { PluginRoute, PluginRouteSegment } from "./types"; +/** + * How specific a segment is: the narrower set of URLs sorts first. + * + * A static segment matches one, a parameter matches one of anything, a catch-all + * matches every remaining segment - so this is the order a reader expects a + * manifest in, and the order a router would have to resolve them in anyway. + */ +const SEGMENT_RANK: Record<PluginRouteSegment["kind"], number> = { + param: 1, + splat: 2, + static: 0, +}; + +/** A segment's own text, for breaking a tie between two of the same kind. */ +const segmentText = (segment: PluginRouteSegment): string => { + switch (segment.kind) { + case "param": + return segment.name; + case "splat": + return ""; + case "static": + return segment.value; + } +}; + const compareSegments = ( a: PluginRouteSegment[], b: PluginRouteSegment[], @@ -11,11 +36,11 @@ const compareSegments = ( const right = b[index]; if (left.kind !== right.kind) { - return left.kind === "static" ? -1 : 1; + return SEGMENT_RANK[left.kind] - SEGMENT_RANK[right.kind]; } - const leftText = left.kind === "static" ? left.value : left.name; - const rightText = right.kind === "static" ? right.value : right.name; + const leftText = segmentText(left); + const rightText = segmentText(right); if (leftText !== rightText) { return leftText < rightText ? -1 : 1; diff --git a/packages/vitnode/src/routing/path.test.ts b/packages/vitnode/src/routing/path.test.ts index 1668032eb..2fa33a125 100644 --- a/packages/vitnode/src/routing/path.test.ts +++ b/packages/vitnode/src/routing/path.test.ts @@ -162,12 +162,41 @@ describe("framework syntax is rejected by name", () => { }); }); -describe("route shapes this prototype defers", () => { - it("rejects a catch-all", () => { - expect(reason("/example/*")).toContain("catch-all"); +describe("a catch-all", () => { + it("reads a catch-all as its own kind of segment", () => { + expect(parse("/admin/content/*").segments).toEqual([ + { kind: "static", value: "admin" }, + { kind: "static", value: "content" }, + { kind: "splat" }, + ]); + }); + + it("round-trips through the canonical spelling", () => { + expect(parse("/admin/content/*").path).toBe("/admin/content/*"); + }); + + it("may be the whole path", () => { + expect(parse("/*").segments).toEqual([{ kind: "splat" }]); + }); + + /** + * A catch-all matches every remaining segment, so a segment after one could + * never be reached - and a route that can never match is worth a build error + * rather than a page nobody can open. + */ + it("refuses a segment after a catch-all", () => { + expect(reason("/admin/content/*/edit")).toContain('after its "*"'); + expect(reason("/admin/*/*")).toContain('after its "*"'); + }); + + it("names the VitNode spelling for each framework's syntax", () => { + expect(reason("/example/$")).toContain('write "*"'); + expect(reason("/example/**")).toContain('write "*"'); expect(reason("/example/[...slug]")).toContain("bracket filesystem syntax"); }); +}); +describe("route shapes this prototype defers", () => { it("rejects an optional segment", () => { expect(reason("/example/:slug?")).toContain("optional segment"); }); @@ -284,6 +313,15 @@ describe("the URLs a TanStack path matches", () => { expect(key("/api/$")).not.toBe(routeMatchKey(parse("/api/:id").segments)); }); + /** + * The two entrances to one key space have to agree about a catch-all too, or + * a plugin route at `/api/*` and an application route at `/api/$` would both + * claim every URL under `/api` without colliding. + */ + it("gives an application's splat and a route's catch-all one key", () => { + expect(routeMatchKey(parse("/api/*").segments)).toBe(key("/api/$")); + }); + /** * An application's own route files are not held to the plugin lowercase rule, * and a router would match `/Users` and `/users` as one URL either way. diff --git a/packages/vitnode/src/routing/path.ts b/packages/vitnode/src/routing/path.ts index 91ccd8f96..2fa87263f 100644 --- a/packages/vitnode/src/routing/path.ts +++ b/packages/vitnode/src/routing/path.ts @@ -29,13 +29,20 @@ const parseSegment = ( if (raw.startsWith("$")) { return { - reason: `"${raw}" is TanStack Router syntax - write ":${raw.slice(1) || "name"}" instead`, + reason: + raw === "$" + ? '"$" is TanStack Router syntax for a catch-all - write "*" instead' + : `"${raw}" is TanStack Router syntax - write ":${raw.slice(1) || "name"}" instead`, }; } - if (raw === "*" || raw === "**") { + if (raw === "*") { + return { segment: { kind: "splat" } }; + } + + if (raw === "**") { return { - reason: `"${raw}" is a catch-all segment, which VitNode route paths do not represent yet`, + reason: `"${raw}" is not how VitNode spells a catch-all - write "*" instead`, }; } @@ -133,64 +140,84 @@ export const parseRoutePath = (path: string): ParseRoutePathResult => { params.add(parsed.segment.name); } + // Checked as the *previous* segment gains a successor rather than by index, + // so the rule reads the same however the loop is written: a splat swallows + // everything after it, so there is nothing for a later segment to match. + if (segments.at(-1)?.kind === "splat") { + return { + ok: false, + reason: `"${path}" has a segment after its "*" - a catch-all matches every remaining segment, so it can only be last`, + }; + } + segments.push(parsed.segment); } return { ok: true, path: formatRoutePath(segments), segments }; }; -/** Segments back to their canonical VitNode path. */ -export function formatRoutePath(segments: PluginRouteSegment[]): string { - if (segments.length === 0) return "/"; - - return `/${segments - .map(segment => - segment.kind === "param" ? `:${segment.name}` : segment.value, - ) - .join("/")}`; -} - -export const toNextRoutePath = (segments: PluginRouteSegment[]): string => { - if (segments.length === 0) return "/"; +/** + * A splat, in a route match key. + * + * Deliberately not `:`. A splat swallows every remaining segment and a parameter + * swallows exactly one, so `/api/*` and `/api/:id` do *not* match the same URLs - + * `/api/a/b` reaches only the first. Giving them one key would break the single + * promise this whole key space makes: equal keys mean equal sets of URLs. + * + * Reached from both entrances - a VitNode path's `*` through {@link routeMatchKey} + * and an application's `$` through {@link routeMatchKeyFromTanStackPath} - so a + * plugin catch-all and an application catch-all at one URL collide, which is the + * whole point. + */ +const MATCH_KEY_SPLAT = "**"; - return `/${segments - .map(segment => - segment.kind === "param" ? `[${segment.name}]` : segment.value, - ) - .join("/")}`; +/** + * One segment in each of the four spellings this module emits. + * + * Written once, as a total function over the segment union, so a new kind of + * segment is a compile error in every projection at once rather than an + * `undefined` that reaches a router as the string "undefined". + */ +const projectSegment = ( + segment: PluginRouteSegment, + spelling: { + param: (name: string) => string; + splat: string; + }, +): string => { + switch (segment.kind) { + case "param": + return spelling.param(segment.name); + case "splat": + return spelling.splat; + case "static": + return segment.value; + } }; -/** Segments to TanStack Router syntax, `/blog/$slug`. */ -export const toTanStackRoutePath = (segments: PluginRouteSegment[]): string => { +const projectPath = ( + segments: PluginRouteSegment[], + spelling: { param: (name: string) => string; splat: string }, +): string => { if (segments.length === 0) return "/"; - return `/${segments - .map(segment => - segment.kind === "param" ? `$${segment.name}` : segment.value, - ) - .join("/")}`; + return `/${segments.map(segment => projectSegment(segment, spelling)).join("/")}`; }; -export const routeMatchKey = (segments: PluginRouteSegment[]): string => { - if (segments.length === 0) return "/"; +/** Segments back to their canonical VitNode path. */ +export function formatRoutePath(segments: PluginRouteSegment[]): string { + return projectPath(segments, { param: name => `:${name}`, splat: "*" }); +} - return `/${segments - .map(segment => (segment.kind === "param" ? ":" : segment.value)) - .join("/")}`; -}; +export const toNextRoutePath = (segments: PluginRouteSegment[]): string => + projectPath(segments, { param: name => `[${name}]`, splat: "[...slug]" }); -/** - * A splat, in a {@link routeMatchKeyFromTanStackPath} key. - * - * Deliberately not `:`. A splat swallows every remaining segment and a parameter - * swallows exactly one, so `/api/$` and `/api/:id` do *not* match the same URLs - - * `/api/a/b` reaches only the first. Giving them one key would break the single - * promise this whole key space makes: equal keys mean equal sets of URLs. No - * canonical VitNode path can produce this marker, because `parseRoutePath` - * rejects catch-alls outright, so a plugin route can never collide with an - * application's splat by key. - */ -const MATCH_KEY_SPLAT = "**"; +/** Segments to TanStack Router syntax, `/blog/$slug` and `/admin/content/$`. */ +export const toTanStackRoutePath = (segments: PluginRouteSegment[]): string => + projectPath(segments, { param: name => `$${name}`, splat: "$" }); + +export const routeMatchKey = (segments: PluginRouteSegment[]): string => + projectPath(segments, { param: () => ":", splat: MATCH_KEY_SPLAT }); /** * {@link routeMatchKey}, for a path already written in TanStack Router syntax. @@ -274,6 +301,12 @@ export const relativeRouteSegments = ( continue; } + // A splat carries no name, so matching kinds is the whole comparison. It can + // only ever be a parent's last segment, and `parseRoutePath` has already + // refused anything after one - so a child that got this far claims exactly + // its parent's URL. + if (here.kind === "splat") continue; + if (there.kind !== "param" || here.name !== there.name) return null; } diff --git a/packages/vitnode/src/routing/tree.test.ts b/packages/vitnode/src/routing/tree.test.ts index c0dbae5bd..976409f26 100644 --- a/packages/vitnode/src/routing/tree.test.ts +++ b/packages/vitnode/src/routing/tree.test.ts @@ -259,6 +259,34 @@ describe("the shape of a tree", () => { expect(error.message).toContain("index()"); }); + /** + * A catch-all matches every remaining segment, so a layout ending in one would + * match before any of its children and then match everything - the children + * would be unreachable rather than nested. + */ + it("refuses a layout whose path ends in a catch-all", () => { + const error = thrownBy(() => + flatten( + layout("/admin/content/*", { + component: lazyPage(), + children: [index({ component: lazyPage() })], + }), + ), + ); + + expect(error.code).toBe("invalid-path"); + expect(error.message).toContain("page()"); + }); + + it("carries a catch-all page through to its segments", () => { + const [route] = flatten( + page("/admin/content/*", { area: "admin", component: lazyPage() }), + ); + + expect(route.path).toBe("/admin/content/*"); + expect(route.segments.at(-1)).toEqual({ kind: "splat" }); + }); + it("refuses an index route with no layout around it", () => { const error = thrownBy(() => flatten(index({ component: lazyPage() }))); diff --git a/packages/vitnode/src/routing/tree.ts b/packages/vitnode/src/routing/tree.ts index 512799f3d..8f4b4d885 100644 --- a/packages/vitnode/src/routing/tree.ts +++ b/packages/vitnode/src/routing/tree.ts @@ -46,6 +46,14 @@ interface PluginRouteDeclarationShared<TModule> { | '`component` must be lazy(() => import("./pages/my-page")): a component imported into routes.ts is in the initial bundle, so its page cannot be split into a chunk of its own.' | PluginRouteLazyComponent<TModule>; messages?: readonly string[]; + /** + * Drawn while this route loads. + * + * Imported directly rather than named through `lazy()`, because a router needs + * it *before* the page's own chunk has arrived - so it is part of the initial + * bundle, and a heavy one is worth a second thought. + */ + pendingComponent?: React.FunctionComponent; requires?: PluginRouteRequirement; } @@ -87,6 +95,7 @@ export interface PluginRouteDeclaration { readonly kind: PluginRouteKind; readonly messages: readonly string[] | undefined; readonly path: null | string; + readonly pendingComponent: unknown; readonly requires: PluginRouteRequirement | undefined; readonly search: unknown; } @@ -110,6 +119,7 @@ interface PluginRouteDeclarationOptions { children?: readonly PluginRouteDeclaration[]; component: unknown; messages?: readonly string[]; + pendingComponent?: unknown; requires?: PluginRouteRequirement; search?: unknown; } @@ -129,6 +139,7 @@ const declaration = ( kind: options.kind, messages: options.messages, path: options.path, + pendingComponent: options.pendingComponent, requires: options.requires, search: options.search, }); @@ -182,3 +193,13 @@ export const definePluginRoutes = (routes: PluginRoutes): PluginRoutes => { return routes; }; + +/** + * {@link definePluginRoutes}, under the name core uses for its own tree. + * + * One function, two spellings, because "plugin" is wrong on exactly one caller: + * `@vitnode/core` declares its pages with this same vocabulary and is not a + * plugin. Everything downstream - the compiler, the manifest, the mount - treats + * the two the same, which is the whole point of core going through this door. + */ +export const defineRoutes = definePluginRoutes; diff --git a/packages/vitnode/src/routing/types.ts b/packages/vitnode/src/routing/types.ts index 976b1cde8..7b8b4544b 100644 --- a/packages/vitnode/src/routing/types.ts +++ b/packages/vitnode/src/routing/types.ts @@ -7,19 +7,28 @@ export type PluginRouteKind = "layout" | "page"; /** Every kind a route may declare. */ export const PLUGIN_ROUTE_KINDS: PluginRouteKind[] = ["layout", "page"]; -export type PluginRouteRequirement = "authenticated" | "guest"; +export type PluginRouteRequirement = "admin-guest" | "authenticated" | "guest"; /** Every requirement a route may declare. */ export const PLUGIN_ROUTE_REQUIREMENTS: PluginRouteRequirement[] = [ + "admin-guest", "authenticated", "guest", ]; export const PLUGIN_ROUTE_ID_SEPARATOR = ":"; -/** One parsed segment of a canonical VitNode route path. */ +/** + * One parsed segment of a canonical VitNode route path. + * + * A `splat` swallows every remaining segment and may only be the last one, which + * is what separates it from a `param`: `/admin/content/*` matches + * `/admin/content/a/b`, and `/admin/content/:id` does not. + */ export type PluginRouteSegment = - { kind: "param"; name: string } | { kind: "static"; value: string }; + | { kind: "param"; name: string } + | { kind: "splat" } + | { kind: "static"; value: string }; export type PluginRouteSearchValidator = ( input: Record<string, unknown>, diff --git a/packages/vitnode/src/tanstack/admin/breadcrumb.tsx b/packages/vitnode/src/tanstack/admin/breadcrumb.tsx index f81759940..4b3d317f0 100644 --- a/packages/vitnode/src/tanstack/admin/breadcrumb.tsx +++ b/packages/vitnode/src/tanstack/admin/breadcrumb.tsx @@ -1,13 +1,14 @@ import { useMatches, useRouter } from "@tanstack/react-router"; +import type { PluginRouteBreadcrumbGroup } from "@/routing"; + import { BreadcrumbAdminContent } from "@/views/admin/layouts/breadcrumb/breadcrumb-admin-content"; import { BreadcrumbTrailContent } from "@/views/breadcrumb/breadcrumb-trail-content"; -import type { RouteBreadcrumbGroup } from "../breadcrumb/model"; - -import { breadcrumbGroup, useBreadcrumbTrail } from "../breadcrumb/model"; +import { useBreadcrumbTrail } from "../breadcrumb/model"; import { useRouteNavigationPending } from "../pending/navigation-pending"; import { BreadcrumbPendingSkeleton } from "../pending/shapes"; +import { routeBreadcrumbGroup } from "../plugin-routes/authoring"; import { useAdminNav } from "./nav"; export const useAdminBreadcrumb = (): React.ReactNode => { @@ -43,9 +44,9 @@ export const AdminBreadcrumb = ({ /> ); -export const adminBreadcrumb = ( +export const adminBreadcrumb = <TData = unknown, TSearch = unknown>( props: Parameters<typeof AdminBreadcrumb>[0], -): RouteBreadcrumbGroup => - breadcrumbGroup(function AdminRouteBreadcrumb() { +): PluginRouteBreadcrumbGroup<TData, TSearch> => + routeBreadcrumbGroup(function AdminRouteBreadcrumb() { return <AdminBreadcrumb {...props} />; }); diff --git a/packages/vitnode/src/tanstack/admin/content/registry-runtime.ts b/packages/vitnode/src/tanstack/admin/content/registry-runtime.ts new file mode 100644 index 000000000..f732af205 --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/content/registry-runtime.ts @@ -0,0 +1,40 @@ +import type { ContentFrontendRegistry } from "@/content/admin/registry"; + +export type ContentRegistryLoader = () => Promise<ContentFrontendRegistry>; + +let loadRegistry: ContentRegistryLoader | undefined; + +/** + * Registers where the Content Engine's admin screens read their registry from. + * + * The one genuinely application-specific value the Content Engine's routes need, + * and the reason it is registered rather than imported: the registry is built by + * the app's own `content-registry.gen.ts`, from the plugins that app configured. + * Core cannot name that file, and it must not be in core's import graph - it + * carries every content type's editor fields and form layouts, which belong in + * the chunk of the screen that renders them and nowhere else. + * + * Call it from a module the router entry imports, the way `configureIntl` is + * called. A thunk, not a registry: nothing is loaded until an admin opens a + * content screen. + */ +export const configureContentRegistry = ( + loader: ContentRegistryLoader, +): void => { + loadRegistry = loader; +}; + +export const getContentRegistryLoader = (): ContentRegistryLoader => { + if (!loadRegistry) { + throw new Error( + "[VitNode] The Content Engine's admin registry is not configured - call `configureContentRegistry(() => import('./content-registry.gen').then(m => m.contentRegistry))` from a module your router entry imports.", + ); + } + + return loadRegistry; +}; + +/** Drops the registered loader. Exported for tests. */ +export const resetContentRegistry = (): void => { + loadRegistry = undefined; +}; diff --git a/packages/vitnode/src/tanstack/admin/cron/route.tsx b/packages/vitnode/src/tanstack/admin/cron/route.tsx index 71627eb59..8622dcd91 100644 --- a/packages/vitnode/src/tanstack/admin/cron/route.tsx +++ b/packages/vitnode/src/tanstack/admin/cron/route.tsx @@ -1,10 +1,9 @@ -import { createTranslator } from "use-intl"; +import type { PluginRouteTranslator } from "@/routing"; import type { CronParams } from "@/views/admin/views/core/advanced/cron/cron-query"; import type { AdminScreenContext } from "../screen"; -import { intlQueryOptions } from "../../i18n/query"; import { requireAdminPermission } from "../screen"; import { cronQuery } from "./query"; @@ -27,32 +26,23 @@ const CRON_VIEW_PERMISSION = { export const loadAdminCronRoute = async ({ adminAccess, - locale, params, queryClient, + t, }: AdminScreenContext & { params: CronParams; + t: PluginRouteTranslator; }): Promise<AdminCronRouteData> => { requireAdminPermission(adminAccess, CRON_VIEW_PERMISSION); - const [intl] = await Promise.all([ - queryClient.query({ - ...intlQueryOptions({ locale, namespaces: ADMIN_CRON_NAMESPACES }), - staleTime: "static", - }), - queryClient.query({ - ...cronQuery({ params }), - staleTime: "static", - }), - ]); - - const t = createTranslator({ - locale, - messages: intl.messages as { - admin: { advanced: { cron: { desc: string; title: string } } }; - }, - namespace: "admin.advanced.cron", + await queryClient.query({ + ...cronQuery({ params }), + staleTime: "static", }); - return { description: t("desc"), params, title: t("title") }; + return { + description: t("admin.advanced.cron.desc"), + params, + title: t("admin.advanced.cron.title"), + }; }; diff --git a/packages/vitnode/src/tanstack/admin/debug/route.tsx b/packages/vitnode/src/tanstack/admin/debug/route.tsx index 9ae6f21a5..381a8778e 100644 --- a/packages/vitnode/src/tanstack/admin/debug/route.tsx +++ b/packages/vitnode/src/tanstack/admin/debug/route.tsx @@ -1,10 +1,9 @@ -import { createTranslator } from "use-intl"; +import type { PluginRouteTranslator } from "@/routing"; import type { DebugLogsParams } from "@/views/admin/views/core/debug/debug-query"; import type { AdminScreenContext } from "../screen"; -import { intlQueryOptions } from "../../i18n/query"; import { requireAdminPermission } from "../screen"; import { debugLogsQuery, debugQueueQuery } from "./query"; @@ -38,19 +37,16 @@ const DEBUG_VIEW_PERMISSION = { export const loadAdminDebugRoute = async ({ adminAccess, - locale, params, queryClient, + t, }: AdminScreenContext & { params: DebugLogsParams; + t: PluginRouteTranslator; }): Promise<AdminDebugRouteData> => { requireAdminPermission(adminAccess, DEBUG_VIEW_PERMISSION); - const [intl] = await Promise.all([ - queryClient.query({ - ...intlQueryOptions({ locale, namespaces: ADMIN_DEBUG_NAMESPACES }), - staleTime: "static", - }), + await Promise.all([ queryClient.query({ ...debugQueueQuery(), staleTime: "static", @@ -61,26 +57,11 @@ export const loadAdminDebugRoute = async ({ }), ]); - const t = createTranslator({ - locale, - messages: intl.messages as { - admin: { - debug: { - desc: string; - logs: { title: string }; - queue: { title: string }; - title: string; - }; - }; - }, - namespace: "admin.debug", - }); - return { - description: t("desc"), - logsTitle: t("logs.title"), + description: t("admin.debug.desc"), + logsTitle: t("admin.debug.logs.title"), params, - queueTitle: t("queue.title"), - title: t("title"), + queueTitle: t("admin.debug.queue.title"), + title: t("admin.debug.title"), }; }; diff --git a/packages/vitnode/src/tanstack/admin/files/route.tsx b/packages/vitnode/src/tanstack/admin/files/route.tsx index 4939cfdda..0f243527f 100644 --- a/packages/vitnode/src/tanstack/admin/files/route.tsx +++ b/packages/vitnode/src/tanstack/admin/files/route.tsx @@ -1,10 +1,9 @@ -import { createTranslator } from "use-intl"; +import type { PluginRouteTranslator } from "@/routing"; import type { AdminFilesParams } from "@/views/admin/views/core/system/files/files-query"; import type { AdminScreenContext } from "../screen"; -import { intlQueryOptions } from "../../i18n/query"; import { requireAdminPermission } from "../screen"; import { adminFilesQuery } from "./query"; @@ -39,32 +38,23 @@ const FILES_VIEW_PERMISSION = { export const loadAdminFilesRoute = async ({ adminAccess, - locale, params, queryClient, + t, }: AdminScreenContext & { params: AdminFilesParams; + t: PluginRouteTranslator; }): Promise<AdminFilesRouteData> => { requireAdminPermission(adminAccess, FILES_VIEW_PERMISSION); - const [intl] = await Promise.all([ - queryClient.query({ - ...intlQueryOptions({ locale, namespaces: ADMIN_FILES_NAMESPACES }), - staleTime: "static", - }), - queryClient.query({ - ...adminFilesQuery({ params }), - staleTime: "static", - }), - ]); - - const t = createTranslator({ - locale, - messages: intl.messages as { - admin: { system: { files: { desc: string; title: string } } }; - }, - namespace: "admin.system.files", + await queryClient.query({ + ...adminFilesQuery({ params }), + staleTime: "static", }); - return { description: t("desc"), params, title: t("title") }; + return { + description: t("admin.system.files.desc"), + params, + title: t("admin.system.files.title"), + }; }; diff --git a/packages/vitnode/src/tanstack/admin/index.ts b/packages/vitnode/src/tanstack/admin/index.ts index 5739545f9..b71942f9f 100644 --- a/packages/vitnode/src/tanstack/admin/index.ts +++ b/packages/vitnode/src/tanstack/admin/index.ts @@ -29,11 +29,7 @@ export type { } from "./session-api"; export * from "./session-query"; export { AdminShellContent } from "./shell"; -export type { AdminSignInRouteData } from "./sign-in-route"; -export { - ADMIN_SIGN_IN_NAMESPACES, - loadAdminSignInRoute, -} from "./sign-in-route"; +export { ADMIN_SIGN_IN_NAMESPACES } from "./sign-in-route"; export type { AdminSignInRouteProps } from "./sign-in-screen"; export { AdminSignInRouteContent } from "./sign-in-screen"; export * from "./state"; diff --git a/packages/vitnode/src/tanstack/admin/integrations/route.tsx b/packages/vitnode/src/tanstack/admin/integrations/route.tsx index 339596508..e5ba409f1 100644 --- a/packages/vitnode/src/tanstack/admin/integrations/route.tsx +++ b/packages/vitnode/src/tanstack/admin/integrations/route.tsx @@ -1,8 +1,7 @@ -import { createTranslator } from "use-intl"; +import type { PluginRouteTranslator } from "@/routing"; import type { AdminScreenContext } from "../screen"; -import { intlQueryOptions } from "../../i18n/query"; import { requireAdminPermission } from "../screen"; import { integrationsQuery } from "./query"; @@ -32,32 +31,20 @@ const SYSTEM_VIEW_PERMISSION = { export const loadAdminIntegrationsRoute = async ({ adminAccess, - locale, queryClient, -}: AdminScreenContext): Promise<AdminIntegrationsRouteData> => { + t, +}: AdminScreenContext & { + t: PluginRouteTranslator; +}): Promise<AdminIntegrationsRouteData> => { requireAdminPermission(adminAccess, SYSTEM_VIEW_PERMISSION); - const [intl] = await Promise.all([ - queryClient.query({ - ...intlQueryOptions({ - locale, - namespaces: ADMIN_INTEGRATIONS_NAMESPACES, - }), - staleTime: "static", - }), - queryClient.query({ - ...integrationsQuery(), - staleTime: "static", - }), - ]); - - const t = createTranslator({ - locale, - messages: intl.messages as { - admin: { system: { integrations: { desc: string; title: string } } }; - }, - namespace: "admin.system.integrations", + await queryClient.query({ + ...integrationsQuery(), + staleTime: "static", }); - return { description: t("desc"), title: t("title") }; + return { + description: t("admin.system.integrations.desc"), + title: t("admin.system.integrations.title"), + }; }; diff --git a/packages/vitnode/src/tanstack/admin/queue/route.tsx b/packages/vitnode/src/tanstack/admin/queue/route.tsx index fea8a915a..63494838d 100644 --- a/packages/vitnode/src/tanstack/admin/queue/route.tsx +++ b/packages/vitnode/src/tanstack/admin/queue/route.tsx @@ -1,10 +1,9 @@ -import { createTranslator } from "use-intl"; +import type { PluginRouteTranslator } from "@/routing"; import type { QueueParams } from "@/views/admin/views/core/advanced/queue/queue-query"; import type { AdminScreenContext } from "../screen"; -import { intlQueryOptions } from "../../i18n/query"; import { requireAdminPermission } from "../screen"; import { queueQuery } from "./query"; @@ -32,32 +31,23 @@ const QUEUE_VIEW_PERMISSION = { export const loadAdminQueueRoute = async ({ adminAccess, - locale, params, queryClient, + t, }: AdminScreenContext & { params: QueueParams; + t: PluginRouteTranslator; }): Promise<AdminQueueRouteData> => { requireAdminPermission(adminAccess, QUEUE_VIEW_PERMISSION); - const [intl] = await Promise.all([ - queryClient.query({ - ...intlQueryOptions({ locale, namespaces: ADMIN_QUEUE_NAMESPACES }), - staleTime: "static", - }), - queryClient.query({ - ...queueQuery({ params }), - staleTime: "static", - }), - ]); - - const t = createTranslator({ - locale, - messages: intl.messages as { - admin: { advanced: { queue: { desc: string; title: string } } }; - }, - namespace: "admin.advanced.queue", + await queryClient.query({ + ...queueQuery({ params }), + staleTime: "static", }); - return { description: t("desc"), params, title: t("title") }; + return { + description: t("admin.advanced.queue.desc"), + params, + title: t("admin.advanced.queue.title"), + }; }; diff --git a/packages/vitnode/src/tanstack/admin/roles/route.tsx b/packages/vitnode/src/tanstack/admin/roles/route.tsx index 3b5e32980..dc117aac0 100644 --- a/packages/vitnode/src/tanstack/admin/roles/route.tsx +++ b/packages/vitnode/src/tanstack/admin/roles/route.tsx @@ -1,4 +1,4 @@ -import { createTranslator } from "use-intl"; +import type { PluginRouteTranslator } from "@/routing"; import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; import type { AdminRolesParams } from "@/views/admin/views/core/users/roles/roles-query"; @@ -7,7 +7,6 @@ import { ADMIN_ROLE_PERMISSIONS } from "@/views/admin/views/core/shared/admin-pe import type { AdminScreenContext } from "../screen"; -import { intlQueryOptions } from "../../i18n/query"; import { adminIdentityOf } from "../identity"; import { requireAdminPermission } from "../screen"; import { adminRolesQuery } from "./query"; @@ -27,46 +26,26 @@ export interface AdminRolesRouteData { export const loadAdminRolesRoute = async ({ adminAccess, - locale, params, queryClient, + t, }: AdminScreenContext & { params: AdminRolesParams; + t: PluginRouteTranslator; }): Promise<AdminRolesRouteData> => { requireAdminPermission(adminAccess, ADMIN_ROLE_PERMISSIONS.view); const adminUserId = adminIdentityOf(adminAccess); - const [intl] = await Promise.all([ - queryClient.query({ - ...intlQueryOptions({ locale, namespaces: ADMIN_ROLES_NAMESPACES }), - staleTime: "static", - }), - queryClient.query({ - ...adminRolesQuery({ adminUserId, params }), - staleTime: "static", - }), - ]); - - const messages = intl.messages as { - admin: { - global: { nav: { users: { roles: string } } }; - role: { list: { desc: string } }; - }; - }; + await queryClient.query({ + ...adminRolesQuery({ adminUserId, params }), + staleTime: "static", + }); return { adminUserId, - description: createTranslator({ - locale, - messages, - namespace: "admin.role.list", - })("desc"), + description: t("admin.role.list.desc"), params, - title: createTranslator({ - locale, - messages, - namespace: "admin.global.nav.users", - })("roles"), + title: t("admin.global.nav.users.roles"), }; }; diff --git a/packages/vitnode/src/tanstack/admin/search-index/route.tsx b/packages/vitnode/src/tanstack/admin/search-index/route.tsx index 0b43f4d3d..863c17474 100644 --- a/packages/vitnode/src/tanstack/admin/search-index/route.tsx +++ b/packages/vitnode/src/tanstack/admin/search-index/route.tsx @@ -1,8 +1,7 @@ -import { createTranslator } from "use-intl"; +import type { PluginRouteTranslator } from "@/routing"; import type { AdminScreenContext } from "../screen"; -import { intlQueryOptions } from "../../i18n/query"; import { requireAdminPermission } from "../screen"; import { searchIndexQuery } from "./query"; @@ -29,32 +28,20 @@ const SEARCH_INDEX_PERMISSION = { export const loadAdminSearchIndexRoute = async ({ adminAccess, - locale, queryClient, -}: AdminScreenContext): Promise<AdminSearchIndexRouteData> => { + t, +}: AdminScreenContext & { + t: PluginRouteTranslator; +}): Promise<AdminSearchIndexRouteData> => { requireAdminPermission(adminAccess, SEARCH_INDEX_PERMISSION); - const [intl] = await Promise.all([ - queryClient.query({ - ...intlQueryOptions({ - locale, - namespaces: ADMIN_SEARCH_INDEX_NAMESPACES, - }), - staleTime: "static", - }), - queryClient.query({ - ...searchIndexQuery(), - staleTime: "static", - }), - ]); - - const t = createTranslator({ - locale, - messages: intl.messages as { - core: { search: { admin: { desc: string; title: string } } }; - }, - namespace: "core.search.admin", + await queryClient.query({ + ...searchIndexQuery(), + staleTime: "static", }); - return { description: t("desc"), title: t("title") }; + return { + description: t("core.search.admin.desc"), + title: t("core.search.admin.title"), + }; }; diff --git a/packages/vitnode/src/tanstack/admin/sign-in-route.tsx b/packages/vitnode/src/tanstack/admin/sign-in-route.tsx index 0e8a5eb49..d1beba778 100644 --- a/packages/vitnode/src/tanstack/admin/sign-in-route.tsx +++ b/packages/vitnode/src/tanstack/admin/sign-in-route.tsx @@ -1,34 +1,4 @@ -import { createTranslator } from "use-intl"; - -import type { AdminLoaderContext } from "./intl"; - -import { intlQueryOptions } from "../i18n/query"; - export const ADMIN_SIGN_IN_NAMESPACES = [ "core.global", "core.auth.sign_in", ] as const; - -/** What the sign-in route's loader returns, and therefore what `head` receives. */ -export interface AdminSignInRouteData { - title: string; -} - -const translateAdminSignInTitle = (locale: string, messages: unknown): string => - createTranslator({ - locale, - messages: messages as { core: { global: { login: string } } }, - namespace: "core.global", - })("login"); - -export const loadAdminSignInRoute = async ({ - locale, - queryClient, -}: AdminLoaderContext): Promise<AdminSignInRouteData> => { - const intl = await queryClient.query({ - ...intlQueryOptions({ locale, namespaces: ADMIN_SIGN_IN_NAMESPACES }), - staleTime: "static", - }); - - return { title: translateAdminSignInTitle(locale, intl.messages) }; -}; diff --git a/packages/vitnode/src/tanstack/admin/sign-in-search.ts b/packages/vitnode/src/tanstack/admin/sign-in-search.ts new file mode 100644 index 000000000..d909fd204 --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/sign-in-search.ts @@ -0,0 +1,21 @@ +import { ADMIN_RETURN_TO_PARAM } from "./state"; + +export interface AdminSignInSearch { + returnTo?: string; +} + +/** + * The AdminCP sign-in page's query string. + * + * Its own module rather than the page's, because `src/routes.ts` names it and + * that file must not reach a page: a route tree is data the build reads in Node, + * and importing a screen from it would put the screen in the initial bundle. + */ +export const normalizeAdminSignInSearch = ( + input: Record<string, unknown>, +): AdminSignInSearch => ({ + returnTo: + typeof input[ADMIN_RETURN_TO_PARAM] === "string" + ? input[ADMIN_RETURN_TO_PARAM] + : undefined, +}); diff --git a/packages/vitnode/src/tanstack/admin/staff/create-route.tsx b/packages/vitnode/src/tanstack/admin/staff/create-route.tsx index 16f0c5d03..fd3c7f405 100644 --- a/packages/vitnode/src/tanstack/admin/staff/create-route.tsx +++ b/packages/vitnode/src/tanstack/admin/staff/create-route.tsx @@ -1,4 +1,4 @@ -import { createTranslator } from "use-intl"; +import type { PluginRouteTranslator } from "@/routing"; import type { PermissionStaffType } from "@/api/lib/permission-staff"; @@ -10,7 +10,6 @@ import { import type { AdminScreenContext } from "../screen"; -import { intlQueryOptions } from "../../i18n/query"; import { requireAdminPermission } from "../screen"; /** Only the two namespaces the screen renders from - no catalog is read here. */ @@ -29,41 +28,19 @@ export interface AdminStaffCreateRouteData { export const loadAdminStaffCreateRoute = async ({ adminAccess, - locale, - queryClient, + t, type, }: AdminScreenContext & { + t: PluginRouteTranslator; type: PermissionStaffType; }): Promise<AdminStaffCreateRouteData> => { requireAdminPermission(adminAccess, adminStaffPermissions(type).create); - const intl = await queryClient.query({ - ...intlQueryOptions({ locale, namespaces: ADMIN_STAFF_CREATE_NAMESPACES }), - staleTime: "static", - }); - - const t = createTranslator({ - locale, - messages: intl.messages as { - admin: { - staff: { - create: { - admins: string; - back: string; - desc: string; - moderators: string; - }; - }; - }; - }, - namespace: "admin.staff.create", - }); - - return { + return await Promise.resolve({ backHref: staffListHref(type), - backLabel: t("back"), - description: t("desc"), - title: t(STAFF_TYPE_SEGMENT[type]), + backLabel: t("admin.staff.create.back"), + description: t("admin.staff.create.desc"), + title: t(`admin.staff.create.${STAFF_TYPE_SEGMENT[type]}`), type, - }; + }); }; diff --git a/packages/vitnode/src/tanstack/admin/staff/edit-route.tsx b/packages/vitnode/src/tanstack/admin/staff/edit-route.tsx index 0c482756e..95dc04b16 100644 --- a/packages/vitnode/src/tanstack/admin/staff/edit-route.tsx +++ b/packages/vitnode/src/tanstack/admin/staff/edit-route.tsx @@ -1,7 +1,8 @@ +import type { PluginRouteTranslator } from "@/routing"; + import type { QueryClient } from "@tanstack/react-query"; import { notFound } from "@tanstack/react-router"; -import { createTranslator } from "use-intl"; import type { PermissionStaffType } from "@/api/lib/permission-staff"; import type { StaffPluginGroup } from "@/views/admin/views/core/staff/staff-model"; @@ -80,10 +81,12 @@ export const loadAdminStaffEditRoute = async ({ id: raw, locale, queryClient, + t, type, }: AdminScreenContext & { /** The `$id` segment, exactly as it was typed. Nothing has checked it yet. */ id: string; + t: PluginRouteTranslator; type: PermissionStaffType; }): Promise<AdminStaffEditRouteData> => { requireAdminPermission(adminAccess, adminStaffPermissions(type).edit); @@ -97,11 +100,7 @@ export const loadAdminStaffEditRoute = async ({ const adminUserId = adminIdentityOf(adminAccess); - const [intl, catalog, entry] = await Promise.all([ - queryClient.query({ - ...intlQueryOptions({ locale, namespaces: ADMIN_STAFF_EDIT_NAMESPACES }), - staleTime: "static", - }), + const [catalog, entry] = await Promise.all([ queryClient.query({ ...adminStaffCatalogQuery({ adminUserId }), staleTime: "static", @@ -118,17 +117,9 @@ export const loadAdminStaffEditRoute = async ({ queryClient, }); - const t = createTranslator({ - locale, - messages: intl.messages as { - admin: { staff: { edit: { back: string; title: string } } }; - }, - namespace: "admin.staff.edit", - }); - return { backHref: staffListHref(type), - backLabel: t("back"), + backLabel: t("admin.staff.edit.back"), grantedKeys: [...grantedStaffPermissionKeys(entry.permissions)], id, plugins: buildStaffPermissionGroups({ @@ -142,7 +133,7 @@ export const loadAdminStaffEditRoute = async ({ self: entry.self, user: entry.user, }, - title: t("title"), + title: t("admin.staff.edit.title"), type, unrestricted: entry.unrestricted, }; diff --git a/packages/vitnode/src/tanstack/admin/staff/navigation.ts b/packages/vitnode/src/tanstack/admin/staff/navigation.ts new file mode 100644 index 000000000..9afdd5113 --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/staff/navigation.ts @@ -0,0 +1,19 @@ +import { useRouter } from "@tanstack/react-router"; +import { useCallback } from "react"; + +/** + * Where a staff form hands a finished entry. + * + * Router-global rather than route-bound: a create form navigates *away* from the + * route that rendered it, to the record it just made. + */ +export const useStaffFormNavigate = (): ((href: string) => Promise<void>) => { + const router = useRouter(); + + return useCallback( + async (href: string) => { + await router.navigate({ to: href }); + }, + [router], + ); +}; diff --git a/packages/vitnode/src/tanstack/admin/staff/route.tsx b/packages/vitnode/src/tanstack/admin/staff/route.tsx index 6ec8935f6..d9318c1e1 100644 --- a/packages/vitnode/src/tanstack/admin/staff/route.tsx +++ b/packages/vitnode/src/tanstack/admin/staff/route.tsx @@ -1,4 +1,4 @@ -import { createTranslator } from "use-intl"; +import type { PluginRouteTranslator } from "@/routing"; import type { PermissionStaffType } from "@/api/lib/permission-staff"; import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; @@ -9,7 +9,6 @@ import { STAFF_TYPE_SEGMENT } from "@/views/admin/views/core/staff/staff-model"; import type { AdminScreenContext } from "../screen"; -import { intlQueryOptions } from "../../i18n/query"; import { adminIdentityOf } from "../identity"; import { requireAdminPermission } from "../screen"; import { adminStaffQuery } from "./query"; @@ -27,49 +26,34 @@ export interface AdminStaffRouteData { export const loadAdminStaffRoute = async ({ adminAccess, - locale, params, queryClient, + t, type, }: AdminScreenContext & { params: AdminStaffParams; + t: PluginRouteTranslator; type: PermissionStaffType; }): Promise<AdminStaffRouteData> => { requireAdminPermission(adminAccess, adminStaffPermissions(type).view); const adminUserId = adminIdentityOf(adminAccess); - const [intl] = await Promise.all([ - queryClient.query({ - ...intlQueryOptions({ locale, namespaces: ADMIN_STAFF_NAMESPACES }), - staleTime: "static", - }), - queryClient.query({ - ...adminStaffQuery({ adminUserId, params, type }), - staleTime: "static", - }), - ]); - - const t = createTranslator({ - locale, - messages: intl.messages as { - admin: { - staff: { - admins: { create: string; desc: string; title: string }; - moderators: { create: string; desc: string; title: string }; - }; - }; - }, - namespace: - `admin.staff.${STAFF_TYPE_SEGMENT[type]}` as "admin.staff.admins", + await queryClient.query({ + ...adminStaffQuery({ adminUserId, params, type }), + staleTime: "static", }); + // The kind of staff member is the URL, not a parameter, so it picks the + // branch of the message tree rather than being interpolated into a string. + const staff = `admin.staff.${STAFF_TYPE_SEGMENT[type]}`; + return { adminUserId, - createLabel: t("create"), - description: t("desc"), + createLabel: t(`${staff}.create`), + description: t(`${staff}.desc`), params, - title: t("title"), + title: t(`${staff}.title`), type, }; }; diff --git a/packages/vitnode/src/tanstack/admin/users/detail-route.tsx b/packages/vitnode/src/tanstack/admin/users/detail-route.tsx index fbb5eb08d..1f9e72ed4 100644 --- a/packages/vitnode/src/tanstack/admin/users/detail-route.tsx +++ b/packages/vitnode/src/tanstack/admin/users/detail-route.tsx @@ -1,5 +1,6 @@ import { notFound } from "@tanstack/react-router"; -import { createTranslator } from "use-intl"; + +import type { PluginRouteTranslator } from "@/routing"; import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; @@ -8,7 +9,6 @@ import { normalizeAdminUserId } from "@/views/admin/views/core/users/detail/user import type { AdminScreenContext } from "../screen"; -import { intlQueryOptions } from "../../i18n/query"; import { adminIdentityOf } from "../identity"; import { requireAdminPermission } from "../screen"; import { adminUserQuery } from "./query"; @@ -32,7 +32,9 @@ export const loadAdminUserRoute = async ({ id: raw, locale, queryClient, + t, }: AdminScreenContext & { + t: PluginRouteTranslator; /** The `$id` segment, exactly as it was typed. Nothing has checked it yet. */ id: string; }): Promise<AdminUserRouteData> => { @@ -47,29 +49,15 @@ export const loadAdminUserRoute = async ({ const adminUserId = adminIdentityOf(adminAccess); - const [intl, user] = await Promise.all([ - queryClient.query({ - ...intlQueryOptions({ locale, namespaces: ADMIN_USER_NAMESPACES }), - staleTime: "static", - }), - queryClient.query({ - ...adminUserQuery({ adminUserId, id }), - staleTime: "static", - }), - ]); - - const t = createTranslator({ - locale, - messages: intl.messages as { - admin: { user: { show: { title: string } } }; - }, - namespace: "admin.user.show", + const user = await queryClient.query({ + ...adminUserQuery({ adminUserId, id }), + staleTime: "static", }); return { adminUserId, id, locale, - title: `${user.name} - ${t("title")}`, + title: `${user.name} - ${t("admin.user.show.title")}`, }; }; diff --git a/packages/vitnode/src/tanstack/admin/users/route.tsx b/packages/vitnode/src/tanstack/admin/users/route.tsx index 78c584484..44aeb3527 100644 --- a/packages/vitnode/src/tanstack/admin/users/route.tsx +++ b/packages/vitnode/src/tanstack/admin/users/route.tsx @@ -1,4 +1,4 @@ -import { createTranslator } from "use-intl"; +import type { PluginRouteTranslator } from "@/routing"; import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; import type { AdminUsersParams } from "@/views/admin/views/core/users/list/users-query"; @@ -7,7 +7,6 @@ import { ADMIN_USER_PERMISSIONS } from "@/views/admin/views/core/shared/admin-pe import type { AdminScreenContext } from "../screen"; -import { intlQueryOptions } from "../../i18n/query"; import { adminIdentityOf } from "../identity"; import { requireAdminPermission } from "../screen"; import { adminUsersQuery } from "./query"; @@ -28,48 +27,26 @@ export interface AdminUsersRouteData { export const loadAdminUsersRoute = async ({ adminAccess, - locale, params, queryClient, + t, }: AdminScreenContext & { params: AdminUsersParams; + t: PluginRouteTranslator; }): Promise<AdminUsersRouteData> => { requireAdminPermission(adminAccess, ADMIN_USER_PERMISSIONS.view); const adminUserId = adminIdentityOf(adminAccess); - const [intl] = await Promise.all([ - queryClient.query({ - ...intlQueryOptions({ locale, namespaces: ADMIN_USERS_NAMESPACES }), - staleTime: "static", - }), - queryClient.query({ - ...adminUsersQuery({ adminUserId, params }), - staleTime: "static", - }), - ]); - - const messages = intl.messages as { - admin: { - global: { nav: { users: { list: string } } }; - user: { list: { desc: string } }; - }; - }; - const t = createTranslator({ - locale, - messages, - namespace: "admin.user.list", - }); - const tNav = createTranslator({ - locale, - messages, - namespace: "admin.global.nav.users", + await queryClient.query({ + ...adminUsersQuery({ adminUserId, params }), + staleTime: "static", }); return { adminUserId, - description: t("desc"), + description: t("admin.user.list.desc"), params, - title: tNav("list"), + title: t("admin.global.nav.users.list"), }; }; diff --git a/packages/vitnode/src/tanstack/auth/index.ts b/packages/vitnode/src/tanstack/auth/index.ts index e8efa013d..c6da58461 100644 --- a/packages/vitnode/src/tanstack/auth/index.ts +++ b/packages/vitnode/src/tanstack/auth/index.ts @@ -1,7 +1,7 @@ export * from "./actions"; export * from "./contract"; export { defaultAuthTransport } from "./default-transport"; -export type { AuthLoaderContext, AuthRouteData } from "./login-route"; +export type { AuthLoaderContext } from "./login-route"; export { loadLoginRoute, LOGIN_NAMESPACES } from "./login-route"; export type { LoginRouteProps } from "./login-screen"; export { LoginRouteContent } from "./login-screen"; diff --git a/packages/vitnode/src/tanstack/auth/login-route.tsx b/packages/vitnode/src/tanstack/auth/login-route.tsx index 044251146..64140fd58 100644 --- a/packages/vitnode/src/tanstack/auth/login-route.tsx +++ b/packages/vitnode/src/tanstack/auth/login-route.tsx @@ -1,8 +1,5 @@ import type { QueryClient } from "@tanstack/react-query"; -import { createTranslator } from "use-intl"; - -import { intlQueryOptions } from "../i18n/query"; import { middlewareConfigQueryOptions } from "./middleware-config"; export const LOGIN_NAMESPACES = [ @@ -17,47 +14,20 @@ export interface AuthLoaderContext { queryClient: QueryClient; } -/** What an auth route's loader returns, and therefore what `head` receives. */ -export interface AuthRouteData { - title: string; -} - -const translateAuthTitle = ( - locale: string, - messages: unknown, - key: "login" | "register", -): string => - createTranslator({ - locale, - messages: messages as { - core: { global: { login: string; register: string } }; - }, - namespace: "core.global", - })(key); - -const loadAuthCard = async ( - { locale, queryClient }: AuthLoaderContext, - namespaces: readonly string[], - key: "login" | "register", -): Promise<AuthRouteData> => { - const [intl] = await Promise.all([ - queryClient.query({ - ...intlQueryOptions({ locale, namespaces }), - staleTime: "static", - }), - queryClient.query({ - ...middlewareConfigQueryOptions(), - staleTime: "static", - }), - ]); - - return { title: translateAuthTitle(locale, intl.messages, key) }; +/** + * What both auth cards need before they render: the deployment's middleware + * configuration, which decides which SSO buttons exist. + * + * The title is no longer here - it is one string from `core.global`, and `head` + * translates it directly now that the route declares its namespaces. + */ +export const loadAuthCard = async ({ + queryClient, +}: AuthLoaderContext): Promise<void> => { + await queryClient.query({ + ...middlewareConfigQueryOptions(), + staleTime: "static", + }); }; -/** {@link loadAuthCard} for `/login`. */ -export const loadLoginRoute = async ( - context: AuthLoaderContext, -): Promise<AuthRouteData> => - await loadAuthCard(context, LOGIN_NAMESPACES, "login"); - -export { loadAuthCard, translateAuthTitle }; +export const loadLoginRoute = loadAuthCard; diff --git a/packages/vitnode/src/tanstack/auth/recovery-route.tsx b/packages/vitnode/src/tanstack/auth/recovery-route.tsx index 7a2d9a56b..d73c9fcf2 100644 --- a/packages/vitnode/src/tanstack/auth/recovery-route.tsx +++ b/packages/vitnode/src/tanstack/auth/recovery-route.tsx @@ -1,16 +1,20 @@ import type { QueryClient } from "@tanstack/react-query"; -import { createTranslator } from "use-intl"; - import { intlQueryOptions } from "../i18n/query"; import { passwordResetNamespaces } from "./recovery"; /** What {@link loadPasswordResetRoute} returns. */ export interface PasswordResetRouteData { namespaces: readonly string[]; - title: string; } +/** + * Warms the namespaces this screen renders, which depend on the mode the URL is + * asking for - the change-password form has copy the request form does not. + * + * The route's *title* is not here: it lives in `core.auth.reset_password`, which + * both modes declare, so `head` translates it directly. + */ export const loadPasswordResetRoute = async ({ locale, mode, @@ -21,18 +25,11 @@ export const loadPasswordResetRoute = async ({ queryClient: QueryClient; }): Promise<PasswordResetRouteData> => { const namespaces = passwordResetNamespaces(mode); - const intl = await queryClient.query({ + + await queryClient.query({ ...intlQueryOptions({ locale, namespaces }), staleTime: "static", }); - const title = createTranslator({ - locale, - messages: intl.messages as { - core: { auth: { reset_password: { title: string } } }; - }, - namespace: "core.auth.reset_password", - })("title"); - - return { namespaces, title }; + return { namespaces }; }; diff --git a/packages/vitnode/src/tanstack/auth/recovery.ts b/packages/vitnode/src/tanstack/auth/recovery.ts index dab2f349d..f15dbb086 100644 --- a/packages/vitnode/src/tanstack/auth/recovery.ts +++ b/packages/vitnode/src/tanstack/auth/recovery.ts @@ -32,7 +32,14 @@ export const passwordResetMode = ( return link ? { link, mode: "change" } : { mode: "request" }; }; -const PASSWORD_RESET_BASE_NAMESPACES = [ +/** + * What both recovery screens render, and what the route declares. + * + * `head` translates its title out of `core.auth.reset_password`, which is in + * here - so the title is available before the loader has decided which mode the + * URL is asking for. + */ +export const PASSWORD_RESET_BASE_NAMESPACES = [ "core.global", "core.auth.sign_up", "core.auth.reset_password", diff --git a/packages/vitnode/src/tanstack/auth/register-route.tsx b/packages/vitnode/src/tanstack/auth/register-route.tsx index 24925d056..c009c851e 100644 --- a/packages/vitnode/src/tanstack/auth/register-route.tsx +++ b/packages/vitnode/src/tanstack/auth/register-route.tsx @@ -1,4 +1,4 @@ -import type { AuthLoaderContext, AuthRouteData } from "./login-route"; +import type { AuthLoaderContext } from "./login-route"; import { loadAuthCard } from "./login-route"; @@ -8,8 +8,9 @@ export const REGISTER_NAMESPACES = [ "core.auth.sso", ] as const; -/** The strings and the deployment configuration `/register` needs. */ +/** The deployment configuration `/register` needs. Its title is in `head`. */ export const loadRegisterRoute = async ( context: AuthLoaderContext, -): Promise<AuthRouteData> => - await loadAuthCard(context, REGISTER_NAMESPACES, "register"); +): Promise<void> => { + await loadAuthCard(context); +}; diff --git a/packages/vitnode/src/tanstack/auth/sso-route.tsx b/packages/vitnode/src/tanstack/auth/sso-route.tsx index aae09a46f..3c3ca87af 100644 --- a/packages/vitnode/src/tanstack/auth/sso-route.tsx +++ b/packages/vitnode/src/tanstack/auth/sso-route.tsx @@ -1,6 +1,5 @@ import type { QueryClient } from "@tanstack/react-query"; -import { intlQueryOptions } from "../i18n/query"; import { middlewareConfigQueryOptions } from "./middleware-config"; /** What the SSO callback screens render strings from. */ @@ -9,21 +8,20 @@ export const SSO_CALLBACK_NAMESPACES = [ "core.auth.sso", ] as const; +/** + * The deployment configuration the callback needs to finish a sign-in. + * + * The strings are not fetched here: the route declares + * {@link SSO_CALLBACK_NAMESPACES} in `messages`, so the runtime warms them + * before this runs. + */ export const loadSsoCallbackRoute = async ({ - locale, queryClient, }: { - locale: string; queryClient: QueryClient; }): Promise<void> => { - await Promise.all([ - queryClient.query({ - ...intlQueryOptions({ locale, namespaces: SSO_CALLBACK_NAMESPACES }), - staleTime: "static", - }), - queryClient.query({ - ...middlewareConfigQueryOptions(), - staleTime: "static", - }), - ]); + await queryClient.query({ + ...middlewareConfigQueryOptions(), + staleTime: "static", + }); }; diff --git a/packages/vitnode/src/tanstack/files/route.tsx b/packages/vitnode/src/tanstack/files/route.tsx index 403df1c99..701776ee7 100644 --- a/packages/vitnode/src/tanstack/files/route.tsx +++ b/packages/vitnode/src/tanstack/files/route.tsx @@ -1,12 +1,11 @@ -import type { QueryClient } from "@tanstack/react-query"; +import type { PluginRouteTranslator } from "@/routing"; -import { createTranslator } from "use-intl"; +import type { QueryClient } from "@tanstack/react-query"; import type { MyFilesParams } from "@/views/files/my-files-query"; import type { MyFilesRouteSearch } from "./route-search"; -import { intlQueryOptions } from "../i18n/query"; import { myFilesQuery } from "./query"; export const MY_FILES_NAMESPACES = ["core.files", "core.global"] as const; @@ -28,34 +27,26 @@ export interface MyFilesRouteData { export const loadMyFilesRoute = async ({ auth, - locale, params, queryClient, + t, }: MyFilesLoaderContext & { params: MyFilesParams; + t: PluginRouteTranslator; }): Promise<MyFilesRouteData> => { const userId = auth.user.id; - const [intl] = await Promise.all([ - queryClient.query({ - ...intlQueryOptions({ locale, namespaces: MY_FILES_NAMESPACES }), - staleTime: "static", - }), - queryClient.query({ - ...myFilesQuery({ params, userId }), - staleTime: "static", - }), - ]); - - const t = createTranslator({ - locale, - messages: intl.messages as { - core: { files: { desc: string; title: string } }; - }, - namespace: "core.files", + await queryClient.query({ + ...myFilesQuery({ params, userId }), + staleTime: "static", }); - return { description: t("desc"), params, title: t("title"), userId }; + return { + description: t("core.files.desc"), + params, + title: t("core.files.title"), + userId, + }; }; export type MyFilesNavigate = (options: { diff --git a/packages/vitnode/src/tanstack/plugin-routes/authoring.ts b/packages/vitnode/src/tanstack/plugin-routes/authoring.ts new file mode 100644 index 000000000..17727c13e --- /dev/null +++ b/packages/vitnode/src/tanstack/plugin-routes/authoring.ts @@ -0,0 +1,98 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import type { + AuthoredPluginRouteOptions, + PluginRouteBreadcrumbGroup, + PluginRouteBreadcrumbProps, + PluginRouteContext, + PluginRouteOptions, +} from "@/routing"; + +import type { AdminAccessState } from "../admin/session-api"; +import type { AuthState } from "../auth/state"; + +/** + * What every route mounted by `withVitNodeRoutes` can count on. + * + * The locale a plugin already had, plus the query client - which is not a + * convenience. VitNode's whole caching story is "warm the route's data in its + * loader with `queryClient.query(...)`", and a loader that cannot reach one has + * to fetch in render instead. + * + * Still a projection of the host's context, not the host's context: these two + * fields are what every host promises, and a field a particular host happens to + * carry stays invisible here. + */ +export interface RouteLoadContext extends PluginRouteContext { + queryClient: QueryClient; +} + +/** + * The signed-in half of {@link AuthState}. + * + * `AuthState` is a union whose anonymous branch has a `null` user, and a route + * behind `requires: "authenticated"` has already been through a guard that + * redirected every visitor in that branch away. Narrowing here is what turns + * that runtime fact into a type - so a loader reads `auth.user.id` without a + * check it has no way to fail. + */ +export type AuthenticatedState = Extract<AuthState, { isAuthenticated: true }>; + +/** {@link RouteLoadContext} for a route that declared `requires`. */ +export interface AuthenticatedRouteLoadContext extends RouteLoadContext { + /** Resolved by the guard before `load` runs, so never anonymous here. */ + auth: AuthenticatedState; +} + +/** {@link RouteLoadContext} for a route in the `admin` area. */ +export interface AdminRouteLoadContext extends RouteLoadContext { + /** Resolved by the AdminCP shell, which a route in this area renders inside. */ + adminAccess: AdminAccessState; +} + +/** + * `definePluginRoute`, with the context a mounted route actually gets. + * + * Three doors rather than one, because the difference between them is a promise + * a route has already earned: `auth` exists because the route declared + * `requires` and a guard resolved it, and `adminAccess` exists because the route + * is in the `admin` area and renders inside the AdminCP shell. A route that made + * neither declaration is handed neither, and asking for one is a type error + * rather than an `undefined` at runtime. + */ +export const defineRoute = <TData = never, TSearch = Record<string, never>>( + options: AuthoredPluginRouteOptions<TData, TSearch, RouteLoadContext>, +): PluginRouteOptions<TData, TSearch, RouteLoadContext> => options; + +/** {@link defineRoute}, for a route that declared `requires`. */ +export const defineAuthenticatedRoute = < + TData = never, + TSearch = Record<string, never>, +>( + options: AuthoredPluginRouteOptions< + TData, + TSearch, + AuthenticatedRouteLoadContext + >, +): PluginRouteOptions<TData, TSearch, AuthenticatedRouteLoadContext> => options; + +/** {@link defineRoute}, for a route in the `admin` area. */ +export const defineAdminRoute = < + TData = never, + TSearch = Record<string, never>, +>( + options: AuthoredPluginRouteOptions<TData, TSearch, AdminRouteLoadContext>, +): PluginRouteOptions<TData, TSearch, AdminRouteLoadContext> => options; + +/** + * Declares that a route contributes several crumbs rather than one label. + * + * Generic where `breadcrumbGroup` in the breadcrumb model is not, so a group + * reads this route's own `loaderData` and `search` with their real types instead + * of `unknown`. The runtime renders the two shapes identically - this one only + * exists so the declaration can be type-checked against the route that carries + * it. + */ +export const routeBreadcrumbGroup = <TData = unknown, TSearch = unknown>( + group: PluginRouteBreadcrumbGroup<TData, TSearch>["group"], +): PluginRouteBreadcrumbGroup<TData, TSearch> => ({ group }); diff --git a/packages/vitnode/src/tanstack/plugin-routes/components.tsx b/packages/vitnode/src/tanstack/plugin-routes/components.tsx index 7fdf4df0e..c0bfd1a30 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/components.tsx +++ b/packages/vitnode/src/tanstack/plugin-routes/components.tsx @@ -6,7 +6,11 @@ import { } from "@tanstack/react-router"; import { createElement, Suspense, useCallback } from "react"; -import type { CheckedPluginRouteModule } from "@/routing"; +import type { + CheckedPluginRouteModule, + PluginRouteBreadcrumbGroup, + PluginRouteBreadcrumbProps, +} from "@/routing"; import type { RouteBreadcrumbDeferred, @@ -101,11 +105,18 @@ export const pluginLayoutComponent = ( }; }; -type DeclaredBreadcrumb = Exclude< - NonNullable<CheckedPluginRouteModule["route"]["breadcrumb"]>, - false +/** A crumb the module actually draws: the component half of either spelling. */ +type DeclaredBreadcrumb = React.ComponentType< + PluginRouteBreadcrumbProps<unknown> >; +const isBreadcrumbGroup = ( + value: unknown, +): value is PluginRouteBreadcrumbGroup => + typeof value === "object" && + value !== null && + typeof (value as PluginRouteBreadcrumbGroup).group === "function"; + export const pluginRouteBreadcrumb = ( module: PluginRouteModuleRef, namespaces: readonly string[], @@ -145,8 +156,18 @@ export const pluginRouteBreadcrumb = ( () => { const declared = module.current?.route.breadcrumb; - return declared === undefined || declared === false - ? declared + // `undefined` is "the module has not arrived yet, ask again"; `false` and + // `null` are both "this route contributes no crumb". They are kept apart + // here because only the first is worth re-resolving. + if (declared === undefined) return undefined; + if (declared === false || declared === null) return false; + + // A group draws its own `<BreadcrumbItem>`s, so it stays a group all the + // way to the trail - wrapping it in one would nest items inside an item. + // Only the component inside it is wrapped, and it is wrapped the same way + // a plain crumb is. + return isBreadcrumbGroup(declared) + ? { group: componentFor(declared.group) } : componentFor(declared); }, listener => module.subscribe(listener), diff --git a/packages/vitnode/src/tanstack/plugin-routes/guard.ts b/packages/vitnode/src/tanstack/plugin-routes/guard.ts index f808aa6f1..985b552b7 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/guard.ts +++ b/packages/vitnode/src/tanstack/plugin-routes/guard.ts @@ -6,6 +6,10 @@ import type { PluginRouteRequirement } from "@/routing"; import type { AuthState } from "../auth/state"; +import { sanitizeAdminReturnTo } from "../admin/return-to"; +import { prefetchAdminAccess } from "../admin/session-query"; +import { canEnterAdmin } from "../admin/state"; +import { internalDestination } from "../auth/navigation"; import { LOGIN_PATH, parseInternalDestination, @@ -58,6 +62,29 @@ export const pluginRouteGuard = ( }; } + if (requires === "admin-guest") { + return async ({ context, search }) => { + // `prefetch` rather than `ensure`, and the difference is the whole guard: + // this is the page somebody lands on *because* they have no admin session, + // so a read that throws when there is none would break the only route that + // can fix it. No session, or one that cannot enter, means stay here. + const access = await prefetchAdminAccess(context.queryClient); + + if (!access || !canEnterAdmin(access)) return undefined; + + const { returnTo } = (search ?? {}) as { returnTo?: unknown }; + + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw redirect( + internalDestination( + sanitizeAdminReturnTo( + typeof returnTo === "string" ? returnTo : undefined, + ), + ), + ); + }; + } + return async ({ context, search }) => { const auth = await ensureAuthState(context.queryClient); diff --git a/packages/vitnode/src/tanstack/plugin-routes/index.ts b/packages/vitnode/src/tanstack/plugin-routes/index.ts index 27b58f7d6..b5e76c00d 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/index.ts +++ b/packages/vitnode/src/tanstack/plugin-routes/index.ts @@ -1,3 +1,20 @@ +export type { ContentRegistryLoader } from "../admin/content/registry-runtime"; +export { + configureContentRegistry, + resetContentRegistry, +} from "../admin/content/registry-runtime"; +export type { + AdminRouteLoadContext, + AuthenticatedRouteLoadContext, + AuthenticatedState, + RouteLoadContext, +} from "./authoring"; +export { + defineAdminRoute, + defineAuthenticatedRoute, + defineRoute, + routeBreadcrumbGroup, +} from "./authoring"; export { fileRoutePaths } from "./collision"; export { PLUGIN_ROUTES_ROUTE_ID } from "./container"; export type { @@ -5,6 +22,7 @@ export type { PluginRoutePageHead, PluginRoutesMountOptions, } from "./mount"; -export { withPluginRoutes } from "./mount"; +// eslint-disable-next-line @typescript-eslint/no-deprecated +export { withPluginRoutes, withVitNodeRoutes } from "./mount"; export type { PluginRouteSpec } from "./specs"; export { pluginRouteSpecs } from "./specs"; diff --git a/packages/vitnode/src/tanstack/plugin-routes/mount-freshness.test.ts b/packages/vitnode/src/tanstack/plugin-routes/mount-freshness.test.ts index dda661c10..359508f7a 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/mount-freshness.test.ts +++ b/packages/vitnode/src/tanstack/plugin-routes/mount-freshness.test.ts @@ -14,7 +14,7 @@ import { definePluginRoutes, lazy, page } from "@/routing"; import type { PluginRoutePageHead } from "./mount"; import { PLUGIN_ROUTES_ROUTE_ID } from "./container"; -import { withPluginRoutes } from "./mount"; +import { withVitNodeRoutes } from "./mount"; import { pluginRouteSpecs } from "./specs"; const pageHead: PluginRoutePageHead = ({ title }) => ({ @@ -103,7 +103,7 @@ describe("enabling, disabling and re-enabling a plugin on a live route tree", () expect(owns(root, "/example")).toBe(false); - withPluginRoutes(root, specsFor(EXAMPLE), { + withVitNodeRoutes(root, specsFor(EXAMPLE), { mountUnder: { admin, main }, pageHead, }); @@ -115,7 +115,7 @@ describe("enabling, disabling and re-enabling a plugin on a live route tree", () const { admin, main, root } = appTree(); const mountUnder = { admin, main }; - withPluginRoutes(root, specsFor(EXAMPLE, REPORTS), { + withVitNodeRoutes(root, specsFor(EXAMPLE, REPORTS), { mountUnder, pageHead, }); @@ -123,7 +123,7 @@ describe("enabling, disabling and re-enabling a plugin on a live route tree", () expect(owns(root, "/reports")).toBe(true); // `example` removed from the app's configuration; `reports` still there. - withPluginRoutes(root, specsFor(REPORTS), { mountUnder, pageHead }); + withVitNodeRoutes(root, specsFor(REPORTS), { mountUnder, pageHead }); expect(owns(root, "/example")).toBe(false); expect(owns(root, "/reports")).toBe(true); @@ -133,11 +133,11 @@ describe("enabling, disabling and re-enabling a plugin on a live route tree", () const { admin, main, root } = appTree(); const mountUnder = { admin, main }; - withPluginRoutes(root, specsFor(EXAMPLE), { mountUnder, pageHead }); - withPluginRoutes(root, specsFor(), { mountUnder, pageHead }); + withVitNodeRoutes(root, specsFor(EXAMPLE), { mountUnder, pageHead }); + withVitNodeRoutes(root, specsFor(), { mountUnder, pageHead }); expect(owns(root, "/example")).toBe(false); - withPluginRoutes(root, specsFor(EXAMPLE), { mountUnder, pageHead }); + withVitNodeRoutes(root, specsFor(EXAMPLE), { mountUnder, pageHead }); expect(owns(root, "/example")).toBe(true); }); @@ -149,10 +149,10 @@ describe("enabling, disabling and re-enabling a plugin on a live route tree", () const { admin, main, root } = appTree(); const mountUnder = { admin, main }; - withPluginRoutes(root, specsFor(EXAMPLE), { mountUnder, pageHead }); + withVitNodeRoutes(root, specsFor(EXAMPLE), { mountUnder, pageHead }); expect(owns(root, "/example")).toBe(true); - withPluginRoutes(root, specsFor(plugin("example", "/showcase")), { + withVitNodeRoutes(root, specsFor(plugin("example", "/showcase")), { mountUnder, pageHead, }); @@ -168,7 +168,7 @@ describe("no orphan routes are left on the tree", () => { const mountUnder = { admin, main }; for (let pass = 0; pass < 4; pass++) { - withPluginRoutes(root, specsFor(EXAMPLE), { mountUnder, pageHead }); + withVitNodeRoutes(root, specsFor(EXAMPLE), { mountUnder, pageHead }); } const containers = childrenOf(main).filter( @@ -191,10 +191,10 @@ describe("no orphan routes are left on the tree", () => { ) .map(child => (child.options as { path?: string }).path); - withPluginRoutes(root, specsFor(EXAMPLE), { mountUnder, pageHead }); + withVitNodeRoutes(root, specsFor(EXAMPLE), { mountUnder, pageHead }); expect(ownPaths()).toEqual(["/"]); - withPluginRoutes(root, specsFor(), { mountUnder, pageHead }); + withVitNodeRoutes(root, specsFor(), { mountUnder, pageHead }); expect(ownPaths()).toEqual(["/"]); expect(owns(root, "/")).toBe(true); }); @@ -214,7 +214,7 @@ describe("no orphan routes are left on the tree", () => { ]), }; - withPluginRoutes(root, specsFor(EXAMPLE, ADMIN_PAGE), { + withVitNodeRoutes(root, specsFor(EXAMPLE, ADMIN_PAGE), { mountUnder, pageHead, }); @@ -222,7 +222,7 @@ describe("no orphan routes are left on the tree", () => { expect(owns(root, "/admin/reports")).toBe(true); // Only the admin plugin is disabled. - withPluginRoutes(root, specsFor(EXAMPLE), { mountUnder, pageHead }); + withVitNodeRoutes(root, specsFor(EXAMPLE), { mountUnder, pageHead }); expect(owns(root, "/admin/reports")).toBe(false); expect(owns(root, "/example")).toBe(true); @@ -234,7 +234,7 @@ describe("no orphan routes are left on the tree", () => { const { admin, main, root } = appTree(); const before = childrenOf(admin).length; - withPluginRoutes(root, specsFor(EXAMPLE), { + withVitNodeRoutes(root, specsFor(EXAMPLE), { mountUnder: { admin, main }, pageHead, }); diff --git a/packages/vitnode/src/tanstack/plugin-routes/mount.tsx b/packages/vitnode/src/tanstack/plugin-routes/mount.tsx index 608bfe56e..6d052505f 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/mount.tsx +++ b/packages/vitnode/src/tanstack/plugin-routes/mount.tsx @@ -1,7 +1,12 @@ import type { QueryClient } from "@tanstack/react-query"; import type { AnyRoute } from "@tanstack/react-router"; +import type { NotFoundRouteProps } from "@tanstack/react-router"; -import { createRoute, lazyRouteComponent } from "@tanstack/react-router"; +import { + createRoute, + lazyRouteComponent, + useRouter, +} from "@tanstack/react-router"; import type { PluginRouteArea } from "@/routing"; @@ -16,6 +21,7 @@ import type { PluginRouteSpec } from "./specs"; // on a route's `staticData` - see `../breadcrumb/model`. import "../breadcrumb/model"; import { intlQueryOptions } from "../i18n/query"; +import { pageHead as defaultPageHead } from "../metadata"; import { assertNoAppCollision, declaredOptions, @@ -29,9 +35,14 @@ import { import { PLUGIN_ROUTES_ROUTE_ID } from "./container"; import { pluginRouteGuard } from "./guard"; import { normalizePluginRouteHead } from "./head"; +import { pluginRouteTranslator } from "./translator"; import { pluginRouteSearchDeps } from "./specs"; export interface PluginRouteRuntimeContext { + /** Present in the `admin` area, contributed by the AdminCP shell. */ + adminAccess?: unknown; + /** Present on a guarded route, contributed by that route's own guard. */ + auth?: unknown; locale: string; queryClient: QueryClient; } @@ -44,19 +55,27 @@ export type PluginRouteAreaRoutes = Partial<Record<PluginRouteArea, AnyRoute>>; export interface PluginRoutesMountOptions { mountUnder?: PluginRouteAreaRoutes; - pageHead: PluginRoutePageHead; + /** + * Optional, and only for an application that formats its titles differently: + * `pageHead` reads the site's own metadata from the VitNode config itself, so + * the default is already this application's. + */ + pageHead?: PluginRoutePageHead; } const pluginRouteHead = - (module: PluginRouteModuleRef, pageHead: PluginRoutePageHead) => + (spec: PluginRouteSpec, pageHead: PluginRoutePageHead) => async ({ loaderData, + match, params, }: { loaderData?: unknown; + /** The router hands `head` the match, and a match carries its context. */ + match: { context: PluginRouteRuntimeContext }; params: Readonly<Record<string, string>>; }): Promise<Partial<RouteHeadResult>> => { - const { route } = await module(); + const { route } = await spec.module(); if (!route.head) return {}; @@ -68,6 +87,10 @@ const pluginRouteHead = loaderData: envelope.data, params, search: envelope.search ?? {}, + // `head` runs outside the React tree, so `useTranslations` cannot + // reach it. The namespaces are already cached by the loader, so this + // resolves without a request. + t: await pluginRouteTranslator(spec, match.context), }), ), ); @@ -96,6 +119,8 @@ const pluginRouteLoader = staleTime: "static", }), ]); + // Built from the query the line above just warmed, so it costs a cache read. + const t = await pluginRouteTranslator(spec, context); const search = spec.validateSearch ? deps @@ -105,13 +130,27 @@ const pluginRouteLoader = return { data: route.load - ? // Projected, never forwarded. `context` here is the host's - it holds - // this app's `QueryClient` - and handing it over whole would make - // every field on it public plugin API by accident, compiling today and - // arriving `undefined` on a host that has no such field. What crosses - // the boundary is `PluginRouteContext` and only that. + ? // Projected, never forwarded. `context` here is the host's, and handing + // it over whole would make every field on it public API by accident - + // compiling today and arriving `undefined` on a host that has no such + // field. What crosses the boundary is the four fields below and + // nothing else, which is what `RouteLoadContext` and its two narrower + // spellings describe. + // + // `auth` and `adminAccess` are copied across only when the host put + // them there, so a route that declared neither `requires` nor the + // `admin` area cannot observe one - and the type it is authored + // against says the same. await route.load({ - context: { locale: context.locale }, + t, + context: { + ...(context.adminAccess === undefined + ? {} + : { adminAccess: context.adminAccess }), + ...(context.auth === undefined ? {} : { auth: context.auth }), + locale: context.locale, + queryClient: context.queryClient, + }, params, search, }) @@ -120,6 +159,35 @@ const pluginRouteLoader = }; }; +/** + * The screen a route shows when it - or its loader - answers `notFound()`. + * + * Lazy, and deliberately so: unlike the pending skeleton, which has to exist + * before the module does, a not-found screen is only ever rendered after the + * route has been matched. It can live in the module's own chunk and cost an + * unvisited route nothing. + * + * Every route gets one, because whether a module declares `notFound` is not + * knowable until the module has loaded and a route's options are fixed when the + * route is built. A module that declares none falls through to the application's + * own `defaultNotFoundComponent`, which is what the route would have reached had + * it declared nothing at all - so the fallback is the default, not a blank page. + */ +const pluginRouteNotFound = (spec: PluginRouteSpec) => + lazyRouteComponent(async () => { + const { route } = await spec.module(); + + return { + default: + route.notFound ?? + function PluginRouteNotFoundFallback(props: NotFoundRouteProps) { + const Default = useRouter().options.defaultNotFoundComponent; + + return Default ? <Default {...props} /> : null; + }, + }; + }); + const pluginRouteOptions = ( spec: PluginRouteSpec, pageHead: PluginRoutePageHead, @@ -130,12 +198,19 @@ const pluginRouteOptions = ( ...(beforeLoad ? { beforeLoad } : {}), ...(spec.validateSearch ? { validateSearch: spec.validateSearch } : {}), + // Absent rather than `undefined`, so a route that declares none leaves the + // router's own `defaultPendingComponent` in place instead of overriding it + // with nothing. + ...(spec.pendingComponent + ? { pendingComponent: spec.pendingComponent } + : {}), + notFoundComponent: pluginRouteNotFound(spec), component: lazyRouteComponent(async () => ({ default: (spec.route.kind === "layout" ? pluginLayoutComponent : pluginPageComponent)(await spec.module(), spec.namespaces), })), - head: pluginRouteHead(spec.module, pageHead), + head: pluginRouteHead(spec, pageHead), loader: pluginRouteLoader(spec), loaderDeps: ({ search }: { search: unknown }) => @@ -247,10 +322,19 @@ const mountPluginSubtree = ( mountPoint.addChildren([...siblings, container]); }; -export const withPluginRoutes = <TRouteTree extends AnyRoute>( +/** + * Mounts every declared route - core's own and every configured plugin's - into + * an application's route tree. + * + * One call, because there is one kind of route now. `@vitnode/core` reaches this + * through the same registry a plugin does, so an application no longer composes + * `withCoreRootRoutes(withCoreAdminRoutes(withCoreMainRoutes(…)))` around it + * and can no longer get that nesting wrong. + */ +export const withVitNodeRoutes = <TRouteTree extends AnyRoute>( routeTree: TRouteTree, specs: PluginRouteSpec[], - { mountUnder, pageHead }: PluginRoutesMountOptions, + { mountUnder, pageHead = defaultPageHead }: PluginRoutesMountOptions, ): TRouteTree => { // Stage 11's default, kept: an application that names no shell has its plugin // pages hang from the tree's root, which is what a host with no chrome wants. @@ -274,3 +358,6 @@ export const withPluginRoutes = <TRouteTree extends AnyRoute>( return routeTree; }; + +/** @deprecated Renamed to {@link withVitNodeRoutes}; core's routes mount here too. */ +export const withPluginRoutes = withVitNodeRoutes; diff --git a/packages/vitnode/src/tanstack/plugin-routes/plugin-routes.test.ts b/packages/vitnode/src/tanstack/plugin-routes/plugin-routes.test.ts index 4d0f92328..2599b80ad 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/plugin-routes.test.ts +++ b/packages/vitnode/src/tanstack/plugin-routes/plugin-routes.test.ts @@ -11,7 +11,7 @@ import type { PluginRoutePageHead } from "./mount"; import { fileRoutePaths } from "./collision"; import { PLUGIN_ROUTES_ROUTE_ID } from "./container"; -import { withPluginRoutes } from "./mount"; +import { withVitNodeRoutes } from "./mount"; import { pluginRouteSpecs } from "./specs"; /** A page module, as a `lazy()` that resolves without a bundler. */ @@ -40,12 +40,12 @@ const pageHead: PluginRoutePageHead = ({ description, robots, title }) => ({ }); const mount = (tree: AnyRoute, specs: ReturnType<typeof pluginRouteSpecs>) => - withPluginRoutes(tree, specs, { pageHead }); + withVitNodeRoutes(tree, specs, { pageHead }); const optionsOf = (route: AnyRoute): { id?: string; path?: string } => route.options; -describe("withPluginRoutes", () => { +describe("withVitNodeRoutes", () => { const appTree = () => { const root = createRootRoute(); @@ -73,7 +73,7 @@ describe("withPluginRoutes", () => { }); it("leaves the app route tree alone when no plugin declares a route", () => { - const tree = withPluginRoutes(appTree(), [], { pageHead }); + const tree = withVitNodeRoutes(appTree(), [], { pageHead }); expect(containerOf(tree)).toBeUndefined(); expect(tree.children).toHaveLength(2); @@ -111,7 +111,7 @@ describe("withPluginRoutes", () => { mount(tree, specsOf(pageAt("/page"))); expect(containerOf(tree)).toBeDefined(); - withPluginRoutes(tree, [], { pageHead }); + withVitNodeRoutes(tree, [], { pageHead }); expect(containerOf(tree)).toBeUndefined(); expect(fileRoutePaths(tree)).toEqual(["/", "/discover"]); @@ -132,7 +132,7 @@ describe("withPluginRoutes", () => { createRoute({ getParentRoute: () => shell, path: "/" }), ]); - const tree = withPluginRoutes( + const tree = withVitNodeRoutes( root.addChildren([shell]), specsOf(pageAt("/page")), { mountUnder: { main: shell }, pageHead }, @@ -200,7 +200,7 @@ describe("plugin route areas", () => { it("mounts each route under the shell its area names", () => { const { admin, main, tree } = shells(); - withPluginRoutes(tree, specsOf(pageAt("/example"), adminRoute()), { + withVitNodeRoutes(tree, specsOf(pageAt("/example"), adminRoute()), { mountUnder: { admin, main }, pageHead, }); @@ -215,7 +215,7 @@ describe("plugin route areas", () => { it("mounts a blank route under the root, outside both shells", () => { const { admin, main, tree } = shells(); - withPluginRoutes(tree, specsOf(pageAt("/example"), blankRoute()), { + withVitNodeRoutes(tree, specsOf(pageAt("/example"), blankRoute()), { mountUnder: { admin, blank: tree, main }, pageHead, }); @@ -229,7 +229,7 @@ describe("plugin route areas", () => { it("hangs a blank route from the root when the host named no shells", () => { const { tree } = shells(); - withPluginRoutes(tree, specsOf(blankRoute()), { pageHead }); + withVitNodeRoutes(tree, specsOf(blankRoute()), { pageHead }); expect(mountedPaths(tree)).toEqual(["/kiosk"]); }); @@ -246,7 +246,7 @@ describe("plugin route areas", () => { const { main, tree } = shells(); expect(() => - withPluginRoutes(tree, specsOf(adminRoute()), { + withVitNodeRoutes(tree, specsOf(adminRoute()), { mountUnder: { main }, pageHead, }), @@ -257,7 +257,7 @@ describe("plugin route areas", () => { const { tree } = shells(); expect(() => - withPluginRoutes(tree, specsOf(adminRoute()), { pageHead }), + withVitNodeRoutes(tree, specsOf(adminRoute()), { pageHead }), ).toThrow(/"admin" area/); }); @@ -304,7 +304,7 @@ describe("plugin route areas", () => { it("mounts two areas whose paths genuinely differ", () => { const { admin, main, tree } = shells(); - withPluginRoutes(tree, specsOf(pageAt("/reports"), adminRoute()), { + withVitNodeRoutes(tree, specsOf(pageAt("/reports"), adminRoute()), { mountUnder: { admin, main }, pageHead, }); @@ -323,13 +323,13 @@ describe("plugin route areas", () => { it("clears one shell's subtree without touching the other's", () => { const { admin, main, tree } = shells(); - withPluginRoutes(tree, specsOf(pageAt("/example"), adminRoute()), { + withVitNodeRoutes(tree, specsOf(pageAt("/example"), adminRoute()), { mountUnder: { admin, main }, pageHead, }); expect(containerOf(admin)).toBeDefined(); - withPluginRoutes(tree, specsOf(pageAt("/example")), { + withVitNodeRoutes(tree, specsOf(pageAt("/example")), { mountUnder: { admin, main }, pageHead, }); @@ -347,7 +347,7 @@ describe("plugin route areas", () => { it("shares one container when two areas name the same route", () => { const { main, tree } = shells(); - withPluginRoutes(tree, specsOf(pageAt("/example"), adminRoute()), { + withVitNodeRoutes(tree, specsOf(pageAt("/example"), adminRoute()), { mountUnder: { admin: main, main }, pageHead, }); @@ -365,7 +365,7 @@ describe("plugin route areas", () => { const { admin, main, tree } = shells(); expect(() => - withPluginRoutes(tree, specsOf(adminRoute("/admin/core")), { + withVitNodeRoutes(tree, specsOf(adminRoute("/admin/core")), { mountUnder: { admin, main }, pageHead, }), @@ -392,7 +392,7 @@ describe("a nested plugin subtree", () => { const mounted = () => { const root = createRootRoute(); - const tree = withPluginRoutes( + const tree = withVitNodeRoutes( root.addChildren([ createRoute({ getParentRoute: () => root, path: "/" }), ]), @@ -450,7 +450,7 @@ describe("a nested plugin subtree", () => { ]); expect(() => - withPluginRoutes(tree, specsOf(guide()), { pageHead }), + withVitNodeRoutes(tree, specsOf(guide()), { pageHead }), ).toThrow(/conflicts with application route/); }); }); @@ -583,10 +583,18 @@ describe("fileRoutePaths", () => { * tree's shape. Nothing here mounts a router or renders a component. */ describe("a plugin route's loader", () => { - /** The host's context. Its `queryClient` is unused: no route here declares namespaces. */ + /** + * The host's context, with a field of its own that no route was promised. + * + * `queryClient` is never called here - no route in this block declares message + * namespaces - but it is a real value, because it is one of the fields that + * *is* projected and the tests below check it arrives. + */ + const queryClient = { marker: "the host's own" } as never; const context = { locale: "pl", - queryClient: undefined as never, + queryClient, + somethingOnlyThisHostHas: "should not cross", }; const loaderOf = (module: Record<string, unknown>) => { @@ -642,23 +650,72 @@ describe("a plugin route's loader", () => { expect(result.search).toEqual({}); }); - /** - * The context boundary, from the runtime's side. `PluginRouteContext` is the - * whole of what a plugin is promised, so the host's own context - which holds - * this app's `QueryClient` - is projected rather than forwarded. Handing it - * over whole would make every field on it public plugin API by accident. - */ - it("projects the public context, and does not forward the host's", async () => { + const contextOf = async ( + hostContext: Record<string, unknown> = context, + ): Promise<Record<string, unknown>> => { const load = vi.fn(() => null); + const module = { default: () => null, route: { load } }; + const root = createRootRoute(); + const tree = mount( + root.addChildren([ + createRoute({ getParentRoute: () => root, path: "/" }), + ]), + specsOf(page("/page", { component: lazyModule(module) })), + ); + const container = (tree.children ?? []).find( + (child: AnyRoute) => optionsOf(child).id === PLUGIN_ROUTES_ROUTE_ID, + ); + const loader = (container?.children?.[0] as AnyRoute).options.loader as ( + args: unknown, + ) => Promise<unknown>; - await loaderOf({ default: () => null, route: { load } })({}); + await loader({ context: hostContext, deps: {}, params: {} }); const [args] = load.mock.calls[0] as unknown as [ { context: Record<string, unknown> }, ]; - expect(args.context).toEqual({ locale: "pl" }); - expect(args.context).not.toHaveProperty("queryClient"); + return args.context; + }; + + /** + * The context boundary, from the runtime's side: the host's context is + * projected rather than forwarded, so a field this particular host happens to + * carry does not become public API by accident - compiling today and arriving + * `undefined` on the next host. + * + * The locale and the query client are what every host promises. The client is + * in because VitNode's whole caching story is "warm the route's data in its + * loader", and a loader with no client has to fetch in render instead. + */ + it("projects the promised context, and does not forward the host's", async () => { + expect(await contextOf()).toEqual({ locale: "pl", queryClient }); + }); + + /** + * `auth` and `adminAccess` are not promises a route makes to itself - they are + * promises it has *earned*, by declaring `requires` or by being in the `admin` + * area. A route that declared neither must not be able to observe one, or the + * narrower authoring types would be describing a guarantee the runtime does + * not keep. + */ + it("omits a session the host never resolved for this route", async () => { + const projected = await contextOf(); + + expect(projected).not.toHaveProperty("auth"); + expect(projected).not.toHaveProperty("adminAccess"); + }); + + it("carries the session across when the host resolved one", async () => { + const auth = { isAuthenticated: true }; + const adminAccess = { session: "admin" }; + + expect(await contextOf({ ...context, adminAccess, auth })).toEqual({ + adminAccess, + auth, + locale: "pl", + queryClient, + }); }); }); @@ -691,7 +748,7 @@ describe("a route's eager search schema", () => { specs: ReturnType<typeof pluginRouteSpecs>, ): Record<string, unknown> => { const root = createRootRoute(); - const tree: AnyRoute = withPluginRoutes(root.addChildren([]), specs, { + const tree: AnyRoute = withVitNodeRoutes(root.addChildren([]), specs, { pageHead, }); const container = (tree.children ?? []).find( @@ -754,7 +811,7 @@ describe("a route's eager search schema", () => { page("/browse", { component: lazyModule(), search: validateSearch }), ); const root = createRootRoute(); - const tree: AnyRoute = withPluginRoutes(root.addChildren([]), [spec], { + const tree: AnyRoute = withVitNodeRoutes(root.addChildren([]), [spec], { pageHead, }); const container = (tree.children ?? []).find( @@ -773,3 +830,65 @@ describe("a route's eager search schema", () => { ).resolves.toEqual({ data: undefined, search: { page: 3 } }); }); }); + +/** + * `head` is the other half of the translator story, and the half that could not + * work before: it runs outside the React tree, so `useTranslations` has no + * provider to read. The strings reach it through the route's declared + * namespaces instead. + */ +describe("a plugin route's head", () => { + const messages = { "@acme/notes": { home: { title: "Notatki" } } }; + + const headOf = async (module: Record<string, unknown>) => { + const root = createRootRoute(); + const tree = mount( + root.addChildren([ + createRoute({ getParentRoute: () => root, path: "/" }), + ]), + specsOf( + page("/notes", { + component: lazyModule(module), + messages: ["@acme/notes.home"], + }), + ), + ); + const container = (tree.children ?? []).find( + (child: AnyRoute) => optionsOf(child).id === PLUGIN_ROUTES_ROUTE_ID, + ); + const head = (container?.children?.[0] as AnyRoute).options.head as ( + args: unknown, + ) => Promise<{ meta?: { content?: string; title?: string }[] }>; + + return await head({ + loaderData: { data: undefined, search: {} }, + match: { + context: { + locale: "pl", + queryClient: { + query: async () => await Promise.resolve({ messages }), + }, + }, + }, + params: {}, + }); + }; + + it("translates the title through the route's own namespaces", async () => { + const result = await headOf({ + default: () => null, + route: { + head: ({ t }: { t: (key: string) => string }) => ({ + title: t("@acme/notes.home.title"), + }), + }, + }); + + // The site's own name is appended by `pageHead`, as it is for every route. + expect(result.meta).toContainEqual({ title: "Notatki - VitNode" }); + }); + + it("hands `head` nothing to translate with when it declares no head", async () => { + expect(await headOf({ default: () => null, route: {} })).toEqual({}); + }); +}); diff --git a/packages/vitnode/src/tanstack/plugin-routes/specs.ts b/packages/vitnode/src/tanstack/plugin-routes/specs.ts index 511543b00..ff519a4c4 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/specs.ts +++ b/packages/vitnode/src/tanstack/plugin-routes/specs.ts @@ -28,13 +28,16 @@ export interface PluginRouteSpec { module: PluginRouteModuleRef; namespaces: string[]; + /** * The **global** id of the plugin route this one is nested inside, or `null` * for one that hangs from the plugin container. */ parentId: null | string; - path: string; + + /** The component this route draws while it loads, if it declared one. */ + pendingComponent: null | React.FunctionComponent; /** The manifest entry this spec was built from, unchanged. */ route: PluginRoute; @@ -66,7 +69,7 @@ export const pluginRouteSearchDeps = ( export const pluginRouteSpecs = ( sources: readonly PluginRouteDeclarationSource[], ): PluginRouteSpec[] => { - const { components, manifest, searchValidators } = + const { components, manifest, pendingComponents, searchValidators } = compilePluginRouteTrees(sources); const graph = buildPluginRouteGraph(manifest); @@ -85,6 +88,7 @@ export const pluginRouteSpecs = ( module: pluginRouteModuleRef(component.load, route.id), namespaces: pluginRouteMessageNamespaces(node), parentId: node.parent?.route.id ?? null, + pendingComponent: pendingComponents.get(route.id) ?? null, path: toTanStackRoutePath( node.parent === null ? route.segments : node.relativeSegments, ), diff --git a/packages/vitnode/src/tanstack/plugin-routes/translator.test.ts b/packages/vitnode/src/tanstack/plugin-routes/translator.test.ts new file mode 100644 index 000000000..2c84319ec --- /dev/null +++ b/packages/vitnode/src/tanstack/plugin-routes/translator.test.ts @@ -0,0 +1,96 @@ +// @vitest-environment node +import type { QueryClient } from "@tanstack/react-query"; + +import { describe, expect, it, vi } from "vitest"; + +import type { PluginRouteSpec } from "./specs"; + +import { pluginRouteTranslator } from "./translator"; + +const specWith = (namespaces: string[]): PluginRouteSpec => + ({ + namespaces, + route: { id: "@acme/notes:page#/notes" }, + }) as PluginRouteSpec; + +const clientWith = (messages: unknown) => + ({ + query: vi.fn(async () => await Promise.resolve({ messages })), + }) as unknown as QueryClient; + +const NOTES = { + "@acme/notes": { + home: { greeting: "Cześć {name}", title: "Notatki" }, + }, +}; + +/** + * The door `head` translates through. + * + * `head` runs outside the React tree - there is no provider to read, so + * `useTranslations` cannot reach it - and this is what closes that gap without + * making every route round-trip its title through `loaderData`. + */ +describe("a route's translator", () => { + it("translates a full dotted key", async () => { + const t = await pluginRouteTranslator(specWith(["@acme/notes.home"]), { + locale: "pl", + queryClient: clientWith(NOTES), + }); + + expect(t("@acme/notes.home.title")).toBe("Notatki"); + }); + + it("interpolates values", async () => { + const t = await pluginRouteTranslator(specWith(["@acme/notes.home"]), { + locale: "pl", + queryClient: clientWith(NOTES), + }); + + expect(t("@acme/notes.home.greeting", { name: "Ada" })).toBe("Cześć Ada"); + }); + + /** + * The messages are already in the cache - the loader warms them before `load` + * runs, and `head` runs after the loader - so a translator costs a cache read + * and never a request. + */ + it("asks for exactly the namespaces the route declared", async () => { + const queryClient = clientWith(NOTES); + + await pluginRouteTranslator(specWith(["@acme/notes.home"]), { + locale: "pl", + queryClient, + }); + + expect(queryClient.query).toHaveBeenCalledTimes(1); + expect( + (queryClient.query as unknown as ReturnType<typeof vi.fn>).mock + .calls[0][0].queryKey, + // Exactly what the spec carries: `core.global` is added upstream, by + // `pluginRouteMessageNamespaces`, not here. + ).toEqual(["vitnode", "intl", "pl", "@acme/notes.home"]); + }); + + /** + * Echoing the key back would put `@acme/notes.home.title` in a `<title>` and + * nothing would surface it until somebody read a search result. + */ + it("refuses to translate for a route that declared no messages", async () => { + const t = await pluginRouteTranslator(specWith([]), { + locale: "pl", + queryClient: clientWith(NOTES), + }); + + expect(() => t("@acme/notes.home.title")).toThrow(/declares no `messages`/); + }); + + it("names the namespace to add when it refuses", async () => { + const t = await pluginRouteTranslator(specWith([]), { + locale: "pl", + queryClient: clientWith(NOTES), + }); + + expect(() => t("@acme/notes.home.title")).toThrow(/@acme\/notes\.home/); + }); +}); diff --git a/packages/vitnode/src/tanstack/plugin-routes/translator.ts b/packages/vitnode/src/tanstack/plugin-routes/translator.ts new file mode 100644 index 000000000..99c8e797b --- /dev/null +++ b/packages/vitnode/src/tanstack/plugin-routes/translator.ts @@ -0,0 +1,62 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { createTranslator } from "use-intl"; + +import type { PluginRouteTranslator } from "@/routing"; + +import type { PluginRouteSpec } from "./specs"; + +import { intlQueryOptions } from "../i18n/query"; + +/** What the intl query resolves to, narrowed to the half this file reads. */ +interface IntlMessages { + messages: Parameters<typeof createTranslator>[0]["messages"]; +} + +/** + * The translator for a route that declared no `messages`. + * + * It throws rather than echoing the key back, because the alternative is a + * `<title>` that reads `@acme/site-notes.home.title` in production - a mistake + * nothing would surface until somebody looked at a search result. + */ +const untranslatable = + (routeId: string): PluginRouteTranslator => + key => { + throw new Error( + `[VitNode plugin routes] Route "${routeId}" called \`t("${key}")\` but declares no \`messages\`. Add the namespace to the route in \`routes.tsx\`: page("/notes", { messages: ["${key.split(".").slice(0, -1).join(".") || "your.namespace"}"], ... }).`, + ); + }; + +/** + * A translator over the namespaces a route declared. + * + * Reads through the query client rather than fetching: the route's namespaces + * are already in the cache - the loader warms them before `load` runs, and + * `head` runs after the loader - so this resolves without a request. It is + * `await`ed anyway because that is what makes it correct on the one path where + * the cache is cold. + */ +export const pluginRouteTranslator = async ( + spec: PluginRouteSpec, + { locale, queryClient }: { locale: string; queryClient: QueryClient }, +): Promise<PluginRouteTranslator> => { + if (spec.namespaces.length === 0) return untranslatable(spec.route.id); + + const intl: IntlMessages = await queryClient.query({ + ...intlQueryOptions({ locale, namespaces: spec.namespaces }), + staleTime: "static", + }); + + const translate = createTranslator({ locale, messages: intl.messages }); + + // Cast at the boundary, and only here: `createTranslator` types its keys from + // a message tree it can see at compile time, and a plugin's tree is loaded at + // runtime. The key is a string either way, and a missing one is use-intl's own + // error rather than a silent blank. + return (key, values) => + (translate as (key: string, values?: Record<string, unknown>) => string)( + key, + values, + ); +}; diff --git a/packages/vitnode/src/tanstack/profile/route.test.ts b/packages/vitnode/src/tanstack/profile/route.test.ts index 73e5526d2..002def184 100644 --- a/packages/vitnode/src/tanstack/profile/route.test.ts +++ b/packages/vitnode/src/tanstack/profile/route.test.ts @@ -2,13 +2,14 @@ import type { QueryClient } from "@tanstack/react-query"; import { isNotFound } from "@tanstack/react-router"; +import { createTranslator } from "use-intl"; import { describe, expect, it } from "vitest"; import type { UserProfile } from "@/views/profile/profile-query"; import { ProfileRequestError } from "@/views/profile/profile-query"; -import { loadProfileRoute, PROFILE_NAMESPACES } from "./route"; +import { loadProfileRoute } from "./route"; const profile: UserProfile = { avatarColor: "3b82f6", @@ -32,8 +33,15 @@ const messages = { }, }; -const isIntlKey = (queryKey: readonly unknown[]) => - queryKey[0] === "vitnode" && queryKey[1] === "intl"; +/** + * The translator the runtime hands a loader, over the namespaces the route + * declared. The loader no longer fetches messages itself, so the fake client + * below answers only for the profile. + */ +const t = createTranslator({ locale: "en", messages }) as unknown as ( + key: string, + values?: Record<string, unknown>, +) => string; const clientAnswering = ( answer: () => Promise<UserProfile>, @@ -45,8 +53,6 @@ const clientAnswering = ( query: async ({ queryKey }: { queryKey: readonly unknown[] }) => { requested.push([...queryKey]); - if (isIntlKey(queryKey)) return await Promise.resolve({ messages }); - return await answer(); }, } as unknown as QueryClient, @@ -56,7 +62,7 @@ const clientAnswering = ( const load = async (nameCode: string, answer: () => Promise<UserProfile>) => { const { queryClient, requested } = clientAnswering(answer); - const data = await loadProfileRoute({ locale: "en", nameCode, queryClient }); + const data = await loadProfileRoute({ nameCode, queryClient, t }); return { data, requested }; }; @@ -70,7 +76,7 @@ describe("a handle that cannot be a profile", () => { ); await expect( - loadProfileRoute({ locale: "en", nameCode, queryClient }), + loadProfileRoute({ nameCode, queryClient, t }), ).rejects.toSatisfy(isNotFound); expect(requested).toEqual([]); }, @@ -84,7 +90,7 @@ describe("a profile the API does not have", () => { ); await expect( - loadProfileRoute({ locale: "en", nameCode: "nobody", queryClient }), + loadProfileRoute({ nameCode: "nobody", queryClient, t }), ).rejects.toSatisfy(isNotFound); }); @@ -97,23 +103,25 @@ describe("a profile the API does not have", () => { ); await expect( - loadProfileRoute({ locale: "en", nameCode: "aXen", queryClient }), + loadProfileRoute({ nameCode: "aXen", queryClient, t }), ).rejects.toBe(error); }, ); }); describe("a profile that exists", () => { - it("warms the route's strings and the profile together", async () => { + /** + * One read, not two: the route's strings are declared in `routes.tsx` and + * warmed by the runtime before this runs, so the loader fetches the profile + * and nothing else. + */ + it("fetches the profile and nothing else", async () => { const { requested } = await load( "aXen", async () => await Promise.resolve(profile), ); - expect(requested).toHaveLength(2); - expect(requested[0].slice(0, 3)).toEqual(["vitnode", "intl", "en"]); - expect(requested[0].slice(3)).toEqual([...PROFILE_NAMESPACES]); - expect(requested[1]).toEqual(["vitnode", "profile", "aXen"]); + expect(requested).toEqual([["vitnode", "profile", "aXen"]]); }); it("titles the page after the member, as the API spells them", async () => { diff --git a/packages/vitnode/src/tanstack/profile/route.ts b/packages/vitnode/src/tanstack/profile/route.ts index 2be05f9bf..664505688 100644 --- a/packages/vitnode/src/tanstack/profile/route.ts +++ b/packages/vitnode/src/tanstack/profile/route.ts @@ -1,7 +1,8 @@ +import type { PluginRouteTranslator } from "@/routing"; + import type { QueryClient } from "@tanstack/react-query"; import { notFound } from "@tanstack/react-router"; -import { createTranslator } from "use-intl"; import type { UserProfile } from "@/views/profile/profile-query"; @@ -10,13 +11,11 @@ import { normalizeProfileNameCode, } from "@/views/profile/profile-query"; -import { intlQueryOptions } from "../i18n/query"; import { userProfileQuery } from "./query"; export const PROFILE_NAMESPACES = ["core.global", "core.profile"] as const; export interface ProfileLoaderContext { - locale: string; queryClient: QueryClient; } @@ -46,36 +45,25 @@ const ensureProfile = async ( }; export const loadProfileRoute = async ({ - locale, nameCode: raw, queryClient, -}: ProfileLoaderContext & { nameCode: string }): Promise<ProfileRouteData> => { + t, +}: ProfileLoaderContext & { + nameCode: string; + t: PluginRouteTranslator; +}): Promise<ProfileRouteData> => { const nameCode = normalizeProfileNameCode(raw); if (nameCode === null) { // eslint-disable-next-line @typescript-eslint/only-throw-error throw notFound(); } - const [intl, user] = await Promise.all([ - queryClient.query({ - ...intlQueryOptions({ locale, namespaces: PROFILE_NAMESPACES }), - staleTime: "static", - }), - ensureProfile(queryClient, nameCode), - ]); - - const t = createTranslator({ - locale, - messages: intl.messages as { - core: { profile: { metaDesc: string; title: string } }; - }, - namespace: "core.profile", - }); + const user = await ensureProfile(queryClient, nameCode); const values = { name: user.name, nameCode: user.nameCode }; return { - description: t("metaDesc", values), + description: t("core.profile.metaDesc", values), nameCode: user.nameCode, - title: t("title", values), + title: t("core.profile.title", values), }; }; diff --git a/packages/vitnode/src/tanstack/routes/admin/admin-routes.test.ts b/packages/vitnode/src/tanstack/routes/admin/admin-routes.test.ts deleted file mode 100644 index 7c7456774..000000000 --- a/packages/vitnode/src/tanstack/routes/admin/admin-routes.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; - -const here = import.meta.dirname; - -/** Source with its comments removed - prose may name what code may not do. */ -const withoutComments = (source: string): string => - source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, ""); - -const modules = readdirSync(here) - .filter(name => /\.tsx?$/.test(name) && !name.endsWith(".test.ts")) - .sort(); - -const codeOf = (name: string): string => - withoutComments(readFileSync(join(here, name), "utf8")); - -const everyRoutePath = modules - .flatMap(name => [ - ...codeOf(name).matchAll(/["`](\/admin(?:\/[^"`\s]*)?)["`]/g), - ]) - .map(match => match[1]) - .sort(); - -describe("what this directory declares", () => { - /** Guards the guard: the assertions below are properties of a real listing. */ - it("declares the AdminCP's screens", () => { - expect(modules).toContain("index.tsx"); - expect(everyRoutePath.length).toBeGreaterThan(10); - }); - - it("spells every path in full, under /admin", () => { - for (const path of everyRoutePath) { - expect(path.startsWith("/admin/"), path).toBe(true); - } - }); - - /** No two screens claim one URL. */ - it("claims each URL once", () => { - expect([...new Set(everyRoutePath)]).toEqual(everyRoutePath); - }); - - it("declares one splat, at the Content Engine namespace", () => { - // A splat ends in a bare `/$`. A dynamic segment (`/$id`) does not, and - // there are three of those - the two staff edit screens and one user. - expect(everyRoutePath.filter(path => path.endsWith("/$"))).toEqual([ - "/admin/content/$", - ]); - // Two dynamic segments, both `$id` - the staff edit family and one user. - expect(everyRoutePath.filter(path => path.includes("$id")).length).toBe(2); - }); - - it("leaves /admin/core to the application's one anchor route file", () => { - expect(everyRoutePath).not.toContain("/admin/core"); - }); -}); - -describe("how they reach an application", () => { - const index = codeOf("index.tsx"); - - it("exports one mount that takes the host's own bindings", () => { - expect(index).toContain("export const withCoreAdminRoutes"); - expect(index).toMatch(/mountUnder/); - expect(index).toMatch(/pageHead/); - expect(index).toMatch(/loadContentRegistry/); - }); - - it("takes the content registry as a thunk rather than as a value", () => { - expect(index).toMatch( - /loadContentRegistry: \(\) => Promise<ContentFrontendRegistry>/, - ); - expect(index).not.toMatch(/contentRegistry: ContentFrontendRegistry/); - }); - - it("mounts under its own pathless container, replacing any previous copy", () => { - expect(index).toContain("CORE_ADMIN_ROUTES_ROUTE_ID"); - expect(index).toMatch(/id: CORE_ADMIN_ROUTES_ROUTE_ID/); - expect(index).toContain("siblings"); - expect(index).toMatch(/addChildren\(\[\.\.\.siblings, container\]\)/); - }); - - it("writes nothing and reads no filesystem", () => { - for (const name of modules) { - const code = codeOf(name); - - expect(code, name).not.toMatch(/node:fs|writeFile|createFileRoute/); - expect(code, name).not.toMatch(/src\/routes/); - } - }); -}); - -describe("what a screen may not do here", () => { - it("leaves the session check to the shell's guard", () => { - for (const name of modules) { - const code = codeOf(name); - - expect(code, name).not.toMatch(/ensureAdminAccess|prefetchAdminAccess/); - expect(code, name).not.toMatch(/redirect\(/); - } - }); - - it("states no permission tuple in a route declaration", () => { - for (const name of modules) { - expect(codeOf(name), name).not.toMatch( - /can_view|can_edit|can_delete|can_run|can_clear_cache/, - ); - } - }); -}); diff --git a/packages/vitnode/src/tanstack/routes/admin/advanced.tsx b/packages/vitnode/src/tanstack/routes/admin/advanced.tsx deleted file mode 100644 index 1a0c52a64..000000000 --- a/packages/vitnode/src/tanstack/routes/admin/advanced.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import { createRoute, lazyRouteComponent } from "@tanstack/react-router"; -import { useCallback } from "react"; - -import type { AdminScreenContext } from "../../admin/screen"; -import type { CoreRouteFactory } from "../types"; - -import { adminBreadcrumb } from "../../admin/breadcrumb"; -import { loadAdminCronRoute } from "../../admin/cron/route"; -import { - cronRouteParams, - normalizeCronRouteSearch, -} from "../../admin/cron/route-search"; -import { loadAdminQueueRoute } from "../../admin/queue/route"; -import { - normalizeQueueRouteSearch, - queueRouteParams, -} from "../../admin/queue/route-search"; -import { loadAdminSearchIndexRoute } from "../../admin/search-index/route"; -import { normalizeSearchIndexRouteSearch } from "../../admin/search-index/route-search"; -import { TablePendingSkeleton } from "../../pending"; -import { routeContext, routeSearch } from "../types"; - -const cronRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { - const route = createRoute({ - getParentRoute: () => parentRoute, - - loaderDeps: ({ search }) => ({ - params: cronRouteParams(routeSearch(search)), - }), - // `head` after `loader`, always. - loader: async ({ context, deps }) => - await loadAdminCronRoute({ - ...routeContext<AdminScreenContext>(context), - params: deps.params, - }), - head: ({ loaderData }) => pageHead({ ...loaderData }), - path: "/admin/core/advanced/cron", - pendingComponent: TablePendingSkeleton, - validateSearch: normalizeCronRouteSearch, - staticData: { - breadcrumb: adminBreadcrumb({ segments: ["core", "advanced", "cron"] }), - }, - }); - - route.update({ - component: lazyRouteComponent(async () => { - const { AdminCronRouteContent } = await import("../../admin/cron/screen"); - - return { - default: function AdminCronRoute() { - const navigate = route.useNavigate(); - - return ( - <AdminCronRouteContent - {...route.useLoaderData()} - navigate={useCallback( - async ({ - resetScroll, - search, - }: { - resetScroll: boolean; - search: ReturnType<typeof normalizeCronRouteSearch>; - }) => { - await navigate({ resetScroll, search }); - }, - [navigate], - )} - search={route.useSearch()} - /> - ); - }, - }; - }), - }); - - return route; -}; - -const queueRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { - const route = createRoute({ - getParentRoute: () => parentRoute, - - loaderDeps: ({ search }) => ({ - params: queueRouteParams(routeSearch(search)), - }), - // `head` after `loader`, always. - loader: async ({ context, deps }) => - await loadAdminQueueRoute({ - ...routeContext<AdminScreenContext>(context), - params: deps.params, - }), - head: ({ loaderData }) => pageHead({ ...loaderData }), - path: "/admin/core/advanced/queue", - pendingComponent: TablePendingSkeleton, - validateSearch: normalizeQueueRouteSearch, - staticData: { - breadcrumb: adminBreadcrumb({ segments: ["core", "advanced", "queue"] }), - }, - }); - - route.update({ - component: lazyRouteComponent(async () => { - const { AdminQueueRouteContent } = - await import("../../admin/queue/screen"); - - return { - default: function AdminQueueRoute() { - const navigate = route.useNavigate(); - - return ( - <AdminQueueRouteContent - {...route.useLoaderData()} - navigate={useCallback( - async ({ - resetScroll, - search, - }: { - resetScroll: boolean; - search: ReturnType<typeof normalizeQueueRouteSearch>; - }) => { - await navigate({ resetScroll, search }); - }, - [navigate], - )} - search={route.useSearch()} - /> - ); - }, - }; - }), - }); - - return route; -}; - -const searchIndexRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { - const route = createRoute({ - getParentRoute: () => parentRoute, - // `head` after `loader`, always. - loader: async ({ context }) => - await loadAdminSearchIndexRoute( - routeContext<AdminScreenContext>(context), - ), - head: ({ loaderData }) => pageHead({ ...loaderData }), - path: "/admin/core/advanced/search", - pendingComponent: TablePendingSkeleton, - validateSearch: normalizeSearchIndexRouteSearch, - staticData: { - breadcrumb: adminBreadcrumb({ segments: ["core", "advanced", "search"] }), - }, - }); - - route.update({ - component: lazyRouteComponent(async () => { - const { AdminSearchIndexRouteContent } = - await import("../../admin/search-index/screen"); - - return { - default: function AdminSearchIndexRoute() { - const navigate = route.useNavigate(); - - return ( - <AdminSearchIndexRouteContent - {...route.useLoaderData()} - navigate={useCallback( - async ({ - resetScroll, - search, - }: { - resetScroll: boolean; - search: ReturnType<typeof normalizeSearchIndexRouteSearch>; - }) => { - await navigate({ resetScroll, search }); - }, - [navigate], - )} - search={route.useSearch()} - /> - ); - }, - }; - }), - }); - - return route; -}; - -export const coreAdvancedRoutes: CoreRouteFactory[] = [ - cronRoute, - queueRoute, - searchIndexRoute, -]; diff --git a/packages/vitnode/src/tanstack/routes/admin/content.tsx b/packages/vitnode/src/tanstack/routes/admin/content.tsx deleted file mode 100644 index fd53a9998..000000000 --- a/packages/vitnode/src/tanstack/routes/admin/content.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { createRoute, lazyRouteComponent } from "@tanstack/react-router"; -import { useCallback } from "react"; - -import type { ContentListRouteSearch } from "../../admin/content/route-search"; -import type { AdminScreenContext } from "../../admin/screen"; -import type { CoreAdminRouteContext, CoreRouteFactory } from "../types"; - -import { ContentAdminBreadcrumbContent } from "../../admin/content/breadcrumb"; -import { breadcrumbGroup } from "../../breadcrumb/model"; -import { TablePendingSkeleton } from "../../pending"; -import { routeContext, routeSearch } from "../types"; - -export const contentAdminRoute: CoreRouteFactory<CoreAdminRouteContext> = ({ - loadContentRegistry, - pageHead, - parentRoute, -}) => { - const route = createRoute({ - getParentRoute: () => parentRoute, - validateSearch: (search: Record<string, unknown>): ContentListRouteSearch => - search as ContentListRouteSearch, - /** The whole search: which page, sort, search term and filters to load. */ - loaderDeps: ({ search }) => ({ - search: routeSearch<ContentListRouteSearch>(search), - }), - - // `head` after `loader`, always. - loader: async ({ context, deps, params }) => { - const [ - { contentRouteSegments, loadContentAdminRoute }, - { loadContentFormScreen }, - registry, - ] = await Promise.all([ - import("../../admin/content/route"), - import("../../admin/content/form/route"), - loadContentRegistry(), - ]); - - const resolved = await loadContentAdminRoute({ - ...routeContext<AdminScreenContext>(context), - registry, - search: deps.search, - segments: contentRouteSegments((params as { _splat?: string })._splat), - }); - - return { - ...resolved, - ...(await loadContentFormScreen({ - ...routeContext<AdminScreenContext>(context), - registry, - route: resolved, - })), - }; - }, - head: ({ loaderData }) => pageHead({ ...loaderData }), - path: "/admin/content/$", - pendingComponent: TablePendingSkeleton, - }); - - route.update({ - staticData: { - breadcrumb: breadcrumbGroup(ContentAdminBreadcrumb), - }, - component: lazyRouteComponent(async () => { - const [{ ContentAdminScreenContent }, registry] = await Promise.all([ - import("../../admin/content/screen"), - loadContentRegistry(), - ]); - - return { - default: function ContentAdminRoute() { - const navigate = route.useNavigate(); - - return ( - <ContentAdminScreenContent - {...route.useLoaderData()} - navigate={useCallback( - async ({ - resetScroll, - search, - }: { - resetScroll: boolean; - search: ContentListRouteSearch; - }) => { - await navigate({ resetScroll, search }); - }, - [navigate], - )} - registry={registry} - search={route.useSearch()} - /> - ); - }, - }; - }), - }); - - function ContentAdminBreadcrumb() { - return <ContentAdminBreadcrumbContent {...route.useLoaderData()} />; - } - - return route; -}; diff --git a/packages/vitnode/src/tanstack/routes/admin/index.tsx b/packages/vitnode/src/tanstack/routes/admin/index.tsx deleted file mode 100644 index fb05ce7ed..000000000 --- a/packages/vitnode/src/tanstack/routes/admin/index.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import type { AnyRoute } from "@tanstack/react-router"; - -import { createRoute } from "@tanstack/react-router"; - -import type { ContentFrontendRegistry } from "../../../content/admin/registry"; -import type { - CoreAdminRouteContext, - CorePageHead, - CoreRouteFactory, -} from "../types"; - -import { coreAdvancedRoutes } from "./advanced"; -import { contentAdminRoute } from "./content"; -import { coreStaffRoutes } from "./staff"; -import { coreSystemRoutes } from "./system"; -import { coreUsersRoutes } from "./users"; - -export type { - CoreAdminRouteContext, - CorePageHead, - CoreRouteFactory, -} from "../types"; - -export const CORE_ADMIN_ROUTES_ROUTE_ID = "_core-admin"; - -/** Every AdminCP screen `@vitnode/core` owns. */ -const CORE_ADMIN_ROUTES: CoreRouteFactory<CoreAdminRouteContext>[] = [ - ...coreAdvancedRoutes, - contentAdminRoute, - ...coreStaffRoutes, - ...coreSystemRoutes, - ...coreUsersRoutes, -]; - -export const withCoreAdminRoutes = <TRouteTree extends AnyRoute>( - routeTree: TRouteTree, - { - loadContentRegistry, - mountUnder, - pageHead, - }: { - loadContentRegistry: () => Promise<ContentFrontendRegistry>; - mountUnder: AnyRoute; - pageHead: CorePageHead; - }, -): TRouteTree => { - const mounted: AnyRoute[] = mountUnder.children ?? []; - const siblings = mounted.filter( - (child: AnyRoute) => - (child.options as { id?: string }).id !== CORE_ADMIN_ROUTES_ROUTE_ID, - ); - - const container = createRoute({ - getParentRoute: () => mountUnder, - id: CORE_ADMIN_ROUTES_ROUTE_ID, - }); - - container.addChildren( - CORE_ADMIN_ROUTES.map(build => - build({ loadContentRegistry, pageHead, parentRoute: container }), - ), - ); - mountUnder.addChildren([...siblings, container]); - - return routeTree; -}; diff --git a/packages/vitnode/src/tanstack/routes/admin/staff.tsx b/packages/vitnode/src/tanstack/routes/admin/staff.tsx deleted file mode 100644 index 8078fa0ab..000000000 --- a/packages/vitnode/src/tanstack/routes/admin/staff.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { - createRoute, - lazyRouteComponent, - useRouter, -} from "@tanstack/react-router"; -import { useCallback } from "react"; - -import type { AdminScreenContext } from "../../admin/screen"; -import type { CoreRouteFactory } from "../types"; - -import { - AdminStaffBreadcrumbContent, - AdminStaffCreateBreadcrumbContent, - AdminStaffEditBreadcrumbContent, -} from "../../admin/staff/breadcrumbs"; -import { loadAdminStaffCreateRoute } from "../../admin/staff/create-route"; -import { loadAdminStaffEditRoute } from "../../admin/staff/edit-route"; -import { loadAdminStaffRoute } from "../../admin/staff/route"; -import { - normalizeStaffRouteSearch, - staffRouteParams, -} from "../../admin/staff/route-search"; -import { breadcrumbGroup } from "../../breadcrumb/model"; -import { FormPendingSkeleton, TablePendingSkeleton } from "../../pending"; -import { routeContext, routeSearch } from "../types"; - -const staffListRoute = - (type: "admin" | "moderator", path: string): CoreRouteFactory => - ({ pageHead, parentRoute }) => { - const route = createRoute({ - getParentRoute: () => parentRoute, - loaderDeps: ({ search }) => ({ - params: staffRouteParams(routeSearch(search)), - }), - // `head` after `loader`, always: `loaderData` is inferred from `loader`, - // and TypeScript reads an object literal's members in order. - loader: async ({ context, deps }) => - await loadAdminStaffRoute({ - ...routeContext<AdminScreenContext>(context), - params: deps.params, - type, - }), - head: ({ loaderData }) => pageHead({ ...loaderData }), - path, - validateSearch: normalizeStaffRouteSearch, - pendingComponent: TablePendingSkeleton, - - staticData: { - breadcrumb: breadcrumbGroup(function StaffBreadcrumb() { - return <AdminStaffBreadcrumbContent type={type} />; - }), - }, - }); - - route.update({ - component: lazyRouteComponent(async () => { - const { AdminStaffRouteContent } = - await import("../../admin/staff/screen"); - - return { - default: function AdminStaffRoute() { - const navigate = route.useNavigate(); - - return ( - <AdminStaffRouteContent - {...route.useLoaderData()} - // Narrowed to the two fields the table asks for, and memoised so the - // screen's own `useMemo` over it keeps holding. - navigate={useCallback( - async ({ - resetScroll, - search, - }: { - resetScroll: boolean; - search: ReturnType<typeof normalizeStaffRouteSearch>; - }) => { - await navigate({ resetScroll, search }); - }, - [navigate], - )} - search={route.useSearch()} - /> - ); - }, - }; - }), - }); - - return route; - }; - -const staffCreateRoute = - (type: "admin" | "moderator", path: string): CoreRouteFactory => - ({ pageHead, parentRoute }) => { - const route = createRoute({ - getParentRoute: () => parentRoute, - // `head` after `loader`, always. - loader: async ({ context }) => - await loadAdminStaffCreateRoute({ - ...routeContext<AdminScreenContext>(context), - type, - }), - head: ({ loaderData }) => pageHead({ ...loaderData }), - path, - pendingComponent: FormPendingSkeleton, - staticData: { - breadcrumb: breadcrumbGroup(function StaffCreateBreadcrumb() { - return <AdminStaffCreateBreadcrumbContent type={type} />; - }), - }, - }); - - route.update({ - component: lazyRouteComponent(async () => { - const { AdminStaffCreateRouteContent } = - await import("../../admin/staff/create-screen"); - - return { - default: function AdminStaffCreateRoute() { - const router = useRouter(); - - return ( - <AdminStaffCreateRouteContent - {...route.useLoaderData()} - navigate={useCallback( - async (href: string) => { - await router.navigate({ to: href }); - }, - [router], - )} - /> - ); - }, - }; - }), - }); - - return route; - }; - -const staffEditRoute = - (type: "admin" | "moderator", path: string): CoreRouteFactory => - ({ pageHead, parentRoute }) => { - const route = createRoute({ - getParentRoute: () => parentRoute, - // `head` after `loader`, always. - loader: async ({ context, params }) => - await loadAdminStaffEditRoute({ - ...routeContext<AdminScreenContext>(context), - id: (params as { id: string }).id, - type, - }), - head: ({ loaderData }) => pageHead({ ...loaderData }), - path, - pendingComponent: FormPendingSkeleton, - staticData: { - breadcrumb: breadcrumbGroup(function StaffEditBreadcrumb() { - return <AdminStaffEditBreadcrumbContent type={type} />; - }), - }, - }); - - route.update({ - component: lazyRouteComponent(async () => { - const { AdminStaffEditRouteContent } = - await import("../../admin/staff/edit-screen"); - - return { - default: function AdminStaffEditRoute() { - const router = useRouter(); - - return ( - <AdminStaffEditRouteContent - {...route.useLoaderData()} - navigate={useCallback( - async (href: string) => { - await router.navigate({ to: href }); - }, - [router], - )} - /> - ); - }, - }; - }), - }); - - return route; - }; - -export const coreStaffRoutes: CoreRouteFactory[] = ( - ["admin", "moderator"] as const -).flatMap(type => [ - staffListRoute(type, `/admin/core/staff/${type}s`), - staffCreateRoute(type, `/admin/core/staff/${type}s/create`), - staffEditRoute(type, `/admin/core/staff/${type}s/edit/$id`), -]); diff --git a/packages/vitnode/src/tanstack/routes/admin/system.tsx b/packages/vitnode/src/tanstack/routes/admin/system.tsx deleted file mode 100644 index 2dc0f8aff..000000000 --- a/packages/vitnode/src/tanstack/routes/admin/system.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { createRoute, lazyRouteComponent } from "@tanstack/react-router"; -import { useCallback } from "react"; - -import type { AdminScreenContext } from "../../admin/screen"; -import type { CoreRouteFactory } from "../types"; - -import { adminBreadcrumb } from "../../admin/breadcrumb"; -import { loadAdminDebugRoute } from "../../admin/debug/route"; -import { - debugLogsRouteParams, - normalizeDebugRouteSearch, -} from "../../admin/debug/route-search"; -import { loadAdminFilesRoute } from "../../admin/files/route"; -import { - adminFilesRouteParams, - normalizeAdminFilesRouteSearch, -} from "../../admin/files/route-search"; -import { loadAdminIntegrationsRoute } from "../../admin/integrations/route"; -import { CardsPendingSkeleton, TablePendingSkeleton } from "../../pending"; -import { routeContext, routeSearch } from "../types"; - -const filesRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { - const route = createRoute({ - getParentRoute: () => parentRoute, - /** The normalised parameters - this table's search term included. */ - loaderDeps: ({ search }) => ({ - params: adminFilesRouteParams(routeSearch(search)), - }), - // `head` after `loader`, always. - loader: async ({ context, deps }) => - await loadAdminFilesRoute({ - ...routeContext<AdminScreenContext>(context), - params: deps.params, - }), - head: ({ loaderData }) => pageHead({ ...loaderData }), - path: "/admin/core/system/files", - pendingComponent: TablePendingSkeleton, - validateSearch: normalizeAdminFilesRouteSearch, - staticData: { - breadcrumb: adminBreadcrumb({ segments: ["core", "system", "files"] }), - }, - }); - - route.update({ - component: lazyRouteComponent(async () => { - const { AdminFilesRouteContent } = - await import("../../admin/files/screen"); - - return { - default: function AdminFilesRoute() { - const navigate = route.useNavigate(); - - return ( - <AdminFilesRouteContent - {...route.useLoaderData()} - navigate={useCallback( - async ({ - resetScroll, - search, - }: { - resetScroll: boolean; - search: ReturnType<typeof normalizeAdminFilesRouteSearch>; - }) => { - await navigate({ resetScroll, search }); - }, - [navigate], - )} - search={route.useSearch()} - /> - ); - }, - }; - }), - }); - - return route; -}; - -const integrationsRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { - const route = createRoute({ - getParentRoute: () => parentRoute, - // `head` after `loader`, always. - loader: async ({ context }) => - await loadAdminIntegrationsRoute( - routeContext<AdminScreenContext>(context), - ), - head: ({ loaderData }) => pageHead({ ...loaderData }), - path: "/admin/core/system/integrations", - pendingComponent: CardsPendingSkeleton, - staticData: { - breadcrumb: adminBreadcrumb({ - segments: ["core", "system", "integrations"], - }), - }, - }); - - /** - * The heading's strings come from the loader, so the `<h1>` and the `<title>` - * are the same string by construction. - */ - route.update({ - component: lazyRouteComponent(async () => { - const { AdminIntegrationsRouteContent } = - await import("../../admin/integrations/screen"); - - return { - default: function AdminIntegrationsRoute() { - return <AdminIntegrationsRouteContent {...route.useLoaderData()} />; - }, - }; - }), - }); - - return route; -}; - -const debugRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { - const route = createRoute({ - getParentRoute: () => parentRoute, - - loaderDeps: ({ search }) => ({ - params: debugLogsRouteParams(routeSearch(search)), - }), - // `head` after `loader`, always. - loader: async ({ context, deps }) => - await loadAdminDebugRoute({ - ...routeContext<AdminScreenContext>(context), - params: deps.params, - }), - head: ({ loaderData }) => pageHead({ ...loaderData }), - path: "/admin/core/debug", - pendingComponent: TablePendingSkeleton, - validateSearch: normalizeDebugRouteSearch, - staticData: { breadcrumb: null }, - }); - - route.update({ - component: lazyRouteComponent(async () => { - const { AdminDebugRouteContent } = - await import("../../admin/debug/screen"); - - return { - default: function AdminDebugRoute() { - const navigate = route.useNavigate(); - - return ( - <AdminDebugRouteContent - {...route.useLoaderData()} - navigate={useCallback( - async ({ - resetScroll, - search, - }: { - resetScroll: boolean; - search: ReturnType<typeof normalizeDebugRouteSearch>; - }) => { - await navigate({ resetScroll, search }); - }, - [navigate], - )} - search={route.useSearch()} - /> - ); - }, - }; - }), - }); - - return route; -}; - -/** The system section, and the debug panel beside it. */ -export const coreSystemRoutes: CoreRouteFactory[] = [ - filesRoute, - integrationsRoute, - debugRoute, -]; diff --git a/packages/vitnode/src/tanstack/routes/admin/users.tsx b/packages/vitnode/src/tanstack/routes/admin/users.tsx deleted file mode 100644 index 0d79d4091..000000000 --- a/packages/vitnode/src/tanstack/routes/admin/users.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { createRoute, lazyRouteComponent } from "@tanstack/react-router"; -import { useCallback } from "react"; - -import type { AdminScreenContext } from "../../admin/screen"; -import type { CoreRouteFactory } from "../types"; - -import { adminBreadcrumb } from "../../admin/breadcrumb"; -import { loadAdminRolesRoute } from "../../admin/roles/route"; -import { - normalizeRolesRouteSearch, - rolesRouteParams, -} from "../../admin/roles/route-search"; -import { AdminUserBreadcrumbContent } from "../../admin/users/detail-breadcrumb"; -import { loadAdminUserRoute } from "../../admin/users/detail-route"; -import { loadAdminUsersRoute } from "../../admin/users/route"; -import { - normalizeUsersRouteSearch, - usersRouteParams, -} from "../../admin/users/route-search"; -import { breadcrumbGroup } from "../../breadcrumb/model"; -import { FormPendingSkeleton, TablePendingSkeleton } from "../../pending"; -import { routeContext, routeSearch } from "../types"; - -const usersListRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { - const route = createRoute({ - getParentRoute: () => parentRoute, - - loaderDeps: ({ search }) => ({ - params: usersRouteParams(routeSearch(search)), - }), - // `head` after `loader`, always. - loader: async ({ context, deps }) => - await loadAdminUsersRoute({ - ...routeContext<AdminScreenContext>(context), - params: deps.params, - }), - head: ({ loaderData }) => pageHead({ ...loaderData }), - path: "/admin/core/users", - pendingComponent: TablePendingSkeleton, - validateSearch: normalizeUsersRouteSearch, - staticData: { - breadcrumb: adminBreadcrumb({ segments: ["core", "users"] }), - }, - }); - - route.update({ - component: lazyRouteComponent(async () => { - const { AdminUsersRouteContent } = - await import("../../admin/users/screen"); - - return { - default: function AdminUsersRoute() { - const navigate = route.useNavigate(); - - return ( - <AdminUsersRouteContent - {...route.useLoaderData()} - navigate={useCallback( - async ({ - resetScroll, - search, - }: { - resetScroll: boolean; - search: ReturnType<typeof normalizeUsersRouteSearch>; - }) => { - await navigate({ resetScroll, search }); - }, - [navigate], - )} - search={route.useSearch()} - /> - ); - }, - }; - }), - }); - - return route; -}; - -const rolesRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { - const route = createRoute({ - getParentRoute: () => parentRoute, - loaderDeps: ({ search }) => ({ - params: rolesRouteParams(routeSearch(search)), - }), - // `head` after `loader`, always. - loader: async ({ context, deps }) => - await loadAdminRolesRoute({ - ...routeContext<AdminScreenContext>(context), - params: deps.params, - }), - head: ({ loaderData }) => pageHead({ ...loaderData }), - path: "/admin/core/users/roles", - pendingComponent: TablePendingSkeleton, - validateSearch: normalizeRolesRouteSearch, - staticData: { - breadcrumb: adminBreadcrumb({ segments: ["core", "users", "roles"] }), - }, - }); - - route.update({ - component: lazyRouteComponent(async () => { - const { AdminRolesRouteContent } = - await import("../../admin/roles/screen"); - - return { - default: function AdminRolesRoute() { - const navigate = route.useNavigate(); - - return ( - <AdminRolesRouteContent - {...route.useLoaderData()} - navigate={useCallback( - async ({ - resetScroll, - search, - }: { - resetScroll: boolean; - search: ReturnType<typeof normalizeRolesRouteSearch>; - }) => { - await navigate({ resetScroll, search }); - }, - [navigate], - )} - search={route.useSearch()} - /> - ); - }, - }; - }), - }); - - return route; -}; - -const userRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { - const route = createRoute({ - getParentRoute: () => parentRoute, - // `head` after `loader`, always. - loader: async ({ context, params }) => - await loadAdminUserRoute({ - ...routeContext<AdminScreenContext>(context), - id: (params as { id: string }).id, - }), - head: ({ loaderData }) => pageHead({ ...loaderData }), - path: "/admin/core/users/$id", - pendingComponent: FormPendingSkeleton, - staticData: { - breadcrumb: breadcrumbGroup(function AdminUserBreadcrumb({ params }) { - return <AdminUserBreadcrumbContent params={params} />; - }), - }, - }); - - route.update({ - component: lazyRouteComponent(async () => { - const { AdminUserRouteContent } = - await import("../../admin/users/detail-screen"); - - return { - default: function AdminUserRoute() { - return <AdminUserRouteContent {...route.useLoaderData()} />; - }, - }; - }), - }); - - return route; -}; - -/** The users section: the list, the roles list beside it, and one user. */ -export const coreUsersRoutes: CoreRouteFactory[] = [ - usersListRoute, - rolesRoute, - userRoute, -]; diff --git a/packages/vitnode/src/tanstack/routes/index.ts b/packages/vitnode/src/tanstack/routes/index.ts deleted file mode 100644 index f5ed2e15a..000000000 --- a/packages/vitnode/src/tanstack/routes/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { CORE_ADMIN_ROUTES_ROUTE_ID, withCoreAdminRoutes } from "./admin"; -export { - CORE_AUTHENTICATED_ROUTES_ROUTE_ID, - CORE_MAIN_ROUTES_ROUTE_ID, - withCoreMainRoutes, -} from "./main"; - -export { CORE_ROOT_ROUTES_ROUTE_ID, withCoreRootRoutes } from "./root"; -export type { CoreRootRouteContext, CoreRootRouteFactory } from "./root/types"; -export type { - CoreAdminRouteContext, - CoreAuthRouteContext, - CoreAuthRouteFactory, - CorePageHead, - CoreRouteContext, - CoreRouteFactory, -} from "./types"; diff --git a/packages/vitnode/src/tanstack/routes/main/auth.tsx b/packages/vitnode/src/tanstack/routes/main/auth.tsx deleted file mode 100644 index f380fac5b..000000000 --- a/packages/vitnode/src/tanstack/routes/main/auth.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { - createRoute, - lazyRouteComponent, - notFound, - redirect, -} from "@tanstack/react-router"; - -import type { CoreAuthRouteFactory } from "../types"; - -import { loadLoginRoute } from "../../auth/login-route"; -import { middlewareConfigQueryOptions } from "../../auth/middleware-config"; -import { - normalizePasswordResetSearch, - passwordRecoveryAvailability, - PasswordRecoveryUnknownError, - passwordResetMode, -} from "../../auth/recovery"; -import { loadPasswordResetRoute } from "../../auth/recovery-route"; -import { - createAuthNavigation, - parseInternalDestination, - postAuthDestination, -} from "../../auth/redirects"; -import { loadRegisterRoute } from "../../auth/register-route"; -import { normalizeLoginSearch } from "../../auth/route-search"; -import { ensureAuthState } from "../../auth/session-query"; -import { canAccessGuestRoute } from "../../auth/state"; -import { AuthPendingSkeleton } from "../../pending"; -import { routeContext, routeSearch } from "../types"; - -const loginRoute: CoreAuthRouteFactory = ({ - localeRouting, - pageHead, - parentRoute, -}) => { - const { internalDestination, useAppNavigate } = createAuthNavigation({ - localeRouting, - }); - - const route = createRoute({ - getParentRoute: () => parentRoute, - - validateSearch: normalizeLoginSearch, - beforeLoad: async ({ context, search }) => { - const auth = await ensureAuthState( - routeContext<{ queryClient: Parameters<typeof ensureAuthState>[0] }>( - context, - ).queryClient, - ); - - if (canAccessGuestRoute(auth)) return; - - const href = postAuthDestination( - routeSearch<{ returnTo?: string }>(search).returnTo, - ); - - // TanStack Router's own control-flow signal: a typed redirect object the - // router catches and turns into a navigation (or, during SSR, a 302). - // eslint-disable-next-line @typescript-eslint/only-throw-error - throw redirect(internalDestination(href)); - }, - // `head` after `loader`, always. - loader: async ({ context }) => await loadLoginRoute(routeContext(context)), - head: ({ loaderData }) => pageHead({ ...loaderData }), - path: "/login", - pendingComponent: AuthPendingSkeleton, - }); - - route.update({ - component: lazyRouteComponent(async () => { - const { LoginRouteContent } = await import("../../auth/login-screen"); - - return { - default: function LoginRoute() { - return ( - <LoginRouteContent - navigate={useAppNavigate()} - returnTo={route.useSearch().returnTo} - /> - ); - }, - }; - }), - }); - - return route; -}; - -const registerRoute: CoreAuthRouteFactory = ({ - localeRouting, - pageHead, - parentRoute, -}) => { - const { useAppNavigate } = createAuthNavigation({ localeRouting }); - - const route = createRoute({ - getParentRoute: () => parentRoute, - beforeLoad: async ({ context }) => { - const auth = await ensureAuthState( - routeContext<{ queryClient: Parameters<typeof ensureAuthState>[0] }>( - context, - ).queryClient, - ); - - if (canAccessGuestRoute(auth)) return; - - // TanStack Router's own control-flow signal - see `/login` above. - // eslint-disable-next-line @typescript-eslint/only-throw-error - throw redirect(parseInternalDestination(postAuthDestination(undefined))); - }, - // `head` after `loader`, always. - loader: async ({ context }) => - await loadRegisterRoute(routeContext(context)), - head: ({ loaderData }) => pageHead({ ...loaderData }), - path: "/register", - pendingComponent: AuthPendingSkeleton, - }); - - route.update({ - component: lazyRouteComponent(async () => { - const { RegisterRouteContent } = - await import("../../auth/register-screen"); - - return { - default: function RegisterRoute() { - return <RegisterRouteContent navigate={useAppNavigate()} />; - }, - }; - }), - }); - - return route; -}; - -const passwordResetRoute: CoreAuthRouteFactory = ({ - pageHead, - parentRoute, -}) => { - const route = createRoute({ - getParentRoute: () => parentRoute, - validateSearch: normalizePasswordResetSearch, - beforeLoad: async ({ context }) => { - const availability = passwordRecoveryAvailability( - await routeContext<{ - queryClient: { - query: (options: unknown) => Promise<never>; - }; - }>(context).queryClient.query({ - ...middlewareConfigQueryOptions(), - staleTime: "static", - }), - ); - - // Not a 404: the route exists, the API could not say whether the flow does. - if (availability === "unknown") throw new PasswordRecoveryUnknownError(); - - // TanStack Router's own control-flow signal, like `redirect()`. - // eslint-disable-next-line @typescript-eslint/only-throw-error - if (availability === "disabled") throw notFound(); - }, - - loaderDeps: ({ search }) => ({ - mode: passwordResetMode(routeSearch(search)).mode, - }), - // `head` after `loader`, always. - loader: async ({ context, deps }) => - await loadPasswordResetRoute({ - ...routeContext<Parameters<typeof loadPasswordResetRoute>[0]>(context), - mode: deps.mode, - }), - head: ({ loaderData }) => pageHead({ ...loaderData }), - path: "/login/reset-password", - pendingComponent: AuthPendingSkeleton, - - notFoundComponent: lazyRouteComponent(async () => { - const [{ PasswordRecoveryNotFound }, { ErrorActions }] = - await Promise.all([ - import("../../auth/recovery-screen"), - import("../../layout/error-actions"), - ]); - - return { - default: function PasswordRecoveryNotFoundScreen() { - return <PasswordRecoveryNotFound actions={<ErrorActions />} />; - }, - }; - }), - }); - - route.update({ - component: lazyRouteComponent(async () => { - const { PasswordResetRouteContent } = - await import("../../auth/recovery-screen"); - - return { - default: function PasswordResetRoute() { - return ( - <PasswordResetRouteContent - namespaces={route.useLoaderData().namespaces} - search={route.useSearch()} - /> - ); - }, - }; - }), - }); - - return route; -}; - -/** The three public auth screens. */ -export const coreAuthRoutes: CoreAuthRouteFactory[] = [ - loginRoute, - registerRoute, - passwordResetRoute, -]; diff --git a/packages/vitnode/src/tanstack/routes/main/discovery.tsx b/packages/vitnode/src/tanstack/routes/main/discovery.tsx deleted file mode 100644 index 9069abb0a..000000000 --- a/packages/vitnode/src/tanstack/routes/main/discovery.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { createRoute, lazyRouteComponent } from "@tanstack/react-router"; - -import type { CoreRouteFactory } from "../types"; - -import { FeedPendingSkeleton } from "../../pending"; -import { loadDiscoverRoute } from "../../search/discover-route"; -import { normalizeSearchRouteSearch } from "../../search/route-search"; -import { loadSearchRoute } from "../../search/search-route"; -import { routeContext, routeSearch } from "../types"; - -const discoverRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { - const route = createRoute({ - getParentRoute: () => parentRoute, - // `head` after `loader`, always: `loaderData` is inferred from `loader`, and - // TypeScript reads an object literal's members in order. - loader: async ({ context }) => - await loadDiscoverRoute(routeContext(context)), - head: ({ loaderData }) => - pageHead({ robots: "index, follow", ...loaderData }), - path: "/discover", - pendingComponent: FeedPendingSkeleton, - }); - - route.update({ - component: lazyRouteComponent(async () => { - const { DiscoverRouteContent } = - await import("../../search/discover-screen"); - - return { - default: function DiscoverRoute() { - return <DiscoverRouteContent {...route.useLoaderData()} />; - }, - }; - }), - }); - - return route; -}; - -const searchRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { - const route = createRoute({ - getParentRoute: () => parentRoute, - - loaderDeps: ({ search }) => ({ - search: routeSearch<{ search: string }>(search).search, - }), - // `head` after `loader`, always. - loader: async ({ context, deps }) => - await loadSearchRoute({ - ...routeContext<Parameters<typeof loadSearchRoute>[0]>(context), - search: deps.search, - }), - head: ({ loaderData }) => - pageHead({ robots: "index, follow", ...loaderData }), - path: "/search", - pendingComponent: FeedPendingSkeleton, - validateSearch: normalizeSearchRouteSearch, - }); - - route.update({ - component: lazyRouteComponent(async () => { - const { SearchRouteContent } = await import("../../search/search-screen"); - - return { - default: function SearchRoute() { - return <SearchRouteContent {...route.useLoaderData()} />; - }, - }; - }), - }); - - return route; -}; - -/** The two public discovery screens. */ -export const coreDiscoveryRoutes: CoreRouteFactory[] = [ - discoverRoute, - searchRoute, -]; diff --git a/packages/vitnode/src/tanstack/routes/main/files.tsx b/packages/vitnode/src/tanstack/routes/main/files.tsx deleted file mode 100644 index 338930bde..000000000 --- a/packages/vitnode/src/tanstack/routes/main/files.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { createRoute, lazyRouteComponent } from "@tanstack/react-router"; -import { useCallback } from "react"; - -import type { CoreRouteFactory } from "../types"; - -import { loadMyFilesRoute } from "../../files/route"; -import { - myFilesRouteParams, - normalizeMyFilesRouteSearch, -} from "../../files/route-search"; -import { TablePendingSkeleton } from "../../pending"; -import { routeContext, routeSearch } from "../types"; - -export const myFilesRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { - const route = createRoute({ - getParentRoute: () => parentRoute, - - loaderDeps: ({ search }) => ({ - params: myFilesRouteParams(routeSearch(search)), - }), - // `head` after `loader`, always. - loader: async ({ context, deps }) => - await loadMyFilesRoute({ - ...routeContext<Parameters<typeof loadMyFilesRoute>[0]>(context), - params: deps.params, - }), - head: ({ loaderData }) => - pageHead({ robots: "noindex, nofollow", ...loaderData }), - path: "/files", - pendingComponent: () => ( - <TablePendingSkeleton className="container mx-auto" /> - ), - validateSearch: normalizeMyFilesRouteSearch, - }); - - route.update({ - component: lazyRouteComponent(async () => { - const { MyFilesRouteContent } = await import("../../files/screen"); - - return { - default: function MyFilesRoute() { - const navigate = route.useNavigate(); - - return ( - <MyFilesRouteContent - {...route.useLoaderData()} - navigate={useCallback( - async ({ - resetScroll, - search, - }: { - resetScroll: boolean; - search: ReturnType<typeof normalizeMyFilesRouteSearch>; - }) => { - await navigate({ resetScroll, search }); - }, - [navigate], - )} - search={route.useSearch()} - /> - ); - }, - }; - }), - }); - - return route; -}; diff --git a/packages/vitnode/src/tanstack/routes/main/index.tsx b/packages/vitnode/src/tanstack/routes/main/index.tsx deleted file mode 100644 index 940e907d3..000000000 --- a/packages/vitnode/src/tanstack/routes/main/index.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import type { AnyRoute } from "@tanstack/react-router"; - -import { createRoute, redirect } from "@tanstack/react-router"; - -import type { - CoreAuthRouteContext, - CoreAuthRouteFactory, - CorePageHead, - CoreRouteFactory, -} from "../types"; - -import { LOGIN_PATH, returnToFor } from "../../auth/redirects"; -import { ensureAuthState } from "../../auth/session-query"; -import { canAccessAuthenticatedRoute } from "../../auth/state"; -import { GuardedOutlet } from "../../pending/guard-pending"; -import { routeContext } from "../types"; -import { coreAuthRoutes } from "./auth"; -import { coreDiscoveryRoutes } from "./discovery"; -import { myFilesRoute } from "./files"; -import { profileRoute } from "./profile"; -import { settingsRoute } from "./settings"; -import { ssoCallbackRoute } from "./sso"; - -export type { - CoreAuthRouteContext, - CoreAuthRouteFactory, - CorePageHead, - CoreRouteContext, - CoreRouteFactory, -} from "../types"; - -export const CORE_MAIN_ROUTES_ROUTE_ID = "_core-main"; - -export const CORE_AUTHENTICATED_ROUTES_ROUTE_ID = "_core-authenticated"; - -const CORE_PUBLIC_ROUTES: CoreAuthRouteFactory[] = [ - ...coreDiscoveryRoutes, - ...coreAuthRoutes, - ssoCallbackRoute, - profileRoute, -]; - -/** Core's screens that require a signed-in visitor. */ -const CORE_AUTHENTICATED_ROUTES: CoreRouteFactory[] = [ - myFilesRoute, - settingsRoute, -]; - -const authenticatedContainer = (parentRoute: AnyRoute): AnyRoute => - createRoute({ - getParentRoute: () => parentRoute, - id: CORE_AUTHENTICATED_ROUTES_ROUTE_ID, - beforeLoad: async ({ context, location }) => { - const auth = await ensureAuthState( - routeContext<{ - queryClient: Parameters<typeof ensureAuthState>[0]; - }>(context).queryClient, - ); - - if (!canAccessAuthenticatedRoute(auth)) { - // TanStack Router's own control-flow signal: `redirect()` returns a - // typed redirect object that the router catches and turns into a - // navigation (or, during SSR, a 302). Throwing it is what stops the - // guard - and what narrows the code below. - // eslint-disable-next-line @typescript-eslint/only-throw-error - throw redirect({ - search: { - // The *internal* path - the locale has already been stripped by the - // rewrite - so the value that round-trips through the login URL - // carries no language, and the prefix is written back exactly once, - // by the rewrite, when the router builds the way home. - returnTo: returnToFor(location), - }, - to: LOGIN_PATH, - }); - } - - return { auth }; - }, - - component: GuardedOutlet, - }); - -export const withCoreMainRoutes = <TRouteTree extends AnyRoute>( - routeTree: TRouteTree, - { - localeRouting, - mountUnder, - pageHead, - }: { - localeRouting: CoreAuthRouteContext["localeRouting"]; - mountUnder: AnyRoute; - pageHead: CorePageHead; - }, -): TRouteTree => { - const mounted: AnyRoute[] = mountUnder.children ?? []; - const siblings = mounted.filter( - (child: AnyRoute) => - (child.options as { id?: string }).id !== CORE_MAIN_ROUTES_ROUTE_ID, - ); - - const container = createRoute({ - getParentRoute: () => mountUnder, - id: CORE_MAIN_ROUTES_ROUTE_ID, - }); - const authenticated = authenticatedContainer(container); - - authenticated.addChildren( - CORE_AUTHENTICATED_ROUTES.map(build => - build({ pageHead, parentRoute: authenticated }), - ), - ); - container.addChildren([ - ...CORE_PUBLIC_ROUTES.map(build => - build({ localeRouting, pageHead, parentRoute: container }), - ), - authenticated, - ]); - mountUnder.addChildren([...siblings, container]); - - return routeTree; -}; diff --git a/packages/vitnode/src/tanstack/routes/main/profile.tsx b/packages/vitnode/src/tanstack/routes/main/profile.tsx deleted file mode 100644 index 1dd4d43cf..000000000 --- a/packages/vitnode/src/tanstack/routes/main/profile.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { createRoute, lazyRouteComponent } from "@tanstack/react-router"; - -import type { ProfileLoaderContext } from "../../profile/route"; -import type { CoreRouteFactory } from "../types"; - -import { ProfilePendingSkeleton } from "../../pending"; -import { loadProfileRoute } from "../../profile/route"; -import { routeContext } from "../types"; - -export const profileRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { - const route = createRoute({ - getParentRoute: () => parentRoute, - // `head` after `loader`, always. - loader: async ({ context, params }) => - await loadProfileRoute({ - ...routeContext<ProfileLoaderContext>(context), - nameCode: params.nameCode, - }), - head: ({ loaderData }) => - pageHead({ robots: "index, follow", ...loaderData }), - path: "/users/$nameCode", - pendingComponent: ProfilePendingSkeleton, - - notFoundComponent: lazyRouteComponent(async () => { - const [{ ProfileNotFound }, { ErrorActions }] = await Promise.all([ - import("../../profile/not-found"), - import("../../layout/error-actions"), - ]); - - return { - default: function ProfileNotFoundScreen() { - return <ProfileNotFound actions={<ErrorActions />} />; - }, - }; - }), - }); - - route.update({ - component: lazyRouteComponent(async () => { - const { ProfileRouteContent } = await import("../../profile/screen"); - - return { - default: function ProfileRoute() { - return ( - <ProfileRouteContent nameCode={route.useLoaderData().nameCode} /> - ); - }, - }; - }), - }); - - return route; -}; diff --git a/packages/vitnode/src/tanstack/routes/main/settings.tsx b/packages/vitnode/src/tanstack/routes/main/settings.tsx deleted file mode 100644 index 321c122cd..000000000 --- a/packages/vitnode/src/tanstack/routes/main/settings.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import type { AnyRoute } from "@tanstack/react-router"; - -import { - createRoute, - lazyRouteComponent, - Outlet, -} from "@tanstack/react-router"; - -import { SettingsBreadcrumbContent } from "@/views/auth/settings/settings-breadcrumb-content"; - -import type { - SettingsLoaderContext, - SettingsNavKey, -} from "../../settings/route"; -import type { CoreRouteFactory } from "../types"; - -import { devicesQuery } from "../../devices/query"; -import { RouteMessages } from "../../i18n/route-messages"; -import { FeedPendingSkeleton, FormPendingSkeleton } from "../../pending"; -import { userProfileQuery } from "../../profile/query"; -import { personalInfoPolicyQuery } from "../../settings/personal-policy"; -import { - loadSettingsPanel, - SETTINGS_NAMESPACES, - settingsMessagesQueryOptions, -} from "../../settings/route"; -import { routeContext } from "../types"; - -const SettingsBreadcrumb = ({ navKey }: { navKey?: SettingsNavKey }) => ( - <RouteMessages namespaces={SETTINGS_NAMESPACES}> - <SettingsBreadcrumbContent navKey={navKey} /> - </RouteMessages> -); - -export const settingsRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { - const layout = createRoute({ - getParentRoute: () => parentRoute, - - loader: async ({ context }) => { - const narrowed = routeContext<SettingsLoaderContext>(context); - - await narrowed.queryClient.query({ - ...settingsMessagesQueryOptions(narrowed.locale), - staleTime: "static", - }); - }, - - head: () => ({ meta: [{ content: "noindex, nofollow", name: "robots" }] }), - path: "/settings", - pendingComponent: () => ( - <FormPendingSkeleton className="container mx-auto" /> - ), - /** - * The first crumb of the trail - "Settings", linking to this frame's own - * URL. Each panel adds its own after it. - */ - staticData: { breadcrumb: <SettingsBreadcrumb /> }, - }); - - layout.update({ - component: lazyRouteComponent(async () => { - const { SettingsLayoutContent } = await import("../../settings/layout"); - - return { - default: function SettingsLayout() { - return ( - <SettingsLayoutContent> - <Outlet /> - </SettingsLayoutContent> - ); - }, - }; - }), - }); - - const panel = ( - navKey: SettingsNavKey, - path: string, - loadPanel: () => Promise<{ default: React.FunctionComponent }>, - ): AnyRoute => - createRoute({ - getParentRoute: () => layout, - // `head` after `loader`, always. - loader: async ({ context }) => - await loadSettingsPanel(routeContext(context), navKey), - head: ({ loaderData }) => pageHead({ ...loaderData }), - path, - component: lazyRouteComponent(loadPanel), - pendingComponent: FormPendingSkeleton, - staticData: { breadcrumb: <SettingsBreadcrumb navKey={navKey} /> }, - }); - - const overview: AnyRoute = createRoute({ - getParentRoute: () => layout, - - loader: async ({ context }) => { - const narrowed = routeContext< - SettingsLoaderContext & { auth: { user: { nameCode: string } } } - >(context); - const { nameCode } = narrowed.auth.user; - - const [data] = await Promise.all([ - loadSettingsPanel(narrowed, "overview"), - narrowed.queryClient.query({ - ...userProfileQuery(nameCode), - staleTime: "static", - }), - narrowed.queryClient.query(personalInfoPolicyQuery()), - ]); - - return { ...data, nameCode }; - }, - head: ({ loaderData }) => pageHead({ ...loaderData }), - path: "/", - pendingComponent: FormPendingSkeleton, - }); - - overview.update({ - component: lazyRouteComponent(async () => { - const { OverviewSettings } = await import("../../settings/overview"); - - return { - default: function OverviewRoute() { - return ( - <OverviewSettings nameCode={overview.useLoaderData().nameCode} /> - ); - }, - }; - }), - }); - - const devices: AnyRoute = createRoute({ - getParentRoute: () => layout, - - loader: async ({ context }) => { - const narrowed = routeContext< - SettingsLoaderContext & { auth: { user: { id: number } } } - >(context); - const userId = narrowed.auth.user.id; - - const [data] = await Promise.all([ - loadSettingsPanel(narrowed, "devices"), - narrowed.queryClient.query({ - ...devicesQuery(userId), - staleTime: "static", - }), - ]); - - return { ...data, userId }; - }, - head: ({ loaderData }) => pageHead({ ...loaderData }), - path: "/devices", - pendingComponent: () => <FeedPendingSkeleton rows={4} />, - staticData: { breadcrumb: <SettingsBreadcrumb navKey="devices" /> }, - }); - - devices.update({ - component: lazyRouteComponent(async () => { - const { DevicesPanelContent } = await import("../../devices/panel"); - - return { - default: function DevicesRoute() { - return ( - <DevicesPanelContent userId={devices.useLoaderData().userId} /> - ); - }, - }; - }), - }); - - layout.addChildren([ - overview, - panel("security", "/security", async () => ({ - default: (await import("@/views/auth/settings/security/security")) - .SecuritySettings, - })), - devices, - ]); - - return layout; -}; diff --git a/packages/vitnode/src/tanstack/routes/main/sso.tsx b/packages/vitnode/src/tanstack/routes/main/sso.tsx deleted file mode 100644 index 197936c49..000000000 --- a/packages/vitnode/src/tanstack/routes/main/sso.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { createRoute, lazyRouteComponent } from "@tanstack/react-router"; - -import type { CoreAuthRouteFactory } from "../types"; - -import { normalizeSsoCallbackSearch } from "../../auth/route-search"; -import { loadSsoCallbackRoute } from "../../auth/sso-route"; -import { AuthPendingSkeleton } from "../../pending"; -import { routeContext } from "../types"; - -export const ssoCallbackRoute: CoreAuthRouteFactory = ({ parentRoute }) => { - const route = createRoute({ - getParentRoute: () => parentRoute, - - validateSearch: normalizeSsoCallbackSearch, - loader: async ({ context }) => - await loadSsoCallbackRoute(routeContext(context)), - path: "/login/sso/$providerId", - pendingComponent: AuthPendingSkeleton, - }); - - route.update({ - component: lazyRouteComponent(async () => { - const [{ SsoCallbackRouteContent }, { ErrorActions }] = await Promise.all( - [import("../../auth/sso-screen"), import("../../layout/error-actions")], - ); - - return { - default: function SsoCallbackRoute() { - return ( - <SsoCallbackRouteContent - errorActions={<ErrorActions />} - providerId={route.useParams().providerId} - search={route.useSearch()} - /> - ); - }, - }; - }), - }); - - return route; -}; diff --git a/packages/vitnode/src/tanstack/routes/root/admin-sign-in.tsx b/packages/vitnode/src/tanstack/routes/root/admin-sign-in.tsx deleted file mode 100644 index 04594dcbd..000000000 --- a/packages/vitnode/src/tanstack/routes/root/admin-sign-in.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { - createRoute, - lazyRouteComponent, - redirect, -} from "@tanstack/react-router"; - -import type { CoreRootRouteFactory } from "./types"; - -import { sanitizeAdminReturnTo } from "../../admin/return-to"; -import { prefetchAdminAccess } from "../../admin/session-query"; -import { loadAdminSignInRoute } from "../../admin/sign-in-route"; -import { ADMIN_RETURN_TO_PARAM, canEnterAdmin } from "../../admin/state"; -import { createAuthNavigation } from "../../auth/redirects"; -import { AuthPendingSkeleton } from "../../pending"; -import { routeContext, routeSearch } from "../types"; - -export const adminSignInRoute: CoreRootRouteFactory = ({ - localeRouting, - pageHead, - parentRoute, -}) => { - const { internalDestination, useAppNavigate } = createAuthNavigation({ - localeRouting, - }); - - const route = createRoute({ - getParentRoute: () => parentRoute, - validateSearch: ( - search: Record<string, unknown>, - ): { returnTo?: string } => ({ - returnTo: - typeof search[ADMIN_RETURN_TO_PARAM] === "string" - ? search[ADMIN_RETURN_TO_PARAM] - : undefined, - }), - - beforeLoad: async ({ context, search }) => { - const access = await prefetchAdminAccess( - routeContext<{ - queryClient: Parameters<typeof prefetchAdminAccess>[0]; - }>(context).queryClient, - ); - - if (!access || !canEnterAdmin(access)) return; - - const href = sanitizeAdminReturnTo( - routeSearch<{ returnTo?: string }>(search).returnTo, - ); - - // TanStack Router's own control-flow signal - see `/login`. - // eslint-disable-next-line @typescript-eslint/only-throw-error - throw redirect(internalDestination(href)); - }, - // `head` after `loader`, always. - loader: async ({ context }) => - await loadAdminSignInRoute(routeContext(context)), - head: ({ loaderData }) => pageHead({ ...loaderData }), - path: "/admin", - pendingComponent: AuthPendingSkeleton, - }); - - route.update({ - component: lazyRouteComponent(async () => { - const { AdminSignInRouteContent } = - await import("../../admin/sign-in-screen"); - - return { - default: function AdminSignInRoute() { - return ( - <AdminSignInRouteContent - navigate={useAppNavigate()} - returnTo={route.useSearch().returnTo} - /> - ); - }, - }; - }), - }); - - return route; -}; diff --git a/packages/vitnode/src/tanstack/routes/root/index.tsx b/packages/vitnode/src/tanstack/routes/root/index.tsx deleted file mode 100644 index fe02caa85..000000000 --- a/packages/vitnode/src/tanstack/routes/root/index.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import type { AnyRoute } from "@tanstack/react-router"; - -import { createRoute } from "@tanstack/react-router"; - -import type { CorePageHead } from "../types"; -import type { CoreRootRouteContext, CoreRootRouteFactory } from "./types"; - -import { adminSignInRoute } from "./admin-sign-in"; - -export type { CoreRootRouteContext, CoreRootRouteFactory } from "./types"; - -export const CORE_ROOT_ROUTES_ROUTE_ID = "_core-root"; - -const CORE_ROOT_ROUTES: CoreRootRouteFactory[] = [adminSignInRoute]; - -export const withCoreRootRoutes = <TRouteTree extends AnyRoute>( - routeTree: TRouteTree, - { - localeRouting, - mountUnder, - pageHead, - }: { - localeRouting: CoreRootRouteContext["localeRouting"]; - mountUnder: AnyRoute; - pageHead: CorePageHead; - }, -): TRouteTree => { - const mounted: AnyRoute[] = mountUnder.children ?? []; - const siblings = mounted.filter( - (child: AnyRoute) => - (child.options as { id?: string }).id !== CORE_ROOT_ROUTES_ROUTE_ID, - ); - - const container = createRoute({ - getParentRoute: () => mountUnder, - id: CORE_ROOT_ROUTES_ROUTE_ID, - }); - - container.addChildren( - CORE_ROOT_ROUTES.map(build => - build({ localeRouting, pageHead, parentRoute: container }), - ), - ); - mountUnder.addChildren([...siblings, container]); - - return routeTree; -}; diff --git a/packages/vitnode/src/tanstack/routes/root/root-routes.test.ts b/packages/vitnode/src/tanstack/routes/root/root-routes.test.ts deleted file mode 100644 index 046fd27ce..000000000 --- a/packages/vitnode/src/tanstack/routes/root/root-routes.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; - -const here = import.meta.dirname; - -/** Source with its comments removed - prose may name what code may not do. */ -const withoutComments = (source: string): string => - source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, ""); - -const modules = readdirSync(here) - .filter(name => /\.tsx?$/.test(name) && !name.endsWith(".test.ts")) - .sort(); - -const codeOf = (name: string): string => - withoutComments(readFileSync(join(here, name), "utf8")); - -const everyRoutePath = modules - .flatMap(name => [...codeOf(name).matchAll(/path: "([^"]+)"/g)]) - .map(match => match[1]) - .sort(); - -describe("what this directory declares", () => { - it("declares the shell-less screen, and only it", () => { - expect(everyRoutePath).toEqual(["/admin"]); - }); - - it("declares no screen the main shell now owns", () => { - for (const path of everyRoutePath) { - expect(path.startsWith("/login"), path).toBe(false); - expect(path, path).not.toBe("/register"); - } - }); -}); - -describe("how it reaches an application", () => { - const index = codeOf("index.tsx"); - - it("takes the host's locale rule as well as its page head", () => { - expect(index).toContain("export const withCoreRootRoutes"); - expect(index).toMatch(/localeRouting/); - expect(index).toMatch(/pageHead/); - expect(index).toMatch(/mountUnder/); - }); - - it("builds its navigation from the injected rule", () => { - const signIn = codeOf("admin-sign-in.tsx"); - - expect(signIn).toContain("createAuthNavigation({"); - expect(signIn).toContain("localeRouting"); - // No second copy of the rule: no route here strips a prefix by hand. The - // injected shape is named once, in `../types.ts`, which is the opposite of - // a copy. - for (const name of modules) { - expect(codeOf(name), name).not.toContain("deLocalize"); - } - }); - - /** Idempotent, and a good neighbour - the same contract the other two have. */ - it("mounts under its own container, replacing any previous copy", () => { - expect(index).toContain("CORE_ROOT_ROUTES_ROUTE_ID"); - expect(index).toContain("siblings"); - expect(index).toMatch(/addChildren\(\[\.\.\.siblings, container\]\)/); - }); - - it("writes nothing and reads no filesystem", () => { - for (const name of modules) { - const code = codeOf(name); - - expect(code, name).not.toMatch(/node:fs|writeFile|createFileRoute/); - expect(code, name).not.toMatch(/src\/routes/); - } - }); -}); - -describe("the guard this screen carries", () => { - it("never redirects by href", () => { - for (const name of modules) { - expect(codeOf(name), name).not.toMatch(/redirect\(\{[^}]*href:/); - } - }); - - it("reads the admin session tolerantly on the AdminCP entrance", () => { - const signIn = codeOf("admin-sign-in.tsx"); - - expect(signIn).toContain("prefetchAdminAccess"); - expect(signIn).not.toContain("ensureAdminAccess"); - expect(signIn).toMatch( - /if \(!access \|\| !canEnterAdmin\(access\)\) return/, - ); - }); -}); diff --git a/packages/vitnode/src/tanstack/routes/root/types.ts b/packages/vitnode/src/tanstack/routes/root/types.ts deleted file mode 100644 index 7a5698e42..000000000 --- a/packages/vitnode/src/tanstack/routes/root/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { CoreAuthRouteContext, CoreRouteFactory } from "../types"; - -export type CoreRootRouteContext = CoreAuthRouteContext; - -/** One screen with no shell above it. */ -export type CoreRootRouteFactory = CoreRouteFactory<CoreRootRouteContext>; diff --git a/packages/vitnode/src/tanstack/routes/types.ts b/packages/vitnode/src/tanstack/routes/types.ts deleted file mode 100644 index 5777f048d..000000000 --- a/packages/vitnode/src/tanstack/routes/types.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { AnyRoute } from "@tanstack/react-router"; - -import type { ContentFrontendRegistry } from "../../content/admin/registry"; -import type { LocaleRouting } from "../../lib/i18n/locale-routing"; -import type { RouteHeadOptions, RouteHeadResult } from "../metadata"; - -export type CorePageHead = (options?: RouteHeadOptions) => RouteHeadResult; - -/** What a core route is built with. */ -export interface CoreRouteContext { - /** The host's `pageHead`, so every title ends with the site's own name. */ - pageHead: CorePageHead; - /** - * The route this screen hangs from - always a **pathless** container, so a - * screen's `path` is its full public URL and nothing prefixes it. - */ - parentRoute: AnyRoute; -} - -export type CoreRouteFactory< - TContext extends CoreRouteContext = CoreRouteContext, -> = (context: TContext) => AnyRoute; - -/** What the Content Engine's splat needs beyond the usual two. */ -export interface CoreAdminRouteContext extends CoreRouteContext { - loadContentRegistry: () => Promise<ContentFrontendRegistry>; -} - -export const routeSearch = <TSearch>(search: unknown): TSearch => - search as TSearch; - -/** See {@link routeSearch}. */ -export const routeContext = <TContext>(context: unknown): TContext => - context as TContext; - -export interface CoreAuthRouteContext extends CoreRouteContext { - localeRouting: Pick<LocaleRouting, "deLocalizeUrl">; -} - -/** One screen that navigates on the visitor's behalf. */ -export type CoreAuthRouteFactory = CoreRouteFactory<CoreAuthRouteContext>; diff --git a/packages/vitnode/src/tanstack/search/discover-route.tsx b/packages/vitnode/src/tanstack/search/discover-route.tsx index 36acb94a0..1d1067c29 100644 --- a/packages/vitnode/src/tanstack/search/discover-route.tsx +++ b/packages/vitnode/src/tanstack/search/discover-route.tsx @@ -1,11 +1,10 @@ -import type { QueryClient } from "@tanstack/react-query"; +import type { PluginRouteTranslator } from "@/routing"; -import { createTranslator } from "use-intl"; +import type { QueryClient } from "@tanstack/react-query"; -import { intlQueryOptions } from "../i18n/query"; import { discoverFeedQueryOptions } from "./discover"; -export const DISCOVER_NAMESPACES = ["core.global", "core.search"] as const; +export { DISCOVER_NAMESPACES } from "./namespaces"; /** The narrowest slice of a route's context this loader reads. */ export interface DiscoverLoaderContext { @@ -22,25 +21,17 @@ export interface DiscoverRouteData { export const loadDiscoverRoute = async ({ locale, queryClient, -}: DiscoverLoaderContext): Promise<DiscoverRouteData> => { - const [intl] = await Promise.all([ - queryClient.query({ - ...intlQueryOptions({ locale, namespaces: DISCOVER_NAMESPACES }), - staleTime: "static", - }), - queryClient.infiniteQuery({ - ...discoverFeedQueryOptions({ locale }), - staleTime: "static", - }), - ]); - - const t = createTranslator({ - locale, - messages: intl.messages as { - core: { search: { discoverDesc: string; discoverTitle: string } }; - }, - namespace: "core.search", + t, +}: DiscoverLoaderContext & { + t: PluginRouteTranslator; +}): Promise<DiscoverRouteData> => { + await queryClient.infiniteQuery({ + ...discoverFeedQueryOptions({ locale }), + staleTime: "static", }); - return { description: t("discoverDesc"), title: t("discoverTitle") }; + return { + description: t("core.search.discoverDesc"), + title: t("core.search.discoverTitle"), + }; }; diff --git a/packages/vitnode/src/tanstack/search/namespaces.ts b/packages/vitnode/src/tanstack/search/namespaces.ts new file mode 100644 index 000000000..d9e446ae7 --- /dev/null +++ b/packages/vitnode/src/tanstack/search/namespaces.ts @@ -0,0 +1,10 @@ +/** + * The message namespaces the two discovery screens declare. + * + * Their own module because `routes.tsx` names them: a route tree is read by the + * build in Node, and importing them from a loader would pull that loader's query + * modules along for a pair of string arrays. + */ +export const DISCOVER_NAMESPACES = ["core.global", "core.search"] as const; + +export const SEARCH_NAMESPACES = ["core.global", "core.search"] as const; diff --git a/packages/vitnode/src/tanstack/search/search-route.tsx b/packages/vitnode/src/tanstack/search/search-route.tsx index 5031e995d..3aa19e287 100644 --- a/packages/vitnode/src/tanstack/search/search-route.tsx +++ b/packages/vitnode/src/tanstack/search/search-route.tsx @@ -1,14 +1,13 @@ -import type { QueryClient } from "@tanstack/react-query"; +import type { PluginRouteTranslator } from "@/routing"; -import { createTranslator } from "use-intl"; +import type { QueryClient } from "@tanstack/react-query"; import type { SearchFeedParams } from "@/views/search/search-feed-query"; -import { intlQueryOptions } from "../i18n/query"; import { feedQueryOptions } from "./feed"; import { searchRouteFeedParams } from "./route-search"; -export const SEARCH_NAMESPACES = ["core.global", "core.search"] as const; +export { SEARCH_NAMESPACES } from "./namespaces"; /** The narrowest slice of a route's context this loader reads. */ export interface SearchLoaderContext { @@ -27,27 +26,21 @@ export const loadSearchRoute = async ({ locale, queryClient, search, -}: SearchLoaderContext & { search?: string }): Promise<SearchRouteData> => { + t, +}: SearchLoaderContext & { + search?: string; + t: PluginRouteTranslator; +}): Promise<SearchRouteData> => { const params = searchRouteFeedParams({ search }); - const [intl] = await Promise.all([ - queryClient.query({ - ...intlQueryOptions({ locale, namespaces: SEARCH_NAMESPACES }), - staleTime: "static", - }), - queryClient.infiniteQuery({ - ...feedQueryOptions({ locale, params }), - staleTime: "static", - }), - ]); - - const t = createTranslator({ - locale, - messages: intl.messages as { - core: { search: { desc: string; title: string } }; - }, - namespace: "core.search", + await queryClient.infiniteQuery({ + ...feedQueryOptions({ locale, params }), + staleTime: "static", }); - return { description: t("desc"), params, title: t("title") }; + return { + description: t("core.search.desc"), + params, + title: t("core.search.title"), + }; }; diff --git a/packages/vitnode/src/tanstack/settings/breadcrumb.tsx b/packages/vitnode/src/tanstack/settings/breadcrumb.tsx new file mode 100644 index 000000000..ad87b1196 --- /dev/null +++ b/packages/vitnode/src/tanstack/settings/breadcrumb.tsx @@ -0,0 +1,20 @@ +import type { SettingsNavKey } from "@/views/auth/settings/settings-nav"; + +import { SettingsBreadcrumbContent } from "@/views/auth/settings/settings-breadcrumb-content"; + +/** + * One crumb of the settings trail - "Settings" for the frame itself, and the + * panel's own name below it. + * + * Here rather than in the layout's page module because three routes share it, + * and a page module's job is to export a page: its `default` and its `route`, + * and nothing anything else imports. + * + * The messages it renders from are declared by the `/settings` layout, and the + * runtime wraps a crumb in its route's namespaces - so there is no + * `RouteMessages` here. + */ +export const settingsBreadcrumb = (navKey?: SettingsNavKey) => + function SettingsBreadcrumb() { + return <SettingsBreadcrumbContent navKey={navKey} />; + }; diff --git a/packages/vitnode/src/tanstack/settings/route.ts b/packages/vitnode/src/tanstack/settings/route.ts index ce4105904..603c884a2 100644 --- a/packages/vitnode/src/tanstack/settings/route.ts +++ b/packages/vitnode/src/tanstack/settings/route.ts @@ -1,138 +1,9 @@ -import type { QueryClient } from "@tanstack/react-query"; - -import { createTranslator } from "use-intl"; - -import type { VitNodeMetadata } from "@/lib/metadata"; import type { SettingsNavKey } from "@/views/auth/settings/settings-nav"; -import { formatPageTitle } from "@/lib/metadata"; - -import { intlQueryOptions } from "../i18n/query"; - export const SETTINGS_NAMESPACES = [ "core.auth.settings", "core.global", "core.profile.images", ] as const; -interface SettingsMessages { - core: { - auth: { - settings: { - desc: string; - nav: { devices: string; overview: string; security: string }; - title: string; - }; - }; - }; -} - -/** The narrowest slice of a settings route's context the loader below reads. */ -export interface SettingsLoaderContext { - locale: string; - queryClient: QueryClient; -} - -export const settingsMessagesQueryOptions = (locale: string) => - intlQueryOptions({ locale, namespaces: SETTINGS_NAMESPACES }); - -/** What a panel's loader returns, and therefore what its `head` receives. */ -export interface SettingsPanelData { - title: string; -} - -export const settingsPanelTitle = ({ - locale, - messages, - navKey, -}: { - locale: string; - messages: unknown; - navKey: SettingsNavKey; -}): string => { - const typed = messages as SettingsMessages; - const t = createTranslator({ - locale, - messages: typed, - namespace: "core.auth.settings", - }); - const tNav = createTranslator({ - locale, - messages: typed, - namespace: "core.auth.settings.nav", - }); - - return `${tNav(navKey)} - ${t("title")}`; -}; - -/** - * The settings panel loader and `head`, bound to one host. - * - * A factory rather than two exported functions, because exactly one thing here - * belongs to the application rather than to the feature: `metadata`, the site's - * own name, which is configuration a package cannot own. The message transport - * is not on that list any more - `intlQueryOptions` reads the runtime a host - * registers through `configureIntl`, so loading the strings is this module's - * job again. - * - * Everything else - which namespaces, which two message keys, what the title - * reads, and the decision that a panel's `head` is a title and nothing else - is - * here, so three panel routes and a breadcrumb cannot drift apart. - */ -/** - * A settings panel's loader: warm the strings, translate its title. - * - * Every panel's loader is this and nothing else, until a panel has data of its - * own to fetch - at which point it awaits this alongside its own read rather - * than replacing it. - * - * Standalone, and it takes no metadata, because it needs none: a panel's *title* - * is a translated string and the site name is appended later, by whichever - * `head` renders it. {@link createSettingsPanel} re-exports this one rather than - * carrying a second copy. - */ -export const loadSettingsPanel = async ( - context: SettingsLoaderContext, - navKey: SettingsNavKey, -): Promise<SettingsPanelData> => { - const intl = await context.queryClient.query({ - ...settingsMessagesQueryOptions(context.locale), - staleTime: "static", - }); - - return { - title: settingsPanelTitle({ - locale: context.locale, - messages: intl.messages, - navKey, - }), - }; -}; - -export const createSettingsPanel = ({ - metadata, -}: { - metadata: VitNodeMetadata; -}) => ({ - loadSettingsPanel, - - /** - * A settings panel's `head`, which is a title and deliberately nothing else. - * - * `robots` is **not** here. The settings layout declares `noindex, nofollow` - * once, and TanStack Start merges the `head` of every matched route - so the - * whole subtree inherits it and a panel that restated it would be a second - * copy to keep in step. - * - * `loaderData` is optional because the router types it so: it is `undefined` - * while the route's loader is still pending, and a `head` that assumed - * otherwise would throw during the first pass of a navigation. - */ - settingsPanelHead: (loaderData?: SettingsPanelData) => ({ - meta: loaderData - ? [{ title: formatPageTitle(metadata, loaderData.title) }] - : [], - }), -}); - export type { SettingsNavKey }; diff --git a/plugins/example/src/pages/browse-page.tsx b/plugins/example/src/pages/browse-page.tsx index 0cf3d45d7..39e5239d1 100644 --- a/plugins/example/src/pages/browse-page.tsx +++ b/plugins/example/src/pages/browse-page.tsx @@ -86,7 +86,15 @@ const BrowsePage = ({ }; export const route = definePluginRoute({ - head: () => ({ title: "Browse" }), + /** + * `head` runs outside the React tree, so `useTranslations` cannot reach it. + * `t` is the same strings by another door, over the namespaces this route + * declared in `routes.ts`. + */ + head: ({ t }) => ({ + description: t("@vitnode/example.browse.desc"), + title: t("@vitnode/example.browse.title"), + }), breadcrumb: false, }); From b4636681744953c95f68f1a690fcf8884a0e03b3 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Tue, 15 Sep 2026 00:23:38 +0200 Subject: [PATCH 2/4] =?UTF-8?q?refactor(devices):=20=E2=9C=A8=20restructur?= =?UTF-8?q?e=20devices=20handling=20and=20improve=20query=20options?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vitnode/src/pages/settings/devices.tsx | 4 +- .../vitnode/src/pages/settings/security.tsx | 3 +- packages/vitnode/src/routes.tsx | 2 +- .../vitnode/src/tanstack/admin/cron/route.tsx | 1 - .../src/tanstack/admin/debug/route.tsx | 1 - .../src/tanstack/admin/files/route.tsx | 1 - .../src/tanstack/admin/integrations/index.ts | 3 +- .../src/tanstack/admin/integrations/query.ts | 8 +- .../src/tanstack/admin/integrations/route.tsx | 5 +- .../tanstack/admin/integrations/screen.tsx | 4 +- .../src/tanstack/admin/queue/route.tsx | 1 - .../src/tanstack/admin/roles/route.tsx | 1 - .../src/tanstack/admin/staff/create-route.tsx | 3 +- .../src/tanstack/admin/staff/edit-route.tsx | 3 +- .../src/tanstack/admin/staff/route.tsx | 3 +- .../src/tanstack/admin/users/detail-route.tsx | 3 +- .../src/tanstack/admin/users/route.tsx | 1 - .../src/tanstack/devices/devices.test.ts | 183 ----------------- .../vitnode/src/tanstack/devices/index.ts | 1 + .../vitnode/src/tanstack/devices/panel.tsx | 16 +- .../vitnode/src/tanstack/devices/query.ts | 7 +- packages/vitnode/src/tanstack/files/route.tsx | 3 +- .../src/tanstack/plugin-routes/mount.tsx | 2 +- .../vitnode/src/tanstack/profile/route.ts | 3 +- .../src/tanstack/search/discover-route.tsx | 4 +- .../src/tanstack/search/search-route.tsx | 3 +- .../devices/devices-boundaries.test.ts | 54 ----- .../settings/devices/devices-query.test.ts | 191 ------------------ 28 files changed, 27 insertions(+), 487 deletions(-) delete mode 100644 packages/vitnode/src/tanstack/devices/devices.test.ts delete mode 100644 packages/vitnode/src/views/auth/settings/devices/devices-boundaries.test.ts delete mode 100644 packages/vitnode/src/views/auth/settings/devices/devices-query.test.ts diff --git a/packages/vitnode/src/pages/settings/devices.tsx b/packages/vitnode/src/pages/settings/devices.tsx index e5fb08183..1de1ead23 100644 --- a/packages/vitnode/src/pages/settings/devices.tsx +++ b/packages/vitnode/src/pages/settings/devices.tsx @@ -1,9 +1,9 @@ import type { PluginRoutePageProps } from "@/routing"; import { DevicesPanelContent } from "@/tanstack/devices/panel"; -import { devicesQuery } from "@/tanstack/devices/query"; import { defineAuthenticatedRoute } from "@/tanstack/plugin-routes"; import { settingsBreadcrumb } from "@/tanstack/settings/breadcrumb"; +import { devicesQueryOptions } from "@/views/auth/settings/devices/devices-query"; interface DevicesData { userId: number; @@ -18,7 +18,7 @@ export const route = defineAuthenticatedRoute<DevicesData>({ const userId = context.auth.user.id; await context.queryClient.query({ - ...devicesQuery(userId), + ...devicesQueryOptions({ userId }), staleTime: "static", }); diff --git a/packages/vitnode/src/pages/settings/security.tsx b/packages/vitnode/src/pages/settings/security.tsx index 2621ee409..3f728d536 100644 --- a/packages/vitnode/src/pages/settings/security.tsx +++ b/packages/vitnode/src/pages/settings/security.tsx @@ -1,7 +1,6 @@ -import { SecuritySettings } from "@/views/auth/settings/security/security"; - import { defineAuthenticatedRoute } from "@/tanstack/plugin-routes"; import { settingsBreadcrumb } from "@/tanstack/settings/breadcrumb"; +import { SecuritySettings } from "@/views/auth/settings/security/security"; export const route = defineAuthenticatedRoute({ head: ({ t }) => ({ diff --git a/packages/vitnode/src/routes.tsx b/packages/vitnode/src/routes.tsx index 9ba1d5239..84c1ccb1c 100644 --- a/packages/vitnode/src/routes.tsx +++ b/packages/vitnode/src/routes.tsx @@ -42,12 +42,12 @@ import { ProfilePendingSkeleton, TablePendingSkeleton, } from "./tanstack/pending"; +import { PROFILE_NAMESPACES } from "./tanstack/profile/route"; import { DISCOVER_NAMESPACES, SEARCH_NAMESPACES, } from "./tanstack/search/namespaces"; import { normalizeSearchRouteSearch } from "./tanstack/search/route-search"; -import { PROFILE_NAMESPACES } from "./tanstack/profile/route"; import { SETTINGS_NAMESPACES } from "./tanstack/settings/route"; /** diff --git a/packages/vitnode/src/tanstack/admin/cron/route.tsx b/packages/vitnode/src/tanstack/admin/cron/route.tsx index 8622dcd91..4068e9b9e 100644 --- a/packages/vitnode/src/tanstack/admin/cron/route.tsx +++ b/packages/vitnode/src/tanstack/admin/cron/route.tsx @@ -1,5 +1,4 @@ import type { PluginRouteTranslator } from "@/routing"; - import type { CronParams } from "@/views/admin/views/core/advanced/cron/cron-query"; import type { AdminScreenContext } from "../screen"; diff --git a/packages/vitnode/src/tanstack/admin/debug/route.tsx b/packages/vitnode/src/tanstack/admin/debug/route.tsx index 381a8778e..74075f6da 100644 --- a/packages/vitnode/src/tanstack/admin/debug/route.tsx +++ b/packages/vitnode/src/tanstack/admin/debug/route.tsx @@ -1,5 +1,4 @@ import type { PluginRouteTranslator } from "@/routing"; - import type { DebugLogsParams } from "@/views/admin/views/core/debug/debug-query"; import type { AdminScreenContext } from "../screen"; diff --git a/packages/vitnode/src/tanstack/admin/files/route.tsx b/packages/vitnode/src/tanstack/admin/files/route.tsx index 0f243527f..f4d0f0e5f 100644 --- a/packages/vitnode/src/tanstack/admin/files/route.tsx +++ b/packages/vitnode/src/tanstack/admin/files/route.tsx @@ -1,5 +1,4 @@ import type { PluginRouteTranslator } from "@/routing"; - import type { AdminFilesParams } from "@/views/admin/views/core/system/files/files-query"; import type { AdminScreenContext } from "../screen"; diff --git a/packages/vitnode/src/tanstack/admin/integrations/index.ts b/packages/vitnode/src/tanstack/admin/integrations/index.ts index d9187e2b3..c0fc93644 100644 --- a/packages/vitnode/src/tanstack/admin/integrations/index.ts +++ b/packages/vitnode/src/tanstack/admin/integrations/index.ts @@ -1,10 +1,11 @@ -export { integrationsQuery, invalidateIntegrations } from "./query"; +export { invalidateIntegrations } from "./query"; export type { AdminIntegrationsRouteData } from "./route"; export { ADMIN_INTEGRATIONS_NAMESPACES, loadAdminIntegrationsRoute, } from "./route"; export { AdminIntegrationsRouteContent } from "./screen"; +export { integrationsQueryOptions } from "@/views/admin/views/core/system/integrations/integrations-query"; export type { AdminIntegrationModel, diff --git a/packages/vitnode/src/tanstack/admin/integrations/query.ts b/packages/vitnode/src/tanstack/admin/integrations/query.ts index ac536ce6b..5afed8d0a 100644 --- a/packages/vitnode/src/tanstack/admin/integrations/query.ts +++ b/packages/vitnode/src/tanstack/admin/integrations/query.ts @@ -1,12 +1,6 @@ import type { QueryClient } from "@tanstack/react-query"; -import { - integrationsQueryKey, - integrationsQueryOptions, -} from "@/views/admin/views/core/system/integrations/integrations-query"; - -/** The board, as the one query definition the loader and the component share. */ -export const integrationsQuery = () => integrationsQueryOptions(); +import { integrationsQueryKey } from "@/views/admin/views/core/system/integrations/integrations-query"; export const invalidateIntegrations = async ( queryClient: QueryClient, diff --git a/packages/vitnode/src/tanstack/admin/integrations/route.tsx b/packages/vitnode/src/tanstack/admin/integrations/route.tsx index e5ba409f1..f49a82b63 100644 --- a/packages/vitnode/src/tanstack/admin/integrations/route.tsx +++ b/packages/vitnode/src/tanstack/admin/integrations/route.tsx @@ -1,9 +1,10 @@ import type { PluginRouteTranslator } from "@/routing"; +import { integrationsQueryOptions } from "@/views/admin/views/core/system/integrations/integrations-query"; + import type { AdminScreenContext } from "../screen"; import { requireAdminPermission } from "../screen"; -import { integrationsQuery } from "./query"; /** * `/admin/core/system/integrations`, as everything a TanStack Start route needs @@ -39,7 +40,7 @@ export const loadAdminIntegrationsRoute = async ({ requireAdminPermission(adminAccess, SYSTEM_VIEW_PERMISSION); await queryClient.query({ - ...integrationsQuery(), + ...integrationsQueryOptions(), staleTime: "static", }); diff --git a/packages/vitnode/src/tanstack/admin/integrations/screen.tsx b/packages/vitnode/src/tanstack/admin/integrations/screen.tsx index 836ec4aa2..5b0897bd9 100644 --- a/packages/vitnode/src/tanstack/admin/integrations/screen.tsx +++ b/packages/vitnode/src/tanstack/admin/integrations/screen.tsx @@ -3,13 +3,13 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { HeaderContent } from "@/components/ui/header-content"; import { CONFIG_PLUGIN } from "@/config"; import { IntegrationsContent } from "@/views/admin/views/core/system/integrations/integrations-content"; +import { integrationsQueryOptions } from "@/views/admin/views/core/system/integrations/integrations-query"; import { sendTestEmailInBrowser } from "@/views/admin/views/core/system/integrations/send-test-email/send-test-email-mutation"; import type { AdminIntegrationsRouteData } from "./route"; import { RouteMessages } from "../../i18n/route-messages"; import { useAdminPermission } from "../permissions"; -import { integrationsQuery } from "./query"; import { ADMIN_INTEGRATIONS_NAMESPACES } from "./route"; import { SYSTEM_MODULE } from "./route"; @@ -17,7 +17,7 @@ export const AdminIntegrationsRouteContent = ({ description, title, }: AdminIntegrationsRouteData) => { - const { data } = useSuspenseQuery(integrationsQuery()); + const { data } = useSuspenseQuery(integrationsQueryOptions()); const canSendTestEmail = useAdminPermission({ module: SYSTEM_MODULE, permission: "can_send_test_email", diff --git a/packages/vitnode/src/tanstack/admin/queue/route.tsx b/packages/vitnode/src/tanstack/admin/queue/route.tsx index 63494838d..79bc01571 100644 --- a/packages/vitnode/src/tanstack/admin/queue/route.tsx +++ b/packages/vitnode/src/tanstack/admin/queue/route.tsx @@ -1,5 +1,4 @@ import type { PluginRouteTranslator } from "@/routing"; - import type { QueueParams } from "@/views/admin/views/core/advanced/queue/queue-query"; import type { AdminScreenContext } from "../screen"; diff --git a/packages/vitnode/src/tanstack/admin/roles/route.tsx b/packages/vitnode/src/tanstack/admin/roles/route.tsx index dc117aac0..6c0234b9e 100644 --- a/packages/vitnode/src/tanstack/admin/roles/route.tsx +++ b/packages/vitnode/src/tanstack/admin/roles/route.tsx @@ -1,5 +1,4 @@ import type { PluginRouteTranslator } from "@/routing"; - import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; import type { AdminRolesParams } from "@/views/admin/views/core/users/roles/roles-query"; diff --git a/packages/vitnode/src/tanstack/admin/staff/create-route.tsx b/packages/vitnode/src/tanstack/admin/staff/create-route.tsx index fd3c7f405..0cca4d53b 100644 --- a/packages/vitnode/src/tanstack/admin/staff/create-route.tsx +++ b/packages/vitnode/src/tanstack/admin/staff/create-route.tsx @@ -1,6 +1,5 @@ -import type { PluginRouteTranslator } from "@/routing"; - import type { PermissionStaffType } from "@/api/lib/permission-staff"; +import type { PluginRouteTranslator } from "@/routing"; import { adminStaffPermissions } from "@/views/admin/views/core/shared/admin-permissions"; import { diff --git a/packages/vitnode/src/tanstack/admin/staff/edit-route.tsx b/packages/vitnode/src/tanstack/admin/staff/edit-route.tsx index 95dc04b16..f3f66975e 100644 --- a/packages/vitnode/src/tanstack/admin/staff/edit-route.tsx +++ b/packages/vitnode/src/tanstack/admin/staff/edit-route.tsx @@ -1,10 +1,9 @@ -import type { PluginRouteTranslator } from "@/routing"; - import type { QueryClient } from "@tanstack/react-query"; import { notFound } from "@tanstack/react-router"; import type { PermissionStaffType } from "@/api/lib/permission-staff"; +import type { PluginRouteTranslator } from "@/routing"; import type { StaffPluginGroup } from "@/views/admin/views/core/staff/staff-model"; import type { AdminStaffRole, diff --git a/packages/vitnode/src/tanstack/admin/staff/route.tsx b/packages/vitnode/src/tanstack/admin/staff/route.tsx index d9318c1e1..086e7c3db 100644 --- a/packages/vitnode/src/tanstack/admin/staff/route.tsx +++ b/packages/vitnode/src/tanstack/admin/staff/route.tsx @@ -1,6 +1,5 @@ -import type { PluginRouteTranslator } from "@/routing"; - import type { PermissionStaffType } from "@/api/lib/permission-staff"; +import type { PluginRouteTranslator } from "@/routing"; import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; import type { AdminStaffParams } from "@/views/admin/views/core/staff/staff-query"; diff --git a/packages/vitnode/src/tanstack/admin/users/detail-route.tsx b/packages/vitnode/src/tanstack/admin/users/detail-route.tsx index 1f9e72ed4..9706cc55e 100644 --- a/packages/vitnode/src/tanstack/admin/users/detail-route.tsx +++ b/packages/vitnode/src/tanstack/admin/users/detail-route.tsx @@ -1,7 +1,6 @@ import { notFound } from "@tanstack/react-router"; import type { PluginRouteTranslator } from "@/routing"; - import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; import { ADMIN_USER_PERMISSIONS } from "@/views/admin/views/core/shared/admin-permissions"; @@ -34,9 +33,9 @@ export const loadAdminUserRoute = async ({ queryClient, t, }: AdminScreenContext & { - t: PluginRouteTranslator; /** The `$id` segment, exactly as it was typed. Nothing has checked it yet. */ id: string; + t: PluginRouteTranslator; }): Promise<AdminUserRouteData> => { requireAdminPermission(adminAccess, ADMIN_USER_PERMISSIONS.view); diff --git a/packages/vitnode/src/tanstack/admin/users/route.tsx b/packages/vitnode/src/tanstack/admin/users/route.tsx index 44aeb3527..aafe7a7ed 100644 --- a/packages/vitnode/src/tanstack/admin/users/route.tsx +++ b/packages/vitnode/src/tanstack/admin/users/route.tsx @@ -1,5 +1,4 @@ import type { PluginRouteTranslator } from "@/routing"; - import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope"; import type { AdminUsersParams } from "@/views/admin/views/core/users/list/users-query"; diff --git a/packages/vitnode/src/tanstack/devices/devices.test.ts b/packages/vitnode/src/tanstack/devices/devices.test.ts deleted file mode 100644 index 626ab8c70..000000000 --- a/packages/vitnode/src/tanstack/devices/devices.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { hashKey, QueryClient } from "@tanstack/react-query"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import type * as DevicesRevokeModule from "@/views/auth/settings/devices/devices-revoke"; -import type { RevokeDeviceResult } from "@/views/auth/settings/devices/devices-revoke"; - -import { devicesQueryKey } from "@/views/auth/settings/devices/devices-query"; - -/** What the stubbed browser revoke answers with on the next call. */ -let nextRevokeResult: RevokeDeviceResult = { data: true }; - -vi.mock( - "@/views/auth/settings/devices/devices-revoke", - async importOriginal => ({ - // Everything real except the one function that would open a socket - so - // `shouldRefreshAfterRevoke`, the rule actually being exercised, is the - // shared one and not a second copy of it written for this test. - ...(await importOriginal<typeof DevicesRevokeModule>()), - revokeDeviceInBrowser: async () => Promise.resolve(nextRevokeResult), - }), -); - -const { devicesQuery, invalidateDevices, revokeDevice } = - await import("./index"); - -/** The visitor these tests are signed in as. */ -const USER = 10; - -/** Another visitor, whose partition must survive this one's revoke untouched. */ -const OTHER_USER = 20; - -/** The two entries a devices invalidation must tell apart. */ -const SESSION_KEY = ["vitnode", "session"] as const; -const MESSAGES_KEY = ["intl", "en", "core.global"] as const; - -const seed = () => { - const queryClient = new QueryClient(); - - queryClient.setQueryData(devicesQuery(USER).queryKey, { devices: [] }); - // A partition left behind by a visitor who signed out on this browser. - queryClient.setQueryData(devicesQuery(OTHER_USER).queryKey, { devices: [] }); - queryClient.setQueryData(SESSION_KEY, { user: { id: USER } }); - queryClient.setQueryData(MESSAGES_KEY, { messages: {} }); - - return queryClient; -}; - -const isStale = (queryClient: QueryClient, queryKey: readonly unknown[]) => - queryClient.getQueryState(queryKey)?.isInvalidated === true; - -beforeEach(() => { - nextRevokeResult = { data: true }; -}); - -describe("this namespace asks for the shared devices list, not its own", () => { - it("lands in the canonical entry", () => { - // The loader and the component both call `devicesQuery()`, and it has to be - // the entry the invalidation names or a revoke would refresh nothing. - expect(hashKey(devicesQuery(USER).queryKey)).toBe( - hashKey(devicesQueryKey(USER)), - ); - }); - - it("carries no locale, because the data is the same in every language", () => { - // An OS name, a browser, an IP address and two timestamps do not change with - // the language. A locale in the key would refetch on every language switch. - expect(devicesQuery(USER).queryKey).toEqual(["devices", "user", USER]); - }); - - it("asks once, so a 429 is not answered by two more requests", () => { - expect(devicesQuery(USER).retry).toBe(false); - }); - - it("gives two visitors two entries, so one cannot read the other's", () => { - expect(hashKey(devicesQuery(USER).queryKey)).not.toBe( - hashKey(devicesQuery(OTHER_USER).queryKey), - ); - }); -}); - -describe("a revoke makes the devices list stale, and only that", () => { - it("marks the list stale when a device actually went", async () => { - const queryClient = seed(); - - await invalidateDevices(queryClient, USER); - - expect(isStale(queryClient, devicesQueryKey(USER))).toBe(true); - }); - - it("leaves everything else in the cache alone", async () => { - // Emphatically not `invalidateQueries()` with no key, and not - // `router.invalidate()`: the session and the messages have not changed - // because a phone was signed out. Refetching them would be the blunt version - // of the `revalidatePath` this replaces. - const queryClient = seed(); - - await invalidateDevices(queryClient, USER); - - expect(isStale(queryClient, SESSION_KEY)).toBe(false); - expect(isStale(queryClient, MESSAGES_KEY)).toBe(false); - }); - - it("keeps the rows on screen while the fresh ones are fetched", async () => { - // Invalidating rather than removing, so the list is not blanked under a - // dialog that is still closing. - const queryClient = seed(); - - await invalidateDevices(queryClient, USER); - - expect(queryClient.getQueryData(devicesQueryKey(USER))).toBeDefined(); - }); - - it("leaves a previous visitor's partition untouched", async () => { - // Prefix matching is the whole of it: one visitor's revoke names their own - // entry and cannot refetch a list on behalf of somebody who signed out. - const queryClient = seed(); - - await revokeDevice(queryClient, USER, { publicId: "a1b2c3" }); - - expect(isStale(queryClient, devicesQueryKey(OTHER_USER))).toBe(false); - }); - - it("does not invalidate the session, because the current device cannot be revoked", async () => { - // The API answers 400 for the device the request itself comes from, so no - // revoke reachable from this page can end the session performing it. There - // is no state in which a successful revoke leaves the cached session falsely - // authenticated - which is why this invalidation is one key rather than two. - const queryClient = seed(); - - await revokeDevice(queryClient, USER, { publicId: "a1b2c3" }); - - expect(isStale(queryClient, SESSION_KEY)).toBe(false); - }); -}); - -describe("the revoke refreshes on exactly the statuses that changed something", () => { - it("refreshes after a success", async () => { - const queryClient = seed(); - nextRevokeResult = { data: true }; - - await revokeDevice(queryClient, USER, { publicId: "a1b2c3" }); - - expect(isStale(queryClient, devicesQueryKey(USER))).toBe(true); - }); - - it.each([404, 400])( - "refreshes after a %i, because the row on screen was already wrong", - async status => { - const queryClient = seed(); - nextRevokeResult = { error: { status } }; - - await revokeDevice(queryClient, USER, { publicId: "a1b2c3" }); - - expect(isStale(queryClient, devicesQueryKey(USER))).toBe(true); - }, - ); - - it.each([401, 403, 429, 500])( - "leaves the list alone after a %i, which deleted nothing", - async status => { - // The refetch would be a second request into whatever refused the first: a - // rate limiter answered by immediately asking again, or an ended session - // answered by a 401 that blanks the list being read. - const queryClient = seed(); - nextRevokeResult = { error: { status } }; - - await revokeDevice(queryClient, USER, { publicId: "a1b2c3" }); - - expect(isStale(queryClient, devicesQueryKey(USER))).toBe(false); - }, - ); - - it("returns the finite result to the caller either way", async () => { - const queryClient = seed(); - nextRevokeResult = { error: { status: 429 } }; - - expect( - await revokeDevice(queryClient, USER, { publicId: "a1b2c3" }), - ).toEqual({ - error: { status: 429 }, - }); - }); -}); diff --git a/packages/vitnode/src/tanstack/devices/index.ts b/packages/vitnode/src/tanstack/devices/index.ts index b9c52138d..f512f14db 100644 --- a/packages/vitnode/src/tanstack/devices/index.ts +++ b/packages/vitnode/src/tanstack/devices/index.ts @@ -4,6 +4,7 @@ export * from "./query"; export { devicesQueryKey, + devicesQueryOptions, DevicesRequestError, isDevicesRequestError, } from "@/views/auth/settings/devices/devices-query"; diff --git a/packages/vitnode/src/tanstack/devices/panel.tsx b/packages/vitnode/src/tanstack/devices/panel.tsx index eaa3ff5d9..1ed9e4d47 100644 --- a/packages/vitnode/src/tanstack/devices/panel.tsx +++ b/packages/vitnode/src/tanstack/devices/panel.tsx @@ -4,8 +4,9 @@ import { useTranslations } from "use-intl"; import { HeaderContent } from "@/components/ui/header-content"; import { DevicesContent } from "@/views/auth/settings/devices/devices-content"; import { DevicesListSkeleton } from "@/views/auth/settings/devices/devices-list-skeleton"; +import { devicesQueryOptions } from "@/views/auth/settings/devices/devices-query"; -import { devicesQuery, useRevokeDeviceCallback } from "./query"; +import { useRevokeDeviceCallback } from "./query"; const DevicesHeading = () => { const t = useTranslations("core.auth.settings.devices"); @@ -21,23 +22,12 @@ export const DevicesPanelPending = () => ( ); export const DevicesPanelContent = ({ userId }: { userId: number }) => { - const { data } = useSuspenseQuery(devicesQuery(userId)); + const { data } = useSuspenseQuery(devicesQueryOptions({ userId })); const onRevoke = useRevokeDeviceCallback(userId); return ( <> <DevicesHeading /> - - {/* - The shared list, handed the two things it cannot resolve for itself: - the devices, and the revoke. - - The revoke goes straight from the browser to Hono - no server function in - between, because it needs no server-only secret and sets no cookie - and - ends in an invalidation of the one `devices/me` entry, but only when the - list is actually wrong. A `429` or a `401` left it exactly as it was, and - refetching would send the same read back into whatever refused the first. - */} <DevicesContent devices={data.devices} onRevoke={onRevoke} /> </> ); diff --git a/packages/vitnode/src/tanstack/devices/query.ts b/packages/vitnode/src/tanstack/devices/query.ts index 7a8d68d84..328043b69 100644 --- a/packages/vitnode/src/tanstack/devices/query.ts +++ b/packages/vitnode/src/tanstack/devices/query.ts @@ -9,17 +9,12 @@ import type { RevokeDeviceResult, } from "@/views/auth/settings/devices/devices-revoke"; -import { - devicesQueryKey, - devicesQueryOptions, -} from "@/views/auth/settings/devices/devices-query"; +import { devicesQueryKey } from "@/views/auth/settings/devices/devices-query"; import { revokeDeviceInBrowser, shouldRefreshAfterRevoke, } from "@/views/auth/settings/devices/devices-revoke"; -export const devicesQuery = (userId: number) => devicesQueryOptions({ userId }); - export const invalidateDevices = async ( queryClient: QueryClient, userId: number, diff --git a/packages/vitnode/src/tanstack/files/route.tsx b/packages/vitnode/src/tanstack/files/route.tsx index 701776ee7..590f786a2 100644 --- a/packages/vitnode/src/tanstack/files/route.tsx +++ b/packages/vitnode/src/tanstack/files/route.tsx @@ -1,7 +1,6 @@ -import type { PluginRouteTranslator } from "@/routing"; - import type { QueryClient } from "@tanstack/react-query"; +import type { PluginRouteTranslator } from "@/routing"; import type { MyFilesParams } from "@/views/files/my-files-query"; import type { MyFilesRouteSearch } from "./route-search"; diff --git a/packages/vitnode/src/tanstack/plugin-routes/mount.tsx b/packages/vitnode/src/tanstack/plugin-routes/mount.tsx index 6d052505f..15121f71d 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/mount.tsx +++ b/packages/vitnode/src/tanstack/plugin-routes/mount.tsx @@ -35,8 +35,8 @@ import { import { PLUGIN_ROUTES_ROUTE_ID } from "./container"; import { pluginRouteGuard } from "./guard"; import { normalizePluginRouteHead } from "./head"; -import { pluginRouteTranslator } from "./translator"; import { pluginRouteSearchDeps } from "./specs"; +import { pluginRouteTranslator } from "./translator"; export interface PluginRouteRuntimeContext { /** Present in the `admin` area, contributed by the AdminCP shell. */ diff --git a/packages/vitnode/src/tanstack/profile/route.ts b/packages/vitnode/src/tanstack/profile/route.ts index 664505688..4814c35a4 100644 --- a/packages/vitnode/src/tanstack/profile/route.ts +++ b/packages/vitnode/src/tanstack/profile/route.ts @@ -1,9 +1,8 @@ -import type { PluginRouteTranslator } from "@/routing"; - import type { QueryClient } from "@tanstack/react-query"; import { notFound } from "@tanstack/react-router"; +import type { PluginRouteTranslator } from "@/routing"; import type { UserProfile } from "@/views/profile/profile-query"; import { diff --git a/packages/vitnode/src/tanstack/search/discover-route.tsx b/packages/vitnode/src/tanstack/search/discover-route.tsx index 1d1067c29..acdfa87b5 100644 --- a/packages/vitnode/src/tanstack/search/discover-route.tsx +++ b/packages/vitnode/src/tanstack/search/discover-route.tsx @@ -1,7 +1,7 @@ -import type { PluginRouteTranslator } from "@/routing"; - import type { QueryClient } from "@tanstack/react-query"; +import type { PluginRouteTranslator } from "@/routing"; + import { discoverFeedQueryOptions } from "./discover"; export { DISCOVER_NAMESPACES } from "./namespaces"; diff --git a/packages/vitnode/src/tanstack/search/search-route.tsx b/packages/vitnode/src/tanstack/search/search-route.tsx index 3aa19e287..6504dad1e 100644 --- a/packages/vitnode/src/tanstack/search/search-route.tsx +++ b/packages/vitnode/src/tanstack/search/search-route.tsx @@ -1,7 +1,6 @@ -import type { PluginRouteTranslator } from "@/routing"; - import type { QueryClient } from "@tanstack/react-query"; +import type { PluginRouteTranslator } from "@/routing"; import type { SearchFeedParams } from "@/views/search/search-feed-query"; import { feedQueryOptions } from "./feed"; diff --git a/packages/vitnode/src/views/auth/settings/devices/devices-boundaries.test.ts b/packages/vitnode/src/views/auth/settings/devices/devices-boundaries.test.ts deleted file mode 100644 index 1edcca00d..000000000 --- a/packages/vitnode/src/views/auth/settings/devices/devices-boundaries.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -// @vitest-environment node -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -import { externalGraph } from "@/tests/import-graph"; - -const here = dirname(fileURLToPath(import.meta.url)); - -const SHARED = { - list: join(here, "devices-content.tsx"), - query: join(here, "devices-query.ts"), - revokeButton: join(here, "revoke-device-button.tsx"), -}; - -describe("the shared devices modules are framework-neutral", () => { - it("never imports the API's own module for one plugin id", () => { - // The fetchers need the users module's *type* to keep route literals - // inferring; a value import would drag Hono, Drizzle and `@/database` into - // the browser bundle of every page that lists a device. - const reached = [...externalGraph(SHARED.query).keys()]; - - expect(reached).not.toContain("drizzle-orm"); - expect(reached.some(one => one.startsWith("hono"))).toBe(false); - }); -}); - -describe("the shared list takes its framework parts as props", () => { - const withoutComments = (path: string): string => - readFileSync(path, "utf8") - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/\/\/.*$/gm, ""); - - it("is handed the devices rather than fetching them", () => { - const code = withoutComments(SHARED.list); - - expect(code).toContain("devices: Device[];"); - expect(code).not.toContain("useQuery"); - expect(code).not.toContain("fetcher"); - }); - - it("is handed the revoke rather than importing one", () => { - const code = withoutComments(SHARED.list); - - expect(code).toContain("onRevoke: RevokeDevice;"); - }); - - it("passes the revoke down to the button rather than the button finding it", () => { - expect(withoutComments(SHARED.revokeButton)).toContain( - "onRevoke: RevokeDevice;", - ); - }); -}); diff --git a/packages/vitnode/src/views/auth/settings/devices/devices-query.test.ts b/packages/vitnode/src/views/auth/settings/devices/devices-query.test.ts deleted file mode 100644 index a52315d77..000000000 --- a/packages/vitnode/src/views/auth/settings/devices/devices-query.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { hashKey } from "@tanstack/react-query"; -import { describe, expect, it } from "vitest"; - -import { - DEVICE_TYPES, - devicesQueryKey, - devicesQueryOptions, - DevicesRequestError, - isDevicesRequestError, -} from "./devices-query"; -import { - isDevicePublicId, - isRevokableDevice, - REVOKE_CURRENT_DEVICE_STATUS, - revokeResultFromStatus, - shouldRefreshAfterRevoke, -} from "./devices-revoke"; - -describe("one list per visitor, one cache entry each", () => { - it("is keyed by the owner, under the devices domain", () => { - expect(devicesQueryKey(10)).toEqual(["devices", "user", 10]); - }); - - it("is the same entry however many times it is asked for", () => { - // The loader and the component both call the factory, and they have to land - // in the same entry or the loader fills one while the component reads the - // other. - expect(hashKey(devicesQueryOptions({ userId: 10 }).queryKey)).toBe( - hashKey(devicesQueryOptions({ userId: 10 }).queryKey), - ); - }); - - it("gives two visitors two entries, so one can never read the other's", () => { - expect(devicesQueryKey(10)).not.toEqual(devicesQueryKey(20)); - expect(hashKey(devicesQueryKey(10))).not.toBe(hashKey(devicesQueryKey(20))); - }); - - it("is what a revoke invalidates, so one visitor's refresh is their own", () => { - // Query matches by prefix, and this key has no sub-keys - so it is both the - // entry and the family, and invalidating it cannot reach visitor 20. - expect(devicesQueryOptions({ userId: 10 }).queryKey).toEqual( - devicesQueryKey(10), - ); - }); - - it("does not share a prefix with the session entry", () => { - // Query matches keys by prefix, so a revoke invalidating this key must not - // reach `['vitnode', 'session']` - the one entry a route guard reads. - expect(devicesQueryKey(10)[0]).not.toBe("vitnode"); - }); - - it("asks once, because every failure it can have is worse when repeated", () => { - expect(devicesQueryOptions({ userId: 10 }).retry).toBe(false); - }); -}); - -describe("a refused read is not an empty list", () => { - it.each([401, 403, 429, 500])( - "turns %i into an error rather than a list nobody is signed in on", - status => { - const error = new DevicesRequestError(status); - - expect(error.status).toBe(status); - expect(isDevicesRequestError(error)).toBe(true); - // The bug this replaces: `getDevicesApi()` parsed the refusal body, which - // has no `devices` in it, and the page said "No active devices." - expect(error).not.toHaveProperty("devices"); - }, - ); - - it("says which status refused, in the message", () => { - expect(new DevicesRequestError(429).message).toContain("429"); - }); - - it("is recognised across two copies of the class", () => { - // `@vitnode/core` is imported from `dist` by the apps and from `src` by these - // tests, so `instanceof` can answer `false` for a genuine one. The guard is - // `name`-based, and this is the shape that proves it. - const fromAnotherCopy = new Error("The devices API answered 401 ..."); - fromAnotherCopy.name = "DevicesRequestError"; - - expect(isDevicesRequestError(fromAnotherCopy)).toBe(true); - }); - - it("is not fooled by an ordinary error", () => { - expect(isDevicesRequestError(new Error("nope"))).toBe(false); - expect(isDevicesRequestError({ status: 401 })).toBe(false); - expect(isDevicesRequestError(undefined)).toBe(false); - }); -}); - -describe("the row shape the API promises", () => { - it("has exactly the three device types the icons cover", () => { - expect([...DEVICE_TYPES]).toEqual(["desktop", "tablet", "mobile"]); - }); -}); - -describe("the current device is the one that cannot be signed out", () => { - it("offers no revoke for the session doing the asking", () => { - // The API answers 400 for it, so a button here would only ever produce an - // error toast. - expect(isRevokableDevice({ isCurrent: true })).toBe(false); - }); - - it("offers a revoke for every other device", () => { - expect(isRevokableDevice({ isCurrent: false })).toBe(true); - }); - - it("names the status the API refuses with", () => { - expect(REVOKE_CURRENT_DEVICE_STATUS).toBe(400); - }); -}); - -describe("the public ids a revoke will send", () => { - it("accepts the 32 hex characters `DeviceModel` mints", () => { - expect(isDevicePublicId("0123456789abcdef0123456789abcdef")).toBe(true); - }); - - it("accepts a shorter url-safe token, for ids minted by an older scheme", () => { - expect(isDevicePublicId("a1b2c3")).toBe(true); - expect(isDevicePublicId("a_b-c")).toBe(true); - }); - - it("refuses an empty id, which would address the list route", () => { - // `/devices/` + `""` is `DELETE /devices`, which is a different route. - expect(isDevicePublicId("")).toBe(false); - }); - - it("refuses anything that would leave the path segment", () => { - expect(isDevicePublicId("../session")).toBe(false); - expect(isDevicePublicId("a/b")).toBe(false); - expect(isDevicePublicId("a.b")).toBe(false); - expect(isDevicePublicId("%2e%2e")).toBe(false); - expect(isDevicePublicId("a b")).toBe(false); - }); - - it("refuses an id longer than any real one", () => { - expect(isDevicePublicId("a".repeat(128))).toBe(true); - expect(isDevicePublicId("a".repeat(129))).toBe(false); - }); -}); - -describe("what a revoke's status becomes", () => { - it("is done only for the 200 the route declares", () => { - expect(revokeResultFromStatus(200)).toEqual({ data: true }); - }); - - it.each([400, 401, 403, 404, 429, 500])( - "carries %i back for the dialog to phrase", - status => { - expect(revokeResultFromStatus(status)).toEqual({ error: { status } }); - }, - ); - - it("never reports both an outcome and a refusal", () => { - expect(revokeResultFromStatus(200).error).toBeUndefined(); - expect(revokeResultFromStatus(404).data).toBeUndefined(); - }); -}); - -describe("whether a finished revoke makes the list stale", () => { - it("refreshes when the device actually went", () => { - expect(shouldRefreshAfterRevoke({ data: true })).toBe(true); - }); - - it("refreshes when the row was already wrong", () => { - // 404: somebody revoked it first. 400: the list believed it was revokable and - // the API considers it current. Either way the screen disagrees with the - // server, and refetching is the repair. - expect(shouldRefreshAfterRevoke({ error: { status: 404 } })).toBe(true); - expect( - shouldRefreshAfterRevoke({ - error: { status: REVOKE_CURRENT_DEVICE_STATUS }, - }), - ).toBe(true); - }); - - it.each([401, 403, 429, 500, 503])( - "leaves the list alone after a %i, which deleted nothing", - status => { - // A 429 answered by immediately re-reading is the thing the limiter is - // asking the app to stop doing; a 401 answered by re-reading blanks the - // list the person is looking at. - expect(shouldRefreshAfterRevoke({ error: { status } })).toBe(false); - }, - ); - - it("does not refresh on a result that says nothing", () => { - expect(shouldRefreshAfterRevoke({})).toBe(false); - }); -}); From 6ba5e9d5bc0fa0966b230516a5e3dda08e17eb01 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Tue, 15 Sep 2026 00:43:36 +0200 Subject: [PATCH 3/4] =?UTF-8?q?refactor(routes):=20=E2=9C=A8=20enhance=20r?= =?UTF-8?q?oute=20handling=20and=20improve=20search=20query=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../copy-of-vitnode-app/root/src/router.tsx | 22 +-- .../src/create/build-plugins-task.test.ts | 84 ---------- .../src/create/scaffold-bootstrap.test.ts | 151 ------------------ packages/vitnode/src/routes.test.ts | 35 +++- packages/vitnode/src/routes.tsx | 2 + .../tanstack/admin/content/route-search.ts | 19 +++ 6 files changed, 67 insertions(+), 246 deletions(-) delete mode 100644 packages/create-vitnode-app/src/create/build-plugins-task.test.ts delete mode 100644 packages/create-vitnode-app/src/create/scaffold-bootstrap.test.ts diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/router.tsx b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/router.tsx index 710365f89..989c6d44f 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/router.tsx +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/router.tsx @@ -11,8 +11,9 @@ import { } from "@vitnode/core/tanstack/layout"; import { RoutePendingSpinner } from "@vitnode/core/tanstack/pending"; import { + configureContentRegistry, pluginRouteSpecs, - withPluginRoutes, + withVitNodeRoutes, } from "@vitnode/core/tanstack/plugin-routes"; // Imported for its side effect: this module calls `configureIntl`, which is @@ -27,14 +28,17 @@ configureContentRegistry( async () => (await import("./content-registry.gen")).contentRegistry, ); -const routeTree = withVitNodeRoutes(fileRouteTree, pluginRouteSpecs(pluginRouteSources), { - mountUnder: { - admin: adminShellRoute, - blank: fileRouteTree, - main: mainShellRoute, - }, - pageHead, -}); +const routeTree = withVitNodeRoutes( + fileRouteTree, + pluginRouteSpecs(pluginRouteSources), + { + mountUnder: { + admin: adminShellRoute, + blank: fileRouteTree, + main: mainShellRoute, + }, + }, +); export function getRouter() { const queryClient = createVitNodeQueryClient(); diff --git a/packages/create-vitnode-app/src/create/build-plugins-task.test.ts b/packages/create-vitnode-app/src/create/build-plugins-task.test.ts deleted file mode 100644 index 7fec396b4..000000000 --- a/packages/create-vitnode-app/src/create/build-plugins-task.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { readFileSync } from "node:fs"; -import { join, resolve } from "node:path"; -import { describe, expect, it } from "vitest"; - -import { pluginScripts } from "../plugin/create/create-package-json.js"; -import { rootScripts } from "./create-package-json.js"; - -interface TurboTask { - cache?: boolean; - dependsOn?: string[]; - outputs?: string[]; -} - -const turbo: { tasks: Record<string, TurboTask> } = JSON.parse( - readFileSync( - resolve( - import.meta.dirname, - "../../copy-of-vitnode-app/monorepo/turbo.json", - ), - "utf8", - ), -) as { tasks: Record<string, TurboTask> }; - -const scripts = rootScripts(true, false, "my-app"); - -describe("the generated monorepo's plugin build", () => { - it("declares the task a plugin's own package.json implements", () => { - expect(Object.keys(pluginScripts(false))).toContain("build:plugins"); - expect(turbo.tasks["build:plugins"]).toBeDefined(); - }); - - it("compiles a plugin's dependencies before the plugin itself", () => { - expect(turbo.tasks["build:plugins"].dependsOn).toEqual(["^build:plugins"]); - }); - - it("caches the dist a plugin's package exports point at", () => { - expect(turbo.tasks["build:plugins"].outputs).toEqual(["dist/**"]); - }); - - it("gives the root a script to run it on its own", () => { - expect(scripts["build:plugins"]).toBe("turbo build:plugins"); - }); -}); - -describe("what waits for the plugin build", () => { - it("builds plugins before an app build, which imports their dist", () => { - expect(turbo.tasks.build.dependsOn).toContain("^build:plugins"); - }); - - it("builds plugins before the database bootstrap reads their config", () => { - expect(turbo.tasks["db:prepare"].dependsOn).toContain("^build:plugins"); - }); - - it("builds plugins before dev starts, which turbo cannot express", () => { - expect(scripts.dev).toBe( - "turbo build:plugins && turbo db:prepare && turbo dev", - ); - }); - - it("keeps the bootstrap gated behind the plugin build in that order", () => { - const order = ["build:plugins", "db:prepare", "dev"].map(task => - scripts.dev.indexOf(`turbo ${task}`), - ); - - expect(order).toEqual([...order].sort((a, b) => a - b)); - expect(order.every(index => index >= 0)).toBe(true); - }); -}); - -describe("the scripts a generated root still exposes", () => { - it("leaves build to turbo, which resolves the dependency itself", () => { - expect(scripts.build).toBe("turbo build"); - }); - - it("adds nothing to a shape that has no root package.json", () => { - const app = readFileSync( - join(import.meta.dirname, "create-package-json.ts"), - "utf8", - ); - - expect(app).toContain("singleAppScripts"); - expect(app.split('"build:plugins": "turbo build:plugins"')).toHaveLength(2); - }); -}); diff --git a/packages/create-vitnode-app/src/create/scaffold-bootstrap.test.ts b/packages/create-vitnode-app/src/create/scaffold-bootstrap.test.ts deleted file mode 100644 index e734e9595..000000000 --- a/packages/create-vitnode-app/src/create/scaffold-bootstrap.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { existsSync, readdirSync, readFileSync } from "node:fs"; -import { join, resolve } from "node:path"; -import { describe, expect, it } from "vitest"; - -const appRoot = resolve( - import.meta.dirname, - "../../copy-of-vitnode-app/root/src", -); - -const read = (file: string): string => - readFileSync(join(appRoot, file), "utf8"); - -const withoutComments = (source: string): string => - source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, ""); - -/** Every `.ts`/`.tsx` file in the template, relative to its `src/`. */ -const sourceFiles = (directory: string, prefix = ""): string[] => - readdirSync(directory, { withFileTypes: true }).flatMap(entry => { - const path = prefix ? `${prefix}/${entry.name}` : entry.name; - - if (entry.isDirectory()) - return sourceFiles(join(directory, entry.name), path); - - return /\.tsx?$/.test(entry.name) ? [path] : []; - }); - -const REMOVED = [ - ["auth", "lib/auth.ts"], - ["the AdminCP session", "lib/admin-auth.ts"], - ["the AdminCP user search", "lib/admin-search.ts"], - ["the AdminCP navigation", "lib/admin-nav.ts"], - ["the Content Engine registry", "lib/content-registry.ts"], - ["the installed packages' translations", "locales/packages.ts"], -] as const; - -describe("bootstrap a generated application no longer owns", () => { - it.each(REMOVED)("scaffolds no module for %s", (_what, file) => { - expect(existsSync(join(appRoot, file))).toBe(false); - }); - - it("leaves the router with no transport side-effect imports", () => { - const router = withoutComments(read("router.tsx")); - - expect(router).not.toContain("./lib/auth"); - expect(router).not.toContain("./lib/admin-auth"); - expect(router).not.toMatch(/^import\s+["'][^"']+["'];?$/m); - }); - - it("renders the AdminCP shell without a user-search prop", () => { - const shell = withoutComments(read("components/admin-shell.tsx")); - - expect(shell).not.toContain("adminUserSearchFn"); - expect(shell).not.toContain("searchUsers"); - }); -}); - -describe("what reads the generated projections", () => { - it("takes the AdminCP navigation straight from the generated file", () => { - const shell = withoutComments(read("components/admin-shell.tsx")); - - expect(shell).toContain('from "@/admin-nav.gen"'); - expect(shell).not.toContain("adminNavBundle"); - }); - - it("loads the same generated file in the admin route", () => { - const route = withoutComments(read("routes/_admin.tsx")); - - expect(route).toContain('await import("@/admin-nav.gen")'); - expect(route).not.toContain("@/lib/admin-nav"); - }); - - /** - * The registry's imports pull every plugin's AdminCP content and editor code, - * so a static import here would put all of it in the entry chunk. The dynamic - * `import()` is what keeps it out, and is the reason this is asserted rather - * than left to review. - */ - it("keeps the content registry behind a dynamic import in the router", () => { - const router = withoutComments(read("router.tsx")); - - expect(router).toContain('await import("./content-registry.gen")'); - expect(router).not.toMatch(/^import .*content-registry\.gen/m); - expect(router).not.toContain("./lib/content-registry"); - }); - - /** - * The file `locales/packages.ts` used to be, and the reason it is gone: the - * plugin list it repeated is the one in `vitnode.config.ts`, and a second - * copy of it could only ever drift. - */ - it("reads the package translations from the generated file", () => { - const config = withoutComments(read("vitnode.server.config.ts")); - - expect(config).toContain('from "@/package-messages.gen"'); - expect(config).toContain("packageMessages,"); - expect(config).not.toContain("@/locales/packages"); - }); - - it("leaves the app's own overrides where an author edits them", () => { - expect(readdirSync(join(appRoot, "locales")).sort()).toEqual(["app.ts"]); - expect(withoutComments(read("vitnode.server.config.ts"))).toContain( - 'from "@/locales/app"', - ); - }); - - it("never re-derives what the generated files already export", () => { - const sources = ["router.tsx", "components/admin-shell.tsx"].map(file => - withoutComments(read(file)), - ); - - for (const source of sources) { - expect(source).not.toContain("adminNavBundle"); - expect(source).not.toContain("buildContentFrontendRegistry"); - expect(source).not.toContain("setContentFrontendRegistry"); - } - }); -}); - -describe("the one adapter a generated application keeps", () => { - it("still ships lib/i18n.ts", () => { - expect(existsSync(join(appRoot, "lib/i18n.ts"))).toBe(true); - }); - - it("is what the router reads its locale routing from", () => { - expect(withoutComments(read("router.tsx"))).toContain('from "./lib/i18n"'); - }); - - it("declares the server function, because only app source is compiled", () => { - const i18n = read("lib/i18n.ts"); - - expect(withoutComments(i18n)).toContain("createServerFn"); - expect(i18n).toContain("@vitnode/core"); - }); - - it("says in the file itself why it cannot move into the package", () => { - expect(read("lib/i18n.ts")).toContain("createServerFn"); - expect(read("lib/i18n.ts").slice(0, 1200)).toMatch(/precompiled|compiler/); - }); - - it("is the only thing left under lib/ at all", () => { - expect(readdirSync(join(appRoot, "lib")).sort()).toEqual(["i18n.ts"]); - }); - - it("is the only module in the app that declares a server function", () => { - const declaring = sourceFiles(appRoot).filter(file => - withoutComments(read(file)).includes("createServerFn"), - ); - - expect(declaring).toEqual(["lib/i18n.ts"]); - }); -}); diff --git a/packages/vitnode/src/routes.test.ts b/packages/vitnode/src/routes.test.ts index ab61f1627..16e7b14f4 100644 --- a/packages/vitnode/src/routes.test.ts +++ b/packages/vitnode/src/routes.test.ts @@ -8,9 +8,10 @@ import type { PluginRoute } from "./routing"; import { routes } from "./routes"; import { compilePluginRouteTrees, routeMatchKey } from "./routing"; -const manifest = compilePluginRouteTrees([ +const compiled = compilePluginRouteTrees([ { pluginId: "@vitnode/core", routes }, -]).manifest; +]); +const manifest = compiled.manifest; const pathsIn = (area: PluginRoute["area"]) => manifest.filter(route => route.area === area).map(route => route.path); @@ -111,6 +112,36 @@ describe("core's route tree", () => { } }); + /** + * A route whose page reads `search` has to declare one: with neither a + * declared `search` nor a `parseSearch` on the module, the runtime hands the + * loader `{}` and every paginated, sorted or filtered URL quietly loads the + * default view. + * + * The Content Engine's splat is the one that cannot normalise in `search` - + * the query string arrives without the path params, so nothing there knows + * which content type the URL is for - and it is exactly the route where + * forgetting to carry it through costs the most. + */ + it("carries the query string through on every route that reads it", () => { + const declared = new Set(compiled.searchValidators.keys()); + + expect(declared).toContain("@vitnode/core:page#/admin/content/*"); + + for (const path of [ + "/admin/core/advanced/cron", + "/admin/core/advanced/queue", + "/admin/core/system/files", + "/admin/core/users", + "/admin/core/users/roles", + "/files", + "/login", + "/search", + ]) { + expect(declared).toContain(`@vitnode/core:page#${path}`); + } + }); + /** * The tree is data a build tool reads in Node before any bundler runs. A page * imported here rather than named through `lazy()` would be in the initial diff --git a/packages/vitnode/src/routes.tsx b/packages/vitnode/src/routes.tsx index 84c1ccb1c..6102f2e73 100644 --- a/packages/vitnode/src/routes.tsx +++ b/packages/vitnode/src/routes.tsx @@ -1,4 +1,5 @@ import { defineRoutes, index, layout, lazy, page } from "./routing"; +import { contentListRouteSearch } from "./tanstack/admin/content/route-search"; import { ADMIN_CRON_NAMESPACES } from "./tanstack/admin/cron/route"; import { normalizeCronRouteSearch } from "./tanstack/admin/cron/route-search"; import { ADMIN_DEBUG_NAMESPACES } from "./tanstack/admin/debug/route"; @@ -280,5 +281,6 @@ export const routes = defineRoutes([ area: "admin", component: lazy(() => import("./pages/admin/content")), pendingComponent: TablePendingSkeleton, + search: contentListRouteSearch, }), ]); diff --git a/packages/vitnode/src/tanstack/admin/content/route-search.ts b/packages/vitnode/src/tanstack/admin/content/route-search.ts index 1489e92ee..56d0949f9 100644 --- a/packages/vitnode/src/tanstack/admin/content/route-search.ts +++ b/packages/vitnode/src/tanstack/admin/content/route-search.ts @@ -74,6 +74,25 @@ export const contentListRouteParams = ( filters: contentListFilters(input, definition), }); +/** + * The content list route's `search`, which carries the query string through + * unchanged. + * + * A content list's URL contract is a function of *its own content type* - which + * columns it sorts by, which filters it accepts, what page size its API defaults + * to - and a route's `search` is handed the query string alone, never the path + * params, so it cannot know which content type this URL is for. + * {@link normalizeContentListSearch} is therefore the loader's job, where the + * splat has just resolved. + * + * Declaring it is not optional: a route with no `search` and no `parseSearch` + * is handed `{}`, so every paginated, sorted or filtered URL would load the + * default list. + */ +export const contentListRouteSearch = ( + input: Record<string, unknown>, +): ContentListRouteSearch => input; + export const normalizeContentListSearch = ( input: UncheckedContentListSearch, definition: AnyContentTypeDefinition, From 6b9d7355901722f52e0d3093cc2183313d819548 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Tue, 15 Sep 2026 09:43:42 +0200 Subject: [PATCH 4/4] =?UTF-8?q?refactor(api-registry):=20=E2=9C=A8=20enhan?= =?UTF-8?q?ce=20plugin=20API=20registry=20generation=20and=20type=20safety?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 7 +- .../root/npmignore.template | 2 +- .../copy-of-vitnode-plugin/root/tsconfig.json | 2 +- .../src/plugin/create/route-templates.test.ts | 55 +++++---- .../src/plugin/create/route-templates.ts | 23 ---- packages/vitnode/scripts/build.ts | 2 + packages/vitnode/scripts/dev.ts | 3 + .../scripts/write-plugin-api-registry.test.ts | 71 +++++++++++ .../scripts/write-plugin-api-registry.ts | 49 ++++++++ .../framework/api-registry/generate.test.ts | 41 +++++++ .../src/framework/api-registry/generate.ts | 47 ++++++++ .../src/framework/api-registry/index.ts | 3 + plugins/blog/.npmignore | 1 + plugins/blog/src/config.api.test-d.ts | 114 ------------------ plugins/blog/tsconfig.json | 2 +- plugins/blog/types/api-registry.gen.d.ts | 22 ++++ plugins/example/.npmignore | 1 + plugins/example/tsconfig.json | 2 +- plugins/example/types/api-registry.gen.d.ts | 22 ++++ 19 files changed, 301 insertions(+), 168 deletions(-) create mode 100644 packages/vitnode/scripts/write-plugin-api-registry.test.ts create mode 100644 packages/vitnode/scripts/write-plugin-api-registry.ts delete mode 100644 plugins/blog/src/config.api.test-d.ts create mode 100644 plugins/blog/types/api-registry.gen.d.ts create mode 100644 plugins/example/types/api-registry.gen.d.ts diff --git a/AGENTS.md b/AGENTS.md index da67afaca..1f9b2033f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ import { Activity } from "react"; - Never annotate the result; the fetcher infers it. Put the shared contract on the feature's own `*Fetcher` type instead. - `args` is required exactly when the route declares a body, params or a query. - `captchaToken` for captcha-gated routes. -- `rawFetcher` only for generated Content Engine admin modules, which have no type to infer from. It is universal too, with the same server-only twin. A content type's *public* routes are typed: `module: "content/<publicApi.path>"`. +- `rawFetcher` only for generated Content Engine admin modules, which have no type to infer from. It is universal too, with the same server-only twin. A content type's _public_ routes are typed: `module: "content/<publicApi.path>"`. ### Caching APIs @@ -102,7 +102,4 @@ npm i x # Testing - Write and run vitest unit tests for all new features and bug fixes - skip only if vitest isn't configured. -- Don't write tests: - - for trivial code unless they have complex logic or edge cases - - for tests where it uses a database or external API - - how UI should be rendered (use playwright for that to write e2e tests) +- Do not test trivial code, config, database models, third-party libraries or UI components. diff --git a/packages/create-vitnode-app/copy-of-vitnode-plugin/root/npmignore.template b/packages/create-vitnode-app/copy-of-vitnode-plugin/root/npmignore.template index a3234cbec..ed9d3ee90 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-plugin/root/npmignore.template +++ b/packages/create-vitnode-app/copy-of-vitnode-plugin/root/npmignore.template @@ -10,7 +10,7 @@ /.swcrc /components.json /global.d.ts -/test-fixtures +/types /tsup.config.ts /vitest.config.ts /tsconfig.json diff --git a/packages/create-vitnode-app/copy-of-vitnode-plugin/root/tsconfig.json b/packages/create-vitnode-app/copy-of-vitnode-plugin/root/tsconfig.json index 7ee719d7a..3bc4e1d49 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-plugin/root/tsconfig.json +++ b/packages/create-vitnode-app/copy-of-vitnode-plugin/root/tsconfig.json @@ -21,5 +21,5 @@ } }, "exclude": ["node_modules"], - "include": ["src", "global.d.ts"] + "include": ["src", "global.d.ts", "types"] } diff --git a/packages/create-vitnode-app/src/plugin/create/route-templates.test.ts b/packages/create-vitnode-app/src/plugin/create/route-templates.test.ts index 7a4b6fa4b..a4401df41 100644 --- a/packages/create-vitnode-app/src/plugin/create/route-templates.test.ts +++ b/packages/create-vitnode-app/src/plugin/create/route-templates.test.ts @@ -1,9 +1,10 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { pluginApiConfigTemplate, pluginApiModuleTemplate, - pluginApiRegistryFixtureTemplate, pluginApiRouteTemplate, pluginApiVariableName, pluginConfigTemplate, @@ -239,7 +240,6 @@ describe("the generated constant", () => { .filter( ([file]) => file !== "global.d.ts" && - file !== "test-fixtures/api-registry.d.ts" && file !== "src/const.ts" && file !== "src/locales/en.json" && file !== "src/pages/home-page.tsx" && @@ -303,22 +303,8 @@ describe("the generated type registrations", () => { expect(types).not.toContain("fetcher/registry"); }); - it("registers the plugin for its own type-checking, outside the package", () => { - const fixture = pluginApiRegistryFixtureTemplate("@acme/blog"); - - expect(fixture).toContain( - 'import type { VitNodeApiPlugin } from "../src/config.api";', - ); - expect(fixture).toContain('"@acme/blog": VitNodeApiPlugin;'); - // A `declare module` merges only into a module the program has loaded. - expect(fixture).toContain( - 'export type { ApiPluginRegistry } from "@vitnode/core/lib/fetcher/registry";', - ); - }); - it("evaluates nothing", () => { // A `.d.ts` that ran the factory would build a Hono app at type-check time. - expect(pluginApiRegistryFixtureTemplate("blog")).not.toContain("()"); expect(pluginGlobalTypesTemplate()).not.toContain("()"); }); }); @@ -402,6 +388,19 @@ describe("the generated package exports", () => { }); }); +const templateTsconfigInclude = (): string[] => + ( + JSON.parse( + readFileSync( + join( + import.meta.dirname, + "../../../copy-of-vitnode-plugin/root/tsconfig.json", + ), + "utf-8", + ), + ) as { include: string[] } + ).include; + describe("the scaffold as a whole", () => { it("writes a file for every module its route tree names", () => { // The failure this prevents: a `lazy()` naming a module the scaffold does @@ -431,7 +430,6 @@ describe("the scaffold as a whole", () => { expect(Object.keys(files)).toContain("src/config.api.ts"); expect(Object.keys(files)).toContain("src/const.ts"); expect(Object.keys(files)).toContain("global.d.ts"); - expect(Object.keys(files)).toContain("test-fixtures/api-registry.d.ts"); expect(Object.keys(files)).not.toContain("src/api/client.ts"); }); @@ -450,15 +448,28 @@ describe("the scaffold as a whole", () => { // reaches an app through its package exports, and the app's own generated // registry is rewritten from the plugin list on every build. Object.keys(pluginRouteScaffold("blog")).forEach(file => { - expect( - file.startsWith("src/") || - file.startsWith("test-fixtures/") || - file === "global.d.ts", - ).toBe(true); + expect(file.startsWith("src/") || file === "global.d.ts").toBe(true); expect(file).not.toContain(".."); }); }); + it("writes only into directories the template tsconfig compiles", () => { + // The failure this prevents: a scaffolded file TypeScript never loads. + Object.keys(pluginRouteScaffold("@acme/blog")).forEach(file => { + expect(templateTsconfigInclude()).toContain( + file.includes("/") ? file.slice(0, file.indexOf("/")) : file, + ); + }); + }); + + it("compiles the directory `vitnode build` generates into", () => { + // `types/api-registry.gen.ts` is what lets the home page's `fetcher` call + // name this plugin. The scaffold does not write it - core does, on every + // build - and outside `include` it is inert, which fails the plugin's own + // `vitnode build` on the page the scaffold just wrote. + expect(templateTsconfigInclude()).toContain("types"); + }); + it("is a pure function of the plugin name", () => { expect(pluginRouteScaffold("blog")).toEqual(pluginRouteScaffold("blog")); }); diff --git a/packages/create-vitnode-app/src/plugin/create/route-templates.ts b/packages/create-vitnode-app/src/plugin/create/route-templates.ts index c81ecd0aa..a67e271fd 100644 --- a/packages/create-vitnode-app/src/plugin/create/route-templates.ts +++ b/packages/create-vitnode-app/src/plugin/create/route-templates.ts @@ -251,27 +251,6 @@ export type VitNodeApiPlugin = ApiPluginContract< >; `; -/** - * `test-fixtures/api-registry.d.ts` - what makes this plugin's own pages - * type-check before any application has installed it. - * - * The entry an app's generated `src/api-registry.gen.ts` will write, kept here - * rather than in `global.d.ts` and kept out of the published package: a plugin - * that registered itself would add its routes to the registry of every project - * that installed it, whether or not that project configured it. - */ -export const pluginApiRegistryFixtureTemplate = (pluginName: string): string => - `import type { VitNodeApiPlugin } from "../src/config.api"; - -declare module "@vitnode/core/lib/fetcher/registry" { - interface ApiPluginRegistry { - "${pluginName}": VitNodeApiPlugin; - } -} - -export type { ApiPluginRegistry } from "@vitnode/core/lib/fetcher/registry"; -`; - export const pluginGlobalTypesTemplate = (): string => `/// <reference types="use-intl" /> @@ -318,8 +297,6 @@ export const pluginRouteScaffold = ( pluginName: string, ): Record<string, string> => ({ "global.d.ts": pluginGlobalTypesTemplate(), - "test-fixtures/api-registry.d.ts": - pluginApiRegistryFixtureTemplate(pluginName), "src/api/modules/hello/hello.module.ts": pluginApiModuleTemplate(), "src/api/modules/hello/hello.route.ts": pluginApiRouteTemplate(), "src/config.api.ts": pluginApiConfigTemplate(pluginName), diff --git a/packages/vitnode/scripts/build.ts b/packages/vitnode/scripts/build.ts index 51e1d0a2c..f2eb79806 100644 --- a/packages/vitnode/scripts/build.ts +++ b/packages/vitnode/scripts/build.ts @@ -1,6 +1,8 @@ import { runInteractiveShellCommand } from "./run-interactive-shell-command.js"; +import { writePluginApiRegistry } from "./write-plugin-api-registry.js"; export const buildPlugin = async () => { + writePluginApiRegistry(); await runInteractiveShellCommand("tsc", ["-p", "tsconfig.build.json"]); await runInteractiveShellCommand("swc", [ "src", diff --git a/packages/vitnode/scripts/dev.ts b/packages/vitnode/scripts/dev.ts index b6f81dfac..bc28cdf86 100644 --- a/packages/vitnode/scripts/dev.ts +++ b/packages/vitnode/scripts/dev.ts @@ -1,5 +1,6 @@ /* eslint-disable no-console */ import { spawnCommand } from "./spawn-command.js"; +import { writePluginApiRegistry } from "./write-plugin-api-registry.js"; const spawnWatch = (command: string, args: string[]) => { const child = spawnCommand(command, args, { @@ -36,6 +37,8 @@ const spawnWatch = (command: string, args: string[]) => { * up when a route file is deleted. */ export const devPlugin = ({ initMessage }: { initMessage: string }) => { + writePluginApiRegistry(); + const children = [ spawnWatch("tsc", [ "-w", diff --git a/packages/vitnode/scripts/write-plugin-api-registry.test.ts b/packages/vitnode/scripts/write-plugin-api-registry.test.ts new file mode 100644 index 000000000..6be160b09 --- /dev/null +++ b/packages/vitnode/scripts/write-plugin-api-registry.test.ts @@ -0,0 +1,71 @@ +// @vitest-environment node +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { PLUGIN_API_REGISTRY_PATH } from "../src/framework/api-registry/generate.js"; +import { writePluginApiRegistry } from "./write-plugin-api-registry.js"; + +const pluginAt = ({ + name, + withApi = true, +}: { + name?: string; + withApi?: boolean; +}): string => { + const root = mkdtempSync(join(tmpdir(), "vitnode-plugin-")); + + if (name !== undefined) { + writeFileSync(join(root, "package.json"), JSON.stringify({ name })); + } + + if (withApi) { + mkdirSync(join(root, "src"), { recursive: true }); + writeFileSync(join(root, "src/config.api.ts"), "export {};"); + } + + return root; +}; + +const generated = (root: string): string => + readFileSync(join(root, PLUGIN_API_REGISTRY_PATH), "utf8"); + +describe("writePluginApiRegistry", () => { + it("registers the plugin under the name its package.json declares", () => { + const root = pluginAt({ name: "@acme/blog" }); + + expect(writePluginApiRegistry(root)).toBe(true); + expect(generated(root)).toContain("'@acme/blog': VitNodeApiPlugin"); + }); + + it("writes a declaration file, which the plugin's build never emits", () => { + // A `.ts` here would compile into `dist` and register the plugin in the + // registry of every project that installed it, configured or not. + expect(PLUGIN_API_REGISTRY_PATH.endsWith(".d.ts")).toBe(true); + }); + + it("skips a package that serves no API", () => { + const root = pluginAt({ name: "@acme/blog", withApi: false }); + + expect(writePluginApiRegistry(root)).toBe(false); + }); + + it("skips a package with no name to register", () => { + expect(writePluginApiRegistry(pluginAt({}))).toBe(false); + }); + + it("rewrites the file when the plugin is renamed", () => { + const root = pluginAt({ name: "@acme/blog" }); + + writePluginApiRegistry(root); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ name: "@acme/shop" }), + ); + writePluginApiRegistry(root); + + expect(generated(root)).toContain("'@acme/shop'"); + expect(generated(root)).not.toContain("'@acme/blog'"); + }); +}); diff --git a/packages/vitnode/scripts/write-plugin-api-registry.ts b/packages/vitnode/scripts/write-plugin-api-registry.ts new file mode 100644 index 000000000..12553fa7d --- /dev/null +++ b/packages/vitnode/scripts/write-plugin-api-registry.ts @@ -0,0 +1,49 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { + generatePluginApiRegistrySource, + PLUGIN_API_REGISTRY_PATH, +} from "../src/framework/api-registry/generate.js"; + +const pluginIdFrom = (root: string): null | string => { + const manifest = join(root, "package.json"); + if (!existsSync(manifest)) return null; + + const { name } = JSON.parse(readFileSync(manifest, "utf8")) as { + name?: string; + }; + + return name ?? null; +}; + +/** + * `types/api-registry.gen.ts` - a plugin's registration of itself. + * + * Written before the compilers run, from the plugin's own `package.json` name, + * so `fetcher({ plugin: <this id> })` resolves inside the package that declares + * those routes. A plugin without a `src/config.api.ts` serves no API and gets + * no file. + * + * Synchronous because `vitnode dev` spawns its watchers without awaiting, and + * `tsc` must not start before the file it needs exists. + */ +export const writePluginApiRegistry = ( + root: string = process.cwd(), +): boolean => { + if (!existsSync(join(root, "src/config.api.ts"))) return false; + + const pluginId = pluginIdFrom(root); + if (pluginId === null) return false; + + const path = join(root, PLUGIN_API_REGISTRY_PATH); + const source = generatePluginApiRegistrySource(pluginId); + const current = existsSync(path) ? readFileSync(path, "utf8") : null; + + if (current === source) return true; + + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, source, "utf8"); + + return true; +}; diff --git a/packages/vitnode/src/framework/api-registry/generate.test.ts b/packages/vitnode/src/framework/api-registry/generate.test.ts index f146ecccf..13eeec8f4 100644 --- a/packages/vitnode/src/framework/api-registry/generate.test.ts +++ b/packages/vitnode/src/framework/api-registry/generate.test.ts @@ -5,6 +5,7 @@ import type { ResolvedApiPluginModule } from "./types.js"; import { API_REGISTRY_SPECIFIER, generateApiRegistrySource, + generatePluginApiRegistrySource, } from "./generate.js"; const BLOG: ResolvedApiPluginModule = { @@ -99,3 +100,43 @@ describe("generateApiRegistrySource", () => { ); }); }); + +describe("generatePluginApiRegistrySource", () => { + it("registers the one plugin against its own API config", () => { + const source = generatePluginApiRegistrySource("@acme/blog"); + + expect(source).toContain( + "import type { VitNodeApiPlugin } from '../src/config.api'", + ); + expect(source).toContain("'@acme/blog': VitNodeApiPlugin"); + }); + + it("augments the module an application's registry augments", () => { + const source = generatePluginApiRegistrySource("@acme/blog"); + + // A `declare module` merges only into a module the program has loaded, so + // the re-export is what pulls the registry in. + expect(source).toContain(`declare module '${API_REGISTRY_SPECIFIER}'`); + expect(source).toContain( + `export type { ApiPluginRegistry } from '${API_REGISTRY_SPECIFIER}'`, + ); + }); + + it("imports types and never values, and executes nothing", () => { + const source = generatePluginApiRegistrySource("@acme/blog"); + + expect(source).not.toMatch(/^import (?!type )/m); + expect(source).not.toContain("()"); + expect(source).not.toContain("typeof "); + }); + + it("escapes a plugin id rather than pasting it into a literal", () => { + expect(generatePluginApiRegistrySource("it's")).toContain("'it\\'s'"); + }); + + it("is a pure function of the plugin id", () => { + expect(generatePluginApiRegistrySource("@acme/blog")).toBe( + generatePluginApiRegistrySource("@acme/blog"), + ); + }); +}); diff --git a/packages/vitnode/src/framework/api-registry/generate.ts b/packages/vitnode/src/framework/api-registry/generate.ts index 541ec0f9f..9a94723e7 100644 --- a/packages/vitnode/src/framework/api-registry/generate.ts +++ b/packages/vitnode/src/framework/api-registry/generate.ts @@ -66,3 +66,50 @@ ${entries} } export type { ApiPluginRegistry } from ${toSingleQuotedLiteral(API_REGISTRY_SPECIFIER)} `; }; + +/** + * Where `vitnode build` writes a plugin's own registration, relative to its root. + * + * A declaration file rather than a `.ts` one: TypeScript emits no output for a + * `.d.ts` input, so the augmentation type-checks the plugin's own pages without + * reaching `dist` - and a plugin a project merely installed cannot put its + * routes into that project's registry. + */ +export const PLUGIN_API_REGISTRY_PATH = "types/api-registry.gen.d.ts"; + +/** What that file imports, relative to itself. */ +export const PLUGIN_API_CONFIG_SPECIFIER = "../src/config.api"; + +const PLUGIN_HEADER = `/* eslint-disable */ + +// This file is generated by VitNode. Do not edit it, and do not format it. +// +// It is rewritten by \`vitnode build\` and \`vitnode dev\` from one input: this +// plugin's own \`src/config.api.ts\`. +// +// It is what lets the plugin's own pages call \`fetcher({ plugin: <this id> })\` +// before any application has installed it. An application registers the plugins +// it configured from its generated \`src/api-registry.gen.ts\`, and this file is +// kept out of the published package so that a plugin a project merely installed +// cannot put its routes into that project's registry. +`; + +/** + * A plugin's registration of itself, for its own type-checking only. + * + * The same shape {@link generateApiRegistrySource} writes for an application, + * with one entry and a relative specifier, so a plugin's pages and an app's + * pages resolve a `fetcher` call against the same declaration. + */ +export const generatePluginApiRegistrySource = (pluginId: string): string => + `${PLUGIN_HEADER} +import type { ${API_PLUGIN_TYPE} } from ${toSingleQuotedLiteral(PLUGIN_API_CONFIG_SPECIFIER)} + +declare module ${toSingleQuotedLiteral(API_REGISTRY_SPECIFIER)} { + interface ApiPluginRegistry { + ${toSingleQuotedLiteral(pluginId)}: ${API_PLUGIN_TYPE} + } +} + +export type { ApiPluginRegistry } from ${toSingleQuotedLiteral(API_REGISTRY_SPECIFIER)} +`; diff --git a/packages/vitnode/src/framework/api-registry/index.ts b/packages/vitnode/src/framework/api-registry/index.ts index 524b6867b..6c8f3627a 100644 --- a/packages/vitnode/src/framework/api-registry/index.ts +++ b/packages/vitnode/src/framework/api-registry/index.ts @@ -2,5 +2,8 @@ export { API_PLUGIN_TYPE, API_REGISTRY_SPECIFIER, generateApiRegistrySource, + generatePluginApiRegistrySource, + PLUGIN_API_CONFIG_SPECIFIER, + PLUGIN_API_REGISTRY_PATH, } from "./generate.js"; export type { ResolvedApiPluginModule } from "./types.js"; diff --git a/plugins/blog/.npmignore b/plugins/blog/.npmignore index 76da93b80..832f1b7ed 100644 --- a/plugins/blog/.npmignore +++ b/plugins/blog/.npmignore @@ -10,6 +10,7 @@ /.swcrc /components.json /global.d.ts +/types /tsup.config.ts /vitest.config.ts /tsconfig.json diff --git a/plugins/blog/src/config.api.test-d.ts b/plugins/blog/src/config.api.test-d.ts deleted file mode 100644 index 8c7781134..000000000 --- a/plugins/blog/src/config.api.test-d.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { fetcher } from "@vitnode/core/tanstack/fetcher"; -import { describe, expectTypeOf, it } from "vitest"; - -import type { VitNodeApiPlugin } from "./config.api"; - -import { blogApiPlugin } from "./config.api"; - -describe("the blog API plugin keeps its literal shape", () => { - it("retains the plugin id and both registered modules", () => { - const plugin = blogApiPlugin(); - - expectTypeOf(plugin.pluginId).toEqualTypeOf<"@vitnode/blog">(); - expectTypeOf(plugin.modules.length).toEqualTypeOf<2>(); - expectTypeOf(plugin.modules[0].name).toEqualTypeOf<"admin">(); - expectTypeOf(plugin.modules[1].name).toEqualTypeOf<"content">(); - }); -}); - -describe("the contract reduces that plugin to its API surface", () => { - it("keeps the literal plugin id", () => { - expectTypeOf< - VitNodeApiPlugin["pluginId"] - >().toEqualTypeOf<"@vitnode/blog">(); - }); - - it("lists the module paths a call may name", () => { - // `admin/content` and its per-content-type children are absent on purpose: - // `buildContentAdminModule` names them from a runtime string, so they carry - // no literal type and admitting them would widen `module` to `string`. - expectTypeOf<VitNodeApiPlugin["modulePaths"]>().toEqualTypeOf< - "admin" | "content" | "content/blog" - >(); - }); - - it("carries no runtime member of the plugin", () => { - expectTypeOf<keyof VitNodeApiPlugin>().toEqualTypeOf< - "endpoints" | "modulePaths" | "modules" | "pluginId" - >(); - }); -}); - -describe("the universal fetcher reaches the blog's public content routes", () => { - it("lists posts through the generated content module", async () => { - const response = await fetcher({ - plugin: "@vitnode/blog", - args: { query: { first: "10" } }, - method: "get", - module: "content/blog", - path: "/", - }); - - expectTypeOf(response.status).toEqualTypeOf<200 | 400>(); - - if (response.status === 200) { - expectTypeOf( - (await response.json()).pageInfo.hasNextPage, - ).toEqualTypeOf<boolean>(); - } - }); - - it("reads one post by its slug", async () => { - const response = await fetcher({ - plugin: "@vitnode/blog", - args: { params: { slug: "hello-world" } }, - method: "get", - module: "content/blog", - path: "/{slug}", - }); - - expectTypeOf(response.status).toEqualTypeOf<200 | 404>(); - }); - - it("offers no public module for a content type without a public API", async () => { - await fetcher({ - plugin: "@vitnode/blog", - method: "get", - // @ts-expect-error -- categories declare no `publicApi` - module: "content/categories", - path: "/", - }); - }); - - it("rejects a slug read that omits its parameter", async () => { - // @ts-expect-error -- `/{slug}` declares params - await fetcher({ - plugin: "@vitnode/blog", - method: "get", - module: "content/blog", - path: "/{slug}", - }); - }); - - it("rejects a write on the read-only public API", async () => { - await fetcher({ - plugin: "@vitnode/blog", - // @ts-expect-error -- the public list is a `get` - method: "post", - module: "content/blog", - path: "/", - }); - }); - - it("rejects the cookie relay, which only a server can honour", async () => { - await fetcher({ - plugin: "@vitnode/blog", - // @ts-expect-error -- `allowSaveCookies` is on `tanstack/fetcher/server` - allowSaveCookies: true, - args: { query: { first: "10" } }, - method: "get", - module: "content/blog", - path: "/", - }); - }); -}); diff --git a/plugins/blog/tsconfig.json b/plugins/blog/tsconfig.json index 62abfaf36..ad8d8e759 100644 --- a/plugins/blog/tsconfig.json +++ b/plugins/blog/tsconfig.json @@ -17,5 +17,5 @@ } }, "exclude": ["node_modules"], - "include": ["src", "global.d.ts", "vitest.config.ts"] + "include": ["types", "src", "global.d.ts", "vitest.config.ts"] } diff --git a/plugins/blog/types/api-registry.gen.d.ts b/plugins/blog/types/api-registry.gen.d.ts new file mode 100644 index 000000000..f6a8627b1 --- /dev/null +++ b/plugins/blog/types/api-registry.gen.d.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ + +// This file is generated by VitNode. Do not edit it, and do not format it. +// +// It is rewritten by `vitnode build` and `vitnode dev` from one input: this +// plugin's own `src/config.api.ts`. +// +// It is what lets the plugin's own pages call `fetcher({ plugin: <this id> })` +// before any application has installed it. An application registers the plugins +// it configured from its generated `src/api-registry.gen.ts`, and this file is +// kept out of the published package so that a plugin a project merely installed +// cannot put its routes into that project's registry. + +import type { VitNodeApiPlugin } from '../src/config.api' + +declare module '@vitnode/core/lib/fetcher/registry' { + interface ApiPluginRegistry { + '@vitnode/blog': VitNodeApiPlugin + } +} + +export type { ApiPluginRegistry } from '@vitnode/core/lib/fetcher/registry' diff --git a/plugins/example/.npmignore b/plugins/example/.npmignore index 76da93b80..832f1b7ed 100644 --- a/plugins/example/.npmignore +++ b/plugins/example/.npmignore @@ -10,6 +10,7 @@ /.swcrc /components.json /global.d.ts +/types /tsup.config.ts /vitest.config.ts /tsconfig.json diff --git a/plugins/example/tsconfig.json b/plugins/example/tsconfig.json index 62abfaf36..ad8d8e759 100644 --- a/plugins/example/tsconfig.json +++ b/plugins/example/tsconfig.json @@ -17,5 +17,5 @@ } }, "exclude": ["node_modules"], - "include": ["src", "global.d.ts", "vitest.config.ts"] + "include": ["types", "src", "global.d.ts", "vitest.config.ts"] } diff --git a/plugins/example/types/api-registry.gen.d.ts b/plugins/example/types/api-registry.gen.d.ts new file mode 100644 index 000000000..7f1a69947 --- /dev/null +++ b/plugins/example/types/api-registry.gen.d.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ + +// This file is generated by VitNode. Do not edit it, and do not format it. +// +// It is rewritten by `vitnode build` and `vitnode dev` from one input: this +// plugin's own `src/config.api.ts`. +// +// It is what lets the plugin's own pages call `fetcher({ plugin: <this id> })` +// before any application has installed it. An application registers the plugins +// it configured from its generated `src/api-registry.gen.ts`, and this file is +// kept out of the published package so that a plugin a project merely installed +// cannot put its routes into that project's registry. + +import type { VitNodeApiPlugin } from '../src/config.api' + +declare module '@vitnode/core/lib/fetcher/registry' { + interface ApiPluginRegistry { + '@vitnode/example': VitNodeApiPlugin + } +} + +export type { ApiPluginRegistry } from '@vitnode/core/lib/fetcher/registry'