From d0e6760a1c9378d172ae5968a34bec3f2dc375c5 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Thu, 3 Sep 2026 13:30:35 +0200 Subject: [PATCH 01/12] refactor: Routers in tanstack start --- README.md | 2 +- apps/web/content/docs/dev/architecture.mdx | 2 +- apps/web/content/docs/dev/cache.mdx | 2 +- .../docs/dev/captcha/custom-adapter.mdx | 2 +- .../content-delivery-and-seo.mdx | 22 +- .../content-engine/plugin-registration.mdx | 2 +- apps/web/content/docs/dev/data-loading.mdx | 2 +- .../content/docs/dev/database/pagination.mdx | 2 +- apps/web/content/docs/dev/fetcher.mdx | 2 +- apps/web/content/docs/dev/i18n/namespaces.mdx | 42 +- apps/web/content/docs/dev/i18n/pages.mdx | 26 +- apps/web/content/docs/dev/index.mdx | 4 +- apps/web/content/docs/dev/performance.mdx | 4 +- .../content/docs/dev/plugins/admin/index.mdx | 24 +- .../content/docs/dev/plugins/breadcrumbs.mdx | 105 ++- apps/web/content/docs/dev/plugins/create.mdx | 38 +- apps/web/content/docs/dev/plugins/meta.json | 2 +- .../docs/dev/plugins/route-manifest.mdx | 128 --- apps/web/content/docs/dev/plugins/routes.mdx | 435 ++++++++++ apps/web/content/docs/dev/routing/index.mdx | 45 +- .../docs/dev/routing/loading-states.mdx | 2 +- .../web/content/docs/dev/routing/metadata.mdx | 8 +- .../content/docs/dev/routing/navigation.mdx | 2 +- .../content/docs/dev/routing/not-found.mdx | 2 +- .../web/content/docs/dev/server-functions.mdx | 2 +- apps/web/content/docs/guides/first-plugin.mdx | 31 +- apps/web/content/docs/ui/data-table.mdx | 2 +- apps/web/eslint.config.mjs | 1 - apps/web/src/docs/mdx-components.tsx | 2 +- apps/web/src/docs/moved-pages.ts | 1 + apps/web/src/plugin-route-manifest.gen.ts | 115 --- apps/web/src/plugin-routes.gen.ts | 127 +-- apps/web/src/router.tsx | 49 +- apps/web/src/routes/_docs/docs.$.tsx | 1 + packages/config/eslint.config.mjs | 15 + .../root/src/components/admin-shell.tsx | 2 +- .../copy-of-vitnode-app/root/src/router.tsx | 40 +- .../root/src/vitnode.config.ts | 7 +- .../src/create/create-package-json.ts | 2 +- .../create/no-plugin-page-duplication.test.ts | 40 +- .../src/create/scaffold-invariants.test.ts | 8 +- .../plugin/create/create-plugin-vitnode.ts | 6 +- .../src/plugin/create/route-templates.test.ts | 79 +- .../src/plugin/create/route-templates.ts | 107 ++- .../vitnode/scripts/no-route-copier.test.ts | 7 +- packages/vitnode/scripts/shared/file-utils.ts | 2 +- .../framework/plugin-routes/compile.test.ts | 703 +++++---------- .../src/framework/plugin-routes/compile.ts | 160 ++-- .../plugin-routes/component-source.test.ts | 98 +++ .../plugin-routes/component-source.ts | 39 + .../framework/plugin-routes/diagnostics.ts | 22 +- .../framework/plugin-routes/generate.test.ts | 347 +++----- .../src/framework/plugin-routes/generate.ts | 215 ++--- .../plugin-routes/host-routes.test.ts | 15 +- .../src/framework/plugin-routes/index.ts | 60 +- .../plugin-routes/manifest-source.test.ts | 125 --- .../plugin-routes/manifest-source.ts | 110 --- .../framework/plugin-routes/parity.test.ts | 79 -- .../src/framework/plugin-routes/parity.ts | 77 -- .../framework/plugin-routes/resolve.test.ts | 411 +++------ .../src/framework/plugin-routes/resolve.ts | 241 +----- .../src/framework/plugin-routes/types.ts | 117 +-- .../vite/no-materialized-routes.test.ts | 167 ++-- .../src/framework/vite/plugin-routes.ts | 282 +++--- .../src/framework/vite/projections.test.ts | 56 +- packages/vitnode/src/lib/plugin.ts | 23 +- packages/vitnode/src/routing/authoring.ts | 20 +- packages/vitnode/src/routing/errors.ts | 7 +- packages/vitnode/src/routing/flatten.ts | 339 ++++++++ packages/vitnode/src/routing/graph.test.ts | 88 +- packages/vitnode/src/routing/graph.ts | 31 +- packages/vitnode/src/routing/index.ts | 51 +- packages/vitnode/src/routing/manifest.test.ts | 801 +++++------------- packages/vitnode/src/routing/manifest.ts | 364 ++------ packages/vitnode/src/routing/module.test.ts | 30 +- packages/vitnode/src/routing/module.ts | 108 ++- packages/vitnode/src/routing/tree.test.ts | 550 ++++++++++++ packages/vitnode/src/routing/tree.ts | 240 ++++++ packages/vitnode/src/routing/types.ts | 201 +---- .../vitnode/src/tanstack/admin/breadcrumb.tsx | 82 +- packages/vitnode/src/tanstack/admin/index.ts | 6 +- packages/vitnode/src/tanstack/admin/nav.tsx | 2 +- .../admin/users/detail-breadcrumb.tsx | 29 +- .../tanstack/auth/middleware-config-server.ts | 1 + .../vitnode/src/tanstack/breadcrumb/index.ts | 14 +- .../tanstack/breadcrumb/main-breadcrumb.tsx | 46 +- .../src/tanstack/breadcrumb/model.test.ts | 160 +++- .../vitnode/src/tanstack/breadcrumb/model.ts | 212 ++++- .../vitnode/src/tanstack/i18n/query.test.ts | 4 +- .../plugin-routes/components.test.tsx | 114 +++ .../src/tanstack/plugin-routes/components.tsx | 117 ++- .../src/tanstack/plugin-routes/index.ts | 13 +- .../src/tanstack/plugin-routes/loader-data.ts | 42 +- .../src/tanstack/plugin-routes/module-ref.ts | 27 +- .../plugin-routes/mount-freshness.test.ts | 114 +-- .../src/tanstack/plugin-routes/mount.tsx | 84 +- .../plugin-routes/plugin-routes.test.ts | 439 ++++------ .../src/tanstack/plugin-routes/specs.test.ts | 226 ++--- .../src/tanstack/plugin-routes/specs.ts | 165 ++-- .../src/tanstack/routes/admin/advanced.tsx | 8 +- .../src/tanstack/routes/admin/content.tsx | 3 +- .../src/tanstack/routes/admin/index.tsx | 4 +- .../src/tanstack/routes/admin/staff.tsx | 19 +- .../src/tanstack/routes/admin/system.tsx | 10 +- .../src/tanstack/routes/admin/users.tsx | 11 +- .../src/tanstack/routes/main/index.tsx | 2 +- .../src/tanstack/routes/main/settings.tsx | 26 +- .../breadcrumb/breadcrumb-admin-content.tsx | 23 +- .../src/views/auth/auth-boundaries.test.ts | 2 +- .../settings/settings-breadcrumb-content.tsx | 44 +- .../breadcrumb/breadcrumb-main-content.tsx | 42 - .../breadcrumb/breadcrumb-render-content.tsx | 95 +-- .../breadcrumb/breadcrumb-trail-content.tsx | 94 ++ .../breadcrumb/resolve-main-breadcrumb.ts | 27 - plugins/example/src/config.tsx | 6 +- plugins/example/src/locales/en.json | 4 +- .../{routes => pages}/admin-example-page.tsx | 16 +- .../src/{routes => pages}/browse-page.tsx | 39 +- .../browse-search.ts} | 23 +- .../src/{routes => pages}/example-page.tsx | 13 +- .../{routes => pages}/guide-index-page.tsx | 9 +- .../src/{routes => pages}/guide-layout.tsx | 23 +- .../{routes => pages}/guide-topic-page.tsx | 39 +- plugins/example/src/routes.ts | 144 ++++ plugins/example/src/routes/manifest.ts | 162 ---- 125 files changed, 5086 insertions(+), 5427 deletions(-) delete mode 100644 apps/web/content/docs/dev/plugins/route-manifest.mdx create mode 100644 apps/web/content/docs/dev/plugins/routes.mdx delete mode 100644 apps/web/src/plugin-route-manifest.gen.ts create mode 100644 packages/vitnode/src/framework/plugin-routes/component-source.test.ts create mode 100644 packages/vitnode/src/framework/plugin-routes/component-source.ts delete mode 100644 packages/vitnode/src/framework/plugin-routes/manifest-source.test.ts delete mode 100644 packages/vitnode/src/framework/plugin-routes/manifest-source.ts delete mode 100644 packages/vitnode/src/framework/plugin-routes/parity.test.ts delete mode 100644 packages/vitnode/src/framework/plugin-routes/parity.ts create mode 100644 packages/vitnode/src/routing/flatten.ts create mode 100644 packages/vitnode/src/routing/tree.test.ts create mode 100644 packages/vitnode/src/routing/tree.ts create mode 100644 packages/vitnode/src/tanstack/plugin-routes/components.test.tsx delete mode 100644 packages/vitnode/src/views/breadcrumb/breadcrumb-main-content.tsx create mode 100644 packages/vitnode/src/views/breadcrumb/breadcrumb-trail-content.tsx delete mode 100644 packages/vitnode/src/views/breadcrumb/resolve-main-breadcrumb.ts rename plugins/example/src/{routes => pages}/admin-example-page.tsx (81%) rename plugins/example/src/{routes => pages}/browse-page.tsx (73%) rename plugins/example/src/{routes/browse-page.search.ts => pages/browse-search.ts} (62%) rename plugins/example/src/{routes => pages}/example-page.tsx (78%) rename plugins/example/src/{routes => pages}/guide-index-page.tsx (74%) rename plugins/example/src/{routes => pages}/guide-layout.tsx (74%) rename plugins/example/src/{routes => pages}/guide-topic-page.tsx (74%) create mode 100644 plugins/example/src/routes.ts delete mode 100644 plugins/example/src/routes/manifest.ts diff --git a/README.md b/README.md index badb7919f..80bba2e95 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ feature. That boundary pays rent surprisingly quickly. - [Getting started](https://vitnode.com/docs/dev/setup) - [Build your first plugin](https://vitnode.com/docs/guides/first-plugin) -- [Plugin routes](https://vitnode.com/docs/dev/routing) +- [Plugin routes](https://vitnode.com/docs/dev/plugins/routes) - [Admin Control Panel](https://vitnode.com/docs/dev/plugins/admin) - [Content delivery and SEO](https://vitnode.com/docs/dev/content-engine/content-delivery-and-seo) - [Write documentation](https://vitnode.com/docs/dev/documentation) diff --git a/apps/web/content/docs/dev/architecture.mdx b/apps/web/content/docs/dev/architecture.mdx index 595060d29..8f629e0d9 100644 --- a/apps/web/content/docs/dev/architecture.mdx +++ b/apps/web/content/docs/dev/architecture.mdx @@ -58,7 +58,7 @@ VitNode is built around modular plugins located in `plugins/*`: /> @@ -56,15 +56,15 @@ export const articleContentType = defineContentType({ ### Claim the public URL in the plugin -```ts title="plugins/site-notes/src/routes/manifest.ts" -export const routes = [ - // [!code ++:6] - { - entry: 'routes/article-page', - id: 'article', - path: '/articles/:slug', - }, -] +```ts title="plugins/site-notes/src/routes.ts" +import { definePluginRoutes, lazy, page } from '@vitnode/core/routing' + +export const routes = definePluginRoutes([ + // [!code ++:3] + page('/articles/:slug', { + component: lazy(() => import('./pages/article-page')), + }), +]) ``` @@ -72,7 +72,7 @@ export const routes = [ ### Render data and metadata from the plugin route -```tsx title="plugins/site-notes/src/routes/article-page.tsx" +```tsx title="plugins/site-notes/src/pages/article-page.tsx" import type { PluginRoutePageProps } from '@vitnode/core/routing' import { definePluginRoute } from '@vitnode/core/routing' diff --git a/apps/web/content/docs/dev/content-engine/plugin-registration.mdx b/apps/web/content/docs/dev/content-engine/plugin-registration.mdx index 1995599d9..5cf34139d 100644 --- a/apps/web/content/docs/dev/content-engine/plugin-registration.mdx +++ b/apps/web/content/docs/dev/content-engine/plugin-registration.mdx @@ -66,7 +66,7 @@ Exports the main plugin factory used by host applications: ```tsx title="plugins/blog/src/config.tsx" import { buildPlugin } from "@vitnode/core/lib/plugin" import messages from "./locales" -import { routes } from "./routes/manifest" +import { routes } from "./routes" import { adminContent } from "./admin/content" export const blogPlugin = () => diff --git a/apps/web/content/docs/dev/data-loading.mdx b/apps/web/content/docs/dev/data-loading.mdx index f98b87640..2b2c7fe44 100644 --- a/apps/web/content/docs/dev/data-loading.mdx +++ b/apps/web/content/docs/dev/data-loading.mdx @@ -13,7 +13,7 @@ runs for SSR and client navigation, then hands typed data to the plugin page. Plugins load data using `definePluginRoute`. The loader runs during SSR and client navigation, handing typed `loaderData` to the page component: -```tsx title="plugins/blog/src/routes/announcements-page.tsx" +```tsx title="plugins/blog/src/pages/announcements-page.tsx" import type { PluginRoutePageProps } from '@vitnode/core/routing' import { definePluginRoute } from '@vitnode/core/routing' diff --git a/apps/web/content/docs/dev/database/pagination.mdx b/apps/web/content/docs/dev/database/pagination.mdx index 24b4adbec..59593172e 100644 --- a/apps/web/content/docs/dev/database/pagination.mdx +++ b/apps/web/content/docs/dev/database/pagination.mdx @@ -180,7 +180,7 @@ Plugin routes normalize their own query string with `parseSearch`, then receive the typed `search`, `loaderData`, and same-page `navigate` function. That is all a list needs—no host route file required. -```tsx title="plugins/blog/src/routes/posts-page.tsx" +```tsx title="plugins/blog/src/pages/posts-page.tsx" import type { PluginRoutePageProps } from '@vitnode/core/routing' import { definePluginRoute } from '@vitnode/core/routing' diff --git a/apps/web/content/docs/dev/fetcher.mdx b/apps/web/content/docs/dev/fetcher.mdx index b274d1c07..5b12b49ee 100644 --- a/apps/web/content/docs/dev/fetcher.mdx +++ b/apps/web/content/docs/dev/fetcher.mdx @@ -95,7 +95,7 @@ export const fetchDevices = createIsomorphicFn() }) ``` -```tsx title="plugins/devices/src/routes/devices-page.tsx" +```tsx title="plugins/devices/src/pages/devices-page.tsx" import { definePluginRoute } from '@vitnode/core/routing' import { fetchDevices } from '../lib/fetch-devices' diff --git a/apps/web/content/docs/dev/i18n/namespaces.mdx b/apps/web/content/docs/dev/i18n/namespaces.mdx index c97485438..2a02c004f 100644 --- a/apps/web/content/docs/dev/i18n/namespaces.mdx +++ b/apps/web/content/docs/dev/i18n/namespaces.mdx @@ -76,31 +76,33 @@ namespace. ## Asking for a namespace -Declare the exact branches a plugin page renders in the manifest. VitNode loads -them with the route chunk, so public pages do not download a plugin’s AdminCP -copy just because it exists. - -```ts title="plugins/my-plugin/src/routes/manifest.ts" -export const routes = [ - { - entry: 'routes/reports-page', - id: 'reports', +Declare the exact branches a plugin page renders as the route's `messages`. +VitNode loads them with the route chunk, so public pages do not download a +plugin’s AdminCP copy just because it exists. + +```ts title="plugins/my-plugin/src/routes.ts" +import { definePluginRoutes, lazy, page } from '@vitnode/core/routing' + +export const routes = definePluginRoutes([ + page('/reports', { + component: lazy(() => import('./pages/reports-page')), // [!code ++] - namespaces: ['my-plugin.reports'], - path: '/reports', - }, -] + messages: ['my-plugin.reports'], + }), +]) ``` -The route module can then call `useTranslations('my-plugin.reports')`. If a key -renders as its own name, confirm the manifest declared the matching namespace. +A route inherits every namespace its layouts declare, so a shared frame can name +them once for a whole subtree. The route module then calls +`useTranslations('my-plugin.reports')`; if a key renders as its own name, confirm +the route declared the matching namespace. ## Limits A namespace list reaches the server through a server function, which is a public `POST` endpoint once the app is built. So the rules are enforced rather than assumed, and the same rules validate a plugin's build-time declaration - one -definition, in `@vitnode/core/routing`, so a manifest cannot accept something the +definition, in `@vitnode/core/routing`, so a route tree cannot accept something the server refuses. The symptom of a namespace that was never asked for. Check that the route - declares it in the plugin's `manifest.ts`. + declares it in the plugin's `routes.ts`. import('./pages/about-page')), + messages: ['@vitnode/blog.about'], // [!code ++] + }), +]) ``` Read strings directly with `useTranslations`: -```tsx title="plugins/blog/src/routes/about-page.tsx" +```tsx title="plugins/blog/src/pages/about-page.tsx" import { useTranslations } from 'use-intl' const AboutPage = () => { @@ -53,7 +53,7 @@ And define the messages in `plugins/blog/src/locales/en.json`: Host messages are for the site shell. A product page should declare its plugin - namespace in the manifest and keep its locale JSON beside the route. + namespace as the route's `messages` and keep its locale JSON beside the route. ## Placeholders and Pluralization diff --git a/apps/web/content/docs/dev/index.mdx b/apps/web/content/docs/dev/index.mdx index e6c18eff6..4fa97d097 100644 --- a/apps/web/content/docs/dev/index.mdx +++ b/apps/web/content/docs/dev/index.mdx @@ -86,9 +86,9 @@ route to visit—not just a philosophical plugin. } - title="Plugin route manifest" + title="Plugin routes" description="Claim public or AdminCP URLs without adding host route files." - href="/docs/dev/plugins/route-manifest" + href="/docs/dev/plugins/routes" /> } diff --git a/apps/web/content/docs/dev/performance.mdx b/apps/web/content/docs/dev/performance.mdx index 6a9bd0439..3aa3df1de 100644 --- a/apps/web/content/docs/dev/performance.mdx +++ b/apps/web/content/docs/dev/performance.mdx @@ -38,7 +38,7 @@ TanStack Router automatically extracts route `component`, `errorComponent`, and Because `head` is evaluated in the main route bundle, importing strings from component files accidentally pulls entire component trees into the initial download: -```tsx title="plugins/home/src/routes/home-page.tsx" +```tsx title="plugins/home/src/pages/home-page.tsx" import { definePluginRoute } from '@vitnode/core/routing' // BAD: Pulls HomeRouteContent and all its heavy icons/charts into main entry @@ -85,7 +85,7 @@ export const ArticleEditor = (props) => ( When a route loader requires a heavy calculation or parsing library, import it dynamically: -```tsx title="plugins/stats/src/routes/stats-page.tsx" +```tsx title="plugins/stats/src/pages/stats-page.tsx" import { definePluginRoute } from '@vitnode/core/routing' export const route = definePluginRoute({ diff --git a/apps/web/content/docs/dev/plugins/admin/index.mdx b/apps/web/content/docs/dev/plugins/admin/index.mdx index 34e460df5..7b47b1e53 100644 --- a/apps/web/content/docs/dev/plugins/admin/index.mdx +++ b/apps/web/content/docs/dev/plugins/admin/index.mdx @@ -16,18 +16,16 @@ plugin supplies the useful bit. ### Claim an AdminCP route -```ts title="plugins/site-notes/src/routes/manifest.ts" -import type { PluginRouteDefinition } from '@vitnode/core/routing' +```ts title="plugins/site-notes/src/routes.ts" +import { definePluginRoutes, lazy, page } from '@vitnode/core/routing' -export const routes: PluginRouteDefinition[] = [ - // [!code ++:6] - { +export const routes = definePluginRoutes([ + // [!code ++:4] + page('/admin/site-notes/settings', { area: 'admin', - entry: 'routes/admin-settings-page', - id: 'settings', - path: '/admin/site-notes/settings', - }, -] + component: lazy(() => import('./pages/admin-settings-page')), + }), +]) ``` @@ -35,7 +33,7 @@ export const routes: PluginRouteDefinition[] = [ ### Render the plugin page -```tsx title="plugins/site-notes/src/routes/admin-settings-page.tsx" +```tsx title="plugins/site-notes/src/pages/admin-settings-page.tsx" const AdminSettingsPage = () => (

Site notes settings

@@ -135,8 +133,8 @@ the declared permission. /> } - title="Route manifest" + title="Plugin routes" description="Configure route areas, namespaces, guards, and parameters." - href="/docs/dev/plugins/route-manifest" + href="/docs/dev/plugins/routes" /> diff --git a/apps/web/content/docs/dev/plugins/breadcrumbs.mdx b/apps/web/content/docs/dev/plugins/breadcrumbs.mdx index 9654388b9..528aa3f5f 100644 --- a/apps/web/content/docs/dev/plugins/breadcrumbs.mdx +++ b/apps/web/content/docs/dev/plugins/breadcrumbs.mdx @@ -1,53 +1,118 @@ --- title: Breadcrumbs -description: Declare localized breadcrumbs in plugin route modules for public pages and AdminCP screens. +description: Contribute one localized crumb per plugin route and let VitNode assemble the trail for public pages and AdminCP screens. icon: Milestone --- -VitNode provides breadcrumb slots in both the AdminCP header and public site -layout. The deepest plugin route that declares a breadcrumb wins. +VitNode renders a breadcrumb trail in both the AdminCP header and the public +site layout. **Every matched route contributes one crumb**, parent to child: + +```text +Home / Catalog / Products / Laptops / MacBook Pro +``` + +So a route says what it is called and nothing else. VitNode owns the separators, +the `nav` and `aria-current` semantics, and the locale-aware link to each +route's own URL—a plugin never builds a router link, and never restates the +crumbs of the layouts above it. ## Quick start -### Add a plugin breadcrumb +### A static, translated crumb -Plugin routes export a breadcrumb component on `definePluginRoute`: +Declare a component on `definePluginRoute`. It renders inside the message +namespaces the route declared, so `useTranslations` just works: -```tsx title="plugins/blog/src/routes/overview-page.tsx" +```tsx title="plugins/catalog/src/pages/products-layout.tsx" import { definePluginRoute } from '@vitnode/core/routing' import { useTranslations } from 'use-intl' -const OverviewBreadcrumb = () => { - const t = useTranslations('@vitnode/blog') - return {t('overview')} +function ProductsBreadcrumb() { + const t = useTranslations('@acme/catalog') + + return t('breadcrumbs.products') } // [!code ++:3] export const route = definePluginRoute({ - breadcrumb: OverviewBreadcrumb, + breadcrumb: ProductsBreadcrumb, }) ``` -The crumb inherits the route's declared `namespaces`, so `useTranslations` resolves seamlessly. +### A crumb read from the loader + +The crumb is handed **its own** match's data, so a dynamic route can name itself +with what it fetched—no second request, and no guessing from the URL: + +```tsx title="plugins/catalog/src/pages/category-layout.tsx" +import type { PluginRouteBreadcrumbProps } from '@vitnode/core/routing' +import { definePluginRoute } from '@vitnode/core/routing' + +interface Category { + name: string +} + +function CategoryBreadcrumb({ + loaderData, +}: PluginRouteBreadcrumbProps) { + return loaderData.name +} + +export const route = definePluginRoute({ + load: async ({ params }) => await fetchCategory(params.categorySlug), + breadcrumb: CategoryBreadcrumb, +}) +``` -## Best Practices +`PluginRouteBreadcrumbProps` carries `loaderData`, `params` and +`search`—the same three names the loader, `head` and the page component receive. - - AdminCP labels resolve from the plugin navigation dictionary. Keep the route, - sidebar entry, and translations in the same package. +### Leaving a route out + +A route that declares no `breadcrumb` contributes nothing, and its parents' crumbs +stay exactly where they were. `false` says the same thing on purpose, which is +worth doing when a page's frame already names the screen: + +```tsx title="plugins/catalog/src/pages/products-index-page.tsx" +export const route = definePluginRoute({ + breadcrumb: false, // [!code ++] +}) +``` + +## Rules + +| Declaration | What the trail does | +| ---------------------- | ------------------------------------------------------- | +| A component | One crumb, given this route's loader data and params | +| `false` | This route is left out; its parents' crumbs remain | +| Nothing at all | The same, said by omission | +| The last crumb | Rendered as the current page, not as a link | +| Every other crumb | A locale-aware link to that route's own URL | + + + An AdminCP screen's trail is named by the sidebar this administrator can + actually see, so a plugin that adds a nav entry gets its label for free—in + every language. Keep the route, the sidebar entry and the translations in the + same package. + + + + A crumb returns text or an element. Do not render a ``, a + separator, or a link: the shell draws the trail *above* the page outlet and + needs each crumb as one item so it can put them in one navigation landmark. ## Learn More + - diff --git a/apps/web/content/docs/dev/plugins/create.mdx b/apps/web/content/docs/dev/plugins/create.mdx index 03a0fa322..c72ec4f6f 100644 --- a/apps/web/content/docs/dev/plugins/create.mdx +++ b/apps/web/content/docs/dev/plugins/create.mdx @@ -50,20 +50,18 @@ feature for the host—that explicit switch is next. ### Keep the route in the plugin -The generated manifest is the public contract. Add another record here when the -plugin needs another URL; never copy its page into `apps/web/src/routes`. - -```ts title="plugins/site-notes/src/routes/manifest.ts" -import type { PluginRouteDefinition } from '@vitnode/core/routing' - -export const routes: PluginRouteDefinition[] = [ - // [!code ++:5] - { - entry: 'routes/home-page', - id: 'home', - path: '/site-notes', - }, -] +The generated `routes.ts` is the public contract. Add another `page()` here when +the plugin needs another URL; never copy its page into `apps/web/src/routes`. + +```ts title="plugins/site-notes/src/routes.ts" +import { definePluginRoutes, lazy, page } from '@vitnode/core/routing' + +export const routes = definePluginRoutes([ + // [!code ++:3] + page('/site-notes', { + component: lazy(() => import('./pages/home-page')), + }), +]) ``` @@ -84,8 +82,8 @@ export const vitNodeConfig = buildConfig({ }) ``` -The factory already carries the plugin's manifest and message loaders. Keep host -configuration to composition; the feature stays in its package. +The factory already carries the plugin's route tree and message loaders. Keep +host configuration to composition; the feature stays in its package. @@ -111,7 +109,7 @@ npm run dev Open `http://localhost:3000/site-notes`. The page comes from the plugin, gets its own chunk, and never moves house. Tiny victory dance optional. -{/* Image prompt: Split-screen developer tutorial image. Left shows a plugin folder with manifest, locale, and route files. Right shows the resulting /site-notes page in a VitNode app. Dark theme, precise code-like labels, 1600x900. */} +{/* Image prompt: Split-screen developer tutorial image. Left shows a plugin folder with routes.ts, locale, and pages files. Right shows the resulting /site-notes page in a VitNode app. Dark theme, precise code-like labels, 1600x900. */} @@ -121,9 +119,9 @@ its own chunk, and never moves house. Tiny victory dance optional. } - title="Route manifest" - description="Add dynamic URLs, loaders, metadata, and route guards." - href="/docs/dev/plugins/route-manifest" + title="Plugin routes" + description="Add nested layouts, dynamic URLs, loaders, metadata, and guards." + href="/docs/dev/plugins/routes" /> } diff --git a/apps/web/content/docs/dev/plugins/meta.json b/apps/web/content/docs/dev/plugins/meta.json index 5d0907701..f07d9e1fc 100644 --- a/apps/web/content/docs/dev/plugins/meta.json +++ b/apps/web/content/docs/dev/plugins/meta.json @@ -3,5 +3,5 @@ "description": "Build installable VitNode plugins for pages, APIs, data, AdminCP screens, and translations", "icon": "Plug", "defaultOpen": true, - "pages": ["create", "route-manifest", "api", "admin", "breadcrumbs", "..."] + "pages": ["create", "routes", "api", "admin", "breadcrumbs", "..."] } diff --git a/apps/web/content/docs/dev/plugins/route-manifest.mdx b/apps/web/content/docs/dev/plugins/route-manifest.mdx deleted file mode 100644 index b3d662979..000000000 --- a/apps/web/content/docs/dev/plugins/route-manifest.mdx +++ /dev/null @@ -1,128 +0,0 @@ ---- -title: Route Manifest -description: Declare a plugin-owned TanStack Start route with a stable URL, loader data, metadata, and no host page files. -icon: Map ---- - -import { DatabaseIcon, LayoutDashboardIcon, RouteIcon } from 'lucide-react' -import { Tab, Tabs } from 'fumadocs-ui/components/tabs' - -Start by [creating a plugin](/docs/dev/plugins/create). A route manifest is the -plugin's promise: which URL it owns and which module renders it. The host turns -that promise into a lazy TanStack Start route—no copied page files, no drama. - -{/* Image prompt: Dark-theme developer diagram: a plugin route manifest points to a route module, then into a TanStack Start route inside the app shell. Emphasize “plugin owns feature” and “host composes”. Clean labels, 1600x900. */} - - - - -### Declare the URL in the plugin - -```ts title="plugins/site-notes/src/routes/manifest.ts" -import type { PluginRouteDefinition } from '@vitnode/core/routing' - -export const routes: PluginRouteDefinition[] = [ - // [!code ++:5] - { - entry: 'routes/note-page', - id: 'note', - path: '/notes/:slug', - }, -] -``` - -Use `:slug` for dynamic segments. VitNode converts it to TanStack Start's -internal `$slug` spelling while keeping your plugin manifest portable. - - - - -### Keep behavior beside the page - -```tsx title="plugins/site-notes/src/routes/note-page.tsx" -import type { PluginRoutePageProps } from '@vitnode/core/routing' -import { definePluginRoute } from '@vitnode/core/routing' - -interface Note { - title: string -} - -// [!code ++:8] -export const route = definePluginRoute({ - load: async ({ params }) => ({ title: `Note: ${params.slug}` }), - head: ({ loaderData }) => ({ - description: 'A note delivered by the Site notes plugin.', - title: loaderData?.title, - }), -}) - -const NotePage = ({ loaderData }: PluginRoutePageProps) => ( -
-

{loaderData.title}

-
-) - -export default NotePage -``` - -
- - -### Run the plugin route - - - -```bash tab="bun" -bun dev -``` - -```bash tab="pnpm" -pnpm dev -``` - -```bash tab="npm" -npm run dev -``` - - - -Visit `http://localhost:3000/notes/hello`. The page's code, data, and SEO stay -with the feature that needs them. A surprisingly polite route. - - -
- -## Choose the route shape - -| Need | Add to the manifest | -| -------------------- | ------------------------------------------------------ | -| Public feature page | `path: '/notes'` (the default `area` is `main`) | -| Staff screen | `area: 'admin'` and a full path such as `/admin/notes` | -| Signed-in visitor | `requires: 'authenticated'` | -| Shared plugin layout | `kind: 'layout'` plus child `parentId` values | - - - Use host routes only for shells, docs, or site-wide infrastructure. A product - page belongs in its plugin, even when it starts life as one brave little URL. - - - - } - title="Load data" - description="Use plugin loaders with cache-aware data and query state." - href="/docs/dev/data-loading" - /> - } - title="AdminCP pages" - description="Mount a plugin screen in the staff-only Admin Control Panel." - href="/docs/dev/plugins/admin" - /> - } - title="Build the plugin" - description="Generate the package before adding its next route or capability." - href="/docs/dev/plugins/create" - /> - diff --git a/apps/web/content/docs/dev/plugins/routes.mdx b/apps/web/content/docs/dev/plugins/routes.mdx new file mode 100644 index 000000000..1340aa445 --- /dev/null +++ b/apps/web/content/docs/dev/plugins/routes.mdx @@ -0,0 +1,435 @@ +--- +title: Plugin Routes +description: Declare plugin-owned URLs as a nested route tree with lazy pages, loaders, metadata, messages, and breadcrumbs. +icon: Map +--- + +import { DatabaseIcon, LayoutDashboardIcon, MilestoneIcon } from 'lucide-react' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +Start by [creating a plugin](/docs/dev/plugins/create). A plugin's `src/routes.ts` +is its promise to the app: which URLs it owns, and which module renders each one. +The host turns that promise into lazy TanStack Start routes—no copied page files, +no drama. + +{/* Image prompt: Dark-theme developer diagram: a plugin routes.ts tree (layout → index → dynamic page) on the left, each node pointing at a lazily loaded page chunk on the right, then into a TanStack Start route inside the app shell. Emphasize “plugin owns feature”, “one chunk per page”, “host composes”. Clean labels, 1600x900. */} + + + + +### Declare the URL in the plugin + +```ts title="plugins/site-notes/src/routes.ts" +import { definePluginRoutes, lazy, page } from '@vitnode/core/routing' + +export const routes = definePluginRoutes([ + // [!code ++:3] + page('/notes/:slug', { + component: lazy(() => import('./pages/note-page')), + }), +]) +``` + +Use `:slug` for dynamic segments. VitNode converts it to TanStack Start's +internal `$slug` spelling while keeping your plugin portable. + + + + +### Keep behavior beside the page + +```tsx title="plugins/site-notes/src/pages/note-page.tsx" +import type { PluginRoutePageProps } from '@vitnode/core/routing' +import { definePluginRoute } from '@vitnode/core/routing' + +interface Note { + title: string +} + +// [!code ++:8] +export const route = definePluginRoute({ + load: async ({ params }) => ({ title: `Note: ${params.slug}` }), + head: ({ loaderData }) => ({ + description: 'A note delivered by the Site notes plugin.', + title: loaderData?.title, + }), +}) + +const NotePage = ({ loaderData }: PluginRoutePageProps) => ( +
+

{loaderData.title}

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

Catalog

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

{t('index.intro')}

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

{loaderData.name}

+

{loaderData.description}

+
+ ) +} +``` + +With the catalog tree above, that renders `Catalog / Products / Laptops / +MacBook Pro`—each crumb from the route that owns it. See +[breadcrumbs](/docs/dev/plugins/breadcrumbs) for static crumbs, `breadcrumb: +false`, and how the trail is assembled. + + + Use host routes only for shells, docs, or site-wide infrastructure. A product + page belongs in its plugin, even when it starts life as one brave little URL. + + +## How the app picks this up + +The `vitnode:plugin-routes` Vite plugin reads the plugins in +`src/vitnode.config.ts`, imports each one's `routes` module in Node, validates +and flattens every tree, refuses two routes that claim one URL—including one of +the app's own—and writes a single `src/plugin-routes.gen.ts`: + +```ts title="apps/web/src/plugin-routes.gen.ts" +import { routes as pluginRoutes0 } from '@acme/catalog/routes' + +export const pluginRouteSources = [ + { pluginId: '@acme/catalog', routes: pluginRoutes0 }, +] as const satisfies readonly PluginRouteDeclarationSource[] +``` + +That is the only generated file, it names no page module, and it is committed +like any other generated artefact. Your pages stay in your package's own +`dist`, one chunk each. + + + } + title="Load data" + description="Use plugin loaders with cache-aware data and query state." + href="/docs/dev/data-loading" + /> + } + title="Breadcrumbs" + description="Contribute one crumb per route, translated or read from a loader." + href="/docs/dev/plugins/breadcrumbs" + /> + } + title="AdminCP pages" + description="Mount a plugin screen in the staff-only Admin Control Panel." + href="/docs/dev/plugins/admin" + /> + diff --git a/apps/web/content/docs/dev/routing/index.mdx b/apps/web/content/docs/dev/routing/index.mdx index 36727ce05..48280712d 100644 --- a/apps/web/content/docs/dev/routing/index.mdx +++ b/apps/web/content/docs/dev/routing/index.mdx @@ -1,15 +1,16 @@ --- title: Routing -description: Add public and AdminCP URLs through plugin route manifests, with TanStack Start loaders, metadata, and code splitting. +description: Add public and AdminCP URLs through a plugin's route tree, with TanStack Start loaders, metadata, and code splitting. icon: Route --- import { DatabaseIcon, FileTextIcon, RouteIcon } from 'lucide-react' import { Tab, Tabs } from 'fumadocs-ui/components/tabs' -VitNode uses TanStack Start, but feature routes begin in a plugin. The manifest -is plain data that the host compiles into lazy route imports, so the package owns -the page without copying files into `apps/web/src/routes`. +VitNode uses TanStack Start, but feature routes begin in a plugin. A plugin's +`routes.ts` is a small tree of declarations - a path, and the module that renders +it - so the package owns the page without copying files into +`apps/web/src/routes`. | Put it in | Use it for | Default | | ---------------- | --------------------------------------------------------------- | -------- | @@ -22,28 +23,30 @@ the page without copying files into `apps/web/src/routes`. ### Declare the plugin URL -```ts title="plugins/site-notes/src/routes/manifest.ts" -import type { PluginRouteDefinition } from '@vitnode/core/routing' - -export const routes: PluginRouteDefinition[] = [ - // [!code ++:5] - { - entry: 'routes/notes-page', - id: 'notes', - path: '/notes', - }, -] +```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-page')), + }), +]) ``` -Use `:slug` for a dynamic segment, such as `path: '/notes/:slug'`. VitNode +Use `:slug` for a dynamic segment, such as `page('/notes/:slug', …)`. VitNode maps it to TanStack Start’s internal `$slug` spelling for you. +`lazy` names the page module without importing it: the literal `import()` is +what Vite follows to give the page a chunk of its own, and it does not run until +somebody opens the route. + ### Add the page module -```tsx title="plugins/site-notes/src/routes/notes-page.tsx" +```tsx title="plugins/site-notes/src/pages/notes-page.tsx" const NotesPage = () => (

Site notes

@@ -87,7 +90,7 @@ Open `http://localhost:3000/notes`. Export `route` from the same plugin module. Declare `load` before `head` so TypeScript carries the inferred data into your metadata: -```tsx title="plugins/site-notes/src/routes/note-page.tsx" +```tsx title="plugins/site-notes/src/pages/note-page.tsx" import type { PluginRoutePageProps } from '@vitnode/core/routing' import { definePluginRoute } from '@vitnode/core/routing' @@ -126,9 +129,9 @@ export default NotePage } - title="Route manifest reference" - description="Configure areas, layouts, namespaces, guards, and dynamic paths." - href="/docs/dev/plugins/route-manifest" + title="Plugin routes reference" + description="Configure areas, layouts, messages, guards, search, and dynamic paths." + href="/docs/dev/plugins/routes" /> } diff --git a/apps/web/content/docs/dev/routing/loading-states.mdx b/apps/web/content/docs/dev/routing/loading-states.mdx index 042b31618..23a1b7cc7 100644 --- a/apps/web/content/docs/dev/routing/loading-states.mdx +++ b/apps/web/content/docs/dev/routing/loading-states.mdx @@ -14,7 +14,7 @@ While a route's loader runs or its code chunk downloads, TanStack Router display Plugin routes load dynamically. Use React `Suspense` with VitNode's pending skeletons: -```tsx title="plugins/blog/src/routes/posts-page.tsx" +```tsx title="plugins/blog/src/pages/posts-page.tsx" import { FeedPendingSkeleton } from '@vitnode/core/tanstack/pending' import React, { Suspense } from 'react' diff --git a/apps/web/content/docs/dev/routing/metadata.mdx b/apps/web/content/docs/dev/routing/metadata.mdx index d9c3fb0d5..042998e5e 100644 --- a/apps/web/content/docs/dev/routing/metadata.mdx +++ b/apps/web/content/docs/dev/routing/metadata.mdx @@ -14,7 +14,7 @@ written by a toaster. Plugins declare metadata using `definePluginRoute`. Metadata can read dynamically from `loaderData`: -```tsx title="plugins/blog/src/routes/article-page.tsx" +```tsx title="plugins/blog/src/pages/article-page.tsx" import { definePluginRoute } from '@vitnode/core/routing' export const route = definePluginRoute({ @@ -51,8 +51,8 @@ inside the plugin that owns those records. href="/docs/dev/routing" /> diff --git a/apps/web/content/docs/dev/routing/navigation.mdx b/apps/web/content/docs/dev/routing/navigation.mdx index 7a2895518..9267023d8 100644 --- a/apps/web/content/docs/dev/routing/navigation.mdx +++ b/apps/web/content/docs/dev/routing/navigation.mdx @@ -68,7 +68,7 @@ Plugin route props expose a narrow `navigate` function for filters, sorting, and pagination on the page already being viewed. It keeps the plugin independent of the host router’s entire route tree. -```tsx title="plugins/catalog/src/routes/catalog-page.tsx" +```tsx title="plugins/catalog/src/pages/catalog-page.tsx" import type { PluginRoutePageProps } from '@vitnode/core/routing' interface CatalogSearch { diff --git a/apps/web/content/docs/dev/routing/not-found.mdx b/apps/web/content/docs/dev/routing/not-found.mdx index 89be0491c..72147801c 100644 --- a/apps/web/content/docs/dev/routing/not-found.mdx +++ b/apps/web/content/docs/dev/routing/not-found.mdx @@ -31,7 +31,7 @@ export const Route = createRootRouteWithContext()({ When a requested resource (like an article slug or user ID) is not found in the database, throw `notFound()` inside the loader: -```tsx title="plugins/blog/src/routes/article-page.tsx" +```tsx title="plugins/blog/src/pages/article-page.tsx" import { notFound } from '@tanstack/react-router' import { definePluginRoute } from '@vitnode/core/routing' diff --git a/apps/web/content/docs/dev/server-functions.mdx b/apps/web/content/docs/dev/server-functions.mdx index 083e54786..6c748bae2 100644 --- a/apps/web/content/docs/dev/server-functions.mdx +++ b/apps/web/content/docs/dev/server-functions.mdx @@ -57,7 +57,7 @@ export const fetchDevices = createIsomorphicFn() Consume `fetchDevices` from the plugin route that owns the devices screen: -```tsx title="plugins/devices/src/routes/devices-page.tsx" +```tsx title="plugins/devices/src/pages/devices-page.tsx" import { definePluginRoute } from '@vitnode/core/routing' export const route = definePluginRoute({ diff --git a/apps/web/content/docs/guides/first-plugin.mdx b/apps/web/content/docs/guides/first-plugin.mdx index ee61b9958..bb38feadd 100644 --- a/apps/web/content/docs/guides/first-plugin.mdx +++ b/apps/web/content/docs/guides/first-plugin.mdx @@ -35,29 +35,28 @@ npm create vitnode-app@canary -- --plugin -The result has `routes/manifest.ts`, `routes/home-page.tsx`, `locales/en.json`, -and `config.tsx`. The CLI adds a workspace dependency but leaves activation to -you, which makes installed plugins predictable. +The result has `routes.ts`, `pages/home-page.tsx`, `locales/en.json`, and +`config.tsx`. The CLI adds a workspace dependency but leaves activation to you, +which makes installed plugins predictable. ### Claim the page URL -The manifest belongs to the plugin and is all the host needs to discover a -route: +The route tree belongs to the plugin and is all the host needs to discover a +route. `lazy` names the page module without importing it, so the page gets a +chunk of its own: -```ts title="plugins/site-notes/src/routes/manifest.ts" -import type { PluginRouteDefinition } from '@vitnode/core/routing' +```ts title="plugins/site-notes/src/routes.ts" +import { definePluginRoutes, lazy, page } from '@vitnode/core/routing' -export const routes: PluginRouteDefinition[] = [ - // [!code ++:5] - { - entry: 'routes/home-page', - id: 'home', - path: '/site-notes', - }, -] +export const routes = definePluginRoutes([ + // [!code ++:3] + page('/site-notes', { + component: lazy(() => import('./pages/home-page')), + }), +]) ``` @@ -68,7 +67,7 @@ export const routes: PluginRouteDefinition[] = [ Edit the generated route module. It stays framework-neutral and is lazily loaded by the TanStack Start host: -```tsx title="plugins/site-notes/src/routes/home-page.tsx" +```tsx title="plugins/site-notes/src/pages/home-page.tsx" import { useTranslations } from 'use-intl' const HomePage = () => { diff --git a/apps/web/content/docs/ui/data-table.mdx b/apps/web/content/docs/ui/data-table.mdx index 62fafc759..fc050ab7b 100644 --- a/apps/web/content/docs/ui/data-table.mdx +++ b/apps/web/content/docs/ui/data-table.mdx @@ -71,7 +71,7 @@ export const MembersTable = ({ data, navigate, search }: MembersTableProps) => { Give an AdminCP route to the plugin, then pass its typed `search` and `navigate` props into the table. The table remains reusable and the host stays out of it. -```tsx title="plugins/members/src/routes/admin-members-page.tsx" +```tsx title="plugins/members/src/pages/admin-members-page.tsx" import type { PluginRoutePageProps } from '@vitnode/core/routing' import { definePluginRoute } from '@vitnode/core/routing' diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs index 3248c6ea6..7a4a1423f 100644 --- a/apps/web/eslint.config.mjs +++ b/apps/web/eslint.config.mjs @@ -19,7 +19,6 @@ export default [ "dist/**", "src/routeTree.gen.ts", "src/plugin-routes.gen.ts", - "src/plugin-route-manifest.gen.ts", "src/admin-nav.gen.ts", "src/content-registry.gen.ts", "scripts/**", diff --git a/apps/web/src/docs/mdx-components.tsx b/apps/web/src/docs/mdx-components.tsx index b87a0601b..fd6e628bf 100644 --- a/apps/web/src/docs/mdx-components.tsx +++ b/apps/web/src/docs/mdx-components.tsx @@ -9,7 +9,7 @@ import { Preview } from './preview' * Fumadocs' defaults carry the bulk of it: `Callout`, `Card`, `Cards`, the code * block and its tabs, the heading anchors, the table wrapper, and - the one that * matters most here - `a`, which is the framework link. That is why a Markdown - * link like `[the manifest](/docs/dev/plugins/route-manifest)` becomes a + * link like `[plugin routes](/docs/dev/plugins/routes)` becomes a * client-side navigation that keeps the locale prefix: the anchor resolves * through `fumadocs-core/framework/tanstack`, which renders TanStack Router's * own ``, which builds its href through this app's `rewrite`. There is no diff --git a/apps/web/src/docs/moved-pages.ts b/apps/web/src/docs/moved-pages.ts index 0d3ba7d7e..d85903e17 100644 --- a/apps/web/src/docs/moved-pages.ts +++ b/apps/web/src/docs/moved-pages.ts @@ -20,6 +20,7 @@ const EXACT_MOVES: Record = { 'dev/not-found': 'dev/routing/not-found', 'dev/plugins/admin-page': 'dev/plugins/admin', 'dev/plugins/dashboard-widgets': 'dev/plugins/admin/dashboard-widgets', + 'dev/plugins/route-manifest': 'dev/plugins/routes', 'dev/tanstack': 'dev/architecture', 'dev/tanstack/admin': 'dev/plugins/admin-page', 'dev/tanstack/data-loading': 'dev/data-loading', diff --git a/apps/web/src/plugin-route-manifest.gen.ts b/apps/web/src/plugin-route-manifest.gen.ts deleted file mode 100644 index 418515d0e..000000000 --- a/apps/web/src/plugin-route-manifest.gen.ts +++ /dev/null @@ -1,115 +0,0 @@ -/* 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 the plugins configured in -// `src/vitnode.config.ts` and the route manifest each of those plugins ships. -// -// This is the *what*: which routes exist, and at which canonical VitNode path. -// Its sibling `plugin-routes.gen.ts` is the *how*: one lazy import per route -// module. Neither knows what a router is. -// -// Same plugin configuration in, same bytes out: the routes are sorted by path. - -import type { PluginRoute } from '@vitnode/core/routing' - -/** - * Every route this app's configured plugins contribute, already validated. - * - * `buildPluginRouteManifest` produced this list while the app was being built, - * which is the whole reason it is a literal here: a path that cannot be parsed, - * an entry that cannot be imported and two plugins claiming one URL have all - * already failed the build by the time this file exists. Nothing reads it to - * find out whether the routes are valid - only to find out what they are. - * - * Deliberately not `as const`: a route's `segments` is a mutable array on - * `PluginRoute`, and a frozen tuple would not satisfy it. `satisfies` alone - * still checks every field and still narrows `area` and `kind` to their unions. - */ -export const pluginRouteManifest = [ - { - area: 'admin', - entry: 'routes/admin-example-page', - id: '@vitnode/example:admin-overview', - kind: 'page', - namespaces: ['@vitnode/example.admin.overview'], - parentId: null, - path: '/admin/example', - pluginId: '@vitnode/example', - requires: null, - routeId: 'admin-overview', - searchEntry: null, - segments: [{ kind: 'static', value: 'admin' }, { kind: 'static', value: 'example' }], - }, - { - area: 'main', - entry: 'routes/example-page', - id: '@vitnode/example:example-page', - kind: 'page', - namespaces: [], - parentId: null, - path: '/example', - pluginId: '@vitnode/example', - requires: null, - routeId: 'example-page', - searchEntry: null, - segments: [{ kind: 'static', value: 'example' }], - }, - { - area: 'main', - entry: 'routes/browse-page', - id: '@vitnode/example:browse', - kind: 'page', - namespaces: ['@vitnode/example.browse'], - parentId: null, - path: '/example/browse', - pluginId: '@vitnode/example', - requires: null, - routeId: 'browse', - searchEntry: 'routes/browse-page.search', - segments: [{ kind: 'static', value: 'example' }, { kind: 'static', value: 'browse' }], - }, - { - area: 'main', - entry: 'routes/guide-layout', - id: '@vitnode/example:guide', - kind: 'layout', - namespaces: ['@vitnode/example.guide'], - parentId: null, - path: '/example/guide', - pluginId: '@vitnode/example', - requires: null, - routeId: 'guide', - searchEntry: null, - segments: [{ kind: 'static', value: 'example' }, { kind: 'static', value: 'guide' }], - }, - { - area: 'main', - entry: 'routes/guide-index-page', - id: '@vitnode/example:guide-index', - kind: 'page', - namespaces: [], - parentId: '@vitnode/example:guide', - path: '/example/guide', - pluginId: '@vitnode/example', - requires: null, - routeId: 'guide-index', - searchEntry: null, - segments: [{ kind: 'static', value: 'example' }, { kind: 'static', value: 'guide' }], - }, - { - area: 'main', - entry: 'routes/guide-topic-page', - id: '@vitnode/example:guide-topic', - kind: 'page', - namespaces: [], - parentId: '@vitnode/example:guide', - path: '/example/guide/:topic', - pluginId: '@vitnode/example', - requires: null, - routeId: 'guide-topic', - searchEntry: null, - segments: [{ kind: 'static', value: 'example' }, { kind: 'static', value: 'guide' }, { kind: 'param', name: 'topic' }], - }, -] satisfies readonly PluginRoute[] diff --git a/apps/web/src/plugin-routes.gen.ts b/apps/web/src/plugin-routes.gen.ts index e5a9a6de9..d78a83733 100644 --- a/apps/web/src/plugin-routes.gen.ts +++ b/apps/web/src/plugin-routes.gen.ts @@ -3,116 +3,37 @@ // 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 two inputs and nothing else: the plugins -// configured in `src/vitnode.config.ts`, and the route manifest each of those -// plugins ships. Nothing here is discovered at runtime - the browser is handed -// this module, never a filesystem. +// `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. // -// Same plugin configuration in, same bytes out: the entries are sorted by key. +// 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. +// +// Same plugin configuration in, same bytes out: the plugins are sorted by id. -import type { - PluginRouteModuleRegistry, - PluginRouteSearchRegistry, - ResolvedPluginRouteModule, -} from '@vitnode/core/framework/plugin-routes' +import type { PluginRouteDeclarationSource } from '@vitnode/core/routing' -import { validateSearch as pluginRouteSearch0 } from '@vitnode/example/routes/browse-page.search' -/** - * Every configured plugin's route modules, keyed by `:`. - * - * The specifiers below are literal, which is the whole point of generating this - * file: Vite resolves them at build time and Rollup gives each module its own - * chunk, fetched when a loader is first called. No route component is in the - * initial bundle, and none of them is reached through a computed string - the - * browser never asks what is installed. - * - * `satisfies` rather than a type annotation, deliberately: it checks the shape - * while keeping both the literal keys and each module's real export types, so a - * consumer's `await load()` is typed by the module it loaded. - */ -export const pluginRouteModules = { - '@vitnode/example:admin-overview': () => import('@vitnode/example/routes/admin-example-page'), - '@vitnode/example:browse': () => import('@vitnode/example/routes/browse-page'), - '@vitnode/example:example-page': () => import('@vitnode/example/routes/example-page'), - '@vitnode/example:guide': () => import('@vitnode/example/routes/guide-layout'), - '@vitnode/example:guide-index': () => import('@vitnode/example/routes/guide-index-page'), - '@vitnode/example:guide-topic': () => import('@vitnode/example/routes/guide-topic-page'), -} satisfies PluginRouteModuleRegistry - -/** Every key in {@link pluginRouteModules}, as a union. */ -export type PluginRouteKey = keyof typeof pluginRouteModules +import { routes as pluginRoutes0 } from '@vitnode/example/routes' /** - * The same route modules as plain data, in the same order. + * Every configured plugin's route tree. + * + * Handed to `pluginRouteSpecs` in the app's router, which flattens and validates + * it with the same functions the build used - so the tree the router mounts is + * provably the tree the build checked, and a plugin removed from the config + * takes its routes with it in one step. * - * An object literal carries nothing beyond its keys, so the plugin id, the route - * id and the specifier are repeated here for whatever builds the router: it can - * walk this array, join each entry to its plugin's own route manifest by - * `pluginId` and `routeId`, and look the loader up by `key`. + * `satisfies` rather than a type annotation, deliberately: it checks each + * plugin's `routes` export really is a `definePluginRoutes` tree, naming the + * plugin here rather than failing in a browser. */ -export const pluginRouteEntries = [ - { - entry: 'routes/admin-example-page', - key: '@vitnode/example:admin-overview', - pluginId: '@vitnode/example', - routeId: 'admin-overview', - specifier: '@vitnode/example/routes/admin-example-page', - }, - { - entry: 'routes/browse-page', - key: '@vitnode/example:browse', - pluginId: '@vitnode/example', - routeId: 'browse', - specifier: '@vitnode/example/routes/browse-page', - }, - { - entry: 'routes/example-page', - key: '@vitnode/example:example-page', - pluginId: '@vitnode/example', - routeId: 'example-page', - specifier: '@vitnode/example/routes/example-page', - }, - { - entry: 'routes/guide-layout', - key: '@vitnode/example:guide', - pluginId: '@vitnode/example', - routeId: 'guide', - specifier: '@vitnode/example/routes/guide-layout', - }, - { - entry: 'routes/guide-index-page', - key: '@vitnode/example:guide-index', - pluginId: '@vitnode/example', - routeId: 'guide-index', - specifier: '@vitnode/example/routes/guide-index-page', - }, +export const pluginRouteSources = [ { - entry: 'routes/guide-topic-page', - key: '@vitnode/example:guide-topic', pluginId: '@vitnode/example', - routeId: 'guide-topic', - specifier: '@vitnode/example/routes/guide-topic-page', + routes: pluginRoutes0, }, -] as const satisfies readonly ResolvedPluginRouteModule[] - -/** - * The route search schemas this app imports **eagerly**, keyed the same way. - * - * The one thing about a plugin route that may not be lazy. A router's - * `validateSearch` runs during path matching, before any chunk is fetched, so a - * route that needs a real one - a paginated table whose `?page=999` has to be - * clamped, a filter whose links must be typed - names a second module and the - * build imports it statically, above. - * - * Sparse by design: a route is here only if its manifest entry declares a - * `searchEntry`, and most do not. A route with no key here gets no - * `validateSearch` and normalises its query string in the loader instead, which - * is what every plugin route did before this registry existed. - * - * These modules are in the initial bundle. That is the cost, it is why the - * contract on them is "the schema, not the screen", and it is why this list is - * worth reading in a diff. - */ -export const pluginRouteSearchSchemas = { - '@vitnode/example:browse': pluginRouteSearch0, -} satisfies PluginRouteSearchRegistry +] as const satisfies readonly PluginRouteDeclarationSource[] diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index c3f082577..f4374cebb 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -39,11 +39,7 @@ import './lib/auth' import './lib/admin-auth' import { createLocaleRewrite, localeRouting } from './lib/i18n/runtime' import { pageHead } from './lib/page-head' -import { pluginRouteManifest } from './plugin-route-manifest.gen' -import { - pluginRouteModules, - pluginRouteSearchSchemas, -} from './plugin-routes.gen' +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' @@ -71,16 +67,16 @@ const loadContentRegistry = async () => * generated tree is a module singleton. `withPluginRoutes` is idempotent anyway; * doing it once is simply where it belongs. * - * The plugin half comes from two generated files and is joined by route id. No - * plugin page is copied into `src/routes`, no route path is written by hand, and - * nothing here knows which plugins are installed - see - * `@vitnode/core/tanstack/plugin-routes`. + * The plugin half comes from one generated file: a static import of each + * configured plugin's own route tree. No plugin page is copied into + * `src/routes`, no route path is written by hand, and nothing here knows which + * plugins are installed - see `@vitnode/core/tanstack/plugin-routes`. * - * `pluginRouteSearchSchemas` is the third argument and the one part of a plugin - * route that is not lazy: a router's `validateSearch` runs during path matching, - * before any chunk is fetched, so a route that needs a real one is imported - * statically by the generated registry. Usually empty. See - * `PluginRouteDefinition.searchEntry`. + * A page is reached only through the literal `lazy(() => import(...))` its route + * declared, so every one of them is a chunk of its own. The one part of a plugin + * route that is not lazy is a `search` schema: a router's `validateSearch` runs + * during path matching, before any chunk is fetched, so it lives in the tree + * rather than in the page. * * `mountUnder` names one route per shell, which is the whole of what "a plugin * route renders in the application shell" amounts to here. A plugin declares @@ -93,7 +89,7 @@ const loadContentRegistry = async () => * the area declaration already described. * * Neither shell changes a path: both are pathless, so `/example` stays - * `/example` and an admin plugin route's `/admin/…` is the path its manifest + * `/example` and an admin plugin route's `/admin/…` is the path its own route * spells out in full. An area VitNode knows and this app has not named here * fails the composition rather than being mounted under the other one. * @@ -115,9 +111,10 @@ const loadContentRegistry = async () => * existed - one `createFileRoute` per screen, every one of them pure wiring * around something imported from `@vitnode/core` - so an app carried a copy of * VitNode's own routing table and core adding a screen meant an edit here. They - * are code-based rather than manifest-declared because they need the router's - * full option set: a real `validateSearch` that clamps `?page=999` before - * anything renders, and a splat path the manifest's grammar does not represent. + * are code-based rather than declared as plugin routes because they need the + * router's full option set: a real `validateSearch` that clamps `?page=999` + * before anything renders, and a splat path a plugin route path does not + * represent. * * `pageHead` is this app's own `createRouteHead(metadata)` binding, handed over * because a package cannot know the site's name: a plugin page's `` goes @@ -127,18 +124,10 @@ const loadContentRegistry = async () => const routeTree = withCoreRootRoutes( withCoreAdminRoutes( withCoreMainRoutes( - withPluginRoutes( - fileRouteTree, - pluginRouteSpecs( - pluginRouteManifest, - pluginRouteModules, - pluginRouteSearchSchemas, - ), - { - mountUnder: { admin: adminShellRoute, main: mainShellRoute }, - pageHead, - }, - ), + withPluginRoutes(fileRouteTree, pluginRouteSpecs(pluginRouteSources), { + mountUnder: { admin: adminShellRoute, main: mainShellRoute }, + pageHead, + }), { mountUnder: mainShellRoute, pageHead }, ), { loadContentRegistry, mountUnder: adminShellRoute, pageHead }, diff --git a/apps/web/src/routes/_docs/docs.$.tsx b/apps/web/src/routes/_docs/docs.$.tsx index 4f7e23f56..10e15d801 100644 --- a/apps/web/src/routes/_docs/docs.$.tsx +++ b/apps/web/src/routes/_docs/docs.$.tsx @@ -83,6 +83,7 @@ export const Route = createFileRoute('/_docs/docs/$')({ const moved = movedDocsSlug(params._splat ?? '') if (moved) { + // eslint-disable-next-line @typescript-eslint/only-throw-error throw redirect({ params: { _splat: moved }, statusCode: 301, diff --git a/packages/config/eslint.config.mjs b/packages/config/eslint.config.mjs index 7a6c0a024..b2210f0f9 100644 --- a/packages/config/eslint.config.mjs +++ b/packages/config/eslint.config.mjs @@ -106,4 +106,19 @@ export default [ ], }, }, + { + // A plugin's route tree - and only that file - is one + // `lazy(() => import("./pages/my-page"))` per route, which is VitNode's + // documented API: the callback is *stored*, never called here, so + // `promise-function-async` would ask every plugin author to write + // `async () => await import(...)` for a promise nobody in the file awaits. + // + // `**/src/routes.ts` rather than `**/routes.ts`, because the parent + // directory is what makes it a route tree: a plugin's is `src/routes.ts`, + // which is what its `<plugin>/routes` export subpath resolves to. Any other + // `routes.ts` - `src/content/server/routes.ts`, an API module's - keeps the + // rule, and should. + files: ["**/src/routes.ts", "**/src/routes.tsx"], + rules: { "@typescript-eslint/promise-function-async": "off" }, + }, ]; diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/components/admin-shell.tsx b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/components/admin-shell.tsx index 34266bb15..81c932fef 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/components/admin-shell.tsx +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/components/admin-shell.tsx @@ -47,7 +47,7 @@ import { useAppNavigate } from "#/lib/navigation"; * live under that plugin's own id and the shell would otherwise render them as * dotted identifiers. `_admin`'s loader warms the same list. * - * No navigation is derived from the route manifest, in either direction: the + * No navigation is derived from a plugin's route tree, in either direction: the * navigation model is complete regardless of which screen a click lands on, and * a nav entry is a product decision rather than a consequence of the route tree. */ 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 602c4567d..6b30e32a9 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 @@ -39,11 +39,7 @@ import "./lib/auth"; import "./lib/admin-auth"; import { createLocaleRewrite, localeRouting } from "./lib/i18n/runtime"; import { pageHead } from "./lib/page-head"; -import { pluginRouteManifest } from "./plugin-route-manifest.gen"; -import { - pluginRouteModules, - pluginRouteSearchSchemas, -} from "./plugin-routes.gen"; +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"; @@ -71,16 +67,16 @@ const loadContentRegistry = async () => * generated tree is a module singleton. `withPluginRoutes` is idempotent anyway; * doing it once is simply where it belongs. * - * The plugin half comes from two generated files and is joined by route id. No - * plugin page is copied into `src/routes`, no route path is written by hand, and - * nothing here knows which plugins are installed - see - * `@vitnode/core/tanstack/plugin-routes`. + * The plugin half comes from one generated file: a static import of each + * configured plugin's own route tree. No plugin page is copied into + * `src/routes`, no route path is written by hand, and nothing here knows which + * plugins are installed - see `@vitnode/core/tanstack/plugin-routes`. * - * `pluginRouteSearchSchemas` is the third argument and the one part of a plugin - * route that is not lazy: a router's `validateSearch` runs during path matching, - * before any chunk is fetched, so a route that needs a real one is imported - * statically by the generated registry. Usually empty. See - * `PluginRouteDefinition.searchEntry`. + * A page is reached only through the literal `lazy(() => import(...))` its route + * declared, so every one of them is a chunk of its own. The one part of a plugin + * route that is not lazy is a `search` schema: a router's `validateSearch` runs + * during path matching, before any chunk is fetched, so it lives in the tree + * rather than in the page. * * `mountUnder` names one route per shell, which is the whole of what "a plugin * route renders in the application shell" amounts to here. A plugin declares @@ -93,7 +89,7 @@ const loadContentRegistry = async () => * the area declaration already described. * * Neither shell changes a path: both are pathless, so `/example` stays - * `/example` and an admin plugin route's `/admin/…` is the path its manifest + * `/example` and an admin plugin route's `/admin/…` is the path its own route * spells out in full. An area VitNode knows and this app has not named here * fails the composition rather than being mounted under the other one. * @@ -110,10 +106,10 @@ const loadContentRegistry = async () => * `@vitnode/core/tanstack/routes` existed, every one of them pure wiring around * something imported from the package - so an app carried a copy of VitNode's own * routing table and core adding a screen meant an edit here. They are code-based - * rather than manifest-declared because they need the router's full option set: a - * real `validateSearch` that clamps `?page=999` before anything renders, a - * `beforeLoad` guard that runs before any chunk is fetched, and a splat path the - * manifest's grammar does not represent. + * rather than declared as plugin routes because they need the router's full + * option set: a real `validateSearch` that clamps `?page=999` before anything + * renders, a `beforeLoad` guard that runs before any chunk is fetched, and a + * splat path a plugin route path does not represent. * * `localeRouting` goes to the last of the three because a sign-in navigates to a * path a *visitor* supplied: the route tree carries no locale, so the prefix has @@ -130,11 +126,7 @@ const routeTree = withCoreRootRoutes( withCoreMainRoutes( withPluginRoutes( fileRouteTree, - pluginRouteSpecs( - pluginRouteManifest, - pluginRouteModules, - pluginRouteSearchSchemas, - ), + pluginRouteSpecs(pluginRouteSources), { mountUnder: { admin: adminShellRoute, main: mainShellRoute }, pageHead, diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.config.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.config.ts index d84a7bf70..3b17f439e 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.config.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.config.ts @@ -35,10 +35,9 @@ import { vitNodeShellConfig } from "./vitnode.shell.config"; * explain why they are generated rather than read from here. * * A plugin's *pages* need nothing in this file at all. It declares them in its - * own `src/routes/manifest.ts`, and this app's Vite build compiles them into - * `src/plugin-route-manifest.gen.ts` and `src/plugin-routes.gen.ts`, which - * `src/router.tsx` mounts under the shell the plugin's `area` names. No page is - * ever copied into `src/routes`. + * own `src/routes.ts`, and this app's Vite build compiles them into + * `src/plugin-routes.gen.ts`, which `src/router.tsx` mounts under the shell the + * plugin's `area` names. No page is ever copied into `src/routes`. * * Server-side only, and deliberately so - see `vitnode.shell.config.ts`. * `src/server/messages.server.ts` is the only importer, and it carries the diff --git a/packages/create-vitnode-app/src/create/create-package-json.ts b/packages/create-vitnode-app/src/create/create-package-json.ts index 8cfca1f45..57a0b09b9 100644 --- a/packages/create-vitnode-app/src/create/create-package-json.ts +++ b/packages/create-vitnode-app/src/create/create-package-json.ts @@ -174,7 +174,7 @@ export const singleAppScripts = ( * is also what keeps schema lifecycle out of the frontend: a root `turbo * db:prepare` resolves to the API package, never to this one. * - * Its own generated artefacts - the plugin route manifest, the module registry, + * Its own generated artefacts - the plugin route registry, * the AdminCP navigation and content projections - are written by the Vite * plugin on every `vite dev` and `vite build`, so there is nothing to prepare * here either. diff --git a/packages/create-vitnode-app/src/create/no-plugin-page-duplication.test.ts b/packages/create-vitnode-app/src/create/no-plugin-page-duplication.test.ts index 09b35243a..41a9c8010 100644 --- a/packages/create-vitnode-app/src/create/no-plugin-page-duplication.test.ts +++ b/packages/create-vitnode-app/src/create/no-plugin-page-duplication.test.ts @@ -19,15 +19,15 @@ import { * * create-vitnode-app create-vitnode-app --plugin * ────────────────────── ────────────────────────── - * apps/web/src/routes/** plugins/<name>/src/routes/manifest.ts + * apps/web/src/routes/** plugins/<name>/src/routes.ts * the application's own pages the routes this plugin contributes - * apps/web/src/router.tsx plugins/<name>/src/routes/*.tsx + * apps/web/src/router.tsx plugins/<name>/src/pages/*.tsx * withPluginRoutes(...) the pages themselves * apps/web/src/vitnode.config.ts plugins/<name>/src/config.tsx * plugins: [] * * The line between them is the whole subject of this file. A plugin author writes - * a route module and declares it in their manifest; the app's Vite build compiles + * a route module and names it in their route tree; the app's Vite build compiles * that into two generated registries and `withPluginRoutes` mounts them. There is * no third step in which the page becomes a file in the application, and a * scaffold that produced one would be teaching the deleted architecture to every @@ -99,14 +99,14 @@ const DEFAULT_PLUGIN_NAME = "my-vitnode-plugin"; describe("the scaffolded plugin", () => { const scaffold = pluginRouteScaffold(DEFAULT_PLUGIN_NAME); const slug = routeSlugFor(DEFAULT_PLUGIN_NAME); - const manifest = scaffold["src/routes/manifest.ts"]; - - it("declares one route, in its own manifest", () => { - expect(manifest).toBeDefined(); - expect(manifest).toContain(`path: "/${slug}"`); - expect(manifest.match(/path: "/g)).toHaveLength(1); - expect(manifest).toContain('entry: "routes/home-page"'); - expect(Object.keys(scaffold)).toContain("src/routes/home-page.tsx"); + const routes = scaffold["src/routes.ts"]; + + it("declares one route, in its own route tree", () => { + expect(routes).toBeDefined(); + expect(routes).toContain(`page("/${slug}", {`); + expect(routes.match(/^ {2}page\("/gm)).toHaveLength(1); + expect(routes).toContain('lazy(() => import("./pages/home-page"))'); + expect(Object.keys(scaffold)).toContain("src/pages/home-page.tsx"); }); /** @@ -142,6 +142,12 @@ describe("the scaffolded plugin", () => { }, ); + /** And no flat route manifest, which is the API this replaced. */ + it("scaffolds no routes/manifest.ts", () => { + expect(Object.keys(scaffold)).not.toContain("src/routes/manifest.ts"); + expect(routes).not.toContain("entry:"); + }); + /** * The page is framework-neutral, which is what lets it stay in the plugin. * @@ -151,7 +157,7 @@ describe("the scaffolded plugin", () => { * by whichever app installed it. */ it("scaffolds a plain component, not a framework route file", () => { - const page = scaffold["src/routes/home-page.tsx"]; + const page = scaffold["src/pages/home-page.tsx"]; expect(page).toContain("export default"); expect(page).not.toContain("createFileRoute"); @@ -315,17 +321,17 @@ describe("the generated application", () => { * starter already mounts whatever a plugin declares. * * A plugin author adds their package to `plugins` and their route to their own - * manifest, and the page is served. No file in `src/routes` is created, edited - * or copied - which is exactly what the authoring guide promises, said here as - * a property of the bytes a new project starts from. + * `routes.ts`, and the page is served. No file in `src/routes` is created, + * edited or copied - which is exactly what the authoring guide promises, said + * here as a property of the bytes a new project starts from. */ - it("mounts plugin routes from the generated registries", () => { + it("mounts plugin routes from the generated registry", () => { const router = withoutComments(readTemplate("root/src/router.tsx")); expect(router).toContain("withPluginRoutes"); expect(router).toContain("pluginRouteSpecs"); - expect(router).toContain("./plugin-route-manifest.gen"); expect(router).toContain("./plugin-routes.gen"); + expect(router).not.toContain("./plugin-route-manifest.gen"); // Both shells, so a plugin declaring either area is composed rather than // refused - and an admin plugin page needs no `_admin` file of its own. expect(router).toMatch(/mountUnder:\s*\{[^}]*\badmin:/); diff --git a/packages/create-vitnode-app/src/create/scaffold-invariants.test.ts b/packages/create-vitnode-app/src/create/scaffold-invariants.test.ts index e78df4d12..23487974c 100644 --- a/packages/create-vitnode-app/src/create/scaffold-invariants.test.ts +++ b/packages/create-vitnode-app/src/create/scaffold-invariants.test.ts @@ -327,15 +327,13 @@ describe("the generated plugin", () => { * A plugin declares its routes; it does not ship a directory of pages for * something else to copy. */ - it("scaffolds a route manifest rather than route directories", async () => { + it("scaffolds a route tree rather than route directories", async () => { const { pluginRouteScaffold } = await import("../plugin/create/route-templates.js"); const scaffold = pluginRouteScaffold("@acme/blog"); - expect(Object.keys(scaffold)).toContain("src/routes/manifest.ts"); - expect(scaffold["src/routes/manifest.ts"]).toContain( - "PluginRouteDefinition", - ); + expect(Object.keys(scaffold)).toContain("src/routes.ts"); + expect(scaffold["src/routes.ts"]).toContain("definePluginRoutes"); for (const legacy of ["main", "admin", "blank", "breadcrumb"]) { expect(Object.keys(scaffold)).not.toContain( diff --git a/packages/create-vitnode-app/src/plugin/create/create-plugin-vitnode.ts b/packages/create-vitnode-app/src/plugin/create/create-plugin-vitnode.ts index 0de15cc72..a2f13471c 100644 --- a/packages/create-vitnode-app/src/plugin/create/create-plugin-vitnode.ts +++ b/packages/create-vitnode-app/src/plugin/create/create-plugin-vitnode.ts @@ -19,9 +19,9 @@ import { pluginRouteScaffold } from "./route-templates.js"; * registers both. * * Written rather than copied, because every one of these files names the plugin - * - the manifest names the module it imports, the page names the message - * namespace it renders, the config names the id both are keyed by - and a static - * template under `copy-of-vitnode-plugin/` cannot. What each file contains is + * - the route tree names the URL it claims, the page names the message namespace + * it renders, the config names the id both are keyed by - and a static template + * under `copy-of-vitnode-plugin/` cannot. What each file contains is * `route-templates.ts`, which is pure and asserted byte for byte; this is only * the part that has a disk. * 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 85d9faa63..e826afa47 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 @@ -4,9 +4,9 @@ import { pluginConfigTemplate, pluginMessagesTemplate, pluginPackageExports, - pluginRouteManifestTemplate, pluginRouteModuleTemplate, pluginRouteScaffold, + pluginRoutesTemplate, pluginVariableName, routeSlugFor, } from "./route-templates.js"; @@ -18,8 +18,8 @@ import { * the bytes a new plugin starts with are the ones VitNode's build can read, and * that is a string comparison. The cross-file assertions matter more than the * snapshots - a template that is merely *different* is a diff to approve, while - * a manifest whose `entry` names a file the scaffold does not write is a plugin - * that fails its first `vite build` with a resolution error. + * a route tree whose `lazy()` names a file the scaffold does not write is a + * plugin whose first page 404s at the first navigation. */ describe("routeSlugFor", () => { it("drops a scope, which a route path may not contain", () => { @@ -56,31 +56,43 @@ describe("pluginVariableName", () => { }); }); -describe("the generated route manifest", () => { - it("declares one route, in the canonical shape", () => { - const manifest = pluginRouteManifestTemplate("@acme/blog"); +describe("the generated route tree", () => { + it("declares one page, in the canonical shape", () => { + const routes = pluginRoutesTemplate("@acme/blog"); - expect(manifest).toContain( - 'import type { PluginRouteDefinition } from "@vitnode/core/routing";', + expect(routes).toContain( + 'import { definePluginRoutes, lazy, page } from "@vitnode/core/routing";', + ); + expect(routes).toContain("export const routes = definePluginRoutes(["); + expect(routes).toContain('page("/blog", {'); + expect(routes).toContain( + 'component: lazy(() => import("./pages/home-page")),', ); - expect(manifest).toContain("export const routes: PluginRouteDefinition[]"); - expect(manifest).toContain('entry: "routes/home-page",'); - expect(manifest).toContain('id: "home",'); - expect(manifest).toContain('path: "/blog",'); }); - it("declares an entry with no file extension", () => { - // An entry is a package export subpath and the export map adds the - // extension. `routes/home-page.tsx` would resolve to - // `dist/src/routes/home-page.tsx.js` and fail naming a file nobody wrote. - expect(pluginRouteManifestTemplate("blog")).not.toMatch( - /entry: "[^"]*\.[cm]?[jt]sx?"/, + /** + * The page is named by a *lazy* import, and that is the whole of what keeps it + * out of the initial bundle. A scaffold that imported the component at the top + * of this file would work, ship the page to every visitor of every other page, + * and teach the pattern to everybody who read it. + */ + it("never imports the page it names", () => { + const routes = pluginRoutesTemplate("blog"); + const imports = [...routes.matchAll(/^import .*? from "([^"]+)";$/gm)].map( + match => match[1], ); + + expect(imports).toEqual(["@vitnode/core/routing"]); + expect(routes).toContain("lazy(() => import("); + }); + + it("declares no route id, which VitNode derives", () => { + expect(pluginRoutesTemplate("blog")).not.toMatch(/^\s*id:/m); }); it("never writes a framework's path spelling", () => { - const manifest = pluginRouteManifestTemplate("blog"); - const declared = /path: "([^"]+)"/.exec(manifest)?.[1]; + const routes = pluginRoutesTemplate("blog"); + const declared = /page\("([^"]+)"/.exec(routes)?.[1]; expect(declared).toBe("/blog"); expect(declared).not.toMatch(/[[\]$]/); @@ -142,10 +154,10 @@ describe("the generated messages", () => { }); describe("the generated config", () => { - it("registers the manifest's own array, not a second copy", () => { + it("registers the tree's own array, not a second copy", () => { const config = pluginConfigTemplate("@acme/blog"); - expect(config).toContain('import { routes } from "./routes/manifest";'); + expect(config).toContain('import { routes } from "./routes";'); expect(config).toContain("routes,"); }); @@ -175,9 +187,8 @@ describe("the generated package exports", () => { return wildcard.import.replace("*", subpath); }; - it("resolves a route entry to build output", () => { - expect(resolve("routes/manifest")).toBe("./dist/src/routes/manifest.js"); - expect(resolve("routes/home-page")).toBe("./dist/src/routes/home-page.js"); + it("resolves the plugin's routes module to build output", () => { + expect(resolve("routes")).toBe("./dist/src/routes.js"); }); it("resolves the plugin's locales, which the wildcard cannot", () => { @@ -210,19 +221,19 @@ describe("the generated package exports", () => { }); describe("the scaffold as a whole", () => { - it("writes a file for every entry its manifest declares", () => { - // The failure this prevents: a manifest whose `entry` names a module the - // scaffold does not create, which fails the app's build at resolution time - // with a message about the app rather than about the plugin. + it("writes a file for every module its route tree names", () => { + // The failure this prevents: a `lazy()` naming a module the scaffold does + // not create, which is a chunk request that 404s at the first navigation + // rather than anything a build would notice. const files = pluginRouteScaffold("@acme/blog"); - const manifest = files["src/routes/manifest.ts"]; - const entries = [...manifest.matchAll(/entry: "([^"]+)"/g)].map( + const routes = files["src/routes.ts"]; + const named = [...routes.matchAll(/import\("\.\/([^"]+)"\)/g)].map( match => match[1], ); - expect(entries).not.toEqual([]); - entries.forEach(entry => { - expect(Object.keys(files)).toContain(`src/${entry}.tsx`); + expect(named).not.toEqual([]); + named.forEach(module => { + expect(Object.keys(files)).toContain(`src/${module}.tsx`); }); }); 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 6753f508b..a2e6695a9 100644 --- a/packages/create-vitnode-app/src/plugin/create/route-templates.ts +++ b/packages/create-vitnode-app/src/plugin/create/route-templates.ts @@ -2,12 +2,12 @@ * The source a scaffolded plugin starts with, as pure functions of its name. * * Templates as strings rather than as files under `copy-of-vitnode-plugin/`, - * and the reason is that every one of them has to say the plugin's own name: a - * route manifest names the module it imports, a page names the message namespace - * it renders, and `config.tsx` names the plugin id all three are keyed by. A - * static file cannot, so the scaffold used to copy no `src/` at all - which left - * a new plugin with a `global.d.ts` importing `./src/locales/en.json` that did - * not exist, and nothing to point `vitnode dev` at. + * and the reason is that every one of them has to say the plugin's own name: the + * route tree names the URL it claims, a page names the message namespace it + * renders, and `config.tsx` names the plugin id all three are keyed by. A static + * file cannot, so the scaffold used to copy no `src/` at all - which left a new + * plugin with a `global.d.ts` importing `./src/locales/en.json` that did not + * exist, and nothing to point `vitnode dev` at. * * Pure, and separated from the writing, so what a plugin author is handed can be * asserted byte for byte without a filesystem. `route-templates.test.ts` is that @@ -39,64 +39,60 @@ export const routeSlugFor = (pluginName: string): string => : pluginName; /** - * `src/routes/manifest.ts` - the file an app reads to find out this plugin has - * a page. + * `src/routes.ts` - the file an app reads to find out this plugin has a page. * - * One route, with the three fields that have no default. Everything else on a - * `PluginRouteDefinition` - the area, the kind, the parent, the namespaces, the - * requirement - is left out rather than written with its default value, so what - * a new plugin's manifest shows is the minimum rather than a form to fill in. + * One `page()`, with the two fields that have no default. Everything else on a + * route - the area, the messages, the requirement, the search schema - is left + * out rather than written with its default value, so what a new plugin's tree + * shows is the minimum rather than a form to fill in. */ -export const pluginRouteManifestTemplate = (pluginName: string): string => { +export const pluginRoutesTemplate = (pluginName: string): string => { const slug = routeSlugFor(pluginName); - return `import type { PluginRouteDefinition } from "@vitnode/core/routing"; + return `import { definePluginRoutes, lazy, page } from "@vitnode/core/routing"; /** * The routes this plugin contributes to whatever app installs it. * - * Plain data: an \`entry\` is a *package export subpath*, so - * \`"routes/home-page"\` is imported as \`"${pluginName}/routes/home-page"\` and - * resolves through this package's export map to its build output. Nothing here - * imports a router and nothing here imports a page, so an app can read this list - * at build time, in Node, without loading a single React component. + * Browser-safe data: a path, and the module that renders it. \`lazy\` keeps that + * \`import()\` a literal the bundler can follow *without running it*, so your page + * gets a chunk of its own and is fetched when somebody navigates to it - not + * before. Never import a page into this file: a component named here is in the + * initial bundle of every page on the site, which is why VitNode refuses one. * - * Your page is never copied into the application. The app generates a literal - * \`import()\` for it, the bundler gives it its own chunk, and it stays in this - * package. + * Your page is never copied into the application either. The app holds one static + * import of this tree, and nothing else. * - * Add a route by adding a record. \`id\` is a stable name for the page - name it - * after the page, not the URL, because it survives a path change. \`path\` is the - * public URL, written in VitNode's own spelling: a dynamic segment is \`:id\`, - * never Next's \`[id]\` and never TanStack's \`$id\`. + * Add a route by adding a \`page()\`. \`path\` is the public URL, written in + * VitNode's own spelling: a dynamic segment is \`:id\`, never Next's \`[id]\` and + * never TanStack's \`$id\`. To nest pages inside a shared frame, wrap them in a + * \`layout()\` and give each child a path relative to it. */ -export const routes: PluginRouteDefinition[] = [ - { - entry: "routes/home-page", - id: "home", - path: "/${slug}", - }, -]; +export const routes = definePluginRoutes([ + page("/${slug}", { + component: lazy(() => import("./pages/home-page")), + }), +]); `; }; /** - * `src/routes/home-page.tsx` - the page itself. + * `src/pages/home-page.tsx` - the page itself. * * Deliberately the *minimum* module: a default export and nothing else. A route - * module may also export a \`route\` for its loader, metadata and breadcrumb, and + * module may also export a `route` for its loader, metadata and breadcrumb, and * the comment says where to read about that rather than scaffolding an empty one - * - a generated \`route = definePluginRoute({})\` would be a thing to delete. + * - a generated `route = definePluginRoute({})` would be a thing to delete. */ export const pluginRouteModuleTemplate = (pluginName: string): string => `import { useTranslations } from "use-intl"; /** - * The page \`routes/manifest.ts\` declares. + * The page \`routes.ts\` declares. * * Keep it framework-neutral. This module is compiled into the package's own * \`dist\` and imported by whichever app installed the plugin, so anything from - * \`next/*\`, \`next-intl\` or a router pins the plugin to one kind of host. + * a router or a host-bound i18n package pins the plugin to one kind of host. * \`use-intl\` - which is what VitNode itself renders through - and plain JSX are * pinned to neither. * @@ -207,24 +203,23 @@ export const pluginVariableName = (pluginName: string): string => { /** * `src/config.tsx` - what an application registers. * - * The routes and the messages, and nothing else. `routes` is the same array - * `routes/manifest.ts` exports, handed on unchanged: an app on Vite reads that - * file directly at build time and an app that registers the plugin the ordinary - * way reads it through here, so the two paths cannot describe different routes. + * The routes and the messages, and nothing else. `routes` is the same tree + * `routes.ts` exports, handed on unchanged: an app on Vite reads that file + * directly at build time and an app that registers the plugin the ordinary way + * reads it through here, so the two paths cannot describe different routes. */ export const pluginConfigTemplate = (pluginName: string): string => `import { buildPlugin } from "@vitnode/core/lib/plugin"; import messages from "./locales"; -import { routes } from "./routes/manifest"; +import { routes } from "./routes"; /** * This plugin, as an application registers it. * - * \`pluginId\` is the package name, and that is not a convention - it is how a - * route module is imported (\`${pluginName}/routes/home-page\`) and how this - * plugin's messages are namespaced. The three cannot drift because they are one - * string. + * \`pluginId\` is the package name, and that is not a convention - it is how this + * plugin's route tree is imported (\`${pluginName}/routes\`) and how its messages + * are namespaced. The two cannot drift because they are one string. * * Add this to an app's \`src/vitnode.config.ts\` \`plugins\` array. A plugin that * is installed but not listed there contributes nothing - no directory is ever @@ -239,15 +234,13 @@ export const ${pluginVariableName(pluginName)} = () => `; /** - * What an app may import from this plugin, and the reason a plugin route - * `entry` is a subpath rather than a file path. + * What an app may import from this plugin. * - * `"./*"` maps every subpath to the build output, so `routes/manifest` is - * imported as `<name>/routes/manifest` and resolves to - * `dist/src/routes/manifest.js`. The plugin can move a page inside its own - * `dist` without breaking any app that installed it, and an app resolves - * these exactly as a published install would - there is no deep source - * import anywhere in the path. + * `"./*"` maps every subpath to the build output, so `routes` is imported as + * `<name>/routes` and resolves to `dist/src/routes.js`. An app resolves it + * exactly as a published install would - there is no deep source import + * anywhere in the path - and the pages that tree names are reached from inside + * it, relative to that same `dist`. * * `"./locales/*.json"` is separate and points at **source**, because it maps * to JSON that is copied rather than compiled: `dist/src/locales/en.json` @@ -274,6 +267,6 @@ export const pluginRouteScaffold = ( "src/config.tsx": pluginConfigTemplate(pluginName), "src/locales/en.json": pluginMessagesTemplate(pluginName), "src/locales/index.ts": pluginMessagesBarrelTemplate(), - "src/routes/home-page.tsx": pluginRouteModuleTemplate(pluginName), - "src/routes/manifest.ts": pluginRouteManifestTemplate(pluginName), + "src/pages/home-page.tsx": pluginRouteModuleTemplate(pluginName), + "src/routes.ts": pluginRoutesTemplate(pluginName), }); diff --git a/packages/vitnode/scripts/no-route-copier.test.ts b/packages/vitnode/scripts/no-route-copier.test.ts index 3419f16f1..3eeabe6e3 100644 --- a/packages/vitnode/scripts/no-route-copier.test.ts +++ b/packages/vitnode/scripts/no-route-copier.test.ts @@ -19,20 +19,20 @@ import { describe, expect, it } from "vitest"; * src/routes/blank/… copied without the site chrome * src/routes/breadcrumb/… copied into a @breadcrumb parallel-route slot * - * src/routes/manifest.ts declared, never copied + * src/routes.ts declared, never copied * * `scripts/prepare-plugins-files.ts` did the copy once per `vitnode init`, * `scripts/plugin.ts` watched and re-copied on every save, and * `scripts/shared/file-utils.ts` rewrote each import on the way through so a * page's `@/` still resolved after it landed in somebody else's `src/`. - * `scripts/legacy-route-overlap.ts` warned when a manifest entry pointed inside + * `scripts/legacy-route-overlap.ts` warned when a route declaration pointed inside * one of those four directories, and * `src/framework/plugin-routes/legacy-routes.ts` refused a plugin route whose * URL a Next.js page still answered. * * ## What replaced it, and why a copy may never come back * - * A plugin declares its routes in `src/routes/manifest.ts`; the app's Vite build + * A plugin declares its routes in `src/routes.ts`; the app's Vite build * compiles that into a literal registry it imports from the plugin's own `dist`. * The page has exactly one home, so there is no copy to go stale, no import to * rewrite, and no generated directory in an application that nobody wrote. @@ -217,6 +217,5 @@ describe("the build-time strangler", () => { const compiler = codeOf("src/framework/plugin-routes/compile.ts"); expect(compiler).toContain("assertNoHostRouteCollision"); - expect(compiler).toContain("assertPluginRouteRegistryParity"); }); }); diff --git a/packages/vitnode/scripts/shared/file-utils.ts b/packages/vitnode/scripts/shared/file-utils.ts index ca40cf0d4..1382e4818 100644 --- a/packages/vitnode/scripts/shared/file-utils.ts +++ b/packages/vitnode/scripts/shared/file-utils.ts @@ -9,7 +9,7 @@ import { join, resolve } from "node:path"; * also held the plugin **route copier** - the machinery that read a plugin's * `src/routes/{main,admin,blank,breadcrumb}/` and wrote copies of those pages * into every Next.js app's `src/app/[locale]/…`, rewriting each import as it - * went. A plugin's pages are no longer copied anywhere: its route manifest is + * went. A plugin's pages are no longer copied anywhere: its route tree is * compiled into a literal registry and the app imports the page out of the * plugin's own `dist`, so there is nothing to copy, nothing to clean up when a * source file is deleted, and no import to rewrite. diff --git a/packages/vitnode/src/framework/plugin-routes/compile.test.ts b/packages/vitnode/src/framework/plugin-routes/compile.test.ts index b0e6f516f..1fab51966 100644 --- a/packages/vitnode/src/framework/plugin-routes/compile.test.ts +++ b/packages/vitnode/src/framework/plugin-routes/compile.test.ts @@ -1,605 +1,340 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; -import type { PluginRouteDefinition } from "../../routing/types.js"; +import type { PluginRouteDeclaration } from "../../routing/tree.js"; import type { PluginRouteCompilerSource } from "./compile.js"; import { PluginRouteError } from "../../routing/errors.js"; +import { + definePluginRoutes, + index, + layout, + lazy, + page, +} from "../../routing/tree.js"; import { compilePluginRoutes } from "./compile.js"; +import { hostRoutePathsFromFiles } from "./host-routes.js"; -const page = (id: string, path: string): PluginRouteDefinition => ({ - entry: `routes/${id}`, - id, - path, -}); +/** + * The whole build-time compilation, from what plugins declare to the one file an + * app holds. + * + * Nothing here touches a filesystem: what is asserted is the part that has to be + * *exactly* reproducible, and the diagnostics a plugin author actually reads. + * Whether a page's module exists on disk is the Vite layer's question, and + * `lazyImportSpecifier` is what lets it ask. + */ +const lazyPage = () => + lazy(async () => await Promise.resolve({ default: () => null })); -const plugin = ( +const source = ( pluginId: string, - ...routes: PluginRouteDefinition[] + ...routes: PluginRouteDeclaration[] ): PluginRouteCompilerSource => ({ - manifestSpecifier: `${pluginId}/routes/manifest`, pluginId, - routes, + routes: definePluginRoutes(routes), + routesSpecifier: `${pluginId}/routes`, }); -const example = (...routes: PluginRouteDefinition[]) => - plugin("@vitnode/example", ...routes); - -const blog = (...routes: PluginRouteDefinition[]) => - plugin("@vitnode/blog", ...routes); - const compile = (...sources: PluginRouteCompilerSource[]) => compilePluginRoutes({ sources }); -/** The message a call threw. `expect().toThrow` cannot narrow to a type. */ -const messageOf = (run: () => unknown): string => { +const thrownBy = (build: () => unknown): Error => { try { - run(); + build(); } catch (error) { - return error instanceof Error ? error.message : String(error); + if (error instanceof Error) return error; + + throw error; } throw new Error("expected a failure"); }; describe("compilePluginRoutes", () => { - it("compiles a plugin's routes into a manifest and a module registry", () => { - const { manifest, modules } = compile(example(page("hello", "/hello"))); - - expect(manifest).toEqual([ - { - area: "main", - entry: "routes/hello", - id: "@vitnode/example:hello", - kind: "page", - namespaces: [], - parentId: null, - path: "/hello", - pluginId: "@vitnode/example", - requires: null, - routeId: "hello", - searchEntry: null, - segments: [{ kind: "static", value: "hello" }], - }, + it("compiles a plugin's tree into a manifest, its components and one source", () => { + const compiled = compile( + source( + "@vitnode/example", + page("/example", { component: lazyPage() }), + layout("/example/guide", { + component: lazyPage(), + messages: ["@vitnode/example.guide"], + children: [ + index({ component: lazyPage() }), + page(":topic", { component: lazyPage() }), + ], + }), + ), + ); + + expect(compiled.manifest.map(route => [route.kind, route.path])).toEqual([ + ["page", "/example"], + ["layout", "/example/guide"], + ["page", "/example/guide"], + ["page", "/example/guide/:topic"], ]); - expect(modules).toEqual([ - { - entry: "routes/hello", - key: "@vitnode/example:hello", - pluginId: "@vitnode/example", - routeId: "hello", - specifier: "@vitnode/example/routes/hello", - }, + expect(compiled.components.size).toBe(4); + expect(compiled.modules).toEqual([ + { pluginId: "@vitnode/example", specifier: "@vitnode/example/routes" }, ]); + expect(compiled.source).toContain( + "import { routes as pluginRoutes0 } from '@vitnode/example/routes'", + ); }); it("compiles nothing at all for an app with no plugins", () => { - const { manifest, manifestSource, modules, registrySource } = - compilePluginRoutes({ sources: [] }); + const compiled = compile(); - expect(manifest).toEqual([]); - expect(modules).toEqual([]); - expect(manifestSource).toContain("export const pluginRouteManifest = []"); - expect(registrySource).toContain("export const pluginRouteModules = {}"); - }); - - it("compiles nothing for a plugin that ships no routes", () => { - // Most plugins are AdminCP content types with no pages at all, and a - // missing route manifest has to mean "none" rather than failing a build. - expect( - compile({ pluginId: "@vitnode/blog" }, example(page("hello", "/hello"))) - .modules, - ).toHaveLength(1); + expect(compiled.manifest).toEqual([]); + expect(compiled.modules).toEqual([]); + expect(compiled.source).toContain("export const pluginRouteSources = []"); }); - it("carries every field of the contract into the generated manifest", () => { - const { manifestSource } = compile( - example( - { entry: "routes/frame", id: "frame", kind: "layout", path: "/x" }, - { - entry: "routes/inner", - id: "inner", - namespaces: ["@vitnode/example.b", "@vitnode/example.a"], - parentId: "frame", - path: "/x/inner", - requires: "authenticated", - }, - ), - ); + it("imports a plugin that resolves a routes module but declares none", () => { + // Importing it is harmless and keeps the dev server's watcher meaningful: + // the file exists, so adding the first route to it regenerates rather than + // requiring a restart. + const compiled = compile(source("@vitnode/example")); - expect(manifestSource).toContain("kind: 'layout',"); - expect(manifestSource).toContain( - "namespaces: ['@vitnode/example.a', '@vitnode/example.b'],", - ); - expect(manifestSource).toContain("parentId: '@vitnode/example:frame',"); - expect(manifestSource).toContain("requires: 'authenticated',"); + expect(compiled.manifest).toEqual([]); + expect(compiled.modules).toHaveLength(1); }); - it("normalises once, so nothing downstream has to", () => { - const [route] = compile( - example({ - entry: "routes/hello", - id: "hello", - namespaces: ["b", "a", "b"], - path: "/hello/", - }), - ).manifest; + it("leaves out a plugin with no routes module at all", () => { + const compiled = compilePluginRoutes({ + sources: [{ pluginId: "@vitnode/blog" }], + }); - // A trailing slash is formatting, and a namespace list is a set. - expect(route.path).toBe("/hello"); - expect(route.namespaces).toEqual(["a", "b"]); + expect(compiled.modules).toEqual([]); + expect(compiled.source).toContain("export const pluginRouteSources = []"); }); }); describe("determinism", () => { - it("produces the same bytes whichever order the plugins were configured in", () => { - const forwards = compile(example(page("b", "/b")), blog(page("a", "/a"))); - const backwards = compile(blog(page("a", "/a")), example(page("b", "/b"))); + const one = () => + source("@vitnode/example", page("/example", { component: lazyPage() })); + const two = () => + source("@acme/blog", page("/blog", { component: lazyPage() })); - expect(backwards.manifestSource).toBe(forwards.manifestSource); - expect(backwards.registrySource).toBe(forwards.registrySource); + it("produces the same bytes whichever order the plugins were configured in", () => { + expect(compile(one(), two()).source).toBe(compile(two(), one()).source); }); - it("produces the same bytes whichever order one plugin declared its routes in", () => { - const forwards = compile(example(page("a", "/a"), page("b", "/b"))); - const backwards = compile(example(page("b", "/b"), page("a", "/a"))); - - expect(backwards.manifestSource).toBe(forwards.manifestSource); - expect(backwards.registrySource).toBe(forwards.registrySource); + it("produces the same manifest whichever order they were configured in", () => { + expect(compile(one(), two()).manifest).toEqual( + compile(two(), one()).manifest, + ); }); it("sorts static paths before the dynamic ones that shadow them", () => { expect( compile( - example(page("show", "/member/:id"), page("new", "/member/new")), + source( + "@vitnode/example", + page("/example/:id", { component: lazyPage() }), + page("/example/new", { component: lazyPage() }), + ), ).manifest.map(route => route.path), - ).toEqual(["/member/new", "/member/:id"]); + ).toEqual(["/example/new", "/example/:id"]); }); }); -describe("the two generated files describe one set of routes", () => { - it("registers a module for every route in the manifest, under its id", () => { - const { manifest, modules } = compile( - example(page("a", "/a")), - blog(page("b", "/b"), page("c", "/c")), +describe("one snapshot, one file", () => { + it("keys a component for every route in the manifest", () => { + const compiled = compile( + source( + "@vitnode/example", + layout("/example", { + component: lazyPage(), + children: [index({ component: lazyPage() })], + }), + ), ); - expect(modules.map(module => module.key)).toEqual( - [...manifest].map(route => route.id).sort(), - ); + for (const route of compiled.manifest) { + expect(compiled.components.get(route.id)).toBeDefined(); + } }); - it("derives the registry from the manifest, so a rejected route reaches neither", () => { - // The manifest is built first and the registry from it, which is what makes - // "a route that fails validation cannot leave an import behind" structural - // rather than something the two steps have to agree about. - const failed = messageOf(() => - compile(example(page("ok", "/ok"), page("bad", "/BAD"))), - ); - - expect(failed).toContain("uppercase letters"); + it("writes the file from the plugins that survived validation", () => { + // A tree that cannot be flattened fails the whole compilation, so there is + // no state in which a broken plugin leaves a stale import behind. + expect( + thrownBy(() => + compile( + source( + "@vitnode/example", + page("/example", { component: lazyPage() }), + ), + source("@acme/blog", page("blog", { component: lazyPage() })), + ), + ).message, + ).toContain("A top-level page in @acme/blog"); }); - it("writes one literal import per route, and never a computed specifier", () => { - const { registrySource } = compile( - example(page("a", "/a")), - blog(page("b", "/b")), + it("names no page module in the generated source", () => { + const compiled = compile( + source("@vitnode/example", page("/example", { component: lazyPage() })), ); - expect(registrySource).toContain( - "'@vitnode/blog:b': () => import('@vitnode/blog/routes/b'),", - ); - expect(registrySource).toContain( - "'@vitnode/example:a': () => import('@vitnode/example/routes/a'),", - ); - // Every specifier a bundler has to follow is a literal string in the source. - expect(registrySource).not.toMatch(/import\((?!')/); + expect(compiled.source).not.toContain("pages/"); }); }); describe("enabling and disabling a plugin", () => { - const both = () => compile(example(page("a", "/a")), blog(page("b", "/b"))); - const one = () => compile(example(page("a", "/a"))); + const example = () => + source("@vitnode/example", page("/example", { component: lazyPage() })); + const blog = () => + source("@acme/blog", page("/blog", { component: lazyPage() })); - it("leaves a disabled plugin no route and no module import", () => { - const { manifestSource, registrySource } = one(); + it("leaves a disabled plugin no route and no import", () => { + const compiled = compile(example()); - expect(manifestSource).not.toContain("@vitnode/blog"); - expect(registrySource).not.toContain("@vitnode/blog"); + expect(compiled.source).not.toContain("@acme/blog"); + expect(compiled.manifest.map(route => route.pluginId)).toEqual([ + "@vitnode/example", + ]); }); it("is the same compilation whether a plugin was never there or removed", () => { - // Both generated files are written from the list the plugin's routes are no - // longer in, so "disabled" and "never installed" cannot differ. - expect(one().registrySource).toBe( - compilePluginRoutes({ - sources: [example(page("a", "/a")), { pluginId: "@vitnode/blog" }], - }).registrySource, + expect(compile(example()).source).toBe(compile(example()).source); + expect(compile(example(), blog()).source).not.toBe( + compile(example()).source, ); }); - it("adds exactly the enabled plugin's routes back", () => { - expect(both().manifest.map(route => route.id)).toEqual([ - "@vitnode/example:a", - "@vitnode/blog:b", - ]); - }); - - it("follows a changed path without changing the route's identity", () => { - const before = compile(example(page("a", "/a"))); - const after = compile(example({ entry: "routes/a", id: "a", path: "/z" })); - - expect(after.manifest[0].id).toBe(before.manifest[0].id); - expect(after.manifest[0].path).toBe("/z"); - expect(after.registrySource).toBe(before.registrySource); - }); - - it("follows a changed entry into the generated import", () => { - const { registrySource } = compile( - example({ entry: "routes/moved", id: "a", path: "/a" }), + it("follows a changed path", () => { + const moved = compile( + source("@vitnode/example", page("/moved", { component: lazyPage() })), ); - expect(registrySource).toContain( - "'@vitnode/example:a': () => import('@vitnode/example/routes/moved'),", - ); + expect(moved.manifest[0].path).toBe("/moved"); + expect(moved.manifest[0].id).toBe("@vitnode/example:page#/moved"); }); }); -describe("collisions", () => { - it("rejects two plugins claiming one path", () => { - expect( - messageOf(() => compile(example(page("a", "/x")), blog(page("b", "/x")))), - ).toContain("Plugin route path collision"); - }); - - it("rejects two paths that are one route space spelled twice", () => { - // `/member/:id` and `/member/:slug` match exactly the same URLs. - expect( - messageOf(() => - compile( - example(page("mine", "/member/:id")), - blog(page("theirs", "/member/:slug")), - ), - ), - ).toContain("collision"); - }); - - it("does not confuse a static path with the dynamic one beside it", () => { - expect( +describe("diagnostics", () => { + it("names the module a colliding route was declared in", () => { + const error = thrownBy(() => compile( - example(page("new", "/member/new")), - blog(page("show", "/member/:id")), - ).manifest, - ).toHaveLength(2); - }); + source("@vitnode/example", page("/example", { component: lazyPage() })), + source("@acme/blog", page("/example", { component: lazyPage() })), + ), + ); - it("rejects a duplicate route id within one plugin", () => { - expect( - messageOf(() => compile(example(page("a", "/a"), page("a", "/b")))), - ).toContain('Duplicate plugin route id "@vitnode/example:a"'); + expect(error).toBeInstanceOf(PluginRouteError); + expect(error.message).toContain("[VitNode plugin routes]"); + expect(error.message).toContain('Declared in "@acme/blog/routes"'); + expect(error.message).toContain('is declared in "@vitnode/example/routes"'); }); - it("lets two plugins use the same local route id", () => { + it("names the module of a route that is illegal on its own", () => { expect( - compile( - example(page("index", "/a")), - blog(page("index", "/b")), - ).modules.map(module => module.key), - ).toEqual(["@vitnode/blog:index", "@vitnode/example:index"]); + thrownBy(() => + compile(source("@acme/blog", page("/Blog", { component: lazyPage() }))), + ).message, + ).toContain('Declared in "@acme/blog/routes"'); }); it("rejects a plugin route that shadows one of the application's own", () => { - const message = messageOf(() => + const error = thrownBy(() => compilePluginRoutes({ - hostRoutes: [{ file: "src/routes/_main/search.tsx", path: "/search" }], - sources: [example(page("search", "/search"))], + hostRoutes: hostRoutePathsFromFiles(["_main/discover.tsx"]), + sources: [ + source( + "@vitnode/example", + page("/discover", { component: lazyPage() }), + ), + ], }), ); - expect(message).toContain('claims "/search"'); - expect(message).toContain("src/routes/_main/search.tsx"); + expect(error.message).toContain("/discover"); + expect(error.message).toContain("_main/discover.tsx"); }); - /** - * `/admin/content/blog` used to be refused here. - * - * A build-time strangler - `assertNoLegacyRouteCollision` - read every URL the - * Next.js AdminCP answered off `@vitnode/core`'s own `src/routes/admin/**` and - * refused a plugin route that claimed one, because a TanStack route at that - * path turned a working Next.js screen into a not-found. There is one - * application now and that directory is gone, so a plugin may claim any URL - * the host's own route files do not - which is exactly what - * `assertNoHostRouteCollision` above checks, against the real route tree - * rather than against a second application. - */ - it("compiles a plugin route under /admin/content, which no other app answers", () => { - expect( - compile( - example({ - area: "admin", - entry: "routes/posts", - id: "posts", - path: "/admin/content/blog", - }), - ).manifest, - ).toHaveLength(1); + it("does not confuse a static host route with a dynamic plugin one", () => { + expect(() => + compilePluginRoutes({ + hostRoutes: hostRoutePathsFromFiles(["_main/example.new.tsx"]), + sources: [ + source( + "@vitnode/example", + page("/example/:id", { component: lazyPage() }), + ), + ], + }), + ).not.toThrow(); }); }); -/** - * The AdminCP as a destination for a plugin page, through the whole compiler. - * - * The unit rules are `routing/manifest.test.ts`'s and `routing/graph.test.ts`'s. - * What is asserted here is that an admin route survives the parts a build adds - - * the generated literal, the registry, the parity check - unchanged. - */ describe("the admin area", () => { - const reports = (): PluginRouteCompilerSource => - example({ - area: "admin", - entry: "routes/reports", - id: "reports", - namespaces: ["@vitnode/example.admin"], - path: "/admin/reports", - }); - - it("carries the area into the generated manifest", () => { - const { manifest, manifestSource } = compile(reports()); + const admin = () => + source( + "@vitnode/example", + page("/admin/example", { area: "admin", component: lazyPage() }), + ); - expect(manifest[0]).toMatchObject({ + it("carries the area into the manifest", () => { + expect(compile(admin()).manifest[0]).toMatchObject({ area: "admin", - path: "/admin/reports", + path: "/admin/example", }); - expect(manifestSource).toContain("area: 'admin'"); - expect(manifestSource).toContain("path: '/admin/reports'"); }); - /** - * The generated registry is the *how*, and an area is not part of it: a module - * is imported the same way wherever its page is framed. One literal `import()` - * per route, exactly as Stage 11 emits for a public page. - */ - it("generates the same one literal import a public route gets", () => { - expect(compile(reports()).registrySource).toContain( - "'@vitnode/example:reports': () => import('@vitnode/example/routes/reports'),", - ); + it("generates the same one static import a public route gets", () => { + expect(compile(admin()).modules).toEqual([ + { pluginId: "@vitnode/example", specifier: "@vitnode/example/routes" }, + ]); }); - /** - * An area frames a page; it does not move it. Both shells are pathless, so an - * admin route and a public route spelling one pathname are one URL claimed - * twice - and the compiler refuses it with both plugins and both manifests - * named, which is the whole value of catching it here rather than in a router. - */ it("refuses an admin route and a public route at one pathname", () => { - expect(() => - compile( - example( - { area: "admin", entry: "routes/a", id: "a", path: "/reports" }, - { entry: "routes/b", id: "b", path: "/reports" }, - ), - ), - ).toThrow(/collision on "\/reports"/); - }); - - /** The pair that is actually two URLs, and is accepted. */ - it("keeps an admin route and a public route with different paths", () => { expect( - compile( - example( - { area: "admin", entry: "routes/a", id: "a", path: "/admin/reports" }, - { entry: "routes/b", id: "b", path: "/reports" }, + thrownBy(() => + compile( + admin(), + source( + "@acme/blog", + page("/admin/example", { component: lazyPage() }), + ), ), - ).manifest.map(route => [route.area, route.path]), - ).toEqual([ - ["admin", "/admin/reports"], - ["main", "/reports"], - ]); + ).message, + ).toContain("path collision"); }); }); describe("hierarchy", () => { - const frame = (id: string, path: string): PluginRouteDefinition => ({ - entry: `routes/${id}`, - id, - kind: "layout", - path, - }); - - const child = ( - id: string, - path: string, - parentId: string, - ): PluginRouteDefinition => ({ entry: `routes/${id}`, id, parentId, path }); - - it("namespaces a declared parent id, so a manifest addresses one id space", () => { - const manifest = compile( - example( - frame("settings", "/settings"), - child("index", "/settings", "settings"), + it("carries a nested route's parent as a namespaced id", () => { + const compiled = compile( + source( + "@vitnode/example", + layout("/example/guide", { + component: lazyPage(), + children: [page(":topic", { component: lazyPage() })], + }), ), - ).manifest; - - expect(manifest.find(route => route.routeId === "index")?.parentId).toBe( - "@vitnode/example:settings", ); - }); - - it("rejects a parent no route in the manifest has", () => { - expect( - messageOf(() => compile(example(child("a", "/a", "nope")))), - ).toContain("declares the parent"); - }); - - it("rejects a route that is its own parent", () => { - expect(messageOf(() => compile(example(child("a", "/a", "a"))))).toContain( - "is its own parent", - ); - }); - - it("rejects a cycle", () => { - expect( - messageOf(() => - compile( - example( - { ...frame("a", "/a"), parentId: "b" }, - { ...frame("b", "/b"), parentId: "a" }, - ), - ), - ), - ).toContain("parent cycle"); - }); - it("rejects a parent that is not a layout", () => { - expect( - messageOf(() => - compile(example(page("a", "/a"), child("b", "/a/b", "a"))), - ), - ).toContain("rather than a layout"); - }); - - it("rejects a layout with nothing inside it", () => { - expect( - messageOf(() => compile(example(frame("frame", "/frame")))), - ).toContain("layout with no routes inside it"); + expect(compiled.manifest.map(route => [route.id, route.parentId])).toEqual([ + ["@vitnode/example:layout#/example/guide", null], + [ + "@vitnode/example:page#/example/guide/:topic", + "@vitnode/example:layout#/example/guide", + ], + ]); }); - it("rejects a child that claims a path outside its parent", () => { + it("fails the build on a layout with nothing inside it", () => { expect( - messageOf(() => + thrownBy(() => compile( - example( - frame("frame", "/frame"), - child("away", "/elsewhere", "frame"), + source( + "@vitnode/example", + layout("/example", { component: lazyPage(), children: [] }), ), ), - ), - ).toContain("not inside its parent"); - }); - - it("cannot express a parent in another plugin", () => { - // A `parentId` is plugin-local, so the id is namespaced into the declaring - // plugin and the other plugin's route is simply not found. - expect( - messageOf(() => - compile( - blog(frame("frame", "/frame"), child("in", "/frame/in", "frame")), - example(child("stolen", "/frame/mine", "frame")), - ), - ), - ).toContain('"@vitnode/example:frame"'); - }); - - it("keeps a parent in front of its children in the compiled manifest", () => { - expect( - compile( - example( - child("security", "/settings/security", "settings"), - child("index", "/settings", "settings"), - frame("settings", "/settings"), - ), - ).manifest.map(route => route.routeId), - ).toEqual(["index", "settings", "security"]); - }); -}); - -describe("diagnostics", () => { - it("names the manifest a bad route was declared in", () => { - expect(messageOf(() => compile(example(page("a", "/A"))))).toContain( - 'Declared in "@vitnode/example/routes/manifest".', - ); - }); - - it("names both manifests in a collision between two plugins", () => { - const message = messageOf(() => - compile(example(page("a", "/x")), blog(page("b", "/x"))), - ); - - expect(message).toContain('Declared in "@vitnode/blog/routes/manifest".'); - expect(message).toContain( - 'is declared in "@vitnode/example/routes/manifest".', - ); - }); - - it("prefixes every failure so it can be found in a Vite log", () => { - expect(messageOf(() => compile(example(page("a", "/A"))))).toContain( - "[VitNode plugin routes]", - ); - }); - - it("keeps the structured fields a build tool can render itself", () => { - let thrown: unknown; - - try { - compile(example(page("a", "/A"))); - } catch (error) { - thrown = error; - } - - expect(thrown).toBeInstanceOf(PluginRouteError); - expect(thrown).toMatchObject({ - code: "invalid-path", - path: "/A", - pluginId: "@vitnode/example", - routeId: "a", - }); - }); - - it("says which property is wrong, not just that the route is", () => { - expect( - messageOf(() => - compile(example({ entry: "routes/a.tsx", id: "a", path: "/a" })), - ), - ).toContain("invalid entry"); - expect( - messageOf(() => - compile( - example({ - entry: "routes/a", - id: "a", - namespaces: ["__proto__"], - path: "/a", - }), - ), - ), - ).toContain("namespaces[0]"); - }); - - it("still fails clearly for a plugin that declared no manifest specifier", () => { - expect( - messageOf(() => - compilePluginRoutes({ - sources: [ - { pluginId: "@vitnode/example", routes: [page("a", "/A")] }, - ], - }), - ), - ).toContain("uppercase letters"); - }); -}); - -describe("escaping", () => { - it("escapes a value that would otherwise close its own string literal", () => { - // Nothing that reaches here can contain a quote today - every field is - // matched against a pattern first - and a generator that concatenates - // unescaped strings is one refactor away from writing a plugin's data into - // an app's source. - const { manifestSource } = compilePluginRoutes({ - sources: [ - { - pluginId: "@vitnode/example", - routes: [ - { - entry: "routes/a", - id: "a", - namespaces: ["it's"], - path: "/a", - }, - ], - }, - ], - }); - - expect(manifestSource).toContain("namespaces: ['it\\'s'],"); + ).message, + ).toContain("layout with no `children`"); }); }); diff --git a/packages/vitnode/src/framework/plugin-routes/compile.ts b/packages/vitnode/src/framework/plugin-routes/compile.ts index 2477c80fe..cf8ad633e 100644 --- a/packages/vitnode/src/framework/plugin-routes/compile.ts +++ b/packages/vitnode/src/framework/plugin-routes/compile.ts @@ -1,62 +1,50 @@ -import type { - PluginRouteDefinition, - PluginRouteManifest, -} from "../../routing/types.js"; +import type { PluginRouteLazyComponent } from "../../routing/tree.js"; +import type { PluginRouteManifest } from "../../routing/types.js"; import type { HostRoutePath } from "./host-routes.js"; -import type { - ResolvedPluginRouteModule, - ResolvedPluginRouteSearchModule, -} from "./types.js"; +import type { ResolvedPluginRoutesModule } from "./types.js"; -import { buildPluginRouteManifest } from "../../routing/manifest.js"; +import { compilePluginRouteTrees } from "../../routing/manifest.js"; import { withPluginRouteDiagnostics } from "./diagnostics.js"; -import { generatePluginRouteRegistrySource } from "./generate.js"; +import { generatePluginRoutesSource } from "./generate.js"; import { assertNoHostRouteCollision } from "./host-routes.js"; -import { generatePluginRouteManifestSource } from "./manifest-source.js"; -import { assertPluginRouteRegistryParity } from "./parity.js"; -import { - pluginRouteEntrySources, - pluginRouteSearchModules, - resolvePluginRouteModules, -} from "./resolve.js"; /** - * One configured plugin's route declarations, exactly as its manifest exported - * them. + * One configured plugin's route tree, exactly as its `routes` module exported + * it. * - * `routes` is typed but not trusted: a plugin is JavaScript by the time it is - * installed, and every field is re-read defensively by - * `buildPluginRouteManifest`. What this layer adds is `manifestSpecifier` - - * where the declarations came from - which is not part of what a plugin declares - * and is the one thing a plugin author needs to know to fix any of these errors. + * `routes` is `unknown` because a plugin is compiled JavaScript by the time it + * is installed: every field is re-read defensively by `flattenPluginRoutes`. + * What this layer adds is `routesSpecifier` - where the tree came from - which + * is not part of what a plugin declares and is the one thing a plugin author + * needs in order to fix any of these errors. */ export interface PluginRouteCompilerSource { + pluginId: string; + routes?: unknown; /** - * The specifier the declarations were loaded from, e.g. - * `"@vitnode/example/routes/manifest"`. Optional so a caller with declarations - * from somewhere else - a test, a Next.js host reading a registered plugin - - * is not made to invent one. + * The specifier the tree was loaded from, e.g. `"@vitnode/example/routes"`. + * Optional so a caller with declarations from somewhere else - a test, or a + * host reading a registered plugin - is not made to invent one. */ - manifestSpecifier?: string; - pluginId: string; - routes?: readonly PluginRouteDefinition[]; + routesSpecifier?: string; } /** What one compilation produced. */ export interface CompiledPluginRoutes { - /** The resolved snapshot both sources below were written from. */ - manifest: PluginRouteManifest; - /** The source of `src/plugin-route-manifest.gen.ts`. */ - manifestSource: string; - /** The manifest's routes, paired with the specifiers they are imported by. */ - modules: ResolvedPluginRouteModule[]; - /** The source of `src/plugin-routes.gen.ts`. */ - registrySource: string; /** - * The routes that declared an eager search schema, paired with the specifiers - * the generated file imports them by. Usually empty. + * Each route's lazy component, keyed by route id. + * + * Here so a build can ask the one question a generated file no longer answers + * for it: does the module this page names actually exist. See + * `lazyImportSpecifier`. */ - searchModules: ResolvedPluginRouteSearchModule[]; + components: Map<string, PluginRouteLazyComponent>; + /** The resolved snapshot the source below was written from. */ + manifest: PluginRouteManifest; + /** The plugins whose route modules the generated file imports. */ + modules: ResolvedPluginRoutesModule[]; + /** The source of `src/plugin-routes.gen.ts`. */ + source: string; } export interface CompilePluginRoutesOptions { @@ -73,87 +61,71 @@ export interface CompilePluginRoutesOptions { } /** - * Every configured plugin's routes, compiled into the two files an app holds. + * Every configured plugin's routes, compiled into the one file an app holds. * - * Pure: plain declarations in, validated data and two source strings out. There - * is no filesystem here, no package resolution and no framework - the build tool - * that owns those (`@vitnode/core/framework/vite`) loads the declarations, checks - * each entry resolves, and writes what this returns. That split is what makes - * the part that has to be *exactly* reproducible testable without a fixture app. + * Pure: plain declarations in, a validated manifest and one source string out. + * There is no filesystem here, no package resolution and no framework - the + * build tool that owns those (`@vitnode/core/framework/vite`) loads the + * declarations and writes what this returns. That split is what makes the part + * that has to be *exactly* reproducible testable without a fixture app. * - * ## One snapshot, two files + * ## One snapshot, one file * - * The manifest is built first and the registry is derived **from it**, rather - * than from a second pass over the same plugins. That ordering is the entire - * anti-drift argument: a route reaches `plugin-routes.gen.ts` only by being in - * the manifest, under the id the manifest gave it, with the entry the manifest - * validated. A route that fails validation cannot leave a stale import behind, - * and a disabled plugin cannot leave one either, because both files are written - * from the list its routes are no longer in. + * The manifest is built first and the generated file is written **from the + * plugins that survived it**, rather than from a second pass over the same + * configuration. That ordering is the whole anti-drift argument: a plugin + * reaches `plugin-routes.gen.ts` only by having declared a tree that validates, + * and a plugin removed from the config cannot leave a stale import behind + * because the file is written from the list it is no longer in. * - * {@link assertPluginRouteRegistryParity} then checks in both directions anyway, - * which is belt and braces on purpose: it is what keeps the derivation honest if - * somebody later gives the registry its own source of truth back. + * There is no second generated file to keep in step, and that is the point. + * Every route's component is the `lazy()` the plugin's own tree carries, so the + * route and the module it renders cannot describe different things - they are + * one declaration. * * ## The order of the checks, which is the order of the diagnostics * - * 1. `buildPluginRouteManifest` - is each route legal on its own, do two of them - * claim one URL, and does the hierarchy they describe hold together. Every - * failure names the plugin and the route; this layer adds the manifest each - * one was declared in. + * 1. `compilePluginRouteTrees` - is each route legal on its own, does the tree + * it sits in hold together, and do two of them claim one URL. Every failure + * names the plugin and the route; this layer adds the module each one was + * declared in. * 2. `assertNoHostRouteCollision` - does a plugin route shadow one of the * application's own pages. - * 3. `resolvePluginRouteModules` - can each entry be written into an import. - * 4. `assertPluginRouteRegistryParity` - do the two files describe one set of - * routes. - * - * There was a third check between 2 and 3 until the Next.js cutover - - * `assertNoLegacyRouteCollision`, which refused a plugin route claiming a URL - * the Next.js application still answered. It read those URLs off - * `@vitnode/core`'s own `src/routes/admin/**`, and that directory no longer - * exists: there is one application now, so `assertNoHostRouteCollision` against - * its real route files is the whole of the question. * - * Deterministic: the manifest is sorted by path and the registry by key, both - * with code-unit comparisons, so the same plugin configuration produces the same - * bytes on any machine and in any registration order. + * Deterministic: the manifest is sorted by path and the generated file by plugin + * id, both with code-unit comparisons, so the same plugin configuration produces + * the same bytes on any machine and in any registration order. */ export const compilePluginRoutes = ({ hostRoutes = [], sources, }: CompilePluginRoutesOptions): CompiledPluginRoutes => { - const manifestSpecifiers = new Map( + const specifiers = new Map( sources.flatMap(source => - source.manifestSpecifier === undefined + source.routesSpecifier === undefined ? [] - : [[source.pluginId, source.manifestSpecifier] as const], + : [[source.pluginId, source.routesSpecifier] as const], ), ); - return withPluginRouteDiagnostics(manifestSpecifiers, () => { - const manifest = buildPluginRouteManifest( - sources.map(({ pluginId, routes }) => ({ - pluginId, - routes: routes === undefined ? [] : [...routes], - })), + return withPluginRouteDiagnostics(specifiers, () => { + const { components, manifest } = compilePluginRouteTrees( + sources.map(({ pluginId, routes }) => ({ pluginId, routes })), ); assertNoHostRouteCollision(manifest, hostRoutes); - const modules = resolvePluginRouteModules( - pluginRouteEntrySources(manifest), + const modules: ResolvedPluginRoutesModule[] = sources.flatMap(source => + source.routesSpecifier === undefined + ? [] + : [{ pluginId: source.pluginId, specifier: source.routesSpecifier }], ); - assertPluginRouteRegistryParity(manifest, modules); - - const searchModules = pluginRouteSearchModules(manifest); - return { + components, manifest, - manifestSource: generatePluginRouteManifestSource(manifest), modules, - registrySource: generatePluginRouteRegistrySource(modules, searchModules), - searchModules, + source: generatePluginRoutesSource(modules), }; }); }; diff --git a/packages/vitnode/src/framework/plugin-routes/component-source.test.ts b/packages/vitnode/src/framework/plugin-routes/component-source.test.ts new file mode 100644 index 000000000..6bd08a3ed --- /dev/null +++ b/packages/vitnode/src/framework/plugin-routes/component-source.test.ts @@ -0,0 +1,98 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { lazyImportSpecifier } from "./component-source.js"; + +/** + * Reading a page's specifier off a `lazy()` callback, which is the only way a + * build can check that the module a route names exists. + * + * `lazy()` deliberately never calls the callback, so there is nothing to observe + * by running it - and the answer is allowed to be "cannot tell". Every case + * below that returns `null` is a case where failing a build would be guessing. + * + * The callbacks are compiled from source rather than written as real dynamic + * imports, because this test file is itself transformed: Vite rewrites an + * `import()` in a test into a call to its own loader, which is precisely the + * "already rewritten" case the last test pins. What the build actually reads is + * a plugin's compiled `dist`, where the import is still an `import`. + */ +const callback = (source: string): unknown => + // eslint-disable-next-line @typescript-eslint/no-implied-eval + new Function(`return ${source}`)() as unknown; + +describe("lazyImportSpecifier", () => { + it("reads a relative specifier out of an arrow function", () => { + expect(lazyImportSpecifier(callback('()=>import("./pages/x.js")'))).toBe( + "./pages/x.js", + ); + }); + + it("reads one written with single quotes", () => { + expect(lazyImportSpecifier(callback("() => import('./pages/y.js')"))).toBe( + "./pages/y.js", + ); + }); + + it("reads one with import attributes after it", () => { + expect( + lazyImportSpecifier( + callback('() => import("./pages/x.js", { with: { type: "json" } })'), + ), + ).toBe("./pages/x.js"); + }); + + it("reads a parent-relative specifier", () => { + expect(lazyImportSpecifier(callback('()=>import("../pages/z.js")'))).toBe( + "../pages/z.js", + ); + }); + + it("reads through an async function that awaits the import", () => { + expect( + lazyImportSpecifier(callback('async () => await import("./pages/x.js")')), + ).toBe("./pages/x.js"); + }); + + it("ignores a bare specifier, which resolves through a package rather than a path", () => { + expect( + lazyImportSpecifier(callback('() => import("@acme/other/page")')), + ).toBeNull(); + }); + + it("cannot tell for a callback that imports nothing", () => { + expect( + lazyImportSpecifier(callback("() => Promise.resolve({})")), + ).toBeNull(); + }); + + it("cannot tell for a callback with more than one import", () => { + expect( + lazyImportSpecifier( + callback( + '() => Promise.all([import("./pages/x.js"), import("./pages/y.js")])', + ), + ), + ).toBeNull(); + }); + + it("cannot tell for a computed specifier", () => { + expect( + lazyImportSpecifier(callback("(name) => import(`./pages/${name}.js`)")), + ).toBeNull(); + }); + + it("cannot tell for a callback a bundler has already rewritten", () => { + expect( + lazyImportSpecifier( + callback('() => __vite_ssr_dynamic_import__("./pages/x.js")'), + ), + ).toBeNull(); + }); + + it("is null for anything that is not a function", () => { + expect(lazyImportSpecifier(undefined)).toBeNull(); + expect(lazyImportSpecifier("./pages/x.js")).toBeNull(); + expect(lazyImportSpecifier({ load: () => null })).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/framework/plugin-routes/component-source.ts b/packages/vitnode/src/framework/plugin-routes/component-source.ts new file mode 100644 index 000000000..706e47bf2 --- /dev/null +++ b/packages/vitnode/src/framework/plugin-routes/component-source.ts @@ -0,0 +1,39 @@ +/** + * The module specifier inside a `lazy(() => import("./pages/x"))` callback, read + * off the compiled function. + * + * A best-effort diagnostic, and typed to say so: `null` means "this build cannot + * tell", which is the honest answer for a callback a bundler has already + * rewritten, one that awaits something computed, or one that imports more than + * one module. Nothing depends on the result being present - it exists so that a + * mistyped page path fails the build naming the plugin and the route, instead of + * failing in a browser the first time somebody opens the page. + * + * `Function.prototype.toString` is the only way in: `lazy()` deliberately keeps + * the callback un-called - that is the whole point of it - so the specifier + * cannot be observed by running anything. What it returns is the source of the + * arrow function as it exists in the plugin's build output, which for the shape + * this looks for is `()=>import("./pages/product-page.js")`. + * + * Only *relative* specifiers are returned. A bare one (`"@acme/other/page"`) is + * resolved by the plugin's own dependency graph rather than by a path on disk, + * and the caller has no business guessing where it lives. + */ +const IMPORT_CALL = /\bimport\(\s*(?:"([^"]*)"|'([^']*)'|`([^`$\\]*)`)\s*[,)]/g; + +export const lazyImportSpecifier = (load: unknown): null | string => { + if (typeof load !== "function") return null; + + const source = String(load); + const found = [...source.matchAll(IMPORT_CALL)].map( + match => match[1] ?? match[2] ?? match[3], + ); + + if (found.length !== 1) return null; + + const [specifier] = found; + + if (!specifier?.startsWith(".")) return null; + + return specifier; +}; diff --git a/packages/vitnode/src/framework/plugin-routes/diagnostics.ts b/packages/vitnode/src/framework/plugin-routes/diagnostics.ts index fc01af6c3..db2840fc2 100644 --- a/packages/vitnode/src/framework/plugin-routes/diagnostics.ts +++ b/packages/vitnode/src/framework/plugin-routes/diagnostics.ts @@ -23,11 +23,11 @@ export const PLUGIN_ROUTES_ERROR_PREFIX = "[VitNode plugin routes]"; * So the message a plugin author actually sees is assembled here: * * [VitNode plugin routes] Plugin route path collision on "/example" (main): - * @vitnode/example already owns "/example" as "@vitnode/example:example-page", - * and @vitnode/blog declares "/example" as "@vitnode/blog:example". - * Two plugins cannot serve the same path - rename one of them. - * Declared in "@vitnode/blog/routes/manifest". - * The route it conflicts with is declared in "@vitnode/example/routes/manifest". + * @vitnode/example already owns "/example" (main), and @vitnode/blog declares + * "/example". Give one of them a different path. + * Declared in "@vitnode/blog/routes". + * The route it conflicts with, "@vitnode/example:page#/example", is declared + * in "@vitnode/example/routes". * * A new error rather than a mutated one, and a `PluginRouteError` rather than a * plain `Error`: `code`, `pluginId`, `routeId` and `path` are structured fields a @@ -35,23 +35,23 @@ export const PLUGIN_ROUTES_ERROR_PREFIX = "[VitNode plugin routes]"; * annotation cost the caller the machine-readable half of the failure. * * Anything that is not a `PluginRouteError` is returned untouched - the resolver - * and the parity check write their own messages, already prefixed. + * and the generator write their own messages, already prefixed. */ export const annotatePluginRouteError = ( error: unknown, - manifestSpecifiers: ReadonlyMap<string, string>, + routesSpecifiers: ReadonlyMap<string, string>, ): unknown => { if (!(error instanceof PluginRouteError)) return error; const parts = [`${PLUGIN_ROUTES_ERROR_PREFIX} ${error.message}`]; - const declaredIn = manifestSpecifiers.get(error.pluginId); + const declaredIn = routesSpecifiers.get(error.pluginId); if (declaredIn !== undefined) { parts.push(`Declared in "${declaredIn}".`); } if (error.conflictsWith) { - const otherIn = manifestSpecifiers.get(error.conflictsWith.pluginId); + const otherIn = routesSpecifiers.get(error.conflictsWith.pluginId); parts.push( otherIn === undefined @@ -76,12 +76,12 @@ export const annotatePluginRouteError = ( * compiler is annotated the same way and adding one cannot mean forgetting to. */ export const withPluginRouteDiagnostics = <T>( - manifestSpecifiers: ReadonlyMap<string, string>, + routesSpecifiers: ReadonlyMap<string, string>, step: () => T, ): T => { try { return step(); } catch (error) { - throw annotatePluginRouteError(error, manifestSpecifiers); + throw annotatePluginRouteError(error, routesSpecifiers); } }; diff --git a/packages/vitnode/src/framework/plugin-routes/generate.test.ts b/packages/vitnode/src/framework/plugin-routes/generate.test.ts index caaa90928..9e855255c 100644 --- a/packages/vitnode/src/framework/plugin-routes/generate.test.ts +++ b/packages/vitnode/src/framework/plugin-routes/generate.test.ts @@ -1,282 +1,129 @@ +// @vitest-environment node import { describe, expect, it } from "vitest"; -import { generatePluginRouteRegistrySource } from "./generate.js"; -import { resolvePluginRouteModules } from "./resolve.js"; +import type { ResolvedPluginRoutesModule } from "./types.js"; -const generate = ( - sources: { pluginId: string; routes: { entry: string; id: string }[] }[], -): string => - generatePluginRouteRegistrySource(resolvePluginRouteModules(sources)); +import { generatePluginRoutesSource } from "./generate.js"; -const EXAMPLE = [ - { - pluginId: "@vitnode/example", - routes: [{ entry: "routes/example-page", id: "example-page" }], - }, -]; - -describe("generatePluginRouteRegistrySource", () => { - it("emits the registry for one plugin route", () => { - expect(generate(EXAMPLE)).toMatchInlineSnapshot(` - "/* 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 two inputs and nothing else: the plugins - // configured in \`src/vitnode.config.ts\`, and the route manifest each of those - // plugins ships. Nothing here is discovered at runtime - the browser is handed - // this module, never a filesystem. - // - // Same plugin configuration in, same bytes out: the entries are sorted by key. - - import type { - PluginRouteModuleRegistry, - PluginRouteSearchRegistry, - ResolvedPluginRouteModule, - } from '@vitnode/core/framework/plugin-routes' - - /** - * Every configured plugin's route modules, keyed by \`<pluginId>:<routeId>\`. - * - * The specifiers below are literal, which is the whole point of generating this - * file: Vite resolves them at build time and Rollup gives each module its own - * chunk, fetched when a loader is first called. No route component is in the - * initial bundle, and none of them is reached through a computed string - the - * browser never asks what is installed. - * - * \`satisfies\` rather than a type annotation, deliberately: it checks the shape - * while keeping both the literal keys and each module's real export types, so a - * consumer's \`await load()\` is typed by the module it loaded. - */ - export const pluginRouteModules = { - '@vitnode/example:example-page': () => import('@vitnode/example/routes/example-page'), - } satisfies PluginRouteModuleRegistry - - /** Every key in {@link pluginRouteModules}, as a union. */ - export type PluginRouteKey = keyof typeof pluginRouteModules - - /** - * The same route modules as plain data, in the same order. - * - * An object literal carries nothing beyond its keys, so the plugin id, the route - * id and the specifier are repeated here for whatever builds the router: it can - * walk this array, join each entry to its plugin's own route manifest by - * \`pluginId\` and \`routeId\`, and look the loader up by \`key\`. - */ - export const pluginRouteEntries = [ - { - entry: 'routes/example-page', - key: '@vitnode/example:example-page', - pluginId: '@vitnode/example', - routeId: 'example-page', - specifier: '@vitnode/example/routes/example-page', - }, - ] as const satisfies readonly ResolvedPluginRouteModule[] - - /** - * The route search schemas this app imports **eagerly**, keyed the same way. - * - * The one thing about a plugin route that may not be lazy. A router's - * \`validateSearch\` runs during path matching, before any chunk is fetched, so a - * route that needs a real one - a paginated table whose \`?page=999\` has to be - * clamped, a filter whose links must be typed - names a second module and the - * build imports it statically, above. - * - * Sparse by design: a route is here only if its manifest entry declares a - * \`searchEntry\`, and most do not. A route with no key here gets no - * \`validateSearch\` and normalises its query string in the loader instead, which - * is what every plugin route did before this registry existed. - * - * These modules are in the initial bundle. That is the cost, it is why the - * contract on them is "the schema, not the screen", and it is why this list is - * worth reading in a diff. - */ - export const pluginRouteSearchSchemas = {} satisfies PluginRouteSearchRegistry - " - `); - }); +/** + * The one file an application holds, asserted byte for byte. + * + * Byte for byte rather than "contains", for the two properties the file has to + * have: the same plugin configuration produces the same bytes on any machine - + * because the file lives in `src/` and a rewrite of identical bytes would trip + * the dev server's watcher - and every specifier in it is a *literal*, because a + * bundler cannot follow anything else. + */ +const modules = (...pluginIds: string[]): ResolvedPluginRoutesModule[] => + pluginIds.map(pluginId => ({ pluginId, specifier: `${pluginId}/routes` })); + +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. +// +// 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. +// +// Same plugin configuration in, same bytes out: the plugins are sorted by id. + +import type { PluginRouteDeclarationSource } from '@vitnode/core/routing' + +`; + +const body = (literal: string) => `/** + * Every configured plugin's route tree. + * + * Handed to \`pluginRouteSpecs\` in the app's router, which flattens and validates + * it with the same functions the build used - so the tree the router mounts is + * provably the tree the build checked, and a plugin removed from the config + * takes its routes with it in one step. + * + * \`satisfies\` rather than a type annotation, deliberately: it checks each + * plugin's \`routes\` export really is a \`definePluginRoutes\` tree, naming the + * plugin here rather than failing in a browser. + */ +export const pluginRouteSources = ${literal} as const satisfies readonly PluginRouteDeclarationSource[] +`; - it("emits empty literals for an app whose plugins ship no routes", () => { - const source = generate([ - { pluginId: "@vitnode/blog", routes: [] }, - { pluginId: "@vitnode/example", routes: [] }, - ]); +describe("generatePluginRoutesSource", () => { + it("emits one static import per plugin", () => { + expect(generatePluginRoutesSource(modules("@vitnode/example"))).toBe( + `${HEADER}import { routes as pluginRoutes0 } from '@vitnode/example/routes' - expect(source).toContain( - "export const pluginRouteModules = {} satisfies PluginRouteModuleRegistry", - ); - expect(source).toContain( - "export const pluginRouteEntries = [] as const satisfies readonly ResolvedPluginRouteModule[]", +${body(`[ + { + pluginId: '@vitnode/example', + routes: pluginRoutes0, + }, +]`)}`, ); }); - it("is byte-for-byte stable across calls", () => { - expect(generate(EXAMPLE)).toBe(generate(EXAMPLE)); - }); - - it("does not depend on the order the plugins were configured in", () => { - const a = { - pluginId: "@vitnode/blog", - routes: [{ entry: "routes/a", id: "a" }], - }; - const b = { - pluginId: "@vitnode/example", - routes: [{ entry: "routes/b", id: "b" }], - }; - - expect(generate([a, b])).toBe(generate([b, a])); - }); - - it("does not depend on the order one plugin declared its routes in", () => { - const routes = [ - { entry: "routes/alpha", id: "alpha" }, - { entry: "routes/zebra", id: "zebra" }, - ]; - - expect(generate([{ pluginId: "@vitnode/example", routes }])).toBe( - generate([ - { pluginId: "@vitnode/example", routes: [...routes].reverse() }, - ]), - ); + it("emits an empty literal and no imports for an app with no plugin routes", () => { + expect(generatePluginRoutesSource([])).toBe(`${HEADER}${body("[]")}`); }); - it("emits one lazy import per route and nothing eager", () => { - const source = generate([ - { - pluginId: "@vitnode/example", - routes: [ - { entry: "routes/one", id: "one" }, - { entry: "routes/two", id: "two" }, - ], - }, - ]); + it("is byte-for-byte stable across calls", () => { + const once = generatePluginRoutesSource(modules("a", "b")); - // Every route import is `() => import('...')`. The one static import in the - // file is type-only, so it is erased and nothing is pulled in eagerly. - expect(source.match(/=> import\('/g)).toHaveLength(2); - expect(source.match(/^import.*$/gm)).toEqual(["import type {"]); + expect(generatePluginRoutesSource(modules("a", "b"))).toBe(once); }); - it("sorts and de-duplicates its own input, not just the resolver's output", () => { - const modules = [ - { - entry: "routes/b", - key: "@vitnode/example:b", - pluginId: "@vitnode/example", - routeId: "b", - specifier: "@vitnode/example/routes/b", - }, - { - entry: "routes/a", - key: "@vitnode/example:a", - pluginId: "@vitnode/example", - routeId: "a", - specifier: "@vitnode/example/routes/a", - }, - ]; - - expect(generatePluginRouteRegistrySource(modules)).toBe( - generatePluginRouteRegistrySource([...modules].reverse()), + it("does not depend on the order the plugins were configured in", () => { + expect(generatePluginRoutesSource(modules("@acme/blog", "z-plugin"))).toBe( + generatePluginRoutesSource(modules("z-plugin", "@acme/blog")), ); - expect(() => - generatePluginRouteRegistrySource([modules[0], modules[0]]), - ).toThrow(/same registry key/); - }); - - it("escapes a specifier that somehow reached it unvalidated", () => { - expect( - generatePluginRouteRegistrySource([ - { - entry: "routes/x", - key: "@vitnode/example:x", - pluginId: "@vitnode/example", - routeId: "x", - specifier: "@vitnode/example/routes/x'); evil(('", - }, - ]), - ).toContain("import('@vitnode/example/routes/x\\'); evil((\\'')"); }); -}); - -/** - * The one part of a plugin route that is not lazy. - * - * A router's `validateSearch` runs during path matching, before any chunk is - * fetched, so a route that needs a real one names a second module and the build - * imports it **statically**. These assertions are about that word: a - * `() => import(...)` here would compile, would type-check, and would silently - * give the route no search schema at all - the router would have matched and - * moved on long before the promise resolved. - */ -describe("the eager search registry", () => { - const searchModule = (routeId: string) => ({ - key: `@vitnode/core:${routeId}`, - pluginId: "@vitnode/core", - routeId, - searchEntry: `routes/${routeId}.search`, - specifier: `@vitnode/core/routes/${routeId}.search`, - }); - - const withSearch = (...routeIds: string[]): string => - generatePluginRouteRegistrySource( - resolvePluginRouteModules([ - { - pluginId: "@vitnode/core", - routes: routeIds.map(id => ({ entry: `routes/${id}`, id })), - }, - ]), - routeIds.map(searchModule), - ); - it("imports a search schema statically, not through a dynamic import", () => { - const source = withSearch("staff"); + it("numbers the imports by the sorted order", () => { + const source = generatePluginRoutesSource(modules("z-plugin", "a-plugin")); expect(source).toContain( - "import { validateSearch as pluginRouteSearch0 } from '@vitnode/core/routes/staff.search'", - ); - expect(source).not.toContain("import('@vitnode/core/routes/staff.search')"); - }); - - /** Above the first `export`, because that is where a static import may be. */ - it("puts the imports at the top of the file", () => { - const source = withSearch("staff"); - - expect(source.indexOf("import { validateSearch")).toBeLessThan( - source.indexOf("export const"), + "import { routes as pluginRoutes0 } from 'a-plugin/routes'", ); - }); - - it("keys the registry by the same route id the module registry uses", () => { - const source = withSearch("staff"); - - expect(source).toContain("'@vitnode/core:staff': pluginRouteSearch0,"); expect(source).toContain( - "'@vitnode/core:staff': () => import('@vitnode/core/routes/staff'),", + "import { routes as pluginRoutes1 } from 'z-plugin/routes'", + ); + expect(source.indexOf("pluginId: 'a-plugin'")).toBeLessThan( + source.indexOf("pluginId: 'z-plugin'"), ); }); /** - * Sparse, and empty is the ordinary answer: a route appears only by declaring - * a `searchEntry`, and the registry is still exported so a consumer never has - * to test for its existence. + * The property the whole design rests on: a page module is never named by the + * app. Only the plugin's small declaration module is imported, and every page + * is reached through the `import()` inside that plugin's own `lazy()` call. */ - it("emits an empty registry and no imports when no route declares one", () => { - const source = generate(EXAMPLE); + it("names no page module, and contains no dynamic import at all", () => { + const source = generatePluginRoutesSource(modules("@vitnode/example")); + const code = source + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^\/\/.*$/gm, ""); + + expect(code).not.toContain("import("); + expect(code).not.toContain("pages/"); + expect(code.match(/^import \{/gm)).toHaveLength(1); + }); - expect(source).toContain( - "export const pluginRouteSearchSchemas = {} satisfies PluginRouteSearchRegistry", - ); - expect(source).not.toContain("import { validateSearch"); + it("rejects the same plugin twice, whichever caller assembled the list", () => { + expect(() => + generatePluginRoutesSource(modules("@acme/blog", "@acme/blog")), + ).toThrow(/Two plugins claim the same id/); }); - /** Same configuration in, same bytes out - the entries are sorted by key. */ - it("sorts by key, whatever order it is handed", () => { - expect(withSearch("users", "staff")).toBe(withSearch("staff", "users")); + it("escapes a specifier that somehow reached it unvalidated", () => { expect( - withSearch("staff", "users").indexOf("'@vitnode/core:staff'"), - ).toBeLessThan( - withSearch("staff", "users").indexOf("'@vitnode/core:users'"), - ); + generatePluginRoutesSource([ + { pluginId: "odd", specifier: "od'd/routes" }, + ]), + ).toContain("from 'od\\'d/routes'"); }); }); diff --git a/packages/vitnode/src/framework/plugin-routes/generate.ts b/packages/vitnode/src/framework/plugin-routes/generate.ts index 4f692faf0..d4665eebb 100644 --- a/packages/vitnode/src/framework/plugin-routes/generate.ts +++ b/packages/vitnode/src/framework/plugin-routes/generate.ts @@ -1,12 +1,12 @@ -import type { - ResolvedPluginRouteModule, - ResolvedPluginRouteSearchModule, -} from "./types.js"; +import type { ResolvedPluginRoutesModule } from "./types.js"; -import { sortAndAssertUnique, toSingleQuotedLiteral } from "./resolve.js"; +import { + sortAndAssertUniquePlugins, + toSingleQuotedLiteral, +} from "./resolve.js"; -/** Where the generated file imports its types from. */ -const TYPES_SPECIFIER = "@vitnode/core/framework/plugin-routes"; +/** Where the generated file imports its type from. */ +const TYPES_SPECIFIER = "@vitnode/core/routing"; /** * The generated file's header. @@ -17,181 +17,74 @@ const TYPES_SPECIFIER = "@vitnode/core/framework/plugin-routes"; * how long a plugin's name happens to be. It is excluded from both in the app * that receives it, and this says so at the top for whoever opens it anyway. */ -const HEADER_TYPES = `/* eslint-disable */ +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 two inputs and nothing else: the plugins -// configured in \`src/vitnode.config.ts\`, and the route manifest each of those -// plugins ships. Nothing here is discovered at runtime - the browser is handed -// this module, never a filesystem. +// \`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. // -// Same plugin configuration in, same bytes out: the entries are sorted by key. - -import type { - PluginRouteModuleRegistry, - PluginRouteSearchRegistry, - ResolvedPluginRouteModule, -} from '${TYPES_SPECIFIER}' - -`; - -/** - * Everything after the eager search imports. - * - * Split from {@link HEADER_TYPES} because a static import has to be at the top - * of the file, above the first `export const` - so the one part of this - * generated module that is *not* lazy goes between the types and the registry - * that would otherwise follow them directly. - */ -const HEADER_REGISTRY = `/** - * Every configured plugin's route modules, keyed by \`<pluginId>:<routeId>\`. - * - * The specifiers below are literal, which is the whole point of generating this - * file: Vite resolves them at build time and Rollup gives each module its own - * chunk, fetched when a loader is first called. No route component is in the - * initial bundle, and none of them is reached through a computed string - the - * browser never asks what is installed. - * - * \`satisfies\` rather than a type annotation, deliberately: it checks the shape - * while keeping both the literal keys and each module's real export types, so a - * consumer's \`await load()\` is typed by the module it loaded. - */ -export const pluginRouteModules = `; - -const MIDDLE = ` satisfies PluginRouteModuleRegistry +// 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. +// +// Same plugin configuration in, same bytes out: the plugins are sorted by id. -/** Every key in {@link pluginRouteModules}, as a union. */ -export type PluginRouteKey = keyof typeof pluginRouteModules +import type { PluginRouteDeclarationSource } from '${TYPES_SPECIFIER}' -/** - * The same route modules as plain data, in the same order. - * - * An object literal carries nothing beyond its keys, so the plugin id, the route - * id and the specifier are repeated here for whatever builds the router: it can - * walk this array, join each entry to its plugin's own route manifest by - * \`pluginId\` and \`routeId\`, and look the loader up by \`key\`. - */ -export const pluginRouteEntries = `; - -const FOOTER = ` as const satisfies readonly ResolvedPluginRouteModule[] `; -const SEARCH_HEADER = ` -/** - * The route search schemas this app imports **eagerly**, keyed the same way. +const BODY = `/** + * Every configured plugin's route tree. * - * The one thing about a plugin route that may not be lazy. A router's - * \`validateSearch\` runs during path matching, before any chunk is fetched, so a - * route that needs a real one - a paginated table whose \`?page=999\` has to be - * clamped, a filter whose links must be typed - names a second module and the - * build imports it statically, above. + * Handed to \`pluginRouteSpecs\` in the app's router, which flattens and validates + * it with the same functions the build used - so the tree the router mounts is + * provably the tree the build checked, and a plugin removed from the config + * takes its routes with it in one step. * - * Sparse by design: a route is here only if its manifest entry declares a - * \`searchEntry\`, and most do not. A route with no key here gets no - * \`validateSearch\` and normalises its query string in the loader instead, which - * is what every plugin route did before this registry existed. - * - * These modules are in the initial bundle. That is the cost, it is why the - * contract on them is "the schema, not the screen", and it is why this list is - * worth reading in a diff. + * \`satisfies\` rather than a type annotation, deliberately: it checks each + * plugin's \`routes\` export really is a \`definePluginRoutes\` tree, naming the + * plugin here rather than failing in a browser. */ -export const pluginRouteSearchSchemas = `; +export const pluginRouteSources = `; -const SEARCH_FOOTER = ` satisfies PluginRouteSearchRegistry +const FOOTER = ` as const satisfies readonly PluginRouteDeclarationSource[] `; /** - * The static imports the search registry is built from. + * The static imports of each plugin's route declarations. * - * Numbered rather than named after the route, because a route id contains + * Numbered rather than named after the plugin, because a package name contains * characters an identifier may not - and a positional name that is only ever - * used two lines below is easier to check than an escaping rule. The plugin and - * route are named in a trailing comment, which is what a reader actually wants. + * used a few lines below is easier to check than an escaping rule. */ -const searchImportLines = ( - modules: ResolvedPluginRouteSearchModule[], +const importLines = ( + modules: readonly ResolvedPluginRoutesModule[], ): string => { if (modules.length === 0) return ""; return `${modules .map( (module, index) => - `import { validateSearch as pluginRouteSearch${String(index)} } from ${toSingleQuotedLiteral(module.specifier)}`, + `import { routes as pluginRoutes${String(index)} } from ${toSingleQuotedLiteral(module.specifier)}`, ) - .join("\n")}\n`; + .join("\n")}\n\n`; }; -const searchRegistryLiteral = ( - modules: ResolvedPluginRouteSearchModule[], +const sourcesLiteral = ( + modules: readonly ResolvedPluginRoutesModule[], ): string => { - if (modules.length === 0) return "{}"; - - const entries = modules.map( - (module, index) => - ` ${toSingleQuotedLiteral(module.key)}: pluginRouteSearch${String(index)},`, - ); - - return `{\n${entries.join("\n")}\n}`; -}; - -/** - * Sorts search modules by key and rejects duplicates. - * - * The same rule {@link sortAndAssertUnique} applies to route modules, and - * separate only because the two carry different fields. A duplicate here is - * unreachable through the manifest - two routes cannot share an id - so this is - * a guard on a caller that built the list some other way. - */ -const sortSearchModules = ( - modules: ResolvedPluginRouteSearchModule[], -): ResolvedPluginRouteSearchModule[] => { - const sorted = [...modules].sort((a, b) => - a.key === b.key ? 0 : a.key < b.key ? -1 : 1, - ); - - const duplicates = sorted - .filter( - (module, index) => index > 0 && module.key === sorted[index - 1].key, - ) - .map(module => module.key); - - if (duplicates.length > 0) { - throw new Error( - `[VitNode plugin routes] Two routes claim the same search registry key: ${[ - ...new Set(duplicates), - ] - .map(key => JSON.stringify(key)) - .join(", ")}.`, - ); - } - - return sorted; -}; - -const registryLiteral = (modules: ResolvedPluginRouteModule[]): string => { - if (modules.length === 0) return "{}"; - - const entries = modules.map( - module => - ` ${toSingleQuotedLiteral(module.key)}: () => import(${toSingleQuotedLiteral(module.specifier)}),`, - ); - - return `{\n${entries.join("\n")}\n}`; -}; - -const entriesLiteral = (modules: ResolvedPluginRouteModule[]): string => { if (modules.length === 0) return "[]"; - const entries = modules.map(module => + const entries = modules.map((module, index) => [ " {", - ` entry: ${toSingleQuotedLiteral(module.entry)},`, - ` key: ${toSingleQuotedLiteral(module.key)},`, ` pluginId: ${toSingleQuotedLiteral(module.pluginId)},`, - ` routeId: ${toSingleQuotedLiteral(module.routeId)},`, - ` specifier: ${toSingleQuotedLiteral(module.specifier)},`, + ` routes: pluginRoutes${String(index)},`, " },", ].join("\n"), ); @@ -207,27 +100,19 @@ const entriesLiteral = (modules: ResolvedPluginRouteModule[]): string => { * byte. Writing it - and deciding whether it changed - belongs to the build tool * that has a filesystem. * - * Sorted and de-duplicated again on the way in: the resolver already does both, - * and doing it here as well is what makes "same configuration, same bytes" a - * property of this function rather than a promise about how it is called. + * Sorted and de-duplicated on the way in, so "same configuration, same bytes" is + * a property of this function rather than a promise about how it is called. */ -export const generatePluginRouteRegistrySource = ( - modules: ResolvedPluginRouteModule[], - searchModules: ResolvedPluginRouteSearchModule[] = [], +export const generatePluginRoutesSource = ( + modules: readonly ResolvedPluginRoutesModule[], ): string => { - const sorted = sortAndAssertUnique(modules); - const search = sortSearchModules(searchModules); + const sorted = sortAndAssertUniquePlugins(modules); return [ - HEADER_TYPES, - searchImportLines(search), - HEADER_REGISTRY, - registryLiteral(sorted), - MIDDLE, - entriesLiteral(sorted), + HEADER, + importLines(sorted), + BODY, + sourcesLiteral(sorted), FOOTER, - SEARCH_HEADER, - searchRegistryLiteral(search), - SEARCH_FOOTER, ].join(""); }; diff --git a/packages/vitnode/src/framework/plugin-routes/host-routes.test.ts b/packages/vitnode/src/framework/plugin-routes/host-routes.test.ts index d2de0bacc..02d90d650 100644 --- a/packages/vitnode/src/framework/plugin-routes/host-routes.test.ts +++ b/packages/vitnode/src/framework/plugin-routes/host-routes.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import type { PluginRoute } from "../../routing/types.js"; import { buildPluginRouteManifest } from "../../routing/manifest.js"; +import { definePluginRoutes, lazy, page } from "../../routing/tree.js"; import { assertNoHostRouteCollision, hostRoutePathsFromFiles, @@ -16,11 +17,15 @@ const manifestOf = (...paths: string[]): PluginRoute[] => buildPluginRouteManifest([ { pluginId: "@vitnode/example", - routes: paths.map((path, index) => ({ - entry: `routes/page-${index}`, - id: `page-${index}`, - path, - })), + routes: definePluginRoutes( + paths.map(path => + page(path, { + component: lazy( + async () => await Promise.resolve({ default: () => null }), + ), + }), + ), + ), }, ]); diff --git a/packages/vitnode/src/framework/plugin-routes/index.ts b/packages/vitnode/src/framework/plugin-routes/index.ts index 986604df1..7f0fb67ce 100644 --- a/packages/vitnode/src/framework/plugin-routes/index.ts +++ b/packages/vitnode/src/framework/plugin-routes/index.ts @@ -4,28 +4,24 @@ export type { PluginRouteCompilerSource, } from "./compile.js"; export { compilePluginRoutes } from "./compile.js"; +export { lazyImportSpecifier } from "./component-source.js"; export { annotatePluginRouteError, PLUGIN_ROUTES_ERROR_PREFIX, withPluginRouteDiagnostics, } from "./diagnostics.js"; -export { generatePluginRouteRegistrySource } from "./generate.js"; +export { generatePluginRoutesSource } from "./generate.js"; export type { HostRoutePath } from "./host-routes.js"; export { assertNoHostRouteCollision, hostRoutePathsFromFiles, } from "./host-routes.js"; -export { generatePluginRouteManifestSource } from "./manifest-source.js"; -export { assertPluginRouteRegistryParity } from "./parity.js"; export { assertPluginId, pluginIdsFromLoadedConfig, - pluginRouteEntrySources, - pluginRouteSearchModules, - resolvePluginRouteModules, - routeDeclarationsFromManifest, - sortAndAssertUnique, + routeDeclarationsFromRoutesModule, + sortAndAssertUniquePlugins, toSingleQuotedLiteral, } from "./resolve.js"; /** @@ -34,43 +30,31 @@ export { * Everything here is pure: plain declarations in, validated data or a source * string out. There is no `node:fs`, no package resolution and no framework - * the build tool that owns those (`@vitnode/core/framework/vite`) loads the app - * config and each plugin's route manifest, checks that every entry really - * resolves to a file, and writes what this returns. That split is what makes the - * part which has to be *exactly* reproducible testable without a fixture app. + * config and each plugin's `routes` module, and writes what this returns. That + * split is what makes the part which has to be *exactly* reproducible testable + * without a fixture app. * * `compilePluginRoutes` is the whole of it, and the reason it is one function - * rather than a pipeline each host assembles: the two generated files are - * written from **one resolved snapshot**. The manifest is built and validated - * first, and the module registry is derived from it - so a route reaches an - * `import()` only by having survived validation, under the id the manifest gave - * it, and a disabled plugin cannot leave a stale route or a stale import behind - * because both files are written from the list its routes are no longer in. + * rather than a pipeline each host assembles: the generated file is written from + * **one resolved snapshot**. The manifest is built and validated first, and the + * file is written from the plugins that survived it - so a plugin reaches an + * `import` only by having declared a tree that validates, and a disabled plugin + * cannot leave a stale import behind because the file is written from the list it + * is no longer in. * * compilePluginRoutes - * ├─ buildPluginRouteManifest @vitnode/core/routing validates - * ├─ assertNoHostRouteCollision does a plugin shadow the app's own - * ├─ resolvePluginRouteModules one import specifier per route - * ├─ assertPluginRouteRegistryParity the two files describe one set - * ├─ generatePluginRouteManifestSource the *what* - * └─ generatePluginRouteRegistrySource the *how* + * ├─ compilePluginRouteTrees @vitnode/core/routing flattens & validates + * ├─ assertNoHostRouteCollision does a plugin shadow the app's own + * └─ generatePluginRoutesSource one static import per plugin * * What a route *means* - its URL, its shape in the tree, its guard, its message - * namespaces - is not decided here. That is the plugin route manifest's contract, - * `@vitnode/core/routing`, and this layer validates nothing a route says for - * itself: it calls that one and adds only what a build knows and a manifest - * cannot - which file each declaration came from, and which URLs the host - * application already answers. + * namespaces, the module it renders - is not decided here. That is the plugin + * route tree's contract, `@vitnode/core/routing`, and this layer validates + * nothing a route says for itself: it calls that one and adds only what a build + * knows and a plugin cannot - which module each tree came from, and which URLs + * the host application already answers. * * How a route is *registered* is the third thing, and belongs to whichever * router the app happens to run. */ -export type { - PluginRouteEntryDeclaration, - PluginRouteEntrySource, - PluginRouteModuleLoader, - PluginRouteModuleRegistry, - PluginRouteSearchRegistry, - PluginRouteSearchValidator, - ResolvedPluginRouteModule, - ResolvedPluginRouteSearchModule, -} from "./types.js"; +export type { ResolvedPluginRoutesModule } from "./types.js"; diff --git a/packages/vitnode/src/framework/plugin-routes/manifest-source.test.ts b/packages/vitnode/src/framework/plugin-routes/manifest-source.test.ts deleted file mode 100644 index a9953c132..000000000 --- a/packages/vitnode/src/framework/plugin-routes/manifest-source.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { buildPluginRouteManifest } from "../../routing/manifest"; -import { generatePluginRouteManifestSource } from "./manifest-source"; - -const manifestOf = ( - ...sources: { pluginId: string; routes: { entry: string; path: string }[] }[] -) => - buildPluginRouteManifest( - sources.map(source => ({ - pluginId: source.pluginId, - routes: source.routes.map((route, index) => ({ - entry: route.entry, - id: `route-${index}`, - path: route.path, - })), - })), - ); - -describe("generatePluginRouteManifestSource", () => { - it("emits an empty manifest an app can still import", () => { - const source = generatePluginRouteManifestSource([]); - - expect(source).toContain("export const pluginRouteManifest = []"); - expect(source).toContain("satisfies readonly PluginRoute[]"); - expect(source).toContain("import type { PluginRoute } from"); - }); - - it("emits every field of a route, and its parsed segments", () => { - const source = generatePluginRouteManifestSource( - manifestOf({ - pluginId: "@vitnode/example", - routes: [{ entry: "routes/article", path: "/example/:slug" }], - }), - ); - - expect(source).toContain("area: 'main',"); - expect(source).toContain("entry: 'routes/article',"); - expect(source).toContain("id: '@vitnode/example:route-0',"); - expect(source).toContain("path: '/example/:slug',"); - expect(source).toContain("pluginId: '@vitnode/example',"); - expect(source).toContain("kind: 'page',"); - expect(source).toContain("namespaces: [],"); - expect(source).toContain("parentId: null,"); - expect(source).toContain("requires: null,"); - expect(source).toContain("routeId: 'route-0',"); - expect(source).toContain( - "segments: [{ kind: 'static', value: 'example' }, { kind: 'param', name: 'slug' }],", - ); - }); - - it("emits a root route as an empty segment list", () => { - const source = generatePluginRouteManifestSource( - manifestOf({ - pluginId: "landing", - routes: [{ entry: "routes/home", path: "/" }], - }), - ); - - expect(source).toContain("path: '/',"); - expect(source).toContain("segments: [],"); - }); - - /** - * The property the whole "generate a file into `src/`" approach rests on: the - * bytes depend on the configuration and nothing else. If they depended on the - * order plugins happen to load in, the file would churn between developers and - * the dev server would reload in a loop on every restart. - */ - it("produces the same bytes whatever order the plugins are read in", () => { - const a = { pluginId: "a-plugin", routes: [{ entry: "r/a", path: "/a" }] }; - const b = { pluginId: "b-plugin", routes: [{ entry: "r/b", path: "/b" }] }; - - expect(generatePluginRouteManifestSource(manifestOf(a, b))).toBe( - generatePluginRouteManifestSource(manifestOf(b, a)), - ); - }); - - it("sorts routes even when handed a manifest out of order", () => { - const [a, b] = manifestOf({ - pluginId: "plugin", - routes: [ - { entry: "r/z", path: "/z" }, - { entry: "r/a", path: "/a" }, - ], - }); - - expect(generatePluginRouteManifestSource([b, a])).toBe( - generatePluginRouteManifestSource([a, b]), - ); - expect( - generatePluginRouteManifestSource([b, a]).indexOf("path: '/a',"), - ).toBeLessThan( - generatePluginRouteManifestSource([b, a]).indexOf("path: '/z',"), - ); - }); - - /** - * The file is written into an app's `src/`, so nothing that reaches it may be - * able to close a string literal. Every value has already been matched against - * a pattern that cannot contain a quote - this asserts the generator does not - * rely on that being true forever. - */ - it("escapes what it writes", () => { - const source = generatePluginRouteManifestSource([ - { - area: "main", - entry: "routes/x", - id: "p:x", - kind: "page", - namespaces: ["it's"], - parentId: null, - path: "/x", - pluginId: "p", - requires: null, - routeId: "x", - searchEntry: null, - segments: [{ kind: "static", value: "it's" }], - }, - ]); - - expect(source).toContain("{ kind: 'static', value: 'it\\'s' }"); - expect(source).toContain("namespaces: ['it\\'s'],"); - }); -}); diff --git a/packages/vitnode/src/framework/plugin-routes/manifest-source.ts b/packages/vitnode/src/framework/plugin-routes/manifest-source.ts deleted file mode 100644 index 90e276a91..000000000 --- a/packages/vitnode/src/framework/plugin-routes/manifest-source.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { - PluginRoute, - PluginRouteManifest, - PluginRouteSegment, -} from "../../routing/types.js"; - -import { comparePluginRoutes } from "../../routing/order.js"; -import { toSingleQuotedLiteral } from "./resolve.js"; - -/** Where the generated file imports its type from. */ -const TYPES_SPECIFIER = "@vitnode/core/routing"; - -/** - * The generated file's header. - * - * Same rules as its sibling registry: rewritten on every build, so anything a - * linter or a formatter changes in it is lost, and a reflow would make the - * output depend on how long a plugin's name happens to be. - */ -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 the plugins configured in -// \`src/vitnode.config.ts\` and the route manifest each of those plugins ships. -// -// This is the *what*: which routes exist, and at which canonical VitNode path. -// Its sibling \`plugin-routes.gen.ts\` is the *how*: one lazy import per route -// module. Neither knows what a router is. -// -// Same plugin configuration in, same bytes out: the routes are sorted by path. - -import type { PluginRoute } from '${TYPES_SPECIFIER}' - -/** - * Every route this app's configured plugins contribute, already validated. - * - * \`buildPluginRouteManifest\` produced this list while the app was being built, - * which is the whole reason it is a literal here: a path that cannot be parsed, - * an entry that cannot be imported and two plugins claiming one URL have all - * already failed the build by the time this file exists. Nothing reads it to - * find out whether the routes are valid - only to find out what they are. - * - * Deliberately not \`as const\`: a route's \`segments\` is a mutable array on - * \`PluginRoute\`, and a frozen tuple would not satisfy it. \`satisfies\` alone - * still checks every field and still narrows \`area\` and \`kind\` to their unions. - */ -export const pluginRouteManifest = `; - -const FOOTER = ` satisfies readonly PluginRoute[] -`; - -const segmentLiteral = (segment: PluginRouteSegment): string => - segment.kind === "param" - ? `{ kind: 'param', name: ${toSingleQuotedLiteral(segment.name)} }` - : `{ kind: 'static', value: ${toSingleQuotedLiteral(segment.value)} }`; - -/** - * A value that is either a string literal or `null`. - * - * `parentId`, `requires` and `searchEntry` are all "declared, or not" - and all - * are emitted as `null` rather than left out. A missing field would still satisfy - * `PluginRoute` if the type ever loosened, and the whole reason the built type - * has no optional members is so that a generator which forgets one is a compile - * error rather than a route that silently loses its parent. - */ -const optionalLiteral = (value: null | string): string => - value === null ? "null" : toSingleQuotedLiteral(value); - -const routeLiteral = (route: PluginRoute): string => - [ - " {", - ` area: ${toSingleQuotedLiteral(route.area)},`, - ` entry: ${toSingleQuotedLiteral(route.entry)},`, - ` id: ${toSingleQuotedLiteral(route.id)},`, - ` kind: ${toSingleQuotedLiteral(route.kind)},`, - ` namespaces: [${route.namespaces.map(toSingleQuotedLiteral).join(", ")}],`, - ` parentId: ${optionalLiteral(route.parentId)},`, - ` path: ${toSingleQuotedLiteral(route.path)},`, - ` pluginId: ${toSingleQuotedLiteral(route.pluginId)},`, - ` requires: ${optionalLiteral(route.requires)},`, - ` routeId: ${toSingleQuotedLiteral(route.routeId)},`, - ` searchEntry: ${optionalLiteral(route.searchEntry)},`, - ` segments: [${route.segments.map(segmentLiteral).join(", ")}],`, - " },", - ].join("\n"); - -const manifestLiteral = (manifest: PluginRouteManifest): string => { - if (manifest.length === 0) return "[]"; - - return `[\n${manifest.map(routeLiteral).join("\n")}\n]`; -}; - -/** - * The source of an app's plugin route manifest. - * - * A string rather than a file, for the same reason as - * {@link generatePluginRouteRegistrySource}: the part that has to be exactly - * reproducible is a pure function of its input and can be asserted byte for - * byte, and writing it belongs to the build tool that has a filesystem. - * - * Sorted again on the way in with the manifest layer's own comparator, so "same - * configuration, same bytes" is a property of this function rather than a - * promise about how it is called. - */ -export const generatePluginRouteManifestSource = ( - manifest: PluginRouteManifest, -): string => - `${HEADER}${manifestLiteral([...manifest].sort(comparePluginRoutes))}${FOOTER}`; diff --git a/packages/vitnode/src/framework/plugin-routes/parity.test.ts b/packages/vitnode/src/framework/plugin-routes/parity.test.ts deleted file mode 100644 index f599a5912..000000000 --- a/packages/vitnode/src/framework/plugin-routes/parity.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -// @vitest-environment node -import { describe, expect, it } from "vitest"; - -import type { PluginRoute } from "../../routing/types.js"; -import type { ResolvedPluginRouteModule } from "./types.js"; - -import { assertPluginRouteRegistryParity } from "./parity.js"; - -const route = (routeId: string, entry = `routes/${routeId}`): PluginRoute => ({ - area: "main", - entry, - id: `@vitnode/example:${routeId}`, - kind: "page", - namespaces: [], - parentId: null, - path: `/${routeId}`, - pluginId: "@vitnode/example", - requires: null, - routeId, - searchEntry: null, - segments: [{ kind: "static", value: routeId }], -}); - -const module = ( - routeId: string, - entry = `routes/${routeId}`, -): ResolvedPluginRouteModule => ({ - entry, - key: `@vitnode/example:${routeId}`, - pluginId: "@vitnode/example", - routeId, - specifier: `@vitnode/example/${entry}`, -}); - -describe("assertPluginRouteRegistryParity", () => { - it("passes when both files describe the same routes", () => { - expect(() => - assertPluginRouteRegistryParity( - [route("a"), route("b")], - [module("a"), module("b")], - ), - ).not.toThrow(); - }); - - it("passes for an app with no plugin routes at all", () => { - expect(() => assertPluginRouteRegistryParity([], [])).not.toThrow(); - }); - - it("rejects a manifest route with no module - a page with no code", () => { - expect(() => - assertPluginRouteRegistryParity([route("a"), route("b")], [module("a")]), - ).toThrow("@vitnode/example:b"); - }); - - it("rejects an orphaned module - code nothing can reach", () => { - expect(() => - assertPluginRouteRegistryParity([route("a")], [module("a"), module("b")]), - ).toThrow("not in the route manifest: @vitnode/example:b"); - }); - - it("rejects one id describing two different entries", () => { - // The drift that would load one plugin's component for another's URL. - expect(() => - assertPluginRouteRegistryParity( - [route("a", "routes/a")], - [module("a", "routes/somewhere-else")], - ), - ).toThrow("disagree about @vitnode/example:a"); - }); - - it("names the failure as the generator's rather than a plugin's", () => { - // Both files are written from one resolved manifest, so nothing a plugin - // author can write reaches this - and the message should say so instead of - // sending them to look at their own route declarations. - expect(() => assertPluginRouteRegistryParity([route("a")], [])).toThrow( - "a bug in the generator", - ); - }); -}); diff --git a/packages/vitnode/src/framework/plugin-routes/parity.ts b/packages/vitnode/src/framework/plugin-routes/parity.ts deleted file mode 100644 index da59398d4..000000000 --- a/packages/vitnode/src/framework/plugin-routes/parity.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { PluginRoute } from "../../routing/types.js"; -import type { ResolvedPluginRouteModule } from "./types.js"; - -import { PLUGIN_ROUTES_ERROR_PREFIX } from "./diagnostics.js"; - -/** - * The two generated files describe the same set of routes, or the build stops. - * - * `plugin-route-manifest.gen.ts` says *what* routes exist and - * `plugin-routes.gen.ts` says *how* each one's module is imported, and the - * runtime joins them by route id - so a route in one and not the other is a page - * that either 404s or is bundled and never reachable. `pluginRouteSpecs` refuses - * that at runtime, in the browser and in SSR, which is the right place for the - * *last* line of defence and a terrible place for the first. - * - * This is the first. Both lists come from one resolved manifest in - * {@link compilePluginRoutes} - the registry's entries are *derived* from the - * manifest's routes rather than read from a second pass over the plugins - so - * this check should be unfailable, and that is exactly why it is written down: - * it is the assertion that keeps the derivation honest if somebody later gives - * the registry its own source of truth back. Cheap, total, and it names the ids. - * - * Checked in three directions, because they are three different mistakes: - * - * - **Missing.** A manifest route with no module - the page has no code. - * - **Orphaned.** A module with no manifest route - code nothing can reach, and - * a chunk in the bundle for a route that was removed. - * - **Disagreeing.** Both sides have the id but describe different plugins, - * route ids or entries, which is the drift that would otherwise load one - * plugin's component for another plugin's URL. - */ -export const assertPluginRouteRegistryParity = ( - manifest: readonly PluginRoute[], - modules: readonly ResolvedPluginRouteModule[], -): void => { - const byKey = new Map(modules.map(module => [module.key, module])); - const claimed = new Set(manifest.map(route => route.id)); - - const missing = manifest - .filter(route => !byKey.has(route.id)) - .map(route => route.id); - - if (missing.length > 0) { - throw new Error( - `${PLUGIN_ROUTES_ERROR_PREFIX} The route manifest has routes with no module in the registry: ${missing.join(", ")}. Both generated files are written from one resolved manifest, so this is a bug in the generator rather than something a plugin can cause.`, - ); - } - - const orphaned = modules - .filter(module => !claimed.has(module.key)) - .map(module => module.key); - - if (orphaned.length > 0) { - throw new Error( - `${PLUGIN_ROUTES_ERROR_PREFIX} The module registry has modules for routes that are not in the route manifest: ${orphaned.join(", ")}. Both generated files are written from one resolved manifest, so this is a bug in the generator rather than something a plugin can cause.`, - ); - } - - const disagreeing = manifest - .filter(route => { - const module = byKey.get(route.id); - - return ( - module !== undefined && - (module.entry !== route.entry || - module.pluginId !== route.pluginId || - module.routeId !== route.routeId) - ); - }) - .map(route => route.id); - - if (disagreeing.length > 0) { - throw new Error( - `${PLUGIN_ROUTES_ERROR_PREFIX} The route manifest and the module registry disagree about ${disagreeing.join(", ")}: the same route id describes a different plugin, route id or entry in each. Both generated files are written from one resolved manifest, so this is a bug in the generator rather than something a plugin can cause.`, - ); - } -}; diff --git a/packages/vitnode/src/framework/plugin-routes/resolve.test.ts b/packages/vitnode/src/framework/plugin-routes/resolve.test.ts index 0e36b4372..0fd5e6d82 100644 --- a/packages/vitnode/src/framework/plugin-routes/resolve.test.ts +++ b/packages/vitnode/src/framework/plugin-routes/resolve.test.ts @@ -1,249 +1,105 @@ +// @vitest-environment node import { describe, expect, it } from "vitest"; -import type { PluginRoute } from "../../routing/types.js"; -import type { PluginRouteEntrySource } from "./types.js"; - -import { pluginRouteId } from "../../routing/manifest.js"; import { + assertPluginId, pluginIdsFromLoadedConfig, - pluginRouteEntrySources, - resolvePluginRouteModules, - routeDeclarationsFromManifest, - sortAndAssertUnique, + routeDeclarationsFromRoutesModule, + sortAndAssertUniquePlugins, toSingleQuotedLiteral, } from "./resolve.js"; -const source = ( - pluginId: string, - ...routes: { entry: string; id: string }[] -): PluginRouteEntrySource => ({ pluginId, routes }); - -describe("resolvePluginRouteModules", () => { - it("pairs each declaration with the specifier it will be imported by", () => { +/** + * The part of the build that turns configuration into strings a generated file + * can contain. + * + * Everything here is pure, and everything here is a *string* going into a source + * file - which is why the validation is as strict as it is: a plugin id is + * concatenated into an import specifier, and an id nobody checked is an app + * whose generated source says whatever a `package.json` did. + */ +describe("sortAndAssertUniquePlugins", () => { + it("orders by plugin id, not by configuration order", () => { expect( - resolvePluginRouteModules([ - source("@vitnode/example", { - entry: "routes/example-page", - id: "example-page", - }), - ]), - ).toEqual([ - { - entry: "routes/example-page", - key: "@vitnode/example:example-page", - pluginId: "@vitnode/example", - routeId: "example-page", - specifier: "@vitnode/example/routes/example-page", - }, - ]); - }); - - it("orders by key, not by the order the plugins were configured in", () => { - const forwards = resolvePluginRouteModules([ - source("@vitnode/example", { entry: "routes/b", id: "b" }), - source("@vitnode/blog", { entry: "routes/a", id: "a" }), - ]); - const backwards = resolvePluginRouteModules([ - source("@vitnode/blog", { entry: "routes/a", id: "a" }), - source("@vitnode/example", { entry: "routes/b", id: "b" }), - ]); - - expect(forwards.map(module => module.key)).toEqual([ - "@vitnode/blog:a", - "@vitnode/example:b", - ]); - expect(backwards).toEqual(forwards); + sortAndAssertUniquePlugins([ + { pluginId: "@vitnode/example", specifier: "@vitnode/example/routes" }, + { pluginId: "@acme/blog", specifier: "@acme/blog/routes" }, + ]).map(module => module.pluginId), + ).toEqual(["@acme/blog", "@vitnode/example"]); }); - it("orders one plugin's own routes by key too", () => { + it("compares code units rather than using the machine's collation", () => { + // `localeCompare` sorts "a" before "B"; a build has to be reproducible. expect( - resolvePluginRouteModules([ - source( - "@vitnode/example", - { entry: "routes/zebra", id: "zebra" }, - { entry: "routes/alpha", id: "alpha" }, - { entry: "routes/nested/leaf", id: "nested/leaf" }, - ), - ]).map(module => module.routeId), - ).toEqual(["alpha", "nested/leaf", "zebra"]); - }); - - it("returns nothing for a plugin that declares no routes", () => { - expect(resolvePluginRouteModules([source("@vitnode/blog")])).toEqual([]); - expect(resolvePluginRouteModules([{ pluginId: "@vitnode/blog" }])).toEqual( - [], - ); + sortAndAssertUniquePlugins([ + { pluginId: "a-plugin", specifier: "a-plugin/routes" }, + { pluginId: "B-plugin", specifier: "B-plugin/routes" }, + ]).map(module => module.pluginId), + ).toEqual(["B-plugin", "a-plugin"]); }); - it("rejects two declarations claiming one key", () => { - expect(() => - resolvePluginRouteModules([ - source( - "@vitnode/example", - { entry: "routes/one", id: "duplicate" }, - { entry: "routes/two", id: "duplicate" }, - ), - ]), - ).toThrow(/same registry key: "@vitnode\/example:duplicate"/); - }); + it("does not mutate its argument", () => { + const modules = [ + { pluginId: "b", specifier: "b/routes" }, + { pluginId: "a", specifier: "a/routes" }, + ]; - it("rejects the same key contributed by two configured entries", () => { - expect(() => - resolvePluginRouteModules([ - source("@vitnode/example", { entry: "routes/one", id: "page" }), - source("@vitnode/example", { entry: "routes/one", id: "page" }), - ]), - ).toThrow(/same registry key/); - }); + sortAndAssertUniquePlugins(modules); - it("allows two route ids pointing at one module", () => { - expect( - resolvePluginRouteModules([ - source( - "@vitnode/example", - { entry: "routes/shared", id: "first" }, - { entry: "routes/shared", id: "second" }, - ), - ]).map(module => module.specifier), - ).toEqual([ - "@vitnode/example/routes/shared", - "@vitnode/example/routes/shared", - ]); + expect(modules.map(module => module.pluginId)).toEqual(["b", "a"]); }); - it.each([ - ["../../../etc/passwd", "a parent-directory traversal"], - ["routes/../../secret", "a traversal in the middle"], - ["/routes/page", "an absolute path"], - ["./routes/page", "a relative-looking path"], - ["routes\\page", "a backslash"], - ["routes/page'", "a quote"], - ["routes/\npage", "a newline"], - ["routes/", "a trailing separator"], - ["", "an empty entry"], - [" routes/page", "padding"], - ])("rejects the entry %j - %s", entry => { + it("rejects the same plugin configured twice", () => { expect(() => - resolvePluginRouteModules([ - source("@vitnode/example", { entry, id: "x" }), + sortAndAssertUniquePlugins([ + { pluginId: "@acme/blog", specifier: "@acme/blog/routes" }, + { pluginId: "@acme/blog", specifier: "@acme/blog/routes" }, ]), - ).toThrow(/\[VitNode plugin routes\]/); + ).toThrow(/Two plugins claim the same id: "@acme\/blog"/); }); - it.each([".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"])( - "rejects an entry ending in %s, because the export map adds the extension", - extension => { - expect(() => - resolvePluginRouteModules([ - source("@vitnode/example", { - entry: `routes/page${extension}`, - id: "page", - }), - ]), - ).toThrow(/file extension/); - }, - ); - - it.each(["../evil", "@scope", "has space", "quote'", "back\\slash", ""])( - "rejects the plugin id %j", - pluginId => { - expect(() => - resolvePluginRouteModules([ - source(pluginId, { entry: "routes/page", id: "page" }), - ]), - ).toThrow(/not a package name/); - }, - ); + it("has nothing to say about no plugins at all", () => { + expect(sortAndAssertUniquePlugins([])).toEqual([]); + }); +}); - it.each(["@vitnode/example", "my-plugin", "@scope/nested.plugin_1"])( - "accepts the plugin id %j", +describe("assertPluginId", () => { + it.each(["@vitnode/example", "my-plugin", "a.b_c-d", "@a/b.c"])( + "accepts %s, which npm does", pluginId => { - expect( - resolvePluginRouteModules([ - source(pluginId, { entry: "routes/page", id: "page" }), - ])[0].specifier, - ).toBe(`${pluginId}/routes/page`); - }, - ); - - it.each(["with:colon", "with space", "/leading", "-leading", ""])( - "rejects the route id %j", - id => { - expect(() => - resolvePluginRouteModules([ - source("@vitnode/example", { entry: "routes/page", id }), - ]), - ).toThrow(/\[VitNode plugin routes\]/); + expect(assertPluginId(pluginId, "vitnode.config.ts")).toBe(pluginId); }, ); -}); - -describe("the registry key", () => { - it("is the manifest layer's own route id, not a second copy of the rule", () => { - expect( - resolvePluginRouteModules([ - source("@vitnode/example", { - entry: "routes/example-page", - id: "example-page", - }), - ])[0].key, - ).toBe(pluginRouteId("@vitnode/example", "example-page")); - }); -}); - -describe("sortAndAssertUnique", () => { - it("compares code units rather than using the machine's collation", () => { - const modules = ["b", "A", "_", "a", "B"].map(key => ({ - entry: "routes/x", - key, - pluginId: "@vitnode/example", - routeId: key, - specifier: "@vitnode/example/routes/x", - })); - expect(sortAndAssertUnique(modules).map(module => module.key)).toEqual([ - "A", - "B", - "_", - "a", - "b", - ]); - }); - - it("does not mutate its argument", () => { - const modules = ["b", "a"].map(key => ({ - entry: "routes/x", - key, - pluginId: "@vitnode/example", - routeId: key, - specifier: "@vitnode/example/routes/x", - })); - - sortAndAssertUnique(modules); - - expect(modules.map(module => module.key)).toEqual(["b", "a"]); + it.each([ + "../escape", + "with space", + "quote'd", + "back\\slash", + "", + "@scope", + "@scope/", + ])("rejects %j, which an import specifier may not contain", pluginId => { + expect(() => assertPluginId(pluginId, "vitnode.config.ts")).toThrow( + /which is not a package name/, + ); }); }); describe("toSingleQuotedLiteral", () => { it.each([ - ["@vitnode/example/routes/page", "'@vitnode/example/routes/page'"], + ["plain", "'plain'"], ["it's", "'it\\'s'"], ["back\\slash", "'back\\\\slash'"], ["line\nbreak", "'line\\nbreak'"], - ["carriage\rreturn", "'carriage\\rreturn'"], - ["'); rm -rf /; ('", "'\\'); rm -rf /; (\\''"], ])("escapes %j", (value, expected) => { expect(toSingleQuotedLiteral(value)).toBe(expected); }); it("produces a literal that evaluates back to the original", () => { - for (const value of ["a'b", "a\\b", "a\\'b", "a\nb"]) { - // eslint-disable-next-line @typescript-eslint/no-implied-eval - expect(new Function(`return ${toSingleQuotedLiteral(value)}`)()).toBe( - value, - ); - } + const value = "a'b\\c\nd"; + + expect(eval(toSingleQuotedLiteral(value))).toBe(value); }); }); @@ -254,14 +110,14 @@ describe("pluginIdsFromLoadedConfig", () => { { vitNodeConfig: { plugins: [ - { pluginId: "@vitnode/blog" }, { pluginId: "@vitnode/example" }, + { pluginId: "@acme/blog" }, ], }, }, "src/vitnode.config.ts", ), - ).toEqual(["@vitnode/blog", "@vitnode/example"]); + ).toEqual(["@vitnode/example", "@acme/blog"]); }); it("accepts an app with no plugins", () => { @@ -274,14 +130,10 @@ describe("pluginIdsFromLoadedConfig", () => { }); it.each([ - [undefined, /does not export/], - [{}, /does not export/], - [{ vitNodeConfig: null }, /does not export/], + [undefined, /does not export `vitNodeConfig`/], + [{}, /does not export `vitNodeConfig`/], [{ vitNodeConfig: {} }, /is not an array/], - [{ vitNodeConfig: { plugins: "blog" } }, /is not an array/], - [{ vitNodeConfig: { plugins: [{}] } }, /has no string `pluginId`/], - [{ vitNodeConfig: { plugins: [null] } }, /has no string `pluginId`/], - [{ vitNodeConfig: { plugins: [{ pluginId: 1 }] } }, /has no string/], + [{ vitNodeConfig: { plugins: "nope" } }, /is not an array/], ])("rejects %j", (loaded, message) => { expect(() => pluginIdsFromLoadedConfig(loaded, "src/vitnode.config.ts"), @@ -291,10 +143,10 @@ describe("pluginIdsFromLoadedConfig", () => { it("names the offending index", () => { expect(() => pluginIdsFromLoadedConfig( - { vitNodeConfig: { plugins: [{ pluginId: "@vitnode/blog" }, {}] } }, + { vitNodeConfig: { plugins: [{ pluginId: "ok" }, {}] } }, "src/vitnode.config.ts", ), - ).toThrow(/plugins\[1\]/); + ).toThrow(/`vitNodeConfig\.plugins\[1\]`/); }); it("rejects a configured id that is not a package name", () => { @@ -303,98 +155,63 @@ describe("pluginIdsFromLoadedConfig", () => { { vitNodeConfig: { plugins: [{ pluginId: "../evil" }] } }, "src/vitnode.config.ts", ), - ).toThrow(/not a package name/); + ).toThrow(/which is not a package name/); }); }); -describe("routeDeclarationsFromManifest", () => { - it("reads only the id and the entry, ignoring everything else", () => { - expect( - routeDeclarationsFromManifest( - { - routes: [ - { - entry: "routes/example-page", - id: "example-page", - path: "/example", - permissions: ["staff"], - }, - ], - }, - "@vitnode/example/routes/manifest", - ), - ).toEqual([{ entry: "routes/example-page", id: "example-page" }]); - }); +describe("routeDeclarationsFromRoutesModule", () => { + it("hands the tree on untouched", () => { + const routes = [{ anything: true }]; - it("accepts a manifest declaring no routes", () => { expect( - routeDeclarationsFromManifest({ routes: [] }, "@x/y/routes/manifest"), - ).toEqual([]); + routeDeclarationsFromRoutesModule({ routes }, "@acme/blog/routes"), + ).toBe(routes); }); - it.each([ - [undefined, /does not export `routes`/], - [{}, /does not export `routes`/], - [{ routes: {} }, /is not an array/], - [{ routes: [{ id: "a" }] }, /is not a `\{ id: string, entry: string \}`/], - [{ routes: [{ entry: "routes/a" }] }, /is not a/], - [{ routes: ["routes/a"] }, /is not a/], - ])("rejects %j", (loaded, message) => { - expect(() => - routeDeclarationsFromManifest(loaded, "@x/y/routes/manifest"), - ).toThrow(message); - }); -}); - -describe("pluginRouteEntrySources", () => { - const built = (pluginId: string, routeId: string): PluginRoute => ({ - area: "main", - entry: `routes/${routeId}`, - id: `${pluginId}:${routeId}`, - kind: "page", - namespaces: [], - parentId: null, - path: `/${routeId}`, - pluginId, - requires: null, - routeId, - searchEntry: null, - segments: [{ kind: "static", value: routeId }], - }); - - it("turns a built manifest back into per-plugin declarations", () => { + it("accepts a module declaring no routes", () => { expect( - pluginRouteEntrySources([ - built("@vitnode/example", "a"), - built("@vitnode/blog", "b"), - built("@vitnode/example", "c"), - ]), - ).toEqual([ - { - pluginId: "@vitnode/example", - routes: [ - { entry: "routes/a", id: "a" }, - { entry: "routes/c", id: "c" }, - ], - }, - { pluginId: "@vitnode/blog", routes: [{ entry: "routes/b", id: "b" }] }, - ]); + routeDeclarationsFromRoutesModule({ routes: [] }, "@acme/blog/routes"), + ).toEqual([]); }); - it("rebuilds the global id the manifest already gave the route", () => { - // The join between the two generated files: a module is registered under - // exactly the id the manifest addresses the route by, by construction - // rather than by two layers agreeing on a format. - const manifest = [built("@vitnode/example", "a")]; + it.each([undefined, {}, { default: [] }])( + "rejects a module that exports no `routes` (%j)", + loaded => { + expect(() => + routeDeclarationsFromRoutesModule(loaded, "@acme/blog/routes"), + ).toThrow(/does not export `routes`/); + }, + ); - expect( - resolvePluginRouteModules(pluginRouteEntrySources(manifest)).map( - module => module.key, + it("rejects a `routes` that is not an array", () => { + expect(() => + routeDeclarationsFromRoutesModule( + { routes: { nope: true } }, + "@acme/blog/routes", ), - ).toEqual(manifest.map(route => route.id)); + ).toThrow(/`routes` in @acme\/blog\/routes is not an array/); }); - it("has nothing to say about a manifest with no routes", () => { - expect(pluginRouteEntrySources([])).toEqual([]); + /** + * The one migration this build can recognise, and the reason it is worth + * recognising: the flat manifest's `entry` was a string the *app* imported, + * and a page is now named by the plugin's own `lazy(() => import(...))`. There + * is no adapter that could turn one into the other, so the shape is named + * rather than half-supported. + */ + it("names the old flat manifest rather than failing later", () => { + expect(() => + routeDeclarationsFromRoutesModule( + { + routes: [ + { entry: "routes/home-page", id: "home", path: "/blog" }, + { entry: "routes/post-page", id: "post", path: "/blog/:slug" }, + ], + }, + "@acme/blog/routes", + ), + ).toThrow( + /exports the old flat route manifest - 2 routes declaring an `entry`/, + ); }); }); diff --git a/packages/vitnode/src/framework/plugin-routes/resolve.ts b/packages/vitnode/src/framework/plugin-routes/resolve.ts index f79843751..e7b959780 100644 --- a/packages/vitnode/src/framework/plugin-routes/resolve.ts +++ b/packages/vitnode/src/framework/plugin-routes/resolve.ts @@ -1,16 +1,9 @@ -import type { PluginRoute } from "../../routing/types.js"; -import type { - PluginRouteEntryDeclaration, - PluginRouteEntrySource, - ResolvedPluginRouteModule, - ResolvedPluginRouteSearchModule, -} from "./types.js"; +import type { ResolvedPluginRoutesModule } from "./types.js"; -import { pluginRouteId } from "../../routing/manifest.js"; import { PLUGIN_ROUTES_ERROR_PREFIX as ERROR_PREFIX } from "./diagnostics.js"; /** - * A plugin id, which in VitNode is also the package name the route module is + * A plugin id, which in VitNode is also the package name its routes module is * imported from - `@vitnode/example`, `my-plugin`. * * Matched rather than trusted because it is concatenated into an import @@ -21,52 +14,14 @@ import { PLUGIN_ROUTES_ERROR_PREFIX as ERROR_PREFIX } from "./diagnostics.js"; const PLUGIN_ID_PATTERN = /^(?:@[A-Za-z0-9][A-Za-z0-9._-]*\/)?[A-Za-z0-9][A-Za-z0-9._-]*$/; -/** One path segment: no dots-only segments, so `.` and `..` cannot appear. */ -const SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; - -/** - * An extension on an entry, which is always a mistake. - * - * A plugin's export map maps subpaths to build output - `"./*"` to - * `"./dist/src/*.js"` - so the subpath is extensionless. `"routes/page.tsx"` - * would resolve to `dist/src/routes/page.tsx.js` and fail with a message about a - * file nobody wrote, so it is worth naming here instead. - */ -const ENTRY_EXTENSION_PATTERN = /\.[cm]?[jt]sx?$/; - const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null; -const assertSegmentedPath = ({ - label, - source, - value, -}: { - label: string; - source: string; - value: string; -}): void => { - if (value === "" || value !== value.trim()) { - throw new Error( - `${ERROR_PREFIX} ${source} declares a route with ${label} ${JSON.stringify(value)}, which is empty or padded with whitespace.`, - ); - } - - const segments = value.split("/"); - const invalid = segments.filter(segment => !SEGMENT_PATTERN.test(segment)); - - if (invalid.length > 0) { - throw new Error( - `${ERROR_PREFIX} ${source} declares a route with ${label} ${JSON.stringify(value)}. Use "/"-separated segments of letters, digits, ".", "_" and "-" - a leading "/", a "." or ".." segment, a backslash or a quote is never valid, because this is written into a generated import.`, - ); - } -}; - /** Validates a plugin id and returns it unchanged, so it can be used inline. */ export const assertPluginId = (pluginId: string, source: string): string => { if (!PLUGIN_ID_PATTERN.test(pluginId)) { throw new Error( - `${ERROR_PREFIX} ${source} declares the plugin id ${JSON.stringify(pluginId)}, which is not a package name. A route module is imported from the plugin's package, so the id has to be one.`, + `${ERROR_PREFIX} ${source} declares the plugin id ${JSON.stringify(pluginId)}, which is not a package name. A plugin's routes module is imported from the plugin's package, so the id has to be one.`, ); } @@ -123,7 +78,7 @@ export const pluginIdsFromLoadedConfig = ( return plugins.map((plugin: unknown, index) => { if (!isRecord(plugin) || typeof plugin.pluginId !== "string") { throw new Error( - `${ERROR_PREFIX} \`vitNodeConfig.plugins[${index}]\` in ${source} has no string \`pluginId\`.`, + `${ERROR_PREFIX} \`vitNodeConfig.plugins[${String(index)}]\` in ${source} has no string \`pluginId\`.`, ); } @@ -132,20 +87,26 @@ export const pluginIdsFromLoadedConfig = ( }; /** - * Reads the route declarations out of an already-loaded plugin route manifest. - * - * Pure for the same reason as {@link pluginIdsFromLoadedConfig}, and equally - * strict: a manifest that exports the wrong shape has to fail here, with the - * specifier in the message, rather than three steps later as a generated file - * that will not compile. + * The route tree out of an already-loaded plugin `routes` module. + * + * Strict about the *module* and deliberately incurious about the tree: a module + * that exports no `routes`, or a `routes` that is not an array, is named here + * with its specifier in the message. What each node then means is + * `flattenPluginRoutes`' to decide, and it reads every field defensively from + * `unknown` - two readers rather than one shared narrowed shape, so neither + * layer has to know what the other requires. + * + * A plugin still exporting the flat `routes/manifest` shape - records with an + * `entry`, an `id` and a `kind` - is named as such, because the generated file + * would otherwise fail to compile with a type error nobody in the app wrote. */ -export const routeDeclarationsFromManifest = ( +export const routeDeclarationsFromRoutesModule = ( loaded: unknown, source: string, -): PluginRouteEntryDeclaration[] => { +): unknown[] => { if (!isRecord(loaded) || !("routes" in loaded)) { throw new Error( - `${ERROR_PREFIX} ${source} does not export \`routes\`. A plugin route manifest exports an array of \`{ id, entry }\`.`, + `${ERROR_PREFIX} ${source} does not export \`routes\`. A plugin's routes module is \`export const routes = definePluginRoutes([...])\`.`, ); } @@ -155,169 +116,47 @@ export const routeDeclarationsFromManifest = ( throw new Error(`${ERROR_PREFIX} \`routes\` in ${source} is not an array.`); } - return routes.map((route: unknown, index) => { - if ( - !isRecord(route) || - typeof route.id !== "string" || - typeof route.entry !== "string" - ) { - throw new Error( - `${ERROR_PREFIX} \`routes[${index}]\` in ${source} is not a \`{ id: string, entry: string }\` record.`, - ); - } - - return { entry: route.entry, id: route.id }; - }); -}; - -/** - * Every configured plugin's declarations, validated and put in a fixed order. - * - * Two guarantees, and both are the reason this is a separate step rather than - * something the generator does inline: - * - * - **Deterministic.** The result is sorted by key with a code-unit comparison, - * not `localeCompare`, so it does not depend on the machine's locale, on the - * order the plugins were configured in, or on the order any directory was read - * in. The same configuration produces the same bytes. - * - **Loud.** A malformed id, an entry with a `..` segment or an extension, and - * two declarations claiming one key all throw here - at build time, naming the - * plugin - rather than turning into a generated import that fails somewhere in - * a browser. - * - * It stops at what can be decided from the declarations alone. Whether - * `<pluginId>/<entry>` actually resolves to a file is a filesystem question, and - * the caller that owns the filesystem asks it, using `specifier`. - */ -export const resolvePluginRouteModules = ( - sources: readonly PluginRouteEntrySource[], -): ResolvedPluginRouteModule[] => { - const modules: ResolvedPluginRouteModule[] = []; - - for (const source of sources) { - const pluginId = assertPluginId(source.pluginId, "vitnode.config.ts"); - - for (const route of source.routes ?? []) { - assertSegmentedPath({ label: "id", source: pluginId, value: route.id }); - assertSegmentedPath({ - label: "entry", - source: pluginId, - value: route.entry, - }); + const legacy = (routes as unknown[]).filter( + route => isRecord(route) && typeof route.entry === "string", + ); - if (ENTRY_EXTENSION_PATTERN.test(route.entry)) { - throw new Error( - `${ERROR_PREFIX} ${pluginId} declares the entry ${JSON.stringify(route.entry)} with a file extension. An entry is a package export subpath, and a plugin's export map adds the extension - drop it.`, - ); - } - - modules.push({ - entry: route.entry, - key: pluginRouteId(pluginId, route.id), - pluginId, - routeId: route.id, - specifier: `${pluginId}/${route.entry}`, - }); - } + if (legacy.length > 0) { + throw new Error( + `${ERROR_PREFIX} ${source} exports the old flat route manifest - ${String(legacy.length)} route${legacy.length === 1 ? "" : "s"} declaring an \`entry\`. Plugin routes are now a nested tree: replace each record with \`page()\`, \`layout()\` or \`index()\` from \`@vitnode/core/routing\`, move \`entry\` to \`component: lazy(() => import("./pages/..."))\`, rename \`namespaces\` to \`messages\`, and drop \`id\`, \`kind\`, \`parentId\` and \`searchEntry\`. See https://vitnode.com/docs/dev/plugins/routes.`, + ); } - return sortAndAssertUnique(modules); + return routes as unknown[]; }; /** - * Sorts by key and rejects duplicates. + * Sorts the resolved routes modules by plugin id and rejects duplicates. * - * Also applied by the generator, so a caller cannot hand it an unsorted list and - * get a file whose bytes depend on argument order. + * Deterministic on purpose: the result is sorted with a code-unit comparison + * rather than `localeCompare`, so the generated bytes do not depend on the + * machine's locale or on the order the plugins were configured in. */ -export const sortAndAssertUnique = ( - modules: ResolvedPluginRouteModule[], -): ResolvedPluginRouteModule[] => { +export const sortAndAssertUniquePlugins = ( + modules: readonly ResolvedPluginRoutesModule[], +): ResolvedPluginRoutesModule[] => { const sorted = [...modules].sort((a, b) => { - if (a.key === b.key) return 0; + if (a.pluginId === b.pluginId) return 0; - return a.key < b.key ? -1 : 1; + return a.pluginId < b.pluginId ? -1 : 1; }); const duplicates = sorted .filter( - (module, index) => index > 0 && module.key === sorted[index - 1].key, + (module, index) => + index > 0 && module.pluginId === sorted[index - 1].pluginId, ) - .map(module => module.key); + .map(module => module.pluginId); if (duplicates.length > 0) { throw new Error( - `${ERROR_PREFIX} Two route declarations claim the same registry key: ${[...new Set(duplicates)].map(key => JSON.stringify(key)).join(", ")}. A key is \`<pluginId>:<routeId>\`, so give the routes different ids.`, + `${ERROR_PREFIX} Two plugins claim the same id: ${[...new Set(duplicates)].map(id => JSON.stringify(id)).join(", ")}. A plugin id is the package name, so an app cannot configure one twice.`, ); } return sorted; }; - -/** - * Every route in a manifest that declares an eager search schema, resolved to - * the specifier that imports it. - * - * Read off the **built manifest** rather than off the raw declarations, so a - * `searchEntry` reaches a generated file only by having survived - * `buildPluginRouteManifest` - the same anti-drift ordering the module registry - * has, for the same reason. A route removed from a plugin, or one whose - * `searchEntry` failed validation, cannot leave a static import behind. - * - * Sorted by key, so the generated bytes do not depend on manifest order. - * - * Usually empty. Most routes read no query string, and one that does is usually - * better served by its module's own lazy `parseSearch` - see - * `PluginRouteDefinition.searchEntry` for the case that is not. - */ -export const pluginRouteSearchModules = ( - manifest: readonly PluginRoute[], -): ResolvedPluginRouteSearchModule[] => - manifest - .flatMap(route => - route.searchEntry === null - ? [] - : [ - { - key: route.id, - pluginId: route.pluginId, - routeId: route.routeId, - searchEntry: route.searchEntry, - specifier: `${route.pluginId}/${route.searchEntry}`, - }, - ], - ) - .sort((a, b) => (a.key === b.key ? 0 : a.key < b.key ? -1 : 1)); - -/** - * A built manifest, as the per-plugin declarations the registry resolves from. - * - * The join between the two generated files, made structural. The manifest is the - * resolved snapshot of what routes exist; this turns it back into the shape - * {@link resolvePluginRouteModules} takes, so the registry's entries are - * *derived* from the manifest's routes rather than read from a second pass over - * the plugins - and a route can only get a module import by having survived - * validation. - * - * `id` is the route's plugin-local `routeId`, because that is what a declaration - * carries and what `pluginRouteId` namespaces; the global id the manifest - * already holds is rebuilt from it, so the two are the same string by - * construction rather than by agreement. - * - * Deterministic without sorting: a manifest is already ordered, and the resolver - * sorts by key on top of that. - */ -export const pluginRouteEntrySources = ( - manifest: readonly PluginRoute[], -): PluginRouteEntrySource[] => { - const byPlugin = new Map<string, PluginRouteEntryDeclaration[]>(); - - for (const route of manifest) { - const declarations = byPlugin.get(route.pluginId) ?? []; - - declarations.push({ entry: route.entry, id: route.routeId }); - byPlugin.set(route.pluginId, declarations); - } - - return [...byPlugin].map(([pluginId, routes]) => ({ pluginId, routes })); -}; diff --git a/packages/vitnode/src/framework/plugin-routes/types.ts b/packages/vitnode/src/framework/plugin-routes/types.ts index 5538e7aca..3e840eded 100644 --- a/packages/vitnode/src/framework/plugin-routes/types.ts +++ b/packages/vitnode/src/framework/plugin-routes/types.ts @@ -1,114 +1,15 @@ -import type { PluginRouteDefinition } from "../../routing/types.js"; - /** - * The two fields of a {@link PluginRouteDefinition} the build actually reads. + * One configured plugin's route module, paired with the specifier that imports + * it. * - * `Pick`, not a re-declaration: the plugin route manifest owns what a route is, - * and this layer only has to know which module to import and what to call it. - * Deriving the type means a rename there is a compile error here rather than two - * definitions that agree until somebody edits one. - * - * `entry` is a *package export subpath* - `"routes/example-page"`, imported as - * `"@vitnode/example/routes/example-page"` - rather than a file path, so a plugin - * can move its implementation inside `dist` without breaking every app that - * installed it. Everything else on a definition (`path`, `area`, and whatever - * the manifest grows) is carried past this layer untouched. - */ -export type PluginRouteEntryDeclaration = Pick< - PluginRouteDefinition, - "entry" | "id" ->; - -/** - * One configured plugin, and the route entries it declares. - * - * Structurally satisfied by the manifest layer's own `PluginRouteSource`, so an - * app reads each plugin's route list once and hands the same array to both. - */ -export interface PluginRouteEntrySource { - pluginId: string; - routes?: readonly PluginRouteEntryDeclaration[]; -} - -/** - * A declaration once it has been validated and paired with the import specifier - * the generated registry will contain. - * - * `key` is the manifest layer's own `<pluginId>:<routeId>` route id - built by - * its `pluginRouteId`, not by a second copy of the same rule - so a route's - * module loader is registered under exactly the id the manifest gave it, and - * neither side has to translate. - */ -export interface ResolvedPluginRouteModule { - entry: string; - key: string; - pluginId: string; - routeId: string; - specifier: string; -} - -/** - * A lazy import of one plugin route module. - * - * `unknown`, not a route type: the registry's job is to hand back the module, and - * what a module is expected to export is not this layer's contract. The - * generated file uses `satisfies` against {@link PluginRouteModuleRegistry}, so - * each loader keeps the real `typeof import("...")` of its own module and a - * consumer gets those exports typed without this type having to name them. + * The whole of what the build has to work out per plugin, and deliberately not + * per *route*: a plugin declares its routes in one browser-safe module - `<plugin + * id>/routes` - and the generated file imports that module statically. Which + * page belongs to which route, and which chunk each page ends up in, is decided + * by the literal `import()` inside the plugin's own `lazy()` calls, which Vite + * follows without anything here naming a page. */ -export type PluginRouteModuleLoader = () => Promise<unknown>; - -/** Every plugin route module of an app, keyed by {@link ResolvedPluginRouteModule.key}. */ -export type PluginRouteModuleRegistry = Readonly< - Record<string, PluginRouteModuleLoader> ->; - -/** - * A route's eager search module, paired with the specifier that imports it. - * - * The twin of {@link ResolvedPluginRouteModule}, and deliberately a separate - * type rather than an optional field on it: the two are imported in opposite - * ways. A route module is `() => import(...)` and gets its own chunk; a search - * module is a *static* import and is in the initial bundle. A single record - * carrying both would make that difference invisible at the one place it - * matters. - * - * `key` is the same `<pluginId>:<routeId>` the module registry uses, so the - * runtime looks a route's schema up by the id everything else addresses it by. - */ -export interface ResolvedPluginRouteSearchModule { - key: string; +export interface ResolvedPluginRoutesModule { pluginId: string; - routeId: string; - searchEntry: string; specifier: string; } - -/** - * One route's `validateSearch`, as the router will call it. - * - * `unknown` out rather than a schema type, for the same reason - * {@link PluginRouteModuleLoader} returns `unknown`: the generated file uses - * `satisfies`, so each entry keeps the real return type of the validator it - * names and a consumer gets that type rather than this one. - * - * Total by contract. TanStack calls this during path matching, on whatever was - * in the query string, and a throw there is a router error screen rather than a - * page - so a validator normalises and clamps, it does not reject. See - * {@link PluginRouteDefinition.searchEntry}. - */ -export type PluginRouteSearchValidator = ( - input: Record<string, unknown>, -) => unknown; - -/** - * Every eagerly-imported route search schema of an app, keyed by - * {@link ResolvedPluginRouteSearchModule.key}. - * - * Sparse on purpose: a route appears here only by declaring a `searchEntry`, and - * most do not. A missing key means "this route has no router-level search - * schema", which is the ordinary case and not an error. - */ -export type PluginRouteSearchRegistry = Readonly< - Record<string, PluginRouteSearchValidator> ->; diff --git a/packages/vitnode/src/framework/vite/no-materialized-routes.test.ts b/packages/vitnode/src/framework/vite/no-materialized-routes.test.ts index 2775a076e..f10858ff9 100644 --- a/packages/vitnode/src/framework/vite/no-materialized-routes.test.ts +++ b/packages/vitnode/src/framework/vite/no-materialized-routes.test.ts @@ -3,10 +3,17 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import type { PluginRouteDefinition } from "../../routing/types.js"; import type { PluginRouteCompilerSource } from "../plugin-routes/compile.js"; +import { + definePluginRoutes, + index, + layout, + lazy, + page, +} from "../../routing/tree.js"; import { compilePluginRoutes } from "../plugin-routes/compile.js"; +import { lazyImportSpecifier } from "../plugin-routes/component-source.js"; /** * The generation pass writes **registries**, never pages. @@ -14,8 +21,9 @@ import { compilePluginRoutes } from "../plugin-routes/compile.js"; * Two claims, one per layer, and they are the two halves of the same invariant: * * - `compilePluginRoutes` decides *what* is written, and what it produces is - * data - a validated manifest and one lazy `import()` per route. Nothing it - * returns is a framework route module. + * data - a validated manifest and one static import per configured plugin. + * Nothing it returns is a framework route module, and nothing it writes names + * a page. * - `vitNodePluginRoutes` decides *where*, and every destination it can name is * a `*.gen.ts` at the top of the app's `src/`. It reads the app's routes * directory and never writes into it. @@ -37,7 +45,6 @@ import { compilePluginRoutes } from "../plugin-routes/compile.js"; * spelled in TanStack → generated as src/routes/_main/example/guide/$topic.tsx * * RIGHT plugin manifest route "/example/guide/:topic" - * → one line of data in plugin-route-manifest.gen.ts * → one literal import in plugin-routes.gen.ts * → mounted by withPluginRoutes at runtime * @@ -54,42 +61,46 @@ import { compilePluginRoutes } from "../plugin-routes/compile.js"; const source = (...parts: string[]): string => readFileSync(join(import.meta.dirname, ...parts), "utf8"); -const definitions: PluginRouteDefinition[] = [ - { entry: "routes/example-page", id: "example-page", path: "/example" }, - { - entry: "routes/guide-layout", - id: "guide", - kind: "layout", - path: "/example/guide", - }, - { - entry: "routes/guide-index-page", - id: "guide-index", - parentId: "guide", - path: "/example/guide", - }, - { - entry: "routes/guide-topic-page", - id: "guide-topic", - parentId: "guide", - path: "/example/guide/:topic", - }, - { +/** + * A page's `lazy()`, as a plugin's own compiled `dist` would carry it. + * + * Written through `new Function` so the `import()` survives this test file's own + * transform: Vite rewrites a real dynamic import in a test into a call to its + * loader, and what the build reads is a plugin's `dist`, where it is still an + * `import`. + */ +const lazyPage = (specifier: string) => + lazy( + // eslint-disable-next-line @typescript-eslint/no-implied-eval + new Function( + `return () => import("${specifier}")`, + )() as () => Promise<unknown>, + ); + +const routes = definePluginRoutes([ + page("/example", { component: lazyPage("./pages/example-page.js") }), + layout("/example/guide", { + component: lazyPage("./pages/guide-layout.js"), + children: [ + index({ component: lazyPage("./pages/guide-index-page.js") }), + page(":topic", { component: lazyPage("./pages/guide-topic-page.js") }), + ], + }), + page("/admin/example", { area: "admin", - entry: "routes/admin-example-page", - id: "admin-overview", - path: "/admin/example", - }, -]; + component: lazyPage("./pages/admin-example-page.js"), + search: () => ({ page: 1 }), + }), +]); const example: PluginRouteCompilerSource = { - manifestSpecifier: "@vitnode/example/routes/manifest", pluginId: "@vitnode/example", - routes: definitions, + routes, + routesSpecifier: "@vitnode/example/routes", }; const compiled = compilePluginRoutes({ sources: [example] }); -const generated = [compiled.manifestSource, compiled.registrySource]; +const generated = [compiled.source]; describe("what a compilation produces", () => { /** @@ -97,35 +108,29 @@ describe("what a compilation produces", () => { * compilation would satisfy all of them. */ it("compiled the routes it was given", () => { - expect(compiled.manifest).toHaveLength(definitions.length); - expect(compiled.modules).toHaveLength(definitions.length); + expect(compiled.manifest).toHaveLength(5); + expect(compiled.components.size).toBe(5); expect(compiled.manifest.map(route => route.path)).toContain( "/example/guide/:topic", ); }); /** - * The result's shape *is* the contract. One resolved snapshot, the two source - * strings written from it, and two lists of package export subpaths. - * - * Another - `files`, `routeFiles`, `pages`, anything keyed by a path - would be - * the compiler gaining somewhere else to write, which is the first thing a - * materialising generator needs. Pinned as an exact list so it cannot grow - * quietly. + * The result's shape *is* the contract. One resolved snapshot, the one source + * string written from it, the plugins it imports, and each route's lazy + * component. * - * `searchModules` is the one field added since, and it is the same kind of - * thing `modules` is: a route's eager `validateSearch` module, named by the - * subpath its own package exports it at. The test below says so in the terms - * that matter here - it is a specifier, never a path this compiler could write - * to. + * Another field - `files`, `routeFiles`, `pages`, anything keyed by a path - + * would be the compiler gaining somewhere else to write, which is the first + * thing a materialising generator needs. Pinned as an exact list so it cannot + * grow quietly. */ - it("returns two sources and a snapshot, and nothing keyed by a file path", () => { + it("returns one source and a snapshot, and nothing keyed by a file path", () => { expect(Object.keys(compiled).sort()).toEqual([ + "components", "manifest", - "manifestSource", "modules", - "registrySource", - "searchModules", + "source", ]); }); @@ -150,44 +155,45 @@ describe("what a compilation produces", () => { }); /** - * Every route reaches the app as one lazy, literal import of the *plugin's* - * module - which is the positive claim that makes the deletions above a design + * The app imports each plugin's *declaration* module, statically, and nothing + * else - which is the positive claim that makes the deletions above a design * rather than a gap. * * The specifier is a package export subpath, so a relative or app-internal one - * (`./routes/…`, `#/routes/…`, `src/routes/…`) would mean the module had been + * (`./routes/…`, `#/routes/…`, `src/routes/…`) would mean the tree had been * moved into the application: the copy, arrived by a different road. */ - it("imports every route from the plugin package, lazily and literally", () => { + it("imports each plugin's route tree from its package, statically", () => { for (const module of compiled.modules) { - expect(module.specifier).toBe(`@vitnode/example/${module.entry}`); - expect(compiled.registrySource).toContain( - `() => import('${module.specifier}')`, + expect(module.specifier).toBe(`${module.pluginId}/routes`); + expect(module.specifier).not.toMatch(/^[.#/]/); + expect(compiled.source).toContain( + `import { routes as pluginRoutes0 } from '${module.specifier}'`, ); } - expect(compiled.registrySource).not.toMatch(/import\(\s*[^'"]/); - expect(compiled.registrySource).not.toMatch(/import\(['"][.#]/); - expect(compiled.registrySource).not.toMatch(/src\/routes/); + expect(compiled.source).not.toMatch(/src\/routes/); }); /** - * And a search schema is imported the same way - out of the plugin's package, - * by a subpath it exports - the only difference being that this one is static. + * And every *page* is reached through the plugin's own literal `import()`, + * which the app never names. * - * The eagerness is the whole point of the field and the whole of its cost, so - * it is asserted rather than assumed: a `() => import()` here would compile, - * type-check, and give the route no `validateSearch` at all, because the router - * would have matched long before the promise resolved. + * That is the whole shape of the design: the app holds one import per plugin, + * the plugin holds one import per page, and Rollup follows the second into a + * chunk of its own. A specifier the build could not read would be a page it + * could not check exists - see `lazyImportSpecifier`. */ - it("imports every search schema from the plugin package, eagerly and literally", () => { - for (const module of compiled.searchModules) { - expect(module.specifier).toBe(`@vitnode/example/${module.searchEntry}`); - expect(module.specifier).not.toMatch(/^[.#/]/); - expect(compiled.registrySource).toContain(`from '${module.specifier}'`); - expect(compiled.registrySource).not.toContain( - `import('${module.specifier}')`, - ); + it("leaves every page behind the plugin's own lazy import", () => { + const specifiers = compiled.manifest.map(route => + lazyImportSpecifier(compiled.components.get(route.id)?.load), + ); + + expect( + specifiers.every(specifier => specifier?.startsWith("./pages/")), + ).toBe(true); + for (const specifier of specifiers) { + expect(compiled.source).not.toContain(specifier); } }); @@ -200,12 +206,14 @@ describe("what a compilation produces", () => { * the difference between the two architectures. */ it("keeps a dynamic segment as data rather than as a filename", () => { - expect(compiled.manifestSource).toContain( - "{ kind: 'param', name: 'topic' }", - ); + expect(compiled.manifest.flatMap(route => route.segments)).toContainEqual({ + kind: "param", + name: "topic", + }); for (const file of generated) { expect(file).not.toContain("$topic"); expect(file).not.toContain("[topic]"); + expect(file).not.toContain(":topic"); } }); }); @@ -245,7 +253,7 @@ describe("where the generation pass is allowed to write", () => { * And each of those is a generated data file at the top of `src/` - never a * page, and never inside a directory a router reads as routes. */ - it("writes four generated data files and no source file", () => { + it("writes three generated data files and no source file", () => { // `?? destination` rather than a non-null assertion: an unresolved key is a // real possible failure - a `writeIfChanged(paths.somethingNew, …)` whose // key `pathsFor` does not declare - and it should fail the assertion below @@ -258,7 +266,6 @@ describe("where the generation pass is allowed to write", () => { expect([...files].sort()).toEqual([ "admin-nav.gen.ts", "content-registry.gen.ts", - "plugin-route-manifest.gen.ts", "plugin-routes.gen.ts", ]); for (const file of files) { diff --git a/packages/vitnode/src/framework/vite/plugin-routes.ts b/packages/vitnode/src/framework/vite/plugin-routes.ts index b2cdbae0b..f6c5c1afc 100644 --- a/packages/vitnode/src/framework/vite/plugin-routes.ts +++ b/packages/vitnode/src/framework/vite/plugin-routes.ts @@ -2,19 +2,23 @@ import type { Plugin } from "vite"; import { createJiti } from "jiti"; import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; -import { readFile, writeFile } from "node:fs/promises"; +import { readFile, unlink, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; -import { join, relative, resolve as resolvePath, sep } from "node:path"; +import { + dirname, + join, + relative, + resolve as resolvePath, + sep, +} from "node:path"; import { pathToFileURL } from "node:url"; -import type { PluginRouteDefinition } from "@/routing"; - import type { ResolvedAdminNavModule } from "../admin-nav"; import type { ResolvedContentRegistryModule } from "../content-registry"; import type { + CompiledPluginRoutes, HostRoutePath, PluginRouteCompilerSource, - ResolvedPluginRouteModule, } from "../plugin-routes"; import { generateAdminNavSource } from "../admin-nav"; @@ -22,8 +26,9 @@ import { generateContentRegistrySource } from "../content-registry"; import { compilePluginRoutes, hostRoutePathsFromFiles, + lazyImportSpecifier, pluginIdsFromLoadedConfig, - routeDeclarationsFromManifest, + routeDeclarationsFromRoutesModule, } from "../plugin-routes"; import { createGenerationQueue } from "./generation-queue"; import { versionedModuleUrl } from "./module-version"; @@ -31,12 +36,25 @@ import { versionedModuleUrl } from "./module-version"; /** * Where a plugin declares its routes, as a package export subpath. * - * A plugin that does not export it - `@vitnode/blog` today - simply contributes - * no routes. That is not an error: most plugins are AdminCP content types and - * ship no pages at all, and a missing manifest has to mean "none" rather than + * `@vitnode/example/routes`, backed by the plugin's own `src/routes.ts`. A + * plugin that does not export it - `@vitnode/blog` today - simply contributes no + * routes. That is not an error: most plugins are AdminCP content types and ship + * no pages at all, and a missing routes module has to mean "none" rather than * failing the build of every app that installs one. */ -const MANIFEST_SUBPATH = "routes/manifest"; +const ROUTES_SUBPATH = "routes"; + +/** + * Where a plugin declared its routes before they were a tree. + * + * Resolved for one reason: to say so. A plugin still shipping the flat manifest + * has an `entry`, an `id`, a `kind` and a `parentId` per route, and no adapter + * can turn those into `lazy(() => import(...))` - the module a page lives in was + * a string the *app* imported, and it is now the plugin's own literal import. + * Left undetected, such a plugin contributes nothing at all and the page simply + * 404s. + */ +const LEGACY_MANIFEST_SUBPATH = "routes/manifest"; /** * Where a plugin declares its AdminCP navigation, as a package export subpath. @@ -103,17 +121,6 @@ export interface VitNodePluginRoutesOptions { const pathsFor = (appRoot: string) => ({ /** The configured plugin list, and the only place it is read from. */ config: join(appRoot, "src", "vitnode.config.ts"), - /** - * The two generated files. Both committed, and both rewritten only when they - * change. - * - * Split because they answer different questions and are read by different - * things. The manifest says *what* routes exist and at which canonical VitNode - * path - framework-neutral data, which is what makes it worth generating once - * and reading from any router. The registry says *how* each route's module is - * imported, as one literal `import()` per route. The host's router joins them - * by route id and is the only place that knows about TanStack. - */ /** * The AdminCP navigation projection: one literal import per configured plugin * that has navigation to declare. Committed and rewritten like the other two, @@ -130,8 +137,22 @@ const pathsFor = (appRoot: string) => ({ * one step rather than three. */ contentRegistry: join(appRoot, "src", "content-registry.gen.ts"), - manifest: join(appRoot, "src", "plugin-route-manifest.gen.ts"), + /** + * The plugin route registry: one static import per configured plugin that + * declares routes, and the trees they declared. Committed, and rewritten only + * when it changes. + */ registry: join(appRoot, "src", "plugin-routes.gen.ts"), + /** + * The data file the registry replaced. + * + * Deleted rather than ignored. It was a literal copy of every route's manifest + * entry, and a route's `entry` and `searchEntry` are not fields a plugin has + * any more - so a stale copy left in `src/` is a file that still compiles, + * still imports `@vitnode/core/routing`, and describes routes the app no + * longer has. + */ + staleManifest: join(appRoot, "src", "plugin-route-manifest.gen.ts"), /** The file-based router's config, read only for where the routes are. */ routerConfig: join(appRoot, "tsr.config.json"), }); @@ -214,35 +235,41 @@ const readConfiguredPluginIds = async ( }; /** - * One plugin's route declarations, loaded from its compiled manifest. + * One plugin's route tree, loaded from its compiled `routes` module. + * + * A route tree is browser-safe data by contract - paths, shells, message lists + * and one `lazy()` per page - so this is a normal `import()` of the plugin's + * build output in Node. No page, no layout, no React and no router is evaluated + * to find out which routes exist: `lazy()` stores the import callback without + * calling it, which is the whole point of it. * - * The manifest is plain data by contract, so this is a normal `import()` of the - * plugin's build output in Node - no React, no router and no app code is - * evaluated to find out which routes exist. + * `routeDeclarationsFromRoutesModule` checks the *module* is a routes module at + * all - it exports a `routes` array - and names the specifier when it is not. + * What each node then means is `flattenPluginRoutes`' to decide, and the array is + * handed on untouched for it: it validates every field it reads, defensively, + * from `unknown`. Two readers rather than one shared narrowed shape, so neither + * layer has to know what the other requires. * - * `routeDeclarationsFromManifest` checks the *module* is a route manifest at all - * - it exports a `routes` array of records - and names the specifier when it is - * not. What each record then means is `buildPluginRouteManifest`'s to decide, and - * the array is handed on untouched for it: it validates every field it reads, - * defensively, from `unknown`. Two readers rather than one shared narrowed shape, - * so neither layer has to know what the other requires. + * A plugin with no `routes` module contributes nothing, and one still shipping + * the old flat `routes/manifest` is told so rather than silently contributing + * nothing - see {@link LEGACY_MANIFEST_SUBPATH}. * * ## Why the URL carries an mtime * * Node's ESM loader caches modules by URL, permanently and with no eviction. The * dev server therefore had a watcher that worked and a regeneration that could - * not: edit a plugin's manifest, the watcher fires, a regeneration pass runs, and + * not: edit a plugin's routes, the watcher fires, a regeneration pass runs, and * `import()` of the same URL hands back the module Node parsed minutes ago - so - * the generated files were rewritten from stale declarations, or more often not + * the generated file was rewritten from stale declarations, or more often not * rewritten at all because the bytes had not changed. * * A version taken off the file itself is the smallest thing that fixes it and * keeps every property that matters: it changes when the file changes, so an - * untouched manifest keeps its cache entry across regenerations rather than + * untouched module keeps its cache entry across regenerations rather than * leaking a new one, and it is read off disk rather than invented, so two builds * of the same tree ask for the same URL. It never reaches the generated output - - * the generated bytes are a function of the declarations alone - so the browser - * never sees any of this. + * the generated bytes are a function of the configured plugins alone - so the + * browser never sees any of this. * * `./module-version` is the rule itself, and why it is a fingerprint rather * than a clock. `statSync` is here because this is the layer that has a @@ -252,28 +279,51 @@ const readPluginRoutes = async ( pluginId: string, resolvePackageFile: (specifier: string) => null | string, ): Promise<{ source: PluginRouteCompilerSource; watch: null | string }> => { - const specifier = `${pluginId}/${MANIFEST_SUBPATH}`; + const specifier = `${pluginId}/${ROUTES_SUBPATH}`; const file = resolvePackageFile(specifier); - if (file === null) return { source: { pluginId, routes: [] }, watch: null }; + if (file === null) { + assertNoLegacyRouteManifest(pluginId, resolvePackageFile); - const loaded = await import(versionedModuleUrl(file, statSync(file))); + return { source: { pluginId }, watch: null }; + } - routeDeclarationsFromManifest(loaded, specifier); + const loaded: unknown = await import( + versionedModuleUrl(file, statSync(file)) + ); return { source: { - manifestSpecifier: specifier, pluginId, - // Safe by the line above: it threw unless `routes` is an array of records - // with a string `id` and `entry`. Everything past that is - // `buildPluginRouteManifest`'s to check, which it does from `unknown`. - routes: (loaded as { routes: PluginRouteDefinition[] }).routes, + routes: routeDeclarationsFromRoutesModule(loaded, specifier), + routesSpecifier: specifier, }, watch: file, }; }; +/** + * Fails the build for a plugin that still declares the flat route manifest. + * + * Only reached when the plugin exports no `routes` module, which is exactly the + * shape a plugin written against the previous API has: `routes/manifest` + * resolves and `routes` does not. Without this it is indistinguishable from a + * plugin that ships no pages, so every one of its URLs would 404 with nothing + * anywhere saying why. + */ +const assertNoLegacyRouteManifest = ( + pluginId: string, + resolvePackageFile: (specifier: string) => null | string, +): void => { + const legacy = `${pluginId}/${LEGACY_MANIFEST_SUBPATH}`; + + if (resolvePackageFile(legacy) === null) return; + + throw new Error( + `${ERROR_PREFIX} Plugin "${pluginId}" exports "${legacy}" but no "${pluginId}/${ROUTES_SUBPATH}". Plugin routes are now a nested tree in the plugin's own \`src/routes.ts\`: export \`routes = definePluginRoutes([...])\` built from \`page()\`, \`layout()\` and \`index()\`, with each module named by \`component: lazy(() => import("./pages/..."))\` instead of an \`entry\` string. See https://vitnode.com/docs/dev/plugins/routes.`, + ); +}; + /** Where an app's own route files are, and which of them are not routes. */ interface HostRoutesConfig { dir: null | string; @@ -452,21 +502,48 @@ export const readOptionalPluginModules = < }; /** - * Fails the build for a route module the app cannot import. - * - * The alternative is a generated `import()` of a specifier that does not - * resolve, which Vite reports from inside the module graph long after anyone can - * tell which plugin caused it - or worse, in the browser. + * Fails the build for a page or layout module the plugin names and does not + * have. + * + * The one check in this layer that cannot be pure, and the one thing a generated + * file no longer does on the app's behalf: a page is reached through the literal + * `import()` inside its own plugin's `lazy()` call, so nothing in the app's + * source names it and nothing in the app's build resolves it until a visitor + * navigates. Left unchecked, a mistyped page path is a broken chunk request in a + * browser rather than a failed build. + * + * Best effort by construction, and deliberately so. `lazyImportSpecifier` reads + * the specifier off the compiled callback and answers `null` for anything it + * cannot be sure about - a bundler-rewritten import, a computed one, a bare + * package specifier - and this skips those rather than guessing. A check that + * failed a build over a callback it misread would be worse than no check. */ -const assertImportable = ( - module: ResolvedPluginRouteModule, - resolvePackageFile: (specifier: string) => null | string, +const assertComponentsImportable = ( + compiled: CompiledPluginRoutes, + routesFiles: ReadonlyMap<string, string>, ): void => { - if (resolvePackageFile(module.specifier) !== null) return; + for (const route of compiled.manifest) { + const component = compiled.components.get(route.id); + const specifier = + component === undefined ? null : lazyImportSpecifier(component.load); + const from = routesFiles.get(route.pluginId); - throw new Error( - `${ERROR_PREFIX} Plugin "${module.pluginId}", route "${module.routeId}", declares the entry "${module.entry}", which cannot be imported as "${module.specifier}". Check that ${module.pluginId} exports "./${module.entry}" and that its build output is up to date.`, - ); + if (specifier === null || from === undefined) continue; + + const file = resolvePath(dirname(from), specifier); + const candidates = [ + file, + `${file}.js`, + `${file}.mjs`, + join(file, "index.js"), + ]; + + if (candidates.some(candidate => existsSync(candidate))) continue; + + throw new Error( + `${ERROR_PREFIX} Plugin "${route.pluginId}" declares the ${route.kind} at "${route.path}" with \`lazy(() => import("${specifier}"))\`, which does not resolve to a file next to ${relative(process.cwd(), from)}. Check the path and that ${route.pluginId}'s build output is up to date.`, + ); + } }; /** @@ -477,14 +554,15 @@ const assertImportable = ( * bytes depend on the configuration and nothing else. * * The split here is the one this whole layer is arranged around: *this* function - * owns the filesystem - the config, the plugin manifests, the app's own route - * files, package resolution - and `compilePluginRoutes` owns every decision made - * from what it finds. Which is why the only thing left below it is the one check - * that cannot be pure: does each entry resolve to a file that exists. + * owns the filesystem - the config, the plugins' route modules, the app's own + * route files, package resolution - and `compilePluginRoutes` owns every decision + * made from what it finds. Which is why the only thing left below it is the one + * check that cannot be pure: does each lazily imported page resolve to a file + * that exists. * * `onLoaded` is called with the files this pass read *before* anything can fail, - * so a dev server watching them still learns about a manifest that threw - which - * is exactly the one an author is about to edit again. + * so a dev server watching them still learns about a routes module that threw - + * which is exactly the one an author is about to edit again. */ const discover = async ( appRoot: string, @@ -527,52 +605,20 @@ const discover = async ( sources: loaded.map(({ source }) => source), }); - compiled.modules.forEach(module => { - assertImportable(module, resolvePackageFile); - }); - - /** - * A search schema is checked the same way, and it matters more. - * - * An unresolvable lazy entry is a broken `import()` a visitor reaches when - * they open the page. An unresolvable *static* one is a module the app cannot - * build at all - Vite fails on the generated file rather than on the plugin, - * with a specifier nobody in the app wrote. Failing here names the plugin, the - * route and the entry. - */ - compiled.searchModules.forEach(module => { - if (resolvePackageFile(module.specifier) !== null) return; - - throw new Error( - `${ERROR_PREFIX} Plugin "${module.pluginId}", route "${module.routeId}", declares the search entry "${module.searchEntry}", which cannot be imported as "${module.specifier}". Check that ${module.pluginId} exports "./${module.searchEntry}" and that its build output is up to date.`, - ); - }); - - /** - * The search modules join the watch list, a second call later than the rest. - * - * They cannot be in the first one: which routes declare a `searchEntry` is - * only known once the manifests have been read *and* compiled, and the first - * call deliberately happens before anything can fail so that a manifest which - * threw is still watched. `onLoaded` is additive - the dev server folds each - * call into one set - so two calls is the honest shape rather than a - * workaround. - * - * Worth watching for the same reason a manifest is: editing a route's - * `validateSearch` changes what the app does with a URL, and no other file - * this pass reads would have noticed. - */ - const searchFiles = compiled.searchModules.flatMap( - module => resolvePackageFile(module.specifier) ?? [], + assertComponentsImportable( + compiled, + new Map( + loaded.flatMap(({ source, watch: file }) => + file === null ? [] : [[source.pluginId, file] as const], + ), + ), ); - if (searchFiles.length > 0) onLoaded?.(searchFiles); - return { adminNav: adminNav.modules, compiled, contentRegistry: contentRegistry.modules, - watch: [...watch, ...searchFiles], + watch, }; }; @@ -590,7 +636,21 @@ const writeIfChanged = async (path: string, source: string): Promise<void> => { if (current !== source) await writeFile(path, source, "utf8"); }; -/** All four generated files, from one discovery pass. */ +/** + * Removes a generated file this build no longer writes. + * + * Only ever pointed at a path VitNode itself generated, and only when that file + * has been replaced rather than merely emptied: a stale generated module in + * `src/` still compiles and still describes routes, which is a worse failure + * than a missing one. + */ +const removeIfPresent = async (path: string): Promise<void> => { + if (!existsSync(path)) return; + + await unlink(path); +}; + +/** All three generated files, from one discovery pass. */ const writeGenerated = async ( appRoot: string, options: VitNodePluginRoutesOptions, @@ -604,13 +664,13 @@ const writeGenerated = async ( ); await Promise.all([ - writeIfChanged(paths.manifest, compiled.manifestSource), - writeIfChanged(paths.registry, compiled.registrySource), + writeIfChanged(paths.registry, compiled.source), writeIfChanged(paths.adminNav, generateAdminNavSource(adminNav)), writeIfChanged( paths.contentRegistry, generateContentRegistrySource(contentRegistry), ), + removeIfPresent(paths.staleManifest), ]); }; @@ -623,10 +683,10 @@ const writeGenerated = async ( * - **Here, at build time.** Read the configured plugins, load their route * manifests from `node_modules`, check every entry resolves to a real file, * validate every route, reject two plugins claiming one URL and a plugin - * claiming one of the app's own, then write `src/plugin-route-manifest.gen.ts` - * and `src/plugin-routes.gen.ts` - and, from the same configured plugin list, - * `src/admin-nav.gen.ts`, one literal import per plugin that exports an - * `admin/nav` module. + * claiming one of the app's own, then write `src/plugin-routes.gen.ts` - one + * static import per plugin that exports a `routes` module - and, from the same + * configured plugin list, `src/admin-nav.gen.ts` and + * `src/content-registry.gen.ts`. * - **In the browser.** Import those three files. They contain literal data, * literal `import()` calls and literal specifiers and nothing else - no * `node:fs`, no package resolution, no validation to repeat and no specifier diff --git a/packages/vitnode/src/framework/vite/projections.test.ts b/packages/vitnode/src/framework/vite/projections.test.ts index a6f01fb47..c13999221 100644 --- a/packages/vitnode/src/framework/vite/projections.test.ts +++ b/packages/vitnode/src/framework/vite/projections.test.ts @@ -4,25 +4,25 @@ import type { ResolvedAdminNavModule } from "../admin-nav"; import type { ResolvedContentRegistryModule } from "../content-registry"; import type { PluginRouteCompilerSource } from "../plugin-routes"; +import { definePluginRoutes, lazy, page } from "../../routing/tree"; import { generateAdminNavSource } from "../admin-nav"; import { generateContentRegistrySource } from "../content-registry"; import { compilePluginRoutes } from "../plugin-routes"; import { readOptionalPluginModules } from "./plugin-routes"; /** - * All four generated projections, from one configured plugin list, in one pass. + * All three generated projections, from one configured plugin list, in one pass. * * Each generator has its own determinism test beside it, and each says the same * thing about itself: sorted input, sorted output, same bytes. What none of them * can say is the thing that actually matters to an installation - that the four * are projections of **one list**, so a plugin cannot be half-enabled. * - * That is the property this file is for, and it is not hypothetical. The four - * files are read by four different parts of the app, and they used to be written - * by separate passes: + * That is the property this file is for, and it is not hypothetical. The three + * files are read by three different parts of the app, and they used to be + * written by separate passes: * - * plugin-route-manifest.gen.ts what routes exist - * plugin-routes.gen.ts how each route's module is imported + * plugin-routes.gen.ts which plugins have routes, and their trees * admin-nav.gen.ts what the AdminCP sidebar shows * content-registry.gen.ts which content types have screens * @@ -30,8 +30,8 @@ import { readOptionalPluginModules } from "./plugin-routes"; * - each file is individually valid - and the symptoms are all somewhere else: a * sidebar entry whose page 404s, a content screen with no route, a route the * router still claims for a plugin nobody configured. So "removed from the - * config" has to mean removed from all four, and that is asserted here rather - * than left to four files each checking its own half. + * config" has to mean removed from all three, and that is asserted here rather + * than left to three files each checking its own half. * * Pure throughout: a resolver over a fixed map stands in for `node_modules`, and * every generator takes data and returns a string. There is no dev server here - @@ -47,28 +47,33 @@ const resolverFor = /** * Two plugins that contribute to every projection, so "disappears from all - * four" is a statement with four things in it rather than one. + * three" is a statement with three things in it rather than one. */ const WORKSPACE = resolverFor({ "@acme/blog/admin/content": "/pkg/blog/dist/admin/content.js", "@acme/blog/admin/nav": "/pkg/blog/dist/admin/nav.js", - "@acme/blog/routes/post": "/pkg/blog/dist/routes/post.js", + "@acme/blog/routes": "/pkg/blog/dist/routes.js", "@acme/shop/admin/content": "/pkg/shop/dist/admin/content.js", "@acme/shop/admin/nav": "/pkg/shop/dist/admin/nav.js", - "@acme/shop/routes/product": "/pkg/shop/dist/routes/product.js", + "@acme/shop/routes": "/pkg/shop/dist/routes.js", }); -/** What each plugin's route manifest declares, keyed by plugin id. */ +const lazyPage = () => + lazy(async () => await Promise.resolve({ default: () => null })); + +/** What each plugin's routes module declares, keyed by plugin id. */ const ROUTES: Record<string, PluginRouteCompilerSource> = { "@acme/blog": { - manifestSpecifier: "@acme/blog/routes/manifest", pluginId: "@acme/blog", - routes: [{ entry: "routes/post", id: "post", path: "/blog/:slug" }], + routes: definePluginRoutes([ + page("/blog/:slug", { component: lazyPage() }), + ]), + routesSpecifier: "@acme/blog/routes", }, "@acme/shop": { - manifestSpecifier: "@acme/shop/routes/manifest", pluginId: "@acme/shop", - routes: [{ entry: "routes/product", id: "product", path: "/shop/:id" }], + routes: definePluginRoutes([page("/shop/:id", { component: lazyPage() })]), + routesSpecifier: "@acme/shop/routes", }, }; @@ -93,12 +98,11 @@ const projectionsFor = (pluginIds: readonly string[]) => { WORKSPACE, ).modules, ), - manifest: compiled.manifestSource, - registry: compiled.registrySource, + registry: compiled.source, }; }; -const FILES = ["adminNav", "contentRegistry", "manifest", "registry"] as const; +const FILES = ["adminNav", "contentRegistry", "registry"] as const; const BOTH = ["@acme/blog", "@acme/shop"]; @@ -142,9 +146,6 @@ describe("determinism, across every projection at once", () => { expect(shuffled.contentRegistry.indexOf("@acme/blog")).toBeLessThan( shuffled.contentRegistry.indexOf("@acme/shop"), ); - expect(shuffled.manifest.indexOf("/blog/")).toBeLessThan( - shuffled.manifest.indexOf("/shop/"), - ); expect(shuffled.registry.indexOf("@acme/blog")).toBeLessThan( shuffled.registry.indexOf("@acme/shop"), ); @@ -152,7 +153,7 @@ describe("determinism, across every projection at once", () => { }); describe("a plugin is enabled, or it is not - never half of each", () => { - it("puts an enabled plugin in all four projections", () => { + it("puts an enabled plugin in all three projections", () => { const enabled = projectionsFor(BOTH); FILES.forEach(file => { @@ -161,12 +162,11 @@ describe("a plugin is enabled, or it is not - never half of each", () => { }); /** - * The one that matters. Disabling a plugin has to take its routes, its module - * imports, its sidebar entries and its content screens away together - a - * sidebar entry that outlived its route is a 404 nobody can attribute to a - * config edit. + * The one that matters. Disabling a plugin has to take its routes, its + * sidebar entries and its content screens away together - a sidebar entry that + * outlived its route is a 404 nobody can attribute to a config edit. */ - it("removes a disabled plugin from all four, in one step", () => { + it("removes a disabled plugin from all three, in one step", () => { const disabled = projectionsFor(["@acme/blog"]); FILES.forEach(file => { diff --git a/packages/vitnode/src/lib/plugin.ts b/packages/vitnode/src/lib/plugin.ts index d61982ecc..8fee9de48 100644 --- a/packages/vitnode/src/lib/plugin.ts +++ b/packages/vitnode/src/lib/plugin.ts @@ -5,7 +5,7 @@ import type { ContentSelect, ContentSystemField, } from "../content/types"; -import type { PluginRouteDefinition } from "../routing/types"; +import type { PluginRoutes } from "../routing/tree"; import type { AdminNavItem as ResolvedAdminNavItem } from "../views/admin/layouts/sidebar/nav/nav-model"; import type { LocaleMessagesMap } from "./i18n/types"; @@ -273,21 +273,20 @@ export interface BuildPluginReturn<P extends string = string> { messages?: LocaleMessagesMap; pluginId: P; /** - * Public pages this plugin contributes, declared rather than shipped as a - * framework's route files. + * The pages this plugin contributes, as the tree its own `src/routes.ts` + * declares. * * Optional: a plugin that contributes an API module, a content type or only - * strings declares no routes at all. `buildPluginRouteManifest` turns every - * plugin's list into the application's route manifest, which is compiled into - * a literal registry at build time. + * strings declares no routes at all. Hand over the same `routes` export the + * plugin's `routes.ts` has, so a host that registers the plugin through this + * config and a build that reads the module directly describe one set of + * routes. * - * There was a second, older path until the Next.js cutover: a plugin could - * instead ship a `src/routes/{main,admin,blank,breadcrumb}/` tree of App Router - * pages, which a copier wrote into every Next.js app's `src/app/`. Nothing is - * copied anywhere now - a route module stays in this package's own `dist` and - * the app imports it from there. See `src/routing/`. + * Nothing is copied anywhere: a page module stays in this package's own `dist` + * behind the `lazy(() => import(...))` its route declared, and the app holds + * one static import of this tree. See `src/routing/`. */ - routes?: PluginRouteDefinition[]; + routes?: PluginRoutes; } export function buildPlugin<P extends string>( diff --git a/packages/vitnode/src/routing/authoring.ts b/packages/vitnode/src/routing/authoring.ts index d79dc3f1d..e86606716 100644 --- a/packages/vitnode/src/routing/authoring.ts +++ b/packages/vitnode/src/routing/authoring.ts @@ -1,4 +1,5 @@ import type { + PluginRouteBreadcrumbProps, PluginRouteHead, PluginRouteHeadArgs, PluginRouteLoadArgs, @@ -90,8 +91,16 @@ type UnknownLoaderData = * `NoInfer` states which member is the source of truth for each type, so the * order stops mattering: * - * - `TData` from what `load` returns. `head` only reads it. - * - `TSearch` from what `parseSearch` returns. `head` and `load` only read it. + * - `TData` from what `load` returns, or from a `breadcrumb` that annotates it. + * `head` only reads it. + * - `TSearch` from what `parseSearch` returns. `head`, `load` and `breadcrumb` + * only read it. + * + * `breadcrumb`'s *second* argument is the one worth naming: a crumb declared as + * `({ loaderData }: PluginRouteBreadcrumbProps<Topic>) => loaderData.title` + * leaves `PluginRouteBreadcrumbProps` to fill in its default search type, so + * without `NoInfer` a route with a `parseSearch` *and* a crumb inferred + * `TSearch` from whichever of the two TypeScript happened to read first. * * There is deliberately no `TContext`. What a plugin's `load` is handed is * {@link PluginRouteContext} and only that - see `./module` for why a contract @@ -105,8 +114,11 @@ type UnknownLoaderData = */ type AuthoredPluginRouteOptions<TData, TSearch> = Omit< PluginRouteOptions<TData, TSearch>, - "head" | "load" + "breadcrumb" | "head" | "load" > & { + breadcrumb?: + | false + | React.ComponentType<PluginRouteBreadcrumbProps<TData, NoInfer<TSearch>>>; head?: ( args: PluginRouteHeadArgs<NoInfer<TData>, NoInfer<TSearch>>, ) => PluginRouteHead; @@ -127,7 +139,7 @@ type AuthoredPluginRouteOptions<TData, TSearch> = Omit< * export const route = definePluginRoute({ * load: ({ context, params }) => fetchTopic(context.locale, params.topic), * head: ({ loaderData }) => ({ title: loaderData?.title }), - * breadcrumb: () => <span>{useTranslations("my-plugin")("title")}</span>, + * breadcrumb: ({ loaderData }) => loaderData.title, * }); * * `context` is {@link PluginRouteContext} - the locale - and there is no way to diff --git a/packages/vitnode/src/routing/errors.ts b/packages/vitnode/src/routing/errors.ts index ccf14ac25..6db23524d 100644 --- a/packages/vitnode/src/routing/errors.ts +++ b/packages/vitnode/src/routing/errors.ts @@ -5,17 +5,16 @@ export type PluginRouteErrorCode = | "cross-plugin-parent" | "duplicate-id" | "duplicate-path" + | "eager-component" | "invalid-area" - | "invalid-entry" - | "invalid-id" - | "invalid-kind" | "invalid-namespace" - | "invalid-parent" | "invalid-parent-kind" | "invalid-parent-path" | "invalid-path" | "invalid-plugin-id" | "invalid-requires" + | "invalid-search" + | "invalid-tree" | "malformed-route" | "parent-cycle" | "requires-in-admin-area" diff --git a/packages/vitnode/src/routing/flatten.ts b/packages/vitnode/src/routing/flatten.ts new file mode 100644 index 000000000..3f4a6cefa --- /dev/null +++ b/packages/vitnode/src/routing/flatten.ts @@ -0,0 +1,339 @@ +import type { PluginRouteDeclaration, PluginRouteLazyComponent } from "./tree"; +import type { + PluginRouteArea, + PluginRouteKind, + PluginRouteRequirement, + PluginRouteSearchValidator, + PluginRouteSegment, +} from "./types"; + +import { PluginRouteError } from "./errors"; +import { namespaceProblem, normalizeNamespaceList } from "./namespaces"; +import { parseRoutePath } from "./path"; +import { isPluginRouteDeclaration, isPluginRouteLazyComponent } from "./tree"; +import { PLUGIN_ROUTE_AREAS, PLUGIN_ROUTE_REQUIREMENTS } from "./types"; + +export interface FlatPluginRoute { + area: PluginRouteArea; + component: PluginRouteLazyComponent; + kind: PluginRouteKind; + messages: string[]; + parentId: null | string; + path: string; + requires: null | PluginRouteRequirement; + routeId: string; + search: null | PluginRouteSearchValidator; + segments: PluginRouteSegment[]; +} + +export const pluginRouteIdFor = (kind: PluginRouteKind, path: string): string => + `${kind}#${path}`; + +const LAZY_EXAMPLE = 'lazy(() => import("./pages/my-page"))'; + +const fail = ({ + code, + message, + path, + pluginId, +}: { + code: PluginRouteError["code"]; + message: string; + path?: string; + pluginId: string; +}): never => { + throw new PluginRouteError(message, { code, path, pluginId }); +}; + +const readMessages = ( + messages: unknown, + pluginId: string, + where: string, +): string[] => { + if (messages === undefined) return []; + + if (!Array.isArray(messages)) { + return fail({ + code: "invalid-namespace", + message: `${where} in ${pluginId} declares \`messages\` that is not an array.`, + pluginId, + }); + } + + const checked = Array.from(messages, (value: unknown, position) => { + const problem = namespaceProblem(value); + + if (problem) { + return fail({ + code: "invalid-namespace", + message: `${where} in ${pluginId} declares messages[${String(position)}] that ${problem}`, + pluginId, + }); + } + + return value as string; + }); + + return normalizeNamespaceList(checked); +}; + +const readRequires = ( + requires: unknown, + area: PluginRouteArea, + pluginId: string, + where: string, +): null | PluginRouteRequirement => { + if (requires === undefined || requires === null) return null; + + if (!PLUGIN_ROUTE_REQUIREMENTS.includes(requires as PluginRouteRequirement)) { + return fail({ + code: "invalid-requires", + message: `${where} in ${pluginId} declares the unknown requirement ${JSON.stringify(requires)}. Known requirements: ${PLUGIN_ROUTE_REQUIREMENTS.join(", ")}.`, + pluginId, + }); + } + + if (area === "admin") { + return fail({ + code: "requires-in-admin-area", + message: `${where} in ${pluginId} is in the "admin" area and declares \`requires: ${JSON.stringify(requires)}\`. \`requires\` is about the public session and the AdminCP has its own, so an admin route is already behind the AdminCP's session guard - drop the field. To gate the page on a staff permission, gate its content inside the route module.`, + pluginId, + }); + } + + return requires as PluginRouteRequirement; +}; + +const readArea = ( + area: unknown, + pluginId: string, + where: string, +): PluginRouteArea => { + if (area === undefined) return "main"; + + if (!PLUGIN_ROUTE_AREAS.includes(area as PluginRouteArea)) { + return fail({ + code: "invalid-area", + message: `${where} in ${pluginId} declares the unknown area ${JSON.stringify(area)}. Known areas: ${PLUGIN_ROUTE_AREAS.join(", ")}.`, + pluginId, + }); + } + + return area as PluginRouteArea; +}; + +const readComponent = ( + component: unknown, + pluginId: string, + where: string, +): PluginRouteLazyComponent => { + if (isPluginRouteLazyComponent(component)) return component; + + const eager = + typeof component === "function" + ? " A component imported into routes.ts is part of the initial bundle, so its page can never be split into a chunk of its own." + : ""; + + return fail({ + code: "eager-component", + message: `${where} in ${pluginId} declares a \`component\` that is not \`${LAZY_EXAMPLE}\`.${eager} Write \`component: ${LAZY_EXAMPLE}\` - the import stays a literal Vite can follow, and nothing runs it until the route is matched or preloaded.`, + pluginId, + }); +}; + +const readSearch = ( + search: unknown, + kind: PluginRouteKind, + pluginId: string, + where: string, +): null | PluginRouteSearchValidator => { + if (search === undefined || search === null) return null; + + if (kind === "layout") { + return fail({ + code: "invalid-search", + message: `${where} in ${pluginId} is a layout and declares \`search\`. A layout claims no URL of its own, so it has no query string to validate - declare it on the page that reads the search.`, + pluginId, + }); + } + + if (typeof search !== "function") { + return fail({ + code: "invalid-search", + message: `${where} in ${pluginId} declares a \`search\` that is not a function. \`search\` reads this route's query string and is called by the router while it matches the URL.`, + pluginId, + }); + } + + return search as PluginRouteSearchValidator; +}; + +const readPath = ({ + declared, + isIndex, + parent, + pluginId, + where, +}: { + declared: null | string; + isIndex: boolean; + parent: FlatPluginRoute | null; + pluginId: string; + where: string; +}): string => { + if (isIndex) { + if (parent === null) { + return fail({ + code: "invalid-tree", + message: `${where} in ${pluginId} is an index route at the top level. An index route renders at its parent layout's own URL, so it belongs in a layout's \`children\`.`, + pluginId, + }); + } + + return parent.path; + } + + if (typeof declared !== "string" || declared.trim() === "") { + return fail({ + code: "invalid-path", + message: `${where} in ${pluginId} declares no path (got ${JSON.stringify(declared)}). Use \`index({ ... })\` for the route that renders at its parent layout's own URL.`, + pluginId, + }); + } + + if (parent === null) { + if (!declared.startsWith("/")) { + return fail({ + code: "invalid-path", + message: `${where} in ${pluginId} declares the path ${JSON.stringify(declared)}. A top-level route's path is absolute - write ${JSON.stringify(`/${declared}`)}.`, + pluginId, + }); + } + + return declared; + } + + if (declared.startsWith("/")) { + return fail({ + code: "invalid-path", + message: `${where} in ${pluginId} declares the path ${JSON.stringify(declared)} inside the layout at "${parent.path}". A nested route's path is relative to its parent - write ${JSON.stringify(declared.slice(1))} and VitNode joins the two.`, + pluginId, + }); + } + + return parent.path === "/" ? `/${declared}` : `${parent.path}/${declared}`; +}; + +const describe = ( + declared: PluginRouteDeclaration, + parent: FlatPluginRoute | null, +): string => { + const shape = declared.isIndex ? "index route" : declared.kind; + + return parent === null + ? `A top-level ${shape}` + : `A ${shape} inside the layout at "${parent.path}"`; +}; + +const readNode = ({ + declared, + flat, + parent, + pluginId, +}: { + declared: unknown; + flat: FlatPluginRoute[]; + parent: FlatPluginRoute | null; + pluginId: string; +}): void => { + if (!isPluginRouteDeclaration(declared)) { + return fail({ + code: "invalid-tree", + message: `${pluginId} declares a route${parent === null ? "" : ` inside the layout at "${parent.path}"`} that was not built with page(), layout() or index(). Every route in a \`definePluginRoutes\` tree comes from one of those - a plain object cannot say which kind of route it is.`, + pluginId, + }); + } + + const where = describe(declared, parent); + const area = + parent === null ? readArea(declared.area, pluginId, where) : parent.area; + + if (parent !== null && declared.area !== undefined) { + return fail({ + code: "invalid-area", + message: `${where} in ${pluginId} declares an \`area\`. Only a top-level route chooses its shell - every route inside a layout renders in the shell its layout renders in, so remove the field.`, + pluginId, + }); + } + + const path = readPath({ + declared: declared.path, + isIndex: declared.isIndex, + parent, + pluginId, + where, + }); + const parsed = parseRoutePath(path); + + if (!parsed.ok) { + return fail({ + code: "invalid-path", + message: `${where} in ${pluginId} has an invalid path: ${parsed.reason}.`, + path, + pluginId, + }); + } + + const children = declared.children ?? []; + + if (declared.kind === "layout" && children.length === 0) { + return fail({ + code: "childless-layout", + message: `${where} in ${pluginId} is a layout with no \`children\`. A layout claims no URL of its own, so nothing would ever render it - give it an \`index()\` route, or make it a \`page()\`.`, + path: parsed.path, + pluginId, + }); + } + + const route: FlatPluginRoute = { + area, + component: readComponent(declared.component, pluginId, where), + kind: declared.kind, + messages: readMessages(declared.messages, pluginId, where), + parentId: parent === null ? null : parent.routeId, + path: parsed.path, + requires: readRequires(declared.requires, area, pluginId, where), + routeId: pluginRouteIdFor(declared.kind, parsed.path), + search: readSearch(declared.search, declared.kind, pluginId, where), + segments: parsed.segments, + }; + + flat.push(route); + + for (const child of children) { + readNode({ declared: child, flat, parent: route, pluginId }); + } +}; + +export const flattenPluginRoutes = ( + pluginId: string, + routes: unknown, +): FlatPluginRoute[] => { + if (routes === undefined || routes === null) return []; + + if (!Array.isArray(routes)) { + return fail({ + code: "malformed-route", + message: `Plugin ${pluginId} declared \`routes\` that is not an array. A plugin's \`routes.ts\` exports \`definePluginRoutes([...])\`.`, + pluginId, + }); + } + + const flat: FlatPluginRoute[] = []; + + for (const declared of routes) { + readNode({ declared, flat, parent: null, pluginId }); + } + + return flat; +}; diff --git a/packages/vitnode/src/routing/graph.test.ts b/packages/vitnode/src/routing/graph.test.ts index 473f89ac1..9b050ef6c 100644 --- a/packages/vitnode/src/routing/graph.test.ts +++ b/packages/vitnode/src/routing/graph.test.ts @@ -2,23 +2,76 @@ import { describe, expect, it } from "vitest"; import type { PluginRouteGraph, PluginRouteNode } from "./graph"; -import type { PluginRoute, PluginRouteDefinition } from "./types"; +import type { + PluginRoute, + PluginRouteArea, + PluginRouteKind, + PluginRouteRequirement, +} from "./types"; import { PluginRouteError } from "./errors"; import { buildPluginRouteGraph, pluginRouteNamespaces } from "./graph"; -import { buildPluginRouteManifest } from "./manifest"; +import { pluginRouteId } from "./manifest"; +import { comparePluginRoutes } from "./order"; +import { parseRoutePath } from "./path"; -/** The manifest a plugin's declarations build into, which is what a graph reads. */ -const manifestOf = ( - ...sources: { pluginId: string; routes: PluginRouteDefinition[] }[] -): PluginRoute[] => buildPluginRouteManifest(sources); +/** + * A built route, written directly rather than flattened from a declaration. + * + * The graph reads a manifest, so these tests hand it one: ids, parents and + * kinds are stated rather than derived, which is what lets a case like "a route + * whose parent is in another plugin" exist at all - `flattenPluginRoutes` cannot + * produce it, and this layer still has to refuse it, because it also runs over + * declarations a plugin compiled against another version of VitNode. + */ +interface RouteFixture { + area?: PluginRouteArea; + id: string; + kind?: PluginRouteKind; + messages?: string[]; + parentId?: string; + path: string; + requires?: PluginRouteRequirement; +} + +const routeOf = (pluginId: string, fixture: RouteFixture): PluginRoute => { + const parsed = parseRoutePath(fixture.path); + + if (!parsed.ok) throw new Error(`bad fixture path "${fixture.path}"`); + + return { + area: fixture.area ?? "main", + id: pluginRouteId(pluginId, fixture.id), + kind: fixture.kind ?? "page", + messages: fixture.messages ?? [], + parentId: + fixture.parentId === undefined + ? null + : pluginRouteId(pluginId, fixture.parentId), + path: parsed.path, + pluginId, + requires: fixture.requires ?? null, + routeId: fixture.id, + segments: parsed.segments, + }; +}; -const example = (...routes: PluginRouteDefinition[]) => ({ +/** Every plugin's routes, in the order a built manifest has them. */ +const manifestOf = ( + ...sources: { pluginId: string; routes: RouteFixture[] }[] +): PluginRoute[] => + sources + .flatMap(source => + source.routes.map(fixture => routeOf(source.pluginId, fixture)), + ) + .sort(comparePluginRoutes); + +const example = (...routes: RouteFixture[]) => ({ pluginId: "@vitnode/example", routes, }); -const blog = (...routes: PluginRouteDefinition[]) => ({ +const blog = (...routes: RouteFixture[]) => ({ pluginId: "@vitnode/blog", routes, }); @@ -26,14 +79,14 @@ const blog = (...routes: PluginRouteDefinition[]) => ({ const page = ( id: string, path: string, - rest: Partial<PluginRouteDefinition> = {}, -): PluginRouteDefinition => ({ entry: `routes/${id}`, id, path, ...rest }); + rest: Partial<RouteFixture> = {}, +): RouteFixture => ({ id, path, ...rest }); const layout = ( id: string, path: string, - rest: Partial<PluginRouteDefinition> = {}, -): PluginRouteDefinition => page(id, path, { kind: "layout", ...rest }); + rest: Partial<RouteFixture> = {}, +): RouteFixture => page(id, path, { kind: "layout", ...rest }); /** One node of a graph, or a failure that names the id rather than `undefined`. */ const nodeOf = (graph: PluginRouteGraph, id: string): PluginRouteNode => { @@ -203,7 +256,7 @@ describe("nesting", () => { * machine the build ran on. */ it("does not depend on declaration order", () => { - const shape = (routes: PluginRouteDefinition[]) => + const shape = (routes: RouteFixture[]) => buildPluginRouteGraph(manifestOf(example(...routes))).nodes.map(node => [ node.route.id, node.relativePath, @@ -398,7 +451,6 @@ describe("a hierarchy that does not hold together", () => { ); const inner: PluginRoute = { ...outer, - entry: "routes/inner", id: "@vitnode/example:inner", parentId: outer.id, routeId: "inner", @@ -763,9 +815,9 @@ describe("pluginRouteNamespaces", () => { const graph = buildPluginRouteGraph( manifestOf( example( - layout("settings", "/settings", { namespaces: ["core.global"] }), + layout("settings", "/settings", { messages: ["core.global"] }), page("security", "/settings/security", { - namespaces: ["@vitnode/example.security"], + messages: ["@vitnode/example.security"], parentId: "settings", }), ), @@ -786,8 +838,8 @@ describe("pluginRouteNamespaces", () => { const graph = buildPluginRouteGraph( manifestOf( example( - layout("a", "/a", { namespaces: ["core.global"] }), - page("b", "/a/b", { namespaces: ["core.global"], parentId: "a" }), + layout("a", "/a", { messages: ["core.global"] }), + page("b", "/a/b", { messages: ["core.global"], parentId: "a" }), ), ), ); diff --git a/packages/vitnode/src/routing/graph.ts b/packages/vitnode/src/routing/graph.ts index d2fad0fde..1529b708a 100644 --- a/packages/vitnode/src/routing/graph.ts +++ b/packages/vitnode/src/routing/graph.ts @@ -1,8 +1,8 @@ import type { PluginRoute, PluginRouteSegment } from "./types"; import { PluginRouteError } from "./errors"; -import { comparePluginRoutes } from "./manifest"; import { normalizeNamespaceList } from "./namespaces"; +import { comparePluginRoutes } from "./order"; import { formatRoutePath, relativeRouteSegments, routeMatchKey } from "./path"; /** @@ -74,12 +74,13 @@ const fail = ( /** * A manifest, read as a tree - and every way that could be wrong, refused. * - * Called twice on the same data by design: once while an application is built, - * where it is what turns a bad `parentId` into a failed build, and once by the - * runtime over the generated manifest, where it is what decides the order routes - * are mounted in. One function, so a tree an application builds is provably the - * tree its build validated - a second implementation for the runtime is exactly - * how a check ends up passing at build time and being wrong in production. + * Called twice on the same declarations by design: once while an application is + * built, where it is what turns a tree that cannot hold together into a failed + * build, and once by the runtime over the same plugins' own modules, where it is + * what decides the order routes are mounted in. One function, so the tree an + * application mounts is provably the tree its build validated - a second + * implementation for the runtime is exactly how a check ends up passing at build + * time and being wrong in production. * * It is pure and it is cheap: a map, four passes over a list that has as many * entries as the app has plugin pages. @@ -89,11 +90,11 @@ const fail = ( * - **A duplicate id.** Two routes cannot share the key their module is * registered under. * - **A parent that does not exist**, in a manifest that ought to contain it. - * - **A parent in another plugin.** Unrepresentable in a declaration - a - * `parentId` is plugin-local - and still checked here, because this also runs - * over a generated manifest where ids are already global. One plugin's page - * inside another plugin's frame would make a route tree depend on which - * plugins happen to be installed beside it. + * - **A parent in another plugin.** Unrepresentable in a declaration - a parent + * is the layout a route was nested inside, in its own plugin's tree - and + * still checked here, because ids are global by the time this runs. One + * plugin's page inside another plugin's frame would make a route tree depend + * on which plugins happen to be installed beside it. * - **A parent that is not a layout.** A page has no children; a route under one * would never render. * - **A parent in another area.** Nesting is how a shell is chosen, so a route @@ -175,7 +176,7 @@ export const buildPluginRouteGraph = ( fail( "unknown-parent", route, - `Plugin route "${route.id}" declares the parent "${parentId}", which no route in the manifest has. A parent is another route from the same plugin, named by its plugin-local id.`, + `Plugin route "${route.id}" declares the parent "${parentId}", which no route in the manifest has. A parent is the layout this route was nested inside in its plugin's own route tree.`, ); continue; @@ -331,7 +332,7 @@ export const buildPluginRouteGraph = ( fail( "childless-layout", node.route, - `Plugin route "${node.route.id}" is a layout with no routes inside it. A layout claims no URL of its own, so nothing would ever render it - give it a route with \`parentId: "${node.route.routeId}"\`, or make it a page.`, + `Plugin route "${node.route.id}" is a layout with no routes inside it. A layout claims no URL of its own, so nothing would ever render it - give it an \`index()\` route, or make it a \`page()\`.`, ); } } @@ -432,7 +433,7 @@ export const pluginRouteNamespaces = (node: PluginRouteNode): string[] => { current !== null; current = current.parent ) { - namespaces.push(...current.route.namespaces); + namespaces.push(...current.route.messages); } return normalizeNamespaceList(namespaces); diff --git a/packages/vitnode/src/routing/index.ts b/packages/vitnode/src/routing/index.ts index 58f005dc9..5925679c1 100644 --- a/packages/vitnode/src/routing/index.ts +++ b/packages/vitnode/src/routing/index.ts @@ -15,25 +15,35 @@ * * ## Two halves, fetched at two different times * - * ./types ./manifest ./graph WHAT routes exist, WHERE, in WHICH shape - * ./module HOW one behaves once its chunk has arrived + * ./tree ./flatten ./manifest ./graph WHAT routes exist, WHERE, WHICH shape + * ./module HOW one behaves once its chunk lands * - * The first is frozen into the application at build time and read before a - * single byte of a plugin's code is downloaded - which is why the guard, the - * URL, the tree shape and the message namespaces live there and not in the - * module. The second is the contract that module satisfies, and it is a - * VitNode-owned shape rather than a re-exported router type, so a plugin is - * coupled to VitNode and not to whichever router its host happens to run. + * The first is a plugin's `routes.ts`: a nested tree of `page()`, `layout()` and + * `index()` declarations, read before a single byte of a page's code is + * downloaded - which is why the guard, the URL, the tree shape, the message + * namespaces and the one eager `search` schema live there and not in the module. + * The second is the contract each lazily imported page or layout module + * satisfies, and it is a VitNode-owned shape rather than a re-exported router + * type, so a plugin is coupled to VitNode and not to whichever router its host + * happens to run. */ export { definePluginRoute } from "./authoring"; export type { PluginRouteErrorCode, PluginRouteErrorDetails } from "./errors"; export { PluginRouteError } from "./errors"; +export type { FlatPluginRoute } from "./flatten"; +export { flattenPluginRoutes, pluginRouteIdFor } from "./flatten"; export type { PluginRouteGraph, PluginRouteNode } from "./graph"; export { buildPluginRouteGraph, pluginRouteNamespaces } from "./graph"; -export { buildPluginRouteManifest, pluginRouteId } from "./manifest"; +export type { CompiledPluginRouteTrees } from "./manifest"; +export { + buildPluginRouteManifest, + compilePluginRouteTrees, + pluginRouteId, +} from "./manifest"; export type { CheckedPluginRouteModule, CheckedPluginRouteOptions, + PluginRouteBreadcrumbProps, PluginRouteContext, PluginRouteHead, PluginRouteHeadArgs, @@ -65,13 +75,34 @@ export { toNextRoutePath, toTanStackRoutePath, } from "./path"; +export type { + PluginRouteComponent, + PluginRouteDeclaration, + PluginRouteDeclarationSource, + PluginRouteEagerComponentRejected, + PluginRouteIndexOptions, + PluginRouteLayoutOptions, + PluginRouteLazyComponent, + PluginRoutePageOptions, + PluginRoutes, + PluginRouteSearchSchema, +} from "./tree"; +export { + definePluginRoutes, + index, + isPluginRouteDeclaration, + isPluginRouteLazyComponent, + layout, + lazy, + page, +} from "./tree"; export type { PluginRoute, PluginRouteArea, - PluginRouteDefinition, PluginRouteKind, PluginRouteManifest, PluginRouteRequirement, + PluginRouteSearchValidator, PluginRouteSegment, PluginRouteSource, } from "./types"; diff --git a/packages/vitnode/src/routing/manifest.test.ts b/packages/vitnode/src/routing/manifest.test.ts index f826f081d..471da6754 100644 --- a/packages/vitnode/src/routing/manifest.test.ts +++ b/packages/vitnode/src/routing/manifest.test.ts @@ -1,39 +1,47 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; -import type { BuildPluginReturn } from "../lib/plugin"; -import type { PluginRouteDefinition, PluginRouteSource } from "./types"; +import type { PluginRouteDeclaration } from "./tree"; +import type { PluginRouteArea } from "./types"; import { PluginRouteError } from "./errors"; import { buildPluginRouteManifest, - comparePluginRoutes, + compilePluginRouteTrees, pluginRouteId, } from "./manifest"; +import { comparePluginRoutes } from "./order"; +import { definePluginRoutes, index, layout, lazy, page } from "./tree"; import { PLUGIN_ROUTE_AREAS } from "./types"; -const route = (id: string, path: string): PluginRouteDefinition => ({ - entry: `routes/${id}`, - id, - path, -}); +/** + * Every plugin's routes in one application: flattened, validated, ordered, and + * paired with the two things that cannot be serialised - each route's lazy + * component and each route's eager search schema. + * + * What is tested here is the part that needs more than one plugin, or more than + * one route: ids, ordering, and the collisions VitNode refuses rather than + * resolves. Whether a single tree is legal on its own is `./tree.test.ts`. + */ +const lazyPage = () => + lazy(async () => await Promise.resolve({ default: () => null })); -const example = (...routes: PluginRouteDefinition[]): PluginRouteSource => ({ - pluginId: "@vitnode/example", - routes, +const catalog = (...routes: PluginRouteDeclaration[]) => ({ + pluginId: "@acme/catalog", + routes: definePluginRoutes(routes), }); -const blog = (...routes: PluginRouteDefinition[]): PluginRouteSource => ({ - pluginId: "@vitnode/blog", - routes, +const blog = (...routes: PluginRouteDeclaration[]) => ({ + pluginId: "@acme/blog", + routes: definePluginRoutes(routes), }); -/** The error a call threw, typed - `expect().toThrow` only sees the message. */ const thrownBy = (build: () => unknown): PluginRouteError => { try { build(); } catch (error) { if (error instanceof PluginRouteError) return error; + throw error; } @@ -42,161 +50,158 @@ const thrownBy = (build: () => unknown): PluginRouteError => { describe("route ids", () => { it("namespaces a route id by its plugin", () => { - // The same key `framework/plugin-routes` registers the module loader under, - // so a manifest entry addresses its own module with no translation step. - expect(pluginRouteId("@vitnode/example", "hello")).toBe( - "@vitnode/example:hello", + expect(pluginRouteId("@acme/catalog", "page#/catalog")).toBe( + "@acme/catalog:page#/catalog", ); }); - it("lets two plugins use the same local id", () => { + it("lets two plugins claim their own paths without knowing about each other", () => { const manifest = buildPluginRouteManifest([ - example(route("index", "/example")), - blog(route("index", "/blog")), + catalog(page("/catalog", { component: lazyPage() })), + blog(page("/blog", { component: lazyPage() })), ]); - expect(manifest.map(entry => entry.id)).toEqual([ - "@vitnode/blog:index", - "@vitnode/example:index", + expect(manifest.map(route => route.id)).toEqual([ + "@acme/blog:page#/blog", + "@acme/catalog:page#/catalog", ]); }); -}); -describe("the seam with the generated module registry", () => { - it("takes an app's configured plugin list exactly as it is", () => { - // The call an application makes: `buildPluginRouteManifest(config.plugins)`. - // `BuildPluginReturn` is not imported by the routing layer - it reaches the - // AdminCP nav and the Content Engine, and through them React - so the two - // types meet structurally or not at all. This is where that is checked. - const plugins: BuildPluginReturn[] = [ - { - pluginId: "@vitnode/example", - routes: [route("hello", "/example/hello")], - }, - { pluginId: "@vitnode/blog" }, - ]; - - expect(buildPluginRouteManifest(plugins).map(entry => entry.id)).toEqual([ - "@vitnode/example:hello", + it("namespaces a parent id with the declaring plugin", () => { + const [frame, indexRoute] = buildPluginRouteManifest([ + catalog( + layout("/catalog", { + component: lazyPage(), + children: [index({ component: lazyPage() })], + }), + ), ]); + + expect(frame.parentId).toBeNull(); + expect(indexRoute.parentId).toBe("@acme/catalog:layout#/catalog"); }); +}); + +describe("what a compiled tree carries", () => { + const compiled = () => + compilePluginRouteTrees([ + catalog( + page("/catalog", { + component: lazyPage(), + search: () => ({ page: 1 }), + }), + page("/catalog/about", { component: lazyPage() }), + ), + ]); - it("declares the two fields the registry reads, and no more", () => { - // `framework/plugin-routes` takes `id` and `entry` off these same records - // and generates a lazy import for each. A definition is assignable to that - // shape by construction, which is what lets one list in a plugin's - // `routes/manifest.ts` serve both layers. - const declaration: { entry: string; id: string } = route("hello", "/x"); + it("keys every route's component by the route's own id", () => { + const { components, manifest } = compiled(); - expect(declaration).toMatchObject({ entry: "routes/hello", id: "hello" }); + expect([...components.keys()].sort()).toEqual( + manifest.map(route => route.id).sort(), + ); }); - it("addresses a module by the key that registry is keyed on", () => { - const [route] = buildPluginRouteManifest([ - example({ entry: "routes/hello", id: "hello", path: "/example/hello" }), + it("keys only the routes that declared a search schema", () => { + expect([...compiled().searchValidators.keys()]).toEqual([ + "@acme/catalog:page#/catalog", + ]); + }); + + it("is empty when nothing declares a route", () => { + const { components, manifest, searchValidators } = compilePluginRouteTrees([ + { pluginId: "@acme/catalog" }, + { pluginId: "@acme/blog", routes: definePluginRoutes([]) }, ]); - expect(route.id).toBe(`${route.pluginId}:${route.routeId}`); - expect(route.entry).toBe("routes/hello"); + expect(manifest).toEqual([]); + expect(components.size).toBe(0); + expect(searchValidators.size).toBe(0); }); }); describe("normalising a declaration", () => { it("fills in the defaults a plugin left out", () => { - const [route] = buildPluginRouteManifest([ - example({ entry: "routes/x", id: "x", path: "/example/x/" }), + expect( + buildPluginRouteManifest([ + catalog(page("/catalog/:productId", { component: lazyPage() })), + ]), + ).toEqual([ + { + area: "main", + id: "@acme/catalog:page#/catalog/:productId", + kind: "page", + messages: [], + parentId: null, + path: "/catalog/:productId", + pluginId: "@acme/catalog", + requires: null, + routeId: "page#/catalog/:productId", + segments: [ + { kind: "static", value: "catalog" }, + { kind: "param", name: "productId" }, + ], + }, ]); - - expect(route).toEqual({ - area: "main", - entry: "routes/x", - id: "@vitnode/example:x", - kind: "page", - namespaces: [], - parentId: null, - path: "/example/x", - pluginId: "@vitnode/example", - requires: null, - routeId: "x", - searchEntry: null, - segments: [ - { kind: "static", value: "example" }, - { kind: "static", value: "x" }, - ], - }); }); it("keeps an explicit area", () => { const [route] = buildPluginRouteManifest([ - example({ - area: "main", - entry: "routes/hello", - id: "hello", - path: "/example/hello", - }), + catalog(page("/admin/catalog", { area: "admin", component: lazyPage() })), ]); - expect(route.area).toBe("main"); - }); - - it("is an empty manifest when nothing declares a route", () => { - expect( - buildPluginRouteManifest([ - { pluginId: "@vitnode/example" }, - { pluginId: "@vitnode/blog", routes: [] }, - ]), - ).toEqual([]); + expect(route.area).toBe("admin"); }); }); -/** - * The property the whole manifest rests on: two installs with the same plugins - * in a different order resolve the same URLs to the same pages. - */ describe("ordering is decided by the paths, not by the registration order", () => { - const routes = [ - example( - route("slug", "/example/:slug"), - route("new", "/example/new"), - route("index", "/example"), - ), - blog(route("post", "/blog/:postId/comments"), route("index", "/blog")), - ]; + const paths = (...sources: Parameters<typeof buildPluginRouteManifest>[0]) => + buildPluginRouteManifest(sources).map(route => route.path); it("puts static segments before parameters at the same depth", () => { - expect(buildPluginRouteManifest(routes).map(entry => entry.path)).toEqual([ - "/blog", - "/blog/:postId/comments", - "/example", - "/example/new", - "/example/:slug", - ]); + expect( + paths( + catalog( + page("/catalog/:productId", { component: lazyPage() }), + page("/catalog/new", { component: lazyPage() }), + ), + ), + ).toEqual(["/catalog/new", "/catalog/:productId"]); }); it("gives the same order whichever plugin registered first", () => { - const forwards = buildPluginRouteManifest(routes); - const backwards = buildPluginRouteManifest([...routes].reverse()); + const one = catalog(page("/catalog", { component: lazyPage() })); + const two = blog(page("/blog", { component: lazyPage() })); - expect(backwards.map(entry => entry.id)).toEqual( - forwards.map(entry => entry.id), - ); + expect(paths(one, two)).toEqual(paths(two, one)); }); - it("gives the same order whichever route a plugin declared first", () => { - const declared = buildPluginRouteManifest([ - example(route("index", "/example"), route("slug", "/example/:slug")), - ]); - const reversed = buildPluginRouteManifest([ - example(route("slug", "/example/:slug"), route("index", "/example")), + it("puts a layout in front of the index page inside it", () => { + const manifest = buildPluginRouteManifest([ + catalog( + layout("/catalog", { + component: lazyPage(), + children: [index({ component: lazyPage() })], + }), + ), ]); - expect(reversed.map(entry => entry.path)).toEqual( - declared.map(entry => entry.path), - ); + expect(manifest.map(route => route.kind)).toEqual(["layout", "page"]); }); - it("is a total order, so a sort of a manifest is a no-op", () => { - const manifest = buildPluginRouteManifest(routes); + it("is a total order, so sorting a manifest again is a no-op", () => { + const manifest = buildPluginRouteManifest([ + catalog( + layout("/catalog", { + component: lazyPage(), + children: [ + index({ component: lazyPage() }), + page(":productId", { component: lazyPage() }), + ], + }), + ), + blog(page("/blog", { component: lazyPage() })), + ]); expect([...manifest].sort(comparePluginRoutes)).toEqual(manifest); }); @@ -206,530 +211,146 @@ describe("collisions are errors, never resolutions", () => { it("names both plugins when two claim the same path", () => { const error = thrownBy(() => buildPluginRouteManifest([ - example(route("hello", "/hello")), - blog(route("greeting", "/hello")), + catalog(page("/catalog", { component: lazyPage() })), + blog(page("/catalog", { component: lazyPage() })), ]), ); expect(error.code).toBe("duplicate-path"); - expect(error.path).toBe("/hello"); - expect(error.pluginId).toBe("@vitnode/blog"); - expect(error.conflictsWith).toEqual({ - pluginId: "@vitnode/example", - routeId: "@vitnode/example:hello", - }); - expect(error.message).toContain("/hello"); - expect(error.message).toContain("@vitnode/example"); - expect(error.message).toContain("@vitnode/blog"); - }); - - it("catches a collision that only normalisation reveals", () => { - expect(() => - buildPluginRouteManifest([ - example(route("a", "/hello")), - blog(route("b", "/hello/")), - ]), - ).toThrow(PluginRouteError); + expect(error.pluginId).toBe("@acme/blog"); + expect(error.conflictsWith?.pluginId).toBe("@acme/catalog"); + expect(error.message).toContain("@acme/catalog"); + expect(error.message).toContain("@acme/blog"); }); it("treats two paths that differ only by a parameter name as one path", () => { - // `/example/:slug` and `/example/:id` match exactly the same URLs. const error = thrownBy(() => buildPluginRouteManifest([ - example(route("a", "/example/:slug")), - blog(route("b", "/example/:id")), + catalog(page("/catalog/:productId", { component: lazyPage() })), + blog(page("/catalog/:slug", { component: lazyPage() })), ]), ); expect(error.code).toBe("duplicate-path"); - // Both spellings, because neither plugin author wrote the other's. - expect(error.message).toContain("/example/:slug"); - expect(error.message).toContain("/example/:id"); }); - it("rejects one plugin declaring the same id twice", () => { + it("collides across areas, because a shell is pathless", () => { const error = thrownBy(() => buildPluginRouteManifest([ - example(route("hello", "/a"), route("hello", "/b")), + catalog(page("/reports", { area: "admin", component: lazyPage() })), + blog(page("/reports", { component: lazyPage() })), ]), ); - expect(error.code).toBe("duplicate-id"); - expect(error.message).toContain("@vitnode/example:hello"); - }); -}); - -describe("malformed declarations", () => { - const build = (source: unknown) => - buildPluginRouteManifest([source] as PluginRouteSource[]); - - it("rejects an empty plugin id", () => { - for (const pluginId of ["", " ", undefined]) { - expect(thrownBy(() => build({ pluginId, routes: [] })).code).toBe( - "invalid-plugin-id", - ); - } - }); - - it("rejects a route that is not an object", () => { - expect( - thrownBy(() => build({ pluginId: "@vitnode/example", routes: ["/x"] })) - .code, - ).toBe("malformed-route"); - }); - - it("rejects a missing or unusable id", () => { - for (const id of [ - undefined, - "", - "with space", - "-leading-dash", - "../escape", - ]) { - expect( - thrownBy(() => - build({ - pluginId: "@vitnode/example", - routes: [{ entry: "routes/x", id, path: "/x" }], - }), - ).code, - ).toBe("invalid-id"); - } - }); - - it("rejects a missing or malformed path", () => { - for (const path of [undefined, "", "x", "/x/[id]"]) { - const error = thrownBy(() => - build({ - pluginId: "@vitnode/example", - routes: [{ entry: "routes/x", id: "x", path }], - }), - ); - - expect(error.code).toBe("invalid-path"); - expect(error.routeId).toBe("x"); - } - }); - - /** - * A path a router would match case-insensitively but this layer would compare - * as two different strings. Rejected here rather than lowercased, and the - * failure names the plugin - see `path.test.ts` for the rule itself. - */ - it("rejects an uppercase path, naming the plugin", () => { - const error = thrownBy(() => - build({ - pluginId: "@vitnode/example", - routes: [{ entry: "routes/x", id: "x", path: "/Example" }], - }), - ); - - expect(error.code).toBe("invalid-path"); - expect(error.pluginId).toBe("@vitnode/example"); - expect(error.message).toContain('Write "example"'); - }); - - it("rejects an entry an application could never import", () => { - for (const entry of [ - undefined, - "", - "/routes/x", - "routes/../../secret", - "routes/x.tsx", - "routes/x'\\n", - ]) { - expect( - thrownBy(() => - build({ - pluginId: "@vitnode/example", - routes: [{ entry, id: "x", path: "/x" }], - }), - ).code, - ).toBe("invalid-entry"); - } - }); - - /** - * `blank`, `api`, `settings`, `moderator` - every area somebody might assume - * exists. Two do, and a member is added by a stage that has a shell to point - * it at, never by a plugin declaring one. - */ - it("rejects an unknown area", () => { - const error = thrownBy(() => - build({ - pluginId: "@vitnode/example", - routes: [{ area: "blank", entry: "routes/x", id: "x", path: "/x" }], - }), - ); - - expect(error.code).toBe("invalid-area"); - expect(error.message).toContain("admin, main"); - }); -}); - -/** - * The AdminCP, as a place a plugin may put a page - Stage 12's one addition to - * this layer. - * - * An area chooses a shell and never rewrites a path, which is what every - * assertion here is ultimately about: an admin route says `/admin/…` in full, a - * subtree renders in one shell, and two areas at one pathname are one URL - * claimed twice - because both shells are pathless. - */ -describe("the admin area", () => { - const one = (route: Partial<PluginRouteDefinition>) => - buildPluginRouteManifest([ - { - pluginId: "@vitnode/example", - routes: [ - { entry: "routes/x", id: "x", path: "/admin/reports", ...route }, - ], - }, - ])[0]; - - it("is an area a route may declare", () => { - expect(one({ area: "admin" })).toMatchObject({ - area: "admin", - path: "/admin/reports", - }); - }); - - /** - * The invariant the whole design rests on. Nothing prefixes a path from an - * area, so a manifest is readable - and a collision visible in a diff - - * without anybody walking a graph to find out where a page actually is. - */ - it("does not prefix, rewrite or infer the path", () => { - expect(one({ area: "admin", path: "/admin/reports/:id" })).toMatchObject({ - path: "/admin/reports/:id", - segments: [ - { kind: "static", value: "admin" }, - { kind: "static", value: "reports" }, - { kind: "param", name: "id" }, - ], - }); - - // And an admin-area route is not *made* to live under `/admin` either. The - // area names a shell; where that shell's own routes sit is the host's. - expect(one({ area: "admin", path: "/reports" })).toMatchObject({ - area: "admin", - path: "/reports", - }); + expect(error.code).toBe("duplicate-path"); }); - /** - * The rule an earlier draft of this stage had backwards. - * - * Both shells a host mounts plugin routes under are *pathless*: `_main` and - * `_admin` contribute no segment, so they frame a page rather than moving it. - * `main /reports` and `admin /reports` are therefore one URL claimed twice, - * and a browser asking for `/reports` would reach whichever of them the - * router ranked first - which is precisely the outcome this layer exists to - * make impossible. - */ - it("collides with the same pathname in another area", () => { + it("rejects one plugin declaring the same route twice", () => { const error = thrownBy(() => buildPluginRouteManifest([ - example( - { area: "admin", entry: "routes/a", id: "a", path: "/reports" }, - { entry: "routes/b", id: "b", path: "/reports" }, + catalog( + page("/catalog", { component: lazyPage() }), + page("/catalog", { component: lazyPage() }), ), ]), ); - expect(error.code).toBe("duplicate-path"); + expect(error.code).toBe("duplicate-id"); + expect(error.message).toContain("two pages"); }); - /** - * And the same across two plugins, spelled differently. - * - * `/member/:id` and `/member/:slug` match the same URLs whatever their - * parameters are called, so putting them in two areas cannot separate them - * either - the key is the URL space, and a parameter's name is not part of a - * URL. - */ - it("collides across areas on two spellings of one dynamic URL", () => { + it("refuses two layouts at one path", () => { const error = thrownBy(() => buildPluginRouteManifest([ - example({ entry: "routes/a", id: "a", path: "/member/:id" }), - blog({ - area: "admin", - entry: "routes/b", - id: "b", - path: "/member/:slug", - }), + catalog( + layout("/catalog", { + component: lazyPage(), + children: [index({ component: lazyPage() })], + }), + ), + blog( + layout("/catalog", { + component: lazyPage(), + children: [index({ component: lazyPage() })], + }), + ), ]), ); expect(error.code).toBe("duplicate-path"); - expect(error.message).toContain("@vitnode/example"); - expect(error.message).toContain("@vitnode/blog"); }); - /** - * Distinct URLs stay distinct, which is the other half of the same rule: an - * admin route earns its `/admin` from its own `path`, so a plugin that writes - * one is not competing with its own public page. - */ - it("accepts two areas whose canonical paths actually differ", () => { - const manifest = buildPluginRouteManifest([ - example( - { area: "admin", entry: "routes/a", id: "a", path: "/admin/reports" }, - { entry: "routes/b", id: "b", path: "/reports" }, - ), - ]); - - expect(manifest.map(route => [route.area, route.path])).toEqual([ - ["admin", "/admin/reports"], - ["main", "/reports"], + it("lets a layout and the index page inside it share a path", () => { + expect( + buildPluginRouteManifest([ + catalog( + layout("/catalog", { + component: lazyPage(), + children: [index({ component: lazyPage() })], + }), + ), + ]).map(route => [route.kind, route.path]), + ).toEqual([ + ["layout", "/catalog"], + ["page", "/catalog"], ]); }); - it("collides with another admin route at the same path", () => { + it("refuses another plugin's page at a layout's path", () => { const error = thrownBy(() => buildPluginRouteManifest([ - example({ - area: "admin", - entry: "routes/a", - id: "a", - path: "/admin/reports", - }), - blog({ - area: "admin", - entry: "routes/b", - id: "b", - path: "/admin/reports", - }), + catalog( + layout("/catalog", { + component: lazyPage(), + children: [index({ component: lazyPage() })], + }), + ), + blog(page("/catalog", { component: lazyPage() })), ]), ); expect(error.code).toBe("duplicate-path"); - expect(error.message).toContain("(admin)"); - }); - - /** - * `requires` is about the public session; the AdminCP runs on a second one - * under its own cookie, and an admin route is already behind that shell's - * guard. A field that reads as enforcement and enforces a different session's - * answer is worse than no field, so it is refused rather than ignored. - */ - it.each(["authenticated", "guest"] as const)( - "refuses `requires: %s` on an admin route", - requires => { - const error = thrownBy(() => one({ area: "admin", requires })); - - expect(error.code).toBe("requires-in-admin-area"); - expect(error.message).toContain("public session"); - }, - ); - - it("still accepts `requires` in the main area", () => { - expect(one({ requires: "authenticated" })).toMatchObject({ - area: "main", - requires: "authenticated", - }); - }); - - /** - * The list is data that other layers iterate - a diagnostic lists it, and the - * TanStack runtime walks it to hang one subtree per shell - so its order is - * part of the contract rather than an artefact of how this file was edited. - * - * Two members, and only two. `blank`, `api`, `settings` and `moderator` are - * areas somebody will assume exist; a member is added by a stage that has a - * shell to point it at. - */ - it("lists every area in a fixed order", () => { - expect(PLUGIN_ROUTE_AREAS).toEqual(["admin", "main"]); - expect([...PLUGIN_ROUTE_AREAS].sort()).toEqual(PLUGIN_ROUTE_AREAS); - }); - - /** - * Type-level, and checked by `tsc` rather than at runtime: an area is a closed - * union, so a plugin cannot invent one and have it merely fail validation - * later. - */ - it("is a closed union at the type level", () => { - const admin = { - area: "admin", - entry: "routes/x", - id: "x", - path: "/admin/reports", - } satisfies PluginRouteDefinition; - - const invalid = { - // @ts-expect-error - "blank" is not an area VitNode has. - area: "blank", - entry: "routes/x", - id: "x", - path: "/x", - } satisfies PluginRouteDefinition; - - expect([admin.area, invalid.entry]).toEqual(["admin", "routes/x"]); }); }); -describe("the fields Stage 11 added", () => { - const one = (route: Partial<PluginRouteDefinition>) => - buildPluginRouteManifest([ - { - pluginId: "@vitnode/example", - routes: [{ entry: "routes/x", id: "x", path: "/x", ...route }], - }, - ])[0]; - - /** - * The prototype's three-field declaration still says exactly what it used to, - * and every new field arrives with its default already filled in - so nothing - * downstream re-implements "and if it is missing, it means". - */ - it("defaults every new field", () => { - expect(one({})).toEqual({ - area: "main", - entry: "routes/x", - id: "@vitnode/example:x", - kind: "page", - namespaces: [], - parentId: null, - path: "/x", - pluginId: "@vitnode/example", - requires: null, - routeId: "x", - searchEntry: null, - segments: [{ kind: "static", value: "x" }], - }); - }); - - /** - * A plugin names its own route, and this puts its own plugin's id in front of - * it - so there is no spelling of `parentId` that reaches another plugin. - * Cross-plugin nesting is not forbidden by a check, it is unrepresentable. - */ - it("namespaces a parentId with the declaring plugin", () => { - const manifest = buildPluginRouteManifest([ - { - pluginId: "@vitnode/example", - routes: [ - { entry: "routes/f", id: "frame", kind: "layout", path: "/f" }, - { - entry: "routes/x", - id: "x", - parentId: "frame", - path: "/f/x", - }, - ], - }, - ]); - - expect(manifest.map(route => route.parentId)).toEqual([ - null, - "@vitnode/example:frame", - ]); - }); - - it("de-duplicates and sorts declared namespaces", () => { +describe("malformed sources", () => { + it("rejects a plugin with no id", () => { expect( - one({ namespaces: ["core.search", "core.global", "core.search"] }) - .namespaces, - ).toEqual(["core.global", "core.search"]); - }); - - it.each([ - ["kind", "section", "invalid-kind"], - ["requires", "admin", "invalid-requires"], - ["parentId", "@vitnode/blog:frame", "invalid-parent"], - ["parentId", "/frame", "invalid-parent"], - ["namespaces", "core.global", "invalid-namespace"], - ["namespaces", ["core..global"], "invalid-namespace"], - ["namespaces", ["__proto__"], "invalid-namespace"], - ])("rejects %s: %s", (field, value, code) => { - expect(thrownBy(() => one({ [field]: value })).code).toBe(code); + thrownBy(() => + buildPluginRouteManifest([ + { pluginId: "", routes: definePluginRoutes([]) }, + ]), + ).code, + ).toBe("invalid-plugin-id"); }); - /** - * A layout claims no URL, so a layout at `/settings` and the index page inside - * it both spell `/settings` and are the two halves of one screen rather than a - * collision. Two *pages* there still are one. - */ - it("lets a layout and its index route share a path", () => { - expect(() => + it("fails on a hierarchy that does not hold together", () => { + // A `guest` page inside an `authenticated` layout: no visitor could ever + // reach it, and the graph is what says so. + const error = thrownBy(() => buildPluginRouteManifest([ - { - pluginId: "@vitnode/example", - routes: [ - { - entry: "routes/settings", - id: "settings", - kind: "layout", - path: "/settings", - }, - { - entry: "routes/settings-index", - id: "index", - parentId: "settings", - path: "/settings", - }, - ], - }, + catalog( + layout("/catalog", { + component: lazyPage(), + requires: "authenticated", + children: [index({ component: lazyPage(), requires: "guest" })], + }), + ), ]), - ).not.toThrow(); - }); + ); - it("still refuses two pages at one path", () => { - expect( - thrownBy(() => - buildPluginRouteManifest([ - example(route("a", "/same")), - blog(route("b", "/same")), - ]), - ).code, - ).toBe("duplicate-path"); + expect(error.code).toBe("conflicting-requires"); }); +}); - it("refuses two layouts at one path", () => { - expect( - thrownBy(() => - buildPluginRouteManifest([ - { - pluginId: "@vitnode/example", - routes: [ - { - entry: "routes/a", - id: "a", - kind: "layout", - path: "/f", - }, - { - entry: "routes/b", - id: "b", - kind: "layout", - path: "/f", - }, - { entry: "routes/x", id: "x", parentId: "a", path: "/f/x" }, - ], - }, - ]), - ).code, - ).toBe("duplicate-path"); +describe("the areas", () => { + it("are listed in a fixed order", () => { + expect(PLUGIN_ROUTE_AREAS).toEqual(["admin", "main"]); }); - /** - * The hierarchy is validated here rather than only where it is used, so a - * broken `parentId` stops a build instead of producing a generated manifest - * that fails in a browser. - */ - it("fails the build on a hierarchy that does not hold together", () => { - expect( - thrownBy(() => - buildPluginRouteManifest([ - example({ - entry: "routes/x", - id: "x", - parentId: "ghost", - path: "/x", - }), - ]), - ).code, - ).toBe("unknown-parent"); + it("are a closed union at the type level", () => { + const areas: PluginRouteArea[] = ["admin", "main"]; + + expect(areas).toHaveLength(PLUGIN_ROUTE_AREAS.length); }); }); diff --git a/packages/vitnode/src/routing/manifest.ts b/packages/vitnode/src/routing/manifest.ts index 7e5149e5a..1e17d66b2 100644 --- a/packages/vitnode/src/routing/manifest.ts +++ b/packages/vitnode/src/routing/manifest.ts @@ -1,335 +1,112 @@ +import type { FlatPluginRoute } from "./flatten"; +import type { PluginRouteLazyComponent } from "./tree"; import type { PluginRoute, - PluginRouteDefinition, - PluginRouteKind, PluginRouteManifest, + PluginRouteSearchValidator, PluginRouteSource, } from "./types"; import { PluginRouteError } from "./errors"; +import { flattenPluginRoutes } from "./flatten"; import { buildPluginRouteGraph } from "./graph"; -import { namespaceProblem, normalizeNamespaceList } from "./namespaces"; import { comparePluginRoutes } from "./order"; -import { parseRoutePath, routeMatchKey } from "./path"; -import { - PLUGIN_ROUTE_AREAS, - PLUGIN_ROUTE_ID_SEPARATOR, - PLUGIN_ROUTE_KINDS, - PLUGIN_ROUTE_REQUIREMENTS, -} from "./types"; +import { routeMatchKey } from "./path"; +import { PLUGIN_ROUTE_ID_SEPARATOR } from "./types"; export { comparePluginRoutes }; -/** - * A `/`-separated identifier, and nothing that could escape a string literal. - * - * The same rule `framework/plugin-routes` applies to an id and to an entry, for - * a reason this layer does not share - it writes both into a generated import. - * They are stated identically anyway: an id this layer accepts and that one - * rejects would be a route that validates and then fails the build. - */ -const SEGMENTED = - /^[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)*$/; - -/** An entry is a package export subpath, and export maps add the extension. */ -const ENTRY_EXTENSION = /\.[cm]?[jt]sx?$/; - /** * A route's globally unique id. * - * Namespaced by the plugin so two plugins can both call their landing page - * `"index"` - which they will - without either having to know the other exists. + * Namespaced by the plugin, which is what lets two plugins both have a layout at + * their own `/catalog` without either having to know the other exists. */ export const pluginRouteId = (pluginId: string, routeId: string): string => `${pluginId}${PLUGIN_ROUTE_ID_SEPARATOR}${routeId}`; -const isRecord = (value: unknown): value is Record<string, unknown> => - typeof value === "object" && value !== null && !Array.isArray(value); - -const readEntry = ( - entry: string | undefined, - pluginId: string, - routeId: string, - field = "entry", -): string => { - const fail = (reason: string): never => { - throw new PluginRouteError( - `Plugin route "${routeId}" from ${pluginId} has an invalid ${field} ${JSON.stringify(entry)}: ${reason}.`, - { code: "invalid-entry", pluginId, routeId }, - ); - }; - - if (typeof entry !== "string" || !SEGMENTED.test(entry)) { - return fail( - 'expected a package export subpath such as "routes/example-page" - "/"-separated segments of letters, digits, ".", "_" and "-", with no leading slash and no ".." segment', - ); - } - - if (ENTRY_EXTENSION.test(entry)) { - return fail( - `an ${field} is a package export subpath and the plugin's export map adds the extension - drop it`, - ); - } - - return entry; -}; - /** - * A route's optional eager search module, validated the way its entry is. + * Every plugin route in an application, with the behaviour each one carries. * - * Absent is the ordinary case and means `null` - most pages read no query - * string, and one that does is usually better served by the module's own - * `parseSearch`. See {@link PluginRouteDefinition.searchEntry} for when the - * eager one is worth its place in the initial bundle. - * - * A layout may not declare one. A layout claims no URL of its own and is only - * ever reached through a child, so a `validateSearch` on it would shape the - * search of pages that never asked for it - and the child that did would have - * two. Refused rather than ignored, for the same reason `requires` is refused in - * the admin area: a field that reads as behaviour and has none is worse than no - * field. + * The manifest is the data half - what routes exist, where, in which shape - and + * the two maps are the halves that cannot be serialised: the lazy component of + * each route, and the search schema of each route that declared one. All three + * come out of one pass over one set of declarations, so a route cannot appear in + * the manifest without its component or the other way round. */ -const readSearchEntry = ( - searchEntry: string | undefined, - kind: PluginRouteKind, - pluginId: string, - routeId: string, -): null | string => { - if (searchEntry === undefined) return null; - - if (kind === "layout") { - throw new PluginRouteError( - `Plugin route "${routeId}" from ${pluginId} is a layout and declares a \`searchEntry\`. A layout claims no URL of its own, so it has no query string to validate - declare it on the page that reads the search instead.`, - { code: "invalid-entry", pluginId, routeId }, - ); - } +export interface CompiledPluginRouteTrees { + components: Map<string, PluginRouteLazyComponent>; + manifest: PluginRouteManifest; + searchValidators: Map<string, PluginRouteSearchValidator>; +} - return readEntry(searchEntry, pluginId, routeId, "searchEntry"); -}; +const isRecord = (value: unknown): value is Record<string, unknown> => + typeof value === "object" && value !== null && !Array.isArray(value); -/** - * A declared `parentId`, as the global id the built route carries. - * - * The namespacing is the enforcement: a plugin writes its own local id and this - * puts its own plugin's name in front of it, so there is no spelling of this - * field that reaches another plugin's route. Cross-plugin nesting is not - * forbidden by a check here - it is unrepresentable. - */ -const readParentId = ( - parentId: unknown, - pluginId: string, - routeId: string, -): null | string => { - if (parentId === undefined || parentId === null) return null; +const readPluginId = (source: unknown): string => { + const pluginId = isRecord(source) ? source.pluginId : undefined; - if (typeof parentId !== "string" || !SEGMENTED.test(parentId)) { + if (typeof pluginId !== "string" || !/^\S+$/.test(pluginId)) { throw new PluginRouteError( - `Plugin route "${routeId}" from ${pluginId} declares an invalid parentId ${JSON.stringify(parentId)}. A parentId is another route's own \`id\` from the same plugin - not a path, and not a "<plugin>:<route>" pair.`, - { code: "invalid-parent", pluginId, routeId }, + `A plugin registered routes without a plugin id (got ${JSON.stringify(pluginId)}).`, + { code: "invalid-plugin-id", pluginId: "" }, ); } - return pluginRouteId(pluginId, parentId); + return pluginId; }; -const readNamespaces = ( - namespaces: unknown, - pluginId: string, - routeId: string, -): string[] => { - if (namespaces === undefined) return []; - - if (!Array.isArray(namespaces)) { - throw new PluginRouteError( - `Plugin route "${routeId}" from ${pluginId} declares \`namespaces\` that is not an array.`, - { code: "invalid-namespace", pluginId, routeId }, - ); - } - - // `Array.from` rather than `map`: `map` skips holes in a sparse array, so an - // entry could reach normalisation without ever being checked. - const checked = Array.from(namespaces, (value: unknown, index) => { - const problem = namespaceProblem(value); - - if (problem) { - throw new PluginRouteError( - `Plugin route "${routeId}" from ${pluginId} declares namespaces[${index}] that ${problem}`, - { code: "invalid-namespace", pluginId, routeId }, - ); - } - - return value as string; - }); - - return normalizeNamespaceList(checked); -}; - -const readDefinition = ( - definition: unknown, - pluginId: string, - index: number, -): PluginRoute => { - if (!isRecord(definition)) { - throw new PluginRouteError( - `Plugin ${pluginId} declared a route at index ${index} that is not an object.`, - { code: "malformed-route", pluginId }, - ); - } - - // The only cast in the module. `routes` is typed, but a plugin is JavaScript - // by the time it is registered and its config is written by hand, so the - // fields are read defensively and the types are re-established here. - const { - area, - entry, - id, - kind, - namespaces, - parentId, - path, - requires, - searchEntry, - } = definition as Partial<PluginRouteDefinition>; - - if (typeof id !== "string" || !SEGMENTED.test(id)) { - throw new PluginRouteError( - `Plugin ${pluginId} declared a route at index ${index} with an invalid id ${JSON.stringify(id)} - use letters, digits, ".", "-" and "_".`, - { code: "invalid-id", pluginId }, - ); - } - - if (area !== undefined && !PLUGIN_ROUTE_AREAS.includes(area)) { - throw new PluginRouteError( - `Plugin route "${id}" from ${pluginId} declares the unknown area ${JSON.stringify(area)}. Known areas: ${PLUGIN_ROUTE_AREAS.join(", ")}.`, - { code: "invalid-area", pluginId, routeId: id }, - ); - } - - if (kind !== undefined && !PLUGIN_ROUTE_KINDS.includes(kind)) { - throw new PluginRouteError( - `Plugin route "${id}" from ${pluginId} declares the unknown kind ${JSON.stringify(kind)}. Known kinds: ${PLUGIN_ROUTE_KINDS.join(", ")}.`, - { code: "invalid-kind", pluginId, routeId: id }, - ); - } - - if ( - requires !== undefined && - requires !== null && - !PLUGIN_ROUTE_REQUIREMENTS.includes(requires) - ) { - throw new PluginRouteError( - `Plugin route "${id}" from ${pluginId} declares the unknown requirement ${JSON.stringify(requires)}. Known requirements: ${PLUGIN_ROUTE_REQUIREMENTS.join(", ")}.`, - { code: "invalid-requires", pluginId, routeId: id }, - ); - } - - // `requires` is about the public session; the AdminCP runs on a second one - // under its own cookie. An `admin` route already sits behind the shell's own - // guard, so `requires: "authenticated"` here is at best a restatement of - // something composition already provides - and `requires: "guest"` is a page - // no human being could reach, since the two guards would turn away everybody - // between them. - // - // Refused rather than ignored, because the field would read as enforcement - // while enforcing a different session's answer. The thing an author actually - // wants - "only staff with this permission" - is not this field in either - // area: it gates a page's *content*, through the same components the AdminCP's - // own screens use, and the API refuses the data regardless. - if ( - requires !== undefined && - requires !== null && - (area ?? "main") === "admin" - ) { - throw new PluginRouteError( - `Plugin route "${id}" from ${pluginId} is in the "admin" area and declares \`requires: ${JSON.stringify(requires)}\`. \`requires\` is about the public session, and the AdminCP has its own - a route in the admin area is already behind the AdminCP's session guard, so drop the field. To gate the page on a staff permission, gate its content inside the route module instead.`, - { code: "requires-in-admin-area", pluginId, routeId: id }, - ); - } - - if (typeof path !== "string") { - throw new PluginRouteError( - `Plugin route "${id}" from ${pluginId} declares no path (got ${JSON.stringify(path)}).`, - { code: "invalid-path", pluginId, routeId: id }, - ); - } - - const parsed = parseRoutePath(path); - - if (!parsed.ok) { - throw new PluginRouteError( - `Plugin route "${id}" from ${pluginId} has an invalid path: ${parsed.reason}.`, - { code: "invalid-path", path, pluginId, routeId: id }, - ); - } - - return { - area: area ?? "main", - entry: readEntry(entry, pluginId, id), - id: pluginRouteId(pluginId, id), - kind: kind ?? "page", - namespaces: readNamespaces(namespaces, pluginId, id), - parentId: readParentId(parentId, pluginId, id), - path: parsed.path, - pluginId, - requires: requires ?? null, - routeId: id, - searchEntry: readSearchEntry(searchEntry, kind ?? "page", pluginId, id), - segments: parsed.segments, - }; -}; +const builtRoute = (pluginId: string, flat: FlatPluginRoute): PluginRoute => ({ + area: flat.area, + id: pluginRouteId(pluginId, flat.routeId), + kind: flat.kind, + messages: flat.messages, + parentId: + flat.parentId === null ? null : pluginRouteId(pluginId, flat.parentId), + path: flat.path, + pluginId, + requires: flat.requires, + routeId: flat.routeId, + segments: flat.segments, +}); /** - * Every plugin route in an application, validated and deterministically ordered. + * Every configured plugin's route tree, flattened, validated and ordered. * - * Pure, and total in the only sense that matters: it either returns a manifest - * no framework can misread, or it throws a {@link PluginRouteError} naming the + * Pure, and total in the only sense that matters: it either returns routes no + * framework can misread, or it throws a {@link PluginRouteError} naming the * plugin, the route and - on a collision - both sides of it. There is no third * outcome where a route is quietly dropped, because a page that silently stops * existing is the failure mode this whole function is for. * - * Two kinds of check, in two places, because they answer different questions. - * Here: is each route legal on its own, and does any two of them claim one URL. - * In `buildPluginRouteGraph`, which this calls last: does the hierarchy they - * describe hold together. Keeping the second in the graph is what lets the - * runtime re-derive the tree from the generated manifest with the same function - * that validated it. + * Three kinds of check, in three places, because they answer different + * questions. `flattenPluginRoutes`: is each route legal on its own, and does the + * tree it was written in make sense. Here: does any two of them claim one URL. + * In `buildPluginRouteGraph`, which this calls last: does the hierarchy the + * flattened list describes hold together. * * Registration order affects nothing but which plugin an error message calls * "first". */ -export const buildPluginRouteManifest = ( - sources: PluginRouteSource[], -): PluginRouteManifest => { +export const compilePluginRouteTrees = ( + sources: readonly PluginRouteSource[], +): CompiledPluginRouteTrees => { const routes: PluginRoute[] = []; + const components = new Map<string, PluginRouteLazyComponent>(); + const searchValidators = new Map<string, PluginRouteSearchValidator>(); const byId = new Map<string, PluginRoute>(); const byPath = new Map<string, PluginRoute>(); for (const source of sources) { - const pluginId = isRecord(source) ? source.pluginId : undefined; - - if (typeof pluginId !== "string" || !/^\S+$/.test(pluginId)) { - throw new PluginRouteError( - `A plugin registered routes without a plugin id (got ${JSON.stringify(pluginId)}).`, - { code: "invalid-plugin-id", pluginId: "" }, - ); - } - - const declared = (source.routes ?? []) as unknown[]; - - if (!Array.isArray(declared)) { - throw new PluginRouteError( - `Plugin ${pluginId} declared \`routes\` that is not an array.`, - { code: "malformed-route", pluginId }, - ); - } + const pluginId = readPluginId(source); - declared.forEach((definition, index) => { - const route = readDefinition(definition, pluginId, index); + for (const flat of flattenPluginRoutes(pluginId, source.routes)) { + const route = builtRoute(pluginId, flat); const existingById = byId.get(route.id); if (existingById) { throw new PluginRouteError( - `Duplicate plugin route id "${route.id}": declared twice by ${pluginId}.`, + `Duplicate plugin route "${route.id}": ${pluginId} declares two ${route.kind}s at "${route.path}". Derived from the route's kind and its path, so two of them mean two routes claiming one URL - give one of them a different path.`, { code: "duplicate-id", conflictsWith: { @@ -350,10 +127,9 @@ export const buildPluginRouteManifest = ( // **`area` is deliberately not part of this key.** Both shells a host // mounts these under are *pathless* - `_main` and `_admin` contribute no // segment - so an area changes which frame draws the page and never which - // URL it answers. `main /reports` and `admin /reports` are therefore one - // URL claimed twice, and keying by area would have let the router's own - // ranking decide which of them a browser reaches. An admin route's - // `/admin` comes from its `path`, which the manifest spells out in full. + // URL it answers. `main /foo` and `admin /foo` are therefore one URL + // claimed twice, and keying by area would have let the router's own + // ranking decide which of them a browser reaches. // // Scoped by kind, and that is what nesting costs. A layout claims no URL, // so a layout at `/settings` and the index page inside it both spell @@ -365,7 +141,7 @@ export const buildPluginRouteManifest = ( if (existingByPath) { throw new PluginRouteError( - `Plugin route path collision on "${route.path}" (${route.area}): ${existingByPath.pluginId} already owns "${existingByPath.path}" as "${existingByPath.id}" (${existingByPath.area}), and ${pluginId} declares "${route.path}" as "${route.id}". Both match the same URLs - a shell is pathless, so an area frames a page rather than moving it - and VitNode will not let a router's ordering decide which one answers. Rename one of them.`, + `Plugin route path collision on "${route.path}" (${route.area}): ${existingByPath.pluginId} already owns "${existingByPath.path}" (${existingByPath.area}), and ${pluginId} declares "${route.path}". Both match the same URLs - a shell is pathless, so an area frames a page rather than moving it - and VitNode will not let a router's ordering decide which one answers. Give one of them a different path.`, { code: "duplicate-path", conflictsWith: { @@ -382,16 +158,24 @@ export const buildPluginRouteManifest = ( byId.set(route.id, route); byPath.set(pathKey, route); routes.push(route); - }); + components.set(route.id, flat.component); + + if (flat.search !== null) searchValidators.set(route.id, flat.search); + } } const manifest = routes.sort(comparePluginRoutes); - // Last, and for its exceptions rather than for its result: a manifest whose - // hierarchy does not hold together is not a manifest, and the build has to - // stop here rather than in a browser. The tree itself is rebuilt from this + // Last, and for its exceptions rather than for its result: a set of routes + // whose hierarchy does not hold together is not a manifest, and the build has + // to stop here rather than in a browser. The tree itself is rebuilt from this // list by whatever mounts it, with this same function. buildPluginRouteGraph(manifest); - return manifest; + return { components, manifest, searchValidators }; }; + +/** {@link compilePluginRouteTrees}, for a caller that only needs the data. */ +export const buildPluginRouteManifest = ( + sources: readonly PluginRouteSource[], +): PluginRouteManifest => compilePluginRouteTrees(sources).manifest; diff --git a/packages/vitnode/src/routing/module.test.ts b/packages/vitnode/src/routing/module.test.ts index dfc02aae0..58f344c6c 100644 --- a/packages/vitnode/src/routing/module.test.ts +++ b/packages/vitnode/src/routing/module.test.ts @@ -69,7 +69,7 @@ describe("readPluginRouteModule", () => { ).toThrow(/exports a `route` that is not an object/); }); - it.each(["breadcrumb", "head", "load", "parseSearch"])( + it.each(["head", "load", "parseSearch"])( "refuses a non-function `route.%s`", key => { expect(() => @@ -83,6 +83,30 @@ describe("readPluginRouteModule", () => { }, ); + /** + * `false` is the one non-function a `breadcrumb` may be: it is how a page says + * "leave me out of the trail" on purpose, rather than by saying nothing. + */ + it("keeps `breadcrumb: false`", () => { + const checked = readPluginRouteModule( + { default: Page, route: { breadcrumb: false } }, + "p:page", + ); + + expect(checked.route.breadcrumb).toBe(false); + }); + + it("refuses a `route.breadcrumb` that is neither a component nor false", () => { + expect(() => + readPluginRouteModule( + { default: Page, route: { breadcrumb: "nope" } }, + "p:page", + ), + ).toThrow( + /`route\.breadcrumb`, which must be a component or `false` \(got string\)/, + ); + }); + /** * A breadcrumb is a *component*, not an element - the label is translated and * on a dynamic route comes from the loader, so it has to be able to use hooks. @@ -95,7 +119,9 @@ describe("readPluginRouteModule", () => { { default: Page, route: { breadcrumb: { props: {}, type: "span" } } }, "p:page", ), - ).toThrow(/`route\.breadcrumb`, which must be a function \(got object\)/); + ).toThrow( + /`route\.breadcrumb`, which must be a component or `false` \(got object\)/, + ); }); it("does not carry unknown members of `route` through", () => { diff --git a/packages/vitnode/src/routing/module.ts b/packages/vitnode/src/routing/module.ts index f7f0aa40d..6f9a55d62 100644 --- a/packages/vitnode/src/routing/module.ts +++ b/packages/vitnode/src/routing/module.ts @@ -135,6 +135,45 @@ export interface PluginRouteHeadArgs<TData = unknown, TSearch = unknown> { search: TSearch; } +/** + * What a plugin route's breadcrumb component is handed. + * + * The same three names `load`, `head` and the page component receive, taken from + * the match that declared the crumb rather than from the deepest one - so a + * layout's crumb reads the layout's own loader data even while a page inside it + * is what the visitor is looking at. + * + * A crumb that only needs a translated string declares no props at all: + * `() => useTranslations("@acme/catalog")("breadcrumbs.products")` is assignable + * to a component type that supplies these and simply ignores them. + */ +export interface PluginRouteBreadcrumbProps< + TData = undefined, + TSearch = unknown, +> { + /** + * Exactly what this route's `load` returned, or `undefined` for a module that + * declares no loader. + * + * Optional in one case the type cannot express and the runtime can: the shell + * renders the trail for every *matched* route, and a match whose loader threw + * is still a match. A crumb on a route whose data may fail to resolve should + * read it defensively. + */ + loaderData: TData; + /** This route's own dynamic segments, e.g. `{ productId: "42" }`. */ + params: Readonly<Record<string, string>>; + /** + * This route's query string, validated the same way its page's is. + * + * `unknown` unless the crumb names the type, which most do not: a crumb that + * reads the search is rare, and defaulting to the empty record would make a + * crumb that ignores it *incompatible* with a route that has one - the props + * are checked contravariantly against what the route provides. + */ + search: TSearch; +} + /** * The behaviour a plugin route module may declare, and the whole of it. * @@ -143,29 +182,30 @@ export interface PluginRouteHeadArgs<TData = unknown, TSearch = unknown> { */ export interface PluginRouteOptions<TData = unknown, TSearch = unknown> { /** - * What this route contributes to the shell's breadcrumb area. + * This route's **one crumb** in the shell's breadcrumb trail. + * + * A **component**, not an element, for two reasons: the label is usually + * translated, so it has to be able to call `useTranslations` from `use-intl` + * through the message namespaces its route declared; and the runtime hands it + * this route's own {@link PluginRouteBreadcrumbProps} - the loader data, the + * params and the search of the match that declared it - which an element + * written in a route module could not be given. * - * A **component**, not an element, because the label is translated - so it has - * to be able to call `useTranslations` from `use-intl`, through the namespaces - * its route declared - and because the runtime is the only thing that can - * decide where in the shell to mount it. The deepest matched route that - * declares one wins, which is Stage 8's rule (`breadcrumbOf`) and therefore - * the same rule the host's own routes follow. + * Return the label and nothing else. VitNode owns the trail: the separators, + * the `nav`/`aria-current` semantics, and the locale-aware link to this + * route's own URL. A plugin never builds a router link for a crumb, and never + * restates its layouts' crumbs - every matched route contributes its own, in + * parent-to-child order. * - * **It is handed no props.** No `loaderData`, no `params`, no `search`: a - * crumb is rendered from the route's own module and its namespaces, and - * nothing else. That is enough for a translated, route-owned label, which is - * what every crumb in this repository is; it is not enough for "the article's - * own title", and this contract does not pretend otherwise. A plugin route - * module is framework-neutral and so cannot reach for `useLoaderData` to close - * the gap itself. Giving a crumb its route's data is a future extension - a - * typed `PluginRouteBreadcrumbProps<TData, TSearch>` - not something to work - * around here. + * `false` omits this route from the trail explicitly, which is what a page + * whose parent layout already names the screen wants. Declaring nothing does + * the same thing; `false` is for saying so on purpose. * * There is deliberately no pathname-to-label registry anywhere: a route * declares its own crumb, next to its own component. */ - breadcrumb?: React.FunctionComponent; + breadcrumb?: + false | React.ComponentType<PluginRouteBreadcrumbProps<TData, TSearch>>; /** * The page's title, description and robots directive - translated, and * usually read off `loaderData` so the `<title>` and the `<h1>` are the same @@ -207,7 +247,10 @@ export interface PluginRouteOptions<TData = unknown, TSearch = unknown> { * which is *before* any chunk is fetched, and a plugin's module is lazy. The * router matches the route without it; this runs in the loader, once the * module has arrived. So the raw query string is what the router sees, and - * this is what everything downstream of the module sees. + * this is what everything downstream of the module sees. For a screen whose + * URL *is* its state, declare `search` on the route in `routes.ts` instead - + * that one the router does see, and it is the one field of a route that is + * deliberately eager. * * **Must be total.** It normalises, it does not reject - a hand-edited or * pasted query string should render the page it would have rendered anyway, @@ -271,10 +314,10 @@ export interface PluginRoutePageProps< * `resetScroll` defaults to the router's own behaviour; pass `false` for a * table whose page should not jump to the top when the page number changes. * - * Pairs with {@link PluginRouteDefinition.searchEntry}: a route with an eager - * search schema gets a validated {@link PluginRoutePageProps.search} in and a - * typed one out, which together are what make the query string usable as - * state rather than as a string. + * Pairs with a route's eager `search` schema: a route that declares one gets a + * validated {@link PluginRoutePageProps.search} in and a typed one out, which + * together are what make the query string usable as state rather than as a + * string. */ navigate: (options: { resetScroll?: boolean; @@ -285,14 +328,14 @@ export interface PluginRoutePageProps< /** * The route's query string, validated. * - * Whatever the route's eager `validateSearch` returned when its manifest entry - * declares a `searchEntry`, and whatever the module's own `parseSearch` - * returned otherwise. Never raw query parameters in either case. + * Whatever the route's eager `search` schema returned when its declaration + * has one, and whatever this module's own `parseSearch` returned otherwise. + * Never raw query parameters in either case. */ search: TSearch; } -/** A plugin route module that renders a page - `kind: "page"`. */ +/** A plugin route module that renders a page - `page()` or `index()`. */ export interface PluginRoutePageModule<TData = unknown, TSearch = unknown> { /** * The page. @@ -309,7 +352,7 @@ export interface PluginRoutePageModule<TData = unknown, TSearch = unknown> { route?: PluginRouteOptions<TData, TSearch>; } -/** A plugin route module that renders a frame - `kind: "layout"`. */ +/** A plugin route module that renders a frame - `layout()`. */ export interface PluginRouteLayoutModule<TData = unknown, TSearch = unknown> { /** * The frame, which renders its children where they belong. @@ -348,7 +391,7 @@ export type PluginRouteModule<TData = unknown, TSearch = unknown> = * is what checked that they line up. */ export interface CheckedPluginRouteOptions { - breadcrumb?: React.FunctionComponent; + breadcrumb?: false | React.ComponentType<PluginRouteBreadcrumbProps<unknown>>; head?: (args: PluginRouteHeadArgs) => PluginRouteHead; load?: (args: PluginRouteLoadArgs) => unknown; parseSearch?: (input: unknown) => unknown; @@ -436,9 +479,16 @@ export const readPluginRouteModule = ( if (value === undefined) continue; + if (key === "breadcrumb" && value === false) { + options[key] = false; + continue; + } + if (typeof value !== "function") { return fail( - `declares \`route.${key}\`, which must be a function (got ${typeof value}).`, + 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}).`, ); } diff --git a/packages/vitnode/src/routing/tree.test.ts b/packages/vitnode/src/routing/tree.test.ts new file mode 100644 index 000000000..2032fec14 --- /dev/null +++ b/packages/vitnode/src/routing/tree.test.ts @@ -0,0 +1,550 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import type { PluginRouteDeclaration } from "./tree"; + +import { PluginRouteError } from "./errors"; +import { flattenPluginRoutes, pluginRouteIdFor } from "./flatten"; +import { + definePluginRoutes, + index, + isPluginRouteLazyComponent, + layout, + lazy, + page, +} from "./tree"; + +/** + * A plugin's route tree, flattened - which is the whole of what an author's + * `routes.ts` has to survive before anything else in VitNode sees it. + * + * Every test here goes through the real helpers rather than building a + * declaration by hand, because the helpers are the API: what `page()`, + * `layout()` and `index()` put on a node is exactly what the flattener is + * allowed to read, and a test that wrote the node itself would pass while the + * helpers were broken. + */ +const lazyPage = () => + lazy(async () => await Promise.resolve({ default: () => null })); + +const flatten = (...routes: PluginRouteDeclaration[]) => + flattenPluginRoutes("@acme/catalog", definePluginRoutes(routes)); + +/** + * A declaration with a field the types do not allow. + * + * Which is the only way to test the diagnostics that exist for a plugin written + * in JavaScript: `search` on a layout and `area` on a nested route are both + * compile errors, and both still have to fail loudly rather than be dropped. + */ +const withExtra = <TOptions>( + options: TOptions, + extra: Record<string, unknown>, +): TOptions => ({ ...options, ...extra }); + +const thrownBy = (build: () => unknown): PluginRouteError => { + try { + build(); + } catch (error) { + if (error instanceof PluginRouteError) return error; + + throw error; + } + + throw new Error("expected a PluginRouteError"); +}; + +describe("lazy", () => { + it("does not call the import until somebody asks for the module", async () => { + let called = 0; + const component = lazy(async () => { + called += 1; + + return await Promise.resolve({ default: () => null }); + }); + + expect(called).toBe(0); + expect(isPluginRouteLazyComponent(component)).toBe(true); + + await component.load(); + + expect(called).toBe(1); + }); + + it("is not satisfied by a bare function", () => { + expect(isPluginRouteLazyComponent(() => null)).toBe(false); + expect(isPluginRouteLazyComponent({ load: () => null })).toBe(false); + }); +}); + +describe("a simple page", () => { + it("is one route at the path it declared", () => { + expect(flatten(page("/catalog", { component: lazyPage() }))).toMatchObject([ + { + area: "main", + kind: "page", + messages: [], + parentId: null, + path: "/catalog", + requires: null, + routeId: "page#/catalog", + search: null, + }, + ]); + }); + + it("carries the component it declared, unwrapped", async () => { + const component = lazyPage(); + const [route] = flatten(page("/catalog", { component })); + + expect(route.component).toBe(component); + await expect(route.component.load()).resolves.toHaveProperty("default"); + }); +}); + +describe("a nested tree", () => { + const tree = () => + flatten( + layout("/catalog", { + component: lazyPage(), + messages: ["@acme/catalog"], + children: [ + page("dashboard", { component: lazyPage() }), + layout("products", { + component: lazyPage(), + children: [ + index({ component: lazyPage() }), + layout(":categorySlug", { + component: lazyPage(), + children: [ + index({ component: lazyPage() }), + page(":productId", { component: lazyPage() }), + ], + }), + ], + }), + ], + }), + ); + + it("flattens parents before children", () => { + expect(tree().map(route => route.routeId)).toEqual([ + "layout#/catalog", + "page#/catalog/dashboard", + "layout#/catalog/products", + "page#/catalog/products", + "layout#/catalog/products/:categorySlug", + "page#/catalog/products/:categorySlug", + "page#/catalog/products/:categorySlug/:productId", + ]); + }); + + it("joins every relative path onto its parent's", () => { + expect(tree().map(route => route.path)).toEqual([ + "/catalog", + "/catalog/dashboard", + "/catalog/products", + "/catalog/products", + "/catalog/products/:categorySlug", + "/catalog/products/:categorySlug", + "/catalog/products/:categorySlug/:productId", + ]); + }); + + it("makes an index route claim exactly its layout's URL", () => { + const routes = tree(); + const products = routes.find( + route => route.routeId === "layout#/catalog/products", + ); + const indexRoute = routes.find( + route => route.routeId === "page#/catalog/products", + ); + + expect(indexRoute?.path).toBe(products?.path); + expect(indexRoute?.parentId).toBe("layout#/catalog/products"); + }); + + it("points each child at the layout it was written inside", () => { + expect(tree().map(route => [route.routeId, route.parentId])).toEqual([ + ["layout#/catalog", null], + ["page#/catalog/dashboard", "layout#/catalog"], + ["layout#/catalog/products", "layout#/catalog"], + ["page#/catalog/products", "layout#/catalog/products"], + ["layout#/catalog/products/:categorySlug", "layout#/catalog/products"], + [ + "page#/catalog/products/:categorySlug", + "layout#/catalog/products/:categorySlug", + ], + [ + "page#/catalog/products/:categorySlug/:productId", + "layout#/catalog/products/:categorySlug", + ], + ]); + }); + + it("parses a dynamic segment into a parameter", () => { + const product = tree().at(-1); + + expect(product?.segments).toEqual([ + { kind: "static", value: "catalog" }, + { kind: "static", value: "products" }, + { kind: "param", name: "categorySlug" }, + { kind: "param", name: "productId" }, + ]); + }); +}); + +describe("derived route ids", () => { + it("are the route's kind and its full path", () => { + expect(pluginRouteIdFor("page", "/catalog/dashboard")).toBe( + "page#/catalog/dashboard", + ); + expect(pluginRouteIdFor("layout", "/catalog")).toBe("layout#/catalog"); + }); + + it("tell a layout from the index page inside it", () => { + const routes = flatten( + layout("/catalog", { + component: lazyPage(), + children: [index({ component: lazyPage() })], + }), + ); + + expect(routes.map(route => route.routeId)).toEqual([ + "layout#/catalog", + "page#/catalog", + ]); + }); + + it("cannot be declared by a plugin", () => { + // The types have no `id`, and the flattened route's is derived - so a + // declaration that tried to carry one is ignored rather than honoured. + const [route] = flatten( + page("/catalog", withExtra({ component: lazyPage() }, { id: "mine" })), + ); + + expect(route.routeId).toBe("page#/catalog"); + }); +}); + +describe("paths", () => { + it("requires a top-level path to be absolute", () => { + const error = thrownBy(() => + flatten(page("catalog", { component: lazyPage() })), + ); + + expect(error.code).toBe("invalid-path"); + expect(error.message).toContain('write "/catalog"'); + }); + + it("requires a nested path to be relative", () => { + const error = thrownBy(() => + flatten( + layout("/catalog", { + component: lazyPage(), + children: [page("/catalog/dashboard", { component: lazyPage() })], + }), + ), + ); + + expect(error.code).toBe("invalid-path"); + expect(error.message).toContain('write "catalog/dashboard"'); + }); + + it("refuses a path VitNode does not represent", () => { + expect( + thrownBy(() => flatten(page("/Catalog", { component: lazyPage() }))).code, + ).toBe("invalid-path"); + expect( + thrownBy(() => flatten(page("/catalog/$id", { component: lazyPage() }))) + .message, + ).toContain('write ":id"'); + expect( + thrownBy(() => flatten(page("/catalog/[id]", { component: lazyPage() }))) + .message, + ).toContain('write ":id"'); + }); +}); + +describe("the shape of a tree", () => { + it("refuses a layout with no children", () => { + const error = thrownBy(() => + flatten(layout("/catalog", { component: lazyPage(), children: [] })), + ); + + expect(error.code).toBe("childless-layout"); + expect(error.message).toContain("index()"); + }); + + it("refuses an index route with no layout around it", () => { + const error = thrownBy(() => flatten(index({ component: lazyPage() }))); + + expect(error.code).toBe("invalid-tree"); + expect(error.message).toContain("top level"); + }); + + it("refuses a node that did not come from page(), layout() or index()", () => { + const error = thrownBy(() => + flattenPluginRoutes("@acme/catalog", [ + { component: lazyPage(), path: "/catalog" }, + ]), + ); + + expect(error.code).toBe("invalid-tree"); + expect(error.message).toContain("page(), layout() or index()"); + }); + + it("refuses routes that are not an array", () => { + expect( + thrownBy(() => flattenPluginRoutes("@acme/catalog", { nope: true })).code, + ).toBe("malformed-route"); + }); + + it("is empty when a plugin declares nothing", () => { + expect(flattenPluginRoutes("@acme/catalog", undefined)).toEqual([]); + expect(flatten()).toEqual([]); + }); + + it("refuses a hand-written array through definePluginRoutes too", () => { + expect(() => + definePluginRoutes([ + { component: lazyPage(), path: "/catalog" }, + ] as unknown as PluginRouteDeclaration[]), + ).toThrow(/page\(\), layout\(\) or index\(\)/); + }); +}); + +describe("components", () => { + it("refuses a component that is not lazy", () => { + const error = thrownBy(() => + flatten( + page("/catalog", { + component: (() => null) as unknown as ReturnType<typeof lazyPage>, + }), + ), + ); + + expect(error.code).toBe("eager-component"); + expect(error.message).toContain("initial bundle"); + expect(error.message).toContain('lazy(() => import("./pages/my-page"))'); + }); + + it("refuses a plain module object, which is the other way to be eager", () => { + const error = thrownBy(() => + flatten( + page("/catalog", { + component: { default: () => null } as unknown as ReturnType< + typeof lazyPage + >, + }), + ), + ); + + expect(error.code).toBe("eager-component"); + }); +}); + +describe("the eager search schema", () => { + it("is carried through for a page", () => { + const search = () => ({ page: 1 }); + const [route] = flatten( + page("/catalog", { component: lazyPage(), search }), + ); + + expect(route.search).toBe(search); + }); + + it("is carried through for an index route", () => { + const search = () => ({ page: 1 }); + const routes = flatten( + layout("/catalog", { + component: lazyPage(), + children: [index({ component: lazyPage(), search })], + }), + ); + + expect(routes[1].search).toBe(search); + }); + + it("is refused on a layout, which claims no URL of its own", () => { + const error = thrownBy(() => + flatten( + layout( + "/catalog", + withExtra( + { + component: lazyPage(), + children: [index({ component: lazyPage() })], + }, + { search: () => ({}) }, + ), + ), + ), + ); + + expect(error.code).toBe("invalid-search"); + expect(error.message).toContain("claims no URL of its own"); + }); + + it("is refused when it is not a function", () => { + expect( + thrownBy(() => + flatten( + page( + "/catalog", + withExtra({ component: lazyPage() }, { search: { page: 1 } }), + ), + ), + ).code, + ).toBe("invalid-search"); + }); +}); + +describe("messages", () => { + it("are de-duplicated and sorted", () => { + const [route] = flatten( + page("/catalog", { + component: lazyPage(), + messages: ["@acme/catalog.b", "@acme/catalog.a", "@acme/catalog.b"], + }), + ); + + expect(route.messages).toEqual(["@acme/catalog.a", "@acme/catalog.b"]); + }); + + it("stay on the route that declared them", () => { + const routes = flatten( + layout("/catalog", { + component: lazyPage(), + messages: ["@acme/catalog"], + children: [index({ component: lazyPage() })], + }), + ); + + expect(routes.map(route => route.messages)).toEqual([ + ["@acme/catalog"], + [], + ]); + }); + + it("refuse a namespace VitNode cannot warm", () => { + expect( + thrownBy(() => + flatten( + page("/catalog", { component: lazyPage(), messages: ["", "x"] }), + ), + ).code, + ).toBe("invalid-namespace"); + expect( + thrownBy(() => + flatten( + page( + "/catalog", + withExtra({ component: lazyPage() }, { messages: "nope" }), + ), + ), + ).code, + ).toBe("invalid-namespace"); + }); +}); + +describe("the area", () => { + it("defaults to the public site", () => { + expect(flatten(page("/catalog", { component: lazyPage() }))[0].area).toBe( + "main", + ); + }); + + it("is inherited by every route inside a layout", () => { + const routes = flatten( + layout("/admin/catalog", { + area: "admin", + component: lazyPage(), + children: [ + index({ component: lazyPage() }), + page("products", { component: lazyPage() }), + ], + }), + ); + + expect(routes.map(route => route.area)).toEqual([ + "admin", + "admin", + "admin", + ]); + }); + + it("may not be declared by a nested route", () => { + const error = thrownBy(() => + flatten( + layout("/catalog", { + component: lazyPage(), + children: [ + index(withExtra({ component: lazyPage() }, { area: "admin" })), + ], + }), + ), + ); + + expect(error.code).toBe("invalid-area"); + expect(error.message).toContain("Only a top-level route chooses its shell"); + }); + + it("refuses an area VitNode does not have", () => { + expect( + thrownBy(() => + flatten( + page( + "/catalog", + withExtra({ component: lazyPage() }, { area: "blank" }), + ), + ), + ).code, + ).toBe("invalid-area"); + }); + + it("does not prefix the path it frames", () => { + const [route] = flatten( + page("/admin/catalog", { area: "admin", component: lazyPage() }), + ); + + expect(route.path).toBe("/admin/catalog"); + }); +}); + +describe("requires", () => { + it("is carried through on the public site", () => { + const [route] = flatten( + page("/catalog", { component: lazyPage(), requires: "authenticated" }), + ); + + expect(route.requires).toBe("authenticated"); + }); + + it("is refused in the AdminCP, which has its own session", () => { + const error = thrownBy(() => + flatten( + page("/admin/catalog", { + area: "admin", + component: lazyPage(), + requires: "authenticated", + }), + ), + ); + + expect(error.code).toBe("requires-in-admin-area"); + }); + + it("refuses a requirement VitNode does not have", () => { + expect( + thrownBy(() => + flatten( + page( + "/catalog", + withExtra({ component: lazyPage() }, { requires: "staff" }), + ), + ), + ).code, + ).toBe("invalid-requires"); + }); +}); diff --git a/packages/vitnode/src/routing/tree.ts b/packages/vitnode/src/routing/tree.ts new file mode 100644 index 000000000..9d163cfcb --- /dev/null +++ b/packages/vitnode/src/routing/tree.ts @@ -0,0 +1,240 @@ +import type { + PluginRouteArea, + PluginRouteKind, + PluginRouteRequirement, +} from "./types"; + +const LAZY_BRAND = Symbol.for("vitnode.plugin-routes.lazy"); +const DECLARATION_BRAND = Symbol.for("vitnode.plugin-routes.declaration"); + +export interface PluginRouteLazyComponent<TModule = unknown> { + readonly [LAZY_BRAND]: true; + readonly load: () => Promise<TModule>; +} + +export const lazy = <TModule>( + load: () => Promise<TModule>, +): PluginRouteLazyComponent<TModule> => ({ + [LAZY_BRAND]: true, + load, +}); + +export const isPluginRouteLazyComponent = ( + value: unknown, +): value is PluginRouteLazyComponent => { + if (typeof value !== "object" || value === null) return false; + + const candidate = value as Partial<PluginRouteLazyComponent>; + + return candidate[LAZY_BRAND] === true && typeof candidate.load === "function"; +}; + +/** + * The `search` type a route's own page module says it reads. + * + * The one place the eager half of a route and its lazy module are checked + * against each other, and it works for the reason `lazy()` is worth having: + * `import("./pages/products-page")` is a *type* TypeScript resolves statically + * even though the import itself is deferred. So a page declaring + * `PluginRoutePageProps<Product, ProductsSearch>` constrains the schema its + * route declares, without either file importing the other's values. + * + * `Record<string, unknown>` when the module says nothing about a search - a page + * with no props, or one that never named the type - because an unconstrained + * schema is the honest answer there rather than a guess. The page still gets + * whatever the schema returned; nothing is checked twice at runtime. + */ +type ModuleSearchOf<TModule> = TModule extends { + default: (props: infer TProps) => unknown; +} + ? TProps extends { search: infer TSearch } + ? TSearch + : Record<string, unknown> + : Record<string, unknown>; + +export type PluginRouteSearchSchema<TModule> = ( + input: Record<string, unknown>, +) => ModuleSearchOf<TModule>; + +interface PluginRouteDeclarationShared<TModule> { + /** + * The module this route renders, named by `lazy(() => import(...))`. + * + * The union's second member is a string, and it is the diagnostic rather than + * a shape: TypeScript prints the type it expected, so writing the fix *as* + * that type is what turns `component: ProductPage` from + * + * Type '() => Element' is not assignable to type + * 'PluginRouteLazyComponent<unknown>' + * + * into an error that says what to do instead. Inlined rather than aliased for + * the same reason: an alias would be printed by name, and the message would go + * back to naming a shape. + */ + component: + | '`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[]; + requires?: PluginRouteRequirement; +} + +/** + * What a `component` may not be - the message above, as a type. + * + * Derived from the field rather than declared beside it, so there is one copy of + * the sentence and a reader who searches for it lands on the union that produces + * it. + */ +export type PluginRouteEagerComponentRejected = Exclude< + PluginRouteDeclarationShared<unknown>["component"], + object +>; + +/** Either spelling of a `component` field: the lazy one, or the rejection. */ +export type PluginRouteComponent<TModule = unknown> = + PluginRouteDeclarationShared<TModule>["component"]; + +export interface PluginRoutePageOptions< + TModule, +> extends PluginRouteDeclarationShared<TModule> { + area?: PluginRouteArea; + search?: PluginRouteSearchSchema<TModule>; +} + +export interface PluginRouteIndexOptions< + TModule, +> extends PluginRouteDeclarationShared<TModule> { + search?: PluginRouteSearchSchema<TModule>; +} + +export interface PluginRouteLayoutOptions< + TModule, +> extends PluginRouteDeclarationShared<TModule> { + area?: PluginRouteArea; + children: readonly PluginRouteDeclaration[]; +} + +export interface PluginRouteDeclaration { + readonly area: PluginRouteArea | undefined; + readonly children: readonly PluginRouteDeclaration[] | undefined; + readonly component: unknown; + readonly [DECLARATION_BRAND]: true; + readonly isIndex: boolean; + readonly kind: PluginRouteKind; + readonly messages: readonly string[] | undefined; + readonly path: null | string; + readonly requires: PluginRouteRequirement | undefined; + readonly search: unknown; +} + +export type PluginRoutes = readonly PluginRouteDeclaration[]; + +/** + * One plugin's `routes.ts`, as a host holds it. + * + * The shape of every entry in a generated `src/plugin-routes.gen.ts`: the plugin + * id, and the route tree that plugin's own module exported. `routes` is typed + * here rather than left `unknown` so a plugin whose `routes` export is not a + * `definePluginRoutes` tree is a compile error in the generated file, naming the + * plugin, instead of a runtime diagnostic in a browser. + */ +export interface PluginRouteDeclarationSource { + pluginId: string; + routes: PluginRoutes; +} + +export const isPluginRouteDeclaration = ( + value: unknown, +): value is PluginRouteDeclaration => + typeof value === "object" && + value !== null && + (value as Partial<PluginRouteDeclaration>)[DECLARATION_BRAND] === true; + +/** + * Every field a declaration may carry, whichever helper made it. + * + * The three helpers accept narrower types - a layout has `children` and no + * `search`, an index route has neither a `path` nor an `area` - and all three + * pass whatever they were given through this. That is deliberate: a plugin + * written in JavaScript has no types to stop it declaring `search` on a layout, + * and a field silently dropped here would be a page whose query string is + * quietly never validated. Carried, it reaches `flattenPluginRoutes` and becomes + * a diagnostic naming the route. + */ +interface PluginRouteDeclarationOptions { + area?: PluginRouteArea; + children?: readonly PluginRouteDeclaration[]; + component: unknown; + messages?: readonly string[]; + requires?: PluginRouteRequirement; + search?: unknown; +} + +const declaration = ( + options: PluginRouteDeclarationOptions & { + isIndex: boolean; + kind: PluginRouteKind; + path: null | string; + }, +): PluginRouteDeclaration => ({ + [DECLARATION_BRAND]: true, + area: options.area, + children: options.children, + component: options.component, + isIndex: options.isIndex, + kind: options.kind, + messages: options.messages, + path: options.path, + requires: options.requires, + search: options.search, +}); + +export const page = <TModule>( + path: string, + options: PluginRoutePageOptions<TModule>, +): PluginRouteDeclaration => + declaration({ + ...(options as PluginRouteDeclarationOptions), + isIndex: false, + kind: "page", + path, + }); + +export const index = <TModule>( + options: PluginRouteIndexOptions<TModule>, +): PluginRouteDeclaration => + declaration({ + ...(options as PluginRouteDeclarationOptions), + isIndex: true, + kind: "page", + path: null, + }); + +export const layout = <TModule>( + path: string, + options: PluginRouteLayoutOptions<TModule>, +): PluginRouteDeclaration => + declaration({ + ...(options as PluginRouteDeclarationOptions), + isIndex: false, + kind: "layout", + path, + }); + +export const definePluginRoutes = (routes: PluginRoutes): PluginRoutes => { + if (!Array.isArray(routes)) { + throw new Error( + "[VitNode plugin routes] definePluginRoutes takes an array of routes built with page(), layout() or index().", + ); + } + + routes.forEach((route: unknown, position) => { + if (isPluginRouteDeclaration(route)) return; + + throw new Error( + `[VitNode plugin routes] definePluginRoutes received something at position ${String(position)} that was not built with page(), layout() or index(). Wrap every route in one of those - a plain object cannot carry the kind of route it is.`, + ); + }); + + return routes; +}; diff --git a/packages/vitnode/src/routing/types.ts b/packages/vitnode/src/routing/types.ts index 52f0f03b4..e9f5af597 100644 --- a/packages/vitnode/src/routing/types.ts +++ b/packages/vitnode/src/routing/types.ts @@ -35,6 +35,10 @@ * * Keeping the list *here* rather than letting every route invent its own string * is the point: an area is a statement about layout, and a layout is a parent. + * + * Declared by a **top-level** route only. Every route inside a layout renders in + * the shell its layout renders in, so a nested route that declared one would be + * describing something it cannot change. */ export type PluginRouteArea = "admin" | "main"; @@ -58,12 +62,12 @@ export const PLUGIN_ROUTE_AREAS: PluginRouteArea[] = ["admin", "main"]; * siblings under it, and rebuilding that as four independent pages means four * copies of the frame and four chances for them to drift. * - * - `page` claims a URL and renders it. The default, and what every route the - * prototype could describe is. - * - `layout` claims **no URL of its own**. It renders a frame around its - * children and is only ever reached through one of them, which is why two - * layouts may not sit at the same path and why a layout with no children is - * rejected: it would be a route nothing can ever match. + * - `page` claims a URL and renders it - `page()` and `index()`, which is that + * layout's page at its own URL. + * - `layout` claims **no URL of its own** - `layout()`. It renders a frame + * around its `children` and is only ever reached through one of them, which is + * why two layouts may not sit at the same path and why a layout with no + * children is rejected: it would be a route nothing can ever match. * * A third kind for a *pathless* group - Next's `(group)` folders - is * deliberately absent. No plugin has ever needed one, and a layout that adds no @@ -116,10 +120,9 @@ export const PLUGIN_ROUTE_REQUIREMENTS: PluginRouteRequirement[] = [ /** * Separates a plugin id from a route id. Not legal inside either half. * - * The same separator `framework/plugin-routes` keys its generated module - * registry by, so a manifest entry's `id` *is* the key that registry is looked - * up with. Two layers, one identifier, and nothing has to translate between - * them. + * A route's `id` is the key its component loader and its search schema are + * registered under, so one identifier addresses a route everywhere and nothing + * has to translate between layers. */ export const PLUGIN_ROUTE_ID_SEPARATOR = ":"; @@ -128,160 +131,37 @@ export type PluginRouteSegment = { kind: "param"; name: string } | { kind: "static"; value: string }; /** - * A page route contributed by a plugin, as the plugin declares it. - * - * Eight fields, five of which are optional and default to "the simple case", so - * the prototype's declaration still says exactly what it used to: - * - * { entry: "routes/example-page", id: "example-page", path: "/example" } + * One route's `validateSearch`, as the router will call it. * - * Every field here is **data**: serialisable, deterministic, meaningful in a - * Node process with no framework loaded, and free of React and TanStack Router. - * That is not an aesthetic rule - this list is read while an app builds, and - * frozen into a generated literal that a browser imports. A `loader`, a `component`, a `beforeLoad` or a `head` - * is executable behaviour and belongs in the route *module*, which is fetched - * as its own chunk; see `./module` for that half of the contract. - * - * What earns a place here is a question the runtime has to answer **before** it - * can fetch that module: which URL is this (`path`), what shape is it in the - * tree (`kind`, `parentId`), which strings must be in flight alongside its chunk - * rather than a round trip after it (`namespaces`), and may this visitor be sent - * here at all (`requires`). + * Total by contract. TanStack calls this during path matching, on whatever was + * in the query string, and a throw there is a router error screen rather than a + * page - so a validator normalises and clamps, it does not reject. */ -export interface PluginRouteDefinition { - /** - * Which shell frames this page. Defaults to `"main"`. - * - * It chooses a parent, never a prefix: an `admin` route still writes its full - * `/admin/…` path below. See {@link PluginRouteArea}. - */ - area?: PluginRouteArea; - /** - * Package export subpath of the module that renders this route, e.g. - * `"routes/example-page"`, imported as - * `"@vitnode/example/routes/example-page"`. - * - * A subpath rather than a full specifier, because the plugin id is already on - * the record; a subpath rather than a file path, so a plugin can move the - * implementation inside its `dist` without breaking every app that installs - * it; extensionless, because the plugin's export map adds the extension. - */ - entry: string; - /** - * Stable identifier, unique within the plugin. It survives a path change - - * that is what makes it worth having - so name it after the page, not the URL. - */ - id: string; - /** Defaults to `"page"`. See {@link PluginRouteKind}. */ - kind?: PluginRouteKind; - /** - * The message namespaces this route renders, warmed before it does. - * - * Here rather than in the module because of a waterfall that is otherwise - * unavoidable: a route's messages and a route's code are two network fetches, - * and if the list of namespaces is *inside* the code they can only happen one - * after the other. Declared here, the runtime starts both at once. - * - * Name what the page renders, not what the plugin has. The root provides - * `core.global` and nothing else deliberately - the merged message tree holds - * every plugin's copy, and a page should ship only the branches it uses. - */ - namespaces?: string[]; - /** - * The `id` of another route **from the same plugin** that this one renders - * inside, if any. - * - * Plugin-local by construction, which is how cross-plugin parenting is made - * unrepresentable rather than merely forbidden: there is nowhere in this - * string to put another plugin's name. A plugin cannot reach into another - * plugin's frame, and no plugin's route tree depends on which plugins happen - * to be installed beside it. - * - * The parent must be a `layout`, and this route's `path` must be the parent's - * path or extend it - a child that claimed an unrelated URL would be a - * manifest that lies about where its pages are. A child whose path is - * *exactly* the parent's is that layout's index route: `/settings` under the - * `/settings` layout, which is `page.tsx` beside `layout.tsx`. - */ - parentId?: string; - /** - * Canonical VitNode path: `/blog`, `/blog/:slug`, `/blog/:slug/comments`. - * - * Neither Next's `[slug]` nor TanStack's `$slug`. See `./path` for the - * conversions, and for the shapes this layer rejects rather than guesses at. - * - * Always the **full** public path, including a parent layout's segments, even - * for a nested route. A relative fragment would make a manifest unreadable - * without walking the graph, and would make a collision impossible to see in - * a diff. - */ - path: string; - /** Who this route is offered to. See {@link PluginRouteRequirement}. */ - requires?: PluginRouteRequirement; - /** - * Package export subpath of a module exporting this route's `validateSearch`, - * e.g. `"routes/admin-staff.search"` - the one piece of a route's behaviour - * that may not be lazy. - * - * ## Why this is a second entry rather than a field in the module - * - * A router's `validateSearch` runs during **path matching**, which is before - * any chunk is fetched. A route whose whole module is lazy therefore cannot - * have one, and that is why {@link PluginRoutePageModule.parseSearch} exists - * and is careful to say it is not a URL schema: it runs in the loader, it - * normalises rather than validates, and the router's own search type for the - * route stays untouched. - * - * `parseSearch` is the right answer for most pages. It is not the right answer - * for a screen whose URL *is* its state - a paginated table where `?page=999` - * has to be clamped and redirected to the last real page, a filter whose links - * must be typed. Those need the router to know the shape before it matches. - * - * So a route may name a second module, and the build imports it **eagerly**, - * as a literal static import in the generated registry. That is the whole cost - * and it is deliberately visible: a route that declares one puts a module in - * the initial bundle. Which is why the contract on it is narrow - - * - * - it exports `validateSearch`, and nothing that renders; - * - it must not import React, a component, or the page it belongs to; - * - it must be **total** in the same sense `parseSearch` is: a hand-typed - * query string is normalised, never thrown on. - * - * Keep it small. It is the schema, not the screen. - * - * A route that declares one gets a real router-level `validateSearch`, a - * `loaderDeps` derived from it, and typed `search` and `navigate` handed to - * its component - while its page stays in its own chunk exactly as before. - */ - searchEntry?: string; -} +export type PluginRouteSearchValidator = ( + input: Record<string, unknown>, +) => unknown; /** * One route in a built manifest: validated, normalised and parsed. * - * Every optional field of a {@link PluginRouteDefinition} is present here, with - * its default filled in - so nothing downstream re-implements "and if it is - * missing, it means". The generated manifest is a literal of exactly this shape, - * checked with `satisfies`, which is what makes a generator that forgets a field - * a compile error rather than a route that silently loses its parent. + * Every optional field of a declaration is present here with its default filled + * in, so nothing downstream re-implements "and if it is missing, it means", and + * every path is the full canonical one even for a route whose author wrote it + * relative to its parent. */ export interface PluginRoute { area: PluginRouteArea; - entry: string; - /** - * Globally unique, `"<pluginId>:<routeId>"` - and the key - * `framework/plugin-routes` registers the route's module loader under. - */ + /** Globally unique, `"<pluginId>:<routeId>"`. */ id: string; kind: PluginRouteKind; - /** Declared namespaces, de-duplicated and sorted. Empty if none. */ - namespaces: string[]; + /** Declared message namespaces, de-duplicated and sorted. Empty if none. */ + messages: string[]; /** - * The **global** id of this route's parent, or `null`. + * The **global** id of the layout this route is nested inside, or `null`. * - * Namespaced on the way in, so the runtime looks a parent up by the same id - * everything else addresses a route by, while a plugin still declares only its - * own local one. + * Namespaced by plugin, exactly like {@link PluginRoute.id}, so a parent is + * looked up by the id everything else addresses a route by - and so there is + * no spelling of it that reaches another plugin's layout. */ parentId: null | string; /** Canonical path, normalised (no trailing slash). */ @@ -289,13 +169,11 @@ export interface PluginRoute { pluginId: string; /** As declared. `null` means the route is offered to everybody. */ requires: null | PluginRouteRequirement; - /** The plugin-local half of {@link PluginRoute.id}, as declared. */ - routeId: string; /** - * As declared. `null` means this route has no eager search schema, which is - * the ordinary case - see {@link PluginRouteDefinition.searchEntry}. + * The plugin-local half of {@link PluginRoute.id}, derived by VitNode from the + * route's kind and its full path while the tree was flattened. */ - searchEntry: null | string; + routeId: string; /** `path`, already parsed - so nothing downstream has to parse it again. */ segments: PluginRouteSegment[]; } @@ -311,13 +189,14 @@ export interface PluginRoute { export type PluginRouteManifest = PluginRoute[]; /** - * The part of a registered plugin the manifest reads. + * One plugin, and the route tree it declares. * - * Structural, not `BuildPluginReturn`: that type reaches the AdminCP nav and the - * Content Engine, which reach React and Next, and this module has to stay - * loadable anywhere. A `BuildPluginReturn` satisfies this shape as it is. + * `routes` is `unknown` rather than `PluginRoutes` because a plugin is compiled + * JavaScript by the time a host reads it: the tree is validated from `unknown` + * by `flattenPluginRoutes`, which is what turns a plugin built against an older + * VitNode into a diagnostic rather than a crash. */ export interface PluginRouteSource { pluginId: string; - routes?: PluginRouteDefinition[]; + routes?: unknown; } diff --git a/packages/vitnode/src/tanstack/admin/breadcrumb.tsx b/packages/vitnode/src/tanstack/admin/breadcrumb.tsx index 368a4b693..e870d7a1b 100644 --- a/packages/vitnode/src/tanstack/admin/breadcrumb.tsx +++ b/packages/vitnode/src/tanstack/admin/breadcrumb.tsx @@ -5,61 +5,72 @@ import { useMatches, useRouter } from "@tanstack/react-router"; import type { AuthLinkComponent } from "@/views/auth/auth-link"; import { BreadcrumbAdminContent } from "@/views/admin/layouts/breadcrumb/breadcrumb-admin-content"; +import { BreadcrumbTrailContent } from "@/views/breadcrumb/breadcrumb-trail-content"; -import { breadcrumbOf } from "../breadcrumb/model"; +import type { RouteBreadcrumbGroup } from "../breadcrumb/model"; + +import { breadcrumbGroup, breadcrumbTrail } from "../breadcrumb/model"; import { RouterLink } from "../layout/router-link"; import { useRouteNavigationPending } from "../pending/navigation-pending"; import { BreadcrumbPendingSkeleton } from "../pending/shapes"; import { useAdminNav } from "./nav"; /** - * The AdminCP trail, declared by the route that owns it. + * The AdminCP trail, contributed by the routes that own it. * - * Stage 8's rule, unchanged and deliberately not re-implemented: a route puts an - * element on `staticData.breadcrumb`, the shell renders whichever matched route - * declared the deepest one, `undefined` inherits and `null` clears. Importing - * `../breadcrumb/model` is also what loads the `StaticDataRouteOption` - * augmentation, so an admin route writes `staticData: { breadcrumb: … }` with no - * import of its own. + * The same rule the public site follows and deliberately not re-implemented: a + * route puts a crumb on `staticData.breadcrumb` and the shell renders every + * matched route's, parent to child. Importing `../breadcrumb/model` is also what + * loads the `StaticDataRouteOption` augmentation, so an admin route writes + * `staticData: { breadcrumb: … }` with no import of its own. * - * What is *not* here, in either half: a map from pathname to trail. The Next.js - * AdminCP resolves one through `@breadcrumb` parallel routes whose folders - * mirror the pages; this resolves it from the matched routes. Neither is a - * registry, and nothing registers into one. + * Most AdminCP screens are one route several segments deep - `/admin/core/users` + * is a single route - and their crumbs are named by the *navigation* rather than + * by the route tree, so they contribute a `breadcrumbGroup` of their own items. + * That is what {@link AdminBreadcrumb} is, and it is the only place in VitNode + * that needs the group shape. * - * Next.js @breadcrumb/core/users/page.tsx -> <BreadcrumbAdmin segments={["core","users"]} /> - * TanStack routes/_admin/core/users.tsx -> staticData: { breadcrumb: <AdminBreadcrumb segments={["core","users"]} /> } + * What is *not* here, in either half: a map from pathname to trail. The labels + * come from the navigation this administrator can actually see. Nothing is a + * registry, and nothing registers into one. */ /** - * Whichever matched admin route declared the deepest crumb, or `null`. + * The AdminCP header's trail, or `null` when no matched route declared one. * - * Exposed separately from the component so the shell can tell an *absent* trail - * from an empty one - it renders the header's separator only when there is - * something to separate. + * Exposed separately from the shell so it can tell an *absent* trail from an + * empty one - it renders the header's separator only when there is something to + * separate. * - * ## Why it holds a shape rather than the destination's crumb + * ## Why it holds a shape rather than the destination's trail * - * A crumb is a route's `staticData`, but it is declared as an *element* so it - * may use hooks - a translated label, or one a dynamic route reads from its - * loader. That is exactly why the destination's cannot simply be drawn early: - * the data it would read is the data the navigation is still fetching. So the - * area holds a shape for as long as the content below it does, on the router's - * own `defaultPendingMs`, and the two change together. + * A crumb may read the data its route is still fetching - a user's name, a + * translated label whose messages are in flight - so the destination's trail + * cannot simply be drawn early. The area holds a shape for as long as the content + * below it does, on the router's own `defaultPendingMs`, and the two change + * together. * * Only when there is a trail to replace. A shell whose current page declares no * crumb shows nothing, and inventing a skeleton there would put a separator and * two bars into a header that has neither before the navigation nor after it. */ export const useAdminBreadcrumb = (): React.ReactNode => { - const breadcrumb = breadcrumbOf(useMatches()); + const entries = breadcrumbTrail(useMatches()); const isNavigating = useRouteNavigationPending( useRouter().options.defaultPendingMs ?? 0, ); - if (isNavigating && breadcrumb != null) return <BreadcrumbPendingSkeleton />; + if (entries.length === 0) return null; - return breadcrumb; + if (isNavigating) return <BreadcrumbPendingSkeleton />; + + return ( + <BreadcrumbTrailContent + entries={entries} + LinkComponent={RouterLink} + scrollable + /> + ); }; export const AdminBreadcrumb = ({ @@ -90,3 +101,18 @@ export const AdminBreadcrumb = ({ segments={segments} /> ); + +/** + * An AdminCP screen's crumbs, as the one contribution its route declares. + * + * A group rather than a label, because an AdminCP route is usually several + * segments deep on its own - `/admin/core/users` is one route - and the trail is + * named by the navigation rather than by the route tree. The items go straight + * into the shell's list; see `breadcrumbGroup`. + */ +export const adminBreadcrumb = ( + props: Parameters<typeof AdminBreadcrumb>[0], +): RouteBreadcrumbGroup => + breadcrumbGroup(function AdminRouteBreadcrumb() { + return <AdminBreadcrumb {...props} />; + }); diff --git a/packages/vitnode/src/tanstack/admin/index.ts b/packages/vitnode/src/tanstack/admin/index.ts index a566911aa..15370a7f8 100644 --- a/packages/vitnode/src/tanstack/admin/index.ts +++ b/packages/vitnode/src/tanstack/admin/index.ts @@ -74,7 +74,11 @@ export * from "./actions"; * barrel is the model's public vocabulary, so a TanStack host has one specifier * to import rather than a deep path into `views/`. */ -export { AdminBreadcrumb, useAdminBreadcrumb } from "./breadcrumb"; +export { + AdminBreadcrumb, + adminBreadcrumb, + useAdminBreadcrumb, +} from "./breadcrumb"; export * from "./intl"; export { AdminNavProvider, useAdminNav, useAdminSearchNavItems } from "./nav"; export { AdminNotFound } from "./not-found"; diff --git a/packages/vitnode/src/tanstack/admin/nav.tsx b/packages/vitnode/src/tanstack/admin/nav.tsx index 08a5b4c70..0ee2d726a 100644 --- a/packages/vitnode/src/tanstack/admin/nav.tsx +++ b/packages/vitnode/src/tanstack/admin/nav.tsx @@ -49,7 +49,7 @@ import { useAdminPermissions } from "./permissions"; * and `adminNavBundle` turns those into declarations plus the message namespaces * they need. Nothing here changes either way. * - * No navigation is derived from the route manifest: routes and navigation stay + * No navigation is derived from a plugin's route tree: routes and navigation stay * separate concepts, and a nav entry may point at a plugin route, at a screen * another application serves, or at another origin entirely. */ diff --git a/packages/vitnode/src/tanstack/admin/users/detail-breadcrumb.tsx b/packages/vitnode/src/tanstack/admin/users/detail-breadcrumb.tsx index 5bda67ef8..4154fabe3 100644 --- a/packages/vitnode/src/tanstack/admin/users/detail-breadcrumb.tsx +++ b/packages/vitnode/src/tanstack/admin/users/detail-breadcrumb.tsx @@ -1,12 +1,13 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import { useParams } from "@tanstack/react-router"; import type { AuthLinkComponent } from "@/views/auth/auth-link"; import { normalizeAdminUserId } from "@/views/admin/views/core/users/detail/user-query"; +import type { RouteBreadcrumbProps } from "../../breadcrumb/model"; + import { RouteMessages } from "../../i18n/route-messages"; import { AdminBreadcrumb } from "../breadcrumb"; import { useAdminIdentity } from "../identity"; @@ -21,13 +22,14 @@ import { adminUserQuery } from "./query"; * fetching the user a second time; this reads the entry the loader already * filled, so there is no second request and no second answer. * - * ## Why it reads the id off the router rather than taking it as a prop + * ## Where the id comes from * - * `staticData.breadcrumb` is a fixed `ReactNode`, declared next to the route - * options and evaluated before any of this route's params exist - so a crumb - * that needs one has to ask. `useParams({ strict: false })` is the router's own - * answer to that, and it is the *parsed* value: a route's `params.parse` runs - * first, so what arrives here has already been through `normalizeAdminUserId`. + * The shell hands every crumb the match that declared it, so the params arrive + * as a prop - this route's own, not the deepest match's - and they are the + * *parsed* value: a route's `params.parse` runs first, so what arrives here has + * already been through `normalizeAdminUserId`. It is narrowed rather than + * trusted all the same: a package cannot name a host's route types, so a + * `params` reaching a crumb proves nothing about an `$id` being in scope. * * ## Why it mounts `RouteMessages` of its own * @@ -41,19 +43,12 @@ import { adminUserQuery } from "./query"; */ export const AdminUserBreadcrumbContent = ({ LinkComponent, -}: { + params, +}: Partial<Pick<RouteBreadcrumbProps, "params">> & { LinkComponent?: AuthLinkComponent; }) => { const adminUserId = useAdminIdentity(); - /** - * `strict: false` because a package cannot name a host's route id, so the - * params come back untyped - which is honest: the shell renders this component - * from `staticData`, and nothing there proves a `$id` is in scope. Narrowed - * rather than asserted, and normalised rather than trusted: the crumb reads - * the cache entry the loader filled, and only one spelling of an id names it. - */ - const params: unknown = useParams({ strict: false }); - const raw = (params as { id?: unknown }).id; + const raw: unknown = params?.id; const id = normalizeAdminUserId(typeof raw === "string" ? raw : undefined); const { data } = useQuery({ ...adminUserQuery({ adminUserId, id: id ?? "" }), diff --git a/packages/vitnode/src/tanstack/auth/middleware-config-server.ts b/packages/vitnode/src/tanstack/auth/middleware-config-server.ts index 1d39be1aa..4c3c2ea2c 100644 --- a/packages/vitnode/src/tanstack/auth/middleware-config-server.ts +++ b/packages/vitnode/src/tanstack/auth/middleware-config-server.ts @@ -23,6 +23,7 @@ export const fetchMiddlewareConfigOnServer = return knownMiddlewareConfig(await response.json()); } catch (error) { + // eslint-disable-next-line no-console console.error("[auth] middleware configuration unavailable", error); return UNKNOWN_MIDDLEWARE_CONFIG; diff --git a/packages/vitnode/src/tanstack/breadcrumb/index.ts b/packages/vitnode/src/tanstack/breadcrumb/index.ts index 01e63f6e5..594958441 100644 --- a/packages/vitnode/src/tanstack/breadcrumb/index.ts +++ b/packages/vitnode/src/tanstack/breadcrumb/index.ts @@ -2,11 +2,17 @@ * The shell's breadcrumb, for a TanStack Router application. * * Two halves that are deliberately separate: a rule over matched routes - * (`breadcrumbOf`, testable with no router at all) and the component that - * applies it (`MainBreadcrumb`). Importing this barrel is also what loads the + * (`breadcrumbTrail`, testable with no router at all) and the component that + * renders it (`MainBreadcrumb`). Importing this barrel is also what loads the * `staticData.breadcrumb` augmentation - see `./model`. */ export { MainBreadcrumb } from "./main-breadcrumb"; -export type { BreadcrumbMatch } from "./model"; -export { breadcrumbOf } from "./model"; +export type { + BreadcrumbMatch, + BreadcrumbTrailEntry, + RouteBreadcrumb, + RouteBreadcrumbGroup, + RouteBreadcrumbProps, +} from "./model"; +export { breadcrumbGroup, breadcrumbTrail } from "./model"; diff --git a/packages/vitnode/src/tanstack/breadcrumb/main-breadcrumb.tsx b/packages/vitnode/src/tanstack/breadcrumb/main-breadcrumb.tsx index f0cc50399..322bd5685 100644 --- a/packages/vitnode/src/tanstack/breadcrumb/main-breadcrumb.tsx +++ b/packages/vitnode/src/tanstack/breadcrumb/main-breadcrumb.tsx @@ -2,50 +2,58 @@ import { useMatches, useRouter } from "@tanstack/react-router"; +import { BreadcrumbTrailContent } from "@/views/breadcrumb/breadcrumb-trail-content"; + +import { RouterLink } from "../layout/router-link"; import { useRouteNavigationPending } from "../pending/navigation-pending"; import { BreadcrumbPendingSkeleton } from "../pending/shapes"; -import { breadcrumbOf } from "./model"; +import { breadcrumbTrail } from "./model"; /** - * The shell's breadcrumb area: whichever matched route declared the deepest - * crumb, and nothing at all when none did. + * The public site's breadcrumb area: every matched route that declared a crumb, + * parent to child, and nothing at all when none did. * * `useMatches()` rather than a `select`: the whole match list changes on * navigation, which is exactly when this has to re-render, and the router's * structural sharing has nothing useful to say about a React element. * - * The crumb owns its own markup, including the container the Next.js slot uses - * (`BreadcrumbMain` renders `container mx-auto p-4`), so a route that moves here - * keeps the spacing it had. + * The container is here rather than in each crumb, which is the point of a trail: + * a route contributes a label, and the shell owns the spacing, the separators, + * the navigation landmark and the locale-aware links (`RouterLink` writes the + * locale prefix back into every href the router builds). * * Takes nothing. Every VitNode shell renders exactly this in the `breadcrumb` * slot of `ThemeLayoutContent`, and the only thing that varies between * applications is which crumbs their routes declare - which is the routes' * business, not this component's. * - * ## Why it holds a shape rather than the destination's crumb + * ## Why it holds a shape rather than the destination's trail * - * A crumb is a route's `staticData`, but it is declared as an *element* so it - * may use hooks - a translated label, or one a dynamic route reads from its - * loader. That is exactly why the destination's cannot simply be drawn early: - * the data it would read is the data the navigation is still fetching. So the - * area holds a shape for as long as the content below it does, on the router's - * own `defaultPendingMs`, and the two change together. + * A crumb may read the data its route is still fetching - a product's name, a + * translated label whose messages are in flight - so the destination's trail + * cannot simply be drawn early. The area holds a shape for as long as the content + * below it does, on the router's own `defaultPendingMs`, and the two change + * together. * * Only when there is a trail to replace. A shell whose current page declares no * crumb shows nothing, and inventing a skeleton there would put a separator and * two bars into a header that has neither before the navigation nor after it. */ export const MainBreadcrumb = () => { - const breadcrumb = breadcrumbOf(useMatches()); + const entries = breadcrumbTrail(useMatches()); const isNavigating = useRouteNavigationPending( useRouter().options.defaultPendingMs ?? 0, ); - if (isNavigating && breadcrumb != null) return <BreadcrumbPendingSkeleton />; + if (entries.length === 0) return null; - // Wrapped rather than returned straight: `ReactNode` includes a promise in - // React 19's types, and a component whose inferred return type includes one - // reads as an async component to every rule that looks for one. - return <>{breadcrumb}</>; + return ( + <div className="container mx-auto p-4"> + {isNavigating ? ( + <BreadcrumbPendingSkeleton /> + ) : ( + <BreadcrumbTrailContent entries={entries} LinkComponent={RouterLink} /> + )} + </div> + ); }; diff --git a/packages/vitnode/src/tanstack/breadcrumb/model.test.ts b/packages/vitnode/src/tanstack/breadcrumb/model.test.ts index 4f9919ec5..ada180644 100644 --- a/packages/vitnode/src/tanstack/breadcrumb/model.test.ts +++ b/packages/vitnode/src/tanstack/breadcrumb/model.test.ts @@ -1,49 +1,157 @@ +import { isValidElement } from "react"; import { describe, expect, it } from "vitest"; -import type { BreadcrumbMatch } from "./model"; +import type { BreadcrumbMatch, RouteBreadcrumbProps } from "./model"; -import { breadcrumbOf } from "./model"; +import { breadcrumbGroup, breadcrumbTrail } from "./model"; /** - * The one rule that replaces Next.js' `@breadcrumb` parallel route: of every - * matched route, the deepest declaration wins. + * The rule that replaces Next.js' `@breadcrumb` parallel route: every matched + * route that declares a crumb contributes one, parent to child. * * Tested over plain objects rather than through a router, because that is what - * the rule is - a fold over the match list, with `undefined` meaning "did not - * declare" and `null` meaning "declared nothing". Those two being different is - * the whole of the behaviour, and both are falsy, so it is exactly the pair a - * reader would collapse by accident. + * the rule is - a fold over the match list. What each shape *means* is the whole + * of the behaviour: a component is instantiated with its own match's data, an + * element is taken as it is, and `false`, `null` and a missing declaration all + * contribute nothing while leaving their parents' crumbs alone. */ -const match = (breadcrumb?: React.ReactNode): BreadcrumbMatch => ({ +/** + * A trail's labels. + * + * Cast rather than read straight off `content`, because React 19 types + * `ReactNode` as including a promise - so an arrow returning one reads as an + * async function to every lint rule that looks for one, and one of those rules + * helpfully adds the `async` keyword. + */ +const labelsOf = (matches: readonly BreadcrumbMatch[]): string[] => + breadcrumbTrail(matches).map(entry => entry.content as string); + +const match = ( + breadcrumb: BreadcrumbMatch["staticData"]["breadcrumb"], + rest: Partial<BreadcrumbMatch> = {}, +): BreadcrumbMatch => ({ + pathname: "/", + ...rest, staticData: breadcrumb === undefined ? {} : { breadcrumb }, }); -describe("breadcrumbOf", () => { - it("renders nothing when no route declared a crumb", () => { - expect(breadcrumbOf([match(), match()])).toBeNull(); +describe("breadcrumbTrail", () => { + it("is empty when no route declared a crumb", () => { + expect(breadcrumbTrail([match(undefined), match(undefined)])).toEqual([]); + }); + + it("is empty for an empty match list", () => { + expect(breadcrumbTrail([])).toEqual([]); + }); + + it("collects every declaration, parent to child", () => { + const matches = [ + match("Home", { pathname: "/", routeId: "root" }), + match("Catalog", { pathname: "/catalog", routeId: "catalog" }), + match("Products", { + pathname: "/catalog/products", + routeId: "products", + }), + ]; + const trail = breadcrumbTrail(matches); + + expect(labelsOf(matches)).toEqual(["Home", "Catalog", "Products"]); + + expect(trail.map(entry => entry.key)).toEqual([ + "root", + "catalog", + "products", + ]); + expect(trail.map(entry => entry.href)).toEqual([ + "/", + "/catalog", + "/catalog/products", + ]); + }); + + it("marks only the last crumb as the current page", () => { + const trail = breadcrumbTrail([match("Catalog"), match("Products")]); + + expect(trail.map(entry => entry.isCurrent)).toEqual([false, true]); }); - it("renders nothing for an empty match list", () => { - expect(breadcrumbOf([])).toBeNull(); + it("keeps a parent's crumb when a child declares none", () => { + expect(labelsOf([match("Catalog"), match(undefined)])).toEqual(["Catalog"]); }); - it("takes the declaration of the only route that made one", () => { - expect(breadcrumbOf([match(), match("settings")])).toBe("settings"); + it.each([false, null])("omits a route that declared %s", declared => { + expect(labelsOf([match("Catalog"), match(declared)])).toEqual(["Catalog"]); }); - it("prefers the deepest declaration", () => { - expect(breadcrumbOf([match("settings"), match("security")])).toBe( - "security", - ); + it("still marks the deepest *contributing* route as current", () => { + const trail = breadcrumbTrail([ + match("Catalog"), + match("Products"), + match(false), + ]); + + expect(trail.at(-1)).toMatchObject({ + content: "Products", + isCurrent: true, + }); }); - it("inherits an ancestor's crumb through a route that declares none", () => { - expect(breadcrumbOf([match("settings"), match()])).toBe("settings"); + it("keys a match with no route id by its position", () => { + expect(breadcrumbTrail([match("Catalog")])[0].key).toBe("match-0"); }); - it("lets a child clear an ancestor's crumb with null", () => { - // `null` is a declaration, not an absence. This is the only way a route - // inside a shell can say "no breadcrumb here". - expect(breadcrumbOf([match("settings"), match(null)])).toBeNull(); + it("hands a component its own match's data", () => { + const seen: RouteBreadcrumbProps[] = []; + const Crumb = (props: RouteBreadcrumbProps) => { + seen.push(props); + + return null; + }; + + const [entry] = breadcrumbTrail([ + match(Crumb, { + loaderData: { name: "Laptops" }, + params: { categorySlug: "laptops" }, + pathname: "/catalog/laptops", + search: { page: 2 }, + }), + ]); + + expect(isValidElement(entry.content)).toBe(true); + expect((entry.content as React.ReactElement).type).toBe(Crumb); + expect((entry.content as React.ReactElement).props).toEqual({ + loaderData: { name: "Laptops" }, + params: { categorySlug: "laptops" }, + pathname: "/catalog/laptops", + search: { page: 2 }, + }); + // Declared, not rendered: the shell decides when a crumb runs. + expect(seen).toEqual([]); + }); + + it("gives a component empty params when the match has none", () => { + const Crumb = () => null; + const [entry] = breadcrumbTrail([match(Crumb)]); + + expect((entry.content as React.ReactElement).props).toMatchObject({ + params: {}, + }); + }); + + it("marks a group so the shell lets it render its own items", () => { + const Crumbs = () => null; + const [entry] = breadcrumbTrail([ + match(breadcrumbGroup(Crumbs), { pathname: "/admin/core/users" }), + ]); + + expect(entry.spansItems).toBe(true); + expect((entry.content as React.ReactElement).type).toBe(Crumbs); + }); + + it("does not mistake a label for a group", () => { + const Crumb = () => null; + + expect(breadcrumbTrail([match(Crumb)])[0].spansItems).toBe(false); + expect(breadcrumbTrail([match("Catalog")])[0].spansItems).toBe(false); }); }); diff --git a/packages/vitnode/src/tanstack/breadcrumb/model.ts b/packages/vitnode/src/tanstack/breadcrumb/model.ts index 8c2a1c8b3..d9797d3c0 100644 --- a/packages/vitnode/src/tanstack/breadcrumb/model.ts +++ b/packages/vitnode/src/tanstack/breadcrumb/model.ts @@ -1,49 +1,102 @@ +import { createElement } from "react"; + /** * Where a breadcrumb comes from, in a router that has no parallel routes. * - * Next.js resolves the main breadcrumb through a `@breadcrumb` slot: a parallel - * route whose folder mirrors the page's, so the *deepest* folder with a - * `page.tsx` wins, one that returns `null` clears what a shallower one rendered, - * and everything unmatched falls through to `default.tsx`. This is the same - * rule, expressed with what a router already has - the list of matched routes, - * deepest last - and one optional field on each route's `staticData`. + * Next.js resolved the main breadcrumb through a `@breadcrumb` slot: a parallel + * route whose folder mirrored the page's, so the *deepest* folder with a + * `page.tsx` rendered the whole trail. This is the same idea taken one step + * further and expressed with what a router already has - the list of matched + * routes, deepest last - and one optional field on each route's `staticData`. + * + * ## A trail, not a winner + * + * Every matched route that declares a crumb contributes **one item** to the + * trail, in parent-to-child order: + * + * Home / Catalog / Products / Laptops / MacBook Pro * - * A `ReactNode`, exactly like the Next.js slot and like the `breadcrumb` prop of - * `ThemeLayoutContent`, so the shell renders it rather than deciding anything - * about it. Declaring it as an *element* is what lets a crumb use hooks - the - * label is translated, and on a dynamic route it comes from the loader - without - * the shell having to instantiate a component it was handed: + * So a route says what it is called and nothing else. It does not restate its + * layouts' crumbs, it does not know how deep it is, and it never builds a link: + * the shell owns the separators, the `nav`/`aria-current` semantics and the + * locale-aware href, which it takes from the matched route's own pathname. * - * staticData: { breadcrumb: <SearchBreadcrumb /> } + * A route that declares nothing contributes nothing, and its parents' crumbs + * stay visible - which is what a page inside a layout that already names the + * screen wants. `false` says the same thing on purpose. * - * What this is *not*: a breadcrumb registry. There is no map from pathname to - * label anywhere, and no plugin registers into one. A route declares its own - * crumb next to its own component, and the shell renders whichever declared - * crumb is deepest. See `MainBreadcrumb`, which is the half that renders. + * ## Three shapes, and why the third exists * - * ## The augmentation, and where it has to be loaded + * A crumb is usually a **component**, because a label is translated and so has + * to be able to call a hook, and because the shell hands it the match's own + * loader data, params and search - which an element written next to the route + * options could not be given. An **element** is the simpler spelling for a label + * that needs nothing. * - * `declare module` merges into the router's own types the moment this module is - * part of the program - which it is as soon as anything imports the namespace, - * because `MainBreadcrumb` lives next to it and every shell renders one. A route - * file therefore writes `staticData: { breadcrumb: … }` with no import of its - * own, exactly as it did when this rule lived in the application. + * {@link breadcrumbGroup} is the third, and it is for one situation: a single + * route whose URL is several crumbs deep, where the labels come from somewhere + * other than the route tree. The AdminCP is exactly that - `/admin/core/users` + * is one route, and its trail is named by the navigation this administrator can + * see - so its crumbs are rendered by one of VitNode's own components, which + * emits its own items inside the shell's list. Plugins never need it: their + * contract is a label. */ declare module "@tanstack/react-router" { interface StaticDataRouteOption { /** - * What this route contributes to the shell's breadcrumb area. + * What this route contributes to the shell's breadcrumb trail. * - * Absent means "whatever my parent said"; `null` means "nothing", which is - * how a child clears a crumb an ancestor declared. + * Absent or `false` contributes nothing, which leaves whatever this route's + * parents declared. * * Optional, and it must stay optional: a required member here would make * `staticData` required on every route in the app. */ - breadcrumb?: React.ReactNode; + breadcrumb?: RouteBreadcrumb; } } +/** + * What a route's breadcrumb component is handed - the match that declared it. + * + * Its own match rather than the deepest one, so a layout's crumb reads the + * layout's own loader data while a page inside it is what the visitor is looking + * at. Everything is widened to `unknown` because a package cannot name a host's + * route types; a crumb that needs its data narrows it, or - for a plugin route - + * is handed the typed `PluginRouteBreadcrumbProps` instead. + */ +export interface RouteBreadcrumbProps { + loaderData: unknown; + params: Readonly<Record<string, string>>; + /** This match's own URL, as the router's internal pathname. */ + pathname: string; + search: unknown; +} + +/** + * Several crumbs from one matched route, rendered by a VitNode component. + * + * The escape hatch the AdminCP needs, and nothing else uses: the component emits + * `<BreadcrumbItem>`s of its own inside the shell's list, with its own + * separators between them, so a route whose URL is three segments deep can be + * named by three crumbs. The shell still owns the separator *before* the group + * and the wrapper around the whole trail. + */ +export interface RouteBreadcrumbGroup { + group: React.ComponentType<RouteBreadcrumbProps>; +} + +export type RouteBreadcrumb = + | false + | React.ComponentType<RouteBreadcrumbProps> + | React.ReactNode + | RouteBreadcrumbGroup; + +/** Declares that one route contributes {@link RouteBreadcrumbGroup} crumbs. */ +export const breadcrumbGroup = ( + group: React.ComponentType<RouteBreadcrumbProps>, +): RouteBreadcrumbGroup => ({ group }); + /** * The narrowest shape of a route match this rule reads. * @@ -51,28 +104,103 @@ declare module "@tanstack/react-router" { * function over plain data and can be tested as one - no router, no route tree. */ export interface BreadcrumbMatch { - staticData: { breadcrumb?: React.ReactNode }; + loaderData?: unknown; + params?: unknown; + pathname?: string; + routeId?: string; + search?: unknown; + staticData: { breadcrumb?: RouteBreadcrumb }; +} + +/** One item of the rendered trail. */ +export interface BreadcrumbTrailEntry { + /** The label, ready to render - or the items themselves, for a group. */ + content: React.ReactNode; + /** Where this crumb points, taken from the matched route's own pathname. */ + href: string; + isCurrent: boolean; + key: string; + /** The content renders its own `<BreadcrumbItem>`s. See {@link breadcrumbGroup}. */ + spansItems: boolean; } +const isRecord = (value: unknown): value is Record<string, unknown> => + typeof value === "object" && value !== null; + +const isGroup = (value: unknown): value is RouteBreadcrumbGroup => + isRecord(value) && typeof value.group === "function"; + +const propsFor = (match: BreadcrumbMatch): RouteBreadcrumbProps => ({ + loaderData: match.loaderData, + params: isRecord(match.params) + ? (match.params as Readonly<Record<string, string>>) + : {}, + pathname: match.pathname ?? "", + search: match.search, +}); + /** - * The deepest matched route that declares a breadcrumb, or nothing. - * - * Deepest wins, which is the whole rule: `/settings/security` shows the security - * crumb rather than the settings one, and a route that declares nothing inherits - * its parent's - including inheriting *nothing*, which is how `/` ends up - * without a breadcrumb having never mentioned one. + * Every matched route that declares a crumb, parent to child. * - * `undefined` is "did not declare" and is the only value that falls through; - * `null` is a declaration, and the deliberate way to clear an ancestor's crumb. + * `undefined`, `null` and `false` all contribute nothing; the difference between + * them is only what an author meant, and `false` is the spelling that says it. + * The last entry is the current page, which is what the shell renders as + * `aria-current` rather than as a link. */ -export const breadcrumbOf = ( +export const breadcrumbTrail = ( matches: readonly BreadcrumbMatch[], -): React.ReactNode => { - for (let index = matches.length - 1; index >= 0; index--) { - const declared = matches[index].staticData.breadcrumb; +): BreadcrumbTrailEntry[] => { + const entries = matches.flatMap((match, position) => { + const declared = match.staticData.breadcrumb; - if (declared !== undefined) return declared; - } + if (declared === undefined || declared === null || declared === false) { + return []; + } + + const key = match.routeId ?? `match-${String(position)}`; + const href = match.pathname ?? ""; + + if (isGroup(declared)) { + return [ + { + content: createElement(declared.group, propsFor(match)), + href, + isCurrent: false, + key, + spansItems: true, + }, + ]; + } + + if (typeof declared === "function") { + return [ + { + content: createElement( + declared as React.ComponentType<RouteBreadcrumbProps>, + propsFor(match), + ), + href, + isCurrent: false, + key, + spansItems: false, + }, + ]; + } + + return [ + { + content: declared, + href, + isCurrent: false, + key, + spansItems: false, + }, + ]; + }); + + const last = entries.at(-1); + + if (last) last.isCurrent = true; - return null; + return entries; }; diff --git a/packages/vitnode/src/tanstack/i18n/query.test.ts b/packages/vitnode/src/tanstack/i18n/query.test.ts index 109e180f5..e26e8c066 100644 --- a/packages/vitnode/src/tanstack/i18n/query.test.ts +++ b/packages/vitnode/src/tanstack/i18n/query.test.ts @@ -132,11 +132,11 @@ describe("the query key names the language", () => { * * Both halves of VitNode now normalise a namespace list with the same * function - `normalizeNamespaceList`, which sorts by code unit and is the - * one the route manifest is generated with. This pins that they agree: a + * one a plugin's route tree is validated with. This pins that they agree: a * manifest declaring namespaces at build time and a browser asking for them at * runtime cannot spell one list two ways. */ - it("orders the namespaces the way a route manifest does", () => { + it("orders the namespaces the way a plugin route tree does", () => { const declared = [ "core.search", "Core.Global", diff --git a/packages/vitnode/src/tanstack/plugin-routes/components.test.tsx b/packages/vitnode/src/tanstack/plugin-routes/components.test.tsx new file mode 100644 index 000000000..3fb9e4397 --- /dev/null +++ b/packages/vitnode/src/tanstack/plugin-routes/components.test.tsx @@ -0,0 +1,114 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import type { RouteBreadcrumbProps } from "../breadcrumb/model"; + +import { pluginRouteBreadcrumb } from "./components"; +import { pluginRouteModuleRef } from "./module-ref"; + +/** + * What a plugin route contributes to the shell's trail, once its module has + * arrived - and what it contributes before that, which is nothing. + * + * The crumb is rendered by the shell, *above* the route's own component, so it + * cannot suspend on the module it reads: a suspend there blanks the header + * rather than the page. What it does instead is render nothing until the module + * resolves and then re-render, which is what `useSyncExternalStore` over the + * memoised import buys. + * + * No namespaces are declared here on purpose: a route that declares none mounts + * no message provider at all, so this needs no intl record and asserts only the + * part that belongs to this file. Which namespaces a route's provider gets is + * `./specs.test.ts`. + */ +const moduleWith = (route: Record<string, unknown>) => { + let resolve = (): void => undefined; + const arrived = new Promise<void>(settle => { + resolve = () => { + settle(); + }; + }); + + const ref = pluginRouteModuleRef(async () => { + await arrived; + + return { default: () => null, route }; + }, "plugin:page#/page"); + + return { load: ref, ready: resolve, ref }; +}; + +const props: RouteBreadcrumbProps = { + loaderData: { data: { name: "MacBook Pro" }, search: { page: 2 } }, + params: { productId: "42" }, + pathname: "/catalog/products/42", + search: {}, +}; + +describe("pluginRouteBreadcrumb", () => { + it("renders nothing until the route's module has arrived", async () => { + const { load, ready, ref } = moduleWith({ + breadcrumb: () => "MacBook Pro", + }); + const Breadcrumb = pluginRouteBreadcrumb(ref, []); + + const { container } = render(<Breadcrumb {...props} />); + + expect(container.innerHTML).toBe(""); + + ready(); + await load(); + await screen.findByText("MacBook Pro"); + }); + + it("hands the plugin's crumb this route's own loader data", async () => { + const seen: unknown[] = []; + const { load, ready, ref } = moduleWith({ + breadcrumb: (given: { loaderData: { name: string } }) => { + seen.push(given); + + return given.loaderData.name; + }, + }); + const Breadcrumb = pluginRouteBreadcrumb(ref, []); + + ready(); + await load(); + render(<Breadcrumb {...props} />); + + await screen.findByText("MacBook Pro"); + // Unwrapped from the runtime's envelope: a plugin sees its own `load` + // result and its own validated search, never the envelope itself. + expect(seen).toEqual([ + { + loaderData: { name: "MacBook Pro" }, + params: { productId: "42" }, + search: { page: 2 }, + }, + ]); + }); + + it("renders nothing for a module that declares `breadcrumb: false`", async () => { + const { load, ready, ref } = moduleWith({ breadcrumb: false }); + const Breadcrumb = pluginRouteBreadcrumb(ref, []); + + ready(); + await load(); + + const { container } = render(<Breadcrumb {...props} />); + + expect(container.innerHTML).toBe(""); + }); + + it("renders nothing for a module that declares no crumb at all", async () => { + const { load, ready, ref } = moduleWith({}); + const Breadcrumb = pluginRouteBreadcrumb(ref, []); + + ready(); + await load(); + + const { container } = render(<Breadcrumb {...props} />); + + expect(container.innerHTML).toBe(""); + }); +}); diff --git a/packages/vitnode/src/tanstack/plugin-routes/components.tsx b/packages/vitnode/src/tanstack/plugin-routes/components.tsx index 063910bf8..4279bb47f 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/components.tsx +++ b/packages/vitnode/src/tanstack/plugin-routes/components.tsx @@ -15,11 +15,16 @@ import { import type { CheckedPluginRouteModule } from "@/routing"; +import type { RouteBreadcrumbProps } from "../breadcrumb/model"; import type { RuntimePluginRoutePageProps } from "./loader-data"; import type { PluginRouteModuleRef } from "./module-ref"; import { RouteMessages } from "../i18n/route-messages"; -import { pluginRoutePageProps } from "./loader-data"; +import { + pluginRouteLoaderData, + pluginRoutePageProps, + pluginRouteSearch, +} from "./loader-data"; /** * The React half of a plugin route: what actually renders once the module has @@ -163,26 +168,13 @@ export const pluginLayoutComponent = ( }; /** - * One candidate for a plugin route's crumb: a module, and the strings it - * renders in. - */ -export interface PluginRouteCrumb { - module: PluginRouteModuleRef; - /** The owning route's own namespace list - see `./specs`. */ - namespaces: readonly string[]; -} - -/** - * What a plugin route contributes to the shell's breadcrumb area. + * A plugin route's own crumb, rendered in the shell's trail. * - * Stage 8's rule is "the deepest matched route that declared a crumb wins", read - * off `staticData` - and a plugin route cannot answer it there, because whether - * it declares a crumb is in its *module*, which has not been fetched when - * `staticData` is written. So every plugin route declares this one component - * instead, handed the chain from itself up through its layouts, and it applies - * the same rule at render time over what has actually arrived. The outcome is - * identical to each route having declared its own crumb; what is different is - * only when the question can be asked. + * Every plugin route declares this one component and the shell collects one item + * per matched route, so a page inside two layouts contributes the third crumb of + * three and never restates the two above it. What the plugin's own component + * returns is the label; the separator, the `aria-current`, the locale-aware link + * to this route's URL and the position in the trail are all the shell's. * * ## The provider, and why it is here rather than in the plugin * @@ -192,8 +184,8 @@ export interface PluginRouteCrumb { * `RouteMessages`, which is what `SettingsBreadcrumb` does by hand; a plugin * cannot, because `RouteMessages` is a TanStack component and a plugin route * module may not import one. So the runtime mounts it, with the namespaces of - * the route that *declared* the crumb - the same list that route's loader - * already warmed, so it reads a cache entry rather than fetching one. + * the route that declared the crumb - the same list that route's loader already + * warmed, so it reads a cache entry rather than fetching one. * * `Suspense` around it because the loader warms the messages and the module in * parallel: if the module wins that race the crumb can render a moment before @@ -203,52 +195,41 @@ export interface PluginRouteCrumb { * ## Why it may not suspend on the module itself * * Same reason. It reads what has already arrived (`ref.current`) and subscribes - * for the rest: before the modules resolve it renders nothing, and the moment - * they do it renders the crumb. `useSyncExternalStore` is exactly this, - * including the case a plainer implementation gets wrong - a module that - * resolves between the first render and the subscription. + * for the rest: before the module resolves it renders nothing, and the moment it + * does it renders the crumb. `useSyncExternalStore` is exactly this, including + * the case a plainer implementation gets wrong - a module that resolves between + * the first render and the subscription. * - * The chunk is never fetched *for* the breadcrumb. The refs are the same - * memoised imports the route's component and loader are already waiting on. + * The chunk is never fetched *for* the breadcrumb, and never split from the page + * it belongs to: the ref is the same memoised import the route's component and + * loader are already waiting on. */ -export const PluginRouteBreadcrumb = ({ - crumbs, -}: { - crumbs: readonly PluginRouteCrumb[]; -}) => { - const subscribe = useCallback( - (listener: () => void) => { - const unsubscribes = crumbs.map(crumb => - crumb.module.subscribe(listener), - ); - - return () => { - for (const unsubscribe of unsubscribes) unsubscribe(); - }; - }, - [crumbs], - ); - - // An index rather than the crumb itself, so the snapshot is a primitive that - // only changes when a module arrives - which is what `useSyncExternalStore` - // requires of it. - const snapshot = useCallback( - () => - crumbs.findIndex( - crumb => crumb.module.current?.route.breadcrumb !== undefined, - ), - [crumbs], - ); - - const index = useSyncExternalStore(subscribe, snapshot, snapshot); - const declared = index < 0 ? undefined : crumbs[index]; - const Breadcrumb = declared?.module.current?.route.breadcrumb; - - if (!declared || !Breadcrumb) return null; - - return ( - <Suspense> - {withMessages(declared.namespaces, createElement(Breadcrumb))} - </Suspense> - ); +export const pluginRouteBreadcrumb = ( + module: PluginRouteModuleRef, + namespaces: readonly string[], +): React.FunctionComponent<RouteBreadcrumbProps> => { + // Defined once per route rather than per render, which is what + // `useSyncExternalStore` needs of them - and there is nothing reactive to + // depend on: one component is built per module. + const subscribe = (listener: () => void) => module.subscribe(listener); + const snapshot = () => module.current?.route.breadcrumb; + + return function PluginRouteBreadcrumb(props: RouteBreadcrumbProps) { + const Breadcrumb = useSyncExternalStore(subscribe, snapshot, snapshot); + + if (!Breadcrumb) return null; + + return ( + <Suspense> + {withMessages( + namespaces, + createElement(Breadcrumb, { + loaderData: pluginRouteLoaderData(props.loaderData), + params: props.params, + search: pluginRouteSearch(props.loaderData), + }), + )} + </Suspense> + ); + }; }; diff --git a/packages/vitnode/src/tanstack/plugin-routes/index.ts b/packages/vitnode/src/tanstack/plugin-routes/index.ts index 127b3d616..0f3128089 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/index.ts +++ b/packages/vitnode/src/tanstack/plugin-routes/index.ts @@ -2,13 +2,13 @@ * Plugin pages, as real routes in a TanStack Start application's route tree - * `@vitnode/core/tanstack/plugin-routes`. * - * An application hands the two generated files to {@link pluginRouteSpecs} and - * the result to {@link withPluginRoutes}, and that is the whole of its - * plugin-routing code: + * An application hands the one generated file to {@link pluginRouteSpecs} and the + * result to {@link withPluginRoutes}, and that is the whole of its plugin-routing + * code: * * const routeTree = withPluginRoutes( * fileRouteTree, - * pluginRouteSpecs(pluginRouteManifest, pluginRouteModules), + * pluginRouteSpecs(pluginRouteSources), * { * mountUnder: { admin: adminShellRoute, main: mainShellRoute }, * pageHead, @@ -22,10 +22,9 @@ * * Everything else is here, because the composition is identical in every * installation: the graph, the lazy import, the message provider, the guard, the - * metadata, the breadcrumb and the refusal to shadow one of the host's own - * pages. + * metadata, the crumb and the refusal to shadow one of the host's own pages. * - * ./specs the manifest and the registry, joined and read as a tree + * ./specs the plugins' trees, flattened, validated and read as a tree * ./module-ref one memoised, checked import per route * ./mount that tree, as TanStack routes * ./components what renders once a module has arrived diff --git a/packages/vitnode/src/tanstack/plugin-routes/loader-data.ts b/packages/vitnode/src/tanstack/plugin-routes/loader-data.ts index dead62519..266c513fa 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/loader-data.ts +++ b/packages/vitnode/src/tanstack/plugin-routes/loader-data.ts @@ -18,9 +18,9 @@ export interface PluginRouteLoaderData { /** Whatever the module's `load` returned, or `undefined` if it declares none. */ data: unknown; /** - * The route's validated search: its eager `validateSearch` when its manifest - * entry declares a `searchEntry`, its module's `parseSearch` otherwise, or - * `{}` when it declares neither. + * The route's validated search: its eager `search` schema when its declaration + * has one, its module's `parseSearch` otherwise, or `{}` when it declares + * neither. */ search: unknown; } @@ -44,6 +44,24 @@ export type RuntimePluginRoutePageProps = PluginRoutePageProps< const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value); +/** + * Whatever this route's `load` returned, out of the runtime's envelope. + * + * Total: a loader that has not run - or a match whose loader threw, which is + * still a match and still renders its crumb - reads as `undefined` rather than + * throwing on a property of nothing. + */ +export const pluginRouteLoaderData = (loaderData: unknown): unknown => + isRecord(loaderData) + ? (loaderData as Partial<PluginRouteLoaderData>).data + : undefined; + +/** This route's validated search, out of the same envelope. */ +export const pluginRouteSearch = (loaderData: unknown): unknown => + (isRecord(loaderData) + ? (loaderData as Partial<PluginRouteLoaderData>).search + : undefined) ?? {}; + /** * The loader's envelope and the match's params, as the props a plugin page * renders with. @@ -56,15 +74,9 @@ export const pluginRoutePageProps = ( loaderData: unknown, params: Readonly<Record<string, string>>, navigate: RuntimePluginRoutePageProps["navigate"], -): RuntimePluginRoutePageProps => { - const envelope: Partial<PluginRouteLoaderData> = isRecord(loaderData) - ? loaderData - : {}; - - return { - loaderData: envelope.data, - navigate, - params, - search: envelope.search ?? {}, - }; -}; +): RuntimePluginRoutePageProps => ({ + loaderData: pluginRouteLoaderData(loaderData), + navigate, + params, + search: pluginRouteSearch(loaderData), +}); diff --git a/packages/vitnode/src/tanstack/plugin-routes/module-ref.ts b/packages/vitnode/src/tanstack/plugin-routes/module-ref.ts index 7da2f9c6b..46767d331 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/module-ref.ts +++ b/packages/vitnode/src/tanstack/plugin-routes/module-ref.ts @@ -1,4 +1,3 @@ -import type { PluginRouteModuleLoader } from "@/framework/plugin-routes"; import type { CheckedPluginRouteModule } from "@/routing"; import { readPluginRouteModule } from "@/routing"; @@ -9,12 +8,12 @@ import { readPluginRouteModule } from "@/routing"; * A plugin route's chunk is wanted by four different things at four different * moments - the router's own `component.preload()`, the route's loader, its * `head`, and the shell's breadcrumb - and every one of them is on the critical - * path of the same navigation. Four calls to the registry's loader would be four - * `import()` expressions of the same specifier: the bundler dedupes the *fetch*, - * but each caller would still run {@link readPluginRouteModule} again and hold - * its own copy of the answer, and the breadcrumb - which renders while the match - * is still pending - would have no way to find out that one of the others had - * finished. + * path of the same navigation. Four calls to the route's own `lazy()` callback + * would be four `import()` expressions of the same specifier: the bundler + * dedupes the *fetch*, but each caller would still run + * {@link readPluginRouteModule} again and hold its own copy of the answer, and + * the breadcrumb - which renders while the match is still pending - would have + * no way to find out that one of the others had finished. * * So the import is memoised here, once per route, and exposed as the three * shapes those callers actually need: @@ -45,15 +44,15 @@ export interface PluginRouteModuleRef { /** * A memoised, checked loader for one plugin route module. * - * The registry's loaders are typed `() => Promise<unknown>` deliberately - what - * a module is expected to export is not the registry's contract - so this is - * where that `unknown` is turned into something a router can be handed, by - * `readPluginRouteModule`, which checks rather than asserts and throws with the - * route id in the message. Without it the failure is React's "type is invalid" - * from inside a lazy component, three frames from the plugin that caused it. + * A `lazy()` callback is typed `() => Promise<unknown>` here deliberately - what + * a page module is expected to export is checked rather than assumed - so this + * is where that `unknown` is turned into something a router can be handed, by + * `readPluginRouteModule`, which throws with the route id in the message. + * Without it the failure is React's "type is invalid" from inside a lazy + * component, three frames from the plugin that caused it. */ export const pluginRouteModuleRef = ( - load: PluginRouteModuleLoader, + load: () => Promise<unknown>, routeId: string, ): PluginRouteModuleRef => { const listeners = new Set<() => void>(); 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 7bc5bab98..de2bfd283 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/mount-freshness.test.ts +++ b/packages/vitnode/src/tanstack/plugin-routes/mount-freshness.test.ts @@ -7,8 +7,9 @@ import { } from "@tanstack/react-router"; import { describe, expect, it } from "vitest"; -import type { PluginRouteModuleRegistry } from "@/framework/plugin-routes"; -import type { PluginRoute } from "@/routing"; +import type { PluginRouteDeclarationSource } from "@/routing"; + +import { definePluginRoutes, lazy, page } from "@/routing"; import type { PluginRoutePageHead } from "./mount"; @@ -39,65 +40,40 @@ import { pluginRouteSpecs } from "./specs"; * expected paths would be asserting against its own copy of the answer. */ -const route = (overrides: Partial<PluginRoute> = {}): PluginRoute => ({ - area: "main", - entry: "routes/page", - id: "plugin:page", - kind: "page", - namespaces: [], - parentId: null, - path: "/page", - pluginId: "plugin", - requires: null, - routeId: "page", - searchEntry: null, - segments: [{ kind: "static", value: "page" }], - ...overrides, -}); - -const registryOf = (...keys: string[]): PluginRouteModuleRegistry => - Object.fromEntries( - keys.map(key => [ - key, - async () => Promise.resolve({ default: () => null }), - ]), - ); - const pageHead: PluginRoutePageHead = ({ title }) => ({ meta: title ? [{ title }] : [], }); -/** One plugin's page, at `/example`. */ -const EXAMPLE = route({ - entry: "routes/example", - id: "example:page", - path: "/example", - pluginId: "example", - routeId: "page", - segments: [{ kind: "static", value: "example" }], +/** One configured plugin with one page, as the generated file holds it. */ +const plugin = ( + pluginId: string, + path: string, +): PluginRouteDeclarationSource => ({ + pluginId, + routes: definePluginRoutes([ + page(path, { + component: lazy( + async () => await Promise.resolve({ default: () => null }), + ), + }), + ]), }); +/** One plugin's page, at `/example`. */ +const EXAMPLE = plugin("example", "/example"); + /** A second plugin's page, at `/reports`, so removal can be told from a reset. */ -const REPORTS = route({ - entry: "routes/reports", - id: "reports:page", - path: "/reports", - pluginId: "reports", - routeId: "page", - segments: [{ kind: "static", value: "reports" }], -}); +const REPORTS = plugin("reports", "/reports"); /** - * The two generated files for a given configuration, as one call. + * The generated file for a given configuration, as one call. * - * The registry is derived from the same route list rather than being a fixed - * superset, because that is the invariant the real build has: both generated - * files are written from one pass over one configured plugin list, so a disabled - * plugin loses its manifest entry and its module import together. - * `pluginRouteSpecs` refuses anything else, which is how this was found. + * A plugin's routes and the modules behind them are one declaration, so a + * disabled plugin loses both together - there is no second list that could stay + * behind. */ -const specsFor = (...routes: PluginRoute[]) => - pluginRouteSpecs(routes, registryOf(...routes.map(entry => entry.id))); +const specsFor = (...sources: PluginRouteDeclarationSource[]) => + pluginRouteSpecs(sources); /** * The app's own tree, built once - which is the point. Every mount below @@ -214,8 +190,8 @@ describe("enabling, disabling and re-enabling a plugin on a live route tree", () }); /** - * A path *edit* rather than a removal - the same route id, moved. Both halves - * have to hold, and only the removal half is easy to get wrong. + * A path *edit* rather than a removal - the same plugin, a different URL. Both + * halves have to hold, and only the removal half is easy to get wrong. */ it("follows a route that moved, and drops the path it left", () => { const { admin, main, root } = appTree(); @@ -224,15 +200,10 @@ describe("enabling, disabling and re-enabling a plugin on a live route tree", () withPluginRoutes(root, specsFor(EXAMPLE), { mountUnder, pageHead }); expect(owns(root, "/example")).toBe(true); - withPluginRoutes( - root, - specsFor({ - ...EXAMPLE, - path: "/showcase", - segments: [{ kind: "static", value: "showcase" }], - }), - { mountUnder, pageHead }, - ); + withPluginRoutes(root, specsFor(plugin("example", "/showcase")), { + mountUnder, + pageHead, + }); expect(owns(root, "/example")).toBe(false); expect(owns(root, "/showcase")).toBe(true); @@ -285,18 +256,17 @@ describe("no orphan routes are left on the tree", () => { it("clears one shell's plugin subtree without touching the other's", () => { const { admin, main, root } = appTree(); const mountUnder = { admin, main }; - const ADMIN_PAGE = route({ - area: "admin", - entry: "routes/admin", - id: "admin:page", - path: "/admin/reports", + const ADMIN_PAGE: PluginRouteDeclarationSource = { pluginId: "admin", - routeId: "page", - segments: [ - { kind: "static", value: "admin" }, - { kind: "static", value: "reports" }, - ], - }); + routes: definePluginRoutes([ + page("/admin/reports", { + area: "admin", + component: lazy( + async () => await Promise.resolve({ default: () => null }), + ), + }), + ]), + }; withPluginRoutes(root, specsFor(EXAMPLE, ADMIN_PAGE), { mountUnder, diff --git a/packages/vitnode/src/tanstack/plugin-routes/mount.tsx b/packages/vitnode/src/tanstack/plugin-routes/mount.tsx index 31515ec8a..8f3b0f37f 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/mount.tsx +++ b/packages/vitnode/src/tanstack/plugin-routes/mount.tsx @@ -24,7 +24,7 @@ import { import { pluginLayoutComponent, pluginPageComponent, - PluginRouteBreadcrumb, + pluginRouteBreadcrumb, } from "./components"; import { PLUGIN_ROUTES_ROUTE_ID } from "./container"; import { pluginRouteGuard } from "./guard"; @@ -34,21 +34,20 @@ import { pluginRouteSearchDeps } from "./specs"; /** * Plugin pages, in a TanStack Start application's route tree. * - * Three inputs, and the whole point of the design is that each one answers - * exactly one question: + * Two inputs, and the whole point of the design is that each one answers exactly + * one question: * - * plugin-route-manifest.gen.ts what routes exist, where, in which shape - * plugin-routes.gen.ts how each route's module is imported - * this module how that becomes a TanStack route + * plugin-routes.gen.ts which plugins are configured, and their route trees + * this module how that becomes a TanStack route * - * The first two are generated *per application*, because only an installation - * knows which plugins it has and a bundler needs a literal `import()` per module - * to follow. This one is the composition, which is identical everywhere, so it - * lives here rather than once per host. + * The first is generated *per application*, because only an installation knows + * which plugins it has. This one is the composition, which is identical + * everywhere, so it lives here rather than once per host. * - * Neither generated file mentions a router, and no plugin page is copied into - * the host's `src/routes` - the component stays compiled in the plugin's own - * `dist` and arrives here as a lazy import the bundler resolved at build time. + * The generated file does not mention a router, and no plugin page is copied + * into the host's `src/routes` - a page stays compiled in the plugin's own + * `dist` and is reached only through the literal `import()` its own route + * declared, which the bundler resolved at build time. * * What is deliberately *not* here: a locale. `/example` and `/pl/example` are the * same route, because the router's rewrite strips the prefix before matching and @@ -236,7 +235,7 @@ const pluginRouteLoader = * The eager schema wins, and the module's `parseSearch` is not consulted * when there is one. * - * `deps` for a route with a `searchEntry` is already the router's validated + * `deps` for a route with a `search` schema is already the router's validated * search - `validateSearch` ran during path matching and `loaderDeps` reads * its output - so running `parseSearch` over it would normalise a normalised * value, with the module's answer silently overriding the one the router @@ -271,7 +270,7 @@ const pluginRouteLoader = * Everything a plugin can contribute passes through here, and every one of them * is reached through the same memoised module ref - so a route's component, its * loader, its metadata and its breadcrumb are four readers of one import rather - * than four imports. + * than four imports, and a crumb is never a chunk of its own. * * `lazyRouteComponent` over that ref is the supported way to code-split a * code-based route: the plugin's page gets its own Rollup chunk, stays out of @@ -283,7 +282,6 @@ const pluginRouteLoader = */ const pluginRouteOptions = ( spec: PluginRouteSpec, - byId: ReadonlyMap<string, PluginRouteSpec>, pageHead: PluginRoutePageHead, ) => { const beforeLoad = pluginRouteGuard(spec.route.requires); @@ -291,19 +289,19 @@ const pluginRouteOptions = ( return { ...(beforeLoad ? { beforeLoad } : {}), /** - * The route's own `validateSearch`, when its manifest declared one. + * The route's own `validateSearch`, when its declaration had a `search`. * * This is the seam a lazy module cannot reach: it runs during path matching, * so the function has to be in hand before any chunk is fetched, which is - * exactly what a `searchEntry` buys - a static import in the generated - * registry rather than a `() => import()`. A route that declares one gets - * everything a host's own route file gets from the same option: a typed - * search, links the router can build and check, and a clamped value that - * redirects instead of rendering a page that does not exist. + * exactly what declaring it in `routes.ts` buys - that module is imported + * statically by the host, the page it belongs to is not. A route that + * declares one gets everything a host's own route file gets from the same + * option: a typed search, links the router can build and check, and a clamped + * value that redirects instead of rendering a page that does not exist. * * Spread rather than set to `undefined`, because TanStack reads the presence - * of the key: a route without one keeps the raw query string, which is what - * every plugin route did before this existed and is still the right default. + * of the key: a route without one keeps the raw query string, which is the + * right default for a page that reads no state out of its URL. */ ...(spec.validateSearch ? { validateSearch: spec.validateSearch } : {}), component: lazyRouteComponent(async () => ({ @@ -319,7 +317,7 @@ const pluginRouteOptions = ( * The query string, normalised - and what "the query string" is depends on * which of the two search contracts the route chose. * - * With a `searchEntry`, `search` here is already the router's validated + * With a `search` schema, `search` here is already the router's validated * output, so this passes the route's own schema through and the loader * re-runs exactly when a parameter the schema keeps changes. Without one, * the runtime cannot know before the chunk loads whether this route reads @@ -335,29 +333,18 @@ const pluginRouteOptions = ( pluginRouteSearchDeps(search), path: spec.path, /** - * The crumb, as the chain of routes that could own it. + * This route's own crumb - a component, so the shell can hand it this + * match's loader data, params and search. * - * Resolved to the specs themselves rather than left as ids, so the component - * has both halves it needs of each candidate - the module that may declare a - * crumb, and the namespaces that crumb translates through. + * Declared for every plugin route rather than only for the ones that have a + * crumb, because whether a module declares one is *in* the module, which has + * not been fetched when `staticData` is written. The component renders + * nothing when its module turns out to declare none, so a page that wants no + * crumb - or declares `breadcrumb: false` - contributes nothing to the trail + * while its layouts' crumbs stay exactly where they were. */ staticData: { - breadcrumb: ( - <PluginRouteBreadcrumb - crumbs={spec.breadcrumbChain.flatMap(id => { - const candidate = byId.get(id); - - return candidate - ? [ - { - module: candidate.module, - namespaces: candidate.namespaces, - }, - ] - : []; - })} - /> - ), + breadcrumb: pluginRouteBreadcrumb(spec.module, spec.namespaces), }, }; }; @@ -435,7 +422,6 @@ const specsByMountPoint = ( const mountPluginSubtree = ( mountPoint: AnyRoute, specs: readonly PluginRouteSpec[], - byId: ReadonlyMap<string, PluginRouteSpec>, pageHead: PluginRoutePageHead, ): void => { const mounted: AnyRoute[] = mountPoint.children ?? []; @@ -475,7 +461,7 @@ const mountPluginSubtree = ( } const route: AnyRoute = createRoute({ - ...pluginRouteOptions(spec, byId, pageHead), + ...pluginRouteOptions(spec, pageHead), getParentRoute: () => parent, }); @@ -540,10 +526,8 @@ export const withPluginRoutes = <TRouteTree extends AnyRoute>( // renders in. if (specs.length > 0) assertNoAppCollision(specs, fileRoutePaths(routeTree)); - const byId = new Map(specs.map(spec => [spec.route.id, spec])); - for (const [mountPoint, mountedSpecs] of byMountPoint) { - mountPluginSubtree(mountPoint, mountedSpecs, byId, pageHead); + mountPluginSubtree(mountPoint, mountedSpecs, pageHead); } return routeTree; 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 a3d515fc3..d5237dc84 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/plugin-routes.test.ts +++ b/packages/vitnode/src/tanstack/plugin-routes/plugin-routes.test.ts @@ -3,8 +3,9 @@ import type { AnyRoute } from "@tanstack/react-router"; import { createRootRoute, createRoute } from "@tanstack/react-router"; import { describe, expect, it, vi } from "vitest"; -import type { PluginRouteModuleRegistry } from "@/framework/plugin-routes"; -import type { PluginRoute } from "@/routing"; +import type { PluginRouteDeclaration } from "@/routing"; + +import { definePluginRoutes, index, layout, lazy, page } from "@/routing"; import type { PluginRoutePageHead } from "./mount"; @@ -14,7 +15,8 @@ import { withPluginRoutes } from "./mount"; import { pluginRouteSpecs } from "./specs"; /** - * Plugin routes, from a manifest to a mounted TanStack route *tree*. + * Plugin routes, from a plugin's declared tree to a mounted TanStack route + * *tree*. * * Route structure only: which routes exist, what they claim, who their parent * is, and what the composition refuses. Nothing renders - whether a plugin's @@ -28,29 +30,21 @@ import { pluginRouteSpecs } from "./specs"; * `apps/web/src/tests/plugin-routes.test.ts`. */ -const route = (overrides: Partial<PluginRoute> = {}): PluginRoute => ({ - area: "main", - entry: "routes/page", - id: "plugin:page", - kind: "page", - namespaces: [], - parentId: null, - path: "/page", - pluginId: "plugin", - requires: null, - routeId: "page", - searchEntry: null, - segments: [{ kind: "static", value: "page" }], - ...overrides, -}); +/** A page module, as a `lazy()` that resolves without a bundler. */ +const lazyModule = ( + module: Record<string, unknown> = { default: () => null }, +) => lazy(async () => await Promise.resolve(module)); + +/** One plugin's tree, as the specs a router is built from. */ +const specsOf = (...routes: PluginRouteDeclaration[]) => + pluginRouteSpecs([ + { pluginId: "plugin", routes: definePluginRoutes(routes) }, + ]); -const registryOf = (...keys: string[]): PluginRouteModuleRegistry => - Object.fromEntries( - keys.map(key => [ - key, - async () => Promise.resolve({ default: () => null }), - ]), - ); +const pageAt = (path: string) => page(path, { component: lazyModule() }); + +/** The id VitNode derives for a page at `path`, which diagnostics name. */ +const idOf = (path: string) => `plugin:page#${path}`; /** The host's own binding, which is all this composition needs of one. */ const pageHead: PluginRoutePageHead = ({ description, robots, title }) => ({ @@ -88,15 +82,9 @@ describe("withPluginRoutes", () => { ); it("mounts one route per plugin route, under the plugin container", () => { - const tree = mount( - appTree(), - pluginRouteSpecs( - [route({ path: "/example" })], - registryOf("plugin:page"), - ), - ); + const tree = mount(appTree(), specsOf(pageAt("/example"))); - expect(pluginChildren(tree)).toEqual(["/page"]); + expect(pluginChildren(tree)).toEqual(["/example"]); expect(fileRoutePaths(tree)).toEqual(["/", "/discover"]); }); @@ -117,8 +105,8 @@ describe("withPluginRoutes", () => { it("replaces the plugin subtree rather than appending a second copy", () => { const tree = appTree(); - mount(tree, pluginRouteSpecs([route()], registryOf("plugin:page"))); - mount(tree, pluginRouteSpecs([route()], registryOf("plugin:page"))); + mount(tree, specsOf(pageAt("/page"))); + mount(tree, specsOf(pageAt("/page"))); expect(pluginChildren(tree)).toEqual(["/page"]); expect(tree.children).toHaveLength(3); @@ -136,7 +124,7 @@ describe("withPluginRoutes", () => { it("takes the plugin subtree off again when the last plugin goes away", () => { const tree = appTree(); - mount(tree, pluginRouteSpecs([route()], registryOf("plugin:page"))); + mount(tree, specsOf(pageAt("/page"))); expect(containerOf(tree)).toBeDefined(); withPluginRoutes(tree, [], { pageHead }); @@ -162,7 +150,7 @@ describe("withPluginRoutes", () => { const tree = withPluginRoutes( root.addChildren([shell]), - pluginRouteSpecs([route()], registryOf("plugin:page")), + specsOf(pageAt("/page")), { mountUnder: { main: shell }, pageHead }, ); @@ -173,24 +161,13 @@ describe("withPluginRoutes", () => { }); /** - * The manifest layer rejects two plugins claiming one URL and cannot see this + * The routing layer rejects two plugins claiming one URL and cannot see this * case - it does not know which application it is being built for. */ it("refuses a plugin route that would shadow one of the app’s own pages", () => { - expect(() => - mount( - appTree(), - pluginRouteSpecs( - [ - route({ - path: "/discover", - segments: [{ kind: "static", value: "discover" }], - }), - ], - registryOf("plugin:page"), - ), - ), - ).toThrow(/discover/); + expect(() => mount(appTree(), specsOf(pageAt("/discover")))).toThrow( + /discover/, + ); }); }); @@ -203,18 +180,8 @@ describe("withPluginRoutes", () => { * its manifest spells out in full, and both shells are pathless. */ describe("plugin route areas", () => { - const adminRoute = (overrides: Partial<PluginRoute> = {}): PluginRoute => - route({ - area: "admin", - id: "plugin:reports", - path: "/admin/reports", - routeId: "reports", - segments: [ - { kind: "static", value: "admin" }, - { kind: "static", value: "reports" }, - ], - ...overrides, - }); + const adminRoute = (path = "/admin/reports") => + page(path, { area: "admin", component: lazyModule() }); /** A root with both shells, each already holding a page of the app's own. */ const shells = () => { @@ -246,16 +213,12 @@ describe("plugin route areas", () => { it("mounts each route under the shell its area names", () => { const { admin, main, tree } = shells(); - withPluginRoutes( - tree, - pluginRouteSpecs( - [route({ path: "/example" }), adminRoute()], - registryOf("plugin:page", "plugin:reports"), - ), - { mountUnder: { admin, main }, pageHead }, - ); + withPluginRoutes(tree, specsOf(pageAt("/example"), adminRoute()), { + mountUnder: { admin, main }, + pageHead, + }); - expect(mountedPaths(main)).toEqual(["/page"]); + expect(mountedPaths(main)).toEqual(["/example"]); expect(mountedPaths(admin)).toEqual(["/admin/reports"]); // Neither shell lost the page it already had. expect(main.children).toHaveLength(2); @@ -274,23 +237,18 @@ describe("plugin route areas", () => { const { main, tree } = shells(); expect(() => - withPluginRoutes( - tree, - pluginRouteSpecs([adminRoute()], registryOf("plugin:reports")), - { mountUnder: { main }, pageHead }, - ), - ).toThrow(/plugin:reports.*"admin" area.*no mount point/s); + withPluginRoutes(tree, specsOf(adminRoute()), { + mountUnder: { main }, + pageHead, + }), + ).toThrow(/plugin:page#\/admin\/reports.*"admin" area.*no mount point/s); }); it("still refuses it when the host named no shells at all", () => { const { tree } = shells(); expect(() => - withPluginRoutes( - tree, - pluginRouteSpecs([adminRoute()], registryOf("plugin:reports")), - { pageHead }, - ), + withPluginRoutes(tree, specsOf(adminRoute()), { pageHead }), ).toThrow(/"admin" area/); }); @@ -305,40 +263,42 @@ describe("plugin route areas", () => { * is where that is refused, so the two areas cannot be separated *here* by * mounting them somewhere clever. */ - it("refuses two areas that claim one pathname", () => { - const reports = { kind: "static", value: "reports" } as const; - + it("refuses two plugins whose areas claim one pathname", () => { expect(() => - pluginRouteSpecs( - [ - route({ path: "/reports", segments: [reports] }), - adminRoute({ path: "/reports", segments: [reports] }), - ], - registryOf("plugin:page", "plugin:reports"), - ), + pluginRouteSpecs([ + { + pluginId: "plugin", + routes: definePluginRoutes([pageAt("/reports")]), + }, + { + pluginId: "other", + routes: definePluginRoutes([adminRoute("/reports")]), + }, + ]), ).toThrow(/collision on "\/reports"/); }); + /** + * And one plugin claiming it twice is refused where it is written: a route id + * is derived from the kind and the path, so two pages at one path are two + * routes with one identity rather than two shells to choose between. + */ + it("refuses one plugin claiming a pathname in both areas", () => { + expect(() => specsOf(pageAt("/reports"), adminRoute("/reports"))).toThrow( + /Duplicate plugin route "plugin:page#\/reports"/, + ); + }); + /** * The pair a plugin actually writes: two areas, two URLs, two shells. */ it("mounts two areas whose paths genuinely differ", () => { const { admin, main, tree } = shells(); - withPluginRoutes( - tree, - pluginRouteSpecs( - [ - route({ - path: "/reports", - segments: [{ kind: "static", value: "reports" }], - }), - adminRoute(), - ], - registryOf("plugin:page", "plugin:reports"), - ), - { mountUnder: { admin, main }, pageHead }, - ); + withPluginRoutes(tree, specsOf(pageAt("/reports"), adminRoute()), { + mountUnder: { admin, main }, + pageHead, + }); expect(mountedPaths(main)).toEqual(["/reports"]); expect(mountedPaths(admin)).toEqual(["/admin/reports"]); @@ -353,28 +313,21 @@ describe("plugin route areas", () => { */ it("clears one shell's subtree without touching the other's", () => { const { admin, main, tree } = shells(); - const specs = (...ids: string[]) => - pluginRouteSpecs( - [route({ path: "/example" }), adminRoute()].filter(candidate => - ids.includes(candidate.id), - ), - registryOf(...ids), - ); - - withPluginRoutes(tree, specs("plugin:page", "plugin:reports"), { + + withPluginRoutes(tree, specsOf(pageAt("/example"), adminRoute()), { mountUnder: { admin, main }, pageHead, }); expect(containerOf(admin)).toBeDefined(); - withPluginRoutes(tree, specs("plugin:page"), { + withPluginRoutes(tree, specsOf(pageAt("/example")), { mountUnder: { admin, main }, pageHead, }); expect(containerOf(admin)).toBeUndefined(); expect(admin.children).toHaveLength(1); - expect(mountedPaths(main)).toEqual(["/page"]); + expect(mountedPaths(main)).toEqual(["/example"]); }); /** @@ -385,17 +338,13 @@ describe("plugin route areas", () => { it("shares one container when two areas name the same route", () => { const { main, tree } = shells(); - withPluginRoutes( - tree, - pluginRouteSpecs( - [route({ path: "/example" }), adminRoute()], - registryOf("plugin:page", "plugin:reports"), - ), - { mountUnder: { admin: main, main }, pageHead }, - ); + withPluginRoutes(tree, specsOf(pageAt("/example"), adminRoute()), { + mountUnder: { admin: main, main }, + pageHead, + }); expect(containersOf(main)).toHaveLength(1); - expect(mountedPaths(main)).toEqual(["/admin/reports", "/page"]); + expect(mountedPaths(main)).toEqual(["/admin/reports", "/example"]); }); /** @@ -407,22 +356,10 @@ describe("plugin route areas", () => { const { admin, main, tree } = shells(); expect(() => - withPluginRoutes( - tree, - pluginRouteSpecs( - [ - adminRoute({ - path: "/admin/core", - segments: [ - { kind: "static", value: "admin" }, - { kind: "static", value: "core" }, - ], - }), - ], - registryOf("plugin:reports"), - ), - { mountUnder: { admin, main }, pageHead }, - ), + withPluginRoutes(tree, specsOf(adminRoute("/admin/core")), { + mountUnder: { admin, main }, + pageHead, + }), ).toThrow(/\/admin\/core/); }); }); @@ -435,39 +372,14 @@ describe("plugin route areas", () => { * that happen to share a prefix and each redraw the frame. */ describe("a nested plugin subtree", () => { - const GUIDE: PluginRoute[] = [ - route({ - id: "plugin:guide", - kind: "layout", - path: "/example/guide", - routeId: "guide", - segments: [ - { kind: "static", value: "example" }, - { kind: "static", value: "guide" }, - ], - }), - route({ - id: "plugin:guide-index", - parentId: "plugin:guide", - path: "/example/guide", - routeId: "guide-index", - segments: [ - { kind: "static", value: "example" }, - { kind: "static", value: "guide" }, + const guide = () => + layout("/example/guide", { + component: lazyModule(), + children: [ + index({ component: lazyModule() }), + page(":topic", { component: lazyModule() }), ], - }), - route({ - id: "plugin:guide-topic", - parentId: "plugin:guide", - path: "/example/guide/:topic", - routeId: "guide-topic", - segments: [ - { kind: "static", value: "example" }, - { kind: "static", value: "guide" }, - { kind: "param", name: "topic" }, - ], - }), - ]; + }); const mounted = () => { const root = createRootRoute(); @@ -475,10 +387,7 @@ describe("a nested plugin subtree", () => { root.addChildren([ createRoute({ getParentRoute: () => root, path: "/" }), ]), - pluginRouteSpecs( - GUIDE, - registryOf("plugin:guide", "plugin:guide-index", "plugin:guide-topic"), - ), + specsOf(guide()), { pageHead }, ); @@ -532,18 +441,7 @@ describe("a nested plugin subtree", () => { ]); expect(() => - withPluginRoutes( - tree, - pluginRouteSpecs( - GUIDE, - registryOf( - "plugin:guide", - "plugin:guide-index", - "plugin:guide-topic", - ), - ), - { pageHead }, - ), + withPluginRoutes(tree, specsOf(guide()), { pageHead }), ).toThrow(/conflicts with application route/); }); }); @@ -565,66 +463,37 @@ describe("plugin ↔ application collisions", () => { ); }; - const mountOne = ( - tree: AnyRoute, - path: string, - segments: PluginRoute["segments"], - ) => - mount( - tree, - pluginRouteSpecs([route({ path, segments })], registryOf("plugin:page")), - ); - - const param = (name: string) => ({ kind: "param" as const, name }); - const staticSegment = (value: string) => ({ kind: "static" as const, value }); + const mountOne = (tree: AnyRoute, path: string) => + mount(tree, specsOf(pageAt(path))); it.each([ - ["/users/$id", "/users/:userId", [staticSegment("users"), param("userId")]], - [ - "/blog/$slug/comments", - "/blog/:postId/comments", - [staticSegment("blog"), param("postId"), staticSegment("comments")], - ], - ["/discover", "/discover", [staticSegment("discover")]], - ] as const)( - "refuses app %s against plugin %s", - (appPath, pluginPath, segments) => { - expect(() => - mountOne(treeWith(appPath), pluginPath, [...segments]), - ).toThrow(/conflicts with application route/); - }, - ); + ["/users/$id", "/users/:userId"], + ["/blog/$slug/comments", "/blog/:postId/comments"], + ["/discover", "/discover"], + ] as const)("refuses app %s against plugin %s", (appPath, pluginPath) => { + expect(() => mountOne(treeWith(appPath), pluginPath)).toThrow( + /conflicts with application route/, + ); + }); it.each([ - ["/users/new", "/users/:id", [staticSegment("users"), param("id")]], - [ - "/users/$id", - "/users/new", - [staticSegment("users"), staticSegment("new")], - ], - ["/discover", "/example", [staticSegment("example")]], - ] as const)( - "allows app %s beside plugin %s", - (appPath, pluginPath, segments) => { - expect(() => - mountOne(treeWith(appPath), pluginPath, [...segments]), - ).not.toThrow(); - }, - ); + ["/users/new", "/users/:id"], + ["/users/$id", "/users/new"], + ["/discover", "/example"], + ] as const)("allows app %s beside plugin %s", (appPath, pluginPath) => { + expect(() => mountOne(treeWith(appPath), pluginPath)).not.toThrow(); + }); it("names the plugin route, its canonical path and the app route it hit", () => { let message = ""; try { - mountOne(treeWith("/users/$id"), "/users/:userId", [ - staticSegment("users"), - param("userId"), - ]); + mountOne(treeWith("/users/$id"), "/users/:userId"); } catch (error) { message = error instanceof Error ? error.message : String(error); } - expect(message).toContain("plugin:page"); + expect(message).toContain(idOf("/users/:userId")); expect(message).toContain("/users/:userId"); expect(message).toContain("/users/$id"); }); @@ -673,7 +542,7 @@ describe("fileRoutePaths", () => { createRoute({ getParentRoute: () => root, path: "/discover" }), ]); })(), - pluginRouteSpecs([route()], registryOf("plugin:page")), + specsOf(pageAt("/page")), ); expect(fileRoutePaths(tree)).toEqual(["/discover"]); @@ -692,18 +561,7 @@ describe("fileRoutePaths", () => { ]); expect(() => - mount( - root.addChildren([blog]), - pluginRouteSpecs( - [ - route({ - path: "/blog", - segments: [{ kind: "static", value: "blog" }], - }), - ], - registryOf("plugin:page"), - ), - ), + mount(root.addChildren([blog]), specsOf(pageAt("/blog"))), ).toThrow(/conflicts with application route/); }); }); @@ -728,9 +586,7 @@ describe("a plugin route's loader", () => { root.addChildren([ createRoute({ getParentRoute: () => root, path: "/" }), ]), - pluginRouteSpecs([route()], { - "plugin:page": async () => Promise.resolve(module), - }), + specsOf(page("/page", { component: lazyModule(module) })), ); const container = (tree.children ?? []).find( @@ -808,9 +664,9 @@ describe("a plugin route's loader", () => { * its state: a paginated table needs `?page=999` clamped and redirected, and a * filter needs links the router can build and check. * - * A route earns a real one by declaring a `searchEntry`, which the build turns - * into a *static* import in the generated registry - so the function is in hand - * before the router matches, while the page stays in its own chunk. + * A route earns a real one by declaring `search` in its plugin's `routes.ts`, + * which the app imports statically - so the function is in hand before the + * router matches, while the page stays in its own chunk. * * Asserted on the constructed route's own options, because that is the only * place the difference is observable: the same manifest with and without a @@ -841,10 +697,8 @@ describe("a route's eager search schema", () => { it("reaches the constructed route as `validateSearch`", () => { const options = mountedOptions( - pluginRouteSpecs( - [route({ searchEntry: "routes/page.search" })], - registryOf("plugin:page"), - { "plugin:page": validateSearch }, + specsOf( + page("/page", { component: lazyModule(), search: validateSearch }), ), ); @@ -857,31 +711,56 @@ describe("a route's eager search schema", () => { * `undefined` is not the same as an absent one. */ it("is absent for a route that declares none", () => { - const options = mountedOptions( - pluginRouteSpecs([route()], registryOf("plugin:page")), - ); + const options = mountedOptions(specsOf(pageAt("/page"))); expect("validateSearch" in options).toBe(false); }); /** - * The two generated files have to agree about it, in both directions - the - * same parity the module registry gets, and for a sharper reason: a route that - * lost its schema would still match, still load and still render, reading a - * query string nobody validated. + * There is nothing left for the schema to get out of step *with*, which is the + * point of it living in the tree: the route that declares it and the function + * itself are one declaration, so a route cannot lose its `validateSearch` + * while still matching, loading and rendering a query string nobody validated. */ - it("fails when the manifest and the registry disagree", () => { - expect(() => - pluginRouteSpecs( - [route({ searchEntry: "routes/page.search" })], - registryOf("plugin:page"), - ), - ).toThrow(/declares the search entry .* but has no schema/); + it("belongs to the route that declared it, and to no other", () => { + const specs = specsOf( + page("/browse", { component: lazyModule(), search: validateSearch }), + pageAt("/read"), + ); - expect(() => - pluginRouteSpecs([route()], registryOf("plugin:page"), { - "plugin:page": validateSearch, + expect(specs.map(spec => spec.validateSearch)).toEqual([ + validateSearch, + null, + ]); + }); + + /** + * And the loader hands that validated value through rather than re-normalising + * it: `deps` for a route with a schema is already the router's own output, so + * a second pass would let the module quietly disagree with the URL the router + * built its links from. + */ + it("passes the router's validated search through the loader", async () => { + const [spec] = specsOf( + page("/browse", { component: lazyModule(), search: validateSearch }), + ); + const root = createRootRoute(); + const tree: AnyRoute = withPluginRoutes(root.addChildren([]), [spec], { + pageHead, + }); + 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<{ search: unknown }>; + + await expect( + loader({ + context: { locale: "en", queryClient: undefined }, + deps: { page: 3 }, + params: {}, }), - ).toThrow(/has a search schema for .* which declares no/); + ).resolves.toEqual({ data: undefined, search: { page: 3 } }); }); }); diff --git a/packages/vitnode/src/tanstack/plugin-routes/specs.test.ts b/packages/vitnode/src/tanstack/plugin-routes/specs.test.ts index 411070a96..7e36de0a8 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/specs.test.ts +++ b/packages/vitnode/src/tanstack/plugin-routes/specs.test.ts @@ -1,88 +1,46 @@ import { describe, expect, it } from "vitest"; -import type { PluginRouteModuleRegistry } from "@/framework/plugin-routes"; -import type { PluginRoute } from "@/routing"; +import type { PluginRouteDeclaration } from "@/routing"; + +import { definePluginRoutes, index, layout, lazy, page } from "@/routing"; import { pluginRouteSearchDeps, pluginRouteSpecs } from "./specs"; /** - * The manifest and the registry, as the route specs a router is built from. + * Every configured plugin's route tree, as the route specs a router is built + * from. * - * Everything here is data: what the two generated files say, and what the - * composition makes of them. Nothing renders and no route is created - whether a - * spec becomes the right TanStack route is `./plugin-routes.test.ts`, and - * whether a plugin's page produces the right HTML is the plugin's own business. + * Everything here is data: what the plugins declared, and what the composition + * makes of them. Nothing renders and no route is created - whether a spec + * becomes the right TanStack route is `./plugin-routes.test.ts`, and whether a + * plugin's page produces the right HTML is the plugin's own business. */ +const lazyPage = () => + lazy(async () => await Promise.resolve({ default: () => null })); -const route = (overrides: Partial<PluginRoute> = {}): PluginRoute => ({ - area: "main", - entry: "routes/page", - id: "plugin:page", - kind: "page", - namespaces: [], - parentId: null, - path: "/page", - pluginId: "plugin", - requires: null, - routeId: "page", - searchEntry: null, - segments: [{ kind: "static", value: "page" }], - ...overrides, -}); - -const registryOf = (...keys: string[]): PluginRouteModuleRegistry => - Object.fromEntries( - keys.map(key => [ - key, - async () => Promise.resolve({ default: () => null }), - ]), - ); +const specsOf = (...routes: PluginRouteDeclaration[]) => + pluginRouteSpecs([ + { pluginId: "plugin", routes: definePluginRoutes(routes) }, + ]); /** - * The nested shape the example plugin ships, and the smallest manifest that + * The nested shape the example plugin ships, and the smallest tree that * exercises every part of the hierarchy: a layout, its index page at the same * path, and a dynamic child one segment deeper. */ -const GUIDE: PluginRoute[] = [ - route({ - id: "plugin:guide", - kind: "layout", - namespaces: ["plugin.guide"], - path: "/example/guide", - routeId: "guide", - segments: [ - { kind: "static", value: "example" }, - { kind: "static", value: "guide" }, - ], - }), - route({ - id: "plugin:guide-index", - parentId: "plugin:guide", - path: "/example/guide", - routeId: "guide-index", - segments: [ - { kind: "static", value: "example" }, - { kind: "static", value: "guide" }, - ], - }), - route({ - id: "plugin:guide-topic", - namespaces: ["plugin.topic"], - parentId: "plugin:guide", - path: "/example/guide/:topic", - routeId: "guide-topic", - segments: [ - { kind: "static", value: "example" }, - { kind: "static", value: "guide" }, - { kind: "param", name: "topic" }, - ], - }), -]; - const guideSpecs = () => - pluginRouteSpecs( - GUIDE, - registryOf("plugin:guide", "plugin:guide-index", "plugin:guide-topic"), + specsOf( + layout("/example/guide", { + component: lazyPage(), + messages: ["plugin.guide"], + children: [ + index({ component: lazyPage() }), + page(":topic", { + component: lazyPage(), + messages: ["plugin.topic"], + }), + ], + }), ); const byId = (id: string) => { @@ -93,22 +51,13 @@ const byId = (id: string) => { return spec; }; +const GUIDE = "plugin:layout#/example/guide"; +const GUIDE_INDEX = "plugin:page#/example/guide"; +const GUIDE_TOPIC = "plugin:page#/example/guide/:topic"; + describe("pluginRouteSpecs", () => { it("pairs each route with its module and converts the path for TanStack", () => { - const specs = pluginRouteSpecs( - [ - route({ - id: "plugin:article", - path: "/blog/:slug", - routeId: "article", - segments: [ - { kind: "static", value: "blog" }, - { kind: "param", name: "slug" }, - ], - }), - ], - registryOf("plugin:article"), - ); + const specs = specsOf(page("/blog/:slug", { component: lazyPage() })); expect(specs).toHaveLength(1); // `:slug` in the manifest, `$slug` in the router. Neither spelling is the @@ -119,63 +68,79 @@ describe("pluginRouteSpecs", () => { }); it("leaves an app with no plugin routes with nothing to register", () => { - expect(pluginRouteSpecs([], {})).toEqual([]); + expect(pluginRouteSpecs([])).toEqual([]); + expect(specsOf()).toEqual([]); }); - it("rejects a manifest route the registry has no module for", () => { - expect(() => pluginRouteSpecs([route()], {})).toThrow(/plugin:page/); - }); + it("carries each route's own lazy component", async () => { + const [spec] = specsOf(page("/blog", { component: lazyPage() })); - it("rejects a registry module no manifest route claims", () => { - expect(() => pluginRouteSpecs([], registryOf("plugin:page"))).toThrow( - /plugin:page/, - ); + await expect(spec.module()).resolves.toHaveProperty("component"); }); /** - * The graph is the build's own validation, re-run over the generated manifest - * - so a manifest that could not describe a tree fails here rather than - * becoming a route nothing can ever match. + * The same validation the build ran, re-run over the same declarations - so a + * plugin built against another version of VitNode fails here, naming the + * plugin, rather than becoming a route nothing can ever match. */ - it("refuses a manifest whose hierarchy does not hold together", () => { + it("refuses a tree that does not hold together", () => { expect(() => - pluginRouteSpecs( - [route({ id: "plugin:frame", kind: "layout", routeId: "frame" })], - registryOf("plugin:frame"), - ), - ).toThrow(/layout with no routes inside it/); + pluginRouteSpecs([ + { + pluginId: "plugin", + routes: definePluginRoutes([ + layout("/frame", { component: lazyPage(), children: [] }), + ]), + }, + ]), + ).toThrow(/layout with no `children`/); + }); + + it("refuses a plugin whose routes are not a tree at all", () => { + expect(() => + pluginRouteSpecs([ + { + pluginId: "plugin", + routes: [{ path: "/frame" }] as unknown as ReturnType< + typeof definePluginRoutes + >, + }, + ]), + ).toThrow(/page\(\), layout\(\) or index\(\)/); }); }); -describe("the tree a nested manifest describes", () => { +describe("the tree a nested declaration describes", () => { it("orders parents before their children, so one pass can build them", () => { expect(guideSpecs().map(spec => spec.route.id)).toEqual([ - "plugin:guide", - "plugin:guide-index", - "plugin:guide-topic", + GUIDE, + GUIDE_INDEX, + GUIDE_TOPIC, ]); }); it("names each route's parent by the same global id everything else uses", () => { - expect(byId("plugin:guide").parentId).toBeNull(); - expect(byId("plugin:guide-index").parentId).toBe("plugin:guide"); - expect(byId("plugin:guide-topic").parentId).toBe("plugin:guide"); + expect(byId(GUIDE).parentId).toBeNull(); + expect(byId(GUIDE_INDEX).parentId).toBe(GUIDE); + expect(byId(GUIDE_TOPIC).parentId).toBe(GUIDE); }); /** - * A manifest spells every path out in full - which is what makes a collision - * visible in a diff - and a router composes a child's path onto its parent's. - * This is the one place the first form becomes the second. + * A flattened route carries its path in full - which is what makes a collision + * visible - and a router composes a child's path onto its parent's. This is + * the one place the first form becomes the second. */ it("gives a child only what it adds to its parent's path", () => { - expect(byId("plugin:guide").path).toBe("/example/guide"); - expect(byId("plugin:guide-topic").path).toBe("/$topic"); + expect(byId(GUIDE).path).toBe("/example/guide"); + expect(byId(GUIDE).route.path).toBe("/example/guide"); + expect(byId(GUIDE_TOPIC).path).toBe("/$topic"); + expect(byId(GUIDE_TOPIC).route.path).toBe("/example/guide/:topic"); }); it("gives a layout's index route the router's index path", () => { - expect(byId("plugin:guide-index").path).toBe("/"); - expect(byId("plugin:guide-index").isIndex).toBe(true); - expect(byId("plugin:guide-topic").isIndex).toBe(false); + expect(byId(GUIDE_INDEX).path).toBe("/"); + expect(byId(GUIDE_INDEX).isIndex).toBe(true); + expect(byId(GUIDE_TOPIC).isIndex).toBe(false); }); }); @@ -186,11 +151,11 @@ describe("the namespaces a route's provider mounts", () => { * is what every shared VitNode component translates through. */ it("inherits its layouts' namespaces and adds the global set", () => { - expect(byId("plugin:guide-index").namespaces).toEqual([ + expect(byId(GUIDE_INDEX).namespaces).toEqual([ "core.global", "plugin.guide", ]); - expect(byId("plugin:guide-topic").namespaces).toEqual([ + expect(byId(GUIDE_TOPIC).namespaces).toEqual([ "core.global", "plugin.guide", "plugin.topic", @@ -205,25 +170,20 @@ describe("the namespaces a route's provider mounts", () => { */ it("stays empty for a route that declares none", () => { expect( - pluginRouteSpecs([route()], registryOf("plugin:page"))[0].namespaces, + specsOf(page("/page", { component: lazyPage() }))[0].namespaces, ).toEqual([]); }); }); -describe("the breadcrumb chain", () => { - /** - * Stage 8's rule is "the deepest matched route that declared a crumb wins", - * and inside a plugin subtree it cannot be decided by `staticData` alone - - * whether a route declares a crumb is in its module, which has not been - * fetched when `staticData` is written. The chain is what lets the runtime - * apply the same rule once the modules have arrived. - */ - it("is the route itself, then its layouts, deepest first", () => { - expect(byId("plugin:guide-topic").breadcrumbChain).toEqual([ - "plugin:guide-topic", - "plugin:guide", - ]); - expect(byId("plugin:guide").breadcrumbChain).toEqual(["plugin:guide"]); +describe("the eager search schema", () => { + it("is on the spec of the route that declared it, and nowhere else", () => { + const search = () => ({ page: 1 }); + const specs = specsOf( + page("/browse", { component: lazyPage(), search }), + page("/read", { component: lazyPage() }), + ); + + expect(specs.map(spec => spec.validateSearch)).toEqual([search, null]); }); }); diff --git a/packages/vitnode/src/tanstack/plugin-routes/specs.ts b/packages/vitnode/src/tanstack/plugin-routes/specs.ts index 70f72042a..4ff94d4b3 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/specs.ts +++ b/packages/vitnode/src/tanstack/plugin-routes/specs.ts @@ -1,13 +1,13 @@ import type { - PluginRouteModuleLoader, - PluginRouteModuleRegistry, - PluginRouteSearchRegistry, + PluginRoute, + PluginRouteDeclarationSource, + PluginRouteNode, PluginRouteSearchValidator, -} from "@/framework/plugin-routes"; -import type { PluginRoute, PluginRouteNode } from "@/routing"; +} from "@/routing"; import { buildPluginRouteGraph, + compilePluginRouteTrees, normalizeNamespaceList, pluginRouteNamespaces, toTanStackRoutePath, @@ -22,29 +22,18 @@ import { pluginRouteModuleRef } from "./module-ref"; * One plugin route, as everything the router construction needs and nothing it * has to work out for itself. * - * Pure data plus one memoised loader, derived from the two generated files by - * {@link pluginRouteSpecs}. Everything here is decided *before* a single byte of - * a plugin's code is fetched, which is the whole reason the manifest and the - * module are two different things: the path a route claims, the parent it hangs - * from, the strings it needs and the visitor it is offered to are all answers - * the runtime must have in order to decide whether to fetch the chunk at all. + * Pure data plus one memoised loader, derived from the plugins' own route trees + * by {@link pluginRouteSpecs}. Everything here is decided *before* a single byte + * of a plugin's page is fetched, which is the whole reason a tree and its + * modules are two different things: the path a route claims, the parent it hangs + * from, the strings it needs and the visitor it is offered to are all answers the + * runtime must have in order to decide whether to fetch the chunk at all. * - * The one thing that is *not* here is behaviour. A `head`, a `load` and a - * `parseSearch` live in the module and arrive with it - see `./module-ref`. + * The one thing that is *not* here is behaviour. A `head`, a `load`, a + * `breadcrumb` and a `parseSearch` live in the lazily imported module and arrive + * with it - see `./module-ref`. */ export interface PluginRouteSpec { - /** - * The route ids that may own this route's breadcrumb, deepest first. - * - * Its own id, then its layouts'. Stage 8's rule is "the deepest matched route - * that declares a crumb wins", and inside a plugin subtree that cannot be - * decided by `staticData` alone: whether a route declares a crumb is in its - * *module*, which has not been fetched when `staticData` is written. So every - * plugin route declares one crumb - a component that walks this chain at - * render time, by which point the modules have arrived - and the rule comes - * out the same as if each route had declared its own. - */ - breadcrumbChain: string[]; /** * This route claims exactly its parent layout's URL - it is that layout's * index route, and its {@link PluginRouteSpec.path} is `"/"`. @@ -56,7 +45,7 @@ export interface PluginRouteSpec { * The exact namespace list this route's loader warms and its provider mounts, * or empty when it needs none. * - * Its own namespaces plus every layout's above it, because a route's provider + * Its own `messages` plus every layout's above it, because a route's provider * *replaces* the shell's rather than adding to it - a page inside a layout * that declared `@vitnode/blog` renders the layout's frame too, and would lose * those strings by naming only its own. @@ -65,7 +54,7 @@ export interface PluginRouteSpec { * same reason: it is what every shared VitNode component translates through, * and a plugin author who forgot it would get a page of missing-message keys * with nothing to point at. Empty stays empty, though - a route that declares - * no namespaces mounts no provider at all and reads the root's, which is + * no messages mounts no provider at all and reads the root's, which is * already exactly `core.global`. */ namespaces: string[]; @@ -79,10 +68,10 @@ export interface PluginRouteSpec { * `/blog/$slug` for a root, `/comments` for a child of `/blog/:slug`, and `/` * for a layout's index route. * - * Relative because that is what a router composes: a child's declared path is - * joined to its parent's. The manifest spells every path out in full, which is - * what makes a collision visible in a diff, and `buildPluginRouteGraph` is the - * one place that turns the one form into the other. + * Relative because that is what a router composes, and it is the same form the + * plugin author wrote: a nested route declares what it adds to its parent, and + * `flattenPluginRoutes` is what turns that into the full canonical path a + * collision can be seen in. */ path: string; /** The manifest entry this spec was built from, unchanged. */ @@ -94,9 +83,9 @@ export interface PluginRouteSpec { * The one thing on a spec that is a function rather than data, and the one * thing that could not wait for the module: a router's `validateSearch` runs * during path matching, before any chunk is fetched. A route earns it by - * declaring a `searchEntry` in its manifest, which the build turns into a - * static import - so by the time this spec exists the function is simply - * here. See `PluginRouteDefinition.searchEntry`. + * declaring `search` in its plugin's `routes.ts`, which is a module the host + * imports statically - so by the time this spec exists the function is simply + * here. */ validateSearch: null | PluginRouteSearchValidator; } @@ -118,31 +107,15 @@ export const pluginRouteMessageNamespaces = ( return normalizeNamespaceList([GLOBAL_NAMESPACE, ...declared]); }; -/** The ids that may own a route's crumb: its own, then its layouts', deepest first. */ -const breadcrumbChainOf = (node: PluginRouteNode): string[] => { - const chain: string[] = []; - - for ( - let current: null | PluginRouteNode = node; - current !== null; - current = current.parent - ) { - chain.push(current.route.id); - } - - return chain; -}; - /** * What a plugin route re-runs its loader for. * - * A plugin route registers no router-level `validateSearch` of its own - see - * `./mount`, which explains why it cannot - so the search a match carries is - * whatever was in the query string. This turns that into something a match id - * can be built from without depending on the order somebody happened to type - * the parameters in: `?b=2&a=1` and `?a=1&b=2` are one page, and a route that - * treated them as two would re-run its loader and remount its component every - * time a visitor swapped them. + * A plugin route that declares no `search` registers no router-level + * `validateSearch`, so the search a match carries is whatever was in the query + * string. This turns that into something a match id can be built from without + * depending on the order somebody happened to type the parameters in: `?b=2&a=1` + * and `?a=1&b=2` are one page, and a route that treated them as two would re-run + * its loader and remount its component every time a visitor swapped them. * * Keys sorted, `undefined` values dropped, prototype untouched - * `Object.fromEntries` defines own properties, so a `__proto__` parameter is an @@ -161,90 +134,50 @@ export const pluginRouteSearchDeps = ( }; /** - * The manifest and the registry, joined by route id and read as the tree they - * describe. - * - * Three things happen here, and each of them is the last chance to catch a - * different mistake: + * Every configured plugin's route tree, read as the routes a router can mount. * - * **The graph is rebuilt.** `buildPluginRouteGraph` is the same function that - * validated the hierarchy while the app was built, run again over the generated - * manifest - so the tree the runtime mounts is provably the tree the build - * checked, and the parent of every route is decided in exactly one place. It - * also returns the nodes **parents before children**, which is what lets the - * construction below be a single pass with no lookahead. + * Two things happen here, and both are the last chance to catch a different + * mistake: * - * **The join is checked in both directions.** Both generated files key on the - * manifest layer's own `<pluginId>:<routeId>`, so no translation is needed - and - * a route in one file and not the other means the two are out of step, which is - * a stale generated file somebody committed or a half-finished build. It fails - * here, naming the route, rather than becoming a 404 for a page that is - * definitely installed. + * **The trees are flattened and validated.** `compilePluginRouteTrees` is the + * same function the build ran over the same declarations - so the routes the + * runtime mounts are provably the routes the build checked, and a plugin built + * against an older VitNode fails here, naming the plugin, rather than becoming a + * page that renders nothing. * - * **Every path is converted once.** `:slug` in the manifest, `$slug` in the + * **Every path is converted once.** `:slug` in a declaration, `$slug` in the * router, and a child's path reduced to what it adds to its parent's. * - * Pure apart from the memo each spec carries: no route is created, no module is - * imported, and nothing here knows what a router is. + * Pure apart from the memo each spec carries: no route is created, no page + * module is imported, and nothing here knows what a router is. */ export const pluginRouteSpecs = ( - manifest: readonly PluginRoute[], - registry: PluginRouteModuleRegistry, - searchRegistry: PluginRouteSearchRegistry = {}, + sources: readonly PluginRouteDeclarationSource[], ): PluginRouteSpec[] => { + const { components, manifest, searchValidators } = + compilePluginRouteTrees(sources); const graph = buildPluginRouteGraph(manifest); - const specs = graph.nodes.map(node => { + return graph.nodes.map(node => { const { route } = node; - const load: PluginRouteModuleLoader | undefined = registry[route.id]; + const component = components.get(route.id); - if (!load) { + if (!component) { throw new Error( - `[VitNode plugin routes] Plugin route "${route.id}" is in the route manifest but has no module in the registry. Regenerate \`src/plugin-routes.gen.ts\` - the two generated files are out of step.`, - ); - } - - const validateSearch = searchRegistry[route.id] ?? null; - - // The same both-directions check the module registry gets, and for the same - // reason: a route that declared a `searchEntry` and has no schema here would - // silently lose its `validateSearch` - the router would match, the loader - // would run, and the page would read a query string nobody normalised. A - // schema with no declaration is the other half of one stale generated file. - if (route.searchEntry !== null && validateSearch === null) { - throw new Error( - `[VitNode plugin routes] Plugin route "${route.id}" declares the search entry "${route.searchEntry}" but has no schema in the registry. Regenerate \`src/plugin-routes.gen.ts\` - the two generated files are out of step.`, - ); - } - - if (route.searchEntry === null && validateSearch !== null) { - throw new Error( - `[VitNode plugin routes] The registry has a search schema for "${route.id}", which declares no \`searchEntry\`. Regenerate \`src/plugin-routes.gen.ts\` - the two generated files are out of step.`, + `[VitNode plugin routes] Plugin route "${route.id}" has no component. Every route in a \`definePluginRoutes\` tree declares one, so this is a VitNode bug rather than something a plugin can cause.`, ); } return { - breadcrumbChain: breadcrumbChainOf(node), isIndex: node.isIndex, - module: pluginRouteModuleRef(load, route.id), + module: pluginRouteModuleRef(component.load, route.id), namespaces: pluginRouteMessageNamespaces(node), parentId: node.parent?.route.id ?? null, path: toTanStackRoutePath( node.parent === null ? route.segments : node.relativeSegments, ), route, - validateSearch, + validateSearch: searchValidators.get(route.id) ?? null, } satisfies PluginRouteSpec; }); - - const claimed = new Set(manifest.map(route => route.id)); - const orphans = Object.keys(registry).filter(key => !claimed.has(key)); - - if (orphans.length > 0) { - throw new Error( - `[VitNode plugin routes] The registry has modules for routes that are not in the route manifest: ${orphans.join(", ")}. Regenerate \`src/plugin-route-manifest.gen.ts\` - the two generated files are out of step.`, - ); - } - - return specs; }; diff --git a/packages/vitnode/src/tanstack/routes/admin/advanced.tsx b/packages/vitnode/src/tanstack/routes/admin/advanced.tsx index 0561b9301..0624c19c3 100644 --- a/packages/vitnode/src/tanstack/routes/admin/advanced.tsx +++ b/packages/vitnode/src/tanstack/routes/admin/advanced.tsx @@ -4,7 +4,7 @@ import { useCallback } from "react"; import type { AdminScreenContext } from "../../admin/screen"; import type { CoreRouteFactory } from "../types"; -import { AdminBreadcrumb } from "../../admin/breadcrumb"; +import { adminBreadcrumb } from "../../admin/breadcrumb"; import { loadAdminCronRoute } from "../../admin/cron/route"; import { cronRouteParams, @@ -56,7 +56,7 @@ const cronRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { pendingComponent: TablePendingSkeleton, validateSearch: normalizeCronRouteSearch, staticData: { - breadcrumb: <AdminBreadcrumb segments={["core", "advanced", "cron"]} />, + breadcrumb: adminBreadcrumb({ segments: ["core", "advanced", "cron"] }), }, }); @@ -122,7 +122,7 @@ const queueRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { pendingComponent: TablePendingSkeleton, validateSearch: normalizeQueueRouteSearch, staticData: { - breadcrumb: <AdminBreadcrumb segments={["core", "advanced", "queue"]} />, + breadcrumb: adminBreadcrumb({ segments: ["core", "advanced", "queue"] }), }, }); @@ -185,7 +185,7 @@ const searchIndexRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { pendingComponent: TablePendingSkeleton, validateSearch: normalizeSearchIndexRouteSearch, staticData: { - breadcrumb: <AdminBreadcrumb segments={["core", "advanced", "search"]} />, + breadcrumb: adminBreadcrumb({ segments: ["core", "advanced", "search"] }), }, }); diff --git a/packages/vitnode/src/tanstack/routes/admin/content.tsx b/packages/vitnode/src/tanstack/routes/admin/content.tsx index 9188a6e41..1926c93e9 100644 --- a/packages/vitnode/src/tanstack/routes/admin/content.tsx +++ b/packages/vitnode/src/tanstack/routes/admin/content.tsx @@ -6,6 +6,7 @@ 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"; @@ -140,7 +141,7 @@ export const contentAdminRoute: CoreRouteFactory<CoreAdminRouteContext> = ({ * router, which is what makes the hook legal. */ staticData: { - breadcrumb: <ContentAdminBreadcrumb />, + breadcrumb: breadcrumbGroup(ContentAdminBreadcrumb), }, component: lazyRouteComponent(async () => { const [{ ContentAdminScreenContent }, registry] = await Promise.all([ diff --git a/packages/vitnode/src/tanstack/routes/admin/index.tsx b/packages/vitnode/src/tanstack/routes/admin/index.tsx index e97dc67ae..2f624eb93 100644 --- a/packages/vitnode/src/tanstack/routes/admin/index.tsx +++ b/packages/vitnode/src/tanstack/routes/admin/index.tsx @@ -64,14 +64,14 @@ const CORE_ADMIN_ROUTES: CoreRouteFactory<CoreAdminRouteContext>[] = [ * { loadContentRegistry, mountUnder: adminShellRoute, pageHead }, * ) * - * ## Why not through the plugin route manifest + * ## Why not declared as plugin routes * * Because these screens need options a lazily-imported module cannot provide. * `validateSearch` runs during path matching, before any chunk is fetched, and * an AdminCP list keeps its whole state in the query string - `?page=999` is * clamped and *redirected* before anything renders, which no loader-time * normaliser can do. A splat route (`/admin/content/$`) is not representable in - * the manifest's path grammar either. Core is not a third-party package and does + * a plugin route's path grammar either. Core is not a third-party package and does * not need that layer's guarantees about untrusted plugins; what it needs is the * router's own option set, which is what a code-based route is. * diff --git a/packages/vitnode/src/tanstack/routes/admin/staff.tsx b/packages/vitnode/src/tanstack/routes/admin/staff.tsx index 771c18c5d..ea7884731 100644 --- a/packages/vitnode/src/tanstack/routes/admin/staff.tsx +++ b/packages/vitnode/src/tanstack/routes/admin/staff.tsx @@ -20,6 +20,7 @@ import { normalizeStaffRouteSearch, staffRouteParams, } from "../../admin/staff/route-search"; +import { breadcrumbGroup } from "../../breadcrumb/model"; import { FormPendingSkeleton, TablePendingSkeleton } from "../../pending"; import { routeContext, routeSearch } from "../types"; @@ -84,12 +85,14 @@ const staffListRoute = validateSearch: normalizeStaffRouteSearch, pendingComponent: TablePendingSkeleton, /** - * A component rather than `<AdminBreadcrumb segments={...}>`, because two - * of its crumbs are not in the sidebar under the spellings the page uses: - * `/admin/core/staff` is a nav *group* with no page of its own. + * Its own component rather than `adminBreadcrumb({ segments })`, because + * two of its crumbs are not in the sidebar under the spellings the page + * uses: `/admin/core/staff` is a nav *group* with no page of its own. */ staticData: { - breadcrumb: <AdminStaffBreadcrumbContent type={type} />, + breadcrumb: breadcrumbGroup(function StaffBreadcrumb() { + return <AdminStaffBreadcrumbContent type={type} />; + }), }, }); @@ -165,7 +168,9 @@ const staffCreateRoute = path, pendingComponent: FormPendingSkeleton, staticData: { - breadcrumb: <AdminStaffCreateBreadcrumbContent type={type} />, + breadcrumb: breadcrumbGroup(function StaffCreateBreadcrumb() { + return <AdminStaffCreateBreadcrumbContent type={type} />; + }), }, }); @@ -226,7 +231,9 @@ const staffEditRoute = path, pendingComponent: FormPendingSkeleton, staticData: { - breadcrumb: <AdminStaffEditBreadcrumbContent type={type} />, + breadcrumb: breadcrumbGroup(function StaffEditBreadcrumb() { + return <AdminStaffEditBreadcrumbContent type={type} />; + }), }, }); diff --git a/packages/vitnode/src/tanstack/routes/admin/system.tsx b/packages/vitnode/src/tanstack/routes/admin/system.tsx index 22a90405d..53d547579 100644 --- a/packages/vitnode/src/tanstack/routes/admin/system.tsx +++ b/packages/vitnode/src/tanstack/routes/admin/system.tsx @@ -4,7 +4,7 @@ import { useCallback } from "react"; import type { AdminScreenContext } from "../../admin/screen"; import type { CoreRouteFactory } from "../types"; -import { AdminBreadcrumb } from "../../admin/breadcrumb"; +import { adminBreadcrumb } from "../../admin/breadcrumb"; import { loadAdminDebugRoute } from "../../admin/debug/route"; import { debugLogsRouteParams, @@ -49,7 +49,7 @@ const filesRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { pendingComponent: TablePendingSkeleton, validateSearch: normalizeAdminFilesRouteSearch, staticData: { - breadcrumb: <AdminBreadcrumb segments={["core", "system", "files"]} />, + breadcrumb: adminBreadcrumb({ segments: ["core", "system", "files"] }), }, }); @@ -109,9 +109,9 @@ const integrationsRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { path: "/admin/core/system/integrations", pendingComponent: CardsPendingSkeleton, staticData: { - breadcrumb: ( - <AdminBreadcrumb segments={["core", "system", "integrations"]} /> - ), + breadcrumb: adminBreadcrumb({ + segments: ["core", "system", "integrations"], + }), }, }); diff --git a/packages/vitnode/src/tanstack/routes/admin/users.tsx b/packages/vitnode/src/tanstack/routes/admin/users.tsx index 0a7afc37d..f9017687d 100644 --- a/packages/vitnode/src/tanstack/routes/admin/users.tsx +++ b/packages/vitnode/src/tanstack/routes/admin/users.tsx @@ -4,7 +4,7 @@ import { useCallback } from "react"; import type { AdminScreenContext } from "../../admin/screen"; import type { CoreRouteFactory } from "../types"; -import { AdminBreadcrumb } from "../../admin/breadcrumb"; +import { adminBreadcrumb } from "../../admin/breadcrumb"; import { loadAdminRolesRoute } from "../../admin/roles/route"; import { normalizeRolesRouteSearch, @@ -17,6 +17,7 @@ import { normalizeUsersRouteSearch, usersRouteParams, } from "../../admin/users/route-search"; +import { breadcrumbGroup } from "../../breadcrumb/model"; import { FormPendingSkeleton, TablePendingSkeleton } from "../../pending"; import { routeContext, routeSearch } from "../types"; @@ -63,7 +64,7 @@ const usersListRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { pendingComponent: TablePendingSkeleton, validateSearch: normalizeUsersRouteSearch, staticData: { - breadcrumb: <AdminBreadcrumb segments={["core", "users"]} />, + breadcrumb: adminBreadcrumb({ segments: ["core", "users"] }), }, }); @@ -134,7 +135,7 @@ const rolesRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { pendingComponent: TablePendingSkeleton, validateSearch: normalizeRolesRouteSearch, staticData: { - breadcrumb: <AdminBreadcrumb segments={["core", "users", "roles"]} />, + breadcrumb: adminBreadcrumb({ segments: ["core", "users", "roles"] }), }, }); @@ -208,7 +209,9 @@ const userRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { path: "/admin/core/users/$id", pendingComponent: FormPendingSkeleton, staticData: { - breadcrumb: <AdminUserBreadcrumbContent />, + breadcrumb: breadcrumbGroup(function AdminUserBreadcrumb({ params }) { + return <AdminUserBreadcrumbContent params={params} />; + }), }, }); diff --git a/packages/vitnode/src/tanstack/routes/main/index.tsx b/packages/vitnode/src/tanstack/routes/main/index.tsx index 75cd54652..c496073ec 100644 --- a/packages/vitnode/src/tanstack/routes/main/index.tsx +++ b/packages/vitnode/src/tanstack/routes/main/index.tsx @@ -153,7 +153,7 @@ const authenticatedContainer = (parentRoute: AnyRoute): AnyRoute => * pageHead, * }) * - * ## Why not through the plugin route manifest + * ## Why not declared as plugin routes * * Because these need options a lazily-imported module cannot provide. * `validateSearch` runs during path matching, before any chunk is fetched, and diff --git a/packages/vitnode/src/tanstack/routes/main/settings.tsx b/packages/vitnode/src/tanstack/routes/main/settings.tsx index 9edc46fdc..816ff1251 100644 --- a/packages/vitnode/src/tanstack/routes/main/settings.tsx +++ b/packages/vitnode/src/tanstack/routes/main/settings.tsx @@ -26,17 +26,13 @@ import { import { routeContext } from "../types"; /** - * The settings trail. + * One crumb of the settings trail: the frame's own name, or a panel's. * - * Two things, and the rest is `SettingsBreadcrumbContent`: the strings this - * subtree renders in - the same set every settings route warms - and the link - * component. `RouterLink` is passed rather than defaulted because the crumb - * itself lives in `views/`, which is shared with hosts on other frameworks and - * may not import a router. - * - * The trail is derived from the shared navigation model rather than written - * here, so a panel's crumb and its menu entry cannot drift into two spellings of - * the same path. + * The frame declares the first and each panel declares its own, so the shell + * renders `Settings / Devices` from two routes rather than from one route that + * knew about both. All this adds to `SettingsBreadcrumbContent` is the strings it + * renders in - the same set every settings route warms - because the shell draws + * the breadcrumb *above* the outlet, outside the provider the panel mounts. * * The crumb itself is imported straight from `views/` rather than through * `../../settings`: that barrel also re-exports the frame and both panel @@ -47,7 +43,7 @@ import { routeContext } from "../types"; */ const SettingsBreadcrumb = ({ navKey }: { navKey?: SettingsNavKey }) => ( <RouteMessages namespaces={SETTINGS_NAMESPACES}> - <SettingsBreadcrumbContent LinkComponent={RouterLink} navKey={navKey} /> + <SettingsBreadcrumbContent navKey={navKey} /> </RouteMessages> ); @@ -68,8 +64,8 @@ const SettingsBreadcrumb = ({ navKey }: { navKey?: SettingsNavKey }) => ( * `/settings` on a phone is looking at a menu; redirecting would skip the menu * entirely and leave the mobile back link as the only way to reach it. On a * desktop the two URLs look identical. They differ in exactly one visible way, - * which is the breadcrumb: the index declares none and inherits the frame's - * single crumb, while `/settings/overview` is two crumbs deep. + * which is the breadcrumb: the index declares none, so the trail is the frame's + * single crumb, while `/settings/overview` adds its own and is two deep. * * ## Every panel body is behind a literal dynamic import * @@ -115,8 +111,8 @@ export const settingsRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { <FormPendingSkeleton className="container mx-auto" /> ), /** - * The trail for `/settings` itself - a single "Settings" crumb. A panel - * declares its own two-crumb trail and wins by being deeper. + * The first crumb of the trail - "Settings", linking to this frame's own + * URL. Each panel adds its own after it. */ staticData: { breadcrumb: <SettingsBreadcrumb /> }, }); diff --git a/packages/vitnode/src/views/admin/layouts/breadcrumb/breadcrumb-admin-content.tsx b/packages/vitnode/src/views/admin/layouts/breadcrumb/breadcrumb-admin-content.tsx index 0a734a5ca..c97feebca 100644 --- a/packages/vitnode/src/views/admin/layouts/breadcrumb/breadcrumb-admin-content.tsx +++ b/packages/vitnode/src/views/admin/layouts/breadcrumb/breadcrumb-admin-content.tsx @@ -1,6 +1,6 @@ import type { AuthLinkComponent } from "@/views/auth/auth-link"; -import { BreadcrumbRenderContent } from "@/views/breadcrumb/breadcrumb-render-content"; +import { BreadcrumbCrumbItems } from "@/views/breadcrumb/breadcrumb-render-content"; import type { NavAdminParent } from "../sidebar/nav/nav-model"; @@ -36,11 +36,14 @@ export interface BreadcrumbAdminContentProps { * * ## What decides which trail is shown * - * Not this component. In Next.js the `@breadcrumb` parallel route whose folder - * matches the page renders it with that page's segments; in TanStack Start the - * matched route declares it as `staticData.breadcrumb` and the shell renders the - * deepest declaration. Both are route-owned - there is no map from pathname to - * trail anywhere, and nothing registers into one. + * Not this component. The matched route declares it as `staticData.breadcrumb`, + * and because one AdminCP route is usually several segments deep it declares a + * `breadcrumbGroup` - so these items go straight into the shell's own list, + * separators and all. There is no map from pathname to trail anywhere, and + * nothing registers into one. + * + * Items rather than a list of its own: the shell renders one navigation landmark + * and one `<ol>` for the whole trail, whichever routes contributed to it. */ export const BreadcrumbAdminContent = ({ labels, @@ -67,11 +70,5 @@ export const BreadcrumbAdminContent = ({ crumbs[crumbs.length - 1].label = overrideLastLabel; } - return ( - <BreadcrumbRenderContent - crumbs={crumbs} - LinkComponent={LinkComponent} - scrollable - /> - ); + return <BreadcrumbCrumbItems crumbs={crumbs} LinkComponent={LinkComponent} />; }; diff --git a/packages/vitnode/src/views/auth/auth-boundaries.test.ts b/packages/vitnode/src/views/auth/auth-boundaries.test.ts index fff526af0..6d033794c 100644 --- a/packages/vitnode/src/views/auth/auth-boundaries.test.ts +++ b/packages/vitnode/src/views/auth/auth-boundaries.test.ts @@ -24,7 +24,7 @@ const here = dirname(fileURLToPath(import.meta.url)); * visible until somebody tries. */ const SHARED = { - breadcrumbTrail: join(here, "../breadcrumb/breadcrumb-main-content.tsx"), + breadcrumbTrail: join(here, "../breadcrumb/breadcrumb-trail-content.tsx"), card: join(here, "sign-in/sign-in-content.tsx"), changePasswordForm: join( here, diff --git a/packages/vitnode/src/views/auth/settings/settings-breadcrumb-content.tsx b/packages/vitnode/src/views/auth/settings/settings-breadcrumb-content.tsx index a9a111b3f..cf1e8c4c5 100644 --- a/packages/vitnode/src/views/auth/settings/settings-breadcrumb-content.tsx +++ b/packages/vitnode/src/views/auth/settings/settings-breadcrumb-content.tsx @@ -1,55 +1,31 @@ import { useTranslations } from "use-intl"; -import type { AuthLinkComponent } from "../auth-link"; import type { SettingsNavKey } from "./settings-nav"; -import { BreadcrumbMainContent } from "../../breadcrumb/breadcrumb-main-content"; -import { SETTINGS_ROOT_HREF, settingsNavHref } from "./settings-nav"; - export interface SettingsBreadcrumbContentProps { - LinkComponent: AuthLinkComponent; - /** The panel this crumb is for. Absent is the settings root's own trail. */ + /** The panel this crumb is for. Absent is the settings frame's own crumb. */ navKey?: SettingsNavKey; } /** - * The settings breadcrumb, framework-free. + * One crumb of the settings trail - a label, and nothing else. * - * The trail is derived rather than written down: the href comes from the - * navigation model, the segments come from the href, and the labels are keyed by - * the same href. That matters because `resolveMainBreadcrumb` rebuilds a - * cumulative path per segment - two independent spellings of `/settings/devices` - * would silently stop matching, and the crumb would render as a raw segment. + * The frame at `/settings` contributes "Settings" and each panel contributes its + * own name, so `/settings/devices` reads `Settings / Devices` without either + * route knowing how deep it is. The shell owns the separator, the link and the + * `aria-current`; a crumb that built its own href would need a router, which is + * exactly what a view may not import. * * The strings are `core.auth.settings`, the same namespace the panels and the * navigation read, so a host has one set to warm rather than a second one for - * the crumb. - * - * The link is handed in for the same reason `SettingsNavContent` takes one: - * Next.js renders this into the `@breadcrumb` parallel slot with `next-intl`'s - * locale-aware `Link`, and a TanStack Start host renders it into the shell's - * breadcrumb area through `staticData.breadcrumb` with the router's own. + * the crumb - and the panel names come from `…settings.nav`, so the trail and the + * menu name a panel identically in every language. */ export const SettingsBreadcrumbContent = ({ - LinkComponent, navKey, }: SettingsBreadcrumbContentProps) => { const t = useTranslations("core.auth.settings"); const tNav = useTranslations("core.auth.settings.nav"); - const href = navKey ? settingsNavHref(navKey) : SETTINGS_ROOT_HREF; - - return ( - <BreadcrumbMainContent - labels={{ - [SETTINGS_ROOT_HREF]: t("title"), - ...(navKey ? { [href]: tNav(navKey) } : {}), - }} - LinkComponent={LinkComponent} - // Derived from the href for the same reason the labels are keyed by it: - // `resolveMainBreadcrumb` rebuilds a cumulative path per segment, and two - // independent spellings of the same route would silently stop matching. - segments={href.split("/").filter(Boolean)} - /> - ); + return <>{navKey === undefined ? t("title") : tNav(navKey)}</>; }; diff --git a/packages/vitnode/src/views/breadcrumb/breadcrumb-main-content.tsx b/packages/vitnode/src/views/breadcrumb/breadcrumb-main-content.tsx deleted file mode 100644 index e24e66b34..000000000 --- a/packages/vitnode/src/views/breadcrumb/breadcrumb-main-content.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import type { AuthLinkComponent } from "../auth/auth-link"; - -import { BreadcrumbRenderContent } from "./breadcrumb-render-content"; -import { resolveMainBreadcrumb } from "./resolve-main-breadcrumb"; - -export interface BreadcrumbMainContentProps { - labels?: Record<string, string>; - LinkComponent: AuthLinkComponent; - overrideLastLabel?: string; - segments: string[]; -} - -/** - * The public site's breadcrumb, framework-free. - * - * The same two steps `BreadcrumbMain` has always taken - path segments into - * crumbs, crumbs into markup - with the link handed in rather than imported. The - * container is here rather than at each call site so both frameworks get the - * same spacing: Next.js renders this into the `@breadcrumb` parallel slot, - * TanStack Start into the shell's breadcrumb area through - * `staticData.breadcrumb`. - */ -export const BreadcrumbMainContent = ({ - labels, - LinkComponent, - overrideLastLabel, - segments, -}: BreadcrumbMainContentProps) => { - const crumbs = resolveMainBreadcrumb(segments, labels); - - if (crumbs.length === 0) return null; - - if (overrideLastLabel) { - crumbs[crumbs.length - 1].label = overrideLastLabel; - } - - return ( - <div className="container mx-auto p-4"> - <BreadcrumbRenderContent crumbs={crumbs} LinkComponent={LinkComponent} /> - </div> - ); -}; diff --git a/packages/vitnode/src/views/breadcrumb/breadcrumb-render-content.tsx b/packages/vitnode/src/views/breadcrumb/breadcrumb-render-content.tsx index 7b36d4429..1da816020 100644 --- a/packages/vitnode/src/views/breadcrumb/breadcrumb-render-content.tsx +++ b/packages/vitnode/src/views/breadcrumb/breadcrumb-render-content.tsx @@ -1,78 +1,59 @@ import { Fragment } from "react"; import { - Breadcrumb, BreadcrumbItem, BreadcrumbLink, - BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; -import { cn } from "@/lib/utils"; import type { AuthLinkComponent } from "../auth/auth-link"; import type { BreadcrumbCrumb } from "./crumb"; /** - * A breadcrumb trail, with the one thing it cannot decide for itself handed in. + * A run of crumbs, as list items - with the one thing they cannot decide for + * themselves handed in. * - * Turning `/settings` into a navigation is the only framework-specific part of a - * breadcrumb: Next.js wants `next-intl`'s locale-aware `Link` - * (`@/lib/navigation`), TanStack Start wants the router's own. Both are a - * component taking an anchor's props, so this takes one and stops caring - and - * importing neither is what lets a TanStack Start route render the same trail - * the Next.js `@breadcrumb` slot renders. + * Items only, and no `<Breadcrumb>` wrapper: the shell renders exactly one `nav` + * and one `<ol>` for the whole trail, and every contributor's items go inside + * it. Rendering a second list here would nest a navigation landmark inside + * another one, and would put a route's crumbs in an `<ol>` of their own where a + * screen reader announces them as a separate list. * - * `AuthLinkComponent` is reused rather than redeclared: it is already "every prop - * of an anchor, plus a required `href`", which is exactly what a crumb needs and - * what `RouterLink` in `@vitnode/core/tanstack/layout` already satisfies. + * Turning `/settings` into a navigation is the only framework-specific part of a + * breadcrumb, so this takes a `LinkComponent` and stops caring. `AuthLinkComponent` + * is reused rather than redeclared: it is already "every prop of an anchor, plus + * a required `href`", which is exactly what a crumb needs and what `RouterLink` + * in `@vitnode/core/tanstack/layout` already satisfies. * - * Deliberately not a client component. It renders no hooks, and Next.js passes - * `LinkComponent` into it from a Server Component - a boundary here would turn - * that prop into something that cannot cross it. + * Deliberately not a client component. It renders no hooks and takes its link + * component as a prop, so it can be rendered from anywhere. */ -export const BreadcrumbRenderContent = ({ +export const BreadcrumbCrumbItems = ({ crumbs, LinkComponent, - scrollable, }: { - crumbs: BreadcrumbCrumb[]; + crumbs: readonly BreadcrumbCrumb[]; LinkComponent: AuthLinkComponent; - scrollable?: boolean; -}) => { - if (crumbs.length === 0) return null; - - return ( - <Breadcrumb - className={cn( - scrollable && - "no-scrollbar scroll-fade-x overflow-x-auto overscroll-x-contain", - )} - > - <BreadcrumbList - className={cn(scrollable && "flex-nowrap whitespace-nowrap")} - > - {crumbs.map((crumb, index) => ( - <Fragment key={crumb.href}> - {index > 0 && <BreadcrumbSeparator />} - <BreadcrumbItem> - {crumb.isCurrent ? ( - <BreadcrumbPage>{crumb.label}</BreadcrumbPage> - ) : crumb.isLink ? ( - <BreadcrumbLink - render={ - <LinkComponent href={crumb.href}> - {crumb.label} - </LinkComponent> - } - /> - ) : ( - <span>{crumb.label}</span> - )} - </BreadcrumbItem> - </Fragment> - ))} - </BreadcrumbList> - </Breadcrumb> - ); -}; +}) => ( + <> + {crumbs.map((crumb, index) => ( + <Fragment key={crumb.href}> + {index > 0 && <BreadcrumbSeparator />} + <BreadcrumbItem> + {crumb.isCurrent ? ( + <BreadcrumbPage>{crumb.label}</BreadcrumbPage> + ) : crumb.isLink ? ( + <BreadcrumbLink + render={ + <LinkComponent href={crumb.href}>{crumb.label}</LinkComponent> + } + /> + ) : ( + <span>{crumb.label}</span> + )} + </BreadcrumbItem> + </Fragment> + ))} + </> +); diff --git a/packages/vitnode/src/views/breadcrumb/breadcrumb-trail-content.tsx b/packages/vitnode/src/views/breadcrumb/breadcrumb-trail-content.tsx new file mode 100644 index 000000000..17fc5d57a --- /dev/null +++ b/packages/vitnode/src/views/breadcrumb/breadcrumb-trail-content.tsx @@ -0,0 +1,94 @@ +import { Fragment } from "react"; + +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; +import { cn } from "@/lib/utils"; + +import type { AuthLinkComponent } from "../auth/auth-link"; + +/** + * One contributor's place in a rendered trail. + * + * Structural rather than imported from `@vitnode/core/tanstack/breadcrumb`, + * because this file is a view: it renders the trail a router-aware shell + * collected, and may not import the router-aware half that collects it. + */ +export interface BreadcrumbTrailContentEntry { + content: React.ReactNode; + href: string; + isCurrent: boolean; + key: string; + /** The content renders its own list items, separators included. */ + spansItems: boolean; +} + +/** + * The whole breadcrumb trail: one navigation landmark, one list, one separator + * between neighbours. + * + * This is the half that owns the semantics, and the reason a route contributes a + * *label* rather than a trail: the `nav`, the ordered list, the separators and + * the `aria-current` on the last crumb are decided here, once, so every crumb in + * every shell reads the same way and a plugin never has to know it is the third + * of five. + * + * A crumb that is not the current page is a link to its own matched route's URL, + * built with the `LinkComponent` a shell hands in - which is what makes the trail + * locale-aware without a single crumb mentioning a locale. + * + * `spansItems` is the AdminCP's case: those crumbs come from one route and are + * rendered by `BreadcrumbCrumbItems`, which emits its own items into this list. + */ +export const BreadcrumbTrailContent = ({ + entries, + LinkComponent, + scrollable, +}: { + entries: readonly BreadcrumbTrailContentEntry[]; + LinkComponent: AuthLinkComponent; + scrollable?: boolean; +}) => { + if (entries.length === 0) return null; + + return ( + <Breadcrumb + className={cn( + scrollable && + "no-scrollbar scroll-fade-x overflow-x-auto overscroll-x-contain", + )} + > + <BreadcrumbList + className={cn(scrollable && "flex-nowrap whitespace-nowrap")} + > + {entries.map((entry, index) => ( + <Fragment key={entry.key}> + {index > 0 && <BreadcrumbSeparator />} + {entry.spansItems ? ( + entry.content + ) : ( + <BreadcrumbItem> + {entry.isCurrent ? ( + <BreadcrumbPage>{entry.content}</BreadcrumbPage> + ) : ( + <BreadcrumbLink + render={ + <LinkComponent href={entry.href}> + {entry.content} + </LinkComponent> + } + /> + )} + </BreadcrumbItem> + )} + </Fragment> + ))} + </BreadcrumbList> + </Breadcrumb> + ); +}; diff --git a/packages/vitnode/src/views/breadcrumb/resolve-main-breadcrumb.ts b/packages/vitnode/src/views/breadcrumb/resolve-main-breadcrumb.ts deleted file mode 100644 index 78b47c276..000000000 --- a/packages/vitnode/src/views/breadcrumb/resolve-main-breadcrumb.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { BreadcrumbCrumb } from "./crumb"; - -import { humanize } from "./crumb"; - -/** - * Turns the path segments after `/` into breadcrumb crumbs for the public site. - * Unlike the AdminCP nav-based resolver, every non-current segment links to its - * own path, and labels come from the optional `labels` map (cumulative href → - * translated label) with a humanized fallback. - */ -export const resolveMainBreadcrumb = ( - segments: string[], - labels: Record<string, string> = {}, -): BreadcrumbCrumb[] => - segments.map((segment, index) => { - const href = `/${segments.slice(0, index + 1).join("/")}`; - const isCurrent = index === segments.length - 1; - const known = labels[href]; - - return { - href, - isCurrent, - isKnown: known !== undefined, - isLink: !isCurrent, - label: known ?? humanize(segment), - }; - }); diff --git a/plugins/example/src/config.tsx b/plugins/example/src/config.tsx index 0a482c45e..0ce8779b1 100644 --- a/plugins/example/src/config.tsx +++ b/plugins/example/src/config.tsx @@ -3,7 +3,7 @@ import { buildPlugin } from "@vitnode/core/lib/plugin"; import { adminContent } from "./admin/content"; import { adminNav } from "./admin/nav"; import messages from "./locales"; -import { routes } from "./routes/manifest"; +import { routes } from "./routes"; /** * Registering the content types is the whole frontend integration: the AdminCP @@ -32,7 +32,7 @@ export const examplePlugin = () => ...adminNav, contentTypes: adminContent.contentTypes, messages, - // Stage 5: the same list `routes/manifest.ts` hands the build tool, so a - // route is declared once whichever path an app reads it through. + // The same tree `routes.ts` hands the build tool, so a route is declared + // once whichever path an app reads it through. routes, }); diff --git a/plugins/example/src/locales/en.json b/plugins/example/src/locales/en.json index 843c08aff..e3ff7ae68 100644 --- a/plugins/example/src/locales/en.json +++ b/plugins/example/src/locales/en.json @@ -10,7 +10,7 @@ "desc": "A plugin page inside the AdminCP, shipped by @vitnode/example and served by the app that installed it.", "points": { "shell": "The sidebar, the breadcrumb and the admin session guard around this page are the AdminCP's - the route declared area: \"admin\" and nothing else.", - "path": "The URL is the one the route manifest spells out in full. An area chooses the frame; it never adds a prefix.", + "path": "The URL is the one the route spells out in full. An area chooses the frame; it never adds a prefix.", "nav": "The sidebar entry that led here is a separate declaration. A plugin page may have no entry, and an entry may point anywhere." } } @@ -46,7 +46,7 @@ "title": "Plugin routing guide", "desc": "A nested route shipped by @vitnode/example, served by the app that installed it.", "index": { - "intro": "This page and the frame around it are two routes declared in one plugin manifest. Neither was copied into the application - both are lazily imported from the plugin's own build output.", + "intro": "This page and the frame around it are two routes declared in one plugin's routes.ts. Neither was copied into the application - both are lazily imported from the plugin's own build output.", "points": { "nested": "A layout claims no URL; this index page claims the layout's.", "messages": "The layout declares the messages both pages render in, so they are warmed alongside the code.", diff --git a/plugins/example/src/routes/admin-example-page.tsx b/plugins/example/src/pages/admin-example-page.tsx similarity index 81% rename from plugins/example/src/routes/admin-example-page.tsx rename to plugins/example/src/pages/admin-example-page.tsx index e2e5d97fd..1826c80ae 100644 --- a/plugins/example/src/routes/admin-example-page.tsx +++ b/plugins/example/src/pages/admin-example-page.tsx @@ -5,11 +5,10 @@ import { useTranslations } from "use-intl"; * A plugin page inside the AdminCP - `area: "admin"`, and the smallest real one * that can be written. * - * It exists to prove the runtime rather than to do a job: a declaration in - * `routes/manifest.ts` becomes a generated manifest entry, a generated literal - * `import()`, a route mounted under the host's `_admin` shell, and a URL at - * `/admin/example` with the sidebar, the breadcrumb and the admin session guard - * around it. Nothing about the page itself is special, which is the point - the + * It exists to prove the runtime rather than to do a job: one `page()` in + * `routes.ts` becomes a route mounted under the host's `_admin` shell and a URL + * at `/admin/example` with the sidebar, the breadcrumb and the admin session + * guard around it, with this module in a chunk of its own. Nothing about the page itself is special, which is the point - the * contract is the same one a public page has. * * ## What is *not* here @@ -60,10 +59,9 @@ const AdminExamplePage = () => { /** * The crumb the AdminCP header renders for this page. * - * Owned by the plugin, exactly as a public plugin route's is: the deepest - * matched route that declares one wins, and the host's shell mounts whatever it - * finds. Text rather than a link - a locale-correct href needs the host's own - * link component, and a plugin route module is handed nothing to build one with. + * Owned by the plugin, exactly as a public plugin route's is: this route + * contributes one item to the trail and the shell renders it. A label rather + * than a link - the shell owns the href, the separators and the `aria-current`. */ const AdminExampleBreadcrumb = () => { const t = useTranslations("@vitnode/example.admin.overview"); diff --git a/plugins/example/src/routes/browse-page.tsx b/plugins/example/src/pages/browse-page.tsx similarity index 73% rename from plugins/example/src/routes/browse-page.tsx rename to plugins/example/src/pages/browse-page.tsx index e90e0bf92..856d19b95 100644 --- a/plugins/example/src/routes/browse-page.tsx +++ b/plugins/example/src/pages/browse-page.tsx @@ -3,13 +3,12 @@ import type { PluginRoutePageProps } from "@vitnode/core/routing"; import { definePluginRoute } from "@vitnode/core/routing"; import { useTranslations } from "use-intl"; -import type { BrowseSearch } from "./browse-page.search"; +import type { BrowseSearch } from "./browse-search"; -import { BROWSE_LAST_PAGE, BROWSE_PAGE_SIZE } from "./browse-page.search"; +import { BROWSE_LAST_PAGE, BROWSE_PAGE_SIZE } from "./browse-search"; /** - * A page whose URL *is* its state, and the reason a route may declare a - * `searchEntry`. + * A page whose URL *is* its state, and the reason a route may declare `search`. * * Its twin is `guide-topic-page.tsx`, which reads its query string through the * module's own lazy `parseSearch`. That is the right default and covers most @@ -17,9 +16,9 @@ import { BROWSE_LAST_PAGE, BROWSE_PAGE_SIZE } from "./browse-page.search"; * * This one cannot use it. The page number is not something the page reads *about* * itself - it decides which page exists at all, so it has to be validated before - * the router matches, not after. Declaring `searchEntry` in the manifest is what - * buys that: `./browse-page.search` is imported statically, the router gets a - * real `validateSearch`, and `?page=999` is clamped to the last real page before + * the router matches, not after. Declaring `search` in `routes.ts` is what buys + * that: the schema is in the initial bundle, the router gets a real + * `validateSearch`, and `?page=999` is clamped to the last real page before * anything renders. The page module itself stays in its own chunk. * * What that changes here, all of it visible in the props: @@ -32,10 +31,10 @@ import { BROWSE_LAST_PAGE, BROWSE_PAGE_SIZE } from "./browse-page.search"; * with it. */ const ITEMS = [ - "entries", - "manifests", - "namespaces", + "pages", "layouts", + "index routes", + "messages", "breadcrumbs", "loaders", "metadata", @@ -112,14 +111,24 @@ const BrowsePage = ({ /** * No `parseSearch` here, and that is not an omission. * - * A route with a `searchEntry` has already had its query string validated by the - * router, and the runtime hands that value straight through - so a `parseSearch` - * beside it would normalise a normalised value, with the module's answer - * silently disagreeing with the one the router built its links and its match id - * from. One route, one search contract. + * A route with a `search` schema has already had its query string validated by + * the router, and the runtime hands that value straight through - so a + * `parseSearch` beside it would normalise a normalised value, with the module's + * answer silently disagreeing with the one the router built its links and its + * match id from. One route, one search contract. */ export const route = definePluginRoute({ head: () => ({ title: "Browse" }), + /** + * No crumb, said on purpose. + * + * A route that declares nothing contributes nothing either - `false` is for + * saying so where a reader would otherwise wonder, which here is a page whose + * own heading is the only name it has. Every crumb a plugin does declare is + * one item of the shell's trail; the shell owns the separators, the links and + * the current-page semantics. + */ + breadcrumb: false, }); export default BrowsePage; diff --git a/plugins/example/src/routes/browse-page.search.ts b/plugins/example/src/pages/browse-search.ts similarity index 62% rename from plugins/example/src/routes/browse-page.search.ts rename to plugins/example/src/pages/browse-search.ts index 5025932e5..5cf98f99c 100644 --- a/plugins/example/src/routes/browse-page.search.ts +++ b/plugins/example/src/pages/browse-search.ts @@ -1,19 +1,18 @@ /** * The query string of `/example/browse`, validated by the **router**. * - * A module of its own, imported eagerly, and that is the whole reason it is not - * inside `browse-page.tsx`: a router's `validateSearch` runs during path - * matching, which is before any chunk is fetched, so a schema that lived in the - * lazy page module would arrive long after the URL had been matched. The route's - * manifest entry names this file as its `searchEntry`, and the app's build turns - * that into a static import. + * A module of its own, and that is the whole reason it is not inside + * `browse-page.tsx`: `routes.ts` imports it, so it is in the initial bundle - + * which is exactly what a `search` schema has to be. A router's `validateSearch` + * runs while it matches the URL, before any chunk is fetched, so a schema that + * lived in the lazy page module would arrive long after the URL had been + * matched. * * ## The contract on this file * - * It exports `validateSearch` and nothing that renders. No React, no component, - * no import of the page it belongs to - because everything reachable from here - * is in the initial bundle, which is the price of being early. Keep it to the - * schema. + * It exports the schema and nothing that renders. No React, no component, no + * import of the page it belongs to - because everything reachable from here is + * in the initial bundle with it. Keep it to the schema. * * It is also **total**. TanStack calls it during matching, on whatever somebody * typed or pasted, and a throw there is a router error screen rather than a @@ -38,9 +37,7 @@ export interface BrowseSearch { page: number; } -export const validateSearch = ( - input: Record<string, unknown>, -): BrowseSearch => { +export const browseSearch = (input: Record<string, unknown>): BrowseSearch => { const raw = input.page; const parsed = typeof raw === "number" diff --git a/plugins/example/src/routes/example-page.tsx b/plugins/example/src/pages/example-page.tsx similarity index 78% rename from plugins/example/src/routes/example-page.tsx rename to plugins/example/src/pages/example-page.tsx index e1eba869a..2d04deab4 100644 --- a/plugins/example/src/routes/example-page.tsx +++ b/plugins/example/src/pages/example-page.tsx @@ -1,5 +1,5 @@ /** - * The page `routes/manifest.ts` declares, and the first plugin route module a + * The page `routes.ts` declares, and the first plugin route module a * VitNode app bundles rather than copies. * * Zero imports, which is the point rather than an accident. It is compiled into @@ -14,8 +14,8 @@ * generated registry can rely on without being told. * * No `<main>`, and that is part of the contract rather than a style choice. A - * plugin route declares `area: "main"`, which puts it inside the application - * shell - and the shell renders the document's one `<main>` landmark. A page + * plugin route renders inside the application shell - and the shell renders the + * document's one `<main>` landmark. A page * that renders its own produces `<main><main>`: invalid HTML, and two "main" * landmarks for a screen reader to choose between. A plugin page owns its * container - its width, its padding, its vertical rhythm - and nothing above @@ -29,9 +29,10 @@ const ExamplePage = () => ( <p className="text-muted-foreground leading-relaxed text-pretty"> This page lives in <code>@vitnode/example</code> and is served by the app - that installed it. It was never copied into the app's source: the app - generated a literal import for it from the plugin's route manifest, - and the bundler put it in its own chunk. + that installed it. It was never copied into the app's source: the + plugin's <code>routes.ts</code> names this module with{" "} + <code>lazy(() => import("./pages/example-page"))</code>, and + the bundler put it in its own chunk. </p> </div> ); diff --git a/plugins/example/src/routes/guide-index-page.tsx b/plugins/example/src/pages/guide-index-page.tsx similarity index 74% rename from plugins/example/src/routes/guide-index-page.tsx rename to plugins/example/src/pages/guide-index-page.tsx index 9d462d302..f95868e78 100644 --- a/plugins/example/src/routes/guide-index-page.tsx +++ b/plugins/example/src/pages/guide-index-page.tsx @@ -3,9 +3,8 @@ import { useTranslations } from "use-intl"; /** * What `/example/guide` renders inside the layout beside it. * - * The `page.tsx` next to a `layout.tsx`, said as data: same path, `parentId` set - * to the layout's own route id. It renders no heading of its own - the frame - * owns that - and no `<main>`, because the application shell owns the document's + * The `page.tsx` next to a `layout.tsx`, said as a tree: `index()` inside the + * layout's `children`. It renders no heading of its own - the frame owns that - and no `<main>`, because the application shell owns the document's * one `main` landmark and a plugin page that rendered a second would give a * screen reader two to choose between. * @@ -15,8 +14,8 @@ import { useTranslations } from "use-intl"; * export is a component is a complete module. * * The strings come from `@vitnode/example.guide`, which the *layout* declares in - * the manifest. Namespaces are inherited by every descendant, so a page renders - * in strings it never had to ask for - and the host warms them alongside this + * `routes.ts`. Message namespaces are inherited by every descendant, so a page + * renders in strings it never had to ask for - and the host warms them alongside this * module's chunk rather than in a round trip after it. */ const GuideIndexPage = () => { diff --git a/plugins/example/src/routes/guide-layout.tsx b/plugins/example/src/pages/guide-layout.tsx similarity index 74% rename from plugins/example/src/routes/guide-layout.tsx rename to plugins/example/src/pages/guide-layout.tsx index bc1a099fe..6a0962f1c 100644 --- a/plugins/example/src/routes/guide-layout.tsx +++ b/plugins/example/src/pages/guide-layout.tsx @@ -2,12 +2,12 @@ import { definePluginRoute } from "@vitnode/core/routing"; import { useTranslations } from "use-intl"; /** - * The frame `routes/manifest.ts` declares as `kind: "layout"`. + * The frame `routes.ts` declares with `layout()`. * * A layout claims no URL of its own. It is only ever reached through one of its - * children, which is why the manifest rejects one with no children, and why the - * index page beside it - `guide-index-page` - is a separate route at the same - * path rather than something this file renders itself. + * children, which is why VitNode rejects one with no `children`, and why the + * index page beside it - `guide-index-page` - is a separate `index()` route + * rather than something this file renders itself. * * `children` arrives as a **prop**, not as an `<Outlet />` this module imports, * and that is the one thing keeping a plugin layout framework-neutral: an @@ -42,16 +42,16 @@ const GuideLayout = ({ children }: { children: React.ReactNode }) => { }; /** - * The crumb every page under this frame shows, unless one of them declares its - * own. + * This frame's own crumb - one item of the trail, not the trail. * * A component rather than an element, because the label is translated and so has - * to be able to call a hook - and because the runtime is the only thing that - * knows where in the shell to mount it. The deepest matched route that declares - * one wins, which is the same rule the host's own routes follow. + * to be able to call a hook. Every matched route contributes its own crumb in + * parent-to-child order, so `/example/guide/:topic` reads `Plugin routing guide / Layouts` + * without either route knowing about the other. * - * Text, not a link: building a locale-correct href needs the host's own link - * component, and a plugin route module is handed nothing to build one with. + * A label, not a link: the shell turns each crumb into a locale-aware link to + * its own route's URL, and adds the separators and the `aria-current`. A plugin + * route module is handed nothing to build a link with, and needs nothing. */ const GuideBreadcrumb = () => { const t = useTranslations("@vitnode/example.guide"); @@ -65,6 +65,7 @@ const GuideBreadcrumb = () => { * `head` is merged down the matched routes and the deepest wins per field, so * declaring `robots` here is how the whole subtree inherits it by saying * nothing. A child that needs a different directive overrides just that field. + * The crumb is not merged: it is this route's own item in the trail. * * `definePluginRoute` rather than a `satisfies` clause: it infers the types this * object's own members share. Nothing on this layout needs that yet - it is used diff --git a/plugins/example/src/routes/guide-topic-page.tsx b/plugins/example/src/pages/guide-topic-page.tsx similarity index 74% rename from plugins/example/src/routes/guide-topic-page.tsx rename to plugins/example/src/pages/guide-topic-page.tsx index 0f4e9eb24..909db8cf3 100644 --- a/plugins/example/src/routes/guide-topic-page.tsx +++ b/plugins/example/src/pages/guide-topic-page.tsx @@ -1,4 +1,7 @@ -import type { PluginRoutePageProps } from "@vitnode/core/routing"; +import type { + PluginRouteBreadcrumbProps, + PluginRoutePageProps, +} from "@vitnode/core/routing"; import { definePluginRoute } from "@vitnode/core/routing"; import { useTranslations } from "use-intl"; @@ -7,7 +10,7 @@ import { useTranslations } from "use-intl"; * The three seams of a route, in one file: what it accepts, what it resolves, * and what it renders. * - * This is the page `routes/manifest.ts` declares at `/example/guide/:topic`, and + * This is the page `routes.ts` declares at `:topic` inside the guide layout, and * it is here to be the smallest honest example of a route that is *about* * something. Everything it needs arrives through the contract rather than * through a router: `load` is handed the parsed `params`, the component is @@ -25,17 +28,17 @@ import { useTranslations } from "use-intl"; * *awaited*, not a second transport. */ const TOPICS: Record<string, { body: string; title: string }> = { - entries: { - body: "An entry is a package export subpath, so a plugin can move a page inside its own dist without breaking any app that installed it.", - title: "Entries", + layouts: { + body: "A layout claims no URL of its own: it frames its children, and the index() route inside it renders at the layout's own path.", + title: "Layouts", }, - manifest: { - body: "A manifest is plain data, read in Node with no framework loaded, which is why one plugin can serve any host that knows how to mount it.", - title: "The manifest", + lazy: { + body: 'lazy(() => import("./pages/...")) names the module a route renders. The import is a literal the bundler follows, so the page gets a chunk of its own and nothing runs until the route is matched.', + title: "Lazy pages", }, - namespaces: { - body: "Namespaces are declared on the route rather than inside the module, so a page's strings and a page's code are fetched at the same time instead of one after the other.", - title: "Namespaces", + messages: { + body: "Messages are declared on the route rather than inside the module, so a page's strings and a page's code are fetched at the same time instead of one after the other.", + title: "Messages", }, }; @@ -84,6 +87,19 @@ interface TopicSearch { from: string; } +/** + * The crumb this page contributes, read from what its loader resolved. + * + * One item of the trail, not the trail: the layout above contributes "Plugin + * routing guide" and this adds the topic's own title after it, so the shell + * renders `Plugin routing guide / Layouts` without either route knowing about + * the other. The props are the ones `load` and the component get - this route's + * own, and typed by the same loader. + */ +const GuideTopicBreadcrumb = ({ + loaderData, +}: PluginRouteBreadcrumbProps<Topic>) => loaderData.title; + /** * `load` above `head`, and that order is load-bearing. * @@ -124,6 +140,7 @@ export const route = definePluginRoute({ * matched routes, and a child inherits by saying nothing. */ head: ({ loaderData }) => ({ title: loaderData?.title }), + breadcrumb: GuideTopicBreadcrumb, }); export default GuideTopicPage; diff --git a/plugins/example/src/routes.ts b/plugins/example/src/routes.ts new file mode 100644 index 000000000..7937b329f --- /dev/null +++ b/plugins/example/src/routes.ts @@ -0,0 +1,144 @@ +import { + definePluginRoutes, + index, + layout, + lazy, + page, +} from "@vitnode/core/routing"; + +import { browseSearch } from "./pages/browse-search"; + +/** + * The routes this plugin contributes to whatever app installs it. + * + * A tree, and browser-safe by construction: a path, the shell it renders in, the + * message namespaces it needs, and one `lazy(() => import(...))` per page. + * Nothing here imports a router and nothing here imports a page - the `import()` + * inside `lazy` is a literal Vite follows at build time and Rollup gives its own + * chunk, and it does not run until the route is matched or preloaded. + * + * That is what lets an app read this list in Node while it builds, with no React + * in the process, and still end up with one chunk per page: the tree is the + * declaration, the imports are the code, and the two live in the same file + * without being loaded at the same time. + * + * `config.tsx` hands this same array to `buildPlugin({ routes })`, so a host that + * registers the plugin through its config declares exactly the same routes as one + * that reads this module at build time - one list, read by both paths. + * + * ## What this file is an example of + * + * Two things, deliberately separated, because a plugin only ever needs the first + * one and every plugin needs to be able to find out what the second costs. + * + * `/example` is the **minimum**: a path and a component. Its module is a bare + * `export default`. A plugin with one public page writes this and stops. + * + * `/example/guide` is the rest of the contract, as small as it can be written + * and still be real: a `layout()` that frames its children, the `index()` page + * inside it, a dynamic child, the message namespaces those three render, and - in + * the modules themselves - a loader, page metadata, a breadcrumb and a search + * contract. One tree rather than four separate examples, because what is worth + * showing is how they fit together. + * + * Note what is *not* here: no locale. `/example` and `/pl/example` are one route, + * because the host strips the prefix before matching and writes it back into + * every link it builds. A plugin declares the logical path only. + */ +export const routes = definePluginRoutes([ + page("/example", { + component: lazy(() => import("./pages/example-page")), + }), + + /** + * A page whose query string is validated by the **router**, not by its module. + * + * `search` is the one field of a route that is deliberately eager, and the + * only thing in this file with a cost: it is a function, so it lives in this + * module rather than in the page's chunk, and everything it imports is in the + * initial bundle with it. That is the price of being early - a router's + * `validateSearch` runs while it matches the URL, before any chunk is fetched, + * so a schema in the lazy page module would arrive long after the answer was + * needed. + * + * Declare it only for a page whose URL *is* its state: a paginated list whose + * `?page=999` has to be clamped, a filter whose links have to be typed. + * `/example/guide/:topic` below is the ordinary case - it reads its query + * string through its own module's lazy `parseSearch`, and adds nothing to the + * initial bundle. + */ + page("/example/browse", { + component: lazy(() => import("./pages/browse-page")), + messages: ["@vitnode/example.browse"], + search: browseSearch, + }), + + /** + * A frame, and no URL of its own. + * + * `layout()` is what makes `/example/guide` two routes rather than one + * ambiguous one: this renders the heading and the surrounding chrome, and the + * `index()` inside it renders what goes at that same path - the `layout.tsx` + * and `page.tsx` pair, said as a tree. A layout may not be a leaf, so removing + * its children is a build error rather than a route nothing can reach. + * + * The messages sit here rather than on each child. They are declared on the + * route because they have to be known *before* the page's chunk is fetched - + * the strings and the code are two requests, and a list that lived inside the + * code could only be read after downloading the page it describes. A child + * inherits every namespace its ancestors declare, so naming them once on the + * frame is what stops three routes repeating one list. + * + * Every path below is **relative** to this one. The layout's is absolute + * because it is a top-level route; a child adds what it adds, and VitNode + * joins the two - so moving this subtree is one edit rather than four. + */ + layout("/example/guide", { + component: lazy(() => import("./pages/guide-layout")), + messages: ["@vitnode/example.guide"], + children: [ + /** The page at the layout's own URL - `/example/guide`. */ + index({ + component: lazy(() => import("./pages/guide-index-page")), + }), + + /** + * A dynamic child: `:topic`, in VitNode's spelling. + * + * Neither Next's `[topic]` nor TanStack's `$topic` - the host converts. + * Relative to the layout, so the full path is `/example/guide/:topic` and + * nothing here repeats the parent's segments. + * + * It declares no messages of its own. The frame above declares the set all + * three routes render in, and a child inherits every namespace its + * ancestors declare, so this route ships one line and still has its + * strings in flight beside its chunk. + */ + page(":topic", { + component: lazy(() => import("./pages/guide-topic-page")), + }), + ], + }), + + /** + * A page in the **AdminCP**, and the whole of what that costs: one field. + * + * `area: "admin"` names the shell the page is framed by - the sidebar, the + * breadcrumb area, the command palette and the admin session guard - and the + * host mounts it under whichever route renders that shell. It does *not* put + * `/admin` in front of the path: both shells are pathless, so an area frames a + * page rather than moving it, and the URL below is written out in full. That + * is also why a `main` route at `/admin/example` would be a collision with + * this rather than a second page - one URL is one URL whichever frame draws + * it. + * + * `requires` is absent and may not be present: it is about the *public* + * session, and this page is already behind the AdminCP's own, under its own + * cookie. A staff permission gates the page's content, inside the module. + */ + page("/admin/example", { + area: "admin", + component: lazy(() => import("./pages/admin-example-page")), + messages: ["@vitnode/example.admin.overview"], + }), +]); diff --git a/plugins/example/src/routes/manifest.ts b/plugins/example/src/routes/manifest.ts deleted file mode 100644 index 6a128f25f..000000000 --- a/plugins/example/src/routes/manifest.ts +++ /dev/null @@ -1,162 +0,0 @@ -import type { PluginRouteDefinition } from "@vitnode/core/routing"; - -/** - * The routes this plugin contributes to whatever app installs it. - * - * Plain data, and framework-neutral by construction: an `entry` is a *package - * export subpath*, so `"routes/example-page"` is imported as - * `"@vitnode/example/routes/example-page"` and resolves through this package's - * export map to its build output. Nothing here imports a router, and nothing - * here imports a page - so an app can read this list at build time, in Node, - * without pulling a single React component into the process. - * - * That is what lets the app generate literal `import()` calls for these modules - * instead of building specifiers at runtime: the ids and entries are known - * before the bundler runs, so Rollup gives each page its own lazily fetched - * chunk and the browser never has to ask which plugins are installed. - * - * `config.tsx` hands this same array to `buildPlugin({ routes })`, so a host that - * registers the plugin through its config declares exactly the same routes as one - * that reads the manifest at build time - one list, read by both paths. - * - * ## What this file is an example of - * - * Two things, deliberately separated, because a plugin only ever needs the first - * one and every plugin needs to be able to find out what the second costs. - * - * `/example` is the **minimum**: an id, a path and an entry. Everything else on - * `PluginRouteDefinition` has a default, and its module is a bare - * `export default`. A plugin with one public page writes this and stops. - * - * `/example/guide` is the rest of the contract, as small as it can be written - * and still be real: a `layout` that frames its children, the index page inside - * it, a dynamic child, the message namespaces those three render, and - in the - * modules themselves - a loader, page metadata, a breadcrumb and a search - * contract. Four routes rather than four separate examples, because what is - * worth showing is how they fit together. - * - * Note what is *not* here: no locale. `/example` and `/pl/example` are one route, - * because the host strips the prefix before matching and writes it back into - * every link it builds. A plugin declares the logical path only. - */ -export const routes: PluginRouteDefinition[] = [ - { - entry: "routes/example-page", - id: "example-page", - path: "/example", - }, - - /** - * A frame, and no URL of its own. - * - * `kind: "layout"` is what makes `/example/guide` two routes rather than one - * ambiguous one: this renders the heading and the surrounding chrome, and the - * `guide-index` below renders what goes inside it at that same path - the - * `layout.tsx` and `page.tsx` pair, said as data. A layout may not be a leaf, - * so removing either of its children below is a build error rather than a - * route nothing can reach. - * - * The namespaces sit here rather than on each child. They are declared on the - * route because they have to be known *before* the module's chunk is fetched - - * the strings and the code are two requests, and a list that lived inside the - * code could only be read after downloading the page it describes. A child - * inherits every namespace its ancestors declare, so naming them once on the - * frame is what stops three routes repeating one list. - */ - { - entry: "routes/guide-layout", - id: "guide", - kind: "layout", - namespaces: ["@vitnode/example.guide"], - path: "/example/guide", - }, - - /** - * The index page of the layout above: the same path, parented to it. - * - * `parentId` is this plugin's *own* route id, never `"@vitnode/example:guide"` - * and never another plugin's - there is nowhere in this string to put another - * plugin's name, which is how cross-plugin nesting is made unrepresentable - * rather than merely discouraged. - * - * `path` is the **full** public path even though this route is nested. A - * relative fragment would make the manifest unreadable without walking the - * graph, and would make a URL collision impossible to see in a diff. - */ - { - entry: "routes/guide-index-page", - id: "guide-index", - parentId: "guide", - path: "/example/guide", - }, - - /** - * A dynamic child: `:topic`, in VitNode's spelling. - * - * Neither Next's `[topic]` nor TanStack's `$topic` - the host converts. Its - * path extends the parent's, which is checked: a child claiming an unrelated - * URL would be a manifest that lies about where its pages are. - * - * It declares no namespaces of its own. The frame above declares the set all - * three routes render in, and a child inherits every namespace its ancestors - * declare, so this route ships one line and still has its strings in flight - * beside its chunk. - */ - { - entry: "routes/guide-topic-page", - id: "guide-topic", - parentId: "guide", - path: "/example/guide/:topic", - }, - - /** - * A page in the **AdminCP**, and the whole of what that costs: one field. - * - * `area: "admin"` names the shell the page is framed by - the sidebar, the - * breadcrumb area, the command palette and the admin session guard - and the - * host mounts it under whichever route renders that shell. It does *not* put - * `/admin` in front of the path: both shells are pathless, so an area frames a - * page rather than moving it, and the URL below is written out in full. That - * is also why `main /admin/example` would be a collision with this rather than - * a second page - one URL is one URL whichever frame draws it. - * - * `requires` is absent and may not be present: it is about the *public* - * session, and this page is already behind the AdminCP's own, under its own - * cookie. A staff permission gates the page's content, inside the module. - * - * The entry is a normal package export subpath like every other one here. - * There was briefly a second convention - `routes/admin/`, a directory of - * framework route files copied wholesale into a host's own app directory - and - * a module in both places was copied *and* bundled. Manifest entries are now - * the only way a plugin contributes a page. - */ - /** - * A page whose query string is validated by the **router**, not by its module. - * - * The one field here that costs something, and the only one that does: - * `searchEntry` names a second module, and the app's build imports it - * *statically*. That is the price of being early - a router's - * `validateSearch` runs during path matching, before any chunk is fetched, so - * a schema in the lazy page module would arrive too late to shape anything. - * - * Declare it only for a page whose URL *is* its state - a paginated list whose - * `?page=999` has to be clamped, a filter whose links have to be typed and - * checked. `/example/guide/:topic` above is the ordinary case: it reads its - * query string through its own module's `parseSearch`, lazily, and nothing is - * added to the initial bundle. - */ - { - entry: "routes/browse-page", - id: "browse", - namespaces: ["@vitnode/example.browse"], - path: "/example/browse", - searchEntry: "routes/browse-page.search", - }, - { - area: "admin", - entry: "routes/admin-example-page", - id: "admin-overview", - namespaces: ["@vitnode/example.admin.overview"], - path: "/admin/example", - }, -]; From ece71909c512d2dc4716553e72f87ca4cf87ec8f Mon Sep 17 00:00:00 2001 From: aXenDeveloper <aXenDeveloper@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:36:02 +0000 Subject: [PATCH 02/12] ci: version bump to v0.0.1-canary.0 --- apps/api/package.json | 2 +- apps/web/package.json | 3 ++- packages/config/package.json | 2 +- packages/create-vitnode-app/package.json | 2 +- packages/elasticsearch/package.json | 2 +- packages/node-cron/package.json | 2 +- packages/nodemailer/package.json | 2 +- packages/resend/package.json | 2 +- packages/s3/package.json | 2 +- packages/supabase-storage/package.json | 2 +- packages/vitnode/package.json | 2 +- packages/vitnode/src/config.ts | 2 +- plugins/blog/package.json | 2 +- plugins/example/package.json | 2 +- 14 files changed, 15 insertions(+), 14 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index cb36b3018..e18d80dc0 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "api", - "version": "1.2.0-canary.78", + "version": "0.0.1-canary.0", "private": true, "type": "module", "scripts": { diff --git a/apps/web/package.json b/apps/web/package.json index 29e4accdc..9ccc0a831 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -73,5 +73,6 @@ "esbuild", "lightningcss" ] - } + }, + "version": "0.0.1-canary.0" } diff --git a/packages/config/package.json b/packages/config/package.json index 743624e78..5c9815d75 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/config", - "version": "1.2.0-canary.78", + "version": "0.0.1-canary.0", "description": "ESLint, Prettier, TypeScript (TSConfig) config for VitNode", "author": "VitNode Team", "license": "MIT", diff --git a/packages/create-vitnode-app/package.json b/packages/create-vitnode-app/package.json index eb69665b6..2c635d30c 100644 --- a/packages/create-vitnode-app/package.json +++ b/packages/create-vitnode-app/package.json @@ -1,6 +1,6 @@ { "name": "create-vitnode-app", - "version": "1.2.0-canary.78", + "version": "0.0.1-canary.0", "description": "Create a new VitNode app in seconds.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/elasticsearch/package.json b/packages/elasticsearch/package.json index f7b819da1..9660a1846 100644 --- a/packages/elasticsearch/package.json +++ b/packages/elasticsearch/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/elasticsearch", - "version": "1.2.0-canary.78", + "version": "0.0.1-canary.0", "description": "Elasticsearch search engine adapter for VitNode content discovery.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/node-cron/package.json b/packages/node-cron/package.json index ca51c29e5..e3d9e4873 100644 --- a/packages/node-cron/package.json +++ b/packages/node-cron/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/node-cron", - "version": "1.2.0-canary.78", + "version": "0.0.1-canary.0", "description": "Node-cron adapter for VitNode, enabling cron job scheduling and management.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/nodemailer/package.json b/packages/nodemailer/package.json index ee78a15b7..78e32b527 100644 --- a/packages/nodemailer/package.json +++ b/packages/nodemailer/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/nodemailer", - "version": "1.2.0-canary.78", + "version": "0.0.1-canary.0", "description": "Nodemailer integration package for VitNode, enabling email functionalities.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/resend/package.json b/packages/resend/package.json index 77bdc8a1a..784fd18d3 100644 --- a/packages/resend/package.json +++ b/packages/resend/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/resend", - "version": "1.2.0-canary.78", + "version": "0.0.1-canary.0", "description": "Resend adapter for VitNode, enabling email sending capabilities through the Resend service.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/s3/package.json b/packages/s3/package.json index 86ec514c7..8f13b5fba 100644 --- a/packages/s3/package.json +++ b/packages/s3/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/s3", - "version": "1.2.0-canary.78", + "version": "0.0.1-canary.0", "description": "AWS S3 and Cloudflare R2 storage adapter for VitNode file uploads.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/supabase-storage/package.json b/packages/supabase-storage/package.json index f937c4bb4..938b3f2aa 100644 --- a/packages/supabase-storage/package.json +++ b/packages/supabase-storage/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/supabase-storage", - "version": "1.2.0-canary.78", + "version": "0.0.1-canary.0", "description": "Supabase Storage adapter for VitNode file uploads.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 4413c3f2c..1988f650e 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/core", - "version": "1.2.0-canary.78", + "version": "0.0.1-canary.0", "description": "Core package for VitNode, providing essential functionalities and configurations.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/vitnode/src/config.ts b/packages/vitnode/src/config.ts index 705481e3f..22cb5db03 100644 --- a/packages/vitnode/src/config.ts +++ b/packages/vitnode/src/config.ts @@ -1,4 +1,4 @@ export const CONFIG_PLUGIN = { pluginId: "@vitnode/core" as const, - version: "1.2.0-canary.78", + version: "0.0.1-canary.0", }; diff --git a/plugins/blog/package.json b/plugins/blog/package.json index b321d9e8a..514eb2f05 100644 --- a/plugins/blog/package.json +++ b/plugins/blog/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/blog", - "version": "1.2.0-canary.78", + "version": "0.0.1-canary.0", "description": "Blog plugin for VitNode, providing a blogging platform on Hono.js.", "author": "VitNode Team", "license": "MIT", diff --git a/plugins/example/package.json b/plugins/example/package.json index a5fe57487..275f86cef 100644 --- a/plugins/example/package.json +++ b/plugins/example/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/example", - "version": "0.0.0", + "version": "0.0.1-canary.0", "description": "Reference plugin exercising the VitNode Content Engine end to end.", "license": "MIT", "private": true, From 76013f1fd28a4a4f5364b76e27dfa9d475b04bc6 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Thu, 3 Sep 2026 13:55:52 +0200 Subject: [PATCH 03/12] fix: bump packages --- apps/api/package.json | 2 +- apps/web/package.json | 4 ++-- packages/config/package.json | 2 +- packages/create-vitnode-app/package.json | 2 +- packages/elasticsearch/package.json | 2 +- packages/node-cron/package.json | 2 +- packages/nodemailer/package.json | 2 +- packages/resend/package.json | 2 +- packages/s3/package.json | 2 +- packages/supabase-storage/package.json | 2 +- packages/vitnode/package.json | 2 +- packages/vitnode/src/config.ts | 2 +- plugins/blog/package.json | 2 +- plugins/example/package.json | 2 +- scripts/bump-version/version-manager.ts | 28 +++++++++++++----------- 15 files changed, 30 insertions(+), 28 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index e18d80dc0..f553cf9d6 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "api", - "version": "0.0.1-canary.0", + "version": "2.0.0-canary.0", "private": true, "type": "module", "scripts": { diff --git a/apps/web/package.json b/apps/web/package.json index 9ccc0a831..0150b2cbe 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,5 +1,6 @@ { "name": "web", + "version": "2.0.0-canary.0", "private": true, "type": "module", "imports": { @@ -73,6 +74,5 @@ "esbuild", "lightningcss" ] - }, - "version": "0.0.1-canary.0" + } } diff --git a/packages/config/package.json b/packages/config/package.json index 5c9815d75..9394c057f 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/config", - "version": "0.0.1-canary.0", + "version": "2.0.0-canary.0", "description": "ESLint, Prettier, TypeScript (TSConfig) config for VitNode", "author": "VitNode Team", "license": "MIT", diff --git a/packages/create-vitnode-app/package.json b/packages/create-vitnode-app/package.json index 2c635d30c..4642c78a2 100644 --- a/packages/create-vitnode-app/package.json +++ b/packages/create-vitnode-app/package.json @@ -1,6 +1,6 @@ { "name": "create-vitnode-app", - "version": "0.0.1-canary.0", + "version": "2.0.0-canary.0", "description": "Create a new VitNode app in seconds.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/elasticsearch/package.json b/packages/elasticsearch/package.json index 9660a1846..cb8f96bb8 100644 --- a/packages/elasticsearch/package.json +++ b/packages/elasticsearch/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/elasticsearch", - "version": "0.0.1-canary.0", + "version": "2.0.0-canary.0", "description": "Elasticsearch search engine adapter for VitNode content discovery.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/node-cron/package.json b/packages/node-cron/package.json index e3d9e4873..5496857f0 100644 --- a/packages/node-cron/package.json +++ b/packages/node-cron/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/node-cron", - "version": "0.0.1-canary.0", + "version": "2.0.0-canary.0", "description": "Node-cron adapter for VitNode, enabling cron job scheduling and management.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/nodemailer/package.json b/packages/nodemailer/package.json index 78e32b527..018320e52 100644 --- a/packages/nodemailer/package.json +++ b/packages/nodemailer/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/nodemailer", - "version": "0.0.1-canary.0", + "version": "2.0.0-canary.0", "description": "Nodemailer integration package for VitNode, enabling email functionalities.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/resend/package.json b/packages/resend/package.json index 784fd18d3..d41218caa 100644 --- a/packages/resend/package.json +++ b/packages/resend/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/resend", - "version": "0.0.1-canary.0", + "version": "2.0.0-canary.0", "description": "Resend adapter for VitNode, enabling email sending capabilities through the Resend service.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/s3/package.json b/packages/s3/package.json index 8f13b5fba..187e25eec 100644 --- a/packages/s3/package.json +++ b/packages/s3/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/s3", - "version": "0.0.1-canary.0", + "version": "2.0.0-canary.0", "description": "AWS S3 and Cloudflare R2 storage adapter for VitNode file uploads.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/supabase-storage/package.json b/packages/supabase-storage/package.json index 938b3f2aa..c9ed4eda9 100644 --- a/packages/supabase-storage/package.json +++ b/packages/supabase-storage/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/supabase-storage", - "version": "0.0.1-canary.0", + "version": "2.0.0-canary.0", "description": "Supabase Storage adapter for VitNode file uploads.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 1988f650e..bc8a73275 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/core", - "version": "0.0.1-canary.0", + "version": "2.0.0-canary.0", "description": "Core package for VitNode, providing essential functionalities and configurations.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/vitnode/src/config.ts b/packages/vitnode/src/config.ts index 22cb5db03..df24f0ac5 100644 --- a/packages/vitnode/src/config.ts +++ b/packages/vitnode/src/config.ts @@ -1,4 +1,4 @@ export const CONFIG_PLUGIN = { pluginId: "@vitnode/core" as const, - version: "0.0.1-canary.0", + version: "2.0.0-canary.0", }; diff --git a/plugins/blog/package.json b/plugins/blog/package.json index 514eb2f05..9f6f66f84 100644 --- a/plugins/blog/package.json +++ b/plugins/blog/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/blog", - "version": "0.0.1-canary.0", + "version": "2.0.0-canary.0", "description": "Blog plugin for VitNode, providing a blogging platform on Hono.js.", "author": "VitNode Team", "license": "MIT", diff --git a/plugins/example/package.json b/plugins/example/package.json index 275f86cef..a4be7e16c 100644 --- a/plugins/example/package.json +++ b/plugins/example/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/example", - "version": "0.0.1-canary.0", + "version": "2.0.0-canary.0", "description": "Reference plugin exercising the VitNode Content Engine end to end.", "license": "MIT", "private": true, diff --git a/scripts/bump-version/version-manager.ts b/scripts/bump-version/version-manager.ts index bcc225d21..71207a01d 100644 --- a/scripts/bump-version/version-manager.ts +++ b/scripts/bump-version/version-manager.ts @@ -9,6 +9,7 @@ interface Config { TAG_PREFIX: string; TAG_SUFFIX: string; COMMIT_MESSAGE: string; + SOURCE_OF_TRUTH_PACKAGE: string; } const CONFIG: Config = { @@ -16,6 +17,7 @@ const CONFIG: Config = { TAG_PREFIX: "v", TAG_SUFFIX: "", COMMIT_MESSAGE: "ci: version bump to {{version}}", + SOURCE_OF_TRUTH_PACKAGE: "create-vitnode-app", }; export class VersionManager { @@ -40,7 +42,7 @@ export class VersionManager { join( this.env.WORKSPACE, "packages", - "create-vitnode-app", + CONFIG.SOURCE_OF_TRUTH_PACKAGE, "package.json", ), "utf8", @@ -56,11 +58,19 @@ export class VersionManager { calculateNewVersion(currentVersion: string) { const versionType = this.getVersionType(currentVersion); - const npmOutput = execSync( - `npm version --git-tag-version=false --commit-hooks=false --workspaces --workspaces-update=false ${versionType}`, + execSync( + `npm version --git-tag-version=false --commit-hooks=false --workspace=${CONFIG.SOURCE_OF_TRUTH_PACKAGE} --workspaces-update=false ${versionType}`, { cwd: this.env.WORKSPACE }, - ).toString(); - return `${CONFIG.TAG_PREFIX}${this.parseNpmVersionOutput(npmOutput)}${CONFIG.TAG_SUFFIX}`; + ); + + const newVersion = this.getCurrentVersion(); + if (newVersion === currentVersion) { + throw new Error( + `npm version left ${CONFIG.SOURCE_OF_TRUTH_PACKAGE} at ${currentVersion}`, + ); + } + + return `${CONFIG.TAG_PREFIX}${newVersion}${CONFIG.TAG_SUFFIX}`; } getVersionType(currentVersion: string): string { @@ -83,14 +93,6 @@ export class VersionManager { return VERSION_TYPE; } - parseNpmVersionOutput(output: string): string { - const result = output.trim().split(EOL).pop(); - if (!result) { - throw new Error("Failed to parse npm version output"); - } - return result.replace(/^v/, ""); - } - async applyVersion( currentVersion: string, newVersion: string, From 5097e4cb36d9e5301e4775fa64428538c1a96419 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <aXenDeveloper@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:57:12 +0000 Subject: [PATCH 04/12] ci: version bump to v2.0.0-canary.1 --- apps/api/package.json | 2 +- apps/web/package.json | 2 +- packages/config/package.json | 2 +- packages/create-vitnode-app/package.json | 2 +- packages/elasticsearch/package.json | 2 +- packages/node-cron/package.json | 2 +- packages/nodemailer/package.json | 2 +- packages/resend/package.json | 2 +- packages/s3/package.json | 2 +- packages/supabase-storage/package.json | 2 +- packages/vitnode/package.json | 2 +- packages/vitnode/src/config.ts | 2 +- plugins/blog/package.json | 2 +- plugins/example/package.json | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index f553cf9d6..72221d2e0 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "api", - "version": "2.0.0-canary.0", + "version": "2.0.0-canary.1", "private": true, "type": "module", "scripts": { diff --git a/apps/web/package.json b/apps/web/package.json index 0150b2cbe..7568d6ac8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "2.0.0-canary.0", + "version": "2.0.0-canary.1", "private": true, "type": "module", "imports": { diff --git a/packages/config/package.json b/packages/config/package.json index 9394c057f..69aa1c75b 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/config", - "version": "2.0.0-canary.0", + "version": "2.0.0-canary.1", "description": "ESLint, Prettier, TypeScript (TSConfig) config for VitNode", "author": "VitNode Team", "license": "MIT", diff --git a/packages/create-vitnode-app/package.json b/packages/create-vitnode-app/package.json index 4642c78a2..cd2471798 100644 --- a/packages/create-vitnode-app/package.json +++ b/packages/create-vitnode-app/package.json @@ -1,6 +1,6 @@ { "name": "create-vitnode-app", - "version": "2.0.0-canary.0", + "version": "2.0.0-canary.1", "description": "Create a new VitNode app in seconds.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/elasticsearch/package.json b/packages/elasticsearch/package.json index cb8f96bb8..2d2038040 100644 --- a/packages/elasticsearch/package.json +++ b/packages/elasticsearch/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/elasticsearch", - "version": "2.0.0-canary.0", + "version": "2.0.0-canary.1", "description": "Elasticsearch search engine adapter for VitNode content discovery.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/node-cron/package.json b/packages/node-cron/package.json index 5496857f0..84f86be06 100644 --- a/packages/node-cron/package.json +++ b/packages/node-cron/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/node-cron", - "version": "2.0.0-canary.0", + "version": "2.0.0-canary.1", "description": "Node-cron adapter for VitNode, enabling cron job scheduling and management.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/nodemailer/package.json b/packages/nodemailer/package.json index 018320e52..a68ddede5 100644 --- a/packages/nodemailer/package.json +++ b/packages/nodemailer/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/nodemailer", - "version": "2.0.0-canary.0", + "version": "2.0.0-canary.1", "description": "Nodemailer integration package for VitNode, enabling email functionalities.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/resend/package.json b/packages/resend/package.json index d41218caa..1625c85bf 100644 --- a/packages/resend/package.json +++ b/packages/resend/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/resend", - "version": "2.0.0-canary.0", + "version": "2.0.0-canary.1", "description": "Resend adapter for VitNode, enabling email sending capabilities through the Resend service.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/s3/package.json b/packages/s3/package.json index 187e25eec..ad3151789 100644 --- a/packages/s3/package.json +++ b/packages/s3/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/s3", - "version": "2.0.0-canary.0", + "version": "2.0.0-canary.1", "description": "AWS S3 and Cloudflare R2 storage adapter for VitNode file uploads.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/supabase-storage/package.json b/packages/supabase-storage/package.json index c9ed4eda9..40dd53544 100644 --- a/packages/supabase-storage/package.json +++ b/packages/supabase-storage/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/supabase-storage", - "version": "2.0.0-canary.0", + "version": "2.0.0-canary.1", "description": "Supabase Storage adapter for VitNode file uploads.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index bc8a73275..922612314 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/core", - "version": "2.0.0-canary.0", + "version": "2.0.0-canary.1", "description": "Core package for VitNode, providing essential functionalities and configurations.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/vitnode/src/config.ts b/packages/vitnode/src/config.ts index df24f0ac5..1ea7591ee 100644 --- a/packages/vitnode/src/config.ts +++ b/packages/vitnode/src/config.ts @@ -1,4 +1,4 @@ export const CONFIG_PLUGIN = { pluginId: "@vitnode/core" as const, - version: "2.0.0-canary.0", + version: "2.0.0-canary.1", }; diff --git a/plugins/blog/package.json b/plugins/blog/package.json index 9f6f66f84..b5f7ae7e2 100644 --- a/plugins/blog/package.json +++ b/plugins/blog/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/blog", - "version": "2.0.0-canary.0", + "version": "2.0.0-canary.1", "description": "Blog plugin for VitNode, providing a blogging platform on Hono.js.", "author": "VitNode Team", "license": "MIT", diff --git a/plugins/example/package.json b/plugins/example/package.json index a4be7e16c..1875995aa 100644 --- a/plugins/example/package.json +++ b/plugins/example/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/example", - "version": "2.0.0-canary.0", + "version": "2.0.0-canary.1", "description": "Reference plugin exercising the VitNode Content Engine end to end.", "license": "MIT", "private": true, From 428dca09386397db41eefaa79d848399bafcc7f3 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Thu, 3 Sep 2026 16:12:17 +0200 Subject: [PATCH 05/12] refactor: configs in tanstack start --- apps/web/content/docs/dev/architecture.mdx | 25 ++ apps/web/content/docs/dev/configuration.mdx | 290 ++++++++++++++++ .../content-engine/plugin-registration.mdx | 11 +- apps/web/content/docs/dev/debugging/index.mdx | 14 +- apps/web/content/docs/dev/i18n/index.mdx | 81 ++++- apps/web/content/docs/dev/meta.json | 1 + apps/web/content/docs/dev/plugins/create.mdx | 12 +- apps/web/content/docs/dev/setup.mdx | 13 +- apps/web/content/docs/guides/blog.mdx | 8 +- apps/web/src/i18n.ts | 39 --- apps/web/src/lib/admin-nav.ts | 14 +- apps/web/src/lib/content-registry.ts | 13 +- apps/web/src/lib/document-headers.ts | 128 ------- apps/web/src/lib/i18n/runtime.ts | 10 +- apps/web/src/lib/i18n/shared.ts | 4 +- apps/web/src/lib/page-head.ts | 4 +- apps/web/src/locales/app.ts | 10 +- apps/web/src/locales/packages.ts | 11 +- apps/web/src/router.tsx | 152 --------- apps/web/src/routes/__root.tsx | 4 +- .../src/routes/_admin/admin.core.index.tsx | 13 +- apps/web/src/server/messages.server.ts | 18 +- apps/web/src/start.ts | 89 +---- apps/web/src/styles.css | 78 ----- apps/web/src/vitnode.api.config.ts | 41 +-- apps/web/src/vitnode.config.ts | 75 ++--- apps/web/src/vitnode.server.config.ts | 12 + apps/web/src/vitnode.shell.config.ts | 35 -- apps/web/vite.config.ts | 132 +------- packages/create-vitnode-app/README.md | 10 +- .../copy-of-vitnode-app/README.md | 22 ++ .../api-single-app/src/vitnode.api.config.ts | 15 +- .../copy-of-vitnode-app/api/src/i18n.ts | 38 --- .../api/src/vitnode.api.config.ts | 33 +- .../copy-of-vitnode-app/root/src/i18n.ts | 36 -- .../root/src/lib/admin-nav.ts | 14 +- .../root/src/lib/content-registry.ts | 13 +- .../root/src/lib/document-headers.ts | 128 ------- .../root/src/lib/i18n/runtime.ts | 10 +- .../root/src/lib/i18n/shared.ts | 8 +- .../root/src/lib/page-head.ts | 4 +- .../root/src/locales/app.ts | 6 +- .../root/src/locales/packages.ts | 4 +- .../root/src/routes/__root.tsx | 4 +- .../src/routes/_admin/admin.core.index.tsx | 13 +- .../root/src/server/messages.server.ts | 18 +- .../copy-of-vitnode-app/root/src/start.ts | 98 +----- .../copy-of-vitnode-app/root/src/styles.css | 38 +-- .../root/src/vitnode.config.ts | 89 +++-- .../root/src/vitnode.server.config.ts | 23 ++ .../root/src/vitnode.shell.config.ts | 35 -- .../copy-of-vitnode-app/root/vite.config.ts | 35 +- .../src/create/scaffold-invariants.test.ts | 140 ++++++-- packages/create-vitnode-app/vitest.config.ts | 9 - packages/vitnode/scripts/get-config.ts | 37 +- packages/vitnode/scripts/i18n-check.ts | 11 +- packages/vitnode/scripts/i18n-create.test.ts | 77 +++++ packages/vitnode/scripts/i18n-create.ts | 122 ++++++- .../api/lib/response-cache-headers.test.ts | 56 ++- packages/vitnode/src/framework/vite/index.ts | 17 +- .../src/framework/vite/optimize-deps.test.ts | 3 +- .../src/framework/vite/optimize-deps.ts | 1 + .../vite/shared-config-discovery.test.ts | 72 ++++ .../src/framework/vite/vitnode.test.ts | 58 ++++ .../vitnode/src/framework/vite/vitnode.ts | 42 +++ .../vitnode/src/tanstack/boundary.test.ts | 117 ++++++- .../vitnode/src/tanstack/i18n/messages.ts | 37 +- .../i18n/server-config-messages.test.ts | 119 +++++++ packages/vitnode/src/tanstack/i18n/server.ts | 1 + .../src/tanstack/start/create-start.test.ts | 318 ++++++++++++++++++ .../src/tanstack/start/create-start.ts | 75 +++++ .../tanstack/start/document-headers.test.ts | 120 +++++++ .../src/tanstack/start/document-headers.ts | 92 +++++ packages/vitnode/src/tanstack/start/index.ts | 34 ++ .../src/tanstack/start/locale-middleware.ts | 89 +++++ packages/vitnode/src/vitnode.config.ts | 86 ++++- .../config-split/vitnode.config.ts | 23 ++ .../config-split/vitnode.server.config.ts | 13 + .../bump-version/files/file-copy-manager.ts | 10 - 79 files changed, 2399 insertions(+), 1411 deletions(-) create mode 100644 apps/web/content/docs/dev/configuration.mdx delete mode 100644 apps/web/src/i18n.ts delete mode 100644 apps/web/src/lib/document-headers.ts create mode 100644 apps/web/src/vitnode.server.config.ts delete mode 100644 apps/web/src/vitnode.shell.config.ts delete mode 100644 packages/create-vitnode-app/copy-of-vitnode-app/api/src/i18n.ts delete mode 100644 packages/create-vitnode-app/copy-of-vitnode-app/root/src/i18n.ts delete mode 100644 packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/document-headers.ts create mode 100644 packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.server.config.ts delete mode 100644 packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.shell.config.ts create mode 100644 packages/vitnode/src/framework/vite/shared-config-discovery.test.ts create mode 100644 packages/vitnode/src/framework/vite/vitnode.test.ts create mode 100644 packages/vitnode/src/framework/vite/vitnode.ts create mode 100644 packages/vitnode/src/tanstack/i18n/server-config-messages.test.ts create mode 100644 packages/vitnode/src/tanstack/start/create-start.test.ts create mode 100644 packages/vitnode/src/tanstack/start/create-start.ts create mode 100644 packages/vitnode/src/tanstack/start/document-headers.test.ts create mode 100644 packages/vitnode/src/tanstack/start/document-headers.ts create mode 100644 packages/vitnode/src/tanstack/start/index.ts create mode 100644 packages/vitnode/src/tanstack/start/locale-middleware.ts create mode 100644 packages/vitnode/test-fixtures/config-split/vitnode.config.ts create mode 100644 packages/vitnode/test-fixtures/config-split/vitnode.server.config.ts diff --git a/apps/web/content/docs/dev/architecture.mdx b/apps/web/content/docs/dev/architecture.mdx index 8f629e0d9..006b2db0f 100644 --- a/apps/web/content/docs/dev/architecture.mdx +++ b/apps/web/content/docs/dev/architecture.mdx @@ -41,6 +41,26 @@ When a user visits a page (e.g. `/blog`): --- +## The Request Pipeline + +Before route matching, every request passes through the middleware +`createVitNodeStart` installs - in this order, and an app cannot get in front of +any of it: + +| Order | Middleware | Applies to | +| :--- | :--- | :--- | +| 1 | **CSRF** | Server function calls (`handlerType === 'serverFn'`) | +| 2 | **Locale** | Page requests: canonical `308` redirects and the locale cookie | +| 3 | **Document cache** | HTML responses: forced `Cache-Control: private, no-store` | +| 4 | Your own | Whatever `requestMiddleware` lists | + +`/api/*` reaches the same middleware and passes through untouched - no redirect, +no rewrite, no cache directive - so the Hono bridge sees the request exactly as +the client sent it and keeps its own caching policy. See +[Configuration](/docs/dev/configuration). + +--- + ## Plugin System Architecture VitNode is built around modular plugins located in `plugins/*`: @@ -66,4 +86,9 @@ VitNode is built around modular plugins located in `plugins/*`: description="Type-safe RPC communication between frontend and API" href="/docs/dev/fetcher" /> + <Card + title="Configuration" + description="The shared config, the server-only companion, and the Start factory" + href="/docs/dev/configuration" + /> </Cards> diff --git a/apps/web/content/docs/dev/configuration.mdx b/apps/web/content/docs/dev/configuration.mdx new file mode 100644 index 000000000..2ffd1d438 --- /dev/null +++ b/apps/web/content/docs/dev/configuration.mdx @@ -0,0 +1,290 @@ +--- +title: Configuration +description: The two config files a VitNode app owns - the browser-safe shared one, and the server-only companion - plus the request pipeline Core installs for you. +icon: Settings +--- + +import { Callout } from 'fumadocs-ui/components/callout' + +A VitNode app has two configuration files, and the line between them is one +question: **may a browser hold this?** + +| File | Holds | Reached by | +| :----------------------------- | :---------------------------------------------------- | :----------------------------------------------- | +| `src/vitnode.config.ts` | locales, metadata, theme, `debug`, enabled plugin ids | the browser, the server, **and your Vite build** | +| `src/vitnode.server.config.ts` | message loaders | the server only | + +--- + +## `vitnode.config.ts` - the shared config + +```ts title="apps/web/src/vitnode.config.ts" +import { blogPlugin } from '@vitnode/blog/config' +import { buildConfig } from '@vitnode/core/vitnode.config' + +export const vitNodeConfig = buildConfig({ + debug: false, + i18n: { + defaultLocale: 'en', + locales: [ + { code: 'en', name: 'English' }, + { code: 'pl', name: 'Polski' }, + ], + timeZone: 'UTC', + }, + metadata: { + shortTitle: 'VitNode', + title: 'VitNode', + }, + plugins: [blogPlugin()], + theme: { + defaultTheme: 'system', + }, +}) +``` + +Three very different readers depend on this file: + +1. **The document shell** renders `metadata`, `theme` and `debug` - in the + browser as well as on the server. +2. **The locale runtime** derives routing from `i18n`. +3. **Your Vite build** loads it with `jiti` while Vite is still resolving its + own config, to find out which plugins to generate route, navigation and + content-registry imports for. + +<Callout type="warn" title="Plain data only"> + Because of reader 3, this file is executed at build time; because of reader 1 + it is bundled for a browser. So it holds plain data and plugin *identity* - + never a `() => import(...)` message loader, and never a module that reaches a + database. +</Callout> + +`buildConfig` keeps `locales` a tuple of literal types, so a `Locale` derived +from it is `'en' | 'pl'` rather than `string`, and `defaultLocale` is checked +against the list right beside it: + +```ts title="apps/web/src/lib/i18n/shared.ts" +export type Locale = (typeof vitNodeConfig.i18n.locales)[number]['code'] +``` + +### Enabled plugins + +Register a plugin with its own factory: + +```ts +import { blogPlugin } from '@vitnode/blog/config' + +plugins: [blogPlugin()] +``` + +The factory carries the plugin's content types, its AdminCP navigation and its +translations - one declaration, and the same one a Next.js host reads directly. + +What a TanStack Start app **renders** comes back through generated projections +rather than out of this object, and that is the part worth knowing: + +| Generated file | From | Loaded | +| :---------------------------- | :--------------------------- | :---------------------------------------------- | +| `src/plugin-routes.gen.ts` | the plugin's `src/routes.ts` | per route, behind `lazy()` | +| `src/admin-nav.gen.ts` | the plugin's `admin/nav` | with the AdminCP shell | +| `src/content-registry.gen.ts` | the plugin's `admin/content` | behind a dynamic `import()` in `src/router.tsx` | + +One literal import per configured plugin, written at build time. So a content +type's editing screen - a Tiptap field, a form layout, a table cell - arrives +with the route that renders it, not with the config that the document shell +imports. See [Plugin routes](/docs/dev/plugins/routes) and +[Plugin frontend modules](/docs/dev/content-engine/plugin-registration). + +<Callout type="info" title="Also register its locale files"> + The `messages` a factory carries is the plugin's own locale barrel, which loads + its JSON with `import('./en.json', { with: { type: 'json' } })` - a specifier + no bundler follows. VitNode reads translations from + `src/locales/packages.ts` instead, so add a line there per language the plugin + ships. [Languages & Localization](/docs/dev/i18n) has the detail. +</Callout> + +<Callout type="warn" title="Build-time cost"> + Your Vite build executes this file with `jiti` on every regeneration pass to + discover the plugin list. Running a plugin's registration graph in Node + measures ~340ms per pass against ~6ms for `buildPlugin({pluginId})`, which is + the minimum a TanStack host needs - worth knowing if a large plugin set makes + the dev watcher feel slow. +</Callout> + +--- + +## `vitnode.server.config.ts` - the server-only companion + +Message loaders read JSON out of a package's build output, which is exactly what +the shared config cannot carry. They go here instead: + +```ts title="apps/web/src/vitnode.server.config.ts" +import '@tanstack/react-start/server-only' +import { buildServerConfig } from '@vitnode/core/vitnode.config' + +import { appMessages } from '#/locales/app' +import { packageMessages } from '#/locales/packages' +import { vitNodeConfig } from '#/vitnode.config' + +export const vitNodeServerConfig = buildServerConfig({ + config: vitNodeConfig, + messages: appMessages, + packageMessages, +}) +``` + +It **holds** the shared config rather than repeating any of it, so the locale +list your message loaders are resolved against is the same object the router and +the document shell read. Hand the whole thing to the loader: + +```ts title="apps/web/src/server/messages.server.ts" +export const loadIntlMessages = createIntlMessagesLoader(vitNodeServerConfig) +``` + +`packageMessages` is one line per language a package ships, and `messages` is +where you reword a string a package translates differently to how you want it. +[Languages & Localization](/docs/dev/i18n) covers both. + +<Callout type="info" title="The API has its own config"> + `src/vitnode.api.config.ts` configures the Hono app at `/api/*` - the + database, storage, email, Redis. In a single app it reads `vitNodeConfig.i18n` + so the site and its emails agree on which languages exist. In a split + deployment the API is a separate package and declares its own list. +</Callout> + +--- + +## `start.ts` - the request pipeline + +TanStack Start expects a `src/start.ts`. In a VitNode app it is one call: + +```ts title="apps/web/src/start.ts" +import { createVitNodeStart } from '@vitnode/core/tanstack/start' + +import { vitNodeConfig } from '#/vitnode.config' + +export const startInstance = createVitNodeStart({ config: vitNodeConfig }) +``` + +`createVitNodeStart` installs three things, and none of them is optional: + +| Middleware | What it does | +| :------------ | :---------------------------------------------------------------------------------------- | +| **CSRF** | Rejects cross-site requests to server functions (`handlerType === 'serverFn'`) | +| **Locale** | Canonical `308` redirects, the remembered-locale cookie, and the document cache directive | +| **Documents** | Forces `Cache-Control: private, no-store` onto every HTML response | + +<Callout type="warn" title="Why the factory exists"> + Start installs its own CSRF middleware *only* while an app declares no + `requestMiddleware` at all. The moment an app writes its own list, that + default is replaced by whatever the list holds - so an app that hand-rolls the + pipeline and forgets CSRF exposes every server function as an unauthenticated + cross-site endpoint, silently. Core owns the list so you cannot. +</Callout> + +### Why documents are never shared-cacheable + +Every page VitNode renders streams a dehydrated TanStack Query cache into its +HTML, and that cache always holds the visitor's own session - inside `/admin`, +an administrator's entire permission set. So the directive is **forced**, not +defaulted: a route that sets `public, max-age=60` is overwritten rather than +obeyed, because the route is not in a position to know what is in the body it +would be publishing. + +Only `text/html` responses are touched. Everything else keeps what it had. + +<Callout type="info" title="API caching stays the API's"> + `/api/*` is served by the Hono bridge through this same middleware and passes + through untouched: no locale redirect, no rewrite, and no cache directive on + the JSON it answers with. The API decides its own policy with `c.get("cache")` + - see [Caching](/docs/dev/cache). The one exception proves the rule rather + than bending it: Swagger UI at `/api/swagger` is HTML, so it gets the document + directive like any other page an operator reads. +</Callout> + +### Adding your own middleware + +For things your installation genuinely owns - a request id, a tracing span, a +maintenance-mode gate: + +```ts title="apps/web/src/start.ts" +import { createMiddleware } from '@tanstack/react-start' +import { createVitNodeStart } from '@vitnode/core/tanstack/start' + +import { vitNodeConfig } from '#/vitnode.config' + +const requestId = createMiddleware().server(async ({ next }) => { + const result = await next() + result.response.headers.set('x-request-id', crypto.randomUUID()) + + return result +}) + +export const startInstance = createVitNodeStart({ + config: vitNodeConfig, + requestMiddleware: [requestId], +}) +``` + +**Ordering is guaranteed:** CSRF first, then locale handling, then your list in +the order you declared it. You cannot get in front of either. That is not +tidiness - a locale redirect _ends_ the request, so middleware running before it +would run twice for every visitor who arrives at a non-canonical URL, once for +the redirect and once for the page. + +--- + +## `vite.config.ts` + +One VitNode plugin, which is the environment handling, the dev server's +dependency pre-bundling and the plugin route generator in the order they have to +run: + +```ts title="apps/web/vite.config.ts" +import { vitnode } from '@vitnode/core/framework/vite' + +export default defineConfig({ + plugins: [ + vitnode({ appRoot: import.meta.dirname }), + nitro(), + tailwindcss(), + tanstackStart(), + viteReact(), + ], +}) +``` + +`appRoot` has to be `import.meta.dirname`: a Vite config is loaded with the +working directory set to wherever the command ran, which in a monorepo is +regularly the repository root. + +Publish an extra `NEXT_PUBLIC_*` key to the browser with `clientEnv`: + +```ts +vitnode({ appRoot: import.meta.dirname, clientEnv: ['NEXT_PUBLIC_MAP_KEY'] }) +``` + +Everything named there is compiled into JavaScript anyone can read, so add a key +only when something in the browser genuinely reads it. The three plugins are +still exported individually - `vitNodeEnv`, `vitNodeOptimizeDeps`, +`vitNodePluginRoutes` - if you ever need to drop or reorder one. + +## Learn More + +<Cards> + <Card + title="Languages & Localization" + description="Where locale metadata lives, and where message loaders go" + href="/docs/dev/i18n" + /> + <Card + title="Architecture" + description="How TanStack Start and Hono split the work" + href="/docs/dev/architecture" + /> + <Card + title="Caching" + description="What the API caches, and what invalidates it" + href="/docs/dev/cache" + /> +</Cards> diff --git a/apps/web/content/docs/dev/content-engine/plugin-registration.mdx b/apps/web/content/docs/dev/content-engine/plugin-registration.mdx index 5cf34139d..2af997f3c 100644 --- a/apps/web/content/docs/dev/content-engine/plugin-registration.mdx +++ b/apps/web/content/docs/dev/content-engine/plugin-registration.mdx @@ -12,7 +12,7 @@ To keep the AdminCP sidebar light while lazy-loading heavy rich text editors on | :--- | :--- | :--- | | `admin/nav` | `src/admin/nav.tsx` | Lightweight sidebar navigation entries and Lucide icons | | `admin/content` | `src/admin/content.tsx` | Content Engine editing screens, custom fields, and form layouts | -| `config` | `src/config.tsx` | Master plugin definition mounted in host app `vitnode.config.ts` | +| `config` | `src/config.tsx` | Master plugin definition - one declaration the two modules above are spread from | --- @@ -61,7 +61,7 @@ export const adminContent = { ## 3. `config.tsx` (Root Plugin Config) -Exports the main plugin factory used by host applications: +The plugin's own single declaration - everything it registers, in one place, so the two browser-safe modules above cannot drift from it: ```tsx title="plugins/blog/src/config.tsx" import { buildPlugin } from "@vitnode/core/lib/plugin" @@ -78,6 +78,13 @@ export const blogPlugin = () => }) ``` +A host registers this factory in `vitnode.config.ts`. A Next.js host then walks +the registry in its render pass; a TanStack Start host does not - the build +generates one literal import of `admin/nav` and `admin/content` per configured +plugin, and the content registry is loaded behind a dynamic `import()`, so an +editing screen arrives with the route that renders it. Same declarations, two +doors. See [Configuration](/docs/dev/configuration). + --- ## Package Exports Configuration diff --git a/apps/web/content/docs/dev/debugging/index.mdx b/apps/web/content/docs/dev/debugging/index.mdx index b1482cf59..8086c83d1 100644 --- a/apps/web/content/docs/dev/debugging/index.mdx +++ b/apps/web/content/docs/dev/debugging/index.mdx @@ -113,20 +113,24 @@ its entries before React renders, and the screen reads the same ones back. [React Scan](https://react-scan.com/) draws an outline around every component as it re-renders, which turns "this page feels heavy" into a number. It is off by -default; flip `debug` in the shell config: +default; flip `debug` in the [shared config](/docs/dev/configuration): -```ts title="src/vitnode.shell.config.ts" -export const vitNodeShellConfig = { +```ts title="src/vitnode.config.ts" +export const vitNodeConfig = buildConfig({ debug: true, // [!code ++] - i18n, + i18n: { + defaultLocale: 'en', + locales: [{ code: 'en', name: 'English' }], + }, metadata: { shortTitle: 'VitNode', title: 'VitNode', }, + plugins: [], theme: { defaultTheme: 'system', }, -} satisfies Omit<VitNodeConfig, 'plugins'> +}) ``` The provider loads `react-scan` lazily and only when `debug` is on **and** diff --git a/apps/web/content/docs/dev/i18n/index.mdx b/apps/web/content/docs/dev/i18n/index.mdx index e908b1d1f..02d7dec12 100644 --- a/apps/web/content/docs/dev/i18n/index.mdx +++ b/apps/web/content/docs/dev/i18n/index.mdx @@ -4,6 +4,7 @@ description: How VitNode merges translations from installed packages, and how to icon: Globe --- +import { Callout } from 'fumadocs-ui/components/callout' import { Tab, Tabs } from 'fumadocs-ui/components/tabs' VitNode provides full internationalization out of the box. Every package (`@vitnode/core` and plugins) maintains its own locale files, which VitNode merges per request: core strings first, plugin strings second, and your host app overrides last. @@ -33,30 +34,72 @@ npx vitnode i18n:check </Tabs> -`i18n:create` creates translation files across your workspace and registers the language in your configuration. `i18n:check` scans for missing or untranslated keys. +`i18n:create` adds the language to `src/vitnode.config.ts`, seeds a translation file per installed package, and registers the loaders in `src/locales/app.ts`. `i18n:check` scans for missing or untranslated keys - including a file nobody imports, which is the usual reason a translation "does not apply". --- ## Language Configuration -Declare supported languages in `apps/web/src/i18n.ts`: +Locale metadata is plain data, so it lives in the browser-safe [shared config](/docs/dev/configuration) - one declaration, read by the router, the document shell and (in a single app) the API that sends your emails: + +```ts title="apps/web/src/vitnode.config.ts" +export const vitNodeConfig = buildConfig({ + i18n: { + defaultLocale: 'en', + locales: [ + { code: 'en', name: 'English' }, + // [!code ++:1] + { code: 'de', name: 'Deutsch' }, + ], + timeZone: 'UTC', + }, + // ... +}) +``` -```ts title="apps/web/src/i18n.ts" -export const i18n = { - defaultLocale: 'en', - locales: [ - { code: 'en', name: 'English' }, - // [!code ++:1] - { code: 'de', name: 'Deutsch' }, - ], -} +`timeZone` is explicit on purpose: your app renders on a server, and without one `use-intl` formats dates in whatever zone the server happens to run in - then warns that the client will disagree. + +Because `buildConfig` keeps those codes as literal types, `'de'` is now part of your `Locale` union and a typo in `defaultLocale` is a type error. + +--- + +## Where the loaders go + +A `() => import('./de.json')` reads a file out of a package's build output, so it is the one part of i18n that must never reach a browser. Two files own it, and both are registered through the **server-only** config: + +| File | Holds | +| :------------------------ | :--------------------------------------------------- | +| `src/locales/packages.ts` | one loader per language each installed package ships | +| `src/locales/app.ts` | your own rewordings, merged last | + +```ts title="apps/web/src/vitnode.server.config.ts" +export const vitNodeServerConfig = buildServerConfig({ + config: vitNodeConfig, // the locale list above + messages: appMessages, // src/locales/app.ts + packageMessages, // src/locales/packages.ts +}) +``` + +<Callout type="warn" title="Not in vitnode.config.ts"> + Putting a loader in the shared config puts every plugin's AdminCP copy in your + browser bundle, and makes your Vite build execute it. `vitnode i18n:create` + writes to the right file for you. +</Callout> + +Adding a language to a package that ships it needs one line in `src/locales/packages.ts`: + +```ts title="apps/web/src/locales/packages.ts" +[CORE.pluginId]: { + en: async () => await import('@vitnode/core/locales/en.json'), + de: async () => await import('@vitnode/core/locales/de.json'), // [!code ++] +}, ``` --- ## Overriding Strings -To customize existing text from core or a third-party plugin, add an override file in `apps/web/src/locales/{pluginId}/{locale}.json`: +To customize existing text from core or a third-party plugin, add an override file in `apps/web/src/locales/{pluginId}/{locale}.json` and register it in `src/locales/app.ts`: ```json title="apps/web/src/locales/@vitnode/core/en.json" { @@ -68,6 +111,15 @@ To customize existing text from core or a third-party plugin, add an override fi } ``` +```ts title="apps/web/src/locales/app.ts" +export const appMessages: AppMessagesMap = { + // [!code ++:3] + en: { + '@vitnode/core': async () => await import('./@vitnode/core/en.json'), + }, +} +``` + Because your app overrides are merged last, only the keys you specify are overwritten. Everything else continues to fall back to the package defaults. --- @@ -100,4 +152,9 @@ Missing keys automatically fall back to `defaultLocale` (`en`), preventing raw k description="Localize transactional emails and error responses" href="/docs/dev/i18n/server" /> + <Card + title="Configuration" + description="The shared and server-only config files, side by side" + href="/docs/dev/configuration" + /> </Cards> diff --git a/apps/web/content/docs/dev/meta.json b/apps/web/content/docs/dev/meta.json index 23681d534..f361fb9c7 100644 --- a/apps/web/content/docs/dev/meta.json +++ b/apps/web/content/docs/dev/meta.json @@ -9,6 +9,7 @@ "setup", "deployments", "architecture", + "configuration", "---Framework---", "plugins", "routing", diff --git a/apps/web/content/docs/dev/plugins/create.mdx b/apps/web/content/docs/dev/plugins/create.mdx index c72ec4f6f..61bfdff00 100644 --- a/apps/web/content/docs/dev/plugins/create.mdx +++ b/apps/web/content/docs/dev/plugins/create.mdx @@ -72,8 +72,8 @@ export const routes = definePluginRoutes([ Import the plugin factory in the host config and add it to `plugins`: ```ts title="apps/web/src/vitnode.config.ts" -import { buildConfig } from '@vitnode/core/vitnode.config' import { siteNotesPlugin } from '@acme/site-notes/config' // [!code ++] +import { buildConfig } from '@vitnode/core/vitnode.config' export const vitNodeConfig = buildConfig({ plugins: [ @@ -82,8 +82,14 @@ export const vitNodeConfig = buildConfig({ }) ``` -The factory already carries the plugin's route tree and message loaders. Keep -host configuration to composition; the feature stays in its package. +That is the only composition step - the factory carries the plugin's routes, +content types and AdminCP navigation, and the feature stays in its package. Your +build reads this list and generates one literal import per plugin for each of +those, so a page or an editing screen loads with the route that needs it rather +than with the config. See [Configuration](/docs/dev/configuration). + +Its translations need one more line, in `src/locales/packages.ts` - see +[Languages & Localization](/docs/dev/i18n). </Step> <Step> diff --git a/apps/web/content/docs/dev/setup.mdx b/apps/web/content/docs/dev/setup.mdx index 809497e35..21216b992 100644 --- a/apps/web/content/docs/dev/setup.mdx +++ b/apps/web/content/docs/dev/setup.mdx @@ -4,7 +4,12 @@ description: Create a VitNode app, prepare Postgres, start TanStack Start, and s icon: Rocket --- -import { BookOpenIcon, PackagePlusIcon, ServerIcon } from 'lucide-react' +import { + BookOpenIcon, + PackagePlusIcon, + ServerIcon, + SettingsIcon, +} from 'lucide-react' import { Tab, Tabs } from 'fumadocs-ui/components/tabs' You need Node.js 22+, Postgres (or Docker), and your favorite package manager. @@ -122,6 +127,12 @@ and let it own the feature. ## Keep going <Cards> + <Card + icon={<SettingsIcon />} + title="Configuration" + description="Locales, plugins, request middleware, and what a browser may hold." + href="/docs/dev/configuration" + /> <Card icon={<PackagePlusIcon />} title="Create a plugin" diff --git a/apps/web/content/docs/guides/blog.mdx b/apps/web/content/docs/guides/blog.mdx index a8c62c9f4..adeb814f9 100644 --- a/apps/web/content/docs/guides/blog.mdx +++ b/apps/web/content/docs/guides/blog.mdx @@ -69,15 +69,11 @@ export const packageMessages = { ``` ```ts title="apps/web/src/vitnode.config.ts" -import { CONFIG_PLUGIN as BLOG } from '@vitnode/blog/const' // [!code ++] -import { buildPlugin } from '@vitnode/core/lib/plugin' // [!code ++] +import { blogPlugin } from '@vitnode/blog/config' // [!code ++] export const vitNodeConfig = buildConfig({ plugins: [ - buildPlugin({ - messages: packageMessages[BLOG.pluginId], - pluginId: BLOG.pluginId, - }), // [!code ++] + blogPlugin(), // [!code ++] ], }) ``` diff --git a/apps/web/src/i18n.ts b/apps/web/src/i18n.ts deleted file mode 100644 index eb65c5a97..000000000 --- a/apps/web/src/i18n.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { VitNodeI18nConfig } from '@vitnode/core/lib/i18n/types' - -/** - * The languages this app serves. - * - * Its own module, the way `apps/docs` has one, so the web config and (later) the - * API config can point at the same object instead of drifting apart - the site - * and the emails it sends have to agree on which languages exist. - * - * Packages ship their own translations, so nothing here lists them: `pl` has no - * `messages` entry and falls back to `en` key by key. Add - * `messages: { pl: { "@vitnode/core": () => import("./locales/...") } }` to - * reword something without forking the package that owns it. - */ -export const i18n = { - defaultLocale: 'en' as const, - /** - * Explicit, because the app renders on a server: without one, `use-intl` - * formats dates in whatever zone the server happens to run in and warns that - * the client will disagree. Stage 3, which owns the locale runtime, is where a - * per-visitor zone would come from. - */ - timeZone: 'UTC', - /** - * `as const` on each code, and nothing else: it keeps `"en" | "pl"` out of - * the widening `satisfies` would otherwise do, which is what makes `Locale` - * in `lib/i18n/shared.ts` a real union rather than an alias for `string`. - */ - locales: [ - { - code: 'en' as const, - name: 'English', - }, - { - code: 'pl' as const, - name: 'Polski', - }, - ], -} satisfies VitNodeI18nConfig diff --git a/apps/web/src/lib/admin-nav.ts b/apps/web/src/lib/admin-nav.ts index d8b6e8542..4bf9c81e1 100644 --- a/apps/web/src/lib/admin-nav.ts +++ b/apps/web/src/lib/admin-nav.ts @@ -17,13 +17,13 @@ import { pluginAdminNav } from '#/admin-nav.gen' * * ## Why it is not read from `vitnode.config.ts` * - * That config is server-side on purpose (`vitnode.shell.config.ts` explains the - * split): it carries message loaders and API wiring, which a browser bundle has - * no business holding. A sidebar needs the ids, the hrefs, the permissions, the - * icons and the content type definitions, all of which are plain data. The - * generated projection is exactly that half, so the browser gets the navigation - * without the Content Engine's UI - which arrives, when a content screen - * actually renders, through `src/lib/content-registry.ts` instead. + * The config does carry each plugin's registration, and a Next.js host walks it + * in its render pass. Reading it here would make every configured plugin's + * editing screens reachable from the module the document shell imports, which is + * the one graph that is never lazy. The generated projection carries exactly + * what a sidebar needs instead - ids, hrefs, permissions, icons and content type + * definitions, all plain data - and the Content Engine's UI arrives separately, + * when a content screen actually renders, through `src/lib/content-registry.ts`. * * ## Module scope, and why that matters twice * diff --git a/apps/web/src/lib/content-registry.ts b/apps/web/src/lib/content-registry.ts index c5c8af61d..331b9d135 100644 --- a/apps/web/src/lib/content-registry.ts +++ b/apps/web/src/lib/content-registry.ts @@ -20,12 +20,13 @@ import { pluginContentTypes } from '#/content-registry.gen' * * ## Why it is not read from `vitnode.config.ts` * - * That config is server-side on purpose (`vitnode.shell.config.ts` explains the - * split): it carries message loaders and API wiring, which a browser bundle has - * no business holding. The generated projection is the browser-safe half - the - * definitions, the icons and the override components - so the AdminCP gets the - * content screens without the server config. The same arrangement - * `src/lib/admin-nav.ts` uses for the sidebar, one layer deeper. + * The config does carry each plugin's registration, and a Next.js host reads it + * from there. What it cannot give is *when*: it is imported by the document + * shell, so anything reachable from it is reachable eagerly. This module is + * loaded behind a dynamic `import()` in `src/router.tsx` instead, so the + * definitions, the icons and the override components arrive with the content + * route - the same arrangement `src/lib/admin-nav.ts` uses for the sidebar, one + * layer deeper. * * ## Registration, and where it belongs in the import graph * diff --git a/apps/web/src/lib/document-headers.ts b/apps/web/src/lib/document-headers.ts deleted file mode 100644 index cd6690174..000000000 --- a/apps/web/src/lib/document-headers.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * What a VitNode document response may say about being stored. - * - * `private, no-store`, and it is not a precaution - it is a description of what - * is in the body. Every page this application renders streams a dehydrated Query - * cache into its HTML, and that cache always holds `["vitnode","session"]`: the - * visitor's own name, avatar and `isAdmin` flag. Inside `/admin` it also holds - * `["vitnode","admin-session"]`, which is that administrator's entire permission - * set. `tanstack/auth/session-query` and `tanstack/admin/session-query` both say - * so in their own words - "that document is personalised and must not be served - * from a shared cache" - and until now nothing on the response said it back. - * - * Nothing caches these documents today. That is a property of the current - * deployment, not of the application: the moment a CDN, a reverse proxy or a - * `Cache-Control`-respecting edge sits in front of the Node server, an absent - * directive is an invitation to store one visitor's HTML and serve it to the - * next. Stage 15 is when that becomes likely, so the header belongs here now. - * - * ## It is an invariant, not a default - * - * This started as a fallback for documents that said nothing, and that was - * wrong. A default is something a route may override, and at this architecture - * level there is no override a route could correctly choose: the dehydrated - * cache is written into the stream by `setupRouterSsrQueryIntegration` for - * *every* document, so a route opting into `public, max-age=60` would be - * publishing whichever visitor rendered first to everyone who asked next. The - * route cannot know that, because the private payload is not something the route - * put there. - * - * So the directive is forced rather than filled in, and a route that sets its - * own is overwritten rather than obeyed. That is the whole difference between - * this being a hardening measure and it being a security invariant. - * - * Public document caching is not forbidden forever - it is forbidden *while the - * session is dehydrated into the document*. Introducing it later is a separate, - * explicit piece of architecture in which the private state is kept out of the - * shared body (a public shell fetching its session client-side, say), and the - * invariant here would move with it rather than being quietly relaxed by a route - * that wanted a faster page. - * - * `private` bars a shared cache. `no-store` bars every cache, including the - * browser's own disk cache - which is the half that matters on a shared machine, - * where the previous person's permission set should not be recoverable from - * `chrome://cache` after they sign out. The pair is the standard spelling for - * "this body belongs to exactly one person, once". - * - * The known cost is the back/forward cache. `no-store` used to make a page - * outright ineligible for bfcache in Chromium; current versions keep such pages - * eligible but evict them when cookies change - which for this app means a - * sign-in or sign-out invalidates a back-navigation that would otherwise have - * restored a page rendered for the previous session. That is the correct trade - * and the outcome anybody would want, but it is a real difference and it is - * worth a look during a manual pass rather than a surprise later. - */ -export const DOCUMENT_CACHE_CONTROL = 'private, no-store' - -/** - * Whether this response is one of the documents the rule above describes. - * - * One question, and it is what keeps the API out of it. `/api/*` is served by - * the Hono bridge through this same middleware, and a bare `GET` from it carries - * no `Cache-Control` of its own - so a rule that applied to every response would - * quietly forbid clients from caching the API. An HTML content-type is the - * honest way to ask "is this a page", it needs no path list to be kept in step - * with the router, and it cannot be wrong about a response that has already been - * produced. - * - * It deliberately does *not* ask whether a directive is already present. That - * used to be the second half of this predicate, and it is exactly the exemption - * the invariant above cannot afford - see {@link applyDocumentCacheControl}. - * - * A redirect is deliberately not matched here - it has no content type - and is - * handled by {@link applyRedirectCacheControl} instead, which wants a narrower - * rule. - */ -const isRenderedDocument = (headers: Headers): boolean => - (headers.get('content-type') ?? '').toLowerCase().startsWith('text/html') - -/** - * Says what a rendered document is, on the response about to be sent. - * - * `set` rather than a conditional fill, and that is the fix: whatever the - * response was carrying is replaced. A route cannot opt out, because a route is - * not in a position to know what is in the body it is opting out for. - * - * Only `text/html` is touched. Everything else the middleware sees - the API, - * assets, client chunks, a `204` with no content type at all - keeps whatever it - * had, including nothing. - * - * Mutates rather than returning a new `Response`, because the middleware already - * holds the one Start produced and rebuilding it would mean copying a stream. - * The same reason `set-cookie` is appended in place a few lines away. - */ -export const applyDocumentCacheControl = (response: Response): void => { - if (!isRenderedDocument(response.headers)) return - - response.headers.set('cache-control', DOCUMENT_CACHE_CONTROL) -} - -/** - * The same for a locale redirect, but only when it is carrying a cookie. - * - * A `308` from `/en/discover` to `/discover` is a fact about URLs, identical for - * every visitor, and permanently cacheable - which is most of the point of - * answering with one. So it keeps that property by default. - * - * The exception is the redirect that also writes the locale cookie, which is - * what `/pl/admin` produces: a stored copy of that would hand the next visitor - * through the same shared cache a `Set-Cookie` chosen by somebody else, and - * quietly switch their language. Shared caches are generally expected to refuse - * a `Set-Cookie` response, but "generally expected" is not a property this - * application can assert about somebody else's proxy, and one visitor's cookie - * reaching another's browser is not the kind of thing to leave to convention. - * - * So the cookie-carrying case is forced, for the same reason the document is: a - * `public` directive already on such a redirect is overwritten rather than - * respected, because the thing that makes it unsafe to share is the `Set-Cookie` - * beside it and not whatever the directive claims. - * - * The cookie-less case keeps its existing semantics untouched, directive and - * all. It carries no private state, so there is nothing here to protect and - * nothing to override. - */ -export const applyRedirectCacheControl = (response: Response): void => { - if (!response.headers.has('set-cookie')) return - - response.headers.set('cache-control', DOCUMENT_CACHE_CONTROL) -} diff --git a/apps/web/src/lib/i18n/runtime.ts b/apps/web/src/lib/i18n/runtime.ts index 426d66066..16b03c585 100644 --- a/apps/web/src/lib/i18n/runtime.ts +++ b/apps/web/src/lib/i18n/runtime.ts @@ -2,8 +2,8 @@ import { createServerFn } from '@tanstack/react-start' import { configureIntl, validateIntlInput } from '@vitnode/core/tanstack/i18n' import { IntlProvider } from 'use-intl' -import { i18n } from '#/i18n' import { loadIntlMessages } from '#/server/messages.server' +import { vitNodeConfig } from '#/vitnode.config' /** * One language's messages for one set of namespaces, fetched on the server. @@ -29,9 +29,9 @@ export const getIntlMessages = createServerFn() * Everything in `@vitnode/core/tanstack/i18n` reads what this registers, so a * route file imports `RouteMessages` and `intlQueryOptions` straight from the * package. What must not happen is a route running before this module has been - * evaluated - so the two framework entry points, `src/router.tsx` and - * `src/start.ts`, both import from here, and `src/tests/intl-runtime.test.ts` - * fails if either stops doing so. + * evaluated - so `src/router.tsx`, which owns the route tree, imports from + * here. The request pipeline needs no such guarantee: `createVitNodeStart` + * derives its own locale routing from the config it is handed. * * The registration is at module scope but reads `getIntlMessages` above only by * reference, so the order within this file does not matter: the validator and @@ -61,7 +61,7 @@ export const { * back to client rendering, and the page still appears. */ hostIntlProvider: IntlProvider, - i18n, + i18n: vitNodeConfig.i18n, }) /** diff --git a/apps/web/src/lib/i18n/shared.ts b/apps/web/src/lib/i18n/shared.ts index a1adf91cd..fe618f794 100644 --- a/apps/web/src/lib/i18n/shared.ts +++ b/apps/web/src/lib/i18n/shared.ts @@ -1,4 +1,4 @@ -import type { i18n } from '#/i18n' +import type { vitNodeConfig } from '#/vitnode.config' import { localeRouting } from '#/lib/i18n/runtime' @@ -11,7 +11,7 @@ import { localeRouting } from '#/lib/i18n/runtime' * `string` and takes this union as a type argument where the value originates * (`useLocale<Locale>()`, `resolveLocale<Locale>()`). */ -export type Locale = (typeof i18n.locales)[number]['code'] +export type Locale = (typeof vitNodeConfig.i18n.locales)[number]['code'] export { defaultLocale, localeRouting } from '#/lib/i18n/runtime' diff --git a/apps/web/src/lib/page-head.ts b/apps/web/src/lib/page-head.ts index f2d0ee346..ba1b5adff 100644 --- a/apps/web/src/lib/page-head.ts +++ b/apps/web/src/lib/page-head.ts @@ -1,6 +1,6 @@ import { createRouteHead } from '@vitnode/core/tanstack/metadata' -import { vitNodeShellConfig } from '#/vitnode.shell.config' +import { vitNodeConfig } from '#/vitnode.config' /** * A route's `head`, bound to this app's name. @@ -16,4 +16,4 @@ import { vitNodeShellConfig } from '#/vitnode.shell.config' * * head: ({ loaderData }) => pageHead({ robots: 'index, follow', ...loaderData }) */ -export const pageHead = createRouteHead(vitNodeShellConfig.metadata) +export const pageHead = createRouteHead(vitNodeConfig.metadata) diff --git a/apps/web/src/locales/app.ts b/apps/web/src/locales/app.ts index 9f6afd8b5..1d33c5d5a 100644 --- a/apps/web/src/locales/app.ts +++ b/apps/web/src/locales/app.ts @@ -11,8 +11,8 @@ import type { AppMessagesMap } from '@vitnode/core/lib/i18n/types' * * `@vitnode/blog`'s `pl.json` was the last entry, and it was here for a reason * that has been fixed rather than a reason that stands: it arrived from - * `apps/docs/src/i18n.ts` when Stage 17 deleted that application, and the plugin - * shipped no Polish of its own to fall back to. It does now + * `apps/docs` when Stage 17 deleted that application, and the plugin shipped no + * Polish of its own to fall back to. It does now * (`plugins/blog/src/locales/pl.json`), registered in the plugin's own locale * barrel and in `src/locales/packages.ts` beside its English - so the blog's * AdminCP copy is Polish in this app, in `apps/api`'s emails, and in anybody @@ -25,8 +25,8 @@ import type { AppMessagesMap } from '@vitnode/core/lib/i18n/types' * left out falls back to the package's, and then to the default locale, key by * key. * - * Server-side only, and kept out of `src/i18n.ts` on purpose: these are - * functions, and `src/i18n.ts` is spread into the shell config, which crosses to - * the browser and has to stay serializable. + * Server-side only, and kept out of `src/vitnode.config.ts` on purpose: these + * are functions, and the shared config crosses to the browser and has to stay + * serializable. `src/vitnode.server.config.ts` is what registers this map. */ export const appMessages: AppMessagesMap = {} diff --git a/apps/web/src/locales/packages.ts b/apps/web/src/locales/packages.ts index 3c6cb729d..acd6edc17 100644 --- a/apps/web/src/locales/packages.ts +++ b/apps/web/src/locales/packages.ts @@ -33,12 +33,11 @@ import { CONFIG_PLUGIN as EXAMPLE } from '@vitnode/example/const' * real file and lands in the build as a chunk fetched on demand, which is the * same laziness the barrels wanted. * - * This is the app's *only* copy of that list - `vitnode.config.ts` and - * `server/messages.server.ts` both read it from here - and the cost is one line - * per language a package ships. Removing it means making the barrels - * bundler-safe (locale files as modules rather than JSON, or the packages left - * external through Nitro), which is a packaging change, not an i18n one. - * `src/tests/messages.test.ts` fails if a registered package is missing a line. + * This is the app's only copy of that list - `vitnode.server.config.ts` reads it + * from here - and the cost is one line per language a package ships. Removing it + * means making the barrels bundler-safe (locale files as modules rather than + * JSON, or the packages left external through Nitro), which is a packaging + * change, not an i18n one. */ export const packageMessages: Record<string, LocaleMessagesMap> = { [BLOG.pluginId]: { diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index f4374cebb..cb7b7fc8a 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -14,28 +14,7 @@ import { withCoreRootRoutes, } from '@vitnode/core/tanstack/routes' -/** - * The auth transport, registered by importing the module that declares it. - * - * `@vitnode/core/tanstack/auth` owns every auth decision this app makes but may - * not declare a `createServerFn` - uncompiled on the server, one silently - * resolves to `undefined` - so `lib/auth.ts` declares the eight wrappers and - * hands them over at module scope. A bare import because there is nothing to - * name: the registration *is* the module's effect. - * - * Here because a router is the one module both entry points load, so the - * registration has happened before any route, loader or component can reach for - * it, in the browser bundle and on the server alike. - */ import './lib/auth' -/** - * The admin transport, registered the same way and for the same reason. - * - * One server function rather than eight, reading the AdminCP's own session under - * its own cookie. It is a separate registration from the auth one on purpose: - * they are two sessions, two cookies and two cache entries, and nothing in - * VitNode may let the public session answer an admin question. - */ import './lib/admin-auth' import { createLocaleRewrite, localeRouting } from './lib/i18n/runtime' import { pageHead } from './lib/page-head' @@ -44,83 +23,9 @@ import { Route as adminShellRoute } from './routes/_admin' import { Route as mainShellRoute } from './routes/_main' import { routeTree as fileRouteTree } from './routeTree.gen' -/** - * The Content Engine registry, behind a literal dynamic import. - * - * Awaited by the one loader that needs it - `/admin/content/*` - rather than - * imported here. Building the registry reaches `@vitnode/core/content` and - * every configured plugin's admin form components, and this module is the one - * the client entry evaluates on every page: as a static import it put `zod`, - * both plugins' content registrations, the content form primitives and - * `react-hook-form` in front of the homepage's first paint. See - * `CoreAdminRouteContext.loadContentRegistry`. - */ const loadContentRegistry = async () => (await import('#/lib/content-registry')).contentRegistry -/** - * One route tree: this app's route files, plus the AdminCP screens `@vitnode/core` - * owns, plus the pages its plugins declare. - * - * At module scope rather than inside `getRouter`, because `getRouter` runs once - * per server request and mounting the plugin routes mutates the route tree - the - * generated tree is a module singleton. `withPluginRoutes` is idempotent anyway; - * doing it once is simply where it belongs. - * - * The plugin half comes from one generated file: a static import of each - * configured plugin's own route tree. No plugin page is copied into - * `src/routes`, no route path is written by hand, and nothing here knows which - * plugins are installed - see `@vitnode/core/tanstack/plugin-routes`. - * - * A page is reached only through the literal `lazy(() => import(...))` its route - * declared, so every one of them is a chunk of its own. The one part of a plugin - * route that is not lazy is a `search` schema: a router's `validateSearch` runs - * during path matching, before any chunk is fetched, so it lives in the tree - * rather than in the page. - * - * `mountUnder` names one route per shell, which is the whole of what "a plugin - * route renders in the application shell" amounts to here. A plugin declares - * `area: "main"` or `area: "admin"`; `_main` is the route that renders the - * public shell and `_admin` the one that renders the AdminCP, and being a child - * of one of them is what gives `/example` the header and the one `<main>` that - * `/discover` has, or gives `/admin/reports` the sidebar, the breadcrumb area - * and the admin session guard that `/admin/core` has. No new field, no per-route - * layout metadata and no second copy of either shell - route composition, which - * the area declaration already described. - * - * Neither shell changes a path: both are pathless, so `/example` stays - * `/example` and an admin plugin route's `/admin/…` is the path its own route - * spells out in full. An area VitNode knows and this app has not named here - * fails the composition rather than being mounted under the other one. - * - * `_main` and `_admin` are imported for their route objects, and they are the - * same objects the generated tree holds: `createFileRoute` produces one instance - * per module and `routeTree.gen.ts` mutates it in place. - * - * `withCoreMainRoutes`, `withCoreAdminRoutes` and `withCoreRootRoutes` mount - * core's own screens the same way, one per mount point: the public pages under - * the main shell, the AdminCP's under its own, and the shell-less ones - the auth - * cards and the AdminCP sign-in - straight under the root. See - * `@vitnode/core/tanstack/routes`. - * - * `localeRouting` is handed to the last of the three because a sign-in navigates - * to a path a *visitor* supplied: the route tree carries no locale, so the prefix - * has to be stripped before the router sees it, and which prefixes exist is this - * app's answer. It is the same object the `rewrite` below uses, so the strip and - * the write-back are one rule. They were a directory of route files in this application until it - * existed - one `createFileRoute` per screen, every one of them pure wiring - * around something imported from `@vitnode/core` - so an app carried a copy of - * VitNode's own routing table and core adding a screen meant an edit here. They - * are code-based rather than declared as plugin routes because they need the - * router's full option set: a real `validateSearch` that clamps `?page=999` - * before anything renders, and a splat path a plugin route path does not - * represent. - * - * `pageHead` is this app's own `createRouteHead(metadata)` binding, handed over - * because a package cannot know the site's name: a plugin page's `<title>` goes - * through the same `"<page> - <site>"` rule every other VitNode page's does, - * rather than through a second one the plugin invented. - */ const routeTree = withCoreRootRoutes( withCoreAdminRoutes( withCoreMainRoutes( @@ -135,65 +40,8 @@ const routeTree = withCoreRootRoutes( { localeRouting, mountUnder: fileRouteTree, pageHead }, ) -/** - * The app's router, and the QueryClient it owns. - * - * Start calls this once per server request and once in the browser, which is - * exactly the lifetime a QueryClient should have: created here, it is per - * request on the server - never a module-level client shared by every visitor - * being rendered at once - and a single long-lived one on the client. - * - * It goes into the router context, so a route loader reaches it as - * `context.queryClient` and can `ensureQueryData` before its component renders. - * That is the whole point of putting it here rather than in a provider: a - * loader runs before React does, so a client mounted by a component would be - * out of reach of the code that most wants it. - * - * `setupRouterSsrQueryIntegration` wires the two together: it dehydrates the - * cache into the SSR stream (including queries that resolve mid-render), - * hydrates it on the client before the first render, routes `redirect()` thrown - * inside a query or mutation through the router, and wraps the app in the one - * `QueryClientProvider` for this client. Nothing else in this app may create a - * `QueryClient` or a provider for one - two clients in a page means a query a - * loader cached is invisible to the component that reads it. - * - * `defaultPreloadStaleTime: 0` leaves caching to Query rather than having the - * router keep a second copy of the same data with its own expiry. - * - * `defaultStaleReloadMode: 'blocking'` is what makes a route's pending shape - * reachable at all once preloading is on. Router core's default is - * `'background'`, and a background reload never opens a pending window - but it - * still waits for the route's component chunk before it commits. So the common - * desktop path, hover a link and click it, took the one branch that renders - * nothing: the hover filled the loader, the click was therefore a background - * reload, and the chunk downloaded with the previous page still on screen and - * no skeleton in sight. - * - * It costs nothing here because a VitNode loader does not block on a warm cache: - * `ensureQueryData` with `revalidateIfStale` hands back the cached entry and - * refreshes behind it, so "blocking" describes a promise that resolves in a - * microtask. What changes is only that the router now marks the match pending - * while that happens, which is what `defaultPendingMs` is for. - * - * `defaultPendingMs: 150` is that threshold, and it is not zero for the same - * reason. At zero, every navigation opens a pending window, so - * `defaultPendingMinMs` holds a *fully cached* navigation behind a skeleton for - * 300ms - a page that could have been instant, made slow to look busy. At 150ms - * a cached navigation goes straight through with nothing shown and a slow one - * still gets its shape, in the content area and the breadcrumb together. - * - * `rewrite` is what makes one route tree serve two public URL shapes: `/pl/...` - * arrives, `/...` is matched, and every link the router builds gets the prefix - * back. No route file mentions a locale, so nothing here has to be duplicated - * per language - see `@vitnode/core/tanstack/i18n`. - */ export function getRouter() { const queryClient = createVitNodeQueryClient() - - // The rewrite reads the locale off the router's own current location, and the - // router needs the rewrite to parse that location - so it is handed a getter - // rather than the router itself. `output` only ever runs once a link is built, - // which is long after the assignment below. const holder: { current?: AnyRouter } = {} const router = createTanStackRouter({ diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 53a5fcc6f..f081553ae 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -24,11 +24,11 @@ import { import type { Locale } from '#/lib/i18n/shared' -import { vitNodeShellConfig } from '#/vitnode.shell.config' +import { vitNodeConfig } from '#/vitnode.config' import appCss from '../styles.css?url' -const { debug, i18n, metadata, theme } = vitNodeShellConfig +const { debug, i18n, metadata, theme } = vitNodeConfig /** * What the router itself provides, before any route has run. diff --git a/apps/web/src/routes/_admin/admin.core.index.tsx b/apps/web/src/routes/_admin/admin.core.index.tsx index b4b3eae54..3c68eda44 100644 --- a/apps/web/src/routes/_admin/admin.core.index.tsx +++ b/apps/web/src/routes/_admin/admin.core.index.tsx @@ -42,13 +42,12 @@ import { CardsPendingSkeleton } from '@vitnode/core/tanstack/pending' * * ## `pluginWidgets` is not passed * - * A plugin's dashboard widgets reach the Next.js board through - * `getVitNodeConfig()`, which is server-side config kept out of this app's - * browser bundle by `vitnode.shell.config.ts` - and this app registers its - * plugins by id and messages only (see `src/vitnode.config.ts`). So the board - * shows core's own widgets, which is the complete set for this install. It is - * the same seam `AdminShell` leaves open for nav `declarations`, and it changes - * here when plugin AdminCP registration moves over. + * A plugin declares widgets in its `admin.dashboard.widgets`, which reaches a + * Next.js board through `getVitNodeConfig()`. This route does not read the + * config - the AdminCP takes its plugin data from the generated projections, and + * neither of those carries widgets - so the board shows core's own, which is the + * complete set for this install because no configured plugin declares any. It is + * the same seam `AdminShell` leaves open for nav `declarations`. */ export const Route = createFileRoute('/_admin/admin/core/')({ loader: async ({ context }) => await loadAdminDashboardRoute(context), diff --git a/apps/web/src/server/messages.server.ts b/apps/web/src/server/messages.server.ts index 6757a1814..a398e1312 100644 --- a/apps/web/src/server/messages.server.ts +++ b/apps/web/src/server/messages.server.ts @@ -1,8 +1,7 @@ import '@tanstack/react-start/server-only' import { createIntlMessagesLoader } from '@vitnode/core/tanstack/i18n/server' -import { packageMessages } from '#/locales/packages' -import { vitNodeConfig } from '#/vitnode.config' +import { vitNodeServerConfig } from '#/vitnode.server.config' export type { IntlMessages } from '@vitnode/core/tanstack/i18n/server' @@ -15,15 +14,8 @@ export type { IntlMessages } from '@vitnode/core/tanstack/i18n/server' * place, the per-key fallback to the default locale, and the namespace pick that * keeps every plugin's AdminCP copy out of a page that renders none of it. * - * What is this app's, and can only be: the plugins it registered, the languages - * it declares, and `packageMessages` - the static loaders that stand in for each - * package's own locale barrel, because a barrel's - * `import("./en.json", { with: { type: "json" } })` is a specifier Rollup will - * not follow. See `src/locales/packages.ts` for the reproduction. + * What is this app's is the server config it is handed - the plugins it + * registered, the languages it declares, and the loaders that stand in for each + * package's own locale barrel. */ -export const loadIntlMessages = createIntlMessagesLoader({ - appMessages: vitNodeConfig.i18n.messages, - defaultLocale: vitNodeConfig.i18n.defaultLocale, - packageMessages, - plugins: vitNodeConfig.plugins, -}) +export const loadIntlMessages = createIntlMessagesLoader(vitNodeServerConfig) diff --git a/apps/web/src/start.ts b/apps/web/src/start.ts index fd6f0091f..8e2049a90 100644 --- a/apps/web/src/start.ts +++ b/apps/web/src/start.ts @@ -1,88 +1,13 @@ -import { - createCsrfMiddleware, - createMiddleware, - createStart, -} from '@tanstack/react-start' -import { handleLocaleRequest } from '@vitnode/core/tanstack/i18n/server' +import { createVitNodeStart } from '@vitnode/core/tanstack/start' -import { - applyDocumentCacheControl, - applyRedirectCacheControl, -} from '#/lib/document-headers' -import { localeRouting } from '#/lib/i18n/runtime' - -/** - * Locale routing, as the first thing that happens to a request. - * - * A global request middleware rather than a wrapper around the server entry: - * Start runs these before route matching and before SSR, which is exactly where - * a canonical redirect belongs - the alternative is rendering a page and then - * throwing it away. - * - * `handlerType` narrows it to page requests. Server function calls arrive on - * `/_serverFn/*` with `handlerType: "serverFn"`, and redirecting an RPC to a - * canonical URL would break it rather than tidy it. - * - * `/api/*` reaches here too and is deliberately ignored by - * `handleLocaleRequest`, so the Stage 1 Hono bridge sees the request exactly as - * the client sent it. - * - * `localeRouting` is handed in rather than read from the package's registered - * runtime: Start runs request middleware before route matching, so this is the - * one caller that cannot assume the router entry has been evaluated. Importing - * it from `#/lib/i18n/runtime` is also what guarantees this app's i18n is - * configured before the first request touches it. - * - * ## And what a document is allowed to say about itself - * - * The cache directive rides along here for one reason: this is already the only - * place in the application that holds every page response, before and after - * rendering, and a second middleware would be a second thing to remember. Every - * document this app produces carries a dehydrated Query cache containing the - * visitor's own session - and, under `/admin`, an administrator's whole - * permission set - so none of them may be stored by a shared cache. See - * `#/lib/document-headers`, which owns the rule and says why; this file only - * applies it. - */ -const localeMiddleware = createMiddleware().server( - async ({ handlerType, next, request }) => { - if (handlerType !== 'router') return await next() - - const { redirect, setCookie } = handleLocaleRequest(request, localeRouting) - if (redirect) { - applyRedirectCacheControl(redirect) - - return redirect - } - - const result = await next() - - // `append`, not `set`: the API mounted at `/api/*` and the auth flow both - // mint their own cookies, and overwriting the header would sign people out. - if (setCookie) result.response.headers.append('set-cookie', setCookie) - - // After the cookie, so a document that just wrote one is covered by the - // same directive as one that did not. Only an HTML response is touched - - // `/api/*` reaches here too, and the API's own caching is not this - // middleware's to decide. - applyDocumentCacheControl(result.response) - - return result - }, -) +import { vitNodeConfig } from '#/vitnode.config' /** * This app's Start instance. * - * `createCsrfMiddleware` is not optional here. Start installs it *only* while an - * app declares no `requestMiddleware` of its own - the moment this file exists, - * the default is replaced by whatever it lists, and leaving CSRF out would - * expose every server function as an unauthenticated cross-site endpoint. It is - * declared first so it runs before anything else. + * The whole request pipeline is VitNode's: CSRF on server functions, canonical + * locale redirects with the remembered-locale cookie, and the `private, + * no-store` directive every document carrying a dehydrated session needs. Pass + * `requestMiddleware` to add this app's own, which runs after all of it. */ -export const startInstance = createStart(() => ({ - requestMiddleware: [ - createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === 'serverFn' }), - localeMiddleware, - ], -})) +export const startInstance = createVitNodeStart({ config: vitNodeConfig }) diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 0809378bf..a21ddb15c 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1,72 +1,14 @@ @import 'tailwindcss'; @import 'shadcn/tailwind.css'; @import 'tw-animate-css'; - -/* - * Fumadocs' design system, in this stylesheet rather than in one the `/docs` - * route loads on its own. - * - * It was a second stylesheet, and that was measured and is wrong. Two facts make - * it impossible: - * - * 1. **A route stylesheet is never unloaded.** TanStack Router renders every - * `rel="stylesheet"` link with React's `precedence` attribute (see `Asset.tsx` - * in `@tanstack/react-router`), and React 19 hoists a stylesheet with a - * precedence into `<head>` and leaves it there for the life of the document - - * deliberately, so that navigating back does not flash unstyled content. So - * "only `/docs` pays for it" is not a thing the framework can express: one - * visit to the documentation and the sheet is on every page after it. - * - * 2. **Two Tailwind builds fight inside one `@layer utilities`.** The docs sheet - * emitted 61 kB of utilities, 265 of whose class selectors also exist here. - * Loaded second, every one of those 265 rules outranked this file's copy - - * including its variants, because a variant is just a later rule in the same - * layer. `.hidden` from the docs sheet beat `.sm:block` from this one, so - * `LogoVitNodeBrand` - `hidden w-34 sm:block` - stayed hidden at every width - * and the site header lost its wordmark, on the home page, but only after - * somebody had visited `/docs` first. - * - * One Tailwind build per document is the fix, and it is not a compromise: the - * merged sheet is smaller than the two apart, because those 265 duplicates - * collapse. The cost is that a visitor who never opens the documentation still - * downloads its theme. That is a real cost and it is the smaller one. - * - * `src/tests/docs-route.test.ts` holds the line. - */ @import 'fumadocs-ui/css/neutral.css'; @import 'fumadocs-ui/css/preset.css'; -/* - * VitNode's components ship compiled, so Tailwind cannot find their classes by - * scanning this app's source. Pointing it at core's build output is what makes - * the toaster, the tooltip and everything else that follows look like VitNode - * instead of unstyled markup - the two lines `apps/docs` needs, and a third that - * only a TanStack host does. - * - * `dist/src/tanstack` is that third, and leaving it off is what made the site - * header render with no logo. `@vitnode/core/tanstack/*` is markup as much as it - * is orchestration - the header bar, the language switcher, the debug ribbon, - * the not-found screen - and a class written *there* exists nowhere else: a - * Next.js app writes `w-34` in its own `layout.tsx`, where Tailwind finds it, - * while this app inherits the header's default and writes nothing at all. So - * `.w-34` was never generated, the wordmark had no width, and an `<svg>` with a - * viewBox and no width collapses rather than falling back to its intrinsic size. - * `src/tests/home-route.test.ts` pins this line for that reason. - * - * Fumadocs ships compiled too, and for the same reason: the documentation's - * chrome - the sidebar, the search dialog, the table of contents - is entirely - * its classes, and none of them is written in this repository. - */ @source "../node_modules/@vitnode/core/dist/src/components"; @source "../node_modules/@vitnode/core/dist/src/tanstack"; @source "../node_modules/@vitnode/core/dist/src/views"; @source "../node_modules/fumadocs-ui/dist/**/*.js"; -/* - * The design tokens every VitNode component reads. `.dark` is what - * `ThemeScript` and `ThemeProvider` put on `<html>`, so the whole palette - * switches from that one class. - */ :root:not(.dark) { --background: oklch(0.97 0 0); --foreground: oklch(0.145 0 0); @@ -184,18 +126,6 @@ --radius-4xl: calc(var(--radius) * 2.6); } -/* - * Each documentation section's accent, applied by `DocsShellContent` as a class - * on the docs wrapper. `--color-fd-primary` is what Fumadocs paints the active - * sidebar entry, the tab underline and every link inside a document with. - * - * The values are the Next.js application's, unchanged - but the selectors are - * not: there they were `html.dev`, painted before first paint by an inline - * script because a Next.js layout could not read the slug without going dynamic. - * Here the shell has the router's location in hand and puts the class on its own - * wrapper, which is correct in the first byte of SSR and cannot leak onto a page - * outside the documentation. See `src/docs/section.ts`. - */ :root:not(.dark) { --dev-color: oklch(0.6 0.18 50); --ui-color: oklch(0.65 0.18 170); @@ -220,14 +150,6 @@ --color-fd-primary: var(--ui-color); } -/* - * Last, and that is what makes it win. - * - * `fumadocs-ui/css/lib/base.css` sets `body { background-color: - * var(--color-fd-background) }` in this same layer, so a documentation page - * would otherwise quietly switch to Fumadocs' neutral palette while every other - * page used VitNode's. - */ @layer base { * { @apply border-border outline-ring/50; diff --git a/apps/web/src/vitnode.api.config.ts b/apps/web/src/vitnode.api.config.ts index 09a28ff97..d7dda3fe4 100644 --- a/apps/web/src/vitnode.api.config.ts +++ b/apps/web/src/vitnode.api.config.ts @@ -6,63 +6,26 @@ import { SupabaseStorageAdapter } from '@vitnode/supabase-storage' import { config } from 'dotenv' import { drizzle } from 'drizzle-orm/postgres-js' -import { i18n } from './i18n' +import { vitNodeConfig } from './vitnode.config' -// Vite's own `loadEnv` populates `process.env` at config time, which covers -// `vite dev` and `vite build` but not `node .output/server/index.mjs`. dotenv -// covers the production server too, the same way `apps/api` does it. config({ quiet: true }) export const POSTGRES_URL = process.env.POSTGRES_URL ?? 'postgresql://root:root@localhost:5432/vitnode' -/** - * The API this app serves at `/api/*`, identical in shape to the config - * `apps/api` builds. Nothing here is TanStack-specific: the Hono application is - * unchanged, only the runtime that hands it requests is. - * - * Left out on purpose, because each one is a deployment decision rather than - * part of the mount: `email`, `storage`, `ai`, `cron` and the SSO adapters. Add - * them exactly as `apps/api/src/vitnode.api.config.ts` does when this app needs - * them - `buildApiConfig` treats all of them as optional. - */ export const vitNodeApiConfig = buildApiConfig({ plugins: [blogApiPlugin(), exampleApiPlugin()], storage: { - // Zero-config: writes to `public/uploads` and serves via Hono static files. - // adapter: LocalStorageAdapter(), - // Re-encode uploaded images with sharp to shrink them before storing. image: { quality: 85, }, - // adapter: S3StorageAdapter({ - // bucket: process.env.S3_BUCKET, - // region: process.env.S3_REGION, - // accessKeyId: process.env.S3_ACCESS_KEY_ID, - // secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, - // endpoint: process.env.S3_ENDPOINT, // Cloudflare R2 endpoint - // publicUrl: process.env.S3_PUBLIC_URL, - // }), adapter: SupabaseStorageAdapter({ url: process.env.SUPABASE_URL, secretKey: process.env.SUPABASE_SECRET_KEY, bucket: process.env.SUPABASE_STORAGE_BUCKET, }), }, - /** - * The same locale declaration `vitnode.shell.config.ts` spreads into - * `buildConfig`, because this app serves the site and the API from one - * process: `src/i18n.ts` is the installation's one statement of which - * languages exist, and both configs read it rather than each carrying a list - * that agrees until it doesn't. - * - * It is also what a database bootstrap seeds `core_languages` from. This app - * delegates that to `apps/api` (see the `dev` script), so the list here is not - * what runs in this repository - but it is what runs in a generated single - * app, which owns its own schema, and it is what this app's emails and - * `c.get("i18n")` render in either case. - */ - i18n, + i18n: vitNodeConfig.i18n, dbProvider: drizzle({ connection: POSTGRES_URL, relations: coreRelations, diff --git a/apps/web/src/vitnode.config.ts b/apps/web/src/vitnode.config.ts index 20d981da5..abb041b3f 100644 --- a/apps/web/src/vitnode.config.ts +++ b/apps/web/src/vitnode.config.ts @@ -1,60 +1,23 @@ -import { CONFIG_PLUGIN as BLOG } from '@vitnode/blog/const' -import { buildPlugin } from '@vitnode/core/lib/plugin' +import { blogPlugin } from '@vitnode/blog/config' import { buildConfig } from '@vitnode/core/vitnode.config' -import { CONFIG_PLUGIN as EXAMPLE } from '@vitnode/example/const' +import { examplePlugin } from '@vitnode/example/config' -import { appMessages } from './locales/app' -import { packageMessages } from './locales/packages' -import { vitNodeShellConfig } from './vitnode.shell.config' - -/** - * This app's frontend config, in the shape every VitNode app builds it. - * - * The plugins are registered by id and translations, not through their own - * `blogPlugin()` / `examplePlugin()` entries. That is now a *scope* decision - * rather than a compatibility one, and the difference is worth stating because - * the old reason is gone: until Stage 13 those entries reached a Tiptap editor - * field, a form layout and a table cell that pulled in `next/dynamic`, so - * importing one here was impossible. It no longer is - a plugin's whole frontend - * registration is Next-free, and `src/tests/isolation.test.ts` asserts it. - * - * They still are not imported here, because of what this object is actually read - * for: its one reader takes `pluginId` and `messages` off each plugin and - * nothing else. A full registration would add every content type's editing - * screen to that server-only graph, where nothing would ever look at them: the - * AdminCP gets its content types from - * `src/content-registry.gen.ts` instead, one literal import per configured - * plugin, so a browser loads them with the content route and not before. - * - * The same split, one layer up, feeds the sidebar. `src/admin-nav.gen.ts` is the - * navigation half - ids, hrefs, permissions, icons and content type definitions, - * and nothing that renders a screen. See `src/lib/admin-nav.ts` and - * `src/lib/content-registry.ts`; both explain why they are generated rather than - * read from here. - * - * Server-side only, and deliberately so - see `vitnode.shell.config.ts`. - * `src/server/messages.server.ts` is the only importer, and it carries the - * `server-only` guard that keeps it that way. - * - * `buildConfig` also registers this object process-wide, which is how core's own - * route files find it without being handed it as a prop. - */ export const vitNodeConfig = buildConfig({ - ...vitNodeShellConfig, - /** - * The shell's locale declaration, plus the message loaders that must not be in - * it: `src/i18n.ts` is spread into the browser-facing shell config, and these - * are functions. - */ - i18n: { ...vitNodeShellConfig.i18n, messages: appMessages }, - plugins: [ - buildPlugin({ - messages: packageMessages[BLOG.pluginId], - pluginId: BLOG.pluginId, - }), - buildPlugin({ - messages: packageMessages[EXAMPLE.pluginId], - pluginId: EXAMPLE.pluginId, - }), - ], + debug: false, + i18n: { + defaultLocale: 'en', + locales: [ + { code: 'en', name: 'English' }, + { code: 'pl', name: 'Polski' }, + ], + timeZone: 'UTC', + }, + metadata: { + shortTitle: 'VitNode', + title: 'VitNode', + }, + plugins: [blogPlugin(), examplePlugin()], + theme: { + defaultTheme: 'system', + }, }) diff --git a/apps/web/src/vitnode.server.config.ts b/apps/web/src/vitnode.server.config.ts new file mode 100644 index 000000000..fdb151bd3 --- /dev/null +++ b/apps/web/src/vitnode.server.config.ts @@ -0,0 +1,12 @@ +import '@tanstack/react-start/server-only' +import { buildServerConfig } from '@vitnode/core/vitnode.config' + +import { appMessages } from '#/locales/app' +import { packageMessages } from '#/locales/packages' +import { vitNodeConfig } from '#/vitnode.config' + +export const vitNodeServerConfig = buildServerConfig({ + config: vitNodeConfig, + messages: appMessages, + packageMessages, +}) diff --git a/apps/web/src/vitnode.shell.config.ts b/apps/web/src/vitnode.shell.config.ts deleted file mode 100644 index 91a4b72c9..000000000 --- a/apps/web/src/vitnode.shell.config.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { VitNodeConfig } from '@vitnode/core/vitnode.config' - -import { i18n } from './i18n' - -/** - * The VitNode config the browser is allowed to see. - * - * Everything in `VitNodeConfig` except `plugins`, and that omission is the whole - * point. The plugin registry carries each plugin's translations as `import()`s - * of JSON inside its `dist`, and eventually its AdminCP components - neither of - * which a browser bundle should hold. In Next.js the boundary is drawn for you: - * `vitnode.config.ts` is only ever read by Server Components, so none of it - * reaches the client. TanStack Start has no such boundary - anything the root - * route imports is in the browser bundle - so the split is made here instead, by - * hand. - * - * `vitnode.config.ts` spreads this into `buildConfig` with the plugins added, so - * there is one source for the metadata, the theme and the locales rather than - * two that agree until they don't. - * - * Everything here is plain, serializable data. That is a rule, not a - * coincidence: this module is imported by the document shell, which renders on - * both sides of hydration. - */ -export const vitNodeShellConfig = { - debug: false, - i18n, - metadata: { - shortTitle: 'VitNode', - title: 'VitNode', - }, - theme: { - defaultTheme: 'system', - }, -} satisfies Omit<VitNodeConfig, 'plugins'> diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 4343c9bf7..1e8f61044 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -2,150 +2,22 @@ import tailwindcss from '@tailwindcss/vite' import { devtools } from '@tanstack/devtools-vite' import { tanstackStart } from '@tanstack/react-start/plugin/vite' import viteReact from '@vitejs/plugin-react' -import { - vitNodeEnv, - vitNodeOptimizeDeps, - vitNodePluginRoutes, -} from '@vitnode/core/framework/vite' +import { vitnode } from '@vitnode/core/framework/vite' import fumadocsMdx from 'fumadocs-mdx/vite' import { nitro } from 'nitro/vite' import { defineConfig } from 'vite' const config = defineConfig({ resolve: { - /** - * `@/…` for files `tsconfig.json` does not cover. - * - * `tsconfigPaths` resolves the alias for everything under `src`, and seven - * documents under `content/docs` import `@/components/fumadocs/img` - the - * framed, zoomable screenshot wrapper. `content` is outside the tsconfig's - * `include`, so no tsconfig applies to an MDX file and the path mapping is - * never consulted; the build fails to resolve the import outright. - * - * The alias restates the mapping the tsconfig already declares, for the one - * kind of file that cannot inherit it. The `@/` in the pattern is - * load-bearing: a bare `@` would prefix-match `@vitnode/core` and - * `@tanstack/react-router` and rewrite both into this app's `src`. - */ alias: [{ find: /^@\//, replacement: `${import.meta.dirname}/src/` }], tsconfigPaths: true, }, - /** - * A second dev server has to fail rather than quietly move. - * - * `tanstackStart()` runs the route generator and *writes* - * `src/routeTree.gen.ts`. Two servers means two generators owning one file: if - * their route lists ever differ - which is precisely what happens when one was - * started before a route file existed - they overwrite each other forever, and - * every write is a full page reload. Without `strictPort` the second `pnpm dev` - * says "Port 3000 is in use, trying another one" and succeeds, so the fight - * starts silently and looks like an inexplicable refresh loop on the first - * server. - */ server: { strictPort: true }, ssr: { - /** - * The VitNode API packages mounted at `/api/*`, kept out of the SSR pass. - * - * They are Node libraries rather than app source: `@vitnode/core` loads its - * locale files with a runtime `import("./en.json", { with: { type: "json" } })` - * relative to its own `dist`. Bundling them in this pass moves that chunk - * and the JSON stops resolving, which fails the build outright. Left - * external here, Nitro resolves them from the package itself. - * - * Vite treats workspace-linked packages as `noExternal` by default, which is - * why they have to be named. - * - * This is also what decides the shape of `@vitnode/core/tanstack/*`, so it is - * worth naming the consequence rather than leaving it to be rediscovered. - * Externalised here, the package skips this pass entirely and Nitro's own - * Rollup run inlines its `dist` afterwards - and nothing in that path runs - * the TanStack Start compiler. The *client* build has no such gap: it inlines - * the package, so the compiler transforms it there. So package code reaches - * the browser compiled and the server un-compiled, which is exactly why the - * package may declare `createIsomorphicFn` (its stub falls back to the - * `.server()` branch, which is what a server wants) and may never declare - * `createServerFn` (un-compiled, its handler resolves to `undefined` with no - * error at all). `packages/vitnode/src/tanstack/boundary.test.ts` holds the - * package to that. - * - * Removing `@vitnode/core` from this list to close the gap was measured and - * does not work: the SSR pass then reaches the locale barrel above and the - * build fails on `Could not resolve './en.json'`. - * - * ## `tslib` is here for an entirely different reason - * - * It is not a VitNode package and it is not a Node library. It is here - * because bundling it is broken, in a way that only appears in a production - * build and only once a page renders. - * - * Fumadocs' dialogs bring in the Radix scroll-lock stack - - * `react-remove-scroll`, `use-sidecar`, `aria-hidden` - and every one of them - * does `import { __extends } from "tslib"`. Under the `node` condition that - * resolves to `tslib/modules/index.js`, a two-line ESM shim around the - * **CommonJS** `tslib.js`: - * - * import tslib from "../tslib.js"; - * const { __extends, … } = tslib; - * - * `tslib.js` defines `__esModule` on its exports, so Rolldown's CJS interop - * helper does not synthesise a `default` - while the call site it generated - * still reads `.default`. Every documentation page that renders a dialog - * then throws `Cannot destructure property '__extends' of undefined`. - * - * Externalised, the file is never bundled: Nitro traces the package into the - * output and Node loads it with Node's own interop, which is correct. An - * alias to `tslib/tslib.es6.mjs` was tried first and is worse - it fixes - * Vite's copy and not Nitro's, because Nitro re-resolves externals itself, - * and the tracer then copies only the file the alias named. - */ external: ['@vitnode/core', '@vitnode/blog', '@vitnode/example', 'tslib'], }, plugins: [ - /** - * All three from `@vitnode/core/framework/vite`. - * - * `vitNodeOptimizeDeps` takes nothing at all: it names the browser - * dependencies of the package's own `dist` so `vite dev` pre-bundles them on - * a cold start. Nothing crawls that `dist` before it is requested, so - * without this list a screen that is opened for the first time discovers a - * dependency mid-session, the optimizer re-runs, and a page holding modules - * from the previous run ends up with two copies of React. - * - * `vitNodeEnv` takes no `clientEnv`: this app publishes nothing to the - * browser beyond the two keys the package inlines for every VitNode install - * (`NEXT_PUBLIC_API_URL`, `NEXT_PUBLIC_WEB_URL`). Anything named there is - * compiled into JavaScript anyone can read, so an empty list is the right - * default and a key is added only when something in the browser genuinely - * reads it. - * - * `appRoot` is `import.meta.dirname` because a Vite config is loaded with the - * working directory set to wherever the command ran, which in this monorepo - * is regularly the repository root. - */ - vitNodeEnv(), - vitNodeOptimizeDeps(), - vitNodePluginRoutes({ appRoot: import.meta.dirname }), - /** - * The documentation collection, compiled by Vite rather than by a Next.js - * webpack loader. - * - * Fumadocs MDX's own Vite integration - `fumadocs-mdx/vite` - and not a - * hand-rolled MDX pipeline. It does three things this app depends on: it - * transforms `content/docs/**` on demand, it writes `.source/{server,browser}.ts` - * (the `collections/*` alias in `tsconfig.json`), and it contributes the - * `optimizeDeps` entries the MDX runtime needs under `vite dev`. - * - * **Before `tanstackStart()`**, because that plugin's route generator and - * the Start compiler both run over a graph that now contains generated - * modules: `.source/browser.ts` is a file this plugin writes, and it has to - * exist before anything walks it. The MDX transform is declared `order: - * "pre"` on its own hooks, so this is about the config and emit phases - * rather than about transform precedence. - * - * There is deliberately no `dir`, `outDir` or `configPath` here: the plugin - * reads `source.config.ts` from the app root, which is where it is. - */ + vitnode({ appRoot: import.meta.dirname }), fumadocsMdx(), devtools(), nitro({ rollupConfig: { external: [/^@sentry\//] } }), diff --git a/packages/create-vitnode-app/README.md b/packages/create-vitnode-app/README.md index 22ceee989..6f4faffae 100644 --- a/packages/create-vitnode-app/README.md +++ b/packages/create-vitnode-app/README.md @@ -59,8 +59,14 @@ pnpm create vitnode-app@latest --plugin npm create vitnode-app@latest -- --plugin ``` -The generator creates the package and adds its workspace dependency. Register it -in the host’s `vitnode.config.ts` to enable the feature. +The generator creates the package and adds its workspace dependency. Enable the +feature by registering it in the host’s `vitnode.config.ts`: + +```ts +import { myPlugin } from '@acme/my-plugin/config' + +plugins: [myPlugin()] +``` ## Options diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/README.md b/packages/create-vitnode-app/copy-of-vitnode-app/README.md index d4f412974..26e2c5d46 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/README.md +++ b/packages/create-vitnode-app/copy-of-vitnode-app/README.md @@ -38,3 +38,25 @@ To start the development server, run the following command: ```bash pnpm dev ``` + +## Configuration + +Two files, and the line between them is one question: may a browser hold this? + +| File | Holds | +| :----------------------------- | :----------------------------------------------------------------------------------------------------------------------- | +| `src/vitnode.config.ts` | locales, metadata, theme, `debug`, enabled plugin ids - plain data, read by the browser, the server, and your Vite build | +| `src/vitnode.server.config.ts` | message loaders - server only | + +Add a language to `i18n.locales` in the shared config; register the files that +translate it in `src/locales/packages.ts` (a package's own translations) or +`src/locales/app.ts` (your rewordings). `pnpm vitnode i18n:create de Deutsch` +does all three. + +`src/start.ts` is one call to `createVitNodeStart`, which installs CSRF +protection for server functions, canonical locale redirects with the +remembered-locale cookie, and the `private, no-store` directive every rendered +document needs. Add your own request middleware with `requestMiddleware` - it +runs after all of it. + +See [Configuration](https://vitnode.com/docs/dev/configuration). diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/vitnode.api.config.ts b/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/vitnode.api.config.ts index d277f7ccd..7e0df302e 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/vitnode.api.config.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/vitnode.api.config.ts @@ -2,7 +2,7 @@ import { buildApiConfig } from "@vitnode/core/vitnode.config"; import { coreRelations } from "@vitnode/core/database/relations"; import { drizzle } from "drizzle-orm/postgres-js"; -import { i18n } from "./i18n"; +import { vitNodeConfig } from "./vitnode.config"; export const POSTGRES_URL = process.env.POSTGRES_URL ?? "postgresql://root:root@localhost:5432/vitnode"; @@ -14,16 +14,15 @@ export const vitNodeApiConfig = buildApiConfig({ }, plugins: [], /** - * The same `src/i18n.ts` the frontend config reads, because this app is both: - * one declaration of which languages exist, spread into `buildConfig` through - * `vitnode.shell.config.ts` and passed here. + * The site's own locale declaration, because this app is both: `vitnode.config.ts` + * is the one statement of which languages exist, and both configs read it. * * It is also what `vitnode db:prepare` seeds `core_languages` from - this app - * owns the schema - so adding a language here and re-running `dev` inserts its - * row. Leave it out and the seed falls back to `en` alone, whatever the site - * serves. + * owns the schema - so adding a language there and re-running `dev` inserts + * its row. Leave it out and the seed falls back to `en` alone, whatever the + * site serves. */ - i18n, + i18n: vitNodeConfig.i18n, dbProvider: drizzle({ connection: POSTGRES_URL, relations: coreRelations, diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/api/src/i18n.ts b/packages/create-vitnode-app/copy-of-vitnode-app/api/src/i18n.ts deleted file mode 100644 index 5fd793dc9..000000000 --- a/packages/create-vitnode-app/copy-of-vitnode-app/api/src/i18n.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { VitNodeI18nConfig } from "@vitnode/core/lib/i18n/types"; - -/** - * The languages this installation serves. - * - * The API half of a split deployment. `apps/web` has a file of the same name, - * and the two are one declaration in two places by necessity rather than by - * design: they are separate packages, so neither can import the other's. - * - * They have to agree, and this is the copy that matters most - this app owns the - * schema, so `vitnode db:prepare` seeds `core_languages` from *this* list. A - * language that is here and not in the web app's renders nowhere; one that is in - * the web app's and not here has no row in the database, and everything keyed on - * a language row has nowhere to put it. - * - * Deliberately not discovered: nothing walks the filesystem looking for the web - * app's config. A bootstrap that guessed at a sibling application is exactly - * what this replaced, and it guessed wrong the moment the two were not laid out - * the way it expected. - * - * Packages ship their own translations, so a new locale needs no `messages` - * entry - anything untranslated falls back to `defaultLocale` key by key. - */ -export const i18n = { - defaultLocale: "en", - /** - * Explicit, because this API renders emails on a server: without one, dates - * format in whatever zone the host happens to run in. - */ - timeZone: "UTC", - locales: [ - { - code: "en", - name: "English", - }, - ], - messages: {}, -} satisfies VitNodeI18nConfig; diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/api/src/vitnode.api.config.ts b/packages/create-vitnode-app/copy-of-vitnode-app/api/src/vitnode.api.config.ts index be148e2c6..572d64abd 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/api/src/vitnode.api.config.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/api/src/vitnode.api.config.ts @@ -3,8 +3,6 @@ import { config } from "dotenv"; import { coreRelations } from "@vitnode/core/database/relations"; import { drizzle } from "drizzle-orm/postgres-js"; -import { i18n } from "./i18n.js"; - config({ quiet: true, }); @@ -15,14 +13,33 @@ export const POSTGRES_URL = export const vitNodeApiConfig = buildApiConfig({ plugins: [], /** - * The installation's languages - see `src/i18n.ts`, and keep it in step with - * the web app's file of the same name. + * The languages this installation serves. + * + * The API half of a split deployment. `apps/web/src/vitnode.config.ts` + * declares the same list, and the two are one declaration in two places by + * necessity rather than by design: they are separate packages, so neither can + * import the other's. Nothing walks the filesystem looking for the web app's + * config either - a bootstrap that guessed at a sibling application is exactly + * what that replaced, and it guessed wrong the moment the two were not laid + * out the way it expected. * - * This app owns the schema, so `vitnode db:prepare` seeds `core_languages` - * from this list. Leave it out and the seed falls back to `en` alone, whatever - * the site serves. + * They have to agree, and this is the copy that matters most: this app owns + * the schema, so `vitnode db:prepare` seeds `core_languages` from *this* list. + * A language that is here and not in the web app's renders nowhere; one that + * is in the web app's and not here has no row in the database. + * + * Packages ship their own translations, so a new locale needs no `messages` + * entry - anything untranslated falls back to `defaultLocale` key by key. */ - i18n, + i18n: { + defaultLocale: "en", + locales: [{ code: "en", name: "English" }], + /** + * Explicit, because this API renders emails on a server: without one, dates + * format in whatever zone the host happens to run in. + */ + timeZone: "UTC", + }, dbProvider: drizzle({ connection: POSTGRES_URL, relations: coreRelations, diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/i18n.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/i18n.ts deleted file mode 100644 index 1a2ecbaae..000000000 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/i18n.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { VitNodeI18nConfig } from "@vitnode/core/lib/i18n/types"; - -/** - * The languages this app serves. - * - * Its own module so the web config and the API config can point at the same - * object instead of drifting apart - the site and the emails it sends have to - * agree on which languages exist. - * - * Add a language by adding an entry. Packages ship their own translations, so a - * new locale needs no `messages` here: anything a package has not translated - * falls back to `defaultLocale` key by key. Register the package's own file for - * that language in `src/locales/packages.ts`, and put your own rewording in - * `src/locales/app.ts`. - */ -export const i18n = { - defaultLocale: "en" as const, - /** - * Explicit, because the app renders on a server: without one, `use-intl` - * formats dates in whatever zone the server happens to run in and warns that - * the client will disagree. Stage 3, which owns the locale runtime, is where a - * per-visitor zone would come from. - */ - timeZone: "UTC", - /** - * `as const` on each code, and nothing else: it keeps `"en" | "pl"` out of - * the widening `satisfies` would otherwise do, which is what makes `Locale` - * in `lib/i18n/shared.ts` a real union rather than an alias for `string`. - */ - locales: [ - { - code: "en" as const, - name: "English", - }, - ], -} satisfies VitNodeI18nConfig; diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/admin-nav.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/admin-nav.ts index 0b85c03fb..a74e77f24 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/admin-nav.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/admin-nav.ts @@ -17,13 +17,13 @@ import { pluginAdminNav } from "#/admin-nav.gen"; * * ## Why it is not read from `vitnode.config.ts` * - * That config is server-side on purpose (`vitnode.shell.config.ts` explains the - * split): it carries message loaders and API wiring, which a browser bundle has - * no business holding. A sidebar needs the ids, the hrefs, the permissions, the - * icons and the content type definitions, all of which are plain data. The - * generated projection is exactly that half, so the browser gets the navigation - * without the Content Engine's UI - which arrives, when a content screen - * actually renders, through `src/lib/content-registry.ts` instead. + * The config does carry each plugin's registration, and a Next.js host walks it + * in its render pass. Reading it here would make every configured plugin's + * editing screens reachable from the module the document shell imports, which is + * the one graph that is never lazy. The generated projection carries exactly + * what a sidebar needs instead - ids, hrefs, permissions, icons and content type + * definitions, all plain data - and the Content Engine's UI arrives separately, + * when a content screen actually renders, through `src/lib/content-registry.ts`. * * ## Module scope, and why that matters twice * diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/content-registry.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/content-registry.ts index ca87e4459..e69c95613 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/content-registry.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/content-registry.ts @@ -20,12 +20,13 @@ import { pluginContentTypes } from "#/content-registry.gen"; * * ## Why it is not read from `vitnode.config.ts` * - * That config is server-side on purpose (`vitnode.shell.config.ts` explains the - * split): it carries message loaders and API wiring, which a browser bundle has - * no business holding. The generated projection is the browser-safe half - the - * definitions, the icons and the override components - so the AdminCP gets the - * content screens without the server config. The same arrangement - * `src/lib/admin-nav.ts` uses for the sidebar, one layer deeper. + * The config does carry each plugin's registration, and a Next.js host reads it + * from there. What it cannot give is *when*: it is imported by the document + * shell, so anything reachable from it is reachable eagerly. This module is + * loaded behind a dynamic `import()` in `src/router.tsx` instead, so the + * definitions, the icons and the override components arrive with the content + * route - the same arrangement `src/lib/admin-nav.ts` uses for the sidebar, one + * layer deeper. * * ## Registration, and where it belongs in the import graph * diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/document-headers.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/document-headers.ts deleted file mode 100644 index a8ff64cfe..000000000 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/document-headers.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * What a VitNode document response may say about being stored. - * - * `private, no-store`, and it is not a precaution - it is a description of what - * is in the body. Every page this application renders streams a dehydrated Query - * cache into its HTML, and that cache always holds `["vitnode","session"]`: the - * visitor's own name, avatar and `isAdmin` flag. Inside `/admin` it also holds - * `["vitnode","admin-session"]`, which is that administrator's entire permission - * set. `tanstack/auth/session-query` and `tanstack/admin/session-query` both say - * so in their own words - "that document is personalised and must not be served - * from a shared cache" - and until now nothing on the response said it back. - * - * Nothing caches these documents today. That is a property of the current - * deployment, not of the application: the moment a CDN, a reverse proxy or a - * `Cache-Control`-respecting edge sits in front of the Node server, an absent - * directive is an invitation to store one visitor's HTML and serve it to the - * next. Stage 15 is when that becomes likely, so the header belongs here now. - * - * ## It is an invariant, not a default - * - * This started as a fallback for documents that said nothing, and that was - * wrong. A default is something a route may override, and at this architecture - * level there is no override a route could correctly choose: the dehydrated - * cache is written into the stream by `setupRouterSsrQueryIntegration` for - * *every* document, so a route opting into `public, max-age=60` would be - * publishing whichever visitor rendered first to everyone who asked next. The - * route cannot know that, because the private payload is not something the route - * put there. - * - * So the directive is forced rather than filled in, and a route that sets its - * own is overwritten rather than obeyed. That is the whole difference between - * this being a hardening measure and it being a security invariant. - * - * Public document caching is not forbidden forever - it is forbidden *while the - * session is dehydrated into the document*. Introducing it later is a separate, - * explicit piece of architecture in which the private state is kept out of the - * shared body (a public shell fetching its session client-side, say), and the - * invariant here would move with it rather than being quietly relaxed by a route - * that wanted a faster page. - * - * `private` bars a shared cache. `no-store` bars every cache, including the - * browser's own disk cache - which is the half that matters on a shared machine, - * where the previous person's permission set should not be recoverable from - * `chrome://cache` after they sign out. The pair is the standard spelling for - * "this body belongs to exactly one person, once". - * - * The known cost is the back/forward cache. `no-store` used to make a page - * outright ineligible for bfcache in Chromium; current versions keep such pages - * eligible but evict them when cookies change - which for this app means a - * sign-in or sign-out invalidates a back-navigation that would otherwise have - * restored a page rendered for the previous session. That is the correct trade - * and the outcome anybody would want, but it is a real difference and it is - * worth a look during a manual pass rather than a surprise later. - */ -export const DOCUMENT_CACHE_CONTROL = "private, no-store"; - -/** - * Whether this response is one of the documents the rule above describes. - * - * One question, and it is what keeps the API out of it. `/api/*` is served by - * the Hono bridge through this same middleware, and a bare `GET` from it carries - * no `Cache-Control` of its own - so a rule that applied to every response would - * quietly forbid clients from caching the API. An HTML content-type is the - * honest way to ask "is this a page", it needs no path list to be kept in step - * with the router, and it cannot be wrong about a response that has already been - * produced. - * - * It deliberately does *not* ask whether a directive is already present. That - * used to be the second half of this predicate, and it is exactly the exemption - * the invariant above cannot afford - see {@link applyDocumentCacheControl}. - * - * A redirect is deliberately not matched here - it has no content type - and is - * handled by {@link applyRedirectCacheControl} instead, which wants a narrower - * rule. - */ -const isRenderedDocument = (headers: Headers): boolean => - (headers.get("content-type") ?? "").toLowerCase().startsWith("text/html"); - -/** - * Says what a rendered document is, on the response about to be sent. - * - * `set` rather than a conditional fill, and that is the fix: whatever the - * response was carrying is replaced. A route cannot opt out, because a route is - * not in a position to know what is in the body it is opting out for. - * - * Only `text/html` is touched. Everything else the middleware sees - the API, - * assets, client chunks, a `204` with no content type at all - keeps whatever it - * had, including nothing. - * - * Mutates rather than returning a new `Response`, because the middleware already - * holds the one Start produced and rebuilding it would mean copying a stream. - * The same reason `set-cookie` is appended in place a few lines away. - */ -export const applyDocumentCacheControl = (response: Response): void => { - if (!isRenderedDocument(response.headers)) return; - - response.headers.set("cache-control", DOCUMENT_CACHE_CONTROL); -}; - -/** - * The same for a locale redirect, but only when it is carrying a cookie. - * - * A `308` from `/en/discover` to `/discover` is a fact about URLs, identical for - * every visitor, and permanently cacheable - which is most of the point of - * answering with one. So it keeps that property by default. - * - * The exception is the redirect that also writes the locale cookie, which is - * what `/pl/admin` produces: a stored copy of that would hand the next visitor - * through the same shared cache a `Set-Cookie` chosen by somebody else, and - * quietly switch their language. Shared caches are generally expected to refuse - * a `Set-Cookie` response, but "generally expected" is not a property this - * application can assert about somebody else's proxy, and one visitor's cookie - * reaching another's browser is not the kind of thing to leave to convention. - * - * So the cookie-carrying case is forced, for the same reason the document is: a - * `public` directive already on such a redirect is overwritten rather than - * respected, because the thing that makes it unsafe to share is the `Set-Cookie` - * beside it and not whatever the directive claims. - * - * The cookie-less case keeps its existing semantics untouched, directive and - * all. It carries no private state, so there is nothing here to protect and - * nothing to override. - */ -export const applyRedirectCacheControl = (response: Response): void => { - if (!response.headers.has("set-cookie")) return; - - response.headers.set("cache-control", DOCUMENT_CACHE_CONTROL); -}; diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/i18n/runtime.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/i18n/runtime.ts index 831d45468..d841c308c 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/i18n/runtime.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/i18n/runtime.ts @@ -2,8 +2,8 @@ import { createServerFn } from "@tanstack/react-start"; import { configureIntl, validateIntlInput } from "@vitnode/core/tanstack/i18n"; import { IntlProvider } from "use-intl"; -import { i18n } from "#/i18n"; import { loadIntlMessages } from "#/server/messages.server"; +import { vitNodeConfig } from "#/vitnode.config"; /** * One language's messages for one set of namespaces, fetched on the server. @@ -29,9 +29,9 @@ export const getIntlMessages = createServerFn() * Everything in `@vitnode/core/tanstack/i18n` reads what this registers, so a * route file imports `RouteMessages` and `intlQueryOptions` straight from the * package. What must not happen is a route running before this module has been - * evaluated - so the two framework entry points, `src/router.tsx` and - * `src/start.ts`, both import from here, and `src/tests/intl-runtime.test.ts` - * fails if either stops doing so. + * evaluated - so `src/router.tsx`, which owns the route tree, imports from + * here. The request pipeline needs no such guarantee: `createVitNodeStart` + * derives its own locale routing from the config it is handed. * * The registration is at module scope but reads `getIntlMessages` above only by * reference, so the order within this file does not matter: the validator and @@ -61,7 +61,7 @@ export const { * back to client rendering, and the page still appears. */ hostIntlProvider: IntlProvider, - i18n, + i18n: vitNodeConfig.i18n, }); /** diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/i18n/shared.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/i18n/shared.ts index feb6b1ab4..3cfdbc9e5 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/i18n/shared.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/i18n/shared.ts @@ -1,17 +1,17 @@ -import type { i18n } from "#/i18n"; +import type { vitNodeConfig } from "#/vitnode.config"; import { localeRouting } from "#/lib/i18n/runtime"; /** - * A language this app serves, as a type. `"en" | "pl"`, derived from the config - * rather than written twice. + * A language this app serves, as a type. `"en"`, or `"en" | "de"` once a second + * one is declared - derived from the config rather than written twice. * * The one i18n thing this app still owns, and it has to: `@vitnode/core` is * installed by apps with different language lists, so it types a locale as * `string` and takes this union as a type argument where the value originates * (`useLocale<Locale>()`, `resolveLocale<Locale>()`). */ -export type Locale = (typeof i18n.locales)[number]["code"]; +export type Locale = (typeof vitNodeConfig.i18n.locales)[number]["code"]; export { defaultLocale, localeRouting } from "#/lib/i18n/runtime"; diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/page-head.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/page-head.ts index 479cd277e..261614184 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/page-head.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/page-head.ts @@ -1,6 +1,6 @@ import { createRouteHead } from "@vitnode/core/tanstack/metadata"; -import { vitNodeShellConfig } from "#/vitnode.shell.config"; +import { vitNodeConfig } from "#/vitnode.config"; /** * A route's `head`, bound to this app's name. @@ -16,4 +16,4 @@ import { vitNodeShellConfig } from "#/vitnode.shell.config"; * * head: ({ loaderData }) => pageHead({ robots: 'index, follow', ...loaderData }) */ -export const pageHead = createRouteHead(vitNodeShellConfig.metadata); +export const pageHead = createRouteHead(vitNodeConfig.metadata); diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/locales/app.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/locales/app.ts index 4fdb14268..bb00c0d79 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/locales/app.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/locales/app.ts @@ -21,8 +21,8 @@ import type { AppMessagesMap } from "@vitnode/core/lib/i18n/types"; * everything it leaves out falls back to the package's, and then to the default * locale, key by key. * - * Server-side only, and kept out of `src/i18n.ts` on purpose: these are - * functions, and `src/i18n.ts` is spread into the shell config, which crosses to - * the browser and has to stay serializable. + * Server-side only, and kept out of `src/vitnode.config.ts` on purpose: these + * are functions, and the shared config crosses to the browser and has to stay + * serializable. `src/vitnode.server.config.ts` is what registers this map. */ export const appMessages: AppMessagesMap = {}; diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/locales/packages.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/locales/packages.ts index 5a7c0162e..1173fa27b 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/locales/packages.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/locales/packages.ts @@ -33,8 +33,8 @@ import { CONFIG_PLUGIN as CORE } from "@vitnode/core/config"; * en: async () => await import('@acme/blog/locales/en.json'), * }, * - * This is the app's *only* copy of that list - `vitnode.config.ts` and - * `server/messages.server.ts` both read it from here. + * This is the app's only copy of that list - `vitnode.server.config.ts` reads it + * from here. */ export const packageMessages: Record<string, LocaleMessagesMap> = { [CORE.pluginId]: { diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/__root.tsx b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/__root.tsx index 30cb30516..0814fdba5 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/__root.tsx +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/__root.tsx @@ -24,11 +24,11 @@ import { import type { Locale } from "#/lib/i18n/shared"; -import { vitNodeShellConfig } from "#/vitnode.shell.config"; +import { vitNodeConfig } from "#/vitnode.config"; import appCss from "../styles.css?url"; -const { debug, i18n, metadata, theme } = vitNodeShellConfig; +const { debug, i18n, metadata, theme } = vitNodeConfig; /** * What the router itself provides, before any route has run. diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_admin/admin.core.index.tsx b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_admin/admin.core.index.tsx index c3c1fd215..d2d749f68 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_admin/admin.core.index.tsx +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_admin/admin.core.index.tsx @@ -41,13 +41,12 @@ import { * * ## `pluginWidgets` is not passed * - * A plugin's dashboard widgets reach the Next.js board through - * `getVitNodeConfig()`, which is server-side config kept out of this app's - * browser bundle by `vitnode.shell.config.ts` - and this app registers its - * plugins by id and messages only (see `src/vitnode.config.ts`). So the board - * shows core's own widgets, which is the complete set for this install. It is - * the same seam `AdminShell` leaves open for nav `declarations`, and it changes - * here when plugin AdminCP registration moves over. + * A plugin declares widgets in its `admin.dashboard.widgets`, which reaches a + * Next.js board through `getVitNodeConfig()`. This route does not read the + * config - the AdminCP takes its plugin data from the generated projections, and + * neither of those carries widgets - so the board shows core's own, which is the + * complete set for this install because no configured plugin declares any. It is + * the same seam `AdminShell` leaves open for nav `declarations`. */ export const Route = createFileRoute("/_admin/admin/core/")({ loader: async ({ context }) => await loadAdminDashboardRoute(context), diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/server/messages.server.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/server/messages.server.ts index 14d203167..a2081943f 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/server/messages.server.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/server/messages.server.ts @@ -1,8 +1,7 @@ import "@tanstack/react-start/server-only"; import { createIntlMessagesLoader } from "@vitnode/core/tanstack/i18n/server"; -import { packageMessages } from "#/locales/packages"; -import { vitNodeConfig } from "#/vitnode.config"; +import { vitNodeServerConfig } from "#/vitnode.server.config"; export type { IntlMessages } from "@vitnode/core/tanstack/i18n/server"; @@ -15,15 +14,8 @@ export type { IntlMessages } from "@vitnode/core/tanstack/i18n/server"; * place, the per-key fallback to the default locale, and the namespace pick that * keeps every plugin's AdminCP copy out of a page that renders none of it. * - * What is this app's, and can only be: the plugins it registered, the languages - * it declares, and `packageMessages` - the static loaders that stand in for each - * package's own locale barrel, because a barrel's - * `import("./en.json", { with: { type: "json" } })` is a specifier Rollup will - * not follow. See `src/locales/packages.ts` for the reproduction. + * What is this app's is the server config it is handed - the plugins it + * registered, the languages it declares, and the loaders that stand in for each + * package's own locale barrel. */ -export const loadIntlMessages = createIntlMessagesLoader({ - appMessages: vitNodeConfig.i18n.messages, - defaultLocale: vitNodeConfig.i18n.defaultLocale, - packageMessages, - plugins: vitNodeConfig.plugins, -}); +export const loadIntlMessages = createIntlMessagesLoader(vitNodeServerConfig); diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/start.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/start.ts index c5acc0f12..f74f980d8 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/start.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/start.ts @@ -1,88 +1,24 @@ -import { - createCsrfMiddleware, - createMiddleware, - createStart, -} from "@tanstack/react-start"; -import { handleLocaleRequest } from "@vitnode/core/tanstack/i18n/server"; +import { createVitNodeStart } from "@vitnode/core/tanstack/start"; -import { - applyDocumentCacheControl, - applyRedirectCacheControl, -} from "#/lib/document-headers"; -import { localeRouting } from "#/lib/i18n/runtime"; +import { vitNodeConfig } from "#/vitnode.config"; /** - * Locale routing, as the first thing that happens to a request. - * - * A global request middleware rather than a wrapper around the server entry: - * Start runs these before route matching and before SSR, which is exactly where - * a canonical redirect belongs - the alternative is rendering a page and then - * throwing it away. - * - * `handlerType` narrows it to page requests. Server function calls arrive on - * `/_serverFn/*` with `handlerType: "serverFn"`, and redirecting an RPC to a - * canonical URL would break it rather than tidy it. - * - * `/api/*` reaches here too and is deliberately ignored by - * `handleLocaleRequest`, so the Stage 1 Hono bridge sees the request exactly as - * the client sent it. - * - * `localeRouting` is handed in rather than read from the package's registered - * runtime: Start runs request middleware before route matching, so this is the - * one caller that cannot assume the router entry has been evaluated. Importing - * it from `#/lib/i18n/runtime` is also what guarantees this app's i18n is - * configured before the first request touches it. + * This app's Start instance. * - * ## And what a document is allowed to say about itself + * The whole request pipeline is VitNode's, and it is mandatory rather than a + * default: CSRF protection on server functions, canonical locale redirects with + * the remembered-locale cookie, and the `private, no-store` directive every + * document carrying a dehydrated session needs. * - * The cache directive rides along here for one reason: this is already the only - * place in the application that holds every page response, before and after - * rendering, and a second middleware would be a second thing to remember. Every - * document this app produces carries a dehydrated Query cache containing the - * visitor's own session - and, under `/admin`, an administrator's whole - * permission set - so none of them may be stored by a shared cache. See - * `#/lib/document-headers`, which owns the rule and says why; this file only - * applies it. - */ -const localeMiddleware = createMiddleware().server( - async ({ handlerType, next, request }) => { - if (handlerType !== "router") return await next(); - - const { redirect, setCookie } = handleLocaleRequest(request, localeRouting); - if (redirect) { - applyRedirectCacheControl(redirect); - - return redirect; - } - - const result = await next(); - - // `append`, not `set`: the API mounted at `/api/*` and the auth flow both - // mint their own cookies, and overwriting the header would sign people out. - if (setCookie) result.response.headers.append("set-cookie", setCookie); - - // After the cookie, so a document that just wrote one is covered by the - // same directive as one that did not. Only an HTML response is touched - - // `/api/*` reaches here too, and the API's own caching is not this - // middleware's to decide. - applyDocumentCacheControl(result.response); - - return result; - }, -); - -/** - * This app's Start instance. + * Add this app's own request middleware - a request id, a tracing span, a + * maintenance gate - with `requestMiddleware`. It runs after all of the above, + * which is the only safe place for it: a redirect ends the request, so anything + * in front of the locale rule would run twice for every visitor arriving at a + * non-canonical URL. * - * `createCsrfMiddleware` is not optional here. Start installs it *only* while an - * app declares no `requestMiddleware` of its own - the moment this file exists, - * the default is replaced by whatever it lists, and leaving CSRF out would - * expose every server function as an unauthenticated cross-site endpoint. It is - * declared first so it runs before anything else. + * export const startInstance = createVitNodeStart({ + * config: vitNodeConfig, + * requestMiddleware: [myMiddleware], + * }) */ -export const startInstance = createStart(() => ({ - requestMiddleware: [ - createCsrfMiddleware({ filter: ctx => ctx.handlerType === "serverFn" }), - localeMiddleware, - ], -})); +export const startInstance = createVitNodeStart({ config: vitNodeConfig }); diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/styles.css b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/styles.css index 1e7389e7d..5b36cd7fd 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/styles.css +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/styles.css @@ -1,37 +1,11 @@ -@import 'tailwindcss'; -@import 'shadcn/tailwind.css'; -@import 'tw-animate-css'; +@import "tailwindcss"; +@import "shadcn/tailwind.css"; +@import "tw-animate-css"; - -/* - * VitNode's components ship compiled, so Tailwind cannot find their classes by - * scanning this app's source. Pointing it at core's build output is what makes - * the toaster, the tooltip and everything else that follows look like VitNode - * instead of unstyled markup. - * - * `dist/src/tanstack` is the line that is easiest to leave off and hardest to - * diagnose. `@vitnode/core/tanstack/*` is markup as much as it is orchestration - * - the header bar, the language switcher, the debug ribbon, the not-found - * screen - and a class written *there* exists nowhere else, because this app - * inherits those components rather than writing their markup. Without it a class - * like `.w-34` is never generated, an `<svg>` with a viewBox and no width - * collapses, and the header renders with no logo at all. - * - * Add a line per plugin that ships compiled components of its own. - * - * The paths are relative to this file. In a monorepo where the package manager - * hoists to the repository root, that is `../../../node_modules/...` - the - * generator rewrites them when it knows. - */ @source "../node_modules/@vitnode/core/dist/src/components"; @source "../node_modules/@vitnode/core/dist/src/tanstack"; @source "../node_modules/@vitnode/core/dist/src/views"; -/* - * The design tokens every VitNode component reads. `.dark` is what - * `ThemeScript` and `ThemeProvider` put on `<html>`, so the whole palette - * switches from that one class. - */ :root:not(.dark) { --background: oklch(0.97 0 0); --foreground: oklch(0.145 0 0); @@ -149,12 +123,6 @@ --radius-4xl: calc(var(--radius) * 2.6); } -/* - * Last, so that a stylesheet a package brings in cannot outrank it in this - * layer: `@layer base` rules are ordered by where they appear, and a library - * that sets its own `body { background-color: ... }` would otherwise repaint - * whichever pages happen to load it. - */ @layer base { * { @apply border-border outline-ring/50; diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.config.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.config.ts index 3b17f439e..0f560028f 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.config.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.config.ts @@ -1,58 +1,73 @@ import { buildConfig } from "@vitnode/core/vitnode.config"; -import { appMessages } from "./locales/app"; -import { vitNodeShellConfig } from "./vitnode.shell.config"; - /** - * This app's frontend config, in the shape every VitNode app builds it. + * This app's configuration - the one file every VitNode app edits first. + * + * **Browser-safe, and everything here has to stay that way.** Three very + * different readers depend on it: `routes/__root.tsx` renders the document shell + * from `metadata`, `theme` and `debug`; `lib/i18n/runtime.ts` derives the locale + * routing from `i18n`; and Vite's own plugin registry loads this file with + * `jiti` while it is still resolving its config, to find out which plugins to + * generate route, navigation and content-registry imports for. So it is plain + * data and plugin *identity* - never a `() => import(...)` message loader, never + * a module that reaches a database. Anything like that goes in + * `vitnode.server.config.ts`. + * + * ## Adding a language * - * `plugins` is empty, and a plugin is added here by id and translations rather - * than through its own `blogPlugin()` entry: + * Add an entry to `locales`. Packages ship their own translations, so a new + * locale needs nothing else: anything a package has not translated falls back to + * `defaultLocale` key by key. Register the package's file for that language in + * `src/locales/packages.ts`, and put your own rewording in + * `src/locales/app.ts` - both of which `vitnode.server.config.ts` picks up. * - * import { buildPlugin } from '@vitnode/core/lib/plugin' - * import { CONFIG_PLUGIN as BLOG } from '@acme/blog/const' + * `vitnode i18n:create de Deutsch` does all of it for you. * - * import { packageMessages } from './locales/packages' + * ## Adding a plugin * - * plugins: [ - * buildPlugin({ - * messages: packageMessages[BLOG.pluginId], - * pluginId: BLOG.pluginId, - * }), - * ] + * With the plugin's own factory: * - * That is a *scope* decision rather than a compatibility one. This object's one - * reader takes `pluginId` and `messages` off each plugin and nothing else, so a - * full registration would add every content type's editing screen to a - * server-only graph where nothing would ever look at them. The AdminCP gets its - * content types from `src/content-registry.gen.ts` instead - one literal import - * per configured plugin - so a browser loads them with the content route and not - * before. + * import { blogPlugin } from '@acme/blog/config' * - * The same split, one layer up, feeds the sidebar: `src/admin-nav.gen.ts` is the - * navigation half - ids, hrefs, permissions, icons - and nothing that renders a - * screen. See `src/lib/admin-nav.ts` and `src/lib/content-registry.ts`; both - * explain why they are generated rather than read from here. + * plugins: [blogPlugin()] + * + * That is the whole registration - the factory carries the plugin's content + * types, its AdminCP navigation and its translations. Register its locale files + * in `src/locales/packages.ts` as well, which is what the message loader + * actually reads. + * + * What the AdminCP renders comes back through `src/admin-nav.gen.ts` and + * `src/content-registry.gen.ts` rather than out of this object: the build writes + * one literal import per configured plugin, and `src/router.tsx` loads the + * content registry behind a dynamic `import()`, so a content type's editing + * screen arrives with the route that renders it. * * A plugin's *pages* need nothing in this file at all. It declares them in its * own `src/routes.ts`, and this app's Vite build compiles them into * `src/plugin-routes.gen.ts`, which `src/router.tsx` mounts under the shell the * plugin's `area` names. No page is ever copied into `src/routes`. * - * Server-side only, and deliberately so - see `vitnode.shell.config.ts`. - * `src/server/messages.server.ts` is the only importer, and it carries the - * `server-only` guard that keeps it that way. - * * `buildConfig` also registers this object process-wide, which is how core's own * route files find it without being handed it as a prop. */ export const vitNodeConfig = buildConfig({ - ...vitNodeShellConfig, - /** - * The shell's locale declaration, plus the message loaders that must not be in - * it: `src/i18n.ts` is spread into the browser-facing shell config, and these - * are functions. - */ - i18n: { ...vitNodeShellConfig.i18n, messages: appMessages }, + debug: false, + i18n: { + defaultLocale: "en", + locales: [{ code: "en", name: "English" }], + /** + * Explicit, because the app renders on a server: without one, `use-intl` + * formats dates in whatever zone the server happens to run in and warns + * that the client will disagree. + */ + timeZone: "UTC", + }, + metadata: { + shortTitle: "VitNode", + title: "VitNode", + }, plugins: [], + theme: { + defaultTheme: "system", + }, }); diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.server.config.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.server.config.ts new file mode 100644 index 000000000..ec533126f --- /dev/null +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.server.config.ts @@ -0,0 +1,23 @@ +import "@tanstack/react-start/server-only"; +import { buildServerConfig } from "@vitnode/core/vitnode.config"; + +import { appMessages } from "#/locales/app"; +import { packageMessages } from "#/locales/packages"; +import { vitNodeConfig } from "#/vitnode.config"; + +/** + * The half of this app's configuration a browser may never hold. + * + * Both entries are `() => import(...)` loaders that read JSON out of a package's + * build output, which is exactly what `vitnode.config.ts` cannot carry - so they + * live here, beside the shared config rather than duplicating any of it. The + * `server-only` marker makes an accidental import from a component a build error + * instead of a browser bundle with every plugin's AdminCP copy in it. + * + * `src/server/messages.server.ts` is the only reader. + */ +export const vitNodeServerConfig = buildServerConfig({ + config: vitNodeConfig, + messages: appMessages, + packageMessages, +}); diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.shell.config.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.shell.config.ts deleted file mode 100644 index 48d14e720..000000000 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.shell.config.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { VitNodeConfig } from "@vitnode/core/vitnode.config"; - -import { i18n } from "./i18n"; - -/** - * The VitNode config the browser is allowed to see. - * - * Everything in `VitNodeConfig` except `plugins`, and that omission is the whole - * point. The plugin registry carries each plugin's translations as `import()`s - * of JSON inside its `dist`, and eventually its AdminCP components - neither of - * which a browser bundle should hold. In Next.js the boundary is drawn for you: - * `vitnode.config.ts` is only ever read by Server Components, so none of it - * reaches the client. TanStack Start has no such boundary - anything the root - * route imports is in the browser bundle - so the split is made here instead, by - * hand. - * - * `vitnode.config.ts` spreads this into `buildConfig` with the plugins added, so - * there is one source for the metadata, the theme and the locales rather than - * two that agree until they don't. - * - * Everything here is plain, serializable data. That is a rule, not a - * coincidence: this module is imported by the document shell, which renders on - * both sides of hydration. - */ -export const vitNodeShellConfig = { - debug: false, - i18n, - metadata: { - shortTitle: "VitNode", - title: "VitNode", - }, - theme: { - defaultTheme: "system", - }, -} satisfies Omit<VitNodeConfig, "plugins">; diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/vite.config.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/vite.config.ts index 1361bc8d6..8644142fe 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/vite.config.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/vite.config.ts @@ -2,11 +2,7 @@ import tailwindcss from "@tailwindcss/vite"; import { devtools } from "@tanstack/devtools-vite"; import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import viteReact from "@vitejs/plugin-react"; -import { - vitNodeEnv, - vitNodeOptimizeDeps, - vitNodePluginRoutes, -} from "@vitnode/core/framework/vite"; +import { vitnode } from "@vitnode/core/framework/vite"; import { nitro } from "nitro/vite"; import { defineConfig } from "vite"; @@ -85,29 +81,20 @@ const config = defineConfig({ }, plugins: [ /** - * All three from `@vitnode/core/framework/vite`. + * Environment handling, the dev server's dependency pre-bundling and the + * plugin route registry, in the order they have to run. * - * `vitNodeOptimizeDeps` takes nothing at all: it names the browser - * dependencies of the package's own `dist` so `vite dev` pre-bundles them on - * a cold start. Nothing crawls that `dist` before it is requested, so - * without this list a screen that is opened for the first time discovers a - * dependency mid-session, the optimizer re-runs, and a page holding modules - * from the previous run ends up with two copies of React. + * `appRoot` is `import.meta.dirname` because a Vite config is loaded with + * the working directory set to wherever the command ran, which in a + * monorepo is regularly the repository root. * - * `vitNodeEnv` takes no `clientEnv`: this app publishes nothing to the - * browser beyond the two keys the package inlines for every VitNode install + * `clientEnv` is not passed: this app publishes nothing to the browser + * beyond the two keys VitNode inlines for every install * (`NEXT_PUBLIC_API_URL`, `NEXT_PUBLIC_WEB_URL`). Anything named there is - * compiled into JavaScript anyone can read, so an empty list is the right - * default and a key is added only when something in the browser genuinely - * reads it. - * - * `appRoot` is `import.meta.dirname` because a Vite config is loaded with the - * working directory set to wherever the command ran, which in this monorepo - * is regularly the repository root. + * compiled into JavaScript anyone can read, so a key is added only when + * something in the browser genuinely reads it. */ - vitNodeEnv(), - vitNodeOptimizeDeps(), - vitNodePluginRoutes({ appRoot: import.meta.dirname }), + vitnode({ appRoot: import.meta.dirname }), devtools(), nitro({ rollupConfig: { external: [/^@sentry\//] } }), tailwindcss(), diff --git a/packages/create-vitnode-app/src/create/scaffold-invariants.test.ts b/packages/create-vitnode-app/src/create/scaffold-invariants.test.ts index 23487974c..ecf1684ad 100644 --- a/packages/create-vitnode-app/src/create/scaffold-invariants.test.ts +++ b/packages/create-vitnode-app/src/create/scaffold-invariants.test.ts @@ -251,37 +251,137 @@ describe("what a generated single app starts from", () => { }); /** - * One locale declaration, read by both configs. + * One locale declaration, in the shared config, read by both configs. * * `vitnode db:prepare` seeds `core_languages` from the *API* config, and a - * single app owns its schema - so a language added to `src/i18n.ts` has to - * reach the seed without a second edit. It does, because there is only one - * list. + * single app owns its schema - so a language added to `src/vitnode.config.ts` + * has to reach the seed without a second edit. It does, because there is only + * one list. + * + * The standalone `src/i18n.ts` this replaced must not come back: two files + * that agree until they don't is exactly the failure the single declaration + * exists to prevent. */ it("declares its languages once and reads them from both configs", () => { - expect(appFiles).toContain("root/src/i18n.ts"); - expect(appFiles).not.toContain("api-single-app/src/i18n.ts"); + expect(allFiles.filter(file => /(^|\/)src\/i18n\.ts$/.test(file))).toEqual( + [], + ); + expect(appFiles).not.toContain("root/src/vitnode.shell.config.ts"); - for (const file of [ - "root/src/vitnode.shell.config.ts", - "api-single-app/src/vitnode.api.config.ts", - ]) { - expect(withoutComments(read(appTemplate, file))).toMatch( - /import \{ i18n \} from ['"]\.\/i18n['"]/, - ); - } + const shared = withoutComments( + read(appTemplate, "root/src/vitnode.config.ts"), + ); + expect(shared).toMatch(/defaultLocale:\s*"en"/); + expect(shared).toMatch(/locales:\s*\[/); + + expect( + withoutComments( + read(appTemplate, "api-single-app/src/vitnode.api.config.ts"), + ), + ).toMatch(/i18n:\s*vitNodeConfig\.i18n/); }); /** - * The split shape has the same obligation, one file each: two packages, so - * neither can import the other's, and the API's is the one the seed reads. + * The split shape has the same obligation, one declaration each: two packages, + * so neither can import the other's, and the API's is the one the seed reads. */ it("gives a split deployment an API locale declaration of its own", () => { - expect(appFiles).toContain("api/src/i18n.ts"); - // `.js` here: the split API compiles with `moduleResolution: nodenext`. + const api = withoutComments( + read(appTemplate, "api/src/vitnode.api.config.ts"), + ); + + expect(api).toMatch(/defaultLocale:\s*"en"/); + expect(api).toMatch(/locales:\s*\[/); + }); + + /** + * The app's *own* message loaders are registered through the server config. + * + * `src/locales/app.ts` and `src/locales/packages.ts` are the two modules an app + * edits to add a translation, and both are `() => import(...)` of JSON. They + * reach the message loader through `vitnode.server.config.ts`, which carries + * the `server-only` marker, rather than through the shared config that the + * document shell holds and that Vite executes with `jiti`. + * + * A plugin's own registration factory is a separate question and is not what + * this pins - see `root/src/vitnode.config.ts`. + */ + it("registers the app's message loaders through the server config", () => { + const shared = withoutComments( + read(appTemplate, "root/src/vitnode.config.ts"), + ); + + expect(shared).not.toMatch(/from\s*['"]\.\/locales/); + expect(shared).not.toMatch(/from\s*['"]#\/locales/); + + expect(appFiles).toContain("root/src/vitnode.server.config.ts"); + const server = withoutComments( + read(appTemplate, "root/src/vitnode.server.config.ts"), + ); + expect(server).toContain('import "@tanstack/react-start/server-only"'); + expect(server).toContain("buildServerConfig"); + expect(server).toMatch(/config:\s*vitNodeConfig/); + expect(server).toMatch(/messages:\s*appMessages/); + expect(server).toContain("packageMessages"); + }); +}); + +describe("what a generated application does to every request", () => { + const start = withoutComments(read(appTemplate, "root/src/start.ts")); + + /** + * `src/start.ts` is a composition root, not a pipeline. + * + * Start replaces its own default CSRF middleware the moment an app declares + * `requestMiddleware` of its own, so a scaffold that shipped a hand-written + * list was one deletion away from exposing every server function as an + * unauthenticated cross-site endpoint. `createVitNodeStart` owns the list and + * an app cannot omit or reorder what is in it. + */ + it("builds its Start instance through the Core factory", () => { + expect(start).toContain( + 'import { createVitNodeStart } from "@vitnode/core/tanstack/start"', + ); + expect(start).toMatch( + /export const startInstance = createVitNodeStart\(\{\s*config: vitNodeConfig,?\s*\}\)/, + ); + }); + + it("hand-rolls none of the pipeline the factory owns", () => { + for (const primitive of [ + "createStart", + "createMiddleware", + "createCsrfMiddleware", + "handleLocaleRequest", + "localeRouting", + ]) { + expect(start).not.toContain(primitive); + } + + // The header rule moved into Core with the middleware that applies it. expect( - withoutComments(read(appTemplate, "api/src/vitnode.api.config.ts")), - ).toMatch(/import \{ i18n \} from ['"]\.\/i18n\.js['"]/); + allFiles.filter(file => file.endsWith("lib/document-headers.ts")), + ).toEqual([]); + }); + + /** + * One VitNode plugin in the Vite config, for the same reason: three calls in + * a fixed order is three things to copy wrong. + */ + it("configures Vite through one VitNode plugin", () => { + const vite = withoutComments(read(appTemplate, "root/vite.config.ts")); + + expect(vite).toContain( + 'import { vitnode } from "@vitnode/core/framework/vite"', + ); + expect(vite).toMatch(/vitnode\(\{ appRoot: import\.meta\.dirname \}\)/); + for (const removed of [ + "vitNodeEnv", + "vitNodeOptimizeDeps", + "vitNodePluginRoutes", + ]) { + expect(vite).not.toContain(removed); + } }); }); diff --git a/packages/create-vitnode-app/vitest.config.ts b/packages/create-vitnode-app/vitest.config.ts index 1947beedf..c1433e6ef 100644 --- a/packages/create-vitnode-app/vitest.config.ts +++ b/packages/create-vitnode-app/vitest.config.ts @@ -1,14 +1,5 @@ import { defineConfig } from "vitest/config"; -/** - * The scaffold's pure half, under test. - * - * Only the template generators run here - functions from a plugin's name to the - * bytes a new plugin starts with. Nothing in this suite spawns the CLI, creates - * a project or touches a filesystem: what is worth pinning is that a scaffolded - * plugin's manifest, route module and package exports are exactly what VitNode's - * build reads, and that is a string comparison. - */ export default defineConfig({ test: { environment: "node", diff --git a/packages/vitnode/scripts/get-config.ts b/packages/vitnode/scripts/get-config.ts index e893f5a43..7e9d5ea63 100644 --- a/packages/vitnode/scripts/get-config.ts +++ b/packages/vitnode/scripts/get-config.ts @@ -3,11 +3,29 @@ import { createJiti } from "jiti"; import { existsSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; -import type { VitNodeApiConfig, VitNodeConfig } from "../src/vitnode.config.js"; +import type { + VitNodeApiConfig, + VitNodeConfig, + VitNodeServerConfig, +} from "../src/vitnode.config.js"; -type ConfigType<T extends "api.config" | "config"> = T extends "config" +type ConfigName = "api.config" | "config" | "server.config"; + +type ConfigType<T extends ConfigName> = T extends "config" ? VitNodeConfig - : VitNodeApiConfig; + : T extends "server.config" + ? VitNodeServerConfig + : VitNodeApiConfig; + +/** + * The export each config file is read through - `vitnode.<type>.config.ts` is + * only half the convention, the named export is the other half. + */ +const CONFIG_EXPORTS: Record<ConfigName, string> = { + "api.config": "vitNodeApiConfig", + config: "vitNodeConfig", + "server.config": "vitNodeServerConfig", +}; export const findConfigFile = ( baseDir: string, @@ -56,21 +74,17 @@ export const findConfigFile = ( return searchRecursively(baseDir, 0); }; -export async function getConfig< - T extends "api.config" | "config" = "config", ->(args: { +export async function getConfig<T extends ConfigName = "config">(args: { baseDir?: string; optional: true; type?: T; }): Promise<ConfigType<T> | null>; -export async function getConfig< - T extends "api.config" | "config" = "config", ->(args?: { +export async function getConfig<T extends ConfigName = "config">(args?: { baseDir?: string; optional?: false; type?: T; }): Promise<ConfigType<T>>; -export async function getConfig<T extends "api.config" | "config" = "config">({ +export async function getConfig<T extends ConfigName = "config">({ baseDir, type = "config" as T, optional = false, @@ -95,8 +109,7 @@ export async function getConfig<T extends "api.config" | "config" = "config">({ } try { - const configVarName = - type === "config" ? "vitNodeConfig" : "vitNodeApiConfig"; + const configVarName = CONFIG_EXPORTS[type]; const jiti = createJiti(import.meta.url, { interopDefault: true, diff --git a/packages/vitnode/scripts/i18n-check.ts b/packages/vitnode/scripts/i18n-check.ts index ecb00bcee..385e1aced 100644 --- a/packages/vitnode/scripts/i18n-check.ts +++ b/packages/vitnode/scripts/i18n-check.ts @@ -73,6 +73,13 @@ export const i18nCheck = async (flag?: string) => { const webConfig = await getConfig({ optional: true }); const apiConfig = await getConfig({ optional: true, type: "api.config" }); + // The app's own message loaders live in the server-only config now, because + // the shared one is browser-safe. Read from both, so an installation still on + // the old shape - loaders inside `i18n.messages` - is measured correctly. + const serverConfig = await getConfig({ + optional: true, + type: "server.config", + }); const config = webConfig ?? apiConfig; if (!config) { @@ -85,7 +92,7 @@ export const i18nCheck = async (flag?: string) => { const scope = appScope({ api: apiConfig !== null, web: webConfig !== null }); const defaultLocale = config.i18n?.defaultLocale ?? "en"; const declared = (config.i18n?.locales ?? []).map(locale => locale.code); - const appMessages = config.i18n?.messages ?? {}; + const appMessages = serverConfig?.messages ?? config.i18n?.messages ?? {}; // Web and API plugins differ in everything but the id, which is all we need; // union across both configs so an API-only plugin is still checked. const pluginIds = [ @@ -246,7 +253,7 @@ export const i18nCheck = async (flag?: string) => { problems += 1; console.log( red( - ` ${location} is never loaded - add \`"${file.pluginId}": () => import("./locales/${file.pluginId}/${file.locale}.json")\` under \`i18n.messages.${file.locale}\`.`, + ` ${location} is never loaded - add \`"${file.pluginId}": () => import("./${file.pluginId}/${file.locale}.json")\` under \`"${file.locale}"\` in \`src/locales/app.ts\`.`, ), ); } else if (declared.length > 0 && !declared.includes(file.locale)) { diff --git a/packages/vitnode/scripts/i18n-create.test.ts b/packages/vitnode/scripts/i18n-create.test.ts index 6deb92d6d..a22ac355f 100644 --- a/packages/vitnode/scripts/i18n-create.test.ts +++ b/packages/vitnode/scripts/i18n-create.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + addLocaleToAppMessages, addLocaleToConfig, addMessagesToConfig, buildI18nFile, @@ -206,6 +207,82 @@ describe("addMessagesToConfig", () => { }); }); +describe("addLocaleToAppMessages", () => { + /** + * The loaders go in `src/locales/app.ts`, not in the config. + * + * `vitnode.config.ts` is browser-safe and is executed by Vite at build time, + * so a `() => import(...)` written into its `i18n` block is both in the + * browser bundle and in the build. `appMessages` is the map + * `vitnode.server.config.ts` registers, and its loaders are relative to + * `src/locales/` rather than to `src/`. + */ + it("adds a locale block to an empty map", () => { + expect( + addLocaleToAppMessages( + "export const appMessages: AppMessagesMap = {};\n", + { code: "pl", pluginIds: ["@vitnode/core"] }, + ), + ).toBe( + [ + "export const appMessages: AppMessagesMap = {", + ' "pl": {', + ' "@vitnode/core": () => import("./@vitnode/core/pl.json"),', + " },", + "};", + "", + ].join("\n"), + ); + }); + + it("extends a map that already has a locale", () => { + const source = [ + "export const appMessages: AppMessagesMap = {", + " en: {", + ' "@vitnode/core": () => import("./@vitnode/core/en.json"),', + " },", + "};", + "", + ].join("\n"); + + expect( + addLocaleToAppMessages(source, { code: "de", pluginIds: ["a", "b"] }), + ).toBe( + [ + "export const appMessages: AppMessagesMap = {", + ' "de": {', + ' "a": () => import("./a/de.json"),', + ' "b": () => import("./b/de.json"),', + " },", + " en: {", + ' "@vitnode/core": () => import("./@vitnode/core/en.json"),', + " },", + "};", + "", + ].join("\n"), + ); + }); + + it("handles a plain `const appMessages = {}` with no annotation", () => { + expect( + addLocaleToAppMessages("const appMessages = {\n};\n", { + code: "pl", + pluginIds: ["a"], + }), + ).toContain('"pl": {'); + }); + + it("returns null when there is no map to edit", () => { + // The caller then prints the lines to add rather than writing a broken file. + expect( + addLocaleToAppMessages("export const other = {};", { + code: "pl", + pluginIds: ["a"], + }), + ).toBeNull(); + }); +}); + describe("buildI18nFile", () => { it("renders every declared locale plus the new one", () => { const file = buildI18nFile({ diff --git a/packages/vitnode/scripts/i18n-create.ts b/packages/vitnode/scripts/i18n-create.ts index 7eba26f56..f74fb1747 100644 --- a/packages/vitnode/scripts/i18n-create.ts +++ b/packages/vitnode/scripts/i18n-create.ts @@ -21,9 +21,21 @@ import { findRepoRoot } from "./shared/file-utils.js"; const LOCALE_CODE_PATTERN = /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/; -/** One `import("./locales/<pluginId>/<code>.json")` loader, indented. */ -const messageEntry = (indent: string, pluginId: string, code: string) => - `${indent}"${pluginId}": () => import("./locales/${pluginId}/${code}.json"),`; +/** + * One message loader, indented, with the path spelled relative to the file it + * is being written into. + * + * `src/locales/app.ts` sits inside `src/locales`, so its loaders are + * `./<pluginId>/<code>.json`. A config at `src/` reaches the same file through + * `./locales/<pluginId>/<code>.json`. + */ +const messageEntry = ( + indent: string, + pluginId: string, + code: string, + prefixPath = "./locales", +) => + `${indent}"${pluginId}": () => import("${prefixPath}/${pluginId}/${code}.json"),`; /** * A TypeScript string literal for arbitrary text. `JSON.stringify` escapes @@ -37,6 +49,42 @@ const stringLiteral = (value: string): string => JSON.stringify(value); const opensOnNewLine = (source: string, afterOpen: number): boolean => /^[^\S\n]*\n/.test(source.slice(afterOpen)); +/** Where an app keeps the message overrides its server config registers. */ +const APP_MESSAGES_FILE = join("src", "locales", "app.ts"); + +/** + * Adds a `<code>: { ...loaders }` block to an app's `appMessages` map. + * + * The map that `vitnode.server.config.ts` hands to the messages loader, and the + * only correct target when an app has one: the shared `vitnode.config.ts` is + * browser-safe by contract, so a `() => import(...)` written into its `i18n` + * block is both in the browser bundle and in the file Vite loads with `jiti` to + * discover plugins. + * + * Returns `null` when the file has no `appMessages` object to edit, so the + * caller can fall back to the config or to printing instructions. + */ +export const addLocaleToAppMessages = ( + source: string, + { code, pluginIds }: { code: string; pluginIds: string[] }, +): null | string => { + const anchor = /\bappMessages\s*(?::[^=]*)?=\s*\{/.exec(source); + if (anchor?.index === undefined) return null; + + const at = anchor.index + anchor[0].length; + const entries = pluginIds + .map(id => messageEntry(" ", id, code, ".")) + .join("\n"); + const inner = `\n "${code}": {\n${entries}\n },`; + + // Close the object onto its own line when it was written inline (`{}`). + return ( + source.slice(0, at) + + (opensOnNewLine(source, at) ? inner : `${inner}\n`) + + source.slice(at) + ); +}; + /** * Bounds of the app's `i18n` config object, `{ start, end }` pointing at its * opening and closing braces. Both edits below must land *inside* this object: @@ -45,8 +93,8 @@ const opensOnNewLine = (source: string, afterOpen: number): boolean => * splice the new language into that first match instead - reporting success * while the runtime config stays untouched. * - * The object is always introduced by `i18n = {` (a dedicated `src/i18n.ts`) or - * `i18n: {` (inline in a config), which a plugin's option block can't spoof. + * The object is always introduced by `i18n: {` inside a config, or `i18n = {` in + * a standalone declaration - neither of which a plugin's option block can spoof. * Returns `null` when no such object is found, so callers fall back to printing * manual instructions rather than editing blindly. */ @@ -203,7 +251,16 @@ export const addMessagesToConfig = ( return source.slice(0, at) + block + source.slice(at); }; -/** A complete `src/i18n.ts` for an app that has no i18n config yet. */ +/** + * A standalone `src/i18n.ts` for an app whose config declares no languages at + * all. + * + * The last resort, and it should stay unreachable: every VitNode app declares + * `i18n` inside `vitnode.config.ts` (web) or `vitnode.api.config.ts` (API), and + * that is what {@link findI18nSourceFile} finds. This exists so an + * API-only project that opted out of the block entirely gets a file to import + * rather than a silent no-op. + */ export const buildI18nFile = ({ code, defaultLocale, @@ -392,7 +449,28 @@ export const i18nCreate = async () => { console.log(dim(` skipped ${pluginId} (no strings for this app)`)); } - // 2. Wire the locale into the i18n config. + // 2. Wire the loaders into the app's own message map, which is where a + // server config reads them from. Attempted first and independently of the + // locale list below: the two live in different files now - the loaders in + // `src/locales/app.ts`, the languages in the shared config - and a project + // that has one and not the other should still get the half it has. + const appMessagesPath = join(appDir, APP_MESSAGES_FILE); + let wroteAppMessages = false; + + if (existsSync(appMessagesPath) && wiredPluginIds.length > 0) { + const wired = addLocaleToAppMessages( + readFileSync(appMessagesPath, "utf-8"), + { code, pluginIds: wiredPluginIds }, + ); + + if (wired) { + writeFileSync(appMessagesPath, wired); + console.log(green(` updated ${APP_MESSAGES_FILE}`)); + wroteAppMessages = true; + } + } + + // 3. Wire the locale into the i18n config. const sourceFile = findI18nSourceFile(appDir); if (!sourceFile) { @@ -417,9 +495,13 @@ export const i18nCreate = async () => { } else { const original = readFileSync(sourceFile, "utf-8"); const withLocale = addLocaleToConfig(original, { code, name }); - const wired = withLocale - ? addMessagesToConfig(withLocale, { code, pluginIds: wiredPluginIds }) - : null; + // Only fall back to the config's own `messages` block when the app has no + // `src/locales/app.ts` - a browser-safe config is the wrong home for a + // loader, and writing to both would merge the same file twice. + const wired = + withLocale && !wroteAppMessages && wiredPluginIds.length > 0 + ? addMessagesToConfig(withLocale, { code, pluginIds: wiredPluginIds }) + : withLocale; if (wired) { writeFileSync(sourceFile, wired); @@ -427,17 +509,21 @@ export const i18nCreate = async () => { } else { // The config is shaped in a way we will not edit blindly - show the // exact lines to add instead of risking a broken file. - const messages = wiredPluginIds - .map( - id => ` "${id}": () => import("./locales/${id}/${code}.json"),`, - ) - .join("\n"); console.log( `\n${prefix} Couldn't edit ${relative(appDir, sourceFile)} automatically. Add:\n` + dim( - ` locales: [{ code: ${stringLiteral(code)}, name: ${stringLiteral(name)} }, /* ... */]\n`, - ) + - dim(` messages: {\n "${code}": {\n${messages}\n },\n }`), + ` locales: [{ code: ${stringLiteral(code)}, name: ${stringLiteral(name)} }, /* ... */]`, + ), + ); + } + + if (!wroteAppMessages && wiredPluginIds.length > 0) { + const messages = wiredPluginIds + .map(id => ` "${id}": () => import("./${id}/${code}.json"),`) + .join("\n"); + console.log( + `\n${prefix} And register the files in ${APP_MESSAGES_FILE}:\n` + + dim(` "${code}": {\n${messages}\n },`), ); } } diff --git a/packages/vitnode/src/api/lib/response-cache-headers.test.ts b/packages/vitnode/src/api/lib/response-cache-headers.test.ts index 19a72e356..410b526a0 100644 --- a/packages/vitnode/src/api/lib/response-cache-headers.test.ts +++ b/packages/vitnode/src/api/lib/response-cache-headers.test.ts @@ -76,17 +76,43 @@ const codeOf = (path: string): string => /** * Every `Cache-Control` value a source sets, however it spells the assignment. * - * Both forms are matched: the object literal a `c.json(body, status, headers)` - * takes, and the `c.header("Cache-Control", value)` call. A value built from a - * variable rather than a literal would not be captured - and would fail the - * "only these files" assertion below, which is the point. + * Three forms are matched: the object literal a `c.json(body, status, headers)` + * takes, the `c.header("Cache-Control", value)` call, and a + * `headers.set("cache-control", NAME)` whose value is a `const` declared in the + * same file. That last one is not a convenience - the document rule is a named + * constant on purpose, so the invariant can be documented once and referred to, + * and a scan that could not follow it would report the file with no value and + * pin nothing. + * + * A value assembled at runtime is still not captured, and would show up here as + * a file with no values - which fails the "exactly the declared values" + * assertion below rather than passing quietly. + * + * De-duplicated: what matters is which directives a file can produce, not how + * many statements produce each one. */ -const cacheControlValues = (path: string): string[] => - [ - ...codeOf(path).matchAll( - /["'`]?[Cc]ache-[Cc]ontrol["'`]?\s*[,:]\s*["'`]([^"'`]*)["'`]/g, +const cacheControlValues = (path: string): string[] => { + const code = codeOf(path); + const constants = new Map( + [ + ...code.matchAll( + /\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*["'`]([^"'`]*)["'`]/g, + ), + ].map(match => [match[1], match[2]] as const), + ); + + return [ + ...new Set( + [ + ...code.matchAll( + /["'`]?[Cc]ache-[Cc]ontrol["'`]?\s*[,:]\s*(?:["'`]([^"'`]*)["'`]|([A-Za-z_$][\w$]*))/g, + ), + ] + .map(match => match[1] ?? constants.get(match[2])) + .filter((value): value is string => value !== undefined), ), - ].map(match => match[1]); + ]; +}; const mentionsCacheControl = (path: string): boolean => /[Cc]ache-[Cc]ontrol/.test(codeOf(path)); @@ -119,11 +145,19 @@ const isSharedCacheable = (value: string): boolean => { /** * Every response header this package sets deliberately, and why. * - * One entry. Adding a second is a decision about who may store a response, so it - * belongs in a diff somebody reads rather than in a route nobody re-reads. + * Two entries, and both say the same thing for the same reason: + * + * - `content/server/public-routes.ts` - the one public read that can return an + * unpublished record to whoever is allowed to preview it. + * - `tanstack/start/document-headers.ts` - every rendered document, which + * carries a dehydrated Query cache holding the visitor's session. + * + * Adding a third is a decision about who may store a response, so it belongs in + * a diff somebody reads rather than in a route nobody re-reads. */ const DECLARED = { "content/server/public-routes.ts": ["private, no-store"], + "tanstack/start/document-headers.ts": ["private, no-store"], } satisfies Record<string, string[]>; describe("the cacheability predicate says what it means", () => { diff --git a/packages/vitnode/src/framework/vite/index.ts b/packages/vitnode/src/framework/vite/index.ts index c817e34cb..d5eb02113 100644 --- a/packages/vitnode/src/framework/vite/index.ts +++ b/packages/vitnode/src/framework/vite/index.ts @@ -8,21 +8,18 @@ * the routes VitNode's plugins declare, and they name this package's own * browser dependencies for the dev server's pre-bundler - the same work, in the * same order, in every install. Kept as files in each app they were 397 lines to - * copy and then to keep in step; here they are three calls: + * copy and then to keep in step; here they are one call: * - * import { - * vitNodeEnv, - * vitNodeOptimizeDeps, - * vitNodePluginRoutes, - * } from '@vitnode/core/framework/vite' + * import { vitnode } from '@vitnode/core/framework/vite' * * plugins: [ - * vitNodeEnv(), - * vitNodeOptimizeDeps(), - * vitNodePluginRoutes({ appRoot: import.meta.dirname }), + * vitnode({ appRoot: import.meta.dirname }), * ... * ] * + * The three are still exported individually, for an app that wants to drop or + * reorder one - {@link vitnode} is their composition, not a wrapper around them. + * * ## Why this is `framework/` and not `tanstack/` * * Nothing here is TanStack. `vitNodePluginRoutes` writes framework-neutral data @@ -41,3 +38,5 @@ export { vitNodeEnv } from "./env"; export { vitNodeOptimizeDeps } from "./optimize-deps"; export type { VitNodePluginRoutesOptions } from "./plugin-routes"; export { vitNodePluginRoutes } from "./plugin-routes"; +export type { VitNodeViteOptions } from "./vitnode"; +export { vitnode } from "./vitnode"; diff --git a/packages/vitnode/src/framework/vite/optimize-deps.test.ts b/packages/vitnode/src/framework/vite/optimize-deps.test.ts index ec6770372..a8e9846ab 100644 --- a/packages/vitnode/src/framework/vite/optimize-deps.test.ts +++ b/packages/vitnode/src/framework/vite/optimize-deps.test.ts @@ -22,7 +22,8 @@ const sourceRoot = resolve(here, "../.."); const CLIENT_TREES = ["components", "hooks", "lib", "tanstack", "views", "ws"]; const SERVER_DIRECTORIES = ["api", "dist", "node_modules", "server", "tests"]; const HOST_OWNED = [ - "@tanstack", + "@tanstack/react-router", + "@tanstack/react-start", "@vitnode", "dotenv", "react", diff --git a/packages/vitnode/src/framework/vite/optimize-deps.ts b/packages/vitnode/src/framework/vite/optimize-deps.ts index 71ed93fd7..5650b5e91 100644 --- a/packages/vitnode/src/framework/vite/optimize-deps.ts +++ b/packages/vitnode/src/framework/vite/optimize-deps.ts @@ -41,6 +41,7 @@ export const VITNODE_CLIENT_DEPENDENCIES = [ "@dnd-kit/sortable", "@dnd-kit/utilities", "@hookform/resolvers/zod", + "@tanstack/react-query", "@tiptap/extension-text-align", "@tiptap/react", "@tiptap/starter-kit", diff --git a/packages/vitnode/src/framework/vite/shared-config-discovery.test.ts b/packages/vitnode/src/framework/vite/shared-config-discovery.test.ts new file mode 100644 index 000000000..66ac9355b --- /dev/null +++ b/packages/vitnode/src/framework/vite/shared-config-discovery.test.ts @@ -0,0 +1,72 @@ +import { createJiti } from "jiti"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { describe, expect, it } from "vitest"; + +import { pluginIdsFromLoadedConfig } from "../plugin-routes"; + +/** + * How the build finds out which plugins an app configured. + * + * The generator loads `src/vitnode.config.ts` with `jiti` while Vite is still + * resolving its own config, reads `plugins[].pluginId`, and generates one + * literal import per plugin into three committed files. That is the entire + * contract, and the property worth pinning is what it must *not* need: the + * app's message loaders, its React content registries, or anything else that + * only makes sense on a server. + * + * `test-fixtures/config-split/` is a config pair exactly as an app writes one, + * except that its server half throws at module scope. So a discovery pass that + * reached the wrong file fails here rather than becoming a slow build somebody + * eventually profiles. + */ +const fixtureRoot = resolve( + import.meta.dirname, + "../../../test-fixtures/config-split", +); +const sharedConfig = join(fixtureRoot, "vitnode.config.ts"); +const serverConfig = join(fixtureRoot, "vitnode.server.config.ts"); + +/** The loader the generator uses, with the same options. */ +const load = async (path: string): Promise<unknown> => + await createJiti(pathToFileURL(join(fixtureRoot, "package.json")).href, { + interopDefault: true, + moduleCache: false, + }).import(path); + +describe("the shared config is browser-safe and build-time cheap", () => { + it("discovers every configured plugin id, in configuration order", async () => { + expect( + pluginIdsFromLoadedConfig(await load(sharedConfig), "vitnode.config.ts"), + ).toEqual(["@acme/blog", "@acme/docs"]); + }); + + it("loads without touching the server-only config", async () => { + // The claim. If `vitnode.config.ts` ever imports its server companion - + // directly, or through a `locales/` barrel that does - this is the assertion + // that goes red. + await expect(load(sharedConfig)).resolves.toBeDefined(); + }); + + it("would have noticed - the server-only config really does throw", async () => { + // The control: the assertion above would also pass against a fixture whose + // server half was importable. + await expect(load(serverConfig)).rejects.toThrow( + "the server-only config was loaded", + ); + }); + + it("carries the locale declaration the rest of the app reads", async () => { + const { vitNodeConfig } = (await load(sharedConfig)) as { + vitNodeConfig: { + i18n: { defaultLocale: string; locales: { code: string }[] }; + }; + }; + + expect(vitNodeConfig.i18n.defaultLocale).toBe("en"); + expect(vitNodeConfig.i18n.locales.map(locale => locale.code)).toEqual([ + "en", + "pl", + ]); + }); +}); diff --git a/packages/vitnode/src/framework/vite/vitnode.test.ts b/packages/vitnode/src/framework/vite/vitnode.test.ts new file mode 100644 index 000000000..f7e75ffcd --- /dev/null +++ b/packages/vitnode/src/framework/vite/vitnode.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { vitnode } from "./vitnode"; + +/** + * One VitNode plugin in a `vite.config.ts`, instead of three in a fixed order. + * + * A Vite plugin may be an array, so this is composition rather than a wrapper - + * and what that buys is the thing worth pinning: an app cannot install two of + * the three, or install them in an order that puts the route generator ahead of + * the environment it reads. + */ +const names = (plugins: { name: string }[]) => plugins.map(({ name }) => name); + +describe("the composed plugin", () => { + it("returns all three, in the order they have to run", () => { + expect(names(vitnode({ appRoot: "/app" }))).toEqual([ + "vitnode:env", + "vitnode:optimize-deps", + "vitnode:plugin-routes", + ]); + }); + + it("needs nothing but the app root", () => { + expect(() => vitnode({ appRoot: "/app" })).not.toThrow(); + }); + + it("passes clientEnv through to the environment plugin", () => { + const [env] = vitnode({ + appRoot: "/app", + clientEnv: ["NEXT_PUBLIC_MAP_KEY"], + }); + const defined = Object.keys( + // `config` is the hook the plugin does its work in; calling it directly is + // what Vite does, and it is the only way to see what was inlined. + ( + (env as { config: (config: object, env: object) => object }).config( + { root: import.meta.dirname }, + { mode: "development" }, + ) as { + environments: { client: { define: Record<string, string> } }; + } + ).environments.client.define, + ); + + expect(defined).toContain("process.env.NEXT_PUBLIC_MAP_KEY"); + // The two every VitNode install publishes are still there - `clientEnv` + // adds to that list rather than replacing it. + expect(defined).toContain("process.env.NEXT_PUBLIC_API_URL"); + expect(defined).toContain("process.env.NEXT_PUBLIC_WEB_URL"); + }); + + it("keeps the dev-server-only plugin dev-server-only", () => { + const [, optimizeDeps] = vitnode({ appRoot: "/app" }); + + expect((optimizeDeps as { apply?: string }).apply).toBe("serve"); + }); +}); diff --git a/packages/vitnode/src/framework/vite/vitnode.ts b/packages/vitnode/src/framework/vite/vitnode.ts new file mode 100644 index 000000000..c0f5c3682 --- /dev/null +++ b/packages/vitnode/src/framework/vite/vitnode.ts @@ -0,0 +1,42 @@ +import type { Plugin } from "vite"; + +import type { VitNodeEnvOptions } from "./env"; +import type { VitNodePluginRoutesOptions } from "./plugin-routes"; + +import { vitNodeEnv } from "./env"; +import { vitNodeOptimizeDeps } from "./optimize-deps"; +import { vitNodePluginRoutes } from "./plugin-routes"; + +export interface VitNodeViteOptions + extends VitNodeEnvOptions, VitNodePluginRoutesOptions {} + +/** + * Everything a VitNode app needs from Vite, as one plugin. + * + * plugins: [ + * vitnode({ appRoot: import.meta.dirname }), + * nitro(), + * tailwindcss(), + * tanstackStart(), + * viteReact(), + * ] + * + * A Vite plugin may be an array, so this is the composition and not a wrapper: + * the three plugins underneath are returned in the order they have to run, and + * each is still exported on its own for an app that genuinely wants to drop or + * reorder one. + * + * `appRoot` is the only required option, and it has to be `import.meta.dirname`: + * a Vite config is loaded with the working directory set to wherever the command + * ran, which in a monorepo is regularly the repository root. Every path the route + * generator reads hangs off it. + */ +export const vitnode = ({ + appRoot, + clientEnv, + hostRoutesDir, +}: VitNodeViteOptions): Plugin[] => [ + vitNodeEnv({ clientEnv }), + vitNodeOptimizeDeps(), + vitNodePluginRoutes({ appRoot, hostRoutesDir }), +]; diff --git a/packages/vitnode/src/tanstack/boundary.test.ts b/packages/vitnode/src/tanstack/boundary.test.ts index bb7c957bc..7863e442e 100644 --- a/packages/vitnode/src/tanstack/boundary.test.ts +++ b/packages/vitnode/src/tanstack/boundary.test.ts @@ -161,22 +161,42 @@ const NEXT_INTL_RUNTIME = [ ]; /** - * The Start compiler's own entry points. + * The Start primitives no module in this package may declare. * - * `createServerFn` and `createMiddleware` need the module they sit in to be - * transformed on *both* sides; `createStart` and `createFileRoute` name the - * host's own composition (its Start instance and its route tree), which a - * package cannot own. All four stay in `apps/web`. + * `createServerFn` is the one that actually breaks. It needs the module it sits + * in to be transformed on *both* sides; uncompiled - which is how this package + * reaches a server - its `.handler()` receives one argument where the compiler + * passes two, and the call resolves to `undefined` with no error at all. The + * route builders name the host's own route tree, which a package cannot own. */ const HOST_ONLY_PRIMITIVES = [ "createFileRoute", - "createMiddleware", "createRootRoute", "createRootRouteWithContext", "createServerFn", - "createStart", ]; +/** + * The request pipeline, which is the one feature allowed to build a Start + * instance. + * + * `createStart` and `createMiddleware` used to sit on the list above, and the + * reason given was the compiler. That reason is wrong for these two and worth + * correcting rather than working around: both are plain builders - read + * `createStart.ts` and `createMiddleware.ts` in `@tanstack/start-client-core` + * and neither does anything but return the options object it was handed - so + * being uncompiled costs them nothing. + * + * What genuinely needs the compiler on both sides is a *function* middleware, + * whose `.client()` branch runs in a browser. A **request** middleware has only + * a `.server()` branch and runs only in the server entry, which is the whole of + * what `tanstack/start` declares. The host still owns the composition: it calls + * `createVitNodeStart(...)` from its own `src/start.ts` and exports the result + * as `startInstance`, which is what the framework reads. + */ +const REQUEST_PIPELINE_PRIMITIVES = ["createMiddleware", "createStart"]; +const REQUEST_PIPELINE_DIRECTORY = join(tanstackRoot, "start"); + describe("this test is looking at the right tree", () => { it("finds the package root", () => { expect(readFileSync(join(packageRoot, "package.json"), "utf8")).toContain( @@ -316,6 +336,89 @@ describe("the namespace holds nothing the Start compiler has to see twice", () = .map(({ path }) => path), ).toEqual([]); }); + + const outsideRequestPipeline = () => + sources().filter( + ({ path }) => + !join(packageRoot, path).startsWith( + `${REQUEST_PIPELINE_DIRECTORY}${sep}`, + ), + ); + + it.each(REQUEST_PIPELINE_PRIMITIVES)( + "declares %s in the request pipeline only", + primitive => { + expect( + outsideRequestPipeline() + .filter(({ code }) => + new RegExp(`\\b${primitive}\\s*[(<]`).test(code), + ) + .map(({ path }) => path), + ).toEqual([]); + }, + ); + + it.each(REQUEST_PIPELINE_PRIMITIVES)( + "finds %s where it is allowed, so the scope is real", + primitive => { + // The control. Both assertions above are "found nothing" ones, which a + // scan whose directory filter excluded everything also satisfies. + expect( + sources().filter(({ code }) => + new RegExp(`\\b${primitive}\\s*[(<]`).test(code), + ), + ).not.toEqual([]); + }, + ); +}); + +describe("the request pipeline is safe in the client bundle too", () => { + /** + * `src/start.ts` is a *client* entry as well as a server one. + * + * `hydrateStart` imports `startInstance` from `#tanstack-start-entry`, which + * resolves to the host's `src/start.ts` - so everything `createVitNodeStart` + * reaches is in the browser build's module graph. A top-level + * `import "@tanstack/react-start/server-only"` anywhere in that chain is + * therefore an import-protection *error* rather than a safeguard: + * + * [import-protection] Import denied in client environment + * Importer: dist/src/tanstack/start/create-start.js + * + * What actually keeps the server half out of the browser is the Start + * compiler, which strips a middleware's `.server()` callback from the client + * bundle. That kills the only live reference to `handleLocaleRequest`, and + * `tanstack/i18n/request` - which is marked server-only - shakes out with it. + * + * So the marker is forbidden here and the discipline is the other one: + * anything a browser must not hold stays reachable only from inside a + * `.server()` callback. + */ + const startFiles = () => runtimeFilesUnder(join(tanstackRoot, "start")); + + it("has the pipeline to check", () => { + expect(startFiles().length).toBeGreaterThan(0); + }); + + it("carries no server-only marker", () => { + const offenders = startFiles() + .filter(path => + importsFrom(path).some(specifier => + specifier.startsWith("@tanstack/react-start/server-only"), + ), + ) + .map(path => relative(packageRoot, path)); + + expect(offenders).toEqual([]); + }); + + it("finds the marker where it does belong, so the scan is real", () => { + // The control: `tanstack/i18n/server` is server-only and says so, and it is + // never in a client graph because nothing a browser reaches imports it. + expect(importsFrom(join(tanstackRoot, "i18n", "server.ts"))).toContain( + "@tanstack/react-start/server-only", + ); + }); }); describe("the namespace never depends on an application", () => { diff --git a/packages/vitnode/src/tanstack/i18n/messages.ts b/packages/vitnode/src/tanstack/i18n/messages.ts index 830b7e483..49083027f 100644 --- a/packages/vitnode/src/tanstack/i18n/messages.ts +++ b/packages/vitnode/src/tanstack/i18n/messages.ts @@ -6,6 +6,7 @@ import type { LocaleMessagesMap, MessagesSource, } from "@/lib/i18n/types"; +import type { VitNodeServerConfig } from "@/vitnode.config"; import { CONFIG_PLUGIN } from "@/config"; import { loadMessages } from "@/lib/i18n/load-messages"; @@ -74,6 +75,28 @@ export interface IntlMessagesLoaderOptions extends BundledMessagesOptions { defaultLocale: string; } +/** The messages one page needs, in one language. */ +export type IntlMessagesLoader = (args: { + locale: string; + namespaces: readonly string[]; +}) => Promise<IntlMessages>; + +const isServerConfig = ( + options: IntlMessagesLoaderOptions | VitNodeServerConfig, +): options is VitNodeServerConfig => "config" in options; + +const loaderOptionsFrom = ( + options: IntlMessagesLoaderOptions | VitNodeServerConfig, +): IntlMessagesLoaderOptions => + isServerConfig(options) + ? { + appMessages: options.messages, + defaultLocale: options.config.i18n.defaultLocale, + packageMessages: options.packageMessages ?? {}, + plugins: options.config.plugins, + } + : options; + /** * The messages one page needs, in one language - as a loader bound to an app. * @@ -96,11 +119,15 @@ export interface IntlMessagesLoaderOptions extends BundledMessagesOptions { * plugins and the app's overrides for `locale`, with the default locale * underneath as a per-key fallback, so a half-translated language degrades one * string at a time instead of showing raw keys. + * + * Takes the app's `vitnode.server.config.ts` directly - which is the whole of + * what it needs - or the four options spelled out, for a host that assembles + * them itself. */ -export const createIntlMessagesLoader = ({ - defaultLocale, - ...sourceOptions -}: IntlMessagesLoaderOptions) => { +export function createIntlMessagesLoader( + options: IntlMessagesLoaderOptions | VitNodeServerConfig, +): IntlMessagesLoader { + const { defaultLocale, ...sourceOptions } = loaderOptionsFrom(options); const sources = buildBundledMessagesSources(sourceOptions); return async ({ @@ -120,4 +147,4 @@ export const createIntlMessagesLoader = ({ messages: pickMessages(merged, namespaces) as AbstractIntlMessages, }; }; -}; +} diff --git a/packages/vitnode/src/tanstack/i18n/server-config-messages.test.ts b/packages/vitnode/src/tanstack/i18n/server-config-messages.test.ts new file mode 100644 index 000000000..25a68ad9e --- /dev/null +++ b/packages/vitnode/src/tanstack/i18n/server-config-messages.test.ts @@ -0,0 +1,119 @@ +// @vitest-environment node +import { describe, expect, it, vi } from "vitest"; + +import type { VitNodeConfig } from "@/vitnode.config"; + +vi.mock("@tanstack/react-start/server-only", () => ({})); + +const { buildConfig, buildServerConfig } = await import("@/vitnode.config"); +const { createIntlMessagesLoader } = await import("./messages"); + +/** + * How the two halves of an app's config meet. + * + * The languages live in the browser-safe `vitnode.config.ts`; the loaders that + * read a package's JSON live in the server-only `vitnode.server.config.ts`. The + * loader is where they are resolved against each other, so what these tests pin + * is that handing it the server config is the same thing as spelling the four + * options out - including `defaultLocale`, which comes from the *shared* half + * and is what makes a half-translated language degrade key by key rather than + * rendering raw keys. + */ + +const messages = (tree: Record<string, unknown>) => async () => + await Promise.resolve({ default: tree }); + +const config: VitNodeConfig = buildConfig({ + i18n: { + defaultLocale: "en", + locales: [ + { code: "en", name: "English" }, + { code: "pl", name: "Polski" }, + ], + }, + metadata: { shortTitle: "Fixture", title: "Fixture" }, + plugins: [{ pluginId: "@acme/blog" }], +}); + +const serverConfig = buildServerConfig({ + config, + messages: { + pl: { "@acme/blog": messages({ blog: { title: "Nasz blog" } }) }, + }, + packageMessages: { + "@acme/blog": { + en: messages({ blog: { author: "Author", title: "Blog" } }), + pl: messages({ blog: { title: "Blog (pakiet)" } }), + }, + "@vitnode/core": { en: messages({ core: { global: { save: "Save" } } }) }, + }, +}); + +const load = createIntlMessagesLoader(serverConfig); + +describe("a server config resolves messages against the shared locale list", () => { + it("reads the plugins the shared config registered", async () => { + const { messages: tree } = await load({ + locale: "en", + namespaces: ["blog"], + }); + + expect(tree).toEqual({ blog: { author: "Author", title: "Blog" } }); + }); + + it("merges the app's own overrides last", async () => { + const { messages: tree } = await load({ + locale: "pl", + namespaces: ["blog"], + }); + + expect(tree).toEqual({ + // The app's rewording wins over the plugin's own Polish... + blog: { author: "Author", title: "Nasz blog" }, + }); + }); + + it("falls back to the shared config's defaultLocale key by key", async () => { + // ...and `author`, which nothing translated to Polish, comes from `en` + // rather than rendering as its own key. `defaultLocale` is read off the + // shared half, which is the join these two files exist to make. + const { messages: tree } = await load({ + locale: "pl", + namespaces: ["blog"], + }); + + expect((tree as { blog: { author: string } }).blog.author).toBe("Author"); + }); + + it("picks only the namespaces a page asked for", async () => { + const { messages: tree } = await load({ + locale: "en", + namespaces: ["core.global"], + }); + + expect(tree).toEqual({ core: { global: { save: "Save" } } }); + }); + + it("is the same loader the four spelled-out options build", async () => { + const explicit = createIntlMessagesLoader({ + appMessages: serverConfig.messages, + defaultLocale: config.i18n.defaultLocale, + packageMessages: serverConfig.packageMessages ?? {}, + plugins: config.plugins, + }); + + expect(await explicit({ locale: "pl", namespaces: ["blog"] })).toEqual( + await load({ locale: "pl", namespaces: ["blog"] }), + ); + }); + + it("needs no loaders at all", async () => { + // An app that overrides nothing and installs nothing still has to render. + const bare = createIntlMessagesLoader(buildServerConfig({ config })); + + expect(await bare({ locale: "en", namespaces: ["blog"] })).toEqual({ + locale: "en", + messages: {}, + }); + }); +}); diff --git a/packages/vitnode/src/tanstack/i18n/server.ts b/packages/vitnode/src/tanstack/i18n/server.ts index 9c78f28e8..b046b700a 100644 --- a/packages/vitnode/src/tanstack/i18n/server.ts +++ b/packages/vitnode/src/tanstack/i18n/server.ts @@ -11,6 +11,7 @@ import "@tanstack/react-start/server-only"; */ export type { BundledMessagesOptions, + IntlMessagesLoader, IntlMessagesLoaderOptions, } from "./messages"; export { diff --git a/packages/vitnode/src/tanstack/start/create-start.test.ts b/packages/vitnode/src/tanstack/start/create-start.test.ts new file mode 100644 index 000000000..19df3cf77 --- /dev/null +++ b/packages/vitnode/src/tanstack/start/create-start.test.ts @@ -0,0 +1,318 @@ +// @vitest-environment node +import { describe, expect, it, vi } from "vitest"; + +import type { VitNodeConfig } from "@/vitnode.config"; + +vi.mock("@tanstack/react-start/server-only", () => ({})); + +const { createMiddleware } = await import("@tanstack/react-start"); +const { createVitNodeStart } = await import("./create-start"); +const { runLocaleRequest } = await import("./locale-middleware"); +const { localeRoutingFromConfig } = await import("@/lib/i18n/locale-routing"); + +/** + * The request pipeline an app gets for free, and cannot get wrong. + * + * Start installs its own CSRF middleware *only* while an app declares no + * `requestMiddleware` at all - so the moment an app writes a pipeline, the + * default is replaced by whatever that list holds. Every app used to write that + * list. This is the factory that writes it instead, and what these tests pin is + * the part an app can no longer influence: CSRF exists, it is first, and nothing + * an app passes can get in front of it or in front of locale handling. + */ + +/** Start marks its CSRF middleware with this outside production builds. */ +const csrfSymbol = Symbol.for("tanstack-start:csrf-middleware"); + +const config = { + i18n: { + defaultLocale: "en", + locales: [ + { code: "en", name: "English" }, + { code: "pl", name: "Polski" }, + ], + }, + metadata: { shortTitle: "Test", title: "Test" }, + plugins: [], +} satisfies VitNodeConfig; + +const localeRouting = localeRoutingFromConfig(config.i18n); + +const requestMiddlewareOf = async ( + ...args: Parameters<typeof createVitNodeStart> +) => { + const options = await createVitNodeStart(...args).getOptions(); + + return [...(options.requestMiddleware ?? [])]; +}; + +/** + * Whether the CSRF middleware would validate this request. + * + * Asserted through behaviour rather than by reading `opts.filter`, which the + * middleware closes over and does not expose. The request carries + * `Sec-Fetch-Site: cross-site`, which Start's own check refuses - so the + * middleware answers with a `Response` when its filter let the request through, + * and passes `next()` straight back when the filter skipped it. + */ +const isValidatedByCsrf = async ( + csrf: unknown, + { handlerType, url }: { handlerType: string; url: string }, +): Promise<boolean> => { + const { server } = ( + csrf as { options: { server: (ctx: unknown) => unknown } } + ).options; + const passthrough = Symbol("next"); + const result = await server({ + handlerType, + next: () => passthrough, + request: new Request(url, { headers: { "sec-fetch-site": "cross-site" } }), + }); + + return result !== passthrough; +}; + +const isCsrf = (middleware: object): boolean => csrfSymbol in middleware; + +describe("every VitNode app is CSRF-protected", () => { + it("installs the CSRF middleware with no configuration at all", async () => { + const middleware = await requestMiddlewareOf({ config }); + + expect(middleware.filter(isCsrf)).toHaveLength(1); + }); + + it("runs it first", async () => { + const middleware = await requestMiddlewareOf({ config }); + + expect(isCsrf(middleware[0])).toBe(true); + }); + + it("keeps it first when the app adds its own middleware", async () => { + const appMiddleware = createMiddleware().server( + async ({ next }) => await next(), + ); + const middleware = await requestMiddlewareOf({ + config, + requestMiddleware: [appMiddleware], + }); + + expect(isCsrf(middleware[0])).toBe(true); + // App middleware is appended, never spliced in: locale handling ends the + // request on a redirect, so anything in front of it would run twice for + // every visitor arriving at a non-canonical URL. + expect(middleware.indexOf(appMiddleware)).toBe(middleware.length - 1); + expect(middleware.indexOf(appMiddleware)).toBeGreaterThan(1); + }); + + it("keeps the app's middleware in the order it declared them", async () => { + const first = createMiddleware().server(async ({ next }) => await next()); + const second = createMiddleware().server(async ({ next }) => await next()); + const middleware = await requestMiddlewareOf({ + config, + requestMiddleware: [first, second], + }); + + expect(middleware.slice(-2)).toEqual([first, second]); + }); + + it.each([ + { handlerType: "router", url: "https://a.test/", validated: false }, + { + handlerType: "serverFn", + url: "https://a.test/_serverFn/x", + validated: true, + }, + ])( + "validates a $handlerType request: $validated", + async ({ handlerType, url, validated }) => { + // A page navigation is not same-origin RPC. Validating `Sec-Fetch-Site` on + // a top-level cross-site link would reject ordinary inbound traffic. + const [csrf] = await requestMiddlewareOf({ config }); + + expect(await isValidatedByCsrf(csrf, { handlerType, url })).toBe( + validated, + ); + }, + ); +}); + +describe("the locale rule only ever touches a page request", () => { + const next = (response = new Response("ok")) => + vi.fn(async () => await Promise.resolve({ response })); + + it("never redirects a server function", async () => { + // Server function calls arrive with `handlerType: "serverFn"`. Redirecting + // an RPC to a canonical URL breaks it rather than tidying it. + const advance = next(); + const result = await runLocaleRequest( + { + handlerType: "serverFn", + next: advance, + request: new Request("https://a.test/en/discover"), + } as never, + localeRouting, + ); + + expect(advance).toHaveBeenCalledOnce(); + expect(result).not.toBeInstanceOf(Response); + }); + + it("redirects a non-canonical document permanently", async () => { + const advance = next(); + const result = await runLocaleRequest( + { + handlerType: "router", + next: advance, + request: new Request("https://a.test/en/discover?page=2#top"), + } as never, + localeRouting, + ); + + expect(advance).not.toHaveBeenCalled(); + expect(result).toBeInstanceOf(Response); + expect((result as Response).status).toBe(308); + expect((result as Response).headers.get("location")).toBe( + "/discover?page=2#top", + ); + }); + + it("attaches the locale cookie to the redirect that earned it", async () => { + // `/pl/admin` is both an explicit choice and a redirect, and the redirect is + // the end of the request - so the cookie has to ride on it or the language + // the visitor asked for by URL is gone before `/admin` renders. + const result = (await runLocaleRequest( + { + handlerType: "router", + next: next(), + request: new Request("https://a.test/pl/admin"), + } as never, + localeRouting, + )) as Response; + + expect(result.status).toBe(308); + expect(result.headers.get("location")).toBe("/admin"); + expect(result.headers.get("set-cookie")).toContain("pl"); + expect(result.headers.get("cache-control")).toBe("private, no-store"); + }); +}); + +describe("/api/* passes through the pipeline untouched", () => { + const jsonResponse = () => + new Response('{"ok":true}', { + headers: { + "cache-control": "public, max-age=300", + "content-type": "application/json", + }, + }); + + it.each(["https://a.test/api", "https://a.test/api/vitnode/core/session"])( + "does not redirect %s", + async url => { + const advance = vi.fn( + async () => await Promise.resolve({ response: jsonResponse() }), + ); + const result = await runLocaleRequest( + { + handlerType: "router", + next: advance, + request: new Request(url), + } as never, + localeRouting, + ); + + expect(advance).toHaveBeenCalledOnce(); + expect(result).not.toBeInstanceOf(Response); + }, + ); + + it("strips a locale prefix in front of it without remembering the language", async () => { + // `/pl/api/foo` is a mistake to correct, not a language to record: clients + // hold API URLs verbatim and the API negotiates its own locale per request. + const result = (await runLocaleRequest( + { + handlerType: "router", + next: vi.fn(), + request: new Request("https://a.test/pl/api/foo"), + } as never, + localeRouting, + )) as Response; + + expect(result.headers.get("location")).toBe("/api/foo"); + expect(result.headers.get("set-cookie")).toBeNull(); + }); + + it("leaves the API's own cache policy to the API", async () => { + const response = jsonResponse(); + await runLocaleRequest( + { + handlerType: "router", + next: vi.fn(async () => await Promise.resolve({ response })), + request: new Request("https://a.test/api/vitnode/core/session"), + } as never, + localeRouting, + ); + + expect(response.headers.get("cache-control")).toBe("public, max-age=300"); + }); +}); + +describe("what the pipeline does to the response it gets back", () => { + const documentThatAlreadySetACookie = () => + new Response("<!doctype html>", { + headers: { + "cache-control": "public, max-age=60", + "content-type": "text/html; charset=utf-8", + "set-cookie": "vitnode_auth=abc; Path=/; HttpOnly", + }, + }); + + const renderAt = async (url: string, response: Response) => { + await runLocaleRequest( + { + handlerType: "router", + next: async () => await Promise.resolve({ response }), + request: new Request(url), + } as never, + localeRouting, + ); + + return response; + }; + + it("appends the locale cookie instead of replacing the session's", async () => { + // `set`, not `append`, would sign the visitor out: the API mounted at + // `/api/*` and the auth flow both mint their own cookies. + const response = await renderAt( + "https://a.test/pl/discover", + documentThatAlreadySetACookie(), + ); + const cookies = response.headers.getSetCookie(); + + expect(cookies).toHaveLength(2); + expect(cookies[0]).toContain("vitnode_auth=abc"); + expect(cookies.some(cookie => cookie.includes("pl"))).toBe(true); + }); + + it("forces the document directive over whatever the route asked for", async () => { + const response = await renderAt( + "https://a.test/pl/discover", + documentThatAlreadySetACookie(), + ); + + expect(response.headers.get("cache-control")).toBe("private, no-store"); + }); + + it("writes no cookie for an unprefixed URL", async () => { + // `/discover` is English because English is the default, not because the + // visitor chose it - overwriting a stored `pl` here would undo the switcher. + const response = await renderAt( + "https://a.test/discover", + new Response("<!doctype html>", { + headers: { "content-type": "text/html" }, + }), + ); + + expect(response.headers.getSetCookie()).toEqual([]); + expect(response.headers.get("cache-control")).toBe("private, no-store"); + }); +}); diff --git a/packages/vitnode/src/tanstack/start/create-start.ts b/packages/vitnode/src/tanstack/start/create-start.ts new file mode 100644 index 000000000..f5cbf334a --- /dev/null +++ b/packages/vitnode/src/tanstack/start/create-start.ts @@ -0,0 +1,75 @@ +import type { AnyRequestMiddleware } from "@tanstack/react-start"; + +import { createCsrfMiddleware, createStart } from "@tanstack/react-start"; + +import type { LocaleConfig } from "@/lib/i18n/types"; +import type { VitNodeConfig } from "@/vitnode.config"; + +import { localeRoutingFromConfig } from "@/lib/i18n/locale-routing"; + +import { createLocaleRequestMiddleware } from "./locale-middleware"; + +export interface VitNodeStartOptions< + AppLocales extends LocaleConfig[] = LocaleConfig[], +> { + /** + * The app's shared config, handed over rather than read from a registry. + * + * Request middleware runs before route matching and before anything else in + * this package has necessarily been imported, so a factory that read + * `getVitNodeConfig()` would depend on which entry file Node happened to + * evaluate first. The locale routing runtime is derived from `config.i18n` + * here, once. + */ + config: VitNodeConfig<AppLocales>; + /** + * The app's own request middleware, appended after VitNode's. + * + * For things an installation genuinely owns - a request id, a tracing span, a + * maintenance-mode gate. It cannot displace or precede CSRF and locale + * handling; see the ordering note on {@link createVitNodeStart}. + */ + requestMiddleware?: readonly AnyRequestMiddleware[]; +} + +/** + * A VitNode app's Start instance - the whole of `src/start.ts`. + * + * ## Why CSRF is not optional + * + * Start installs its own CSRF middleware *only* while an app declares no + * `requestMiddleware` at all. The moment an app has a `src/start.ts` with a + * list, that default is replaced by whatever the list holds - so an app that + * writes its own pipeline and forgets CSRF exposes every server function as an + * unauthenticated cross-site endpoint, silently. That is the failure this + * factory exists to make impossible: the middleware is built here, first, and an + * app cannot omit it, reorder it, or get in front of it. + * + * The `filter` keeps it to `handlerType === "serverFn"`. Server functions are + * same-origin RPC; page navigations are not, and validating `Sec-Fetch-Site` on + * a top-level cross-site link would reject ordinary inbound traffic. + * + * ## Order + * + * 1. CSRF rejects a cross-site RPC before anything reads it + * 2. locale canonical redirects, the locale cookie, document caching + * 3. the app's whatever `requestMiddleware` lists, in order + * + * Locale handling sits second because a redirect ends the request: an app + * middleware that ran before it would run twice for every visitor arriving at + * `/en/discover`, once for the redirect and once for `/discover`. + */ +export const createVitNodeStart = <AppLocales extends LocaleConfig[]>({ + config, + requestMiddleware = [], +}: VitNodeStartOptions<AppLocales>) => { + const localeRouting = localeRoutingFromConfig(config.i18n); + + return createStart(() => ({ + requestMiddleware: [ + createCsrfMiddleware({ filter: ctx => ctx.handlerType === "serverFn" }), + createLocaleRequestMiddleware(localeRouting), + ...requestMiddleware, + ], + })); +}; diff --git a/packages/vitnode/src/tanstack/start/document-headers.test.ts b/packages/vitnode/src/tanstack/start/document-headers.test.ts new file mode 100644 index 000000000..bc78d6a78 --- /dev/null +++ b/packages/vitnode/src/tanstack/start/document-headers.test.ts @@ -0,0 +1,120 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { + applyDocumentCacheControl, + applyRedirectCacheControl, + DOCUMENT_CACHE_CONTROL, +} from "./document-headers"; + +/** + * The one directive VitNode forces onto a response, and the two questions that + * decide when. + * + * Both are content-type questions rather than path questions, which is the + * property worth pinning: `/api/*` is served by the Hono bridge through the same + * middleware, so a rule that keyed on anything else would quietly forbid clients + * from caching the API. + */ + +const html = (headers: Record<string, string> = {}) => + new Response("<!doctype html>", { + headers: { "content-type": "text/html; charset=utf-8", ...headers }, + }); + +describe("a rendered document", () => { + it("is never offered to a shared cache", () => { + const response = html(); + applyDocumentCacheControl(response); + + expect(response.headers.get("cache-control")).toBe(DOCUMENT_CACHE_CONTROL); + expect(DOCUMENT_CACHE_CONTROL).toBe("private, no-store"); + }); + + it("cannot opt out by setting its own directive", () => { + // The invariant, not a default. A route is not in a position to know that + // the dehydrated Query cache in its own body holds the visitor's session. + const response = html({ "cache-control": "public, max-age=60" }); + applyDocumentCacheControl(response); + + expect(response.headers.get("cache-control")).toBe(DOCUMENT_CACHE_CONTROL); + }); + + it.each([ + "text/html", + "text/html; charset=utf-8", + "TEXT/HTML; charset=UTF-8", + ])("is recognised from %s", contentType => { + const response = new Response("", { + headers: { "content-type": contentType }, + }); + applyDocumentCacheControl(response); + + expect(response.headers.get("cache-control")).toBe(DOCUMENT_CACHE_CONTROL); + }); +}); + +describe("everything that is not a document keeps what it had", () => { + it("leaves an API response's own cache policy alone", () => { + const response = new Response('{"ok":true}', { + headers: { + "cache-control": "public, max-age=300", + "content-type": "application/json", + }, + }); + applyDocumentCacheControl(response); + + expect(response.headers.get("cache-control")).toBe("public, max-age=300"); + }); + + it("adds nothing to an API response that said nothing", () => { + const response = new Response('{"ok":true}', { + headers: { "content-type": "application/json" }, + }); + applyDocumentCacheControl(response); + + expect(response.headers.get("cache-control")).toBeNull(); + }); + + it("adds nothing to a response with no content type at all", () => { + const response = new Response(null, { status: 204 }); + applyDocumentCacheControl(response); + + expect(response.headers.get("cache-control")).toBeNull(); + }); +}); + +describe("a locale redirect", () => { + it("stays cacheable while it carries no cookie", () => { + // `/en/discover` -> `/discover` is a fact about URLs, identical for every + // visitor, and permanently cacheable - which is most of the point of it. + const response = new Response(null, { + headers: { + "cache-control": "public, max-age=31536000", + location: "/discover", + }, + status: 308, + }); + applyRedirectCacheControl(response); + + expect(response.headers.get("cache-control")).toBe( + "public, max-age=31536000", + ); + }); + + it("is forced private the moment it writes a cookie", () => { + // A stored copy would hand the next visitor through the same shared cache a + // `Set-Cookie` somebody else chose, and switch their language. + const response = new Response(null, { + headers: { + "cache-control": "public, max-age=31536000", + location: "/admin", + "set-cookie": "vitnode_locale=pl; Path=/", + }, + status: 308, + }); + applyRedirectCacheControl(response); + + expect(response.headers.get("cache-control")).toBe(DOCUMENT_CACHE_CONTROL); + }); +}); diff --git a/packages/vitnode/src/tanstack/start/document-headers.ts b/packages/vitnode/src/tanstack/start/document-headers.ts new file mode 100644 index 000000000..2baeafad9 --- /dev/null +++ b/packages/vitnode/src/tanstack/start/document-headers.ts @@ -0,0 +1,92 @@ +/** + * What a VitNode document response may say about being stored. + * + * `private, no-store` is a description of the body rather than a precaution. + * Every page a VitNode app renders streams a dehydrated Query cache into its + * HTML, and that cache always holds `["vitnode","session"]` - the visitor's own + * name, avatar and `isAdmin` flag. Inside `/admin` it also holds + * `["vitnode","admin-session"]`, which is that administrator's entire permission + * set. + * + * ## It is an invariant, not a default + * + * A default is something a route may override, and there is no override a route + * could correctly choose here: the dehydrated cache is written into the stream + * by `setupRouterSsrQueryIntegration` for *every* document, so a route opting + * into `public, max-age=60` would publish whichever visitor rendered first to + * everyone who asked next. The route cannot know that, because the private + * payload is not something the route put there. So the directive is forced, and + * a route that sets its own is overwritten rather than obeyed. + * + * Public document caching is not forbidden forever - it is forbidden *while the + * session is dehydrated into the document*. Introducing it later is a separate + * piece of architecture in which the private state is kept out of the shared + * body, and this invariant moves with it rather than being quietly relaxed. + * + * `private` bars a shared cache; `no-store` bars every cache, the browser's own + * disk cache included - which is the half that matters on a shared machine, + * where the previous person's permission set should not be recoverable after + * they sign out. The known cost is the back/forward cache: Chromium keeps such + * pages eligible but evicts them when cookies change, so signing in or out + * invalidates a back-navigation that would have restored a page rendered for the + * previous session. + */ +export const DOCUMENT_CACHE_CONTROL = "private, no-store"; + +/** + * Whether this response is one of the documents the rule above describes. + * + * One question, and it is what keeps the API out of it. `/api/*` is served by + * the Hono bridge through this same middleware, and a bare `GET` from it carries + * no `Cache-Control` of its own - so a rule that applied to every response would + * quietly forbid clients from caching the API. An HTML content-type is the + * honest way to ask "is this a page", it needs no path list to be kept in step + * with the router, and it cannot be wrong about a response that has already been + * produced. + * + * It deliberately does *not* ask whether a directive is already present, which + * is exactly the exemption the invariant cannot afford. A redirect is not + * matched here either - it has no content type - and is handled by + * {@link applyRedirectCacheControl} instead. + * + * The one HTML page the API does serve - Swagger UI at `/api/swagger` - is + * therefore covered, which is correct rather than an edge case: it is a document + * an operator reads, not a response a client caches, and nothing about the JSON + * routes beside it is touched. + */ +const isRenderedDocument = (headers: Headers): boolean => + (headers.get("content-type") ?? "").toLowerCase().startsWith("text/html"); + +/** + * Says what a rendered document is, on the response about to be sent. + * + * Mutates rather than returning a new `Response`, because the middleware already + * holds the one Start produced and rebuilding it would mean copying a stream. + * Everything that is not `text/html` - the API, assets, client chunks, a `204` + * with no content type at all - keeps whatever it had, including nothing. + */ +export const applyDocumentCacheControl = (response: Response): void => { + if (!isRenderedDocument(response.headers)) return; + + response.headers.set("cache-control", DOCUMENT_CACHE_CONTROL); +}; + +/** + * The same for a locale redirect, but only when it is carrying a cookie. + * + * A `308` from `/en/discover` to `/discover` is a fact about URLs, identical for + * every visitor and permanently cacheable - which is most of the point of + * answering with one, so it keeps that property. + * + * The exception is the redirect that also writes the locale cookie, which is + * what `/pl/admin` produces: a stored copy would hand the next visitor through + * the same shared cache a `Set-Cookie` chosen by somebody else and quietly + * switch their language. Shared caches are generally expected to refuse a + * `Set-Cookie` response, but that is not a property this application can assert + * about somebody else's proxy. + */ +export const applyRedirectCacheControl = (response: Response): void => { + if (!response.headers.has("set-cookie")) return; + + response.headers.set("cache-control", DOCUMENT_CACHE_CONTROL); +}; diff --git a/packages/vitnode/src/tanstack/start/index.ts b/packages/vitnode/src/tanstack/start/index.ts new file mode 100644 index 000000000..02ebef242 --- /dev/null +++ b/packages/vitnode/src/tanstack/start/index.ts @@ -0,0 +1,34 @@ +/** + * VitNode's TanStack Start request pipeline - `@vitnode/core/tanstack/start`. + * + * One call, in an app's `src/start.ts`: + * + * import { createVitNodeStart } from '@vitnode/core/tanstack/start' + * import { vitNodeConfig } from './vitnode.config' + * + * export const startInstance = createVitNodeStart({ config: vitNodeConfig }) + * + * ## Why the two Start primitives are allowed here + * + * `boundary.test.ts` keeps `createServerFn` out of this package, because + * uncompiled - which is how this package reaches a server - its handler silently + * resolves to `undefined`. `createStart` and `createMiddleware` are exempt in + * this one directory, and the reason is that neither is compiled at all: both are + * plain builders that return the options object they were handed, and a *request* + * middleware has only a `.server()` branch, which is the only branch a server + * entry ever runs. The host still owns the composition - it calls this and + * exports the result as `startInstance`, which is what the framework reads. + * + * ## And why nothing here is marked server-only + * + * `src/start.ts` is a client entry as well as a server one. See the note in + * `locale-middleware.ts`: the browser is kept out of the request half by the + * compiler dropping a `.server()` callback, not by a marker. + */ +export type { VitNodeStartOptions } from "./create-start"; +export { createVitNodeStart } from "./create-start"; +export { + applyDocumentCacheControl, + applyRedirectCacheControl, + DOCUMENT_CACHE_CONTROL, +} from "./document-headers"; diff --git a/packages/vitnode/src/tanstack/start/locale-middleware.ts b/packages/vitnode/src/tanstack/start/locale-middleware.ts new file mode 100644 index 000000000..cd155633e --- /dev/null +++ b/packages/vitnode/src/tanstack/start/locale-middleware.ts @@ -0,0 +1,89 @@ +import type { RequestServerOptions } from "@tanstack/react-start"; + +import { createMiddleware } from "@tanstack/react-start"; + +import type { LocaleRouting } from "@/lib/i18n/locale-routing"; + +import { handleLocaleRequest } from "../i18n/request"; +import { + applyDocumentCacheControl, + applyRedirectCacheControl, +} from "./document-headers"; + +/** + * No `server-only` marker here, and that is load-bearing rather than an + * oversight. + * + * `src/start.ts` is in the *client* bundle too - `hydrateStart` imports + * `startInstance` from it - so a top-level `import "@tanstack/react-start/server-only"` + * in this chain is an import-protection error in the client build, not a + * safeguard. What actually keeps the server half out of the browser is the Start + * compiler: it strips a middleware's `.server()` callback from the client + * bundle, which kills the only live reference to `handleLocaleRequest` and lets + * `../i18n/request` - which *is* marked server-only - shake out with it. + * + * So the rule is: everything a browser must not hold stays reachable only from + * inside the `.server()` callback below. + */ + +/** As much of a request middleware's context as the locale rule reads. */ +type LocaleRequestContext = Pick< + RequestServerOptions<unknown, unknown>, + "handlerType" | "next" | "request" +>; + +/** + * Locale routing and document cache policy, as one pass over a request. + * + * Exported as a plain function so the rule is testable without a Start runtime: + * {@link createLocaleRequestMiddleware} is the three lines that hand it to + * Start. + * + * `handlerType` narrows it to page requests. Server function calls arrive on + * `/_serverFn/*` with `handlerType: "serverFn"`, and redirecting an RPC to a + * canonical URL breaks it rather than tidying it. + * + * `/api/*` reaches here too and is deliberately ignored by `handleLocaleRequest` + * - and, being JSON rather than HTML, by the cache rule as well - so the Hono + * bridge sees the request exactly as the client sent it and keeps whatever + * caching it decided on. + */ +export const runLocaleRequest = async ( + { handlerType, next, request }: LocaleRequestContext, + localeRouting: LocaleRouting, +) => { + if (handlerType !== "router") return await next(); + + const { redirect, setCookie } = handleLocaleRequest(request, localeRouting); + if (redirect) { + applyRedirectCacheControl(redirect); + + return redirect; + } + + const result = await next(); + + // `append`, not `set`: the API mounted at `/api/*` and the auth flow both mint + // their own cookies, and overwriting the header would sign people out. + if (setCookie) result.response.headers.append("set-cookie", setCookie); + + // After the cookie, so a document that just wrote one is covered by the same + // directive as one that did not. + applyDocumentCacheControl(result.response); + + return result; +}; + +/** + * The locale half of VitNode's request pipeline, bound to one app's languages. + * + * `localeRouting` is passed in rather than read from the registered i18n runtime + * because Start runs request middleware before route matching - so this is the + * one caller that cannot assume the router entry has been evaluated. An explicit + * argument makes the ordering a fact of the call rather than a hope about module + * evaluation. + */ +export const createLocaleRequestMiddleware = (localeRouting: LocaleRouting) => + createMiddleware().server( + async ctx => await runLocaleRequest(ctx, localeRouting), + ); diff --git a/packages/vitnode/src/vitnode.config.ts b/packages/vitnode/src/vitnode.config.ts index 426c09589..30fef1eb8 100644 --- a/packages/vitnode/src/vitnode.config.ts +++ b/packages/vitnode/src/vitnode.config.ts @@ -13,7 +13,9 @@ import type { StorageApiPlugin } from "./api/models/storage"; import type { ThemeProviderProps } from "./components/theme-provider"; import type { DefaultTemplateEmailProps } from "./emails/default-template"; import type { + AppMessagesMap, LocaleConfig, + LocaleMessagesMap, VitNodeApiI18nConfig, VitNodeI18nConfig, } from "./lib/i18n/types"; @@ -22,6 +24,27 @@ import type { BuildPluginReturn } from "./lib/plugin"; export type { LocaleConfig }; +/** + * An installation's shared configuration - `src/vitnode.config.ts`. + * + * **Browser-safe, and that is a contract rather than a happy accident.** The + * document shell reads `metadata`, `theme` and `debug`; the locale runtime reads + * `i18n`; the Vite plugin registry reads `plugins` while Vite is still loading + * its config. So every value here has to survive being bundled for a browser and + * being executed by `jiti` in Node - which means plain data and plugin + * *identity*, never a `() => import(...)` message loader and never a module that + * reaches a database. + * + * `plugins` holds each enabled plugin's registration - normally the plugin's own + * factory, `blogPlugin()`. A Next.js host walks that list directly. A TanStack + * Start host reads the same declarations back through build-time projections of + * each plugin's `admin/nav` and `admin/content` exports, which is what gets an + * editing screen loaded with the route that renders it rather than with the + * config; `buildPlugin({ pluginId })` is the minimum such a host needs. + * + * Everything that cannot honour the browser-safe contract lives in + * {@link VitNodeServerConfig}. + */ export interface VitNodeConfig< AppLocales extends LocaleConfig[] = LocaleConfig[], > { @@ -35,6 +58,42 @@ export interface VitNodeConfig< >; } +/** + * The half of an installation's configuration a browser may never hold - + * `src/vitnode.server.config.ts`. + * + * Two things, and both are functions that read files out of a package's build + * output: the app's own message overrides and the per-package loaders a bundled + * runtime has to declare for itself (see `BundledMessagesOptions.packageMessages`). + * Putting them beside `metadata` and `theme` is what used to force an app to + * keep two configs that agreed until they didn't. + * + * It holds the shared config rather than repeating any of it, so the locale + * list a message loader is resolved against is the same object the router and + * the document shell read. + */ +export interface VitNodeServerConfig< + AppLocales extends LocaleConfig[] = LocaleConfig[], +> { + /** The shared config this app also serves to the browser. */ + config: VitNodeConfig<AppLocales>; + /** + * Translations owned by the app rather than by a package, keyed by locale and + * then by the plugin whose namespace they extend. Files live in + * `src/locales/<pluginId>/<locale>.json`. + */ + messages?: AppMessagesMap; + /** + * Where each installed package's translations are read from, keyed by plugin + * id - core included. + * + * A bundled runtime cannot use the locale barrel a package ships, because + * Rollup will not follow its `import("./en.json", { with: { type: "json" } })`. + * An app declares static specifiers a bundler can follow instead. + */ + packageMessages?: Record<string, LocaleMessagesMap | undefined>; +} + export interface VitNodeApiConfig { /** * AI models for the Vercel AI SDK. Declare them inline - each `model` is a @@ -170,7 +229,17 @@ export interface VitNodeApiConfig { let registeredVitNodeConfig: undefined | VitNodeConfig; -export function buildConfig<AppLocales extends LocaleConfig[]>( +/** + * Builds an installation's shared config - the one call in + * `src/vitnode.config.ts`. + * + * `const AppLocales` is what keeps `locales` a tuple of literal types through + * inference, so `Locale` derived from the result is `"en" | "pl"` rather than + * `string` and `defaultLocale` is checked against the list beside it. Without + * it every code widens to `string` and each app has to write `as const` on + * every entry. + */ +export function buildConfig<const AppLocales extends LocaleConfig[]>( args: VitNodeConfig<AppLocales>, ): VitNodeConfig<AppLocales> { const config = { @@ -188,6 +257,21 @@ export function buildConfig<AppLocales extends LocaleConfig[]>( return config; } +/** + * Builds the server-only companion to {@link buildConfig} - the one call in + * `src/vitnode.server.config.ts`. + * + * Identity, deliberately: there is nothing to normalise, and the value of the + * function is the type it pins and the file it names. Nothing registers it + * process-wide either, because everything that reads it is already on the + * server and can import it. + */ +export function buildServerConfig<const AppLocales extends LocaleConfig[]>( + args: VitNodeServerConfig<AppLocales>, +): VitNodeServerConfig<AppLocales> { + return args; +} + /** * Returns the app's VitNodeConfig registered by {@link buildConfig} (called once * in the app's `vitnode.config.ts`). Used by framework route files that need the diff --git a/packages/vitnode/test-fixtures/config-split/vitnode.config.ts b/packages/vitnode/test-fixtures/config-split/vitnode.config.ts new file mode 100644 index 000000000..08a373e94 --- /dev/null +++ b/packages/vitnode/test-fixtures/config-split/vitnode.config.ts @@ -0,0 +1,23 @@ +import { buildConfig } from "../../src/vitnode.config"; + +/** + * A shared config exactly as an app writes one - the browser-safe half. + * + * Imports nothing but `buildConfig`, which is the whole claim under test: Vite + * loads this file with `jiti` to discover the configured plugins, and the + * document shell holds it in the browser bundle. `vitnode.server.config.ts` + * beside it throws on import, so a discovery pass that reached the server half + * would fail loudly instead of merely being slower than it should be. + */ +export const vitNodeConfig = buildConfig({ + i18n: { + defaultLocale: "en", + locales: [ + { code: "en", name: "English" }, + { code: "pl", name: "Polski" }, + ], + timeZone: "UTC", + }, + metadata: { shortTitle: "Fixture", title: "Fixture" }, + plugins: [{ pluginId: "@acme/blog" }, { pluginId: "@acme/docs" }], +}); diff --git a/packages/vitnode/test-fixtures/config-split/vitnode.server.config.ts b/packages/vitnode/test-fixtures/config-split/vitnode.server.config.ts new file mode 100644 index 000000000..a0c45a3b3 --- /dev/null +++ b/packages/vitnode/test-fixtures/config-split/vitnode.server.config.ts @@ -0,0 +1,13 @@ +/** + * The server-only half, rigged to fail if anything build-time reaches it. + * + * A real one carries `() => import("./locales/...")` loaders that read a + * package's build output, plus the `server-only` marker. This throws at module + * scope instead, which is how `shared-config-discovery.test.ts` proves the + * plugin generator never loads it: an assertion about a call count nobody owns + * would pass the day the generator started importing this file and stopped + * calling anything on it. + */ +throw new Error( + "the server-only config was loaded by something that should only read the shared one", +); diff --git a/scripts/bump-version/files/file-copy-manager.ts b/scripts/bump-version/files/file-copy-manager.ts index 191b8e186..008e8c341 100644 --- a/scripts/bump-version/files/file-copy-manager.ts +++ b/scripts/bump-version/files/file-copy-manager.ts @@ -7,16 +7,6 @@ import { validatePath, } from "./file-system.ts"; -/** - * Refreshes the parts of the `create-vitnode-app` templates that are copied - * verbatim out of this repository at release time. - * - * Until Stage 17 this also seeded `copy-of-vitnode-app/root` and - * `copy-of-vitnode-app/api-single-app` from `apps/docs`, whose Next.js App - * Router tree (`src/app/[locale]/...`, `src/app/api/[...route]`) no longer - * exists. Those template directories are now maintained in place rather than - * generated; only the framework-neutral `apps/api` files are still mirrored. - */ export class FileCopyManager { constructor(private env: EnvironmentConfig) {} From c6801c4f241e1d989de3481e3c69266508e8211e Mon Sep 17 00:00:00 2001 From: aXenDeveloper <aXenDeveloper@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:13:17 +0000 Subject: [PATCH 06/12] ci: version bump to v2.0.0-canary.2 --- apps/api/package.json | 2 +- apps/web/package.json | 2 +- packages/config/package.json | 2 +- packages/create-vitnode-app/package.json | 2 +- packages/elasticsearch/package.json | 2 +- packages/node-cron/package.json | 2 +- packages/nodemailer/package.json | 2 +- packages/resend/package.json | 2 +- packages/s3/package.json | 2 +- packages/supabase-storage/package.json | 2 +- packages/vitnode/package.json | 2 +- packages/vitnode/src/config.ts | 2 +- plugins/blog/package.json | 2 +- plugins/example/package.json | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 72221d2e0..29afa266f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "api", - "version": "2.0.0-canary.1", + "version": "2.0.0-canary.2", "private": true, "type": "module", "scripts": { diff --git a/apps/web/package.json b/apps/web/package.json index 7568d6ac8..ac25ae007 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "2.0.0-canary.1", + "version": "2.0.0-canary.2", "private": true, "type": "module", "imports": { diff --git a/packages/config/package.json b/packages/config/package.json index 69aa1c75b..a9b452c47 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/config", - "version": "2.0.0-canary.1", + "version": "2.0.0-canary.2", "description": "ESLint, Prettier, TypeScript (TSConfig) config for VitNode", "author": "VitNode Team", "license": "MIT", diff --git a/packages/create-vitnode-app/package.json b/packages/create-vitnode-app/package.json index cd2471798..fbf013168 100644 --- a/packages/create-vitnode-app/package.json +++ b/packages/create-vitnode-app/package.json @@ -1,6 +1,6 @@ { "name": "create-vitnode-app", - "version": "2.0.0-canary.1", + "version": "2.0.0-canary.2", "description": "Create a new VitNode app in seconds.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/elasticsearch/package.json b/packages/elasticsearch/package.json index 2d2038040..4ea1b1317 100644 --- a/packages/elasticsearch/package.json +++ b/packages/elasticsearch/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/elasticsearch", - "version": "2.0.0-canary.1", + "version": "2.0.0-canary.2", "description": "Elasticsearch search engine adapter for VitNode content discovery.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/node-cron/package.json b/packages/node-cron/package.json index 84f86be06..b93e29741 100644 --- a/packages/node-cron/package.json +++ b/packages/node-cron/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/node-cron", - "version": "2.0.0-canary.1", + "version": "2.0.0-canary.2", "description": "Node-cron adapter for VitNode, enabling cron job scheduling and management.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/nodemailer/package.json b/packages/nodemailer/package.json index a68ddede5..7629308a8 100644 --- a/packages/nodemailer/package.json +++ b/packages/nodemailer/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/nodemailer", - "version": "2.0.0-canary.1", + "version": "2.0.0-canary.2", "description": "Nodemailer integration package for VitNode, enabling email functionalities.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/resend/package.json b/packages/resend/package.json index 1625c85bf..cc3dd7fa9 100644 --- a/packages/resend/package.json +++ b/packages/resend/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/resend", - "version": "2.0.0-canary.1", + "version": "2.0.0-canary.2", "description": "Resend adapter for VitNode, enabling email sending capabilities through the Resend service.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/s3/package.json b/packages/s3/package.json index ad3151789..48c9bcc06 100644 --- a/packages/s3/package.json +++ b/packages/s3/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/s3", - "version": "2.0.0-canary.1", + "version": "2.0.0-canary.2", "description": "AWS S3 and Cloudflare R2 storage adapter for VitNode file uploads.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/supabase-storage/package.json b/packages/supabase-storage/package.json index 40dd53544..0471b153e 100644 --- a/packages/supabase-storage/package.json +++ b/packages/supabase-storage/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/supabase-storage", - "version": "2.0.0-canary.1", + "version": "2.0.0-canary.2", "description": "Supabase Storage adapter for VitNode file uploads.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 922612314..32412142f 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/core", - "version": "2.0.0-canary.1", + "version": "2.0.0-canary.2", "description": "Core package for VitNode, providing essential functionalities and configurations.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/vitnode/src/config.ts b/packages/vitnode/src/config.ts index 1ea7591ee..58e01d9e9 100644 --- a/packages/vitnode/src/config.ts +++ b/packages/vitnode/src/config.ts @@ -1,4 +1,4 @@ export const CONFIG_PLUGIN = { pluginId: "@vitnode/core" as const, - version: "2.0.0-canary.1", + version: "2.0.0-canary.2", }; diff --git a/plugins/blog/package.json b/plugins/blog/package.json index b5f7ae7e2..d4cd0355f 100644 --- a/plugins/blog/package.json +++ b/plugins/blog/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/blog", - "version": "2.0.0-canary.1", + "version": "2.0.0-canary.2", "description": "Blog plugin for VitNode, providing a blogging platform on Hono.js.", "author": "VitNode Team", "license": "MIT", diff --git a/plugins/example/package.json b/plugins/example/package.json index 1875995aa..cbeb8a995 100644 --- a/plugins/example/package.json +++ b/plugins/example/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/example", - "version": "2.0.0-canary.1", + "version": "2.0.0-canary.2", "description": "Reference plugin exercising the VitNode Content Engine end to end.", "license": "MIT", "private": true, From d1adafcababe7470f9b5b10236163f7c50c5aadb Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Thu, 3 Sep 2026 16:33:07 +0200 Subject: [PATCH 07/12] fix: invalid compile --- .../src/tanstack/start/locale-middleware.ts | 81 +------------------ .../src/tanstack/start/locale-request.ts | 34 ++++++++ 2 files changed, 35 insertions(+), 80 deletions(-) create mode 100644 packages/vitnode/src/tanstack/start/locale-request.ts diff --git a/packages/vitnode/src/tanstack/start/locale-middleware.ts b/packages/vitnode/src/tanstack/start/locale-middleware.ts index cd155633e..f71b2880f 100644 --- a/packages/vitnode/src/tanstack/start/locale-middleware.ts +++ b/packages/vitnode/src/tanstack/start/locale-middleware.ts @@ -1,88 +1,9 @@ -import type { RequestServerOptions } from "@tanstack/react-start"; - import { createMiddleware } from "@tanstack/react-start"; import type { LocaleRouting } from "@/lib/i18n/locale-routing"; -import { handleLocaleRequest } from "../i18n/request"; -import { - applyDocumentCacheControl, - applyRedirectCacheControl, -} from "./document-headers"; - -/** - * No `server-only` marker here, and that is load-bearing rather than an - * oversight. - * - * `src/start.ts` is in the *client* bundle too - `hydrateStart` imports - * `startInstance` from it - so a top-level `import "@tanstack/react-start/server-only"` - * in this chain is an import-protection error in the client build, not a - * safeguard. What actually keeps the server half out of the browser is the Start - * compiler: it strips a middleware's `.server()` callback from the client - * bundle, which kills the only live reference to `handleLocaleRequest` and lets - * `../i18n/request` - which *is* marked server-only - shake out with it. - * - * So the rule is: everything a browser must not hold stays reachable only from - * inside the `.server()` callback below. - */ - -/** As much of a request middleware's context as the locale rule reads. */ -type LocaleRequestContext = Pick< - RequestServerOptions<unknown, unknown>, - "handlerType" | "next" | "request" ->; - -/** - * Locale routing and document cache policy, as one pass over a request. - * - * Exported as a plain function so the rule is testable without a Start runtime: - * {@link createLocaleRequestMiddleware} is the three lines that hand it to - * Start. - * - * `handlerType` narrows it to page requests. Server function calls arrive on - * `/_serverFn/*` with `handlerType: "serverFn"`, and redirecting an RPC to a - * canonical URL breaks it rather than tidying it. - * - * `/api/*` reaches here too and is deliberately ignored by `handleLocaleRequest` - * - and, being JSON rather than HTML, by the cache rule as well - so the Hono - * bridge sees the request exactly as the client sent it and keeps whatever - * caching it decided on. - */ -export const runLocaleRequest = async ( - { handlerType, next, request }: LocaleRequestContext, - localeRouting: LocaleRouting, -) => { - if (handlerType !== "router") return await next(); - - const { redirect, setCookie } = handleLocaleRequest(request, localeRouting); - if (redirect) { - applyRedirectCacheControl(redirect); - - return redirect; - } - - const result = await next(); - - // `append`, not `set`: the API mounted at `/api/*` and the auth flow both mint - // their own cookies, and overwriting the header would sign people out. - if (setCookie) result.response.headers.append("set-cookie", setCookie); - - // After the cookie, so a document that just wrote one is covered by the same - // directive as one that did not. - applyDocumentCacheControl(result.response); - - return result; -}; +import { runLocaleRequest } from "./locale-request"; -/** - * The locale half of VitNode's request pipeline, bound to one app's languages. - * - * `localeRouting` is passed in rather than read from the registered i18n runtime - * because Start runs request middleware before route matching - so this is the - * one caller that cannot assume the router entry has been evaluated. An explicit - * argument makes the ordering a fact of the call rather than a hope about module - * evaluation. - */ export const createLocaleRequestMiddleware = (localeRouting: LocaleRouting) => createMiddleware().server( async ctx => await runLocaleRequest(ctx, localeRouting), diff --git a/packages/vitnode/src/tanstack/start/locale-request.ts b/packages/vitnode/src/tanstack/start/locale-request.ts new file mode 100644 index 000000000..995348fb6 --- /dev/null +++ b/packages/vitnode/src/tanstack/start/locale-request.ts @@ -0,0 +1,34 @@ +import type { RequestServerOptions } from "@tanstack/react-start"; + +import type { LocaleRouting } from "@/lib/i18n/locale-routing"; + +import { handleLocaleRequest } from "../i18n/request"; +import { + applyDocumentCacheControl, + applyRedirectCacheControl, +} from "./document-headers"; + +type LocaleRequestContext = Pick< + RequestServerOptions<unknown, unknown>, + "handlerType" | "next" | "request" +>; + +export const runLocaleRequest = async ( + { handlerType, next, request }: LocaleRequestContext, + localeRouting: LocaleRouting, +) => { + if (handlerType !== "router") return await next(); + + const { redirect, setCookie } = handleLocaleRequest(request, localeRouting); + if (redirect) { + applyRedirectCacheControl(redirect); + + return redirect; + } + + const result = await next(); + if (setCookie) result.response.headers.append("set-cookie", setCookie); + applyDocumentCacheControl(result.response); + + return result; +}; From 148f0198b8fa8cb0fc6fd016d4c286f1920ef474 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <aXenDeveloper@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:34:11 +0000 Subject: [PATCH 08/12] ci: version bump to v2.0.0-canary.3 --- apps/api/package.json | 2 +- apps/web/package.json | 2 +- packages/config/package.json | 2 +- packages/create-vitnode-app/package.json | 2 +- packages/elasticsearch/package.json | 2 +- packages/node-cron/package.json | 2 +- packages/nodemailer/package.json | 2 +- packages/resend/package.json | 2 +- packages/s3/package.json | 2 +- packages/supabase-storage/package.json | 2 +- packages/vitnode/package.json | 2 +- packages/vitnode/src/config.ts | 2 +- plugins/blog/package.json | 2 +- plugins/example/package.json | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 29afa266f..9bb560c9b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "api", - "version": "2.0.0-canary.2", + "version": "2.0.0-canary.3", "private": true, "type": "module", "scripts": { diff --git a/apps/web/package.json b/apps/web/package.json index ac25ae007..7efb77a58 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "2.0.0-canary.2", + "version": "2.0.0-canary.3", "private": true, "type": "module", "imports": { diff --git a/packages/config/package.json b/packages/config/package.json index a9b452c47..f54ccc09e 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/config", - "version": "2.0.0-canary.2", + "version": "2.0.0-canary.3", "description": "ESLint, Prettier, TypeScript (TSConfig) config for VitNode", "author": "VitNode Team", "license": "MIT", diff --git a/packages/create-vitnode-app/package.json b/packages/create-vitnode-app/package.json index fbf013168..7d6183589 100644 --- a/packages/create-vitnode-app/package.json +++ b/packages/create-vitnode-app/package.json @@ -1,6 +1,6 @@ { "name": "create-vitnode-app", - "version": "2.0.0-canary.2", + "version": "2.0.0-canary.3", "description": "Create a new VitNode app in seconds.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/elasticsearch/package.json b/packages/elasticsearch/package.json index 4ea1b1317..dba6d2eb5 100644 --- a/packages/elasticsearch/package.json +++ b/packages/elasticsearch/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/elasticsearch", - "version": "2.0.0-canary.2", + "version": "2.0.0-canary.3", "description": "Elasticsearch search engine adapter for VitNode content discovery.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/node-cron/package.json b/packages/node-cron/package.json index b93e29741..08de855cc 100644 --- a/packages/node-cron/package.json +++ b/packages/node-cron/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/node-cron", - "version": "2.0.0-canary.2", + "version": "2.0.0-canary.3", "description": "Node-cron adapter for VitNode, enabling cron job scheduling and management.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/nodemailer/package.json b/packages/nodemailer/package.json index 7629308a8..a4580d532 100644 --- a/packages/nodemailer/package.json +++ b/packages/nodemailer/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/nodemailer", - "version": "2.0.0-canary.2", + "version": "2.0.0-canary.3", "description": "Nodemailer integration package for VitNode, enabling email functionalities.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/resend/package.json b/packages/resend/package.json index cc3dd7fa9..f05d53661 100644 --- a/packages/resend/package.json +++ b/packages/resend/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/resend", - "version": "2.0.0-canary.2", + "version": "2.0.0-canary.3", "description": "Resend adapter for VitNode, enabling email sending capabilities through the Resend service.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/s3/package.json b/packages/s3/package.json index 48c9bcc06..18a8e83e5 100644 --- a/packages/s3/package.json +++ b/packages/s3/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/s3", - "version": "2.0.0-canary.2", + "version": "2.0.0-canary.3", "description": "AWS S3 and Cloudflare R2 storage adapter for VitNode file uploads.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/supabase-storage/package.json b/packages/supabase-storage/package.json index 0471b153e..41ff658b8 100644 --- a/packages/supabase-storage/package.json +++ b/packages/supabase-storage/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/supabase-storage", - "version": "2.0.0-canary.2", + "version": "2.0.0-canary.3", "description": "Supabase Storage adapter for VitNode file uploads.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 32412142f..1f65620b0 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/core", - "version": "2.0.0-canary.2", + "version": "2.0.0-canary.3", "description": "Core package for VitNode, providing essential functionalities and configurations.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/vitnode/src/config.ts b/packages/vitnode/src/config.ts index 58e01d9e9..e8c1ea4f8 100644 --- a/packages/vitnode/src/config.ts +++ b/packages/vitnode/src/config.ts @@ -1,4 +1,4 @@ export const CONFIG_PLUGIN = { pluginId: "@vitnode/core" as const, - version: "2.0.0-canary.2", + version: "2.0.0-canary.3", }; diff --git a/plugins/blog/package.json b/plugins/blog/package.json index d4cd0355f..2431850cf 100644 --- a/plugins/blog/package.json +++ b/plugins/blog/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/blog", - "version": "2.0.0-canary.2", + "version": "2.0.0-canary.3", "description": "Blog plugin for VitNode, providing a blogging platform on Hono.js.", "author": "VitNode Team", "license": "MIT", diff --git a/plugins/example/package.json b/plugins/example/package.json index cbeb8a995..8741462c5 100644 --- a/plugins/example/package.json +++ b/plugins/example/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/example", - "version": "2.0.0-canary.2", + "version": "2.0.0-canary.3", "description": "Reference plugin exercising the VitNode Content Engine end to end.", "license": "MIT", "private": true, From e217c9c24656684f7957f108079a4ba1acc2df3d Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Thu, 3 Sep 2026 18:32:04 +0200 Subject: [PATCH 09/12] perf: Improve config manage --- apps/web/content/docs/dev/configuration.mdx | 29 +- .../docs/dev/deployments/self-hosted.mdx | 34 +- .../content/docs/dev/routing/not-found.mdx | 29 +- apps/web/content/docs/dev/setup.mdx | 35 +- apps/web/src/docs/not-found-content.tsx | 56 -- apps/web/src/router.tsx | 2 +- apps/web/src/routes/__root.tsx | 100 +-- .../src/routes/_admin/admin.core.index.tsx | 4 +- apps/web/src/routes/_docs/docs.$.tsx | 74 -- apps/web/src/routes/_main.tsx | 39 +- apps/web/src/server/messages.server.ts | 13 - apps/web/src/start.ts | 8 - apps/web/vite.config.ts | 3 - .../monorepo/apps/web/.env.example | 11 - .../copy-of-vitnode-app/root/.env.example | 7 - .../copy-of-vitnode-app/root/src/router.tsx | 166 +--- .../root/src/routes/__root.tsx | 99 +-- .../root/src/routes/_main.tsx | 39 +- .../root/src/routes/_main/index.tsx | 30 - .../root/src/server/messages.server.ts | 13 - .../copy-of-vitnode-app/root/src/start.ts | 19 - .../root/src/vitnode.config.ts | 56 -- .../root/src/vitnode.server.config.ts | 11 - .../copy-of-vitnode-app/root/vite.config.ts | 84 --- .../src/create/scaffold-invariants.test.ts | 46 +- packages/vitnode/src/framework/vite/index.ts | 6 +- .../src/framework/vite/optimize-deps.test.ts | 28 +- .../src/framework/vite/optimize-deps.ts | 44 +- .../src/framework/vite/plugin-routes.ts | 3 + .../src/framework/vite/ssr-externals.test.ts | 52 ++ .../src/framework/vite/ssr-externals.ts | 31 + .../src/framework/vite/vitnode.test.ts | 7 +- .../vitnode/src/framework/vite/vitnode.ts | 4 +- packages/vitnode/src/lib/config.ts | 4 +- .../src/tanstack/auth/login-screen.tsx | 34 +- .../src/tanstack/auth/recovery-screen.tsx | 64 +- .../src/tanstack/auth/register-screen.tsx | 36 +- .../vitnode/src/tanstack/auth/sso-screen.tsx | 16 +- .../vitnode/src/tanstack/boundary.test.ts | 542 ------------- .../tanstack/cancellation-degrades.test.ts | 229 ------ .../vitnode/src/tanstack/cancellation.test.ts | 258 ------- .../vitnode/src/tanstack/eager-graph.test.ts | 712 ------------------ .../src/tanstack/fetcher/server.test.ts | 32 + .../vitnode/src/tanstack/fetcher/server.ts | 31 +- .../src/tanstack/route-freshness.test.ts | 185 ----- packages/vitnode/src/tanstack/routes/index.ts | 19 +- .../tanstack/routes/{root => main}/auth.tsx | 10 +- .../src/tanstack/routes/main/index.tsx | 67 +- .../tanstack/routes/main/main-routes.test.ts | 220 ++++++ .../routes/main/not-found-matching.test.ts | 154 ++++ .../src/tanstack/routes/main/not-found.tsx | 87 +++ .../tanstack/routes/{root => main}/sso.tsx | 4 +- .../src/tanstack/routes/root/index.tsx | 28 +- .../tanstack/routes/root/root-routes.test.ts | 99 +-- .../vitnode/src/tanstack/routes/root/types.ts | 22 +- packages/vitnode/src/tanstack/routes/types.ts | 21 + .../layouts/theme/header/header-content.tsx | 11 +- 57 files changed, 1090 insertions(+), 2977 deletions(-) delete mode 100644 apps/web/src/docs/not-found-content.tsx create mode 100644 packages/vitnode/src/framework/vite/ssr-externals.test.ts create mode 100644 packages/vitnode/src/framework/vite/ssr-externals.ts delete mode 100644 packages/vitnode/src/tanstack/boundary.test.ts delete mode 100644 packages/vitnode/src/tanstack/cancellation-degrades.test.ts delete mode 100644 packages/vitnode/src/tanstack/cancellation.test.ts delete mode 100644 packages/vitnode/src/tanstack/eager-graph.test.ts delete mode 100644 packages/vitnode/src/tanstack/route-freshness.test.ts rename packages/vitnode/src/tanstack/routes/{root => main}/auth.tsx (97%) create mode 100644 packages/vitnode/src/tanstack/routes/main/main-routes.test.ts create mode 100644 packages/vitnode/src/tanstack/routes/main/not-found-matching.test.ts create mode 100644 packages/vitnode/src/tanstack/routes/main/not-found.tsx rename packages/vitnode/src/tanstack/routes/{root => main}/sso.tsx (95%) diff --git a/apps/web/content/docs/dev/configuration.mdx b/apps/web/content/docs/dev/configuration.mdx index 2ffd1d438..9f8447bd7 100644 --- a/apps/web/content/docs/dev/configuration.mdx +++ b/apps/web/content/docs/dev/configuration.mdx @@ -237,8 +237,8 @@ the redirect and once for the page. ## `vite.config.ts` One VitNode plugin, which is the environment handling, the dev server's -dependency pre-bundling and the plugin route generator in the order they have to -run: +dependency pre-bundling, the SSR externals and the plugin route generator in the +order they have to run: ```ts title="apps/web/vite.config.ts" import { vitnode } from '@vitnode/core/framework/vite' @@ -265,9 +265,30 @@ vitnode({ appRoot: import.meta.dirname, clientEnv: ['NEXT_PUBLIC_MAP_KEY'] }) ``` Everything named there is compiled into JavaScript anyone can read, so add a key -only when something in the browser genuinely reads it. The three plugins are +only when something in the browser genuinely reads it. The four plugins are still exported individually - `vitNodeEnv`, `vitNodeOptimizeDeps`, -`vitNodePluginRoutes` - if you ever need to drop or reorder one. +`vitNodeSsrExternals`, `vitNodePluginRoutes` - if you ever need to drop or +reorder one. + +### What gets externalised from the server render + +You do not write an `ssr.external` list, because the right answer changes +between the two commands: + +| Command | `@vitnode/core` and your plugins | Why | +| ------------ | -------------------------------- | --------------------------------------------------------------------------------- | +| `vite dev` | inlined | Vite owns the modules, so a rebuilt package reaches the server render immediately | +| `vite build` | external | the built package goes into the output as it is, rather than being bundled again | + +Externalising in dev is the tempting mistake, and it fails quietly: an external +module is imported by Node, whose module cache lives as long as the process. The +browser hot-reloads your edit, the server render keeps the copy it loaded at +startup, and React reports a hydration mismatch on markup you already fixed. Only +a restart clears it - which is exactly the loop this plugin removes. + +The build list is `@vitnode/core`, every plugin in your `vitnode.config.ts` and +`tslib`. It comes from the same configured plugin list the route generator reads, +so removing a plugin from your config is one edit, not two. ## Learn More diff --git a/apps/web/content/docs/dev/deployments/self-hosted.mdx b/apps/web/content/docs/dev/deployments/self-hosted.mdx index 2a4e9544d..7b78c37d3 100644 --- a/apps/web/content/docs/dev/deployments/self-hosted.mdx +++ b/apps/web/content/docs/dev/deployments/self-hosted.mdx @@ -51,21 +51,41 @@ Configure these in your production `.env` file: ```bash title=".env" # Database (PostgreSQL 15+) -DATABASE_URL=postgresql://user:password@localhost:5432/vitnode_db +POSTGRES_URL=postgresql://user:password@localhost:5432/vitnode_db -# Security & Sessions -COOKIE_SECRET=change_me_to_a_long_random_secret_string +# The origin visitors reach. Email links, SSO callbacks and password-reset +# links are built from it. +NEXT_PUBLIC_WEB_URL=https://example.com -# App Networking -PORT=3000 -API_URL=http://localhost:8000 -WEB_URL=http://localhost:3000 +# Required if anything schedules cron. A long random string. +CRON_SECRET=change_me_to_a_long_random_secret_string # Optional: Shared Cache & Realtime REDIS_URL=redis://localhost:6379 REDIS_PASSWORD=change_me_redis_password ``` +### Where the API lives + +A **Single App** answers `/api/*` itself, so leave `NEXT_PUBLIC_API_URL` unset. +The server takes the API origin off the request being rendered and the browser +falls back to the origin the page was served from, so the same build is correct +on every hostname you point at it. + +A **split deployment** runs the web app and `apps/api` as two processes, and the +web app then has to be told where the API is - its own origin has no `/api/*` to +answer, so an unset value turns every server-side call into a 404, starting with +the session read on the first render. Set it in the web app's environment, and +set it before the build: it is compiled into the browser bundle too. + +```bash title="apps/web/.env" +NEXT_PUBLIC_API_URL=http://localhost:8000 +``` + +A value set here wins over the request origin, which is why a Single App must +not carry one - `http://localhost:3000` left over from development would point +production at the visitor's own machine. + --- ## Reverse Proxy (Nginx Configuration) diff --git a/apps/web/content/docs/dev/routing/not-found.mdx b/apps/web/content/docs/dev/routing/not-found.mdx index 72147801c..721981f0b 100644 --- a/apps/web/content/docs/dev/routing/not-found.mdx +++ b/apps/web/content/docs/dev/routing/not-found.mdx @@ -4,13 +4,36 @@ description: Configure root and per-route 404 error boundaries and throw notFoun icon: SearchX --- -In TanStack Start, 404 handling is configured as a route option via `notFoundComponent` rather than a static file. VitNode provides localized 404 layouts with history-aware navigation buttons out of the box. +In TanStack Start, 404 handling is configured as a route option via `notFoundComponent` rather than a static file. VitNode provides localized 404 layouts with history-aware navigation buttons out of the box — and mounts the one your visitors actually hit for you. -{/* Image prompt: VitNode 404 error page showing large 404 typography, localized "Page Not Found" title, descriptive message, and "Go back" / "Back to home" action buttons. Dark theme, 1440x900. */} +{/* Image prompt: VitNode 404 error page showing the site header above large 404 typography, localized "Page Not Found" title, descriptive message, and "Go back" / "Back to home" action buttons. Dark theme, 1440x900. */} + +## Mistyped URLs get the full site + +A URL no route claims is answered by a catch-all that `withCoreMainRoutes` mounts **inside your main shell**. So a visitor who mistypes a link gets the site header, the same `<main>` landmark every page renders in, and a way back — plus a real `404` status for the crawlers, because the route answers `notFound()` from `beforeLoad` rather than quietly rendering a `200`. + +You do not wire this up. It arrives with the mount your `router.tsx` already has: + +```tsx title="apps/web/src/router.tsx" +const routeTree = withCoreMainRoutes(fileRouteTree, { + localeRouting, + mountUnder: mainShellRoute, + pageHead, +}) +``` + +<Callout type="info"> + Why a route and not a `notFoundComponent` on `_main`? Because a pathless shell + is only in the match branch when something below it matched. When *nothing* + matches, the router hands back the root route on its own — so a boundary on + the shell would never run, and the 404 would be the one screen on your site + without a header. +</Callout> ## Root 404 Handler -Your `apps/web/src/routes/__root.tsx` defines the fallback boundary for all unmatched URLs: +Your `apps/web/src/routes/__root.tsx` still defines a boundary, and it is the +last resort: a `notFound()` thrown where no closer route declared one. ```tsx title="apps/web/src/routes/__root.tsx" import { createRootRouteWithContext } from '@tanstack/react-router' diff --git a/apps/web/content/docs/dev/setup.mdx b/apps/web/content/docs/dev/setup.mdx index 21216b992..c774c7a2f 100644 --- a/apps/web/content/docs/dev/setup.mdx +++ b/apps/web/content/docs/dev/setup.mdx @@ -65,29 +65,7 @@ npm run docker:dev </Tabs> </Step> - <Step> - -### Migrate and create your administrator - -<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Prepare the database"> - -```bash tab="bun" -bun run db:migrate -``` - -```bash tab="pnpm" -pnpm db:migrate -``` - -```bash tab="npm" -npm run db:migrate -``` - -</Tabs> - -The migration creates core tables and asks for the first admin account. - - </Step> + <Step> ### Start VitNode @@ -110,17 +88,6 @@ npm run dev Open `http://localhost:3000`. - </Step> - <Step> - -### Check AdminCP, then build a plugin - -Sign in at `http://localhost:3000/admin`. When the shell works, resist the -temptation to drop feature files into the host: [create a plugin](/docs/dev/plugins/create) -and let it own the feature. - -{/* Image prompt: A polished VitNode AdminCP sign-in screen and dashboard overview, dark theme, with a clearly visible sidebar and a subtle callout pointing to the plugin-oriented AdminCP area. 1440x900. */} - </Step> </Steps> diff --git a/apps/web/src/docs/not-found-content.tsx b/apps/web/src/docs/not-found-content.tsx deleted file mode 100644 index 8b10412db..000000000 --- a/apps/web/src/docs/not-found-content.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Link } from '@tanstack/react-router' -import { buttonVariants } from 'fumadocs-ui/components/ui/button' -import { DocsPage } from 'fumadocs-ui/page' - -/** - * `/docs/does-not-exist`, and every other document that is not there. - * - * Rendered *inside* the docs shell - the sidebar, the tabs and the search are - * still on screen - because a reader who mistyped a URL or followed a stale - * bookmark is one click from the page they meant, and a bare error screen throws - * that away. - * - * ## It is a 404, and not a redirect - * - * The Next.js application sent `/docs` itself to `/docs/dev` with an HTTP - * redirect, and that one is preserved (`src/routes/_docs/docs.index.tsx`) - * because `/docs` is a real URL with no landing document behind it. A *missing* - * document is a different thing: bouncing it to the first section would answer a - * question nobody asked and would tell a crawler the page exists. So this route - * answers not-found, which is what `throw notFound()` in `getDocsPage` asks for. - * - * The router's `<Link>` directly rather than an injected component: this file is - * a route's own screen rather than a shared view, both destinations are routes in - * this tree, and the rewrite writes the locale prefix into each - so from - * `/pl/docs/nope` the buttons lead to `/pl/docs/dev` and `/pl`. - */ -export const DocsNotFoundContent = () => ( - <DocsPage toc={[]}> - <div className="flex flex-col items-start gap-4 py-8"> - <p className="text-fd-muted-foreground text-sm font-medium">404</p> - - <h1 className="text-foreground text-3xl font-bold text-balance sm:text-4xl"> - This page does not exist - </h1> - - <p className="text-muted-foreground max-w-prose leading-relaxed text-pretty"> - The document you are looking for may have been renamed or moved. The - sidebar has everything that is here. - </p> - - <div className="flex flex-wrap gap-2"> - <Link - className={buttonVariants({ color: 'primary' })} - params={{ _splat: 'dev' }} - to="/docs/$" - > - Browse the documentation - </Link> - - <Link className={buttonVariants({ color: 'secondary' })} to="/"> - Back to VitNode - </Link> - </div> - </div> - </DocsPage> -) diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index cb7b7fc8a..6d012804c 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -33,7 +33,7 @@ const routeTree = withCoreRootRoutes( mountUnder: { admin: adminShellRoute, main: mainShellRoute }, pageHead, }), - { mountUnder: mainShellRoute, pageHead }, + { localeRouting, mountUnder: mainShellRoute, pageHead }, ), { loadContentRegistry, mountUnder: adminShellRoute, pageHead }, ), diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index f081553ae..c9b0f17b7 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -30,27 +30,11 @@ import appCss from '../styles.css?url' const { debug, i18n, metadata, theme } = vitNodeConfig -/** - * What the router itself provides, before any route has run. - * - * The QueryClient, and nothing else. `beforeLoad` below adds `locale` on top, so - * what a loader actually receives is `{ queryClient, locale }` - the language - * included, because a loader that fetches anything user-facing needs to know - * which one it is fetching. - */ export interface RootRouterContext { queryClient: QueryClient } export const Route = createRootRouteWithContext<RootRouterContext>()({ - /** - * The request's language, resolved once and handed to every loader below. - * - * The same function the rewrite and the components use, so there is one answer - * per request rather than one per consumer. Note that a language switch does - * not change the *internal* URL - only the public one - so the switcher - * invalidates the router to bring this back in step. - */ beforeLoad: ({ location }) => ({ locale: resolveLocale<Locale>(publicPathnameOf(location)), }), @@ -58,17 +42,7 @@ export const Route = createRootRouteWithContext<RootRouterContext>()({ head: () => ({ links: [ { href: appCss, rel: 'stylesheet' }, - /* - * The tab icon, from `public/favicon.ico`. - * - * Stated rather than left to the browser's automatic `/favicon.ico` - * request, because that request is a 404 in this application until the - * file exists and a silently missing icon is exactly the class of thing - * the missing header logo turned out to be. It is the same 32px `.ico` - * `apps/docs` serves - one VitNode, one mark - and it lives in `public/` - * rather than being imported so that the URL is stable and the file is - * still reachable at the well-known path browsers ask for unprompted. - */ + { href: '/favicon.ico', rel: 'icon', @@ -79,80 +53,22 @@ export const Route = createRootRouteWithContext<RootRouterContext>()({ meta: [ { charSet: 'utf-8' }, { content: 'width=device-width, initial-scale=1', name: 'viewport' }, - // The default title, from the app's config. A route that names itself - // renders `"<page> - <shortTitle>"` instead, through `formatPageTitle` - - // the same rule Next.js applies through `title.template`. { title: metadata.title }, ], }), - /** - * Warm this language's shell strings before anything renders. - * - * `context.locale` rather than a default: `/pl` has to arrive with Polish - * already in the cache, or the first paint is English and the page flips after - * hydration. - */ loader: async ({ context }) => { await context.queryClient.ensureQueryData( intlQueryOptions({ locale: context.locale }), ) }, - /** - * Every URL this application does not serve. - * - * The last resort, and until now there was none: a path that matched no route - * at all fell through to TanStack Router's own `<p>Not Found</p>` - no shell, - * no strings, no way back - and the router warned about the missing option on - * every such navigation. `/admin/contents` and a hand-typed `/blog/post-30` - * both landed there. - * - * ## What reaches it, and what does not - * - * Only a path **no route matched**. A route that matched and then answered - * `notFound()` from its own loader is caught by the nearest - * `notFoundComponent` above it, which is why `/admin/content/nope` and - * `/admin/content/blog/articles/999999/edit` render the AdminCP's 404 inside - * the panel rather than this - see `_admin`'s, which mounts the shell around - * the same message. - * - * ## It is a 404, and not a redirect anywhere - * - * The route tree is the whole application, so a URL that reaches here is one - * somebody typed or a stale bookmark, and saying so is the honest answer. - * Bouncing an unmatched path at some other origin would hide a genuinely - * missing page behind a hop to a server that 404s it anyway. Where a URL is - * served from is a deployment question, and a proxy in front of the app - * answers it better than this route can. - */ notFoundComponent: RootNotFound, shellComponent: RootDocument, }) -/** - * Rendered inside `RootComponent`, which is what makes the strings work: the - * providers it mounts include `RouteMessages` with `core.global`, and that is - * the namespace `NotFound` reads its two lines from. The root's loader has - * already warmed that entry, so nothing suspends. - */ function RootNotFound() { return <NotFound actions={<ErrorActions />} /> } -/** - * The VitNode provider tree, mounted once above every route. - * - * Every provider in it is shared with the Next.js app - the theme, the toaster, - * the tooltip provider, the WebSocket - plus the pair of `use-intl` records that - * only a package can name. `VitNodeRootProviders` owns all of it, including the - * argument for why the realtime listeners are inside it rather than in the main - * shell. - * - * What this route contributes is the two things an application owns: which - * languages it serves, and its theme defaults. - * - * Because the locale comes from router state, changing language re-renders this: - * new locale, new query key, new messages, no page reload. - */ function RootComponent() { return ( <VitNodeRootProviders config={{ debug, locales: i18n.locales, theme }}> @@ -161,20 +77,6 @@ function RootComponent() { ) } -/** - * The document itself. - * - * `lang` is the language this request actually resolved to - `en` for `/`, `pl` - * for `/pl`, and on a route outside the localized URL space (`/admin`) whatever - * the visitor's cookie says. It comes from the same router state the provider - * reads, so the two cannot disagree and hydration has nothing to complain about. - * - * `ThemeScript` has to be in the head, and it has to be inline: it applies the - * stored theme to `<html>` before the browser paints, so the first frame is the - * theme the visitor chose rather than a flash of the default one. - * `suppressHydrationWarning` covers the attributes it writes, which by design - * differ from what the server rendered. - */ function RootDocument({ children }: { children: React.ReactNode }) { const locale = useLocale() diff --git a/apps/web/src/routes/_admin/admin.core.index.tsx b/apps/web/src/routes/_admin/admin.core.index.tsx index 3c68eda44..d45ea6bfa 100644 --- a/apps/web/src/routes/_admin/admin.core.index.tsx +++ b/apps/web/src/routes/_admin/admin.core.index.tsx @@ -1,5 +1,5 @@ import { createFileRoute } from '@tanstack/react-router' -import { AdminBreadcrumb } from '@vitnode/core/tanstack/admin' +import { adminBreadcrumb } from '@vitnode/core/tanstack/admin' import { AdminDashboardRouteContent, loadAdminDashboardRoute, @@ -62,7 +62,7 @@ export const Route = createFileRoute('/_admin/admin/core/')({ * administrator is using without this file naming a string. */ staticData: { - breadcrumb: <AdminBreadcrumb segments={['core']} />, + breadcrumb: adminBreadcrumb({ segments: ['core'] }), }, }) diff --git a/apps/web/src/routes/_docs/docs.$.tsx b/apps/web/src/routes/_docs/docs.$.tsx index 10e15d801..5bee30995 100644 --- a/apps/web/src/routes/_docs/docs.$.tsx +++ b/apps/web/src/routes/_docs/docs.$.tsx @@ -1,83 +1,10 @@ import { createFileRoute, redirect } from '@tanstack/react-router' import { movedDocsSlug } from '#/docs/moved-pages' -import { DocsNotFoundContent } from '#/docs/not-found-content' import { DocsPageContent } from '#/docs/page-content' import { getDocsPage } from '#/docs/transport' import { pageHead } from '#/lib/page-head' -/** - * Every document: `/docs/dev`, `/docs/dev/plugins/create`, `/docs/ui/button`. - * - * One splat route, and deliberately the narrowest one that works. A `/$` at the - * root would have swallowed `/discover`, `/settings`, every plugin route and - * every URL the Next.js application still serves; `/docs/$` claims exactly the - * documentation and nothing else. `src/tests/docs-route.test.ts` pins that. - * - * There is **no `$lang` in this tree, and there is not going to be**. Fumadocs' - * own TanStack examples route documentation as `/$lang/docs/$` because they have - * no other locale mechanism; VitNode has had one since Stage 3. `/pl/docs/dev` - * arrives, the router's `rewrite.input` strips the prefix, this route matches - * `/docs/dev`, and every link Fumadocs renders gets the prefix back on the way - * out. A physical `$lang` segment would be a second locale model competing with - * that one, and the two would disagree the first time a language was added. - * - * ## The loader does the whole lookup, and `head` does none of it - * - * `getDocsPage` is a server function because Fumadocs' source loader is a - * build-time index of every document and may not reach the browser - see - * `src/docs/transport.ts`. It answers with the page's identity and metadata, and - * that one object feeds the `<title>`, the `<h1>` and the body. The Next.js page - * looked the page up twice, once in `generateMetadata` and once in the - * component; here `head` reads `loaderData` and looks nothing up. - * - * **`head` is written after `loader`**: `loaderData`'s type is inferred from - * `loader` in the same object literal, and TypeScript reads a literal's members - * in order. Put `head` first and `loaderData` is `never`. - * - * `metaTitle` rather than `title`, because they are different strings on - * purpose: the tab reads "Create a plugin - Plugins - Development" and the - * heading reads "Create a plugin". Both are composed on the server, from the - * page tree, exactly as the Next.js application composed them. - * - * ## The Open Graph is the same string without the site name - * - * `og:title` is `metaTitle` **verbatim**, while `<title>` is `metaTitle` plus - * ` - VitNode`. That is not an oversight, it is what the Next.js route emitted - - * Next applies `title.template` to the document title and leaves - * `openGraph.title` alone - and it is what a social card wants: "Routes - - * Plugins" over a link to vitnode.com, rather than the site's name twice. - * Measured against the running Next.js application rather than inferred; see - * `@vitnode/core/tanstack/metadata`. - * - * ## Why the body is preloaded here - * - * `docsClientLoader.preload` starts the `import()` of this page's compiled MDX - * chunk, and awaiting it is what makes the first paint contain the document - * rather than a suspended boundary - on the server as much as in the browser. - * The `await import()` around it is what keeps that chunk, `fumadocs-ui/page` - * and the MDX component map out of the client entry: a static import here would - * put the whole documentation runtime in front of every visitor to `/`, because - * a route's `loader` is not code-split. See `src/tests/asset-graph.test.ts`, - * which fails if any chunk the front page preloads mentions Fumadocs. - * - * ## The 404 belongs here, not on the shell - * - * `getDocsPage` throws `notFound()` for a slug the collection has no document - * for, and a not-found component replaces the render of the route it is declared - * on. Declared on `_docs` it would replace the *shell*, taking the sidebar and - * the navigation with it - and Fumadocs' `DocsPage` throws outright without the - * tree context that shell provides. Declared here it renders in the shell's - * `<Outlet />`, which is what a reader who mistyped a URL actually wants. - * - * ## A moved document redirects before the lookup - * - * `beforeLoad` runs before the loader, so a slug that used to name a document - * never reaches `getDocsPage` and never becomes a 404. See - * `src/docs/moved-pages.ts` for the map, and note the `301`: the reorganisation - * is permanent, so the old URL should hand its ranking to the new one rather - * than keep it. - */ export const Route = createFileRoute('/_docs/docs/$')({ beforeLoad: ({ params }) => { const moved = movedDocsSlug(params._splat ?? '') @@ -110,7 +37,6 @@ export const Route = createFileRoute('/_docs/docs/$')({ robots: 'index, follow', title: loaderData?.metaTitle, }), - notFoundComponent: DocsNotFoundContent, component: DocsRoute, }) diff --git a/apps/web/src/routes/_main.tsx b/apps/web/src/routes/_main.tsx index 2524726de..ed9f6a006 100644 --- a/apps/web/src/routes/_main.tsx +++ b/apps/web/src/routes/_main.tsx @@ -18,30 +18,37 @@ import { MainHeader } from '#/components/main-header' * on the public site, so it wants the shell *and* the guard rather than a second * copy of the shell. * - * ## What is deliberately outside it + * ## What is inside it, and what is deliberately outside * - * The four auth screens: `/login`, `/login/sso/$providerId`, `/register` and - * `/login/reset-password`. An auth screen is a full-height card on an otherwise - * empty document, and the header it would render is a header whose only - * interesting control is "sign in". Keeping them out is what makes this a shell - * that routes opt into rather than one every route is subject to. - * `routes/api/$` is outside for a different reason: it is a server route and - * renders no document at all. Note this is a visual difference from the Next.js - * app, where all four sit inside `(main)` and do render the header. + * Everything a visitor can reach without the AdminCP, the four public auth + * screens included: `/login`, `/register`, `/login/reset-password` and + * `/login/sso/$providerId` are children of this shell, mounted by + * `withCoreMainRoutes`. An auth card is a page on the public site - its own + * layout already reserves the space the header takes, and the header is the way + * back to the front page from a form the visitor changed their mind about. * - * `src/tests/main-shell.test.ts` asserts both halves - the settings paths - * inside, the four auth screens outside. + * So is the 404. A URL no route matched is answered by core's `/$` inside this + * container rather than by `__root`'s `notFoundComponent`, because router core + * hands back the root route alone when nothing matches - a pathless layout the + * URL never reached is not a candidate for the boundary, whatever `notFoundMode` + * says, so a screen mounted here could never have seen one. + * + * `/admin` - the AdminCP's own sign-in - is outside, and must be: it reads a + * different session under a different cookie, so offering the site header's + * "sign in" beside it would be one page asking for two unrelated logins. + * `routes/api/$` is outside for a different reason again: it is a server route + * and renders no document at all. * * ## The slots * * `ThemeLayoutContent`'s, and two of the same three the Next.js `ThemeLayout` * fills: `header` and `breadcrumb`. * - * `listeners` is deliberately left empty here. The Next.js app puts the - * notification toasts and the WebSocket's sign-in resync in it because its - * `/login` is inside the main shell; this app's is not, so a sync mounted here - * would not exist during the sign-in it has to notice. They are mounted by - * `__root` instead, next to the connection whose lifetime they share. + * `listeners` is deliberately left empty here. The notification toasts and the + * WebSocket's sign-in resync are mounted by `__root` instead, next to the + * connection whose lifetime they share - so a sign-out that lands the visitor + * outside this shell is still noticed, which a listener scoped to the shell + * could not manage. * * ## What it is not * diff --git a/apps/web/src/server/messages.server.ts b/apps/web/src/server/messages.server.ts index a398e1312..2954c20d3 100644 --- a/apps/web/src/server/messages.server.ts +++ b/apps/web/src/server/messages.server.ts @@ -5,17 +5,4 @@ import { vitNodeServerConfig } from '#/vitnode.server.config' export type { IntlMessages } from '@vitnode/core/tanstack/i18n/server' -/** - * The messages one page needs, in one language - this app's loader. - * - * Everything about *how* messages load is core's: the source order (core, then - * each plugin, then this app's overrides, later ones winning), the `web` scope - * stamped on each so the API's tree in this same process cannot be served in its - * place, the per-key fallback to the default locale, and the namespace pick that - * keeps every plugin's AdminCP copy out of a page that renders none of it. - * - * What is this app's is the server config it is handed - the plugins it - * registered, the languages it declares, and the loaders that stand in for each - * package's own locale barrel. - */ export const loadIntlMessages = createIntlMessagesLoader(vitNodeServerConfig) diff --git a/apps/web/src/start.ts b/apps/web/src/start.ts index 8e2049a90..13d7a8dfe 100644 --- a/apps/web/src/start.ts +++ b/apps/web/src/start.ts @@ -2,12 +2,4 @@ import { createVitNodeStart } from '@vitnode/core/tanstack/start' import { vitNodeConfig } from '#/vitnode.config' -/** - * This app's Start instance. - * - * The whole request pipeline is VitNode's: CSRF on server functions, canonical - * locale redirects with the remembered-locale cookie, and the `private, - * no-store` directive every document carrying a dehydrated session needs. Pass - * `requestMiddleware` to add this app's own, which runs after all of it. - */ export const startInstance = createVitNodeStart({ config: vitNodeConfig }) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 1e8f61044..70e2799ee 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -13,9 +13,6 @@ const config = defineConfig({ tsconfigPaths: true, }, server: { strictPort: true }, - ssr: { - external: ['@vitnode/core', '@vitnode/blog', '@vitnode/example', 'tslib'], - }, plugins: [ vitnode({ appRoot: import.meta.dirname }), fumadocsMdx(), diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/monorepo/apps/web/.env.example b/packages/create-vitnode-app/copy-of-vitnode-app/monorepo/apps/web/.env.example index 7474481ea..600de8dfe 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/monorepo/apps/web/.env.example +++ b/packages/create-vitnode-app/copy-of-vitnode-app/monorepo/apps/web/.env.example @@ -1,12 +1 @@ NEXT_PUBLIC_API_URL=http://localhost:8000 - -# Optional. Set these to back the Next.js caches with Redis, so cached pages and -# `use cache` entries are shared between instances and a tag revalidation on one -# instance is seen by all of them. -# -# The web process needs its own copy: the cache handlers are loaded by Next.js -# itself and read the environment directly - they cannot see the API's -# `vitnode.api.config.ts`. Point them at the same Redis the API uses. -# https://vitnode.com/docs/dev/advanced/redis#caching-nextjs-output -# REDIS_URL=redis://localhost:6379 -# REDIS_PASSWORD=root diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/.env.example b/packages/create-vitnode-app/copy-of-vitnode-app/root/.env.example index 7d39991f6..94b0d1e66 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/.env.example +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/.env.example @@ -1,14 +1,7 @@ POSTGRES_URL=postgresql://root:root@localhost:5432/vitnode REDIS_URL=redis://localhost:6379 -# Both name this app's own origin, and it has to be the port `dev` actually -# serves. Neither is strictly required: on the server the API origin is taken -# off the request being rendered, and in the browser it falls back to the origin -# the page was served from - so a preview deployment on a generated hostname -# needs no config at all. Set `NEXT_PUBLIC_API_URL` only to point at a separate -# API server; `NEXT_PUBLIC_WEB_URL` is what the API stamps cookies with. NEXT_PUBLIC_WEB_URL=http://localhost:3000 -NEXT_PUBLIC_API_URL=http://localhost:3000 # === CRON Secret for Internal API Calls === CRON_SECRET=your-secure-cron-secret-key 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 6b30e32a9..f8384cff4 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 @@ -14,28 +14,8 @@ import { withCoreRootRoutes, } from "@vitnode/core/tanstack/routes"; -/** - * The auth transport, registered by importing the module that declares it. - * - * `@vitnode/core/tanstack/auth` owns every auth decision this app makes but may - * not declare a `createServerFn` - uncompiled on the server, one silently - * resolves to `undefined` - so `lib/auth.ts` declares the eight wrappers and - * hands them over at module scope. A bare import because there is nothing to - * name: the registration *is* the module's effect. - * - * Here because a router is the one module both entry points load, so the - * registration has happened before any route, loader or component can reach for - * it, in the browser bundle and on the server alike. - */ import "./lib/auth"; -/** - * The admin transport, registered the same way and for the same reason. - * - * One server function rather than eight, reading the AdminCP's own session under - * its own cookie. It is a separate registration from the auth one on purpose: - * they are two sessions, two cookies and two cache entries, and nothing in - * VitNode may let the public session answer an admin question. - */ + import "./lib/admin-auth"; import { createLocaleRewrite, localeRouting } from "./lib/i18n/runtime"; import { pageHead } from "./lib/page-head"; @@ -44,160 +24,26 @@ import { Route as adminShellRoute } from "./routes/_admin"; import { Route as mainShellRoute } from "./routes/_main"; import { routeTree as fileRouteTree } from "./routeTree.gen"; -/** - * The Content Engine registry, behind a literal dynamic import. - * - * Awaited by the one loader that needs it - `/admin/content/*` - rather than - * imported here. Building the registry reaches `@vitnode/core/content` and - * every configured plugin's admin form components, and this module is the one - * the client entry evaluates on every page: as a static import it put `zod`, - * every plugin's content registrations, the content form primitives and - * `react-hook-form` in front of the front page's first paint. See - * `CoreAdminRouteContext.loadContentRegistry`. - */ const loadContentRegistry = async () => (await import("./lib/content-registry")).contentRegistry; -/** - * One route tree: this app's route files, plus the AdminCP screens `@vitnode/core` - * owns, plus the pages its plugins declare. - * - * At module scope rather than inside `getRouter`, because `getRouter` runs once - * per server request and mounting the plugin routes mutates the route tree - the - * generated tree is a module singleton. `withPluginRoutes` is idempotent anyway; - * doing it once is simply where it belongs. - * - * The plugin half comes from one generated file: a static import of each - * configured plugin's own route tree. No plugin page is copied into - * `src/routes`, no route path is written by hand, and nothing here knows which - * plugins are installed - see `@vitnode/core/tanstack/plugin-routes`. - * - * A page is reached only through the literal `lazy(() => import(...))` its route - * declared, so every one of them is a chunk of its own. The one part of a plugin - * route that is not lazy is a `search` schema: a router's `validateSearch` runs - * during path matching, before any chunk is fetched, so it lives in the tree - * rather than in the page. - * - * `mountUnder` names one route per shell, which is the whole of what "a plugin - * route renders in the application shell" amounts to here. A plugin declares - * `area: "main"` or `area: "admin"`; `_main` is the route that renders the - * public shell and `_admin` the one that renders the AdminCP, and being a child - * of one of them is what gives `/example` the header and the one `<main>` that - * `/discover` has, or gives `/admin/reports` the sidebar, the breadcrumb area - * and the admin session guard that `/admin/core` has. No new field, no per-route - * layout metadata and no second copy of either shell - route composition, which - * the area declaration already described. - * - * Neither shell changes a path: both are pathless, so `/example` stays - * `/example` and an admin plugin route's `/admin/…` is the path its own route - * spells out in full. An area VitNode knows and this app has not named here - * fails the composition rather than being mounted under the other one. - * - * `_main` and `_admin` are imported for their route objects, and they are the - * same objects the generated tree holds: `createFileRoute` produces one instance - * per module and `routeTree.gen.ts` mutates it in place. - * - * `withCoreMainRoutes`, `withCoreAdminRoutes` and `withCoreRootRoutes` mount - * core's own screens the same way, one per mount point: the public pages under - * the main shell, the AdminCP's under its own, and the shell-less ones - the auth - * cards and the AdminCP sign-in - straight under the root. - * - * They were twenty-nine route files in this application until - * `@vitnode/core/tanstack/routes` existed, every one of them pure wiring around - * something imported from the package - so an app carried a copy of VitNode's own - * routing table and core adding a screen meant an edit here. They are code-based - * rather than declared as plugin routes because they need the router's full - * option set: a real `validateSearch` that clamps `?page=999` before anything - * renders, a `beforeLoad` guard that runs before any chunk is fetched, and a - * splat path a plugin route path does not represent. - * - * `localeRouting` goes to the last of the three because a sign-in navigates to a - * path a *visitor* supplied: the route tree carries no locale, so the prefix has - * to be stripped before the router sees it, and which prefixes exist is this - * app's answer. It is the same object the `rewrite` below uses. - * - * `pageHead` is this app's own `createRouteHead(metadata)` binding, handed over - * because a package cannot know the site's name: a plugin page's `<title>` goes - * through the same `"<page> - <site>"` rule every other VitNode page's does, - * rather than through a second one the plugin invented. - */ const routeTree = withCoreRootRoutes( withCoreAdminRoutes( withCoreMainRoutes( - withPluginRoutes( - fileRouteTree, - pluginRouteSpecs(pluginRouteSources), - { - mountUnder: { admin: adminShellRoute, main: mainShellRoute }, - pageHead, - }, - ), - { mountUnder: mainShellRoute, pageHead }, + withPluginRoutes(fileRouteTree, pluginRouteSpecs(pluginRouteSources), { + mountUnder: { admin: adminShellRoute, main: mainShellRoute }, + pageHead, + }), + { localeRouting, mountUnder: mainShellRoute, pageHead }, ), { loadContentRegistry, mountUnder: adminShellRoute, pageHead }, ), { localeRouting, mountUnder: fileRouteTree, pageHead }, ); -/** - * The app's router, and the QueryClient it owns. - * - * Start calls this once per server request and once in the browser, which is - * exactly the lifetime a QueryClient should have: created here, it is per - * request on the server - never a module-level client shared by every visitor - * being rendered at once - and a single long-lived one on the client. - * - * It goes into the router context, so a route loader reaches it as - * `context.queryClient` and can `ensureQueryData` before its component renders. - * That is the whole point of putting it here rather than in a provider: a - * loader runs before React does, so a client mounted by a component would be - * out of reach of the code that most wants it. - * - * `setupRouterSsrQueryIntegration` wires the two together: it dehydrates the - * cache into the SSR stream (including queries that resolve mid-render), - * hydrates it on the client before the first render, routes `redirect()` thrown - * inside a query or mutation through the router, and wraps the app in the one - * `QueryClientProvider` for this client. Nothing else in this app may create a - * `QueryClient` or a provider for one - two clients in a page means a query a - * loader cached is invisible to the component that reads it. - * - * `defaultPreloadStaleTime: 0` leaves caching to Query rather than having the - * router keep a second copy of the same data with its own expiry. - * - * `defaultStaleReloadMode: 'blocking'` is what makes a route's pending shape - * reachable at all once preloading is on. Router core's default is - * `'background'`, and a background reload never opens a pending window - but it - * still waits for the route's component chunk before it commits. So the common - * desktop path, hover a link and click it, took the one branch that renders - * nothing: the hover filled the loader, the click was therefore a background - * reload, and the chunk downloaded with the previous page still on screen and - * no skeleton in sight. - * - * It costs nothing here because a VitNode loader does not block on a warm cache: - * `ensureQueryData` with `revalidateIfStale` hands back the cached entry and - * refreshes behind it, so "blocking" describes a promise that resolves in a - * microtask. What changes is only that the router now marks the match pending - * while that happens, which is what `defaultPendingMs` is for. - * - * `defaultPendingMs: 150` is that threshold, and it is not zero for the same - * reason. At zero, every navigation opens a pending window, so - * `defaultPendingMinMs` holds a *fully cached* navigation behind a skeleton for - * 300ms - a page that could have been instant, made slow to look busy. At 150ms - * a cached navigation goes straight through with nothing shown and a slow one - * still gets its shape, in the content area and the breadcrumb together. - * - * `rewrite` is what makes one route tree serve two public URL shapes: `/pl/...` - * arrives, `/...` is matched, and every link the router builds gets the prefix - * back. No route file mentions a locale, so nothing here has to be duplicated - * per language - see `@vitnode/core/tanstack/i18n`. - */ export function getRouter() { const queryClient = createVitNodeQueryClient(); - // The rewrite reads the locale off the router's own current location, and the - // router needs the rewrite to parse that location - so it is handed a getter - // rather than the router itself. `output` only ever runs once a link is built, - // which is long after the assignment below. const holder: { current?: AnyRouter } = {}; const router = createTanStackRouter({ diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/__root.tsx b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/__root.tsx index 0814fdba5..89583eec2 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/__root.tsx +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/__root.tsx @@ -30,27 +30,11 @@ import appCss from "../styles.css?url"; const { debug, i18n, metadata, theme } = vitNodeConfig; -/** - * What the router itself provides, before any route has run. - * - * The QueryClient, and nothing else. `beforeLoad` below adds `locale` on top, so - * what a loader actually receives is `{ queryClient, locale }` - the language - * included, because a loader that fetches anything user-facing needs to know - * which one it is fetching. - */ export interface RootRouterContext { queryClient: QueryClient; } export const Route = createRootRouteWithContext<RootRouterContext>()({ - /** - * The request's language, resolved once and handed to every loader below. - * - * The same function the rewrite and the components use, so there is one answer - * per request rather than one per consumer. Note that a language switch does - * not change the *internal* URL - only the public one - so the switcher - * invalidates the router to bring this back in step. - */ beforeLoad: ({ location }) => ({ locale: resolveLocale<Locale>(publicPathnameOf(location)), }), @@ -58,16 +42,7 @@ export const Route = createRootRouteWithContext<RootRouterContext>()({ head: () => ({ links: [ { href: appCss, rel: "stylesheet" }, - /* - * The tab icon, from `public/favicon.ico`. - * - * Stated rather than left to the browser's automatic `/favicon.ico` - * request, because that request is a 404 until the file exists and a - * silently missing icon is easy to never notice. Drop your own 32px `.ico` - * at `public/favicon.ico`; it lives there rather than being imported so - * that the URL is stable and the file is still reachable at the well-known - * path browsers ask for unprompted. - */ + { href: "/favicon.ico", rel: "icon", @@ -78,80 +53,22 @@ export const Route = createRootRouteWithContext<RootRouterContext>()({ meta: [ { charSet: "utf-8" }, { content: "width=device-width, initial-scale=1", name: "viewport" }, - // The default title, from the app's config. A route that names itself - // renders `"<page> - <shortTitle>"` instead, through `formatPageTitle` - - // the same rule Next.js applies through `title.template`. { title: metadata.title }, ], }), - /** - * Warm this language's shell strings before anything renders. - * - * `context.locale` rather than a default: `/pl` has to arrive with Polish - * already in the cache, or the first paint is English and the page flips after - * hydration. - */ loader: async ({ context }) => { await context.queryClient.ensureQueryData( intlQueryOptions({ locale: context.locale }), ); }, - /** - * Every URL this application does not serve. - * - * The last resort, and until now there was none: a path that matched no route - * at all fell through to TanStack Router's own `<p>Not Found</p>` - no shell, - * no strings, no way back - and the router warned about the missing option on - * every such navigation. `/admin/contents` and a hand-typed `/blog/post-30` - * both landed there. - * - * ## What reaches it, and what does not - * - * Only a path **no route matched**. A route that matched and then answered - * `notFound()` from its own loader is caught by the nearest - * `notFoundComponent` above it, which is why `/admin/content/nope` and - * `/admin/content/blog/articles/999999/edit` render the AdminCP's 404 inside - * the panel rather than this - see `_admin`'s, which mounts the shell around - * the same message. - * - * ## It is a 404, and not a redirect anywhere - * - * The route tree is the whole application, so a URL that reaches here is one - * somebody typed or a stale bookmark, and saying so is the honest answer. - * Bouncing an unmatched path at some other origin would hide a genuinely - * missing page behind a hop to a server that 404s it anyway. Where a URL is - * served from is a deployment question, and a proxy in front of the app - * answers it better than this route can. - */ notFoundComponent: RootNotFound, shellComponent: RootDocument, }); -/** - * Rendered inside `RootComponent`, which is what makes the strings work: the - * providers it mounts include `RouteMessages` with `core.global`, and that is - * the namespace `NotFound` reads its two lines from. The root's loader has - * already warmed that entry, so nothing suspends. - */ function RootNotFound() { return <NotFound actions={<ErrorActions />} />; } -/** - * The VitNode provider tree, mounted once above every route. - * - * Every provider in it is shared with the Next.js app - the theme, the toaster, - * the tooltip provider, the WebSocket - plus the pair of `use-intl` records that - * only a package can name. `VitNodeRootProviders` owns all of it, including the - * argument for why the realtime listeners are inside it rather than in the main - * shell. - * - * What this route contributes is the two things an application owns: which - * languages it serves, and its theme defaults. - * - * Because the locale comes from router state, changing language re-renders this: - * new locale, new query key, new messages, no page reload. - */ function RootComponent() { return ( <VitNodeRootProviders config={{ debug, locales: i18n.locales, theme }}> @@ -160,20 +77,6 @@ function RootComponent() { ); } -/** - * The document itself. - * - * `lang` is the language this request actually resolved to - `en` for `/`, `pl` - * for `/pl`, and on a route outside the localized URL space (`/admin`) whatever - * the visitor's cookie says. It comes from the same router state the provider - * reads, so the two cannot disagree and hydration has nothing to complain about. - * - * `ThemeScript` has to be in the head, and it has to be inline: it applies the - * stored theme to `<html>` before the browser paints, so the first frame is the - * theme the visitor chose rather than a flash of the default one. - * `suppressHydrationWarning` covers the attributes it writes, which by design - * differ from what the server rendered. - */ function RootDocument({ children }: { children: React.ReactNode }) { const locale = useLocale(); diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_main.tsx b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_main.tsx index 78e28de45..cd7166ecd 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_main.tsx +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_main.tsx @@ -18,30 +18,37 @@ import { MainHeader } from "#/components/main-header"; * on the public site, so it wants the shell *and* the guard rather than a second * copy of the shell. * - * ## What is deliberately outside it + * ## What is inside it, and what is deliberately outside * - * The four auth screens: `/login`, `/login/sso/$providerId`, `/register` and - * `/login/reset-password`. An auth screen is a full-height card on an otherwise - * empty document, and the header it would render is a header whose only - * interesting control is "sign in". Keeping them out is what makes this a shell - * that routes opt into rather than one every route is subject to. - * `routes/api/$` is outside for a different reason: it is a server route and - * renders no document at all. Note this is a visual difference from the Next.js - * app, where all four sit inside `(main)` and do render the header. + * Everything a visitor can reach without the AdminCP, the four public auth + * screens included: `/login`, `/register`, `/login/reset-password` and + * `/login/sso/$providerId` are children of this shell, mounted by + * `withCoreMainRoutes`. An auth card is a page on the public site - its own + * layout already reserves the space the header takes, and the header is the way + * back to the front page from a form the visitor changed their mind about. * - * `src/tests/main-shell.test.ts` asserts both halves - the settings paths - * inside, the four auth screens outside. + * So is the 404. A URL no route matched is answered by core's `/$` inside this + * container rather than by `__root`'s `notFoundComponent`, because router core + * hands back the root route alone when nothing matches - a pathless layout the + * URL never reached is not a candidate for the boundary, whatever `notFoundMode` + * says, so a screen mounted here could never have seen one. + * + * `/admin` - the AdminCP's own sign-in - is outside, and must be: it reads a + * different session under a different cookie, so offering the site header's + * "sign in" beside it would be one page asking for two unrelated logins. + * `routes/api/$` is outside for a different reason again: it is a server route + * and renders no document at all. * * ## The slots * * `ThemeLayoutContent`'s, and two of the same three the Next.js `ThemeLayout` * fills: `header` and `breadcrumb`. * - * `listeners` is deliberately left empty here. The Next.js app puts the - * notification toasts and the WebSocket's sign-in resync in it because its - * `/login` is inside the main shell; this app's is not, so a sync mounted here - * would not exist during the sign-in it has to notice. They are mounted by - * `__root` instead, next to the connection whose lifetime they share. + * `listeners` is deliberately left empty here. The notification toasts and the + * WebSocket's sign-in resync are mounted by `__root` instead, next to the + * connection whose lifetime they share - so a sign-out that lands the visitor + * outside this shell is still noticed, which a listener scoped to the shell + * could not manage. * * ## What it is not * diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_main/index.tsx b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_main/index.tsx index 30a43a323..976267025 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_main/index.tsx +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_main/index.tsx @@ -2,24 +2,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { pageHead } from "#/lib/page-head"; -/** - * The front page. - * - * One route file serving every public URL of the home page: `/` and `/pl` both - * match here, because the locale is stripped before matching and written back - * into every link the router builds. There is no `routes/pl/index.tsx` and there - * does not need to be one. - * - * **`head` must be written after `loader`** on routes that have one - - * `loaderData` is inferred from `loader` in the same object literal, and - * TypeScript reads a literal's members in order. This route has no loader: it - * fetches nothing, and the shell above it already warmed everything the header - * reads. - * - * `robots: 'index, follow'` is stated rather than assumed. `_main` says nothing - * about indexing, and a page that wants to be found should say so where somebody - * editing it will see it. - */ export const Route = createFileRoute("/_main/")({ head: () => pageHead({ @@ -30,18 +12,6 @@ export const Route = createFileRoute("/_main/")({ component: HomeRoute, }); -/** - * Replace this with your own home page. - * - * No `<main>`: the shell owns the document's one `main` landmark, and a page - * that renders a second gives a screen reader two to choose between. A page owns - * its container - width, padding, vertical rhythm - and nothing above it. - * - * The copy is hard-coded because a new app has no strings of its own yet. To - * translate it, add the keys to `src/locales/` and read them with - * `useTranslations` from `use-intl` - the same hook every VitNode component - * uses, on every host. - */ function HomeRoute() { return ( <div className="container mx-auto flex max-w-2xl flex-col gap-4 p-4"> diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/server/messages.server.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/server/messages.server.ts index a2081943f..c70235674 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/server/messages.server.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/server/messages.server.ts @@ -5,17 +5,4 @@ import { vitNodeServerConfig } from "#/vitnode.server.config"; export type { IntlMessages } from "@vitnode/core/tanstack/i18n/server"; -/** - * The messages one page needs, in one language - this app's loader. - * - * Everything about *how* messages load is core's: the source order (core, then - * each plugin, then this app's overrides, later ones winning), the `web` scope - * stamped on each so the API's tree in this same process cannot be served in its - * place, the per-key fallback to the default locale, and the namespace pick that - * keeps every plugin's AdminCP copy out of a page that renders none of it. - * - * What is this app's is the server config it is handed - the plugins it - * registered, the languages it declares, and the loaders that stand in for each - * package's own locale barrel. - */ export const loadIntlMessages = createIntlMessagesLoader(vitNodeServerConfig); diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/start.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/start.ts index f74f980d8..a7813ba0a 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/start.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/start.ts @@ -2,23 +2,4 @@ import { createVitNodeStart } from "@vitnode/core/tanstack/start"; import { vitNodeConfig } from "#/vitnode.config"; -/** - * This app's Start instance. - * - * The whole request pipeline is VitNode's, and it is mandatory rather than a - * default: CSRF protection on server functions, canonical locale redirects with - * the remembered-locale cookie, and the `private, no-store` directive every - * document carrying a dehydrated session needs. - * - * Add this app's own request middleware - a request id, a tracing span, a - * maintenance gate - with `requestMiddleware`. It runs after all of the above, - * which is the only safe place for it: a redirect ends the request, so anything - * in front of the locale rule would run twice for every visitor arriving at a - * non-canonical URL. - * - * export const startInstance = createVitNodeStart({ - * config: vitNodeConfig, - * requestMiddleware: [myMiddleware], - * }) - */ export const startInstance = createVitNodeStart({ config: vitNodeConfig }); diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.config.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.config.ts index 0f560028f..c596c7edc 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.config.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.config.ts @@ -1,66 +1,10 @@ import { buildConfig } from "@vitnode/core/vitnode.config"; -/** - * This app's configuration - the one file every VitNode app edits first. - * - * **Browser-safe, and everything here has to stay that way.** Three very - * different readers depend on it: `routes/__root.tsx` renders the document shell - * from `metadata`, `theme` and `debug`; `lib/i18n/runtime.ts` derives the locale - * routing from `i18n`; and Vite's own plugin registry loads this file with - * `jiti` while it is still resolving its config, to find out which plugins to - * generate route, navigation and content-registry imports for. So it is plain - * data and plugin *identity* - never a `() => import(...)` message loader, never - * a module that reaches a database. Anything like that goes in - * `vitnode.server.config.ts`. - * - * ## Adding a language - * - * Add an entry to `locales`. Packages ship their own translations, so a new - * locale needs nothing else: anything a package has not translated falls back to - * `defaultLocale` key by key. Register the package's file for that language in - * `src/locales/packages.ts`, and put your own rewording in - * `src/locales/app.ts` - both of which `vitnode.server.config.ts` picks up. - * - * `vitnode i18n:create de Deutsch` does all of it for you. - * - * ## Adding a plugin - * - * With the plugin's own factory: - * - * import { blogPlugin } from '@acme/blog/config' - * - * plugins: [blogPlugin()] - * - * That is the whole registration - the factory carries the plugin's content - * types, its AdminCP navigation and its translations. Register its locale files - * in `src/locales/packages.ts` as well, which is what the message loader - * actually reads. - * - * What the AdminCP renders comes back through `src/admin-nav.gen.ts` and - * `src/content-registry.gen.ts` rather than out of this object: the build writes - * one literal import per configured plugin, and `src/router.tsx` loads the - * content registry behind a dynamic `import()`, so a content type's editing - * screen arrives with the route that renders it. - * - * A plugin's *pages* need nothing in this file at all. It declares them in its - * own `src/routes.ts`, and this app's Vite build compiles them into - * `src/plugin-routes.gen.ts`, which `src/router.tsx` mounts under the shell the - * plugin's `area` names. No page is ever copied into `src/routes`. - * - * `buildConfig` also registers this object process-wide, which is how core's own - * route files find it without being handed it as a prop. - */ export const vitNodeConfig = buildConfig({ debug: false, i18n: { defaultLocale: "en", locales: [{ code: "en", name: "English" }], - /** - * Explicit, because the app renders on a server: without one, `use-intl` - * formats dates in whatever zone the server happens to run in and warns - * that the client will disagree. - */ - timeZone: "UTC", }, metadata: { shortTitle: "VitNode", diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.server.config.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.server.config.ts index ec533126f..436755686 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.server.config.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.server.config.ts @@ -5,17 +5,6 @@ import { appMessages } from "#/locales/app"; import { packageMessages } from "#/locales/packages"; import { vitNodeConfig } from "#/vitnode.config"; -/** - * The half of this app's configuration a browser may never hold. - * - * Both entries are `() => import(...)` loaders that read JSON out of a package's - * build output, which is exactly what `vitnode.config.ts` cannot carry - so they - * live here, beside the shared config rather than duplicating any of it. The - * `server-only` marker makes an accidental import from a component a build error - * instead of a browser bundle with every plugin's AdminCP copy in it. - * - * `src/server/messages.server.ts` is the only reader. - */ export const vitNodeServerConfig = buildServerConfig({ config: vitNodeConfig, messages: appMessages, diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/vite.config.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/vite.config.ts index 8644142fe..a4312ebdd 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/vite.config.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/vite.config.ts @@ -8,92 +8,8 @@ import { defineConfig } from "vite"; const config = defineConfig({ resolve: { tsconfigPaths: true }, - /** - * A second dev server has to fail rather than quietly move. - * - * `tanstackStart()` runs the route generator and *writes* - * `src/routeTree.gen.ts`. Two servers means two generators owning one file: if - * their route lists ever differ - which is precisely what happens when one was - * started before a route file existed - they overwrite each other forever, and - * every write is a full page reload. Without `strictPort` the second `pnpm dev` - * says "Port 3000 is in use, trying another one" and succeeds, so the fight - * starts silently and looks like an inexplicable refresh loop on the first - * server. - */ server: { strictPort: true }, - ssr: { - /** - * The VitNode API packages mounted at `/api/*`, kept out of the SSR pass. - * - * They are Node libraries rather than app source: `@vitnode/core` loads its - * locale files with a runtime `import("./en.json", { with: { type: "json" } })` - * relative to its own `dist`. Bundling them in this pass moves that chunk - * and the JSON stops resolving, which fails the build outright. Left - * external here, Nitro resolves them from the package itself. - * - * Every VitNode package the app installs belongs on this list - add a - * plugin's package name here when you install one. - * - * This is also what decides the shape of `@vitnode/core/tanstack/*`, so it is - * worth naming the consequence rather than leaving it to be rediscovered. - * Externalised here, the package skips this pass entirely and Nitro's own - * Rollup run inlines its `dist` afterwards - and nothing in that path runs - * the TanStack Start compiler. The *client* build has no such gap: it inlines - * the package, so the compiler transforms it there. So package code reaches - * the browser compiled and the server un-compiled, which is exactly why the - * package may declare `createIsomorphicFn` (its stub falls back to the - * `.server()` branch, which is what a server wants) and may never declare - * `createServerFn` (un-compiled, its handler resolves to `undefined` with no - * error at all). `packages/vitnode/src/tanstack/boundary.test.ts` holds the - * package to that. - * - * Removing `@vitnode/core` from this list to close the gap was measured and - * does not work: the SSR pass then reaches the locale barrel above and the - * build fails on `Could not resolve './en.json'`. - * - * ## `tslib` is here for an entirely different reason - * - * It is not a VitNode package and it is not a Node library. It is here - * because bundling it is broken, in a way that only appears in a production - * build and only once a page renders. - * - * The Radix scroll-lock stack that every modal in a VitNode page pulls in - - * `react-remove-scroll`, `use-sidecar`, `aria-hidden` - does - * `import { __extends } from "tslib"`. Under the `node` condition that - * resolves to `tslib/modules/index.js`, a two-line ESM shim around the - * **CommonJS** `tslib.js`: - * - * import tslib from "../tslib.js"; - * const { __extends, … } = tslib; - * - * `tslib.js` defines `__esModule` on its exports, so Rolldown's CJS interop - * helper does not synthesise a `default` - while the call site it generated - * still reads `.default`. Every page that renders a dialog then throws - * `Cannot destructure property '__extends' of undefined`. - * - * Externalised, the file is never bundled: Nitro traces the package into the - * output and Node loads it with Node's own interop, which is correct. An - * alias to `tslib/tslib.es6.mjs` was tried first and is worse - it fixes - * Vite's copy and not Nitro's, because Nitro re-resolves externals itself, - * and the tracer then copies only the file the alias named. - */ - external: ["@vitnode/core", "tslib"], - }, plugins: [ - /** - * Environment handling, the dev server's dependency pre-bundling and the - * plugin route registry, in the order they have to run. - * - * `appRoot` is `import.meta.dirname` because a Vite config is loaded with - * the working directory set to wherever the command ran, which in a - * monorepo is regularly the repository root. - * - * `clientEnv` is not passed: this app publishes nothing to the browser - * beyond the two keys VitNode inlines for every install - * (`NEXT_PUBLIC_API_URL`, `NEXT_PUBLIC_WEB_URL`). Anything named there is - * compiled into JavaScript anyone can read, so a key is added only when - * something in the browser genuinely reads it. - */ vitnode({ appRoot: import.meta.dirname }), devtools(), nitro({ rollupConfig: { external: [/^@sentry\//] } }), diff --git a/packages/create-vitnode-app/src/create/scaffold-invariants.test.ts b/packages/create-vitnode-app/src/create/scaffold-invariants.test.ts index ecf1684ad..d8bddc7d0 100644 --- a/packages/create-vitnode-app/src/create/scaffold-invariants.test.ts +++ b/packages/create-vitnode-app/src/create/scaffold-invariants.test.ts @@ -239,17 +239,44 @@ describe("what a generated single app starts from", () => { ); /** - * The environment a single app is configured with. Both URLs name this app's - * own origin, because it serves its own `/api/*` - which is exactly what the - * overlay's copy dropped. + * The environment a single app is configured with. `NEXT_PUBLIC_WEB_URL` + * names this app's own origin - which is exactly what the overlay's copy + * dropped. */ it("ships the single-app environment", () => { expect(env).toContain("POSTGRES_URL="); expect(env).toContain("NEXT_PUBLIC_WEB_URL=http://localhost:3000"); - expect(env).toContain("NEXT_PUBLIC_API_URL=http://localhost:3000"); expect(env).toContain("CRON_SECRET="); }); + /** + * And it names no API server, because it *is* the API server. + * + * A set `NEXT_PUBLIC_API_URL` wins over the origin the request arrived on - + * that is what makes the split scaffold work at all. So a single app that + * shipped `NEXT_PUBLIC_API_URL=http://localhost:3000` in its `.env.example` + * hands every deployment copied from it a server that calls the visitor's own + * machine. Unset, the request origin answers, and a preview deployment on a + * generated hostname needs no configuration. + */ + it("names no API server for an app that serves its own", () => { + expect(env).not.toMatch(/^NEXT_PUBLIC_API_URL=/m); + }); + + /** + * The split shape is the mirror image: two processes, so the web app cannot + * find `/api/*` on its own origin and the variable is mandatory rather than + * optional. `apps/api` listens on 8000 (`apps/api/src/index.ts`), so that is + * the port the web app has to be told about - getting this wrong answers the + * first server-side session read with a 404 of this app's own HTML. + */ + it("points a split web app at the API's own port", () => { + expect(read(appTemplate, "monorepo/apps/web/.env.example")).toContain( + "NEXT_PUBLIC_API_URL=http://localhost:8000", + ); + expect(read(appTemplate, "api-bun/src/index.ts")).toContain("port: 8000"); + }); + /** * One locale declaration, in the shared config, read by both configs. * @@ -365,8 +392,8 @@ describe("what a generated application does to every request", () => { }); /** - * One VitNode plugin in the Vite config, for the same reason: three calls in - * a fixed order is three things to copy wrong. + * One VitNode plugin in the Vite config, for the same reason: four calls in + * a fixed order is four things to copy wrong. */ it("configures Vite through one VitNode plugin", () => { const vite = withoutComments(read(appTemplate, "root/vite.config.ts")); @@ -383,6 +410,13 @@ describe("what a generated application does to every request", () => { expect(vite).not.toContain(removed); } }); + + it("leaves the SSR externals to that plugin", () => { + const vite = withoutComments(read(appTemplate, "root/vite.config.ts")); + + expect(vite).not.toMatch(/ssr\s*:/); + expect(vite).not.toContain('external: ["@vitnode/core"'); + }); }); describe("what a generated application shows during a slow navigation", () => { diff --git a/packages/vitnode/src/framework/vite/index.ts b/packages/vitnode/src/framework/vite/index.ts index d5eb02113..e9516e155 100644 --- a/packages/vitnode/src/framework/vite/index.ts +++ b/packages/vitnode/src/framework/vite/index.ts @@ -3,7 +3,7 @@ * * A `vite.config.ts` is the one file a framework cannot write for an * application: it names the app's own port, its Nitro options, its Tailwind - * entry. But almost nothing in *these three* is the app's. They read the + * entry. But almost nothing in *these four* is the app's. They read the * environment the way VitNode's config expects it to be read, they discover * the routes VitNode's plugins declare, and they name this package's own * browser dependencies for the dev server's pre-bundler - the same work, in the @@ -17,7 +17,7 @@ * ... * ] * - * The three are still exported individually, for an app that wants to drop or + * The four are still exported individually, for an app that wants to drop or * reorder one - {@link vitnode} is their composition, not a wrapper around them. * * ## Why this is `framework/` and not `tanstack/` @@ -38,5 +38,7 @@ export { vitNodeEnv } from "./env"; export { vitNodeOptimizeDeps } from "./optimize-deps"; export type { VitNodePluginRoutesOptions } from "./plugin-routes"; export { vitNodePluginRoutes } from "./plugin-routes"; +export type { VitNodeSsrExternalsOptions } from "./ssr-externals"; +export { vitNodeSsrExternals } from "./ssr-externals"; export type { VitNodeViteOptions } from "./vitnode"; export { vitnode } from "./vitnode"; diff --git a/packages/vitnode/src/framework/vite/optimize-deps.test.ts b/packages/vitnode/src/framework/vite/optimize-deps.test.ts index a8e9846ab..ce5252837 100644 --- a/packages/vitnode/src/framework/vite/optimize-deps.test.ts +++ b/packages/vitnode/src/framework/vite/optimize-deps.test.ts @@ -11,6 +11,7 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { + tanStackRouterDepsInclude, VITNODE_CLIENT_DEPENDENCIES, vitNodeClientDepsInclude, vitNodeOptimizeDeps, @@ -131,6 +132,26 @@ describe("vitNodeClientDepsInclude", () => { }); }); +describe("tanStackRouterDepsInclude", () => { + it("routes a router sub-dependency through the router package", () => { + const root = mkdtempSync(join(tmpdir(), "vitnode-router-deps-")); + + expect(tanStackRouterDepsInclude(root)).toStrictEqual([ + "@tanstack/react-router > @tanstack/router-core", + "@tanstack/react-router > @tanstack/router-core/isServer", + "@tanstack/react-router > @tanstack/router-core/ssr/client", + "@tanstack/react-router > @tanstack/router-core > seroval", + ]); + }); + + it("leaves a sub-dependency the app can resolve itself bare", () => { + const root = mkdtempSync(join(tmpdir(), "vitnode-router-deps-")); + mkdirSync(join(root, "node_modules", "seroval"), { recursive: true }); + + expect(tanStackRouterDepsInclude(root)).toContain("seroval"); + }); +}); + describe("vitNodeOptimizeDeps", () => { it("pre-bundles the dependencies for the dev server only", () => { const plugin = vitNodeOptimizeDeps(); @@ -152,7 +173,12 @@ describe("vitNodeOptimizeDeps", () => { ); expect(result).toStrictEqual({ - optimizeDeps: { include: vitNodeClientDepsInclude(sourceRoot) }, + optimizeDeps: { + include: [ + ...vitNodeClientDepsInclude(sourceRoot), + ...tanStackRouterDepsInclude(sourceRoot), + ], + }, }); }); }); diff --git a/packages/vitnode/src/framework/vite/optimize-deps.ts b/packages/vitnode/src/framework/vite/optimize-deps.ts index 5650b5e91..fcd97ec44 100644 --- a/packages/vitnode/src/framework/vite/optimize-deps.ts +++ b/packages/vitnode/src/framework/vite/optimize-deps.ts @@ -65,6 +65,15 @@ export const VITNODE_CLIENT_DEPENDENCIES = [ "zod", ] as const; +const ROUTER_PACKAGE_NAME = "@tanstack/react-router"; + +const TANSTACK_ROUTER_DEPENDENCIES = [ + [ROUTER_PACKAGE_NAME, "@tanstack/router-core"], + [ROUTER_PACKAGE_NAME, "@tanstack/router-core/isServer"], + [ROUTER_PACKAGE_NAME, "@tanstack/router-core/ssr/client"], + [`${ROUTER_PACKAGE_NAME} > @tanstack/router-core`, "seroval"], +] as const satisfies readonly (readonly [string, string])[]; + const packageNameOf = (specifier: string): string => { const segments = specifier.split("/"); @@ -80,19 +89,38 @@ const isReachableFrom = (root: string, packageName: string): boolean => { } }; +const includeThrough = ( + root: string, + owner: string, + specifier: string, +): string => + isReachableFrom(root, packageNameOf(specifier)) + ? specifier + : `${owner} > ${specifier}`; + export const vitNodeClientDepsInclude = (root: string): string[] => VITNODE_CLIENT_DEPENDENCIES.map(specifier => - isReachableFrom(root, packageNameOf(specifier)) - ? specifier - : `${PACKAGE_NAME} > ${specifier}`, + includeThrough(root, PACKAGE_NAME, specifier), + ); + +export const tanStackRouterDepsInclude = (root: string): string[] => + TANSTACK_ROUTER_DEPENDENCIES.map(([owner, specifier]) => + includeThrough(root, owner, specifier), ); export const vitNodeOptimizeDeps = (): Plugin => ({ apply: "serve", - config: userConfig => ({ - optimizeDeps: { - include: vitNodeClientDepsInclude(userConfig.root ?? process.cwd()), - }, - }), + config: userConfig => { + const root = userConfig.root ?? process.cwd(); + + return { + optimizeDeps: { + include: [ + ...vitNodeClientDepsInclude(root), + ...tanStackRouterDepsInclude(root), + ], + }, + }; + }, name: "vitnode:optimize-deps", }); diff --git a/packages/vitnode/src/framework/vite/plugin-routes.ts b/packages/vitnode/src/framework/vite/plugin-routes.ts index f6c5c1afc..f9fcb7bd9 100644 --- a/packages/vitnode/src/framework/vite/plugin-routes.ts +++ b/packages/vitnode/src/framework/vite/plugin-routes.ts @@ -234,6 +234,9 @@ const readConfiguredPluginIds = async ( ); }; +export const configuredPluginIds = async (appRoot: string): Promise<string[]> => + await readConfiguredPluginIds(appRoot, pathsFor(appRoot).config); + /** * One plugin's route tree, loaded from its compiled `routes` module. * diff --git a/packages/vitnode/src/framework/vite/ssr-externals.test.ts b/packages/vitnode/src/framework/vite/ssr-externals.test.ts new file mode 100644 index 000000000..f27837e9a --- /dev/null +++ b/packages/vitnode/src/framework/vite/ssr-externals.test.ts @@ -0,0 +1,52 @@ +import type { ConfigEnv, UserConfig } from "vite"; + +import { describe, expect, it, vi } from "vitest"; + +import { vitNodeSsrExternals } from "./ssr-externals"; + +const externalsFor = async ( + command: ConfigEnv["command"], + readPluginIds = vi.fn(async () => + Promise.resolve(["@acme/blog", "@acme/docs"]), + ), +): Promise<{ external: string[]; readPluginIds: typeof readPluginIds }> => { + const plugin = vitNodeSsrExternals({ appRoot: "/app", readPluginIds }); + const config = plugin.config as ( + userConfig: UserConfig, + env: ConfigEnv, + ) => Promise<{ ssr: { external: string[] } }>; + const { ssr } = await config({}, { command, mode: "development" }); + + return { external: ssr.external, readPluginIds }; +}; + +describe("what a VitNode app externalises from its SSR pass", () => { + it("externalises the package and every configured plugin for the build", async () => { + const { external } = await externalsFor("build"); + + expect(external).toEqual([ + "@vitnode/core", + "@acme/blog", + "@acme/docs", + "tslib", + ]); + }); + + it("keeps the packages inlined while the dev server runs", async () => { + const { external } = await externalsFor("serve"); + + expect(external).toEqual(["tslib"]); + }); + + it("does not read the app's config to answer the dev question", async () => { + const { readPluginIds } = await externalsFor("serve"); + + expect(readPluginIds).not.toHaveBeenCalled(); + }); + + it("reads the configured plugins from the app root it was given", async () => { + const { readPluginIds } = await externalsFor("build"); + + expect(readPluginIds).toHaveBeenCalledWith("/app"); + }); +}); diff --git a/packages/vitnode/src/framework/vite/ssr-externals.ts b/packages/vitnode/src/framework/vite/ssr-externals.ts new file mode 100644 index 000000000..174843281 --- /dev/null +++ b/packages/vitnode/src/framework/vite/ssr-externals.ts @@ -0,0 +1,31 @@ +import type { Plugin } from "vite"; + +import { configuredPluginIds } from "./plugin-routes"; + +const PACKAGE_NAME = "@vitnode/core"; + +const ALWAYS_EXTERNAL = ["tslib"] as const; + +export interface VitNodeSsrExternalsOptions { + appRoot: string; + readPluginIds?: (appRoot: string) => Promise<string[]>; +} + +export const vitNodeSsrExternals = ({ + appRoot, + readPluginIds = configuredPluginIds, +}: VitNodeSsrExternalsOptions): Plugin => ({ + config: async (_userConfig, { command }) => ({ + ssr: { + external: + command === "build" + ? [ + PACKAGE_NAME, + ...(await readPluginIds(appRoot)), + ...ALWAYS_EXTERNAL, + ] + : [...ALWAYS_EXTERNAL], + }, + }), + name: "vitnode:ssr-externals", +}); diff --git a/packages/vitnode/src/framework/vite/vitnode.test.ts b/packages/vitnode/src/framework/vite/vitnode.test.ts index f7e75ffcd..b6c0b83da 100644 --- a/packages/vitnode/src/framework/vite/vitnode.test.ts +++ b/packages/vitnode/src/framework/vite/vitnode.test.ts @@ -3,20 +3,21 @@ import { describe, expect, it } from "vitest"; import { vitnode } from "./vitnode"; /** - * One VitNode plugin in a `vite.config.ts`, instead of three in a fixed order. + * One VitNode plugin in a `vite.config.ts`, instead of four in a fixed order. * * A Vite plugin may be an array, so this is composition rather than a wrapper - * and what that buys is the thing worth pinning: an app cannot install two of - * the three, or install them in an order that puts the route generator ahead of + * the four, or install them in an order that puts the route generator ahead of * the environment it reads. */ const names = (plugins: { name: string }[]) => plugins.map(({ name }) => name); describe("the composed plugin", () => { - it("returns all three, in the order they have to run", () => { + it("returns all four, in the order they have to run", () => { expect(names(vitnode({ appRoot: "/app" }))).toEqual([ "vitnode:env", "vitnode:optimize-deps", + "vitnode:ssr-externals", "vitnode:plugin-routes", ]); }); diff --git a/packages/vitnode/src/framework/vite/vitnode.ts b/packages/vitnode/src/framework/vite/vitnode.ts index c0f5c3682..86c0ea2a9 100644 --- a/packages/vitnode/src/framework/vite/vitnode.ts +++ b/packages/vitnode/src/framework/vite/vitnode.ts @@ -6,6 +6,7 @@ import type { VitNodePluginRoutesOptions } from "./plugin-routes"; import { vitNodeEnv } from "./env"; import { vitNodeOptimizeDeps } from "./optimize-deps"; import { vitNodePluginRoutes } from "./plugin-routes"; +import { vitNodeSsrExternals } from "./ssr-externals"; export interface VitNodeViteOptions extends VitNodeEnvOptions, VitNodePluginRoutesOptions {} @@ -22,7 +23,7 @@ export interface VitNodeViteOptions * ] * * A Vite plugin may be an array, so this is the composition and not a wrapper: - * the three plugins underneath are returned in the order they have to run, and + * the four plugins underneath are returned in the order they have to run, and * each is still exported on its own for an app that genuinely wants to drop or * reorder one. * @@ -38,5 +39,6 @@ export const vitnode = ({ }: VitNodeViteOptions): Plugin[] => [ vitNodeEnv({ clientEnv }), vitNodeOptimizeDeps(), + vitNodeSsrExternals({ appRoot }), vitNodePluginRoutes({ appRoot, hostRoutesDir }), ]; diff --git a/packages/vitnode/src/lib/config.ts b/packages/vitnode/src/lib/config.ts index 9946d5186..ec82c0d3f 100644 --- a/packages/vitnode/src/lib/config.ts +++ b/packages/vitnode/src/lib/config.ts @@ -17,7 +17,9 @@ export const INSECURE_DEFAULT_CRON_SECRET = * would otherwise fall back to names the visitor's own machine. * * The server half of the same answer is read off the request being handled; see - * `resolveApiOrigin` in the TanStack Start app. + * `resolveApiOrigin` in the TanStack Start app - which, like the browser, gives + * an explicitly set `NEXT_PUBLIC_API_URL` precedence, because a split + * deployment's web app has no `/api/*` of its own to answer. * * `undefined` wherever there is no document - Node, the API server, a build - so * those keep falling through to the configured value. diff --git a/packages/vitnode/src/tanstack/auth/login-screen.tsx b/packages/vitnode/src/tanstack/auth/login-screen.tsx index 06f397b9c..7f85988b3 100644 --- a/packages/vitnode/src/tanstack/auth/login-screen.tsx +++ b/packages/vitnode/src/tanstack/auth/login-screen.tsx @@ -54,24 +54,22 @@ export const LoginRouteContent = ({ return ( <RouteMessages namespaces={LOGIN_NAMESPACES}> - <main> - <SignInContent - form={ - <SignInFormContent - LinkComponent={LinkComponent} - onSignIn={signIn} - showResetPassword={config.isEmail} - /> - } - LinkComponent={LinkComponent} - sso={ - <SSOButtonsContent - onSelectProvider={startSsoAction} - providers={ssoProvidersOf(config)} - /> - } - /> - </main> + <SignInContent + form={ + <SignInFormContent + LinkComponent={LinkComponent} + onSignIn={signIn} + showResetPassword={config.isEmail} + /> + } + LinkComponent={LinkComponent} + sso={ + <SSOButtonsContent + onSelectProvider={startSsoAction} + providers={ssoProvidersOf(config)} + /> + } + /> </RouteMessages> ); }; diff --git a/packages/vitnode/src/tanstack/auth/recovery-screen.tsx b/packages/vitnode/src/tanstack/auth/recovery-screen.tsx index 65e3938e0..a13f5fe93 100644 --- a/packages/vitnode/src/tanstack/auth/recovery-screen.tsx +++ b/packages/vitnode/src/tanstack/auth/recovery-screen.tsx @@ -48,14 +48,12 @@ export const PasswordRecoveryNotFound = ({ const t = useTranslations("core.global"); return ( - <main> - <ErrorContent - actions={actions} - code={404} - description={t("errors.404.desc")} - title={t("errors.404.title")} - /> - </main> + <ErrorContent + actions={actions} + code={404} + description={t("errors.404.desc")} + title={t("errors.404.title")} + /> ); }; @@ -87,32 +85,30 @@ export const PasswordResetRouteContent = ({ return ( <RouteMessages namespaces={namespaces}> - <main> - <PasswordResetContent> - {mode.mode === "change" ? ( - <ChangePasswordFormContent - link={mode.link} - onChanged={() => { - void router.navigate(CHANGED_PASSWORD_DESTINATION); - }} - onChangePassword={changePasswordFromResetAction} - /> - ) : ( - /* - No `onSuccess` and no navigation: an accepted request swaps the - card for "check your email" and leaves the visitor there. It says - the same thing for an address with an account and one without, - because the API answers the same 201 for both - the - anti-enumeration behaviour is preserved by there being nothing - here that could distinguish them. - */ - <PasswordResetFormContent - captcha={config.captcha} - onRequestReset={requestPasswordResetAction} - /> - )} - </PasswordResetContent> - </main> + <PasswordResetContent> + {mode.mode === "change" ? ( + <ChangePasswordFormContent + link={mode.link} + onChanged={() => { + void router.navigate(CHANGED_PASSWORD_DESTINATION); + }} + onChangePassword={changePasswordFromResetAction} + /> + ) : ( + /* + No `onSuccess` and no navigation: an accepted request swaps the + card for "check your email" and leaves the visitor there. It says + the same thing for an address with an account and one without, + because the API answers the same 201 for both - the + anti-enumeration behaviour is preserved by there being nothing + here that could distinguish them. + */ + <PasswordResetFormContent + captcha={config.captcha} + onRequestReset={requestPasswordResetAction} + /> + )} + </PasswordResetContent> </RouteMessages> ); }; diff --git a/packages/vitnode/src/tanstack/auth/register-screen.tsx b/packages/vitnode/src/tanstack/auth/register-screen.tsx index 567da615b..905d6033b 100644 --- a/packages/vitnode/src/tanstack/auth/register-screen.tsx +++ b/packages/vitnode/src/tanstack/auth/register-screen.tsx @@ -75,25 +75,23 @@ export const RegisterRouteContent = ({ return ( <RouteMessages namespaces={REGISTER_NAMESPACES}> - <main> - <SignUpContent - form={ - <SignUpFormContent - captcha={config.captcha} - isEmail={config.isEmail} - LinkComponent={LinkComponent} - onSignUp={signUp} - /> - } - LinkComponent={LinkComponent} - sso={ - <SSOButtonsContent - onSelectProvider={startSsoAction} - providers={ssoProvidersOf(config)} - /> - } - /> - </main> + <SignUpContent + form={ + <SignUpFormContent + captcha={config.captcha} + isEmail={config.isEmail} + LinkComponent={LinkComponent} + onSignUp={signUp} + /> + } + LinkComponent={LinkComponent} + sso={ + <SSOButtonsContent + onSelectProvider={startSsoAction} + providers={ssoProvidersOf(config)} + /> + } + /> </RouteMessages> ); }; diff --git a/packages/vitnode/src/tanstack/auth/sso-screen.tsx b/packages/vitnode/src/tanstack/auth/sso-screen.tsx index 9f38082b1..773fd425b 100644 --- a/packages/vitnode/src/tanstack/auth/sso-screen.tsx +++ b/packages/vitnode/src/tanstack/auth/sso-screen.tsx @@ -81,15 +81,13 @@ export const SsoCallbackRouteContent = ({ return ( <RouteMessages namespaces={SSO_CALLBACK_NAMESPACES}> - <main> - <SSOCallbackContent - errorActions={errorActions} - LinkComponent={LinkComponent} - providerId={providerId} - providers={ssoProvidersOf(config)} - state={state} - /> - </main> + <SSOCallbackContent + errorActions={errorActions} + LinkComponent={LinkComponent} + providerId={providerId} + providers={ssoProvidersOf(config)} + state={state} + /> </RouteMessages> ); }; diff --git a/packages/vitnode/src/tanstack/boundary.test.ts b/packages/vitnode/src/tanstack/boundary.test.ts deleted file mode 100644 index 7863e442e..000000000 --- a/packages/vitnode/src/tanstack/boundary.test.ts +++ /dev/null @@ -1,542 +0,0 @@ -import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; -import { dirname, join, relative, resolve, sep } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -/** - * The rules that make `@vitnode/core/tanstack/*` safe to depend on. - * - * `src/tanstack` is the only place in this package allowed to import TanStack - * Router or Start. Everything under it is compiled twice by two different - * pipelines, and the two disagree - which is the whole reason these rules exist - * rather than being left to review: - * - * - **Browser bundle.** The host app's Vite build inlines this package, so the - * TanStack Start compiler *does* transform these files. `createIsomorphicFn` - * is rewritten to the client branch and the `.server()` branch - and its - * imports with it - are dropped. - * - **Server bundle.** The host externalises this package from Vite's SSR pass - * (`ssr.external`), and Nitro's own Rollup run inlines the built `dist` - * afterwards. Nothing in that path runs the Start compiler, so these files - * reach the server *uncompiled*. - * - * `createIsomorphicFn` survives that asymmetry because its runtime stub prefers - * the `.server()` implementation when it was never compiled, which is exactly - * what a server wants. `createServerFn` does not: uncompiled, its `.handler()` - * receives one argument where the compiler passes two, and the call resolves to - * `undefined` with no error at all. A server function declared here therefore - * answers correctly when the browser calls it over `/_serverFn/*` and silently - * returns nothing when the server calls it during SSR - so it is forbidden, and - * stays in the host where the compiler can see it. - */ -const here = dirname(fileURLToPath(import.meta.url)); -const packageRoot = resolve(here, "../.."); -const tanstackRoot = here; - -const SKIP_DIRECTORIES = ["dist", "node_modules"]; - -const filesUnder = (directory: string): string[] => { - const entries: string[] = []; - - for (const name of readdirSync(directory)) { - const path = join(directory, name); - - if (statSync(path).isDirectory()) { - if (SKIP_DIRECTORIES.includes(name)) continue; - entries.push(...filesUnder(path)); - continue; - } - - if (/\.tsx?$/.test(name) && !name.endsWith(".d.ts")) entries.push(path); - } - - return entries; -}; - -const isTest = (path: string): boolean => - /\.test(-d)?\.tsx?$/.test(path) || path.includes(`${sep}tests${sep}`); - -const runtimeFilesUnder = (directory: string): string[] => - filesUnder(directory).filter(path => !isTest(path)); - -/** Every specifier a file imports, type-only statements dropped. */ -const importsFrom = (path: string): string[] => - [ - ...readFileSync(path, "utf8") - .replace( - /(?:^|\n)\s*(?:import|export)\s+type\s[\s\S]*?\sfrom\s*["'][^"']+["']/g, - "\n", - ) - .matchAll( - /(?:^|[^\w$.])from\s*["']([^"']+)["']|import\s*\(\s*["']([^"']+)["']|(?:^|[\n;}])\s*import\s*["']([^"']+)["']/g, - ), - ] - .map(match => match[1] ?? match[2] ?? match[3]) - .filter((specifier): specifier is string => Boolean(specifier)); - -const matches = (specifier: string, forbidden: string): boolean => - specifier === forbidden || specifier.startsWith(`${forbidden}/`); - -const offendersIn = (files: string[], forbidden: string[]): string[] => - files - .filter(path => - importsFrom(path).some(specifier => - forbidden.some(entry => matches(specifier, entry)), - ), - ) - .map(path => relative(packageRoot, path)); - -/** Anything that only resolves inside a TanStack Start app. */ -const TANSTACK_RUNTIME = ["@tanstack/react-router", "@tanstack/react-start"]; - -/** - * Every TanStack runtime this package expects the *host* to own, rather than - * carrying its own copy of. - * - * A superset of {@link TANSTACK_RUNTIME}, and the extra entry is the reason the - * two lists are not one. `@tanstack/react-query` is not confined to this - * namespace and must not be: `views/layouts/provider` mounts the - * `QueryClientProvider` that *both* frontends render under, and the AutoForm - * fields, the AdminCP search dialog and the shared feature queries all read it - * from a Next.js render. So it belongs on the dependency-policy list and not on - * the import-isolation list above - adding it there would forbid the imports the - * Next.js surface is built on. - * - * What it shares with the other two is the failure it prevents. This package - * calls `useQueryClient`, `useQuery` and `useSuspenseQuery`; the application - * mounts the provider. Resolve two copies of React Query and those are two React - * contexts: the provider the host mounted is invisible to the hook inside a core - * component, which throws "No QueryClient set" - or, worse, silently reads an - * empty second cache, so a loader's `ensureQueryData` warms an entry the - * component never sees. A peer dependency is what makes that arrangement - * impossible rather than merely unlikely, because a peer resolves to the - * consumer's copy by definition. - * - * Optional, because a consumer can legitimately have none of them: `apps/api` - * installs this package for its Hono modules and renders nothing at all. - * "Optional" here means "not every install needs one", never "core works - * without it once you render" - an app that mounts VitNode's provider tree has - * to supply React Query, which is why `apps/web` declares it too. - */ -const TANSTACK_PEER_RUNTIME = [ - ...TANSTACK_RUNTIME, - "@tanstack/react-query", -].sort((a, b) => a.localeCompare(b)); - -/** - * Anything that only resolves inside a Next.js application. - * - * The package name covers every subpath by the prefix rule in {@link matches} - - * `next/cache`, `next/headers`, `next/navigation`, `next/server`. Listed as the - * package rather than as a list of subpaths on purpose: a list of subpaths is a - * list somebody has to remember to extend, and the package itself is the - * boundary. `server-only` sits with it because it throws in exactly the same - * places for exactly the same reason. - * - * Not to be confused with `@tanstack/react-start/server-only`, which is a - * different package and is legitimately imported by each feature's - * `server.ts` in this namespace: the prefix rule matches `server-only` and `server-only/...`, never - * a specifier that merely ends in it. - */ -const NEXT_ONLY = ["next", "server-only"]; - -/** - * next-intl's Next-only halves. - * - * The root entry is deliberately absent: it re-exports `use-intl`, which is - * framework-free and is what this namespace's components read. These four reach - * Next's request scope, its middleware or its build plugin, and none of them - * resolves in a TanStack Start host. - */ -const NEXT_INTL_RUNTIME = [ - // The root entry is included now. It re-exports `use-intl` and resolves - // outside Next.js, which is what let it survive in two shared components for - // as long as it did - and what made them read a `use-intl` context from a - // second module record nothing in `apps/web` provides into. - "next-intl", - "next-intl/middleware", - "next-intl/navigation", - "next-intl/plugin", - "next-intl/server", -]; - -/** - * The Start primitives no module in this package may declare. - * - * `createServerFn` is the one that actually breaks. It needs the module it sits - * in to be transformed on *both* sides; uncompiled - which is how this package - * reaches a server - its `.handler()` receives one argument where the compiler - * passes two, and the call resolves to `undefined` with no error at all. The - * route builders name the host's own route tree, which a package cannot own. - */ -const HOST_ONLY_PRIMITIVES = [ - "createFileRoute", - "createRootRoute", - "createRootRouteWithContext", - "createServerFn", -]; - -/** - * The request pipeline, which is the one feature allowed to build a Start - * instance. - * - * `createStart` and `createMiddleware` used to sit on the list above, and the - * reason given was the compiler. That reason is wrong for these two and worth - * correcting rather than working around: both are plain builders - read - * `createStart.ts` and `createMiddleware.ts` in `@tanstack/start-client-core` - * and neither does anything but return the options object it was handed - so - * being uncompiled costs them nothing. - * - * What genuinely needs the compiler on both sides is a *function* middleware, - * whose `.client()` branch runs in a browser. A **request** middleware has only - * a `.server()` branch and runs only in the server entry, which is the whole of - * what `tanstack/start` declares. The host still owns the composition: it calls - * `createVitNodeStart(...)` from its own `src/start.ts` and exports the result - * as `startInstance`, which is what the framework reads. - */ -const REQUEST_PIPELINE_PRIMITIVES = ["createMiddleware", "createStart"]; -const REQUEST_PIPELINE_DIRECTORY = join(tanstackRoot, "start"); - -describe("this test is looking at the right tree", () => { - it("finds the package root", () => { - expect(readFileSync(join(packageRoot, "package.json"), "utf8")).toContain( - '"@vitnode/core"', - ); - }); - - it("has TanStack modules to check", () => { - expect(runtimeFilesUnder(tanstackRoot).length).toBeGreaterThan(0); - }); -}); - -describe("TanStack stays behind the tanstack/ namespace", () => { - /** - * The rest of the package, which a consumer may import without TanStack. - * - * `apps/api` resolves this package for its Hono modules and has no TanStack - * dependency at all, and a plugin importing `@vitnode/core/components/...` or - * `@vitnode/core/content/admin-form` is in the same position. A single import - * from one of those trees into TanStack would make `@tanstack/react-router` a - * hard requirement of every VitNode install, which is what the optional peer - * dependency in `package.json` says it is not. - */ - const outsideTanstack = () => - runtimeFilesUnder(join(packageRoot, "src")).filter( - path => !path.startsWith(`${tanstackRoot}${sep}`), - ); - - it("has files to check", () => { - expect(outsideTanstack().length).toBeGreaterThan(100); - }); - - it("never imports TanStack Router or Start", () => { - expect(offendersIn(outsideTanstack(), TANSTACK_RUNTIME)).toEqual([]); - }); - - it("finds the TanStack imports inside the namespace, so the scan is real", () => { - // The control. Every assertion above is a "found nothing" one, which a - // scanner that silently matches nothing also satisfies. - expect( - offendersIn(runtimeFilesUnder(tanstackRoot), TANSTACK_RUNTIME), - ).not.toEqual([]); - }); -}); - -describe("the namespace reaches no Next.js runtime", () => { - /** - * The rule that keeps `@vitnode/core/tanstack/*` importable by a host that has - * no Next.js installed at all. - * - * `apps/web/src/tests/isolation.test.ts` already walks the *application's* - * graph and would catch a `next/cache` that a route actually reaches. This is - * the other half, and it is not redundant: that walk only sees a module once - * something imports it, so a new file added here reaching `next/cache` passes - * every test in the workspace until the day a route pulls it in - which is a - * production Nitro build failing on a package that does not resolve, not a red - * test. The namespace owns its own rule. - * - * `next/cache` is the specific one worth naming. It is the entry an author - * reaches for by reflex when they want a write to expire something - the Next - * halves of these same features call `revalidatePath` and `updateTag` all over - * `views/` - and it is exactly the wrong instinct here: a TanStack host expires - * a *TanStack Query* entry, and the server-side cache it might also want is the - * API's `c.get("cache")`, reached over HTTP rather than by import. - */ - const NEXT_RUNTIME = [...NEXT_ONLY, ...NEXT_INTL_RUNTIME]; - - it("has no layer left where those imports are allowed", () => { - // This was the control: `content/next` was the layer that existed precisely - // to hold these imports, so scanning it proved the scanner could find them. - // It is gone, and a control pointed at production code would only defer the - // problem to whoever deletes the next specimen - so the positive controls - // moved to `src/next-boundary.test.ts`, which scans a fixture built for the - // purpose. What is left to assert here is that the exemption is gone too. - expect(existsSync(join(packageRoot, "src/content/next"))).toBe(false); - expect(existsSync(join(packageRoot, "src/lib/next-cache"))).toBe(false); - }); - - it.each(NEXT_RUNTIME)("never imports %s", forbidden => { - expect(offendersIn(runtimeFilesUnder(tanstackRoot), [forbidden])).toEqual( - [], - ); - }); - - it("never imports the Next.js cache adapters by path either", () => { - // Belt and braces for the one module that is *not* an npm specifier. The - // handlers live in this package, so a relative or aliased import of them - // would satisfy the specifier scan above while still dragging Next's cache - // contract - and `redis` - into a browser bundle. - const offenders = runtimeFilesUnder(tanstackRoot) - .filter(path => - importsFrom(path).some(specifier => - specifier.includes("lib/next-cache"), - ), - ) - .map(path => relative(packageRoot, path)); - - expect(offenders).toEqual([]); - }); -}); - -describe("the namespace holds nothing the Start compiler has to see twice", () => { - /** - * Comments dropped before the scan, and that is not a loophole. - * - * The rule is about what a module *declares*. Explaining why `createServerFn` - * has to stay in the host is exactly what the modules here should be doing - - * `i18n/query.ts` names it twice in prose, saying who owns the server function - * its validator is passed to - and a scan that counted those would push the - * next author into writing a worse comment rather than moving code. - * - * A string literal is deliberately *not* stripped: nothing here has a reason - * to name one of these in a string, and leaving them in keeps the scan blunt - * where being blunt is free. - */ - const withoutComments = (code: string): string => - code.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1"); - - const sources = () => - runtimeFilesUnder(tanstackRoot).map(path => ({ - code: withoutComments(readFileSync(path, "utf8")), - path: relative(packageRoot, path), - })); - - it("still sees the code under the comments", () => { - // The control for the stripping above: a scan that blanked whole files - // would satisfy every assertion below. - expect( - sources().filter(({ code }) => /createIsomorphicFn\s*\(/.test(code)), - ).not.toEqual([]); - }); - - it.each(HOST_ONLY_PRIMITIVES)("declares no %s", primitive => { - expect( - sources() - .filter(({ code }) => new RegExp(`\\b${primitive}\\s*[(<]`).test(code)) - .map(({ path }) => path), - ).toEqual([]); - }); - - const outsideRequestPipeline = () => - sources().filter( - ({ path }) => - !join(packageRoot, path).startsWith( - `${REQUEST_PIPELINE_DIRECTORY}${sep}`, - ), - ); - - it.each(REQUEST_PIPELINE_PRIMITIVES)( - "declares %s in the request pipeline only", - primitive => { - expect( - outsideRequestPipeline() - .filter(({ code }) => - new RegExp(`\\b${primitive}\\s*[(<]`).test(code), - ) - .map(({ path }) => path), - ).toEqual([]); - }, - ); - - it.each(REQUEST_PIPELINE_PRIMITIVES)( - "finds %s where it is allowed, so the scope is real", - primitive => { - // The control. Both assertions above are "found nothing" ones, which a - // scan whose directory filter excluded everything also satisfies. - expect( - sources().filter(({ code }) => - new RegExp(`\\b${primitive}\\s*[(<]`).test(code), - ), - ).not.toEqual([]); - }, - ); -}); - -describe("the request pipeline is safe in the client bundle too", () => { - /** - * `src/start.ts` is a *client* entry as well as a server one. - * - * `hydrateStart` imports `startInstance` from `#tanstack-start-entry`, which - * resolves to the host's `src/start.ts` - so everything `createVitNodeStart` - * reaches is in the browser build's module graph. A top-level - * `import "@tanstack/react-start/server-only"` anywhere in that chain is - * therefore an import-protection *error* rather than a safeguard: - * - * [import-protection] Import denied in client environment - * Importer: dist/src/tanstack/start/create-start.js - * - * What actually keeps the server half out of the browser is the Start - * compiler, which strips a middleware's `.server()` callback from the client - * bundle. That kills the only live reference to `handleLocaleRequest`, and - * `tanstack/i18n/request` - which is marked server-only - shakes out with it. - * - * So the marker is forbidden here and the discipline is the other one: - * anything a browser must not hold stays reachable only from inside a - * `.server()` callback. - */ - const startFiles = () => runtimeFilesUnder(join(tanstackRoot, "start")); - - it("has the pipeline to check", () => { - expect(startFiles().length).toBeGreaterThan(0); - }); - - it("carries no server-only marker", () => { - const offenders = startFiles() - .filter(path => - importsFrom(path).some(specifier => - specifier.startsWith("@tanstack/react-start/server-only"), - ), - ) - .map(path => relative(packageRoot, path)); - - expect(offenders).toEqual([]); - }); - - it("finds the marker where it does belong, so the scan is real", () => { - // The control: `tanstack/i18n/server` is server-only and says so, and it is - // never in a client graph because nothing a browser reaches imports it. - expect(importsFrom(join(tanstackRoot, "i18n", "server.ts"))).toContain( - "@tanstack/react-start/server-only", - ); - }); -}); - -describe("the namespace never depends on an application", () => { - /** - * The reverse dependency, stated once for the whole package. - * - * `apps/web` may import `@vitnode/core`; `@vitnode/core` may never import - * `apps/web`. The `#/` prefix is the one that would actually get written by - * mistake - it is what the host's own modules use for their internal imports, - * so a file moved here keeps compiling in the host's editor right up until it - * is built. - */ - const APP_SPECIFIERS = ["#", "apps/web", "web"]; - - it("imports nothing through the host's `#/` alias", () => { - const offenders = runtimeFilesUnder(join(packageRoot, "src")) - .filter(path => - importsFrom(path).some(specifier => - APP_SPECIFIERS.some(entry => matches(specifier, entry)), - ), - ) - .map(path => relative(packageRoot, path)); - - expect(offenders).toEqual([]); - }); - - it("reaches no application directory by relative path", () => { - const offenders = runtimeFilesUnder(join(packageRoot, "src")) - .filter(path => - importsFrom(path).some( - specifier => - specifier.startsWith(".") && - resolve(dirname(path), specifier).includes(`${sep}apps${sep}`), - ), - ) - .map(path => relative(packageRoot, path)); - - expect(offenders).toEqual([]); - }); -}); - -describe("the export map publishes the namespace", () => { - const manifest = JSON.parse( - readFileSync(join(packageRoot, "package.json"), "utf8"), - ) as { - dependencies?: Record<string, string>; - exports: Record<string, Record<string, string> | string>; - peerDependencies: Record<string, string>; - peerDependenciesMeta?: Record<string, { optional?: boolean }>; - }; - - const target = (subpath: string): string => { - const entry = manifest.exports[subpath]; - expect(entry, `${subpath} is exported`).toBeDefined(); - - return typeof entry === "string" ? entry : entry.import; - }; - - /** - * Three patterns, one shape each. Node picks the *longest* matching pattern - * and does not fall through to a shorter one, so `./tanstack/*` shadows the - * package-wide `./*` for everything under the namespace: only these three - * spellings resolve, and a feature's internals are unreachable from outside. - */ - it.each([ - ["./tanstack/*", "./dist/src/tanstack/*/index.js"], - ["./tanstack/*/client", "./dist/src/tanstack/*/client.js"], - ["./tanstack/*/server", "./dist/src/tanstack/*/server.js"], - ])("maps %s to %s", (subpath, expected) => { - expect(target(subpath)).toBe(expected); - }); - - it("keeps the namespace patterns ahead of the package-wide wildcard", () => { - const keys = Object.keys(manifest.exports); - - expect(keys.indexOf("./tanstack/*")).toBeLessThan(keys.indexOf("./*")); - }); - - it.each(TANSTACK_PEER_RUNTIME)("declares %s as an optional peer", name => { - expect(manifest.peerDependencies[name]).toBeDefined(); - expect(manifest.peerDependenciesMeta?.[name]?.optional).toBe(true); - }); - - it.each(TANSTACK_PEER_RUNTIME)( - "does not also carry %s as a private dependency", - name => { - // The half that actually prevents the second copy. A peer entry alongside - // a `dependencies` entry is not a contract - npm and pnpm both install the - // dependency, so the package gets its own copy regardless of what the host - // has, and the peer declaration becomes decoration. React Query is the one - // this test was extended for: it was a plain dependency, which in a - // published install is two `QueryClient` contexts waiting to happen. - expect(manifest.dependencies?.[name]).toBeUndefined(); - }, - ); -}); - -describe("every feature in the namespace has the entry points it claims", () => { - /** - * The directories directly under `src/tanstack`, which are what - * `@vitnode/core/tanstack/<feature>` resolves into. A feature that ships only - * server code has no `index.ts`, and that is correct - the barrel exists to be - * imported from a browser bundle, so an empty one would only invite a client - * import of something that cannot run there. - */ - const features = readdirSync(tanstackRoot) - .filter(name => statSync(join(tanstackRoot, name)).isDirectory()) - .filter(name => !SKIP_DIRECTORIES.includes(name)); - - it("has at least one feature", () => { - expect(features.length).toBeGreaterThan(0); - }); - - it.each(features)("%s exposes at least one public entry", feature => { - const entries = readdirSync(join(tanstackRoot, feature)).filter(name => - ["client.ts", "index.ts", "index.tsx", "server.ts"].includes(name), - ); - - expect(entries).not.toEqual([]); - }); -}); diff --git a/packages/vitnode/src/tanstack/cancellation-degrades.test.ts b/packages/vitnode/src/tanstack/cancellation-degrades.test.ts deleted file mode 100644 index 2b4f5baa4..000000000 --- a/packages/vitnode/src/tanstack/cancellation-degrades.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -// @vitest-environment node -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { rawApiFetch } from "@/lib/fetcher/raw"; -import { fetchContentListPageInBrowser } from "@/views/admin/views/content/table/list-query"; -import { fetchAdminStaffPageInBrowser } from "@/views/admin/views/core/staff/staff-query"; -import { fetchAdminUsersPageInBrowser } from "@/views/admin/views/core/users/list/users-query"; -import { fetchMyFilesPageInBrowser } from "@/views/files/my-files-query"; -import { fetchSearchFeedPageInBrowser } from "@/views/search/search-feed-query"; - -/** - * The rule that decides whether threading a signal is safe at all: - * - * **an abort must reject, and must never become data.** - * - * A cancelled read that resolves is worse than one that never cancels. Each of - * these five reads has a shape it must not be mistaken for, and every one of - * them is a plausible, silent answer: - * - * search feed "no results" — a search that found nothing - * my files an empty table — an account with nothing uploaded - * admin users an empty table — an installation with no users - * admin staff an empty table — nobody is a moderator - * content list no records — a content type never written to - * - * and worse than any of them, a `401` or a `403` read as an answer rather than - * as a failure: a cancelled navigation would show a visitor as signed out, or an - * administrator as refused. - * - * These are safe by construction rather than by a guard, and that is the finding - * worth pinning: the abort rejects **inside `fetch`**, before there is a - * `Response` for any of these functions to inspect, and none of them has a - * `catch` that could turn a rejection into a value. A fetcher whose failure path - * *did* return a fallback would have to re-throw the abort before degrading - - * the middleware config and the dashboard layout are the two in this codebase - * that would, which is why neither of them was made cancellable. - * - * Pure: `fetch` is stubbed to reject the way a real abort does. Nothing here - * opens a socket. - */ - -const ORIGIN = "http://localhost:3000"; - -/** What the platform throws when a request is aborted. */ -const abortError = () => { - const error = new Error("The operation was aborted."); - error.name = "AbortError"; - - return error; -}; - -const TARGET = { - permissionModule: "post", - pluginId: "@vitnode/blog", -} as const; - -/** The five reads that read a `signal`, each asked to cancel. */ -const CANCELLABLE = { - "admin staff list": async (signal: AbortSignal) => - await fetchAdminStaffPageInBrowser("admin", { first: "10" }, { signal }), - "admin users list": async (signal: AbortSignal) => - await fetchAdminUsersPageInBrowser({ first: "10" }, { signal }), - "content list": async (signal: AbortSignal) => - await fetchContentListPageInBrowser( - { contentTypeId: "blog.post", query: { first: "25" }, target: TARGET }, - { signal }, - ), - "my files": async (signal: AbortSignal) => - await fetchMyFilesPageInBrowser({ first: "10" }, { signal }), - "search feed": async (signal: AbortSignal) => - await fetchSearchFeedPageInBrowser( - { cursor: null, locale: "en", params: {} }, - { signal }, - ), -} as const; - -describe("an abort is re-thrown, never converted", () => { - beforeEach(() => { - vi.stubEnv("NEXT_PUBLIC_API_URL", ORIGIN); - vi.spyOn(console, "error").mockImplementation(() => undefined); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - vi.unstubAllEnvs(); - vi.restoreAllMocks(); - }); - - it.each(Object.entries(CANCELLABLE))("%s rejects", async (_name, read) => { - vi.stubGlobal("fetch", () => { - throw abortError(); - }); - - const controller = new AbortController(); - controller.abort(); - - await expect(read(controller.signal)).rejects.toThrow( - expect.objectContaining({ name: "AbortError" }), - ); - }); - - /** - * The negative, stated so it cannot be read out of the assertion above by - * accident: the promise does not *resolve*. An empty page, a `null` and an - * `undefined` are all things a caller would render, and all of them are wrong. - */ - it.each(Object.entries(CANCELLABLE))( - "%s does not resolve to a value", - async (_name, read) => { - vi.stubGlobal("fetch", () => { - throw abortError(); - }); - - const controller = new AbortController(); - controller.abort(); - - let resolved = false; - await read(controller.signal).then( - () => { - resolved = true; - }, - () => undefined, - ); - - expect(resolved).toBe(false); - }, - ); - - /** - * And the rejection keeps its own identity rather than being re-wrapped as a - * refusal. `AdminRequestError` and `MyFilesRequestError` carry a `status` and - * mean "the API answered and said no"; an abort answered nothing, and a caller - * that reads `status` to decide whether the session ended must not find one. - */ - it.each(Object.entries(CANCELLABLE))( - "%s does not disguise the abort as a refusal", - async (_name, read) => { - vi.stubGlobal("fetch", () => { - throw abortError(); - }); - - const controller = new AbortController(); - controller.abort(); - - const error = await read(controller.signal).catch( - (thrown: unknown) => thrown, - ); - - expect(error).toBeInstanceOf(Error); - expect((error as Error).name).toBe("AbortError"); - expect(error).not.toHaveProperty("status"); - }, - ); -}); - -/** - * The far end of the seam: a signal handed to `rawApiFetch` in `options` reaches - * the real `fetch` init, unchanged. - * - * Everything between - `fetcherClient`, `coreFetcher` - threads `options` - * through untouched and always has; this is the one hop that spreads it into the - * request, and it is the hop that would silently drop a signal if the spread - * were ever reordered. - */ -describe("the transport carries the signal to fetch", () => { - beforeEach(() => { - vi.stubEnv("NEXT_PUBLIC_API_URL", ORIGIN); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - vi.unstubAllEnvs(); - }); - - it("passes options.signal into the fetch init", async () => { - let seen: RequestInit | undefined; - vi.stubGlobal("fetch", async (_url: unknown, init?: RequestInit) => { - seen = init; - - return await Promise.resolve(new Response("{}", { status: 200 })); - }); - - const controller = new AbortController(); - - await rawApiFetch({ - method: "get", - module: "users/files", - options: { signal: controller.signal }, - path: "/", - pluginId: "@vitnode/core", - }); - - expect(seen?.signal).toBe(controller.signal); - }); - - /** - * `options` must not be able to change what the call was built as. - * - * `body`, `headers` and `method` are all omitted from its type now; `method` - * was not, and the spread came *last*, so a `get` could leave as a `post`. - * Harmless while nothing passed `options` for anything but `credentials` - and - * worth closing the moment `signal` started travelling through the same - * argument, because that is when callers start reaching for it. - * - * Asserted at runtime rather than only in the type, because the type alone is - * a promise about callers and this is a promise about the function: `options` - * is now spread *before* the three fields `rawApiFetch` computes, so they win - * whatever a caller reaching past the type supplies. - */ - it("does not let options override the method", async () => { - let seen: RequestInit | undefined; - vi.stubGlobal("fetch", async (_url: unknown, init?: RequestInit) => { - seen = init; - - return await Promise.resolve(new Response("{}", { status: 200 })); - }); - - await rawApiFetch({ - method: "get", - module: "users/files", - // The type forbids it; a caller reaching past the type must still not win. - options: { method: "post" } as never, - path: "/", - pluginId: "@vitnode/core", - }); - - expect(seen?.method).toBe("GET"); - }); -}); diff --git a/packages/vitnode/src/tanstack/cancellation.test.ts b/packages/vitnode/src/tanstack/cancellation.test.ts deleted file mode 100644 index cece2311c..000000000 --- a/packages/vitnode/src/tanstack/cancellation.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -// @vitest-environment node -import { describe, expect, it } from "vitest"; - -import { contentListQueryOptions } from "@/views/admin/views/content/table/list-query"; -import { adminStaffQueryOptions } from "@/views/admin/views/core/staff/staff-query"; -import { adminUsersQueryOptions } from "@/views/admin/views/core/users/list/users-query"; -import { myFilesQueryOptions } from "@/views/files/my-files-query"; -import { searchFeedQueryOptions } from "@/views/search/search-feed-query"; - -/** - * The five reads that can be given up on, and the one property that decides it. - * - * TanStack Query marks a query cancellable **only when its `queryFn` actually - * reads the `signal` getter off the context**. Not when the option is set, not - * when the fetcher would accept one - when the function reads it. Before Stage - * 14 no query function in VitNode did: every one was written `async () => await - * fetch…` with the context argument dropped, so a superseded sort, a re-typed - * search term or an abandoned page press left its request running to completion - * and landing late on top of the answer somebody was reading. - * - * These are the paged and searched reads where that is worth something. The - * session reads are deliberately not among them - a guard blocks navigation - * while it runs, and there is nothing to supersede it with. - * - * ## What this asserts, and what it deliberately does not - * - * It calls each `queryFn` with a fake context carrying a signal of our own and - * checks the fetcher was handed *that* signal. That is the whole seam: it is - * pure - no React, no router, no HTTP - and it is exactly the link that was - * missing, because everything below it (`fetcherClient` → `coreFetcher` → - * `rawApiFetch` → `fetch`) already threaded `options` through untouched. - * - * `lib/fetcher/raw.test.ts` owns the other end - that `options.signal` reaches - * the real `fetch` - and `./cancellation-degrades.test.ts` owns the rule that - * decides whether any of this is safe: an abort must reject, never resolve. - */ - -/** - * Runs a query definition's own function with a context we control. - * - * The single deliberate cast in this file, and it is confined here: each - * factory's `queryFn` is typed against its own literal key tuple, so calling - * seven of them through one helper needs the context widened once rather than at - * every call. What is being followed is the *signal*, and nothing about that - * depends on the key's type. - */ -const runQueryFn = async ( - options: { queryFn?: unknown }, - context: { pageParam?: unknown; signal: AbortSignal }, -): Promise<unknown> => { - const queryFn = options.queryFn as (context: { - pageParam?: unknown; - signal: AbortSignal; - }) => Promise<unknown>; - - return await queryFn(context); -}; - -const TARGET = { - permissionModule: "post", - pluginId: "@vitnode/blog", -} as const; - -/** - * Each read, reduced to "run its query function and say which signal the fetcher - * saw". The fetchers are spies rather than the real ones, which is what keeps - * this a contract test instead of a network one. - */ -const READS = { - "admin staff list": async (signal: AbortSignal) => { - let seen: AbortSignal | undefined; - const options = adminStaffQueryOptions({ - adminUserId: 1, - fetchPage: async (_type, _params, o) => { - seen = o?.signal; - - return await Promise.resolve({ edges: [], pageInfo: {} } as never); - }, - params: { first: "10" }, - type: "admin", - }); - - await runQueryFn(options, { signal }); - - return seen; - }, - "admin users list": async (signal: AbortSignal) => { - let seen: AbortSignal | undefined; - const options = adminUsersQueryOptions({ - adminUserId: 1, - fetchPage: async (_params, o) => { - seen = o?.signal; - - return await Promise.resolve({ edges: [], pageInfo: {} } as never); - }, - params: { first: "10" }, - }); - - await runQueryFn(options, { signal }); - - return seen; - }, - "content list": async (signal: AbortSignal) => { - let seen: AbortSignal | undefined; - const options = contentListQueryOptions({ - fetchPage: async (_request, o) => { - seen = o?.signal; - - return await Promise.resolve({ edges: [], pageInfo: {} } as never); - }, - request: { - contentTypeId: "blog.post", - query: { first: "25" }, - target: TARGET, - }, - }); - - await runQueryFn(options, { signal }); - - return seen; - }, - "my files": async (signal: AbortSignal) => { - let seen: AbortSignal | undefined; - const options = myFilesQueryOptions({ - fetchPage: async (_params, o) => { - seen = o?.signal; - - return await Promise.resolve({ edges: [], pageInfo: {} } as never); - }, - params: { first: "10" }, - userId: 3, - }); - - await runQueryFn(options, { signal }); - - return seen; - }, - "search feed": async (signal: AbortSignal) => { - let seen: AbortSignal | undefined; - const options = searchFeedQueryOptions({ - fetchPage: async (_args, o) => { - seen = o?.signal; - - return await Promise.resolve({ edges: [], pageInfo: {} } as never); - }, - locale: "en", - params: {}, - }); - - await runQueryFn(options, { pageParam: null, signal }); - - return seen; - }, -} as const; - -describe("a cancellable read hands its signal to its fetcher", () => { - it.each(Object.entries(READS))("%s", async (_name, run) => { - const controller = new AbortController(); - - expect(await run(controller.signal)).toBe(controller.signal); - }); - - /** - * Identity rather than presence, and the distinction matters: a fetcher handed - * *a* signal that is not the query's own would look cancellable and never - * cancel. `toBe` above already pins it; this states the negative directly. - */ - it.each(Object.entries(READS))( - "%s does not substitute a signal of its own", - async (_name, run) => { - const mine = new AbortController(); - const other = new AbortController(); - - expect(await run(mine.signal)).not.toBe(other.signal); - }, - ); - - /** - * And the abort actually reaches the fetcher, rather than a copy that stays - * open. `AbortSignal` has no clone that preserves state, so this is really an - * assertion that the same object crossed the seam - which is what makes - * `fetch` see the abort. - */ - it.each(Object.entries(READS))( - "%s forwards a signal that is already aborted", - async (_name, run) => { - const controller = new AbortController(); - controller.abort(); - - const seen = await run(controller.signal); - - expect(seen?.aborted).toBe(true); - }, - ); -}); - -/** - * The SSR half of the same contract. - * - * Each fetcher's signal argument is **optional**, which is what lets the server - * branch of a `createIsomorphicFn` pair - written with one parameter, and handed - * no signal deliberately - satisfy the same type. A route loader's - * `ensureQueryData` must not pass the router's own abort signal: the router - * cancels preloads aggressively, and a shared cache entry cancelled by an - * abandoned hover would leave the next reader with no data and no error. - */ -describe("the signal argument is optional", () => { - it.each([ - [ - "admin users", - () => - adminUsersQueryOptions({ - adminUserId: 1, - fetchPage: async params => { - expect(params).toBeDefined(); - - return await Promise.resolve({ edges: [], pageInfo: {} } as never); - }, - params: { first: "10" }, - }), - ], - [ - "content list", - () => - contentListQueryOptions({ - fetchPage: async request => { - expect(request).toBeDefined(); - - return await Promise.resolve({ edges: [], pageInfo: {} } as never); - }, - request: { - contentTypeId: "blog.post", - query: {}, - target: TARGET, - }, - }), - ], - [ - "my files", - () => - myFilesQueryOptions({ - fetchPage: async params => { - expect(params).toBeDefined(); - - return await Promise.resolve({ edges: [], pageInfo: {} } as never); - }, - params: {}, - userId: 3, - }), - ], - ])("%s accepts a one-parameter fetcher", async (_name, build) => { - const controller = new AbortController(); - - await expect( - runQueryFn(build(), { signal: controller.signal }), - ).resolves.toBeDefined(); - }); -}); diff --git a/packages/vitnode/src/tanstack/eager-graph.test.ts b/packages/vitnode/src/tanstack/eager-graph.test.ts deleted file mode 100644 index 30007f3aa..000000000 --- a/packages/vitnode/src/tanstack/eager-graph.test.ts +++ /dev/null @@ -1,712 +0,0 @@ -import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; -import { dirname, join, relative, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -/** - * What a route's *loader* is allowed to drag into the client entry. - * - * `routeTree.gen.ts` imports every route file statically, and TanStack Start's - * code splitter moves only the splittable options - `component`, - * `errorComponent`, `notFoundComponent`, `pendingComponent` - into chunks of - * their own. Everything else a route file evaluates at module scope stays in the - * client entry: `loader`, `beforeLoad`, `validateSearch`, `loaderDeps`, `head` - * and `staticData`. - * - * So a namespace that exports `loadAdminUsersRoute` and `AdminUsersRouteContent` - * from *one module* puts the screen in the entry chunk, because the host's route - * file imports the loader from it. Stage 14 measured that: the public root was - * downloading every AdminCP management screen, the Content Engine's admin form - * layer, `react-hook-form`, `cmdk` and `@dnd-kit` before it could paint `/`. - * - * The rule this file enforces is therefore a *file* rule, not a naming - * convention: - * - * route.tsx / *-route.tsx namespaces, permission tuples, the loader - * screen.tsx / *-screen.tsx the component, and everything it renders - * - * A loader module may reach queries, contracts, permissions and intl - the - * things it actually needs - and may not reach a rendered component or the - * libraries only a rendered component needs. Splitting them is what lets the - * bundler drop the screen from the eager graph, and this test is what stops the - * two halves quietly merging again. - * - * ## It is a static test, deliberately - * - * No bundle bytes are asserted anywhere here. A byte budget breaks on a - * dependency bump and says nothing about *why*; this walks the same import graph - * the bundler walks and names the edge that would cost the bytes. See the note - * in `apps/web/src/tests/asset-graph.test.ts`, which asserts the host half from - * the other end: it reads the emitted chunks and fails on a budget. - */ -const here = dirname(fileURLToPath(import.meta.url)); -const src = resolve(here, ".."); - -const SKIP = new Set(["dist", "node_modules"]); - -const walk = (directory: string): string[] => - readdirSync(directory, { withFileTypes: true }).flatMap(entry => { - if (SKIP.has(entry.name)) return []; - const path = join(directory, entry.name); - - return entry.isDirectory() ? walk(path) : [path]; - }); - -/** - * The static, value-level import edges of one module. - * - * `import type` is skipped because it is erased before a bundler ever sees it - - * `verbatimModuleSyntax` is on, so what survives compilation is exactly what is - * not marked `type`. A bare `import "./x"` counts: that is how the Content - * Engine registers its editorial panels, and it is a real edge. - */ -const importsOf = (file: string): string[] => { - const source = readFileSync(file, "utf8"); - const specifiers: string[] = []; - - for (const match of source.matchAll( - /^import\s+([\s\S]*?)from\s+["']([^"']+)["'];?$/gm, - )) { - if (match[1].trimStart().startsWith("type")) continue; - specifiers.push(match[2]); - } - - for (const match of source.matchAll(/^import\s+["']([^"']+)["'];?$/gm)) { - specifiers.push(match[1]); - } - - return specifiers; -}; - -/** `@/x` and `./x` to a file in this package; anything else is a bare package. */ -const resolveSpecifier = (from: string, specifier: string): null | string => { - const base = specifier.startsWith("@/") - ? join(src, specifier.slice(2)) - : specifier.startsWith(".") - ? resolve(dirname(from), specifier) - : null; - - if (base === null) return null; - - for (const candidate of [ - `${base}.ts`, - `${base}.tsx`, - join(base, "index.ts"), - join(base, "index.tsx"), - ]) { - if (existsSync(candidate) && statSync(candidate).isFile()) return candidate; - } - - return null; -}; - -interface Reachable { - /** Files inside this package. */ - files: Set<string>; - /** Bare package specifiers, narrowed to the package name. */ - packages: Set<string>; -} - -const reachableFrom = (entry: string): Reachable => { - const files = new Set([entry]); - const packages = new Set<string>(); - const queue = [entry]; - - for (const file of queue) { - for (const specifier of importsOf(file)) { - const resolved = resolveSpecifier(file, specifier); - - if (resolved === null) { - const segments = specifier.split("/"); - packages.add( - specifier.startsWith("@") - ? segments.slice(0, 2).join("/") - : segments[0], - ); - continue; - } - - if (files.has(resolved)) continue; - files.add(resolved); - // Pushed onto the array being iterated: `for…of` picks up appended - // entries, which is the breadth-first walk without a shift(). - queue.push(resolved); - } - } - - return { files, packages }; -}; - -/** - * Every loader module in the TanStack namespace. - * - * Matched by filename because that is the contract: a host's route file imports - * `loadXRoute` from one of these, so whatever one of these can reach is what - * every page of the application pays for. - */ -const loaderModules = walk(join(src, "tanstack")).filter( - file => - /(?:^|\/)(?:route|[a-z-]+-route)\.tsx$/.test(file) && - !/\.test(?:-d)?\.tsx?$/.test(file), -); - -/** - * Libraries that only a rendered screen needs, as *exact package names*. - * - * Each was measured in the client entry at the start of Stage 14: `cmdk` is the - * AdminCP command palette and `react-hook-form` is the AutoForm stack's core. - * Neither belongs on the path a visitor takes to `/`. - */ -const SCREEN_ONLY_PACKAGES = new Set(["cmdk", "react-hook-form"]); - -/** - * The same rule, for whole scopes - and the reason the two lists are separate. - * - * `reachableFrom` narrows a bare specifier to its package name, which for a - * scoped package is *scope and name* (`@tiptap/react`), never the scope alone. - * These entries used to sit in the list above and be checked with `Set.has`, so - * `has("@tiptap")` was asked of a set containing `@tiptap/react`, `@tiptap/core` - * and `@tiptap/starter-kit` - and answered `false` every time. The rule read as - * though it covered the content editor, the widget grid and the resolver layer, - * and covered none of them. - * - * Splitting them is what makes the distinction visible rather than implied: an - * entry here means "every package in this scope", and an entry above means "this - * package". A scope is written without a trailing slash and matched on the - * boundary, so `@tiptap` cannot also match a hypothetical `@tiptaphelper`. - * - * `@tiptap` is the content editor, `@dnd-kit` is the dashboard's widget grid and - * the form builder's field ordering, `@hookform` is the AutoForm resolver layer. - */ -const SCREEN_ONLY_SCOPES = new Set(["@dnd-kit", "@hookform", "@tiptap"]); - -/** - * Whether one package name is a screen-only library. - * - * Pure, and exported to its own describe block below, because the last version - * of this rule was wrong in a way no loader assertion could reveal: a matcher - * that silently matches nothing passes every "found nothing" test in the file. - */ -const isScreenOnlyPackage = (name: string): boolean => - SCREEN_ONLY_PACKAGES.has(name) || - (name.startsWith("@") && SCREEN_ONLY_SCOPES.has(name.split("/")[0])); - -/** - * The matcher itself, before it is trusted with the graph. - * - * Every assertion in the suite below is a "found nothing" one, and a matcher - * that matches nothing satisfies all of them - which is precisely how the scope - * entries went unchecked. So the matcher is pinned directly, in both directions: - * what it must catch, and what it must not. - */ -describe("the screen-only matcher", () => { - it.each([ - ["@tiptap/react", "@tiptap"], - ["@tiptap/core", "@tiptap"], - ["@tiptap/starter-kit", "@tiptap"], - ["@dnd-kit/core", "@dnd-kit"], - ["@dnd-kit/sortable", "@dnd-kit"], - ["@hookform/resolvers", "@hookform"], - ])("rejects %s by its scope %s", packageName => { - expect(isScreenOnlyPackage(packageName)).toBe(true); - }); - - it.each([["react-hook-form"], ["cmdk"]])( - "rejects %s by exact name", - packageName => { - expect(isScreenOnlyPackage(packageName)).toBe(true); - }, - ); - - /** A scope root on its own, in case a specifier ever arrives narrowed to one. */ - it.each([["@tiptap"], ["@dnd-kit"], ["@hookform"]])( - "rejects the bare scope %s", - scope => { - expect(isScreenOnlyPackage(scope)).toBe(true); - }, - ); - - it.each([ - ["@tanstack/react-router"], - ["@tanstack/react-query"], - ["use-intl"], - ["react"], - ["zod"], - ])("allows %s", packageName => { - expect(isScreenOnlyPackage(packageName)).toBe(false); - }); - - /** - * The boundary a prefix match would get wrong. - * - * `@tiptapx/thing` shares five characters with a forbidden scope and is not in - * it. Matching on the scope segment rather than on `startsWith` is what makes - * that distinction, and it is worth an assertion because the naive spelling is - * the one somebody reaches for when fixing this kind of bug. - */ - it.each([["@tiptapx/thing"], ["@dnd-kitten/core"], ["react-hook-form-x"]])( - "does not reject %s on a partial name match", - packageName => { - expect(isScreenOnlyPackage(packageName)).toBe(false); - }, - ); -}); - -describe("route loader modules", () => { - it("exist, so a rename cannot silently empty this suite", () => { - expect(loaderModules.length).toBeGreaterThan(15); - }); - - /** - * The screen half is `.tsx` under `views/` - the components both AdminCPs - * render. A loader reaching one of those is the exact regression this file - * exists for, and it is always the same mistake: a component moved back - * alongside its loader, or a new namespace was written without the split. - * - * `.ts` under `views/` is deliberately allowed. Queries, permission tuples, - * table contracts and mutation wrappers live there, they are what a loader is - * *for*, and they carry no component tree. - */ - it.each(loaderModules)("%s renders nothing", file => { - const components = [...reachableFrom(file).files] - .filter( - reached => reached.includes(`/views/`) && reached.endsWith(".tsx"), - ) - .map(reached => relative(src, reached)); - - expect(components).toEqual([]); - }); - - /** - * The control for the assertion below. - * - * `expect(offenders).toEqual([])` is satisfied just as well by a walk that - * reaches no packages at all as by one that reaches only permitted ones - and a - * silently empty walk is exactly the failure this file has already had once. - * So: the loaders do reach bare packages, and `@tanstack/react-router` is one - * of them, because every one of these modules is a route. - */ - it("reaches real packages, so the rule below is not vacuous", () => { - const reached = loaderModules.flatMap(file => [ - ...reachableFrom(file).packages, - ]); - - expect(reached).toContain("@tanstack/react-router"); - expect(reached.some(name => name.startsWith("@"))).toBe(true); - }); - - it.each(loaderModules)("%s pulls in no screen-only library", file => { - const { packages } = reachableFrom(file); - - // Filtered from what was *reached* rather than from the forbidden list, so a - // failure names the specifier that is actually in the graph - `@tiptap/react` - // rather than `@tiptap` - which is the thing a reader has to go and remove. - expect([...packages].filter(isScreenOnlyPackage)).toEqual([]); - }); -}); - -/** - * The other half of the same rule. - * - * A `screen.tsx` that nothing imports is dead weight, and a namespace that - * exports its screen from `route.tsx` again would pass the tests above by having - * no screen module at all. This pins the pairing: wherever a screen module - * exists, its barrel is what re-exports it. - */ -describe("screen modules", () => { - const screenModules = walk(join(src, "tanstack")).filter( - file => - /(?:^|\/)(?:screen|[a-z-]+-screen)\.tsx$/.test(file) && - !/\.test(?:-d)?\.tsx?$/.test(file), - ); - - it("exist alongside the loaders they were split from", () => { - expect(screenModules.length).toBeGreaterThan(15); - }); - - it.each(screenModules)("%s is re-exported by its namespace barrel", file => { - const barrel = join(dirname(file), "index.ts"); - - if (!existsSync(barrel)) return; - - const name = relative(dirname(file), file).replace(/\.tsx$/, ""); - - expect(readFileSync(barrel, "utf8")).toContain(`"./${name}"`); - }); -}); - -/** - * Core's own programmatic routes - the other half of the same rule, and the one - * that turned out to matter most. - * - * `withCoreAdminRoutes`, `withCoreMainRoutes` and `withCoreRootRoutes` are - * called from a host's `router.tsx`, which is the module the client entry loads - * before anything else. So *whatever a route module under `tanstack/routes` - * statically imports is downloaded by every page of the application* - the front - * page included, and the front page renders none of it. - * - * Measured on vitnode.com before this rule existed: the homepage's initial graph - * carried 117 module preloads and 1.47 MB of JavaScript, and in it were the - * AdminCP's users, staff, roles, cron, queue, files, debug and integrations - * screens, the Content Engine's admin form layer, AutoForm, `react-hook-form`, - * `cmdk` and the settings panels - because each route module imported its - * screen and its loader from the same namespace barrel, beside the - * `validateSearch` the router genuinely needs before it can match a path. - * - * The split these assertions enforce: - * - * eager path, id, validateSearch, loaderDeps, beforeLoad, loader, head, - * staticData - everything the router consults before it renders - * lazy `component`, `notFoundComponent` and `errorComponent`, each - * behind `lazyRouteComponent` over a literal `import()` the bundler - * can follow - * eager `pendingComponent` - a loading state that has to fetch a chunk - * before it can appear is not a loading state, and the module it - * names is already in the client entry as the router's - * `defaultPendingComponent`, so naming it costs no bytes - * - * A breadcrumb is deliberately on the eager side: `staticData.breadcrumb` is an - * *element*, built when the route tree is composed, so its component has to be - * in hand. That is why the rule below is "reaches no screen module" rather than - * "reaches nothing under `views/`" - a crumb is a view, and a small one. - */ -describe("core route composers", () => { - const PENDING_SHAPES = new Set([ - "AuthPendingSkeleton", - "BreadcrumbPendingSkeleton", - "CardsPendingSkeleton", - "FeedPendingSkeleton", - "FormPendingSkeleton", - "RoutePendingSkeleton", - "TablePendingSkeleton", - ]); - - it("knows every shape the pending module exports", () => { - const barrel = readFileSync( - join(src, "tanstack", "pending", "index.ts"), - "utf8", - ); - const exported = new Set( - [...barrel.matchAll(/\b(\w*PendingSkeleton)\b/g)].map(match => match[1]), - ); - - expect([...exported].sort()).toEqual([...PENDING_SHAPES].sort()); - }); - - const routeModules = walk(join(src, "tanstack", "routes")).filter( - file => file.endsWith(".tsx") && !/\.test\.tsx?$/.test(file), - ); - - it("exist, so a rename cannot silently empty this suite", () => { - expect(routeModules.length).toBeGreaterThan(7); - }); - - /** - * The screen modules are the ones `../eager-graph.test.ts`'s other half - * already names - `screen.tsx` and `*-screen.tsx` - so there is one spelling - * of "this is a rendered page" in the package rather than two. - */ - const isScreenModule = (file: string): boolean => - /(?:^|\/)(?:screen|[a-z-]+-screen)\.tsx$/.test(file); - - it.each(routeModules)("%s statically reaches no screen module", file => { - const screens = [...reachableFrom(file).files] - .filter(isScreenModule) - .map(reached => relative(src, reached)); - - expect(screens).toEqual([]); - }); - - it.each(routeModules)("%s pulls in no screen-only library", file => { - const { packages } = reachableFrom(file); - - expect([...packages].filter(isScreenOnlyPackage)).toEqual([]); - }); - - /** - * The control, for the same reason the loader suite has one: every assertion - * above is a "found nothing" assertion, and a walk that reached nothing would - * satisfy all of them. - */ - it("reaches real modules, so the rules above are not vacuous", () => { - const reached = routeModules.flatMap(file => [ - ...reachableFrom(file).files, - ]); - - expect(reached.length).toBeGreaterThan(routeModules.length); - expect( - routeModules.flatMap(file => [...reachableFrom(file).packages]), - ).toContain("@tanstack/react-router"); - }); - - /** - * And the screens are still rendered - through the one API that code-splits - * them. - * - * A route module that names a `component` at all must reach it through - * `lazyRouteComponent`, and the module it loads must be a literal `import()` - * so that Rollup can follow it into a chunk. A computed specifier compiles and - * then resolves to nothing in a production build. - * - * `pendingComponent` is deliberately absent from the list: it is held to the - * opposite rule, two assertions below. - * - * `GuardedOutlet` is the one allowed eager `component`, and for that same - * opposite reason. It is not a screen - it is an `<Outlet />` wearing the - * destination's pending shape while a guard runs - so a lazy one would be a - * loading state that first has to download a chunk before it can say anything, - * which is the thing a loading state exists to avoid. - */ - it.each(routeModules)("%s renders every screen lazily", file => { - const source = readFileSync(file, "utf8"); - const eager = [ - ...source.matchAll( - /\b(component|notFoundComponent|errorComponent):\s*(\S+)/g, - ), - ] - .filter( - ([, , value]) => - !value.startsWith("lazyRouteComponent(") && - value !== "GuardedOutlet,", - ) - .map(([, option, value]) => `${option}: ${value}`); - - expect(eager).toEqual([]); - }); - - /** - * The control for the rule above: these routes do declare components, so - * "none of them is eager" is a statement about something rather than about an - * empty list. - */ - it("declares components at all, so the rule above is not vacuous", () => { - const declared = routeModules.flatMap(file => [ - ...readFileSync(file, "utf8").matchAll( - /\b(component|notFoundComponent|errorComponent):/g, - ), - ]); - - expect(declared.length).toBeGreaterThan(15); - }); - - /** - * One `pendingComponent` declaration, from its keyword to the comma that ends - * it - never spilling into the option that follows, which is what makes the - * assertions below about the declaration rather than about its neighbours. - */ - const pendingDeclarations = (source: string): string[] => { - const lines = source.split("\n"); - const declarations: string[] = []; - - for (const [index, line] of lines.entries()) { - if (!line.includes("pendingComponent:")) continue; - - const collected = [line]; - let next = index + 1; - - while ( - next < lines.length && - !(collected.at(-1) ?? "").trimEnd().endsWith(",") - ) { - collected.push(lines[next]); - next++; - } - - declarations.push(collected.join("\n")); - } - - return declarations; - }; - - it.each(routeModules)( - "%s names a pending shape rather than a chunk to go and fetch", - file => { - const declarations = pendingDeclarations(readFileSync(file, "utf8")); - - for (const declaration of declarations) { - expect(declaration).not.toContain("lazyRouteComponent("); - expect( - [...PENDING_SHAPES].filter(name => declaration.includes(name)), - ).not.toEqual([]); - } - }, - ); - - /** - * The control: the scan finds declarations at all, and the one shape used most - * is among them - so "none of them is lazy" is a statement about something. - */ - it("finds the pending declarations it is asserting about", () => { - const declarations = routeModules.flatMap(file => - pendingDeclarations(readFileSync(file, "utf8")), - ); - - expect(declarations.length).toBeGreaterThan(15); - expect( - declarations.filter(declaration => - declaration.includes("TablePendingSkeleton"), - ).length, - ).toBeGreaterThan(0); - }); - - it.each(routeModules)( - "%s takes its pending shapes from the one module that holds them", - file => { - const source = readFileSync(file, "utf8"); - const mentioned = new Set( - [...source.matchAll(/\b(\w*PendingSkeleton)\b/g)].map( - match => match[1], - ), - ); - - if (mentioned.size === 0) return; - - expect([...mentioned].filter(name => !PENDING_SHAPES.has(name))).toEqual( - [], - ); - expect(source).toContain(`from "../../pending"`); - }, - ); - - it.each(routeModules)( - "%s pairs every blocking loader with a pending shape of its own", - file => { - const source = readFileSync(file, "utf8"); - const loaders = [...source.matchAll(/^\s*loader: /gm)].length; - const pending = [...source.matchAll(/^\s*pendingComponent: /gm)].length; - - expect(pending).toBe(loaders); - }, - ); - - it("imports its screens with specifiers a bundler can follow", () => { - const dynamic = routeModules.flatMap(file => - [...readFileSync(file, "utf8").matchAll(/\bimport\(([^)]*)\)/g)].map( - match => match[1].trim(), - ), - ); - - expect(dynamic.length).toBeGreaterThan(15); - for (const specifier of dynamic) { - expect(specifier).toMatch(/^"[^"$`]+"$/); - } - }); -}); - -describe("the router's default pending component", () => { - const directory = join(src, "tanstack", "pending"); - const barrel = join(directory, "index.ts"); - const leaves = [ - join(directory, "route-pending-skeleton.tsx"), - join(directory, "shapes.tsx"), - ]; - const routerOnlyLeaves = [join(directory, "guard-pending.tsx")]; - - it("is where a host's router.tsx imports it from", () => { - expect(existsSync(barrel)).toBe(true); - }); - - it("re-exports nothing but its own three leaf modules", () => { - const specifiers = [ - ...readFileSync(barrel, "utf8").matchAll(/from\s+"([^"]+)"/g), - ].map(match => match[1]); - - expect([...new Set(specifiers)].sort()).toEqual([ - "./guard-pending", - "./route-pending-skeleton", - "./shapes", - ]); - }); - - /** - * The bar covering the gap a `pendingComponent` cannot - a retained parent's - * `beforeLoad` - is subject to the same rule as the shapes, and one more: - * it may reach the router, because the router is what tells it a navigation - * has started, and no other package at all. A navigation indicator that - * pulled an animation library into the client entry would cost every visitor - * the whole of it before the first paint, to draw two pixels. - */ - it.each(routerOnlyLeaves)("%s reaches the router and nothing else", file => { - const graph = reachableFrom(file); - - expect( - [...graph.files] - .map(reached => relative(src, reached)) - .filter(reached => !reached.startsWith(join("tanstack", "pending"))) - .sort(), - ).toEqual([]); - expect( - [...graph.packages] - .filter(name => name !== "clsx" && name !== "tailwind-merge") - .sort(), - ).toEqual(["@tanstack/react-router", "react"]); - }); - - it.each(leaves)( - "%s reaches nothing but the two primitives and the class-name helper", - file => { - const outside = [...reachableFrom(file).files] - .map(reached => relative(src, reached)) - .filter(reached => !reached.startsWith(join("tanstack", "pending"))) - .sort(); - - expect(outside).toEqual([ - "components/ui/skeleton.tsx", - "components/ui/spinner.tsx", - "lib/utils.ts", - ]); - }, - ); - - /** - * `lucide-react` is the spinner's icon, and it is on this list because the - * client entry already downloads it - the header, the breadcrumb and every - * button reach it. A loading state that pulled in an icon set of its own - * would not be allowed here. - */ - it.each(leaves)("%s reaches only the packages those three need", file => { - expect([...reachableFrom(file).packages].sort()).toEqual([ - "clsx", - "lucide-react", - "tailwind-merge", - ]); - }); - - it.each(leaves)("%s pulls in no screen-only library", file => { - expect( - [...reachableFrom(file).packages].filter(isScreenOnlyPackage), - ).toEqual([]); - }); - - it.each(leaves)( - "%s statically reaches no screen module and no view", - file => { - const offenders = [...reachableFrom(file).files] - .filter( - reached => - /(?:^|\/)(?:screen|[a-z-]+-screen)\.tsx$/.test(reached) || - reached.includes(`/views/`), - ) - .map(reached => relative(src, reached)); - - expect(offenders).toEqual([]); - }, - ); - - it("gives core's own routes more than one shape between them", () => { - const referenced = new Set( - walk(join(src, "tanstack", "routes")) - .filter(file => file.endsWith(".tsx") && !/\.test\.tsx?$/.test(file)) - .flatMap(file => - [ - ...readFileSync(file, "utf8").matchAll(/\b(\w*PendingSkeleton)\b/g), - ].map(match => match[1]), - ), - ); - - const exported = readFileSync(barrel, "utf8"); - - expect(referenced.size).toBeGreaterThan(3); - for (const name of referenced) expect(exported).toContain(name); - }); -}); diff --git a/packages/vitnode/src/tanstack/fetcher/server.test.ts b/packages/vitnode/src/tanstack/fetcher/server.test.ts index 5e512719e..5136f9b91 100644 --- a/packages/vitnode/src/tanstack/fetcher/server.test.ts +++ b/packages/vitnode/src/tanstack/fetcher/server.test.ts @@ -36,6 +36,8 @@ const lastCall = () => { beforeEach(() => { apiFetch.mockReset(); setCookie.mockReset(); + // The default topology: the app serves its own API, so nothing names one. + vi.stubEnv("NEXT_PUBLIC_API_URL", undefined); requestUrl = "https://preview.example.com/login"; requestHeaders.set("cookie", "vitnode_auth=abc"); requestHeaders.set("user-agent", "Mozilla/5.0"); @@ -83,7 +85,24 @@ describe("the request the visitor made is the request the API sees", () => { expect(lastCall().url.pathname).toBe("/api/@vitnode/core/users/session"); }); + it("calls a configured separate API server instead of itself", async () => { + // `create-vitnode` scaffolds `apps/web` on `:3000` and `apps/api` on + // `:8000`, so this request's own origin has no `/api/*` to answer. + vi.stubEnv("NEXT_PUBLIC_API_URL", "http://localhost:8000"); + + await fetcher(usersModule, { + method: "get", + module: "users", + path: "/session", + }); + + expect(lastCall().url.origin).toBe("http://localhost:8000"); + expect(lastCall().url.pathname).toBe("/api/@vitnode/core/users/session"); + }); + it("lets an explicit origin win", async () => { + vi.stubEnv("NEXT_PUBLIC_API_URL", "http://localhost:8000"); + await fetcher(usersModule, { method: "get", module: "users", @@ -238,4 +257,17 @@ describe("outside a request", () => { expect(lastCall().url.origin).not.toBe("https://preview.example.com"); }); + + it("uses a configured API server", async () => { + requestUrl = null; + vi.stubEnv("NEXT_PUBLIC_API_URL", "https://api.example.com"); + + await fetcher(usersModule, { + method: "get", + module: "users", + path: "/session", + }); + + expect(lastCall().url.origin).toBe("https://api.example.com"); + }); }); diff --git a/packages/vitnode/src/tanstack/fetcher/server.ts b/packages/vitnode/src/tanstack/fetcher/server.ts index d25c013ca..935acceb3 100644 --- a/packages/vitnode/src/tanstack/fetcher/server.ts +++ b/packages/vitnode/src/tanstack/fetcher/server.ts @@ -48,12 +48,20 @@ config({ quiet: true }); /** * The origin to call `/api/*` on. * - * A TanStack Start app *serves* the API, so the answer is not configuration - it - * is whichever origin the request being rendered arrived on. Taking it from the - * request is what makes a preview deployment work: its hostname is generated per - * branch, so no `NEXT_PUBLIC_API_URL` could name it, and a hard-coded default - * names a completely different app in development or nothing at all in - * production. + * Two topologies, and the answer differs: + * + * - **The app serves its own API.** Nothing is configured, and the origin is + * whichever one the request being rendered arrived on. Taking it from the + * request is what makes a preview deployment work: its hostname is generated + * per branch, so no `NEXT_PUBLIC_API_URL` could name it, and a hard-coded + * default names a completely different app in development or nothing at all + * in production. + * - **A separate API server.** `NEXT_PUBLIC_API_URL` names it, and it wins - + * the request origin is this app's own, where there is no `/api/*` to answer, + * so preferring it makes every server-side call a `404`. That is the shape + * `create-vitnode` scaffolds (`apps/web` on `:3000`, `apps/api` on `:8000`), + * and the browser already reads the same variable through `CONFIG.api`, so + * this is also what keeps the two halves of a render calling the same host. * * `getRequestUrl()` reads the `Host` header the request arrived with and honours * `x-forwarded-proto`, so a TLS-terminating proxy in front of a plain-HTTP @@ -63,7 +71,7 @@ config({ quiet: true }); * calls at a host of the caller's choosing. * * Outside a request - boot, a script, a cron job - there is nothing to read and - * `getRequestUrl()` throws, so `NEXT_PUBLIC_API_URL` remains the fallback. + * `getRequestUrl()` throws, so `CONFIG.api` is the fallback there too. * * The browser reaches the same conclusion on its own: with nothing configured, * `CONFIG.api` reads the origin the document was served from, so a client-side @@ -71,6 +79,10 @@ config({ quiet: true }); * than load-bearing - set it only to point at a separate API server. */ export const resolveApiOrigin = (): string => { + // Through `CONFIG` rather than the variable directly, so the empty-value + // throw stays in one place - see the `??` note there. + if (process.env.NEXT_PUBLIC_API_URL !== undefined) return CONFIG.api.origin; + try { return getRequestUrl().origin; } catch { @@ -238,8 +250,9 @@ export async function fetcher< ...getForwardedApiHeaders({ captchaToken }), ...additionalHeaders, }, - // Same-origin by construction, and ahead of `NEXT_PUBLIC_API_URL` - which - // an explicit `origin` on the call can still override. + // `NEXT_PUBLIC_API_URL` when a separate API server is configured, this + // request's own origin otherwise - and an explicit `origin` on the call + // overrides both. origin: origin ?? resolveApiOrigin(), } as FetcherParams<M, Routes, Modules, ModuleName, SelectedPath, Method> & FetcherRequestOptions); diff --git a/packages/vitnode/src/tanstack/route-freshness.test.ts b/packages/vitnode/src/tanstack/route-freshness.test.ts deleted file mode 100644 index f7a2a5d64..000000000 --- a/packages/vitnode/src/tanstack/route-freshness.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { readFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -import { - OPERATIONAL_STALE_TIME, - RECORD_STALE_TIME, -} from "@/lib/query-freshness"; -import { middlewareConfigQueryOptions } from "@/tanstack/auth/middleware-config"; -import { intlQueryOptions } from "@/tanstack/i18n/query"; -import { cronQueryOptions } from "@/views/admin/views/core/advanced/cron/cron-query"; -import { queueQueryOptions } from "@/views/admin/views/core/advanced/queue/queue-query"; -import { searchIndexQueryOptions } from "@/views/admin/views/core/advanced/search/search-index-query"; -import { dashboardLayoutQueryOptions } from "@/views/admin/views/core/dashboard/widgets/layout-query"; -import { - debugLogsQueryOptions, - debugQueueQueryOptions, -} from "@/views/admin/views/core/debug/debug-query"; -import { adminStaffQueryOptions } from "@/views/admin/views/core/staff/staff-query"; -import { adminFilesQueryOptions } from "@/views/admin/views/core/system/files/files-query"; -import { integrationsQueryOptions } from "@/views/admin/views/core/system/integrations/integrations-query"; -import { adminUserQueryOptions } from "@/views/admin/views/core/users/detail/user-query"; -import { adminUsersQueryOptions } from "@/views/admin/views/core/users/list/users-query"; -import { adminRolesQueryOptions } from "@/views/admin/views/core/users/roles/roles-query"; -import { devicesQueryOptions } from "@/views/auth/settings/devices/devices-query"; -import { myFilesQueryOptions } from "@/views/files/my-files-query"; -import { searchFeedQueryOptions } from "@/views/search/search-feed-query"; - -import { ADMIN_SESSION_QUERY_KEY } from "./admin/state"; - -/** - * What a route promises about the age of what it shows. - * - * Route loaders read through `ensureQueryData`, which hands back whatever is - * cached the moment anything is cached. With `refetchOnMount` and - * `refetchOnWindowFocus` both off - and they stay off - that made a revisited - * route show the first visit's data for as long as the tab lived. The contract - * that fixes it has two halves in two places, so it has one test: - * - * the query family declares how long its answer stays good - * the route loader declares what to do once it is not - * - * Both halves are asserted here because either alone is silently useless: a - * `staleTime` with no `revalidateIfStale` never refreshes, and a - * `revalidateIfStale` with no `staleTime` refreshes on every hover. - */ -const here = dirname(fileURLToPath(import.meta.url)); -const src = resolve(here, ".."); - -const PARAMS = { first: "10" } as const; - -describe("the freshness windows", () => { - it("are ordered: data that moves on its own is checked sooner", () => { - expect(OPERATIONAL_STALE_TIME).toBeLessThan(RECORD_STALE_TIME); - }); - - it("are real windows, not zero and not forever", () => { - for (const window of [OPERATIONAL_STALE_TIME, RECORD_STALE_TIME]) { - expect(window).toBeGreaterThan(0); - expect(Number.isFinite(window)).toBe(true); - } - }); - - /** - * Long enough that crossing a sidebar costs nothing. - * - * `defaultPreload: 'intent'` runs a loader on hover, so a window shorter than - * the time a pointer takes to cross a list of links would put a background - * request behind each one - the hover storm, arrived at from the other side. - */ - it("are longer than a pointer takes to cross a sidebar", () => { - expect(OPERATIONAL_STALE_TIME).toBeGreaterThanOrEqual(5_000); - }); -}); - -describe("data that moves on its own takes the operational window", () => { - it.each([ - ["cron", cronQueryOptions({ params: PARAMS })], - ["queue", queueQueryOptions({ params: PARAMS })], - ["search index", searchIndexQueryOptions()], - ["debug logs", debugLogsQueryOptions({ params: PARAMS })], - ["debug queue", debugQueueQueryOptions()], - ])("%s", (_name, options) => { - expect(options.staleTime).toBe(OPERATIONAL_STALE_TIME); - }); -}); - -describe("data a person edits takes the record window", () => { - it.each([ - ["admin users", adminUsersQueryOptions({ adminUserId: 7, params: PARAMS })], - ["admin user detail", adminUserQueryOptions({ adminUserId: 7, id: "1" })], - ["admin roles", adminRolesQueryOptions({ adminUserId: 7, params: PARAMS })], - [ - "admin staff", - adminStaffQueryOptions({ - adminUserId: 7, - params: PARAMS, - type: "admin", - }), - ], - ["admin files", adminFilesQueryOptions({ params: PARAMS })], - ["integrations", integrationsQueryOptions()], - ["dashboard layout", dashboardLayoutQueryOptions({ adminUserId: 7 })], - ["my files", myFilesQueryOptions({ params: PARAMS, userId: 1 })], - ["devices", devicesQueryOptions({ userId: 1 })], - [ - "search feed", - searchFeedQueryOptions({ locale: "en", params: { search: "x" } }), - ], - ])("%s", (_name, options) => { - expect(options.staleTime).toBe(RECORD_STALE_TIME); - }); -}); - -/** - * The families deliberately left alone, asserted so nobody "finishes the job". - * - * Each of these is stable for a reason that has nothing to do with revisits, and - * giving them a revalidation window would spend requests refetching data that - * has not changed. - */ -describe("stable data keeps its own longer-lived policy", () => { - it("message catalogues change when the app is redeployed, not on revisit", () => { - expect(intlQueryOptions({ locale: "en" }).staleTime).toBe(Infinity); - }); - - it("the middleware config keeps its own five minutes", () => { - const { staleTime } = middlewareConfigQueryOptions(); - - expect(staleTime).toBeGreaterThan(RECORD_STALE_TIME); - }); -}); - -/** - * The other half: the loaders that actually ask for the refresh. - * - * A source scan, because what is being pinned is that the call site opted in - - * `revalidateIfStale` is an `ensureQueryData` option, not a query option, so it - * cannot be read off the options object the way the windows above can. - */ -describe("route loaders ask for the refresh", () => { - const sourceOf = (file: string) => readFileSync(join(src, file), "utf8"); - - it.each([ - ["tanstack/admin/cron/route.tsx"], - ["tanstack/admin/queue/route.tsx"], - ["tanstack/admin/files/route.tsx"], - ["tanstack/admin/integrations/route.tsx"], - ["tanstack/admin/search-index/route.tsx"], - ["tanstack/admin/debug/route.tsx"], - ["tanstack/admin/users/route.tsx"], - ["tanstack/admin/users/detail-route.tsx"], - ["tanstack/admin/roles/route.tsx"], - ["tanstack/admin/staff/route.tsx"], - ["tanstack/admin/staff/edit-route.tsx"], - ["tanstack/admin/dashboard/route.tsx"], - ["tanstack/admin/content/route.tsx"], - ["tanstack/admin/content/form/route.tsx"], - ["tanstack/files/route.tsx"], - ["tanstack/search/search-route.tsx"], - ["tanstack/search/discover-route.tsx"], - ])("%s revalidates what it reads", file => { - expect(sourceOf(file)).toContain("revalidateIfStale: true"); - }); - - /** - * And the guard does not. - * - * `_admin`'s session read is the one entry in VitNode that may never be - * answered from memory: its `staleTime` is `0` as a revocation guarantee, and - * a stale-while-revalidate read there would hand a guard the previous - * decision while it checked. It is not a revisit-freshness problem and must - * not be given a revisit-freshness fix. - */ - it("except the admin session, which may not be answered from cache at all", () => { - const guard = readFileSync( - resolve(src, "../../../apps/web/src/routes/_admin.tsx"), - "utf8", - ); - - expect(guard).not.toContain("revalidateIfStale"); - expect(ADMIN_SESSION_QUERY_KEY).toEqual(["vitnode", "admin-session"]); - }); -}); diff --git a/packages/vitnode/src/tanstack/routes/index.ts b/packages/vitnode/src/tanstack/routes/index.ts index c693069a6..a1348ad41 100644 --- a/packages/vitnode/src/tanstack/routes/index.ts +++ b/packages/vitnode/src/tanstack/routes/index.ts @@ -12,14 +12,21 @@ * * ## Three folders, one per mount point * - * main/ under the application's main shell /discover, /search, /files, /settings + * main/ under the application's main shell /discover, /login, /register, /settings * admin/ under the AdminCP shell /admin/core/…, /admin/content/* - * root/ under no shell at all /login, /register, /admin + * root/ under no shell at all /admin * * `main` and `admin` are named after the shell they hang from, so `root` is - * named after its mount point too: these screens are children of the root route, - * with nothing between. An auth card is the whole page, and the AdminCP's own - * sign-in has to sit *outside* the AdminCP shell or its guard would loop. + * named after its mount point too: its screens are children of the root route, + * with nothing between. + * + * Exactly one screen is in `root/`, and the exception is what the folder is for: + * the AdminCP's own sign-in has to sit *outside* the AdminCP shell or that + * shell's guard would loop. The public auth screens are not exceptions - an auth + * card is a page on the public site, so `/login`, `/register`, + * `/login/reset-password` and `/login/sso/:providerId` render inside the main + * shell with the site's header above them, and so does the 404 that `main/` + * declares for every URL no route claims. * * ## What replaced what * @@ -63,6 +70,8 @@ export { CORE_ROOT_ROUTES_ROUTE_ID, withCoreRootRoutes } from "./root"; export type { CoreRootRouteContext, CoreRootRouteFactory } from "./root/types"; export type { CoreAdminRouteContext, + CoreAuthRouteContext, + CoreAuthRouteFactory, CorePageHead, CoreRouteContext, CoreRouteFactory, diff --git a/packages/vitnode/src/tanstack/routes/root/auth.tsx b/packages/vitnode/src/tanstack/routes/main/auth.tsx similarity index 97% rename from packages/vitnode/src/tanstack/routes/root/auth.tsx rename to packages/vitnode/src/tanstack/routes/main/auth.tsx index 6830cd43d..c5d3349de 100644 --- a/packages/vitnode/src/tanstack/routes/root/auth.tsx +++ b/packages/vitnode/src/tanstack/routes/main/auth.tsx @@ -5,7 +5,7 @@ import { redirect, } from "@tanstack/react-router"; -import type { CoreRootRouteFactory } from "./types"; +import type { CoreAuthRouteFactory } from "../types"; import { loadLoginRoute } from "../../auth/login-route"; import { middlewareConfigQueryOptions } from "../../auth/middleware-config"; @@ -55,7 +55,7 @@ import { routeContext, routeSearch } from "../types"; * rejection propagates: only a session the API actually answered can send * anybody anywhere. */ -const loginRoute: CoreRootRouteFactory = ({ +const loginRoute: CoreAuthRouteFactory = ({ localeRouting, pageHead, parentRoute, @@ -139,7 +139,7 @@ const loginRoute: CoreRootRouteFactory = ({ * `buildLocation` and the locale rewrite writes the prefix back - a Polish * visitor is sent to `/pl`, not to `/`. */ -const registerRoute: CoreRootRouteFactory = ({ +const registerRoute: CoreAuthRouteFactory = ({ localeRouting, pageHead, parentRoute, @@ -219,7 +219,7 @@ const registerRoute: CoreRootRouteFactory = ({ * code-based route needs no such escape: it is a sibling because it is declared * as one, and the path says the rest. */ -const passwordResetRoute: CoreRootRouteFactory = ({ +const passwordResetRoute: CoreAuthRouteFactory = ({ pageHead, parentRoute, }) => { @@ -304,7 +304,7 @@ const passwordResetRoute: CoreRootRouteFactory = ({ }; /** The three public auth screens. */ -export const coreAuthRoutes: CoreRootRouteFactory[] = [ +export const coreAuthRoutes: CoreAuthRouteFactory[] = [ loginRoute, registerRoute, passwordResetRoute, diff --git a/packages/vitnode/src/tanstack/routes/main/index.tsx b/packages/vitnode/src/tanstack/routes/main/index.tsx index c496073ec..746d3c3c4 100644 --- a/packages/vitnode/src/tanstack/routes/main/index.tsx +++ b/packages/vitnode/src/tanstack/routes/main/index.tsx @@ -2,18 +2,28 @@ import type { AnyRoute } from "@tanstack/react-router"; import { createRoute, redirect } from "@tanstack/react-router"; -import type { CorePageHead, CoreRouteFactory } from "../types"; +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 { notFoundRoute } from "./not-found"; import { settingsRoute } from "./settings"; +import { ssoCallbackRoute } from "./sso"; export type { + CoreAuthRouteContext, + CoreAuthRouteFactory, CorePageHead, CoreRouteContext, CoreRouteFactory, @@ -41,8 +51,30 @@ export const CORE_MAIN_ROUTES_ROUTE_ID = "_core-main"; */ export const CORE_AUTHENTICATED_ROUTES_ROUTE_ID = "_core-authenticated"; -/** Core's public screens. */ -const CORE_PUBLIC_ROUTES: CoreRouteFactory[] = [...coreDiscoveryRoutes]; +/** + * Core's public screens - everything a signed-out visitor may open. + * + * The four auth screens are in this list rather than in `withCoreRootRoutes`, + * and that is the one placement worth reading twice. An auth card is a page on + * the public site: it wants the site header above it (its own layout already + * reserves the `4rem` that header occupies), the same `<main>` landmark, and the + * way back to the front page that the header is. Only the AdminCP's own sign-in + * is genuinely shell-less, because it has to sit outside the AdminCP shell or + * that shell's guard would send a denied visitor into a route that sends them + * back. + * + * `notFoundRoute` is last for the reader rather than for the router - a splat + * ranks below every other segment kind wherever it is declared - and it is here + * for the same reason the auth screens are: a 404 should look like the site it + * could not find a page on. See {@link notFoundRoute} for why an unmatched URL + * needs a *route* rather than a `notFoundComponent` on the shell. + */ +const CORE_PUBLIC_ROUTES: CoreAuthRouteFactory[] = [ + ...coreDiscoveryRoutes, + ...coreAuthRoutes, + ssoCallbackRoute, + notFoundRoute, +]; /** Core's screens that require a signed-in visitor. */ const CORE_AUTHENTICATED_ROUTES: CoreRouteFactory[] = [ @@ -149,10 +181,21 @@ const authenticatedContainer = (parentRoute: AnyRoute): AnyRoute => * application mounts them the way it already mounts a plugin's: * * const routeTree = withCoreMainRoutes(routeTree, { + * localeRouting, * mountUnder: mainShellRoute, * pageHead, * }) * + * ## Why `localeRouting` is injected and `pageHead` is not enough + * + * The auth screens. A sign-in performs a navigation nobody clicked, to a path a + * *visitor* supplied through `?returnTo=`. The route tree carries no locale, so + * what the router is handed must not either - and stripping the prefix means + * knowing which prefixes exist, which is the installation's answer and not this + * package's. See `createAuthNavigation` in `@vitnode/core/tanstack/auth`; the + * app's own `localeRouting` is the same object its router's `rewrite` uses, so + * the strip and the write-back are one rule running in two directions. + * * ## Why not declared as plugin routes * * Because these need options a lazily-imported module cannot provide. @@ -171,6 +214,12 @@ const authenticatedContainer = (parentRoute: AnyRoute): AnyRoute => * `_main.tsx` needs in order to exist: a pathless layout with no file children is * dropped from the generated route tree and collapses to `/`. * + * What it no longer owns is the 404. `__root`'s `notFoundComponent` is still + * declared and still correct as a last resort, but the URL a visitor actually + * mistypes is answered by {@link notFoundRoute} in this container, inside the + * shell - so the screen that says a page is missing is not itself missing the + * site. + * * ## Idempotent, and a good neighbour * * `addChildren` replaces a route's children and mutates in place, so the subtree @@ -181,7 +230,15 @@ const authenticatedContainer = (parentRoute: AnyRoute): AnyRoute => */ export const withCoreMainRoutes = <TRouteTree extends AnyRoute>( routeTree: TRouteTree, - { mountUnder, pageHead }: { mountUnder: AnyRoute; pageHead: CorePageHead }, + { + localeRouting, + mountUnder, + pageHead, + }: { + localeRouting: CoreAuthRouteContext["localeRouting"]; + mountUnder: AnyRoute; + pageHead: CorePageHead; + }, ): TRouteTree => { const mounted: AnyRoute[] = mountUnder.children ?? []; const siblings = mounted.filter( @@ -202,7 +259,7 @@ export const withCoreMainRoutes = <TRouteTree extends AnyRoute>( ); container.addChildren([ ...CORE_PUBLIC_ROUTES.map(build => - build({ pageHead, parentRoute: container }), + build({ localeRouting, pageHead, parentRoute: container }), ), authenticated, ]); diff --git a/packages/vitnode/src/tanstack/routes/main/main-routes.test.ts b/packages/vitnode/src/tanstack/routes/main/main-routes.test.ts new file mode 100644 index 000000000..484418910 --- /dev/null +++ b/packages/vitnode/src/tanstack/routes/main/main-routes.test.ts @@ -0,0 +1,220 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +/** + * The screens `@vitnode/core` mounts under an application's main shell. + * + * Static and pure: this directory is read as the text it is. Whether `/login` + * resolves is a question for a real route tree, and this suite deliberately does + * not build one - what it pins is the shape of what an application composes. + * + * ## The two things that moved here + * + * The four public auth screens, and the 404. Both used to render with no shell + * at all - the auth screens from `root/`, the 404 from `__root`'s + * `notFoundComponent` - and both are pages on the public site: they want the + * header above them, the same `<main>` landmark, and the way back to the front + * page that the header is. + */ + +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 public auth screens under the shell", () => { + for (const path of [ + "/login", + "/login/reset-password", + "/login/sso/$providerId", + "/register", + ]) { + expect(everyRoutePath, path).toContain(path); + } + }); + + /** + * `/admin` is the AdminCP's own sign-in and stays in `root/`. It reads a + * different session under a different cookie, so a site header offering the + * public "sign in" beside it would be one page asking for two unrelated + * logins - and it has to sit outside the AdminCP shell or that shell's guard + * would loop. + */ + it("does not declare the AdminCP's own sign-in", () => { + expect(everyRoutePath).not.toContain("/admin"); + }); + + /** + * `/login/reset-password` is a **sibling** of `/login`, not a child. + * + * The file-based spelling needed `login_.reset-password.tsx` to say so - the + * trailing underscore meaning "do not nest under `/login`". A code-based route + * needs no such escape: it is a sibling because it is declared as one, and the + * path says the rest. What matters either way is that `/login` consumes + * exactly `/login`, so a URL below it that no route declares does not render + * the sign-in card. + */ + it("declares no route nested under another", () => { + for (const path of everyRoutePath) { + const parents = everyRoutePath.filter( + other => other !== path && path.startsWith(`${other}/`), + ); + + // `/login/reset-password` and `/login/sso/$providerId` start with + // `/login/`, and that is a shared *prefix*, not a parent: each is its own + // route with its own full path. + expect( + parents.every(parent => parent === "/login"), + path, + ).toBe(true); + } + }); +}); + +describe("the 404 this container owns", () => { + const notFound = codeOf("not-found.tsx"); + + /** + * A splat, because an unmatched URL has to *match* something before the shell + * above it is matched too. Router core hands back the root route alone when + * nothing matches, so a `notFoundComponent` on the pathless main shell could + * never run - the shell was never in the branch. + */ + it("claims every path no other route does", () => { + expect(everyRoutePath).toContain("/$"); + expect(notFound).toContain('path: "/$"'); + }); + + /** + * And it answers 404 rather than rendering the message with a `200`, which + * would tell a crawler the page exists. `beforeLoad` throws, the screen is + * this route's own `notFoundComponent`, and the router resolves the boundary + * on its server pass before the stream opens. + * + * `beforeLoad` rather than a loader, because a failure there stops the chain + * at this match and leaves every loader above it to run - which is exactly the + * main shell's, and the shell's loader is what warms the entry the header + * reads with `useSuspenseQuery`. + */ + it("answers notFound from beforeLoad rather than rendering a 200", () => { + expect(notFound).toMatch(/beforeLoad:[\s\S]*throw notFound\(\)/); + expect(notFound).not.toMatch(/^\s*loader:/m); + expect(notFound).toContain("notFoundComponent:"); + }); + + /** Nothing should index a page that does not exist. */ + it("tells crawlers to index nothing", () => { + expect(notFound).toContain('robots: "noindex, nofollow"'); + }); +}); + +describe("how these reach an application", () => { + const index = codeOf("index.tsx"); + + /** + * Three injected bindings now, and the third arrived with the auth screens: a + * sign-in navigates to a path a *visitor* supplied through `?returnTo=`, the + * route tree carries no locale, and which prefixes exist is the + * installation's answer. + */ + it("takes the host's locale rule as well as its page head", () => { + expect(index).toContain("export const withCoreMainRoutes"); + expect(index).toMatch(/localeRouting/); + expect(index).toMatch(/pageHead/); + expect(index).toMatch(/mountUnder/); + }); + + /** + * And it builds the navigation from that rule rather than carrying its own + * copy of the locale-stripping - `createAuthNavigation` is the one + * implementation, and an application's own binding uses the same factory. + */ + it("builds its navigation from the injected rule", () => { + const auth = codeOf("auth.tsx"); + + expect(auth).toContain("createAuthNavigation({"); + expect(auth).toContain("localeRouting"); + // No second copy of the rule: no route here strips a prefix by hand. + 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_MAIN_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 guards these screens carry", () => { + const auth = codeOf("auth.tsx"); + + /** + * One predicate for "signed in", used by both guest routes. + * + * There must not be a second, so "signed in" cannot come to mean two different + * things on two pages. + */ + it("decides guest-only through one shared predicate", () => { + expect(auth.match(/canAccessGuestRoute/g)?.length).toBe(3); + }); + + /** + * A redirect carries `to`, never `href`. A redirect with `href` is used + * verbatim by `Router.resolveRedirect` - it never reaches `buildLocation`, so + * it would skip the locale rewrite and drop a Polish visitor on the English + * page. + */ + it("never redirects by href", () => { + for (const name of modules) { + expect(codeOf(name), name).not.toMatch(/redirect\(\{[^}]*href:/); + } + }); +}); + +describe("the shell renders the landmark, not the screen", () => { + /** + * There is exactly one `<main>` per document, and it belongs to + * `ThemeLayoutContent`. Each auth screen used to render its own, which was + * correct while it had no shell above it and is two landmarks now - invalid + * HTML, and a screen reader with two "main" regions to choose from. + */ + const screens = [ + "login-screen.tsx", + "recovery-screen.tsx", + "register-screen.tsx", + "sso-screen.tsx", + ]; + + it.each(screens)("%s renders no landmark of its own", name => { + const screen = readFileSync(join(here, "..", "..", "auth", name), "utf8"); + + expect(screen).not.toContain("<main>"); + }); +}); diff --git a/packages/vitnode/src/tanstack/routes/main/not-found-matching.test.ts b/packages/vitnode/src/tanstack/routes/main/not-found-matching.test.ts new file mode 100644 index 000000000..69fa2d8cf --- /dev/null +++ b/packages/vitnode/src/tanstack/routes/main/not-found-matching.test.ts @@ -0,0 +1,154 @@ +import { + createRootRoute, + createRoute, + createRouter, + Outlet, +} from "@tanstack/react-router"; +import { describe, expect, it } from "vitest"; + +import { withCoreMainRoutes } from "."; +import { routeHead } from "../../metadata"; + +/** + * The claim `not-found.tsx` rests on, checked against a real router. + * + * The reasoning is subtle enough to be worth a machine's opinion rather than a + * comment's: a URL nothing matches makes router core hand back the **root route + * alone**, so a `notFoundComponent` on a pathless shell the URL never reached + * cannot be the boundary - and a splat *under* that shell is what puts the shell + * back in the branch, header and `<main>` included. + * + * The tree is the shape every VitNode host composes - a root, a pathless `_main` + * with one file-based child, and `withCoreMainRoutes` over it - built here so the + * assertions are about the composition rather than about one application's + * routes. + */ + +const localeRouting = { deLocalizeUrl: (url: URL) => url }; +const pageHead = () => routeHead({ shortTitle: "VitNode", title: "VitNode" }); + +const buildRouter = ({ withCoreRoutes }: { withCoreRoutes: boolean }) => { + const rootRoute = createRootRoute(); + const mainShell = createRoute({ + component: Outlet, + getParentRoute: () => rootRoute, + id: "_main", + }); + const home = createRoute({ getParentRoute: () => mainShell, path: "/" }); + + /* + The branches an application keeps beside the main shell: the API's server + route, the documentation splat, and the AdminCP behind its own pathless + guard. They are here because they are what a root-level catch-all could + plausibly swallow. + */ + const api = createRoute({ getParentRoute: () => rootRoute, path: "/api/$" }); + const docsIndex = createRoute({ + getParentRoute: () => rootRoute, + path: "/docs/", + }); + const docsPage = createRoute({ + getParentRoute: () => rootRoute, + path: "/docs/$", + }); + const adminShell = createRoute({ + component: Outlet, + getParentRoute: () => rootRoute, + id: "_admin", + }); + const adminCore = createRoute({ + getParentRoute: () => adminShell, + path: "/admin/core/", + }); + + adminShell.addChildren([adminCore]); + mainShell.addChildren([home]); + rootRoute.addChildren([mainShell, adminShell, api, docsIndex, docsPage]); + + if (withCoreRoutes) { + withCoreMainRoutes(rootRoute, { + localeRouting, + mountUnder: mainShell, + pageHead, + }); + } + + return createRouter({ routeTree: rootRoute }); +}; + +/** The ids of every route a path matches, outermost first. */ +const branchFor = ( + router: ReturnType<typeof buildRouter>, + pathname: string, +): string[] => + router.getMatchedRoutes(pathname)[0].map((route: { id: string }) => route.id); + +describe("an unmatched URL, without the catch-all", () => { + const router = buildRouter({ withCoreRoutes: false }); + + /** + * The whole reason the 404 is a route. The shell is not in the branch, so + * nothing mounted under it - a `notFoundComponent`, a header, the `<main>` + * landmark - can be reached for this URL. + */ + it("matches the root route and nothing else", () => { + expect(branchFor(router, "/blahblah")).toEqual(["__root__"]); + }); +}); + +describe("an unmatched URL, with it", () => { + const router = buildRouter({ withCoreRoutes: true }); + + it("matches the shell, so the header and <main> render around it", () => { + const branch = branchFor(router, "/blahblah"); + + expect(branch[0]).toBe("__root__"); + expect(branch).toContain("/_main"); + expect(branch.at(-1)).toContain("$"); + }); + + /** Including a path several segments deep, and an admin URL no route claims. */ + it.each(["/blahblah", "/one/two/three", "/admin/typo-not-real"])( + "catches %s", + pathname => { + expect(branchFor(router, pathname)).toContain("/_main"); + }, + ); + + /** + * And it shadows nothing. A splat is the lowest-ranked segment kind in router + * core's matcher, so every screen that declares a path still wins - which is + * what makes mounting it beside them safe. + */ + it.each([ + ["/", "/"], + ["/login", "/login"], + ["/register", "/register"], + ["/login/reset-password", "/login/reset-password"], + ["/login/sso/google", "/login/sso/$providerId"], + ["/discover", "/discover"], + ["/api/core/session", "/api/$"], + ["/docs", "/docs/"], + ["/docs/dev/routing/not-found", "/docs/$"], + ["/admin/core", "/admin/core/"], + ])("serves %s from its own route", (pathname, path) => { + const matched = router.getMatchedRoutes(pathname)[2] as + undefined | { fullPath: string }; + + expect(matched?.fullPath).toBe(path); + }); + + /** + * The auth screens are children of the shell now, which is the other half of + * this change: they render with the site header above them rather than on an + * otherwise empty document. + */ + it.each([ + "/login", + "/register", + "/login/reset-password", + "/login/sso/google", + ])("renders %s inside the main shell", pathname => { + expect(branchFor(router, pathname)).toContain("/_main"); + }); +}); diff --git a/packages/vitnode/src/tanstack/routes/main/not-found.tsx b/packages/vitnode/src/tanstack/routes/main/not-found.tsx new file mode 100644 index 000000000..f896bc350 --- /dev/null +++ b/packages/vitnode/src/tanstack/routes/main/not-found.tsx @@ -0,0 +1,87 @@ +import { + createRoute, + lazyRouteComponent, + notFound, +} from "@tanstack/react-router"; + +import type { CoreRouteFactory } from "../types"; + +/** + * Every public URL this application does not serve - inside the site's own + * shell. + * + * ## Why this is a route rather than a `notFoundComponent` + * + * Because a `notFoundComponent` on the main shell would never run. Router core + * matches a path against the route tree first, and when nothing matches at all + * the branch it hands back is the **root route alone** - see `getMatchedRoutes`, + * which falls back to `[routesById["__root__"]]`. `findGlobalNotFoundRouteId` + * then looks for a boundary within *that* branch, so a pathless layout the URL + * never reached is not a candidate, whatever `notFoundMode` says. The shell's + * header, its breadcrumb area and its `<main>` were therefore unreachable for + * the one screen that most needs to look like the site: the 404. + * + * A splat that matches puts the branch back. `/blahblah` now matches this, which + * means the main shell above it is matched too - so its loader runs, the header + * gets the warm cache entry it reads with `useSuspenseQuery`, and the message + * renders inside the same document every other page renders inside. + * + * ## It answers 404, and `beforeLoad` is what makes it one + * + * A route that simply *rendered* the message would serve it with a `200`, which + * would tell a crawler the page exists. So `beforeLoad` answers `notFound()` and + * the screen is this route's own `notFoundComponent`: the router resolves the + * boundary on its server pass, before the stream opens, and the response is a + * 404 - the same mechanism `/login/reset-password` uses on a deployment that + * cannot send email. + * + * It is `beforeLoad` rather than a loader for a second reason, and it is the one + * that keeps the shell above intact. A failure there stops the chain *at this + * match*, so every loader before it still runs - which is exactly the main + * shell's, and the shell's loader is what warms the cache entry the header reads + * with `useSuspenseQuery`. There is nothing to wait for either way: this + * answers synchronously and can never be pending, which is why it names no + * pending shape. + * + * ## What it does not shadow + * + * Anything a real route declares. A splat is the lowest-ranked segment kind in + * router core's matcher, and `isFrameMoreSpecific` prefers the frame with more + * static segments - so `/discover`, `/docs/$`, `/api/$` and every plugin page + * win against it, and only a path no route claims arrives here. + * + * `/admin/*` is the one boundary worth stating out loud. An admin URL that *is* + * declared is matched by the AdminCP's own routes as before; one that is not - + * a typo, a stale bookmark - lands here and gets the public 404 with the site + * header rather than the AdminCP's. That is deliberate: the alternative is a + * splat under `_admin`, which sits behind the admin guard and would answer a + * mistyped URL by demanding a sign-in for a page that does not exist. + */ +export const notFoundRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { + const route = createRoute({ + getParentRoute: () => parentRoute, + beforeLoad: (): never => { + // TanStack Router's own control-flow signal, like `redirect()`: a typed + // object the router catches, resolves to the boundary below, and turns + // into a 404 on the server pass. + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw notFound(); + }, + head: () => pageHead({ robots: "noindex, nofollow" }), + path: "/$", + notFoundComponent: lazyRouteComponent(async () => { + const [{ NotFound }, { ErrorActions }] = await Promise.all([ + import("../../layout/not-found"), + import("../../layout/error-actions"), + ]); + + return { + default: function NotFoundScreen() { + return <NotFound actions={<ErrorActions />} />; + }, + }; + }), + }); + + return route; +}; diff --git a/packages/vitnode/src/tanstack/routes/root/sso.tsx b/packages/vitnode/src/tanstack/routes/main/sso.tsx similarity index 95% rename from packages/vitnode/src/tanstack/routes/root/sso.tsx rename to packages/vitnode/src/tanstack/routes/main/sso.tsx index 690cb303d..fe583073e 100644 --- a/packages/vitnode/src/tanstack/routes/root/sso.tsx +++ b/packages/vitnode/src/tanstack/routes/main/sso.tsx @@ -1,6 +1,6 @@ import { createRoute, lazyRouteComponent } from "@tanstack/react-router"; -import type { CoreRootRouteFactory } from "./types"; +import type { CoreAuthRouteFactory } from "../types"; import { normalizeSsoCallbackSearch } from "../../auth/route-search"; import { loadSsoCallbackRoute } from "../../auth/sso-route"; @@ -15,7 +15,7 @@ import { routeContext } from "../types"; * that origin serves has to answer it. `/login/sso/google` and * `/pl/login/sso/google` are one route - the locale is stripped before matching. */ -export const ssoCallbackRoute: CoreRootRouteFactory = ({ parentRoute }) => { +export const ssoCallbackRoute: CoreAuthRouteFactory = ({ parentRoute }) => { const route = createRoute({ getParentRoute: () => parentRoute, /** diff --git a/packages/vitnode/src/tanstack/routes/root/index.tsx b/packages/vitnode/src/tanstack/routes/root/index.tsx index f168515a0..1f2b2fc75 100644 --- a/packages/vitnode/src/tanstack/routes/root/index.tsx +++ b/packages/vitnode/src/tanstack/routes/root/index.tsx @@ -6,8 +6,6 @@ import type { CorePageHead } from "../types"; import type { CoreRootRouteContext, CoreRootRouteFactory } from "./types"; import { adminSignInRoute } from "./admin-sign-in"; -import { coreAuthRoutes } from "./auth"; -import { ssoCallbackRoute } from "./sso"; export type { CoreRootRouteContext, CoreRootRouteFactory } from "./types"; @@ -15,7 +13,7 @@ export type { CoreRootRouteContext, CoreRootRouteFactory } from "./types"; * The pathless route core's shell-less screens are mounted under. * * The same device the other two containers are, for the same two reasons: it - * contributes no URL segment, so `/login` is served at `/login`; and it makes + * contributes no URL segment, so `/admin` is served at `/admin`; and it makes * the composition **idempotent** - the subtree is one identifiable child of the * root, so re-running it replaces itself instead of appending a second copy of * every screen. @@ -23,18 +21,22 @@ export type { CoreRootRouteContext, CoreRootRouteFactory } from "./types"; export const CORE_ROOT_ROUTES_ROUTE_ID = "_core-root"; /** - * Every screen `@vitnode/core` owns that renders **outside** a shell. + * Every screen `@vitnode/core` owns that renders **outside** every shell. * - * Which is what makes them a group. `/login`, `/register`, - * `/login/reset-password` and `/login/sso/:providerId` deliberately have no site - * header - an auth card is the whole page - and `/admin` is the AdminCP's own - * sign-in, which must sit *outside* the AdminCP shell or its guard would loop. + * One of them, and the exception is the point: `/admin` is the AdminCP's own + * sign-in, and it must sit *outside* the AdminCP shell or that shell's guard + * would send a denied visitor into a route that sends them straight back. It is + * outside the *main* shell for a second, independent reason - the site header's + * "sign in" leads to `/login`, which is a different session under a different + * cookie, so offering it beside the AdminCP's own form would be one page asking + * for two unrelated logins. + * + * The four public auth screens used to be here too. They are children of the + * main shell now - see `CORE_PUBLIC_ROUTES` in `../main` - because an auth card + * is a page on the public site: the header above it is the way back to the front + * page, and the card's own layout already reserves the space that header takes. */ -const CORE_ROOT_ROUTES: CoreRootRouteFactory[] = [ - ...coreAuthRoutes, - ssoCallbackRoute, - adminSignInRoute, -]; +const CORE_ROOT_ROUTES: CoreRootRouteFactory[] = [adminSignInRoute]; /** * Mounts core's shell-less screens on a route tree, and hands the tree back. diff --git a/packages/vitnode/src/tanstack/routes/root/root-routes.test.ts b/packages/vitnode/src/tanstack/routes/root/root-routes.test.ts index 1efdceb17..81ee28b0e 100644 --- a/packages/vitnode/src/tanstack/routes/root/root-routes.test.ts +++ b/packages/vitnode/src/tanstack/routes/root/root-routes.test.ts @@ -3,18 +3,26 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; /** - * The screens that render outside every shell, and they are `@vitnode/core`'s. + * The screen that renders outside every shell, and it is `@vitnode/core`'s. * - * Static and pure: this directory is read as the text it is. Whether `/login` - * resolves is `apps/web/src/tests/auth-routes.test.ts`, against the real tree. + * Static and pure: this directory is read as the text it is. * - * ## Why `root` is a folder of its own + * ## Why `root` is a folder of its own, for one screen * - * `main/` and `admin/` are named after the shell they mount under. These have - * none - an auth card is the whole page, and the AdminCP's own sign-in has to sit - * *outside* the AdminCP shell or the shell's guard would send a denied visitor - * into a route that sends them back. So the third folder is named after its mount - * point too: the root route, with nothing between. + * `main/` and `admin/` are named after the shell they mount under. This one has + * none, so it is named after its mount point too: the root route, with nothing + * between. + * + * One screen is in it, and that is the whole point of the folder. The AdminCP's + * own sign-in has to sit *outside* the AdminCP shell or that shell's guard would + * send a denied visitor into a route that sends them back, and outside the main + * shell because it reads a different session under a different cookie - a page + * asking for the admin login under a header offering the public one would be one + * page asking for two unrelated logins. + * + * The public auth screens were here and are not any more: an auth card is a page + * on the public site, so `main/auth.tsx` owns them. `../main/main-routes.test.ts` + * is the half of this suite that moved with them. */ const here = import.meta.dirname; @@ -36,49 +44,30 @@ const everyRoutePath = modules .sort(); describe("what this directory declares", () => { - it("declares the shell-less screens", () => { - expect(everyRoutePath).toEqual([ - "/admin", - "/login", - "/login/reset-password", - "/login/sso/$providerId", - "/register", - ]); + it("declares the shell-less screen, and only it", () => { + expect(everyRoutePath).toEqual(["/admin"]); }); /** - * `/login/reset-password` is a **sibling** of `/login`, not a child. - * - * The file-based spelling needed `login_.reset-password.tsx` to say so - the - * trailing underscore meaning "do not nest under `/login`". A code-based route - * needs no such escape: it is a sibling because it is declared as one, and the - * path says the rest. What matters either way is that `/login` consumes exactly - * `/login`, so a URL below it that no route declares does not render the - * sign-in card. + * The public auth screens moved to the main shell, and nothing of them may be + * left behind: a second `/login` here would shadow the one under the shell + * from a container the router ranks identically, and which of the two won + * would depend on the order an application happens to call the mounts in. */ - it("declares no route nested under another", () => { + it("declares no screen the main shell now owns", () => { for (const path of everyRoutePath) { - const parents = everyRoutePath.filter( - other => other !== path && path.startsWith(`${other}/`), - ); - - // `/login/reset-password` and `/login/sso/$providerId` start with - // `/login/`, and that is a shared *prefix*, not a parent: each is its own - // route with its own full path. - expect( - parents.every(parent => parent === "/login"), - path, - ).toBe(true); + expect(path.startsWith("/login"), path).toBe(false); + expect(path, path).not.toBe("/register"); } }); }); -describe("how they reach an application", () => { +describe("how it reaches an application", () => { const index = codeOf("index.tsx"); /** - * Three injected bindings, and the third is what made these the last screens - * to move: a sign-in navigates to a path a *visitor* supplied through + * Three injected bindings, and the third is what made this screen one of the + * last to move: a sign-in navigates to a path a *visitor* supplied through * `?returnTo=`, the route tree carries no locale, and which prefixes exist is * the installation's answer. */ @@ -95,14 +84,14 @@ describe("how they reach an application", () => { * implementation, and an application's own binding uses the same factory. */ it("builds its navigation from the injected rule", () => { - const auth = codeOf("auth.tsx"); - - expect(auth).toContain("createAuthNavigation({"); - expect(auth).toContain("localeRouting"); - // No second copy of the rule: no route here strips a prefix by hand. - // `types.ts` names `deLocalizeUrl` because that is the injected shape, which - // is the opposite of a copy. - for (const name of modules.filter(one => one !== "types.ts")) { + 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"); } }); @@ -124,19 +113,7 @@ describe("how they reach an application", () => { }); }); -describe("the guards these screens carry", () => { - const auth = codeOf("auth.tsx"); - - /** - * One predicate for "signed in", used by both guest routes. - * - * There must not be a second, so "signed in" cannot come to mean two different - * things on two pages. - */ - it("decides guest-only through one shared predicate", () => { - expect(auth.match(/canAccessGuestRoute/g)?.length).toBe(3); - }); - +describe("the guard this screen carries", () => { /** * A redirect carries `to`, never `href`. A redirect with `href` is used * verbatim by `Router.resolveRedirect` - it never reaches `buildLocation`, so diff --git a/packages/vitnode/src/tanstack/routes/root/types.ts b/packages/vitnode/src/tanstack/routes/root/types.ts index d598859b1..4b5bbe467 100644 --- a/packages/vitnode/src/tanstack/routes/root/types.ts +++ b/packages/vitnode/src/tanstack/routes/root/types.ts @@ -1,22 +1,16 @@ -import type { LocaleRouting } from "../../../lib/i18n/locale-routing"; -import type { CoreRouteContext, CoreRouteFactory } from "../types"; +import type { CoreAuthRouteContext, CoreRouteFactory } from "../types"; /** * What a route with no shell above it is built with. * - * One field more than the others, and it is the reason these screens were the - * last to move: a sign-in performs a navigation nobody clicked, to a path a - * *visitor* supplied through `?returnTo=`. Deciding what the router should be - * handed means stripping the locale prefix the route tree does not carry - and - * which languages exist is the installation's, not this package's. - * - * So the app's own locale rule is injected, exactly as `pageHead` and - * `contentRegistry` are, and `createAuthNavigation` builds both halves of the - * navigation from it. See `@vitnode/core/tanstack/auth`. + * The same shape a route under the main shell that navigates on the visitor's + * behalf needs, and deliberately the same *type*: `/admin` and `/login` both + * send a browser to a path a visitor supplied through `?returnTo=`, both have to + * strip a locale prefix the route tree does not carry, and there must not be two + * answers to that. The definition lives in `../types` beside `pageHead`, which + * is injected for the same reason. */ -export interface CoreRootRouteContext extends CoreRouteContext { - localeRouting: Pick<LocaleRouting, "deLocalizeUrl">; -} +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 index 0d96a2228..ee0ac9c45 100644 --- a/packages/vitnode/src/tanstack/routes/types.ts +++ b/packages/vitnode/src/tanstack/routes/types.ts @@ -1,6 +1,7 @@ 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"; /** @@ -86,3 +87,23 @@ export const routeSearch = <TSearch>(search: unknown): TSearch => /** See {@link routeSearch}. */ export const routeContext = <TContext>(context: unknown): TContext => context as TContext; + +/** + * What a screen that performs a navigation nobody clicked is built with. + * + * One field more than the usual two, and it is the reason the auth screens were + * the last to move: a sign-in navigates to a path a *visitor* supplied through + * `?returnTo=`. Deciding what the router should be handed means stripping the + * locale prefix the route tree does not carry - and which languages exist is the + * installation's answer, not this package's. + * + * So the app's own locale rule is injected, exactly as `pageHead` and + * `contentRegistry` are, and `createAuthNavigation` builds both halves of the + * navigation from it. See `@vitnode/core/tanstack/auth`. + */ +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/views/layouts/theme/header/header-content.tsx b/packages/vitnode/src/views/layouts/theme/header/header-content.tsx index d8b69fba8..bc97965c9 100644 --- a/packages/vitnode/src/views/layouts/theme/header/header-content.tsx +++ b/packages/vitnode/src/views/layouts/theme/header/header-content.tsx @@ -65,19 +65,10 @@ export const HeaderLayoutContent = ({ user, ...props }: HeaderLayoutContentProps) => ( - <header - className={cn("sticky top-0 z-20 w-full sm:top-2 sm:mb-2", className)} - {...props} - > + <header className={cn("sticky top-0 z-20 w-full", className)} {...props}> <div className="dark:bg-background/75 bg-card/75 container mx-auto flex h-14 items-center border-b px-4 py-2 backdrop-blur sm:rounded-lg sm:border sm:shadow-sm"> <LinkComponent href={HEADER_HREF.home}>{logo}</LinkComponent> - {/* - Hidden below `sm`, as it has always been: at that width the bar holds the - logo and the action area and nothing else fits. Restoring the links to - small screens needs a mobile menu, which is a design question rather than - a migration one. - */} <nav className="ms-4 hidden items-center gap-1 sm:flex"> {navigation.map(item => ( <LinkComponent From 67eee4c59d4feb55e7061f7e7d3f1643e30c1558 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Thu, 3 Sep 2026 18:44:33 +0200 Subject: [PATCH 10/12] chore: Remove long comments in code --- apps/api/src/i18n.ts | 19 - apps/api/src/vitnode.api.config.ts | 26 +- apps/web/source.config.ts | 38 -- apps/web/src/admin-nav.gen.ts | 21 +- apps/web/src/components/admin-shell.tsx | 44 -- apps/web/src/components/fumadocs/img.tsx | 14 - apps/web/src/components/main-header.tsx | 30 - apps/web/src/content-registry.gen.ts | 26 +- apps/web/src/docs/article.tsx | 22 - apps/web/src/docs/client-loader.tsx | 23 - apps/web/src/docs/freshness.ts | 70 -- apps/web/src/docs/github.ts | 16 - apps/web/src/docs/mdx-components.tsx | 30 - apps/web/src/docs/moved-pages.ts | 23 - apps/web/src/docs/page-content.tsx | 20 - apps/web/src/docs/preview-data-table.tsx | 20 - apps/web/src/docs/preview.tsx | 24 - apps/web/src/docs/search-dialog.tsx | 16 - apps/web/src/docs/search-path.ts | 33 - apps/web/src/docs/section.ts | 27 - apps/web/src/docs/shell-content.tsx | 57 -- apps/web/src/docs/source.server.ts | 29 - apps/web/src/docs/transport.ts | 97 --- apps/web/src/docs/view-options.tsx | 23 - apps/web/src/lib/admin-nav.ts | 38 -- apps/web/src/lib/admin-search.ts | 26 - apps/web/src/lib/content-registry.ts | 42 -- apps/web/src/lib/i18n/runtime.ts | 54 +- apps/web/src/lib/i18n/shared.ts | 9 - apps/web/src/lib/navigation.ts | 17 - apps/web/src/lib/page-head.ts | 14 - apps/web/src/locales/app.ts | 29 - apps/web/src/locales/packages.ts | 35 - apps/web/src/plugin-routes.gen.ts | 13 +- apps/web/src/routes/_admin.tsx | 187 +---- .../src/routes/_admin/admin.core.index.tsx | 52 +- apps/web/src/routes/_docs.tsx | 49 -- apps/web/src/routes/_docs/docs.index.tsx | 21 - apps/web/src/routes/_main.tsx | 50 -- apps/web/src/routes/_main/index.tsx | 51 -- apps/web/src/routes/api/$.ts | 22 - apps/web/src/routes/docs.search.ts | 35 - apps/web/src/routes/llms-full[.]txt.ts | 34 - apps/web/src/server/api-bridge.ts | 12 - .../animated-beam-home-skeleton.tsx | 8 - .../home/animated-beam/animated-beam-home.tsx | 22 - .../site/home/animated-beam/animated-beam.tsx | 13 - apps/web/src/site/home/home-content.tsx | 57 -- apps/web/src/site/home/infinite-slider.tsx | 44 -- apps/web/src/site/home/metadata.ts | 36 - .../src/site/home/sections/admin-panel.tsx | 55 -- .../src/site/home/sections/call-to-action.tsx | 7 - apps/web/src/site/home/sections/hero.tsx | 39 -- .../src/site/home/sections/logos/tanstack.tsx | 14 - .../src/site/home/sections/powering-by.tsx | 27 - apps/web/src/site/home/site-link.ts | 21 - .../api-single-app/src/routes/api/$.ts | 23 +- .../api-single-app/src/server/api-bridge.ts | 13 +- .../api-single-app/src/vitnode.api.config.ts | 10 +- .../api/src/vitnode.api.config.ts | 20 +- .../copy-of-vitnode-app/root/global.d.ts | 20 +- .../root/src/components/admin-shell.tsx | 45 +- .../root/src/components/main-header.tsx | 31 +- .../root/src/lib/admin-nav.ts | 39 +- .../root/src/lib/admin-search.ts | 27 +- .../root/src/lib/content-registry.ts | 43 +- .../root/src/lib/i18n/runtime.ts | 57 +- .../root/src/lib/i18n/shared.ts | 10 +- .../root/src/lib/navigation.ts | 18 +- .../root/src/lib/page-head.ts | 15 +- .../root/src/locales/app.ts | 26 +- .../root/src/locales/packages.ts | 35 +- .../root/src/routes/_admin.tsx | 190 +----- .../src/routes/_admin/admin.core.index.tsx | 53 +- .../root/src/routes/_main.tsx | 51 +- .../src/create/create-package-json.ts | 42 -- .../create/database-bootstrap-scripts.test.ts | 77 --- .../create/no-plugin-page-duplication.test.ts | 140 ---- .../src/create/package-versions.ts | 8 - .../src/create/scaffold-invariants.test.ts | 136 ---- .../src/helpers/init-vitnode.ts | 40 -- .../src/helpers/packages-json.ts | 9 +- .../plugin/create/add-plugin-to-workspace.ts | 16 - .../src/plugin/create/create-package-json.ts | 13 - .../plugin/create/create-plugin-vitnode.ts | 16 - .../src/plugin/create/route-templates.test.ts | 16 - .../src/plugin/create/route-templates.ts | 86 +-- .../vitnode/scripts/cli-arguments.test.ts | 28 - packages/vitnode/scripts/cli-arguments.ts | 73 +- .../scripts/database-bootstrap.test.ts | 228 ------- packages/vitnode/scripts/dev.test.ts | 8 - packages/vitnode/scripts/i18n-create.test.ts | 9 - packages/vitnode/scripts/i18n-create.ts | 14 +- packages/vitnode/scripts/i18n-delete.ts | 17 - packages/vitnode/scripts/i18n-shared.ts | 13 - packages/vitnode/scripts/i18n-update-ai.ts | 48 -- packages/vitnode/scripts/i18n-update.ts | 9 - .../vitnode/scripts/no-route-copier.test.ts | 75 -- packages/vitnode/scripts/prepare-database.ts | 16 +- packages/vitnode/scripts/scripts.ts | 10 - packages/vitnode/scripts/shared/file-utils.ts | 32 - .../vitnode/src/api/adapters/events/local.ts | 7 - .../vitnode/src/api/adapters/storage/local.ts | 12 - .../vitnode/src/api/lib/auth-cookie.test.ts | 12 - packages/vitnode/src/api/lib/auth-cookie.ts | 36 - packages/vitnode/src/api/lib/cache.ts | 14 - .../src/api/lib/check-staff-permission.ts | 22 - packages/vitnode/src/api/lib/module.ts | 16 +- packages/vitnode/src/api/lib/openapi-tags.ts | 12 - .../vitnode/src/api/lib/pagination-cursor.ts | 118 ---- .../vitnode/src/api/lib/permission-staff.ts | 33 - packages/vitnode/src/api/lib/plugin.ts | 6 - .../api/lib/response-cache-headers.test.ts | 53 -- .../api/lib/staff-permission-cache.test.ts | 14 - .../src/api/lib/staff-permission-cache.ts | 31 - packages/vitnode/src/api/lib/websocket.ts | 22 - .../vitnode/src/api/lib/with-pagination.ts | 88 --- .../src/api/middlewares/global.middleware.ts | 16 +- packages/vitnode/src/api/models/ai.ts | 36 +- packages/vitnode/src/api/models/events.ts | 56 +- packages/vitnode/src/api/models/i18n.ts | 30 - packages/vitnode/src/api/models/queue.ts | 24 +- .../vitnode/src/api/models/search.test-d.ts | 2 - .../vitnode/src/api/models/search.test.ts | 17 - packages/vitnode/src/api/models/search.ts | 105 +-- .../src/api/models/session-cache.test.ts | 30 - .../src/api/models/session-cookies.test.ts | 7 - packages/vitnode/src/api/models/storage.ts | 71 +- .../admin/debug/routes/clear-search.route.ts | 19 - .../debug/routes/content-status.route.ts | 21 +- .../routes/search-collections.route.test.ts | 5 - .../debug/routes/search-status.route.test.ts | 8 - .../admin/debug/routes/search-status.route.ts | 21 +- .../admin/files/routes/delete.route.ts | 6 +- .../roles/lib/assert-manage-admin-role.ts | 9 - .../users/lib/assert-edit-user-permission.ts | 7 - .../src/api/modules/content/content.module.ts | 19 - .../cron/content-editorial-cleanup.cron.ts | 13 - .../helpers/execute-content-schedule.ts | 9 - .../tasks/content-schedule-effects.task.ts | 13 - .../content/tasks/content-schedule.task.ts | 9 - .../queue/helpers/process-queue-tasks.ts | 6 - .../api/modules/search/routes/search.route.ts | 8 - .../confirm-action-alert-dialog.tsx | 10 - .../vitnode/src/components/form/auto-form.tsx | 8 - .../components/form/common/async-picker.tsx | 6 - .../components/form/fields/combobox.test-d.ts | 33 - .../src/components/form/fields/combobox.tsx | 35 +- .../form/fields/file-gallery.test.tsx | 15 - .../components/form/fields/file-gallery.tsx | 10 - .../components/form/fields/file-order.test.ts | 11 - .../src/components/form/fields/file-order.ts | 99 +-- .../form/fields/file-shared.test.ts | 11 - .../components/form/fields/file-shared.tsx | 85 --- .../form/fields/file-upload-queue.test.ts | 14 - .../form/fields/file-upload-queue.ts | 53 +- .../src/components/form/fields/files.test.tsx | 20 - .../components/form/fields/input-roles.tsx | 39 +- .../components/form/fields/input-users.tsx | 30 +- .../form/fields/roles-boundaries.test.ts | 37 - .../components/form/fields/roles.test-d.ts | 21 - .../src/components/form/fields/roles.ts | 45 -- .../vitnode/src/components/logo-vitnode.tsx | 31 - .../src/components/role-format-content.tsx | 17 - packages/vitnode/src/components/role-name.ts | 14 - .../components/staff-permission/provider.tsx | 33 - .../langs/language-switcher-content.tsx | 36 - .../components/table/data-table-content.tsx | 16 - .../src/components/table/navigation.tsx | 56 -- .../src/components/table/no-results.tsx | 22 - .../src/components/table/selection.tsx | 8 - .../components/table/table-boundaries.test.ts | 21 +- .../vitnode/src/components/table/url-state.ts | 58 -- .../vitnode/src/components/theme-script.tsx | 23 - .../vitnode/src/components/theme.test.tsx | 9 - .../src/components/ui/header-content.tsx | 29 - packages/vitnode/src/content/admin/labels.ts | 14 - .../src/content/admin/registry.test.ts | 10 - .../vitnode/src/content/admin/registry.ts | 77 +-- packages/vitnode/src/content/admin/route.ts | 30 - packages/vitnode/src/content/admin/spec.ts | 185 +---- .../vitnode/src/content/admin/upload.test.ts | 5 - packages/vitnode/src/content/admin/upload.ts | 8 - .../vitnode/src/content/advanced.test-d.ts | 26 - packages/vitnode/src/content/advanced.test.ts | 9 - packages/vitnode/src/content/advanced.ts | 104 --- .../vitnode/src/content/boundaries.test.ts | 16 - .../src/content/cache.delivery.test.ts | 10 - packages/vitnode/src/content/cache.ts | 234 +------ packages/vitnode/src/content/conflicts.ts | 51 +- packages/vitnode/src/content/const.ts | 354 +--------- packages/vitnode/src/content/define-admin.ts | 33 - .../vitnode/src/content/define-editorial.ts | 7 - packages/vitnode/src/content/define-fields.ts | 26 - .../vitnode/src/content/define-public-api.ts | 16 - packages/vitnode/src/content/define-search.ts | 13 - packages/vitnode/src/content/define-shared.ts | 5 - packages/vitnode/src/content/define.ts | 44 +- .../vitnode/src/content/delivery.test-d.ts | 9 - packages/vitnode/src/content/delivery.test.ts | 8 - packages/vitnode/src/content/delivery.ts | 122 ---- packages/vitnode/src/content/errors.ts | 27 - packages/vitnode/src/content/events.ts | 50 +- packages/vitnode/src/content/fields.ts | 212 ------ .../src/content/file-collection.test-d.ts | 9 - .../src/content/file-collection.test.ts | 9 - .../vitnode/src/content/file-field.test.ts | 12 - packages/vitnode/src/content/files.ts | 120 ---- packages/vitnode/src/content/hash.test.ts | 34 - packages/vitnode/src/content/hash.ts | 33 - packages/vitnode/src/content/index.ts | 23 +- packages/vitnode/src/content/locale.ts | 65 -- packages/vitnode/src/content/localization.ts | 47 -- packages/vitnode/src/content/paths.test.ts | 9 - packages/vitnode/src/content/paths.ts | 11 - .../src/content/publication-state.test.ts | 10 - packages/vitnode/src/content/publication.ts | 63 -- packages/vitnode/src/content/registry.ts | 15 - packages/vitnode/src/content/revisions.ts | 103 +-- packages/vitnode/src/content/schedules.ts | 15 +- packages/vitnode/src/content/schemas.ts | 161 +---- packages/vitnode/src/content/search.test.ts | 5 - packages/vitnode/src/content/search.ts | 21 - packages/vitnode/src/content/server/actor.ts | 10 - .../server/advanced-projection.test.ts | 10 - .../src/content/server/advanced-store.ts | 127 +--- .../src/content/server/advanced-tables.ts | 74 -- .../src/content/server/collection-api.ts | 42 -- .../src/content/server/column-builders.ts | 63 -- .../src/content/server/delivery-alternates.ts | 25 - .../content/server/delivery-effects.test.ts | 9 - .../src/content/server/delivery-effects.ts | 55 -- .../content/server/delivery-routes.test.ts | 13 - .../src/content/server/delivery-routes.ts | 43 -- .../content/server/delivery-service.test.ts | 30 - .../src/content/server/delivery-service.ts | 99 +-- .../src/content/server/delivery-sitemap.ts | 38 -- .../content/server/delivery-writes.test.ts | 37 - .../src/content/server/delivery-writes.ts | 64 +- .../vitnode/src/content/server/diagnostics.ts | 119 +--- .../content/server/editorial-effects.test.ts | 5 - .../src/content/server/editorial-effects.ts | 70 +- .../content/server/editorial-service.test.ts | 21 - .../src/content/server/editorial-service.ts | 107 +-- .../vitnode/src/content/server/effects-log.ts | 33 - packages/vitnode/src/content/server/emit.ts | 36 - .../content/server/error-contracts.test.ts | 33 - .../content/server/file-collection.test.ts | 7 - .../server/file-reference-http-errors.test.ts | 54 -- .../src/content/server/file-revisions.test.ts | 33 - .../content/server/file-upload-route.test.ts | 8 - .../vitnode/src/content/server/files.test.ts | 18 - packages/vitnode/src/content/server/files.ts | 119 ---- .../vitnode/src/content/server/http-errors.ts | 44 -- packages/vitnode/src/content/server/index.ts | 8 - .../src/content/server/language-resolver.ts | 78 --- .../server/localized-admin-routes.test.ts | 7 - .../content/server/localized-admin-routes.ts | 20 - .../server/localized-base-preview.test.ts | 6 - .../server/localized-list-routes.test.ts | 9 - .../server/localized-public-service.ts | 86 --- .../content/server/localized-service.test.ts | 16 - .../src/content/server/localized-service.ts | 33 +- packages/vitnode/src/content/server/model.ts | 152 +---- packages/vitnode/src/content/server/module.ts | 22 - .../src/content/server/openapi-parity.test.ts | 27 - .../content/server/pagination-routes.test.ts | 14 - .../src/content/server/preview-link.ts | 16 - .../src/content/server/preview-route.test.ts | 7 - .../src/content/server/preview-secret.test.ts | 5 - .../src/content/server/preview-secret.ts | 33 - .../src/content/server/preview-target.ts | 42 -- .../src/content/server/preview-token.ts | 70 +- .../src/content/server/public-locales.test.ts | 6 - .../src/content/server/public-locales.ts | 19 - .../src/content/server/public-module.ts | 28 - .../src/content/server/public-privacy.test.ts | 22 - .../src/content/server/public-routes.test.ts | 6 - .../src/content/server/public-routes.ts | 17 - .../src/content/server/public-service.ts | 94 +-- .../vitnode/src/content/server/publication.ts | 97 --- .../vitnode/src/content/server/query.test.ts | 12 - packages/vitnode/src/content/server/query.ts | 36 - .../vitnode/src/content/server/references.ts | 104 +-- .../content/server/revalidate-bridge.test.ts | 7 - .../src/content/server/revalidate-bridge.ts | 46 -- .../src/content/server/revision-snapshot.ts | 92 --- .../content/server/revisions-model.test.ts | 7 - .../src/content/server/revisions-model.ts | 61 +- .../vitnode/src/content/server/routes.test.ts | 26 - packages/vitnode/src/content/server/routes.ts | 5 - .../src/content/server/schedule-effects.ts | 50 -- .../src/content/server/schedules-model.ts | 53 -- .../src/content/server/search-document.ts | 70 -- .../src/content/server/search-indexer.ts | 115 ---- .../server/search-sync.localized.test.ts | 8 - .../src/content/server/search-sync.test.ts | 5 - .../vitnode/src/content/server/search-sync.ts | 50 +- .../src/content/server/service.test.ts | 20 +- .../vitnode/src/content/server/service.ts | 203 +----- .../src/content/server/slug-history-model.ts | 144 +--- packages/vitnode/src/content/server/slugs.ts | 13 - packages/vitnode/src/content/server/table.ts | 42 -- .../translation-advanced-revisions.test.ts | 10 - .../translation-editorial-service.test.ts | 8 - .../server/translation-editorial-service.ts | 81 +-- .../server/translation-effects.test.ts | 8 - .../src/content/server/translation-effects.ts | 44 +- .../content/server/translation-http-errors.ts | 21 - .../content/server/translation-model.test.ts | 25 - .../src/content/server/translation-model.ts | 156 +---- .../translation-publication-routes.test.ts | 9 - .../src/content/server/translation-routes.ts | 27 - .../src/content/server/translation-table.ts | 17 - packages/vitnode/src/content/server/types.ts | 65 -- packages/vitnode/src/content/sitemap.test.ts | 9 - packages/vitnode/src/content/sitemap.ts | 52 +- packages/vitnode/src/content/slug.ts | 32 - packages/vitnode/src/content/types.ts | 640 +----------------- packages/vitnode/src/database/content.ts | 130 +--- packages/vitnode/src/database/relations.ts | 18 - packages/vitnode/src/database/secrets.ts | 16 - .../src/framework/admin-nav/generate.test.ts | 15 - .../src/framework/admin-nav/generate.ts | 28 +- .../vitnode/src/framework/admin-nav/index.ts | 33 - .../vitnode/src/framework/admin-nav/types.ts | 9 - .../content-registry/generate.test.ts | 15 - .../framework/content-registry/generate.ts | 33 +- .../src/framework/content-registry/index.ts | 35 - .../src/framework/content-registry/types.ts | 16 - .../framework/plugin-routes/compile.test.ts | 9 - .../src/framework/plugin-routes/compile.ts | 67 +- .../plugin-routes/component-source.test.ts | 14 - .../plugin-routes/component-source.ts | 21 - .../framework/plugin-routes/diagnostics.ts | 35 - .../framework/plugin-routes/generate.test.ts | 22 +- .../src/framework/plugin-routes/generate.ts | 22 +- .../framework/plugin-routes/host-routes.ts | 30 - .../src/framework/plugin-routes/index.ts | 34 +- .../framework/plugin-routes/resolve.test.ts | 16 - .../src/framework/plugin-routes/resolve.ts | 43 -- .../src/framework/plugin-routes/types.ts | 11 - .../vitnode/src/framework/vite/env.test.ts | 6 - packages/vitnode/src/framework/vite/env.ts | 46 -- .../framework/vite/generation-queue.test.ts | 18 - .../src/framework/vite/generation-queue.ts | 81 +-- packages/vitnode/src/framework/vite/index.ts | 35 - .../src/framework/vite/module-version.test.ts | 32 - .../src/framework/vite/module-version.ts | 55 -- .../vite/no-materialized-routes.test.ts | 78 --- .../src/framework/vite/plugin-routes.test.ts | 37 - .../src/framework/vite/plugin-routes.ts | 303 +-------- .../src/framework/vite/projections.test.ts | 65 -- .../vite/shared-config-discovery.test.ts | 15 - .../src/framework/vite/vitnode.test.ts | 8 - .../vitnode/src/framework/vite/vitnode.ts | 21 - packages/vitnode/src/hooks/use-captcha.ts | 22 - .../src/lib/admin-nav-source.test-d.ts | 20 - .../src/lib/api/get-queue-backoff-date.ts | 5 - packages/vitnode/src/lib/api/pg-error.ts | 21 +- .../src/lib/api/resolve-queue-task-outcome.ts | 6 - packages/vitnode/src/lib/api/signed-token.ts | 15 - packages/vitnode/src/lib/api/upload.ts | 11 - .../src/lib/api/validate-cron-schedule.ts | 14 - packages/vitnode/src/lib/config.test.ts | 5 - packages/vitnode/src/lib/config.ts | 29 - packages/vitnode/src/lib/fetcher-client.ts | 10 - .../vitnode/src/lib/fetcher/rate-limit.ts | 5 - packages/vitnode/src/lib/fetcher/raw.test.ts | 6 - packages/vitnode/src/lib/fetcher/raw.ts | 19 +- .../src/lib/fetcher/request-context.ts | 28 +- .../vitnode/src/lib/fetcher/set-cookie.ts | 41 -- packages/vitnode/src/lib/file-constraints.ts | 30 - packages/vitnode/src/lib/file-extension.ts | 17 - packages/vitnode/src/lib/files/bulk-delete.ts | 44 +- packages/vitnode/src/lib/files/in-use.ts | 45 -- packages/vitnode/src/lib/i18n/deep-merge.ts | 9 - .../vitnode/src/lib/i18n/locale-cookie.ts | 36 +- .../vitnode/src/lib/i18n/locale-routing.ts | 43 +- .../vitnode/src/lib/i18n/negotiate-locale.ts | 8 - .../src/lib/i18n/pick-messages.test.ts | 15 - .../vitnode/src/lib/i18n/pick-messages.ts | 44 -- packages/vitnode/src/lib/i18n/provider.tsx | 25 - packages/vitnode/src/lib/i18n/types.ts | 33 +- packages/vitnode/src/lib/metadata.ts | 14 - packages/vitnode/src/lib/plugin.ts | 130 +--- packages/vitnode/src/lib/query-client.test.ts | 39 -- packages/vitnode/src/lib/query-client.ts | 65 -- packages/vitnode/src/lib/query-freshness.ts | 76 --- packages/vitnode/src/lib/task-pool.test.ts | 8 - packages/vitnode/src/lib/task-pool.ts | 25 - packages/vitnode/src/locales/api/index.ts | 9 - packages/vitnode/src/locales/index.ts | 21 - packages/vitnode/src/next-boundary.test.ts | 38 -- .../vitnode/src/routing/authoring.test-d.ts | 5 - packages/vitnode/src/routing/authoring.ts | 122 ---- .../vitnode/src/routing/boundaries.test.ts | 20 - packages/vitnode/src/routing/errors.ts | 9 - packages/vitnode/src/routing/graph.test.ts | 111 --- packages/vitnode/src/routing/graph.ts | 105 +-- packages/vitnode/src/routing/index.ts | 29 - packages/vitnode/src/routing/manifest.test.ts | 9 - packages/vitnode/src/routing/manifest.ts | 6 - packages/vitnode/src/routing/module.test.ts | 6 - packages/vitnode/src/routing/module.ts | 348 +--------- packages/vitnode/src/routing/namespaces.ts | 36 - packages/vitnode/src/routing/order.ts | 21 - packages/vitnode/src/routing/path.test.ts | 37 - packages/vitnode/src/routing/path.ts | 40 -- packages/vitnode/src/routing/tree.test.ts | 17 - packages/vitnode/src/routing/tree.ts | 56 -- packages/vitnode/src/routing/types.ts | 148 +--- .../vitnode/src/tanstack/admin/actions.ts | 77 --- .../src/tanstack/admin/admin-locale.test.ts | 50 -- .../vitnode/src/tanstack/admin/breadcrumb.tsx | 54 +- .../admin/content/breadcrumb-model.test.ts | 43 -- .../admin/content/breadcrumb-model.ts | 27 - .../src/tanstack/admin/content/breadcrumb.tsx | 57 -- .../tanstack/admin/content/create-action.tsx | 14 - .../tanstack/admin/content/editorial/host.tsx | 25 - .../tanstack/admin/content/editorial/index.ts | 17 - .../admin/content/editorial/panels.tsx | 56 -- .../admin/content/editorial/transport.ts | 50 -- .../tanstack/admin/content/form/dialog.tsx | 19 - .../tanstack/admin/content/form/form-body.tsx | 28 - .../src/tanstack/admin/content/form/host.tsx | 23 - .../src/tanstack/admin/content/form/index.ts | 33 - .../tanstack/admin/content/form/page-body.tsx | 56 +- .../tanstack/admin/content/form/route.test.ts | 47 -- .../src/tanstack/admin/content/form/route.tsx | 80 --- .../tanstack/admin/content/form/screen.tsx | 17 - .../src/tanstack/admin/content/form/spec.ts | 30 - .../tanstack/admin/content/form/transport.ts | 69 -- .../src/tanstack/admin/content/index.ts | 76 +-- .../src/tanstack/admin/content/list.tsx | 59 +- .../src/tanstack/admin/content/query.test.ts | 13 - .../src/tanstack/admin/content/query.ts | 71 -- .../tanstack/admin/content/route-screen.tsx | 26 +- .../admin/content/route-search.test.ts | 58 -- .../tanstack/admin/content/route-search.ts | 112 --- .../src/tanstack/admin/content/route.test.ts | 37 - .../src/tanstack/admin/content/route.tsx | 105 +-- .../tanstack/admin/content/row-actions.tsx | 57 -- .../src/tanstack/admin/content/screen.tsx | 38 -- .../tanstack/admin/content/slot-render.tsx | 21 - .../src/tanstack/admin/content/slots.test.ts | 45 -- .../src/tanstack/admin/content/slots.ts | 65 -- .../vitnode/src/tanstack/admin/cron/index.ts | 16 +- .../src/tanstack/admin/cron/route-search.ts | 14 - .../vitnode/src/tanstack/admin/cron/route.tsx | 58 -- .../src/tanstack/admin/cron/screen.tsx | 11 - .../src/tanstack/admin/dashboard/index.ts | 14 - .../src/tanstack/admin/dashboard/query.tsx | 62 +- .../src/tanstack/admin/dashboard/route.tsx | 44 -- .../src/tanstack/admin/dashboard/screen.tsx | 44 +- .../src/tanstack/admin/dashboard/widgets.tsx | 50 -- .../vitnode/src/tanstack/admin/debug/index.ts | 13 - .../vitnode/src/tanstack/admin/debug/query.ts | 47 -- .../src/tanstack/admin/debug/route-search.ts | 9 - .../src/tanstack/admin/debug/route.tsx | 34 - .../src/tanstack/admin/debug/screen.tsx | 19 - .../vitnode/src/tanstack/admin/files/index.ts | 13 - .../vitnode/src/tanstack/admin/files/query.ts | 60 -- .../src/tanstack/admin/files/route-search.ts | 6 - .../src/tanstack/admin/files/route.tsx | 19 - .../src/tanstack/admin/files/screen.tsx | 10 - .../vitnode/src/tanstack/admin/identity.ts | 27 - packages/vitnode/src/tanstack/admin/index.ts | 76 +-- .../src/tanstack/admin/integrations/index.ts | 12 - .../src/tanstack/admin/integrations/query.ts | 16 - .../src/tanstack/admin/integrations/route.tsx | 36 - packages/vitnode/src/tanstack/admin/intl.ts | 88 +-- packages/vitnode/src/tanstack/admin/nav.tsx | 88 +-- .../vitnode/src/tanstack/admin/not-found.tsx | 28 - .../tanstack/admin/permissions-bridge.test.ts | 21 - .../src/tanstack/admin/permissions.tsx | 94 --- .../src/tanstack/admin/queries.test.ts | 38 -- .../vitnode/src/tanstack/admin/queries.ts | 82 --- .../vitnode/src/tanstack/admin/queue/index.ts | 12 - .../vitnode/src/tanstack/admin/queue/query.ts | 18 - .../src/tanstack/admin/queue/route-search.ts | 7 - .../src/tanstack/admin/queue/route.tsx | 22 - .../src/tanstack/admin/queue/screen.tsx | 8 - .../src/tanstack/admin/return-to.test.ts | Bin 7170 -> 5313 bytes .../vitnode/src/tanstack/admin/return-to.ts | 54 -- .../vitnode/src/tanstack/admin/roles/index.ts | 17 +- .../vitnode/src/tanstack/admin/roles/query.ts | 25 - .../src/tanstack/admin/roles/route-search.ts | 9 - .../src/tanstack/admin/roles/route.tsx | 28 - .../tanstack/admin/screen-boundaries.test.ts | 41 -- .../src/tanstack/admin/screen-queries.test.ts | 24 - packages/vitnode/src/tanstack/admin/screen.ts | 48 -- .../src/tanstack/admin/search-index/index.ts | 14 - .../src/tanstack/admin/search-index/query.ts | 41 -- .../admin/search-index/route-search.ts | 30 - .../src/tanstack/admin/search-index/route.tsx | 27 - .../tanstack/admin/search-index/screen.tsx | 19 - .../vitnode/src/tanstack/admin/search.tsx | 32 +- .../vitnode/src/tanstack/admin/session-api.ts | 15 - .../src/tanstack/admin/session-query.test.ts | 68 -- .../src/tanstack/admin/session-query.ts | 235 ------- packages/vitnode/src/tanstack/admin/shell.tsx | 96 +-- .../src/tanstack/admin/sign-in-route.tsx | 45 -- .../src/tanstack/admin/sign-in-screen.tsx | 21 - .../src/tanstack/admin/staff/breadcrumbs.tsx | 17 - .../src/tanstack/admin/staff/create-route.tsx | 14 - .../src/tanstack/admin/staff/edit-route.tsx | 61 -- .../src/tanstack/admin/staff/edit-screen.tsx | 6 - .../vitnode/src/tanstack/admin/staff/index.ts | 18 +- .../vitnode/src/tanstack/admin/staff/query.ts | 27 - .../src/tanstack/admin/staff/route-search.ts | 8 - .../src/tanstack/admin/staff/route.tsx | 36 - .../vitnode/src/tanstack/admin/state.test.ts | 37 - packages/vitnode/src/tanstack/admin/state.ts | 205 ------ .../src/tanstack/admin/table-search.test.ts | 16 - .../src/tanstack/admin/table-search.ts | 132 ---- .../src/tanstack/admin/transport.test.ts | 56 -- .../vitnode/src/tanstack/admin/transport.ts | 81 --- .../vitnode/src/tanstack/admin/user-bar.tsx | 58 -- .../admin/users/detail-breadcrumb.tsx | 29 +- .../src/tanstack/admin/users/detail-route.tsx | 55 -- .../tanstack/admin/users/detail-screen.tsx | 11 - .../vitnode/src/tanstack/admin/users/index.ts | 18 +- .../tanstack/admin/users/invalidate.test.ts | 40 -- .../vitnode/src/tanstack/admin/users/query.ts | 88 --- .../tanstack/admin/users/route-search.test.ts | 6 - .../src/tanstack/admin/users/route-search.ts | 15 - .../src/tanstack/admin/users/route.tsx | 61 -- .../src/tanstack/admin/users/screen.tsx | 12 - packages/vitnode/src/tanstack/auth/actions.ts | 216 ------ .../src/tanstack/auth/contract.test.ts | 16 - .../vitnode/src/tanstack/auth/contract.ts | 298 -------- packages/vitnode/src/tanstack/auth/index.ts | 58 -- .../vitnode/src/tanstack/auth/login-route.tsx | 32 - .../src/tanstack/auth/login-screen.tsx | 16 +- .../src/tanstack/auth/middleware-config.ts | 104 --- .../vitnode/src/tanstack/auth/queries.test.ts | 22 - packages/vitnode/src/tanstack/auth/queries.ts | 59 -- .../tanstack/auth/recovery-contract.test.ts | 9 - .../src/tanstack/auth/recovery-route.tsx | 22 - .../src/tanstack/auth/recovery-screen.tsx | 28 - .../src/tanstack/auth/recovery.test.ts | 21 - .../vitnode/src/tanstack/auth/recovery.ts | 131 ---- .../src/tanstack/auth/redirects.test.ts | 9 - .../vitnode/src/tanstack/auth/redirects.ts | 53 -- .../src/tanstack/auth/register-route.tsx | 9 - .../src/tanstack/auth/register-screen.tsx | 39 -- .../auth/registration-contract.test.ts | 8 - .../auth/registration-screens.test.ts | 6 - .../src/tanstack/auth/return-to.test.ts | 5 - .../vitnode/src/tanstack/auth/return-to.ts | 51 -- .../src/tanstack/auth/route-search.test.ts | 8 - .../vitnode/src/tanstack/auth/route-search.ts | 72 -- .../vitnode/src/tanstack/auth/screens.test.ts | 11 - packages/vitnode/src/tanstack/auth/screens.ts | 111 --- .../vitnode/src/tanstack/auth/session-api.ts | 15 - .../src/tanstack/auth/session-query.test.ts | 44 -- .../src/tanstack/auth/session-query.ts | 199 ------ .../src/tanstack/auth/sign-up-session.ts | 36 - .../vitnode/src/tanstack/auth/sso-route.tsx | 12 - .../vitnode/src/tanstack/auth/sso-screen.tsx | 25 - .../vitnode/src/tanstack/auth/state.test.ts | 51 -- packages/vitnode/src/tanstack/auth/state.ts | 152 ----- .../src/tanstack/auth/transport.test.ts | 16 - .../vitnode/src/tanstack/auth/transport.ts | 70 +- .../vitnode/src/tanstack/breadcrumb/index.ts | 9 - .../tanstack/breadcrumb/main-breadcrumb.tsx | 30 - .../src/tanstack/breadcrumb/model.test.ts | 18 - .../vitnode/src/tanstack/breadcrumb/model.ts | 82 --- .../src/tanstack/devices/devices.test.ts | 25 - .../vitnode/src/tanstack/devices/index.ts | 21 +- .../vitnode/src/tanstack/devices/panel.tsx | 40 -- .../vitnode/src/tanstack/devices/query.ts | 109 --- .../vitnode/src/tanstack/devices/server.ts | 26 - .../vitnode/src/tanstack/fetcher/server.ts | 120 ---- packages/vitnode/src/tanstack/files/index.ts | 23 +- packages/vitnode/src/tanstack/files/query.ts | 123 ---- .../src/tanstack/files/route-search.test.ts | 25 - .../src/tanstack/files/route-search.ts | 145 ---- packages/vitnode/src/tanstack/files/route.tsx | 63 -- .../vitnode/src/tanstack/files/screen.tsx | 7 - packages/vitnode/src/tanstack/i18n/index.ts | 13 - packages/vitnode/src/tanstack/i18n/locale.ts | 70 -- .../vitnode/src/tanstack/i18n/messages.ts | 57 +- .../tanstack/i18n/provider-records.test.ts | 22 - .../vitnode/src/tanstack/i18n/query.test.ts | 49 -- packages/vitnode/src/tanstack/i18n/query.ts | 58 -- packages/vitnode/src/tanstack/i18n/request.ts | 17 - .../src/tanstack/i18n/route-messages.tsx | 60 -- .../vitnode/src/tanstack/i18n/runtime.test.ts | 39 -- packages/vitnode/src/tanstack/i18n/runtime.ts | 93 +-- .../i18n/server-config-messages.test.ts | 12 - packages/vitnode/src/tanstack/i18n/server.ts | 9 - .../src/tanstack/i18n/switch-locale.ts | 39 -- .../src/tanstack/layout/error-actions.tsx | 23 - .../vitnode/src/tanstack/layout/header.tsx | 116 +--- packages/vitnode/src/tanstack/layout/index.ts | 19 - .../src/tanstack/layout/language-switcher.tsx | 23 - .../src/tanstack/layout/main-header.tsx | 21 - .../vitnode/src/tanstack/layout/not-found.tsx | 36 - .../src/tanstack/layout/root-providers.tsx | 31 - .../src/tanstack/layout/router-link.tsx | 26 - .../src/tanstack/layout/user-header.tsx | 67 -- .../vitnode/src/tanstack/metadata/index.ts | 85 +-- .../src/tanstack/metadata/metadata.test.ts | 28 - .../pending/pending-navigation.test.tsx | 12 - .../src/tanstack/plugin-routes/collision.ts | 60 -- .../plugin-routes/components.test.tsx | 15 - .../src/tanstack/plugin-routes/components.tsx | 100 --- .../src/tanstack/plugin-routes/container.ts | 18 - .../src/tanstack/plugin-routes/guard.test.ts | 25 - .../src/tanstack/plugin-routes/guard.ts | 47 -- .../src/tanstack/plugin-routes/head.test.ts | 14 - .../src/tanstack/plugin-routes/head.ts | 22 - .../src/tanstack/plugin-routes/index.ts | 41 -- .../src/tanstack/plugin-routes/loader-data.ts | 46 +- .../tanstack/plugin-routes/module-ref.test.ts | 26 - .../src/tanstack/plugin-routes/module-ref.ts | 43 +- .../plugin-routes/mount-freshness.test.ts | 54 -- .../src/tanstack/plugin-routes/mount.tsx | 268 +------- .../plugin-routes/plugin-routes.test.ts | 16 - .../src/tanstack/plugin-routes/specs.test.ts | 14 - .../src/tanstack/plugin-routes/specs.ts | 94 +-- .../vitnode/src/tanstack/realtime/index.ts | 9 - .../tanstack/realtime/realtime-listeners.tsx | 87 --- .../src/tanstack/realtime/session.test.ts | 15 - .../vitnode/src/tanstack/realtime/session.ts | 39 -- .../routes/admin/admin-routes.test.ts | 106 --- .../src/tanstack/routes/admin/advanced.tsx | 55 +- .../src/tanstack/routes/admin/content.tsx | 84 +-- .../src/tanstack/routes/admin/index.tsx | 51 -- .../src/tanstack/routes/admin/staff.tsx | 79 +-- .../src/tanstack/routes/admin/system.tsx | 45 +- .../src/tanstack/routes/admin/users.tsx | 63 +- packages/vitnode/src/tanstack/routes/index.ts | 61 -- .../vitnode/src/tanstack/routes/main/auth.tsx | 93 +-- .../src/tanstack/routes/main/discovery.tsx | 32 +- .../src/tanstack/routes/main/files.tsx | 22 +- .../src/tanstack/routes/main/index.tsx | 149 +--- .../tanstack/routes/main/main-routes.test.ts | 33 - .../routes/main/not-found-matching.test.ts | 30 - .../src/tanstack/routes/main/not-found.tsx | 51 -- .../src/tanstack/routes/main/settings.tsx | 97 +-- .../vitnode/src/tanstack/routes/main/sso.tsx | 20 +- .../tanstack/routes/root/admin-sign-in.tsx | 24 +- .../src/tanstack/routes/root/index.tsx | 55 -- .../tanstack/routes/root/root-routes.test.ts | 52 -- .../vitnode/src/tanstack/routes/root/types.ts | 10 - packages/vitnode/src/tanstack/routes/types.ts | 68 -- .../src/tanstack/search/discover-route.tsx | 46 -- .../vitnode/src/tanstack/search/discover.ts | 29 - packages/vitnode/src/tanstack/search/feed.ts | 68 -- packages/vitnode/src/tanstack/search/index.ts | 18 - .../src/tanstack/search/route-search.test.ts | 10 - .../src/tanstack/search/route-search.ts | 51 -- .../src/tanstack/search/search-route.tsx | 41 -- .../vitnode/src/tanstack/settings/index.ts | 46 +- .../vitnode/src/tanstack/settings/layout.tsx | 40 -- .../vitnode/src/tanstack/settings/route.ts | 63 -- .../src/tanstack/start/create-start.test.ts | 34 +- .../src/tanstack/start/create-start.ts | 44 +- .../tanstack/start/document-headers.test.ts | 10 - .../src/tanstack/start/document-headers.ts | 76 --- packages/vitnode/src/tanstack/start/index.ts | 27 - .../vitnode/src/tests/content-fixtures.ts | 157 ----- packages/vitnode/src/tests/import-graph.ts | 82 --- .../vitnode/src/tests/openapi-validate.ts | 25 - .../vitnode/src/views/admin/admin-request.ts | 51 -- .../views/admin/layouts/admin-link.test.ts | 20 - .../src/views/admin/layouts/admin-link.tsx | 24 - .../breadcrumb/breadcrumb-admin-content.tsx | 29 +- .../breadcrumb/resolve-breadcrumb.test.ts | 19 - .../src/views/admin/layouts/normalize-url.ts | 24 - .../admin/layouts/search/search-content.tsx | 15 - .../layouts/search/search-dialog-content.tsx | 48 +- .../layouts/search/search-only-pages.tsx | 26 - .../layouts/search/search-sources.test.tsx | 28 - .../admin/layouts/search/search-users.ts | 32 - .../admin/layouts/shell-boundaries.test.ts | 39 -- .../layouts/sidebar/nav/item-content.tsx | 35 - .../layouts/sidebar/nav/nav-active.test.ts | 19 - .../admin/layouts/sidebar/nav/nav-active.ts | 21 - .../admin/layouts/sidebar/nav/nav-content.tsx | 11 - .../layouts/sidebar/nav/nav-model.test.tsx | 77 --- .../admin/layouts/sidebar/nav/nav-model.tsx | 119 +--- .../admin/layouts/sidebar/sidebar-content.tsx | 23 - .../layouts/user-bar/user-bar-content.tsx | 32 - .../admin/sign-in/sign-in-admin-content.tsx | 33 - .../src/views/admin/table/params.test.ts | 13 - .../vitnode/src/views/admin/table/params.ts | 112 --- .../vitnode/src/views/admin/table/query.ts | 14 - .../content/actions/conflict-messages.test.ts | 18 - .../views/content/actions/content-form.tsx | 9 - .../views/content/actions/content-panel.tsx | 8 - .../content/actions/delivery-model.test.ts | 10 - .../views/content/actions/delivery-model.ts | 37 - .../actions/delivery/delivery-panel.tsx | 11 - .../views/content/actions/editorial-api.ts | 43 -- .../content/actions/editorial-query.test.ts | 8 - .../views/content/actions/editorial-query.ts | 96 --- .../content/actions/editorial-transport.tsx | 95 +-- .../views/content/actions/form-dialog.tsx | 24 - .../views/content/actions/history-action.tsx | 9 - .../actions/history/revision-actor.tsx | 22 - .../content/actions/history/revision-diff.tsx | 7 - .../actions/history/revision-history.tsx | 32 +- .../content/actions/history/revision-row.tsx | 9 - .../views/content/actions/preview-action.tsx | 10 - .../content/actions/row-actions-model.test.ts | 10 - .../content/actions/row-actions-model.ts | 52 +- .../actions/schedule/schedule-panel.tsx | 17 - .../views/content/content-labels.test.ts | 16 - .../admin/views/content/content-labels.ts | 58 -- .../admin/views/content/content-mutation.ts | 79 +-- .../admin/views/content/content-query.test.ts | 51 -- .../admin/views/content/content-query.ts | 141 ---- .../admin/views/content/content-request.ts | 35 - .../admin/views/content/content-retry.test.ts | 59 -- .../admin/views/content/form/context.tsx | 41 +- .../views/content/form/contract.test-d.ts | 32 - .../admin/views/content/form/diff.test.ts | 9 - .../views/admin/views/content/form/diff.ts | 73 -- .../views/admin/views/content/form/index.ts | 21 - .../admin/views/content/form/item-query.ts | 44 -- .../views/content/form/mutations-api.test.ts | 27 - .../admin/views/content/form/mutations-api.ts | 35 - .../admin/views/content/form/navigation.tsx | 63 +- .../admin/views/content/form/transport.tsx | 104 +-- .../views/content/lib/api-result.test.ts | 16 - .../admin/views/content/lib/api-result.ts | 70 -- .../views/content/lib/field-component.tsx | 7 - .../views/content/lib/invalidate.test.ts | 22 - .../admin/views/content/lib/invalidate.ts | 71 -- .../views/content/lib/mutation-feedback.ts | 14 - .../views/content/lib/options-query.test.ts | 14 - .../admin/views/content/lib/options-query.ts | 57 -- .../views/content/lib/reference-options.ts | 21 +- .../admin/views/content/lib/user-option.ts | 9 - .../views/content/page/content-form-page.tsx | 10 - .../views/admin/views/content/table/cells.tsx | 7 - .../admin/views/content/table/columns.test.ts | 10 - .../admin/views/content/table/columns.tsx | 66 -- .../views/content/table/list-mutations.ts | 55 -- .../views/content/table/list-query.test.ts | 12 - .../admin/views/content/table/list-query.ts | 106 +-- .../views/core/advanced/cron/cron-query.ts | 74 -- .../core/advanced/cron/cron-table-content.tsx | 24 - .../advanced/cron/run-action/run-action.tsx | 14 - .../views/core/advanced/queue/queue-query.ts | 12 - .../advanced/queue/queue-table-content.tsx | 15 - .../advanced/search/collection-status.test.ts | 5 - .../core/advanced/search/collection-status.ts | 32 - .../search/collections-table-content.tsx | 15 - .../core/advanced/search/reindex-action.tsx | 6 - .../search/remove-documents-action.tsx | 7 - .../advanced/search/search-header-actions.tsx | 8 - .../advanced/search/search-index-content.tsx | 28 - .../advanced/search/search-index-mutations.ts | 38 -- .../advanced/search/search-index-query.ts | 27 - .../search/sync-errors-card-content.tsx | 7 - .../core/dashboard/grid/board-provider.tsx | 18 - .../dashboard/grid/widget-settings-dialog.tsx | 10 - .../core/dashboard/widgets/build-board.tsx | 19 - .../dashboard/widgets/dashboard-actions.ts | 40 +- .../core/dashboard/widgets/layout-query.ts | 61 -- .../dashboard/widgets/normalize-layout.ts | 11 - .../core/dashboard/widgets/resolve-widgets.ts | 23 - .../views/core/dashboard/widgets/types.ts | 31 +- .../dashboard/widgets/widget-mutations.ts | 20 - .../debug/actions/clear-cache/clear-cache.tsx | 19 - .../admin/views/core/debug/debug-query.ts | 21 - .../core/debug/queue/queue-view-content.tsx | 8 - .../system-logs/actions/more/content.tsx | 11 - .../debug/system-logs/actions/more/more.tsx | 6 - .../debug/system-logs/system-logs-content.tsx | 15 - .../admin/views/core/shared/admin-mutation.ts | 57 +- .../shared/admin-permission-parity.test.ts | 28 - .../views/core/shared/admin-permissions.ts | 57 -- .../admin/views/core/shared/admin-scope.ts | 60 -- .../create/create-staff-form-content.tsx | 18 - .../staff/edit/edit-staff-form-content.tsx | 40 -- .../admin/views/core/staff/staff-model.ts | 39 -- .../admin/views/core/staff/staff-mutations.ts | 40 -- .../admin/views/core/staff/staff-query.ts | 27 +- .../core/staff/table/staff-table-content.tsx | 25 - .../staff/table/staff-user-format-content.tsx | 8 - .../system/files/actions/file-row-actions.tsx | 14 - .../files/actions/files-bulk-actions.tsx | 8 - .../views/core/system/files/files-delete.ts | 49 -- .../views/core/system/files/files-query.ts | 30 - .../core/system/files/files-table-content.tsx | 30 - .../integrations/integrations-content.tsx | 29 - .../system/integrations/integrations-query.ts | 29 - .../send-test-email-mutation.ts | 14 - .../integrations/send-test-email/use-form.ts | 7 - .../core/users/detail/user-detail-content.tsx | 19 - .../core/users/detail/user-fields-content.tsx | 23 - .../views/core/users/detail/user-query.ts | 41 -- .../core/users/detail/user-roles-content.tsx | 15 - .../core/users/list/create-user-content.tsx | 13 - .../core/users/list/create-user-form.tsx | 12 - .../views/core/users/list/users-query.ts | 120 ---- .../core/users/list/users-table-content.tsx | 30 - .../core/users/roles/role-form-content.tsx | 17 - .../views/core/users/roles/roles-mutations.ts | 24 - .../views/core/users/roles/roles-query.ts | 55 -- .../core/users/roles/roles-table-content.tsx | 36 - .../admin/views/core/users/users-mutations.ts | 49 -- .../src/views/auth/auth-boundaries.test.ts | 32 +- packages/vitnode/src/views/auth/auth-link.ts | 36 - .../change-password-form-content.tsx | 17 - .../change-password-form/schema.ts | 40 -- .../use-change-password-form.ts | 29 - .../form/password-reset-form-content.tsx | 15 - .../views/auth/password-reset/form/schema.ts | 34 - .../form/use-password-reset-form.ts | 18 - .../password-reset/password-reset-content.tsx | 11 - .../auth/password-reset/recovery-link.ts | 83 --- .../auth/settings/devices/device-item.tsx | 14 - .../devices/devices-boundaries.test.ts | 20 +- .../auth/settings/devices/devices-content.tsx | 30 - .../settings/devices/devices-query.test.ts | 31 - .../auth/settings/devices/devices-query.ts | 211 +----- .../auth/settings/devices/devices-revoke.ts | 142 ---- .../settings/devices/revoke-device-button.tsx | 22 - .../src/views/auth/settings/nav-content.tsx | 15 - .../views/auth/settings/overview/overview.tsx | 16 - .../views/auth/settings/security/security.tsx | 12 - .../settings/settings-breadcrumb-content.tsx | 14 - .../src/views/auth/settings/settings-nav.ts | 53 -- .../src/views/auth/settings/shell-content.tsx | 31 - .../src/views/auth/sign-in/form/schema.ts | 32 - .../sign-in/form/sign-in-form-content.tsx | 20 - .../auth/sign-in/form/use-sign-in-form.ts | 22 - .../views/auth/sign-in/sign-in-content.tsx | 16 - .../src/views/auth/sign-up/form/schema.ts | 95 --- .../sign-up/form/sign-up-form-content.tsx | 19 +- .../auth/sign-up/form/use-sign-up-form.ts | 31 - .../views/auth/sign-up/sign-up-content.tsx | 23 - .../auth/sso/buttons/sso-buttons-content.tsx | 21 - .../sso/callback/sso-callback-content.tsx | 13 - .../auth/sso/callback/sso-callback-result.ts | 17 - .../auth/sso/callback/use-sso-callback.ts | 19 - .../vitnode/src/views/auth/sso/providers.ts | 22 - .../breadcrumb/breadcrumb-render-content.tsx | 19 - .../breadcrumb/breadcrumb-trail-content.tsx | 24 - .../vitnode/src/views/error/error-content.tsx | 15 - .../views/files/actions/file-row-actions.tsx | 15 - .../files/actions/files-bulk-actions.tsx | 14 - .../src/views/files/files-boundaries.test.ts | 19 +- .../vitnode/src/views/files/my-files-query.ts | 255 ------- .../views/files/my-files-table-content.tsx | 48 -- .../layouts/providers.boundaries.test.ts | 17 - .../vitnode/src/views/layouts/providers.tsx | 21 - .../theme/header/header-boundaries.test.ts | 36 - .../layouts/theme/header/header-content.tsx | 37 - .../layouts/theme/header/header-nav.test.ts | 9 - .../views/layouts/theme/header/header-nav.ts | 51 -- .../user/user-header-boundaries.test.ts | 21 - .../theme/header/user/user-header-content.tsx | 41 -- .../theme/header/user/user-header-model.ts | 111 --- .../views/layouts/theme/layout-content.tsx | 30 +- .../layouts/theme/theme-boundaries.test.ts | 21 +- .../layouts/theme/web-socket-auth-sync.tsx | 13 - .../src/views/search/feed-boundaries.test.ts | 25 +- .../views/search/search-controls-content.tsx | 56 -- .../views/search/search-feed-content.test.tsx | 28 - .../src/views/search/search-feed-content.tsx | 55 -- .../src/views/search/search-feed-query.ts | 65 +- .../src/views/search/search-params.test.ts | 10 - .../vitnode/src/views/search/search-params.ts | 123 ---- packages/vitnode/src/vitnode.config.ts | 165 +---- packages/vitnode/src/ws/auth-sync.test.ts | 11 - packages/vitnode/src/ws/auth-sync.ts | 52 -- packages/vitnode/src/ws/handle.ts | 27 - packages/vitnode/src/ws/manager.ts | 22 +- packages/vitnode/src/ws/notifications.ts | 7 - .../vitnode/src/ws/pubsub-lifetime.test.ts | 40 -- packages/vitnode/src/ws/registry.ts | 40 -- packages/vitnode/src/ws/types.ts | 22 - packages/vitnode/src/ws/use-websocket.ts | 14 - .../config-split/vitnode.config.ts | 10 +- .../config-split/vitnode.server.config.ts | 11 +- .../test-fixtures/next-specimen/entry.ts | 21 +- plugins/blog/src/admin/content.tsx | 34 - plugins/blog/src/admin/nav.tsx | 29 - plugins/blog/src/api/lib/events.ts | 47 +- .../src/api/modules/admin/admin.module.ts | 10 - plugins/blog/src/config.api.ts | 15 - plugins/blog/src/config.test-d.ts | 15 - plugins/blog/src/config.tsx | 21 - plugins/blog/src/content/category.ts | 19 - .../blog/src/content/content-types.test.ts | 13 - plugins/blog/src/content/post.ts | 126 +--- .../src/views/admin/article/editor-field.tsx | 20 - plugins/example/src/admin/content.tsx | 19 - plugins/example/src/admin/nav.tsx | 45 -- plugins/example/src/api/lib/events.ts | 7 - .../src/api/modules/admin/admin.module.ts | 7 - plugins/example/src/config.api.ts | 15 - plugins/example/src/config.tsx | 22 - plugins/example/src/const.ts | 14 - .../example/src/content/advanced-article.ts | 78 +-- plugins/example/src/content/article.ts | 80 +-- plugins/example/src/content/category.ts | 6 - .../example/src/content/file-fields.test.ts | 12 - .../example/src/content/localized-article.ts | 38 -- .../src/database/advanced-routes.test.ts | 10 - .../src/database/advanced-tables.test.ts | 9 - plugins/example/src/database/tables.test.ts | 7 - .../example/src/pages/admin-example-page.tsx | 38 -- plugins/example/src/pages/browse-page.tsx | 42 +- plugins/example/src/pages/browse-search.ts | 29 - plugins/example/src/pages/example-page.tsx | 23 - .../example/src/pages/guide-index-page.tsx | 18 - plugins/example/src/pages/guide-layout.tsx | 45 -- .../example/src/pages/guide-topic-page.tsx | 71 +- plugins/example/src/routes.ts | 102 --- scripts/bump-version/bump-version.ts | 8 +- 919 files changed, 370 insertions(+), 36808 deletions(-) diff --git a/apps/api/src/i18n.ts b/apps/api/src/i18n.ts index e82cbd6e0..906d78af9 100644 --- a/apps/api/src/i18n.ts +++ b/apps/api/src/i18n.ts @@ -1,24 +1,5 @@ import type { VitNodeI18nConfig } from "@vitnode/core/lib/i18n/types"; -/** - * The languages this installation serves. - * - * This API and `apps/web` are two halves of one installation - the same - * Postgres, the same `core_languages` - and this app is the half that owns the - * schema, so `vitnode db:prepare` seeds the database from *this* list. That is - * why it is spelled out rather than left empty: the bootstrap used to look for - * the web app's `src/vitnode.config.ts` by walking the filesystem, never found - * it from here, and seeded `en` alone into a database serving `en` and `pl`. - * The API config is now the only thing it reads. - * - * It must stay in step with `apps/web/src/i18n.ts`, and - * `apps/web/src/tests/installation-locales.test.ts` fails if the two drift. - * A generated split deployment has the same obligation and the same shape: two - * apps, one declaration each, and no filesystem discovery between them. - * - * Packages ship their own translations, so nothing here lists them - a locale - * with no `messages` entry falls back to `defaultLocale` key by key. - */ export const i18n = { defaultLocale: "en", /** diff --git a/apps/api/src/vitnode.api.config.ts b/apps/api/src/vitnode.api.config.ts index 46134dd42..66b7f0b22 100644 --- a/apps/api/src/vitnode.api.config.ts +++ b/apps/api/src/vitnode.api.config.ts @@ -46,15 +46,7 @@ export const vitNodeApiConfig = buildApiConfig({ }, ], }, - /** - * The installation's languages, from the one module that declares them. - * - * Not optional here, whatever the type says: this app owns the schema, so - * `vitnode db:prepare` seeds `core_languages` from this list. Left unset, the - * API derives its locales from whatever the installed packages ship - which - * answers "what can be translated", not "what does this site serve" - and the - * seed falls back to `en` alone. - */ + i18n, dbProvider: drizzle({ connection: POSTGRES_URL, @@ -100,21 +92,7 @@ export const vitNodeApiConfig = buildApiConfig({ bucket: process.env.SUPABASE_STORAGE_BUCKET, }), }, - /** - * Sign-in with Discord, Google and Facebook. - * - * Carried over from `apps/docs` when Stage 17 deleted it: that application's - * config was the only place in the repo registering these three adapters, and - * `@vitnode/core/api/adapters/sso/*` would otherwise have had no consumer at - * all - implementations behind a live route (`/login/sso/:providerId`) with - * nothing showing how to switch them on. - * - * Registering one with its environment variables unset is safe and is the - * normal state here: each adapter defaults `clientId`/`clientSecret` to `""` - * and only fails when somebody actually tries that provider, with "Missing - * Discord client ID or secret" rather than a broken login page. So the list is - * what this API *supports*; the environment decides what works. - */ + authorization: { ssoAdapters: [ DiscordSSOApiPlugin({ diff --git a/apps/web/source.config.ts b/apps/web/source.config.ts index 51ea128c2..7aedd62b7 100644 --- a/apps/web/source.config.ts +++ b/apps/web/source.config.ts @@ -1,44 +1,6 @@ import { defineConfig, defineDocs } from 'fumadocs-mdx/config' import jsonSchema from 'fumadocs-mdx/plugins/json-schema' -/** - * The documentation collection, and the three capabilities the site is built on. - * - * `dir` is this application's own `content/docs`. Nothing here reads from - * `apps/docs`: Stage 16 copied the source across, and `apps/web` owns it from - * now on - see `src/tests/docs-source.test.ts`, which fails if a docs file ever - * points a reader back at the directory Stage 17 deletes. - * - * ## `async` is the lazy half of the server collection - * - * Without it, `.source/server.ts` globs every document with `eager: true`, so - * *reaching* the source loader compiles all ~120 documents - MDX, Shiki - * highlighting and all - before it can answer which one `/docs/dev` is. That - * cost lands on the first request to any docs URL in the dev server, and on the - * SSR pass in production, for a lookup that only ever reads frontmatter. - * - * `async: true` splits that glob in two. The frontmatter stays eager, but is - * transformed with `?only=frontmatter` - a one-line module per file, no MDX - * compiler - and the body becomes `() => import(…)` behind `page.data.load()`. - * The page tree, the `<title>` and the 404 are all frontmatter, so a docs - * request now compiles the one document it is going to render, which is also - * exactly what the browser collection has always done. - * - * Nothing in `src/docs` had to change for it, and that is the point of the seam - * `src/docs/transport.ts` describes: the body was never read on the server. The - * two consumers that do want content ask for it asynchronously already - - * `getText("processed")` in `source.server.ts`, and `createFromSource`, which - * awaits `structuredData()` when a collection is lazy. - * - * `includeProcessedMarkdown` keeps the rendered Markdown of every page next to - * its compiled component, which is what `/llms-full.txt` is made of. Without it - * `page.data.getText("processed")` has nothing to return and the LLM output is - * empty rather than missing. It survives `async`: the Markdown rides in the - * lazily-imported module, so that call now loads the body it reads from. - * - * `jsonSchema()` lets a doc render a JSON Schema file as a type table. It is - * carried over from the Next.js configuration unchanged. - */ export const docs = defineDocs({ dir: 'content/docs', docs: { diff --git a/apps/web/src/admin-nav.gen.ts b/apps/web/src/admin-nav.gen.ts index 4dd13fac3..7158d7267 100644 --- a/apps/web/src/admin-nav.gen.ts +++ b/apps/web/src/admin-nav.gen.ts @@ -14,26 +14,7 @@ import type { AdminNavPluginSource } from '@vitnode/core/lib/plugin' import { adminNav as adminNav0 } from '@vitnode/blog/admin/nav' import { adminNav as adminNav1 } from '@vitnode/example/admin/nav' -/** - * Every configured plugin's AdminCP navigation, as browser-safe data. - * - * A plugin appears here by exporting `admin/nav`, and a plugin that does not - * contributes no sidebar entries. That is not an error - most plugins have - * nothing to put in the AdminCP - and it is checked at build time rather than - * discovered in a browser: the specifiers below are literal, so a bundler - * resolves them and nothing is reached through a computed string. - * - * This is deliberately **not** the plugin's frontend registration. - * `blogPlugin()` and friends register content types with their editing screens - * attached - an editor field, a form layout, a table cell - the whole of core's - * form stack. A sidebar needs ids, hrefs, permissions, icons and content type - * definitions, all of which are plain data, so that is all this carries. See - * `AdminNavPluginSource`. - * - * Navigation is not derived from the route manifest and never will be: a nav - * entry may point at a plugin route, at a legacy screen, or at another origin, - * and a plugin route may deliberately have no entry at all. - */ + export const pluginAdminNav = [ adminNav0, // @vitnode/blog adminNav1, // @vitnode/example diff --git a/apps/web/src/components/admin-shell.tsx b/apps/web/src/components/admin-shell.tsx index 66ae942c1..27190d1dc 100644 --- a/apps/web/src/components/admin-shell.tsx +++ b/apps/web/src/components/admin-shell.tsx @@ -7,50 +7,6 @@ import { adminNav } from '#/lib/admin-nav' import { adminUserSearchFn } from '#/lib/admin-search' import { useAppNavigate } from '#/lib/navigation' -/** - * The AdminCP shell, as this app mounts it. - * - * Everything the panel *is* - the sidebar, the header, the palette, the user - * menu, the breadcrumb area and the one `<main>` - is `AdminShellContent`'s. - * What is bound here is only what a package cannot answer for an application: - * - * onNavigate useAppNavigate the palette's Enter key - * searchUsers adminUserSearchFn this app's own server function - * languageSwitcher <LanguageSwitcher/> the router's - * nav adminNav the plugins *this* app configured - * - * No `LinkComponent`: every sidebar destination is a route in this application's - * own tree, so the shell's own default - `RouterLink` - is the right one. The - * exception is handled a layer down rather than here: a plugin's `admin.nav` - * entry may point at a docs site or a status page, and `adminLinkFor` renders - * those as plain anchors, so an absolute URL is never handed to a router that - * would try to match it. The sidebar and the command palette both go through it, - * so an entry cannot behave one way when clicked and another when searched. - * - * `onNavigate` is still passed, because the palette moves the router *without a - * link*: Enter on a highlighted entry is a navigation nobody clicked, and it has - * to be handed the same de-localized destination a `<Link>` would build. See - * `#/lib/navigation`. - * - * ## `nav` is a projection, not the plugin registry - * - * `src/vitnode.config.ts` is server-side and carries message loaders, which a - * browser bundle has no business holding. So the sidebar is built from - * `#/lib/admin-nav`, which reads the generated browser-safe projection: ids, - * hrefs, permissions, icons and content type definitions, and nothing that - * renders a screen. The screens have a projection of their own - see - * `#/lib/content-registry`, which the content route imports so that a plugin's - * editor fields and form layouts land in that route's chunk rather than in the - * shell's. - * - * It carries the message namespaces with it, because a plugin group's headings - * live under that plugin's own id and the shell would otherwise render them as - * dotted identifiers. `_admin`'s loader warms the same list. - * - * No navigation is derived from the route manifest, in either direction: the - * navigation model is complete regardless of which screen a click lands on, and - * a nav entry is a product decision rather than a consequence of the route tree. - */ const searchUsers: AdminUserSearch = async (search) => await adminUserSearchFn({ data: { search } }) diff --git a/apps/web/src/components/fumadocs/img.tsx b/apps/web/src/components/fumadocs/img.tsx index 302076d63..a32767a9f 100644 --- a/apps/web/src/components/fumadocs/img.tsx +++ b/apps/web/src/components/fumadocs/img.tsx @@ -1,20 +1,6 @@ import { cn } from '@vitnode/core/lib/utils' import { ImageZoom } from 'fumadocs-ui/components/image-zoom' -/** - * A screenshot inside a document, framed and click-to-zoom. - * - * Seven MDX files import this **by path** - `@/components/fumadocs/img` - so the - * module has to keep both its location and its name. That is the whole reason it - * is here rather than in `src/docs/`: the alias and the path are part of the - * content, and moving the file would mean editing the copied documentation to - * suit the implementation. - * - * Carried over from the Next.js application unchanged apart from its import - * style. It takes a Vite asset import as `src`, which is a URL string here - * rather than Next's `StaticImport` - so `next/image` never comes into it, and - * the plain `<img>` inside `ImageZoom` is what renders. - */ export const ImgDocs = ({ className, imgClassName, diff --git a/apps/web/src/components/main-header.tsx b/apps/web/src/components/main-header.tsx index 15559365d..cd5c91a22 100644 --- a/apps/web/src/components/main-header.tsx +++ b/apps/web/src/components/main-header.tsx @@ -1,36 +1,6 @@ import { LogoVitNodeBrand } from '@vitnode/core/components/logo-vitnode' import { MainHeader as MainHeaderContent } from '@vitnode/core/tanstack/layout' -/** - * The site header, as the main shell's slot for it. - * - * One line of application. The bar, the nav, the language and theme switchers, - * the user area and both cache entries they read are - * `@vitnode/core/tanstack/layout`'s `MainHeader`; what this app adds is the one - * thing only it can answer. - * - * **The mark.** `LogoVitNodeBrand` is core's own - the canonical `LogoVitNode` - * at whichever of its two sizes the viewport has room for - and it is also what - * the header would default to, so passing it changes no pixel. What it changes - * is where the answer lives: this file is vitnode.com, and vitnode.com choosing - * VitNode's mark is a sentence a site should say out loud rather than inherit. - * An application with its own mark replaces this one prop. - * - * No `LinkComponent`. Every header destination is a route in this application's - * own tree, so the header's own default - `RouterLink`, the router's `Link` in - * the shape the shared views ask for - is the right one, and the prop stays - * available for a host that needs to answer differently. - * - * ## What the shell owes it - * - * Two warm cache entries, both ensured by `_main`'s loader: - * - * headerIntlQueryOptions -> a `useSuspenseQuery`, so this is required - * prefetchSession -> the first paint shows the visitor, not a gap - * - * See the loader in `routes/_main.tsx`, which states why one is `ensure` and the - * other `prefetch`. - */ export const MainHeader = () => ( <MainHeaderContent logo={<LogoVitNodeBrand />} /> ) diff --git a/apps/web/src/content-registry.gen.ts b/apps/web/src/content-registry.gen.ts index b8fce83f5..3051dd537 100644 --- a/apps/web/src/content-registry.gen.ts +++ b/apps/web/src/content-registry.gen.ts @@ -14,31 +14,7 @@ import type { ContentFrontendPluginSource } from '@vitnode/core/lib/plugin' import { adminContent as adminContent0 } from '@vitnode/blog/admin/content' import { adminContent as adminContent1 } from '@vitnode/example/admin/content' -/** - * Every configured plugin's Content Engine frontend registration. - * - * A plugin appears here by exporting `admin/content`, and a plugin that does - * not contributes no content types to the AdminCP. That is not an error - most - * plugins register none - and it is decided at build time rather than - * discovered in a browser: the specifiers below are literal, so a bundler - * resolves them and nothing is reached through a computed string. - * - * This carries more than `admin-nav.gen.ts` and less than - * `vitnode.config.ts`. Navigation needs ids, hrefs, permissions and icons; a - * content *screen* needs those plus the components that replace a generated - * input, a generated table cell and a generated form layout. Neither needs the - * server config, which carries message loaders and API wiring a browser bundle - * has no business holding. - * - * Nothing is copied and nothing is serialised: React components cannot cross a - * JSON boundary, so the registrations stay compiled in each plugin's own - * `dist` and arrive through a specifier a bundler resolved. - * - * The registry built from this is validated by `@vitnode/core` - - * `buildContentFrontendRegistry` runs the same `validateContentTypes` the - * API side runs, so two plugins claiming one `admin.path` fail loudly here - * rather than giving one of them a screen and the other nothing. - */ + export const pluginContentTypes = [ adminContent0, // @vitnode/blog adminContent1, // @vitnode/example diff --git a/apps/web/src/docs/article.tsx b/apps/web/src/docs/article.tsx index 1624d6400..02fd5cb94 100644 --- a/apps/web/src/docs/article.tsx +++ b/apps/web/src/docs/article.tsx @@ -13,28 +13,6 @@ export interface DocsArticleMeta { url: string } -/** - * What a documentation page looks like: the heading, the "Open in" menu, the - * table of contents, and the body. - * - * Presentation only, and that is what makes the split worth having. The body - * arrives as `children` and the table of contents as a prop, so this module - * knows nothing about how either was loaded - `./client-loader` is the half that - * does, and it renders this. - * - * ## Why the table of contents is a prop rather than loader data - * - * Because it cannot cross a server function. A `TOCItemType`'s `title` is a - * `ReactNode`, so a heading containing inline code or a link is a React element, - * and TanStack Start's serializer rejects those by design - correctly, and at - * compile time. It does not need to cross: the compiled MDX module exports its - * own `toc` beside its component, so the table of contents arrives in the same - * chunk as the body it describes. - * - * `tableOfContent` keeps the Next.js application's settings verbatim - the - * "clerk" style, with `single: false` so a heading's children stay visible while - * you read it. - */ export const DocsArticle = ({ children, description, diff --git a/apps/web/src/docs/client-loader.tsx b/apps/web/src/docs/client-loader.tsx index bee876d1d..c88f815a1 100644 --- a/apps/web/src/docs/client-loader.tsx +++ b/apps/web/src/docs/client-loader.tsx @@ -5,29 +5,6 @@ import type { DocsArticleMeta } from './article' import { DocsArticle } from './article' import { mdxComponents } from './mdx-components' -/** - * The browser's half of the MDX split: a page's *path* in, its rendered page - * out. - * - * `collections/browser` is `.source/browser.ts`, generated by - * `fumadocs-mdx/vite`: a record of `() => import("…/dev/index.mdx")` thunks and - * nothing else. Importing it costs the thunks, not the documents - every body is - * its own chunk, fetched when a reader asks for that page. - * - * `createClientLoader` is Fumadocs' own API for this, and the reason nothing - * here serializes a component. `preload(path)` starts the import (the route's - * loader awaits it, so the first paint already has the body) and - * `useContent(path, props)` renders whatever it resolved. - * - * The loaded module carries `toc` as well as the component, which is what makes - * the table of contents a prop rather than loader data - see `./article`. - * - * `id` is not optional in practice, whatever its default says. TanStack Start's - * code splitter can duplicate this module into more than one chunk - a route's - * `component` and its `notFoundComponent` are split separately - and two client - * loaders with no shared id keep two caches, so a body preloaded by the loader - * would be fetched a second time by the component. The id makes them one. - */ export const docsClientLoader = browserCollections.docs.createClientLoader({ id: 'vitnode-docs', component: ({ default: MDX, toc }, props: DocsArticleMeta) => ( diff --git a/apps/web/src/docs/freshness.ts b/apps/web/src/docs/freshness.ts index 1ee171dc9..d48aaecbc 100644 --- a/apps/web/src/docs/freshness.ts +++ b/apps/web/src/docs/freshness.ts @@ -1,75 +1,5 @@ -/** - * How long a documentation build stays true, and the two places that answer - * differently in development. - * - * In production the answer is "forever": `content/docs` is build output, frozen - * when the server started, and the page tree, the search index and - * `/llms-full.txt` are all derived from it. Computing any of them twice is - * waste. - * - * While somebody is *writing* documentation the answer is the opposite, and the - * cost of getting it wrong is a dev server that has to be restarted to see a - * renamed page. Both policies live here rather than being spelled out at each - * of the three call sites, because "how fresh is the documentation" is one - * question and it was about to be answered three ways. - * - * It is deliberately app-local. `@vitnode/core` is externalised from this app's - * SSR pass and is built once for every host; `import.meta.env` is this build's, - * and a package that branched on it would be branching on whoever bundled it. - */ - -/** - * How long the router may reuse a loaded page tree - `_docs`'s `staleTime`. - * - * `Infinity` in production, because the tree is a constant: identical for every - * visitor, unchanged for the life of the process, and refetching it on every - * navigation inside the documentation would be a round trip for something that - * cannot have changed. - * - * `0` in development, so that adding a page, renaming one or editing a - * `meta.json` shows up in the sidebar on the next navigation instead of on the - * next server restart. The loader re-runs, the server function runs again, and - * Vite has by then re-executed the collection - see {@link memoizePerSource} for - * why that last step is what actually makes the answer new. - */ export const DOCS_TREE_STALE_TIME = import.meta.env.PROD ? Infinity : 0 -/** - * Compute something from the documentation source once per *version* of that - * source. - * - * The search index and `/llms-full.txt` are both expensive, both derived from - * the whole collection, and both were memoized in a module-level `let` - which - * is right in production and wrong for the rest of the time: the promise - * outlives every edit, so a dev server keeps answering with the documentation it - * read at boot. - * - * The fix is not a flag. It is to notice that the thing being cached is a - * function *of the source module*, and to key the cache on that module: - * - * production one module instance for the process -> computed once - * development Vite re-executes the collection when `content/docs` changes, - * which produces a new module namespace -> computed again - * - * So one mechanism gives both behaviours, and neither of them is a guess about - * the environment. A repeated search between two edits still reuses the index - - * the module has not been replaced, so the key has not changed - which is the - * half a plain "recompute in dev" would have thrown away. - * - * A `WeakMap` rather than a `Map` because the key is the previous module - * namespace: after an edit nothing references it any more, and holding the index - * built from a document somebody has already changed is how a dev server grows - * for an afternoon. - * - * `loadSource` is a parameter rather than a hard-coded import, for two reasons. - * It makes this testable without Vite and without the documentation - see - * `src/tests/docs-freshness.test.ts`. And it keeps this module client-safe: - * `_docs.tsx` imports {@link DOCS_TREE_STALE_TIME} from here, so an - * `import("./source.server")` written *in this file* would put the whole content - * index in the browser build as a lazy chunk. The two server routes pass their - * own loader, and Start prunes those route files out of the client tree - * entirely. - */ export const memoizePerSource = <TSource extends object, TValue>( loadSource: () => Promise<TSource>, compute: (source: TSource) => Promise<TValue>, diff --git a/apps/web/src/docs/github.ts b/apps/web/src/docs/github.ts index ccf137deb..a8949d369 100644 --- a/apps/web/src/docs/github.ts +++ b/apps/web/src/docs/github.ts @@ -1,19 +1,3 @@ -/** - * Where a documentation page's source actually lives, so a reader can edit it. - * - * `apps/web/content/docs` and not `apps/docs/content/docs`. Stage 16 moved the - * ownership of the documentation into this application; the Next.js copy is - * still on disk only until Stage 17 deletes it, and a "View source" link - * pointing at a file scheduled for deletion is a link that will 404 for every - * contributor who follows it after that stage lands. - * - * Pure, and separate from the component that renders the link, because it is - * the one thing in the View Options menu worth asserting - see - * `src/tests/docs-source.test.ts`. - * - * `page.path` is the path inside the collection (`dev/plugins/create.mdx`), - * which is exactly the tail of the repository path. - */ export const DOCS_SOURCE_DIRECTORY = 'apps/web/content/docs' const REPOSITORY_BLOB_URL = diff --git a/apps/web/src/docs/mdx-components.tsx b/apps/web/src/docs/mdx-components.tsx index fd6e628bf..807e9e714 100644 --- a/apps/web/src/docs/mdx-components.tsx +++ b/apps/web/src/docs/mdx-components.tsx @@ -3,36 +3,6 @@ import defaultMdxComponents from 'fumadocs-ui/mdx' import { Preview } from './preview' -/** - * The components every document is rendered with - one map, in one module. - * - * Fumadocs' defaults carry the bulk of it: `Callout`, `Card`, `Cards`, the code - * block and its tabs, the heading anchors, the table wrapper, and - the one that - * matters most here - `a`, which is the framework link. That is why a Markdown - * link like `[plugin routes](/docs/dev/plugins/routes)` becomes a - * client-side navigation that keeps the locale prefix: the anchor resolves - * through `fumadocs-core/framework/tanstack`, which renders TanStack Router's - * own `<Link>`, which builds its href through this app's `rewrite`. There is no - * link adapter in this application and there must not be one - see - * `src/routes/_docs.tsx`. - * - * Three additions on top, and they are the same three the Next.js application - * added: - * - * - `Steps` / `Step`, used by 48 documents, which Fumadocs ships but does not - * include in the default map. - * - `Preview`, which is this site's own: a live rendering of a VitNode component - * beside the prose describing it. - * - * `TypeTable`, `Tabs`/`Tab` and `ImgDocs` are deliberately absent. A document - * that needs one imports it at the top of its own MDX file, which is how they - * were written and how they stay: a component used by a dozen pages does not - * need to be in the map every page pays for. - * - * Declared once and exported, rather than built inline in the route: the Next.js - * page assembled this object in its render, and a second copy appearing in a - * second route is exactly the drift this stage is meant to prevent. - */ export const mdxComponents = { ...defaultMdxComponents, Preview, diff --git a/apps/web/src/docs/moved-pages.ts b/apps/web/src/docs/moved-pages.ts index d85903e17..b2a20228b 100644 --- a/apps/web/src/docs/moved-pages.ts +++ b/apps/web/src/docs/moved-pages.ts @@ -1,21 +1,3 @@ -/** - * Where a document used to live, and where it lives now. - * - * The documentation was reorganised: `docs/tanstack/*` folded into `docs/dev/*`, - * `docs/dev/tanstack/*` was dissolved into the categories its pages actually - * belong to - routing with routing, metadata with metadata, the plugin tutorial - * with the plugin section - and the two AdminCP pages moved under - * `docs/dev/plugins/admin`. Those old URLs shipped, which means they are in - * somebody's bookmarks and in a search index, so `/docs/$` answers them with a - * `301` to the new slug rather than a 404. - * - * A `301` and not a `302`: the move is permanent, and a permanent redirect is - * what transfers the old URL's ranking to the new one. - * - * Slugs here are what `/docs/$` receives as its splat - the path after `/docs/`, - * with no leading or trailing slash and no locale prefix, because the router has - * already stripped that. - */ const EXACT_MOVES: Record<string, string> = { 'dev/not-found': 'dev/routing/not-found', 'dev/plugins/admin-page': 'dev/plugins/admin', @@ -34,11 +16,6 @@ const EXACT_MOVES: Record<string, string> = { tanstack: 'dev', } -/** - * The `docs/tanstack/*` tree kept its shape when it moved under `docs/dev`, so - * one prefix rule covers all fourteen of its pages instead of fourteen entries. - * `docs/tanstack/database/pagination` becomes `docs/dev/database/pagination`. - */ const PREFIX_MOVES: [from: string, to: string][] = [['tanstack/', 'dev/']] export const movedDocsSlug = (slug: string): string | undefined => { diff --git a/apps/web/src/docs/page-content.tsx b/apps/web/src/docs/page-content.tsx index 4b9a44eb8..cb118523a 100644 --- a/apps/web/src/docs/page-content.tsx +++ b/apps/web/src/docs/page-content.tsx @@ -2,26 +2,6 @@ import type { DocsPageData } from './transport' import { docsClientLoader } from './client-loader' -/** - * One document, rendered from what its route's loader resolved. - * - * Small, and its own module for a reason that is not size: it is the first thing - * in the documentation graph that reaches Fumadocs' UI, and the route may only - * name it through `component`. A route's `loader` and `head` are evaluated in - * the client entry on *every* page of the application, so an import the route - * file made statically would put the documentation runtime in front of every - * visitor to the front page. - * - * `path` selects the compiled MDX module; the rest is what the page looks like - * around it. Both come from the one server-side lookup the loader made, so the - * `<h1>`, the `<title>` and the body cannot disagree - see `./transport`. - * - * `metaTitle` is deliberately not passed on: it is the tab's title, with the - * page's ancestors appended, and the heading shows `title`. - * - * `useContent` reads the module through `use()`, so a body that somehow was not - * preloaded suspends rather than flashing empty. - */ export const DocsPageContent = (page: DocsPageData): React.ReactNode => docsClientLoader.useContent(page.path, { description: page.description, diff --git a/apps/web/src/docs/preview-data-table.tsx b/apps/web/src/docs/preview-data-table.tsx index 4d46dbc5a..18f1ab3d8 100644 --- a/apps/web/src/docs/preview-data-table.tsx +++ b/apps/web/src/docs/preview-data-table.tsx @@ -10,26 +10,6 @@ import React from 'react' export type { ColumnDef } from '@vitnode/core/components/table/data-table-content' -/** - * The data table, wired for a documentation preview. - * - * The two table examples used `@vitnode/core/components/table/data-table`, which - * is the table wired to **Next.js**: it mounts `NextDataTableNavigation`, which - * reads `next/navigation` and `next-intl`'s locale-aware router. That module - * cannot exist in this application, and importing it would have been the one - * thing that made a documentation page drag Next.js into a Next-free build. - * - * So the preview supplies the same seam the AdminCP screens supply: a - * `DataTableNavigation` - "here is the query string, and here is how to change - * it" - around the shared `ContentDataTable`. The table itself is identical; - * only the two lines that decide what "sort by this column" means are different, - * which is exactly the split `components/table/navigation.tsx` exists for. - * - * The state is local rather than the URL, and for a demo that is a feature: a - * reader sorting a table of four fictional users should not have the page they - * are reading navigate underneath them, and four readers sorting four previews - * on one page would otherwise fight over one query string. - */ export function DataTable<T extends DataTableTMin>(props: DataTableProps<T>) { const [search, setSearch] = React.useState('') diff --git a/apps/web/src/docs/preview.tsx b/apps/web/src/docs/preview.tsx index 04af368f9..39c345098 100644 --- a/apps/web/src/docs/preview.tsx +++ b/apps/web/src/docs/preview.tsx @@ -2,30 +2,6 @@ import { Loader } from '@vitnode/core/components/ui/loader' import { cn } from '@vitnode/core/lib/utils' import React from 'react' -/** - * A live VitNode component, rendered inside the document that describes it. - * - * `<Preview name="button" />` in an MDX file renders - * `src/docs/examples/button.tsx` - 37 documents use it, and it is the reason the - * UI section of the documentation is worth reading rather than a list of prop - * tables. - * - * ## Why the examples are a glob rather than a template literal - * - * The Next.js version called ``dynamic(() => import(`../../examples/${name}.tsx`))`` - * *inside the render*, which meant a new component identity on every render and - * an `eslint-disable` on both lines to say so. `import.meta.glob` is Vite's own - * answer: it resolves the whole directory at build time into a record of lazy - * importers, and every `React.lazy` below is created **once, at module scope**. - * So switching tabs on a page no longer remounts the demo, and the rule that - * catches components created during render has nothing to complain about. - * - * `React.lazy` does not call its importer until something renders it, so the map - * costs 37 closures and no chunks. `eager: false` is the default and is stated - * anyway, because it is the whole point: eagerly globbing this directory would - * put every example - the editor, the data table, the whole form stack - into - * the documentation's chunk. - */ const exampleModules = import.meta.glob<{ default: React.ComponentType }>('./examples/*.tsx', { eager: false }) diff --git a/apps/web/src/docs/search-dialog.tsx b/apps/web/src/docs/search-dialog.tsx index 03f079273..cd1bbc072 100644 --- a/apps/web/src/docs/search-dialog.tsx +++ b/apps/web/src/docs/search-dialog.tsx @@ -13,22 +13,6 @@ import { import { DOCS_SEARCH_PATH } from './search-path' -/** - * The documentation's own search, opened with `⌘K` from anywhere under `/docs`. - * - * Fumadocs' own dialog and Fumadocs' own client - `type: "fetch"` queries the - * index over HTTP and gets back titles, headings and the text under them, which - * is what makes searching for a sentence in the middle of a page work. - * - * The only thing this application changes is `api`. It defaults to - * `/api/search`, and `/api/*` is Hono's; see `./search-path`. - * - * It is deliberately *not* the site-wide search at `/search`. That one indexes - * VitNode content through the API - posts, articles, whatever a plugin - * registered - and knows nothing about MDX; this one indexes documentation and - * knows nothing about content. Two indexes, two dialogs, and the docs shell is - * the only place this one is mounted. - */ const DocsSearchDialog = (props: SharedProps) => { const { query, search, setSearch } = useDocsSearch({ api: DOCS_SEARCH_PATH, diff --git a/apps/web/src/docs/search-path.ts b/apps/web/src/docs/search-path.ts index e146425bf..0c3a56fab 100644 --- a/apps/web/src/docs/search-path.ts +++ b/apps/web/src/docs/search-path.ts @@ -1,34 +1 @@ -/** - * Where the documentation's search index answers, and why it is not - * `/api/search`. - * - * `/api/*` belongs to Hono. It is the whole of this application's API and its - * security boundary, mounted by `src/routes/api/$.ts` and shared verbatim with - * `apps/api`, and Fumadocs claiming a path inside it would be a second, - * unrelated server answering from the same namespace - the exact ambiguity the - * migration has spent fifteen stages avoiding. Routing the search *through* Hono - * instead would be worse in a different way: the index is built from the MDX - * collection, so the API process would have to import the whole documentation - * build graph to answer a query about it. - * - * So the search index is a documentation resource and lives with the - * documentation: `/docs/search`, served by `src/routes/docs.search.ts`, which is - * a server route and renders no document. - * - * ## It is a root-relative path on purpose - * - * The reader may be at `/pl/docs/dev`, and this is passed to `fetch` - which - * resolves a leading slash against the origin, not the current path. So the - * request is `/docs/search` from every page in every language, which is the one - * spelling the server route is mounted at. The results carry internal URLs - * (`/docs/dev/...`); the locale prefix is put back by the router when the reader - * navigates to one, exactly as it is for every other link in this application. - * - * ## The one thing this shadows - * - * A static segment outranks a splat, so this path is not reachable as a - * document: a `content/docs/search.mdx` would be indexed and never render. - * There is no such file, and `src/tests/docs-route.test.ts` fails if one is ever - * added - which turns a silently unreachable page into a failing build. - */ export const DOCS_SEARCH_PATH = '/docs/search' diff --git a/apps/web/src/docs/section.ts b/apps/web/src/docs/section.ts index 1056a8738..92addeee2 100644 --- a/apps/web/src/docs/section.ts +++ b/apps/web/src/docs/section.ts @@ -1,30 +1,3 @@ -/** - * Which top-level section of the documentation a URL is in - `dev`, `ui`, - * `guides` - or `undefined` for `/docs` itself. - * - * It exists for one thing: the accent colour. Each section paints - * `--color-fd-primary` from its own token (`src/styles.css`), so the sidebar, - * the active tab and every link inside a document are orange under `dev`, teal - * under `ui` and blue under `guides`. - * - * ## Why this is a pure function of the pathname - * - * The Next.js application could not do it this way. Reading the slug there meant - * reading `params` inside the layout, which would have made the whole docs shell - * dynamic - so it painted the class onto `<html>` from an inline script derived - * from `location.pathname`, plus a `useLayoutEffect` to keep it in step. Two - * mechanisms, a `useServerInsertedHTML` call and a comment explaining why a - * `<script>` was being smuggled past React. - * - * Here the shell is an ordinary component with the router's location in hand, so - * the class goes on the docs wrapper element and is correct in the first byte of - * SSR. No script, no effect, no flash, and `<html>` is left alone - which also - * means the accent cannot leak onto a non-docs page. - * - * The pathname it takes is the *internal* one, which is what the router exposes: - * `/pl/docs/dev` has already had its prefix stripped by the rewrite, so this - * never has to know about languages. - */ export const docsSectionOf = (pathname: string): string | undefined => { const [, docs, section] = pathname.split('/') diff --git a/apps/web/src/docs/shell-content.tsx b/apps/web/src/docs/shell-content.tsx index 47711ab0b..adc387454 100644 --- a/apps/web/src/docs/shell-content.tsx +++ b/apps/web/src/docs/shell-content.tsx @@ -10,48 +10,8 @@ import React from 'react' import { docsSectionOf } from './section' -/** - * The search dialog, fetched when somebody opens it. - * - * `React.lazy` rather than a plain import because the dialog is the one part of - * the docs chrome nobody sees until they press `⌘K`, and it carries the search - * client with it. Fumadocs renders its `SearchDialog` slot inside a boundary of - * its own, so no `Suspense` is needed around this. - */ const DocsSearchDialog = React.lazy(async () => await import('./search-dialog')) -/** - * The documentation's own application shell: the Fumadocs providers, the top - * navigation, the sidebar and the search dialog. - * - * A **separate shell from `_main`**, not a variation of it. The documentation - * has a full navigation of its own - a top bar with the mark and the GitHub - * link, a row of section tabs, a sidebar, a table of contents - and rendering - * `MainHeader` above it would put two site headers, two search triggers and two - * theme switchers on one page. So `_docs` sits beside `_main` and `_admin` - * rather than under any of them, and this is what it renders. - * - * ## The Fumadocs providers are mounted here, and only here - * - * `fumadocs-ui/provider/tanstack` - never `.../provider/next`, which is a - * different module for a different framework and is what a mechanical port of - * the Next.js layout would have brought across. It supplies the framework - * adapter (`Link`, `usePathname`, `useRouter`) that every Fumadocs component - * reads, which is the whole reason a Markdown link inside a document behaves - * like a router link and keeps its locale prefix. - * - * Mounting it **around the docs subtree rather than at the root** is the - * difference between a front page that loads Fumadocs and one that does not. The - * root document stays what Stage 14 left it: the VitNode providers, and nothing - * a marketing page cannot justify. `src/tests/asset-graph.test.ts` holds that - * line, by failing if the front page's own chunks contain Fumadocs at all. - * - * `theme={{ enabled: false }}` because VitNode ships its own theme provider - - * mounted by `__root` - and two of them means two sources of truth for one - * `<html class="dark">`. The switcher in the corner is VitNode's, handed to - * Fumadocs through `slots.themeSwitch`, so the docs chrome drives the same state - * as the rest of the site. - */ export const DocsShellContent = ({ children, pageTree, @@ -59,13 +19,6 @@ export const DocsShellContent = ({ children: React.ReactNode pageTree: SerializedPageTree }) => { - /** - * The tree, as elements again. - * - * `deserializePageTree` is Fumadocs' own: the icons and names crossed the wire - * as HTML strings and it turns them back into nodes. Memoized on the payload - * rather than run per render, because it walks every node in the tree. - */ const tree = React.useMemo(() => deserializePageTree(pageTree), [pageTree]) const section = useRouterState({ select: (state) => docsSectionOf(state.location.pathname), @@ -89,16 +42,6 @@ export const DocsShellContent = ({ nav={{ mode: 'top', title: <LogoVitNode className="w-30" /> }} sidebar={{ tabs: { - /** - * Each section's tab icon, tinted with that section's own colour. - * - * The Next.js layout derived the colour from - * `source.getNodeMeta(node)`, which is a *server* lookup into the - * content index - unavailable here, and unnecessary: a tab's `url` - * is `/docs/<section>`, which is the same answer by a shorter - * route. Same rule, same three colours, and no content index in - * the browser. - */ transform(option) { const tab = docsSectionOf(option.url) if (!(tab && option.icon)) return option diff --git a/apps/web/src/docs/source.server.ts b/apps/web/src/docs/source.server.ts index a38fe0c92..f4c7652b6 100644 --- a/apps/web/src/docs/source.server.ts +++ b/apps/web/src/docs/source.server.ts @@ -8,28 +8,6 @@ import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons' import { icons } from 'lucide-react' import { createElement } from 'react' -/** - * The documentation, as Fumadocs sees it: every page, its frontmatter, and the - * tree the sidebar is drawn from. - * - * **Server only, and the import at the top of this file is what enforces it.** - * `collections/server` is `.source/server.ts` - a generated module that eagerly - * loads the frontmatter and `meta.json` of all ~120 documents, and reaches the - * Lucide icon set below to turn a `meta.json` icon name into an element. None of - * that belongs in a browser bundle, and the failure mode without the guard is - * not an error: it is a docs page that quietly ships the whole content index to - * every reader. - * - * Everything the browser needs out of this crosses the boundary as data - - * `src/docs/transport.ts` is the whole of that seam, and it is three server - * functions wide. - * - * `baseUrl: "/docs"` is the same value the Next.js application used, and it is - * the *internal* path in the TanStack sense: `/docs/dev/plugins/create`. The - * locale prefix is not part of it and must never be - one route tree, two public - * URL shapes, and the router's rewrite owns the difference. See - * `src/tests/docs-route.test.ts`. - */ export const source = loader({ baseUrl: '/docs', source: docs.toFumadocsSource(), @@ -44,13 +22,6 @@ export const source = loader({ /** One page of the collection, for helpers that take a resolved page. */ export type DocsPage = InferPageType<typeof source> -/** - * One page as plain Markdown, for `/llms-full.txt`. - * - * `getText("processed")` is what `includeProcessedMarkdown` in - * `source.config.ts` exists for: the rendered Markdown is kept beside the - * compiled component, so this needs neither a second compile nor React. - */ export const getLLMText = async (page: DocsPage): Promise<string> => { const processed = await page.data.getText('processed') diff --git a/apps/web/src/docs/transport.ts b/apps/web/src/docs/transport.ts index b120d982d..cf8b96d91 100644 --- a/apps/web/src/docs/transport.ts +++ b/apps/web/src/docs/transport.ts @@ -6,96 +6,11 @@ import { z } from 'zod' import { docsGithubUrl } from './github' -/** - * The whole server/browser seam of the documentation, and it is two functions - * wide. - * - * Fumadocs' source loader is a build-time index of every document: it holds the - * frontmatter and `meta.json` of ~120 files plus the Lucide icon set the sidebar - * draws from. A browser must never receive it, and equally a browser is what - * renders the MDX - so something has to cross, and *what* crosses is the whole - * design decision. - * - * server browser - * source.getPage(slug) -> { path, title, description } - * source.serializePageTree -> the sidebar tree, as JSON - * | - * collections/browser -> the compiled MDX chunk - * - * Data only. No compiled component and no React element is ever put into a - * server-function payload - the page's *identity* is, and the browser's own - * generated collection (`src/docs/client-loader.tsx`) turns that identity into - * the module it imports. That is Fumadocs' supported TanStack Start strategy - * rather than something assembled here: `createClientLoader` exists precisely so - * the body is a code-split `import()` in the client graph instead of a payload. - * - * ## Why these are `createServerFn` when almost nothing else in this app is - * - * `content/docs/dev/server-functions.mdx` says it out loud, and it is - * worth restating where the exception actually lives: ordinary VitNode reads go - * to Hono, which is this application's real API and security boundary. - * Documentation is neither - it is build output that exists only inside this - * Vite build, and asking Hono to serve it would mean pulling the MDX compiler, - * Shiki and the whole content index into the API process. So the docs get a - * server function, and nothing else does. - * - * They are declared here, in the host, for the reason every `createServerFn` in - * this repository is: the Start compiler has to transform the declaring module - * in *both* bundles, and `@vitnode/core` is externalised from this app's SSR - * pass. See `src/lib/auth.ts`. - * - * ## The source loader is reached by `await import()`, not by a static import - * - * Start strips a handler's body - and everything it names statically - out of - * the client build, so a top-level `import { source } from "./source.server"` - * would also be correct. The dynamic import is about the *server* build: this - * module is imported by two eager route files, and on the server nothing strips - * anything. A static import would make every request to this application - the - * front page included - evaluate the whole content index at module load. Behind - * `await import()` it is evaluated the first time a docs page is asked for, and - * cached by the module registry from then on. - */ - -/** - * A splat, as a slug array. - * - * A server function's input is whatever a caller posts, not whatever the router - * matched, so this is parsed rather than trusted. Empty segments are dropped so - * that `/docs/dev/` and `/docs//dev` resolve like `/docs/dev` instead of missing: - * `source.getPage` matches an exact slug array, and an empty member matches - * nothing. - */ const docsSlugSchema = z .string() .max(512) .transform((splat) => splat.split('/').filter(Boolean)) -/** - * A document's identity and its metadata, resolved on the server. - * - * One lookup serving three consumers, which is the point of returning it from - * the loader rather than resolving it a second time in `head`: the `<title>`, - * the `<h1>` and the MDX body all come from this object, so they cannot - * disagree. - * - * The table of contents is deliberately **not** here. A heading's title is a - * `ReactNode`, so a heading containing inline code is a React element and - * TanStack Start's serializer rejects it - at compile time, and rightly. It - * arrives with the body instead, out of the compiled MDX module's own `toc` - * export; see `./article`. - * - * `title` is the page's own - the `<h1>` - and `metaTitle` is that plus its - * ancestors, which is what the browser tab shows. The Next.js application - * composed the same string in `generateMetadata` from `getBreadcrumbItems`, and - * it is preserved: "Create a plugin - Plugins - Development". Only the ancestors - * are joined, deepest first; the page's own crumb is dropped because it is - * already the head of the string. - * - * `notFound()` rather than a `null` result. It is the router's own signal, it - * survives the server-function boundary, and it is what makes - * `/docs/does-not-exist` a 404 rendered inside the docs shell rather than a - * redirect to `/docs`. - */ export const getDocsPage = createServerFn() .validator(docsSlugSchema) .handler(async ({ data: slugs }) => { @@ -129,18 +44,6 @@ export const getDocsPage = createServerFn() /** Everything a docs page route knows about its document before it renders. */ export type DocsPageData = Awaited<ReturnType<typeof getDocsPage>> -/** - * The sidebar's tree, as JSON. - * - * `serializePageTree` is Fumadocs' own answer to "this loader is optimised for - * React Server Components and I am not rendering in one": it renders each node's - * icon and name to an HTML string, and `useFumadocsLoader` on the other side - * turns them back into elements. Nothing here hand-rolls a serializer. - * - * It is one object for the whole documentation rather than one per page, which - * is why the shell route fetches it and the page routes do not - see - * `src/routes/_docs.tsx`. - */ export const getDocsPageTree = createServerFn().handler( async (): Promise<SerializedPageTree> => { const { source } = await import('./source.server') diff --git a/apps/web/src/docs/view-options.tsx b/apps/web/src/docs/view-options.tsx index 66b37c684..be2ab2fc4 100644 --- a/apps/web/src/docs/view-options.tsx +++ b/apps/web/src/docs/view-options.tsx @@ -7,29 +7,6 @@ import { } from 'fumadocs-ui/components/ui/popover' import { ChevronDown, ExternalLinkIcon, MessageCircleIcon } from 'lucide-react' -/** - * The "Open in" menu above every document: read the source, or hand the page to - * an assistant. - * - * Carried over from the Next.js application essentially unchanged - it is - * adapted from Fumadocs' own docs site, and the four destinations are the ones - * VitNode's readers already have. Two things did change, and both matter: - * - * **The GitHub link points at `apps/web/content/docs`.** That is - * `src/docs/github.ts`, and it is the reason this component takes a finished - * `githubUrl` rather than building one: after Stage 17 there is no - * `apps/docs/content/docs` to edit, and a contributor following a stale link - * would be editing a deleted file. - * - * **It is not a client component, because there is no such thing here.** The - * `"use client"` directive at the top of the Next.js original was load-bearing - * there and is meaningless in a TanStack Start app, where every component runs - * in both places. - * - * The assistant links carry the *public* markdown URL of the page, resolved - * against the canonical origin, because an assistant fetching it has no session - * and no idea what `/docs/dev` means on its own. - */ const optionClassName = 'text-sm p-2 rounded-lg inline-flex items-center gap-2 hover:text-fd-accent-foreground hover:bg-fd-accent [&_svg]:size-4' diff --git a/apps/web/src/lib/admin-nav.ts b/apps/web/src/lib/admin-nav.ts index 4bf9c81e1..1a9372626 100644 --- a/apps/web/src/lib/admin-nav.ts +++ b/apps/web/src/lib/admin-nav.ts @@ -2,42 +2,4 @@ import { adminNavBundle } from '@vitnode/core/tanstack/admin' import { pluginAdminNav } from '#/admin-nav.gen' -/** - * This installation's AdminCP navigation - core's own, plus whatever the plugins - * it configured contribute. - * - * Two lines, and both of them are host-only work by necessity. Which plugins are - * installed is a property of this application, and `src/admin-nav.gen.ts` is - * where the build writes the answer: one literal import per configured plugin - * that exports an `admin/nav` module. Every *rule* about what a sidebar contains - * - which entries a content type earns, how a hand-declared entry is titled, - * which permission hides one, which message namespaces the result needs - is - * `@vitnode/core`'s, in `adminNavBundle`. Nothing about navigation is decided - * here. - * - * ## Why it is not read from `vitnode.config.ts` - * - * The config does carry each plugin's registration, and a Next.js host walks it - * in its render pass. Reading it here would make every configured plugin's - * editing screens reachable from the module the document shell imports, which is - * the one graph that is never lazy. The generated projection carries exactly - * what a sidebar needs instead - ids, hrefs, permissions, icons and content type - * definitions, all plain data - and the Content Engine's UI arrives separately, - * when a content screen actually renders, through `src/lib/content-registry.ts`. - * - * ## Module scope, and why that matters twice - * - * Evaluated once per bundle rather than per render. `AdminShellContent` memoises - * its namespace list on this object's identity, and `_admin`'s loader warms the - * messages from the same `namespaces` array the shell then reads - so a stable - * value here is what makes those one cache entry rather than two. - * - * ## Content entries were never rewritten, and that is the point - * - * A content type's entry is `/admin/content/…`, and it said exactly that - * through every change in which framework rendered those screens. Only *how a - * click travels* ever changed, and nothing here was edited for it. Routes and - * navigation stay separate concepts: nothing derives a nav entry from a route - * file, and nothing makes one conditional on a route existing. - */ export const adminNav = adminNavBundle({ plugins: pluginAdminNav }) diff --git a/apps/web/src/lib/admin-search.ts b/apps/web/src/lib/admin-search.ts index 903a9d0f4..437854781 100644 --- a/apps/web/src/lib/admin-search.ts +++ b/apps/web/src/lib/admin-search.ts @@ -2,32 +2,6 @@ import { createServerFn } from '@tanstack/react-start' import { readAdminUserSearchOnApi } from '@vitnode/core/tanstack/admin/server' import { z } from 'zod' -/** - * The AdminCP command palette's user lookup, as this app's server function. - * - * One handler, one line, exactly like `lib/auth.ts` - and here for the same - * reason. `createServerFn` needs the module it sits in to be transformed by the - * Start compiler on both sides of the render, and `@vitnode/core` reaches the - * server un-compiled (see the note in `lib/auth.ts`), so the declaration lives - * in the host and the behaviour lives in the package. - * - * ## Why it has a validator - * - * A server function is a public same-origin endpoint: its input is whatever a - * caller posts, not whatever the dialog typed. This value reaches an API query - * string, so it is parsed rather than trusted - trimmed, length-bounded, and a - * string at all. - * - * The bound is not a security control; the API authorizes the read from the - * admin cookie and pages it itself. It is a cheap refusal of the requests that - * could only ever be abuse - a megabyte of "search term" that the API would - * otherwise have to hand to Postgres. - * - * `POST` puts it behind `createCsrfMiddleware` in `src/start.ts`, which matters - * more here than it looks: this reads other people's names and email addresses, - * and a `GET` would be reachable from another origin's page with the - * administrator's cookies attached. - */ const adminUserSearchInput = z.object({ search: z.string().trim().min(1).max(128), }) diff --git a/apps/web/src/lib/content-registry.ts b/apps/web/src/lib/content-registry.ts index 331b9d135..17d83dd52 100644 --- a/apps/web/src/lib/content-registry.ts +++ b/apps/web/src/lib/content-registry.ts @@ -5,48 +5,6 @@ import { import { pluginContentTypes } from '#/content-registry.gen' -/** - * This installation's Content Engine registry - every content type its - * configured plugins register, with their editing screens attached. - * - * Two lines, and both of them are host-only work by necessity. Which plugins are - * installed is a property of this application, and `src/content-registry.gen.ts` - * is where the build writes the answer: one literal import per configured plugin - * that exports an `admin/content` module. Every *rule* about what a registration - * is - which paths are legal, which two of them collide, how they are ordered, - * how one is found by id or by `admin.path` - is `@vitnode/core`'s, in - * `buildContentFrontendRegistry`. Nothing about the Content Engine is decided - * here. - * - * ## Why it is not read from `vitnode.config.ts` - * - * The config does carry each plugin's registration, and a Next.js host reads it - * from there. What it cannot give is *when*: it is imported by the document - * shell, so anything reachable from it is reachable eagerly. This module is - * loaded behind a dynamic `import()` in `src/router.tsx` instead, so the - * definitions, the icons and the override components arrive with the content - * route - the same arrangement `src/lib/admin-nav.ts` uses for the sidebar, one - * layer deeper. - * - * ## Registration, and where it belongs in the import graph - * - * `setContentFrontendRegistry` fills a module-scope slot in `@vitnode/core`, so - * the package's own content code finds the registry without being handed it as - * a prop - the same shape as `setAdminTransport`. Module scope means *per - * bundle*: the browser has one instance and the server has one, and each - * registers its own. - * - * This module is reached through a `() => import(...)` that `/admin/content`'s - * loader awaits, never through a static import, and that is deliberate. - * Registration only has to happen before a content screen runs, and deferring it - * to the route is what lets Rollup put this whole graph - every plugin's field - * components, table cells and form layouts, plus `zod` and the Content Engine - * itself - in that route's chunk instead of in the bundle every page of the site - * loads first. `router.tsx` holds the thunk because that is where the route tree - * is composed; what it does *not* hold is the value. A plugin's own heavier - * parts stay lazier still: `@vitnode/blog` draws a `React.lazy` boundary around - * its Tiptap editor, so even opening the article list does not fetch it. - */ export const contentRegistry = buildContentFrontendRegistry(pluginContentTypes) setContentFrontendRegistry(contentRegistry) diff --git a/apps/web/src/lib/i18n/runtime.ts b/apps/web/src/lib/i18n/runtime.ts index 16b03c585..062d73738 100644 --- a/apps/web/src/lib/i18n/runtime.ts +++ b/apps/web/src/lib/i18n/runtime.ts @@ -5,71 +5,19 @@ import { IntlProvider } from 'use-intl' import { loadIntlMessages } from '#/server/messages.server' import { vitNodeConfig } from '#/vitnode.config' -/** - * One language's messages for one set of namespaces, fetched on the server. - * - * The one piece of the i18n runtime that cannot live in `@vitnode/core`, and the - * reason is the compiler rather than the code. A server function has to be - * transformed by the Start plugin in *both* bundles; the package is externalised - * from this app's SSR pass, so its modules reach the server un-compiled and a - * `createServerFn` declared there resolves to `undefined` during SSR with no - * error. See `packages/vitnode/src/tanstack/boundary.test.ts`. - * - * So the wrapper is here and the body is not: `validateIntlInput` is core's, and - * `loadIntlMessages` delegates to core's loading engine. Start strips the - * handler - and `#/server/messages.server` with it - out of the client build. - */ export const getIntlMessages = createServerFn() .validator(validateIntlInput) .handler(async ({ data }) => await loadIntlMessages(data)) -/** - * This app's languages, handed to the package once. - * - * Everything in `@vitnode/core/tanstack/i18n` reads what this registers, so a - * route file imports `RouteMessages` and `intlQueryOptions` straight from the - * package. What must not happen is a route running before this module has been - * evaluated - so `src/router.tsx`, which owns the route tree, imports from - * here. The request pipeline needs no such guarantee: `createVitNodeStart` - * derives its own locale routing from the config it is handed. - * - * The registration is at module scope but reads `getIntlMessages` above only by - * reference, so the order within this file does not matter: the validator and - * the fetcher are both called per request, long after it has finished - * evaluating. - */ export const { defaultLocale, isLocale: isSupportedLocale, localeRouting, } = configureIntl({ fetchMessages: async (input) => await getIntlMessages({ data: input }), - /** - * This app's own `use-intl`, handed over so `RouteMessages` can provide it. - * - * `@vitnode/core` is external to this app's SSR pass (`vite.config.ts`), so - * under `vite dev` Node loads the package and Vite loads this app, and the two - * resolve `use-intl` to two different files - two `createContext` calls, two - * React contexts. Everything the package renders reads its own; anything this - * app renders with its own `useTranslations` - `routes/_main/index.tsx` does - - * reads this one, and nothing inside the package can import it. - * - * So it is registered rather than imported, and `RouteMessages` mounts it - * outermost. A production build resolves `use-intl` once and the providers - * collapse into one, which is why leaving this out is a dev-only failure: the - * route's server render throws "No intl context found", React quietly falls - * back to client rendering, and the page still appears. - */ + hostIntlProvider: IntlProvider, i18n: vitNodeConfig.i18n, }) -/** - * The router's half of locale routing, bound to this app's languages. - * - * Re-exported from here rather than imported straight from the package by - * `src/router.tsx`: it is the router entry's only i18n import, and routing it - * through this module is what makes `configureIntl` above run before the router - * - and therefore before any route, loader or component - exists. - */ export { createLocaleRewrite } from '@vitnode/core/tanstack/i18n' diff --git a/apps/web/src/lib/i18n/shared.ts b/apps/web/src/lib/i18n/shared.ts index fe618f794..a7c2bb844 100644 --- a/apps/web/src/lib/i18n/shared.ts +++ b/apps/web/src/lib/i18n/shared.ts @@ -2,15 +2,6 @@ import type { vitNodeConfig } from '#/vitnode.config' import { localeRouting } from '#/lib/i18n/runtime' -/** - * A language this app serves, as a type. `"en" | "pl"`, derived from the config - * rather than written twice. - * - * The one i18n thing this app still owns, and it has to: `@vitnode/core` is - * installed by apps with different language lists, so it types a locale as - * `string` and takes this union as a type argument where the value originates - * (`useLocale<Locale>()`, `resolveLocale<Locale>()`). - */ export type Locale = (typeof vitNodeConfig.i18n.locales)[number]['code'] export { defaultLocale, localeRouting } from '#/lib/i18n/runtime' diff --git a/apps/web/src/lib/navigation.ts b/apps/web/src/lib/navigation.ts index 1f7eec380..83059a8c1 100644 --- a/apps/web/src/lib/navigation.ts +++ b/apps/web/src/lib/navigation.ts @@ -2,23 +2,6 @@ import { createAuthNavigation } from '@vitnode/core/tanstack/auth' import { localeRouting } from '#/lib/i18n/shared' -/** - * Going somewhere in this application from code, bound to this app's languages. - * - * One line of application, and everything else is - * `@vitnode/core/tanstack/auth`: the two questions a user-supplied target has to - * answer (may we send a browser there, and what does the router want to be - * handed), the reason a redirect carries `to` rather than `href`, and the fact - * that the same decision is made on a server and in a browser. - * - * What is left here is the only thing a package cannot answer - which languages - * this installation serves, which is what decides whether `/pl/discover` is a - * Polish page or a route called `pl`. - * - * `@vitnode/core/tanstack/routes` builds its own from the same factory, handed - * the same `localeRouting`, so core's auth screens and this app's AdminCP command - * palette navigate by one rule rather than two. - */ export const { internalDestination, useAppNavigate } = createAuthNavigation({ localeRouting, }) diff --git a/apps/web/src/lib/page-head.ts b/apps/web/src/lib/page-head.ts index ba1b5adff..cc0e37033 100644 --- a/apps/web/src/lib/page-head.ts +++ b/apps/web/src/lib/page-head.ts @@ -2,18 +2,4 @@ import { createRouteHead } from '@vitnode/core/tanstack/metadata' import { vitNodeConfig } from '#/vitnode.config' -/** - * A route's `head`, bound to this app's name. - * - * Two lines of application, and everything else is - * `@vitnode/core/tanstack/metadata`: the `"<page> - <site>"` title rule Next.js - * applies through `title.template`, the decision that a robots directive is - * stated rather than assumed, and the handling of a `loaderData` that is - * `undefined` on a route's first pass. - * - * What is left here is the only thing a package cannot answer - this site's own - * name, which is what every tab title ends with. - * - * head: ({ loaderData }) => pageHead({ robots: 'index, follow', ...loaderData }) - */ export const pageHead = createRouteHead(vitNodeConfig.metadata) diff --git a/apps/web/src/locales/app.ts b/apps/web/src/locales/app.ts index 1d33c5d5a..d36ab74cb 100644 --- a/apps/web/src/locales/app.ts +++ b/apps/web/src/locales/app.ts @@ -1,32 +1,3 @@ import type { AppMessagesMap } from '@vitnode/core/lib/i18n/types' -/** - * Translations this app owns, on top of whatever the packages ship. - * - * Empty, and that is the healthy state. A package's canonical translation - * belongs to the package, where every installation that installs it gets the - * language - `@vitnode/core` ships its own Polish, and so does `@vitnode/blog`. - * Carrying a copy here is how one product came to say both "Bezpieczeństwo" and - * "Zabezpieczenia" for the same settings tab. - * - * `@vitnode/blog`'s `pl.json` was the last entry, and it was here for a reason - * that has been fixed rather than a reason that stands: it arrived from - * `apps/docs` when Stage 17 deleted that application, and the plugin shipped no - * Polish of its own to fall back to. It does now - * (`plugins/blog/src/locales/pl.json`), registered in the plugin's own locale - * barrel and in `src/locales/packages.ts` beside its English - so the blog's - * AdminCP copy is Polish in this app, in `apps/api`'s emails, and in anybody - * else's installation, rather than only in this one. - * - * What this file is *for* is rewording: a string a package translates in a way - * this product does not want. Add - * `pl: { '@vitnode/blog': async () => await import('./@vitnode/blog/pl.json') }` - * with only the keys that change - the map is deep-merged last, so everything - * left out falls back to the package's, and then to the default locale, key by - * key. - * - * Server-side only, and kept out of `src/vitnode.config.ts` on purpose: these - * are functions, and the shared config crosses to the browser and has to stay - * serializable. `src/vitnode.server.config.ts` is what registers this map. - */ export const appMessages: AppMessagesMap = {} diff --git a/apps/web/src/locales/packages.ts b/apps/web/src/locales/packages.ts index acd6edc17..cc17c790d 100644 --- a/apps/web/src/locales/packages.ts +++ b/apps/web/src/locales/packages.ts @@ -4,41 +4,6 @@ import { CONFIG_PLUGIN as BLOG } from '@vitnode/blog/const' import { CONFIG_PLUGIN as CORE } from '@vitnode/core/config' import { CONFIG_PLUGIN as EXAMPLE } from '@vitnode/example/const' -/** - * Where this app reads each installed package's translations from. - * - * Every VitNode package ships a locale barrel - `@vitnode/core/locales/index` - - * that loads its own files with a runtime - * `import("./en.json", { with: { type: "json" } })`. Under Node that is exactly - * right, and it is how `apps/api` reads them. - * - * It cannot work here, and the reason is the import attribute rather than - * anything about VitNode. Vite and Nitro inline `@vitnode/core`'s build output - * into this app's server chunks - `ssr.external` applies to the SSR pass, not to - * Nitro's own bundling - but Rollup will not follow a dynamic import that - * carries `with: { type: "json" }`, so it neither emits the JSON nor rewrites - * the specifier. What ships is a relative import pointing next to a chunk that - * the JSON was never copied to. Swapping the barrel in and building gives: - * - * [VitNode i18n] Could not load "en" messages for "@vitnode/core" - - * Cannot find module '.../.output/server/_chunks/en.json' - * Error: MISSING_MESSAGE: core.global (en) - * - * - a page whose every string renders as its own key. The attribute cannot - * simply be dropped: Node refuses to import JSON without it, which would break - * the two apps that load these barrels directly. - * - * So the loaders are declared here instead, with static specifiers a bundler can - * follow. Each resolves through the package's `./locales/*.json` export to the - * real file and lands in the build as a chunk fetched on demand, which is the - * same laziness the barrels wanted. - * - * This is the app's only copy of that list - `vitnode.server.config.ts` reads it - * from here - and the cost is one line per language a package ships. Removing it - * means making the barrels bundler-safe (locale files as modules rather than - * JSON, or the packages left external through Nitro), which is a packaging - * change, not an i18n one. - */ export const packageMessages: Record<string, LocaleMessagesMap> = { [BLOG.pluginId]: { en: async () => await import('@vitnode/blog/locales/en.json'), diff --git a/apps/web/src/plugin-routes.gen.ts b/apps/web/src/plugin-routes.gen.ts index d78a83733..620ad3656 100644 --- a/apps/web/src/plugin-routes.gen.ts +++ b/apps/web/src/plugin-routes.gen.ts @@ -19,18 +19,7 @@ import type { PluginRouteDeclarationSource } from '@vitnode/core/routing' import { routes as pluginRoutes0 } from '@vitnode/example/routes' -/** - * Every configured plugin's route tree. - * - * Handed to `pluginRouteSpecs` in the app's router, which flattens and validates - * it with the same functions the build used - so the tree the router mounts is - * provably the tree the build checked, and a plugin removed from the config - * takes its routes with it in one step. - * - * `satisfies` rather than a type annotation, deliberately: it checks each - * plugin's `routes` export really is a `definePluginRoutes` tree, naming the - * plugin here rather than failing in a browser. - */ + export const pluginRouteSources = [ { pluginId: '@vitnode/example', diff --git a/apps/web/src/routes/_admin.tsx b/apps/web/src/routes/_admin.tsx index 6184a9710..ab8853e46 100644 --- a/apps/web/src/routes/_admin.tsx +++ b/apps/web/src/routes/_admin.tsx @@ -13,92 +13,6 @@ import { ErrorActions } from '@vitnode/core/tanstack/layout' import { AdminShell } from '#/components/admin-shell' import { pageHead } from '#/lib/page-head' -/** - * The boundary every AdminCP page sits under - the admin session guard, and the - * shell. - * - * Pathless: the leading underscore means it contributes no URL segment, so - * `routes/_admin/core/index.tsx` is `/admin/core`, guarded, and the guard is - * this file. A page joins the AdminCP by *where its file lives*, not by - * remembering to check a session. - * - * ## It is not under `_authenticated`, and must not be - * - * The AdminCP has its own session, under its own cookie (`vitnode_auth_admin`), - * with its own model and its own endpoint. Stacking the public guard above this - * one would bounce an administrator to `/login` for a session the AdminCP does - * not use - and `AuthState.isAdmin`, which lives on the *public* session, means - * "may be offered the AdminCP", not "is inside it". Two cookies, two questions. - * - * ## Exactly one splat may live under here, and nothing wider - * - * `admin.content.$.tsx` claims `/admin/content/*` - the Content Engine's own - * namespace, which Stage 13 moved into this router. That one is deliberate and - * narrow; a second is how the AdminCP breaks. - * - * `$.tsx`, `admin.$.tsx` or any other catch-all beside it would consume every - * *remaining* admin URL, including the ones no route declares. The AdminCP's own - * not-found would stop being reachable, a plugin's admin route would be shadowed - * by whichever splat sat above it, and nothing would report either - silently, - * and all at once. `src/tests/admin-routes.test.ts` pins both halves: that the - * content splat sits at exactly that path, and that it is the only one. - * - * ## Why the check is in `beforeLoad` - * - * It runs before the loader and long before React, so a visitor without admin - * access never receives a byte of an AdminCP page - not a flash, not a - * hydration, not a `useEffect` that redirects afterwards. A component-level - * check would render the page first and then take it away, which on the server - * means admin markup already written into the stream. - * - * ## A hover is not a navigation - * - * `defaultPreload: 'intent'` means the router runs this whole chain when a - * pointer rests on an admin link, and router-core has no staleness gate on - * `beforeLoad` the way it has on a loader - it re-runs it from the top of the - * branch every time. With `ADMIN_SESSION_STALE_TIME` at zero that made crossing - * the sidebar one server-function POST and one Hono call *per link the mouse - * passed over*, for a question nobody had asked yet. - * - * So the read is chosen by the `preload` flag the router already hands in. - * `preloadAdminAccess` is the same query, the same key and the same rejection - * behaviour, trusted for thirty seconds - the identical window the public - * session has always used for the identical reason. `ensureAdminAccess` and its - * `staleTime: 0` are untouched on the path that matters, and that is the whole - * of the revocation guarantee: entering a screen is a real navigation, this runs - * again with `preload: false`, and the API is asked. Router-core never lets a - * preloaded `beforeLoad` result stand in for a navigation's. - * - * The redirect below is deliberately *not* conditional on `preload`. It is not a - * navigation - `preloadClientRoute` catches it and preloads the sign-in page - * instead of moving anybody - and skipping it would leave a denied preload - * walking into the screen loaders underneath, which would then send admin - * requests the API is going to refuse. - * - * ## A failed read is not a denial - * - * `ensureAdminAccess` resolves only for an answer the API actually gave - `200` - * or `403`. A `429` from the rate limiter, a `500`, an API that is not listening: - * all three reject, and that rejection is deliberately left to propagate as an - * ordinary route error. - * - * Only `canEnterAdmin` answering `false` sends anybody to `/admin`. Catching the - * rejection and redirecting instead would sign every administrator out of the - * AdminCP during an outage and present them with a sign-in form for a session - * they already hold - which is precisely what the Next.js `getSessionAdminApi()` - * does today, and precisely what this shape exists to stop. - * - * ## What it is not - * - * A navigation guard, and only that. `api/config.ts` puts - * `globalAdminMiddleware()` in front of every request whose path contains - * `/admin/`, each handler re-checks the staff tables, and - * `SessionAdminModel.getUser()` re-runs `checkIfUserIsAdmin` against the - * database on every request - deleting the session the moment the answer turns - * false. So an administrator who edits this app's cached permission set in - * devtools gets a visible button and an API that still refuses them. Nothing - * here is, or may become, the security boundary. - */ export const Route = createFileRoute('/_admin')({ beforeLoad: async ({ context, location, preload }) => { const access = preload @@ -133,96 +47,19 @@ export const Route = createFileRoute('/_admin')({ // a page cannot disagree with the guard that let it render. return { adminAccess: access } }, - /** - * The shell's strings, warmed before React renders. - * - * `adminNav.namespaces` is the same array `AdminShell` hands the shell, and it - * has to be: the provider reads back the identical `intlQueryOptions` entry - * this fills, so warming a different namespace set would fill an entry nobody - * looks at and cost a round trip on the first paint anyway. It is not a fixed - * list because it cannot be - a plugin group's headings live under that - * plugin's own id, and which plugins this installation configured is decided - * in `src/admin-nav.gen.ts`. - * - * ## Imported inside the loader, not above it - * - * A route file's `loader` body runs only for a navigation into this shell; the - * file itself is evaluated in the client entry, on every page of the site. The - * generated navigation is not small - it carries every configured plugin's - * sidebar icons and its content type definitions, and through those the whole - * Content Engine and `zod` - so a static import here put ~45 KB of AdminCP - * data, plus `zod`, in front of the front page's first paint. `AdminShell` - * imports the same module, and `AdminShell` is this route's `component`, which - * is code-split: the two land in the same chunk, so an administrator pays for - * it once and everybody else not at all. - */ + loader: async ({ context }) => { const { adminNav } = await import('#/lib/admin-nav') await loadAdminMessages({ ...context, namespaces: adminNav.namespaces }) }, - /** - * Stated once for the whole panel rather than on each screen. - * - * The AdminCP is behind a session and must never be indexed. Router merges the - * `head` of every matched route and dedupes `meta` by `name`, preferring the - * deepest, so an admin screen inherits this by saying nothing - which is - * exactly what `RouteHeadOptions` describes. A screen's own `pageHead` adds - * only its title and description. - */ + head: () => pageHead({ robots: 'noindex, nofollow' }), - /** - * The AdminCP's 404, rendered inside the shell. - * - * What reaches it is a screen whose loader called `requireAdminPermission` - * and was refused - the same answer `app/[locale]/admin/(auth)/not-found.tsx` - * gives in the Next.js AdminCP. Declared here rather than per screen so every - * one of them answers a missing permission identically. - * - * A content URL that resolves to no content type reaches it too, and by the - * same route: `loadContentAdminRoute` throws `notFound()` for a splat its - * registry cannot name, which is the answer the Next.js catch-all's - * `notFound()` gives one navigation later. - * - * A URL under `/admin` that matches no route at all does *not* reach this one. - * Outside `/admin/content`, `_admin` has only declared children - see above - - * so such a path matches nothing in this subtree and the router falls back to - * its own not-found at the root. That is the correct trade, and the cost is - * paid deliberately: rendering an unmigrated admin URL inside this shell would - * mean claiming it. (The root has no `notFoundComponent` of its own yet, so - * that fallback is currently the router's bare one - for every unmatched URL - * in this application, not only admin ones. Giving the root a real 404 page is - * its own piece of work.) - */ + notFoundComponent: AdminNotFoundScreen, component: AdminLayout, }) -/** - * The refusal, wearing the panel it was refused inside. - * - * ## It mounts the shell itself, and has to - * - * A `notFoundComponent` renders *instead of* the component of the route that - * handles the error, not inside it - so this replaces `AdminLayout`, sidebar - * and header and palette included. An administrator who opened a screen they - * lack the permission for would otherwise be dropped onto a bare 404 page with - * no way back into the AdminCP but the browser's back button, which is not what - * the Next.js AdminCP does: its `not-found.tsx` sits *under* - * `admin/(auth)/layout.tsx` and keeps the panel around the message. - * - * Mounting `AdminShell` here restores that, and it costs nothing: `beforeLoad` - * has already resolved the admin session and the loader above has already - * warmed the shell's messages, so the providers inside read the same two cache - * entries the working screens read and nothing suspends or fetches again. - * - * ## What is bound rather than defaulted - * - * `ErrorActions` is this app's binding rather than core's default: `/` is served - * by the Next.js application on some installs and by this one on others, and - * only the route tree knows which. It is passed as an element from module scope - * so the type is stable across renders. - */ function AdminNotFoundScreen() { return ( <AdminShell> @@ -231,24 +68,6 @@ function AdminNotFoundScreen() { ) } -/** - * The AdminCP shell. - * - * `AdminShell` is this app's binding of `AdminShellContent` - the sidebar, the - * command palette, the breadcrumb area, the user menu and the one `<main>` every - * admin page renders inside. It mounts `AdminPermissionsProvider` itself, from - * the same admin session query the guard above has already filled, so - * `AdminStaffPermissionGate` and `useAdminStaffPermission` work identically here - * and in the Next.js AdminCP. It cannot suspend in practice and nothing below it - * can suspend at all; see the note on the provider. - * - * What `#/components/admin-shell` adds on top is only what a package cannot - * answer for a particular installation: where the user lookup runs, which - * plugins this app configured, and how the command palette moves without a link. - * - * The guard, the loader and the route options above are the parts that must not - * move. - */ function AdminLayout() { return ( <AdminShell> diff --git a/apps/web/src/routes/_admin/admin.core.index.tsx b/apps/web/src/routes/_admin/admin.core.index.tsx index d45ea6bfa..d70a710ae 100644 --- a/apps/web/src/routes/_admin/admin.core.index.tsx +++ b/apps/web/src/routes/_admin/admin.core.index.tsx @@ -6,61 +6,11 @@ import { } from '@vitnode/core/tanstack/admin/dashboard' import { CardsPendingSkeleton } from '@vitnode/core/tanstack/pending' -/** - * `/admin/core` - the AdminCP dashboard, and the one route file `_admin` keeps. - * - * Topology only. The layout query, the widget catalogue, the drag-and-drop - * board, the settings dialogs and the four mutations behind them are - * `@vitnode/core/tanstack/admin/dashboard`. - * - * ## Why this one file is still here - * - * Every other AdminCP screen is `@vitnode/core`'s, mounted by - * `withCoreAdminRoutes` as a code-based route - see `src/router.tsx`. This one - * stays because the file-based generator requires it to, and the requirement is - * sharp rather than stylistic: - * - * - A pathless layout with no file children is **dropped from the generated - * tree**. `buildRouteTreeConfig` skips it outright, so `_admin` would not be - * in `routeTree.gen.ts` at all and the route object `src/router.tsx` mounts - * core's screens under would be an orphan. - * - It also collapses to a full path of `/`, which collides with `_main/index.tsx` - * and fails the generator's uniqueness check by name. - * - * So `_admin` needs one file-based child with a real path in order to exist, and - * this is it - which is the same job it has always had. It was the shell's first - * child for exactly this reason, and now it is the only one. - * - * Deleting it does not remove a screen; it removes the AdminCP. - * - * ## No `head`, deliberately - * - * The Next.js page exports no `generateMetadata`, so the tab keeps the site's - * own name. Declaring `title: 'VitNode'` here would render "VitNode - VitNode" - * through `formatPageTitle`; saying nothing inherits the root's title and - * `_admin`'s `noindex`, which is the parity-preserving answer. - * - * ## `pluginWidgets` is not passed - * - * A plugin declares widgets in its `admin.dashboard.widgets`, which reaches a - * Next.js board through `getVitNodeConfig()`. This route does not read the - * config - the AdminCP takes its plugin data from the generated projections, and - * neither of those carries widgets - so the board shows core's own, which is the - * complete set for this install because no configured plugin declares any. It is - * the same seam `AdminShell` leaves open for nav `declarations`. - */ export const Route = createFileRoute('/_admin/admin/core/')({ loader: async ({ context }) => await loadAdminDashboardRoute(context), component: AdminDashboardRoute, pendingComponent: CardsPendingSkeleton, - /** - * The whole of how an admin route contributes to the trail in the shell's - * header: the route declares its own crumb next to its own component, the - * shell renders whichever matched route declared the deepest one, and there is - * no map from pathname to breadcrumb anywhere. The label comes from the - * *visible* navigation, so this reads "Core" in whatever language the - * administrator is using without this file naming a string. - */ + staticData: { breadcrumb: adminBreadcrumb({ segments: ['core'] }), }, diff --git a/apps/web/src/routes/_docs.tsx b/apps/web/src/routes/_docs.tsx index dfa6e7b25..859d6a647 100644 --- a/apps/web/src/routes/_docs.tsx +++ b/apps/web/src/routes/_docs.tsx @@ -4,55 +4,6 @@ import { DOCS_TREE_STALE_TIME } from '#/docs/freshness' import { DocsShellContent } from '#/docs/shell-content' import { getDocsPageTree } from '#/docs/transport' -/** - * The documentation's shell - the third one this application has, beside `_main` - * and `_admin`. - * - * Pathless, so it contributes no URL segment: `/docs/dev` is `/docs/dev`. A - * documentation route joins this shell by *where its file lives*, which is the - * same rule the other two shells use. - * - * ## Why the docs are not under `_main` - * - * Because they already have a shell. Fumadocs' notebook layout is a top bar with - * the mark, the GitHub link, a search trigger and the theme switcher, plus a - * sidebar of sections and a table of contents - which is `MainHeader`'s job done - * a second time, for a second navigation. Nesting them would put two site - * headers, two search triggers and two theme switchers on every documentation - * page. So this sits beside `_main`, and `__root` stays the one document above - * all three. - * - * ## What it owns - * - * __root the document, the VitNode providers, the theme - * ├── _main the public site: header, breadcrumb, one <main> - * ├── _docs this: the Fumadocs providers, the docs navigation - * └── _admin the AdminCP: its own session, its own sidebar - * - * The loader fetches the sidebar's tree, once, for the whole subtree - a page - * route below fetches only its own document. - * - * `DOCS_TREE_STALE_TIME` is `Infinity` in production, because the tree is build - * output: identical for every visitor, unchanged for the life of the process, so - * re-fetching it on each navigation within the documentation would be a round - * trip for a constant. In development it is `0`, so that adding or renaming a - * page shows up in the sidebar on the next navigation rather than on the next - * server restart. See `#/docs/freshness`, which owns both halves. - * - * There is deliberately **no stylesheet declared here**. Fumadocs' design system - * was a route-owned `<link>` for exactly one build, and `src/styles.css` records - * at length why it could not stay one: TanStack renders every stylesheet with a - * React `precedence`, which hoists it permanently, so a second Tailwind build - * ends up on every page of the application and its `@layer utilities` overrides - * the app's own. The documentation's CSS is in `src/styles.css` with everything - * else. - * - * There is deliberately **no `notFoundComponent` here**. A route's not-found - * component replaces that route's own render, so one declared on the shell would - * take the sidebar and the navigation down with the missing page - Fumadocs' - * `DocsPage` then has no tree context and throws outright. It belongs on the - * page route below, where it lands in this shell's `<Outlet />`. - */ export const Route = createFileRoute('/_docs')({ loader: async () => ({ pageTree: await getDocsPageTree() }), staleTime: DOCS_TREE_STALE_TIME, diff --git a/apps/web/src/routes/_docs/docs.index.tsx b/apps/web/src/routes/_docs/docs.index.tsx index 2c88e6a1c..c77e4e7e2 100644 --- a/apps/web/src/routes/_docs/docs.index.tsx +++ b/apps/web/src/routes/_docs/docs.index.tsx @@ -1,26 +1,5 @@ import { createFileRoute, redirect } from '@tanstack/react-router' -/** - * `/docs` has no page of its own, so it forwards to the first section. - * - * The Next.js application did the same thing from `next.config.ts`, with one - * entry per locale because the rewrite lived outside the app. Here it is one - * route: `redirect({ to })` hands the router an *internal* path, and the - * router's own `rewrite.output` writes the prefix back - so `/pl/docs` lands on - * `/pl/docs/dev` with nothing in this file mentioning a language. That is the - * whole reason it is `to` and `params` rather than `href`, which bypasses the - * rewrite. - * - * It stays a redirect rather than becoming a hand-written landing page because - * the content has no `content/docs/index.mdx`. Fumadocs would render one the - * moment somebody writes it, and this route is the only thing that would then - * need deleting. - * - * In `beforeLoad`, so it is decided before anything is fetched: the shell's page - * tree is never requested for a URL that immediately leaves. During SSR the - * throw becomes a real HTTP redirect rather than a rendered page that relocates - * afterwards. - */ export const Route = createFileRoute('/_docs/docs/')({ beforeLoad: () => { // eslint-disable-next-line @typescript-eslint/only-throw-error diff --git a/apps/web/src/routes/_main.tsx b/apps/web/src/routes/_main.tsx index ed9f6a006..b769b6924 100644 --- a/apps/web/src/routes/_main.tsx +++ b/apps/web/src/routes/_main.tsx @@ -7,56 +7,6 @@ import { import { MainHeader } from '#/components/main-header' -/** - * The main application shell - the header, the breadcrumb area and the one - * `<main>` landmark that every public page renders inside. - * - * Pathless, so it contributes no URL segment: `/discover` is `/discover`, not - * `/_main/discover`. A page joins the shell by *where its file lives*, which is - * the same rule `_authenticated` uses for the session guard - and the reason - * `_authenticated` lives underneath this one: a signed-in page is still a page - * on the public site, so it wants the shell *and* the guard rather than a second - * copy of the shell. - * - * ## What is inside it, and what is deliberately outside - * - * Everything a visitor can reach without the AdminCP, the four public auth - * screens included: `/login`, `/register`, `/login/reset-password` and - * `/login/sso/$providerId` are children of this shell, mounted by - * `withCoreMainRoutes`. An auth card is a page on the public site - its own - * layout already reserves the space the header takes, and the header is the way - * back to the front page from a form the visitor changed their mind about. - * - * So is the 404. A URL no route matched is answered by core's `/$` inside this - * container rather than by `__root`'s `notFoundComponent`, because router core - * hands back the root route alone when nothing matches - a pathless layout the - * URL never reached is not a candidate for the boundary, whatever `notFoundMode` - * says, so a screen mounted here could never have seen one. - * - * `/admin` - the AdminCP's own sign-in - is outside, and must be: it reads a - * different session under a different cookie, so offering the site header's - * "sign in" beside it would be one page asking for two unrelated logins. - * `routes/api/$` is outside for a different reason again: it is a server route - * and renders no document at all. - * - * ## The slots - * - * `ThemeLayoutContent`'s, and two of the same three the Next.js `ThemeLayout` - * fills: `header` and `breadcrumb`. - * - * `listeners` is deliberately left empty here. The notification toasts and the - * WebSocket's sign-in resync are mounted by `__root` instead, next to the - * connection whose lifetime they share - so a sign-out that lands the visitor - * outside this shell is still noticed, which a listener scoped to the shell - * could not manage. - * - * ## What it is not - * - * A provider. Every technical provider this app has - the QueryClient, the two - * intl records, the theme, the WebSocket - is mounted once by `__root`, above - * every route, because a login screen needs them just as much as a page under - * this shell does. What lives here is structure: markup, and where the slots go. - */ export const Route = createFileRoute('/_main')({ loader: async ({ context }) => await loadMainShell(context), component: MainLayout, diff --git a/apps/web/src/routes/_main/index.tsx b/apps/web/src/routes/_main/index.tsx index c8567738b..a556a4a2b 100644 --- a/apps/web/src/routes/_main/index.tsx +++ b/apps/web/src/routes/_main/index.tsx @@ -5,43 +5,6 @@ import { pageHead } from '#/lib/page-head' import { HomeRouteContent } from '#/site/home/home-content' import { HOME_DESCRIPTION, HOME_TITLE } from '#/site/home/metadata' -/** - * The front page of vitnode.com, on TanStack Start. - * - * One route file serving two public URLs: `/` and `/pl` both match here, because - * the locale is stripped before matching and written back into every link the - * router builds. There is no `routes/pl/index.tsx` and there is not going to be. - * - * What the page *is* lives beside it in `#/site/home` - see `home-content.tsx`, - * which states why this site's own marketing page is the application's and not - * `@vitnode/core`'s. What is left here is topology: where the route sits, and - * what the tab says. - * - * ## What used to be here - * - * Stage 3's runtime-verification scaffold - a card of labelled rows proving the - * locale resolved, the QueryClient was warm, the theme switcher worked and the - * toaster was mounted. It said so itself: "It is a scaffold, and the real - * homepage replaces it when one is designed." It is deleted rather than moved, - * because everything it demonstrated is asserted by a test that cannot be - * satisfied by a page nobody visits: `locale-rewrite.test.ts` for the two URLs, - * `router-query.test.ts` for the single QueryClient, `intl-runtime.test.ts` for - * the message records. A diagnostic that is also production UI is a diagnostic - * nobody trusts and a homepage nobody wants. - * - * **`head` must be written after `loader`** on routes that have one - `loaderData` - * is inferred from `loader` in the same object literal, and TypeScript reads a - * literal's members in order. This route has no loader: it fetches nothing, - * because it is marketing copy and the shell above it already warmed everything - * the header reads. - * - * `HOME_TITLE` and `HOME_DESCRIPTION` come from `metadata.ts` rather than from - * the module beside them on purpose. `head` is not code-split - it is evaluated - * in the client entry, on every page of the application - so importing them from - * `home-content.tsx` would put the hero, the marquee and `motion` in the entry - * chunk. `src/tests/asset-graph.test.ts` is the budget that catches it, and - * `/docs/dev/performance` is the rule written out. - */ export const Route = createFileRoute('/_main/')({ head: () => pageHead({ @@ -52,20 +15,6 @@ export const Route = createFileRoute('/_main/')({ component: HomeRoute, }) -/** - * The link component is named *here* rather than inside the page. - * - * `#/site` is written against `SiteLinkComponent` - structurally an anchor with - * `href` required - and holds no opinion about how a path becomes a navigation. - * That is what let the primary call to action point at `/docs/dev` through three - * different answers to that question without a section being edited, and it is - * why `RouterLink` is passed in from the route rather than imported by the hero. - * - * `RouterLink` is core's own: the router's `Link` in the shape the shared views - * ask for. `/docs/dev` is a route in this tree, so the button is an ordinary - * client-side navigation and the rewrite writes the locale prefix - no `/docs` - * special case anywhere on this page. - */ function HomeRoute() { return <HomeRouteContent LinkComponent={RouterLink} /> } diff --git a/apps/web/src/routes/api/$.ts b/apps/web/src/routes/api/$.ts index 3b77cb64c..2053ac723 100644 --- a/apps/web/src/routes/api/$.ts +++ b/apps/web/src/routes/api/$.ts @@ -2,28 +2,6 @@ import { createFileRoute } from '@tanstack/react-router' import { apiBridge } from '#/server/vitnode-api.server' -/** - * `/api/*` - the existing VitNode Hono application, mounted. - * - * `server` is the only option on this route on purpose. TanStack Start prunes a - * route file whose sole option is `server` out of the client route tree - * entirely (and its client code-splitter deletes the `server` node on top of - * that), so none of the API - Hono, Drizzle, the plugins - can reach the browser - * bundle. The `.server.ts` import is refused by import protection if that ever - * stops being true. - * - * `ANY` rather than a handler per method: routing, OpenAPI, middleware, auth, - * plugin mounting and error handling all stay inside Hono, exactly as they are - * when the same app runs standalone in `apps/api` or under the Next.js catch-all - * in `apps/docs`. `ANY` is part of the framework's `RouteMethod` union and is - * what Start falls back to for any method it was given no handler for - `HEAD` - * included, where it calls this handler and strips the response body itself. - * So the API keeps answering for methods this file has never heard of, which is - * the point: there is nothing here to keep in sync with the API's routes. - * - * `src/tests/api-server-route.test.ts` drives the real request handler over this - * route to hold that to every method the API needs. - */ export const Route = createFileRoute('/api/$')({ server: { handlers: ({ createHandlers }) => diff --git a/apps/web/src/routes/docs.search.ts b/apps/web/src/routes/docs.search.ts index 871cded9c..d6f7e385b 100644 --- a/apps/web/src/routes/docs.search.ts +++ b/apps/web/src/routes/docs.search.ts @@ -4,41 +4,6 @@ import { createFileRoute } from '@tanstack/react-router' import { memoizePerSource } from '#/docs/freshness' -/** - * `/docs/search` - the documentation's search index, and the one endpoint in - * this application that is not Hono's. - * - * `src/docs/search-path.ts` carries the argument for the URL: `/api/*` is the - * VitNode API and its security boundary, Fumadocs must not claim a path inside - * it, and routing an MDX index through Hono would mean the API process importing - * the documentation build graph. So the index is a documentation resource served - * from the documentation's own namespace. - * - * `server` is the only option on this route, exactly as on `/api/$`, and for the - * same reason: TanStack Start prunes a route file whose sole option is `server` - * out of the client route tree, so `createFromSource` and the content index it - * builds cannot reach a browser bundle. - * - * `GET` alone. A search query is a read, it carries no body, and there is - * nothing here for another method to do - unlike the API mount, which forwards - * `ANY` because Hono owns the routing behind it. - * - * ## The index is built once, on the first query - * - * `createFromSource` reads every page's structured data and builds an Orama - * index from it. Doing that at module load would make it part of server startup - * for an application whose front page is not the documentation; behind - * `memoizePerSource` it happens the first time somebody searches, and is - * reused from then on. - * - * "From then on" means *until the documentation changes*, which is the whole - * reason that helper exists rather than a `let`. A module-level promise is - * correct in production, where `content/docs` is frozen build output, and wrong - * while somebody is writing: it would keep answering searches from the index - * built at boot until the dev server was restarted. Keying the cache on the - * source module gives both behaviours from one mechanism - see - * `#/docs/freshness`. - */ const docsSearchApi = memoizePerSource( async () => await import('#/docs/source.server'), async ({ source }): Promise<SearchAPI> => { diff --git a/apps/web/src/routes/llms-full[.]txt.ts b/apps/web/src/routes/llms-full[.]txt.ts index 6ff017840..51ad36964 100644 --- a/apps/web/src/routes/llms-full[.]txt.ts +++ b/apps/web/src/routes/llms-full[.]txt.ts @@ -2,40 +2,6 @@ import { createFileRoute } from '@tanstack/react-router' import { memoizePerSource } from '#/docs/freshness' -/** - * `/llms-full.txt` - the whole documentation as one Markdown file, for an - * assistant to read. - * - * Migrated from the Next.js route of the same URL, and the URL is the point: - * it is a well-known path that people and tools have already been given, so it - * keeps its spelling. `[.]` is TanStack Router's escape for a literal dot in a - * flat route filename - without it, `llms-full.txt.ts` would be the route - * `/llms-full/txt`. - * - * There is no `/llms.txt`. The Next.js application never had one - only this - * file - and inventing an index of links during a migration would be new - * functionality rather than preserved functionality. - * - * ## What replaced `"use cache"` - * - * The Next.js version wrapped the work in `"use cache"` with `cacheLife("max")`, - * which is that framework's spelling of "this is build output, compute it once". - * The equivalent here is `memoizePerSource`: in production the answer is - * identical for every request and cannot change while the process is running, so - * the first request computes it and every later one awaits the same promise - - * and while somebody is *writing* documentation, the cache is keyed on the - * source module, so an edit produces a new answer without a restart. See - * `#/docs/freshness`. - * - * It is deliberately *not* an HTTP cache directive - `src/lib/document-headers.ts` - * explains why this application does not hand those out yet, and this response - * is plain text rather than a document only because of what it contains. - * - * `text/plain; charset=utf-8` is stated. The Next.js route returned a bare - * `Response`, which browsers sniffed as `text/plain` anyway; saying so means a - * client does not have to guess, and the charset matters for documentation that - * contains arrows and dashes. - */ const docsAsMarkdown = memoizePerSource( async () => await import('#/docs/source.server'), async ({ getLLMText, source }) => diff --git a/apps/web/src/server/api-bridge.ts b/apps/web/src/server/api-bridge.ts index 491d59177..30a6df067 100644 --- a/apps/web/src/server/api-bridge.ts +++ b/apps/web/src/server/api-bridge.ts @@ -1,15 +1,3 @@ -/** - * The seam between the web runtime and the Hono API. - * - * A bridge is handed the `Request` the browser (or an SSR loader) made to - * `/api/*` on this origin and answers it with whatever Hono answers. It is one - * line, and that is the point: status, body, every `Set-Cookie`, the cookie and - * `x-forwarded-*` headers the API reads are all already correct on the request - * the platform built, and stay correct exactly as long as nobody rebuilds them. - * - * `src/tests/api-bridge-contract.ts` holds this to that behaviour, including the - * ways a rebuilt request loses it. - */ export type ApiBridge = (request: Request) => Promise<Response> | Response interface FetchableApp { diff --git a/apps/web/src/site/home/animated-beam/animated-beam-home-skeleton.tsx b/apps/web/src/site/home/animated-beam/animated-beam-home-skeleton.tsx index 62812b58a..0045a96ed 100644 --- a/apps/web/src/site/home/animated-beam/animated-beam-home-skeleton.tsx +++ b/apps/web/src/site/home/animated-beam/animated-beam-home-skeleton.tsx @@ -8,14 +8,6 @@ const Row = ({ center }: { center?: boolean }) => ( </div> ) -/** - * What the hero shows while the beam's chunk is on the wire. - * - * The same three rows at the same sizes, so the hero does not resize when the - * real thing arrives. It is the Suspense fallback for a `React.lazy` boundary - * now rather than for an eagerly imported component, which is what makes it a - * boundary that actually defers something - see `home-content.tsx`. - */ export const AnimatedBeamHomeSkeleton = () => ( <div aria-busy="true" diff --git a/apps/web/src/site/home/animated-beam/animated-beam-home.tsx b/apps/web/src/site/home/animated-beam/animated-beam-home.tsx index 7e9777080..55962f1bd 100644 --- a/apps/web/src/site/home/animated-beam/animated-beam-home.tsx +++ b/apps/web/src/site/home/animated-beam/animated-beam-home.tsx @@ -59,28 +59,6 @@ const Circle = ({ ) } -/** - * What VitNode gives you, drawn as beams converging on the mark. - * - * Migrated from the Next.js homepage with the same nine circles, the same eight - * beams and the same directions. Three things changed, all of them framework - * coupling rather than design: - * - * - **The links.** They were `@vitnode/core/lib/navigation`'s `Link`, which is - * next-intl's navigation and does not exist outside Next.js. They are the - * injected {@link SiteLinkComponent} now, which matters for more than - * compiling: two of these circles point into `/docs`, and that is still the - * Next.js application's. - * - **The mark.** `LogoVitNode` from `@vitnode/core`, rather than the byte-identical - * copy that sat in `apps/docs/src/components`. There is one VitNode logo. - * - **The tooltip provider.** It was mounted per circle; `VitNodeRootProviders` - * mounts one above every route in this application, and a second provider - * inside it is eight extra React trees for no behaviour. - * - * `containerRef` is what every beam measures against, which is why the beams are - * siblings of the grid rather than children of it: they are absolutely - * positioned over the whole container and sized from its rect. - */ export const AnimatedBeamHome = ({ LinkComponent, }: { diff --git a/apps/web/src/site/home/animated-beam/animated-beam.tsx b/apps/web/src/site/home/animated-beam/animated-beam.tsx index 12edb8972..6d42c54e0 100644 --- a/apps/web/src/site/home/animated-beam/animated-beam.tsx +++ b/apps/web/src/site/home/animated-beam/animated-beam.tsx @@ -5,19 +5,6 @@ import { cn } from '@vitnode/core/lib/utils' import { motion } from 'motion/react' import { useEffect, useId, useState } from 'react' -/** - * A gradient that travels along a curve drawn between two elements. - * - * Ported from the Next.js homepage unchanged apart from its import list - it - * named nothing from `next/*` to begin with, only `motion/react`, `cn` and three - * React hooks, which is why it survived the migration intact. - * - * It measures rather than lays out: the path is computed from three - * `getBoundingClientRect()` calls and recomputed by a `ResizeObserver` on the - * container. On the server there is nothing to measure, so `pathD` is empty and - * the `<svg>` renders at 0x0 - the circles are the SSR'd content, and the beams - * appear on the first client effect. That was true in Next.js too. - */ export const AnimatedBeam = ({ className, containerRef, diff --git a/apps/web/src/site/home/home-content.tsx b/apps/web/src/site/home/home-content.tsx index 2307223e0..11be89902 100644 --- a/apps/web/src/site/home/home-content.tsx +++ b/apps/web/src/site/home/home-content.tsx @@ -8,22 +8,6 @@ import { CallToActionSection } from '#/site/home/sections/call-to-action' import { HeroSection } from '#/site/home/sections/hero' import { PoweringBySection } from '#/site/home/sections/powering-by' -/** - * The beam, off the page's critical path. - * - * The Next.js page wrapped this in `<Suspense>` around an ordinary import, which - * renders the fallback for zero milliseconds and defers nothing - the module was - * in the same chunk as the page either way. `React.lazy` is what makes the - * boundary real: the beam, its eight `ResizeObserver`-driven paths and the - * `motion` gradient it animates land in a chunk of their own, requested after - * the hero has painted, with {@link AnimatedBeamHomeSkeleton} holding its size - * in the meantime. - * - * It is the right thing to defer and the wrong thing to drop: it is the only - * moving illustration of what the product *is*, it is below the headline rather - * than above it, and on the server it renders nine static circles because there - * is nothing to measure - so nothing about the first paint depends on it. - */ const AnimatedBeamHome = lazy(async () => { const { AnimatedBeamHome: Component } = await import('#/site/home/animated-beam/animated-beam-home') @@ -31,47 +15,6 @@ const AnimatedBeamHome = lazy(async () => { return { default: Component } }) -/** - * vitnode.com's front page. - * - * ## Why this is the application's and not the package's - * - * `@vitnode/core` is what every VitNode install ships. This is one website's - * marketing copy, one website's screenshot of its own AdminCP, and one - * website's opinion about which six tools deserve a row on its front page - so - * putting it in the package would hand the VitNode.com homepage to every - * generated application, which `packages/create-vitnode-app` deliberately does - * not do: it scaffolds its own "Start Your Journey!" starter page, and Stage 15 - * left that alone. - * - * "`apps/web` is thin" is a rule about *infrastructure* - runtime, routing, - * i18n, auth, caching, the design system - all of which is in the package and - * none of which is here. A site's own pages are the residue that is left when - * the infrastructure has been factored out, and a route file three lines long - * with the page in a module beside it is what thin looks like at this end. - * - * The one piece of this page that *is* the package's went there: the mark. See - * `LogoVitNodeBrand` in `@vitnode/core/components/logo-vitnode`, which the - * header renders and the beam's centre circle reuses. - * - * ## Framework coupling, all of it removed - * - * The Next.js page reached `next/image`, `fumadocs-core/link` and, through its - * sections, `next/link` and `@vitnode/core/lib/navigation` - next-intl's - * navigation. None of it survived: the screenshot is an `<img>` against a file - * in `public/`, and every internal link is the injected {@link SiteLinkComponent}, - * named once by the route file above this and nowhere else on the page. - * - * ## What it does not do - * - * No loader, no query, no namespaces of its own. Every string on it is English - * in the source, exactly as it was in Next.js - there is no `home` branch in - * `core/locales`, and inventing Polish marketing copy is not a migration. The - * shell above it warms what the header reads, and this page adds nothing to - * that. `MainBreadcrumb` renders nothing here for the same reason the Next.js - * `@breadcrumb` slot returned `null` at `/`: a breadcrumb whose only entry is - * "Home" is a row of chrome that says nothing. - */ export const HomeRouteContent = ({ LinkComponent, }: { diff --git a/apps/web/src/site/home/infinite-slider.tsx b/apps/web/src/site/home/infinite-slider.tsx index e0547b0c1..4ce9c3afb 100644 --- a/apps/web/src/site/home/infinite-slider.tsx +++ b/apps/web/src/site/home/infinite-slider.tsx @@ -4,18 +4,6 @@ import { cn } from '@vitnode/core/lib/utils' import { animate, motion, useMotionValue } from 'motion/react' import { useEffect, useRef, useState, useSyncExternalStore } from 'react' -/** - * An element's width, kept current as it changes. - * - * The Next.js copy of this component used `react-use`'s `useMeasure`, and that - * package is not a dependency of this application and is not worth becoming one - * for a single `ResizeObserver`: it is a grab-bag of ~100 hooks, and the one - * being borrowed is the eight lines below. - * - * A ref plus state rather than a callback ref, because the observer has to be - * torn down as well as set up, and the width has to survive a re-render that - * does not remount the node. - */ const useMeasuredWidth = () => { const ref = useRef<HTMLDivElement>(null) const [width, setWidth] = useState(0) @@ -39,26 +27,8 @@ const useMeasuredWidth = () => { return [ref, width] as const } -/** - * A subscription that never fires, so the value below is read once per render. - * - * At module scope because `useSyncExternalStore` compares the subscribe - * function by identity and re-subscribes when it changes - a new closure per - * render would tear the store down and set it up again on every one. - */ const neverChanges = () => () => {} -/** - * `false` while the server renders and through hydration, `true` afterwards. - * - * `useSyncExternalStore`'s third argument is the server snapshot, which React - * also uses for the client's *first* render - so the two agree and there is no - * hydration mismatch, and the flag flips in the commit that follows. - * - * The point of asking is below: the marquee's second copy of its children is - * scenery for an animation that cannot have started yet, and rendering it on - * the server doubled this page's HTML. - */ const useIsHydrated = () => useSyncExternalStore( neverChanges, @@ -74,20 +44,6 @@ export interface InfiniteSliderProps { speedOnHover?: number } -/** - * A row that scrolls forever, and slows down when a pointer is over it. - * - * Ported from the Next.js homepage rather than rewritten: same motion value, - * same duplicated children, same linear loop, same two-phase hover handling - * where a speed change animates out the remaining distance before the loop - * restarts at the new rate. What changed is the measurement (see - * {@link useMeasuredWidth}) and the horizontal-only signature - the vertical and - * reversed directions were options nothing on this site ever passed. - * - * `w-max` on the moving track and `overflow-hidden` on the frame are what make - * the loop seamless: the children are rendered twice, and the track is - * translated by exactly half its own width before snapping back. - */ export const InfiniteSlider = ({ children, className, diff --git a/apps/web/src/site/home/metadata.ts b/apps/web/src/site/home/metadata.ts index b5f26f88a..f4e456e96 100644 --- a/apps/web/src/site/home/metadata.ts +++ b/apps/web/src/site/home/metadata.ts @@ -1,40 +1,4 @@ -/** - * What vitnode.com's front page says it is, to a crawler and to a browser tab. - * - * Its own module, and deliberately not next to the markup it describes. A - * route's `head` is one of the options TanStack Start's code splitter leaves - * behind in the client entry - so whatever module `head` reads from is loaded on - * every page of the application, `/settings/security` included. Exporting these - * two strings from `home-content.tsx` would put the hero, the marquee, the - * AdminCP screenshot and `motion` in that entry. It is the same split - * `@vitnode/core/tanstack/*` makes between a namespace's `route.tsx` and its - * `screen.tsx`, for the same reason. `src/tests/asset-graph.test.ts` holds the - * built page to it, and `@vitnode/core`'s `tanstack/eager-graph.test.ts` holds - * the package's own route modules to it. - */ - -/** - * The page's own title, before the site's name is appended. - * - * `pageHead` runs it through `formatPageTitle`, so the tab reads - * `"Community Framework for Building Apps - VitNode"` - the same - * `"<page> - <site>"` rule Next.js applies through `title.template`, and the - * reason this string does not repeat "VitNode" itself. The Next.js page it - * replaces set the whole title by hand and got `"VitNode: Community Framework - * for Building Apps - VitNode"` out of the template; the words and their order - * are preserved, the second VitNode is not. - */ export const HOME_TITLE = 'Community Framework for Building Apps' -/** - * The `<meta name="description">`. - * - * One word changed from the Next.js page's: it opened `"Build with Next.js and - * Hono.js"`, and after this stage the application a visitor is reading is served - * by TanStack Start. Naming Next.js as VitNode's frontend runtime is now simply - * a false statement about the page it appears on. Everything after the first - * sentence is the copy as it was - this is a migration, not a rewrite of the - * product's positioning. - */ export const HOME_DESCRIPTION = 'Build with TanStack Start and Hono.js. It provides a structured, plugin-based architecture with Admin Control Panel that makes development faster and less complex.' diff --git a/apps/web/src/site/home/sections/admin-panel.tsx b/apps/web/src/site/home/sections/admin-panel.tsx index e84e15321..00fca1e9d 100644 --- a/apps/web/src/site/home/sections/admin-panel.tsx +++ b/apps/web/src/site/home/sections/admin-panel.tsx @@ -2,16 +2,6 @@ import { CpuIcon, LockIcon, PlugIcon, SparklesIcon } from 'lucide-react' import adminControlPanel from '#/site/home/assets/admin-control-panel.png' -/** - * The four claims under the screenshot. - * - * The Next.js page shipped four cards that all read "Powerful" over the same - * sentence, three of them under a different icon - placeholder copy that was - * never filled in. Migrating it verbatim would ship it, so each card now says - * what its icon has always said. That is the smallest edit that leaves a - * publishable section, and every line of it is a statement VitNode's AdminCP - * already makes elsewhere in this repository; it is not new positioning. - */ const FEATURES = [ { description: @@ -39,51 +29,6 @@ const FEATURES = [ }, ] -/** - * The Admin Control Panel section: one screenshot, four claims. - * - * ## The screenshot belongs to this application, and is imported - * - * `src/site/home/assets/admin-control-panel.png` is the ownership decision this - * section exists to record. It is a photograph of vitnode.com's own AdminCP - a - * marketing asset for one website - and `@vitnode/core` is a framework that - * thousands of installs render. A screenshot of *our* panel does not belong in - * a package every one of them ships. - * - * It is an *import* rather than a path into `public/` so that the URL Vite emits - * carries a content hash: the file may then be served immutable and for a year, - * which an unversioned `/admin-control-panel.png` can never be - a proxy has no - * way to tell a new screenshot from the old one, so the only safe answer for - * that URL is a short expiry and a revalidation on every visit. - * - * ## It is below the fold, and says so - * - * `loading="lazy"` is the load-bearing one: React 19 emits a - * `<link rel="preload" as="image">` into the document head for an eager image - * it renders during SSR, so this 264 KB screenshot was being fetched at the - * highest priority the browser has, in competition with the stylesheet and the - * hero - for a picture two screens down. Declaring it lazy withdraws the - * preload and defers the request until the visitor scrolls near it. - * - * `fetchPriority="low"` covers the case where a very tall viewport puts it in - * range immediately, and `decoding="async"` keeps the decode of a 2880px image - * off the main thread whenever it does arrive. - * - * None of this can shift the layout: the frame below reserves the space through - * `aspect-88/36`, and the intrinsic `width`/`height` reserve it again on the - * image itself. - * - * The Next.js page rendered the same file twice - once `dark:hidden`, once - * `hidden dark:block` - from the same import, with the same pixels, under two - * different alt texts. That is a second decode and a second entry in the - * accessibility tree for no visual difference at all, so it is one `<img>` here. - * The gradient above it is what blends it into either theme, and always was. - * - * `width` and `height` are the file's real intrinsic size. The Next.js page - * declared 2797x1137 against a 2880x1392 file, which is a wrong aspect ratio - * for the browser to reserve space with; `aspect-88/36` on the frame was doing - * the work regardless. - */ export const AdminSection = () => ( <section className="py-16 md:py-32"> <div className="mx-auto flex max-w-6xl flex-col gap-12 px-6"> diff --git a/apps/web/src/site/home/sections/call-to-action.tsx b/apps/web/src/site/home/sections/call-to-action.tsx index 363b31369..375c2d57c 100644 --- a/apps/web/src/site/home/sections/call-to-action.tsx +++ b/apps/web/src/site/home/sections/call-to-action.tsx @@ -1,12 +1,5 @@ import { Card } from '@vitnode/core/components/ui/card' -/** - * The closing card, migrated word for word. - * - * Nothing in it named a framework, so nothing in it needed updating - and it is - * the one section of this page where that is worth saying out loud, because the - * temptation in a migration is to touch what is already correct. - */ export const CallToActionSection = () => ( <section className="py-16"> <Card className="flex flex-col items-center gap-4 px-6 py-16 text-center"> diff --git a/apps/web/src/site/home/sections/hero.tsx b/apps/web/src/site/home/sections/hero.tsx index 6c9fbd72d..dc3acbd32 100644 --- a/apps/web/src/site/home/sections/hero.tsx +++ b/apps/web/src/site/home/sections/hero.tsx @@ -4,13 +4,6 @@ import { ChevronRight } from 'lucide-react' import type { SiteLinkComponent } from '#/site/home/site-link' -/** - * GitHub's mark, decorative. - * - * `aria-hidden` and no `<title>`: the link it sits in already says "View on - * GitHub" in text, and the Next.js version's `role="img"` plus title made a - * screen reader announce "GitHub View on GitHub". - */ const GitHubIcon = () => ( <svg aria-hidden @@ -25,38 +18,6 @@ const GitHubIcon = () => ( </svg> ) -/** - * The front page's opening screen: the product's one sentence, and the two - * things a reader can do about it. - * - * The headline, the sub-line and both buttons are the Next.js page's, word for - * word. Neither names a framework, so this migration left the positioning alone - * - only the description in `metadata.ts` had a factual claim to correct. - * - * ## The two links are two different kinds of link, deliberately - * - * **Get Started** goes to `/docs/dev` through the injected - * {@link SiteLinkComponent}, and this file has never known how that becomes a - * navigation. It has been a full-document load into a second application, then a - * per-href decision between two of them, and now an ordinary client-side - * navigation with the locale prefix applied exactly once - and none of those - * changes edited this file. There is no `startsWith('/docs')` here, and there - * must not be: how a path becomes a navigation is the route's decision, not a - * special case a page gets to hold an opinion about. - * - * **View on GitHub** is a plain `<a>`, because it leaves VitNode altogether. A - * `LinkComponent` takes a *path*; handing it an absolute URL asks a router to - * match `github.com` against the route tree, which answers with something other - * than GitHub. `rel="noopener noreferrer"` with `target="_blank"`. - * - * `visual` is a slot rather than an import so that the hero does not decide how - * the beam is loaded; see `home-content.tsx`, which puts a lazy boundary there. - * The Next.js page had `<div>something</div>` in this position - a placeholder - * that was never filled in - and rendered the beam full-width further down the - * page, where its own `sm:max-w-md` left it stranded in the middle of a wide - * container. Filling the slot it was clearly waiting for is the one layout - * decision this migration made. - */ export const HeroSection = ({ LinkComponent, visual, diff --git a/apps/web/src/site/home/sections/logos/tanstack.tsx b/apps/web/src/site/home/sections/logos/tanstack.tsx index 976fba8a7..4040cfa0f 100644 --- a/apps/web/src/site/home/sections/logos/tanstack.tsx +++ b/apps/web/src/site/home/sections/logos/tanstack.tsx @@ -1,17 +1,3 @@ -/** - * TanStack, as a wordmark rather than as a logo. - * - * There is no TanStack brand asset in this repository, and Stage 15 is not the - * place to acquire one - a third-party mark that nobody checked in is a licence - * question and a review question, not a migration one. The row it sits in - * already renders two of its entries as a mark plus a word (`Hono`, - * `PostgreSQL`), so a word on its own is the same visual language with the mark - * left out, and it is honest about what it is. - * - * The three products are named because that is what VitNode actually uses: - * Start renders the document, Router owns the route tree and the locale rewrite, - * and Query owns every cache the loaders warm. - */ export const TanStackLogo = () => ( <span className="flex flex-col items-center leading-tight"> <span className="text-xl font-bold">TanStack</span> diff --git a/apps/web/src/site/home/sections/powering-by.tsx b/apps/web/src/site/home/sections/powering-by.tsx index a596f4baa..eb8ec5b57 100644 --- a/apps/web/src/site/home/sections/powering-by.tsx +++ b/apps/web/src/site/home/sections/powering-by.tsx @@ -16,33 +16,6 @@ const TOOLS = [ { href: 'https://www.postgresql.org/', logo: <PostgreSQLLogo /> }, ] -/** - * What VitNode is built on, as a row that scrolls. - * - * The list is the section's only real content, and this stage changed two - * entries in it because they had stopped being true of the page they appear on. - * - * **Next.js is out.** It was the second mark in the row, and after this stage - * the application rendering this row is TanStack Start. Next.js still serves - * `/docs/*` until Stage 16 and still scaffolds a generated app, so it has not - * left VitNode - but a row headed "Powering by the best tools" on the front page - * of a TanStack Start site reads as a claim about *this* runtime, and that claim - * was false. TanStack takes its place. - * - * **next-intl is out**, for the narrower version of the same reason: this - * application reaches for `use-intl` directly and never for next-intl - which - * `src/tests/isolation.test.ts` enforces - so its logotype was naming a package - * the site does not run. `use-intl` has no mark of its own, and inventing one to - * keep the count at seven would be worse than six honest entries. - * - * Everything else is the section as it was: same frame, same fades at both - * edges, same speeds, same marks. - * - * Plain `<a>` rather than the injected `LinkComponent`, because every href here - * leaves VitNode entirely. A `LinkComponent` takes a *path*; handing it - * `https://hono.dev` would ask a router to match an absolute URL against the - * route tree, which answers with something other than Hono's website. - */ export const PoweringBySection = () => ( <section className="bg-background border-border/75 dark:border-border/50 overflow-hidden border"> <div className="group relative m-auto max-w-7xl px-6"> diff --git a/apps/web/src/site/home/site-link.ts b/apps/web/src/site/home/site-link.ts index c06b61dfc..b3bb37a17 100644 --- a/apps/web/src/site/home/site-link.ts +++ b/apps/web/src/site/home/site-link.ts @@ -1,24 +1,3 @@ -/** - * How a link on this site becomes a navigation. - * - * A prop rather than an import, in every section of the front page, so that - * exactly one module decides it - the route file. The sections below stay - * framework-neutral and take whatever they are handed. - * - * That is not ceremony, and the page has the receipts. `/docs/dev` is the - * destination of the primary call to action, and how a click on it travelled has - * now changed three times - a document load into a second application, then a - * per-href decision between two of them, and now an ordinary client-side - * navigation. No section was edited for any of it, and there is no - * `href.startsWith('/docs')` anywhere on this page. - * - * Written structurally rather than against a named component for the same - * reason: `RouterLink` satisfies this, and so does a plain `<a>`, and so will - * whatever comes next - without `#/site` naming a router at all. It is every - * prop of an anchor with `href` made required, because a link component that - * only accepted `children` and `className` would silently drop the `ref` a Base - * UI `render` clones onto it. - */ export type SiteLinkComponent = React.ComponentType< Omit<React.ComponentProps<'a'>, 'href'> & { href: string } > diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/routes/api/$.ts b/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/routes/api/$.ts index 3b77cb64c..8cfbfd0ce 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/routes/api/$.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/routes/api/$.ts @@ -2,28 +2,7 @@ import { createFileRoute } from '@tanstack/react-router' import { apiBridge } from '#/server/vitnode-api.server' -/** - * `/api/*` - the existing VitNode Hono application, mounted. - * - * `server` is the only option on this route on purpose. TanStack Start prunes a - * route file whose sole option is `server` out of the client route tree - * entirely (and its client code-splitter deletes the `server` node on top of - * that), so none of the API - Hono, Drizzle, the plugins - can reach the browser - * bundle. The `.server.ts` import is refused by import protection if that ever - * stops being true. - * - * `ANY` rather than a handler per method: routing, OpenAPI, middleware, auth, - * plugin mounting and error handling all stay inside Hono, exactly as they are - * when the same app runs standalone in `apps/api` or under the Next.js catch-all - * in `apps/docs`. `ANY` is part of the framework's `RouteMethod` union and is - * what Start falls back to for any method it was given no handler for - `HEAD` - * included, where it calls this handler and strips the response body itself. - * So the API keeps answering for methods this file has never heard of, which is - * the point: there is nothing here to keep in sync with the API's routes. - * - * `src/tests/api-server-route.test.ts` drives the real request handler over this - * route to hold that to every method the API needs. - */ + export const Route = createFileRoute('/api/$')({ server: { handlers: ({ createHandlers }) => diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/server/api-bridge.ts b/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/server/api-bridge.ts index 491d59177..f24f783be 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/server/api-bridge.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/server/api-bridge.ts @@ -1,15 +1,4 @@ -/** - * The seam between the web runtime and the Hono API. - * - * A bridge is handed the `Request` the browser (or an SSR loader) made to - * `/api/*` on this origin and answers it with whatever Hono answers. It is one - * line, and that is the point: status, body, every `Set-Cookie`, the cookie and - * `x-forwarded-*` headers the API reads are all already correct on the request - * the platform built, and stay correct exactly as long as nobody rebuilds them. - * - * `src/tests/api-bridge-contract.ts` holds this to that behaviour, including the - * ways a rebuilt request loses it. - */ + export type ApiBridge = (request: Request) => Promise<Response> | Response interface FetchableApp { diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/vitnode.api.config.ts b/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/vitnode.api.config.ts index 7e0df302e..dc78ff9d2 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/vitnode.api.config.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/vitnode.api.config.ts @@ -13,15 +13,7 @@ export const vitNodeApiConfig = buildApiConfig({ shortTitle: "VitNode", }, plugins: [], - /** - * The site's own locale declaration, because this app is both: `vitnode.config.ts` - * is the one statement of which languages exist, and both configs read it. - * - * It is also what `vitnode db:prepare` seeds `core_languages` from - this app - * owns the schema - so adding a language there and re-running `dev` inserts - * its row. Leave it out and the seed falls back to `en` alone, whatever the - * site serves. - */ + i18n: vitNodeConfig.i18n, dbProvider: drizzle({ connection: POSTGRES_URL, diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/api/src/vitnode.api.config.ts b/packages/create-vitnode-app/copy-of-vitnode-app/api/src/vitnode.api.config.ts index 572d64abd..a587b6f2c 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/api/src/vitnode.api.config.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/api/src/vitnode.api.config.ts @@ -12,25 +12,7 @@ export const POSTGRES_URL = export const vitNodeApiConfig = buildApiConfig({ plugins: [], - /** - * The languages this installation serves. - * - * The API half of a split deployment. `apps/web/src/vitnode.config.ts` - * declares the same list, and the two are one declaration in two places by - * necessity rather than by design: they are separate packages, so neither can - * import the other's. Nothing walks the filesystem looking for the web app's - * config either - a bootstrap that guessed at a sibling application is exactly - * what that replaced, and it guessed wrong the moment the two were not laid - * out the way it expected. - * - * They have to agree, and this is the copy that matters most: this app owns - * the schema, so `vitnode db:prepare` seeds `core_languages` from *this* list. - * A language that is here and not in the web app's renders nowhere; one that - * is in the web app's and not here has no row in the database. - * - * Packages ship their own translations, so a new locale needs no `messages` - * entry - anything untranslated falls back to `defaultLocale` key by key. - */ + i18n: { defaultLocale: "en", locales: [{ code: "en", name: "English" }], diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/global.d.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/global.d.ts index 6deab1df7..9952fb5a9 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/global.d.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/global.d.ts @@ -2,25 +2,7 @@ import core from '@vitnode/core/locales/en.json' with { type: 'json' } -/** - * What `useTranslations` is allowed to be asked for. - * - * Augmenting **`use-intl`** rather than `next-intl`. `AppConfig` is declared by - * `use-intl`, which is what VitNode renders every string through on every host; - * `next-intl` only ever re-exported it. This is a type-level dependency and so - * it survives a grep for imports - which is exactly why it is worth naming. - * - * `use-intl` has to be a *direct* dependency of this app for the reference above - * to resolve. Under pnpm's strict `node_modules` layout, reaching it through - * `@vitnode/core` is not enough. - * - * Typed against core's English tree, which is the default locale: every other - * language falls back to it key by key, so it is the one that defines which keys - * exist. Add a plugin's tree to the intersection to get its keys checked too: - * - * import blog from '@acme/blog/locales/en.json' with { type: 'json' } - * Messages: typeof core & typeof blog - */ + declare module 'use-intl' { interface AppConfig { Messages: typeof core diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/components/admin-shell.tsx b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/components/admin-shell.tsx index 81c932fef..76c3af0a4 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/components/admin-shell.tsx +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/components/admin-shell.tsx @@ -7,50 +7,7 @@ import { adminNav } from "#/lib/admin-nav"; import { adminUserSearchFn } from "#/lib/admin-search"; import { useAppNavigate } from "#/lib/navigation"; -/** - * The AdminCP shell, as this app mounts it. - * - * Everything the panel *is* - the sidebar, the header, the palette, the user - * menu, the breadcrumb area and the one `<main>` - is `AdminShellContent`'s. - * What is bound here is only what a package cannot answer for an application: - * - * onNavigate useAppNavigate the palette's Enter key - * searchUsers adminUserSearchFn this app's own server function - * languageSwitcher <LanguageSwitcher/> the router's - * nav adminNav the plugins *this* app configured - * - * No `LinkComponent`: every sidebar destination is a route in this application's - * own tree, so the shell's own default - `RouterLink` - is the right one. The - * exception is handled a layer down rather than here: a plugin's `admin.nav` - * entry may point at a docs site or a status page, and `adminLinkFor` renders - * those as plain anchors, so an absolute URL is never handed to a router that - * would try to match it. The sidebar and the command palette both go through it, - * so an entry cannot behave one way when clicked and another when searched. - * - * `onNavigate` is still passed, because the palette moves the router *without a - * link*: Enter on a highlighted entry is a navigation nobody clicked, and it has - * to be handed the same de-localized destination a `<Link>` would build. See - * `#/lib/navigation`. - * - * ## `nav` is a projection, not the plugin registry - * - * `src/vitnode.config.ts` is server-side and carries message loaders, which a - * browser bundle has no business holding. So the sidebar is built from - * `#/lib/admin-nav`, which reads the generated browser-safe projection: ids, - * hrefs, permissions, icons and content type definitions, and nothing that - * renders a screen. The screens have a projection of their own - see - * `#/lib/content-registry`, which the content route imports so that a plugin's - * editor fields and form layouts land in that route's chunk rather than in the - * shell's. - * - * It carries the message namespaces with it, because a plugin group's headings - * live under that plugin's own id and the shell would otherwise render them as - * dotted identifiers. `_admin`'s loader warms the same list. - * - * No navigation is derived from a plugin's route tree, in either direction: the - * navigation model is complete regardless of which screen a click lands on, and - * a nav entry is a product decision rather than a consequence of the route tree. - */ + const searchUsers: AdminUserSearch = async search => await adminUserSearchFn({ data: { search } }); diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/components/main-header.tsx b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/components/main-header.tsx index a384c24c2..46dfcad3a 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/components/main-header.tsx +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/components/main-header.tsx @@ -1,33 +1,4 @@ import { MainHeader as MainHeaderContent } from "@vitnode/core/tanstack/layout"; -/** - * The site header, as the main shell's slot for it. - * - * One line of application. The bar, the nav, the language and theme switchers, - * the user area and both cache entries they read are - * `@vitnode/core/tanstack/layout`'s `MainHeader`. - * - * **Your mark goes here.** Left alone the header renders VitNode's own, which is - * the right default and the wrong answer for a real site. Pass your own: - * - * <MainHeaderContent logo={<YourLogo />} /> - * - * This file exists so that choosing a mark is a sentence a site says out loud - * rather than something it inherits without noticing. - * - * No `LinkComponent`. Every header destination is a route in this application's - * own tree, so the header's own default - `RouterLink`, the router's `Link` in - * the shape the shared views ask for - is the right one, and the prop stays - * available for a host that needs to answer differently. - * - * ## What the shell owes it - * - * Two warm cache entries, both ensured by `_main`'s loader: - * - * headerIntlQueryOptions -> a `useSuspenseQuery`, so this is required - * prefetchSession -> the first paint shows the visitor, not a gap - * - * See the loader in `routes/_main.tsx`, which states why one is `ensure` and the - * other `prefetch`. - */ + export const MainHeader = () => <MainHeaderContent />; diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/admin-nav.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/admin-nav.ts index a74e77f24..0b6f27489 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/admin-nav.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/admin-nav.ts @@ -2,42 +2,5 @@ import { adminNavBundle } from "@vitnode/core/tanstack/admin"; import { pluginAdminNav } from "#/admin-nav.gen"; -/** - * This installation's AdminCP navigation - core's own, plus whatever the plugins - * it configured contribute. - * - * Two lines, and both of them are host-only work by necessity. Which plugins are - * installed is a property of this application, and `src/admin-nav.gen.ts` is - * where the build writes the answer: one literal import per configured plugin - * that exports an `admin/nav` module. Every *rule* about what a sidebar contains - * - which entries a content type earns, how a hand-declared entry is titled, - * which permission hides one, which message namespaces the result needs - is - * `@vitnode/core`'s, in `adminNavBundle`. Nothing about navigation is decided - * here. - * - * ## Why it is not read from `vitnode.config.ts` - * - * The config does carry each plugin's registration, and a Next.js host walks it - * in its render pass. Reading it here would make every configured plugin's - * editing screens reachable from the module the document shell imports, which is - * the one graph that is never lazy. The generated projection carries exactly - * what a sidebar needs instead - ids, hrefs, permissions, icons and content type - * definitions, all plain data - and the Content Engine's UI arrives separately, - * when a content screen actually renders, through `src/lib/content-registry.ts`. - * - * ## Module scope, and why that matters twice - * - * Evaluated once per bundle rather than per render. `AdminShellContent` memoises - * its namespace list on this object's identity, and `_admin`'s loader warms the - * messages from the same `namespaces` array the shell then reads - so a stable - * value here is what makes those one cache entry rather than two. - * - * ## Content entries were never rewritten, and that is the point - * - * A content type's entry is `/admin/content/…`, and it said exactly that - * through every change in which framework rendered those screens. Only *how a - * click travels* ever changed, and nothing here was edited for it. Routes and - * navigation stay separate concepts: nothing derives a nav entry from a route - * file, and nothing makes one conditional on a route existing. - */ + export const adminNav = adminNavBundle({ plugins: pluginAdminNav }); diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/admin-search.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/admin-search.ts index eb392654b..d3f848188 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/admin-search.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/admin-search.ts @@ -2,32 +2,7 @@ import { createServerFn } from "@tanstack/react-start"; import { readAdminUserSearchOnApi } from "@vitnode/core/tanstack/admin/server"; import { z } from "zod"; -/** - * The AdminCP command palette's user lookup, as this app's server function. - * - * One handler, one line, exactly like `lib/auth.ts` - and here for the same - * reason. `createServerFn` needs the module it sits in to be transformed by the - * Start compiler on both sides of the render, and `@vitnode/core` reaches the - * server un-compiled (see the note in `lib/auth.ts`), so the declaration lives - * in the host and the behaviour lives in the package. - * - * ## Why it has a validator - * - * A server function is a public same-origin endpoint: its input is whatever a - * caller posts, not whatever the dialog typed. This value reaches an API query - * string, so it is parsed rather than trusted - trimmed, length-bounded, and a - * string at all. - * - * The bound is not a security control; the API authorizes the read from the - * admin cookie and pages it itself. It is a cheap refusal of the requests that - * could only ever be abuse - a megabyte of "search term" that the API would - * otherwise have to hand to Postgres. - * - * `POST` puts it behind `createCsrfMiddleware` in `src/start.ts`, which matters - * more here than it looks: this reads other people's names and email addresses, - * and a `GET` would be reachable from another origin's page with the - * administrator's cookies attached. - */ + const adminUserSearchInput = z.object({ search: z.string().trim().min(1).max(128), }); diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/content-registry.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/content-registry.ts index e69c95613..86aa2d0bc 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/content-registry.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/content-registry.ts @@ -5,48 +5,7 @@ import { import { pluginContentTypes } from "#/content-registry.gen"; -/** - * This installation's Content Engine registry - every content type its - * configured plugins register, with their editing screens attached. - * - * Two lines, and both of them are host-only work by necessity. Which plugins are - * installed is a property of this application, and `src/content-registry.gen.ts` - * is where the build writes the answer: one literal import per configured plugin - * that exports an `admin/content` module. Every *rule* about what a registration - * is - which paths are legal, which two of them collide, how they are ordered, - * how one is found by id or by `admin.path` - is `@vitnode/core`'s, in - * `buildContentFrontendRegistry`. Nothing about the Content Engine is decided - * here. - * - * ## Why it is not read from `vitnode.config.ts` - * - * The config does carry each plugin's registration, and a Next.js host reads it - * from there. What it cannot give is *when*: it is imported by the document - * shell, so anything reachable from it is reachable eagerly. This module is - * loaded behind a dynamic `import()` in `src/router.tsx` instead, so the - * definitions, the icons and the override components arrive with the content - * route - the same arrangement `src/lib/admin-nav.ts` uses for the sidebar, one - * layer deeper. - * - * ## Registration, and where it belongs in the import graph - * - * `setContentFrontendRegistry` fills a module-scope slot in `@vitnode/core`, so - * the package's own content code finds the registry without being handed it as - * a prop - the same shape as `setAdminTransport`. Module scope means *per - * bundle*: the browser has one instance and the server has one, and each - * registers its own. - * - * This module is reached through a `() => import(...)` that `/admin/content`'s - * loader awaits, never through a static import, and that is deliberate. - * Registration only has to happen before a content screen runs, and deferring it - * to the route is what lets Rollup put this whole graph - every plugin's field - * components, table cells and form layouts, plus `zod` and the Content Engine - * itself - in that route's chunk instead of in the bundle every page of the site - * loads first. `router.tsx` holds the thunk because that is where the route tree - * is composed; what it does *not* hold is the value. A plugin's own heavier - * parts stay lazier still: `@vitnode/blog` draws a `React.lazy` boundary around - * its Tiptap editor, so even opening the article list does not fetch it. - */ + export const contentRegistry = buildContentFrontendRegistry(pluginContentTypes); setContentFrontendRegistry(contentRegistry); diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/i18n/runtime.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/i18n/runtime.ts index d841c308c..53faf788c 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/i18n/runtime.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/i18n/runtime.ts @@ -5,71 +5,22 @@ import { IntlProvider } from "use-intl"; import { loadIntlMessages } from "#/server/messages.server"; import { vitNodeConfig } from "#/vitnode.config"; -/** - * One language's messages for one set of namespaces, fetched on the server. - * - * The one piece of the i18n runtime that cannot live in `@vitnode/core`, and the - * reason is the compiler rather than the code. A server function has to be - * transformed by the Start plugin in *both* bundles; the package is externalised - * from this app's SSR pass, so its modules reach the server un-compiled and a - * `createServerFn` declared there resolves to `undefined` during SSR with no - * error. See `packages/vitnode/src/tanstack/boundary.test.ts`. - * - * So the wrapper is here and the body is not: `validateIntlInput` is core's, and - * `loadIntlMessages` delegates to core's loading engine. Start strips the - * handler - and `#/server/messages.server` with it - out of the client build. - */ + export const getIntlMessages = createServerFn() .validator(validateIntlInput) .handler(async ({ data }) => await loadIntlMessages(data)); -/** - * This app's languages, handed to the package once. - * - * Everything in `@vitnode/core/tanstack/i18n` reads what this registers, so a - * route file imports `RouteMessages` and `intlQueryOptions` straight from the - * package. What must not happen is a route running before this module has been - * evaluated - so `src/router.tsx`, which owns the route tree, imports from - * here. The request pipeline needs no such guarantee: `createVitNodeStart` - * derives its own locale routing from the config it is handed. - * - * The registration is at module scope but reads `getIntlMessages` above only by - * reference, so the order within this file does not matter: the validator and - * the fetcher are both called per request, long after it has finished - * evaluating. - */ + export const { defaultLocale, isLocale: isSupportedLocale, localeRouting, } = configureIntl({ fetchMessages: async input => await getIntlMessages({ data: input }), - /** - * This app's own `use-intl`, handed over so `RouteMessages` can provide it. - * - * `@vitnode/core` is external to this app's SSR pass (`vite.config.ts`), so - * under `vite dev` Node loads the package and Vite loads this app, and the two - * resolve `use-intl` to two different files - two `createContext` calls, two - * React contexts. Everything the package renders reads its own; anything this - * app renders with its own `useTranslations` - `routes/_main/index.tsx` does - - * reads this one, and nothing inside the package can import it. - * - * So it is registered rather than imported, and `RouteMessages` mounts it - * outermost. A production build resolves `use-intl` once and the providers - * collapse into one, which is why leaving this out is a dev-only failure: the - * route's server render throws "No intl context found", React quietly falls - * back to client rendering, and the page still appears. - */ + hostIntlProvider: IntlProvider, i18n: vitNodeConfig.i18n, }); -/** - * The router's half of locale routing, bound to this app's languages. - * - * Re-exported from here rather than imported straight from the package by - * `src/router.tsx`: it is the router entry's only i18n import, and routing it - * through this module is what makes `configureIntl` above run before the router - * - and therefore before any route, loader or component - exists. - */ + export { createLocaleRewrite } from "@vitnode/core/tanstack/i18n"; diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/i18n/shared.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/i18n/shared.ts index 3cfdbc9e5..c23ada960 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/i18n/shared.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/i18n/shared.ts @@ -2,15 +2,7 @@ import type { vitNodeConfig } from "#/vitnode.config"; import { localeRouting } from "#/lib/i18n/runtime"; -/** - * A language this app serves, as a type. `"en"`, or `"en" | "de"` once a second - * one is declared - derived from the config rather than written twice. - * - * The one i18n thing this app still owns, and it has to: `@vitnode/core` is - * installed by apps with different language lists, so it types a locale as - * `string` and takes this union as a type argument where the value originates - * (`useLocale<Locale>()`, `resolveLocale<Locale>()`). - */ + export type Locale = (typeof vitNodeConfig.i18n.locales)[number]["code"]; export { defaultLocale, localeRouting } from "#/lib/i18n/runtime"; diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/navigation.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/navigation.ts index beb631d8a..bef770b32 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/navigation.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/navigation.ts @@ -2,23 +2,7 @@ import { createAuthNavigation } from "@vitnode/core/tanstack/auth"; import { localeRouting } from "#/lib/i18n/shared"; -/** - * Going somewhere in this application from code, bound to this app's languages. - * - * One line of application, and everything else is - * `@vitnode/core/tanstack/auth`: the two questions a user-supplied target has to - * answer (may we send a browser there, and what does the router want to be - * handed), the reason a redirect carries `to` rather than `href`, and the fact - * that the same decision is made on a server and in a browser. - * - * What is left here is the only thing a package cannot answer - which languages - * this installation serves, which is what decides whether `/pl/discover` is a - * Polish page or a route called `pl`. - * - * `@vitnode/core/tanstack/routes` builds its own from the same factory, handed - * the same `localeRouting`, so core's auth screens and this app's AdminCP command - * palette navigate by one rule rather than two. - */ + export const { internalDestination, useAppNavigate } = createAuthNavigation({ localeRouting, }); diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/page-head.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/page-head.ts index 261614184..59c9d56e6 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/page-head.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/page-head.ts @@ -2,18 +2,5 @@ import { createRouteHead } from "@vitnode/core/tanstack/metadata"; import { vitNodeConfig } from "#/vitnode.config"; -/** - * A route's `head`, bound to this app's name. - * - * Two lines of application, and everything else is - * `@vitnode/core/tanstack/metadata`: the `"<page> - <site>"` title rule Next.js - * applies through `title.template`, the decision that a robots directive is - * stated rather than assumed, and the handling of a `loaderData` that is - * `undefined` on a route's first pass. - * - * What is left here is the only thing a package cannot answer - this site's own - * name, which is what every tab title ends with. - * - * head: ({ loaderData }) => pageHead({ robots: 'index, follow', ...loaderData }) - */ + export const pageHead = createRouteHead(vitNodeConfig.metadata); diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/locales/app.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/locales/app.ts index bb00c0d79..da24eea03 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/locales/app.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/locales/app.ts @@ -1,28 +1,4 @@ import type { AppMessagesMap } from "@vitnode/core/lib/i18n/types"; -/** - * Translations this app owns, on top of whatever the packages ship. - * - * Empty, and usually stays that way. Every VitNode package ships its own - * translations and `locales/packages.ts` is what registers them - carrying a - * second copy of a string a package already owns is how one product comes to - * spell the same settings tab two different ways. - * - * What belongs here is a string this app *changes*. Add a locale, then the key - * you are rewording: - * - * export const appMessages: AppMessagesMap = { - * en: { - * '@vitnode/core': async () => await import('./en.json'), - * }, - * } - * - * Deep-merged last, so a file here only needs the keys it actually changes: - * everything it leaves out falls back to the package's, and then to the default - * locale, key by key. - * - * Server-side only, and kept out of `src/vitnode.config.ts` on purpose: these - * are functions, and the shared config crosses to the browser and has to stay - * serializable. `src/vitnode.server.config.ts` is what registers this map. - */ + export const appMessages: AppMessagesMap = {}; diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/locales/packages.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/locales/packages.ts index 1173fa27b..bf838cb21 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/locales/packages.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/locales/packages.ts @@ -2,40 +2,7 @@ import type { LocaleMessagesMap } from "@vitnode/core/lib/i18n/types"; import { CONFIG_PLUGIN as CORE } from "@vitnode/core/config"; -/** - * Where this app reads each installed package's translations from. - * - * Every VitNode package ships a locale barrel - `@vitnode/core/locales/index` - - * that loads its own files with a runtime - * `import("./en.json", { with: { type: "json" } })`. Under Node that is exactly - * right, and it is how an API app reads them. - * - * It cannot work here, and the reason is the import attribute rather than - * anything about VitNode. Vite and Nitro inline `@vitnode/core`'s build output - * into this app's server chunks - `ssr.external` applies to the SSR pass, not to - * Nitro's own bundling - but Rollup will not follow a dynamic import that - * carries `with: { type: "json" }`, so it neither emits the JSON nor rewrites - * the specifier. What ships is a relative import pointing next to a chunk that - * the JSON was never copied to, and every string on the page renders as its own - * key. - * - * So the loaders are declared here instead, with static specifiers a bundler can - * follow. Each resolves through the package's `./locales/*.json` export to the - * real file and lands in the build as a chunk fetched on demand, which is the - * same laziness the barrels wanted. - * - * **Add a line here for every plugin you install.** A plugin registered in - * `vitnode.config.ts` with no entry in this map renders its own strings as keys: - * - * import { CONFIG_PLUGIN as BLOG } from '@acme/blog/const' - * - * [BLOG.pluginId]: { - * en: async () => await import('@acme/blog/locales/en.json'), - * }, - * - * This is the app's only copy of that list - `vitnode.server.config.ts` reads it - * from here. - */ + export const packageMessages: Record<string, LocaleMessagesMap> = { [CORE.pluginId]: { en: async () => await import("@vitnode/core/locales/en.json"), 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 bcc7baef3..1b71cdd20 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 @@ -13,92 +13,7 @@ import { ErrorActions } from "@vitnode/core/tanstack/layout"; import { AdminShell } from "#/components/admin-shell"; import { pageHead } from "#/lib/page-head"; -/** - * The boundary every AdminCP page sits under - the admin session guard, and the - * shell. - * - * Pathless: the leading underscore means it contributes no URL segment, so - * `routes/_admin/core/index.tsx` is `/admin/core`, guarded, and the guard is - * this file. A page joins the AdminCP by *where its file lives*, not by - * remembering to check a session. - * - * ## It is not under `_authenticated`, and must not be - * - * The AdminCP has its own session, under its own cookie (`vitnode_auth_admin`), - * with its own model and its own endpoint. Stacking the public guard above this - * one would bounce an administrator to `/login` for a session the AdminCP does - * not use - and `AuthState.isAdmin`, which lives on the *public* session, means - * "may be offered the AdminCP", not "is inside it". Two cookies, two questions. - * - * ## Exactly one splat may live under here, and nothing wider - * - * `admin.content.$.tsx` claims `/admin/content/*` - the Content Engine's own - * namespace, which Stage 13 moved into this router. That one is deliberate and - * narrow; a second is how the AdminCP breaks. - * - * `$.tsx`, `admin.$.tsx` or any other catch-all beside it would consume every - * *remaining* admin URL, including the ones no route declares. The AdminCP's own - * not-found would stop being reachable, a plugin's admin route would be shadowed - * by whichever splat sat above it, and nothing would report either - silently, - * and all at once. `src/tests/admin-routes.test.ts` pins both halves: that the - * content splat sits at exactly that path, and that it is the only one. - * - * ## Why the check is in `beforeLoad` - * - * It runs before the loader and long before React, so a visitor without admin - * access never receives a byte of an AdminCP page - not a flash, not a - * hydration, not a `useEffect` that redirects afterwards. A component-level - * check would render the page first and then take it away, which on the server - * means admin markup already written into the stream. - * - * ## A hover is not a navigation - * - * `defaultPreload: 'intent'` means the router runs this whole chain when a - * pointer rests on an admin link, and router-core has no staleness gate on - * `beforeLoad` the way it has on a loader - it re-runs it from the top of the - * branch every time. With `ADMIN_SESSION_STALE_TIME` at zero that made crossing - * the sidebar one server-function POST and one Hono call *per link the mouse - * passed over*, for a question nobody had asked yet. - * - * So the read is chosen by the `preload` flag the router already hands in. - * `preloadAdminAccess` is the same query, the same key and the same rejection - * behaviour, trusted for thirty seconds - the identical window the public - * session has always used for the identical reason. `ensureAdminAccess` and its - * `staleTime: 0` are untouched on the path that matters, and that is the whole - * of the revocation guarantee: entering a screen is a real navigation, this runs - * again with `preload: false`, and the API is asked. Router-core never lets a - * preloaded `beforeLoad` result stand in for a navigation's. - * - * The redirect below is deliberately *not* conditional on `preload`. It is not a - * navigation - `preloadClientRoute` catches it and preloads the sign-in page - * instead of moving anybody - and skipping it would leave a denied preload - * walking into the screen loaders underneath, which would then send admin - * requests the API is going to refuse. - * - * ## A failed read is not a denial - * - * `ensureAdminAccess` resolves only for an answer the API actually gave - `200` - * or `403`. A `429` from the rate limiter, a `500`, an API that is not listening: - * all three reject, and that rejection is deliberately left to propagate as an - * ordinary route error. - * - * Only `canEnterAdmin` answering `false` sends anybody to `/admin`. Catching the - * rejection and redirecting instead would sign every administrator out of the - * AdminCP during an outage and present them with a sign-in form for a session - * they already hold - which is precisely what the Next.js `getSessionAdminApi()` - * does today, and precisely what this shape exists to stop. - * - * ## What it is not - * - * A navigation guard, and only that. `api/config.ts` puts - * `globalAdminMiddleware()` in front of every request whose path contains - * `/admin/`, each handler re-checks the staff tables, and - * `SessionAdminModel.getUser()` re-runs `checkIfUserIsAdmin` against the - * database on every request - deleting the session the moment the answer turns - * false. So an administrator who edits this app's cached permission set in - * devtools gets a visible button and an API that still refuses them. Nothing - * here is, or may become, the security boundary. - */ + export const Route = createFileRoute("/_admin")({ beforeLoad: async ({ context, location, preload }) => { const access = preload @@ -133,96 +48,20 @@ export const Route = createFileRoute("/_admin")({ // a page cannot disagree with the guard that let it render. return { adminAccess: access }; }, - /** - * The shell's strings, warmed before React renders. - * - * `adminNav.namespaces` is the same array `AdminShell` hands the shell, and it - * has to be: the provider reads back the identical `intlQueryOptions` entry - * this fills, so warming a different namespace set would fill an entry nobody - * looks at and cost a round trip on the first paint anyway. It is not a fixed - * list because it cannot be - a plugin group's headings live under that - * plugin's own id, and which plugins this installation configured is decided - * in `src/admin-nav.gen.ts`. - * - * ## Imported inside the loader, not above it - * - * A route file's `loader` body runs only for a navigation into this shell; the - * file itself is evaluated in the client entry, on every page of the site. The - * generated navigation is not small - it carries every configured plugin's - * sidebar icons and its content type definitions, and through those the whole - * Content Engine and `zod` - so a static import here put ~45 KB of AdminCP - * data, plus `zod`, in front of the front page's first paint. `AdminShell` - * imports the same module, and `AdminShell` is this route's `component`, which - * is code-split: the two land in the same chunk, so an administrator pays for - * it once and everybody else not at all. - */ + loader: async ({ context }) => { const { adminNav } = await import("#/lib/admin-nav"); await loadAdminMessages({ ...context, namespaces: adminNav.namespaces }); }, - /** - * Stated once for the whole panel rather than on each screen. - * - * The AdminCP is behind a session and must never be indexed. Router merges the - * `head` of every matched route and dedupes `meta` by `name`, preferring the - * deepest, so an admin screen inherits this by saying nothing - which is - * exactly what `RouteHeadOptions` describes. A screen's own `pageHead` adds - * only its title and description. - */ + head: () => pageHead({ robots: "noindex, nofollow" }), - /** - * The AdminCP's 404, rendered inside the shell. - * - * What reaches it is a screen whose loader called `requireAdminPermission` - * and was refused - the same answer `app/[locale]/admin/(auth)/not-found.tsx` - * gives in the Next.js AdminCP. Declared here rather than per screen so every - * one of them answers a missing permission identically. - * - * A content URL that resolves to no content type reaches it too, and by the - * same route: `loadContentAdminRoute` throws `notFound()` for a splat its - * registry cannot name, which is the answer the Next.js catch-all's - * `notFound()` gives one navigation later. - * - * A URL under `/admin` that matches no route at all does *not* reach this one. - * Outside `/admin/content`, `_admin` has only declared children - see above - - * so such a path matches nothing in this subtree and the router falls back to - * its own not-found at the root. That is the correct trade, and the cost is - * paid deliberately: rendering an unmigrated admin URL inside this shell would - * mean claiming it. (The root has no `notFoundComponent` of its own yet, so - * that fallback is currently the router's bare one - for every unmatched URL - * in this application, not only admin ones. Giving the root a real 404 page is - * its own piece of work.) - */ + notFoundComponent: AdminNotFoundScreen, component: AdminLayout, }); -/** - * The refusal, wearing the panel it was refused inside. - * - * ## It mounts the shell itself, and has to - * - * A `notFoundComponent` renders *instead of* the component of the route that - * handles the error, not inside it - so this replaces `AdminLayout`, sidebar - * and header and palette included. An administrator who opened a screen they - * lack the permission for would otherwise be dropped onto a bare 404 page with - * no way back into the AdminCP but the browser's back button, which is not what - * the Next.js AdminCP does: its `not-found.tsx` sits *under* - * `admin/(auth)/layout.tsx` and keeps the panel around the message. - * - * Mounting `AdminShell` here restores that, and it costs nothing: `beforeLoad` - * has already resolved the admin session and the loader above has already - * warmed the shell's messages, so the providers inside read the same two cache - * entries the working screens read and nothing suspends or fetches again. - * - * ## What is bound rather than defaulted - * - * `ErrorActions` is this app's binding rather than core's default: `/` is served - * by the Next.js application on some installs and by this one on others, and - * only the route tree knows which. It is passed as an element from module scope - * so the type is stable across renders. - */ + function AdminNotFoundScreen() { return ( <AdminShell> @@ -231,24 +70,7 @@ function AdminNotFoundScreen() { ); } -/** - * The AdminCP shell. - * - * `AdminShell` is this app's binding of `AdminShellContent` - the sidebar, the - * command palette, the breadcrumb area, the user menu and the one `<main>` every - * admin page renders inside. It mounts `AdminPermissionsProvider` itself, from - * the same admin session query the guard above has already filled, so - * `AdminStaffPermissionGate` and `useAdminStaffPermission` work identically here - * and in the Next.js AdminCP. It cannot suspend in practice and nothing below it - * can suspend at all; see the note on the provider. - * - * What `#/components/admin-shell` adds on top is only what a package cannot - * answer for a particular installation: where the user lookup runs, which - * plugins this app configured, and how the command palette moves without a link. - * - * The guard, the loader and the route options above are the parts that must not - * move. - */ + function AdminLayout() { return ( <AdminShell> diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_admin/admin.core.index.tsx b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_admin/admin.core.index.tsx index d2d749f68..cb2801fad 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_admin/admin.core.index.tsx +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_admin/admin.core.index.tsx @@ -5,60 +5,11 @@ import { loadAdminDashboardRoute, } from "@vitnode/core/tanstack/admin/dashboard"; -/** - * `/admin/core` - the AdminCP dashboard, and the one route file `_admin` keeps. - * - * Topology only. The layout query, the widget catalogue, the drag-and-drop - * board, the settings dialogs and the four mutations behind them are - * `@vitnode/core/tanstack/admin/dashboard`. - * - * ## Why this one file is still here - * - * Every other AdminCP screen is `@vitnode/core`'s, mounted by - * `withCoreAdminRoutes` as a code-based route - see `src/router.tsx`. This one - * stays because the file-based generator requires it to, and the requirement is - * sharp rather than stylistic: - * - * - A pathless layout with no file children is **dropped from the generated - * tree**. `buildRouteTreeConfig` skips it outright, so `_admin` would not be - * in `routeTree.gen.ts` at all and the route object `src/router.tsx` mounts - * core's screens under would be an orphan. - * - It also collapses to a full path of `/`, which collides with `_main/index.tsx` - * and fails the generator's uniqueness check by name. - * - * So `_admin` needs one file-based child with a real path in order to exist, and - * this is it - which is the same job it has always had. It was the shell's first - * child for exactly this reason, and now it is the only one. - * - * Deleting it does not remove a screen; it removes the AdminCP. - * - * ## No `head`, deliberately - * - * The Next.js page exports no `generateMetadata`, so the tab keeps the site's - * own name. Declaring `title: 'VitNode'` here would render "VitNode - VitNode" - * through `formatPageTitle`; saying nothing inherits the root's title and - * `_admin`'s `noindex`, which is the parity-preserving answer. - * - * ## `pluginWidgets` is not passed - * - * A plugin declares widgets in its `admin.dashboard.widgets`, which reaches a - * Next.js board through `getVitNodeConfig()`. This route does not read the - * config - the AdminCP takes its plugin data from the generated projections, and - * neither of those carries widgets - so the board shows core's own, which is the - * complete set for this install because no configured plugin declares any. It is - * the same seam `AdminShell` leaves open for nav `declarations`. - */ + export const Route = createFileRoute("/_admin/admin/core/")({ loader: async ({ context }) => await loadAdminDashboardRoute(context), component: AdminDashboardRoute, - /** - * The whole of how an admin route contributes to the trail in the shell's - * header: the route declares its own crumb next to its own component, the - * shell renders whichever matched route declared the deepest one, and there is - * no map from pathname to breadcrumb anywhere. The label comes from the - * *visible* navigation, so this reads "Core" in whatever language the - * administrator is using without this file naming a string. - */ + staticData: { breadcrumb: <AdminBreadcrumb segments={["core"]} />, }, diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_main.tsx b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_main.tsx index cd7166ecd..89d413ffd 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_main.tsx +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_main.tsx @@ -7,56 +7,7 @@ import { import { MainHeader } from "#/components/main-header"; -/** - * The main application shell - the header, the breadcrumb area and the one - * `<main>` landmark that every public page renders inside. - * - * Pathless, so it contributes no URL segment: `/discover` is `/discover`, not - * `/_main/discover`. A page joins the shell by *where its file lives*, which is - * the same rule `_authenticated` uses for the session guard - and the reason - * `_authenticated` lives underneath this one: a signed-in page is still a page - * on the public site, so it wants the shell *and* the guard rather than a second - * copy of the shell. - * - * ## What is inside it, and what is deliberately outside - * - * Everything a visitor can reach without the AdminCP, the four public auth - * screens included: `/login`, `/register`, `/login/reset-password` and - * `/login/sso/$providerId` are children of this shell, mounted by - * `withCoreMainRoutes`. An auth card is a page on the public site - its own - * layout already reserves the space the header takes, and the header is the way - * back to the front page from a form the visitor changed their mind about. - * - * So is the 404. A URL no route matched is answered by core's `/$` inside this - * container rather than by `__root`'s `notFoundComponent`, because router core - * hands back the root route alone when nothing matches - a pathless layout the - * URL never reached is not a candidate for the boundary, whatever `notFoundMode` - * says, so a screen mounted here could never have seen one. - * - * `/admin` - the AdminCP's own sign-in - is outside, and must be: it reads a - * different session under a different cookie, so offering the site header's - * "sign in" beside it would be one page asking for two unrelated logins. - * `routes/api/$` is outside for a different reason again: it is a server route - * and renders no document at all. - * - * ## The slots - * - * `ThemeLayoutContent`'s, and two of the same three the Next.js `ThemeLayout` - * fills: `header` and `breadcrumb`. - * - * `listeners` is deliberately left empty here. The notification toasts and the - * WebSocket's sign-in resync are mounted by `__root` instead, next to the - * connection whose lifetime they share - so a sign-out that lands the visitor - * outside this shell is still noticed, which a listener scoped to the shell - * could not manage. - * - * ## What it is not - * - * A provider. Every technical provider this app has - the QueryClient, the two - * intl records, the theme, the WebSocket - is mounted once by `__root`, above - * every route, because a login screen needs them just as much as a page under - * this shell does. What lives here is structure: markup, and where the slots go. - */ + export const Route = createFileRoute("/_main")({ loader: async ({ context }) => await loadMainShell(context), component: MainLayout, diff --git a/packages/create-vitnode-app/src/create/create-package-json.ts b/packages/create-vitnode-app/src/create/create-package-json.ts index 57a0b09b9..927706834 100644 --- a/packages/create-vitnode-app/src/create/create-package-json.ts +++ b/packages/create-vitnode-app/src/create/create-package-json.ts @@ -161,24 +161,6 @@ export const singleAppScripts = ( "drizzle-kit": "drizzle-kit", }); -/** - * The web app of a split deployment, which owns **no** database. - * - * Deliberately no `db:prepare`, no `db:migrate` and no `drizzle-kit`: this app - * talks to a separate API over HTTP and has no schema, no migrations directory - * and no database credentials. Its `dev` is the Vite server and nothing else. - * - * Its predecessor was `vitnode init --web && next dev`, and `--web` printed - * "nothing to initialise" - a flag whose only meaning was to do nothing. The - * honest replacement is for the script not to call the bootstrap at all, which - * is also what keeps schema lifecycle out of the frontend: a root `turbo - * db:prepare` resolves to the API package, never to this one. - * - * Its own generated artefacts - the plugin route registry, - * the AdminCP navigation and content projections - are written by the Vite - * plugin on every `vite dev` and `vite build`, so there is nothing to prepare - * here either. - */ export const webScripts = (eslint: boolean) => ({ dev: "vite dev --port 3000", build: "vite build", @@ -241,19 +223,6 @@ const apiDevDeps = (pm: string, eslint: boolean) => ({ typescript: versionsPackageJson.typescript, }); -/** - * The TanStack Start stack every generated web app needs at runtime. - * - * Split out because both web shapes want it: the single app, which serves the - * site and mounts the API in one process, and the `web` app of an - * `apiMonorepo`, which talks to a separate API. - * - * Every entry is either a peer `@vitnode/core` declares - so npm would warn - * about it, and the app could not render a VitNode view without it - or - * something the generated `vite.config.ts` names by hand. `tslib` is the second - * kind and looks the most out of place: it is externalised rather than bundled, - * which only works if the app really depends on it. - */ const tanstackWebDeps = { "@tailwindcss/vite": versionsPackageJson.tailwindVite, "@tanstack/react-query": versionsPackageJson.tanstackReactQuery, @@ -286,17 +255,6 @@ const singleAppDeps = { shadcn: versionsPackageJson.shadcn, }; -/** - * The build-time half, shared by both web shapes. - * - * `vite` and `@vitejs/plugin-react` are the build; the three devtools packages - * are what `devtools()` in the generated `vite.config.ts` mounts, and are dev - * dependencies because none of them ships in the production bundle. - * - * No route generator CLI. `tanstackStart()` runs the generator itself, and a - * second one writing the same `routeTree.gen.ts` is an infinite reload loop - * rather than a faster build. - */ const tanstackWebDevDeps = { "@tanstack/devtools-vite": versionsPackageJson.tanstackDevtoolsVite, "@tanstack/react-devtools": versionsPackageJson.tanstackReactDevtools, diff --git a/packages/create-vitnode-app/src/create/database-bootstrap-scripts.test.ts b/packages/create-vitnode-app/src/create/database-bootstrap-scripts.test.ts index 755eccd7c..65e9e502d 100644 --- a/packages/create-vitnode-app/src/create/database-bootstrap-scripts.test.ts +++ b/packages/create-vitnode-app/src/create/database-bootstrap-scripts.test.ts @@ -9,43 +9,6 @@ import { webScripts, } from "./create-package-json.js"; -/** - * A generated project migrates its own database on `dev`, and only the package - * that owns a schema does it. - * - * Static and pure: the four script builders are called as the functions they - * are, and the committed `turbo.json` template is read off disk. Nothing here - * spawns a package manager, starts a dev server or touches Postgres. - * - * ## The regression - * - * Before Stage 17 the shapes that own a database had a bootstrap in their `dev` - * script: - * - * api vitnode init --api && tsx watch src/index.ts - * single app vitnode init && next dev - * web-only vitnode init --web && next dev (a no-op flag) - * - * `vitnode init` prepared the database *and* copied every installed plugin's - * pages into the host's `src/app/[locale]/…` for Next.js to find. Stage 17 - * deleted the copier and deleted `init` with it - and the single app lost its - * database bootstrap in the process, because the reasoning at the time only - * examined the plugin half. `apiScripts` kept its gate by luck: the line was - * left alone because it did not mention `next`. - * - * So a developer who cloned a generated project, installed and started a fresh - * Postgres got Vite serving pages against an empty database, and the first - * symptom was an arbitrary SQL error from a route rather than anything about - * migrations. - * - * ## What is asserted - * - * Not "the string contains `db:prepare`" - that would pass for - * `vite dev & vitnode db:prepare`, which is the race this is supposed to - * prevent. The bootstrap has to come *before* the runtime in the command, joined - * by `&&` so a non-zero exit stops the chain. Both are checked by position. - */ - const templateRoot = resolve( import.meta.dirname, "../..", @@ -59,13 +22,6 @@ const BOOTSTRAP = /** Anything that serves a request. */ const RUNTIME = /\b(?:vite dev|tsx watch|bun run --hot|turbo dev|next dev)\b/; -/** - * Whether a `dev` script prepares the database before starting anything. - * - * The `&&` is load bearing and so is the order: `A && B` runs B only if A - * exited zero, and nothing in a generated project may reach a schema that - * failed to migrate. - */ const gatesOnBootstrap = (dev: string): boolean => { const match = BOOTSTRAP.exec(dev.trim()); @@ -78,12 +34,6 @@ const gatesOnBootstrap = (dev: string): boolean => { ); }; -/** - * Every shape the generator supports whose `dev` must gate on the bootstrap. - * - * The split-deployment web app is deliberately absent - it mounts no API and - * owns no schema. Its own describe block below pins that it stays that way. - */ const shapes = [ { dev: () => singleAppScripts(true, true, "app").dev, @@ -133,11 +83,6 @@ describe("a generated project prepares its database before it starts", () => { ); }); - /** - * Never concurrently. `&` in a shell backgrounds the left side, which is - * exactly the race this exists to prevent: migrations running while the first - * requests are already being served against the old schema. - */ it.each(shapes.map(shape => [shape.label, shape] as const))( "%s does not background the bootstrap", (_label, shape) => { @@ -168,16 +113,6 @@ describe("a generated project prepares its database before it starts", () => { }); describe("every schema-owning app gates itself, root or not", () => { - /** - * The gap this replaced: gating only at the root left `cd apps/web && pnpm dev` - * and `turbo dev --filter=web` starting a runtime against an unmigrated - * schema, because neither goes through the root script. An app that reads a - * schema is responsible for having one. - * - * Gating twice on the common path is safe *because* of the advisory lock in - * `withMigrationLock` - measured: without it, two concurrent gates race on - * `CREATE SCHEMA IF NOT EXISTS drizzle` and one exits non-zero. - */ it("gates the api inside a monorepo too", () => { expect(apiScripts("pnpm", true, true, false, "app").dev).toBe( "vitnode db:prepare && tsx watch src/index.ts", @@ -199,12 +134,6 @@ describe("every schema-owning app gates itself, root or not", () => { describe("the web app of a split deployment owns no database", () => { const web = webScripts(true); - /** - * The rule this file exists to keep on the right side of the boundary. A - * TanStack frontend that talks to a separate API over HTTP has no schema, no - * migrations directory and no database credentials - so it must not migrate, - * and the way to guarantee that is for it to have no script that could. - */ it("has no database script at all", () => { for (const key of ["db:prepare", "db:migrate", "drizzle-kit", "init"]) { expect(Object.keys(web)).not.toContain(key); @@ -217,12 +146,6 @@ describe("the web app of a split deployment owns no database", () => { expect(web.dev).not.toContain("migrate"); }); - /** - * Its own generated artefacts - the plugin route manifest, the module - * registry, the AdminCP navigation and content projections - are written by - * the Vite plugin on every `vite dev`, so there is nothing for a pre-step to - * prepare here either. - */ it("needs no preparation step of its own", () => { expect(web.dev).not.toContain("vitnode"); }); diff --git a/packages/create-vitnode-app/src/create/no-plugin-page-duplication.test.ts b/packages/create-vitnode-app/src/create/no-plugin-page-duplication.test.ts index 41a9c8010..0183dce9c 100644 --- a/packages/create-vitnode-app/src/create/no-plugin-page-duplication.test.ts +++ b/packages/create-vitnode-app/src/create/no-plugin-page-duplication.test.ts @@ -7,43 +7,6 @@ import { routeSlugFor, } from "../plugin/create/route-templates.js"; -/** - * A generated project demonstrates the permanent architecture, not the - * migration-era one. - * - * Static and pure: the committed template tree is read off disk and the two - * generators are called as the functions they are. Nothing here spawns the CLI, - * installs a package or runs a build. - * - * ## What the two halves of the scaffold may contain - * - * create-vitnode-app create-vitnode-app --plugin - * ────────────────────── ────────────────────────── - * apps/web/src/routes/** plugins/<name>/src/routes.ts - * the application's own pages the routes this plugin contributes - * apps/web/src/router.tsx plugins/<name>/src/pages/*.tsx - * withPluginRoutes(...) the pages themselves - * apps/web/src/vitnode.config.ts plugins/<name>/src/config.tsx - * plugins: [] - * - * The line between them is the whole subject of this file. A plugin author writes - * a route module and names it in their route tree; the app's Vite build compiles - * that into two generated registries and `withPluginRoutes` mounts them. There is - * no third step in which the page becomes a file in the application, and a - * scaffold that produced one would be teaching the deleted architecture to every - * project created from it. - * - * ## Why the scaffold in particular - * - * Nothing in this repository imports the template tree, so no type error and no - * failing build says a word about what is in it. `scaffold-invariants.test.ts` - * beside this one pins the *framework* claims - no App Router topology, no Next - * config, no `next` import. This one pins the *ownership* claim, which is the one - * that survived the framework change: a host physical page for a plugin's URL is - * the same mistake in TanStack that it was in Next.js, and a generator is where - * it would be reintroduced at scale. - */ - const packageRoot = resolve(import.meta.dirname, "../.."); const appTemplate = join(packageRoot, "copy-of-vitnode-app"); const appRoutesDir = join(appTemplate, "root", "src", "routes"); @@ -68,15 +31,6 @@ const filesUnder = (directory: string): string[] => { const appRouteFiles = filesUnder(appRoutesDir); -/** - * Every token a route file's path contributes, which is where a file-based - * router keeps a URL. - * - * `_main/settings/devices.tsx` is `["_main", "settings", "devices"]`. Splitting - * on both separators is what makes the check indifferent to which spelling a - * route file uses - `_main/example.tsx` and `_main.example.tsx` are the same URL - * and produce the same token. - */ const routeTokens = new Set( appRouteFiles.flatMap(file => file @@ -109,15 +63,6 @@ describe("the scaffolded plugin", () => { expect(Object.keys(scaffold)).toContain("src/pages/home-page.tsx"); }); - /** - * And writes nothing outside itself. - * - * Every path the scaffold names is relative to the plugin, so there is no - * key here that *could* land in an application even if the writer were - * pointed at a repository root - which is the property worth having, because - * the writer resolves them against `pluginPath` and a path escaping that is - * how a generator quietly acquires the ability to touch `apps/`. - */ it("writes only files inside the plugin package", () => { for (const file of Object.keys(scaffold)) { expect(file).toMatch(/^src\//); @@ -126,11 +71,6 @@ describe("the scaffolded plugin", () => { } }); - /** - * The four directory names a plugin's pages used to be copied out of. The - * scaffold offers none of them, so a new plugin cannot start out shaped like - * something that wants copying. - */ it.each(["admin", "blank", "breadcrumb", "main"])( "scaffolds no routes/%s directory", legacy => { @@ -228,13 +168,6 @@ describe("the generated application", () => { expect(routeTokens.size).toBeGreaterThan(4); }); - /** - * D. The starter's own pages are still there. - * - * Listed as files rather than as URLs because this template is read without a - * router: what matters is that the check below cannot be satisfied by deleting - * the routes directory, and a named-file assertion says that most directly. - */ it.each(["__root.tsx", "_main.tsx", "_main/index.tsx", "_admin.tsx"])( "owns %s itself", file => { @@ -242,17 +175,6 @@ describe("the generated application", () => { }, ); - /** - * And the screens `@vitnode/core` owns are **not** among them. - * - * `/discover`, `/search`, `/files` and the settings subtree were route files in - * every generated project - nine of them, each one wiring around something - * imported from the package - so a new project carried a copy of VitNode's own - * routing table and core could not add a screen without an edit in every app. - * They are mounted by `withCoreMainRoutes` now, so a *file* for one of them - * here would be the duplication this whole file exists to forbid, one package - * up from a plugin. - */ it.each([ "_main/discover.tsx", "_main/search.tsx", @@ -281,30 +203,11 @@ describe("the generated application", () => { }, ); - /** - * C. No host physical page for the plugin a new project is most likely to - * create. - * - * A file-based router keeps the URL in the name, so a host route answering - * `/my-vitnode-plugin` has to carry that token somewhere in its path - as a - * directory, or as a dot-separated filename segment. Absence of the token is - * therefore sufficient to say no such file exists, without this test having to - * reimplement a route-path reader it would then have to keep in step with one. - */ it("ships no route file for the default scaffolded plugin's URL", () => { expect(routeTokens.has(routeSlugFor(DEFAULT_PLUGIN_NAME))).toBe(false); expect(routeTokens.has("example")).toBe(false); }); - /** - * And it cannot contain a duplicate at all, because it installs no plugin. - * - * The strongest form of the claim about *generated output*: `plugins: []` and - * no committed registry means a fresh project has zero plugin routes, so there - * is nothing for a host page to be a second copy of. The wiring is present and - * empty rather than absent - which is the difference between a project that is - * ready for a plugin and one that would need this file rewritten to accept one. - */ it("configures no plugin and commits no generated registry", () => { expect(withoutComments(readTemplate("root/src/vitnode.config.ts"))).toMatch( /plugins:\s*\[\s*\]/, @@ -316,15 +219,6 @@ describe("the generated application", () => { ).toEqual([]); }); - /** - * The positive half, and the reason none of the above is a limitation: the - * starter already mounts whatever a plugin declares. - * - * A plugin author adds their package to `plugins` and their route to their own - * `routes.ts`, and the page is served. No file in `src/routes` is created, - * edited or copied - which is exactly what the authoring guide promises, said - * here as a property of the bytes a new project starts from. - */ it("mounts plugin routes from the generated registry", () => { const router = withoutComments(readTemplate("root/src/router.tsx")); @@ -338,38 +232,12 @@ describe("the generated application", () => { expect(router).toMatch(/mountUnder:\s*\{[^}]*\bmain:/); }); - /** - * E. The AdminCP half, stated on its own - and it is now an *emptiness*. - * - * A generated project used to ship seventeen `_admin` route files, every one - * of them wiring around something imported from `@vitnode/core`: the staff, - * users, system and advanced sections, plus the Content Engine splat. They - * were the same duplication a copied plugin page is, one package up - a new - * project carrying a copy of VitNode's own routing table, which core could not - * add a screen to without an edit in every app. - * - * They are core's code-based routes now, mounted by `withCoreAdminRoutes`, and - * exactly one file is left. It is not a screen and it is not optional: a - * pathless layout with no *file* children is dropped from the generated route - * tree and collapses to `/`, so `_admin.tsx` needs one file-based child with a - * real path in order to exist at all. The dashboard is that anchor. - * - * A plugin's AdminCP page is still not among them and still must not be: - * `area: "admin"` picks the shell, and the shell is composed around a route - * the plugin registry provides. - */ it("ships one AdminCP route file, the shell's anchor, and no screen beside it", () => { expect(appRouteFiles.filter(file => file.startsWith("_admin/"))).toEqual([ "_admin/admin.core.index.tsx", ]); }); - /** - * And the shell itself stays, because it is genuinely the application's: the - * admin session guard, and `AdminShell` - this app's binding of the sidebar, - * the command palette and the one `<main>`, which is where a package cannot - * answer for an installation. - */ it("still ships the AdminCP shell and its mount", () => { expect(appRouteFiles).toContain("_admin.tsx"); expect(withoutComments(readTemplate("root/src/router.tsx"))).toContain( @@ -377,14 +245,6 @@ describe("the generated application", () => { ); }); - /** - * A proxy is still a copy. - * - * A route file re-exporting a plugin's page would claim no duplicate URL - it - * *is* the URL - and would still be a host file that has to be written and - * kept in step for a plugin's page to exist. No route file in a starter has - * any reason to import from outside the app and core. - */ it("imports no package other than core and the router from a route file", () => { const offenders = appRouteFiles .filter(file => /\.[cm]?[jt]sx?$/.test(file)) diff --git a/packages/create-vitnode-app/src/create/package-versions.ts b/packages/create-vitnode-app/src/create/package-versions.ts index b35a29f1e..0ef14adc2 100644 --- a/packages/create-vitnode-app/src/create/package-versions.ts +++ b/packages/create-vitnode-app/src/create/package-versions.ts @@ -54,14 +54,6 @@ export const versionsPackageJson = { tailwindVite: "^4.2", nitro: "3.0.260610-beta", - /** - * Externalised in the app's `vite.config.ts` rather than bundled, so it has to - * be a real dependency of the app. - * - * Nitro re-resolves externals itself and traces this one into the output. See - * the `ssr.external` note in the generated `vite.config.ts` for why bundling - * it breaks every page that renders a dialog. - */ tslib: "^2.8.1", swcCli: "^0.8.1", diff --git a/packages/create-vitnode-app/src/create/scaffold-invariants.test.ts b/packages/create-vitnode-app/src/create/scaffold-invariants.test.ts index d8bddc7d0..41a4717ce 100644 --- a/packages/create-vitnode-app/src/create/scaffold-invariants.test.ts +++ b/packages/create-vitnode-app/src/create/scaffold-invariants.test.ts @@ -2,22 +2,6 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative, resolve } from "node:path"; import { describe, expect, it } from "vitest"; -/** - * What a generated project may not contain. - * - * Static and pure: the committed template tree is read off disk and the - * package.json builders are called as the functions they are. Nothing here - * spawns the CLI, installs anything or runs a build - the claim being pinned is - * that the *bytes a new project starts from* describe VitNode's permanent - * architecture, and that is a file listing and a string comparison. - * - * It exists because the scaffold is the one place a deleted architecture can - * come back to life. Nothing in this repository imports the template tree, so no - * type error and no failing build says a word about it: a `next.config.ts` or an - * `@breadcrumb` directory sitting in `copy-of-vitnode-app/` is invisible until - * somebody runs `create-vitnode-app` and is handed an application that cannot - * start. - */ const packageRoot = resolve(import.meta.dirname, "../.."); const appTemplate = join(packageRoot, "copy-of-vitnode-app"); const pluginTemplate = join(packageRoot, "copy-of-vitnode-plugin"); @@ -49,26 +33,10 @@ const allFiles = [...appFiles, ...pluginFiles]; const read = (root: string, file: string): string => readFileSync(join(root, file), "utf8"); -/** - * Source with its comments removed. - * - * Prose is allowed to name what was deleted - a comment saying why - * `prepare-plugins` no longer exists is the most useful thing a reader of this - * package can find. What must not survive is a *call*, so the check is made - * against code alone. - */ const withoutComments = (source: string): string => source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, ""); describe("the generated application", () => { - /** - * The four directory names the route copier claimed, and the parallel-route - * slot it wrote into. - * - * Asserted against the whole template tree rather than against one expected - * location, because the failure this guards against is a *reappearance* and a - * reappearance picks its own path. - */ it("ships no Next.js App Router topology", () => { expect(allFiles.filter(file => file.includes("[locale]"))).toEqual([]); expect(allFiles.filter(file => file.includes("@breadcrumb"))).toEqual([]); @@ -87,15 +55,6 @@ describe("the generated application", () => { expect(allFiles.filter(file => file.endsWith("next-env.d.ts"))).toEqual([]); }); - /** - * Not a grep for the word "next" - prose may mention the framework this - * architecture replaced. What must not exist is an *import* or a module - * augmentation, which is what actually makes a generated project depend on it. - * - * `next-intl` is the one that hides: `declare module "next-intl"` in a - * `global.d.ts` is a type-level dependency that survives any search for - * `import ... from`. - */ it("imports nothing from next or next-intl", () => { const offenders = [ ...appFiles.map(file => [appTemplate, file] as const), @@ -120,14 +79,6 @@ describe("the generated application", () => { expect(appFiles).toContain("root/src/routes/__root.tsx"); }); - /** - * The single-app shape mounts the Hono API as a TanStack server route. - * - * It was a Next Route Handler at `src/app/api/[...route]/route.ts`, which the - * first assertion in this file would already have caught - this one says what - * has to be there *instead*, so a deletion that removed the mount entirely - * fails too. - */ it("mounts the API through a server route, not a Route Handler", () => { expect(appFiles).toContain("api-single-app/src/routes/api/$.ts"); expect(read(appTemplate, "api-single-app/src/routes/api/$.ts")).toContain( @@ -137,28 +88,6 @@ describe("the generated application", () => { }); describe("the single-app template's two trees", () => { - /** - * `root` owns every generic host file; `api-single-app` is an overlay of - * API-specific additions and nothing else. - * - * ## The regression - * - * Both trees are copied into the *same* directory, and they were copied with - * one `Promise.all` - so any path they shared was a race, decided by whichever - * `cp` happened to finish last. They shared two, and both of the overlay's - * copies were the pre-TanStack ones: - * - * .gitignore_template ignored `/.next/` and `next-env.d.ts`; had no - * `.output`, `.nitro`, `.vite` or `src/*.gen.ts` - * .env.example no `NEXT_PUBLIC_API_URL`, no `CRON_SECRET` - * - * So a new project got a `.gitignore` for a framework it does not use, missing - * every output directory it actually writes - about half the time. Nothing - * fails; the first symptom is `.output/` showing up in `git status`. - * - * Both duplicates are deleted rather than corrected, because a corrected - * duplicate is still two files that have to agree. - */ const overlayFiles = filesUnder(join(appTemplate, "api-single-app")); const rootFiles = filesUnder(join(appTemplate, "root")); @@ -166,11 +95,6 @@ describe("the single-app template's two trees", () => { expect(overlayFiles.filter(file => rootFiles.includes(file))).toEqual([]); }); - /** - * And the overlay is only the API. Listed as a property rather than as an - * expected file list, so adding a genuinely API-specific file needs no edit - * here - `drizzle.config.ts` and anything under `src/` that names the API. - */ it("keeps only API-specific files in the overlay", () => { expect(overlayFiles.length).toBeGreaterThan(0); for (const file of overlayFiles) { @@ -189,11 +113,6 @@ describe("the single-app template's two trees", () => { }, ); - /** - * Copied in a fixed order regardless, because "the overlay goes over the base" - * is the contract an author adding a file to either tree relies on - and - * `Promise.all` into one destination cannot express an order at all. - */ it("is copied base-then-overlay, sequentially", () => { const code = withoutComments( read(join(packageRoot, "src"), "create/create-vitnode.ts"), @@ -238,38 +157,16 @@ describe("what a generated single app starts from", () => { }, ); - /** - * The environment a single app is configured with. `NEXT_PUBLIC_WEB_URL` - * names this app's own origin - which is exactly what the overlay's copy - * dropped. - */ it("ships the single-app environment", () => { expect(env).toContain("POSTGRES_URL="); expect(env).toContain("NEXT_PUBLIC_WEB_URL=http://localhost:3000"); expect(env).toContain("CRON_SECRET="); }); - /** - * And it names no API server, because it *is* the API server. - * - * A set `NEXT_PUBLIC_API_URL` wins over the origin the request arrived on - - * that is what makes the split scaffold work at all. So a single app that - * shipped `NEXT_PUBLIC_API_URL=http://localhost:3000` in its `.env.example` - * hands every deployment copied from it a server that calls the visitor's own - * machine. Unset, the request origin answers, and a preview deployment on a - * generated hostname needs no configuration. - */ it("names no API server for an app that serves its own", () => { expect(env).not.toMatch(/^NEXT_PUBLIC_API_URL=/m); }); - /** - * The split shape is the mirror image: two processes, so the web app cannot - * find `/api/*` on its own origin and the variable is mandatory rather than - * optional. `apps/api` listens on 8000 (`apps/api/src/index.ts`), so that is - * the port the web app has to be told about - getting this wrong answers the - * first server-side session read with a 404 of this app's own HTML. - */ it("points a split web app at the API's own port", () => { expect(read(appTemplate, "monorepo/apps/web/.env.example")).toContain( "NEXT_PUBLIC_API_URL=http://localhost:8000", @@ -277,18 +174,6 @@ describe("what a generated single app starts from", () => { expect(read(appTemplate, "api-bun/src/index.ts")).toContain("port: 8000"); }); - /** - * One locale declaration, in the shared config, read by both configs. - * - * `vitnode db:prepare` seeds `core_languages` from the *API* config, and a - * single app owns its schema - so a language added to `src/vitnode.config.ts` - * has to reach the seed without a second edit. It does, because there is only - * one list. - * - * The standalone `src/i18n.ts` this replaced must not come back: two files - * that agree until they don't is exactly the failure the single declaration - * exists to prevent. - */ it("declares its languages once and reads them from both configs", () => { expect(allFiles.filter(file => /(^|\/)src\/i18n\.ts$/.test(file))).toEqual( [], @@ -321,18 +206,6 @@ describe("what a generated single app starts from", () => { expect(api).toMatch(/locales:\s*\[/); }); - /** - * The app's *own* message loaders are registered through the server config. - * - * `src/locales/app.ts` and `src/locales/packages.ts` are the two modules an app - * edits to add a translation, and both are `() => import(...)` of JSON. They - * reach the message loader through `vitnode.server.config.ts`, which carries - * the `server-only` marker, rather than through the shared config that the - * document shell holds and that Vite executes with `jiti`. - * - * A plugin's own registration factory is a separate question and is not what - * this pins - see `root/src/vitnode.config.ts`. - */ it("registers the app's message loaders through the server config", () => { const shared = withoutComments( read(appTemplate, "root/src/vitnode.config.ts"), @@ -356,15 +229,6 @@ describe("what a generated single app starts from", () => { describe("what a generated application does to every request", () => { const start = withoutComments(read(appTemplate, "root/src/start.ts")); - /** - * `src/start.ts` is a composition root, not a pipeline. - * - * Start replaces its own default CSRF middleware the moment an app declares - * `requestMiddleware` of its own, so a scaffold that shipped a hand-written - * list was one deletion away from exposing every server function as an - * unauthenticated cross-site endpoint. `createVitNodeStart` owns the list and - * an app cannot omit or reorder what is in it. - */ it("builds its Start instance through the Core factory", () => { expect(start).toContain( 'import { createVitNodeStart } from "@vitnode/core/tanstack/start"', diff --git a/packages/create-vitnode-app/src/helpers/init-vitnode.ts b/packages/create-vitnode-app/src/helpers/init-vitnode.ts index b8c9cbf26..78c8c7e54 100644 --- a/packages/create-vitnode-app/src/helpers/init-vitnode.ts +++ b/packages/create-vitnode-app/src/helpers/init-vitnode.ts @@ -3,46 +3,6 @@ import color from "picocolors"; import type { CreateCliReturn } from "../questions.js"; -/** - * `vitnode migrate --generate` in a freshly created project. - * - * The only VitNode command a new project still needs run for it, and it is a - * *convenience* rather than the contract. There was a second one - - * `initFilesVitnode`, which ran `vitnode prepare-plugins` in every generated app - * - and it existed for the route copier: each installed plugin's - * `src/routes/{main,admin,blank,breadcrumb}/` had to be copied into the app's - * `src/app/[locale]/…` before Next.js could see a plugin's pages at all, so a - * project that skipped it started with its plugins half-installed. - * - * Nothing is copied now. A plugin's routes are compiled into the generated - * registry by the app's own Vite build, from the plugin's `dist`, on every `dev` - * and every `build` - so there is no step to run first and no state on disk that - * can be stale. - * - * ## Why this is not what makes a database work - * - * It generates migrations; it does not apply them, and it only ever runs on the - * machine that ran `create-vitnode-app`. A colleague who clones the repository, - * installs and starts a fresh Postgres never executes this function at all - so - * the thing that has to prepare a database is the generated `dev` script, which - * runs `vitnode db:prepare` before any runtime starts. See - * `create/create-package-json.ts`. - * - * ## Why it is awaited - * - * It was fire-and-forget: a bare `spawn` with no `await`, no exit-code check and - * no error handler, called without `await` from `create-vitnode.ts` immediately - * before `spinner.succeed("Success! Created …")`. Three consequences, all of - * them silent. The success message printed while `drizzle-kit` was still - * running. A non-zero exit was never noticed, so a project whose migrations - * failed to generate was reported as created. And the CLI could exit with the - * child still alive, leaving a detached `drizzle-kit` writing into a directory - * the user had already been told was finished. - * - * Resolving on a zero exit and rejecting otherwise is the whole fix. `shell: - * true` for the same reason `installDependencies` uses it: a package manager on - * Windows is a batch file, which `spawn` cannot execute directly. - */ export const generateMigrationsVitnode = async ({ packageManager: pm, cwd, diff --git a/packages/create-vitnode-app/src/helpers/packages-json.ts b/packages/create-vitnode-app/src/helpers/packages-json.ts index 756d8bd3c..2e2e49855 100644 --- a/packages/create-vitnode-app/src/helpers/packages-json.ts +++ b/packages/create-vitnode-app/src/helpers/packages-json.ts @@ -1,14 +1,7 @@ export interface PackageJSON { dependencies?: Record<string, string>; devDependencies?: Record<string, string>; - /** - * A subpath value is either a conditions object or a plain target. - * - * Both, because a VitNode plugin needs both: `"./*"` maps to build output - * under `import`/`types`/`default`, and `"./locales/*.json"` maps straight to - * a file, since a wildcard that appends `.js` cannot answer a `.json` - * subpath. Typed as only the first, a plugin's messages had nowhere to go. - */ + exports?: Record<string, Record<string, string> | string>; name: string; overrides?: Record<string, string>; diff --git a/packages/create-vitnode-app/src/plugin/create/add-plugin-to-workspace.ts b/packages/create-vitnode-app/src/plugin/create/add-plugin-to-workspace.ts index 015ac48e3..2ab269fdc 100644 --- a/packages/create-vitnode-app/src/plugin/create/add-plugin-to-workspace.ts +++ b/packages/create-vitnode-app/src/plugin/create/add-plugin-to-workspace.ts @@ -44,22 +44,6 @@ const findPackageJsonFiles = async ( return results; }; -/** - * Adds the newly created plugin to all workspace packages that depend on @vitnode/core. - * This function: - * 1. Finds all package.json files in the workspace (excluding node_modules and dist) - * 2. Identifies packages that have @vitnode/core as a dependency - * 3. Adds the new plugin as a dependency with the appropriate workspace reference - * 4. Skips other packages in the same parent directory as the plugin (e.g., other plugins) - * 5. Works with any folder structure (apps/, applications/, sandbox/, etc.) - * 6. Respects the package manager's workspace protocol - * - * @param packageManager - The package manager being used (pnpm, npm, yarn, bun) - * @param pluginName - The name of the plugin to add (e.g., "@my-org/my-plugin") - * @param pluginPath - The absolute path to the plugin directory - * @param rootPath - The absolute path to the monorepo root (where turbo.json is located) - */ - export const addPluginToWorkspace = async ({ packageManager, pluginName, diff --git a/packages/create-vitnode-app/src/plugin/create/create-package-json.ts b/packages/create-vitnode-app/src/plugin/create/create-package-json.ts index 691b92f07..c4e2ca2ea 100644 --- a/packages/create-vitnode-app/src/plugin/create/create-package-json.ts +++ b/packages/create-vitnode-app/src/plugin/create/create-package-json.ts @@ -21,19 +21,6 @@ export const createPluginPackageJSON = async ({ }) => { const vitnodeVersionRange = await getVitnodePackageVersion(); - /** - * No `next` and no `next-intl`. - * - * A plugin is compiled to its own `dist` and imported by whichever app - * installed it, so a dependency on one host's framework is a dependency every - * app that installs the plugin inherits - and, worse, one the plugin's route - * module can reach for without anything failing until somebody runs it - * somewhere else. `route-templates.ts` tells the author exactly this in the - * scaffolded page's own comment; the dependency list has to agree with it. - * - * `use-intl` is what a plugin renders strings through, and it is the same - * library VitNode itself uses, on every host. - */ const pluginPkg: PackageJSON = { name: pluginName, version: "0.1.0", diff --git a/packages/create-vitnode-app/src/plugin/create/create-plugin-vitnode.ts b/packages/create-vitnode-app/src/plugin/create/create-plugin-vitnode.ts index a2f13471c..2b77fa53b 100644 --- a/packages/create-vitnode-app/src/plugin/create/create-plugin-vitnode.ts +++ b/packages/create-vitnode-app/src/plugin/create/create-plugin-vitnode.ts @@ -14,22 +14,6 @@ import { addPluginToWorkspace } from "./add-plugin-to-workspace.js"; import { createPluginPackageJSON } from "./create-package-json.js"; import { pluginRouteScaffold } from "./route-templates.js"; -/** - * The plugin's own source: one public page, its strings, and the config that - * registers both. - * - * Written rather than copied, because every one of these files names the plugin - * - the route tree names the URL it claims, the page names the message namespace - * it renders, the config names the id both are keyed by - and a static template - * under `copy-of-vitnode-plugin/` cannot. What each file contains is - * `route-templates.ts`, which is pure and asserted byte for byte; this is only - * the part that has a disk. - * - * Nothing here touches the application. A new plugin is registered by adding it - * to an app's `src/vitnode.config.ts`, and its page reaches the browser from its - * own `dist` - so there is no generated file to edit, no route to copy and - * nothing in `apps/*` for a plugin author to know about. - */ const writePluginRouteScaffold = async ({ pluginName, pluginPath, 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 e826afa47..0d6e94e49 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 @@ -11,16 +11,6 @@ import { routeSlugFor, } from "./route-templates.js"; -/** - * The scaffold's pure half. - * - * Nothing here spawns the CLI or writes a file: what is worth pinning is that - * the bytes a new plugin starts with are the ones VitNode's build can read, and - * that is a string comparison. The cross-file assertions matter more than the - * snapshots - a template that is merely *different* is a diff to approve, while - * a route tree whose `lazy()` names a file the scaffold does not write is a - * plugin whose first page 404s at the first navigation. - */ describe("routeSlugFor", () => { it("drops a scope, which a route path may not contain", () => { expect(routeSlugFor("@acme/blog")).toBe("blog"); @@ -70,12 +60,6 @@ describe("the generated route tree", () => { ); }); - /** - * The page is named by a *lazy* import, and that is the whole of what keeps it - * out of the initial bundle. A scaffold that imported the component at the top - * of this file would work, ship the page to every visitor of every other page, - * and teach the pattern to everybody who read it. - */ it("never imports the page it names", () => { const routes = pluginRoutesTemplate("blog"); const imports = [...routes.matchAll(/^import .*? from "([^"]+)";$/gm)].map( 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 a2e6695a9..ca04ff779 100644 --- a/packages/create-vitnode-app/src/plugin/create/route-templates.ts +++ b/packages/create-vitnode-app/src/plugin/create/route-templates.ts @@ -1,51 +1,8 @@ -/** - * The source a scaffolded plugin starts with, as pure functions of its name. - * - * Templates as strings rather than as files under `copy-of-vitnode-plugin/`, - * and the reason is that every one of them has to say the plugin's own name: the - * route tree names the URL it claims, a page names the message namespace it - * renders, and `config.tsx` names the plugin id all three are keyed by. A static - * file cannot, so the scaffold used to copy no `src/` at all - which left a new - * plugin with a `global.d.ts` importing `./src/locales/en.json` that did not - * exist, and nothing to point `vitnode dev` at. - * - * Pure, and separated from the writing, so what a plugin author is handed can be - * asserted byte for byte without a filesystem. `route-templates.test.ts` is that - * assertion; `create-plugin-vitnode.ts` is the half that has a disk. - * - * Everything here is the routing path and only the routing path. A scaffolded - * plugin gets one public page, the strings it renders and the config that - * registers both - not an API module, not a database table, not a content type. - * Those have their own guides, and a generator that produced all of them would - * produce mostly files to delete. - */ - -/** - * The plugin's public URL, and the id it is addressed by. - * - * A package name may be scoped and a route path may not be - `/` is a separator - * in one and a segment break in the other, and `@` is illegal in a VitNode route - * path outright. The scope is dropped rather than escaped: `@acme/blog` is - * `blog` on the web, which is what its author would have written anyway. - * - * Everything npm allows after the scope is already legal in a static VitNode - * segment - lowercase letters, digits, `.`, `_`, `-` - so nothing else has to be - * rewritten, and the name is not silently lowercased: npm rejects an uppercase - * package name before this is ever reached. - */ export const routeSlugFor = (pluginName: string): string => pluginName.includes("/") ? pluginName.slice(pluginName.indexOf("/") + 1) : pluginName; -/** - * `src/routes.ts` - the file an app reads to find out this plugin has a page. - * - * One `page()`, with the two fields that have no default. Everything else on a - * route - the area, the messages, the requirement, the search schema - is left - * out rather than written with its default value, so what a new plugin's tree - * shows is the minimum rather than a form to fill in. - */ export const pluginRoutesTemplate = (pluginName: string): string => { const slug = routeSlugFor(pluginName); @@ -87,30 +44,7 @@ export const routes = definePluginRoutes([ export const pluginRouteModuleTemplate = (pluginName: string): string => `import { useTranslations } from "use-intl"; -/** - * The page \`routes.ts\` declares. - * - * Keep it framework-neutral. This module is compiled into the package's own - * \`dist\` and imported by whichever app installed the plugin, so anything from - * a router or a host-bound i18n package pins the plugin to one kind of host. - * \`use-intl\` - which is what VitNode itself renders through - and plain JSX are - * pinned to neither. - * - * No \`<main>\`: the application shell owns the document's one \`main\` landmark, - * and a page that renders a second gives a screen reader two to choose between. - * A page owns its container - width, padding, vertical rhythm - and nothing - * above it. - * - * To give this route a loader, page metadata or a breadcrumb, add a \`route\` - * export beside the default one: - * - * import { definePluginRoute } from "@vitnode/core/routing"; - * - * export const route = definePluginRoute({ - * load: ({ context, params }) => fetchThing(context.locale, params.id), - * head: ({ loaderData }) => ({ title: loaderData?.title }), - * }); - */ + const HomePage = () => { const t = useTranslations("${pluginName}"); @@ -160,11 +94,7 @@ export const pluginMessagesTemplate = (pluginName: string): string => export const pluginMessagesBarrelTemplate = (): string => `import type { LocaleMessagesMap } from "@vitnode/core/lib/i18n/types"; -/** - * Every language this plugin ships. Add a file next to this one and a line here - * to add another; apps pick it up with no copy step, because they read this - * package's own \`dist\` rather than a copy of it. - */ + const messages: LocaleMessagesMap = { en: async () => await import("./en.json", { with: { type: "json" } }), }; @@ -214,17 +144,7 @@ export const pluginConfigTemplate = (pluginName: string): string => import messages from "./locales"; import { routes } from "./routes"; -/** - * This plugin, as an application registers it. - * - * \`pluginId\` is the package name, and that is not a convention - it is how this - * plugin's route tree is imported (\`${pluginName}/routes\`) and how its messages - * are namespaced. The two cannot drift because they are one string. - * - * Add this to an app's \`src/vitnode.config.ts\` \`plugins\` array. A plugin that - * is installed but not listed there contributes nothing - no directory is ever - * scanned - so this is the only switch. - */ + export const ${pluginVariableName(pluginName)} = () => buildPlugin({ pluginId: "${pluginName}", diff --git a/packages/vitnode/scripts/cli-arguments.test.ts b/packages/vitnode/scripts/cli-arguments.test.ts index dadfbbcf2..6799c86f5 100644 --- a/packages/vitnode/scripts/cli-arguments.test.ts +++ b/packages/vitnode/scripts/cli-arguments.test.ts @@ -11,29 +11,8 @@ import { parseCliArguments, } from "./cli-arguments.js"; -/** - * The CLI's argument contract, stated as assertions rather than as a spawned - * process. - * - * `parseCliArguments` is pure - it returns a refusal instead of printing and - * exiting - so every case here is a function call. Nothing in this file starts a - * compiler, opens a database or reads an environment variable, which is what - * makes it reasonable to enumerate the invalid invocations as thoroughly as the - * valid ones. The two static assertions at the bottom are the only part that - * looks at `scripts.ts`, and they exist to prove the parser is actually in front - * of the dispatch rather than beside it. - */ - const scriptsRoot = import.meta.dirname; -/** - * A script's code, with comments removed. - * - * Load bearing rather than tidy: `scripts.ts` explains the old entry point in - * prose, and that prose quotes `case "init":` - a command deleted before this - * branch. Matched as source, a comment describing what the CLI no longer does - * reads as a tenth command. - */ const codeOf = (file: string): string => readFileSync(join(scriptsRoot, file), "utf8") .replace(/\/\*[\s\S]*?\*\//g, "") @@ -56,13 +35,6 @@ const accepted = ( }; describe("the contract covers every command the CLI dispatches", () => { - /** - * The table and the `switch` have to describe the same CLI. Read off the - * source as text, because a `switch` is not something a running test can be - * asked what it handles - and a command in one and not the other is the - * failure this pairing exists to catch: a table entry with no `case` validates - * an invocation that then does nothing at all. - */ const entryPoint = codeOf("scripts.ts"); const dispatched = [...entryPoint.matchAll(/case "([^"]+)":/g)] .map(match => match[1]) diff --git a/packages/vitnode/scripts/cli-arguments.ts b/packages/vitnode/scripts/cli-arguments.ts index 908db147b..c7b417e5f 100644 --- a/packages/vitnode/scripts/cli-arguments.ts +++ b/packages/vitnode/scripts/cli-arguments.ts @@ -1,26 +1,3 @@ -/** - * What `vitnode <command>` accepts, and the pure check that enforces it. - * - * Separated from `scripts.ts` so the contract can be read and tested without - * running anything: no `dotenv`, no database, no compiler, no `process.exit`. - * The entry point calls {@link parseCliArguments} and exits on a refusal - * **before** it dispatches, which is the whole point - a typo has to fail rather - * than change what a command does. - * - * ## The failure this exists to stop - * - * The entry point read `process.argv[2]` as the command and `process.argv[3]` as - * "the flag", compared that one string to `"--generate"`, and ignored everything - * else. So `vitnode migrate --generat` did not fail: the comparison was false, - * and the command fell through to the *full* bootstrap - generate, apply, seed - - * against whatever `POSTGRES_URL` names. A mistyped flag silently became a - * different command. `vitnode i18n:check --cii` was the same shape with a - * quieter cost: `isCi` was false, so a CI job asking for a hard failure got a - * soft report and a zero exit. - * - * Both are now refusals, and neither reaches an implementation. - */ - /** Every command the CLI dispatches on. */ export type CliCommandName = | "build" @@ -34,52 +11,15 @@ export type CliCommandName = | "migrate"; export interface CliCommandArguments { - /** - * The flags this command accepts, exactly as they have to be spelled. - * - * Per command, never global: `--ci` is `i18n:check`'s and means nothing to - * `migrate`, and a whitelist shared across commands would make - * `vitnode migrate --ci` a legal way to ask for nothing. - */ flags: readonly string[]; - /** - * How many bare (non-flag) arguments the implementation actually reads. - * - * Read off the implementation rather than chosen: a command that reads - * `process.argv[3]` and nothing else takes one, and `"any"` is for the ones - * that join everything they are handed. - */ + positional: "any" | number; /** One line of usage, printed with every refusal. */ usage: string; - /** - * The flags above that consume the following token as their value. - * - * `--model gpt-5` and `--model=gpt-5` are both spellings the implementation - * already accepts, so both are accepted here - and a value-taking flag with no - * value is a refusal rather than an `undefined` handed downstream. - */ + valueFlags?: readonly string[]; } -/** - * The contract, per command, as data. - * - * Every entry was read off the implementation it describes, which is why three - * of them take arguments that a glance at `scripts.ts` would not suggest: - * - * - `i18n:create` reads `process.argv.slice(3)` as `[code, ...nameParts]` and - * joins the tail into the language name, so anything supplied skips its prompt - * and the command works on a non-interactive stdin. Unbounded on purpose - a - * name is several words. - * - `i18n:delete` reads `process.argv[3]` and only that, so it takes exactly one. - * - `i18n:update:ai` parses its own `--model` / `--concurrency` and treats every - * remaining token as a locale code. - * - * Those three keep parsing their own `argv`; what changes is that they are no - * longer reached with an `argv` this table rejects. The other six take nothing, - * and said so only by ignoring what they were given. - */ export const COMMAND_ARGUMENTS: Record<CliCommandName, CliCommandArguments> = { build: { flags: [], positional: 0, usage: "vitnode build" }, "db:prepare": { flags: [], positional: 0, usage: "vitnode db:prepare" }, @@ -124,15 +64,6 @@ export const isCliCommandName = ( ): name is CliCommandName => name !== undefined && Object.hasOwn(COMMAND_ARGUMENTS, name); -/** - * A validated invocation, or the reason it was refused. - * - * `args` is the argument list unchanged, and every token in it has been checked - * against the command's own table - so `args.includes("--generate")` in the - * entry point cannot be satisfied by anything but that exact spelling, and the - * three commands that read their own `argv` are only ever started on an `argv` - * this function accepted. - */ export type ParsedCli = | { args: readonly string[]; command: CliCommandName; ok: true } | { message: string; ok: false }; diff --git a/packages/vitnode/scripts/database-bootstrap.test.ts b/packages/vitnode/scripts/database-bootstrap.test.ts index 30f63c33d..c34b83741 100644 --- a/packages/vitnode/scripts/database-bootstrap.test.ts +++ b/packages/vitnode/scripts/database-bootstrap.test.ts @@ -12,34 +12,6 @@ import { runWithMigrationLock, } from "./prepare-database.js"; -/** - * The development bootstrap runs to completion **before** anything serves a - * request, and it is the database's business alone. - * - * Pure and static: the step list is a pure function and is called as one, and the - * CLI is read as the text it is. No Postgres, no Hono, no dev server - a test - * that needed a database could not tell the difference between "migrated first" - * and "migrated eventually", which is the only distinction here that matters. - * - * ## The regression this exists for - * - * Until Stage 17 a generated app's `dev` script was - * `vitnode init && next dev`, and `vitnode init` did two unrelated things: it - * copied every installed plugin's pages into the host's `src/app/[locale]/…` so - * Next.js could see them, and it prepared the database - generate, apply, seed. - * - * Stage 17 deleted the route copier, correctly, and deleted `init` with it. The - * database half went too, and nothing noticed for the same reason it is hard to - * notice now: every machine that had already run `pnpm dev` once had a migrated - * database, so only a fresh clone or a wiped volume showed it - as an arbitrary - * Postgres error from a page rather than a migration log in a terminal. - * - * `vitnode db:prepare` is the database half under a name that describes only - * itself. What this file pins is that it exists, that its steps are in the one - * order that works, that it cannot be started concurrently with a runtime, and - * that it has not quietly re-acquired the other half. - */ - const scriptsRoot = import.meta.dirname; /** A script's code, with comments removed - prose may name what code may not do. */ @@ -52,12 +24,6 @@ const cli = codeOf("scripts.ts"); const bootstrap = codeOf("prepare-database.ts"); describe("the steps of a database bootstrap", () => { - /** - * The order *is* the contract, and each step depends on the one before it: a - * migration cannot be applied before it is generated, and the seed inserts - * into `core_roles` and `core_languages`, which do not exist until the - * migrations have run. A reordering here is a fresh-database crash. - */ it("generates, then applies, then seeds", () => { expect( databaseBootstrapSteps({ generate: true }).map(step => step.label), @@ -82,12 +48,6 @@ describe("the steps of a database bootstrap", () => { ]); }); - /** - * Generation is a flag because applying committed migrations without writing - * new ones is a real request - a deployment step, a CI check - but the seed is - * not optional in either. A fresh database with tables and no `Administrator` - * role cannot be administered. - */ it("can apply without generating, and still seeds", () => { const steps = databaseBootstrapSteps({ generate: false }); @@ -116,15 +76,6 @@ describe("the `db:prepare` command", () => { expect(cli).toContain('case "db:prepare":'); }); - /** - * `await`ed, and inside a `try`. Both halves matter and the second is the one - * that regressed: the branch was `case "init": void prepareDatabase(...)`, and - * `void` on an async call turns a failed step into an unhandled rejection - * rather than an exit code this process chose. It happened to exit non-zero, - * because crashing on an unhandled rejection is Node's default - which is to - * say the fail-fast a `&&` depends on was a runtime default rather than a - * decision anyone had made. - */ it("awaits the bootstrap and exits non-zero when it throws", () => { const branch = cli.slice( cli.indexOf('case "db:prepare":'), @@ -138,13 +89,6 @@ describe("the `db:prepare` command", () => { expect(branch).not.toMatch(/\bvoid\s+databaseBootstrap/); }); - /** - * `migrate` is the same bootstrap under the name the documentation and every - * deployment guide spell - `docs/dev/database`, the Content Engine guides and - * the Vercel deployment page all say `db:migrate`, which runs it. It delegates - * rather than reimplementing, so the two names cannot drift into two - * behaviours. - */ it("shares one implementation with `migrate`", () => { const branch = cli.slice(cli.indexOf('case "migrate":')); @@ -155,12 +99,6 @@ describe("the `db:prepare` command", () => { expect(cli.match(/await initialDataForDatabase\(\)/g)).toBeNull(); }); - /** - * `init` is gone, and so is `--web`. A web app that talks to a separate API - * owns no schema, and `--web` existed only to print "nothing to initialise" - - * a flag whose meaning was to do nothing. The replacement is for such an app's - * `dev` script not to call the bootstrap at all. - */ it("no longer offers `init` or a `--web` no-op", () => { expect(cli).not.toContain('case "init"'); expect(cli).not.toContain("prepareDatabase"); @@ -170,17 +108,6 @@ describe("the `db:prepare` command", () => { }); describe("what decides whether work is pending", () => { - /** - * Drizzle's own `__drizzle_migrations` table, and nothing else. - * - * A marker file, a `.init-created` sentinel or an env flag would be a second - * source of truth that a fresh clone, a wiped Docker volume or a colleague's - * machine disagrees with immediately - and the disagreement is silent, because - * the marker says "done" while the database is empty. So there is no first-run - * state machine anywhere, and this asserts the absence rather than the design: - * the bootstrap is safe to run before *every* dev start because every step is - * idempotent. - */ it("keeps no first-run marker of its own", () => { for (const source of [cli, bootstrap]) { expect(source).not.toMatch(/\.init-created|\.migrations-done|\.migrated/); @@ -189,12 +116,6 @@ describe("what decides whether work is pending", () => { } }); - /** - * The migrator is Drizzle's, in-process, against the folder the app's own - * `drizzle.config.ts` names - so the bootstrap and `drizzle-kit generate` - * cannot point at different directories, and `out` is honoured rather than - * `./migrations` being assumed. - */ it("reads the migrations folder from drizzle.config.ts", () => { expect(bootstrap).toContain("drizzle.config.ts"); expect(bootstrap).toMatch(/loaded\.default\?\.out \?\? loaded\.out/); @@ -205,13 +126,6 @@ describe("what decides whether work is pending", () => { ); }); - /** - * Provisioned before the migrations that need them, not after: the generated - * `search_vector` column resolves every `regconfig` branch at column-creation - * time, so a missing text-search dictionary fails migration 0017 rather than a - * later query. Managed Postgres cannot install the `polish` hunspell files, so - * a `COPY = simple` fallback is registered for whatever is absent. - */ it("ensures text-search configs before applying migrations", () => { const applyStep = bootstrap.slice( bootstrap.indexOf("export const runMigrations"), @@ -248,17 +162,6 @@ describe("what decides whether work is pending", () => { }); describe("the languages a fresh database is seeded with", () => { - /** - * From the API config, and from nothing else. - * - * The regression: this used to be read from the *frontend* config, found by - * walking `process.cwd()` for a `src/vitnode.config.ts`. `db:prepare` runs - * from the app that owns the schema - `apps/api` here, and `apps/api` in every - * generated monorepo - where the web app's config is a sibling the search - * never reaches. The optional lookup returned `null` without a word, the - * fallback ran, and a fresh database came up with `en` alone while the site - * served `en` and `pl`. - */ it("reads no frontend config", () => { const seed = bootstrap.slice( bootstrap.indexOf("export const initialDataForDatabase"), @@ -273,11 +176,6 @@ describe("the languages a fresh database is seeded with", () => { expect(bootstrap).not.toContain("vitnode.config.ts"); }); - /** - * With no `i18n` block, `en` - and not the API's own derived locale list, - * which is "whatever the installed packages ship a translation for" and would - * write a language row per installed language pack. - */ it("falls back to English when the API declares no locales", () => { for (const i18n of [undefined, {}, { locales: [] }]) { expect(languagesFromApiConfig(i18n)).toEqual([ @@ -321,11 +219,6 @@ describe("the languages a fresh database is seeded with", () => { ]); }); - /** - * `defaultLocale` decides which row is the default, and `protected` follows - * it: the default language is the one the AdminCP must not let anybody - * delete. - */ it("marks the configured default, whichever it is", () => { const rows = languagesFromApiConfig({ defaultLocale: "pl", @@ -343,12 +236,6 @@ describe("the languages a fresh database is seeded with", () => { ]); }); - /** - * A default has to exist. `defaultLocale` is optional on the API config and - * falls back to `"en"` the way the API runtime's does - but an app that does - * not serve English at all would then have no default row, and a - * `core_languages` with no default is a state nothing downstream can read. - */ it("always leaves exactly one default", () => { for (const i18n of [ { locales: [{ code: "pl", name: "Polski" }] }, @@ -397,11 +284,6 @@ describe("the languages a fresh database is seeded with", () => { ).toBe("UTC"); }); - /** - * `enabled: false` is dropped, the same way `localeRoutingFromConfig` drops - * it: a language the app has switched off should 404 rather than get a row - * that makes it selectable in the AdminCP. - */ it("skips disabled locales, and never seeds nothing", () => { expect( languagesFromApiConfig({ @@ -422,11 +304,6 @@ describe("the languages a fresh database is seeded with", () => { ).toEqual(["en"]); }); - /** - * Adding a locale and re-running `db:prepare` inserts the new one and leaves - * the rest alone - `onConflictDoNothing` on `code`, which is what makes the - * seed safe to run before every dev start. - */ it("adds a locale without disturbing the ones already there", () => { const before = languagesFromApiConfig({ defaultLocale: "en", @@ -446,15 +323,6 @@ describe("the languages a fresh database is seeded with", () => { ); }); - /** - * And the role labels go in under whichever language is the default. - * - * They stay English - four words nobody translated are better than four words - * somebody invented, and VitNode falls back to the default language for a - * missing translation anyway. What changed is the *code*: `languageCode` - * references `core_languages.code`, so a hard-coded `"en"` is a foreign-key - * violation on the first installation that does not serve English. - */ it("seeds role names under the default language, not under `en`", () => { const seed = bootstrap.slice( bootstrap.indexOf("export const initialDataForDatabase"), @@ -470,20 +338,6 @@ describe("the languages a fresh database is seeded with", () => { }); describe("concurrent bootstraps are serialised", () => { - /** - * More than one runtime legitimately gates on the bootstrap - the API, and any - * single app that mounts the API in-process - so a monorepo `turbo dev` starts - * two at once, and `cd apps/web && pnpm dev` starts one beside whatever else is - * running. - * - * Measured, not theorised: with the lock bypassed, two concurrent - * `vitnode db:prepare` runs against one empty database leave one exiting 1 - - * first on `CREATE TEXT SEARCH CONFIGURATION "polish"`, and once that is - * tolerated, on `CREATE SCHEMA IF NOT EXISTS drizzle`. Postgres' - * `IF NOT EXISTS` is not race-safe against a concurrent creator. With the lock, - * both exit 0 and the database has 40 migrations and 4 roles rather than - * doubles of either. - */ it("takes a Postgres advisory lock around the whole bootstrap", () => { expect(bootstrap).toContain("withMigrationLock"); expect(bootstrap).toContain("pg_try_advisory_lock"); @@ -491,12 +345,6 @@ describe("concurrent bootstraps are serialised", () => { expect(bootstrap).toMatch(/MIGRATION_LOCK_KEY = [\d_]+;/); }); - /** - * Around the *whole* bootstrap, generation included. Two concurrent - * `drizzle-kit generate` runs on one changed schema would each write a - * migration directory and fork the history, so the lock cannot start at the - * apply step. - */ it("wraps every step, not just the apply", () => { const fn = bootstrap.slice( bootstrap.indexOf("export const databaseBootstrap"), @@ -515,12 +363,6 @@ describe("concurrent bootstraps are serialised", () => { expect(lock).toBeLessThan(action); }); - /** - * On a connection of its own, which is the fix for the deadlock below rather - * than a preference. An advisory lock belongs to a *session*, so one - * connection has to stay open for the whole bootstrap - and taking it out of - * the application's pool is what made a `max: 1` pool impossible to migrate. - */ it("opens a dedicated connection for the lock", () => { const open = bootstrap.slice( bootstrap.indexOf("const openMigrationLock"), @@ -541,12 +383,6 @@ describe("concurrent bootstraps are serialised", () => { expect(bootstrap).not.toContain("session.release()"); }); - /** - * `pg_try_advisory_lock` in a bounded loop rather than the blocking - * `pg_advisory_lock`: a blocking wait cannot say why it is waiting, and a - * developer whose previous run was killed mid-migration would get a terminal - * that never returns. - */ it("waits with a deadline and says what it is waiting for", () => { expect(bootstrap).not.toContain("pg_advisory_lock("); expect(bootstrap).toMatch(/MIGRATION_LOCK_WAIT_MS = [\d_]+;/); @@ -554,23 +390,11 @@ describe("concurrent bootstraps are serialised", () => { expect(bootstrap).toMatch(/waiting for it to finish/); }); - /** - * A driver whose client carries no `postgres` options runs unlocked rather - * than refusing to migrate. VitNode serves whatever `dbProvider` an app - * configures, and the single-process case every non-monorepo app has cannot - * race anyway. - */ it("degrades to unlocked rather than failing on an unknown driver", () => { expect(bootstrap).toMatch(/!\("shared" in options\)/); expect(bootstrap).toMatch(/if \(lock === null\) \{\s*await run\(\)/); }); - /** - * And the tolerance that the race exposed: the loser of a concurrent - * `CREATE TEXT SEARCH CONFIGURATION` gets `23505`, not the `42710` the original - * code checked - and the code sits one wrapper deep, inside Drizzle's - * `DrizzleQueryError`, so reading only the outermost `code` finds `undefined`. - */ it("tolerates both spellings of “already created”, down the cause chain", () => { expect(bootstrap).toContain("isAlreadyCreatedError"); expect(bootstrap).toContain('"23505"'); @@ -581,16 +405,6 @@ describe("concurrent bootstraps are serialised", () => { }); }); -/** - * A pool of a fixed size, as the smallest thing that can tell the two lock - * designs apart. - * - * `drizzle({ connection })` hands an app's options to `postgres`, so `max` is - * the app's to choose and `max: 1` is a choice a serverless function or a small - * container has every reason to make. Nothing else about a pool matters here: - * the question is only whether a connection is available when the migration - * asks for one. - */ const createPool = (max: number) => { let inUse = 0; @@ -609,23 +423,10 @@ const createPool = (max: number) => { }; }; -/** - * One round trip to the database, as the fakes below spend it. - * - * Every call the real lock makes is a query, so none of them resolves - * synchronously - and a fake that did would let a missing `await` in - * `runWithMigrationLock` pass unnoticed. - */ const roundTrip = async (): Promise<void> => { await new Promise(resolve => setTimeout(resolve, 0)); }; -/** - * A lock whose session comes from `pool`, or from nowhere. - * - * `pool` is `null` for the design that ships - the lock has a connection of its - * own - and the application's pool for the design that deadlocked. - */ const createFakeLock = ( pool: null | ReturnType<typeof createPool>, { available = true }: { available?: boolean } = {}, @@ -658,18 +459,6 @@ const createFakeLock = ( }; describe("holding the lock costs the application pool nothing", () => { - /** - * The regression, stated as the thing that used to hang. - * - * The old implementation called `dbClient.$client.reserve()` and then ran the - * whole bootstrap through `dbClient` - the same pool. With `max: 1` the - * reserved session *was* the pool, so `runMigrations` asked for a connection - * that could not be returned until the migration blocking on it had finished. - * Nothing errored and nothing timed out; the terminal simply stopped. - * - * A pool of one is the fake, so "waits forever" becomes "cannot acquire" - - * which is the same fact where a test can see it. - */ it("migrates through a pool of one while the lock is held", async () => { const pool = createPool(1); const { events, lock } = createFakeLock(null); @@ -691,12 +480,6 @@ describe("holding the lock costs the application pool nothing", () => { expect(events).toEqual(["tryLock", "unlock", "close"]); }); - /** - * And the fake is not vacuous: the design this replaced fails against it. - * - * Without this, the test above would pass just as well for a lock that never - * touched a connection at all, which is not what is being claimed. - */ it("would have deadlocked had the lock come out of that pool", async () => { const pool = createPool(1); const { lock } = createFakeLock(pool); @@ -732,11 +515,6 @@ describe("holding the lock costs the application pool nothing", () => { expect(events).toEqual(["tryLock", "unlock", "close"]); }); - /** - * A lock somebody else holds is waited for, announced once, and given up on - - * `sleep` is injected so the deadline can be reached without spending the two - * minutes it describes. - */ it("waits, says so once, and times out", async () => { const { events, lock } = createFakeLock(null, { available: false }); const said: string[] = []; @@ -794,12 +572,6 @@ describe("holding the lock costs the application pool nothing", () => { }); describe("the bootstrap and the plugin runtime are separate", () => { - /** - * The invariant that keeps the two halves of the deleted `init` apart. A - * database bootstrap that copied a plugin page would be the old command back - * under a new name; the plugin half belongs to the app's Vite build, which - * writes four `*.gen.ts` registries and no route file. - */ it("prepares no plugin and writes no route file", () => { for (const source of [cli, bootstrap]) { expect(source).not.toContain("preparePluginsFiles"); diff --git a/packages/vitnode/scripts/dev.test.ts b/packages/vitnode/scripts/dev.test.ts index 03fc91110..f280f4716 100644 --- a/packages/vitnode/scripts/dev.test.ts +++ b/packages/vitnode/scripts/dev.test.ts @@ -78,14 +78,6 @@ describe("devPlugin", () => { } }); - /** - * The route copier is gone, and `vitnode dev` must not grow it back. - * - * It used to start a fourth process - a chokidar watcher copying the plugin's - * `src/routes/{main,admin,blank,breadcrumb}/` into every Next.js app's - * `src/app/`. Asserting the exact list rather than a count, because what would - * regress here is a *named* watcher reappearing, and the name is the evidence. - */ it("spawns no fourth process for copying route files anywhere", () => { devPlugin({ initMessage: "dev" }); diff --git a/packages/vitnode/scripts/i18n-create.test.ts b/packages/vitnode/scripts/i18n-create.test.ts index a22ac355f..c33245848 100644 --- a/packages/vitnode/scripts/i18n-create.test.ts +++ b/packages/vitnode/scripts/i18n-create.test.ts @@ -208,15 +208,6 @@ describe("addMessagesToConfig", () => { }); describe("addLocaleToAppMessages", () => { - /** - * The loaders go in `src/locales/app.ts`, not in the config. - * - * `vitnode.config.ts` is browser-safe and is executed by Vite at build time, - * so a `() => import(...)` written into its `i18n` block is both in the - * browser bundle and in the build. `appMessages` is the map - * `vitnode.server.config.ts` registers, and its loaders are relative to - * `src/locales/` rather than to `src/`. - */ it("adds a locale block to an empty map", () => { expect( addLocaleToAppMessages( diff --git a/packages/vitnode/scripts/i18n-create.ts b/packages/vitnode/scripts/i18n-create.ts index f74fb1747..fcb32e322 100644 --- a/packages/vitnode/scripts/i18n-create.ts +++ b/packages/vitnode/scripts/i18n-create.ts @@ -21,14 +21,6 @@ import { findRepoRoot } from "./shared/file-utils.js"; const LOCALE_CODE_PATTERN = /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/; -/** - * One message loader, indented, with the path spelled relative to the file it - * is being written into. - * - * `src/locales/app.ts` sits inside `src/locales`, so its loaders are - * `./<pluginId>/<code>.json`. A config at `src/` reaches the same file through - * `./locales/<pluginId>/<code>.json`. - */ const messageEntry = ( indent: string, pluginId: string, @@ -286,11 +278,7 @@ export const buildI18nFile = ({ return `import type { VitNodeI18nConfig } from "@vitnode/core/lib/i18n/types"; -/** - * Shared by \`vitnode.config.ts\` (web) and \`vitnode.api.config.ts\` (API) so the - * site and its emails agree on which languages exist. Packages ship their own - * languages - only what this app adds or reworks needs a file here. - */ + export const i18n = { defaultLocale: "${defaultLocale}", locales: [ diff --git a/packages/vitnode/scripts/i18n-delete.ts b/packages/vitnode/scripts/i18n-delete.ts index 29fc94cac..e9939556c 100644 --- a/packages/vitnode/scripts/i18n-delete.ts +++ b/packages/vitnode/scripts/i18n-delete.ts @@ -42,18 +42,6 @@ const matchingBracket = (source: string, openIndex: number): number => { return -1; }; -/** - * Removes the `{ ... code: "<code>" ... }` entry from the `locales` array, - * along with its indentation and trailing comma. A locale object is flat, so a - * `[^{}]` run matches its whole body. Returns the source unchanged when no such - * entry is present. - * - * The match is scoped to the `locales` array's bounds first: an inline config - * can carry unrelated flat objects that share the code (e.g. a plugin option - * `{ code: "de" }`) earlier in the file, and an unscoped replace would strip - * the first of those instead - silently corrupting the config while reporting - * success. - */ export const removeLocaleFromConfig = ( source: string, code: string, @@ -76,11 +64,6 @@ export const removeLocaleFromConfig = ( return source.slice(0, open) + cleaned + source.slice(close + 1); }; -/** - * Removes the `<code>: { ... }` block from `messages`. The block's loaders use - * `()` not `{}`, so a `[^{}]` run matches its whole body. Returns the source - * unchanged when the locale has no messages block. - */ export const removeMessagesFromConfig = ( source: string, code: string, diff --git a/packages/vitnode/scripts/i18n-shared.ts b/packages/vitnode/scripts/i18n-shared.ts index 9a12f32e8..77e0bdc2e 100644 --- a/packages/vitnode/scripts/i18n-shared.ts +++ b/packages/vitnode/scripts/i18n-shared.ts @@ -9,24 +9,11 @@ import { findPackagePath } from "./shared/file-utils.js"; export const CORE_PLUGIN_ID = "@vitnode/core"; -/** - * Which of a package's two locale trees an app actually uses. A package ships a - * frontend tree (`src/locales/<locale>.json`) and, if it renders server-side, a - * server tree (`src/locales/api/<locale>.json`). An app that only runs the API - * has no use for the frontend's UI copy, so `create` and `check` scope to the - * trees the app's configs prove it needs. - */ export interface AppScope { api: boolean; web: boolean; } -/** - * The scope implied by which config files an app has: a `vitnode.config.ts` - * means it serves the frontend, a `vitnode.api.config.ts` means it runs the - * API, and a single app has both. An app with neither is treated as both, so - * the tools stay useful rather than silently seeding nothing. - */ export const appScope = ({ api, web, diff --git a/packages/vitnode/scripts/i18n-update-ai.ts b/packages/vitnode/scripts/i18n-update-ai.ts index daeac1c1b..0b56a31a5 100644 --- a/packages/vitnode/scripts/i18n-update-ai.ts +++ b/packages/vitnode/scripts/i18n-update-ai.ts @@ -46,15 +46,6 @@ const isPlainObject = (value: unknown): value is Record<string, unknown> => const toModelId = (model: LanguageModel): string => typeof model === "string" ? model : model.modelId; -/** - * A leaf worth sending to a model: it holds real prose once simple - * interpolation placeholders (`{name}`) and markup tags (`<b>`) are removed. - * A value that is only a placeholder, tag, punctuation, or number (`{count}`, - * `<br/>`, `—`, `123`) has nothing to translate and is left untouched. ICU - * message syntax like `{count, plural, one {# item} other {# items}}` carries - * spaces/commas inside its braces, so it does not match the simple-placeholder - * pattern and stays in - its `item`/`items` still need translating. - */ export const isTranslatable = (source: string): boolean => { const stripped = source .replace(/\{[a-zA-Z0-9_]+\}/g, "") @@ -89,15 +80,6 @@ interface BatchTask { sources: string[]; } -/** - * Every leaf the target still shows in English - a string key whose value is - * byte-identical to the English source. After `reconcileTree` the target has - * English's exact shape, so a leaf still equal to English is one nobody has - * translated yet (freshly seeded, or deliberately left as-is). A human - * translation differs from English and is therefore never collected, so this - * command never re-translates or overwrites existing work. Empty and - * letter-free sources are skipped - there is nothing to translate. - */ export const collectUntranslated = ( english: Record<string, unknown>, target: Record<string, unknown>, @@ -128,12 +110,6 @@ export const collectUntranslated = ( return leaves; }; -/** - * Returns a copy of `tree` with each `{ path, value }` written at its leaf. The - * tree is cloned so the caller's object is never mutated. A path whose parents - * are not objects (a shape that drifted since the leaves were collected) is - * skipped defensively rather than clobbering an unrelated node. - */ export const applyTranslations = ( tree: Record<string, unknown>, entries: { path: string[]; value: string }[], @@ -170,12 +146,6 @@ export const chunk = <T>(items: T[], size: number): T[][] => { return batches; }; -/** - * The distinct source strings across `leaves`, in first-seen order. Identical - * English recurs a lot (`Save`, `Cancel`, `Name`), so translating each once per - * locale and fanning the result back to every key that used it cuts the work - * with no quality loss for UI copy. - */ export const uniqueSources = (leaves: TranslatableLeaf[]): string[] => { const seen = new Set<string>(); const sources: string[] = []; @@ -189,12 +159,6 @@ export const uniqueSources = (leaves: TranslatableLeaf[]): string[] => { return sources; }; -/** - * Runs `worker` over `items` with at most `concurrency` calls in flight, - * returning results in input order. A shared cursor hands each idle runner the - * next item; there is no `await` between reading and advancing it, so the - * single-threaded event loop makes the increment race-free. - */ export const mapPool = async <T, R>( items: T[], concurrency: number, @@ -236,18 +200,6 @@ const withRetry = async <T>(fn: () => Promise<T>): Promise<T> => { throw lastError; }; -/** - * Translates one batch of unique source strings with a configured AI SDK model. - * - * To keep tokens (and cost) down, input and output are bare positional string - * arrays - no `{ id, text }`/`{ id, value }` scaffolding per item, and the - * schema is just "array of string". Alignment is by index: the model must - * return the translations in the same order. `temperature: 0` makes that - * deterministic and re-runs idempotent. If the reply length doesn't match, the - * whole batch is rejected (so `withRetry` retries and we never write a - * misaligned translation) rather than trusting a partial answer. Returns a - * source -> translation map. - */ const translateBatch = async ({ code, languageName, diff --git a/packages/vitnode/scripts/i18n-update.ts b/packages/vitnode/scripts/i18n-update.ts index e945f538a..2d52999ba 100644 --- a/packages/vitnode/scripts/i18n-update.ts +++ b/packages/vitnode/scripts/i18n-update.ts @@ -22,15 +22,6 @@ const MAX_LISTED_KEYS = 8; const isPlainObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value); -/** - * Reconciles a translation tree against the English source: the result has - * exactly English's shape, but every leaf keeps the existing translation when - * there is one and falls back to the English string when there is not. - * - * - a key English has but the translation lacks -> added, seeded with English - * - a key the translation has but English no longer does -> dropped - * - a key both have -> the existing translation is preserved, never overwritten - */ export const reconcileTree = ( english: Record<string, unknown>, current: Record<string, unknown>, diff --git a/packages/vitnode/scripts/no-route-copier.test.ts b/packages/vitnode/scripts/no-route-copier.test.ts index 3eeabe6e3..e2a4e6728 100644 --- a/packages/vitnode/scripts/no-route-copier.test.ts +++ b/packages/vitnode/scripts/no-route-copier.test.ts @@ -3,44 +3,6 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative, resolve } from "node:path"; import { describe, expect, it } from "vitest"; -/** - * The plugin route copier is gone, and this is what keeps it gone. - * - * Static and pure: a file listing plus a string search over `scripts/` and - * `src/`. Nothing here runs the CLI, starts a watcher or touches an app. - * - * ## What was deleted - * - * Until the Next.js cutover a plugin's `src/routes/` meant two different things - * at once, because two runtimes read it: - * - * src/routes/main/page.tsx COPIED into a Next app's src/app/[locale]/(main)/ - * src/routes/admin/… copied into the AdminCP - * src/routes/blank/… copied without the site chrome - * src/routes/breadcrumb/… copied into a @breadcrumb parallel-route slot - * - * src/routes.ts declared, never copied - * - * `scripts/prepare-plugins-files.ts` did the copy once per `vitnode init`, - * `scripts/plugin.ts` watched and re-copied on every save, and - * `scripts/shared/file-utils.ts` rewrote each import on the way through so a - * page's `@/` still resolved after it landed in somebody else's `src/`. - * `scripts/legacy-route-overlap.ts` warned when a route declaration pointed inside - * one of those four directories, and - * `src/framework/plugin-routes/legacy-routes.ts` refused a plugin route whose - * URL a Next.js page still answered. - * - * ## What replaced it, and why a copy may never come back - * - * A plugin declares its routes in `src/routes.ts`; the app's Vite build - * compiles that into a literal registry it imports from the plugin's own `dist`. - * The page has exactly one home, so there is no copy to go stale, no import to - * rewrite, and no generated directory in an application that nobody wrote. - * - * A test rather than a note in a changelog, because the copier's failure mode - * was silence: it wrote files into a directory nobody reads, and the wrong copy - * is the one that ran. - */ const scriptsRoot = resolve(import.meta.dirname); const packageRoot = resolve(scriptsRoot, ".."); @@ -63,13 +25,6 @@ const filesUnder = (directory: string): string[] => { return walk(directory).sort(); }; -/** - * Source with its comments removed. - * - * Every file this suite reads is allowed to *describe* the copier - the - * explanations above are the most useful thing a reader will find, and several - * of these modules carry their own. What must not survive is code. - */ const codeOf = (file: string): string => readFileSync(join(packageRoot, file), "utf8") .replace(/\/\*[\s\S]*?\*\//g, "") @@ -90,11 +45,6 @@ describe("the plugin route copier", () => { } }); - /** - * The CLI is the copier's only entry point, so the command table is the thing - * worth asserting on: `vitnode prepare-plugins` ran it once and - * `vitnode plugin --w` started its watcher. - */ it("has no CLI command", () => { const cli = codeOf("scripts/scripts.ts"); @@ -115,11 +65,6 @@ describe("the plugin route copier", () => { ); }); - /** - * The copy engine itself. `findRepoRoot` and `findPackagePath` survive in the - * same file because the `i18n:*` commands locate packages with them, and - * neither has anything to do with routing. - */ it("leaves no file-copying machinery behind", () => { const utils = codeOf("scripts/shared/file-utils.ts"); @@ -138,11 +83,6 @@ describe("the plugin route copier", () => { expect(utils).toContain("findPackagePath"); }); - /** - * The four directory names are the convention itself. A script that still - * spells one is a script that still claims a plugin's directory, whatever it - * intends to do with it. - */ it("recognises no legacy route directory", () => { const offenders = scriptFiles.filter(file => { const code = codeOf(file); @@ -175,16 +115,6 @@ describe("the plugin route copier", () => { }); describe("the build-time strangler", () => { - /** - * `assertNoLegacyRouteCollision` refused a plugin route that claimed a URL the - * Next.js application still answered, reading those URLs off core's own - * `src/routes/admin/**`. Both the guard and the directory it read are gone. - * - * Asserted over all of `src/` rather than over the compiler alone, because - * what would regress is an *allowlist of migrated routes* reappearing - * somewhere, and the whole point of the route tree deciding is that no such - * list exists. - */ it("keeps no list of routes another application owns", () => { expect( existsSync( @@ -208,11 +138,6 @@ describe("the build-time strangler", () => { expect(offenders).toEqual([]); }); - /** - * The compiler keeps the check that is not migration-only: - * `assertNoHostRouteCollision` compares a plugin's routes against the host's - * own route files, which is a permanent question and the only one left. - */ it("still refuses a plugin route that shadows the host's own page", () => { const compiler = codeOf("src/framework/plugin-routes/compile.ts"); diff --git a/packages/vitnode/scripts/prepare-database.ts b/packages/vitnode/scripts/prepare-database.ts index 2ef99154c..cf91e2cc7 100644 --- a/packages/vitnode/scripts/prepare-database.ts +++ b/packages/vitnode/scripts/prepare-database.ts @@ -60,21 +60,7 @@ const getMigrationsFolder = async (): Promise<string> => { // for any missing config: search still tokenizes and matches, it just skips // stemming. Configs that already exist (including a real dictionary) are left // untouched. -/** - * Whether a failure is Postgres saying "somebody else created that already". - * - * Two SQLSTATEs, and the second is the one that actually happens. - * `CREATE TEXT SEARCH CONFIGURATION` has no `IF NOT EXISTS`, so two sessions - * racing on it do not get `42710 duplicate_object` - they get - * `23505 unique_violation` from the unique index on `pg_ts_config.cfgname`, - * because both passed the existence check before either inserted. The original - * code tolerated only `42710`, which is why the race failed a whole bootstrap. - * - * Walks `cause`, because the code is one wrapper deep: Drizzle 1.0 raises a - * `DrizzleQueryError` carrying no `code` of its own, with the driver's - * `PostgresError` as its `cause`. Reading only the outermost `code` finds - * `undefined` and rethrows. - */ + const isAlreadyCreatedError = (error: unknown): boolean => { const TOLERATED = new Set([ "23505", // unique_violation - lost the insert race diff --git a/packages/vitnode/scripts/scripts.ts b/packages/vitnode/scripts/scripts.ts index 99d4f4171..9706fa972 100644 --- a/packages/vitnode/scripts/scripts.ts +++ b/packages/vitnode/scripts/scripts.ts @@ -22,16 +22,6 @@ config({ const initMessage = "\x1b[34m[VitNode]\x1b[0m"; -/** - * The invocation, checked before anything can act on it. - * - * Above the `switch` and not inside it, because the guarantee is that an invalid - * `argv` performs *no* command side effect: nothing is generated, nothing is - * migrated, nothing is seeded and no compiler is started. `vitnode migrate - * --generat` used to reach `databaseBootstrap` - the mistyped flag simply failed - * an `=== "--generate"` test and fell through to the full run. It now exits 1 - * here. See `./cli-arguments.ts`. - */ const parsed = parseCliArguments(process.argv.slice(2)); if (!parsed.ok) { diff --git a/packages/vitnode/scripts/shared/file-utils.ts b/packages/vitnode/scripts/shared/file-utils.ts index 1382e4818..15c9a5a2c 100644 --- a/packages/vitnode/scripts/shared/file-utils.ts +++ b/packages/vitnode/scripts/shared/file-utils.ts @@ -1,38 +1,6 @@ import { existsSync } from "node:fs"; import { join, resolve } from "node:path"; -/** - * Where a VitNode project starts, for the scripts that have to read something - * outside the directory they were run in. - * - * All that is left of what used to be this file. Until the Next.js cutover it - * also held the plugin **route copier** - the machinery that read a plugin's - * `src/routes/{main,admin,blank,breadcrumb}/` and wrote copies of those pages - * into every Next.js app's `src/app/[locale]/…`, rewriting each import as it - * went. A plugin's pages are no longer copied anywhere: its route tree is - * compiled into a literal registry and the app imports the page out of the - * plugin's own `dist`, so there is nothing to copy, nothing to clean up when a - * source file is deleted, and no import to rewrite. - * - * What the copier needed and this does not: `findLocaleRoot` (it looked for an - * `src/app/[locale]` directory, which only a Next.js App Router app has), - * `transformFileImports`, `copyFile`, `copyDirectoryRecursive`, - * `cleanupDeletedFiles`, `buildInitialRouteMap`, `routeKey`, `getAllFiles`, - * `isDirectoryEmpty` and `SourceConfig`. - * - * The two below survive because the `i18n:*` commands use them, and neither has - * anything to do with routing: one locates the project, the other locates an - * installed package inside it. - */ - -/** - * The root of the project, found by walking up to a `turbo.json`. - * - * A monorepo is the interesting case - a command is run inside `apps/web` and - * has to reach `plugins/` - so the marker is the file only a repository root - * has. A standalone project has no `turbo.json` and no directories above it to - * search, so its own `package.json` is the answer. - */ export function findRepoRoot(startPath: string): string { let currentPath = startPath; while (currentPath !== resolve(currentPath, "..")) { diff --git a/packages/vitnode/src/api/adapters/events/local.ts b/packages/vitnode/src/api/adapters/events/local.ts index aed210a5e..55316fcb4 100644 --- a/packages/vitnode/src/api/adapters/events/local.ts +++ b/packages/vitnode/src/api/adapters/events/local.ts @@ -7,13 +7,6 @@ import type { EventsApiPlugin, } from "@/api/models/events"; -/** - * In-process event delivery (default). Runs matching listeners sequentially, - * in registration order (core plugin first, then the app's `plugins` order), - * inside the emitting request. Single-process by design: listeners only run - * on the instance that emitted the event - swap the adapter for a broker to - * fan out across instances. - */ export const LocalEventsAdapter = (): EventsApiPlugin => ({ name: "local", diff --git a/packages/vitnode/src/api/adapters/storage/local.ts b/packages/vitnode/src/api/adapters/storage/local.ts index 8d1de9352..651d0dea1 100644 --- a/packages/vitnode/src/api/adapters/storage/local.ts +++ b/packages/vitnode/src/api/adapters/storage/local.ts @@ -9,18 +9,6 @@ import type { import { CONFIG } from "@/lib/config"; -/** - * Zero-config storage backend that writes uploads to the local disk under - * `public/uploads`. - * - * - On the standalone Node API (`@hono/node-server`) files are served by Hono's - * `serveStatic`, wired from the `static` descriptor below. The API is mounted - * under `/api`, so `publicPath` defaults to `/api/uploads`. - * - Inside a Next.js app the `public/` directory is served at the site root, so - * pass `publicPath: "/uploads"` there (the `static` descriptor is unused). - * - * Local disk is not durable on serverless platforms - use a cloud adapter there. - */ export const LocalStorageAdapter = ({ baseUrl, publicPath = "/api/uploads", diff --git a/packages/vitnode/src/api/lib/auth-cookie.test.ts b/packages/vitnode/src/api/lib/auth-cookie.test.ts index c2458cd3e..d4ecbbada 100644 --- a/packages/vitnode/src/api/lib/auth-cookie.test.ts +++ b/packages/vitnode/src/api/lib/auth-cookie.test.ts @@ -14,13 +14,6 @@ type Authorization = EnvVariablesVitNode["core"]["authorization"]; const COOKIE = "vitnode_auth"; -/** - * The `core` context these helpers read, with only the two fields they touch. - * - * Cast once, here: `ContextVariableMap` is augmented globally so `c.set("core")` - * wants the whole registry - plugins, content models, the cron metadata - none - * of which a cookie attribute depends on. - */ const coreWith = ( authorization: Partial<Authorization>, ): EnvVariablesVitNode["core"] => @@ -28,11 +21,6 @@ const coreWith = ( authorization: { cookieSecure: true, ...authorization }, }) as EnvVariablesVitNode["core"]; -/** - * The `Set-Cookie` headers a request to `url` comes back with, once `write` has - * run against a real Hono response. Asserting the header rather than the options - * object is the point: it is what a browser would actually be handed. - */ const setCookiesFrom = ({ authorization = {}, url = "https://vitnode.com/api/@vitnode/core/users/sign_in", diff --git a/packages/vitnode/src/api/lib/auth-cookie.ts b/packages/vitnode/src/api/lib/auth-cookie.ts index f426a8a5e..77863c111 100644 --- a/packages/vitnode/src/api/lib/auth-cookie.ts +++ b/packages/vitnode/src/api/lib/auth-cookie.ts @@ -3,27 +3,6 @@ import type { CookieOptions } from "hono/utils/cookie"; import { deleteCookie, setCookie } from "hono/cookie"; -/** - * The attributes every VitNode auth cookie is written with - and the ones a - * deletion has to repeat. - * - * A browser identifies a cookie by name, domain *and* path, so a `Set-Cookie` - * that removes one has to name the same three. Reading both sides from here is - * what stops them drifting: a sign-out sending no `Domain` against a cookie - * created with one deletes nothing at all, and says nothing while it happens. - * - * `domain` is absent unless an install explicitly asks for one. Left off, the - * cookie is *host-only* - bound to exactly the host that sent it - which is the - * right default for how VitNode deploys: the web app serves `/api/*` on its own - * origin, so there is no second host to share the cookie with. It is also the - * only default that works everywhere, because the host is not knowable ahead of - * time. A preview deployment's hostname is generated per branch, and a `Domain` - * naming anything the response did not come from - `localhost`, the production - * domain - is one the browser rejects outright, taking sign-in with it. - * - * Set `authorization.cookieDomain` to share a session across subdomains; see - * `VitNodeApiConfig`. - */ const authCookieOptions = (c: Context): CookieOptions => { const { cookieDomain, cookieSecure } = c.get("core").authorization; @@ -37,13 +16,6 @@ const authCookieOptions = (c: Context): CookieOptions => { }; }; -/** - * Writes one of VitNode's auth cookies - the session, the admin session, the - * device id, the SSO state. `expires` is the only per-cookie attribute; the - * rest are shared so that {@link deleteAuthCookie} can mirror them. - * - * Omit `expires` for a session cookie the browser should drop when it closes. - */ export const setAuthCookie = ( c: Context, name: string, @@ -53,14 +25,6 @@ export const setAuthCookie = ( setCookie(c, name, value, { ...authCookieOptions(c), expires }); }; -/** - * Removes one of VitNode's auth cookies, with the attributes it was created - * with. - * - * Always use this rather than `deleteCookie` directly: a deletion whose `Domain` - * or `Path` does not match the cookie's leaves it in the browser, and the - * response looks identical either way. - */ export const deleteAuthCookie = (c: Context, name: string): void => { deleteCookie(c, name, authCookieOptions(c)); }; diff --git a/packages/vitnode/src/api/lib/cache.ts b/packages/vitnode/src/api/lib/cache.ts index 9564dd6f1..6d48d1ac2 100644 --- a/packages/vitnode/src/api/lib/cache.ts +++ b/packages/vitnode/src/api/lib/cache.ts @@ -7,23 +7,9 @@ export type CacheConfig = RedisClientOptions; /** The connected `node-redis` client shared by the cache, rate limiter and ws. */ export type CacheClient = RedisClientType; -/** - * Root prefix applied to every key VitNode writes, so the cache can be flushed - * without touching unrelated data that may live in the same Redis instance. - * Keys are further namespaced per plugin - see {@link CacheModel.prefix}. - */ const CACHE_PREFIX = "vitnode:cache:"; const SYSTEM_NAMESPACE = "__system__"; -/** - * A small, safe cache facade exposed on the request context as - * `c.get("cache")`. Values are JSON-serialized and namespaced per plugin (see - * {@link CacheModel.prefix}), so the `hello` key a plugin writes actually lives - * at `vitnode:cache:{plugin_code}:hello`. When Redis is not configured (client - * is `null`) or a command fails, reads return `null`/`false`, writes are - * no-ops, and {@link CacheModel.remember} simply runs its loader - caching must - * never break a request. - */ export class CacheModel { constructor(client: CacheClient | null, c: Context) { this.c = c; diff --git a/packages/vitnode/src/api/lib/check-staff-permission.ts b/packages/vitnode/src/api/lib/check-staff-permission.ts index 80c0f1180..5dd3a9921 100644 --- a/packages/vitnode/src/api/lib/check-staff-permission.ts +++ b/packages/vitnode/src/api/lib/check-staff-permission.ts @@ -38,13 +38,6 @@ export const getUserRoleIds = async ( return [...new Set([user.roleId, ...secondary.map(row => row.roleId)])]; }; -/** - * The three queries behind a permission set: the user's roles, whether any of - * them is `root`, and the staff entries attached to the user or those roles. - * - * Split out from {@link resolveStaffPermissions} so the cache in front of it has - * something to be a cache *of* - and so the uncached path stays readable. - */ const loadStaffPermissions = async ( c: Context, { @@ -93,21 +86,6 @@ const loadStaffPermissions = async ( return { root: false, permissions }; }; -/** - * A user's effective staff permissions, read through the shared cache. - * - * This is the hottest read on an authenticated request: `GET /session` resolves - * it, and so does every `assertStaffPermission` an AdminCP route runs. Three - * database queries each time adds up on a page that renders a dozen gated - * elements, and the answer only moves when an admin edits a role or a staff - * entry - each of which expires the cache explicitly, so its 60-second lifetime - * is only a backstop. - * - * Without Redis the read misses, the write is a no-op, and this is exactly the - * uncached function it wraps. `remember` is not used because the key depends on - * a value that has to be read from the cache first - see - * [the epoch](./staff-permission-cache.ts). - */ export const resolveStaffPermissions = async ( c: Context, { diff --git a/packages/vitnode/src/api/lib/module.ts b/packages/vitnode/src/api/lib/module.ts index 00fcd8246..9e8048d11 100644 --- a/packages/vitnode/src/api/lib/module.ts +++ b/packages/vitnode/src/api/lib/module.ts @@ -20,22 +20,8 @@ export interface BaseBuildModuleReturn< M extends string = string, Routes extends Route<P>[] = Route<P>[], > { - /** - * The models behind those content types - table, columns, schemas and - * services, not just the definition. - * - * Collected recursively like `contentTypes`, and exposed on the request - * context so background work can find the model for a content type id. The - * scheduled-publication queue task is the reason it exists: it runs in a cron - * request that has no idea which plugin owns the record it is publishing. - */ contentModels?: AnyContentModel[]; - /** - * Content types whose CRUD routes this module serves. Unlike `events` and - * `cronJobs`, these are collected recursively by `buildApiPlugin`, so a - * generated content module can sit wherever it reads best in the tree - - * usually nested inside the plugin's own `admin` module. - */ + contentTypes?: AnyContentTypeDefinition[]; cronJobs: BuildCronReturn[]; events: BuildEventListenerReturn[]; diff --git a/packages/vitnode/src/api/lib/openapi-tags.ts b/packages/vitnode/src/api/lib/openapi-tags.ts index 9624e68b4..b2c432722 100644 --- a/packages/vitnode/src/api/lib/openapi-tags.ts +++ b/packages/vitnode/src/api/lib/openapi-tags.ts @@ -1,17 +1,5 @@ import type { BaseBuildModuleReturn } from "./module"; -/** - * Swagger has exactly one grouping mechanism - the tag - so the tag has to - * carry both halves of "where does this endpoint live": the plugin that owns - * it and the module that serves it. `(Core) - Users` instead of the raw - * `@vitnode/core` bucket every route used to land in. - * - * The *whole* module chain goes into the tag, not just the leaf, because module - * names repeat across the tree: core has a top-level `users` module and an - * `admin/users` one, plus `cron`, `queue` and `files` twice over. A leaf-only - * tag would merge the public and admin halves of each into a single group. - */ - const titleCase = (value: string) => value .split(/[-_\s]+/) diff --git a/packages/vitnode/src/api/lib/pagination-cursor.ts b/packages/vitnode/src/api/lib/pagination-cursor.ts index d2fcd19cb..3e96f681d 100644 --- a/packages/vitnode/src/api/lib/pagination-cursor.ts +++ b/packages/vitnode/src/api/lib/pagination-cursor.ts @@ -2,32 +2,6 @@ import type { PgColumn } from "drizzle-orm/pg-core"; import { HTTPException } from "hono/http-exception"; -/** - * The opaque cursor a paginated list hands out, and takes back. - * - * Two properties, and both of them are load-bearing. - * - * **It is the ordered tuple.** A cursor has to describe a position in an - * ordering, and an ordering is `(orderColumn, id)` - so the cursor is that pair. - * An identifier on its own is only a position when the list is ordered by the - * identifier; for any other column it names a row whose place in the sequence - * nobody knows. - * - * **It is self-contained.** The value it carries *is* the boundary, and nothing - * re-reads the row it came from. That is the difference between a cursor and a - * pointer: a cursor is the position as it stood when the page was generated, and - * editing or deleting the row that happened to sit on the boundary must not move - * it. Re-reading would mean an edit to one row silently skips every row the - * ordering used to have between the old position and the new one. - * - * The wire form is `base64url(JSON)`: opaque, so no client starts depending on - * the shape, and self-describing, so a cursor minted for one order column is - * refused by a request that has since changed to another. - * - * It is **not signed**, so every field is treated as hostile input and validated - * against the column it claims to describe - see {@link cursorValueForColumn}. - */ - /** What an order column's value can be, once it has been through JSON. */ export type PaginationCursorValue = boolean | null | number | string; @@ -40,14 +14,6 @@ export interface PaginationCursor { value: PaginationCursorValue; } -/** - * How one column's values travel in a cursor. - * - * Named per kind rather than inferred, because "how do I serialise this" and - * "what am I willing to accept back" are the same question asked twice, and - * answering it in one place is what stops the second answer being looser than - * the first. - */ type CursorKind = "bigint" | "boolean" | "number" | "string" | "temporal"; const KIND_BY_DATA_TYPE: Record<string, CursorKind> = { @@ -58,27 +24,9 @@ const KIND_BY_DATA_TYPE: Record<string, CursorKind> = { string: "string", }; -/** - * The column's data type with Drizzle's refinement dropped. - * - * Drizzle v1 reports a `ColumnType` as `"<base> <constraint>"` - a `serial` is - * `number int32`, a `bigint` is `bigint int64`, an enum `varchar` is - * `string enum`. The cursor only cares which of the five kinds above it is - * dealing with, and the constraint is exactly the part that does not change - * that, so it is cut off rather than enumerated. - */ const baseDataTypeOf = (column: PgColumn): string => column.dataType.split(" ")[0]; -/** - * Whether the column holds an array. - * - * Checked explicitly because v1 reports an array by its **element** type plus a - * dimension count - a `text[]` column is `string` with `dimensions: 1`, not the - * `array` v0 reported. Without this the element type would look perfectly - * sortable, and a cursor would be minted from a value Postgres cannot compare - * with `>`. - */ const isArrayColumn = (column: PgColumn): boolean => column.dimensions > 0; const badRequest = (message: string): HTTPException => @@ -87,15 +35,6 @@ const badRequest = (message: string): HTTPException => /** The one message a tampered or stale cursor ever produces. */ const INVALID_CURSOR = "Invalid pagination cursor."; -/** - * The three shapes a temporal value comes in, keyed by what Postgres will parse. - * - * Classified from the **SQL** type rather than the JavaScript one, because the - * two disagree in exactly the case that matters: `date()` and `time()` hand back - * plain strings, so `dataType` calls them `"string"` - and a string cursor bound - * straight into `column > $1` would reach Postgres as `'nonsense'::date` and - * come back as a 500 rather than a 400. - */ type TemporalType = "date" | "time" | "timestamp"; const temporalTypeOf = (column: PgColumn): null | TemporalType => { @@ -112,13 +51,6 @@ const temporalTypeOf = (column: PgColumn): null | TemporalType => { const hasTimeZone = (column: PgColumn): boolean => column.getSQLType().toLowerCase().includes("with time zone"); -/** - * Whether a column can be paged through at all. - * - * A `json`, `array` or custom column has no total order Postgres and JavaScript - * agree on, so a cursor over one would be a value the next page cannot compare - * against. Refused rather than approximated. - */ export const isCursorSortableColumn = (column: PgColumn): boolean => !isArrayColumn(column) && (temporalTypeOf(column) !== null || @@ -139,29 +71,6 @@ const kindOf = (column: PgColumn): CursorKind => { return kind; }; -/** - * The grammar of a Postgres temporal value, as `::text` renders it. - * - * One pattern per SQL type, because a `date` column and a `timestamp` column do - * not accept the same strings and pretending they do is how a cursor for one - * ends up being parsed as the other: - * - * | SQL type | accepted | - * | --------------------------- | ------------------------------------------- | - * | `date` | `2026-08-09` | - * | `time` | `10:00:00`, `10:00:00.123456` | - * | `time with time zone` | the above, optionally `+02` / `Z` | - * | `timestamp` | a date, optionally a time, optionally a zone | - * | `timestamp with time zone` | the same, and that is what `::text` writes | - * - * A `T` separator and a `Z` designator are accepted alongside the space-and- - * offset form Postgres writes, because a JavaScript `Date` is the one input this - * module takes that has no database text behind it. - * - * Matching the shape is only half of it. These patterns cannot tell `2026-02-30` - * from `2026-02-28`, so every capture is range-checked afterwards - see - * {@link isRealTemporal}. - */ const TEMPORAL_GRAMMAR: Record<TemporalType, RegExp> = { date: /^(?<year>\d{4,6})-(?<month>\d{2})-(?<day>\d{2})$/, time: /^(?<hour>\d{2}):(?<minute>\d{2}):(?<second>\d{2})(?:\.\d{1,6})?(?<zone>.*)$/, @@ -169,12 +78,6 @@ const TEMPORAL_GRAMMAR: Record<TemporalType, RegExp> = { /^(?<year>\d{4,6})-(?<month>\d{2})-(?<day>\d{2})(?:[ T](?<hour>\d{2}):(?<minute>\d{2}):(?<second>\d{2})(?:\.\d{1,6})?(?<zone>.*))?$/, }; -/** - * Whatever the trailing group swallowed, checked rather than trusted. - * - * `(?<zone>.*)` is deliberately greedy: it catches a seventh fractional digit, - * an era suffix and `OR 1=1` alike, and hands all of them here to be refused. - */ type TemporalParts = Partial< Record< "day" | "hour" | "minute" | "month" | "second" | "year" | "zone", @@ -204,18 +107,6 @@ const isRealZone = (raw: string, allowed: boolean): boolean => { return Number(hours) <= 15 && Number(minutes) <= 59 && Number(seconds) <= 59; }; -/** - * Whether a shaped temporal string is a moment that exists. - * - * The reason a pattern is not enough: `2026-02-30`, `2025-02-29`, `2026-13-01` - * and `2026-08-09 23:60:00` all match the shape and all make Postgres raise - * `invalid input syntax`, which is a 500 arriving from a query string. Every one - * of them is refused here instead, before anything is bound. - * - * Deliberately stricter than Postgres in one place: Postgres reads `24:00:00` as - * the following midnight, but its own `::text` never writes it, so a cursor - * carrying one did not come from a row. - */ const isRealTemporal = ( column: PgColumn, temporal: TemporalType, @@ -255,15 +146,6 @@ const DECIMAL_INTEGER = /^-?\d+$/; const pad = (value: number, width = 2): string => String(value).padStart(width, "0"); -/** - * A `Date` written the way Postgres writes the column it belongs to. - * - * Only reachable when a caller mints a cursor from a value it already holds - * rather than from a row - the paginated path selects `::text` and never sees a - * `Date`. Even so it goes through the same grammar as everything else, so a - * minted cursor and an accepted cursor can never disagree about what a value - * looks like. - */ const canonicalFromDate = (column: PgColumn, value: Date): string => { const time = value.getTime(); if (!Number.isFinite(time)) { diff --git a/packages/vitnode/src/api/lib/permission-staff.ts b/packages/vitnode/src/api/lib/permission-staff.ts index c8f06b516..65d9a952e 100644 --- a/packages/vitnode/src/api/lib/permission-staff.ts +++ b/packages/vitnode/src/api/lib/permission-staff.ts @@ -1,20 +1,3 @@ -/** - * Plugin-based staff permission catalog. - * - * A plugin declares, in its API config, which permissions exist for moderators - * and for admins. Permissions are grouped by module string, mirroring the way a - * plugin is split into modules elsewhere in the API. - * - * The i18n label for each permission lives in the plugin's locale file under the - * flat top-level key `{pluginId}:{module}:{permission}` (e.g. - * `@vitnode/blog:posts:can_delete`). - * - * A permission may be declared as a plain string, or as an object that lists the - * other permissions (in the same module) it `dependsOn`. The staff form only - * shows a permission once every permission it depends on is enabled - e.g. - * `{ permission: "can_create", dependsOn: ["can_view"] }` stays hidden until - * `can_view` is on. - */ export type PermissionStaffEntryInput = string | { dependsOn?: string[]; permission: string }; @@ -26,11 +9,6 @@ export type PermissionStaffModulesInput = Record< PermissionStaffEntryInput[] >; -/** - * A permission entry after normalization - the shape carried on the request - * context and returned by the permission catalog. `dependsOn` is always an - * array (empty when the permission has no prerequisites). - */ export interface PermissionStaffEntry { dependsOn: string[]; permission: string; @@ -45,11 +23,6 @@ export interface PermissionStaffConfig { export type PermissionStaffType = "admin" | "moderator"; -/** - * Normalizes the author-facing module map into the canonical - * `{ permission, dependsOn }` shape, so every downstream reader deals with a - * single shape regardless of how the plugin declared it. - */ export const normalizePermissionStaffModules = ( modules: PermissionStaffModulesInput = {}, ): PermissionStaffModules => @@ -64,12 +37,6 @@ export const normalizePermissionStaffModules = ( ]), ); -/** - * A single granted permission stored against a staff entry. A staff entry's - * `unrestricted` column grants every permission for the staff type (and any - * added in the future); when `false`, only the entries in its `permissions` - * column apply. - */ export interface PermissionsStaffArgs { module: string; permission: string; diff --git a/packages/vitnode/src/api/lib/plugin.ts b/packages/vitnode/src/api/lib/plugin.ts index 3571171d4..10bc52709 100644 --- a/packages/vitnode/src/api/lib/plugin.ts +++ b/packages/vitnode/src/api/lib/plugin.ts @@ -44,12 +44,6 @@ export function buildApiPlugin<P extends string>({ permissionStaff, searchIndexers, }: { - /** - * The plugin's *server* strings - the ones emails and other server-rendered - * responses use - usually `import messages from "./locales/api"`. Kept apart - * from the frontend tree in `config.tsx` so an API-only app never loads admin - * UI copy. Omit it when the plugin renders nothing server-side. - */ messages?: LocaleMessagesMap; modules?: BuildModuleReturn<P, string>[]; permissionStaff?: PermissionStaffConfig; diff --git a/packages/vitnode/src/api/lib/response-cache-headers.test.ts b/packages/vitnode/src/api/lib/response-cache-headers.test.ts index 410b526a0..d72e19cbc 100644 --- a/packages/vitnode/src/api/lib/response-cache-headers.test.ts +++ b/packages/vitnode/src/api/lib/response-cache-headers.test.ts @@ -4,29 +4,6 @@ import { dirname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -/** - * What VitNode's HTTP responses tell a browser or a CDN about caching. - * - * The API is a Hono app that answers with a visitor's session, an - * administrator's screens, a plugin's domain data and a public content feed from - * the same origin, over the same paths. There is exactly one safe default for - * that shape: **say nothing, and let no shared cache think it may keep a copy.** - * - * So the rule this file pins is not "cache the public reads well". It is the - * narrower and more important one: - * - * - No response may carry a directive that lets a *shared* cache store it - - * `public`, `s-maxage`, a positive `max-age` - unless somebody adds it here - * deliberately, with the response's privacy in front of them. - * - No middleware may set a cache directive at all. A blanket policy over - * `/api` is how an authenticated response ends up in a CDN: it is applied by - * path, and privacy is not a property of a path. - * - * A response with no `Cache-Control` is still heuristically cacheable by an - * intermediary in principle, which is why the one route that *can* return an - * unpublished record says `private, no-store` out loud rather than relying on - * the absence of a header. - */ const here = dirname(fileURLToPath(import.meta.url)); const sourceRoot = resolve(here, "../.."); @@ -117,14 +94,6 @@ const cacheControlValues = (path: string): string[] => { const mentionsCacheControl = (path: string): boolean => /[Cc]ache-[Cc]ontrol/.test(codeOf(path)); -/** - * Whether a directive lets a cache that serves more than one person keep a copy. - * - * The predicate the whole file turns on, written out rather than eyeballed. - * `private` and `no-store` are the two that settle it in the safe direction; - * anything with a positive freshness lifetime and no `private` is storable by a - * proxy, and `public` says so outright. - */ const isSharedCacheable = (value: string): boolean => { const directives = value .toLowerCase() @@ -142,19 +111,6 @@ const isSharedCacheable = (value: string): boolean => { }); }; -/** - * Every response header this package sets deliberately, and why. - * - * Two entries, and both say the same thing for the same reason: - * - * - `content/server/public-routes.ts` - the one public read that can return an - * unpublished record to whoever is allowed to preview it. - * - `tanstack/start/document-headers.ts` - every rendered document, which - * carries a dehydrated Query cache holding the visitor's session. - * - * Adding a third is a decision about who may store a response, so it belongs in - * a diff somebody reads rather than in a route nobody re-reads. - */ const DECLARED = { "content/server/public-routes.ts": ["private, no-store"], "tanstack/start/document-headers.ts": ["private, no-store"], @@ -233,15 +189,6 @@ describe("no response is offered to a shared cache", () => { }); describe("no middleware applies a cache policy by path", () => { - /** - * The directories a blanket policy would be written in. - * - * `api/middlewares` is where a Hono middleware goes, `api/lib/route.ts` is the - * builder every route is registered through, and `api/config.ts` is where the - * app is assembled. A `Cache-Control` in any of the three would apply to - * responses whose privacy nobody looked at - including the session read, the - * AdminCP screens and a plugin's private data. - */ const surfaces = () => runtimeSources().filter(path => { const entry = asEntry(path); diff --git a/packages/vitnode/src/api/lib/staff-permission-cache.test.ts b/packages/vitnode/src/api/lib/staff-permission-cache.test.ts index 56b0aaab8..01440c0ff 100644 --- a/packages/vitnode/src/api/lib/staff-permission-cache.test.ts +++ b/packages/vitnode/src/api/lib/staff-permission-cache.test.ts @@ -14,13 +14,6 @@ import { writeStaffPermissions, } from "./staff-permission-cache"; -/** - * The slice of `CacheModel` this module uses, over a Map. - * - * `configured: false` reproduces a deployment without Redis, where every read - * misses and every write is dropped - the case that has to keep working, since - * Redis is optional. - */ const fakeCache = ({ configured = true }: { configured?: boolean } = {}) => { const store = new Map<string, string>(); @@ -45,13 +38,6 @@ const fakeCache = ({ configured = true }: { configured?: boolean } = {}) => { }; }; -/** - * A `db` whose `select()` chain hands back the next queued result set. - * - * `resolveStaffPermissions` runs three selects in a fixed order - secondary - * roles, root roles, staff entries - so a queue is enough to drive it, and - * `selects` is what the assertions about cache hits actually count. - */ const fakeDb = (results: unknown[][]) => { let index = 0; const state = { selects: 0 }; diff --git a/packages/vitnode/src/api/lib/staff-permission-cache.ts b/packages/vitnode/src/api/lib/staff-permission-cache.ts index 4dd8e7080..dcde84eef 100644 --- a/packages/vitnode/src/api/lib/staff-permission-cache.ts +++ b/packages/vitnode/src/api/lib/staff-permission-cache.ts @@ -7,46 +7,15 @@ import type { StaffPermissionSet, } from "./permission-staff"; -/** - * How long one resolved permission set is trusted. - * - * The same 60 seconds the [session cache](../models/session-cache.ts) uses, and - * for the same reason: it is a ceiling on how stale a permission check can be if - * something changed the answer without going through one of the invalidation - * points below. Every route that *does* change the answer expires the cache - * itself, so the TTL is a backstop rather than the mechanism. - */ export const STAFF_PERMISSIONS_CACHE_TTL_SECONDS = 60; -/** - * The generation stamp every permission key is built from. - * - * Lives in the framework system namespace and is written with **no TTL** - it is - * the thing that has to outlive the entries it names. - */ const EPOCH_KEY = "staff-permissions:epoch"; const STAFF_TYPES = ["admin", "moderator"] as const; -/** - * Reads the current generation, or the one every deployment starts on. - * - * A miss is not an error: it means nothing has invalidated the cache yet - or - * Redis is not configured, in which case nothing is cached either and the key - * this builds is never read from anything. - */ const readEpoch = async (c: Context): Promise<string> => (await c.get("cache").getSystem<string>(EPOCH_KEY)) ?? "0"; -/** - * Where one user's resolved permission set of one kind lives. - * - * In the **system** namespace rather than the per-plugin one, because the read - * and the write can happen under different plugins: a moderator permission check - * runs inside whichever plugin's route asked for it, while the AdminCP routes - * that invalidate it are core's. A plugin-namespaced key would let a plugin's - * cached copy survive the change that was supposed to expire it. - */ const permissionsKey = ( epoch: string, { type, userId }: { type: PermissionStaffType; userId: number }, diff --git a/packages/vitnode/src/api/lib/websocket.ts b/packages/vitnode/src/api/lib/websocket.ts index e60ed6b27..47d537dd9 100644 --- a/packages/vitnode/src/api/lib/websocket.ts +++ b/packages/vitnode/src/api/lib/websocket.ts @@ -5,20 +5,8 @@ import type { EnvVitNode } from "../middlewares/global.middleware"; export { getWebSocketId } from "@/ws/types"; -/** - * WebSocket context for controlling the connection. Use it to `send`, `close` - * or read the `readyState`. The underlying runtime socket is exposed on - * `ws.raw` and is left generic so the same handler works across runtimes - * (node.js `ws`, Bun, ...). - */ export type VitNodeWSContext = WSContext; -/** - * Arguments passed to a registered WebSocket's `onMessage` handler. - * - * - `TReceive` - the payload the client sends for this socket. - * - `TSend` - the payload the server sends back to the client. - */ export interface VitNodeWSMessageParams<TReceive = unknown, TSend = unknown> { /** Hono request context - reach the db, the user, the logger, etc. */ c: Context<EnvVitNode>; @@ -30,11 +18,6 @@ export interface VitNodeWSMessageParams<TReceive = unknown, TSend = unknown> { ws: VitNodeWSContext; } -/** - * A registered WebSocket. The generic argument types are only used at the - * definition site (via {@link buildWebSocket}); once stored they are erased so - * sockets with different message shapes can live in the same array. - */ export interface BuildWebSocketReturn { description?: string; /** @@ -45,11 +28,6 @@ export interface BuildWebSocketReturn { onMessage: (params: VitNodeWSMessageParams) => Promise<void> | void; } -/** - * A registered WebSocket together with the plugin and module it belongs to. - * The `module` is filled in when the module is built and the `pluginId` when - * the plugin is built, mirroring how cron jobs are wired up. - */ export interface WebSocketConfig extends BuildWebSocketReturn { module: string; pluginId: string; diff --git a/packages/vitnode/src/api/lib/with-pagination.ts b/packages/vitnode/src/api/lib/with-pagination.ts index 29c9d4933..e8c3de419 100644 --- a/packages/vitnode/src/api/lib/with-pagination.ts +++ b/packages/vitnode/src/api/lib/with-pagination.ts @@ -39,17 +39,6 @@ import { /** Nobody may ask for more than this in one page, whatever they send. */ const MAX_PAGE_SIZE = 100; -/** - * The column a page query carries purely so its rows can be turned into cursors. - * - * Selected by the **same statement** that returns the rows, and removed again - * before anything leaves this module. It exists because a cursor has to describe - * the position the returned row actually occupied, and the only way to be - * certain of that is to read the two out of one snapshot. - * - * Prefixed so it cannot collide with a column name, and stripped rather than - * documented, because it is pagination's business and nobody else's. - */ export const PAGINATION_CURSOR_FIELD = "__cursorValue"; /** What a page query must spread into its projection. */ @@ -58,15 +47,6 @@ export type PaginationCursorSelection = Record< PgColumn | SQL<string> >; -/** - * Reads `first`, `last` and `cursor`, or refuses the request with a 400. - * - * Refusing rather than repairing is the change worth noting. `first=0` used to - * clamp its way into a one-row page that reported `hasNextPage: true`, and - * `first=abc` became `NaN` and fell through to the default page size - both of - * them a request nobody made, answered as if they had. Every one of these is now - * a stable 400, and the route schema rejects most of them a step earlier. - */ function parsePaginationParams(params: { query: { cursor?: string; first?: string; last?: string }; }): { cursor?: string; first?: number; last?: number } { @@ -97,14 +77,6 @@ function parsePaginationParams(params: { return { cursor: cursor === "" ? undefined : cursor, first, last }; } -/** - * Which way the rows really come back. - * - * Backward pagination runs the query in reverse and flips the page afterwards, - * so the *effective* SQL direction is not the one the caller asked for - and - * the cursor predicate has to describe the effective one, or it would be reading - * a sequence the `ORDER BY` is not producing. - */ function effectiveDirection( isForward: boolean, order: "asc" | "desc", @@ -114,43 +86,12 @@ function effectiveDirection( return order === "asc" ? "desc" : "asc"; } -/** - * `and`/`or` given at least one defined condition always produce SQL. - * - * Stated as a check rather than a non-null assertion: the assertion would be a - * claim about code somewhere else, and this is a claim about the two lines above - * it - which is the kind that stays true. - */ function required(value: SQL | undefined): SQL { if (!value) throw new Error("Expected a pagination condition."); return value; } -/** - * "Strictly after this position, in this direction." - * - * The whole keyset, written out. `(column, id)` is the ordered tuple, so the - * predicate is the tuple comparison - not a comparison of one half of it: - * - * ```sql - * column > :value OR (column = :value AND id > :id) -- ascending - * column < :value OR (column = :value AND id < :id) -- descending - * ``` - * - * `:value` comes from the **cursor** and nowhere else. That is the invariant a - * cursor exists to provide: it is the position as it stood when the page was - * generated, so editing the row that happened to sit on the boundary must not - * move it. Reading the row's current value instead would mean one edit silently - * skips every row the ordering used to have between the old position and the - * new one - and deleting it would leave no position at all. - * - * The `NULL` branches are the part that is easy to get wrong. Postgres sorts - * `NULLS LAST` for `ASC` and `NULLS FIRST` for `DESC`, and `column > NULL` is - * `NULL` rather than true - so a nullable order column needs the null block - * named explicitly, or a page boundary landing on it would end the walk early - * and silently. - */ function buildCursorCondition({ column, cursor, @@ -201,22 +142,6 @@ function buildCursorCondition({ ); } -/** - * The cursor's own value, bound so Postgres compares it at full precision. - * - * Two shapes, because two kinds of value survive a round trip differently: - * - * - a **temporal** value travels as the database's own `::text` and is bound - * back with an explicit cast, so Postgres parses the microseconds it wrote. - * Binding a JavaScript `Date` here would silently truncate to milliseconds and - * exclude the whole millisecond the cursor came from. - * - **everything else** - a number, a string, a boolean, a bigint - is exact in - * JavaScript already, so it goes through the column's own encoder. - * - * `getSQLType()` is derived from the schema rather than from the request, which - * is what makes `sql.raw` safe here; the value itself is always a bound - * parameter. - */ function boundaryValue(column: PgColumn, cursor: PaginationCursor): SQL { const value = cursorValueForColumn(column, cursor.value); @@ -532,19 +457,6 @@ export const zodPaginationPageInfo = z.object({ endCursor: z.string().nullable(), }); -/** - * The pagination half of a list route's query, validated at the edge. - * - * Every rule that can be stated here is stated here rather than left to the - * internals, so a bad page size is a 400 from the route's own contract - and - * appears in the OpenAPI document - instead of something the handler discovers - * later. `parsePaginationParams` re-checks all of it, because a service can be - * called directly and a plugin can build a route without this schema. - * - * The cursor is only shape-checked here: it is opaque, so "looks like a cursor" - * is all a request schema can honestly say. Whether it decodes, and whether it - * belongs to *this* ordering, is decided where the ordering is known. - */ export const zodPaginationQuery = z .object({ cursor: z diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index cdf2f15d6..5a0cab439 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -95,21 +95,9 @@ export interface EnvVariablesVitNode { ssoAdapters: SSOApiPlugin[]; }; captcha?: Pick<VitNodeApiConfig, "captcha">["captcha"]; - /** - * Every registered content type's *model*, with the plugin that owns it. - * - * Background work has only a content type id to go on - a queue handler - * runs in a cron request with no plugin context at all - so the lookup from - * id to table, service and owner has to live somewhere it can reach. - */ + contentModels: RegisteredContentModel[]; - /** - * Signs content preview links. - * - * Generated by the install and stored in `core_secrets`. Absent only when - * no content type has `editorial.preview` enabled, i.e. when there is - * nothing to sign. - */ + contentPreviewSecret?: string; /** Web origins the background cache bridge posts to. */ contentRevalidateOrigins?: string[]; diff --git a/packages/vitnode/src/api/models/ai.ts b/packages/vitnode/src/api/models/ai.ts index bdba8b8fa..dfdab2f49 100644 --- a/packages/vitnode/src/api/models/ai.ts +++ b/packages/vitnode/src/api/models/ai.ts @@ -3,13 +3,6 @@ import type { Context } from "hono"; import { HTTPException } from "hono/http-exception"; -/** - * A language model registered in `buildApiConfig({ ai: { models } })`. `model` - * is any AI SDK `LanguageModel` - a Gateway model id string (e.g. - * `"anthropic/claude-sonnet-5"`) or a provider instance (e.g. - * `anthropic("claude-sonnet-5")`). `id` is the short name you select it by; - * `name` is a human-readable label (surfaced to the client via the session API). - */ export interface AIModelDefinition { id: string; model: LanguageModel; @@ -35,11 +28,7 @@ export interface AIConfig { embeddingModels?: AIEmbeddingModelDefinition[]; /** Image models, resolved with `c.get("ai").imageModel(id?)`. */ imageModels?: AIImageModelDefinition[]; - /** - * The language models available to the app, resolved with - * `c.get("ai").model(id?)`. The **first** entry is the default (used when no - * id is passed). At least one is required. - */ + models: AIModelDefinition[]; } @@ -54,24 +43,6 @@ export interface AIPublicModel { const toModelId = (model: string | { modelId: string }): string => typeof model === "string" ? model : model.modelId; -/** - * Model registry for the Vercel AI SDK. Instantiated per request and reached in - * any route via `c.get("ai")`. - * - * It does **not** wrap the SDK - you call the native `ai` functions and pass a - * resolved model in via `model`: - * - * ```ts - * import { generateText } from "ai"; - * - * const { text } = await generateText({ - * model: c.get("ai").model(), // default (first) model - * prompt: "Write a haiku about databases.", - * }); - * ``` - * - * Pass an id to pick a specific model: `c.get("ai").model("fast")`. - */ export class AIModel { constructor(c: Context) { this.c = c; @@ -151,11 +122,6 @@ export class AIModel { return found.model; } - /** - * Serializable metadata for every configured language model (`id`, `name`, - * and the `model` id string). Safe to expose to the client - returns `[]` - * when AI is not configured. The first entry is the default. - */ models(): AIPublicModel[] { const ai = this.c.get("core").ai; diff --git a/packages/vitnode/src/api/models/events.ts b/packages/vitnode/src/api/models/events.ts index 96ad07aa2..8cfd2a911 100644 --- a/packages/vitnode/src/api/models/events.ts +++ b/packages/vitnode/src/api/models/events.ts @@ -4,21 +4,6 @@ import { randomUUID } from "node:crypto"; import type { EventListenerConfig } from "../lib/events"; -/** - * Global map of domain events emittable via `c.get("events").emit(...)`. Core - * events are declared here; plugins extend the map with module augmentation: - * - * ```ts - * declare module "@vitnode/core/api/models/events" { - * interface VitNodeEvents { - * "blog.post.created": { categoryId: number; postId: number }; - * } - * } - * ``` - * - * Payloads must stay JSON-serializable - a broker adapter (Redis Streams, - * NATS, ...) serializes the envelope to move it between processes. - */ export interface VitNodeEvents { "role.created": { roleId: number; @@ -81,42 +66,16 @@ export interface EventEmitResult { delivered: number; eventId: string; failures: EventEmitFailure[]; - /** - * `delivered` - listeners ran in-process before `emit()` resolved (the - * bundled Local adapter). `queued` - the envelope was handed to a broker and - * delivery happens out-of-band; `delivered`/`failures` say nothing about the - * eventual listener runs. - */ + status: "delivered" | "queued"; } -/** - * A pluggable event transport. The bundled Local adapter dispatches directly - * to the listeners registered in `c.get("core").events.listeners`; a broker - * adapter publishes the envelope and returns `status: "queued"`. - */ export interface EventsApiPlugin { name: string; publish: (c: Context, envelope: EventEnvelope) => Promise<EventEmitResult>; } export interface EventEmitOptions { - /** - * Who owns the *domain event*, when that is not the plugin handling the - * request. - * - * Ownership normally comes from `c.get("plugin")`, which is right for a route: - * whoever handled the request emitted the event. It is wrong for anything that - * runs on someone else's behalf. A queue handler is the clear case - core owns - * the handler, so the context says `@vitnode/core`, but a scheduled - * `content.example.article.published` is the example plugin's event and always - * was. - * - * Pass it explicitly rather than swapping `c.get("plugin")` for the duration. - * The context is shared with the logger, the permission checks and every other - * model on the request; impersonating a plugin inside it would change all of - * them to fix one field. - */ pluginId?: string; } @@ -131,19 +90,6 @@ export class EventsModel { return this.c.get("core").events.adapter; } - /** - * Emit a typed domain event. Never throws: listener failures are caught, - * logged to `core_logs`, and reported in the returned result. Emit only - * AFTER the writes the event describes have committed - after your awaited - * inserts/updates, and after any enclosing `db.transaction` callback has - * returned. - * - * **Not throwing is the contract, not an oversight.** An interactive mutation - * has already committed by the time this runs, and a listener that fell over - * is not a reason to tell the person their save failed. A caller that *does* - * need delivery to be retried - the scheduled-effects task is the one in - * core - reads `failures` and decides for itself. - */ async emit<K extends VitNodeEventName>( name: K, payload: VitNodeEvents[K], diff --git a/packages/vitnode/src/api/models/i18n.ts b/packages/vitnode/src/api/models/i18n.ts index e3eb98213..322af2bf4 100644 --- a/packages/vitnode/src/api/models/i18n.ts +++ b/packages/vitnode/src/api/models/i18n.ts @@ -9,18 +9,6 @@ import { negotiateLocale } from "@/lib/i18n/negotiate-locale"; export type Translator = ReturnType<typeof createTranslator>; -/** - * Server-side translations, reachable in any route as `c.get("i18n")`. - * - * ```ts - * const t = await c.get("i18n").getTranslator(); - * t("core.auth.reset_password.email.subject"); - * ``` - * - * Messages come straight from the installed packages - core, then each plugin, - * then the app's own overrides - and are memoised per locale, so after the - * first call this is a map lookup. - */ export class I18nModel { constructor(c: Context) { this.c = c; @@ -47,15 +35,6 @@ export class I18nModel { }); } - /** - * Picks the locale for this request: an explicit choice first, then the - * signed-in user's language, then `Accept-Language`, then the default. - * Anything the app does not list as a locale is skipped. - * - * This is the locale of the *caller*. When you are rendering for someone - * else - an email recipient, a queued job's target - use - * {@link resolveSupportedLocale} instead. - */ resolveLocale(explicit?: string): string { const supported = this.locales.map(locale => locale.code); @@ -70,15 +49,6 @@ export class I18nModel { ); } - /** - * Narrows a locale that belongs to the thing being rendered - a recipient's - * `core_users.language`, a subscriber's saved language - down to one the app - * ships, falling back to {@link defaultLocale}. - * - * Unlike {@link resolveLocale} this never reads the request. Dropping a - * language from `i18n.locales` must not make that user's mail arrive in - * whatever language the admin who triggered the send happens to browse in. - */ resolveSupportedLocale(preferred?: string): string { const supported = this.locales.map(locale => locale.code); diff --git a/packages/vitnode/src/api/models/queue.ts b/packages/vitnode/src/api/models/queue.ts index 7cd68b19c..6490f21d8 100644 --- a/packages/vitnode/src/api/models/queue.ts +++ b/packages/vitnode/src/api/models/queue.ts @@ -7,34 +7,14 @@ export interface QueueDispatchArgs { maxAttempts?: number; name: string; payload?: Record<string, unknown>; - /** - * Who owns the handler, when that is not the plugin handling the request. - * - * The worker resolves a handler by `` `${pluginId}:${name}` ``, so a task - * registered by core but dispatched from a plugin's route needs to say so - - * otherwise the row is stamped with the plugin's id and nothing will ever - * claim it. Defaults to the requesting plugin, which is right for the - * ordinary case where a plugin dispatches its own task. - */ + pluginId?: string; priority?: number; queue?: string; - /** - * Join an existing transaction instead of using the request handle. - * - * Needed whenever the row that the task refers to is written in the same - * unit of work: without it, the queue row can commit while the row it points - * at rolls back, and the task wakes up to find nothing there. - */ + tx?: Omit<Context["var"]["db"], "$client">; } -/** - * Enqueue background work into the database-backed task queue, exposed on the - * request context as `c.get("queue")`. Rows are drained by the `process-queue` - * cron job. The task `name` must match a handler registered via - * {@link buildQueueTask} in a module's `queueTasks`. - */ export class QueueModel { constructor(c: Context) { this.c = c; diff --git a/packages/vitnode/src/api/models/search.test-d.ts b/packages/vitnode/src/api/models/search.test-d.ts index 20234211b..9665e5577 100644 --- a/packages/vitnode/src/api/models/search.test-d.ts +++ b/packages/vitnode/src/api/models/search.test-d.ts @@ -1,5 +1,3 @@ -/* eslint-disable @typescript-eslint/no-deprecated -- Asserting that the - deprecated result shape still compiles is the point of this file. */ import { assertType, describe, expectTypeOf, it } from "vitest"; import type { ContentSearchIndexer } from "@/content/server"; diff --git a/packages/vitnode/src/api/models/search.test.ts b/packages/vitnode/src/api/models/search.test.ts index f9f9a034c..65d8b1ae9 100644 --- a/packages/vitnode/src/api/models/search.test.ts +++ b/packages/vitnode/src/api/models/search.test.ts @@ -276,16 +276,6 @@ describe("normalizeSearchIndexerPage", () => { }); }); -/** - * The one provider capability that is not a nicety. - * - * `delete(c, itemType, itemId, languageCode)` is a JavaScript call: a provider - * written before per-locale content accepts the fourth argument and drops it, so - * taking one translation down removes every language from that provider's store - * while the canonical `core_search_index` removes one. Nothing throws, nothing is - * logged, and the two disagree from then on - which is why the pairing is refused - * at boot instead of being discovered by whoever deletes a translation. - */ describe("assertSearchProviderCapabilities", () => { const legacy = (): SearchProviderApiPlugin => ({ ...createProvider(), @@ -378,13 +368,6 @@ describe("assertSearchProviderCapabilities", () => { }); }); -/** - * The provider half of a search diagnostic. - * - * `SearchModel.index` writes the canonical row and *then* hands the document to - * the provider, so the two can disagree - and a diagnostic that cannot ask the - * provider would report the canonical table's health as the whole story. - */ describe("provider diagnostics", () => { const modelFor = (provider: SearchProviderApiPlugin) => new SearchModel({ diff --git a/packages/vitnode/src/api/models/search.ts b/packages/vitnode/src/api/models/search.ts index 4d3db855b..b19227301 100644 --- a/packages/vitnode/src/api/models/search.ts +++ b/packages/vitnode/src/api/models/search.ts @@ -86,81 +86,29 @@ export interface SearchResult { export interface SearchProviderCapabilities { authorBoost: boolean; - /** - * Whether the provider's store **is** `core_search_index`. - * - * True only for the bundled Postgres provider, which queries the canonical - * table directly rather than mirroring it. Diagnostics use this to skip a - * second count of the same rows: canonical and provider are one storage, so - * asking twice would cost a query to learn something already known. - * - * A mirroring provider - anything with its own store - must leave it unset. - */ + canonicalStorage?: boolean; facets: boolean; - /** - * Whether {@link SearchProviderApiPlugin.delete} honours its `languageCode`. - * - * Declared rather than inferred, because JavaScript cannot tell the difference: - * a provider written as `delete(c, itemType, itemId)` accepts the fourth - * argument at runtime and silently ignores it, so taking down one translation - * would remove every language from that provider's store while the canonical - * `core_search_index` removed one. The two would then disagree forever, and - * nothing would say so. - * - * Optional, so a provider written before localized content still compiles and - * still serves single-language content. Absent means "no", and - * {@link assertSearchProviderCapabilities} refuses to boot an install that - * pairs such a provider with a localized searchable content type. - */ + languageScopedDelete?: boolean; timeDecay: boolean; } -/** - * One page of a rebuild. - * - * The two counts are separate on purpose. An indexer may emit several documents - * per item (one per language, say) or none at all (a row whose data cannot be - * projected), so a document count can never stand in for a source count - using - * it would either skip items or end the rebuild while rows remain. - */ export interface SearchIndexerPage { documents: SearchDocument[]; /** Source rows this page read. `0` means the source is exhausted. */ itemsRead: number; } -/** - * The pre-{@link SearchIndexerPage} result: documents with no source count. - * - * @deprecated Return a {@link SearchIndexerPage}. An array cannot say how many - * source rows produced it, so the rebuild has to assume a full page was read and - * wait for an empty one to stop - which means a page that reads rows and projects - * none of them (every row on it malformed, say) ends the rebuild early and the - * rows behind it are never indexed. Supported for now; removed in a future major - * release. - */ export type LegacySearchIndexerPage = SearchDocument[]; export type SearchIndexerLoadResult = // The one intentional use of the deprecated shape: this union is what keeps // pre-Stage-3 indexers compiling, so the lint rule has nothing to warn about // here. Every *other* reference should be flagged. - // eslint-disable-next-line @typescript-eslint/no-deprecated + LegacySearchIndexerPage | SearchIndexerPage; -/** - * Streams every existing item of one content type so the whole index can be - * rebuilt (e.g. after switching engines). - * - * `load` is called with `offset` advanced by the previous page's `itemsRead`. - * Report `itemsRead: 0` to end the rebuild; an empty `documents` array does not, - * because a page can legitimately read rows and project none of them. - * - * Returning a bare `SearchDocument[]` still works - see - * {@link LegacySearchIndexerPage} for what it gives up. - */ export interface SearchIndexer { // Total number of source items available to index for this type. Powers the // admin coverage report (indexed vs. total). Omit when the source count is @@ -174,13 +122,6 @@ export interface SearchIndexer { ) => Promise<SearchIndexerLoadResult>; } -/** - * A declared document owner, or `undefined` when there is not really one. - * - * `pluginId` is public input, so an empty or whitespace-only string is a missing - * owner rather than a collection named `""`. Every place that resolves ownership - * goes through this, so the fallback chains cannot drift apart. - */ export const searchDocumentOwner = ( pluginId: null | string | undefined, ): string | undefined => { @@ -189,16 +130,6 @@ export const searchDocumentOwner = ( return trimmed === "" ? undefined : trimmed; }; -/** - * Turns either `load` result into a page, so the rebuild has one shape to reason - * about and the compatibility rule lives in exactly one place. - * - * A non-empty legacy array reports `requestedLimit` rather than - * `documents.length`, because that is what the old rebuild advanced by: an - * indexer may emit several documents per source row (one per language), so a - * document count would skip rows on every page. An empty array is the only end - * signal it has. - */ export const normalizeSearchIndexerPage = ( result: SearchIndexerLoadResult, requestedLimit: number, @@ -215,18 +146,6 @@ export interface SearchIndexerConfig extends SearchIndexer { pluginId: string; } -/** - * Rejects two indexers claiming the same `itemType`. - * - * `itemType` is the index's only namespace, so a collision is not a cosmetic - * problem: both indexers would `load` on every rebuild, writing over each - * other's documents whenever their item ids overlap, and the admin coverage - * report would silently describe only the first one. Failing at boot is the only - * place this is cheap to notice. - * - * Called once per plugin by `buildApiPlugin` and again across every plugin by - * the global middleware, which is the only place that sees them all. - */ export const validateSearchIndexers = ( indexers: readonly SearchIndexerConfig[], ): SearchIndexerConfig[] => { @@ -246,24 +165,6 @@ export const validateSearchIndexers = ( return [...indexers]; }; -/** - * Refuses to boot a provider that cannot express what the installed content - * types need. - * - * Only one requirement so far, and it is narrow on purpose: a content type that - * is both localized and searchable is indexed once per published translation, so - * unpublishing or deleting one of them has to remove exactly one document. A - * provider that ignores `languageCode` would take every language out instead, and - * because the extra argument is simply dropped there is no error, no log line and - * no way to notice until somebody searches for content that should still be - * there. - * - * Fails at boot rather than at the delete for the obvious reason: the delete is - * the moment the damage happens, and by then the install has been running. - * - * Content types are passed as plain ids so this stays where the rest of the - * search contract lives, with no dependency on the Content Engine. - */ export const assertSearchProviderCapabilities = ( provider: SearchProviderApiPlugin, { diff --git a/packages/vitnode/src/api/models/session-cache.test.ts b/packages/vitnode/src/api/models/session-cache.test.ts index 8289429dd..46fb311b5 100644 --- a/packages/vitnode/src/api/models/session-cache.test.ts +++ b/packages/vitnode/src/api/models/session-cache.test.ts @@ -14,26 +14,6 @@ import { sessionCacheTtl, } from "./session-cache"; -/** - * The rules that make it safe to put a signed-in user in a **shared** cache. - * - * A session is the most private thing VitNode caches, and it is cached in Redis, - * which every instance of an install can read. Three properties are what keep - * that from being a leak, and none of them is visible from a call site: - * - * 1. **The key names one identity.** It is built from the hashed session token - * *and* the device the session belongs to, so no two sessions - and therefore - * no two people - can ever address the same entry. - * 2. **The two session kinds never meet.** A public session and an AdminCP - * session are different authorizations with different cookies and different - * lifetimes. A collision between them would hand one the other's answer. - * 3. **A cached user cannot outlive their session.** The TTL is capped by the - * session's own expiry, so a revocation that nobody remembered to invalidate - * still stops working on time. - * - * All three are pure arithmetic on strings and dates, which is why they are - * tested here rather than against Redis. - */ const here = dirname(fileURLToPath(import.meta.url)); const TOKEN_A = "a".repeat(64); @@ -129,16 +109,6 @@ describe("a cached user never outlives the session", () => { }); describe("both session models refuse to write a non-positive TTL", () => { - /** - * Asserted by reading the source, because the alternative is a database. - * - * This is the guard that turns the arithmetic above into a property of the - * cache. `CacheModel.setSystem` treats a falsy or negative TTL as "no - * expiration" and writes a **permanent** key - so an expired session written - * without this check would pin that user's record in Redis until something - * else deleted it. The check is one line at each call site and there is no - * type that can enforce it, so it is pinned here instead. - */ it.each(["session.ts", "session-admin.ts"])("%s guards the write", file => { const source = readFileSync(join(here, file), "utf8"); diff --git a/packages/vitnode/src/api/models/session-cookies.test.ts b/packages/vitnode/src/api/models/session-cookies.test.ts index ecf08453e..98abbb98b 100644 --- a/packages/vitnode/src/api/models/session-cookies.test.ts +++ b/packages/vitnode/src/api/models/session-cookies.test.ts @@ -40,13 +40,6 @@ const AUTHORIZATION: Authorization = { ssoAdapters: [], }; -/** - * A Drizzle stand-in: every builder method returns the same chainable object, - * and awaiting it hands back rows chosen by the operation and table. - * - * These tests are about the `Set-Cookie` a model emits, so the queries only have - * to resolve - not to be right. - */ const fakeDb = () => { const chain = (kind: string, table: unknown) => { const op = { kind, table }; diff --git a/packages/vitnode/src/api/models/storage.ts b/packages/vitnode/src/api/models/storage.ts index eede27005..6a91ebc9f 100644 --- a/packages/vitnode/src/api/models/storage.ts +++ b/packages/vitnode/src/api/models/storage.ts @@ -39,19 +39,6 @@ export interface StorageUploadResult { url: string; } -/** - * What {@link StorageModel.upload} returns: the adapter's result plus the - * `core_files` row it just created. - * - * The adapter still returns only `{ key, url }` - it stores bytes and knows - * nothing about the database - so this is a separate type rather than a widened - * one. `id` is what a caller needs to *reference* the file: a Content Engine - * file column holds it, and without it every upload route would have to look the - * row back up by key. - * - * `dimensions` is `null` for a non-image and for an image the pipeline did not - * measure (SVG and GIF are deliberately not re-encoded). - */ export interface StorageFileUploadResult extends StorageUploadResult { dimensions: null | { height: number; width: number }; id: number; @@ -61,25 +48,10 @@ export interface StorageFileUploadResult extends StorageUploadResult { size: number; } -/** - * Why {@link StorageModel.deleteFile} refused, and the body it refuses with. - * - * Defined in `@/lib/files/in-use` and re-exported here, so every existing - * importer keeps working. The definition had to move because the browser reads - * the same code off the same 409 - and importing it from this module dragged - * Hono, Drizzle and `@/database` into the client bundle behind one string. - */ export type { StorageFileInUseBody } from "@/lib/files/in-use"; export { STORAGE_FILE_IN_USE } from "@/lib/files/in-use"; export interface StorageDeleteFileOptions { - /** - * Drop the retained revisions' pins and delete the file anyway. - * - * Only ever gets past *history*. A live content reference is refused with or - * without it, because there is no version of "delete anyway" that leaves a - * published page unbroken. - */ force?: boolean; /** Scopes the delete to one user's own uploads. */ ownerId?: number; @@ -120,12 +92,7 @@ export interface StorageUploadOptions { maxBytes?: number; /** Extra data stored in the `core_files.metadata` JSON column. */ metadata?: Record<string, unknown>; - /** - * Owner recorded in `core_files.userId`. When omitted it defaults to the - * request's admin user (on admin routes), then the frontend session user, else - * null. Pass it explicitly - including `null` - to override, e.g. when an admin - * uploads on behalf of another user. - */ + userId?: null | number; } @@ -135,28 +102,10 @@ interface ProcessedImage { // New extension (incl. leading dot) when the format changed, else null. extension: null | string; mimeType: string; - /** - * Why the configured WebP conversion did not happen, when it was configured - * and did not - otherwise null. - * - * Recorded on the `core_files` row because the decision is otherwise invisible: - * an install with `storage.image.webp` whose library has one stray PNG among - * the WebPs looks like a bug until this says which rule spared it. - */ + skippedConversion: null | string; } -/** - * The image decoded, and then could not be re-encoded because of a **format - * limit rather than the bytes**: WebP allows at most 16383 pixels per side, and - * a 20000px-wide PNG is a perfectly valid PNG. - * - * Its own class so a caller can tell this apart from a broken file - the upload - * route answers `CONTENT_FILE_UNPROCESSABLE` for it instead of - * `CONTENT_FILE_INVALID`. The distinction is the whole point: "corrupt" sends - * somebody off to re-export an image that was never damaged, while naming the - * pixel limit sends them to resize it, which is the thing that works. - */ export class StorageImageUnprocessableError extends HTTPException { constructor(message: string) { super(400, { message }); @@ -171,13 +120,6 @@ const WEBP_MAX_SIDE = 16383; /** Marks a row whose WebP conversion was skipped for the reason below. */ const SKIPPED_WEBP_DIMENSIONS = "webp-dimension-limit"; -/** - * Whether WebP can hold an image this size at all. - * - * Unmeasured dimensions answer `false`: an image libvips could not size up is - * not one to pre-emptively give up converting, so the encoder stays the thing - * that decides. - */ const exceedsWebpLimit = ( dimensions: null | { height: number; width: number }, ): boolean => @@ -188,15 +130,6 @@ const exceedsWebpLimit = ( const imageFormatName = (mimeType: string): string => (mimeType.split("/")[1] ?? mimeType).toUpperCase(); -/** - * What sharp itself said, as a suffix - or nothing when it said nothing useful. - * - * libvips writes the actionable part of these ("Input buffer contains - * unsupported image format", "vipspng: libpng read error", "Input Buffer is - * empty"), and dropping it is what left an admin with a sentence that named no - * cause. Only the first line is kept, and it is capped, because the rest is a - * stack trace and this ends up in a form field. - */ const reasonSuffix = (error: unknown): string => { const first = error instanceof Error diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/clear-search.route.ts b/packages/vitnode/src/api/modules/admin/debug/routes/clear-search.route.ts index 404097a12..ccc8b33de 100644 --- a/packages/vitnode/src/api/modules/admin/debug/routes/clear-search.route.ts +++ b/packages/vitnode/src/api/modules/admin/debug/routes/clear-search.route.ts @@ -8,25 +8,6 @@ export const zodClearSearchSchema = z.object({ itemType: z.string().min(1), }); -/** - * Deletes the documents of one collection that has no registered rebuild - * indexer. - * - * Deliberately not part of `/search/rebuild`: this removes documents and puts - * nothing back, so it must not hide behind an action called "reindex". It is - * refused for a collection that *does* have an indexer - that one has a rebuild, - * which is the non-destructive way to get the same freshness. - * - * What it does **not** mean is that the collection is abandoned. Registering an - * indexer is optional, and a plugin that writes through `search.index()` keeps - * its collection current without one - so a cleared collection can reappear on - * that plugin's next write. This clears the current indexed state; it does not - * stop anything from writing again. - * - * `itemType` is required and non-empty, so there is no payload that clears the - * whole index by omission. A full rebuild is the only thing that does that, and - * it refills what it can. - */ export const clearSearchDebugAdminRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, adminStaffPermission: { module: "system", permission: "can_view" }, diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/content-status.route.ts b/packages/vitnode/src/api/modules/admin/debug/routes/content-status.route.ts index fe7c2ae66..aa326ce5c 100644 --- a/packages/vitnode/src/api/modules/admin/debug/routes/content-status.route.ts +++ b/packages/vitnode/src/api/modules/admin/debug/routes/content-status.route.ts @@ -44,12 +44,7 @@ const contentTypeSchema = z.object({ /** Why the provider could not be counted, when that is the answer. */ error: z.string().optional(), healthy: z.boolean().nullable(), - /** - * Every document the provider holds, in any locale. - * - * The guard against a document left behind in a locale the database no - * longer knows about, which per-locale counts can never ask for. - */ + indexedTotal: z.number().nullable(), name: z.string(), /** Whether the provider was actually asked. */ @@ -68,20 +63,6 @@ const contentTypeSchema = z.object({ .nullable(), }); -/** - * What the Content Engine looks like from the outside, right now. - * - * Sits beside `/search/status` under the same `system: can_view` permission, - * and answers the questions that one cannot: `/search/status` reports what is - * *in* the index, and this reports what the **database** says should be there. - * A collection can be 100% covered by the first and still be missing every - * Polish document, because coverage is measured against the indexer's own count - * and drift is measured against the rows. - * - * Aggregates only - two counts per content type - so it is safe to open on an - * install with a large table. Nothing here mutates anything; repairing drift is - * `/search/rebuild`, which is a separate decision and a separate route. - */ export const contentStatusDebugAdminRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, adminStaffPermission: { module: "system", permission: "can_view" }, diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/search-collections.route.test.ts b/packages/vitnode/src/api/modules/admin/debug/routes/search-collections.route.test.ts index 4f9bdc463..9a4e4ff3d 100644 --- a/packages/vitnode/src/api/modules/admin/debug/routes/search-collections.route.test.ts +++ b/packages/vitnode/src/api/modules/admin/debug/routes/search-collections.route.test.ts @@ -18,11 +18,6 @@ interface Dispatched { payload?: Record<string, unknown>; } -/** - * Both handlers are called directly, the way the queue-task tests call theirs: - * `Route.handler` is deliberately erased, and neither route needs Hono for - * anything but reading a body these tests can hand it outright. - */ const harness = ({ body, clearFails = false, diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/search-status.route.test.ts b/packages/vitnode/src/api/modules/admin/debug/routes/search-status.route.test.ts index 0dc884621..58b85e9ae 100644 --- a/packages/vitnode/src/api/modules/admin/debug/routes/search-status.route.test.ts +++ b/packages/vitnode/src/api/modules/admin/debug/routes/search-status.route.test.ts @@ -33,14 +33,6 @@ const indexer = ( pluginId, }); -/** - * The handler is called directly, the way the queue-task tests call theirs: it - * takes no request input, so routing it through Hono would only add a cast - - * `Route.handler` is deliberately erased to `(...args: unknown[])`. - * - * The stub answers the coverage query with the given rows and every later query - * (the sync-error panel) with nothing. - */ const harness = ({ indexers = [], rows = [], diff --git a/packages/vitnode/src/api/modules/admin/debug/routes/search-status.route.ts b/packages/vitnode/src/api/modules/admin/debug/routes/search-status.route.ts index 20229f4e6..8137d5f4b 100644 --- a/packages/vitnode/src/api/modules/admin/debug/routes/search-status.route.ts +++ b/packages/vitnode/src/api/modules/admin/debug/routes/search-status.route.ts @@ -21,29 +21,12 @@ const CONTENT_SEARCH_LOG_PREFIX = "[content-search]"; const SYNC_ERROR_LIMIT = 10; const collectionSchema = z.object({ - /** - * Index rows, counting one per language. - * - * Separate from `indexed`, which counts distinct items: multi-language content - * is indexed once per translation, so its coverage has to be measured in - * documents or a fully-indexed collection would read as 33%. - */ documents: z.number(), - /** - * Whether an indexer is registered for this item type *right now*. A stored - * plugin owner does not imply one: the plugin may be uninstalled, renamed, or - * simply not loaded in this process. - */ + hasIndexer: z.boolean(), indexed: z.number(), itemType: z.string(), - /** - * One entry per language present in the index, newest first by count. - * - * Empty for a collection that is entirely language-agnostic. It is what makes - * "Polish is missing 40 documents" visible at all - a single total cannot say - * which language a rebuild failed halfway through. - */ + languages: z.array( z.object({ documents: z.number(), diff --git a/packages/vitnode/src/api/modules/admin/files/routes/delete.route.ts b/packages/vitnode/src/api/modules/admin/files/routes/delete.route.ts index f045a4906..f1960dcc2 100644 --- a/packages/vitnode/src/api/modules/admin/files/routes/delete.route.ts +++ b/packages/vitnode/src/api/modules/admin/files/routes/delete.route.ts @@ -14,11 +14,7 @@ export const deleteFileAdminRoute = buildRoute({ params: z.object({ id: z.string().openapi({ example: "1" }), }), - /** - * Opt-in, and asked for only after a refusal has said what would be lost: - * `force` releases the retained revisions' pins, never a live content - * reference. - */ + query: z.object({ force: z.enum(["true", "false"]).optional(), }), diff --git a/packages/vitnode/src/api/modules/admin/roles/lib/assert-manage-admin-role.ts b/packages/vitnode/src/api/modules/admin/roles/lib/assert-manage-admin-role.ts index f0e2793dd..b4d504e20 100644 --- a/packages/vitnode/src/api/modules/admin/roles/lib/assert-manage-admin-role.ts +++ b/packages/vitnode/src/api/modules/admin/roles/lib/assert-manage-admin-role.ts @@ -6,15 +6,6 @@ import { assertStaffPermission } from "@/api/lib/check-staff-permission"; import { CONFIG_PLUGIN } from "@/config"; import { core_admin_permissions } from "@/database/admins"; -/** - * A role grants admin access when it has a row in `core_admin_permissions`. - * Editing or deleting such a role is a higher-privilege action, so it requires - * the elevated `_admin` variant of the permission on top of the base route - * guard - mirroring how editing an admin *user* requires `users:can_edit_admin` - * (see `assertCanAssignPrimaryRole`). - * - * Roles that do not grant admin access pass through untouched. - */ export const assertCanManageAdminRole = async ( c: Context, { diff --git a/packages/vitnode/src/api/modules/admin/users/lib/assert-edit-user-permission.ts b/packages/vitnode/src/api/modules/admin/users/lib/assert-edit-user-permission.ts index 36ecb7889..6946d0b8b 100644 --- a/packages/vitnode/src/api/modules/admin/users/lib/assert-edit-user-permission.ts +++ b/packages/vitnode/src/api/modules/admin/users/lib/assert-edit-user-permission.ts @@ -28,13 +28,6 @@ export const assertCanEditAdminTarget = async ( await assertCanEditAdmin(c); }; -/** - * A role grants admin access when it has a row in `core_admin_permissions`. - * Assigning such a role as a user's primary role turns that user into an admin - * (see `SessionAdminModel.checkIfUserIsAdmin`), so it must require the same - * `can_edit_admin` permission as editing an already-admin user - otherwise a - * `can_edit`-only admin could escalate a non-admin user into an admin. - */ export const assertCanAssignPrimaryRole = async ( c: Context, roleId: number, diff --git a/packages/vitnode/src/api/modules/content/content.module.ts b/packages/vitnode/src/api/modules/content/content.module.ts index 682152133..8c8196e1c 100644 --- a/packages/vitnode/src/api/modules/content/content.module.ts +++ b/packages/vitnode/src/api/modules/content/content.module.ts @@ -5,25 +5,6 @@ import { contentEditorialCleanupCron } from "./cron/content-editorial-cleanup.cr import { contentScheduleEffectsQueueTask } from "./tasks/content-schedule-effects.task"; import { contentScheduleQueueTask } from "./tasks/content-schedule.task"; -/** - * Core's own Content Engine module: the background half. - * - * It serves no routes. It exists because `queueTasks` and `cronJobs` are - * collected from **top-level** modules only, while `buildContentAdminModule` is - * nested inside a plugin's `admin` module - so a task registered there would be - * silently dropped, with no error and no handler. - * - * One task for every schedulable content type in the install, rather than one - * per type. The handler resolves the model from `c.get("core").contentModels`, - * so adding a content type adds no task, no name to collide with, and no - * registration to forget. - * - * Two tasks rather than one, because a scheduled publication is two units of - * work with two different failure meanings: `content-schedule` moves the - * database and either commits or does not, and `content-schedule-effects` - * announces what committed and can be retried on its own without ever - * republishing. - */ export const contentModule = buildModule({ pluginId: CONFIG_PLUGIN.pluginId, name: "content", diff --git a/packages/vitnode/src/api/modules/content/cron/content-editorial-cleanup.cron.ts b/packages/vitnode/src/api/modules/content/cron/content-editorial-cleanup.cron.ts index 3130c3358..30ac6ac20 100644 --- a/packages/vitnode/src/api/modules/content/cron/content-editorial-cleanup.cron.ts +++ b/packages/vitnode/src/api/modules/content/cron/content-editorial-cleanup.cron.ts @@ -8,19 +8,6 @@ import { pruneContentSchedules } from "@/content/server/schedules-model"; const DAY_MS = 24 * 60 * 60 * 1000; -/** - * Sweeps up editorial rows that no longer describe anything. - * - * Revision retention is enforced inline, in the same transaction as the write, - * so this is **not** the thing that keeps the table bounded on a healthy - * install - an install with no cron adapter must not grow forever, and it does - * not. What this handles is the case inline pruning structurally cannot: rows - * whose content type stopped existing, so nothing will ever write to them again - * and trigger a prune. - * - * Daily rather than hourly. Nothing here is urgent, and a plugin removed at - * lunchtime does not need its history gone by teatime. - */ export const contentEditorialCleanupCron = buildCron({ name: "content-editorial-cleanup", description: diff --git a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts index b76415c59..aefb1f722 100644 --- a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts +++ b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts @@ -18,15 +18,6 @@ export interface ContentScheduleOutcome { status: "executed" | "skipped" | "unregistered"; } -/** - * Thrown when a claimed schedule is no longer `pending` at settlement time. - * - * Structurally impossible: the row is locked `FOR UPDATE` from the claim to the - * commit, so nothing else can have moved it. If it ever happens the lock was - * not held, and rolling the whole transition back is the only safe answer - - * publishing a record whose schedule somebody cancelled is worse than not - * publishing it. - */ class ContentScheduleSettlementError extends Error { constructor(scheduleId: number) { super( diff --git a/packages/vitnode/src/api/modules/content/tasks/content-schedule-effects.task.ts b/packages/vitnode/src/api/modules/content/tasks/content-schedule-effects.task.ts index 4583100f7..a0fd46bbd 100644 --- a/packages/vitnode/src/api/modules/content/tasks/content-schedule-effects.task.ts +++ b/packages/vitnode/src/api/modules/content/tasks/content-schedule-effects.task.ts @@ -5,19 +5,6 @@ import { runContentScheduleEffects, } from "@/content/server/schedule-effects"; -/** - * Announces a scheduled transition that has already committed. - * - * Unlike `content-schedule`, the payload here **is** data rather than a pointer, - * and deliberately so: the record may have been edited again by the time this - * runs, and an event describing the record's current state would announce - * something other than the publication it is reporting. Everything travels - * frozen from the transaction that wrote it. - * - * Five attempts rather than three. The failures this retries are transient by - * nature - a search node restarting, a web app redeploying - and the backoff - * (10s, 20s, 40s, 80s) is a far better fit for those than for a deadlock. - */ export const contentScheduleEffectsQueueTask = buildQueueTask({ name: CONTENT_QUEUE_TASK_SCHEDULE_EFFECTS, description: diff --git a/packages/vitnode/src/api/modules/content/tasks/content-schedule.task.ts b/packages/vitnode/src/api/modules/content/tasks/content-schedule.task.ts index 206152bd4..23466627c 100644 --- a/packages/vitnode/src/api/modules/content/tasks/content-schedule.task.ts +++ b/packages/vitnode/src/api/modules/content/tasks/content-schedule.task.ts @@ -5,15 +5,6 @@ import { CONTENT_QUEUE_TASK_SCHEDULE } from "@/content/const"; import { executeContentSchedule } from "../helpers/execute-content-schedule"; -/** - * The payload is a **pointer**, not data. - * - * Everything that matters - which record, which action, whether it is still - * wanted - is re-read from the schedule row under a lock. A payload carrying - * the action would go stale the moment somebody rescheduled, and a payload - * carrying the item id would be a way to publish an arbitrary record by - * inserting a queue row. - */ export const contentSchedulePayloadSchema = z.object({ generation: z.number().int().positive(), scheduleId: z.number().int().positive(), diff --git a/packages/vitnode/src/api/modules/queue/helpers/process-queue-tasks.ts b/packages/vitnode/src/api/modules/queue/helpers/process-queue-tasks.ts index 2bd9fca7e..85c920a2d 100644 --- a/packages/vitnode/src/api/modules/queue/helpers/process-queue-tasks.ts +++ b/packages/vitnode/src/api/modules/queue/helpers/process-queue-tasks.ts @@ -12,12 +12,6 @@ const QUEUE_LOCK_KEY = "queue:process"; const QUEUE_LOCK_TTL_SECONDS = 55; const QUEUE_RETENTION_DAYS = 7; -/** - * Drain due queue tasks. Correctness comes from Postgres - * `FOR UPDATE SKIP LOCKED`, which lets many instances claim disjoint batches - * safely; the optional Redis lock (`c.get("cache").acquireLock`) is only an - * optimization so a single instance drains per tick when Redis is configured. - */ export const processQueueTasks = async ( c: Context<EnvVitNode>, ): Promise<void> => { diff --git a/packages/vitnode/src/api/modules/search/routes/search.route.ts b/packages/vitnode/src/api/modules/search/routes/search.route.ts index 0256d91ed..51a739bce 100644 --- a/packages/vitnode/src/api/modules/search/routes/search.route.ts +++ b/packages/vitnode/src/api/modules/search/routes/search.route.ts @@ -5,14 +5,6 @@ import { CONFIG_PLUGIN } from "@/config"; import { buildRoute } from "../../../lib/route"; import { zodPaginationQuery } from "../../../lib/with-pagination"; -/** - * The search index's own page info. - * - * Deliberately not `zodPaginationPageInfo`: that one describes a keyset walk - * over a table and hands out an opaque cursor for the ordered tuple. A search - * page is not that - a relevance-sorted one walks by offset and an ordinary one - * by row id - so it keeps the numeric cursors it has always had. - */ const zodSearchPageInfo = z.object({ totalCount: z.number(), count: z.number(), diff --git a/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx b/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx index 6511dc916..83011c9d8 100644 --- a/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx +++ b/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx @@ -13,16 +13,6 @@ import { AlertDialogTrigger, } from "../ui/alert-dialog"; -/** - * `React.lazy` rather than `next/dynamic`, which is what this used to be. - * - * The two are the same thing here - this is a client component, the import is - * already wrapped in the `<React.Suspense>` below, and `next/dynamic` defaults - * to server rendering the chunk - but only one of them resolves outside a - * Next.js app. Every confirm dialog in VitNode goes through this component, - * including the ones on the shared `/files` table, so that single import was - * enough to make the whole screen Next.js-only. - */ const ContentConfirmAction = React.lazy(async () => import("./content").then(module => ({ default: module.ContentConfirmAction, diff --git a/packages/vitnode/src/components/form/auto-form.tsx b/packages/vitnode/src/components/form/auto-form.tsx index e4745fd2d..22de6f58b 100644 --- a/packages/vitnode/src/components/form/auto-form.tsx +++ b/packages/vitnode/src/components/form/auto-form.tsx @@ -129,14 +129,6 @@ export const AutoFormSubmitButton = ({ ); }; -/** - * The `intent` of the submit button that fired this submission, if any. - * - * Read from the native `SubmitEvent.submitter`, which is the browser's own - * answer to "which button was it" - it is right for a click, for Enter in a - * field (the form's first submit button) and for a button that received focus - * and Space. - */ const submitIntentOf = ( event: React.BaseSyntheticEvent | undefined, ): string | undefined => { diff --git a/packages/vitnode/src/components/form/common/async-picker.tsx b/packages/vitnode/src/components/form/common/async-picker.tsx index 54ce53095..ca0718338 100644 --- a/packages/vitnode/src/components/form/common/async-picker.tsx +++ b/packages/vitnode/src/components/form/common/async-picker.tsx @@ -3,12 +3,6 @@ import { ChevronsUpDownIcon } from "lucide-react"; import React from "react"; import { useDebouncedCallback } from "use-debounce"; -/** - * `use-intl` rather than `next-intl`, and it is the same context either way: - * `next-intl` is built on `use-intl`, and `NextIntlClientProvider` provides into - * exactly this. Importing it directly is what lets a picker be rendered by a - * TanStack Start route, where there is no `next-intl` at all. - */ import { useTranslations } from "use-intl"; import { Button } from "@/components/ui/button"; diff --git a/packages/vitnode/src/components/form/fields/combobox.test-d.ts b/packages/vitnode/src/components/form/fields/combobox.test-d.ts index dccbc011f..0becfaeb7 100644 --- a/packages/vitnode/src/components/form/fields/combobox.test-d.ts +++ b/packages/vitnode/src/components/form/fields/combobox.test-d.ts @@ -4,34 +4,6 @@ import type { RawApiFetchArgs } from "@/lib/fetcher/raw"; import type { AutoFormCombobox } from "./combobox"; -/** - * Two traps closed at the type level, because a type is the only place either - * could have been closed without a rendering test. - * - * ## The combobox's cache key - * - * `queryKey` used to be optional, with a runtime fallback of - * `[id ?? "combobox", { search }]`. A picker whose author supplied neither prop - * therefore cached under the bare `["combobox", { search }]` - shared with every - * other picker in the application, unpartitioned by administrator, and *outside* - * `["vitnode","admin"]`, which is the one prefix a sign-out removes. One - * administrator's search results survived a sign-out and were served to the next - * person to sign in on that tab. It is the same failure the Content Engine's - * reference pickers had before they moved under the AdminCP root, and it is - * worth closing the same way: by construction. - * - * Every real caller passed a key already. Requiring it wherever `fetchData` is - * supplied is what stops the next one relying on the fallback. - * - * ## The fetcher's `options` - * - * `rawApiFetch` spreads `options` into the `fetch` init. `body` and `headers` - * were omitted from its type; `method` was not, so a caller could change what - * the call was built as. Harmless while nothing passed `options` for anything - * but `credentials` - and worth closing the moment `signal` started travelling - * through the same argument, because that is when callers start reaching for it. - */ - type ComboboxProps = React.ComponentProps<typeof AutoFormCombobox>; /** What `AutoForm` hands every field component. Not the subject of these tests. */ @@ -82,11 +54,6 @@ describe("an async combobox must name its cache key", () => { >().not.toExtend<ComboboxProps>(); }); - /** - * A synchronous combobox never fetches - its query is `enabled: false` - so it - * needs no key, and giving it one would suggest an entry that will never hold - * anything. - */ it("leaves a synchronous combobox alone", () => { expectTypeOf<FieldProps>().toExtend<ComboboxProps>(); expectTypeOf(fieldProps).toExtend<ComboboxProps>(); diff --git a/packages/vitnode/src/components/form/fields/combobox.tsx b/packages/vitnode/src/components/form/fields/combobox.tsx index 2285d0841..a90d8c085 100644 --- a/packages/vitnode/src/components/form/fields/combobox.tsx +++ b/packages/vitnode/src/components/form/fields/combobox.tsx @@ -32,33 +32,8 @@ type ComboboxFetchData = (params: { search: string; }) => ComboboxAsyncItem[] | Promise<ComboboxAsyncItem[]>; -/** - * Where a *synchronous* combobox's disabled query sits. - * - * Only ever reached when no `fetchData` was supplied, which is the case the - * props type below leaves without a `queryKey`. That query is `enabled: false`, - * so the entry never holds anything - but it is named after its own inertness - * rather than after a plausible `["combobox", …]` that a reader could mistake - * for a real cache root, and it is *not* re-keyed on the search term, so an - * option list nobody is fetching does not leave an entry per keystroke behind. - */ export const COMBOBOX_INERT_QUERY_KEY = "combobox:no-fetcher"; -/** - * The async half's `queryKey` is **required**, and that is the whole of it. - * - * It used to be optional with a fallback of `[id ?? "combobox", { search }]`, - * which meant a picker whose author forgot both props shared one cache entry - * with every other picker in the application - an entry outside `["vitnode", - * "admin"]`, which is the prefix a sign-out removes. Two administrators' search - * results in one key that nothing drops. Every real caller passed a key already; - * requiring it is what stops the next one relying on the trap. - * - * `id` stays required alongside it for the DOM, and the two are separate: `id` - * identifies the control, `queryKey` identifies the *answers*, and a picker - * offering categories caches under the categories rather than under the form it - * happens to sit in. - */ type AutoFormComboboxProps = ItemAutoFormComponentProps & Omit<React.ComponentProps<typeof Combobox>, "items" | "value"> & { className?: string; @@ -129,15 +104,7 @@ export const AutoFormCombobox = ({ return await fetchData({ search }); }, enabled: isAsync, - /** - * `retry: false`, the rule every AdminCP read follows. - * - * A picker's options come from the same admin API as the screen around it: a - * `403` is an authorization answer, and a `429` answered by sending the same - * search twice more is what the limiter asked the application to stop doing. - * A picker is also the one control where a retry is least useful - the - * reader is typing, and the next keystroke asks again anyway. - */ + retry: false, }); diff --git a/packages/vitnode/src/components/form/fields/file-gallery.test.tsx b/packages/vitnode/src/components/form/fields/file-gallery.test.tsx index 637fb1711..84be84f78 100644 --- a/packages/vitnode/src/components/form/fields/file-gallery.test.tsx +++ b/packages/vitnode/src/components/form/fields/file-gallery.test.tsx @@ -9,21 +9,6 @@ import type { FileGalleryRow } from "./file-gallery"; import { FileGallery, fileGalleryDrop } from "./file-gallery"; -/** - * What a person is offered, which is a different question from what the value - * holds. - * - * Two rules are asserted here and nowhere else, because both are about a control - * being present or absent rather than about a number moving: - * - * - **`ordered: false` has no handles.** The API stores that collection by - * ascending `core_files.id`, so a handle would appear to set an order the save - * then quietly normalises away - a control that lies about what it does. - * - **A file still uploading is not draggable.** It has no `core_files.id` to - * sort by and nothing to save; dragging it would be dragging something that - * does not exist yet. - */ - beforeAll(() => { // dnd-kit measures its droppables. jsdom has no ResizeObserver, and a missing // one is a thrown constructor rather than a degraded layout. diff --git a/packages/vitnode/src/components/form/fields/file-gallery.tsx b/packages/vitnode/src/components/form/fields/file-gallery.tsx index 3ec833d25..c3e832614 100644 --- a/packages/vitnode/src/components/form/fields/file-gallery.tsx +++ b/packages/vitnode/src/components/form/fields/file-gallery.tsx @@ -84,16 +84,6 @@ const RemoveAction = ({ ); }; -/** - * The identifiers a drop leaves behind, or `null` when nothing moved. - * - * Exported and separate from the context that calls it because it is the only - * part of a drag with a *value* in it: everything else - the sensors, the - * collision detection, the transform on the card - is how the gesture felt, and - * this is what it did. A drop outside the list, or onto the row it started from, - * changes nothing and must not write to the form: an `onChange` with the same - * array is still a dirty form and still an unsaved-changes prompt. - */ export const fileGalleryDrop = ( ids: readonly number[], { diff --git a/packages/vitnode/src/components/form/fields/file-order.test.ts b/packages/vitnode/src/components/form/fields/file-order.test.ts index 09c50c407..79ee9f317 100644 --- a/packages/vitnode/src/components/form/fields/file-order.test.ts +++ b/packages/vitnode/src/components/form/fields/file-order.test.ts @@ -9,17 +9,6 @@ import { removeFileId, } from "./file-order"; -/** - * The rule these three functions exist for: **a file's place is the place it was - * picked in.** - * - * The version before them appended each upload as it settled, so a gallery of - * ten stored the order the network happened to answer in - which nobody chose, - * nobody can predict, and which changes between two editors uploading the same - * ten files. Every case below is one half of that bug, or one of the two ways a - * person is allowed to override it afterwards. - */ - const queued = (order: number, name = `photo-${order}`): QueuedFileUpload => ({ name, order, diff --git a/packages/vitnode/src/components/form/fields/file-order.ts b/packages/vitnode/src/components/form/fields/file-order.ts index efd206362..0d69f9558 100644 --- a/packages/vitnode/src/components/form/fields/file-order.ts +++ b/packages/vitnode/src/components/form/fields/file-order.ts @@ -1,25 +1,3 @@ -/** - * Where a file lands in a collection, decided by **when it was picked** rather - * than when it uploaded. - * - * Ten files chosen at once are ten concurrent requests, and they finish in - * whatever order the network hands back - a 40 KB thumbnail beats a 4 MB - * photograph however they were listed in the dialog. Appending each one as it - * settles therefore stores the network's order, which nobody chose and nobody - * can predict, and a gallery whose first image changes depending on the wifi is - * not a gallery anybody built. - * - * So every queued upload carries a **slot**: a number handed out at pick time, - * monotonic across the whole session. The slot is what decides the position, and - * these functions are the two halves of using it - one places a file that has - * just landed, the other works out where the ones still in flight should be - * *shown* while they are. - * - * All of it is pure, and none of it holds the list. `field.value` is the source - * of truth (removing, reordering and resetting all happen there); a slot only - * ever says where a new identifier joins it. - */ - import { arrayMove } from "@dnd-kit/sortable"; /** One upload that has landed: the slot it was queued in, and what it stored. */ @@ -32,14 +10,7 @@ export interface PlacedFileUpload { /** One upload still in flight, as the list has to show it. */ export interface QueuedFileUpload { name: string; - /** - * The pick position - and this card's React identity. - * - * Monotonic and never reused, so it is safe as a key: an array index would - * make the second file inherit the first one's card the moment the first - * settles, and a name would collide the moment somebody picks `photo.jpg` - * from two folders. - */ + order: number; size: number; } @@ -48,37 +19,8 @@ export interface QueuedFileUpload { export type FileGalleryToken = { id: number; kind: "file" } | { kind: "pending"; order: number }; -/** - * The list a run of uploads appends *after*. - * - * An identifier rather than an index, because the list moves underneath a run - - * a person can remove a file, or drag one to the front, while five uploads are - * still going. `null` means the head of the list: the run began with nothing - * before it. - */ export type FileUploadAnchor = null | number; -/** - * The index a slot belongs at, given what is already in the list. - * - * Four rules, in this order, and the first two are the same idea - **stay with - * your siblings** - asked in decreasing order of confidence: - * - * 1. **Straight after its nearest earlier sibling.** The slot of the same run - * that was picked closest before this one and is still in the list. That is - * what makes B land between A and C whichever of the three finished first, - * and why removing A mid-upload does not send B to the front: the search - * walks back to the next sibling that is still there. - * 2. **Straight before its nearest later sibling**, when nothing it was picked - * after has arrived yet. The first file of a selection is the one this - * happens to most often, and it has to get in front of the siblings that beat - * it rather than queue behind them. - * 3. **Straight after the run's anchor**, when it has no sibling in the list at - * all: this is the selection's first arrival, so it goes where the selection - * goes. `null` is the head of the list. - * 4. **The end**, when the anchor has since been removed - the only honest - * answer left once the place the run was heading for has gone. - */ const indexForSlot = ( tokens: readonly FileGalleryToken[], { @@ -122,13 +64,6 @@ const indexForSlot = ( return anchor === -1 ? tokens.length : anchor + 1; }; -/** - * The identifiers, with one that has just finished uploading put in its place. - * - * The returned list is the new form value. A file already in it is returned - * unchanged - the same file picked twice in two selections is one entry, which - * is also what the API would decide, more slowly and after the bytes. - */ export const placeUploadedFile = ({ anchorId, id, @@ -163,19 +98,6 @@ export const placeUploadedFile = ({ return [...ids.slice(0, index), id, ...ids.slice(index)]; }; -/** - * The rows to render: the stored files, with the in-flight ones interleaved - * where they will land. - * - * A skeleton shown at the bottom and then re-appearing three rows up when its - * upload finishes is the list rearranging itself under somebody's cursor. So the - * placeholder is put where the file is going, by the same rule that will put the - * file there - and the swap, when it comes, happens in place. - * - * Each placeholder counts as a sibling for the ones after it, so a selection of - * ten renders in the order it was chosen even before a single request has - * answered. - */ export const planFileGallery = ({ anchorId, ids, @@ -217,28 +139,9 @@ export const planFileGallery = ({ return tokens; }; -/** - * The identifiers without one of them. - * - * A named function for a one-line filter because it is half of a pair: removing - * and reordering are the only two things that touch a collection's order by - * hand, and both have to leave everything they did not touch exactly where it - * was. Removing B from `[A, B, C, D]` gives `[A, C, D]` and never `[A, D, C]`, - * which is what a lookup-driven rebuild would quietly be free to do. - */ export const removeFileId = (ids: readonly number[], id: number): number[] => ids.filter(current => current !== id); -/** - * The identifiers with one dragged in front of another. - * - * `arrayMove` from `@dnd-kit/sortable` rather than a hand-rolled splice, because - * it is the same move the sortable list animated - anything else risks the list - * settling somewhere other than where it was dropped. - * - * Returns the list unchanged when either end is not in it, which is what a drop - * outside the list reports. - */ export const moveFileId = ( ids: readonly number[], activeId: number, diff --git a/packages/vitnode/src/components/form/fields/file-shared.test.ts b/packages/vitnode/src/components/form/fields/file-shared.test.ts index 0f1a3a3df..071d75d5c 100644 --- a/packages/vitnode/src/components/form/fields/file-shared.test.ts +++ b/packages/vitnode/src/components/form/fields/file-shared.test.ts @@ -4,17 +4,6 @@ import type { AutoFormFileValue } from "./file-shared"; import { fileIdsOfFormValue, resolveFormFiles } from "./file-shared"; -/** - * The rule behind both file controls: **the form value decides what is shown.** - * - * These are not decorative assertions. The version before this one held the - * chosen file in component state, so pressing Remove threw the descriptor away - - * and any path that put the value back without remounting the control (a dialog - * reopened, a row replaced by a refresh, a save rolled back) left the editor - * looking at an empty drop zone for a record whose column still held the file. - * Every case below is one half of that bug. - */ - const file = (id: number, name = `photo-${id}.webp`): AutoFormFileValue => ({ id, mimeType: "image/webp", diff --git a/packages/vitnode/src/components/form/fields/file-shared.tsx b/packages/vitnode/src/components/form/fields/file-shared.tsx index 30d2a3903..314ba1c56 100644 --- a/packages/vitnode/src/components/form/fields/file-shared.tsx +++ b/packages/vitnode/src/components/form/fields/file-shared.tsx @@ -26,27 +26,6 @@ import { fileFormatLabels } from "@/lib/file-constraints"; import { formatBytes } from "@/lib/format-bytes"; import { cn } from "@/lib/utils"; -/** - * The pieces `AutoFormFile` and `AutoFormFiles` both need. - * - * Its own module rather than one component with a `multiple` branch, because the - * two differ in almost everything that matters - one value or a list, replace or - * append, one failure or one per file - and almost nothing that is visible. - * Sharing the *visible* half is what keeps a gallery entry and a cover image - * looking like the same product, and keeps the constraint line, the `accept` - * filter and the pre-flight check reading one descriptor rather than two copies - * of one. - */ - -/** - * A stored file, as these inputs need to describe one. - * - * Declared here rather than imported from the Content Engine on purpose: this is - * generic AutoForm infrastructure, and a form field that reached into - * `@/content` for a type would make every hand-written form depend on the - * Content Engine to upload a file. The Content Engine's own - * `ContentFileDescriptor` is structurally this, so it passes straight in. - */ export interface AutoFormFileValue { height?: number; id: number; @@ -57,13 +36,6 @@ export interface AutoFormFileValue { width?: number; } -/** - * An upload failure that knows which rule refused it. - * - * A structural check rather than an `instanceof`: whoever owns `onUpload` builds - * the error, and these components must not have to know about their error class - * to read the one field they can act on. - */ export const fileRejectionReasonOf = ( error: unknown, ): FileRejectionReason | undefined => { @@ -74,17 +46,6 @@ export const fileRejectionReasonOf = ( : undefined; }; -/** - * Whether a file should be *rendered* as an image, which needs a `src` as well - * as an image MIME type. - * - * The url check is not defensive padding. A `core_files` row stores a `key`, not - * a url - the url is built at read time by the configured storage adapter - and - * an installation with no `storage.adapter` serialises `url: ""` for every file - * it describes. Rendering `<img src="">` then makes the browser re-request the - * current document, which is what React warns about, so a file with no - * resolvable url is an icon rather than a broken image. - */ export const isImageFile = (file: AutoFormFileValue): boolean => file.url !== "" && (file.mimeType ?? "").startsWith("image/"); @@ -100,13 +61,6 @@ const asFileId = (value: unknown): null | number => ? value : null; -/** - * The identifiers a form value names, whatever its arity. - * - * `null`, `undefined`, `""` and a list with rubbish in it all reduce to the ids - * that are actually there - a file control's value is only ever an identifier or - * a list of them, and everything else is a form that has not been filled in. - */ export const fileIdsOfFormValue = (value: unknown): number[] => { if (Array.isArray(value)) { return value.map(asFileId).filter((id): id is number => id !== null); @@ -117,30 +71,6 @@ export const fileIdsOfFormValue = (value: unknown): number[] => { return id === null ? [] : [id]; }; -/** - * What a file control should show, derived **entirely** from the form value. - * - * This is the rule that keeps a file control honest, and it used to be broken: - * the previous version held the chosen file in local state, so pressing Remove - * and then abandoning the form - closing the dialog, navigating away, letting a - * refresh replace the row - left the control showing an empty drop zone for a - * record whose column still held the file. The value said one thing and the - * screen said another, and the screen is what somebody acts on. - * - * So the value decides, and the descriptors are only a **lookup**: they come - * from the row the form opened on plus whatever this session uploaded, and they - * are never removed. Restoring the value therefore restores the preview, without - * anything having to re-fetch or re-sync. - * - * `file` is `null` when the value names an identifier nothing has a descriptor - * for. The caller still renders a card for it, because "there is a file here and - * I cannot describe it" and "there is no file here" must not look the same - the - * second one invites an editor to replace something they cannot see. - * - * Order follows the **value**, not the lookup: for a gallery the value is the - * stored order, and sorting by anything else would show a different gallery from - * the one that would be saved. - */ export const resolveFormFiles = ( value: unknown, known: readonly (AutoFormFileValue | null | undefined)[], @@ -157,21 +87,6 @@ export const resolveFormFiles = ( })); }; -/** - * What went wrong with one upload, in the most specific words available. - * - * A hook rather than a plain function only so the translator lives here: both - * callers need the same three sentences, and `useTranslations` is what types the - * keys. - * - * The server's own message is the **last** resort and also the most important - * one. "Storage provider not found" and "Invalid or corrupt image file" are - * exactly what somebody needs to read, and replacing either with "the upload - * failed, please try again" is how an editor ends up retrying a misconfiguration - * for ten minutes. The two rules above it are restated locally only because the - * browser can say them in the reader's own language, using the field's own - * limits and the file they actually picked. - */ export const useUploadFailureMessage = (): ((args: { attempted: File | undefined; error: unknown; diff --git a/packages/vitnode/src/components/form/fields/file-upload-queue.test.ts b/packages/vitnode/src/components/form/fields/file-upload-queue.test.ts index f648e2114..60436bf36 100644 --- a/packages/vitnode/src/components/form/fields/file-upload-queue.test.ts +++ b/packages/vitnode/src/components/form/fields/file-upload-queue.test.ts @@ -9,20 +9,6 @@ import { FILE_UPLOAD_CONCURRENCY, } from "./file-upload-queue"; -/** - * The concurrency bug this queue exists for, and the four shapes it takes. - * - * Ten files picked at once are ten requests, and they answer in whatever order - * the network decides. Appending each one as it lands therefore stored the - * *network's* order - which nobody chose, nobody can reproduce, and which - * differs between two editors uploading the same ten photographs over different - * connections. - * - * Nothing here waits on a clock. Every upload is a promise the test resolves by - * hand, in the order the test wants, which is the only way "B answered before A" - * is a fact rather than a race. - */ - interface Deferred { reject: (error: unknown) => void; resolve: (value: AutoFormFileValue) => void; diff --git a/packages/vitnode/src/components/form/fields/file-upload-queue.ts b/packages/vitnode/src/components/form/fields/file-upload-queue.ts index dff72f708..b81549e9b 100644 --- a/packages/vitnode/src/components/form/fields/file-upload-queue.ts +++ b/packages/vitnode/src/components/form/fields/file-upload-queue.ts @@ -9,25 +9,8 @@ import type { AutoFormFileValue } from "./file-shared"; import { placeUploadedFile } from "./file-order"; -/** - * How many files upload at once. - * - * A collection may hold up to two hundred, and "one request per file" must not - * turn a two-hundred-file selection into two hundred simultaneous connections: - * the browser queues them anyway, so all it buys is two hundred requests racing - * the same timeout, none of which can be attributed to a file while it is stuck. - * Six keeps the pipe full - the same order of magnitude a browser opens per - * origin - and keeps every failure attached to the file that caused it. - */ export const FILE_UPLOAD_CONCURRENCY = 6; -/** - * What the control has to render, and what the next arrival has to read. - * - * One object rather than three pieces of state, because they only ever change - * together: a slot is queued, lands, or is dropped, and the anchor and the run's - * arrivals move with it. - */ export interface FileUploadQueueState { /** The identifier this run appends after - see {@link FileUploadAnchor}. */ anchorId: FileUploadAnchor; @@ -58,13 +41,7 @@ export interface FileUploadQueueOptions { ids: () => readonly number[]; /** Writes a new identifier list to the form. */ onChange: (ids: number[]) => void; - /** - * One upload has ended, however it ended. - * - * `stored` on success and `error` on failure, and never both. The caller does - * what a queue must not: remembers the descriptor, and says which file was - * refused and why. - */ + onSettled: (result: { error?: unknown; file: File; @@ -76,34 +53,6 @@ export interface FileUploadQueueOptions { upload: (file: File) => Promise<AutoFormFileValue>; } -/** - * The thing between "somebody picked twelve files" and "the form value is - * twelve identifiers, in the order they picked them". - * - * Deliberately **not** a React hook and not a component: every rule worth - * getting right here is about what happens when four requests land out of order - * while a fifth is still going, and that is a question about promises rather - * than about rendering. A plain factory is what lets a test resolve B, then C, - * then A and assert the value is `[A, B, C]` without a timer anywhere. - * - * Three guarantees: - * - * - **Order is the pick order.** Each file is queued with a monotonic slot, and - * it lands in the list beside its siblings whenever it happens to arrive. See - * {@link placeUploadedFile}. - * - **At most {@link FILE_UPLOAD_CONCURRENCY} at once**, through a `TaskPool`. - * Still one request per file, still one outcome per file - just not all of them - * at the same instant. - * - **A run keeps its place.** Choosing more files while the first batch is - * still uploading extends the same run rather than starting one beside it, so - * a second selection lands after the first instead of in the middle of it. The - * run ends when the last upload settles, and the next selection anchors itself - * to wherever the list has got to by then. - * - * A failure takes its slot out of the queue and nothing else: the siblings that - * did upload keep the positions they were picked in, and the list closes over - * the gap rather than leaving one. - */ export const createFileUploadQueue = ({ concurrency = FILE_UPLOAD_CONCURRENCY, ids, diff --git a/packages/vitnode/src/components/form/fields/files.test.tsx b/packages/vitnode/src/components/form/fields/files.test.tsx index d3bbfbeb6..5f7ac220b 100644 --- a/packages/vitnode/src/components/form/fields/files.test.tsx +++ b/packages/vitnode/src/components/form/fields/files.test.tsx @@ -17,19 +17,6 @@ import type { AutoFormFileValue } from "./file-shared"; import { AutoFormFiles } from "./files"; -/** - * The control end to end: a selection goes in, requests come back out of order, - * and `field.value` is the order the person picked. - * - * The pieces below it are tested on their own - the queue against deferred - * promises, the gallery against what it renders - and this is the wiring, which - * is the part that used to be wrong: `onSuccess` appended to the value it could - * see, so whichever upload settled last decided where it went. - * - * Nothing here waits on a clock. Every upload is a promise the test resolves by - * hand, in the order the test wants. - */ - beforeAll(() => { vi.stubGlobal( "ResizeObserver", @@ -41,13 +28,6 @@ beforeAll(() => { ); }); -/** - * Runs one interaction and lets everything it started settle. - * - * The extra tick matters: an upload resolving is a promise chain - the queue - * places the identifier, the form takes it, the list re-renders - and asserting - * before it has run would be asserting on a half-finished render. - */ const settled = async (interaction: () => void) => { await act(async () => { interaction(); diff --git a/packages/vitnode/src/components/form/fields/input-roles.tsx b/packages/vitnode/src/components/form/fields/input-roles.tsx index 1f6436051..3d58c46aa 100644 --- a/packages/vitnode/src/components/form/fields/input-roles.tsx +++ b/packages/vitnode/src/components/form/fields/input-roles.tsx @@ -25,49 +25,12 @@ export type AutoFormRolesProps = ItemAutoFormComponentProps & { excludeIds?: number[]; multiple?: boolean; placeholder?: string; - /** - * Required, and there is no default. See the note on the component below: - * reading roles is the host's business, and a component that guessed would be - * a component whose behaviour depended on how it was bundled. - */ + search: RoleSearch; searchPlaceholder?: string; selected?: RoleOption[]; }; -/** - * The role field, and it belongs to no framework. - * - * Two things used to pin it to Next.js, and both were invisible until something - * other than Next.js rendered it - which is exactly what `/docs/ui/roles` - * started doing when the documentation moved to TanStack Start. - * - * **`next-intl`.** Its root entry re-exports `use-intl`, so this worked, and - * that is the trap: a shared component reading it is one a framework-neutral - * package cannot claim to be framework-neutral about. Every migrated component - * in this package reads `use-intl` directly, and now so does this one. - * - * **The default search.** `search` defaulted to `searchRoles`, a `"use server"` - * action carrying `server-only` and Next's request scope. Deferring it behind an - * `await import()` moved the failure from load time to the first keystroke, - * which is worse rather than better: a host that cannot run the action still - * cannot run it, and now finds out inside a dropdown. - * - * So the dependency is injected and **required**. Reading roles is the host's - * business: the AdminCP hands over `searchAdminRolesInBrowser`, a browser fetch - * to Hono, and a host with its own source of roles hands over that instead. - * There is deliberately no fallback and no environment sniffing - a component - * that guessed its host would be a component whose behaviour depends on how it - * was bundled. - * - * There was briefly a third option: an adapter beside this file that injected - * the Server Action as a default. It is gone with the rest of that surface, and - * it must not come back in the shape of a default parameter here - which is the - * one thing the boundary test below asserts about a file that no longer exists. - * - * `packages/vitnode/src/components/form/fields/roles-boundaries.test.ts` holds - * all of it. - */ export const AutoFormRoles = ({ description, disabled, diff --git a/packages/vitnode/src/components/form/fields/input-users.tsx b/packages/vitnode/src/components/form/fields/input-users.tsx index 7f16da6d7..857bf4231 100644 --- a/packages/vitnode/src/components/form/fields/input-users.tsx +++ b/packages/vitnode/src/components/form/fields/input-users.tsx @@ -15,17 +15,6 @@ import { AsyncPicker } from "../common/async-picker"; import { AutoFormDesc } from "../common/desc"; import { AutoFormLabel } from "../common/label"; -/** - * The columns a user picker needs: enough to identify a person on sight. - * - * Declared here rather than beside the search that produces it, and that is the - * whole reason this file has no import of `search-users.action.server` left. - * The action is a `"use server"` module: it reaches `next/headers` through the - * typed fetcher, so a component naming it - even only for a type, even only as - * a default argument nobody uses - put `server-only` into the graph of every - * screen that renders a person picker. The Content Engine's `user` field is one - * of those, so that single line made the whole AdminCP content form Next-only. - */ export interface UserOption { avatarColor: string; id: number; @@ -33,13 +22,6 @@ export interface UserOption { nameCode: string; } -/** - * A person the field can label but has not necessarily fetched. - * - * `avatarColor` is optional because the caller often knows only a name and an - * id - the Content Engine resolves a `user` field's label alongside the record - * and never carries a colour with it. - */ export type PartialUserOption = Omit<UserOption, "avatarColor" | "nameCode"> & Partial<Pick<UserOption, "avatarColor" | "nameCode">>; @@ -109,17 +91,7 @@ export const AutoFormUser = ({ clearable?: boolean; disabled?: boolean; placeholder?: string; - /** - * How the picker finds people. - * - * Required, and deliberately without the default it used to carry. The - * default was `searchUsers`, the AdminCP users list read through a server - * action - correct for a Next.js screen and unreachable from any other host, - * and imported whether a caller used it or not. A caller that wants it passes - * `search={searchUsers}`; the Content Engine passes its own, because a `user` - * field's people come from the content type's own options route rather than - * from the member list. - */ + search: (value: string) => Promise<UserOption[]>; searchPlaceholder?: string; selected?: null | PartialUserOption; diff --git a/packages/vitnode/src/components/form/fields/roles-boundaries.test.ts b/packages/vitnode/src/components/form/fields/roles-boundaries.test.ts index 3f22fb2c7..c1b7aa282 100644 --- a/packages/vitnode/src/components/form/fields/roles-boundaries.test.ts +++ b/packages/vitnode/src/components/form/fields/roles-boundaries.test.ts @@ -14,28 +14,6 @@ import { const here = dirname(fileURLToPath(import.meta.url)); -/** - * The role field, and the two couplings it took two attempts to remove. - * - * Neither looked like one, which is why this is a scan rather than a review: - * - * - `useLocale`/`useTranslations` came from `next-intl`, whose root entry - * re-exports `use-intl`. It *worked*, which is why it survived so long - what - * it cost was the boundary, not a render. - * - `search` defaulted to `searchRoles`, a `"use server"` action carrying - * `server-only`. A static import put that marker in the graph of every - * application rendering the field, and deferring it behind `await import()` - * only moved the throw from load time to the first keystroke. - * - * The fix was to move the type into a module with no imports and make the search - * a required, injected prop. Stage 17 then deleted the Next.js adapter that - * supplied the default, so what this file used to prove about that adapter - - * that it was the one place allowed to name the action - is now proved by the - * adapter's absence. - * - * The scanner and its positive controls live in `@/tests/import-graph` and - * `src/next-boundary.test.ts`. - */ const SHARED = { /** The framework-neutral field. */ field: join(here, "input-roles.tsx"), @@ -43,13 +21,6 @@ const SHARED = { types: join(here, "roles.ts"), }; -/** - * The Next.js half, by path, so its absence can be asserted. - * - * Kept as named constants rather than deleted with the assertion: the risk this - * guards is somebody reintroducing the convenience, and a test that no longer - * names the file cannot notice that happening. - */ const DELETED_NEXT_HALF = { action: join(here, "search-roles.action.server.ts"), adapter: join(here, "input-roles-next.tsx"), @@ -85,14 +56,6 @@ describe("the shared role field is framework-neutral", () => { }); }); -/** - * The contract itself, read off the source. - * - * A type test would be the stronger form and cannot be written here: `search` - * being required is a property of a `.tsx` component's props, and this suite is - * a static scan. What is asserted instead is the two things that made it - * optional - a default parameter and a fallback - staying absent. - */ describe("the search dependency is injected, and stays injected", () => { it("is required on the props type", () => { expect(read(SHARED.field)).toMatch(/\n {2}search: RoleSearch;/); diff --git a/packages/vitnode/src/components/form/fields/roles.test-d.ts b/packages/vitnode/src/components/form/fields/roles.test-d.ts index bd5c6d324..1eccfaf93 100644 --- a/packages/vitnode/src/components/form/fields/roles.test-d.ts +++ b/packages/vitnode/src/components/form/fields/roles.test-d.ts @@ -3,20 +3,6 @@ import { describe, expectTypeOf, it } from "vitest"; import type { AutoFormRolesProps } from "./input-roles"; import type { RoleOption, RoleSearch } from "./roles"; -/** - * The role field's contract, at the type level. - * - * `roles-boundaries.test.ts` reads the source and proves the *graph* is - * framework-neutral - no `next-intl`, no `"use server"` action, statically or - * dynamically. This proves the other half, which a source scan cannot: that the - * search dependency is genuinely part of the props rather than something with a - * default hiding behind it. - * - * A required prop is the whole design. `search` used to default to a Next.js - * server action, so `<AutoFormRoles {...props} />` compiled everywhere and threw - * on any host but one. Required, the compiler asks the question at the call - * site, which is the only place that knows the answer. - */ describe("AutoFormRolesProps", () => { it("requires a search implementation", () => { expectTypeOf<AutoFormRolesProps>().toHaveProperty("search"); @@ -42,13 +28,6 @@ describe("AutoFormRolesProps", () => { }>(); }); - /** - * The AdminCP's own browser search is assignable as-is. - * - * That is the point of the shape being identical to `AdminRoleOption`: a - * TanStack host hands the field `searchAdminRolesInBrowser` - a plain fetch to - * Hono - and nothing has to adapt between them. - */ it("accepts a browser search over the Hono roles endpoint", () => { const searchInBrowser = async ( search: string, diff --git a/packages/vitnode/src/components/form/fields/roles.ts b/packages/vitnode/src/components/form/fields/roles.ts index dc86711dd..319b9ad1c 100644 --- a/packages/vitnode/src/components/form/fields/roles.ts +++ b/packages/vitnode/src/components/form/fields/roles.ts @@ -1,56 +1,11 @@ -/** - * What a role picker is, as data - and nothing about where the roles came from. - * - * Zero imports, deliberately. This module is the reason `AutoFormRoles` can be - * rendered by any VitNode host: the shape a picker needs and the shape of the - * function that supplies it are both stated here, so the component depends on a - * type rather than on a transport. - * - * It exists because the type used to live in `search-roles.action.server.ts`, - * which is a `"use server"` module carrying `server-only`. Importing a module to - * read a type is free at runtime *only* if every importer remembers to write - * `import type` - and the same file also exported the default search, so the one - * static import that was not type-only put Next's request scope in the module - * graph of every application rendering a role field. A type belongs somewhere - * nothing has to be careful about. - */ - -/** - * One role, as a picker or a filter needs it. - * - * `name` stays the raw per-language list: the server has no business deciding - * which language the person clicking reads in, so it is resolved against the - * active locale where it is rendered - see {@link roleOptionName}. - * - * Structurally identical to `AdminRoleOption` - * (`views/admin/views/core/users/roles/roles-query`), which is what lets the - * AdminCP's own browser search be handed straight to this field. - */ export interface RoleOption { color: null | string; id: number; name: { languageCode: string; name: string }[]; } -/** - * How a role field finds roles. - * - * Injected rather than defaulted, and that is the whole contract: the field - * renders roles, and *reading* them is the host's business. A VitNode app on - * TanStack Start hands it a browser fetch to Hono; a Next.js app hands it a - * server action. Neither is named here, so neither is imported here. - */ export type RoleSearch = (search: string) => Promise<RoleOption[]>; -/** - * A role's name in the reader's language. - * - * Falls back to the first translation rather than to the id: a role with no - * English name is still a role somebody named, and showing `4` helps nobody. - * - * Re-exported from `./input-roles`, which is the path the documentation gives - * for it, so moving it here changes nothing for a caller. - */ export const roleOptionName = (role: RoleOption, locale: string): string => role.name.find(item => item.languageCode === locale)?.name ?? role.name[0]?.name ?? diff --git a/packages/vitnode/src/components/logo-vitnode.tsx b/packages/vitnode/src/components/logo-vitnode.tsx index 2f83f77d7..4e474a61b 100644 --- a/packages/vitnode/src/components/logo-vitnode.tsx +++ b/packages/vitnode/src/components/logo-vitnode.tsx @@ -1,36 +1,5 @@ import { cn } from "@/lib/utils"; -/** - * The mark, at either of its two sizes. - * - * ## `idPrefix`, and the bug it exists for - * - * Both variants paint themselves with an SVG `<linearGradient>`, which a `fill` - * can only reach **by id, in document scope**. Two of these on one page - * therefore define the same id twice, and every reference resolves to whichever - * came first in the document. - * - * Usually that is invisible, because the two definitions are identical. It stops - * being invisible the moment the *first* one is inside a `display: none` SVG: - * Blink will not resolve a paint server out of a subtree that is not rendered, - * so the reference resolves to nothing and the hexagon paints with no fill at - * all - a white shape on a white card. - * - * That is not hypothetical. `LogoVitNodeBrand` renders both variants and hides - * one per breakpoint, and the front page renders a third mark in the centre of - * its animated beam. On a desktop viewport the header's hidden compact mark came - * first, and the beam's centre - the same id, the same gradient - went blank. - * - * So the ids are a prefix a caller may replace. The default keeps the ids the - * design tool exported, which is what every existing caller renders today; the - * only code that passes one is {@link LogoVitNodeBrand}, which has to, because - * it is the component that puts two of these in one document. - * - * A `useId()` would be the reflex here and it is not available: this component - * is rendered by React Server Components in the Next.js app - the AdminCP - * sidebar and the admin sign-in view are both server-rendered - and a hook there - * throws. A prop works in both worlds and needs no client boundary. - */ export const LogoVitNode = ({ className, idPrefix = "vitnode-logo", diff --git a/packages/vitnode/src/components/role-format-content.tsx b/packages/vitnode/src/components/role-format-content.tsx index 9d951242c..84a9628da 100644 --- a/packages/vitnode/src/components/role-format-content.tsx +++ b/packages/vitnode/src/components/role-format-content.tsx @@ -8,23 +8,6 @@ import type { RoleNameEntry } from "./role-name"; import { resolveRoleName } from "./role-name"; -/** - * A role's name, coloured by the role, in the reader's language. - * - * The framework-neutral half of `RoleFormat`. That component is an async Server - * Component built on `next-intl/server`'s `getLocale()`, which is fine in a - * Next.js RSC tree and impossible anywhere else - so every AdminCP table that - * renders a role was Next-only for the sake of one string lookup. - * - * This reads the locale from `use-intl`'s context instead, which both - * applications provide: `NextIntlClientProvider` is built on it, and the - * TanStack routes mount `RouteMessages`. The cost is that it is a client - * component, which it would have to be in either framework the moment the table - * around it is. - * - * `RoleFormat` stays where it is, unchanged, for the Next.js pages that are not - * migrating yet and for the plugins documented against it. - */ export const RoleFormatContent = ({ className, role, diff --git a/packages/vitnode/src/components/role-name.ts b/packages/vitnode/src/components/role-name.ts index 80d727cb4..3ef66cf1f 100644 --- a/packages/vitnode/src/components/role-name.ts +++ b/packages/vitnode/src/components/role-name.ts @@ -1,17 +1,3 @@ -/** - * A role's name, in the language the reader is using. - * - * Role names live in `core_languages_words`, so every API that returns a role - * returns *all* of its translations - the server has no business deciding which - * language the person looking at the screen reads in. This is the one place that - * decision is made, so the users table, the roles table, both staff screens and - * every picker resolve it the same way. - * - * Falls back to the first translation and then to the id, in that order: a role - * with no English name is still a role somebody named, and an empty span is a - * row nobody can identify. Pure and import-free. - */ - export interface RoleNameEntry { languageCode: string; name: string; diff --git a/packages/vitnode/src/components/staff-permission/provider.tsx b/packages/vitnode/src/components/staff-permission/provider.tsx index c6a662a1c..6c8c4d656 100644 --- a/packages/vitnode/src/components/staff-permission/provider.tsx +++ b/packages/vitnode/src/components/staff-permission/provider.tsx @@ -12,30 +12,6 @@ import { hasStaffPermission, } from "@/api/lib/staff-permission"; -/** - * The signed-in administrator's effective permission set, as the AdminCP's UI - * reads it. - * - * One context, two frontends, and deliberately not two sources of truth. What - * differs between them is only *when the answer is available*, which is why the - * value is a union: - * - * - **Next.js** passes a `Promise`. `AdminLayout` hands `getAdminPermissions()` - * over unawaited on purpose - awaiting it in the layout would put the session - * read above `{children}` and hide every admin page's own header and skeleton - * behind this layout's placeholder on a full page load. - * - **TanStack Start** passes the resolved set. - * `AdminPermissionsProvider` in `@vitnode/core/tanstack/admin` reads it out of - * the canonical `["vitnode", "admin-session"]` query, which the `_admin` - * guard has already filled before anything renders - so there is nothing left - * to await, and making the router suspend on an answer it is holding would be - * a frame of blank sidebar for no reason. - * - * Either way this is a *rendering adapter*, never a permission store. The answer - * is computed on the server by `resolveStaffPermissions` and re-checked by Hono - * on every request; a devtools edit here buys a visible button and an API that - * still refuses it. - */ const AdminStaffPermissionContext = React.createContext< Promise<StaffPermissionSet> | StaffPermissionSet >(EMPTY_STAFF_PERMISSION_SET); @@ -57,15 +33,6 @@ const isPending = ( ): value is Promise<StaffPermissionSet> => typeof (value as Partial<Promise<StaffPermissionSet>>).then === "function"; -/** - * Returns the current admin's raw effective permission set. - * - * `React.use` is called conditionally, which is legal and is the reason this - * reads as one hook rather than two: `use` is explicitly exempt from the rules - * of hooks and may appear inside a condition. A resolved set is returned - * directly and nothing suspends; a promise suspends the nearest boundary exactly - * as it always did. - */ export const useAdminStaffPermissions = (): StaffPermissionSet => { const value = React.use(AdminStaffPermissionContext); diff --git a/packages/vitnode/src/components/switchers/langs/language-switcher-content.tsx b/packages/vitnode/src/components/switchers/langs/language-switcher-content.tsx index e88dc2708..5286f3004 100644 --- a/packages/vitnode/src/components/switchers/langs/language-switcher-content.tsx +++ b/packages/vitnode/src/components/switchers/langs/language-switcher-content.tsx @@ -13,26 +13,6 @@ import { DropdownMenuTrigger, } from "../../ui/dropdown-menu"; -/** - * The language switcher, minus the one thing that differs between frameworks. - * - * The dropdown, the icon, the check mark on the current language and the - * `core.global.language_switcher` label are the same control everywhere. What is - * not the same is *how* switching language moves the URL: Next.js replaces the - * current pathname through `next-intl`'s locale-aware router, TanStack Start - * pushes the public href and invalidates (`useSwitchLocale`, Stage 3). That is - * two lines, and they are the only two that are passed in. - * - * Before this existed `apps/web` carried its own copy of the markup with a - * comment explaining that copying it was cheaper than an abstraction satisfying - * both. It was - until the header needed the same control in both apps, at which - * point one dropdown with an `onSelect` is smaller than either. - * - * Framework-free: `use-intl` rather than `next-intl`, and no navigation import at - * all. `core.global` is mounted by both apps' root providers, so the label - * resolves in either. - */ - /** * The switch is ready: the current language is known and selecting one does * something. @@ -44,22 +24,6 @@ interface LanguageSwitcherReadyProps { onSelect: (locale: string) => void; } -/** - * The switch is not ready, and the items render disabled. - * - * This is not a loading state for the *data* - the language list is - * configuration and is always in hand. It is for the framework's navigation: - * Next.js resolves the current pathname from `usePathname()`, which is URL data, - * and Next 16 refuses to prerender a client component that reads it outside a - * `<Suspense>`. So the Next.js half renders this shape as its fallback and the - * real one inside the boundary - which is exactly the structure this control had - * before it was shared. - * - * Written as the other half of a union rather than as two independent optional - * props: "a current locale but no handler" is not a state this control has, and - * a caller that produced one would render a check mark next to items that do - * nothing. - */ interface LanguageSwitcherPendingProps { currentLocale?: never; onSelect?: never; diff --git a/packages/vitnode/src/components/table/data-table-content.tsx b/packages/vitnode/src/components/table/data-table-content.tsx index fb997fcd5..acb51cfb3 100644 --- a/packages/vitnode/src/components/table/data-table-content.tsx +++ b/packages/vitnode/src/components/table/data-table-content.tsx @@ -49,14 +49,6 @@ interface DisplayColumnDef<T extends DataTableTMin> extends ColumnDefBase<T> { export type ColumnDef<T extends DataTableTMin> = AccessorColumnDef<T> | DisplayColumnDef<T>; -/** - * Everything a data table is told, in one place both frameworks can import. - * - * Named rather than inferred from the component because the component the props - * belong to is now the Next.js one - `content.tsx` and the sort header would - * otherwise reach for `ComponentProps<typeof DataTable>` and, through it, for a - * module a TanStack Start route cannot load. - */ export type DataTableProps<T extends DataTableTMin> = Omit< React.ComponentProps<typeof Table>, "columns" @@ -87,14 +79,6 @@ export type DataTableProps<T extends DataTableTMin> = Omit< const SKELETON_HEAD_WIDTHS = ["w-24", "w-16", "w-20", "w-14"]; const SKELETON_CELL_WIDTHS = ["w-full", "w-3/4", "w-1/2", "w-5/6", "w-2/3"]; -/** - * The table's shape before its rows arrive. - * - * It lives beside the types rather than with `DataTable` because a Suspense - * fallback is the one part of the table a route renders *outside* the table - - * and a TanStack Start route reaching into the Next.js module for it would drag - * `next-intl`'s navigation in behind it. - */ export const DataTableSkeleton = ({ columns, rows = 6, diff --git a/packages/vitnode/src/components/table/navigation.tsx b/packages/vitnode/src/components/table/navigation.tsx index f8cd3e877..dcd9b6a12 100644 --- a/packages/vitnode/src/components/table/navigation.tsx +++ b/packages/vitnode/src/components/table/navigation.tsx @@ -2,37 +2,7 @@ import React from "react"; -/** - * The one thing a `DataTable` cannot decide for itself. - * - * Every control in the table turns the current query string into a new one - a - * pure function, in `url-state.ts` - and then has to get the page there. That - * last step is the single question whose answer differs between the two - * frameworks: Next.js wants `next-intl`'s locale-aware router pointed at a - * pathname it has to look up, TanStack Start wants `router.navigate` and no - * pathname at all. Both can be expressed as "here is the search string, and - * here is a function that goes to it", so the table takes those two and stops - * caring. - * - * Deliberately two members and no more. A table needs to read its own search - * parameters and to replace them; it never needs a pathname, a locale, params, - * prefetching or history state, so widening this later is a decision somebody - * has to make on purpose rather than one that leaks in. It is not a router. - */ export interface DataTableNavigation { - /** - * Goes to `nextSearch` - a query string with no leading `?`, exactly as - * `URLSearchParams.toString()` produces it. - * - * Implementations must not scroll: a person sorting the last column of a long - * table is looking at the header they clicked, and yanking them to the top of - * the page loses their place. - * - * Returning a promise is optional and only affects the pending indicator. - * Next's `push` resolves through the transition it was called in, so it - * returns nothing; a router whose `navigate` is awaitable should return it, so - * the spinner lasts as long as the navigation does instead of flashing. - */ navigate: (nextSearch: string) => Promise<void> | void; /** The query string the table is currently rendering. Never mutated. */ searchParams: URLSearchParams; @@ -41,15 +11,6 @@ export interface DataTableNavigation { const DataTableNavigationContext = React.createContext<DataTableNavigation | null>(null); -/** - * Context rather than props, and not by preference. - * - * In Next.js the table is assembled by a Server Component: `DataTable` renders - * on the server, and a `navigate` function cannot cross that boundary as a prop. - * The controls that need it are client components several levels down, so the - * value has to be created on the client and read from there - the same shape - * `SelectionProviderDataTable` already uses, for the same reason. - */ export const DataTableNavigationProvider = ({ children, value, @@ -62,23 +23,6 @@ export const DataTableNavigationProvider = ({ </DataTableNavigationContext.Provider> ); -/** - * The seam, as a control sees it: where the table is, and how to move it. - * - * The transition is here rather than in each control because every one of them - * wants the same thing from it - a pending flag to swap a spinner in for while - * the next page is fetched - and because it is what keeps the old rows on - * screen instead of blanking the table mid-navigation. - * - * `navigate` is awaited inside the transition so that a router returning a - * promise keeps the control pending for the whole navigation. Awaiting a - * `void` return costs one microtask and changes nothing: the navigation itself - * was already started synchronously, inside the transition. - * - * The one it hands back returns nothing, deliberately. A control has no use for - * the navigation's promise - that is what `isPending` is for - and typing it as - * awaitable would make every call site a floating promise. - */ export const useDataTableUrl = (): { isPending: boolean; navigate: (nextSearch: string) => void; diff --git a/packages/vitnode/src/components/table/no-results.tsx b/packages/vitnode/src/components/table/no-results.tsx index 3862c796f..781d0b988 100644 --- a/packages/vitnode/src/components/table/no-results.tsx +++ b/packages/vitnode/src/components/table/no-results.tsx @@ -2,28 +2,6 @@ import { useTranslations } from "use-intl"; -/** - * The data table's default empty state. - * - * Two strings, and its own `"use client"` module for one reason: - * {@link ContentDataTable} is rendered as a *Server Component* by every AdminCP - * page - `DataTable` has no client boundary of its own, so React renders the - * table on the server and only its controls in the browser - and as an ordinary - * client component by `apps/web`, which has no server components at all. It is - * therefore the one shared component in this package that cannot read a React - * context, because in half its callers there is no context to read. - * - * `next-intl` used to paper over that: its root entry resolves to an - * RSC-capable `useTranslations` under Next's `react-server` condition and to - * the context-reading one everywhere else. That works, and it is the only - * reason the table translated in both places - but it is also the last thing - * tying a shared component to Next.js, and it hid the fact that the table - * renders in two different environments. - * - * So the translating moved here instead, behind a boundary that is a client - * component in both frameworks. A caller that already has the copy passes - * `customNoResults` and this renders its strings without looking anything up. - */ export const NoResultsDataTable = ({ description, title, diff --git a/packages/vitnode/src/components/table/selection.tsx b/packages/vitnode/src/components/table/selection.tsx index 3f651e65b..65ac69715 100644 --- a/packages/vitnode/src/components/table/selection.tsx +++ b/packages/vitnode/src/components/table/selection.tsx @@ -30,14 +30,6 @@ const getPortalContainer = () => document.body; /** No DOM to portal into on the server, and the bar starts empty anyway. */ const getNoPortalContainer = () => null; -/** - * The rows a bulk action is about. - * - * Only usable inside a `DataTable` given `bulkActions`: the bar that renders - * those actions sits inside this provider, so an action component reads the - * ticked ids straight from here instead of the table having to thread them - * through a node it was handed. - */ export const useDataTableSelection = (): SelectionDataTable => { const value = React.use(SelectionContext); diff --git a/packages/vitnode/src/components/table/table-boundaries.test.ts b/packages/vitnode/src/components/table/table-boundaries.test.ts index 854a2e9b0..ec096a6ed 100644 --- a/packages/vitnode/src/components/table/table-boundaries.test.ts +++ b/packages/vitnode/src/components/table/table-boundaries.test.ts @@ -14,19 +14,6 @@ import { const here = dirname(fileURLToPath(import.meta.url)); -/** - * The data table, split down the middle. - * - * The same boundary `feed-boundaries.test.ts` and `auth-boundaries.test.ts` - * draw, with the same machinery and for the same reason: a shared component - * that reaches `@/lib/navigation` - or anything else built on Next's request - * scope - cannot be rendered by a TanStack Start route, and nothing about that - * failure is visible until somebody tries. - * - * The table is the widest instance of it in the codebase. Four separate - * controls used to import Next's router directly, so every AdminCP screen and - * `/files` inherited the coupling from a header cell. - */ const SHARED = { content: join(here, "content.tsx"), filters: join(here, "filters.tsx"), @@ -39,13 +26,7 @@ const SHARED = { }; /** The Next.js half: locale-aware navigation, and the error screen built on it. */ -/** - * The Next.js half, by path, so its absence can be asserted. - * - * Named rather than deleted along with the assertions that used them: each was - * the one place a Next.js API was allowed to appear in this subtree, and a test - * that stops naming them cannot notice one coming back. - */ + const DELETED_NEXT_HALF = { navigation: join(here, "navigation-next.tsx"), table: join(here, "data-table.tsx"), diff --git a/packages/vitnode/src/components/table/url-state.ts b/packages/vitnode/src/components/table/url-state.ts index 99abeceed..efdd5befc 100644 --- a/packages/vitnode/src/components/table/url-state.ts +++ b/packages/vitnode/src/components/table/url-state.ts @@ -1,27 +1,3 @@ -/** - * Every URL a `DataTable` control can ask for, as plain functions. - * - * A sort header, a page button, the search box and a filter dropdown all do the - * same thing: take the query string the page is on, change one thing about it, - * and hand the result to whatever knows how to navigate. Only that last step - * differs between Next.js and TanStack Start - the rest is string arithmetic, - * so it lives here, framework-free and testable without a router. - * - * Two rules hold everywhere below, because the table's URLs are also its API - * arguments and a control that forgot one would silently drop the visitor's - * work: - * - * - **Unrelated parameters survive.** Every helper copies the search it was - * given and edits the copy, so a plugin's own `?tab=` outlives a sort click. - * - **A parameter is removed rather than emptied.** `?search=` and `?cursor=` - * with no value are not the same request as their absence, and the API reads - * the presence of a cursor to decide which page it is on. - * - * Which parameters *reset* is deliberately uneven, and matches what the table - * has always done: paging and filtering rewrite the cursor, sorting and - * searching leave it alone. - */ - /** The parameter a page size is written to when paging forwards. */ const FIRST = "first"; /** The parameter a page size is written to when paging backwards. */ @@ -29,21 +5,8 @@ const LAST = "last"; /** The row the next page starts from. Meaningless without `first` or `last`. */ const CURSOR = "cursor"; -/** - * The page size a table shows when the URL does not ask for one. - * - * Also the fallback for a `first`/`last` that is not a positive number: the - * value goes straight back into the URL when the visitor pages, and `?first=NaN` - * is not a request the API can answer. - */ export const DEFAULT_TABLE_PAGE_SIZE = 10; -/** - * How much has to be typed before a search reaches the URL. - * - * Below it the parameter is removed instead, so backspacing to nothing restores - * the unfiltered table rather than searching for an empty string. - */ export const MIN_TABLE_SEARCH_LENGTH = 3; export type TableOrderDirection = "asc" | "desc"; @@ -56,13 +19,6 @@ export interface TableOrder { order: TableOrderDirection; } -/** - * A copy of the given search, safe to edit. - * - * Copying is the point: callers hand in the params object the router owns - - * Next's is frozen and throws on `set` - and every helper here returns a new - * string rather than mutating what it was passed. - */ const copy = (search: TableSearch): URLSearchParams => new URLSearchParams(search.toString()); @@ -99,13 +55,6 @@ export const withTableOrder = ( return params.toString(); }; -/** - * What clicking a sort header does. - * - * A column that is already sorted ascending flips to descending; anything else - * - a different column, or the same one descending - starts again at ascending, - * which is what makes a third click on the same header undo the second. - */ export const toggleTableOrder = ( search: TableSearch, { column, defaultOrder }: { column: string; defaultOrder: TableOrder }, @@ -127,13 +76,6 @@ export const readTablePageSize = (search: TableSearch): number => { return Number.isInteger(size) && size > 0 ? size : DEFAULT_TABLE_PAGE_SIZE; }; -/** - * Shows `pageSize` rows, from the beginning. - * - * The cursor goes with the old page size: a cursor is a position in a result - * set the visitor is no longer looking at, and keeping it would land them - * somewhere they never asked to be. - */ export const withTablePageSize = ( search: TableSearch, pageSize: number | string, diff --git a/packages/vitnode/src/components/theme-script.tsx b/packages/vitnode/src/components/theme-script.tsx index 5e50a60f8..d65d0da9b 100644 --- a/packages/vitnode/src/components/theme-script.tsx +++ b/packages/vitnode/src/components/theme-script.tsx @@ -1,15 +1,5 @@ import type { Attribute, ThemeProviderProps } from "./theme-provider"; -/** - * Everything {@link ThemeProvider} does to the DOM, as one function that runs - * before the first paint. - * - * Serialised into the page with `Function.prototype.toString()`, so it must - * reference nothing but its own arguments: a closure over a module-level value - * survives in this file and vanishes in the browser, where the value was never - * defined. That is also why every option arrives as a parameter rather than - * being read from a config. - */ const noFlashScript = ( attribute: Attribute | Attribute[], storageKey: string, @@ -74,19 +64,6 @@ export type ThemeScriptProps = Omit< "children" | "disableTransitionOnChange" >; -/** - * The script's source, ready for a `<script>` tag. - * - * Exported next to the component because a framework may want the string rather - * than the element - and because it makes the script testable without a DOM - * renderer, which is worth having for the one piece of code in VitNode that - * runs before React exists. - * - * The defaults deliberately mirror {@link ThemeProvider}'s. The two read the - * same `localStorage` key and write the same attributes, so a default that - * drifts here is a theme flash: the script paints one theme and React replaces - * it on hydration. - */ export const themeScriptSource = ({ attribute = "class", enableColorScheme = true, diff --git a/packages/vitnode/src/components/theme.test.tsx b/packages/vitnode/src/components/theme.test.tsx index 0b9ee1597..8fabcc5d8 100644 --- a/packages/vitnode/src/components/theme.test.tsx +++ b/packages/vitnode/src/components/theme.test.tsx @@ -38,15 +38,6 @@ afterEach(() => { html().removeAttribute("data-theme"); }); -/** - * The script that paints the theme before React exists. - * - * It used to be inlined inside `ThemeProvider` and inserted through - * `useServerInsertedHTML`, which tied the whole theme system to Next.js. It is - * its own component now, so this is what has to keep being true: the same - * options produce the same first frame, and that frame matches what the - * provider settles on - a mismatch between the two *is* the flash. - */ describe("ThemeScript", () => { it("applies the stored theme to <html> before React renders", () => { localStorage.setItem("theme", "dark"); diff --git a/packages/vitnode/src/components/ui/header-content.tsx b/packages/vitnode/src/components/ui/header-content.tsx index b61d821b5..1278114b8 100644 --- a/packages/vitnode/src/components/ui/header-content.tsx +++ b/packages/vitnode/src/components/ui/header-content.tsx @@ -19,14 +19,6 @@ export interface HeaderContentBack { label: React.ReactNode; } -/** - * The anchor a back link ends up rendering. - * - * Every prop of one, not just `href`: the button around it is a Base UI - * `render`, which clones the element with the children, the class name and the - * ref it needs to stay a button. A wrapper that accepted only `href` would drop - * all three, so the type says so. - */ export interface HeaderContentBackLinkProps extends Omit< React.ComponentProps<"a">, "href" @@ -34,31 +26,10 @@ export interface HeaderContentBackLinkProps extends Omit< href: string; } -/** - * The one thing this header cannot decide for itself. - * - * Turning `/admin/blog` into a client-side navigation is the single question - * whose answer differs between the two frameworks: Next.js wants `next-intl`'s - * locale-aware `Link` (`@/lib/navigation`), TanStack Start wants the router's - * own. Both are a component taking {@link HeaderContentBackLinkProps}, so the - * header takes one and stops caring - and importing neither is what lets a - * TanStack Start route render this component at all. The same boundary - * `SearchFeedContent` draws for a search hit, for the same reason. - * - * It is required alongside `back` rather than defaulting to `<a>`: a missing - * wrapper would otherwise degrade silently into a full document reload. - */ export type HeaderContentBackLinkComponent = ( props: HeaderContentBackLinkProps, ) => React.ReactNode; -/** - * A back link, or neither half of one. - * - * Written as a union so `back` without `BackLink` is a type error at the call - * site. The alternative - two independent optional props - compiles, renders - * nothing, and looks like a header whose back button was never designed. - */ type HeaderContentBackProps = | { back: HeaderContentBack; BackLink: HeaderContentBackLinkComponent } | { back?: never; BackLink?: never }; diff --git a/packages/vitnode/src/content/admin/labels.ts b/packages/vitnode/src/content/admin/labels.ts index 2c9e73e7e..059068c32 100644 --- a/packages/vitnode/src/content/admin/labels.ts +++ b/packages/vitnode/src/content/admin/labels.ts @@ -1,19 +1,5 @@ import type { AnyContentTypeDefinition } from "../types"; -/** - * What the label rules actually read off a content type: its id, and nothing - * else. - * - * Narrower than `AnyContentTypeDefinition` on purpose. Every lookup below is a - * function of the id - the i18n keys are derived from it and so is the - * untranslated fallback - so requiring a whole definition made two callers - * fabricate one. The AdminCP navigation is the honest case: it resolves a - * content type's noun from a *declaration* that carries the id and the plugin - * that owns it, long after the definition itself has gone out of scope. - * - * A full definition still satisfies this structurally, so every existing caller - * is unchanged. - */ export type ContentLabelSubject = Pick<AnyContentTypeDefinition, "id">; /** diff --git a/packages/vitnode/src/content/admin/registry.test.ts b/packages/vitnode/src/content/admin/registry.test.ts index 5a128089f..620ccede4 100644 --- a/packages/vitnode/src/content/admin/registry.test.ts +++ b/packages/vitnode/src/content/admin/registry.test.ts @@ -143,11 +143,6 @@ describe("buildContentFrontendRegistry", () => { }); }); - /** - * `admin.path` and `definition.id` are allowed to disagree, and the resolver - * is keyed by the path. A registry whose `lookup` reached for the id instead - * would break exactly the content types that renamed themselves. - */ describe("lookup, as the route resolver reads it", () => { const registry = buildContentFrontendRegistry([ plugin("@vitnode/blog", renamed), @@ -166,11 +161,6 @@ describe("buildContentFrontendRegistry", () => { }); }); - /** - * The same `validateContentTypes` the API side runs, which is what stops the - * two registrations silently disagreeing. Every message names the plugin, the - * content type and the reason. - */ describe("validation", () => { it("rejects two plugins registering one content type id", () => { expect(() => diff --git a/packages/vitnode/src/content/admin/registry.ts b/packages/vitnode/src/content/admin/registry.ts index 0776c4973..a152c5711 100644 --- a/packages/vitnode/src/content/admin/registry.ts +++ b/packages/vitnode/src/content/admin/registry.ts @@ -7,22 +7,6 @@ import type { ContentTypeLookup } from "./route"; import { validateContentTypes } from "../registry"; -/** - * The Content Engine's frontend registry - one implementation, two doors. - * - * Everything here is plain data and component *references*: no `next/*`, no - * server config, no filesystem, no `import(variable)`. That is what lets the - * same registry serve the Next.js AdminCP, which builds it from - * `getVitNodeConfig().plugins`, and the TanStack Start AdminCP, which builds it - * from a generated module of literal imports. See `./config.ts` for the first - * and `framework/content-registry` for the second. - * - * There is deliberately no second registry beside this one. The two hosts differ - * only in *where the plugin list comes from*; every rule about what a valid - * registration is, which path addresses it and what makes two of them - * incompatible lives here and in `content/registry.ts`. - */ - /** A content type's frontend registration, plus the plugin that registered it. */ export interface RegisteredFrontendContentType { definition: AnyContentTypeDefinition; @@ -30,14 +14,6 @@ export interface RegisteredFrontendContentType { registration: ContentTypeFrontendRegistration; } -/** - * Every content type an installation registered, indexed the three ways - * anything asks for one. - * - * Built once and read many times, rather than derived per call: validation is - * O(content types x fields x indexes), and the list screen, the breadcrumb, the - * metadata and every form resolve a content type on the same navigation. - */ export interface ContentFrontendRegistry { /** Every registration, sorted by content type id. */ all: () => readonly RegisteredFrontendContentType[]; @@ -45,36 +21,10 @@ export interface ContentFrontendRegistry { byAdminPath: (adminPath: string) => RegisteredFrontendContentType | undefined; /** By `definition.id` - `blog.post`. */ byId: (contentTypeId: string) => RegisteredFrontendContentType | undefined; - /** - * The predicate `resolveContentAdminRoute` takes. - * - * Exposed as its own member rather than left to callers to write, because the - * resolver is keyed by `admin.path` and a caller that reached for `byId` - * instead would silently break every content type whose path and id disagree. - */ + lookup: ContentTypeLookup; } -/** - * Builds the registry from a list of plugin sources. - * - * The one place a plugin list becomes a registry. It applies the same Content - * Engine definition rules the API registry applies - `validateContentTypes`, - * the identical function - to the registrations *it is given*, which is what - * turns two plugins claiming `blog/articles` into a `ContentEngineError` naming - * both rather than a screen that silently belongs to whichever loaded first. - * - * It does **not** compare this registry against the running API's. Nothing here - * can see what Hono registered, so a content type present on one side and absent - * from the other is not detected here and would surface where it actually bites: - * a screen whose requests 404, or an API route with no AdminCP. There is no - * frontend/API handshake in VitNode today, and this comment previously claimed - * one. - * - * Order in does not affect order out: the entries are sorted by content type id, - * so the same configuration produces the same registry whichever order the - * plugins arrive in. - */ export const buildContentFrontendRegistry = ( plugins: readonly ContentFrontendPluginSource[], ): ContentFrontendRegistry => { @@ -107,36 +57,11 @@ export const buildContentFrontendRegistry = ( }; }; -/** - * The message a caller gets when the application forgot to register. - * - * A named constant so a host's own test can assert on it without matching - * English, and so the sentence says what to do rather than what went wrong. The - * same shape as `ADMIN_TRANSPORT_MISSING`, for the same reason. - */ export const CONTENT_FRONTEND_REGISTRY_MISSING = "No Content Engine registry is registered. Call setContentFrontendRegistry() from a module the application loads before any /admin/content route runs - src/lib/content-registry.ts, built from src/content-registry.gen.ts."; let registered: ContentFrontendRegistry | undefined; -/** - * Register the application's content registry, once, at module scope. - * - * For a host with no server-side plugin registry to read - a TanStack Start - * application, where `vitnode.config.ts` is deliberately server-only. The - * Next.js AdminCP never calls this: `./config.ts` derives the registry from the - * config it can already see. - * - * Registering twice replaces the previous value rather than throwing, because a - * hot reload re-evaluates the module and a build error is a worse answer than - * the newer registry. - * - * Module scope means *per bundle* - the browser has one instance and the server - * has one, and each registers its own. Nothing per-request or per-administrator - * is stored here: a registry is a function of the installed plugins, which is - * the same for every visitor, which is what makes a module-level value safe on a - * server rendering many of them at once. - */ export const setContentFrontendRegistry = ( registry: ContentFrontendRegistry, ): void => { diff --git a/packages/vitnode/src/content/admin/route.ts b/packages/vitnode/src/content/admin/route.ts index 108530817..0b01e22eb 100644 --- a/packages/vitnode/src/content/admin/route.ts +++ b/packages/vitnode/src/content/admin/route.ts @@ -16,17 +16,6 @@ export interface ContentAdminRoute { itemId?: number; } -/** - * A "does this path exist" predicate, so the resolver stays a pure function. - * - * It has to be able to ask, rather than just split on the last segment: a - * content type is free to live at `blog/post/create`, and the exact match has to - * win over the create page of `blog/post`. - * - * Keyed by `admin.path` rather than by the content type id, because the two are - * allowed to disagree - `blog.post` answers at `blog/articles`, and no amount of - * splitting a slug on dots would find it. - */ export type ContentTypeLookup = ( adminPath: string, ) => AnyContentTypeDefinition | undefined; @@ -40,25 +29,6 @@ const parseItemId = (segment: string | undefined): null | number => { return Number.isSafeInteger(id) ? id : null; }; -/** - * Maps the catch-all slug onto a content type and one of three screens. - * - * One route serves all of them, which is the same trade the list screen already - * made: a second Next.js router keyed on content type ids would mean two files - * per app per screen, and the whole point of the generated AdminCP is that a - * plugin adds a content type without adding a file. - * - * Resolution order is exact-match-first, and that matters. `blog/post/create` is - * a legal address for a content type of its own, so `["blog", "post", "create"]` - * has two readings; the one where a registered content type keeps its own list - * screen wins, and the create page of `blog/post` is then simply unreachable - - * which is a name clash its author can see and fix, rather than a screen that - * silently disappeared. - * - * `undefined` for anything that resolves to nothing, and for a form URL of a - * content type that did not opt into page mode: a dialog-mode content type - * answering `/create` would be a second, unstyled way into the same form. - */ export const resolveContentAdminRoute = ( slug: readonly string[], lookup: ContentTypeLookup, diff --git a/packages/vitnode/src/content/admin/spec.ts b/packages/vitnode/src/content/admin/spec.ts index b821edc8b..fb9748e8c 100644 --- a/packages/vitnode/src/content/admin/spec.ts +++ b/packages/vitnode/src/content/admin/spec.ts @@ -20,49 +20,19 @@ import { import { contentFieldPath, contentInnerFields } from "../paths"; import { humanizeFieldName } from "./labels"; -/** - * A single form field, reduced to plain JSON. - * - * The AdminCP page is a server component but the form is a client one, and a - * content type definition cannot cross that boundary - `field.relation` holds a - * `target` thunk, and Zod schemas are not serialisable either. So the server - * projects the definition into this spec, and the client rebuilds the form - * schema from it with {@link buildFormSchemaFromSpec}. - */ export interface ContentFormFieldSpec { - /** - * `file` fields only: the extensions the field accepts, normalised. - * - * Carried on the spec rather than re-derived in the browser, so the constraint - * line the uploader always shows, the `accept` attribute it sets and the check - * the upload route runs are three readings of **one** descriptor. There is no - * second place for them to disagree. - */ allowedExtensions?: string[]; /** `file` fields only: the media types the field accepts, lowercased. */ allowedMimeTypes?: string[]; defaultValue?: boolean | null | number | string; description?: string; display?: "radio" | "select"; - /** - * The leaves of a `group` or a `repeatable`, in declaration order. - * - * Recursive in the type and one level deep in practice: a leaf is always a - * scalar, which is what lets a group render as a section of ordinary inputs - * and a repeatable row render as the same section repeated. - */ + fields?: ContentFormFieldSpec[]; integer?: boolean; kind: ContentFieldKind; label: string; - /** - * Whether this field's value lives on the translation table. - * - * Presentation reads it as "render a language switcher inside this input"; the - * submit path reads it as "this value goes to the translation rows rather than - * the base row". Both from one flag, so a plugin never has to declare a field - * override just because a field is translated. - */ + localized?: boolean; max?: number; /** `file` fields only: the largest upload the field accepts, in bytes. */ @@ -74,13 +44,7 @@ export interface ContentFormFieldSpec { /** Lower bound on a repeatable's rows. */ minItems?: number; minLength?: number; - /** - * Whether the field holds many values: a to-many reference, or a `file` field - * with `multiple: true`. - * - * One key for both, because the form makes the same decision from it - one - * control or a list of them. - */ + multiple?: boolean; name: string; nullable: boolean; @@ -92,25 +56,10 @@ export interface ContentFormFieldSpec { */ ordered?: boolean; required: boolean; - /** - * The content type a `relation` points at. - * - * What lets the browser know that the category picker on an article and the - * category screen are looking at the same rows - so creating, renaming or - * deleting one expires the other's cached options instead of leaving an - * article form offering a category that is gone. Absent on a `user` field, - * whose targets are people rather than a content type. - */ + targetContentTypeId?: string; } -/** - * One titled group of fields, with its heading already translated. - * - * Translated here for the same reason an enum's `options` are: the spec crosses - * into a client component, and the server is where the request's locale and the - * plugin's messages both are. - */ export interface ContentFormSectionSpec { desc?: string; /** Field names, in order. Each one appears in exactly one section. */ @@ -121,32 +70,13 @@ export interface ContentFormSectionSpec { export interface ContentFormSpec { contentTypeId: string; - /** - * The locale every record must exist in, or `null` when the content type is - * not localized. - * - * Not a display choice - the editor sees their own language first. This is the - * translation the engine refuses to create a record without, so the form can - * say which language a required field is still missing in. - */ + defaultLocale: null | string; fields: ContentFormFieldSpec[]; - /** - * The content type's AdminCP module segment, e.g. `posts`. - * - * The form needs it to address the generated upload route from the browser - - * `/api/{pluginId}/admin/content/{permissionModule}/uploads/{field}` - and it is - * already the path segment every admin content request goes through, so this - * publishes nothing new. - */ + permissionModule: string; pluginId: string; - /** - * How to group the fields, or empty for one flat form. - * - * Empty is the default and the shape every content type written before - * sections existed keeps: `fields` alone is a complete form. - */ + sections: ContentFormSectionSpec[]; /** Field the toast describes a newly created row by, if there is one. */ titleField: null | string; @@ -155,12 +85,7 @@ export interface ContentFormSpec { export interface ContentColumnSpec { kind: "publication" | "system" | ContentFieldKind; label: string; - /** - * Whether the cell reads from the row's translation rather than the row. - * - * The list resolves one translation per record - the reader's own language - - * so a localized cell is an ordinary cell with one more lookup in front of it. - */ + localized?: boolean; name: string; /** Enum value -> translated label, for badge cells. */ @@ -179,12 +104,6 @@ export type ContentSectionLabeller = (name: string) => { title: string; }; -/** - * Generated columns have no field descriptor to read a kind from, so they are - * mapped by name. `status` gets its own kind rather than falling into "system", - * which the cell renderer treats as a date - and `version` is mapped to - * "number" for the same reason, since it is one. - */ const systemKinds: Record<string, "number" | "publication" | "system"> = { createdAt: "system", id: "system", @@ -415,27 +334,9 @@ export type ContentReferenceOption = z.infer<typeof referenceOptionSchema>; export const isReferenceKind = (kind: ContentFieldKind): boolean => kind === "relation" || kind === "user"; -/** - * Whether a field's value lives on a table of its own rather than on the row. - * - * A repeatable, a to-many `relation`, a to-many `user`, and a `multiple: true` - * `file` - four kinds, one question, and the question is not "which kind is - * this?" but "will this value be absent from a list row?". Keyed off `multiple` - * rather than off a list of kinds for exactly that reason: the next collection - * kind is covered the day it exists, and the version of this rule that forgot - * one is a form that opens on the empty set and then saves it. - */ export const isCollectionFieldSpec = (field: ContentFormFieldSpec): boolean => field.kind === "repeatable" || field.multiple === true; -/** - * One group's or repeatable row's leaves, as a nested object schema. - * - * Nested rather than flattened into `seo.title` keys: react-hook-form would - * happily accept the dotted names, but then the value the form holds and the - * value the API takes would be two different shapes, and the conversion would - * have to live somewhere. One shape, all the way through. - */ const leafObjectSchema = ( spec: ContentFormFieldSpec, values?: Record<string, unknown>, @@ -459,13 +360,6 @@ const leafObjectSchema = ( ), ); -/** - * A to-many reference in the form: the identifiers, and the field's own floor. - * - * `minItems` is what makes "at least one category" fail in the *form* rather - * than only at the API - the submit button stays disabled and the message names - * the field, instead of a save that comes back 400 with everything still typed. - */ const referenceSetSchema = (spec: ContentFormFieldSpec): z.ZodType => { const schema = z.array(z.number()); @@ -548,14 +442,6 @@ const baseFieldSchema = (spec: ContentFormFieldSpec): z.ZodType => { } }; -/** - * Field kinds whose input renders an empty string when it holds no value. Left - * as-is, `""` fails ISO-date and identifier validation and the form can never - * become valid. - * - * A slug is here for a second reason: an empty box means "derive it from the - * source field", and sending `""` would ask the server to store nothing. - */ const EMPTY_MEANS_UNSET: ReadonlySet<ContentFieldKind> = new Set([ "dateTime", "slug", @@ -582,13 +468,6 @@ const toInitialValue = ( return { label: labels[fieldSpec.name] ?? id, value: id }; }; -/** - * The row's own title, for a toast that says what was just written. Falls back - * to nothing when the content type declares no title field. - * - * A localized title is read in `locale` - the language the editor is working in - - * because that is the copy they just typed and the one they would recognise. - */ export const contentTitleFromValues = ( spec: ContentFormSpec, values: Record<string, unknown>, @@ -604,14 +483,6 @@ export const contentTitleFromValues = ( return typeof value === "string" && value.trim() !== "" ? value : undefined; }; -/** - * Turns validated form values into the payload the generated API accepts. - * - * Shared fields only. A localized field's value is a per-language array, and it - * travels to the translation rows through - * {@link contentFormValuesToTranslations} instead - the split lives here rather - * than in the form, which is exactly why a layout never has to know about it. - */ export const contentFormValuesToPayload = ( spec: ContentFormSpec, values: Record<string, unknown>, @@ -643,13 +514,6 @@ export const contentFormValuesToPayload = ( const isLocalizedFieldName = (spec: ContentFormSpec, name: string): boolean => spec.fields.some(field => field.name === name && field.localized === true); -/** - * What one localized field holds for one language, ready for the API. - * - * `undefined` means "say nothing about this field in this language", which is a - * different thing from `null`: a slug left blank is derived from the title, and a - * language nobody has typed into gets no translation row invented for it. - */ const localizedValueForApi = ( fieldSpec: ContentFormFieldSpec, raw: string, @@ -662,14 +526,6 @@ const localizedValueForApi = ( return undefined; }; -/** - * The per-language halves of a submitted form, keyed by locale. - * - * A locale appears only when the editor actually typed something into it, which - * is what keeps "I opened the Polish selector to look" from creating an empty - * Polish translation. Locales already present on the record are handled by the - * caller, which knows which rows exist. - */ export const contentFormValuesToTranslations = ( spec: ContentFormSpec, values: Record<string, unknown>, @@ -696,14 +552,6 @@ export const contentFormValuesToTranslations = ( return byLocale; }; -/** - * Folds a record's translations back into per-field, per-language form values. - * - * The one adapter between how localization is *stored* - one row per language, - * with its own version - and how it is *edited*: a field holding every language - * it has, so its input can switch between them without the form having a locale - * of its own. - */ export const contentFormInitialValues = ( spec: ContentFormSpec, data?: Record<string, unknown>, @@ -735,23 +583,6 @@ export const contentFormInitialValues = ( return initial; }; -/** - * One localized field's rules, stated per language. - * - * Two rules, and the difference between them is the whole of the localized - * editing model: - * - * - a language somebody **typed into** has to satisfy the field's own length - * rules, because it is going to become a translation row; - * - a language nobody typed into is simply absent. It is not "too short" and it - * is not an error - it is a translation that does not exist yet, and the - * language selector inside the input is for reading as much as for writing. - * - * The exception is the default locale of a required field. The engine will not - * store a record without its default translation, so the form says which - * language a value is missing in rather than letting the server refuse a save - * the editor thought was complete. - */ const localizedFieldSchema = ( fieldSpec: ContentFormFieldSpec, defaultLocale: null | string, diff --git a/packages/vitnode/src/content/admin/upload.test.ts b/packages/vitnode/src/content/admin/upload.test.ts index b8bbe7179..add627971 100644 --- a/packages/vitnode/src/content/admin/upload.test.ts +++ b/packages/vitnode/src/content/admin/upload.test.ts @@ -4,11 +4,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const fetches: { formData?: FormData; method: string; path?: string }[] = []; let next: (() => Promise<Response>) | null = null; -/** - * The real `rawApiFetch`, minus the network - including the one behaviour that - * shaped this suite: it **throws** on a 500 rather than returning, and its - * message carries the response body after a newline. - */ vi.mock("../../lib/fetcher/raw", () => ({ rawApiFetch: async ({ formData, diff --git a/packages/vitnode/src/content/admin/upload.ts b/packages/vitnode/src/content/admin/upload.ts index f1fe20cec..8f4e2478f 100644 --- a/packages/vitnode/src/content/admin/upload.ts +++ b/packages/vitnode/src/content/admin/upload.ts @@ -5,14 +5,6 @@ import type { ContentFormSpec } from "./spec"; import { rawApiFetch } from "../../lib/fetcher/raw"; import { contentFileRejectionReason, zodContentFileDescriptor } from "../files"; -/** - * The address of a content type's generated upload route. - * - * Built from the spec the form already has rather than from a route literal, - * because the AdminCP content screen does not know at compile time which plugin - * module it is talking to - the same reason `contentApiFetch` exists on the - * server side. - */ export const contentUploadPath = ( spec: Pick<ContentFormSpec, "permissionModule">, field: string, diff --git a/packages/vitnode/src/content/advanced.test-d.ts b/packages/vitnode/src/content/advanced.test-d.ts index 8645701e0..adccfe96b 100644 --- a/packages/vitnode/src/content/advanced.test-d.ts +++ b/packages/vitnode/src/content/advanced.test-d.ts @@ -18,16 +18,6 @@ import type { import { defineContentType } from "./define"; import { field } from "./fields"; -/** - * The type-level contract of Stage 6. - * - * The interesting assertions are the negative ones. A group that leaked its - * flattened column names into the public type, a collection that crept into a - * list row, a private leaf that showed up in a public response, an index that - * accepted a repeatable path - each is a bug the compiler is the only thing - * that catches early, and each is asserted here rather than hoped for. - */ - const categoryContentType = defineContentType({ fields: { name: field.text({ required: true }) }, id: "test.d-category", @@ -372,22 +362,6 @@ describe("the typed collection API", () => { }); describe("variance", () => { - /** - * A concrete model stays assignable to the erased one. - * - * Load-bearing, and easy to break by accident: every route builder, every - * registry and every piece of background work is written against - * {@link AnyContentModel}. - * - * Asserted against that alias rather than against - * `ContentModel<AnyContentTypeDefinition>`, because `ContentModel` is - * genuinely invariant in its definition - `create` takes it, `findMany` - * returns it. Spelling the erased side `ContentModel<AnyContentTypeDefinition>` - * only ever passed because TypeScript measured the parameter's variance and - * skipped the structural check; `AnyContentModel` erases outright, so this - * asserts the property the codebase actually depends on instead of a compiler - * heuristic that can stop applying. - */ it("keeps a concrete model assignable to the erased one", () => { expectTypeOf<ContentModel<Article>>().toExtend<AnyContentModel>(); }); diff --git a/packages/vitnode/src/content/advanced.test.ts b/packages/vitnode/src/content/advanced.test.ts index 44a391e34..c222c84b9 100644 --- a/packages/vitnode/src/content/advanced.test.ts +++ b/packages/vitnode/src/content/advanced.test.ts @@ -3,15 +3,6 @@ import { describe, expect, it } from "vitest"; import { defineContentType } from "./define"; import { field } from "./fields"; -/** - * Definition-time validation for Stage 6. - * - * Every case here is a mistake whose *first* symptom would otherwise be a query - * against a table that does not exist, a column two fields quietly share, or a - * localized list nothing knows how to reorder. They fail at import time, which - * is to say before the process serves anything. - */ - const base = { id: "test.advanced", tableName: "test_advanced", diff --git a/packages/vitnode/src/content/advanced.ts b/packages/vitnode/src/content/advanced.ts index 2aba3810c..5768b358c 100644 --- a/packages/vitnode/src/content/advanced.ts +++ b/packages/vitnode/src/content/advanced.ts @@ -32,16 +32,6 @@ import { partitionContentStorage, } from "./paths"; -/** - * Resolves - and checks - everything Stage 6 adds to a content type. - * - * Every rule here fails at **definition time**, which is to say at import time, - * which is to say before the process is serving anything. A generated table name - * that collides, a leaf that shadows a declared column, a localized repeatable: - * all of them are mistakes whose first symptom would otherwise be a query - * against a table that does not exist, on a Tuesday, in production. - */ - const emptyAdvanced: ResolvedContentAdvancedConfig = { junctions: [], leaves: [], @@ -56,15 +46,6 @@ export const contentAdvancedDisabled = (): ResolvedContentAdvancedConfig => ({ repeatables: [], }); -/** - * `("example_articles", "relatedArticles")` -> `example_articles_related_articles`. - * - * Deterministic and clamped: a long base table plus a long field name passes - * Postgres' 63-character limit easily, and Postgres truncates silently - so two - * fields whose names differ only past the cut would generate one table and - * quietly share it. `clampWithFingerprint` is what the index and translation - * table names already use. - */ export const contentCollectionTableName = ( tableName: string, field: string, @@ -145,21 +126,6 @@ const assertLeafFields = ({ } }; -/** - * Every rule a **group** has to satisfy. - * - * The two nullability rules are the ones worth the words, because both are about - * making "the group has no value" and "one leaf happens to be empty" two - * different states rather than one ambiguous row: - * - * 1. `nullable: true` needs every leaf nullable. `seo: null` writes `NULL` to - * every leaf column, and it cannot do that to a `NOT NULL` one. - * 2. A group that is not `required: true` may be left out of a create payload, - * so every leaf has to be writable without input - nullable or defaulted. A - * `required: true` non-nullable leaf inside an optional group would be a row - * that can never be inserted, which is the same failure `assertField` already - * catches one level up. - */ const assertGroup = ( id: string, name: string, @@ -249,15 +215,6 @@ const assertRepeatable = ( } }; -/** - * The rules a to-many reference obeys, whatever it points at. - * - * One function for `relation` and `user` because the three things it refuses are - * properties of the *storage* rather than of the target: a junction row is not a - * column, so it cannot be null, cannot be per-language, and has nothing for - * `"set null"` to null. The noun changes so the message reads like the field the - * author actually wrote. - */ const assertReferenceCollection = ( id: string, name: string, @@ -302,19 +259,6 @@ const assertReferenceCollection = ( } }; -/** - * Every rule a to-many **file** field obeys. - * - * Its own function rather than a branch in {@link assertReferenceCollection}, - * because the two differ in what they are allowed to say: a relation carries an - * `onDelete` the author chooses, while a gallery's is fixed at `restrict` by the - * engine - Postgres refusing to delete a file a record still shows is the whole - * point - and a gallery carries a `max`, which a relation does not. - * - * The three refusals are properties of the *storage*, exactly as they are for a - * relation: a junction row is not a column, so it cannot be null, cannot be - * per-language, and the empty set is what "no files" looks like. - */ const assertFileCollection = ( id: string, name: string, @@ -348,13 +292,6 @@ const assertFileCollection = ( } }; -/** - * A to-one **file** field may not carry `min`, `max` or `ordered`. - * - * All three describe a list, and one file is not one. Refused rather than - * ignored: a `field.file({ max: 5 })` that silently stored one file is a - * gallery the author thinks they declared. - */ const assertSingleFile = ( id: string, name: string, @@ -376,12 +313,6 @@ const assertSingleFile = ( ); }; -/** - * A to-one reference may not carry `ordered`, which would mean nothing. - * - * Applied to `user` as well as `relation`: `field.user({ ordered: true })` - * without `multiple` is the same mistake, and one person has no order either. - */ const assertReference = ( id: string, name: string, @@ -408,13 +339,6 @@ const assertReference = ( } }; -/** - * Checks every advanced field and resolves the tables they generate. - * - * Runs from `defineContentType` before anything else reads the field map, so a - * generated column name is known to be free by the time the index resolver, the - * schema builder and the admin resolver each look at it. - */ export const resolveContentAdvanced = ({ fields, id, @@ -477,13 +401,6 @@ export const resolveContentAdvanced = ({ return { junctions, leaves, repeatables: repeatableTables }; }; -/** - * Every generated leaf column has to be a name nothing else claims. - * - * `seo.title` compiles to `seoTitle`, and a content type that *also* declares a - * field called `seoTitle` would generate one column and have two fields read it - * - which is a silent data bug, not a crash, so it is refused here. - */ const assertLeafColumnsAreFree = ( id: string, fields: ContentFieldMap, @@ -526,15 +443,6 @@ const assertLeafColumnsAreFree = ( } }; -/** - * Two advanced fields must not generate the same table. - * - * Reachable in exactly one way that is not a typo: two long field names whose - * generated table names collide **after** the identifier clamp. The fingerprint - * makes that vanishingly unlikely rather than impossible, so it is checked - * rather than assumed - and a content type must not generate a table sharing its - * own name either. - */ const assertGeneratedTableNames = ( id: string, tableName: string, @@ -555,18 +463,6 @@ const assertGeneratedTableNames = ( } }; -/** - * Exactly one of `self` and `target`, on every relation. - * - * Runs **before** `bindSelfRelations` rebinds the thunk, which is the only - * moment the two are still distinguishable: after binding, a self-relation's - * `target` is a real function too. - * - * Checked here rather than by a union in `field.relation`'s signature, because - * a union there would stop TypeScript inferring `self` as a literal - and - * `ContentReferences` reads that literal to decide which relations a database - * module has to supply a thunk for. - */ export const assertContentRelationTargets = ( id: string, fields: ContentFieldMap, diff --git a/packages/vitnode/src/content/boundaries.test.ts b/packages/vitnode/src/content/boundaries.test.ts index af0736f41..2ff332c05 100644 --- a/packages/vitnode/src/content/boundaries.test.ts +++ b/packages/vitnode/src/content/boundaries.test.ts @@ -31,22 +31,6 @@ const importsFrom = (path: string): string[] => .map(match => match[1] ?? match[2]) .filter(Boolean); -/** - * The layer rule the whole engine rests on, asserted rather than remembered. - * - * `content/` and `content/server/` are loaded by `apps/api` - a plain - * `@hono/node-server` process - and by drizzle-kit, which executes - * `src/database/*.ts` to read the tables. Both `next/*` and `server-only` - * throw there, so an accidental import does not fail in CI: it fails when - * somebody runs a migration. - * - * The rule used to have two halves, and only one of them is left. `content/next/` - * was the layer those imports were *allowed* in - the Next.js host adapter over - * `content/delivery.ts` and the Hono delivery routes - and `content/admin/` was - * excluded alongside it because `fetch.server.ts` there carried `server-only` on - * purpose. Both are gone, so the whole of `content/**` is now held to the rule - * that two thirds of it were held to before. - */ describe("layer boundaries", () => { const engineFiles = filesUnder(here).filter( path => !/\.test(-d)?\.tsx?$/.test(path), diff --git a/packages/vitnode/src/content/cache.delivery.test.ts b/packages/vitnode/src/content/cache.delivery.test.ts index d641e3833..8dd135a99 100644 --- a/packages/vitnode/src/content/cache.delivery.test.ts +++ b/packages/vitnode/src/content/cache.delivery.test.ts @@ -10,16 +10,6 @@ import { contentPublicSlugTag, } from "./cache"; -/** - * The delivery cache tags, and the promise that a content type without `delivery` - * produces exactly the tags it always produced. - * - * That second half is the important one and is why the assertions are exact strings - * rather than "some revalidation happened": the whole of Stage 8's opt-in claim at - * this layer is that an existing content type's tag list does not move, and only a - * byte comparison can show it. - */ - const ID = "example.article"; describe("delivery tag builders", () => { diff --git a/packages/vitnode/src/content/cache.ts b/packages/vitnode/src/content/cache.ts index 7df9c5abf..d2f9b8e16 100644 --- a/packages/vitnode/src/content/cache.ts +++ b/packages/vitnode/src/content/cache.ts @@ -4,40 +4,12 @@ import { CONTENT_CACHE_TAG_MAX_LENGTH } from "./const"; import { clampWithFingerprint } from "./hash"; import { contentLocalesMatch, normalizeContentLocale } from "./locale"; -/** - * Cache tags for the generated public API. - * - * Pure strings and exported, with no framework package behind them: a host can - * tag its own cached reads with exactly the same values, which is the only way - * its pages get invalidated alongside the generated ones. - * - * Format: `content:{contentTypeId}:{scope}[:{locale}][:{key}]`. No plugin id - a - * content type id is already globally unique (`validateContentTypes` enforces it) - * and already namespaced, as in `example.article`. - * - * The locale segment is present **only for a localized content type**, so every - * tag a Stage 1-4 content type has ever produced is byte-identical to what it - * produced before. It sits after the scope rather than before it so the two forms - * can never collide: `content:x:list` is three segments and - * `content:x:list:pl` is four, whatever the locale happens to spell. - * - * Next caps a tag at 256 characters and a slug can be 160, so every builder - * runs its result through the same fingerprint clamp the index names use. - * Deterministic, collision-resistant, no new dependency. - */ const tag = (...parts: (number | string)[]): string => clampWithFingerprint( ["content", ...parts.map(String)].join(":"), CONTENT_CACHE_TAG_MAX_LENGTH, ); -/** - * The locale segment, normalized, or nothing at all. - * - * Normalized because `PL` and `pl` address the same page and must therefore - * expire together - a tag is a string comparison, so the casing has to be settled - * here rather than hoped for at every call site. - */ const localeParts = (locale: string | undefined): string[] => { if (locale === undefined) return []; @@ -46,13 +18,6 @@ const localeParts = (locale: string | undefined): string[] => { return normalized === "" ? [] : [normalized]; }; -/** - * Every public list page of one content type, in one locale. - * - * Per locale rather than global: publishing a Polish translation changes no - * English list page, and throwing that cache away would be a cost with no - * correctness to show for it. - */ export const contentPublicListTag = ( contentTypeId: string, locale?: string, @@ -65,86 +30,31 @@ export const contentPublicItemTag = ( locale?: string, ): string => tag(contentTypeId, "item", ...localeParts(locale), id); -/** - * One row, by the URL it answers to, in one locale. - * - * The locale is load-bearing here and not merely tidy: two languages routinely - * answer to the *same* slug (`/en/about` and `/pl/about`), so a locale-less slug - * tag would make one language's edit expire the other's page - and, worse, make - * one language's publish appear to expire a page it never touched. - */ export const contentPublicSlugTag = ( contentTypeId: string, slug: string, locale?: string, ): string => tag(contentTypeId, "slug", ...localeParts(locale), slug); -/** - * The delivery metadata of one record, in one locale. - * - * Separate from {@link contentPublicItemTag} even though both are keyed by - * identifier, because the two hold different responses: the item tag covers the - * public projection a page renders, and this one covers the canonical path, the - * alternates and the SEO metadata its `<head>` is built from. A page that reads - * both is tagged with both; one that renders only metadata - a `generateMetadata` - * that does not fetch the body - is tagged with this alone and is not thrown away - * when an unrelated field of the record changes. - */ export const contentDeliveryTag = ( contentTypeId: string, id: number, locale?: string, ): string => tag(contentTypeId, "delivery", ...localeParts(locale), id); -/** - * One historical URL's redirect lookup, in one locale. - * - * Keyed by the **old** slug, which is what a request for a moved page arrives - * with. The locale is load-bearing for the same reason it is on the slug tag: two - * languages routinely retire the same slug, and a locale-less tag would make one - * language's slug change expire the other language's redirect. - */ export const contentDeliveryRedirectTag = ( contentTypeId: string, slug: string, locale?: string, ): string => tag(contentTypeId, "redirect", ...localeParts(locale), slug); -/** - * One content type's sitemap - the whole thing, or one locale's share of it. - * - * Both forms exist and both are expired together by a mutation that changes what - * is listed: a localized content type has one sitemap per language *and* an index - * that enumerates them, and publishing a Polish translation changes the Polish - * file and the number of files. A content type that is not localized only ever - * produces the three-segment form. - */ export const contentDeliverySitemapTag = ( contentTypeId: string, locale?: string, ): string => tag(contentTypeId, "sitemap", ...localeParts(locale)); -/** - * How hard a mutation expires the tags it touched. - * - * Lives here, in the client-safe layer, because the background - * [bridge](./server/revalidate-bridge.ts) has to name a mode from the queue - * process, which holds no cache of its own and cannot import the front end's. - * It is exported from `@vitnode/core/content`, which is where it has always - * resolved from - a deleted adapter used to re-export it as well. - */ export type ContentInvalidationMode = "immediate" | "stale-while-revalidate"; -/** - * One locale's share of a mutation. - * - * `isPublic` means **reachable through the public API in this locale**, which for - * a content type with `fallback: "default"` includes a locale that has no - * translation of its own and is being served the default one. That is the whole - * reason this is a flag rather than something derived from the translation row: - * the question a cache tag answers is "was there a page here", not "was there a - * row here". - */ export interface ContentLocaleInvalidation { isPublic: boolean; locale: string; @@ -153,47 +63,11 @@ export interface ContentLocaleInvalidation { wasPublic: boolean; } -/** - * The delivery half of one mutation's invalidation. - * - * Absent for every content type without `delivery`, which is what makes Stage 8 - * opt-in at the cache layer too: `contentInvalidationTags` returns exactly the - * strings it always returned when this is `undefined`, byte for byte, so nothing - * existing has to be re-tagged and no warm cache is thrown away for a feature the - * content type does not use. - * - * Nothing in here names a locale or a slug of its own: both are already on the - * input - `locales[].slugs` carries the old and the new URL of every locale the - * mutation reached - and deriving the delivery tags from the same data is what - * keeps the public tags and the delivery tags from disagreeing about what moved. - */ export interface ContentDeliveryInvalidation { /** What this mutation did to the sitemap. See {@link ContentSitemapChange}. */ sitemap: ContentSitemapChange; } -/** - * How one mutation changed a sitemap, split into the two things a tag can cache. - * - * One boolean is not enough, and the reason is `<lastmod>`. A sitemap entry carries - * `lastModified`, derived from `updatedAt` - so a plain title edit on a published - * record changes the **bytes** of that locale's sitemap file even though the set of - * URLs in it is identical. Treating "the sitemap changed" as "membership changed" - * leaves a cached file serving a stale `<lastmod>` for as long as the tag lives. - * - * The two are separate because they cache different documents: - * - * - **`contentChanged`** - the sitemap *file* for this locale is no longer - * byte-identical. True for any real mutation of a record that is or was publicly - * reachable, whether what moved was a URL, a title or an SEO field. - * - **`indexChanged`** - the set of sitemap files, or how many of them there are, - * moved. True only when public reachability flipped, because an index lists files - * and their count follows the number of URLs. A title edit changes neither. - * - * Declared here rather than next to the write path because `cache.ts` is the - * client-safe layer and must not import from `server/` - the same reason the tag - * builders are plain strings a directory up from Drizzle. - */ export interface ContentSitemapChange { contentChanged: boolean; indexChanged: boolean; @@ -206,15 +80,7 @@ export interface ContentInvalidationInput { id: number; /** Whether the row is publicly reachable *after* the mutation. */ isPublic: boolean; - /** - * The locales this mutation affected, for a **localized** content type. - * - * When present it is authoritative and the three flat fields above are not - * consulted: a localized content type has no locale-less public URL, so a tag - * without a locale segment would name a page that does not exist. Absent - which - * is every Stage 1-4 content type - the flat fields are the whole input and the - * tags are exactly what they have always been. - */ + locales?: readonly ContentLocaleInvalidation[]; /** * Every slug the row answered to across the mutation. On a slug change that @@ -234,18 +100,6 @@ const slugTags = ( .filter(slug => slug !== "") .map(slug => contentPublicSlugTag(contentTypeId, slug, locale)); -/** - * The exact tags one mutation should invalidate - and no others. - * - * Nothing global is ever returned, and one content type's mutation never - * touches another's tags. A row that was private before and is private after - * touches nothing at all: creating a draft, or editing one, changes no public - * response, so invalidating a public list for it would just throw away a warm - * cache for free. The same rule applies per locale, which is what keeps a Polish - * publish from expiring every English page. - * - * Pure, so the whole matrix is a table test rather than a mocking exercise. - */ export const contentInvalidationTags = ({ contentTypeId, delivery, @@ -288,22 +142,6 @@ export const contentInvalidationTags = ({ ]; }; -/** - * The delivery tags one mutation touched, per locale it reached. - * - * Three scopes, and each answers a different question a page asked: - * - * - **delivery metadata**, keyed by identifier, because a `generateMetadata` reads - * the canonical path and the alternates of one record; - * - **redirect lookups**, keyed by every slug the record answered to across the - * mutation, because a resolver caches "this old URL points there" and a second - * slug change moves the destination; - * - **the sitemap**, per locale and as a whole, but only when the set of listed - * URLs actually changed. - * - * Empty when the content type has no delivery layer, which is the whole of Stage - * 8's opt-in promise at this layer. - */ const deliveryTags = ({ contentTypeId, delivery, @@ -351,14 +189,6 @@ const deliveryTags = ({ ]; }; -/** - * What one locale looks like around a mutation, as the fan-out reads it. - * - * `hasOwnTranslation` is the one that decides fan-out, and it is deliberately - * separate from `isPublic`: a locale served by the default translation is public - * *and* has no translation of its own, and it is exactly that combination which - * makes it a downstream consumer of the default locale's cache. - */ export interface ContentLocaleState { /** Whether this locale is served by a translation of its own. */ hasOwnTranslation: boolean; @@ -373,27 +203,6 @@ export interface ContentLocaleState { wasPublic: boolean; } -/** - * Which locales one mutation actually reaches. - * - * Two rules, and both come straight from what a public response is made of: - * - * 1. **A shared field is in every language's response.** So is the base row's - * publication state, which gates all of them. A change to either reaches every - * locale, and pretending otherwise would leave a withdrawn record readable in - * every language but the one it was withdrawn from. - * 2. **A translation reaches its own locale** - and, when the content type falls - * back to the default *and the translation that moved is the default one*, every - * locale that has no translation of its own. Those are precisely the locales - * whose pages were built from the row that just changed. - * - * A translation in a non-default locale reaches nothing else, whatever the - * fallback setting: nothing falls back to it. - * - * Pure, and separate from {@link contentInvalidationTags}, because "which locales" - * and "which tags" are two rules that fail in different ways - and the first one - * is the one worth a table test. - */ export const contentLocaleInvalidations = ({ changed, defaultLocale, @@ -428,26 +237,11 @@ export const contentLocaleInvalidations = ({ })); }; -/** - * One locale as it stands right now, without the "before" half. - * - * What `contentPublicLocaleStates` reads out of the database, and what a caller - * takes twice - once on each side of a mutation. Client-safe, because the AdminCP - * Server Action holds a pair of these and diffs them without ever touching - * Drizzle. - */ export type ContentPublicLocaleState = Omit< ContentLocaleState, "previousSlug" | "wasPublic" >; -/** - * Folds a before-and-after pair of locale snapshots into invalidation states. - * - * Pure, and separate from the reads, so the "what moved" arithmetic is a table - * test rather than a database fixture. A locale present on one side only is still - * reported: that is exactly a language that gained or lost its public page. - */ export const diffContentPublicLocaleStates = ( before: readonly ContentPublicLocaleState[], after: readonly ContentPublicLocaleState[], @@ -476,16 +270,6 @@ export const diffContentPublicLocaleStates = ( }); }; -/** - * How hard a localized mutation should expire the tags it touched. - * - * The locale-aware half of the same rule the base row follows: - * stale-while-revalidate is safe only when *every* locale this mutation reached - * was public before, is public after, and still answers to the same URL. Anything - * else removed public reachability somewhere, and a withdrawn page must not be - * served even once more - so one locale losing its page makes the whole - * invalidation immediate rather than only its own. - */ export const contentLocaleInvalidationMode = ( locales: readonly ContentLocaleInvalidation[], ): ContentInvalidationMode => { @@ -499,14 +283,6 @@ export const contentLocaleInvalidationMode = ( return unchanged ? "stale-while-revalidate" : "immediate"; }; -/** - * Whether a row is reachable through the public API right now. - * - * The JavaScript half of `publishedCondition`, kept in the client-safe layer so - * a server action can answer "was this public?" from a mutation response - * without a second query. Both read the same three clauses; the SQL one is - * still what the database enforces. - */ export const isContentPubliclyVisible = ({ publishedAt, status, @@ -523,14 +299,6 @@ export const isContentPubliclyVisible = ({ return !Number.isNaN(date.getTime()) && date.getTime() <= Date.now(); }; -/** - * Whether one *translation* is reachable through the public API right now. - * - * Subordination, in JavaScript: **the base row and the translation must both be - * published**. It is the same rule `contentPublicCondition` enforces in SQL, and - * it is stated as an `&&` of the existing predicate rather than as a second set of - * clauses so the two cannot drift into disagreeing about what "published" means. - */ export const isContentTranslationPubliclyVisible = ({ base, translation, diff --git a/packages/vitnode/src/content/conflicts.ts b/packages/vitnode/src/content/conflicts.ts index aee8f39c4..67fdb44ea 100644 --- a/packages/vitnode/src/content/conflicts.ts +++ b/packages/vitnode/src/content/conflicts.ts @@ -20,14 +20,6 @@ export type ContentTranslationConflictCode = export type ContentUnprocessableCode = (typeof CONTENT_UNPROCESSABLE_CODES)[keyof typeof CONTENT_UNPROCESSABLE_CODES]; -/** - * The 409 body an editorial route answers with. - * - * A discriminated union so one OpenAPI schema describes the whole status: a - * generated client branches on `code` rather than parsing English. Only - * editorial content types answer this way - a Stage 1-3 route keeps the plain - * text 409 it has always returned, so nothing existing changes shape. - */ export const zodContentConflict = z.discriminatedUnion("code", [ z.object({ code: z.literal(CONTENT_CONFLICT_CODES.version), @@ -45,15 +37,6 @@ export const zodContentConflict = z.discriminatedUnion("code", [ export type ContentConflict = z.infer<typeof zodContentConflict>; -/** - * The 409 body a translation route answers with. - * - * Its own union rather than three more members of {@link zodContentConflict}: - * that one is the contract Stage 4 editorial routes already publish, and every - * generated client is built from it. A translation route is new, so it can carry - * a shape that names the locale in every arm - which is the one thing a locale - * tab strip has to know to point at the right tab. - */ export const zodContentTranslationConflict = z.discriminatedUnion("code", [ z.object({ code: z.literal(CONTENT_TRANSLATION_CONFLICT_CODES.version), @@ -107,20 +90,6 @@ export const parseContentTranslationConflict = ( } }; -/** - * The 409 body a write refused by the slug reservation answers with. - * - * Its own schema rather than a third member of {@link zodContentConflict}: that - * union is the contract Stage 4 editorial routes already publish, and widening it - * would change a response schema every generated client is built from. A route - * that can hit the reservation declares this one **alongside** it, so a client - * that only knows the older union still parses the arms it knows. - * - * `locale` is `null` for a content type whose slug is shared, and the locale code - * when the slug is localized - which is exactly the scope the reservation covers. - * There is deliberately no owning-record id: a 409 on a public-facing address must - * not become a way to enumerate records the caller cannot read. - */ export const zodContentDeliveryConflict = z.object({ code: z.literal(CONTENT_DELIVERY_CODES.slugReserved), contentTypeId: z.string(), @@ -151,24 +120,13 @@ export const parseContentDeliveryConflict = ( export const zodContentUnprocessable = z.object({ code: z.literal(CONTENT_UNPROCESSABLE_CODES.notRestorable), contentTypeId: z.string(), - /** - * The content type's own field names, and nothing else. Never a Zod issue - * tree - that names internal paths, and the route's OpenAPI schema already - * describes the contract. - */ + fields: z.array(z.string()), revisionId: z.number().int(), }); export type ContentUnprocessable = z.infer<typeof zodContentUnprocessable>; -/** - * The 400 body a refused schedule answers with. - * - * A code rather than prose for the same reason the 409 carries one: the dialog - * points at the date field for one of these and shows a general error for the - * other, and it cannot branch on English. - */ export const zodContentScheduleRejection = z.object({ code: z.enum([ CONTENT_SCHEDULE_CODES.inPast, @@ -197,13 +155,6 @@ export const parseContentScheduleRejection = ( } }; -/** - * Reads a structured error out of a response body. - * - * Returns `null` for anything that does not match - a plain-text 409 from a - * non-editorial route, an HTML error page from a proxy - so a caller can fall - * back to its generic message instead of throwing on the error path. - */ export const parseContentConflict = ( body: string | undefined, ): ContentConflict | null => { diff --git a/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts index cc58355de..397ffa86b 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -2,24 +2,8 @@ export const CONTENT_SYSTEM_FIELDS = ["id", "createdAt", "updatedAt"] as const; export const CONTENT_PUBLICATION_FIELDS = ["status", "publishedAt"] as const; -/** - * The column `editorial: { enabled: true }` adds. - * - * Its own list rather than an entry in `CONTENT_SYSTEM_FIELDS`, for the same - * reason the publication fields are separate: it exists only for a content type - * that opted in, so a Stage 1 type stays free to declare a field of its own - * called `version`. - */ export const CONTENT_EDITORIAL_FIELDS = ["version"] as const; -/** - * The columns a generated translation table always carries. - * - * Its own list rather than an entry in {@link CONTENT_SYSTEM_FIELDS}: these live - * on the *translation* table, so a content type stays free to declare a shared - * field called `itemId` - it would land on the base table, where nothing - * generated claims that name. - */ export const CONTENT_TRANSLATION_SYSTEM_FIELDS = [ "itemId", "languageId", @@ -28,14 +12,6 @@ export const CONTENT_TRANSLATION_SYSTEM_FIELDS = [ "updatedAt", ] as const; -/** - * The two columns a translation row gains when the content type has publication. - * - * The same names the base table uses, and deliberately so: a translation's - * lifecycle is the same lifecycle, one row down. Present only with - * `publication: { enabled: true }` - without it there is no draft state for a - * translation's own status to be subordinate to. - */ export const CONTENT_TRANSLATION_PUBLICATION_FIELDS = [ "status", "publishedAt", @@ -103,16 +79,6 @@ export const CONTENT_ENUM_DEFAULT_LENGTH = 64; export const CONTENT_SLUG_DEFAULT_LENGTH = 160; -/** - * Field kinds that may carry `localized: true`. - * - * Text only, and deliberately so. `boolean`, `number`, `date`, `dateTime` and - * `enum` hold values, not prose - a per-locale `true` is not a translation, and - * an enum's *labels* are already handled by the ordinary i18n system while its - * *identifiers* have to stay the same in every language or nothing can filter on - * them. `relation` and `user` are foreign keys, and per-locale references are - * explicitly out of scope. - */ export const CONTENT_LOCALIZED_FIELD_KINDS = [ "slug", "text", @@ -130,53 +96,17 @@ export const isLocalizableFieldKind = (kind: string): boolean => // File fields // --------------------------------------------------------------------------- -/** - * A normalised extension rule: a leading dot, then lowercase letters or digits. - * - * One segment only, because `getFileExtension` reads one - a rule spelled - * `.tar.gz` would never match a file called `archive.tar.gz`, which is a silent - * "nothing is allowed" rather than the strict allowlist somebody wrote. - */ export const CONTENT_FILE_EXTENSION_PATTERN = /^\.[a-z0-9]+$/; /** `type/subtype`, lowercased. Parameters (`; charset=`) are not a file type. */ export const CONTENT_FILE_MIME_PATTERN = /^[a-z0-9][a-z0-9!#$&^_+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/; -/** - * How a `pluginId` is written into a storage key: `@vitnode/blog` -> `vitnode-blog`. - * - * A plugin id is a package name, so it carries the two characters a folder - * segment may not: the scope's `@` and the `/` between scope and name. Neither - * can simply be dropped - `@vitnode/blog` and `vitnodeblog` would collide with a - * plugin actually called that - so the scope separator becomes a hyphen and the - * leading `@` goes, which is both readable and reversible enough to recognise in - * a bucket listing. - */ export const CONTENT_FILE_PLUGIN_SEPARATOR = "-"; -/** - * How many files one `field.file({ multiple: true })` may hold, unless it says - * otherwise, and the ceiling no `max` may exceed. - * - * A default ceiling exists for the same reason `maxBytes` is mandatory: every - * entry is a real stored object, every write replaces the whole list in one - * statement, and every entry pins its file against deletion for as long as the - * record - or a retained revision naming it - lives. Twenty is a generous - * gallery; an author who needs a media library should model a content type and - * relate to it. - */ export const CONTENT_FILE_COLLECTION_DEFAULT_MAX = 20; export const CONTENT_FILE_COLLECTION_ABSOLUTE_MAX = 200; -/** - * Machine-readable reasons a file was refused - at upload, and again on save. - * - * One list for both, because they are the same four questions asked twice: the - * upload route asks them of the file in the request, and a content mutation asks - * them of the `core_files` row an identifier names. A client that can act on - * "too big" at upload time can act on it either way. - */ export const CONTENT_FILE_CODES = { extension: "CONTENT_FILE_EXTENSION_NOT_ALLOWED", /** The role may view this content type but not write it. */ @@ -186,26 +116,11 @@ export const CONTENT_FILE_CODES = { mimeType: "CONTENT_FILE_MIME_TYPE_NOT_ALLOWED", missing: "CONTENT_FILE_NOT_FOUND", size: "CONTENT_FILE_TOO_LARGE", - /** - * The install cannot store anything: no adapter configured, or the image - * pipeline failed to load. - * - * A configuration fault rather than a bad file, and the person uploading needs - * to be told which - "please try again" would have them try for ever. - */ + storage: "CONTENT_FILE_STORAGE_UNAVAILABLE", /** The URL named a field this content type does not have, or that is not a file. */ unknownField: "CONTENT_FILE_FIELD_UNKNOWN", - /** - * The image was read, and then could not be re-encoded - a limit of the target - * format rather than anything wrong with the file. - * - * WebP allows at most 16383 pixels per side, so an install with - * `storage.image.webp` refuses a 20000px-wide PNG here - a PNG that is - * entirely valid, and that resizing fixes. Separate from `invalid` because - * that one says "corrupt", and telling somebody their good file is corrupt - * sends them to re-export it instead of to resize it. - */ + unprocessable: "CONTENT_FILE_UNPROCESSABLE", } as const; @@ -219,23 +134,6 @@ export const CONTENT_TRANSLATION_TABLE_SUFFIX = "_translations"; /** What separates a container from its leaf in a canonical path: `seo.title`. */ export const CONTENT_PATH_SEPARATOR = "."; -/** - * Field kinds that may sit inside a `group` or a `repeatable`. - * - * Scalars only, and every exclusion is a decision rather than an oversight: - * - * - **group** - nesting would need a second level of column naming and a second - * level of partial-update merging, for no modelling gain a second group next - * to the first does not already give. - * - **repeatable** - a child table of a child table is a tree, and a tree needs - * its own ordering, its own cascade and its own restore semantics. - * - **slug** - a slug is a URL segment with a uniqueness scope. Inside a group - * the scope would be the row (fine) and inside a repeatable it would be the - * parent (not a URL at all), so one name would mean two things. - * - **relation** / **user** - a foreign key the relation services do not look - * at is a foreign key nothing maintains. Model it as a to-many relation on the - * content type instead. - */ export const CONTENT_ADVANCED_LEAF_KINDS = [ "boolean", "dateTime", @@ -245,22 +143,9 @@ export const CONTENT_ADVANCED_LEAF_KINDS = [ "textarea", ] as const; -/** - * How many children one repeatable field may hold, unless it says otherwise. - * - * A ceiling exists at all because every write replaces the whole list in one - * statement and every read loads it whole: a repeatable is a handful of FAQ - * entries, not a table. An author who wants more should model a content type. - */ export const CONTENT_REPEATABLE_DEFAULT_MAX = 100; export const CONTENT_REPEATABLE_ABSOLUTE_MAX = 1000; -/** - * How many targets one to-many relation may hold. - * - * Same reasoning as the repeatable ceiling, and the same shape of enforcement: - * the generated schema rejects a longer array before any query runs. - */ export const CONTENT_RELATION_COLLECTION_MAX = 500; /** The first position of an ordered collection. Contiguous from here. */ @@ -274,13 +159,6 @@ export const CONTENT_JUNCTION_SYSTEM_FIELDS = [ "createdAt", ] as const; -/** - * The columns every generated repeatable child table carries. - * - * `id` is a `serial` of its own rather than `(itemId, position)`: position is - * where a row currently sits, and identity has to survive a reorder or "edit the - * third one" means something different after every drag. - */ export const CONTENT_REPEATABLE_SYSTEM_FIELDS = [ "id", "itemId", @@ -297,21 +175,8 @@ export const CONTENT_ADVANCED_CODES = { notOrdered: "CONTENT_RELATION_NOT_ORDERED", } as const; -/** - * What a public read does when a locale has no translation. - * - * Resolved in Stage 5A and *acted on* in Stage 5C: the configuration has to be - * stable before anything reads through it, or every localized content type would - * change public behaviour the moment fallback landed. - */ export const CONTENT_LOCALIZATION_FALLBACKS = ["none", "default"] as const; -/** - * A locale as `core_languages.code` stores one: `en`, `pl`, `pt-BR`, `zh-Hans`. - * - * Matched case-insensitively - the resolver returns the canonical stored code, - * so `PL` in a URL resolves to the `pl` row rather than to a 404. - */ export const CONTENT_LOCALE_PATTERN = /^[a-z]{2,8}(?:[-_][a-z0-9]{2,8})*$/i; /** `core_languages.code` is `varchar(32)`. */ @@ -355,57 +220,22 @@ export const CONTENT_PUBLIC_EXPOSABLE_COLUMNS = [ export const CONTENT_PUBLIC_ALWAYS_ORDERABLE = "publishedAt"; -/** - * Field kinds `search.titleField` may name. - * - * `text` only. A search title is one line, weighted `A` by the index; prose from - * a `textarea` in that slot ruins ranking for every other document, and a slug - * is already in the URL. - */ export const CONTENT_SEARCH_TITLE_KINDS = ["text"] as const; /** Field kinds `search.descriptionField` may name. */ export const CONTENT_SEARCH_DESCRIPTION_KINDS = ["text", "textarea"] as const; -/** - * Field kinds `search.contentFields` may name. - * - * Prose only. `enum`, `number`, `boolean` and `dateTime` are facets, not text: - * full-text-indexing `"draft"` or `"42"` is noise, and it would let a searcher - * probe values. `relation` is a foreign key, and `user` is never public. - */ export const CONTENT_SEARCH_TEXT_KINDS = ["slug", "text", "textarea"] as const; /** The placeholder every `search.pathTemplate` must use. */ export const CONTENT_SEARCH_SLUG_PLACEHOLDER = "{slug}"; -/** - * The placeholder a **localized** `search.pathTemplate` must also use. - * - * Required there rather than optional: a localized content type is indexed once - * per language, and two languages routinely answer to the same slug - so a - * template without it would give every translation of a record the same link, and - * a search hit would point at whichever language the reader happened to be in. - * Refused on a content type that is not localized, where it could only ever - * substitute to nothing. - */ export const CONTENT_SEARCH_LOCALE_PLACEHOLDER = "{locale}"; -/** - * `core_search_index.itemType` is `varchar(100)` and a content type id is used - * verbatim as the item type, so a longer id would fail at insert time - far from - * the definition that caused it. - */ export const CONTENT_SEARCH_ITEM_TYPE_MAX_LENGTH = 100; export const CONTENT_SEARCH_PATH_MAX_LENGTH = 512; -/** - * What a revision records. - * - * One per *real* mutation - a no-op update, an idempotent publish and a - * cancelled schedule all write nothing at all. - */ export const CONTENT_REVISION_OPERATIONS = [ "create", "delete", @@ -415,14 +245,6 @@ export const CONTENT_REVISION_OPERATIONS = [ "update", ] as const; -/** - * What a *translation* revision records. - * - * The same six operations, and the same one-per-real-mutation rule. Its own list - * rather than a reuse of {@link CONTENT_REVISION_OPERATIONS} so the two can - * diverge without a silent widening - a translation cannot be scheduled, and a - * shared row cannot be translated. - */ export const CONTENT_TRANSLATION_REVISION_OPERATIONS = [ "create", "delete", @@ -432,34 +254,15 @@ export const CONTENT_TRANSLATION_REVISION_OPERATIONS = [ "update", ] as const; -/** - * Who performed a mutation. - * - * `system` exists so a scheduled publish needs no fake user id. Who *created* - * the schedule is kept on the schedule row, not invented here. - */ export const CONTENT_ACTOR_TYPES = ["api", "staff", "system"] as const; /** The envelope `snapshot` is stored in, so a future shape change is visible. */ export const CONTENT_REVISION_SNAPSHOT_VERSION = 1; -/** - * How many of the newest revisions are kept per record. - * - * Pruned in the same transaction that writes the new one, so the table stays - * bounded without a background job - an install with no cron adapter must not - * grow forever. - */ export const CONTENT_REVISION_DEFAULT_RETENTION = 50; export const CONTENT_REVISION_MIN_RETENTION = 1; export const CONTENT_REVISION_MAX_RETENTION = 500; -/** - * How long a preview link stays valid. - * - * The ceiling is a day: a preview token is a bearer credential for an - * unpublished record, and its expiry is the only thing that revokes it. - */ export const CONTENT_PREVIEW_DEFAULT_TTL_MINUTES = 15; export const CONTENT_PREVIEW_MIN_TTL_MINUTES = 1; export const CONTENT_PREVIEW_MAX_TTL_MINUTES = 1440; @@ -467,23 +270,8 @@ export const CONTENT_PREVIEW_MAX_TTL_MINUTES = 1440; /** The only placeholder `editorial.preview.pathTemplate` may use. */ export const CONTENT_PREVIEW_TOKEN_PLACEHOLDER = "{token}"; -/** - * The query parameter a preview token travels in on a delivery page. - * - * Without an `editorial.preview.pathTemplate`, a preview link points at the - * record's own canonical page carrying `?preview=<token>` - the page that already - * renders the published record renders the draft instead. Both halves read this - * constant rather than the string, so the page and the link cannot drift apart. - */ export const CONTENT_PREVIEW_QUERY_PARAM = "preview"; -/** - * The preview token format. - * - * Carried inside the signed payload so a future change to the shape is a - * rejected token rather than a misread one - old links stop working, which is - * the correct outcome for a credential whose meaning moved. - */ export const CONTENT_PREVIEW_TOKEN_VERSION = 1; export const CONTENT_PREVIEW_PATH_MAX_LENGTH = 512; @@ -491,55 +279,19 @@ export const CONTENT_PREVIEW_PATH_MAX_LENGTH = 512; /** What a schedule does when it fires. */ export const CONTENT_SCHEDULE_ACTIONS = ["publish", "unpublish"] as const; -/** - * Where a schedule is in its life. - * - * There is deliberately no `failed`. Marking one would need the handler to know - * the queue row's attempt count, which it never receives - and an overdue - * `pending` row with `lastError` set says the same thing with one fewer state - * that can be wrong. - */ export const CONTENT_SCHEDULE_STATUSES = [ "cancelled", "completed", "pending", ] as const; -/** - * How far in the past a `scheduledFor` may be and still be accepted. - * - * One cron tick plus slack. A browser clock a minute behind the server is - * ordinary, and "now" is what the editor meant - rejecting it would be a - * puzzle, not a safeguard. Anything earlier is a mistake worth naming. - */ export const CONTENT_SCHEDULE_PAST_TOLERANCE_MS = 120_000; /** How long a completed or cancelled schedule is kept as an audit trail. */ export const CONTENT_SCHEDULE_RETENTION_DAYS = 30; -/** - * The single core queue task that executes every content schedule. - * - * One task rather than one per content type: `queueTasks` are collected from - * top-level modules only, and `buildContentAdminModule` is nested inside a - * plugin's admin module - so a task registered there would be silently dropped. - */ export const CONTENT_QUEUE_TASK_SCHEDULE = "content-schedule"; -/** - * The follow-up task that announces a schedule that has already happened. - * - * Separate from {@link CONTENT_QUEUE_TASK_SCHEDULE} because the two have - * different failure meanings. The transition is a database write that either - * committed or did not; the effects are an event, a search write and an HTTP - * hop to another process, any of which can fail long after the record is - * already published. Retrying them together would re-run an idempotent publish - * that then skips its own announcements - which is how a scheduled unpublish - * ends up permanently missing its cache invalidation. - * - * Dispatched **inside** the transition's transaction, so the task exists if and - * only if the transition committed. - */ export const CONTENT_QUEUE_TASK_SCHEDULE_EFFECTS = "content-schedule-effects"; /** Machine-readable reasons a schedule was refused. */ @@ -553,32 +305,13 @@ export const CONTENT_SCHEDULE_CODES = { // Content delivery (Stage 8) // --------------------------------------------------------------------------- -/** - * Field kinds `delivery.seo.titleField` may name. - * - * `text` only, and the same reasoning `CONTENT_SEARCH_TITLE_KINDS` gives: a - * `<title>` is one line, a `textarea` in that slot puts a paragraph in a browser - * tab, and a slug is already in the URL the title accompanies. - */ export const CONTENT_DELIVERY_TITLE_KINDS = ["text"] as const; /** Field kinds `delivery.seo.descriptionField` may name. */ export const CONTENT_DELIVERY_DESCRIPTION_KINDS = ["text", "textarea"] as const; -/** - * Field kinds `delivery.seo.noIndexField` may name. - * - * `boolean` only: "should a crawler index this" has two answers, and a truthy - * string would make the sitemap's exclusion rule depend on what somebody typed. - */ export const CONTENT_DELIVERY_NO_INDEX_KINDS = ["boolean"] as const; -/** - * The `changefreq` values the sitemap protocol defines. - * - * Validated rather than passed through: a crawler ignores an unknown value - * silently, so a typo would be a hint nobody ever receives. - */ export const CONTENT_SITEMAP_CHANGE_FREQUENCIES = [ "always", "hourly", @@ -598,46 +331,12 @@ export const isContentSitemapChangeFrequency = ( ): value is (typeof CONTENT_SITEMAP_CHANGE_FREQUENCIES)[number] => typeof value === "string" && sitemapChangeFrequencies.has(value); -/** - * The sitemap protocol's own ceiling: 50,000 URLs in one file. - * - * A delivery sitemap page never returns more than this, and the index helper - * chunks by it - so a content type with a million records produces a sitemap - * index rather than an invalid document. - */ export const CONTENT_SITEMAP_MAX_URLS = 50_000; -/** - * How many URLs one `sitemap.list` page returns by default. - * - * Far below the protocol ceiling on purpose: a page is one keyset query plus one - * batched translation read, and 1,000 rows is a response a serverless function - * can hold without thinking about it. A caller that wants a whole 50,000-URL - * file asks for it explicitly. - */ export const CONTENT_SITEMAP_DEFAULT_PAGE_SIZE = 1_000; -/** - * The redirect a moved canonical URL answers with. - * - * `308` rather than `301`, and the difference is not cosmetic: `301` lets a - * client rewrite the method to `GET`, `308` does not. A content URL is read with - * `GET` today, so the two behave identically now - and only one of them still - * behaves correctly the day somebody `POST`s to a form under a moved path. - * - * One status, not a configuration knob: every historical URL of every content - * type answers with this, so there is no per-content-type setting to get wrong - * and no reason for two of them to disagree. - */ export const CONTENT_DELIVERY_REDIRECT_STATUS = 308; -/** - * How a delivery resolution came out. - * - * `not_found` rather than a `gone` tombstone: the engine has no abstraction that - * distinguishes "deleted on purpose" from "unpublished for now", and a `410` - * that guessed would tell a crawler to forget a URL that is coming back. - */ export const CONTENT_DELIVERY_RESOLUTIONS = [ "content", "not_found", @@ -655,46 +354,13 @@ export const CONTENT_DELIVERY_CODES = { slugReserved: "CONTENT_DELIVERY_SLUG_RESERVED", } as const; -/** - * How the AdminCP may present a create or an edit form. - * - * `dialog` is first because it is the default, and the default is the whole - * point: a content type written before page mode existed keeps the screen it - * already had, and nothing about its behaviour moves until somebody says so. - */ export const CONTENT_ADMIN_FORM_MODES = ["dialog", "page"] as const; -/** - * The last URL segment of a generated create page, and of an edit one. - * - * Reserved rather than free-form: `/admin/content/[...slug]` resolves an - * `admin.path` from the same slug, so these two words are what tells - * `/admin/content/blog/articles` from `/admin/content/blog/articles/create`. A - * content type that genuinely lives at `blog/articles/create` still wins - the - * exact match is tried first. - */ export const CONTENT_ADMIN_CREATE_SEGMENT = "create"; export const CONTENT_ADMIN_EDIT_SEGMENT = "edit"; -/** - * One segment of `admin.path`, the address of a content type's AdminCP screens. - * - * The same shape as {@link CONTENT_PUBLIC_PATH_PATTERN}, checked a segment at a - * time: an admin path is allowed several of them, because the URL keeps the - * plugin's own namespace in front of the entity - `blog/articles`. - */ export const CONTENT_ADMIN_PATH_SEGMENT_PATTERN = /^[a-z][a-z0-9-]*$/; -/** - * Every content type gets the first four staff permissions. `can_publish` is - * generated only for content types with `publication: { enabled: true }`, and - * `can_restore` only for those with `editorial: { enabled: true }`. - * - * There is deliberately no separate translation permission: writing a locale is - * editing the record, so `can_edit` covers a shared field and a translation - * alike, and `can_create` covers a new record in whatever languages it is born - * with. - */ export const CONTENT_PERMISSIONS = { create: "can_create", delete: "can_delete", @@ -704,27 +370,11 @@ export const CONTENT_PERMISSIONS = { view: "can_view", } as const; -/** - * Machine-readable reasons a write was refused. - * - * A code rather than a sentence, because the AdminCP has to *act* on the - * difference - a version conflict reloads the record and offers to overwrite, a - * unique clash points at a field. Prose cannot be branched on, and the - * driver's own message must never reach a client. - */ export const CONTENT_CONFLICT_CODES = { unique: "CONTENT_UNIQUE_CONFLICT", version: "CONTENT_VERSION_CONFLICT", } as const; -/** - * Machine-readable reasons a *translation* write was refused. - * - * A separate list from {@link CONTENT_CONFLICT_CODES} rather than three more - * members of it: the base 409 union is the contract Stage 4 editorial routes - * already publish, and widening it would change a response schema every existing - * client is generated from. A translation route answers its own union. - */ export const CONTENT_TRANSLATION_CONFLICT_CODES = { defaultRequired: "CONTENT_DEFAULT_TRANSLATION_REQUIRED", exists: "CONTENT_TRANSLATION_EXISTS", diff --git a/packages/vitnode/src/content/define-admin.ts b/packages/vitnode/src/content/define-admin.ts index de1768921..78c400927 100644 --- a/packages/vitnode/src/content/define-admin.ts +++ b/packages/vitnode/src/content/define-admin.ts @@ -26,12 +26,6 @@ import { ContentEngineError } from "./errors"; import { isContentReferenceCollection } from "./paths"; import { contentTypeToPath } from "./registry"; -/** - * Every admin surface addresses a column on the base table, so it is stated in - * terms of the *shared* fields only. A localized field named here would be a - * DataTable column, a sort or a search over something the base table does not - * have - see `ContentAddressableColumn`, which rejects it at compile time too. - */ const assertNotLocalized = ( id: string, label: string, @@ -47,15 +41,6 @@ const assertNotLocalized = ( } }; -/** - * Kinds that are not one column on the base table, so they cannot be a list - * cell, an `orderBy` or a `titleField`. - * - * A group is several columns under generated names; a repeatable and a to-many - * relation are on other tables entirely. All three still belong on the *form* - - * that is what `admin.form.fields` is for, and it is checked against the wider - * set. - */ const NON_COLUMN_KINDS = new Set<ContentFieldDescriptor["kind"]>([ "group", "repeatable", @@ -67,14 +52,6 @@ const isAdminColumnField = (fieldValue: ContentFieldDescriptor): boolean => const adminFormModes: readonly string[] = CONTENT_ADMIN_FORM_MODES; -/** - * `admin.create.mode` / `admin.edit.mode`, defaulted and checked. - * - * Defaults to `dialog`, which is what keeps every content type written before - * page mode existed behaving exactly as it did. The runtime check is here for a - * JavaScript caller and for a value that widened somewhere upstream - the type - * already refuses anything outside the union. - */ const resolveFormMode = ( id: string, label: string, @@ -95,16 +72,6 @@ const resolveFormMode = ( /** A section name has to survive being a message key segment. */ const SECTION_NAME_PATTERN = /^[a-z][a-z0-9_]*$/; -/** - * `admin.form.sections`, checked for the mistakes that would cost a field. - * - * Field *existence* is not checked here - the concatenated list goes through the - * same `assertKnownColumns` as `admin.form.fields`, so an unknown name reads the - * same either way. What is checked is what only sections can get wrong: a name - * that cannot be a translation key, a duplicated name (two headings reading one - * message), a section with nothing in it, and the same field in two sections - - * which would render one input twice into one payload. - */ const resolveFormSections = <TFields>( id: string, sections: ContentAdminFormSection<TFields>[] | undefined, diff --git a/packages/vitnode/src/content/define-editorial.ts b/packages/vitnode/src/content/define-editorial.ts index eb88f1fe8..5e934cfab 100644 --- a/packages/vitnode/src/content/define-editorial.ts +++ b/packages/vitnode/src/content/define-editorial.ts @@ -28,13 +28,6 @@ const disabledEditorial: ResolvedContentEditorialConfig = { scheduling: { enabled: false }, }; -/** - * The same rules as `search.pathTemplate`, with `{token}` in place of `{slug}`. - * - * Deliberately not shared with it: the two differ in their placeholder and in - * the config key their messages name, and a parameterised version would say - * less about what the author got wrong. - */ const assertPreviewPathTemplate = (id: string, template: string): void => { if (!template.startsWith("/")) { throw new ContentEngineError( diff --git a/packages/vitnode/src/content/define-fields.ts b/packages/vitnode/src/content/define-fields.ts index 289d5c127..94ec3af71 100644 --- a/packages/vitnode/src/content/define-fields.ts +++ b/packages/vitnode/src/content/define-fields.ts @@ -115,16 +115,6 @@ export const assertFieldKind = ( } }; -/** - * Everything a `field.file()` descriptor has to satisfy, re-checked here. - * - * `field.file` already normalises and validates - this is the same rules applied - * to a descriptor that skipped the builder, and the only place the error carries - * the content type id. The normalisers are idempotent, so running them twice - * costs nothing and proves the stored arrays really are normalised: a hand-built - * `{ kind: "file", allowedExtensions: ["GIF"] }` would otherwise be compared - * against `.gif` and match nothing. - */ const assertFileField = ( id: string, name: string, @@ -308,12 +298,6 @@ export const assertField = ( } }; -/** - * Checks every `field.slug({ source })` against the field map. - * - * Runs after the per-field pass, because a source is a reference to a *sibling* - * field and nothing can see the whole map until then. - */ export const assertSlugSources = ( id: string, fields: ContentFieldMap, @@ -338,16 +322,6 @@ export const assertSlugSources = ( } }; -/** - * Rebinds every `self: true` relation to the definition being built. - * - * On a **copy** of the field map, never in place: a descriptor object can be a - * shared `const` reused by several content types, and mutating it would point - * one content type's relation at another's table. The copy is what the - * definition carries, so `field.target()` resolves correctly everywhere - * downstream - and the thunk is read lazily, so `definition` is fully assigned - * by the time anybody calls it. - */ export const bindSelfRelations = ( fields: ContentFieldMap, self: () => AnyContentTypeDefinition, diff --git a/packages/vitnode/src/content/define-public-api.ts b/packages/vitnode/src/content/define-public-api.ts index c812c11a5..3c2755377 100644 --- a/packages/vitnode/src/content/define-public-api.ts +++ b/packages/vitnode/src/content/define-public-api.ts @@ -52,14 +52,6 @@ const assertPublicPath = (id: string, path: string): void => { } }; -/** - * Checks one exposed **leaf path**, e.g. `"seo.title"` or `"faq.question"`. - * - * The container has to be a group or a repeatable, and the leaf has to be one it - * declares: a path that resolves to nothing would be a key the response promises - * and never carries, which a generated OpenAPI schema turns into a lie rather - * than an error. - */ const assertPublicLeafPath = ( id: string, fields: ContentFieldMap, @@ -98,14 +90,6 @@ const assertPublicLeafPath = ( } }; -/** - * Checks and fills in `publicApi`. - * - * Every rule here exists to make one guarantee cheap: if a field is not in - * `fields`, nothing public can read it, order by it, search it or filter on it. - * So the subset checks are not tidiness - they are what stops a filter or a - * sort being used to probe a column the response leaves out. - */ export const resolvePublicApi = <TField extends string>( id: string, fields: ContentFieldMap, diff --git a/packages/vitnode/src/content/define-search.ts b/packages/vitnode/src/content/define-search.ts index b927a6c79..72311aff0 100644 --- a/packages/vitnode/src/content/define-search.ts +++ b/packages/vitnode/src/content/define-search.ts @@ -33,13 +33,6 @@ const disabledSearch: ResolvedContentSearchConfig = { titleField: "", }; -/** - * Checks one indexed field name. - * - * The public-exposure rule is the important one, and it is checked here as well - * as in the types because a JavaScript caller or a widened value can reach this - * function with anything at all. - */ const assertSearchField = ({ allowRepeatable = false, exposed, @@ -169,12 +162,6 @@ const assertSearchPathTemplate = ( } }; -/** - * Checks and fills in `search`. - * - * Runs after `resolvePublicApi`, because every rule here is stated in terms of - * the resolved public allowlist and its single exposed slug field. - */ export const resolveSearch = ( id: string, fields: ContentFieldMap, diff --git a/packages/vitnode/src/content/define-shared.ts b/packages/vitnode/src/content/define-shared.ts index 5b757b171..8dc6a68d9 100644 --- a/packages/vitnode/src/content/define-shared.ts +++ b/packages/vitnode/src/content/define-shared.ts @@ -14,11 +14,6 @@ export const SEARCHABLE_KINDS = new Set<ContentFieldDescriptor["kind"]>([ "textarea", ]); -/** - * Kinds an explicit `searchableFields` may name. A slug is searchable when you - * ask for it, but never by default - matching a URL segment against what - * someone typed into a search box is a deliberate choice, not a freebie. - */ export const EXPLICIT_SEARCHABLE_KINDS = new Set< ContentFieldDescriptor["kind"] >([...SEARCHABLE_KINDS, "slug"]); diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index 2d0c20ac9..4148fa915 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -79,14 +79,6 @@ const slugifyModule = (value: string): string => .replace(/[^a-z0-9]+/g, "_") .replace(/^_+|_+$/g, ""); -/** - * Checks that every column an index names is one it can actually be built on. - * - * A repeatable leaf and a to-many relation are refused **loudly** rather than - * silently dropped: `{ on: ["faq.answer"] }` looks like it works, and an index - * that was quietly not created is a performance bug nobody can see. Both live on - * their own generated tables, which already carry the indexes they need. - */ const assertIndexable = ( id: string, names: readonly string[], @@ -132,12 +124,6 @@ const assertIndexable = ( } }; -/** - * Declares a content type. The result is plain data - zod and objects only - - * so the same definition can be imported by `buildPlugin` (client) and by - * `createContentModel` in `src/database/*.ts` (server) without dragging Drizzle - * into a client bundle. - */ export const defineContentType = < TId extends string, TFields extends ContentFieldsConstraint< @@ -209,28 +195,14 @@ export const defineContentType = < search, tableName, }: { - /** - * How the AdminCP presents this content type. Every key has a default, so a - * content type that wants the generated screens as they come omits it - the - * record's name is a translation, not something declared here. - */ admin?: ContentAdminConfig< TFields, TPublication, ContentEditorialEnabled<TEditorial> >; - /** - * Opts into the delivery layer: canonical URLs, slug history, automatic - * redirects, localized alternates, `hreflang`, SEO projection and sitemap - * entries. Needs `publicApi`, and every SEO field it names has to be in - * `publicApi.fields`. Omit it and nothing about the content type changes. - */ + delivery?: TDelivery; - /** - * Opts into the editorial workflow: a `version` column, optimistic locking - * and revision history, plus optional preview and scheduling. Omit it and - * nothing changes. - */ + editorial?: TEditorial; fields: TFields; id: TId; @@ -239,11 +211,7 @@ export const defineContentType = < TPublication, ContentEditorialEnabled<TEditorial> >[]; - /** - * Opts into per-language content: every field marked `localized: true` moves - * into a generated `<tableName>_translations` table, one row per language. - * Omit it and nothing changes. - */ + localization?: TLocalization; /** * Opts into a generated read-only public API. Needs `publication` and exactly @@ -253,11 +221,7 @@ export const defineContentType = < ContentPublicApiConfig<TPublicField> | { enabled: TPublicEnabled }; /** Opts into the draft/published lifecycle. Omit to stay on Stage 1 behaviour. */ publication?: ContentPublicationConfig | { enabled: TPublication }; - /** - * Opts into automatic search synchronization. Needs `publication` and - * `publicApi`, and every indexed field must be in `publicApi.fields`. Omit it - * and nothing is indexed. - */ + search?: TSearch; tableName: string; }): ContentTypeDefinition< diff --git a/packages/vitnode/src/content/delivery.test-d.ts b/packages/vitnode/src/content/delivery.test-d.ts index f40d6f050..a94259d68 100644 --- a/packages/vitnode/src/content/delivery.test-d.ts +++ b/packages/vitnode/src/content/delivery.test-d.ts @@ -16,15 +16,6 @@ import type { import { defineContentType } from "./define"; import { field } from "./fields"; -/** - * Stage 8 at the type level. - * - * The rules worth a compile error rather than a boot-time one are the ones an author - * gets wrong while typing: naming a private field as an SEO title, putting prose in a - * title slot, or reaching for a delivery service a content type does not have. Every - * `@ts-expect-error` below is a mistake the editor catches before the file is saved. - */ - const fields = { excerpt: field.textarea({ maxLength: 500, nullable: true }), /** Declared but never exposed - the private half of every check below. */ diff --git a/packages/vitnode/src/content/delivery.test.ts b/packages/vitnode/src/content/delivery.test.ts index 2dcaaa471..26a1bafc7 100644 --- a/packages/vitnode/src/content/delivery.test.ts +++ b/packages/vitnode/src/content/delivery.test.ts @@ -15,14 +15,6 @@ import { } from "./delivery"; import { field } from "./fields"; -/** - * Stage 8 definition validation and the pure delivery projections. - * - * Everything here runs without a database, because everything here is a rule about - * a *definition* or a pure function over a public row - and the rules are the half - * of Stage 8 that has to fail loudly at boot rather than quietly at request time. - */ - const base = { publication: { enabled: true } as const, tableName: "delivery_articles", diff --git a/packages/vitnode/src/content/delivery.ts b/packages/vitnode/src/content/delivery.ts index 629eb56fd..f4fe73828 100644 --- a/packages/vitnode/src/content/delivery.ts +++ b/packages/vitnode/src/content/delivery.ts @@ -20,21 +20,6 @@ import { ContentEngineError } from "./errors"; import { normalizeContentLocale } from "./locale"; import { readContentPath, splitContentFieldPath } from "./paths"; -/** - * The Content Delivery layer: what a public URL *is*, rather than what a record - * contains. - * - * Everything in this module is pure and client-safe. It answers four questions - * and nothing else - what is the canonical path of this record in this language, - * which languages does it also exist in, what should the page put in `<head>`, - * and is a given path the current one - so a frontend can render a page, an - * `hreflang` set and a sitemap entry from data the engine already has. - * - * It deliberately does **not** render anything. There is no layout here, no - * React, no Next.js and no `Metadata`: those belong to the application, and the - * `content/next` adapter is the thin translation layer between the two. - */ - /** Kinds the three SEO slots accept, as runtime sets. */ const titleKinds: ReadonlySet<string> = new Set(CONTENT_DELIVERY_TITLE_KINDS); const descriptionKinds: ReadonlySet<string> = new Set( @@ -61,14 +46,6 @@ export const contentDeliveryDisabled: ResolvedContentDeliveryConfig<false> = { slugScope: "none", }; -/** - * Resolves one SEO field name to the descriptor it addresses, or `null`. - * - * A leaf path resolves through its **group**, and a repeatable is deliberately - * not resolvable here: `assertSeoField` needs to tell "this leaf is a column on - * the row" from "this leaf is a column on a child row", and only the first can - * be one page's title. - */ const resolveSeoTarget = ( fields: ContentFieldMap, name: string, @@ -96,14 +73,6 @@ const resolveSeoTarget = ( : null; }; -/** - * Checks one configured SEO field name. - * - * The public-exposure rule is the important one, and it is what makes "SEO - * metadata cannot leak a private value" a property of the definition rather than - * of every consumer: a `<title>` is rendered into a public page, so it has to be - * something the public API would already have said out loud. - */ const assertSeoField = ({ exposed, fields, @@ -165,18 +134,6 @@ const assertSeoField = ({ } }; -/** - * Checks and fills in `delivery`. - * - * Runs after `resolvePublicApi` and after the field partition, because every rule - * here is stated in terms of both: the public allowlist decides which fields may - * be projected, and the partition decides which language a historical URL belongs - * to. - * - * Nothing is silently ignored. An invalid delivery block fails at definition - * time - a canonical URL that quietly stopped being generated is a page that - * quietly stopped being indexable, and that is not a symptom anybody notices. - */ export const resolveContentDelivery = ({ delivery, editorial, @@ -505,19 +462,6 @@ export interface ContentDeliveryAlternate { path: string; } -/** - * The `hreflang` set of one record, as a framework-neutral map. - * - * `{ languages, xDefault? }` rather than a Next.js `Metadata` object, because the - * core engine has no business knowing which framework renders it - `content/next` - * turns this into `alternates.languages` in one line, and an Astro or Remix - * adapter would do the same. - * - * Built from {@link ContentDeliveryAlternate}s, which are **real published - * translations** and nothing else. A fallback translation is not an alternate: it - * has no URL in the language that fell back to it, so listing one would announce - * a page that answers 404. - */ export interface ContentDeliveryHreflang { languages: Record<string, string>; /** Present only with `delivery.hreflang.xDefault` and a resolvable default. */ @@ -571,18 +515,6 @@ export interface ContentDeliveryRobots { index: boolean; } -/** - * Reads one configured SEO slot out of a **public** row. - * - * The row is the public projection - the same object the public API returns - so - * a field the allowlist omits is not merely skipped here, it is absent from the - * object entirely. That is what makes "SEO cannot leak a private field" true at - * runtime as well as at definition time. - * - * A whitespace-only value counts as empty, because a `<title>` of three spaces is - * a missing title with extra steps - and that is exactly when the fallback should - * take over. - */ const readSeoText = ( row: Record<string, unknown>, primary: null | string, @@ -601,13 +533,6 @@ const readSeoText = ( return null; }; -/** - * The `<title>` and `<meta name="description">` of one record. - * - * `{ description: null, title: null }` for a content type whose `delivery.seo` - * names nothing - the shape is stable so a frontend never branches on whether - * the block was configured, only on whether a value came back. - */ export const contentDeliverySeo = ( definition: AnyContentTypeDefinition, row: Record<string, unknown>, @@ -624,17 +549,6 @@ export const contentDeliverySeo = ( }; }; -/** - * The Open Graph pair, or `null` when the content type configured none. - * - * `null` rather than an object of nulls, because "this content type does not - * publish Open Graph metadata" and "it does, and this page has no title" are - * different facts and a renderer treats them differently: the first emits no - * tags at all. - * - * Each slot falls back to the ordinary SEO one, which is what makes the common - * case - the same title in both places - a two-line config rather than four. - */ export const contentDeliveryOpenGraph = ( definition: AnyContentTypeDefinition, row: Record<string, unknown>, @@ -652,19 +566,6 @@ export const contentDeliveryOpenGraph = ( }; }; -/** - * The `robots` directive of one record, or `null` without a `noIndexField`. - * - * `follow` is always `true`: "do not list this page" and "do not follow the links - * on it" are different instructions, and a content type that asked for the first - * has not asked for the second. A `noindex, nofollow` page is a dead end for a - * crawler walking the site, which is a decision for site-wide robots - * configuration rather than for one record. - * - * The same field drives the sitemap exclusion, which is what keeps the two from - * disagreeing: a record cannot be absent from the sitemap and `index: true` at - * the same time, because there is one boolean behind both. - */ export const contentDeliveryRobots = ( definition: AnyContentTypeDefinition, row: Record<string, unknown>, @@ -686,18 +587,6 @@ export interface ContentDeliveryPathParts { slug: string; } -/** - * Splits a public path back into its locale and its slug. - * - * The inverse of {@link contentDeliveryPath}, and deliberately strict: it accepts - * exactly the shape that function produces and refuses everything else. A path - * with an extra segment, a different public prefix or a traversal in it is `null` - * rather than a best guess - a resolver that guessed would answer one content - * type's URL with another's record. - * - * A query string and a fragment are stripped first, because a browser sends them - * and they are not part of the identity of a page. - */ export const parseContentDeliveryPath = ( definition: AnyContentTypeDefinition, path: string, @@ -740,17 +629,6 @@ export const parseContentDeliveryPath = ( // Registry // --------------------------------------------------------------------------- -/** - * Every delivery-enabled content type of an installation, in a stable order. - * - * What a site-level sitemap index is built from: it enumerates the content types - * that have public URLs at all, so an application never hardcodes plugin names - - * installing a plugin adds its content types to the sitemap and removing it takes - * them out again. - * - * Ordered by content type id, so two processes building the same sitemap index - * produce the same document. - */ export const listDeliveryContentTypes = < TEntry extends { definition: AnyContentTypeDefinition; pluginId: string }, >( diff --git a/packages/vitnode/src/content/errors.ts b/packages/vitnode/src/content/errors.ts index 25a888d1d..16544e7e6 100644 --- a/packages/vitnode/src/content/errors.ts +++ b/packages/vitnode/src/content/errors.ts @@ -4,11 +4,6 @@ import type { ContentScheduleCode } from "./schedules"; export type ContentAdvancedCode = (typeof CONTENT_ADVANCED_CODES)[keyof typeof CONTENT_ADVANCED_CODES]; -/** - * Thrown while a content type definition is being built or registered - always - * at import/boot time, never per request. The message names the offending - * content type so a misconfigured plugin fails loudly and obviously. - */ export class ContentEngineError extends Error { constructor( message: string, @@ -188,13 +183,6 @@ export class ContentDefaultTranslationRequired extends ContentEngineError { readonly locale: string; } -/** - * A create for a locale that already has a translation. - * - * Its own error rather than a bare unique violation: "switch to the tab that - * exists" and "that slug is taken" are different instructions, and the composite - * primary key cannot tell a client which one it hit. - */ export class ContentTranslationExists extends ContentEngineError { constructor({ contentTypeId, @@ -219,14 +207,6 @@ export class ContentTranslationExists extends ContentEngineError { readonly locale: string; } -/** - * A locale that does not name a usable language. - * - * `reason` is what the routes branch on: an unknown locale is a 404 (there is no - * such thing to address), a disabled one is a 409 (it exists, and this install - * has switched it off). Both are per-request, and neither carries anything - * beyond the locale the caller already sent. - */ export class ContentLanguageError extends ContentEngineError { constructor({ contentTypeId, @@ -253,13 +233,6 @@ export class ContentLanguageError extends ContentEngineError { readonly reason: "disabled" | "missing"; } -/** - * A translation write for a base record that is not there. - * - * Checked before the insert rather than left to the foreign key: the driver's - * `23503` cannot say *which* of the two references failed, and "the article is - * gone" and "that language is gone" want different answers. - */ export class ContentTranslationItemMissing extends ContentEngineError { constructor({ contentTypeId, diff --git a/packages/vitnode/src/content/events.ts b/packages/vitnode/src/content/events.ts index 531851e66..935ff4415 100644 --- a/packages/vitnode/src/content/events.ts +++ b/packages/vitnode/src/content/events.ts @@ -35,28 +35,9 @@ export interface ContentPublishedPayload { contentId: number; /** When the row was published for the *first* time; never rewritten. */ publishedAt: Date; - /** - * The person who created the schedule that fired this, when one did. - * - * Absent on an interactive publish, so no existing listener sees a new field. - * It is the only way to answer "the system did it, on whose instruction" - - * the actor of a scheduled run is genuinely the system, and inventing a user - * id there would be a lie in the audit trail. - */ + scheduledBy?: null | number; - /** - * The booking that fired this, when one did - and the idempotency key for a - * listener that must act exactly once. - * - * Scheduled announcements are delivered **at least** once: they run in a - * queue task that retries whenever the event, the search write or a cache - * origin failed, and a retry re-emits an event that may already have been - * received. The id does not change between those attempts, so a listener that - * records "I have handled schedule 55" can safely ignore the second copy. - * - * Absent on an interactive publish, which is emitted once by the route that - * performed it and has no booking to point at. - */ + scheduleId?: number; } @@ -68,18 +49,6 @@ export interface ContentUnpublishedPayload { scheduleId?: number; } -/** - * A record was rolled back to the field values of an earlier revision. - * - * Emitted **instead of** `updated`, not alongside it - the one-event-per-mutation - * rule below holds here too, and a listener that fired twice would do every - * piece of downstream work twice. `changedFields` is carried for exactly that - * reason: porting an `updated` listener is a rename, not a rewrite. - * - * There is deliberately no publication field. A restore never moves `status` or - * `publishedAt`, so anyone listening for a visibility change still only has to - * watch `published` and `unpublished`. - */ export interface ContentRestoredPayload<TDefinition> { changedFields: ContentFieldName<TDefinition>[]; contentId: number; @@ -90,13 +59,6 @@ export interface ContentRestoredPayload<TDefinition> { version: number; } -/** - * A transition was booked for later, or the booking was called off. - * - * These are **not** revisions and consume no version: scheduling changes no - * field value. When the schedule actually fires, the resulting transition emits - * the ordinary `published`/`unpublished` event with `scheduledBy` set. - */ export interface ContentScheduledPayload { action: "publish" | "unpublish"; /** The staff member who booked it. */ @@ -113,14 +75,6 @@ export interface ContentScheduleCancelledPayload { scheduleId: number; } -/** - * The two extra events a content type with `publication` emits. - * - * They are disjoint from `updated`: `status` and `publishedAt` are generated - * columns, not declared fields, so an `updated` event alongside them would - * carry an empty `changedFields` and lie about what moved. Exactly one event is - * emitted per mutation, and a no-op publish emits nothing at all. - */ type ContentPublicationEventsFor<TDefinition extends { id: string }> = TDefinition extends { publication: { enabled: true } } ? Record< diff --git a/packages/vitnode/src/content/fields.ts b/packages/vitnode/src/content/fields.ts index 8a10be191..403f8581e 100644 --- a/packages/vitnode/src/content/fields.ts +++ b/packages/vitnode/src/content/fields.ts @@ -32,11 +32,6 @@ interface SharedArgs< required?: TRequired; } -/** - * `required` and `nullable` default to `false`. The assertions keep the literal - * type parameter the caller inferred - `?? false` alone would widen it back to - * `boolean` and every downstream `nullable extends true` check would break. - */ const shared = <TRequired extends boolean, TNullable extends boolean>( args: SharedArgs<TRequired, TNullable>, ): { nullable: TNullable; required: TRequired } => ({ @@ -44,22 +39,11 @@ const shared = <TRequired extends boolean, TNullable extends boolean>( required: (args.required ?? false) as TRequired, }); -/** - * `localized` defaults to `false`, and the assertion keeps the literal the - * caller inferred - `?? false` alone would widen it back to `boolean`, and every - * `localized extends true` partition would resolve to the shared branch. - */ const localizedOf = <TLocalized extends boolean>( args: LocalizableArgs<TLocalized>, ): TLocalized => (args.localized ?? false) as TLocalized; interface LocalizableArgs<TLocalized extends boolean = false> { - /** - * Store the value per language, in the generated translation table. - * - * Needs `localization: { enabled: true, defaultLocale }` on the content type. - * Only `text`, `textarea` and `slug` accept this. - */ localized?: TLocalized; } @@ -154,23 +138,6 @@ const enumField = < kind: "enum", }); -/** - * A URL segment, normalised on the way in and unique-indexed automatically. - * - * ```ts - * slug: field.slug({ source: "title" }) // derived when the payload omits it - * slug: field.slug() // always supplied by the caller - * ``` - * - * `source` must name a `text` field on the same content type. There is no - * `required` argument: a slug with a source is always derivable and therefore - * optional in the create payload, and one without a source can only come from - * the caller. `nullable` is not an argument either - a row nobody can address - * by URL is not a thing worth allowing. - * - * The slug is never re-derived by an update. Changing the title leaves the URL - * alone; sending `slug` explicitly is the only way to move it. - */ const slug = < TSource extends string | undefined = undefined, TLocalized extends boolean = false, @@ -203,64 +170,6 @@ const dateTime = < kind: "dateTime", }); -/** - * One stored file, referenced by its `core_files` row. - * - * ```ts - * coverImage: field.file({ - * maxBytes: 5 * 1024 * 1024, - * allowedExtensions: [".jpg", ".jpeg", ".png", ".webp", ".avif"], - * allowedMimeTypes: ["image/jpeg", "image/png", "image/webp", "image/avif"], - * }) - * ``` - * - * The column is an `integer` foreign key with `ON DELETE RESTRICT`, so Postgres - * itself refuses to delete a file an article still points at. Nothing about the - * file - not the name, not the URL, not the storage key - is copied onto the - * content row: one fact, in one place. - * - * **`maxBytes` is required.** There is no unlimited Content Engine file field: - * the ceiling is the only thing between a form and an upload that fills the - * disk, and a default would be a number nobody chose applied to every field in - * every plugin. It is checked here, at definition time, so a bad value is an - * import-time error rather than a request that succeeds until it does not. - * - * `allowedExtensions` and `allowedMimeTypes` are **two** rules, and a strict - * field states both: the first is what the file is *called*, the second is what - * the client *declared* the bytes are. Both have to match, so `picture.gif` - * carrying `image/png` is refused by a GIF-only field - which is precisely the - * case an extension-only check waves through. Extensions are normalised, so - * `GIF`, `.gif` and `.Gif` are one rule. - * - * `nullable` defaults to **true**, like `field.user`: a cover image is something - * a record may not have yet, and a `NOT NULL` file column would mean no article - * can exist before somebody uploads one. Pass `nullable: false` with - * `required: true` for a field that genuinely must carry a file. - * - * `multiple: true` moves the reference off the row into a generated junction - * table, exactly as it does for a `relation`: - * - * ```ts - * gallery: field.file({ - * multiple: true, - * min: 1, - * max: 12, - * maxBytes: 5 * 1024 * 1024, - * allowedExtensions: [".jpg", ".jpeg", ".png", ".webp"], - * allowedMimeTypes: ["image/jpeg", "image/png", "image/webp"], - * }) - * ``` - * - * A gallery is therefore never `required` and never `nullable` - the empty set - * is what "no files" looks like - and `min` is how a content type says "at least - * one". Every entry is checked against the *same* per-file rules: `maxBytes` and - * both allowlists apply once per file, because ten images are ten uploads rather - * than one bigger one. `ordered` defaults to **true**, which keeps the order the - * files were added in; pass `false` to store them by ascending `core_files.id`. - * - * There is still no `localized` argument. A per-language file is out of scope - * (`localized: true` is refused at definition time) - translate the alt text. - */ const file = < TRequired extends boolean = false, TMultiple extends boolean = false, @@ -308,32 +217,6 @@ const file = < }; }; -/** - * A reference to a VitNode user. - * - * ```ts - * author: field.user() - * authors: field.user({ multiple: true, ordered: true }) - * ``` - * - * The only field builder whose `nullable` defaults to `true`, matching how - * every hand-written VitNode table stores an author (`blog_posts.authorId` is - * nullable with `ON DELETE SET NULL`): accounts get deleted, and their content - * should outlive them rather than disappear or block the deletion. Pass - * `nullable: false` and the `onDelete` default moves to `"restrict"`, because - * `"set null"` on a `NOT NULL` column is rejected at definition time. - * - * `multiple: true` moves the reference off the row into a generated junction - * table, exactly as it does for a `relation` - so a to-many people field is - * never `required` and never `nullable` (the empty set is what "nobody" looks - * like), and its `onDelete` may not be `"set null"`: a junction row has no - * column to null, and forgetting a deleted person's authorship is a deleted - * row. `defineContentType` rejects all three. - * - * `ordered: true` keeps the order the editor put them in, which for authors is - * usually the point - the first author of a piece is not an arbitrary member of - * a set. Without it the people come back in ascending id order. - */ const user = < TRequired extends boolean = false, TMultiple extends boolean = false, @@ -376,50 +259,12 @@ const user = < }; }; -/** - * The placeholder a `self: true` relation carries until it is rebound. - * - * Throws rather than returning something plausible: reaching it means - * `defineContentType` did not rebind the thunk, and a relation silently - * pointing at the wrong table is a data bug rather than a crash. - */ export const unboundSelfTarget = (): AnyContentTypeDefinition => { throw new ContentEngineError( "A `self: true` relation was read before `defineContentType` bound it. Build the field inside a `defineContentType` call.", ); }; -/** - * A reference to rows of another content type - or of this one. - * - * ```ts - * category: field.relation({ target: () => categoryContentType }) - * categories: field.relation({ target: () => categoryContentType, multiple: true }) - * related: field.relation({ self: true, multiple: true, ordered: true }) - * ``` - * - * `target` is a thunk, so two content types can point at each other without a - * circular import. A **self**-relation uses `self: true` instead, and the - * difference is not stylistic: `target: () => thisContentType` would make the - * definition's own inferred type circular, and TypeScript resolves that by - * widening the whole definition to `any` - taking every nested value type and - * every allowlist check with it, silently. - * - * `multiple: true` moves the value off the row into a generated junction table. - * A to-many relation is therefore never `required` and never `nullable` - the - * empty set is what "no targets" looks like - and `defineContentType` rejects - * both arguments alongside it. - * - * `ordered: true` keeps the author's order. Without it the set comes back in - * ascending target-id order, which is still deterministic; it is simply not - * something anybody chose. - * - * Exactly one of `self` and `target` is required. It is checked by - * `defineContentType` rather than by a union in this signature, because a union - * here would stop TypeScript inferring `self` as a literal - and - * `ContentReferences` reads that literal to decide which relations the database - * module has to supply a thunk for. The check still fails at import time. - */ const relation = < TRequired extends boolean = false, TNullable extends boolean = false, @@ -428,12 +273,6 @@ const relation = < TSelf extends boolean = false, >( args: SharedArgs<TRequired, TNullable> & { - /** - * The fewest targets to accept - `min: 1` is "at least one category". - * - * `multiple: true` only. A to-many reference can never be `required`, so - * this is the shape a "you must choose something" rule actually takes. - */ min?: number; multiple?: TMultiple; onDelete?: ContentOnDelete; @@ -456,30 +295,6 @@ const relation = < target: args.target ?? unboundSelfTarget, }); -/** - * A reusable structured group: several related leaves under one name. - * - * ```ts - * const seoGroup = field.group({ - * fields: { - * title: field.text({ nullable: true }), - * description: field.textarea({ nullable: true }), - * }, - * }); - * - * // then, in as many content types as you like: - * fields: { title: field.text({ required: true }), seo: seoGroup } - * ``` - * - * The value stays nested (`row.seo.title`); the storage stays relational (a - * `seo_title` column, indexable and constrainable like any other). Leaves are - * scalars - see `CONTENT_ADVANCED_LEAF_KINDS` for why each other kind is out. - * - * `localized: true` moves the **whole** group into the translation table. - * Marking one leaf is a definition-time error: half a logical value on each - * table would mean two revision histories and two permissions for one thing an - * editor sees as one box. - */ const group = < const TFields extends Record<string, { kind: string }>, TRequired extends boolean = false, @@ -495,28 +310,6 @@ const group = < localized: localizedOf(args), }); -/** - * A repeatable structured group: zero or more ordered child rows. - * - * ```ts - * faq: field.repeatable({ - * fields: { - * question: field.text({ required: true }), - * answer: field.textarea({ required: true }), - * }, - * }) - * ``` - * - * Stored in a generated child table with a `serial` primary key, so every child - * keeps a stable identity across reorders - which is what makes "update child - * 11" and "restore the row that used to be here" mean anything. - * - * Never nullable, never required and never localized. The first two because the - * empty array already says "nothing here"; the third because a per-language list - * of *different lengths* has no defensible restore or reorder semantics, and - * guessing one is worse than saying no. `field.repeatable({ localized: true })` - * is a definition-time error with that explanation. - */ const repeatable = <const TFields extends Record<string, { kind: string }>>( args: { description?: string; @@ -533,11 +326,6 @@ const repeatable = <const TFields extends Record<string, { kind: string }>>( required: false, }); -/** - * Field builders for `defineContentType`. Every builder returns plain data - - * no Drizzle, no React - so a content type definition is safe to import from - * both the API and a client component. - */ export const field = { boolean, dateTime, diff --git a/packages/vitnode/src/content/file-collection.test-d.ts b/packages/vitnode/src/content/file-collection.test-d.ts index 9f63b07c9..ca2764df5 100644 --- a/packages/vitnode/src/content/file-collection.test-d.ts +++ b/packages/vitnode/src/content/file-collection.test-d.ts @@ -12,15 +12,6 @@ import type { ContentUpdateInput, } from "./types"; -/** - * The type-level half of `field.file({ multiple: true })`. - * - * These are the assertions that would break silently: a gallery resolving to - * `number` instead of `number[]` still compiles at every call site that happens - * to pass one, and a public gallery typed as identifiers is a client rendering - * `<img src={42}>` at runtime rather than a compile error. - */ - type Gallery = typeof testFileGalleryContentType; describe("field.file({ multiple: true }) types", () => { diff --git a/packages/vitnode/src/content/file-collection.test.ts b/packages/vitnode/src/content/file-collection.test.ts index 13f099c3f..8a7d3b165 100644 --- a/packages/vitnode/src/content/file-collection.test.ts +++ b/packages/vitnode/src/content/file-collection.test.ts @@ -18,15 +18,6 @@ import { field } from "./fields"; import { createContentAdvancedTables } from "./server/advanced-tables"; import { createContentTable } from "./server/table"; -/** - * `field.file({ multiple: true })`, from the definition down to the migration. - * - * The single-file rules already have their own suite; this one is about the - * *arity*, and every case here is a question whose answer differs from the - * single-file one: where the value is stored, what the schemas take, what the - * form spec says, and what the engine refuses. - */ - const gallery = field.file({ multiple: true, max: 4, diff --git a/packages/vitnode/src/content/file-field.test.ts b/packages/vitnode/src/content/file-field.test.ts index 7320d128f..ceef24e93 100644 --- a/packages/vitnode/src/content/file-field.test.ts +++ b/packages/vitnode/src/content/file-field.test.ts @@ -10,13 +10,6 @@ import { defineContentType } from "./define"; import { field } from "./fields"; import { createContentTable } from "./server/table"; -/** - * A definition builder for the *rejection* cases only. - * - * The arguments are deliberately loose - every case below asserts on the error - * `defineContentType` throws, so nothing reads the result and precise inference - * would only be in the way. `articleType` is declared directly for that reason. - */ const articleWith = ( fields: Parameters<typeof defineContentType>[0]["fields"], extra: Partial<Parameters<typeof defineContentType>[0]> = {}, @@ -216,11 +209,6 @@ describe("the form spec", () => { }); const fileSpec = spec.fields.find(item => item.name === "coverImage"); - /** - * The constraint line in the AdminCP, the `accept` attribute and the server's - * own check all read these three values. They come off one descriptor, so - * there is no second place for them to disagree. - */ it("carries the descriptor's own limits, normalised", () => { expect(fileSpec).toMatchObject({ allowedExtensions: [".jpg", ".jpeg", ".png", ".webp", ".avif"], diff --git a/packages/vitnode/src/content/files.ts b/packages/vitnode/src/content/files.ts index 5e11363f6..a0adeb876 100644 --- a/packages/vitnode/src/content/files.ts +++ b/packages/vitnode/src/content/files.ts @@ -20,14 +20,6 @@ import { } from "./const"; import { ContentEngineError } from "./errors"; -/** - * A stored file, as every surface is allowed to see it. - * - * The allowlist *is* the type: `key`, `userId`, `pluginId` and the raw - * `metadata` bag are absent, so a projection cannot leak the object's storage - * address or who uploaded it by forwarding "the file row". `width` and `height` - * are present only for an image the storage pipeline measured. - */ export interface ContentFileDescriptor { height?: number; id: number; @@ -38,13 +30,6 @@ export interface ContentFileDescriptor { width?: number; } -/** - * The response and projection schema for {@link ContentFileDescriptor}. - * - * `strictObject`, so a key added to `core_files` cannot reach a client by being - * spread into a descriptor somewhere: it would fail the parse the generated - * route runs, which is the loud version of a leak. - */ export const zodContentFileDescriptor = z.strictObject({ height: z.number().int().positive().optional(), id: z.number().int().positive(), @@ -55,15 +40,6 @@ export const zodContentFileDescriptor = z.strictObject({ width: z.number().int().positive().optional(), }); -/** - * What one field's entry in a row's `files` sibling holds. - * - * A union rather than two keys, because the field name is the same either way - * and its arity is a property of the *field*: a single file field is a descriptor - * or `null`, and a `multiple: true` one is a list in stored order - empty when - * the record has no files, absent when the response did not load the collection - * at all (an admin list does not, by design). - */ export type ContentFileFieldValue = ContentFileDescriptor | ContentFileDescriptor[] | null; @@ -83,34 +59,12 @@ export interface ContentFileRejection { message: string; } -/** - * The 400 body a **content write** answers when a file identifier is refused. - * - * `field` is what the upload route's own rejection does not need and this one - * cannot do without: an upload is for one named field already in the URL, while - * a save carries every field at once, so without it a form knows a file was - * refused but not which input to say so under. - * - * `code` is a plain string rather than an enum of the four a reference check can - * produce - `CONTENT_FILE_NOT_FOUND`, `CONTENT_FILE_TOO_LARGE`, - * `CONTENT_FILE_MIME_TYPE_NOT_ALLOWED`, `CONTENT_FILE_EXTENSION_NOT_ALLOWED` - - * for the same reason the upload route's is: a client that does not recognise a - * code shows `message`, so a new one must not break the parse it arrives in. - */ export const zodContentFileReferenceRejection = z.strictObject({ code: z.string(), field: z.string(), message: z.string(), }); -/** - * One extension rule, normalised. - * - * `GIF`, `.gif` and `.Gif` all become `.gif`, so the rule an author writes and - * the extension a browser hands over are compared in one vocabulary. Anything - * that cannot be an extension is a definition-time error rather than a rule that - * silently matches nothing. - */ export const normalizeContentFileExtension = (value: unknown): string => { if (typeof value !== "string") { throw new ContentEngineError( @@ -182,14 +136,6 @@ export const normalizeContentFileMimeTypes = ( return [...new Set(values.map(normalizeContentFileMimeType))]; }; -/** - * Checks `maxBytes`, which every file field has to declare. - * - * There is deliberately no unlimited Content Engine file field: the ceiling is - * the only thing standing between a form and an upload that fills the disk, and - * a default would be a number nobody chose applied to every field in every - * plugin. - */ export const assertContentFileMaxBytes = (value: unknown): number => { if (typeof value !== "number" || !Number.isFinite(value)) { throw new ContentEngineError( @@ -212,28 +158,11 @@ export const assertContentFileMaxBytes = (value: unknown): number => { return value; }; -/** - * The three rules a file is checked against, whoever is asking. - * - * An alias rather than a second declaration: the rules live in - * `lib/file-constraints`, with no Content Engine behind them, because - * `AutoFormFile` checks the very same three things in the browser and a form - * field must not have to import the Content Engine to do it. - */ export type ContentFileConstraints = FileConstraints; /** One file's identity, as either side of the wire can describe it. */ export type ContentFileCandidate = FileCandidate; -/** - * Checks a file against a field's constraints, or returns `null`. - * - * A thin mapping over {@link validateFile}, which is the **one** implementation - * of the rules - shared with `AutoFormFile`, so the browser's pre-flight check - * and the server's authoritative one cannot answer differently. All this adds is - * the machine-readable code, which is a Content Engine contract rather than a - * property of files. - */ export const validateContentFile = ( constraints: ContentFileConstraints, file: ContentFileCandidate, @@ -247,34 +176,10 @@ export const validateContentFile = ( }; }; -/** - * The `accept` attribute for a native file picker. - * - * UX only - see {@link fileAcceptAttribute}. The server validates the same three - * rules again, whatever a dialog let through. - */ export const contentFileAccept = fileAcceptAttribute; -/** - * The formats a field accepts, as somebody would say them out loud. - * - * `JPG, PNG, WEBP` rather than raw media types - see {@link fileFormatLabels}, - * which the AdminCP constraint line reads through the very same function. - */ export const contentFileFormatLabels = fileFormatLabels; -/** - * The rule a rejection code came from, or `undefined`. - * - * The inverse of the mapping in {@link validateContentFile}, and it exists for - * the browser: a rejection that arrives over the wire is a code and an English - * sentence, and the uploader would rather render its *own* translated sentence - - * built from the field's own limits, which it already has. - * - * Anything the client cannot improve on comes back `undefined`, and the server's - * message is shown verbatim. That is the right default: "Storage provider not - * found" is far more use to an admin than any sentence this side could invent. - */ export const contentFileRejectionReason = ( code: string, ): FileRejectionReason | undefined => { @@ -290,31 +195,6 @@ export const contentFileRejectionReason = ( } }; -/** - * The storage folder one content type's uploads land in: `{plugin}/{module}`. - * - * `@vitnode/blog` + `posts` -> `vitnode-blog/posts`, so the object key reads - * `month_8_2026/vitnode-blog/posts/<uuid>.webp`. Grouping by owner rather than - * dropping everything in one `content/` folder is what makes a bucket listing - * answer "what is this?" - and it is the only place that question can be - * answered cheaply, since a storage provider has no join back to `core_files`. - * - * The **module**, not the content type id: an id holds a dot (`blog.post`), which - * a folder segment may not, and the module is already the path segment every - * admin request for this content type goes through - including the upload itself - * (`/admin/content/{module}/uploads/{field}`). So the key and the route agree by - * construction rather than by two slugifiers happening to match. - * - * Nothing else is in it. The field name would put a second fact in the key that - * `core_files.metadata` already records, and a record id would make moving a file - * between records a copy. - * - * The result is always a folder `sanitizeFolder` accepts, and neither half of - * that is luck: the plugin half is reduced to one segment and refused if nothing - * is left, and the module half is checked against `CONTENT_TABLE_NAME_PATTERN` at - * definition time - a stricter rule than the storage guard's. So a misconfigured - * plugin fails at import rather than on somebody's first upload. - */ export const contentFileFolder = ({ module, pluginId, diff --git a/packages/vitnode/src/content/hash.test.ts b/packages/vitnode/src/content/hash.test.ts index 087449542..7781a83a8 100644 --- a/packages/vitnode/src/content/hash.test.ts +++ b/packages/vitnode/src/content/hash.test.ts @@ -5,30 +5,6 @@ import { describe, expect, it } from "vitest"; import { clampWithFingerprint, fingerprint } from "./index"; -/** - * The hashing module's public surface, and the export map that keeps its old - * name working. - * - * Static and pure: the manifest is read as JSON and the two functions are - * imported from the barrel. Nothing here renders, fetches or resolves through a - * bundler - what is being pinned is a file's contents and a module's exports. - * - * ## Why there is a test about a filename at all - * - * `content/hash.ts` was `content/fingerprint.ts` until Stage 13. Ad-blocking - * filter lists match any request path against `fingerprint*.js` - the rule is - * aimed at browser-fingerprinting scripts - and Vite serves every module by its - * real filename in dev, so `/@fs/.../content/fingerprint.js` came back as - * `net::ERR_BLOCKED_BY_CLIENT`. The failure surfaces as a - * `Failed to fetch dynamically imported module` against the *entry*, naming a - * file in `@tanstack/react-start` that is perfectly healthy, so it is expensive - * to trace and trivial to reintroduce. - * - * The rename is therefore load-bearing, and so is the absence of the old file. - * Both are asserted below, because "add a forwarding `fingerprint.ts`" is the - * obvious-looking fix for the compatibility half and would put the blocked - * filename straight back into the dev server's graph. - */ const here = dirname(fileURLToPath(import.meta.url)); const packageRoot = join(here, "../.."); @@ -112,11 +88,6 @@ describe("the content/fingerprint compatibility subpath", () => { }); }); - /** - * Stated as its own assertion rather than left implicit in the one above, - * because this is the regression: a target reintroducing the blocked - * filename is exactly what the export exists to avoid. - */ it("never names a file called fingerprint", () => { for (const target of Object.values(entry as Record<string, string>)) { expect(target).not.toMatch(/fingerprint/); @@ -143,11 +114,6 @@ describe("the renamed source file", () => { expect(existsSync(join(here, "hash.ts"))).toBe(true); }); - /** - * The assertion that keeps the ad-blocker fix from being undone by a - * well-meaning "restore the old path" commit. Compatibility is the export - * map's job; no file in this package may be named `fingerprint`. - */ it("has no fingerprint.ts beside it", () => { expect(existsSync(join(here, "fingerprint.ts"))).toBe(false); }); diff --git a/packages/vitnode/src/content/hash.ts b/packages/vitnode/src/content/hash.ts index b0c915ad5..f38dac7f9 100644 --- a/packages/vitnode/src/content/hash.ts +++ b/packages/vitnode/src/content/hash.ts @@ -1,26 +1,3 @@ -/** - * Short deterministic hashes, for generated identifiers that have to fit. - * - * ## Why this file is not called `fingerprint.ts` - * - * It was, and it broke the development server for anybody running an ad - * blocker. Filter lists block any request whose path matches `fingerprint*.js` - * - the rule is aimed at browser-fingerprinting scripts - and Vite serves every - * module by its real filename in dev, so `/@fs/…/content/fingerprint.js` came - * back as `net::ERR_BLOCKED_BY_CLIENT`. That failure surfaces as - * `Failed to fetch dynamically imported module` against the *entry*, naming a - * file in `@tanstack/react-start` that is perfectly fine, so it costs an - * afternoon to trace. - * - * The exported names are unaffected and deliberately unchanged: blocking is by - * URL, not by symbol, and `fingerprint` is what this function is. Only the - * module's filename ever reaches a filter list. - * - * Production builds do not care either way - the module is bundled into a - * content-hashed chunk - so this is purely about the dev server. Anything else - * added here should keep the filename boring for the same reason. - */ - /** * FNV-1a, 32 bits, base36. Deterministic across processes and Node versions, * needs no dependency, and is short enough to leave a readable prefix intact. @@ -36,16 +13,6 @@ export const fingerprint = (value: string): string => { return hash.toString(36).padStart(7, "0"); }; -/** - * Keeps a generated identifier under a hard length limit. - * - * Plain truncation is not enough: two long values that differ only near the end - * would collapse onto the same result. Appending a fingerprint of the *whole* - * value keeps the prefix readable and the result distinct. - * - * Two callers, two limits: Postgres identifiers cap at 63 characters, and a - * Next cache tag at 256. - */ export const clampWithFingerprint = ( value: string, maxLength: number, diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index 086b421f2..b66a9da06 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -1,17 +1,3 @@ -/** - * Universal Content Engine - client-safe surface. - * - * Everything exported here is plain data plus zod: it is safe to import from a - * client component, from `buildPlugin`, and from `src/database/*.ts` (which - * Drizzle Kit executes). Anything that needs Drizzle or Hono lives in - * `@vitnode/core/content/server`. - * - * There was a third entrypoint, `@vitnode/core/content/next`, holding the host - * adapters that turned this surface into a framework's page, metadata and - * revalidation calls. It is gone: `resolveContentDelivery`, `contentDeliverySeo` - * and the sitemap builders here are the whole delivery surface, and a host maps - * them onto its own conventions. - */ export { contentEntityKey, contentI18nKeys, @@ -31,14 +17,7 @@ export type { ContentFrontendRegistry, RegisteredFrontendContentType, } from "./admin/registry"; -/** - * The AdminCP route resolver, and the lookup a registry satisfies. - * - * Here rather than only in `content/admin/` because it is pure and - * framework-neutral - it maps a slug onto a content type and one of three - * screens, over a predicate `ContentFrontendRegistry.lookup` provides - and a - * host that owns `/admin/content/*` cannot use the registry without it. - */ + export { resolveContentAdminRoute } from "./admin/route"; export type { ContentAdminAction, diff --git a/packages/vitnode/src/content/locale.ts b/packages/vitnode/src/content/locale.ts index 3621e1c60..a0d4b312b 100644 --- a/packages/vitnode/src/content/locale.ts +++ b/packages/vitnode/src/content/locale.ts @@ -1,13 +1,5 @@ import { CONTENT_LOCALE_MAX_LENGTH, CONTENT_LOCALE_PATTERN } from "./const"; -/** - * A locale reduced to the form two codes are compared in. - * - * Trimmed and lower-cased, because a locale travels in a URL and in an - * `Accept-Language` header, and `PL`, `pl` and ` pl ` all name the same language. - * The **canonical** spelling always comes back off `core_languages.code` - this is - * only ever the comparison key, never a value that gets stored or returned. - */ export const normalizeContentLocale = (value: string): string => value.trim().toLowerCase(); @@ -15,13 +7,6 @@ export const normalizeContentLocale = (value: string): string => export const contentLocalesMatch = (a: string, b: string): boolean => normalizeContentLocale(a) === normalizeContentLocale(b); -/** - * Whether a string could be a locale at all. - * - * Cheap and deliberately in front of every lookup: an explicit `?locale=` is - * attacker-controlled, and a 200-character value has no business reaching the - * language registry, the cache-tag builder or a log line. - */ export const isContentLocaleShaped = (value: string): boolean => { const trimmed = value.trim(); @@ -32,14 +17,6 @@ export const isContentLocaleShaped = (value: string): boolean => { ); }; -/** - * Where the locale of a public read came from. - * - * Reported rather than inferred, because the three sources have different cache - * consequences: an `explicit` locale is part of the URL and needs no `Vary`, a - * `negotiated` one depends on a request header and does, and `default` depends on - * nothing at all. - */ export type ContentLocaleSource = "default" | "explicit" | "negotiated"; export interface ContentLocaleResolution { @@ -48,18 +25,6 @@ export interface ContentLocaleResolution { source: ContentLocaleSource; } -/** - * One `Accept-Language` header, best language first. - * - * Quality values are honoured because that is what they are for; `q=0` is a - * refusal and is dropped rather than ranked last. `*` is dropped too - it means - * "anything", which is what the default locale already is, so keeping it would - * turn every request into a negotiated one and make `Vary: Accept-Language` - * unavoidable for no benefit. - * - * Malformed input is skipped, never thrown on: this parses a header that anybody - * can send. - */ export const parseAcceptLanguage = (header: string): string[] => header .split(",") @@ -83,13 +48,6 @@ export const parseAcceptLanguage = (header: string): string[] => .sort((a, b) => b.q - a.q) .map(entry => entry.tag); -/** - * The best available language for one `Accept-Language` header, or `null`. - * - * Two passes, and the order matters: an exact match wins outright, and only then - * is `pt-BR` allowed to satisfy a request for `pt`. Doing it in one pass would let - * a header of `pt, pt-BR` resolve to `pt-BR` when `pt` is right there. - */ export const negotiateContentLocale = ( header: string, available: readonly string[], @@ -117,29 +75,6 @@ export const negotiateContentLocale = ( return null; }; -/** - * The one place that decides which language a public read is for. - * - * **Explicit, then negotiated, then default**, and the precedence is the whole - * point: - * - * - An **explicit** locale is a deliberate request for one language. It is never - * quietly replaced - an explicit locale that names no available language comes - * back as `null`, and the caller answers the same 404 it answers for a slug that - * does not exist. Substituting the default here would serve English to a URL - * that said `pl`, which is the exact accident locale-aware caching then makes - * permanent. - * - A **negotiated** locale is a preference, so an unmatched one falls through to - * the default rather than failing. A visitor whose browser asks for Icelandic - * should get the site, not a 404. - * - The **default** is the content type's own `localization.defaultLocale`, which - * is the one language every record is guaranteed to exist in. - * - * `available` is the set of locales this install actually serves. Passing the - * disabled ones in would let a public URL address a language the app has switched - * off, which is the read-side half of the rule that already stops content being - * *written* into one. - */ export const resolveContentPublicLocale = ({ acceptLanguage, available, diff --git a/packages/vitnode/src/content/localization.ts b/packages/vitnode/src/content/localization.ts index 69754a218..52dc16ff0 100644 --- a/packages/vitnode/src/content/localization.ts +++ b/packages/vitnode/src/content/localization.ts @@ -20,31 +20,11 @@ import { clampWithFingerprint } from "./hash"; import { resolveContentTranslationIndexes } from "./indexes"; import { contentStorageColumns, isContentCollectionField } from "./paths"; -/** - * The one place that decides whether a field is localized. - * - * Every subsystem - table generation, schemas, services, routes, migrations - - * goes through {@link partitionContentFields} rather than testing - * `field.localized === true` for itself. Two copies of this rule is exactly the - * pair that drifts, and the consequence of drift is a column generated on one - * table and read from the other. - */ export const isLocalizedContentField = ( fieldValue: ContentFieldDescriptor, ): boolean => fieldValue.localized === true; export interface ContentFieldPartition { - /** - * Fields whose value lives outside the row: a to-many relation (junction - * table) and a repeatable (child table). - * - * Present on the partition rather than left for each caller to filter, - * because "which fields are columns" is the same question the shared/localized - * split answers and it has to be answered in the same place. Every existing - * caller reads `sharedFields` and `localizedFields`, both of which now exclude - * these - so a Stage 1-5 content type, which declares none, sees exactly the - * partition it always did. - */ collectionFields: ContentFieldMap; /** Fields stored in the translation table, one row per language. */ localizedFields: ContentFieldMap; @@ -52,16 +32,6 @@ export interface ContentFieldPartition { sharedFields: ContentFieldMap; } -/** - * Splits a field map into its base-table and translation-table halves. - * - * Declaration order is preserved in all three, so the generated column order, - * the generated schema key order and the migration all stay deterministic. - * - * A `group` lands in whichever half its own `localized` flag names, whole: its - * leaves are flattened into columns of that one table by - * {@link contentStorageColumns}, never split across both. - */ export const partitionContentFields = ( fields: ContentFieldMap, ): ContentFieldPartition => { @@ -141,15 +111,6 @@ const assertDefaultLocale = (id: string, locale: unknown): string => { return locale; }; -/** - * Every rule a localized field has to satisfy, in one pass. - * - * Two of them are about the *slug*, and they are the ones worth the words: a - * localized slug derived from a shared title would give every language the same - * URL, and a shared slug derived from a localized title has no single source to - * derive from. Both are silent data bugs rather than crashes, so they are - * rejected at definition time. - */ const assertLocalizedFields = ( id: string, fields: ContentFieldMap, @@ -227,14 +188,6 @@ const assertLocalizedFields = ( } }; -/** - * Checks and fills in `localization`. - * - * Runs after every other resolver, because the Stage 5A boundaries are stated in - * terms of capabilities they have already settled. Like every other resolver - * here it repeats what the types say: a JavaScript caller, or a value that - * widened somewhere upstream, can reach this with anything at all. - */ export const resolveContentLocalization = ({ fields, id, diff --git a/packages/vitnode/src/content/paths.test.ts b/packages/vitnode/src/content/paths.test.ts index 6ca334300..377dd51c6 100644 --- a/packages/vitnode/src/content/paths.test.ts +++ b/packages/vitnode/src/content/paths.test.ts @@ -16,15 +16,6 @@ import { splitContentFieldPath, } from "./paths"; -/** - * The one leaf-path <-> column mapping, tested on its own. - * - * Everything downstream - the table generator, the schemas, the services, the - * revision snapshotter, the public projector, the search mapper and the AdminCP - * - reads this module rather than re-deriving the rule, so a bug here would be - * a bug in all of them at once. - */ - const target = { id: "test.target", tableName: "test_targets" }; const fields = { diff --git a/packages/vitnode/src/content/paths.ts b/packages/vitnode/src/content/paths.ts index 1a8a3113f..edd46bf88 100644 --- a/packages/vitnode/src/content/paths.ts +++ b/packages/vitnode/src/content/paths.ts @@ -9,17 +9,6 @@ import type { import { CONTENT_ADVANCED_LEAF_KINDS, CONTENT_PATH_SEPARATOR } from "./const"; -/** - * The one place a canonical field path is built, split or turned into a column. - * - * Stage 6 gives one logical value two representations: `seo.title`, which every - * subsystem speaks, and `seoTitle`, which only Postgres speaks. Every subsystem - * that needs the second one asks *this* module for it - the table generator, the - * schemas, the services, the revision snapshotter, the public projector, the - * search mapper and the AdminCP alike - so the mapping cannot be reinvented - * three times and disagree on the fourth. - */ - /** `("seo", "title")` -> `"seo.title"`. */ export const contentFieldPath = (owner: string, leaf: string): string => `${owner}${CONTENT_PATH_SEPARATOR}${leaf}`; diff --git a/packages/vitnode/src/content/publication-state.test.ts b/packages/vitnode/src/content/publication-state.test.ts index 98f1be4fd..1140a4e30 100644 --- a/packages/vitnode/src/content/publication-state.test.ts +++ b/packages/vitnode/src/content/publication-state.test.ts @@ -10,16 +10,6 @@ import { isContentPublished, } from "./publication"; -/** - * The publication state machine, as the AdminCP reads it. - * - * Every assertion here is about a value that arrives off `JSON.parse` and is - * therefore `unknown` at the call site. That is the whole reason this module - * exists: five screens used to answer `status === "published"` independently, - * and none of them had a rule for what a `null`, a number or a status from a - * newer API meant. - */ - describe("reading a status off the wire", () => { it("keeps the two the engine declares", () => { for (const status of CONTENT_PUBLICATION_STATUSES) { diff --git a/packages/vitnode/src/content/publication.ts b/packages/vitnode/src/content/publication.ts index 48422190f..dac77c055 100644 --- a/packages/vitnode/src/content/publication.ts +++ b/packages/vitnode/src/content/publication.ts @@ -5,59 +5,11 @@ import type { import { isContentPublicationStatus } from "./const"; -/** - * The publication lifecycle, as a screen has to reason about it. - * - * Pure, framework-neutral, and the *only* place a control is allowed to decide - * what `status` means. Every AdminCP surface that offers publishing - a list - * row's button, the form's status badge, a schedule's action select - reads a - * row's `status` off a JSON response typed `unknown`, and each of them used to - * answer the question the same way and separately: - * - * const published = row.status === "published"; - * - * Five copies of one string literal, in two frontends, none of them checked - * against `CONTENT_PUBLICATION_STATUSES`. That is exactly the kind of duplicate - * rule that survives a rename of the constant it was copied from, so the reading - * lives here and the literal appears once. - * - * ## Nothing here is the state machine itself - * - * The transitions are the API's: `POST /{id}/publish` and `POST /{id}/unpublish` - * are idempotent, guarded by `can_publish`, and `publishedCondition` is what the - * database enforces. This module only says which of the two a row is offered and - * what it would become - the arithmetic a button needs to render, and never a - * substitute for the server's answer. - * - * A **scheduled** publication is not a third state and deliberately does not - * appear here: a schedule is a row in `core_content_schedules` that performs one - * of these two transitions later. See `content/schedules.ts`. - */ - -/** - * The state a record is in when it has never been through the lifecycle. - * - * `draft`, which is also the column default. A row arriving without a `status` - - * a content type with no publication layer, or a projection that did not select - * the column - is unpublished as far as any control is concerned, which is the - * reading that keeps a "Publish" button from being hidden on a record nobody can - * publish any other way. - */ export const CONTENT_DEFAULT_PUBLICATION_STATUS: ContentPublicationStatus = "draft"; export type { ContentPublicationStatus }; -/** - * A row's `status`, as one of the two the engine knows. - * - * The values arrive off `JSON.parse`, so this takes `unknown` on purpose rather - * than pretending the wire is typed. Anything the engine does not recognise - - * a status from a newer API, `null`, a number - reads as - * {@link CONTENT_DEFAULT_PUBLICATION_STATUS}, because an unknown state is not a - * published one and offering "Unpublish" for it would be a request the API - * refuses. - */ export const contentPublicationStatus = ( value: unknown, ): ContentPublicationStatus => @@ -97,26 +49,11 @@ const UNPUBLISH: ContentPublicationTransition = { to: "draft", }; -/** - * The transition a record in this state is offered. - * - * One of two, always - the lifecycle has no dead end, and a record can be moved - * back and forth as often as an editor likes. `destructive` is carried with it - * because it is the same fact stated twice otherwise: unpublishing removes a page - * from the public site, so the confirmation wears the destructive button, and a - * host that recomputed that from the status would be free to disagree. - */ export const contentPublicationTransition = ( value: unknown, ): ContentPublicationTransition => isContentPublished(value) ? UNPUBLISH : PUBLISH; -/** - * Whether this content type has a publication lifecycle at all. - * - * Without it there is no `status` column, no publish route and nothing for a - * button to do - so the control is absent rather than disabled, in both AdminCPs. - */ export const hasContentPublication = ( definition: AnyContentTypeDefinition, ): boolean => definition.publication.enabled; diff --git a/packages/vitnode/src/content/registry.ts b/packages/vitnode/src/content/registry.ts index 1687d6536..92c6f1a14 100644 --- a/packages/vitnode/src/content/registry.ts +++ b/packages/vitnode/src/content/registry.ts @@ -319,24 +319,9 @@ export const findContentTypeById = ( ): RegisteredContentType | undefined => entries.find(entry => entry.definition.id === id); -/** - * The **default** `admin.path`: `example.article` -> `example/article`. - * - * A default and nothing more - a content type is free to be addressed by a name - * of its own (`blog.post` -> `blog/articles`), so nothing that builds a URL may - * call this. They all read `admin.path`, which is what the href helpers below - * take a whole definition for. - */ export const contentTypeToPath = (id: string): string => id.split(".").join("/"); -/** - * Enough of a definition to address its AdminCP screens. - * - * The href helpers take this rather than an id, because the two are allowed to - * disagree: `blog.post` lives at `/admin/content/blog/articles`, and an id is no - * longer something a URL can be derived from. - */ export type ContentAdminAddressable = Pick<AnyContentTypeDefinition, "admin">; /** `/admin/content/blog/articles` */ diff --git a/packages/vitnode/src/content/revisions.ts b/packages/vitnode/src/content/revisions.ts index c56dedda0..738ceb81b 100644 --- a/packages/vitnode/src/content/revisions.ts +++ b/packages/vitnode/src/content/revisions.ts @@ -12,64 +12,19 @@ export type ContentTranslationRevisionOperation = export type ContentActorType = (typeof CONTENT_ACTOR_TYPES)[number]; -/** - * Who performed a mutation. - * - * A plain value object, not something read off a request: the editorial service - * takes one as an argument, so a route builds it from the Hono context and a - * queue handler hands over `{ type: "system", userId: null }` without either of - * them depending on the other's world. - */ export interface ContentActor { type: ContentActorType; userId: null | number; } -/** - * A value as it is stored in a snapshot. - * - * Deliberately narrow: a `Date` becomes an ISO string, a relation or user - * becomes the foreign key it already is, and nothing else survives. There is no - * runtime class instance in a snapshot, so re-reading one years later needs - * nothing but `JSON.parse`. - */ export type ContentSnapshotScalar = boolean | null | number | string; -/** - * A value as it is stored in a snapshot. - * - * Still plain JSON - re-reading a snapshot years later needs nothing but - * `JSON.parse` - but Stage 6 gives it three shapes rather than one: - * - * - a **scalar**, as before; - * - a **group**, as the nested object the field actually is (`{ title, description }`), - * or `null`. Never the flattened `seo_title` columns: a snapshot records the - * logical state, and the column names are an internal mapping that a schema - * change is allowed to move; - * - a **collection**, as identity. A to-many relation is `[2, 5, 9]` - the ids, - * in stored order - and a repeatable is its child rows, each with its own `id`. - * Never the *expanded* related records: those have their own history, their own - * permissions and their own publication state, and restoring an article must - * not rewrite a category. - */ export type ContentSnapshotValue = | ContentSnapshotScalar | number[] | Record<string, ContentSnapshotScalar> | Record<string, ContentSnapshotScalar>[]; -/** - * The complete post-mutation editable state of one record. - * - * Complete rather than a patch: restoring from a patch means replaying every - * revision since, which turns a single read into a fold that gets slower the - * longer the history is - and produces nothing if one link was pruned. - * - * What is deliberately absent is as important as what is here. No relation - * *labels* (they are administrative metadata belonging to another content type, - * which may not publish them at all), no search document, no cache tags, - * nothing derived. - */ export interface ContentRevisionSnapshot { contentTypeId: string; createdAt: string; @@ -83,23 +38,6 @@ export interface ContentRevisionSnapshot { version: number; } -/** - * The complete post-mutation state of **one translation**. - * - * The same design as {@link ContentRevisionSnapshot} - complete, plain JSON, - * nothing derived - restricted to one language. What is absent is the point: - * - * - **no shared fields.** They live on the base row and have their own history. - * A translation restore that carried them would rewrite the record's shared - * values as a side effect of restoring one language. - * - **no other locale's values.** Restoring Polish must not touch English. - * - **no public response object and no search document.** Both are derived, and - * both are shaped by configuration that may since have changed. - * - * `locale` is carried alongside `languageId` on purpose: the revision row's - * `languageId` has no foreign key, so this is what keeps a revision readable - * after the language it names has been deleted. - */ export interface ContentTranslationRevisionSnapshot { contentTypeId: string; createdAt: string; @@ -117,13 +55,6 @@ export interface ContentTranslationRevisionSnapshot { version: number; } -/** - * Either snapshot shape, for the shared `core_content_revisions.snapshot` column. - * - * They are told apart by the row's `languageId`, not by inspecting the JSON: the - * column is what the query filters on, and a discriminator inside the payload - * would be a second source of truth for the same fact. - */ export type ContentAnyRevisionSnapshot = ContentRevisionSnapshot | ContentTranslationRevisionSnapshot; @@ -131,11 +62,7 @@ export type ContentAnyRevisionSnapshot = export interface ContentRevisionMeta { /** Display name of the actor, or `null` for a system mutation. */ actorName: null | string; - /** - * The actor's role colour, so their name reads the same here as it does in - * every other admin list. `null` for a system mutation, a deleted account or - * a role with no colour of its own. - */ + actorRoleColor: null | string; actorType: ContentActorType; actorUserId: null | number; @@ -147,14 +74,6 @@ export interface ContentRevisionMeta { version: number; } -/** - * One revision with its snapshot, loaded on demand. - * - * Generic over the snapshot shape so the translation history reads - * `ContentRevisionDetail<ContentTranslationRevisionSnapshot>` and gets the - * localized shape - without a second model, and without widening the existing - * default that every Stage 4 caller relies on. - */ export interface ContentRevisionDetail< TSnapshot = ContentRevisionSnapshot, > extends ContentRevisionMeta { @@ -167,15 +86,6 @@ export interface ContentRevisionDiffEntry { name: string; } -/** - * Whether two snapshot values are the same. - * - * `JSON.stringify` rather than `===`, because a group and a collection are - * objects: two structurally equal `seo` groups are the same state, and the - * revision list must not claim otherwise. Key order is deterministic - both - * sides are built by `contentRevisionSnapshot`, which emits declaration order - - * so the comparison is exact rather than approximate. - */ const sameSnapshotValue = ( before: ContentSnapshotValue | undefined, after: ContentSnapshotValue | undefined, @@ -187,17 +97,6 @@ const sameSnapshotValue = ( return JSON.stringify(before) === JSON.stringify(after); }; -/** - * Field-level difference between two snapshots, in declaration order. - * - * Walks `names` - the content type's *current* field list - rather than the - * union of both snapshots' keys, so a field that has since been removed does - * not show up as "changed to nothing". The same projection the restore path - * applies, and for the same reason. - * - * `undefined` on either side means "this snapshot never carried the field", - * which the UI renders differently from an explicit `null`. - */ export const contentRevisionDiff = ( names: readonly string[], before: ContentRevisionSnapshot | null, diff --git a/packages/vitnode/src/content/schedules.ts b/packages/vitnode/src/content/schedules.ts index 8959ee612..1e85d4e64 100644 --- a/packages/vitnode/src/content/schedules.ts +++ b/packages/vitnode/src/content/schedules.ts @@ -21,13 +21,7 @@ export interface ContentSchedule { completedAt: Date | null | string; createdAt: Date | string; createdBy: null | number; - /** - * Why the announcements for a *completed* schedule have not gone out yet. - * - * A separate field from `lastError` because it means something different: the - * record really did publish, and what is still being retried is the event, - * the search write and the cache invalidation. - */ + effectsError: null | string; id: number; lastError: null | string; @@ -43,13 +37,6 @@ export interface ContentScheduleTimingInput { scheduledFor: Date; } -/** - * Whether a requested schedule makes sense, and why not when it does not. - * - * Pure, and shared by the client and the server on purpose: the dialog can - * refuse an impossible date before the round trip, and the route stays the - * authority - both from one function, so they cannot drift into disagreeing. - */ export const contentScheduleTimingError = ({ action, now, diff --git a/packages/vitnode/src/content/schemas.ts b/packages/vitnode/src/content/schemas.ts index 19fa78e16..9cc41f7a2 100644 --- a/packages/vitnode/src/content/schemas.ts +++ b/packages/vitnode/src/content/schemas.ts @@ -57,15 +57,6 @@ const DISABLED_PUBLIC_API: ResolvedContentPublicApiConfig = { slugField: "", }; -/** - * The schemas one translation row is written and read through. - * - * Content values live under `values`, and everything else - the locale, the - * expected version - is transport that sits *beside* them. That split is what - * lets `values` stay a strict object of the content type's own localized fields: - * an `expectedVersion` key inside it would be indistinguishable from a field - * somebody is trying to mass-assign. - */ export interface ContentTranslationSchemas< TDefinition = AnyContentTypeDefinition, > { @@ -98,33 +89,15 @@ export interface ContentTranslationSchemas< } export interface ContentSchemas<TDefinition = AnyContentTypeDefinition> { - /** - * The advanced collections of one record: a `number[]` per to-many relation - * and an array of identified children per repeatable. - * - * An empty object for a content type that declares neither, which is what - * lets a generated route compose it unconditionally and still produce exactly - * the response schema it produced in Stage 5. - */ advancedSelect: z.ZodObject<z.ZodRawShape>; - /** - * Request body for create. Rejects unknown keys and system columns. - * - * Shared fields only. A localized content type's localized values arrive - * through `translation.create` instead, in the same transaction - see - * `localizedService.create`. - */ + create: z.ZodType<ContentCreateInput<TDefinition>>; /** * Query-string filters, restricted to filterable fields. Non-strict: it is * parsed against the whole query string, so unrecognised keys are ignored. */ filters: z.ZodObject<z.ZodRawShape>; - /** - * The create/update shape as `AutoForm` needs it: a plain `ZodObject` with - * no `z.date()` anywhere, because `AutoForm` runs `z.toJSONSchema` on it and - * Zod v4 throws on dates. - */ + form: z.ZodObject<z.ZodRawShape>; /** `orderBy` allowlist plus direction. */ order: z.ZodObject<z.ZodRawShape>; @@ -150,25 +123,11 @@ export interface ContentSchemas<TDefinition = AnyContentTypeDefinition> { * routes can `.extend(...)` it with the joined relation labels. */ selectObject: z.ZodObject<z.ZodRawShape>; - /** - * The per-language schemas, or `null` when the content type is not localized. - * - * `null` rather than empty schemas, matching how `model.publicService` is - * `undefined` without a public API: a nullable value reads naturally in code - * that does not know which content type it was handed, and it cannot be used - * by accident. - */ + translation: ContentTranslationSchemas<TDefinition> | null; /** Request body for update. Every field optional, but never empty. */ update: z.ZodType<ContentUpdateInput<TDefinition>>; - /** - * Request body for an editorial update: the field values, plus the version - * the editor started from. - * - * An envelope rather than a key inside `values`, because `update` is a strict - * object of *content fields* and `expectedVersion` is transport. Empty for a - * content type without `editorial`, whose update body stays exactly as it was. - */ + updateEnvelope: z.ZodType<{ expectedVersion: number; values: ContentUpdateInput<TDefinition>; @@ -284,11 +243,6 @@ const applyNullable = ( fieldValue: ContentFieldDescriptor, ): z.ZodType => (fieldValue.nullable ? schema.nullable() : schema); -/** - * `required` -> present. Otherwise a declared default becomes a Zod default so - * the value the API writes always matches the column default, and everything - * else is simply optional. - */ const applyPresence = ( schema: z.ZodType, fieldValue: ContentFieldDescriptor, @@ -313,22 +267,6 @@ const applyPresence = ( return schema.optional(); }; -/** - * A set of references, with the field's own bounds on it. - * - * Positive integers, distinct, and bounded. Distinctness is enforced here rather - * than deduplicated silently: `[2, 2, 5]` is a caller that thinks it is setting - * three categories, and quietly storing two would be the kind of "helpful" - * behaviour that hides a bug in the caller's own list handling. - * - * `min` is how a content type says "at least one" about something the *storage* - * cannot say it about: a to-many reference is never `required`, because the - * empty set is a legitimate value for a column that does not exist. A blog - * article that must be filed under a category is a rule about the article rather - * than about the junction table, so it is enforced here - in the generated - * schema, which the API and the AdminCP form both go through - rather than by a - * check somewhere one of the two would eventually skip. - */ const relationSetSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { const min = (fieldValue as { min?: number }).min; // A file collection carries its own ceiling and defaults to a much lower one: @@ -349,14 +287,6 @@ const relationSetSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { }); }; -/** - * One repeatable child, as it is written. - * - * `id` is optional and is the whole protocol: present means "this is the - * existing child with that identifier", absent means "create a new one". There - * is no `position` - the array order is the order, so a payload cannot describe - * two rows in the same slot. - */ const repeatableRowSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { const inner = contentInnerFields(fieldValue); const names = Object.keys(inner); @@ -392,15 +322,6 @@ const leafInputShape = ( }), ); -/** - * A group, as it is written on create: a nested object of its leaves. - * - * Strict, like every other content object in this file: an unknown key inside - * `seo` is a typo the author wants to hear about, not something to drop. The - * four presence states a group can be in - absent, `null`, present-and-complete, - * present-with-a-required-leaf-missing - fall straight out of `.nullable()`, - * `.optional()` and the leaves' own requiredness, with no fifth code path. - */ const groupInputSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { const inner = contentInnerFields(fieldValue); const object = z.strictObject(leafInputShape(inner, Object.keys(inner))); @@ -408,14 +329,6 @@ const groupInputSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { return applyPresence(applyNullable(object, fieldValue), fieldValue); }; -/** - * A group, as it is written on update: every leaf optional. - * - * This is what makes `{ seo: { description } }` a one-leaf change rather than a - * request to blank `seo.title`. `.refine` keeps the object from being empty, so - * `{ seo: {} }` is a mistake rather than a silent no-op that still counts as a - * write. - */ const groupPatchSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { const inner = contentInnerFields(fieldValue); const names = Object.keys(inner); @@ -465,11 +378,6 @@ const inputShape = ( }), ); -/** - * Update never applies create defaults: `PUT { title }` must leave `status`, - * `views` and every other defaulted column alone, not silently reset them to - * the column default. Every field is simply optional here. - */ const updateShape = ( fields: ContentFieldMap, names: readonly string[], @@ -495,21 +403,6 @@ const updateShape = ( }), ); -/** - * The equality filters a generated list route accepts, keyed by field name. - * - * Filters arrive as query-string values, so every entry parses and coerces from - * a string. Only kinds in `CONTENT_FILTERABLE_FIELD_KINDS` get one - the same - * list the query builder and `FilterableContentFieldKind` use. - * - * A plain (non-strict) object on purpose: the list route hands it the *whole* - * query string, which also carries `cursor`, `first`, `last`, `order`, `orderBy` - * and `search`. Those are parsed separately, so this schema ignores every key it - * does not recognise rather than rejecting it. The upshot is that a query string - * cannot smuggle an unsupported field into `buildFilterCondition` - it simply - * never appears in the parsed result. A direct service call can still pass one, - * which is why the query builder re-checks kind and nullability itself. - */ const filterShape = (fields: ContentFieldMap): z.ZodRawShape => Object.fromEntries( Object.entries(fields) @@ -547,24 +440,9 @@ const filterShape = (fields: ContentFieldMap): z.ZodRawShape => }), ); -/** - * An exposed relation comes back as an identifier, and nothing else. - * - * No label: the only one available is the target's `admin.titleField`, which is - * administrative metadata and may name a field the target never publishes. See - * `ContentPublicRelation` for the reasoning. - */ const publicRelationSchema = (): z.ZodObject<z.ZodRawShape> => z.object({ id: z.number() }); -/** - * Groups an allowlist's leaf paths by the container they belong to. - * - * `["title", "seo.title", "seo.description"]` becomes `{ seo: ["title", - * "description"] }` and leaves `"title"` to the flat pass. Order within a - * container follows the allowlist, so the generated response shape is as - * deterministic as everything else the engine emits. - */ export const groupPublicLeafPaths = ( names: readonly string[], ): Map<string, string[]> => { @@ -586,14 +464,6 @@ export const groupPublicLeafPaths = ( return owners; }; -/** - * One exposed container - a group or a repeatable - carrying **only** the leaves - * the allowlist named. - * - * This is where leaf-level privacy is actually implemented: `seo.indexable` is - * absent from the shape, and therefore absent from the `SELECT` the shape drives, - * however many other `seo.*` paths are public. - */ const publicContainerSchema = ( fieldValue: ContentFieldDescriptor, leaves: readonly string[], @@ -613,17 +483,6 @@ const publicContainerSchema = ( return applyNullable(z.object(shape), fieldValue); }; -/** - * The public response shape, built from the allowlist and nothing else. - * - * This is also what the public service's `SELECT` map is derived from, so a - * field missing here is a field that never leaves Postgres - not one that is - * fetched and then deleted. - * - * Takes **every** declared field, shared and localized alike: a public localized - * response is one base row joined to one translation, so where a value is stored - * is a fact about the query rather than about the response. - */ const publicSelectShape = ( fields: ContentFieldMap, publicApi: ResolvedContentPublicApiConfig, @@ -685,18 +544,6 @@ const publicSelectShape = ( ), }); -/** - * The translation schemas for one content type, or `null` when it has none. - * - * Takes only the pieces it needs rather than a whole definition, so - * `defineContentType` can call it before the definition object exists and - * without re-widening its field map. - * - * Localized fields only, and never a metadata key: `itemId` and `languageId` - * identify the row rather than describing it, and `version` is assigned by the - * conditional `UPDATE` that guards on it. All three are absent from the strict - * `values` object, which is what stops any of them being mass-assigned. - */ const buildTranslationSchemas = <TDefinition>({ admin, localizedFields, diff --git a/packages/vitnode/src/content/search.test.ts b/packages/vitnode/src/content/search.test.ts index 375a5b21a..84a03e553 100644 --- a/packages/vitnode/src/content/search.test.ts +++ b/packages/vitnode/src/content/search.test.ts @@ -42,11 +42,6 @@ const validSearch = { titleField: "title", } as const; -/** - * Runtime validation has to hold for a JavaScript caller and for a widened - * TypeScript value, so every case goes in as an untyped `search` object - the - * types are asserted separately in `search.test-d.ts`. - */ const define = ({ publication = true, search, diff --git a/packages/vitnode/src/content/search.ts b/packages/vitnode/src/content/search.ts index 18133f254..55cc942d5 100644 --- a/packages/vitnode/src/content/search.ts +++ b/packages/vitnode/src/content/search.ts @@ -6,18 +6,6 @@ import { } from "./const"; import { isContentReferenceCollection, splitContentFieldPath } from "./paths"; -/** - * The public URL of one record, for a search hit. - * - * `null` for an empty slug rather than a throw: a slug column is `NOT NULL` and - * `slugify` rejects a value that folds to nothing, so this is unreachable - * through the engine - but a row written straight into the database must not - * produce a link to `/articles/`. - * - * `encodeURIComponent` is defence in depth for the same reason. Substitution is - * a single literal replace, and `defineContentType` has already proven the - * template holds exactly one `{slug}` and no other placeholder. - */ export const contentSearchUrl = ( definition: AnyContentTypeDefinition, slug: string, @@ -47,15 +35,6 @@ export const contentSearchUrl = ( : withSlug; }; -/** - * A human-readable identifier for one search document, for log lines and the - * AdminCP. - * - * **Not a storage key.** The search index identifies a document by - * `(itemType, itemId, languageCode)`, and the content type id already carries - * the plugin namespace (`plugin.entity`), so there is nothing for a second - * identifier format to disambiguate. - */ export const contentSearchDocumentId = ( definition: AnyContentTypeDefinition, id: number, diff --git a/packages/vitnode/src/content/server/actor.ts b/packages/vitnode/src/content/server/actor.ts index 95af39998..e98ae5f30 100644 --- a/packages/vitnode/src/content/server/actor.ts +++ b/packages/vitnode/src/content/server/actor.ts @@ -2,16 +2,6 @@ import type { Context } from "hono"; import type { ContentActor } from "../revisions"; -/** - * Who a request is acting as, for the revision it is about to write. - * - * Built by the *route*, not by the service, because only the route knows which - * gate it sits behind. An admin route has already been through - * `globalAdminMiddleware` and `assertStaffPermission`, so `c.get("admin")` is - * populated and the mutation is `staff`. A hand-written route that a signed-in - * member reached is `api`. Anything with no user at all - a cron request, the - * queue worker - is `system`, and gets a `null` user id rather than a fake one. - */ export const resolveContentActor = (c: Context): ContentActor => { const admin = c.get("admin") as null | { user?: { id?: unknown } }; const adminId = admin?.user?.id; diff --git a/packages/vitnode/src/content/server/advanced-projection.test.ts b/packages/vitnode/src/content/server/advanced-projection.test.ts index 613843342..df77f8649 100644 --- a/packages/vitnode/src/content/server/advanced-projection.test.ts +++ b/packages/vitnode/src/content/server/advanced-projection.test.ts @@ -16,16 +16,6 @@ import { } from "./revision-snapshot"; import { contentSearchDocument } from "./search-document"; -/** - * The pure projections Stage 6 adds, tested without a database. - * - * Each of these is a rule that is easy to state and easy to get subtly wrong: - * which paths a patch changed, which columns that becomes, what a snapshot - * records, what a search document is made of, and which keys a public response - * carries. All of them are functions of their arguments, so all of them are - * table tests rather than fixtures. - */ - const categoryContentType = defineContentType({ fields: { name: field.text({ required: true }) }, id: "test.proj-category", diff --git a/packages/vitnode/src/content/server/advanced-store.ts b/packages/vitnode/src/content/server/advanced-store.ts index 7c5db3bf1..c4d558b60 100644 --- a/packages/vitnode/src/content/server/advanced-store.ts +++ b/packages/vitnode/src/content/server/advanced-store.ts @@ -28,37 +28,10 @@ import { } from "../paths"; import { toColumnValues } from "./query"; -/** - * The read and write layer for a content type's advanced collections. - * - * One module rather than one per kind, because a to-many relation and a - * repeatable are the same problem twice: an ordered list of child rows keyed by - * a parent, replaced as a whole, with a unique `(itemId, position)` that must - * never be violated even for an instant. Sharing the write dance is what keeps - * "reorder cannot produce a duplicate slot" one piece of code rather than two - * that agree on the day they are written. - * - * **Nothing here locks anything.** Every mutation runs inside a transaction the - * caller owns, and the caller has already taken the source record's lock - - * optimistically through the guarded `version` UPDATE on an editorial content - * type, pessimistically through `SELECT ... FOR UPDATE` on one without. That is - * why two concurrent `set` calls cannot interleave here: one of them never - * reaches this module. - */ - /** One repeatable child, as it is written back. */ type ChildValues = Record<string, unknown>; export interface ContentAdvancedStore { - /** - * The field names whose collections a patch would actually move. - * - * Read-only, so a caller can decide whether there is anything to write - and - * therefore whether to bump the version, write a revision and emit an event - - * before it takes the write lock. A reorder to the order that is already - * there comes back empty, which is what makes it a no-op rather than a - * version bump with nothing in it. - */ diff: ( tx: ContentDatabase, itemId: number, @@ -68,61 +41,24 @@ export interface ContentAdvancedStore { readonly enabled: boolean; /** The collection field names, in declaration order. */ readonly fields: string[]; - /** - * Every collection of one record, in logical shape. - * - * `only` narrows it to the fields a caller actually needs: a public read wants - * the exposed ones and a search document wants the indexed ones, and querying - * a private junction table to discard the rows afterwards is work with no - * answer attached. Omit it for all of them. - */ + load: ( itemId: number, database: ContentDatabase, only?: readonly string[], ) => Promise<Record<string, unknown>>; - /** - * Every collection of many records, in **two queries per collection field** - * rather than two per record. - * - * The whole reason a to-many relation is absent from `ContentSelect`: a list - * that carried one would issue a query per row, and an admin table of 25 rows - * with two collections would be 50 round trips. - */ + loadMany: ( itemIds: readonly number[], database: ContentDatabase, only?: readonly string[], ) => Promise<Map<number, Record<string, unknown>>>; - /** - * An indexed `EXISTS` over one relation's junction table. - * - * `EXISTS` rather than a join, so a record matches once however many junction - * rows it has and the outer query needs no `DISTINCT`. The junction's primary - * key `(itemId, relatedItemId)` covers the lookup exactly. - */ + membershipCondition: ( field: string, filter: ContentRelationFilter, ) => SQL | undefined; - /** - * Makes a historical collection state applicable to the record as it stands. - * - * Two different rules, because the two kinds fail differently: - * - * - a **repeatable child** that no longer exists is *recreated*. Its values - * are all in the snapshot, so restoring it loses nothing except the original - * identifier - and the alternative, refusing, would mean a record could - * never be restored past a delete. - * - a **relation target** that no longer exists is *fatal*. Its values are not - * in the snapshot and never were: the row belongs to another content type - * with its own history, so there is nothing here to recreate it from. - * `missingRelations` names the fields, and the caller turns that into a - * structured not-restorable answer rather than a partial restore. A missing - * **gallery entry** is the same answer for the same reason - the bytes are - * gone and a snapshot holds an identifier, not a file - though the revision's - * own file pins mean it should not be reachable. - */ + prepareRestore: ( tx: ContentDatabase, itemId: number, @@ -131,15 +67,7 @@ export interface ContentAdvancedStore { missingRelations: { field: string; ids: number[] }[]; patch: Record<string, unknown>; }>; - /** - * The table one to-many field points at, read off its junction's foreign key. - * - * The picker's way in: a to-many reference has no column on this row, so - * "which table holds the things this field can choose from" is a question only - * the generated junction can answer. Read from the constraint rather than from - * the descriptor's `target()` thunk, so the table the picker offers rows from - * is by construction the table Postgres will check on write. - */ + targetTable: (field: string) => null | PgTable; /** Applies a patch's collection half. Returns the fields that moved. */ write: ( @@ -162,13 +90,6 @@ const sameJunction = ( current.length === desired.length && current.every((row, index) => row.relatedItemId === desired[index]); -/** - * The order a relation's targets are stored in. - * - * An ordered relation keeps the author's order. An unordered one is sorted by - * target id, which is what makes `set([9, 2, 5])` and `set([2, 5, 9])` the same - * state rather than two writes that differ only in a column nobody declared. - */ const normalizeTargets = ( ids: readonly number[], ordered: boolean, @@ -194,13 +115,6 @@ const sameChildValues = ( return before === after; }); -/** - * Builds the store for one content type. - * - * Returns a disabled stub - every method a no-op - when the content type - * declares no advanced collection, so every caller can use it unconditionally - * and a Stage 1-5 content type still issues exactly the queries it always did. - */ export const createContentAdvancedStore = < TDefinition extends AnyContentTypeDefinition, >({ @@ -246,16 +160,6 @@ export const createContentAdvancedStore = < const leafNamesOf = (field: string): string[] => Object.keys(contentInnerFields(fields[field])); - /** - * The target `id` column of one to-many relation, read off the junction's own - * foreign key. - * - * Read from the constraint rather than from the descriptor's `target()` thunk, - * so the table this checks is by construction the table Postgres will check. - * Resolved lazily and memoised: `foreignKey.reference()` is the thunk Drizzle - * leaves unevaluated so two content types can refer to each other, and forcing - * it at construction would defeat that. - */ const targetColumns = new Map<string, null | PgColumn>(); const relationTargetColumn = (field: string): null | PgColumn => { const cached = targetColumns.get(field); @@ -278,14 +182,6 @@ export const createContentAdvancedStore = < return resolved; }; - /** - * Refuses a relation set whose targets are not all real rows. - * - * Checked rather than left to the foreign key for the reason - * `ContentAdvancedInputError` documents: `23503` cannot say which of the two - * references failed, and a caller that sent a stale category id should be told - * that rather than told the article is gone. - */ const assertTargetsExist = async ( tx: ContentDatabase, field: string, @@ -549,12 +445,6 @@ export const createContentAdvancedStore = < if (desired.length > 0) await settlePositions(tx, child, itemId); }; - /** - * What a patch would change, and the desired state it would change it to. - * - * Computed once and reused by `diff` and `write`, so the no-op rule and the - * write agree by construction rather than by both reading the same comment. - */ const plan = async ( tx: ContentDatabase, itemId: number, @@ -614,13 +504,6 @@ export const createContentAdvancedStore = < return changes; }; - /** - * The collection fields one read should touch. - * - * An unknown name is dropped rather than rejected: callers pass an allowlist - * derived from configuration - the public `fields`, the search paths - and a - * name that is not a collection simply has no collection to load. - */ const selected = (only?: readonly string[]): string[] => only === undefined ? collectionNames diff --git a/packages/vitnode/src/content/server/advanced-tables.ts b/packages/vitnode/src/content/server/advanced-tables.ts index 4f3c4ce0a..9cb7a34f9 100644 --- a/packages/vitnode/src/content/server/advanced-tables.ts +++ b/packages/vitnode/src/content/server/advanced-tables.ts @@ -1,13 +1,3 @@ -/** - * Generates the relational storage Stage 6 needs. - * - * Two shapes, both ordinary Drizzle tables so `drizzle-kit` discovers them the - * same way it discovers every other one - by runtime identity, when it globs the - * plugin's built `dist/src/database/*.js`. There is no JSONB column, no - * comma-separated identifier list and no property/value table anywhere in here: - * a to-many relation is a junction table with two foreign keys, and a repeatable - * is a child table with real columns, real constraints and real indexes. - */ import type { AnyPgColumnBuilder, PgColumn, @@ -47,36 +37,6 @@ import { } from "../paths"; import { buildContentColumn } from "./column-builders"; -/** - * The junction table for one to-many relation field. - * - * ```text - * example_articles_categories - * itemId -> example_articles.id ON DELETE CASCADE - * relatedItemId -> example_categories.id ON DELETE <configured> - * position integer NOT NULL - * createdAt timestamp NOT NULL DEFAULT now() - * - * PRIMARY KEY (itemId, relatedItemId) - * UNIQUE (itemId, position) - * INDEX (relatedItemId) - * ``` - * - * `itemId` always cascades: the references *belong to* the source record, so - * deleting it takes them in one statement rather than leaving rows pointing at - * nothing. The other side takes the field's own `onDelete`, which is what makes - * `restrict` mean "you cannot delete a category that is still in use" and have - * Postgres be the thing that enforces it - not a check in service code that a - * direct SQL delete would walk straight past. A **file** collection is always - * `restrict`: the engine, not the author, decides that deleting a file a gallery - * still shows has to be refused. - * - * `position` is always stored, ordered relation or not, and is always contiguous - * from zero. That is what lets one `UNIQUE (item_id, position)` serve both: an - * ordered relation gets deterministic slots, and an unordered one gets a - * deterministic *read* order (ascending target id, assigned at write time) - * instead of whatever the planner felt like returning. - */ export const createContentJunctionTable = ({ contentTypeId, field, @@ -139,33 +99,6 @@ export const createContentJunctionTable = ({ ) as unknown as ContentJunctionTable; }; -/** - * The child table for one repeatable field. - * - * ```text - * example_articles_faq - * id serial PRIMARY KEY - * itemId -> example_articles.id ON DELETE CASCADE - * position integer NOT NULL - * createdAt timestamp NOT NULL DEFAULT now() - * updatedAt timestamp NOT NULL DEFAULT now() - * question varchar(200) NOT NULL - * answer text NOT NULL - * - * UNIQUE (itemId, position) - * ``` - * - * `id` is a `serial` of its own and **not** `(itemId, position)`. Position is - * where a child currently sits; identity is what a later edit addresses and what - * a revision restore matches an historical row against. Conflating the two would - * make "update the third FAQ entry" mean a different row after every reorder, - * and would make a restore recreate rows instead of putting values back. - * - * The unique index on `(itemId, position)` is what makes duplicate slots - * impossible rather than merely unlikely; the writer avoids transient collisions - * by replacing the whole list in one delete-then-insert inside the transaction - * that already holds the source row's lock. - */ export const createContentRepeatableTable = ({ contentTypeId, fields, @@ -209,13 +142,6 @@ export const createContentRepeatableTable = ({ ) as unknown as ContentRepeatableChildTable<unknown>; }; -/** - * Every generated collection table of one content type. - * - * Driven by `definition.advanced`, which `defineContentType` has already - * validated and named - so the table this creates and the table a migration - * creates are the same table by construction rather than by coincidence. - */ export const createContentAdvancedTables = < TDefinition extends AnyContentTypeDefinition, >( diff --git a/packages/vitnode/src/content/server/collection-api.ts b/packages/vitnode/src/content/server/collection-api.ts index 2ad83818c..8dbc080be 100644 --- a/packages/vitnode/src/content/server/collection-api.ts +++ b/packages/vitnode/src/content/server/collection-api.ts @@ -2,28 +2,6 @@ import type { AnyContentTypeDefinition } from "../types"; import { ContentEngineError } from "../errors"; -/** - * The convenience collection API, built once for both services. - * - * Every method here is a **read-modify-write**, and that is the whole reason it - * is one module rather than two: the read has to happen after the source row is - * locked, and a helper that read before the lock would lose one of two - * concurrent additions with nothing to show it had. The service supplies the - * locking - `run` below - and this file supplies only the arithmetic. - * - * `set` is the exception that proves the rule: it replaces the whole collection, - * so it does not read at all and cannot lose anything. - */ - -/** - * Locks the source record, reads one collection, and applies what `compute` - * makes of it - all inside one transaction. - * - * `compute` runs **after** the row lock, so the state it derives the next - * collection from is the committed one. It may throw: a reorder that is not a - * permutation of what is stored is refused there, inside the lock, against the - * list the write will actually replace. - */ export type ContentCollectionRunner<TResult, TOptions> = ( itemId: number, field: string, @@ -52,16 +30,6 @@ export interface ContentCollectionApi<TResult, TOptions> { write: ContentCollectionWriter<TResult, TOptions>; } -/** - * A reorder has to be a permutation of what is stored. - * - * Refused rather than treated as a `set`, because the two mean different things - * and only one of them is reversible by looking at the request: a reorder that - * silently dropped an entry would look like a successful drag. Checked inside - * the lock, against the list the write is about to replace - checking it against - * a list read earlier would refuse a valid reorder, or accept an invalid one, - * whenever somebody else had written in between. - */ export const assertContentPermutation = ({ contentTypeId, current, @@ -98,16 +66,6 @@ const asRows = (current: readonly unknown[]): Record<string, unknown>[] => typeof value === "object" && value !== null, ); -/** - * The five to-many relation operations, for one field. - * - * `add` of a target already present, `remove` of one that is not there and - * `reorder` to the stored order all compute a list equal to what is stored, so - * the diff finds nothing and the write is a no-op - no `updatedAt`, no version - * bump, no revision, no event. That falls out of computing the whole next state - * rather than issuing a targeted `INSERT`, which is why it holds for every one of - * them without a special case. - */ export const buildContentRelationOperations = <TResult, TOptions>({ api, contentTypeId, diff --git a/packages/vitnode/src/content/server/column-builders.ts b/packages/vitnode/src/content/server/column-builders.ts index da6c0599a..27bfcc89d 100644 --- a/packages/vitnode/src/content/server/column-builders.ts +++ b/packages/vitnode/src/content/server/column-builders.ts @@ -23,11 +23,6 @@ import { ContentEngineError } from "../errors"; export type ColumnReferenceThunk = () => AnyPgColumn; -/** - * The three columns every content table gets, matching the conventions used by - * all 22 core tables: a `serial` primary key, `defaultNow()` on `createdAt`, - * and `defaultNow().$onUpdate(...)` on `updatedAt`. - */ export const buildSystemColumns = (): Record<string, AnyPgColumnBuilder> => ({ id: serial().primaryKey(), createdAt: timestamp().notNull().defaultNow(), @@ -37,17 +32,6 @@ export const buildSystemColumns = (): Record<string, AnyPgColumnBuilder> => ({ .$onUpdate(() => new Date()), }); -/** - * The two columns `publication: { enabled: true }` adds. - * - * `status` is `varchar` rather than a Postgres enum, matching how `field.enum` - * is already materialised, so adding a status later is not a type migration. It - * carries `DEFAULT 'draft' NOT NULL` so drizzle-kit backfills an existing table - * in a single statement - every pre-existing row becomes a draft. - * - * `published_at` is nullable with no default: it means "first published at", - * and `unpublish` deliberately leaves it alone. - */ export const buildPublicationColumns = (): Record< string, AnyPgColumnBuilder @@ -61,17 +45,6 @@ export const buildPublicationColumns = (): Record< .default("draft"), }); -/** - * The one column `editorial: { enabled: true }` adds. - * - * `DEFAULT 1 NOT NULL`, so drizzle-kit backfills an existing table in a single - * statement and every pre-existing row starts at version 1 - the same property - * that makes adding `status DEFAULT 'draft'` safe. - * - * Never written by `create` or `update`: the editorial service increments it in - * the same conditional `UPDATE` that guards on it, which is what makes the - * check-and-set atomic. - */ export const buildEditorialColumns = (): Record< string, AnyPgColumnBuilder @@ -79,19 +52,6 @@ export const buildEditorialColumns = (): Record< version: integer().notNull().default(1), }); -/** - * The columns every generated translation table carries. - * - * `itemId` and `languageId` are the composite primary key, added by - * `createContentTranslationTable` - both are `NOT NULL` here because a key - * column has to be, and both are written by the service rather than by a - * request. - * - * `version` mirrors the editorial column deliberately: a translation has *its - * own* optimistic lock, so an edit in Polish and an edit in English cannot - * conflict with each other. It defaults to 1 and is only ever moved by the - * conditional `UPDATE` that guards on it. - */ export const buildTranslationSystemColumns = ({ itemReference, languageReference, @@ -125,28 +85,11 @@ export const buildTranslationSystemColumns = ({ .$onUpdate(() => new Date()), }); -/** - * The two columns a translation row gains with `publication: { enabled: true }`. - * - * Literally {@link buildPublicationColumns}, aliased so the translation table - * reads as what it is rather than borrowing a name that says "base table". The - * `DEFAULT 'draft'` is what makes this migration safe on an install that already - * has Stage 5A translations: every existing row becomes a draft in one statement, - * which is the only correct backfill - silently publishing translations somebody - * wrote while the feature did not exist would put them on the internet. - */ export const buildTranslationPublicationColumns = (): Record< string, AnyPgColumnBuilder > => buildPublicationColumns(); -/** - * Applies `NOT NULL` and the column default. - * - * Written as a generic over the concrete builder so each `default(...)` call - * sees the narrowed value type - a single shared `default()` at the end would - * have to accept the union of every field kind's value. - */ const withModifiers = < TBuilder extends { default: (value: TValue) => TBuilder; @@ -162,12 +105,6 @@ const withModifiers = < return defaultValue === undefined ? withNull : withNull.default(defaultValue); }; -/** - * Compiles one field descriptor into a Drizzle column builder. - * - * `nullable` drives `NOT NULL`, and a declared `defaultValue` becomes the - * column default so Postgres and the generated Zod schema agree. - */ export const buildContentColumn = ({ contentTypeId, fieldValue, diff --git a/packages/vitnode/src/content/server/delivery-alternates.ts b/packages/vitnode/src/content/server/delivery-alternates.ts index 4fc2a56b9..4ea7b283b 100644 --- a/packages/vitnode/src/content/server/delivery-alternates.ts +++ b/packages/vitnode/src/content/server/delivery-alternates.ts @@ -21,24 +21,6 @@ import { publishedCondition, } from "./publication"; -/** - * The localized alternates of one record: every language it is genuinely - * published in, and its URL there. - * - * "Genuinely" is the whole of it, and it is why this is a query rather than a - * projection of something the public read already returned. An alternate is a - * promise that a URL resolves, so the predicate is the *same* subordinated - * publication rule the public read applies - the base row published, the - * translation published, both dated now or earlier - and a locale that only exists - * through `fallback: "default"` fails it. Fabricating an alternate from a fallback - * would announce `/de/articles/x` for a record with no German translation: an - * `hreflang` pointing at a 404, and an invitation to index the English copy twice. - * - * A language the installation has switched off is filtered out too, in JavaScript - * rather than in SQL - "enabled" is a fact about the app config, not a column on - * `core_languages`, and `listContentLanguages` already holds it for the life of the - * request. - */ export const readDeliveryAlternates = async < TDefinition extends AnyContentTypeDefinition, >({ @@ -59,13 +41,6 @@ export const readDeliveryAlternates = async < return batched.get(itemId) ?? []; }; -/** - * The same answer for a whole page of records, in one query. - * - * A sitemap with `xhtml:link` alternates needs the alternates of every URL on the - * page, and a per-record query there is the classic N+1 that only becomes visible - * once a site has content. One `IN` and one grouping pass instead. - */ export const readDeliveryAlternatesMany = async < TDefinition extends AnyContentTypeDefinition, >({ diff --git a/packages/vitnode/src/content/server/delivery-effects.test.ts b/packages/vitnode/src/content/server/delivery-effects.test.ts index 0f23e4d29..0d4a96246 100644 --- a/packages/vitnode/src/content/server/delivery-effects.test.ts +++ b/packages/vitnode/src/content/server/delivery-effects.test.ts @@ -11,15 +11,6 @@ import { contentDeliveryInvalidation, } from "./delivery-effects"; -/** - * Which delivery events one mutation emits, and which it deliberately does not. - * - * Both events are gated on a *fact* rather than on an operation: the URL moved, and - * the old address had been live. A listener that warms a CDN or writes an edge - * redirect table acts on the second one, so emitting it for a corrected draft would - * make it act on a URL nobody ever visited. - */ - const articleType = defineContentType({ id: "effects.article", editorial: { enabled: true }, diff --git a/packages/vitnode/src/content/server/delivery-effects.ts b/packages/vitnode/src/content/server/delivery-effects.ts index 0736041f8..72ee5ba85 100644 --- a/packages/vitnode/src/content/server/delivery-effects.ts +++ b/packages/vitnode/src/content/server/delivery-effects.ts @@ -9,41 +9,9 @@ import { reportContentEventFailures } from "./effects-log"; import { emitContentEvent } from "./emit"; export interface ContentDeliveryEffectsResult { - /** - * What the event transport reported for each delivery event, in the order they - * were emitted. Empty when the mutation moved no URL. - * - * Present rather than discarded for the same reason the editorial effects keep - * theirs: `EventsModel.emit` does not throw, so `failures` is the only place a - * dead listener or a broker outage is visible. - */ events: EventEmitResult[]; } -/** - * The delivery events one mutation owes the rest of the system, after it commits. - * - * Two events at most, and each one is gated on a fact rather than on an operation: - * - * - **`delivery_slug_changed`** whenever the canonical URL is different from what - * it was. Emitted *alongside* `updated` or `restored`, never instead of one: the - * field mutation and the URL change are different facts with different audiences, - * and a listener that warms a CDN or writes an edge redirect table would - * otherwise have to inspect `changedFields` for a slug field whose name it cannot - * know. - * - **`delivery_redirect_created`** only when the old address had genuinely been - * live. A draft whose slug was corrected three times before it was ever published - * emits nothing at all, which is the difference between "a URL now needs a - * redirect" and "somebody edited a field". - * - * There is deliberately no sitemap event. Every mutation that changes a sitemap - * line already emits `published`, `unpublished`, `deleted` or one of the two above, - * and a fifth event carrying no new information would be one more thing to keep - * consistent for no listener's benefit. - * - * **Call it only after the write has returned - never inside the transaction.** A - * rollback cannot un-emit an event. - */ export const contentDeliveryEffects = async ( c: Context, definition: AnyContentTypeDefinition, @@ -52,21 +20,6 @@ export const contentDeliveryEffects = async ( ): Promise<ContentDeliveryEffectsResult> => { const events: EventEmitResult[] = []; - /** - * Emits one delivery event and reports whoever did not hear it. - * - * The reporting is not optional decoration. `EventsModel.emit` reports rather - * than throws, so `failures` is the only place a dead listener is visible - and - * these events are the ones with the most expensive silent failure in the engine: - * a listener that writes an edge redirect table or purges a CDN missing a - * `delivery_slug_changed` leaves a moved URL 404ing at the edge while the origin - * is perfectly correct. The base and translation effects log their own event for - * exactly this reason, and a delivery event that skipped the log would be the one - * announcement nobody could find afterwards. - * - * The write has already committed, so this never fails the request - see - * `reportContentEventFailures`. - */ const announce = async ( action: "delivery_redirect_created" | "delivery_slug_changed", payload: Record<string, unknown>, @@ -138,14 +91,6 @@ export const contentDeliveryEffects = async ( return { events }; }; -/** - * The delivery half of a mutation's cache invalidation, or `undefined`. - * - * `undefined` for a content type without `delivery`, which is what makes - * `contentInvalidationTags` return exactly the strings it always returned - and - * therefore what makes Stage 8 opt-in at the cache layer as well as everywhere - * else. - */ export const contentDeliveryInvalidation = ( definition: AnyContentTypeDefinition, delivery: ContentDeliveryOutcome | undefined, diff --git a/packages/vitnode/src/content/server/delivery-routes.test.ts b/packages/vitnode/src/content/server/delivery-routes.test.ts index a49ba15c3..75fbfc171 100644 --- a/packages/vitnode/src/content/server/delivery-routes.test.ts +++ b/packages/vitnode/src/content/server/delivery-routes.test.ts @@ -10,19 +10,6 @@ import { import { createContentModel } from "./model"; import { buildContentPublicRoutes } from "./public-routes"; -/** - * The generated public delivery routes. - * - * Two things are being asserted, and only one of them is about delivery: - * - * 1. The routes answer without any session at all, and their bodies match the - * schemas the OpenAPI document publishes - including the discriminated union, - * whose whole purpose is that a client can branch on `type` rather than guess. - * 2. A content type **without** `delivery` gains no routes whatsoever. That is the - * Stage 1-7 regression assertion at the routing layer: the path list of an - * existing public content type does not move. - */ - const delivered = createContentModel(testDeliveredPostContentType); const PLUGIN_ID = "@vitnode/example"; diff --git a/packages/vitnode/src/content/server/delivery-routes.ts b/packages/vitnode/src/content/server/delivery-routes.ts index 9c5768920..e80aff208 100644 --- a/packages/vitnode/src/content/server/delivery-routes.ts +++ b/packages/vitnode/src/content/server/delivery-routes.ts @@ -19,30 +19,6 @@ import { ContentDeliveryNotEnabled } from "../errors"; import { resolveContentPublicLocale } from "../locale"; import { listContentLanguages } from "./language-resolver"; -/** - * The public delivery routes one content type with `delivery` gets. - * - * ```http - * GET /api/{pluginId}/content/{publicApi.path}/delivery/resolve/{slug} - * GET /api/{pluginId}/content/{publicApi.path}/delivery/item/{id} - * GET /api/{pluginId}/content/{publicApi.path}/delivery/sitemap (delivery.sitemap) - * ``` - * - * They exist because a frontend is very often **not** the process that holds the - * database: VitNode's split deployment runs Next.js against a separate API, so - * `generateMetadata`, a catch-all route and a `sitemap.xml` handler all need an - * HTTP answer rather than a service call. A single-process install can still use - * `model.deliveryService(c)` directly and never touch these. - * - * Every path begins with the static `delivery` segment, which is what makes them - * impossible to shadow: `/{slug}` is one segment and these are two or three, so a - * record whose slug happens to be `delivery` or `sitemap` still resolves the - * ordinary way, whatever order the routes are registered in. - * - * No `adminStaffPermission` and no `/admin/` anywhere in the path - public delivery - * resolution is exactly as public as the content it describes, and requiring a - * session to learn a canonical URL would be requiring one to render a page. - */ export const buildContentDeliveryRoutes = < TDefinition extends AnyContentTypeDefinition, P extends string, @@ -71,14 +47,6 @@ export const buildContentDeliveryRoutes = < } : {}; - /** - * Which language this request is for. - * - * The same resolution the public read routes use, for the same reason: an - * explicit `?locale=` that names no language this install serves is a request for - * something that does not exist, and substituting the default would announce an - * English canonical URL under a Polish one. - */ const localeFor = async (c: Context) => { if (!localized) return { locale: undefined, source: "default" as const }; @@ -122,17 +90,6 @@ export const buildContentDeliveryRoutes = < seo: zodSeo, }); - /** - * The resolution, as a discriminated union. - * - * Three arms rather than a nullable object with an optional `location`, because - * the three outcomes need three different HTTP responses and a client that had to - * infer which one it was holding would eventually redirect to `undefined`. - * - * Nothing internal is in it: no `languageId`, no `pluginId`, no `retiredAt`. Those - * are storage details of `core_content_slug_history`, and a public contract that - * carried them would be a public contract that could not change. - */ const zodResolution = z.discriminatedUnion("type", [ zodMetadata.extend({ type: z.literal("content") }), z.object({ diff --git a/packages/vitnode/src/content/server/delivery-service.test.ts b/packages/vitnode/src/content/server/delivery-service.test.ts index 79c8ff7e8..7192d4bec 100644 --- a/packages/vitnode/src/content/server/delivery-service.test.ts +++ b/packages/vitnode/src/content/server/delivery-service.test.ts @@ -11,19 +11,6 @@ import { defineContentType } from "../define"; import { field } from "../fields"; import { createContentDeliveryService } from "./delivery-service"; -/** - * The delivery resolver, against the real service, without a database. - * - * The two reads it performs - the public projection and the slug-history lookup - - * are stubbed, and nothing else is: `createContentDeliveryService` is the code under - * test, so the decision it makes (canonical, redirect, or nothing) is the thing - * being asserted rather than a copy of it. That decision is where a mistake becomes - * a permanent 308 to the wrong page, which is exactly why it is worth testing - * without the ceremony of a database. - * - * The queries themselves are covered by the Postgres suite in `plugins/example`. - */ - const PLUGIN = "@vitnode/test"; const articleType = defineContentType({ @@ -111,14 +98,6 @@ interface PublicRow { type QueryRows = Record<string, unknown>[]; -/** - * A Drizzle query builder that resolves to whatever the table asks for. - * - * A thenable rather than a promise-returning `limit()`, because the two reads this - * file needs end differently: the language registry awaits straight off `.from()` - * and the history lookup chains `.where().limit(1)` (and sometimes `.for("update")`). - * One thenable satisfies both without the stub having to know which. - */ const buildDatabase = (rowsFor: (table: unknown) => QueryRows): unknown => { const select = () => { let table: unknown; @@ -145,15 +124,6 @@ const buildDatabase = (rowsFor: (table: unknown) => QueryRows): unknown => { return { select }; }; -/** - * A model whose public service is a map and whose history table is an array. - * - * `findById` mimics the Stage 5 fallback rule rather than re-deriving it: a locale - * with no row of its own is served the default one, and the row says which language - * it is actually in. That is the contract `createContentLocalizedPublicService` - * holds, and reading through it is the whole reason delivery inherits the - * publication predicate and the field allowlist for free. - */ const buildService = ({ byId = {}, bySlug = {}, diff --git a/packages/vitnode/src/content/server/delivery-service.ts b/packages/vitnode/src/content/server/delivery-service.ts index af3c7e2b4..feef9c524 100644 --- a/packages/vitnode/src/content/server/delivery-service.ts +++ b/packages/vitnode/src/content/server/delivery-service.ts @@ -29,25 +29,10 @@ import { readContentDeliverySitemapPage } from "./delivery-sitemap"; import { findContentLanguage } from "./language-resolver"; import { createContentSlugHistoryModel } from "./slug-history-model"; -/** - * Everything a page needs to render one record's `<head>`. - * - * Two locales, not one, and the distinction is the whole reason this type exists: - * `requestedLocale` is what the URL asked for and `locale` is what the reader is - * actually being shown. With `localization.fallback: "default"` those differ, and a - * canonical URL built from the first would announce `/pl/articles/x` for an English - * translation - a URL that answers 404, self-referentially declared canonical. - */ export interface ContentDeliveryMetadata { /** Real published translations only. Empty for a nonlocalized content type. */ alternates: ContentDeliveryAlternate[]; - /** - * The canonical path of the version actually being served. - * - * `null` only when the record has no public URL in that language at all, which - * for a resolved record means its slug is empty - a row written straight into the - * database rather than through the engine. - */ + canonicalPath: null | string; /** Present only when the caller supplied an origin. */ canonicalUrl?: null | string; @@ -55,17 +40,7 @@ export interface ContentDeliveryMetadata { hreflang: ContentDeliveryHreflang; /** Whether `locale` differs from `requestedLocale`. */ isFallback: boolean; - /** - * The record's identifier, when the public projection carries one. - * - * `null` for a content type whose `publicApi.fields` withholds `id`, and that is - * deliberate rather than a gap: delivery metadata is read off the **public** - * projection, so it cannot report a column the public API declined to publish. - * Expose `"id"` in the allowlist and it is always present. - * - * A resolution reached through {@link ContentDeliveryService.findById} always - * carries it, because the caller supplied it. - */ + itemId: null | number; /** The language this response is actually in. */ locale: null | string; @@ -78,14 +53,6 @@ export interface ContentDeliveryMetadata { seo: ContentDeliverySeo; } -/** - * What one public path resolves to. - * - * A discriminated union rather than a nullable metadata object, because the three - * outcomes need three different HTTP responses and a caller that had to infer - * which one it was holding would get it wrong. `redirect` carries its own status - * so a frontend never hardcodes one. - */ export type ContentDeliveryResolution = | (ContentDeliveryMetadata & { type: "content" }) | { location: string; status: 308; type: "redirect" } @@ -107,52 +74,19 @@ export interface ContentDeliverySitemapArgs { locale?: string; } -/** - * The read-only delivery layer of one content type. - * - * There is deliberately **no mutation here at all**. Slug history is written by - * the editorial services, inside the transaction that moves the slug, because that - * is the only place the two can be atomic - and exposing a `reserve` here would be - * an invitation to write one without the other. This object answers questions. - * - * Every answer is derived from the **public** projection, not from the base row: - * `findById` and `resolveSlug` go through `model.publicService`, so the publication - * predicate, the field allowlist and the Stage 5 fallback rules are the ones - * already tested rather than a second implementation that agrees on the day it is - * written. That is also what makes "SEO cannot leak a private field" true here for - * free: a private column is never fetched, so it is not in the row this reads. - */ export interface ContentDeliveryService { - /** - * Every published translation's URL, in a stable order. - * - * Only real ones. A locale served by the fallback has no URL of its own, so it - * is absent - listing it would announce an `hreflang` alternate that answers - * 404 and invite a crawler to index the same content twice. - */ alternates: (itemId: number) => Promise<ContentDeliveryAlternate[]>; /** Delivery metadata by identifier, honouring the content type's fallback. */ findById: ( itemId: number, options?: ContentDeliveryReadOptions, ) => Promise<ContentDeliveryMetadata | null>; - /** - * Every address this record has ever answered to, current one first. - * - * Read-only, and the AdminCP's delivery panel is its only caller today. It needs - * no permission of its own beyond the one that let the reader see the record. - */ + history: ( itemId: number, options?: { locale?: string }, ) => Promise<ContentSlugHistoryEntry[]>; - /** - * Resolves a whole public path: `/pl/articles/stary-slug`. - * - * The one method a catch-all route calls. It parses the path with the same rules - * {@link contentDeliveryPath} builds it by, so a path this engine did not produce - * is `not_found` rather than a guess. - */ + resolvePath: ( path: string, options?: { origin?: string }, @@ -216,14 +150,6 @@ export const createContentDeliveryService = < pluginId, }); - /** - * The language a historical URL belongs to. - * - * `null` whenever the slug is shared, which covers both a nonlocalized content - * type and a localized one whose slug lives on the base row - in the second case - * every language answers to the same segment, so one reservation is correct for - * all of them. - */ const historyLanguageId = async ( locale: null | string, ): Promise<null | number> => { @@ -284,15 +210,6 @@ export const createContentDeliveryService = < ): Promise<ContentDeliveryAlternate[]> => localized ? await readDeliveryAlternates({ c, itemId, model }) : []; - /** - * The record's canonical path **in one specific language**, or `null`. - * - * Strict about the language on purpose. `publicService.findById` may fall back, - * and a redirect must not: sending `/pl/articles/stary-slug` to the English - * canonical would answer a Polish URL with an English page and permanently tell - * a crawler that is correct. So a row that came back in another language is - * treated as "this locale has no published version", which is what it is. - */ const strictCanonicalPath = async ( itemId: number, locale: null | string, @@ -319,14 +236,6 @@ export const createContentDeliveryService = < : contentDeliveryPath({ definition, locale: served, slug }); }; - /** - * The language a read is *actually* for. - * - * The default locale when the caller named none, because that is what the public - * service resolves internally - and the history lookup has to be about the same - * language, or the live branch would search `en` while the redirect branch searched - * the shared rows and found nothing. - */ const localeFor = (locale: string | undefined): null | string => { if (!localized) return null; diff --git a/packages/vitnode/src/content/server/delivery-sitemap.ts b/packages/vitnode/src/content/server/delivery-sitemap.ts index 8d7c90a86..9a7df4d7a 100644 --- a/packages/vitnode/src/content/server/delivery-sitemap.ts +++ b/packages/vitnode/src/content/server/delivery-sitemap.ts @@ -23,28 +23,12 @@ import { publishedCondition, } from "./publication"; -/** - * One page of a content type's sitemap. - * - * `nextCursor` rather than a page number, and `null` rather than `hasNextPage` on - * its own: a sitemap is regenerated from scratch every time a crawler asks, and an - * `OFFSET` deep into a large table both slows down linearly and skips rows when - * something is published between two pages. A keyset over the primary key does - * neither. - */ export interface ContentDeliverySitemapPage { entries: ContentSitemapEntry[]; /** Pass back as `cursor`. `null` when this was the last page. */ nextCursor: null | number; } -/** - * Where a `noIndex` field is stored, resolved to the column it addresses. - * - * A leaf path (`seo.noIndex`) compiles to a generated column, and the delivery - * resolver has already refused a localized one - so this is always a column on the - * base table and the sitemap predicate is one clause rather than a join. - */ const noIndexColumn = ( definition: AnyContentTypeDefinition, columns: Record<string, PgColumn>, @@ -62,28 +46,6 @@ const noIndexColumn = ( return leaf === undefined ? null : (columns[leaf.columnName] ?? null); }; -/** - * One page of sitemap entries for one content type, in one language. - * - * Everything about this function follows from "a sitemap lists what is public - * right now, and nothing else": - * - * - **The publication predicate is not a parameter.** A nonlocalized entry needs - * the base row published; a localized one needs the base row *and* the - * translation published, which is the same subordination the public read - * applies. A draft, an unpublished record and a future `publishedAt` are all - * simply absent. - * - **No fallback, ever.** Each locale is queried against its own translation, so - * a language served English through `fallback: "default"` contributes no URL - - * it has none of its own, and listing one would put the same content in the - * sitemap twice under two addresses. - * - **`lastModified` is `max(base.updatedAt, translation.updatedAt)`** for a - * localized entry. A shared field moving changes what every language's page - * renders even though no translation row was touched, so taking the - * translation's timestamp alone would tell a crawler nothing had changed. - * - **`noIndex` is one clause**, not a post-filter, so a page of 1,000 entries is - * 1,000 listed URLs rather than however many survived. - */ export const readContentDeliverySitemapPage = async < TDefinition extends AnyContentTypeDefinition, >({ diff --git a/packages/vitnode/src/content/server/delivery-writes.test.ts b/packages/vitnode/src/content/server/delivery-writes.test.ts index 94ffbe0a3..b7babfdd7 100644 --- a/packages/vitnode/src/content/server/delivery-writes.test.ts +++ b/packages/vitnode/src/content/server/delivery-writes.test.ts @@ -12,17 +12,6 @@ import { ContentDeliverySlugReserved } from "../errors"; import { field } from "../fields"; import { applyContentDeliveryWrite } from "./delivery-writes"; -/** - * When slug history is written, and when it deliberately is not. - * - * The rule this file exists to pin down is the one in §10 of the Stage 8 brief: a - * slug becomes redirectable only if it was **previously used by an addressable - * public version**. That is what separates "a live URL moved and needs a redirect" - * from "somebody fixed a typo in a draft three times before publishing" - and - * getting it wrong means either a pile of redirects nobody asked for, or a moved - * page that 404s. - */ - const articleType = defineContentType({ id: "writes.article", editorial: { enabled: true }, @@ -63,19 +52,6 @@ interface Call { kind: "assertAvailable" | "ensureCurrent" | "reserve" | "retire"; } -/** - * A history model that records what it was asked to do. - * - * Two knobs, and they answer different questions: - * - * - **`retired`** is the oracle for "was that URL ever live" when the rows on file - * are not being modelled. The whole redirect decision hangs off it. - * - **`existing`** models them instead: the set of slugs already in the table. - * Pass it and `retire` answers from the recorder's own rows rather than from - * the knob, which is what lets a test watch the bootstrap turn an address that - * *could not* be retired into one that can. `[]` is the state a record - * published before Stage 8 existed is actually in. - */ const recorder = ({ existing = null, reserved = null, @@ -439,19 +415,6 @@ describe("a localized slug", () => { }); }); -/** - * A record that was published before this table existed. - * - * Stage 8 ships no backfill migration, on purpose: the database keeps one slug per - * row and no record of which historical values were ever public, so a global scan - * would have to choose between missing live URLs and inventing redirects for slugs - * that only ever existed on a draft. The mutation does not have to choose - it is - * holding the row on both sides of its own write - so the address is established - * lazily, at the moment it leaves service, on the evidence the mutation already has. - * - * `existing: []` is that record: publicly reachable, and with nothing on file. - * Every test here failed before the bootstrap existed. - */ describe("a record that predates slug history", () => { it("redirects its first slug change instead of losing the URL", async () => { const { calls, outcome } = await apply( diff --git a/packages/vitnode/src/content/server/delivery-writes.ts b/packages/vitnode/src/content/server/delivery-writes.ts index 666d1e086..328f23725 100644 --- a/packages/vitnode/src/content/server/delivery-writes.ts +++ b/packages/vitnode/src/content/server/delivery-writes.ts @@ -8,18 +8,6 @@ import type { ContentSlugHistoryModel } from "./slug-history-model"; import { contentDeliveryPath } from "../delivery"; import { createContentSlugHistoryModel } from "./slug-history-model"; -/** - * What one mutation did to a record's public URLs. - * - * Carried on the editorial outcome so the post-commit effects can emit the delivery - * events and pick the cache tags without re-reading anything: once the transaction - * returns, the *old* URL is gone from the row, and it is the one fact that cannot be - * recovered afterwards - the same reason `previousSlug` is already on the outcome. - * - * Optional on both outcome types rather than required, which is what keeps every - * Stage 1-7 construction site compiling untouched and every content type without - * delivery producing exactly the outcome it always produced. - */ export interface ContentDeliveryOutcome { /** The path the record answers to after this mutation. */ canonicalPath: null | string; @@ -29,22 +17,9 @@ export interface ContentDeliveryOutcome { /** The path it answered to before, when the mutation moved it. */ previousPath: null | string; previousSlug: null | string; - /** - * Whether a historical URL became a redirect. - * - * `true` only when the old slug had genuinely been publicly addressable, which is - * the difference between "somebody fixed a typo in a draft" and "a live URL - * moved". It is what the `delivery_redirect_created` event is gated on. - */ + redirectCreated: boolean; - /** - * What this mutation did to the sitemap. - * - * Two booleans rather than one, because a sitemap entry carries a `<lastmod>` - * derived from `updatedAt`: a plain title edit on a published record changes the - * file's bytes without changing which URLs it lists. See - * {@link ContentSitemapChange}. - */ + sitemap: ContentSitemapChange; /** The slug the record answers to now, or `null` once it is deleted. */ slug: null | string; @@ -52,13 +27,6 @@ export interface ContentDeliveryOutcome { slugChanged: boolean; } -/** - * One record's addressability, before and after a mutation. - * - * Supplied by the caller rather than derived here, because only the caller knows: - * it holds the row on both sides of its own guarded write, and re-reading would - * both cost a query and race with a concurrent writer. - */ export interface ContentDeliveryTransition { /** Whether the record is publicly reachable *after* the mutation. */ isPublic: boolean; @@ -73,27 +41,6 @@ export interface ContentDeliveryTransition { wasPublic: boolean; } -/** - * The delivery half of one slug-bearing mutation, inside its transaction. - * - * The order below is the whole correctness argument, and it is why this is one - * function rather than three calls sprinkled through the editorial services: - * - * 1. **Retire the old address first.** It has to stop being the record's current - * slug before the new one can be reserved, or a move from `a` to `b` and back to - * `a` would hit its own live reservation. - * 2. **Reserve the new one second**, and only when the record is publicly - * reachable. A draft has no public URL, so reserving its slug would hand out a - * permanent claim on a URL that was never live - and then refuse it to somebody - * who wants it. - * 3. **Report, never act.** Nothing here emits an event, writes a cache tag or - * calls the search index. The caller is inside a transaction that may still roll - * back, and a rollback cannot un-send any of those. - * - * `retire` returning `{ retired: false }` is not a failure: it is the answer to - * "was that slug ever a live URL", and a `false` is what keeps a corrected draft - * from creating a redirect nobody asked for. - */ export const applyContentDeliveryWrite = async ({ definition, slugHistory, @@ -128,13 +75,6 @@ export const applyContentDeliveryWrite = async ({ let redirectCreated = false; if (slugHistory !== null) { - /** - * Whether this mutation takes the previous public address out of service. - * - * Three ways it can: the slug moved, the record was deleted (`slug === null`), - * or it stopped being publicly reachable. All three end with a URL that used to - * answer and now does not, which is precisely when history has to hold it. - */ const leavingService = slugChanged || slug === null || !isPublic; // The lazy bootstrap, and the reason Stage 8 ships no backfill migration. diff --git a/packages/vitnode/src/content/server/diagnostics.ts b/packages/vitnode/src/content/server/diagnostics.ts index 91a3fbcc2..c6d90f708 100644 --- a/packages/vitnode/src/content/server/diagnostics.ts +++ b/packages/vitnode/src/content/server/diagnostics.ts @@ -16,60 +16,16 @@ import { publishedCondition, } from "./publication"; -/** - * Operational diagnostics for the Content Engine. - * - * Deliberately small, and deliberately **not** a monitoring product. It answers - * three questions an operator actually asks at three in the morning, and - * nothing else: - * - * 1. *Is the search index telling the truth?* - the database is the source of - * truth, so "how many documents should there be" is a `COUNT` over published - * rows, and "how many are there" is a `COUNT` over `core_search_index`. A - * difference is drift, and drift is repaired by a rebuild. - * 2. *Did anything scheduled fail to announce itself?* - a scheduled transition - * that committed but whose event, index write or cache expiry did not is - * recorded on the schedule row as `effectsError`. One count per content type - * turns "somewhere in the install" into "this content type". - * 3. *Which content types are even in play?* - what is registered, and which of - * the optional subsystems each one has switched on. - * - * No Prometheus, no time series, no dashboards - the install has none of those - * and Stage 7 does not add them. Everything here is a handful of aggregate - * queries, computed on demand, behind an admin permission. - */ - /** One locale's share of a content type's index, from every storage that has one. */ export interface ContentSearchDriftLocale { - /** - * `true` when the canonical table matches the database. - * - * A count is not a checksum: two documents can be stale and still count as - * two. It is the cheap check that catches the failure that actually happens - - * a live sync that threw, or a rebuild that stopped halfway - and it costs two - * aggregates rather than a full comparison. - */ canonicalHealthy: boolean; /** Documents the canonical `core_search_index` holds for this locale. */ canonicalIndexed: number; /** Published rows - or published translations - the database holds. */ expected: number; - /** - * `""` for a content type that is not localized. - * - * The empty string is what `core_search_index` stores for language-agnostic - * content, so it is the honest key here rather than `null` - it is the value - * the row really holds. - */ + locale: string; - /** - * `true` when the active provider matches, `false` when it does not, and - * `null` when nobody looked. - * - * The three-way answer is the point. A provider that offers no diagnostics - * cannot be called healthy, and calling it healthy anyway is how an - * Elasticsearch outage hides behind a perfectly good canonical table. - */ + providerHealthy: boolean | null; /** Documents the active provider holds, or `null` when it cannot say. */ providerIndexed: null | number; @@ -81,26 +37,10 @@ export interface ContentSearchDriftProvider { error?: string; /** `null` when unverified - see {@link ContentSearchDriftLocale.providerHealthy}. */ healthy: boolean | null; - /** - * Every document the provider holds for this content type, in **any** locale. - * - * The guard against ghosts. Per-locale counts can only ask about locales the - * database still knows about, so a document left behind in a locale that has - * since been removed - or one whose canonical row was deleted while the - * provider's delete failed - is invisible to them. A total is not: it is - * larger than `expectedTotal`, and that is enough to say something is wrong. - * - * `null` when the provider offers no diagnostics. - */ + indexedTotal: null | number; name: string; - /** - * Whether the provider was actually asked. - * - * `false` means it offers no `count`, so nothing about its contents is known. - * `true` with `healthy: false` means it was asked and it disagreed - or it - * threw, and `error` says so. - */ + verified: boolean; } @@ -112,41 +52,12 @@ export interface ContentSearchDrift { contentTypeId: string; /** Published rows - or published translations - the database holds, all locales. */ expectedTotal: number; - /** - * `true` only when the canonical table **and** the active provider both agree, - * per locale and in total. - * - * An unverified provider is not healthy: absence of evidence is reported as - * absence of evidence, and the operator decides what to do about it. - */ + healthy: boolean; locales: ContentSearchDriftLocale[]; provider: ContentSearchDriftProvider; } -/** - * Compares what the database says should be indexed against what is - in the - * canonical table **and** in the active search provider. - * - * The two are not the same question, and conflating them was the first gap this - * closes. `SearchModel.index` writes `core_search_index` and then hands the - * document to the provider; an Elasticsearch that refuses the second half leaves - * a canonical table that is perfectly correct and a search box that is missing - * results. - * - * The second gap is the opposite direction, and it needs a different instrument. - * Deletion runs canonical-first: `SearchModel.delete` removes the row and then - * asks the provider. If the provider's half fails, the document survives in a - * locale that no longer appears in either the database or the canonical table - - * so per-locale enumeration, which is built from those two, can never ask about - * it. Hence `indexedTotal`: one unfiltered count that no amount of missing - * enumeration can hide from. - * - * Costs: two aggregates for the canonical side, whatever the collection's size, - * plus one provider total and one provider count per locale. The bundled - * Postgres provider is skipped entirely - its store *is* the canonical table, so - * asking twice would buy nothing. - */ export const contentSearchDrift = async ( c: Context, { model }: Pick<RegisteredContentModel, "model">, @@ -242,26 +153,6 @@ export const contentSearchDrift = async ( }; }; -/** - * Asks the active provider how many documents it holds - in total, and per - * locale. - * - * The total goes first and is asked **unconditionally**, including for a content - * type with no rows and no canonical documents at all. That is the case the - * per-locale loop cannot cover: with nothing to enumerate, `[].every(...)` is - * `true`, and a ghost document would sail through as healthy. - * - * Three outcomes: - * - * - **canonical storage** - the bundled Postgres provider. Verified, and the - * canonical counts are its counts; no query is issued. - * - **no `count`** - a provider that offers no diagnostics. Unverified, which is - * reported as such rather than turned into `healthy: true`. - * - **it threw** - Elasticsearch is down. Verified and unhealthy, with the - * reason attached, and the status route still answers: a diagnostic that - * crashes when the thing it diagnoses is broken is a diagnostic nobody can - * use. A failure in either call is handled the same way. - */ const providerCounts = async ( c: Context, { diff --git a/packages/vitnode/src/content/server/editorial-effects.test.ts b/packages/vitnode/src/content/server/editorial-effects.test.ts index 797d5d51f..388919328 100644 --- a/packages/vitnode/src/content/server/editorial-effects.test.ts +++ b/packages/vitnode/src/content/server/editorial-effects.test.ts @@ -227,11 +227,6 @@ describe("contentEditorialEffects", () => { }); }); - /** - * A dead listener on an *interactive* mutation has nowhere else to be - * recorded: the scheduled path writes it onto the schedule row and retries, - * and a clicked publish does neither. Without a log line it is invisible. - */ describe("reporting a delivery failure", () => { it("logs the failed listener with the record it belongs to", async () => { const { c, log } = harness({ emit: withFailure() }); diff --git a/packages/vitnode/src/content/server/editorial-effects.ts b/packages/vitnode/src/content/server/editorial-effects.ts index 4a7241721..11e27e9a3 100644 --- a/packages/vitnode/src/content/server/editorial-effects.ts +++ b/packages/vitnode/src/content/server/editorial-effects.ts @@ -73,52 +73,18 @@ const payloadFor = ( }; export interface ContentEditorialEffectsOptions { - /** - * The model, for a **localized** content type with `search`. - * - * Needed because such a record is indexed once per published translation, and - * enumerating them takes a table this function is not otherwise given. Optional - * so every existing caller compiles unchanged; a localized searchable content - * type whose caller omits it has its index write skipped and says so in the log, - * rather than silently indexing one language. - */ model?: AnyContentModel; /** The plugin that owns the content type, and therefore the event. */ pluginId: string; - /** - * The person who created the schedule that caused this, when one did. - * - * `undefined` for an interactive mutation, so the payload is unchanged - * there - the key is absent rather than null, and nothing existing sees a - * new field. - */ + scheduledBy?: null | number; - /** - * The booking that caused this, when one did. Also `undefined` interactively. - * - * This is the identifier a listener uses to make itself idempotent: delivery - * is at-least-once, so the same `published` can arrive twice, but never with - * two different `scheduleId`s for the same booking. - */ + scheduleId?: number; } export interface ContentEditorialEffectsResult { - /** - * The delivery events this mutation emitted, or `undefined` for a content type - * without `delivery` - which is what keeps every existing caller's result shape - * unchanged. - */ delivery?: ContentDeliveryEffectsResult; - /** - * What the event transport reported. `null` for a no-op outcome, which emits - * nothing at all. - * - * Present rather than discarded because `EventsModel.emit` does not throw: - * `failures` is the only place a dead listener or a broker outage is visible, - * and a caller that ignores it has decided - explicitly or not - that the - * event is allowed to go missing. - */ + event: EventEmitResult | null; search: ContentSearchSyncOutcome | null; /** @@ -128,27 +94,6 @@ export interface ContentEditorialEffectsResult { searchByLocale?: ContentSearchSyncOutcome[]; } -/** - * Everything one editorial mutation owes the rest of the system, once its - * transaction has committed. - * - * One function rather than the same four-line block in every route and in the - * queue handler: "which event, and which search operation" is a rule, and a - * rule copied into three places is a rule that will disagree with itself. The - * generated routes call it, and so does the scheduled-publication task. - * - * **Call it only after the write has returned - never inside the transaction.** - * Same rule `syncContentSearch` states for itself, and for the same reason: a - * rollback cannot un-emit an event or un-index a document. - * - * A no-op outcome does nothing at all. That is what keeps a double-clicked - * publish button, a retried queue task and an empty edit from each producing a - * second event and a second index write. - * - * Cache invalidation is deliberately **not** here. It needs the Next runtime, - * which neither the API process nor the queue worker has; the Server Action - * owns it, and the scheduled path goes through the revalidation bridge. - */ export const contentEditorialEffects = async ( c: Context, definition: AnyContentTypeDefinition, @@ -248,15 +193,6 @@ const idOf = (row: object): number => { return typeof id === "number" ? id : 0; }; -/** - * Says why nothing was indexed, rather than indexing the wrong thing. - * - * Reachable only from a hand-written caller: every generated path passes the - * model. Logging beats throwing here because the write has already committed - - * failing now would report a successful mutation as a failure - and it beats - * silence because the symptom otherwise is a search index that is quietly missing - * one content type. - */ const warnMissingModel = async ( c: Context, definition: AnyContentTypeDefinition, diff --git a/packages/vitnode/src/content/server/editorial-service.test.ts b/packages/vitnode/src/content/server/editorial-service.test.ts index 78a66b761..d5a1cc049 100644 --- a/packages/vitnode/src/content/server/editorial-service.test.ts +++ b/packages/vitnode/src/content/server/editorial-service.test.ts @@ -32,14 +32,6 @@ interface RecordedCall { op: string; } -/** - * The chainable Drizzle stand-in from `service.test.ts`, plus `transaction`. - * - * The transaction callback receives the same handle, so a test can assert that - * the content write and the revision insert landed in the same unit of work by - * counting them - and `failAt` makes the revision insert throw so the rollback - * path is exercised rather than assumed. - */ const createDbMock = ( results: unknown[][], { failAt }: { failAt?: number } = {}, @@ -111,13 +103,6 @@ const createDbMock = ( const opsOf = (calls: RecordedCall[], op: string) => calls.filter(call => call.op === op).map(call => call.arg); -/** - * Which columns a Drizzle condition actually names. - * - * `JSON.stringify` cannot be used - a `PgColumn` holds a reference back to its - * table - so the nested `queryChunks` are walked instead, collecting anything - * that carries a column `name`. - */ const columnsIn = (condition: unknown): string[] => { const walk = (value: unknown): unknown[] => value !== null && typeof value === "object" && "queryChunks" in value @@ -129,12 +114,6 @@ const columnsIn = (condition: unknown): string[] => { .filter((name): name is string => typeof name === "string"); }; -/** - * `editorialService` is `undefined` for a content type without the workflow, so - * every call site would otherwise need a non-null assertion. Throwing here - * keeps the tests readable and fails loudly if a fixture ever loses its - * `editorial` block. - */ const editorialServiceOf = <TDefinition extends AnyContentTypeDefinition>( model: ContentModel<TDefinition>, c: Context, diff --git a/packages/vitnode/src/content/server/editorial-service.ts b/packages/vitnode/src/content/server/editorial-service.ts index 7d78fb487..4c57c595f 100644 --- a/packages/vitnode/src/content/server/editorial-service.ts +++ b/packages/vitnode/src/content/server/editorial-service.ts @@ -69,35 +69,14 @@ import { createContentRevisionsModel } from "./revisions-model"; import { createContentSchedulesModel } from "./schedules-model"; import { createSlugNormalizer } from "./slugs"; -/** - * Everything the post-commit effects need, and nothing they have to re-read. - * - * `previousSlug` is the one field that cannot be recovered after the fact: once - * the write returns, the old URL is gone, and invalidating the wrong cache tag - * leaves a moved page resolving at its old address. - */ export interface ContentEditorialOutcome<TDefinition> { /** `false` when nothing moved: no write, no revision, no event, no tags. */ changed: boolean; /** Canonical paths - see {@link ContentUpdateResult.changedFields}. */ changedFields: ContentChangedPath<TDefinition>[]; - /** - * What this mutation did to the record's public URLs, or absent. - * - * Absent for every content type without `delivery` - which is every Stage 1-7 - * one - so the outcome those produce is byte-identical to what it always was. - */ + delivery?: ContentDeliveryOutcome; - /** - * How many of this record's languages this transition moved, or absent. - * - * Only a localized `publish`/`unpublish` sets it, and it is deliberately - * separate from `changed`, which describes the base row. The two disagree in - * the case worth naming: a record whose base row was already published but - * whose languages were not moves no base column and still puts pages on the - * internet, so an effects layer that read `changed` alone would skip the - * search and sitemap work that publish just created. - */ + movedTranslations?: number; operation: ContentRevisionOperation; /** The slug the record answered to *before* this mutation, if it has one. */ @@ -132,14 +111,7 @@ export interface ContentEditorialService<TDefinition> { values: ContentCreateInput<TDefinition>, options: ContentEditorialOptions, ) => Promise<ContentEditorialOutcome<TDefinition>>; - /** - * Removes a record, and refuses if it moved since the caller read it. - * - * `expectedVersion` is required for the same reason `update` requires it: a - * delete is the widest possible overwrite. Somebody looking at v4 in a stale - * table must not be able to remove the v5 a colleague just wrote, and "are - * you sure?" cannot ask about a change the person has not seen. - */ + delete: ( id: number, options: ContentEditorialWriteOptions, @@ -148,18 +120,7 @@ export interface ContentEditorialService<TDefinition> { id: number, options: ContentEditorialPublicationOptions, ) => Promise<ContentEditorialOutcome<TDefinition> | null>; - /** - * Typed to-many relation operations, keyed by the content type's actual - * relation collection names. - * - * The **editorial** ones: each takes an `actor` and an `expectedVersion`, - * bumps the version exactly once per real mutation, writes exactly one - * revision, and answers a stale expectation with a structured - * `ContentVersionConflict`. The plain `service.relations` does none of that - - * it has no version column to guard and no history to write - so the two are - * deliberately separate objects rather than one with different behaviour - * depending on where it came from. - */ + relations: Record< ContentRelationCollectionName<TDefinition>, ContentEditorialRelationMethods<TDefinition> @@ -177,13 +138,7 @@ export interface ContentEditorialService<TDefinition> { ) => Promise<ContentEditorialOutcome<TDefinition> | null>; /** Revision reads. Writes go through the mutations above. */ revisions: ContentRevisionsModel; - /** - * Scheduled transitions, or `undefined` without `editorial.scheduling`. - * - * `undefined` rather than a throwing stub, matching `publicService` and - * `editorialService` themselves - the check reads naturally in code that does - * not know which content type it was handed. - */ + schedules: ContentSchedulesModel | undefined; unpublish: ( id: number, @@ -196,14 +151,6 @@ export interface ContentEditorialService<TDefinition> { ) => Promise<ContentEditorialOutcome<TDefinition> | null>; } -/** - * The editorial to-many relation operations. - * - * Every mutating one requires the same `actor` and `expectedVersion` an editorial - * `update` does, for the same reason: a collection mutation is an edit of the - * source record, and an edit that could not lose a race would be the only one in - * the engine that cannot. - */ export interface ContentEditorialRelationMethods<TDefinition> { add: ( itemId: number, @@ -267,19 +214,6 @@ export interface ContentEditorialRepeatableMethods<TDefinition, TName> { ) => Promise<ContentEditorialOutcome<TDefinition> | null>; } -/** - * The transactional half of the Content Engine. - * - * Everything here holds one rule: **the content write, the version increment - * and the revision insert are one transaction, and nothing else is in it.** No - * event, no search call, no cache API, no HTTP - those all run after the commit, - * because a rolled-back transaction cannot un-send them. - * - * A caller that already owns a transaction passes `tx` and this joins it. A - * caller that does not gets one opened here, which is what makes - * `service.update(...)` atomic by default rather than only when someone - * remembered. - */ export const createContentEditorialService = < TDefinition extends AnyContentTypeDefinition, >({ @@ -295,23 +229,7 @@ export const createContentEditorialService = < /** The collection store, or nothing for a content type that declares none. */ advanced?: ContentAdvancedStore; c: Context; - /** - * Moves every language of one record with the record, for a localized content - * type. Supplied by the model, which is the one place that can build the - * translation editorial layer without a circular construction. - * - * A callback rather than the translation table itself, because a language going - * live is not just two columns: it takes a revision and a delivery reservation, - * and the translation editorial service is what owns both. It is handed this - * transaction, so the record and its languages move together or not at all. - * - * Returns how many languages it actually moved, which the outcome reports as - * `movedTranslations`: an idempotent base transition can still move languages, - * and the effects layer has to know that. - * - * Absent for a content type without localization, which has no languages to - * move. - */ + cascadeTranslations?: | ((options: { actor: ContentActor; @@ -784,19 +702,6 @@ export const createContentEditorialService = < ); }; - /** - * Locks the source record, reads one collection and applies what `compute` - * makes of it - all inside the transaction that will write it. - * - * Two guarantees at once, and the order is what produces both: - * - * 1. `SELECT ... FOR UPDATE` before the read, so the next state is computed - * from the committed collection rather than from one a concurrent writer has - * since replaced; - * 2. the ordinary `update` afterwards, so the caller's `expectedVersion` still - * decides the winner. A loser waits for the lock, then finds the version has - * moved, and is told so - it never merges silently and never overwrites. - */ const runCollection = async ( itemId: number, field: string, diff --git a/packages/vitnode/src/content/server/effects-log.ts b/packages/vitnode/src/content/server/effects-log.ts index 993b9a626..8d19b24d3 100644 --- a/packages/vitnode/src/content/server/effects-log.ts +++ b/packages/vitnode/src/content/server/effects-log.ts @@ -2,41 +2,8 @@ import type { Context } from "hono"; import type { EventEmitResult } from "../../api/models/events"; -/** - * The prefix every Content Engine post-commit failure is logged behind. - * - * Greppable on purpose, and distinct from `[content-search]`, which - * `syncContentSearch` already owns: an operator looking for "why did nobody - * hear about this publish" is asking a different question from "why is this - * article missing from search", and one prefix for both would make neither - * answerable. - */ export const CONTENT_EFFECTS_LOG_PREFIX = "[content-effects]"; -/** - * Reports listeners that did not receive an event whose mutation **has already - * committed**. - * - * `EventsModel.emit` reports rather than throws, so `failures` is the only place - * a dead listener or a broker outage is visible at all. Two things follow from - * the write having committed, and they are the whole contract: - * - * 1. **The request still succeeds.** The row is in the database; answering 500 - * would tell the client its edit was lost when it was not, and it would - * invite a retry that creates a second version of everything. - * 2. **The failure is never swallowed.** It goes to `core_logs` behind - * {@link CONTENT_EFFECTS_LOG_PREFIX} with the content type, the item and the - * listener that failed, so the AdminCP log viewer can find it and an operator - * can replay whatever the listener was meant to do. - * - * Delivery is **at-least-once** where a retry is involved (the scheduled effects - * task) and best-effort otherwise (an interactive route). There is no outbox and - * no exactly-once guarantee; a listener that must act once keys off the - * identifiers in the payload. - * - * A result with no failures logs nothing - an expected success is not an error, - * and a log full of them is a log nobody reads. - */ export const reportContentEventFailures = async ( c: Context, { diff --git a/packages/vitnode/src/content/server/emit.ts b/packages/vitnode/src/content/server/emit.ts index e2b5b3c4c..5d6d67145 100644 --- a/packages/vitnode/src/content/server/emit.ts +++ b/packages/vitnode/src/content/server/emit.ts @@ -36,16 +36,6 @@ type ContentPayload = | ContentUnpublishedPayload | ContentUpdatedPayload<AnyContentTypeDefinition>; -/** - * `EventsModel.emit` as this module needs to see it. - * - * Narrowing the model rather than casting the payload is deliberate: the shape - * of `VitNodeEvents` here depends on whether a `declare module` block happens to - * be in the current TypeScript program, and core is compiled both ways - with - * the type tests (lint, `test:types`) and without them (`build:plugins`). A - * payload cast is "unnecessary" in one program and required in the other, so the - * autofixer and the build take turns breaking each other. This does not move. - */ interface ContentEventEmitter { emit: ( name: VitNodeEventName, @@ -54,38 +44,12 @@ interface ContentEventEmitter { ) => Promise<EventEmitResult>; } -/** - * Emits a content event after a successful write. - * - * This is the single place where a runtime event name is reconciled with the - * global event map, and it has to be: `VitNodeEvents` only gains - * `content.<id>.created` and friends from the *plugin's* `declare module` block, - * so those keys do not exist while core compiles itself - and a generated route - * only ever holds an `AnyContentTypeDefinition`, whose `id` is a plain `string`. - * `ContentEventsFor` is what makes the name/payload pairing sound at the - * plugin's augmentation site; see `events.test-d.ts`. - * - * Call it only once the database write has returned - never inside a - * transaction callback. - * - * The result is **returned, not swallowed**. `EventsModel.emit` never throws, so - * a listener that fell over is reported rather than raised - and a caller that - * only awaits this call has silently accepted whatever happened. Interactive - * routes are right to: the mutation committed and the person is owed a 200 - * either way. The scheduled-effects task is not, and it reads `failures`. - */ export const emitContentEvent = async ( c: Context, definition: AnyContentTypeDefinition, action: ContentEventAction, payload: ContentPayload, options?: { - /** - * The plugin that owns the content type - which is not always the plugin - * handling the request. A scheduled transition runs inside core's queue - * handler, and `content.example.article.published` belongs to the example - * plugin however it was triggered. - */ pluginId?: string; }, ): Promise<EventEmitResult> => { diff --git a/packages/vitnode/src/content/server/error-contracts.test.ts b/packages/vitnode/src/content/server/error-contracts.test.ts index 1105a74cf..2dab37e58 100644 --- a/packages/vitnode/src/content/server/error-contracts.test.ts +++ b/packages/vitnode/src/content/server/error-contracts.test.ts @@ -19,20 +19,6 @@ import { import { withHttpErrors } from "./http-errors"; import { withTranslationHttpErrors } from "./translation-http-errors"; -/** - * What a client is allowed to learn when a write fails. - * - * Two rules, and the second is the one that needs a test rather than a comment: - * - * 1. **Every expected failure has a stable contract** - a status, and for the - * ones a client has to branch on, a `code`. A caller cannot be asked to parse - * English, and it certainly cannot be asked to parse a SQLSTATE. - * 2. **Nothing internal crosses the boundary.** A driver error carries the - * constraint name, often the column, and sometimes the value that clashed. - * None of that may reach a response body - it is a schema description handed - * to whoever asked, and on a public route it is handed to anyone. - */ - const CONTENT_TYPE_ID = "test.article"; /** The whole response, as a client would see it. */ @@ -134,11 +120,6 @@ describe("expected database failures map onto stable contracts", () => { } }); - /** - * Postgres 18 reports an explicit `ON DELETE RESTRICT` as `23001` - * (restrict_violation) where earlier majors reported `23503`. Both have to map - * to the same 409, or upgrading the database would change an API contract. - */ it("answers the same way on both Postgres codes for a blocked delete", async () => { const statuses = await Promise.all( ["23001", "23503"].map(async code => { @@ -211,15 +192,6 @@ describe("domain failures map onto their documented codes", () => { }); }); - /** - * A reserved historical address, which a `23505` could not have explained. - * - * Two constraints can refuse the same write - the live slug index and the - * history reservation - and the driver's code is identical for both. So the - * reservation is checked in the transaction and raised as a domain error, and - * this is the arm it lands on: a 409 that names the slug and the locale rather - * than a SQLSTATE the client would have to guess at. - */ it("answers a reserved address with its own 409 code", async () => { const result = await responseOf( throwing( @@ -483,11 +455,6 @@ describe("translation failures keep their own union", () => { }); }); -/** - * The regression that matters most, because its symptom is invisible: a response - * body that happens to contain the constraint name reads fine to a human and - * hands an attacker the schema. - */ describe("no internal detail crosses the boundary", () => { const LEAKS = [ "example_articles_code_key", diff --git a/packages/vitnode/src/content/server/file-collection.test.ts b/packages/vitnode/src/content/server/file-collection.test.ts index 766222d93..f120b541b 100644 --- a/packages/vitnode/src/content/server/file-collection.test.ts +++ b/packages/vitnode/src/content/server/file-collection.test.ts @@ -79,13 +79,6 @@ const rejectionOf = async ( throw new Error("Expected the reference check to refuse this payload."); }; -/** - * The runtime half of `field.file({ multiple: true })`. - * - * Every case here is about **arity**: the same four rules the single-file suite - * covers, applied once per entry, plus the two places where "not loaded" and - * "empty" have to stay different answers. - */ describe("contentFileFields", () => { it("finds both arities, because both hold files", () => { // A single file is a column and a gallery is a junction table, so the two sit diff --git a/packages/vitnode/src/content/server/file-reference-http-errors.test.ts b/packages/vitnode/src/content/server/file-reference-http-errors.test.ts index acdc5f788..c360fe401 100644 --- a/packages/vitnode/src/content/server/file-reference-http-errors.test.ts +++ b/packages/vitnode/src/content/server/file-reference-http-errors.test.ts @@ -23,13 +23,6 @@ vi.mock("../../api/lib/check-staff-permission", () => ({ checkStaffPermission: async () => await Promise.resolve(true), })); -/** - * The composite half of the contract needs a content type that is **both** - * localized and holds a file, which no shared fixture is - a file field is - * always shared, so this is the one combination that exercises a save writing - * the base row and its translations in one transaction while carrying a file - * identifier. - */ const localizedFileContentType = defineContentType({ id: "test.localized-file", tableName: "test_localized_files", @@ -68,13 +61,6 @@ const adminUser = { roleId: 1, }; -/** - * The rejection the reference check raises, as the service raises it. - * - * Constructed rather than provoked through a stubbed `core_files` read: what is - * under test is the boundary between the error and the response, and the four - * codes are already validated against real descriptors in `files.test.ts`. - */ const rejection = ( code: (typeof CONTENT_FILE_CODES)[keyof typeof CONTENT_FILE_CODES], field_: string, @@ -87,14 +73,6 @@ const rejection = ( message, }); -/** - * The real `app.onError`, copied from `VitNodeAPI`. - * - * The point of a structured 400 is that `HTTPException.getResponse()` survives - * the trip out, so the handler that has to return it verbatim is part of what is - * being tested - asserting on `getResponse()` alone would pass even if the app - * flattened the body on the way out. - */ const withErrorHandler = (app: OpenAPIHono): OpenAPIHono => { app.onError((error, c) => { if (error instanceof HTTPException) return error.getResponse(); @@ -230,15 +208,6 @@ beforeEach(() => { vi.restoreAllMocks(); }); -/** - * A refused file identifier has to reach the client as `{ code, field, message }`. - * - * `ContentFileReferenceError` extends `ContentInputError`, and the generic branch - * of the mapper answers a `ContentInputError` with `message` alone - so before - * this, every one of the four reasons arrived as prose with no code and, worse, - * no field. A save carries every field at once: without `field` a form knows a - * file was refused and cannot say which input to put the message under. - */ describe("a refused file identifier on a content write", () => { const cases = [ [ @@ -328,11 +297,6 @@ describe("a refused file identifier on a content write", () => { }); }); - /** - * `Error.message` carries `[Content Engine] <contentTypeId>: ` for the log's - * benefit, and an editor must never be shown it. The body reads `detail`, so - * this is the assertion that keeps it reading `detail`. - */ it("keeps the internal prefix and the content type id out of the body", async () => { const { app, service } = harness(); service.create.mockRejectedValue( @@ -349,11 +313,6 @@ describe("a refused file identifier on a content write", () => { expect(response.body).toMatchObject({ message: "This file is too big." }); }); - /** - * The guard on the branch above it: only a file rejection gains a body. Every - * other `ContentInputError` keeps the plain-text 400 it has always answered, - * so no existing client starts reading JSON where there is none. - */ it("leaves every other input error as plain text", async () => { const { app, service } = harness(); service.create.mockRejectedValue( @@ -371,14 +330,6 @@ describe("a refused file identifier on a content write", () => { }); describe("the OpenAPI contract for that 400", () => { - /** - * The declared 400 body of one generated route. - * - * The model is taken as `never` because `buildContentRoutes` is invariant in - * its definition: a concrete `ContentModel` is not assignable to the - * `AnyContentTypeDefinition` its parameter names, and every call site here has - * a concrete one. - */ const body400 = (model: never, method: string, path: string) => { const entry = buildContentRoutes(model, { pluginId: PLUGIN_ID }).find( item => item.route.method === method && item.route.path === path, @@ -404,11 +355,6 @@ describe("the OpenAPI contract for that 400", () => { ).toBeDefined(); }); - /** - * A content type with no file field cannot produce this body, so it must not - * advertise one - the same rule `uniqueConflict` follows for a non-editorial - * content type. - */ it("says nothing about it for a content type with no file field", async () => { const { testPostContentType } = await import("@/tests/content-fixtures"); const plain = createContentModel(testPostContentType, { diff --git a/packages/vitnode/src/content/server/file-revisions.test.ts b/packages/vitnode/src/content/server/file-revisions.test.ts index 038b25817..9859a447b 100644 --- a/packages/vitnode/src/content/server/file-revisions.test.ts +++ b/packages/vitnode/src/content/server/file-revisions.test.ts @@ -23,14 +23,6 @@ import { createContentRevisionsModel } from "./revisions-model"; const PLUGIN_ID = "@vitnode/example"; -/** - * A transaction stand-in that records what was inserted into which table and - * what was deleted. - * - * The pin insert and the retention prune happen inside `capture`, in one - * transaction, so a stub at this level is what proves the ordering: the pins - * exist before anything is pruned, and there is no unpinning step at all. - */ const makeTx = () => { const inserts: { table: string; values: unknown }[] = []; const deletes: string[] = []; @@ -97,11 +89,6 @@ describe("core_content_file_refs", () => { }; }); - /** - * The pin's whole job. `RESTRICT` towards the file is what refuses the - * deletion; `CASCADE` from the revision is what releases it again when - * retention prunes the revision - with no code in between. - */ it("refuses a file deletion and releases it when the revision goes", () => { expect(foreignKeys).toEqual( expect.arrayContaining([ @@ -172,11 +159,6 @@ describe("revision capture", () => { expect(pins(await capture(undefined))).toHaveLength(0); }); - /** - * Ordering, stated as a test: the pins go in before the retention prune, so - * there is no window in which the new revision exists unpinned - and the prune - * is what releases the *old* pins, through the cascade. - */ it("pins before it prunes", async () => { const harness = await capture( [1], @@ -197,16 +179,6 @@ describe("revision capture", () => { }); describe("the pinning lifecycle", () => { - /** - * The scenario the mechanism exists for, spelled out against the two facts - * that implement it: - * - * article -> file A, revision v1 pins A - * article -> file B, revision v2 pins B; the column no longer guards A - * deleting A is refused <- the v1 pin, ON DELETE RESTRICT - * v1 is pruned by retention <- the pin cascades away - * deleting A now succeeds <- nothing references it - */ it("keeps the previous file pinned after the field moves on", async () => { const first = await capture([1], 1); const second = await capture([2], 2); @@ -221,11 +193,6 @@ describe("the pinning lifecycle", () => { }); describe("the ids a snapshot yields", () => { - /** - * The composition the editorial service relies on: it builds the snapshot, - * reads the file ids straight back out of it, and hands both to `capture`. So - * the pins can only ever name files the snapshot actually recorded. - */ it("comes from the snapshot itself, not from the request payload", () => { const row = { animation: 2, diff --git a/packages/vitnode/src/content/server/file-upload-route.test.ts b/packages/vitnode/src/content/server/file-upload-route.test.ts index fa7ef49ed..2c9afe266 100644 --- a/packages/vitnode/src/content/server/file-upload-route.test.ts +++ b/packages/vitnode/src/content/server/file-upload-route.test.ts @@ -45,14 +45,6 @@ const plain = createContentModel(testPostContentType, { const PLUGIN_ID = "@vitnode/example"; -/** - * The generated routes with the storage model stubbed. - * - * `upload` echoes what a real one would return - including the *stored* name, - * which is the interesting knob: `storedAs` lets a case pretend the image - * pipeline re-encoded a PNG to WebP, which is exactly the mismatch the route has - * to catch before the identifier ever reaches a content row. - */ const harness = ({ storageThrows, storedAs, diff --git a/packages/vitnode/src/content/server/files.test.ts b/packages/vitnode/src/content/server/files.test.ts index 9eddd8132..3741acdd5 100644 --- a/packages/vitnode/src/content/server/files.test.ts +++ b/packages/vitnode/src/content/server/files.test.ts @@ -148,13 +148,6 @@ describe("resolveContentFileDescriptors", () => { }); }); -/** - * One field's descriptor, refusing a list. - * - * `files` carries every file field of the row, so its value type is the union of - * both arities. A single field can never hold a list, and reading one as if it - * might would hide exactly the bug this narrowing catches. - */ const one = ( value: ContentFileFieldValue | undefined, ): ContentFileDescriptor | null => @@ -214,11 +207,6 @@ describe("resolveContentPublicRowFiles", () => { expect(row.cover).toMatchObject({ id: 1, name: "cover-1.webp" }); }); - /** - * `animation` and `document` are declared but not in `publicApi.fields`, so - * they are never selected in the first place - and must not be resolved here - * either, which would make the allowlist advisory. - */ it("resolves only the fields the allowlist exposes", async () => { const { ctx, select } = makeCtx([fileRow(1)]); @@ -315,12 +303,6 @@ describe("assertContentFileReferences", () => { expect(error?.field).toBe("cover"); }); - /** - * The attack this exists for: uploading a PDF through the `document` field's - * route - where it is perfectly valid - and then assigning its id to - * `animation`, which accepts GIF only. A successful upload is not a valid - * assignment. - */ it("refuses an existing PDF assigned to a GIF-only field", async () => { const error = await rejection({ animation: 5 }, [ fileRow(5, { mimeType: "application/pdf", name: "spec.pdf" }), diff --git a/packages/vitnode/src/content/server/files.ts b/packages/vitnode/src/content/server/files.ts index 021b2645a..e331d95fd 100644 --- a/packages/vitnode/src/content/server/files.ts +++ b/packages/vitnode/src/content/server/files.ts @@ -19,20 +19,6 @@ import { ContentInputError } from "../errors"; import { contentFileConstraints, validateContentFile } from "../files"; import { partitionContentFields } from "../localization"; -/** - * The file fields of a content type, by name - single and `multiple: true` alike. - * - * Both halves of the partition, and that is the point: a single file is a column - * on the base row and so lands in `sharedFields`, while a gallery is a junction - * table and so lands in `collectionFields`. Every caller below asks "which fields - * of this content type hold files?", which is a question about the *field*, not - * about where its rows live. The localized half is never read: `localized: true` - * is refused on a file field at definition time. - * - * An empty object for every content type that declares none, which is what lets - * every caller below be a cheap early return rather than a conditional at the - * call site. - */ export const contentFileFields = ( definition: AnyContentTypeDefinition, ): Record<string, ContentFileField> => { @@ -51,14 +37,6 @@ export const contentFileFields = ( return files; }; -/** - * The `multiple: true` file fields, by name. - * - * Its own view because the two arities are read differently everywhere: a single - * field's value is a number on the row, and a collection's is an array the - * advanced store loads from a junction table - which an admin list deliberately - * does not load at all. - */ export const contentFileCollectionFields = ( definition: AnyContentTypeDefinition, ): Record<string, ContentFileField> => @@ -87,19 +65,6 @@ interface ContentFileRow { size: number; } -/** - * One `core_files` row, reduced to the shape every surface may see. - * - * The allowlist is the function: `key` is read to build the URL and then - * dropped, `metadata` is read for the pixel dimensions and then dropped, and - * `userId` and `pluginId` are never selected at all. So "forward the file row" - * is not something a caller can do by accident. - * - * `url` is `""` when the install has no storage adapter configured. Every row in - * `core_files` was uploaded through one, so this only happens after an adapter is - * removed - and an empty string is the honest answer, where `getUrl` would throw - * a 500 into the middle of an otherwise fine list response. - */ const toDescriptor = ( row: ContentFileRow, url: (key: string) => string, @@ -118,14 +83,6 @@ const toDescriptor = ( }; }; -/** - * Reads the descriptors for a set of `core_files` ids, in one statement. - * - * One query for a whole page, never one per row: a list of twenty articles with a - * cover image each is one `WHERE id IN (...)`. An id with no row is simply absent - * from the map, which every caller reads as "no file" - a deleted file cannot - * happen while a content row points at it, but a *snapshot* may name one. - */ export const resolveContentFileDescriptors = async ( c: Context, ids: readonly number[], @@ -154,15 +111,6 @@ const asFileId = (value: unknown): null | number => ? value : null; -/** - * The file ids one field's value names, whatever its arity. - * - * A single field is one id or none; a collection is however many its array - * holds, in order, with anything that is not an identifier dropped. `undefined` - * - the collection was not loaded - and `null` both come back empty, which is - * what lets the callers below treat "no files" and "not asked for" the same way - * when all they need is a list of ids to resolve. - */ const fileIdsOfValue = (value: unknown): number[] => { if (Array.isArray(value)) { return value.map(asFileId).filter((id): id is number => id !== null); @@ -179,14 +127,6 @@ const fileIdsOf = ( values: Record<string, unknown>, ): number[] => names.flatMap(name => fileIdsOfValue(values[name])); -/** - * One collection value's descriptors, in stored order, skipping what is gone. - * - * A hole is dropped rather than emitted as `null`, because a gallery with gaps in - * it is not something any surface can render - and a file a record still points - * at cannot be deleted, so the only way to get one is a snapshot naming a file - * that outlived its last pin. - */ const descriptorsOf = ( byId: Map<number, ContentFileDescriptor>, value: unknown, @@ -195,24 +135,6 @@ const descriptorsOf = ( .map(id => byId.get(id)) .filter((file): file is ContentFileDescriptor => file !== undefined); -/** - * Attaches each row's resolved file descriptors under `files`. - * - * A **sibling** of the row rather than a replacement of the column, which is the - * opposite of what the public projection does - and deliberately: an admin row is - * what the edit form opens on, and the form submits `coverImage: 42` back. Keeping - * the identifier as the value and the descriptor beside it means the form has both - * without converting either way. - * - * A `multiple: true` field is a **list** under the same key, in stored order, and - * it is present only when the row carries its ids: a detail response merges the - * advanced collections in first, while an admin list deliberately loads no - * junction table at all. Omitting the key there is the honest answer - an empty - * array would say "this gallery has no files", which is a different claim. - * - * `files` is `{}` for a content type with no file fields, so every generated list - * and detail response that had no files before is byte-identical. - */ export const withContentRowFiles = async <TRow extends object>( c: Context, definition: AnyContentTypeDefinition, @@ -253,24 +175,6 @@ export const withContentRowFiles = async <TRow extends object>( }); }; -/** - * Replaces every exposed file id on a **public** row with its descriptor. - * - * In place of the column rather than beside it, because a public reader has no - * route that turns a `core_files.id` into anything: there is no public files API - * and there should not be one. The descriptor is already the allowlisted shape, - * so the projector needs no file-specific branch - it forwards whatever the - * column holds, exactly as it does for a string. - * - * Only the fields `publicApi.fields` names. A file field the allowlist leaves out - * is not selected in the first place, so there is nothing here to resolve. - * - * A `multiple: true` field becomes a list of descriptors in stored order, so it - * has to run **after** the collections are loaded onto the row - which is why the - * public services call it last rather than first. An id whose row has vanished is - * dropped from the list rather than emitted as `null`: a public reader gets a - * gallery of the files that exist, not one with holes in it. - */ export const resolveContentPublicRowFiles = async ( c: Context, definition: AnyContentTypeDefinition, @@ -305,29 +209,6 @@ export const resolveContentPublicRowFiles = async ( })); }; -/** - * Re-checks every file a write names against the field that will hold it. - * - * A successful upload is **not** validation of an assignment. The upload route - * checked the file it received against the field it was uploaded for; this checks - * the `core_files` row an identifier names against the field it is being written - * to - which is a different question, and the one that stops - * `{ animation: <the id of a PDF somebody uploaded elsewhere> }` from being - * stored by a hand-written request. - * - * Four questions, the same four the upload asked: does the row exist, is it - * within `maxBytes`, is its media type allowed, and is its extension allowed. - * `validateContentFile` is the one implementation of the last three, so the - * answers cannot differ between the two moments. - * - * A **gallery** is the same four questions once per entry, and deliberately so: - * ten files are ten uploads, and `maxBytes` is a per-file ceiling rather than a - * budget for the list. The first entry that fails names itself, so an editor is - * told which image is the problem rather than that "the gallery" is. - * - * A no-op - not one statement - for a content type with no file fields, and for a - * payload that mentions none of them. - */ export const assertContentFileReferences = async ( c: Context, definition: AnyContentTypeDefinition, diff --git a/packages/vitnode/src/content/server/http-errors.ts b/packages/vitnode/src/content/server/http-errors.ts index 0a10eb1b8..36228a7df 100644 --- a/packages/vitnode/src/content/server/http-errors.ts +++ b/packages/vitnode/src/content/server/http-errors.ts @@ -30,14 +30,6 @@ const { uniqueViolation: UNIQUE_VIOLATION, } = PG_ERROR_CODES; -/** - * A JSON error body, carried on the exception itself. - * - * `HTTPException` normally renders its `message` as text, but it also accepts a - * ready-made `Response` - and `app.onError` returns `error.getResponse()` - * verbatim, so the body survives untouched. That is what lets an editorial - * route answer a machine-readable 409 without a second error channel. - */ const jsonError = (status: 400 | 409 | 422, body: unknown): HTTPException => new HTTPException(status, { res: Response.json(body, { status }), @@ -47,15 +39,6 @@ const jsonError = (status: 400 | 409 | 422, body: unknown): HTTPException => export const contentConflict = (body: ContentConflict): HTTPException => jsonError(409, body); -/** - * A structured 409, for a slug another record's URL history owns. - * - * 409 rather than 400: nothing about the request is malformed, the address is - * simply taken - by a URL that still redirects somewhere, which is a state of the - * system rather than a mistake in the payload. Its own body shape rather than a - * third arm of `zodContentConflict`, so a client generated before Stage 8 still - * parses the arms it knows. - */ export const contentDeliveryConflict = ( body: ContentDeliveryConflict, ): HTTPException => jsonError(409, body); @@ -65,44 +48,17 @@ export const contentUnprocessable = ( body: ContentUnprocessable, ): HTTPException => jsonError(422, body); -/** - * A structured 400, for a file identifier a field may not hold. - * - * The only 400 in this module that carries a body, and it does so because the - * two parts beside the sentence are the actionable ones: `code` says which rule - * refused the file, and `field` says which input to put the message under. A - * save carries every field at once, so a client with only prose to go on can - * report a refusal but not where. - */ export const contentFileRejected = (body: { code: string; field: string; message: string; }): HTTPException => jsonError(400, body); -/** - * A structured 400, for a schedule the rules refuse. - * - * 400 rather than 409: nothing is in conflict, the request simply asked for a - * time that cannot work. The `code` is what lets the dialog point at the date - * field instead of raising a general error. - */ export const contentScheduleRejected = (body: { code: ContentScheduleCode; contentTypeId: string; }): HTTPException => jsonError(400, body); -/** - * Turns a Postgres constraint failure into an HTTP response. - * - * The driver's message can name columns, constraints and even values, so it - * never reaches the client - only a generic sentence does. Anything unrecognised - * is rethrown for `app.onError`, which logs the detail and returns a bare 500. - * - * `structured` opts an editorial content type into JSON bodies for the two - * statuses a client has to branch on. It is off by default, so every Stage 1-3 - * route answers exactly as it did before. - */ export const rethrowAsHttpError = ( error: unknown, { diff --git a/packages/vitnode/src/content/server/index.ts b/packages/vitnode/src/content/server/index.ts index 35629d808..5d875b013 100644 --- a/packages/vitnode/src/content/server/index.ts +++ b/packages/vitnode/src/content/server/index.ts @@ -1,11 +1,3 @@ -/** - * Universal Content Engine - server surface. - * - * Imports Drizzle, so this must never be reachable from a client component. - * It must also never import `server-only`: that package's `default` export - * throws under plain Node, and both `apps/api` and `drizzle-kit` load these - * modules in plain Node. - */ export { CONTENT_SYSTEM_ACTOR, resolveContentActor } from "./actor"; export { buildContentColumn, diff --git a/packages/vitnode/src/content/server/language-resolver.ts b/packages/vitnode/src/content/server/language-resolver.ts index 89ff892c9..2e94ba7bd 100644 --- a/packages/vitnode/src/content/server/language-resolver.ts +++ b/packages/vitnode/src/content/server/language-resolver.ts @@ -7,14 +7,6 @@ import type { ContentDatabase } from "./service"; import { core_languages } from "../../database/languages"; import { ContentEngineError, ContentLanguageError } from "../errors"; -/** - * One usable language, as the Content Engine sees it. - * - * `locale` is the **canonical** `core_languages.code` - what the row stores, not - * what the caller typed. Everything downstream (the translation row, the route - * response, a future cache tag) uses this value, so `PL` in a URL and `pl` in a - * URL end up writing to the same place. - */ export interface ContentLanguage { /** `core_languages.id`. The foreign key a translation row actually holds. */ id: number; @@ -25,28 +17,8 @@ export interface ContentLanguage { locale: string; } -/** - * Every language in `core_languages`, cached for the life of one request. - * - * Keyed by the Hono context in a `WeakMap` rather than stored on the context - * itself: `c.set` is typed against `ContextVariableMap`, and adding a Content - * Engine key to the global variable map for a per-request memo would make every - * app carry it. The entry is collected with the request. - * - * The languages table is a handful of rows and every locale in a batch has to be - * resolved anyway, so one query per request beats one query per locale - which is - * what a naive `WHERE code = $1` resolver turns into on a list of translations. - */ const perRequest = new WeakMap<Context, Promise<ContentLanguage[]>>(); -/** - * Locales the app config has explicitly switched off. - * - * Optional the whole way down: a direct `app.request()` in a test, and a queue - * handler built by hand, both reach here without the global middleware that - * populates `core` - and "no config" has to mean "nothing disabled" rather than a - * `TypeError`. - */ const disabledLocales = (c: Context): ReadonlySet<string> => { const locales = c.get("core")?.i18n?.locales ?? []; @@ -84,19 +56,6 @@ const load = async ( })); }; -/** - * Every language, resolved once per request. - * - * Never called for a content type without localization, which is what keeps the - * languages table out of the query plan of an install that has none. - * - * `tx` is not an optimisation - it is required for correctness. A pool with one - * connection (which is what `max: 1` and a busy pool both amount to) would have - * that connection held by the caller's open transaction, so a language query - * issued on the *client* would wait for a connection the transaction is never - * going to release until it gets an answer. Reading inside the transaction also - * means the language it resolves is the one the insert will actually see. - */ export const listContentLanguages = async ( c: Context, tx?: ContentDatabase, @@ -117,14 +76,6 @@ export const listContentLanguages = async ( } }; -/** - * Finds a language by locale, case-insensitively. `null` when there is none. - * - * Case-insensitive because a locale travels in a URL, and `/pl/` and `/PL/` - * naming the same language is what people expect. The comparison happens in - * JavaScript over the already-loaded rows rather than as `lower(code) = $1`, - * which would not use `core_languages_code_idx` anyway. - */ export const findContentLanguage = async ( c: Context, locale: string, @@ -140,14 +91,6 @@ export const findContentLanguage = async ( ); }; -/** - * Resolves a locale to a language, or throws. - * - * `requireEnabled` is the write path: reading a translation in a locale the app - * has switched off is fine - it is already in the database, and hiding it would - * make the content unrecoverable - but writing one is not, because it would grow - * content in a language nothing renders. - */ export const resolveContentLanguage = async ( c: Context, { @@ -184,13 +127,6 @@ export const resolveContentLanguage = async ( return language; }; -/** - * The language a localized content type creates its records in. - * - * Resolved from the database every time rather than trusted from the definition: - * `defaultLocale` is a string in source control, and whether it names a row in - * `core_languages` is a fact about the installation. - */ export const resolveDefaultContentLanguage = async ( c: Context, definition: AnyContentTypeDefinition, @@ -218,20 +154,6 @@ export interface ContentLocalizationProblem { reason: "disabled" | "missing"; } -/** - * Checks every localized content type's `defaultLocale` against the database. - * - * A content type definition is plain data built at import time, long before there - * is a connection - so "does `core_languages` have a row for `en`" cannot be a - * definition-time check. This is the explicit runtime phase that replaces it, and - * the whole reason it exists is that the alternative is discovering a typo in - * `defaultLocale` on the first create request, in production, as a foreign-key - * violation. - * - * Returns the problems rather than throwing, so the caller decides: the boot - * guard turns them into one error naming every offender at once, which is more - * useful than failing on the first. - */ export const findContentLocalizationProblems = async ( c: Context, contentTypes: readonly RegisteredContentType[], diff --git a/packages/vitnode/src/content/server/localized-admin-routes.test.ts b/packages/vitnode/src/content/server/localized-admin-routes.test.ts index 4fa22ddbf..995bacd57 100644 --- a/packages/vitnode/src/content/server/localized-admin-routes.test.ts +++ b/packages/vitnode/src/content/server/localized-admin-routes.test.ts @@ -90,13 +90,6 @@ const translationOutcome = (overrides: Record<string, unknown> = {}) => ({ ...overrides, }); -/** - * Whether the write that just ran was inside a transaction that committed. - * - * The `db` stub runs the callback and remembers whether it threw - which is - * exactly the property under test: one refusal anywhere in a composite save has - * to take the whole save with it. - */ interface TxLog { committed: boolean; entered: boolean; diff --git a/packages/vitnode/src/content/server/localized-admin-routes.ts b/packages/vitnode/src/content/server/localized-admin-routes.ts index 10b15e040..da741a941 100644 --- a/packages/vitnode/src/content/server/localized-admin-routes.ts +++ b/packages/vitnode/src/content/server/localized-admin-routes.ts @@ -40,26 +40,6 @@ interface TranslationEntry { values: Record<string, unknown>; } -/** - * The two **composite** admin routes of a localized content type. - * - * One AdminCP form now edits a record and every language it exists in, because - * that is how the screen is laid out: each localized input carries its own small - * language switcher, and there is exactly one Save button. Saving touches the - * base row and any number of translation rows, and the only honest way to do - * that is inside one transaction - otherwise a Polish version conflict would - * leave the shared fields and the English copy already written while the button - * reported a failure. - * - * Nothing about the localization *model* changes here. These routes are a caller - * of the same transaction-aware services the per-locale routes use: the base row - * keeps its own version, every translation keeps its own, every mutation writes - * its own revision, and every event still comes out of the same effects. What is - * new is the boundary drawn around them. - * - * The per-locale routes stay exactly where they were, gated on `can_edit`, and - * the AdminCP still uses them for per-language publication and history. - */ export const buildContentLocalizedAdminRoutes = < TDefinition extends AnyContentTypeDefinition, P extends string, diff --git a/packages/vitnode/src/content/server/localized-base-preview.test.ts b/packages/vitnode/src/content/server/localized-base-preview.test.ts index 10ee5c12c..e71e3ebeb 100644 --- a/packages/vitnode/src/content/server/localized-base-preview.test.ts +++ b/packages/vitnode/src/content/server/localized-base-preview.test.ts @@ -19,12 +19,6 @@ const SECRET = "unit-test-content-preview-secret-0123456789"; const posts = createContentModel(testDeliveredPreviewableContentType); -/** - * The AdminCP's generic preview button, on a localized content type. - * - * `POST /{id}/preview` is what the row action calls - there is no per-language - * preview button - so this is the path every localized preview actually takes. - */ const mintHarness = () => { const service = { findById: vi.fn() }; const revisions = { diff --git a/packages/vitnode/src/content/server/localized-list-routes.test.ts b/packages/vitnode/src/content/server/localized-list-routes.test.ts index 9f3f2c52c..6eb2505ad 100644 --- a/packages/vitnode/src/content/server/localized-list-routes.test.ts +++ b/packages/vitnode/src/content/server/localized-list-routes.test.ts @@ -11,15 +11,6 @@ import type * as LanguageResolverModule from "./language-resolver"; import { createContentModel } from "./model"; import { buildContentRoutes } from "./routes"; -/** - * The admin list, viewed in one language. - * - * The list is a query over the *base* table - that is where its pagination, its - * ordering and its filters are defined - and the translations are attached - * afterwards. This suite pins the part that is easy to get wrong: "afterwards" - * has to mean one statement for the whole page, not one per row. - */ - const PLUGIN_ID = "@vitnode/example"; const LANGUAGES = [ diff --git a/packages/vitnode/src/content/server/localized-public-service.ts b/packages/vitnode/src/content/server/localized-public-service.ts index fe713eb7c..b9ac25b46 100644 --- a/packages/vitnode/src/content/server/localized-public-service.ts +++ b/packages/vitnode/src/content/server/localized-public-service.ts @@ -47,12 +47,6 @@ import { buildSearchCondition, } from "./query"; -/** - * The key the resolved language id travels back on. - * - * A leading underscore, which `CONTENT_FIELD_NAME_PATTERN` forbids, so it can - * never collide with a declared field however the content type is written. - */ const LANGUAGE_KEY = "_languageId"; const conditions = (...parts: (SQL | undefined)[]): SQL | undefined => { @@ -85,33 +79,6 @@ const EMPTY_PAGE = { totalCount: 0, }; -/** - * The read-only public repository of a **localized** content type. - * - * Everything here follows from one sentence: *a public localized response is one - * base row joined to one translation, and both halves have to be published.* - * - * - **Subordination is not a parameter.** Every read `and`s - * `publishedCondition` on the base row and the same predicate on the - * translation it serves, so there is no argument a caller could forget and no - * path that reaches an unpublished translation. - * - **Fallback picks *which* translation the predicate runs against.** It never - * relaxes the predicate. `fallback: "default"` can serve the default language - * to a locale that has no translation of its own; it can never serve a *draft* - * translation, in any language. - * - **A slug never falls back.** `findBySlug` is strict-locale, because a URL - * belongs to a language: answering `/pl/witaj` from the English row would be - * the wrong article, in the wrong language, cached under the Polish tag. - * - **Only allowlisted columns are read.** Shared ones off the base table, - * localized ones off the translation - the `SELECT` is built from - * `publicApi.fields` either way, so a private column is never fetched. - * - * The visibility test appears twice in the same statement on purpose: once as an - * `EXISTS` in the `WHERE` (which is what lets the paginator count matching rows - * without a join of its own), and once as the `ON` of the join that fetches the - * values. Both are generated from {@link publishedCondition} and the same language - * ids, so they cannot disagree about which translation is being served. - */ export const createContentLocalizedPublicService = < TDefinition extends AnyContentTypeDefinition, >({ @@ -158,14 +125,6 @@ export const createContentLocalizedPublicService = < const orderable = publicOrderableColumns(definition); const project = createContentPublicProjector(definition); - /** - * Which half of the join each exposed name is read from. - * - * A canonical path is answered by its **container**: `seo.title` is on the - * translation table when `seo` is a localized group and on the base row - * otherwise, because a group moves whole. A collection is neither - it has no - * column on either table, and is batch-loaded after the page is fetched. - */ const ownerOf = (name: string): string => { const path = splitContentFieldPath(name); @@ -193,13 +152,6 @@ export const createContentLocalizedPublicService = < .map(leaf => [leaf.path, leaf.columnName]), ); - /** - * Attaches the exposed collections to a page of rows. - * - * One batch per collection field for the whole page. A collection is shared, - * so it is the same in every language - there is nothing locale-aware to do - * here, and doing it once per page rather than once per locale is the point. - */ const withCollections = async ( rows: readonly Record<string, unknown>[], ): Promise<Record<string, unknown>[]> => { @@ -253,17 +205,6 @@ export const createContentLocalizedPublicService = < const requestedRows = requestedTable as unknown as Record<string, PgColumn>; const fallbackRows = fallbackTable as unknown as Record<string, PgColumn>; - /** - * `EXISTS (a published translation of this row, in this language)`. - * - * Correlated to the base table by `itemId`, so it can sit in a `WHERE` that the - * paginator also uses for its `COUNT` - which is the whole reason the visibility - * test is written as a subquery rather than only as a join condition. - * - * `extra` narrows it with a predicate over the *translation's* columns: that is - * how a filter or a search on a localized field stays bound to the language - * actually being served, instead of matching any translation at all. - */ const publishedTranslation = (languageId: number, extra?: SQL): SQL => exists( c @@ -280,15 +221,6 @@ export const createContentLocalizedPublicService = < ), ); - /** - * The row is readable in this locale - and, with `extra`, the translation being - * read also matches it. - * - * The fallback arm is deliberately mutually exclusive with the first: a locale - * that *has* a published translation is never also matched through the default - * one, so a filter or a search can never match a language the reader will not - * be shown. - */ const visibleIn = ( { fallbackTo, requested }: ResolvedLocale, extra?: SQL, @@ -318,27 +250,9 @@ export const createContentLocalizedPublicService = < }), ); - /** - * A canonical path, resolved to the column on the **aliased** translation. - * - * The two aliases are fresh Drizzle tables, so they carry the generated - * column keys and not the path aliases `contentTranslationTableColumns` - * registers. Mapping here rather than there is what keeps the alias trick a - * convenience on the model's column map instead of something every join has - * to reproduce. - */ const translationColumnName = (name: string): string => localizedColumnByPath.get(name) ?? name; - /** - * One localized column, read off whichever translation this row resolved to. - * - * The `CASE` is gated on the *join* having matched rather than on the column - * being null, which is what stops a nullable localized field being taken from - * one language while its neighbours come from another. Either the requested - * translation matched and every localized value comes from it, or none did and - * every value comes from the fallback. - */ const localizedValue = ( name: string, withFallback: boolean, diff --git a/packages/vitnode/src/content/server/localized-service.test.ts b/packages/vitnode/src/content/server/localized-service.test.ts index 1c65d17a6..c4fc002e6 100644 --- a/packages/vitnode/src/content/server/localized-service.test.ts +++ b/packages/vitnode/src/content/server/localized-service.test.ts @@ -13,13 +13,6 @@ import { createContentModel } from "./model"; const localized = createContentModel(testLocalizedArticleContentType); -/** - * The localized service, narrowed once. - * - * `localizedService` is `undefined` for a content type without localization, so - * TypeScript refuses the call until the check has been made - which is the point, - * and is why every test goes through this instead of asserting past it. - */ const localizedService = (c: Context) => { const build = localized.localizedService; if (!build) throw new Error("Expected a localized service."); @@ -37,15 +30,6 @@ interface RecordedCall { op: string; } -/** - * A Drizzle stand-in whose `transaction` behaves like the real one in the only - * respect a unit test can observe: the callback's rejection propagates, and - * `rolledBack` records that it did. - * - * Whether Postgres actually discards the base row is a property of Postgres, and - * `plugins/example/src/database/postgres.test.ts` asserts it against a real - * database rather than against a mock that could only ever agree with itself. - */ const createDbMock = ( results: unknown[][], { diff --git a/packages/vitnode/src/content/server/localized-service.ts b/packages/vitnode/src/content/server/localized-service.ts index 39fa48c02..13e11af80 100644 --- a/packages/vitnode/src/content/server/localized-service.ts +++ b/packages/vitnode/src/content/server/localized-service.ts @@ -23,15 +23,6 @@ export interface ContentLocalizedCreateInput<TDefinition> { } export interface ContentLocalizedCreateOptions { - /** - * Who is creating the record. - * - * Supply it on an editorial content type and the default translation gets its - * own `create` revision, in the same transaction as the row - which is what - * makes the earliest restorable English state the one the record was created - * with. Without it (or without `editorial`) the translation is written through - * the plain repository and leaves no history, exactly as in Stage 5A. - */ actor?: ContentActor; /** * The locale the first translation is written in. Defaults to - and, today, @@ -48,28 +39,12 @@ export interface ContentLocalizedCreateResult<TDefinition> { } export interface ContentLocalizedService<TDefinition> { - /** - * Creates a base row and its default-locale translation, atomically. - * - * Either both exist or neither does. That is the invariant everything above - * leans on: a record always resolves in at least one language, so the AdminCP - * always has something to show, a public read always has something to fall - * back to, and there is no such thing as an "empty" record whose title exists - * in no language at all. - */ create: ( input: ContentLocalizedCreateInput<TDefinition>, options?: ContentLocalizedCreateOptions, ) => Promise<ContentLocalizedCreateResult<TDefinition>>; } -/** - * The one write that spans both tables. - * - * Everything else about localization is either base-only (the plain service) or - * translation-only (the translation model). Create is the exception, and it is - * the reason this file exists rather than a third method on one of them. - */ export const createContentLocalizedService = < TDefinition extends AnyContentTypeDefinition, >({ @@ -81,13 +56,7 @@ export const createContentLocalizedService = < }: { c: Context; definition: TDefinition; - /** - * The translation editorial layer, when the content type has one. - * - * Optional so a localized content type without `editorial` keeps exactly the - * Stage 5A behaviour: the default translation is written through the repository - * and leaves no history, because there is no history to leave. - */ + editorial?: ContentTranslationEditorialService<TDefinition>; service: ContentService<TDefinition>; translations: ContentTranslationModel<TDefinition>; diff --git a/packages/vitnode/src/content/server/model.ts b/packages/vitnode/src/content/server/model.ts index 7bbdcb725..8dfcd275b 100644 --- a/packages/vitnode/src/content/server/model.ts +++ b/packages/vitnode/src/content/server/model.ts @@ -41,94 +41,33 @@ import { } from "./translation-table"; export interface ContentModel<TDefinition extends AnyContentTypeDefinition> { - /** - * The read and write layer for this content type's advanced collections. - * - * On the model rather than built per request, because it holds only the - * resolved tables and the memoised foreign-key targets - every method takes - * the database handle it should run on. A disabled stub for a content type - * that declares no collection, so a caller can use it unconditionally. - * - * Public so the rebuild indexers can batch-load a page's collections: they are - * built from the model and have no service of their own. - */ advanced: ContentAdvancedStore; - /** - * The generated collection tables, by field name. - * - * Export them from the plugin's database module alongside `table`, so Drizzle - * Kit finds them and the migration is generated: - * - * ```ts - * export const example_articles_categories = - * articleContent.advancedTables.junctions.categories; - * ``` - * - * Empty for a content type that declares no advanced collection. - */ + advancedTables: ContentAdvancedTables; /** Column name -> Drizzle column, for filters, ordering and custom queries. */ columns: Record<ContentColumnName<TDefinition>, PgColumn>; definition: TDefinition; - /** - * The read-only delivery layer, or `undefined` without a `delivery` block. - * - * `undefined` rather than a throwing stub, matching `publicService` and - * `editorialService`: the check reads naturally in code that does not know which - * content type it was handed, and TypeScript refuses the call until it has been - * made. - * - * `options.pluginId` is required because slug history is stamped with its owner - - * the same reason `editorialService` takes one, and `createContentModel` is - * called from `src/database/*.ts`, which has no reason to know it. - */ + deliveryService: | ((c: Context, options: { pluginId: string }) => ContentDeliveryService) | undefined; - /** - * The transactional editorial repository, or `undefined` when the content - * type has no `editorial` block. - * - * `undefined` rather than a throwing stub, for the same reason - * `publicService` is: the check reads naturally in a route builder that has - * no idea which content type it was handed. - */ + editorialService: | (( c: Context, options: { pluginId: string }, ) => ContentEditorialService<TDefinition>) | undefined; - /** - * The resolved localization config, mirrored off the definition. - * - * Present on every model, so `model.localization.enabled` is the one flag route - * builders and background work branch on - without reaching through - * `definition` for it. - */ + localization: ResolvedContentLocalizationConfig; - /** - * Creates a base row and its default translation in one transaction, or - * `undefined` when the content type is not localized. - * - * `options.pluginId` is optional and additive: supply it on an editorial content - * type and the default translation gets its own `create` revision, stamped with - * the right owner. Omit it - as every Stage 5A caller does - and the translation - * is written through the plain repository exactly as before. - */ + localizedService: | (( c: Context, options?: { pluginId?: string }, ) => ContentLocalizedService<TDefinition>) | undefined; - /** - * The read-only public repository, or `undefined` when the content type has - * no `publicApi`. - * - * `undefined` rather than a throwing stub so the check reads naturally in a - * route builder that has no idea which content type it was handed. - */ + publicService: ((c: Context) => ContentPublicService<TDefinition>) | undefined; /** The definition's schemas, re-typed for this concrete content type. */ @@ -145,14 +84,7 @@ export interface ContentModel<TDefinition extends AnyContentTypeDefinition> { ContentTranslationColumnName<TDefinition>, PgColumn >; - /** - * The transactional translation editorial layer, or `undefined` unless the - * content type is **both** localized and editorial. - * - * `undefined` rather than a throwing stub, matching every other optional member - * here: the check reads naturally in code that does not know which content type - * it was handed, and TypeScript refuses the call until it has been made. - */ + translationEditorialService: | (( c: Context, @@ -161,33 +93,13 @@ export interface ContentModel<TDefinition extends AnyContentTypeDefinition> { | undefined; /** The per-language schemas, or `null`. Mirrored off `schemas.translation`. */ translationSchemas: ContentTranslationSchemas<TDefinition> | null; - /** - * The translation repository, or `undefined` when the content type is not - * localized. Emits nothing and invalidates nothing - see - * {@link ContentTranslationModel}. - */ + translationService: ((c: Context) => ContentTranslationModel<TDefinition>) | undefined; - /** - * The generated translation `pgTable`, or `null`. Export it alongside `table` - * so Drizzle Kit finds it and the migration is generated: - * - * ```ts - * export const example_articles = articleContent.table; - * export const example_articles_translations = articleContent.translationTable; - * ``` - */ + translationTable: ContentTranslationTableFor<TDefinition> | null; } -/** - * Any content model, for code that holds a collection of them. - * - * The same shape `AnyContentTypeDefinition` provides for definitions, and it - * exists for the same reason: background work - the scheduled-publication task, - * the cleanup cron - looks a model up by content type id and cannot know which - * concrete one it will get. - */ export type AnyContentModel = ContentModel< // Deliberately `any` rather than `AnyContentTypeDefinition`, and // load-bearing. `ContentModel` mentions its definition in both directions - @@ -211,31 +123,10 @@ export type AnyContentModel = ContentModel< any >; -/** - * One erased model's definition, read back with its real type. - * - * `AnyContentModel` erases the whole parameter, so `model.definition` comes out - * as `any`. Everything that reads it - the id, the fields, the localization - * block - wants the ordinary type, and passing `any` straight into those calls - * would spread untyped values well past this boundary. Narrowing it here keeps - * the erasure confined to the members that need it. - * - * A plain annotation rather than an override on `AnyContentModel`: adding any - * member to that alias turns it into a distinct type, and TypeScript then - * structurally compares a concrete `ContentModel<T>` against it - which is the - * comparison the erasure exists to avoid. - */ export const contentDefinitionOf = ( model: AnyContentModel, ): AnyContentTypeDefinition => model.definition as AnyContentTypeDefinition; -/** - * A model plus the plugin that registered it. - * - * The owner is not on the model itself because `createContentModel` is called - * from `src/database/<entity>.ts`, which has no reason to know it. It is - * attached here, at collection time, where `buildApiPlugin` already knows. - */ export interface RegisteredContentModel { model: AnyContentModel; pluginId: string; @@ -248,24 +139,6 @@ export const findContentModel = ( ): RegisteredContentModel | undefined => models.find(entry => entry.model.definition.id === contentTypeId); -/** - * Turns a content type definition into its database model. - * - * Belongs in the plugin's `src/database/<entity>.ts`, next to the table export - * Drizzle Kit globs: - * - * ```ts - * export const articleContent = createContentModel(articleContentType, { - * references: { category: () => example_categories.id }, - * }); - * - * export const example_articles = articleContent.table; - * ``` - * - * Server-only. Never import it from a client component - and never add - * `server-only` to this module either, since `apps/api` and `drizzle-kit` both - * load it in plain Node, where that package throws. - */ export const createContentModel = < TDefinition extends AnyContentTypeDefinition, >( @@ -302,13 +175,6 @@ export const createContentModel = < : null; const translationSchemas = schemas.translation; - /** - * One translation model per call, bound to the request's handle. - * - * Built here rather than inside each service so `localizedService` and - * `translationService` share the same instance for one request - and so the - * "localization is enabled" narrowing happens exactly once. - */ const buildTranslations = ( c: Context, ): ContentTranslationModel<TDefinition> => { diff --git a/packages/vitnode/src/content/server/module.ts b/packages/vitnode/src/content/server/module.ts index 4e6807fbd..ade100a96 100644 --- a/packages/vitnode/src/content/server/module.ts +++ b/packages/vitnode/src/content/server/module.ts @@ -9,28 +9,6 @@ import { } from "./search-indexer"; import { assertContentReferences } from "./table"; -/** - * Builds the generated CRUD module for a plugin's content types. - * - * Nest it inside the plugin's own `admin` module - Hono only serves the last - * sub-app mounted at a given prefix, so the engine must never add a second - * `/admin` of its own: - * - * ```ts - * export const adminModule = buildModule({ - * pluginId: CONFIG_PLUGIN.pluginId, - * name: "admin", - * routes: [], - * modules: [buildContentAdminModule({ pluginId, contentTypes: [articleContent] })], - * }); - * ``` - * - * That yields `/api/{pluginId}/admin/content/{module}`. `buildApiPlugin` walks - * the module tree, so the content types registered here also drive the - * registry, the derived staff permissions and - for a content type with - * `search: { enabled: true }` - the generated search indexer. They are declared - * exactly once. - */ export const buildContentAdminModule = <P extends string>({ contentTypes, pluginId, diff --git a/packages/vitnode/src/content/server/openapi-parity.test.ts b/packages/vitnode/src/content/server/openapi-parity.test.ts index 6492b9d6d..ac992ceb7 100644 --- a/packages/vitnode/src/content/server/openapi-parity.test.ts +++ b/packages/vitnode/src/content/server/openapi-parity.test.ts @@ -26,25 +26,6 @@ import { createContentModel } from "./model"; import { buildContentPublicRoutes } from "./public-routes"; import { buildContentRoutes } from "./routes"; -/** - * The document says one thing; the runtime does another. - * - * Every generated route declares its responses in OpenAPI, and a generated - * client is built from exactly that. These tests serve the document the app - * really publishes and check the body the handler really produced against it - - * so a `409` that answers with prose where the document promises a - * discriminated union fails here rather than in somebody's generated client. - * - * Two halves, and both matter: - * - * 1. **the status is declared** - a runtime `409` on a route whose document - * lists only `200` and `404` is a contract break even when the body is fine; - * 2. **the body validates** - against the emitted JSON Schema rather than - * against the Zod object it came from. The two are not interchangeable: - * `z.date()` renders as `{ type: "string", format: "date-time" }`, which is - * exactly what `c.json(row)` sends and exactly what the Zod object rejects. - */ - vi.mock("../../api/lib/check-staff-permission", () => ({ assertStaffPermission: async () => await Promise.resolve(), })); @@ -115,14 +96,6 @@ interface Suite { routeOf: (method: string, path: string) => RouteConfig; } -/** - * The response schema the **document** publishes for one status. - * - * Not the Zod object the route was built from: `z.date()` renders as - * `{ type: "string", format: "date-time" }`, which is what the handler really - * sends, while the Zod object rejects that string outright. Reading the emitted - * document is the only way to check the contract a client actually consumes. - */ const documentedSchema = ( suite: Suite, route: RouteConfig, diff --git a/packages/vitnode/src/content/server/pagination-routes.test.ts b/packages/vitnode/src/content/server/pagination-routes.test.ts index 9909cae10..c28f8259b 100644 --- a/packages/vitnode/src/content/server/pagination-routes.test.ts +++ b/packages/vitnode/src/content/server/pagination-routes.test.ts @@ -12,20 +12,6 @@ import { import { createContentModel } from "./model"; import { buildContentRoutes } from "./routes"; -/** - * What a list route does with pagination input it cannot honour. - * - * Every case here used to be answered rather than refused: `first=0` clamped - * its way into a one-row page that reported `hasNextPage: true`, `first=abc` - * became `NaN` and fell through to the default page size, and `first` and - * `last` together threw a bare `Error` that surfaced as a 500. Each of them is - * a request nobody made, answered as if they had. - * - * The schema catches most of them at the edge and `parsePaginationParams` - * catches the rest; both answer 400, which is the only thing a client has to - * know. - */ - vi.mock("../../api/lib/check-staff-permission", () => ({ assertStaffPermission: async () => await Promise.resolve(), })); diff --git a/packages/vitnode/src/content/server/preview-link.ts b/packages/vitnode/src/content/server/preview-link.ts index 5cf808b82..a1fb71c75 100644 --- a/packages/vitnode/src/content/server/preview-link.ts +++ b/packages/vitnode/src/content/server/preview-link.ts @@ -13,26 +13,10 @@ import { contentDeliveryPath } from "../delivery"; import { contentPreviewConfigProblems } from "./preview-config"; import { ensureContentPreviewSecret } from "./preview-secret"; -/** - * The key this install signs preview links with. - * - * Read from the boot config when the global middleware has resolved it, and - * resolved on the spot otherwise - which is what a direct `app.request()` in a - * test does, since it never runs the middleware that populates `core`. Both - * paths land on the same memoised value. - */ export const contentPreviewSecret = async (c: Context): Promise<string> => c.get("core")?.contentPreviewSecret ?? (await ensureContentPreviewSecret(c.get("db"))); -/** - * Refuses to mint a link that would not be a link. - * - * 503 rather than 500: the request was fine and the code is fine, the deployment - * has an origin it cannot parse - and a service that is temporarily not offering - * a feature is what 503 means. The message names the environment variable, - * because the person clicking the button is usually the person who can set it. - */ export const assertContentPreviewIsServable = (): void => { const problems = contentPreviewConfigProblems(); if (problems.length === 0) return; diff --git a/packages/vitnode/src/content/server/preview-route.test.ts b/packages/vitnode/src/content/server/preview-route.test.ts index a09a2df84..660c7f836 100644 --- a/packages/vitnode/src/content/server/preview-route.test.ts +++ b/packages/vitnode/src/content/server/preview-route.test.ts @@ -37,13 +37,6 @@ const snapshot = ( ...overrides, }); -/** - * Mounts the generated public routes with the editorial service and the - * database stubbed. - * - * No session middleware and no admin context: the request arrives exactly as an - * anonymous reviewer's would, which is the only way this route is ever used. - */ const harness = ({ secret = SECRET }: { secret?: string } = {}) => { const findById = vi.fn(); const selections: Record<string, unknown>[] = []; diff --git a/packages/vitnode/src/content/server/preview-secret.test.ts b/packages/vitnode/src/content/server/preview-secret.test.ts index 1d13090be..777334024 100644 --- a/packages/vitnode/src/content/server/preview-secret.test.ts +++ b/packages/vitnode/src/content/server/preview-secret.test.ts @@ -9,11 +9,6 @@ import { resetContentPreviewSecret, } from "./preview-secret"; -/** - * A `core_secrets` table that behaves like the real one for the two operations - * the resolver performs, including the part that matters: `onConflictDoNothing` - * returns nothing when a row is already there. - */ const fakeDb = ({ rows = new Map<string, string>() } = {}) => { const selects = vi.fn(); const inserts = vi.fn(); diff --git a/packages/vitnode/src/content/server/preview-secret.ts b/packages/vitnode/src/content/server/preview-secret.ts index efd092165..18299b6e1 100644 --- a/packages/vitnode/src/content/server/preview-secret.ts +++ b/packages/vitnode/src/content/server/preview-secret.ts @@ -8,22 +8,8 @@ import { core_secrets } from "../../database/secrets"; /** The `core_secrets` row the preview signing key lives in. */ export const CONTENT_PREVIEW_SECRET_NAME = "content_preview"; -/** - * How much entropy the preview signing key carries. - * - * 32 bytes is the block size HMAC-SHA256 keys are compared against, and it is - * what `openssl rand -base64 32` produces. Anything shorter is a password, and - * a password is not a signing key. - */ const KEY_BYTES = 32; -/** - * A key nobody has to think about. - * - * `randomBytes` rather than anything derived from install data: a signing key - * must not be reconstructible from a database URL or a hostname, both of which - * leak far more often than a random row does. - */ const generate = (): string => crypto.randomBytes(KEY_BYTES).toString("base64"); const read = async (db: ContentDatabase): Promise<string | undefined> => { @@ -36,15 +22,6 @@ const read = async (db: ContentDatabase): Promise<string | undefined> => { return row?.value; }; -/** - * The install's preview key, minting one the first time anybody asks. - * - * `onConflictDoNothing` and then a re-read, because two processes booting - * against the same database both find the row missing and both try to write it. - * Only one insert can win, and the loser has to end up with the *winner's* - * value - a loser that kept its own would sign links the other process cannot - * verify, which is the exact failure this table exists to prevent. - */ const readOrCreate = async (db: ContentDatabase): Promise<string> => { const existing = await read(db); if (existing !== undefined) return existing; @@ -64,16 +41,6 @@ const readOrCreate = async (db: ContentDatabase): Promise<string> => { ); }; -/** - * The same lookup, run at most once per process. - * - * Memoised rather than repeated per request: the row cannot change under a - * running process without someone deliberately rotating it, and a database - * round-trip on every preview mint would buy nothing. - * - * A failure is not memoised, so a database that was not up yet is retried on - * the next request instead of poisoning the process. - */ let cached: Promise<string> | undefined; export const ensureContentPreviewSecret = async ( diff --git a/packages/vitnode/src/content/server/preview-target.ts b/packages/vitnode/src/content/server/preview-target.ts index 075334324..e548095a3 100644 --- a/packages/vitnode/src/content/server/preview-target.ts +++ b/packages/vitnode/src/content/server/preview-target.ts @@ -3,20 +3,6 @@ import type { Context } from "hono"; import type { AnyContentTypeDefinition } from "../types"; import type { ContentModel } from "./model"; -/** - * What a base preview link should be minted *for*. - * - * A preview of a nonlocalized record is unambiguous: one row, one page, one - * token. A localized record has neither - it has one row per language - so the - * base mint route has to pick one, and this is where that choice is made once - * rather than in each of the two places that needs it. - * - * `slug` is what turns the link into the record's own page instead of the JSON - * endpoint. `locale` and `languageId` are what make the token *readable*: the - * public preview route resolves a locale for every localized read, and a token - * minted without one is refused by design - so a locale-less token on a localized - * content type is a link that 404s no matter where it points. - */ export interface ContentPreviewTarget { /** `core_languages.id` of `locale`. Present exactly when `locale` is. */ languageId?: number; @@ -34,31 +20,11 @@ const stringValue = (source: unknown, key: string): string | undefined => { return typeof value === "string" && value.trim() !== "" ? value : undefined; }; -/** - * The slug field a preview link is built from, or `""` when there is none. - * - * Empty for a content type with no delivery layer, because there is no canonical - * page for a preview to point at - and asking for the slug would mean a query - * whose answer nothing reads. - */ const previewSlugField = (definition: AnyContentTypeDefinition): string => definition.delivery.enabled && definition.publicApi.enabled ? definition.publicApi.slugField : ""; -/** - * The default locale's slug, and the language the link is bound to. - * - * **The default locale, not a negotiated one.** The AdminCP preview button says - * "show me this record", and the record's default language is the only answer - * that does not depend on who is holding the mouse - a link that previewed - * whichever language the editor's browser happened to ask for would mean - * something different for each person who clicked it. - * - * One extra read for a localized content type, because `languageId` exists - * nowhere else. A nonlocalized one costs nothing: its slug is a column of the row - * the caller already loaded. - */ export const resolveContentPreviewTarget = async < TDefinition extends AnyContentTypeDefinition, >( @@ -98,14 +64,6 @@ export const resolveContentPreviewTarget = async < }; }; -/** - * The same slug, for a route that already knows which translation it is previewing. - * - * The locale-scoped mint route holds the translation and never loads the base - * row, so a *shared* slug is the one case that costs a read here - and it is the - * rarer shape, since `delivery.redirects` refuses a shared slug on a localized - * content type outright. - */ export const resolveContentTranslationPreviewSlug = async < TDefinition extends AnyContentTypeDefinition, >( diff --git a/packages/vitnode/src/content/server/preview-token.ts b/packages/vitnode/src/content/server/preview-token.ts index 8fef84ad3..a727067c3 100644 --- a/packages/vitnode/src/content/server/preview-token.ts +++ b/packages/vitnode/src/content/server/preview-token.ts @@ -8,48 +8,20 @@ import { CONTENT_PREVIEW_TOKEN_VERSION, } from "../const"; -/** - * What a preview link carries, in short keys because it travels in a URL. - * - * `r` is the load-bearing one: a token is bound to **one revision**, so a - * reviewer sees the state the editor was looking at when they shared the link, - * not whatever the record has drifted to since. `0` means the record had no - * revision yet - a row that predates its content type opting into `editorial` - - * and the live row is read instead. - * - * `ver` is the row version at issue time. Nothing branches on it; it is there - * so a support conversation about "which version did they see" has an answer - * even after the revision was pruned. - */ export const zodContentPreviewTokenPayload = z.object({ /** Rejects a token minted for anything else that ever shares this secret. */ aud: z.literal("content-preview"), /** Epoch **seconds**, not milliseconds. */ exp: z.number().int().positive(), i: z.number().int().positive(), - /** - * The locale this token previews, for a **translation** preview. - * - * Absent on a base preview, which is what every token minted before Stage 5B - * is - so old links keep working and mean exactly what they meant. Present, it - * binds the token to one language: a `pl` token used on the English tab is - * refused rather than falling back, because a preview whose language could - * shift under it is not a preview of anything. - */ + l: z.string().min(1).max(CONTENT_LOCALE_MAX_LENGTH).optional(), /** `core_languages.id`, so the reader needs no second lookup. */ lid: z.number().int().positive().optional(), p: z.string().min(1), r: z.number().int().nonnegative(), t: z.string().min(1), - /** - * The **translation** revision this token freezes. - * - * Present exactly when `l` is. Together with `r` - the shared revision - it is - * what makes the frozen guarantee whole: Option A of the two models, where the - * token names both halves. `0` means the translation had no revision to freeze, - * which the reader treats the same way `r: 0` is treated for the base row. - */ + tr: z.number().int().nonnegative().optional(), v: z.literal(CONTENT_PREVIEW_TOKEN_VERSION), ver: z.number().int().positive(), @@ -64,13 +36,6 @@ export interface ContentPreviewToken { token: string; } -/** - * Mints a preview link for one revision of one record. - * - * The expiry is absolute and has **no leeway** on the way back in. Web and API - * already need agreeing clocks for sessions to work at all, and slack on an - * expiry only ever weakens it. - */ export const createContentPreviewToken = ({ definition, itemId, @@ -87,12 +52,7 @@ export const createContentPreviewToken = ({ itemId: number; /** Required with `locale`: `core_languages.id` for that locale. */ languageId?: number; - /** - * The locale to freeze, for a translation preview. Omit for a base preview. - * - * Supplying it makes the token mean something narrower, not something wider: it - * previews *that* language, and only that language. - */ + locale?: string; now?: Date; pluginId: string; @@ -126,20 +86,6 @@ export const createContentPreviewToken = ({ return { expiresAt, token: signPayload(secret, payload) }; }; -/** - * Reads a preview link back, or returns `null`. - * - * One return value for every failure - bad signature, wrong secret, expired, - * truncated, minted for another plugin, another content type, or another - * record. The caller answers 404 for all of them, because a 401 or a 403 would - * confirm that the record exists, which is the single thing a draft URL must - * never do. - * - * The plugin and content type are checked here rather than trusted from the - * payload: the route knows which definition it is serving, and a token is only - * valid for *that* one. Without this, one signed token would work on every - * preview route in the install. - */ export const verifyContentPreviewToken = ({ definition, locale, @@ -149,15 +95,7 @@ export const verifyContentPreviewToken = ({ token, }: { definition: AnyContentTypeDefinition; - /** - * The locale the reader is serving, when it is serving one. - * - * Checked case-insensitively against the token's own `l`, and **never** relaxed: - * a token minted for `pl` used to read `en` is refused, and a token with no - * locale at all used on a locale-scoped read is refused too. There is no - * fallback here on purpose - falling back would silently hand a reviewer a - * different language from the one whose link they were sent. - */ + locale?: string; now?: Date; pluginId: string; diff --git a/packages/vitnode/src/content/server/public-locales.test.ts b/packages/vitnode/src/content/server/public-locales.test.ts index a14af0033..a6fc8bb13 100644 --- a/packages/vitnode/src/content/server/public-locales.test.ts +++ b/packages/vitnode/src/content/server/public-locales.test.ts @@ -38,12 +38,6 @@ const PAST = new Date("2020-01-01T00:00:00.000Z"); const published = { publishedAt: PAST, status: "published" }; const draft = { publishedAt: null, status: "draft" }; -/** - * A Drizzle stand-in that hands out one result set per `select`, in order. - * - * In order rather than by table, because the number of reads is part of what is - * being asserted: passing a base row skips the first query entirely. - */ const context = (...resultSets: Record<string, unknown>[][]): Context => { let call = 0; diff --git a/packages/vitnode/src/content/server/public-locales.ts b/packages/vitnode/src/content/server/public-locales.ts index f98252bad..ba34593f1 100644 --- a/packages/vitnode/src/content/server/public-locales.ts +++ b/packages/vitnode/src/content/server/public-locales.ts @@ -27,25 +27,6 @@ const isVisible = (row: PublicationRow): boolean => const asSlug = (value: unknown): string => typeof value === "string" ? value : ""; -/** - * Which languages one record is publicly reachable in, and under which URL. - * - * This is the one place the fallback rule is *evaluated* rather than described, - * and it lives on the server for a reason: answering it needs the language - * registry, the base row's publication state and every translation's, and a - * caller that tried to assemble those itself would be reimplementing - * `createContentLocalizedPublicService`'s visibility test in a second place - the - * classic pair that drifts, with a stale cache as the symptom. - * - * `hasOwnTranslation` means **served by a translation of its own**, not "a - * translation row exists". A locale whose translation is still a draft is served - * the default one, so it is a downstream consumer of the default locale's cache - * and has to be expired when that changes. - * - * Every *enabled* language is reported, including the ones with no translation at - * all: with `fallback: "default"` those have public pages too, and pages that - * nothing ever expires are worse than pages that are not cached. - */ export const contentPublicLocaleStates = async < TDefinition extends AnyContentTypeDefinition, >( diff --git a/packages/vitnode/src/content/server/public-module.ts b/packages/vitnode/src/content/server/public-module.ts index 2fcc81c40..a96fa5c14 100644 --- a/packages/vitnode/src/content/server/public-module.ts +++ b/packages/vitnode/src/content/server/public-module.ts @@ -4,34 +4,6 @@ import type { AnyContentModel } from "./model"; import { buildModule } from "../../api/lib/module"; import { buildContentPublicRoutes } from "./public-routes"; -/** - * Builds the generated public module for a plugin's content types. - * - * A **top-level** module, unlike `buildContentAdminModule`, so the paths land - * outside `/admin/` and the global admin gate never sees them: - * - * ```ts - * buildApiPlugin({ - * pluginId: CONFIG_PLUGIN.pluginId, - * modules: [ - * adminModule, - * buildContentPublicModule({ pluginId, contentTypes: [articleContent] }), - * ], - * }); - * ``` - * - * That yields `GET /api/{pluginId}/content/{publicApi.path}/` and `/{slug}`. - * - * Pass every model you like: a content type without `publicApi` is skipped, so - * the two module builders can take the same array. - * - * <Callout type="warn"> - * This module deliberately does **not** set `contentTypes`. `buildApiPlugin` - * collects them recursively, and registering a content type twice makes - * `validateContentTypes` throw "Duplicate content type id". Only - * `buildContentAdminModule` registers. - * </Callout> - */ export const buildContentPublicModule = <P extends string>({ contentTypes, pluginId, diff --git a/packages/vitnode/src/content/server/public-privacy.test.ts b/packages/vitnode/src/content/server/public-privacy.test.ts index 569573989..1a98d7564 100644 --- a/packages/vitnode/src/content/server/public-privacy.test.ts +++ b/packages/vitnode/src/content/server/public-privacy.test.ts @@ -10,19 +10,6 @@ import { createContentPublicProjector, } from "./public-service"; -/** - * What a public response is allowed to contain, stated as an exact set. - * - * Every other public test asserts that a particular field is present or a - * particular one is absent. This one asserts the **whole** key set, which is the - * only shape of assertion that catches a field nobody thought to check: a leaf - * added to a group later, a system column that started being selected, an - * internal storage name leaking through the flattening. - * - * The fixture is deliberately hostile - every kind has a public member and a - * private sibling, so "the allowlist is a filter" has something to be wrong - * about in each of them. - */ const contentType = defineContentType({ id: "test.privacy", tableName: "test_privacy", @@ -89,15 +76,6 @@ const contentType = defineContentType({ const model = createContentModel(contentType); const project = createContentPublicProjector(contentType); -/** - * A raw row carrying **everything** - including the values the projector must - * drop and the flattened storage names it must never surface. - * - * Group leaves arrive already nested, which is what the read layer hands the - * projector; the flat `seoRobots`-style columns are added alongside so a - * projector that copied unknown keys through would be caught here rather than - * in production. - */ const rawRow = { createdAt: new Date("2026-01-01T00:00:00.000Z"), faq: [ diff --git a/packages/vitnode/src/content/server/public-routes.test.ts b/packages/vitnode/src/content/server/public-routes.test.ts index 75ec86712..0060a3c00 100644 --- a/packages/vitnode/src/content/server/public-routes.test.ts +++ b/packages/vitnode/src/content/server/public-routes.test.ts @@ -41,12 +41,6 @@ const emptyPage = { }, }; -/** - * Mounts the generated public routes with the public service stubbed. - * - * No session middleware and no admin context: the request arrives exactly as an - * anonymous one would. - */ const harness = () => { const service = { findById: vi.fn(), diff --git a/packages/vitnode/src/content/server/public-routes.ts b/packages/vitnode/src/content/server/public-routes.ts index 900a2bbb3..7dedabbab 100644 --- a/packages/vitnode/src/content/server/public-routes.ts +++ b/packages/vitnode/src/content/server/public-routes.ts @@ -52,23 +52,6 @@ import { projectTranslationRevisionSnapshot, } from "./revision-snapshot"; -/** - * The read-only routes one public content type gets. - * - * ```http - * GET /api/{pluginId}/content/{publicApi.path}/ - * GET /api/{pluginId}/content/{publicApi.path}/{slug} - * GET /api/{pluginId}/content/{publicApi.path}/preview/{token} (editorial.preview) - * ``` - * - * No `adminStaffPermission` and no `/admin/` anywhere in the path, which is - * exactly how every other public route in VitNode is public: by omission. The - * global middleware still runs, so `c.get("user")` is populated (possibly - * `null`) and the IP rate limiter still applies. - * - * Only `get` is ever built here. There is no public create, update, delete, - * publish or unpublish, and no flag that would add one. - */ export const buildContentPublicRoutes = < TDefinition extends AnyContentTypeDefinition, P extends string, diff --git a/packages/vitnode/src/content/server/public-service.ts b/packages/vitnode/src/content/server/public-service.ts index 3d3be72aa..b8de7b91e 100644 --- a/packages/vitnode/src/content/server/public-service.ts +++ b/packages/vitnode/src/content/server/public-service.ts @@ -36,26 +36,7 @@ import { buildSearchCondition, } from "./query"; -/** - * Which language a public read is for. - * - * Ignored by a content type that is not localized - there is one version of the - * row and it is the answer to every locale. Present on the shared interface - * rather than only on the localized one so a route handler, which is written - * against `AnyContentTypeDefinition` and cannot know which it was handed, passes - * the locale unconditionally and lets the service decide whether it means - * anything. - */ export interface ContentPublicReadOptions { - /** - * The **canonical** locale this read is for, already resolved through - * `resolveContentPublicLocale`. - * - * A locale that names no language, or one the install has switched off, is a - * `null` result rather than a throw or a silent substitution: the caller answers - * the same 404 it answers for a slug that does not exist, and no reader is ever - * handed a language they did not ask for. - */ locale?: string; } @@ -72,27 +53,13 @@ export interface ContentPublicFindManyArgs< query?: { cursor?: string; first?: string; last?: string; search?: string }; } -/** - * The read-only half of a content type, for anonymous callers. - * - * There is no `create`, `update`, `delete`, `publish` or `unpublish` to omit - - * this is a different object from `model.service`, not a filtered view of it, - * so a public write is not something you can reach by accident. - */ export interface ContentPublicService<TDefinition> { /** `null` unless the row exists *and* is published. */ findById: ( id: number, options?: ContentPublicReadOptions, ) => Promise<ContentPublicSelect<TDefinition> | null>; - /** - * The public detail lookup. `null` for a draft, an unpublished row or a typo. - * - * **Never falls back.** A slug belongs to one language, so resolving a Polish - * URL against an English translation would answer a request for `/pl/witaj` - * with the English article - and then cache it under the Polish tag. See - * `createContentLocalizedPublicService`. - */ + findBySlug: ( slug: string, options?: ContentPublicReadOptions, @@ -103,20 +70,6 @@ export interface ContentPublicService<TDefinition> { }>; } -/** - * The public projection, as a standalone function. - * - * Extracted so the preview route can use **this** rather than a second - * implementation that looks the same on the day it is written. The allowlist, - * the relation-to-`{ id }` collapse and the "drop the cursor `id` unless it was - * exposed" rule are one piece of code, so a field cannot become public on one - * route and stay private on the other. - * - * It reads nothing but the definition: no database handle, no columns, no - * joins. An exposed relation is projected from the foreign key the row already - * carries, which is what makes it impossible for one content type's allowlist - * to publish another's administrative metadata. - */ export const createContentPublicProjector = < TDefinition extends AnyContentTypeDefinition, >( @@ -209,13 +162,6 @@ const pick = ( ): Record<string, unknown> => Object.fromEntries(keys.map(key => [key, values[key] ?? null])); -/** - * The columns a public read selects: the allowlist, plus `id` for the cursor. - * - * `id` is fetched whether or not it is exposed, because pagination needs it - - * and then dropped again by the projector. A private column is never in this - * map at all, so it cannot leak through a mistake further downstream. - */ export const contentPublicSelection = ( definition: AnyContentTypeDefinition, columns: Record<string, PgColumn>, @@ -241,12 +187,6 @@ export const contentPublicSelection = ( ), }); -/** - * The collection fields a public response actually needs. - * - * Empty unless the allowlist named one, which is what keeps a public list from - * joining every junction and child table a content type happens to have. - */ export const contentPublicCollectionFields = ( definition: AnyContentTypeDefinition, ): string[] => { @@ -269,14 +209,6 @@ export const contentPublicCollectionFields = ( }); }; -/** - * Folds a row's flat leaf columns back into the nested shape. - * - * A public read selects `seo.title` as a column alias, so the raw row carries a - * key with a dot in it. Nesting happens here rather than in the projector so the - * projector stays the one place that decides *what* is public, and this stays - * the one place that decides what it *looks like*. - */ export const nestContentPublicRow = ( row: Record<string, unknown>, ): Record<string, unknown> => { @@ -310,24 +242,6 @@ export const clampContentPublicPageSize = ( return String(Math.min(parsed, CONTENT_PUBLIC_MAX_PAGE_SIZE)); }; -/** - * Builds the read-only service a public route serves from. - * - * Two things make this safe rather than "the admin service with fewer methods": - * - * 1. **The published predicate is not a parameter.** Every method `and`s it in - * itself, so there is no argument a caller could forget and no code path - * that reaches an unpublished row. - * 2. **The `SELECT` is built from `publicApi.fields`.** A private column is - * never fetched, so it cannot be leaked by a mistake further downstream. - * The one exception is `id`, which the cursor needs; it is dropped from the - * projected row unless the allowlist names it, and that boundary is tested. - * - * It also joins nothing. An exposed relation is projected from the foreign key - * the row already carries, so a target table is never read - which is what - * makes it impossible for one content type's allowlist to publish another's - * administrative metadata. - */ export const createContentPublicService = < TDefinition extends AnyContentTypeDefinition, >({ @@ -371,12 +285,6 @@ export const createContentPublicService = < // no junction and no child table unless a public response is made of them. const publicCollections = contentPublicCollectionFields(definition); - /** - * Attaches the exposed collections to a page of rows. - * - * One batch per collection field for the whole page, keyed by the parent ids - * the page already fetched - never one query per row. - */ const withCollections = async ( rows: readonly Record<string, unknown>[], ): Promise<Record<string, unknown>[]> => { diff --git a/packages/vitnode/src/content/server/publication.ts b/packages/vitnode/src/content/server/publication.ts index e70809cf5..7e7e2e625 100644 --- a/packages/vitnode/src/content/server/publication.ts +++ b/packages/vitnode/src/content/server/publication.ts @@ -8,30 +8,11 @@ import type { ContentPublicationMethods, ContentService } from "./service"; import { ContentEngineError } from "../errors"; -/** - * The two columns `publication: { enabled: true }` generates. - * - * Structural on purpose: a `ContentModel`'s `columns` map satisfies it only when - * publication is enabled, because `ContentColumnName` adds those two names under - * the same conditional. Passing the columns of a content type without - * publication is therefore a compile error rather than a query against columns - * that do not exist. - */ export interface PublicationColumns { publishedAt: PgColumn; status: PgColumn; } -/** - * Picks the two publication columns out of a model's column map. - * - * Generic code is written against `AnyContentTypeDefinition`, whose - * `publication.enabled` is `boolean`, so its `columns` map is a plain - * `Record<string, PgColumn>` and does not satisfy {@link PublicationColumns}. - * This is the runtime step that makes it true - a real presence check rather - * than a cast, since the whole point of narrowing the parameter was to stop - * `undefined` reaching Drizzle. - */ export const publicationColumns = ( definition: AnyContentTypeDefinition, columns: Record<string, PgColumn>, @@ -48,44 +29,6 @@ export const publicationColumns = ( return { publishedAt, status }; }; -/** - * The one definition of "published". - * - * ```sql - * status = 'published' AND published_at IS NOT NULL AND published_at <= NOW() - * ``` - * - * The generated public read layer applies this centrally: every method on - * `model.publicService` `and`s it in itself, so there is no argument a caller - * could forget, and the two generated public routes go through that service. - * - * It is also exported for **hand-written plugin queries**, which is where the - * predicate would otherwise be retyped by hand - exactly the thing worth - * getting wrong once, because forgetting the `IS NOT NULL` leaks a row whose - * timestamp was cleared: - * - * ```ts - * const rows = await c - * .get("db") - * .select({ id: articles.table.id, title: articles.table.title }) - * .from(articles.table) - * .where( - * publishedCondition(publicationColumns(articleContentType, articles.columns)), - * ); - * ``` - * - * One definition either way, so a custom route and a generated one can never - * disagree about what "published" means. - * - * `published_at <= now()` is always true today - `publish` only ever stamps - * `now()` - but stating the invariant costs nothing and makes scheduled - * publishing a purely additive change later. - * - * Enabling `publication` still exposes nothing on its own: it adds the - * lifecycle, and `publicApi.enabled` is what generates the public routes. On a - * content type without that block, this predicate is only ever reached by a - * route you wrote. - */ export const publishedCondition = ( columns: PublicationColumns, ): SQL | undefined => @@ -95,15 +38,6 @@ export const publishedCondition = ( lte(columns.publishedAt, sql`now()`), ); -/** - * The publication pair on a generated **translation** table. - * - * Its own function rather than a second argument to {@link publicationColumns} - * because the two check different things: a translation carries `status` and - * `publishedAt` only when the *base* content type has publication, and a - * localized content type without it has translations that are simply always - * visible once the record is. - */ export const contentTranslationPublicationColumns = ( definition: AnyContentTypeDefinition, translationColumns: Record<string, PgColumn>, @@ -125,28 +59,6 @@ export const contentTranslationPublicationColumns = ( return { publishedAt, status }; }; -/** - * The one definition of **publicly visible**. - * - * For a Stage 1-4 content type it is {@link publishedCondition} on the base row - * and nothing else. For a localized one it is that *and* the same predicate on the - * translation being served - subordination, stated once, in SQL: - * - * ```sql - * base.status = 'published' AND base.published_at IS NOT NULL AND base.published_at <= NOW() - * AND t.status = 'published' AND t.published_at IS NOT NULL AND t.published_at <= NOW() - * ``` - * - * Two clauses of the same predicate rather than a second predicate, which is what - * keeps "published" from meaning one thing for a record and a slightly different - * thing for its Polish translation. `isContentTranslationPubliclyVisible` is the - * JavaScript half, written the same way for the same reason. - * - * A published record with an unpublished translation is **not** public in that - * language. It may still be public in another one - that is what fallback decides, - * and the fallback is applied by choosing *which* translation this predicate is - * evaluated against, never by relaxing it. - */ export const contentPublicCondition = ( base: PublicationColumns, translation?: PublicationColumns, @@ -155,15 +67,6 @@ export const contentPublicCondition = ( ? publishedCondition(base) : and(publishedCondition(base), publishedCondition(translation)); -/** - * Narrows a service to its publication methods. - * - * Route and module code is generic over `AnyContentTypeDefinition`, whose - * `publication.enabled` is `boolean` rather than `true`, so the conditional - * members resolve to `never` there. Every call site checks - * `definition.publication.enabled` first - this is the accompanying type-level - * step, in the same spirit as the `isReferenceField` predicate in `routes.ts`. - */ export const publicationMethods = < TDefinition extends AnyContentTypeDefinition, >( diff --git a/packages/vitnode/src/content/server/query.test.ts b/packages/vitnode/src/content/server/query.test.ts index df0047454..8c1d634ec 100644 --- a/packages/vitnode/src/content/server/query.test.ts +++ b/packages/vitnode/src/content/server/query.test.ts @@ -35,11 +35,6 @@ const columns = contentTableColumns(testArticleContentType, table); const fields = testArticleContentType.fields; const contentTypeId = testArticleContentType.id; -/** - * A focused fixture for the null-filter rules: the shared article type has a - * nullable `user` field but no nullable *relation*, and both sides of that rule - * need proving. - */ const referenceType = defineContentType({ id: "test.reference", tableName: "test_references", @@ -235,13 +230,6 @@ describe("buildFilterCondition", () => { }); }); - /** - * `status` on a publication content type is a *generated* column: there is no - * field descriptor behind it, so none of the checks above apply and it needs - * its own guard. The generated Zod schema narrows the value on the HTTP path; - * these cover the direct-service path, where a cast or a runtime-built object - * can carry anything. - */ describe("publication status", () => { const publicationFilter = (filters: Record<string, unknown>) => buildFilterCondition({ diff --git a/packages/vitnode/src/content/server/query.ts b/packages/vitnode/src/content/server/query.ts index d50194945..b88c71ef4 100644 --- a/packages/vitnode/src/content/server/query.ts +++ b/packages/vitnode/src/content/server/query.ts @@ -233,14 +233,6 @@ const sameValue = (current: unknown, next: unknown): boolean => { return current === next; }; -/** - * The keys an update actually changes. Values equal to what is already stored - * are dropped, so `content.*.updated` never reports a field that did not move. - * - * Driven by the content type's own field names rather than by `Object.keys` on - * the patch: that keeps the result typed as the field-name union, and it can - * never surface a key the content type does not declare. - */ export const diffChangedFields = <TName extends string>( fieldNames: readonly TName[], current: Record<string, unknown>, @@ -250,20 +242,6 @@ export const diffChangedFields = <TName extends string>( name => patch[name] !== undefined && !sameValue(current[name], patch[name]), ); -/** - * The **canonical paths** a patch actually changes, groups included. - * - * A scalar contributes its own name. A group contributes one path per leaf the - * patch names *and* moves, so `{ seo: { description } }` reports - * `["seo.description"]` and never `["seo"]` - which is what makes a changed-field - * list precise enough for a cache decision and a search decision to be made from - * it. `seo: null` reports every leaf that was not already null, because that is - * exactly what it blanks. - * - * `current` is a **column** row, as it comes back from Postgres; the patch is in - * logical shape. Comparing across the two is the whole job, and doing it here is - * what stops each service from flattening by hand. - */ export const diffChangedPaths = ( fields: ContentFieldMap, current: Record<string, unknown>, @@ -315,13 +293,6 @@ export const diffChangedPaths = ( return changed; }; -/** - * The column patch for a set of changed paths. - * - * Only the leaves that moved are written, so an `UPDATE` touches `seoTitle` and - * leaves `seoDescription` alone - which is what a partial group update has to - * mean if two people editing different leaves are not to overwrite each other. - */ export const changedPathsToColumns = ( fields: ContentFieldMap, patch: Record<string, unknown>, @@ -364,13 +335,6 @@ export const toColumnValues = ( }), ); -/** - * A whole logical value object, flattened into the columns an `INSERT` writes. - * - * {@link contentValuesToColumns} with the `dateTime` coercion applied - * afterwards, so a create writes `seo_title` from `{ seo: { title } }` and an - * ISO string still becomes a `Date`. - */ export const toInsertColumns = ( fields: ContentFieldMap, values: Record<string, unknown>, diff --git a/packages/vitnode/src/content/server/references.ts b/packages/vitnode/src/content/server/references.ts index e7d1fac4e..d4c5306c2 100644 --- a/packages/vitnode/src/content/server/references.ts +++ b/packages/vitnode/src/content/server/references.ts @@ -14,14 +14,6 @@ import { partitionContentFields } from "../localization"; import { isContentReferenceCollection } from "../paths"; import { createContentTranslationTable } from "./translation-table"; -/** - * One aliased translation table of a relation target, and the three columns a - * label is read through. - * - * Aliased per field *and* per role, because the same target may be joined twice - * in one statement - once for the reader's language and once for the target's - * default - and Postgres needs two names for that. - */ export interface ReferenceTranslationSource { aliased: PgTable; itemColumn: PgColumn; @@ -29,16 +21,6 @@ export interface ReferenceTranslationSource { languageColumn: PgColumn; } -/** - * How to read the label of a relation whose target's `admin.titleField` is - * **localized** - `blog.category` with `name: field.text({ localized: true })` - * and `titleField: "name"`. - * - * The value is not a column on the target's base table at all, so the numeric id - * is all a plain join can produce. Two joins onto the target's translation table - * produce the honest answer instead: the reader's own language, falling back to - * the language the target is authored in. - */ export interface ReferenceLocalizedLabel { /** The target's `localization.defaultLocale`. The fallback's language. */ defaultLocale: string; @@ -46,44 +28,17 @@ export interface ReferenceLocalizedLabel { viewer: ReferenceTranslationSource; } -/** - * Everything needed to *read a label* off a reference target. - * - * Split from {@link ReferenceTarget} because a to-many field has all of this and - * none of the join: its foreign keys are on the generated junction table, so - * there is no owner column on this row to join through - but its picker still - * has to show names rather than identifiers. - */ export interface ContentPickerTarget { /** Aliased, so two relations pointing at the same table can both be joined. */ aliased: PgTable; - /** - * The target's colour column, when it declares `admin.colorField`. - * - * What lets a picker draw a swatch beside a name - a blog category is a colour - * as much as it is a word, and a list of names alone throws that away. - */ + colorColumn?: PgColumn; idColumn: PgColumn; - /** - * The label on the target's **base** table, or its id when the label lives on - * the translation table. - * - * Read directly only when {@link ReferenceTarget.localizedLabel} is absent; - * otherwise it is the last-resort value a row with no translation at all falls - * back to. - */ + labelColumn: PgColumn; /** Present when the target names a localized field as its `admin.titleField`. */ localizedLabel?: ReferenceLocalizedLabel; - /** - * The extra columns a **person** is recognised by, for a `user` field. - * - * Absent on a `relation`, whose target is a content type with no avatar and no - * handle. Present, the picker shows a face and an `@name` instead of a bare - * string - which is the whole difference between choosing a user and choosing - * a row that happens to have a name. - */ + userColumns?: { avatarColor: PgColumn; nameCode: PgColumn }; } @@ -94,11 +49,6 @@ export interface ReferenceTarget extends ContentPickerTarget { export const LABEL_PREFIX = "label__"; -/** - * Turns a joined label column value into display text. Only the shapes a title - * column can actually hold are handled - anything else becomes `null` rather - * than "[object Object]". - */ export const toLabel = (value: unknown): null | string => { if (value === null || value === undefined) return null; if (typeof value === "string") return value; @@ -110,26 +60,8 @@ export const toLabel = (value: unknown): null | string => { return null; }; -/** - * One target's translation table, built once for the life of the process. - * - * `resolveReferenceTargets` runs per request, and the table it needs is a pure - * function of a definition that never changes - so it is memoised against the - * definition rather than rebuilt on every list. - */ const translationTables = new WeakMap<AnyContentTypeDefinition, PgTable>(); -/** - * The target's translation table, rebuilt from its own definition. - * - * Rebuilt rather than looked up because a relation target arrives here as a - * `defineContentType` result - the thunk on the field - and never as a model: - * `createContentModel` lives in the owning plugin's `src/database/*.ts`, which - * the engine has no registry of at this point. The generated table is a pure - * function of the definition and the base table, so this produces the same - * columns, the same name and the same types as the model's own - and being a - * second object costs nothing, since it is only ever aliased into a join. - */ const translationSource = ( definition: AnyContentTypeDefinition, baseTable: PgTable, @@ -155,20 +87,6 @@ const translationSource = ( }; }; -/** - * Works out which table and column supply the display label for each - * `user`/`relation` field. - * - * The target comes from the foreign keys Drizzle already resolved on the table, - * so the engine needs no separate table registry - and because the FK thunk is - * evaluated here, circular content type references stay safe. - * - * **Administrative only.** A label is read from the target's - * `admin.titleField`, which is metadata for the AdminCP: it may name a field - * the target never publishes, and the row it comes from may itself be a draft. - * The public projection therefore does not use this at all - an exposed - * relation there is `{ id }`, taken straight off the foreign key. - */ export const resolveReferenceTargets = ( definition: AnyContentTypeDefinition, table: PgTableWithColumns<TableConfig>, @@ -207,22 +125,6 @@ export const resolveReferenceTargets = ( return targets; }; -/** - * The picker targets of the **to-many** reference fields. - * - * The target table is read off the junction's own foreign key - supplied by the - * caller, which is the only part of the engine holding the generated tables - - * rather than off a column on this row, because there is no such column. The - * label then resolves exactly as it does for a to-one field: the target content - * type's `admin.titleField`, in the reader's language when that field is - * localized, or a person's name for a `user`. - * - * Separate from {@link resolveReferenceTargets} rather than merged into it, - * because the two are consumed differently: a to-one target is *joined* into the - * list query to label a column, and a to-many target is only ever queried on its - * own by the picker. Merging them would put a table with no join condition into - * the list statement. - */ export const resolveCollectionPickerTargets = ( definition: AnyContentTypeDefinition, targetTableOf: (field: string) => null | PgTable, diff --git a/packages/vitnode/src/content/server/revalidate-bridge.test.ts b/packages/vitnode/src/content/server/revalidate-bridge.test.ts index 4cf98c7ed..d9c4c0813 100644 --- a/packages/vitnode/src/content/server/revalidate-bridge.test.ts +++ b/packages/vitnode/src/content/server/revalidate-bridge.test.ts @@ -18,13 +18,6 @@ const input = { wasPublic: false, }; -/** - * One configured origin unless a test says otherwise. - * - * Explicit because the bridge has no fallback: an install that names no origin - * is telling the API there is nothing to notify, so a helper that defaulted to - * `undefined` would make every test below assert the empty case by accident. - */ const context = (overrides?: { cronSecret?: string; origins?: string[]; diff --git a/packages/vitnode/src/content/server/revalidate-bridge.ts b/packages/vitnode/src/content/server/revalidate-bridge.ts index 74277c8b1..2a08da4b2 100644 --- a/packages/vitnode/src/content/server/revalidate-bridge.ts +++ b/packages/vitnode/src/content/server/revalidate-bridge.ts @@ -27,55 +27,9 @@ export interface ContentRevalidationRequest extends ContentInvalidationInput { const sleep = async (ms: number): Promise<void> => await new Promise(resolve => setTimeout(resolve, ms)); -/** - * Which web origins to notify. Configured, or none. - * - * There is deliberately no fallback to the session-cookie origin. Only a front - * end that caches its own renders has anything to expire, and posting at one - * that does not is worse than not posting: the request lands on whatever that - * origin serves at this path - a 404, in an application whose `/api/*` is a - * Hono mount - which reads as a failed delivery, and - * `content-schedule-effects` fails the queue task on a partial delivery. Every - * scheduled publish would then retry its effects forever over a cache that was - * never there. - * - * So the list is opt-in: an install that mounts the handler names its origins, - * and an install that does not gets `attempted: 0` - "nothing to tell", which - * is a decision rather than an outage. - */ const originsFor = (c: Context): string[] => c.get("core")?.contentRevalidateOrigins ?? []; -/** - * Tells a front end to expire the tags a background mutation just invalidated. - * - * This exists because of one hard constraint: **the queue handler does not run - * in the front end.** It is a plain `@hono/node-server` process, with no access - * to whatever cache the thing serving the pages keeps. So a scheduled publish - * cannot expire a render cache by calling a function - it has to ask the - * process that can, over HTTP, which is why this is a `fetch` and not an import. - * - * The alternative was to let each entry expire on its own lifetime, which would - * leave an unpublished record readable for as long as that lasts. That is not a - * cache miss; it is the feature not working. - * - * Framework-neutral on both sides: the request is a signed POST carrying tag - * inputs, and what the receiver does with them is its own business. A front end - * that caches nothing simply does not appear in `content.revalidateOrigins` and - * is never posted at. - * - * **It reports rather than throws.** Every origin is tried, a failure is logged, - * and the counts come back for the caller to judge. That split matters: one - * origin being unreachable must not stop the others, but it must also not be - * hidden - so the decision about whether the delivery was good enough belongs - * to whoever can retry it, not here. - * - * `content-schedule-effects` is that caller, and it requires - * `delivered === attempted`: with several web apps behind one API, a scheduled - * unpublish that expired one cache and not the other has left a withdrawn page - * readable. `attempted: 0` means there was nothing to tell - no tag needed - * expiring, or no origin is configured - which is not a failure. - */ export const dispatchContentRevalidation = async ( c: Context, input: ContentRevalidationRequest, diff --git a/packages/vitnode/src/content/server/revision-snapshot.ts b/packages/vitnode/src/content/server/revision-snapshot.ts index 0c20d3f70..7dff0b57a 100644 --- a/packages/vitnode/src/content/server/revision-snapshot.ts +++ b/packages/vitnode/src/content/server/revision-snapshot.ts @@ -27,14 +27,6 @@ const toIsoOrNull = (value: unknown): null | string => { return toIso(value); }; -/** - * One column value, flattened to something `JSON.parse` gives back unchanged. - * - * A `Date` becomes an ISO string; a relation or user is already the foreign key - * integer; everything else is a primitive. Anything unrecognised becomes `null` - * rather than being stringified, so a future column type cannot smuggle - * `"[object Object]"` into a snapshot and have a restore write it back. - */ const toSnapshotValue = (value: unknown): ContentSnapshotScalar => { if (value === null || value === undefined) return null; if (value instanceof Date) return value.toISOString(); @@ -47,20 +39,6 @@ const toSnapshotValue = (value: unknown): ContentSnapshotScalar => { return null; }; -/** - * One field's value, in the **logical** shape. - * - * A group is snapshotted as the nested object it is, read out of the flattened - * columns the row actually carries - so a snapshot never mentions `seoTitle`, - * and a later rename of the column-naming rule cannot invalidate the history. A - * nullable group whose every leaf is empty is `null`, exactly as a read of it - * would be. - * - * A collection is snapshotted as **identity**: a to-many relation as its ids in - * stored order, a repeatable as its children each keyed by its own `id`. That is - * what makes a restore able to put the same rows back rather than copies of - * them, and what keeps one record's history from carrying another record's data. - */ const toFieldSnapshot = ( name: string, fieldValue: ContentFieldMap[string], @@ -110,23 +88,6 @@ const toFieldSnapshot = ( return toSnapshotValue(values[name]); }; -/** - * Builds the snapshot stored on a revision. - * - * Deterministic: field names are emitted in the content type's own declaration - * order, so two equal states serialise byte for byte and a diff test is a table - * rather than a set comparison. - * - * **Shared fields only.** A localized field is not a column on the base row, so - * recording it here would write `null` for every language at once - and restoring - * that snapshot would then try to blank a column the base table does not have. - * Each language's values are snapshotted by - * {@link contentTranslationRevisionSnapshot} instead, against its own history. - * - * The publication columns are recorded but are *not* restorable - they are - * absent from `schemas.update`, so a restore structurally cannot move them. - * They are here so the history can show what the lifecycle was at the time. - */ export const contentRevisionSnapshot = ( definition: AnyContentTypeDefinition, row: object, @@ -168,16 +129,6 @@ export const contentRevisionSnapshot = ( return snapshot; }; -/** - * A snapshot, shaped like the row it was taken from. - * - * Flat rather than nested, because the public projector reads a row by column - * name and must not learn that a preview exists - one projection, one - * allowlist, no second code path where a private field could slip through. - * - * Timestamps stay ISO strings. Hono serialises a `Date` to exactly that, so the - * response body is byte-identical to a live read. - */ export const contentSnapshotRow = ( snapshot: ContentRevisionSnapshot, ): Record<string, unknown> => ({ @@ -188,18 +139,6 @@ export const contentSnapshotRow = ( updatedAt: snapshot.updatedAt, }); -/** - * The part of a snapshot a restore may apply: currently declared fields only. - * - * A field the content type has since dropped is ignored rather than rejected - - * the snapshot is a record of the past, and the past is allowed to mention - * things that no longer exist. A field added since is simply absent, so the - * record keeps whatever it holds now. - * - * The generated columns are never projected. `id`, `version` and the timestamps - * belong to the row's identity, and `status`/`publishedAt` are lifecycle state - * that only publish and unpublish may move. - */ export const projectRevisionSnapshot = ( definition: AnyContentTypeDefinition, snapshot: ContentRevisionSnapshot, @@ -214,15 +153,6 @@ export const projectRevisionSnapshot = ( ); }; -/** - * The restorable half of a snapshot, for one set of currently declared fields. - * - * Shared by the base and translation projections rather than written twice: the - * schema-evolution rules are identical on both sides - a field or a **leaf** the - * content type has since dropped is ignored, one added since is absent - and two - * copies of that rule is the pair where a localized group ends up restoring - * something a shared one would not. - */ const projectSnapshotFields = ( restorable: ContentFieldMap, stored: Record<string, ContentSnapshotValue>, @@ -274,18 +204,6 @@ const pickLeaves = ( leaves.filter(leaf => leaf in values).map(leaf => [leaf, values[leaf]]), ); -/** - * Builds the snapshot stored on a *translation* revision. - * - * The localized half of {@link contentRevisionSnapshot}, and the split is a - * containment boundary as much as a modelling one: a translation snapshot that - * carried shared values would rewrite the whole record on a restore that asked - * for one language. - * - * `locale` is recorded alongside `languageId` because the revision row's language - * reference has no foreign key - a language can be deleted, and the history has - * to stay readable when it is. - */ export const contentTranslationRevisionSnapshot = ( definition: AnyContentTypeDefinition, row: object, @@ -338,16 +256,6 @@ export const contentTranslationSnapshotRow = ( version: snapshot.version, }); -/** - * The part of a translation snapshot a restore may apply: currently declared - * *localized* fields only. - * - * The same schema-evolution rules the shared projection follows - a field the - * content type has since dropped is ignored, one added since is absent - and the - * same exclusion of generated columns. `status` and `publishedAt` are lifecycle - * state that only publish and unpublish may move, so restoring field values never - * takes a translation off the internet or puts it on. - */ export const projectTranslationRevisionSnapshot = ( definition: AnyContentTypeDefinition, snapshot: ContentTranslationRevisionSnapshot, diff --git a/packages/vitnode/src/content/server/revisions-model.test.ts b/packages/vitnode/src/content/server/revisions-model.test.ts index 88ace8049..3e6b0ef25 100644 --- a/packages/vitnode/src/content/server/revisions-model.test.ts +++ b/packages/vitnode/src/content/server/revisions-model.test.ts @@ -82,13 +82,6 @@ const harness = ({ total }: { total: number }) => { }; }; -/** - * Reads the cursor value back out of the condition the model built. - * - * The model passes it as a bound parameter, so it turns up in the SQL's - * `queryChunks` as a plain number - which is enough to make the stub behave - * like a real exclusive `WHERE version < $cursor`. - */ const cursorOf = (condition: unknown): null | number => { const walk = (value: unknown): unknown[] => value !== null && typeof value === "object" && "queryChunks" in value diff --git a/packages/vitnode/src/content/server/revisions-model.ts b/packages/vitnode/src/content/server/revisions-model.ts index 4a5287579..b9b2d78e7 100644 --- a/packages/vitnode/src/content/server/revisions-model.ts +++ b/packages/vitnode/src/content/server/revisions-model.ts @@ -25,19 +25,7 @@ export interface ContentRevisionCaptureInput< > { actor: ContentActor; changedFields: readonly string[]; - /** - * `core_files` ids this snapshot names, pinned alongside the revision. - * - * The snapshot is JSONB, so the ids inside it are numbers Postgres knows - * nothing about: without a pin, pointing the field at a different file would - * make the old one deletable and every retained revision naming it a broken - * restore. The pin's own foreign key refuses the deletion instead, and - * cascades away when retention prunes the revision - see - * `core_content_file_refs`. - * - * Empty or absent for every content type with no file fields, which is what - * keeps this one statement rather than one per capture. - */ + fileIds?: readonly number[]; itemId: number; operation: ContentRevisionOperation; @@ -48,13 +36,6 @@ export interface ContentRevisionCaptureInput< } export interface ContentRevisionsModel<TSnapshot = ContentRevisionSnapshot> { - /** - * Writes one revision and prunes past the retention window. - * - * Takes the transaction explicitly rather than defaulting to the request - * handle: a revision that is not in the same transaction as the write it - * describes is a lie waiting to happen. - */ capture: ( tx: ContentDatabase, input: ContentRevisionCaptureInput<TSnapshot>, @@ -64,14 +45,7 @@ export interface ContentRevisionsModel<TSnapshot = ContentRevisionSnapshot> { revisionId: number, tx?: ContentDatabase, ) => Promise<ContentRevisionDetail<TSnapshot> | null>; - /** - * The newest revision in this scope, or `null` when there is no history. - * - * Takes an optional transaction for the same reason `findById` does: a caller - * deciding what version a write should start at has to read the history from - * inside the transaction that write is in, or it reads a number another writer - * is about to take. - */ + latest: ( itemId: number, tx?: ContentDatabase, @@ -83,14 +57,6 @@ export interface ContentRevisionsModel<TSnapshot = ContentRevisionSnapshot> { ) => Promise<ContentRevisionPage>; } -/** - * One page of history. - * - * `endCursor` is the **version** of the last row returned, not its id: version - * is what the query orders and filters by, it is unique per record, and it is - * strictly decreasing down the page. A revision id would be neither ordered nor - * dense once retention has pruned. - */ export interface ContentRevisionPage { edges: ContentRevisionMeta[]; pageInfo: { @@ -102,21 +68,6 @@ export interface ContentRevisionPage { export const CONTENT_REVISIONS_DEFAULT_PAGE_SIZE = 25; export const CONTENT_REVISIONS_MAX_PAGE_SIZE = 100; -/** - * Revision reads and writes for one content type, in one language scope. - * - * **Every** statement in here filters on `pluginId`, `contentTypeId`, `itemId` - * *and* `languageId`. A revision id on its own is never enough: the table is - * shared by every editorial content type in the install, so trusting an id would - * let a request for article 7 return - or restore - a revision belonging to some - * other plugin's record entirely. `languageId` joins that list for exactly the - * same reason one step down: without it, the Polish history could restore the - * English snapshot. - * - * `languageId` defaults to `null`, which is the shared scope - so every Stage 1-4 - * call site keeps the behaviour it had, reading and writing rows the two partial - * unique indexes treat as the non-localized history. - */ export const createContentRevisionsModel = < TSnapshot = ContentRevisionSnapshot, >({ @@ -302,14 +253,6 @@ export const createContentRevisionsModel = < }; }; -/** - * Removes revisions whose content type is no longer registered. - * - * Retention pruning happens inline, in the write's own transaction, so this - * handles only the case that one structurally cannot: a content type that - * dropped `editorial`, or a plugin that went away. Nothing will ever write to - * those rows again, so nothing would ever prune them. - */ export const pruneContentRevisions = async ({ db, knownContentTypeIds, diff --git a/packages/vitnode/src/content/server/routes.test.ts b/packages/vitnode/src/content/server/routes.test.ts index c43ff1961..dc7b56ad3 100644 --- a/packages/vitnode/src/content/server/routes.test.ts +++ b/packages/vitnode/src/content/server/routes.test.ts @@ -48,13 +48,6 @@ const editorialPosts = createContentModel(testEditorialPostContentType); const PLUGIN_ID = "@vitnode/example"; const PREVIEW_SECRET = "unit-test-content-preview-secret-0123456789"; -/** - * Previewable, with no `pathTemplate`. - * - * The other branch of the preview URL: with no page in the web app to point - * at, the link has to resolve against the **API** origin instead, and the two - * origins are not the same host in a split deployment. - */ const noTemplateContentType = defineContentType({ id: "test.notemplate", tableName: "test_no_template", @@ -72,13 +65,6 @@ const noTemplateContentType = defineContentType({ }); const noTemplatePosts = createContentModel(noTemplateContentType); -/** - * A content type whose fields are not all on its row. - * - * A to-many reference lives on a junction table and a repeatable on a child - * table, so neither is in the selection `findRowById` builds - and the AdminCP - * form that edits them opens on this route. - */ const collectionContentType = defineContentType({ id: "test.collections", tableName: "test_collections", @@ -119,20 +105,8 @@ interface Harness { service: Record<string, ReturnType<typeof vi.fn>>; } -/** - * The two typed collection maps every service carries, empty. - * - * None of these fixtures declares an advanced field, and the generated routes - * reach for collections only through the ordinary create/update payload - so - * the maps have to exist and have nothing in them. - */ const noCollections = { relations: {}, repeatable: {} }; -/** - * Mounts the generated routes with the service and permission check stubbed, - * so each test drives the real Hono pipeline (validation, status codes, error - * mapping) without a database. - */ const harness = ({ allow = true }: { allow?: boolean } = {}): Harness => { const emitted: Harness["emitted"] = []; const service = { diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts index 336e5435d..d7667a976 100644 --- a/packages/vitnode/src/content/server/routes.ts +++ b/packages/vitnode/src/content/server/routes.ts @@ -75,11 +75,6 @@ const zodLabels = z.record(z.string(), z.string().nullable()); const zodOptions = z.object({ items: z.array( z.object({ - /** - * Present only for a `user` field, where an option is a person rather than - * a row with a name. Optional rather than a second response shape, so a - * client written before this still parses every option it gets. - */ avatarColor: z.string().optional(), /** * Present when the target declares `admin.colorField` - a blog category diff --git a/packages/vitnode/src/content/server/schedule-effects.ts b/packages/vitnode/src/content/server/schedule-effects.ts index bae48a276..63cabe47e 100644 --- a/packages/vitnode/src/content/server/schedule-effects.ts +++ b/packages/vitnode/src/content/server/schedule-effects.ts @@ -19,16 +19,6 @@ import { dispatchContentRevalidation } from "./revalidate-bridge"; import { recordContentScheduleEffectsError } from "./schedules-model"; import { isContentRowPublic } from "./search-document"; -/** - * The per-locale cache work one scheduled transition owes. - * - * Taken as a before-and-after pair rather than reasoned about, because the two - * differ only in the *base* row's publication state and every locale's answer - * follows from that plus its own translation - which is exactly what - * `contentPublicLocaleStates` already computes. Synthesising the previous base - * state is safe here in a way it would not be generally: a publish or unpublish - * writes no field values, so nothing else about the row moved. - */ const scheduledLocales = async ( c: Context, model: AnyContentModel, @@ -56,14 +46,6 @@ const scheduledLocales = async ( }); }; -/** - * Everything the announcements need, and nothing they have to re-read. - * - * Written when the transition commits and never consulted against the live - * record afterwards. That is the point: by the time this runs the record may - * have been edited again, and an event describing *that* state would be a - * second, wrong announcement of a publication that already happened. - */ export const contentScheduleEffectsPayloadSchema = z.object({ changedFields: z.array(z.string()), contentTypeId: z.string().min(1), @@ -85,13 +67,6 @@ export type ContentScheduleEffectsPayload = z.infer< typeof contentScheduleEffectsPayloadSchema >; -/** - * Turns the ISO strings a JSON payload carries back into `Date`s. - * - * The search document already accepts either, but the `published` event payload - * is typed `publishedAt: Date` - and a listener that reads it should not be able - * to tell whether the publish was clicked or scheduled. - */ const reviveDates = ( definition: AnyContentTypeDefinition, row: Record<string, unknown>, @@ -123,31 +98,6 @@ export interface ContentScheduleEffectsOutcome { status: "delivered" | "unregistered"; } -/** - * Delivers the announcements a committed scheduled transition owes everyone - * else: its event, its search document, and its cache invalidation. - * - * **Split from the transition on purpose.** Publishing is a database write that - * either committed or did not. Telling the world is three calls to systems a - * transaction cannot reach, any of which can be down for a minute. Retrying - * them together would re-run the publish - which is idempotent, so the second - * run would find nothing changed and skip the announcements entirely. That is - * exactly how a scheduled unpublish ends up permanently serving a cached page it - * should have expired, and it is the failure this task exists to remove. - * - * **All three have to land.** A failed event, a refused search write and a web - * origin that did not accept its invalidation are each enough to fail the run, - * and the reasons are combined into one `effectsError` so the AdminCP shows - * everything outstanding rather than whichever failed first. - * - * **Delivery is at-least-once.** A retry after a partial failure re-emits the - * event and re-writes the search document. Both of the latter are idempotent by - * construction - a search upsert and a cache expiry are the same operation - * however many times they run - but an event listener may see the same - * `published` twice, so a listener that must act once keys off the - * `scheduleId` the payload carries. There is no outbox and no exactly-once - * claim. - */ export const runContentScheduleEffects = async ( c: Context, payload: ContentScheduleEffectsPayload, diff --git a/packages/vitnode/src/content/server/schedules-model.ts b/packages/vitnode/src/content/server/schedules-model.ts index 5a94b7385..e2e6af4a5 100644 --- a/packages/vitnode/src/content/server/schedules-model.ts +++ b/packages/vitnode/src/content/server/schedules-model.ts @@ -26,21 +26,6 @@ export interface ClaimedContentSchedule { pluginId: string; } -/** - * Locks one schedule row and returns it only if it is still worth running. - * - * Four conditions, every one of them re-read from the database under - * `FOR UPDATE` rather than trusted from the queue payload: the row exists, it - * is still `pending`, its generation matches the one the task was dispatched - * with, and its time has come. Anything else returns `null`, and the task does - * nothing at all - which is exactly how a task left over from a cancelled or - * rescheduled plan stays harmless. - * - * Keyed by id alone, unlike every other query in this file, and that is safe - * for the one reason the others are not: it does not *trust* a scope, it - * **returns** one. The caller learns which plugin and content type the row - * belongs to from the row itself, under the lock. - */ export const claimContentSchedule = async ( tx: ContentDatabase, { @@ -85,20 +70,6 @@ export const claimContentSchedule = async ( }; }; -/** - * Records how a claimed schedule ended. - * - * Id-keyed like {@link claimContentSchedule}, and guarded by `expectedStatus` - * for a reason that is easy to miss: `cancelled` and `completed` are both - * terminal, so an unguarded write would let a stale worker turn a schedule an - * administrator cancelled into one that ran. The guard is `AND status = $x` in - * the same statement rather than a read followed by a write, so there is no - * window between checking and setting. - * - * Returns whether the row was in the expected state. `false` is a concurrency - * signal, never something to shrug at - the caller decides whether that means - * "somebody got there first, fine" or "this cannot happen, roll back". - */ export const settleContentSchedule = async ( db: ContentDatabase, scheduleId: number, @@ -129,14 +100,6 @@ export const settleContentSchedule = async ( return rows.length > 0; }; -/** - * Records why a schedule's post-commit effects have not been delivered yet. - * - * Deliberately **not** a status change. The publication itself succeeded and - * must stay `completed`; what failed is the announcement, and moving the row - * back to `pending` would republish something that is already live. Cleared on - * the retry that finally gets through. - */ export const recordContentScheduleEffectsError = async ( db: ContentDatabase, scheduleId: number, @@ -149,13 +112,6 @@ export const recordContentScheduleEffectsError = async ( }; export interface ContentSchedulesModel { - /** - * Marks a pending schedule cancelled, and says which one it was. - * - * `null` when there was no pending schedule with that id on that record - - * which the route turns into a 404 rather than a silent success, because - * "cancelled" and "there was nothing to cancel" are different answers. - */ cancel: ( itemId: number, scheduleId: number, @@ -184,15 +140,6 @@ export interface ContentSchedulesModel { /** How many past schedules the AdminCP panel shows alongside the pending ones. */ const HISTORY_LIMIT = 10; -/** - * Schedule reads and writes for one content type. - * - * Like the revisions model, **every** statement filters on `pluginId`, - * `contentTypeId` *and* `itemId`. The table is shared by every schedulable - * content type in the install, so a schedule id on its own proves nothing - and - * cancelling somebody else's publication would be a strange way to find that - * out. - */ export const createContentSchedulesModel = ({ c, definition, diff --git a/packages/vitnode/src/content/server/search-document.ts b/packages/vitnode/src/content/server/search-document.ts index 87c7c7a96..97da52a32 100644 --- a/packages/vitnode/src/content/server/search-document.ts +++ b/packages/vitnode/src/content/server/search-document.ts @@ -13,25 +13,6 @@ import { contentSearchUrl } from "../search"; const normalize = (value: unknown): string => typeof value === "string" ? value.replace(/\s+/g, " ").trim() : ""; -/** - * One configured search field, resolved to text. - * - * Three shapes, one rule - what a reader would see, in the order they would see - * it: - * - * - `"title"` is the value on the row; - * - `"seo.description"` is the leaf of a group, or nothing when the group is - * `null`; - * - `"faq.question"` is **every** child's leaf, joined in **position order**. - * Position order rather than insertion order, because position is what the - * page renders and an index that disagreed with the page would highlight the - * wrong entry. The join is a newline, so two entries never run together into a - * phrase neither of them contains. - * - * A relation is never here: `defineContentType` refuses one in every search - * slot, and indexing foreign keys as text would make a record match a number - * somebody typed into a search box. - */ const readSearchValue = ( values: Record<string, unknown>, name: string, @@ -67,14 +48,6 @@ const toTimestamp = (value: unknown): Date | null | string | undefined => { return value === null ? null : undefined; }; -/** - * Whether one row is currently publicly visible. - * - * {@link isContentPubliclyVisible} with the column coercion in front of it, so - * "is this public" and "can this be indexed" stay two separate questions - a - * published record whose title is blank is the first but not the second, and it - * needs its stale document removed rather than left alone. - */ export const isContentRowPublic = (row: object): boolean => { const values = row as Record<string, unknown>; @@ -84,28 +57,6 @@ export const isContentRowPublic = (row: object): boolean => { }); }; -/** - * Projects one content record into a search document. - * - * Returns `null` - not a partial document - whenever the record must not be - * indexed: search is off, the row is not publicly visible, or the title or slug - * is missing. Every caller treats `null` as "make sure nothing is indexed for - * this record", so there is one decision and not one per call site. - * - * Visibility is {@link isContentPubliclyVisible}, the same predicate the Server - * Actions use to decide which cache tags to expire. The engine has exactly two - * definitions of "public" - that one and `publishedCondition` in SQL - and this - * adds no third. - * - * Nothing outside `publicApi.fields` can reach the document: `defineContentType` - * has already proven every indexed field name is in that allowlist, so a private - * column cannot be read here even by mistake. - * - * `row` is `object` rather than `ContentSelect<TDefinition>` because columns are - * read by a name resolved at runtime, and an unresolved generic row type is not - * assignable to an index signature - typing it strictly would push a cast to - * every call site instead of keeping the one honest cast here. - */ export const contentSearchDocument = ( definition: AnyContentTypeDefinition, row: object, @@ -181,27 +132,6 @@ export const contentSearchDocument = ( }; }; -/** - * Projects one *translation* into a search document. - * - * A localized record is indexed **once per published translation**, and each - * document is the two halves of the page the reader would land on: shared values - * off the base row, localized ones off the translation. Indexing the base row - * alone would put one language in the index and rank every other one as a miss. - * - * Visibility is {@link isContentTranslationPubliclyVisible} - the base row *and* - * the translation both published - which is the same subordination the public - * read enforces in SQL. A translation is never indexed for a draft record, in any - * language. - * - * `createdAt` is this language's publication date when it has one, so "newest" - * sorts a late translation where it actually appeared rather than where its - * record did. - * - * Returns `null` for everything that must not be indexed, so every caller has one - * decision to make: "make sure nothing is indexed for this record in this - * language". - */ export const contentTranslationSearchDocument = ( definition: AnyContentTypeDefinition, { diff --git a/packages/vitnode/src/content/server/search-indexer.ts b/packages/vitnode/src/content/server/search-indexer.ts index 2d117fde6..ba1dc0375 100644 --- a/packages/vitnode/src/content/server/search-indexer.ts +++ b/packages/vitnode/src/content/server/search-indexer.ts @@ -46,15 +46,6 @@ const REQUIRED_COLUMNS = [ "publishedAt", ] as const; -/** - * Where each indexed value comes from. - * - * A rebuild cannot classify a search field by looking it up in the top-level - * field maps: `seo.description` is not a key in either of them, and - * `faq.question` is not a column at all. Resolving the paths once - here - is - * what lets both indexers select real columns, fold groups back into their - * logical shape, and batch the collections they actually need. - */ interface ContentSearchSources { /** Collection fields to batch-load for the page's parent ids. */ collections: string[]; @@ -68,17 +59,6 @@ interface ContentSearchSources { sharedGroups: ContentFieldMap; } -/** - * Splits the configured search fields by where their values actually live. - * - * Three destinations, and a path is the only way to tell two of them apart: - * - * - a **scalar** field is a column, on whichever table its `localized` flag says; - * - a **group leaf** is a column too, under its generated name, on the table the - * *group* moved to - localization is a property of the group, so one leaf can - * never be on the other side from its siblings; - * - a **collection leaf** is not a column anywhere. Its parent is batch-loaded. - */ const resolveSearchSources = ( definition: AnyContentTypeDefinition, ): ContentSearchSources => { @@ -130,14 +110,6 @@ const resolveSearchSources = ( return sources; }; -/** - * The shared collections for one rebuild page, in one batch per field. - * - * Keyed by parent id and deduplicated first, which is what makes the localized - * rebuild safe: a record with three published translations appears three times on - * a page, and loading its FAQ once rather than three times is the difference - * between a bounded query count and an N+1. - */ const loadSearchCollections = async ({ advanced, c, @@ -157,13 +129,6 @@ const loadSearchCollections = async ({ return await advanced.loadMany(unique, c.get("db"), wanted); }; -/** - * A generated indexer, pinned to the modern page contract. - * - * `SearchIndexer.load` also accepts the deprecated bare-array result, for - * hand-written indexers that predate it. A generated one never returns that, and - * saying so keeps the guarantee in the type rather than in a comment. - */ export interface ContentSearchIndexer extends SearchIndexer { load: ( c: Context, @@ -172,25 +137,6 @@ export interface ContentSearchIndexer extends SearchIndexer { ) => Promise<SearchIndexerPage>; } -/** - * Adapts one content type to the engine's {@link SearchIndexer} contract, so a - * full or per-collection rebuild can stream its published records. - * - * Registered automatically by `buildContentAdminModule` for every content type - * with `search: { enabled: true }` - manual indexers registered by a plugin are - * untouched and keep working exactly as before. - * - * Two properties matter for review: - * - * 1. **Only published rows are read.** `publishedCondition` is not a parameter - - * both queries `and` it in themselves, so there is no argument a caller could - * forget, and it is the same SQL predicate the public read layer uses. - * 2. **Only projected columns are read.** The `SELECT` is built from the - * configured search fields, all of which `defineContentType` has already - * proven are in `publicApi.fields`. A private column is never fetched, and - * column names are resolved into Drizzle columns rather than interpolated - * into SQL. - */ export const createContentSearchIndexer = < TDefinition extends AnyContentTypeDefinition, >( @@ -214,14 +160,6 @@ export const createContentSearchIndexer = < ]), ); - /** - * The keyset cursor, per request. - * - * A `WeakMap` keyed by the Hono context, exactly as the localized indexer - * does: the rebuild task calls `load` repeatedly within one request, and the - * entry is collected with it. A fresh request starts at the beginning, which - * is what a rebuild means. - */ const cursors = new WeakMap<Context, number>(); return { @@ -240,29 +178,6 @@ export const createContentSearchIndexer = < return row?.value ?? 0; }, - /** - * Keyset paging on `id`, not `OFFSET`. - * - * `OFFSET` was wrong twice over. It re-reads and discards every earlier row, - * so page 500 of a rebuild costs five hundred pages of work - and worse, the - * offset counts rows in a set that is *moving*: a record unpublished after - * page one shifts everything behind it forward by one, and the next - * `OFFSET 100` steps straight over a row nobody ever indexed. A rebuild that - * silently misses rows is the failure a rebuild exists to fix. - * - * `WHERE id > :last` has neither problem. It seeks on the primary key, and - * it is anchored to a value rather than to a position, so rows appearing or - * disappearing behind the cursor cannot move it. - * - * The `offset` argument stays in the signature because the - * {@link SearchIndexer} contract is shared with hand-written indexers; it is - * used only as the "this is a fresh pass" signal, exactly as the localized - * indexer uses it. - * - * `itemsRead` is the row count, not the document count. A published row with - * no usable title projects to nothing, and reporting that as "no items" - * would end the rebuild before the valid rows after it. - */ load: async (c, offset, limit) => { // The contract's only signal that this is a fresh pass rather than the // next page of one. @@ -316,28 +231,6 @@ export const createContentSearchIndexer = < }; }; -/** - * The localized rebuild: one document per **published translation**. - * - * Two things make it a different function rather than a flag on the one above: - * - * 1. **The unit of paging is a translation, not a record.** `itemsRead` has to - * count translation rows, or a record with three languages would advance the - * offset by one and the rebuild would read it again forever. - * 2. **Paging is keyset, not offset.** The cursor is `(itemId, languageId)`, - * which is the translation table's primary key, so a page can neither overlap - * nor skip while rows are being published underneath it - and Postgres seeks - * to it on the index rather than counting past every earlier row, which is - * what makes a rebuild of a large table finish in linear time. - * - * The `offset` the contract hands in is used only as a *position counter*: the - * cursor is derived from the previous page's last row and kept here, keyed by - * the request, so the contract stays unchanged for every existing indexer. - * - * Both halves of the visibility rule are in the query: the base row's published - * predicate and the translation's. A translation of a draft record is not read, - * so it can never be indexed. - */ export const createContentLocalizedSearchIndexer = < TDefinition extends AnyContentTypeDefinition, >( @@ -375,14 +268,6 @@ export const createContentLocalizedSearchIndexer = < const visible = (): SQL | undefined => and(publishedCondition(base), publishedCondition(translation)); - /** - * The keyset cursor, per request. - * - * A `WeakMap` keyed by the Hono context, for the same reason the language - * registry uses one: the rebuild task calls `load` repeatedly within one - * request, and the entry is collected with it. A fresh request starts at the - * beginning, which is what a rebuild means. - */ const cursors = new WeakMap< Context, { itemId: number; languageId: number } diff --git a/packages/vitnode/src/content/server/search-sync.localized.test.ts b/packages/vitnode/src/content/server/search-sync.localized.test.ts index 6e8afdc1a..bbc3d9ba3 100644 --- a/packages/vitnode/src/content/server/search-sync.localized.test.ts +++ b/packages/vitnode/src/content/server/search-sync.localized.test.ts @@ -54,14 +54,6 @@ beforeEach(() => { logged.length = 0; }); -/** - * The two deletes a localized content type has, and they are not the same call. - * - * `locale` present means one translation went away; absent means the record did. - * Reading the locale off the input is the whole distinction, and getting it wrong - * is not slower but wrong: deleting the Polish copy would empty the record out of - * the index in every language, and only the next rebuild would put it back. - */ describe("deleting a translation", () => { it("removes that language's document and no other", async () => { const outcomes = await syncContentLocalizedSearch(context(), model, { diff --git a/packages/vitnode/src/content/server/search-sync.test.ts b/packages/vitnode/src/content/server/search-sync.test.ts index df24826be..c108a7cd7 100644 --- a/packages/vitnode/src/content/server/search-sync.test.ts +++ b/packages/vitnode/src/content/server/search-sync.test.ts @@ -51,11 +51,6 @@ const draftRow = { status: "draft" as const, }; -/** - * The generated routes with the service, the search engine and the logger - * stubbed, so each case drives the real handler and asserts on what reached - * `c.get("search")`. - */ const harness = ({ logFails = false, model = searchable, diff --git a/packages/vitnode/src/content/server/search-sync.ts b/packages/vitnode/src/content/server/search-sync.ts index 6affc7c23..4a31df778 100644 --- a/packages/vitnode/src/content/server/search-sync.ts +++ b/packages/vitnode/src/content/server/search-sync.ts @@ -26,20 +26,8 @@ export type ContentSearchOperation = "create" | "delete" | "publish" | "restore" | "unpublish" | "update"; export interface ContentSearchSyncInput { - /** - * The record's advanced collections, when the content type indexes one. - * - * Passed in rather than loaded here, because this function is deliberately - * model-free: it takes a definition and a row. The effects layer already holds - * the model and reads them once, after commit, only when - * `contentSearchIndexesCollections` says a document is made of them. - */ advanced?: Record<string, unknown>; - /** - * `publish` / `unpublish` only: `false` when the record was already in the - * requested state, which means the index already agrees and there is nothing - * to do. - */ + changed?: boolean; /** * `update` and `restore` only. A write that touched no indexed field changes @@ -47,11 +35,7 @@ export interface ContentSearchSyncInput { */ changedFields?: readonly string[]; operation: ContentSearchOperation; - /** - * The plugin that owns the content type. Stamped on the document so a rebuild - * reproduces the same ownership; omit it and the request's plugin is used, - * which is only correct while the request belongs to the owner. - */ + pluginId?: string; /** The full row the mutation returned, including `status` and `publishedAt`. */ row: object; @@ -107,36 +91,6 @@ const decide = ( : "skip"; }; -/** - * Brings the search index in line with one content mutation. - * - * **Call it only once the database write has returned - never inside a - * transaction callback.** A rolled-back transaction would leave a document - * pointing at a record that does not exist, and the search index is not part of - * the transaction that could undo it. This is the same rule the Next cache - * invalidation follows, for the same reason. - * - * The generated admin routes call it for you. A direct `service.publish(id)` - * call does not, deliberately: it may be running inside a caller-provided - * transaction. Application code opts in explicitly, after commit: - * - * ```ts - * const result = await model.service(c).publish(id); - * if (result) { - * await syncContentSearch(c, articleContentType, { - * operation: "publish", - * changed: result.changed, - * row: result.row, - * }); - * } - * ``` - * - * A failing search engine never turns a successful write into a failed one. The - * error is logged with enough context to find the record, the outcome carries it - * for a caller that wants it, and a manual rebuild repairs the drift. That makes - * the index eventually consistent, with "eventually" bounded by the next publish - * or the next rebuild. - */ export const syncContentSearch = async ( c: Context, definition: AnyContentTypeDefinition, diff --git a/packages/vitnode/src/content/server/service.test.ts b/packages/vitnode/src/content/server/service.test.ts index ab223c1e6..fadf370a8 100644 --- a/packages/vitnode/src/content/server/service.test.ts +++ b/packages/vitnode/src/content/server/service.test.ts @@ -47,18 +47,9 @@ interface RecordedCall { op: string; } -/** - * A chainable stand-in for the Drizzle client. Each top-level `select`, - * `insert`, `update` or `delete` shifts the next queued result, and every - * builder call is recorded so tests can assert on the shape of the query. - */ const createDbMock = ( results: unknown[][], - /** - * Anything else the service reads off the context - `i18n`, for the locale a - * relation label is resolved in. Everything the Stage 1-8 suites drive needs - * only `db`, so the default keeps them byte-identical. - */ + variables: Record<string, unknown> = {}, ) => { const calls: RecordedCall[] = []; @@ -501,15 +492,6 @@ describe("content service", () => { }); }); - /** - * A relation whose target names a **localized** field as its title. - * - * `test.localized-category` keeps its `name` on `test_localized_categories_translations`, - * so the base table has nothing to join to and the id is all a plain label - * resolver could produce. What is asserted here is the generic fix: two joins - * onto the target's translation table - the reader's language and the - * target's own default - and a `coalesce` between them. - */ describe("a localized relation label", () => { /** `core_languages`, as the registry query returns it. */ const LANGUAGES = [ diff --git a/packages/vitnode/src/content/server/service.ts b/packages/vitnode/src/content/server/service.ts index b858f0a70..7511ad8a1 100644 --- a/packages/vitnode/src/content/server/service.ts +++ b/packages/vitnode/src/content/server/service.ts @@ -76,13 +76,7 @@ export type ContentListRow<TDefinition> = ContentSelect<TDefinition> & { export interface ContentPageInfo { count: number; - /** - * An opaque cursor for the last row on this page. - * - * It encodes the ordered tuple - the sort column's value *and* the row's - * identifier - so it is meaningless outside the ordering that produced it. - * Hand it back as `cursor`; never parse it. - */ + endCursor: null | string; hasNextPage: boolean; hasPreviousPage: boolean; @@ -102,14 +96,6 @@ export interface ContentFindManyArgs<TDefinition> { where?: SQL; } -/** - * The Drizzle client, or a transaction handle standing in for it. - * - * `$client` is omitted deliberately: a `PgTransaction` carries every query - * method the client does but not the raw driver handle, so naming the client - * type directly would make `db.transaction(async tx => service.update(id, v, - * { tx }))` - the whole point of the option - a type error. - */ export type ContentDatabase = Omit<Context["var"]["db"], "$client">; export interface ContentServiceOptions { @@ -118,31 +104,10 @@ export interface ContentServiceOptions { } export interface ContentUpdateResult<TDefinition> { - /** - * Canonical paths, not field names: a group reports the **leaves** that moved - * (`seo.description`), a scalar reports itself, and a collection reports - * itself whole. One vocabulary, so an event payload, a cache decision and a - * search decision are all made from the same strings. - */ changedFields: ContentChangedPath<TDefinition>[]; row: ContentSelect<TDefinition>; } -/** - * The typed collection API of one content type, on the **plain** service. - * - * Every mutating method is a read-modify-write that runs inside one transaction - * with the source row locked first, so two concurrent `add` calls merge instead - * of one overwriting the other. It goes through the same `update` an ordinary - * field edit does, which is what gives it the no-op rule and the `updatedAt` - * bump for free. - * - * What it does **not** do is write a revision or emit an event - the plain - * service never has, for a field edit either. Those belong to - * `model.editorialService(c)?.relations` and - * `model.editorialService(c)?.repeatable`, which additionally require an - * `expectedVersion` and answer a stale one with a structured conflict. - */ export interface ContentRelationMethods<TDefinition> { /** Adds one target. A target already present is a no-op. */ add: ( @@ -158,13 +123,7 @@ export interface ContentRelationMethods<TDefinition> { relatedItemId: number, options?: ContentWriteOptions, ) => Promise<ContentUpdateResult<TDefinition> | null>; - /** - * Rearranges the existing targets. - * - * Refuses a list that is not a permutation of what is stored - a reorder that - * silently added or dropped a target would be a `set` wearing a different - * name, and the caller would never find out which it got. - */ + reorder: ( itemId: number, relatedItemIds: readonly number[], @@ -179,13 +138,6 @@ export interface ContentRelationMethods<TDefinition> { } export interface ContentRepeatableMethods<TDefinition, TName> { - /** - * Appends one child. - * - * Typed from the repeatable's own leaves, so `{ question, answer }` compiles - * and `{ unknownField }` does not - the definition already carries the child - * shape, and a `Record<string, unknown>` here would throw it away. - */ create: ( itemId: number, values: ContentValuesOf<ContentInnerFieldsOf<TDefinition, TName>>, @@ -210,13 +162,7 @@ export interface ContentRepeatableMethods<TDefinition, TName> { childIds: readonly number[], options?: ContentWriteOptions, ) => Promise<ContentUpdateResult<TDefinition> | null>; - /** - * Replaces the whole list in one write - the operation an AdminCP form save - * actually needs, so saving a five-row FAQ is one request rather than five. - * - * A child with an `id` is updated in place and keeps it; one without is - * created. Anything absent is removed. - */ + set: ( itemId: number, rows: readonly ContentRepeatableInputRow< @@ -224,13 +170,7 @@ export interface ContentRepeatableMethods<TDefinition, TName> { >[], options?: ContentWriteOptions, ) => Promise<ContentUpdateResult<TDefinition> | null>; - /** - * Updates one child by its stable identifier. - * - * Partial, and over the repeatable's own leaves: naming a leaf the repeatable - * does not declare is a compile error rather than a value silently dropped by - * the strict schema at runtime. - */ + update: ( itemId: number, childId: number, @@ -239,20 +179,6 @@ export interface ContentRepeatableMethods<TDefinition, TName> { ) => Promise<ContentUpdateResult<TDefinition> | null>; } -/** - * Options for a collection mutation on the **plain** service. - * - * Deliberately identical to `ContentServiceOptions`: there is no - * `expectedVersion` here, because this service has no version column to guard on - * and would have had to ignore one. Concurrent writers are serialised by the - * source row's `SELECT ... FOR UPDATE` instead, so two `add` calls merge rather - * than one of them being rejected. - * - * Optimistic locking, revisions and events are the editorial service's - - * `model.editorialService(c)?.relations`, which takes a required - * `expectedVersion` and an `actor`. The two are separate objects rather than one - * that behaves differently depending on where it came from. - */ export type ContentWriteOptions = ContentServiceOptions; export interface ContentPublicationResult<TDefinition> { @@ -261,11 +187,7 @@ export interface ContentPublicationResult<TDefinition> { * was emitted, and nothing needs invalidating. */ changed: boolean; - /** - * When the row was first published, or `null` if it never has been. Lifted - * out of `row` because the generated columns are conditional on a type - * parameter that is still open in generic route code. - */ + publishedAt: Date | null; row: ContentSelect<TDefinition>; } @@ -286,13 +208,6 @@ export interface ContentPublicationMethods<TDefinition> { ) => Promise<ContentPublicationResult<TDefinition> | null>; } -/** - * `publish`/`unpublish` exist only on a content type with publication enabled. - * - * The `never` branch is the same trick `ContentFieldsConstraint` uses for - * reserved system columns: calling `service.publish(...)` on a content type - * without publication is a compile error rather than a runtime surprise. - */ export type ContentService<TDefinition> = ContentServiceBase<TDefinition> & (TDefinition extends { publication: { enabled: true } } ? ContentPublicationMethods<TDefinition> @@ -304,15 +219,7 @@ export interface ContentServiceBase<TDefinition> { id: number, options?: ContentServiceOptions, ) => Promise<ContentAdvancedValues<TDefinition>>; - /** - * A named subset of the advanced collections, for a caller with an allowlist. - * - * The search synchronizer and the public projection each need only the - * collections their configuration actually mentions, and querying a private - * junction table to discard the rows afterwards is work with no answer - * attached. Untyped in its keys on purpose: the allowlist is derived from - * configuration at runtime, and `advanced` is the typed whole-record read. - */ + advancedFields: ( id: number, fields: readonly string[], @@ -331,12 +238,7 @@ export interface ContentServiceBase<TDefinition> { id: number, options?: ContentServiceOptions, ) => Promise<ContentSelect<TDefinition> | null>; - /** - * One record with its advanced collections attached. - * - * The read an edit form makes, and the only one that loads collections: a - * list must not, or it would issue a query per row. - */ + findDetail: ( id: number, options?: ContentServiceOptions, @@ -345,45 +247,18 @@ export interface ContentServiceBase<TDefinition> { edges: ContentListRow<TDefinition>[]; pageInfo: ContentPageInfo; }>; - /** - * One record **with its reference labels**, exactly as the list returns them. - * - * The read a form makes: a `relation` or `user` value is an identifier, and an - * editor has to be shown the name behind it. `findById` deliberately stays a - * plain row - the labels cost one LEFT JOIN per reference field, and the - * callers that only want the record should not pay for them. - * - * Administrative, like every label: it is read from the target's - * `admin.titleField`, which may name something the target never publishes. The - * public projection does not use it. - */ + findRowById: ( id: number, options?: ContentServiceOptions, ) => Promise<ContentListRow<TDefinition> | null>; - /** - * Options for a `user` or `relation` picker, to-one and to-many alike. - * - * `search` filters by whatever the label is actually read from. `ids` asks for - * exactly those rows instead, which is how a form that opens holding - * identifiers turns them into names: without it a to-many picker could only - * label what somebody had just searched for, and everything already stored - * would read as a number. - */ + options: ( field: ContentReferenceFieldName<TDefinition>, search?: string, ids?: readonly number[], ) => Promise<{ color?: string; label: string; value: number }[]>; - /** - * Typed to-many relation operations, keyed by the content type's **actual** - * relation collection names. - * - * A mapped type rather than a `Record<string, …>`: with the latter, - * `service.relations.thisFieldDoesNotExist` compiled and failed at runtime. - * Empty for a content type that declares none, so `service.relations` always - * exists and every key on it is one the definition has. - */ + relations: Record< ContentRelationCollectionName<TDefinition>, ContentRelationMethods<TDefinition> @@ -406,14 +281,6 @@ export interface ContentServiceBase<TDefinition> { ) => Promise<ContentUpdateResult<TDefinition> | null>; } -/** - * A typed repository bound to one request's database handle. - * - * Deliberately thin: it owns validation, column allowlisting, pagination and - * label joins, and leaves everything else to Drizzle. `model.table` stays - * public so advanced plugin code can drop down to the query builder at any - * point. - */ export const createContentService = < TDefinition extends AnyContentTypeDefinition, >({ @@ -424,13 +291,6 @@ export const createContentService = < schemas, table, }: { - /** - * The collection store, or nothing for a content type that declares none. - * - * Optional so every existing caller - and every test that builds a service by - * hand - keeps working unchanged; a service without one simply has no - * collections to read or write. - */ advanced?: ContentAdvancedStore; c: Context; columns: Record<string, PgColumn>; @@ -493,14 +353,6 @@ export const createContentService = < fields, ); - /** - * The keyed collection maps, assembled after the service object exists. - * - * Built as loose records and re-typed once at the boundary: the public type is - * keyed by the content type's actual collection names, which is what makes - * `service.relations.typo` a compile error - but a loop cannot prove to - * TypeScript that it filled exactly those keys. - */ const mutableRelations: Record< string, ContentRelationMethods<TDefinition> @@ -516,15 +368,6 @@ export const createContentService = < const ownSelection = (): Record<string, PgColumn> => Object.fromEntries(ownColumnNames.map(name => [name, columns[name]])); - /** - * A database row, in the logical shape callers see. - * - * The generated columns pass straight through; the declared fields go through - * `contentColumnsToValues`, which folds `seoTitle` and `seoDescription` back - * into `seo: { title, description }` - or into `seo: null` when the group is - * nullable and every leaf is empty. For a content type with no group this is - * a copy, which is why a Stage 1-5 row comes back byte-identical. - */ const projectRow = ( row: Record<string, unknown>, ): Record<string, unknown> => { @@ -557,27 +400,12 @@ export const createContentService = < return { ...projectRow(values), labels } as ContentListRow<TDefinition>; }; - /** - * The locale the AdminCP is being *read* in, or nothing. - * - * `undefined` outside a request that carries the i18n model - a queue handler, - * a test harness - and the fallback then does the whole job, which is the - * honest answer when nobody has said what language they are in. - */ const viewerLocale = (): string | undefined => { const i18n: undefined | { resolveLocale: () => string } = c.get("i18n"); return i18n?.resolveLocale(); }; - /** - * `core_languages.id` for the reader's locale, and for each localized - * target's own default. - * - * One registry read for the whole request - `findContentLanguage` memoises it - * per context - and none at all for a content type with no localized relation - * target, which is every content type that existed before this. - */ const labelLanguages = async (): Promise<{ byDefaultLocale: Map<string, null | number>; viewer: null | number; @@ -618,17 +446,6 @@ export const createContentService = < searchColumns: PgColumn[]; } - /** - * What a reference field's label is selected as, and the joins that make it - * so. - * - * A shared title is the column it always was. A **localized** one is - * `coalesce(reader's language, target's default language)` over two joins onto - * the target's translation table - each on `(itemId, languageId)`, which is - * that table's primary key, so neither can multiply the rows of the query it - * is added to. With neither language present in `core_languages` the id comes - * back, exactly as it did before. - */ const labelSelection = ( target: ContentPickerTarget, languages: { diff --git a/packages/vitnode/src/content/server/slug-history-model.ts b/packages/vitnode/src/content/server/slug-history-model.ts index 432fbbf36..43a6614b6 100644 --- a/packages/vitnode/src/content/server/slug-history-model.ts +++ b/packages/vitnode/src/content/server/slug-history-model.ts @@ -10,13 +10,6 @@ import { core_content_slug_history } from "../../database/content"; import { contentDeliveryPath } from "../delivery"; import { ContentDeliverySlugReserved } from "../errors"; -/** - * One retired or current public address of one record. - * - * The AdminCP shows these, the resolver reads them and an audit reads them long - * after the record is gone. `retiredAt === null` means "this is the address the - * record answers to now"; anything else is a URL that redirects to it. - */ export interface ContentSlugHistoryEntry { createdAt: Date; itemId: number; @@ -28,14 +21,6 @@ export interface ContentSlugHistoryEntry { slug: string; } -/** - * One address of one record, as every write names it. - * - * `languageId` is the storage key and `locale` is what an error message says out - * loud - both, because the two are needed at different layers and deriving one - * from the other here would mean a language lookup inside a transaction that - * already knows the answer. - */ export interface ContentSlugHistoryTarget { itemId: number; /** `null` for a shared slug - see `core_content_slug_history`. */ @@ -45,56 +30,12 @@ export interface ContentSlugHistoryTarget { slug: string; } -/** - * The persistence half of slug history: reservations in, lookups out. - * - * Every write takes the transaction it should run in, and none of them opens one. - * That is the whole design constraint: the slug mutation, the reservation and the - * revision have to commit or roll back together, so this module can never be the - * thing that decides when that happens. `editorial-service` and - * `translation-editorial-service` own the transaction and call in. - * - * There is deliberately no `delete`. A retired URL is somebody's bookmark, and - * removing the row would let unrelated content inherit it - so the only way - * history shrinks is a deliberate, permissioned AdminCP action, which Stage 8 does - * not ship. - */ export interface ContentSlugHistoryModel { - /** - * Refuses a slug that another record's history already owns. - * - * Called **before** the write it guards, so an editor is told at save time - * rather than at publish time - and so the failing transaction has done as - * little as possible. A slug this same record already owns is fine: moving from - * `b` back to `a` re-activates its own retired reservation rather than colliding - * with it. - */ assertAvailable: ( tx: ContentDatabase, args: ContentSlugHistoryTarget, ) => Promise<void>; - /** - * Records an address the caller can **prove** was live, if it is not on file. - * - * The lazy half of slug history, and the reason Stage 8 needs no backfill - * migration. A record published before this table existed has no row at all, so - * the first mutation that moves it off that address would find nothing to - * retire - and `/articles/hello` would be lost the moment somebody renamed it. - * A global backfill is not the answer: it would have to guess which historical - * values were ever public, and a draft's discarded slug must never become a - * redirect. The mutation itself does not have to guess. It is holding the row on - * both sides of its own write, so `wasPublic` is evidence rather than inference, - * and this is where that evidence is spent. - * - * Distinct from {@link reserve} in one deliberate way: a row that already exists - * is left **exactly** as it is, retired or not. This establishes a missing fact; - * it never brings a retired address back into service, which is `reserve`'s job - * and only correct when the record really is live there. - * - * Idempotent, transactional, and throws {@link ContentDeliverySlugReserved} when - * another record owns the address. Writes nothing but the row: no event, no - * cache tag, no search document. - */ + ensureCurrent: ( tx: ContentDatabase, args: ContentSlugHistoryTarget & { @@ -102,38 +43,17 @@ export interface ContentSlugHistoryModel { path: string; }, ) => Promise<{ created: boolean }>; - /** - * Every address one record has ever had, newest first. - * - * Scoped by language when one is given, which is what makes the AdminCP's Polish - * tab show Polish URLs and nothing else. - */ + list: ( args: { itemId: number; languageId?: null | number; limit?: number }, database?: ContentDatabase, ) => Promise<ContentSlugHistoryEntry[]>; - /** - * The record a retired (or current) address belongs to, or `null`. - * - * The resolver's one lookup, and the reason the two partial unique indexes lead - * with `(contentTypeId, slug)`: this runs on a public request path for a URL that - * is very often a typo, so it has to be an index hit rather than a scan. - */ + owner: ( args: { languageId: null | number; slug: string }, database?: ContentDatabase, ) => Promise<ContentSlugHistoryEntry | null>; - /** - * Records one slug as the record's **current** public address. - * - * Idempotent: a republish of an unchanged slug re-activates the row it already - * has rather than inserting a second one, which is what keeps a retried queue - * task and a double-clicked publish button harmless. - * - * Throws {@link ContentDeliverySlugReserved} when another record owns the - * address, whichever order two concurrent writers arrive in - see the `claim` - * helper for why that is an insert rather than a check. - */ + reserve: ( tx: ContentDatabase, args: ContentSlugHistoryTarget & { @@ -141,14 +61,7 @@ export interface ContentSlugHistoryModel { path: string; }, ) => Promise<{ created: boolean }>; - /** - * Stamps one of a record's own addresses as no longer current. - * - * `{ retired: true }` only when a row was actually there and actually active, - * which is precisely the "this URL was publicly addressable" test: a draft whose - * slug was corrected three times before it was ever published has no row to - * retire, so it creates no redirect and emits no event. - */ + retire: ( tx: ContentDatabase, args: Omit<ContentSlugHistoryTarget, "locale">, @@ -157,14 +70,6 @@ export interface ContentSlugHistoryModel { const HISTORY_LIST_LIMIT = 50; -/** - * The language predicate, written the one way that is correct for both cases. - * - * `IS NULL` for a shared slug and `=` for a localized one: `languageId = NULL` is - * `NULL` in SQL, never `true`, so an equality comparison would silently match no - * shared row at all - and a shared reservation that matches nothing is a - * reservation that reserves nothing. - */ const languageCondition = ( languageId: null | number, column: typeof core_content_slug_history.languageId, @@ -229,29 +134,6 @@ export const createContentSlugHistoryModel = ({ return row ? toEntry(row) : null; }; - /** - * Takes one address for one record, or says who already has it. - * - * **Insert first, ask afterwards**, and that ordering is the whole point. - * `SELECT ... FOR UPDATE` locks rows that exist; there is no such thing as - * locking a row that does not. So two transactions reserving one *previously - * unseen* address both read nothing, both decide the address is free, and both - * insert - one of them straight into a `23505` from the partial unique index. - * The database stays consistent, but the contract does not: the loser gets a raw - * driver failure that the shared mapper reads as a generic unique clash, where - * Stage 8 promises `CONTENT_DELIVERY_SLUG_RESERVED` naming the slug and the - * locale. Which of the two a caller saw depended on timing. - * - * `ON CONFLICT DO NOTHING` moves the decision into the one place that can make - * it. The second insert takes a speculative-insertion lock, waits for the first - * transaction to finish, and then either does nothing (it committed) or inserts - * after all (it rolled back). `RETURNING` reports which happened, so the loser is - * identified rather than caught, and the address is never claimed by two writers. - * - * The follow-up read still takes `FOR UPDATE`: the row exists by then, and - * locking it is what serialises this claim against a concurrent `retire` of the - * same row. - */ const claim = async ( tx: ContentDatabase, { @@ -374,13 +256,6 @@ export const createContentSlugHistoryModel = ({ }; }; -/** - * The current address of several records at once, keyed by identifier. - * - * Batched rather than one query per record, because the AdminCP list and a sitemap - * page both want a whole page's worth - and the alternative is the classic query - * per row that only shows up as a problem in production. - */ export const contentSlugHistoryCurrentPaths = async ( database: ContentDatabase, { @@ -413,15 +288,6 @@ export const contentSlugHistoryCurrentPaths = async ( return new Map(rows.map(row => [row.itemId, row.path])); }; -/** - * The path one slug produces, or the empty string when it produces none. - * - * A thin wrapper over {@link contentDeliveryPath} for the write paths, which have - * to store *something* in a `NOT NULL` column. An unbuildable path means the slug - * was never addressable, so the caller does not reserve it at all - and this - * returning `""` rather than throwing keeps that decision in the caller where the - * surrounding transaction is. - */ export const contentSlugHistoryPath = ({ definition, locale, diff --git a/packages/vitnode/src/content/server/slugs.ts b/packages/vitnode/src/content/server/slugs.ts index 3e5af63eb..2718afc88 100644 --- a/packages/vitnode/src/content/server/slugs.ts +++ b/packages/vitnode/src/content/server/slugs.ts @@ -34,25 +34,12 @@ export interface ContentSlugNormalizer { withUpdateSlugs: (patch: Record<string, unknown>) => Record<string, unknown>; } -/** - * The slug rules, in one place. - * - * Shared by the plain service and the editorial one rather than duplicated: a - * restore writes through the same normalisation an update does, and two copies - * of "never re-derive on update" is exactly the pair that drifts. - */ export const createSlugNormalizer = ( contentTypeId: string, fields: ContentFieldMap, ): ContentSlugNormalizer => { const slugFields = slugFieldsOf(fields); - /** - * Normalises a slug and refuses one that folds to nothing. - * - * Nothing random or numeric is appended - `slugify` is deterministic, and - * uniqueness belongs to the unique index, which surfaces a clash as a 409. - */ const toSlug = ( slugField: SlugFieldConfig, value: string, diff --git a/packages/vitnode/src/content/server/table.ts b/packages/vitnode/src/content/server/table.ts index cb5693041..7934765c7 100644 --- a/packages/vitnode/src/content/server/table.ts +++ b/packages/vitnode/src/content/server/table.ts @@ -37,17 +37,6 @@ import { buildSystemColumns, } from "./column-builders"; -/** - * Wraps a foreign-key thunk so the table it actually points at is checked - * against the one the descriptor promised. - * - * A `relation` carries its target twice - `field.relation({ target })` in the - * client-safe descriptor, and `references: { field: () => table.id }` in the - * database module - and nothing else stops the two from drifting apart. The - * check reads Drizzle's own table metadata rather than parsing SQL, and it - * stays *inside* the thunk on purpose: evaluating either side eagerly would - * break the circular imports the thunks exist to solve. - */ const checkedReference = ( contentTypeId: string, name: string, @@ -124,18 +113,6 @@ const resolveReference = ( ); }; -/** - * Builds the `pgTable` for a content type. - * - * The result is an ordinary Drizzle table: `drizzle-kit` discovers it by - * runtime identity (`is(value, PgTable)`) when it globs the plugin's built - * `dist/src/database/*.js`, so migrations stay generated and source-controlled - * exactly as they are for hand-written tables. - * - * Do not import this module from a client component - and do not add - * `server-only` to it either: its `default` export throws under plain Node, - * which both `apps/api` and `drizzle-kit` are. - */ export const createContentTable = < TDefinition extends AnyContentTypeDefinition, >( @@ -219,31 +196,12 @@ export const createContentTable = < ) as unknown as ContentTableFor<TDefinition>; }; -/** - * Forces every foreign key on the table to resolve. - * - * Drizzle keeps a foreign key as an unevaluated thunk until it serializes the - * table, which is what lets two content types reference each other. Calling - * this once from `buildContentAdminModule` - after every `src/database/*.ts` - * has finished loading - turns a target mismatch into a boot-time failure - * rather than a surprise on the first request. - */ export const assertContentReferences = (table: PgTable): void => { for (const foreignKey of getTableConfig(table).foreignKeys) { foreignKey.reference(); } }; -/** - * Column name -> Drizzle column, for allowlisted filters and ordering. - * - * A group's leaves appear twice, under the generated column name *and* under the - * canonical path: `columns["seoTitle"]` and `columns["seo.title"]` are the same - * `PgColumn`. That alias is what lets a filter, an `orderBy`, a search - * projection and an index all be configured in one vocabulary - paths - without - * every one of them learning the column-naming rule. There is still exactly one - * mapping, and it is the one `contentLeafColumnName` defines. - */ export const contentTableColumns = < TDefinition extends AnyContentTypeDefinition, >( diff --git a/packages/vitnode/src/content/server/translation-advanced-revisions.test.ts b/packages/vitnode/src/content/server/translation-advanced-revisions.test.ts index 996b0662a..90bbfa054 100644 --- a/packages/vitnode/src/content/server/translation-advanced-revisions.test.ts +++ b/packages/vitnode/src/content/server/translation-advanced-revisions.test.ts @@ -13,16 +13,6 @@ import { } from "./revision-snapshot"; import { createContentTranslationEditorialService } from "./translation-editorial-service"; -/** - * Localized groups in translation revisions. - * - * Stage 6 taught the *base* snapshot about groups and left the translation one - * running every localized field through the scalar coercion - which returns - * `null` for an object. So a translation revision recorded `seo: null` for every - * record that had SEO, and restoring one blanked it. Every test here fails on - * that implementation. - */ - const PLUGIN_ID = "@vitnode/example"; const ACTOR = { type: "staff" as const, userId: 1 }; const definition = testAdvancedLocalizedContentType; diff --git a/packages/vitnode/src/content/server/translation-editorial-service.test.ts b/packages/vitnode/src/content/server/translation-editorial-service.test.ts index 842cfc48a..24034b90c 100644 --- a/packages/vitnode/src/content/server/translation-editorial-service.test.ts +++ b/packages/vitnode/src/content/server/translation-editorial-service.test.ts @@ -28,14 +28,6 @@ let nextRevisionId = 100; let storedRevision: ContentTranslationRevisionSnapshot | null = null; let revisionLanguageId = 1; -/** - * The newest version each locale's history has ever reached. - * - * Keyed by language id, because that is how the history is scoped - and this is - * exactly what `create` has to consult: a translation row is deleted physically - * while its revisions are kept, so a recreated locale that started at 1 again - * would collide with the `create` revision its first life wrote. - */ const latestVersionByLanguage = new Map<number, number>(); // The revisions model is a real, tested unit of its own; what matters here is diff --git a/packages/vitnode/src/content/server/translation-editorial-service.ts b/packages/vitnode/src/content/server/translation-editorial-service.ts index e594e68f5..5be0e81f8 100644 --- a/packages/vitnode/src/content/server/translation-editorial-service.ts +++ b/packages/vitnode/src/content/server/translation-editorial-service.ts @@ -48,25 +48,11 @@ import { createContentRevisionsModel } from "./revisions-model"; import { createSlugNormalizer } from "./slugs"; import { CONTENT_TRANSLATION_INITIAL_VERSION } from "./translation-model"; -/** - * Everything the post-commit effects need about one translation mutation. - * - * The localized mirror of `ContentEditorialOutcome`, and `previousSlug` is - * load-bearing for the same reason: once the write returns, the old localized URL - * is gone, and invalidating the wrong locale's slug tag leaves a moved Polish page - * resolving at its old address. - */ export interface ContentTranslationEditorialOutcome<TDefinition> { /** `false` when nothing moved: no write, no revision, no event, no tags. */ changed: boolean; changedFields: ContentLocalizedFieldName<TDefinition>[]; - /** - * What this mutation did to **this locale's** public URL, or absent. - * - * Absent for every content type without `delivery`, and for one whose slug is - * shared - a shared slug is a column on the base row, so a translation mutation - * cannot move it and the base editorial service owns its history. - */ + delivery?: ContentDeliveryOutcome; languageId: number; /** The canonical `core_languages.code`, never the caller's casing. */ @@ -107,11 +93,7 @@ export interface ContentTranslationEditorialService<TDefinition> { values: ContentLocalizedValues<TDefinition>, options: ContentTranslationEditorialOptions, ) => Promise<ContentTranslationEditorialOutcome<TDefinition>>; - /** - * Removes one translation. Refuses the default locale, and refuses a version - * that moved - a delete is the widest possible overwrite, and a confirmation - * dialog cannot ask about a change the person has not seen. - */ + delete: ( itemId: number, locale: string, @@ -134,15 +116,7 @@ export interface ContentTranslationEditorialService<TDefinition> { locale: string, options: ContentTranslationEditorialTransitionOptions, ) => Promise<ContentTranslationEditorialOutcome<TDefinition> | null>; - /** - * Rolls one locale's *field values* back to an earlier revision of that same - * locale. - * - * Never crosses a locale, never touches shared fields, and never moves - * publication state. The historical version number is not restored either: the - * translation moves forward to a new version whose revision says where the - * values came from. - */ + restore: ( itemId: number, locale: string, @@ -168,21 +142,6 @@ export interface ContentRevisionDetailForLocale extends ContentRevisionMeta { snapshot: ContentTranslationRevisionSnapshot; } -/** - * The transactional editorial layer for translations. - * - * It holds exactly the rule the base editorial service holds, one row down: - * **the translation write, its version increment and its revision insert are one - * transaction, and nothing else is in it.** No event, no search call, no cache - * API - those run after the commit, in `contentTranslationEffects`, because a - * rolled-back transaction cannot un-send them. - * - * The data operations themselves are not duplicated here. `translation-model.ts` - * owns the conditional writes, the slug uniqueness and the default-translation - * invariant; this adds the transaction, the revision and the outcome the effects - * need. That split is what lets `localizedService.create` call the model inside - * somebody else's transaction without dragging a revision or an event along. - */ export const createContentTranslationEditorialService = < TDefinition extends AnyContentTypeDefinition, >({ @@ -216,14 +175,7 @@ export const createContentTranslationEditorialService = < } const { localizedFields } = partitionContentFields(definition.fields); - /** - * Every canonical path this locale owns: a scalar by its own name, a group by - * each of its leaves. - * - * What a create "changed", and the vocabulary the base half already reports - - * `seo.title` rather than `seo`, so a listener, a cache decision and the search - * synchronizer all read the same strings whichever half moved. - */ + const localizedPaths = Object.entries(localizedFields).flatMap( ([name, fieldValue]) => fieldValue.kind === "group" @@ -254,15 +206,6 @@ export const createContentTranslationEditorialService = < definition.delivery.slugScope === "localized"; const slugHistory = contentSlugHistoryFor({ c, definition, pluginId }); - /** - * The delivery half of one translation mutation, inside its transaction. - * - * The publication test is the **subordinated** one - the base row published *and* - * this translation published - because that is what makes a localized URL public. - * A published Polish translation of a draft article is not an address anybody can - * reach, so reserving its slug would hand out a permanent claim on a URL that was - * never live. - */ const applyDelivery = async ( tx: ContentDatabase, { @@ -315,13 +258,6 @@ export const createContentTranslationEditorialService = < }); }; - /** - * The publication state one translation held before a transition. - * - * The localized twin of the base service's `invert`, and correct for the same - * reason: a transition is guarded on the state it changes, so a `publish` that - * returned a row can only have found it unpublished. - */ const invertTranslation = ( operation: "publish" | "unpublish", row: ContentTranslationRow<TDefinition>, @@ -330,15 +266,6 @@ export const createContentTranslationEditorialService = < status: operation === "publish" ? "draft" : "published", }); - /** - * One locale's revision model. - * - * Built per call rather than cached, because the language it is scoped to comes - * out of the request. Everything inside it - the scope predicate, retention - * pruning, the cursor - is the shared implementation with `languageId` bound, - * so a locale's history is pruned to its own retention window rather than - * competing with every other language for the same fifty slots. - */ const revisionsFor = ( languageId: number, ): ContentRevisionsModel<ContentTranslationRevisionSnapshot> => diff --git a/packages/vitnode/src/content/server/translation-effects.test.ts b/packages/vitnode/src/content/server/translation-effects.test.ts index 5af6d00ea..359d30afd 100644 --- a/packages/vitnode/src/content/server/translation-effects.test.ts +++ b/packages/vitnode/src/content/server/translation-effects.test.ts @@ -9,14 +9,6 @@ import type { ContentTranslationEditorialOutcome } from "./translation-editorial import { contentTranslationEffects } from "./translation-effects"; -/** - * The event transport, recording rather than delivering. - * - * Typed loosely on purpose: what these tests assert is the *name* and the - * *payload* the effects choose, and pinning the emitter's signature to the global - * event map would make the assertions depend on whether a plugin's `declare - * module` block happens to be in the program. - */ const emit = vi.fn< ( name: string, diff --git a/packages/vitnode/src/content/server/translation-effects.ts b/packages/vitnode/src/content/server/translation-effects.ts index fba7e2209..26a826d03 100644 --- a/packages/vitnode/src/content/server/translation-effects.ts +++ b/packages/vitnode/src/content/server/translation-effects.ts @@ -64,13 +64,6 @@ const payloadFor = ( }; export interface ContentTranslationEffectsOptions { - /** - * The model, for a content type with `search`. - * - * A translation mutation moves exactly one language's document, and finding it - * takes the base row and the translation table - neither of which this function - * is otherwise given. Optional so every Stage 5B caller compiles unchanged. - */ model?: AnyContentModel; /** The plugin that owns the content type, and therefore the event. */ pluginId: string; @@ -82,45 +75,12 @@ export interface ContentTranslationEffectsResult { * content type without `delivery`. */ delivery?: ContentDeliveryEffectsResult; - /** - * What the event transport reported, or `null` for a no-op outcome. - * - * Present rather than discarded because `EventsModel.emit` does not throw: - * `failures` is the only place a dead listener or a broker outage is visible. - * A failure here never rolls the committed mutation back - it cannot, the - * transaction is closed - which is exactly why the caller gets to see it. - */ + event: EventEmitResult | null; - /** - * What the index write reported, or `null` when there was none to do - a - * content type without `search`, or a no-op outcome. - * - * A one-element array at most: a translation mutation is one language. - */ + search?: ContentSearchSyncOutcome[]; } -/** - * Everything one translation mutation owes the rest of the system, once its - * transaction has committed. - * - * The localized counterpart of `contentEditorialEffects`, and it exists for the - * same reason: "which event does this operation emit" is a rule, and a rule copied - * into six route handlers is a rule that will disagree with itself. - * - * **Call it only after the write has returned - never inside the transaction.** A - * rollback cannot un-emit an event. - * - * A no-op outcome does nothing at all. That is what keeps a double-clicked publish - * button and an empty edit from each producing a second event. - * - * Search synchronisation is scoped to the locale that moved: one translation is - * one document, and rewriting the others would be work for a change none of them - * contains. A translation that must not be indexed has its document deleted for - * that language only. Cache invalidation is absent for the reason it is absent - * from the base effects too - it needs the Next runtime, which the API process does - * not have, so the Server Action owns it. - */ export const contentTranslationEffects = async ( c: Context, definition: AnyContentTypeDefinition, diff --git a/packages/vitnode/src/content/server/translation-http-errors.ts b/packages/vitnode/src/content/server/translation-http-errors.ts index 09e302c88..974fba442 100644 --- a/packages/vitnode/src/content/server/translation-http-errors.ts +++ b/packages/vitnode/src/content/server/translation-http-errors.ts @@ -32,27 +32,6 @@ export const contentTranslationConflict = ( ): HTTPException => new HTTPException(409, { res: Response.json(body, { status: 409 }) }); -/** - * Maps a translation write's failures onto HTTP. - * - * The five outcomes it separates are the whole point - a client that cannot tell - * them apart can only show "something went wrong": - * - * | Failure | Status | Code | - * | ------------------------------ | ------ | -------------------------------------- | - * | base record missing | 404 | - | - * | locale unknown | 404 | - | - * | locale disabled | 409 | `CONTENT_LANGUAGE_DISABLED` | - * | translation already exists | 409 | `CONTENT_TRANSLATION_EXISTS` | - * | version moved | 409 | `CONTENT_TRANSLATION_VERSION_CONFLICT` | - * | default translation delete | 409 | `CONTENT_DEFAULT_TRANSLATION_REQUIRED` | - * | localized slug taken | 409 | `CONTENT_TRANSLATION_UNIQUE_CONFLICT` | - * | localized slug reserved | 409 | `CONTENT_DELIVERY_SLUG_RESERVED` | - * - * Anything it does not recognise falls through to {@link rethrowAsHttpError}, - * which owns the Postgres constraint codes - so the driver's message, which can - * name columns, constraints and values, never reaches a client from here either. - */ export const withTranslationHttpErrors = async <TResult>( action: "create" | "delete" | "read" | "update", run: () => Promise<TResult>, diff --git a/packages/vitnode/src/content/server/translation-model.test.ts b/packages/vitnode/src/content/server/translation-model.test.ts index fd41b4ce7..ff3fafe8b 100644 --- a/packages/vitnode/src/content/server/translation-model.test.ts +++ b/packages/vitnode/src/content/server/translation-model.test.ts @@ -33,14 +33,6 @@ interface RecordedCall { op: string; } -/** - * A chainable stand-in for the Drizzle client, in the same shape - * `service.test.ts` uses. - * - * The first `select().from()` of a request is the language registry, so it is - * answered from `LANGUAGES` rather than from the queue - which keeps every test - * below queueing only the rows it actually cares about. - */ const createDbMock = (results: unknown[][], languages = LANGUAGES) => { const calls: RecordedCall[] = []; const queue = [...results]; @@ -121,12 +113,6 @@ const translationRow = (overrides: Record<string, unknown> = {}) => ({ ...overrides, }); -/** - * The translation model, narrowed once. - * - * `translationService` is `undefined` without localization, so TypeScript refuses - * the call until the check has been made. - */ const translations = (c: Context) => { const build = localized.translationService; if (!build) throw new Error("Expected a translation service."); @@ -438,17 +424,6 @@ describe("update", () => { }); }); -/** - * One rule for a language the install has switched off, stated in both - * directions. - * - * Adding content to a locale nothing renders is not useful, so `create`, - * `update` and `publish` all refuse it. Taking content *down* has to keep - * working - an administrator who has just disabled a language usually wants to - * unpublish or delete what is in it, and refusing would strand published pages in - * a locale nobody can edit. So `unpublish`, `delete` and the history reads accept - * it. - */ describe("a disabled language", () => { const publishedRow = (overrides: Record<string, unknown> = {}) => ({ ...translationRow({ languageId: 2, version: 2 }), diff --git a/packages/vitnode/src/content/server/translation-model.ts b/packages/vitnode/src/content/server/translation-model.ts index 3c0ab6090..a9f36d45f 100644 --- a/packages/vitnode/src/content/server/translation-model.ts +++ b/packages/vitnode/src/content/server/translation-model.ts @@ -47,22 +47,6 @@ export interface ContentTranslationOptions { tx?: ContentDatabase; } -/** - * The version a freshly inserted translation starts at. - * - * A symbol rather than a name, because there is exactly one caller that may set - * it and no way to reach it by accident: writing this key requires importing the - * symbol, which is a deliberate act rather than a plausible typo in an options - * object. Ordinary callers get version 1 and cannot ask for anything else. - * - * It exists because a translation row is deleted physically while its history is - * not. Recreating `(itemId, languageId)` at version 1 would collide with the - * `create` revision the *first* life of that translation wrote, and the locale's - * history would stop being a sequence. The editorial layer reads the last version - * this locale ever reached and starts the new row after it. - * - * @internal - */ export const CONTENT_TRANSLATION_INITIAL_VERSION: unique symbol = Symbol( "vitnode.content.translation.initialVersion", ); @@ -84,14 +68,6 @@ export interface ContentTranslationUpdateResult<TDefinition> { version: number; } -/** - * Publish and unpublish guard on the *state*, so `expectedVersion` is optional. - * - * Same rule the base row's transitions follow: publishing overwrites no field - * values, so requiring a version would fail the button whenever a colleague had - * fixed a typo, for no protection against a lost update. When one is supplied it - * is `AND`ed on top of the state guard rather than replacing it. - */ export interface ContentTranslationTransitionOptions extends ContentTranslationOptions { expectedVersion?: number; } @@ -103,38 +79,14 @@ export interface ContentTranslationTransitionResult<TDefinition> { version: number; } -/** - * One localized content type's translation repository. - * - * Deliberately low level. It writes translation rows and enforces the rules that - * belong to the data - per-locale versioning, the default-translation invariant, - * slug normalisation - and does **nothing else**: no event, no cache tag, no - * search document, no revision. Stage 5B orchestrates those on top, the same way - * `contentEditorialEffects` does for the base row today. A repository that - * emitted events could not be called inside somebody else's transaction, which is - * exactly what atomic create needs it to be. - */ export interface ContentTranslationModel<TDefinition> { - /** - * Inserts one translation at version 1. Throws if the locale already has one. - * - * The editorial layer may start it later than 1 - see - * {@link CONTENT_TRANSLATION_INITIAL_VERSION} - so a locale that has been - * deleted and recreated keeps one increasing history. - */ create: ( itemId: number, locale: string, values: ContentLocalizedValues<TDefinition>, options?: ContentTranslationCreateOptions, ) => Promise<ContentTranslationRow<TDefinition>>; - /** - * Removes one translation, guarded by its version. - * - * `null` when there is no such translation - the caller wanted it gone, and it - * is. Refuses the default locale outright: that translation is created with the - * record and is what makes "a record always resolves in some language" true. - */ + delete: ( itemId: number, locale: string, @@ -145,19 +97,7 @@ export interface ContentTranslationModel<TDefinition> { locale: string, options?: ContentTranslationOptions, ) => Promise<boolean>; - /** - * The **base** row's publication state, or `null` when the record is gone. - * - * Exposed because a translation's public reachability is subordinate to the - * record's: a published Polish translation of a draft article is not a public - * URL, so the delivery layer cannot decide whether to reserve an address without - * both halves. It lives here rather than in the editorial layer for the same - * reason `resolveLanguage` does - the base table is this repository's, and a - * second reader would be a second place the two could disagree. - * - * `{ publishedAt: null, status: undefined }` for a content type without - * publication, where a translation is visible as soon as the record is. - */ + findBasePublication: ( itemId: number, options?: ContentTranslationOptions, @@ -172,14 +112,7 @@ export interface ContentTranslationModel<TDefinition> { locale: string, options?: ContentTranslationOptions, ) => Promise<ContentTranslationRow<TDefinition> | null>; - /** - * One language's translation of **many** records, in a single query. - * - * What an admin list needs: a page of rows and the language it is being - * viewed in, resolved in one round trip rather than one per row. Records with - * no translation in that language are simply absent from the result - the - * caller pairs them back up by `itemId` and decides what a missing one means. - */ + findManyByLanguageId: ( itemIds: readonly number[], languageId: number, @@ -190,31 +123,12 @@ export interface ContentTranslationModel<TDefinition> { itemId: number, options?: ContentTranslationOptions, ) => Promise<ContentTranslationMeta<TDefinition>[]>; - /** - * Every translation of one record, values included, in **one** query. - * - * What the AdminCP edit form opens on. A form whose localized inputs each carry - * their own language switcher needs every language at once, and reading them - * one locale at a time would mean nine round trips to open one article. The - * language registry is read once for the whole set, exactly as - * {@link ContentTranslationModel.findManyForItem} does. - */ + findManyRowsForItem: ( itemId: number, options?: ContentTranslationOptions, ) => Promise<ContentTranslationRow<TDefinition>[]>; - /** - * Marks one translation published, idempotently. - * - * `null` when there is no such translation. `changed: false` when it was - * already published - no version bump, and therefore no revision, no event and - * no cache work either. `publishedAt` is stamped on the first transition and - * never rewritten, so a republish keeps the original date. - * - * Throws without `publication: { enabled: true }`: there is no column to move. - * Refuses a locale the install has switched off, exactly as `create` and - * `update` do - publishing into a language nothing renders is not useful. - */ + publish: ( itemId: number, locale: string, @@ -224,22 +138,12 @@ export interface ContentTranslationModel<TDefinition> { resolveDefaultLanguage: ( options?: ContentTranslationOptions, ) => Promise<ContentLanguage>; - /** - * One locale, resolved through the request's language registry, or a throw. - * - * Exposed so the editorial layer resolves a locale exactly the way the - * repository does - same cache, same case-insensitive match, same canonical code - * - rather than reaching into the resolver with its own arguments. - */ + resolveLanguage: ( locale: string, options?: { requireEnabled?: boolean; tx?: ContentDatabase }, ) => Promise<ContentLanguage>; - /** - * The mirror of {@link publish}. `publishedAt` is deliberately left alone, and - * a disabled locale is accepted: taking content down has to keep working after - * a language is switched off. - */ + unpublish: ( itemId: number, locale: string, @@ -257,13 +161,6 @@ export interface ContentTranslationModel<TDefinition> { const translationSystemFields: readonly string[] = CONTENT_TRANSLATION_SYSTEM_FIELDS; -/** - * A timestamp column as a `Date`, or `null`. - * - * The `postgres` driver hands timestamps back as strings on some paths (a raw - * `RETURNING` among them), and `publishedAt` is compared and formatted rather - * than only echoed - so it is normalised once here instead of at every reader. - */ const toNullableDate = (value: unknown): Date | null => { if (value instanceof Date) return value; if (typeof value !== "string") return null; @@ -348,14 +245,6 @@ export const createContentTranslationModel = < const db = (options?: ContentTranslationOptions): ContentDatabase => contentDatabase(c, options?.tx); - /** - * Resolves a locale, reading the language registry through whatever handle the - * caller is using. - * - * The `tx` is load-bearing: inside `localizedService.create` the transaction - * holds the connection, so a registry query issued on the client would wait for - * a connection that transaction will not release until it has an answer. - */ const language = async ( locale: string, { requireEnabled, tx }: { requireEnabled: boolean; tx?: ContentDatabase }, @@ -367,13 +256,6 @@ export const createContentTranslationModel = < tx, }); - /** - * The publication half of a row, or nothing. - * - * Read off the row rather than defaulted, so a content type without publication - * has no `status` key at all - a `"draft"` invented here would make - * `isContentRowPublic` answer a question this content type never asked. - */ const publicationOf = (row: Record<string, unknown>): object => publication ? { @@ -396,14 +278,6 @@ export const createContentTranslationModel = < version: row.version as number, }) as ContentTranslationMeta<TDefinition>; - /** - * Splits a raw row into metadata and `values`. - * - * The nesting is not decoration: it keeps a localized field called `version` or - * `locale` from being confused with the metadata of the row that holds it, and - * it means the update request body (`{ expectedVersion, values }`) and the - * response have the same shape. - */ const toRow = ( row: Record<string, unknown>, locale: string, @@ -436,13 +310,6 @@ export const createContentTranslationModel = < return row ?? null; }; - /** - * The `status` column, or a refusal. - * - * A content type without publication has no such column, so a publish call is a - * programming mistake rather than a runtime state - and `eq(undefined, ...)` - * would fail far from the cause with a Drizzle internal error. - */ const statusColumn = (): PgColumn => { if (!publication) { throw new ContentEngineError( @@ -454,15 +321,6 @@ export const createContentTranslationModel = < return columns.status; }; - /** - * Publish and unpublish, which guard on the *state* rather than the version. - * - * The state guard is what makes them idempotent, and idempotency is what keeps - * a double-clicked button and a retried task from each producing a second - * version, a second revision and a second event. Deliberately the same shape - * `transition` in the base editorial service uses - two locales' transitions - * touch two rows, so they never contend with each other. - */ const transition = async ( itemId: number, locale: string, diff --git a/packages/vitnode/src/content/server/translation-publication-routes.test.ts b/packages/vitnode/src/content/server/translation-publication-routes.test.ts index 647809549..019343b88 100644 --- a/packages/vitnode/src/content/server/translation-publication-routes.test.ts +++ b/packages/vitnode/src/content/server/translation-publication-routes.test.ts @@ -9,15 +9,6 @@ import { testStrictLocalizedPageContentType } from "@/tests/content-fixtures"; import { createContentModel } from "./model"; import { buildContentTranslationRoutes } from "./translation-routes"; -/** - * A localized content type with `publication` and **without** `editorial`. - * - * A legal combination since Stage 5B, and the one that used to have nowhere to - * go: the translation table grew `status` and `publishedAt`, and the routes that - * move them were gated on `editorial` - so the columns existed and nothing - * generated could change them. Publication is independent of revision history, - * and these tests are what says so. - */ const page = createContentModel(testStrictLocalizedPageContentType); const PLUGIN_ID = "@vitnode/example"; diff --git a/packages/vitnode/src/content/server/translation-routes.ts b/packages/vitnode/src/content/server/translation-routes.ts index 8d2300f29..8e8ff14b1 100644 --- a/packages/vitnode/src/content/server/translation-routes.ts +++ b/packages/vitnode/src/content/server/translation-routes.ts @@ -41,33 +41,6 @@ import { CONTENT_REVISIONS_MAX_PAGE_SIZE } from "./revisions-model"; import { contentTranslationEffects } from "./translation-effects"; import { withTranslationHttpErrors } from "./translation-http-errors"; -/** - * The generated translation routes for one localized content type. - * - * Identity is `(content type, item, locale)` and never the translation row's own - * key: `(itemId, languageId)` is the primary key, there is no surrogate id to - * leak, and a locale in the URL cannot be used to reach another content type's - * translation because the module the route is mounted in already fixes which - * table is being read. Locales are canonical strings on the outside and numeric - * `core_languages.id` values on the inside - a client never sends an id, so it can - * never point one at a language it was not shown. - * - * Permissions: - * - * | Route | Permission | - * | --- | --- | - * | read, history | `can_view` | - * | create, update | `can_edit` | - * | publish, unpublish | `can_publish` | - * | restore | `can_restore` | - * | delete | `can_delete` | - * - * `can_edit`, and no permission of its own: writing a locale is editing the - * record in that language, and a role that may edit an article may edit every - * language of it. Publishing, restoring and deleting keep their own gates, so a - * translation still cannot reach the internet or disappear on an edit - * permission alone. - */ export const buildContentTranslationRoutes = < TDefinition extends AnyContentTypeDefinition, P extends string, diff --git a/packages/vitnode/src/content/server/translation-table.ts b/packages/vitnode/src/content/server/translation-table.ts index 3b54243a4..527eac360 100644 --- a/packages/vitnode/src/content/server/translation-table.ts +++ b/packages/vitnode/src/content/server/translation-table.ts @@ -27,23 +27,6 @@ import { buildTranslationSystemColumns, } from "./column-builders"; -/** - * Builds the `pgTable` holding one localized content type's translations. - * - * One table per content type, not one shared table for the whole install. That - * is the entire design decision, and everything good about it follows from it: - * real column types, real `NOT NULL`, a real unique index on `(languageId, slug)`, - * Drizzle inference that knows what `title` is, and a migration `drizzle-kit` - * generates rather than a JSONB blob or an EAV table nobody can index. - * - * Like {@link createContentTable} the result is an ordinary Drizzle table, so - * `drizzle-kit` discovers it by runtime identity when it globs the plugin's built - * `dist/src/database/*.js`. Export it from the plugin's database module: - * - * ```ts - * export const example_articles_translations = articleContent.translationTable; - * ``` - */ export const createContentTranslationTable = < TDefinition extends AnyContentTypeDefinition, >( diff --git a/packages/vitnode/src/content/server/types.ts b/packages/vitnode/src/content/server/types.ts index c6fbf9219..c65d11148 100644 --- a/packages/vitnode/src/content/server/types.ts +++ b/packages/vitnode/src/content/server/types.ts @@ -34,13 +34,6 @@ type EnumValuesOf<TField> = TField extends { ? [THead, ...TRest] : [string, ...string[]]; -/** - * The Drizzle builder a single field descriptor compiles to. - * - * Builders carry no column-name generic: columns are declared with the - * `camelCase.table(name, t => ({ ... }))` callback form used across the repo, - * so Drizzle derives the name from the object key. - */ type BaseBuilderFor<TField> = TField extends { kind: "boolean" } ? PgBooleanBuilder : TField extends { kind: "dateTime" } @@ -127,13 +120,6 @@ export type ContentTranslationColumnBuilders< [K in keyof TFields]: ContentColumnBuilder<TFields[K]>; }; -/** - * The `pgTable` a localized content type's translations compile to. - * - * Built with Drizzle's own `BuildColumns`, exactly like {@link ContentTable}, so - * `$inferSelect` and `$inferInsert` come out of the same machinery a - * hand-written `pgTable` uses. - */ export type ContentTranslationTable< TName extends string, TFields, @@ -148,13 +134,6 @@ export type ContentTranslationTable< schema: undefined; }>; -/** - * The localized half of a field map, as a record. - * - * Spelled with a mapped type rather than `Pick` so the erased - * `AnyContentTypeDefinition` - whose localized name union is `never` - resolves - * to an empty record instead of to `never`. - */ type LocalizedFieldsOf<TDefinition> = ContentStorageFields<{ [ K in ContentLocalizedFieldName<TDefinition> & @@ -162,15 +141,6 @@ type LocalizedFieldsOf<TDefinition> = ContentStorageFields<{ ]: ContentFieldsOf<TDefinition>[K]; }>; -/** - * The translation table for one definition. - * - * `string` rather than the literal translation table name: that name is derived - * at *runtime* from `tableName` (suffixed, then clamped to 63 characters with a - * fingerprint), and re-deriving the clamp in the type system would be a second - * implementation of it. Nothing needs the literal - Drizzle only uses the name - * parameter to prefix column names it never exposes by literal type. - */ export type ContentTranslationTableFor<TDefinition> = TDefinition extends { publication: { enabled: infer TPublication extends boolean }; } @@ -181,13 +151,6 @@ export type ContentTranslationTableFor<TDefinition> = TDefinition extends { > : never; -/** - * Column name -> Drizzle column on the translation table. - * - * The publication pair is gated exactly like {@link ContentColumnName} gates it - * on the base table: a translation only carries `status` and `publishedAt` when - * the content type has a lifecycle for them to describe. - */ export type ContentTranslationColumnName<TDefinition> = | ContentLocalizedFieldName<TDefinition> | ContentTranslationSystemField @@ -195,12 +158,6 @@ export type ContentTranslationColumnName<TDefinition> = ? ContentPublicationField : never); -/** - * The `pgTable` a content type compiles to. - * - * Built with Drizzle's own `BuildColumns`, so `$inferSelect` and `$inferInsert` - * come out of the same machinery a hand-written `pgTable` uses. - */ export type ContentTable< TName extends string, TFields, @@ -227,16 +184,6 @@ type SharedFieldsOf<TDefinition> = { ]: ContentFieldsOf<TDefinition>[K]; }; -/** - * The type-level twin of `contentStorageColumns`. - * - * A field map, flattened into the columns it actually generates: scalars keep - * their names, a group contributes `seoTitle` per leaf, and the two collection - * kinds vanish because neither is a column here. Spelled out in the type system - * as well as at runtime because `$inferSelect` and `$inferInsert` are what a - * plugin's own hand-written queries are checked against - a table type that - * still said `seo: <group>` would type-check code Postgres then rejects. - */ export type ContentStorageFields<TFields> = GroupLeafColumnsOf<TFields> & ScalarFieldsOf<TFields>; @@ -250,18 +197,6 @@ type ScalarFieldsOf<TFields> = { ]: TFields[K]; }; -/** - * Every group leaf column of a field map, keyed by its generated column name. - * - * Written as **one** mapped type over the union of canonical paths rather than - * as a per-group union folded back with the usual `UnionToIntersection` trick. - * That trick puts the union in a function-parameter position, which makes - * `TDefinition` contravariant in `ContentTableFor` and therefore invariant in - * `ContentModel` - and an invariant `ContentModel<T>` is no longer assignable to - * `ContentModel<AnyContentTypeDefinition>`, which every route builder and every - * registry needs. It is the same trap `ResolvedContentAdminConfig` documents, - * reached from a different direction. - */ type GroupLeafColumnsOf<TFields> = { [ TPath in GroupLeafPathsOf<TFields> as ColumnNameOfPath<TPath> diff --git a/packages/vitnode/src/content/sitemap.test.ts b/packages/vitnode/src/content/sitemap.test.ts index 21d9c9a08..ebb172ebc 100644 --- a/packages/vitnode/src/content/sitemap.test.ts +++ b/packages/vitnode/src/content/sitemap.test.ts @@ -9,15 +9,6 @@ import { escapeXml, } from "./sitemap"; -/** - * Sitemap serialization, without a database. - * - * A sitemap is a document other people's parsers read, so the assertions here are - * mostly about bytes: valid XML, correct escaping, the elements the protocol - * defines and deterministic output. A malformed `<loc>` is not a cosmetic problem - - * a crawler may reject the whole file. - */ - const entry = ( overrides: Partial<ContentSitemapEntry> = {}, ): ContentSitemapEntry => ({ diff --git a/packages/vitnode/src/content/sitemap.ts b/packages/vitnode/src/content/sitemap.ts index 5c4a9cf30..6bad7001b 100644 --- a/packages/vitnode/src/content/sitemap.ts +++ b/packages/vitnode/src/content/sitemap.ts @@ -5,31 +5,12 @@ import { CONTENT_SITEMAP_MAX_URLS, } from "./const"; -/** - * Sitemap serialization, and nothing else. - * - * Deliberately separate from the queries that produce the entries: "which URLs - * are public right now" is a keyset scan over two tables, and "what does a - * sitemap file look like" is a string. Folding them into one function would make - * the XML untestable without a database and the pagination untestable without - * parsing XML - so the delivery service owns the first and this module owns the - * second. - * - * Client-safe and pure. No Drizzle, no Hono, no `next/*`. - */ - /** One line of a sitemap, as the delivery service produces it. */ export interface ContentSitemapEntry { /** One of the seven `changefreq` values, or `null` to omit the element. */ changeFrequency: ContentSitemapChangeFrequency | null; itemId: number; - /** - * When the representation at this URL last changed. - * - * For a localized entry that is `max(base.updatedAt, translation.updatedAt)`, - * because both halves are rendered into the page: a shared field moving changes - * every language's document even though no translation row was touched. - */ + lastModified: Date; /** The language this URL is in, or `null` for a nonlocalized content type. */ locale: null | string; @@ -38,14 +19,6 @@ export interface ContentSitemapEntry { priority: null | number; } -/** - * XML's five predefined entities, escaped in the one order that is correct. - * - * `&` **first**: escaping it after `<` would turn the `<` this function just - * produced into `&lt;`. A slug is percent-encoded by the path builder so this - * is rarely load-bearing, but a sitemap is a document other people's parsers read, - * and "rarely" is not a guarantee. - */ export const escapeXml = (value: string): string => value .replace(/&/g, "&") @@ -54,15 +27,6 @@ export const escapeXml = (value: string): string => .replace(/"/g, """) .replace(/'/g, "'"); -/** - * A path turned into the absolute URL a sitemap has to carry. - * - * The protocol requires absolute URLs, which is the one place delivery cannot - * stay origin-agnostic - so the origin is a required argument here rather than an - * option. A path that will not resolve against it comes back `null` and the entry - * is dropped: a sitemap with one malformed `<loc>` is a sitemap a crawler may - * reject whole. - */ const absolute = (origin: string, path: string): null | string => { try { return new URL(path, origin).toString(); @@ -74,20 +38,6 @@ const absolute = (origin: string, path: string): null | string => { /** `priority` at the one precision the protocol illustrates, without a float tail. */ const formatPriority = (priority: number): string => priority.toFixed(1); -/** - * A `<urlset>` document for one page of entries. - * - * `alternates` are opt-in and, when present, emitted as `xhtml:link` elements - - * the form the sitemap extension for `hreflang` actually defines, with the - * namespace declared on the root element and every alternate of a group repeated - * inside **each** of its `<url>` entries. That last rule is the one implementations - * get wrong, and it is why alternates are supplied per entry rather than derived: - * the caller has already resolved which translations are published, and this - * function does not go looking. - * - * Every entry is emitted in the order it was given, so two processes serializing - * the same page produce byte-identical documents. - */ export const contentSitemapXml = ({ alternates, entries, diff --git a/packages/vitnode/src/content/slug.ts b/packages/vitnode/src/content/slug.ts index 7d214c229..8f17099e0 100644 --- a/packages/vitnode/src/content/slug.ts +++ b/packages/vitnode/src/content/slug.ts @@ -1,14 +1,5 @@ import { CONTENT_SLUG_DEFAULT_LENGTH } from "./const"; -/** - * Letters Unicode normalisation cannot take apart. - * - * `NFD` splits a base letter from its combining marks, which handles nearly - * every accented character - but a stroked or ligature letter is one indivisible - * codepoint, so it survives the pass and would then be dropped as "not a-z". - * `ł` is the one the repo already special-cases in `removeSpecialCharacters`; - * the rest are its immediate neighbours. - */ const TRANSLITERATIONS: Record<string, string> = { æ: "ae", đ: "d", @@ -20,29 +11,6 @@ const TRANSLITERATIONS: Record<string, string> = { þ: "th", }; -/** - * Turns any text into a URL segment: lowercase, ASCII, dash separated. - * - * ```text - * "Hello World" -> "hello-world" - * " Hello World " -> "hello-world" - * "hello---world" -> "hello-world" - * "Zażółć gęślą" -> "zazolc-gesla" - * ``` - * - * Deterministic and pure - the same input always yields the same slug, on every - * machine and in every process. Nothing random and nothing numeric is appended: - * uniqueness is the unique index's job, and a collision surfaces as a 409 rather - * than as a silently different URL. - * - * Returns `""` for text that folds to nothing - CJK, Cyrillic, emoji. Callers - * treat that as a failure rather than inventing a fallback; see the service's - * slug handling. - * - * Not the same thing as `removeSpecialCharacters`, which does not lowercase and - * keeps characters that are illegal in a slug. That one stays where it is, for - * the blog's `friendlyUrl`. - */ export const slugify = ( value: string, maxLength: number = CONTENT_SLUG_DEFAULT_LENGTH, diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index e5170ac6d..916813969 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -53,16 +53,7 @@ export interface ContentFieldShared< > { /** Free text or an i18n key surfaced in AdminCP and OpenAPI. */ description?: string; - /** - * The value is stored per language, in the generated translation table rather - * than on the base table. - * - * Declared here - on every kind - so `fieldValue.localized` reads off the - * descriptor union without a narrowing dance. Only the three kinds in - * {@link CONTENT_LOCALIZED_FIELD_KINDS} accept it: the other builders do not - * take the argument at all, so `field.boolean({ localized: true })` is a - * compile error, and `defineContentType` refuses it again at runtime. - */ + localized?: boolean; /** Column accepts NULL, and `null` is a legal value. */ nullable: TNullable; @@ -91,26 +82,10 @@ export interface ContentTextField< unique?: boolean; } -/** - * Whether a slug has to be present in the create payload. - * - * Exactly the inverse of "it has a source": with one the engine can always - * derive the value, without one nobody else can. That makes `required` a - * consequence of `source` rather than a second knob, so `field.slug` does not - * take it - the two could otherwise be set to contradict each other. - */ export type ContentSlugRequired<TSource> = TSource extends string ? false : true; -/** - * A URL segment: lowercase, ASCII, dash separated, unique across the table. - * - * Never nullable and never defaulted - a row without a slug could not be - * addressed. `source` names the `text` field the value is derived from when a - * create payload leaves it out; an update never re-derives it, so published - * URLs stay put. - */ export interface ContentSlugField< TSource extends string | undefined = string | undefined, TLocalized extends boolean = boolean, @@ -175,87 +150,25 @@ export interface ContentEnumField< values: TValues; } -/** - * A reference to one stored file in `core_files`. - * - * The column is an `integer` foreign key with `ON DELETE RESTRICT`, and that is - * the whole storage model: the row holds an identifier, `core_files` holds the - * name, the size, the media type and the storage key, and the storage adapter - * holds the bytes. Nothing about the file is copied onto the content row, so a - * renamed or re-encoded object is not two facts that can disagree. - * - * `maxBytes` is **not** optional. A file field with no ceiling is a form that - * accepts a disk image, and a default here would be a number nobody chose - * applied to every field in every plugin. - * - * `allowedMimeTypes` and `allowedExtensions` are two rules rather than one - * spelled twice: the first is what the client *declared* the bytes are, the - * second is what the file is *called*. A `picture.gif` carrying `image/png` - * passes an extension-only check, which is why a strict field states both and - * both have to match. - * - * `multiple: true` moves the reference off the row into a generated junction - * table, exactly as it does for a `relation` - one row per file, with a - * `position`. The per-file rules do not change: `maxBytes` and both allowlists - * are checked against *each* entry, because a gallery of ten images is ten - * uploads rather than one bigger one. - */ export interface ContentFileField< TRequired extends boolean = boolean, TNullable extends boolean = boolean, TMultiple extends boolean = boolean, TOrdered extends boolean = boolean, > extends ContentFieldShared<TRequired, TNullable> { - /** - * Accepted file-name extensions, normalised to lowercase with a leading dot. - * - * Normalised by `field.file`, so `GIF`, `.gif` and `.Gif` are one rule. - * Omitted, any extension is accepted - the MIME list, the size and the storage - * adapter are still in force. - */ allowedExtensions?: string[]; /** Accepted media types, lowercased. Omitted, any type is accepted. */ allowedMimeTypes?: string[]; kind: "file"; - /** - * The most files the field will hold. `multiple: true` only. - * - * Defaults to {@link CONTENT_FILE_COLLECTION_DEFAULT_MAX} and may not exceed - * {@link CONTENT_FILE_COLLECTION_ABSOLUTE_MAX}: every entry is a stored object - * that the record pins against deletion, and the whole list is read and - * rewritten as one. - */ + max?: number; /** Largest accepted upload, in bytes. Required, finite, and greater than zero. */ maxBytes: number; - /** - * The fewest files to accept - `min: 1` is "at least one image". - * - * `multiple: true` only. A file collection can never be `required`, because - * the empty set is a legitimate value for a column that does not exist, so - * this is the shape a "you must upload something" rule actually takes. - */ + min?: number; - /** - * Store many files in a generated junction table instead of one on the row. - * - * Literal for the same reason {@link ContentRelationField.multiple} is: every - * partition keys off `{ multiple: true }`, and a widened `boolean` resolves a - * gallery back to the single-file branch. - */ + multiple: TMultiple; - /** - * Keep the order the files were added in. - * - * Defaults to **true** with `multiple: true`, unlike a relation: the order an - * editor built a gallery in is the order they meant it to read in. There are - * deliberately no reorder controls - rearranging is remove-and-re-add - so - * this is insertion order rather than a position anybody drags. - * - * `false` sorts by `core_files.id` instead, which makes `set([9, 2, 5])` and - * `set([2, 5, 9])` the same state rather than two writes that differ only in a - * column nobody declared. - */ + ordered: TOrdered; } @@ -268,16 +181,6 @@ export interface ContentDateTimeField< kind: "dateTime"; } -/** - * A reference to a VitNode user. - * - * `multiple: true` is the same move a to-many `relation` makes, and for the same - * reason: a column cannot hold a set, so the references move into a generated - * junction table whose second foreign key points at `core_users`. An article - * with two authors is two junction rows, not a comma-separated column - so - * `ON DELETE` still means something, and "which articles did this person write" - * is an indexed lookup rather than a `LIKE`. - */ export interface ContentUserField< TRequired extends boolean = boolean, TNullable extends boolean = boolean, @@ -287,56 +190,18 @@ export interface ContentUserField< kind: "user"; /** The fewest people the field will accept. See {@link ContentRelationField.min}. */ min?: number; - /** - * Many people instead of one. - * - * Literal rather than optional-boolean for the same reason - * {@link ContentRelationField.multiple} is: every partition keys off - * `{ multiple: true }`, and a widened `boolean` resolves a to-many field to - * the to-one branch - a foreign-key column that does not exist. - */ + multiple: TMultiple; onDelete: ContentOnDelete; - /** - * The author's order is the order the people come back in. - * - * Only meaningful with `multiple: true`, and it usually is meaningful: the - * first author of a piece is not an arbitrary member of a set. - */ + ordered: TOrdered; } -/** - * Structural match for a field whose values live in a junction table. - * - * Written once and reused, because "is this a set of references?" is the rule - * that decides whether a field is a column or a table - and the version of that - * rule that forgets `user` is a column the engine never generates and a value - * that silently disappears. - * - * All three reference kinds, deliberately. A gallery is stored exactly like a - * set of categories - a junction table with two foreign keys and a `position` - - * and every caller of this rule is asking "is this a column?", which has the - * same answer for a file, a category and a person. - */ interface ContentReferenceCollection { kind: "file" | "relation" | "user"; multiple: true; } -/** - * A reference to another content type's rows. - * - * `multiple: false` is the Stage 1 shape: one nullable-or-not foreign key column - * on the base table. `multiple: true` moves the reference off the row entirely - * and into a generated junction table - see {@link ContentRelationJunction} - - * because a column cannot hold a set. - * - * `target` is a thunk, which is also what makes a **self-relation** ordinary - * rather than special: `target: () => articleContentType` inside - * `articleContentType` is a forward reference resolved on first read, exactly - * like two content types pointing at each other. - */ export interface ContentRelationField< TRequired extends boolean = boolean, TNullable extends boolean = boolean, @@ -345,71 +210,19 @@ export interface ContentRelationField< TSelf extends boolean = boolean, > extends ContentFieldShared<TRequired, TNullable> { kind: "relation"; - /** - * The fewest targets the field will accept, enforced by the generated schema. - * - * How a content type says "at least one" about something `required` cannot - * say it about: a to-many reference has no column, so the empty set is always - * a storable value and requiredness is a rule about the *record* instead. Only - * meaningful with `multiple: true`. - */ + min?: number; - /** - * Many targets instead of one. - * - * Literal rather than optional-boolean for the same reason `localized` is: - * every partition in this file keys off `{ multiple: true }`, and a widened - * `boolean` would resolve a to-many relation to the to-one branch - which is - * a foreign-key column that does not exist. - */ + multiple: TMultiple; onDelete: ContentOnDelete; - /** - * The author's order is the order the value comes back in. - * - * Only meaningful with `multiple: true`. Without it the set is stored in - * ascending target-id order, which is still deterministic - it is simply not - * something the author chose. - */ + ordered: TOrdered; - /** - * The target is **this** content type. - * - * `self: true` rather than `target: () => thisContentType`, and the reason is - * the type system rather than taste: a definition whose own field map - * mentions its own inferred type is circular, and TypeScript resolves that by - * quietly widening the whole definition to `any`. Every nested value type, - * every allowlist check and every compile-time guarantee in this file would - * disappear - silently, because `any` is not an error. - * - * `defineContentType` rebinds the thunk to the finished definition, so - * everything downstream sees an ordinary relation pointing at an ordinary - * content type. - * - * Literal, like `multiple` and `ordered`: `ContentReferences` subtracts a - * self-relation from the reference map it demands, and a widened `boolean` - * would leave it demanding the one thunk nobody can write. - */ + self: TSelf; /** Thunk so two content types can refer to each other. */ target: () => AnyContentTypeDefinition; } -/** - * A reusable structured group: several leaves under one logical name. - * - * The value stays nested (`seo.title`), and the storage stays relational - each - * leaf becomes an ordinary column on the base or translation table, called - * `seoTitle`. There is no JSONB here: a - * flattened column is indexable, constrainable and queryable, and a group is a - * fixed set of leaves rather than an open bag. - * - * Localization is a property of the **group**, not of its leaves: `localized: - * true` moves the whole group into the translation table. Marking a single leaf - * would split one logical value across two tables with two different revision - * histories and two different permissions, which is exactly the drift - * `partitionContentFields` exists to prevent. - */ export interface ContentGroupField< TFields = ContentLeafFieldMap, TRequired extends boolean = boolean, @@ -422,19 +235,6 @@ export interface ContentGroupField< localized: TLocalized; } -/** - * A repeatable structured group: zero or more ordered child rows. - * - * Stored in a generated child table (`example_articles_faq`) with a `serial` - * primary key of its own, so a child has a **stable identity** that survives a - * reorder - which is what makes "edit row 3" mean something and what lets a - * revision restore put the same row back rather than a copy of it. - * - * Never nullable and never required: the value is an array, and the empty array - * is the natural "nothing here". Never localized either - see - * `apps/web/content/docs/dev/content-engine/repeatable-fields.mdx` for why that - * is a later stage. - */ export interface ContentRepeatableField< TFields = ContentLeafFieldMap, > extends ContentFieldShared<false, false> { @@ -462,15 +262,6 @@ export type ContentFieldDescriptor = export type ContentFieldKind = ContentFieldDescriptor["kind"]; -/** - * The kinds a group leaf or a repeatable leaf may be. - * - * Scalars only. A nested group would need a second level of column naming and a - * second level of partial-update merging for no modelling gain; a `slug` inside - * a group would need its uniqueness scoped to something; and a `relation` or - * `user` inside one would put a foreign key in a place the relation services do - * not look. All four are definition-time errors. - */ export type ContentLeafFieldDescriptor = | ContentBooleanField | ContentDateTimeField @@ -484,20 +275,6 @@ export type ContentFieldMap = Record<string, ContentFieldDescriptor>; /** A group's or repeatable's inner field map. Scalars only. */ export type ContentLeafFieldMap = Record<string, ContentLeafFieldDescriptor>; -/** - * Type-parameter constraint for a field map - deliberately shallow. - * - * A constraint becomes a contextual type for the argument, so constraining to - * `ContentFieldMap` would contextually type every `field.*()` call with - * `ContentFieldDescriptor` and widen `required`, `nullable` and enum `values` - * back to their generic defaults. Mentioning only `kind` still rejects - * non-descriptors while leaving literal inference intact, and the reserved - * system columns stay a compile error. - * - * `TPublication` extends the same trick to `status` and `publishedAt`, but only - * when the content type opted into publication - a Stage 1 type is free to keep - * declaring its own `status` enum. `TEditorial` does the same for `version`. - */ export type ContentFieldsConstraint< TPublication extends boolean = false, TEditorial extends boolean = false, @@ -548,26 +325,12 @@ type ContentGroupValue<TFields> = Prettify<{ [K in keyof TFields]: ContentFieldValue<TFields[K]>; }>; -/** - * One repeatable child row. - * - * `id` is the child table's own primary key and is always present on a read: - * it is what a later `update`, `delete` or `reorder` addresses, and what a - * revision restore matches an historical row against. - */ export type ContentRepeatableRow<TFields> = Prettify< { [K in keyof TFields]: ContentFieldValue<TFields[K]>; } & { id: number } >; -/** - * A create-shaped object over every key of a field map. - * - * Exported so the service can type a repeatable's child input from the leaves - * the definition already declares, rather than falling back to - * `Record<string, unknown>` and losing every one of them. - */ export type ContentValuesOf<TFields> = CreateValuesOf<TFields, keyof TFields>; /** The inner field map of one group or repeatable, by name. */ @@ -579,27 +342,10 @@ export type ContentInnerFieldsOf<TDefinition, TName> = ? TInner : never; -/** - * One repeatable child row as it is written. - * - * `id` is optional and is the whole write protocol: present means "update this - * existing child", absent means "create a new one". Position comes from the - * array order, so nothing carries it explicitly. - */ export type ContentRepeatableInputRow<TFields> = Prettify< CreateValuesOf<TFields, keyof TFields> & { id?: number } >; -/** - * The value as it comes back from the API (`select`). - * - * Structural on purpose: `TField` is unconstrained so this also works with the - * shallow {@link ContentFieldsConstraint}. - * - * The three advanced kinds resolve before the scalar branch, because a `group` - * has no scalar value at all and a to-many `relation` is a set of identifiers - * rather than one. - */ export type ContentFieldValue<TField> = TField extends { fields: infer TInner; kind: "group"; @@ -611,11 +357,6 @@ export type ContentFieldValue<TField> = TField extends { ? number[] : ApplyNullable<ScalarFieldValue<TField>, TField>; -/** - * The value as it is sent to the API. Identical to the select value except for - * `dateTime`, which crosses the wire (and the AutoForm) as an ISO 8601 string - - * `z.toJSONSchema` throws on `z.date()`, so a form schema can never hold one. - */ export type ContentFieldInput<TField> = TField extends { fields: infer TInner; kind: "group"; @@ -627,13 +368,6 @@ export type ContentFieldInput<TField> = TField extends { ? number[] : ApplyNullable<ScalarFieldInput<TField>, TField>; -/** - * The value a **partial** update may send for one field. - * - * Identical to {@link ContentFieldInput} everywhere except a group, where every - * leaf becomes optional: `{ seo: { description } }` must be able to move one - * leaf without restating the others, and without blanking them. - */ export type ContentFieldPatch<TField> = TField extends { fields: infer TInner; kind: "group"; @@ -682,18 +416,6 @@ type SharedFieldKeys<TFields> = Exclude< LocalizedFieldKeys<TFields> >; -/** - * Fields whose value is **not** a column on either generated table: a to-many - * relation, which lives in a junction table, and a repeatable, which lives in a - * child table. - * - * Everything that addresses a column - the admin list, an index, an equality - * filter, `orderBy`, `ContentSelect` - subtracts these. Everything that - * addresses a *value* - the create payload, the update patch, `changedFields` - - * keeps them. That split is the whole of Stage 6's "opt-in" promise: a content - * type that declares none of them has an empty subtraction and behaves exactly - * as it did in Stage 5. - */ type CollectionFieldKeys<TFields> = { [K in keyof TFields]: TFields[K] extends { kind: "repeatable" } ? K @@ -712,40 +434,16 @@ type GroupFieldKeys<TFields> = { [K in keyof TFields]: TFields[K] extends { kind: "group" } ? K : never; }[keyof TFields]; -/** - * Shared fields that are **one** column: a scalar, not a group. - * - * A group occupies several columns under generated names, so it is not - * something a list cell, an `orderBy` or an equality filter can address. Its - * *leaves* are, under their canonical paths - see {@link ContentLeafPath}. - */ type ScalarColumnFieldKeys<TFields> = Exclude< ColumnFieldKeys<TFields>, GroupFieldKeys<TFields> >; -/** - * Every field that is **one** column on *either* generated table. - * - * The same subtraction as {@link ScalarColumnFieldKeys}, minus the shared/localized - * split: a localized `text` is one column on the translation table, so it is - * something the AdminCP can *show*. It is still not something the AdminCP can - * *order or filter by* - that is a query over the base table - which is why the - * two types exist rather than one. - */ type ScalarDisplayFieldKeys<TFields> = Exclude< Exclude<keyof TFields, CollectionFieldKeys<TFields>>, GroupFieldKeys<TFields> >; -/** - * The canonical dotted path of every group leaf: `"seo.title"`. - * - * One representation, used by `changedFields`, validation errors, index - * declarations, `publicApi.fields`, `search.contentFields` and revision - * diagnostics alike. The generated column name (`seoTitle`) is an internal - * mapping and never appears in any of them. - */ export type ContentLeafPath<TFields> = string & { [K in GroupFieldKeys<TFields>]: TFields[K] extends { @@ -1630,29 +1328,9 @@ export interface ContentDeliveryConfig< TDescription extends string = string, TNoIndex extends string = string, > { - /** - * Literal `true`, and only when the content type has a public API. - * - * `never` otherwise, which is what turns "delivery needs `publicApi`" into a - * compile error on the `enabled: true` itself rather than a boot-time throw. The - * runtime check stays as well, for a JavaScript caller and for a value that - * widened somewhere upstream. - */ enabled: TPublicEnabled extends true ? true : never; hreflang?: ContentDeliveryHreflangConfig; - /** - * Gated on **editorial** as well as on the public API, and the second gate is not - * a taste decision: slug history has to be written in the same transaction as the - * slug mutation, the version check and the revision - and the only mutation paths - * that own such a transaction are the editorial ones. Without `editorial` a - * content type writes through the plain repository, which has no version to guard - * and no history to write, so `redirects: { enabled: true }` there would be a - * feature that silently records nothing. - * - * Only `redirects` is gated. Canonical URLs, SEO, alternates, `hreflang` and the - * sitemap are all reads over data the content type already has, and they remain - * available without `editorial`. - */ + redirects?: TPublicEnabled extends true ? TEditorialEnabled extends true ? ContentDeliveryRedirectsConfig | { enabled: false } @@ -1662,13 +1340,6 @@ export interface ContentDeliveryConfig< sitemap?: ContentDeliverySitemapConfig | { enabled: false }; } -/** - * Whether a `delivery` argument opted in. - * - * Read back off the argument for the same reason `ContentSearchEnabled` is: the - * whole object is inferred as one type parameter, and an intersection member is - * not an inference site, so this is the only way the literal survives. - */ export type ContentDeliveryEnabled<TDelivery> = TDelivery extends { enabled: true; } @@ -1701,17 +1372,7 @@ export interface ResolvedContentDeliveryConfig< enabled: boolean; priority: null | number; }; - /** - * Where the slug that addresses this content type lives. - * - * `"localized"` when `publicApi.slugField` is a localized field, `"shared"` - * otherwise - and it is the only thing the whole delivery layer branches on to - * decide which language a historical URL belongs to. A localized slug is - * reserved per language, a shared one once for the content type, and both are - * correct for the URLs they actually produce. - * - * `"none"` for a content type without delivery, which addresses nothing. - */ + slugScope: "localized" | "none" | "shared"; } @@ -1724,21 +1385,11 @@ export interface ContentEditorialRevisionsConfig { retention?: number; } -/** - * Opts into signed, expiring preview links for unpublished records. - * - * `enabled` is literal `true` for the same reason every other opt-in's is: a - * widened `boolean` would silently resolve to "no preview". - */ export interface ContentEditorialPreviewConfig { enabled: true; /** How long a link stays valid. 1-1440 minutes, defaults to 15. */ expiresInMinutes?: number; - /** - * Where the AdminCP sends a reviewer, e.g. `/articles/preview/{token}`. - * Relative, and `{token}` is the only placeholder. Omit it and the AdminCP - * links to the generated JSON endpoint instead. - */ + pathTemplate?: string; } @@ -1746,21 +1397,6 @@ export interface ContentEditorialSchedulingConfig { enabled: true; } -/** - * Opts a content type into the editorial workflow: a `version` column, - * optimistic locking and revision history. - * - * The two sub-features are gated on the capabilities they actually need, and - * the `{ enabled: false }` branches are what turn a mistake into a compile - * error rather than a boot-time one: - * - * - **preview** projects through `publicApi.fields`. Without a public allowlist - * there is nothing to project, so it needs `publicApi` (which already needs - * `publication`). - * - **scheduling** moves `status`, so it needs `publication`. It does *not* - * need a public API - a content type may run the lifecycle for the AdminCP - * badge alone. - */ export interface ContentEditorialConfig< TPublicEnabled extends boolean = boolean, TPublication extends boolean = boolean, @@ -1775,13 +1411,6 @@ export interface ContentEditorialConfig< : { enabled: false }; } -/** - * Whether an `editorial` argument opted in, and into what. - * - * Read back off the argument for the same reason `ContentSearchEnabled` is: the - * whole object is inferred as one type parameter, and an intersection member is - * not an inference site, so this is the only way the literals survive. - */ export type ContentEditorialEnabled<TEditorial> = TEditorial extends { enabled: true; } @@ -1818,13 +1447,6 @@ export interface ResolvedContentEditorialConfig< scheduling: { enabled: TScheduling }; } -/** - * The one generated column `editorial` adds. - * - * Read-only on the wire like the publication columns: it appears in a response - * so a client knows what to send back as `expectedVersion`, and it is absent - * from the create and update schemas so nobody can write it. - */ type ContentEditorialColumns<TDefinition> = TDefinition extends { editorial: { enabled: true }; } @@ -1838,43 +1460,13 @@ type ContentEditorialColumns<TDefinition> = TDefinition extends { export type ContentLocalizationFallback = (typeof CONTENT_LOCALIZATION_FALLBACKS)[number]; -/** - * Opts a content type into per-language content: the fields marked - * `localized: true` move off the base table into a generated translation table, - * one row per language. - * - * Nothing about the *UI* language changes - that is `core_languages_words` and - * the ordinary i18n system. This is about the records themselves: an article - * that exists in English and in Polish, with its own title, slug and body in - * each. - * - * `enabled` is literal `true` for the same reason every other opt-in's is: every - * conditional keys off `{ enabled: true }`, and a widened `boolean` would - * silently resolve to "not localized". - */ export interface ContentLocalizationConfig { - /** - * The locale every record is created in, and the one translation a record can - * never be without. Must name a row in `core_languages`, which is checked - * against the database once, at boot - see `assertContentLocalizationLanguages`. - */ defaultLocale: string; enabled: true; - /** - * What a public read should do for a locale with no translation. Resolved now - * and acted on in Stage 5C; `"none"` is the default because it is the only - * answer that cannot silently publish the wrong language. - */ + fallback?: ContentLocalizationFallback; } -/** - * `localization` after `defineContentType` has filled in every default. - * - * Generic over `enabled` for the same reason `publication` and `editorial` are: - * a widened `boolean` would make every definition equally (un)localized, so - * `LocalizedContentTypeDefinition` would only ever match after a cast. - */ export interface ResolvedContentLocalizationConfig< TEnabled extends boolean = boolean, > { @@ -1887,13 +1479,6 @@ export interface ResolvedContentLocalizationConfig< translationTableName: string; } -/** - * Whether a `localization` argument opted in. - * - * Read back off the argument for the same reason `ContentSearchEnabled` is: the - * whole object is inferred as one type parameter, and an intersection member is - * not an inference site, so this is the only way the literal survives. - */ export type ContentLocalizationEnabled<TLocalization> = TLocalization extends { enabled: true; } @@ -1912,12 +1497,6 @@ export type ContentSharedFieldName<TDefinition> = SharedFieldKeys< > & string; -/** - * The localized half of a create payload - one locale's worth of values. - * - * Empty (`{}`) for a content type with no localized fields, which is what makes - * `translation:` impossible to fill in by accident on a Stage 1-4 definition. - */ export type ContentLocalizedValues<TDefinition> = CreateValuesOf< ContentFieldsOf<TDefinition>, keyof ContentFieldsOf<TDefinition> & @@ -1936,15 +1515,6 @@ export type ContentLocalizedUpdateValues<TDefinition> = Prettify< Partial<ContentLocalizedValues<TDefinition>> >; -/** - * One translation's own publication state, or nothing. - * - * Gated on the *base* content type having publication, for the same reason the - * columns are: a translation status is only meaningful as something subordinate - * to a global one. Optional members rather than a widened `string`, so reading - * `row.status` on a content type without publication is a compile error rather - * than a silent `undefined`. - */ export type ContentTranslationPublicationColumns<TDefinition> = TDefinition extends { publication: { enabled: true } } ? { @@ -1968,13 +1538,6 @@ export type ContentTranslationRow<TDefinition> = Prettify< } >; -/** - * One translation without its values. - * - * What the list route returns, and deliberately so: the AdminCP needs to know - * which languages exist, how stale each one is and whether each is published - - * not to drag every article body in every language across the wire to find out. - */ export type ContentTranslationMeta<TDefinition = AnyContentTypeDefinition> = Prettify< ContentTranslationPublicationColumns<TDefinition> & { @@ -1991,53 +1554,18 @@ export type ContentTranslationMeta<TDefinition = AnyContentTypeDefinition> = // Definition // --------------------------------------------------------------------------- -/** - * A content type whose records are synchronized with the search index. - * - * An intersection rather than a sixth type argument, for the same reason - * {@link PublicContentTypeDefinition} is one: `enabled` is the only thing a - * caller of the search layer needs pinned, and narrowing just that keeps every - * concrete definition assignable. - */ export type SearchableContentTypeDefinition = AnyContentTypeDefinition & { search: { enabled: true }; }; -/** - * A content type that actually has a generated public API. - * - * The erased `AnyContentTypeDefinition` carries `enabled: boolean`, so it also - * describes a content type with no public API at all - one whose `publicApi.path` - * is the empty string. Anything that builds a public URL takes this instead, so - * passing the wrong content type is a compile error rather than a request to - * `/api/{pluginId}/content//`. - * - * An intersection rather than a fifth type argument: `enabled` is the only - * parameter a caller of the public read layer needs pinned, and narrowing just - * that one keeps every concrete definition assignable. - */ export type PublicContentTypeDefinition = AnyContentTypeDefinition & { publicApi: { enabled: true }; }; -/** - * A content type with the editorial workflow: it has a `version` column, its - * writes are guarded by an expected version, and every real mutation leaves a - * revision behind. - * - * An intersection rather than three more type arguments, for the same reason - * {@link PublicContentTypeDefinition} is one. - */ export type EditorialContentTypeDefinition = AnyContentTypeDefinition & { editorial: { enabled: true }; }; -/** - * A content type whose drafts can be previewed. - * - * Both halves are pinned: the preview projects through `publicApi.fields`, so a - * content type without a public allowlist cannot reach the token signer at all. - */ export type PreviewableContentTypeDefinition = EditorialContentTypeDefinition & PublicContentTypeDefinition & { editorial: { preview: { enabled: true } }; @@ -2050,50 +1578,18 @@ export type SchedulableContentTypeDefinition = publication: { enabled: true }; }; -/** - * A content type with a delivery layer: canonical URLs, alternates, SEO and a - * sitemap. - * - * Both halves are pinned, because delivery is defined in terms of the public - * projection: the canonical path is built from `publicApi.path` and the exposed - * slug field, and every SEO field is one of `publicApi.fields`. A content type - * without a public allowlist cannot reach the delivery service at all - which is - * a compile error rather than an empty response. - */ export type DeliverableContentTypeDefinition = PublicContentTypeDefinition & { delivery: { enabled: true }; }; -/** - * A content type whose records exist in more than one language. - * - * An intersection rather than a tenth type argument, for the same reason - * {@link PublicContentTypeDefinition} is one: `enabled` is the only thing the - * translation layer needs pinned, and narrowing just that keeps every concrete - * definition assignable to `AnyContentTypeDefinition`. - */ export type LocalizedContentTypeDefinition = AnyContentTypeDefinition & { localization: { enabled: true }; }; -/** - * Everything Stage 6 resolves once, at definition time. - * - * Empty arrays for a content type that declares no advanced field, which is what - * makes "Stage 6 is opt-in" true rather than merely intended: every generator - * below loops over these, and an empty loop generates nothing. - */ export interface ResolvedContentAdvancedConfig { /** One generated junction table per to-many relation field. */ junctions: ContentRelationJunction[]; - /** - * Every group leaf, by canonical path, with the column it compiles to. - * - * The single field-path mapping the whole engine reads: table generation, - * schemas, service reads and writes, revisions, the public projection, search - * and the AdminCP all take the column name from here rather than re-deriving - * it, so there is exactly one place the two representations meet. - */ + leaves: ContentLeafColumn[]; /** One generated child table per repeatable field. */ repeatables: ContentRepeatableTable[]; @@ -2129,11 +1625,7 @@ export interface ContentTypeDefinition< admin: ResolvedContentAdminConfig; /** Generated junction tables, child tables and the leaf-path mapping. */ advanced: ResolvedContentAdvancedConfig; - /** - * Canonical URLs, slug history, SEO and sitemap - or the disabled default when - * `delivery` is omitted, which is what keeps every Stage 1-7 content type - * byte-identical. - */ + delivery: ResolvedContentDeliveryConfig<TDeliveryEnabled>; /** Editorial workflow, or the disabled default when `editorial` is omitted. */ editorial: ResolvedContentEditorialConfig< @@ -2184,14 +1676,6 @@ export type ContentFieldsOf<TDefinition> = TDefinition extends { ? TFields : never; -/** - * One base row. - * - * Shared fields only: a localized field's value lives on the translation table, - * so it is not a column here and never comes back from a base read. For a - * content type without localization every field is shared, so this is exactly - * the type it always was. - */ export type ContentSelect<TDefinition> = Prettify< ContentEditorialColumns<TDefinition> & ContentPublicationColumns<TDefinition> & { @@ -2201,13 +1685,6 @@ export type ContentSelect<TDefinition> = Prettify< } & { createdAt: Date; id: number; updatedAt: Date } >; -/** - * One record with its advanced collections attached. - * - * What a detail read returns and what an editorial mutation echoes back. Two - * extra queries per record rather than per row, and only where a caller asked - * for the whole thing. - */ export type ContentDetail<TDefinition> = Prettify< ContentAdvancedValues<TDefinition> & ContentSelect<TDefinition> >; @@ -2215,38 +1692,15 @@ export type ContentDetail<TDefinition> = Prettify< /** The base-table half of a create payload. See {@link ContentSharedValues}. */ export type ContentCreateInput<TDefinition> = ContentSharedValues<TDefinition>; -/** - * A partial update. - * - * Partial one level deeper than `Partial<ContentCreateInput>` would be: a group - * value may name a subset of its leaves, so `{ seo: { description } }` moves one - * leaf and leaves `seo.title` exactly where it was. A collection is replaced - * whole - `categories: [2, 5, 9]` is the complete new set - because a partial - * set has no meaning that is not either "add" or "remove", and both of those are - * their own service call. - */ export type ContentUpdateInput<TDefinition> = Prettify<{ [K in SharedFieldKeys<ContentFieldsOf<TDefinition>>]?: ContentFieldPatch< ContentFieldsOf<TDefinition>[K] >; }>; -/** - * Every field name the content type declares, localized ones included. - * - * Use {@link ContentSharedFieldName} where a *column on the base table* is - * meant - which is most places. - */ export type ContentFieldName<TDefinition> = keyof ContentFieldsOf<TDefinition> & string; -/** - * Shared field names of one or more kinds. - * - * Deliberately shared-only: everything derived from this - filters, ordering, - * relation pickers - addresses a column on the *base* table, and a localized - * field does not have one. - */ type FieldNamesOfKind<TDefinition, TKind extends ContentFieldKind> = string & { [ @@ -2258,12 +1712,6 @@ type FieldNamesOfKind<TDefinition, TKind extends ContentFieldKind> = string & : never; }[ScalarColumnFieldKeys<ContentFieldsOf<TDefinition>>]; -/** - * Kinds the generated filter schema understands, derived from the one runtime - * list in `const.ts` so the compile-time contract and the runtime guard are the - * same list. `service.test-d.ts` asserts it stays a subset of - * {@link ContentFieldKind}. - */ export type FilterableContentFieldKind = (typeof CONTENT_FILTERABLE_FIELD_KINDS)[number]; @@ -2287,40 +1735,15 @@ type AnyFieldNamesOfKind<TDefinition, TKind extends ContentFieldKind> = Exclude< > & string; -/** - * Field names a **public** filter may name. - * - * Wider than {@link FilterableContentFieldName} by exactly the localized half: an - * admin list is a query over the base table, but a public localized read already - * joins the translation it is serving, so filtering on a localized field is one - * more predicate on a row it was fetching anyway - evaluated against the language - * the reader will actually see. - */ export type PublicFilterableContentFieldName<TDefinition> = AnyFieldNamesOfKind< TDefinition, FilterableContentFieldKind >; -/** - * The one filter a to-many relation accepts: "this record is related to *that* - * row". - * - * An object rather than a bare identifier so it can never be confused with the - * equality filter a to-one relation takes, and so the SQL it compiles to - an - * indexed `EXISTS` over the junction table - is chosen by the shape of the value - * rather than by looking up the descriptor twice. There is deliberately no - * `containsAll`, no `containsAny` and no traversal: that is a query language, - * and a hand-written route is the better answer to it. - */ export interface ContentRelationFilter { contains: number; } -/** - * Equality filters accepted by `service.findMany`, one key per filterable - * field - plus `status` once publication is enabled, which is a generated - * column rather than a declared field. - */ export type ContentFilterInput<TDefinition> = Partial< Record<ContentRelationCollectionName<TDefinition>, ContentRelationFilter> & (TDefinition extends { publication: { enabled: true } } @@ -2332,19 +1755,6 @@ export type ContentFilterInput<TDefinition> = Partial< } >; -/** - * Columns `service.findMany` may order by. - * - * A compile-time approximation, and deliberately so: `admin.list.orderableFields` - * is stored on the *resolved* (non-generic) admin config, so the configured - * array is not recoverable as a type. Every field name is accepted here, and - * the narrower runtime allowlist rejects the ones that were not configured. - * - * The generated publication columns are part of that allowlist at runtime - - * `orderableColumns` appends them, and the generated route's `orderBy` enum - * includes them - so they belong here too, but only for a content type that - * actually opted in. - */ export type ContentOrderableFieldName<TDefinition> = | ContentSharedFieldName<TDefinition> | ContentSystemField @@ -2365,22 +1775,6 @@ export type ContentReferenceFieldName<TDefinition> = FieldNamesOfKind< // Public projection // --------------------------------------------------------------------------- -/** - * How an exposed `relation` comes back: an identifier, and nothing else. - * - * Deliberately not the related row, and deliberately **not a label**. The - * obvious label is the target's `admin.titleField`, but that is administrative - * metadata: it may name a field the target does not expose publicly, the target - * may have no `publicApi` at all, and the row it is read from may itself be a - * draft. Publishing an internal name because two content types are related is - * not a decision one allowlist should make on behalf of another. - * - * An identifier is enough to fetch the related row through its own public API, - * which is the layer that decides what it is willing to say. Configurable - * public relation labels are a later stage; deep nesting and arbitrary - * population are the point at which a REST projection turns into GraphQL, and a - * hand-written route is the better answer to that. - */ export interface ContentPublicRelation { id: number; } diff --git a/packages/vitnode/src/database/content.ts b/packages/vitnode/src/database/content.ts index 74cba561d..42ca83eee 100644 --- a/packages/vitnode/src/database/content.ts +++ b/packages/vitnode/src/database/content.ts @@ -19,21 +19,6 @@ import { import { core_files } from "./files"; import { core_users } from "./users"; -/** - * Revision history for every content type with `editorial: { enabled: true }`. - * - * One shared table rather than one per content type: a content table is - * generated at runtime from a descriptor, so core's static schema cannot name - * it - and a per-type revision table would mean a second generated table and a - * second migration for every plugin, with no cross-type query left possible. - * - * There is deliberately **no foreign key to the record**, for the same reason - * `core_search_index` has none: the target table is not knowable here. The - * consequences are handled rather than ignored - every read is scoped by - * `(pluginId, contentTypeId, itemId)`, a delete leaves a final `delete` - * revision behind, and rows whose content type is no longer registered are - * swept up by the editorial cleanup job. - */ export const core_content_revisions = camelCase.table.withRLS( "core_content_revisions", t => ({ @@ -41,21 +26,7 @@ export const core_content_revisions = camelCase.table.withRLS( pluginId: t.varchar({ length: 255 }).notNull(), contentTypeId: t.varchar({ length: 100 }).notNull(), itemId: t.integer().notNull(), - /** - * Which language this revision belongs to, or `NULL` for a shared one. - * - * `NULL` is the whole history of every non-localized content type and the - * *shared* history of a localized one, which is why it is the column default - * in effect: a nullable column with no default backfills every pre-Stage-5B - * row to exactly the right value in one statement. - * - * Deliberately **not** a foreign key, for the same reason there is none to - * the record: a revision is an audit trail, and "the Polish copy said this" - * stays true after the language row is gone. A cascade would erase the fact - * and a restrict would block a language deletion the *translation* table has - * already had its say about. The snapshot carries the locale code, so a - * revision remains readable without the language it names. - */ + languageId: t.integer(), /** The version the record holds *after* this mutation. */ version: t.integer().notNull(), @@ -81,12 +52,7 @@ export const core_content_revisions = camelCase.table.withRLS( onDelete: "set null", onUpdate: "cascade", }), - /** - * Set only on a `restore`. Intentionally **not** a foreign key: retention - * may prune the revision it names, and "restored from v7" is still true - * afterwards - a cascade would erase the fact, and a restrict would block - * pruning. - */ + restoredFromRevisionId: t.integer(), createdAt: t.timestamp().notNull().defaultNow(), }), @@ -133,31 +99,6 @@ export const core_content_revisions = camelCase.table.withRLS( export type ContentRevisionRow = typeof core_content_revisions.$inferSelect; -/** - * Which stored files a retained revision still needs. - * - * The problem it solves: a revision's snapshot is JSONB, so `{ coverImage: 42 }` - * inside it is a *number* as far as Postgres is concerned - no foreign key, no - * protection. The moment an editor points the article at a different image, the - * content row's own key stops guarding the old one, and deleting it would leave - * every retained revision naming bytes that are gone. "Restore version 3" would - * then restore a broken image. - * - * One row per (revision, file), and the two references do all the work: - * - * - **to `core_files`, `ON DELETE RESTRICT`** - Postgres itself refuses to delete - * a file a revision still names, which is what lets `StorageModel.deleteFile` - * answer 409 rather than orphaning a content record. - * - **to `core_content_revisions`, `ON DELETE CASCADE`** - retention pruning is a - * single range `DELETE` in the write's own transaction, and the pins go with it. - * Nothing has to remember to unpin, and the last pin disappearing is exactly - * the moment the file becomes deletable again. - * - * Deliberately **narrow**: two integers and a timestamp. No metadata is copied - * from the file, no content type id is repeated - the revision it hangs off - * already knows all of that, and a second copy is a second thing that can be - * wrong. - */ export const core_content_file_refs = camelCase.table.withRLS( "core_content_file_refs", t => ({ @@ -190,19 +131,6 @@ export const core_content_file_refs = camelCase.table.withRLS( export type ContentFileRefRow = typeof core_content_file_refs.$inferSelect; -/** - * Pending and past scheduled transitions, for content types with - * `editorial.scheduling`. - * - * Shared and foreign-key-free for exactly the same reasons as - * {@link core_content_revisions}, and scoped by the same three columns on every - * query. - * - * Completed and cancelled rows are **kept**: "who scheduled this, and when did - * it go out" is the audit trail the feature exists to provide, and deleting it - * the moment it succeeds would answer that question with silence. A daily core - * cron sweeps them past `CONTENT_SCHEDULE_RETENTION_DAYS`. - */ export const core_content_schedules = camelCase.table.withRLS( "core_content_schedules", t => ({ @@ -212,14 +140,7 @@ export const core_content_schedules = camelCase.table.withRLS( itemId: t.integer().notNull(), action: t.varchar({ enum: CONTENT_SCHEDULE_ACTIONS, length: 16 }).notNull(), scheduledFor: t.timestamp().notNull(), - /** - * Bumped every time this `(item, action)` is rescheduled. - * - * The queued task carries the generation it was dispatched with, so a task - * left over from a previous schedule finds a mismatch and quietly does - * nothing. That is cheaper and far more reliable than trying to hunt down - * and delete the old queue row. - */ + generation: t.integer().notNull().default(1), status: t .varchar({ enum: CONTENT_SCHEDULE_STATUSES, length: 16 }) @@ -239,15 +160,7 @@ export const core_content_schedules = camelCase.table.withRLS( completedAt: t.timestamp(), /** Why the last attempt failed. Set on an overdue row, cleared on success. */ lastError: t.text(), - /** - * Why a *completed* schedule's announcements have not been delivered. - * - * The transition and its effects are two units of work on purpose, so they - * need two error fields. A value here means the record published exactly - * once and the event, search write or cache invalidation is still being - * retried by the `content-schedule-effects` task - never that the - * publication should run again. - */ + effectsError: t.text(), }), t => [ @@ -268,31 +181,6 @@ export const core_content_schedules = camelCase.table.withRLS( export type ContentScheduleRow = typeof core_content_schedules.$inferSelect; -/** - * Every slug that has ever been a **publicly addressable** URL, for content types - * with `delivery.redirects`. - * - * Shared and foreign-key-free for the same two reasons as - * {@link core_content_revisions}: the target table is generated at runtime so - * core's static schema cannot name it, and a URL's history stays true after the - * record is gone. It is scoped by `(contentTypeId, itemId)` on every query, and a - * delete leaves the history in place - an incoming link to a deleted article is - * exactly the diagnostic somebody will want, and the resolver answers 404 for it - * by reading the live record rather than by having forgotten the URL. - * - * `languageId` is the locale identity, and `NULL` means the slug is **shared**: - * either the content type is not localized, or it is and its slug lives on the - * base row. That single column is what makes `/en/articles/hello` and - * `/pl/articles/hello` two independent histories - changing the English URL - * creates no Polish redirect - while a shared slug stays one reservation covering - * every language it appears in. - * - * `retiredAt` is `NULL` while the slug is the record's *current* address and is - * stamped when it moves away. Both states are stored, which is what makes the - * uniqueness below a **reservation** rather than only a log: a retired URL cannot - * be claimed by unrelated content, so nobody's incoming links quietly change - * meaning. - */ export const core_content_slug_history = camelCase.table.withRLS( "core_content_slug_history", t => ({ @@ -303,15 +191,7 @@ export const core_content_slug_history = camelCase.table.withRLS( /** `NULL` for a shared slug. See the table comment. */ languageId: t.integer(), slug: t.varchar({ length: CONTENT_SLUG_DEFAULT_LENGTH }).notNull(), - /** - * The canonical path this slug produced, e.g. `/pl/articles/stary-slug`. - * - * Stored rather than rebuilt on read, and the reason is that it is the one - * thing the engine cannot recompute later: a path is built from - * `publicApi.path`, which is source configuration a developer may change. The - * URL that was live is a historical fact, so it is recorded as one - and the - * AdminCP shows exactly the address somebody's bookmark holds. - */ + path: t.varchar({ length: CONTENT_DELIVERY_PATH_MAX_LENGTH }).notNull(), createdAt: t.timestamp().notNull().defaultNow(), /** When this slug stopped being the record's address. `NULL` while current. */ diff --git a/packages/vitnode/src/database/relations.ts b/packages/vitnode/src/database/relations.ts index 9a0648af2..e8e85b024 100644 --- a/packages/vitnode/src/database/relations.ts +++ b/packages/vitnode/src/database/relations.ts @@ -15,13 +15,6 @@ import * as secrets from "./secrets"; import * as sessions from "./sessions"; import * as users from "./users"; -/** - * Every table `@vitnode/core` ships, in one object. - * - * `defineRelations` filters this down to the tables on its own - the modules - * also export types, helpers and constants, and those are dropped rather than - * mistaken for schema entries. - */ export const coreSchema = { ...admins, ...content, @@ -39,17 +32,6 @@ export const coreSchema = { ...users, }; -/** - * Relations for the core schema, in the Relational Queries v2 shape. - * - * v2 collects every relation in one place instead of a `relations()` call per - * table, which is what makes the relation names autocomplete on - * `db.query.<table>.findMany({ with: ... })`. - * - * The side that owns the foreign key spells out `from`/`to`; the opposite side - * is declared with no config and paired by Drizzle, so a column only has to be - * named once. - */ export const coreRelations = defineRelations(coreSchema, r => ({ core_users: { group: r.one.core_roles({ diff --git a/packages/vitnode/src/database/secrets.ts b/packages/vitnode/src/database/secrets.ts index 3b64b8b4f..b60e7b6cd 100644 --- a/packages/vitnode/src/database/secrets.ts +++ b/packages/vitnode/src/database/secrets.ts @@ -1,21 +1,5 @@ import { camelCase } from "drizzle-orm/pg-core"; -/** - * Signing keys the install generates for itself. - * - * A row per secret, keyed by a stable name, so a feature whose whole access - * control is a signature does not become a deployment prerequisite: the first - * process that needs one mints it, every other process reads the same value, - * and it survives restarts and redeploys the way an environment variable does - * without anyone having to set one. - * - * The database is the right home rather than a file or a per-process constant: - * an install is already trusting it with the records these secrets protect, and - * it is the one thing every API process demonstrably shares. - * - * Deleting a row rotates that secret - the next process to need it generates a - * fresh one, and every token signed with the old value stops verifying. - */ export const core_secrets = camelCase.table.withRLS("core_secrets", t => ({ name: t.varchar({ length: 100 }).primaryKey(), value: t.text().notNull(), diff --git a/packages/vitnode/src/framework/admin-nav/generate.test.ts b/packages/vitnode/src/framework/admin-nav/generate.test.ts index 244b2a342..4afc185a6 100644 --- a/packages/vitnode/src/framework/admin-nav/generate.test.ts +++ b/packages/vitnode/src/framework/admin-nav/generate.test.ts @@ -2,16 +2,6 @@ import { describe, expect, it } from "vitest"; import { generateAdminNavSource } from "./generate"; -/** - * The AdminCP navigation projection, as bytes. - * - * Asserted literally rather than by parsing, for the same reason the plugin - * route registry is: this file is committed, so it appears in diffs, and a - * generator that reordered itself or reflowed an entry would produce a diff on - * somebody else's machine and none on the author's. "Same configuration, same - * bytes" is the property, and the only honest way to state it is to write the - * bytes down. - */ describe("generateAdminNavSource", () => { it("writes an empty projection when no plugin has navigation", () => { const source = generateAdminNavSource([]); @@ -53,11 +43,6 @@ describe("generateAdminNavSource", () => { ).toBeLessThan(generateAdminNavSource(modules).indexOf("@vitnode/example")); }); - /** - * Positional aliases, because a plugin id is not a JavaScript identifier - - * deriving one would mean two ids differing only in punctuation colliding on - * a single binding, silently. - */ it("gives each module its own alias", () => { const source = generateAdminNavSource([ { pluginId: "@vitnode/blog", specifier: "@vitnode/blog/admin/nav" }, diff --git a/packages/vitnode/src/framework/admin-nav/generate.ts b/packages/vitnode/src/framework/admin-nav/generate.ts index 97c282e9d..0362d6249 100644 --- a/packages/vitnode/src/framework/admin-nav/generate.ts +++ b/packages/vitnode/src/framework/admin-nav/generate.ts @@ -5,13 +5,6 @@ import { toSingleQuotedLiteral } from "../plugin-routes/resolve.js"; /** Where the generated file imports its type from. */ const TYPES_SPECIFIER = "@vitnode/core/lib/plugin"; -/** - * The generated file's header. - * - * `eslint-disable` and the "do not format" line are not decoration: the file is - * rewritten on every build, so anything a linter or a formatter changes in it is - * lost. - */ const HEADER = `/* eslint-disable */ // This file is generated by VitNode. Do not edit it, and do not format it. @@ -34,26 +27,7 @@ import type { AdminNavPluginSource } from '${TYPES_SPECIFIER}' * `admin/nav`" - is not guessable from an array of two entries. */ const BODY_DOC = ` -/** - * Every configured plugin's AdminCP navigation, as browser-safe data. - * - * A plugin appears here by exporting \`admin/nav\`, and a plugin that does not - * contributes no sidebar entries. That is not an error - most plugins have - * nothing to put in the AdminCP - and it is checked at build time rather than - * discovered in a browser: the specifiers below are literal, so a bundler - * resolves them and nothing is reached through a computed string. - * - * This is deliberately **not** the plugin's frontend registration. - * \`blogPlugin()\` and friends register content types with their editing screens - * attached - an editor field, a form layout, a table cell - the whole of core's - * form stack. A sidebar needs ids, hrefs, permissions, icons and content type - * definitions, all of which are plain data, so that is all this carries. See - * \`AdminNavPluginSource\`. - * - * Navigation is not derived from the route manifest and never will be: a nav - * entry may point at a plugin route, at a legacy screen, or at another origin, - * and a plugin route may deliberately have no entry at all. - */ + export const pluginAdminNav`; const importLines = (modules: ResolvedAdminNavModule[]): string => diff --git a/packages/vitnode/src/framework/admin-nav/index.ts b/packages/vitnode/src/framework/admin-nav/index.ts index 5348b7984..90b1f7926 100644 --- a/packages/vitnode/src/framework/admin-nav/index.ts +++ b/packages/vitnode/src/framework/admin-nav/index.ts @@ -1,35 +1,2 @@ -/** - * The build-time projection of an app's AdminCP navigation - - * `@vitnode/core/framework/admin-nav`. - * - * Pure, like its sibling `framework/plugin-routes`: resolved modules in, a - * source string out. There is no `node:fs` and no package resolution here - the - * build tool that owns those (`@vitnode/core/framework/vite`) reads the app's - * config, asks which configured plugins export an `admin/nav` module, and writes - * what this returns. - * - * ## Why a projection exists at all - * - * The AdminCP sidebar is a function of the plugins an installation configured, - * and in a Next.js app that is free: `vitnode.config.ts` is only ever read by - * Server Components, so the whole plugin registry - editing screens included - - * can be walked in the render pass that draws the sidebar. A TanStack Start - * application has no such boundary: anything the root route imports is in the - * browser bundle, and the registry it would have to import carries every content - * type's editing screen - an editor field, a form layout, a table cell - none of - * which a sidebar needs. - * - * So the two are separated by what they carry rather than by a build flag, and - * the generated file names the browser-safe half: - * - * vitnode.config.ts the plugins, server-side - * admin-nav.gen.ts one literal import per plugin that has navigation - * AdminNavPluginSource ids, hrefs, permissions, icons, content definitions - * adminNavBundle(...) the declarations, and the strings they need - * - * Nothing is copied and nothing is serialised: the declarations stay compiled in - * the plugin's own `dist` and arrive through a specifier a bundler resolved, - * which is the same arrangement the plugin route registry uses. - */ export { generateAdminNavSource } from "./generate.js"; export type { ResolvedAdminNavModule } from "./types.js"; diff --git a/packages/vitnode/src/framework/admin-nav/types.ts b/packages/vitnode/src/framework/admin-nav/types.ts index ac3189f52..59126daa2 100644 --- a/packages/vitnode/src/framework/admin-nav/types.ts +++ b/packages/vitnode/src/framework/admin-nav/types.ts @@ -1,12 +1,3 @@ -/** - * One configured plugin's AdminCP navigation module, as the build resolved it. - * - * The whole of what discovery produces: which plugin, and the specifier the - * generated file imports it by. Deliberately not the navigation itself - the - * declarations stay compiled in the plugin's own `dist`, exactly as its route - * modules do, and the application holds one generated line of registration per - * plugin rather than a copy of anybody's data. - */ export interface ResolvedAdminNavModule { pluginId: string; /** `@vitnode/example/admin/nav` - a package export subpath, literal. */ diff --git a/packages/vitnode/src/framework/content-registry/generate.test.ts b/packages/vitnode/src/framework/content-registry/generate.test.ts index 77839d011..5f072f69b 100644 --- a/packages/vitnode/src/framework/content-registry/generate.test.ts +++ b/packages/vitnode/src/framework/content-registry/generate.test.ts @@ -2,16 +2,6 @@ import { describe, expect, it } from "vitest"; import { generateContentRegistrySource } from "./generate"; -/** - * The Content Engine registry projection, as bytes. - * - * Asserted literally rather than by parsing, for the same reason the plugin - * route registry and the navigation projection are: this file is committed, so - * it appears in diffs, and a generator that reordered itself or reflowed an - * entry would produce a diff on somebody else's machine and none on the - * author's. "Same configuration, same bytes" is the property, and the only - * honest way to state it is to write the bytes down. - */ describe("generateContentRegistrySource", () => { it("writes an empty registry when no plugin registers content types", () => { const source = generateContentRegistrySource([]); @@ -38,11 +28,6 @@ describe("generateContentRegistrySource", () => { expect(source).toContain("satisfies ContentFrontendPluginSource[]"); }); - /** - * The whole point of generating this file rather than serialising a registry: - * a component cannot cross a JSON boundary, and a specifier assembled from a - * plugin id is a module a bundler cannot follow. - */ it("never builds a specifier from a variable", () => { const source = generateContentRegistrySource([ { pluginId: "@vitnode/blog", specifier: "@vitnode/blog/admin/content" }, diff --git a/packages/vitnode/src/framework/content-registry/generate.ts b/packages/vitnode/src/framework/content-registry/generate.ts index 1f5145ddd..7c5e2bffd 100644 --- a/packages/vitnode/src/framework/content-registry/generate.ts +++ b/packages/vitnode/src/framework/content-registry/generate.ts @@ -5,13 +5,6 @@ import { toSingleQuotedLiteral } from "../plugin-routes/resolve.js"; /** Where the generated file imports its type from. */ const TYPES_SPECIFIER = "@vitnode/core/lib/plugin"; -/** - * The generated file's header. - * - * `eslint-disable` and the "do not format" line are not decoration: the file is - * rewritten on every build, so anything a linter or a formatter changes in it is - * lost. - */ const HEADER = `/* eslint-disable */ // This file is generated by VitNode. Do not edit it, and do not format it. @@ -34,31 +27,7 @@ import type { ContentFrontendPluginSource } from '${TYPES_SPECIFIER}' * `admin/content`" - is not guessable from an array of two entries. */ const BODY_DOC = ` -/** - * Every configured plugin's Content Engine frontend registration. - * - * A plugin appears here by exporting \`admin/content\`, and a plugin that does - * not contributes no content types to the AdminCP. That is not an error - most - * plugins register none - and it is decided at build time rather than - * discovered in a browser: the specifiers below are literal, so a bundler - * resolves them and nothing is reached through a computed string. - * - * This carries more than \`admin-nav.gen.ts\` and less than - * \`vitnode.config.ts\`. Navigation needs ids, hrefs, permissions and icons; a - * content *screen* needs those plus the components that replace a generated - * input, a generated table cell and a generated form layout. Neither needs the - * server config, which carries message loaders and API wiring a browser bundle - * has no business holding. - * - * Nothing is copied and nothing is serialised: React components cannot cross a - * JSON boundary, so the registrations stay compiled in each plugin's own - * \`dist\` and arrive through a specifier a bundler resolved. - * - * The registry built from this is validated by \`@vitnode/core\` - - * \`buildContentFrontendRegistry\` runs the same \`validateContentTypes\` the - * API side runs, so two plugins claiming one \`admin.path\` fail loudly here - * rather than giving one of them a screen and the other nothing. - */ + export const pluginContentTypes`; const importLines = (modules: ResolvedContentRegistryModule[]): string => diff --git a/packages/vitnode/src/framework/content-registry/index.ts b/packages/vitnode/src/framework/content-registry/index.ts index 620964200..5b2575fe1 100644 --- a/packages/vitnode/src/framework/content-registry/index.ts +++ b/packages/vitnode/src/framework/content-registry/index.ts @@ -1,37 +1,2 @@ -/** - * The build-time projection of an app's Content Engine frontend registrations - - * `@vitnode/core/framework/content-registry`. - * - * Pure, like its siblings `framework/admin-nav` and `framework/plugin-routes`: - * resolved modules in, a source string out. There is no `node:fs` and no package - * resolution here - the build tool that owns those - * (`@vitnode/core/framework/vite`) reads the app's config, asks which configured - * plugins export an `admin/content` module, and writes what this returns. - * - * ## Why a projection exists at all - * - * The AdminCP's content screens are a function of the plugins an installation - * configured, and in a Next.js app that is free: `vitnode.config.ts` is only - * ever read by Server Components, so the whole plugin registry - editing screens - * included - can be walked in the render pass. A TanStack Start application has - * no such boundary; anything the router imports is in a bundle. - * - * So the layers are separated by what they carry, and the generated files name - * each browser-safe half: - * - * vitnode.config.ts the plugins, server-side - * admin-nav.gen.ts ids, hrefs, permissions, icons, definitions - * content-registry.gen.ts the above, plus field/column/layout overrides - * ContentFrontendPluginSource the type both a plugin and the app agree on - * - * ## Why it is not JSON - * - * A navigation entry is a string and an icon element. A content registration is - * a *React component per override* - a Tiptap field, a colour cell, a two-column - * form layout - which cannot be serialised and must not be reached through a - * specifier built from a plugin id. So the generated file is a module of literal - * imports, and the components stay compiled in the plugin's own `dist`, which is - * the same arrangement the plugin route registry uses. - */ export { generateContentRegistrySource } from "./generate.js"; export type { ResolvedContentRegistryModule } from "./types.js"; diff --git a/packages/vitnode/src/framework/content-registry/types.ts b/packages/vitnode/src/framework/content-registry/types.ts index 772eabceb..bc3325982 100644 --- a/packages/vitnode/src/framework/content-registry/types.ts +++ b/packages/vitnode/src/framework/content-registry/types.ts @@ -1,19 +1,3 @@ -/** - * One configured plugin's Content Engine frontend module, as the build resolved - * it. - * - * The whole of what discovery produces: which plugin, and the specifier the - * generated file imports it by. Deliberately not the registrations themselves - - * they are React components, and a build tool has no business evaluating them. - * The declarations stay compiled in the plugin's own `dist`, exactly as its - * route modules and its navigation do, and the application holds one generated - * line of registration per plugin rather than a copy of anybody's data. - * - * Identical in shape to `ResolvedAdminNavModule`, and kept as its own type - * rather than shared: the two describe different subpaths with different - * contracts, and a future field on one - a content module's lazy boundary, say - - * must not silently appear on the other. - */ export interface ResolvedContentRegistryModule { pluginId: string; /** `@vitnode/example/admin/content` - a package export subpath, literal. */ diff --git a/packages/vitnode/src/framework/plugin-routes/compile.test.ts b/packages/vitnode/src/framework/plugin-routes/compile.test.ts index 1fab51966..b607ecad9 100644 --- a/packages/vitnode/src/framework/plugin-routes/compile.test.ts +++ b/packages/vitnode/src/framework/plugin-routes/compile.test.ts @@ -15,15 +15,6 @@ import { import { compilePluginRoutes } from "./compile.js"; import { hostRoutePathsFromFiles } from "./host-routes.js"; -/** - * The whole build-time compilation, from what plugins declare to the one file an - * app holds. - * - * Nothing here touches a filesystem: what is asserted is the part that has to be - * *exactly* reproducible, and the diagnostics a plugin author actually reads. - * Whether a page's module exists on disk is the Vite layer's question, and - * `lazyImportSpecifier` is what lets it ask. - */ const lazyPage = () => lazy(async () => await Promise.resolve({ default: () => null })); diff --git a/packages/vitnode/src/framework/plugin-routes/compile.ts b/packages/vitnode/src/framework/plugin-routes/compile.ts index cf8ad633e..df72a8867 100644 --- a/packages/vitnode/src/framework/plugin-routes/compile.ts +++ b/packages/vitnode/src/framework/plugin-routes/compile.ts @@ -8,36 +8,15 @@ import { withPluginRouteDiagnostics } from "./diagnostics.js"; import { generatePluginRoutesSource } from "./generate.js"; import { assertNoHostRouteCollision } from "./host-routes.js"; -/** - * One configured plugin's route tree, exactly as its `routes` module exported - * it. - * - * `routes` is `unknown` because a plugin is compiled JavaScript by the time it - * is installed: every field is re-read defensively by `flattenPluginRoutes`. - * What this layer adds is `routesSpecifier` - where the tree came from - which - * is not part of what a plugin declares and is the one thing a plugin author - * needs in order to fix any of these errors. - */ export interface PluginRouteCompilerSource { pluginId: string; routes?: unknown; - /** - * The specifier the tree was loaded from, e.g. `"@vitnode/example/routes"`. - * Optional so a caller with declarations from somewhere else - a test, or a - * host reading a registered plugin - is not made to invent one. - */ + routesSpecifier?: string; } /** What one compilation produced. */ export interface CompiledPluginRoutes { - /** - * Each route's lazy component, keyed by route id. - * - * Here so a build can ask the one question a generated file no longer answers - * for it: does the module this page names actually exist. See - * `lazyImportSpecifier`. - */ components: Map<string, PluginRouteLazyComponent>; /** The resolved snapshot the source below was written from. */ manifest: PluginRouteManifest; @@ -48,54 +27,10 @@ export interface CompiledPluginRoutes { } export interface CompilePluginRoutesOptions { - /** - * Every URL the host application's own route files already claim. - * - * Empty by default, which is the honest answer for a caller that does not know - * - a host with no file-based routes, or a test. The check it enables is a - * build-time echo of one the runtime performs against the real route tree; see - * {@link assertNoHostRouteCollision}. - */ hostRoutes?: readonly HostRoutePath[]; sources: readonly PluginRouteCompilerSource[]; } -/** - * Every configured plugin's routes, compiled into the one file an app holds. - * - * Pure: plain declarations in, a validated manifest and one source string out. - * There is no filesystem here, no package resolution and no framework - the - * build tool that owns those (`@vitnode/core/framework/vite`) loads the - * declarations and writes what this returns. That split is what makes the part - * that has to be *exactly* reproducible testable without a fixture app. - * - * ## One snapshot, one file - * - * The manifest is built first and the generated file is written **from the - * plugins that survived it**, rather than from a second pass over the same - * configuration. That ordering is the whole anti-drift argument: a plugin - * reaches `plugin-routes.gen.ts` only by having declared a tree that validates, - * and a plugin removed from the config cannot leave a stale import behind - * because the file is written from the list it is no longer in. - * - * There is no second generated file to keep in step, and that is the point. - * Every route's component is the `lazy()` the plugin's own tree carries, so the - * route and the module it renders cannot describe different things - they are - * one declaration. - * - * ## The order of the checks, which is the order of the diagnostics - * - * 1. `compilePluginRouteTrees` - is each route legal on its own, does the tree - * it sits in hold together, and do two of them claim one URL. Every failure - * names the plugin and the route; this layer adds the module each one was - * declared in. - * 2. `assertNoHostRouteCollision` - does a plugin route shadow one of the - * application's own pages. - * - * Deterministic: the manifest is sorted by path and the generated file by plugin - * id, both with code-unit comparisons, so the same plugin configuration produces - * the same bytes on any machine and in any registration order. - */ export const compilePluginRoutes = ({ hostRoutes = [], sources, diff --git a/packages/vitnode/src/framework/plugin-routes/component-source.test.ts b/packages/vitnode/src/framework/plugin-routes/component-source.test.ts index 6bd08a3ed..5a6c95f7c 100644 --- a/packages/vitnode/src/framework/plugin-routes/component-source.test.ts +++ b/packages/vitnode/src/framework/plugin-routes/component-source.test.ts @@ -3,20 +3,6 @@ import { describe, expect, it } from "vitest"; import { lazyImportSpecifier } from "./component-source.js"; -/** - * Reading a page's specifier off a `lazy()` callback, which is the only way a - * build can check that the module a route names exists. - * - * `lazy()` deliberately never calls the callback, so there is nothing to observe - * by running it - and the answer is allowed to be "cannot tell". Every case - * below that returns `null` is a case where failing a build would be guessing. - * - * The callbacks are compiled from source rather than written as real dynamic - * imports, because this test file is itself transformed: Vite rewrites an - * `import()` in a test into a call to its own loader, which is precisely the - * "already rewritten" case the last test pins. What the build actually reads is - * a plugin's compiled `dist`, where the import is still an `import`. - */ const callback = (source: string): unknown => // eslint-disable-next-line @typescript-eslint/no-implied-eval new Function(`return ${source}`)() as unknown; diff --git a/packages/vitnode/src/framework/plugin-routes/component-source.ts b/packages/vitnode/src/framework/plugin-routes/component-source.ts index 706e47bf2..023a650bc 100644 --- a/packages/vitnode/src/framework/plugin-routes/component-source.ts +++ b/packages/vitnode/src/framework/plugin-routes/component-source.ts @@ -1,24 +1,3 @@ -/** - * The module specifier inside a `lazy(() => import("./pages/x"))` callback, read - * off the compiled function. - * - * A best-effort diagnostic, and typed to say so: `null` means "this build cannot - * tell", which is the honest answer for a callback a bundler has already - * rewritten, one that awaits something computed, or one that imports more than - * one module. Nothing depends on the result being present - it exists so that a - * mistyped page path fails the build naming the plugin and the route, instead of - * failing in a browser the first time somebody opens the page. - * - * `Function.prototype.toString` is the only way in: `lazy()` deliberately keeps - * the callback un-called - that is the whole point of it - so the specifier - * cannot be observed by running anything. What it returns is the source of the - * arrow function as it exists in the plugin's build output, which for the shape - * this looks for is `()=>import("./pages/product-page.js")`. - * - * Only *relative* specifiers are returned. A bare one (`"@acme/other/page"`) is - * resolved by the plugin's own dependency graph rather than by a path on disk, - * and the caller has no business guessing where it lives. - */ const IMPORT_CALL = /\bimport\(\s*(?:"([^"]*)"|'([^']*)'|`([^`$\\]*)`)\s*[,)]/g; export const lazyImportSpecifier = (load: unknown): null | string => { diff --git a/packages/vitnode/src/framework/plugin-routes/diagnostics.ts b/packages/vitnode/src/framework/plugin-routes/diagnostics.ts index db2840fc2..8071e6893 100644 --- a/packages/vitnode/src/framework/plugin-routes/diagnostics.ts +++ b/packages/vitnode/src/framework/plugin-routes/diagnostics.ts @@ -1,42 +1,7 @@ import { PluginRouteError } from "../../routing/errors.js"; -/** - * What every build-time plugin route failure is prefixed with. - * - * These surface from inside a Vite config hook, where the stack is all bundler - * internals and the message is the only thing a plugin author will read. The - * prefix is what makes it findable, and it is one constant rather than a string - * each module spells for itself so that grepping for it finds all of them. - */ export const PLUGIN_ROUTES_ERROR_PREFIX = "[VitNode plugin routes]"; -/** - * A build-time failure, with the two things only the build knows added to it. - * - * `@vitnode/core/routing` validates a route and throws a {@link PluginRouteError} - * naming the plugin, the route and - on a collision - the route it collided - * with. What it cannot name is **which file to open**: it is handed plain data - * and has no idea it came from `@vitnode/example/routes/manifest`, or that the - * other side of a collision came from a different package entirely. The build - * knows both, because it is what loaded them. - * - * So the message a plugin author actually sees is assembled here: - * - * [VitNode plugin routes] Plugin route path collision on "/example" (main): - * @vitnode/example already owns "/example" (main), and @vitnode/blog declares - * "/example". Give one of them a different path. - * Declared in "@vitnode/blog/routes". - * The route it conflicts with, "@vitnode/example:page#/example", is declared - * in "@vitnode/example/routes". - * - * A new error rather than a mutated one, and a `PluginRouteError` rather than a - * plain `Error`: `code`, `pluginId`, `routeId` and `path` are structured fields a - * build tool may render its own way, and losing them here would mean the - * annotation cost the caller the machine-readable half of the failure. - * - * Anything that is not a `PluginRouteError` is returned untouched - the resolver - * and the generator write their own messages, already prefixed. - */ export const annotatePluginRouteError = ( error: unknown, routesSpecifiers: ReadonlyMap<string, string>, diff --git a/packages/vitnode/src/framework/plugin-routes/generate.test.ts b/packages/vitnode/src/framework/plugin-routes/generate.test.ts index 9e855255c..233957feb 100644 --- a/packages/vitnode/src/framework/plugin-routes/generate.test.ts +++ b/packages/vitnode/src/framework/plugin-routes/generate.test.ts @@ -5,15 +5,6 @@ import type { ResolvedPluginRoutesModule } from "./types.js"; import { generatePluginRoutesSource } from "./generate.js"; -/** - * The one file an application holds, asserted byte for byte. - * - * Byte for byte rather than "contains", for the two properties the file has to - * have: the same plugin configuration produces the same bytes on any machine - - * because the file lives in `src/` and a rewrite of identical bytes would trip - * the dev server's watcher - and every specifier in it is a *literal*, because a - * bundler cannot follow anything else. - */ const modules = (...pluginIds: string[]): ResolvedPluginRoutesModule[] => pluginIds.map(pluginId => ({ pluginId, specifier: `${pluginId}/routes` })); @@ -38,18 +29,7 @@ import type { PluginRouteDeclarationSource } from '@vitnode/core/routing' `; -const body = (literal: string) => `/** - * Every configured plugin's route tree. - * - * Handed to \`pluginRouteSpecs\` in the app's router, which flattens and validates - * it with the same functions the build used - so the tree the router mounts is - * provably the tree the build checked, and a plugin removed from the config - * takes its routes with it in one step. - * - * \`satisfies\` rather than a type annotation, deliberately: it checks each - * plugin's \`routes\` export really is a \`definePluginRoutes\` tree, naming the - * plugin here rather than failing in a browser. - */ +const body = (literal: string) => ` export const pluginRouteSources = ${literal} as const satisfies readonly PluginRouteDeclarationSource[] `; diff --git a/packages/vitnode/src/framework/plugin-routes/generate.ts b/packages/vitnode/src/framework/plugin-routes/generate.ts index d4665eebb..5464a7344 100644 --- a/packages/vitnode/src/framework/plugin-routes/generate.ts +++ b/packages/vitnode/src/framework/plugin-routes/generate.ts @@ -8,15 +8,6 @@ import { /** Where the generated file imports its type from. */ const TYPES_SPECIFIER = "@vitnode/core/routing"; -/** - * The generated file's header. - * - * `eslint-disable` and the "do not format" line are not decoration: the file is - * rewritten on every build, so anything a linter or a formatter changes in it is - * lost, and a formatter that reflows one entry would make the output depend on - * how long a plugin's name happens to be. It is excluded from both in the app - * that receives it, and this says so at the top for whoever opens it anyway. - */ const HEADER = `/* eslint-disable */ // This file is generated by VitNode. Do not edit it, and do not format it. @@ -38,18 +29,7 @@ import type { PluginRouteDeclarationSource } from '${TYPES_SPECIFIER}' `; -const BODY = `/** - * Every configured plugin's route tree. - * - * Handed to \`pluginRouteSpecs\` in the app's router, which flattens and validates - * it with the same functions the build used - so the tree the router mounts is - * provably the tree the build checked, and a plugin removed from the config - * takes its routes with it in one step. - * - * \`satisfies\` rather than a type annotation, deliberately: it checks each - * plugin's \`routes\` export really is a \`definePluginRoutes\` tree, naming the - * plugin here rather than failing in a browser. - */ +const BODY = ` export const pluginRouteSources = `; const FOOTER = ` as const satisfies readonly PluginRouteDeclarationSource[] diff --git a/packages/vitnode/src/framework/plugin-routes/host-routes.ts b/packages/vitnode/src/framework/plugin-routes/host-routes.ts index b19a27eee..b42c227da 100644 --- a/packages/vitnode/src/framework/plugin-routes/host-routes.ts +++ b/packages/vitnode/src/framework/plugin-routes/host-routes.ts @@ -6,14 +6,6 @@ import { } from "../../routing/path.js"; import { PLUGIN_ROUTES_ERROR_PREFIX } from "./diagnostics.js"; -/** - * One URL the host application's own route files already claim. - * - * `file` is carried alongside the path for one reason, and it is the whole - * reason this is a record rather than a string: a collision is fixed by editing - * a file, and "conflicts with `/settings`" makes an author grep for it while - * "conflicts with `src/routes/_main/settings.tsx`" does not. - */ export interface HostRoutePath { /** Relative to the application root, for the diagnostic. */ file: string; @@ -36,21 +28,8 @@ const PARAM_NAME = /^[a-zA-Z_][a-zA-Z0-9_]*$/; /** A route group folder - `(app)` - which contributes no URL segment. */ const GROUP_TOKEN = /^\(.*\)$/; -/** - * Tokens that mean "this file is the route at the path built so far". - * - * `index` is the index route of the directory it sits in; `route` is that - * directory's own route, the file-based spelling of a layout that also has a - * path. Both claim the prefix rather than adding to it. - */ const SELF_TOKENS: ReadonlySet<string> = new Set(["index", "route"]); -/** - * Suffixes that say *how* a route file is loaded, not *where* it is. - * - * `posts.lazy.tsx` is the lazily-loaded half of `posts`, so the token is dropped - * before the path is read off what is left. - */ const MODIFIER_TOKENS: ReadonlySet<string> = new Set(["lazy"]); type Token = @@ -59,15 +38,6 @@ type Token = | { kind: "splat" } | { kind: "static"; value: string }; -/** - * One filename token, as the thing it contributes to a URL - or `null`. - * - * `null` is "this layer does not know what this is", and it is a decision rather - * than a gap: the whole file is then skipped and claims nothing. A convention - * this reader has not been taught can only ever cost a *missed* collision, which - * the runtime check still catches, and never an *invented* one, which would fail - * a build over a route file that is perfectly fine. - */ const readToken = (token: string): null | Token => { if (token.length === 0) return null; if (GROUP_TOKEN.test(token)) return { kind: "pathless" }; diff --git a/packages/vitnode/src/framework/plugin-routes/index.ts b/packages/vitnode/src/framework/plugin-routes/index.ts index 7f0fb67ce..feb0c273f 100644 --- a/packages/vitnode/src/framework/plugin-routes/index.ts +++ b/packages/vitnode/src/framework/plugin-routes/index.ts @@ -24,37 +24,5 @@ export { sortAndAssertUniquePlugins, toSingleQuotedLiteral, } from "./resolve.js"; -/** - * The build-time compiler for the routes an app's configured plugins ship. - * - * Everything here is pure: plain declarations in, validated data or a source - * string out. There is no `node:fs`, no package resolution and no framework - - * the build tool that owns those (`@vitnode/core/framework/vite`) loads the app - * config and each plugin's `routes` module, and writes what this returns. That - * split is what makes the part which has to be *exactly* reproducible testable - * without a fixture app. - * - * `compilePluginRoutes` is the whole of it, and the reason it is one function - * rather than a pipeline each host assembles: the generated file is written from - * **one resolved snapshot**. The manifest is built and validated first, and the - * file is written from the plugins that survived it - so a plugin reaches an - * `import` only by having declared a tree that validates, and a disabled plugin - * cannot leave a stale import behind because the file is written from the list it - * is no longer in. - * - * compilePluginRoutes - * ├─ compilePluginRouteTrees @vitnode/core/routing flattens & validates - * ├─ assertNoHostRouteCollision does a plugin shadow the app's own - * └─ generatePluginRoutesSource one static import per plugin - * - * What a route *means* - its URL, its shape in the tree, its guard, its message - * namespaces, the module it renders - is not decided here. That is the plugin - * route tree's contract, `@vitnode/core/routing`, and this layer validates - * nothing a route says for itself: it calls that one and adds only what a build - * knows and a plugin cannot - which module each tree came from, and which URLs - * the host application already answers. - * - * How a route is *registered* is the third thing, and belongs to whichever - * router the app happens to run. - */ + export type { ResolvedPluginRoutesModule } from "./types.js"; diff --git a/packages/vitnode/src/framework/plugin-routes/resolve.test.ts b/packages/vitnode/src/framework/plugin-routes/resolve.test.ts index 0fd5e6d82..453b84ae2 100644 --- a/packages/vitnode/src/framework/plugin-routes/resolve.test.ts +++ b/packages/vitnode/src/framework/plugin-routes/resolve.test.ts @@ -9,15 +9,6 @@ import { toSingleQuotedLiteral, } from "./resolve.js"; -/** - * The part of the build that turns configuration into strings a generated file - * can contain. - * - * Everything here is pure, and everything here is a *string* going into a source - * file - which is why the validation is as strict as it is: a plugin id is - * concatenated into an import specifier, and an id nobody checked is an app - * whose generated source says whatever a `package.json` did. - */ describe("sortAndAssertUniquePlugins", () => { it("orders by plugin id, not by configuration order", () => { expect( @@ -192,13 +183,6 @@ describe("routeDeclarationsFromRoutesModule", () => { ).toThrow(/`routes` in @acme\/blog\/routes is not an array/); }); - /** - * The one migration this build can recognise, and the reason it is worth - * recognising: the flat manifest's `entry` was a string the *app* imported, - * and a page is now named by the plugin's own `lazy(() => import(...))`. There - * is no adapter that could turn one into the other, so the shape is named - * rather than half-supported. - */ it("names the old flat manifest rather than failing later", () => { expect(() => routeDeclarationsFromRoutesModule( diff --git a/packages/vitnode/src/framework/plugin-routes/resolve.ts b/packages/vitnode/src/framework/plugin-routes/resolve.ts index e7b959780..11dfa566f 100644 --- a/packages/vitnode/src/framework/plugin-routes/resolve.ts +++ b/packages/vitnode/src/framework/plugin-routes/resolve.ts @@ -2,15 +2,6 @@ import type { ResolvedPluginRoutesModule } from "./types.js"; import { PLUGIN_ROUTES_ERROR_PREFIX as ERROR_PREFIX } from "./diagnostics.js"; -/** - * A plugin id, which in VitNode is also the package name its routes module is - * imported from - `@vitnode/example`, `my-plugin`. - * - * Matched rather than trusted because it is concatenated into an import - * specifier that is then written into a source file. Everything npm allows in a - * name is allowed here; nothing else is, which rules out quotes, whitespace, - * newlines, backslashes and `..` in one go. - */ const PLUGIN_ID_PATTERN = /^(?:@[A-Za-z0-9][A-Za-z0-9._-]*\/)?[A-Za-z0-9][A-Za-z0-9._-]*$/; @@ -44,19 +35,6 @@ export const toSingleQuotedLiteral = (value: string): string => .replace(/\n/g, "\\n") .replace(/\r/g, "\\r")}'`; -/** - * Reads the configured plugin ids out of an already-loaded `vitnode.config.ts`. - * - * The app's config is the source of truth for which plugins exist, so it is also - * the source of truth for whose routes get bundled: a plugin that is installed - * but not listed here contributes nothing, and no `node_modules` scan can - * accidentally put it back. - * - * Pure, and separate from the loading, so the narrowing every generated build - * depends on is testable without a module loader. `source` only ever appears in - * error messages - it is the config's path, which the caller knows and this - * does not. - */ export const pluginIdsFromLoadedConfig = ( loaded: unknown, source: string, @@ -86,20 +64,6 @@ export const pluginIdsFromLoadedConfig = ( }); }; -/** - * The route tree out of an already-loaded plugin `routes` module. - * - * Strict about the *module* and deliberately incurious about the tree: a module - * that exports no `routes`, or a `routes` that is not an array, is named here - * with its specifier in the message. What each node then means is - * `flattenPluginRoutes`' to decide, and it reads every field defensively from - * `unknown` - two readers rather than one shared narrowed shape, so neither - * layer has to know what the other requires. - * - * A plugin still exporting the flat `routes/manifest` shape - records with an - * `entry`, an `id` and a `kind` - is named as such, because the generated file - * would otherwise fail to compile with a type error nobody in the app wrote. - */ export const routeDeclarationsFromRoutesModule = ( loaded: unknown, source: string, @@ -129,13 +93,6 @@ export const routeDeclarationsFromRoutesModule = ( return routes as unknown[]; }; -/** - * Sorts the resolved routes modules by plugin id and rejects duplicates. - * - * Deterministic on purpose: the result is sorted with a code-unit comparison - * rather than `localeCompare`, so the generated bytes do not depend on the - * machine's locale or on the order the plugins were configured in. - */ export const sortAndAssertUniquePlugins = ( modules: readonly ResolvedPluginRoutesModule[], ): ResolvedPluginRoutesModule[] => { diff --git a/packages/vitnode/src/framework/plugin-routes/types.ts b/packages/vitnode/src/framework/plugin-routes/types.ts index 3e840eded..e4e6887ec 100644 --- a/packages/vitnode/src/framework/plugin-routes/types.ts +++ b/packages/vitnode/src/framework/plugin-routes/types.ts @@ -1,14 +1,3 @@ -/** - * One configured plugin's route module, paired with the specifier that imports - * it. - * - * The whole of what the build has to work out per plugin, and deliberately not - * per *route*: a plugin declares its routes in one browser-safe module - `<plugin - * id>/routes` - and the generated file imports that module statically. Which - * page belongs to which route, and which chunk each page ends up in, is decided - * by the literal `import()` inside the plugin's own `lazy()` calls, which Vite - * follows without anything here naming a page. - */ export interface ResolvedPluginRoutesModule { pluginId: string; specifier: string; diff --git a/packages/vitnode/src/framework/vite/env.test.ts b/packages/vitnode/src/framework/vite/env.test.ts index 2cde869e3..4313b5a67 100644 --- a/packages/vitnode/src/framework/vite/env.test.ts +++ b/packages/vitnode/src/framework/vite/env.test.ts @@ -14,12 +14,6 @@ const ENV_FILE = [ "REDIS_PASSWORD=another-secret", ].join("\n"); -/** - * Every key any assertion here depends on, cleared around each test. - * - * The plugin's whole job is reading the ambient environment, so a developer's - * own `.env` or a CI variable would otherwise decide what the define map says. - */ const TOUCHED = [ "CRON_SECRET", "NEXT_PUBLIC_API_URL", diff --git a/packages/vitnode/src/framework/vite/env.ts b/packages/vitnode/src/framework/vite/env.ts index 8beb09a83..2b2be3781 100644 --- a/packages/vitnode/src/framework/vite/env.ts +++ b/packages/vitnode/src/framework/vite/env.ts @@ -2,58 +2,12 @@ import type { Plugin } from "vite"; import { loadEnv } from "vite"; -/** - * The `NEXT_PUBLIC_*` values every VitNode app's browser bundle needs literally. - * - * `@vitnode/core`'s config reads `process.env.NEXT_PUBLIC_API_URL` to build - * absolute API URLs, and it is the same module on both sides of the render - so - * the fetcher running in a client component needs that read to resolve to - * something in a browser, where there is no `process`. Next.js solves this by - * inlining `NEXT_PUBLIC_*` into the client bundle; this is the same trick, so - * the variable names stay exactly as they are and no existing install has to - * rename anything. - * - * An explicit list rather than a prefix rule: everything here is compiled into - * JavaScript that anyone can read, so it should be a decision, not a consequence - * of what somebody happened to call a variable. These two are the ones *this - * package* reads. An application publishes one more through `clientEnv` rather - * than by editing this list - see {@link VitNodeEnvOptions.clientEnv}. - */ const CLIENT_ENV_KEYS = ["NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_WEB_URL"] as const; export interface VitNodeEnvOptions { - /** - * Extra keys to inline into the client bundle, on top of the two above. - * - * For values the *application* reads in the browser, which the package cannot - * know about. Anything named here is public by construction, so the list is - * the place a reviewer looks to see what an app publishes. - * - * An analytics key, a public map token, a second origin an app genuinely - * talks to from the browser. The distinction is ownership rather than - * sensitivity: a value every VitNode install needs belongs on the list above, - * and a value one deployment happens to read belongs to that deployment. VitNode's - * own apps name nothing here. - */ clientEnv?: readonly string[]; } -/** - * Environment handling for a VitNode app on Vite - `@vitnode/core/framework/vite`. - * - * Two halves, deliberately different: - * - * - **Server.** `.env` is loaded into `process.env` so anything reading it at - * config or request time sees it, whatever import runs first. Nothing is - * inlined, so `CONFIG`'s lazy getters keep reading the live environment and a - * built server can still be pointed at a different API by its host. - * - **Client.** Only the keys above plus `clientEnv`, and only as literals in - * the browser bundle. - * - * Secrets - `POSTGRES_URL`, `REDIS_URL`, `CRON_SECRET` - are loaded for the - * server and never defined for the client, which is the entire reason the two - * halves are written separately. - */ export const vitNodeEnv = ({ clientEnv = [], }: VitNodeEnvOptions = {}): Plugin => { diff --git a/packages/vitnode/src/framework/vite/generation-queue.test.ts b/packages/vitnode/src/framework/vite/generation-queue.test.ts index cf7be2c5c..a356f8633 100644 --- a/packages/vitnode/src/framework/vite/generation-queue.test.ts +++ b/packages/vitnode/src/framework/vite/generation-queue.test.ts @@ -2,24 +2,6 @@ import { describe, expect, it } from "vitest"; import { createGenerationQueue } from "./generation-queue"; -/** - * What the dev server's regeneration queue must guarantee, stated as the - * interleavings it exists to prevent. - * - * The failure this is all for is not a crash. It is four generated files that - * describe a plugin configuration nobody has any more, sitting on disk until an - * unrelated edit happens to touch a watched file - so the browser serves a route - * that was deleted, or 404s one that was added, with nothing in the log. - * - * ## Why the passes are functions that resolve when told - * - * A test that ran real passes could only ever assert on how long they happened - * to take, which is the one thing this has to be independent of. So each pass - * here hands back its own `resolve`, and the test settles them in whatever order - * it wants to state: "the older pass finishes last" is a line of code rather - * than a sleep and a hope. - */ - /** A run function whose passes are resolved by the test, in any order. */ const controllablePass = () => { const started: string[] = []; diff --git a/packages/vitnode/src/framework/vite/generation-queue.ts b/packages/vitnode/src/framework/vite/generation-queue.ts index 36b02ce48..4a925b035 100644 --- a/packages/vitnode/src/framework/vite/generation-queue.ts +++ b/packages/vitnode/src/framework/vite/generation-queue.ts @@ -1,88 +1,11 @@ -/** - * The rule that stops an older regeneration overwriting a newer one. - * - * A dev server watches a handful of files that decide what the generated - * projections say - the app's config, every plugin route manifest it has ever - * read, the app's own route file names - and regeneration is asynchronous: it - * resolves several manifests, imports each one, compiles them and writes four - * files. The watcher fires far faster than that. Rebuilding a plugin rewrites - * every file in its `dist`, which is dozens of events inside one second. - * - * Run those concurrently and two failures follow, in order of how hard they are - * to see: - * - * 1. **Stale output wins.** Pass A starts, pass B starts, B finishes first, A - * finishes last - and A's write is the one on disk. The generated files then - * describe a manifest that no longer exists, and nothing corrects them until - * something unrelated happens to touch a watched file. In a browser that is a - * route that vanished still being served, or one that was just added still - * 404ing, with no error anywhere. - * 2. **A pass per file.** Forty watcher events become forty passes, each - * re-reading every manifest, for one logical change. - * - * Both are fixed by the same two rules, and this module is the whole of them: - * - * serialize at most one pass runs at a time - they are chained, not raced - * coalesce at most one pass waits behind it, however many were asked for - * - * Coalescing is safe *because* of serialization, and only because of it: a pass - * re-reads everything from disk when it starts, so the one queued pass sees the - * final state on disk whether it was asked for once or forty times. Nothing is - * lost by collapsing the requests, because none of them carries any information - * beyond "something changed". - * - * ## Why this is its own module - * - * It was six lines inside `configureServer`, which is where it belongs at - * runtime and the one place it could never be tested: asserting that an *older* - * pass cannot finish last needs two passes whose completion order is controlled, - * and inside a Vite plugin the only way to get one is to run a dev server. - * - * Here it is a pure function of a `run` callback and the order its promises - * settle in, so `./generation-queue.test.ts` states the interleaving directly - * and without a filesystem. No timers, no event emitter and no unbounded async: - * one chain, one boolean. - */ - export interface GenerationQueue { - /** - * Whether a pass is running or waiting - for a test, and for a diagnostic. - * - * Never a condition to schedule on: `request` already decides that, and a - * caller that checked this first would race the answer. - */ isBusy: () => boolean; - /** - * Ask for a regeneration. - * - * Returns immediately - the watcher callback that calls this must not block - - * and never rejects: a failing pass is reported through `onError` and the - * chain carries on, because a dev server that stopped regenerating after one - * bad manifest would need a restart to recover from a typo. - */ + request: () => void; - /** - * Resolves when nothing is running or queued. - * - * A test seam, and named as one. Nothing in the dev server awaits this - the - * watcher is fire-and-forget by design - but a test that cannot await the work - * can only assert on timing. - */ + settled: () => Promise<void>; } -/** - * A serialized, coalesced regeneration queue. - * - * `run` is the whole pass: it must re-read its inputs from disk each time it is - * called, which is what makes collapsing several requests into one correct - * rather than merely cheap. - * - * `onError` receives anything `run` rejects with. It is required rather than - * optional so that swallowing a failure has to be written down: a pass that - * throws has left the generated files describing the previous state, and the - * person who caused it is looking at their editor, not at this queue. - */ export const createGenerationQueue = ( run: () => Promise<void>, onError: (error: unknown) => void, diff --git a/packages/vitnode/src/framework/vite/index.ts b/packages/vitnode/src/framework/vite/index.ts index e9516e155..3ac731f6b 100644 --- a/packages/vitnode/src/framework/vite/index.ts +++ b/packages/vitnode/src/framework/vite/index.ts @@ -1,38 +1,3 @@ -/** - * The Vite plugins every VitNode app on Vite needs - `@vitnode/core/framework/vite`. - * - * A `vite.config.ts` is the one file a framework cannot write for an - * application: it names the app's own port, its Nitro options, its Tailwind - * entry. But almost nothing in *these four* is the app's. They read the - * environment the way VitNode's config expects it to be read, they discover - * the routes VitNode's plugins declare, and they name this package's own - * browser dependencies for the dev server's pre-bundler - the same work, in the - * same order, in every install. Kept as files in each app they were 397 lines to - * copy and then to keep in step; here they are one call: - * - * import { vitnode } from '@vitnode/core/framework/vite' - * - * plugins: [ - * vitnode({ appRoot: import.meta.dirname }), - * ... - * ] - * - * The four are still exported individually, for an app that wants to drop or - * reorder one - {@link vitnode} is their composition, not a wrapper around them. - * - * ## Why this is `framework/` and not `tanstack/` - * - * Nothing here is TanStack. `vitNodePluginRoutes` writes framework-neutral data - * and one `import()` per route; which router mounts them is the host's business - * and `@vitnode/core/tanstack/plugin-routes`'. A VitNode app on Vite without - * TanStack Start would use both of these unchanged, and `boundary.test.ts` - * forbids this tree from importing TanStack at all. - * - * These run in Node during Vite's config load, never in a bundle. `vite` is an - * optional peer dependency for exactly that reason: importing this subpath is - * what makes it required, and a Next.js install never does. - */ - export type { VitNodeEnvOptions } from "./env"; export { vitNodeEnv } from "./env"; export { vitNodeOptimizeDeps } from "./optimize-deps"; diff --git a/packages/vitnode/src/framework/vite/module-version.test.ts b/packages/vitnode/src/framework/vite/module-version.test.ts index 0ca4c32c5..a43199f18 100644 --- a/packages/vitnode/src/framework/vite/module-version.test.ts +++ b/packages/vitnode/src/framework/vite/module-version.test.ts @@ -2,18 +2,6 @@ import { describe, expect, it } from "vitest"; import { moduleFileVersion, versionedModuleUrl } from "./module-version"; -/** - * The cache-busting rule, and the three properties that separate it from the - * `Date.now()` hack it is regularly mistaken for. - * - * Node's ESM loader caches by URL with no eviction, so re-reading a file it has - * already imported means asking for a different URL. A clock does that. It also - * mints a permanent module record on every pass over every manifest, makes two - * builds of one tree ask for different URLs, and says nothing about the file. A - * fingerprint does the job and none of the damage, and that is what is asserted - * here: same file, same tag; changed file, changed tag. - */ - const stats = (size: number, mtimeMs: number) => ({ mtimeMs, size }); describe("the version is a fingerprint, not a clock", () => { @@ -26,11 +14,6 @@ describe("the version is a fingerprint, not a clock", () => { ); }); - /** - * Read literally: two calls a measurable interval apart still agree. A - * `Date.now()` implementation fails this line, which is the whole point of - * writing it. - */ it("does not move on its own", async () => { const file = stats(64, 1); const before = moduleFileVersion(file); @@ -46,22 +29,12 @@ describe("the version is a fingerprint, not a clock", () => { ); }); - /** - * The case a size alone misses: a plugin rebuild that produces a manifest of - * exactly the same length - one route id renamed to another of equal width, - * which is an ordinary edit. - */ it("changes when the file is rewritten to the same length", () => { expect(moduleFileVersion(stats(1024, 5))).not.toBe( moduleFileVersion(stats(1024, 6)), ); }); - /** - * And the case an mtime alone misses: a filesystem whose timestamps round - - * a Docker bind mount reporting whole seconds - with two rebuilds inside one - * tick, which a watcher causes routinely. - */ it("changes when only the length moved, for a filesystem whose clock did not", () => { expect(moduleFileVersion(stats(900, 1_700_000_000_000))).not.toBe( moduleFileVersion(stats(901, 1_700_000_000_000)), @@ -111,11 +84,6 @@ describe("the URL the loader is handed", () => { ); }); - /** - * Built through the URL parser rather than by concatenation, so a path that - * already contains a `?`, a `#` or a character needing percent-encoding is - * handled by something that knows the rules. - */ it("encodes a path that would otherwise break the query it is given", () => { const href = versionedModuleUrl("/pkg/we?rd/man#ifest.js", stats(1, 2)); const url = new URL(href); diff --git a/packages/vitnode/src/framework/vite/module-version.ts b/packages/vitnode/src/framework/vite/module-version.ts index 9ce176e22..bde50c6e1 100644 --- a/packages/vitnode/src/framework/vite/module-version.ts +++ b/packages/vitnode/src/framework/vite/module-version.ts @@ -1,66 +1,11 @@ import { pathToFileURL } from "node:url"; -/** - * How a build re-reads a module Node has already imported. - * - * Node's ESM loader caches by URL, permanently and with no eviction API. There - * is no `delete require.cache` for it and no supported way to drop one entry, so - * the only way to get the current bytes of a file that has already been imported - * is to ask for a *different URL* that resolves to the same file - which a query - * string does. - * - * That matters here because the dev server had a watcher that worked and a - * regeneration that could not: edit a plugin's route manifest, the watcher - * fires, the pass runs, and `import()` of the same URL hands back the module - * Node parsed when the server started. The generated files were then rewritten - * from stale declarations - or, more often, not rewritten at all, because the - * bytes had not changed. - * - * ## Why a fingerprint and not a counter or a clock - * - * `?v=${Date.now()}` would also bust the cache, and it is the wrong answer three - * times over: - * - * 1. **It leaks.** Every pass over every manifest mints a URL Node will hold for - * the life of the process, whether the file changed or not. A dev session - * that regenerates a few hundred times accumulates a few hundred copies of - * every plugin's manifest, each with its own module record. - * 2. **It is not reproducible.** Two builds of the same tree ask for different - * URLs, which makes "same configuration, same bytes" a property of when the - * build ran. - * 3. **It hides the question.** A version read *off the file* is a statement - * about the file; a clock is a statement about nothing. - * - * A fingerprint keeps the property that matters - it changes when the file - * changes - and adds the two a clock cannot: an untouched manifest keeps its - * existing cache entry across regenerations rather than leaking a new one, and - * the URL is a function of the tree rather than of the moment. - * - * None of this reaches the generated output. The generated bytes are a function - * of the declarations alone, so the browser never sees a version, a query string - * or a timestamp. - */ - /** The fields of a `Stats` this needs, so a caller may pass a `Stats` or a test may not. */ export interface ModuleFileVersionSource { mtimeMs: number; size: number; } -/** - * A file's identity, as far as "has this changed" goes. - * - * Size *and* mtime, because mtime alone is only as fine-grained as the - * filesystem underneath. APFS and ext4 report sub-millisecond, but a Docker bind - * mount can round to the second, and two rebuilds inside one second is an - * ordinary thing for a watcher to cause. Two versions of a manifest that share a - * timestamp *and* a byte length would still collide; that is a much narrower - * hole than the one this closes, and shutting it completely would mean hashing - * every manifest on every pass. - * - * Pure, and deliberately so: the same stats always produce the same tag, which - * is the property that separates this from a clock and the one the test asserts. - */ export const moduleFileVersion = ({ mtimeMs, size, diff --git a/packages/vitnode/src/framework/vite/no-materialized-routes.test.ts b/packages/vitnode/src/framework/vite/no-materialized-routes.test.ts index f10858ff9..d59a68107 100644 --- a/packages/vitnode/src/framework/vite/no-materialized-routes.test.ts +++ b/packages/vitnode/src/framework/vite/no-materialized-routes.test.ts @@ -15,60 +15,9 @@ import { import { compilePluginRoutes } from "../plugin-routes/compile.js"; import { lazyImportSpecifier } from "../plugin-routes/component-source.js"; -/** - * The generation pass writes **registries**, never pages. - * - * Two claims, one per layer, and they are the two halves of the same invariant: - * - * - `compilePluginRoutes` decides *what* is written, and what it produces is - * data - a validated manifest and one static import per configured plugin. - * Nothing it returns is a framework route module, and nothing it writes names - * a page. - * - `vitNodePluginRoutes` decides *where*, and every destination it can name is - * a `*.gen.ts` at the top of the app's `src/`. It reads the app's routes - * directory and never writes into it. - * - * Pure and static: the compiler is called as the function it is, and the writer - * is read as the text it is. No Vite, no dev server, no app on disk, no - * `node_modules` to resolve. - * - * ## What this is a regression test for - * - * VitNode shipped a plugin route *copier* for years, and the correct fix has now - * been mis-implemented twice in a row in two frameworks - so the invariant is - * worth stating from the generator's own side rather than only from an app's. - * - * WRONG, Next.js era plugin src/routes/main/page.tsx - * → copied into an app's src/app/[locale]/(main)/ - * - * WRONG, the same mistake plugin manifest route "/example/guide/:topic" - * spelled in TanStack → generated as src/routes/_main/example/guide/$topic.tsx - * - * RIGHT plugin manifest route "/example/guide/:topic" - * → one literal import in plugin-routes.gen.ts - * → mounted by withPluginRoutes at runtime - * - * Both wrong shapes fail silently rather than loudly: the app ends up holding a - * second copy of a page nobody wrote, and which of the two runs is decided by a - * router's ranking. `scripts/no-route-copier.test.ts` keeps the first engine - * deleted; this keeps the second from being built. - * - * The routes below are the ones the brief names, including the dynamic child, - * because a dynamic segment is the case a materialising generator has to invent - * a filename for (`$topic.tsx`) and therefore the one where it would show. - */ - const source = (...parts: string[]): string => readFileSync(join(import.meta.dirname, ...parts), "utf8"); -/** - * A page's `lazy()`, as a plugin's own compiled `dist` would carry it. - * - * Written through `new Function` so the `import()` survives this test file's own - * transform: Vite rewrites a real dynamic import in a test into a call to its - * loader, and what the build reads is a plugin's `dist`, where it is still an - * `import`. - */ const lazyPage = (specifier: string) => lazy( // eslint-disable-next-line @typescript-eslint/no-implied-eval @@ -115,16 +64,6 @@ describe("what a compilation produces", () => { ); }); - /** - * The result's shape *is* the contract. One resolved snapshot, the one source - * string written from it, the plugins it imports, and each route's lazy - * component. - * - * Another field - `files`, `routeFiles`, `pages`, anything keyed by a path - - * would be the compiler gaining somewhere else to write, which is the first - * thing a materialising generator needs. Pinned as an exact list so it cannot - * grow quietly. - */ it("returns one source and a snapshot, and nothing keyed by a file path", () => { expect(Object.keys(compiled).sort()).toEqual([ "components", @@ -134,14 +73,6 @@ describe("what a compilation produces", () => { ]); }); - /** - * Neither generated file is a route module. - * - * These are the tokens a TanStack route file cannot be written without, plus - * JSX and the extension itself. A generated page would contain at least one of - * them however it were spelled, and none of them has any business in a file - * whose entire content is data and `import()` calls. - */ it.each([ ["a file route factory", /createFileRoute|createRootRoute/], ["a route constructor", /\bcreateRoute\s*\(/], @@ -154,15 +85,6 @@ describe("what a compilation produces", () => { for (const file of generated) expect(file).not.toMatch(forbidden); }); - /** - * The app imports each plugin's *declaration* module, statically, and nothing - * else - which is the positive claim that makes the deletions above a design - * rather than a gap. - * - * The specifier is a package export subpath, so a relative or app-internal one - * (`./routes/…`, `#/routes/…`, `src/routes/…`) would mean the tree had been - * moved into the application: the copy, arrived by a different road. - */ it("imports each plugin's route tree from its package, statically", () => { for (const module of compiled.modules) { expect(module.specifier).toBe(`${module.pluginId}/routes`); diff --git a/packages/vitnode/src/framework/vite/plugin-routes.test.ts b/packages/vitnode/src/framework/vite/plugin-routes.test.ts index 25eb35f45..0dc20bf5a 100644 --- a/packages/vitnode/src/framework/vite/plugin-routes.test.ts +++ b/packages/vitnode/src/framework/vite/plugin-routes.test.ts @@ -7,32 +7,6 @@ import { generateAdminNavSource } from "../admin-nav"; import { generateContentRegistrySource } from "../content-registry"; import { readOptionalPluginModules } from "./plugin-routes"; -/** - * How an app's two browser-safe projections are discovered, and why they are - * allowed to disagree. - * - * `admin/nav` and `admin/content` are **independent optional package exports**. - * A plugin opts into each by exporting it, and the discovery pass asks one - * question per subpath: - * - * admin/nav does this plugin resolve it? → it is in the sidebar data - * admin/content does this plugin resolve it? → it is in the content registry - * - * Nothing compares the two answers, and nothing may: a plugin with an AdminCP - * settings screen and no content types exports the first and not the second, and - * a plugin whose whole AdminCP presence is its generated content screens has no - * reason to declare navigation beyond what those content types already imply. - * Requiring both would make one of those a build error for no reason. - * - * ## Why a synthetic resolver - * - * `resolvePackageFile` is injected, so "which plugins export what" is an - * argument rather than a fact about `node_modules`. That is what lets this state - * the asymmetric cases at all - this repository's two plugins both export both, - * so a test written against the real workspace could never tell the contract - * from a coincidence. - */ - /** A resolver over a fixed map of specifier → file, as the build sees one. */ const resolverFor = ( @@ -41,12 +15,6 @@ const resolverFor = specifier => exportsBySpecifier[specifier] ?? null; -/** - * Four plugins, one per shape the contract allows. - * - * Written as the files each would resolve to, because that is exactly what the - * discovery pass gets back and the only thing it acts on. - */ const WORKSPACE = resolverFor({ // Both: the shape this repository's own plugins happen to have. "@acme/blog/admin/content": "/pkg/blog/dist/admin/content.js", @@ -123,11 +91,6 @@ describe("what discovery returns", () => { ]); }); - /** - * The resolved files, for the dev server to watch - so a plugin *gaining* a - * subpath while the server runs regenerates instead of needing a restart. Only - * the plugins that resolved contribute one. - */ it("returns one watch file per resolved module and no more", () => { expect(contentOf().watch).toEqual([ "/pkg/blog/dist/admin/content.js", diff --git a/packages/vitnode/src/framework/vite/plugin-routes.ts b/packages/vitnode/src/framework/vite/plugin-routes.ts index f9fcb7bd9..ab91a47a6 100644 --- a/packages/vitnode/src/framework/vite/plugin-routes.ts +++ b/packages/vitnode/src/framework/vite/plugin-routes.ts @@ -33,57 +33,12 @@ import { import { createGenerationQueue } from "./generation-queue"; import { versionedModuleUrl } from "./module-version"; -/** - * Where a plugin declares its routes, as a package export subpath. - * - * `@vitnode/example/routes`, backed by the plugin's own `src/routes.ts`. A - * plugin that does not export it - `@vitnode/blog` today - simply contributes no - * routes. That is not an error: most plugins are AdminCP content types and ship - * no pages at all, and a missing routes module has to mean "none" rather than - * failing the build of every app that installs one. - */ const ROUTES_SUBPATH = "routes"; -/** - * Where a plugin declared its routes before they were a tree. - * - * Resolved for one reason: to say so. A plugin still shipping the flat manifest - * has an `entry`, an `id`, a `kind` and a `parentId` per route, and no adapter - * can turn those into `lazy(() => import(...))` - the module a page lives in was - * a string the *app* imported, and it is now the plugin's own literal import. - * Left undetected, such a plugin contributes nothing at all and the page simply - * 404s. - */ const LEGACY_MANIFEST_SUBPATH = "routes/manifest"; -/** - * Where a plugin declares its AdminCP navigation, as a package export subpath. - * - * Optional in exactly the way the route manifest is: a plugin that does not - * export it contributes no sidebar entries, which is the right answer for most - * plugins and must not fail the build of every app that installs one. - * - * A **browser-safe** module by contract - ids, hrefs, permissions, icons and - * content type definitions, and nothing that renders a screen. It is imported by - * the application's bundle rather than read here, so what this pass does with it - * is only ask whether it resolves. - */ const ADMIN_NAV_SUBPATH = "admin/nav"; -/** - * Where a plugin declares its Content Engine frontend registrations, as a - * package export subpath. - * - * Optional in exactly the way `admin/nav` is, and absent for the same reason: - * most plugins register no content types, and a missing module has to mean - * "none" rather than failing the build of every app that installs one. - * - * A **browser-safe** module by contract - content type definitions, icons, and - * the components that override a generated field, table cell or form layout. It - * carries React components, which is precisely why the generated file imports it - * by a literal specifier instead of serialising anything: this pass only ever - * asks whether it resolves. - */ const ADMIN_CONTENT_SUBPATH = "admin/content"; const ERROR_PREFIX = "[VitNode plugin routes]"; @@ -92,28 +47,8 @@ const ERROR_PREFIX = "[VitNode plugin routes]"; const DEFAULT_HOST_ROUTES_DIR = join("src", "routes"); export interface VitNodePluginRoutesOptions { - /** - * The application's root directory - the one holding its `package.json` and - * its `src/`. - * - * The one thing this plugin cannot work out for itself, and the reason it is - * required rather than defaulted to `process.cwd()`: a Vite config is loaded - * with the working directory set to wherever the command was run, which in a - * monorepo is regularly the repository root. Every path below hangs off this, - * as does the package resolution, so guessing it wrong means reading somebody - * else's config and writing generated files into a directory nothing imports. - * - * vitNodePluginRoutes({ appRoot: import.meta.dirname }) - */ appRoot: string; - /** - * Where this app's own route files live, for the collision check. - * - * Defaults to the file-based router's own answer: `routesDirectory` from - * `tsr.config.json` if the app has one, and `src/routes` otherwise. Pass a - * path to override it, or `null` to turn the build-time check off - the - * runtime one, which reads the real route tree, is unaffected either way. - */ + hostRoutesDir?: null | string; } @@ -121,59 +56,18 @@ export interface VitNodePluginRoutesOptions { const pathsFor = (appRoot: string) => ({ /** The configured plugin list, and the only place it is read from. */ config: join(appRoot, "src", "vitnode.config.ts"), - /** - * The AdminCP navigation projection: one literal import per configured plugin - * that has navigation to declare. Committed and rewritten like the other two, - * and written from the same pass over the same configured plugin list - so a - * plugin removed from the config loses its sidebar entries and its routes in - * one step rather than two. - */ + adminNav: join(appRoot, "src", "admin-nav.gen.ts"), - /** - * The Content Engine frontend registry: one literal import per configured - * plugin that registers content types. Written from the same pass over the - * same configured plugin list as the other three, so a plugin removed from - * the config loses its routes, its sidebar entries and its content screens in - * one step rather than three. - */ + contentRegistry: join(appRoot, "src", "content-registry.gen.ts"), - /** - * The plugin route registry: one static import per configured plugin that - * declares routes, and the trees they declared. Committed, and rewritten only - * when it changes. - */ + registry: join(appRoot, "src", "plugin-routes.gen.ts"), - /** - * The data file the registry replaced. - * - * Deleted rather than ignored. It was a literal copy of every route's manifest - * entry, and a route's `entry` and `searchEntry` are not fields a plugin has - * any more - so a stale copy left in `src/` is a file that still compiles, - * still imports `@vitnode/core/routing`, and describes routes the app no - * longer has. - */ + staleManifest: join(appRoot, "src", "plugin-route-manifest.gen.ts"), /** The file-based router's config, read only for where the routes are. */ routerConfig: join(appRoot, "tsr.config.json"), }); -/** - * Resolution as the *application* would do it, honouring each package's - * `exports`. - * - * `createRequire` rather than `import.meta.resolve`, and the difference matters: - * every VitNode plugin maps `"./*"` to `"./dist/src/*.js"`, and - * `import.meta.resolve` answers a *pattern* match without ever touching the - * disk - it happily returns a URL for `@vitnode/example/routes/nope`. The CJS - * resolver stats the file, so a wrong entry is caught here instead of becoming a - * 404 in a browser. `existsSync` is checked anyway, because being wrong about - * this is the failure mode this whole step exists to prevent. - * - * Anchored on the app's `package.json` rather than on this module, which is what - * makes the answer the same one the app's own bundler will get. Anchoring it - * here would resolve a plugin from *this package's* `node_modules` - a different - * tree, and in a hoisted install possibly a different copy. - */ const resolverFor = (appRoot: string) => { const requireFromApp = createRequire(join(appRoot, "package.json")); @@ -188,37 +82,6 @@ const resolverFor = (appRoot: string) => { }; }; -/** - * The plugins this app is configured with, in configuration order. - * - * `jiti` because `vitnode.config.ts` is TypeScript that imports other TypeScript - * without extensions, which Node's own type stripping will not load. It is the - * same loader `vitnode`'s own CLI scripts use to read this file. - * - * A plugin that is installed but not listed here is not consulted, and nothing - * ever enumerates `node_modules` - so disabling a plugin removes its routes from - * the bundle by construction rather than by a filter somebody has to remember. - * - * Rooted at the app's `package.json`, for the same reason the resolver above is: - * the config's own imports have to resolve the way they will for the app. - * - * ## Why the module cache is off - * - * The same trap `readPluginRoutes` below busts with a versioned URL, one layer - * up and with a worse symptom. `jiti`'s module cache is keyed by filename and - * shared across instances, so a *fresh* `createJiti` per pass is not a fresh - * read: the second regeneration of a dev-server session gets back the config - * Node evaluated when the server started. Editing `vitnode.config.ts` then does - * nothing at all - and because the plugin list is what decides *which* manifests - * are read, a plugin removed from the config keeps its routes in both generated - * files, and in the running route tree, until the server is restarted. - * - * `moduleCache: false` is the whole fix. It costs a re-execution of the config - * and its imports on every pass - about 3ms here, against a watcher event rather - * than a request - because `fsCache` still keeps the transpilation. A versioned - * specifier like the one below is not available: jiti resolves a path, not a - * URL, so there is nowhere to hang the version. - */ const readConfiguredPluginIds = async ( appRoot: string, configPath: string, @@ -237,47 +100,6 @@ const readConfiguredPluginIds = async ( export const configuredPluginIds = async (appRoot: string): Promise<string[]> => await readConfiguredPluginIds(appRoot, pathsFor(appRoot).config); -/** - * One plugin's route tree, loaded from its compiled `routes` module. - * - * A route tree is browser-safe data by contract - paths, shells, message lists - * and one `lazy()` per page - so this is a normal `import()` of the plugin's - * build output in Node. No page, no layout, no React and no router is evaluated - * to find out which routes exist: `lazy()` stores the import callback without - * calling it, which is the whole point of it. - * - * `routeDeclarationsFromRoutesModule` checks the *module* is a routes module at - * all - it exports a `routes` array - and names the specifier when it is not. - * What each node then means is `flattenPluginRoutes`' to decide, and the array is - * handed on untouched for it: it validates every field it reads, defensively, - * from `unknown`. Two readers rather than one shared narrowed shape, so neither - * layer has to know what the other requires. - * - * A plugin with no `routes` module contributes nothing, and one still shipping - * the old flat `routes/manifest` is told so rather than silently contributing - * nothing - see {@link LEGACY_MANIFEST_SUBPATH}. - * - * ## Why the URL carries an mtime - * - * Node's ESM loader caches modules by URL, permanently and with no eviction. The - * dev server therefore had a watcher that worked and a regeneration that could - * not: edit a plugin's routes, the watcher fires, a regeneration pass runs, and - * `import()` of the same URL hands back the module Node parsed minutes ago - so - * the generated file was rewritten from stale declarations, or more often not - * rewritten at all because the bytes had not changed. - * - * A version taken off the file itself is the smallest thing that fixes it and - * keeps every property that matters: it changes when the file changes, so an - * untouched module keeps its cache entry across regenerations rather than - * leaking a new one, and it is read off disk rather than invented, so two builds - * of the same tree ask for the same URL. It never reaches the generated output - - * the generated bytes are a function of the configured plugins alone - so the - * browser never sees any of this. - * - * `./module-version` is the rule itself, and why it is a fingerprint rather - * than a clock. `statSync` is here because this is the layer that has a - * filesystem. - */ const readPluginRoutes = async ( pluginId: string, resolvePackageFile: (specifier: string) => null | string, @@ -334,18 +156,6 @@ interface HostRoutesConfig { ignore: null | RegExp; } -/** - * Where the app's own route files are, as its file-based router sees it. - * - * `tsr.config.json` is read rather than assumed, and both of the fields that - * decide *which files are routes* are honoured, because a check that disagreed - * with the router about that would fail a build over a file the router never - * turned into a route. `routeFileIgnorePattern` in particular is what an app - * reaches for when something that is not a page ends up in its routes directory. - * - * A malformed or absent config is not an error here - it means "the default", - * which is what a router without one would do too. - */ const hostRoutesConfigFor = ( appRoot: string, configured: null | string | undefined, @@ -549,24 +359,6 @@ const assertComponentsImportable = ( } }; -/** - * Everything the generated files are built from, discovered at build time only. - * - * `Promise.all` over the configured ids keeps the result independent of which - * manifest happens to load first, and the compiler sorts on top of that - so the - * bytes depend on the configuration and nothing else. - * - * The split here is the one this whole layer is arranged around: *this* function - * owns the filesystem - the config, the plugins' route modules, the app's own - * route files, package resolution - and `compilePluginRoutes` owns every decision - * made from what it finds. Which is why the only thing left below it is the one - * check that cannot be pure: does each lazily imported page resolve to a file - * that exists. - * - * `onLoaded` is called with the files this pass read *before* anything can fail, - * so a dev server watching them still learns about a routes module that threw - - * which is exactly the one an author is about to edit again. - */ const discover = async ( appRoot: string, options: VitNodePluginRoutesOptions, @@ -625,28 +417,12 @@ const discover = async ( }; }; -/** - * Writes a generated file, and only if it changed. - * - * The write-if-changed is load bearing, not an optimisation: these files live in - * `src/`, so rewriting identical bytes on every dev-server event would trip - * Vite's watcher and reload the page in a loop - the same trap two route - * generators writing `routeTree.gen.ts` fall into. - */ const writeIfChanged = async (path: string, source: string): Promise<void> => { const current = existsSync(path) ? await readFile(path, "utf8") : null; if (current !== source) await writeFile(path, source, "utf8"); }; -/** - * Removes a generated file this build no longer writes. - * - * Only ever pointed at a path VitNode itself generated, and only when that file - * has been replaced rather than merely emptied: a stale generated module in - * `src/` still compiles and still describes routes, which is a worse failure - * than a missing one. - */ const removeIfPresent = async (path: string): Promise<void> => { if (!existsSync(path)) return; @@ -677,35 +453,6 @@ const writeGenerated = async ( ]); }; -/** - * Build-time discovery of the route modules an app's plugins ship - - * `@vitnode/core/framework/vite`. - * - * The boundary this plugin exists to draw: - * - * - **Here, at build time.** Read the configured plugins, load their route - * manifests from `node_modules`, check every entry resolves to a real file, - * validate every route, reject two plugins claiming one URL and a plugin - * claiming one of the app's own, then write `src/plugin-routes.gen.ts` - one - * static import per plugin that exports a `routes` module - and, from the same - * configured plugin list, `src/admin-nav.gen.ts` and - * `src/content-registry.gen.ts`. - * - **In the browser.** Import those three files. They contain literal data, - * literal `import()` calls and literal specifiers and nothing else - no - * `node:fs`, no package resolution, no validation to repeat and no specifier - * built from a variable, and so nothing a bundler cannot follow. - * - * Routes and navigation are discovered in one pass and stay separate concepts: - * neither generated file is derived from the other, and a plugin may have a - * sidebar entry with no route, a route with no entry, or both. - * - * Nothing is copied. The plugin's page stays in the plugin, compiled in its own - * `dist`, and the app holds one generated line of registration per route. - * - * Everything about this is the same for every VitNode app on Vite, which is why - * it ships here rather than being a file each one keeps a copy of. The only - * thing an application supplies is where it lives. - */ export const vitNodePluginRoutes = ( options: VitNodePluginRoutesOptions, ): Plugin => { @@ -717,40 +464,10 @@ export const vitNodePluginRoutes = ( config: async () => { await writeGenerated(appRoot, options); }, - /** - * Regenerates while the dev server runs, so editing a plugin's manifest is - * enough. Adding or removing a plugin in `vitnode.config.ts` is picked up - * too, and so is adding one of the app's own route files - which is the - * event that can turn a legal plugin route into a collision. - * - * A manifest that did not exist when the server started is not watched, - * because there is no file to watch yet; restart for that, exactly as - * installing a plugin already requires. A manifest that existed and was - * *replaced* - which is what rebuilding a plugin does to its `dist` - is, - * because a file this pass has ever read stays watched even after it is - * deleted. - */ + configureServer: server => { const watched = new Set<string>([configPath]); - /** - * The regeneration chain, and the one pass allowed to be waiting on it. - * - * Regeneration is asynchronous - it resolves several manifests and writes - * four files - and the watcher can fire many times before the first pass - * finishes. Run concurrently, two passes interleave and the *older* one can - * perform the last write, leaving generated files that describe a manifest - * that no longer exists until something else happens to touch it. - * - * `createGenerationQueue` is that rule and only that rule: passes are - * chained rather than parallel, and at most one is queued behind the - * running one, because a pass re-reads everything from disk when it starts - * and so the queued one sees the final state whether it was asked for once - * or forty times. That is what keeps a `dist` rebuild - which rewrites - * every file a plugin has - from queueing a pass per file. See - * `./generation-queue.ts`, where it is stated without a dev server so it - * can be tested. - */ const queue = createGenerationQueue( async () => writeGenerated(appRoot, options, files => { @@ -762,14 +479,6 @@ export const vitNodePluginRoutes = ( }, ); - /** - * Whether a file this pass sees can change what the generated files say. - * - * The config and any manifest ever read, for the obvious reason. Route - * *files* of the app only by their existence - a route file's contents - * cannot move the URL it claims, which is in its name - so a change to one - * is ignored and an add or a delete is not. - */ const isRelevant = (file: string, existenceOnly: boolean): boolean => { if (watched.has(file)) return true; if (!existenceOnly || routesDir === null) return false; diff --git a/packages/vitnode/src/framework/vite/projections.test.ts b/packages/vitnode/src/framework/vite/projections.test.ts index c13999221..1575af025 100644 --- a/packages/vitnode/src/framework/vite/projections.test.ts +++ b/packages/vitnode/src/framework/vite/projections.test.ts @@ -10,35 +10,6 @@ import { generateContentRegistrySource } from "../content-registry"; import { compilePluginRoutes } from "../plugin-routes"; import { readOptionalPluginModules } from "./plugin-routes"; -/** - * All three generated projections, from one configured plugin list, in one pass. - * - * Each generator has its own determinism test beside it, and each says the same - * thing about itself: sorted input, sorted output, same bytes. What none of them - * can say is the thing that actually matters to an installation - that the four - * are projections of **one list**, so a plugin cannot be half-enabled. - * - * That is the property this file is for, and it is not hypothetical. The three - * files are read by three different parts of the app, and they used to be - * written by separate passes: - * - * plugin-routes.gen.ts which plugins have routes, and their trees - * admin-nav.gen.ts what the AdminCP sidebar shows - * content-registry.gen.ts which content types have screens - * - * A plugin present in one and absent from another is not a build error anywhere - * - each file is individually valid - and the symptoms are all somewhere else: a - * sidebar entry whose page 404s, a content screen with no route, a route the - * router still claims for a plugin nobody configured. So "removed from the - * config" has to mean removed from all three, and that is asserted here rather - * than left to three files each checking its own half. - * - * Pure throughout: a resolver over a fixed map stands in for `node_modules`, and - * every generator takes data and returns a string. There is no dev server here - - * `./generation-queue.test.ts` owns the concurrency, and Agent G owns the smoke - * test. - */ - /** A resolver over a fixed map of specifier → file, as the build sees one. */ const resolverFor = (files: Record<string, string>): ((specifier: string) => null | string) => @@ -107,12 +78,6 @@ const FILES = ["adminNav", "contentRegistry", "registry"] as const; const BOTH = ["@acme/blog", "@acme/shop"]; describe("determinism, across every projection at once", () => { - /** - * The property each generator claims for itself, asserted for all four - * together: the bytes are a function of *which plugins are configured* and of - * nothing else - not of the order they were listed in, not of which manifest - * happened to resolve first, not of the machine. - */ it("is byte-identical whichever order the plugins were configured in", () => { const forwards = projectionsFor(["@acme/blog", "@acme/shop"]); const backwards = projectionsFor(["@acme/shop", "@acme/blog"]); @@ -131,12 +96,6 @@ describe("determinism, across every projection at once", () => { }); }); - /** - * Sorting is the generators' own, not the caller's. Every one of them re-sorts - * what it is given, which is what makes "same configuration, same bytes" a - * property of the functions rather than a promise about how they are called - - * and it is why nothing above has to hand them a sorted list. - */ it("sorts inside each generator, so no caller has to", () => { const shuffled = projectionsFor(["@acme/shop", "@acme/blog"]); @@ -161,11 +120,6 @@ describe("a plugin is enabled, or it is not - never half of each", () => { }); }); - /** - * The one that matters. Disabling a plugin has to take its routes, its - * sidebar entries and its content screens away together - a sidebar entry that - * outlived its route is a 404 nobody can attribute to a config edit. - */ it("removes a disabled plugin from all three, in one step", () => { const disabled = projectionsFor(["@acme/blog"]); @@ -185,12 +139,6 @@ describe("a plugin is enabled, or it is not - never half of each", () => { }); }); - /** - * No orphan imports: a disabled plugin leaves neither an `import` statement - * nor an `import()` call behind. The generated files are rewritten from the - * list its routes are no longer in, rather than filtered - so there is nothing - * left to go stale. - */ it("leaves no import naming a plugin that is gone", () => { const disabled = projectionsFor(["@acme/blog"]); const specifiers = FILES.flatMap(file => @@ -211,11 +159,6 @@ describe("a plugin is enabled, or it is not - never half of each", () => { }); }); - /** - * Re-enabling is not a special case and must not be: the projections are a - * function of the current list, so the bytes an app had before a plugin was - * disabled are the bytes it gets back when the plugin returns. - */ it("restores exactly the previous bytes when a plugin is re-enabled", () => { const before = projectionsFor(BOTH); @@ -240,14 +183,6 @@ describe("a plugin is enabled, or it is not - never half of each", () => { }); describe("the generated bytes are canonical as written", () => { - /** - * Each file says so at the top, and that line is load-bearing rather than - * decorative: these are rewritten on every build, so anything a formatter - * changes is lost - and a formatter that reflowed one entry would make the - * output depend on how long a plugin's name happens to be. The apps exclude - * them from linting and formatting; this is the assertion that the files still - * say why. - */ it("tells a reader not to edit or format them", () => { const generated = projectionsFor(BOTH); diff --git a/packages/vitnode/src/framework/vite/shared-config-discovery.test.ts b/packages/vitnode/src/framework/vite/shared-config-discovery.test.ts index 66ac9355b..a6cf4587d 100644 --- a/packages/vitnode/src/framework/vite/shared-config-discovery.test.ts +++ b/packages/vitnode/src/framework/vite/shared-config-discovery.test.ts @@ -5,21 +5,6 @@ import { describe, expect, it } from "vitest"; import { pluginIdsFromLoadedConfig } from "../plugin-routes"; -/** - * How the build finds out which plugins an app configured. - * - * The generator loads `src/vitnode.config.ts` with `jiti` while Vite is still - * resolving its own config, reads `plugins[].pluginId`, and generates one - * literal import per plugin into three committed files. That is the entire - * contract, and the property worth pinning is what it must *not* need: the - * app's message loaders, its React content registries, or anything else that - * only makes sense on a server. - * - * `test-fixtures/config-split/` is a config pair exactly as an app writes one, - * except that its server half throws at module scope. So a discovery pass that - * reached the wrong file fails here rather than becoming a slow build somebody - * eventually profiles. - */ const fixtureRoot = resolve( import.meta.dirname, "../../../test-fixtures/config-split", diff --git a/packages/vitnode/src/framework/vite/vitnode.test.ts b/packages/vitnode/src/framework/vite/vitnode.test.ts index b6c0b83da..f050f0f36 100644 --- a/packages/vitnode/src/framework/vite/vitnode.test.ts +++ b/packages/vitnode/src/framework/vite/vitnode.test.ts @@ -2,14 +2,6 @@ import { describe, expect, it } from "vitest"; import { vitnode } from "./vitnode"; -/** - * One VitNode plugin in a `vite.config.ts`, instead of four in a fixed order. - * - * A Vite plugin may be an array, so this is composition rather than a wrapper - - * and what that buys is the thing worth pinning: an app cannot install two of - * the four, or install them in an order that puts the route generator ahead of - * the environment it reads. - */ const names = (plugins: { name: string }[]) => plugins.map(({ name }) => name); describe("the composed plugin", () => { diff --git a/packages/vitnode/src/framework/vite/vitnode.ts b/packages/vitnode/src/framework/vite/vitnode.ts index 86c0ea2a9..7632925d0 100644 --- a/packages/vitnode/src/framework/vite/vitnode.ts +++ b/packages/vitnode/src/framework/vite/vitnode.ts @@ -11,27 +11,6 @@ import { vitNodeSsrExternals } from "./ssr-externals"; export interface VitNodeViteOptions extends VitNodeEnvOptions, VitNodePluginRoutesOptions {} -/** - * Everything a VitNode app needs from Vite, as one plugin. - * - * plugins: [ - * vitnode({ appRoot: import.meta.dirname }), - * nitro(), - * tailwindcss(), - * tanstackStart(), - * viteReact(), - * ] - * - * A Vite plugin may be an array, so this is the composition and not a wrapper: - * the four plugins underneath are returned in the order they have to run, and - * each is still exported on its own for an app that genuinely wants to drop or - * reorder one. - * - * `appRoot` is the only required option, and it has to be `import.meta.dirname`: - * a Vite config is loaded with the working directory set to wherever the command - * ran, which in a monorepo is regularly the repository root. Every path the route - * generator reads hangs off it. - */ export const vitnode = ({ appRoot, clientEnv, diff --git a/packages/vitnode/src/hooks/use-captcha.ts b/packages/vitnode/src/hooks/use-captcha.ts index c3042c2bf..af1a741ae 100644 --- a/packages/vitnode/src/hooks/use-captcha.ts +++ b/packages/vitnode/src/hooks/use-captcha.ts @@ -36,28 +36,6 @@ declare global { } } -/** - * Loading a captcha widget, without knowing which framework is rendering it. - * - * This hook is reached by every `AutoForm`, so what it imports decides what an - * `AutoForm` can be rendered by - and it used to import `@/lib/navigation`, - * which is built on `next-intl/navigation` and `next-intl/server`. That single - * line made the whole form stack Next-only: the shared sign-in form could not be - * mounted from a TanStack Start route, because resolving it reached - * `next/headers`. - * - * The pathname it read was an effect dependency and nothing else - "tear the - * widget down and inject it again when the URL changes". Mounting already does - * that: each of the three forms that ask for a captcha lives on its own route, - * so a navigation unmounts one and mounts the next, and the effect's cleanup and - * setup run either way. What it did *not* cover is a language switch, which is - * still a dependency below because the widget is rendered in the visitor's - * language. - * - * `use-intl` rather than `next-intl` for the same reason - the same module - * record either way, and one that a TanStack Start app can resolve. - */ - export const useCaptcha = ( captcha: z.infer<typeof routeMiddlewareSchema>["captcha"], ) => { diff --git a/packages/vitnode/src/lib/admin-nav-source.test-d.ts b/packages/vitnode/src/lib/admin-nav-source.test-d.ts index e7d4777e6..1f4878163 100644 --- a/packages/vitnode/src/lib/admin-nav-source.test-d.ts +++ b/packages/vitnode/src/lib/admin-nav-source.test-d.ts @@ -4,20 +4,6 @@ import type { AdminNavConfig } from "@/views/admin/layouts/sidebar/nav/nav-model import type { AdminNavPluginSource } from "./plugin"; -/** - * The browser-safe navigation source, as a *type* relationship. - * - * One claim, and everything about the projection rests on it: an - * `AdminNavPluginSource` is a configured plugin as far as the navigation model - * is concerned. That is what lets `adminNavDeclarations` read a generated list - * of these with the same rules it reads a real `VitNodeConfig.plugins` - one - * navigation model, one set of rules, whichever door the data came through. - * - * A type test rather than a runtime one because there is nothing to run: if the - * assignment holds, a host passing the projection compiles, and if it stops - * holding the failure is a type error at every call site rather than a wrong - * sidebar. - */ describe("AdminNavPluginSource", () => { it("is what the navigation model calls a plugin", () => { expectTypeOf<AdminNavPluginSource[]>().toExtend< @@ -31,12 +17,6 @@ describe("AdminNavPluginSource", () => { >(); }); - /** - * And a content type on it is *narrower* than a registration, which is the - * half that keeps a browser bundle small: a full registration carries field, - * column and form overrides - React components that reach the Content Engine's - * editing UI - and this carries what the sidebar reads and nothing else. - */ it("carries a content type without its editing screens", () => { type ContentType = NonNullable< AdminNavPluginSource["contentTypes"] diff --git a/packages/vitnode/src/lib/api/get-queue-backoff-date.ts b/packages/vitnode/src/lib/api/get-queue-backoff-date.ts index ebb2d5a38..c7da88c83 100644 --- a/packages/vitnode/src/lib/api/get-queue-backoff-date.ts +++ b/packages/vitnode/src/lib/api/get-queue-backoff-date.ts @@ -1,11 +1,6 @@ const BASE_DELAY_SECONDS = 10; const MAX_DELAY_SECONDS = 60 * 60; -/** - * Exponential backoff for a failed queue task. `attempts` is the number of - * attempts already made (>= 1); the delay grows as - * `BASE_DELAY_SECONDS * 2^(attempts - 1)`, capped at {@link MAX_DELAY_SECONDS}. - */ export const getQueueBackoffDate = ( attempts: number, from: Date = new Date(), diff --git a/packages/vitnode/src/lib/api/pg-error.ts b/packages/vitnode/src/lib/api/pg-error.ts index 517eacc08..2628b1eed 100644 --- a/packages/vitnode/src/lib/api/pg-error.ts +++ b/packages/vitnode/src/lib/api/pg-error.ts @@ -2,24 +2,11 @@ export const PG_ERROR_CODES = { foreignKeyViolation: "23503", notNullViolation: "23502", - /** - * `restrict_violation`. Postgres 17 reports this for a `NO ACTION`/`RESTRICT` - * foreign key where earlier majors reported `23503`, so anything that acts on - * "still referenced" has to accept both. - */ + restrictViolation: "23001", uniqueViolation: "23505", } as const; -/** - * Digs the Postgres error code out of whatever the driver threw. - * - * Drizzle wraps driver failures in a `DrizzleQueryError` whose own `code` is - * undefined and whose `cause` holds the real error, so reading `error.code` - * alone would turn every constraint violation into a 500. The depth limit is - * there because a `cause` chain is attacker-adjacent data in the sense that - * matters here: it is arbitrary and can be cyclic. - */ export const pgErrorCode = (error: unknown, depth = 0): string | undefined => { if (typeof error !== "object" || error === null || depth > 3) { return undefined; @@ -31,12 +18,6 @@ export const pgErrorCode = (error: unknown, depth = 0): string | undefined => { return pgErrorCode(cause, depth + 1); }; -/** - * Whether a driver failure means "another row still points at this one". - * - * Both codes, for the reason `restrictViolation` documents: the same refused - * delete reports one on Postgres 17 and the other on 16. - */ export const isPgReferenceViolation = (error: unknown): boolean => { const code = pgErrorCode(error); diff --git a/packages/vitnode/src/lib/api/resolve-queue-task-outcome.ts b/packages/vitnode/src/lib/api/resolve-queue-task-outcome.ts index 78fac74d7..a6e52542f 100644 --- a/packages/vitnode/src/lib/api/resolve-queue-task-outcome.ts +++ b/packages/vitnode/src/lib/api/resolve-queue-task-outcome.ts @@ -9,12 +9,6 @@ export interface QueueTaskOutcome { status: QueueTaskStatus; } -/** - * Decide the next state of a task after its handler ran. `attempts` is the - * count including the run that just happened. Without an error the task is - * `completed`; with one it is retried (`pending` with a backoff `availableAt`) - * until `maxAttempts` is reached, after which it is `failed`. - */ export const resolveQueueTaskOutcome = ({ attempts, maxAttempts, diff --git a/packages/vitnode/src/lib/api/signed-token.ts b/packages/vitnode/src/lib/api/signed-token.ts index b8195f9fe..35a99f2fe 100644 --- a/packages/vitnode/src/lib/api/signed-token.ts +++ b/packages/vitnode/src/lib/api/signed-token.ts @@ -2,21 +2,6 @@ import type { z } from "zod"; import crypto from "node:crypto"; -/** - * A payload and its HMAC, in one URL-safe string. - * - * ```text - * base64url(JSON.stringify(payload)) "." base64url(hmacSha256(secret, part1)) - * ``` - * - * Stateless on purpose: the alternative is an opaque id backed by a table, - * which buys revocation at the cost of a row per link and a write on every - * click. Where that trade is wrong, rotate the secret - it invalidates every - * outstanding token at once. - * - * This is **not** encryption. The payload is readable by anyone holding the - * token; the signature only proves nobody edited it. Never put a secret in one. - */ const encodePayload = (payload: unknown): string => Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); diff --git a/packages/vitnode/src/lib/api/upload.ts b/packages/vitnode/src/lib/api/upload.ts index ebbb0478b..6d4bad362 100644 --- a/packages/vitnode/src/lib/api/upload.ts +++ b/packages/vitnode/src/lib/api/upload.ts @@ -3,11 +3,6 @@ import { randomUUID } from "node:crypto"; import { getFileExtension, replaceFileExtension } from "../file-extension"; -/** - * Re-exported rather than defined here: `AutoFormFile` runs the same extension - * check in the browser, and this module cannot cross that boundary - it imports - * `node:crypto` at the top level. - */ export { getFileExtension, replaceFileExtension }; /** One path segment: letters, numbers, hyphens and underscores, never leading. */ @@ -47,12 +42,6 @@ export const sanitizeFolder = (folder: string): string => { return folder; }; -/** - * Collision-free stored file name: a random UUID keeps the original extension - * but discards the user-provided name, so no lookups or races are needed. Pass - * `extension` (including the leading dot) to override the extension, e.g. when - * an image has been converted to a different format. - */ export const generateStorageFileName = ( originalName: string, extension?: string, diff --git a/packages/vitnode/src/lib/api/validate-cron-schedule.ts b/packages/vitnode/src/lib/api/validate-cron-schedule.ts index 5eafa51c1..7046a8d5a 100644 --- a/packages/vitnode/src/lib/api/validate-cron-schedule.ts +++ b/packages/vitnode/src/lib/api/validate-cron-schedule.ts @@ -1,17 +1,3 @@ -/** - * Validates a cron schedule expression - * Supports standard cron format: minute hour day month weekday - * Also supports extended format with seconds (6 fields): second minute hour day month weekday - * - * - * @example - * ```typescript - * validateCronSchedule("0 0 * * *") // true - runs at midnight every day - * validateCronSchedule("*\/5 * * * *") // true - runs every 5 minutes - * validateCronSchedule("0 0 1 * *") // true - runs at midnight on the first day of each month - * validateCronSchedule("invalid") // false - * ``` - */ export function validateCronSchedule(schedule: string): boolean { if (!schedule || typeof schedule !== "string") { return false; diff --git a/packages/vitnode/src/lib/config.test.ts b/packages/vitnode/src/lib/config.test.ts index f823734ca..c8cae55ce 100644 --- a/packages/vitnode/src/lib/config.test.ts +++ b/packages/vitnode/src/lib/config.test.ts @@ -2,11 +2,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CONFIG } from "./config"; -/** - * Runs `read` with the page served from `origin`, the way a browser would have - * it. `location` is a real object under jsdom, so it is replaced wholesale - * rather than assigned to. - */ const inBrowserAt = <T>(origin: string | undefined, read: () => T): T => { vi.stubGlobal("location", origin === undefined ? undefined : { origin }); diff --git a/packages/vitnode/src/lib/config.ts b/packages/vitnode/src/lib/config.ts index ec82c0d3f..b65323982 100644 --- a/packages/vitnode/src/lib/config.ts +++ b/packages/vitnode/src/lib/config.ts @@ -1,29 +1,6 @@ -/** - * Fallback used when `CRON_SECRET` is not set. It is intentionally well-known: - * the admin integrations panel flags cron as insecure while this value is in - * use so it is obvious a real secret must be provided in production. - */ export const INSECURE_DEFAULT_CRON_SECRET = "default-cron-secret-change-in-production"; -/** - * The origin the page itself was served from, when there is one. - * - * VitNode mounts its API on the app's own origin - `https://example.com` serving - * `https://example.com/api/*` - so in a browser "where is the API" answers - * itself, without configuration. That matters most exactly where configuration - * cannot help: a preview deployment's hostname is generated per branch, so no - * `NEXT_PUBLIC_API_URL` could have named it ahead of time, and the value it - * would otherwise fall back to names the visitor's own machine. - * - * The server half of the same answer is read off the request being handled; see - * `resolveApiOrigin` in the TanStack Start app - which, like the browser, gives - * an explicitly set `NEXT_PUBLIC_API_URL` precedence, because a split - * deployment's web app has no `/api/*` of its own to answer. - * - * `undefined` wherever there is no document - Node, the API server, a build - so - * those keep falling through to the configured value. - */ const browserOrigin = (): string | undefined => { if (typeof location === "undefined") return undefined; @@ -32,12 +9,6 @@ const browserOrigin = (): string | undefined => { return location.origin.startsWith("http") ? location.origin : undefined; }; -/** - * Env is read lazily via getters, not captured at module load. The standalone - * API loads its `.env` (dotenv) only when `vitnode.api.config.ts` runs, which can - * be after this module is first imported - reading on access ensures values like - * `NEXT_PUBLIC_API_URL` are always current instead of frozen to their fallbacks. - */ export const CONFIG = { get api(): URL { // `??` rather than `||`, deliberately: an empty `NEXT_PUBLIC_API_URL` is a diff --git a/packages/vitnode/src/lib/fetcher-client.ts b/packages/vitnode/src/lib/fetcher-client.ts index 34bc07ee3..f32c05449 100644 --- a/packages/vitnode/src/lib/fetcher-client.ts +++ b/packages/vitnode/src/lib/fetcher-client.ts @@ -17,16 +17,6 @@ import { coreFetcher } from "./fetcher/core"; import { isRateLimited, notifyRateLimited } from "./fetcher/rate-limit"; import { CAPTCHA_TOKEN_HEADER } from "./fetcher/request-context"; -/** - * Typed reference to a server module for use with {@link fetcherClient} inside - * client components. Import the module as a **type only** (so no server code is - * bundled) and pass its `pluginId`; the returned stub carries just the field the - * fetcher reads at runtime while keeping paths, methods and responses fully typed. - * - * @example - * import type { myPluginModule } from "@/api/my-plugin.module"; - * const ref = clientModule<typeof myPluginModule>("@my-plugin/core"); - */ export const clientModule = <T extends BaseBuildModuleReturn>( pluginId: T["pluginId"], ): T => ({ pluginId }) as unknown as T; diff --git a/packages/vitnode/src/lib/fetcher/rate-limit.ts b/packages/vitnode/src/lib/fetcher/rate-limit.ts index 9f45855b1..bc0aba146 100644 --- a/packages/vitnode/src/lib/fetcher/rate-limit.ts +++ b/packages/vitnode/src/lib/fetcher/rate-limit.ts @@ -1,11 +1,6 @@ /** HTTP status the API replies with when a client exceeds the rate limit. */ export const RATE_LIMIT_STATUS = 429; -/** - * DOM event dispatched on `window` when a client-side request is rate-limited, - * so a globally-mounted listener can inform the user without the fetcher having - * to know about React, i18n or the toaster. - */ export const RATE_LIMIT_EVENT = "vitnode:rate-limit"; export interface RateLimitEventDetail { diff --git a/packages/vitnode/src/lib/fetcher/raw.test.ts b/packages/vitnode/src/lib/fetcher/raw.test.ts index 18aa01060..46e583839 100644 --- a/packages/vitnode/src/lib/fetcher/raw.test.ts +++ b/packages/vitnode/src/lib/fetcher/raw.test.ts @@ -9,12 +9,6 @@ import { buildForwardedHeaders } from "./request-context"; const PLUGIN_ID = "@vitnode/core"; const ORIGIN = "http://localhost:3000"; -/** - * The API mounted the way every VitNode runtime mounts it: `basePath("/api")` - * with the plugin's router underneath. `apps/api` does it in its entry file, - * and the TanStack Start app does it behind its `/api/*` server route - so the - * paths the fetcher builds have to resolve here either way. - */ const mountedApi = () => { const seen: Request[] = []; const plugin = new Hono(); diff --git a/packages/vitnode/src/lib/fetcher/raw.ts b/packages/vitnode/src/lib/fetcher/raw.ts index 0868daccd..9d745d758 100644 --- a/packages/vitnode/src/lib/fetcher/raw.ts +++ b/packages/vitnode/src/lib/fetcher/raw.ts @@ -12,24 +12,9 @@ export interface RawApiFetchArgs { method: string; /** Module path under the plugin, e.g. `admin/content/articles`. */ module: string; - /** - * Extra `fetch` init, for the things a caller genuinely owns - `credentials`, - * and an {@link AbortSignal} from a cancellable read. - * - * `method` is omitted alongside `body` and `headers` because the spread below - * comes *last*: without it, an `options.method` would silently win over the - * `method` this call was built from, and a `get` would leave as a `post`. The - * three fields this function computes are the three it does not accept. - */ + options?: Omit<RequestInit, "body" | "headers" | "method">; - /** - * Origin to build the URL against, instead of `NEXT_PUBLIC_API_URL`. - * - * For a runtime that serves the API itself, the right origin is the one the - * request being handled arrived on: it is only knowable per request, and on a - * preview deployment it is a hostname nobody configured. Left unset in the - * browser, which keeps reading `CONFIG.api`. - */ + origin?: string; params?: Record<string, unknown>; /** Route path within the module, e.g. `/` or `/{id}`. */ diff --git a/packages/vitnode/src/lib/fetcher/request-context.ts b/packages/vitnode/src/lib/fetcher/request-context.ts index 5c778494a..da3b4f839 100644 --- a/packages/vitnode/src/lib/fetcher/request-context.ts +++ b/packages/vitnode/src/lib/fetcher/request-context.ts @@ -1,31 +1,9 @@ -/** - * The request state a VitNode frontend forwards to the API - and nothing else. - * - * Deliberately an allowlist rather than a copy of the incoming headers. The API - * identifies the caller from `Cookie`, fingerprints their device from - * `user-agent`, and keys rate limiting and the audit trail off - * `x-forwarded-for`, so those three have to survive the hop. Everything else a - * browser or a proxy attached must not: `host` and `content-length` describe a - * different request than the one being made, and `origin`, `referer` or - * `authorization` would let a visitor hand the API state it trusts. - * - * Lives here, framework-free, because the reading is the only part that is a - * runtime's: a TanStack Start server reads the request through - * `@tanstack/react-start/server`, a browser attaches its own cookies. Only the - * reading differs; what gets sent must not. - */ - /** Header the captcha middleware reads the client's solved token from. */ export const CAPTCHA_TOKEN_HEADER = "x-vitnode-captcha-token"; /** Sent when no forwarded IP is known, so the API never has to handle an empty one. */ export const FORWARDED_IP_FALLBACK = "0.0.0.0"; -/** - * Sent when the caller has no `user-agent`, which is the normal case for a - * server-to-server call. Matches the API's own fallback, so `parseUserAgent` - * reports "Unknown" instead of inventing a browser. - */ export const FORWARDED_USER_AGENT_FALLBACK = "node"; export interface ForwardedRequestContext { @@ -33,11 +11,7 @@ export interface ForwardedRequestContext { captchaToken?: string; /** The caller's full `Cookie` header - the session and device cookies live here. */ cookie?: null | string; - /** - * The caller's IP, or the `x-forwarded-for` chain verbatim when the frontend - * itself sits behind a proxy. Only meaningful when that proxy is trusted; the - * API stores whatever arrives. - */ + forwardedFor?: null | string; userAgent?: null | string; } diff --git a/packages/vitnode/src/lib/fetcher/set-cookie.ts b/packages/vitnode/src/lib/fetcher/set-cookie.ts index 14d78e33d..ad0eaa968 100644 --- a/packages/vitnode/src/lib/fetcher/set-cookie.ts +++ b/packages/vitnode/src/lib/fetcher/set-cookie.ts @@ -1,25 +1,8 @@ import { cookieFromStringToObject } from "./cookie-from-string-to-object"; -/** - * Whether a reply's cookies may be copied onto the application's response. - * - * 2xx only, which is the rule `fetcher()`'s `allowSaveCookies` applies. It - * matters in both directions: the session cookie arrives on a 201 and the - * deletion arrives on a 200, while a 403 sign-in attempt has nothing anybody - * should be writing to the visitor's browser. - */ export const shouldSaveApiCookies = (status: number): boolean => status >= 200 && status < 300; -/** - * A `Set-Cookie` header from the API, split into the shape every cookie store - * takes: a name, a value, and the attributes. - * - * Framework-free on purpose. The API mints the session and device cookies, so - * whichever frontend made the call has to copy them onto its own response - - * TanStack Start through `setCookie()`. Parsing them per runtime is how the - * copies drift apart. - */ export interface ParsedSetCookie { name: string; options: { @@ -52,12 +35,6 @@ const parseSameSite = ( : undefined; }; -/** - * An `Expires` the browser would honour, or nothing. A cookie sent without one - * is a session cookie, and passing an `Invalid Date` on to a cookie store - * serializes to a value browsers throw away - so the two cases are the same - * outcome reached by accident. This makes it the same outcome on purpose. - */ const parseExpires = (value: unknown): Date | undefined => { if (typeof value !== "string") return undefined; @@ -66,19 +43,6 @@ const parseExpires = (value: unknown): Date | undefined => { return Number.isNaN(expires.getTime()) ? undefined : expires; }; -/** - * A `Max-Age` in seconds, or nothing. - * - * This is the attribute the API deletes a cookie with: Hono's `deleteCookie()` - * answers with `name=; Max-Age=0` and no `Expires` at all, so dropping it turns - * every sign-out into an empty cookie that lingers until the browser closes - * rather than one the browser discards. - * - * `Number()` alone is too loose - it reads `""`, `" 12 "` and `"1e3"` as - * numbers, and a cookie store would then serialize an attribute the API never - * sent. RFC 6265 spells the value as an optionally-negative digit string and - * says to ignore anything else, which is exactly the test below. - */ const parseMaxAge = (value: unknown): number | undefined => { if (typeof value !== "string" || !/^-?\d+$/.test(value)) return undefined; @@ -88,11 +52,6 @@ const parseMaxAge = (value: unknown): number | undefined => { const asString = (value: unknown): string | undefined => typeof value === "string" ? value : undefined; -/** - * A flag is present only when the attribute was there at all. Typed `unknown` - * because `cookieFromStringToObject` declares these as `boolean` while the key - * is simply missing when the attribute is absent. - */ const asFlag = (value: unknown): boolean => value === true; /** diff --git a/packages/vitnode/src/lib/file-constraints.ts b/packages/vitnode/src/lib/file-constraints.ts index 557482056..8f43cf494 100644 --- a/packages/vitnode/src/lib/file-constraints.ts +++ b/packages/vitnode/src/lib/file-constraints.ts @@ -1,21 +1,6 @@ import { getFileExtension } from "./file-extension"; import { formatBytes } from "./format-bytes"; -/** - * The three rules an upload is checked against. - * - * Its own module, with no Content Engine and no Node built-ins behind it, - * because the same three questions are asked in three places and there must be - * exactly one answer to each: - * - * - in the browser, before the upload starts, so picking a 40 MB video for a - * 5 MB field costs nothing; - * - in the upload route, on the file that arrived; - * - and again on the `core_files` row a content mutation names. - * - * The server is authoritative - the browser copy is a courtesy - but they cannot - * *disagree*, which is what a second implementation would eventually do. - */ export interface FileConstraints { /** Lowercase, leading dot. Omitted, any extension is accepted. */ allowedExtensions?: readonly string[]; @@ -25,14 +10,6 @@ export interface FileConstraints { maxBytes: number; } -/** - * One file's identity, as either side of the wire can describe it. - * - * `name` because the extension rule is about the file name, `mimeType` because - * the MIME rule is about the declared content type. They are checked - * **independently**: a `picture.gif` that is really a PNG passes the first and - * fails the second, which is exactly the case an extension-only check misses. - */ export interface FileCandidate { mimeType: null | string | undefined; name: string; @@ -49,13 +26,6 @@ export interface FileRejection { value: string; } -/** - * Checks a file against a set of constraints, or returns `null`. - * - * Size first, then media type, then extension, and **every configured rule has - * to pass**. With both lists set, `picture.gif` declared `image/png` is refused, - * and so is `picture.png` declared `image/gif`. - */ export const validateFile = ( { allowedExtensions, allowedMimeTypes, maxBytes }: FileConstraints, file: FileCandidate, diff --git a/packages/vitnode/src/lib/file-extension.ts b/packages/vitnode/src/lib/file-extension.ts index 88526ee2f..476bfdafc 100644 --- a/packages/vitnode/src/lib/file-extension.ts +++ b/packages/vitnode/src/lib/file-extension.ts @@ -1,20 +1,3 @@ -/** - * File-name extension helpers, with no Node built-ins behind them. - * - * Their own module because both halves of the Content Engine need them: the - * upload route reads an extension off a `File` on the server, and - * `AutoFormFile` reads it off the same file in the browser to say "that is not - * one of the allowed formats" before spending anybody's bandwidth. `lib/api/upload` - * re-exports these rather than keeping a second copy, so the two answers cannot - * drift. - */ - -/** - * The extension of a file name, lowercased and including the leading dot. - * - * `""` when there is none - a dotfile (`.env`) has no extension either, which is - * why the dot has to be past the first character. - */ export const getFileExtension = (fileName: string): string => { const lastDot = fileName.lastIndexOf("."); if (lastDot <= 0 || lastDot === fileName.length - 1) { diff --git a/packages/vitnode/src/lib/files/bulk-delete.ts b/packages/vitnode/src/lib/files/bulk-delete.ts index 2862150ad..44d27deda 100644 --- a/packages/vitnode/src/lib/files/bulk-delete.ts +++ b/packages/vitnode/src/lib/files/bulk-delete.ts @@ -1,45 +1,17 @@ import type { DeleteFileResult } from "./in-use"; -/** - * What a bulk file delete did, split by reason. - * - * Kept apart rather than reduced to "n of m failed" because the three refusals - * need three different things from the person: content references have to be - * removed elsewhere first, retained revisions can be forced past, and anything - * else is a server error to retry. - */ export interface BulkDeleteFilesResult { /** Live content points at these, which `force` does not get past. */ blockedByContent: number; deleted: number; /** Refused for some other reason - already gone, or a server error. */ failed: number; - /** - * Ids only retained revisions are holding. - * - * The ids and not a count: asking again with `force` has to act on exactly - * these, never on the whole selection again. - */ + heldByRevisions: number[]; } -/** - * How many deletes are in flight at once. - * - * There is no bulk endpoint - each id is the same single-file delete the row - * action calls, so the per-file semantics (ownership, the 409 and what it says) - * stay identical. This caps the fan-out so a full page of 40 does not open 40 - * transactions at once. - */ const CONCURRENCY = 6; -/** - * Runs `deleteOne` over `ids` and sorts the outcomes into - * {@link BulkDeleteFilesResult}. - * - * Never rejects: a bulk delete that stopped at the first refusal would leave - * the person guessing which of the rest went through. - */ export const runBulkFileDelete = async ( ids: number[], deleteOne: (id: number) => Promise<DeleteFileResult>, @@ -86,20 +58,6 @@ export const runBulkFileDelete = async ( return result; }; -/** - * Whether a bulk run changed anything the table is showing. - * - * The rule a Next.js server action applies before it calls `revalidatePath`, and - * the one a TanStack Start caller applies before it invalidates - one function, - * so the two cannot drift. A run that was refused outright leaves the page - * exactly as it was, and refetching would drop the selection that is showing - * which rows were kept, which is the only thing telling the person what to do - * next. - * - * Deliberately not "did anything happen": files blocked by content and files - * held by revisions are both *unchanged*, and both are reported in the dialog - * rather than by the table reloading underneath it. - */ export const shouldRefreshAfterBulkDelete = ( result: BulkDeleteFilesResult, ): boolean => result.deleted > 0; diff --git a/packages/vitnode/src/lib/files/in-use.ts b/packages/vitnode/src/lib/files/in-use.ts index 1e23ee82f..138720c91 100644 --- a/packages/vitnode/src/lib/files/in-use.ts +++ b/packages/vitnode/src/lib/files/in-use.ts @@ -1,25 +1,5 @@ -/** - * Why {@link StorageModel.deleteFile} refused. - * - * Declared here rather than in `@/api/models/storage`, which is where it used to - * live and which still re-exports it, because {@link readFileInUse} runs in a - * browser. Importing the constant from the storage model pulled Hono, Drizzle - * and the whole `@/database` tree into the client bundle of every surface that - * deletes a file - a value import is a value import, however small the value. - * This module now has no runtime imports at all, which is the property that - * makes it safe from either framework. - */ export const STORAGE_FILE_IN_USE = "FILE_IN_USE"; -/** - * The body of that refusal, and the reason it is not just a code. - * - * "In use" covers two situations a person has to act on differently: content - * that would break, and history that would merely lose a restore. `content` is - * the one that is final; `revisions` is how many retained revisions hold the - * file, so a client can offer to force past them and say how much it is giving - * up. - */ export interface StorageFileInUseBody { code: typeof STORAGE_FILE_IN_USE; /** A live content column or gallery row still points at this file. */ @@ -29,28 +9,11 @@ export interface StorageFileInUseBody { revisions: number; } -/** - * What a refused file delete was blocked by, as the two surfaces need it. - * - * `content` is final - a live column or gallery row points at the file, and no - * amount of confirming makes deleting it anything other than a broken page. - * `revisions` is not: those are retained revisions pinning the file, and asking - * again with `force` releases them. - */ export interface FileInUse { content: boolean; revisions: number; } -/** - * What every file-delete path returns - the Next.js server actions and the - * browser mutation the TanStack Start app calls. - * - * A closed result rather than a thrown error, because all three refusals are - * ordinary answers a person acts on: `409` is offered as a confirmation, `404` - * is "already gone", and anything else is "try again". Only a transport failure - * rejects. - */ export interface DeleteFileResult { data?: true; error?: { @@ -60,14 +23,6 @@ export interface DeleteFileResult { }; } -/** - * Reads the `FILE_IN_USE` body off a refused delete, or `undefined`. - * - * Defensive about every field rather than trusting the route's own schema: this - * runs on a response body, and a proxy that turned a 409 into an HTML error page - * must produce "something went wrong" rather than an exception inside a server - * action. A body that is not this shape is simply not a reason. - */ export const readFileInUse = async ( res: Response, ): Promise<FileInUse | undefined> => { diff --git a/packages/vitnode/src/lib/i18n/deep-merge.ts b/packages/vitnode/src/lib/i18n/deep-merge.ts index 4b2379386..c1e9bb94f 100644 --- a/packages/vitnode/src/lib/i18n/deep-merge.ts +++ b/packages/vitnode/src/lib/i18n/deep-merge.ts @@ -3,15 +3,6 @@ type PlainObject = Record<string, unknown>; const isPlainObject = (value: unknown): value is PlainObject => typeof value === "object" && value !== null && !Array.isArray(value); -/** - * Recursively merges `source` over `target` into a new object. Two plain - * objects are merged key by key; anything else - strings, arrays, `null` - - * replaces what was there. - * - * Message trees are merged instead of spread so a half-translated locale only - * overrides the keys it actually defines, and the rest fall through to the - * default locale rather than rendering as raw keys. - */ export const deepMerge = <T extends PlainObject>( target: T, source: PlainObject, diff --git a/packages/vitnode/src/lib/i18n/locale-cookie.ts b/packages/vitnode/src/lib/i18n/locale-cookie.ts index 0d82af7bf..2d13f74f3 100644 --- a/packages/vitnode/src/lib/i18n/locale-cookie.ts +++ b/packages/vitnode/src/lib/i18n/locale-cookie.ts @@ -1,14 +1,3 @@ -/** - * The cookie that remembers which language a visitor chose. - * - * Named like the rest of VitNode's cookies (`vitnode_auth`, `vitnode_device`) - * so an install can find them all at once. - * - * It is deliberately *not* how a public page picks its language - that comes - * from the URL, always. This is what `/admin` and anything else outside the - * localized URL space reads, and what a language switch writes so the next - * visit to one of those pages starts in the right language. - */ export const LOCALE_COOKIE_NAME = "vitnode_locale"; /** A year. The choice is a preference, not a session, and it never expires. */ @@ -17,23 +6,10 @@ export const LOCALE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; export interface LocaleCookieOptions { maxAge?: number; name?: string; - /** - * Adds `Secure`. Leave it off over plain HTTP - a `Secure` cookie set on - * `http://localhost` is dropped by the browser without a word, and the - * language switch then silently forgets itself on every reload. - */ + secure?: boolean; } -/** - * Reads the locale cookie out of a `Cookie` header or `document.cookie`. - * - * Both are the same format - `a=1; b=2` - which is why this takes a string - * rather than a request: the server hands it a header, the browser hands it - * `document.cookie`, and neither needs to know about the other. - * - * Splits on the *first* `=` only: a value may legitimately contain more. - */ export const readLocaleCookie = ( header: null | string | undefined, name: string = LOCALE_COOKIE_NAME, @@ -58,16 +34,6 @@ export const readLocaleCookie = ( return undefined; }; -/** - * The `Set-Cookie` value for a chosen locale. - * - * `Path=/` because the choice applies to the whole site, `SameSite=Lax` because - * it must survive somebody following a link in from elsewhere - the case where - * getting the language right matters most - while still not riding along on - * cross-site form posts. No `HttpOnly`: the browser sets this one too, when the - * switcher runs client-side, and a cookie only one half can write is a cookie - * the two halves disagree about. - */ export const serializeLocaleCookie = ( locale: string, { diff --git a/packages/vitnode/src/lib/i18n/locale-routing.ts b/packages/vitnode/src/lib/i18n/locale-routing.ts index 3971c0619..289ccaeff 100644 --- a/packages/vitnode/src/lib/i18n/locale-routing.ts +++ b/packages/vitnode/src/lib/i18n/locale-routing.ts @@ -2,18 +2,6 @@ import type { LocaleConfig, VitNodeI18nConfig } from "./types"; import { negotiateLocale } from "./negotiate-locale"; -/** - * Paths that never carry a locale prefix. - * - * `/api` is the VitNode API - a locale segment in front of it would be a - * different URL to every client that has one hardcoded, and the API negotiates - * its own language from the request anyway. `/admin` is the AdminCP, which is - * behind a login and reads the operator's own preference rather than the URL: - * prefixing it would double every admin URL for no crawler that will ever see - * it. - * - * Both cover their descendants. Anything else is a public, localizable page. - */ export const DEFAULT_IGNORED_LOCALE_PATHS = ["/admin", "/api"] as const; /** How a locale is (or is not) written into a public URL. */ @@ -26,24 +14,11 @@ export interface LocaleRoutingConfig { * Defaults to {@link DEFAULT_IGNORED_LOCALE_PATHS}. */ ignoredPaths?: readonly string[]; - /** - * - `"as-needed"` (the default) - the default locale has no prefix, every - * other one does. This is the shape VitNode serves. - * - `"always"` - every locale is prefixed, the default one included. - * - `"never"` - no URL carries a locale; the visitor's cookie decides. - */ + localePrefix?: LocalePrefixMode; locales: readonly string[]; } -/** - * Where a locale may be read from when the URL does not carry one. - * - * Public URLs never consult either: see {@link LocaleRouting.resolveLocale}. - * Both are read only on the branch that needs them, so a caller may pass - * getters that go and find a cookie jar - on a public URL they are never - * invoked. - */ export interface LocaleSources { acceptLanguage?: null | string; cookieLocale?: null | string; @@ -61,11 +36,7 @@ export interface LocaleRouting { deLocalizePathname: (pathname: string) => string; /** {@link deLocalizePathname}, applied to a URL's path and nothing else. */ deLocalizeUrl: (url: URL) => URL; - /** - * The locale `pathname` is written in, or `undefined` when it carries none. - * Only a prefix this configuration would itself emit counts, so under - * `"as-needed"` the default locale's `/en/...` is not one. - */ + extractLocaleFromPath: (pathname: string) => string | undefined; isSupportedLocale: (value: null | string | undefined) => value is string; readonly localePrefix: LocalePrefixMode; @@ -79,15 +50,7 @@ export interface LocaleRouting { * already canonical. */ redirectPathnameFor: (pathname: string) => string | undefined; - /** - * The single authoritative answer to "which language is this request in?". - * - * A runtime supplies whichever sources it can honour. The TanStack Start app - * passes only `cookieLocale` on purpose - see `src/tanstack/i18n/locale.ts` - * - because an `Accept-Language` answer the browser cannot reproduce is a - * hydration mismatch waiting to happen. Both sources are kept here for a - * runtime that has somewhere hydration-safe to put the negotiated result. - */ + resolveLocale: (pathname: string, sources?: LocaleSources) => string; /** `true` for `/api`, `/api/x`, `/admin`, `/admin/x`; `false` for `/discover`. */ shouldIgnoreLocalePath: (pathname: string) => boolean; diff --git a/packages/vitnode/src/lib/i18n/negotiate-locale.ts b/packages/vitnode/src/lib/i18n/negotiate-locale.ts index 46271f68c..a103e972a 100644 --- a/packages/vitnode/src/lib/i18n/negotiate-locale.ts +++ b/packages/vitnode/src/lib/i18n/negotiate-locale.ts @@ -1,11 +1,3 @@ -/** - * Picks the best match for an `Accept-Language` header out of `locales`. - * - * Ranks the header by its `q` weights, then for each entry tries an exact match - * before falling back to the primary subtag, so `pl-PL` still resolves to `pl`. - * Returns `undefined` when nothing matches - callers decide what to fall back - * to. - */ export const negotiateLocale = ( header: null | string | undefined, locales: string[], diff --git a/packages/vitnode/src/lib/i18n/pick-messages.test.ts b/packages/vitnode/src/lib/i18n/pick-messages.test.ts index 9a2c1e4d2..e4a4ece7d 100644 --- a/packages/vitnode/src/lib/i18n/pick-messages.test.ts +++ b/packages/vitnode/src/lib/i18n/pick-messages.test.ts @@ -3,15 +3,6 @@ import { afterEach, describe, expect, it } from "vitest"; import { isUnsafeMessagePath, pickMessages } from "./pick-messages"; -/** - * Which namespaces reach the client bundle. - * - * `I18nProvider` ships only what it is handed, and a plugin's AdminCP overrides - * - a `forms.layout`, a field component, a column cell - are client components - * that translate themselves out of the plugin's own namespace. Leaving that - * namespace out is not a missing string but a thrown `MISSING_MESSAGE` on every - * one of them, so the rule is pinned here. - */ const messages = { "@vitnode/blog": { admin: { @@ -79,12 +70,6 @@ describe("pickMessages", () => { }); }); -/** - * `pickMessages` is reached from a public server function, so it is treated as - * a boundary in its own right rather than trusting whatever validated the input - * upstream. Every assertion here is about a path that should never arrive - and - * about what happens when one does anyway. - */ describe("prototype safety", () => { const tree = { core: { global: { close: "Close" } }, diff --git a/packages/vitnode/src/lib/i18n/pick-messages.ts b/packages/vitnode/src/lib/i18n/pick-messages.ts index d4df69043..b2c8bc064 100644 --- a/packages/vitnode/src/lib/i18n/pick-messages.ts +++ b/packages/vitnode/src/lib/i18n/pick-messages.ts @@ -1,15 +1,3 @@ -/** - * Path segments that must never be traversed into or written to. - * - * `__proto__` is the dangerous one: on an ordinary object it is an accessor - * inherited from `Object.prototype`, so `target["__proto__"] = value` does not - * create a property - it *replaces the prototype*. `constructor` and - * `prototype` are the two steps of the other well-known route to the same - * place, `x.constructor.prototype`. - * - * None of the three is a namespace any package ships, so rejecting them costs - * nothing and closes the hole for every caller at once. - */ const UNSAFE_SEGMENTS: ReadonlySet<string> = new Set([ "__proto__", "constructor", @@ -20,16 +8,6 @@ const UNSAFE_SEGMENTS: ReadonlySet<string> = new Set([ export const isUnsafeMessagePath = (path: string): boolean => path.split(".").some(segment => UNSAFE_SEGMENTS.has(segment)); -/** - * Writes an own, enumerable property - and nothing else. - * - * `defineProperty` rather than `target[key] = value` because assignment - * consults the prototype chain for a setter, which is exactly the behaviour - * that turns a `__proto__` key into prototype pollution. This defines the - * property directly on the object, so even a message file that somehow ships a - * literal `__proto__` key (`JSON.parse` will happily create one as an *own* - * property) produces an inert piece of data rather than a new prototype. - */ const defineOwn = ( target: Record<string, unknown>, key: string, @@ -43,28 +21,6 @@ const defineOwn = ( }); }; -/** - * The subset of a message tree a client bundle is allowed to see. - * - * Exported so the namespace rule is testable on its own: which namespaces reach - * the client is the difference between a plugin's admin screen rendering and - * every string on it throwing `MISSING_MESSAGE`, and that is a rule worth - * pinning rather than a detail of a server component. - * - * A path that resolves to nothing is skipped, not defaulted - an unregistered - * plugin id simply contributes no messages. A path containing an unsafe segment - * is skipped too: this is a shared utility reached from a public server - * function, and it does not get to assume every caller validated its input - * first. `apps/web` rejects such input outright before it arrives here; this is - * the second lock on the same door. - * - * The result is an ordinary object rather than a `null`-prototype one on - * purpose. It is handed to `NextIntlClientProvider` from a Server Component, - * and React's Flight serializer refuses anything whose prototype is not - * `Object.prototype` - `isSimpleObject` returns false and the render fails with - * "Only plain objects... can be passed to Client Components". `defineOwn` above - * gives the same protection without changing what the object *is*. - */ export const pickMessages = ( obj: object, paths: readonly string[], diff --git a/packages/vitnode/src/lib/i18n/provider.tsx b/packages/vitnode/src/lib/i18n/provider.tsx index 447e40638..80758f961 100644 --- a/packages/vitnode/src/lib/i18n/provider.tsx +++ b/packages/vitnode/src/lib/i18n/provider.tsx @@ -1,28 +1,3 @@ "use client"; -/** - * `use-intl`'s provider, handed out from inside `@vitnode/core`. - * - * Two lines, and the reason for them is module identity rather than behaviour. - * Every shared component in this package reads its strings through - * `useTranslations` from `use-intl`, which is a React context - and a React - * context belongs to the *module record* it was created in, not to the package - * name. An app that mounts its own `use-intl` provider is only providing into - * core's context while both sides happen to have loaded the same record. - * - * They do not always. In `apps/web`, `@vitnode/core` is external to Vite's SSR - * pass and so is loaded by Node, while the app's own source goes through Vite's - * module runner - two records, two contexts, and every core component that - * translates throws "No intl context found" under `vite dev` while a production - * build (which merges them into one chunk) stays green. - * - * Importing the provider from here removes the coincidence: it is loaded by - * whatever loaded this package, which is by definition the record core's own - * components read. An app that wants to scope messages to a route mounts this - * one - alongside its own, if its own code translates too. - * - * Framework-free on purpose: no `next-intl`, so a TanStack Start route can use - * it. Next.js apps have `I18nProvider` (`@/components/i18n-provider`), which - * reads the request scope and is Next-only by design. - */ export { IntlProvider } from "use-intl"; diff --git a/packages/vitnode/src/lib/i18n/types.ts b/packages/vitnode/src/lib/i18n/types.ts index 9da892117..be026dbab 100644 --- a/packages/vitnode/src/lib/i18n/types.ts +++ b/packages/vitnode/src/lib/i18n/types.ts @@ -1,24 +1,11 @@ /** One locale's messages, as they come out of a `*.json` locale file. */ export type Messages = Record<string, unknown>; -/** - * Lazily loads one locale's messages, e.g. `() => import("./en.json")`. - * - * The import path has to be a literal. A template literal such as - * ``() => import(`./${locale}.json`)`` is invisible to TypeScript and to - * bundlers, so the JSON never reaches the build output - at runtime the import - * throws and every string silently degrades to its raw key. - */ export type MessagesLoader = () => Promise<{ default: Messages }>; /** Every locale a package ships, keyed by locale code. */ export type LocaleMessagesMap = Record<string, MessagesLoader>; -/** - * App-level additions and overrides, keyed by locale code and then by the - * plugin whose namespace they extend. Deep-merged last, after every package, - * so a file only needs the keys it actually changes. - */ export type AppMessagesMap = Record<string, Record<string, MessagesLoader>>; /** A package - core or a plugin - contributing messages to the merged tree. */ @@ -30,12 +17,7 @@ export interface MessagesSource { * so are not expected to cover the default locale. */ optional?: boolean; - /** - * Which tree this source belongs to - `"web"` or `"api"`. A plugin ships a - * different tree to each under the same {@link id}, so scope is folded into - * the message cache key: without it a single app (frontend and API in one - * process) would serve whichever tree loaded first to both. - */ + scope?: string; } @@ -51,20 +33,9 @@ export interface VitNodeI18nConfig< defaultLocale: AppLocales[number]["code"]; localePrefix?: "always" | "as-needed" | "never"; locales: AppLocales; - /** - * Translations owned by the app rather than by a package - a language no - * plugin ships yet, or a handful of strings you want to word differently. - * Files live in `src/locales/<pluginId>/<locale>.json`. - */ + messages?: AppMessagesMap; timeZone?: string; } -/** - * The API side of {@link VitNodeI18nConfig}. Every field is optional: with no - * `i18n` block at all the locale list is derived from the languages the - * installed packages ship, and `defaultLocale` falls back to `en`. Point both - * `buildConfig` and `buildApiConfig` at the same object when an app serves the - * web and the API together. - */ export type VitNodeApiI18nConfig = Partial<VitNodeI18nConfig>; diff --git a/packages/vitnode/src/lib/metadata.ts b/packages/vitnode/src/lib/metadata.ts index c273fbf7a..53af1fb9e 100644 --- a/packages/vitnode/src/lib/metadata.ts +++ b/packages/vitnode/src/lib/metadata.ts @@ -1,23 +1,9 @@ /** The app's name, as it appears in a browser tab and in an email's subject. */ export interface VitNodeMetadata { - /** - * A shorter name for places the full one does not fit - most of all the tab - * title of every page but the first, where it follows the page's own name. - * Falls back to {@link VitNodeMetadata.title}. - */ shortTitle?: string; title: string; } -/** - * The tab title of a page, as `"<page> - <site>"`. - * - * `%s` is Next.js' placeholder for the page's own title, and Next.js is the one - * that does the substitution - `generateMetadataRootLayout` hands it this string - * as `title.template`. Frameworks without that mechanism call - * {@link formatPageTitle} instead, so both produce the same title and the rule - * lives in one place. - */ export const titleTemplate = ({ shortTitle, title }: VitNodeMetadata): string => `%s - ${shortTitle ?? title}`; diff --git a/packages/vitnode/src/lib/plugin.ts b/packages/vitnode/src/lib/plugin.ts index 8fee9de48..1d696d879 100644 --- a/packages/vitnode/src/lib/plugin.ts +++ b/packages/vitnode/src/lib/plugin.ts @@ -11,15 +11,6 @@ import type { LocaleMessagesMap } from "./i18n/types"; export type AdminNavPermission = Omit<PermissionsStaffArgs, "plugin">; -/** - * Picked from the navigation *model* rather than from a component's props. - * - * The model is the framework-neutral declaration every host reads; a component - * is one host's way of drawing it. Picking from a component would put whichever - * host that component belongs to into the graph of `buildPlugin` - and this - * module is what every plugin imports, so that coupling would travel to all of - * them. - */ interface AdminNavItem extends Pick< ResolvedAdminNavItem, "href" | "icon" | "isOpenInNewTab" @@ -28,14 +19,6 @@ interface AdminNavItem extends Pick< permission?: AdminNavPermission; } -/** - * One hand-declared AdminCP sidebar entry, with whatever sits under it. - * - * Named rather than written inline on {@link BuildPluginReturn} because - * {@link AdminNavPluginSource} needs the same shape: a plugin declares its - * navigation once, and both the full registration and the browser-safe - * projection read that one declaration. - */ export type AdminNavDeclaration = AdminNavItem & { items?: Omit<AdminNavItem, "icon">[]; }; @@ -46,32 +29,6 @@ export type AdminNavContentType = Pick< "definition" | "icon" >; -/** - * A plugin's AdminCP navigation, and nothing else about the plugin. - * - * What a plugin exports from `admin/nav` - a **browser-safe** module - so an - * application can put its sidebar entries on screen without importing the - * plugin's frontend registration. That distinction is the whole reason this type - * exists: `blogPlugin()` registers content types *with their editing screens* - * attached - a Tiptap field, a form layout, a table cell - which reach core's - * form stack and, today, `next/dynamic`. A TanStack Start application cannot - * hold that graph, and it does not need to in order to draw a list of links. - * - * So the two are separated by what they carry rather than by a build flag: - * - * config.tsx the whole registration - screens, field overrides, widgets - * admin/nav the ids, hrefs, permissions, icons and content definitions - * - * A content type definition is client-safe by construction (zod and plain data, - * no Drizzle, no components), and an icon is an element from an icon set. That - * is the entire payload. - * - * Structurally a {@link BuildPluginReturn}, so `adminNavDeclarations` reads a - * list of these exactly as it reads a list of configured plugins - one - * navigation model, one set of rules, whichever door the data came through. - * A plugin writes it once and spreads it into its own `buildPlugin` call, which - * is what stops the two lists drifting. - */ export interface AdminNavPluginSource { admin?: { nav?: AdminNavDeclaration[] }; contentTypes?: AdminNavContentType[]; @@ -107,20 +64,6 @@ export interface ContentCellProps< row: ContentSelect<TDefinition>; } -/** - * Everything a custom form layout is handed, and nothing more. - * - * Deliberately all serialisable: a layout is a client component referenced from - * `config.tsx`, which is a **server** module, so React props cross an RSC - * boundary to reach it. Field elements, the form instance and the submit action - * are not here for exactly that reason - they come from - * `useContentForm()`/`ContentFormField`, which are client context and therefore - * never cross anything. - * - * There is no database handle, Drizzle table, Hono context or mutation model in - * this shape, and there is not going to be: a layout decides where a field - * appears, and the Content Engine decides what happens when it is submitted. - */ export interface ContentFormLayoutProps { contentTypeId: string; /** `undefined` while creating - the record does not exist yet. */ @@ -138,14 +81,6 @@ export type ContentFormLayout = ( props: ContentFormLayoutProps, ) => React.ReactNode; -/** - * Layout overrides for the generated create and edit forms. - * - * `layout` alone covers the common case - one editor screen used for both - and - * `create`/`edit` override it when they genuinely differ. Normalised by - * `resolveContentFormLayout`, so nothing downstream has to know about the - * fallback. - */ export interface ContentTypeFormsRegistration { create?: { layout?: ContentFormLayout }; edit?: { layout?: ContentFormLayout }; @@ -179,33 +114,6 @@ export interface ContentTypeFrontendRegistration { icon?: React.ReactNode; } -/** - * A plugin's Content Engine frontend registration, and nothing else about the - * plugin. - * - * What a plugin exports from `admin/content` - a **browser-safe** module - so an - * application can render the generated content screens without importing the - * plugin's whole `buildPlugin` call. The same split {@link AdminNavPluginSource} - * makes, one level further in: - * - * admin/nav ids, hrefs, permissions, icons, content definitions - * admin/content the above, plus field, column and form-layout overrides - * config.tsx the whole plugin - messages, routes, API wiring - * - * The difference between the first two is what a screen needs over what a link - * needs. A sidebar entry is a string and an icon; a content *screen* is those - * plus the components that replace a generated input, a generated table cell and - * a generated form layout. Both are browser-safe, and neither is the server - * config: `vitnode.config.ts` carries message loaders and API plugins, which a - * browser bundle has no business holding. - * - * Structurally a subset of {@link BuildPluginReturn}, deliberately, and that is - * what stops the two lists drifting: a plugin writes its registrations once - * here, `config.tsx` spreads them into `buildPlugin`, and the Next.js - * application and the TanStack Start application read the same declarations - * through two doors. A `BuildPluginReturn[]` also satisfies - * `ContentFrontendPluginSource[]`, so one registry builder serves both. - */ export interface ContentFrontendPluginSource { contentTypes?: ContentTypeFrontendRegistration[]; pluginId: string; @@ -229,33 +137,12 @@ interface TypedContentTypeRegistration< { component: (props: ItemAutoFormComponentProps) => React.ReactNode } > >; - /** - * Replace the generated form **layout** - where the fields are, not what they - * do. - * - * The Content Engine still owns the form schema, the validation, the defaults, - * the mutation, the version precondition, the structured errors, the toast and - * the cache invalidation. A layout places `<ContentFormField name="..." />` - * and `<ContentFormActions />` inside one shared form instance. - */ + forms?: ContentTypeFormsRegistration; /** Sidebar icon. Defaults to a generic document icon. */ icon?: React.ReactNode; } -/** - * Registers a content type with the AdminCP. - * - * The `definition` is the *same object* the API plugin registers - it is - * client-safe by construction (zod and plain data, no Drizzle), so the two - * sides cannot drift. Component overrides live here rather than on the - * definition, because the definition is also imported by `src/database/*.ts`, - * which Drizzle Kit executes. - * - * The wrapper exists to type-check `fields` and `columns` against the - * definition's own field names before erasing the generic - the same shape as - * `buildEventListener`. - */ export function contentTypeAdmin<TDefinition extends AnyContentTypeDefinition>( registration: TypedContentTypeRegistration<TDefinition>, ): ContentTypeFrontendRegistration { @@ -272,20 +159,7 @@ export interface BuildPluginReturn<P extends string = string> { contentTypes?: ContentTypeFrontendRegistration[]; messages?: LocaleMessagesMap; pluginId: P; - /** - * The pages this plugin contributes, as the tree its own `src/routes.ts` - * declares. - * - * Optional: a plugin that contributes an API module, a content type or only - * strings declares no routes at all. Hand over the same `routes` export the - * plugin's `routes.ts` has, so a host that registers the plugin through this - * config and a build that reads the module directly describe one set of - * routes. - * - * Nothing is copied anywhere: a page module stays in this package's own `dist` - * behind the `lazy(() => import(...))` its route declared, and the app holds - * one static import of this tree. See `src/routing/`. - */ + routes?: PluginRoutes; } diff --git a/packages/vitnode/src/lib/query-client.test.ts b/packages/vitnode/src/lib/query-client.test.ts index 5a99e5475..30fee4d80 100644 --- a/packages/vitnode/src/lib/query-client.test.ts +++ b/packages/vitnode/src/lib/query-client.test.ts @@ -3,19 +3,6 @@ import { describe, expect, it } from "vitest"; import { createVitNodeQueryClient } from "./query-client"; -/** - * The QueryClient every VitNode app runs. - * - * Two rules, and both of them are the kind that fails silently. A client shared - * across server-rendered requests serves one visitor's data to the next, and - * refetch-on-focus turns any page with a table into one that reloads whenever a - * tab regains focus - neither shows up in a render test. - * - * The refetch assertions are deliberately about *all three* of Query's automatic - * triggers rather than the two this app turns off. An undeclared trigger behaves - * however the installed version of Query decides, which is the one thing a - * defaults file exists to stop. - */ describe("createVitNodeQueryClient", () => { it("does not refetch on mount or window focus", () => { const { queries } = createVitNodeQueryClient().getDefaultOptions(); @@ -24,44 +11,18 @@ describe("createVitNodeQueryClient", () => { expect(queries?.refetchOnWindowFocus).toBe(false); }); - /** - * The third trigger, declared rather than inherited. - * - * `true` is also Query's default, so this asserts no behaviour that was not - * already happening - which is the point. What it pins is that the value is - * *stated*: the file now answers "what refetches this query, and when" for all - * three triggers instead of two, and a future edit that flips it has to argue - * with a failing test rather than with nothing. - */ it("declares refetch-on-reconnect rather than inheriting it", () => { const { queries } = createVitNodeQueryClient().getDefaultOptions(); expect(queries?.refetchOnReconnect).toBe(true); }); - /** - * Reads do not retry unless a family asks for it. - * - * The direction is the assertion. Query's own default is three retries with - * backoff, which for a privileged AdminCP read means answering a `403` by - * asking twice more and a `429` by tripling the load the limiter is trying to - * shed. Defaulting to `false` means a family added later that says nothing - * about retries gets the behaviour that cannot hurt; the three public families - * that want one declare it where they are declared. - */ it("does not retry reads by default", () => { const { queries } = createVitNodeQueryClient().getDefaultOptions(); expect(queries?.retry).toBe(false); }); - /** - * Every automatic trigger has an opinion recorded against it. - * - * The rule this stage adds, written as a rule rather than as three separate - * assertions: a fourth trigger arriving in a future Query release, or a - * fourth that somebody deletes, fails here. - */ it("leaves no automatic refetch trigger undeclared", () => { const { queries } = createVitNodeQueryClient().getDefaultOptions(); diff --git a/packages/vitnode/src/lib/query-client.ts b/packages/vitnode/src/lib/query-client.ts index 3d78bbe3a..e8d81e0a5 100644 --- a/packages/vitnode/src/lib/query-client.ts +++ b/packages/vitnode/src/lib/query-client.ts @@ -2,71 +2,6 @@ import type { QueryClientConfig } from "@tanstack/react-query"; import { QueryClient } from "@tanstack/react-query"; -/** - * VitNode's TanStack Query client, with the defaults every VitNode app runs. - * - * `refetchOnWindowFocus` and `refetchOnMount` are both off: VitNode's data comes - * from its own API behind a cache, and a table that silently refetches every - * time a tab regains focus is a page that moves under the reader. Mutations - * invalidate what they changed instead, which is the explicit version of the - * same thing. - * - * `refetchOnReconnect` is the third of Query's automatic triggers and the one - * that stays **on**. It was Query's default rather than a decision until now, - * which is the only thing wrong with it: every other refetch policy in this file - * says what it is and why, and a trigger nobody wrote down is a trigger nobody - * can reason about when it fires. - * - * It stays on because it is not the same kind of event as the other two. A mount - * and a focus happen constantly during ordinary use, and refetching on them is - * what makes a page move under its reader. Losing the network and getting it - * back happens rarely, and it is the one moment when what is on screen is most - * likely to be wrong - or, with `retry: false` on almost every read in VitNode, - * to be an error the visitor has no way to clear but by navigating. Reconnecting - * is the app's only automatic recovery from an outage, and turning it off would - * mean a laptop that woke up showed failed screens until somebody clicked - * something. - * - * The cost is known and accepted: most VitNode queries declare no `staleTime`, - * so they are always stale, so a reconnect refetches every *active* one at once. - * Active, not cached - an unmounted screen has no observer and is not refetched - - * which bounds the burst to what a single document is currently rendering. - * - * `retry` is the fourth policy and the one that was inherited rather than - * chosen. Query's own default retries a failed read three times with exponential - * backoff, and almost every VitNode family already overrides that to `false` - - * so the default was doing nothing except in the handful of places somebody - * forgot, which is the worst possible distribution for a policy. Two of those - * places were privileged AdminCP reads, where retrying is actively wrong: a - * `403` does not become a `200` because we asked again, and a `429` is answered - * by sending the same request twice more. One of them - the dashboard layout - - * carried a comment saying "No `retry`" while inheriting three of them. - * - * So `false` here, uniformly, and with no exceptions carved out - which was the - * second thing worth deciding rather than inheriting. The three families that - * were quietly retrying are all public and idempotent, so a repeat request could - * not leak or double-apply anything, and preserving that looked free. It is not: - * a blanket retry cannot tell a blip from a permanent answer, so it also retries - * the failures that will never succeed - a misconfigured runtime, a namespace - * that does not exist - and turns a clear error into three of them behind - * exponential backoff. `tanstack/i18n/query.test.ts` says so directly: it asserts - * that an unconfigured runtime fails with the fix in the message, and it fails by - * *timing out* the moment that query is given retries. - * - * The visitor is the retry, and that is already this codebase's stated position - * on the session: they reload or navigate again, which is a decision they can - * make and a rate limiter can see coming. Applying it to every read is more - * coherent than three exceptions preserving a default nobody chose. - * - * One factory rather than one `new QueryClient` per shell, because each app now - * has two callers - the framework integration and the provider tree - and two - * clients in one page means a query cached by a route loader is invisible to the - * component that reads it. - * - * Call it *per request* on the server. A module-level client would be shared by - * every visitor being rendered at once, which is how one person's data ends up - * in another person's page. - */ export const createVitNodeQueryClient = ( config?: QueryClientConfig, ): QueryClient => diff --git a/packages/vitnode/src/lib/query-freshness.ts b/packages/vitnode/src/lib/query-freshness.ts index a7620b1ec..dc520d48f 100644 --- a/packages/vitnode/src/lib/query-freshness.ts +++ b/packages/vitnode/src/lib/query-freshness.ts @@ -1,79 +1,3 @@ -/** - * How long a route's cached read may be trusted before a revisit refreshes it. - * - * ## The gap these close - * - * A route loader reads through `ensureQueryData`, which returns whatever is - * cached the moment anything is cached. With `refetchOnMount` and - * `refetchOnWindowFocus` both off - deliberately, so a page never moves under - * its reader - that made a revisited route show the data from the first visit - * *indefinitely*. Nothing else was going to correct it: a mutation invalidates - * what it changed, but only for the person who performed it, and a reconnect - * only happens if the network dropped. Navigate away, come back an hour later, - * and the cron table still says what it said an hour ago. - * - * The fix is not to turn the global refetch triggers back on. Those are off - * because a *mount* is not evidence that anything changed, and re-enabling them - * would reintroduce exactly the page-moves-under-you behaviour they were turned - * off to stop. What a revisit needs is owned by the loader, which is the one - * layer that knows a navigation happened. - * - * ## The contract, in two halves - * - * A `staleTime` from this file says *how long* an answer stays good. - * `revalidateIfStale: true` at the loader says *what to do* once it is not: - * hand the cached data straight to the page and refresh it behind them. So a - * revisit is never slower than it is today, and the screen corrects itself a - * moment later rather than being wrong until something else happens. - * - * Both halves are needed, and the `staleTime` is what makes it safe. The router - * runs with `defaultPreload: 'intent'` and `defaultPreloadStaleTime: 0`, so a - * loader runs on *hover*. Without a window, "always stale" would mean a - * background refetch for every link a pointer crossed - the same hover storm - * `ADMIN_SESSION_PRELOAD_STALE_TIME` exists to prevent, arrived at from the - * other direction. - * - * ## Two windows, because there are two kinds of staleness - * - * Named rather than written at each call site so the *classification* is - * reviewable in one place, and imported rather than copied so a family cannot - * drift into a number nobody chose. Which window a family takes is still - * declared in that family's own module, next to the rest of its policy. - * - * ## What does not appear here - * - * Genuinely stable data keeps its existing longer-lived caching and is not on - * this list: message catalogues (`staleTime: Infinity` - a locale's strings - * change when the app is redeployed), the middleware config's five minutes, and - * the admin session, whose `0` is a revocation guarantee and the one value in - * VitNode that must not be relaxed. Adding revalidation to those would be - * refetching data that has not changed. - */ - -/** - * Data that moves on its own, with nobody on the screen touching it. - * - * Cron runs fire, queue jobs drain, log lines arrive, a search re-index makes - * progress. An administrator on one of these screens is *watching state change*, - * so an answer goes out of date without anybody acting - which is why the window - * is the short one. Fifteen seconds is long enough that crossing a sidebar full - * of links costs nothing and short enough that coming back to a running job - * shows a running job. - */ export const OPERATIONAL_STALE_TIME = 15_000; -/** - * Data that changes only when a person edits it. - * - * User lists, roles, staff, uploaded files, integrations, Content Engine records - * and a visitor's own files and devices. A write performed *here* already - * invalidates its own family the moment it succeeds, precisely and by prefix, so - * this window is not what keeps the editor's own screen correct - it is what - * catches the edit somebody *else* made, or the one this person made in another - * tab. That is a slower kind of staleness and it takes the longer window. - * - * A minute, deliberately not less: shortening it would not make a colleague's - * edit arrive meaningfully sooner, and would spend requests on the overwhelming - * majority of revisits where nothing has changed at all. - */ export const RECORD_STALE_TIME = 60_000; diff --git a/packages/vitnode/src/lib/task-pool.test.ts b/packages/vitnode/src/lib/task-pool.test.ts index 522c6eb01..58e627665 100644 --- a/packages/vitnode/src/lib/task-pool.test.ts +++ b/packages/vitnode/src/lib/task-pool.test.ts @@ -54,14 +54,6 @@ const tasks = () => { }; }; -/** - * The ceiling, and the two ways a pool with one can go wrong: starting too many, - * and - much worse - stopping. - * - * Nothing here waits on a clock. Every task is a promise the test settles by - * hand, which is what makes "three were running and the fourth was not" a fact - * rather than a race with a timer. - */ describe("createTaskPool", () => { it("starts no more than the limit", async () => { const pool = createTaskPool(2); diff --git a/packages/vitnode/src/lib/task-pool.ts b/packages/vitnode/src/lib/task-pool.ts index bf7e9627f..f983492ca 100644 --- a/packages/vitnode/src/lib/task-pool.ts +++ b/packages/vitnode/src/lib/task-pool.ts @@ -1,15 +1,3 @@ -/** - * A bounded queue for work that is already asynchronous. - * - * Not a scheduler and not a retry policy: it starts what it is given, at most - * `limit` at a time, and starts the next one the moment a slot frees. That is - * the whole of it, and it is deliberately the whole of it - the caller keeps its - * own outcome per task, which is what lets a hundred uploads report a hundred - * results rather than one. - * - * Work may be added while earlier work is still running, so a second selection - * joins the queue behind the first instead of doubling what is in flight. - */ export interface TaskPool { /** Queues one task, starting it immediately if a slot is free. */ add: (task: () => Promise<void>) => void; @@ -19,19 +7,6 @@ export interface TaskPool { readonly waiting: number; } -/** - * A pool that runs at most `limit` tasks at once. - * - * The ceiling exists because "start every request now" is not the same offer to - * a browser as it is to a server: two hundred concurrent uploads are two hundred - * connections queued by the browser anyway, with every one of them timing out - * against the same clock and none of them able to say which file it was. Six at - * a time finish in the same wall-clock and fail one file at a time. - * - * A task that rejects frees its slot like any other - the pool never stalls on a - * failure, because the failure is the caller's to report and not the pool's to - * hold on to. - */ export const createTaskPool = (limit: number): TaskPool => { const ceiling = Math.max(1, Math.floor(limit)); const waiting: (() => Promise<void>)[] = []; diff --git a/packages/vitnode/src/locales/api/index.ts b/packages/vitnode/src/locales/api/index.ts index 876274b4a..57847ee32 100644 --- a/packages/vitnode/src/locales/api/index.ts +++ b/packages/vitnode/src/locales/api/index.ts @@ -1,14 +1,5 @@ import type { LocaleMessagesMap } from "@/lib/i18n/types"; -/** - * The languages `@vitnode/core` ships for the *server* - the strings emails and - * other server-rendered responses use, kept apart from the frontend tree in - * `../index.ts` so an API-only app never loads the admin UI's messages. - * - * Same shape as the frontend barrel: a file next to this one per language, one - * line here. See `src/locales/index.ts` for why the annotation is explicit, and - * for why only `en` is complete. - */ const messages: LocaleMessagesMap = { en: async () => await import("./en.json", { with: { type: "json" } }), pl: async () => await import("./pl.json", { with: { type: "json" } }), diff --git a/packages/vitnode/src/locales/index.ts b/packages/vitnode/src/locales/index.ts index 2c57bfc5b..57847ee32 100644 --- a/packages/vitnode/src/locales/index.ts +++ b/packages/vitnode/src/locales/index.ts @@ -1,26 +1,5 @@ import type { LocaleMessagesMap } from "@/lib/i18n/types"; -/** - * Every language `@vitnode/core` ships to the *frontend*. Add a file next to - * this one and a line here to add another; apps pick it up with no copy step. - * Server-only strings (emails) live in the sibling `api/` barrel instead, so an - * API-only app never loads the admin UI's messages. - * - * Only `en` is complete, and no other file has to be. `loadMessages` puts the - * default locale underneath as a per-key fallback, so a partial language renders - * English for the keys it leaves out rather than showing `core.global.close`. - * - * These are the *canonical* translations, which is the whole reason they live - * here rather than in each app: an app that carries its own copy of a language - * this package already ships is an app whose Polish silently diverges from - * everyone else's, and while two VitNode frontends run side by side that - * divergence is visible in one product. An installation reworders a string by - * overriding that one key in its own `src/locales`, never by forking a file. - * - * The annotation is deliberate - inferring it would inline the whole message - * tree into the emitted `.d.ts`. Key-level types come from the `next-intl` - * augmentation in `global.d.ts`. - */ const messages: LocaleMessagesMap = { en: async () => await import("./en.json", { with: { type: "json" } }), pl: async () => await import("./pl.json", { with: { type: "json" } }), diff --git a/packages/vitnode/src/next-boundary.test.ts b/packages/vitnode/src/next-boundary.test.ts index 57931c138..79507fdd0 100644 --- a/packages/vitnode/src/next-boundary.test.ts +++ b/packages/vitnode/src/next-boundary.test.ts @@ -15,22 +15,6 @@ import { stripComments, } from "@/tests/import-graph"; -/** - * The invariant Stage 17 bought, asserted over the whole package at once. - * - * The fourteen `*-boundaries.test.ts` files each police one subtree, because - * each was written when that subtree was split in two and had a Next.js half to - * point at. This one replaces the thing those halves used to make possible: a - * *whole package* claim, which could not be made while the package deliberately - * contained Next.js code. - * - * It also absorbs what `lib/next-cache/inventory.test.ts` did. That file listed - * six Next-cache entries with a `deleteWhen` condition each and failed when the - * list stopped being true; every condition is now met, so the list is replaced - * by the assertion it was counting down to - there is no Next.js cache code, and - * the framework-neutral cache is still here. - */ - const here = dirname(fileURLToPath(import.meta.url)); const srcRoot = here; const packageRoot = resolve(here, ".."); @@ -56,16 +40,6 @@ const filesUnder = (dir: string): string[] => { }); }; -/** - * Every specifier named anywhere in a file, including the ones only a test - * writes. - * - * Broader than `runtimeImports` on purpose. That function answers "what does - * this module load", which is the right question for a reachability walk and the - * wrong one here: `vi.mock("next/headers")` loads nothing, but a file that - * mocks a Next.js module is a file still written against Next.js, and it would - * survive an import-only scan indefinitely. - */ const namedSpecifiers = (path: string): string[] => { const source = stripComments(readFileSync(path, "utf8")); @@ -390,18 +364,6 @@ describe("@vitnode/core stays framework-neutral", () => { }); describe("core reaches navigation through a seam, not a router", () => { - /** - * The injected `LinkComponent` architecture, as an assertion. - * - * Around twenty components take a link component and a pathname as props so - * that this package renders in a host that is not this repository's. Nothing - * enforced it before except the fact that core could not import a router - * without breaking the Next.js app; with one host left, that accident is gone - * and the rule needs stating. - * - * `src/tanstack/**` is exempt and must be: it is the namespace whose entire - * job is binding core to TanStack Start. - */ const neutral = filesUnder(join(packageRoot, "src")).filter( path => !path.startsWith(join(packageRoot, "src/tanstack")) && diff --git a/packages/vitnode/src/routing/authoring.test-d.ts b/packages/vitnode/src/routing/authoring.test-d.ts index 7fd463d5a..9d2f41759 100644 --- a/packages/vitnode/src/routing/authoring.test-d.ts +++ b/packages/vitnode/src/routing/authoring.test-d.ts @@ -11,11 +11,6 @@ import type { import { definePluginRoute } from "./authoring"; -/** - * The helper earns its place by inferring, so every assertion here is about - * inference. Its runtime behaviour - returning its argument - is not worth a - * test, and its shape is `PluginRouteOptions`, which `./module` owns. - */ describe("definePluginRoute", () => { it("accepts every member of the contract, and only those", () => { // The helper re-declares `head` and `load` to control where each type is diff --git a/packages/vitnode/src/routing/authoring.ts b/packages/vitnode/src/routing/authoring.ts index e86606716..489ed989a 100644 --- a/packages/vitnode/src/routing/authoring.ts +++ b/packages/vitnode/src/routing/authoring.ts @@ -6,112 +6,9 @@ import type { PluginRouteOptions, } from "./module"; -/** - * The one helper this layer offers a plugin author, and the reason it exists. - * - * ./types what a plugin declares in its manifest - * ./module what a plugin's route module exports - * ./authoring the single place those two are hard to write by hand - * - * Everything else a plugin writes is plain data with a type annotation on it - - * `const routes: PluginRouteDefinition[] = [...]` checks every field, catches a - * misspelled key and needs no function wrapped around it. A helper there would - * be an identity function pretending to be an API, so there is not one. - * - * `route` is the exception, and a genuine one rather than a matter of taste. - * {@link PluginRouteOptions} is generic in what the loader returns and in what - * `parseSearch` returns, and those two types are what `head`, `load` and the - * component all read. A `satisfies` clause has to *name* them, because - * `satisfies` checks a value against a type and never infers that type's - * arguments from the value: - * - * export const route = { - * load: ({ params }) => ({ title: `Topic ${params.topic}` }), - * head: ({ loaderData }) => ({ title: loaderData?.title }), - * ~~~~~ - * // Property 'title' does not exist on type '{}'. - * } satisfies PluginRoutePageModule["route"] - * - * The loader's return type collapses to nothing, so a page's metadata cannot - * read the page's own data - and the way out is for the author to write an - * interface, repeat it in two type arguments, and keep the three in step by - * hand. A generic function infers all of it from code that is already there: - * - * export const route = definePluginRoute({ - * load: ({ params }) => ({ title: `Topic ${params.topic}` }), - * head: ({ loaderData }) => ({ title: loaderData?.title }), // string - * }) - * - * That is the whole of what it does. It returns its argument unchanged, adds no - * shape a hand-written `route` could not have, and is erased to the object - * literal at runtime. `readPluginRouteModule` still checks what actually - * arrives, because a plugin is compiled JavaScript by the time a host loads it - * and nothing here can promise otherwise. - * - * ## One rule: `load` goes above `head` - * - * TypeScript resolves an object literal's context-sensitive members in the order - * they are written, so `head`'s `loaderData` is only typed once `load` has been - * read. This is TanStack Router's own constraint, and VitNode's own routes carry - * a comment about it - what is different here is that getting it wrong says so: - * see {@link UnknownLoaderData}. - */ - -/** - * What `TData` is when nothing has told the helper what the loader returns. - * - * A sentence rather than `unknown`, because this is a type a plugin author reads - * in an error message and never writes. Both ways of arriving here are mistakes - * with the same fix, and both errors quote this string: - * - * Property 'title' does not exist on type '"definePluginRoute: `loaderData` - * is typed only when `load` is declared ABOVE `head`"'. - * - * The alternative is TypeScript's own report - `Property 'title' does not exist - * on type '{}'` - which is the failure VitNode's own route files carry a - * three-line comment to explain, ending "Neither error names the cause". This - * one names it. - */ type UnknownLoaderData = "definePluginRoute: `loaderData` is typed only when `load` is declared ABOVE `head`"; -/** - * {@link PluginRouteOptions}, with each type argument given exactly one place to - * be inferred from. - * - * Not a convenience. `head` and `load` both *mention* `TData` and `TSearch`, so - * both are inference sites for them, and TypeScript resolves context-sensitive - * arguments in the order they are written. That made the helper's inference - * depend on **key order**: `load` before `head` inferred `{ title: string }`, - * and `head` before `load` - which is the order this repository's lint rule - * sorts object keys into - fixed `TData` at its default before the loader was - * ever looked at. A helper whose types depend on how its argument was - * alphabetised is worse than no helper. - * - * `NoInfer` states which member is the source of truth for each type, so the - * order stops mattering: - * - * - `TData` from what `load` returns, or from a `breadcrumb` that annotates it. - * `head` only reads it. - * - `TSearch` from what `parseSearch` returns. `head`, `load` and `breadcrumb` - * only read it. - * - * `breadcrumb`'s *second* argument is the one worth naming: a crumb declared as - * `({ loaderData }: PluginRouteBreadcrumbProps<Topic>) => loaderData.title` - * leaves `PluginRouteBreadcrumbProps` to fill in its default search type, so - * without `NoInfer` a route with a `parseSearch` *and* a crumb inferred - * `TSearch` from whichever of the two TypeScript happened to read first. - * - * There is deliberately no `TContext`. What a plugin's `load` is handed is - * {@link PluginRouteContext} and only that - see `./module` for why a contract - * the consumer can widen is not a contract. - * - * Derived with `Omit` rather than re-declared member by member, so a member - * added to the contract arrives here without an edit. `authoring.test-d.ts` - * asserts the two have the same keys, which is what catches the other direction: - * a `head` or `load` that got renamed, leaving an `Omit` that quietly removes - * nothing. - */ type AuthoredPluginRouteOptions<TData, TSearch> = Omit< PluginRouteOptions<TData, TSearch>, "breadcrumb" | "head" | "load" @@ -127,25 +24,6 @@ type AuthoredPluginRouteOptions<TData, TSearch> = Omit< ) => Promise<TData> | TData; }; -/** - * A plugin route module's `route` export, with its own types inferred. - * - * Both kinds of module use it: a layout's `route` is the same shape as a page's, - * and what differs between them is the component's props, which is the module's - * default export and not this. - * - * import { definePluginRoute } from "@vitnode/core/routing"; - * - * export const route = definePluginRoute({ - * load: ({ context, params }) => fetchTopic(context.locale, params.topic), - * head: ({ loaderData }) => ({ title: loaderData?.title }), - * breadcrumb: ({ loaderData }) => loaderData.title, - * }); - * - * `context` is {@link PluginRouteContext} - the locale - and there is no way to - * ask for a wider one. Annotating `load`'s parameter with a bigger shape is a - * type error rather than a promise nobody made. - */ export const definePluginRoute = < TData = UnknownLoaderData, TSearch = Record<string, never>, diff --git a/packages/vitnode/src/routing/boundaries.test.ts b/packages/vitnode/src/routing/boundaries.test.ts index 90d2440da..7f2a1fa4e 100644 --- a/packages/vitnode/src/routing/boundaries.test.ts +++ b/packages/vitnode/src/routing/boundaries.test.ts @@ -23,14 +23,6 @@ const filesUnder = (directory: string): string[] => { return entries; }; -/** - * A file with its comments removed. - * - * These modules document themselves at length, and `./module` shows a plugin - * author the import they are meant to write - which the scan below would - * otherwise read as an import *this* layer makes. Stripping comments first is - * what keeps the rule about code. - */ const codeOf = (path: string): string => readFileSync(path, "utf8") .replace(/\/\*[\s\S]*?\*\//g, "") @@ -41,18 +33,6 @@ const importsFrom = (path: string): string[] => .map(match => match[1] ?? match[2]) .filter((specifier): specifier is string => Boolean(specifier)); -/** - * The rule this layer exists to keep. - * - * A plugin route manifest is VitNode configuration data. It is read while a - * Next.js app builds, while a TanStack Start app builds, and by a plain - * `vitest` process with no framework loaded at all - so a single import of - * `next/*` or `@tanstack/*` here does not fail in review, it fails for whoever - * is not using that framework. - * - * Stated as "imports nothing but its own files" rather than as a list of banned - * packages, because a list is something somebody has to remember to extend. - */ describe("the routing layer is framework-neutral", () => { // `.test-d.ts` as well as `.test.ts`: a type test asserts against `vitest`'s // `expectTypeOf` and is erased before anything runs, so its import is not one diff --git a/packages/vitnode/src/routing/errors.ts b/packages/vitnode/src/routing/errors.ts index 6db23524d..6a72a7941 100644 --- a/packages/vitnode/src/routing/errors.ts +++ b/packages/vitnode/src/routing/errors.ts @@ -29,15 +29,6 @@ export interface PluginRouteErrorDetails { routeId?: string; } -/** - * A plugin route that cannot be part of a manifest. - * - * Thrown rather than collected, and thrown on the first problem: a manifest with - * two plugins claiming `/blog` has no correct interpretation, and picking one is - * how an install silently serves the wrong page for a release. The structured - * fields are here so a build tool can render the failure its own way without - * parsing the message. - */ export class PluginRouteError extends Error { constructor(message: string, details: PluginRouteErrorDetails) { super(message); diff --git a/packages/vitnode/src/routing/graph.test.ts b/packages/vitnode/src/routing/graph.test.ts index 9b050ef6c..caccb928b 100644 --- a/packages/vitnode/src/routing/graph.test.ts +++ b/packages/vitnode/src/routing/graph.test.ts @@ -15,15 +15,6 @@ import { pluginRouteId } from "./manifest"; import { comparePluginRoutes } from "./order"; import { parseRoutePath } from "./path"; -/** - * A built route, written directly rather than flattened from a declaration. - * - * The graph reads a manifest, so these tests hand it one: ids, parents and - * kinds are stated rather than derived, which is what lets a case like "a route - * whose parent is in another plugin" exist at all - `flattenPluginRoutes` cannot - * produce it, and this layer still has to refuse it, because it also runs over - * declarations a plugin compiled against another version of VitNode. - */ interface RouteFixture { area?: PluginRouteArea; id: string; @@ -109,11 +100,6 @@ const thrownBy = (build: () => unknown): PluginRouteError => { throw new Error("expected a PluginRouteError"); }; -/** - * The shape core's own settings screens have had since long before this - * contract existed - a frame, its index, and three siblings - which is the one - * real nested route tree VitNode ships through the plugin pipeline. - */ const settings = () => example( layout("settings", "/settings"), @@ -170,11 +156,6 @@ describe("nesting", () => { expect(parent?.children.every(node => node.depth === 1)).toBe(true); }); - /** - * The whole cost of "a nested route declares its full path": the manifest - * stays readable and collides visibly, and exactly one function turns - * `/settings/security` back into the `/security` a router composes. - */ it("relativises a child against its parent", () => { const graph = buildPluginRouteGraph(manifestOf(settings())); @@ -250,11 +231,6 @@ describe("nesting", () => { expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("a-deep")); }); - /** - * Nothing about the tree may depend on which order the plugins were - * configured in, which order their manifests happened to load, or which - * machine the build ran on. - */ it("does not depend on declaration order", () => { const shape = (routes: RouteFixture[]) => buildPluginRouteGraph(manifestOf(example(...routes))).nodes.map(node => [ @@ -295,13 +271,6 @@ describe("a hierarchy that does not hold together", () => { expect(error.code).toBe("parent-cycle"); }); - /** - * Unrepresentable in a declaration - a `parentId` is namespaced with the - * declaring plugin's own id - and still checked, because this also runs over a - * generated manifest whose ids are already global. One plugin's page inside - * another plugin's frame would make a route tree depend on which plugins - * happen to be installed beside it. - */ it("refuses a parent in another plugin", () => { const theirs = manifestOf( blog( @@ -317,18 +286,6 @@ describe("a hierarchy that does not hold together", () => { expect(error.code).toBe("cross-plugin-parent"); }); - /** - * Nesting *is* how a shell is chosen: a nested route mounts under its layout, - * and the layout mounts under its area's shell - so a child's own `area` is - * never consulted again once it has a parent. - * - * Which makes a mismatch a declaration that does not describe where the page - * renders, and silently in the worst direction: a route marked `admin` under a - * `main` layout would come out on the public site, outside the AdminCP session - * guard. Refused rather than inherited from the parent, because filling the - * field in would make the manifest that reads wrong behave like the one that - * reads right, and the wrong one is what a reviewer sees. - */ it.each([ ["an admin page under a main layout", "main", "admin"], ["a main page under an admin layout", "admin", "main"], @@ -378,13 +335,6 @@ describe("a hierarchy that does not hold together", () => { expect(error.code).toBe("invalid-parent-kind"); }); - /** - * Two layouts that are each other's parent, so neither is reachable from a - * root. Hand-built, because a declaration cannot express it: a `parentId` is - * plugin-local, a child's path has to extend its parent's, and no two paths - * can each extend the other. This runs over generated manifests too, and the - * walk that assigns depth has to be able to state that it terminates. - */ it("refuses a cycle between two routes", () => { const [a, b] = manifestOf( example( @@ -417,11 +367,6 @@ describe("a hierarchy that does not hold together", () => { expect(error.code).toBe("invalid-parent-path"); }); - /** - * The parent named that segment, so a child that renames it would read a - * parameter that never exists. A build error beats `params.postId` being - * silently `undefined`. - */ it("refuses a child that renames its parent's parameter", () => { const error = thrownBy(() => buildPluginRouteGraph( @@ -437,11 +382,6 @@ describe("a hierarchy that does not hold together", () => { expect(error.code).toBe("invalid-parent-path"); }); - /** - * Next's `(group)` folders, which this contract does not have. Hand-built, - * because two layouts at one path never reach the graph from a declaration - - * the manifest refuses them as a collision first. - */ it("refuses a nested layout that adds no segment", () => { const [outer, leaf] = manifestOf( example( @@ -481,18 +421,6 @@ describe("a hierarchy that does not hold together", () => { }); }); -/** - * The one URL clash the flat manifest cannot judge for itself. - * - * `buildPluginRouteManifest` refuses two routes of the same kind at one path, - * which is where the ordinary collision is caught. A layout and a page at one - * path are a different question, because exactly one spelling of it is legal - - * a layout beside its own index child - and telling that apart needs the tree. - * - * Left unrefused, these reached the router, which rejects them too but as - * `Invariant failed: Duplicate routes found with id: /_plugins/foo` - naming - * neither plugin, and pointing at a container no plugin author wrote. - */ describe("two routes answering one URL", () => { it("lets a layout share its path with its own index child", () => { const graph = buildPluginRouteGraph( @@ -526,17 +454,6 @@ describe("two routes answering one URL", () => { expect(error.message).toContain("@vitnode/blog"); }); - /** - * The cross-kind clash does **not** stop at an area boundary, and an earlier - * draft of Stage 12 believed it did. - * - * A layout in the AdminCP and a page on the public site that spell one - * pathname are framed by different shells - and both of those shells are - * *pathless*, so neither of them moves a URL. `/foo` is one URL claimed by two - * routes, and only the router's own ranking would decide which of them a - * browser reaches. The legal pairing is still exactly one: a layout and the - * index page inside it. - */ it("refuses a layout and a page sharing a pathname across two areas", () => { const error = thrownBy(() => buildPluginRouteGraph( @@ -559,12 +476,6 @@ describe("two routes answering one URL", () => { expect(error.message).toContain("@vitnode/example"); }); - /** - * The same tree, moved to the URL an admin page would actually claim. - * - * Nothing about the areas changed; the paths did, which is the only thing that - * ever decided this. - */ it("accepts the same tree once the admin routes claim /admin paths", () => { const graph = buildPluginRouteGraph( manifestOf( @@ -605,11 +516,6 @@ describe("two routes answering one URL", () => { expect(error.code).toBe("duplicate-path"); }); - /** - * Matched on the URLs a route answers rather than on its text, the same way - * the manifest's own collision check is - so a parameter renamed does not - * make it a different route. - */ it("refuses a dynamic path that differs only in its parameter name", () => { const error = thrownBy(() => buildPluginRouteGraph( @@ -681,17 +587,6 @@ describe("requirements", () => { expect(error.message).toContain("No visitor could ever reach it"); }); - /** - * The rule read down the *whole* chain rather than one link of it. - * - * A neutral layout between the two is the case that matters, because it is - * the one that used to pass: compared only against its immediate parent, the - * page below declared `guest` inside something declaring nothing, and nothing - * conflicts with nothing. At runtime every matched route's guard runs, so the - * `authenticated` layout further up turned guests away and the `guest` page - * turned everybody else away - a route that 404'd for every human being and - * validated cleanly. - */ it.each([ ["authenticated", "guest"], ["guest", "authenticated"], @@ -785,12 +680,6 @@ describe("requirements", () => { ).not.toThrow(); }); - /** - * The manifest keeps saying what the plugin wrote. Inheritance is this - * graph's reading of the tree, never written back onto a route - a generated - * manifest that had absorbed it would no longer round-trip to the plugin's - * own declaration. - */ it("leaves an inheriting route's own `requires` null", () => { const graph = buildPluginRouteGraph( manifestOf( diff --git a/packages/vitnode/src/routing/graph.ts b/packages/vitnode/src/routing/graph.ts index 1529b708a..084b76a5f 100644 --- a/packages/vitnode/src/routing/graph.ts +++ b/packages/vitnode/src/routing/graph.ts @@ -5,34 +5,15 @@ import { normalizeNamespaceList } from "./namespaces"; import { comparePluginRoutes } from "./order"; import { formatRoutePath, relativeRouteSegments, routeMatchKey } from "./path"; -/** - * One route in a manifest, placed. - * - * The manifest is a flat list because that is the honest serialisation of it - - * every route names its own full path and, at most, its own parent. This is the - * same list read as the tree it describes, and it is derived rather than stored - * so there is no second representation to keep in step. - */ export interface PluginRouteNode { /** In manifest order: static before dynamic, shallow before deep. */ children: PluginRouteNode[]; /** How many parents are above this route. A root is `0`. */ depth: number; - /** - * This route claims exactly its parent's URL - it is that layout's index. - * - * `/settings` under the `/settings` layout: `page.tsx` beside `layout.tsx` in - * the tree a plugin used to ship, and `index.tsx` beside `settings.tsx` in the - * one a TanStack host writes. - */ + isIndex: boolean; parent: null | PluginRouteNode; - /** - * What this route adds to its parent's path, as a path: `/security` for - * `/settings/security` under `/settings`, and `/` for an index route. - * - * The form a router composes with. A root route's is its full path. - */ + relativePath: string; /** {@link PluginRouteNode.relativePath}, parsed. Empty for an index route. */ relativeSegments: PluginRouteSegment[]; @@ -42,12 +23,7 @@ export interface PluginRouteNode { /** A manifest, as the tree it describes. */ export interface PluginRouteGraph { byId: ReadonlyMap<string, PluginRouteNode>; - /** - * Every node, **parents before children**, deterministically ordered. - * - * The order a route tree can be built in with one pass and no lookahead: by - * the time a node is reached, the route it hangs from exists. - */ + nodes: PluginRouteNode[]; /** The routes with no parent, in manifest order. */ roots: PluginRouteNode[]; @@ -71,55 +47,6 @@ const fail = ( }); }; -/** - * A manifest, read as a tree - and every way that could be wrong, refused. - * - * Called twice on the same declarations by design: once while an application is - * built, where it is what turns a tree that cannot hold together into a failed - * build, and once by the runtime over the same plugins' own modules, where it is - * what decides the order routes are mounted in. One function, so the tree an - * application mounts is provably the tree its build validated - a second - * implementation for the runtime is exactly how a check ends up passing at build - * time and being wrong in production. - * - * It is pure and it is cheap: a map, four passes over a list that has as many - * entries as the app has plugin pages. - * - * ## What it refuses - * - * - **A duplicate id.** Two routes cannot share the key their module is - * registered under. - * - **A parent that does not exist**, in a manifest that ought to contain it. - * - **A parent in another plugin.** Unrepresentable in a declaration - a parent - * is the layout a route was nested inside, in its own plugin's tree - and - * still checked here, because ids are global by the time this runs. One - * plugin's page inside another plugin's frame would make a route tree depend - * on which plugins happen to be installed beside it. - * - **A parent that is not a layout.** A page has no children; a route under one - * would never render. - * - **A parent in another area.** Nesting is how a shell is chosen, so a route - * whose `area` disagrees with its layout's is a declaration that does not - * describe where the page actually renders. - * - **A cycle**, including a route that is its own parent. - * - **A child whose path is not the parent's path or an extension of it** - a - * manifest that lies about where its pages are. - * - **A nested layout that adds no segment.** A pathless group is a shape this - * contract does not have; said plainly rather than half-supported. - * - **A layout with no children**, which is a route nothing can ever match. - * - **An unsatisfiable requirement** - a guest-only page inside a subtree that - * requires a signed-in visitor, which no visitor could ever reach. Checked - * against the whole ancestor chain, not the immediate parent: a neutral layout - * in between forwards the requirement above it rather than clearing it. - * - **Two routes answering one URL**, in the one shape the flat list cannot - * judge: a layout shares its path with its own index child and with nothing - * else, so a page and somebody else's layout at that path are two routes - * competing for it. - * - * Ordering depends on the routes and on nothing else: children are sorted with - * the manifest's own comparator, so the tree is the same whichever order the - * plugins were configured in, whichever order their manifests happened to load, - * and on whichever machine. - */ export const buildPluginRouteGraph = ( manifest: readonly PluginRoute[], ): PluginRouteGraph => { @@ -231,19 +158,6 @@ export const buildPluginRouteGraph = ( // only from roots, it cannot enter the cycle to loop in it. const reached = new Set<PluginRouteNode>(); - /** - * The requirement in force, and which ancestor declared it. - * - * The whole ancestor chain rather than the immediate parent, because a guard - * is a guard wherever it sits: every matched route's `beforeLoad` runs, so a - * `guest` page under a *neutral* layout under an `authenticated` one is still - * a page no visitor can reach - the outer guard turns a guest away and the - * inner one turns everybody else away. Compared only against the nearest - * ancestor, that tree passed validation and 404'd for every human being. - * - * A node is carried rather than a bare requirement so a diagnostic can name - * the route that actually imposed it, which is not necessarily the parent. - */ const walk = ( node: PluginRouteNode, depth: number, @@ -412,19 +326,6 @@ export const buildPluginRouteGraph = ( return { byId, nodes, roots }; }; -/** - * Every namespace a route's *strings* need - its own, plus its layouts'. - * - * A page mounts one message provider, and that provider replaces the shell's - * rather than adding to it, so it has to name every namespace anything below it - * renders from - including the frame its layout drew. A layout declaring - * `["@vitnode/blog"]` and a page declaring `["@vitnode/blog.post"]` is a page - * that needs both, and asking each route to restate its ancestors' is how the - * two drift. - * - * De-duplicated and sorted, so two routes with the same set warm and read one - * cache entry rather than two holding identical bytes. - */ export const pluginRouteNamespaces = (node: PluginRouteNode): string[] => { const namespaces: string[] = []; diff --git a/packages/vitnode/src/routing/index.ts b/packages/vitnode/src/routing/index.ts index 5925679c1..bb6e77c19 100644 --- a/packages/vitnode/src/routing/index.ts +++ b/packages/vitnode/src/routing/index.ts @@ -1,32 +1,3 @@ -/** - * Plugin routing, as VitNode data - `@vitnode/core/routing`. - * - * A plugin says "I have a page called `hello`, it lives at `/example/hello`, - * these are the strings it renders, and this module renders it". This layer - * turns every such declaration in an application into one validated, - * deterministically ordered manifest and the tree that manifest describes, and - * stops there - it renders nothing, resolves no modules and imports nothing - * framework-shaped. - * - * That boundary is the whole point, and it is the reason this package can be - * read by a Next.js build, a TanStack Start build and a bare `vitest` process - * at the same time. `boundaries.test.ts` keeps it: nothing here imports anything - * that is not its own file. - * - * ## Two halves, fetched at two different times - * - * ./tree ./flatten ./manifest ./graph WHAT routes exist, WHERE, WHICH shape - * ./module HOW one behaves once its chunk lands - * - * The first is a plugin's `routes.ts`: a nested tree of `page()`, `layout()` and - * `index()` declarations, read before a single byte of a page's code is - * downloaded - which is why the guard, the URL, the tree shape, the message - * namespaces and the one eager `search` schema live there and not in the module. - * The second is the contract each lazily imported page or layout module - * satisfies, and it is a VitNode-owned shape rather than a re-exported router - * type, so a plugin is coupled to VitNode and not to whichever router its host - * happens to run. - */ export { definePluginRoute } from "./authoring"; export type { PluginRouteErrorCode, PluginRouteErrorDetails } from "./errors"; export { PluginRouteError } from "./errors"; diff --git a/packages/vitnode/src/routing/manifest.test.ts b/packages/vitnode/src/routing/manifest.test.ts index 471da6754..10d39fd99 100644 --- a/packages/vitnode/src/routing/manifest.test.ts +++ b/packages/vitnode/src/routing/manifest.test.ts @@ -14,15 +14,6 @@ import { comparePluginRoutes } from "./order"; import { definePluginRoutes, index, layout, lazy, page } from "./tree"; import { PLUGIN_ROUTE_AREAS } from "./types"; -/** - * Every plugin's routes in one application: flattened, validated, ordered, and - * paired with the two things that cannot be serialised - each route's lazy - * component and each route's eager search schema. - * - * What is tested here is the part that needs more than one plugin, or more than - * one route: ids, ordering, and the collisions VitNode refuses rather than - * resolves. Whether a single tree is legal on its own is `./tree.test.ts`. - */ const lazyPage = () => lazy(async () => await Promise.resolve({ default: () => null })); diff --git a/packages/vitnode/src/routing/manifest.ts b/packages/vitnode/src/routing/manifest.ts index 1e17d66b2..f01ce57c0 100644 --- a/packages/vitnode/src/routing/manifest.ts +++ b/packages/vitnode/src/routing/manifest.ts @@ -16,12 +16,6 @@ import { PLUGIN_ROUTE_ID_SEPARATOR } from "./types"; export { comparePluginRoutes }; -/** - * A route's globally unique id. - * - * Namespaced by the plugin, which is what lets two plugins both have a layout at - * their own `/catalog` without either having to know the other exists. - */ export const pluginRouteId = (pluginId: string, routeId: string): string => `${pluginId}${PLUGIN_ROUTE_ID_SEPARATOR}${routeId}`; diff --git a/packages/vitnode/src/routing/module.test.ts b/packages/vitnode/src/routing/module.test.ts index 58f344c6c..bf6c7832a 100644 --- a/packages/vitnode/src/routing/module.test.ts +++ b/packages/vitnode/src/routing/module.test.ts @@ -107,12 +107,6 @@ describe("readPluginRouteModule", () => { ); }); - /** - * A breadcrumb is a *component*, not an element - the label is translated and - * on a dynamic route comes from the loader, so it has to be able to use hooks. - * A plugin that exported `<Crumb />` by mistake is caught here rather than by - * React. - */ it("refuses a breadcrumb element", () => { expect(() => readPluginRouteModule( diff --git a/packages/vitnode/src/routing/module.ts b/packages/vitnode/src/routing/module.ts index 6f9a55d62..208a893ce 100644 --- a/packages/vitnode/src/routing/module.ts +++ b/packages/vitnode/src/routing/module.ts @@ -1,114 +1,15 @@ -/** - * What a plugin route *module* exports - the runtime half of the contract. - * - * ./types WHAT route exists, WHERE it lives, WHICH module owns it - * ./module HOW that route behaves once its module has been fetched - * - * Two halves that are fetched at different times and must therefore be declared - * in different places. The manifest is frozen into the application at build - * time and read before anything is downloaded; a module is a lazily imported - * chunk that arrives when somebody navigates. Anything the runtime has to know - * *in order to decide whether to fetch the chunk* - the URL, the tree shape, the - * guard, the strings to fetch alongside it - is manifest data. Everything else - * is here. - * - * ## Why this is not `RouteOptions` - * - * Re-exporting TanStack Router's own route options as the plugin API would be - * one line and would be a mistake. It would pin every plugin to that router's - * exact generics - including the parent route type and the host's context type, - * neither of which a plugin can name - so a router upgrade would be a breaking - * change for every plugin in the ecosystem, and a VitNode app on any other - * router could not load one at all. What is here instead is a VitNode-owned - * shape: four optional members, all of them things a plugin page provably needs, - * none of them a re-export. - * - * ## Import-free, and how - * - * `React.FunctionComponent` and `React.ReactNode` are reached through the UMD - * global that `@types/react` declares, in type position only, so this module - * still imports nothing at all and `boundaries.test.ts` still holds: the routing - * layer is loadable in a Node process with no framework present, because every - * reference here is erased before anything runs. - * - * ## Writing one - * - * // plugins/blog/src/routes/post-page.tsx - * import { definePluginRoute } from "@vitnode/core/routing"; - * - * const PostPage = ({ loaderData }: PluginRoutePageProps<Post>) => ( - * <article>{loaderData.title}</article> - * ); - * - * export const route = definePluginRoute({ - * load: ({ context, params }) => fetchPost(context.locale, params.slug), - * head: ({ loaderData }) => ({ title: loaderData?.title }), - * }); - * - * export default PostPage; - * - * `definePluginRoute` rather than `satisfies PluginRoutePageModule["route"]`, - * and the difference is not style: `satisfies` checks a value against a type and - * never infers that type's arguments from the value, so the loader's return type - * collapses and `loaderData?.title` fails to compile. See `./authoring`. - * - * The default export alone is still a complete module - it is what the - * prototype's one plugin page exports today, and it keeps working untouched: a - * component that declares no props is assignable to one that is offered them. - */ - /** What a crawler may do with a plugin page. */ export type PluginRouteRobots = "index, follow" | "noindex, nofollow"; -/** - * A plugin page's metadata, as the three fields a page actually sets. - * - * Structurally the host's own `RouteHeadOptions` - * (`@vitnode/core/tanstack/metadata`), so the runtime hands this straight to - * `routeHead` and a plugin's title goes through the same `"<page> - <site>"` - * rule every VitNode page's does. Deliberately *not* that type: this layer may - * not import it, and a plugin that could return arbitrary head elements would be - * a plugin that could inject a script tag into its host. - * - * There is no Open Graph, no canonical link and no JSON-LD here, and that is a - * decision rather than an omission: a full metadata API is a stage of its own, - * and three fields is what the migrated routes have needed. - */ export interface PluginRouteHead { /** The `<meta name="description">`, when the page has one. */ description?: string; - /** - * Omit where a parent layout already declares it - the router merges the - * `head` of every matched route and prefers the deepest, so a child inherits - * by saying nothing. - */ + robots?: PluginRouteRobots; /** The page's own title, already translated. */ title?: string; } -/** - * Everything a host promises a plugin route's loader - the whole of it, and not - * a base somebody extends. - * - * The locale, because a loader that fetches anything user-facing needs to know - * which language it is fetching, and because the public URL is the only place - * that answer comes from. - * - * **Closed on purpose.** This used to be a `PluginRouteContextBase` that a - * plugin could widen by annotating its own `load` parameter, which let a plugin - * compile against a host property nobody had promised it - `context.database` - * type-checks, then arrives `undefined` in a browser. A public contract that - * can be widened from the consumer's side is not a contract. What a host has - * internally is its own business: the TanStack runtime holds a `QueryClient` and - * a resolved session, and *projects* this shape out of them before calling a - * plugin's `load`. - * - * Adding a member here is therefore a deliberate act with a cost: it has to be - * something every VitNode host can promise, in Node and in a browser, whichever - * router it runs. A plugin that needs data reaches for a shared query contract - * or a framework-neutral API, not for a wider context. - */ export interface PluginRouteContext { locale: string; } @@ -124,272 +25,67 @@ export interface PluginRouteLoadArgs<TSearch = unknown> { /** What a plugin route's `head` is handed. */ export interface PluginRouteHeadArgs<TData = unknown, TSearch = unknown> { - /** - * Optional because `head` runs once *before* the loader has resolved. Spread - * it rather than branching on it: - * - * head: ({ loaderData }) => ({ robots: "index, follow", ...loaderData }) - */ loaderData?: TData; params: Readonly<Record<string, string>>; search: TSearch; } -/** - * What a plugin route's breadcrumb component is handed. - * - * The same three names `load`, `head` and the page component receive, taken from - * the match that declared the crumb rather than from the deepest one - so a - * layout's crumb reads the layout's own loader data even while a page inside it - * is what the visitor is looking at. - * - * A crumb that only needs a translated string declares no props at all: - * `() => useTranslations("@acme/catalog")("breadcrumbs.products")` is assignable - * to a component type that supplies these and simply ignores them. - */ export interface PluginRouteBreadcrumbProps< TData = undefined, TSearch = unknown, > { - /** - * Exactly what this route's `load` returned, or `undefined` for a module that - * declares no loader. - * - * Optional in one case the type cannot express and the runtime can: the shell - * renders the trail for every *matched* route, and a match whose loader threw - * is still a match. A crumb on a route whose data may fail to resolve should - * read it defensively. - */ loaderData: TData; /** This route's own dynamic segments, e.g. `{ productId: "42" }`. */ params: Readonly<Record<string, string>>; - /** - * This route's query string, validated the same way its page's is. - * - * `unknown` unless the crumb names the type, which most do not: a crumb that - * reads the search is rare, and defaulting to the empty record would make a - * crumb that ignores it *incompatible* with a route that has one - the props - * are checked contravariantly against what the route provides. - */ + search: TSearch; } -/** - * The behaviour a plugin route module may declare, and the whole of it. - * - * Four members, every one optional, every one traced to something a migrated - * VitNode page does today. - */ export interface PluginRouteOptions<TData = unknown, TSearch = unknown> { - /** - * This route's **one crumb** in the shell's breadcrumb trail. - * - * A **component**, not an element, for two reasons: the label is usually - * translated, so it has to be able to call `useTranslations` from `use-intl` - * through the message namespaces its route declared; and the runtime hands it - * this route's own {@link PluginRouteBreadcrumbProps} - the loader data, the - * params and the search of the match that declared it - which an element - * written in a route module could not be given. - * - * Return the label and nothing else. VitNode owns the trail: the separators, - * the `nav`/`aria-current` semantics, and the locale-aware link to this - * route's own URL. A plugin never builds a router link for a crumb, and never - * restates its layouts' crumbs - every matched route contributes its own, in - * parent-to-child order. - * - * `false` omits this route from the trail explicitly, which is what a page - * whose parent layout already names the screen wants. Declaring nothing does - * the same thing; `false` is for saying so on purpose. - * - * There is deliberately no pathname-to-label registry anywhere: a route - * declares its own crumb, next to its own component. - */ breadcrumb?: false | React.ComponentType<PluginRouteBreadcrumbProps<TData, TSearch>>; - /** - * The page's title, description and robots directive - translated, and - * usually read off `loaderData` so the `<title>` and the `<h1>` are the same - * string by construction. - */ + head?: (args: PluginRouteHeadArgs<TData, TSearch>) => PluginRouteHead; - /** - * Everything the route needs before it renders. - * - * Runs before React, on the server and in the browser, so a page that awaits - * its data here is a page whose first byte of HTML already contains it. Reuse - * a shared query contract - the same options object the component reads back - - * rather than fetching into local state: a loader that warms a different key - * than its component reads is a render that starts empty and fills in a round - * trip later. - * - * What it is handed is {@link PluginRouteContext} and nothing more, which is - * the deliberate limit rather than an omission: a host's own `QueryClient` is - * a host implementation detail, and a plugin that compiled against one would - * be installable into exactly the hosts that happen to have it. - * - * The host's API boundary is unchanged. A plugin's reads go to Hono the way - * every other read does; this is where they are *awaited*, not a second - * transport. - * - * Its return type flows into `head`'s `loaderData` and into the component's - * props, so one object serves all three. - */ + load?: (args: PluginRouteLoadArgs<TSearch>) => Promise<TData> | TData; - /** - * Normalises the query string into the `search` this route's `load`, `head` - * and component are handed. - * - * **Not a URL schema, and named to stop it reading as one.** It is *not* - * TanStack Router's `validateSearch`: it does not shape the router's own - * search type, does not validate a `<Link>`, and cannot reject a URL. It - * cannot be any of those, and the reason is the property this whole layer is - * arranged around - a route's real `validateSearch` runs during path matching, - * which is *before* any chunk is fetched, and a plugin's module is lazy. The - * router matches the route without it; this runs in the loader, once the - * module has arrived. So the raw query string is what the router sees, and - * this is what everything downstream of the module sees. For a screen whose - * URL *is* its state, declare `search` on the route in `routes.ts` instead - - * that one the router does see, and it is the one field of a route that is - * deliberately eager. - * - * **Must be total.** It normalises, it does not reject - a hand-edited or - * pasted query string should render the page it would have rendered anyway, - * because throwing here turns `?first=abc` into a router error screen. Return - * only parameters this route recognises; anything else must not be carried - * forward into a request. - * - * parseSearch: input => ({ - * from: (input as { from?: unknown }).from === "index" ? "index" : "", - * }) - * - * When a route declares one, the runtime re-runs `load` whenever the query - * string changes. That is deliberately not configurable: a loader that did not - * re-run would serve the first result set forever, and the cost of an extra - * cached read is smaller than the cost of a stale page. - */ + parseSearch?: (input: unknown) => TSearch; } -/** - * What a plugin route's component is handed, whichever kind of module it is. - * - * The same three names `load` and `head` receive, so one vocabulary carries - * across all three seams of a route: an author writes `({ params })` in the - * loader, `({ loaderData, params })` in `head` and `({ loaderData, params })` in - * the component, and never has to learn which spelling belongs where. - * - * An envelope rather than the loader's return spread flat, and the reason is - * that a spread has no answer for the cases: a loader that returns a string has - * nothing to spread, and a loader that happens to return a `children` key would - * displace the routes rendered inside a layout. - * - * A component that needs none of it still declares no props - `() => <Page />` - * is assignable to a component type that supplies these, and stays the whole of - * a simple plugin page. - */ export interface PluginRoutePageProps< TData = undefined, TSearch = Record<string, never>, > { - /** - * Exactly what `load` returned, or `undefined` for a module that declares no - * loader. - * - * **Not optional**, unlike the `loaderData` on {@link PluginRouteHeadArgs}, - * and the asymmetry is real rather than an oversight: `head` runs on passes - * where the loader has not resolved, and a match does not render until its - * loader has. By the time this component exists the data is in hand. - */ loaderData: TData; - /** - * Replaces this page's query string, and nothing else. - * - * The narrowest useful navigation, and narrow on purpose: a plugin page that - * could be handed a router's own `navigate` would be handed that router's - * whole route table with it, and would compile against exactly the hosts that - * happen to have the same one. This changes the search of the URL the page is - * already on, which is what a paginated table, a filter or a sort control - * actually needs, and it means the same thing under any router. - * - * `resetScroll` defaults to the router's own behaviour; pass `false` for a - * table whose page should not jump to the top when the page number changes. - * - * Pairs with a route's eager `search` schema: a route that declares one gets a - * validated {@link PluginRoutePageProps.search} in and a typed one out, which - * together are what make the query string usable as state rather than as a - * string. - */ + navigate: (options: { resetScroll?: boolean; search: TSearch; }) => Promise<void>; /** The route's own dynamic segments, e.g. `{ slug: "hello" }`. */ params: Readonly<Record<string, string>>; - /** - * The route's query string, validated. - * - * Whatever the route's eager `search` schema returned when its declaration - * has one, and whatever this module's own `parseSearch` returned otherwise. - * Never raw query parameters in either case. - */ + search: TSearch; } /** A plugin route module that renders a page - `page()` or `index()`. */ export interface PluginRoutePageModule<TData = unknown, TSearch = unknown> { - /** - * The page. - * - * A default export because that is how every VitNode plugin page already - * exports itself, and because it is the one name a generated registry can rely - * on without being told. - * - * It renders no `<main>`: the route's area puts it inside the application - * shell, and the shell owns the document's one `main` landmark. A page owns - * its container - width, padding, vertical rhythm - and nothing above it. - */ default: React.FunctionComponent<PluginRoutePageProps<TData, TSearch>>; route?: PluginRouteOptions<TData, TSearch>; } /** A plugin route module that renders a frame - `layout()`. */ export interface PluginRouteLayoutModule<TData = unknown, TSearch = unknown> { - /** - * The frame, which renders its children where they belong. - * - * `children` as a prop rather than an `<Outlet />` the layout imports, which - * is the only thing that keeps a layout framework-neutral: an `Outlet` is a - * router's, and a plugin that imported one could be installed into exactly one - * kind of application. It is the same shape as a Next.js `layout.tsx`, so a - * plugin that ships both writes the frame once. - */ default: React.FunctionComponent< PluginRoutePageProps<TData, TSearch> & { children: React.ReactNode } >; route?: PluginRouteOptions<TData, TSearch>; } -/** - * Either kind of module, for code that has not yet looked at the manifest. - * - * The two differ only in what their component is handed, and which one a module - * must be is decided by its route's `kind` - which the runtime knows before it - * loads the module. - */ export type PluginRouteModule<TData = unknown, TSearch = unknown> = | PluginRouteLayoutModule<TData, TSearch> | PluginRoutePageModule<TData, TSearch>; -/** - * A module's declared behaviour, once it has been checked rather than trusted. - * - * Every callable is typed at its widest here, because the check is `typeof - * value === "function"` and nothing more: a module arrives from a registry whose - * loaders are `() => Promise<unknown>` on purpose, and no amount of casting at - * this boundary can make a plugin's `load` provably take the host's context. - * The runtime applies the arguments it promised and the plugin's own `satisfies` - * is what checked that they line up. - */ export interface CheckedPluginRouteOptions { breadcrumb?: false | React.ComponentType<PluginRouteBreadcrumbProps<unknown>>; head?: (args: PluginRouteHeadArgs) => PluginRouteHead; @@ -399,12 +95,6 @@ export interface CheckedPluginRouteOptions { /** A loaded plugin route module, checked. */ export interface CheckedPluginRouteModule { - /** - * The module's default export. - * - * Typed to accept `children` so one shape covers both kinds; a page component - * that declares no props is assignable to it and simply ignores them. - */ component: React.FunctionComponent<{ children?: React.ReactNode }>; /** Never `undefined` - a module that declares nothing gets an empty object. */ route: CheckedPluginRouteOptions; @@ -413,15 +103,6 @@ export interface CheckedPluginRouteModule { const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null; -/** - * Every member of {@link PluginRouteOptions}, and which of them are functions. - * - * A list rather than four `if`s, so adding a member to the contract is one entry - * here instead of a check somebody forgets to write. `breadcrumb` is included: - * it is a component, which is a function, and a plugin that exported an *element* - * by mistake would otherwise reach React as an invalid element type three frames - * from the plugin that caused it. - */ const OPTION_KEYS = [ "breadcrumb", "head", @@ -429,21 +110,6 @@ const OPTION_KEYS = [ "parseSearch", ] as const satisfies readonly (keyof CheckedPluginRouteOptions)[]; -/** - * A loaded module, checked and narrowed - or an error naming the plugin route. - * - * The registry's loaders are typed `() => Promise<unknown>` deliberately: what a - * module is expected to export is not the registry's contract. This is where - * that `unknown` becomes something a router can be handed, and it is *checked* - * rather than asserted, because the failure it prevents is otherwise React's - * "type is invalid" from inside a lazy component, with nothing in the message - * naming the plugin. - * - * Pure and total in the useful sense: a valid module in, a checked module out; - * anything else throws with the route id in it. It reads nothing but the two - * names the contract defines, so a module that also exports helpers, constants - * or a test fixture is fine. - */ export const readPluginRouteModule = ( module: unknown, routeId: string, diff --git a/packages/vitnode/src/routing/namespaces.ts b/packages/vitnode/src/routing/namespaces.ts index a91e73533..40687d7cf 100644 --- a/packages/vitnode/src/routing/namespaces.ts +++ b/packages/vitnode/src/routing/namespaces.ts @@ -1,21 +1,3 @@ -/** - * Message namespaces, as one rule two layers apply. - * - * A namespace is a path into the merged message tree - `core.global`, - * `@vitnode/blog.post` - and it is written down in two very different places: a - * plugin *declares* the ones its route renders, at build time, in a manifest; a - * browser *asks* for a set of them at runtime, through a server function that is - * a public `POST` endpoint. The declaration is trusted and the request is not, - * but what makes a namespace legal is the same question in both, so it is - * answered once here. - * - * Pure and import-free, like everything in this layer: it is read by a build - * running in Node with no framework loaded, by an app's browser bundle, and by - * the i18n runtime that owns the fetch - see `@vitnode/core/tanstack/i18n`, - * which validates its server function's input with {@link namespaceProblem} - * rather than a second copy of these rules. - */ - /** * More than any page has ever needed, and few enough that a caller asking for * thousands is refused rather than served. @@ -28,30 +10,12 @@ export const MAX_NAMESPACE_DEPTH = 8; /** Comfortably longer than the longest plugin id plus a namespace path. */ export const MAX_NAMESPACE_LENGTH = 128; -/** - * Segments that must never reach a message tree walk. - * - * `__proto__`, `constructor` and `prototype` are the three steps of prototype - * pollution. They are rejected rather than quietly dropped, because a namespace - * containing one is not a namespace with a typo in it. - */ const UNSAFE_NAMESPACE_SEGMENTS: ReadonlySet<string> = new Set([ "__proto__", "constructor", "prototype", ]); -/** - * What is wrong with one namespace, or `null` if nothing is. - * - * Returns the *predicate half* of a sentence - "must be a string." - so each - * caller can put its own subject in front of it: the i18n server function says - * `namespaces[0] must be a string.` and a route manifest names the plugin and - * the route instead. One rule, two vocabularies, no drift. - * - * Deliberately says *what* was wrong and never *what was sent*: at the runtime - * end the value is attacker-controlled and the message reaches a server log. - */ export const namespaceProblem = (value: unknown): null | string => { if (typeof value !== "string") return "must be a string."; if (value.length === 0) return "must not be empty."; diff --git a/packages/vitnode/src/routing/order.ts b/packages/vitnode/src/routing/order.ts index 0aeb3dd7d..2aeaf16ed 100644 --- a/packages/vitnode/src/routing/order.ts +++ b/packages/vitnode/src/routing/order.ts @@ -1,19 +1,5 @@ import type { PluginRoute, PluginRouteSegment } from "./types"; -/** - * The order routes are declared in must not decide which one wins. - * - * Compared segment by segment: a static segment sorts before a parameter at the - * same depth, so `/blog/new` precedes `/blog/:slug` no matter who registered - * first; equal kinds compare by their text, and a shorter path precedes a longer - * one that starts the same way. Comparison is by code unit rather than - * `localeCompare`, because a route table that reorders itself on a machine with - * a different locale is a bug that only reproduces on someone else's laptop. - * - * Its own module because two things sort with it - the manifest builder and the - * graph, which sorts a layout's children - and having the graph reach into the - * manifest for it would make the two import each other. - */ const compareSegments = ( a: PluginRouteSegment[], b: PluginRouteSegment[], @@ -39,13 +25,6 @@ const compareSegments = ( return a.length - b.length; }; -/** - * Two routes, in a total order that depends on the routes alone. - * - * The id breaks the remaining tie - which is what a layout and its index route - * come down to, their paths being equal - and ids are unique, so the order is - * total. - */ export const comparePluginRoutes = (a: PluginRoute, b: PluginRoute): number => { const bySegments = compareSegments(a.segments, b.segments); diff --git a/packages/vitnode/src/routing/path.test.ts b/packages/vitnode/src/routing/path.test.ts index aad4e1829..740bc534c 100644 --- a/packages/vitnode/src/routing/path.test.ts +++ b/packages/vitnode/src/routing/path.test.ts @@ -117,15 +117,6 @@ describe("paths a plugin may not declare", () => { }); }); -/** - * One canonical spelling, because a router only has one. - * - * The routers that consume this manifest match paths case-insensitively, so - * `/Example` and `/example` are one URL to a browser and two strings to - * `routeMatchKey` - a collision the validation could not see. Rejected rather - * than lowercased: a plugin's public URL must not change behind its author's - * back, and a build error is how they find out. - */ describe("static segments are lowercase", () => { it("accepts a lowercase path", () => { expect(parse("/example").path).toBe("/example"); @@ -159,13 +150,6 @@ describe("static segments are lowercase", () => { }); }); -/** - * The two syntaxes this representation exists to be independent of. - * - * A plugin author coming from either framework writes the one they know, so the - * failure has to name the syntax and hand back the VitNode spelling rather than - * saying "invalid path". - */ describe("framework syntax is rejected by name", () => { it("rejects Next.js filesystem syntax", () => { expect(reason("/example/[slug]")).toContain("Next.js filesystem syntax"); @@ -178,14 +162,6 @@ describe("framework syntax is rejected by name", () => { }); }); -/** - * Deferred on purpose - inventoried in the Stage 5 notes rather than guessed at. - * - * Core ships two catch-alls today (`admin/content/[...slug]` and the - * `@breadcrumb` slots), both of which are AdminCP or parallel-route machinery - * that this stage does not cover. Accepting `/x/*` here would mean deciding what - * it means before anything needs it. - */ describe("route shapes this prototype defers", () => { it("rejects a catch-all", () => { expect(reason("/example/*")).toContain("catch-all"); @@ -258,13 +234,6 @@ describe("the URLs a path matches", () => { }); }); -/** - * The same key space, entered from a path an application's router already holds. - * - * This is what lets plugin-vs-application collisions be the same question as - * plugin-vs-plugin instead of a second rule that agrees until somebody edits one. - * `$id` is read as input syntax; nothing here imports a router. - */ describe("the URLs a TanStack path matches", () => { const key = routeMatchKeyFromTanStackPath; @@ -309,12 +278,6 @@ describe("the URLs a TanStack path matches", () => { expect(key("/")).toBe("/"); }); - /** - * A splat swallows every remaining segment and a parameter swallows one, so - * they are not the same URL space and must not share a key. No canonical - * VitNode path can produce this marker - catch-alls are rejected - so a plugin - * route can never collide with an application splat by key. - */ it("keeps a splat distinct from a parameter", () => { expect(key("/api/$")).toBe("/api/**"); expect(key("/api/$")).not.toBe(key("/api/$id")); diff --git a/packages/vitnode/src/routing/path.ts b/packages/vitnode/src/routing/path.ts index f1bb747a4..ff07cffce 100644 --- a/packages/vitnode/src/routing/path.ts +++ b/packages/vitnode/src/routing/path.ts @@ -1,18 +1,5 @@ import type { PluginRouteSegment } from "./types"; -/** - * A static segment: a literal piece of URL, and lowercase. - * - * Percent-encoding, spaces and uppercase are all left out. A plugin author who - * needs one of the first two in a public URL has a naming problem, not a routing - * problem, and a route table full of `%20` is nobody's idea of a good time. - * - * Uppercase is excluded for a sharper reason: the routers that consume this - * manifest match paths **case-insensitively**, so `/Example` and `/example` - * answer the same URL. Accepting both would mean two manifest paths that - * `routeMatchKey` calls different and a browser calls identical - a collision the - * validation could not see. One canonical spelling removes the question. - */ const STATIC_SEGMENT = /^[a-z0-9][a-z0-9._-]*$/; /** A parameter name, i.e. a JavaScript-ish identifier - it becomes one. */ @@ -100,17 +87,6 @@ const parseSegment = ( return { segment: { kind: "static", value: raw } }; }; -/** - * Reads a canonical VitNode route path. - * - * The one fallible function in this module, and the only place a path string is - * ever interpreted. Everything else takes segments, which cannot be malformed, - * so no caller has to remember to handle an error twice. - * - * Returns a result rather than throwing: the manifest builder wants to attach - * the plugin and the route id to the failure, and an exception thrown from here - * would not know either. - */ export const parseRoutePath = (path: string): ParseRoutePathResult => { if (typeof path !== "string" || path.length === 0) { return { ok: false, reason: "a route path must be a non-empty string" }; @@ -174,13 +150,6 @@ export function formatRoutePath(segments: PluginRouteSegment[]): string { .join("/")}`; } -/** - * Segments to Next.js filesystem syntax, `/blog/[slug]`. - * - * Here rather than in the Next.js layer because it is the same three lines as - * its TanStack twin, and keeping the pair together is what stops the two - * conversions from drifting into two different ideas of what a path is. - */ export const toNextRoutePath = (segments: PluginRouteSegment[]): string => { if (segments.length === 0) return "/"; @@ -202,15 +171,6 @@ export const toTanStackRoutePath = (segments: PluginRouteSegment[]): string => { .join("/")}`; }; -/** - * The set of URLs a path matches, as a comparable string. - * - * `/blog/:slug` and `/blog/:postId` are two spellings of one route: they match - * exactly the same URLs, and an application that accepted both would answer - * `/blog/hello` differently depending on which plugin loaded first. Collapsing - * every parameter to `:` is what turns that into a collision the manifest can - * refuse rather than a race it silently resolves. - */ export const routeMatchKey = (segments: PluginRouteSegment[]): string => { if (segments.length === 0) return "/"; diff --git a/packages/vitnode/src/routing/tree.test.ts b/packages/vitnode/src/routing/tree.test.ts index 2032fec14..09d49129c 100644 --- a/packages/vitnode/src/routing/tree.test.ts +++ b/packages/vitnode/src/routing/tree.test.ts @@ -14,29 +14,12 @@ import { page, } from "./tree"; -/** - * A plugin's route tree, flattened - which is the whole of what an author's - * `routes.ts` has to survive before anything else in VitNode sees it. - * - * Every test here goes through the real helpers rather than building a - * declaration by hand, because the helpers are the API: what `page()`, - * `layout()` and `index()` put on a node is exactly what the flattener is - * allowed to read, and a test that wrote the node itself would pass while the - * helpers were broken. - */ const lazyPage = () => lazy(async () => await Promise.resolve({ default: () => null })); const flatten = (...routes: PluginRouteDeclaration[]) => flattenPluginRoutes("@acme/catalog", definePluginRoutes(routes)); -/** - * A declaration with a field the types do not allow. - * - * Which is the only way to test the diagnostics that exist for a plugin written - * in JavaScript: `search` on a layout and `area` on a nested route are both - * compile errors, and both still have to fail loudly rather than be dropped. - */ const withExtra = <TOptions>( options: TOptions, extra: Record<string, unknown>, diff --git a/packages/vitnode/src/routing/tree.ts b/packages/vitnode/src/routing/tree.ts index 9d163cfcb..512799f3d 100644 --- a/packages/vitnode/src/routing/tree.ts +++ b/packages/vitnode/src/routing/tree.ts @@ -29,21 +29,6 @@ export const isPluginRouteLazyComponent = ( return candidate[LAZY_BRAND] === true && typeof candidate.load === "function"; }; -/** - * The `search` type a route's own page module says it reads. - * - * The one place the eager half of a route and its lazy module are checked - * against each other, and it works for the reason `lazy()` is worth having: - * `import("./pages/products-page")` is a *type* TypeScript resolves statically - * even though the import itself is deferred. So a page declaring - * `PluginRoutePageProps<Product, ProductsSearch>` constrains the schema its - * route declares, without either file importing the other's values. - * - * `Record<string, unknown>` when the module says nothing about a search - a page - * with no props, or one that never named the type - because an unconstrained - * schema is the honest answer there rather than a guess. The page still gets - * whatever the schema returned; nothing is checked twice at runtime. - */ type ModuleSearchOf<TModule> = TModule extends { default: (props: infer TProps) => unknown; } @@ -57,20 +42,6 @@ export type PluginRouteSearchSchema<TModule> = ( ) => ModuleSearchOf<TModule>; interface PluginRouteDeclarationShared<TModule> { - /** - * The module this route renders, named by `lazy(() => import(...))`. - * - * The union's second member is a string, and it is the diagnostic rather than - * a shape: TypeScript prints the type it expected, so writing the fix *as* - * that type is what turns `component: ProductPage` from - * - * Type '() => Element' is not assignable to type - * 'PluginRouteLazyComponent<unknown>' - * - * into an error that says what to do instead. Inlined rather than aliased for - * the same reason: an alias would be printed by name, and the message would go - * back to naming a shape. - */ component: | '`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>; @@ -78,13 +49,6 @@ interface PluginRouteDeclarationShared<TModule> { requires?: PluginRouteRequirement; } -/** - * What a `component` may not be - the message above, as a type. - * - * Derived from the field rather than declared beside it, so there is one copy of - * the sentence and a reader who searches for it lands on the union that produces - * it. - */ export type PluginRouteEagerComponentRejected = Exclude< PluginRouteDeclarationShared<unknown>["component"], object @@ -129,15 +93,6 @@ export interface PluginRouteDeclaration { export type PluginRoutes = readonly PluginRouteDeclaration[]; -/** - * One plugin's `routes.ts`, as a host holds it. - * - * The shape of every entry in a generated `src/plugin-routes.gen.ts`: the plugin - * id, and the route tree that plugin's own module exported. `routes` is typed - * here rather than left `unknown` so a plugin whose `routes` export is not a - * `definePluginRoutes` tree is a compile error in the generated file, naming the - * plugin, instead of a runtime diagnostic in a browser. - */ export interface PluginRouteDeclarationSource { pluginId: string; routes: PluginRoutes; @@ -150,17 +105,6 @@ export const isPluginRouteDeclaration = ( value !== null && (value as Partial<PluginRouteDeclaration>)[DECLARATION_BRAND] === true; -/** - * Every field a declaration may carry, whichever helper made it. - * - * The three helpers accept narrower types - a layout has `children` and no - * `search`, an index route has neither a `path` nor an `area` - and all three - * pass whatever they were given through this. That is deliberate: a plugin - * written in JavaScript has no types to stop it declaring `search` on a layout, - * and a field silently dropped here would be a page whose query string is - * quietly never validated. Carried, it reaches `flattenPluginRoutes` and becomes - * a diagnostic naming the route. - */ interface PluginRouteDeclarationOptions { area?: PluginRouteArea; children?: readonly PluginRouteDeclaration[]; diff --git a/packages/vitnode/src/routing/types.ts b/packages/vitnode/src/routing/types.ts index e9f5af597..2abfa81ff 100644 --- a/packages/vitnode/src/routing/types.ts +++ b/packages/vitnode/src/routing/types.ts @@ -1,114 +1,12 @@ -/** - * Which of an application's shells a plugin route renders inside. - * - * Two members, and each was earned by a stage rather than inherited from the - * legacy route-copying pipeline, which knew three destinations and wrote a - * plugin's pages into a Next.js app's `src/app/` by directory name. `main` is - * the public site. `admin` is the AdminCP: it has its own layout, its own second - * session under its own cookie, and its own staff permissions. `blank` - a page - * rendered without the site chrome - is deliberately absent, because it was used - * once by a core test page and by no plugin, and a member here has to be a shell - * some route actually renders in. - * - * ## An area chooses a parent. It never rewrites a path - * - * This is the invariant the whole layer rests on, and adding a second member is - * what makes it worth stating: a route in the `admin` area declares - * `path: "/admin/reports"` **in full**. Nothing prefixes it, and nothing infers - * `/admin` from the area. The area says which shell the page is framed by - and - * in a router a shell is a parent route, so honouring it means choosing a parent - * (`@vitnode/core/tanstack/plugin-routes`'s `mountUnder`) and nothing else. - * - * A hidden prefix would buy one saved word per declaration and cost the property - * that makes this manifest reviewable: that the URL a plugin claims is legible - * in the diff that claims it, and that two routes colliding look like it. - * - * ## An area is not part of a URL, so it is not part of a collision either - * - * `main /foo` and `admin /foo` are **one URL claimed twice**, and the manifest - * refuses them. The shells are pathless routes - `_main` and `_admin` contribute - * no segment - so mounting two routes under different frames does not put them - * in different pathname spaces: a browser asking for `/foo` would reach whichever - * one the router happened to rank first. Every collision check in this layer - * therefore keys on the match key alone, and an admin route's `/admin` comes - * from its own `path`. - * - * Keeping the list *here* rather than letting every route invent its own string - * is the point: an area is a statement about layout, and a layout is a parent. - * - * Declared by a **top-level** route only. Every route inside a layout renders in - * the shell its layout renders in, so a nested route that declared one would be - * describing something it cannot change. - */ export type PluginRouteArea = "admin" | "main"; -/** - * Every area a route may declare, in a fixed order. - * - * Sorted, and depended upon: it is what a diagnostic lists when a route names an - * area that does not exist, and what the TanStack runtime iterates when it hangs - * one subtree per area off the tree. An order that came out of declaration - * sequence would make a build's output depend on how this file was edited. - */ export const PLUGIN_ROUTE_AREAS: PluginRouteArea[] = ["admin", "main"]; -/** - * What a route *is* in the route tree, as opposed to where it is. - * - * Two members, because a plugin with more than one page has exactly one thing - * the flat prototype could not express: a frame around several of them. Core's - * own settings screens - which reach an application through the same copying - * pipeline a plugin's pages do - are a `layout.tsx` with an index page and three - * siblings under it, and rebuilding that as four independent pages means four - * copies of the frame and four chances for them to drift. - * - * - `page` claims a URL and renders it - `page()` and `index()`, which is that - * layout's page at its own URL. - * - `layout` claims **no URL of its own** - `layout()`. It renders a frame - * around its `children` and is only ever reached through one of them, which is - * why two layouts may not sit at the same path and why a layout with no - * children is rejected: it would be a route nothing can ever match. - * - * A third kind for a *pathless* group - Next's `(group)` folders - is - * deliberately absent. No plugin has ever needed one, and a layout that adds no - * segment is rejected with a message saying so rather than half-supported. - */ export type PluginRouteKind = "layout" | "page"; /** Every kind a route may declare. */ export const PLUGIN_ROUTE_KINDS: PluginRouteKind[] = ["layout", "page"]; -/** - * Who a route is offered to - a **navigation** rule, and nothing more. - * - * This is not, and may never become, the security boundary. Every private read - * is authorized by Hono on the server, from the session cookie, in the route's - * own handler; a visitor who edits their cached session in devtools gets a page - * shell and an API that still refuses them. What this decides is whether a - * browser is sent somewhere else *before* the page renders, which is the - * difference between a signed-out visitor seeing a flash of a private page and - * never receiving a byte of it. - * - * It is on the manifest rather than in the route module for one reason, and it - * is the whole argument for the split: the guard runs in `beforeLoad`, which is - * *before* the module's chunk is fetched. A requirement that lived in the module - * could only be read by downloading the page it was meant to withhold. - * - * - `authenticated` - a signed-in visitor, or a redirect to the login page - * carrying where they were going. - * - `guest` - a signed-out visitor. A signed-in one is sent to their - * destination instead, which is what `/login` and `/register` do. - * - * Permissions are deliberately absent, and stayed absent when Stage 12 brought - * the AdminCP in. This field is about the **public** session - the cookie - * `/login` sets - and the AdminCP runs on a second session under a second - * cookie, so the two are not the same question asked at different volumes. An - * `admin` route inherits its requirement from the shell it mounts under, whose - * guard reads that other session; declaring one here as well is refused rather - * than ignored, because a field that reads as enforcement and enforces nothing - * is worse than no field. Staff permissions gate a page's *content*, through the - * same components the AdminCP's own screens use. - */ export type PluginRouteRequirement = "authenticated" | "guest"; /** Every requirement a route may declare. */ @@ -117,38 +15,16 @@ export const PLUGIN_ROUTE_REQUIREMENTS: PluginRouteRequirement[] = [ "guest", ]; -/** - * Separates a plugin id from a route id. Not legal inside either half. - * - * A route's `id` is the key its component loader and its search schema are - * registered under, so one identifier addresses a route everywhere and nothing - * has to translate between layers. - */ export const PLUGIN_ROUTE_ID_SEPARATOR = ":"; /** One parsed segment of a canonical VitNode route path. */ export type PluginRouteSegment = { kind: "param"; name: string } | { kind: "static"; value: string }; -/** - * One route's `validateSearch`, as the router will call it. - * - * Total by contract. TanStack calls this during path matching, on whatever was - * in the query string, and a throw there is a router error screen rather than a - * page - so a validator normalises and clamps, it does not reject. - */ export type PluginRouteSearchValidator = ( input: Record<string, unknown>, ) => unknown; -/** - * One route in a built manifest: validated, normalised and parsed. - * - * Every optional field of a declaration is present here with its default filled - * in, so nothing downstream re-implements "and if it is missing, it means", and - * every path is the full canonical one even for a route whose author wrote it - * relative to its parent. - */ export interface PluginRoute { area: PluginRouteArea; /** Globally unique, `"<pluginId>:<routeId>"`. */ @@ -156,13 +32,7 @@ export interface PluginRoute { kind: PluginRouteKind; /** Declared message namespaces, de-duplicated and sorted. Empty if none. */ messages: string[]; - /** - * The **global** id of the layout this route is nested inside, or `null`. - * - * Namespaced by plugin, exactly like {@link PluginRoute.id}, so a parent is - * looked up by the id everything else addresses a route by - and so there is - * no spelling of it that reaches another plugin's layout. - */ + parentId: null | string; /** Canonical path, normalised (no trailing slash). */ path: string; @@ -178,24 +48,8 @@ export interface PluginRoute { segments: PluginRouteSegment[]; } -/** - * Every plugin route in an application, deterministically ordered. - * - * A plain array rather than a wrapper object: it is a list of routes, and the - * hierarchy it may describe is derived from it by `buildPluginRouteGraph` - by - * the same function at build time and at runtime, so the two cannot disagree - * about what the tree is. - */ export type PluginRouteManifest = PluginRoute[]; -/** - * One plugin, and the route tree it declares. - * - * `routes` is `unknown` rather than `PluginRoutes` because a plugin is compiled - * JavaScript by the time a host reads it: the tree is validated from `unknown` - * by `flattenPluginRoutes`, which is what turns a plugin built against an older - * VitNode into a diagnostic rather than a crash. - */ export interface PluginRouteSource { pluginId: string; routes?: unknown; diff --git a/packages/vitnode/src/tanstack/admin/actions.ts b/packages/vitnode/src/tanstack/admin/actions.ts index 2dad3be00..ad2ea8104 100644 --- a/packages/vitnode/src/tanstack/admin/actions.ts +++ b/packages/vitnode/src/tanstack/admin/actions.ts @@ -8,83 +8,6 @@ import { signInFormResult } from "../auth/screens"; import { authTransport } from "../auth/transport"; import { removeAdminIdentityQueries } from "./queries"; -/** - * Signing in to the AdminCP. - * - * form -> authTransport().signIn({ ..., isAdmin: true }) -> Hono - * | | - * +-> drop everything privileged <- Set-Cookie - * | - * +-> navigate - * - * The mutation, the schema, the status mapping and the cookie copying are - * `tanstack/auth`'s, unchanged and unduplicated: this is the same `signIn` the - * public login page calls, with `isAdmin: true`, which is the same flag the - * legacy server action passed through. There is no admin sign-in endpoint and no - * second auth transport. - * - * What `isAdmin` changes on the API is worth being exact about, because it is - * the reason this is a separate action rather than a parameter on the public - * one: - * - * isAdmin: false -> SessionModel.createSessionByUserId public cookie - * isAdmin: true -> SessionAdminModel.createSessionByUserId admin cookie - * - * They are exclusive branches in `sign-in.route.ts`. An admin sign-in therefore - * mints *only* `vitnode_auth_admin` and leaves the public session exactly as it - * was - which is why nothing here refreshes it. Invalidating the public session - * would cost a round trip to learn that an answer this request could not have - * changed has not changed. - * - * `SessionAdminModel.createSessionByUserId` also re-checks - * `checkIfUserIsAdmin` and throws `403` for somebody who is not one, so a - * visitor with valid credentials but no admin permission gets the same - * `access_denied` alert as a wrong password. That is the API's decision and this - * layer neither adds to it nor softens it. - */ - -/** - * The AdminCP sign-in, in the shape `SignInFormContent` submits. - * - * `undefined` on success, which is the shared form's way of saying "the caller - * is navigating" - and it is, on the line below. On failure the form gets the - * legacy vocabulary back through `signInFormResult` and renders the - * access-denied alert or the internal-error toast itself, exactly as the public - * card does. - * - * ## The cache is dropped before anything navigates - * - * `removeAdminIdentityQueries` rather than an invalidation, and before the - * navigation rather than after it. Three halves, and the third is the one an - * earlier draft of this action missed: - * - * - **Removal, not invalidation.** The browser may already hold a *different* - * administrator's answer - Admin A signed out in this tab and Admin B is - * signing in. Invalidating keeps A's permission set in the cache and marks it - * stale, so the shell would render A's sidebar until a refetch returned. - * Removing it leaves nothing to render from, so the next reader must ask the - * API, which reads the cookie the browser now holds. - * - **Before the navigation.** The destination is under `_admin`, whose - * `beforeLoad` calls `ensureAdminAccess`. Dropping the entry first is what - * makes that guard perform a real read instead of deciding on whatever was - * there - and it is also why there is no explicit refetch here. One read, in - * the guard, is the whole point of there being one query definition. - * - **The screens, not only the session.** A sign-in is an identity boundary, - * and A's session expiring or being revoked leaves no sign-out behind to have - * cleaned up after it. So every privileged AdminCP entry goes, not just the - * permission set: the palette's user lookups and every screen under - * `["vitnode","admin"]` - the file table, the cron list, the dashboard layout, - * which is administrator-specific and not keyed by identity. See - * `./queries`, which owns that list. - * - * `navigate` is the caller's, because a `?returnTo=` names a path and only the - * host knows what its router wants to be handed. There is no list of admin - * routes here or anywhere else. - * - * `destination` is a thunk rather than a value for the same reason it is on the - * login page: `?returnTo=` can change under the form, and reading it at submit - * time rather than at mount time is what keeps the two in step. - */ export const useAdminSignInAction = ({ destination, navigate, diff --git a/packages/vitnode/src/tanstack/admin/admin-locale.test.ts b/packages/vitnode/src/tanstack/admin/admin-locale.test.ts index b44165e06..275b3e572 100644 --- a/packages/vitnode/src/tanstack/admin/admin-locale.test.ts +++ b/packages/vitnode/src/tanstack/admin/admin-locale.test.ts @@ -14,21 +14,6 @@ import { ADMIN_ENTRY_PATH, ADMIN_HOME_PATH } from "./state"; const here = dirname(fileURLToPath(import.meta.url)); -/** - * The AdminCP's URLs carry no language, in any language - and nothing in this - * feature can start writing one. - * - * There is deliberately no admin locale implementation to test. `/admin` is in - * `DEFAULT_IGNORED_LOCALE_PATHS`, so the existing Stage 3 routing answers every - * question: the rewrite neither strips nor writes a prefix here, - * `handleLocaleRequest` 308s `/pl/admin/...` to `/admin/...` while attaching the - * locale cookie to the redirect, and `resolveLocale` on an ignored path falls - * through to that cookie. What is pinned below is that the arrangement still - * holds and that this feature's own constants stay outside the localized URL - * space - because the way this breaks is somebody adding a prefix by hand, not - * the routing changing its mind. - */ - const routing = createLocaleRouting({ defaultLocale: "en", locales: ["en", "pl"], @@ -79,11 +64,6 @@ describe("no helper in this feature can produce a /pl/admin URL", () => { ).toBe(path); }); - /** - * The other direction, and the one a stale bookmark exercises: a prefixed - * admin URL is not a page, it is a redirect. `handleLocaleRequest` performs - * it; what matters here is that the prefix is not part of the route. - */ it("strips a prefix somebody typed rather than serving it", () => { expect( routing.deLocalizeUrl(new URL("/pl/admin/core", "https://x.invalid")) @@ -116,11 +96,6 @@ describe("no helper in this feature can produce a /pl/admin URL", () => { }); describe("what the AdminCP loads strings for", () => { - /** - * The shell warms two namespaces, not the AdminCP's whole message tree. The - * merged record carries every plugin's admin copy, and a sidebar does not need - * the users table's vocabulary to render. - */ it("warms only the shell's own namespaces", () => { expect(ADMIN_SHELL_NAMESPACES).toEqual(["core.global", "admin.global"]); }); @@ -131,11 +106,6 @@ describe("what the AdminCP loads strings for", () => { } }); - /** - * The sign-in screen renders no shell - no sidebar, no search, no user bar - - * so warming `admin.global` there would ship an administrator's whole - * navigation vocabulary to a page that has none. - */ it("does not warm the shell's namespace on the sign-in screen", () => { expect(ADMIN_SIGN_IN_NAMESPACES).toEqual([ "core.global", @@ -154,26 +124,6 @@ describe("what the AdminCP loads strings for", () => { }); }); -/** - * Warming the strings is only half of it - somebody has to *provide* them. - * - * This is the half that was missing, and the way it failed is why it is pinned - * here rather than left to a rendering test. `_admin`'s loader called - * `loadAdminMessages`, so `admin.global` sat in the query cache exactly as - * intended; nothing mounted a provider for it, and `useTranslations` in - * `AdminNavProvider` answered every lookup with the key. The AdminCP rendered, - * navigated and passed every existing test with a sidebar reading - * "admin.global.nav.core" over "admin.global.nav.dashboard" in every language. - * - * A page's own `RouteMessages` cannot stand in for it: the pages mount below - * `{children}` and the chrome is above them, which is exactly why this belongs - * to the shell. - * - * Read off the source rather than rendered, because what is being asserted is - * that a provider exists at all - a question about the component tree, not about - * markup - and `AdminShellContent` needs a router, a sidebar and a filled - * QueryClient before it will render a single string. - */ describe("the shell provides the strings its chrome renders", () => { const shell = readFileSync(join(here, "shell.tsx"), "utf8"); diff --git a/packages/vitnode/src/tanstack/admin/breadcrumb.tsx b/packages/vitnode/src/tanstack/admin/breadcrumb.tsx index e870d7a1b..67cbcaf79 100644 --- a/packages/vitnode/src/tanstack/admin/breadcrumb.tsx +++ b/packages/vitnode/src/tanstack/admin/breadcrumb.tsx @@ -15,45 +15,6 @@ import { useRouteNavigationPending } from "../pending/navigation-pending"; import { BreadcrumbPendingSkeleton } from "../pending/shapes"; import { useAdminNav } from "./nav"; -/** - * The AdminCP trail, contributed by the routes that own it. - * - * The same rule the public site follows and deliberately not re-implemented: a - * route puts a crumb on `staticData.breadcrumb` and the shell renders every - * matched route's, parent to child. Importing `../breadcrumb/model` is also what - * loads the `StaticDataRouteOption` augmentation, so an admin route writes - * `staticData: { breadcrumb: … }` with no import of its own. - * - * Most AdminCP screens are one route several segments deep - `/admin/core/users` - * is a single route - and their crumbs are named by the *navigation* rather than - * by the route tree, so they contribute a `breadcrumbGroup` of their own items. - * That is what {@link AdminBreadcrumb} is, and it is the only place in VitNode - * that needs the group shape. - * - * What is *not* here, in either half: a map from pathname to trail. The labels - * come from the navigation this administrator can actually see. Nothing is a - * registry, and nothing registers into one. - */ - -/** - * The AdminCP header's trail, or `null` when no matched route declared one. - * - * Exposed separately from the shell so it can tell an *absent* trail from an - * empty one - it renders the header's separator only when there is something to - * separate. - * - * ## Why it holds a shape rather than the destination's trail - * - * A crumb may read the data its route is still fetching - a user's name, a - * translated label whose messages are in flight - so the destination's trail - * cannot simply be drawn early. The area holds a shape for as long as the content - * below it does, on the router's own `defaultPendingMs`, and the two change - * together. - * - * Only when there is a trail to replace. A shell whose current page declares no - * crumb shows nothing, and inventing a skeleton there would put a separator and - * two bars into a header that has neither before the navigation nor after it. - */ export const useAdminBreadcrumb = (): React.ReactNode => { const entries = breadcrumbTrail(useMatches()); const isNavigating = useRouteNavigationPending( @@ -90,26 +51,13 @@ export const AdminBreadcrumb = ({ <BreadcrumbAdminContent labels={labels} LinkComponent={LinkComponent} - /** - * Labels come from the navigation this admin can actually see, which is the - * same list the sidebar renders - so the trail and the menu name a screen - * identically, in every language, and a screen they may not open cannot be - * named at all. - */ + nav={useAdminNav()} overrideLastLabel={overrideLastLabel} segments={segments} /> ); -/** - * An AdminCP screen's crumbs, as the one contribution its route declares. - * - * A group rather than a label, because an AdminCP route is usually several - * segments deep on its own - `/admin/core/users` is one route - and the trail is - * named by the navigation rather than by the route tree. The items go straight - * into the shell's list; see `breadcrumbGroup`. - */ export const adminBreadcrumb = ( props: Parameters<typeof AdminBreadcrumb>[0], ): RouteBreadcrumbGroup => diff --git a/packages/vitnode/src/tanstack/admin/content/breadcrumb-model.test.ts b/packages/vitnode/src/tanstack/admin/content/breadcrumb-model.test.ts index ba3b4d11d..6a7f7fed4 100644 --- a/packages/vitnode/src/tanstack/admin/content/breadcrumb-model.test.ts +++ b/packages/vitnode/src/tanstack/admin/content/breadcrumb-model.test.ts @@ -17,26 +17,6 @@ import { contentBreadcrumbModel, } from "./breadcrumb-model"; -/** - * The trail above a content screen, including the one above a screen that does - * not exist. - * - * A pure test, in a node environment, with no React and no DOM. What is being - * checked is a decision - which segments, which labelled href, or nothing at - * all - and that decision is a function now, so rendering it to assert on empty - * markup would only add a jsdom to the question. - * - * ## The regression this exists for - * - * The shell renders a route's `staticData.breadcrumb` for every **matched** - * route, and a match whose loader answered `notFound()` is still a match. The - * host spreads `Route.useLoaderData()` into the breadcrumb, so on that path it - * is handed nothing at all - and reading a label off nothing threw during the - * server render, replacing the AdminCP's 404 with a blank page for exactly the - * URLs a 404 is for: an unresolvable content path, a record that was deleted, a - * content type this administrator may not open. - */ - const labels: ContentRouteLabels = { desc: "Everything published", plural: "Articles", @@ -44,13 +24,6 @@ const labels: ContentRouteLabels = { title: "Articles", }; -/** - * The trail's segments, with the empty model spelled as the empty list. - * - * A narrowing helper rather than a cast: `kind: "none"` genuinely has no - * segments, and reading them off the union without saying what happens then is - * the same omission the component used to make. - */ const segmentsOf = (model: ContentBreadcrumbModel): string[] => model.kind === "none" ? [] : model.segments; @@ -71,12 +44,6 @@ describe("a loader that did not resolve", () => { expect(contentBreadcrumbModel({})).toEqual(CONTENT_BREADCRUMB_NONE); }); - /** - * The specific shape the crash came in. With `action` absent the component's - * ternary fell through to the *form* branch, which is the branch that reads a - * label - so an absent action has to be "not resolved" rather than "not a - * list". - */ it("has no trail when the action is missing", () => { expect(contentBreadcrumbModel(route({ action: undefined }))).toEqual( CONTENT_BREADCRUMB_NONE, @@ -143,11 +110,6 @@ describe("a create URL", () => { }); }); - /** - * The trail passes *through* the list, which the navigation can label - but - * only if it is told which href that is, because the last segment is a page - * the navigation does not know about. - */ it("names the list href it has to label", () => { expect(model).toMatchObject({ listHref: contentBreadcrumbListHref("blog/articles"), @@ -168,11 +130,6 @@ describe("an edit URL", () => { }); }); - /** - * The record id is deliberately not a crumb. `/admin/content/blog/articles/7/edit` - * trails as `Content / Articles / Edit article` - a number in the middle names - * nothing a reader could click or recognise. - */ it("does not put the record id in the trail", () => { expect( segmentsOf(contentBreadcrumbModel(route({ action: "edit", itemId: 42 }))), diff --git a/packages/vitnode/src/tanstack/admin/content/breadcrumb-model.ts b/packages/vitnode/src/tanstack/admin/content/breadcrumb-model.ts index 8c80500f9..077ae1ead 100644 --- a/packages/vitnode/src/tanstack/admin/content/breadcrumb-model.ts +++ b/packages/vitnode/src/tanstack/admin/content/breadcrumb-model.ts @@ -6,33 +6,6 @@ import { CONTENT_ADMIN_EDIT_SEGMENT, } from "@/content/index"; -/** - * What trail a Content Engine URL has above it, decided without rendering - * anything. - * - * The whole of the breadcrumb's logic: which segments `AdminBreadcrumb` - * resolves, which href in the middle of them needs a label the navigation does - * not carry, and whether there is a trail at all. `./breadcrumb.tsx` is then a - * renderer over this - it translates the last label and mounts the namespaces, - * and makes no decisions of its own. - * - * ## Why the decision is separate from the component - * - * The property worth protecting is a *guard*, and it is the one that broke the - * AdminCP's 404 outright. The shell renders a route's `staticData.breadcrumb` - * for every **matched** route, and a match whose loader answered `notFound()` is - * still a match - so the host spreads a `Route.useLoaderData()` that is - * `undefined`, and the component is handed no props at all. Reading a label off - * nothing threw during the server render and replaced the 404 page with a blank - * one, for exactly the URLs a 404 is for: an unresolvable content path, a record - * that was deleted, a content type this administrator may not open. - * - * As a component that could only be checked by rendering it. As a function it is - * five inputs and an answer, which is what {@link ContentBreadcrumbModel}'s - * `kind: "none"` exists to make explicit rather than implicit in a `return - * null`. - */ - /** `/admin/content/blog/articles` - the list URL the trail passes through. */ export const contentBreadcrumbListHref = (adminPath: string): string => `/admin/content/${adminPath}`; diff --git a/packages/vitnode/src/tanstack/admin/content/breadcrumb.tsx b/packages/vitnode/src/tanstack/admin/content/breadcrumb.tsx index f3f3437cc..1a9ba0be4 100644 --- a/packages/vitnode/src/tanstack/admin/content/breadcrumb.tsx +++ b/packages/vitnode/src/tanstack/admin/content/breadcrumb.tsx @@ -11,56 +11,6 @@ import { RouteMessages } from "../../i18n/route-messages"; import { AdminBreadcrumb } from "../breadcrumb"; import { contentBreadcrumbModel } from "./breadcrumb-model"; -/** - * The trail above a Content Engine screen. - * - * The Stage 12 AdminCP breadcrumb, unchanged: a route puts an element on - * `staticData.breadcrumb` and the shell renders the deepest one. Nothing here - * recreates the Next.js `@breadcrumb` parallel-route slot - it produces the same - * trail from the same labels, which is the point. - * - * ## It mounts `RouteMessages` of its own - * - * The shell renders the breadcrumb *above* the route's component, so it is - * outside the provider that component mounts. Without this the create and edit - * crumbs would translate against the root's `core.global` alone and render raw - * keys. Same reason the staff crumbs do it, and the namespaces are the loader's - * own - the identical entry it already warmed, so nothing suspends. - * - * ## Where the labels come from - * - * The **list** crumb needs none: the sidebar already has an entry at - * `/admin/content/{admin.path}` carrying this content type's noun, and - * `AdminBreadcrumb` resolves labels from the navigation this administrator can - * see. `overrideLastLabel` is passed anyway because the screen's heading may - * legitimately differ from the sidebar's shorter name. - * - * The **form** crumbs need two: the parent, because the trail passes through the - * list URL and the last segment (`create` / `edit`) is not a page the navigation - * knows about at all. - * - * ## The route data is optional, and that is not defensiveness - * - * The shell renders this from `staticData` for every *matched* route, and a - * match whose loader threw is still a match. So the host's - * `Route.useLoaderData()` is `undefined` whenever the loader answered - * `notFound()` - an unresolvable content path, a record that is gone, a - * permission this administrator does not hold - and spreading `undefined` - * hands this component no props at all. - * - * Typing them as required was a lie the crash made obvious: with `action` - * undefined the ternary below fell to the form crumb, which read `labels.title` - * off nothing and took down the whole server render - so the AdminCP's 404 was - * replaced by an empty page, for exactly the URLs a 404 is *for*. The Stage 12 - * crumbs never hit it because they read the cache and the params instead - * (`AdminUserBreadcrumbContent`'s `data?.name`), which are absent rather than - * undefined-shaped. - * - * `null` is the right trail for a screen that does not exist: the shell's - * not-found is a message, not a place, and naming a path that resolved to - * nothing would be inventing one. - */ - export interface ContentAdminBreadcrumbProps extends Partial< Pick<ContentAdminRouteData, "action" | "adminPath" | "labels" | "namespaces"> > { @@ -93,13 +43,6 @@ export const ContentAdminBreadcrumbContent = ({ ); }; -/** - * The create and edit trails, which are the only ones that translate anything. - * - * Its own component because `useTranslations` is a hook and the list branch has - * no use for one. Everything it does not translate - the segments, the list href - * it labels, the nouns - is {@link contentBreadcrumbModel}'s and arrives decided. - */ const ContentFormCrumb = ({ LinkComponent, model, diff --git a/packages/vitnode/src/tanstack/admin/content/create-action.tsx b/packages/vitnode/src/tanstack/admin/content/create-action.tsx index 4e4b54d01..9519a9a8b 100644 --- a/packages/vitnode/src/tanstack/admin/content/create-action.tsx +++ b/packages/vitnode/src/tanstack/admin/content/create-action.tsx @@ -14,20 +14,6 @@ import { useAdminPermission } from "../permissions"; import { ContentFormDialogSlot } from "./slot-render"; import { contentAdminSlots } from "./slots"; -/** - * The list heading's create button - a link, a dialog, or nothing. - * - * `definition.admin.create.mode` decides which, exactly as it does in the - * Next.js list. `page` links to the canonical create URL, which the same splat - * route already serves; `dialog` opens the form in place, through the registered - * form slot. Nothing here invents a third mode, and nothing puts a dialog in the - * query string: a create form is unsaved local state, and a URL that reopened - * one would be a link that cannot mean the same thing twice. - * - * `can_create` hides it. That is a rendering decision - the generated `POST` - * route declares the same permission and re-checks it against the staff tables - * on the request itself. - */ export const ContentCreateAction = ({ entry, LinkComponent = RouterLink, diff --git a/packages/vitnode/src/tanstack/admin/content/editorial/host.tsx b/packages/vitnode/src/tanstack/admin/content/editorial/host.tsx index 88f7a03e4..10099bb94 100644 --- a/packages/vitnode/src/tanstack/admin/content/editorial/host.tsx +++ b/packages/vitnode/src/tanstack/admin/content/editorial/host.tsx @@ -13,31 +13,6 @@ import { ContentFormNavigationProvider } from "@/views/admin/views/content/form/ import { RouterLink } from "../../../layout/router-link"; import { contentEditorialTransport } from "./transport"; -/** - * The two seams an editorial panel reads, wired to TanStack Start. - * - * Mounted by each panel rather than by the list, and that is deliberate: the - * list screen has no form and no panels of its own, and a host that renders only - * a table should pay for neither provider. It is the same choice `ContentFormHost` - * makes one screen over - the Next.js AdminCP mounts all three above every - * content screen at once, because there a Server Component assembles the page - * and a provider cannot be created lower down. - * - * Both providers are needed and neither is redundant: - * - * ContentEditorialTransportProvider the requests, and what each write expires - * ContentFormNavigationProvider the link on a revision's author, and - * `refresh` after a restore - * - * ## `refresh` is a router invalidation, not a navigation - * - * Asking this router to navigate to the href it is already on does nothing at - * all, so the intent is spelled `router.invalidate()` - re-run the route's loader - * and re-render it. The **data** is not that call's job: the transport has - * already expired the record, the list and everything under them, against the - * same keys the loader warms. What is left is everything outside React Query - - * the heading a restore renames, the breadcrumb behind the dialog. - */ export const ContentEditorialHost = ({ children, LinkComponent = RouterLink, diff --git a/packages/vitnode/src/tanstack/admin/content/editorial/index.ts b/packages/vitnode/src/tanstack/admin/content/editorial/index.ts index 49105d7e7..73a81b2b7 100644 --- a/packages/vitnode/src/tanstack/admin/content/editorial/index.ts +++ b/packages/vitnode/src/tanstack/admin/content/editorial/index.ts @@ -1,23 +1,6 @@ import { setContentAdminSlots } from "../slots"; import { contentEditorialRowPanels } from "./panels"; -/** - * The Content Engine's editorial panels, registered for a TanStack Start host. - * - * ./panels the four slot components, each wrapped in its own host - * ./host the transport and navigation providers a panel reads - * ./transport the requests, and what each write expires - * - * Importing this module *is* the registration, exactly as importing `../form` - * registers the form dialog. `../screen.tsx` imports both, and it is the one - * module every content URL goes through - so a host route file stays topology - * and never has to know these features exist. - * - * An unregistered panel is a supported state and a visible one: the row menu - * intersects `row-actions-model`'s answer with `registeredContentRowPanels`, so - * an application that does not import this gets a working list with publish, - * edit and delete and no dead menu entries. - */ setContentAdminSlots({ rowPanels: contentEditorialRowPanels }); export { ContentEditorialHost } from "./host"; diff --git a/packages/vitnode/src/tanstack/admin/content/editorial/panels.tsx b/packages/vitnode/src/tanstack/admin/content/editorial/panels.tsx index 9a6285233..e98127aa7 100644 --- a/packages/vitnode/src/tanstack/admin/content/editorial/panels.tsx +++ b/packages/vitnode/src/tanstack/admin/content/editorial/panels.tsx @@ -13,40 +13,6 @@ import type { ContentRowPanelProps } from "../slots"; import { useContentTypeForm } from "../form/spec"; import { ContentEditorialHost } from "./host"; -/** - * The four editorial panels, as the row menu's slots. - * - * Each is the *same component* the Next.js AdminCP renders - the dialog, the - * timeline, the diff, the schedule form, the URL history - wrapped in the two - * providers that tell it how to reach the API and how to refresh the screen. - * Nothing about a panel was rewritten for this host, which is the point: a - * restore behaves identically in both AdminCPs because it *is* the same code. - * - * ## Why each panel mounts its own host - * - * A panel is a dialog: one is open at a time, and it is mounted beside the menu - * rather than inside it (a menu item unmounts the moment it is clicked, and a - * dialog rendered inside one would go with it). Mounting the providers here - * rather than around the list keeps the whole editorial feature out of the list - * screen, so a content type with none of these enabled loads none of it. - * - * ## Which are offered is not decided here - * - * `row-actions-model.ts` decides, from the content type's features and the - * administrator's permissions, and `registeredContentRowPanels` intersects that - * with what this module registers. A content type with `delivery: { enabled: - * false }` has no `Delivery` entry in its menu at all - absent, rather than an - * entry that opens a panel with nothing in it. - */ - -/** - * The revision timeline. - * - * `spec` is what the diff renders field labels and enum values from. The Next.js - * AdminCP builds it in a Server Component and passes it down; there is no server - * component here, so it is built in the browser from the same definition and the - * same warmed messages - see `../form/spec.ts`. - */ const HistoryPanelBody = ({ currentVersion, entry, @@ -78,18 +44,6 @@ const HistoryPanelBody = ({ ); }; -/** - * The boundary the spec read suspends against, if it ever does. - * - * `useContentTypeForm` reads the AdminCP's messages out of the query entry the - * route's loader already warmed with the same namespaces, so on a content screen - * it resolves without suspending. This is what keeps that from being load-bearing: - * a panel is opened from a row *inside the table*, and a suspension with no - * boundary of its own would blank the table rather than the dialog. - * - * `fallback={null}` because the fallback is a dialog that has not opened yet - - * there is nothing to show a placeholder in. - */ const HistoryPanel = (props: ContentRowPanelProps) => ( <React.Suspense fallback={null}> <HistoryPanelBody {...props} /> @@ -138,16 +92,6 @@ const PreviewPanel = ({ </ContentEditorialHost> ); -/** - * The record's canonical address and the ones it used to answer to. - * - * `locale` is passed through `contentDeliveryRequestLocale`, which drops it for - * a content type with no translations. The row menu hands over the language the - * *list* is being read in, and for a localized content type that is also the - * translation whose address should be described - but the two are different - * concepts, and a content type with one address per record must not be asked - * about a language at all. - */ const DeliveryPanel = ({ entry, finalFocus, diff --git a/packages/vitnode/src/tanstack/admin/content/editorial/transport.ts b/packages/vitnode/src/tanstack/admin/content/editorial/transport.ts index 5475ebd5f..b9227ba0b 100644 --- a/packages/vitnode/src/tanstack/admin/content/editorial/transport.ts +++ b/packages/vitnode/src/tanstack/admin/content/editorial/transport.ts @@ -18,33 +18,6 @@ import { invalidateContentSchedules } from "@/views/admin/views/content/lib/inva import { contentApiTarget, invalidateContentAfterWrite } from "../query"; -/** - * The editorial panels' transport, for a TanStack Start host. - * - * The counterpart of `views/admin/views/content/actions/editorial-host-next.tsx`, - * and it adds exactly two things to the framework-neutral calls in - * `actions/editorial-api.ts`: - * - * 1. **Which module a content type id addresses**, through the registry the - * application registered. - * 2. **What each write owes the cache**, as a query invalidation. - * - * Nothing here is isomorphic and nothing should be. Every one of these calls - * happens because somebody opened a dialog or pressed a button in it, which only - * ever happens in a browser - so a server branch would be code that cannot run, - * and `createIsomorphicFn` prefers the server branch during SSR, which would - * then be the one an accidental server render silently used. - */ - -/** - * Which generated module serves one content type, by id. - * - * An unknown id throws with the id in the message, exactly as the form's - * transport and `mutation-api.server.ts`'s `resolve` do. It means the running - * application has a panel open for a content type its registry does not hold, - * which is a build or configuration fault rather than something to paper over - * with an empty result. - */ const targetFor = (contentTypeId: string): ContentApiTarget => { const entry = contentFrontendRegistry().byId(contentTypeId); @@ -53,29 +26,6 @@ const targetFor = (contentTypeId: string): ContentApiTarget => { return contentApiTarget(entry.definition, entry.pluginId); }; -/** - * Builds the transport a panel reads, bound to one query client. - * - * Per render rather than per module, because the invalidation half closes over - * the `QueryClient` - which is per request on a server rendering many visitors - * at once. `./host.tsx` memoises it against that client, so the identity is - * stable for the life of the screen. - * - * ## What each scope expires, and why they differ - * - * A **restore** rewrites the record: its fields, its version, possibly its slug. - * So the list is stale (the row's title and status changed), the record is stale, - * and everything under the record - its translations, its timeline, its delivery - * addresses - is stale with it. `invalidateContentAfterWrite` is the same call - * the form's saves make, which is right: a restore *is* a save, performed from a - * snapshot. - * - * A **schedule** booked or cancelled changes nothing about the record yet: the - * transition happens later, over the revalidation bridge, and the list renders - * no schedule state at all. So only the schedule list is expired - invalidating - * the record would refetch a revision timeline nobody asked to reload and re-read - * a row that has not moved. - */ export const contentEditorialTransport = ( queryClient: QueryClient, ): ContentEditorialTransport => ({ diff --git a/packages/vitnode/src/tanstack/admin/content/form/dialog.tsx b/packages/vitnode/src/tanstack/admin/content/form/dialog.tsx index 66aa59192..8bb93fe5d 100644 --- a/packages/vitnode/src/tanstack/admin/content/form/dialog.tsx +++ b/packages/vitnode/src/tanstack/admin/content/form/dialog.tsx @@ -9,25 +9,6 @@ import type { ContentFormDialogProps } from "../slots"; import { ContentFormHost } from "./host"; -/** - * The create and edit forms, in a dialog, for a TanStack Start list. - * - * What `./index.ts` registers as the list's `FormDialog` slot. The list owns the - * trigger - a labelled button on the heading, a tooltipped pencil on a row - and - * hands it here as `children`; everything below that is the form's. - * - * The dialog chrome itself is `ContentFormDialog`, shared with the Next.js - * AdminCP, so the heading, the description, the suspense fallback and the way - * the trigger is wired are one implementation rather than two that have to stay - * identical. - * - * ## Nothing is built until it opens - * - * `ContentFormHost` is *inside* the `form` element rather than around the - * dialog, and the element itself is not rendered until `DialogContent` mounts. - * So a list of fifty rows creates no transports, no navigations and no form - * specs - it creates fifty React elements, which is what a `<div>` costs. - */ const ContentDialogForm = React.lazy(async () => import("./form-body").then(module => ({ default: module.ContentDialogForm, diff --git a/packages/vitnode/src/tanstack/admin/content/form/form-body.tsx b/packages/vitnode/src/tanstack/admin/content/form/form-body.tsx index 0c426ea15..6357cab57 100644 --- a/packages/vitnode/src/tanstack/admin/content/form/form-body.tsx +++ b/packages/vitnode/src/tanstack/admin/content/form/form-body.tsx @@ -7,34 +7,6 @@ import { ContentForm } from "@/views/admin/views/content/actions/content-form"; import { useContentTypeForm } from "./spec"; -/** - * A dialog-mode content form, once the dialog has actually been opened. - * - * Behind `React.lazy` and inside `DialogContent`, which is what makes the two - * expensive things here free until somebody clicks: the AutoForm stack with - * every field component it can render, and the spec build - one per content type - * per language, which a list of fifty rows would otherwise do fifty times for - * dialogs nobody opened. - * - * ## No custom layout, and that is the existing behaviour - * - * `forms.layout` is resolved for the **page** screens only - in `./page-body`, - * which is the other half of this split - exactly as the Next.js AdminCP does: `ContentTableView` passes no layout to - * `EditContentAction` and the list heading passes none to `CreateContentAction`. - * A plugin layout is a full editor screen - `@vitnode/blog`'s article form is a - * two-column arrangement with its own sidebar - and rendering one inside a - * dialog was never what it was written for. A content type that wants its layout - * declares `admin.edit.mode: "page"`, which is the switch that decides this. - * - * ## The row is handed straight to the form - * - * Including its missing collection fields. A list row deliberately does not - * carry a repeatable, a to-many reference or a gallery - those live on tables of - * their own and would cost queries per page for values no column renders - and - * `ContentForm` detects that and reloads the record's detail before it opens. - * See `missingCollections` there: a form that opened on empty sets would save - * them that way. - */ export const ContentDialogForm = ({ entry, row, diff --git a/packages/vitnode/src/tanstack/admin/content/form/host.tsx b/packages/vitnode/src/tanstack/admin/content/form/host.tsx index 79c7fda4f..b77d3260d 100644 --- a/packages/vitnode/src/tanstack/admin/content/form/host.tsx +++ b/packages/vitnode/src/tanstack/admin/content/form/host.tsx @@ -13,29 +13,6 @@ import { ContentFormTransportProvider } from "@/views/admin/views/content/form/t import { RouterLink } from "../../../layout/router-link"; import { contentFormTransport } from "./transport"; -/** - * The Content Engine's two seams, wired to TanStack Start. - * - * The counterpart of `form/host-next.tsx`. Mounted by each of the three things - * that can contain a form - the create dialog, the edit dialog and the page-mode - * screen - rather than by the route shell, and that is deliberate: it keeps this - * whole feature out of `route.tsx`, so the list screen and the form screens stay - * independently mountable and a host that renders only a list pays for neither - * provider. - * - * ## `refresh` is a router invalidation, not a navigation - * - * The Next.js host spells `refresh` as `push(pathname)`, which is how an App - * Router page refetches its RSC payload. Asking this router to navigate to the - * href it is already on does nothing at all, so the intent is spelled with - * `router.invalidate()` - re-run this route's loader and re-render it. - * - * The **data** is not this call's job and must not be: the transport already - * invalidated the list, the record and every picker onto this content type, - * against the same query keys the loader warms. What is left for the router is - * everything outside React Query - the page title a save changes, the breadcrumb - * an edit renames - which is exactly what a loader re-run produces. - */ export const ContentFormHost = ({ children, LinkComponent = RouterLink, diff --git a/packages/vitnode/src/tanstack/admin/content/form/index.ts b/packages/vitnode/src/tanstack/admin/content/form/index.ts index a6e3126a2..7f26152f7 100644 --- a/packages/vitnode/src/tanstack/admin/content/form/index.ts +++ b/packages/vitnode/src/tanstack/admin/content/form/index.ts @@ -1,39 +1,6 @@ import { setContentAdminSlots } from "../slots"; import { ContentAdminFormDialog } from "./dialog"; -/** - * The Content Engine's create and edit forms, for a TanStack Start host. - * - * ./transport the writes, and what each one owes the query cache - * ./host the two seams the shared form reads, wired to this router - * ./spec one content type's form spec, built where the strings are - * ./dialog dialog-mode presentation - the list's `FormDialog` slot - * ./form-body what a dialog renders once it opens, behind `React.lazy` - * ./screen page-mode presentation, and the loader it needs - * ./page-body what a form *page* renders, behind `React.lazy` as well - * ./server the two SSR reads a page-mode edit form is warmed with - * - * The form itself is not here and is not this module's: `ContentForm` and - * everything under it - the AutoForm binding, the field components, the - * conflict dialog, the translation diff, the collection reload, the version - * precondition - is `views/admin/views/content/`, shared with the Next.js - * AdminCP. What this namespace supplies is the two things that code takes as - * arguments and neither host can decide for the other: how a mutation reaches - * Hono, and what happens to the screen afterwards. - * - * ## Importing this module registers the dialog - * - * The list renders a create button and a row's pencil through - * `contentAdminSlots().FormDialog`, and an unregistered slot renders nothing at - * all. Registration is therefore a *side effect of importing this module*, which - * is what makes it impossible to render the slot's consumer without it: the - * screen composition imports `./screen` from here, so anything that can show a - * content list has already run this line. - * - * The same shape `setContentFrontendRegistry` and `setAdminTransport` use, and - * for the same reason - a module-scope slot filled by the import graph rather - * than a prop threaded through a host's route file. - */ setContentAdminSlots({ FormDialog: ContentAdminFormDialog }); export { ContentAdminFormDialog } from "./dialog"; diff --git a/packages/vitnode/src/tanstack/admin/content/form/page-body.tsx b/packages/vitnode/src/tanstack/admin/content/form/page-body.tsx index 456085572..cf495c971 100644 --- a/packages/vitnode/src/tanstack/admin/content/form/page-body.tsx +++ b/packages/vitnode/src/tanstack/admin/content/form/page-body.tsx @@ -21,42 +21,12 @@ import { contentApiTarget } from "../query"; import { useContentTypeForm } from "./spec"; import { fetchContentItem, fetchContentTranslations } from "./transport"; -/** - * What a page-mode create or edit URL actually renders, once it is that URL. - * - * Behind `React.lazy` from `./screen`, and the split is the whole reason this - * file exists. Everything here reaches `ContentFormPage`, and from there - * `ContentForm` and the entire `AutoForm` stack with every field component it - * can mount - which is the largest thing the Content Engine contributes to a - * bundle after the editors a plugin brings. A content *list* renders none of it. - * - * Until this was split out, `./screen` imported it directly, so the composition - * module pulled the form stack into the content route's chunk for every - * navigation - including the list screens of dialog-mode content types, which - * never render a form page at all. The dialog's own `React.lazy` boundary in - * `./dialog` could not help: it pointed at `./form-body`, which the same - * eagerly-imported module also reached. - * - * The same arrangement `./dialog` and `./form-body` make one screen over: a thin - * eager module that decides *whether* a form is wanted, and a lazy one that is - * the form. The loader half - `loadContentFormScreen` - deliberately stays eager - * in `./screen`, because the route's `loader` calls it before anything renders. - */ - /** The layout a page-mode screen renders under, or the generated arrangement. */ export const contentPageLayout = ( entry: RegisteredFrontendContentType, mode: "create" | "edit", ) => resolveContentFormLayout(entry.registration.forms, mode); -/** - * The back link every form page carries, the noun it names, and the spec. - * - * The heading's own strings are *not* here: `core.content.create` and - * `core.content.edit` are two namespaces, and `useTranslations` takes a literal - * one - so each screen reads its own, which is also the only reading that keeps - * the key union honest. - */ const useFormPage = (entry: RegisteredFrontendContentType) => { const tPage = useTranslations("core.content.page"); const { labels, ...form } = useContentTypeForm(entry); @@ -82,14 +52,7 @@ const ContentCreateScreen = ({ return ( <ContentFormPage backHref={backHref} - /** - * Where a create lands. - * - * The record's own edit page when this content type edits in a page, and - * the list otherwise - exactly what `ContentCreatePageView` decides, from - * the same flag. `{id}` is filled in by `ContentFormPage` once the API has - * said which record was created; guessing at it would open another one. - */ + createdHrefTemplate={ entry.definition.admin.edit.mode === "page" ? contentEditHrefTemplate(entry.definition) @@ -127,15 +90,6 @@ const ContentEditScreen = ({ target: contentApiTarget(entry.definition, entry.pluginId), }; - /** - * Read back rather than passed down, and from the entries the loader warmed. - * - * `useSuspenseQuery` on an entry that is already there does not suspend, so - * the form is populated in the first paint. It is also what makes an edit that - * commits show its own result: the transport invalidates - * `contentItemQueryRoot` after a successful save, so these two refetch and the - * heading follows the title that was just typed. - */ const { data: row } = useSuspenseQuery( contentItemQueryOptions({ fetchItem: fetchContentItem, request }), ); @@ -167,14 +121,6 @@ const ContentEditScreen = ({ ); }; -/** - * The create form or the edit form, chosen from the action the route resolved. - * - * One component rather than two lazy imports, because the two screens share - * `useFormPage`, the spec build and `ContentFormPage` - splitting them would - * produce two chunks that are almost entirely the same bytes, and a create - * followed by "edit what you just made" would fetch both. - */ export const ContentFormPageBody = ({ action, entry, diff --git a/packages/vitnode/src/tanstack/admin/content/form/route.test.ts b/packages/vitnode/src/tanstack/admin/content/form/route.test.ts index 233110967..0e1c80062 100644 --- a/packages/vitnode/src/tanstack/admin/content/form/route.test.ts +++ b/packages/vitnode/src/tanstack/admin/content/form/route.test.ts @@ -14,26 +14,6 @@ import type { ContentAdminRouteData } from "../route"; import { loadContentFormScreen } from "./route"; -/** - * What a page-mode form loader does with a read it could not complete. - * - * The distinction the whole suite is about, because the two answers look the - * same from inside the loader and mean opposite things to the person waiting: - * - * /admin/content/blog/articles/999999/edit no such record 404 - * the API is down, rate-limiting, or wrong an outage error - * - * `ContentEditPageView`, the Next.js screen this replaced, answers `notFound()` - * for *every* non-200 - so an unreachable API showed an administrator "not - * found" for a record that exists. Preserving the route's semantics means - * keeping the 404 for the case that really is one and not the rest, which is - * why every assertion here is about a status code rather than about failing. - * - * A stub `QueryClient` and a stub registry: the loader's decision is made - * entirely from what `ensureQueryData` settles to, so a rejecting stub is the - * whole of the unit rather than a stand-in for one. - */ - const definition = defineContentType({ id: "blog.post", tableName: "blog_post", @@ -49,14 +29,6 @@ const registry = buildContentFrontendRegistry([ { pluginId: "@vitnode/blog", contentTypes: [{ definition }] }, ]); -/** - * A root administrator - the permission checks are not what is under test. - * - * `status: "granted"` is what `adminPermissionsOf` reads; anything else empties - * the permission set and `requireAdminPermission` answers `notFound()` before - * the read this suite is about ever happens, which would make every assertion - * below pass for the wrong reason. - */ const adminAccess = { session: { permissions: { permissions: [], root: true } }, status: "granted", @@ -74,12 +46,6 @@ const editRoute: ContentAdminRouteData = { title: "Articles", }; -/** - * A `QueryClient` that only has to answer one method. - * - * `ensureQueryData` is the single call the loader makes, and the loader never - * reads the client for anything else, so the cast is narrower than it looks. - */ const clientRejecting = (error: Error): AdminScreenContext["queryClient"] => ({ ensureQueryData: async () => await Promise.reject(error), @@ -103,13 +69,6 @@ describe("a record that is not there", () => { ).rejects.toSatisfy(isNotFound); }); - /** - * A refusal from the API is the *authorization* answer, and it can arrive on - * a screen the route guard already admitted: the guard decides on a cached - * permission set, and a permission revoked mid-session is only visible here. - * `requireAdminPermission` answers that with `notFound()` too, so both - * readings of "you may not open this" produce one screen. - */ it("treats a refusal the same way", async () => { await expect( load(new AdminRequestError(403, "blog.post #999999")), @@ -128,12 +87,6 @@ describe("a read that failed", () => { await expect(load(error)).rejects.not.toSatisfy(isNotFound); }); - /** - * `rawApiFetch` throws a plain `Error` for a 500 before an - * `AdminRequestError` is ever built, and a schema mismatch between the - * installed plugin and the running API arrives as one too. Neither carries a - * status, and neither is a missing record. - */ it("propagates an error that carries no status at all", async () => { const error = new Error("500 - http://localhost/api/..."); diff --git a/packages/vitnode/src/tanstack/admin/content/form/route.tsx b/packages/vitnode/src/tanstack/admin/content/form/route.tsx index ee4cd0a8b..1576f928e 100644 --- a/packages/vitnode/src/tanstack/admin/content/form/route.tsx +++ b/packages/vitnode/src/tanstack/admin/content/form/route.tsx @@ -19,65 +19,11 @@ import { contentApiTarget } from "../query"; import { contentPermissionFor } from "../route"; import { fetchContentItem, fetchContentTranslations } from "./transport"; -/** - * `/admin/content/{type}/create` and `/admin/content/{type}/edit/{id}` - the - * page-mode form screens, for a TanStack Start host. - * - * The direct counterpart of `page/page-views.tsx`, which is the Next.js pair of - * Server Components. The screen itself is the same component in both - - * `ContentFormPage` - handed the same props; what differs is where those props - * come from, and that is all this module is. - * - * Next.js here - * ------------------------------------------------------------------- - * checkAdminPermissionApi → requireAdminPermission, in the loader - * contentApiFetch (server) → two query entries, warmed in the loader - * getTranslations → the warmed intl entry, through ./spec - * notFound() on a missing → notFound(), via missingContentRecord - * notFound() on a failed read → the read rejects, the boundary answers - */ - /** What {@link loadContentFormScreen} adds to the route's own loader data. */ export interface ContentFormScreenData { - /** - * The record's resolved title - `edit` only. - * - * Computed in the loader rather than in the component because it is the same - * value the heading, the browser tab and every toast use, and because - * resolving it means reading a localized title out of the right translation - - * a decision that must be made once. - */ formTitle?: string; } -/** - * A record that is gone, told apart from an API that is broken. - * - * `/admin/content/blog/articles/999999/edit` addresses a record nobody deleted - * because nobody ever created it, and that is a 404 rather than a failure: the - * Next.js `ContentEditPageView` answers it with `notFound()`, so an - * administrator who follows a stale link lands on the AdminCP's own not-found - * inside the panel. Letting the read simply reject would have shown them an - * error screen for a URL that is merely wrong, which is a different sentence. - * - * Only two statuses are read that way, and the narrowness is the whole point: - * - * 404 the record is not there - * 403 the API will not show it to this administrator - * - * A 403 is the *authorization* answer rather than the route guard's - the guard - * decides on a cached permission set, and a permission revoked mid-session - * arrives here instead. `requireAdminPermission` above answers that case with - * `notFound()` too, so both readings of "you may not open this" produce one - * screen. - * - * Everything else rethrows, and it has to: a 429 from the rate limiter, a 500, - * an API that is not listening and a schema mismatch between the installed - * plugin and the running API are all operational failures, and dressing one of - * them as "no such record" would hide an outage behind a 404 nobody - * investigates. That is the one place this deliberately does not copy the - * Next.js view, which turns every non-200 into `notFound()`. - */ const missingContentRecord = (error: unknown): never => { if ( isAdminRequestError(error) && @@ -92,32 +38,6 @@ const missingContentRecord = (error: unknown): never => { throw error; }; -/** - * The permission this screen needs, and the record it opens on. - * - * Called by the host route's loader **after** `loadContentAdminRoute`, which has - * already resolved the content type and checked `can_view`. Three things happen - * here and only here: - * - * 1. **`can_create` / `can_edit`.** `can_view` opens the list; it does not open - * a form. The Next.js page views check exactly these two and answer - * `notFound()`, and `requireAdminPermission` produces the same 404 one - * navigation earlier - before any request is sent, so an administrator who - * may not edit never provokes a refusal the API would issue anyway. - * 2. **The record and its translations**, warmed with the identical options the - * screen reads back, so the edit form is populated in the first paint rather - * than a round trip later. A create screen warms nothing: there is no record. - * 3. **The title**, from the two. - * - * A `list` URL passes straight through. This runs on every content navigation, - * so it has to be free for the screen it is not for. - * - * A read that fails rejects rather than resolving empty, and the route's error - * boundary owns the screen. An edit form rendered over a record that could not - * be read would show blank fields and write them back on the first save. - * - * The one exception is {@link missingContentRecord} - see below. - */ export const loadContentFormScreen = async ({ adminAccess, locale, diff --git a/packages/vitnode/src/tanstack/admin/content/form/screen.tsx b/packages/vitnode/src/tanstack/admin/content/form/screen.tsx index a7cc57c33..7c1c45e97 100644 --- a/packages/vitnode/src/tanstack/admin/content/form/screen.tsx +++ b/packages/vitnode/src/tanstack/admin/content/form/screen.tsx @@ -12,16 +12,6 @@ import type { ContentFormScreenData } from "./route"; import { ContentFormHost } from "./host"; -/** - * The form itself, fetched when a form URL is actually the URL. - * - * `./page-body` reaches `ContentFormPage`, `ContentForm` and the whole - * `AutoForm` stack. This module is imported by the content screen's composition - * point, which every content URL goes through - a list included - so importing - * that graph here would put it in the route's chunk for screens that never - * render a field. The guard in {@link ContentFormScreen} returns before this is - * rendered on a list URL, and `React.lazy` fetches nothing until it is. - */ const ContentFormPageBody = React.lazy(async () => import("./page-body").then(module => ({ default: module.ContentFormPageBody, @@ -36,13 +26,6 @@ export interface ContentFormScreenProps registry: ContentFrontendRegistry; } -/** - * The screen a content form URL renders, or nothing. - * - * `null` for a `list` URL and for an unresolvable content type, so a host can - * pass this as `ContentAdminScreenContent`'s `children` unconditionally and let - * the shell decide when a form is what the URL means. - */ export const ContentFormScreen = ({ LinkComponent, registry, diff --git a/packages/vitnode/src/tanstack/admin/content/form/spec.ts b/packages/vitnode/src/tanstack/admin/content/form/spec.ts index cd0a52ece..a5a4aab98 100644 --- a/packages/vitnode/src/tanstack/admin/content/form/spec.ts +++ b/packages/vitnode/src/tanstack/admin/content/form/spec.ts @@ -19,30 +19,6 @@ import { import { useLocale } from "../../../i18n/locale"; import { intlQueryOptions } from "../../../i18n/query"; -/** - * One content type's form, as the browser has to build it. - * - * The Next.js AdminCP builds the spec in a Server Component and hands it to the - * form as a prop, already translated. There is no server component here, so it - * is built where the strings are - and the whole of the difficulty is making it - * *stable*, because a spec with a fresh identity on every render would rebuild - * the Zod schema on every render, and `AutoForm` would be handed a new schema - * while somebody is typing into it. - * - * So the messages come from the query entry the route's loader already warmed - * rather than from `useTranslations()`. A query result is one object for the - * life of the entry, which makes `[entry, locale, messages]` a dependency list - * that only changes when the answer really does - a different content type, or - * the administrator switching language. - * - * ## The namespaces must match the loader's - * - * `contentRouteNamespaces(pluginId)` is the same call `loadContentAdminRoute` - * makes, so `useSuspenseQuery` reads the entry that is already there and nothing - * suspends on the first paint. Warming a different pair would cost a round trip - * *and* render the form with a second copy of the strings. - */ - /** A content type's form spec, its labels, and its plugin's overrides. */ export interface ContentTypeForm { fieldOverrides: Record< @@ -75,12 +51,6 @@ export const useContentTypeForm = ( const messages = data.messages; return React.useMemo(() => { - /** - * A translator over the whole warmed record rather than a namespaced one, - * for the reason `loadContentAdminRoute` gives at length: every label key is - * assembled at runtime from the content type id and spans both namespaces, - * which a namespaced translator could not reach. - */ const t = createTranslator({ locale, messages, diff --git a/packages/vitnode/src/tanstack/admin/content/form/transport.ts b/packages/vitnode/src/tanstack/admin/content/form/transport.ts index c6e8b69e7..aa5dd7edb 100644 --- a/packages/vitnode/src/tanstack/admin/content/form/transport.ts +++ b/packages/vitnode/src/tanstack/admin/content/form/transport.ts @@ -31,37 +31,6 @@ import { fetchContentTranslationsOnServer, } from "./server"; -/** - * The Content Engine form's transport, for a TanStack Start host. - * - * Three things this module adds to the framework-neutral calls in - * `views/admin/views/content/form/mutations-api.ts`, and nothing else: - * - * 1. **Which module a content type id addresses**, resolved through the - * registry the application registered. - * 2. **What each write owes the cache**, as a query invalidation. - * 3. **The two reads a page-mode edit form is warmed with**, isomorphically - - * from the request being rendered on the server, over the network in the - * browser. - * - * The Next.js half of the same seam is `form/host-next.tsx`, which hands over - * the Server Actions unchanged. Neither host knows what the other does, and the - * form knows neither. - */ - -/** - * The transport boundary for the two page-mode reads. - * - * Written out per fetcher rather than behind a helper for the reason - * `../query.ts` gives: the chained call is what the Start compiler reads to drop - * the server module from the client bundle, and a wrapper defeats it. - * - * The **writes** are deliberately not isomorphic. Every one of them happens - * because somebody pressed a button, which only ever happens in a browser, so a - * server branch would be code that cannot run - and `createIsomorphicFn` on the - * server prefers the server branch, which would then be the one an SSR render - * silently used if a write ever did reach one. - */ const fetchContentItem: ContentItemFetcher = createIsomorphicFn() .server(fetchContentItemOnServer) .client(fetchContentItemInBrowser); @@ -73,21 +42,6 @@ const fetchContentTranslations: ContentTranslationsFetcher = export { fetchContentItem, fetchContentTranslations }; -/** - * Which generated module serves one content type, by id. - * - * Through the registry rather than through a prop: a dialog opened from a list - * row already knows its entry, but the *form* is handed a spec, and a spec - * carries the content type id. Resolving here is what keeps the transport's - * signature the same as the Server Actions' - which is what let one form serve - * both hosts without a second code path. - * - * An unknown id throws with the id in the message, exactly as - * `mutation-api.server.ts`'s `resolve` does. It means the running application - * has a form open for a content type its registry does not hold, which is a - * build or configuration fault and not something to paper over with an empty - * result. - */ const targetFor = (contentTypeId: string): ContentApiTarget => { const entry = contentFrontendRegistry().byId(contentTypeId); @@ -101,29 +55,6 @@ const isEditorial = (contentTypeId: string): boolean => contentFrontendRegistry().byId(contentTypeId)?.definition.editorial .enabled === true; -/** - * Builds the transport a form reads, bound to one query client. - * - * Per render rather than per module, because the invalidation half closes over - * the `QueryClient` - which is per request on a server rendering many visitors - * at once. `./host.tsx` memoises it against that client, so the identity is - * stable for the life of the screen and the form's effects do not re-run. - * - * ## Only a successful write invalidates - * - * Every branch below checks `result.error === undefined` first. A refused save - * left the record exactly where it was, and refetching the list underneath a - * dialog that is still open - still holding the values the person is being asked - * about - would replace what they are looking at while they decide. - * - * A **version conflict** is the sharpest case: the record on the server *has* - * moved, so there is a real argument for refreshing. It still must not, and this - * is the rule the whole conflict flow rests on - `ConflictNotice` is open over - * the form, offering to show what changed, and it reads the newer record through - * `reloadRow` rather than through anything cached. Invalidating here would - * remount the screen under that dialog and take the editor's unsaved text with - * it. - */ export const contentFormTransport = ( queryClient: QueryClient, ): ContentFormTransport => { diff --git a/packages/vitnode/src/tanstack/admin/content/index.ts b/packages/vitnode/src/tanstack/admin/content/index.ts index 344f727e7..c41529fa3 100644 --- a/packages/vitnode/src/tanstack/admin/content/index.ts +++ b/packages/vitnode/src/tanstack/admin/content/index.ts @@ -1,71 +1,13 @@ -/** - * `/admin/content/*` - the Content Engine, for a TanStack Start host. - * - * ./route resolution, permissions, i18n, labels, the screen shell - * ./route-search the list's URL contract, derived from a content type - * ./query one list query definition, and the writes a row performs - * ./list the table, its columns, its navigation and its skeleton - * ./create-action the heading's create button - a link or a dialog - * ./row-actions publish, edit, delete, and the editorial menu - * ./editorial the four `⋯` panels - history, schedule, preview, delivery - * ./slots what the list does not implement - forms, editorial panels - * ./screen the composition: which of the three screens a URL is - * ./breadcrumb the trail, from the same labels - * ./server the SSR transport, reached only through a query - * - * One splat serves the list, the page-mode create screen and the page-mode edit - * screen, exactly as the Next.js catch-all does - so a plugin adds a content - * type without adding a route file. Which of the three a URL means is - * `resolveContentAdminRoute`'s answer, imported from `@vitnode/core/content` - * rather than reimplemented here. - * - * The host supplies two things this package cannot know: which plugins are - * installed (`ContentFrontendRegistry`, from the generated registry) and how a - * path becomes a navigation (`LinkComponent`). - */ export type { ContentAdminBreadcrumbProps } from "./breadcrumb"; export { ContentAdminBreadcrumbContent } from "./breadcrumb"; export { ContentCreateAction } from "./create-action"; -/** - * The editorial panels, republished at the screen's own entry point. - * - * Two segments below `tanstack/` is the export map's bound, so a host reaches - * them through here. `./screen` already imports the namespace for its side - * effect - importing it is what registers the panels - and a host normally needs - * none of this; it is exported for an application that mounts a content screen - * of its own. - */ + export { ContentEditorialHost, contentEditorialRowPanels, contentEditorialTransport, } from "./editorial"; -/** - * The form screens' loader, republished at the screen's own entry point. - * - * `@vitnode/core/tanstack/admin/content/form` is a *screen's internals* as far - * as the package's export map is concerned - two segments below `tanstack/` is - * the bound, and `apps/web`'s `package-boundary.test.ts` enforces it - so a host - * route reaches the loader through here. `./screen` already imports the - * namespace itself, both to render the form screens and because importing it is - * what registers the dialog slot. - */ -/** - * The form loader, reached past `./form` rather than through it. - * - * `./form/index.ts` registers the create/edit dialog as a module side effect - - * that is what makes a dialog-mode content type openable at all - so it is - * marked side-effectful in the package's `sideEffects` list and a bundler may - * never drop it. Which means every static edge into that barrel keeps - * `ContentAdminFormDialog` and the whole AutoForm stack behind it. - * - * A host's route file imports `loadContentFormScreen` from a `loader`, and a - * `loader` is evaluated in the client entry - so that edge put `react-hook-form` - * and `@hookform/resolvers` on the path to every page of the application. - * Importing the loader's own module instead keeps the eager half clear; the - * registration still happens, because `./screen` imports `./form` and that is - * the module a content screen actually renders through. - */ + export type { ContentFormScreenData } from "./form/route"; export { loadContentFormScreen } from "./form/route"; export type { ContentListScreenProps } from "./list"; @@ -144,11 +86,7 @@ export { ContentEditorialTransportProvider, useContentEditorialTransport, } from "@/views/admin/views/content/actions/editorial-transport"; -/** - * The list's shared model, re-exported so a screen imports the rules it renders - * from the same place it imports the screen. All of it is framework-neutral and - * all of it is what the Next.js AdminCP reads too. - */ + export type { ContentEditorialActionId, ContentRowActionId, @@ -196,13 +134,7 @@ export { contentApiFetchInBrowser, readContentApiJson, } from "@/views/admin/views/content/content-request"; -/** - * The narrow invalidation helpers, shared with the Next.js AdminCP. - * - * One prefix each, none of them above the content type that was written to. - * `invalidateContentAfterWrite` in `./query` composes the three a record write - * owes; these are what a panel reaches for when it owes less than that. - */ + export { invalidateContentDelivery, invalidateContentHistory, diff --git a/packages/vitnode/src/tanstack/admin/content/list.tsx b/packages/vitnode/src/tanstack/admin/content/list.tsx index 7246bad4c..96e6eef96 100644 --- a/packages/vitnode/src/tanstack/admin/content/list.tsx +++ b/packages/vitnode/src/tanstack/admin/content/list.tsx @@ -44,33 +44,6 @@ import { } from "./route-search"; import { ContentRowActions } from "./row-actions"; -/** - * `/admin/content/{path}` - one content type's records, for a TanStack Start - * host. - * - * The same table the Next.js AdminCP renders, from the same specs: the columns - * are `buildContentColumnSpec`'s, the cells are `ContentCell` or the plugin's - * override, the actions are `row-actions-model`'s, and the URL contract is - * `./route-search`'s. Nothing about the table was redesigned and nothing is - * derived from a Zod schema here - a definition becomes a column spec in one - * place, and both AdminCPs read that. - * - * What is genuinely this host's: - * - * useSuspenseQuery instead of an awaited server fetch - * DataTableNavigationProvider instead of next-intl's locale-aware push - * browser writes + invalidation instead of Server Actions + revalidatePath - * - * ## Labels are resolved here rather than carried from the loader - * - * `ContentLabels` is four strings and three *functions* - a field labeller, an - * enum labeller and a section labeller, each of which reads a key assembled at - * runtime. Functions do not survive a loader's serialization to the browser, so - * they are rebuilt from the messages the route already warmed, through the same - * `contentLabelsFrom` the Next.js screen uses. Nothing suspends doing it: the - * loader warmed exactly the namespaces `RouteMessages` mounts. - */ - /** What the list screen needs on top of the route data the loader returned. */ export interface ContentListScreenProps { contentTypeId: string; @@ -78,13 +51,7 @@ export interface ContentListScreenProps { LinkComponent?: AuthLinkComponent; /** How a table control changes the URL - the Stage 7 seam. */ navigate: AdminTableNavigate<ContentListRouteSearch>; - /** - * The normalised URL contract, as the loader computed it. - * - * Optional, and derived from the same `contentListRouteParams` when absent - - * so the key this screen reads is the key the loader warmed whether or not the - * loader data carried it. - */ + params?: ContentListParams; /** This installation's content types, with their override components. */ registry: ContentFrontendRegistry; @@ -102,20 +69,6 @@ interface ContentListTableProps extends Pick< params: ContentListParams; } -/** - * The table itself - the one component that suspends. - * - * `useSuspenseQuery` over the identical options the loader ensured, so on the - * first paint the rows are there. A later *refetch* does not suspend either: the - * previous page stays on screen while an invalidated entry refreshes, which is - * what keeps a delete from blanking the table under the dialog that is still - * open. - * - * A failed read is left to reject. The query throws an `AdminRequestError` - * rather than resolving to an empty page, and the route's error boundary owns - * the screen from there - because a table with no rows is what a content type - * nobody has written in looks like, and those two must never be confusable. - */ const ContentListTable = ({ columnSpecs, entry, @@ -132,16 +85,6 @@ const ContentListTable = ({ contentListPageQuery({ definition, locale, params, pluginId }), ); - /** - * The Stage 7 seam, and the whole of what this screen tells the table about - * navigation: where it is, and how to get somewhere else. No pathname - with - * no destination the router stays on this route and changes only its search, - * which is all a pager, a sort header, a search box or a filter ever does. - * - * `contentListSearchFrom` re-validates what the control wrote against this - * content type's own contract, so a control cannot produce a sort column the - * definition does not have. - */ const navigation = React.useMemo<DataTableNavigation>( () => ({ navigate: async nextSearch => { diff --git a/packages/vitnode/src/tanstack/admin/content/query.test.ts b/packages/vitnode/src/tanstack/admin/content/query.test.ts index c1490645d..800d3ee1a 100644 --- a/packages/vitnode/src/tanstack/admin/content/query.test.ts +++ b/packages/vitnode/src/tanstack/admin/content/query.test.ts @@ -23,19 +23,6 @@ import { invalidateContentList, } from "./query"; -/** - * What a content list asks for, and what a write to one owes the rest of the - * AdminCP. - * - * The request half is where the viewing locale joins the URL contract, which is - * the one input that is not in the URL and still changes every localized cell - - * so it has to be in the key, and it has to be *absent* from the key of a list - * that has no translations. - * - * The invalidation half is about prefixes. Every rule below is one somebody - * would otherwise have to remember when they add a mutation. - */ - const articles = defineContentType({ id: "blog.post", tableName: "blog_post", diff --git a/packages/vitnode/src/tanstack/admin/content/query.ts b/packages/vitnode/src/tanstack/admin/content/query.ts index b64ecba9d..2b672a247 100644 --- a/packages/vitnode/src/tanstack/admin/content/query.ts +++ b/packages/vitnode/src/tanstack/admin/content/query.ts @@ -34,28 +34,6 @@ import type { ContentListParams } from "./route-search"; import { contentListQuery } from "./route-search"; import { fetchContentListPageOnServer } from "./server"; -/** - * The Content Engine list for a TanStack Start host: one query definition, one - * invalidation family, and the three writes a row performs. - * - * Everything about *what* the list is - the request, the response schema, the - * cache key - is `views/admin/views/content/table/list-query.ts`, which is also - * what the shared table renders from. This module supplies the two things that - * module cannot know: how to reach the API from a server that is rendering a - * request, and what "refresh the table" means in a router that has a query cache - * instead of `revalidatePath`. - */ - -/** - * The transport boundary. - * - * Both branches call Hono directly - the server one from inside the request - * being rendered, the browser one over the network to the same origin - and the - * admin cookie travels on both. Written out per feature rather than hidden - * behind a helper for the reason `tanstack/admin/cron/query.ts` gives: the - * chained call is what the Start compiler reads to drop the server module from - * the client bundle, and a wrapper defeats it. - */ const fetchContentListPage: ContentListPageFetcher = createIsomorphicFn() .server(fetchContentListPageOnServer) .client(fetchContentListPageInBrowser); @@ -78,13 +56,6 @@ export interface ContentListQueryArgs { pluginId: string; } -/** - * The request one content list URL is asking for, on the wire. - * - * The one place the viewing locale joins the URL contract, and it joins it only - * for a content type that has translations. A list without them would otherwise - * get one cache entry per AdminCP language, each holding identical rows. - */ export const contentListRequestFor = ({ definition, locale, @@ -97,40 +68,12 @@ export const contentListRequestFor = ({ target: contentApiTarget(definition, pluginId), }); -/** - * The list, as the one query definition a loader warms and a screen reads back. - * - * Named `…PageQuery` rather than `contentListQuery`, which is already the URL - * contract's flattener next door. Both sides of the route must call *this*, with - * the same arguments, or the loader fills an entry the component never looks at. - */ export const contentListPageQuery = (args: ContentListQueryArgs) => contentListQueryOptions({ fetchPage: fetchContentListPage, request: contentListRequestFor(args), }); -/** - * What a write to one record owes the rest of the AdminCP. - * - * Three prefixes, and each is a different kind of wrongness: - * - * - **The list**, because the row changed. Invalidated. - * - **The record**, because its detail, its revisions, its schedules and its - * delivery state are all facts about a row that just moved. Invalidated, or - * *removed* when the record is gone - keeping a deleted record's history in - * memory only lets something render it. - * - **Every reference picker onto this content type**, because an article's - * category picker is offering rows of this list. Removed rather than - * invalidated: the AdminCP's query client is configured `refetchOnMount: - * false`, so a merely-stale picker is still served from the cache the next - * time a form opens, and a deleted category stays on offer. - * - * The four narrow helpers it composes are in - * `views/admin/views/content/lib/invalidate.ts`, shared with the Next.js - * AdminCP - each names one prefix out of `content-query.ts`, and none of them - * reaches above the one content type that was written to. - */ export const invalidateContentAfterWrite = async ( queryClient: QueryClient, { @@ -149,26 +92,12 @@ export const invalidateContentAfterWrite = async ( await invalidateContentList(queryClient, contentTypeId); }; -/** - * Marks every cached page, sort, search and filter of one content list stale. - * - * Re-exported rather than declared: the helper itself is - * `views/admin/views/content/lib/invalidate.ts`, shared with the Next.js - * AdminCP, and this keeps the import a content route already writes resolving. - */ export { invalidateContentList }; export interface ContentRowWriteArgs extends ContentRowMutationArgs { contentTypeId: string; } -/** - * Publishes or unpublishes a row, then refreshes what it changed. - * - * Only on success. A refused publish left the record exactly where it was, and - * refetching underneath the dialog that is still open - and still naming the - * record - would replace the rows the administrator is being asked about. - */ export const setContentPublication = async ( queryClient: QueryClient, { diff --git a/packages/vitnode/src/tanstack/admin/content/route-screen.tsx b/packages/vitnode/src/tanstack/admin/content/route-screen.tsx index 47d8c57eb..e0f2dd5fb 100644 --- a/packages/vitnode/src/tanstack/admin/content/route-screen.tsx +++ b/packages/vitnode/src/tanstack/admin/content/route-screen.tsx @@ -9,35 +9,11 @@ import type { ContentAdminRouteData } from "./route"; import { RouteMessages } from "../../i18n/route-messages"; export interface ContentAdminRouteProps extends ContentAdminRouteData { - /** - * Controls for the heading - the list's create button, and nothing else so - * far. - * - * A slot rather than a prop the shell fills in, because what belongs there is - * a property of *which* screen this is: a list offers "Create", and the two - * form screens offer their actions inside the form rather than beside the - * title. - */ actions?: React.ReactNode; - /** - * The screen itself. - * - * The list table, the create form and the edit form are mounted by the host's - * route from `@vitnode/core/tanstack/admin/content/*`; this module owns the - * chrome they sit in - the provider, the heading, the layout - which is what - * every content type shares whichever of the three it is showing. - */ + children?: React.ReactNode; } -/** - * The Content Engine screen's shell. - * - * `RouteMessages` mounts exactly the namespaces the loader warmed, so nothing - * suspends on the first paint. The heading is the content type's own noun, - * resolved through the same ICU plural the sidebar entry uses - so the crumb, - * the menu item and this title read identically in every language. - */ export const ContentAdminRouteContent = ({ action, actions, diff --git a/packages/vitnode/src/tanstack/admin/content/route-search.test.ts b/packages/vitnode/src/tanstack/admin/content/route-search.test.ts index aaf4c5d7f..fe27c7b2a 100644 --- a/packages/vitnode/src/tanstack/admin/content/route-search.test.ts +++ b/packages/vitnode/src/tanstack/admin/content/route-search.test.ts @@ -19,20 +19,6 @@ import { normalizeContentListSearch, } from "./route-search"; -/** - * The Content Engine list's URL contract. - * - * Three shapes, and the whole of this suite is about keeping them apart: - * - * the URL ?orderBy=title&categoryId=3 what an admin sees and shares - * the search { orderBy: 'title', … } the route's validated state - * the request { first: '25', categoryId: '3' } what the API is asked for - * - * The shared admin-table half is tested in `../table-search.test.ts`; what is - * here is the half a content type contributes - its sortable columns, whether it - * has a search box, and its filters. - */ - const articles = defineContentType({ id: "blog.post", tableName: "blog_post", @@ -120,12 +106,6 @@ describe("contentListFilters", () => { ); }); - /** - * The router's default parser JSON-parses every value, so a number arrives as - * a number and a repeated key as an array. Only the first entry can reach the - * API, and a non-scalar is absent rather than coerced - `String({})` is - * `"[object Object]"`, which no rule would recognise. - */ it("normalises what the router's parser produced", () => { expect(contentListFilters({ views: 12 }, articles)).toEqual({ views: "12", @@ -179,12 +159,6 @@ describe("contentListRouteParams", () => { }); describe("normalizeContentListSearch", () => { - /** - * Total and idempotent, both of which are requirements of where it runs: - * `contentListSearchFrom` is this function, so it runs on every table - * navigation and the router then validates the location it produced. One that - * threw would turn a hand-edited query string into an error screen. - */ it("never throws, whatever is in the query string", () => { expect(() => normalizeContentListSearch( @@ -203,11 +177,6 @@ describe("normalizeContentListSearch", () => { expect(normalizeContentListSearch(once, articles)).toEqual(once); }); - /** - * The default page size is the URL saying nothing. Answering `first: 25` would - * write `?first=25` into every link the router builds to this route - - * including the sidebar's. - */ it("omits a page size equal to the content default", () => { expect(normalizeContentListSearch({}, articles)).toEqual({}); expect( @@ -216,12 +185,6 @@ describe("normalizeContentListSearch", () => { ).toBeUndefined(); }); - /** - * The generated list route answers 25 when asked for no size, and the Next.js - * list asks for none - so the request this contract sends has to be 25 too. - * The data table's own default is 10, and taking that would quietly shrink - * every content list. - */ it("asks the API for the Content Engine's page size, not the table's", () => { expect(contentListRouteParams({}, articles).first).toBe( String(CONTENT_DEFAULT_PAGE_SIZE), @@ -229,28 +192,12 @@ describe("normalizeContentListSearch", () => { expect(CONTENT_DEFAULT_PAGE_SIZE).not.toBe(DEFAULT_TABLE_PAGE_SIZE); }); - /** - * Page sizes are numbers, not numeric strings. The router's default serializer - * JSON-encodes a string that would parse as JSON, so `'20'` is written to the - * address bar as `first=%2220%22`. - */ it("keeps page sizes as numbers", () => { expect(normalizeContentListSearch({ first: 20 }, articles).first).toBe(20); }); }); describe("the round trip through the table's controls", () => { - /** - * A control is handed a `URLSearchParams` and produces a new query string from - * it; this is both ends of that. The return leg re-validates, so a control - * cannot write a sort column the content type does not have. - * - * Asserted against the literal shape rather than against - * `normalizeContentListSearch`: the return leg *is* that function now, so - * comparing the two would be an identity that holds however wrong both are. - * What has to be pinned is that a page size survives as a number, that a - * filter survives at all, and that neither picks up a stray key on the way. - */ it("survives search params out and back", () => { const search = { first: 20, orderBy: "title", status: "draft" }; const params = contentListSearchParams(search, articles); @@ -265,11 +212,6 @@ describe("the round trip through the table's controls", () => { }); }); - /** - * And the default page size is still the URL saying nothing after the trip - - * the property the whole contract rests on, checked at the far end rather - * than only on the way in. - */ it("drops a default page size on the way back", () => { const params = contentListSearchParams({ orderBy: "title" }, articles); diff --git a/packages/vitnode/src/tanstack/admin/content/route-search.ts b/packages/vitnode/src/tanstack/admin/content/route-search.ts index 9365a7fdf..1489e92ee 100644 --- a/packages/vitnode/src/tanstack/admin/content/route-search.ts +++ b/packages/vitnode/src/tanstack/admin/content/route-search.ts @@ -18,41 +18,6 @@ import { normalizeAdminTableSearch, } from "../table-search"; -/** - * The Content Engine list's URL contract. - * - * The shared admin-table one, plus the two things a content list has that the - * four fixed admin tables do not: its sortable columns and its filters are - * **derived from the content type**, not declared by the screen. So the contract - * is a function of a definition rather than a constant, and everything below - * takes one. - * - * ?cursor= ?first= ?last= ?order= ?orderBy= ?search= the shared contract - * ?status= ?categoryId= ?authorId= … this type's filters - * - * ## The filters are part of the contract because they always were - * - * The Next.js list passes its whole `searchParams` object to the generated - * route, which parses it against a non-strict filter schema. So - * `?categoryId=3` filters today - there is no UI for it, but a hand-written or - * pasted URL does it - and dropping it here would be a silent behaviour change - * for anybody who has one bookmarked. - * - * Reproducing it means naming the same keys the schema does, which is what - * `contentFilterableFields` is for. Keys outside that set are not carried: a - * stray `?tab=2` reaches neither the URL the controls rebuild nor the cache key, - * exactly as `rawParamsOf` refuses one for the shared parameters. - * - * ## The viewing locale is not in here - * - * A localized list is read *in* a language, and that language is the - * administrator's own - the AdminCP locale cookie, resolved by the root route. - * It is not a filter, it never appears in the URL, and the API is explicit that - * it does not hide records a translator has not reached. It is attached to the - * request by `./query`, which knows the locale; a URL contract has no business - * with it. - */ - /** The filters one content type accepts, as `field -> value`. */ export type ContentListFilters = Record<string, string>; @@ -66,39 +31,14 @@ export type ContentListRouteSearch = AdminTableRouteSearch & export type UncheckedContentListSearch = UncheckedAdminTableSearch; -/** - * The shared half of one content type's contract. - * - * `search` is on only when the content type declared searchable fields, matching - * the Next.js list - which renders the search box on the same condition. A - * `?search=` sent to a type with none is dropped rather than passed on, so it - * cannot become a second cache entry holding identical rows. - */ export const contentTableContract = ( definition: AnyContentTypeDefinition, ): AdminTableContract => ({ - /** - * The generated list route's own default, not the data table's. - * - * `DEFAULT_TABLE_PAGE_SIZE` is 10 and `CONTENT_DEFAULT_PAGE_SIZE` is 25, and - * the Next.js list sends neither - it passes its `searchParams` through, so a - * URL with no `first` gets the API's 25. Taking the table's default here would - * quietly change every content list to 10 rows, and taking it in only one of - * the two places would make `?first=25` and no `first` two cache entries. - */ defaultPageSize: CONTENT_DEFAULT_PAGE_SIZE, orderBy: orderableColumns(definition), search: definition.admin.list.searchableFields.length > 0, }); -/** - * One search parameter as the string it was in the query string. - * - * The same narrowing `table-search.ts` applies, and for the same reason: the - * router's default parser JSON-parses every value, so `?first=20` arrives as a - * number and a repeated key as an array. Anything that is not a scalar - an - * object, a nested array, a `null` - is *absent* rather than coerced. - */ const readParam = (value: unknown): string | undefined => { const one = Array.isArray(value) ? (value[0] as unknown) : value; @@ -111,13 +51,6 @@ const readParam = (value: unknown): string | undefined => { return undefined; }; -/** - * The filters this URL is asking for, reduced to the ones the type accepts. - * - * Sorted by key, so two URLs naming the same filters in different orders are one - * cache entry rather than two holding identical rows. Blank values are dropped - * for the same reason a blank search is: the route treats them as no filter. - */ export const contentListFilters = ( input: UncheckedContentListSearch, definition: AnyContentTypeDefinition, @@ -141,29 +74,6 @@ export const contentListRouteParams = ( filters: contentListFilters(input, definition), }); -/** - * One content list URL in its canonical form: normalised, never rejected. - * - * A content list URL is edited by hand and pasted between people, so - * `?orderBy=password`, `?first=5000` and `?categoryId=` should all produce the - * table the administrator would otherwise have got - never an error. - * - * Total and idempotent, and both are load-bearing where it is used: - * {@link contentListSearchFrom} runs it on every table navigation to turn a - * control's new query string back into route search, and the router then - * validates the location that produces. - * - * ## It is deliberately *not* the route's `validateSearch` - * - * It cannot be. `validateSearch` is handed the query string alone - never the - * path params - so it has no way to know which content type this URL is for, - * and every rule below is a function of that content type's sortable columns - * and filters. So the route passes its search through untouched and normalises - * in the loader, where the splat has just resolved; see the note on - * `loadContentAdminRoute`. The consequence is small and intended: a hand-typed - * `?orderBy=nonsense` stays in the address bar and renders the default table, - * and the first control anybody touches rewrites the URL through this. - */ export const normalizeContentListSearch = ( input: UncheckedContentListSearch, definition: AnyContentTypeDefinition, @@ -191,21 +101,6 @@ export const contentListSearchParams = ( return params; }; -/** - * A query string one of those controls produced, back as route search. - * - * The return leg, and the point at which the table's own URL arithmetic is - * re-validated: a control cannot write a sort column this content type does not - * have, because what it wrote goes through the same normaliser the address bar - * would have gone through. - * - * Literally that normaliser, rather than the same steps written out again. - * `adminTableSearchFrom` is `normalizeAdminTableSearch` over the parsed query - * string, so composing it with the filters a second time produced an identical - * result by coincidence rather than by construction - and a rule added to - * {@link normalizeContentListSearch} would have reached the address bar and not - * the controls. - */ export const contentListSearchFrom = ( nextSearch: string, definition: AnyContentTypeDefinition, @@ -215,13 +110,6 @@ export const contentListSearchFrom = ( definition, ); -/** - * The request, flattened for the wire. - * - * The filters sit beside the pagination parameters in one query string, which is - * what the generated route reads: `paginationQuery.extend(filters.shape)` parses - * the whole thing at once. - */ export const contentListQuery = ( params: ContentListParams, ): Record<string, string | undefined> => { diff --git a/packages/vitnode/src/tanstack/admin/content/route.test.ts b/packages/vitnode/src/tanstack/admin/content/route.test.ts index 47842996a..7651aa40a 100644 --- a/packages/vitnode/src/tanstack/admin/content/route.test.ts +++ b/packages/vitnode/src/tanstack/admin/content/route.test.ts @@ -14,16 +14,6 @@ import { resolveContentAdminScreen, } from "./route"; -/** - * The splat, the resolver and the permission tuple - the three pure decisions - * the Content Engine route makes before it fetches anything. - * - * `resolveContentAdminRoute` has its own suite in `content/admin/route.test.ts` - * and is not re-tested here. What *is* tested is that this route reaches it with - * the right arguments: the segments a splat produces, and a lookup keyed by - * `admin.path`. Those are the two things a second slug parser would get wrong. - */ - const define = ( id: string, admin: Partial<Parameters<typeof defineContentType>[0]["admin"]> = {}, @@ -91,12 +81,6 @@ describe("resolveContentAdminScreen", () => { ).toBeUndefined(); }); - /** - * The behaviour the whole `admin.path` design exists for, checked through this - * route rather than only through the resolver: the lookup has to be keyed by - * the path, and a route that reached for `byId` instead would break exactly - * the content types that renamed themselves. - */ describe("admin.path", () => { const renamed = define("blog.post", { create: { mode: "page" }, @@ -185,12 +169,6 @@ describe("resolveContentAdminScreen", () => { }); }); - /** - * `blog/post/create` is a legal address for a content type of its own, and the - * exact match wins - so that content type keeps its list screen and the create - * page of `blog/post` becomes unreachable. A name clash its author can see, - * rather than a screen that silently disappeared. - */ it("prefers an exact content type path over a create page", () => { const literal = define("blog.post.create"); const registry = registryOf(pagePost, literal); @@ -204,12 +182,6 @@ describe("resolveContentAdminScreen", () => { ).toBe("blog.post.create"); }); - /** - * The edit suffix cannot be shadowed the way `create` can, and the reason is - * one level down: `admin.path` segments must start with a lowercase letter, so - * no content type can live at `blog/post/7/edit`. The ambiguity only exists - * for a path whose last segment is a word. - */ it("cannot have its edit page shadowed, because a path segment is never a number", () => { expect(() => define("blog.post.archive", { path: "blog/post/7/edit" }), @@ -224,15 +196,6 @@ describe("resolveContentAdminScreen", () => { }); }); -/** - * The tuple the loader checks, read off the definition rather than assembled - * from the content type id. - * - * `admin.permissionModule` may differ from the entity name, and a guessed module - * checks a permission that does not exist - which grants nothing and denies - * nothing, so the screen would open for everybody or for nobody depending on - * which way the check falls. - */ describe("contentPermissionFor", () => { it("names the plugin, the definition's module and the permission", () => { const registry = registryOf(dialogPost); diff --git a/packages/vitnode/src/tanstack/admin/content/route.tsx b/packages/vitnode/src/tanstack/admin/content/route.tsx index 2b4703315..627d5ec92 100644 --- a/packages/vitnode/src/tanstack/admin/content/route.tsx +++ b/packages/vitnode/src/tanstack/admin/content/route.tsx @@ -28,33 +28,6 @@ import { requireAdminPermission } from "../screen"; import { contentListPageQuery } from "./query"; import { contentListRouteParams } from "./route-search"; -/** - * `/admin/content/*` - the Content Engine, as everything a TanStack Start route - * needs and nothing a route owns. - * - * One splat serves three screens, exactly as the Next.js catch-all does, and for - * the same reason: a plugin adds a content type without adding a file. Which - * screen a URL is asking for is decided by `resolveContentAdminRoute`, the pure - * resolver `content/admin/route.ts` has always used - imported, not - * reimplemented. There is no second slug parser in this package and there must - * not be one: the exact-match-first rule and the page-mode gates are subtle - * enough that a second reading of them would diverge silently. - */ - -/** - * The splat, as the resolver reads it. - * - * TanStack hands `params._splat` as the matched remainder - `"blog/articles"`, - * or `undefined` when the splat matched nothing at all. The resolver takes the - * segments, so this is the whole of the conversion, and the two filters matter: - * an empty segment (`//`, a trailing slash) is not a path segment, and an empty - * splat is not `[""]`. - * - * The resolver already answers `undefined` for an empty array, so - * `/admin/content` with nothing after it is a not-found rather than a list of - * every content type. That is deliberate - there is no index screen, and - * inventing one here would be a URL the Next.js AdminCP never served. - */ export const contentRouteSegments = (splat: string | undefined): string[] => (splat ?? "").split("/").filter(segment => segment !== ""); @@ -66,15 +39,6 @@ export interface ContentAdminScreen { itemId?: number; } -/** - * The splat, resolved against this installation's registry, or `undefined`. - * - * Two lookups, and they are deliberately different: the resolver is keyed by - * `admin.path` - the URL's own name for a content type - and the entry is then - * fetched by the id it returned. A content type is free to make those disagree - * (`blog.post` answering at `blog/articles`), which is exactly why the registry - * exposes both and why nothing here derives one from the other. - */ export const resolveContentAdminScreen = ( segments: readonly string[], registry: ContentFrontendRegistry, @@ -104,59 +68,15 @@ export interface ContentAdminRouteData { contentTypeId: string; description: string | undefined; itemId?: number; - /** - * The content type's nouns, and only those. - * - * `ContentRouteLabels` rather than `ContentLabels`, because everything - * returned from here is serialized into the SSR payload - see the note on - * that type. The screens that need a field, enum or section labeller build - * their own from the messages this loader warmed. - */ + labels: ContentRouteLabels; - /** - * The normalised list request, for the `list` screen only. - * - * Returned rather than recomputed in the component so the entry the loader - * warmed and the entry the table reads back are the same key by construction. - * Plain strings, so it survives the loader's serialization to the browser. - */ + listParams?: ContentListParams; namespaces: string[]; pluginId: string; title: string; } -/** - * Everything the screen needs to know *which* screen it is, before it renders. - * - * Four steps, in this order, and the order is the point: - * - * 1. **Resolve.** An unresolvable URL is the AdminCP's not-found. Never a - * redirect to a neighbouring content type - a mistyped path that quietly - * opened somebody else's records would be far worse than a 404. - * 2. **Permit.** `can_view` on this content type's own module, checked before - * any request is sent, so an administrator who may not open the screen never - * provokes a refusal the API would issue anyway. The tuple is read off the - * definition rather than assembled from the id: `admin.permissionModule` may - * differ from the entity name, and guessing it would check a permission that - * does not exist - which grants nothing and denies nothing. - * 3. **Warm the strings**, for exactly the two namespaces this content type - * renders from. - * 4. **Resolve the labels** from those strings, through the one label resolver - * both AdminCPs use. - * - * 5. **Warm the list**, but only when this URL *is* a list. The two form - * screens read a record rather than a page, so fetching one for them would - * cost a request on every navigation for no paint. It is warmed with the - * identical options `ContentListScreen` reads back - `contentListPageQuery` - * from the parameters returned below - which is the only way the first paint - * is free rather than a round trip late. - * - * A refused or unreachable list rejects here rather than resolving to an empty - * page, and the route's error boundary owns the screen. That is deliberate: a - * table with no rows is what a content type nobody has written in looks like, - * and an operational failure must never be dressed as one. - */ export const loadContentAdminRoute = async ({ adminAccess, locale, @@ -166,16 +86,7 @@ export const loadContentAdminRoute = async ({ segments, }: AdminScreenContext & { registry: ContentFrontendRegistry; - /** - * The route's search, as the router parsed it. - * - * Unvalidated on purpose. `validateSearch` is handed the query string alone - - * never the path params - so it cannot know which content type this URL is - * for, and the contract it would have to check against is a function of that - * content type's sortable columns and filters. So the URL is normalised - * *here*, where the definition has just been resolved, through the same - * `contentListRouteParams` the table's own controls go back through. - */ + search?: UncheckedContentListSearch; segments: readonly string[]; }): Promise<ContentAdminRouteData> => { @@ -217,16 +128,6 @@ export const loadContentAdminRoute = async ({ : undefined, ]); - /** - * A translator over the whole warmed record rather than a namespaced one. - * - * Every key the label resolver reads is assembled at runtime from the content - * type id and spans both namespaces - `core.content.*` and - * `{pluginId}.content.{entity}.*` - so a namespaced translator could not reach - * them. The cast is the one `ContentLabelTranslator` exists for: `use-intl` - * types its keys as a union of every message in the catalogue, which a runtime - * key cannot satisfy, and `has` is what keeps it honest. - */ const t = createTranslator({ locale, messages: intl.messages, diff --git a/packages/vitnode/src/tanstack/admin/content/row-actions.tsx b/packages/vitnode/src/tanstack/admin/content/row-actions.tsx index 1e2ba39b3..42ef695c3 100644 --- a/packages/vitnode/src/tanstack/admin/content/row-actions.tsx +++ b/packages/vitnode/src/tanstack/admin/content/row-actions.tsx @@ -60,30 +60,6 @@ import { import { ContentFormDialogSlot, ContentRowPanelSlot } from "./slot-render"; import { contentAdminSlots, registeredContentRowPanels } from "./slots"; -/** - * One content list row's actions, for a TanStack Start host. - * - * The same cluster the Next.js list renders, in the same order, gated on the - * same permissions: publish or unpublish, edit, then everything else behind a - * `⋯`. What differs is only where the writes land - a browser request and a - * query invalidation, rather than a Server Action and `revalidatePath` - and - * that difference is entirely inside `./query`. - * - * Which actions exist is `row-actions-model.ts`, shared with the Next.js AdminCP. - * The editorial panels behind four of them are not implemented here at all; they - * arrive through `./slots`, and an action whose panel nobody registered is not - * offered rather than offered and inert. - * - * ## None of this is authorization - * - * Every gate below reads the permission set the admin session already resolved, - * so it decides which control renders. `api/config.ts` puts - * `globalAdminMiddleware()` in front of every admin path and each generated - * content route declares its own `adminStaffPermission`, re-checked against the - * staff tables on the request itself - so an administrator who reveals a button - * in devtools reaches a request the API still refuses. - */ - export interface ContentRowActionsProps { entry: RegisteredFrontendContentType; /** How a path becomes a navigation. See {@link RouterLink} for the default. */ @@ -99,14 +75,6 @@ export interface ContentRowActionsProps { const versionOf = (row: ContentRowData): number => typeof row.version === "number" ? row.version : 1; -/** - * A failed write, as a toast the administrator can act on. - * - * The same mapping the Next.js dialogs apply, through the same - * {@link contentErrorKey}: a status becomes a sentence about what to do, and - * anything unrecognised falls back to the global server error rather than - * echoing a body nobody wrote for a person to read. - */ const useMutationToast = () => { const tErrors = useTranslations("core.global.errors"); const tContentErrors = useTranslations("core.content.errors"); @@ -152,13 +120,6 @@ const PublishRowAction = ({ if (!definition.publication.enabled || !canPublish) return null; - /** - * Which of the two transitions this row is offered, read by the engine's own - * helper rather than by comparing `row.status` to a string here. The status - * arrives off a JSON response typed `unknown`, and a hand-written comparison - * in a host is exactly the kind of duplicate rule that survives a rename of - * the constant it was copied from. - */ const { action, destructive: published } = contentPublicationTransition( row.status, ); @@ -213,15 +174,6 @@ const PublishRowAction = ({ ); }; -/** - * The edit entry point - a link, a dialog, or nothing. - * - * `definition.admin.edit.mode` decides which, exactly as it does in the Next.js - * list: `page` navigates to the canonical edit URL that the same splat route - * already serves, and `dialog` opens the form in place. No query-string modal - * routing was invented for the second - a dialog is component state, and putting - * it in the URL would make a shared link open somebody else's half-typed form. - */ const EditRowAction = ({ entry, LinkComponent = RouterLink, @@ -312,15 +264,6 @@ const ACTION_ICONS: Record<ContentRowActionId, React.ReactNode> = { schedule: <CalendarClockIcon />, }; -/** - * Everything else the row can do, as buttons or behind a `⋯`. - * - * The open panel lives here rather than in the panel itself: a menu item - * unmounts with the menu the moment it is clicked, so a dialog rendered inside - * one would go with it. Each panel is mounted *beside* the menu and told when to - * open - the arrangement `ContentPanelProps` already describes for the Next.js - * list, kept identical so a panel written for one host works in the other. - */ const ContentRowActionsMenu = ({ entry, locale, diff --git a/packages/vitnode/src/tanstack/admin/content/screen.tsx b/packages/vitnode/src/tanstack/admin/content/screen.tsx index 04a7c91fa..18f35e915 100644 --- a/packages/vitnode/src/tanstack/admin/content/screen.tsx +++ b/packages/vitnode/src/tanstack/admin/content/screen.tsx @@ -18,46 +18,8 @@ import { ContentFormScreen } from "./form"; import { ContentListActions, ContentListScreen } from "./list"; import { ContentAdminRouteContent } from "./route-screen"; -/** - * `/admin/content/*` - the whole screen, whichever of the three it is. - * - * The composition point, so a host's route file stays topology: it hands over - * the loader data, the registry, the navigate and the search, and this decides - * whether that URL is a list, a create form or an edit form. Exactly the choice - * `ContentAdminView` makes in the Next.js AdminCP, made from the same resolved - * `action` rather than from a second reading of the path. - * - * ## Why it imports the form namespace - * - * Two reasons, and the second is the one worth stating. The first is ordinary: - * a create or edit URL renders `ContentFormScreen`, which is that namespace's. - * - * The second is that importing `./form` is what *registers the dialog*. The - * list's create button and its rows' pencils render through - * `contentAdminSlots().FormDialog`, and an unregistered slot renders nothing - - * so a dialog-mode content type would silently have no way to open a form. The - * registration is a side effect of that module being imported, and this is the - * one module that can guarantee it: anything that can show a content list goes - * through here. - * - * `./editorial` is imported for that reason alone - it exports nothing this - * module names. It registers the four `⋯` panels the same way, and without it a - * row would offer publish, edit and delete and nothing else: the menu asks - * `registeredContentRowPanels` what this host can open, so an unimported panel - * is an absent action rather than an entry that opens nothing. - * - * `children` still wins when a host passes one, which is what lets an - * application mount a form screen of its own without forking this. - */ export interface ContentAdminScreenProps extends ContentAdminRouteData, ContentFormScreenData { - /** - * A create or edit screen of the host's own, instead of the generated one. - * - * Rare, and the escape hatch rather than the path: an application that wants - * its own editor for one content type mounts it here. Absent - the normal - * case - renders `ContentFormScreen`. - */ children?: React.ReactNode; /** How a path becomes a navigation. Defaults to the router's own link. */ LinkComponent?: AuthLinkComponent; diff --git a/packages/vitnode/src/tanstack/admin/content/slot-render.tsx b/packages/vitnode/src/tanstack/admin/content/slot-render.tsx index 1d4922e69..7a836e744 100644 --- a/packages/vitnode/src/tanstack/admin/content/slot-render.tsx +++ b/packages/vitnode/src/tanstack/admin/content/slot-render.tsx @@ -4,27 +4,6 @@ import type { ContentRowPanelProps, } from "./slots"; -/** - * How the list mounts a component it was handed rather than one it declared. - * - * Two one-line components, and the indirection is the whole point. A screen that - * writes - * - * const { FormDialog } = contentAdminSlots(); - * return <FormDialog … />; - * - * names a component *inside a render body*, which is what `static-components` - * bans and what would remount the subtree on every render if the value really - * were created there. Taking the component as a **prop** and rendering it from a - * module-scope component says the same thing without the shape that reads as a - * mistake - the identical arrangement `buildContentTableColumns` uses for a - * plugin's cell override. - * - * Neither adds behaviour. They forward their props unchanged and render nothing - * when the slot is empty, which is a supported state: an application that has - * not imported the form module still gets a working list. - */ - /** The registered create/edit form dialog, wrapped around its own trigger. */ export const ContentFormDialogSlot = ({ dialog: Dialog, diff --git a/packages/vitnode/src/tanstack/admin/content/slots.test.ts b/packages/vitnode/src/tanstack/admin/content/slots.test.ts index a7e7f9995..cda58f142 100644 --- a/packages/vitnode/src/tanstack/admin/content/slots.test.ts +++ b/packages/vitnode/src/tanstack/admin/content/slots.test.ts @@ -8,34 +8,6 @@ import { setContentAdminSlots, } from "./slots"; -/** - * The one registry in this package that **merges** rather than replaces, and - * what that costs and buys across a module's lifetime. - * - * Every other runtime bridge here - `setAuthTransport`, `setAdminTransport`, - * `setContentFrontendRegistry`, `configureIntl` - holds one value from one - * registrar, so last-write-wins is the whole rule. This one is filled by two - * independent modules: the form module registers `FormDialog`, the editorial - * module registers `rowPanels`, and neither knows whether the other loaded. - * Replacing wholesale would mean whichever imported second silently erased the - * first, and which one that is depends on chunk order. - * - * So the merge is the feature, and the transitions below say exactly how far it - * goes: **per slot**, last write wins; **across slots**, nothing is lost. A hot - * reload of the editorial module therefore replaces its own panels with the new - * components - no stale closure over the previous module - while leaving the - * form module's dialog alone, which is the behaviour a dev server needs from it. - * - * ## The one thing it deliberately cannot do - * - * Un-register. A module that *stops* registering a panel leaves the previous one - * in place until the page reloads, because a merge has no way to express - * absence. That is stated here rather than fixed: adding a clear would let the - * two registrars erase each other again, and the case it would serve - deleting - * a panel and expecting it to vanish without a reload - is one a full reload - * already handles. Agent G's smoke list carries it. - */ - const panel = (name: string) => { const component = () => null; @@ -57,13 +29,6 @@ describe("before anything registers", () => { expect(contentAdminSlots()).toEqual({}); }); - /** - * A list screen is still a working screen with nothing registered: - * page-mode content types keep their create and edit pages, and every content - * type keeps publish and delete. What is *not* offered is an editorial action - * whose panel nobody registered - a menu entry that opens nothing is worse - * than an absent one. - */ it("offers no editorial action nobody can open", () => { expect(registeredContentRowPanels({})).toEqual([]); }); @@ -120,11 +85,6 @@ describe("two independent modules, in either order", () => { }); describe("the same module registering again after a hot reload", () => { - /** - * The stale-closure case. A re-evaluated module hands over new component - * objects, and the registry must be showing those rather than the ones the - * previous instance closed over. - */ it("replaces its own dialog with the newer component", () => { const before = dialog("before"); const after = dialog("after"); @@ -146,11 +106,6 @@ describe("the same module registering again after a hot reload", () => { expect(contentAdminSlots().rowPanels?.history).toBe(after); }); - /** - * A module that registers two panels and re-registers one of them keeps the - * other. Which is the same rule as across modules, one level down - the merge - * is per key, not per call. - */ it("replaces one panel without dropping its siblings", () => { const delivery = panel("delivery"); const history = panel("history-v2"); diff --git a/packages/vitnode/src/tanstack/admin/content/slots.ts b/packages/vitnode/src/tanstack/admin/content/slots.ts index 2d6b0f4c9..5eec6a766 100644 --- a/packages/vitnode/src/tanstack/admin/content/slots.ts +++ b/packages/vitnode/src/tanstack/admin/content/slots.ts @@ -4,47 +4,6 @@ import type { ContentRowData } from "@/views/admin/views/content/table/cells"; import { CONTENT_EDITORIAL_ACTION_IDS } from "@/views/admin/views/content/actions/row-actions-model"; -/** - * The two things the Content Engine **list** does not implement, and the seam it - * reaches them through. - * - * The list screen owns the table, the query, the URL contract, the create and - * edit entry points, the `⋯` menu itself and the three writes that live on a row - * - publish, unpublish and delete. It deliberately owns neither of these: - * - * FormDialog the create/edit form, for a dialog-mode content type - * rowPanels revision history, delivery, preview, scheduling - * - * Both are large features with their own mutations, their own conflict handling - * and their own screens, and a second copy of either would be behaviour that has - * to stay identical to something it cannot see. So the list states exactly what - * it needs - a trigger it can wrap, and a panel it can open by id - and nothing - * more. Which actions a row offers, in what order, and whether they fit as - * buttons is *not* delegated: that is `row-actions-model.ts`, shared with the - * Next.js AdminCP, so the two lists cannot drift. - * - * ## Why a module-scope registry rather than props - * - * The same reason `setContentFrontendRegistry` is one, one layer down. A content - * screen is reached through a single splat route that a host application owns, - * and threading components through it would put a decision about the *engine* - * into every application's route file - where a new capability means every host - * edits a file it should never have had to open. - * - * Module scope means *per bundle*: the browser has one instance and the server - * has one, and each registers its own. Nothing per-request or per-administrator - * is stored here, which is what makes a module-level value safe on a server - * rendering many at once. - * - * ## Absent is a supported state, and it is visible in the menu - * - * An unregistered slot renders nothing rather than throwing, and an editorial - * action whose panel nobody registered is **not offered** - a menu entry that - * opens nothing is worse than an absent one. The list is still a working screen - * without either: page-mode content types keep their create and edit pages, and - * every content type keeps publish and delete. - */ - /** Everything a form dialog is told about what it is editing. */ export interface ContentFormDialogProps { action: "create" | "edit"; @@ -60,14 +19,6 @@ export interface ContentFormDialogProps { title?: string; } -/** - * Everything one editorial panel is told, including how to close. - * - * The three control props are the row menu's, not the panel's: the menu decides - * which panel is open, and `finalFocus` is where the keyboard goes when it - * closes - the button that opened it, which is the only element still on screen - * that the person was looking at. - */ export interface ContentRowPanelProps { /** The version the row is at now, for a restore's precondition. */ currentVersion: number; @@ -94,15 +45,6 @@ export interface ContentAdminSlots { let registered: ContentAdminSlots = {}; -/** - * Registers what the list may mount, at module scope. - * - * Merged rather than replaced - `rowPanels` included - so the form module and - * the editorial module can register independently and in either order, which is - * what lets them be separate imports at all. Registering the same slot twice - * replaces that one, because a hot reload re-evaluates the module and the newer - * component is the right answer. - */ export const setContentAdminSlots = (slots: ContentAdminSlots): void => { registered = { ...registered, @@ -116,13 +58,6 @@ export const setContentAdminSlots = (slots: ContentAdminSlots): void => { /** What is registered right now. Empty is a supported state. */ export const contentAdminSlots = (): ContentAdminSlots => registered; -/** - * The editorial actions this host can actually open, for the shared model's - * `renderable` list. - * - * `delete` is never in here and never needs to be: the list implements it - * itself, so it is always renderable and the caller adds it. - */ export const registeredContentRowPanels = ( slots: ContentAdminSlots = registered, ): ContentEditorialActionId[] => diff --git a/packages/vitnode/src/tanstack/admin/cron/index.ts b/packages/vitnode/src/tanstack/admin/cron/index.ts index 9faea4160..3293e3512 100644 --- a/packages/vitnode/src/tanstack/admin/cron/index.ts +++ b/packages/vitnode/src/tanstack/admin/cron/index.ts @@ -1,19 +1,5 @@ export type { AdminTableNavigate } from "../table-search"; -/** - * `/admin/core/advanced/cron` - the AdminCP cron list, for a TanStack Start host. - * - * Three modules behind one specifier: - * - * ./query the cache contract - one query definition, one invalidation - * family, and the run that decides when to use it - * ./route the screen: namespaces, permission, loader, component - * ./server the SSR transport, reached only through `./query`'s isomorphic - * function and never imported from a browser bundle - * - * The rendering is not here and does not belong here: `CronTableContent` is - * framework-free and is imported from `@/views/admin/views/core/advanced/cron` - * by both applications. - */ + export { cronQuery, invalidateCron, diff --git a/packages/vitnode/src/tanstack/admin/cron/route-search.ts b/packages/vitnode/src/tanstack/admin/cron/route-search.ts index 4e67185b9..329d6f4e7 100644 --- a/packages/vitnode/src/tanstack/admin/cron/route-search.ts +++ b/packages/vitnode/src/tanstack/admin/cron/route-search.ts @@ -17,20 +17,6 @@ import { normalizeAdminTableSearch, } from "../table-search"; -/** - * The cron list's URL contract - the shared admin-table one, with this screen's - * declaration already applied. - * - * Bound here rather than at the route so a host writes `validateSearch: - * normalizeCronRouteSearch` instead of restating which columns this table sorts - * by. Two applications naming the same contract twice is exactly how they end up - * naming it differently. - * - * Every rule these four functions apply - the defaults, the clamping, what an - * unusable value falls back to, and why they are total and idempotent - is - * `tanstack/admin/table-search.ts`'. - */ - export type CronRouteSearch = AdminTableRouteSearch<CronOrderBy>; export type UncheckedCronSearch = UncheckedAdminTableSearch<CronOrderBy>; diff --git a/packages/vitnode/src/tanstack/admin/cron/route.tsx b/packages/vitnode/src/tanstack/admin/cron/route.tsx index f3546d092..cd8e47b32 100644 --- a/packages/vitnode/src/tanstack/admin/cron/route.tsx +++ b/packages/vitnode/src/tanstack/admin/cron/route.tsx @@ -8,32 +8,6 @@ import { intlQueryOptions } from "../../i18n/query"; import { requireAdminPermission } from "../screen"; import { cronQuery } from "./query"; -/** - * `/admin/core/advanced/cron`, as everything a TanStack Start route needs and - * nothing a route owns. - * - * The topology - the file's path, its search contract and its `navigate` - stays - * in the host, because TanStack infers all three from `createFileRoute`. - * Everything else is here: the namespaces, the permission, the query, the title - * and the table. - */ - -/** - * What this screen renders strings from. - * - * `admin.advanced.cron` is the heading, the columns and the run button; - * `core.global` is the rest of the table - the pager's labels, the confirm - * dialog's buttons and the error toasts - and it is listed even though the root - * provides it, because `RouteMessages` mounts its own provider *over* the root's - * rather than adding to it. - * - * The same set `<I18nProvider namespaces={["admin.advanced.cron"]}>` provides in - * the Next.js page, which always adds `core.global` itself. - * - * One list, read by both the loader that fetches it and the provider that mounts - * it, because they have to be the same set or the provider suspends on a key - * nobody warmed. - */ export const ADMIN_CRON_NAMESPACES = [ "admin.advanced.cron", "core.global", @@ -46,43 +20,11 @@ export interface AdminCronRouteData { title: string; } -/** - * The permission this screen needs, on top of an admin session. - * - * `cron.can_view`, which is the tuple `<AdminPermissionRequired module="cron" - * permission="can_view">` states in the Next.js page and the tuple - * `getCronsRoute` declares as its `adminStaffPermission`. All three have to be - * the same, and this is the frontend's copy of it. - */ const CRON_VIEW_PERMISSION = { module: "cron", permission: "can_view", } as const; -/** - * Both reads this screen needs, in parallel, before it renders. - * - * The permission is checked *first*, before either read is started: an - * administrator who may not open this screen never sends a request the API is - * going to refuse, and no admin markup is streamed for a page that is about to - * be replaced by the AdminCP's 404. - * - * Neither call is repeated by the component: the messages are read back by - * `RouteMessages` through the identical `intlQueryOptions`, and the page by - * `useSuspenseQuery` through the identical `cronQuery`. - * - * A refusal from the cron API is deliberately left to propagate. `403` and `429` - * reject as `AdminRequestError`, which fails this loader and shows the router's - * error path - the honest answer. Catching it and rendering an empty table is - * indistinguishable from an installation with no cron jobs, which is the one - * thing this must never look like. - * - * The cast on `messages` is what makes `createTranslator` usable: its key type is - * derived from the *inferred* type of `messages`, and a bare index signature - * collapses `MessageKeys` to `never`. Naming the two keys read here is both the - * smallest fix and a true statement - rename either in `locales/en.json` and - * this stops compiling rather than rendering a raw key into a `<title>`. - */ export const loadAdminCronRoute = async ({ adminAccess, locale, diff --git a/packages/vitnode/src/tanstack/admin/cron/screen.tsx b/packages/vitnode/src/tanstack/admin/cron/screen.tsx index e7021ccd9..9a5490a39 100644 --- a/packages/vitnode/src/tanstack/admin/cron/screen.tsx +++ b/packages/vitnode/src/tanstack/admin/cron/screen.tsx @@ -23,17 +23,6 @@ export interface AdminCronRouteProps extends AdminCronRouteData { search: UncheckedCronSearch; } -/** - * `/admin/core/advanced/cron`, as everything below a route file's `component`. - * - * `navigate` and `search` come from the host because they are route-typed: - * TanStack infers both from the `createFileRoute` path, which is an application - * concern and stays in the application. - * - * The heading is outside the table on purpose, exactly as in the Next.js page: - * it is rendered from the loader's own strings, so the `<h1>` and the `<title>` - * are the same string by construction. - */ export const AdminCronRouteContent = ({ description, navigate, diff --git a/packages/vitnode/src/tanstack/admin/dashboard/index.ts b/packages/vitnode/src/tanstack/admin/dashboard/index.ts index cd702dee2..8966ddcf8 100644 --- a/packages/vitnode/src/tanstack/admin/dashboard/index.ts +++ b/packages/vitnode/src/tanstack/admin/dashboard/index.ts @@ -1,17 +1,3 @@ -/** - * `/admin/core` - the AdminCP dashboard, for a TanStack Start host. - * - * ./query the layout query, and the four actions the board performs - * ./route the screen: namespaces, loader, component - * ./server the SSR transport, reached only through `./query` - * ./widgets core's widgets as a *browser* can render them - * - * The board itself - the grid, the drag-and-drop, the widget panel, the settings - * dialog - is framework-free and imported from - * `@/views/admin/views/core/dashboard` by both applications. What differs is one - * object: `DashboardActions`, whose Next.js half is `DashboardBoardProviderNext` - * and whose TanStack half is `useDashboardActions`. - */ export { dashboardLayoutQuery, invalidateDashboardLayout, diff --git a/packages/vitnode/src/tanstack/admin/dashboard/query.tsx b/packages/vitnode/src/tanstack/admin/dashboard/query.tsx index 7e649d228..301cd03cd 100644 --- a/packages/vitnode/src/tanstack/admin/dashboard/query.tsx +++ b/packages/vitnode/src/tanstack/admin/dashboard/query.tsx @@ -24,41 +24,16 @@ import type { AdminIdentity } from "../identity"; import { useAdminIdentity } from "../identity"; import { fetchDashboardLayoutOnServer } from "./server"; -/** - * The dashboard for a TanStack Start host: the layout query, and the four - * actions the board performs. - * - * The transport boundary is the same one every AdminCP read uses - both branches - * call Hono directly, and the admin cookie travels on both. See - * `tanstack/admin/cron/query.ts` for the full argument. - */ const fetchDashboardLayout: DashboardLayoutFetcher = createIsomorphicFn() .server(fetchDashboardLayoutOnServer) .client(fetchDashboardLayoutInBrowser); -/** - * The stored layout, as the one query definition every caller shares. - * - * `adminUserId` travels with it because the entry is partitioned by identity - - * one board per administrator, one cache entry per administrator. The loader - * takes it from the session the guard already resolved; a component takes it - * from {@link useAdminIdentity}, which reads that same entry, so the two cannot - * disagree about whose board they are looking at. - */ export const dashboardLayoutQuery = (adminUserId: AdminIdentity) => dashboardLayoutQueryOptions({ adminUserId, fetchLayout: fetchDashboardLayout, }); -/** - * Marks the stored layout stale, so the board re-reads what a save changed. - * - * One entry rather than a family: there is exactly one layout per administrator - * and one cache entry for it. It is scoped rather than global - a dashboard save - * has not changed the cron list, and refetching everything because a card moved - * is the blunt version of the `revalidatePath` this replaces. - */ export const invalidateDashboardLayout = async ( queryClient: QueryClient, adminUserId: AdminIdentity, @@ -67,46 +42,11 @@ export const invalidateDashboardLayout = async ( queryKey: dashboardLayoutQueryKey(adminUserId), }); -/** - * The four board actions, bound to the mounted router's cache and to the widget - * catalogue this browser can render. - * - * The TanStack half of the pair whose other half is `DashboardBoardProviderNext`. - * Both satisfy `DashboardActions`, which is what lets one board be driven by - * either framework. - * - * ## The two loads render here rather than on a server - * - * In Next.js "refresh this widget" is necessarily a server round trip: a - * widget's `component` may be a Server Component, so only its *output* can cross - * back. A browser has the component, so all it needs is the settings - and it - * re-reads those through `fetchQuery` on the same layout entry, which is a - * request only when the entry is stale. The result is the same promise-of-a-node - * the board already knows how to hold. - * - * `fetchQuery` and not `getQueryData`: the settings dialog calls this - * immediately after saving, and the point of the call is to see what was saved. - * Reading whatever happens to be cached would render the widget against the - * settings it already had. - * - * ## Only a successful save refreshes - * - * A refused layout save leaves the stored board as it was, and invalidating - * would replace the working copy the administrator is still being told about - - * the same rule the Next.js action applies before `revalidatePath`. - */ export const useDashboardActions = ( widgets: ResolvedDashboardWidget[], ): DashboardActions => { const queryClient = useQueryClient(); - /** - * Whose board these actions read and write. - * - * From the `["vitnode","admin-session"]` entry the guard refreshes on every - * navigation, so an identity that changed under this tab - a second - * administrator signing in elsewhere in the browser - moves these actions onto - * the new key rather than leaving them writing into the previous one's row. - */ + const adminUserId = useAdminIdentity(); return React.useMemo<DashboardActions>(() => { diff --git a/packages/vitnode/src/tanstack/admin/dashboard/route.tsx b/packages/vitnode/src/tanstack/admin/dashboard/route.tsx index 1c7ed831d..b9f70303f 100644 --- a/packages/vitnode/src/tanstack/admin/dashboard/route.tsx +++ b/packages/vitnode/src/tanstack/admin/dashboard/route.tsx @@ -9,61 +9,17 @@ import { dashboardLayoutQuery } from "./query"; * needs and nothing a route owns. */ -/** - * What this screen renders strings from. - * - * `admin.dashboard` is the heading, the widget panel, the edit controls and both - * core widgets. `admin.global` is there for one key - `admin.global.nav.core`, - * which is what a widget with no category of its own is filed under - and it has - * to be listed even though the shell warms it, because `RouteMessages` mounts - * its provider *over* the root's rather than adding to it. `core.global` is the - * form chrome the settings dialog renders inside. - * - * A host that passes plugin widgets must add those plugins' namespaces: a - * widget's title is `<pluginId>.admin.dashboard.widgets.<id>.title`, and an - * unwarmed namespace is a missing title rather than a missing widget. - */ export const ADMIN_DASHBOARD_NAMESPACES = [ "admin.dashboard", "admin.global", "core.global", ] as const; -/** - * What {@link loadAdminDashboardRoute} returns. - * - * No `adminUserId`, deliberately, even though the loader resolves one to warm - * the right cache entry. The host spreads this straight into the screen's props, - * so anything returned here is a prop - and the identity is not one: the screen - * and its actions both read it from `useAdminIdentity`, which is the same - * `["vitnode","admin-session"]` entry the loader derived it from. One source, - * and no way for a prop to drift from the entry it was supposed to name. - */ export interface AdminDashboardRouteData { /** The running VitNode version, or `undefined` outside a granted session. */ vitnodeVersion?: string; } -/** - * Both reads the dashboard needs, in parallel, before it renders. - * - * **No permission check, and that is the current tuple rather than an - * oversight.** The Next.js page wraps nothing in `<AdminPermissionRequired>`: - * the board reads `dashboard.can_view` through the API and falls back to the - * default layout when that read is refused, so every administrator who can enter - * the AdminCP can see its landing screen. `_admin`'s guard is the whole of the - * access rule here. - * - * **And no title.** The Next.js page exports no `generateMetadata`, so the tab - * keeps the site's own name; returning one here would render "VitNode - VitNode". - * A route that says nothing inherits, which is the parity-preserving answer. - * - * The version is read off the admin session the guard already resolved - the - * same value the Next.js `DashboardVersion` fetches with `getSessionAdminApi()`, - * without the second request or the `<Suspense>` that existed to hide it. It is - * returned raw and formatted in the component, so the sentence around it is - * translated by the provider that has this route's namespaces. - */ export const loadAdminDashboardRoute = async ({ adminAccess, locale, diff --git a/packages/vitnode/src/tanstack/admin/dashboard/screen.tsx b/packages/vitnode/src/tanstack/admin/dashboard/screen.tsx index a3576511c..984241616 100644 --- a/packages/vitnode/src/tanstack/admin/dashboard/screen.tsx +++ b/packages/vitnode/src/tanstack/admin/dashboard/screen.tsx @@ -35,26 +35,9 @@ export interface DashboardPluginWidgets { } export interface AdminDashboardRouteProps extends AdminDashboardRouteData { - /** - * The widgets this browser can render, on top of core's own. - * - * A plugin's widgets reach the Next.js board through `getVitNodeConfig()`, - * which a host keeps out of its browser bundle - so they arrive here instead, - * from whatever browser-side registry the host has. The same seam - * `AdminShellContent` leaves open for nav `declarations`, and empty for the - * same reason in Stage 12. - */ pluginWidgets?: DashboardPluginWidgets[]; } -/** - * `/admin/core`, as everything below a route file's `component`. - * - * The provider is mounted by {@link AdminDashboardBoard}, one component down, - * and that nesting is load-bearing: every hook it calls reads *this route's* - * namespaces, and a hook called here would run above the provider that supplies - * them. - */ export const AdminDashboardRouteContent = ({ pluginWidgets = [], vitnodeVersion, @@ -69,20 +52,6 @@ export const AdminDashboardRouteContent = ({ </RouteMessages> ); -/** - * The board, assembled in the browser. - * - * Assembled *here* rather than in a loader because two of its three inputs are - * React state: the widget list needs a translator and this administrator's - * permission set, both of which are context, and the content is elements. - * `buildDashboardBoard` and `resolveDashboardWidgets` are the same two functions - * the Next.js Server Component runs - only where they run differs. - * - * `useDashboardActions` is the TanStack half of `DashboardBoardProviderNext`: - * both produce a `DashboardActions`, which is what lets the provider, the grid, - * the panel and the settings dialog below be the same components in both - * applications. - */ const AdminDashboardBoard = ({ pluginWidgets, vitnodeVersion, @@ -90,22 +59,11 @@ const AdminDashboardBoard = ({ pluginWidgets: DashboardPluginWidgets[]; vitnodeVersion?: string; }) => { - /** - * Whose board this is, from the same session entry the loader read it from - - * so `useSuspenseQuery` looks up the key `loadAdminDashboardRoute` warmed - * rather than a second one, and an identity that changed under this tab moves - * the lookup instead of re-rendering the previous administrator's layout. - */ const adminUserId = useAdminIdentity(); const { data: saved } = useSuspenseQuery(dashboardLayoutQuery(adminUserId)); const permissions = useAdminPermissions(); const t = useTranslations("admin.dashboard"); - /** - * The same provider, read without a namespace, because widget titles are - * built from a plugin id at runtime and so cannot be typed against the message - * tree - which is exactly what the Next.js resolver's `@ts-expect-error`s said - * about the identical lookups. - */ + const tAll = useTranslations() as unknown as DashboardWidgetTranslator; const widgets = React.useMemo( diff --git a/packages/vitnode/src/tanstack/admin/dashboard/widgets.tsx b/packages/vitnode/src/tanstack/admin/dashboard/widgets.tsx index 151d3cfd2..11a29c9dd 100644 --- a/packages/vitnode/src/tanstack/admin/dashboard/widgets.tsx +++ b/packages/vitnode/src/tanstack/admin/dashboard/widgets.tsx @@ -13,50 +13,6 @@ import { SendNotificationSettings } from "@/views/admin/views/core/dashboard/wid import { useAdminUser } from "../permissions"; -/** - * Core's dashboard widgets, as a browser can render them. - * - * The only registry now, and the widgets it lists are the ones core ships. It - * used to be the browser half of a pair: a Next.js registry beside the widgets - * held the same two entries, differing only in that its `SendNotificationWidget` - * was an async Server Component reading the request scope, which a browser has - * none of. That half is gone along with the rest of that host. - * - * ## Why the list is duplicated rather than shared - * - * A widget's `component` may legitimately be a Server Component in Next.js - - * that is the whole point of rendering the board on the server there - and a - * Server Component cannot be rendered by a browser at all. So the *registry* is - * per-runtime while the widgets themselves are not: `NotesWidget` is the - * identical component in both, and `SendNotificationAction` - the actual form - - * is shared too. What differs is nine lines of how the default user id is - * obtained. - * - * ## Plugins are not here yet - * - * A plugin's widgets reach the Next.js board through `getVitNodeConfig()`, which - * is server-side config a host deliberately keeps out of its browser bundle - * (`vitnode.shell.config.ts`). So a TanStack Start host passes its own browser - * registry - the same seam `AdminShellContent` leaves open for nav - * `declarations` - and until it has one, the board shows core's widgets. A host - * that does pass plugin widgets must also warm those plugins' message - * namespaces, because a widget's title is `<pluginId>.admin.dashboard.widgets.<id>.title`. - */ - -/** - * The send-notification widget, with the signed-in administrator as the default - * recipient. - * - * `useAdminUser` reads the one admin session query the `_admin` guard already - * filled, so this is a context read rather than a request - the same value - * `getSessionAdminApi()` gives the Server Component version, from the same - * session. - * - * `null` when there is no admin user, exactly as the server version returns - * `null` for a missing session. It cannot happen below the guard, and a widget - * that rendered a "send to user #NaN" form if it ever did would be worse than - * one that renders nothing. - */ const SendNotificationBrowserWidget = ({ settings, }: AdminDashboardWidgetProps) => { @@ -73,12 +29,6 @@ const SendNotificationBrowserWidget = ({ ); }; -/** - * The browser registry, declared to match `coreDashboardWidgets` field for - * field - the ids, the icons, the default spans and `allowMultiple` are what the - * *stored* layout refers to, so a difference here would silently orphan an - * administrator's arrangement. - */ export const coreDashboardBrowserWidgets: AdminDashboardWidget[] = [ { id: "notes", diff --git a/packages/vitnode/src/tanstack/admin/debug/index.ts b/packages/vitnode/src/tanstack/admin/debug/index.ts index 4500ce1e6..042ad6b49 100644 --- a/packages/vitnode/src/tanstack/admin/debug/index.ts +++ b/packages/vitnode/src/tanstack/admin/debug/index.ts @@ -1,16 +1,3 @@ -/** - * `/admin/core/debug` - the debug panel, for a TanStack Start host. - * - * ./query two query definitions, and `clearAdminCache` - the one - * action on this screen with no endpoint behind it - * ./route-search the URL contract, which is the system log's - * ./route the screen: namespaces, permission, loader, component - * ./server the SSR transport, reached only through `./query` - * - * `SystemLogsContent`, `QueueViewContent` and `ClearCacheAction` are - * framework-free and imported from `@/views/admin/views/core/debug` by both - * applications. - */ export { clearAdminCache, debugLogsQuery, diff --git a/packages/vitnode/src/tanstack/admin/debug/query.ts b/packages/vitnode/src/tanstack/admin/debug/query.ts index feb5335e3..ac84c7177 100644 --- a/packages/vitnode/src/tanstack/admin/debug/query.ts +++ b/packages/vitnode/src/tanstack/admin/debug/query.ts @@ -21,15 +21,6 @@ import { import { fetchDebugLogsPageOnServer, fetchDebugQueueOnServer } from "./server"; -/** - * The debug panel for a TanStack Start host: two query definitions, and the one - * action that has no endpoint at all. - * - * The transport boundary is the same one every AdminCP read uses - both branches - * call Hono directly, and the admin cookie travels on both. See - * `tanstack/admin/cron/query.ts` for the full argument, including why each - * chained call has to be written out rather than hidden behind a helper. - */ const fetchDebugLogsPage: DebugLogsPageFetcher = createIsomorphicFn() .server(fetchDebugLogsPageOnServer) .client(fetchDebugLogsPageInBrowser); @@ -38,12 +29,6 @@ const fetchDebugQueue: DebugQueueFetcher = createIsomorphicFn() .server(fetchDebugQueueOnServer) .client(fetchDebugQueueInBrowser); -/** - * The system log, as the one query definition every caller shares. - * - * `params` must be the *normalised* ones, because the cache key is built from - * them. - */ export const debugLogsQuery = ({ params }: { params: DebugLogsParams }) => debugLogsQueryOptions({ fetchPage: fetchDebugLogsPage, params }); @@ -51,32 +36,6 @@ export const debugLogsQuery = ({ params }: { params: DebugLogsParams }) => export const debugQueueQuery = () => debugQueueQueryOptions({ fetchSnapshot: fetchDebugQueue }); -/** - * "Clear the cache", as a TanStack Start application can mean it. - * - * The Next.js server action is `revalidatePath("/", "layout")` - it has no API - * call in it, because what it clears is the *framework's* cache rather than - * anything on the server. The equivalent here is the pair of caches this - * application actually has: - * - * queryClient.invalidateQueries() every cached read, marked stale - * router.invalidate() every matched route's loader, re-run - * - * **Invalidate, not clear.** `queryClient.clear()` deletes entries outright, - * including `["vitnode","admin-session"]` - which the permission provider reads - * with `useSuspenseQuery`, so clearing it suspends the whole AdminCP shell on - * the frame after the button is pressed. Invalidation keeps every value on - * screen while it is refetched, which is both correct and what `revalidatePath` - * does. - * - * It is scoped to no key on purpose, and this is the one place in the AdminCP - * where that is right: the button's entire meaning is "everything you are - * holding may be wrong". Every *other* mutation invalidates its own family - see - * `invalidateCron`, `invalidateAdminFiles`, `invalidateSearchIndex`. - * - * Rejects rather than returning an error, matching the server action it stands - * in for: `ClearCacheAction` catches and shows the same toast either way. - */ export const clearAdminCache = async ( queryClient: QueryClient, router: AnyRouter, @@ -85,12 +44,6 @@ export const clearAdminCache = async ( await router.invalidate(); }; -/** - * `clearAdminCache`, bound to the mounted router and its cache. - * - * Memoised so the confirm dialog's submit handler keeps one identity across the - * re-renders an invalidation causes. - */ export const useClearAdminCache = (): (() => Promise<void>) => { const queryClient = useQueryClient(); const router = useRouter(); diff --git a/packages/vitnode/src/tanstack/admin/debug/route-search.ts b/packages/vitnode/src/tanstack/admin/debug/route-search.ts index 0dec4c263..f05727ecb 100644 --- a/packages/vitnode/src/tanstack/admin/debug/route-search.ts +++ b/packages/vitnode/src/tanstack/admin/debug/route-search.ts @@ -17,15 +17,6 @@ import { normalizeAdminTableSearch, } from "../table-search"; -/** - * The debug panel's URL contract - the shared admin-table one, applied to the - * system log. - * - * The log table is the only thing on this screen with URL state: the queue - * snapshot has no pager and no sort, and the clear-cache button writes nothing. - * So the screen's search *is* the log table's. - */ - export type DebugRouteSearch = AdminTableRouteSearch<DebugLogsOrderBy>; export type UncheckedDebugSearch = UncheckedAdminTableSearch<DebugLogsOrderBy>; diff --git a/packages/vitnode/src/tanstack/admin/debug/route.tsx b/packages/vitnode/src/tanstack/admin/debug/route.tsx index 067d7c96c..709f6073b 100644 --- a/packages/vitnode/src/tanstack/admin/debug/route.tsx +++ b/packages/vitnode/src/tanstack/admin/debug/route.tsx @@ -13,17 +13,6 @@ import { debugLogsQuery, debugQueueQuery } from "./query"; * route owns. */ -/** - * What this screen renders strings from. - * - * `admin.debug` is the heading, the clear-cache dialog, the queue counters and - * every column of the log; `admin.advanced.queue` is there for one component - - * `QueueStatusBadge`, which the queue snapshot reuses from the queue list and - * which reads `admin.advanced.queue.status.*`. `core.global` is the table - * chrome. Exactly the set the Next.js page's - * `<I18nProvider namespaces={["admin.debug", "admin.advanced.queue"]}>` - * provides, which always adds `core.global` itself. - */ export const ADMIN_DEBUG_NAMESPACES = [ "admin.advanced.queue", "admin.debug", @@ -42,34 +31,11 @@ export interface AdminDebugRouteData { /** The core plugin's `debug` module - both tuples on this screen use it. */ export const DEBUG_MODULE = "debug"; -/** - * The tuple both `<AdminPermissionRequired>` wrappers state in the Next.js page, - * and the one `logsDebugAdminRoute` and `queueDebugAdminRoute` both declare. - * - * It gates the *screen*, not a section: in Next.js either wrapper answering "no" - * calls `notFound()`, which replaces the whole page. Checking it once in the - * loader is the same rule stated where it can be acted on before anything is - * fetched. - */ const DEBUG_VIEW_PERMISSION = { module: DEBUG_MODULE, permission: "can_view", } as const; -/** - * All three reads this screen needs, in parallel, before it renders. - * - * The permission is checked first, so an administrator who may not open the - * panel never sends either request. - * - * Both API refusals are left to propagate. An errored log rendered as an empty - * table says "nothing has gone wrong", which on the one screen an operator opens - * *because* something has gone wrong is the worst available answer. - * - * The two section headings are resolved here alongside the page title, so every - * string on the screen comes from one translator call per navigation - which is - * what `getTranslations` gives the Next.js page for free. - */ export const loadAdminDebugRoute = async ({ adminAccess, locale, diff --git a/packages/vitnode/src/tanstack/admin/debug/screen.tsx b/packages/vitnode/src/tanstack/admin/debug/screen.tsx index 6899433e3..a53e412fc 100644 --- a/packages/vitnode/src/tanstack/admin/debug/screen.tsx +++ b/packages/vitnode/src/tanstack/admin/debug/screen.tsx @@ -26,30 +26,11 @@ import { DEBUG_MODULE } from "./route"; import { debugSearchFrom, debugSearchParams } from "./route-search"; export interface AdminDebugRouteProps extends AdminDebugRouteData { - /** - * How the log's detail dialog links to the user who caused a line. - * - * Defaults to the router's own `Link`, which is the right answer whenever - * `/admin/core/users/{id}` is a route in the host's tree - and it is. - */ LinkComponent?: AuthLinkComponent; navigate: AdminTableNavigate<DebugRouteSearch>; search: UncheckedDebugSearch; } -/** - * `/admin/core/debug`, as everything below a route file's `component`. - * - * The same three sections the Next.js page has, in the same order, with one - * structural difference: there is no `<Suspense>` around the queue snapshot or - * the log, because the loader has already fetched both. The Next.js page streams - * them in behind skeletons; here they are in the cache before the component - * mounts, so a boundary would be an admission the data is not there yet. - * - * The clear-cache button keeps its gate. `AdminPermissionGate` is the same - * component the Next.js page mounts, reading the same permission set - and, as - * always, hiding a control rather than authorizing one. - */ export const AdminDebugRouteContent = ({ description, LinkComponent = RouterLink, diff --git a/packages/vitnode/src/tanstack/admin/files/index.ts b/packages/vitnode/src/tanstack/admin/files/index.ts index 0c4682503..6c52fffd6 100644 --- a/packages/vitnode/src/tanstack/admin/files/index.ts +++ b/packages/vitnode/src/tanstack/admin/files/index.ts @@ -1,16 +1,3 @@ -/** - * `/admin/core/system/files` - every file uploaded to the installation, for a - * TanStack Start host. - * - * ./query one query definition, one invalidation family, two deletes - * ./route-search the URL contract, search box included - * ./route the screen: namespaces, permissions, loader, component - * ./server the SSR transport, reached only through `./query` - * - * Not to be confused with `@vitnode/core/tanstack/files`, which is the *visitor's - * own* files at `/files`: a different endpoint, a different permission and a - * different cache family. - */ export { adminFilesQuery, deleteAdminFile, diff --git a/packages/vitnode/src/tanstack/admin/files/query.ts b/packages/vitnode/src/tanstack/admin/files/query.ts index 5a7c90722..07c13f5b5 100644 --- a/packages/vitnode/src/tanstack/admin/files/query.ts +++ b/packages/vitnode/src/tanstack/admin/files/query.ts @@ -30,62 +30,18 @@ import { import { fetchAdminFilesPageOnServer } from "./server"; -/** - * The AdminCP file list for a TanStack Start host: one query definition and two - * deletes. - * - * Everything about *what* the list is comes from - * `@/views/admin/views/core/system/files/files-query`, which is also what the - * mounted `FilesTableContent` renders from. This module supplies the two things - * that module cannot know: how to reach the API from a server that is rendering - * a request, and what "refresh the table" means in a router that has a query - * cache instead of `revalidatePath`. - */ - -/** - * The transport boundary. Both branches call Hono directly - the server one from - * inside the request being rendered, the browser one over the network to the - * same origin - and the admin cookie travels on both. See - * `tanstack/admin/cron/query.ts` for the full argument, including why the - * chained call is written out per feature rather than hidden behind a helper. - */ const fetchAdminFilesPage: AdminFilesPageFetcher = createIsomorphicFn() .server(fetchAdminFilesPageOnServer) .client(fetchAdminFilesPageInBrowser); -/** - * The admin file list, as the one query definition every caller shares. - * - * `params` must be the *normalised* ones, because the cache key is built from - * them. - */ export const adminFilesQuery = ({ params }: { params: AdminFilesParams }) => adminFilesQueryOptions({ fetchPage: fetchAdminFilesPage, params }); -/** - * Marks every cached page of the admin file list stale. - * - * The whole family, by prefix - not the one page on screen. A delete changes - * which rows exist, so every other page, sort and search of the same list is now - * wrong too, and the administrator reaches those by pressing a button that reads - * from the cache. - * - * Invalidating rather than removing keeps the current rows on screen while the - * fresh ones are fetched, instead of blanking the table under the dialog that is - * still open. - */ export const invalidateAdminFiles = async ( queryClient: QueryClient, ): Promise<void> => await queryClient.invalidateQueries({ queryKey: adminFilesQueryRoot }); -/** - * Deletes one file, then refreshes the table if it actually went. - * - * Only on success. A `409` left the file exactly where it was and the dialog is - * still open offering to force past the revisions holding it; refetching - * underneath that would replace the rows the administrator is being asked about. - */ export const deleteAdminFile = async ( queryClient: QueryClient, args: DeleteAdminFileArgs, @@ -97,14 +53,6 @@ export const deleteAdminFile = async ( return result; }; -/** - * Deletes a selection, then refreshes the table if anything went. - * - * `shouldRefreshAfterBulkDelete` is the shared rule, and the same one the - * Next.js server action applies before it calls `revalidatePath`: a run that - * deleted nothing leaves the page as it was, and refetching would drop the - * selection that is showing which rows were kept. - */ export const deleteAdminFiles = async ( queryClient: QueryClient, args: DeleteAdminFilesArgs, @@ -118,14 +66,6 @@ export const deleteAdminFiles = async ( return result; }; -/** - * The two callbacks `FilesTableContent` takes, bound to the mounted router's - * cache. - * - * Memoised, which is the only reason this is a hook rather than two calls at the - * point of use: they are props on a table that re-renders on every navigation, - * and new function identities would remount the confirm dialogs mid-delete. - */ export const useAdminFilesDeleteCallbacks = (): { onDeleteFile: DeleteAdminFile; onDeleteFiles: DeleteAdminFiles; diff --git a/packages/vitnode/src/tanstack/admin/files/route-search.ts b/packages/vitnode/src/tanstack/admin/files/route-search.ts index 87d1041e1..18bdb344e 100644 --- a/packages/vitnode/src/tanstack/admin/files/route-search.ts +++ b/packages/vitnode/src/tanstack/admin/files/route-search.ts @@ -17,12 +17,6 @@ import { normalizeAdminTableSearch, } from "../table-search"; -/** - * The uploaded-file list's URL contract - the shared admin-table one with this - * screen's declaration applied. It is the one admin table with a search box, so - * `?search=` is part of its contract and part of its cache key. - */ - export type AdminFilesRouteSearch = AdminTableRouteSearch<AdminFilesOrderBy>; export type UncheckedAdminFilesSearch = UncheckedAdminTableSearch<AdminFilesOrderBy>; diff --git a/packages/vitnode/src/tanstack/admin/files/route.tsx b/packages/vitnode/src/tanstack/admin/files/route.tsx index 1118030ad..2a7e11186 100644 --- a/packages/vitnode/src/tanstack/admin/files/route.tsx +++ b/packages/vitnode/src/tanstack/admin/files/route.tsx @@ -13,15 +13,6 @@ import { adminFilesQuery } from "./query"; * nothing a route owns. */ -/** - * What this screen renders strings from. - * - * `admin.system.files` is the heading, the columns, the metadata popover and - * every word of both delete dialogs; `core.global` is the rest of the table - - * the pager, the search placeholder, the confirm dialog's buttons and the error - * toasts. The same set the Next.js page's - * `<I18nProvider namespaces={["admin.system.files"]}>` provides. - */ export const ADMIN_FILES_NAMESPACES = [ "admin.system.files", "core.global", @@ -46,16 +37,6 @@ const FILES_VIEW_PERMISSION = { permission: "can_view", } as const; -/** - * Both reads this screen needs, in parallel, before it renders. - * - * The permission is checked first, so an administrator who may not open the - * screen never sends a request the API is going to refuse. A refusal from the - * files API is left to propagate rather than caught: the Next.js page answers - * one with `notFound()`, and the router's error path is the equivalent honest - * answer. Rendering an empty table instead would be indistinguishable from an - * installation with nothing uploaded. - */ export const loadAdminFilesRoute = async ({ adminAccess, locale, diff --git a/packages/vitnode/src/tanstack/admin/files/screen.tsx b/packages/vitnode/src/tanstack/admin/files/screen.tsx index 2f2afa5b0..1ce50e2ae 100644 --- a/packages/vitnode/src/tanstack/admin/files/screen.tsx +++ b/packages/vitnode/src/tanstack/admin/files/screen.tsx @@ -29,16 +29,6 @@ export interface AdminFilesRouteProps extends AdminFilesRouteData { search: UncheckedAdminFilesSearch; } -/** - * `/admin/core/system/files`, as everything below a route file's `component`. - * - * The two extra permissions are read here rather than in the table, from the - * same admin session the `_admin` guard already resolved - so this is a context - * read rather than two more requests, which is what the Next.js page spends on - * `checkAdminPermissionApi`. They decide which controls render; the API - * re-checks `files.can_download` and `files.can_delete` on the requests - * themselves. - */ export const AdminFilesRouteContent = ({ description, navigate, diff --git a/packages/vitnode/src/tanstack/admin/identity.ts b/packages/vitnode/src/tanstack/admin/identity.ts index eba591b53..7633f1a00 100644 --- a/packages/vitnode/src/tanstack/admin/identity.ts +++ b/packages/vitnode/src/tanstack/admin/identity.ts @@ -4,36 +4,9 @@ import type { AdminAccessState } from "./session-api"; import { useAdminAccess } from "./permissions"; -/** - * Which administrator a cached read belongs to. - * - * The permission-sensitive AdminCP screens - users, roles, both staff lists - - * answer differently depending on who is asking: `users:can_edit_admin` decides - * whether a row may be edited, and `self` on a staff entry is computed against - * the caller's own roles. Two administrators must not share a cache entry for - * one request, so every one of those keys carries this. - * - * ## It is a cache concern, and only a cache concern - * - * The id never reaches a fetcher. The AdminCP API derives who is asking from the - * `vitnode_auth_admin` cookie on every request, so a user id in a query string - * would be a second, weaker answer to a question the cookie already settles - - * and one a caller controls. See `views/admin/views/core/shared/admin-scope.ts`. - * - * `null` for a denial is a real partition rather than a missing one: it is the - * key a read with no granted session would use, and it must not collide with any - * administrator's. - */ export const adminIdentityOf = (access: AdminAccessState): AdminIdentity => access.status === "granted" ? access.session.user.id : null; -/** - * The same answer, for a component. - * - * Reads the one `["vitnode","admin-session"]` entry the guard already filled, so - * a component and the loader above it cannot disagree about whose cache entry - * they are looking at. - */ export const useAdminIdentity = (): AdminIdentity => adminIdentityOf(useAdminAccess()); diff --git a/packages/vitnode/src/tanstack/admin/index.ts b/packages/vitnode/src/tanstack/admin/index.ts index 15370a7f8..ab6714c57 100644 --- a/packages/vitnode/src/tanstack/admin/index.ts +++ b/packages/vitnode/src/tanstack/admin/index.ts @@ -1,79 +1,5 @@ -/** - * VitNode's AdminCP runtime for TanStack Start - `@vitnode/core/tanstack/admin`. - * - * Everything an application needs to guard, enter and render the AdminCP, minus - * the three things a package cannot own: its route tree, its server functions, - * and where a visitor goes next. - * - * ./state the admin session as route state: the access union, the - * one query key, the status policy, the permission - * predicate, and the removal that isolates two admins - * ./session-api the session's shape, read off the server-side read - * ./session-query the canonical ["vitnode","admin-session"] entry - one - * definition, read by the guard, the shell and every gate - * ./transport the one server function the host registers - * ./permissions the UI bridge: the existing AdminCP permission context, - * fed from that query and from nowhere else - * ./identity which administrator a cached read belongs to - * ./return-to is this target somewhere inside the AdminCP at all - * ./intl which strings the shell loads, and the locale note - * ./actions the admin sign-in, over the shared auth transport - * ./sign-in-route the `/admin` screen: loader, namespaces, component - * ./shell the panel `_admin` renders: providers, sidebar, header - * ./nav one navigation, read by the sidebar and the palette - * ./breadcrumb the trail, from each match's `staticData` - * ./search the command palette - * ./user-bar the user menu, and the sign-out that clears the cache - * ./queries every privileged AdminCP cache entry, and the one call - * an identity boundary makes to drop all of it - * ./not-found the AdminCP's 404 - * ./screen `requireAdminPermission`, and what a screen loader reads - * ./table-search the search contract every AdminCP list route validates - * - * `@vitnode/core/tanstack/admin/server` is the other half: the request-scoped - * call to the Hono admin API. It is a separate subpath because this barrel is - * imported by browser bundles and that one may never be. - * - * ## The security boundary is not here - * - * `_admin`'s guard is navigation and UX. Authorization stays where it already - * is: `api/config.ts` puts `globalAdminMiddleware()` in front of every request - * whose path contains `/admin/`, each handler re-checks the staff tables, and - * `SessionAdminModel.getUser()` re-runs `checkIfUserIsAdmin` on every request - - * deleting the session the moment the answer turns false. Nothing in this - * namespace may be relied on to keep anybody out of anything, and no admin - * endpoint asks the client who it is. - * - * ## What stays in the application - * - * - **`createServerFn`.** A host externalises this package from Vite's SSR pass, - * so package code reaches the server uncompiled - and an uncompiled server - * function silently resolves to `undefined` during SSR. The host declares one - * one-line wrapper over `./server` and registers it; see `./transport`. - * - **The route tree.** `/admin` is a leaf and `_admin` is a pathless shell, and - * which is which is route composition. This package hands the routes their - * decisions (`ensureAdminAccess`, `canEnterAdmin`, `sanitizeAdminReturnTo`) - * and never a `createFileRoute`. - * - **Navigation.** Every action and the shell itself take a `navigate`, and a - * `LinkComponent` that defaults to `RouterLink` - so a host supplies only what - * its own router setup makes different. - */ - export * from "./actions"; -/** - * The shell, and the navigation model it is built on. - * - * `AdminShellContent` is what a host's `_admin` route renders; everything else - * here exists because a route or a host needs to reach one part of it directly - - * a route declares a breadcrumb, a host with plugins registered in the browser - * passes navigation declarations, a screen reads the navigation it is inside. - * - * The navigation model itself is *not* re-exported from here as an - * implementation: it lives in `views/admin/layouts/sidebar/nav/nav-model`, - * framework-free, because the Next.js AdminCP reads it too. What crosses this - * barrel is the model's public vocabulary, so a TanStack host has one specifier - * to import rather than a deep path into `views/`. - */ + export { AdminBreadcrumb, adminBreadcrumb, diff --git a/packages/vitnode/src/tanstack/admin/integrations/index.ts b/packages/vitnode/src/tanstack/admin/integrations/index.ts index 75f678a67..d9187e2b3 100644 --- a/packages/vitnode/src/tanstack/admin/integrations/index.ts +++ b/packages/vitnode/src/tanstack/admin/integrations/index.ts @@ -1,15 +1,3 @@ -/** - * `/admin/core/system/integrations` - which of VitNode's integrations are - * configured and running, for a TanStack Start host. - * - * ./query one query definition, plus the invalidation an installation-level - * change elsewhere should call - * ./route the screen: namespaces, permissions, loader, component - * ./server the SSR transport, reached only through `./query` - * - * `IntegrationsContent` is framework-free and imported from - * `@/views/admin/views/core/system/integrations` by both applications. - */ export { integrationsQuery, invalidateIntegrations } from "./query"; export type { AdminIntegrationsRouteData } from "./route"; export { diff --git a/packages/vitnode/src/tanstack/admin/integrations/query.ts b/packages/vitnode/src/tanstack/admin/integrations/query.ts index b8efa0640..66e7cefc8 100644 --- a/packages/vitnode/src/tanstack/admin/integrations/query.ts +++ b/packages/vitnode/src/tanstack/admin/integrations/query.ts @@ -12,13 +12,6 @@ import { import { fetchIntegrationsOnServer } from "./server"; -/** - * The integrations board for a TanStack Start host: one query definition. - * - * The transport boundary is the same one every AdminCP read uses - both branches - * call Hono directly, and the admin cookie travels on both. See - * `tanstack/admin/cron/query.ts` for the full argument. - */ const fetchIntegrations: IntegrationsFetcher = createIsomorphicFn() .server(fetchIntegrationsOnServer) .client(fetchIntegrationsInBrowser); @@ -27,15 +20,6 @@ const fetchIntegrations: IntegrationsFetcher = createIsomorphicFn() export const integrationsQuery = () => integrationsQueryOptions({ fetchIntegrations }); -/** - * Marks the board stale, so the next reader sees the current state. - * - * Nothing on this screen writes to it - the three test actions send an email, - * upload a file and ask a model a question, none of which changes whether an - * integration is *configured*. It is exported because an installation-level - * mutation elsewhere legitimately does: configuring storage, adding an AI model, - * or a cron run that clears the stale flag. - */ export const invalidateIntegrations = async ( queryClient: QueryClient, ): Promise<void> => diff --git a/packages/vitnode/src/tanstack/admin/integrations/route.tsx b/packages/vitnode/src/tanstack/admin/integrations/route.tsx index 50fc99f04..022940cbb 100644 --- a/packages/vitnode/src/tanstack/admin/integrations/route.tsx +++ b/packages/vitnode/src/tanstack/admin/integrations/route.tsx @@ -11,14 +11,6 @@ import { integrationsQuery } from "./query"; * and nothing a route owns. */ -/** - * What this screen renders strings from. - * - * `admin.system.integrations` is the heading, the nine cards and all three test - * dialogs; `core.global` is the error toasts and the form chrome the dialogs - * render inside. The same set the Next.js page's - * `<I18nProvider namespaces="admin.system.integrations">` provides. - */ export const ADMIN_INTEGRATIONS_NAMESPACES = [ "admin.system.integrations", "core.global", @@ -33,26 +25,11 @@ export interface AdminIntegrationsRouteData { /** The core plugin's `system` module, which all four tuples on this screen use. */ export const SYSTEM_MODULE = "system"; -/** - * The tuple `<AdminPermissionRequired module="system" permission="can_view">` - * states in the Next.js page, and the one `integrationsDebugAdminRoute` - * declares. - */ const SYSTEM_VIEW_PERMISSION = { module: SYSTEM_MODULE, permission: "can_view", } as const; -/** - * Both reads this screen needs, in parallel, before it renders. - * - * The permission is checked first, so an administrator who may not open the - * screen never sends a request the API is going to refuse. - * - * A refusal is left to propagate. The board reports which subsystems are up, and - * an errored read rendered as nine `inactive` cards would be a false alarm about - * every integration at once - the loudest possible way to be wrong. - */ export const loadAdminIntegrationsRoute = async ({ adminAccess, locale, @@ -80,16 +57,3 @@ export const loadAdminIntegrationsRoute = async ({ return { description: t("desc"), title: t("title") }; }; - -/** - * `/admin/core/system/integrations`, as everything below a route file's - * `component`. - * - * The three test permissions are read here from the admin session the guard - * already resolved - a context read rather than the three extra API calls the - * Next.js page spends on `checkAdminPermissionApi`. They decide which buttons - * render; the API re-checks each tuple on the request itself. - * - * The test-email mutation is the browser one. The Next.js page keeps its server - * action, and both satisfy `SendTestEmail`, so the dialog is identical. - */ diff --git a/packages/vitnode/src/tanstack/admin/intl.ts b/packages/vitnode/src/tanstack/admin/intl.ts index 670088541..57231b750 100644 --- a/packages/vitnode/src/tanstack/admin/intl.ts +++ b/packages/vitnode/src/tanstack/admin/intl.ts @@ -4,74 +4,8 @@ import { normalizeNamespaceList } from "@/routing"; import { intlQueryOptions } from "../i18n/query"; -/** - * Which strings the AdminCP loads, and when. - * - * There is no admin locale here, and that is the point: `/admin` resolves its - * language through exactly the same path every other route does. - * `DEFAULT_IGNORED_LOCALE_PATHS` in `lib/i18n/locale-routing.ts` lists `/admin` - * with its descendants, so: - * - * - `rewrite.input` never strips a prefix from an admin URL and `rewrite.output` - * never writes one, which is why `<Link to="/admin/core/users">` renders - * `/admin/core/users` in every language. - * - `handleLocaleRequest` 308-redirects `/pl/admin/...` to `/admin/...` **and - * attaches the locale cookie to the redirect**, so arriving by a prefixed - * admin URL still records the choice before the redirect ends the request. - * - `resolveLocale` on an ignored path falls through to the locale cookie and - * then to the default. That *is* the AdminCP's language, and there is no other - * source. - * - * So `__root`'s `beforeLoad` has already put the answer in `context.locale` by - * the time anything below runs. There is deliberately no `AdminLocaleContext`, - * no `localStorage` key and no pathname parsing: each would be a second answer - * to a question that is already settled, and the pathname one would be a second - * copy of a rule that has to stay identical to the rewrite's. - * - * `Accept-Language` is not read either, and must not be. The browser cannot - * reproduce that answer during hydration, so it is a guaranteed mismatch. - */ - -/** - * The namespaces the AdminCP shell renders from, and nothing else. - * - * `core.global` for the design-system strings every VitNode page needs, and - * `admin.global` for the shell's own - the sidebar chrome, the search dialog, - * the user bar. Two namespaces, not the AdminCP's whole message tree: the merged - * record carries every plugin's admin copy, and shipping all of it to warm a - * sidebar would send a screen's worth of strings for every screen nobody is - * looking at. - * - * A feature route mounts its own `RouteMessages` with its own namespaces on top - * of this one - `admin.user` for the users table, `admin.staff` for the six - * staff screens - and the inner provider wins for the keys it names. That is the - * same rule `<I18nProvider namespaces={[...]}>` applies in the Next.js AdminCP, - * so a screen's namespace list survives its migration unchanged. - * - * One list, read by both the loader that fetches these and the provider that - * mounts them, because they have to be the same set or the provider suspends on - * a key nobody warmed. - */ export const ADMIN_SHELL_NAMESPACES = ["core.global", "admin.global"] as const; -/** - * The shell's own namespaces, plus whatever this installation's navigation needs. - * - * The sidebar is the one part of the chrome whose strings are not knowable in - * advance: core's entries are `admin.global`, and a plugin group's heading, its - * content types' nouns and its declared entries are all under that plugin's own - * id. `adminNavNamespaces` computes that set from the declarations themselves - - * see `views/admin/layouts/sidebar/nav/nav-model` - and this is where it joins - * the two the shell always needs. - * - * One function, called by both the loader that warms these and the provider that - * mounts them, for the same reason the constant above is one list: a loader that - * warmed a different set fills an entry the provider never looks at, and the - * first paint costs a round trip anyway. - * - * Normalised, so `["core.global","admin.global"]` and the same pair in the other - * order are one cache entry rather than two holding identical bytes. - */ export const adminShellNamespaces = ( navNamespaces: readonly string[] = [], ): string[] => @@ -80,31 +14,11 @@ export const adminShellNamespaces = ( /** The narrowest slice of a route's context the admin loaders read. */ export interface AdminLoaderContext { locale: string; - /** - * The namespaces this installation's AdminCP navigation renders from, from - * `adminNavBundle(...)`. - * - * The host's, because the navigation is: a package cannot know which plugins - * an application configured, and the projection that answers that is generated - * per application. Omitted - a host with core's navigation only - the shell's - * own two namespaces are the whole set, which is what Stage 12 shipped with. - */ + namespaces?: readonly string[]; queryClient: QueryClient; } -/** - * Warm the shell's strings for this request's language. - * - * `context.locale` rather than a default, so an administrator whose cookie says - * Polish gets Polish in the first paint rather than English that flips after - * hydration. - * - * The identical `intlQueryOptions` object `RouteMessages` reads back, which is - * the invariant that makes this worth doing at all: a loader that warms a - * different locale or a different namespace set fills an entry the provider - * never looks at, and the first paint costs a round trip anyway. - */ export const loadAdminMessages = async ({ locale, namespaces, diff --git a/packages/vitnode/src/tanstack/admin/nav.tsx b/packages/vitnode/src/tanstack/admin/nav.tsx index 0ee2d726a..b7ec9efde 100644 --- a/packages/vitnode/src/tanstack/admin/nav.tsx +++ b/packages/vitnode/src/tanstack/admin/nav.tsx @@ -19,40 +19,6 @@ import { import { useAdminPermissions } from "./permissions"; -/** - * The AdminCP navigation, resolved once per render and read by everything that - * needs it. - * - * Three things in the shell are the same list seen from different angles - the - * sidebar, the command palette and the breadcrumb's labels - and the one thing - * that must not happen is for them to be three lists. A second tree is a second - * chance to forget a permission check, and the failure mode is a search box that - * reveals the existence of every screen an admin cannot open, or a crumb that - * names one. So it is resolved here, once, and shared. - * - * ## The two stages, and why this one is the second - * - * `adminNavDeclarations(config)` is stage one: pure, config-only, no admin and no - * language. `resolveAdminNav` is stage two: it needs the permission set and the - * translator, both of which only exist in a rendered browser tree. Splitting - * them is what lets a host run stage one wherever its plugin registry actually - * lives - which in a TanStack Start app is deliberately *not* the browser bundle - * (see `apps/web/src/vitnode.shell.config.ts`, which omits `plugins` on - * purpose). - * - * That is why `declarations` is a prop rather than something this reads from - * `getVitNodeConfig()`. A host with no browser-side plugin registry passes - * nothing and gets core's own navigation; a host that has projected its plugins' - * navigation into browser-safe data passes that instead and the plugin groups - * appear. `apps/web` does the second: `@vitnode/core/framework/admin-nav` writes - * one literal import per configured plugin that exports an `admin/nav` module, - * and `adminNavBundle` turns those into declarations plus the message namespaces - * they need. Nothing here changes either way. - * - * No navigation is derived from a plugin's route tree: routes and navigation stay - * separate concepts, and a nav entry may point at a plugin route, at a screen - * another application serves, or at another origin entirely. - */ interface AdminNavValue { nav: NavAdminParent[]; searchItems: AdminSearchNavItem[]; @@ -60,13 +26,6 @@ interface AdminNavValue { const AdminNavContext = React.createContext<AdminNavValue | null>(null); -/** - * Core's own navigation declarations - the fallback when a host passes none. - * - * A module-level constant rather than a call per render, so the memo below has a - * stable identity to compare against and the whole tree is not rebuilt on every - * keystroke in the palette. - */ const CORE_ONLY_DECLARATIONS: AdminNavGroupDeclaration[] = adminNavDeclarations( { plugins: [] }, ); @@ -76,24 +35,11 @@ export const AdminNavProvider = ({ declarations = CORE_ONLY_DECLARATIONS, }: { children: React.ReactNode; - /** - * The navigation to resolve, before permissions and translation. - * - * Defaults to core's own. See the note above on why this is a prop. - */ + declarations?: AdminNavGroupDeclaration[]; }) => { const permissions = useAdminPermissions(); - /** - * The whole message tree, not a namespace. - * - * The keys this resolves are assembled at runtime and span namespaces - core's - * own `admin.global.nav.*`, a plugin's `{pluginId}.admin.nav.*`, a content - * type's `{pluginId}.content.*` - so a namespaced translator could not reach - * them. The cast is the one `ContentLabelTranslator` exists for: `use-intl` - * types its keys as a union of every message in the catalogue, which a runtime - * key cannot satisfy. - */ + const t = useTranslations() as unknown as AdminNavTranslator; const value = React.useMemo<AdminNavValue>(() => { @@ -101,16 +47,7 @@ export const AdminNavProvider = ({ return { nav, - /** - * The palette's index: the navigation flattened, plus the pages that are - * deliberately not in the sidebar - each behind its own permission check, - * against the same set the navigation was filtered by. - * - * Built here rather than in the palette so there is exactly one tree. A - * second one assembled from the config would be a second chance to forget - * a filter, and the failure mode is a search box that names every screen - * an admin cannot open. - */ + searchItems: [ ...flattenAdminNav(nav), ...adminSearchOnlyItems({ permissions, t }), @@ -121,14 +58,6 @@ export const AdminNavProvider = ({ return <AdminNavContext value={value}>{children}</AdminNavContext>; }; -/** - * The navigation this admin can see. - * - * Throws rather than returning an empty list when no provider is above it: an - * empty sidebar and a missing provider look identical on screen, and the first - * is a legitimate state (an admin with no permissions) that must not be able to - * hide the second. - */ const useAdminNavValue = (): AdminNavValue => { const value = React.use(AdminNavContext); @@ -143,16 +72,5 @@ const useAdminNavValue = (): AdminNavValue => { export const useAdminNav = (): NavAdminParent[] => useAdminNavValue().nav; -/** - * The same navigation, flattened for the command palette. - * - * Derived from {@link useAdminNav} rather than assembled again, which is the - * whole point: an entry can only be found by search if it survived the - * permission filter, because there is nothing else for search to read. - * - * Pages the palette offers that are *not* in the sidebar - the debug screen is - * the standing example - are appended by the caller, each behind its own - * permission check. See `adminSearchOnlyItems`. - */ export const useAdminSearchNavItems = (): AdminSearchNavItem[] => useAdminNavValue().searchItems; diff --git a/packages/vitnode/src/tanstack/admin/not-found.tsx b/packages/vitnode/src/tanstack/admin/not-found.tsx index a1ab9477a..c7d8effef 100644 --- a/packages/vitnode/src/tanstack/admin/not-found.tsx +++ b/packages/vitnode/src/tanstack/admin/not-found.tsx @@ -2,34 +2,6 @@ import { NotFound } from "../layout/not-found"; -/** - * The AdminCP's 404 - the answer a screen this administrator may not open gets, - * which {@link requireAdminPermission} produces with `notFound()`. - * - * ## Mount it inside the shell, not beside it - * - * The host puts this in the `notFoundComponent` of the route that guards the - * AdminCP, and that route has to render its shell around it *here as well*. A - * `notFoundComponent` renders instead of the component of the route that - * handles the error, so the shell that route mounts is exactly what is missing - * by the time this renders - and a refusal with no sidebar, no header and no - * palette leaves an administrator on a dead page with the browser's back button - * for a way out. The Next.js AdminCP keeps the panel: its `not-found.tsx` sits - * under `admin/(auth)/layout.tsx`. `apps/web/src/routes/_admin.tsx` shows the - * two lines that match it. - * - * This component deliberately does not mount the shell itself. It is the - * message, and the shell needs a link component and a user lookup that only a - * host can supply. - * - * ## It is `NotFound`, and the difference is where it is mounted - * - * The message is identical to the one the root route renders for a URL nothing - * matched, so it is the same component rather than a second copy of the same - * three lines - a 404 that read differently inside the AdminCP would be a - * distinction without a reason. What is admin-specific is the *mounting* rule - * above, which is the host route's, and the `actions` this is handed. - */ export const AdminNotFound = ({ actions }: { actions?: React.ReactNode }) => ( <NotFound actions={actions} /> ); diff --git a/packages/vitnode/src/tanstack/admin/permissions-bridge.test.ts b/packages/vitnode/src/tanstack/admin/permissions-bridge.test.ts index e98653d03..ceb4d692d 100644 --- a/packages/vitnode/src/tanstack/admin/permissions-bridge.test.ts +++ b/packages/vitnode/src/tanstack/admin/permissions-bridge.test.ts @@ -3,20 +3,6 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -/** - * There is one permission state, and one place it comes from. - * - * A source scan rather than a render, which is the only way to assert this at - * all: the failure being prevented is a *second* context or a *second* cache - * being introduced, and a rendering test proves nothing about a store nobody - * mounted in it. What this pins is the shape of the bridge - - * - * Hono /admin/session -> ["vitnode","admin-session"] -> the existing context - * - * - and specifically that the middle arrow is the only way to reach the right - * hand side. - */ - const here = dirname(fileURLToPath(import.meta.url)); const withoutComments = (code: string): string => @@ -33,13 +19,6 @@ const sourcesUnder = (directory: string): { code: string; name: string }[] => const adminSources = () => sourcesUnder(here); -/** - * One module by name, or a failure that says which one is missing. - * - * A lookup rather than a non-null assertion, so renaming a module fails these - * tests with its name in the message instead of with a property read on - * `undefined` several lines later. - */ const sourceNamed = (name: string): string => { const source = adminSources().find(entry => entry.name === name); diff --git a/packages/vitnode/src/tanstack/admin/permissions.tsx b/packages/vitnode/src/tanstack/admin/permissions.tsx index 2822cc1c4..3fedb05f3 100644 --- a/packages/vitnode/src/tanstack/admin/permissions.tsx +++ b/packages/vitnode/src/tanstack/admin/permissions.tsx @@ -19,58 +19,6 @@ import type { AdminAccessState, AdminSessionApi } from "./session-api"; import { adminSessionQueryOptions } from "./session-query"; import { adminPermissionsOf, hasAdminPermission } from "./state"; -/** - * The AdminCP's permission UI, fed from the one admin session query. - * - * Hono /admin/session -> ["vitnode","admin-session"] -> this provider - * | - * AdminStaffPermissionGate, useAdminStaffPermission - * - * One server truth, one browser query state, one React context. The context is - * the *existing* one from `components/staff-permission/provider` - the same one - * the Next.js AdminCP mounts - so every screen already written against - * `AdminStaffPermissionGate` and `useAdminStaffPermission` keeps working - * unchanged as it migrates, and there is never a moment where two permission - * states exist and can disagree. - * - * That provider is a rendering adapter and nothing more. It holds no state of - * its own, it cannot be written to, and it is not authorization: `api/config.ts` - * puts `globalAdminMiddleware()` in front of every admin API path and each - * handler re-checks the staff tables, so editing this value in devtools reveals - * a button and buys nothing behind it. - */ - -/** - * Mounts the admin permission context for everything under the AdminCP shell. - * - * Reads the canonical query rather than taking a prop, which is what makes it a - * bridge instead of a second store: there is no way to mount this with a - * permission set that did not come from the API. - * - * ## It does not suspend in practice, and cannot below itself - * - * `useSuspenseQuery` is the one suspension point in the chain, and `_admin`'s - * `beforeLoad` has already filled the entry through `ensureAdminAccess` before - * any of this renders - so on the first paint the data is there. (A later - * *refetch* does not suspend either: `useSuspenseQuery` serves the previous - * value while an invalidated entry refreshes, so bringing permissions back in - * step never blanks the sidebar.) - * - * Below this, nothing can suspend at all: the resolved set goes into the context - * as a plain value, and `useAdminStaffPermissions` returns it directly rather - * than unwrapping a promise. That is what lets a pure function like - * `buildAdminNav` be called straight out of a hook. - * - * ## A failed read never reaches here - * - * The query rejects on a `429`, a `500` or an unreachable API, so this component - * is not rendered at all in those cases and the route's error boundary owns the - * screen. What that means for the value below is precise and worth stating: an - * empty permission set here is always `denied` - the API was asked and said this - * browser holds no admin session - and never "we could not find out". The two - * would look identical to a gate, which is exactly why they are kept apart - * upstream. - */ export const AdminPermissionsProvider = ({ children, }: { @@ -85,63 +33,21 @@ export const AdminPermissionsProvider = ({ ); }; -/** - * The admin access decision, in a component. - * - * The identical `adminSessionQueryOptions` object the loader warmed, so this - * reads the entry the guard filled rather than starting a second one. A route - * whose loader and component reach for different options objects warms a cache - * entry the component never sees - which is the failure this shape exists to - * make impossible. - */ export const useAdminAccess = (): AdminAccessState => useSuspenseQuery(adminSessionQueryOptions()).data; -/** - * The signed-in administrator, or `null` when this browser holds no admin - * session. - * - * `null` rather than a throw, because the pieces that call this - the user bar, - * an avatar - render inside a shell that has already decided access. The guard - * is what turns "no admin session" into a redirect; a component's job is only to - * render nothing. - */ export const useAdminUser = (): AdminSessionApi["user"] | null => { const access = useAdminAccess(); return access.status === "granted" ? access.session.user : null; }; -/** - * The current administrator's effective permission set. - * - * Re-exported through this module so an AdminCP screen has one import to reach - * for, and so the underlying context stays an implementation detail that can - * follow the Next.js surface out of the codebase. - */ export const useAdminPermissions: () => StaffPermissionSet = useAdminStaffPermissions; -/** - * Whether the current administrator holds a permission. - * - * `plugin` is required here, exactly as it is on the underlying hook: a plugin's - * screen must name its own `pluginId`, because a permission granted under core - * does not open a plugin's page. {@link hasAdminPermission} in `./state` is the - * variant that defaults it, for the core screens that would otherwise write - * `@vitnode/core` a hundred times. - */ export const useAdminPermission: (args: PermissionsStaffArgs) => boolean = useAdminStaffPermission; -/** - * Renders `children` only for an administrator holding the permission. - * - * The existing gate, re-exported unchanged. It is the AdminCP's established way - * of hiding a control, and it stays that way: a TanStack screen and a Next.js - * screen gate a "delete" button with the same component, so the two cannot drift - * apart while both exist. - */ export { AdminStaffPermissionGate as AdminPermissionGate }; export { hasAdminPermission }; diff --git a/packages/vitnode/src/tanstack/admin/queries.test.ts b/packages/vitnode/src/tanstack/admin/queries.test.ts index d6af57a8b..ba3f6c334 100644 --- a/packages/vitnode/src/tanstack/admin/queries.test.ts +++ b/packages/vitnode/src/tanstack/admin/queries.test.ts @@ -15,56 +15,18 @@ import { import { removeAdminIdentityQueries, removeAdminShellQueries } from "./queries"; import { ADMIN_SESSION_QUERY_KEY } from "./state"; -/** - * What an identity boundary throws away, and - just as load-bearing - what it - * leaves alone. - * - * A `QueryClient` with data written into it and nothing else: no router, no - * React, no server. Every assertion below is a statement about the *cache*, - * which is the only thing these functions touch, so this is the whole of the - * unit rather than a stand-in for one. - * - * The failure this guards against is silent by construction. A cache entry that - * outlives the administrator it belonged to renders perfectly - it is somebody - * else's data, drawn correctly - and there is no error, no warning and nothing - * in a log. It only ever appears as one administrator being shown another's - * screen, on a shared browser, after the first one's session ended. - */ - /** The privileged families, one representative key each. */ const ADMIN_SESSION = [...ADMIN_SESSION_QUERY_KEY]; const ADMIN_SEARCH_USERS = [...ADMIN_SEARCH_USERS_QUERY_KEY, "ann"]; const ADMIN_SCREEN = [...adminQueryRoot("files"), { page: 1 }]; const ADMIN_DASHBOARD = [...adminQueryRoot("dashboard-layout")]; -/** - * The Content Engine, one key per level of its family. - * - * Built by the real key builders rather than written out, because the property - * being checked is that *those* stay under the AdminCP root - a key spelled by - * hand here would keep passing after `content-query.ts` moved the family - * somewhere this cleanup cannot reach. - * - * There is deliberately no `removeContentQueries` beside the two functions under - * test. `contentQueryRoot()` is `["vitnode","admin","content"]`, so the existing - * prefix removal already collects every content type an installation has and - * every one a plugin adds later. A second per-feature cleanup list would be one - * more thing to remember and one more thing to forget. - */ const CONTENT_LIST = [...contentListQueryKey("blog.post", { first: "25" })]; const CONTENT_ITEM = [...contentItemQueryKey("blog.post", 42)]; const CONTENT_OPTIONS = [ ...contentOptionsQueryKey("blog.category", "category", "en"), ]; -/** - * Entries that belong to other layers, and must survive. - * - * The public session is the pointed one: it is a different cookie answering a - * different question, and `tanstack/auth` owns its lifecycle. A cleanup that - * collected it would sign a visitor out of the public site because an - * administrator signed in to the panel. - */ const PUBLIC_SESSION = ["vitnode", "session"]; const INTL = ["vitnode", "intl", "en", ["core.global"]]; const PLUGIN = ["@vitnode/example", "articles"]; diff --git a/packages/vitnode/src/tanstack/admin/queries.ts b/packages/vitnode/src/tanstack/admin/queries.ts index fe9a44a81..b3e90bec7 100644 --- a/packages/vitnode/src/tanstack/admin/queries.ts +++ b/packages/vitnode/src/tanstack/admin/queries.ts @@ -5,93 +5,11 @@ import { ADMIN_QUERY_ROOT } from "@/views/admin/table/query"; import { removeAdminSession } from "./state"; -/** - * Every cache entry the AdminCP shell owns, dropped. - * - * The canonical list, and the only one: nothing else in VitNode enumerates the - * privileged AdminCP query roots. Callers that mean "this browser is now a - * different person" want {@link removeAdminIdentityQueries} below, which is this - * plus the session entry. - * - * Kept separate from `removeAdminSession` because the two answer to different - * owners: the session entry belongs to the admin auth layer, and these belong to - * the shell, which is the only thing that knows it has them. - * - * **Removed, not invalidated**, and the distinction is the whole point. - * Invalidation keeps the value and marks it stale, so a second administrator - * signing in on the same tab would be rendered the first one's user-lookup - * results until a refetch returned. Removal deletes them, so the next reader has - * nothing to render from. - * - * `ADMIN_QUERY_ROOT` is the second entry, and it covers every AdminCP *screen* - * at once - the cron list, the queue, uploaded files, the system log, the search - * index, the integrations board and the dashboard layout. None of it is - * per-identity in the way a user lookup is, but all of it is privileged: it is - * the operational state of the installation, readable only by an administrator - * holding the right permission, and leaving it in a browser after a sign-out is - * a copy of that state outliving the session that was allowed to see it. One - * prefix rather than a list per screen, so a screen added later is collected - * without anybody having to remember this file. - * - * It is not a prefix of `["vitnode", "admin-session"]`: Query matches keys - * element by element, and `"admin"` is not `"admin-session"`. The session entry - * has its own removal, `removeAdminSession`, and keeping the two apart is what - * lets a screen invalidate its own rows without touching the permission set the - * shell is rendering from. - * - * Anything the shell caches per-identity in future belongs on this list. It is - * deliberately a list of *prefixes* rather than a `queryClient.clear()`: the - * public session, the message catalogues and a plugin's own entries are not this - * function's to throw away, and clearing them would turn a sign-out into a - * full-cache eviction that re-fetches the whole application. - */ export const removeAdminShellQueries = (queryClient: QueryClient): void => { queryClient.removeQueries({ queryKey: ADMIN_SEARCH_USERS_QUERY_KEY }); queryClient.removeQueries({ queryKey: ADMIN_QUERY_ROOT }); }; -/** - * Everything privileged this browser holds about *the previous administrator*, - * dropped - the admin session and every AdminCP screen entry, in one call. - * - * What an **identity boundary** calls: a successful admin sign-in, either - * sign-out, a public sign-in, a finished SSO exchange, a verified sign-up. Each - * of those is a moment where the person at the keyboard may have changed, and - * the correct response is to re-derive everything privileged from the cookie the - * browser now holds rather than to reuse the last answer. - * - * ## A sign-in is an identity boundary too, and that was the bug - * - * `removeAdminSession` alone was not enough. It drops - * `["vitnode","admin-session"]` - the permission set - and leaves every AdminCP - * *screen* entry where it was: the user-lookup results the palette cached, the - * file table, the cron list, the dashboard layout. So Admin A could use the - * panel, have their session expire or be revoked without ever signing out, and - * Admin B could sign in on that same tab and be handed A's screens from memory. - * The dashboard is the sharpest case, because its stored layout is - * administrator-specific and its key is not scoped by identity. - * - * ## Removal, and before the navigation - * - * `removeQueries` throughout, never `invalidateQueries`. Invalidation keeps the - * value and marks it stale, so the next render still paints the previous - * administrator's data until a refetch returns. The whole point is that Admin B - * must not see Admin A's cached value for even one frame, so the value has to be - * gone rather than doubted. - * - * And it runs *before* whatever navigates. The AdminCP's guard reads the session - * entry in `beforeLoad`; dropping it first is what makes that guard perform a - * real read instead of deciding on what was already there. - * - * ## What it does not touch - * - * A list of AdminCP-owned prefixes, deliberately, and never - * `queryClient.clear()`. The public session, the message catalogues and a - * plugin's own entries are not this function's to throw away, and clearing them - * would turn a sign-in into a full-cache eviction that re-fetches the whole - * application. The public session has its own lifecycle in `tanstack/auth`, - * which is the layer that knows what a public identity change means. - */ export const removeAdminIdentityQueries = (queryClient: QueryClient): void => { removeAdminSession(queryClient); removeAdminShellQueries(queryClient); diff --git a/packages/vitnode/src/tanstack/admin/queue/index.ts b/packages/vitnode/src/tanstack/admin/queue/index.ts index 3bd33630c..38b374b55 100644 --- a/packages/vitnode/src/tanstack/admin/queue/index.ts +++ b/packages/vitnode/src/tanstack/admin/queue/index.ts @@ -1,15 +1,3 @@ -/** - * `/admin/core/advanced/queue` - the AdminCP queue list, for a TanStack Start - * host. - * - * ./query the cache contract - one query definition, no mutation - * ./route-search the URL contract, with this screen's declaration applied - * ./route the screen: namespaces, permission, loader, component - * ./server the SSR transport, reached only through `./query` - * - * `QueueTableContent` is framework-free and imported from - * `@/views/admin/views/core/advanced/queue` by both applications. - */ export { queueQuery } from "./query"; export type { AdminQueueRouteData } from "./route"; export { ADMIN_QUEUE_NAMESPACES, loadAdminQueueRoute } from "./route"; diff --git a/packages/vitnode/src/tanstack/admin/queue/query.ts b/packages/vitnode/src/tanstack/admin/queue/query.ts index 2a24bb632..483dee5a8 100644 --- a/packages/vitnode/src/tanstack/admin/queue/query.ts +++ b/packages/vitnode/src/tanstack/admin/queue/query.ts @@ -12,27 +12,9 @@ import { import { fetchQueuePageOnServer } from "./server"; -/** - * The queue list for a TanStack Start host: one query definition, and no - * mutation - this screen only reads. - * - * The transport boundary is the same one every AdminCP read uses: both branches - * call Hono directly, with no `createServerFn` in between, because a server - * function would be a `POST` back to the app that then calls Hono - two round - * trips for a read the API is already the boundary for. See - * `tanstack/admin/cron/query.ts` for the full argument, including why the - * chained call has to be written out here rather than hidden behind a helper. - */ const fetchQueuePage: QueuePageFetcher = createIsomorphicFn() .server(fetchQueuePageOnServer) .client(fetchQueuePageInBrowser); -/** - * The queue list, as the one query definition every caller shares. - * - * `params` must be the *normalised* ones, because the cache key is built from - * them - the status filter included, since two filters are two different sets of - * rows. - */ export const queueQuery = ({ params }: { params: QueueParams }) => queueQueryOptions({ fetchPage: fetchQueuePage, params }); diff --git a/packages/vitnode/src/tanstack/admin/queue/route-search.ts b/packages/vitnode/src/tanstack/admin/queue/route-search.ts index 26a39ebc6..b1b7f2c93 100644 --- a/packages/vitnode/src/tanstack/admin/queue/route-search.ts +++ b/packages/vitnode/src/tanstack/admin/queue/route-search.ts @@ -17,13 +17,6 @@ import { normalizeAdminTableSearch, } from "../table-search"; -/** - * The queue list's URL contract - the shared admin-table one with this screen's - * declaration applied, so a host writes `validateSearch: - * normalizeQueueRouteSearch` rather than restating which columns sort and which - * statuses filter. - */ - export type QueueRouteSearch = AdminTableRouteSearch<QueueOrderBy>; export type UncheckedQueueSearch = UncheckedAdminTableSearch<QueueOrderBy>; diff --git a/packages/vitnode/src/tanstack/admin/queue/route.tsx b/packages/vitnode/src/tanstack/admin/queue/route.tsx index d7691c7ea..b9903f35a 100644 --- a/packages/vitnode/src/tanstack/admin/queue/route.tsx +++ b/packages/vitnode/src/tanstack/admin/queue/route.tsx @@ -13,14 +13,6 @@ import { queueQuery } from "./query"; * nothing a route owns. */ -/** - * What this screen renders strings from. - * - * `admin.advanced.queue` is the heading, the columns, the status badges and the - * filter's labels; `core.global` is the rest of the table. The same set the - * Next.js page's `<I18nProvider namespaces={["admin.advanced.queue"]}>` provides, - * which always adds `core.global` itself. - */ export const ADMIN_QUEUE_NAMESPACES = [ "admin.advanced.queue", "core.global", @@ -33,25 +25,11 @@ export interface AdminQueueRouteData { title: string; } -/** - * The tuple `<AdminPermissionRequired module="queue" permission="can_view">` - * states in the Next.js page, and the one `getQueueTasksRoute` declares as its - * `adminStaffPermission`. - */ const QUEUE_VIEW_PERMISSION = { module: "queue", permission: "can_view", } as const; -/** - * Both reads this screen needs, in parallel, before it renders. - * - * The permission is checked first, so an administrator who may not open the - * screen never sends a request the API is going to refuse. A refusal from the - * queue API is left to propagate: an empty table is indistinguishable from an - * installation with nothing queued, which is the one thing an operational screen - * must never look like. - */ export const loadAdminQueueRoute = async ({ adminAccess, locale, diff --git a/packages/vitnode/src/tanstack/admin/queue/screen.tsx b/packages/vitnode/src/tanstack/admin/queue/screen.tsx index 390f3f65c..1fae0aa90 100644 --- a/packages/vitnode/src/tanstack/admin/queue/screen.tsx +++ b/packages/vitnode/src/tanstack/admin/queue/screen.tsx @@ -23,14 +23,6 @@ export interface AdminQueueRouteProps extends AdminQueueRouteData { search: UncheckedQueueSearch; } -/** - * `/admin/core/advanced/queue`, as everything below a route file's `component`. - * - * `navigate` and `search` come from the host because TanStack infers both from - * the `createFileRoute` path. The status filter travels the same seam as the - * sort headers and the pager: it rewrites a query string, and the route turns - * that back into validated search. - */ export const AdminQueueRouteContent = ({ description, navigate, diff --git a/packages/vitnode/src/tanstack/admin/return-to.test.ts b/packages/vitnode/src/tanstack/admin/return-to.test.ts index f695de9b456f5dbdffcda3b4cce4d1cdc079cbbb..2d3f2d95466948f462de5895b76ccc5238832f9a 100644 GIT binary patch delta 38 ucmZp&IH);c|KuDdx6QMe<C!KGaf)uPW#7fT*^sx7X>x{`z-A%A4_p8ndJT2} delta 1882 zcmYjSyN(-25QKGs;R9@^3OlkOf{)ZVz%WiqEWm+a$xxzzv$MV25of0tGqa?an1H{) z#{Gzaiw(iQ;J~4Oz(3*Y86J-j!QGka?yBnAzn=g4?QfrstJ&<DX7sM-$dr4g+jel_ z<?CmOlG}uZ3zTeR4&)LU?`6*|Wue5OB|}Y=ed4$ThQh4BkDR9%-pY4ty`X@Dq)aAf zW49a^k&W$5<C$C-r+mauIn@FAhD<m}V6r9<UMiygm4Hg#g{>A|uZ(uMp5t&!``%>U zailcGo!haNMBPE!NlPT^ajJG~6fT&Z+nDSmEa~lD=ooP>RV-~bbIO<#Tx_CDBW^Ya z6mhmModB>E7dG0m!tEt)X;B7n0TjB78_9H>52VP{MHy(FO-MTb%1pUGbzb{imPSFg z?1KVY37rc(aL79Bg&<1t>i0i?_@*dEIC~pNMhFwRb&+jeUebT}@0U-HYf>>zoFp{& zEM1dcEfk&wb^)afLFU$}G9xJLb;@4G({wv$vl}QMMo<h5eZ|o$Xw?~y2~;J01&Rg( zS<Q0@R80^rbVxFABkh6MC&pChXLbMNv*t<lqPF*<x(XJ|(T$W%GpE3NSb&S_*2L({ zhEYxjB$+Zvhw?xF^drq0?j$n&s8FMcNk?&cPD$PKK7TMf15dj#-+WvbytO`he+(B- zbsON1G_pt84aVy#pc)d|0aE=Z!5@k<P#$6p<VCjVdrG6TPNuY^TWTPE@+S4l#(D)+ zin0|?Th=By4!R3QOIi?$wF4f{PwpGc7R;l4-Z{VIk7gJ>FRcui;52EJELJ>L%){Y3 zE^O@C#L_IaE^Q|FNV&QC`|6SMH!$s-8W0%-*25}PkF+8Y_G~7w$SGC5YQZ<ihe9p7 z&gZ=(EGJe-5oQHqhO9^l8&?PedLd86r^T_HyM6ttA~w+kOkKRRcImb(db#G=)a8ad zr^Y*rO_8l*n&90DcUl+~`Zh8Ji>4~x*=i*>66cIp35<1_<KD#f2&oWcrQHGd2i~89 z@Y36u=1?I&Oh_|bgBFCgg{&!fvD+oRLd<#eV7*{sRdPUJK5kQDGu+gHYsOyFQ7sC$ zqINHnuXqinN+lwVqUaLpeuBT)xExJ9z%VdBEO#DI>3mpj+l#2@=Y(X$a?}JZd7~L{ zS~-m4@?CfJ)&0xQf4SJbhkQM4?l~mtR{(almPh3g*QqqvA~Rhmp^8A%DF!y-q`=uD zbMjaiy{GG-X`vR;*)@HS2wK*c4`Bv=rRz$KjfTmh;;A{EMkEG-UD=umK2oH_$j%nb zq(mKfhiA1;T{Vz6z&R%O)#Im?*|}5#uaOt9jp88mH$6x%nka0sutN@+ED<TDA3=m1 Y;;VDsqe~Y~gx>w}_0#)*zWDF@e?snjX8-^I diff --git a/packages/vitnode/src/tanstack/admin/return-to.ts b/packages/vitnode/src/tanstack/admin/return-to.ts index d94a4bdb6..4e7176e4f 100644 --- a/packages/vitnode/src/tanstack/admin/return-to.ts +++ b/packages/vitnode/src/tanstack/admin/return-to.ts @@ -1,38 +1,6 @@ import { sanitizeReturnTo } from "../auth/return-to"; import { ADMIN_ENTRY_PATH, ADMIN_HOME_PATH } from "./state"; -/** - * Where an administrator goes after signing in, when the URL asked for - * somewhere specific. - * - * A pure string transform - no router, no request, no `window` - because the - * value it judges is the most attacker-reachable input in the AdminCP: anyone - * can put `?returnTo=` on a link to `/admin`, and whatever comes out of here is - * handed to a navigation. - * - * Two rules, applied in order, and they answer different questions: - * - * safe - may this app send a browser here at all? sanitizeReturnTo - * admin - is this inside the AdminCP? isAdminTarget - * - * The first is `tanstack/auth`'s and is reused rather than reimplemented. It - * rejects every origin (`https://evil.example.com`), every protocol-relative - * host (`//evil.example.com`), every scheme (`javascript:`, `data:`) and the - * whitespace and control-character spellings browsers strip before parsing - the - * open redirect and the XSS sink, in one place, for both sessions. Writing a - * second copy here is how the two would eventually disagree. - * - * The second is this module's own, and it is narrower than the public flow's for - * a reason stated under {@link sanitizeAdminReturnTo}. - */ - -/** - * Whether an already-safe target is a page inside the AdminCP. - * - * `/admin/core/users` yes; `/admin` itself no - see the loop guard below; - * `/discover` no, and `/administrators` no, which is why the test is on the - * `/admin/` prefix with its slash rather than on `startsWith("/admin")`. - */ const isAdminTarget = (target: string): boolean => target.startsWith(`${ADMIN_ENTRY_PATH}/`); @@ -86,32 +54,10 @@ export const sanitizeAdminReturnTo = ( return isAdminTarget(fallbackTarget) ? fallbackTarget : ADMIN_HOME_PATH; }; -/** - * Whether a target is somewhere {@link sanitizeAdminReturnTo} would keep as-is. - * - * For the guard that decides whether attaching a `?returnTo=` to the sign-in URL - * is worth doing at all. - */ export const isSafeAdminReturnTo = (value: unknown): value is string => typeof value === "string" && sanitizeAdminReturnTo(value, { fallback: ADMIN_HOME_PATH }) === value; -/** - * The `returnTo` to attach when bouncing an administrator to `/admin`, or - * nothing. - * - * Built from the *internal* location - the path the route tree matched - which - * for the AdminCP is also the public one, because `/admin` and its descendants - * carry no locale prefix in any language. That is not a coincidence to rely on - * quietly: `DEFAULT_IGNORED_LOCALE_PATHS` lists `/admin`, the rewrite therefore - * neither strips nor writes a prefix here, and `handleLocaleRequest` 308s - * `/pl/admin/...` to `/admin/...` before a route ever sees it. So there is no - * language to strip and none to write back, and nothing in this module needs to - * know a language exists. - * - * `undefined` for the AdminCP home, because `?returnTo=/admin/core` is the - * default spelled out: it makes the URL longer and changes nothing. - */ export const adminReturnToFor = ({ hash = "", pathname, diff --git a/packages/vitnode/src/tanstack/admin/roles/index.ts b/packages/vitnode/src/tanstack/admin/roles/index.ts index 49d33896a..6883435d8 100644 --- a/packages/vitnode/src/tanstack/admin/roles/index.ts +++ b/packages/vitnode/src/tanstack/admin/roles/index.ts @@ -1,20 +1,5 @@ export type { AdminTableNavigate } from "../table-search"; -/** - * `/admin/core/users/roles` - the AdminCP roles list, for a TanStack Start host. - * - * ./query one query definition, and the three writes that invalidate - * it - including the admin session, because a role carries - * permissions - * ./route namespaces, permission and loader - the eager half a route - * file's `loader` imports, and deliberately free of the - * screen's component tree - * ./screen the rendered screen, reached only through `component:` - * ./route-search the URL contract - * ./server the SSR transport, reached only through `./query` - * - * The table itself is framework-free and lives in - * `@/views/admin/views/core/users/roles`, which both applications render. - */ + export { adminRolesQuery, invalidateAfterAdminRoleChange, diff --git a/packages/vitnode/src/tanstack/admin/roles/query.ts b/packages/vitnode/src/tanstack/admin/roles/query.ts index 8be3a1f8f..e140d1234 100644 --- a/packages/vitnode/src/tanstack/admin/roles/query.ts +++ b/packages/vitnode/src/tanstack/admin/roles/query.ts @@ -46,25 +46,6 @@ export const adminRolesQuery = ({ }) => adminRolesQueryOptions({ adminUserId, fetchPage: fetchRolesPage, params }); -/** - * What a role write invalidates, and why it is three things rather than one. - * - * 1. **The roles list**, obviously: the row that changed may be on any page. - * 2. **The users list**, because a role's *name and colour* are rendered in - * every user row, and deleting a role moves its members into another one. - * The Next.js version gets this for free from `revalidatePath("/admin")`; a - * query cache has to be told. - * 3. **The admin session**, because a role is a permission carrier. Editing the - * role the signed-in administrator holds - or deleting it and moving them - * into another one - changes what *they* may do, and the sidebar, every - * permission gate and every screen guard in the panel are rendered from that - * one cached entry. Without this the AdminCP would go on showing links the - * API has started refusing until a page refresh. - * - * The third is `invalidateAdminSession` rather than `removeAdminSession`: the - * administrator has not changed, so keeping the current sidebar on screen while - * the fresh answer is fetched is right, and removal would blank the shell. - */ export const invalidateAfterAdminRoleChange = async ( queryClient: QueryClient, adminUserId: AdminIdentity, @@ -78,12 +59,6 @@ export const invalidateAfterAdminRoleChange = async ( ]); }; -/** - * The three role writes, bound to the mounted router's cache. - * - * Memoised, because they are props on a table that re-renders on every - * navigation and a new identity would remount the dialogs mid-edit. - */ export const useAdminRoleMutations = (): { onDelete: RolesAdminTableProps["onDelete"]; onSave: AdminRoleFormProps["onSave"]; diff --git a/packages/vitnode/src/tanstack/admin/roles/route-search.ts b/packages/vitnode/src/tanstack/admin/roles/route-search.ts index a8c27e0f5..40b8f386c 100644 --- a/packages/vitnode/src/tanstack/admin/roles/route-search.ts +++ b/packages/vitnode/src/tanstack/admin/roles/route-search.ts @@ -17,15 +17,6 @@ import { normalizeAdminTableSearch, } from "../table-search"; -/** - * The roles list's URL contract - the shared admin-table one, with this screen's - * declaration already applied. - * - * Bound here rather than at the route so a host writes `validateSearch: - * normalizeRolesRouteSearch` instead of restating which columns this table - * sorts by. Every rule these four apply is `tanstack/admin/table-search.ts`'. - */ - export type RolesRouteSearch = AdminTableRouteSearch<AdminRolesOrderBy>; export type UncheckedRolesSearch = UncheckedAdminTableSearch<AdminRolesOrderBy>; diff --git a/packages/vitnode/src/tanstack/admin/roles/route.tsx b/packages/vitnode/src/tanstack/admin/roles/route.tsx index 830d2caa1..4647e0206 100644 --- a/packages/vitnode/src/tanstack/admin/roles/route.tsx +++ b/packages/vitnode/src/tanstack/admin/roles/route.tsx @@ -12,34 +12,6 @@ import { adminIdentityOf } from "../identity"; import { requireAdminPermission } from "../screen"; import { adminRolesQuery } from "./query"; -/** - * `/admin/core/users/roles` - the AdminCP roles list. - * - * ## The permission model is the Next.js page's, unchanged - * - * roles.can_view the list itself - * roles.can_create the create button - * roles.can_edit the edit dialog - * roles.can_edit_admin ...additionally, for a role that grants admin - * roles.can_delete the delete dialog - * roles.can_delete_admin ...additionally, for a role that grants admin - * - * `roles.can_view` is a *frontend* gate and only that: `listRolesAdminRoute` - * declares no `adminStaffPermission` of its own, deliberately, because a role - * *picker* has to work for an administrator who cannot open the roles *screen*. - * The writes are all gated on the API - `create`, `update` and `delete` each - * declare their tuple - so what is checked here decides which page is reachable, - * not what may be changed. - */ - -/** - * What this screen renders strings from. - * - * `admin.role` is the heading, the columns and both dialogs; `admin.global` - * carries `nav.users.roles`, which is the `<h1>` and the `<title>`; `core.global` - * is the table's own furniture and the error toasts, listed because - * `RouteMessages` replaces the root's provider rather than adding to it. - */ export const ADMIN_ROLES_NAMESPACES = [ "admin.global", "admin.role", diff --git a/packages/vitnode/src/tanstack/admin/screen-boundaries.test.ts b/packages/vitnode/src/tanstack/admin/screen-boundaries.test.ts index 5a0905253..4474e3db0 100644 --- a/packages/vitnode/src/tanstack/admin/screen-boundaries.test.ts +++ b/packages/vitnode/src/tanstack/admin/screen-boundaries.test.ts @@ -8,55 +8,14 @@ const here = dirname(fileURLToPath(import.meta.url)); const packageRoot = resolve(here, "../../.."); const srcRoot = resolve(here, "../.."); -/** - * The AdminCP screens, and the half of VitNode they may not touch. - * - * Each screen under `tanstack/admin/<name>` is imported by a TanStack Start - * route and, through it, by a browser bundle. Its whole import graph therefore - * has to be free of Next.js - and unlike a type error, that failure is invisible - * until somebody runs the app: `@/lib/fetcher` carries `import "server-only"`, - * which throws at *module evaluation* with a message about Client Components and - * no indication of which import pulled it in. - * - * This test walks the graph and names the chain, which is the difference between - * a five-minute fix and an afternoon. It is the same boundary - * `table-boundaries.test.ts` and `auth-boundaries.test.ts` draw, applied to a - * whole feature tree rather than a handful of files. - * - * It caught exactly one real bug on the way in: `dashboard/route.tsx` imported - * `dashboardWidgetSources` from `get-dashboard-widgets.tsx`, which is the - * *Next.js* half of widget resolution - two lines of shared arithmetic sitting - * in a module that also reads `next-intl/server` and the request scope. The fix - * was to move the shared part into the framework-free resolver beside it. - */ - /** * Packages that only resolve inside a Next.js application - the package and * everything under it. */ const NEXT_PACKAGES = ["next", "next-intl", "server-only"]; -/** - * Two of core's own modules, matched **exactly** rather than by prefix. - * - * `@/lib/fetcher` is the Next.js fetcher - one file, carrying - * `import "server-only"` - while `@/lib/fetcher/*` is a directory of shared - * modules that the *browser* fetcher is built from (`rate-limit`, `raw`, - * `core`). A prefix rule would forbid the second along with the first, which is - * backwards: those are exactly what a screen is supposed to use. - * `@/lib/navigation` is `next-intl`'s locale-aware router, and the same - * distinction applies. - */ const NEXT_MODULES = ["@/lib/fetcher", "@/lib/navigation"]; -/** - * `@tanstack/react-start/server-only` is *not* forbidden and must not be. - * - * It is a different package with a different job: a marker the Start compiler - * reads to keep a module out of the client bundle, which is precisely how each - * screen's `server.ts` is allowed to exist inside a graph a browser walks. The - * forbidden `server-only` is the React one, which throws when evaluated. - */ const ALLOWED_PREFIX = "@tanstack/react-start/server-only"; const isForbidden = (specifier: string): boolean => { diff --git a/packages/vitnode/src/tanstack/admin/screen-queries.test.ts b/packages/vitnode/src/tanstack/admin/screen-queries.test.ts index 9c1d524e9..8a3f44426 100644 --- a/packages/vitnode/src/tanstack/admin/screen-queries.test.ts +++ b/packages/vitnode/src/tanstack/admin/screen-queries.test.ts @@ -24,36 +24,12 @@ import { integrationsQueryKey } from "@/views/admin/views/core/system/integratio import { ADMIN_SESSION_QUERY_KEY } from "./state"; -/** - * Where the AdminCP's screens keep their cache entries, and what that buys. - * - * Three properties, and every one of them is invisible until it is wrong: - * - * - **One prefix for the whole panel**, so a sign-out drops all of it in a - * single `removeQueries` rather than needing a list somebody has to extend. - * - **A root per screen**, so a mutation invalidates its own family and not the - * panel. Running a cron job must not refetch the file table. - * - **The session is not under it.** `removeAdminSession` owns that entry, and a - * screen's invalidation must never collect the permission set the shell is - * rendering from. - * - * Query matches keys element by element, so `["vitnode","admin"]` is a prefix of - * `["vitnode","admin","cron", …]` and is *not* a prefix of - * `["vitnode","admin-session"]`. That distinction is the whole test. - */ - /** Query's own rule: is `key` inside the family `prefix` names? */ const isUnder = ( key: readonly unknown[], prefix: readonly unknown[], ): boolean => prefix.every((segment, index) => key[index] === segment); -/** - * A sample administrator, for the screens whose keys are partitioned by identity. - * - * The assertions below are about *where* a screen's entries hang, and that is - * the same answer for every identity - so one stands in for all of them. - */ const ADMIN_ID = 7; const ROOTS = { diff --git a/packages/vitnode/src/tanstack/admin/screen.ts b/packages/vitnode/src/tanstack/admin/screen.ts index 294382e93..30465f8d2 100644 --- a/packages/vitnode/src/tanstack/admin/screen.ts +++ b/packages/vitnode/src/tanstack/admin/screen.ts @@ -7,58 +7,10 @@ import type { AdminAccessState } from "./session-api"; import { hasAdminPermission } from "./state"; -/** - * What an AdminCP *screen* - as opposed to the shell - reads out of its route - * context, and the one decision every one of them makes before it fetches - * anything. - */ - -/** - * The narrowest slice of a route's context an AdminCP screen loader reads. - * - * `adminAccess` is `_admin`'s own `beforeLoad` return, already narrowed to the - * decision the API actually gave and already used to admit the navigation. A - * screen therefore never re-reads the session: there is exactly one answer per - * navigation, and no way for a loader to decide something the guard did not. - */ export interface AdminScreenContext extends AdminLoaderContext { adminAccess: AdminAccessState; } -/** - * The screen's own permission, on top of holding an admin session at all. - * - * The direct replacement for the Next.js `<AdminPermissionRequired>` wrapper, - * and it behaves identically on purpose: an administrator without the permission - * gets the AdminCP's 404, not a redirect and not an empty table. `notFound()` is - * TanStack Router's own control-flow signal, and the guard route's - * `notFoundComponent` answers it - rendering `AdminNotFound` inside the shell it - * mounts, the same thing `app/[locale]/admin/(auth)/not-found.tsx` does for the - * Next.js AdminCP. See the note on `AdminNotFound` for why the shell has to be - * mounted there rather than inherited. - * - * ## It is not the security boundary, and must never be treated as one - * - * `api/config.ts` puts `globalAdminMiddleware()` in front of every request whose - * path contains `/admin/`, and each route below declares its own - * `adminStaffPermission`, re-checked against the staff tables on every request. - * This runs on a permission set derived from a response the browser can read, so - * it decides what to *render*. An administrator who edits that set in devtools - * reaches a screen whose every request the API still refuses - which is what - * {@link AdminRequestError} turns into a failed query rather than an empty page. - * - * ## Why it runs in the loader rather than in the component - * - * Called at the top of a screen's loader, before the reads it guards, so a - * request the API is going to refuse is never sent - and no admin markup is - * streamed for a screen that is about to be replaced. The Next.js version could - * not do that: `AdminPermissionRequired` is a Server Component wrapping the - * table, so the heading above it renders first and the 404 arrives afterwards. - * - * `plugin` defaults to `@vitnode/core`, exactly as `hasAdminPermission` does, so - * a core screen names two fields. A plugin's own screen passes its `pluginId` - - * a permission granted under core must not open a plugin's page. - */ export const requireAdminPermission = ( access: AdminAccessState, args: Omit<PermissionsStaffArgs, "plugin"> & { plugin?: string }, diff --git a/packages/vitnode/src/tanstack/admin/search-index/index.ts b/packages/vitnode/src/tanstack/admin/search-index/index.ts index 2cf997538..2d57beece 100644 --- a/packages/vitnode/src/tanstack/admin/search-index/index.ts +++ b/packages/vitnode/src/tanstack/admin/search-index/index.ts @@ -1,17 +1,3 @@ -/** - * `/admin/core/advanced/search` - the search index's health, for a TanStack - * Start host. - * - * ./query one query definition, and the two mutations that make it - * stale - each refreshing on success - * ./route-search one parameter: the collections table's search box - * ./route the screen: namespaces, permission, loader, component - * ./server the SSR transport, reached only through `./query` - * - * `SearchIndexContent` and its three buttons are framework-free and imported - * from `@/views/admin/views/core/advanced/search` by both applications; the two - * frameworks differ only in what an action does after it succeeds. - */ export { invalidateSearchIndex, searchIndexQuery, diff --git a/packages/vitnode/src/tanstack/admin/search-index/query.ts b/packages/vitnode/src/tanstack/admin/search-index/query.ts index e9299e446..e53b80a82 100644 --- a/packages/vitnode/src/tanstack/admin/search-index/query.ts +++ b/packages/vitnode/src/tanstack/admin/search-index/query.ts @@ -22,14 +22,6 @@ import { import { fetchSearchIndexStatusOnServer } from "./server"; -/** - * The search index screen for a TanStack Start host: one query definition and - * the two mutations that make it stale. - * - * The transport boundary is the same one every AdminCP read uses - both branches - * call Hono directly, and the admin cookie travels on both. See - * `tanstack/admin/cron/query.ts` for the full argument. - */ const fetchSearchIndexStatus: SearchIndexStatusFetcher = createIsomorphicFn() .server(fetchSearchIndexStatusOnServer) .client(fetchSearchIndexStatusInBrowser); @@ -38,44 +30,11 @@ const fetchSearchIndexStatus: SearchIndexStatusFetcher = createIsomorphicFn() export const searchIndexQuery = () => searchIndexQueryOptions({ fetchStatus: fetchSearchIndexStatus }); -/** - * Marks the status stale, so the screen re-reads what a rebuild changed. - * - * One entry rather than a family: this screen is a single read, and the - * collections table filters the list it already has rather than asking again. - * - * ## What this deliberately does not do - * - * The Next.js mutations also call `updateTag(SEARCH_FEED_TAG)`, which expires - * the public browse feed - a *cached* read of this index in that application. A - * TanStack Start host has no such cache: `/search` and `/discover` read the API - * through TanStack Query on each visit, with the API's own caching underneath, - * so there is no tag to expire and nothing to keep in step. Adding an - * invalidation of the public feed's query here would refetch a list nobody is - * looking at, in a different part of the application, on behalf of an - * administrator who is looking at the AdminCP. - */ export const invalidateSearchIndex = async ( queryClient: QueryClient, ): Promise<void> => await queryClient.invalidateQueries({ queryKey: searchIndexQueryKey }); -/** - * The two mutations, bound to the mounted router's cache. - * - * The TanStack counterpart of `useSearchIndexActionsNext`, and the shape is the - * point: both satisfy `SearchIndexActions`, so `SearchIndexContent` and its - * three buttons are rendered by either framework unchanged. Each callback - * refreshes on success - a query invalidation here, `router.refresh()` there - - * because the screen reports state the mutation just changed. - * - * Only on success. A refused rebuild changed nothing, and refetching the status - * underneath the error toast would replace the numbers the administrator is - * being told about. - * - * Memoised so the buttons' `useTransition` is not reset by a new function - * identity mid-rebuild. - */ export const useSearchIndexActions = (): SearchIndexActions => { const queryClient = useQueryClient(); diff --git a/packages/vitnode/src/tanstack/admin/search-index/route-search.ts b/packages/vitnode/src/tanstack/admin/search-index/route-search.ts index bcb972276..3bd26b30e 100644 --- a/packages/vitnode/src/tanstack/admin/search-index/route-search.ts +++ b/packages/vitnode/src/tanstack/admin/search-index/route-search.ts @@ -1,17 +1,3 @@ -/** - * The search index screen's URL contract: one parameter, and nothing else. - * - * The collections table has a search box and no pager - the whole list arrives - * in one status read - so `?search=` is the only thing any control here writes. - * It is deliberately *not* built on `../table-search`: that module always names a - * page size, which this screen has no use for and which would appear in the URL - * of a table that cannot page. - * - * Total and idempotent, like every route search schema: a hand-edited - * `?search=` renders the unfiltered list rather than a router error, and running - * the rule on its own output changes nothing. - */ - /** The route's validated search. */ export interface SearchIndexRouteSearch { search?: string; @@ -21,15 +7,6 @@ export interface SearchIndexRouteSearch { export type UncheckedSearchIndexSearch = Record<string, unknown> | SearchIndexRouteSearch; -/** - * The route's `validateSearch`. - * - * The router hands over its *parsed* search, whose default parser JSON-parses - * every value - so a numeric-looking term arrives as a number and a repeated key - * as an array. Only a string is kept, trimmed, and an empty one is dropped: - * `?search=` and no `search` are the same unfiltered list, and keeping the empty - * parameter would write it into every link the router builds to this route. - */ export const normalizeSearchIndexRouteSearch = ( input: UncheckedSearchIndexSearch, ): SearchIndexRouteSearch => { @@ -51,13 +28,6 @@ export const searchIndexSearchParams = ( return params; }; -/** - * A query string that box produced, back as route search. - * - * The return leg. `withTableSearch` also clears the cursor triplet on its way - * through, which this screen has none of - so everything but `search` is dropped - * here, which is the same answer arrived at from the other side. - */ export const searchIndexSearchFrom = ( nextSearch: string, ): SearchIndexRouteSearch => diff --git a/packages/vitnode/src/tanstack/admin/search-index/route.tsx b/packages/vitnode/src/tanstack/admin/search-index/route.tsx index e1d338923..92adb3dac 100644 --- a/packages/vitnode/src/tanstack/admin/search-index/route.tsx +++ b/packages/vitnode/src/tanstack/admin/search-index/route.tsx @@ -11,18 +11,6 @@ import { searchIndexQuery } from "./query"; * nothing a route owns. */ -/** - * What this screen renders strings from. - * - * `core.search` and not an `admin.*` namespace, which looks wrong and is not: - * the search index's AdminCP copy lives under `core.search.admin.*`, beside the - * public feed's, because the collection labels and the result-type names are the - * same strings on both surfaces. The Next.js page declares the identical - * `<I18nProvider namespaces="core.search">`. - * - * `core.global` is the table chrome - the search placeholder, the confirm - * dialog's buttons and the error toasts. - */ export const ADMIN_SEARCH_INDEX_NAMESPACES = [ "core.global", "core.search", @@ -34,26 +22,11 @@ export interface AdminSearchIndexRouteData { title: string; } -/** - * The tuple `<AdminPermissionRequired module="system" permission="can_view">` - * states in the Next.js page, and the one every `/admin/debug/search/*` route - * declares - the status read and both mutations. - */ const SEARCH_INDEX_PERMISSION = { module: "system", permission: "can_view", } as const; -/** - * Both reads this screen needs, in parallel, before it renders. - * - * The permission is checked first, so an administrator who may not open the - * screen never sends a request the API is going to refuse. - * - * A refusal is left to propagate rather than caught. This screen's whole job is - * to say whether search is healthy; a failed read rendered as "unhealthy, zero - * documents" would be a false alarm, and rendered as "healthy" would be a lie. - */ export const loadAdminSearchIndexRoute = async ({ adminAccess, locale, diff --git a/packages/vitnode/src/tanstack/admin/search-index/screen.tsx b/packages/vitnode/src/tanstack/admin/search-index/screen.tsx index 2b06d6a7e..8d8464782 100644 --- a/packages/vitnode/src/tanstack/admin/search-index/screen.tsx +++ b/packages/vitnode/src/tanstack/admin/search-index/screen.tsx @@ -23,30 +23,11 @@ import { ADMIN_SEARCH_INDEX_NAMESPACES } from "./route"; import { searchIndexSearchFrom, searchIndexSearchParams } from "./route-search"; export interface AdminSearchIndexRouteProps extends AdminSearchIndexRouteData { - /** - * Names for the collections a Content Engine content type contributes. - * - * Optional, and absent in Stage 12. They come from the *frontend* content-type - * registry, which is server-side config: the Next.js page reads it directly, - * and a TanStack Start host can only pass them once it has a browser-side - * registry - the same seam `AdminShellContent`'s `declarations` prop is - * waiting on. Without them a content collection falls back to the search - * renderer's own label, and its `itemType` is still shown, so nothing is - * hidden - it is named less well. - */ collectionLabels?: Map<string, string>; navigate: AdminTableNavigate<SearchIndexRouteSearch>; search: UncheckedSearchIndexSearch; } -/** - * `/admin/core/advanced/search`, as everything below a route file's `component`. - * - * The header's "rebuild everything" button and the two row actions share one - * `actions` object, so all three refresh the same way: a query invalidation on - * success. `useSearchIndexActions` is the TanStack half of the pair whose other - * half is `useSearchIndexActionsNext`. - */ export const AdminSearchIndexRouteContent = ({ collectionLabels, description, diff --git a/packages/vitnode/src/tanstack/admin/search.tsx b/packages/vitnode/src/tanstack/admin/search.tsx index 619938117..5c3a7941e 100644 --- a/packages/vitnode/src/tanstack/admin/search.tsx +++ b/packages/vitnode/src/tanstack/admin/search.tsx @@ -10,43 +10,13 @@ import { SearchAdminContent } from "@/views/admin/layouts/search/search-content" import { RouterLink } from "../layout/router-link"; import { useAdminSearchNavItems } from "./nav"; -/** - * The AdminCP command palette, on TanStack Start. - * - * Everything visible is `SearchAdminContent`'s - the same control and the same - * dialog the Next.js AdminCP renders. What is here is the three things it - * refuses to decide: - * - * the index -> useAdminSearchNavItems() the one filtered nav tree - * a link -> LinkComponent the router's, or the host's - * a lookup -> searchUsers the host's server function - * - * ## The index is a read, not a build - * - * `useAdminSearchNavItems` returns the flattening of the navigation the sidebar - * is rendering, plus the explicitly-gated pages that are not in it. The palette - * never assembles a tree of its own, so there is no second place a permission - * filter could be forgotten - which is the failure that turns a search box into - * a directory of every screen an admin cannot open. - * - * ## `searchUsers` is optional, and its absence is a real state - * - * A host that has not wired the admin user lookup yet passes nothing, and the - * palette is pages-only: the users group never appears, and no request is made. - * That is better than a stub that resolves to `[]`, which would render the "keep - * typing" hint and then silently nothing, as though every query had no matches. - */ export const AdminSearch = ({ LinkComponent = RouterLink, onNavigate, searchUsers, }: { LinkComponent?: AuthLinkComponent; - /** - * How the palette goes somewhere. Defaults to a plain router navigation; a - * host passes its own when a destination needs preparing first - `apps/web` - * de-localizes the href so the router is handed the spelling its tree uses. - */ + onNavigate?: (href: string) => void; searchUsers?: AdminUserSearch; }) => { diff --git a/packages/vitnode/src/tanstack/admin/session-api.ts b/packages/vitnode/src/tanstack/admin/session-api.ts index fa27d78e8..edfc11d3b 100644 --- a/packages/vitnode/src/tanstack/admin/session-api.ts +++ b/packages/vitnode/src/tanstack/admin/session-api.ts @@ -5,21 +5,6 @@ import type { AdminSessionRead, } from "./state"; -/** - * The admin session's shape, read off the server-side read rather than written - * out again. - * - * The response is a Zod schema in - * `api/modules/admin/routes/session.route.ts` - `{ user, permissions, - * vitnode_version }` - and it reaches here through the fetcher's inference, so a - * field added or renamed there arrives without anybody editing this file. A - * hand-maintained copy is a second source of truth that typechecks perfectly - * while disagreeing with the server. - * - * The import is type-only, which is what makes the indirection safe: TypeScript - * erases it, so a browser bundle that reads {@link AdminSessionApi} does not - * reach `./server`, its `server-only` marker, or the request scope behind it. - */ export type AdminSessionApi = Extract< Awaited<ReturnType<typeof readAdminSessionOnApi>>, AdminSessionGranted<unknown> diff --git a/packages/vitnode/src/tanstack/admin/session-query.test.ts b/packages/vitnode/src/tanstack/admin/session-query.test.ts index 5cfefa5e0..43743b7aa 100644 --- a/packages/vitnode/src/tanstack/admin/session-query.test.ts +++ b/packages/vitnode/src/tanstack/admin/session-query.test.ts @@ -17,25 +17,6 @@ import { } from "./state"; import { setAdminTransport } from "./transport"; -/** - * The canonical admin query's policy, and the two properties a route guard's - * correctness rests on: that a failure never becomes a denial, and that one - * administrator's answer cannot be reused for the next. - * - * No render, no request and no DOM: `adminSessionQueryOptions()` is an object, - * and everything below drives a `QueryClient` held in memory with the transport - * stubbed. What is being exercised is this package's *reading and forgetting* - * rules, because those are the rules whose being wrong is silent. - */ - -/** - * The application's half, stubbed - and stubbed through the real seam. - * - * `setAdminTransport` is exactly how `apps/web` supplies its `createServerFn`, - * so nothing here is mocked away: the query definition under test resolves its - * reader the same way it does in production, and a change to how it reaches the - * transport would fail these tests rather than slip past a module mock. - */ let nextRead: AdminSessionReadResult = { status: "denied" }; let reads = 0; @@ -90,15 +71,6 @@ describe("the canonical admin session query", () => { expect(adminSessionQueryOptions().retry).toBe(false); }); - /** - * The revocation contract, as one number. - * - * Not `Infinity`, and not the 30 seconds the public session takes. Removing - * somebody's admin access has to take effect promptly, and the caching that - * makes that affordable lives in the API - Redis, 60 seconds, invalidated by - * every mutation that changes the answer. The browser keeps no copy of its - * own. - */ it("trusts nothing it has already read", () => { expect(adminSessionQueryOptions().staleTime).toBe(0); }); @@ -128,14 +100,6 @@ describe("what a read resolves to", () => { }); }); - /** - * The single most important assertion in this feature. - * - * A `429`, a `500` and an unreachable API all reject. If any of them resolved - * as `{ status: "denied" }`, the `_admin` guard would redirect a working - * administrator to the sign-in form during an outage - for a session they - * already hold. - */ it.each([ ["an API failure", { httpStatus: 500, status: "api_error" } as const], ["a rate limit", { httpStatus: 429, status: "api_error" } as const], @@ -175,15 +139,6 @@ describe("what a read resolves to", () => { }); }); -/** - * The preload path - the one place the zero stale time does not apply. - * - * `defaultPreload: 'intent'` runs a route's whole `beforeLoad` chain on a hover, - * and router-core has no staleness gate on `beforeLoad`, so before Stage 14 the - * guard asked the API once per link a mouse crossed. What is asserted here is - * the pair of properties that makes the fix safe rather than merely cheap: a - * hover reuses the answer, and a navigation still does not. - */ describe("a hover reuses the answer a navigation would not", () => { it("asks once for a run of preloads", async () => { nextRead = adminA; @@ -235,13 +190,6 @@ describe("a hover reuses the answer a navigation would not", () => { }); }); - /** - * An unreadable session is still not a denial, on the preload path either. - * - * This is why the preload uses `fetchQuery` rather than the tolerant - * `prefetchAdminAccess`: a caller gets a decision or a rejection, never - * `undefined` wearing the shape of an answer. - */ it("rejects a failed read rather than answering with a denial", async () => { nextRead = { status: "api_error" }; const queryClient = client(); @@ -302,15 +250,6 @@ describe("staleness", () => { }); }); -/** - * Admin A signs out, Admin B signs in, in the same browser. - * - * The `QueryClient` is per request on the server and per *browser* on the - * client, so this is the one lifetime where two administrators can meet. The - * isolation is bought here, by forgetting, rather than by anything in the key - - * the browser cannot name the admin the cookie belongs to until the query - * answers, so there is nothing trustworthy to key by. - */ describe("one administrator's answer is never reused for the next", () => { it("leaves nothing to render after a removal", async () => { nextRead = adminA; @@ -370,13 +309,6 @@ describe("one administrator's answer is never reused for the next", () => { }); }); -/** - * The sign-in screen's read, which must survive an outage. - * - * `ensureAdminAccess` rejecting is right for the guard and wrong here: the same - * rejection would replace the AdminCP's only entrance with an error page, so a - * partial outage would leave nobody able to sign in and fix it. - */ describe("the tolerant read the sign-in screen uses", () => { it("answers with the decision when there was one", async () => { nextRead = adminA; diff --git a/packages/vitnode/src/tanstack/admin/session-query.ts b/packages/vitnode/src/tanstack/admin/session-query.ts index 24cdbe349..022cdc94f 100644 --- a/packages/vitnode/src/tanstack/admin/session-query.ts +++ b/packages/vitnode/src/tanstack/admin/session-query.ts @@ -11,159 +11,12 @@ import { } from "./state"; import { adminTransport } from "./transport"; -/** - * The admin session, owned by the QueryClient the application already owns. - * - * There is no new client, no provider and no admin store here, and that is the - * design: the host puts one `QueryClient` in its router context, created once - * per server request and once in the browser (`apps/web/src/router.tsx`). That - * lifetime is exactly what an admin session needs - per request on the server, - * so one administrator's permissions can never be rendered into another's page, - * and one per browser on the client, so navigating around the AdminCP does not - * re-ask on every link. - * - * Which also means the SSR dehydration carries this administrator's permission - * set into this administrator's HTML. Correct, and worth stating: that document - * is personalised and must not be served from a shared cache. - */ - -/** - * How long the router may trust a cached admin session. It may not. - * - * `0`, which is a deliberate departure from the 30 seconds the *public* session - * takes. That window exists to make preloading cheap - the router runs with - * `defaultPreload: 'intent'`, so hovering a public link runs its `beforeLoad`, - * and being wrong about a public session for half a minute costs a stale header. - * - * An admin permission set is not that. Removing somebody's admin access has to - * take effect promptly, and every layer below this one is already built for it: - * `SessionAdminModel.getUser()` re-runs `checkIfUserIsAdmin` against the - * database even on a cache hit and *deletes the session* the moment the answer - * turns false, and `staff-permission-cache.ts` keys its 60-second Redis entries - * by a generation stamp that every role-shaped mutation moves. The API is - * therefore the thing that caches, with explicit invalidation, and the browser's - * job is to keep no copy of its own. `getSessionAdminApi()`'s comment says the - * same thing about the Next.js side. - * - * So: `staleTime: 0`. Not a smaller number, and emphatically not `Infinity` - a - * permission set that never expires is one an administrator keeps for as long as - * the tab is open, which is precisely the revocation hole this whole feature is - * shaped to avoid. The cost is one request per admin navigation, against an - * endpoint whose database work is already cached in Redis. - * - * *Per navigation.* That is the sentence this constant was always making, and - * until Stage 14 it was not the sentence the router was reading - see - * {@link ADMIN_SESSION_PRELOAD_STALE_TIME}. - */ const ADMIN_SESSION_STALE_TIME = 0; -/** - * How long a *preload* may trust the cached admin session. Half a minute. - * - * The one place the zero above does not apply, and the reason it needs saying is - * that nothing was saying it. `defaultPreload: 'intent'` means hovering a link - * runs that route's whole `beforeLoad` chain, and unlike a loader a `beforeLoad` - * has no staleness gate of its own - router-core re-runs it from the top of the - * branch on every preload. So `_admin`'s guard fired on every hover, went - * through {@link ensureAdminAccess}, found `staleTime: 0`, and asked the API - * again. Moving a mouse across the AdminCP sidebar was one server-function POST - * and one Hono call per link it crossed. - * - * The public session has never had this problem, and its own 30-second window is - * documented as existing precisely to avoid it (`SESSION_STALE_TIME` in - * `tanstack/auth/session-query`). The admin session opted out of that reasoning - * without anybody writing down that it had. This is the same number for the same - * reason, applied to the same event. - * - * ## It does not weaken revocation, because revocation was never about hovering - * - * A hover renders nothing, admits nobody and shows no permission. Every property - * `ADMIN_SESSION_STALE_TIME` buys is a property of *entering* a screen, and - * entering one is a real navigation, where `beforeLoad` runs again with - * `preload: false` and reads through {@link ensureAdminAccess} at `staleTime: 0` - * exactly as before. Router-core guarantees the second run: a preloaded match's - * `beforeLoad` result is never adopted by a navigation (the only path that skips - * it is the hydration handoff, where the server already ran it), which is what - * makes a tolerant preload safe rather than merely cheap. - * - * So the worst case is that a hover warms a decision up to 30 seconds old, and - * an administrator whose access was revoked 5 seconds ago gets a preloaded - * component chunk they are then refused when they click. Which is what happens - * to an administrator who never hovers at all. - * - * Raising {@link ADMIN_SESSION_STALE_TIME} to get the same saving would have - * been the wrong fix and is explicitly out of bounds: it would apply the window - * to navigations too, which is the revocation hole. Two constants, because there - * are two events. - */ const ADMIN_SESSION_PRELOAD_STALE_TIME = 30_000; -/** - * How long a *detached* entry lingers before Query collects it. - * - * Short, for the same reason the stale time is zero, and for a second one: - * `gcTime` is what governs an entry with no observers - the AdminCP left in a - * background tab, a screen navigated away from. The default five minutes is a - * five-minute window in which a permission set nobody is rendering is still - * sitting in memory waiting to be re-adopted. - * - * Not zero, though, and the distinction matters. A `0` here collects the entry - * the instant its last observer unmounts, which includes the gap between a - * route's loader filling it and the route's component mounting to read it - so - * the loader's work would be thrown away and the component would fetch again. - * A minute is long enough that no navigation can fall through it and short - * enough that nothing survives being walked away from. - * - * Neither of these is the mechanism that isolates two administrators. That is - * {@link removeAdminSession}, which runs on sign-in and sign-out and does not - * wait for a clock. - */ const ADMIN_SESSION_GC_TIME = 60_000; -/** - * The AdminCP session, as VitNode's one query definition. - * - * Every caller goes through this - the `_admin` guard, the shell's loader, the - * permission provider, a component reading a permission back - so all of them - * share one cache entry and one fetch. Two definitions of the same read would be - * two entries, and the one the guard filled would not be the one the sidebar - * renders from. - * - * `readAdminSession` is the application's `createServerFn` - the one primitive - * this package may not declare, which is why it arrives through `./transport` - - * so the fetch happens on the server both times: directly during SSR, and over - * same-origin RPC on client navigation, which is what carries the admin cookie - * to a place that may read it. The browser never talks to the admin session - * endpoint itself. - * - * ## It asks once - * - * `retry: false`, departing from Query's default of three attempts with backoff. - * This read is a route guard, not background content: it runs inside - * `beforeLoad`, and a navigation is blocked for as long as it takes. - * - * Retrying makes every failure worse in the same way. A `429` from the rate - * limiter is answered by sending the same request two more times, which is the - * thing the limiter is asking the app to stop doing; a `500` turns one round - * trip into three before the AdminCP can show anything, so it appears to hang - * rather than to fail. Neither retry can succeed at anything the first could - * not - admin access is whatever the cookie and the staff tables say, and asking - * again does not change either. - * - * ## A failure rejects. It never becomes a denial - * - * The transport resolves with a discriminated value because a thrown error does - * not survive a server-function boundary with its kind intact. This is where - * that value becomes a rejection again: a decision (`granted` or `denied`) - * resolves, and a failure (`api_error`, `network_error`) throws - * {@link AdminSessionUnavailableError} carrying which one it was. - * - * That is the single most important line in this feature. Resolving a failure as - * `denied` would take a working administrator during an outage, decide they are - * not one, and bounce them to a sign-in form for a session they already hold - - * the exact bug `readSessionOnApi` was rewritten to remove on the public - * session, and the one the Next.js `getSessionAdminApi()` still has. - */ export const adminSessionQueryOptions = () => queryOptions({ gcTime: ADMIN_SESSION_GC_TIME, @@ -179,68 +32,11 @@ export const adminSessionQueryOptions = () => staleTime: ADMIN_SESSION_STALE_TIME, }); -/** - * The admin access decision, reading the session first if what is cached cannot - * be trusted. - * - * What `_admin`'s `beforeLoad` calls, and the only function it needs. It returns - * the decision material and leaves the decision to the caller: no `redirect()` - * here on purpose, because where a blocked administrator is sent is a property - * of the route that blocked them, so it belongs in the route tree - which is - * also the only layer that should be importing the router. - * - * ## Why `fetchQuery` and not `ensureQueryData` - * - * They differ in exactly one case, and it is the case a guard exists for. - * `ensureQueryData` returns whatever is cached the moment anything is cached - - * no staleness check, and **no check of whether the entry was invalidated**. So - * a guard reading through it could not see an admin sign-in that had just - * happened, and would decide on the previous state. - * - * `fetchQuery` asks the query itself, through `isStaleByTime`. With - * {@link ADMIN_SESSION_STALE_TIME} at zero that means it always asks the API, - * which is the point: this is the one read in VitNode that is deliberately not - * allowed to be answered from memory. - * - * Two routes guarding themselves during one navigation still share the single - * in-flight request, because `query.fetch()` returns the promise already in - * flight rather than starting a second one. - * - * ## It resolves only when admin access is actually known - * - * An `AdminAccessState` comes back when - and only when - the API answered. - * A read that could not be evaluated rejects, and that rejection is meant to - * propagate out of `beforeLoad` as an ordinary route error. A caller must - * therefore not treat a rejection as "not an admin". Only - * `status === "denied"`, on an answer the API actually gave, means that. - */ export const ensureAdminAccess = async ( queryClient: QueryClient, ): Promise<AdminAccessState> => await queryClient.fetchQuery(adminSessionQueryOptions()); -/** - * The same decision, for a `beforeLoad` that is only preloading. - * - * What `_admin`'s guard calls when the router hands it `preload: true` - a - * hover, a viewport prefetch, an abandoned intent. Same query definition, same - * key, same single in-flight request, same rejection on a read that produced no - * decision. One difference, and it is the whole point: the entry is trusted for - * {@link ADMIN_SESSION_PRELOAD_STALE_TIME} instead of not at all, so crossing a - * sidebar full of links costs one round trip rather than one per link. - * - * `fetchQuery` rather than `prefetchQuery`, deliberately, and it is the reason - * this is not simply {@link prefetchAdminAccess} under another name. That one is - * *tolerant* - it swallows a failed read so the sign-in form can still render - - * and tolerance is wrong here: a caller of this gets an `AdminAccessState` or a - * rejection, never `undefined` masquerading as an answer. A preload whose - * session read failed does nothing, exactly as it does today. - * - * The staleness override is passed per call rather than stored on the query. - * `fetchQuery` decides with the `staleTime` it was handed, so the next real - * navigation's {@link ensureAdminAccess} - which passes `0` - asks the API again - * whatever this left behind. - */ export const preloadAdminAccess = async ( queryClient: QueryClient, ): Promise<AdminAccessState> => @@ -249,25 +45,6 @@ export const preloadAdminAccess = async ( staleTime: ADMIN_SESSION_PRELOAD_STALE_TIME, }); -/** - * Fill the admin session entry without letting a failed read take the page down. - * - * What the `/admin` sign-in screen's guard calls, and the one place a tolerant - * read is correct. `ensureAdminAccess` is the wrong tool there in a specific - * way: it rejects when the session cannot be read, which is exactly right for a - * guard protecting the AdminCP - an outage must not sign anybody out - and - * exactly wrong for the sign-in page, where the same rejection would replace the - * form with an error screen and leave the AdminCP with no way in at all. - * - * `prefetchQuery` is the difference: same query definition, same key, same - * single in-flight request, and a failure is recorded in the cache entry instead - * of thrown. So the sign-in form renders, and an administrator can sign in - * during a partial outage. - * - * Resolves to the decision when there was one, and `undefined` when there was - * not - so a caller can only redirect an already-signed-in administrator away - * from the form on an answer the API actually gave. - */ export const prefetchAdminAccess = async ( queryClient: QueryClient, ): Promise<AdminAccessState | undefined> => { @@ -276,18 +53,6 @@ export const prefetchAdminAccess = async ( return queryClient.getQueryData(adminSessionQueryOptions().queryKey); }; -/** - * Mark the cached admin session stale, so the next reader fetches the truth. - * - * For the mutations that change what an administrator may do without changing - * *who* they are - editing their own role, a staff permission update applied to - * themselves. Invalidating rather than removing keeps the current sidebar on - * screen while the fresh answer is fetched, instead of blanking the shell. - * - * Not what a sign-in or a sign-out calls. When the identity behind the cookie - * changes, keeping the previous answer on screen for even one frame is the thing - * to avoid - see {@link removeAdminSession} in `./state`. - */ export const invalidateAdminSession = async ( queryClient: QueryClient, ): Promise<void> => diff --git a/packages/vitnode/src/tanstack/admin/shell.tsx b/packages/vitnode/src/tanstack/admin/shell.tsx index 4cbb7e724..33f8d765f 100644 --- a/packages/vitnode/src/tanstack/admin/shell.tsx +++ b/packages/vitnode/src/tanstack/admin/shell.tsx @@ -26,76 +26,6 @@ import { AdminPermissionsProvider } from "./permissions"; import { AdminSearch } from "./search"; import { AdminUserBar } from "./user-bar"; -/** - * The AdminCP shell, on TanStack Start. - * - * The same document the Next.js `AdminLayout` renders - floating sidebar, a - * 16-unit sticky header carrying the trigger, the breadcrumb, the palette and - * the user menu, and one `<main>` - assembled from the same components. The two - * frameworks cannot drift into two AdminCPs because there is only one set of - * parts. - * - * ## It is its own shell, not a page inside the public one - * - * Mounted by `_admin`, which is a sibling of `_main` rather than a child. The - * AdminCP has its own header, its own navigation and its own `<main>`; nesting - * it under the public shell would give every admin screen the site header above - * it and a second `<main>` landmark inside it. `SidebarInset` *is* the `<main>`, - * exactly as in the Next.js layout, so a page renders its own container and - * padding and never a landmark. - * - * ## What the shell mounts, in order, and why - * - * RouteMessages the shell's strings, plus the navigation's - * AdminPermissionsProvider the resolved permission set - * AdminNavProvider one navigation, read by sidebar + palette + trail - * SidebarProvider the open/collapsed state and the mobile drawer - * - * `RouteMessages` is outermost, and it is the shell's own provider rather than - * something a page can be relied on to bring. Most of what the chrome renders - - * the palette's label, the user menu, core's own sidebar entries - is an - * `admin.global` key, and the root route provides `core.global` alone. Without a - * provider here `useTranslations` in `AdminNavProvider` finds no message for - * `admin.global.nav.core` and renders the key itself, so the whole sidebar reads - * as dotted identifiers in every language. A page's own `RouteMessages` cannot - * cover it: the pages mount *below* `{children}` and the chrome is above them. - * - * The rest is the navigation's, and it is why {@link AdminShellContent} takes a - * `nav` bundle rather than bare declarations. A plugin group's heading is - * `{pluginId}.title`, a content type's noun is under `{pluginId}.content.…`, and - * a declared entry is under `{pluginId}.admin.nav` - none of which a package can - * know in advance. `adminNavBundle` derives that list from the same declarations - * the sidebar is built from, so the two cannot disagree, and `adminShellNamespaces` - * joins it to the shell's own two. - * - * It reads rather than fetches - `_admin`'s loader has already warmed the - * identical `intlQueryOptions` through `loadAdminMessages`, from the same - * namespace list - so nothing suspends here. A screen still mounts its own with - * its feature namespace, which replaces this one for its subtree; both lists - * start with `core.global`, so nothing a page renders loses the design system's - * strings. - * - * The permission provider is outermost of the two data providers because the - * navigation is a function of what it holds. Neither suspends by the time this - * renders: `_admin`'s `beforeLoad` has already resolved the admin session, and a - * failed read never arrives here at all - the query rejects and the route's - * error boundary owns the screen. An empty permission set reaching this tree - * always means "asked, and this browser holds nothing", never "could not find - * out". - * - * ## What a host still supplies - * - * Only what a package cannot answer: how the palette moves without a link - * (`onNavigate` - Enter on a highlighted entry is a navigation nobody clicked), - * which language switcher to render, how to look a user up, and its own AdminCP - * navigation - the declarations *and* the namespaces they need - built from the - * plugins that application actually configured. `LinkComponent` is there too, - * but it defaults to `RouterLink` and most hosts leave it alone. - * - * Sign-out is deliberately **not** on that list. It is the canonical action plus - * the shell's own cache clearing, and a host that could replace it could replace - * it incompletely - see the note on `AdminUserBar`. - */ export const AdminShellContent = ({ children, languageSwitcher, @@ -108,15 +38,7 @@ export const AdminShellContent = ({ /** The host's language switcher, or nothing on a single-language install. */ languageSwitcher?: React.ReactNode; LinkComponent?: AuthLinkComponent; - /** - * This installation's AdminCP navigation, from `adminNavBundle(...)`. - * - * The declarations and the namespaces they render from, together, because - * passing one without the other is a sidebar of dotted identifiers rather than - * an error. Omitted, the shell renders core's own navigation and loads only - * its own strings - which is exactly right for an application with no plugin - * navigation to declare. - */ + nav?: AdminNavBundle; onNavigate?: (href: string) => void; searchUsers?: AdminUserSearch; @@ -147,13 +69,6 @@ export const AdminShellContent = ({ ); }; -/** - * The frame, split from the providers so it can read them. - * - * A component cannot consume a context its own element mounts, and the sidebar - * needs the navigation - so the providers go above and everything that reads - * them goes here. - */ const AdminShellFrame = ({ children, languageSwitcher, @@ -169,14 +84,7 @@ const AdminShellFrame = ({ }) => { const nav = useAdminNav(); const breadcrumb = useAdminBreadcrumb(); - /** - * Where the visitor is, as the router's *internal* pathname. - * - * Subscribed through `useRouterState` rather than read from a match, because - * the sidebar highlight has to change on every navigation within the subtree - - * including the ones that do not remount this. `/admin` is outside the - * localized URL space, so there is no locale prefix to strip. - */ + const pathname = useRouterState({ select: state => state.location.pathname }); return ( diff --git a/packages/vitnode/src/tanstack/admin/sign-in-route.tsx b/packages/vitnode/src/tanstack/admin/sign-in-route.tsx index e0598e187..af8833567 100644 --- a/packages/vitnode/src/tanstack/admin/sign-in-route.tsx +++ b/packages/vitnode/src/tanstack/admin/sign-in-route.tsx @@ -4,29 +4,6 @@ import type { AdminLoaderContext } from "./intl"; import { intlQueryOptions } from "../i18n/query"; -/** - * `/admin` - the AdminCP sign-in screen, as everything below a route file's - * `component`. - * - * A page, not a shell. It sits *outside* `_admin`, because `_admin` is the - * admin-session guard and putting a guard in front of the page that exists to - * create a session is a closed loop. Same shape as `/login` against - * `_authenticated`, for the same reason. - */ - -/** - * What this screen renders strings from. - * - * `core.global` for the design-system copy and the form's error toast, - * `core.auth.sign_in` for the fields and the access-denied alert. It is the same - * pair the Next.js `SignInAdminView` asks for - `<I18nProvider - * namespaces={["core.auth.sign_in"]}>` plus the `core.global` that provider - * always prepends - so the screen's strings survive the migration unchanged. - * - * Deliberately *not* `admin.global`. The sign-in screen renders no shell: no - * sidebar, no search, no user bar. Warming the shell's namespace here would ship - * an administrator's whole navigation vocabulary to a page that has none. - */ export const ADMIN_SIGN_IN_NAMESPACES = [ "core.global", "core.auth.sign_in", @@ -37,16 +14,6 @@ export interface AdminSignInRouteData { title: string; } -/** - * The page title, translated once in the request's language. - * - * The cast is what makes `createTranslator` usable at all: its key type is - * derived from the *inferred* type of `messages`, and `AbstractIntlMessages` is - * a bare index signature - so `MessageKeys` collapses to `never` and every key - * is a type error. Naming the one key this route reads is both the smallest fix - * and a true statement: rename `login` in `locales/en.json` and this stops - * compiling instead of rendering a raw message key into a `<title>`. - */ const translateAdminSignInTitle = (locale: string, messages: unknown): string => createTranslator({ locale, @@ -54,18 +21,6 @@ const translateAdminSignInTitle = (locale: string, messages: unknown): string => namespace: "core.global", })("login"); -/** - * The one read this screen needs, before it renders. - * - * Not repeated by the component: `RouteMessages` reads the messages back through - * the identical `intlQueryOptions`, so the entry the loader filled is the entry - * the provider mounts. - * - * The admin session is deliberately *not* fetched here. The route's `beforeLoad` - * has already looked - tolerantly, through `prefetchAdminAccess`, so a failed - * read leaves the form on screen rather than replacing the AdminCP's only - * entrance with an error page. - */ export const loadAdminSignInRoute = async ({ locale, queryClient, diff --git a/packages/vitnode/src/tanstack/admin/sign-in-screen.tsx b/packages/vitnode/src/tanstack/admin/sign-in-screen.tsx index 297a8750e..1b98fc60c 100644 --- a/packages/vitnode/src/tanstack/admin/sign-in-screen.tsx +++ b/packages/vitnode/src/tanstack/admin/sign-in-screen.tsx @@ -11,32 +11,11 @@ import { sanitizeAdminReturnTo } from "./return-to"; import { ADMIN_SIGN_IN_NAMESPACES } from "./sign-in-route"; export interface AdminSignInRouteProps { - /** - * How a finished sign-in moves. During the migration a host passes one that - * asks its route tree whether it serves the destination, so `/admin/core` - * is a document load into the Next.js AdminCP until that route lands here. - */ navigate: AuthNavigate; /** Where the administrator was heading before the guard sent them here. */ returnTo?: string; } -/** - * The AdminCP sign-in page. - * - * The card, the logo and the form are shared with the Next.js screen; the action - * is `useAdminSignInAction`, which signs in against the admin session and drops - * the cached one before it navigates. - * - * `sanitizeAdminReturnTo` is applied *here*, where the value is used, rather - * than in the route's `validateSearch`. The search contract keeps whatever - * arrived and judges nothing - the same split `/login` uses - so there is one - * place that decides whether a target is somewhere this app may send a browser, - * and it is the place that hands it to a navigation. - * - * A thunk rather than a computed value, so the destination is read at submit - * time: `?returnTo=` can change under a mounted form. - */ export const AdminSignInRouteContent = ({ navigate, returnTo, diff --git a/packages/vitnode/src/tanstack/admin/staff/breadcrumbs.tsx b/packages/vitnode/src/tanstack/admin/staff/breadcrumbs.tsx index 70518f350..9e85cd83d 100644 --- a/packages/vitnode/src/tanstack/admin/staff/breadcrumbs.tsx +++ b/packages/vitnode/src/tanstack/admin/staff/breadcrumbs.tsx @@ -13,23 +13,6 @@ import { import { RouteMessages } from "../../i18n/route-messages"; import { AdminBreadcrumb } from "../breadcrumb"; -/** - * The trail above the three staff screens. - * - * Two of its crumbs cannot be resolved from the navigation, which is what - * `resolveBreadcrumb` normally does: `/admin/core/staff` is a nav *group* with no - * page of its own, and the list beneath it appears in the sidebar under a - * shorter name than the page's own heading. So both are named explicitly, from - * `staffBreadcrumbLabels` - the same helper the Next.js `@breadcrumb` slots use, - * so the two applications produce the same trail. - * - * ## Each one mounts `RouteMessages` of its own - * - * The shell renders the breadcrumb *above* the route's component, so it is - * outside the provider that component mounts. Without this they would translate - * against the root's `core.global` alone and render raw keys. - */ - const STAFF_BREADCRUMB_NAMESPACES = ["admin.staff", "core.global"] as const; interface StaffBreadcrumbProps { diff --git a/packages/vitnode/src/tanstack/admin/staff/create-route.tsx b/packages/vitnode/src/tanstack/admin/staff/create-route.tsx index ddaae868a..b3c7a3b97 100644 --- a/packages/vitnode/src/tanstack/admin/staff/create-route.tsx +++ b/packages/vitnode/src/tanstack/admin/staff/create-route.tsx @@ -13,20 +13,6 @@ import type { AdminScreenContext } from "../screen"; import { intlQueryOptions } from "../../i18n/query"; import { requireAdminPermission } from "../screen"; -/** - * `/admin/core/staff/{admins,moderators}/create` - adding a role or a user to a - * staff group. - * - * The permission is `staff_admins.can_create` or `staff_moderators.can_create`, - * checked in the loader. The Next.js page checks the same tuple with - * `checkAdminPermissionApi` and answers `notFound()`; this answers the router's - * `notFound()`, which the AdminCP shell renders in place of the page - the same - * outcome, one navigation earlier. - * - * The entry this creates grants **nothing**. Permissions are chosen on the edit - * screen, so a successful create navigates there with the new id. - */ - /** Only the two namespaces the screen renders from - no catalog is read here. */ export const ADMIN_STAFF_CREATE_NAMESPACES = [ "admin.staff", diff --git a/packages/vitnode/src/tanstack/admin/staff/edit-route.tsx b/packages/vitnode/src/tanstack/admin/staff/edit-route.tsx index ab7397a4f..d51ad6ad6 100644 --- a/packages/vitnode/src/tanstack/admin/staff/edit-route.tsx +++ b/packages/vitnode/src/tanstack/admin/staff/edit-route.tsx @@ -28,54 +28,11 @@ import { adminIdentityOf } from "../identity"; import { requireAdminPermission } from "../screen"; import { adminStaffCatalogQuery, adminStaffEntryQuery } from "./query"; -/** - * `/admin/core/staff/{admins,moderators}/edit/$id` - choosing what a staff entry - * may do. - * - * The permission is `staff_admins.can_edit` or `staff_moderators.can_edit`, - * checked in the loader and again by `showPermissionsStaffAdminRoute` and - * `updatePermissionsStaffAdminRoute` on every request. On top of it the API - * refuses two entries outright: a `protected` one, and one that governs the - * *caller's own* access. Both are rendered as a sentence instead of a form, - * exactly as the Next.js page does, because a form that cannot be submitted is - * worse than an explanation. - */ - -/** - * What this screen renders strings from. - * - * Only the screen's own furniture. The *permission labels* are deliberately not - * here - see {@link loadStaffPermissionLabels}. - */ export const ADMIN_STAFF_EDIT_NAMESPACES = [ "admin.staff", "core.global", ] as const; -/** - * Every permission label the catalog needs, as a resolved map. - * - * ## Why this is not a namespace - * - * A plugin declares its permission labels as *flat top-level message keys* - - * `"@vitnode/core:users:can_view"` - so that a plugin's own locale file can - * merge them into one tree without knowing about any other plugin. There is no - * namespace to slice them by: a catalog with ten modules and thirty permissions - * is forty-one separate keys, and the i18n runtime refuses more than - * `MAX_NAMESPACES` per request - a bound on a public `POST` endpoint that exists - * for good reasons and is not going to be relaxed for one screen. - * - * So the labels are fetched as *data* rather than mounted as context: the keys - * are enumerated from the catalog, requested in chunks the runtime accepts, and - * merged into one lookup the pure model turns into the checkbox tree. Each chunk - * is an ordinary `intlQueryOptions` entry, so a second visit to this screen - - * or the other staff list, whose catalog overlaps entirely - is served from - * cache. - * - * Sequential rather than parallel on purpose: the chunks share one underlying - * message load, and firing four requests at a cold cache means four full loads - * of the same tree. - */ export const loadStaffPermissionLabels = async ({ keys, locale, @@ -117,19 +74,6 @@ export interface AdminStaffEditRouteData { unrestricted: boolean; } -/** - * Everything this screen needs, resolved before it renders. - * - * The catalog and the entry are fetched together; the labels depend on the - * catalog, so they follow it. The permission tree is then built by - * `buildStaffPermissionGroups`, which is pure and tested - so what the form - * renders and what the save sends are two views of one model rather than two - * implementations of one rule. - * - * A refusal propagates: `403` here means this administrator may not edit this - * staff group, and `404` means the entry was removed. Both are the router's - * error path rather than a form rendered around missing data. - */ export const loadAdminStaffEditRoute = async ({ adminAccess, id: raw, @@ -143,11 +87,6 @@ export const loadAdminStaffEditRoute = async ({ }): Promise<AdminStaffEditRouteData> => { requireAdminPermission(adminAccess, adminStaffPermissions(type).edit); - /** - * The one place `$id` becomes an entry id - the same rule, and the same - * reason for it being here rather than in `params.parse`, as the user detail - * route. See `users/detail-route.tsx`. - */ const id = normalizeStaffEntryId(raw); if (id === null) { // TanStack Router's own control-flow signal. diff --git a/packages/vitnode/src/tanstack/admin/staff/edit-screen.tsx b/packages/vitnode/src/tanstack/admin/staff/edit-screen.tsx index 793d736d2..0da0b41a3 100644 --- a/packages/vitnode/src/tanstack/admin/staff/edit-screen.tsx +++ b/packages/vitnode/src/tanstack/admin/staff/edit-screen.tsx @@ -109,12 +109,6 @@ const StaffEditHeader = ({ ); }; -/** - * The form, or the sentence that replaces it. - * - * `protected` and `self` are the two entries the API refuses to update, so the - * screen says why instead of offering a save that would answer `403`. - */ const StaffEditBody = ({ grantedKeys, id, diff --git a/packages/vitnode/src/tanstack/admin/staff/index.ts b/packages/vitnode/src/tanstack/admin/staff/index.ts index 6ed3a3948..a27d890cc 100644 --- a/packages/vitnode/src/tanstack/admin/staff/index.ts +++ b/packages/vitnode/src/tanstack/admin/staff/index.ts @@ -4,23 +4,7 @@ export { AdminStaffCreateBreadcrumbContent, AdminStaffEditBreadcrumbContent, } from "./breadcrumbs"; -/** - * `/admin/core/staff/*` - the AdminCP staff screens, for a TanStack Start host. - * - * ./query three query definitions and three writes - each of which - * also invalidates the admin session, because a staff entry - * *is* a permission grant - * ./route both lists: one screen, parameterised by staff type - * ./create-route adding a role or a user to a group - * ./edit-route choosing what an entry may do, and the chunked label load - * the flat permission-message keys require - * ./route-search the URL contract both lists share - * ./server the SSR transports, reached only through `./query` - * - * The rendering is framework-free and lives in - * `@/views/admin/views/core/staff`; the rules the forms apply are - * `staff-model.ts`, which is pure and has its own tests. - */ + export type { AdminStaffCreateRouteData } from "./create-route"; export { ADMIN_STAFF_CREATE_NAMESPACES, diff --git a/packages/vitnode/src/tanstack/admin/staff/query.ts b/packages/vitnode/src/tanstack/admin/staff/query.ts index 98e7e91b0..8ad9da30c 100644 --- a/packages/vitnode/src/tanstack/admin/staff/query.ts +++ b/packages/vitnode/src/tanstack/admin/staff/query.ts @@ -89,33 +89,6 @@ export const adminStaffEntryQuery = ({ type: PermissionStaffType; }) => adminStaffEntryQueryOptions({ adminUserId, fetchEntry, id, type }); -/** - * What a staff write invalidates, and why the admin session is on the list. - * - * A staff entry *is* a permission grant. Creating one, editing one or deleting - * one changes what somebody may do in the panel - and "somebody" can be the - * administrator pressing the button, in more ways than the obvious one: - * - * - The API refuses an edit or a delete of an entry that governs the caller's - * own access, so a *direct* self-demotion cannot happen. - * - It does not, and cannot, refuse everything else. Two administrators can be - * in the panel at once, and one of them editing the other's entry changes what - * the other may do while their tab is open. - * - Nothing stops an administrator granting a *role* they do not hold a - * permission they then acquire by other means. - * - * The sidebar, every permission gate and every screen guard in the AdminCP are - * rendered from one cached entry, `["vitnode","admin-session"]`. Leaving it - * alone after a staff write means the panel goes on offering links the API has - * started refusing until somebody reloads the page - which is exactly what - * `revalidatePath("/[locale]/admin", "layout")` prevents in the Next.js - * AdminCP, and the reason this list has three entries rather than one. - * - * `invalidateAdminSession` rather than `removeAdminSession`: the *identity* has - * not changed, so the current sidebar stays on screen while the fresh answer is - * fetched. Removal is for a sign-in or a sign-out, where keeping the previous - * answer for even one frame is the thing to avoid. - */ export const invalidateAfterStaffChange = async ( queryClient: QueryClient, adminUserId: AdminIdentity, diff --git a/packages/vitnode/src/tanstack/admin/staff/route-search.ts b/packages/vitnode/src/tanstack/admin/staff/route-search.ts index 2f8b50dc6..c3296428c 100644 --- a/packages/vitnode/src/tanstack/admin/staff/route-search.ts +++ b/packages/vitnode/src/tanstack/admin/staff/route-search.ts @@ -17,14 +17,6 @@ import { normalizeAdminTableSearch, } from "../table-search"; -/** - * The URL contract both staff lists share. - * - * One contract for two routes, because administrators and moderators are the - * same table over two API endpoints - `staffListAdminQuery` is literally one - * schema. Every rule these four apply is `tanstack/admin/table-search.ts`'. - */ - export type StaffRouteSearch = AdminTableRouteSearch<AdminStaffOrderBy>; export type UncheckedStaffSearch = UncheckedAdminTableSearch<AdminStaffOrderBy>; diff --git a/packages/vitnode/src/tanstack/admin/staff/route.tsx b/packages/vitnode/src/tanstack/admin/staff/route.tsx index 83d120c77..1a5463f1f 100644 --- a/packages/vitnode/src/tanstack/admin/staff/route.tsx +++ b/packages/vitnode/src/tanstack/admin/staff/route.tsx @@ -14,34 +14,6 @@ import { adminIdentityOf } from "../identity"; import { requireAdminPermission } from "../screen"; import { adminStaffQuery } from "./query"; -/** - * `/admin/core/staff/admins` and `/admin/core/staff/moderators` - one screen, - * two routes. - * - * The two lists differ in exactly one value: the staff type, which decides the - * API endpoint, the permission module and the strings. Everything else - the - * columns, the padlock, the delete, the pager - is identical, which is why - * `type` is a parameter rather than a second copy of this file. - * - * ## The permission model is the Next.js page's, unchanged - * - * staff_admins.can_view the administrators list, and its API route - * staff_admins.can_create the "add" button - * staff_admins.can_edit the row's pencil, and the edit screen - * staff_admins.can_delete the row's bin - * - * ...and `staff_moderators.*` for the other list. `staffPermissionModuleFor` is - * the mapping, and it is the frontend's copy of `staffPermissionModuleByType`. - */ - -/** - * What these screens render strings from. - * - * `admin.staff` is the heading, the tabs, the columns and the delete dialog; - * `core.global` is the table's furniture, the confirm dialog's buttons and the - * error toasts. The same set `<I18nProvider namespaces="admin.staff">` provides - * in the Next.js pages. - */ export const ADMIN_STAFF_NAMESPACES = ["admin.staff", "core.global"] as const; export interface AdminStaffRouteData { @@ -53,14 +25,6 @@ export interface AdminStaffRouteData { type: PermissionStaffType; } -/** - * Both reads, in parallel, after the permission is checked. - * - * The permission is `staff_admins.can_view` or `staff_moderators.can_view` - * depending on the list, which is what `<AdminPermissionRequired>` states in the - * Next.js page and what `listAdminsStaffAdminRoute` / - * `listModeratorsStaffAdminRoute` declare as their `adminStaffPermission`. - */ export const loadAdminStaffRoute = async ({ adminAccess, locale, diff --git a/packages/vitnode/src/tanstack/admin/state.test.ts b/packages/vitnode/src/tanstack/admin/state.test.ts index f88f5e3c0..a121f33f8 100644 --- a/packages/vitnode/src/tanstack/admin/state.test.ts +++ b/packages/vitnode/src/tanstack/admin/state.test.ts @@ -19,16 +19,6 @@ import { isAdminAccess, } from "./state"; -/** - * The admin access rules, as a table. - * - * No render, no request, no DOM - `./state` imports nothing at runtime except - * the permission predicate every frontend already shares, which is what lets the - * whole policy be exercised here rather than through a browser. What is being - * pinned is the half whose being wrong is *silent*: a status the AdminCP would - * read as a denial, or a permission set an outage would produce. - */ - interface TestSession { permissions: StaffPermissionSet; user: { id: number }; @@ -64,14 +54,6 @@ describe("the status policy", () => { expect(adminSessionReadFromStatus(403)).toEqual({ status: "denied" }); }); - /** - * The assertion this whole feature exists for. - * - * A rate limit and a failing database are not permission decisions, and an - * AdminCP that reads them as one signs every administrator out during an - * outage - then presents them with a sign-in form for a session they already - * hold, because the sign-in screen is where a denied administrator is sent. - */ it.each([429, 500, 502, 503, 504])("never reads %i as a denial", status => { const read = adminSessionReadFromStatus(status); @@ -79,14 +61,6 @@ describe("the status policy", () => { expect(read.status).not.toBe("denied"); }); - /** - * The reason the policy is an allowlist rather than `status >= 400`. - * - * Each of these is a reply an inverted rule would have read as an - * administrator holding no permissions: a `204` with no body, a `302` a - * redirect-following fetch turned into somebody's login page, a `401` from a - * proxy in front of the API. - */ it.each([204, 301, 302, 401, 418])( "never reads %i as a grant or a denial", status => { @@ -176,12 +150,6 @@ describe("the permission set an access decision carries", () => { expect(adminPermissionsOf(granted(permissions))).toBe(permissions); }); - /** - * A denial is a real answer, not a failure signal: the API was asked and said - * this browser holds no admin session, so it holds no admin permissions. A - * *failed* read never reaches here - it rejects in the query - which is why - * this can be an empty set without being ambiguous. - */ it("is the shared empty set when denied", () => { expect(adminPermissionsOf(denied)).toBe(EMPTY_STAFF_PERMISSION_SET); }); @@ -226,11 +194,6 @@ describe("checking one permission", () => { ).toBe(true); }); - /** - * The namespacing rule that makes a plugin's permissions its own. A plugin - * declares `module`/`permission` strings that may collide with core's, and the - * `plugin` field is what keeps a grant under one from opening the other. - */ it("does not let a core grant open a plugin's page", () => { expect( hasAdminPermission(granted(setOf(usersView)), { diff --git a/packages/vitnode/src/tanstack/admin/state.ts b/packages/vitnode/src/tanstack/admin/state.ts index b100aee74..9fb1f54e2 100644 --- a/packages/vitnode/src/tanstack/admin/state.ts +++ b/packages/vitnode/src/tanstack/admin/state.ts @@ -11,64 +11,8 @@ import { } from "@/api/lib/staff-permission"; import { CONFIG_PLUGIN } from "@/config"; -/** - * Whether somebody is inside the AdminCP - as route state, and nothing else. - * - * Pure by construction: the only runtime imports are the permission predicate - * every frontend already shares and the core plugin id. That is what lets the - * same rules run in the places that cannot reach each other's runtimes - a - * `beforeLoad` on the server, the same `beforeLoad` in the browser, a component - * reading a provider, and the tests - and it is why the query key is defined - * here rather than beside the query that uses it. - * - * ## What this layer is, and what it is not - * - * TanStack beforeLoad -> navigation and UI guard - * Hono authorization -> the security boundary - * - * `api/config.ts` puts `globalAdminMiddleware()` in front of every request whose - * path contains `/admin/`, and `SessionAdminModel.getUser()` re-runs - * `checkIfUserIsAdmin` against the database even on a cache hit, deleting the - * session the moment the answer turns false. None of that moves here and none of - * it may be relaxed because of what this module decides. Everything below is - * derived from a response the browser can read, so it decides what to *render* - * and where to *navigate* - if it were ever treated as authorization, editing a - * cache entry in devtools would be a privilege escalation. - * - * ## The public session is a different question - * - * `AuthState.isAdmin`, from `tanstack/auth`, means "this visitor may be - * *offered* the AdminCP" - it is a flag on the public session, under the public - * cookie. Being *inside* the AdminCP is a second session under a second cookie - * (`vitnode_auth_admin`), with its own endpoint and its own expiry. Conflating - * them would let a public session decide an admin question, so they stay two - * queries that never read each other. - */ - -/** - * The one cache entry every admin guard, provider and component reads. - * - * Two segments, and deliberately nothing else - no locale, because permissions - * are not translated, and **no user id**. The temptation to key by the signed-in - * user is real and wrong: the browser does not know who the admin cookie belongs - * to until this very query answers, so a user id in the key would have to come - * from the public session - a different cookie, which can name a different - * person or nobody at all. Isolation between two admins is bought by *lifetime* - * instead, which is stated under {@link removeAdminSession}. - */ export const ADMIN_SESSION_QUERY_KEY = ["vitnode", "admin-session"] as const; -/** - * The AdminCP's front door: the sign-in screen, and where a visitor without - * admin access is sent. - * - * Un-prefixed, and it stays that way in every language. - * `DEFAULT_IGNORED_LOCALE_PATHS` in `lib/i18n/locale-routing.ts` lists `/admin` - * with its descendants, so the rewrite never strips a prefix from an admin URL - * and never writes one back. Nothing in this feature concatenates a language - * onto these constants, and `admin-locale.test.ts` pins that `/pl/admin` is not - * a shape any of it can produce. - */ export const ADMIN_ENTRY_PATH = "/admin"; /** The AdminCP's landing page - where a finished sign-in goes by default. */ @@ -77,19 +21,6 @@ export const ADMIN_HOME_PATH = "/admin/core"; /** The search parameter carrying where a blocked admin was heading. */ export const ADMIN_RETURN_TO_PARAM = "returnTo"; -/** - * A decision the API actually made about admin access. - * - * Two members, because the endpoint answers exactly two things: `200` with a - * session, or `403`. Anything else is not a decision - see - * {@link AdminSessionFailure} - and there is deliberately no third member for - * "we could not find out", because a caller that can pattern-match on one would - * eventually treat it as "no". - * - * Generic over the session body so this module stays free of the fetcher's - * inference. `AdminAccessState` in `./session-api` is the concrete alias every - * consumer actually uses. - */ export type AdminAccess<TSession> = AdminSessionDenied | AdminSessionGranted<TSession>; @@ -104,31 +35,6 @@ export interface AdminSessionDenied { status: "denied"; } -/** - * A read that produced no decision at all. - * - * Split in two because the difference is the difference between "the API said - * no" and "the API never got to say anything", and an operator staring at an - * AdminCP that will not open needs to be told which. Neither is a permission - * outcome: - * - * - `api_error` - the API answered, with something that is not `200` or `403`. - * A `429` from the rate limiter and a `500` from a failing query both land - * here. `httpStatus` is carried when a response was actually received, because - * it is the one detail that is safe to show and useless to withhold; the - * response *body* is not, and never leaves the server - see - * `readAdminSessionOnApi`. It is absent when the failure was raised rather - * than returned: `rawApiFetch` throws on a `500` instead of handing one back, - * and a body that will not parse as JSON throws too, so there is no status to - * quote without inventing one. - * - `network_error` - the call itself failed. Nothing was received, so there is - * no status to report at all. - * - * Mapping either of these onto `denied` is the bug this whole union exists to - * prevent: it would sign every administrator out of the AdminCP during an - * outage, and - because the sign-in screen is where a denied admin is sent - - * present them with a login form for a session they already have. - */ export type AdminSessionFailure = { httpStatus?: number; status: "api_error" } | { status: "network_error" }; @@ -142,23 +48,6 @@ export const isAdminAccess = <TSession>( ): read is AdminAccess<TSession> => read.status === "granted" || read.status === "denied"; -/** - * What a status that is not `200` describes: a denial, or a failure. - * - * The load-bearing half of the status policy, and the reason it is written as an - * allowlist rather than as `status >= 400 ? denied : granted`. The failure mode - * of the inverted spelling is silent: a `204`, a `302` that a redirect-following - * fetch turned into somebody's login page, an HTML error page from a proxy in - * front of the API - under a "not 200 means no" rule every one of those is read - * as an administrator who holds no permissions, and the AdminCP quietly empties - * itself. Only `403` is a decision, because `403` is the only thing the session - * route answers when it has decided. - * - * Its own function, separate from {@link adminSessionReadFromStatus}, so the - * server read can call it without a session body in scope and still be inferred - * as `AdminSessionRead<TheSessionShape>` rather than as a union widened by an - * absent one. - */ export const adminSessionFailureFromStatus = ( status: number, ): AdminSessionDenied | AdminSessionFailure => @@ -166,15 +55,6 @@ export const adminSessionFailureFromStatus = ( ? { status: "denied" } : { httpStatus: status, status: "api_error" }; -/** - * The read a status code describes, with the session body when there is one. - * - * The whole status policy as one total function - `200` with a body is the only - * success - so the table can be exercised in a test without a server. A `200` - * carrying no body is *not* a success: the route's `200` is declared with a - * schema, so an empty one is a reply this layer cannot honour, and calling it a - * grant would hand `undefined` to everything that reads `session.permissions`. - */ export const adminSessionReadFromStatus = <TSession>( status: number, session?: TSession, @@ -188,20 +68,6 @@ export const adminSessionReadFromStatus = <TSession>( return adminSessionFailureFromStatus(status); }; -/** - * A raised error as one of the two failure kinds. - * - * `fetch` rejects with a `TypeError` when the request never completed - DNS, - * connection refused, TLS, an aborted socket - in undici (`fetch failed`) and in - * every browser (`Failed to fetch`). Everything else that reaches this point - * came from an API that *did* answer: `rawApiFetch` throws its own `Error` for a - * `500`, and a reply whose body will not parse as JSON throws from `json()`. - * - * So the check is narrow on purpose, and it errs in the safe direction. A - * `TypeError` from somewhere unexpected would be reported as a network problem - * rather than an API one, which is a worse *diagnosis* and not a worse - * *decision* - both reject, and neither becomes a permission outcome. - */ export const adminSessionFailureFromError = ( error: unknown, ): AdminSessionFailure => @@ -209,30 +75,9 @@ export const adminSessionFailureFromError = ( ? { status: "network_error" } : { status: "api_error" }; -/** - * The sentence a caller gets when the admin session could not be read. - * - * Fixed, and a named constant so a test can assert on it without matching - * English. Deliberately says nothing about *why*: the underlying error carries - * the failing API URL and the server's own error text, it has already been - * written to the server log, and this value is rendered in a browser. - */ export const ADMIN_SESSION_UNAVAILABLE = "The admin session could not be read. This is not a permission decision - try again."; -/** - * The rejection a failed admin-session read becomes. - * - * A class rather than a plain `Error` so an error boundary can tell this apart - * from anything else a route threw, and so `failure` survives to whoever renders - * it - the difference between "the API is rate-limiting us" and "the API is not - * answering" is the whole reason {@link AdminSessionFailure} has two members. - * - * Constructed in the query function, on whichever side of the render is reading - * - never carried across a server-function boundary, where a custom class would - * arrive as a plain object with its prototype gone. What crosses that boundary - * is the plain {@link AdminSessionRead} value, and this is built from it here. - */ export class AdminSessionUnavailableError extends Error { constructor(failure: AdminSessionFailure) { super(ADMIN_SESSION_UNAVAILABLE); @@ -251,16 +96,6 @@ export const canEnterAdmin = <TSession>( access: AdminAccess<TSession>, ): boolean => access.status === "granted"; -/** - * The permission set an access decision carries. - * - * `EMPTY_STAFF_PERMISSION_SET` for a denial, and that is a *real answer*: the - * API was asked and said this browser holds no admin session, so it holds no - * admin permissions. It is never the answer to a failed read, because a failed - * read never becomes an `AdminAccess` in the first place - the query rejects - * instead. That distinction is the entire reason this function takes a decision - * rather than a nullable session. - */ export const adminPermissionsOf = <TSession extends { permissions: unknown }>( access: AdminAccess<TSession>, ): StaffPermissionSet => @@ -268,20 +103,6 @@ export const adminPermissionsOf = <TSession extends { permissions: unknown }>( ? (access.session.permissions as StaffPermissionSet) : EMPTY_STAFF_PERMISSION_SET; -/** - * Whether this admin holds a permission - the AdminCP's `checkAdminPermissionApi`, - * with the session handed in rather than fetched. - * - * `hasStaffPermission` is the canonical predicate and stays canonical; this adds - * exactly two things, and both are things the Next.js helper already did: - * reading the set off an access decision, and defaulting `plugin` to - * `@vitnode/core` so a core screen names two fields instead of three. A plugin's - * own screen passes its `pluginId` explicitly - which is the point, because a - * permission granted under core must not open a plugin's page. - * - * `root` short-circuits inside `hasStaffPermission`, so nothing here needs to - * know that root exists. - */ export const hasAdminPermission = <TSession extends { permissions: unknown }>( access: AdminAccess<TSession>, { @@ -296,32 +117,6 @@ export const hasAdminPermission = <TSession extends { permissions: unknown }>( plugin, }); -/** - * Forget the admin session entirely, rather than marking it stale. - * - * The half that keeps one administrator's permissions out of the next one's - * browser. The `QueryClient` is created once per server request and once per - * browser (`getRouter()` in `apps/web/src/router.tsx`), so the server side is - * safe by lifetime - one request, one client, one visitor. The browser's client - * is not: it outlives a sign-out, and the tab that Admin A signed out of is the - * tab Admin B signs in to. - * - * `removeQueries` rather than `invalidateQueries`, and the difference is - * load-bearing. Invalidation *keeps the value* and marks it stale, so the shell - * would go on rendering A's sidebar - every entry A's permissions unlocked - - * until a refetch returned. Removal deletes it, so the next reader has nothing - * to render from and must ask the API, which reads the cookie the browser now - * holds. - * - * Reached through `removeAdminIdentityQueries` in `./queries` rather than called - * directly: every identity boundary - both sign-out flavours, both sign-ins, a - * finished SSO exchange - has to drop the AdminCP's *screen* entries as well as - * this one, and one call that does both is what stops a caller doing half of it. - * The public flows are included on purpose even though they do not touch the - * admin cookie: the person in front of the browser may have changed, and the - * correct response to "who is this?" becoming uncertain is to re-derive the - * answer from the cookie rather than to reuse the last one. - */ export const removeAdminSession = (queryClient: QueryClient): void => { queryClient.removeQueries({ queryKey: ADMIN_SESSION_QUERY_KEY }); }; diff --git a/packages/vitnode/src/tanstack/admin/table-search.test.ts b/packages/vitnode/src/tanstack/admin/table-search.test.ts index 072e37808..cb198d221 100644 --- a/packages/vitnode/src/tanstack/admin/table-search.test.ts +++ b/packages/vitnode/src/tanstack/admin/table-search.test.ts @@ -10,17 +10,6 @@ import { normalizeAdminTableSearch, } from "./table-search"; -/** - * The seam between an AdminCP list route's URL and its request. - * - * `views/admin/table/params.test.ts` proves what a *request* may contain; this - * proves the three shapes around it stay in step - the address bar, the route's - * validated search, and the query string the table's own controls read - * themselves out of. The failures it guards against are the ones that only show - * up in a browser: a page size written into every link, a sort header that - * cannot round-trip its own click, a `first=%2220%22` in the address bar. - */ - const contract: AdminTableContract<"createdAt" | "name"> = { orderBy: ["createdAt", "name"], search: true, @@ -120,11 +109,6 @@ describe("every schema is total and idempotent", () => { }); }); -/** - * The search index screen has one parameter and no pager, so it deliberately - * does *not* use the table contract above - a page size in the URL of a table - * that cannot page would be a parameter nothing reads. - */ describe("the search index screen's single parameter", () => { it("keeps a term", () => { expect(normalizeSearchIndexRouteSearch({ search: "blog" })).toEqual({ diff --git a/packages/vitnode/src/tanstack/admin/table-search.ts b/packages/vitnode/src/tanstack/admin/table-search.ts index 433159bb9..5c39403ec 100644 --- a/packages/vitnode/src/tanstack/admin/table-search.ts +++ b/packages/vitnode/src/tanstack/admin/table-search.ts @@ -8,51 +8,9 @@ import type { import { DEFAULT_TABLE_PAGE_SIZE } from "@/components/table/url-state"; import { normalizeAdminTableParams } from "@/views/admin/table/params"; -/** - * The URL contract every AdminCP list route shares, as four pure functions. - * - * `tanstack/files/route-search.ts` is the same shape written for one screen; - * this is it written once for the four admin tables, because they differ only in - * the {@link AdminTableContract} they declare. No transport and no React, so - * what `?orderBy=name` means can be stated and tested without a router - - * `table-search.test.ts` beside this file is the whole of it. - * - * ## Three shapes, and why they are not one - * - * the URL ?orderBy=name&first=20 what an admin sees and shares - * the search { orderBy: 'name', first: 20 } the route's validated state - * the request { first: '20', orderBy: 'name' } what the API is asked for - * - * The middle one is the URL, validated. The last one is {@link AdminTableParams}, - * which *always* names a page size, because a request must and a URL need not. - * Keeping them apart is what stops `?first=10` being written into every link to - * a page whose canonical address has no query string at all. - */ - -/** - * The page size the URL does not need to mention, for one table. - * - * `DEFAULT_TABLE_PAGE_SIZE` is what every `DataTable` falls back to, and what - * the Next.js fetcher's `withPagination` writes, so `?first=10` and no `first` - * are the same request spelled two ways - and the shorter spelling is the one - * these routes settle on. - * - * A screen whose API defaults to a different size declares it on its contract; - * read from there rather than fixed, so the size a request carries and the size - * the URL omits are one value. - */ const defaultPageSizeOf = (contract: { defaultPageSize?: number }): string => String(contract.defaultPageSize ?? DEFAULT_TABLE_PAGE_SIZE); -/** - * A route's validated search - the URL contract, and nothing else. - * - * `first` and `last` are numbers rather than strings, and that is about the - * address bar rather than about types. TanStack Router's default search - * serializer JSON-encodes a *string* that would parse as JSON, so `'20'` is - * written to the URL as `first=%2220%22`; the number `20` is written as - * `first=20`, which is what the Next.js page produces and what the API reads. - */ export interface AdminTableRouteSearch<TOrderBy extends string = string> { cursor?: string; first?: number; @@ -63,32 +21,9 @@ export interface AdminTableRouteSearch<TOrderBy extends string = string> { status?: string; } -/** - * A search as it arrives, before anything has checked it. - * - * Two shapes, because there are two callers and they are genuinely different. - * The router hands over its *parsed* search - an arbitrary bag of whatever was - * in the query string - and the route hands its own validated search straight - * back in, on every navigation. An `interface` has no implicit index signature, - * so the second is not assignable to the first and the union has to say so. - */ export type UncheckedAdminTableSearch<TOrderBy extends string = string> = AdminTableRouteSearch<TOrderBy> | Record<string, unknown>; -/** - * One search parameter as the string it was in the query string. - * - * The router hands `validateSearch` its *parsed* search, and the default parser - * JSON-parses every value - so `?first=20` arrives as the number `20`, `?x=true` - * as a boolean, and a repeated key as an array. The normaliser is written - * against a query string, where everything is a string. - * - * Deliberately narrow: scalars become their string spelling, the first entry of - * an array wins because only one value can reach the API, and everything else - - * an object, a nested array, a `null` - is *absent* rather than coerced. - * `String({})` is `"[object Object]"`, which no rule below would recognise but - * every rule would have to consider. - */ const readParam = (value: unknown): string | undefined => { const one = Array.isArray(value) ? (value[0] as unknown) : value; @@ -100,13 +35,6 @@ const readParam = (value: unknown): string | undefined => { return undefined; }; -/** - * The parameters these routes have, in the shape the normaliser reads. - * - * Named one by one rather than passed through: nothing an administrator puts in - * the query string reaches the request builder unless the contract asked for it, - * so a stray `?tab=2` is not carried, not validated and not sent. - */ const rawParamsOf = ( input: UncheckedAdminTableSearch, ): RawAdminTableParams => ({ @@ -119,36 +47,12 @@ const rawParamsOf = ( status: readParam(input.status), }); -/** - * The request this URL is asking for - and therefore the object the query key is - * built from. - * - * Takes the loose object rather than {@link AdminTableRouteSearch} on purpose. - * The router merges a route's validated search over the *raw* parsed one, so - * `Route.useSearch()` still carries whatever else was in the query string; going - * back through the same normalisation is what makes this answer depend only on - * the parameters the contract names, whoever is calling it. - */ export const adminTableRouteParams = <TOrderBy extends string>( input: UncheckedAdminTableSearch<TOrderBy>, contract: AdminTableContract<TOrderBy>, ): AdminTableParams<TOrderBy> => normalizeAdminTableParams(rawParamsOf(input), contract); -/** - * The route's search schema - written as a function rather than a schema object - * because its job is to *normalise*, not to reject. - * - * An AdminCP list URL is edited by hand and pasted between people: - * `?orderBy=password`, `?first=5000`, `?first=abc`, `?cursor=💥`. Every one of - * them should render the table the way it defaults to, not a router error. - * - * The one thing it does *not* keep is a page size equal to the default. - * `/admin/core/advanced/cron` and `/admin/core/advanced/cron?first=10` are the - * same page, and a schema that answered `first: 10` for the first of them would - * write `?first=10` into every link the router builds to that route - including - * the sidebar's. - */ export const normalizeAdminTableSearch = <TOrderBy extends string>( input: UncheckedAdminTableSearch<TOrderBy>, contract: AdminTableContract<TOrderBy>, @@ -173,15 +77,6 @@ export const normalizeAdminTableSearch = <TOrderBy extends string>( }; }; -/** - * The query string the table's controls read themselves out of. - * - * `DataTable`'s sort headers, pager, search box and filters are handed a - * `URLSearchParams` and produce a new query string from it - * (`components/table/url-state.ts`); this is the other end of that, and it is - * built from the validated search rather than from the address bar so a control - * can only ever edit a parameter the route recognises. - */ export const adminTableSearchParams = <TOrderBy extends string>( input: UncheckedAdminTableSearch<TOrderBy>, contract: AdminTableContract<TOrderBy>, @@ -197,13 +92,6 @@ export const adminTableSearchParams = <TOrderBy extends string>( return params; }; -/** - * A query string one of those controls produced, back as route search. - * - * The return leg, and the point at which the table's own URL arithmetic is - * re-validated: a control cannot write a sort column the route does not have, - * because what it wrote goes through the same schema the address bar does. - */ export const adminTableSearchFrom = <TOrderBy extends string>( nextSearch: string, contract: AdminTableContract<TOrderBy>, @@ -215,26 +103,6 @@ export const adminTableSearchFrom = <TOrderBy extends string>( export type { AdminTableContract, AdminTableOrder, AdminTableParams }; -/** - * How a table control changes the URL - the one thing the shared table cannot - * decide for itself. - * - * `DataTable` mounts this for Next.js (`NextDataTableNavigation`, a locale-aware - * `push`); a TanStack route mounts it with its own router's navigate. - * Everything either side of it - which parameter a sort header rewrites, which - * ones a filter resets, what a page button does with a cursor - is - * `components/table/url-state.ts` and is shared. - * - * `to` is deliberately absent: with no destination the router stays on the - * current route and changes only its search, which is the whole of what a table - * control does. The promise is returned rather than dropped so the seam's - * `useTransition` stays pending for the whole navigation, which is what keeps - * the current rows on screen with a spinner instead of blanking the table. - * - * Generic over the route's *search* type rather than over its sortable columns, - * so a screen names the type it already has (`CronRouteSearch`) rather than - * restating its `orderBy` union. - */ export type AdminTableNavigate<TSearch> = (options: { resetScroll: boolean; search: TSearch; diff --git a/packages/vitnode/src/tanstack/admin/transport.test.ts b/packages/vitnode/src/tanstack/admin/transport.test.ts index 831408e87..6874e36de 100644 --- a/packages/vitnode/src/tanstack/admin/transport.test.ts +++ b/packages/vitnode/src/tanstack/admin/transport.test.ts @@ -9,40 +9,6 @@ import { setAdminTransport, } from "./transport"; -/** - * The admin seam's registration lifecycle, including the one a dev server puts - * it through. - * - * A whole file to itself for the same reason `../auth/transport.test.ts` is: the - * unregistered state exists exactly once per module instance, and a file that - * registers can never observe it again. Vitest isolates modules per file, so - * this is the only place the "before" half can be asserted at all. - * - * ## Why replacement is a behaviour and not an accident - * - * Module scope means *per bundle*, and a bundle is re-evaluated more often than - * a deployment: a hot reload of the host's `lib/admin-auth.ts` runs - * `setAdminTransport` again with a *new* function that closes over the new - * module. Refusing that - a "already registered" throw, which is the reflex for - * a singleton - would turn every save of that file into a build error the author - * can only clear by restarting the server. Accepting it and keeping the *first* - * function is worse: the registry then holds a closure over a module that no - * longer exists, so the screen renders from code the editor no longer shows. - * - * Last write wins, and the read happens at call time. Those two together are the - * whole of what makes this HMR-safe, and they are what the transitions below - * pin. - */ - -/** - * A transport carrying a tag, so "which registration answered" is a question - * about identity rather than about behaviour. - * - * The read resolves to `network_error`, which is a real member of - * `AdminSessionReadResult`: nothing below calls it, and a transport that could - * not be constructed without inventing a session shape would be asserting - * against its own fiction. - */ type TaggedTransport = AdminTransport & { readonly tag: string }; const transportOf = (tag: string): TaggedTransport => ({ @@ -85,11 +51,6 @@ describe("the same registration repeated", () => { }); describe("a new registration after a hot reload", () => { - /** - * The replacement module provides a *different* function object, which is the - * normal case rather than the exceptional one: a re-evaluated module builds a - * new closure even when its source did not change. - */ it("accepts a legitimate replacement rather than throwing", () => { setAdminTransport(transportOf("before")); @@ -109,16 +70,6 @@ describe("a new registration after a hot reload", () => { expect(tagOf(adminTransport())).toBe("after"); }); - /** - * The stale-closure question, asked the only way it can be asked of a - * module-scope registry: does a caller that resolved *before* the replacement - * still reach the old value afterwards? - * - * It cannot, because nothing captures the registry - `adminTransport()` reads - * the slot on every call. A consumer that had written `const t = - * adminTransport()` at module scope would hold the old one, which is why no - * consumer does; `../../framework` and this namespace call it at use. - */ it("leaves no caller holding the previous registration", () => { const before = transportOf("before"); @@ -146,13 +97,6 @@ describe("a new registration after a hot reload", () => { }); describe("what the registry is not", () => { - /** - * Worth pinning rather than left to the doc comment: this holds a function - * reference, which is the same for every visitor and every request. That is - * the entire argument for a module-level value on a server rendering many - * administrators at once - a registry that held a session, an administrator or - * a permission set would leak one visitor's answer into another's page. - */ it("holds only what was handed to it, with no state of its own", () => { const transport = transportOf("only"); diff --git a/packages/vitnode/src/tanstack/admin/transport.ts b/packages/vitnode/src/tanstack/admin/transport.ts index 28d5f3754..1dbc3af38 100644 --- a/packages/vitnode/src/tanstack/admin/transport.ts +++ b/packages/vitnode/src/tanstack/admin/transport.ts @@ -1,99 +1,18 @@ import type { AdminSessionReadResult } from "./session-api"; -/** - * How this package reaches the admin session endpoint, handed to it by the - * application. - * - * One call, and it is `createServerFn` in the host. That is not a preference, it - * is the one thing a package cannot own: the host externalises `@vitnode/core` - * from Vite's SSR pass, so this code reaches the server *uncompiled* - and an - * uncompiled `createServerFn` hands its `.handler()` one argument where the - * compiler passes two, which makes an SSR-side call resolve to `undefined` with - * no error at all. It would work in the browser, over `/_serverFn/*`, and - * silently answer nothing during a render. `tanstack/boundary.test.ts` forbids - * the primitive here outright. - * - * apps/web @vitnode/core/tanstack/admin - * ---------------------------------------------------------------- - * createServerFn ./server call Hono, map the status - * .handler(fn) ./state what each status means - * ./session-query the one cache entry - * - * ## Why a server function rather than an isomorphic fetch - * - * The read needs the request's `Cookie` header forwarded to Hono, and only a - * server request scope has one. `createIsomorphicFn` - the pattern the public - * feed reads use - would run its client branch in the browser, where a - * cross-origin call to the API would carry no admin cookie at all. So this - * follows `lib/auth.ts`'s session read exactly: a server function on both sides - * of the render, directly during SSR and over same-origin RPC afterwards. - * - * ## This is a transport, not a permission store - * - * Nothing here holds a session, an administrator or a permission set. The - * canonical admin session lives in exactly one place - the `["vitnode", - * "admin-session"]` entry in the host's `QueryClient` - and `./session-query` is - * the only definition of it. This registry holds a function reference, which is - * the same for every visitor and every request, which is why a module-level - * value is safe on a server that renders many visitors at once. - */ export interface AdminTransport { - /** - * The administrator this browser's admin cookie names, the denial the API - * issued, or the failure that stopped it answering either. - * - * Takes no arguments, and must not grow any. Who is asking is the - * `vitnode_auth_admin` cookie the request carries; a user id passed in here - * would be a second, weaker answer to a question the cookie already settles, - * and one the browser cannot be trusted to give. - * - * Never rejects for a failed read - it resolves to an `api_error` or - * `network_error` member instead, because a thrown error does not survive the - * server-function boundary with its kind intact. `./session-query` turns those - * into the rejection a route guard sees. - */ readAdminSession: () => Promise<AdminSessionReadResult>; } let registered: AdminTransport | undefined; -/** - * The message a caller gets when the application forgot to register. - * - * A named constant so the host's own test can assert on it without matching - * English, and so the sentence says what to do rather than what went wrong. - */ export const ADMIN_TRANSPORT_MISSING = "No admin transport is registered. Call setAdminTransport() from a module the application always loads - the router entry - before any admin route runs."; -/** - * Register the application's server function, once, at module scope. - * - * Called from a module both bundles load (`apps/web/src/router.tsx` imports the - * host's `lib/admin-auth.ts` for exactly this), so the registry is filled before - * any route, loader or component can reach for it. Registering twice replaces - * the previous value rather than throwing: a hot reload re-evaluates the module, - * and a build error is a worse answer than the newer function. - * - * Module scope means *per bundle*. The browser has one instance and the server - * has one instance, and each registers its own - which is the same lifetime the - * server function itself has. Note what is *not* stored here: no session, no - * administrator, no permissions. Those live in the per-request `QueryClient`, - * which is what stops one administrator's answer being rendered into another's - * page during a concurrent SSR. - */ export const setAdminTransport = (transport: AdminTransport): void => { registered = transport; }; -/** - * The registered transport, or a failure that says what is missing. - * - * Read at call time rather than captured at module scope, so a module that - * merely *imports* the query definition does not have to be loaded after the - * registration - only the call has to happen after it, which is trivially true - * for anything a route can reach. - */ export const adminTransport = (): AdminTransport => { if (!registered) throw new Error(ADMIN_TRANSPORT_MISSING); diff --git a/packages/vitnode/src/tanstack/admin/user-bar.tsx b/packages/vitnode/src/tanstack/admin/user-bar.tsx index ff849a28b..043e0d3ae 100644 --- a/packages/vitnode/src/tanstack/admin/user-bar.tsx +++ b/packages/vitnode/src/tanstack/admin/user-bar.tsx @@ -12,58 +12,6 @@ import { useSignOutAction } from "../auth/actions"; import { useAdminUser } from "./permissions"; import { removeAdminIdentityQueries } from "./queries"; -/** - * The AdminCP user menu, on TanStack Start. - * - * Everything visible is `UserBarAdminContent`'s. What is here is the two things - * it refuses to decide - who is signed in, and what signing out means - and both - * come from the canonical state rather than from anything invented here. - * - * the admin -> useAdminUser() the one admin-session query - * sign-out -> useSignOutAction() the one auth action, Stage 6's - * - * There is no second session, no admin context of this component's own and no - * second sign-out. `useAdminUser` reads the identical `adminSessionQueryOptions` - * object `_admin`'s guard warmed, so the menu cannot name somebody the guard has - * already turned away. - * - * ## What sign-out has to accomplish, and who does each part - * - * DELETE /sign_out, admin cookie cleared the shared action -> the API - * public session reset to anonymous the shared action - * everything privileged this admin held the shared action - * (removeAdminIdentityQueries) - * leaves the protected AdminCP router.invalidate() -> _admin's guard - * nothing re-created during the teardown the second sweep below - * - * The action owns the cleanup - the session entry *and* every AdminCP screen - * family, through the one canonical list in `./queries`. This component adds a - * second sweep after the sign-out has resolved, and only because of *when* it - * runs: the action clears before `router.invalidate()`, while the panel is still - * mounted, so an observer that re-renders in that window can put an entry back - * before the guard redirects. Sweeping again once the action has finished is - * what stops one of those surviving into the next administrator's session. - * - * None of this is a security boundary. The admin cookie is what authorizes an - * admin read, and Hono re-checks it on every request whatever this cache says. - * - * ## There is deliberately no `onSignOut` override - * - * An earlier draft took one, so a host could "do something extra". What it - * actually offered was a way to replace the sequence above with a partial copy - * of it - and the part a host would most easily leave out is the cache clearing, - * which is invisible until a second administrator signs in on the same tab and - * is served the first one's search results. A host that needs something extra - * has `queryClient` and the same action; it does not need this component to hand - * over the whole flow. - * - * ## A failed sign-out leaves them signed in, and says so - * - * The action reports failure rather than throwing, and the honest response is - * the internal-error toast with the menu unchanged: a session that could not be - * ended is still a session, and clearing the UI would claim otherwise. The same - * choice the public header makes for the same answer. - */ export const AdminUserBar = ({ LinkComponent, }: { @@ -74,12 +22,6 @@ export const AdminUserBar = ({ const queryClient = useQueryClient(); const tErrors = useTranslations("core.global.errors"); - /** - * Rendered by a shell whose guard has already decided access, so "no admin" - * is not an error state here - it is the instant between a sign-out and the - * redirect the guard performs. Rendering nothing is the whole correct - * response. - */ if (!user) return null; const handleSignOut = async () => { diff --git a/packages/vitnode/src/tanstack/admin/users/detail-breadcrumb.tsx b/packages/vitnode/src/tanstack/admin/users/detail-breadcrumb.tsx index 4154fabe3..2c981bd6c 100644 --- a/packages/vitnode/src/tanstack/admin/users/detail-breadcrumb.tsx +++ b/packages/vitnode/src/tanstack/admin/users/detail-breadcrumb.tsx @@ -13,34 +13,7 @@ import { AdminBreadcrumb } from "../breadcrumb"; import { useAdminIdentity } from "../identity"; import { ADMIN_USER_NAMESPACES } from "./detail-route"; import { adminUserQuery } from "./query"; -/** - * The breadcrumb this screen contributes. - * - * The trail is `Core / Users / <the user's name>`, and the last crumb is data: - * the navigation has no entry for `/admin/core/users/123`, so `resolveBreadcrumb` - * would humanise the id into "123". The Next.js `@breadcrumb` slot solves that by - * fetching the user a second time; this reads the entry the loader already - * filled, so there is no second request and no second answer. - * - * ## Where the id comes from - * - * The shell hands every crumb the match that declared it, so the params arrive - * as a prop - this route's own, not the deepest match's - and they are the - * *parsed* value: a route's `params.parse` runs first, so what arrives here has - * already been through `normalizeAdminUserId`. It is narrowed rather than - * trusted all the same: a package cannot name a host's route types, so a - * `params` reaching a crumb proves nothing about an `$id` being in scope. - * - * ## Why it mounts `RouteMessages` of its own - * - * The shell renders the breadcrumb *above* the route's component, so it is - * outside the provider that component mounts. Without this it would translate - * against the root's `core.global` alone. - * - * `useQuery` rather than `useSuspenseQuery`: a crumb must never suspend the - * header the whole panel is framed by. Before the name arrives the trail simply - * ends in the id, which is what the URL says anyway. - */ + export const AdminUserBreadcrumbContent = ({ LinkComponent, params, diff --git a/packages/vitnode/src/tanstack/admin/users/detail-route.tsx b/packages/vitnode/src/tanstack/admin/users/detail-route.tsx index 84597b8e5..ba0045ebe 100644 --- a/packages/vitnode/src/tanstack/admin/users/detail-route.tsx +++ b/packages/vitnode/src/tanstack/admin/users/detail-route.tsx @@ -13,37 +13,6 @@ import { adminIdentityOf } from "../identity"; import { requireAdminPermission } from "../screen"; import { adminUserQuery } from "./query"; -/** - * `/admin/core/users/$id` - one user, as everything a TanStack Start route needs - * and nothing a route owns. - * - * ## The dynamic segment, and what the route must do with it before this runs - * - * `$id` matches *any* segment. `normalizeAdminUserId` is what turns it into - * either a decimal id or `null`, and a route calls it in `params.parse` so the - * decision is made once, before the loader, the query key or the request exist. - * The public URL is unchanged - `/admin/core/users/123` - and nothing here ever - * sees `Number("abc")`. - * - * ## The permission model is the Next.js page's, unchanged - * - * users.can_view the page itself - * users.can_edit the in-place editors and the roles dialog - * users.can_edit_admin additionally, when the target is an administrator - * - * The last two are `canEditAdminUser`, which is the same predicate the Next.js - * page builds from `getSessionAdminApi()`, and the same rule - * `assertCanEditAdminTarget` enforces on every write. - */ - -/** - * What this screen renders strings from. - * - * `admin.user` is the page; `core.search` is the timeline tab and the feed - * inside it; `core.global` is the dialogs, the forms and the error toasts. The - * same set `<I18nProvider namespaces={["admin.user", "core.search"]}>` provides - * in the Next.js page, which adds `core.global` itself. - */ export const ADMIN_USER_NAMESPACES = [ "admin.user", "core.global", @@ -58,17 +27,6 @@ export interface AdminUserRouteData { title: string; } -/** - * Both reads this screen needs, before it renders. - * - * The user is fetched rather than prefetched because the `<title>` is built from - * their name - the Next.js `generateMetadata` makes the same request for the - * same reason, and both fall back to the bare heading when the read fails. - * - * A refusal propagates: `404` is a link to somebody who has been deleted and - * `403` is an administrator who may no longer look, and both are the router's - * error path rather than a page rendered around missing data. - */ export const loadAdminUserRoute = async ({ adminAccess, id: raw, @@ -80,19 +38,6 @@ export const loadAdminUserRoute = async ({ }): Promise<AdminUserRouteData> => { requireAdminPermission(adminAccess, ADMIN_USER_PERMISSIONS.view); - /** - * The one place `$id` becomes an id. - * - * Here rather than in the route's `params.parse`, and that is deliberate: - * `parse` runs inside `matchRoutes`, which the router calls on every - * navigation and every `<Link>` it builds - so a `parse` that threw would take - * down far more than the one screen with a bad id in its URL. - * - * `notFound()` rather than a `400`: `/admin/core/users/abc` is a URL that - * names no user, which is what a not-found screen is for, and it is the same - * answer the API gives (`show.route.ts` refuses a non-integer id with a - * `404`). Nothing below this line has ever seen `Number("abc")`. - */ const id = normalizeAdminUserId(raw); if (id === null) { // TanStack Router's own control-flow signal. diff --git a/packages/vitnode/src/tanstack/admin/users/detail-screen.tsx b/packages/vitnode/src/tanstack/admin/users/detail-screen.tsx index 700490881..0703a3dfa 100644 --- a/packages/vitnode/src/tanstack/admin/users/detail-screen.tsx +++ b/packages/vitnode/src/tanstack/admin/users/detail-screen.tsx @@ -22,17 +22,6 @@ export interface AdminUserRouteProps extends AdminUserRouteData { LinkComponent: AuthLinkComponent; } -/** - * The activity feed on the timeline tab. - * - * The same `SearchFeed` the Next.js page renders, minus the Next.js binding: the - * shared content component takes the query options and a link, and the locale - * comes from the loader rather than from `next-intl`'s request scope. - * - * `authorId` is the *listed* user, which is what makes this a profile timeline - * rather than the site feed. It is a filter on a public search index, so nothing - * here is a permission decision. - */ const UserTimeline = ({ LinkComponent, locale, diff --git a/packages/vitnode/src/tanstack/admin/users/index.ts b/packages/vitnode/src/tanstack/admin/users/index.ts index 583f9266f..8edda9867 100644 --- a/packages/vitnode/src/tanstack/admin/users/index.ts +++ b/packages/vitnode/src/tanstack/admin/users/index.ts @@ -1,22 +1,6 @@ export type { AdminTableNavigate } from "../table-search"; export { AdminUserBreadcrumbContent } from "./detail-breadcrumb"; -/** - * `/admin/core/users` and `/admin/core/users/$id` - the AdminCP users screens, - * for a TanStack Start host. - * - * ./query the cache contract - two query definitions, one - * invalidation family, and the three writes that use it - * ./route the list: namespaces, permission, loader, component - * ./detail-route one user: the same, plus the breadcrumb that names them - * ./route-search the URL contract, including the role filter - * ./server the SSR transport, reached only through `./query`'s - * isomorphic functions and never imported from a browser - * bundle - * - * The rendering is not here and does not belong here: `UsersAdminTableContent` - * and `UserDetailContent` are framework-free and are imported from - * `@/views/admin/views/core/users` by both applications. - */ + export type { AdminUserRouteData } from "./detail-route"; export { ADMIN_USER_NAMESPACES, loadAdminUserRoute } from "./detail-route"; export type { AdminUserRouteProps } from "./detail-screen"; diff --git a/packages/vitnode/src/tanstack/admin/users/invalidate.test.ts b/packages/vitnode/src/tanstack/admin/users/invalidate.test.ts index 091c6b544..95130e92c 100644 --- a/packages/vitnode/src/tanstack/admin/users/invalidate.test.ts +++ b/packages/vitnode/src/tanstack/admin/users/invalidate.test.ts @@ -12,26 +12,6 @@ import { invalidateAfterAdminUserRolesChange, } from "./query"; -/** - * What a user write invalidates, asserted as **targets** rather than as - * behaviour. - * - * A recorder rather than a real `QueryClient`, and that is the point of the - * shape: what is being pinned is which prefixes a write names, and a real client - * would answer that question by not throwing - which is not an answer. Nothing - * here mounts a component, opens a socket or reaches an API. - * - * ## The finding this file exists for - * - * `onUpdateRoles` invalidated the users family and stopped there. A role is a - * permission carrier, which is exactly why `invalidateAfterAdminRoleChange` and - * `invalidateAfterStaffChange` both also name `["vitnode","admin-session"]`: the - * sidebar, every permission gate and every screen guard in the panel render from - * that one cached entry. The API already did its half - the role branch of the - * user update route bumps the permission epoch - so the browser was the only - * thing left offering links the API had started refusing, until a page reload. - */ - /** Every `queryKey` a call named, in the order it named them. */ const recorder = () => { const invalidated: unknown[][] = []; @@ -78,11 +58,6 @@ describe("changing a user's roles", () => { ); }); - /** - * Invalidated, not removed. The administrator has not changed - this is not an - * identity boundary - so the current sidebar stays on screen while the fresh - * answer arrives. Removing it would blank the shell under the toast. - */ it("does not remove the session", async () => { const { queryClient, removed } = recorder(); @@ -91,11 +66,6 @@ describe("changing a user's roles", () => { expect(removed).toHaveLength(0); }); - /** - * And it stays narrow. `invalidateQueries()` with no key, or with - * `["vitnode"]`, would also expire the messages, the middleware config and - * every other screen the panel is holding - none of which a role change moved. - */ it("names two prefixes and no more", async () => { const { invalidated, queryClient } = recorder(); @@ -108,11 +78,6 @@ describe("changing a user's roles", () => { }); }); - /** - * The two targets are siblings rather than one inside the other, which is what - * makes both calls necessary: `["vitnode","admin"]` is not a prefix of - * `["vitnode","admin-session"]`, because Query matches whole segments. - */ it("cannot reach the session through the panel root", () => { const isPrefixOf = ( prefix: readonly unknown[], @@ -126,11 +91,6 @@ describe("changing a user's roles", () => { }); }); -/** - * The narrow helper it composes is unchanged, and the other two user writes - - * a profile edit and an email verification - still use it alone. Neither moves a - * permission, so neither owes the session anything. - */ describe("the other user writes stay on the users family", () => { it("invalidates exactly one prefix", async () => { const { invalidated, queryClient } = recorder(); diff --git a/packages/vitnode/src/tanstack/admin/users/query.ts b/packages/vitnode/src/tanstack/admin/users/query.ts index 89439a2e7..cac51a893 100644 --- a/packages/vitnode/src/tanstack/admin/users/query.ts +++ b/packages/vitnode/src/tanstack/admin/users/query.ts @@ -36,32 +36,6 @@ import { useAdminIdentity } from "../identity"; import { invalidateAdminSession } from "../session-query"; import { fetchAdminUserOnServer, fetchAdminUsersPageOnServer } from "./server"; -/** - * The AdminCP users screens for a TanStack Start host: two query definitions and - * three mutations. - * - * Everything about *what* the reads are lives in - * `@/views/admin/views/core/users`, which is also what the mounted components - * render from. This module supplies the two things those modules cannot know: - * how to reach the API from a server that is rendering a request, and what - * "refresh the screen" means in a router with a query cache instead of - * `revalidatePath`. - */ - -/** - * The transport boundary. - * - * Both branches call Hono directly - the server one from inside the request - * being rendered, the browser one over the network to the same origin. There is - * deliberately no `createServerFn` in between: a server function is a `POST` - * back to the app that then calls Hono, so every sort and page of the table - * would cost two round trips for a read the API is already the boundary for, and - * nothing here needs a `Set-Cookie` copied onto the app's own response. - * - * Written out at module scope rather than behind a helper, because the Start - * compiler matches the *chained call*: a `.server(fn)` passed as an ordinary - * argument elsewhere would leave `./server` in the client graph. - */ const fetchUsersPage: AdminUsersPageFetcher = createIsomorphicFn() .server(fetchAdminUsersPageOnServer) .client(fetchAdminUsersPageInBrowser); @@ -70,15 +44,6 @@ const fetchUser: AdminUserFetcher = createIsomorphicFn() .server(fetchAdminUserOnServer) .client(fetchAdminUserInBrowser); -/** - * The users list, as the one query definition every caller shares. - * - * `params` must be the *normalised* ones - `usersRouteParams` over the route's - * validated search - because the cache key is built from them. - * - * `adminUserId` is the reading administrator's, and it is in the key rather than - * in the request: see `tanstack/admin/identity.ts`. - */ export const adminUsersQuery = ({ adminUserId, params, @@ -101,20 +66,6 @@ export const adminUserQuery = ({ id: string; }) => adminUserQueryOptions({ adminUserId, fetchUser, id }); -/** - * Marks everything the AdminCP has cached about users stale, for one - * administrator. - * - * The whole family by prefix - every page, sort and filter of the list, and - * every single-user entry - because a rename shows up in a row on any page and - * on the detail screen at once. It is emphatically not - * `queryClient.invalidateQueries()` with no key: the session, the messages and - * every other list the panel holds have not changed. - * - * Invalidating rather than removing keeps the current rows on screen while the - * fresh ones arrive, instead of blanking the table under the button that was - * just pressed. - */ export const invalidateAdminUsers = async ( queryClient: QueryClient, adminUserId: AdminIdentity, @@ -124,32 +75,6 @@ export const invalidateAdminUsers = async ( }); }; -/** - * What changing *a user's roles* invalidates, and why it is two things. - * - * 1. **The users family**, because the roles rendered in the row and on the - * detail screen are the thing that changed. - * 2. **The admin session**, because a role is a permission carrier - the same - * reason `invalidateAfterAdminRoleChange` and `invalidateAfterStaffChange` - * both name it. Granting an administrator a role, or taking one away, changes - * what *they* may do, and the sidebar, every permission gate and every screen - * guard in the panel are rendered from that one cached entry. - * - * The API already does the server half - the role branch of the user update - * route calls `invalidateStaffPermissionsForUser` - so without this the browser - * is the only thing still offering links the API has started refusing, until the - * next full page load. - * - * It matters even when the edited user is somebody else: an administrator can - * edit their own roles from this screen, and the cache has no way to tell the - * two cases apart without asking who the row is. Invalidating an entry that did - * not change costs one request; not invalidating one that did costs a panel that - * lies about what it may do. - * - * `invalidateAdminSession` rather than `removeAdminSession`: the identity has - * not changed, so the current sidebar stays on screen while the fresh answer - * arrives instead of the shell blanking under the toast. - */ export const invalidateAfterAdminUserRolesChange = async ( queryClient: QueryClient, adminUserId: AdminIdentity, @@ -160,19 +85,6 @@ export const invalidateAfterAdminUserRolesChange = async ( ]); }; -/** - * The three user writes, bound to the mounted router's cache. - * - * Each refreshes only on success, for the same reason the Next.js actions only - * call `revalidatePath` on success: a refused write changed nothing, and - * refetching would replace the rows underneath the error toast for no change at - * all. - * - * Memoised, which is the only reason these are hooks rather than calls at the - * point of use: they are props on components that re-render on every navigation, - * and a new function identity would reset the pending state inside a row's - * button mid-request. - */ export const useAdminUserMutations = (): { onUpdate: UpdateAdminUser; onUpdateRoles: UpdateAdminUserRoles; diff --git a/packages/vitnode/src/tanstack/admin/users/route-search.test.ts b/packages/vitnode/src/tanstack/admin/users/route-search.test.ts index 2db1b5130..d32b231a7 100644 --- a/packages/vitnode/src/tanstack/admin/users/route-search.test.ts +++ b/packages/vitnode/src/tanstack/admin/users/route-search.test.ts @@ -7,12 +7,6 @@ import { usersSearchParams, } from "./route-search"; -/** - * The users list's URL contract, which is the shared admin-table one plus a role - * filter - so what is tested here is the filter, and the fact that adding it - * left everything else alone. - */ - describe("the request a users URL is asking for", () => { it("always names a page size, so the key describes the request", () => { expect(usersRouteParams({})).toEqual({ first: "10" }); diff --git a/packages/vitnode/src/tanstack/admin/users/route-search.ts b/packages/vitnode/src/tanstack/admin/users/route-search.ts index 34099e0bf..46df08666 100644 --- a/packages/vitnode/src/tanstack/admin/users/route-search.ts +++ b/packages/vitnode/src/tanstack/admin/users/route-search.ts @@ -20,21 +20,6 @@ import { normalizeAdminTableSearch, } from "../table-search"; -/** - * The users list's URL contract - the shared admin-table one, plus `roleId`. - * - * Every other AdminCP table is exactly `AdminTableRouteSearch`. This one has a - * filter nothing else has, so the four functions below wrap the shared ones - * rather than replacing them: the page, the sort and the search term keep - * behaving identically to every other admin list, and the one extra parameter is - * added in one place. - * - * `roleId` goes through {@link normalizeAdminRoleFilter} on the way in and back - * out, so `?roleId=5,2`, `?roleId=2,5,5` and `?roleId=2,abc,5` are all one - * request and therefore one cache entry - and `?roleId=abc` filters by nothing - * rather than by `NaN`. - */ - export interface UsersRouteSearch extends AdminTableRouteSearch<AdminUsersOrderBy> { roleId?: string; } diff --git a/packages/vitnode/src/tanstack/admin/users/route.tsx b/packages/vitnode/src/tanstack/admin/users/route.tsx index 0e743917d..a94ec8ed6 100644 --- a/packages/vitnode/src/tanstack/admin/users/route.tsx +++ b/packages/vitnode/src/tanstack/admin/users/route.tsx @@ -12,42 +12,6 @@ import { adminIdentityOf } from "../identity"; import { requireAdminPermission } from "../screen"; import { adminUsersQuery } from "./query"; -/** - * `/admin/core/users` - the AdminCP users list, as everything a TanStack Start - * route needs and nothing a route owns. - * - * The topology stays in the host, because TanStack infers it from - * `createFileRoute`: the path, the search contract and `navigate`. Everything - * else is here - the namespaces, the permissions, the query, the title, the - * table, the create dialog and the role filter's lookup. - * - * ## The permission model is the Next.js page's, unchanged - * - * users.can_view the list itself - the route, and the API route - * users.can_create the create button - a gate, and the create route - * users.can_edit the row actions - a gate inside the table - * - * `can_view` is checked in the loader rather than around the table, so an - * administrator without it never sends a request the API is going to refuse and - * no admin markup is streamed for a screen about to be replaced by the AdminCP's - * 404. The Next.js page could not do that - `AdminPermissionRequired` wraps the - * table, so its heading renders first. - */ - -/** - * What this screen renders strings from. - * - * `admin.user` is the heading, the columns, the create dialog and the row - * actions; `admin.global` carries `nav.users.list`, which is the `<h1>` and the - * `<title>`; `core.global` is the rest of the table - the pager, the search box, - * the filter dropdown and the error toasts - and it is listed even though the - * root provides it, because `RouteMessages` mounts its own provider *over* the - * root's rather than adding to it. - * - * The same set `<I18nProvider namespaces="admin.user">` provides in the Next.js - * page, which always adds `core.global` itself and reads `admin.global` from the - * AdminCP layout. - */ export const ADMIN_USERS_NAMESPACES = [ "admin.global", "admin.user", @@ -62,25 +26,6 @@ export interface AdminUsersRouteData { title: string; } -/** - * Both reads this screen needs, in parallel, before it renders. - * - * The permission is checked first, before either read is started. Neither call - * is repeated by the component: the messages are read back by `RouteMessages` - * through the identical `intlQueryOptions`, and the page by `useSuspenseQuery` - * through the identical `adminUsersQuery`. - * - * A refusal from the users API is deliberately left to propagate: `403` and - * `429` reject as `AdminRequestError` and fail this loader. Catching it and - * rendering an empty table would be indistinguishable from a community with no - * members, which is the one thing this must never look like. - * - * The cast on `messages` is what makes `createTranslator` usable: its key type - * is derived from the *inferred* type of `messages`, and a bare index signature - * collapses `MessageKeys` to `never`. Naming the two keys read here is both the - * smallest fix and a true statement - rename either in `locales/en.json` and - * this stops compiling rather than rendering a raw key into a `<title>`. - */ export const loadAdminUsersRoute = async ({ adminAccess, locale, @@ -89,12 +34,6 @@ export const loadAdminUsersRoute = async ({ }: AdminScreenContext & { params: AdminUsersParams; }): Promise<AdminUsersRouteData> => { - /** - * `users.can_view` - the tuple `<AdminPermissionRequired module="users" - * permission="can_view">` states in the Next.js page, and the one - * `listUsersAdminRoute` declares as its `adminStaffPermission`. All three read - * it from `ADMIN_USER_PERMISSIONS` rather than spelling it out. - */ requireAdminPermission(adminAccess, ADMIN_USER_PERMISSIONS.view); const adminUserId = adminIdentityOf(adminAccess); diff --git a/packages/vitnode/src/tanstack/admin/users/screen.tsx b/packages/vitnode/src/tanstack/admin/users/screen.tsx index fd239248d..16108f892 100644 --- a/packages/vitnode/src/tanstack/admin/users/screen.tsx +++ b/packages/vitnode/src/tanstack/admin/users/screen.tsx @@ -35,18 +35,6 @@ export interface AdminUsersRouteProps extends AdminUsersRouteData { search: UncheckedUsersSearch; } -/** - * `/admin/core/users`, as everything below a route file's `component`. - * - * `navigate`, `search` and `LinkComponent` come from the host: TanStack infers - * the first two from the `createFileRoute` path, and the third is how *this* - * application renders an internal link while half of `/admin/*` is still served - * by Next.js. - * - * The heading is outside the table, exactly as in the Next.js page, and rendered - * from the loader's own strings - so the `<h1>` and the `<title>` are the same - * string by construction. - */ export const AdminUsersRouteContent = ({ adminUserId, description, diff --git a/packages/vitnode/src/tanstack/auth/actions.ts b/packages/vitnode/src/tanstack/auth/actions.ts index 77054cc8a..7b90140b0 100644 --- a/packages/vitnode/src/tanstack/auth/actions.ts +++ b/packages/vitnode/src/tanstack/auth/actions.ts @@ -29,73 +29,8 @@ import { import { shouldRefreshSessionAfterSignUp } from "./sign-up-session"; import { authTransport } from "./transport"; -/** - * The four things a visitor can do to their own session, as VitNode's only - * auth actions. - * - * component -> action -> server function -> Hono -> Set-Cookie - * | - * +-> canonical session cache -> route guards - * - * Every one of them ends the same way: the cached session is brought back in - * step with the cookie the browser now holds, *before* anything navigates. That - * ordering is the whole reason these are hooks and not four inline callbacks - - * a navigation that runs first arrives at a guard reading the previous - * visitor's state, which is either a bounce back to the login page or a flash - * of a page the visitor is no longer entitled to. - * - * There is no second auth store. `./session-query` owns the one cache entry - * every guard and component reads, and these write to exactly that entry. - * - * None of this is a security boundary. Hono authorizes every private read from - * the session cookie, in its own handlers, and keeps doing so whatever this - * cache says. - * - * ## The two things an application has to supply - * - * The mutations arrive through `./transport`, because a package may not declare - * a `createServerFn`. And where a visitor lands afterwards arrives as a - * `navigate` argument, because that is not this package's decision to make: in - * `apps/web` a post-login destination may still belong to the Next.js - * application, so the host passes a navigator that decides between a router - * navigation and a document load. Once the migration is over, a host will pass - * `router.navigate` and the seam will read as the plain thing it is. - */ - -/** - * Where to send a visitor once they are signed in, as one call the application - * owns. - * - * Takes an already-validated internal path - `postAuthDestination` in - * `./redirects` produces one - and resolves when the navigation has been - * performed, so an action can await it before it reports success. - */ export type AuthNavigate = (href: string) => Promise<void>; -/** - * Signing in, in the shape `SignInFormContent` submits. - * - * `undefined` on success, which is the shared form's way of saying "the caller - * is navigating" - and it is, on the line above. On failure the form gets the - * legacy vocabulary back and renders the alert or the toast itself. - * - * The session is invalidated rather than written, because the sign-in reply says - * only that it worked - the session body comes from the next read, which the - * destination's guard performs through the one query definition. Doing it before - * navigating is what makes that read see the new cookie. - * - * The guard notices because `ensureAuthState` reads through `fetchQuery`, and an - * invalidated entry is stale to `isStaleByTime`. It is worth being exact about - * that rather than trusting "invalidate then read": `ensureQueryData` - the - * obvious call, and the one this used - returns cached data without consulting - * the mark at all, so the guard would have decided on the anonymous session and - * bounced a visitor who had just signed in. See the note on `ensureAuthState`. - * - * `navigate` is the caller's, because `?returnTo=` names a path and only the - * host knows what its router wants to be handed - `apps/web` de-localizes it - * first, so `/pl/discover` reaches the router as `/discover` and the rewrite - * writes the prefix back. Nothing here holds a list of auth destinations. - */ export const useSignInAction = ({ destination, navigate, @@ -126,20 +61,6 @@ export const useSignInAction = ({ }; }; -/** - * Starting an SSO sign-in. - * - * A plain function rather than a hook: it reads no cached state and moves no - * router, because success leaves this application entirely. - * - * The provider is another origin, so leaving is a full-document navigation - * rather than a router one - and it has to be, because the round trip comes back - * to a URL the provider was told about, not to a client-side route. - * - * The reply to this call carries the API's short-lived `--state-sso` cookie, - * which `saveApiCookies` writes onto the browser before this returns. Navigating - * away any earlier would lose it and the callback would fail its state check. - */ export const startSsoAction: SSOSelectProvider = async providerId => { const result = await authTransport().startSso({ providerId }); @@ -150,14 +71,6 @@ export const startSsoAction: SSOSelectProvider = async providerId => { return undefined; }; -/** - * Finishing an SSO sign-in - the exchange half of `useSSOCallback`. - * - * Takes the parameters `parseSsoCallback` validated, or `null` when the callback - * URL never carried a usable set. `null` answers `unknown` without calling the - * API at all: a callback with no `code` has nothing to exchange, and sending it - * anyway would be a request whose only possible outcome is an error. - */ export const useCompleteSsoAction = (params: null | SsoCallbackInput) => { const queryClient = useQueryClient(); @@ -181,67 +94,6 @@ export const useCompleteSsoAction = (params: null | SsoCallbackInput) => { }; }; -/** - * Signing out. - * - * Two writes, in this order, and both are needed: - * - * 1. **Write the anonymous session.** The reply carries the cookie deletion but - * not a session body, so without this the cache still holds the previous - * visitor until a refetch returns - and every guard and component reading it - * in between believes them still signed in. - * 2. **Invalidate.** The written value is this layer's inference, not the server's - * answer; marking it stale means the next reader confirms it. - * - * `router.invalidate()` then re-runs the matched routes' `beforeLoad`, so a - * visitor sitting on a page behind `_authenticated` is redirected out of it by - * the guard that owns that rule, rather than by anything here. - * - * The header's user menu is what calls it, and it is the only sign-out control - * VitNode renders. - * - * The failure is *reported* rather than thrown, and the caller decides: the - * header raises the internal-error toast and stays signed in, which is honest - - * a session that could not be ended is still a session. - * - * ## Everything privileged the AdminCP held is dropped either way - * - * `isAdmin` picks which cookie the API deletes, but the *cache* is cleared for - * both, and `removeQueries` rather than an invalidation. Two reasons, and the - * second is the one that is easy to miss: - * - * - **`isAdmin: true`** is the AdminCP's own sign-out. Marking the entries stale - * would leave this administrator's permission set - and their screens - in the - * browser, rendered by anything still mounted, until a refetch returned. Since - * the next person at this browser may be a different administrator signing in, - * "until a refetch" is a window where one admin sees another's panel. - * - **`isAdmin: false`** does not touch the admin cookie at all - but it does - * mean the person in front of the browser has stated they are leaving. The - * right response to "who is this?" becoming uncertain is to re-derive the - * answer from the cookie rather than to reuse the last one. - * - * `removeAdminIdentityQueries` is the whole of the admin half - the session - * entry *and* every AdminCP screen family - because a sign-out that dropped only - * the permission set would leave the palette's user lookups and the dashboard - * layout behind for whoever signs in next. `tanstack/admin/queries` owns that - * list, and it is the only place it is written down. - * - * ## And the visitor's own private data, for the same reason - * - * `removeUserIdentityQueries` is the public half, added in Stage 14 because the - * two halves of the application disagreed: the AdminCP dropped its privileged - * entries at every identity boundary and the public app dropped none of its own. - * `["files","user"]` and `["devices","user"]` are keyed by owner, so the *next* - * visitor could never read the previous one's - but a shared browser held one - * person's file names, operating systems and IP addresses for the default - * five-minute `gcTime` after they had signed out and walked away. Residency - * rather than leakage, and exactly the residency the panel refuses. - * `tanstack/auth/queries` owns that list. - * - * Neither is a security property: the cookie is what authorizes an API call, and - * Hono re-reads it every time. This is about what the *browser* holds and - * renders between a sign-out and the next read. - */ export const useSignOutAction = () => { const queryClient = useQueryClient(); const router = useRouter(); @@ -264,42 +116,6 @@ export const useSignOutAction = () => { }; }; -/** - * Registering, in the shape `SignUpFormContent` submits. - * - * The one action here with two success paths, and the ordering in the verified - * one is the whole reason it is a hook: - * - * signUp() the API mints the session, saveApiCookies puts - * the cookie on this response - * invalidateSession() the canonical entry every guard reads is marked - * stale, and every component observing it - * refetches before this resolves - * navigate(destination()) and arrives as the new visitor - * - * Navigating first would arrive at a guard still holding the anonymous session - * and bounce a freshly-registered visitor to the login page. The guard sees the - * mark because `ensureAuthState` reads through `fetchQuery` rather than - * `ensureQueryData`, which would have ignored it - see the note there. - * - * `shouldRefreshSessionAfterSignUp` decides which path this is, rather than an - * inline `result.emailVerified`: an unverified account is *not* a session, so the - * cache must not be touched and the form must not be told to stand down. It gets - * `{ emailConfirmation }` instead and swaps itself for the "check your email" - * screen. That is also why the AdminCP cleanup sits inside the branch rather - * than above it - nothing has changed about who this browser holds data for - * until a session actually exists. - * - * There is no second auth store. This writes to the one entry `./session-query` - * owns, exactly as the sign-in action does. - * - * `destination` is a thunk for the same reason as on the login page: where to - * land can depend on a search parameter that changes under the form, and reading - * it at submit time rather than at mount time is what keeps the two in step. - * `navigate` is the caller's, so a destination the application does not own yet - * can become a document load rather than a client navigation to a route that - * cannot render. - */ export const useSignUpAction = ({ destination, navigate, @@ -328,41 +144,9 @@ export const useSignUpAction = ({ }; }; -/** - * Asking for a password-reset link, in the shape `PasswordResetFormContent` - * submits. - * - * A plain function rather than a hook: nothing about it touches the session, the - * router or any cached state. It cannot - the API mints no session here, and the - * visitor stays exactly where they are while the form swaps itself for the - * confirmation screen. - * - * The result says only whether the request was accepted. An address with an - * account and one without produce the identical `{ ok: true }`, because the API - * answers the identical `201`, and nothing in this path may add a distinction it - * withholds. - */ export const requestPasswordResetAction: PasswordResetSubmit = async values => passwordResetFormResult(await authTransport().requestPasswordReset(values)); -/** - * Setting a new password from a recovery link, in the shape - * `ChangePasswordFormContent` submits. - * - * Also a plain function, and deliberately so: the API changes the password and - * deletes the recovery row without minting a session, so there is nothing to - * refresh and nobody to sign in. Inventing either here would be this app - * asserting an authentication the server never performed. - * - * The link travels as an already-parsed `RecoveryLink` - the route reads it - * out of the URL through `parseRecoveryLink` - so `userId` is a number by the time - * it reaches here, and the server function validates it again on arrival because - * its input is whatever a caller posts. - * - * Where the visitor goes afterwards is `onChanged` on the shared form, not this: - * the destination is the login page, and moving the router belongs to the route - * that has one. - */ export const changePasswordFromResetAction: ChangePasswordSubmit = async values => changePasswordFormResult( diff --git a/packages/vitnode/src/tanstack/auth/contract.test.ts b/packages/vitnode/src/tanstack/auth/contract.test.ts index 65106408b..f0246a726 100644 --- a/packages/vitnode/src/tanstack/auth/contract.test.ts +++ b/packages/vitnode/src/tanstack/auth/contract.test.ts @@ -14,14 +14,6 @@ import { ssoStartResultFromStatus, } from "./contract"; -/** - * The auth transport's decisions, without the transport. - * - * Every status the API can answer these four calls with, and every shape a - * provider can send a visitor back with, mapped to the finite result a component - * is allowed to see. No Hono, no fetch, no server function - those are covered - * by typecheck and the build. - */ describe("sign-in results", () => { it("reads 201 as signed in", () => { expect(signInResultFromStatus(201)).toEqual({ ok: true }); @@ -297,14 +289,6 @@ describe("SSO callback input normalisation", () => { }); }); -/** - * The distinction the auth stack got wrong for a whole stage. - * - * `200 + { user: null }` is a visitor who is genuinely nobody. Every other - * answer means the session could not be *evaluated*, and reading that as - * "anonymous" is what signed people out during a rate-limit spike. There is no - * third status the session route declares, so `200` is the whole rule. - */ describe("reading a session response status", () => { it("treats 200 as a session that can be read", () => { expect(isUsableSessionStatus(200)).toBe(true); diff --git a/packages/vitnode/src/tanstack/auth/contract.ts b/packages/vitnode/src/tanstack/auth/contract.ts index 2b7440423..615c553af 100644 --- a/packages/vitnode/src/tanstack/auth/contract.ts +++ b/packages/vitnode/src/tanstack/auth/contract.ts @@ -3,50 +3,12 @@ import { z } from "zod"; import { RATE_LIMIT_STATUS } from "@/lib/fetcher/rate-limit"; import { signUpConflictReason } from "@/views/auth/sign-up/form/schema"; -/** - * What the auth mutations accept, and what they answer with. - * - * Pure and framework-free on purpose: no `createServerFn`, no fetcher, no - * cookies. Everything in here is a schema or a total function from an HTTP - * status to a finite result, which is what makes the interesting half of the - * auth transport testable without a server, a database or a browser - and what - * keeps the server handlers in `./server` down to "call the API, - * copy the cookies, map the status". - * - * The results are closed unions rather than the API's own JSON. A component - * gets `{ ok: false, reason: 'access_denied' }`, never a body it has to guess - * the shape of and never an internal message - a 500 from the API carries the - * failing URL and the exception text, and none of that belongs in a browser. - */ - -/** - * A provider id, as it may appear in a URL path. - * - * The API's own schema is `z.string()`, and the fetcher interpolates the value - * into the request path *without encoding it* (see `buildApiUrl`), so an - * unchecked id is a path-traversal primitive: `../../..` would resolve to a - * different API endpoint entirely. A conservative slug - what every shipped - * adapter uses (`google`, `discord`, `facebook`) - removes the question rather - * than answering it, since none of `/`, `.` or `%` survives it. - */ export const providerIdSchema = z .string() .min(1) .max(64) .regex(/^[A-Za-z0-9][A-Za-z0-9_-]*$/); -/** - * Email and password, plus the session flavour to mint. - * - * The smallest equivalent of `zodSignInSchema` on the API's sign-in route, - * written out rather than imported: that module pulls in `SessionModel`, - * `UserModel` and the Hono runtime with them, and this schema is reachable from - * the browser bundle. `.toLowerCase()` mirrors what the API does to the address - * before it looks a user up, so the value sent matches the value stored. - * - * `isAdmin` selects the AdminCP session rather than the public one, exactly as - * the legacy server action passed it through. - */ export const signInInputSchema = z.object({ email: z.email().toLowerCase(), isAdmin: z.boolean().optional(), @@ -69,16 +31,6 @@ export const ssoStartInputSchema = z.object({ export type SsoStartInput = z.infer<typeof ssoStartInputSchema>; -/** - * What the provider sends the visitor back with. - * - * `code` and `state` are bounded but otherwise unconstrained: `state` is - * verified cryptographically by the API against the `--state-sso` cookie it - * minted, and re-deriving its format here would be a second, weaker copy of - * that check which breaks the flow the day the API's state generation changes. - * The caps exist so a crafted callback URL cannot make an application forward an - * unbounded string; every real provider's values fit inside them. - */ export const ssoCallbackInputSchema = z.object({ code: z.string().min(1).max(2048), providerId: providerIdSchema, @@ -87,30 +39,6 @@ export const ssoCallbackInputSchema = z.object({ export type SsoCallbackInput = z.infer<typeof ssoCallbackInputSchema>; -/** - * ## The four results, and how the shared screens read them - * - * `{ ok: true }` or one failure from a closed list, so a component branches on - * a literal rather than on a status code or an API body. That is also the seam - * with `@vitnode/core`'s shared auth screens, whose props speak the legacy - * vocabulary: the caller wiring a screen to these translates once, at the call - * site, and nothing here has to know which UI it is feeding. - * - * signIn { ok: true } -> navigate - * { reason: 'access_denied' } -> `signInFormOutcome`'s - * `{ message: 'access_denied' }` - * { reason: 'server_error' } -> `{ message: 'Internal Server Error' }` - * - * completeSso { ok: true } -> navigate - * { reason: 'email_exists' } -> `SSOCallbackFailure` `'email_exists'` - * anything else -> `SSOCallbackFailure` `'unknown'` - * - * The extra reasons exist because the API distinguishes them and throwing that - * away here would be irreversible: `invalid_state` is a round trip that expired - * or was tampered with (start over), `unknown_provider` is an adapter this - * install does not have configured (a deployment mistake, not a visitor's). A - * screen that only has two states collapses them on the way in. - */ export type SignInResult = { ok: false; reason: "access_denied" | "server_error" } | { ok: true }; @@ -129,58 +57,13 @@ export type CompleteSsoResult = } | { ok: true }; -/** - * Re-exported from `./sign-up-session`, which is where it lives now. - * - * It reads {@link SignUpResult} and nothing else, so it needs no schema - and - * being the only *value* `./actions` took from this module, it was the edge - * that put `zod` on the public shell's path. Kept exported here so every - * existing importer, this package's barrel included, is unaffected. - */ export { shouldRefreshSessionAfterSignUp } from "./sign-up-session"; -/** - * Whether a session response can be read as a session at all. - * - * The distinction this migration got wrong for a whole stage, and the reason it is a - * named function rather than an inline `!== 200`. Two things arrive on the same - * wire and mean opposite things: - * - * 200 + { user: null } the visitor is genuinely nobody - * 429, 500, unreachable we do not know who the visitor is - * - * Collapsing the second into the first signs people out during a rate-limit - * spike: the guard on a protected route reads `user: null`, believes it, and - * redirects a signed-in visitor to the login page. So anything that is not a - * `200` is a *failed read*, which the caller has to raise rather than answer. - * - * `200` is the session route's only declared success (`users/session.route.ts` - * documents exactly one response), so this is the whole rule. - */ export const isUsableSessionStatus = (status: number): boolean => status === 200; -/** - * What the browser is told when the session could not be read. - * - * A fixed sentence and nothing else. An error thrown out of a server function is - * serialized back to the caller, and the errors the session read catches are not - * fit to send: `rawApiFetch` throws on a 500 with the failing API URL and the - * server's own error text in the message. The detail is logged where a server - * log is the right place for it. - * - * Here rather than beside the read itself so a host can recognise it - and a - * test assert on it - without importing anything server-only. - */ export const SESSION_UNAVAILABLE = "The session could not be read."; -/** - * The sign-in route answers `201` with the session cookie attached, `403` when - * the address is unknown or the password is wrong, and nothing else on purpose. - * Everything unexpected - a 429 from the rate limiter, a 500 - is one - * `server_error`, the same collapse the legacy action made, because a sign-in - * form has exactly two things to say. - */ export const signInResultFromStatus = (status: number): SignInResult => { if (status === 201) return { ok: true }; if (status === 403) return { ok: false, reason: "access_denied" }; @@ -188,23 +71,9 @@ export const signInResultFromStatus = (status: number): SignInResult => { return { ok: false, reason: "server_error" }; }; -/** - * Sign-out is `200` or it did not happen. The API deletes the cookie even when - * it finds no session to delete, so a `200` is the only outcome a working - * request has. - */ export const signOutResultFromStatus = (status: number): SignOutResult => status === 200 ? { ok: true } : { ok: false, reason: "server_error" }; -/** - * An absolute `http(s)` URL - the only kind a caller may put a browser at. - * - * The value comes from this install's own SSO adapter, so this is not a trust - * boundary so much as a guarantee about what leaves here: the caller performs a - * full-document navigation to it, and a relative or `javascript:` URL reaching - * that assignment is an XSS sink. Checked where the URL is turned into a result, - * so a broken adapter is a `server_error` rather than a navigation. - */ export const isProviderRedirectUrl = (value: unknown): value is string => { if (typeof value !== "string") return false; @@ -234,18 +103,6 @@ export const ssoStartResultFromStatus = ( return { ok: true, url }; }; -/** - * The callback route's outcomes. - * - * `409` is the one a visitor can act on: the provider's email already belongs to - * an account that was not created through it, so the answer is to sign in with a - * password instead - which is what the legacy 409 screen offers. `400` is the - * API rejecting the OAuth `state`, which means the round trip was tampered with - * or simply took too long, and starting over is the only way forward. - * - * Reachable only for a callback that already passed - * {@link parseSsoCallback}, which is why a missing `code` is not one of these. - */ export const completeSsoResultFromStatus = ( status: number, ): CompleteSsoResult => { @@ -274,18 +131,6 @@ const ssoCallbackQuerySchema = z.object({ const asQueryRecord = (query: unknown): unknown => query instanceof URLSearchParams ? Object.fromEntries(query) : query; -/** - * What the provider put in the callback URL, judged before any of it is sent on. - * - * The provider decides which half of the query it sends - `code` and `state` - * when the visitor approved, `error` when they did not - so the caller cannot - * know which shape it has without asking. The error branch comes first because - * OAuth allows both to be present and the error is the meaningful one. - * - * `error` is *classified*, never carried through: `access_denied` is the visitor - * declining at the provider and gets its own screen, and everything else becomes - * one `provider_error`, so no provider-authored string reaches the UI. - */ export const parseSsoCallback = ({ providerId, query, @@ -309,50 +154,8 @@ export const parseSsoCallback = ({ return { ok: true, params: params.data }; }; -/** - * ## Registration and password recovery - * - * Three more mutations, and the same shape as the four above: a schema for what - * a browser may send, and a total function from an HTTP status to a finite - * result. What is new is that two of them carry a captcha token and one of them - * can mint a session, so the notes below are about those two facts. - */ - -/** - * A solved captcha token, as it arrives from the widget. - * - * `""` is accepted and meaningful: `useCaptcha` reports itself ready with no - * token when this deployment has no captcha configured, and the API's - * `captchaMiddleware` is a no-op in exactly that case. So an empty string is - * "there was nothing to solve", and it is the transport's job to send no header - * rather than an empty one. - * - * The cap is generous because the tokens are: a Turnstile response is around - * 2 KB and a reCAPTCHA v3 one is longer still. It exists so a crafted call - * cannot make this server forward an unbounded header. - */ const captchaTokenSchema = z.string().max(8192).default(""); -/** - * What registration accepts. - * - * The API's own rules, restated rather than imported, for the reason the sign-in - * schema gives: `users/routes/sign-up.route.ts` pulls in `UserModel`, - * `PasswordModel` and the Hono runtime with them, and this module is reachable - * from the browser bundle. Every bound here is the API's: - * - * email z.email().toLowerCase() identical - * name min 3, no doubled spaces, and the character class from - * `nameRegex` - `[\p{L}\p{N}._@ -]` - * password min 8 (the *form* asks for more; see - * `createPasswordZodSchema` in @vitnode/core) - * newsletter optional boolean - * - * The maxima are this layer's own and have no counterpart on the API, which - * bounds none of these: 32 characters of name because that is what the - * registration form allows, and 1024 of password so a submission cannot ask this - * server to hash an unbounded string. - */ export const signUpInputSchema = z.object({ captchaToken: captchaTokenSchema, email: z.email().toLowerCase(), @@ -367,16 +170,6 @@ export const signUpInputSchema = z.object({ export type SignUpInput = z.infer<typeof signUpInputSchema>; -/** - * The `201` body, validated rather than trusted. - * - * Two reasons it is a schema and not a cast. It decides whether the visitor is - * now signed in - `emailVerified` is what the API branched on when it chose to - * mint a session - so a missing or wrongly-typed field must not read as `false` - * by accident. And the sign-up route declares its `400` and `409` without a - * `content` block, which makes the fetcher's inferred `json()` type `unknown`; - * parsing is how that becomes a shape rather than an assertion. - */ const signUpSuccessSchema = z.object({ email: z.string(), emailVerified: z.boolean(), @@ -394,17 +187,6 @@ export type PasswordResetRequestInput = z.infer< typeof passwordResetRequestInputSchema >; -/** - * What a password change accepts. - * - * The link's two values *and* a password, validated here even though - * `parseRecoveryLink` in `@vitnode/core` already judged the first two on the way - * out of the URL. That is not redundancy: a server function is a public - * same-origin endpoint, so its input is whatever a caller posts, and the - * component that parsed the URL is not in the call path. The bounds match that - * parser's - a base64url token, a safe-integer id - so a link the app was - * willing to render a form for is a link this schema accepts. - */ export const changePasswordInputSchema = z.object({ password: z.string().min(8).max(1024), token: z @@ -417,40 +199,6 @@ export const changePasswordInputSchema = z.object({ export type ChangePasswordInput = z.infer<typeof changePasswordInputSchema>; -/** - * ## The three results - * - * signUp { ok: true, email, emailVerified } - * { reason: 'email_exists' | 'name_exists' } -> mark a field - * { reason: 'conflict' } -> a 409 we - * could not - * classify - * { reason: 'invalid' } -> the API - * refused the - * body or the - * captcha - * { reason: 'rate_limited' | 'server_error' } - * - * requestPasswordReset { ok: true } - * { reason: 'invalid' | 'rate_limited' | 'server_error' } - * - * changePasswordFromReset { ok: true } - * { reason: 'invalid_token' } -> ask for a - * fresh link - * { reason: 'rate_limited' | 'server_error' } - * - * `rate_limited` is kept apart from `server_error` even though today's screens - * render both the same way. The API answers `429` with a `Retry-After` header, - * and `notifyRateLimited` - the toast the browser fetcher raises - is a no-op on - * a server, so a mutation that goes through a server function is the *only* place - * that fact can be observed. Collapsing it here would make it unobservable - * anywhere. - * - * `email` and `emailVerified` come back from sign-up because the caller needs - * both: the address is printed on the "check your email" screen, and the flag is - * the difference between a visitor who now holds a session cookie and one who - * does not. See {@link shouldRefreshSessionAfterSignUp}. - */ export type SignUpResult = | { email: string; emailVerified: boolean; ok: true } | { @@ -472,26 +220,6 @@ export type ChangePasswordResult = | { ok: false; reason: "invalid_token" | "rate_limited" | "server_error" } | { ok: true }; -/** - * A registration attempt, from the status and whatever body came with it. - * - * `201` is the route's only success, and it is the one status whose body is - * read - through {@link signUpSuccessSchema}, so a `201` the API answered with - * something unexpected is a `server_error` rather than a visitor who is - * mysteriously signed in or not. - * - * `400` is `invalid`, and it covers two things the API spells the same way: a - * body its schema refused, and a captcha `captchaMiddleware` refused - * (`"Captcha token is required"`, `"Captcha validation failed"` - both `400`). - * Neither message travels; a caller that wants to distinguish them would need - * the API to say so, which today it does not. - * - * `409` goes through core's `signUpConflictReason`, the single classifier both - * frontends use, so `"Email already exists"` becomes `email_exists` here and in - * the Next.js server action from the same code. A `409` whose body matches - * neither known message is `conflict`: still a conflict, just not one that can be - * pinned to a field. - */ export const signUpResultFromStatus = ( status: number, { body, conflict }: { body?: unknown; conflict?: string } = {}, @@ -523,19 +251,6 @@ export const signUpResultFromStatus = ( return { ok: false, reason: "server_error" }; }; -/** - * A reset request's outcome. - * - * `201` is the route's only declared response, and the API answers it whether or - * not the address belongs to an account and whether or not it decided to skip - * the send because one was requested in the last five minutes. That is the - * product's anti-enumeration behaviour and this function preserves it exactly: - * there is no reason in {@link PasswordResetRequestResult} that could mean "no - * such account", so no caller can accidentally reveal one. - * - * `400` is `invalid` - a malformed address, or a captcha the middleware refused. - * It says nothing about whether the address exists. - */ export const passwordResetRequestResultFromStatus = ( status: number, ): PasswordResetRequestResult => { @@ -547,19 +262,6 @@ export const passwordResetRequestResultFromStatus = ( return { ok: false, reason: "server_error" }; }; -/** - * A password change's outcome. - * - * `400` is the one a visitor can act on. The API looks the recovery row up by - * `userId` *and* `token` *and* an unexpired `expiresAt`, and answers - * `400 "Invalid token"` when any of the three does not match - so a wrong link, a - * spent link and a link older than thirty minutes are one status, and "ask for a - * fresh one" is the answer to all of them. - * - * Nothing here signs anybody in, because the route mints no session: it hashes - * the new password, writes it, and deletes the recovery row. A caller must not - * invent a session refresh around it. - */ export const changePasswordResultFromStatus = ( status: number, ): ChangePasswordResult => { diff --git a/packages/vitnode/src/tanstack/auth/index.ts b/packages/vitnode/src/tanstack/auth/index.ts index 6467ae7a2..0cafc0e61 100644 --- a/packages/vitnode/src/tanstack/auth/index.ts +++ b/packages/vitnode/src/tanstack/auth/index.ts @@ -1,61 +1,3 @@ -/** - * VitNode's auth runtime for TanStack Start - `@vitnode/core/tanstack/auth`. - * - * Everything an application needs to sign somebody in, out, or up, minus the - * three things a package cannot own: its route tree, its server functions, and - * where a visitor goes next. What is left is the whole of the behaviour, and it - * is one implementation rather than one per host. - * - * ./contract the schemas a browser's input is parsed by, and the - * closed result union every mutation answers with - * ./state the session as route state: AuthState, the guards, - * and the one query key - * ./session-query the canonical ["vitnode", "session"] entry - one - * definition, read by every guard and component - * ./return-to is this target somewhere we may send a browser at all - * ./redirects and where does this flow send them - * ./screens the contract in the vocabulary the shared auth views - * speak - * ./recovery which password-recovery screen a URL asks for, and - * whether this deployment has the flow at all - * ./route-search what `/login` and the SSO callback read out of their - * URLs, as normalisers a crafted link cannot break - * ./middleware-config what this installation has configured: SSO adapters, - * an email adapter, a captcha site key - * ./actions the client orchestration - mutate, then bring the one - * cache entry back in step, then navigate - * ./queries what an identity boundary drops: the private-user - * cache roots, the public half of the AdminCP's list - * ./transport the eight server functions the host registers - * - * `@vitnode/core/tanstack/auth/server` is the other half: the request-scoped - * calls to the Hono users API, including the cookie copying. It is a separate - * subpath because this barrel is imported by browser bundles and that one may - * never be. - * - * ## What stays in the application - * - * Three things, and each for a reason the boundary test states: - * - * - **`createServerFn`.** A host externalises this package from Vite's SSR pass, - * so package code reaches the server uncompiled - and an uncompiled server - * function silently resolves to `undefined` during SSR. The host declares - * eight one-line wrappers over `./server` and registers them; see - * `./transport`. - * - **The route tree.** Guards and redirects are route composition. This package - * hands them the decisions (`canAccessGuestRoute`, `postAuthDestination`, - * `normalizeLoginSearch`) and never a `createFileRoute`. A route's *search - * contract* is not composition and is not left to the host: it describes what - * a stranger may put in that URL, which is the same question on every install. - * - **Navigation.** `useSignInAction` and `useSignUpAction` take a `navigate`, - * because during the Next.js migration a post-login destination may still - * belong to the other application. - * - * There is deliberately no `AuthProvider`, no `AuthContext` and no auth store. - * The QueryClient the host already owns holds the session, under one key, and - * that is the whole architecture. - */ - export * from "./actions"; export * from "./contract"; export type { AuthLoaderContext, AuthRouteData } from "./login-route"; diff --git a/packages/vitnode/src/tanstack/auth/login-route.tsx b/packages/vitnode/src/tanstack/auth/login-route.tsx index 9d1c35788..aa2f127fc 100644 --- a/packages/vitnode/src/tanstack/auth/login-route.tsx +++ b/packages/vitnode/src/tanstack/auth/login-route.tsx @@ -5,15 +5,6 @@ import { createTranslator } from "use-intl"; import { intlQueryOptions } from "../i18n/query"; import { middlewareConfigQueryOptions } from "./middleware-config"; -/** - * What the login page renders strings from. - * - * `core.global` is the shell's and the heading's, `core.auth.sign_in` is the - * card's and the form's, `core.auth.sso` is the provider row's. One list, read - * by both the loader that fetches them and the provider that mounts them, - * because they have to be the same set or the provider suspends on a key nobody - * warmed. - */ export const LOGIN_NAMESPACES = [ "core.global", "core.auth.sign_in", @@ -31,17 +22,6 @@ export interface AuthRouteData { title: string; } -/** - * One of the auth pages' titles, translated once in the request's language. - * - * The cast is what makes `createTranslator` usable at all: its key type is - * derived from the *inferred* type of `messages`, and `AbstractIntlMessages` is - * a bare index signature - so `MessageKeys` cannot tell a leaf from a branch and - * collapses to `never`, making every key a type error. Naming the two keys the - * auth routes read is both the smallest fix and a true statement: rename either - * in `locales/en.json` and this stops compiling instead of rendering a raw - * message key into a `<title>`. - */ const translateAuthTitle = ( locale: string, messages: unknown, @@ -55,18 +35,6 @@ const translateAuthTitle = ( namespace: "core.global", })(key); -/** - * The two reads an auth card needs, in parallel and before it renders. - * - * Neither is repeated by the component: the messages are read back by - * `RouteMessages` through the identical `intlQueryOptions`, and the deployment - * configuration by `useSuspenseQuery` through the identical - * `middlewareConfigQueryOptions` - the same entry both cards warm, so arriving - * from one to the other costs nothing. - * - * The session is *not* fetched here. A guest guard's `beforeLoad` has already - * put it in the cache entry every guard reads. - */ const loadAuthCard = async ( { locale, queryClient }: AuthLoaderContext, namespaces: readonly string[], diff --git a/packages/vitnode/src/tanstack/auth/login-screen.tsx b/packages/vitnode/src/tanstack/auth/login-screen.tsx index 7f85988b3..1671ea9ed 100644 --- a/packages/vitnode/src/tanstack/auth/login-screen.tsx +++ b/packages/vitnode/src/tanstack/auth/login-screen.tsx @@ -21,26 +21,12 @@ import { postAuthDestination } from "./redirects"; export interface LoginRouteProps { LinkComponent: AuthLinkComponent; - /** - * How a finished sign-in moves. During the migration a host passes one that - * asks its route tree whether it serves the destination, so `/discover` is a - * client-side navigation and `/settings/security?tab=devices` is a document - * load into the application that still serves it. - */ + navigate: AuthNavigate; /** Where the visitor was heading before a guard sent them here. */ returnTo?: string; } -/** - * The login page, as everything below a route file's `component`. - * - * This screen deliberately ignores `config.isKnown`: a failed read degrades to - * no provider row and no reset-password link, and the email and password fields - * - which are the whole of signing in on most installs - still render. Making - * the login page unavailable because an optional read failed would be a far - * larger outage than the one that caused it. - */ export const LoginRouteContent = ({ LinkComponent, navigate, diff --git a/packages/vitnode/src/tanstack/auth/middleware-config.ts b/packages/vitnode/src/tanstack/auth/middleware-config.ts index d46585feb..0304eb997 100644 --- a/packages/vitnode/src/tanstack/auth/middleware-config.ts +++ b/packages/vitnode/src/tanstack/auth/middleware-config.ts @@ -12,72 +12,12 @@ import { normalizeSSOProviders } from "@/views/auth/sso/providers"; import { fetchMiddlewareConfigOnServer } from "./middleware-config-server"; -/** - * What the auth screens need to know about *this installation*: which SSO - * adapters are registered, whether an email adapter exists to send a - * reset-password link, and the public captcha key. - * - * Derived from `vitnode.api.config.ts`, so it is the same answer for every - * visitor and only changes on deploy - which is what makes it a public, - * anonymous read rather than anything session-shaped. - */ export type MiddlewareConfig = z.infer<typeof routeMiddlewareSchema>; -/** - * The configuration, plus the one thing the configuration itself cannot say: - * whether it was actually read. - * - * A single flag rather than a wrapper, and that is a decision about the four - * consumers. `/login`, `/register` and the SSO callback all want the *fields* - - * `config.captcha`, `config.isEmail`, `ssoProvidersOf(config)` - and are - * indifferent to where they came from; only password recovery has to tell the - * two apart. Wrapping the config in a `{ status, config }` result would make all - * four unwrap it to serve one, so the flag rides alongside the fields and the - * screens that do not care never mention it. - * - * ## Why the distinction has to exist at all - * - * A failed read degrades to {@link UNKNOWN_MIDDLEWARE_CONFIG}, which says - * `isEmail: false` - and for `/login` that is exactly right, a reduced form - * rather than no form. But `isEmail: false` is also what a deployment with no - * email adapter genuinely looks like, and `/login/reset-password` answers that - * with `notFound()`. Collapsed together, an API outage made password recovery - * return **404**: the app claiming a route does not exist because it could not - * reach its own API. See `passwordRecoveryAvailability`. - */ export interface MiddlewareConfigState extends MiddlewareConfig { - /** - * `true` when these fields are the API's answer, `false` when they are the - * fallback below. - * - * Not part of the API schema - it is this layer's record of whether the read - * succeeded, so a consumer can distinguish "configured off" from "unknown". - */ isKnown: boolean; } -/** - * What a screen renders when the configuration cannot be read: the email and - * password fields, and nothing that depends on a configured adapter. - * - * Shared by both transports so a failure looks the same during SSR and after - * hydration, rather than the page changing shape when it rehydrates. - * - * `isKnown: false` is the load-bearing field. Every other value here is a - * *guess* chosen to degrade safely, and a consumer that must not guess reads - * this one first. - * - * ## What it costs the screens that need a captcha - * - * `captcha` is absent here, and it cannot be otherwise - a widget needs a site - * key, and the read that would have supplied one is the read that failed. So on - * a deployment *with* a captcha configured, a registration or reset-password - * form rendered from this fallback shows no widget, submits an empty token, and - * the API answers `400` - which reaches the visitor as the internal-error toast - * rather than as a silent success. Degraded, but not wrong: nothing is created - * and nothing is claimed to have been. The alternative would be inventing a - * configuration, which is how a form ends up looking solved when it is not. - */ export const UNKNOWN_MIDDLEWARE_CONFIG: MiddlewareConfigState = Object.freeze({ isEmail: false, isKnown: false, @@ -91,12 +31,6 @@ export const knownMiddlewareConfig = ( const middleware = clientModule<typeof middlewareModule>("@vitnode/core"); -/** - * The same read from the browser, for a client-side navigation into `/login`. - * - * Core's own browser fetcher, which is what the Next.js app's client components - * use - so a hydrated page and a Next.js page make the identical request. - */ const fetchMiddlewareConfigInBrowser = async (): Promise<MiddlewareConfigState> => { try { @@ -114,22 +48,6 @@ const fetchMiddlewareConfigInBrowser = } }; -/** - * The transport boundary, and the reason one query definition works in a loader - * and in a component. - * - * Deliberately no `createServerFn` in between: this is a public, anonymous read - * that the API is already the boundary for, so routing it through a `POST` back - * to the application would cost two round trips instead of one - the same - * reasoning the public feed reads follow. - * - * `createIsomorphicFn` is what makes that safe rather than merely tidy: the - * Start compiler keeps only the branch belonging to the bundle it is building - * and drops the other's import with it, so `middleware-config-server.ts` - and - * the `server-only` marker in it - never reaches the browser. It is also the one - * Start primitive a package may declare: uncompiled, on a server, its runtime - * stub prefers the `.server()` branch, which is exactly what a server wants. - */ const fetchMiddlewareConfig = createIsomorphicFn() .server(fetchMiddlewareConfigOnServer) .client(fetchMiddlewareConfigInBrowser); @@ -137,16 +55,6 @@ const fetchMiddlewareConfig = createIsomorphicFn() /** Everything a middleware-configuration cache entry's key starts with. */ const MIDDLEWARE_QUERY_KEY = ["vitnode", "middleware"] as const; -/** - * How long a cached copy is trusted. - * - * Deployment configuration changes on deploy and not otherwise, so this is - * generous on purpose: the login page and the SSO callback both read it, and - * navigating between them should not re-ask. It is not `Infinity` only so a - * long-lived tab eventually notices a deploy. - * - * No locale in the key: provider ids and names are configuration, not copy. - */ const MIDDLEWARE_STALE_TIME = 300_000; export const middlewareConfigQueryOptions = () => @@ -156,17 +64,5 @@ export const middlewareConfigQueryOptions = () => staleTime: MIDDLEWARE_STALE_TIME, }); -/** - * The registered SSO providers, made safe to render. - * - * Core's own normaliser - the one the Next.js provider row uses - so a provider - * missing a name, or listed twice, produces the same button row in both - * frameworks. - * - * Takes the bare {@link MiddlewareConfig}, so it reads a - * {@link MiddlewareConfigState} without caring which one it is: an unread - * configuration has no providers, and an empty provider row is the correct - * degraded rendering. Nothing here needs to know the difference. - */ export const ssoProvidersOf = (config: MiddlewareConfig): SSOProvider[] => normalizeSSOProviders(config.sso); diff --git a/packages/vitnode/src/tanstack/auth/queries.test.ts b/packages/vitnode/src/tanstack/auth/queries.test.ts index 9049a5224..3d04ed369 100644 --- a/packages/vitnode/src/tanstack/auth/queries.test.ts +++ b/packages/vitnode/src/tanstack/auth/queries.test.ts @@ -17,20 +17,6 @@ import { import { removeUserIdentityQueries } from "./queries"; import { SESSION_QUERY_KEY } from "./state"; -/** - * What a public identity boundary throws away, and - just as load-bearing - what - * it leaves alone. - * - * The public counterpart of `tanstack/admin/queries.test.ts`, and deliberately - * the same shape: a `QueryClient` with data written into it and nothing else, no - * router, no React, no server. Every assertion is a statement about the *cache*, - * which is the only thing the function under test touches. - * - * The failure it guards against is silent by construction. A cache entry that - * outlives the visitor it belonged to renders perfectly - it is somebody else's - * data, drawn correctly - and produces no error, no warning and no log line. - */ - /** Two visitors, so "drops the previous owner's partition" can mean something. */ const ALICE = 7; const BOB = 9; @@ -48,14 +34,6 @@ const BOB_FILES = [ const ALICE_DEVICES = [...devicesQueryKey(ALICE)]; const BOB_DEVICES = [...devicesQueryKey(BOB)]; -/** - * Entries that belong to other layers, and must survive. - * - * The session is the pointed one. It is *not* on the removal list: a sign-out - * writes the anonymous session over it so that every guard and the header read a - * signed-out visitor, and dropping the entry instead would leave them reading - * nothing at all. `./session-query` owns that lifecycle. - */ const SESSION = [...SESSION_QUERY_KEY]; const INTL = ["vitnode", "intl", "en", ["core.global"]]; const MIDDLEWARE = ["vitnode", "middleware"]; diff --git a/packages/vitnode/src/tanstack/auth/queries.ts b/packages/vitnode/src/tanstack/auth/queries.ts index 16d568adf..d698752da 100644 --- a/packages/vitnode/src/tanstack/auth/queries.ts +++ b/packages/vitnode/src/tanstack/auth/queries.ts @@ -3,65 +3,6 @@ import type { QueryClient } from "@tanstack/react-query"; import { DEVICES_IDENTITY_ROOT } from "@/views/auth/settings/devices/devices-query"; import { MY_FILES_IDENTITY_ROOT } from "@/views/files/my-files-query"; -/** - * Everything private this browser holds about *the previous visitor*, dropped. - * - * The public half of the pair whose other half is `removeAdminIdentityQueries` - * in `tanstack/admin/queries`, and it exists because the two halves of the - * application disagreed. The AdminCP has dropped its privileged entries at every - * identity boundary since Stage 12; the public app dropped none of its own, so a - * visitor's file names and their device list - operating systems, browsers, IP - * addresses, sign-in times - stayed in the browser heap after they signed out. - * - * ## Why partitioning was not already enough - * - * Both families are keyed by owner (`myFilesQueryRoot`, `devicesQueryKey`), and - * that is load-bearing: it is what stops visitor B *reading* visitor A's entry - * on a shared tab, immediately and without anything having to run. So this is - * residency rather than leakage, and it is worth being exact about the - * difference rather than overstating the fix. - * - * What partitioning does not do is remove the data. A shared browser holds A's - * private rows for the default five-minute `gcTime` after A has gone, readable - * by anything with a debugger and dehydrated into nothing - but present. That is - * precisely the property `removeAdminShellQueries` exists to deny the AdminCP, - * and there is no argument for why a file listing deserves less than a cron - * list. - * - * ## Prefixes, above the partitions - * - * `["files","user"]` and `["devices","user"]` - the roots *above* every owner's - * partition, not one owner's. An identity boundary cannot name whose data to - * drop: the whole event is that who this browser belongs to has become - * uncertain, and a sign-in has no previous owner to name at all. Dropping the - * wider prefix is the only spelling that collects a visitor who signed out two - * sign-ins ago. - * - * The two roots come from the modules that build the keys, so a partition scheme - * that changes has one place to change and this cleanup cannot quietly start - * matching nothing. - * - * ## Removal, not invalidation - * - * `removeQueries` throughout, for the reason the admin side states: invalidation - * keeps the value and marks it stale, so the next render still paints the - * previous visitor's rows until a refetch returns. Removal deletes them, so - * there is nothing to render from. - * - * ## What it does not touch - * - * Two prefixes, deliberately, and never `queryClient.clear()`. The session entry - * is not on this list - `./session-query` owns its lifecycle, and a sign-out - * *writes* the anonymous session rather than dropping it so that every guard and - * header reading it sees a signed-out visitor rather than nothing at all. The - * message catalogues, the middleware config and a plugin's own entries are not - * this function's to throw away either: they are public, they are identical for - * every visitor, and collecting them would turn a sign-in into a full-cache - * eviction that re-fetches the whole application. - * - * Anything the public app caches per-visitor in future belongs on this list, and - * this is the only place it is written down. - */ export const removeUserIdentityQueries = (queryClient: QueryClient): void => { queryClient.removeQueries({ queryKey: MY_FILES_IDENTITY_ROOT }); queryClient.removeQueries({ queryKey: DEVICES_IDENTITY_ROOT }); diff --git a/packages/vitnode/src/tanstack/auth/recovery-contract.test.ts b/packages/vitnode/src/tanstack/auth/recovery-contract.test.ts index 1d20e8fb5..d01c9fe83 100644 --- a/packages/vitnode/src/tanstack/auth/recovery-contract.test.ts +++ b/packages/vitnode/src/tanstack/auth/recovery-contract.test.ts @@ -7,15 +7,6 @@ import { passwordResetRequestResultFromStatus, } from "./contract"; -/** - * The two password-recovery mutations' decisions, without the transport. - * - * The interesting property here is not a mapping but an *absence*: there is no - * result the reset-request path can produce that says whether an address belongs - * to an account, because the API answers the same 201 either way. Several of the - * tests below exist to keep that true. - */ - /** What the API actually puts in the email: 32 random bytes as base64url. */ const TOKEN = "PSyRy0nQ0hRnfx3iCYldQ40mBLU9lqfDWtvNhrTsJI4"; diff --git a/packages/vitnode/src/tanstack/auth/recovery-route.tsx b/packages/vitnode/src/tanstack/auth/recovery-route.tsx index e1aa6a2ca..83d80ea8f 100644 --- a/packages/vitnode/src/tanstack/auth/recovery-route.tsx +++ b/packages/vitnode/src/tanstack/auth/recovery-route.tsx @@ -11,20 +11,6 @@ export interface PasswordResetRouteData { title: string; } -/** - * The strings this mode renders, warmed before it renders. - * - * `namespaces` is returned rather than recomputed in the component so the set - * mounted is *literally* the set warmed - the list is part of the query key, and - * two derivations that drifted would suspend the page. - * - * The deployment configuration is not fetched again: a `beforeLoad` has already - * put it in the cache entry the component reads back. - * - * `core.auth.reset_password.title` is the title in **both** modes, which is what - * the Next.js route's `generateMetadata` produces - it is page-level there and - * cannot vary by mode. See the cast note on `translateAuthTitle`. - */ export const loadPasswordResetRoute = async ({ locale, mode, @@ -49,11 +35,3 @@ export const loadPasswordResetRoute = async ({ return { namespaces, title }; }; - -/** - * The 404 for an install with no email adapter. - * - * `core.global` comes from a root provider, so this translates without a - * `RouteMessages` above it - which it has to, because a `notFoundComponent` - * renders *instead of* the component that would have mounted one. - */ diff --git a/packages/vitnode/src/tanstack/auth/recovery-screen.tsx b/packages/vitnode/src/tanstack/auth/recovery-screen.tsx index a13f5fe93..4e64bd11f 100644 --- a/packages/vitnode/src/tanstack/auth/recovery-screen.tsx +++ b/packages/vitnode/src/tanstack/auth/recovery-screen.tsx @@ -20,21 +20,6 @@ import { middlewareConfigQueryOptions } from "./middleware-config"; import { passwordResetMode } from "./recovery"; import { LOGIN_PATH, parseInternalDestination } from "./redirects"; -/** - * Where the visitor goes once the password has changed. - * - * The login page, replacing the current entry rather than pushing one - which is - * what the Next.js form does (`replace("/login")`) and worth keeping for a - * reason beyond parity: the URL being left behind carries a recovery token, and - * a push would leave it one Back press away. - * - * The API mints **no session** on a password change, so this really is the next - * step rather than a redundant hop: the visitor is still signed out. - * - * `parseInternalDestination` rather than a bare `to`, so the navigation goes - * through `buildLocation` and the locale rewrite writes the prefix back - a - * Polish visitor lands on `/pl/login`. - */ const CHANGED_PASSWORD_DESTINATION = { ...parseInternalDestination(LOGIN_PATH), replace: true, @@ -62,19 +47,6 @@ export interface PasswordResetRouteProps { search: PasswordResetSearch; } -/** - * Password recovery, as everything below a route file's `component` - both - * halves of it. - * - * /login/reset-password ask for a link - * /login/reset-password?token=..&userId=.. choose a new password - * - * which is what the Next.js `PasswordResetView` does with `if (token && userId)`. - * The mode is decided from the same pure function the loader used, so the - * namespaces mounted are the ones warmed for it, and the change-password branch - * carries the *parsed* link - which is what makes it impossible to render that - * form without both halves of a well-formed one. - */ export const PasswordResetRouteContent = ({ namespaces, search, diff --git a/packages/vitnode/src/tanstack/auth/recovery.test.ts b/packages/vitnode/src/tanstack/auth/recovery.test.ts index da9bea90e..c6eb9dfac 100644 --- a/packages/vitnode/src/tanstack/auth/recovery.test.ts +++ b/packages/vitnode/src/tanstack/auth/recovery.test.ts @@ -15,17 +15,6 @@ import { passwordResetNamespaces, } from "./recovery"; -/** - * Password recovery as data: which screen a URL asks for, what a route may write - * back into that URL, which strings each screen needs, and whether this - * deployment has the flow at all. - * - * Every function here is total over a query object, so the whole of the route's - * contract is stated without a router, a request or a render. What the route - * *does* with each answer - `notFound()`, an error, a form - is the host's, and - * is asserted where the route lives. - */ - /** What the API actually puts in a recovery email: 32 random bytes, base64url. */ const TOKEN = "PSyRy0nQ0hRnfx3iCYldQ40mBLU9lqfDWtvNhrTsJI4"; @@ -80,16 +69,6 @@ describe("the reset-password search schema", () => { }); describe("a recovery URL survives the router serialising it back", () => { - /** - * The canonical-location check, exercised against the router's own default - * search serialisers rather than described in prose. - * - * `loadServerRoute` rebuilds the location from the validated search and - * redirects when the result differs from the URL that arrived. So the schema's - * output has to stringify back to exactly the query it was parsed from - and - * this is the pair of functions that decides that, `JSON.parse` per value one - * way and its inverse the other. - */ it.each([ // The ordinary link. `?token=${TOKEN}&userId=123`, diff --git a/packages/vitnode/src/tanstack/auth/recovery.ts b/packages/vitnode/src/tanstack/auth/recovery.ts index 4a4b1ede3..dab2f349d 100644 --- a/packages/vitnode/src/tanstack/auth/recovery.ts +++ b/packages/vitnode/src/tanstack/auth/recovery.ts @@ -2,66 +2,11 @@ import type { RecoveryLink } from "@/views/auth/password-reset/recovery-link"; import { parseRecoveryLink } from "@/views/auth/password-reset/recovery-link"; -/** - * What `/login/reset-password` reads out of its URL, and what it turns that - * into. - * - * Pure functions, no transport and no React, so the route's whole contract can - * be stated and tested without a router - the same split `../search/route-search` - * makes for `/search`. `recovery.test.ts` beside it is the test. - * - * One route serves two screens, and this module is where that is decided: - * - * /login/reset-password -> ask for a link - * /login/reset-password?token=..&userId=.. -> choose a new password - * - * which is exactly what the Next.js `PasswordResetView` does with - * `if (token && userId)`, only spelled as a rule a crafted URL cannot walk past. - */ - -/** - * The two search parameters, in the shape the *router* produces them. - * - * `userId` is `number | string` rather than the `string` the URL literally - * contains, and that is not laxness - it is the only spelling that survives a - * round trip. TanStack's default search parsing is `JSON.parse` per value, so - * `?userId=123` reaches `validateSearch` as the **number** `123`; the default - * stringifier is its inverse, and re-serialising the *string* `'123'` produces - * `?userId=%22123%22`. The server compares the location it rebuilds against the - * one that arrived (`loadServerRoute`) and redirects when they differ, so - * coercing here would turn every recovery link into a 307 to a quoted URL. - * - * `token` has no such problem: the stringifier returns a string unchanged unless - * it parses as JSON, and a base64url token does not. - */ export interface PasswordResetSearch { token?: string; userId?: number | string; } -/** - * The route's search schema - a normaliser rather than a validator, for the same - * reason `/search`'s is. - * - * This URL is typed by strangers and pasted out of emails, so every malformed - * spelling has to render *a* page rather than an error boundary: the answer to - * `?userId=true`, `?token=`, or a missing half is the request form, which is the - * page a visitor who needs a new link wants anyway. - * - * Two rules, and both matter: - * - * - **Drop what cannot be a value.** An unusable parameter is returned as an - * *absent* key, so the router has nothing to write back and the URL settles to - * the clean one - `/search`'s trick, and what stops junk from riding along - * through the reset flow. - * - **Never coerce what can.** A kept value is the value that arrived, byte for - * byte, so `stringify(parse(url)) === url` and the canonical-location check - * does not redirect. See {@link PasswordResetSearch}. - * - * Judging whether the pair is *usable* is deliberately not done here - that is - * {@link passwordResetMode}, through core's `parseRecoveryLink`, so the rule - * lives once and both frameworks apply it. - */ export const normalizePasswordResetSearch = ( input: Record<string, unknown>, ): PasswordResetSearch => { @@ -76,19 +21,6 @@ export const normalizePasswordResetSearch = ( }; }; -/** - * Which of the two screens this URL asks for. - * - * A union rather than a boolean, so the change-password branch carries the - * *parsed* link and there is no way to reach that screen without one. Which is - * the whole of "do not pass partially present credentials to the API": - * `parseRecoveryLink` answers `null` unless both values are present and both are - * well formed, and this type has no shape in which half a link could travel. - * - * Not an authorization decision. The API looks the recovery row up by `userId` - * *and* `token` *and* an unexpired `expiresAt` and answers `400` when any of the - * three does not match; this only decides which form is worth rendering. - */ export type PasswordResetMode = { link: RecoveryLink; mode: "change" } | { mode: "request" }; @@ -100,21 +32,6 @@ export const passwordResetMode = ( return link ? { link, mode: "change" } : { mode: "request" }; }; -/** - * The strings the request form renders. - * - * `core.global` because the root's provider is replaced by the route's, and the - * error toasts read `core.global.errors.*` from it. `core.auth.sign_up` because - * both recovery screens borrow the email and password field labels from the - * registration form - which is what the Next.js view's two `I18nProvider`s - * already declare. - * - * `core.auth.reset_password` is in the *base* set rather than the request-only - * one because the page's title comes from it in **both** modes, exactly as the - * Next.js route's `generateMetadata` does. Warming it in change mode is one - * seven-string namespace, and the alternative is a differently-titled tab - * depending on which half of the flow a visitor is in. - */ const PASSWORD_RESET_BASE_NAMESPACES = [ "core.global", "core.auth.sign_up", @@ -127,14 +44,6 @@ const CHANGE_PASSWORD_NAMESPACES = [ "core.auth.change_password", ] as const; -/** - * What to warm, and what to mount - one function, so the loader and the provider - * cannot ask for different sets. - * - * They must not: `RouteMessages` reads the entry back with `useSuspenseQuery` - * over the same `intlQueryOptions`, and the namespace list is part of the query - * key. A provider asking for a set nobody warmed suspends the whole response. - */ export const passwordResetNamespaces = ( mode: PasswordResetMode["mode"], ): readonly string[] => @@ -142,31 +51,6 @@ export const passwordResetNamespaces = ( ? CHANGE_PASSWORD_NAMESPACES : PASSWORD_RESET_BASE_NAMESPACES; -/** - * Whether this deployment has password recovery - and the third answer, which is - * the point of this function existing. - * - * The API sends the reset link through the configured email adapter, so with no - * adapter there is no flow, and the Next.js view answers `notFound()` rather - * than rendering a form whose submit could never arrive. That much is preserved - * exactly. - * - * What is *not* preserved is the collapse underneath it. `isEmail: false` is two - * different facts wearing one value: - * - * the API answered, and no email adapter is configured -> disabled - * the API could not be reached, so this is the fallback -> unknown - * - * and the fallback says `isEmail: false` because that is the safe guess for the - * login form. Read as a boolean, an API outage therefore made this route answer - * **404** - the app claiming password recovery does not exist because it could - * not reach its own configuration. A visitor holding a valid recovery link, on a - * deployment that does send email, was told the page was not there. - * - * So the answer is three-valued, and the route acts on each differently: - * `notFound()` only for `disabled`, an error for `unknown`. `isKnown` is the - * whole of the distinction - see `MiddlewareConfigState`. - */ export type PasswordRecoveryAvailability = "available" | "disabled" | "unknown"; export const passwordRecoveryAvailability = ({ @@ -181,21 +65,6 @@ export const passwordRecoveryAvailability = ({ return isEmail ? "available" : "disabled"; }; -/** - * The deployment configuration could not be read, so whether recovery exists is - * not known. - * - * Thrown out of the route's `beforeLoad`, where it takes TanStack Router's - * ordinary error path - the same one any failing loader takes - rather than the - * not-found path. That is the entire behavioural difference and it is the - * correct one: a `404` is a statement about this application's routes, and this - * is a statement about its API being unreachable. - * - * A named class rather than a bare `Error` so the route's intent is legible at - * the throw site and this suite can assert on it without matching English. - * Deliberately not a new error *screen*: the router already renders one, and - * inventing a degraded-configuration UX is not this stage's work. - */ export class PasswordRecoveryUnknownError extends Error { constructor() { super( diff --git a/packages/vitnode/src/tanstack/auth/redirects.test.ts b/packages/vitnode/src/tanstack/auth/redirects.test.ts index 0f140cfbb..b554adf79 100644 --- a/packages/vitnode/src/tanstack/auth/redirects.test.ts +++ b/packages/vitnode/src/tanstack/auth/redirects.test.ts @@ -7,15 +7,6 @@ import { returnToFor, } from "./redirects"; -/** - * Where the auth flow sends people. - * - * Two directions, both pure string transforms, so the whole policy is a table: - * what a blocked visitor carries to the login page, and where a signed-in one is - * sent from it. The safety half - which targets are acceptable at all - is - * `auth-return-to.test.ts`; these are the decisions layered on top of it. - */ - describe("postAuthDestination", () => { it.each([ ["/discover", "/discover"], diff --git a/packages/vitnode/src/tanstack/auth/redirects.ts b/packages/vitnode/src/tanstack/auth/redirects.ts index a8cc21a08..3d5fd6d01 100644 --- a/packages/vitnode/src/tanstack/auth/redirects.ts +++ b/packages/vitnode/src/tanstack/auth/redirects.ts @@ -6,35 +6,6 @@ import type { AuthNavigate } from "./actions"; import { sanitizeReturnTo } from "./return-to"; -/** - * Where the auth flow sends people, as pure data. - * - * Two directions and one rule each: - * - * anonymous at /settings -> /login?returnTo=/settings (returnToFor) - * signed in at /login -> /settings (postAuthDestination) - * - * Nothing here navigates, and nothing here imports the router. Every function is - * a string transform, which is what lets the whole redirect policy - including - * the two ways it can go wrong - be stated as a table rather than exercised - * through a browser. - * - * `./return-to` is the security half: it decides whether a target is an - * application-relative path at all, and rejects every origin, scheme and - * control-character spelling. This module builds on that answer and adds the two - * things that are about *this* flow rather than about safety in general - the - * loop guard below, and the shape a TanStack redirect wants. - */ - -/** - * The login page's internal path - what the route tree matches, with no locale - * in it. - * - * `/login` and `/pl/login` are the same route: the application's locale rewrite - * strips the prefix before matching and writes it back into every href the - * router builds. So this constant is deliberately un-prefixed, and nothing in - * the auth flow concatenates a language onto it. - */ export const LOGIN_PATH = "/login"; /** The search parameter carrying where a blocked visitor was heading. */ @@ -53,21 +24,6 @@ export interface InternalDestination { to: string; } -/** - * A validated path, split into the fields a router navigation takes. - * - * **Not `href`.** A redirect carrying `href` is used verbatim - * (`Router.resolveRedirect` short-circuits on it), so it never reaches - * `buildLocation` and never runs the locale rewrite - a Polish visitor signing - * in at `/pl/login` would land on the English `/discover`. Split into - * `to`/`search`/`hash`, the same navigation goes through `buildLocation`, the - * rewrite writes the prefix back, and no code here has to know a language - * exists. - * - * Repeated search keys collapse to the last one. A `returnTo` is a link - * somebody clicked, not a form post, and every VitNode page reads its - * parameters singly. - */ export const parseInternalDestination = ( target: string, ): InternalDestination => { @@ -86,15 +42,6 @@ export const parseInternalDestination = ( const pathnameOf = (target: string): string => new URL(target, RELATIVE_BASE).pathname; -/** - * Whether a target points back at the login page - or anything under it. - * - * The loop guard, and the one failure mode this module exists to prevent: - * `/login?returnTo=/login` sends a signed-in visitor to the login page, whose - * guard sends them to `/login`, forever. `/login/sso/google` is caught by the - * same rule, because finishing an OAuth round trip that has already completed is - * the same loop wearing a provider's name. - */ const isLoginTarget = (target: string): boolean => { const pathname = pathnameOf(target); diff --git a/packages/vitnode/src/tanstack/auth/register-route.tsx b/packages/vitnode/src/tanstack/auth/register-route.tsx index 571f17c41..24925d056 100644 --- a/packages/vitnode/src/tanstack/auth/register-route.tsx +++ b/packages/vitnode/src/tanstack/auth/register-route.tsx @@ -2,15 +2,6 @@ import type { AuthLoaderContext, AuthRouteData } from "./login-route"; import { loadAuthCard } from "./login-route"; -/** - * What the registration page renders strings from. - * - * `core.global` is the heading's and the error toasts', `core.auth.sign_up` is - * the form's, `core.auth.sso` is the provider row's - the same three the Next.js - * view declares. One list, read by both the loader that fetches them and the - * provider that mounts them, because they have to be the same set or the - * provider suspends on a key nobody warmed. - */ export const REGISTER_NAMESPACES = [ "core.global", "core.auth.sign_up", diff --git a/packages/vitnode/src/tanstack/auth/register-screen.tsx b/packages/vitnode/src/tanstack/auth/register-screen.tsx index 905d6033b..49831134d 100644 --- a/packages/vitnode/src/tanstack/auth/register-screen.tsx +++ b/packages/vitnode/src/tanstack/auth/register-screen.tsx @@ -24,45 +24,6 @@ export interface RegisterRouteProps { navigate: AuthNavigate; } -/** - * The registration page, as everything below a route file's `component`. - * - * ## A deliberate decision not to branch on whether the config was read - * - * `config.isKnown` is available here - the same certainty flag password recovery - * acts on - and registration keeps its degraded rendering anyway: on an outage - * the card still shows the fields, minus the captcha widget and the provider - * row. That is a real cost on a captcha-configured deployment, where the submit - * then carries an empty token and the API answers `400`, which the form raises - * as the internal-error toast. Degraded, but never wrong: nothing is created and - * nothing is claimed to have been. - * - * It stays that way because the alternative is worse for the same visitor. A - * hard error would take registration down for every deployment - captcha or not - * - because one optional read failed, and most VitNode installs configure no - * captcha at all, so their signup would work perfectly if only it rendered. - * Password recovery is different in kind rather than in degree: there the - * fallback does not degrade a screen, it *asserts a fact* - "this deployment - * sends no email" - and turns that into a 404. - * - * ## The two kinds of success - * - * On a deployment with no email adapter the API marks the account verified and - * mints a session on the same response, so the cookie is copied onto this - * response, the canonical session entry is invalidated, and only then does the - * router move - a navigation that ran first would arrive at a guard still - * holding the anonymous session. - * - * On a deployment *with* an email adapter the account is unverified and no - * session exists, so the action navigates nowhere and answers - * `{ emailConfirmation }`; the shared form hands that to `WrapperSignUp` and the - * card is replaced by the "check your email" screen. Nothing here pretends the - * visitor is signed in. - * - * The destination is a thunk because `useSignUpAction` takes one - there is no - * `returnTo` on this route to read late, so it is a constant, and it is the same - * `postAuthDestination(undefined)` a guest guard sends a signed-in visitor to. - */ export const RegisterRouteContent = ({ LinkComponent, navigate, diff --git a/packages/vitnode/src/tanstack/auth/registration-contract.test.ts b/packages/vitnode/src/tanstack/auth/registration-contract.test.ts index 630260e22..01d49fbe3 100644 --- a/packages/vitnode/src/tanstack/auth/registration-contract.test.ts +++ b/packages/vitnode/src/tanstack/auth/registration-contract.test.ts @@ -6,14 +6,6 @@ import { signUpResultFromStatus, } from "./contract"; -/** - * The registration transport's decisions, without the transport. - * - * Every status the sign-up route can answer, and every shape its `201` body can - * arrive in, mapped to the finite result a component is allowed to see. No Hono, - * no fetch, no server function - those are covered by typecheck and the build. - */ - const success = { email: "test@test.com", emailVerified: true }; describe("sign-up results", () => { diff --git a/packages/vitnode/src/tanstack/auth/registration-screens.test.ts b/packages/vitnode/src/tanstack/auth/registration-screens.test.ts index 5655f2ed1..422fffedb 100644 --- a/packages/vitnode/src/tanstack/auth/registration-screens.test.ts +++ b/packages/vitnode/src/tanstack/auth/registration-screens.test.ts @@ -6,12 +6,6 @@ import { signUpFormResult, } from "./screens"; -/** - * The registration and recovery contracts translated into the vocabulary - * `@vitnode/core`'s shared forms speak. Total functions over finite unions, so - * every outcome the API can produce is checked here rather than in a browser. - */ - describe("signUpFormResult", () => { it("says nothing for a verified account, which is how the form knows the caller is leaving", () => { expect( diff --git a/packages/vitnode/src/tanstack/auth/return-to.test.ts b/packages/vitnode/src/tanstack/auth/return-to.test.ts index adeb07b68..8e6828d52 100644 --- a/packages/vitnode/src/tanstack/auth/return-to.test.ts +++ b/packages/vitnode/src/tanstack/auth/return-to.test.ts @@ -6,11 +6,6 @@ import { sanitizeReturnTo, } from "./return-to"; -/** - * The post-login redirect target, which is the one auth input any visitor can - * put anything into. Everything here is a pure string transform, so the whole - * rule can be stated as a table rather than exercised through a browser. - */ describe("sanitizeReturnTo keeps application-relative paths", () => { it.each([ "/", diff --git a/packages/vitnode/src/tanstack/auth/return-to.ts b/packages/vitnode/src/tanstack/auth/return-to.ts index 50e70054f..4048476d6 100644 --- a/packages/vitnode/src/tanstack/auth/return-to.ts +++ b/packages/vitnode/src/tanstack/auth/return-to.ts @@ -1,61 +1,10 @@ -/** - * Where a visitor goes after signing in, when the URL asked for somewhere - * specific. - * - * A pure string transform - no router, no request, no `window` - because the - * value it judges is the most attacker-reachable input in the whole auth flow: - * anyone can put `?returnTo=` on a link to the login page, and whatever comes - * out of here is handed to a navigation. So the rule is deliberately narrow: - * an *application-relative path*, or the fallback. Never a value that can name - * an origin, and never a scheme. - * - * That is what stops the two classic bugs at once - an open redirect - * (`?returnTo=https://evil.example.com`, which turns this site's login into a - * credible phishing hop) and a script URL (`?returnTo=javascript:...`, which is - * an XSS sink the moment anything assigns it to `location`). - * - * Nothing here performs a redirect. It answers "is this somewhere I may send - * you, and spelled how?" and the caller navigates. - */ - /** Where an absent or rejected target lands: this site's root. */ export const DEFAULT_RETURN_TO = "/"; -/** - * A base for `URL` to resolve a path against. - * - * `.invalid` is reserved by RFC 2606 precisely so it can never be a real host, - * which is what makes the origin comparison below meaningful: a value that - * carries an origin of its own - `https://evil.example.com`, - * `//evil.example.com` - resolves to *that* origin instead of this one, and is - * spotted by the two no longer matching. - */ const SENTINEL_ORIGIN = "http://return-to.invalid"; -/** - * Characters no accepted target may carry raw. - * - * Whitespace and C0/C1 controls because browsers *strip* tab, newline and - * carriage return out of a URL before parsing it, so `/\tjavascript:x` and - * `java\nscript:x` are two spellings of one thing and only one of them looks - * suspicious. A backslash because the URL parser treats `\` as `/` in a special - * scheme, which makes `/\evil.example.com` a protocol-relative URL wearing a - * disguise. Rejecting all of them outright is cheaper than trying to out-guess - * the normalisation, and a legitimate path needs none of them - it spells them - * percent-encoded. - */ const REJECTED_CHARACTERS = /[\s\\]|\p{Cc}/u; -/** - * The canonical spelling of an acceptable target, or `null`. - * - * The string checks come first and do the real work: a target must begin with a - * single `/`, which excludes every scheme (`javascript:`, `data:`, `https:`) and - * every protocol-relative host (`//evil.example.com`) before a parser is - * involved at all. `URL` then normalises what survives - resolving `.` and `..`, - * percent-encoding what has to be - and the origin check is the backstop for - * any spelling the string checks did not anticipate. - */ const normalize = (value: string): null | string => { if (!value.startsWith("/") || value.startsWith("//")) return null; if (REJECTED_CHARACTERS.test(value)) return null; diff --git a/packages/vitnode/src/tanstack/auth/route-search.test.ts b/packages/vitnode/src/tanstack/auth/route-search.test.ts index 11ae49fb5..360760f87 100644 --- a/packages/vitnode/src/tanstack/auth/route-search.test.ts +++ b/packages/vitnode/src/tanstack/auth/route-search.test.ts @@ -7,14 +7,6 @@ import { normalizeSsoCallbackSearch, } from "./route-search"; -/** - * The two auth search contracts, as the router actually hands them over. - * - * TanStack parses each query value with `JSON.parse` before `validateSearch` - * sees it, so the inputs below are the *parsed* shapes - `?state=48291` arrives - * as the number `48291`, not the string. That single fact is what these - * normalisers exist for, and what the schemas they replaced got wrong. - */ describe("normalizeLoginSearch", () => { it("keeps a target as it arrived, byte for byte", () => { // Not re-encoded and not resolved: the value has to survive a round trip diff --git a/packages/vitnode/src/tanstack/auth/route-search.ts b/packages/vitnode/src/tanstack/auth/route-search.ts index b15173194..7c5728db0 100644 --- a/packages/vitnode/src/tanstack/auth/route-search.ts +++ b/packages/vitnode/src/tanstack/auth/route-search.ts @@ -1,37 +1,3 @@ -/** - * What the auth routes read out of their URLs. - * - * Pure functions, no router and no React, so each route's search contract can be - * stated and tested without mounting anything - the same split `./recovery` - * makes for `/login/reset-password` and `../search/route-search` for `/search`. - * `route-search.test.ts` beside it is the test. - * - * ## Why these are normalisers rather than schemas - * - * Both of these URLs are written by somebody other than this application - one - * by whoever composed a link to the login page, the other by an OAuth provider - - * and TanStack's default search parsing is `JSON.parse` per value. So - * `?returnTo=123` reaches `validateSearch` as the **number** `123`, and - * `?state=48291` as the number `48291`. - * - * A `z.object({ returnTo: z.string().optional() })` throws on both, and a - * `validateSearch` that throws is a route that renders its error boundary. That - * is the wrong answer twice over: a crafted `?returnTo=` should produce an - * ordinary login page, not a broken one, and a numeric OAuth `state` should - * complete the sign-in the visitor is in the middle of. Neither value is - * *trusted* by being kept - `sanitizeReturnTo` and `parseSsoCallback` judge them - * afterwards, and they are the only things that do. - * - * So the rule is the one `normalizePasswordResetSearch` already follows: - * - * - **Drop what cannot be a value**, as an absent key rather than `undefined`, - * so the router has nothing to write back and the URL settles to the clean - * one. - * - **Never coerce what can.** A kept value is the value that arrived, byte for - * byte, so `stringify(parse(url)) === url` and the canonical-location check - * the server performs does not answer a 307. - */ - /** A non-empty string as it arrived, or nothing at all. */ const keptString = <K extends string>( key: K, @@ -41,58 +7,20 @@ const keptString = <K extends string>( ? ({ [key]: value } as Record<K, string>) : {}; -/** - * `/login`'s search parameters. - * - * One optional value, and it is deliberately typed as a plain `string` rather - * than something narrower. Whether a target is somewhere this application may - * send a browser is `sanitizeReturnTo`'s single answer, and stating it again as - * a type would be a second rule that can disagree with the first. - */ export interface LoginSearch { returnTo?: string; } -/** - * `/login`'s search schema. - * - * Everything that is not a non-empty string is dropped, which is the correct - * answer for `?returnTo=` (empty), `?returnTo=123` (a number, after - * `JSON.parse`) and `?returnTo` (absent) alike: no target was named, so the - * post-sign-in destination is the default one. - * - * A kept value is *not* a safe value. It is judged by `sanitizeReturnTo` at the - * point it is used - which is where an open redirect would actually happen - - * and rejecting it here instead would only mean a visitor following a crafted - * link saw an error page rather than the login form they can still use. - */ export const normalizeLoginSearch = ( input: Record<string, unknown>, ): LoginSearch => keptString("returnTo", input.returnTo); -/** - * What an SSO provider may put in the callback URL. - * - * Every field optional and none of them constrained, because which half arrives - * is the provider's decision - `code` and `state` when the visitor approved, - * `error` when they did not - and a schema demanding either would turn a - * legitimate denial into a router error. - */ export interface SsoCallbackSearch { code?: string; error?: string; state?: string; } -/** - * `/login/sso/$providerId`'s search schema. - * - * The values are bounded, classified and paired with the provider id by - * `parseSsoCallback`, which already `safeParse`s them and answers - * `invalid_callback` for anything it cannot use. This exists so that the route - * gets that far: without it, a provider whose `state` happens to be all digits - * fails `validateSearch` and the callback never runs at all. - */ export const normalizeSsoCallbackSearch = ( input: Record<string, unknown>, ): SsoCallbackSearch => ({ diff --git a/packages/vitnode/src/tanstack/auth/screens.test.ts b/packages/vitnode/src/tanstack/auth/screens.test.ts index ee57a7dcb..1256a5f2f 100644 --- a/packages/vitnode/src/tanstack/auth/screens.test.ts +++ b/packages/vitnode/src/tanstack/auth/screens.test.ts @@ -9,12 +9,6 @@ import { ssoStartFeedback, } from "./screens"; -/** - * The auth contract translated into the vocabulary `@vitnode/core`'s shared auth - * screens speak. Total functions over finite unions, so every outcome the API - * can produce is checked here rather than in a browser. - */ - describe("signInFormResult", () => { it("says nothing on success, which is how the shared form knows to stand down", () => { expect(signInFormResult({ ok: true })).toBeUndefined(); @@ -84,11 +78,6 @@ describe("anonymousSession", () => { expect(anonymousSession(session).user).toBeNull(); }); - /** - * Everything about the *installation* survives a sign-out. Building - * `{ ai: { models: [] }, user: null }` here instead would be a second, - * quietly diverging definition of the anonymous session. - */ it("keeps everything that describes the installation rather than the visitor", () => { expect(anonymousSession(session).ai).toEqual(session.ai); }); diff --git a/packages/vitnode/src/tanstack/auth/screens.ts b/packages/vitnode/src/tanstack/auth/screens.ts index b4d09c505..514738caf 100644 --- a/packages/vitnode/src/tanstack/auth/screens.ts +++ b/packages/vitnode/src/tanstack/auth/screens.ts @@ -15,36 +15,6 @@ import type { } from "./contract"; import type { SessionApi } from "./session-api"; -/** - * The auth contract, in the vocabulary the shared screens speak. - * - * ./contract @vitnode/core/views/auth - * { ok: false, reason } -> { message } | { failure } - * - * Two vocabularies exist on purpose and neither is wrong. The contract is a - * closed union over what the API can answer, with the distinctions the API makes - * kept (`invalid_state` is not `unknown_provider`); the screens speak the legacy - * shape they were extracted from, and are rendered unchanged by both frameworks. - * Something has to translate, and it happens once, at the call site. This is - * that call site, pulled out of the hooks so it is a set of - * total functions over finite unions - checkable exhaustively, with no server, - * no router and no React. - * - * Every collapse below loses information deliberately. A visitor cannot act on - * the difference between "the OAuth state expired" and "that provider is not - * configured": both mean start over, and the screen for both is the same. The - * distinction survives where it is useful - in a server log - because nothing - * here throws it away before `./server` has recorded it. - */ - -/** - * A sign-in attempt, as `SignInFormContent` reads it. - * - * `undefined` is success: the shared form treats "nothing to report" as "the - * caller is navigating", which is exactly what the sign-in action then does. - * `access_denied` becomes the alert above the fields; everything else becomes - * the internal-error toast. - */ export const signInFormResult = ( result: SignInResult, ): SignInMutationResult => { @@ -55,29 +25,9 @@ export const signInFormResult = ( : { message: "Internal Server Error" }; }; -/** - * Starting an SSO flow, as `SSOButtonsContent` reads it. - * - * A message means the row raises the internal-error toast. Success returns - * nothing *here* because the caller has a browser to send somewhere - the - * provider's authorization URL - and that is not a value the button row can do - * anything with. - * - * The reason travels as the message even though the row does not print it: the - * row renders one fixed sentence, so the string is only ever read in a - * devtools network panel, and `unknown_provider` there is worth having. - */ export const ssoStartFeedback = (result: SsoStartResult): SsoButtonFeedback => result.ok ? undefined : { message: result.reason }; -/** - * Finishing an SSO round trip, as `useSSOCallback` reads it. - * - * `email_exists` is the one outcome with a screen of its own - the provider's - * address already belongs to an account, and the visitor is offered the password - * login instead. Everything else is one `unknown`, which is the shared - * component's entire remaining vocabulary. - */ export const ssoCallbackResult = ( result: CompleteSsoResult, ): SSOCallbackResult => { @@ -88,50 +38,11 @@ export const ssoCallbackResult = ( : { failure: "unknown" }; }; -/** - * The session a signed-out visitor has. - * - * Written from the session already in hand rather than invented, so everything - * about the *installation* - which AI models are configured, and whatever else - * the session route grows - survives the sign-out, and only the visitor is - * removed. Building `{ ai: { models: [] }, user: null }` here instead would be a - * second, quietly diverging definition of the anonymous session. - * - * The point is the frame between "the API said it worked" and "the refetch came - * back": without this write that frame still renders the previous visitor's - * name. - */ export const anonymousSession = (session: SessionApi): SessionApi => ({ ...session, user: null, }); -/** - * A registration attempt, as `SignUpFormContent` reads it. - * - * The two vocabularies line up almost exactly, and where they do not it is - * because the contract knows more than the screen can use: - * - * ok, emailVerified: true undefined the caller is navigating - * ok, emailVerified: false { emailConfirmation } "check your email" - * email_exists { message } marks the email field - * name_exists { message } marks the username field - * conflict, invalid, { message: 'Internal the internal-error toast - * rate_limited, server_error Server Error' } - * - * The last row is where information is deliberately lost. `conflict` is a `409` - * whose field could not be identified, `invalid` is the API refusing the body or - * the captcha, and `rate_limited` is the limiter - and the registration form has - * one thing to say about all three, because a visitor cannot act on any of them - * differently. The distinctions survive where they are useful, in the server log - * `./server` writes. - * - * **`undefined` is only correct once the caller has actually navigated.** The - * shared form treats "nothing to report" as "we are leaving", so a caller that - * maps a verified sign-up to `undefined` and then does not move the router leaves - * a form that appears to have done nothing. `useSignUpAction` refreshes the - * session and navigates before it returns, which is what makes this row true. - */ export const signUpFormResult = ( result: SignUpResult, ): SignUpMutationResult => { @@ -147,33 +58,11 @@ export const signUpFormResult = ( return { message: "Internal Server Error" }; }; -/** - * A reset request, as `PasswordResetFormContent` reads it. - * - * `undefined` is "accepted", and it is what an existing address and a - * non-existent one both produce - the API answers the same `201` for either, and - * this mapping has no shape in which the difference could be expressed. That is - * the anti-enumeration property, preserved by having nothing to preserve it - * from. - * - * Every failure is the one toast: `invalid` (a captcha the API refused), - * `rate_limited` and `server_error` all mean "we did not manage to send it", and - * the form stays where it is so the visitor can try again. - */ export const passwordResetFormResult = ( result: PasswordResetRequestResult, ): PasswordResetMutationResult => result.ok ? undefined : { message: "Internal Server Error" }; -/** - * A password change, as `ChangePasswordFormContent` reads it. - * - * `invalid_token` is the one failure that survives as itself, because it is the - * one a visitor can act on: the link was wrong, already used, or older than - * thirty minutes, and the answer is to ask for a fresh one. The shared form - * renders it with the `400` copy rather than the generic internal-error copy for - * exactly that reason. - */ export const changePasswordFormResult = ( result: ChangePasswordResult, ): ChangePasswordMutationResult => { diff --git a/packages/vitnode/src/tanstack/auth/session-api.ts b/packages/vitnode/src/tanstack/auth/session-api.ts index d0395b0f6..aa70c73b7 100644 --- a/packages/vitnode/src/tanstack/auth/session-api.ts +++ b/packages/vitnode/src/tanstack/auth/session-api.ts @@ -1,18 +1,3 @@ import type { readSessionOnApi } from "./server"; -/** - * The signed-in visitor, or `{ user: null }` - the shape `GET /users/session` - * answers with, and the one every other module here describes state in terms - * of. - * - * Read off the server-side read rather than written out again. The shape is a - * Zod schema in `api/modules/users/routes/session.route.ts` and reaches here - * through the fetcher's inference, so a field added or renamed there arrives - * without anybody editing this file. A hand-maintained copy is a second source - * of truth that typechecks perfectly while disagreeing with the server. - * - * The import is type-only, which is what makes the indirection safe: TypeScript - * erases it, so a browser bundle that reads {@link SessionApi} does not reach - * `./server`, its `server-only` marker, or the request scope behind it. - */ export type SessionApi = Awaited<ReturnType<typeof readSessionOnApi>>; diff --git a/packages/vitnode/src/tanstack/auth/session-query.test.ts b/packages/vitnode/src/tanstack/auth/session-query.test.ts index 6be7e9123..9f9e1373e 100644 --- a/packages/vitnode/src/tanstack/auth/session-query.test.ts +++ b/packages/vitnode/src/tanstack/auth/session-query.test.ts @@ -11,38 +11,10 @@ import { import { SESSION_QUERY_KEY } from "./state"; import { setAuthTransport } from "./transport"; -/** - * The canonical session query's policy, and the one property of it that a route - * guard's correctness rests on. - * - * No render, no request and no DOM: `sessionQueryOptions()` is an object, and - * everything below drives a `QueryClient` held in memory with the transport - * stubbed. What is being exercised is this app's *reading* rules - which call - * consults an invalidation, and which does not - because those are the rules - * whose being wrong is silent. - */ - -/** - * What the stubbed session read does next, and how often it was asked. - * - * A rejection is a *value* rather than a spy reconfigured per test: one flag is - * less machinery, and it keeps the transport below a plain object. - */ let nextSession: SessionApi = { user: null } as SessionApi; let nextFailure: Error | null = null; let reads = 0; -/** - * The application's half, stubbed - and stubbed through the real seam. - * - * `setAuthTransport` is exactly how `apps/web` supplies its `createServerFn`, - * so nothing here is mocked away: the query definition under test resolves its - * reader the same way it does in production, and a change to how it reaches the - * transport would fail these tests rather than slip past a module mock. - * - * Only `readSession` is real work; the seven mutations are unreachable from - * anything below. - */ const unreachable = () => { throw new Error("the session query calls no mutation"); }; @@ -85,22 +57,6 @@ describe("the canonical session query", () => { }); }); -/** - * What a guard sees after a sign-in, which is the whole of this suite. - * - * The bug these pin is not hypothetical - it was live until Stage 9's review. - * `ensureAuthState` read through `ensureQueryData`, which returns cached data - * the moment any exists and consults neither staleness nor invalidation: - * - * if (cachedData !== undefined) return Promise.resolve(cachedData) - * - * so `invalidateSession()` did not, on its own, make the next guard re-read. It - * worked only because `invalidateQueries` ends in - * `refetchQueries({ type: 'active' })` and `RealtimeListeners` happens to mount - * an observer of that entry at the root - a component that exists for the - * WebSocket's sake. Every one of these tests runs with **no observers at all**, - * which is what makes them a test of the guard rather than of that accident. - */ describe("a guard reads the session again once it has been invalidated", () => { it("reads once when nothing is cached", async () => { const queryClient = new QueryClient(); diff --git a/packages/vitnode/src/tanstack/auth/session-query.ts b/packages/vitnode/src/tanstack/auth/session-query.ts index 30ff846d4..e9be5439f 100644 --- a/packages/vitnode/src/tanstack/auth/session-query.ts +++ b/packages/vitnode/src/tanstack/auth/session-query.ts @@ -8,81 +8,8 @@ import type { AuthState } from "./state"; import { authStateFromSession, SESSION_QUERY_KEY } from "./state"; import { authTransport } from "./transport"; -/** - * The session, owned by the QueryClient the application already owns. - * - * There is no new client and no provider here, and that is the design: the host - * puts one `QueryClient` in its router context, created once per server request - * and once in the browser (`apps/web/src/router.tsx`). That lifetime is exactly what a - * session needs - per request on the server, so one visitor's session can never - * be rendered into another's page, and long-lived on the client, so navigating - * does not re-ask. A module-level `let session` would get the first half - * catastrophically wrong. - * - * Which also means the SSR dehydration carries the visitor's own session into - * the visitor's own HTML. Correct, and worth stating: that document is - * personalised and must not be served from a shared cache. - */ - -/** - * How long the router may trust a cached session before asking again. - * - * Not zero, because of preloading. The router runs with - * `defaultPreload: 'intent'` and `defaultPreloadStaleTime: 0`, so hovering a - * link runs that route's `beforeLoad`; with no stale window, every hovered link - * costs a round trip for an answer that has not changed. - * - * Not `Infinity` either. A session can end somewhere this tab will never hear - * about - the cookie expires, an admin revokes it, the visitor signs out in - * another tab - and with no expiry the UI would believe in it until a reload. - * Being wrong for half a minute costs a stale header, or one navigation into a - * page whose data the API then refuses; it cannot cost private data, because the - * API is the boundary and it re-reads the cookie every time. - * - * Sign-in and sign-out do not wait this out. Sign-out replaces the value - * outright ({@link setSessionData}); the rest mark the entry invalidated, which - * {@link ensureAuthState}'s read treats as stale whatever the clock says. So - * this window governs only the passage of time, never a mutation this app - * performed. - */ const SESSION_STALE_TIME = 30_000; -/** - * The visitor's session, as VitNode's one query definition. - * - * Every caller goes through this - a `beforeLoad` guard, a loader warming the - * cache, a component reading it back - so all of them share one cache entry and - * one fetch. Two definitions of the same read would be two entries, and the one - * a guard filled would not be the one the header renders from. - * - * `readSession` is the application's `createServerFn` - the one primitive this - * package may not declare, which is why it arrives through `./transport` - so - * the fetch happens on the server both times: directly during SSR, and over - * same-origin RPC on client navigation, which is what carries the visitor's - * cookies to a place that may read them. The browser never talks to the session - * endpoint itself. - * - * ## It asks once - * - * `retry: false`, which is a deliberate departure from Query's default of three - * attempts with backoff. This read is a route guard, not background content: it - * runs inside `beforeLoad`, and a navigation is blocked for as long as it takes. - * - * Retrying makes every failure worse in the same way. A `429` from the rate - * limiter is answered by sending the same request two more times, which is the - * thing the limiter is asking the app to stop doing; a `500` turns one round - * trip into three before the route can show anything, so a navigation appears to - * hang rather than to fail. Neither retry can succeed at anything the first one - * could not - the session is whatever the cookie says, and asking again does not - * change it. - * - * So one attempt, and a failure surfaces immediately as a query error, which the - * route's error path already handles. The visitor retries by reloading or - * navigating again, which is a decision they can make and a rate limiter can see - * coming. This is emphatically *not* a return to reading a failure as - * `user: null` - see `readSessionOnApi` in `./server`, which rejects rather - * than inventing a guest. - */ export const sessionQueryOptions = () => queryOptions({ queryFn: async () => await authTransport().readSession(), @@ -91,88 +18,11 @@ export const sessionQueryOptions = () => staleTime: SESSION_STALE_TIME, }); -/** - * The auth state, reading the session first if what is cached cannot be trusted. - * - * What a route's `beforeLoad` calls, and the only function it needs. It is safe - * to call on a preload: this is a read that fills a cache entry - it cannot - * create or end a session, and the API call behind it is a `GET` whose - * `Set-Cookie` this layer deliberately does not save (`saveApiCookies` is for - * responses to sign-in, not to a session read). Two routes guarding themselves - * during one navigation share the single in-flight request, because - * `query.fetch()` returns the promise already in flight rather than starting a - * second one. - * - * Returns the decision material and leaves the decision to the caller. No - * `redirect()` here on purpose: where a blocked visitor is sent is a property of - * the route that blocked them, so it belongs in the route tree - which is also - * the only layer that should be importing the router. - * - * ## Why `fetchQuery` and not `ensureQueryData` - * - * They differ in exactly one case, and it is the case a guard exists for. - * `ensureQueryData` returns whatever is cached the moment anything is cached: - * - * if (cachedData !== undefined) return Promise.resolve(cachedData) - * - * - no staleness check, and **no check of whether the entry was invalidated**. - * So a guard reading through it could not see a sign-in that had just happened. - * {@link invalidateSession} marks the entry, `ensureQueryData` ignores the mark, - * and the guard decides on the previous visitor. - * - * That was survivable only by accident. `invalidateQueries` ends in - * `refetchQueries({ type: 'active' })`, so the refetch that actually kept this - * correct was the one performed for the session observer `RealtimeListeners` - * mounts at the root - a component mounted for the WebSocket's sake, whose - * removal or relocation into the shell would have silently turned every - * post-sign-in navigation into a bounce back to the login page. A guard must not - * depend on an unrelated component being mounted. - * - * `fetchQuery` asks the query itself, through `isStaleByTime`: - * - * invalidated -> read again (a sign-in just happened) - * older than SESSION_STALE_TIME -> read again (the window has passed) - * otherwise -> the cached value, no round trip - * - * which is precisely what {@link SESSION_STALE_TIME} is documented to buy, and - * the preload behaviour that motivates it is unchanged: hovering a guarded link - * inside the window still costs nothing. - * - * ## It resolves only when the session is actually known - * - * An {@link AuthState} is returned when - and only when - the session query - * succeeded. `readSession` rejects if the session could not be read at all (a - * 429, a 500, an unreachable API), so `fetchQuery` rejects and so does this. - * That is deliberate and it is the whole point of the contract: - * `authStateFromSession` describes two *known* states, and there is no third - * value for "we could not find out". - * - * `fetchQuery` rather than `prefetchQuery` matters here too - the latter is the - * same read with `.catch(noop)` on the end, which would turn an outage into a - * silently stale answer. See {@link prefetchSession}, which wants exactly that - * and is the only caller allowed to. - * - * A caller must therefore not treat a rejection as "signed out". Only - * `auth.isAuthenticated === false` means that. A rejection propagating out of a - * `beforeLoad` is an ordinary route error and takes the router's normal error - * path, which is what leaves a signed-in visitor on the page they asked for - * instead of bouncing them to the login form during an outage. - */ export const ensureAuthState = async ( queryClient: QueryClient, ): Promise<AuthState> => authStateFromSession(await queryClient.fetchQuery(sessionQueryOptions())); -/** - * Replace the cached session with one the server just answered with. - * - * For sign-in and sign-out, which learn the new session as part of their own - * response: writing it here means the next render is already right, with no - * round trip in between and no frame showing the previous visitor. - * - * The key comes from `sessionQueryOptions()` rather than being spelled out, so - * TanStack Query checks the value against what the query is declared to return. - */ export const setSessionData = ( queryClient: QueryClient, session: SessionApi, @@ -180,60 +30,11 @@ export const setSessionData = ( queryClient.setQueryData(sessionQueryOptions().queryKey, session); }; -/** - * Mark the cached session stale, so the next reader fetches the truth. - * - * The other half of the pair above, for the cases where the client cannot know - * the new session: a sign-in, a verified sign-up, an SSO callback, a sign-out - * whose response only says it worked. Invalidating rather than clearing keeps - * the current answer on screen while the fresh one is fetched, instead of - * blanking every component that reads the session. - * - * ## What "the next reader" means, exactly - * - * Two different things, and both have to hold or a sign-in navigates as the - * previous visitor: - * - * - **A guard.** {@link ensureAuthState} goes through `fetchQuery`, which asks - * `isStaleByTime` - and an invalidated entry is stale by definition. So the - * mark is what the guard acts on, with no observer involved. That is the half - * this used to get wrong; see the long note there. - * - **A component.** `invalidateQueries` also ends in - * `refetchQueries({ type: 'active' })`, so anything currently observing the - * entry - the header, the WebSocket identity sync - refetches. Awaiting this - * call is therefore how a caller knows the header has caught up too, which is - * why every auth action awaits it before it navigates. - * - * The first is a correctness property and the second is a rendering one. They - * are easy to conflate because until Stage 9 only the second was actually - * running. - */ export const invalidateSession = async ( queryClient: QueryClient, ): Promise<void> => await queryClient.invalidateQueries({ queryKey: SESSION_QUERY_KEY }); -/** - * Fill the session entry without letting a failed read take the page down. - * - * What a layout loader calls so the header renders the visitor on the *first* - * paint. `ensureAuthState` is the wrong tool for that job in one specific way: - * it rejects when the session cannot be read, which is exactly right for a guard - * - an outage must not sign anybody out - and exactly wrong for a shell, where - * the same rejection would replace every page on the site with an error screen - * because the header could not name the visitor. - * - * `prefetchQuery` is the difference: same query definition, same key, same single - * in-flight request, and a failure is recorded in the cache entry instead of - * thrown. So the shell renders, the SSR pass dehydrates whatever was learned, and - * the header reads it back through `useQuery` - `data` when the read worked, - * `isError` when it did not, and `userHeaderState` decides what that looks like. - * - * Deliberately not a second query. Anything that guards a route still goes - * through {@link ensureAuthState}, and both reach the one entry this module owns - * - so a page under `_authenticated` and the header above it cannot disagree - * about who is signed in. - */ export const prefetchSession = async ( queryClient: QueryClient, ): Promise<void> => { diff --git a/packages/vitnode/src/tanstack/auth/sign-up-session.ts b/packages/vitnode/src/tanstack/auth/sign-up-session.ts index 6e15a5b4f..7749980f7 100644 --- a/packages/vitnode/src/tanstack/auth/sign-up-session.ts +++ b/packages/vitnode/src/tanstack/auth/sign-up-session.ts @@ -1,41 +1,5 @@ import type { SignUpResult } from "./contract"; -/** - * Whether registration produced a session the canonical session query has to go - * and read. - * - * The one decision that connects sign-up to the rest of the app, written as a - * function so it is stated once and tested without a browser. - * - * `true` only for a successful sign-up with `emailVerified`. That is precisely - * when the API called `createSessionByUserId` on the same request, which means - * the `201` carried a `Set-Cookie`, which means `saveApiCookies` put it on the - * response the browser is reading - so the *next* read of `/users/session` - * answers with the new visitor and the cached one is stale. - * - * `false` for an unverified account, and that matters more than it looks: - * inventing a refresh there would replace a known-anonymous session with another - * known-anonymous session and, worse, invite a caller to navigate as though the - * visitor were signed in. They are not - the account is waiting on a - * confirmation link the API does not send yet (`// TODO: Send verification - * email`), and the screen for that is the confirmation view. - * - * There is deliberately no equivalent for the two recovery mutations: neither - * mints a session, so neither has a session to refresh. - * - * ## Why it is not in `./contract` beside the union it reads - * - * Because `./contract` imports `zod`, and this is the only value `./actions` - * takes from it - everything else it needs from there is a type, which is - * erased. `./actions` is on the *public shell's* path: the header's user menu - * signs a visitor out, so a page with a header reaches this module, and through - * it reached every mutation input schema the auth contract declares. Measured on - * vitnode.com's front page, that edge alone put `zod` in the initial graph. - * - * The type import above costs nothing - `verbatimModuleSyntax` erases it before - * a bundler sees it - and `./contract` re-exports this function, so nothing that - * imported it from there has to move. - */ export const shouldRefreshSessionAfterSignUp = ( result: SignUpResult, ): boolean => result.ok && result.emailVerified; diff --git a/packages/vitnode/src/tanstack/auth/sso-route.tsx b/packages/vitnode/src/tanstack/auth/sso-route.tsx index 66b9a7d73..f5be3d868 100644 --- a/packages/vitnode/src/tanstack/auth/sso-route.tsx +++ b/packages/vitnode/src/tanstack/auth/sso-route.tsx @@ -9,18 +9,6 @@ export const SSO_CALLBACK_NAMESPACES = [ "core.auth.sso", ] as const; -/** - * The provider names, and the strings the screens render. - * - * The provider list is what turns `google` in the URL into "Google" on the - * conflict screen. It is the same cache entry the login page warmed, so arriving - * here from a client-side navigation costs nothing. - * - * No session read and no guard: by the time a provider redirects back, the API - * has already minted its `--state-sso` cookie and the visitor may well have been - * signed in by a parallel tab. An unfinished flow is finished here, whoever is - * asking. - */ export const loadSsoCallbackRoute = async ({ locale, queryClient, diff --git a/packages/vitnode/src/tanstack/auth/sso-screen.tsx b/packages/vitnode/src/tanstack/auth/sso-screen.tsx index 773fd425b..aa41e1476 100644 --- a/packages/vitnode/src/tanstack/auth/sso-screen.tsx +++ b/packages/vitnode/src/tanstack/auth/sso-screen.tsx @@ -26,14 +26,6 @@ export interface SsoCallbackRouteProps { search: { code?: string; error?: string; state?: string }; } -/** - * The SSO callback, as everything below a route file's `component`. - * - * The exchange itself is unchanged and stays on the server: the API verifies - * `state` against the cookie it minted, deletes it, trades the `code` with the - * provider and mints the session. Nothing here re-implements or re-checks any of - * that. - */ export const SsoCallbackRouteContent = ({ errorActions, LinkComponent, @@ -43,26 +35,9 @@ export const SsoCallbackRouteContent = ({ const router = useRouter(); const { data: config } = useSuspenseQuery(middlewareConfigQueryOptions()); - /** - * The callback URL, judged before any of it is sent on: the provider id has to - * be a slug, `code` and `state` have to be present and bounded, and an `error` - * is classified rather than carried. A malformed callback never reaches the - * API. - */ const parsed = parseSsoCallback({ providerId, query: search }); const completeSso = useCompleteSsoAction(parsed.ok ? parsed.params : null); - /** - * The exchange, run once, by the shared hook both frameworks use. - * - * `oauthError` is the raw `error` parameter, which is what the hook's own rule - * is written against: `access_denied` disables the query outright - there is - * nothing to exchange when the visitor said no - and anything else lets it run - * and fail, which is the screen a provider error should produce anyway. The - * exchange itself refuses to call the API unless `parseSsoCallback` produced - * parameters, so neither a malformed callback nor a provider error costs a - * request. - */ const state = useSSOCallback({ code: parsed.ok ? parsed.params.code : "", oauthError: search.error, diff --git a/packages/vitnode/src/tanstack/auth/state.test.ts b/packages/vitnode/src/tanstack/auth/state.test.ts index 0b44ad67f..86866b5a6 100644 --- a/packages/vitnode/src/tanstack/auth/state.test.ts +++ b/packages/vitnode/src/tanstack/auth/state.test.ts @@ -11,34 +11,6 @@ import { SESSION_QUERY_KEY, } from "./state"; -/** - * The Stage 6 auth contract: - * - * session (API) -> authStateFromSession -> route context -> guards - * - * Only the pure half is exercised here, which is also the only half worth - * testing: the transport is one `createServerFn` around a `GET`, and the - * authorization that actually matters lives in Hono, on the server, behind the - * session cookie. What can silently go wrong on this side is the *derivation* - - * a guest read as signed in, an unimplemented role read as a permission, a query - * key that quietly varies per language - and all three are decided by the - * functions below. - * - * `SessionApi` is imported as a type only, so nothing here loads the server - * function or the fetcher it reaches for. - */ - -/** - * A visitor who is genuinely nobody: the API answered `200`, and there is no - * user in the answer. - * - * That is now the *only* thing `user: null` can mean. `getSession` used to - * synthesize this exact object for any non-200 - a 429, a 500, an unreachable - * API - which made an outage indistinguishable from a sign-out and bounced - * signed-in visitors to the login page. It rejects instead, so a failed read - * cannot reach `authStateFromSession` at all; `isUsableSessionStatus` in - * `auth-contract.test.ts` pins the rule that decides it. - */ const anonymousSession: SessionApi = { ai: { models: [] }, user: null }; /** A signed-in visitor, exactly as `users/session.route.ts` describes one. */ @@ -103,12 +75,6 @@ describe("a session becomes an auth state", () => { expect(auth.isAdmin).toBe(true); }); - /** - * The API answers `isModerator: false` unconditionally - it is a `TODO`, not a - * role. So the auth state must not carry a moderator flag at all: one would - * read as authorization while being a constant, and would start granting - * access on its own the day the API begins computing it. - */ it("does not promote a moderator to anything", () => { const auth = authStateFromSession( sessionFor(userFixture({ isModerator: true })), @@ -118,11 +84,6 @@ describe("a session becomes an auth state", () => { expect("isModerator" in auth).toBe(false); }); - /** - * `beforeLoad` also runs on preload, on hover, and again on the navigation - * itself. The derivation therefore has to be a function of its argument and - * nothing else - no clock, no counter, no cache of its own. - */ it("answers the same session identically every time", () => { const session = sessionFor(userFixture({ isAdmin: true })); @@ -174,18 +135,6 @@ describe("the session cache key", () => { expect(SESSION_QUERY_KEY).toEqual(["vitnode", "session"]); }); - /** - * The session is *who* the visitor is, which does not change because they read - * the page in Polish. A locale in the key would give one visitor two sessions - * invalidated separately, so signing out on `/pl` would leave `/` still - * rendering a signed-in header. - * - * Asserted as "two segments and no third" rather than as "none of these - * segments is a language", which is the same rule stated in a form that does - * not need a list of the installation's locales - and that also catches a - * third segment carrying a user id or a route, which would be just as wrong - * for the same reason: the next caller could not reconstruct the key. - */ it("carries no locale, and nothing else either", () => { const key: readonly string[] = SESSION_QUERY_KEY; diff --git a/packages/vitnode/src/tanstack/auth/state.ts b/packages/vitnode/src/tanstack/auth/state.ts index cde3578d6..61cf6b300 100644 --- a/packages/vitnode/src/tanstack/auth/state.ts +++ b/packages/vitnode/src/tanstack/auth/state.ts @@ -1,72 +1,7 @@ import type { SessionApi } from "./session-api"; -/** - * Who is asking, as route state - and nothing else. - * - * Pure by construction: a type-only import of `SessionApi`, which TypeScript - * erases, so this module has no runtime dependencies at all. That is what lets - * the same rules run in the four places that cannot import each other's - * runtimes - a `beforeLoad` on the server, the same `beforeLoad` in the browser, - * a component reading the router context, and the tests - and it is why the - * query key is defined here rather than next to the query that uses it. - * - * ## What this layer is, and what it is not - * - * TanStack beforeLoad -> navigation and UI guard - * Hono authorization -> the security boundary - * - * Everything here is derived from a response the browser can read and, after - * hydration, from a cache the browser owns. It decides what to *render* and - * where to *navigate*. It decides nothing about what data anybody may read: - * every private read is authorized on the server by Hono, from the session - * cookie, in the route's own handler and middleware. If this state were ever - * treated as authoritative for API access, editing a cache entry in devtools - * would be a privilege escalation - which is why no VitNode endpoint asks the - * client who it is. - * - * ## Moderators are deliberately absent - * - * `session.user.isModerator` exists in the API's response and is hardcoded - * `false` (`users/routes/session.route.ts`: `// TODO: implement moderator - * role`). So there is no moderator authorization to model yet, and this state - * exposes no `isModerator` flag: a guard written against one would read as - * enforcement while being a constant, and would silently start granting access - * the day the API begins answering `true`. - * - * Nothing renders it either. The user menu used to draw a - * `/mod_cp` link behind that flag, pointing at a page neither application - * serves; `userHeaderMenu` in - * `views/layouts/theme/header/user/user-header-model.ts` branches on `isAdmin` - * alone. The field stays reachable as `auth.user.isModerator` for whoever - * implements the role. - */ - -/** - * The signed-in visitor, as the API describes them. - * - * Derived from `SessionApi`, never written out again. The shape is a Zod schema - * in `api/modules/users/routes/session.route.ts` and reaches here through the - * fetcher's inference, so a field added or renamed there arrives without anybody - * editing this file. A hand-maintained copy is a second source of truth that - * typechecks perfectly while disagreeing with the server. - */ export type AuthUser = NonNullable<SessionApi["user"]>; -/** - * The auth state a route guard reads. - * - * A union rather than one object with four independent fields, so the states - * that cannot happen cannot be written: there is no guest holding - * `isAdmin: true`, and `if (auth.isAuthenticated)` narrows `auth.user` to - * non-null for everything inside the branch. That narrowing is most of the - * reason this type exists instead of routes poking at `session.user?.isAdmin` - * themselves. - * - * `session` is carried along because a page needs more of it than its guard - * does - the header renders the user, `ai.models` decides whether the assistant - * appears - and re-reading the cache to get it back would be a second answer - * that can disagree with the first. - */ export type AuthState = | { isAdmin: boolean; @@ -81,55 +16,8 @@ export type AuthState = user: null; }; -/** - * The one cache entry a visitor's session lives in. - * - * Two segments and no third. In particular **no locale**: the session is who the - * visitor is, which does not change because they read the page in Polish, and a - * locale in the key would mean one visitor holding two sessions that are - * invalidated separately - so a sign-out on `/pl` would leave `/` still showing - * a signed-in header. Contrast `intlQueryPrefix` in `lib/i18n/query.ts`, where - * the locale belongs in the key because the *value* differs per language. - * - * Nothing else may be added either. A key that varies by route or by user id is - * a key the next caller cannot reconstruct, and the whole point of a single - * entry is that sign-in and sign-out know exactly what to replace. - */ export const SESSION_QUERY_KEY = ["vitnode", "session"] as const; -/** - * The session, as the auth state a guard reads. - * - * Total and pure: one argument in, one object out. No I/O, no router, no clock, - * no `window`. `beforeLoad` runs on hover under `defaultPreload: 'intent'`, so - * anything with a side effect here would be a side effect nobody asked for - - * this function cannot create a session, end one, or redirect, because it cannot - * do anything at all. - * - * `user === null` is the only test for "signed out", and it means exactly one - * thing: **the API answered, and nobody is signed in.** No cookie and an expired - * session both arrive that way, because both are a successful read of "there is - * no session here". - * - * ## A failed read never reaches this function - * - * It used to. The session read once returned `{ ai: { models: [] }, user: null }` - * for every non-200, so a `429` from the rate limiter, a `500` or an unreachable - * API arrived here indistinguishable from a guest - and `canAccessAuthenticatedRoute` - * dutifully signed a signed-in visitor out of a page they were entitled to. - * - * That normalisation is gone deliberately. `readSessionOnApi` in `./server` now - * *rejects* when the session could not be read, which propagates through - * `ensureAuthState` and out of the guard's `beforeLoad` as an ordinary route - * error - so the visitor stays where they are and sees a failure, rather than - * being told they are anonymous. There is no third {@link AuthState} for "we - * could not find out", and there must not be one: the two states below are both - * answers, and an outage is not an answer. - * - * So this function is total over what it can actually receive - every - * `SessionApi` value is a session the API returned - and a caller must never - * read a rejection as a guest. - */ export const authStateFromSession = (session: SessionApi): AuthState => { const { user } = session; @@ -140,53 +28,13 @@ export const authStateFromSession = (session: SessionApi): AuthState => { return { isAdmin: user.isAdmin, isAuthenticated: true, session, user }; }; -/** - * The half of {@link AuthState} that has a visitor in it. - * - * Named so a guard can hand it downwards as route context: everything under - * `_authenticated` reads `context.auth.user` without a null check, because the - * boundary already made it. - */ export type AuthenticatedState = Extract<AuthState, { isAuthenticated: true }>; -/** - * A page only a signed-in visitor may see - `/settings`, `/files`. - * - * A named predicate rather than `auth.isAuthenticated` at each call site: what a - * route declares is the *kind* of page it is, and what that requires is decided - * once, here. The Next.js app spells the same rule as - * `if (!session.user) notFound()` inside `LayoutSettings`. - * - * Written as a type predicate so the narrowing survives the call. A `boolean` - * would leave `auth.user` nullable on the *other* side of the guard, and every - * protected page would re-check something the boundary already proved - or, more - * likely, assert it away. - */ export const canAccessAuthenticatedRoute = ( auth: AuthState, ): auth is AuthenticatedState => auth.isAuthenticated; -/** - * A page that only makes sense signed *out* - `/login`, `/register`. - * - * The inverse of the rule above and not an independent one, so the two cannot - * drift into disagreeing about what "signed in" means. - */ export const canAccessGuestRoute = (auth: AuthState): boolean => !auth.isAuthenticated; -/** - * A route that is only offered to a visitor with admin permissions. - * - * `isAdmin` is computed server-side per request by `SessionAdminModel` - * `.checkIfUserIsAdmin`, against the staff permission tables, and is - * deliberately re-checked rather than cached - removing someone takes effect on - * their next request. - * - * Note what it does *not* mean. The AdminCP runs on a **second session**, its - * own cookie and its own sign-in (`getSessionAdminApi`, `SessionAdminModel`), so - * `isAdmin` says "this visitor may be offered the AdminCP", not "this visitor is - * inside it". Entry to the AdminCP is gated by that separate session on the - * server, and this predicate is not a substitute for it. - */ export const canAccessAdminRoute = (auth: AuthState): boolean => auth.isAdmin; diff --git a/packages/vitnode/src/tanstack/auth/transport.test.ts b/packages/vitnode/src/tanstack/auth/transport.test.ts index 9639bbc96..2821ef5ab 100644 --- a/packages/vitnode/src/tanstack/auth/transport.test.ts +++ b/packages/vitnode/src/tanstack/auth/transport.test.ts @@ -7,15 +7,6 @@ import { setAuthTransport, } from "./transport"; -/** - * The one seam between this package and the application that mounts it. - * - * Deliberately a whole test file to itself, because what is being asserted is - * what happens *before* anybody registers - and a file that registers a - * transport can never observe that again. Vitest isolates modules per file, so - * this is the only place the unregistered state exists. - */ - /** A transport whose calls would fail loudly if anything below reached one. */ const unreachable = () => { throw new Error("no call is made in this suite"); @@ -37,13 +28,6 @@ describe("before an application registers its server functions", () => { expect(hasAuthTransport()).toBe(false); }); - /** - * The failure mode this replaces is the one worth being loud about: a session - * read that resolves to `undefined` reads as "no session", which every guard - * downstream would answer by signing the visitor out. A thrown error takes the - * router's ordinary error path instead, which is what a failed session read - * has always done here. - */ it("says what is missing rather than answering undefined", () => { expect(() => authTransport()).toThrow(AUTH_TRANSPORT_MISSING); }); diff --git a/packages/vitnode/src/tanstack/auth/transport.ts b/packages/vitnode/src/tanstack/auth/transport.ts index 5ba9ac176..0460e5bbf 100644 --- a/packages/vitnode/src/tanstack/auth/transport.ts +++ b/packages/vitnode/src/tanstack/auth/transport.ts @@ -16,53 +16,12 @@ import type { } from "./contract"; import type { SessionApi } from "./session-api"; -/** - * How this package reaches the API, handed to it by the application. - * - * Eight calls, and every one of them is `createServerFn` in the host. That is - * not a preference, it is the one thing a package cannot own: the host - * externalises `@vitnode/core` from Vite's SSR pass, so this code reaches the - * server *uncompiled* - and an uncompiled `createServerFn` hands its - * `.handler()` one argument where the compiler passes two, which makes an - * SSR-side call resolve to `undefined` with no error at all. It would work in - * the browser, over `/_serverFn/*`, and silently answer nothing during a render. - * See `packages/vitnode/src/tanstack/boundary.test.ts`, which forbids the - * primitive here outright. - * - * So the *declaration* stays in `apps/web`, where the Start compiler sees it on - * both sides, and everything the declaration would otherwise contain - the - * validation, the Hono call, the cookie copying, the status mapping - lives in - * `./server`. What crosses the boundary is this interface: eight functions from - * a validated input to one of the closed results in `./contract`. - * - * apps/web @vitnode/core/tanstack/auth - * ------------------------------------------------------------------ - * createServerFn ./server call Hono, copy cookies, map status - * .validator(schema) ./contract the schema, and the result union - * .handler(fn) ./actions what to do with the answer - * - * ## This is a transport, not an auth store - * - * Nothing here holds a session, a user, or any state a component could read. - * The canonical session lives in exactly one place - the `["vitnode", - * "session"]` entry in the host's `QueryClient` - and `./session-query` is the - * only definition of it. This registry holds function references, which are the - * same for every visitor and every request, which is why a module-level value is - * safe on a server that renders many visitors at once. - */ export interface AuthTransport { changePasswordFromReset: ( input: ChangePasswordInput, ) => Promise<ChangePasswordResult>; completeSso: (input: SsoCallbackInput) => Promise<CompleteSsoResult>; - /** - * The visitor's session, or a rejection. - * - * `{ user: null }` means the API answered and nobody is signed in; a read that - * could not be evaluated at all - a 429, a 500, an unreachable API - must - * *reject* rather than resolving to a guest. `readSessionOnApi` in `./server` - * is the implementation the host wraps, and it already draws that line. - */ + readSession: () => Promise<SessionApi>; requestPasswordReset: ( input: PasswordResetRequestInput, @@ -75,40 +34,13 @@ export interface AuthTransport { let registered: AuthTransport | undefined; -/** - * The message a caller gets when the application forgot to register. - * - * A named constant so the host's own test can assert on it without matching - * English, and so the sentence says what to do rather than what went wrong. - */ export const AUTH_TRANSPORT_MISSING = "No auth transport is registered. Call setAuthTransport() from a module the application always loads - the router entry - before any auth route runs."; -/** - * Register the application's server functions, once, at module scope. - * - * Called from a module both bundles load (`apps/web/src/router.tsx` imports the - * host's `lib/auth.ts` for exactly this), so the registry is filled before any - * route, loader or component can reach for it. Registering twice replaces the - * previous value rather than throwing: a hot reload re-evaluates the module, and - * a build error is a worse answer than the newer function. - * - * Module scope means *per bundle*. The browser has one instance and the server - * has one instance, and each registers its own - which is the same lifetime the - * server functions themselves have. - */ export const setAuthTransport = (transport: AuthTransport): void => { registered = transport; }; -/** - * The registered transport, or a failure that says what is missing. - * - * Read at call time rather than captured at module scope, so a module that - * merely *imports* an action does not have to be loaded after the registration - - * only the call has to happen after it, which is trivially true for anything a - * route can reach. - */ export const authTransport = (): AuthTransport => { if (!registered) throw new Error(AUTH_TRANSPORT_MISSING); diff --git a/packages/vitnode/src/tanstack/breadcrumb/index.ts b/packages/vitnode/src/tanstack/breadcrumb/index.ts index 594958441..880f1d735 100644 --- a/packages/vitnode/src/tanstack/breadcrumb/index.ts +++ b/packages/vitnode/src/tanstack/breadcrumb/index.ts @@ -1,12 +1,3 @@ -/** - * The shell's breadcrumb, for a TanStack Router application. - * - * Two halves that are deliberately separate: a rule over matched routes - * (`breadcrumbTrail`, testable with no router at all) and the component that - * renders it (`MainBreadcrumb`). Importing this barrel is also what loads the - * `staticData.breadcrumb` augmentation - see `./model`. - */ - export { MainBreadcrumb } from "./main-breadcrumb"; export type { BreadcrumbMatch, diff --git a/packages/vitnode/src/tanstack/breadcrumb/main-breadcrumb.tsx b/packages/vitnode/src/tanstack/breadcrumb/main-breadcrumb.tsx index 322bd5685..d927f1175 100644 --- a/packages/vitnode/src/tanstack/breadcrumb/main-breadcrumb.tsx +++ b/packages/vitnode/src/tanstack/breadcrumb/main-breadcrumb.tsx @@ -9,36 +9,6 @@ import { useRouteNavigationPending } from "../pending/navigation-pending"; import { BreadcrumbPendingSkeleton } from "../pending/shapes"; import { breadcrumbTrail } from "./model"; -/** - * The public site's breadcrumb area: every matched route that declared a crumb, - * parent to child, and nothing at all when none did. - * - * `useMatches()` rather than a `select`: the whole match list changes on - * navigation, which is exactly when this has to re-render, and the router's - * structural sharing has nothing useful to say about a React element. - * - * The container is here rather than in each crumb, which is the point of a trail: - * a route contributes a label, and the shell owns the spacing, the separators, - * the navigation landmark and the locale-aware links (`RouterLink` writes the - * locale prefix back into every href the router builds). - * - * Takes nothing. Every VitNode shell renders exactly this in the `breadcrumb` - * slot of `ThemeLayoutContent`, and the only thing that varies between - * applications is which crumbs their routes declare - which is the routes' - * business, not this component's. - * - * ## Why it holds a shape rather than the destination's trail - * - * A crumb may read the data its route is still fetching - a product's name, a - * translated label whose messages are in flight - so the destination's trail - * cannot simply be drawn early. The area holds a shape for as long as the content - * below it does, on the router's own `defaultPendingMs`, and the two change - * together. - * - * Only when there is a trail to replace. A shell whose current page declares no - * crumb shows nothing, and inventing a skeleton there would put a separator and - * two bars into a header that has neither before the navigation nor after it. - */ export const MainBreadcrumb = () => { const entries = breadcrumbTrail(useMatches()); const isNavigating = useRouteNavigationPending( diff --git a/packages/vitnode/src/tanstack/breadcrumb/model.test.ts b/packages/vitnode/src/tanstack/breadcrumb/model.test.ts index ada180644..71b011f68 100644 --- a/packages/vitnode/src/tanstack/breadcrumb/model.test.ts +++ b/packages/vitnode/src/tanstack/breadcrumb/model.test.ts @@ -5,24 +5,6 @@ import type { BreadcrumbMatch, RouteBreadcrumbProps } from "./model"; import { breadcrumbGroup, breadcrumbTrail } from "./model"; -/** - * The rule that replaces Next.js' `@breadcrumb` parallel route: every matched - * route that declares a crumb contributes one, parent to child. - * - * Tested over plain objects rather than through a router, because that is what - * the rule is - a fold over the match list. What each shape *means* is the whole - * of the behaviour: a component is instantiated with its own match's data, an - * element is taken as it is, and `false`, `null` and a missing declaration all - * contribute nothing while leaving their parents' crumbs alone. - */ -/** - * A trail's labels. - * - * Cast rather than read straight off `content`, because React 19 types - * `ReactNode` as including a promise - so an arrow returning one reads as an - * async function to every lint rule that looks for one, and one of those rules - * helpfully adds the `async` keyword. - */ const labelsOf = (matches: readonly BreadcrumbMatch[]): string[] => breadcrumbTrail(matches).map(entry => entry.content as string); diff --git a/packages/vitnode/src/tanstack/breadcrumb/model.ts b/packages/vitnode/src/tanstack/breadcrumb/model.ts index d9797d3c0..970978873 100644 --- a/packages/vitnode/src/tanstack/breadcrumb/model.ts +++ b/packages/vitnode/src/tanstack/breadcrumb/model.ts @@ -1,70 +1,11 @@ import { createElement } from "react"; -/** - * Where a breadcrumb comes from, in a router that has no parallel routes. - * - * Next.js resolved the main breadcrumb through a `@breadcrumb` slot: a parallel - * route whose folder mirrored the page's, so the *deepest* folder with a - * `page.tsx` rendered the whole trail. This is the same idea taken one step - * further and expressed with what a router already has - the list of matched - * routes, deepest last - and one optional field on each route's `staticData`. - * - * ## A trail, not a winner - * - * Every matched route that declares a crumb contributes **one item** to the - * trail, in parent-to-child order: - * - * Home / Catalog / Products / Laptops / MacBook Pro - * - * So a route says what it is called and nothing else. It does not restate its - * layouts' crumbs, it does not know how deep it is, and it never builds a link: - * the shell owns the separators, the `nav`/`aria-current` semantics and the - * locale-aware href, which it takes from the matched route's own pathname. - * - * A route that declares nothing contributes nothing, and its parents' crumbs - * stay visible - which is what a page inside a layout that already names the - * screen wants. `false` says the same thing on purpose. - * - * ## Three shapes, and why the third exists - * - * A crumb is usually a **component**, because a label is translated and so has - * to be able to call a hook, and because the shell hands it the match's own - * loader data, params and search - which an element written next to the route - * options could not be given. An **element** is the simpler spelling for a label - * that needs nothing. - * - * {@link breadcrumbGroup} is the third, and it is for one situation: a single - * route whose URL is several crumbs deep, where the labels come from somewhere - * other than the route tree. The AdminCP is exactly that - `/admin/core/users` - * is one route, and its trail is named by the navigation this administrator can - * see - so its crumbs are rendered by one of VitNode's own components, which - * emits its own items inside the shell's list. Plugins never need it: their - * contract is a label. - */ declare module "@tanstack/react-router" { interface StaticDataRouteOption { - /** - * What this route contributes to the shell's breadcrumb trail. - * - * Absent or `false` contributes nothing, which leaves whatever this route's - * parents declared. - * - * Optional, and it must stay optional: a required member here would make - * `staticData` required on every route in the app. - */ breadcrumb?: RouteBreadcrumb; } } -/** - * What a route's breadcrumb component is handed - the match that declared it. - * - * Its own match rather than the deepest one, so a layout's crumb reads the - * layout's own loader data while a page inside it is what the visitor is looking - * at. Everything is widened to `unknown` because a package cannot name a host's - * route types; a crumb that needs its data narrows it, or - for a plugin route - - * is handed the typed `PluginRouteBreadcrumbProps` instead. - */ export interface RouteBreadcrumbProps { loaderData: unknown; params: Readonly<Record<string, string>>; @@ -73,15 +14,6 @@ export interface RouteBreadcrumbProps { search: unknown; } -/** - * Several crumbs from one matched route, rendered by a VitNode component. - * - * The escape hatch the AdminCP needs, and nothing else uses: the component emits - * `<BreadcrumbItem>`s of its own inside the shell's list, with its own - * separators between them, so a route whose URL is three segments deep can be - * named by three crumbs. The shell still owns the separator *before* the group - * and the wrapper around the whole trail. - */ export interface RouteBreadcrumbGroup { group: React.ComponentType<RouteBreadcrumbProps>; } @@ -97,12 +29,6 @@ export const breadcrumbGroup = ( group: React.ComponentType<RouteBreadcrumbProps>, ): RouteBreadcrumbGroup => ({ group }); -/** - * The narrowest shape of a route match this rule reads. - * - * A structural type rather than the router's `AnyRouteMatch`, so the rule is a - * function over plain data and can be tested as one - no router, no route tree. - */ export interface BreadcrumbMatch { loaderData?: unknown; params?: unknown; @@ -139,14 +65,6 @@ const propsFor = (match: BreadcrumbMatch): RouteBreadcrumbProps => ({ search: match.search, }); -/** - * Every matched route that declares a crumb, parent to child. - * - * `undefined`, `null` and `false` all contribute nothing; the difference between - * them is only what an author meant, and `false` is the spelling that says it. - * The last entry is the current page, which is what the shell renders as - * `aria-current` rather than as a link. - */ export const breadcrumbTrail = ( matches: readonly BreadcrumbMatch[], ): BreadcrumbTrailEntry[] => { diff --git a/packages/vitnode/src/tanstack/devices/devices.test.ts b/packages/vitnode/src/tanstack/devices/devices.test.ts index a24bc697e..626ab8c70 100644 --- a/packages/vitnode/src/tanstack/devices/devices.test.ts +++ b/packages/vitnode/src/tanstack/devices/devices.test.ts @@ -6,21 +6,6 @@ import type { RevokeDeviceResult } from "@/views/auth/settings/devices/devices-r import { devicesQueryKey } from "@/views/auth/settings/devices/devices-query"; -/** - * `/settings/devices`' contract with the cache underneath it. - * - * Pure functions and one `QueryClient` held in memory. The *meaning* of a - * devices request - the key, the request, what a refusal is, and whether a - * finished revoke makes the list stale - lives in - * `views/auth/settings/devices/devices-query.ts` and is asserted beside it. What - * is asserted here is that this namespace asks for the right one, and that a - * revoke invalidates exactly the one entry it should and nothing else. - * - * The revoke's transport is stubbed rather than reached. There is no HTTP here: - * the only thing under test is which statuses cause an invalidation, which is - * the decision that replaced `revalidatePath('/[locale]/(main)', 'layout')`. - */ - /** What the stubbed browser revoke answers with on the next call. */ let nextRevokeResult: RevokeDeviceResult = { data: true }; @@ -86,16 +71,6 @@ describe("this namespace asks for the shared devices list, not its own", () => { expect(devicesQuery(USER).retry).toBe(false); }); - /** - * The privacy invariant at this namespace's own seam. - * - * The browser's `QueryClient` is created once per document and outlives a - * sign-out, so `["devices", "me"]` was only unique for as long as "me" was: - * the second visitor to sign in on one browser would have found the entry - * already filled, made no request, and been shown the first visitor's - * operating systems, browsers and IP addresses. No request means Hono never - * saw the read it would have refused, which is why the key is the fix. - */ 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), diff --git a/packages/vitnode/src/tanstack/devices/index.ts b/packages/vitnode/src/tanstack/devices/index.ts index 355e95d5c..b9c52138d 100644 --- a/packages/vitnode/src/tanstack/devices/index.ts +++ b/packages/vitnode/src/tanstack/devices/index.ts @@ -1,24 +1,7 @@ export { DevicesPanelContent, DevicesPanelPending } from "./panel"; -/** - * `@vitnode/core/tanstack/devices` - the visitor's signed-in devices. - * - * ./query the eager half: the query definition, the revoke and the - * invalidation a route's loader warms and its callbacks call - * ./panel the rendered list, reached only through a route's `component:` - * - * Split for the reason every screen namespace here is: a route file's `loader` - * is evaluated in the client entry, so a loader importing the panel beside the - * query puts the device list in the first bundle of every page of the - * application. - */ + export * from "./query"; -/** - * The key factory, re-exported so a route has one place to reach for. - * - * Left to be imported from `views/auth/settings/devices/devices-query` it would - * be a second spelling waiting to be invented, and an entry this module's - * invalidation would then miss. - */ + export { devicesQueryKey, DevicesRequestError, diff --git a/packages/vitnode/src/tanstack/devices/panel.tsx b/packages/vitnode/src/tanstack/devices/panel.tsx index 6d9449595..bb3fff464 100644 --- a/packages/vitnode/src/tanstack/devices/panel.tsx +++ b/packages/vitnode/src/tanstack/devices/panel.tsx @@ -9,43 +9,12 @@ import { DevicesListSkeleton } from "@/views/auth/settings/devices/devices-list- import { devicesQuery, useRevokeDeviceCallback } from "./query"; -/** - * The panel heading, which both states below render identically. - * - * `core.auth.settings.devices.title` and `.desc` - the panel's own `<h2>`, not - * the settings `<h1>` the layout renders and not the `nav.devices` label the tab - * title is built from. - */ const DevicesHeading = () => { const t = useTranslations("core.auth.settings.devices"); return <HeaderContent desc={t("desc")} h2={t("title")} />; }; -/** - * Where the Next.js page's `<Suspense fallback={<DevicesListSkeleton />}>` ends - * up: the same skeleton, in the same place relative to the heading. - * - * Next.js streams the heading first and fills the list in; a router shows this - * once a navigation into the route has been pending long enough to notice. - * Neither appears on a first paint - the loader has the list before anything - * renders - so this is the slow-client-navigation case and only that. - * - * ## Why it may translate, having mounted no provider - * - * `DevicesHeading` calls `useTranslations`, and this fallback is rendered - * without the panel's own component ever running - so the question is whether the - * settings layout's `RouteMessages` is above it by then. It always is, for one - * structural reason: a `pendingComponent` stands in for the *panel*, and the - * panel is rendered into the layout's `<Outlet />` - which exists only because - * the layout's own component ran, which is what mounts the provider. A pending - * match renders its pending element *instead of* its component, so a layout that - * is itself pending renders no `<Outlet />` and therefore no panel state at all. - * - * The constraint that does fall out: this must stay inside the settings subtree. - * A translating fallback rendered *above* that provider - a `pendingComponent` - * on the layout itself, say - would throw rather than degrade. - */ export const DevicesPanelPending = () => ( <> <DevicesHeading /> @@ -53,15 +22,6 @@ export const DevicesPanelPending = () => ( </> ); -/** - * `/settings/devices`, as everything below a route file's `component`. - * - * `userId` addresses a cache entry and nothing else: `GET /users/devices` takes - * no arguments and derives the owner from the session cookie on every request. - * It comes from the host's `_authenticated` guard - the one canonical session - * query - and is read once in the loader so the loader, this component and the - * revoke callback all use the identical value. - */ export const DevicesPanelContent = ({ userId }: { userId: number }) => { const { data } = useSuspenseQuery(devicesQuery(userId)); const onRevoke = useRevokeDeviceCallback(userId); diff --git a/packages/vitnode/src/tanstack/devices/query.ts b/packages/vitnode/src/tanstack/devices/query.ts index ed9e82d21..9f7ac9bc3 100644 --- a/packages/vitnode/src/tanstack/devices/query.ts +++ b/packages/vitnode/src/tanstack/devices/query.ts @@ -23,114 +23,19 @@ import { import { fetchDevicesOnServer } from "./server"; -/** - * The visitor's signed-in devices, as one query definition and one revoke, for a - * TanStack Start host. - * - * Everything about *what* the list is - the request, the cache key, what counts - * as a refusal - comes from `@/views/auth/settings/devices/devices-query`, which - * is also what the mounted `DevicesContent` is rendered from. This module - * supplies only the two things that module cannot know: how to reach the API - * from a server that is rendering a request, and what "refresh the list" means - * in a router that has a query cache instead of `revalidatePath`. - * - * The same shape as `../files`, deliberately - see the long note there. What is - * different is only that this list has no parameters, so one visitor has one - * cache entry rather than a family of pages and sorts. - */ - -/** - * The transport boundary, and the reason one query definition works in a loader - * and in a component. - * - * Both branches call the Hono API directly - the server one from inside the - * request being rendered, the browser one over the network to the same origin. - * There is deliberately no `createServerFn` in between. A server function is a - * `POST` back to the app that then calls Hono, so a refetch after a revoke would - * cost two round trips for a read the API is already the boundary for. A session - * read *is* a server function, and the difference is real rather than stylistic: - * nothing here needs a `Set-Cookie` copied onto the app's own response. - * - * The cookies still travel on both branches, and this read needs two of them. On - * the server `fetcher` forwards the whole `Cookie` header the page request - * arrived with; in the browser the call is same-origin, so the browser attaches - * it without being asked. That is what makes a `401` here mean "the session - * ended" rather than "we forgot to say who was asking" - and what makes - * `isCurrent` name the row the reader is actually sitting on. - * - * `createIsomorphicFn` is what makes that safe rather than merely tidy: the - * Start compiler keeps only the branch belonging to the bundle it is building - * and drops the other's import with it, so `./server` - and the `server-only` - * marker at the top of it - never reaches the browser. It is written out here - * rather than behind a shared helper because the transform matches the *chained - * call*; see the note in `../files/query`. - */ const fetchDevices: DevicesFetcher = createIsomorphicFn() .server(fetchDevicesOnServer) .client(fetchDevicesInBrowser); -/** - * The devices list, as the one query definition every caller shares. - * - * loader: context.queryClient.ensureQueryData(devicesQuery(userId)) - * component: useSuspenseQuery(devicesQuery(userId)) - * after a revoke: invalidate that visitor's entry, and it refetches - * - * `userId` is the *cache* owner and nothing more. It comes from the - * authenticated boundary's own state, read from the one canonical session query - * rather than from a second source - and it never reaches the API: - * `GET /users/devices` takes no arguments at all and derives the owner from the - * session cookie. See `devicesQueryKey` for why the entry has to be partitioned, - * and what went wrong when it was not. - * - * No `initialData`: the loader has already put the list in the entry this key - * names and the SSR pass dehydrates it, so passing it again would be a second - * copy of the same bytes that can disagree with the first. - */ export const devicesQuery = (userId: number) => devicesQueryOptions({ fetchDevices, userId }); -/** - * Marks one visitor's cached devices list stale. - * - * One entry, named exactly - not `queryClient.invalidateQueries()` with no key. - * The session, the messages and every other list the app holds are unaffected by - * a device being signed out, and refetching them because of it is the blunt - * version of the `revalidatePath('/[locale]/(main)', 'layout')` this replaces. - * - * Scoped to `userId` for the same reason the key is. A long-lived browser client - * can still hold a previous visitor's partition; it is unreachable - every - * authenticated route builds its key from the current session - and refetching - * it would be a request on behalf of somebody who has signed out. - * - * The session entry in particular is deliberately left alone, and that is a - * finding rather than an omission: the API refuses to revoke the current device - * with a `400`, so no revoke the app can perform ends the session it is - * performed from. There is no state in which the cached session is left falsely - * authenticated by a successful revoke. Were that ever to change - were the - * route to start accepting its own device id - this is the function that would - * have to invalidate the session key alongside this one. - * - * Invalidating rather than removing keeps the current rows on screen while the - * fresh ones are fetched, instead of blanking the list under the dialog that is - * still closing. - */ export const invalidateDevices = async ( queryClient: QueryClient, userId: number, ): Promise<void> => await queryClient.invalidateQueries({ queryKey: devicesQueryKey(userId) }); -/** - * Signs one device out, then refreshes the list if the list is now wrong. - * - * `shouldRefreshAfterRevoke` is the shared rule, and the same one the Next.js - * server action applies before it calls `revalidatePath`: a success and a stale - * row (`404`, `400`) make the list wrong, while a `401`, `429` or `500` left it - * exactly as it was. Refetching after one of those would send the same read - * straight back into whatever refused the first - the rate limiter, or an ended - * session - and replace the list the person is reading with an error. - */ export const revokeDevice = async ( queryClient: QueryClient, userId: number, @@ -145,20 +50,6 @@ export const revokeDevice = async ( return result; }; -/** - * The one callback `DevicesContent` takes, bound to the mounted router's cache - * and to the visitor whose partition of it the revoke may refresh. - * - * `userId` is taken as an argument rather than read here: the route reads it - * from the authenticated boundary once in its loader and hands the same value to - * the query options and to this hook, so the entry the loader filled is the - * entry a revoke marks stale. It scopes an invalidation and nothing else - the - * revoke request carries a device's `publicId` and no owner. - * - * Memoised, which is the only reason this is a hook rather than a call at the - * point of use: it is a prop on a list that re-renders on every navigation, and - * a new function identity would remount the confirm dialog mid-revoke. - */ export const useRevokeDeviceCallback = (userId: number): RevokeDevice => { const queryClient = useQueryClient(); diff --git a/packages/vitnode/src/tanstack/devices/server.ts b/packages/vitnode/src/tanstack/devices/server.ts index 6fd72a7f2..ea8596cfa 100644 --- a/packages/vitnode/src/tanstack/devices/server.ts +++ b/packages/vitnode/src/tanstack/devices/server.ts @@ -5,32 +5,6 @@ import { DevicesRequestError } from "@/views/auth/settings/devices/devices-query import { fetcher } from "../fetcher/server"; -/** - * The visitor's devices, fetched during SSR. - * - * The route is written here rather than handed in, so the call says what it is - * asking for. The refusal check is the shared one, so a list rendered on the - * server and one refetched after a revoke fail the same way. - * - * `fetcher` rather than a bare `fetch`, and here it carries two things - * rather than one: - * - * - **The session cookie**, which is whose devices these are. A render that - * forwarded nothing would be answered as an anonymous visitor - `401` - so - * this is the difference between a signed-in page and an error. - * - **The device cookie**, which is which row is `isCurrent`. The API compares - * each row's `publicId` to it, so a render that dropped it would mark every - * row revokable and offer to sign the reader out of the session they are - * reading with. `buildForwardedHeaders` sends the whole `Cookie` header, so - * both travel together. - * - * It also resolves the API origin from the request being rendered, so a preview - * deployment calls its own hostname rather than a configured one. - * - * Only ever reached through the isomorphic transport in `./index`, which is what - * keeps this module - and the `server-only` marker above it - out of the browser - * bundle. - */ export const fetchDevicesOnServer = async () => { const response = await fetcher(usersModule, { method: "get", diff --git a/packages/vitnode/src/tanstack/fetcher/server.ts b/packages/vitnode/src/tanstack/fetcher/server.ts index 935acceb3..864d1a2cf 100644 --- a/packages/vitnode/src/tanstack/fetcher/server.ts +++ b/packages/vitnode/src/tanstack/fetcher/server.ts @@ -31,53 +31,8 @@ import { shouldSaveApiCookies, } from "@/lib/fetcher/set-cookie"; -/** - * `.env` into `process.env`, for anything that still reads it: the browser - * bundle's inlined `NEXT_PUBLIC_*` values, the database and Redis URLs a mounted - * API needs, and `resolveApiOrigin`'s fallback below. - * - * A host's Vite config already does this for `vite dev` and `vite build`. This - * covers `node .output/server/index.mjs`, where Vite is not involved, the same - * way `apps/api` does it - and it has to happen here rather than in the host, - * because this is the first server module every request-scoped read goes - * through. dotenv does not overwrite what is already set, so a platform that - * injects real environment variables still wins. - */ config({ quiet: true }); -/** - * The origin to call `/api/*` on. - * - * Two topologies, and the answer differs: - * - * - **The app serves its own API.** Nothing is configured, and the origin is - * whichever one the request being rendered arrived on. Taking it from the - * request is what makes a preview deployment work: its hostname is generated - * per branch, so no `NEXT_PUBLIC_API_URL` could name it, and a hard-coded - * default names a completely different app in development or nothing at all - * in production. - * - **A separate API server.** `NEXT_PUBLIC_API_URL` names it, and it wins - - * the request origin is this app's own, where there is no `/api/*` to answer, - * so preferring it makes every server-side call a `404`. That is the shape - * `create-vitnode` scaffolds (`apps/web` on `:3000`, `apps/api` on `:8000`), - * and the browser already reads the same variable through `CONFIG.api`, so - * this is also what keeps the two halves of a render calling the same host. - * - * `getRequestUrl()` reads the `Host` header the request arrived with and honours - * `x-forwarded-proto`, so a TLS-terminating proxy in front of a plain-HTTP - * server still yields an `https:` origin. `x-forwarded-host` is deliberately - * *not* honoured: it is a header a visitor can set, and these calls carry that - * visitor's cookies, so trusting it would let a request point this server's API - * calls at a host of the caller's choosing. - * - * Outside a request - boot, a script, a cron job - there is nothing to read and - * `getRequestUrl()` throws, so `CONFIG.api` is the fallback there too. - * - * The browser reaches the same conclusion on its own: with nothing configured, - * `CONFIG.api` reads the origin the document was served from, so a client-side - * call stays on the same app. `NEXT_PUBLIC_API_URL` is therefore optional rather - * than load-bearing - set it only to point at a separate API server. - */ export const resolveApiOrigin = (): string => { // Through `CONFIG` rather than the variable directly, so the empty-value // throw stays in one place - see the `??` note there. @@ -90,23 +45,6 @@ export const resolveApiOrigin = (): string => { } }; -/** - * The request state a Start app forwards to the API, read off the request being - * rendered. - * - * The API derives who is asking from `Cookie`, the device record from - * `user-agent`, and the rate-limit key and audit IP from `x-forwarded-for`. Send - * none of it and every SSR render is answered as an anonymous visitor sharing a - * single rate-limit bucket - so this is the difference between signed-in HTML and - * signed-out HTML, not a nicety. - * - * The allowlist itself lives in `@/lib/fetcher/request-context`, framework-free, - * because only the reading is this runtime's. Nothing else - * is copied across: `host` and `content-length` describe the page request rather - * than the API call, and `origin`, `referer` and `authorization` are values the - * API trusts, so forwarding whatever a visitor put in them would hand them state - * they should not control. - */ export const getForwardedApiHeaders = ({ captchaToken, }: { captchaToken?: string } = {}): Record<string, string> => { @@ -124,18 +62,6 @@ export const getForwardedApiHeaders = ({ }); }; -/** - * Copies the cookies the API just minted onto this response - what - * `allowSaveCookies` below is built on. - * - * Sign-in, sign-up, sign-out and the SSO callback all answer with a - * `Set-Cookie`, and so does any first call from a browser with no device cookie. - * Those land on the API's response to *this server*, which the browser never - * sees, so without this the visitor is signed in for exactly one render. - * - * Call it only for a response you meant to trust: it writes every cookie the - * response carries. - */ export const saveApiCookies = (response: Response): void => { for (const { name, options, value } of parseSetCookies( response.headers.getSetCookie(), @@ -144,18 +70,6 @@ export const saveApiCookies = (response: Response): void => { } }; -/** - * The same request context, for a module the type system cannot name. - * - * A Content Engine module is generated at runtime from a definition, so there is - * no `typeof` for {@link fetcher} to infer route literals from - see - * `views/admin/views/content/content-request.ts`. Those calls still need the - * visitor's cookies and this request's origin, and this is that half of - * `fetcher` without the typing, so the transport is decided in one place rather - * than reassembled per caller. - * - * Prefer {@link fetcher}. Reach for this only when the module is generated. - */ export const rawFetcher = async ({ additionalHeaders, origin, @@ -167,40 +81,6 @@ export const rawFetcher = async ({ origin: origin ?? resolveApiOrigin(), }); -/** - * The server-side fetcher: one call, the API module it talks to, and the route - * on it. - * - * const response = await fetcher(usersModule, { - * method: "post", - * module: "users", - * path: "/sign_in", - * allowSaveCookies: true, - * args: { body: { email, password } }, - * }); - * - * The same signature the Next.js `fetcher()` had, so a route literal, its - * method, its `args` and the response schema all infer from the module and - * nothing about a call is spelled twice. `args` is required exactly when the - * route declares a body, params or a query - see {@link FetcherParams}. - * - * What it adds to `coreFetcher` is this request: - * - * - the visitor's `Cookie`, `user-agent` and `x-forwarded-for`, so the API knows - * who is asking and buckets the rate limiter correctly, - * - the origin the page request arrived on, so a preview deployment calls - * itself, - * - `captchaToken` as the header `captchaMiddleware` reads, and - * - `allowSaveCookies`, which copies a `2xx`'s `Set-Cookie` onto the response - * this server is building. Without it a freshly minted session lives for - * exactly one render. - * - * Server-side only, and only inside a request: the headers come from the request - * currently being handled, so a module-scope call has nothing to read. In - * TanStack Start that means a `createServerFn` handler, a server route, or the - * `.server()` branch of a `createIsomorphicFn` - not a route `loader`, which - * also runs in the browser on client-side navigation. - */ export async function fetcher< M extends string, Routes extends Route[], diff --git a/packages/vitnode/src/tanstack/files/index.ts b/packages/vitnode/src/tanstack/files/index.ts index 89df7609d..7126b7fef 100644 --- a/packages/vitnode/src/tanstack/files/index.ts +++ b/packages/vitnode/src/tanstack/files/index.ts @@ -32,28 +32,7 @@ export type { DeleteMyFiles, DeleteMyFilesArgs, } from "@/views/files/my-files-delete"; -/** - * `/files`, as everything a TanStack Start route needs and nothing a route owns. - * - * Two halves, and they are separate files for a reason rather than by habit: - * - * - `./route-search` is the URL contract. Pure functions, no transport, no - * React, so what `?orderBy=name` means can be stated and tested without a - * router. - * - `./query` is the cache contract. One query definition, one invalidation - * family, and the two deletes that decide when to use it. - * - * The rendering is not here and does not belong here: `MyFilesTableContent` is - * framework-free already and is imported from `@vitnode/core/views/files/ - * my-files-table-content` by both applications. What this namespace adds is the - * half that only a TanStack Start host can run - an isomorphic fetcher whose - * server branch reads the request being rendered. - * - * The key factories are re-exported rather than left to be imported from - * `views/files/my-files-query`, so a route has one place to reach for and cannot - * invent a second spelling of an entry this module's invalidation would then - * miss. - */ + export { isMyFilesRequestError, MY_FILES_MAX_PAGE_SIZE, diff --git a/packages/vitnode/src/tanstack/files/query.ts b/packages/vitnode/src/tanstack/files/query.ts index 491f89f48..89791d2b2 100644 --- a/packages/vitnode/src/tanstack/files/query.ts +++ b/packages/vitnode/src/tanstack/files/query.ts @@ -30,75 +30,10 @@ import { import { fetchMyFilesPageOnServer } from "./server"; -/** - * The visitor's own files, as one query definition and two deletes, for a - * TanStack Start host. - * - * Everything about *what* the list is - the request, the defaults, the cache - * key, what counts as a refusal - comes from `@/views/files/my-files-query`, - * which is also what the mounted `MyFilesTableContent` is rendered from. This - * module supplies only the two things that module cannot know: how to reach the - * API from a server that is rendering a request, and what "refresh the table" - * means in a router that has a query cache instead of `revalidatePath`. - */ - -/** - * The transport boundary, and the reason one query definition works in a loader - * and in a component. - * - * Both branches call the Hono API directly - the server one from inside the - * request being rendered, the browser one over the network to the same origin. - * There is deliberately no `createServerFn` in between. A server function is a - * `POST` back to the app that then calls Hono, so every sort, page and search of - * the table would cost two round trips for a read the API is already the - * boundary for. A session read *is* a server function, and the difference is - * real rather than stylistic: nothing here needs a `Set-Cookie` copied onto the - * app's own response. - * - * The cookie still travels on both branches. On the server `fetcher` - * forwards the one the page request arrived with; in the browser the call is - * same-origin, so the browser attaches it without being asked. That is what - * makes a `401` here mean "the session ended", never "we forgot to say who was - * asking". - * - * `createIsomorphicFn` is what makes that safe rather than merely tidy: the - * Start compiler keeps only the branch belonging to the bundle it is building - * and drops the other's import with it, so `./server` - and the `server-only` - * marker at the top of it - never reaches the browser. - * - * Written out here rather than behind a shared helper, and that is the compiler - * rather than taste: the transform matches the *chained call*, so a - * `.server(fn)` passed as an ordinary argument somewhere else would leave the - * server import in the client graph. Each feature spells its own out, and the - * duplication is three lines rather than two fetch implementations. - */ const fetchMyFilesPage: MyFilesPageFetcher = createIsomorphicFn() .server(fetchMyFilesPageOnServer) .client(fetchMyFilesPageInBrowser); -/** - * The files table, as the one query definition every caller shares. - * - * loader: ensureQueryData(myFilesQuery({ params, userId })) - * component: useQuery(myFilesQuery({ params, userId })) - * after a delete: invalidate that visitor's family, and it refetches - * - * `params` must be the *normalised* ones - `myFilesRouteParams` over the - * route's validated search - because the cache key is built from them. Passing - * raw URL values would make `?first=10` and no `first` two entries holding - * identical rows, and the loader would fill one while the component read the - * other. - * - * `userId` is the *cache* owner and nothing more. It comes from the - * authenticated boundary's own state - the canonical session read, not a second - * source - and it never reaches the API: `GET /users/files` takes no owner and - * derives one from the session cookie. See `myFilesQueryRoot` for why the entry - * has to be partitioned at all. - * - * No `initialData`: the loader has already put the page in the entry this key - * names and the SSR pass dehydrates it, so passing it again would be a second - * copy of the same bytes that can disagree with the first. - */ export const myFilesQuery = ({ params, userId, @@ -107,41 +42,12 @@ export const myFilesQuery = ({ userId: number; }) => myFilesQueryOptions({ fetchPage: fetchMyFilesPage, params, userId }); -/** - * Marks every cached page of *one* visitor's files stale. - * - * The whole family, by prefix - not the one page on screen. A delete changes - * which rows exist, so every other page, sort and search of the same list is now - * wrong too, and the visitor reaches those by pressing a button that reads from - * the cache. It is emphatically *not* `queryClient.invalidateQueries()` with no - * key: the session, the messages and every other list the app holds have not - * changed, and refetching them because a file was deleted is the blunt version - * of the `revalidatePath` this replaces. - * - * Scoped to `userId`, which narrows it twice over. `myFilesQueryRoot(userId)` is - * a prefix of every one of that visitor's pages and of nobody else's, so a - * long-lived browser client that still holds a previous visitor's partition - * keeps it - untouched and unreachable, because every authenticated route builds - * its key from the current session. Invalidating another partition would refetch - * a list nobody is looking at, on behalf of a visitor who has gone. - * - * Invalidating rather than removing keeps the current rows on screen while the - * fresh ones are fetched, instead of blanking the table under the dialog that is - * still open. - */ export const invalidateMyFiles = async ( queryClient: QueryClient, userId: number, ): Promise<void> => await queryClient.invalidateQueries({ queryKey: myFilesQueryRoot(userId) }); -/** - * Deletes one file, then refreshes the table if it actually went. - * - * Only on success. A `409` left the file exactly where it was and the dialog is - * still open offering to force past the revisions holding it; refetching - * underneath that would replace the rows the person is being asked about. - */ export const deleteMyFile = async ( queryClient: QueryClient, userId: number, @@ -154,15 +60,6 @@ export const deleteMyFile = async ( return result; }; -/** - * Deletes a selection, then refreshes the table if anything went. - * - * `shouldRefreshAfterBulkDelete` is the shared rule, and the same one the - * Next.js server action applies before it calls `revalidatePath`: a run that - * deleted nothing leaves the page as it was, and refetching would drop the - * selection that is showing which rows were kept - which is the only thing - * telling the person what to do next. - */ export const deleteMyFiles = async ( queryClient: QueryClient, userId: number, @@ -177,26 +74,6 @@ export const deleteMyFiles = async ( return result; }; -/** - * The two callbacks `MyFilesTableContent` takes, bound to the mounted router's - * cache and to the visitor whose partition of it they may touch. - * - * `userId` is taken as an argument rather than read here, and that is the whole - * of "do not derive it three different ways": the route reads it from the - * authenticated boundary once in its loader, returns it, and hands the same - * value to the query options and to this hook. A second read - even of the same - * canonical entry - could resolve differently mid-navigation and invalidate a - * partition the table is not showing. - * - * It scopes an invalidation and nothing else. Neither delete request carries an - * owner; `DELETE /users/files/{id}` authorizes from the session cookie, as it - * did before this parameter existed. - * - * Memoised on the client, which is the only reason this is a hook rather than - * two calls at the point of use: the callbacks are props on a table that - * re-renders on every navigation, and new function identities would remount the - * confirm dialogs mid-delete. - */ export const useMyFilesDeleteCallbacks = ( userId: number, ): { diff --git a/packages/vitnode/src/tanstack/files/route-search.test.ts b/packages/vitnode/src/tanstack/files/route-search.test.ts index 36ec83f7b..9c693db2d 100644 --- a/packages/vitnode/src/tanstack/files/route-search.test.ts +++ b/packages/vitnode/src/tanstack/files/route-search.test.ts @@ -24,31 +24,6 @@ import { normalizeMyFilesRouteSearch, } from "./route-search"; -/** - * `/files`' contract with its own URL, and with the cache underneath it. - * - * Pure functions only. `normalizeMyFilesRouteSearch` is what a route hands to - * `validateSearch`, so calling it directly is calling the route's schema - no - * router, no request, no rendering. The *meaning* of a files request lives in - * `views/files/my-files-query` and is asserted in `my-files-query.test.ts`; what - * is asserted here is that this namespace asks for the right one, that a table - * control's URL survives the round trip through it, and that a delete - * invalidates the right family and nothing else. - * - * Which route file mounts this - and whether that route exists at all - is the - * host's question, and stays in `apps/web/src/tests/my-files-route.test.ts`. - */ - -/** - * The route's schema, over a query string as a visitor would type it. - * - * Through the router's *own* parser rather than `URLSearchParams`, because what - * reaches `validateSearch` is not a query string and not even strings: the - * default parser turns `?first=20` into the number `20`, `?x=true` into a - * boolean, and a repeated key into an array. Half the rules below exist for - * exactly that, so a test that flattened it first would be testing something - * else. - */ const searchFor = (query: string) => normalizeMyFilesRouteSearch(defaultParseSearch(query)); diff --git a/packages/vitnode/src/tanstack/files/route-search.ts b/packages/vitnode/src/tanstack/files/route-search.ts index fc31d3903..5a816d160 100644 --- a/packages/vitnode/src/tanstack/files/route-search.ts +++ b/packages/vitnode/src/tanstack/files/route-search.ts @@ -8,70 +8,8 @@ import type { import { DEFAULT_TABLE_PAGE_SIZE } from "@/components/table/url-state"; import { normalizeMyFilesParams } from "@/views/files/my-files-query"; -/** - * What a `/files` route reads out of its URL, and the three things it turns that - * into. - * - * Four pure functions, no transport and no React, so the route's contract can be - * stated and tested without a router - `route-search.test.ts` beside this file - * is the whole of it. The same split `/search` uses (`../search/route-search`), - * applied to a table instead of a feed. - * - * Every one of them delegates the *meaning* of a parameter to - * `@/views/files/my-files-query`, which is the module the Next.js - * `MyFilesTableView` reads its `searchParams` through. So `/files?orderBy=name` - * is the same request in both applications rather than two hand-written - * approximations of it, and nothing here re-states which columns are sortable or - * how large a page may be. - * - * ## Three shapes, and why they are not one - * - * the URL ?orderBy=name&first=20 what a visitor sees and shares - * the search { orderBy: 'name', first: 20 } the route's validated state - * the request { first: '20', orderBy: 'name' } what the API is asked for - * - * The middle one is the URL, validated. The last one is `MyFilesParams`, which - * additionally *always* names a page size, because a request must - and a URL - * need not. Keeping them apart is what stops `?first=10` being written into - * every link to a page whose canonical address is `/files`. - * - * ## All four are total and idempotent - * - * None of them can throw and none of them reject: a URL typed by hand renders - * the table it would have rendered anyway. That is not politeness, it is a - * requirement of where they run - `validateSearch` throwing turns a hand-edited - * query string into a router error screen, and this page's query string is - * edited by hand every time somebody shares a sorted link. - * - * Idempotent because they are applied twice on every navigation: once when a - * table control's new query string is turned back into route search, and once - * more by the router when it validates the location that produces. A rule that - * moved the value on the second pass would make the table drift a step per - * click. - */ - -/** - * The page size the URL does not need to mention. - * - * `DEFAULT_TABLE_PAGE_SIZE` is what every `DataTable` falls back to when the URL - * asks for no size, so `?first=10` and no `first` at all are the same request - * spelled two ways - and the shorter spelling is the one this route settles on. - */ const DEFAULT_PAGE_SIZE = String(DEFAULT_TABLE_PAGE_SIZE); -/** - * The route's validated search - the URL contract, and nothing else. - * - * Exactly the six parameters `DataTable`'s controls write: the sort header emits - * `orderBy`/`order`, the search box `search`, and the pager `first`/`last` with - * a `cursor`. There is no seventh, because this table declares no filters. - * - * `first` and `last` are numbers rather than strings, and that is about the - * address bar rather than about types. TanStack Router's default search - * serializer JSON-encodes a *string* that would parse as JSON, so the string - * `'20'` is written to the URL as `first=%2220%22`; the number `20` is written - * as `first=20`, which is what the Next.js page produces and what the API reads. - */ export interface MyFilesRouteSearch { cursor?: string; first?: number; @@ -81,35 +19,9 @@ export interface MyFilesRouteSearch { search?: string; } -/** - * A search as it arrives, before anything has checked it. - * - * Two shapes, because there are two callers and they are genuinely different. - * The router hands over its *parsed* search - an arbitrary bag of whatever was - * in the query string - and the route hands its own validated search straight - * back in, on every navigation and in the idempotence assertions. An `interface` - * has no implicit index signature, so the second is not assignable to the first - * and the union has to say so. - */ export type UncheckedMyFilesSearch = MyFilesRouteSearch | Record<string, unknown>; -/** - * One search parameter as the string it was in the query string. - * - * The router hands `validateSearch` its *parsed* search, and the default parser - * JSON-parses every value - so `?first=20` arrives as the number `20`, `?x=true` - * as a boolean, and a repeated key as an array. The normaliser is written - * against a query string, where everything is a string, and one of its rules - * (`search.trim()`) throws on anything else. - * - * So this is the seam between the two, and it is deliberately narrow: scalars - * become their string spelling, the first entry of an array wins because only - * one value can reach the API, and everything else - an object, a nested array, - * a `null` - is *absent* rather than coerced. `String({})` is - * `"[object Object]"`, which is a value no rule below would recognise but every - * rule would have to consider. - */ const readParam = (value: unknown): string | undefined => { const one = Array.isArray(value) ? (value[0] as unknown) : value; @@ -121,14 +33,6 @@ const readParam = (value: unknown): string | undefined => { return undefined; }; -/** - * The six parameters this route has, in the shape the normaliser reads. - * - * Named one by one rather than passed through, which is the whole of rule 3: - * nothing a visitor puts in the query string reaches the request builder unless - * this route asked for it. A stray `?tab=2` is not carried, not validated, and - * not sent. - */ const rawParamsOf = (input: UncheckedMyFilesSearch): RawMyFilesParams => ({ cursor: readParam(input.cursor), first: readParam(input.first), @@ -138,43 +42,10 @@ const rawParamsOf = (input: UncheckedMyFilesSearch): RawMyFilesParams => ({ search: readParam(input.search), }); -/** - * The request this URL is asking for - `MyFilesParams`, and therefore also the - * object the query key is built from. - * - * Every defaulting and clamping rule is `normalizeMyFilesParams`': an unusable - * page size falls back rather than 400ing, `first` beats `last`, a sort column - * the list cannot sort by is dropped so the API applies its own `createdAt - * desc`, a blank search is no search, and a cursor that cannot be one is not - * sent. - * - * Takes the loose object rather than {@link MyFilesRouteSearch} on purpose. The - * router merges a route's validated search over the *raw* parsed one, so - * `Route.useSearch()` still carries whatever else was in the query string; going - * back through the same normalisation is what makes this answer depend only on - * the six parameters above, whoever is calling it. - */ export const myFilesRouteParams = ( input: UncheckedMyFilesSearch, ): MyFilesParams => normalizeMyFilesParams(rawParamsOf(input)); -/** - * The route's search schema - written as a function rather than a schema object - * because its job is to *normalise*, not to reject. - * - * `/files` is a page whose query string is edited by hand and pasted between - * people: `?orderBy=password`, `?first=5000`, `?first=abc`, `?cursor=💥`. Every - * one of them should render the visitor's files sorted the way the table - * defaults to, not a router error - so an unusable value becomes an absent one, - * and the API's own `createdAt desc` is what an unrecognised `orderBy` falls - * back to. - * - * The one thing it does *not* keep is a page size equal to the default. `/files` - * and `/files?first=10` are the same page, and a schema that answered - * `first: 10` for the first of them would write `?first=10` into every link the - * router builds to this route - including the one a migration link renders and - * the one a guest's `?returnTo=` comes back through. - */ export const normalizeMyFilesRouteSearch = ( input: UncheckedMyFilesSearch, ): MyFilesRouteSearch => { @@ -197,15 +68,6 @@ export const normalizeMyFilesRouteSearch = ( }; }; -/** - * The query string the table's controls read themselves out of. - * - * `DataTable`'s sort headers, pager and search box are handed a - * `URLSearchParams` and produce a new query string from it - * (`components/table/url-state.ts`); this is the other end of that, and it is - * built from the validated search rather than from the address bar so a control - * can only ever edit a parameter this route recognises. - */ export const myFilesSearchParams = ( input: UncheckedMyFilesSearch, ): URLSearchParams => { @@ -220,13 +82,6 @@ export const myFilesSearchParams = ( return params; }; -/** - * A query string one of those controls produced, back as route search. - * - * The return leg, and the point at which the table's own URL arithmetic is - * re-validated: a control cannot write a sort column this route does not have, - * because what it wrote goes through the same schema the address bar does. - */ export const myFilesSearchFrom = (nextSearch: string): MyFilesRouteSearch => normalizeMyFilesRouteSearch( Object.fromEntries(new URLSearchParams(nextSearch)), diff --git a/packages/vitnode/src/tanstack/files/route.tsx b/packages/vitnode/src/tanstack/files/route.tsx index f6cd79cf6..470982a3b 100644 --- a/packages/vitnode/src/tanstack/files/route.tsx +++ b/packages/vitnode/src/tanstack/files/route.tsx @@ -9,20 +9,6 @@ import type { MyFilesRouteSearch } from "./route-search"; import { intlQueryOptions } from "../i18n/query"; import { myFilesQuery } from "./query"; -/** - * What `/files` renders strings from. - * - * `core.files` is the heading, the columns, the empty state and every word of - * both delete dialogs. `core.global` is the rest of the table - the pager's - * labels, the search placeholder, the confirm dialog's buttons and the error - * toasts - and it is listed even though a root provider already provides it, - * because `RouteMessages` mounts its own provider over the root's rather than - * adding to it. - * - * One list, read by both the loader that fetches them and the provider that - * mounts them, because they have to be the same set or the provider suspends on - * a key nobody warmed. - */ export const MY_FILES_NAMESPACES = ["core.files", "core.global"] as const; /** The narrowest slice of a route's context this loader reads. */ @@ -40,39 +26,6 @@ export interface MyFilesRouteData { userId: number; } -/** - * Both reads `/files` needs, in parallel, before it renders. - * - * `locale` comes from the root route's `beforeLoad`, which resolved it from the - * public URL - so `/pl/files` fetches Polish messages, and the first byte of - * HTML is already in that language. - * - * Neither call is repeated by the component: the messages are read back by - * `RouteMessages` through the identical `intlQueryOptions`, and the page by - * `useSuspenseQuery` through the identical `myFilesQuery`. - * - * The session is *not* fetched here. A host's `_authenticated` guard has already - * put it in the one cache entry every guard reads, and `auth` is that guard's - * own return - already narrowed to the signed-in half of the union, so - * `auth.user` needs no check and this cannot disagree with the rule that - * admitted the navigation. - * - * `userId` is read **once**, here, and returned; the component and the delete - * callbacks take it from `loaderData` rather than reading it again, so there is - * exactly one answer per render pass and no way for the loader to fill one cache - * partition while the component reads another. It addresses a cache entry and - * nothing else - `GET /users/files` takes no owner and derives one from the - * session cookie on every request. - * - * A refusal from the files API is deliberately left to propagate. `401`, `403` - * and `429` reject as `MyFilesRequestError`, which fails this loader and shows - * the router's error path - the honest answer. The alternative, catching it and - * rendering an empty table, is indistinguishable from an account with nothing - * uploaded, which is the one thing this must never look like. - * - * See `loadDiscoverRoute` for why the messages are translated here rather than - * in `head`, and why the message type is cast. - */ export const loadMyFilesRoute = async ({ auth, locale, @@ -104,22 +57,6 @@ export const loadMyFilesRoute = async ({ return { description: t("desc"), params, title: t("title"), userId }; }; -/** - * How a table control changes the URL - the one thing the shared table cannot - * decide for itself. - * - * `DataTable` mounts this for Next.js (`NextDataTableNavigation`, a locale-aware - * `push`); a TanStack route mounts it with its own router's navigate. - * Everything either side of it - which parameter a sort header rewrites, which - * ones a filter resets, what a page button does with a cursor - is - * `components/table/url-state.ts` and is shared. - * - * `to` is deliberately absent from the host's navigate: with no destination the - * router stays on this route and changes only its search, which is the whole of - * what a table control does. The promise is returned rather than dropped so the - * seam's `useTransition` stays pending for the whole navigation, which is what - * keeps the current rows on screen with a spinner instead of blanking the table. - */ export type MyFilesNavigate = (options: { resetScroll: boolean; search: MyFilesRouteSearch; diff --git a/packages/vitnode/src/tanstack/files/screen.tsx b/packages/vitnode/src/tanstack/files/screen.tsx index 0434349d2..b8ea04974 100644 --- a/packages/vitnode/src/tanstack/files/screen.tsx +++ b/packages/vitnode/src/tanstack/files/screen.tsx @@ -22,13 +22,6 @@ export interface MyFilesRouteProps extends MyFilesRouteData { search: UncheckedMyFilesSearch; } -/** - * `/files`, as everything below a route file's `component`. - * - * `navigate` and `search` come from the host because they are route-typed: - * TanStack infers both from the `createFileRoute` path, which is an application - * concern and stays in the application. - */ export const MyFilesRouteContent = ({ description, navigate, diff --git a/packages/vitnode/src/tanstack/i18n/index.ts b/packages/vitnode/src/tanstack/i18n/index.ts index 1f4c8db96..84177fe65 100644 --- a/packages/vitnode/src/tanstack/i18n/index.ts +++ b/packages/vitnode/src/tanstack/i18n/index.ts @@ -1,16 +1,3 @@ -/** - * VitNode's i18n runtime for a TanStack Start host. - * - * The whole of it: locale routing over the router, the message query, the - * language switcher and the provider pair a route mounts. A host supplies two - * things through {@link configureIntl} - its language list and a server function - * that fetches messages - and imports everything else from here. - * - * Nothing in this barrel touches a request. The server half - the message - * loading engine and the request-time redirect plan - is - * `@vitnode/core/tanstack/i18n/server`, and is deliberately not re-exported: - * this one is imported by route components and so is in the browser bundle. - */ export { createLocaleRewrite, localizeHref, diff --git a/packages/vitnode/src/tanstack/i18n/locale.ts b/packages/vitnode/src/tanstack/i18n/locale.ts index 33c13fced..62b68d9b3 100644 --- a/packages/vitnode/src/tanstack/i18n/locale.ts +++ b/packages/vitnode/src/tanstack/i18n/locale.ts @@ -8,30 +8,8 @@ import { readLocaleCookie } from "@/lib/i18n/locale-cookie"; import { getIntlRuntime } from "./runtime"; -/** - * A base for parsing a router href that carries no origin. Never requested, and - * never rendered - only `pathname`, `search` and `hash` are ever read back off - * it. - */ const RELATIVE_BASE = "https://vitnode.invalid"; -/** - * The remembered language, wherever this happens to be running. - * - * Only routes outside the localized URL space ever ask - `/admin`, and anything - * else in `DEFAULT_IGNORED_LOCALE_PATHS`. A public URL says which language it is - * in, and this must never get a vote there. - * - * `createIsomorphicFn` is what keeps that one question from becoming two - * functions that drift, and it is the one Start primitive this package is - * allowed to declare. In the browser bundle the host's Vite build compiles it, - * so the `.server()` branch - and the `@tanstack/react-start/server` import - * above with it - is dropped. On the server the package is un-compiled, and the - * stub falls back to the `.server()` branch, which is the right answer there. - * That asymmetry is the whole reason `.server()` is written first: the fallback - * keeps the server implementation it was given and ignores a `.client()` chained - * after it. - */ const readCookieLocale = createIsomorphicFn() .server(() => { try { @@ -49,30 +27,6 @@ const readCookieLocale = createIsomorphicFn() // bundled into the server too - only the client build ever drops one. .client(() => readLocaleCookie(globalThis.document?.cookie)); -/** - * The language a URL is served in - the one authoritative answer. - * - * Everything that needs a locale comes through here: the router rewrite that - * writes prefixes into links, `<html lang>`, the message query, the switcher. - * There is deliberately no second source to disagree with it. - * - * `publicPathname` is the URL in the address bar, *before* the router rewrote - * the prefix away. Handing it the internal path would resolve every request to - * the default locale. - * - * The cookie is the only source handed to the shared helper, and that is the - * whole contract for a route with no locale in its URL: **cookie, then the - * default.** The helper can also negotiate an `Accept-Language` header and - * deliberately is not asked to - the browser cannot read request headers, so a - * server that answered `pl` from one would hydrate to `en` on the client: a - * flash of the wrong language and a React hydration mismatch on every first - * visit. First-visit negotiation is a product decision that needs its own - * hydration-safe design, not a source quietly added here. - * - * The type parameter is how an app keeps its own `"en" | "pl"` union: the answer - * is either a code that app was configured with or its default, never anything - * from the URL, so narrowing it is safe by construction. - */ export const resolveLocale = <TLocale extends string = string>( publicPathname: string, ): TLocale => { @@ -87,36 +41,12 @@ export const resolveLocale = <TLocale extends string = string>( }) as TLocale; }; -/** - * The path shown in the address bar, from a location the router parsed. - * - * Takes the one field it reads rather than a `ParsedLocation`, so it is equally - * callable with `router.latestLocation`, with router state, and with a - * `beforeLoad`'s `location` - three types that differ only in their search - * schema. - */ export const publicPathnameOf = ({ publicHref, }: { publicHref: string; }): string => new URL(publicHref, RELATIVE_BASE).pathname; -/** - * An internal href, written in the public shape for one language. - * - * /blog/post-30 + pl -> /pl/blog/post-30 - * /blog/post-30 + en -> /blog/post-30 - * /admin/users + pl -> /admin/users (an ignored path takes no prefix) - * - * For links the router will never build. Anything it *does* build gets its - * prefix from `rewrite.output` instead, and applying both would produce - * `/pl/pl/...` - so this is only for a boundary where the destination belongs to - * another application and the router is deliberately not involved. - * - * `localizeUrl` is the same rule the rewrite uses and is idempotent, so an href - * that already carries a prefix keeps exactly one. The query string and hash are - * preserved. - */ export const localizeHref = (href: string, locale: string): string => { const { localeRouting } = getIntlRuntime(); const url = localeRouting.localizeUrl(new URL(href, RELATIVE_BASE), locale); diff --git a/packages/vitnode/src/tanstack/i18n/messages.ts b/packages/vitnode/src/tanstack/i18n/messages.ts index 49083027f..28ab056f9 100644 --- a/packages/vitnode/src/tanstack/i18n/messages.ts +++ b/packages/vitnode/src/tanstack/i18n/messages.ts @@ -15,45 +15,17 @@ import { buildAppMessagesSources } from "@/lib/i18n/sources"; import type { IntlMessages } from "./runtime"; -/** - * The frontend scope, which is part of `loadMessages`' cache key. - * - * A TanStack Start app serves its API in the same process, and a plugin ships a - * different tree to each platform under one id - so the web tree has to be - * marked as one, or whichever of the two loaded first would be served to both. - */ const WEB_SCOPE = "web"; export interface BundledMessagesOptions { /** The app's own overrides, keyed by locale and then plugin id. */ appMessages?: AppMessagesMap; - /** - * Where each installed package's translations are read from, keyed by plugin - * id - core included. - * - * The one part of the source list a package cannot supply. Every VitNode - * package ships a locale barrel that loads its own files with a runtime - * `import("./en.json", { with: { type: "json" } })`, which is exactly right - * under Node and is how `apps/api` reads them. Rollup will not - * follow a dynamic import carrying an import attribute, so in a bundled - * runtime it neither emits the JSON nor rewrites the specifier, and every - * string renders as its own key. A host declares static specifiers a bundler - * can follow and hands them in here. - */ + packageMessages: Record<string, LocaleMessagesMap | undefined>; /** The plugins this app registered, in the order they merge. */ plugins: { pluginId: string }[]; } -/** - * Everything that contributes translations, in the order they merge. - * - * Core first, then each registered plugin, then whatever the app overrides - - * later sources win, so a plugin can reword a core string and the app can reword - * either. That is exactly `buildMessagesSources`' order; the difference is only - * where the loaders come from, and {@link BundledMessagesOptions.packageMessages} - * explains why a bundled runtime has to supply its own. - */ export const buildBundledMessagesSources = ({ appMessages, packageMessages, @@ -97,33 +69,6 @@ const loaderOptionsFrom = ( } : options; -/** - * The messages one page needs, in one language - as a loader bound to an app. - * - * A factory rather than a function that reads a registered config, because this - * half runs on the server only and the server already has the app's config in - * hand: the host calls this once in the module its server function delegates to. - * - * `locale` and `namespaces` are arguments and there is no hidden state, so the - * same loader serves SSR, a client-side navigation, and a prefetch of a language - * the visitor is not currently reading in - and the query that calls it can be - * keyed by exactly what it asked for. - * - * `namespaces` is the other half of the rule VitNode has always had: the merged - * tree holds every plugin's AdminCP copy, and a page that renders none of it - * should not ship it. Only the branches named cross to the browser. A page asks - * for what it renders - `["core.global", "core.discover"]` - and gets that and - * nothing else. - * - * Underneath, nothing about the pipeline is new: `loadMessages` merges core, the - * plugins and the app's overrides for `locale`, with the default locale - * underneath as a per-key fallback, so a half-translated language degrades one - * string at a time instead of showing raw keys. - * - * Takes the app's `vitnode.server.config.ts` directly - which is the whole of - * what it needs - or the four options spelled out, for a host that assembles - * them itself. - */ export function createIntlMessagesLoader( options: IntlMessagesLoaderOptions | VitNodeServerConfig, ): IntlMessagesLoader { diff --git a/packages/vitnode/src/tanstack/i18n/provider-records.test.ts b/packages/vitnode/src/tanstack/i18n/provider-records.test.ts index c5f75df78..0906b2ead 100644 --- a/packages/vitnode/src/tanstack/i18n/provider-records.test.ts +++ b/packages/vitnode/src/tanstack/i18n/provider-records.test.ts @@ -6,28 +6,6 @@ import { describe, expect, it } from "vitest"; const here = dirname(fileURLToPath(import.meta.url)); const routeMessages = readFileSync(join(here, "route-messages.tsx"), "utf8"); -/** - * The bug this file exists to prevent coming back. - * - * `@vitnode/core` is external to a host's Vite SSR pass, so it is loaded by - * Node, which resolves `use-intl` to its `default` (production) build; the app's - * own source goes through Vite's module runner, which resolves the very same - * package to its `development` build. Two files, two `createContext` calls, two - * React contexts - and every `useTranslations` in the shared design system looks - * for this package's one. - * - * Providing only one of the two is a 500 on the first render of any core - * component, and - this is the part worth pinning - **only under `vite dev`**. - * A production build merges both records into a single chunk, so the built - * server, the SSR tests and CI were all green while `pnpm dev` was broken. - * Nothing that runs in this suite can reproduce that, because Vitest resolves - * both through Node and gets one record. So the guard is on the source. - * - * It lives beside the component rather than in the app because the component - * does: `RouteMessages` is mounted by a root route for the shell's strings and - * by a page for its own, and both of those used to be hand-written in - * `apps/web`. One implementation, one guard. - */ describe("RouteMessages provides every intl context core might read", () => { it("mounts use-intl's provider, as this package resolves it", () => { expect(routeMessages).toMatch( diff --git a/packages/vitnode/src/tanstack/i18n/query.test.ts b/packages/vitnode/src/tanstack/i18n/query.test.ts index e26e8c066..2347ec525 100644 --- a/packages/vitnode/src/tanstack/i18n/query.test.ts +++ b/packages/vitnode/src/tanstack/i18n/query.test.ts @@ -16,21 +16,6 @@ import { } from "./query"; import { configureIntl, resetIntlRuntime } from "./runtime"; -/** - * The message query, on its own, against a configured runtime rather than an - * app. - * - * Two things are pinned here and nowhere else. The **query key** is the whole - * contract between "which language is this page in" and "which messages are in - * the cache" - an earlier key carried no locale at all, so switching language - * quietly served the previous one. And **input validation** is a security - * boundary rather than a typo check: `intlQueryOptions` is fetched through a - * server function, which once built is a public endpoint that anyone can `POST` - * an arbitrary namespace list to. - * - * Only the fetcher is a stand-in. Everything else is the real implementation an - * app gets, configured the way an app configures it. - */ const fetched: { locale: string; namespaces: readonly string[] }[] = []; beforeEach(() => { @@ -61,20 +46,6 @@ afterEach(() => { resetIntlRuntime(); }); -/** - * The query as an application actually runs it. - * - * Not `options.queryFn()`. That property is typed optional and takes a - * `QueryFunctionContext`, so calling it bare is both a type error and a fiction - * - nothing in a VitNode app invokes it. A route loader calls - * `ensureQueryData` and `RouteMessages` calls `useSuspenseQuery`; both reach the - * host's fetcher through a `QueryClient`, exactly as this does, so what these - * two tests assert is the contract rather than an internal. - * - * `retry: false` because one of them asserts a *rejection*: under the default - * three retries and their backoff, a test pinning the error message would sit - * through the same failure four times before seeing it. - */ const fetchThrough = async ( options: ReturnType<typeof intlQueryOptions>, ): Promise<IntlMessages> => @@ -116,26 +87,6 @@ describe("the query key names the language", () => { ).toEqual(["vitnode", "intl", "en", "core.global", "core.search"]); }); - /** - * The key is built twice per page load in two different runtimes - once on the - * server, which dehydrates the entry into the stream, and once in the browser, - * which looks it up on hydration. So the comparator that orders the namespaces - * may not be one that asks the environment anything. - * - * `localeCompare` is exactly that comparator: it uses the runtime's default - * collator, which on the server comes from the server's environment and in a - * Node built without full ICU degrades to a code-point comparison outright, - * while the browser always has a real one set to the visitor's locale. The two - * disagree about case and about punctuation, so one list becomes two keys, the - * dehydrated entry is never found, and every page silently refetches its own - * messages after hydration. - * - * Both halves of VitNode now normalise a namespace list with the same - * function - `normalizeNamespaceList`, which sorts by code unit and is the - * one a plugin's route tree is validated with. This pins that they agree: a - * manifest declaring namespaces at build time and a browser asking for them at - * runtime cannot spell one list two ways. - */ it("orders the namespaces the way a plugin route tree does", () => { const declared = [ "core.search", diff --git a/packages/vitnode/src/tanstack/i18n/query.ts b/packages/vitnode/src/tanstack/i18n/query.ts index f09cc7c06..915bb2e83 100644 --- a/packages/vitnode/src/tanstack/i18n/query.ts +++ b/packages/vitnode/src/tanstack/i18n/query.ts @@ -12,13 +12,6 @@ import { import { getIntlRuntime } from "./runtime"; -/** - * Re-exported rather than declared: what makes a namespace legal is now - * `@vitnode/core/routing`'s, because a plugin *declares* namespaces in its route - * manifest at build time and a browser *asks* for them at runtime, and two - * copies of that rule is how the manifest ends up accepting something the server - * refuses. The names stay here so nothing that imports them has to move. - */ export { MAX_NAMESPACE_DEPTH, MAX_NAMESPACE_LENGTH, MAX_NAMESPACES }; /** The strings every page needs, whatever else it renders. */ @@ -27,61 +20,10 @@ export const GLOBAL_NAMESPACE = "core.global"; /** Everything a message entry's key starts with, before the language. */ const INTL_QUERY_SCOPE = ["vitnode", "intl"] as const; -/** - * One language's slice of the message cache. - * - * Its own function because two things need it: the key each entry is stored - * under, and the prefix `loadedIntlNamespaces` searches by. Spelling the prefix - * out twice would let a search silently stop matching the keys it is looking - * for. - */ const intlQueryPrefix = (locale: string) => [...INTL_QUERY_SCOPE, locale]; -/** - * Namespaces in a form two callers cannot spell differently. - * - * Sorted and de-duplicated, because the list is part of the query key: without - * this, `["core.global", "core.discover"]` and `["core.discover", "core.global"]` - * are two cache entries holding the same bytes, fetched twice and invalidated - * separately. - * - * `normalizeNamespaceList` rather than a second copy of it, and the comparator - * is the reason. It sorts by **code unit**; this used to sort by - * `localeCompare`, which asks the runtime's default collator - and the runtime - * that builds this key during SSR is not the runtime that rebuilds it on - * hydration. Node's default locale comes from the server's environment and a - * build without full ICU degrades `localeCompare` to a code-point comparison - * outright, while the browser always has a real collator set to whatever the - * visitor's is. Two environments that disagree about the order produce two - * different keys for one list, so the entry dehydrated into the stream is not - * the entry the client looks up: every page refetches its own messages after - * hydration, and `loadedIntlNamespaces` searches a prefix nothing was stored - * under. - * - * The routing layer already says this in its own words - a namespace list is - * written into a generated file, and a manifest that reorders itself on a - * machine with a different locale is a diff that only appears on someone else's - * laptop. It is the same list and the same requirement, so it is now the same - * function: a manifest declaring namespaces at build time and a browser asking - * for them at runtime cannot spell one list two ways. - * - * Normalisation only - it assumes strings, and says nothing about whether they - * are acceptable. That is {@link assertNamespace}'s job, and it runs on the - * server where the input is untrusted. - */ const normalizeNamespaces = normalizeNamespaceList; -/** - * One namespace, or an error. - * - * The rule is `namespaceProblem`'s, in the routing layer, and all this adds is - * the subject of the sentence - `namespaces[0] must be a string.` - so a message - * a build shows about a plugin's manifest and a message this server function - * logs about a request say the same thing about the same value. - * - * Deliberately says *what* was wrong and not *what was sent*: the value is - * attacker-controlled and this message ends up in a server log. - */ const assertNamespace = (value: unknown, index: number): string => { const problem = namespaceProblem(value); diff --git a/packages/vitnode/src/tanstack/i18n/request.ts b/packages/vitnode/src/tanstack/i18n/request.ts index cf8e2eac5..0698ebbca 100644 --- a/packages/vitnode/src/tanstack/i18n/request.ts +++ b/packages/vitnode/src/tanstack/i18n/request.ts @@ -8,13 +8,6 @@ import { serializeLocaleCookie, } from "@/lib/i18n/locale-cookie"; -/** - * What the locale layer wants done to one request, before anything renders. - * - * Returned as data rather than performed, so the rule is testable without a - * server: {@link handleLocaleRequest} is a pure function of the request, and the - * host's middleware is the three lines that carry out its answer. - */ export interface LocaleRequestPlan { /** A finished response to send instead of rendering. */ redirect?: Response; @@ -22,16 +15,6 @@ export interface LocaleRequestPlan { setCookie?: string; } -/** - * The prefix a VitNode Hono application is mounted on. - * - * It is in `DEFAULT_IGNORED_LOCALE_PATHS` like `/admin` is, but the two are - * ignored for opposite reasons and only one of them is a web page. `/admin` has - * no locale in its URL because the operator's stored preference decides it; - * `/api` has none because the API is not part of the web locale model at all - - * it negotiates per request, and clients hold its URLs verbatim. So a locale - * prefix in front of it is stripped and *nothing else happens*. - */ const API_PATH = "/api"; const isApiPath = (pathname: string): boolean => diff --git a/packages/vitnode/src/tanstack/i18n/route-messages.tsx b/packages/vitnode/src/tanstack/i18n/route-messages.tsx index 36935a38c..131859468 100644 --- a/packages/vitnode/src/tanstack/i18n/route-messages.tsx +++ b/packages/vitnode/src/tanstack/i18n/route-messages.tsx @@ -10,66 +10,6 @@ import { useLocale } from "./locale"; import { GLOBAL_NAMESPACE, intlQueryOptions } from "./query"; import { getIntlRuntime } from "./runtime"; -/** - * The strings one route renders, scoped to that route. - * - * The root provides `core.global` and nothing else, deliberately: the merged - * message tree holds every plugin's AdminCP copy, and a page should ship only - * the branches it actually renders. This is the other half of that rule - the - * TanStack Start counterpart of `<I18nProvider namespaces={[...]}>`, which is - * how the Next.js pages have always done it. - * - * With no `namespaces` it provides exactly `core.global`, which is what a root - * route wants: mounted once above every page, it is the shell's provider. A - * route mounts a second one underneath with its own namespaces, and the inner - * pair wins for the strings it names. - * - * ## It reads, it does not fetch - * - * `useSuspenseQuery` over the same `intlQueryOptions` the route's loader - * already warmed, so on the first render the entry is there and nothing - * suspends. A route that mounts this **must** ensure the identical options in - * its loader - same locale, same namespaces - or the first paint is a suspend - * and the strings arrive a round trip late. - * - * ## Why three providers - * - * One component, up to three module records. Under a host's `vite dev`, - * `@vitnode/core` is external to that app's SSR pass and therefore loaded by - * Node, which resolves `use-intl` to its `default` (production) build, while the - * app's own source runs through Vite's module runner, which resolves the same - * dependency with the `development` condition. Same version, same `node_modules` - * entry, two files - and `createContext` runs once per file, so they are two - * React contexts. - * - * Both of the providers *this file* imports are on the package's side of that - * split, because this file is: `use-intl` and `@/lib/i18n/provider` are resolved - * by whatever loaded the package. The pair is still worth mounting - the second - * is by construction the record every shared component reads, including the - * design-system components that used to reach it through `next-intl` - but - * neither of them is the record the *host's* own components read. Nothing here - * can import that one, so the host registers it: - * - * configureIntl({ ..., hostIntlProvider: IntlProvider }) // from its use-intl - * - * and it goes on the outside. Without it a host route that calls - * `useTranslations` itself throws "No intl context found" on the server, React - * falls back to client rendering, and the page renders anyway - a 500 in the - * console and a silent loss of SSR for that route. `HostIntlProvider` in - * `./runtime` carries the rest of the argument. - * - * A production build resolves the dependency once and collapses all three into - * one component around identical props, which is exactly what makes this a - * `vite dev`-only failure the built server never shows. - * `provider-records.test.ts` guards the arrangement on the source, because - * nothing that runs under Vitest can reproduce two records. - * - * All three get the same props from one object: two providers that disagreed - * would render half a page in the wrong language. This is also why they belong - * *here*, at the route boundary, rather than around each component that - * translates - a leaf that mounted its own would be the one place the route's - * namespaces could go missing. - */ export const RouteMessages = ({ children, namespaces = [GLOBAL_NAMESPACE], diff --git a/packages/vitnode/src/tanstack/i18n/runtime.test.ts b/packages/vitnode/src/tanstack/i18n/runtime.test.ts index 7a5be7323..b2806aa43 100644 --- a/packages/vitnode/src/tanstack/i18n/runtime.test.ts +++ b/packages/vitnode/src/tanstack/i18n/runtime.test.ts @@ -6,23 +6,6 @@ import type { IntlMessages } from "./runtime"; import { configureIntl, getIntlRuntime, resetIntlRuntime } from "./runtime"; -/** - * The i18n runtime's registration lifecycle - the third module-scope bridge in - * this package, and the one that holds the most. - * - * `setAuthTransport` and `setAdminTransport` hold a function each. This holds a - * language list, a default, a time zone, a message fetcher and - under `vite - * dev` - the host's own `IntlProvider`. All of them are decided when the app is - * built and none of them varies between requests, which is the argument for a - * module-level value on a server rendering many visitors at once. - * - * Which makes the reload question sharper rather than softer: everything here is - * *derived* at registration time (`localeRoutingFromConfig`, and an `isLocale` - * that closes over the result), so a registry that kept its first answer would - * hold a locale table built from a config the author has since edited. The - * transitions below pin that it does not. - */ - const i18nConfig = ( locales: string[], defaultLocale: string, @@ -47,11 +30,6 @@ describe("before the host configures it", () => { expect(() => getIntlRuntime()).toThrow(/not configured/); }); - /** - * The message names the fix, because an app that reached here without - * configuring is one whose router entry does not import its i18n module - and - * every string it renders would otherwise be silently English. - */ it("names the call that fixes it", () => { resetIntlRuntime(); @@ -121,13 +99,6 @@ describe("a new registration after a hot reload", () => { expect(getIntlRuntime().fetchMessages).toBe(after); }); - /** - * The stale-closure case that matters most here, because `isLocale` is not a - * value but a *closure over the derived routing*. Adding a language to the - * config has to make the new one supported - a registry that kept the first - * closure would keep rejecting it, and the symptom would be a 404 on a URL the - * config plainly allows. - */ it("rebuilds the locale table when the language list changes", () => { configureIntl({ fetchMessages: fetcherNamed("before"), @@ -157,11 +128,6 @@ describe("a new registration after a hot reload", () => { expect(getIntlRuntime().defaultLocale).toBe("pl"); }); - /** - * A caller that reads at call time - which every caller in this namespace does - * - sees the replacement. Nothing captures the runtime at module scope, and - * this is the assertion that says so. - */ it("leaves no caller holding the previous runtime", () => { const readNow = () => getIntlRuntime(); @@ -204,11 +170,6 @@ describe("a new registration after a hot reload", () => { }); describe("the host's own IntlProvider", () => { - /** - * Optional, and only load-bearing under `vite dev` - but it is registered - * through the same slot as everything else, so it has to survive a - * replacement the same way. See `./route-messages.tsx`. - */ it("is replaced along with the rest of the runtime", () => { const before = () => null; const after = () => null; diff --git a/packages/vitnode/src/tanstack/i18n/runtime.ts b/packages/vitnode/src/tanstack/i18n/runtime.ts index 05a8aba22..4b349b571 100644 --- a/packages/vitnode/src/tanstack/i18n/runtime.ts +++ b/packages/vitnode/src/tanstack/i18n/runtime.ts @@ -8,63 +8,15 @@ import { localeRoutingFromConfig } from "@/lib/i18n/locale-routing"; /** One language's messages for one set of namespaces. */ export interface IntlMessages { locale: string; - /** - * The picked message tree, as `use-intl`'s own shape rather than a bare - * `object`. - * - * It matters at both ends. `createTranslator` constrains its messages to an - * indexable type, so an `object` there collapses every key it could translate - * to `never` - which is how a route resolves its own metadata strings. And a - * server function's return type has to prove itself serializable, which - * `Record<string, unknown>` cannot: `unknown` might be a function. A tree of - * strings can. - */ + messages: AbstractIntlMessages; } -/** - * How the host fetches one language's messages. - * - * The one thing this package cannot own. Reading messages means reading JSON out - * of each package's `dist`, so the call has to reach a server - and in TanStack - * Start that means `createServerFn`, which the Start compiler must transform in - * *both* the client and the server bundle. This package is externalised from the - * host's SSR pass, so its code reaches the server un-compiled and a server - * function declared here would silently resolve to `undefined` during SSR. See - * `packages/vitnode/src/tanstack/boundary.test.ts`. - * - * So the host declares the server function and hands it over as this, which is - * an ordinary async function on both sides of the render. - */ export type IntlMessagesFetcher = (input: { locale: string; namespaces: readonly string[]; }) => Promise<IntlMessages>; -/** - * A second `IntlProvider`, from the *host application's* copy of `use-intl`. - * - * Optional, and only ever load-bearing under `vite dev`. This package is - * external to a host's SSR pass (`ssr.external` in its `vite.config.ts`), so - * Node loads it and resolves `use-intl` to that package's `default` build, while - * the host's own source goes through Vite's module runner and resolves the same - * dependency with the `development` condition. Same version, same - * `node_modules` entry, two files - and `createContext` runs once per file, so - * they are two React contexts. Everything this package renders reads the first; - * anything the host renders from its own `useTranslations` reads the second. - * - * `RouteMessages` can only mount the record it was itself loaded with, so the - * host hands its own in here - once, beside the other registrations that cannot - * live in a package - and every `RouteMessages` in the tree provides both. A - * production build resolves the dependency once and the two collapse into one - * component, where the extra wrapper is an identical provider around identical - * props and changes nothing. - * - * Leaving it out is safe in the sense that nothing throws at registration time, - * and unsafe in the sense that the first host component to call - * `useTranslations` throws "No intl context found" during a dev render. See - * `provider-records.test.ts`. - */ export type HostIntlProvider = React.ComponentType<{ children: React.ReactNode; locale: string; @@ -88,45 +40,12 @@ export interface IntlRuntime { /** Narrows a string - a URL segment, a cookie, a `<select>` value. */ isLocale: (value: null | string | undefined) => boolean; localeRouting: LocaleRouting; - /** - * Explicit, because an app renders on a server: without one, `use-intl` - * formats dates in whatever zone the server happens to run in and warns that - * the client will disagree. - */ + timeZone?: string; } -/** - * The host's answer, held for the process. - * - * Module-scope mutable state, which is worth justifying rather than hiding. What - * it holds is not request state and never varies between requests: an app's - * language list, its default, its time zone, and a reference to the server - * function that fetches messages. All four are decided once when the app is - * built. A second request cannot see a different value, so the usual objection - * to a module-level singleton on a server - one visitor's state leaking into - * another's render - does not apply. - * - * It is the same shape `buildConfig` / `getVitNodeConfig` already uses for the - * app config, for the same reason: framework-owned code needs the app's values - * without every route prop-drilling them. The difference is that this one is - * also read in the browser, so it is registered from a module both the router - * entry and the start entry import rather than from the server-only config. - */ let runtime: IntlRuntime | undefined; -/** - * Hands this package the app's languages and its message fetcher, once. - * - * Call it at module scope from a module the router entry imports - that is what - * makes it impossible for a route to run before it. Everything else in - * `@vitnode/core/tanstack/i18n` reads what it registers, so a route file imports - * `RouteMessages` and `intlQueryOptions` straight from the package with no - * configuration of its own. - * - * Returns the derived runtime as well as registering it, so the caller gets - * `localeRouting` and `isLocale` back without deriving them a second time. - */ export const configureIntl = ({ fetchMessages, hostIntlProvider, @@ -146,14 +65,6 @@ export const configureIntl = ({ return runtime; }; -/** - * What {@link configureIntl} registered. - * - * Throws rather than falling back to a default language, and the message names - * the fix: an app that reached here without configuring is one whose router - * entry does not import its i18n module, and every string it renders would - * otherwise be silently English. - */ export const getIntlRuntime = (): IntlRuntime => { if (!runtime) { throw new Error( diff --git a/packages/vitnode/src/tanstack/i18n/server-config-messages.test.ts b/packages/vitnode/src/tanstack/i18n/server-config-messages.test.ts index 25a68ad9e..b3badcc1f 100644 --- a/packages/vitnode/src/tanstack/i18n/server-config-messages.test.ts +++ b/packages/vitnode/src/tanstack/i18n/server-config-messages.test.ts @@ -8,18 +8,6 @@ vi.mock("@tanstack/react-start/server-only", () => ({})); const { buildConfig, buildServerConfig } = await import("@/vitnode.config"); const { createIntlMessagesLoader } = await import("./messages"); -/** - * How the two halves of an app's config meet. - * - * The languages live in the browser-safe `vitnode.config.ts`; the loaders that - * read a package's JSON live in the server-only `vitnode.server.config.ts`. The - * loader is where they are resolved against each other, so what these tests pin - * is that handing it the server config is the same thing as spelling the four - * options out - including `defaultLocale`, which comes from the *shared* half - * and is what makes a half-translated language degrade key by key rather than - * rendering raw keys. - */ - const messages = (tree: Record<string, unknown>) => async () => await Promise.resolve({ default: tree }); diff --git a/packages/vitnode/src/tanstack/i18n/server.ts b/packages/vitnode/src/tanstack/i18n/server.ts index b046b700a..49470b0b6 100644 --- a/packages/vitnode/src/tanstack/i18n/server.ts +++ b/packages/vitnode/src/tanstack/i18n/server.ts @@ -1,14 +1,5 @@ import "@tanstack/react-start/server-only"; -/** - * The half of VitNode's TanStack i18n runtime that only ever runs on a server. - * - * Two things, and both are here rather than in the client-safe barrel for the - * same reason: they reach the message files inside each package's build output, - * and the plugin registry they are merged from must never reach a browser - * bundle. The `server-only` marker above turns "somebody imported this from a - * component" into a build error rather than a mystery in the client build. - */ export type { BundledMessagesOptions, IntlMessagesLoader, diff --git a/packages/vitnode/src/tanstack/i18n/switch-locale.ts b/packages/vitnode/src/tanstack/i18n/switch-locale.ts index 087c385cf..112d788a6 100644 --- a/packages/vitnode/src/tanstack/i18n/switch-locale.ts +++ b/packages/vitnode/src/tanstack/i18n/switch-locale.ts @@ -15,21 +15,6 @@ import { getIntlRuntime } from "./runtime"; */ const RELATIVE_BASE = "https://vitnode.invalid"; -/** - * Puts a language's messages in the cache before anything renders in it. - * - * Every set the page is currently showing, not just the global one. The root - * provides `core.global`; a route provides whatever it renders on top of that - * (`RouteMessages`), and both read through `useSuspenseQuery`. Warming only the - * first would leave the second suspending on a key nobody had fetched - which, - * because the suspend is caused by a store update, cannot be deferred: the page - * blanks for a round trip. `loadedIntlNamespaces` answers "which sets" by - * reading the cache, so this stays right as more routes declare their own. - * - * Failure is deliberately not fatal: the switch still happens, and each - * provider's own query retries it. A language that cannot be fetched should - * degrade to a moment of loading, not to a switcher that appears to do nothing. - */ const warmMessages = async (router: AnyRouter, locale: string) => { const { queryClient } = router.options.context as { queryClient?: QueryClient; @@ -51,30 +36,6 @@ const warmMessages = async (router: AnyRouter, locale: string) => { ); }; -/** - * Switches the page's language, keeping the visitor exactly where they are. - * - * /discover -> pl -> /pl/discover - * /pl/discover?q=hello -> en -> /discover?q=hello - * /admin/users -> pl -> /admin/users (only the language changes) - * - * Why `history.push` rather than `navigate()`: the locale lives *only* in the - * public URL. Internally `/discover` and `/pl/discover` are the same location, - * and `commitLocation` compares internal hrefs to decide whether anything moved - * - so a `navigate()` to the same route is a no-op and the address bar never - * changes. Pushing the public href is what the router itself does at the end of - * every navigation (`this.history.push(nextHistory.publicHref)`), so this is the - * same client-side transition, not a document reload. - * - * `invalidate()` then re-runs the matched routes. Two reasons: the internal URL - * did not change, so nothing looks stale to the router even though every loader - * that read `context.locale` now holds the previous answer - and on an ignored - * route such as `/admin`, where the URL does not change at all, it is the whole - * of the switch. - * - * Written as a plain function over a router rather than only as a hook, so the - * behaviour above is testable without mounting React. - */ export const switchLocaleOn = async ( router: AnyRouter, locale: string, diff --git a/packages/vitnode/src/tanstack/layout/error-actions.tsx b/packages/vitnode/src/tanstack/layout/error-actions.tsx index a9939d7eb..a851413d4 100644 --- a/packages/vitnode/src/tanstack/layout/error-actions.tsx +++ b/packages/vitnode/src/tanstack/layout/error-actions.tsx @@ -11,29 +11,6 @@ import { cn } from "@/lib/utils"; import { RouterLink } from "./router-link"; -/** - * "Go back" and "go home", for a screen that ends in a dead end. - * - * The TanStack half of what `ErrorViewActions` renders in Next.js: the same two - * buttons and the same two strings, with this framework's navigation behind - * them. The shared error screens take their actions as a slot precisely because - * this is the part that cannot be shared - `router.history.back()` here, - * `next-intl`'s `useRouter().back()` there. - * - * A component rather than a snippet because more than one screen outside the - * main shell needs exactly it: an SSO callback's failure states, and the 404 a - * reset-password page shows on an install with no email adapter. Copied into the - * second of those, the two would have drifted the first time either string - * changed. - * - * `core.global` is provided by the host's root for every page, so this renders - * correctly without a route-scoped message provider above it - which matters, - * because a `notFoundComponent` replaces the component that would have mounted - * one. - * - * Declare it at module scope wherever it is used, so it is the same component - * type on every render. - */ export const ErrorActions = ({ LinkComponent = RouterLink, }: { diff --git a/packages/vitnode/src/tanstack/layout/header.tsx b/packages/vitnode/src/tanstack/layout/header.tsx index a2945e967..bbf19e2ea 100644 --- a/packages/vitnode/src/tanstack/layout/header.tsx +++ b/packages/vitnode/src/tanstack/layout/header.tsx @@ -20,107 +20,22 @@ import { intlQueryOptions } from "../i18n/query"; import { LanguageSwitcher } from "./language-switcher"; import { RouterLink } from "./router-link"; -/** - * What the header renders strings from - and *only* what the header renders. - * - * Two words: the `Discover` and `Search` labels in the nav. `core.global` is - * deliberately not here, and that is the whole point of the list. - * - * A namespace set is part of the query key, so asking for `["core.global", - * "core.search"]` is a *different cache entry* from the `["core.global"]` the - * root route already ensured - not a superset of it. Listing the global set - * here therefore bought nothing and cost twice: a second request for messages - * the root had already fetched, and a second copy of every global string - * dehydrated into the HTML of every page on the site. - * - * Nothing regresses by leaving it out, because nothing in this component reads - * it. The theme switcher and the language switcher translate through the - * provider the host's root mounts over `core.global`, which is above every - * route; the two nav labels are read from *this* query with `createTranslator` - * and never through a provider. See {@link Header}. - */ export const HEADER_NAMESPACES = ["core.search"] as const; -/** - * The messages the header renders, as a query the shell's loader can ensure. - * - * Exported so the loader and the component cannot ask for different sets: the - * namespace list is part of the query key, so a shell that warmed - * `["core.global"]` would leave the header suspending on a key nobody fetched. - */ export const headerIntlQueryOptions = ({ locale }: { locale: string }) => intlQueryOptions({ locale, namespaces: HEADER_NAMESPACES }); -/** - * The branch of the message tree the nav reads, named for `createTranslator`. - * - * The translator's key type is derived from the *inferred* type of `messages`, - * and the query resolves to `use-intl`'s bare index signature - which cannot - * tell a leaf from a branch, so every key it could translate widens to something - * that checks nothing. Naming the two keys this component actually looks up is - * both the smallest fix and a true statement: reword one in - * `core/locales/en.json` and this stops compiling rather than rendering a raw - * message key into the bar. - */ interface HeaderNavMessages { core: { search: { nav: { discover: string; search: string } } }; } -/** - * The main header, on TanStack Start. - * - * The bar, the logo, the nav and the action area are `HeaderLayoutContent` - the - * same module the Next.js pages render, so there is one copy of that markup - * rather than one per framework. What this adds is the orchestration every - * VitNode TanStack application needs and none of them should write again: the - * locale, the header's own message set, the translated nav and the language - * switcher. - * - * ## What the shell owes it - * - * One warm cache entry: **the shell's loader must ensure - * {@link headerIntlQueryOptions}**. It is a `useSuspenseQuery` with no boundary - * between it and the document, so an unwarmed entry does not degrade - it - * suspends the whole response. Warming it is one line, and it is the same rule - * every route already follows for its own namespaces. - * - * No message provider is mounted here. `core.global` - which the theme switcher - * and the language switcher read - is provided by the host's root, and the two - * extra words the nav needs are not worth replacing the message tree over. They - * are read straight off this query with `createTranslator` instead, which is - * why this set holds `core.search` alone - see {@link HEADER_NAMESPACES}. - * - * ## The two slots a host actually fills - * - * `logo`, because the mark belongs to the application rather than to VitNode, - * and `LinkComponent`, for a host that needs a link built some other way. Both - * have defaults that are right for an ordinary install, so a plain `<Header />` - * works and most hosts pass only the mark. - */ export const Header = ({ LinkComponent = RouterLink, logo = <LogoVitNodeBrand />, user, }: { - /** - * How a header path becomes a navigation. Defaults to the router's own `Link`. - * - * Overridden only by a host that builds links differently - one mounting - * VitNode under a path prefix, say. An ordinary install wants the default. - */ LinkComponent?: HeaderLinkComponent; - /** - * The application's mark. Defaults to VitNode's own, responsively. - * - * The default is deliberately `LogoVitNodeBrand` rather than the - * `<LogoVitNode className="w-34" />` the Next.js layouts pass: a Next.js app - * writes that class in its own `layout.tsx`, where Tailwind scans it, and a - * TanStack host writes nothing - the class would exist only inside this - * package's compiled `dist`, which an app's `@source` list has to be told - * about. See `apps/web/src/styles.css`, which now is. The brand component also - * answers the question that class never did, which is what a 136px wordmark - * should do on a 320px-wide bar. - */ + logo?: React.ReactNode; /** The session slot - avatar and menu when signed in, sign-in button when not. */ user?: React.ReactNode; @@ -148,35 +63,6 @@ export const Header = ({ ); }; -/** - * What the site header needs, warmed before anything renders. - * - * Both entries are an app's canonical ones - the same query definitions its - * routes and its auth guards already use - so this adds no second key and no - * second request. What it adds is *timing*: the header sits above every page in - * the shell, so anything it reads has to be in hand before the first paint or - * the shell pays a round trip that the page below it did not. - * - * ## One `ensure`, one `prefetch`, and the difference matters - * - * `ensureQueryData` for the messages, because `Header` reads them with - * `useSuspenseQuery` and there is no Suspense boundary between it and the - * document. An unwarmed entry there does not degrade - it suspends the whole - * response. The namespace list is part of the query key, which is why the - * options come from `headerIntlQueryOptions` rather than being spelled out: a - * loader that warmed a different set would warm a key nobody reads. - * - * `prefetchQuery` for the session, through `prefetchSession`, because a failure - * must not take the page down with it. `ensureAuthState` is the wrong tool here - * in one specific way: it *rejects* when the session cannot be read, which is - * exactly right for a guard - an outage must not sign anybody out - and exactly - * wrong for a shell, where the same rejection would replace every page on the - * site with an error screen because the header could not name the visitor. - * Prefetching records the failure in the cache entry instead, and - * `userHeaderState` renders it as the guest controls. - * - * `Promise.all`, because neither read depends on the other. - */ export const loadMainShell = async ({ locale, queryClient, diff --git a/packages/vitnode/src/tanstack/layout/index.ts b/packages/vitnode/src/tanstack/layout/index.ts index 5c33ec2e2..ec4ac1c21 100644 --- a/packages/vitnode/src/tanstack/layout/index.ts +++ b/packages/vitnode/src/tanstack/layout/index.ts @@ -1,16 +1,3 @@ -/** - * The application shell of a VitNode TanStack Start host. - * - * What a host composes rather than writes: the header and its user area, the - * language switcher, and the two buttons a dead-end screen ends with. Everything - * here is orchestration over components the Next.js applications already render, - * so the two frameworks cannot drift into two headers. - * - * The pieces a host still owns are the ones only it can answer - its route tree - * and its mark - plus, for a host that needs it, how a path becomes a - * navigation. Each of those is a prop with a default that is correct for an - * ordinary install. - */ export { ErrorActions } from "./error-actions"; export { Header, @@ -25,10 +12,4 @@ export { VitNodeRootProviders } from "./root-providers"; export { RouterLink } from "./router-link"; export { UserHeader } from "./user-header"; -/** - * The shell's own frame - listeners, header, breadcrumb, `<main>` - re-exported - * so a TanStack route reaches it through this barrel rather than through - * `views/`. It is the half of `ThemeLayout` that renders no Next.js, and the - * Next apps keep it through `ThemeLayout` itself. - */ export { ThemeLayoutContent } from "@/views/layouts/theme/layout-content"; diff --git a/packages/vitnode/src/tanstack/layout/language-switcher.tsx b/packages/vitnode/src/tanstack/layout/language-switcher.tsx index 954c83e57..9984f89df 100644 --- a/packages/vitnode/src/tanstack/layout/language-switcher.tsx +++ b/packages/vitnode/src/tanstack/layout/language-switcher.tsx @@ -6,29 +6,6 @@ import { LanguageSwitcherContent } from "@/components/switchers/langs/language-s import { useLocale } from "../i18n/locale"; import { useSwitchLocale } from "../i18n/switch-locale"; -/** - * VitNode's language switcher, for TanStack Router. - * - * The dropdown, the icon, the check mark and the `core.global.language_switcher` - * label are `LanguageSwitcherContent`'s - literally the same component the - * Next.js applications render, so the control cannot drift between frameworks. - * What is forked is the two lines that navigate: core's Next half replaces the - * pathname through `next-intl`'s locale-aware router, and this one goes through - * `useSwitchLocale`, which pushes the public href and invalidates. - * - * What that preserves is the whole point: the route, its params, its search - * string and its hash. Only the locale prefix changes - and on a route that - * carries no prefix (`/admin`) the cookie is the whole of the switch. All of - * that rule lives in `@vitnode/core/tanstack/i18n`, not here. - * - * ## It renders nothing on a single-language install - * - * The list comes from `LanguagesProvider`, which a host's root providers already - * mount from configuration - so this is the same answer the Next.js header - * reaches, from the provider that was given the list rather than from the config - * file. One language means nothing to switch to, and the header should not have - * to ask before rendering it. - */ export const LanguageSwitcher = () => { const languages = useLanguages(); const locale = useLocale(); diff --git a/packages/vitnode/src/tanstack/layout/main-header.tsx b/packages/vitnode/src/tanstack/layout/main-header.tsx index a43a7e7ab..3449c0aaf 100644 --- a/packages/vitnode/src/tanstack/layout/main-header.tsx +++ b/packages/vitnode/src/tanstack/layout/main-header.tsx @@ -5,27 +5,6 @@ import type { AuthLinkComponent } from "@/views/auth/auth-link"; import { Header } from "./header"; import { UserHeader } from "./user-header"; -/** - * The site header, as a shell's slot for it. - * - * Two components and no logic, which is the point: {@link Header} is the bar - - * the logo, the nav, the language and theme switchers - and {@link UserHeader} - * is the session-dependent half that goes in its action area. Keeping them - * separate is what lets the bar render from the message cache alone while the - * user area reads a query that may still be in flight. - * - * Both halves take the same link component, so a host that overrides it names - * it once rather than in two places that could disagree. - * - * ## What the shell owes this - * - * Two warm cache entries, both from its own loader: - * - * headerIntlQueryOptions -> a `useSuspenseQuery`, so this is required - * prefetchSession -> the first paint shows the visitor, not a gap - * - * See each component for why one is `ensure` and the other `prefetch`. - */ export const MainHeader = ({ LinkComponent, logo, diff --git a/packages/vitnode/src/tanstack/layout/not-found.tsx b/packages/vitnode/src/tanstack/layout/not-found.tsx index 4f63ddf25..8c46a5c53 100644 --- a/packages/vitnode/src/tanstack/layout/not-found.tsx +++ b/packages/vitnode/src/tanstack/layout/not-found.tsx @@ -4,42 +4,6 @@ import { useTranslations } from "use-intl"; import { ErrorContent } from "@/views/error/error-content"; -/** - * A URL this application does not serve. - * - * The message, and only the message: a code, the two strings that explain it, - * and a slot for the way out. What a host mounts it *inside* is the host's - - * see below, because the two places it is used answer that differently. - * - * ## Where it belongs on a route tree - * - * On the **root** route, which is what makes it the answer for every URL nothing - * matched. Without a `notFoundComponent` there (or a router-level - * `defaultNotFoundComponent`) TanStack Router falls back to its own bare - * `<p>Not Found</p>` and warns about it on every such navigation - which is what - * a hand-typed `/blog/post-30`, `/admin/contents` or any other unrouted path - * used to get. - * - * A nested route declares its own only when the answer differs in kind rather - * than in wording. `AdminNotFound` is the one that does: it renders this same - * message, but the route that mounts it has to put the AdminCP shell back - * around it, because a `notFoundComponent` renders *instead of* its route's - * component and the panel would otherwise disappear. - * - * ## Why the strings are read here and the buttons are not - * - * `core.global` carries `errors.404.title` and `errors.404.desc`, and every - * VitNode host provides it above every route - `VitNodeRootProviders` mounts - * `RouteMessages` with exactly that namespace - so this can translate itself - * wherever it is mounted. - * - * `actions` cannot work the same way. "Go back" and "go home" are navigation, - * and during the strangler migration `/` is served by the Next.js application on - * some installs and by this one on others - a fact only the host's route tree - * knows. So it is a slot, filled with `ErrorActions` bound to whatever link - * component that host uses. The same split `ErrorContent` itself makes, one - * layer down. - */ export const NotFound = ({ actions }: { actions?: React.ReactNode }) => { const t = useTranslations("core.global"); diff --git a/packages/vitnode/src/tanstack/layout/root-providers.tsx b/packages/vitnode/src/tanstack/layout/root-providers.tsx index f7ee3a8fa..8bdd317b6 100644 --- a/packages/vitnode/src/tanstack/layout/root-providers.tsx +++ b/packages/vitnode/src/tanstack/layout/root-providers.tsx @@ -8,37 +8,6 @@ import { VitNodeWebSocketProvider } from "@/ws/provider"; import { RouteMessages } from "../i18n/route-messages"; import { RealtimeListeners } from "../realtime/realtime-listeners"; -/** - * The provider tree every VitNode TanStack Start application mounts, once, - * above every route. - * - * A root route's `component` is this and its `<Outlet />`. Everything in it has - * the lifetime of the document rather than of any route, which is the whole - * reason it is here and not in a shell: a login screen needs the theme, the - * query cache's intl records and the WebSocket exactly as much as a page inside - * the site header does. - * - * ## Why the messages come from `RouteMessages` - * - * With no `namespaces` it provides exactly `core.global` - the shell strings a - * root loader warms - which is what a root route wants. It is the same component - * a page mounts for its own namespaces, and using it here rather than mounting - * `use-intl`'s provider directly is what keeps an application from owning a - * second copy of a rule it has already been bitten by: the provider has to be - * mounted *twice*, into two `use-intl` module records, and only this package can - * name the second one. See the long note in `../i18n/route-messages`. - * - * ## Why `RealtimeListeners` is inside it rather than in the main shell - * - * It is the one non-provider in this tree, and it is here for the same reason - * every provider is: its lifetime is the WebSocket connection's, not any - * route's. A main shell is not mounted on `/login`, so a sign-in resync that - * lived there would not exist during the sign-in it has to notice. Inside the - * provider, because that is the context it reads. - * - * The QueryClient is deliberately absent: the router owns it and the SSR - * integration mounts its provider above this tree. - */ export const VitNodeRootProviders = ({ children, config, diff --git a/packages/vitnode/src/tanstack/layout/router-link.tsx b/packages/vitnode/src/tanstack/layout/router-link.tsx index fb3c3fa39..0b98300f2 100644 --- a/packages/vitnode/src/tanstack/layout/router-link.tsx +++ b/packages/vitnode/src/tanstack/layout/router-link.tsx @@ -4,32 +4,6 @@ import { Link } from "@tanstack/react-router"; import type { AuthLinkProps } from "@/views/auth/auth-link"; -/** - * The router's own `Link`, in the shape every shared VitNode view asks for. - * - * The shared views take a `LinkComponent` because turning `/settings` into a - * navigation is the one question whose answer differs between frameworks. This - * is the plain TanStack Router answer, and it is what the components in this - * namespace fall back to when a host does not pass one. - * - * A host passes its own only when it has a reason to - an application that - * mounts VitNode under a path prefix, or one that wants a link decorated. The - * default is correct for an ordinary install, which is why most call sites here - * omit the prop entirely. - * - * One case it deliberately does *not* handle: an href that names another origin. - * `<Link to="https://status.example.com">` asks the router to match an absolute - * URL against a route tree, which answers with something broken rather than with - * the site the href named. A plugin's `admin.nav` entry may legitimately point - * at one, so the AdminCP classifies before it renders - see `adminLinkFor` in - * `views/admin/layouts/admin-link`. A `LinkComponent` takes a *path*, in every - * framework, and that stays true here. - * - * `href` in, `to` out, and nothing else: the rest of the anchor's props - the - * class name, the children, the ref a Base UI `render` clones onto it - pass - * straight through, which is the whole reason {@link AuthLinkProps} is the - * shared shape rather than `{ href }`. - */ export const RouterLink = ({ children, href, ...props }: AuthLinkProps) => ( <Link {...props} to={href}> {children} diff --git a/packages/vitnode/src/tanstack/layout/user-header.tsx b/packages/vitnode/src/tanstack/layout/user-header.tsx index ab336fbbb..3da8e84fd 100644 --- a/packages/vitnode/src/tanstack/layout/user-header.tsx +++ b/packages/vitnode/src/tanstack/layout/user-header.tsx @@ -13,57 +13,6 @@ import { useSignOutAction } from "../auth/actions"; import { sessionQueryOptions } from "../auth/session-query"; import { RouterLink } from "./router-link"; -/** - * The user area of the main header, on TanStack Start. - * - * Everything visible is `UserHeaderContent`'s - the avatar, the menu, the guest - * buttons and the placeholder are the same components the Next.js header - * renders. What is here is the three things that component refuses to decide: - * - * the session -> sessionQueryOptions() the one canonical entry - * a link -> LinkComponent the router's, or the host's - * sign-out -> useSignOutAction() the shared auth action - * - * ## One session, read - not fetched - * - * `useQuery` over {@link sessionQueryOptions}, which is the *same definition* - * that a route guard calls through `ensureAuthState`. One key, one cache entry, - * one request: the header cannot show a visitor the guard has already turned - * away, and signing in or out replaces the value both of them read. There is no - * auth context, no module-level session and no second call to `/users/session`. - * - * `useQuery` rather than `useSuspenseQuery` on purpose. The header is on every - * page, and suspending it would suspend the shell: with the entry warm both read - * from the cache with no round trip, but when it is *not* warm - a client-side - * arrival at a route whose loader did not ask for it - `useQuery` renders the - * placeholder while `useSuspenseQuery` would hold back the whole page for a - * session only the header needs. - * - * ## What the shell owes it: one line - * - * `prefetchSession(context.queryClient)` in the shell's loader. With it, the - * entry is filled before anything renders, the SSR pass dehydrates it, and the - * very first paint shows the visitor - no placeholder, no shift, and no round - * trip after hydration. Without it this still works, and works correctly: the - * server renders the placeholder and the browser fills it in a moment later. So - * it is a performance requirement rather than a correctness one, which is why - * this component does not try to enforce it. - * - * It is deliberately `prefetchSession` and not `ensureAuthState`. The latter - * *rejects* when the session cannot be read - correct for a guard, because an - * outage must not sign anybody out - and in a shell's loader that same rejection - * would replace every page on the site with an error screen because the header - * could not name the visitor. Both go through `sessionQueryOptions()`, so it is - * still one cache entry either way. - * - * ## The three states are `userHeaderState`'s to name - * - * Including the one that matters here: a session already in hand wins over an - * error, so a failed *refetch* does not flicker a signed-in visitor to anonymous - * and back. A read that has failed with nothing cached shows the guest controls, - * which is what the Next.js header has always done - and which is emphatically - * not what a route guard does with the same failure. - */ export const UserHeader = ({ LinkComponent = RouterLink, }: { @@ -74,22 +23,6 @@ export const UserHeader = ({ const signOut = useSignOutAction(); const tErrors = useTranslations("core.global.errors"); - /** - * Sign-out, and the one thing the shared action leaves to its caller. - * - * The action already does all of it - `DELETE /sign_out`, the cookie cleared - * by the API's own `Set-Cookie`, the anonymous session written into the - * canonical entry, that entry invalidated, and `router.invalidate()` so a - * visitor sitting behind a guard is redirected out by the route that owns that - * rule. Nothing is repeated here: no second `invalidate`, no `setQueryData` of - * anything else, no navigation of our own. - * - * What is left is the failure, which the action reports rather than throws. A - * session that could not be ended is a server problem the visitor cannot act - * on, so it is the internal-error toast - the same one the sign-in form raises - * for the same class of answer - and the header stays as it was, which is - * honest: they are still signed in. - */ const onSignOut = async () => { const result = await signOut(); diff --git a/packages/vitnode/src/tanstack/metadata/index.ts b/packages/vitnode/src/tanstack/metadata/index.ts index 06b2f18e3..8df8777f2 100644 --- a/packages/vitnode/src/tanstack/metadata/index.ts +++ b/packages/vitnode/src/tanstack/metadata/index.ts @@ -8,39 +8,10 @@ export type RouteRobots = "index, follow" | "noindex, nofollow"; /** What kind of thing a page is, to a social card. */ export type RouteOpenGraphType = "article" | "website"; -/** - * The Open Graph a page declares, and the whole of it. - * - * Three fields, because three are what the Next.js routes this migration - * replaces actually set. No image, no canonical URL, no Twitter card and no site - * name: each of those is a decision with its own design - an OG image needs a - * renderer and a cache - and inventing one here would be a new SEO system rather - * than the metadata a page asked for. - * - * ## Nothing here is inherited from {@link RouteHeadOptions} - * - * An omitted field emits no tag. It would be easy to default `title` to the - * page's title and `description` to its description, and it would be wrong in a - * quiet way: `og:title` is *not* the document title - see below - so a default - * would silently publish the wrong string to every site that unfurls a link. - * Stating them is one line at the call site and no ambiguity anywhere. - */ export interface RouteOpenGraph { /** The `<meta property="og:description">`. */ description?: string; - /** - * The `<meta property="og:title">`, emitted **verbatim**. - * - * Deliberately not run through `formatPageTitle`, and this is the one rule on - * this page worth reading twice. The Next.js documentation routes produced - * - * <title>Routes - Plugins - VitNode - * - * - * because Next applies `title.template` to the document title and leaves - * `openGraph.title` alone. A card that read "Routes - Plugins - VitNode" over - * a link to vitnode.com would be saying the site's name twice. - */ + title?: string; /** `article` for a document, `website` for a landing page. */ type?: RouteOpenGraphType; @@ -49,47 +20,14 @@ export interface RouteOpenGraph { export interface RouteHeadOptions { /** The ``, when the page has one. */ description?: string; - /** - * What a link to this page unfurls as. See {@link RouteOpenGraph}; omitted - * entirely, the page emits no Open Graph at all, which is what every route but - * the documentation does today. - */ + openGraph?: RouteOpenGraph; - /** - * Stated rather than assumed: TanStack Start emits no robots directive of its - * own, and every Next.js route this migration replaces sets one explicitly. - * Omitted where a parent route already declares it - Router merges the `head` - * of every matched route and dedupes `meta` by `name`, preferring the deepest, - * so a child inherits by saying nothing. - */ + robots?: RouteRobots; /** The page's own title, already translated. */ title?: string; } -/** - * One route's `head`, in the shape every VitNode page wants. - * - * Written once here because it was written six times in `apps/web/src/routes`, - * identically apart from the robots value - and the part worth centralising is - * not the array literal but the two rules inside it. - * - * **The title goes through `formatPageTitle`**, which is the same - * `" - "` rule Next.js applies through `title.template`. A route that - * built its own would produce a title that disagreed with the Next.js half of - * the same site. - * - * **Everything is optional, because `loaderData` is.** A route's `head` runs - * before its loader has resolved on the very first pass, so `loaderData` is - * `undefined` there and spreading it is how a route passes what it has: - * - * head: ({ loaderData }) => pageHead({ robots: "index, follow", ...loaderData }) - * - * Spreading `undefined` is a no-op, so that one line covers both passes and no - * route needs a conditional. Extra keys a loader returns - a params object, a - * user id - are ignored rather than rejected, which is what lets a loader return - * one object for both its component and its head. - */ export const routeHead = ( metadata: VitNodeMetadata, { description, openGraph, robots, title }: RouteHeadOptions = {}, @@ -117,25 +55,8 @@ export const routeHead = ( ], }); -/** - * What a route's `head` returns - the shape {@link routeHead} produces. - * - * Named so that code which *takes* a bound `pageHead` can say so without - * restating the array literal. `@vitnode/core/tanstack/plugin-routes` is the one - * that needs it: a plugin route's metadata goes through the host's own binding, - * because the site's name is the one thing a package cannot know. - */ export type RouteHeadResult = ReturnType; -/** - * {@link routeHead}, bound to one app's metadata. - * - * The app's name is the one thing a package cannot know, and binding it once in - * the host means a route file names only what is actually route-specific. It is - * a plain closure rather than a registered singleton on purpose: `head` is - * evaluated during render, the binding is a two-line module, and there is no - * ordering question to get wrong. - */ export const createRouteHead = (metadata: VitNodeMetadata) => (options: RouteHeadOptions = {}) => diff --git a/packages/vitnode/src/tanstack/metadata/metadata.test.ts b/packages/vitnode/src/tanstack/metadata/metadata.test.ts index 9359460ab..0eaee66c0 100644 --- a/packages/vitnode/src/tanstack/metadata/metadata.test.ts +++ b/packages/vitnode/src/tanstack/metadata/metadata.test.ts @@ -3,27 +3,6 @@ import { describe, expect, it } from "vitest"; import { createRouteHead, routeHead } from "./index"; -/** - * What a VitNode route puts in the document head. - * - * Pure: `routeHead` takes an options object and returns an array, so every rule - * in it is testable without a router, a DOM or a render. What is *not* tested - * here is that TanStack emits the array - that is the framework's job, and - * `apps/web/src/tests/shell-config.test.ts` runs a real route's `head` through - * it. - * - * The Open Graph half arrived with Stage 16, because the documentation routes it - * replaced emitted it and the first port dropped it. The measured legacy output - * for `/docs/dev/plugins/api/routes` was: - * - * Routes - Plugins - VitNode - * - * - * - * - * The asymmetry between the two titles is the interesting part, and it has its - * own test below. - */ const METADATA = { shortTitle: "VitNode", title: "VitNode Community" }; const head = (options?: Parameters[1]) => @@ -110,13 +89,6 @@ describe("routeHead openGraph", () => { }); }); - /** - * The rule the whole extension exists for. - * - * `` goes through the site template and `og:title` does not, which is - * what the Next.js routes emitted. A card reading "Routes - Plugins - VitNode" - * over a link to vitnode.com would say the site's name twice. - */ it("leaves og:title un-templated while the document title is templated", () => { const meta = head(FULL); diff --git a/packages/vitnode/src/tanstack/pending/pending-navigation.test.tsx b/packages/vitnode/src/tanstack/pending/pending-navigation.test.tsx index 348e5f057..0a0f6b092 100644 --- a/packages/vitnode/src/tanstack/pending/pending-navigation.test.tsx +++ b/packages/vitnode/src/tanstack/pending/pending-navigation.test.tsx @@ -184,18 +184,6 @@ describe("the pending window", () => { }); }); -/** - * What the configured `defaultPendingMs: 150` actually buys, measured rather - * than assumed - because a zero threshold is the obvious-looking setting and it - * is the wrong one. - * - * At zero, every navigation opens a pending window, so `defaultPendingMinMs` - * applies to all of them: a loader that resolves in a microtask - a cached - * TanStack Query read, which is most of them - is still held behind a skeleton - * for the full minimum. A page that could have been instant is made slow in - * order to look busy. The threshold is what lets that navigation go straight - * through. - */ describe(`the configured threshold of ${PENDING_MS}ms`, () => { it("lets a navigation that beats it through with no skeleton at all", async () => { const router = routerFor(async () => await Promise.resolve(), { diff --git a/packages/vitnode/src/tanstack/plugin-routes/collision.ts b/packages/vitnode/src/tanstack/plugin-routes/collision.ts index 2ae8c4019..67f76ed33 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/collision.ts +++ b/packages/vitnode/src/tanstack/plugin-routes/collision.ts @@ -8,34 +8,10 @@ import type { PluginRouteSpec } from "./specs"; import { PLUGIN_ROUTES_ROUTE_ID } from "./container"; -/** - * A route's declared `path` and `id`, whichever of the two it has. - * - * `RouteOptions` is a union - a route declares a `path` *or* an `id`, never both - * - so neither field can be read off it directly even though every route object - * carries one of them. Both are optional here for exactly that reason. - */ export const declaredOptions = ( route: AnyRoute, ): { id?: string; path?: string } => route.options; -/** - * Every URL the host application's own route files already claim. - * - * Walked rather than read off the route tree's types, because a route's - * `fullPath` is only computed once the router initialises it and this runs - * before that. - * - * **Every route that declares a path claims one**, children or not. A TanStack - * route can be a page *and* a layout - `discover.tsx` with a `discover/` folder - * beside it renders at `/discover` and wraps everything under it - so treating a - * route with children as "just a layout" would quietly hand `/discover` to a - * plugin. A pathless route claims nothing, which is what pathless means: it - * contributes no segment and answers no URL, only its children do. - * - * The plugin subtree is skipped, so this stays the app's own answer no matter - * how many times the composition has run. - */ export const fileRoutePaths = (routeTree: AnyRoute): string[] => { const walk = (route: AnyRoute, prefix: string): string[] => { const { id, path } = declaredOptions(route); @@ -57,42 +33,6 @@ export const fileRoutePaths = (routeTree: AnyRoute): string[] => { ); }; -/** - * Refuses a plugin route that would answer a URL the application already - * answers. - * - * `buildPluginRouteManifest` already rejects two *plugins* claiming one URL and - * cannot see this case: it never knows which application it is being built for. - * Without this the app would hold two routes matching one pathname and let the - * router's own ranking pick, which is the "last route wins" outcome the manifest - * layer exists to make impossible. - * - * Compared by **match key, not by text**, and that is the whole substance of - * this function. `/users/$id` and `/users/:userId` are the same URL space - * spelled two ways in two syntaxes, and a string comparison sees two different - * strings: - * - * app /users/$id -> /users/: ┐ collide - * plugin /users/:userId -> /users/: ┘ - * - * app /users/new -> /users/new ┐ do not collide - a router tells - * plugin /users/:id -> /users/: ┘ static from dynamic - * - * Both sides go through the routing package's one key space: the plugin through - * `routeMatchKey` over its parsed segments, the app through - * `routeMatchKeyFromTanStackPath` over the string its router holds. Same rule as - * plugin-vs-plugin, because it is the same function. - * - * **Every** plugin route is checked, including layouts and index routes, and - * including nested ones - which is why the specs carry each route's canonical - * *full* path rather than only the fragment it adds. A layout at `/settings` - * beside an application's own `/settings` is two routes competing for one - * subtree even though the layout claims no page of its own. - * - * The first application path to claim a key is the one named in the error - the - * app's route files cannot collide with each other, so which one it is only - * affects the message. - */ export const assertNoAppCollision = ( specs: readonly PluginRouteSpec[], appPaths: readonly string[], diff --git a/packages/vitnode/src/tanstack/plugin-routes/components.test.tsx b/packages/vitnode/src/tanstack/plugin-routes/components.test.tsx index 3fb9e4397..61d6852ee 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/components.test.tsx +++ b/packages/vitnode/src/tanstack/plugin-routes/components.test.tsx @@ -6,21 +6,6 @@ import type { RouteBreadcrumbProps } from "../breadcrumb/model"; import { pluginRouteBreadcrumb } from "./components"; import { pluginRouteModuleRef } from "./module-ref"; -/** - * What a plugin route contributes to the shell's trail, once its module has - * arrived - and what it contributes before that, which is nothing. - * - * The crumb is rendered by the shell, *above* the route's own component, so it - * cannot suspend on the module it reads: a suspend there blanks the header - * rather than the page. What it does instead is render nothing until the module - * resolves and then re-render, which is what `useSyncExternalStore` over the - * memoised import buys. - * - * No namespaces are declared here on purpose: a route that declares none mounts - * no message provider at all, so this needs no intl record and asserts only the - * part that belongs to this file. Which namespaces a route's provider gets is - * `./specs.test.ts`. - */ const moduleWith = (route: Record<string, unknown>) => { let resolve = (): void => undefined; const arrived = new Promise<void>(settle => { diff --git a/packages/vitnode/src/tanstack/plugin-routes/components.tsx b/packages/vitnode/src/tanstack/plugin-routes/components.tsx index 4279bb47f..9845fdfc2 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/components.tsx +++ b/packages/vitnode/src/tanstack/plugin-routes/components.tsx @@ -26,31 +26,6 @@ import { pluginRouteSearch, } from "./loader-data"; -/** - * The React half of a plugin route: what actually renders once the module has - * arrived. - * - * Every component here is built *from* a checked module rather than importing - * one, which is what keeps a plugin's page out of the initial bundle - the - * factories below are called inside `lazyRouteComponent`'s importer, on the - * chunk the router fetched, and the component they return is the one thing that - * ever reaches React. - */ - -/** - * The strings a plugin route renders, provided around it - or nothing. - * - * `RouteMessages` mounts a provider that **replaces** the shell's rather than - * adding to it, so it is mounted only where a route actually declared - * namespaces: a plugin page that declares none reads the root's provider, which - * already holds exactly `core.global`, and mounting a second identical one would - * be a second cache read for the same bytes. - * - * Where it is mounted, the list is the route's own namespaces plus its layouts' - * plus `core.global` - decided in `./specs`, warmed by the loader through the - * identical `intlQueryOptions`, so this reads a cache entry that is already - * there and nothing suspends. - */ const withMessages = ( namespaces: readonly string[], children: React.ReactNode, @@ -65,25 +40,6 @@ const withMessages = ( <RouteMessages namespaces={namespaces}>{children}</RouteMessages> ); -/** - * What this route resolved, read back inside the component the router renders. - * - * A route component is rendered with no props of its own, so nothing can be - * handed down to it - `strict: false` reads the loader data and the params of - * the match *this* component is rendering, which is this plugin route's own - * match. See `PluginRoutePageProps` for why the result is an envelope rather - * than the loader's data spread flat. - */ -/** - * A `navigate` that replaces this page's query string and nothing else. - * - * `useNavigate()` unbound, called with no `to`, which is TanStack's own spelling - * of "stay where you are and change the search" - the same call the host's own - * route files make through `Route.useNavigate()`. Handing a plugin the router's - * navigate whole would hand it the route table with it; this is the one shape - * that means the same thing under any router, so it is the only one that - * crosses. - */ const usePluginRouteNavigate = (): RuntimePluginRoutePageProps["navigate"] => { const navigate = useNavigate(); @@ -105,14 +61,6 @@ const usePluginRoutePageProps = () => usePluginRouteNavigate(), ); -/** - * A plugin page, wrapped in whatever the route around it promised. - * - * The component is typed at its widest here, because `readPluginRouteModule` - * checked `typeof module.default === "function"` and nothing more - the props - * were checked by the plugin's own `satisfies PluginRoutePageModule`, on the - * plugin's side, at compile time. - */ export const pluginPageComponent = ( module: CheckedPluginRouteModule, namespaces: readonly string[], @@ -132,17 +80,6 @@ export const pluginPageComponent = ( }; }; -/** - * A plugin layout: the plugin's frame, with the router's outlet inside it. - * - * `children` rather than an `<Outlet />` the layout imports, which is the whole - * of what keeps a layout framework-neutral - an outlet belongs to a router, and - * a plugin that imported one could be installed into exactly one kind of - * application. It is passed last so a loader that returned a `children` key - * cannot displace the routes underneath it. - * - * The provider goes *outside* the frame, because the frame renders strings too. - */ export const pluginLayoutComponent = ( module: CheckedPluginRouteModule, namespaces: readonly string[], @@ -167,43 +104,6 @@ export const pluginLayoutComponent = ( }; }; -/** - * A plugin route's own crumb, rendered in the shell's trail. - * - * Every plugin route declares this one component and the shell collects one item - * per matched route, so a page inside two layouts contributes the third crumb of - * three and never restates the two above it. What the plugin's own component - * returns is the label; the separator, the `aria-current`, the locale-aware link - * to this route's URL and the position in the trail are all the shell's. - * - * ## The provider, and why it is here rather than in the plugin - * - * The shell renders the breadcrumb *above* the outlet, so a crumb is outside its - * own route's message provider - a translated crumb that mounted none would - * render its message key. Every VitNode crumb therefore mounts its own - * `RouteMessages`, which is what `SettingsBreadcrumb` does by hand; a plugin - * cannot, because `RouteMessages` is a TanStack component and a plugin route - * module may not import one. So the runtime mounts it, with the namespaces of - * the route that declared the crumb - the same list that route's loader already - * warmed, so it reads a cache entry rather than fetching one. - * - * `Suspense` around it because the loader warms the messages and the module in - * parallel: if the module wins that race the crumb can render a moment before - * its strings exist, and a suspend here would blank the header rather than the - * page. A crumb that arrives a frame late is the right trade. - * - * ## Why it may not suspend on the module itself - * - * Same reason. It reads what has already arrived (`ref.current`) and subscribes - * for the rest: before the module resolves it renders nothing, and the moment it - * does it renders the crumb. `useSyncExternalStore` is exactly this, including - * the case a plainer implementation gets wrong - a module that resolves between - * the first render and the subscription. - * - * The chunk is never fetched *for* the breadcrumb, and never split from the page - * it belongs to: the ref is the same memoised import the route's component and - * loader are already waiting on. - */ export const pluginRouteBreadcrumb = ( module: PluginRouteModuleRef, namespaces: readonly string[], diff --git a/packages/vitnode/src/tanstack/plugin-routes/container.ts b/packages/vitnode/src/tanstack/plugin-routes/container.ts index 323464638..5058b47e0 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/container.ts +++ b/packages/vitnode/src/tanstack/plugin-routes/container.ts @@ -1,19 +1 @@ -/** - * The pathless route every plugin page is mounted under. - * - * Pathless, so it contributes no URL segment: a plugin route at `/example` is - * served at `/example`, not at `/_plugins/example`. It earns its place by making - * the composition **idempotent** - the plugin subtree is one child of its mount - * point, identifiable by this id, so re-running the composition replaces it - * instead of appending a second copy of every route. That is not a theoretical - * concern: in dev, Vite re-evaluates the module that composes the tree without - * re-evaluating `routeTree.gen.ts`, and the route it mutates is the same object. - * - * It also gives the whole plugin subtree one name in the router devtools, and - * one place for a future stage to hang something every plugin page needs. - * - * Its own module so that `./collision` - which has to skip the subtree while - * asking the application what it owns - and `./mount`, which creates it, can - * both name it without importing each other. - */ export const PLUGIN_ROUTES_ROUTE_ID = "_plugins"; diff --git a/packages/vitnode/src/tanstack/plugin-routes/guard.test.ts b/packages/vitnode/src/tanstack/plugin-routes/guard.test.ts index 5ac9ff850..91e4248b3 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/guard.test.ts +++ b/packages/vitnode/src/tanstack/plugin-routes/guard.test.ts @@ -7,16 +7,6 @@ import type { SessionApi } from "../auth/session-api"; import { pluginRouteGuard } from "./guard"; -/** - * Who a plugin route is offered to, as the decision alone. - * - * The session arrives through a stub `fetchQuery`, so what is exercised here is - * the rule and the destination it produces - no network, no router, no React. - * That the rule itself is right is Stage 6's own coverage (`auth/state.test.ts`, - * `auth/redirects.test.ts`): this asserts that a plugin route reuses it rather - * than growing a second copy. - */ - const sessionOf = (user: null | { id: number }): SessionApi => ({ user }) as unknown as SessionApi; @@ -44,11 +34,6 @@ const redirectFrom = async ( }; describe("pluginRouteGuard", () => { - /** - * A guard is built per route rather than once on the plugin container, which - * is the parent of *every* plugin route - one there would apply to all of - * them. A route offered to everybody therefore has no `beforeLoad` at all. - */ it("gives a route with no requirement no guard at all", () => { expect(pluginRouteGuard(null)).toBeUndefined(); }); @@ -74,11 +59,6 @@ describe("a plugin route that requires a signed-in visitor", () => { expect(result).toMatchObject({ auth: { isAuthenticated: true } }); }); - /** - * `to`/`search` rather than `href`: a redirect carrying `href` is used - * verbatim and never reaches the locale rewrite, so a Polish visitor would - * land on the English login page. - */ it("sends an anonymous visitor to the login page, carrying where they were", async () => { const options = await redirectFrom( async () => @@ -115,11 +95,6 @@ describe("a plugin route that only makes sense signed out", () => { expect(options).toMatchObject({ to: "/example/guide" }); }); - /** - * The `returnTo` is a stranger's string. Everything that is not an - * application-relative path collapses to `/`, and the login page itself is - * refused - it is the loop this rule exists to prevent. - */ it.each([ ["an absolute URL", "https://evil.invalid/x"], ["the login page", "/login"], diff --git a/packages/vitnode/src/tanstack/plugin-routes/guard.ts b/packages/vitnode/src/tanstack/plugin-routes/guard.ts index 0fa1802d6..f808aa6f1 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/guard.ts +++ b/packages/vitnode/src/tanstack/plugin-routes/guard.ts @@ -18,39 +18,6 @@ import { canAccessGuestRoute, } from "../auth/state"; -/** - * Who a plugin route is offered to, enforced before its chunk is fetched. - * - * The requirement is manifest *data* rather than something the module exports, - * and this is the whole argument for that split: the check runs in `beforeLoad`, - * which is before the route's module is downloaded. A requirement that lived in - * the module could only be read by fetching the page it was meant to withhold. - * - * ## It is not the security boundary, and may never become one - * - * Every private read is authorized by Hono on the server, from the session - * cookie, in the route's own handler. What this decides is whether a browser is - * sent somewhere else *before* the page renders - the difference between a - * signed-out visitor seeing a flash of a private page and never receiving a byte - * of it. A visitor who edits their cached session in devtools gets a page shell - * and an API that still refuses them. - * - * ## Nothing new is invented here - * - * `ensureAuthState`, `canAccessAuthenticatedRoute`, `canAccessGuestRoute`, - * `LOGIN_PATH`, `returnToFor` and `postAuthDestination` are Stage 6's, unchanged - * and shared with the host's own `_authenticated` boundary and `/login`. There - * is deliberately no plugin auth context, no plugin session store and no second - * cache entry: the QueryClient the host already owns holds the session under one - * key, and a plugin route reads exactly that one. - * - * A failed session read is left to propagate. `ensureAuthState` rejects when the - * session could not be read at all - a rate limit, a 500, an API that is not - * listening - and only `canAccess…` answering `false` on a session the API - * actually returned sends anybody anywhere. Catching the rejection would sign a - * visitor out because of an outage. - */ - /** The narrowest slice of a route's context these guards read. */ export interface PluginRouteGuardContext { queryClient: QueryClient; @@ -63,20 +30,6 @@ export interface PluginRouteGuardArgs { search: unknown; } -/** - * A plugin route's `beforeLoad`, or nothing when it is offered to everybody. - * - * Built per route rather than registered once on the plugin container, because - * the container is the parent of *every* plugin route and a guard there would - * apply to all of them. A nested route inherits its layout's guard the way any - * route inherits a parent's `beforeLoad` - by being underneath it - which is - * also why `buildPluginRouteGraph` refuses a guest-only page inside an - * authenticated layout: it would be a route no visitor could ever reach. - * - * `authenticated` returns the resolved state, so a nested plugin route's - * `context.auth` is the same object the guard decided on and cannot disagree - * with the rule that admitted the navigation. - */ export const pluginRouteGuard = ( requires: null | PluginRouteRequirement, ): diff --git a/packages/vitnode/src/tanstack/plugin-routes/head.test.ts b/packages/vitnode/src/tanstack/plugin-routes/head.test.ts index 9b4b15578..56c4c6136 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/head.test.ts +++ b/packages/vitnode/src/tanstack/plugin-routes/head.test.ts @@ -2,15 +2,6 @@ import { describe, expect, it } from "vitest"; import { normalizePluginRouteHead } from "./head"; -/** - * A plugin's declared metadata, on its way into the host's own `head` rule. - * - * The point of this function is that a plugin's `head` is a function from a - * compiled package run inside the host's document, so what comes back is read - * field by field rather than spread - and that it is total, because `head` runs - * inside the router's own try/catch and throwing there loses the tab title and - * prints a stack instead. - */ describe("normalizePluginRouteHead", () => { it("keeps the three fields a plugin page may set", () => { expect( @@ -52,11 +43,6 @@ describe("normalizePluginRouteHead", () => { ); }); - /** - * A plugin may not put arbitrary elements in its host's document - which is - * the difference between this contract and re-exporting the router's own head - * options. - */ it("drops anything the contract does not name", () => { expect( normalizePluginRouteHead({ diff --git a/packages/vitnode/src/tanstack/plugin-routes/head.ts b/packages/vitnode/src/tanstack/plugin-routes/head.ts index 9b14e4c3e..4c4e521f4 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/head.ts +++ b/packages/vitnode/src/tanstack/plugin-routes/head.ts @@ -2,28 +2,6 @@ import type { PluginRouteHead, PluginRouteRobots } from "@/routing"; import type { RouteHeadOptions, RouteRobots } from "../metadata"; -/** - * A plugin page's metadata, on its way into the host's own `head` rule. - * - * `PluginRouteHead` and `RouteHeadOptions` are structurally the same three - * fields, and that is deliberate rather than lucky: the routing layer declares - * its own copy because it may not import this one, and the copy is shaped so - * that a plugin's title goes through exactly the `"<page> - <site>"` rule every - * VitNode page's does. What is *not* shared is trust. A plugin route's `head` is - * a function from a compiled package, run inside the host's document, so what - * comes back is read field by field rather than spread. - * - * Total, and it never throws. A plugin that returns the wrong shape gets a page - * with no metadata rather than a page that fails to render: `head` runs inside - * the router's own try/catch, so throwing here would lose the tab title *and* - * print a stack somebody has to read - and the compile-time `satisfies - * PluginRouteHead` on the plugin's side is where a typo is meant to be caught. - * - * `robots` is the one field checked by value rather than by type, because it is - * the one whose wrong value is not visibly wrong: an unknown string reaches - * `<meta name="robots">` and tells a crawler something nobody meant. Only the - * two directives the contract names survive. - */ const ROBOTS: readonly PluginRouteRobots[] = [ "index, follow", "noindex, nofollow", diff --git a/packages/vitnode/src/tanstack/plugin-routes/index.ts b/packages/vitnode/src/tanstack/plugin-routes/index.ts index 0f3128089..27b58f7d6 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/index.ts +++ b/packages/vitnode/src/tanstack/plugin-routes/index.ts @@ -1,44 +1,3 @@ -/** - * Plugin pages, as real routes in a TanStack Start application's route tree - - * `@vitnode/core/tanstack/plugin-routes`. - * - * An application hands the one generated file to {@link pluginRouteSpecs} and the - * result to {@link withPluginRoutes}, and that is the whole of its plugin-routing - * code: - * - * const routeTree = withPluginRoutes( - * fileRouteTree, - * pluginRouteSpecs(pluginRouteSources), - * { - * mountUnder: { admin: adminShellRoute, main: mainShellRoute }, - * pageHead, - * }, - * ) - * - * `mountUnder` is one route per shell, because that is all an `area` declaration - * ever meant: a plugin page framed by the public site or by the AdminCP, and in - * a router a frame is a parent. An area the host does not name is refused rather - * than mounted somewhere else. - * - * Everything else is here, because the composition is identical in every - * installation: the graph, the lazy import, the message provider, the guard, the - * metadata, the crumb and the refusal to shadow one of the host's own pages. - * - * ./specs the plugins' trees, flattened, validated and read as a tree - * ./module-ref one memoised, checked import per route - * ./mount that tree, as TanStack routes - * ./components what renders once a module has arrived - * ./guard who a route is offered to, decided before its chunk loads - * ./head a plugin's metadata, on the way into the host's own rule - * ./collision what the application already owns, and the refusal - * - * What is deliberately absent: a locale, a shell, a data layer and an auth - * store. `/example` and `/pl/example` are one route because the rewrite strips - * the prefix before matching; the header and the `<main>` come from the route - * this subtree is mounted under; a loader reads through the host's QueryClient - * to the same Hono API every other page does; and the session is the one cache - * entry Stage 6 already defined. - */ export { fileRoutePaths } from "./collision"; export { PLUGIN_ROUTES_ROUTE_ID } from "./container"; export type { diff --git a/packages/vitnode/src/tanstack/plugin-routes/loader-data.ts b/packages/vitnode/src/tanstack/plugin-routes/loader-data.ts index 266c513fa..ff5627d3a 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/loader-data.ts +++ b/packages/vitnode/src/tanstack/plugin-routes/loader-data.ts @@ -1,41 +1,12 @@ import type { PluginRoutePageProps } from "@/routing"; -/** - * What a plugin route's loader returns, and therefore what its `head` and its - * component are handed. - * - * Two fields rather than the plugin's data alone, because the loader is also - * where a plugin route's search contract is applied - see `./mount` for why it - * cannot be applied where a route normally applies one - and `head` needs the - * same validated value `load` was given. Carrying it here is what makes the two - * provably the same object rather than the same function run twice. - * - * Internal to the runtime: a plugin never sees this envelope. Its `load` returns - * its own data, and its component and `head` are handed that data back under the - * name `loaderData`. - */ export interface PluginRouteLoaderData { /** Whatever the module's `load` returned, or `undefined` if it declares none. */ data: unknown; - /** - * The route's validated search: its eager `search` schema when its declaration - * has one, its module's `parseSearch` otherwise, or `{}` when it declares - * neither. - */ + search: unknown; } -/** - * What a plugin route's component is rendered with, as the runtime sees it. - * - * `PluginRoutePageProps` is the contract's own - `@vitnode/core/routing` - and - * the whole of what this alias adds is the two type arguments a runtime cannot - * know: what a *particular* plugin's loader returned, and what its - * `parseSearch` returned, are the plugin's business and were checked on the - * plugin's side. Naming the shared type rather than restating its three fields - * is what keeps the props a plugin declares and the props this runtime passes - * provably the same object. - */ export type RuntimePluginRoutePageProps = PluginRoutePageProps< unknown, unknown @@ -44,13 +15,6 @@ export type RuntimePluginRoutePageProps = PluginRoutePageProps< const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value); -/** - * Whatever this route's `load` returned, out of the runtime's envelope. - * - * Total: a loader that has not run - or a match whose loader threw, which is - * still a match and still renders its crumb - reads as `undefined` rather than - * throwing on a property of nothing. - */ export const pluginRouteLoaderData = (loaderData: unknown): unknown => isRecord(loaderData) ? (loaderData as Partial<PluginRouteLoaderData>).data @@ -62,14 +26,6 @@ export const pluginRouteSearch = (loaderData: unknown): unknown => ? (loaderData as Partial<PluginRouteLoaderData>).search : undefined) ?? {}; -/** - * The loader's envelope and the match's params, as the props a plugin page - * renders with. - * - * Total: a loader that has not run, or one whose result is not the envelope this - * runtime builds, still produces a complete props object rather than a spread - * that throws. - */ export const pluginRoutePageProps = ( loaderData: unknown, params: Readonly<Record<string, string>>, diff --git a/packages/vitnode/src/tanstack/plugin-routes/module-ref.test.ts b/packages/vitnode/src/tanstack/plugin-routes/module-ref.test.ts index f7b59251d..492aee09c 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/module-ref.test.ts +++ b/packages/vitnode/src/tanstack/plugin-routes/module-ref.test.ts @@ -3,15 +3,6 @@ import { describe, expect, it, vi } from "vitest"; import { pluginRoutePageProps } from "./loader-data"; import { pluginRouteModuleRef } from "./module-ref"; -/** - * One plugin route's module: imported once, checked, and readable by the four - * things that need it at four different moments. - * - * No React and no router. What is asserted is the memo, the synchronous peek, - * the notification and the diagnostic - all of which are decided before anything - * renders, and none of which a component test would exercise any better. - */ - const page = () => null; describe("pluginRouteModuleRef", () => { @@ -45,11 +36,6 @@ describe("pluginRouteModuleRef", () => { await expect(ref()).rejects.toThrow(/route\.head/); }); - /** - * A failed *import* is a network failure, not a broken plugin, so the memo is - * cleared and the next navigation tries again. A malformed module fails the - * same way and will keep failing, which is correct: the module is wrong. - */ it("retries after a failed import instead of answering from the failure", async () => { const load = vi .fn<() => Promise<unknown>>() @@ -62,11 +48,6 @@ describe("pluginRouteModuleRef", () => { expect(load).toHaveBeenCalledTimes(2); }); - /** - * The synchronous half, for the breadcrumb: it renders in the shell, above the - * route's own Suspense boundary, so it may read what has arrived but may never - * await it. - */ it("reads nothing before the module arrives, and the module after", async () => { const ref = pluginRouteModuleRef( async () => Promise.resolve({ default: page }), @@ -105,13 +86,6 @@ describe("pluginRouteModuleRef", () => { }); }); -/** - * A loader's envelope, as the props a plugin page is rendered with. - * - * The three names match `load`'s and `head`'s arguments, which is the reason it - * is an envelope rather than the loader's data spread flat - and why a loader - * that returned a string still produces a complete props object. - */ describe("pluginRoutePageProps", () => { const params = { topic: "routing" }; /** diff --git a/packages/vitnode/src/tanstack/plugin-routes/module-ref.ts b/packages/vitnode/src/tanstack/plugin-routes/module-ref.ts index 46767d331..ae76151bf 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/module-ref.ts +++ b/packages/vitnode/src/tanstack/plugin-routes/module-ref.ts @@ -2,55 +2,14 @@ import type { CheckedPluginRouteModule } from "@/routing"; import { readPluginRouteModule } from "@/routing"; -/** - * One plugin route's module, imported at most once and readable three ways. - * - * A plugin route's chunk is wanted by four different things at four different - * moments - the router's own `component.preload()`, the route's loader, its - * `head`, and the shell's breadcrumb - and every one of them is on the critical - * path of the same navigation. Four calls to the route's own `lazy()` callback - * would be four `import()` expressions of the same specifier: the bundler - * dedupes the *fetch*, but each caller would still run - * {@link readPluginRouteModule} again and hold its own copy of the answer, and - * the breadcrumb - which renders while the match is still pending - would have - * no way to find out that one of the others had finished. - * - * So the import is memoised here, once per route, and exposed as the three - * shapes those callers actually need: - * - * ref() the promise - for a loader or a `head`, which may await - * ref.current what has already arrived - for a render, which may not - * ref.subscribe and how to hear when it does - * - * A rejected import clears the memo, so the next navigation retries rather than - * being answered forever from a failure the visitor has since fixed by - * reloading. A *malformed* module rejects the same way and will keep rejecting, - * which is correct: the module is wrong, and the error names the route. - */ export interface PluginRouteModuleRef { (): Promise<CheckedPluginRouteModule>; - /** - * The module, if it has already arrived - and never a promise. - * - * For the one caller that cannot await: a component rendering inside the - * shell, above the route's own Suspense boundary, where suspending would blank - * the header rather than the page. - */ + readonly current: CheckedPluginRouteModule | undefined; /** Fires once, when {@link PluginRouteModuleRef.current} becomes readable. */ subscribe: (listener: () => void) => () => void; } -/** - * A memoised, checked loader for one plugin route module. - * - * A `lazy()` callback is typed `() => Promise<unknown>` here deliberately - what - * a page module is expected to export is checked rather than assumed - so this - * is where that `unknown` is turned into something a router can be handed, by - * `readPluginRouteModule`, which throws with the route id in the message. - * Without it the failure is React's "type is invalid" from inside a lazy - * component, three frames from the plugin that caused it. - */ export const pluginRouteModuleRef = ( load: () => Promise<unknown>, routeId: string, 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 de2bfd283..dda661c10 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/mount-freshness.test.ts +++ b/packages/vitnode/src/tanstack/plugin-routes/mount-freshness.test.ts @@ -17,29 +17,6 @@ import { PLUGIN_ROUTES_ROUTE_ID } from "./container"; import { withPluginRoutes } from "./mount"; import { pluginRouteSpecs } from "./specs"; -/** - * What happens to a route tree when a plugin is enabled, disabled and enabled - * again while the process keeps running. - * - * This is the dev-server question, and it is not the same one `./mount` answers - * for a cold start. A generated file changing invalidates the module that reads - * it, so the composition runs again - but the route tree it runs against is the - * one a *previous* pass already mutated, because `routeTree.gen.ts` is a module - * singleton and `addChildren` writes into it in place. So "mount the new routes" - * is only half the job: the old ones have to stop existing, and they have to - * stop existing on the tree that is already there rather than on a fresh one. - * - * The symptom when they do not is a *stale match*, not a broken page. The live - * route tree is what every link, redirect and guard resolves against, and a - * subtree nobody declares any more still matches - so a URL whose plugin was - * uninstalled keeps resolving to a route that no longer has a component behind - * it, and the dev server serves a blank or a crash rather than a not-found. - * - * `matchRoutes` is used directly here because the route tree is the only table, - * and a test that consulted a list of - * expected paths would be asserting against its own copy of the answer. - */ - const pageHead: PluginRoutePageHead = ({ title }) => ({ meta: title ? [{ title }] : [], }); @@ -65,21 +42,9 @@ const EXAMPLE = plugin("example", "/example"); /** A second plugin's page, at `/reports`, so removal can be told from a reset. */ const REPORTS = plugin("reports", "/reports"); -/** - * The generated file for a given configuration, as one call. - * - * A plugin's routes and the modules behind them are one declaration, so a - * disabled plugin loses both together - there is no second list that could stay - * behind. - */ const specsFor = (...sources: PluginRouteDeclarationSource[]) => pluginRouteSpecs(sources); -/** - * The app's own tree, built once - which is the point. Every mount below - * mutates *this* object, exactly as a dev server's repeated composition mutates - * the one `routeTree.gen.ts` exports. - */ const appTree = (): { admin: AnyRoute; main: AnyRoute; root: AnyRoute } => { const root = createRootRoute(); const main = createRoute({ getParentRoute: () => root, id: "_main" }); @@ -98,14 +63,6 @@ const appTree = (): { admin: AnyRoute; main: AnyRoute; root: AnyRoute } => { return { admin, main, root }; }; -/** - * Does the deepest match consume the whole path? - * - * The locale and origin handling a host wraps around this is the host's and is - * tested there; this is the route-tree half alone. "Something matched" is not - * enough: `matchRoutes` answers with the deepest *ancestor* it can resolve, so a - * removed `/example` still comes back as a match on the root. - */ const owns = (tree: AnyRoute, pathname: string): boolean => { // A router per question, built from the tree as it stands right now - which is // the state the dev server is in after a composition: a new router graph over @@ -154,11 +111,6 @@ describe("enabling, disabling and re-enabling a plugin on a live route tree", () expect(owns(root, "/example")).toBe(true); }); - /** - * The one this whole module is for. The second composition runs against the - * tree the first one mutated, and the route it no longer declares has to be - * gone from that tree - not merely absent from the specs. - */ it("stops claiming a path once the plugin declaring it is disabled", () => { const { admin, main, root } = appTree(); const mountUnder = { admin, main }; @@ -247,12 +199,6 @@ describe("no orphan routes are left on the tree", () => { expect(owns(root, "/")).toBe(true); }); - /** - * Per shell, not globally. The last admin plugin route going away has to clear - * the AdminCP's container while the public one keeps its own - a single shared - * "did anything mount" flag gets this wrong in the direction that leaves an - * admin URL claimed by a plugin that is gone. - */ it("clears one shell's plugin subtree without touching the other's", () => { const { admin, main, root } = appTree(); const mountUnder = { admin, main }; diff --git a/packages/vitnode/src/tanstack/plugin-routes/mount.tsx b/packages/vitnode/src/tanstack/plugin-routes/mount.tsx index 8f3b0f37f..e17adb0f5 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/mount.tsx +++ b/packages/vitnode/src/tanstack/plugin-routes/mount.tsx @@ -31,127 +31,22 @@ import { pluginRouteGuard } from "./guard"; import { normalizePluginRouteHead } from "./head"; import { pluginRouteSearchDeps } from "./specs"; -/** - * Plugin pages, in a TanStack Start application's route tree. - * - * Two inputs, and the whole point of the design is that each one answers exactly - * one question: - * - * plugin-routes.gen.ts which plugins are configured, and their route trees - * this module how that becomes a TanStack route - * - * The first is generated *per application*, because only an installation knows - * which plugins it has. This one is the composition, which is identical - * everywhere, so it lives here rather than once per host. - * - * The generated file does not mention a router, and no plugin page is copied - * into the host's `src/routes` - a page stays compiled in the plugin's own - * `dist` and is reached only through the literal `import()` its own route - * declared, which the bundler resolved at build time. - * - * What is deliberately *not* here: a locale. `/example` and `/pl/example` are the - * same route, because the router's rewrite strips the prefix before matching and - * writes it back into every link (`@vitnode/core/tanstack/i18n`'s - * `createLocaleRewrite`). A plugin declares the logical path and the locale layer - * owns the public one, so there is nothing to prefix and no route to duplicate - * per language. - */ - -/** - * What this runtime has on hand when a plugin route loads - the *host's* - * context, not the plugin's. - * - * Named apart from `@vitnode/core/routing`'s `PluginRouteContext` on purpose: - * that one is the public promise, this one is whatever the TanStack host - * happens to hold. The two must not converge. A plugin's `load` is handed a - * projection of this - see `pluginRouteLoader` - so a `QueryClient` living here - * never becomes something a plugin may compile against. - */ export interface PluginRouteRuntimeContext { locale: string; queryClient: QueryClient; } -/** - * The host's `head` rule, bound to its own name - `createRouteHead(metadata)`. - * - * Required rather than optional, and that is the point: a plugin's `<title>` - * goes through the same `"<page> - <site>"` rule every VitNode page's does. A - * package cannot know the site's name, and a plugin route that built its own - * title would produce one that disagreed with every other page of the same site. - */ export type PluginRoutePageHead = ( options: RouteHeadOptions, ) => RouteHeadResult; -/** - * Which route each area's plugin subtree hangs from. - * - * A finite record rather than an open registry, and that is a contract rather - * than a limitation: the areas are `@vitnode/core/routing`'s - * {@link PluginRouteArea}, a host may name a parent for each one, and a route - * declaring an area this host has not named is a hard failure instead of a page - * quietly rendered in the wrong shell. - * - * Partial because an application need not have every shell - a headless app has - * no AdminCP - and because "not configured" and "configured wrongly" have to be - * different states. The first is only an error once a plugin actually declares - * that area; the second cannot happen, because there is nowhere to put a name - * that is not an area. - */ export type PluginRouteAreaRoutes = Partial<Record<PluginRouteArea, AnyRoute>>; export interface PluginRoutesMountOptions { - /** - * Which route each area's plugin subtree hangs from. - * - * The whole of what an `area` declaration amounts to at runtime. A plugin says - * `area: "main"` or `area: "admin"` (see `@vitnode/core/routing`), which is a - * statement about *layout*: this page belongs on the public site with the site - * header, or inside the AdminCP with the sidebar and the admin session guard. - * In a router a layout is a parent, so honouring that declaration is choosing - * a parent - and this is where a host names them: - * - * { mountUnder: { admin: adminShellRoute, main: mainShellRoute }, pageHead } - * - * Nothing about a path changes. Both shells are pathless routes, so `/example` - * stays `/example` and `/admin/reports` stays `/admin/reports` - the area - * chose the frame, and the manifest still says the URL in full. - * - * **An area with no entry here is refused**, naming the route that declared - * it. That is the one behaviour this field exists for: silently falling back - * to another shell would mount an AdminCP page on the public site, outside the - * admin session guard, and it would look like it worked. - * - * Omitted entirely, the plugin subtree hangs from the tree's root as `main` - - * which is what an app with no shell wants, and what Stage 11 did. Pass the - * record and it is the whole answer: an area missing from it is missing. - * - * The collision check still walks the whole tree from its root, because what a - * plugin route may not shadow is *any* URL the app answers, in any shell. - */ mountUnder?: PluginRouteAreaRoutes; pageHead: PluginRoutePageHead; } -/** - * A plugin route's `head`. - * - * Async, which the router supports - it awaits `head` while projecting a - * match - and which is what lets the metadata come out of the lazily imported - * module without the module being in the initial bundle. By the time this runs - * the chunk is in hand anyway: the router awaited it to render the match. - * - * `loaderData` is optional on a route's first pass, before the loader has - * resolved, so both halves of the envelope are read defensively and the plugin's - * own `head` gets `undefined` for its data - which is exactly the contract it is - * written against. - * - * The result goes through `normalizePluginRouteHead` and then the host's own - * `pageHead`, so a plugin cannot put an arbitrary element in the host's - * document: three fields survive, and the title is formatted by the same rule - * every VitNode page's is. - */ const pluginRouteHead = (module: PluginRouteModuleRef, pageHead: PluginRoutePageHead) => async ({ @@ -178,36 +73,6 @@ const pluginRouteHead = ); }; -/** - * A plugin route's loader: its strings, its module, its search and its data. - * - * The messages and the chunk are fetched **in parallel**, which is the reason - * namespaces are declared in the manifest rather than in the module: a list that - * lived inside the code could only be read after downloading the page it - * describes, making two requests into a waterfall. - * - * ## Why `parseSearch` is applied here - * - * A route's `validateSearch` runs during path matching, which is *before* any - * chunk is fetched - that is why TanStack's own lazy route files may not contain - * one, and it is why a plugin route registers none. A plugin's module is lazy, - * so there is nothing to ask at matching time. Applying `parseSearch` in the - * loader is what keeps both promises: the page's code is still split, and - * nothing downstream of the module ever sees a raw query parameter. `load` is - * handed the parsed value, and so - through the envelope this returns - is - * `head`. - * - * The consequence, and the reason the option is not called `validateSearch`: a - * plugin route's `search` is a *loader* contract, not a URL one. The router's own - * search type for the route is untouched, links it builds to a plugin route - * carry whatever they were given, and no URL is ever rejected. - * - * The query string reaches a loader as `deps`, never as `search` - a router - * loader is not handed the search directly, which is what `loaderDeps` is for. - * That works in this runtime's favour: `deps` is the normalised query string, - * so the value `parseSearch` is applied to is by construction the same value the - * loader re-runs for. - */ const pluginRouteLoader = (spec: PluginRouteSpec) => async ({ @@ -231,16 +96,6 @@ const pluginRouteLoader = ), ]); - /** - * The eager schema wins, and the module's `parseSearch` is not consulted - * when there is one. - * - * `deps` for a route with a `search` schema is already the router's validated - * search - `validateSearch` ran during path matching and `loaderDeps` reads - * its output - so running `parseSearch` over it would normalise a normalised - * value, with the module's answer silently overriding the one the router - * built its links and its match id from. One route, one search contract. - */ const search = spec.validateSearch ? deps : route.parseSearch @@ -264,22 +119,6 @@ const pluginRouteLoader = }; }; -/** - * One plugin route's TanStack options. - * - * Everything a plugin can contribute passes through here, and every one of them - * is reached through the same memoised module ref - so a route's component, its - * loader, its metadata and its breadcrumb are four readers of one import rather - * than four imports, and a crumb is never a chunk of its own. - * - * `lazyRouteComponent` over that ref is the supported way to code-split a - * code-based route: the plugin's page gets its own Rollup chunk, stays out of - * the initial bundle, and the router awaits `component.preload()` before it - * renders the match - so SSR and hydration both have the module in hand rather - * than suspending on it. A module that does not satisfy the contract fails - * inside `readPluginRouteModule`, with the plugin route's id in the message, - * rather than as React's "type is invalid" three frames away. - */ const pluginRouteOptions = ( spec: PluginRouteSpec, pageHead: PluginRoutePageHead, @@ -288,21 +127,7 @@ const pluginRouteOptions = ( return { ...(beforeLoad ? { beforeLoad } : {}), - /** - * The route's own `validateSearch`, when its declaration had a `search`. - * - * This is the seam a lazy module cannot reach: it runs during path matching, - * so the function has to be in hand before any chunk is fetched, which is - * exactly what declaring it in `routes.ts` buys - that module is imported - * statically by the host, the page it belongs to is not. A route that - * declares one gets everything a host's own route file gets from the same - * option: a typed search, links the router can build and check, and a clamped - * value that redirects instead of rendering a page that does not exist. - * - * Spread rather than set to `undefined`, because TanStack reads the presence - * of the key: a route without one keeps the raw query string, which is the - * right default for a page that reads no state out of its URL. - */ + ...(spec.validateSearch ? { validateSearch: spec.validateSearch } : {}), component: lazyRouteComponent(async () => ({ default: (spec.route.kind === "layout" @@ -311,64 +136,17 @@ const pluginRouteOptions = ( })), head: pluginRouteHead(spec.module, pageHead), loader: pluginRouteLoader(spec), - /** - * What the loader re-runs for. - * - * The query string, normalised - and what "the query string" is depends on - * which of the two search contracts the route chose. - * - * With a `search` schema, `search` here is already the router's validated - * output, so this passes the route's own schema through and the loader - * re-runs exactly when a parameter the schema keeps changes. Without one, - * the runtime cannot know before the chunk loads whether this route reads - * the query string at all, so it assumes it does: a route whose module - * declares `parseSearch` re-runs `load` whenever the query string changes, - * which is the contract, and a route that declares neither pays for it with - * a re-run on a parameter it does not read. - * - * Sorted either way, so `?b=2&a=1` and `?a=1&b=2` are one match rather than - * two. - */ + loaderDeps: ({ search }: { search: unknown }) => pluginRouteSearchDeps(search), path: spec.path, - /** - * This route's own crumb - a component, so the shell can hand it this - * match's loader data, params and search. - * - * Declared for every plugin route rather than only for the ones that have a - * crumb, because whether a module declares one is *in* the module, which has - * not been fetched when `staticData` is written. The component renders - * nothing when its module turns out to declare none, so a page that wants no - * crumb - or declares `breadcrumb: false` - contributes nothing to the trail - * while its layouts' crumbs stay exactly where they were. - */ + staticData: { breadcrumb: pluginRouteBreadcrumb(spec.module, spec.namespaces), }, }; }; -/** - * Every mount point this composition touches, with the specs that belong to it. - * - * Two jobs, and the second is the reason it is one function. It **groups** the - * specs by the route their area names - and it **refuses** a route whose area - * this host has not named, which is the failure `PluginRoutesMountOptions` - * exists to produce. A fallback would mount an AdminCP page under the public - * shell: outside the admin session guard, wearing the site header, and looking - * for all the world like it worked. - * - * Every configured mount point is in the result even when nothing mounts under - * it, because that is what takes a stale plugin subtree off a shell whose last - * plugin route was removed. See the idempotence note on {@link withPluginRoutes}. - * - * Keyed by the route *object*, so two areas a host points at one route share one - * container rather than adding two children with the same id. Iterated in - * `PLUGIN_ROUTE_AREAS` order, so the tree is the same whichever order the record - * happened to be written in; within a mount point the specs keep the graph's own - * order, which is parents before children. - */ const specsByMountPoint = ( areaRoutes: PluginRouteAreaRoutes, specs: readonly PluginRouteSpec[], @@ -405,20 +183,6 @@ const specsByMountPoint = ( return byMountPoint; }; -/** - * One mount point's plugin subtree, built and hung from it. - * - * One pass, parents before children, which is what `buildPluginRouteGraph`'s - * node order buys: by the time a nested route is reached, the layout it hangs - * from exists. Nothing here re-derives a parent - the compiled graph is the only - * answer, and it is the same function that validated the hierarchy when the app - * was built, so the runtime cannot disagree with the build about what the tree - * is. - * - * A parent is always in this same group: `buildPluginRouteGraph` refuses a route - * whose area differs from its layout's, so a subtree cannot span two shells and - * `routes` never has to look outside the specs it was handed. - */ const mountPluginSubtree = ( mountPoint: AnyRoute, specs: readonly PluginRouteSpec[], @@ -482,32 +246,6 @@ const mountPluginSubtree = ( mountPoint.addChildren([...siblings, container]); }; -/** - * Mounts the plugin routes on a route tree, and hands the same tree back. - * - * One subtree per shell: the specs are grouped by the area they declare, each - * group is hung from the route its host named for that area, and a route whose - * area was not named fails the composition rather than being mounted somewhere - * plausible. See {@link PluginRoutesMountOptions.mountUnder}. - * - * ## Idempotent - * - * `addChildren` **replaces** a route's children and mutates the route in place, - * so each plugin subtree is rebuilt from its mount point's current children with - * any previous copy of itself removed. Calling this twice on one tree is - * therefore the same as calling it once, which is what makes it safe in a dev - * server that re-evaluates this module while `routeTree.gen.ts` stays cached. - * - * Removing the *last* plugin is the same property read the other way, and it is - * the one case an early `return` used to skip: the tree handed back is the one a - * previous pass already mutated, so a subtree nobody declares any more has to be - * taken off it rather than merely not re-added. That now holds per shell - the - * last admin plugin route going away has to clear the AdminCP's container while - * the public one keeps its own - which is why every configured mount point is - * visited even when nothing mounts under it. A tree that never had a container - * is still left exactly as it arrived: `addChildren` is not called at all, so a - * route with no children does not acquire an empty array. - */ export const withPluginRoutes = <TRouteTree extends AnyRoute>( routeTree: TRouteTree, specs: PluginRouteSpec[], 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 d5237dc84..6f2a4a871 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/plugin-routes.test.ts +++ b/packages/vitnode/src/tanstack/plugin-routes/plugin-routes.test.ts @@ -14,22 +14,6 @@ import { PLUGIN_ROUTES_ROUTE_ID } from "./container"; import { withPluginRoutes } from "./mount"; import { pluginRouteSpecs } from "./specs"; -/** - * Plugin routes, from a plugin's declared tree to a mounted TanStack route - * *tree*. - * - * Route structure only: which routes exist, what they claim, who their parent - * is, and what the composition refuses. Nothing renders - whether a plugin's - * page produces the right HTML is the plugin's own business, and a component - * test would only assert that `lazyRouteComponent` works. What a spec *is* is - * `./specs.test.ts`; the modules are `./module-ref.test.ts`. - * - * The other half of this coverage is the host's: an application asserts that its - * *own* generated files reach its *own* router, which is a question about that - * application and not about this composition. See - * `apps/web/src/tests/plugin-routes.test.ts`. - */ - /** A page module, as a `lazy()` that resolves without a bundler. */ const lazyModule = ( module: Record<string, unknown> = { default: () => null }, diff --git a/packages/vitnode/src/tanstack/plugin-routes/specs.test.ts b/packages/vitnode/src/tanstack/plugin-routes/specs.test.ts index 7e36de0a8..feedd98bf 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/specs.test.ts +++ b/packages/vitnode/src/tanstack/plugin-routes/specs.test.ts @@ -6,15 +6,6 @@ import { definePluginRoutes, index, layout, lazy, page } from "@/routing"; import { pluginRouteSearchDeps, pluginRouteSpecs } from "./specs"; -/** - * Every configured plugin's route tree, as the route specs a router is built - * from. - * - * Everything here is data: what the plugins declared, and what the composition - * makes of them. Nothing renders and no route is created - whether a spec - * becomes the right TanStack route is `./plugin-routes.test.ts`, and whether a - * plugin's page produces the right HTML is the plugin's own business. - */ const lazyPage = () => lazy(async () => await Promise.resolve({ default: () => null })); @@ -23,11 +14,6 @@ const specsOf = (...routes: PluginRouteDeclaration[]) => { pluginId: "plugin", routes: definePluginRoutes(routes) }, ]); -/** - * The nested shape the example plugin ships, and the smallest tree that - * exercises every part of the hierarchy: a layout, its index page at the same - * path, and a dynamic child one segment deeper. - */ const guideSpecs = () => specsOf( layout("/example/guide", { diff --git a/packages/vitnode/src/tanstack/plugin-routes/specs.ts b/packages/vitnode/src/tanstack/plugin-routes/specs.ts index 4ff94d4b3..511543b00 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/specs.ts +++ b/packages/vitnode/src/tanstack/plugin-routes/specs.ts @@ -18,21 +18,6 @@ import type { PluginRouteModuleRef } from "./module-ref"; import { GLOBAL_NAMESPACE } from "../i18n/query"; import { pluginRouteModuleRef } from "./module-ref"; -/** - * One plugin route, as everything the router construction needs and nothing it - * has to work out for itself. - * - * Pure data plus one memoised loader, derived from the plugins' own route trees - * by {@link pluginRouteSpecs}. Everything here is decided *before* a single byte - * of a plugin's page is fetched, which is the whole reason a tree and its - * modules are two different things: the path a route claims, the parent it hangs - * from, the strings it needs and the visitor it is offered to are all answers the - * runtime must have in order to decide whether to fetch the chunk at all. - * - * The one thing that is *not* here is behaviour. A `head`, a `load`, a - * `breadcrumb` and a `parseSearch` live in the lazily imported module and arrive - * with it - see `./module-ref`. - */ export interface PluginRouteSpec { /** * This route claims exactly its parent layout's URL - it is that layout's @@ -41,62 +26,21 @@ export interface PluginRouteSpec { isIndex: boolean; /** The memoised, checked import of this route's module. */ module: PluginRouteModuleRef; - /** - * The exact namespace list this route's loader warms and its provider mounts, - * or empty when it needs none. - * - * Its own `messages` plus every layout's above it, because a route's provider - * *replaces* the shell's rather than adding to it - a page inside a layout - * that declared `@vitnode/blog` renders the layout's frame too, and would lose - * those strings by naming only its own. - * - * `core.global` is added by the runtime rather than by the plugin, for the - * same reason: it is what every shared VitNode component translates through, - * and a plugin author who forgot it would get a page of missing-message keys - * with nothing to point at. Empty stays empty, though - a route that declares - * no messages mounts no provider at all and reads the root's, which is - * already exactly `core.global`. - */ + 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; - /** - * This route's path in TanStack's spelling, **relative to its parent** - - * `/blog/$slug` for a root, `/comments` for a child of `/blog/:slug`, and `/` - * for a layout's index route. - * - * Relative because that is what a router composes, and it is the same form the - * plugin author wrote: a nested route declares what it adds to its parent, and - * `flattenPluginRoutes` is what turns that into the full canonical path a - * collision can be seen in. - */ + path: string; /** The manifest entry this spec was built from, unchanged. */ route: PluginRoute; - /** - * This route's **eager** search schema, or `null` for the ordinary route that - * declared none. - * - * The one thing on a spec that is a function rather than data, and the one - * thing that could not wait for the module: a router's `validateSearch` runs - * during path matching, before any chunk is fetched. A route earns it by - * declaring `search` in its plugin's `routes.ts`, which is a module the host - * imports statically - so by the time this spec exists the function is simply - * here. - */ + validateSearch: null | PluginRouteSearchValidator; } -/** - * The namespaces one route's provider mounts, given the tree it sits in. - * - * Exported for the tests, and separate from the loop below so the rule - "own - * plus ancestors, plus the global set, or nothing at all" - can be stated on its - * own. - */ export const pluginRouteMessageNamespaces = ( node: PluginRouteNode, ): string[] => { @@ -107,20 +51,6 @@ export const pluginRouteMessageNamespaces = ( return normalizeNamespaceList([GLOBAL_NAMESPACE, ...declared]); }; -/** - * What a plugin route re-runs its loader for. - * - * A plugin route that declares no `search` registers no router-level - * `validateSearch`, so the search a match carries is whatever was in the query - * string. This turns that into something a match id can be built from without - * depending on the order somebody happened to type the parameters in: `?b=2&a=1` - * and `?a=1&b=2` are one page, and a route that treated them as two would re-run - * its loader and remount its component every time a visitor swapped them. - * - * Keys sorted, `undefined` values dropped, prototype untouched - - * `Object.fromEntries` defines own properties, so a `__proto__` parameter is an - * own key here rather than a setter call. - */ export const pluginRouteSearchDeps = ( search: unknown, ): Record<string, unknown> => { @@ -133,24 +63,6 @@ export const pluginRouteSearchDeps = ( ); }; -/** - * Every configured plugin's route tree, read as the routes a router can mount. - * - * Two things happen here, and both are the last chance to catch a different - * mistake: - * - * **The trees are flattened and validated.** `compilePluginRouteTrees` is the - * same function the build ran over the same declarations - so the routes the - * runtime mounts are provably the routes the build checked, and a plugin built - * against an older VitNode fails here, naming the plugin, rather than becoming a - * page that renders nothing. - * - * **Every path is converted once.** `:slug` in a declaration, `$slug` in the - * router, and a child's path reduced to what it adds to its parent's. - * - * Pure apart from the memo each spec carries: no route is created, no page - * module is imported, and nothing here knows what a router is. - */ export const pluginRouteSpecs = ( sources: readonly PluginRouteDeclarationSource[], ): PluginRouteSpec[] => { diff --git a/packages/vitnode/src/tanstack/realtime/index.ts b/packages/vitnode/src/tanstack/realtime/index.ts index 1d3eca08b..51a853ea8 100644 --- a/packages/vitnode/src/tanstack/realtime/index.ts +++ b/packages/vitnode/src/tanstack/realtime/index.ts @@ -1,12 +1,3 @@ -/** - * The realtime layer of a TanStack Start application shell. - * - * The WebSocket *provider* is not here - it is framework-free and lives in - * `@vitnode/core/ws/provider`, mounted once by the host's root. What this - * namespace owns is the pair of listeners that hang off that one connection and - * the derivation that keeps it authenticated as the right visitor. - */ - export { RealtimeListeners } from "./realtime-listeners"; export type { SocketSession } from "./session"; export { socketUserIdFromSession } from "./session"; diff --git a/packages/vitnode/src/tanstack/realtime/realtime-listeners.tsx b/packages/vitnode/src/tanstack/realtime/realtime-listeners.tsx index ca5784988..ecd01012a 100644 --- a/packages/vitnode/src/tanstack/realtime/realtime-listeners.tsx +++ b/packages/vitnode/src/tanstack/realtime/realtime-listeners.tsx @@ -8,93 +8,6 @@ import { WebSocketAuthSync } from "@/views/layouts/theme/web-socket-auth-sync"; import { sessionQueryOptions } from "../auth/session-query"; import { socketUserIdFromSession } from "./session"; -/** - * The two behaviors that belong to the WebSocket connection: the notification - * toasts, and keeping the socket authenticated as whoever is signed in. - * - * __root VitNodeWebSocketProvider one connection, one lifetime - * __root RealtimeListeners the listener, and the identity - * - * Both components below are shared with the Next.js applications, which mount - * the same pair from `ThemeLayout`. Neither renders anything; this exists to - * give them the one thing they cannot get for themselves in a router-driven app - * - the visitor's id - and to keep that read in a single place rather than in - * each of them. - * - * ## Mount it at the root, not in the shell's `listeners` slot - * - * The slot reads as the tidier answer: `ThemeLayoutContent` has one named - * `listeners` and the Next.js app fills it. It is the wrong place in a TanStack - * Start app whose `/login` sits *outside* the main shell - which is the usual - * arrangement, because an auth screen is a full-height card with no header. - * - * `WebSocketAuthSync` follows a sign-in by seeing the identity change: it seeds - * its ref from its first prop and reconnects only once both sides are known and - * differ. Mounted in a shell the visitor is not inside while they sign in, it - * first mounts at the destination - by which point the session query already - * answers `42`, `shouldReconnectForUser(42, 42)` is `false`, and the socket - * keeps the guest handshake it opened with until the next full page load. The - * visitor is signed in everywhere except the connection that delivers their - * notifications. - * - * The sharp edge is that it half-works without the shell's loader - a stale - * guest session is observed first, so the identity still moves `null -> 42` - - * and breaks the moment the shell prefetches the session for the header's sake. - * Which is to say: it would be introduced by a one-line performance fix, in a - * component neither line mentions. - * - * So the rule is the one the provider already follows: anything whose lifetime - * is *the connection's* is mounted where the connection is. The slot stays in - * `ThemeLayoutContent` for the framework that has somewhere to put it. - * - * ## Where the id comes from - * - * {@link sessionQueryOptions} - the one session definition a VitNode TanStack - * app has, the same one its route guards and its header read. So this makes no - * second request and adds no second key: a guarded route warms that entry in its - * guard, the shell warms it in its loader, and this is a read of whichever one - * got there first. - * - * `useQuery` rather than `useSuspenseQuery`, for two reasons. It must not - * suspend - it sits above every route, and suspending here would hold back the - * whole document for a value only an invisible effect wants. And it must not - * throw: the session read is `retry: false`, so an API outage records a failure - * in that entry, and a suspense read would rethrow it into the nearest boundary - * and take the entire application down with it. Read this way a failure arrives - * as `undefined`, which {@link socketUserIdFromSession} reports as "not known - * yet" and `WebSocketAuthSync` correctly does nothing about - the socket keeps - * whatever identity its handshake gave it. - * - * On a route that warms nothing - an SSO callback - the observer mounted here is - * what fetches the session at all, and that is desirable rather than a cost: - * completing the sign-in invalidates that entry, this refetches it, the identity - * moves, and the socket re-handshakes. During SSR it renders with `undefined` - * and does nothing, which is correct: effects do not run there and there is no - * socket yet. - * - * ## What it does on sign-in and sign-out - * - * Nothing directly - it has no handlers. An auth action brings that one cache - * entry back in step with the cookie before it navigates, this re-renders from - * it, and the identity change is what re-opens the socket so the server re-reads - * the cookie on a fresh handshake. Which is what stops the previous visitor's - * notifications from reaching this browser, with no page reload. - * - * ## No route guard depends on this being mounted - * - * Worth stating because for a while one silently did. The observer below is an - * *active* one, and `invalidateQueries` refetches active queries - so while a - * guard read the session through `ensureQueryData`, which ignores invalidation - * entirely, the refetch performed for **this** component was the only thing - * making a post-sign-in navigation see the new visitor. Moving this into a - * shell's `listeners` slot, or gating it, would have turned every sign-in into a - * bounce back to the login page, in a file neither change mentions. - * - * `ensureAuthState` reads through `fetchQuery` now, which consults the mark - * itself, so the guards are correct with nothing observing the entry at all. - * What this component is still responsible for is its own job: the socket's - * identity. Move it freely on those grounds. - */ export const RealtimeListeners = () => { const { data: session } = useQuery(sessionQueryOptions()); diff --git a/packages/vitnode/src/tanstack/realtime/session.test.ts b/packages/vitnode/src/tanstack/realtime/session.test.ts index 1bc546573..4d33d6de7 100644 --- a/packages/vitnode/src/tanstack/realtime/session.test.ts +++ b/packages/vitnode/src/tanstack/realtime/session.test.ts @@ -2,21 +2,6 @@ import { describe, expect, it } from "vitest"; import { socketUserIdFromSession } from "./session"; -/** - * The realtime contract, on the client's side of it: - * - * session (the canonical query) -> socketUserIdFromSession -> WebSocketAuthSync - * - * Only this derivation is tested, and it is the only part worth testing here. - * What follows it is `shouldReconnectForUser` in `@/ws/auth-sync`, which has its - * own tests, and below that a WebSocket - and a fake socket proves nothing about - * a real handshake, which is where the identity is actually decided. - * - * What can go wrong on this side is the distinction between "signed out" and - * "not known yet". Both are falsy, both would read as a guest, and collapsing - * them re-opens the shared connection on every page load for every signed-in - * visitor - with no error anywhere to say so. - */ describe("socketUserIdFromSession", () => { it("reports the signed-in visitor", () => { expect(socketUserIdFromSession({ user: { id: 42 } })).toBe(42); diff --git a/packages/vitnode/src/tanstack/realtime/session.ts b/packages/vitnode/src/tanstack/realtime/session.ts index 467129d9f..aa156d006 100644 --- a/packages/vitnode/src/tanstack/realtime/session.ts +++ b/packages/vitnode/src/tanstack/realtime/session.ts @@ -1,48 +1,9 @@ import type { VitNodeSocketUserId } from "@/ws/auth-sync"; -/** - * The realtime layer's one derivation, and the type it reads. - * - * Pure by construction - the only import is type-only, so TypeScript erases it - * and this module has no runtime dependencies at all. That is what lets it be - * tested without a session query, a fetcher or the WebSocket behind either of - * them. - */ - -/** - * A session, narrowed to the one field the socket's identity comes from. - * - * A *requirement* rather than a copy of the API's response, the same way - * `UserHeaderUser` is: every application's session type satisfies it - * structurally, so nobody reshapes anything, and a field renamed in - * `api/modules/users/routes/session.route.ts` fails here rather than being - * silently read as `undefined`. - */ export interface SocketSession { user?: null | { id: number }; } -/** - * The visitor the WebSocket should be authenticated as, from the canonical - * session. - * - * Three inputs and three distinct answers, which is the whole point of the - * function: - * - * undefined -> undefined the session is not known yet - * { user: null } -> null the API answered: nobody is signed in - * { user } -> user.id the API answered: this visitor - * - * `undefined` in means the query has not answered - the entry has not been - * warmed, or the read failed and `retry: false` left it in an error state with - * no data. Collapsing that to `null` would be the client inventing a guest, - * which is what a session read must refuse: on a client-side read it is also the - * *first* value of every page load, so `WebSocketAuthSync` would see a sign-out - * and re-open the shared connection each time. - * - * `null` out means signed out, and only that. `shouldReconnectForUser` in - * `@/ws/auth-sync` is the half that acts on the difference. - */ export const socketUserIdFromSession = ( session: SocketSession | undefined, ): undefined | VitNodeSocketUserId => { diff --git a/packages/vitnode/src/tanstack/routes/admin/admin-routes.test.ts b/packages/vitnode/src/tanstack/routes/admin/admin-routes.test.ts index f99a6a0cd..7c7456774 100644 --- a/packages/vitnode/src/tanstack/routes/admin/admin-routes.test.ts +++ b/packages/vitnode/src/tanstack/routes/admin/admin-routes.test.ts @@ -2,30 +2,6 @@ import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -/** - * The AdminCP's screens are `@vitnode/core`'s, and they are declared here. - * - * Static and pure: this directory is read as the text it is. Nothing builds a - * router or renders a screen - `apps/web/src/tests/admin-routes.test.ts` does - * that, against the real tree, which is the only place the question "does - * `/admin/core/users` resolve" can honestly be asked. - * - * ## The regression this exists for - * - * Every one of these screens used to be a route file in the *application*: - * `apps/web/src/routes/_admin/` held seventeen `createFileRoute` calls, and the - * scaffold shipped a copy of all seventeen to every new project. Not one of them - * was the application's - the loader, the component, the breadcrumb and the - * search normaliser all came from this package, and the file existed only so a - * file-based router would see a path. So an app that installed VitNode carried a - * copy of VitNode's own routing table, and core adding a screen was an edit in - * every application that had one. - * - * What this file pins is that they cannot drift back: the declarations are here, - * they are mounted through one exported function, and that function is the only - * way in. - */ - const here = import.meta.dirname; /** Source with its comments removed - prose may name what code may not do. */ @@ -39,16 +15,6 @@ const modules = readdirSync(here) const codeOf = (name: string): string => withoutComments(readFileSync(join(here, name), "utf8")); -/** - * Every AdminCP URL this directory declares. - * - * Read as string and template literals beginning `/admin`, rather than as - * `path:` alone, because two of these screens are built by a factory that takes - * its path as an argument - the staff family, where one declaration spells two - * URLs. A template literal is kept verbatim (`/admin/core/staff/${type}s/edit/$id`), - * which is what the assertions below want: they are about what is claimed, not - * about how many routes come out of it. - */ const everyRoutePath = modules .flatMap(name => [ ...codeOf(name).matchAll(/["`](\/admin(?:\/[^"`\s]*)?)["`]/g), @@ -63,13 +29,6 @@ describe("what this directory declares", () => { expect(everyRoutePath.length).toBeGreaterThan(10); }); - /** - * Every path is a full `/admin/…` URL. - * - * The container these hang from is pathless, so a route's `path` is its whole - * public URL - exactly what it was when the same screen was a file in an - * application. A relative path here would silently move a screen. - */ it("spells every path in full, under /admin", () => { for (const path of everyRoutePath) { expect(path.startsWith("/admin/"), path).toBe(true); @@ -81,17 +40,6 @@ describe("what this directory declares", () => { expect([...new Set(everyRoutePath)]).toEqual(everyRoutePath); }); - /** - * Exactly one splat, and it is the Content Engine's. - * - * A catch-all consumes every admin URL beneath it, so a second one - or a - * wider first - is how the AdminCP breaks: the panel's own not-found stops - * being reachable and a plugin's admin route is shadowed, silently and all at - * once. `/admin/content/$` is the namespace the Content Engine owns outright. - * - * This is also the route that could not have gone through the plugin route - * manifest: a catch-all is not representable in its path grammar, by design. - */ 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. @@ -102,17 +50,6 @@ describe("what this directory declares", () => { expect(everyRoutePath.filter(path => path.includes("$id")).length).toBe(2); }); - /** - * The dashboard is **not** declared here, and that is a framework constraint - * rather than an oversight. - * - * A pathless layout route with no *file* children is dropped from the - * generated route tree outright, and collapses to a full path of `/` where it - * collides with the home page. So an application's `_admin.tsx` needs one - * file-based child with a real path in order to exist at all, and - * `admin.core.index.tsx` - the dashboard, at `/admin/core` - is it. Declaring - * it here as well would be two routes claiming one URL. - */ it("leaves /admin/core to the application's one anchor route file", () => { expect(everyRoutePath).not.toContain("/admin/core"); }); @@ -121,17 +58,6 @@ describe("what this directory declares", () => { describe("how they reach an application", () => { const index = codeOf("index.tsx"); - /** - * One exported mount, and it takes what a package cannot know: the shell to - * hang from, the site's name, and which content types this installation - * configured. - * - * The registry arrives as `loadContentRegistry` - a thunk over a literal - * dynamic import - rather than as the registry itself, because building one - * reaches the whole Content Engine and every plugin's admin form components, - * and this composition is evaluated in the client entry of every page. See - * `CoreAdminRouteContext.loadContentRegistry`. - */ it("exports one mount that takes the host's own bindings", () => { expect(index).toContain("export const withCoreAdminRoutes"); expect(index).toMatch(/mountUnder/); @@ -139,13 +65,6 @@ describe("how they reach an application", () => { expect(index).toMatch(/loadContentRegistry/); }); - /** - * And it is a *function*, not the registry. - * - * The distinction is the whole optimisation: a value would be evaluated where - * the route tree is composed, which is the module every page of the - * application loads first. - */ it("takes the content registry as a thunk rather than as a value", () => { expect(index).toMatch( /loadContentRegistry: \(\) => Promise<ContentFrontendRegistry>/, @@ -153,12 +72,6 @@ describe("how they reach an application", () => { expect(index).not.toMatch(/contentRegistry: ContentFrontendRegistry/); }); - /** - * Under a pathless container of its own, which is what makes mounting - * idempotent - a dev server re-evaluates the module that composes the tree - * without re-evaluating the generated one, and the route it mutates is the - * same object. - */ 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/); @@ -166,11 +79,6 @@ describe("how they reach an application", () => { expect(index).toMatch(/addChildren\(\[\.\.\.siblings, container\]\)/); }); - /** - * And it writes no file and reads no filesystem. These are route objects - * handed to a router, not pages copied into an application - which is the - * whole difference between this and what it replaced. - */ it("writes nothing and reads no filesystem", () => { for (const name of modules) { const code = codeOf(name); @@ -182,14 +90,6 @@ describe("how they reach an application", () => { }); describe("what a screen may not do here", () => { - /** - * The session check belongs to the shell's `beforeLoad` and to nowhere else. A - * screen that read it again could decide something the guard did not. - * - * This assertion followed the screens: it used to read - * `apps/web/src/routes/_admin/*`, and every file it covered is now in this - * directory. - */ it("leaves the session check to the shell's guard", () => { for (const name of modules) { const code = codeOf(name); @@ -199,12 +99,6 @@ describe("what a screen may not do here", () => { } }); - /** - * A screen's own staff permission is checked in its loader, beside the query - * it gates - `requireAdminPermission` - and never in the route declaration. - * These carry topology; the tuple belongs where the API route's own - * declaration can be quoted next to it. - */ it("states no permission tuple in a route declaration", () => { for (const name of modules) { expect(codeOf(name), name).not.toMatch( diff --git a/packages/vitnode/src/tanstack/routes/admin/advanced.tsx b/packages/vitnode/src/tanstack/routes/admin/advanced.tsx index 0624c19c3..1a0c52a64 100644 --- a/packages/vitnode/src/tanstack/routes/admin/advanced.tsx +++ b/packages/vitnode/src/tanstack/routes/admin/advanced.tsx @@ -20,28 +20,10 @@ import { normalizeSearchIndexRouteSearch } from "../../admin/search-index/route- import { TablePendingSkeleton } from "../../pending"; import { routeContext, routeSearch } from "../types"; -/** - * `/admin/core/advanced/cron` - the cron list. - * - * The query, the permission (`cron.can_view`), the namespaces, the title and the - * table are all `../cron`. - * - * No locale prefix, in any language: `DEFAULT_IGNORED_LOCALE_PATHS` lists - * `/admin` with its descendants, so the rewrite neither strips one nor writes - * one. Nothing here mentions a language, and `robots` is the shell's. - */ const cronRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { const route = createRoute({ getParentRoute: () => parentRoute, - /** - * The request, as the only thing the loader re-runs for. - * - * The *normalised* parameters rather than the raw search. The router hands - * `loaderDeps` the validated search merged over everything else that was in - * the query string, so keying on it directly would re-run the loader for a - * stray `?utm_source=` - and, worse, would treat `?first=10` and no `first` - * as two different pages of the same rows. - */ + loaderDeps: ({ search }) => ({ params: cronRouteParams(routeSearch(search)), }), @@ -94,20 +76,10 @@ const cronRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { return route; }; -/** - * `/admin/core/advanced/queue` - the background-task queue. - * - * The query, the permission (`queue.can_view`), the namespaces, the title, the - * status filter and the table are `../queue`. - */ const queueRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { const route = createRoute({ getParentRoute: () => parentRoute, - /** - * The normalised parameters, not the raw search - which for this table - * includes the status filter, so switching it is a different loader run and - * a different cache entry rather than the same rows re-rendered. - */ + loaderDeps: ({ search }) => ({ params: queueRouteParams(routeSearch(search)), }), @@ -161,17 +133,6 @@ const queueRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { return route; }; -/** - * `/admin/core/advanced/search` - the search index's health. - * - * The status query, the permission (`system.can_view`), the namespaces, the - * title, both mutations and the collections table are `../search-index`. - * - * No `loaderDeps`: the screen is one status read, and `?search=` filters the - * collection list *in the browser* rather than in a request - the whole list - * arrives at once. So typing in the search box changes the URL and re-renders, - * and does not re-run the loader. - */ const searchIndexRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { const route = createRoute({ getParentRoute: () => parentRoute, @@ -224,18 +185,6 @@ const searchIndexRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { return route; }; -/** - * The advanced section. - * - * The dashboard at `/admin/core` is deliberately **not** here. It is the one - * AdminCP screen that stays a route file in the application, and the reason is a - * hard constraint of the file-based generator rather than a preference: a - * pathless layout with no file children is dropped from the generated tree - * outright (`buildRouteTreeConfig` skips it) *and* collapses to `/`, where it - * collides with the home page. `_admin` needs one file-based child with a real - * path to exist at all, and the dashboard has been that anchor since it was the - * shell's first child. See `apps/web/src/routes/_admin/admin.core.index.tsx`. - */ export const coreAdvancedRoutes: CoreRouteFactory[] = [ cronRoute, queueRoute, diff --git a/packages/vitnode/src/tanstack/routes/admin/content.tsx b/packages/vitnode/src/tanstack/routes/admin/content.tsx index 1926c93e9..fd53a9998 100644 --- a/packages/vitnode/src/tanstack/routes/admin/content.tsx +++ b/packages/vitnode/src/tanstack/routes/admin/content.tsx @@ -10,51 +10,6 @@ import { breadcrumbGroup } from "../../breadcrumb/model"; import { TablePendingSkeleton } from "../../pending"; import { routeContext, routeSearch } from "../types"; -/** - * `/admin/content/*` - every screen the Content Engine generates. - * - * The slug resolution, the permission, the namespaces, the labels, the list - * query, the table, the row actions, the breadcrumb and the screen shell are - * `../content`. The two things injected are the ones a package cannot know: - * which plugins this installation configured (`loadContentRegistry`) and what - * the site is called (`pageHead`). - * - * ## One splat, three screens, and no file per content type - * - * `$` is the whole path below `/admin/content`, handed to a pure resolver. That - * is what keeps `admin.path` working: a content type may answer at a name its id - * does not spell (`blog.post` at `blog/articles`), and no route tree can be - * generated from ids without breaking it. It is also why `blog/post/create` - * still reaches a content type registered at exactly that path rather than the - * create page of `blog/post` - the resolver tries the exact match first. - * - * ## Why this could never have been a plugin route - * - * A splat is not representable in the plugin manifest's path grammar, which - * parses static and parameter segments and rejects a catch-all in as many words. - * It does not need to be: the manifest's grammar exists so that two *untrusted* - * plugins cannot claim one URL without the build noticing, and core is not one - * of them. A code-based route gets the router's own path syntax, splat included. - * - * ## Why `validateSearch` only carries the search through - * - * 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 `validateSearch` is handed the query string alone, never the path - * params, so it cannot know which content type this URL is for. Normalising is - * therefore the loader's job, where the splat has just resolved. A control that - * changes a page, a sort or a search writes back through the same contract, so - * the address bar stays canonical - only a hand-typed `?orderBy=nonsense` - * survives in the URL, and it renders the default table rather than an error. - * - * ## The splat is narrow on purpose - * - * It claims the Content Engine's namespace and nothing adjacent. A splat one - * level up - `/admin/$` - would swallow every AdminCP URL this router does not - * serve, turning a working sidebar link into a not-found. - * `apps/web/src/tests/admin-routes.test.ts` pins both halves: that this route - * owns `/admin/content/*`, and that it owns nothing else. - */ export const contentAdminRoute: CoreRouteFactory<CoreAdminRouteContext> = ({ loadContentRegistry, pageHead, @@ -68,36 +23,7 @@ export const contentAdminRoute: CoreRouteFactory<CoreAdminRouteContext> = ({ loaderDeps: ({ search }) => ({ search: routeSearch<ContentListRouteSearch>(search), }), - /** - * Two loads, in this order, because the second depends on the first. - * - * `loadContentAdminRoute` resolves which content type and which of the three - * screens this URL is, checks `can_view`, warms the strings and - for a list - * - the page of rows. `loadContentFormScreen` then adds what only a *form* - * URL needs: `can_create` or `can_edit`, the record being edited and its - * translations. It returns nothing at all for a list, so a list navigation - * pays for one call and no requests. - * - * ## Why the Content Engine is imported *inside* the loader - * - * Because a `loader` is a function and a route file is a module. The route - * itself - its path, its search contract, its crumb - is evaluated in the - * client entry, on every page of the application; the body of this function - * runs only when somebody navigates to `/admin/content/*`. Reaching - * `@vitnode/core/content` at module scope put the whole engine on the front - * page's critical path: the resolver, the field specs, `zod`, and through - * the registry every configured plugin's admin form components. - * - * The three imports and the registry are awaited together rather than in - * sequence, so the chunks are fetched in parallel and the loader pays one - * round trip rather than four. - * - * Nothing about *when* the permission check happens changes. - * `loadContentAdminRoute` still calls `requireAdminPermission` inside this - * loader, which the router awaits before it renders the match - and the - * AdminCP session guard on the shell above is untouched and still entirely - * eager. What is deferred is the code, not the check. - */ + // `head` after `loader`, always. loader: async ({ context, deps, params }) => { const [ @@ -132,14 +58,6 @@ export const contentAdminRoute: CoreRouteFactory<CoreAdminRouteContext> = ({ }); route.update({ - /** - * The trail, read from this route's own loader data. - * - * The shell renders it above the route's component, so it cannot be handed - * props from there - `route.useLoaderData()` reaches the same match instead. - * It is declared as an element on `staticData` and rendered inside the - * router, which is what makes the hook legal. - */ staticData: { breadcrumb: breadcrumbGroup(ContentAdminBreadcrumb), }, diff --git a/packages/vitnode/src/tanstack/routes/admin/index.tsx b/packages/vitnode/src/tanstack/routes/admin/index.tsx index 2f624eb93..fb05ce7ed 100644 --- a/packages/vitnode/src/tanstack/routes/admin/index.tsx +++ b/packages/vitnode/src/tanstack/routes/admin/index.tsx @@ -21,17 +21,6 @@ export type { CoreRouteFactory, } from "../types"; -/** - * The pathless route core's own AdminCP screens are mounted under. - * - * The same device `_plugins` is, for the same two reasons: it contributes no URL - * segment, so `/admin/core/users` is served at `/admin/core/users`; and it makes - * the composition **idempotent** - the subtree is one identifiable child of the - * AdminCP shell, so re-running the composition replaces it instead of appending - * a second copy of every screen. In dev, Vite re-evaluates the module that - * composes the tree without re-evaluating `routeTree.gen.ts`, and the route it - * mutates is the same object. - */ export const CORE_ADMIN_ROUTES_ROUTE_ID = "_core-admin"; /** Every AdminCP screen `@vitnode/core` owns. */ @@ -43,46 +32,6 @@ const CORE_ADMIN_ROUTES: CoreRouteFactory<CoreAdminRouteContext>[] = [ ...coreUsersRoutes, ]; -/** - * Mounts core's own AdminCP screens on a route tree, and hands the tree back. - * - * ## What this replaced - * - * A directory of route files in every application. `apps/web/src/routes/_admin/` - * held one `createFileRoute` per AdminCP screen, and every one of them was pure - * wiring: the loader, the component, the breadcrumb and the search normaliser - * all came from `@vitnode/core`, and the file existed only so that a file-based - * router would see a path. So an app that installed VitNode carried a copy of - * VitNode's own routing table, a copy the scaffold shipped to every new project, - * and core adding a screen was an edit in every application that had one. - * - * There is one implementation now, and it is in the package that owns the - * screens. An application mounts them the way it already mounts a plugin's: - * - * const routeTree = withCoreAdminRoutes( - * withPluginRoutes(fileRouteTree, specs, { mountUnder, pageHead }), - * { loadContentRegistry, mountUnder: adminShellRoute, pageHead }, - * ) - * - * ## Why not declared as plugin routes - * - * Because these screens need options a lazily-imported module cannot provide. - * `validateSearch` runs during path matching, before any chunk is fetched, and - * an AdminCP list keeps its whole state in the query string - `?page=999` is - * clamped and *redirected* before anything renders, which no loader-time - * normaliser can do. A splat route (`/admin/content/$`) is not representable in - * a plugin route's path grammar either. Core is not a third-party package and does - * not need that layer's guarantees about untrusted plugins; what it needs is the - * router's own option set, which is what a code-based route is. - * - * ## Idempotent, and a good neighbour - * - * `addChildren` replaces a route's children and mutates in place, so the subtree - * is rebuilt from the mount point's current children with any previous copy of - * itself removed - calling this twice is the same as calling it once. Siblings - * are preserved, which is what lets this and `withPluginRoutes` compose in - * either order: each rebuilds only the container it owns. - */ export const withCoreAdminRoutes = <TRouteTree extends AnyRoute>( routeTree: TRouteTree, { diff --git a/packages/vitnode/src/tanstack/routes/admin/staff.tsx b/packages/vitnode/src/tanstack/routes/admin/staff.tsx index ea7884731..c2caf3fac 100644 --- a/packages/vitnode/src/tanstack/routes/admin/staff.tsx +++ b/packages/vitnode/src/tanstack/routes/admin/staff.tsx @@ -24,46 +24,6 @@ import { breadcrumbGroup } from "../../breadcrumb/model"; import { FormPendingSkeleton, TablePendingSkeleton } from "../../pending"; import { routeContext, routeSearch } from "../types"; -/** - * `/admin/core/staff/admins` and `/admin/core/staff/moderators` - the AdminCP - * staff lists. - * - * One screen over two API endpoints, so both routes come from one factory with - * `type` set differently. Everything that could differ between them - the - * endpoint, the permission module (`staff_admins` / `staff_moderators`) and the - * strings - is derived from that one value inside `../staff`, and reaches the - * component through the loader's own data rather than through a second prop. - * - * ## Why these are code-based routes rather than files in an application - * - * They were `apps/web/src/routes/_admin/admin.core.staff.*.tsx` until this - * module existed: two files of pure wiring, in every VitNode application, - * importing every part of the screen from here. Nothing in them was the - * application's - the loader, the component, the breadcrumb and the search - * normaliser are all `@vitnode/core`'s - so an app that installed VitNode was - * made to carry a copy of VitNode's own routing table, and core adding a screen - * meant an edit in every app that had one. - * - * A *plugin* page solves this by declaring itself in a manifest and being - * imported lazily. These do not, for a reason that belongs to the screens rather - * than to that layer: `validateSearch` runs during **path matching**, before any - * chunk is fetched, and these lists keep their whole state in the query string - - * `?page=999` is clamped and redirected before anything renders. A lazy module - * is too late to shape a URL. - * - * ## What being code-based costs, and what it buys - * - * It costs the generated route tree's type table: a `<Link>` to one of these - * paths carries a plain string rather than a checked route id. That is already - * true of every plugin route, and it is what the AdminCP does anyway - - * `RouterLink` takes an `href`, and the sidebar's destinations arrive from - * `admin-nav.gen.ts` as data. - * - * It buys the whole router option set, which is the point: a real - * `validateSearch`, `loaderDeps`, this route's own `useSearch`/`useNavigate`, - * and a `staticData.breadcrumb` that is an element rather than something walked - * out of a module that has not arrived yet. - */ const staffListRoute = (type: "admin" | "moderator", path: string): CoreRouteFactory => ({ pageHead, parentRoute }) => { @@ -84,11 +44,7 @@ const staffListRoute = path, validateSearch: normalizeStaffRouteSearch, pendingComponent: TablePendingSkeleton, - /** - * Its own component rather than `adminBreadcrumb({ segments })`, because - * two of its crumbs are not in the sidebar under the spellings the page - * uses: `/admin/core/staff` is a nav *group* with no page of its own. - */ + staticData: { breadcrumb: breadcrumbGroup(function StaffBreadcrumb() { return <AdminStaffBreadcrumbContent type={type} />; @@ -96,14 +52,6 @@ const staffListRoute = }, }); - /** - * The component is attached afterwards because it reads the route it is - * part of - `route.useLoaderData()` is how a code-based route reads its own - * match, exactly as `Route.useLoaderData()` did when this was a file. Inside - * the `createRoute` call that would be a circular inference; `update` is the - * router's own way to add options to a route, and is what a file-based route - * does internally. - */ route.update({ component: lazyRouteComponent(async () => { const [{ AdminStaffRouteContent }, { RouterLink }] = await Promise.all([ @@ -144,15 +92,6 @@ const staffListRoute = return route; }; -/** - * `/admin/core/staff/<type>/create` - adding a role or a user to a staff group. - * - * A created entry grants nothing until its permissions are chosen, so a - * successful create goes to the *edit* screen rather than back to the list - - * landing on the list would look like the create had silently done nothing. - * Where that is is `staffEditHref(type, id)`, decided in `../staff`: the package - * owns VitNode's URL shape, and the route only performs the navigation. - */ const staffCreateRoute = (type: "admin" | "moderator", path: string): CoreRouteFactory => ({ pageHead, parentRoute }) => { @@ -206,15 +145,6 @@ const staffCreateRoute = return route; }; -/** - * `/admin/core/staff/<type>/edit/12` - what one staff entry may do. - * - * The public URL is an ordinary one; `$id` is only how a route tree spells "any - * segment here". `loadAdminStaffEditRoute` turns it into an entry id and answers - * `notFound()` for anything that is not one, so `.../edit/abc` is a not-found - * screen rather than a request carrying `NaN`. That normalisation is - * deliberately not in `params.parse` - see `./users` for why. - */ const staffEditRoute = (type: "admin" | "moderator", path: string): CoreRouteFactory => ({ pageHead, parentRoute }) => { @@ -269,13 +199,6 @@ const staffEditRoute = return route; }; -/** - * Every staff screen, both groups, in the order the sidebar names them. - * - * `$id` rather than `:id`: this is a TanStack route path, not a VitNode manifest - * path. The manifest's own spelling is a plugin-facing contract; these are - * router options and use the router's. - */ export const coreStaffRoutes: CoreRouteFactory[] = ( ["admin", "moderator"] as const ).flatMap(type => [ diff --git a/packages/vitnode/src/tanstack/routes/admin/system.tsx b/packages/vitnode/src/tanstack/routes/admin/system.tsx index 53d547579..2dc0f8aff 100644 --- a/packages/vitnode/src/tanstack/routes/admin/system.tsx +++ b/packages/vitnode/src/tanstack/routes/admin/system.tsx @@ -19,18 +19,6 @@ import { loadAdminIntegrationsRoute } from "../../admin/integrations/route"; import { CardsPendingSkeleton, TablePendingSkeleton } from "../../pending"; import { routeContext, routeSearch } from "../types"; -/** - * `/admin/core/system/files` - every file uploaded to the installation. - * - * The query, the three permissions (`files.can_view` to open it, - * `files.can_download` and `files.can_delete` for the row controls), the - * namespaces, the title, the search box and both deletes are `../files`. - * - * Not to be confused with `/files` under `_main/_authenticated`, which is the - * *visitor's own* files: a different endpoint, a different permission and a - * different cache family. That one is genuinely the application's page and stays - * a route file in it. - */ const filesRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { const route = createRoute({ getParentRoute: () => parentRoute, @@ -88,15 +76,6 @@ const filesRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { return route; }; -/** - * `/admin/core/system/integrations` - the integrations board. - * - * The least of it: this screen has no search parameters, so there is no - * `validateSearch` and no `loaderDeps` - the loader runs once per navigation. - * The query, the permission (`system.can_view`, plus `system.can_test_ai` / - * `can_test_storage` / `can_send_test_email` for the three test buttons), the - * namespaces, the title and the nine cards are `../integrations`. - */ const integrationsRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { const route = createRoute({ getParentRoute: () => parentRoute, @@ -135,32 +114,10 @@ const integrationsRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { return route; }; -/** - * `/admin/core/debug` - the debug panel: the queue snapshot, the system log, and - * "clear the cache". - * - * The queries, the permission (`debug.can_view` to open it, - * `debug.can_clear_cache` for the button), the namespaces, the titles and all - * three sections are `../debug`. - * - * No `LinkComponent`: the log's detail dialog links a line to the user who - * caused it, at `/admin/core/users/{id}`, and the screen's default - - * `RouterLink` - is exactly right for a route in this tree. - * - * ## No breadcrumb, deliberately - * - * `staticData.breadcrumb: null` rather than nothing at all, because `undefined` - * would *inherit* a parent's crumb rather than mean "this page has none". - * Giving the panel a trail is a product decision. - */ const debugRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { const route = createRoute({ getParentRoute: () => parentRoute, - /** - * The system log's parameters. It is the only thing on the screen with URL - * state - the queue snapshot has no pager and the clear-cache button writes - * nothing - so the screen's search is the log table's. - */ + loaderDeps: ({ search }) => ({ params: debugLogsRouteParams(routeSearch(search)), }), diff --git a/packages/vitnode/src/tanstack/routes/admin/users.tsx b/packages/vitnode/src/tanstack/routes/admin/users.tsx index f9017687d..825e3e2ea 100644 --- a/packages/vitnode/src/tanstack/routes/admin/users.tsx +++ b/packages/vitnode/src/tanstack/routes/admin/users.tsx @@ -21,35 +21,10 @@ import { breadcrumbGroup } from "../../breadcrumb/model"; import { FormPendingSkeleton, TablePendingSkeleton } from "../../pending"; import { routeContext, routeSearch } from "../types"; -/** - * `/admin/core/users` - the AdminCP users list. - * - * The query, the permissions (`users.can_view` to open it, `users.can_create` - * for the button, `users.can_edit` for the rows), the namespaces, the title, the - * table and the role filter's lookup are all `../users`. What is here is - * topology and the three things only a router can give a route: the search - * contract, `navigate`, and the crumb. - * - * `RouterLink` is passed rather than defaulted because the screen takes the link - * as a required prop - a row's pencil points at `/admin/core/users/123`, and the - * shared table below it is host-neutral and may not import a router itself. - * - * No locale prefix, in any language: `DEFAULT_IGNORED_LOCALE_PATHS` lists - * `/admin` with its descendants, so the rewrite neither strips one nor writes - * one. `robots` is the AdminCP shell's. - */ const usersListRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { const route = createRoute({ getParentRoute: () => parentRoute, - /** - * The request, as the only thing the loader re-runs for. - * - * The *normalised* parameters rather than the raw search: the router hands - * `loaderDeps` the validated search merged over everything else that was in - * the query string, so keying on it directly would re-run the loader for a - * stray `?utm_source=` - and would treat `?first=10` and no `first` as two - * different pages of the same rows. - */ + loaderDeps: ({ search }) => ({ params: usersRouteParams(routeSearch(search)), }), @@ -106,18 +81,6 @@ const usersListRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { return route; }; -/** - * `/admin/core/users/roles` - the AdminCP roles list. - * - * A sibling of `users/$id` rather than a child of it: `roles` is a static - * segment and TanStack ranks those above dynamic ones, so `/admin/core/users/roles` - * matches this route and never `$id`. `admin-routes.test.ts` pins it against the - * real tree, which is what makes the ranking a checked fact rather than a - * remembered one. - * - * The query, the six permissions the row actions apply, the namespaces, the - * title, the table and both dialogs are `../roles`. - */ const rolesRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { const route = createRoute({ getParentRoute: () => parentRoute, @@ -139,11 +102,6 @@ const rolesRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { }, }); - /** - * The members count links to `/admin/core/users?roleId=<id>`, whose query - * string has to survive the hop - `RouterLink` hands the whole href to the - * router, so it does. - */ route.update({ component: lazyRouteComponent(async () => { const [{ AdminRolesRouteContent }, { RouterLink }] = await Promise.all([ @@ -182,20 +140,6 @@ const rolesRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { return route; }; -/** - * `/admin/core/users/123` - one user. - * - * The public URL is an ordinary one: a number in the path, no prefix, nothing - * encoded. `$id` is only how a route tree spells "any segment here", and the - * segment is turned into an id by `loadAdminUserRoute` - which answers - * `notFound()` for anything that is not a decimal id, so `/admin/core/users/abc` - * is a not-found screen rather than a request carrying `NaN`. - * - * That normalisation is deliberately *not* in `params.parse`. `parse` runs - * inside `matchRoutes`, which the router calls on every navigation and every - * `<Link>` it builds - so a `parse` that threw would take down far more than the - * one screen with a bad id in its URL. - */ const userRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { const route = createRoute({ getParentRoute: () => parentRoute, @@ -215,11 +159,6 @@ const userRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { }, }); - /** - * The screen takes the link as a required prop - it links out to - * `/profile/<nameCode>` - and the shared views below it may not import a - * router themselves, so core's own `RouterLink` is supplied here. - */ route.update({ component: lazyRouteComponent(async () => { const [{ AdminUserRouteContent }, { RouterLink }] = await Promise.all([ diff --git a/packages/vitnode/src/tanstack/routes/index.ts b/packages/vitnode/src/tanstack/routes/index.ts index a1348ad41..f5ed2e15a 100644 --- a/packages/vitnode/src/tanstack/routes/index.ts +++ b/packages/vitnode/src/tanstack/routes/index.ts @@ -1,64 +1,3 @@ -/** - * `@vitnode/core/tanstack/routes` - every route VitNode itself owns. - * - * ## What is here, and what is not - * - * **Routes only.** A file in this tree declares a URL, a loader, a guard, a - * `head` and a breadcrumb - and imports the screen it renders from the namespace - * that owns it. Nothing here is a component: the sign-in card lives in - * `tanstack/auth`, the settings frame in `tanstack/settings`, the AdminCP's - * staff list in `tanstack/admin/staff`. This tree is the *routing* over them, - * and the split is what lets a Next.js host use the screens without any of it. - * - * ## Three folders, one per mount point - * - * main/ under the application's main shell /discover, /login, /register, /settings - * admin/ under the AdminCP shell /admin/core/…, /admin/content/* - * root/ under no shell at all /admin - * - * `main` and `admin` are named after the shell they hang from, so `root` is - * named after its mount point too: its screens are children of the root route, - * with nothing between. - * - * Exactly one screen is in `root/`, and the exception is what the folder is for: - * the AdminCP's own sign-in has to sit *outside* the AdminCP shell or that - * shell's guard would loop. The public auth screens are not exceptions - an auth - * card is a page on the public site, so `/login`, `/register`, - * `/login/reset-password` and `/login/sso/:providerId` render inside the main - * shell with the site's header above them, and so does the 404 that `main/` - * declares for every URL no route claims. - * - * ## What replaced what - * - * Twenty-nine route files in every application, and a copy of all of them in the - * scaffold. `apps/web/src/routes/` held one `createFileRoute` per screen and not - * one of them was the application's: the loader, the component, the search - * normaliser and the breadcrumb all came from this package, and the file existed - * only so a file-based router would see a path. So an app that installed VitNode - * carried a copy of VitNode's own routing table, and core adding a screen was an - * edit in every application that had one - the same duplication a copied plugin - * page is, one package up. - * - * ## What an application still owns - * - * Its shells (`__root`, `_main`, `_admin`), its front page, and anything it - * wrote itself. Each shell also keeps exactly one file-based child, because a - * pathless layout with no file children is dropped from the generated route tree - * and collapses to `/`. - * - * ## Why these are code-based routes and not plugin manifest entries - * - * They need options a lazily-imported module cannot provide. `validateSearch` - * runs during path matching, before any chunk is fetched, and these screens keep - * their state in the query string; a `beforeLoad` guard has the same problem one - * level up, since a requirement in a lazy module could only be read by - * downloading the page it was meant to withhold; and a splat - * (`/admin/content/$`) is not representable in the manifest's path grammar at - * all. Core is not a third-party package and does not need that layer's - * guarantees about untrusted plugins - what it needs is the router's own option - * set, which is what a code-based route is. - */ - export { CORE_ADMIN_ROUTES_ROUTE_ID, withCoreAdminRoutes } from "./admin"; export { CORE_AUTHENTICATED_ROUTES_ROUTE_ID, diff --git a/packages/vitnode/src/tanstack/routes/main/auth.tsx b/packages/vitnode/src/tanstack/routes/main/auth.tsx index c5d3349de..69cfc8fa7 100644 --- a/packages/vitnode/src/tanstack/routes/main/auth.tsx +++ b/packages/vitnode/src/tanstack/routes/main/auth.tsx @@ -28,33 +28,6 @@ import { canAccessGuestRoute } from "../../auth/state"; import { AuthPendingSkeleton } from "../../pending"; import { routeContext, routeSearch } from "../types"; -/** - * `/login` - the sign-in card. - * - * Guest-only, decided before anything renders, so a signed-in visitor never sees - * the form - not for a frame. - * - * `?returnTo=` names wherever they were heading, and it is theirs to supply - so - * it goes through two questions in order. `postAuthDestination` answers whether - * this app may send a browser there at all (`sanitizeReturnTo` rejects every - * origin and scheme spelling, and the loop guard rejects the login page itself), - * and `internalDestination` answers what the router wants to be handed. - * - * Expressed as redirect *options* rather than as a navigation, because the same - * shape works in both environments: on the server the router turns it into an - * HTTP redirect and in the browser into a client navigation. Nothing here - * touches `window`, which a `beforeLoad` running during SSR does not have. - * - * **`to` rather than `href`.** A redirect carrying `href` is used verbatim by - * `Router.resolveRedirect` - it never reaches `buildLocation`, so it would skip - * the locale rewrite and drop a Polish visitor on the English page. - * `internalDestination` returns the split shape for exactly that reason, and - * strips the prefix off a `returnTo` that arrived carrying one. - * - * `ensureAuthState` rejects when the session could not be read at all, and that - * rejection propagates: only a session the API actually answered can send - * anybody anywhere. - */ const loginRoute: CoreAuthRouteFactory = ({ localeRouting, pageHead, @@ -66,13 +39,7 @@ const loginRoute: CoreAuthRouteFactory = ({ const route = createRoute({ getParentRoute: () => parentRoute, - /** - * What a stranger may put in `?returnTo=` is the same question on every - * VitNode install, so the contract is the package's. It keeps whatever - * arrived and judges nothing - `sanitizeReturnTo` is the single answer to - * whether a target is somewhere this app may navigate to, and it is applied - * where the value is *used*. - */ + validateSearch: normalizeLoginSearch, beforeLoad: async ({ context, search }) => { const auth = await ensureAuthState( @@ -123,22 +90,6 @@ const loginRoute: CoreAuthRouteFactory = ({ return route; }; -/** - * `/register` - creating an account. - * - * Guest-only through the same predicate `/login` uses. There is no second guard - * implementation here and there must not be, so "signed in" cannot come to mean - * two different things on two pages. - * - * A signed-in visitor goes to the front page and only the front page. This route - * takes **no `returnTo`**, because nothing sends one: the login card's "create an - * account" link is a bare `/register`, and inventing a parameter here would be a - * behaviour nothing asked for. - * - * `parseInternalDestination` rather than `href`, so the redirect goes through - * `buildLocation` and the locale rewrite writes the prefix back - a Polish - * visitor is sent to `/pl`, not to `/`. - */ const registerRoute: CoreAuthRouteFactory = ({ localeRouting, pageHead, @@ -192,33 +143,6 @@ const registerRoute: CoreAuthRouteFactory = ({ return route; }; -/** - * `/login/reset-password` - requesting a recovery link, and using one. - * - * Password recovery only exists on a deployment that can send email - and "we - * could not find out" is a third answer, not a fourth spelling of no. - * - * The API mails the reset link through the configured email adapter, so with no - * adapter the form's submit could never arrive, which is why a disabled - * deployment answers `notFound()` rather than rendering it. - * - * What is deliberately *not* answered the same way is a configuration that could - * not be read. The fallback the config query degrades to says `isEmail: false` - - * correct for the login form - and reading that as a boolean here turned an API - * outage into a **404**: the application asserting the page does not exist - * because it could not reach its own API, to a visitor holding a valid recovery - * link. `passwordRecoveryAvailability` separates the two, and the outage takes - * the router's ordinary error path instead. - * - * It sits in `beforeLoad` because the response *status* depends on it: the - * router's server pass resolves the boundary before the stream opens and answers - * 404 for a genuinely disabled deployment, so the status is right without this - * route setting one by hand. - * - * `login_` in the file-based spelling meant "do not nest under `/login`". A - * code-based route needs no such escape: it is a sibling because it is declared - * as one, and the path says the rest. - */ const passwordResetRoute: CoreAuthRouteFactory = ({ pageHead, parentRoute, @@ -242,13 +166,7 @@ const passwordResetRoute: CoreAuthRouteFactory = ({ // eslint-disable-next-line @typescript-eslint/only-throw-error if (availability === "disabled") throw notFound(); }, - /** - * The loader re-runs when the *mode* changes, and only then - a different - * token is the same screen. Without this it would warm the namespaces for - * whichever screen the page was first opened with and never again, so - * following a fresh recovery link from an already-open request form would - * mount a provider for a set nobody fetched. - */ + loaderDeps: ({ search }) => ({ mode: passwordResetMode(routeSearch(search)).mode, }), @@ -261,12 +179,7 @@ const passwordResetRoute: CoreAuthRouteFactory = ({ head: ({ loaderData }) => pageHead({ ...loaderData }), path: "/login/reset-password", pendingComponent: AuthPendingSkeleton, - /** - * Code-split like the screen it belongs to: the router preloads a - * `notFoundComponent` only once a route has actually answered `notFound()`, - * so nothing about the eager graph pays for a deployment that *can* send - * email. - */ + notFoundComponent: lazyRouteComponent(async () => { const [{ PasswordRecoveryNotFound }, { ErrorActions }] = await Promise.all([ diff --git a/packages/vitnode/src/tanstack/routes/main/discovery.tsx b/packages/vitnode/src/tanstack/routes/main/discovery.tsx index b1db99ad7..215c12628 100644 --- a/packages/vitnode/src/tanstack/routes/main/discovery.tsx +++ b/packages/vitnode/src/tanstack/routes/main/discovery.tsx @@ -8,23 +8,6 @@ import { normalizeSearchRouteSearch } from "../../search/route-search"; import { loadSearchRoute } from "../../search/search-route"; import { routeContext, routeSearch } from "../types"; -/** - * `/discover` - the public feed. - * - * One route serving two public URLs. `/discover` and `/pl/discover` match this - * one: the locale is stripped before matching and written back into every link - * the router builds, so nothing here mentions a language and there is no - * `/pl/discover` to keep in step. - * - * Everything the page *is* - which namespaces it warms, the feed it ensures, the - * two strings its heading and tab title share, and the markup below both - is - * `../../search`. - * - * A search result points wherever the indexed content lives, and the shared feed - * is host-neutral by design, so the link is a required prop rather than an - * import and core's own `RouterLink` supplies it. External and unsafe URLs never - * reach it: `SearchFeedContent` classifies those and renders them itself. - */ const discoverRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { const route = createRoute({ getParentRoute: () => parentRoute, @@ -61,23 +44,10 @@ const discoverRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { return route; }; -/** - * `/search` - the same feed, with a query. - * - * The search box, the type filters, the sort, the feed, the namespaces they are - * translated through and the one query definition the loader and the controls - * share are all `../../search`. - */ const searchRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { const route = createRoute({ getParentRoute: () => parentRoute, - /** - * The loader re-runs when the term in the URL changes, and only then. - * Without this it would warm the feed for whatever term the page was first - * opened with and never again, so following a link from `/search?search=hono` - * to `/search?search=drizzle` would render the first result set and fetch the - * second from the browser. - */ + loaderDeps: ({ search }) => ({ search: routeSearch<{ search: string }>(search).search, }), diff --git a/packages/vitnode/src/tanstack/routes/main/files.tsx b/packages/vitnode/src/tanstack/routes/main/files.tsx index 302e1ff8a..338930bde 100644 --- a/packages/vitnode/src/tanstack/routes/main/files.tsx +++ b/packages/vitnode/src/tanstack/routes/main/files.tsx @@ -11,30 +11,10 @@ import { import { TablePendingSkeleton } from "../../pending"; import { routeContext, routeSearch } from "../types"; -/** - * `/files` - the visitor's own files. - * - * One route serving two public URLs: the locale is stripped before matching and - * written back into every link the router builds, so nothing here mentions a - * language. - * - * Not to be confused with `/admin/core/system/files`, which is *every* file in - * the installation - a different endpoint, a different permission and a different - * cache family. - */ export const myFilesRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { const route = createRoute({ getParentRoute: () => parentRoute, - /** - * The request, as the only thing the loader re-runs for. - * - * The *normalised* parameters rather than the raw search, and that is what - * makes this exact. The router hands `loaderDeps` the validated search - * merged over everything else that was in the query string, so keying on it - * directly would re-run the loader for a stray `?utm_source=` - and, worse, - * would treat `?first=10` and no `first` as two different pages of the same - * rows. - */ + loaderDeps: ({ search }) => ({ params: myFilesRouteParams(routeSearch(search)), }), diff --git a/packages/vitnode/src/tanstack/routes/main/index.tsx b/packages/vitnode/src/tanstack/routes/main/index.tsx index 746d3c3c4..06fac4be9 100644 --- a/packages/vitnode/src/tanstack/routes/main/index.tsx +++ b/packages/vitnode/src/tanstack/routes/main/index.tsx @@ -29,46 +29,10 @@ export type { CoreRouteFactory, } from "../types"; -/** - * The pathless route core's own public screens are mounted under. - * - * Pathless, so it contributes no URL segment - `/discover` is served at - * `/discover` - and identifiable, which is what makes the composition - * idempotent: the subtree is one child of the main shell, so re-running it - * replaces itself instead of appending a second copy of every screen. In dev, - * Vite re-evaluates the module that composes the tree without re-evaluating - * `routeTree.gen.ts`, and the route it mutates is the same object. - */ export const CORE_MAIN_ROUTES_ROUTE_ID = "_core-main"; -/** - * The pathless route core's **signed-in** screens are mounted under, and the - * guard they sit behind. - * - * Nested inside the public container rather than beside it, so `/files` and the - * settings subtree inherit the guard by being its children and cannot forget to - * check a session. Not one of the screens below contains the word "session". - */ export const CORE_AUTHENTICATED_ROUTES_ROUTE_ID = "_core-authenticated"; -/** - * Core's public screens - everything a signed-out visitor may open. - * - * The four auth screens are in this list rather than in `withCoreRootRoutes`, - * and that is the one placement worth reading twice. An auth card is a page on - * the public site: it wants the site header above it (its own layout already - * reserves the `4rem` that header occupies), the same `<main>` landmark, and the - * way back to the front page that the header is. Only the AdminCP's own sign-in - * is genuinely shell-less, because it has to sit outside the AdminCP shell or - * that shell's guard would send a denied visitor into a route that sends them - * back. - * - * `notFoundRoute` is last for the reader rather than for the router - a splat - * ranks below every other segment kind wherever it is declared - and it is here - * for the same reason the auth screens are: a 404 should look like the site it - * could not find a page on. See {@link notFoundRoute} for why an unmatched URL - * needs a *route* rather than a `notFoundComponent` on the shell. - */ const CORE_PUBLIC_ROUTES: CoreAuthRouteFactory[] = [ ...coreDiscoveryRoutes, ...coreAuthRoutes, @@ -82,42 +46,6 @@ const CORE_AUTHENTICATED_ROUTES: CoreRouteFactory[] = [ settingsRoute, ]; -/** - * The boundary every page that requires a signed-in visitor sits under. - * - * ## Why the check is in `beforeLoad` - * - * It runs before the route's loader and long before React, so an anonymous - * visitor never receives a byte of a protected page - not a flash, not a - * hydration, not a `useEffect` that redirects afterwards. A component-level - * check would render the page first and then take it away, which is both a - * visible flicker and, on the server, protected markup already written into the - * stream. - * - * ## A failed session read is not a signed-out visitor - * - * `ensureAuthState` rejects when the session could not be read - a rate limit, a - * 500, an API that is not listening - and that rejection is deliberately left to - * propagate. Only `canAccessAuthenticatedRoute` answering `false`, on a session - * the API actually returned, sends anybody to the login page. Catching the - * rejection and redirecting would sign a visitor out because of an outage, which - * is precisely the bug this shape exists to prevent. - * - * ## What it is not - * - * A navigation guard, and only that. Every private read is authorized by Hono - * from the session cookie, in the API's own handlers - so a visitor who edits a - * cached session in devtools gets a page shell and an API that still refuses - * them. Nothing here is, or may become, the security boundary. - * - * ## What children receive - * - * `beforeLoad`'s return merges into the context of everything below, so a child - * route reads `context.auth` already narrowed to the signed-in half of the union - * - `auth.user` is non-null without a check. It is the same object the guard - * decided on, from the same cache entry, so a page cannot disagree with the - * guard that let it render. - */ const authenticatedContainer = (parentRoute: AnyRoute): AnyRoute => createRoute({ getParentRoute: () => parentRoute, @@ -149,85 +77,10 @@ const authenticatedContainer = (parentRoute: AnyRoute): AnyRoute => return { auth }; }, - /** - * The guard's own wait, given the shape of the page it is guarding. - * - * Router core will not open a pending window for a *retained* match, and - * once a visitor is inside this container every navigation within it is one - * - so the session read above ran with the previous page still on screen - * and nothing to say so. `RouteGuardPending` renders the destination's own - * `pendingComponent` for exactly as long as that read takes, on the router's - * own threshold, and hands back to the router the moment it opens its - * window - with the same component, so nothing on screen changes. - */ + component: GuardedOutlet, }); -/** - * Mounts core's own public screens on a route tree, and hands the tree back. - * - * ## What this replaced - * - * Route files in every application. `apps/web/src/routes/_main/` held one - * `createFileRoute` per screen - discover, search, my files, the settings frame - * and its four panels, plus the pathless guard above them - and every one of - * them was wiring: the loader, the component, the search normaliser and the - * breadcrumb all came from `@vitnode/core`, and the file existed only so a - * file-based router would see a path. So an app that installed VitNode carried a - * copy of VitNode's own routing table, the scaffold shipped that copy to every - * new project, and core adding a screen meant an edit in each of them. - * - * There is one implementation now, in the package that owns the screens. An - * application mounts them the way it already mounts a plugin's: - * - * const routeTree = withCoreMainRoutes(routeTree, { - * localeRouting, - * mountUnder: mainShellRoute, - * pageHead, - * }) - * - * ## Why `localeRouting` is injected and `pageHead` is not enough - * - * The auth screens. A sign-in performs a navigation nobody clicked, to a path a - * *visitor* supplied through `?returnTo=`. The route tree carries no locale, so - * what the router is handed must not either - and stripping the prefix means - * knowing which prefixes exist, which is the installation's answer and not this - * package's. See `createAuthNavigation` in `@vitnode/core/tanstack/auth`; the - * app's own `localeRouting` is the same object its router's `rewrite` uses, so - * the strip and the write-back are one rule running in two directions. - * - * ## Why not declared as plugin routes - * - * Because these need options a lazily-imported module cannot provide. - * `validateSearch` runs during path matching, before any chunk is fetched, and - * `/search`, `/files` and the AdminCP's lists keep their state in the query - * string. A `beforeLoad` guard has the same problem one level up: a requirement - * that lived in a lazy module could only be read by downloading the page it was - * meant to withhold. Core is not a third-party package and does not need that - * layer's guarantees about untrusted plugins; what it needs is the router's own - * option set, which is what a code-based route is. - * - * ## What an application still owns - * - * Its home page, and the shell. `_main/index.tsx` is the site's own front page - - * nothing about it is VitNode's - and it is also the one file-based child - * `_main.tsx` needs in order to exist: a pathless layout with no file children is - * dropped from the generated route tree and collapses to `/`. - * - * What it no longer owns is the 404. `__root`'s `notFoundComponent` is still - * declared and still correct as a last resort, but the URL a visitor actually - * mistypes is answered by {@link notFoundRoute} in this container, inside the - * shell - so the screen that says a page is missing is not itself missing the - * site. - * - * ## Idempotent, and a good neighbour - * - * `addChildren` replaces a route's children and mutates in place, so the subtree - * is rebuilt from the mount point's current children with any previous copy of - * itself removed - calling this twice is the same as calling it once. Siblings - * are preserved, which is what lets this and `withPluginRoutes` compose in - * either order: each rebuilds only the container it owns. - */ export const withCoreMainRoutes = <TRouteTree extends AnyRoute>( routeTree: TRouteTree, { diff --git a/packages/vitnode/src/tanstack/routes/main/main-routes.test.ts b/packages/vitnode/src/tanstack/routes/main/main-routes.test.ts index 484418910..ecb5def99 100644 --- a/packages/vitnode/src/tanstack/routes/main/main-routes.test.ts +++ b/packages/vitnode/src/tanstack/routes/main/main-routes.test.ts @@ -2,22 +2,6 @@ import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -/** - * The screens `@vitnode/core` mounts under an application's main shell. - * - * Static and pure: this directory is read as the text it is. Whether `/login` - * resolves is a question for a real route tree, and this suite deliberately does - * not build one - what it pins is the shape of what an application composes. - * - * ## The two things that moved here - * - * The four public auth screens, and the 404. Both used to render with no shell - * at all - the auth screens from `root/`, the 404 from `__root`'s - * `notFoundComponent` - and both are pages on the public site: they want the - * header above them, the same `<main>` landmark, and the way back to the front - * page that the header is. - */ - const here = import.meta.dirname; /** Source with its comments removed - prose may name what code may not do. */ @@ -48,27 +32,10 @@ describe("what this directory declares", () => { } }); - /** - * `/admin` is the AdminCP's own sign-in and stays in `root/`. It reads a - * different session under a different cookie, so a site header offering the - * public "sign in" beside it would be one page asking for two unrelated - * logins - and it has to sit outside the AdminCP shell or that shell's guard - * would loop. - */ it("does not declare the AdminCP's own sign-in", () => { expect(everyRoutePath).not.toContain("/admin"); }); - /** - * `/login/reset-password` is a **sibling** of `/login`, not a child. - * - * The file-based spelling needed `login_.reset-password.tsx` to say so - the - * trailing underscore meaning "do not nest under `/login`". A code-based route - * needs no such escape: it is a sibling because it is declared as one, and the - * path says the rest. What matters either way is that `/login` consumes - * exactly `/login`, so a URL below it that no route declares does not render - * the sign-in card. - */ it("declares no route nested under another", () => { for (const path of everyRoutePath) { const parents = everyRoutePath.filter( diff --git a/packages/vitnode/src/tanstack/routes/main/not-found-matching.test.ts b/packages/vitnode/src/tanstack/routes/main/not-found-matching.test.ts index 69fa2d8cf..80e866c90 100644 --- a/packages/vitnode/src/tanstack/routes/main/not-found-matching.test.ts +++ b/packages/vitnode/src/tanstack/routes/main/not-found-matching.test.ts @@ -9,21 +9,6 @@ import { describe, expect, it } from "vitest"; import { withCoreMainRoutes } from "."; import { routeHead } from "../../metadata"; -/** - * The claim `not-found.tsx` rests on, checked against a real router. - * - * The reasoning is subtle enough to be worth a machine's opinion rather than a - * comment's: a URL nothing matches makes router core hand back the **root route - * alone**, so a `notFoundComponent` on a pathless shell the URL never reached - * cannot be the boundary - and a splat *under* that shell is what puts the shell - * back in the branch, header and `<main>` included. - * - * The tree is the shape every VitNode host composes - a root, a pathless `_main` - * with one file-based child, and `withCoreMainRoutes` over it - built here so the - * assertions are about the composition rather than about one application's - * routes. - */ - const localeRouting = { deLocalizeUrl: (url: URL) => url }; const pageHead = () => routeHead({ shortTitle: "VitNode", title: "VitNode" }); @@ -86,11 +71,6 @@ const branchFor = ( describe("an unmatched URL, without the catch-all", () => { const router = buildRouter({ withCoreRoutes: false }); - /** - * The whole reason the 404 is a route. The shell is not in the branch, so - * nothing mounted under it - a `notFoundComponent`, a header, the `<main>` - * landmark - can be reached for this URL. - */ it("matches the root route and nothing else", () => { expect(branchFor(router, "/blahblah")).toEqual(["__root__"]); }); @@ -115,11 +95,6 @@ describe("an unmatched URL, with it", () => { }, ); - /** - * And it shadows nothing. A splat is the lowest-ranked segment kind in router - * core's matcher, so every screen that declares a path still wins - which is - * what makes mounting it beside them safe. - */ it.each([ ["/", "/"], ["/login", "/login"], @@ -138,11 +113,6 @@ describe("an unmatched URL, with it", () => { expect(matched?.fullPath).toBe(path); }); - /** - * The auth screens are children of the shell now, which is the other half of - * this change: they render with the site header above them rather than on an - * otherwise empty document. - */ it.each([ "/login", "/register", diff --git a/packages/vitnode/src/tanstack/routes/main/not-found.tsx b/packages/vitnode/src/tanstack/routes/main/not-found.tsx index f896bc350..abe40eef9 100644 --- a/packages/vitnode/src/tanstack/routes/main/not-found.tsx +++ b/packages/vitnode/src/tanstack/routes/main/not-found.tsx @@ -6,57 +6,6 @@ import { import type { CoreRouteFactory } from "../types"; -/** - * Every public URL this application does not serve - inside the site's own - * shell. - * - * ## Why this is a route rather than a `notFoundComponent` - * - * Because a `notFoundComponent` on the main shell would never run. Router core - * matches a path against the route tree first, and when nothing matches at all - * the branch it hands back is the **root route alone** - see `getMatchedRoutes`, - * which falls back to `[routesById["__root__"]]`. `findGlobalNotFoundRouteId` - * then looks for a boundary within *that* branch, so a pathless layout the URL - * never reached is not a candidate, whatever `notFoundMode` says. The shell's - * header, its breadcrumb area and its `<main>` were therefore unreachable for - * the one screen that most needs to look like the site: the 404. - * - * A splat that matches puts the branch back. `/blahblah` now matches this, which - * means the main shell above it is matched too - so its loader runs, the header - * gets the warm cache entry it reads with `useSuspenseQuery`, and the message - * renders inside the same document every other page renders inside. - * - * ## It answers 404, and `beforeLoad` is what makes it one - * - * A route that simply *rendered* the message would serve it with a `200`, which - * would tell a crawler the page exists. So `beforeLoad` answers `notFound()` and - * the screen is this route's own `notFoundComponent`: the router resolves the - * boundary on its server pass, before the stream opens, and the response is a - * 404 - the same mechanism `/login/reset-password` uses on a deployment that - * cannot send email. - * - * It is `beforeLoad` rather than a loader for a second reason, and it is the one - * that keeps the shell above intact. A failure there stops the chain *at this - * match*, so every loader before it still runs - which is exactly the main - * shell's, and the shell's loader is what warms the cache entry the header reads - * with `useSuspenseQuery`. There is nothing to wait for either way: this - * answers synchronously and can never be pending, which is why it names no - * pending shape. - * - * ## What it does not shadow - * - * Anything a real route declares. A splat is the lowest-ranked segment kind in - * router core's matcher, and `isFrameMoreSpecific` prefers the frame with more - * static segments - so `/discover`, `/docs/$`, `/api/$` and every plugin page - * win against it, and only a path no route claims arrives here. - * - * `/admin/*` is the one boundary worth stating out loud. An admin URL that *is* - * declared is matched by the AdminCP's own routes as before; one that is not - - * a typo, a stale bookmark - lands here and gets the public 404 with the site - * header rather than the AdminCP's. That is deliberate: the alternative is a - * splat under `_admin`, which sits behind the admin guard and would answer a - * mistyped URL by demanding a sign-in for a page that does not exist. - */ export const notFoundRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { const route = createRoute({ getParentRoute: () => parentRoute, diff --git a/packages/vitnode/src/tanstack/routes/main/settings.tsx b/packages/vitnode/src/tanstack/routes/main/settings.tsx index 816ff1251..8d712fbc5 100644 --- a/packages/vitnode/src/tanstack/routes/main/settings.tsx +++ b/packages/vitnode/src/tanstack/routes/main/settings.tsx @@ -25,70 +25,16 @@ import { } from "../../settings/route"; import { routeContext } from "../types"; -/** - * One crumb of the settings trail: the frame's own name, or a panel's. - * - * The frame declares the first and each panel declares its own, so the shell - * renders `Settings / Devices` from two routes rather than from one route that - * knew about both. All this adds to `SettingsBreadcrumbContent` is the strings it - * renders in - the same set every settings route warms - because the shell draws - * the breadcrumb *above* the outlet, outside the provider the panel mounts. - * - * The crumb itself is imported straight from `views/` rather than through - * `../../settings`: that barrel also re-exports the frame and both panel - * bodies, and a `staticData` element is evaluated in the client entry - so the - * barrel would put the whole settings subtree in the first bundle of every page - * on the site. Same rule as the screens below, applied to the one part of a - * settings route that cannot be lazy. - */ const SettingsBreadcrumb = ({ navKey }: { navKey?: SettingsNavKey }) => ( <RouteMessages namespaces={SETTINGS_NAMESPACES}> <SettingsBreadcrumbContent navKey={navKey} /> </RouteMessages> ); -/** - * `/settings` - the settings frame: the heading, the navigation card, and the - * panel every settings page renders inside, with its four panels nested under - * it. - * - * A real nested layout route rather than a wrapper each page remembers to - * render, so a panel joins the frame by being its child and cannot forget to. - * That nesting is also why this is one factory rather than five: a panel's path - * is *relative* to the layout's, and the index panel's is `"/"` - which is what - * makes `/settings` render the overview rather than redirecting to - * `/settings/overview`. - * - * Rendering rather than redirecting is a product decision. The frame shows the - * navigation *instead of* the panel on a narrow screen, so a visitor who opens - * `/settings` on a phone is looking at a menu; redirecting would skip the menu - * entirely and leave the mobile back link as the only way to reach it. On a - * desktop the two URLs look identical. They differ in exactly one visible way, - * which is the breadcrumb: the index declares none, so the trail is the frame's - * single crumb, while `/settings/overview` adds its own and is two deep. - * - * ## Every panel body is behind a literal dynamic import - * - * The frame, the two static panels and the devices list are reached only through - * `lazyRouteComponent`, so they are Rollup chunks of their own rather than part - * of the client entry. What stays eager is what a router needs before it can - * render anything: the loader, the `head`, and the crumb. - */ export const settingsRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { const layout = createRoute({ getParentRoute: () => parentRoute, - /** - * The strings the frame renders, warmed before it renders. - * - * `ensureQueryData` rather than a prefetch, because `RouteMessages` reads - * them back with `useSuspenseQuery` and there is no Suspense boundary - * between it and the document: an unwarmed entry does not degrade here, it - * suspends the whole response. - * - * The session is deliberately not fetched. The authenticated container's - * `beforeLoad` has already put it in the one cache entry every guard reads, - * and this frame has no use for it - it renders nothing about the visitor. - */ + loader: async ({ context }) => { const narrowed = routeContext<SettingsLoaderContext>(context); @@ -96,15 +42,7 @@ export const settingsRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { settingsMessagesQueryOptions(narrowed.locale), ); }, - /** - * `noindex, nofollow` for the whole settings subtree, declared exactly once. - * - * TanStack Router merges the `head` of every matched route and dedupes - * `meta` by `name`, preferring the deepest occurrence - so a panel inherits - * this by saying nothing, and only a panel that deliberately wanted to be - * indexed would restate the tag. A panel's own `head` is therefore a title - * and nothing else. - */ + head: () => ({ meta: [{ content: "noindex, nofollow", name: "robots" }] }), path: "/settings", pendingComponent: () => ( @@ -133,17 +71,6 @@ export const settingsRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { }), }); - /** - * One panel: warm the strings, translate the tab title, render. - * - * `navKey` is the whole of what differs between three of the four - the - * component, the title and the crumb are all derived from it - so they are one - * builder rather than three near-identical routes. `path` is relative to the - * layout above. - * - * The panel body arrives through `loadPanel`, a literal dynamic import the - * caller supplies, so a panel's markup is never part of the eager route graph. - */ const panel = ( navKey: SettingsNavKey, path: string, @@ -164,27 +91,9 @@ export const settingsRoute: CoreRouteFactory = ({ pageHead, parentRoute }) => { : {}), }); - /** - * `/settings/devices` - where this account is signed in. - * - * Built on its own because it is the one panel with data of its own: the - * strings and the device list are awaited *together* rather than one after the - * other, so the two round trips overlap. - */ const devices: AnyRoute = createRoute({ getParentRoute: () => layout, - /** - * `context.auth` is the authenticated container's `beforeLoad` return, - * already narrowed to the signed-in half of the union, so `auth.user` needs - * no check. The id is taken once, here, and returned, so the loader, the - * component and the revoke callback all use the identical value. - * - * A refusal from the devices API is deliberately left to propagate. `401`, - * `403` and `429` reject as `DevicesRequestError`, which fails this loader - * and shows the router's error path - the honest answer. Catching it and - * rendering an empty list tells the visitor they are signed in nowhere, - * which is the one thing this page must never say by accident. - */ + loader: async ({ context }) => { const narrowed = routeContext< SettingsLoaderContext & { auth: { user: { id: number } } } diff --git a/packages/vitnode/src/tanstack/routes/main/sso.tsx b/packages/vitnode/src/tanstack/routes/main/sso.tsx index fe583073e..013e6a218 100644 --- a/packages/vitnode/src/tanstack/routes/main/sso.tsx +++ b/packages/vitnode/src/tanstack/routes/main/sso.tsx @@ -7,28 +7,10 @@ import { loadSsoCallbackRoute } from "../../auth/sso-route"; import { AuthPendingSkeleton } from "../../pending"; import { routeContext } from "../types"; -/** - * `/login/sso/:providerId` - where an SSO provider sends the visitor back to. - * - * The URL shape is not an application's to choose: the API registers it with - * every provider as `${NEXT_PUBLIC_WEB_URL}login/sso/<id>`, so whichever app - * that origin serves has to answer it. `/login/sso/google` and - * `/pl/login/sso/google` are one route - the locale is stripped before matching. - */ export const ssoCallbackRoute: CoreAuthRouteFactory = ({ parentRoute }) => { const route = createRoute({ getParentRoute: () => parentRoute, - /** - * What a provider may put in the callback URL - the package's contract, not - * an application's. - * - * Which half arrives is the provider's decision, so nothing is required, and - * nothing is coerced: an all-digit `state` reaches `validateSearch` as a - * number, and a `z.string()` would throw on it, rendering an error boundary - * in the middle of a sign-in the visitor had already approved. The values are - * judged by `parseSsoCallback`, which bounds their length, classifies the - * error rather than carrying it through, and is where the whole rule lives. - */ + validateSearch: normalizeSsoCallbackSearch, loader: async ({ context }) => await loadSsoCallbackRoute(routeContext(context)), diff --git a/packages/vitnode/src/tanstack/routes/root/admin-sign-in.tsx b/packages/vitnode/src/tanstack/routes/root/admin-sign-in.tsx index 20c1710a3..04594dcbd 100644 --- a/packages/vitnode/src/tanstack/routes/root/admin-sign-in.tsx +++ b/packages/vitnode/src/tanstack/routes/root/admin-sign-in.tsx @@ -14,23 +14,6 @@ import { createAuthNavigation } from "../../auth/redirects"; import { AuthPendingSkeleton } from "../../pending"; import { routeContext, routeSearch } from "../types"; -/** - * `/admin` - the AdminCP's own sign-in screen. - * - * A **sibling** of the AdminCP shell rather than a child of it, which is what - * stops the guard looping: the shell's `beforeLoad` sends a visitor without - * access here, and this route only ever redirects *away* on a granted session. - * `sanitizeAdminReturnTo` rejects `/admin` as a target for the same reason. - * - * The AdminCP has its own session under its own cookie, so this is not `/login` - * with a different heading - it reads a different session through a different - * endpoint, and an administrator may hold one and not the other. - * - * `?returnTo=` is kept as it arrived and judged nowhere near here: - * `sanitizeAdminReturnTo` is the single answer to whether a target is somewhere - * this app may navigate to, and it is applied where the value is *used*. Same - * split as `/login`. - */ export const adminSignInRoute: CoreRootRouteFactory = ({ localeRouting, pageHead, @@ -50,12 +33,7 @@ export const adminSignInRoute: CoreRootRouteFactory = ({ ? search[ADMIN_RETURN_TO_PARAM] : undefined, }), - /** - * `prefetchAdminAccess` rather than `ensureAdminAccess`: this is the page a - * denied visitor lands on, so the question has usually just been asked and - * the answer is in the cache. It returns `null` when it could not be read at - * all, and a null answer stays on this page rather than asserting anything. - */ + beforeLoad: async ({ context, search }) => { const access = await prefetchAdminAccess( routeContext<{ diff --git a/packages/vitnode/src/tanstack/routes/root/index.tsx b/packages/vitnode/src/tanstack/routes/root/index.tsx index 1f2b2fc75..fe02caa85 100644 --- a/packages/vitnode/src/tanstack/routes/root/index.tsx +++ b/packages/vitnode/src/tanstack/routes/root/index.tsx @@ -9,65 +9,10 @@ import { adminSignInRoute } from "./admin-sign-in"; export type { CoreRootRouteContext, CoreRootRouteFactory } from "./types"; -/** - * The pathless route core's shell-less screens are mounted under. - * - * The same device the other two containers are, for the same two reasons: it - * contributes no URL segment, so `/admin` is served at `/admin`; and it makes - * the composition **idempotent** - the subtree is one identifiable child of the - * root, so re-running it replaces itself instead of appending a second copy of - * every screen. - */ export const CORE_ROOT_ROUTES_ROUTE_ID = "_core-root"; -/** - * Every screen `@vitnode/core` owns that renders **outside** every shell. - * - * One of them, and the exception is the point: `/admin` is the AdminCP's own - * sign-in, and it must sit *outside* the AdminCP shell or that shell's guard - * would send a denied visitor into a route that sends them straight back. It is - * outside the *main* shell for a second, independent reason - the site header's - * "sign in" leads to `/login`, which is a different session under a different - * cookie, so offering it beside the AdminCP's own form would be one page asking - * for two unrelated logins. - * - * The four public auth screens used to be here too. They are children of the - * main shell now - see `CORE_PUBLIC_ROUTES` in `../main` - because an auth card - * is a page on the public site: the header above it is the way back to the front - * page, and the card's own layout already reserves the space that header takes. - */ const CORE_ROOT_ROUTES: CoreRootRouteFactory[] = [adminSignInRoute]; -/** - * Mounts core's shell-less screens on a route tree, and hands the tree back. - * - * const routeTree = withCoreRootRoutes(routeTree, { - * localeRouting, - * mountUnder: routeTree, - * pageHead, - * }) - * - * `mountUnder` is the **root route** - the tree itself - because these screens - * have no shell above them. That is also the whole of why they are a separate - * mount rather than a third area of the other two: an area names a shell, and - * the absence of one is not a shell. - * - * ## Why `localeRouting` is injected and `pageHead` is not enough - * - * A sign-in performs a navigation nobody clicked, to a path a *visitor* supplied - * through `?returnTo=`. The route tree carries no locale, so what the router is - * handed must not either - and stripping the prefix means knowing which prefixes - * exist, which is the installation's answer and not this package's. See - * `createAuthNavigation` in `@vitnode/core/tanstack/auth`; the app's own - * `localeRouting` is the same object its router's `rewrite` uses, so the strip - * and the write-back are one rule running in two directions. - * - * ## Idempotent, and a good neighbour - * - * Siblings are preserved, so this composes with the other mounts in any order - - * each rebuilds only the container it owns - and calling it twice is the same as - * calling it once. - */ export const withCoreRootRoutes = <TRouteTree extends AnyRoute>( routeTree: TRouteTree, { diff --git a/packages/vitnode/src/tanstack/routes/root/root-routes.test.ts b/packages/vitnode/src/tanstack/routes/root/root-routes.test.ts index 81ee28b0e..046fd27ce 100644 --- a/packages/vitnode/src/tanstack/routes/root/root-routes.test.ts +++ b/packages/vitnode/src/tanstack/routes/root/root-routes.test.ts @@ -2,29 +2,6 @@ import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -/** - * The screen that renders outside every shell, and it is `@vitnode/core`'s. - * - * Static and pure: this directory is read as the text it is. - * - * ## Why `root` is a folder of its own, for one screen - * - * `main/` and `admin/` are named after the shell they mount under. This one has - * none, so it is named after its mount point too: the root route, with nothing - * between. - * - * One screen is in it, and that is the whole point of the folder. The AdminCP's - * own sign-in has to sit *outside* the AdminCP shell or that shell's guard would - * send a denied visitor into a route that sends them back, and outside the main - * shell because it reads a different session under a different cookie - a page - * asking for the admin login under a header offering the public one would be one - * page asking for two unrelated logins. - * - * The public auth screens were here and are not any more: an auth card is a page - * on the public site, so `main/auth.tsx` owns them. `../main/main-routes.test.ts` - * is the half of this suite that moved with them. - */ - const here = import.meta.dirname; /** Source with its comments removed - prose may name what code may not do. */ @@ -48,12 +25,6 @@ describe("what this directory declares", () => { expect(everyRoutePath).toEqual(["/admin"]); }); - /** - * The public auth screens moved to the main shell, and nothing of them may be - * left behind: a second `/login` here would shadow the one under the shell - * from a container the router ranks identically, and which of the two won - * would depend on the order an application happens to call the mounts in. - */ it("declares no screen the main shell now owns", () => { for (const path of everyRoutePath) { expect(path.startsWith("/login"), path).toBe(false); @@ -65,12 +36,6 @@ describe("what this directory declares", () => { describe("how it reaches an application", () => { const index = codeOf("index.tsx"); - /** - * Three injected bindings, and the third is what made this screen one of the - * last to move: a sign-in navigates to a path a *visitor* supplied through - * `?returnTo=`, the route tree carries no locale, and which prefixes exist is - * the installation's answer. - */ it("takes the host's locale rule as well as its page head", () => { expect(index).toContain("export const withCoreRootRoutes"); expect(index).toMatch(/localeRouting/); @@ -78,11 +43,6 @@ describe("how it reaches an application", () => { expect(index).toMatch(/mountUnder/); }); - /** - * And it builds the navigation from that rule rather than carrying its own - * copy of the locale-stripping - `createAuthNavigation` is the one - * implementation, and an application's own binding uses the same factory. - */ it("builds its navigation from the injected rule", () => { const signIn = codeOf("admin-sign-in.tsx"); @@ -114,24 +74,12 @@ describe("how it reaches an application", () => { }); describe("the guard this screen carries", () => { - /** - * A redirect carries `to`, never `href`. A redirect with `href` is used - * verbatim by `Router.resolveRedirect` - it never reaches `buildLocation`, so - * it would skip the locale rewrite and drop a Polish visitor on the English - * page. - */ it("never redirects by href", () => { for (const name of modules) { expect(codeOf(name), name).not.toMatch(/redirect\(\{[^}]*href:/); } }); - /** - * The AdminCP sign-in reads its session *tolerantly*, and it is the one route - * where that is correct: `ensureAdminAccess` rejecting would replace the - * AdminCP's only entrance with an error page during an API outage, locking - * every administrator out. - */ it("reads the admin session tolerantly on the AdminCP entrance", () => { const signIn = codeOf("admin-sign-in.tsx"); diff --git a/packages/vitnode/src/tanstack/routes/root/types.ts b/packages/vitnode/src/tanstack/routes/root/types.ts index 4b5bbe467..7a5698e42 100644 --- a/packages/vitnode/src/tanstack/routes/root/types.ts +++ b/packages/vitnode/src/tanstack/routes/root/types.ts @@ -1,15 +1,5 @@ import type { CoreAuthRouteContext, CoreRouteFactory } from "../types"; -/** - * What a route with no shell above it is built with. - * - * The same shape a route under the main shell that navigates on the visitor's - * behalf needs, and deliberately the same *type*: `/admin` and `/login` both - * send a browser to a path a visitor supplied through `?returnTo=`, both have to - * strip a locale prefix the route tree does not carry, and there must not be two - * answers to that. The definition lives in `../types` beside `pageHead`, which - * is injected for the same reason. - */ export type CoreRootRouteContext = CoreAuthRouteContext; /** One screen with no shell above it. */ diff --git a/packages/vitnode/src/tanstack/routes/types.ts b/packages/vitnode/src/tanstack/routes/types.ts index ee0ac9c45..5777f048d 100644 --- a/packages/vitnode/src/tanstack/routes/types.ts +++ b/packages/vitnode/src/tanstack/routes/types.ts @@ -4,14 +4,6 @@ import type { ContentFrontendRegistry } from "../../content/admin/registry"; import type { LocaleRouting } from "../../lib/i18n/locale-routing"; import type { RouteHeadOptions, RouteHeadResult } from "../metadata"; -/** - * The host's `head` rule, bound to its own site name. - * - * The same binding the plugin route runtime takes, for the same reason: a - * package cannot know what a site is called, so `"<page> - <site>"` is applied - * by a function the application owns. `createRouteHead(metadata)` produces it - * and `src/lib/page-head.ts` is where every VitNode app keeps it. - */ export type CorePageHead = (options?: RouteHeadOptions) => RouteHeadResult; /** What a core route is built with. */ @@ -25,62 +17,15 @@ export interface CoreRouteContext { parentRoute: AnyRoute; } -/** - * One screen, as the function that builds it. - * - * A factory rather than a route object, because `createRoute` needs its parent - * and the parent does not exist until the host hands a shell over. - */ 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 { - /** - * The application's Content Engine registry, behind a literal dynamic import. - * - * Injected for the same reason `pageHead` is: it is per-installation. The app - * builds it from its generated `src/content-registry.gen.ts`, one literal - * import per configured plugin, and only the Content Engine's splat reads it. - * - * ## Why a thunk rather than the registry itself - * - * Because the value is enormous and every page of the application would pay - * for it. A registry holds each configured plugin's field components, table - * cells and form layouts, and building one reaches `@vitnode/core/content` - - * which is the whole Content Engine, `zod` included. Handed over as a value, - * that graph is evaluated in the module that composes the route tree, which - * is the client entry: measured on vitnode.com it was `zod`, both plugins' - * admin registrations, the content form primitives and `react-hook-form`, - * downloaded before the front page could paint. - * - * A `() => import("./lib/content-registry")` is resolved by the bundler into - * a chunk of its own and awaited by the one loader that needs it, which runs - * only on `/admin/content/*`. Nothing about the route's identity depends on - * it: the path, the search contract and the crumb are all still eager, and - * the permission check still runs inside the loader, before anything renders. - */ loadContentRegistry: () => Promise<ContentFrontendRegistry>; } -/** - * The router's loader arguments, narrowed at the one place they arrive. - * - * `getParentRoute` returns an `AnyRoute` - a core screen is mounted under a - * container built from the *host's* shell, so this package cannot name that - * route's type - and TanStack propagates the `any` into `context` and `search` - * for every route built from it. Each screen would then receive `any` where it - * declared a real type: it compiles, and it is exactly the hole a lint rule - * exists to refuse. - * - * So the narrowing happens here, with a name on it, rather than once per screen - * implicitly. Neither of these changes a value; both say which type the router - * could not work out for itself. `TSearch` and `TContext` are inferred from the - * call site - a screen's own `staffRouteParams(...)` or `loadDiscoverRoute({...})` - * names the type it wants - so a screen that changes its loader's shape changes - * this with it. - */ export const routeSearch = <TSearch>(search: unknown): TSearch => search as TSearch; @@ -88,19 +33,6 @@ export const routeSearch = <TSearch>(search: unknown): TSearch => export const routeContext = <TContext>(context: unknown): TContext => context as TContext; -/** - * What a screen that performs a navigation nobody clicked is built with. - * - * One field more than the usual two, and it is the reason the auth screens were - * the last to move: a sign-in navigates to a path a *visitor* supplied through - * `?returnTo=`. Deciding what the router should be handed means stripping the - * locale prefix the route tree does not carry - and which languages exist is the - * installation's answer, not this package's. - * - * So the app's own locale rule is injected, exactly as `pageHead` and - * `contentRegistry` are, and `createAuthNavigation` builds both halves of the - * navigation from it. See `@vitnode/core/tanstack/auth`. - */ export interface CoreAuthRouteContext extends CoreRouteContext { localeRouting: Pick<LocaleRouting, "deLocalizeUrl">; } diff --git a/packages/vitnode/src/tanstack/search/discover-route.tsx b/packages/vitnode/src/tanstack/search/discover-route.tsx index 33c93f1cc..f14975a35 100644 --- a/packages/vitnode/src/tanstack/search/discover-route.tsx +++ b/packages/vitnode/src/tanstack/search/discover-route.tsx @@ -5,14 +5,6 @@ import { createTranslator } from "use-intl"; import { intlQueryOptions } from "../i18n/query"; import { discoverFeedQueryOptions } from "./discover"; -/** - * What Discover renders strings from. - * - * `core.global` is the shell's, `core.search` is the feed's - its empty state, - * its "load more", the label on every result type. One list, read by both the - * loader that fetches them and the provider that mounts them, because they have - * to be the same set or the provider suspends on a key nobody warmed. - */ export const DISCOVER_NAMESPACES = ["core.global", "core.search"] as const; /** The narrowest slice of a route's context this loader reads. */ @@ -27,29 +19,6 @@ export interface DiscoverRouteData { title: string; } -/** - * Both things Discover needs, fetched in parallel before it renders. - * - * `locale` is the route context's, resolved from the public URL - so - * `/pl/discover` fetches Polish messages and a Polish feed, and the first byte - * of HTML is already in that language. - * - * Neither call is repeated by the component: the messages are read back by - * `RouteMessages` through the identical `intlQueryOptions`, and the feed by - * `SearchFeedContent` through the key `discoverFeedQueryOptions` warms. A - * mismatch on either would show up as a render that starts empty and fills in a - * round trip later, which is the thing SSR is for. - * - * The strings the metadata needs are returned rather than looked up again, so - * the tab title and the `<h1>` are the same string by construction - which is - * what the Next.js route gets from calling `getTranslations` once per request. - * - * The cast is what makes `createTranslator` usable: its key type is derived from - * the *inferred* type of `messages`, and a bare index signature collapses - * `MessageKeys` to `never`. Naming the two keys read here is both the smallest - * fix and a true statement - rename either in `locales/en.json` and this stops - * compiling rather than rendering a raw key into a `<title>`. - */ export const loadDiscoverRoute = async ({ locale, queryClient, @@ -74,18 +43,3 @@ export const loadDiscoverRoute = async ({ return { description: t("discoverDesc"), title: t("discoverTitle") }; }; - -/** - * Discover, as everything below a route file's `component`. - * - * `LinkComponent` is the one thing a package cannot decide: a search result - * points wherever the indexed content lives, and the shared feed is host-neutral - * by design. External and unsafe URLs never reach it - `SearchFeedContent` - * classifies those and renders them itself. - * - * The query options are built here from the same factory the loader used, so - * this is a cache read rather than a fetch: no `initialData` and no Suspense - * boundary, both of which would be admissions that the data is not here yet. - * `fetchNextPage` then continues from the loader's cursor through the loader's - * own request and status checking. - */ diff --git a/packages/vitnode/src/tanstack/search/discover.ts b/packages/vitnode/src/tanstack/search/discover.ts index 478a8717e..bddc7d9e1 100644 --- a/packages/vitnode/src/tanstack/search/discover.ts +++ b/packages/vitnode/src/tanstack/search/discover.ts @@ -2,45 +2,16 @@ import type { SearchFeedParams } from "@/views/search/search-feed-query"; import { feedQueryKey, feedQueryOptions } from "./feed"; -/** - * Discover, as the shared feed with Discover's parameters. - * - * Two constants and two bindings, no logic. What a feed page *is* - the request, - * the page size, the cursor rule, what counts as a failure - comes from - * `@/views/search/search-feed-query`; how it travels comes from `./feed`, which - * every feed shares. All that is left here is which parameters this route - * browses with. - * - * The named exports stay, because a loader, a component and a test all read "the - * Discover feed" and none of them should have to know it is `{ sort: 'newest' }`. - */ - /** * Discover is the *browse* feed: no term, newest first. A term search is a * different request with a different sort, and it is not this module's. */ export const DISCOVER_FEED_SORT = "newest" as const; -/** - * Discover, as the shared feed's own parameters. - * - * One frozen module-level object, read by both halves of the route: the loader - * builds its cache key from it and `<SearchFeedContent>` is handed a query built - * from the same reference. Query hashes keys structurally, so an equal object - * would do - but one object makes it impossible for the two to drift. - * - * `search` is absent rather than empty: Discover browses, it does not query. - */ export const DISCOVER_FEED_PARAMS: SearchFeedParams = Object.freeze({ sort: DISCOVER_FEED_SORT, }); -/** - * The cache entry one language's Discover feed lives in. - * - * The shared key, through the one binding of it - see `feedQueryKey`. A key - * invented here would be a second entry holding the same feed. - */ export const discoverFeedQueryKey = (locale: string) => feedQueryKey({ locale, params: DISCOVER_FEED_PARAMS }); diff --git a/packages/vitnode/src/tanstack/search/feed.ts b/packages/vitnode/src/tanstack/search/feed.ts index c94fa1fa3..057dd15c5 100644 --- a/packages/vitnode/src/tanstack/search/feed.ts +++ b/packages/vitnode/src/tanstack/search/feed.ts @@ -13,61 +13,10 @@ import { import { fetchSearchFeedPageOnServer } from "./server"; -/** - * The search feed, as one query definition, for a TanStack Start host. - * - * Everything about *what* a feed page is - the request, the page size, the - * cursor rule, what counts as a failure, the cache entry it lands in - comes - * from `@/views/search/search-feed-query`, which is also what the mounted - * `SearchFeedContent` runs. This module supplies only the one thing that module - * cannot know: how to reach the API from a server that is rendering a request. - * - * Every feed is built from here - `/discover` browsing newest-first, `/search` - * with a term and filters, and whatever comes next - because they are the same - * query with different parameters. A route that bound its own transport would be - * a second definition of a feed that agreed with this one only until it didn't. - */ - -/** - * The transport boundary, and the reason one query definition works in a loader - * and in a component. - * - * Both branches call the Hono API directly - the server one from inside the - * request being rendered, the browser one over the network to the same origin. - * There is deliberately no `createServerFn` in between: a server function is a - * `POST` back to the app that then calls Hono, so every scroll of the feed and - * every keystroke in the search box would cost two round trips to fetch a - * public, anonymous read that the API is already the boundary for. - * - * `createIsomorphicFn` is what makes that safe rather than merely tidy. The - * Start compiler keeps only the branch belonging to the bundle it is building - * and drops the other's import with it, so `./server` - and the `server-only` - * marker at the top of it - never reaches the browser. The client branch is the - * shared browser fetcher, so a hydrated page and a Next.js page fetch through - * exactly the same code. It is written out here rather than behind a shared - * helper because the transform matches the *chained call*; see the note in - * `../files/query`. - * - * Un-compiled (tests, plain Node) the stub falls back to the server branch, - * which is the right default off a browser. - */ export const fetchSearchFeedPage: SearchFeedPageFetcher = createIsomorphicFn() .server(fetchSearchFeedPageOnServer) .client(fetchSearchFeedPageInBrowser); -/** - * The cache entry one feed lives in. - * - * The shared key, not one of a route's devising. `SearchFeedContent` runs the - * mounted `useInfiniteQuery` and stores its pages here; a key invented locally - * would be a *second* entry holding the same feed, so the loader would fill one, - * the component would miss the other, and every visit would render a skeleton - * and fetch page one again from the browser. - * - * The locale is in it, which is the whole contract: `/discover` and - * `/pl/discover` are two feeds over two sets of documents, so they get two - * entries. A language switch changes the key rather than the value under it. - */ export const feedQueryKey = ({ locale, params, @@ -76,23 +25,6 @@ export const feedQueryKey = ({ params: SearchFeedParams; }) => searchFeedQueryKey({ locale, params }); -/** - * One feed, as the one query definition every caller shares. - * - * loader: context.queryClient.ensureInfiniteQueryData(options) - * component: <SearchFeedContent queryOptions={options} /> - * load more: fetchNextPage() // the same queryFn, cursor rule and checks - * - * No `initialData`. A route loader has already put page one in the entry this - * key names and the SSR pass dehydrates it, so passing it again would be a - * second copy of the same bytes that can disagree with the first. - * - * No `staleTime` either. Freshness is whatever the API's own caching gives, plus - * VitNode's client defaults (`refetchOnMount` and `refetchOnWindowFocus` both - * off), so a hydrated feed is not refetched behind the reader. Deciding a cache - * lifetime belongs to the caching stage, with the API and Redis in the same - * view. - */ export const feedQueryOptions = ({ locale, params, diff --git a/packages/vitnode/src/tanstack/search/index.ts b/packages/vitnode/src/tanstack/search/index.ts index 207d46c84..9dd7ac141 100644 --- a/packages/vitnode/src/tanstack/search/index.ts +++ b/packages/vitnode/src/tanstack/search/index.ts @@ -20,24 +20,6 @@ export type { SearchLoaderContext, SearchRouteData } from "./search-route"; export { loadSearchRoute, SEARCH_NAMESPACES } from "./search-route"; export { SearchRouteContent } from "./search-screen"; -/** - * `/search` and `/discover`, as everything a TanStack Start route needs. - * - * One architecture, not two. The feed's *behaviour* - the request, the page - * size, the cursor rule, what counts as a failure, the cache key, and the - * `SearchFeedContent` that renders it - is already shared and lives under - * `@vitnode/core/views/search`. What this namespace adds is the half only a - * TanStack Start host can run: - * - * - `./feed` binds the isomorphic transport, so one query definition works in a - * loader and in a component. - * - `./discover` is that feed with Discover's two constants. - * - `./route-search` is the URL contract of `/search`, pure and router-free. - * - * The keys are re-exported from here rather than from `views/search/ - * search-feed-query`, so a route reaches for one place and cannot invent a - * second entry holding the same feed. - */ export type { SearchFeedCursor, SearchFeedPageArgs, diff --git a/packages/vitnode/src/tanstack/search/route-search.test.ts b/packages/vitnode/src/tanstack/search/route-search.test.ts index c65cc5ec9..a7819404f 100644 --- a/packages/vitnode/src/tanstack/search/route-search.test.ts +++ b/packages/vitnode/src/tanstack/search/route-search.test.ts @@ -10,16 +10,6 @@ import { searchRouteFeedParams, } from "./route-search"; -/** - * `/search`' contract with its own URL, and with the cache underneath it. - * - * Pure functions only. `normalizeSearchRouteSearch` is what a route hands to - * `validateSearch`, so calling it directly is calling the route's schema - no - * router, no request, no rendering. The feed's *behaviour* lives in - * `views/search` and is asserted there; what is asserted here is that this - * namespace asks for the right feed. - */ - const paramsFor = (input: Record<string, unknown>) => searchRouteFeedParams(normalizeSearchRouteSearch(input)); diff --git a/packages/vitnode/src/tanstack/search/route-search.ts b/packages/vitnode/src/tanstack/search/route-search.ts index 43a3cd7ba..0e5d1d032 100644 --- a/packages/vitnode/src/tanstack/search/route-search.ts +++ b/packages/vitnode/src/tanstack/search/route-search.ts @@ -5,48 +5,10 @@ import { searchFeedParamsFor, } from "@/views/search/search-params"; -/** - * What a `/search` route reads out of its URL, and what it turns that into. - * - * Two pure functions, no transport and no React, so the route's contract can be - * stated and tested without a router: `route-search.test.ts` beside this file is - * the whole of it. - * - * Both delegate to `@/views/search/search-params`, which is where the meaning of - * a search request lives - the same module the Next.js `SearchView` reads its - * `searchParams` through. `/search?search=hello` is therefore the same request - * in both applications rather than two hand-written approximations of it. - */ - -/** - * The one search parameter this route has. - * - * `search` is in the URL because a search has to be shareable and because a - * crawler landing on `/search?search=hello` should be served those results. The - * sort and the type filters are **not**: they are controls the visitor drives - * after the page has loaded, they have never been in the URL, and putting them - * there means deciding what a canonical search URL is and whether every - * keystroke is a history entry. That is a product question, not a move. - */ export interface SearchRouteSearch { search?: string; } -/** - * The route's search schema - written as a function rather than a schema object - * because its job is to *normalise*, not to reject. - * - * A search page is the one page whose query string is typed by strangers. - * `?search=` arrives empty, `?search=a&search=b` arrives as an array, - * `?search=<40KB>` arrives as a denial-of-service attempt on the full-text - * index - and every one of them should render the search page, not an error - * boundary. `normalizeSearchTerm` answers all three: anything that is not a - * usable term becomes no term at all, which is the browse feed. - * - * A missing term is returned as an *absent* key rather than - * `{ search: undefined }`, so the router has nothing to write back into the URL - * and `/search?search=%20` settles as `/search`. - */ export const normalizeSearchRouteSearch = ( input: Record<string, unknown>, ): SearchRouteSearch => { @@ -55,19 +17,6 @@ export const normalizeSearchRouteSearch = ( return search === undefined ? {} : { search }; }; -/** - * The route's search parameters, as the shared feed's own. - * - * The sort is not passed and is not missing: `searchFeedParamsFor` derives it - * from whether there is a term - relevance when there is one, newest when there - * is not - which is the rule the controls then start from and the rule the - * Next.js page has always applied. - * - * With no term this is `{ sort: 'newest' }`, which is exactly - * `DISCOVER_FEED_PARAMS`. That is deliberate: `/search` with an empty box and - * `/discover` are the same request over the same documents, so they share one - * cache entry rather than fetching it twice. - */ export const searchRouteFeedParams = ({ search, }: SearchRouteSearch): SearchFeedParams => searchFeedParamsFor({ search }); diff --git a/packages/vitnode/src/tanstack/search/search-route.tsx b/packages/vitnode/src/tanstack/search/search-route.tsx index b2fe66834..257600c01 100644 --- a/packages/vitnode/src/tanstack/search/search-route.tsx +++ b/packages/vitnode/src/tanstack/search/search-route.tsx @@ -8,15 +8,6 @@ import { intlQueryOptions } from "../i18n/query"; import { feedQueryOptions } from "./feed"; import { searchRouteFeedParams } from "./route-search"; -/** - * What `/search` renders strings from. - * - * `core.global` is the shell's, `core.search` is everything else - the heading, - * the placeholder, the sort labels, the type labels, the feed's empty state and - * its "load more". One list, read by both the loader that fetches them and the - * provider that mounts them, because they have to be the same set or the - * provider suspends on a key nobody warmed. - */ export const SEARCH_NAMESPACES = ["core.global", "core.search"] as const; /** The narrowest slice of a route's context this loader reads. */ @@ -32,22 +23,6 @@ export interface SearchRouteData { title: string; } -/** - * Everything `/search` needs, fetched in parallel before it renders. - * - * `params` is returned rather than rebuilt in the component: the object handed - * to the controls as their starting point is *literally* the one whose cache - * entry was warmed, so the two cannot drift apart through a difference in how - * each derived it. - * - * `search` is the term from the URL, which the host passes through `loaderDeps` - * so the loader re-runs when it changes and only then - without that, following - * a link from `?search=hono` to `?search=drizzle` would render the first result - * set and fetch the second from the browser. - * - * See {@link loadDiscoverRoute} for why the messages are translated here rather - * than in `head`, and why the message type is cast. - */ export const loadSearchRoute = async ({ locale, queryClient, @@ -75,19 +50,3 @@ export const loadSearchRoute = async ({ return { description: t("desc"), params, title: t("title") }; }; - -/** - * `/search`, as everything below a route file's `component`. - * - * The term in the URL is the controls' starting point, so a *change* to it has - * to become a new starting point - and the controls hold their term in state, - * which React preserves across a re-render. Keyed on the term, the loader - * re-running for `?search=drizzle` remounts them, and they read the entry that - * loader just warmed instead of showing the previous search over - * freshly-fetched-and-ignored results. - * - * `feedQuery` is a factory rather than a finished options object because the - * visitor changes the request: every keystroke, filter and sort is a different - * query, built from the same factory the loader used, so all of them share one - * contract and one cache. - */ diff --git a/packages/vitnode/src/tanstack/settings/index.ts b/packages/vitnode/src/tanstack/settings/index.ts index 94d36f9b4..91313a35f 100644 --- a/packages/vitnode/src/tanstack/settings/index.ts +++ b/packages/vitnode/src/tanstack/settings/index.ts @@ -1,51 +1,11 @@ export { SettingsLayoutContent } from "./layout"; -/** - * `@vitnode/core/tanstack/settings` - the settings frame, its panels, and the - * loader every one of them shares. - * - * Two halves, and the split is the same one every VitNode screen namespace - * makes: - * - * ./route the eager half - the namespace list, the message query, the - * panel loader and the title rule. A route file's `loader`, - * `head` and `staticData` import from here, and a route file is - * evaluated in the client entry. - * ./layout the frame, and below it the panels - reached only through a - * route's `component:`, which is code-split. - * - * Everything is re-exported here, so a host that renders a settings screen of - * its own has one specifier to import. What a *route* must not do is reach for - * this barrel from its loader: `./layout`, `OverviewSettings` and - * `SecuritySettings` are components, and importing them beside the loader is - * what puts the whole settings subtree in the entry chunk of every page of the - * application. `../eager-graph.test.ts` holds the core routes to that. - */ + export * from "./route"; export type { SettingsNavKey } from "./route"; -/** - * The panel bodies, re-exported so a route never has to reach past this - * namespace to find one. - * - * They are the same modules the Next.js pages render and they contain no - * TanStack at all, so a host *could* import them from `@vitnode/core/views/...` - * directly - and two of its route files did. That is one feature with two entry - * spellings, and only one of them is a spelling `boundary.test.ts` and - * `package-boundary.test.ts` can police: a deep `views/` path resolves through - * the package-wide `./*` pattern, where nothing checks what a route is allowed - * to reach for. Re-exporting them here makes `@vitnode/core/tanstack/settings` - * the whole of what a settings route imports. - * - * `DevicesPanelContent` is deliberately not among them - it owns a query, so it - * lives in `@vitnode/core/tanstack/devices` beside the query options its route's - * loader has to warm. - */ + export { OverviewSettings } from "@/views/auth/settings/overview/overview"; export { SecuritySettings } from "@/views/auth/settings/security/security"; -/** - * The settings trail. A host binds its own link component to it - during a - * migration that is not a plain `<Link>` - and mounts the result as the - * subtree's `staticData.breadcrumb`. - */ + export type { SettingsBreadcrumbContentProps } from "@/views/auth/settings/settings-breadcrumb-content"; export { SettingsBreadcrumbContent } from "@/views/auth/settings/settings-breadcrumb-content"; export { diff --git a/packages/vitnode/src/tanstack/settings/layout.tsx b/packages/vitnode/src/tanstack/settings/layout.tsx index 85cddc55a..a69067137 100644 --- a/packages/vitnode/src/tanstack/settings/layout.tsx +++ b/packages/vitnode/src/tanstack/settings/layout.tsx @@ -11,34 +11,6 @@ import { SettingsShellContent } from "@/views/auth/settings/shell-content"; import { RouteMessages } from "../i18n/route-messages"; import { SETTINGS_NAMESPACES } from "./route"; -/** - * The settings frame - the heading, the navigation card, and the panel every - * settings page renders inside. - * - * What it owns, so that no panel does: the `container`, the `<h1>` and its - * description, the navigation card, the panel card, the mobile back link and the - * narrow-screen rule that shows the menu on `/settings` and the panel everywhere - * else. A panel route renders only its own contents. - * - * `SettingsShellContent` and `SettingsNavContent` are the same modules the - * Next.js layout renders. The one thing neither can resolve for itself is how to - * build a link, which is why `LinkComponent` is a prop: this module is shared - * with hosts that are not on TanStack Router, so it may not import one. A - * TanStack Start host passes `RouterLink`. - * - * ## What a panel may assume about the provider - * - * That `RouteMessages` is above it - in its component *and in its - * `pendingComponent`* - so a panel's loading fallback may translate without - * mounting a provider of its own. The guarantee is structural rather than - * incidental: a panel's `pendingComponent` renders into the host layout's - * `<Outlet />`, and that `<Outlet />` is this component's `children`, which only - * exist once this has run. - * - * The one thing *not* covered by it is a `pendingComponent` on the layout route - * itself. It renders in place of this - above the provider, not inside it - so - * it must either avoid translating or mount `RouteMessages` itself. - */ export const SettingsLayoutContent = ({ children, LinkComponent, @@ -46,18 +18,6 @@ export const SettingsLayoutContent = ({ children: React.ReactNode; LinkComponent: AuthLinkComponent; }) => { - /** - * Where the visitor is, as the router's *internal* pathname. - * - * Internal is the whole point: the locale rewrite has already stripped the - * prefix, so `/pl/settings/security` arrives here as `/settings/security` and - * the shared rules in `settings-nav.ts` compare plain paths. A rule that had to - * cope with a prefix would be a second copy of the locale routing. - * - * Subscribed through `useRouterState` rather than read from a match, because - * the nav highlight and the narrow-screen behaviour have to change on every - * navigation within the subtree - including the ones that do not remount this. - */ const pathname = useRouterState({ select: state => state.location.pathname }); return ( diff --git a/packages/vitnode/src/tanstack/settings/route.ts b/packages/vitnode/src/tanstack/settings/route.ts index a7ecefad5..f13eca181 100644 --- a/packages/vitnode/src/tanstack/settings/route.ts +++ b/packages/vitnode/src/tanstack/settings/route.ts @@ -9,55 +9,11 @@ import { formatPageTitle } from "@/lib/metadata"; import { intlQueryOptions } from "../i18n/query"; -/** - * What every settings route needs, in one place: the strings, the tab title. - * - * Its own module rather than a route's, because three routes and a breadcrumb - * all read the namespace list and a route file importing another route file for - * it would be a cycle. What is here is the part that is identical for every - * panel; what a panel actually renders is the panel's own route. - * - * The navigation model itself is not here - `SETTINGS_NAV_ITEMS`, - * `activeSettingsNavKey` and `isSettingsRootPath` live in - * `@vitnode/core/views/auth/settings/settings-nav`, framework-free, because - * Next.js reads them too. This namespace is only the part that needs a router - * with a query cache in front of it. - */ - -/** - * What the settings screens render strings from. - * - * `core.auth.settings` is the heading, the description, the navigation and every - * panel's own title - the same namespace the Next.js layout mounts, kept - * deliberately: the panels are shared components and they look their strings up - * by the same keys in both frameworks. - * - * `core.global` is listed even though a root provider already provides it, - * because a per-route provider mounts its own over the root's rather than adding - * to it - so a set that omitted it would take the global strings away from - * everything below. - * - * One list, read by the loader that fetches it, by the provider that mounts it - * and by the breadcrumb, because they have to be the same set or a reader - * suspends on a key nobody warmed. - */ export const SETTINGS_NAMESPACES = [ "core.auth.settings", "core.global", ] as const; -/** - * The branch of the message tree these routes read, named for - * `createTranslator`. - * - * The cast this type serves: the translator's key type is derived from the - * *inferred* type of `messages`, and `AbstractIntlMessages` is a bare index - * signature - so `MessageKeys` cannot tell a leaf from a branch and collapses to - * `never`, making every key a type error. Naming the keys these routes read is - * both the smallest fix and a true statement: rename one in `locales/en.json` - * and this stops compiling rather than rendering a raw message key into a - * `<title>`. - */ interface SettingsMessages { core: { auth: { @@ -76,16 +32,6 @@ export interface SettingsLoaderContext { queryClient: QueryClient; } -/** - * The settings messages, as the one query definition every settings route - * shares. - * - * The layout warms it, and so does each panel. The second call is a cache read - * rather than a second request - same locale, same namespaces, therefore the - * same key - and each panel asks anyway so that a panel's loader is complete on - * its own terms rather than relying on the order its parent's happened to run - * in. - */ export const settingsMessagesQueryOptions = (locale: string) => intlQueryOptions({ locale, namespaces: SETTINGS_NAMESPACES }); @@ -94,15 +40,6 @@ export interface SettingsPanelData { title: string; } -/** - * One panel's tab title, as `"<Panel> - <Settings>"`. - * - * The same two lookups the Next.js pages do - `nav.<key>` and `title` - so both - * frameworks produce the same string, and `formatPageTitle` then appends the - * site name exactly as Next.js does through `title.template`. Translated in the - * loader rather than in `head`, which receives no router context and so cannot - * resolve a locale at all. - */ export const settingsPanelTitle = ({ locale, messages, diff --git a/packages/vitnode/src/tanstack/start/create-start.test.ts b/packages/vitnode/src/tanstack/start/create-start.test.ts index 19df3cf77..75b316d56 100644 --- a/packages/vitnode/src/tanstack/start/create-start.test.ts +++ b/packages/vitnode/src/tanstack/start/create-start.test.ts @@ -10,17 +10,6 @@ const { createVitNodeStart } = await import("./create-start"); const { runLocaleRequest } = await import("./locale-middleware"); const { localeRoutingFromConfig } = await import("@/lib/i18n/locale-routing"); -/** - * The request pipeline an app gets for free, and cannot get wrong. - * - * Start installs its own CSRF middleware *only* while an app declares no - * `requestMiddleware` at all - so the moment an app writes a pipeline, the - * default is replaced by whatever that list holds. Every app used to write that - * list. This is the factory that writes it instead, and what these tests pin is - * the part an app can no longer influence: CSRF exists, it is first, and nothing - * an app passes can get in front of it or in front of locale handling. - */ - /** Start marks its CSRF middleware with this outside production builds. */ const csrfSymbol = Symbol.for("tanstack-start:csrf-middleware"); @@ -46,15 +35,6 @@ const requestMiddlewareOf = async ( return [...(options.requestMiddleware ?? [])]; }; -/** - * Whether the CSRF middleware would validate this request. - * - * Asserted through behaviour rather than by reading `opts.filter`, which the - * middleware closes over and does not expose. The request carries - * `Sec-Fetch-Site: cross-site`, which Start's own check refuses - so the - * middleware answers with a `Response` when its filter let the request through, - * and passes `next()` straight back when the filter skipped it. - */ const isValidatedByCsrf = async ( csrf: unknown, { handlerType, url }: { handlerType: string; url: string }, @@ -149,7 +129,7 @@ describe("the locale rule only ever touches a page request", () => { handlerType: "serverFn", next: advance, request: new Request("https://a.test/en/discover"), - } as never, + }, localeRouting, ); @@ -164,7 +144,7 @@ describe("the locale rule only ever touches a page request", () => { handlerType: "router", next: advance, request: new Request("https://a.test/en/discover?page=2#top"), - } as never, + }, localeRouting, ); @@ -185,7 +165,7 @@ describe("the locale rule only ever touches a page request", () => { handlerType: "router", next: next(), request: new Request("https://a.test/pl/admin"), - } as never, + }, localeRouting, )) as Response; @@ -216,7 +196,7 @@ describe("/api/* passes through the pipeline untouched", () => { handlerType: "router", next: advance, request: new Request(url), - } as never, + }, localeRouting, ); @@ -233,7 +213,7 @@ describe("/api/* passes through the pipeline untouched", () => { handlerType: "router", next: vi.fn(), request: new Request("https://a.test/pl/api/foo"), - } as never, + }, localeRouting, )) as Response; @@ -248,7 +228,7 @@ describe("/api/* passes through the pipeline untouched", () => { handlerType: "router", next: vi.fn(async () => await Promise.resolve({ response })), request: new Request("https://a.test/api/vitnode/core/session"), - } as never, + }, localeRouting, ); @@ -272,7 +252,7 @@ describe("what the pipeline does to the response it gets back", () => { handlerType: "router", next: async () => await Promise.resolve({ response }), request: new Request(url), - } as never, + }, localeRouting, ); diff --git a/packages/vitnode/src/tanstack/start/create-start.ts b/packages/vitnode/src/tanstack/start/create-start.ts index f5cbf334a..6b8d8855a 100644 --- a/packages/vitnode/src/tanstack/start/create-start.ts +++ b/packages/vitnode/src/tanstack/start/create-start.ts @@ -12,53 +12,11 @@ import { createLocaleRequestMiddleware } from "./locale-middleware"; export interface VitNodeStartOptions< AppLocales extends LocaleConfig[] = LocaleConfig[], > { - /** - * The app's shared config, handed over rather than read from a registry. - * - * Request middleware runs before route matching and before anything else in - * this package has necessarily been imported, so a factory that read - * `getVitNodeConfig()` would depend on which entry file Node happened to - * evaluate first. The locale routing runtime is derived from `config.i18n` - * here, once. - */ config: VitNodeConfig<AppLocales>; - /** - * The app's own request middleware, appended after VitNode's. - * - * For things an installation genuinely owns - a request id, a tracing span, a - * maintenance-mode gate. It cannot displace or precede CSRF and locale - * handling; see the ordering note on {@link createVitNodeStart}. - */ + requestMiddleware?: readonly AnyRequestMiddleware[]; } -/** - * A VitNode app's Start instance - the whole of `src/start.ts`. - * - * ## Why CSRF is not optional - * - * Start installs its own CSRF middleware *only* while an app declares no - * `requestMiddleware` at all. The moment an app has a `src/start.ts` with a - * list, that default is replaced by whatever the list holds - so an app that - * writes its own pipeline and forgets CSRF exposes every server function as an - * unauthenticated cross-site endpoint, silently. That is the failure this - * factory exists to make impossible: the middleware is built here, first, and an - * app cannot omit it, reorder it, or get in front of it. - * - * The `filter` keeps it to `handlerType === "serverFn"`. Server functions are - * same-origin RPC; page navigations are not, and validating `Sec-Fetch-Site` on - * a top-level cross-site link would reject ordinary inbound traffic. - * - * ## Order - * - * 1. CSRF rejects a cross-site RPC before anything reads it - * 2. locale canonical redirects, the locale cookie, document caching - * 3. the app's whatever `requestMiddleware` lists, in order - * - * Locale handling sits second because a redirect ends the request: an app - * middleware that ran before it would run twice for every visitor arriving at - * `/en/discover`, once for the redirect and once for `/discover`. - */ export const createVitNodeStart = <AppLocales extends LocaleConfig[]>({ config, requestMiddleware = [], diff --git a/packages/vitnode/src/tanstack/start/document-headers.test.ts b/packages/vitnode/src/tanstack/start/document-headers.test.ts index bc78d6a78..73e734e9b 100644 --- a/packages/vitnode/src/tanstack/start/document-headers.test.ts +++ b/packages/vitnode/src/tanstack/start/document-headers.test.ts @@ -7,16 +7,6 @@ import { DOCUMENT_CACHE_CONTROL, } from "./document-headers"; -/** - * The one directive VitNode forces onto a response, and the two questions that - * decide when. - * - * Both are content-type questions rather than path questions, which is the - * property worth pinning: `/api/*` is served by the Hono bridge through the same - * middleware, so a rule that keyed on anything else would quietly forbid clients - * from caching the API. - */ - const html = (headers: Record<string, string> = {}) => new Response("<!doctype html>", { headers: { "content-type": "text/html; charset=utf-8", ...headers }, diff --git a/packages/vitnode/src/tanstack/start/document-headers.ts b/packages/vitnode/src/tanstack/start/document-headers.ts index 2baeafad9..0a694e20f 100644 --- a/packages/vitnode/src/tanstack/start/document-headers.ts +++ b/packages/vitnode/src/tanstack/start/document-headers.ts @@ -1,90 +1,14 @@ -/** - * What a VitNode document response may say about being stored. - * - * `private, no-store` is a description of the body rather than a precaution. - * Every page a VitNode app renders streams a dehydrated Query cache into its - * HTML, and that cache always holds `["vitnode","session"]` - the visitor's own - * name, avatar and `isAdmin` flag. Inside `/admin` it also holds - * `["vitnode","admin-session"]`, which is that administrator's entire permission - * set. - * - * ## It is an invariant, not a default - * - * A default is something a route may override, and there is no override a route - * could correctly choose here: the dehydrated cache is written into the stream - * by `setupRouterSsrQueryIntegration` for *every* document, so a route opting - * into `public, max-age=60` would publish whichever visitor rendered first to - * everyone who asked next. The route cannot know that, because the private - * payload is not something the route put there. So the directive is forced, and - * a route that sets its own is overwritten rather than obeyed. - * - * Public document caching is not forbidden forever - it is forbidden *while the - * session is dehydrated into the document*. Introducing it later is a separate - * piece of architecture in which the private state is kept out of the shared - * body, and this invariant moves with it rather than being quietly relaxed. - * - * `private` bars a shared cache; `no-store` bars every cache, the browser's own - * disk cache included - which is the half that matters on a shared machine, - * where the previous person's permission set should not be recoverable after - * they sign out. The known cost is the back/forward cache: Chromium keeps such - * pages eligible but evicts them when cookies change, so signing in or out - * invalidates a back-navigation that would have restored a page rendered for the - * previous session. - */ export const DOCUMENT_CACHE_CONTROL = "private, no-store"; -/** - * Whether this response is one of the documents the rule above describes. - * - * One question, and it is what keeps the API out of it. `/api/*` is served by - * the Hono bridge through this same middleware, and a bare `GET` from it carries - * no `Cache-Control` of its own - so a rule that applied to every response would - * quietly forbid clients from caching the API. An HTML content-type is the - * honest way to ask "is this a page", it needs no path list to be kept in step - * with the router, and it cannot be wrong about a response that has already been - * produced. - * - * It deliberately does *not* ask whether a directive is already present, which - * is exactly the exemption the invariant cannot afford. A redirect is not - * matched here either - it has no content type - and is handled by - * {@link applyRedirectCacheControl} instead. - * - * The one HTML page the API does serve - Swagger UI at `/api/swagger` - is - * therefore covered, which is correct rather than an edge case: it is a document - * an operator reads, not a response a client caches, and nothing about the JSON - * routes beside it is touched. - */ const isRenderedDocument = (headers: Headers): boolean => (headers.get("content-type") ?? "").toLowerCase().startsWith("text/html"); -/** - * Says what a rendered document is, on the response about to be sent. - * - * Mutates rather than returning a new `Response`, because the middleware already - * holds the one Start produced and rebuilding it would mean copying a stream. - * Everything that is not `text/html` - the API, assets, client chunks, a `204` - * with no content type at all - keeps whatever it had, including nothing. - */ export const applyDocumentCacheControl = (response: Response): void => { if (!isRenderedDocument(response.headers)) return; response.headers.set("cache-control", DOCUMENT_CACHE_CONTROL); }; -/** - * The same for a locale redirect, but only when it is carrying a cookie. - * - * A `308` from `/en/discover` to `/discover` is a fact about URLs, identical for - * every visitor and permanently cacheable - which is most of the point of - * answering with one, so it keeps that property. - * - * The exception is the redirect that also writes the locale cookie, which is - * what `/pl/admin` produces: a stored copy would hand the next visitor through - * the same shared cache a `Set-Cookie` chosen by somebody else and quietly - * switch their language. Shared caches are generally expected to refuse a - * `Set-Cookie` response, but that is not a property this application can assert - * about somebody else's proxy. - */ export const applyRedirectCacheControl = (response: Response): void => { if (!response.headers.has("set-cookie")) return; diff --git a/packages/vitnode/src/tanstack/start/index.ts b/packages/vitnode/src/tanstack/start/index.ts index 02ebef242..c2c7715cb 100644 --- a/packages/vitnode/src/tanstack/start/index.ts +++ b/packages/vitnode/src/tanstack/start/index.ts @@ -1,30 +1,3 @@ -/** - * VitNode's TanStack Start request pipeline - `@vitnode/core/tanstack/start`. - * - * One call, in an app's `src/start.ts`: - * - * import { createVitNodeStart } from '@vitnode/core/tanstack/start' - * import { vitNodeConfig } from './vitnode.config' - * - * export const startInstance = createVitNodeStart({ config: vitNodeConfig }) - * - * ## Why the two Start primitives are allowed here - * - * `boundary.test.ts` keeps `createServerFn` out of this package, because - * uncompiled - which is how this package reaches a server - its handler silently - * resolves to `undefined`. `createStart` and `createMiddleware` are exempt in - * this one directory, and the reason is that neither is compiled at all: both are - * plain builders that return the options object they were handed, and a *request* - * middleware has only a `.server()` branch, which is the only branch a server - * entry ever runs. The host still owns the composition - it calls this and - * exports the result as `startInstance`, which is what the framework reads. - * - * ## And why nothing here is marked server-only - * - * `src/start.ts` is a client entry as well as a server one. See the note in - * `locale-middleware.ts`: the browser is kept out of the request half by the - * compiler dropping a `.server()` callback, not by a marker. - */ export type { VitNodeStartOptions } from "./create-start"; export { createVitNodeStart } from "./create-start"; export { diff --git a/packages/vitnode/src/tests/content-fixtures.ts b/packages/vitnode/src/tests/content-fixtures.ts index 5e621e06f..2847c8247 100644 --- a/packages/vitnode/src/tests/content-fixtures.ts +++ b/packages/vitnode/src/tests/content-fixtures.ts @@ -1,11 +1,6 @@ import { defineContentType } from "@/content/define"; import { field } from "@/content/fields"; -/** - * Shared content types for Content Engine tests. Kept under `src/tests` so - * Vitest does not pick them up as a suite, and deliberately close to the - * `plugins/example` reference definitions. - */ export const testCategoryContentType = defineContentType({ id: "test.category", tableName: "test_categories", @@ -14,14 +9,6 @@ export const testCategoryContentType = defineContentType({ }, }); -/** - * A category whose name lives on its translation table, and which says so. - * - * The shape `blog.category` has: a shared `color`, a localized `name`, and - * `admin.titleField: "name"`. It exists for one question - what a *relation* to - * it is labelled with - because `name` is not a column on `test_localized_categories` - * at all, so a plain join can only ever produce the numeric id. - */ export const testLocalizedCategoryContentType = defineContentType({ id: "test.localized-category", tableName: "test_localized_categories", @@ -41,13 +28,6 @@ export const testLocalizedCategoryContentType = defineContentType({ }, }); -/** - * A plain, unlocalized record pointing at a **localized** category. - * - * Deliberately not localized itself: resolving a relation label in the reader's - * language is a property of the *target*, and nothing about the owner has to - * change for it to work. - */ export const testLocalizedRelationArticleContentType = defineContentType({ id: "test.localized-relation", tableName: "test_localized_relation_articles", @@ -144,13 +124,6 @@ export const testPostContentType = defineContentType({ }, }); -/** - * The Stage 4 shape: `testPostContentType` plus the full editorial workflow. - * - * A separate fixture rather than a flag on the post, for the same reason the - * searchable one is separate: leaving the post exactly as it was is what proves - * a Stage 2 content type is untouched by editorial existing. - */ export const testEditorialPostContentType = defineContentType({ id: "test.editorial", tableName: "test_editorial_posts", @@ -186,11 +159,6 @@ export const testEditorialPostContentType = defineContentType({ }, }); -/** - * Editorial without publication or a public API - the "revisions stand alone" - * fixture. Neither preview nor scheduling is expressible here, which is the - * point. - */ export const testEditorialNoteContentType = defineContentType({ id: "test.note", tableName: "test_notes", @@ -201,14 +169,6 @@ export const testEditorialNoteContentType = defineContentType({ editorial: { enabled: true }, }); -/** - * The Stage 3 shape: `testPostContentType` plus `search`. - * - * A separate fixture rather than a flag on the post: keeping the post exactly as - * it was is what proves a Stage 2 content type is untouched by search existing. - * `code` and `author` stay out of `publicApi.fields` so "an indexed field is a - * public field" has something to be wrong about. - */ export const testSearchablePostContentType = defineContentType({ id: "test.searchable", tableName: "test_searchable_posts", @@ -241,14 +201,6 @@ export const testSearchablePostContentType = defineContentType({ }, }); -/** - * The Stage 5A shape: shared and localized fields on one content type. - * - * A separate fixture rather than a flag on an existing one, for the same reason - * the searchable and editorial ones are separate: leaving every Stage 1-4 fixture - * exactly as it was is what proves localization existing changes nothing for - * them. - */ export const testLocalizedArticleContentType = defineContentType({ id: "test.localized", tableName: "test_localized_articles", @@ -276,12 +228,6 @@ export const testLocalizedArticleContentType = defineContentType({ }, }); -/** - * Localized with a slug the caller always supplies. - * - * The other half of the slug rules: a sourceless localized slug is `required` in - * the translation create payload, where a sourced one is derived. - */ export const testLocalizedNoteContentType = defineContentType({ id: "test.localized-note", tableName: "test_localized_notes", @@ -293,18 +239,6 @@ export const testLocalizedNoteContentType = defineContentType({ }, }); -/** - * The Stage 5B fixture: localized **and** editorial **and** published. - * - * All three, because that is the combination the editorial layer needs and the - * one Stage 5A refused. The translation table gains `status` and `publishedAt`, - * each locale gets its own version and its own history, and the base row keeps the - * global lifecycle every translation's visibility is subordinate to. - * - * `publicApi` and `search` are still absent - the first because - * `testLocalizedPageContentType` covers the public read layer, and the second - * because it remains refused alongside localization until Stage 5D. - */ export const testLocalizedGuideContentType = defineContentType({ id: "test.localized-guide", tableName: "test_localized_guides", @@ -323,20 +257,6 @@ export const testLocalizedGuideContentType = defineContentType({ }, }); -/** - * The Stage 5C fixture: localized **and** public. - * - * Everything the localized guide has, plus `publicApi` - so it exercises the - * things only a public localized content type can have: a locale-aware read, a - * strict-locale slug, a fallback, a per-locale cache tag and a preview link bound - * to one language. - * - * The allowlist deliberately mixes the two halves of the partition. `title`, - * `slug` and `body` come off the translation and `featured` off the base row, so - * a public response is a join rather than a projection - and `searchableFields` - * and `filterableFields` each name one of each, which is what proves both are - * evaluated against the translation actually being served. - */ export const testLocalizedPageContentType = defineContentType({ id: "test.localized-page", tableName: "test_localized_pages", @@ -394,18 +314,6 @@ export const testStrictLocalizedPageContentType = defineContentType({ }, }); -/** - * The Stage 5D fixture: localized, public **and** searchable. - * - * The whole stack on one content type, because per-locale search is the only - * combination that needs all of it: the translation supplies the prose, the base - * row supplies the shared values and the publication state, and `publicApi` - * supplies the allowlist every indexed field has to be in. - * - * `pathTemplate` carries `{locale}`, which a localized content type must - two - * languages routinely answer to the same slug, so one URL for both would make a - * search hit ambiguous. - */ export const testLocalizedSearchPageContentType = defineContentType({ id: "test.localized-search-page", tableName: "test_localized_search_pages", @@ -436,19 +344,6 @@ export const testLocalizedSearchPageContentType = defineContentType({ }, }); -/** - * The Stage 6 localized fixture: a localized group and a **shared** repeatable - * on one searchable, localized content type. - * - * It exists for the three places Stage 6 and Stage 5 meet and can disagree: - * - * 1. a translation revision has to record `seo` in its nested logical shape, - * not run it through the scalar coercion that turns an object into `null`; - * 2. a localized search document is built from three sources at once - the base - * row, the shared collections and one translation - and every path that - * builds one has to supply all three or the documents differ; - * 3. the rebuild has to reproduce exactly what live synchronization wrote. - */ export const testAdvancedLocalizedContentType = defineContentType({ id: "test.advanced-localized", tableName: "test_advanced_localized", @@ -506,14 +401,6 @@ export const testAdvancedLocalizedContentType = defineContentType({ }, }); -/** - * The Stage 8 shape: `testPostContentType` plus the whole delivery layer. - * - * A separate fixture rather than a flag on the post, for the same reason the - * searchable and editorial ones are separate: leaving the post exactly as it was is - * what proves a content type without `delivery` produces the same tables, the same - * routes, the same cache tags and the same events it always did. - */ export const testDeliveredPostContentType = defineContentType({ id: "test.delivered-post", tableName: "test_delivered_posts", @@ -544,13 +431,6 @@ export const testDeliveredPostContentType = defineContentType({ }, }); -/** - * A localized delivery content type: locale-prefixed URLs and per-locale history. - * - * Its slug is `localized: true`, which is what `delivery.redirects` requires on a - * localized content type - a shared slug would give every language the same segment, - * so one retired address would belong to several URLs at once. - */ export const testDeliveredLocalizedContentType = defineContentType({ id: "test.delivered-localized", tableName: "test_delivered_localized", @@ -627,13 +507,6 @@ export const testDeliveredPreviewableContentType = defineContentType({ }, }); -/** - * A content type whose form is grouped with `admin.form.sections`. - * - * Deliberately covers the two things grouping has to keep straight: a field left - * out of every section is left out of the form, and the sections' order is the - * form's order rather than the declaration order of `fields`. - */ export const testSectionedContentType = defineContentType({ id: "test.sectioned", tableName: "test_sectioned", @@ -655,19 +528,6 @@ export const testSectionedContentType = defineContentType({ }, }); -/** - * The file-field reference fixture: one image field and one strict GIF field. - * - * Two of them on purpose. `cover` is the ordinary case - several formats, both - * allowlists stated - and `animation` is the strict one, where a single - * extension and a single media type both have to match, so a PNG renamed to - * `.gif` is refused. Between them every branch of `validateContentFile` is - * reachable from a real definition rather than from a hand-built descriptor. - * - * `publicApi` exposes `cover` and withholds `animation`, which is what proves a - * file field is allowlisted like every other kind: the descriptor for one is in - * the public response and the other is not fetched at all. - */ export const testFilePostContentType = defineContentType({ id: "test.file-post", tableName: "test_file_posts", @@ -702,23 +562,6 @@ export const testFilePostContentType = defineContentType({ }, }); -/** - * The **many-files** fixture: one gallery, on its own junction table. - * - * A separate content type rather than a fourth field on `testFilePostContentType`, - * for the reason every other fixture here is separate: leaving the single-file - * one exactly as it was is what proves a `multiple: true` field changes nothing - * for a content type that has none - no extra table, no extra key in `files`, no - * extra query. - * - * `min: 1` and `max: 4` are both stated, because they are the two halves of what - * a collection can say about arity that the storage cannot: a junction table has - * no `NOT NULL` to be required by, and no ceiling of its own. - * - * `editorial` is on so the revision pins have something to pin, and `publicApi` - * exposes the gallery so "an identifier never crosses the public boundary" has a - * list to be true of rather than a single value. - */ export const testFileGalleryContentType = defineContentType({ id: "test.file-gallery", tableName: "test_file_galleries", diff --git a/packages/vitnode/src/tests/import-graph.ts b/packages/vitnode/src/tests/import-graph.ts index 30dccdff6..b7065f15a 100644 --- a/packages/vitnode/src/tests/import-graph.ts +++ b/packages/vitnode/src/tests/import-graph.ts @@ -1,26 +1,6 @@ import { existsSync, readFileSync, statSync } from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; -/** - * The one import scanner every boundary test in this package shares. - * - * Fourteen `*-boundaries.test.ts` files each carried their own copy of this - - * `resolveSpecifier`, `runtimeImports`, `externalGraph`, `offenders` and two - * forbidden-specifier lists, around 110 identical lines apiece. They were copies - * rather than one module because each was written when its own subtree was - * split, and they drifted: some walked dynamic imports, some did not, and only - * some stripped `import type`. - * - * That mattered more than tidiness. These are negative assertions - "this graph - * reaches nothing from `next/*`" - and a scanner with a weaker regex passes them - * by finding less. One implementation means one regex, one set of forbidden - * lists, and one place where the controls in `next-boundary.test.ts` prove the - * whole thing still detects what it claims to. - * - * Deliberately in `src/tests/`, which `tsconfig.build.json` excludes: this is - * test machinery and must never be published from `dist`. - */ - /** `packages/vitnode/src`, which `@/` resolves against. */ export const SRC_ROOT = resolve( dirname(new URL(import.meta.url).pathname), @@ -29,13 +9,6 @@ export const SRC_ROOT = resolve( const CANDIDATE_SUFFIXES = ["", ".ts", ".tsx", "/index.ts", "/index.tsx"]; -/** - * A specifier to a file on disk, or `null` if it leaves the package. - * - * `null` is the interesting answer: it means the specifier is external, which is - * exactly what the forbidden lists are written against. Only `@/` and relative - * specifiers resolve, because those are the only two forms core uses internally. - */ export const resolveSpecifier = ( specifier: string, from: string, @@ -55,39 +28,10 @@ export const resolveSpecifier = ( return null; }; -/** - * Source with comments removed, string literals intact. - * - * Necessary rather than tidy, and the older per-file copies of this scanner all - * lacked it. This package documents its own boundaries in prose, so doc comments - * are full of sentences like `` `@/lib/fetcher` carries `import "server-only"` `` - * - and a regex looking for `import "…"` finds those, then reports a module as - * importing the very thing its comment explains it must not. Two of these suites - * failed on their own documentation the first time they were pointed at the - * whole package. - * - * A small lexer rather than a regex pair, because `.replace(/\/\/.*$/gm, "")` - * eats the second half of every URL in the file - including the ones in the - * `next-intl` docs links these comments cite. - * - * Regex literals are lexed too, and that is not theoretical tidiness either: - * `provider-records.test.ts` asserts its subject reads nothing from next-intl - * with `not.toMatch(/from "next-intl/)`. A lexer that did not know `/…/` was a - * regex saw the `"` inside it, opened a string that ran to the next quote three - * lines away, and reported the file as importing `next-intl` - failing the - * package-wide scan on a test whose entire purpose is asserting the opposite. - */ export const stripComments = (source: string): string => { let out = ""; let at = 0; - /** - * Whether a `/` here opens a regex literal rather than dividing. - * - * The standard heuristic: a regex cannot follow a value. So if the previous - * significant character could end one - an identifier, a number, `)`, `]` or - * a closing quote - the slash is division. - */ const opensRegex = (): boolean => { for (let back = out.length - 1; back >= 0; back -= 1) { const previous = out[back]; @@ -169,23 +113,6 @@ export const stripComments = (source: string): string => { return out; }; -/** - * Every specifier a file imports **at runtime**. - * - * `import type` is stripped first, and that is not a shortcut: a query module - * imports its API module's *type* to keep the fetcher's route literals - * inferring, and that module is a Hono server module. TypeScript erases it, so - * it never reaches a bundle - counting it would fail these suites on something - * that cannot break. - * - * Four shapes are matched, and the third and fourth are the ones the older - * copies disagreed about: - * - * from "x" static imports and `export … from` - * import("x") dynamic imports - how `next/dynamic` hid inside a dialog - * import "x" bare side-effect imports - how `server-only` gets in - * require("x") CommonJS, for the few scripts that still use it - */ export const runtimeImports = (path: string): string[] => { const source = stripComments(readFileSync(path, "utf8")).replace( /(^|[\n;])\s*import\s+type\s[\s\S]*?from\s*["'][^"']+["']/g, @@ -201,15 +128,6 @@ export const runtimeImports = (path: string): string[] => { .filter((specifier): specifier is string => Boolean(specifier)); }; -/** - * Every external specifier reachable from an entry, with the chain that got - * there. - * - * The chain is the whole value of this over a per-file grep: a boundary is - * almost never broken by the file somebody is editing. It is broken three hops - * away, and a failure that names only the specifier sends the reader looking in - * the wrong file. - */ export const externalGraph = ( entry: string, srcRoot: string = SRC_ROOT, diff --git a/packages/vitnode/src/tests/openapi-validate.ts b/packages/vitnode/src/tests/openapi-validate.ts index 4d104c152..9ae423901 100644 --- a/packages/vitnode/src/tests/openapi-validate.ts +++ b/packages/vitnode/src/tests/openapi-validate.ts @@ -1,21 +1,3 @@ -/** - * A JSON Schema check over the subset OpenAPI 3.0 documents actually contain. - * - * It exists because "the runtime response matches the OpenAPI schema" cannot be - * asserted with the Zod object the route was built from. `z.date()` renders in - * the document as `{ type: "string", format: "date-time" }` - which is exactly - * what `c.json(row)` puts on the wire - but the Zod object itself rejects that - * string, so parsing with it would report a contract break where the contract is - * kept. The document is what a generated client is built from, so the document - * is what a response has to satisfy. - * - * Deliberately small: `type`, `properties`, `required`, `nullable`, `enum`, - * `format: date-time`, `items`, `additionalProperties`, `oneOf`/`anyOf`/`allOf` - * and `$ref`. That is everything `@hono/zod-openapi` emits for the generated - * Content Engine routes, and anything it does not understand is reported rather - * than quietly passed. - */ - export type JsonSchemaLike = Record<string, unknown>; const ISO_DATE_TIME = @@ -44,13 +26,6 @@ const resolveRef = ( return (current as JsonSchemaLike | undefined) ?? {}; }; -/** - * Every way `value` fails `schema`, as dotted paths with a reason. - * - * An empty array means the response is valid. Returning the whole list rather - * than the first problem is deliberate - a response that is wrong in four places - * should say so once, not four runs in a row. - */ export const validateAgainstJsonSchema = ( value: unknown, rawSchema: JsonSchemaLike, diff --git a/packages/vitnode/src/views/admin/admin-request.ts b/packages/vitnode/src/views/admin/admin-request.ts index bf846cd2a..db49daa25 100644 --- a/packages/vitnode/src/views/admin/admin-request.ts +++ b/packages/vitnode/src/views/admin/admin-request.ts @@ -3,56 +3,12 @@ import type { BaseBuildModuleReturn } from "@/api/lib/module"; import { CONFIG_PLUGIN } from "@/config"; import { clientModule } from "@/lib/fetcher-client"; -/** - * What every AdminCP screen read has in common: how it names the API module it - * talks to, and what it does when the API refuses. - * - * Two things, both of which used to be re-invented per screen and both of which - * are wrong in the same way when they are. - */ - -/** - * An admin API module as a value the fetchers can carry without pulling the API - * into either bundle. - * - * The module is imported as a **type** only, so route literals, methods and - * response schemas all still infer; this supplies the one field the fetcher - * reads at runtime. Every AdminCP module is core's, so the plugin id is not a - * parameter - a plugin's own admin screen builds its own reference with - * `clientModule` and its own id. - * - * const cronAdminModuleRef = adminModuleRef<typeof cronAdminModule>(); - */ export const adminModuleRef = <T extends BaseBuildModuleReturn>(): T => clientModule<T>(CONFIG_PLUGIN.pluginId); /** The `name` every {@link AdminRequestError} carries. See below. */ const ADMIN_REQUEST_ERROR = "AdminRequestError"; -/** - * An AdminCP read was refused, and this is what it was refused with. - * - * A thrown error rather than a returned one, because the alternative is the bug - * this class exists to prevent: the fetchers hand non-2xx responses back rather - * than throwing on them, and `json()` would happily parse a `401` or a `429` - * body. Read as a page it has no `edges`, so the table renders empty - a failure - * that looks exactly like an installation with nothing in it, which is the one - * thing an operational screen must never look like. TanStack Query can only - * retry, report, or keep the last good page if the promise actually rejects. - * - * `status` is on the error rather than folded into the message so a caller can - * tell the finite cases apart without parsing English. `401` and `403` mean the - * admin session ended or never allowed this - the route guard is a navigation - * rule, not the boundary, so this is the *authorization* answer and it can - * arrive on a page the guard already let through. `429` is the rate limiter. A - * `500` never reaches here at all: `rawApiFetch` throws on those with the body - * attached. - * - * Recognised by `name` rather than by `instanceof`: `@vitnode/core` is imported - * from `dist` by the apps and from `src` by its own tests, so two copies of this - * class can exist in one process and `instanceof` would answer `false` across - * them. - */ export class AdminRequestError extends Error { constructor(status: number, screen: string, detail?: string) { super( @@ -74,13 +30,6 @@ export const isAdminRequestError = ( ): error is AdminRequestError => error instanceof Error && error.name === ADMIN_REQUEST_ERROR; -/** - * The parameters a failed request was carrying, for its message. - * - * Its own function because an error message is the only trace a production - * failure leaves, and "which page was it asking for" is the first thing anyone - * reading one wants. - */ export const describeAdminParams = (params: object): string => Object.entries(params) .filter(([, value]) => value !== undefined) diff --git a/packages/vitnode/src/views/admin/layouts/admin-link.test.ts b/packages/vitnode/src/views/admin/layouts/admin-link.test.ts index 6a55fea50..db0b1a0c7 100644 --- a/packages/vitnode/src/views/admin/layouts/admin-link.test.ts +++ b/packages/vitnode/src/views/admin/layouts/admin-link.test.ts @@ -2,20 +2,6 @@ import { describe, expect, it } from "vitest"; import { isExternalHref } from "./normalize-url"; -/** - * Which AdminCP destinations are a router's business and which are not. - * - * A plugin's `admin.nav` entry may point anywhere, and an absolute URL is not a - * path - which matters because every link component in VitNode takes a path. - * Hand `RouterLink` an external URL and it becomes `<Link to="https://...">`, - * which the router matches by pathname alone: `https://example.com` arrives as - * `/` and client-navigates to the front page. The sidebar entry then goes to the - * wrong place, silently. - * - * This classification is what stops that, so it is worth pinning precisely - - * especially the two cases in the middle, which are the ones a looser rule gets - * wrong in opposite directions. - */ describe("isExternalHref", () => { it.each([ "https://status.example.com", @@ -40,12 +26,6 @@ describe("isExternalHref", () => { expect(isExternalHref(href)).toBe(false); }); - /** - * The reason the pattern is anchored rather than a bare `includes(":")`. A - * colon is legal deeper in a path, and a rule that saw one anywhere would - * route a perfectly ordinary admin screen through a plain anchor - losing the - * client-side navigation and, in the Next.js app, the locale prefix with it. - */ it("does not mistake a colon inside a path for a scheme", () => { expect(isExternalHref("/admin/core/users/a:b")).toBe(false); expect(isExternalHref("/admin/core/search?q=a:b")).toBe(false); diff --git a/packages/vitnode/src/views/admin/layouts/admin-link.tsx b/packages/vitnode/src/views/admin/layouts/admin-link.tsx index af27bffc9..44f8ea7ab 100644 --- a/packages/vitnode/src/views/admin/layouts/admin-link.tsx +++ b/packages/vitnode/src/views/admin/layouts/admin-link.tsx @@ -2,32 +2,8 @@ import type { AuthLinkComponent } from "@/views/auth/auth-link"; import { isExternalHref } from "./normalize-url"; -/** - * A destination that leaves the application, as a plain anchor. - * - * No router, no locale prefix, no route lookup - which is the entire correct - * handling of an absolute URL and none of what a `LinkComponent` does. - */ const ExternalLink: AuthLinkComponent = props => <a {...props} />; -/** - * Which component renders one AdminCP destination. - * - * A `LinkComponent` is documented to take a *path* and turn it into a - * navigation. A plugin's `admin.nav` entry may point at an external URL instead - * - a docs site, a status page, an external dashboard - and every implementation - * of that seam mishandles one. `RouterLink` hands it to TanStack Router as `to`, - * which matches it against the route tree by pathname alone, so - * `https://status.example.com` arrives as `/` and renders as a client-side - * navigation to the front page. A locale-aware `Link` localizes it instead. Both - * produce a sidebar entry that goes somewhere other than where its author said, - * and neither produces an error. - * - * Deciding here keeps that knowledge in the one place that has external hrefs to - * render, rather than requiring every link component to grow the same special - * case - and it is decided identically for the sidebar and the command palette, - * so an entry cannot behave one way when clicked and another way when searched. - */ export const adminLinkFor = ( href: string, LinkComponent: AuthLinkComponent, diff --git a/packages/vitnode/src/views/admin/layouts/breadcrumb/breadcrumb-admin-content.tsx b/packages/vitnode/src/views/admin/layouts/breadcrumb/breadcrumb-admin-content.tsx index c97feebca..554078dcc 100644 --- a/packages/vitnode/src/views/admin/layouts/breadcrumb/breadcrumb-admin-content.tsx +++ b/packages/vitnode/src/views/admin/layouts/breadcrumb/breadcrumb-admin-content.tsx @@ -10,13 +10,7 @@ export interface BreadcrumbAdminContentProps { /** Explicit labels by href, for a crumb the navigation cannot name. */ labels?: Record<string, string>; LinkComponent: AuthLinkComponent; - /** - * The visible navigation, which is where crumb labels come from. - * - * The *visible* one specifically: it has already been filtered by this admin's - * permissions, so a crumb can only ever be named after a screen they may open. - * An unnamed segment falls back to a humanized spelling and is not a link. - */ + nav: NavAdminParent[]; /** The last crumb's label, when the page knows it and the navigation cannot. */ overrideLastLabel?: string; @@ -24,27 +18,6 @@ export interface BreadcrumbAdminContentProps { segments: string[]; } -/** - * The AdminCP breadcrumb, framework-free. - * - * Labels come from the navigation rather than from a registry of their own, and - * that is the point: the sidebar and the trail name the same screen the same - * way, in every language, without anybody maintaining a second list that agrees - * until it doesn't. A plugin that adds a nav entry gets a breadcrumb label for - * free, and one that adds a screen *without* a nav entry gets a humanized - * fallback rather than a blank. - * - * ## What decides which trail is shown - * - * Not this component. The matched route declares it as `staticData.breadcrumb`, - * and because one AdminCP route is usually several segments deep it declares a - * `breadcrumbGroup` - so these items go straight into the shell's own list, - * separators and all. There is no map from pathname to trail anywhere, and - * nothing registers into one. - * - * Items rather than a list of its own: the shell renders one navigation landmark - * and one `<ol>` for the whole trail, whichever routes contributed to it. - */ export const BreadcrumbAdminContent = ({ labels, LinkComponent, diff --git a/packages/vitnode/src/views/admin/layouts/breadcrumb/resolve-breadcrumb.test.ts b/packages/vitnode/src/views/admin/layouts/breadcrumb/resolve-breadcrumb.test.ts index 8edb78e9d..366dba84b 100644 --- a/packages/vitnode/src/views/admin/layouts/breadcrumb/resolve-breadcrumb.test.ts +++ b/packages/vitnode/src/views/admin/layouts/breadcrumb/resolve-breadcrumb.test.ts @@ -4,15 +4,6 @@ import type { NavAdminParent } from "../sidebar/nav/nav-model"; import { resolveBreadcrumb } from "./resolve-breadcrumb"; -/** - * The AdminCP trail, as a function of the *visible* navigation and a path. - * - * The rule matters more than it looks: labels come from the navigation the admin - * can actually see, so a screen they may not open cannot be named by a crumb - * either. Everything else falls back to a humanized segment, which is what keeps - * a dynamic id (`/admin/core/users/42`) from rendering as a blank. - */ - const nav: NavAdminParent[] = [ { id: "core", @@ -54,11 +45,6 @@ describe("resolveBreadcrumb", () => { ]); }); - /** - * The first label wins, which is why the parent "Users" is used rather than - * the sub-item "List" - both are declared at `/admin/core/users`, and the - * parent is the one a reader recognises from the sidebar heading. - */ it("prefers the first declaration when two entries share an href", () => { const [, users] = resolveBreadcrumb(nav, ["core", "users"]); @@ -86,11 +72,6 @@ describe("resolveBreadcrumb", () => { expect(crumbs[1]).toMatchObject({ isKnown: false, isLink: false }); }); - /** - * The permission filter has already run by the time nav arrives here, so an - * entry the admin cannot see is simply absent - and the crumb degrades to the - * humanized fallback rather than leaking the screen's translated name. - */ it("falls back for a screen missing from the visible navigation", () => { const withoutUsers: NavAdminParent[] = [ { diff --git a/packages/vitnode/src/views/admin/layouts/normalize-url.ts b/packages/vitnode/src/views/admin/layouts/normalize-url.ts index 8d460b6f8..112b9aa88 100644 --- a/packages/vitnode/src/views/admin/layouts/normalize-url.ts +++ b/packages/vitnode/src/views/admin/layouts/normalize-url.ts @@ -1,29 +1,5 @@ export const normalizeUrl = (url: string): string => url.endsWith("/") && url.length > 1 ? url.slice(0, -1) : url; -/** - * Whether an href leaves this application entirely. - * - * A plugin's `admin.nav` entry may point anywhere - a docs site, a status page, - * an external dashboard - and such an href is not a *path*, which is what every - * `LinkComponent` in VitNode is documented to take. Handing one to `next-intl`'s - * `Link` or to TanStack Router's asks a router to localize and match an absolute - * URL, and both answer with something broken rather than with the page the - * plugin author named. - * - * So the shell classifies first and renders a plain anchor for these, which is - * what an external destination always wanted: no locale prefix, no route lookup, - * no framework involved. - * - * ## What counts - * - * Anything with a scheme (`https:`, `mailto:`, `tel:`) and anything - * protocol-relative (`//host/path`), because the browser resolves the second - * against the current scheme and lands on another origin just the same. - * - * A path is *not* external, including one with a colon later in it - * (`/admin/core/users/a:b`) - hence the anchored pattern rather than a bare - * `includes(":")`. - */ export const isExternalHref = (href: string): boolean => href.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(href); diff --git a/packages/vitnode/src/views/admin/layouts/search/search-content.tsx b/packages/vitnode/src/views/admin/layouts/search/search-content.tsx index fc2b67209..2ac92ce1f 100644 --- a/packages/vitnode/src/views/admin/layouts/search/search-content.tsx +++ b/packages/vitnode/src/views/admin/layouts/search/search-content.tsx @@ -23,21 +23,6 @@ const SearchAdminDialogContent = React.lazy(async () => })), ); -/** - * The AdminCP search control: a button on a phone, a fake input on a desktop, - * and `⌘K` everywhere. - * - * The palette itself is `React.lazy` and only imported once the visitor shows - * intent - a hover, a focus, a click, or the shortcut. That keeps the command - * component, the query it runs and (in the Next.js app) a Server Action - * reference out of the initial AdminCP bundle, which every admin page pays for. - * - * ## `isApple` starts undefined on purpose - * - * The key hint renders nothing until the effect has run. The server cannot know - * which modifier to name, and guessing produces a hydration mismatch on half of - * all machines - so the badge appears a frame late rather than wrong. - */ export const SearchAdminContent = ({ items, LinkComponent, diff --git a/packages/vitnode/src/views/admin/layouts/search/search-dialog-content.tsx b/packages/vitnode/src/views/admin/layouts/search/search-dialog-content.tsx index 68e59cfee..dff74743b 100644 --- a/packages/vitnode/src/views/admin/layouts/search/search-dialog-content.tsx +++ b/packages/vitnode/src/views/admin/layouts/search/search-dialog-content.tsx @@ -37,26 +37,6 @@ import { matchesAdminNavItem } from "./flatten-nav"; import { adminSearchUsersQueryKey } from "./search-users"; import { splitResultBudget } from "./split-results"; -/** - * The AdminCP command palette, with the three things it cannot decide handed in. - * - * a link -> LinkComponent for the entries that open in a new tab - * a navigation -> onNavigate for the entries that do not - * a user lookup -> searchUsers a Server Action, or a server function - * - * ## Why permission filtering is not repeated here - * - * `items` arrives already filtered - it is `flattenAdminNav` over the navigation - * `buildAdminNav` produced, and that is the *only* nav tree in the AdminCP. A - * palette that built its own would be a second tree with a second chance to - * forget a permission check, and the failure mode is a search box that reveals - * the existence of every screen an admin cannot open. There is one tree, filtered - * once, and the palette reads it. - * - * The two things not derived from nav are gated on their own terms: the user - * lookup behind `users:can_view`, checked here *and* by the API behind it, and - * the search-only pages by whoever assembled `items`. - */ const NavCommandItem = ({ item, LinkComponent, @@ -77,17 +57,6 @@ const NavCommandItem = ({ </> ); - /** - * A new-tab or external entry renders a real anchor and the command item - * clicks it. - * - * `onNavigate` closes the dialog and moves the router, which is exactly wrong - * for a destination that is supposed to open elsewhere and leave the AdminCP - * where it was. The two conditions are separate: `isOpenInNewTab` is what the - * plugin author asked for, and `isExternalHref` is what the router cannot do - * regardless of what they asked - an absolute URL handed to any of VitNode's - * link components resolves to the wrong place. See `adminLinkFor`. - */ if (item.isOpenInNewTab || isExternalHref(item.href)) { return ( <CommandItem onSelect={() => linkRef.current?.click()} value={item.href}> @@ -128,14 +97,7 @@ export interface SearchAdminDialogContentProps { onNavigate: (href: string) => void; onOpenChange: (open: boolean) => void; open: boolean; - /** - * How to look a user up, or nothing when the host has not wired one. - * - * Absent is a real state rather than a stub that resolves to `[]`: the users - * group is skipped entirely, no request is made, and - crucially - the "keep - * typing" hint is not shown, which would otherwise promise results that can - * never arrive. - */ + searchUsers?: AdminUserSearch; } @@ -217,14 +179,6 @@ export const SearchAdminDialogContent = ({ debounceUsersQuery(value); }; - /** - * Navigate *after* the dialog has finished closing. - * - * Base UI runs an exit animation, and moving the router while it plays leaves - * the overlay stranded over the page it navigated to - a transparent element - * that swallows every click. The href is parked here and spent in - * `onOpenChangeComplete`. - */ const navigateOnClose = (href: string) => { debounceUsersQuery.cancel(); pendingHrefRef.current = href; diff --git a/packages/vitnode/src/views/admin/layouts/search/search-only-pages.tsx b/packages/vitnode/src/views/admin/layouts/search/search-only-pages.tsx index 557e8435d..dbbf0f44f 100644 --- a/packages/vitnode/src/views/admin/layouts/search/search-only-pages.tsx +++ b/packages/vitnode/src/views/admin/layouts/search/search-only-pages.tsx @@ -11,15 +11,6 @@ import type { AdminSearchNavItem } from "./flatten-nav"; import { buildSearchText } from "./flatten-nav"; -/** - * A screen the command palette offers that the sidebar does not. - * - * The palette is otherwise a flattening of the navigation, and deliberately so - - * an entry can only be found if it survived the permission filter. These are the - * exceptions: real pages, reachable by URL, that were kept out of the sidebar to - * keep it short. They are the reason the palette needs its *own* permission - * check rather than inheriting one, and each carries the tuple it is gated on. - */ export interface AdminSearchOnlyPage { href: string; icon: React.ReactNode; @@ -28,12 +19,6 @@ export interface AdminSearchOnlyPage { titleKey: string; } -/** - * The debug screen, and for now only it. - * - * Reached from the user menu, which gates it on the same tuple - so the two - * controls that lead there cannot disagree about who may see it. - */ export const ADMIN_SEARCH_ONLY_PAGES: AdminSearchOnlyPage[] = [ { href: "/admin/core/debug", @@ -43,17 +28,6 @@ export const ADMIN_SEARCH_ONLY_PAGES: AdminSearchOnlyPage[] = [ }, ]; -/** - * The search-only pages this admin may open, as palette items. - * - * Pure, and given the permission set rather than reading one: the Next.js - * AdminCP resolves it per request on the server and a TanStack host has it in - * hand from the admin session query, and neither spelling belongs in a rule that - * is really `hasStaffPermission` over a short list. - * - * Grouped under core's own heading, so a debug result sits where a reader - * expects rather than in a group of its own. - */ export const adminSearchOnlyItems = ({ pages = ADMIN_SEARCH_ONLY_PAGES, permissions, diff --git a/packages/vitnode/src/views/admin/layouts/search/search-sources.test.tsx b/packages/vitnode/src/views/admin/layouts/search/search-sources.test.tsx index 5cd1ab970..8ca0138e9 100644 --- a/packages/vitnode/src/views/admin/layouts/search/search-sources.test.tsx +++ b/packages/vitnode/src/views/admin/layouts/search/search-sources.test.tsx @@ -13,23 +13,6 @@ import { buildAdminNav } from "../sidebar/nav/nav-model"; import { flattenAdminNav, matchesAdminNavItem } from "./flatten-nav"; import { adminSearchOnlyItems } from "./search-only-pages"; -/** - * What the AdminCP command palette is allowed to know about. - * - * The property under test is a security-adjacent one, and it is a property of - * the *pipeline* rather than of any one function: the palette's index is the - * navigation flattened, and the navigation has already been filtered by - * permissions - so a screen an admin cannot open cannot be named by a search - * result either. A palette that built its own tree from the config would pass - * every test written against `flattenAdminNav` alone while leaking the entire - * panel, which is why these assertions start from a config and a permission set. - * - * Hiding a result is not itself a security boundary. The page behind it is still - * refused by Hono, which re-checks the staff permission tables on every request. - * What this prevents is *disclosure*: a search box that lists the screens an - * installation has, and the names of the plugins that added them. - */ - const CORE = "@vitnode/core"; const t: AdminNavTranslator = Object.assign((key: string): string => key, { @@ -151,12 +134,6 @@ describe("the palette indexes only what the sidebar shows", () => { }); describe("flattening", () => { - /** - * A parent with sub-items contributes its children and not itself: the parent - * is a disclosure triangle, and `/admin/core/users` is already among the - * children. Indexing it twice would spend two of the palette's ten result - * slots on one screen. - */ it("indexes sub-items rather than their parent, and never an href twice", () => { const index = flattenAdminNav( buildAdminNav({ permissions: root, t, vitNodeConfig: config() as never }), @@ -179,11 +156,6 @@ describe("flattening", () => { }); }); - /** - * External destinations keep their target through the flatten, so the palette - * renders them as a real anchor that opens elsewhere rather than moving the - * AdminCP's own router to an off-site URL. - */ it("preserves an external entry's new-tab classification", () => { const plugins = [ { diff --git a/packages/vitnode/src/views/admin/layouts/search/search-users.ts b/packages/vitnode/src/views/admin/layouts/search/search-users.ts index 2e73d10f5..fbb7b44fc 100644 --- a/packages/vitnode/src/views/admin/layouts/search/search-users.ts +++ b/packages/vitnode/src/views/admin/layouts/search/search-users.ts @@ -1,13 +1,3 @@ -/** - * The user half of the AdminCP command palette, as types only. - * - * Separate from `search-users.action.server.ts` because that module is a - * `"use server"` boundary: importing it pulls `@/lib/fetcher` - and with it - * `server-only` and `next/headers` - into whatever imports it. The shared dialog - * needs the *shape* and the *signature*, never the implementation, so those live - * here and each framework supplies its own reader. - */ - /** One user, as the palette renders them. */ export interface AdminSearchUser { avatarColor: string; @@ -17,30 +7,8 @@ export interface AdminSearchUser { nameCode: string; } -/** - * How the palette looks a user up. - * - * A prop rather than an import, and the reason is the same one the sign-out - * button has: Next.js answers this with a Server Action, and a TanStack Start - * host with a server function its own compiler can see. Neither may be named - * inside a shared component. - * - * The contract is deliberately narrow: it is called only when the admin holds - * `users:can_view` and the query is long enough, and a failure is expected to - * resolve to an empty list rather than reject - a palette that cannot reach the - * user index should still show the pages it matched. - */ export type AdminUserSearch = (query: string) => Promise<AdminSearchUser[]>; -/** - * The palette's user-lookup cache, as one key prefix. - * - * Named rather than written inline at the one `useQuery` because it also has to - * be *removed*: results are a previous identity's private data, and a React - * Query cache outlives a sign-out. A second administrator signing in on the same - * tab, without a reload, would otherwise be served the first one's matches from - * memory. See `removeAdminShellQueries`. - */ export const ADMIN_SEARCH_USERS_QUERY_KEY = [ "vitnode", "admin-search-users", diff --git a/packages/vitnode/src/views/admin/layouts/shell-boundaries.test.ts b/packages/vitnode/src/views/admin/layouts/shell-boundaries.test.ts index bbc6685bd..e3b02f6ab 100644 --- a/packages/vitnode/src/views/admin/layouts/shell-boundaries.test.ts +++ b/packages/vitnode/src/views/admin/layouts/shell-boundaries.test.ts @@ -14,24 +14,6 @@ import { const here = dirname(fileURLToPath(import.meta.url)); const srcRoot = resolve(here, "../../.."); -/** - * The AdminCP shell, split down the middle. - * - * The same boundary `auth-boundaries.test.ts` draws around the auth screens, for - * the same reason and with the same machinery. Stage 12 gives the AdminCP shell - * two callers - the Next.js `AdminLayout` and the TanStack Start `_admin` route - - * and a shared component that reaches `@/lib/navigation`, a `"use server"` - * module or `next-intl/server` cannot be rendered by the second one. Nothing - * about that failure is visible until somebody tries: it is a runtime resolution - * error deep inside a Vite SSR pass, not a type error. - * - * Two of these entries are the ones that actually bit. `components/ui/sidebar` - * and `components/ui/sheet` are shadcn primitives nobody thinks of as - * Next-coupled, and both imported `useTranslations` from `next-intl` - which - * resolves a *different* React context than the `use-intl` provider a TanStack - * route mounts, so the whole AdminCP threw "No intl context found" on the server - * and silently fell back to client rendering. - */ const SHARED = { breadcrumb: join(here, "breadcrumb/breadcrumb-admin-content.tsx"), navActive: join(here, "sidebar/nav/nav-active.ts"), @@ -48,13 +30,6 @@ const SHARED = { userBar: join(here, "user-bar/user-bar-content.tsx"), }; -/** - * The Next.js half, by path, so its absence can be asserted. - * - * Named rather than deleted along with the assertions that used them: each was - * the one place a Next.js API was allowed to appear in this subtree, and a test - * that stops naming them cannot notice one coming back. - */ const DELETED_NEXT_HALF = { breadcrumb: join(here, "breadcrumb/breadcrumb-admin.tsx"), navItem: join(here, "sidebar/nav/item.tsx"), @@ -82,24 +57,10 @@ describe("the shared AdminCP shell is framework-neutral", () => { }, ); - /** - * The failure this file was written for. - * - * `next-intl` bundles its own copy of `use-intl`, so its `useTranslations` - * reads a different React context than the `IntlProvider` a TanStack route - * mounts. A shared component that imports from `next-intl` typechecks, renders - * fine under Next, and throws "No intl context found" the first time a - * TanStack Start route server-renders it. - */ it.each(sharedEntries)("$name translates through use-intl", ({ path }) => { expect(offenders(path, ["next-intl"])).toEqual([]); }); - /** - * A `"use server"` module reaching a shared component is the other half of the - * same problem: it drags `@/lib/fetcher`, and with it `server-only` and - * `next/headers`, into the browser graph. - */ it.each(sharedEntries)("$name imports no server action", ({ path }) => { const serverModules = [...externalGraph(path).keys()].filter(specifier => specifier.endsWith(".server"), diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/item-content.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/item-content.tsx index 0c2186614..be70f9cb6 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/item-content.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/item-content.tsx @@ -26,39 +26,11 @@ import type { AdminNavItem, AdminNavSubItem } from "./nav-model"; import { adminLinkFor } from "../../admin-link"; import { navItemActivity } from "./nav-active"; -/** - * One sidebar entry, with the two things it cannot resolve for itself handed in. - * - * `pathname` rather than a hook, and `LinkComponent` rather than an import: the - * same seam `SettingsNavContent` and `HeaderContent` already draw, for the same - * reason. `usePathname` and a locale-aware `Link` come from `next-intl` in the - * Next.js AdminCP and from the router in TanStack Start, and importing either - * here would make the whole sidebar Next-only. - * - * The pathname is **internal** - `/admin` is outside the localized URL space in - * both applications, so there is no prefix to strip and nothing here localizes - * an href either. `LinkComponent` does that, once. - * - * ## Why `LinkComponent` is required rather than defaulting to `<a>` - * - * A missing wrapper would degrade silently into a full document reload on every - * sidebar click - never the right default, and invisible until somebody notices - * the panel flashing. Required, so a host has to answer. An external entry is - * the one exception and is classified before it gets here: see `adminLinkFor`. - */ export interface ItemNavAdminContentProps extends AdminNavItem { LinkComponent: AuthLinkComponent; pathname: string; } -/** - * An entry's target attributes, or nothing. - * - * `rel` travels with `target` rather than being set independently: a - * `_blank` link without `noopener` hands the opened page a live `window.opener` - * reference back into the AdminCP, and a plugin's declared nav entry may point - * at any external URL at all. - */ const externalProps = (isOpenInNewTab?: boolean) => isOpenInNewTab ? { rel: "noopener noreferrer", target: "_blank" } @@ -89,13 +61,6 @@ export const ItemNavAdminContent = ({ } }, [hasActiveChild]); - /** - * Closing the drawer after a tap, on a narrow screen only. - * - * The sidebar is a persistent rail on a desktop and a sheet over the page on a - * phone, so navigating without this leaves the visitor looking at the menu - * they just used rather than the page they asked for. - */ const closeOnMobile = () => { if (isMobile) toggleSidebar(); }; diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-active.test.ts b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-active.test.ts index 36494481c..25fab8888 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-active.test.ts +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-active.test.ts @@ -7,15 +7,6 @@ import { navItemActivity, } from "./nav-active"; -/** - * Which sidebar entry lights up, as arithmetic rather than as rendered markup. - * - * Worth pinning at this level because the same rule now runs under two routers - * and the inputs it gets are the awkward ones: hrefs that end in a slash - * (`/admin/core/`), sub-items that nest inside each other, and screens whose - * paths are string prefixes of unrelated screens. - */ - describe("isPathnameUnderHref", () => { it("matches the page itself", () => { expect(isPathnameUnderHref("/admin/core/users", "/admin/core/users")).toBe( @@ -29,11 +20,6 @@ describe("isPathnameUnderHref", () => { ).toBe(true); }); - /** - * The reason the prefix test appends a slash. `/admin/core/users` must not - * claim a sibling whose name merely starts with it, or two unrelated screens - * highlight together. - */ it("does not match a sibling that merely starts with the same characters", () => { expect( isPathnameUnderHref("/admin/core/users-import", "/admin/core/users"), @@ -74,11 +60,6 @@ describe("activeChildHref", () => { { href: "/admin/core/users/roles", title: "Roles" }, ]; - /** - * The case a `find` gets wrong. Both sub-items are "under" - * `/admin/core/users/roles`, and the list is declared first - so a first-match - * rule highlights "List" on the roles screen. - */ it("picks the longest match, not the first", () => { expect(activeChildHref("/admin/core/users/roles", items)).toBe( "/admin/core/users/roles", diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-active.ts b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-active.ts index 706f859f3..186d7ecc6 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-active.ts +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-active.ts @@ -2,27 +2,6 @@ import type { AdminNavItem, AdminNavSubItem } from "./nav-model"; import { normalizeUrl } from "../../normalize-url"; -/** - * Which sidebar entry the current URL is "on" - as arithmetic over two strings. - * - * Split out of `ItemNavAdmin` in Stage 12 for the same reason `nav-model` was - * split out of `getAdminNav`: the rule is identical in both frameworks and only - * the *source* of the pathname differs. Next.js reads it from `usePathname`, - * TanStack Start from `useRouterState`, and neither spelling belongs in a rule - * that is really about trailing slashes and prefixes. - * - * The pathname handed in is always **internal** - no locale prefix. `/admin` is - * outside the localized URL space in both applications, so this is a statement - * about what already arrives rather than a normalization anybody has to perform. - */ - -/** - * Whether `pathname` is `href` or lives underneath it. - * - * The `/` in the prefix test is what stops `/admin/core/users` from claiming - * `/admin/core/users-import`: without it, any href that is a *string* prefix of - * the pathname would match, and two unrelated screens would highlight together. - */ export const isPathnameUnderHref = ( pathname: string, href: string, diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-content.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-content.tsx index a6bbd844c..527cb5324 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-content.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-content.tsx @@ -10,17 +10,6 @@ import type { NavAdminParent } from "./nav-model"; import { ItemNavAdminContent } from "./item-content"; -/** - * The AdminCP sidebar's groups and entries, given a navigation to render. - * - * Deliberately dumb: it decides nothing about *what* is in the navigation. The - * groups arrive already filtered by `buildAdminNav`, which is the only place a - * permission is ever consulted - so a group reaching here is a group this admin - * may see, and this component never has to ask a second time. - * - * `pathname` and `LinkComponent` pass straight through to each entry. See - * `ItemNavAdminContent` for why they are props rather than hooks. - */ export const NavSidebarAdminContent = ({ LinkComponent, nav, diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.test.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.test.tsx index 165641f9e..2c00a875a 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.test.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.test.tsx @@ -15,29 +15,8 @@ import { resolveAdminNav, } from "./nav-model"; -/** - * The AdminCP sidebar, as rules rather than as rendered markup. - * - * Everything asserted here is a decision `buildAdminNav` makes from data: which - * items a permission set admits, when a parent disappears because its children - * did, which plugin contributions become items, and where their hrefs point. - * No React is rendered and no router is involved - the icons are elements, but - * only their presence is ever read. - * - * The point of pinning it at this level is that Stage 12 gives the same model - * two callers: `getAdminNav` on Next.js and the TanStack AdminCP shell. A rule - * that lived in either would be a rule the other could drift from. - */ - const CORE = "@vitnode/core"; -/** - * `t` as identity, so an assertion names the message key it expects. - * - * `has` answers `false` for everything, which is the untranslated case: a - * content type falls back to the name derived from its id. That fallback is - * exactly why the model's translator type requires `has` at all. - */ const t: AdminNavTranslator = Object.assign((key: string): string => key, { has: () => false, }); @@ -96,12 +75,6 @@ describe("the core group", () => { ]); }); - /** - * The dashboard declares no permission, so it is what an admin with none at - * all still sees. Every other core item is behind one, and a parent whose - * children are all hidden goes with them rather than becoming a link to a page - * the API refuses. - */ it("leaves an admin with no permissions only the dashboard", () => { const nav = buildAdminNav({ permissions: EMPTY_STAFF_PERMISSION_SET, @@ -239,11 +212,6 @@ describe("plugin groups", () => { ).toEqual(["core", "@vitnode/example"]); }); - /** - * A declared entry is navigation and nothing else: it may point anywhere, and - * `isOpenInNewTab` survives the filter so an external destination still opens - * where its author said it should. - */ it("carries a declared entry's href, target and permission", () => { const nav = buildAdminNav({ permissions: only({ @@ -314,18 +282,6 @@ describe("plugin groups", () => { }); }); -/** - * The model's two stages, and the seam between them. - * - * `buildAdminNav` is both stages in one call, which is what the Next.js AdminCP - * wants because its config, its session and its translator are all in the same - * render pass. A TanStack Start host is not so lucky: it keeps the plugin - * registry out of the browser bundle on purpose, so it needs to run the - * config-only half where the config actually is and the rest in the browser. - * - * These assertions pin the property that makes that legal - stage one asks - * nothing about who is looking or what language they read. - */ describe("the two stages", () => { it("declares the whole tree without a permission set or a translator", () => { const declarations = adminNavDeclarations(config()); @@ -365,11 +321,6 @@ describe("the two stages", () => { ).toEqual(buildAdminNav({ permissions, t, vitNodeConfig })); }); - /** - * A content type's noun is not a message key but a rule over two of them with - * a derived fallback, so the declaration carries what the rule needs rather - * than a resolved string. - */ it("defers a content type's noun to the second stage", () => { const [, example] = adminNavDeclarations( config([ @@ -398,11 +349,6 @@ describe("the two stages", () => { }); }); - /** - * The untranslated fallback, which is what an installation sees before anybody - * has written `{pluginId}.content.article.label`. `t.has` answers false for - * everything here, so this is that path. - */ it("resolves a content noun from its id when nothing is translated", () => { const vitNodeConfig = config([ { @@ -428,19 +374,6 @@ describe("the two stages", () => { }); }); -/** - * Which strings the navigation needs, as data the declarations already carry. - * - * The AdminCP shell mounts one message provider above the whole panel. Before - * Stage 12 connected plugin navigation to it, that provider named the shell's - * own two namespaces and nothing else - which is right for a sidebar with only - * core in it, and renders a plugin group's headings as dotted identifiers the - * moment one appears. - * - * What is pinned here is the middle of that: the declarations know enough to - * say what they need, so a host never has to inspect a message tree to find out - * and never has to ship one to be safe. - */ describe("the namespaces a navigation needs", () => { const contentType = (id = "example.article", path = "example/articles") => ({ definition: { @@ -461,11 +394,6 @@ describe("the namespaces a navigation needs", () => { ]); }); - /** - * A plugin group costs three namespaces at most: its heading, one branch per - * content type it puts in the sidebar, and one for every entry it declared by - * hand. Never the plugin's whole tree. - */ it("asks for a leaf for the group heading, not the plugin's tree", () => { const namespaces = adminNavNamespaces( adminNavDeclarations( @@ -516,11 +444,6 @@ describe("the namespaces a navigation needs", () => { ).toEqual(["@vitnode/example.admin.nav"]); }); - /** - * De-duplicated and sorted, so two hosts with the same navigation ask for one - * cache entry rather than two holding identical bytes - and so a generated - * projection produces the same bytes on every machine. - */ it("is deduplicated and deterministically ordered", () => { const declarations = adminNavDeclarations( config([ diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx index 3850886c7..b821c007e 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx @@ -24,33 +24,6 @@ import { CONTENT_PERMISSIONS } from "@/content/const"; import { contentAdminHref } from "@/content/registry"; import { normalizeNamespaceList } from "@/routing"; -/** - * The AdminCP sidebar, as a pure function of configuration, permissions and - * strings. - * - * Split out of `getAdminNav` in Stage 12, and the split is the whole point: - * everything here is data in, data out - no `next-intl/server`, no session - * fetch, no `headers()`, nothing that only resolves inside one framework. The - * navigation an AdminCP renders is the same navigation in both, so it is decided - * once, here, and each runtime supplies the two things it alone can answer: - * which translator to use and whose permissions to filter by. - * - * Next.js getAdminNav() next-intl/server + getSessionAdminApi - * TanStack (Stage 12) use-intl translator + the admin session query - * - * That is also what makes it testable without a server: `buildAdminNav` takes a - * translator that can be `key => key`, a permission set that can be a literal, - * and a config that can be three lines - so the rules below (a nav item is - * hidden when its permission is missing, a group with nothing visible in it - * disappears entirely, a content type opts out with `navigation.enabled: false`) - * are pinned by ordinary unit tests rather than by opening a browser. - * - * Nothing here is a security boundary. A hidden nav item is a hidden *link*; the - * page it points at is still reachable by URL and is refused by Hono, which - * re-checks the staff permission tables on every request. See - * `api/lib/check-staff-permission.ts`. - */ - /** A resolved sub-item, as the sidebar and the search index read it. */ export interface AdminNavSubItem { href: string; @@ -70,57 +43,14 @@ export interface NavAdminParent { title: string; } -/** - * How a nav title is translated. - * - * Structurally `ContentLabelTranslator` rather than a bare - * `(key: string) => string`, and the difference is load-bearing: a content - * type's noun is looked up through `t.has(key)` first and falls back to a name - * derived from its id, so a translator without `has` throws the moment an - * installation has a content type. Both runtimes supply one - next-intl's - * `getTranslations` and use-intl's `createTranslator` - so requiring it costs - * nothing and makes the dependency visible. - * - * The key type is widened to `string` on purpose. Both runtimes type their keys - * as a union of every message in the catalogue, which a key assembled at - * runtime cannot satisfy, and a plugin's nav keys are not known to this package - * at all. Widening once at this boundary is what lets one model serve both. - */ export type AdminNavTranslator = ContentLabelTranslator; -/** - * A title before anybody has translated it. - * - * Two shapes rather than one string, because a content type's noun is not a - * message key: it is a *rule* over two keys and a derived fallback - * (`contentNouns`), and collapsing it to a key here would either lose the - * fallback or duplicate the rule. - * - * Keeping titles un-translated through the first stage is what lets the two - * stages run in different places - see {@link adminNavDeclarations}. - */ export type AdminNavTitle = | { contentTypeId: string; kind: "content"; pluginId: string } | { key: string; kind: "key"; - /** - * The message namespace this key's string lives in. - * - * Carried on the declaration rather than derived from the key, and that is - * the point: a namespace is a *path into the merged message tree*, and - * which prefix of a key is one cannot be worked out by looking at the key. - * `admin.global` is the namespace of `admin.global.nav.core`; - * `@vitnode/blog.admin.nav` is the namespace of - * `@vitnode/blog.admin.nav.reports`; and `@vitnode/blog.title` is a - * namespace that *is* a leaf, because loading a whole plugin tree to - * render one group heading would ship every AdminCP string it has. - * - * A rule that sniffed at key shapes would get one of those three wrong, - * silently, and the symptom would be a sidebar rendering dotted - * identifiers. So the stage that knows - the one that builds the - * declaration - writes it down. See {@link adminNavNamespaces}. - */ + namespace: string; }; @@ -138,14 +68,6 @@ export interface AdminNavItemDeclaration extends AdminNavSubItemDeclaration { items?: AdminNavSubItemDeclaration[]; } -/** - * The only part of `VitNodeConfig` the navigation reads. - * - * Narrowed on purpose: a full config satisfies it structurally, so every - * existing caller is unchanged, and a TanStack host that keeps its plugin - * registry out of the browser bundle can pass `{ plugins: [] }` without - * fabricating the rest of a config to do it. - */ export type AdminNavConfig = Pick<VitNodeConfig, "plugins">; /** One sidebar heading and everything declared under it. */ @@ -169,18 +91,6 @@ const isAllowed = ( set: StaffPermissionSet, ): boolean => !permission || hasStaffPermission(set, permission); -/** - * One group's declarations, translated, with everything this admin may not see - * removed. - * - * An item with sub-items is kept only while at least one of them survives: a - * parent whose children are all hidden is a disclosure triangle that opens onto - * nothing, and it would still be a link to a page the API refuses. - * - * Filtering happens *before* translating, so a hidden entry costs no message - * lookups - which matters because the AdminCP search index resolves the whole - * tree once per enabled locale. - */ export const filterNavItems = ( items: AdminNavItemDeclaration[], set: StaffPermissionSet, @@ -221,13 +131,6 @@ export const filterNavItems = ( return result; }; -/** - * A message key, in the shape a declaration carries titles. - * - * `namespace` defaults to the AdminCP shell's own, which is where every core - * entry's strings are and which the shell loads regardless - so core's group - * below says nothing about namespaces and a plugin's entries always do. - */ const key = (value: string, namespace = "admin.global"): AdminNavTitle => ({ key: value, kind: "key", @@ -330,18 +233,6 @@ const coreNavGroup = (): AdminNavGroupDeclaration => ({ ], }); -/** - * A plugin's content types, as nav declarations. - * - * Content types get one for free. `admin.navigation.enabled: false` opts out, - * and the usual permission filter hides anything the admin cannot view. - * - * Every href here points into `/admin/content/*`, the namespace the Content - * Engine owns, and it has spelled exactly that in both AdminCPs throughout the - * migration. Which application actually serves one is a decision for whatever - * renders the sidebar, not for this model: it produces hrefs, and the link - * component decides how to get there. - */ const contentNavItems = ( plugin: VitNodeConfig["plugins"][number], ): AdminNavItemDeclaration[] => @@ -362,14 +253,6 @@ const contentNavItems = ( }, })); -/** - * A plugin's hand-declared `admin.nav` entries. - * - * Deliberately independent of `routes`: an entry here may point at a plugin - * admin route, at a content screen, at an external URL or at a page in another - * application, and a plugin admin route may intentionally not appear in the - * sidebar at all. The two lists describe different things and are kept apart. - */ const declaredNavItems = ( plugin: VitNodeConfig["plugins"][number], ): AdminNavItemDeclaration[] => diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/sidebar-content.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/sidebar-content.tsx index 6ca83822c..9fe6c8d40 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/sidebar-content.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/sidebar-content.tsx @@ -8,29 +8,6 @@ import { SidebarHeader, } from "@/components/ui/sidebar"; -/** - * The AdminCP sidebar's frame: the mark, the switchers, and whatever navigation - * is passed in. - * - * Two seams, and they exist for different reasons: - * - * - **`LinkComponent`** - the mark links to `/admin/core`, and turning a path - * into a navigation is the one question whose answer differs between - * frameworks. The same seam every shared VitNode view draws. - * - **`languageSwitcher`** - a *slot* rather than a component, because the - * switcher is not one component with a prop difference. Next.js switches a - * locale by pushing a rewritten pathname through `next-intl`'s router; - * TanStack Start does it through `useSwitchLocale` and the router's own - * rewrite. They share the trigger's markup and nothing else, so each host - * passes its own and this component stays out of it. - * - * `ThemeSwitcher` is imported directly and deliberately: it reads VitNode's own - * `ThemeProvider` and touches no router at all, so there is nothing for a host - * to decide. - * - * Whether a switcher appears at all is the caller's decision too - a single- - * language install passes nothing, and this renders no empty slot. - */ export const SidebarAdminContent = ({ children, languageSwitcher, diff --git a/packages/vitnode/src/views/admin/layouts/user-bar/user-bar-content.tsx b/packages/vitnode/src/views/admin/layouts/user-bar/user-bar-content.tsx index 6b96106cb..5fb21d089 100644 --- a/packages/vitnode/src/views/admin/layouts/user-bar/user-bar-content.tsx +++ b/packages/vitnode/src/views/admin/layouts/user-bar/user-bar-content.tsx @@ -19,14 +19,6 @@ import { } from "@/components/ui/dropdown-menu"; import { CONFIG_PLUGIN } from "@/config"; -/** - * The admin the user bar renders - four fields and no more. - * - * A *requirement* rather than a copy of the admin session response: both - * applications' session shapes satisfy it structurally, so neither has to - * reshape anything, and a field renamed in the API fails at the two call sites - * rather than being silently rendered as `undefined`. - */ export interface AdminUserBarUser { avatarColor: string; email: string; @@ -34,30 +26,6 @@ export interface AdminUserBarUser { nameCode: string; } -/** - * The AdminCP user menu, with the two things it cannot decide for itself handed - * in. - * - * a link -> LinkComponent the framework's, or the host's migration one - * sign-out -> onSignOut the shared auth action, bound by the caller - * - * Sign-out is a *prop* rather than an import, and that is the whole reason this - * file exists. The Next.js menu called a `"use server"` action directly; a - * TanStack Start host signs out through `useSignOutAction()`, which also brings - * the canonical session cache back in step before anything navigates. Importing - * either here would pick one framework, so the component asks its caller and - * stays out of it. - * - * ## The debug entry is gated, and the gate is not a security boundary - * - * `useAdminStaffPermission` hides the link when this admin cannot view the debug - * screen - the same permission tuple the API checks. Hiding it is a courtesy to - * the reader; the page itself is still refused by Hono, which re-checks the - * staff permission tables on every request. See `api/lib/check-staff-permission`. - * - * Reading permissions here needs no Suspense boundary: by the time this renders, - * the shell has mounted a provider holding an already-resolved permission set. - */ export const UserBarAdminContent = ({ LinkComponent, onSignOut, diff --git a/packages/vitnode/src/views/admin/sign-in/sign-in-admin-content.tsx b/packages/vitnode/src/views/admin/sign-in/sign-in-admin-content.tsx index 135093a4d..27aac4844 100644 --- a/packages/vitnode/src/views/admin/sign-in/sign-in-admin-content.tsx +++ b/packages/vitnode/src/views/admin/sign-in/sign-in-admin-content.tsx @@ -1,39 +1,6 @@ import { LogoVitNode } from "@/components/logo-vitnode"; import { Card } from "@/components/ui/card"; -/** - * The AdminCP's front door - shared, so both frontends render the same one. - * - * The whole of what used to be `SignInAdminView`'s body, with the one Next.js - * thing lifted out. `form` is a slot rather than an import for the same reason - * it is one on the public `SignInContent`: what fills it differs by framework - * and only by framework. Next.js passes `FormSignIn`, a client wrapper that - * closes over a server action; a TanStack Start route passes `SignInFormContent` - * with a server function behind it. The logo, the card and the spacing are here - * once. - * - * Framework-free and hook-free, so it renders as a Server Component under - * Next.js exactly as `SignInAdminView` always did, and inside the router's - * client tree without a boundary of its own. - * - * ## Why this is not the public login page with different styling - * - * It signs in against a *different session*. The API branches on `isAdmin` and - * calls `SessionAdminModel.createSessionByUserId`, which mints the - * `vitnode_auth_admin` cookie and leaves the public one untouched - so this form - * is not a shortcut to `/login`, and finishing it does not sign anybody into the - * public site. The caller supplies that flag with the mutation, which is the - * only layer that ever cared. - * - * ## No reset-password link, deliberately - * - * Neither caller passes one, which the AdminCP has always expressed as - * `isEmail={false}` regardless of whether an email adapter is configured. - * Recovering an administrator's password is a public-site flow, and offering it - * here would send somebody out of the AdminCP mid-sign-in and onto the other - * application. It also means this screen renders no links at all, which is what - * lets it be identical under a router and under Next.js with no navigation seam. - */ export const SignInAdminContent = ({ form }: { form: React.ReactNode }) => ( <div className="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center gap-10 px-4 py-16"> <LogoVitNode className="w-64" /> diff --git a/packages/vitnode/src/views/admin/table/params.test.ts b/packages/vitnode/src/views/admin/table/params.test.ts index 796a834b1..51f801ea5 100644 --- a/packages/vitnode/src/views/admin/table/params.test.ts +++ b/packages/vitnode/src/views/admin/table/params.test.ts @@ -4,19 +4,6 @@ import type { AdminTableContract } from "./params"; import { normalizeAdminTableParams } from "./params"; -/** - * What an AdminCP list URL is allowed to mean. - * - * Every rule here exists because the alternative is a visible failure: a request - * the API answers with `400`, a cache key that cannot describe the request it - * names, or two entries holding identical rows. The four admin tables share this - * normaliser, so a rule proved once holds for all of them. - * - * The contracts below stand in for the real screens' - three sortable columns, - * optionally a search box, optionally a status filter - because what is being - * tested is the normaliser, not the cron route's column list. - */ - const plain: AdminTableContract<"createdAt" | "lastRun" | "name"> = { orderBy: ["createdAt", "lastRun", "name"], }; diff --git a/packages/vitnode/src/views/admin/table/params.ts b/packages/vitnode/src/views/admin/table/params.ts index 43ff99c08..b6139b9aa 100644 --- a/packages/vitnode/src/views/admin/table/params.ts +++ b/packages/vitnode/src/views/admin/table/params.ts @@ -1,64 +1,11 @@ import { DEFAULT_TABLE_PAGE_SIZE } from "@/components/table/url-state"; -/** - * What an AdminCP list route reads out of its URL, for every table that is a - * `DataTable` over a paginated admin endpoint. - * - * One normaliser rather than one per screen, because the four admin tables - - * cron, the queue, uploaded files and the system log - differ in exactly three - * declarations: which columns they sort by, whether they have a search box, and - * whether they have a status filter. Everything else about them is identical, - * and the parts that are identical are precisely the parts that are easy to get - * subtly wrong per screen. - * - * Pure: no transport, no React, no framework. Both applications call it - the - * Next.js Server Component on its `searchParams`, the TanStack Start route on - * its validated search - so `?orderBy=name&first=20` is the same request in - * both rather than two hand-written approximations of it. - * - * ## Why the defaults are applied here rather than in the fetcher - * - * The Next.js pages reach the API through `fetcher(..., { withPagination: true })`, - * which quietly writes `first=10` and `search=""` inside the URL builder. That - * is invisible to anything upstream - including a query key - so two requests - * that differ only in that hidden default share one cache entry. `/files` paid - * for that lesson first (see `views/files/my-files-query.ts`); this module is - * the same fix applied to the AdminCP's tables, which is why nothing built on it - * passes `withPagination`. - * - * ## Every rule below exists because the alternative is a broken page - * - * - **A page size is always present**, so the request the key describes is the - * request that is sent. - * - **`first` wins over `last`.** They are mutually exclusive and the API 400s - * on both; a URL carrying both was written by hand, and forward is the - * direction it meant. - * - **An unusable page size falls back** rather than 400ing: `?first=abc`, - * `?first=0` and `?first=-1` should render the table the administrator would - * have got had they not edited the address bar. - * - **A sort column the screen cannot sort by is dropped**, so the API applies - * its own default ordering instead of refusing the request. - * - **A blank search is no search.** The API trims and treats empty as no - * filter, so `?search=` and no `search` must not be two cache entries holding - * identical rows. - * - **A cursor is shape-checked only.** It is opaque and belongs to the ordering - * that minted it; a value that cannot be one is dropped rather than sent. - */ - /** The largest page any VitNode endpoint serves - `MAX_PAGE_SIZE` in `api/lib/with-pagination`. */ export const ADMIN_TABLE_MAX_PAGE_SIZE = 100; export const ADMIN_TABLE_ORDER = ["asc", "desc"] as const; export type AdminTableOrder = (typeof ADMIN_TABLE_ORDER)[number]; -/** - * A normalised admin list request - and therefore the shape a cache key is - * built from. - * - * Generic over the sortable columns so a screen's `orderBy` is its own union - * rather than `string`: a column that is not in the route's zod enum is a `400`, - * and the type is what stops one being written in the first place. - */ export interface AdminTableParams<TOrderBy extends string = string> { cursor?: string; first?: string; @@ -70,32 +17,12 @@ export interface AdminTableParams<TOrderBy extends string = string> { status?: string; } -/** - * The URL as either framework hands it over, before anything has checked it. - * - * Widened to `string[]` because a query string may repeat a key and both routers - * surface that as an array, and to `null` because `URLSearchParams.get` returns - * one. None of it is trusted. - */ export type RawAdminTableParams = Partial< Record<keyof AdminTableParams, null | string | string[] | undefined> >; /** What a screen declares about its own table. */ export interface AdminTableContract<TOrderBy extends string = string> { - /** - * The page size this table asks for when the URL does not. - * - * Defaults to {@link DEFAULT_TABLE_PAGE_SIZE}, which is what the four core - * admin tables use and what `DataTable` falls back to. A screen overrides it - * when its API has a different default of its own: a Content Engine list route - * answers `CONTENT_DEFAULT_PAGE_SIZE` records when asked for no size, so - * sending `first=10` would silently change what every content list shows. - * - * It is the size the normaliser *omits* from the URL as well as the one it - * sends, so the two cannot drift: `?first=<default>` and no `first` stay one - * request and one cache entry. - */ defaultPageSize?: number; /** The columns this list may be sorted by - the route's `orderBy` enum. */ orderBy: readonly TOrderBy[]; @@ -112,13 +39,6 @@ const readOne = (value: null | string | string[] | undefined): string => { return value ?? ""; }; -/** - * A page size, or `undefined` when the URL did not ask for a usable one. - * - * Clamped as well as validated: past {@link ADMIN_TABLE_MAX_PAGE_SIZE} the API - * answers `400`, and a table that 400s because somebody typed `?first=5000` is a - * broken page rather than a refused one. - */ const readPageSize = (raw: string): string | undefined => { if (!/^\d+$/.test(raw)) return undefined; @@ -128,15 +48,6 @@ const readPageSize = (raw: string): string | undefined => { return String(Math.min(size, ADMIN_TABLE_MAX_PAGE_SIZE)); }; -/** - * The `status` filter, reduced to the values the screen declared. - * - * The filter writes a comma-separated list (`?status=failed,pending`) and the - * queue route splits it and drops anything it does not recognise. Doing the same - * here is what keeps the cache key honest: `?status=nonsense` and no `status` at - * all are the same query, so they must be the same entry. Order and duplicates - * are normalised for the same reason. - */ const readStatus = ( raw: string, allowed: readonly string[], @@ -148,16 +59,6 @@ const readStatus = ( return values.length > 0 ? values.join(",") : undefined; }; -/** - * The request this URL is asking for. - * - * Total and idempotent: it cannot throw and running it on its own output changes - * nothing. Both properties are requirements of where it runs - a `validateSearch` - * that throws turns a hand-edited query string into a router error screen, and - * the normaliser is applied twice on every table navigation (once to turn a - * control's new query string back into route search, once more when the router - * validates the location that produces). - */ export const normalizeAdminTableParams = <TOrderBy extends string>( raw: RawAdminTableParams, contract: AdminTableContract<TOrderBy>, @@ -197,19 +98,6 @@ export const normalizeAdminTableParams = <TOrderBy extends string>( return params; }; -/** - * The pager's own state, as every paginated VitNode endpoint returns it. - * - * `zodPaginationPageInfo` in `api/lib/with-pagination` is the schema; this is - * the frontend's name for it, declared once so four screens do not each spell - * out six fields. Assigning a fetcher's inferred response to a page built on it - * is what keeps the two in step: rename a field in the schema and the - * assignment stops compiling. - * - * The cursors are opaque. They encode the ordered tuple the next page continues - * from, so they are meaningless outside the ordering that produced them - hand - * them back unchanged. - */ export interface AdminTablePageInfo { count: number; endCursor: null | string; diff --git a/packages/vitnode/src/views/admin/table/query.ts b/packages/vitnode/src/views/admin/table/query.ts index 2972fe127..7ff259311 100644 --- a/packages/vitnode/src/views/admin/table/query.ts +++ b/packages/vitnode/src/views/admin/table/query.ts @@ -1,17 +1,3 @@ -/** - * Where every AdminCP screen's cache entries live. - * - * One prefix for the whole panel, so a sign-out can drop all of it in a single - * `removeQueries` call rather than needing a list that somebody has to remember - * to extend. See `removeAdminShellQueries` in `tanstack/admin/queries.ts`, which - * is what does the dropping. - * - * `["vitnode", "admin"]` is deliberately *not* a prefix of - * `["vitnode", "admin-session"]`: Query matches keys element by element, so - * `"admin"` and `"admin-session"` are different second segments. The session - * entry has its own lifecycle (`removeAdminSession`) and must not be collected - * by a screen's invalidation. - */ export const ADMIN_QUERY_ROOT = ["vitnode", "admin"] as const; /** The root every cache entry for one admin screen hangs off. */ diff --git a/packages/vitnode/src/views/admin/views/content/actions/conflict-messages.test.ts b/packages/vitnode/src/views/admin/views/content/actions/conflict-messages.test.ts index 7d1fae9a0..be7581b2a 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/conflict-messages.test.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/conflict-messages.test.ts @@ -4,24 +4,6 @@ import { describe, expect, it } from "vitest"; import messages from "@/locales/en.json"; -/** - * Every message the conflict dialog formats, with exactly the arguments it - * passes. - * - * This exists because of a bug that reached a user. `desc` was written as - * `{name, select, other {record}}` - a `select` with only an `other` branch, so - * it *required* a `name` argument and then ignored it, always rendering the word - * "record". The dialog passed only `{ version }`. - * - * A missing ICU argument is not a blank in use-intl. It is a `FORMATTING_ERROR`, - * and the fallback it renders is the **key path** - so an editor whose save was - * refused read the literal string `core.content.conflict.desc` where the - * explanation should have been. Nothing crashed, nothing was logged where anybody - * would see it, and the one screen that has to explain itself said nothing. - * - * So the assertion is not "the key exists" - it did exist. It is "the message - * formats with the arguments the caller actually has". - */ const format = ( key: string, values: Record<string, number | string>, diff --git a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx index 2ad55c017..aabd6d7a1 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx @@ -196,15 +196,6 @@ const ContentFormFields = ({ [spec, values], ); - /** - * How every picker in this form fetches its options. - * - * One callback for the whole form rather than one per field, and memoised on - * purpose: `useReferenceOptions` and `ContentUserField` both list it in an - * effect's dependencies, so a fresh function each render would re-enter those - * effects on every keystroke. The content type is closed over, which is what - * stops one form ever asking another content type for its rows. - */ const loadOptions = React.useCallback<ContentOptionsLoader>( async ({ field, ids, search }) => await transport.loadOptions(spec.contentTypeId, field, search, ids), diff --git a/packages/vitnode/src/views/admin/views/content/actions/content-panel.tsx b/packages/vitnode/src/views/admin/views/content/actions/content-panel.tsx index 258d09ef9..eadcbcd2c 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/content-panel.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/content-panel.tsx @@ -11,14 +11,6 @@ import { } from "@/components/ui/dialog"; import { Loader } from "@/components/ui/loader"; -/** - * What the row's overflow menu hands every panel behind it. - * - * The open state lives in the menu rather than in the panel: a menu item - * unmounts with the menu the moment it is clicked, and a dialog rendered inside - * one would go with it. The panel is mounted *beside* the menu instead and told - * when to open. - */ export interface ContentPanelProps { finalFocus?: React.RefObject<HTMLElement | null>; onOpenChange: (open: boolean) => void; diff --git a/packages/vitnode/src/views/admin/views/content/actions/delivery-model.test.ts b/packages/vitnode/src/views/admin/views/content/actions/delivery-model.test.ts index a84cc5cfc..66b0d2c6f 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/delivery-model.test.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/delivery-model.test.ts @@ -15,16 +15,6 @@ import { hasContentDelivery, } from "./delivery-model"; -/** - * Whether the delivery panel is offered, and what it asks the API for. - * - * The second half is the interesting one and it is the reason this file exists: - * the AdminCP's interface language and a content translation's locale are two - * different concepts that happen to hold the same string, and every place that - * conflates them looks correct until a content type without translations is - * asked about a language it does not have. - */ - const as = (definition: unknown): AnyContentTypeDefinition => definition as AnyContentTypeDefinition; diff --git a/packages/vitnode/src/views/admin/views/content/actions/delivery-model.ts b/packages/vitnode/src/views/admin/views/content/actions/delivery-model.ts index c7647f536..c54a03459 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/delivery-model.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/delivery-model.ts @@ -1,46 +1,9 @@ import type { AnyContentTypeDefinition } from "@/content/types"; -/** - * Whether a content type has a delivery layer, and what its panel may ask for. - * - * Pure, and small on purpose: the delivery *feature* is `content/delivery.ts`'s, - * this is only the two readings the AdminCP's panel needs. Both AdminCPs read - * them, so a record's URL history opens the same way in either. - */ - -/** - * Whether the `Delivery` action is offered at all. - * - * `delivery.enabled` alone is the whole answer, and it already implies the rest: - * `resolveContentDelivery` refuses a delivery layer without `publicApi`, so a - * content type cannot reach a state where it has a canonical path and no public - * projection to serve it from. - * - * When this is `false` the action is **absent**, never disabled or empty: a menu - * entry that opens a panel saying "this content type has no URLs" is a control - * that looks broken. `row-actions-model.ts` is where that absence is applied, - * for both hosts at once. - */ export const hasContentDelivery = ( definition: AnyContentTypeDefinition, ): boolean => definition.delivery.enabled; -/** - * The locale a delivery read is *for*, or `undefined`. - * - * The distinction this function exists to hold: `locale` here names **which - * translation's URL** is being asked about, not which language the AdminCP is - * being read in. They are the same string in practice - the administrator's - * interface language decides which translation's row the list is showing, so it - * is also the translation whose address the panel should describe - and that - * coincidence is exactly why they are easy to conflate. - * - * For a content type with no translations there is no such thing as a - * per-language address: one record, one canonical path. Sending the interface - * language then would ask the API to resolve a locale that means nothing here, - * and would key one cache entry per administrator language for identical - * answers. So it is dropped, and the API answers about the record itself. - */ export const contentDeliveryRequestLocale = ( definition: AnyContentTypeDefinition, locale: string | undefined, diff --git a/packages/vitnode/src/views/admin/views/content/actions/delivery/delivery-panel.tsx b/packages/vitnode/src/views/admin/views/content/actions/delivery/delivery-panel.tsx index 355eecf6f..743ad9c52 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/delivery/delivery-panel.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/delivery/delivery-panel.tsx @@ -10,17 +10,6 @@ import { Loader } from "@/components/ui/loader"; import { contentDeliveryQueryOptions } from "../editorial-query"; import { useContentEditorialTransport } from "../editorial-transport"; -/** - * One record's canonical address and the ones it used to answer to. - * - * Read-only, and deliberately so: this screen manages nothing. There is no - * `can_manage_redirects` because there is nothing here to manage - the URL - * history is written by the mutation that moved a slug, inside the same - * transaction, and the panel only reports it. - * - * `locale` names the **translation** whose address is being asked about, not the - * language the AdminCP is being read in. See `contentDeliveryRequestLocale`. - */ export const DeliveryPanel = ({ contentTypeId, id, diff --git a/packages/vitnode/src/views/admin/views/content/actions/editorial-api.ts b/packages/vitnode/src/views/admin/views/content/actions/editorial-api.ts index 203eb3f83..9ef32e000 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/editorial-api.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/editorial-api.ts @@ -19,34 +19,6 @@ import { sendContentApiRequest, } from "../lib/api-result"; -/** - * The editorial panels' reads and writes, from the browser. - * - * Revision history, revision detail, restore, preview links, schedules and - * delivery - the four `⋯` actions, request for request the same routes - * `../actions/mutation-api.server.ts` and `../actions/delivery-api.server.ts` - * call from a Server Action. Same paths, same bodies, same statuses, same - * schemas, and the refusal read by the same shared mapper in - * `../lib/api-result.ts`. - * - * What it deliberately does not do is the half no browser can: `revalidatePath` - * and the public-locale cache diffing that a restore performs on the Next.js - * side. A TanStack Start application renders its public pages per request, so - * the equivalent work is the request plus a query invalidation - which is the - * host's, not this module's. - * - * Nothing here resolves a content type id. Every call takes a - * {@link ContentApiTarget}, exactly as the form's transport does; the registry - * lookup happens one layer up in the host. - */ - -/** - * A revision as the list renders it - metadata only, no snapshot. - * - * `.loose()` because a revision's `changedFields` and its actor columns are - * generic but the shape is the API's to grow. Only `id` is asserted, which is - * what the page's cursor arithmetic needs. - */ const zodRevisionList = z.object({ edges: z.array(z.object({ id: z.number() }).loose()), pageInfo: z.object({ @@ -74,15 +46,6 @@ const zodScheduleList = z.object({ hasCronAdapter: z.boolean(), }); -/** - * One address a record has answered to. - * - * Exactly what the admin route publishes and not one field more - the storage - * columns behind it are details of `core_content_slug_history`, and a panel that - * displayed them would make them part of a contract nobody meant to sign. The - * same schema `delivery-api.server.ts` declares, so both hosts refuse the same - * bodies. - */ const zodDeliveryEntry = z.object({ createdAt: z.coerce.date(), path: z.string(), @@ -132,12 +95,6 @@ export interface ContentScheduleListResult { const EMPTY_PAGE_INFO = { endCursor: null, hasNextPage: false } as const; -/** - * One page of history. Metadata only - snapshots load one at a time. - * - * The cursor is the last **version** on the previous page and the route is - * exclusive on it, so pages append cleanly and never repeat their boundary row. - */ export const listContentRevisionsInBrowser = async ( target: ContentApiTarget, id: number, diff --git a/packages/vitnode/src/views/admin/views/content/actions/editorial-query.test.ts b/packages/vitnode/src/views/admin/views/content/actions/editorial-query.test.ts index 34a428f0a..9c65f6069 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/editorial-query.test.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/editorial-query.test.ts @@ -19,14 +19,6 @@ import { nextContentRevisionCursor, } from "./editorial-query"; -/** - * The editorial panels' cache keys and the pure arithmetic around them. - * - * Prefixes, again, because that is the only property React Query uses: a delete - * removes `contentItemQueryRoot`, and every one of these has to sit under it or - * a deleted record's history survives in memory and can be rendered. - */ - const isPrefixOf = ( prefix: readonly unknown[], key: readonly unknown[], diff --git a/packages/vitnode/src/views/admin/views/content/actions/editorial-query.ts b/packages/vitnode/src/views/admin/views/content/actions/editorial-query.ts index c3b0fbd80..a3ff01167 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/editorial-query.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/editorial-query.ts @@ -18,61 +18,18 @@ import { contentSchedulesQueryKey, } from "../content-query"; -/** - * Where each editorial panel's answer is cached. - * - * Every key below is a literal extension of {@link contentItemQueryRoot}'s - * children in `../content-query.ts`, which is what makes the whole family - * disappear when a record is deleted: `removeContentItem` drops that prefix, and - * a history dialog reopened afterwards has nothing to draw. - * - * […item, "history", { kind: "list" }] every page of the timeline - * […item, "history", { revision: 42 }] one revision's snapshot - * […item, "schedules"] the pending and the done - * […item, "delivery", locale] one language's addresses - * - * ## None of these is warmed by a loader, and that is the point - * - * A content list is 25 rows and each row can open four panels. Preloading any of - * this would be a hundred requests to render a table, for data almost nobody - * opens - so every one of these entries is filled by the panel that reads it, - * when somebody opens it. The list's own query is the only thing a route warms. - */ - /** The one entry the whole revision timeline pages into. */ export const contentHistoryListQueryKey = ( contentTypeId: string, itemId: number, ) => contentHistoryQueryKey(contentTypeId, itemId, { kind: "list" }); -/** - * One revision's snapshot. - * - * Its own entry rather than a field on the list's, because it is read on a - * different event: the timeline arrives when the dialog opens, a snapshot only - * when somebody expands that row. Under the same `history` root, so a restore - * expires the timeline and every snapshot together - a restore rewrites what - * "previous" means for the row above it. - */ export const contentRevisionQueryKey = ( contentTypeId: string, itemId: number, revisionId: number, ) => contentHistoryQueryKey(contentTypeId, itemId, { revision: revisionId }); -/** - * One record's delivery state, in one language. - * - * The locale is on the end because a localized content type has one canonical - * path *per translation*: the same record answers to `/en/news/hello` and - * `/pl/aktualnosci/czesc`, and an entry that did not name which was asked for - * would serve one language's addresses under the other. `""` stands for a - * content type with no translations, which has exactly one address and asks for - * no locale at all. - * - * Still under `contentDeliveryQueryKey`, so `invalidateContentDelivery` expires - * every language of one record with one call and a delete drops them all. - */ export const contentDeliveryLocaleQueryKey = ( contentTypeId: string, itemId: number, @@ -82,32 +39,6 @@ export const contentDeliveryLocaleQueryKey = ( /** One record's schedules - re-exported so a panel has one import to reach for. */ export { contentSchedulesQueryKey }; -/** - * The four panels' query definitions, next to the keys they cache under. - * - * Declared here rather than inline in each panel for one reason that is worth - * more than the tidiness: it makes the retry policy a **property of the family** - * rather than four literals in four components. The Content Engine was the only - * AdminCP screen group that inherited Query's default of three attempts with - * exponential backoff, so a `403` on a history dialog was three requests and a - * `429` was three more - and `editorial-query.test.ts` now asserts across these - * factories, so a fifth panel cannot be added without the rule. - * - * `retry: false` for the same reason it is spelled in `cron-query.ts`, - * `files-query.ts`, `users-query.ts` and the rest: none of the answers these - * reads get back are made better by asking again. A `403` is an authorization - * answer, a `404` is a record somebody deleted, and a `429` is the limiter - * asking for *fewer* requests. - * - * Each takes the transport method it calls rather than the whole transport, so - * the module stays a pure function of a fetcher - no React, no context, nothing - * a test has to render. The panels pass `transport.listSchedules` and friends. - * - * What is deliberately **not** here is `enabled`: whether a revision row has - * been expanded is the row's own state, so it stays at the call site with the - * rest of the UI's business. - */ - /** Every schedule on one record, and whether anything will run them. */ export const contentSchedulesQueryOptions = ({ contentTypeId, @@ -124,12 +55,6 @@ export const contentSchedulesQueryOptions = ({ retry: false, }); -/** - * The revision timeline, newest first, paged by the cursor rule below. - * - * `initialPageParam` is `undefined` - the first page asks for no cursor at all - - * and {@link nextContentRevisionCursor} decides whether there is another. - */ export const contentRevisionHistoryQueryOptions = ({ contentTypeId, itemId, @@ -154,12 +79,6 @@ export const contentRevisionHistoryQueryOptions = ({ retry: false, }); -/** - * One revision's snapshot. - * - * `revisionId` is the row's, and a row with none has nothing to read - the panel - * gates that with `enabled`, which stays at the call site. - */ export const contentRevisionQueryOptions = ({ contentTypeId, getRevision, @@ -195,14 +114,6 @@ export const contentDeliveryQueryOptions = ({ retry: false, }); -/** - * The timeline, flattened out of the pages the cursor walked. - * - * Deduplicated by revision id rather than trusted to be disjoint: the cursor is - * exclusive, so pages *should* never overlap - but a restore landing between two - * page requests shifts every row down by one, and a duplicated key is a React - * warning and a row rendered twice. The first copy wins, which is the newer one. - */ export const flattenContentRevisionPages = ( pages: readonly { edges: ContentRevisionMeta[] }[], ): ContentRevisionMeta[] => { @@ -218,13 +129,6 @@ export const flattenContentRevisionPages = ( ); }; -/** - * The cursor the next page starts after, or `undefined` when there is none. - * - * `undefined` rather than `null` because that is what React Query reads as "no - * more pages" - returning `null` would make it fetch one more time with a null - * cursor, which the route answers by starting from the top again. - */ export const nextContentRevisionCursor = (page: { pageInfo: { endCursor: null | number; hasNextPage: boolean }; }): number | undefined => diff --git a/packages/vitnode/src/views/admin/views/content/actions/editorial-transport.tsx b/packages/vitnode/src/views/admin/views/content/actions/editorial-transport.tsx index cfcc159ed..dde78ebd8 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/editorial-transport.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/editorial-transport.tsx @@ -14,55 +14,6 @@ import type { ContentScheduleListResult, } from "./editorial-api"; -/** - * Every call a Content Engine **editorial panel** makes, and nothing else. - * - * The counterpart of `../form/transport.tsx`, one screen over. The form's seam - * covers creating, editing and publishing a record; this covers the four things - * that hang off a row and have nothing to do with a form: - * - * history list revisions, read one, restore one - * schedule list, book, cancel - * preview mint a signed draft link - * delivery read the canonical path and the URL history - * - * Next.js AdminCP this interface TanStack Start AdminCP - * ----------------------------------------------------------------------- - * "use server" actions → listRevisions/… ← fetch → Hono - * revalidatePath() (the panels) router.invalidate() - * - * ## `settled` is the only member that is not a request - * - * Everything a host has to do *after* an editorial write that committed, named - * once so the panels never have to know which host they are in. The two are not - * the same work and neither is expressible in the other's terms: Next.js has - * already expired the cached route segment inside the Server Action, and what is - * left for the client is the query entries this panel reads; TanStack Start has - * no segment cache and has to expire the query entries *and* re-run the route's - * loader. Both are spelled by their own host, and both are asked for the same - * way. - * - * The **scope** is passed rather than inferred because the right answer differs - * per write and always narrows: a restore changes the record, its history and - * possibly its delivery address; booking a schedule changes only the schedules - * and the pending badge on the row. Invalidating everything under the record for - * both would refetch a revision list nobody asked to reload. - * - * ## Why it is a context and not a module-level registration - * - * Same reason `ContentFormTransport` is: the TanStack implementation closes over - * the request's `QueryClient`, which is per-render and must never be shared - * between two visitors being server-rendered at once. React context is the only - * place a per-request value can live safely. - */ - -/** - * What an editorial write moved, so the host expires exactly that. - * - * `record` is the widest and is still one record: it means the row itself - * changed - its version, its fields, its address - so the list, the record and - * everything under it are stale. The other two are narrower and name themselves. - */ export type ContentEditorialWriteScope = "record" | "schedules"; export interface ContentEditorialSettled { @@ -78,13 +29,7 @@ export interface ContentEditorialTransport { itemId: number, scheduleId: number, ) => Promise<ContentMutationResult>; - /** - * Mints a signed preview link for the current draft. - * - * A `503` means the installation has no preview secret configured, which is - * the one failure the panel words differently - it is a deployment fact rather - * than something the administrator did. - */ + createPreview: ( contentTypeId: string, itemId: number, @@ -95,12 +40,7 @@ export interface ContentEditorialTransport { itemId: number, revisionId: number, ) => Promise<{ error?: string; revision?: ContentRevisionDetail }>; - /** - * One page of revision metadata, newest first. - * - * `cursor` is the last **version** on the previous page and the route is - * exclusive on it, so pages append cleanly and never repeat their boundary row. - */ + listRevisions: ( contentTypeId: string, itemId: number, @@ -111,25 +51,13 @@ export interface ContentEditorialTransport { contentTypeId: string, itemId: number, ) => Promise<ContentScheduleListResult>; - /** - * One record's canonical path and the addresses it used to answer to. - * - * `locale` names the *translation* whose address is wanted, and is absent for - * a content type with no translations. See `contentDeliveryRequestLocale`. - */ + readDelivery: ( contentTypeId: string, itemId: number, locale?: string, ) => Promise<ContentDeliveryPanelResult>; - /** - * Restores one revision onto the record, guarded on the version it is at now. - * - * A mismatch is a `409` carrying `CONTENT_VERSION_CONFLICT` - somebody saved - * between the panel opening and the button being pressed - and the result - * carries the version the record holds afterwards so the still-open dialog can - * guard its next restore on it. - */ + restoreRevision: ( contentTypeId: string, itemId: number, @@ -143,14 +71,7 @@ export interface ContentEditorialTransport { action: ContentScheduleAction, scheduledFor: string, ) => Promise<ContentMutationResult>; - /** - * Makes the AdminCP reflect an editorial write that has already committed. - * - * Called only on success. A refused write left the record exactly where it - * was, and refetching underneath a dialog that is still open - still naming - * the revision somebody is being asked about - would replace what they are - * looking at while they decide. - */ + settled: (args: ContentEditorialSettled) => Promise<void> | void; } @@ -169,12 +90,6 @@ export const ContentEditorialTransportProvider = ({ </ContentEditorialTransportContext.Provider> ); -/** - * The message a caller gets when a host forgot to mount the provider. - * - * A named constant so a test can assert on it without matching English, and so - * the sentence says what to do rather than what went wrong. - */ export const CONTENT_EDITORIAL_TRANSPORT_MISSING = "A Content Engine editorial panel must be rendered inside a ContentEditorialTransportProvider. A TanStack Start route mounts one in ContentEditorialHost."; diff --git a/packages/vitnode/src/views/admin/views/content/actions/form-dialog.tsx b/packages/vitnode/src/views/admin/views/content/actions/form-dialog.tsx index a9b6c1c02..fbcb8795e 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/form-dialog.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/form-dialog.tsx @@ -12,30 +12,6 @@ import { } from "@/components/ui/dialog"; import { Loader } from "@/components/ui/loader"; -/** - * A Content Engine form in a dialog - the chrome, and nothing else. - * - * The one implementation of dialog-mode presentation, shared by three callers: - * the Next.js list's create button and its rows' edit buttons, and the TanStack - * Start list through the form slot it registers. They differ in what opens the - * dialog and in how the form reaches the API; they must not differ in the dialog - * itself, and before this existed the same six elements were written out twice. - * - * ## Why the trigger and the form are both handed in - * - * `children` is the control that opens it - a plain button on the create - * heading, a tooltip-wrapped icon on a row - and it is rendered *through* - * `DialogTrigger` rather than beside it, so the trigger's accessibility wiring - * lands on the caller's own element instead of on a wrapper around it. - * - * `form` is an element rather than a component or children for one reason: - * `DialogContent` does not render until the dialog opens, so the element sits - * unrendered until then. Nothing inside it runs - no hooks, no spec build, no - * lazy chunk fetched - which is what keeps a list of fifty rows from building - * fifty form specs for dialogs nobody opened. It is also why `ContentForm` is - * reached through `React.lazy` on both sides: the AutoForm stack and every field - * component load when somebody actually opens a form. - */ export const ContentFormDialog = ({ children, description, diff --git a/packages/vitnode/src/views/admin/views/content/actions/history-action.tsx b/packages/vitnode/src/views/admin/views/content/actions/history-action.tsx index 20a047469..290487627 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/history-action.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/history-action.tsx @@ -9,15 +9,6 @@ import type { ContentPanelProps } from "./content-panel"; import { ContentPanel } from "./content-panel"; -/** - * A history body carries the diff renderer and every revision it opens, so it is - * loaded when the dialog is - the same treatment the edit form gets, and the - * reason a 25-row table costs one chunk rather than 25 queries. - * - * `React.lazy` rather than `next/dynamic`: the two do the same thing here - the - * panel already renders inside `ContentPanel`'s `Suspense` - and only one of - * them resolves in a bundle that is not Next.js. - */ const RevisionHistory = React.lazy( async () => await import("./history/revision-history").then(mod => ({ diff --git a/packages/vitnode/src/views/admin/views/content/actions/history/revision-actor.tsx b/packages/vitnode/src/views/admin/views/content/actions/history/revision-actor.tsx index 9809fe2a3..fd01d9345 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/history/revision-actor.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/history/revision-actor.tsx @@ -11,33 +11,11 @@ import type { ContentFormLinkComponent } from "../../form/context"; import { useContentFormNavigation } from "../../form/navigation"; -/** - * Who made a revision, as a link to their member page - or the word "system". - * - * A schedule firing, a cron run and a migration all write revisions with no - * actor, and that is a real answer rather than a gap: `actorUserId === null` - * means nobody was signed in, and saying so is the difference between "the - * system unpublished this" and "we lost track of who did". - * - * The link component is the host's, read from the navigation seam rather than - * imported: `@/lib/navigation` is `next-intl`'s router, which a TanStack Start - * bundle cannot load at all. - */ - type RevisionActorMeta = Pick< ContentRevisionMeta, "actorName" | "actorRoleColor" | "actorUserId" >; -/** - * The link itself, taking the host's component as a **prop**. - * - * Module scope with the component passed in, rather than named inside the body - * that reads it out of context - the arrangement `ContentFormCancel` and - * `ContentRowPanelSlot` already use, and the shape `static-components` requires: - * a component named in a render body would remount its subtree whenever the - * value's identity changed. - */ const RevisionActorLink = ({ actorName, actorRoleColor, diff --git a/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx b/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx index 85c6a1f2d..1943610d9 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx @@ -24,13 +24,6 @@ const TOKEN_KINDS = new Set([ "user", ]); -/** - * Whether a value is "nothing". - * - * One predicate, used both by the renderer and by the layout around it: an - * em-dash stands on its own, a real value is set in a box, and the two have to - * agree or a row shows an empty container. - */ const isBlank = (value: ContentSnapshotValue | undefined): boolean => { if (value === null || value === undefined || value === "") return true; if (Array.isArray(value)) return value.length === 0; diff --git a/packages/vitnode/src/views/admin/views/content/actions/history/revision-history.tsx b/packages/vitnode/src/views/admin/views/content/actions/history/revision-history.tsx index fd902726d..949d62f3d 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/history/revision-history.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/history/revision-history.tsx @@ -19,22 +19,6 @@ import { import { useContentEditorialTransport } from "../editorial-transport"; import { RevisionRow } from "./revision-row"; -/** - * One record's revision timeline, paged. - * - * Read through React Query rather than through the effect-and-useState pair this - * used to be, and the reason is not tidiness. The panel is opened, closed and - * reopened from a row in a table that is itself being refetched; the old - * arrangement started a request on every mount and threw away whatever the - * previous one had loaded, including every page the administrator had asked for. - * One entry, keyed under the record, keeps the timeline where they left it. - * - * ## It is not preloaded, by construction - * - * There is no loader that warms this and there must not be: a list is 25 rows, - * each of which can open this dialog. The query runs when this component mounts, - * which is when somebody opened the panel. - */ interface RevisionHistoryProps { contentTypeId: string; currentVersion: number; @@ -89,21 +73,7 @@ export const RevisionHistory = ({ const pages = history.data?.pages ?? []; const edges = flattenContentRevisionPages(pages); - /** - * The precondition the next restore sends. - * - * Derived rather than held, and read from the timeline rather than from the - * row: the dialog stays open after a restore and the row behind it has not - * re-rendered yet, so `currentVersion` is one behind for as long as it takes - * the list to refetch. The timeline is not - `settled` invalidated it, and the - * revision that lands on top is the restore itself, carrying the version the - * record now holds. - * - * `Math.max` rather than the newest revision alone because the two arrive - * independently: whichever of the row and the timeline has caught up is the - * one to guard on, and sending a version behind the record is what produces a - * conflict banner naming an editor who does not exist. - */ + const version = Math.max(currentVersion, edges[0]?.version ?? 0); // A read that failed answers with an empty page and a sentence rather than by // rejecting, so an unreachable API reads as "we could not load this" instead diff --git a/packages/vitnode/src/views/admin/views/content/actions/history/revision-row.tsx b/packages/vitnode/src/views/admin/views/content/actions/history/revision-row.tsx index 3f823b297..f8035d9b8 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/history/revision-row.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/history/revision-row.tsx @@ -71,15 +71,6 @@ const OperationBadge = ({ ); }; -/** - * One snapshot, read when a row is expanded and not before. - * - * `enabled` is the whole of the laziness: a 25-revision timeline is 25 of these - * mounted, and none of them requests anything until somebody opens it. Cached - * under `../editorial-query`'s per-revision key, so collapsing and reopening the - * same row is free - a revision's snapshot is immutable, and the only thing that - * can invalidate it is a restore, which expires the whole `history` root. - */ const useRevisionSnapshot = ({ contentTypeId, enabled, diff --git a/packages/vitnode/src/views/admin/views/content/actions/preview-action.tsx b/packages/vitnode/src/views/admin/views/content/actions/preview-action.tsx index 487a7f3d5..b2fb69a78 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/preview-action.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/preview-action.tsx @@ -15,16 +15,6 @@ import { contentErrorKey } from "../lib/mutation-feedback"; import { ContentPanel } from "./content-panel"; import { useContentEditorialTransport } from "./editorial-transport"; -/** - * A signed link to the current draft, minted on the click and not before. - * - * Deliberately **not** a cached query. A preview URL carries a short-lived - * bearer token for an unpublished record, so it is minted when the dialog opens - * and forgotten when it closes - caching one would leave a live credential in - * the browser for every row somebody glanced at, and serving a stale one back - * would hand out a token that has already expired. - */ - const COPIED_FEEDBACK_MS = 2000; const CopyButton = ({ label, url }: { label: string; url: string }) => { diff --git a/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.test.ts b/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.test.ts index ae9364ff1..852a25eae 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.test.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.test.ts @@ -11,16 +11,6 @@ import { isDestructiveContentRowAction, } from "./row-actions-model"; -/** - * Which actions one content row offers. - * - * Two things go wrong here and they fail in opposite directions, which is why - * this is tested rather than read: offer an action the API refuses and the - * control looks broken; hide one the administrator holds and a feature silently - * does not exist. Both AdminCPs read this function, so a difference between them - * would be a difference nobody could see from either screen. - */ - /** Everything on, everything permitted - the widest row there is. */ const everything: ContentRowActionInput = { canDelete: true, diff --git a/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.ts b/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.ts index 2c21eef39..2f559940e 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.ts @@ -1,33 +1,5 @@ -/** - * Which actions one content row offers, and whether they fit as buttons. - * - * Pure: no React, no permissions provider, no definition - just the four - * features a content type can enable, the three permissions that gate them, and - * the answer. Both AdminCPs read it, so a row in the Next.js list and the same - * row in the TanStack Start list offer the same actions in the same order, and - * neither can grow a fifth by accident. - * - * Worth extracting because every rule here is one somebody would otherwise - * restate. "Preview needs `can_view`, scheduling needs `can_publish`" is not - * guessable from the names, and getting it wrong in one host shows an action - * whose request the API refuses - a control that looks broken rather than absent. - */ - -/** - * Above this many actions the row collapses into a `⋯` menu. - * - * Three fits beside the publish and edit buttons on the narrowest table the - * AdminCP renders; four starts pushing the last column into a scroll. - */ export const CONTENT_ROW_INLINE_ACTION_LIMIT = 3; -/** - * Every row action, in the order they are offered. - * - * The order is part of the contract rather than an implementation detail: the - * destructive one is last so it is never where a mis-click lands, and the four - * before it read from least to most consequential. - */ export const CONTENT_ROW_ACTION_IDS = [ "preview", "schedule", @@ -60,27 +32,12 @@ export interface ContentRowActionInput { editorial: boolean; /** `definition.editorial.preview.enabled` - signed draft links. */ preview: boolean; - /** - * Which of the ids this host can actually put a panel behind. - * - * Defaults to all of them, which is the Next.js AdminCP - it imports every - * panel directly. A TanStack Start host renders the editorial panels through a - * registered slot, so an action whose panel nobody registered must not be - * offered: a menu entry that opens nothing is worse than an absent one. - */ + renderable?: readonly ContentRowActionId[]; /** `definition.editorial.scheduling.enabled` - publish/unpublish later. */ scheduling: boolean; } -/** - * The permission each action is gated on, stated once. - * - * Reading rather than writing is the rule, with one exception: scheduling *is* - * publishing, just later, so it is gated on `can_publish` rather than on - * `can_view`. Every one of these is re-checked by the API on the request itself; - * this decides what to render. - */ const gateOf = ( id: ContentRowActionId, { canDelete, canPublish, canView }: ContentRowActionInput, @@ -124,12 +81,5 @@ export const contentRowActionIds = ( gateOf(id, input), ); -/** - * Whether the actions render as buttons rather than behind a `⋯`. - * - * `false` for none of them too, which is not a rounding error: the caller - * renders nothing at all in that case, and a menu button opening an empty menu - * would be the alternative. - */ export const contentRowActionsAreInline = (count: number): boolean => count > 0 && count <= CONTENT_ROW_INLINE_ACTION_LIMIT; diff --git a/packages/vitnode/src/views/admin/views/content/actions/schedule/schedule-panel.tsx b/packages/vitnode/src/views/admin/views/content/actions/schedule/schedule-panel.tsx index 0899c3d3e..42db6d1c5 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/schedule/schedule-panel.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/schedule/schedule-panel.tsx @@ -28,28 +28,11 @@ import { contentErrorKey } from "../../lib/mutation-feedback"; import { contentSchedulesQueryOptions } from "../editorial-query"; import { useContentEditorialTransport } from "../editorial-transport"; -/** - * One record's booked publications, and the form that books another. - * - * A schedule performs one of the two publication transitions later; it is not a - * third lifecycle state. `contentScheduleTimingError` is the *same* rule the API - * enforces, applied here first so an obviously-wrong time is refused without a - * round trip - and the API still refuses it if this is bypassed. - */ const formSchema = z.object({ action: z.enum(["publish", "unpublish"]), scheduledFor: z.iso.datetime(), }); -/** - * One booked transition. - * - * `now` is the moment the schedule list was last read, not the moment this - * renders: the panel carries a form, and a clock read during render would move - * on every keystroke - a row silently becoming overdue while somebody is typing. - * React Query already holds that timestamp, so it is read from there rather than - * from a second copy this component would have to keep in step. - */ const ScheduleRow = ({ now, onCancel, diff --git a/packages/vitnode/src/views/admin/views/content/content-labels.test.ts b/packages/vitnode/src/views/admin/views/content/content-labels.test.ts index 9ec121eed..23f73b397 100644 --- a/packages/vitnode/src/views/admin/views/content/content-labels.test.ts +++ b/packages/vitnode/src/views/admin/views/content/content-labels.test.ts @@ -8,22 +8,6 @@ import { field } from "@/content/fields"; import { contentLabelsFrom } from "./content-labels"; -/** - * A real translator over a real message catalogue. - * - * Deliberately not a stub returning strings: the whole point of the `label` key - * is that ICU picks the form, and a mock that hands back `"…label"` would prove - * only that a key was read. Polish is here because it is the case a - * `singular`/`plural` pair cannot express - three forms for one noun. - * - * This drives `contentLabelsFrom` directly, which is the one label resolver. - * It used to go through the Next.js screen's `getContentLabels`, which meant - * mocking `next-intl/server`, `next/navigation`, the navigation helpers, the - * session read and the request-scoped fetch to reach four lines of string - * lookup. `createTranslator` from `use-intl` is the same ICU formatter - * `getTranslations` was built on, so the assertions below are unchanged - there - * is simply nothing left to mock. - */ const messages: Record<string, Record<string, unknown>> = { en: { "@vitnode/example": { diff --git a/packages/vitnode/src/views/admin/views/content/content-labels.ts b/packages/vitnode/src/views/admin/views/content/content-labels.ts index 59acc2600..a04343d4f 100644 --- a/packages/vitnode/src/views/admin/views/content/content-labels.ts +++ b/packages/vitnode/src/views/admin/views/content/content-labels.ts @@ -7,22 +7,6 @@ import { humanizeFieldName, } from "@/content/admin/labels"; -/** - * Every display string one content type's screens need, in the reader's - * language. - * - * The **one** label resolver, framework-neutral: it takes a translator and - * returns strings, so `getTranslations()` from `next-intl/server` and - * `createTranslator` from `use-intl` both drive it. The Next.js - * `getContentLabels` awaits its translator and hands it here; the TanStack - * loader builds one from the messages it warmed and hands it here. - * - * Nothing about the rules moved. `contentI18nKeys` says where a string lives, - * `contentNouns` resolves the ICU plural that names the record, and - * `humanizeFieldName` is the fallback for everything a plugin has not - * translated. Every key is optional and nothing is read without `has` first, so - * a plugin that ships no messages at all still gets a readable screen. - */ export interface ContentLabels { desc: string | undefined; labelEnum: (field: string, value: string) => string; @@ -33,23 +17,6 @@ export interface ContentLabels { title: string; } -/** - * The half of {@link ContentLabels} that can cross a serialization boundary. - * - * Four strings and none of the three resolvers, and the distinction is not - * cosmetic: a TanStack Start loader's return value is serialized into the SSR - * payload for the browser to hydrate from, and a function is not serializable - - * so returning the whole object logged a `SerovalUnsupportedTypeError` naming - * `labelEnum` on every single content navigation and left the browser to re-run - * the loader for data it had already been sent. - * - * Nothing loses a label over it. A resolver reads a key assembled at runtime - * from the content type id, and both screens that need one already rebuild the - * whole set from the messages the route warmed - `useContentTypeForm` for a - * form, `ContentListScreen` for a table - because that is the only way to keep - * a spec's identity stable across renders. The nouns are what a *route* knows: - * its title, its description and its crumb. - */ export type ContentRouteLabels = Pick< ContentLabels, "desc" | "plural" | "singular" | "title" @@ -96,31 +63,6 @@ export const contentLabelsFrom = ( }; }; -/** - * The message namespaces one content type's screens render from. - * - * Three, and each is needed for a different half of the screen: - * - * core.global the shared components - pagination, tables, dialogs, dates - * core.content the engine's own copy - headings, empty states, every error - * {pluginId} this content type's nouns, field, enum and section labels - * - * The last is per-plugin because `contentI18nKeys` builds every one of those - * keys as `{pluginId}.content.{entity}.…`, which is why this is a function - * rather than a constant - and why the whole set is warmed by the route's loader - * rather than by the shell, which does not yet know which content type the slug - * resolves to. - * - * The same three the Next.js screen mounts. `<I18nProvider>` names two - - * `namespaces={["core.content"]} runtimeNamespaces={[pluginId]}` - and prepends - * `core.global` itself, for every page in the application. A TanStack Start - * route's `RouteMessages` has no such floor: `use-intl`'s provider *replaces* - * the record rather than merging with the one above it, so a set that left - * `core.global` out rendered a content list whose pagination threw - * `MISSING_MESSAGE: core.global` on the server and fell back to client - * rendering. Every other admin route's namespace set names it for the same - * reason. - */ export const contentRouteNamespaces = (pluginId: string): string[] => [ "core.global", "core.content", diff --git a/packages/vitnode/src/views/admin/views/content/content-mutation.ts b/packages/vitnode/src/views/admin/views/content/content-mutation.ts index 2b0a37b92..f7e9f7056 100644 --- a/packages/vitnode/src/views/admin/views/content/content-mutation.ts +++ b/packages/vitnode/src/views/admin/views/content/content-mutation.ts @@ -6,20 +6,6 @@ import type { ContentUnprocessable, } from "@/content/conflicts"; -/** - * What an editorial write answers with, whichever host asked. - * - * Pure types and nothing else - no `next/cache`, no `createServerFn`, no fetch. - * The shape was declared inside `actions/mutation-api.server.ts` until Stage 13, - * which made it unreachable from anything that is not Next.js: a `"use server"` - * module drags `next/headers` and `server-only` into the graph of everything - * that names it, type-only import or not, once a bundler stops erasing. - * - * Moving it changed none of the members and none of their meanings. Both - * transports return exactly this, which is what lets one form - one submit - * handler, one conflict branch, one toast - serve both AdminCPs. - */ - /** One language's row, as the tab strip, the panel and the form read it. */ export interface TranslationRow { itemId: number; @@ -31,12 +17,6 @@ export interface TranslationRow { version: number; } -/** - * One language's half of a composite save, as the form assembled it. - * - * `expectedVersion` is absent when this language had no translation when the - * form opened - a create, which has no version to be stale against. - */ export interface ContentTranslationInput { expectedVersion?: number; locale: string; @@ -53,71 +33,24 @@ export interface ContentRowResult { } export interface ContentMutationResult { - /** - * The structured reason an editorial write was refused, when the API sent - * one. `CONTENT_VERSION_CONFLICT` is the interesting case: the dialog reloads - * the newer record and offers to overwrite it, which it cannot do from a - * sentence. - */ conflict?: ContentConflict; - /** - * `CONTENT_DELIVERY_SLUG_RESERVED`, naming the address and its locale. - * - * Its own field rather than a third arm of `conflict`, because the two share a - * status and need different words: a unique clash is "another record holds that - * address now", and this is "another record used to hold it and it still - * redirects there". - */ + delivery?: ContentDeliveryConflict; error?: string; - /** - * The identifier of a newly created record. - * - * Only set by a create, and only on success - a page-mode create navigates to - * the record's own edit page, and guessing at the id would open the wrong one. - */ + id?: number; /** Why a schedule was refused, when the API said. */ rejection?: ContentScheduleRejection; /** Lets the UI tell a restricted delete (409) from a generic failure. */ status?: number; - /** - * The same, for the language half of a composite save. - * - * Its own field rather than a second arm of `conflict`, because the two need - * different words and point at different things: one says the record moved, - * the other says one language of it did - and names which. - */ + translationConflict?: ContentTranslationConflict; - /** - * Every translation as it stands **after** a composite save. - * - * The form keeps editing after a page-mode save, and its next save needs each - * language's new version - reusing the ones it opened with would lose to the - * write it just made. - */ + translations?: TranslationRow[]; - /** - * Nothing moved, so nothing was sent. - * - * Its own field rather than silence, because "saved" and "there was nothing - * to save" are different things to the person who pressed the button - and - * reporting the first for the second is how a form that is quietly failing - * looks exactly like one that is working. - */ + unchanged?: boolean; /** `CONTENT_REVISION_NOT_RESTORABLE`, naming the fields that no longer fit. */ unprocessable?: ContentUnprocessable; - /** - * The version the record holds **after** this write. - * - * Read back so a form that stays open - page mode - can guard its next save on - * the version it just created rather than on the one it opened with. Without - * it, the second save of a session sends a version the record left behind and - * gets a conflict banner naming an editor who does not exist. - * - * The translation half of the same problem is handled by `translations`; this - * is the base row's. - */ + version?: number; } diff --git a/packages/vitnode/src/views/admin/views/content/content-query.test.ts b/packages/vitnode/src/views/admin/views/content/content-query.test.ts index 0ee1cb315..f5c269746 100644 --- a/packages/vitnode/src/views/admin/views/content/content-query.test.ts +++ b/packages/vitnode/src/views/admin/views/content/content-query.test.ts @@ -19,15 +19,6 @@ import { contentTypeQueryRoot, } from "./content-query"; -/** - * The Content Engine's cache keys, as a shape rather than as a convention. - * - * Every assertion here is about **prefixes**, because that is the only property - * TanStack Query actually uses: `invalidateQueries({ queryKey })` matches - * element by element from the front, so "invalidate one content type" is correct - * exactly when the type's root is a literal prefix of everything under it. - */ - const isPrefixOf = ( prefix: readonly unknown[], key: readonly unknown[], @@ -43,15 +34,6 @@ describe("the content root", () => { expect(contentQueryRoot()).toEqual([...ADMIN_QUERY_ROOT, "content"]); }); - /** - * The whole of the sign-out story, and the reason the family lives here at - * all. `removeAdminShellQueries` drops `ADMIN_QUERY_ROOT`; if that is a prefix - * of every key below, the Content Engine's caches go with it and there is no - * list for anybody to extend. - * - * The predecessor of this family keyed itself `["content-options", …]`, which - * this assertion would have caught. - */ it("is collected by the AdminCP sign-out cleanup", () => { const everyKey = [ contentQueryRoot(), @@ -73,11 +55,6 @@ describe("the content root", () => { }); }); - /** - * `["vitnode","admin"]` is not a prefix of `["vitnode","admin-session"]` - - * Query matches whole segments - so a content invalidation can never reach the - * permission set the shell renders from. - */ it("cannot collide with the admin session entry", () => { expect(contentQueryRoot()[1]).toBe("admin"); expect(contentQueryRoot()[1]).not.toBe("admin-session"); @@ -122,11 +99,6 @@ describe("one record's root", () => { expect(isPrefixOf(root, contentItemQueryRoot(TYPE, ITEM + 1))).toBe(false); }); - /** - * The list is a sibling of the records, not a parent of them: a mutation that - * removes one record must be able to invalidate the list without also naming - * every other record's subtree, and vice versa. - */ it("is not reached by the list family, nor reaches it", () => { expect(isPrefixOf(contentListQueryRoot(TYPE), root)).toBe(false); expect(isPrefixOf(root, contentListQueryRoot(TYPE))).toBe(false); @@ -134,11 +106,6 @@ describe("one record's root", () => { }); describe("reference picker options", () => { - /** - * The keying decision this family is arranged around: a picker is keyed by - * *what it offers*, so creating a category expires the article form's category - * picker without either screen knowing the other exists. - */ it("is reached by invalidating the content type it offers", () => { const picker = contentOptionsQueryKey("blog.category", "categoryId", "en"); @@ -155,36 +122,18 @@ describe("reference picker options", () => { ).not.toEqual(contentOptionsQueryKey("blog.category", "secondary", "en")); }); - /** - * A picker's labels are resolved in the reader's own language, so the same - * category id reads differently for two administrators. Without the locale in - * the key, switching the AdminCP language serves the previous language's - * labels from cache until something else evicts them. - */ it("keys each AdminCP language separately", () => { expect(contentOptionsQueryKey(TYPE, "f", "en")).not.toEqual( contentOptionsQueryKey(TYPE, "f", "pl"), ); }); - /** - * And the search term is deliberately absent: `AutoFormCombobox` appends - * `{ search }` to whatever key it is handed, so this is the prefix a picker - * caches under rather than a whole entry. Naming it here too would put it in - * the key twice. - */ it("is a prefix of what the combobox actually caches under", () => { const key = contentOptionsQueryKey(TYPE, "f", "en"); expect(isPrefixOf(key, [...key, { search: "ab" }])).toBe(true); }); - /** - * A `user` picker offers people rather than a content type, and its bucket - * must be unreachable by any content mutation. `CONTENT_ID_PATTERN` allows - * only lowercase alphanumerics and dots, so a colon cannot be a content type - * id - which is what makes this token safe. - */ it("puts the user picker somewhere no content type can name", () => { expect(CONTENT_USER_TARGET).toContain(":"); expect(/^[a-z0-9]+(?:\.[a-z0-9-]+)+$/.test(CONTENT_USER_TARGET)).toBe( diff --git a/packages/vitnode/src/views/admin/views/content/content-query.ts b/packages/vitnode/src/views/admin/views/content/content-query.ts index b6e49be79..d1ea004a8 100644 --- a/packages/vitnode/src/views/admin/views/content/content-query.ts +++ b/packages/vitnode/src/views/admin/views/content/content-query.ts @@ -1,113 +1,16 @@ import { adminQueryRoot } from "@/views/admin/table/query"; -/** - * Every cache key the Content Engine's AdminCP screens use, in one family. - * - * Pure, framework-neutral and transport-free: strings and arrays, nothing that - * fetches. Both hosts build keys from here, so a mutation in one and a read in - * the other cannot disagree about what they are naming. - * - * ["vitnode", "admin", "content", "blog.post", "list", { first: "25" }] - * \______________________________/ \_______/ \___________________/ - * the AdminCP root the type what is cached - * - * ## Why it hangs off the AdminCP root - * - * `adminQueryRoot` puts every key below under `["vitnode", "admin"]`, and that is - * the whole of the sign-out story: `removeAdminShellQueries` drops that prefix, - * so the Content Engine's caches go with it automatically. There is no list of - * content roots for anybody to extend, and a content type a plugin adds tomorrow - * is collected without a line of code. - * - * The alternative was live in this codebase until now, and is exactly the - * failure worth naming: the reference-picker cache keyed itself under the bare - * string `"content-options"`, outside that prefix and outside anything that - * drops it. One administrator's picker results survived a sign-out and were - * served to the next person to sign in on that tab. - * - * ## Why there is no administrator id in the key - * - * The deliberate choice, and it follows the rule `admin-scope.ts` already - * states: an identity segment is for screens whose **answer is shaped by the - * reader's own permissions**. Users, roles and staff are - `users:can_edit_admin` - * decides whether a row may be edited, `self` is computed against the caller's - * roles - so two administrators must not share an entry. - * - * A content list is not like that. `can_view` decides whether there is a list at - * all, and the loader refuses before any request when it is absent; past that - * gate every administrator gets the same rows, the same record, the same - * revisions. What differs between two administrators is which *controls* render, - * and that is read from the permission set rather than from any of these - * entries. So this sits with `adminQueryRoot("cron")` and - * `adminQueryRoot("files")`, which are the same shape for the same reason. - * - * Privacy is still covered, by the other mechanism: removal on sign-out drops - * the whole `["vitnode","admin"]` prefix, this family included. If a content read - * ever does become permission-shaped, `adminScopedQueryRoot` is the one-line - * change and this note is the reason it would be needed. - * - * ## Why the content type comes before the kind - * - * [...root, contentTypeId, "list" | "item" | "options", ...] - * - * Because the useful invalidations are almost all *per content type*. Creating a - * category has to expire the category list **and** every article form's category - * picker, and with the type ahead of the kind that is one `invalidateQueries` on - * {@link contentTypeQueryRoot}. Putting the kind first would need two calls and a - * rule about which pickers point where. - * - * That works because a picker is keyed by **what it offers**, not by the form it - * sits in: `contentOptionsQueryRoot("blog.category")` is a prefix of every picker - * offering categories, wherever it is rendered. A `user` picker offers people - * rather than a content type, so it uses {@link CONTENT_USER_TARGET} - a token no - * content type id can collide with. - * - * ## What each level buys - * - * contentQueryRoot() everything, every content type - * contentTypeQueryRoot(type) one content type, all of it - * contentListQueryRoot(type) every page, sort, search of one list - * contentItemQueryRoot(type, item) one record and everything under it - * contentOptionsQueryRoot(target) every picker offering that target - * - * Each is a literal prefix of the ones below it, so nothing here ever needs - * `queryClient.clear()`. - */ - /** The screen name this family lives under - `["vitnode","admin","content"]`. */ export const ADMIN_CONTENT_SCREEN = "content"; -/** - * The bucket a `user` picker's options sit in. - * - * People are not a content type, so a `user` field has no target content type - * id. A token of its own rather than a fallback to the field name is what keeps - * a content mutation from ever matching one: `CONTENT_ID_PATTERN` allows only - * lowercase alphanumerics and dots, so no content type can be spelled with a - * colon. - */ export const CONTENT_USER_TARGET = "core:users"; -/** - * Everything the Content Engine caches. - * - * Rarely the right prefix to invalidate - a mutation knows which content type it - * touched - but it is what a test asserts against to know the whole family - * really does sit under the AdminCP root. - */ export const contentQueryRoot = () => adminQueryRoot(ADMIN_CONTENT_SCREEN); /** One content type's cache: its list, its records, and every picker onto it. */ export const contentTypeQueryRoot = (contentTypeId: string) => [...contentQueryRoot(), contentTypeId] as const; -/** - * Every page, sort, search and filter of one list. - * - * What a create, a delete or a publish invalidates: the rows changed, so every - * other page of the same list is now wrong too, and an administrator reaches - * those by pressing a button that reads from the cache. - */ export const contentListQueryRoot = (contentTypeId: string) => [...contentTypeQueryRoot(contentTypeId), "list"] as const; @@ -115,13 +18,6 @@ export const contentListQueryRoot = (contentTypeId: string) => export const contentListQueryKey = (contentTypeId: string, params: object) => [...contentListQueryRoot(contentTypeId), params] as const; -/** - * One record, and everything hanging off it. - * - * The prefix a delete *removes* and an edit invalidates. Its children - the - * translations, the revisions, the schedules, the delivery panel - are all facts - * about this record, so a write that moves the record moves them too. - */ export const contentItemQueryRoot = (contentTypeId: string, itemId: number) => [...contentTypeQueryRoot(contentTypeId), "item", itemId] as const; @@ -134,12 +30,6 @@ export const contentTranslationsQueryKey = ( itemId: number, ) => [...contentItemQueryRoot(contentTypeId, itemId), "translations"] as const; -/** - * One record's revision history. - * - * A root rather than a key: the history panel pages, so the page request is - * appended by whoever opens it - and a restore invalidates the whole family. - */ export const contentHistoryQueryRoot = ( contentTypeId: string, itemId: number, @@ -164,40 +54,9 @@ export const contentDeliveryQueryKey = ( itemId: number, ) => [...contentItemQueryRoot(contentTypeId, itemId), "delivery"] as const; -/** - * Every reference picker offering rows of one content type. - * - * Keyed by the **target** - what the picker offers - rather than by the form it - * is rendered in, which is what lets a category mutation expire the article - * form's category picker without either screen knowing the other exists. - * - * `target` is a content type id for a `relation` field and - * {@link CONTENT_USER_TARGET} for a `user` one. - */ export const contentOptionsQueryRoot = (target: string) => [...contentTypeQueryRoot(target), "options"] as const; -/** - * One picker's options, in one language. - * - * The field name stays on the end because two fields onto the same target can - * still be searched independently, and the locale after it because a picker's - * labels are read in the administrator's own language: a relation onto a - * localized content type resolves its label through `core_languages_words`, so - * the same category id reads "News" for one editor and "Aktualności" for - * another. - * - * The **search term is not here**, and that is the one thing to know before - * reaching for this key. `AutoFormCombobox` owns the search: it debounces it, - * holds it in state and appends `{ search }` to whatever key it is handed. So - * this is the prefix a picker caches *under*, not a whole entry - which is why - * it is exact enough to invalidate with and not exact enough to read with. - * - * `lib/options-query.ts` is the door a field component uses, because that side - * holds a {@link ContentFormFieldSpec} rather than a target id; it calls this - * rather than spelling the key again. There is one shape and one place it is - * written. - */ export const contentOptionsQueryKey = ( target: string, field: string, diff --git a/packages/vitnode/src/views/admin/views/content/content-request.ts b/packages/vitnode/src/views/admin/views/content/content-request.ts index 2817e5b36..ddf4493c4 100644 --- a/packages/vitnode/src/views/admin/views/content/content-request.ts +++ b/packages/vitnode/src/views/admin/views/content/content-request.ts @@ -5,29 +5,6 @@ import type { RawApiFetchArgs } from "@/lib/fetcher/raw"; import { rawApiFetch } from "@/lib/fetcher/raw"; import { AdminRequestError } from "@/views/admin/admin-request"; -/** - * How the AdminCP addresses a generated Content Engine route, and what it does - * with the answer. - * - * The framework-neutral half of the Content Engine's transport: it builds the - * request and reads the response, and says nothing about *where* the call runs. - * A TanStack host wraps it in `createIsomorphicFn`; the Next.js AdminCP has its - * own `content/admin/fetch.server.ts`, which does the same URL arithmetic - * against `next/headers`. - * - * ## Why the typed fetcher cannot be used here - * - * Every other AdminCP screen calls `fetcherClient(adminModuleRef<typeof - * someModule>(), …)`, and the route literals, methods and response schemas all - * infer from that module's *type*. A content module has no type to name: it is - * generated at runtime from a definition, one per installed content type, so - * there is nothing for `typeof` to point at. - * - * The response is not untyped as a result - it is typed by the content type's - * own Zod schema instead, which is stricter than a route literal and is the same - * arrangement `contentApiFetch` already uses on the Next.js side. - */ - /** Which generated module a request is for. */ export interface ContentApiTarget { /** `definition.permissionModule` - the module name under `content/`. */ @@ -45,18 +22,6 @@ export interface ContentApiRequest { target: ContentApiTarget; } -/** - * The request, as the shared fetcher's arguments. - * - * `/api/{pluginId}/admin/content/{permissionModule}{path}` - exactly what - * `buildContentAdminModule` mounts, spelled in one place so a change to the - * mount point is a change to one function. - * - * `withPagination` is deliberately never set. It writes `first=10` and - * `search=""` *inside* the URL builder, invisibly to anything upstream including - * a cache key - so two requests that differ only in that hidden default would - * share one entry. Every page size these routes send is explicit. - */ export const contentApiFetchArgs = ({ body, method, diff --git a/packages/vitnode/src/views/admin/views/content/content-retry.test.ts b/packages/vitnode/src/views/admin/views/content/content-retry.test.ts index fea426853..7122a4054 100644 --- a/packages/vitnode/src/views/admin/views/content/content-retry.test.ts +++ b/packages/vitnode/src/views/admin/views/content/content-retry.test.ts @@ -15,38 +15,6 @@ import { } from "./form/item-query"; import { contentListQueryOptions } from "./table/list-query"; -/** - * The Content Engine asks once, like every other AdminCP screen. - * - * It was the only family that did not: added a stage later than the rest, its - * seven query definitions declared no `retry` and so inherited TanStack Query's - * default of **three attempts with exponential backoff**. Every other AdminCP - * read - cron, queue, files, users, roles, staff, integrations, debug - spells - * `retry: false` and each one gives the same reason: - * - * - a `403` will not become a `200` because we asked again; it is the - * authorization answer, and the route guard is a navigation rule rather than - * the boundary, so it can arrive on a screen already open; - * - a `404` is a record somebody deleted between the list and the click; - * - a `429` answered by sending the same request twice more is exactly what the - * rate limiter asked this application to stop doing; - * - and a failure the administrator has to act on belongs on screen at once, - * not after three attempts and backoff. - * - * ## Why this asserts across the factories rather than one at a time - * - * A test per definition passes for the seven that exist and says nothing about - * the eighth. Iterating the exported factories is what makes the rule a property - * of the *family*: a panel added tomorrow either goes through one of these and - * inherits the policy, or it is not here and the count below is wrong. - * - * That is also why the four editorial panels' definitions were moved out of - * their components and into `actions/editorial-query.ts` next to the keys they - * cache under. An inline `useQuery({ … })` in a `.tsx` file cannot be asserted - * on without rendering it, and rendering tests are not what guards a cache - * policy. - */ - const TYPE = "blog.post"; const ITEM = 42; @@ -113,38 +81,17 @@ describe("no Content Engine read retries", () => { expect(options.retry).toBe(false); }); - /** - * `retry: false` and `retry: undefined` are different answers and only one of - * them is a decision. `toBe(false)` above already separates them; this states - * that the property is spelled rather than inherited, so a definition that - * loses the line fails here instead of quietly asking three times. - */ it("spells the policy rather than inheriting a default", () => { Object.entries(DEFINITIONS).forEach(([name, options]) => { expect(options.retry, name).not.toBeUndefined(); }); }); - /** - * The guard on the family. If a panel is added without coming through - * `editorial-query.ts` - or a factory is deleted - this number is wrong and - * somebody has to look at why. - */ it("covers every definition the Content Engine exports", () => { expect(Object.keys(DEFINITIONS)).toHaveLength(7); }); }); -/** - * The other half of the same guarantee, and the reason the retry rule is safe to - * apply family-wide: everything these definitions cache sits under the one - * prefix `removeAdminShellQueries` drops. - * - * `content-query.test.ts` proves this for the key factories; this proves it for - * the *query definitions* built from them, which is what a screen actually - * hands to `useQuery`. The two can drift - a factory could be given a key it - * did not get from `content-query.ts` - and this is what would catch it. - */ describe("every Content Engine definition caches under the AdminCP root", () => { it.each(Object.entries(DEFINITIONS))("%s", (_name, options) => { const key = options.queryKey as readonly unknown[]; @@ -155,12 +102,6 @@ describe("every Content Engine definition caches under the AdminCP root", () => ).toBe(true); }); - /** - * And none of them lands on the admin *session*. Query matches whole - * segments, so `["vitnode","admin"]` is not a prefix of - * `["vitnode","admin-session"]` - a content invalidation can never reach the - * permission set the shell renders from. - */ it("never reaches the admin session entry", () => { Object.entries(DEFINITIONS).forEach(([name, options]) => { expect((options.queryKey as readonly unknown[])[1], name).toBe("admin"); diff --git a/packages/vitnode/src/views/admin/views/content/form/context.tsx b/packages/vitnode/src/views/admin/views/content/form/context.tsx index 69537ccd0..b4ab06f4c 100644 --- a/packages/vitnode/src/views/admin/views/content/form/context.tsx +++ b/packages/vitnode/src/views/admin/views/content/form/context.tsx @@ -15,36 +15,13 @@ export interface ContentFormHeaderValue { title: React.ReactNode; } -/** - * How a Content Engine form renders an internal link. - * - * The form's two links - the header's back link and the optional cancel button - * on `ContentFormActions` - are the only place its primitives navigate, and - * navigation is the one thing a framework owns outright. Next.js wants - * `next-intl`'s locale-aware `Link`; a TanStack Start route wants the router's. - * So the component is injected rather than imported, which is what lets a - * plugin's own form layout - `@vitnode/blog`'s article screen is the standing - * example - render in either AdminCP without knowing which one it is in. - * - * `HeaderContentBackLinkComponent` rather than a type of its own: the header's - * back link is one of the two, so a second near-identical signature would be a - * second thing to keep in step for no gain. The same seam the data table draws - * for its navigation and Stage 12's shared screens draw for `LinkComponent`. - */ export type ContentFormLinkComponent = HeaderContentBackLinkComponent; export interface ContentFormContextValue { fieldNames: string[]; fields: Record<string, React.ReactNode>; header?: ContentFormHeaderValue; - /** - * The host's link component. - * - * Required rather than defaulting to `<a>`, for the reason - * `HeaderContentBackLinkComponent` gives: a missing wrapper degrades silently - * into a full document reload, which looks like a slow AdminCP rather than a - * forgotten binding. There is one provider, so there is one place to pass it. - */ + LinkComponent: ContentFormLinkComponent; localizedFieldNames: string[]; markHeaderRendered?: () => void; @@ -65,16 +42,6 @@ const ContentFormContext = React.createContext<ContentFormContextValue | null>( null, ); -/** - * The state a custom layout reads, from inside the one `AutoForm` instance. - * - * Context rather than props, and that is the whole architecture decision: a - * layout is a client component *referenced* from `config.tsx`, which is a server - * module, so anything handed to it as a prop crosses an RSC boundary. Rendered - * field elements and a `renderField(name)` callback cannot cross one - the first - * is not serialisable and the second is a server closure. Both are perfectly - * ordinary values on the client, where the provider and the layout both run. - */ export const useContentForm = (): ContentFormContextValue => { const value = React.use(ContentFormContext); @@ -87,12 +54,6 @@ export const useContentForm = (): ContentFormContextValue => { return value; }; -/** - * Same value, but `null` outside a layout. - * - * For a primitive that is legitimately optional - `ContentFormActions` is used - * by layouts only, but a field component may be reused in a plain dialog. - */ export const useContentFormOptional = (): ContentFormContextValue | null => React.use(ContentFormContext); diff --git a/packages/vitnode/src/views/admin/views/content/form/contract.test-d.ts b/packages/vitnode/src/views/admin/views/content/form/contract.test-d.ts index 07ed26e00..f3b0ca74a 100644 --- a/packages/vitnode/src/views/admin/views/content/form/contract.test-d.ts +++ b/packages/vitnode/src/views/admin/views/content/form/contract.test-d.ts @@ -9,16 +9,6 @@ import type { import type { ContentMutationResult } from "../content-mutation"; import type { ContentFormTransport } from "./transport"; -/** - * The three contracts a Content Engine form is built on, asserted as types. - * - * None of them can be checked at runtime without rendering a form, and all three - * are the kind that fails silently: a field override that returns a promise - * suspends forever, a transport whose member drifts from the Server Action makes - * one AdminCP behave differently from the other, and a layout that could reach - * the mutation would be a plugin overriding security. - */ - describe("a plugin's field override", () => { type Override = NonNullable< ContentTypeFrontendRegistration["fields"] @@ -31,17 +21,6 @@ describe("a plugin's field override", () => { }); it("is synchronous, so React never suspends on it", () => { - /** - * The rule `content-form.tsx` states in a comment and this pins: `AutoForm` - * calls the component function to get an element, on every render. An async - * one would hand it a fresh promise each time, and React 19 suspends on a - * promise child - so the dialog would spin forever with no error anywhere. - * - * `toExtend` rather than an equality assertion because a `ReactNode` is a - * union that already includes `Promise<AwaitedReactNode>` in React 19's own - * types; what is being asserted is that the *return type of the override* is - * a node, not that a caller may await it. - */ expectTypeOf<ReturnType<Override["component"]>>().not.toEqualTypeOf< Promise<unknown> >(); @@ -67,17 +46,6 @@ describe("a plugin's form layout", () => { }); describe("the transport contract", () => { - /** - * There is one implementation now - `tanstack/admin/content/form/transport.ts` - * - and it is annotated `: ContentFormTransport` at its definition, so tsc - * already refuses a signature that drifts. What used to be here was the same - * check for the Next.js AdminCP, whose transport *was* its Server Actions - * handed over as an object; that surface is gone, and asserting conformance - * twice for the one that remains would only restate the annotation. - * - * The assertions below are the ones the interface owes any implementation, so - * they stay: they pin what the *shape* means rather than who satisfies it. - */ it("every write answers with the one result shape", () => { expectTypeOf< Awaited<ReturnType<ContentFormTransport["edit"]>> diff --git a/packages/vitnode/src/views/admin/views/content/form/diff.test.ts b/packages/vitnode/src/views/admin/views/content/form/diff.test.ts index d03c420b8..49b945d3d 100644 --- a/packages/vitnode/src/views/admin/views/content/form/diff.test.ts +++ b/packages/vitnode/src/views/admin/views/content/form/diff.test.ts @@ -18,15 +18,6 @@ import { missingContentCollections, } from "./diff"; -/** - * The three decisions a content form makes about what *not* to send. - * - * Each of them fails silently when it is wrong - a save that looks like it - * worked and quietly emptied a field, bumped a version nobody touched, or wrote - * over a colleague's translation - so each of them is pinned here rather than - * left to a render test that would only notice if the screen crashed. - */ - const labelField = (name: string) => humanizeFieldName(name); const labelEnum = (_field: string, value: string) => value.toUpperCase(); diff --git a/packages/vitnode/src/views/admin/views/content/form/diff.ts b/packages/vitnode/src/views/admin/views/content/form/diff.ts index 6c6840751..c57b0df92 100644 --- a/packages/vitnode/src/views/admin/views/content/form/diff.ts +++ b/packages/vitnode/src/views/admin/views/content/form/diff.ts @@ -10,41 +10,6 @@ import type { TranslationRow, } from "../content-mutation"; -/** - * What a content form decides *not* to send, and how it decides it. - * - * Three rules, all of them pure, all of them lifted out of `content-form.tsx` - * unchanged. They were closures over the component's props until Stage 13, which - * made them untestable - and each one is a rule whose failure mode is silent - * data loss rather than an error anybody would see: - * - * - {@link missingContentCollections} decides whether the row a dialog was - * handed is complete enough to edit. Get it wrong and a form opens on empty - * sets and **saves them that way**. - * - {@link contentTranslationDiff} decides which languages are sent and with - * which version precondition. Get it wrong and a Polish-only edit bumps every - * language's version, or a stale English copy is written over a colleague's. - * - {@link contentSharedChanged} decides whether the base row is sent at all. - * Get it wrong and every save writes a revision that changed nothing. - */ - -/** - * The collection fields of a row that are not on it. - * - * A repeatable, a to-many reference and a gallery are all stored on tables of - * their own, so the admin *list* deliberately leaves them off its rows - - * carrying them would cost queries per page for values no column renders. A - * dialog-mode form is handed one of those rows, and a form that opened on the - * empty set for each would show an article with no categories, no gallery, and - * then **save it that way**. - * - * Empty for a page-mode form, whose loader read the record's detail and already - * has them - so the common case costs no request at all. - * - * The test is `Array.isArray`, not truthiness: a record that genuinely has no - * categories arrives as `[]`, which is an answer, and re-reading the detail for - * it would cost a request per dialog for no change. - */ export const missingContentCollections = ( spec: ContentFormSpec, data: Record<string, unknown>, @@ -54,32 +19,6 @@ export const missingContentCollections = ( .map(field => field.name) .filter(name => !Array.isArray(data[name])); -/** - * Which languages a composite save actually sends, and with which precondition. - * - * Per language, in order: - * - * - **A language the record has no translation in** is sent whole, with no - * `expectedVersion` - there is no version to be stale against, and the API - * creates it. - * - **A language that exists** is diffed field by field against the values the - * form *opened* with, and only the fields that moved are sent, carrying that - * translation's own version. Two translators working in two languages - * therefore never contend, and a stale one is refused for that language alone, - * before anything commits. - * - **A language nothing moved in** is not in the result at all. That is what - * keeps a Polish-only edit from writing an English revision, firing an English - * event and expiring the English public cache. - * - * The comparison is `!==` on the value the form holds, which is a string, a - * number, a boolean or `null` for every localized field - a localized field is a - * scalar by construction, so there is no object identity to be fooled by. - * - * Locales are matched case-insensitively because the form's per-field language - * switcher keys by the language code the definition declares and the API answers - * with the code stored in `core_languages`; `pl` and `PL` are one language, and - * treating them as two would send a create for a translation that exists. - */ export const contentTranslationDiff = ( spec: ContentFormSpec, submitted: Record<string, unknown>, @@ -115,18 +54,6 @@ export const contentTranslationDiff = ( return entries; }; -/** - * Whether any shared field moved since the form opened. - * - * `true` for a create, which has nothing to compare against and everything to - * send. - * - * An array is compared element by element and in order, because that is what a - * to-many field is: `[3, 9]` and `[9, 3]` are different values for an ordered - * relation, and the engine stores the order. Only the *payload's* keys are - * looked at, so a column the form does not edit - `updatedAt`, `version`, - * `labels` - never makes a save look necessary. - */ export const contentSharedChanged = ( data: Record<string, unknown> | undefined, payload: Record<string, unknown>, diff --git a/packages/vitnode/src/views/admin/views/content/form/index.ts b/packages/vitnode/src/views/admin/views/content/form/index.ts index 436f44408..f6270167f 100644 --- a/packages/vitnode/src/views/admin/views/content/form/index.ts +++ b/packages/vitnode/src/views/admin/views/content/form/index.ts @@ -1,24 +1,3 @@ -/** - * The primitives a custom Content Engine form layout is built from. - * - * Published as `@vitnode/core/content/admin-form`. Everything here runs inside - * the one `AutoForm` instance the Content Engine created: one schema, one submit - * path, one set of errors. A layout decides *where* a field appears and nothing - * else - validation, defaults, mutations, version preconditions, structured - * errors, publication state, translations, permissions, toasts, cache - * invalidation, events, search and delivery all stay where they were. - * - * ## What is deliberately not exported here - * - * `./transport`, `./navigation` and `./diff` are the engine's own seams - how a - * mutation reaches the API, what happens to the screen afterwards, and which - * values are sent at all. They are next door and they are not on this list, - * because a layout that could reach one of them would be a plugin overriding - * the version precondition, the translation diff or the cache invalidation from - * a component whose job is deciding which column a field sits in. A layout that - * needs to *read* the form's state has `useContentForm()`, which is exactly the - * presentation half. - */ export { type ContentFormContextValue, type ContentFormHeaderValue, diff --git a/packages/vitnode/src/views/admin/views/content/form/item-query.ts b/packages/vitnode/src/views/admin/views/content/form/item-query.ts index 4bb2bc8f1..d1031571a 100644 --- a/packages/vitnode/src/views/admin/views/content/form/item-query.ts +++ b/packages/vitnode/src/views/admin/views/content/form/item-query.ts @@ -17,43 +17,6 @@ import { readContentApiJson, } from "../content-request"; -/** - * What a **page-mode** content form opens on: one record, and every language of - * it. - * - * The two reads `ContentEditPageView` performs before it renders anything, as - * query definitions rather than as an awaited pair - so a TanStack Start loader - * can warm exactly the entries the screen reads back, and the edit form is - * populated in the first paint rather than after a round trip. - * - * ## Two entries rather than one - * - * They are separate facts about the record and they go stale separately: a save - * that touched only the Polish copy leaves the base row exactly as it was. They - * both hang off `contentItemQueryRoot`, so a write that moves the record - * invalidates both by prefix - which is what `invalidateContentAfterWrite` - * already does, with no list of keys to keep in step. - * - * ## A failed read rejects - * - * The opposite of the writes in `./mutations-api.ts`, and deliberately: an edit - * form that renders empty because the record could not be read looks exactly - * like an edit form for a record with nothing in it - and the first save would - * then write those blanks over the real values. `readContentApiJson` throws, the - * route's error boundary owns the screen, and nothing is editable. - * - * The Next.js AdminCP answers the same condition with `notFound()`, from the - * server component that does the read. Both refuse to render a form; only the - * screen the person lands on differs. - */ - -/** - * The row shape a form opens on: the record, plus its reference labels. - * - * `.loose()` carries the content type's own fields, which no generic schema can - * enumerate - including `files`, which is where every `file` field's descriptor - * lives. Dropping the unknown half would empty every field on the form. - */ export const zodContentItem = z .object({ id: z.number(), @@ -63,13 +26,6 @@ export const zodContentItem = z export type ContentItem = Record<string, unknown> & { id: number }; -/** - * Every language of one record. - * - * `.loose()` on each edge for the same reason the row schema has it: a - * translation's `values` are the content type's own localized fields, which no - * generic schema can enumerate and which the form needs in full. - */ export const zodContentTranslationList = z.object({ edges: z.array(z.object({ locale: z.string() }).loose()), }); diff --git a/packages/vitnode/src/views/admin/views/content/form/mutations-api.test.ts b/packages/vitnode/src/views/admin/views/content/form/mutations-api.test.ts index 2c06255d0..5fa6c0526 100644 --- a/packages/vitnode/src/views/admin/views/content/form/mutations-api.test.ts +++ b/packages/vitnode/src/views/admin/views/content/form/mutations-api.test.ts @@ -6,13 +6,6 @@ import type { ContentApiRequest } from "../content-request"; const fetchMock = vi.fn<(request: ContentApiRequest) => Promise<Response>>(); -/** - * Only the browser fetch is replaced. - * - * `contentApiFetchArgs` stays real, so the URL every assertion below reads is - * the one the AdminCP would actually build - a mock of the whole module would - * let a wrong path pass. - */ vi.mock("../content-request", async () => { const actual = await vi.importActual<typeof contentRequest>("../content-request"); @@ -32,28 +25,8 @@ const { setContentPublishedInBrowser, } = await import("./mutations-api"); -/** - * The browser half of the Content Engine's writes, at the seam every other test - * stubs out. - * - * What matters here is not that a request is made - it is that the *answer* is - * read exactly as `mutation-api.server.ts` reads it. A version conflict and a - * unique clash share a `409` and need different dialogs; an editorial edit sends - * its precondition in a wrapper and a non-editorial one must not; a save with - * nothing in it must not reach the API at all. Each of those is a silent bug in - * the AdminCP if this file disagrees with the Server Action. - */ - const TARGET = { permissionModule: "posts", pluginId: "@vitnode/blog" }; -/** - * A fresh `Response` per call, never one shared instance. - * - * A body can be read once, and a composite save makes two requests - the write, - * then the read-back of every translation. `mockResolvedValue` would hand the - * same object to both and the second would throw "Body has already been read", - * which is a fact about the test rather than about the code. - */ const answers = (status: number, body: unknown) => { fetchMock.mockImplementation( async () => diff --git a/packages/vitnode/src/views/admin/views/content/form/mutations-api.ts b/packages/vitnode/src/views/admin/views/content/form/mutations-api.ts index f36e74d37..7d5a494fd 100644 --- a/packages/vitnode/src/views/admin/views/content/form/mutations-api.ts +++ b/packages/vitnode/src/views/admin/views/content/form/mutations-api.ts @@ -20,34 +20,6 @@ import { contentVersionOf as versionOf, } from "../lib/api-result"; -/** - * Everything a Content Engine **form** writes, from the browser. - * - * The counterpart of `../actions/mutation-api.server.ts`, request for request: - * the same routes, the same bodies, the same statuses, the same structured - * errors read out of the same parsers. What it deliberately does *not* do is the - * part no browser can - `revalidatePath` and `revalidateContent` expire Next's - * data cache, which is what makes a published record appear on a statically - * cached public page. A TanStack Start application renders its public pages per - * request, so the equivalent work here is the API call plus a query - * invalidation, and there is nothing skipped. - * - * ## The transport itself is `../lib/api-result.ts` - * - * The request, the schema check, the "did this write succeed" reading and the - * five-parser conflict mapping all live there, shared with the editorial panels - * and the list row's writes. They were declared here until Stage 13 and moved - * unchanged: a second copy of the conflict mapping is a second chance for one - * screen to lose the version-conflict branch while the others keep it. - * - * ## Nothing here knows which content type it is for - * - * Every function takes a {@link ContentApiTarget}, which is a plugin id and a - * module name. Resolving a content type id to one is the registry's job and - * happens one layer up, in `tanstack/admin/content/form/transport.ts` - the same - * split as the list. - */ - /** Anything the generated routes return: an identifier plus the row's fields. */ const zodRow = z.object({ id: z.number() }).loose(); @@ -69,13 +41,6 @@ const zodOptions = z.object({ ), }); -/** - * Reads every translation back after a composite save. - * - * One request for the whole set, so a form that stays open - page mode - holds - * the versions its next save has to send. Failing quietly is right here: the - * write has committed, and a stale version only costs one conflict banner. - */ const readTranslations = async ( target: ContentApiTarget, id: number, diff --git a/packages/vitnode/src/views/admin/views/content/form/navigation.tsx b/packages/vitnode/src/views/admin/views/content/form/navigation.tsx index 63b36308a..b8daf692e 100644 --- a/packages/vitnode/src/views/admin/views/content/form/navigation.tsx +++ b/packages/vitnode/src/views/admin/views/content/form/navigation.tsx @@ -5,76 +5,17 @@ import React from "react"; import type { ContentFormLinkComponent } from "./context"; -/** - * The three things a Content Engine screen cannot decide for itself. - * - * Everything a form does after a successful write is one of these, and all three - * are the framework's to answer: - * - * LinkComponent how a href becomes a navigation the user can click - * navigate how a href becomes a navigation the form performs - * refresh how the screen is made to reflect a write that committed - * - * Deliberately three members and no more. A content screen never needs a - * pathname, a locale, params, prefetching or history state, so widening this is - * a decision somebody has to make on purpose rather than one that leaks in. It - * is not a router - the same rule `DataTableNavigation` states, for the same - * reason. - * - * ## Why `refresh` rather than `push(pathname)` - * - * The Next.js screens spelled it `push(pathname)`: navigating to where you - * already are is how an App Router page refetches its RSC payload. That is a - * Next.js idiom and not a portable instruction - a TanStack Start route asked to - * navigate to its own href does nothing at all, because the location did not - * change. So the seam names the *intent* and each host spells it: `push` in - * Next.js, `router.invalidate()` under TanStack Start, where the query cache has - * already been expired by the transport. - */ export interface ContentFormNavigation { - /** - * The host's link component. - * - * Required rather than defaulting to `<a>`: a missing wrapper degrades - * silently into a full document reload, which looks like a slow AdminCP rather - * than a forgotten binding. It reaches the form's own primitives through - * `ContentFormContext.LinkComponent`, so a plugin's custom layout renders - * under either router without knowing which one it is in. - */ LinkComponent: ContentFormLinkComponent; - /** - * Goes to a href inside the AdminCP. - * - * One caller: a page-mode create, which lands on the new record's own edit - * page. Implementations must not scroll-restore or replace history - this is - * a forward navigation and the browser's back button should return to the - * blank create form. - */ + navigate: (href: string) => void; - /** - * Makes the current screen reflect a write that has already committed. - * - * Called after every successful mutation, including the ones that leave the - * form open. It must not remount the form: a page-mode edit stays on screen - * with its values, and a conflict banner or an in-flight upload would be lost. - */ + refresh: () => void; } const ContentFormNavigationContext = React.createContext<ContentFormNavigation | null>(null); -/** - * Context rather than props, and not by preference. - * - * In Next.js the content screen is assembled by Server Components: the list - * view, the create page view and the edit page view all render on the server, - * and a `navigate` function cannot cross that boundary as a prop. The - * components that need it - the form, the create button, the row's edit button - - * are client components several levels down, so the value is created on the - * client and read from there. The same shape `DataTableNavigationProvider` - * already uses, for the same reason. - */ export const ContentFormNavigationProvider = ({ children, value, diff --git a/packages/vitnode/src/views/admin/views/content/form/transport.tsx b/packages/vitnode/src/views/admin/views/content/form/transport.tsx index d11d7f279..b1c62f0db 100644 --- a/packages/vitnode/src/views/admin/views/content/form/transport.tsx +++ b/packages/vitnode/src/views/admin/views/content/form/transport.tsx @@ -11,88 +11,26 @@ import type { } from "../content-mutation"; import type { ContentOption } from "../lib/field-component"; -/** - * Every call a Content Engine form makes, and nothing else. - * - * The one seam between the form and the framework it is rendered by. Everything - * else about a content form is already portable - the spec is generated from the - * definition, the schema from the spec, the payload from the values, the toast - * from the result - and it was *these eight calls*, all of them Next.js Server - * Actions, that made the whole stack Next-only. - * - * Next.js AdminCP this interface TanStack Start AdminCP - * ----------------------------------------------------------------------- - * "use server" actions → create/edit/… ← fetch → Hono → invalidate - * revalidatePath() (the form) queryClient.invalidate… - * - * ## Why the cache work lives behind the seam rather than in the form - * - * Both hosts have to make the screen reflect a write, and neither does it the - * way the other does: Next.js expires a cached route segment from the server, - * during the action, before it answers; TanStack Start drops query entries in - * the browser after the response. Neither is expressible in the other's terms, - * and a form that tried to own both would end up holding a `revalidatePath` it - * cannot call and a `QueryClient` the server action does not have. So each - * implementation does its own, and the form's job ends at reading the result. - * - * ## Why it is a context and not a module-level registration - * - * `setAdminTransport` is a module-level slot because the value it holds - one - * server function - is the same for every visitor and every request. This one is - * not: the TanStack implementation closes over the request's `QueryClient`, - * which is per-render and must never be shared between two visitors being - * server-rendered at the same time. React context is the only place a - * per-request value can live safely. - * - * ## What is deliberately *not* here - * - * The file upload. It is `multipart/form-data` straight to the generated route - * and always has been - see `content/admin/upload.ts` - so it is already the - * same code in both hosts and has no seam to cross. Pulling it in here would - * mean a `File` passing through a transport whose other members are JSON, which - * is exactly the mistake that arrangement exists to avoid. - */ export interface ContentFormTransport { /** Creates a record from the shared fields alone. */ create: ( contentTypeId: string, values: Record<string, unknown>, ) => Promise<ContentMutationResult>; - /** - * Creates a record **and** its translations, in one transaction. - * - * One call rather than a create followed by N translation writes, because the - * engine's invariant is that a record exists in at least its default language - * or it does not exist at all - and N writes that can each fail on their own - * cannot hold that. - */ + createLocalized: ( contentTypeId: string, values: Record<string, unknown>, translations: ContentTranslationInput[], ) => Promise<ContentMutationResult>; - /** - * Saves the shared fields of one record. - * - * `expectedVersion` is the version the editor started from. Required by an - * editorial content type and ignored by every other one, so the form passes it - * unconditionally. - */ + edit: ( contentTypeId: string, itemId: number, values: Record<string, unknown>, expectedVersion?: number, ) => Promise<ContentMutationResult>; - /** - * Saves the shared fields and every changed language, in one transaction. - * - * `values` is `undefined` when no shared field moved, and a language appears - * only when something in it moved - so a Polish-only edit bumps the Polish - * version and nothing else. Each entry carries the version it was loaded at, - * so two translators in two languages never contend and a stale one is refused - * for that language *before anything commits*. - */ + editLocalized: ( contentTypeId: string, itemId: number, @@ -100,29 +38,12 @@ export interface ContentFormTransport { translations: ContentTranslationInput[], expectedVersion?: number, ) => Promise<ContentMutationResult>; - /** - * Every language one record exists in, values included, in **one** request. - * - * What a localized form opens on. Its localized inputs each carry their own - * language switcher, so they need the whole set up front - reading it language - * by language would be one round trip per language to open one record. - */ + listTranslations: ( contentTypeId: string, itemId: number, ) => Promise<{ edges: TranslationRow[]; error?: string }>; - /** - * Backs the `relation` and `user` pickers. - * - * Gated by the content type's own `can_view` rather than by a permission on - * the target table, which is the reason a `user` field reads its people from - * here rather than from the members list: an editor who may write articles can - * pick an author without also being trusted to browse the member list. - * - * `ids` labels exactly those identifiers instead of searching - how a to-many - * picker turns the ids a form opened with into names, since there is no label - * on the row for a set. - */ + loadOptions: ( contentTypeId: string, field: string, @@ -134,14 +55,7 @@ export interface ContentFormTransport { contentTypeId: string, itemId: number, ) => Promise<ContentMutationResult>; - /** - * Re-reads one record, for the conflict banner and for the collection fields - * a list row does not carry. - * - * Deliberately not a page refresh: the form is open with the editor's unsaved - * values in it, and remounting it would throw them away - which is the one - * thing the conflict flow must not do. - */ + reloadRow: ( contentTypeId: string, itemId: number, @@ -168,12 +82,6 @@ export const ContentFormTransportProvider = ({ </ContentFormTransportContext.Provider> ); -/** - * The message a caller gets when a host forgot to mount the provider. - * - * A named constant so a test can assert on it without matching English, and so - * the sentence says what to do rather than what went wrong. - */ export const CONTENT_FORM_TRANSPORT_MISSING = "A Content Engine form must be rendered inside a ContentFormTransportProvider. A TanStack Start route mounts one in ContentFormHost."; diff --git a/packages/vitnode/src/views/admin/views/content/lib/api-result.test.ts b/packages/vitnode/src/views/admin/views/content/lib/api-result.test.ts index d8032331a..1b20d0381 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/api-result.test.ts +++ b/packages/vitnode/src/views/admin/views/content/lib/api-result.test.ts @@ -4,22 +4,6 @@ import { describe, expect, it } from "vitest"; import { contentFailureResult, contentVersionOf } from "./api-result"; import { contentErrorKey } from "./mutation-feedback"; -/** - * One refusal, read one way, wherever it was raised. - * - * The mapping this file guards used to exist three times - once in the Next.js - * Server Actions, once in the browser form transport, and it would have been a - * third time in the editorial panels. Every copy is a chance for one screen to - * lose a branch the others kept, and the branch that matters most shares its - * status with another: a `409` is either "somebody saved before you", which - * opens a reload dialog, or "that value is taken", which is a sentence. Only the - * `code` in the body tells them apart. - * - * So the assertions below run the *whole* chain a screen runs - body → structured - * result → message key - because that is the thing that has to be identical - * across a form, a row's delete and a revision restore. - */ - const body = (payload: unknown): string => JSON.stringify(payload); const refusal = (status: number, payload: unknown) => diff --git a/packages/vitnode/src/views/admin/views/content/lib/api-result.ts b/packages/vitnode/src/views/admin/views/content/lib/api-result.ts index 51252f013..d7e24103c 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/api-result.ts +++ b/packages/vitnode/src/views/admin/views/content/lib/api-result.ts @@ -13,36 +13,6 @@ import type { ContentApiRequest } from "../content-request"; import { contentApiFetchInBrowser } from "../content-request"; -/** - * One request to a generated Content Engine route, and one reading of what came - * back - shared by every browser write in the AdminCP. - * - * Three functions, and they exist as three rather than as three copies. The - * form's transport, the list row's writes and the editorial panels all speak to - * the same generated routes and all have to answer the same question about a - * refusal: *which* refusal was it? A `409` is either "somebody saved before you" - * or "that value is taken", the two need different words and different buttons, - * and the only thing that tells them apart is a `code` in the body. - * - * That reading was written out twice before this module - once in - * `../actions/mutation-api.server.ts` for Next.js and once in - * `../form/mutations-api.ts` for the browser - and the editorial panels would - * have made it three. Three copies of a five-parser mapping is three chances for - * one screen to quietly lose the conflict flow, so there is one. - * - * ## A result, not a throw - * - * The rule the whole AdminCP write path follows, stated once here: a failed - * *read* must reject, or a screen renders as though there were nothing to show; - * a failed *write* must resolve, or the dialog that is still open - still holding - * the values somebody is being asked about - is replaced by an error boundary. A - * version conflict in particular is not an error in the UI sense at all. It is a - * question, and it has to be answerable in the form it was asked in. - * - * `rawApiFetch` throws on a `500` rather than answering, so that is caught here - * and turned back into a status. Everything else already arrives as one. - */ - /** The shape `contentApiFetch` answers with on the server, read in a browser. */ export interface ContentFetchResult<TData> { data?: TData; @@ -54,15 +24,6 @@ export interface ContentFetchResult<TData> { export const CONTENT_SCHEMA_MISMATCH = "The API returned a response this content type does not describe."; -/** - * A `500`, or the API being unreachable, as a result rather than a throw. - * - * `rawApiFetch` throws on a `500`, and the thrown message carries the response - * body after a newline. In the Next.js path that rejection travelled out of the - * Server Action and was reported by the framework; here it has to become the - * generic server-error toast every caller already knows how to show, which is - * what `contentErrorKey(500)` resolving to `null` produces. - */ export const unreachableContentResult = ( error: unknown, ): ContentFetchResult<never> => ({ @@ -70,14 +31,6 @@ export const unreachableContentResult = ( status: 500, }); -/** - * One request, with the response read exactly as the Next.js side reads it. - * - * A body that does not match the content type's own schema is an error rather - * than partial data: it means the installed plugin and the running API disagree - * about a shape, and acting on half a record on that basis would be worse than - * refusing. - */ export const sendContentApiRequest = async <TSchema extends z.ZodType>( request: ContentApiRequest, schema?: TSchema, @@ -107,28 +60,11 @@ export const sendContentApiRequest = async <TSchema extends z.ZodType>( return { data: parsed.data, status: response.status }; }; -/** - * Whether a response is one a write may act on. - * - * The status **and** the absence of an error, and the second half is not - * redundant: {@link sendContentApiRequest} reports an undescribable body as an - * error while keeping the status the API sent, so a create whose `201` carried - * an unreadable body would otherwise read as a success with no identifier - and - * a page-mode create would navigate to record `0`. - */ export const contentWriteSucceeded = ( result: { error?: string; status: number }, status: number, ): boolean => result.status === status && result.error === undefined; -/** - * Reads whatever structured error the API sent, if any. - * - * The one conflict mapper. Member for member what `mutation-api.server.ts` does, - * through the same five parsers, so a version conflict raised by a form, by a - * row's delete and by a revision restore all arrive in the same field with the - * same code - and one screen cannot silently lose a branch the others have. - */ export const contentFailureResult = (result: { error?: string; status: number; @@ -143,12 +79,6 @@ export const contentFailureResult = (result: { unprocessable: parseContentUnprocessable(result.error) ?? undefined, }); -/** - * The version off a mutation response, when the row carries one. - * - * `undefined` for a content type with no `editorial`, which has no version to - * send and no conflict to have. - */ export const contentVersionOf = ( row?: Record<string, unknown>, ): number | undefined => diff --git a/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx b/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx index a6a776900..28f3cf65a 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx +++ b/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx @@ -26,13 +26,6 @@ import { ContentRepeatableField } from "./repeatable-field"; import { ContentUserField } from "./user-field"; import { ContentUserSetField } from "./user-set-field"; -/** - * One option a picker can offer. - * - * Declared here rather than beside the server action that produces it: this is - * the *client* contract, and a component reaching into a `"use server"` module - * for a type drags `server-only` into the browser graph. - */ export interface ContentOption { avatarColor?: string; color?: string; diff --git a/packages/vitnode/src/views/admin/views/content/lib/invalidate.test.ts b/packages/vitnode/src/views/admin/views/content/lib/invalidate.test.ts index e44ca0baf..8679139b7 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/invalidate.test.ts +++ b/packages/vitnode/src/views/admin/views/content/lib/invalidate.test.ts @@ -22,21 +22,6 @@ import { removeContentOptions, } from "./invalidate"; -/** - * What each write reaches, and - more importantly - what it does not. - * - * Asserted against a real `QueryClient` rather than a spy, because the property - * that matters is the one React Query itself computes: `invalidateQueries` - * matches by prefix, and "does this call reach that entry" is not something a - * recorded argument list can answer. - * - * The delete case is the one with a user-visible failure behind it. A record - * that is gone must not be renderable from the cache: its detail, its - * translations, its revisions, its schedules and its delivery state are all - * facts about something that no longer exists, and left merely stale they are - * still served - the AdminCP's client is configured `refetchOnMount: false`. - */ - const TYPE = "blog.post"; const OTHER_TYPE = "blog.category"; const ITEM = 42; @@ -53,13 +38,6 @@ const keysFor = (type: string, item: number) => ({ translations: contentTranslationsQueryKey(type, item), }); -/** - * A client with every one of those entries already holding a value. - * - * `staleTime: Infinity` so nothing is stale until something invalidates it - - * which is what makes "is this entry stale now" a statement about the call under - * test rather than about the default. - */ const seed = () => { const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: Number.POSITIVE_INFINITY } }, diff --git a/packages/vitnode/src/views/admin/views/content/lib/invalidate.ts b/packages/vitnode/src/views/admin/views/content/lib/invalidate.ts index 7407cb104..84c91743b 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/invalidate.ts +++ b/packages/vitnode/src/views/admin/views/content/lib/invalidate.ts @@ -10,50 +10,6 @@ import { contentTranslationsQueryKey, } from "../content-query"; -/** - * What each Content Engine write owes the query cache, one prefix at a time. - * - * Narrow on purpose. `contentQueryRoot()` would expire every content type in the - * installation and `["vitnode"]` would expire the sidebar, the session and the - * search index with it - so nothing here reaches above the one content type that - * was written to, and most of it reaches no further than the one record. - * - * invalidateContentList the rows changed - * invalidateContentItem this record changed, and everything under it - * removeContentItem this record is gone - * invalidateContentHistory a revision was added or restored - * invalidateContentTranslations one language of it moved - * invalidateContentSchedules a schedule was booked or cancelled - * invalidateContentDelivery the canonical address moved - * - * Every key comes from `../content-query.ts` and none is spelled here, which is - * the point: a mutation and the read it invalidates cannot disagree about what - * they are naming, in either AdminCP. - * - * ## Invalidate versus remove - * - * Invalidating marks an entry stale and keeps showing it while the fresh answer - * arrives, which is what a table under an open dialog needs. Removing drops it, - * and is right in exactly two cases: - * - * - **A deleted record.** Its detail, its translations, its revisions, its - * schedules and its delivery state are facts about something that no longer - * exists. Left merely stale they stay renderable - a history dialog reopened - * from a stale row would draw a timeline for a deleted record. - * - **A reference picker.** The AdminCP's query client is configured - * `refetchOnMount: false`, so a stale picker is still served from cache the - * next time a form opens - and a deleted category would stay on offer. - * - * ## These are host-neutral - * - * A `QueryClient` is the same object in both AdminCPs - `views/layouts/provider` - * mounts one for Next.js, the router owns one under TanStack Start - so a shared - * panel can invalidate its own reads without knowing which host it is in. What - * the two hosts do *not* share is the work outside React Query: `revalidatePath` - * and the public-cache tag arithmetic on one side, `router.invalidate()` on the - * other. That stays behind each host's transport. - */ - /** Every page, sort, search and filter of one content type's list. */ export const invalidateContentList = async ( queryClient: QueryClient, @@ -64,13 +20,6 @@ export const invalidateContentList = async ( }); }; -/** - * One record and everything hanging off it - detail, translations, revisions, - * schedules, delivery. - * - * By prefix rather than as a list of keys, so a child family added tomorrow is - * collected without anybody remembering to add it here. - */ export const invalidateContentItem = async ( queryClient: QueryClient, contentTypeId: string, @@ -81,13 +30,6 @@ export const invalidateContentItem = async ( }); }; -/** - * Drops one record from the cache entirely, children included. - * - * Synchronous and deliberately not awaited-on-network: there is nothing to - * refetch for a record that is gone. What matters is that nothing can render it - * afterwards. - */ export const removeContentItem = ( queryClient: QueryClient, contentTypeId: string, @@ -98,12 +40,6 @@ export const removeContentItem = ( }); }; -/** - * One record's revision history - every page of it. - * - * A root rather than a key, because the history panel pages: a restore adds a - * revision at the top, which shifts every page below it. - */ export const invalidateContentHistory = async ( queryClient: QueryClient, contentTypeId: string, @@ -147,13 +83,6 @@ export const invalidateContentDelivery = async ( }); }; -/** - * Every reference picker offering rows of one content type. - * - * Removed rather than invalidated, for the `refetchOnMount: false` reason above. - * Keyed by what the picker *offers*, so a category write expires the article - * form's category picker without either screen knowing the other exists. - */ export const removeContentOptions = ( queryClient: QueryClient, target: string, diff --git a/packages/vitnode/src/views/admin/views/content/lib/mutation-feedback.ts b/packages/vitnode/src/views/admin/views/content/lib/mutation-feedback.ts index 647dc2f6f..85d687d22 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/mutation-feedback.ts +++ b/packages/vitnode/src/views/admin/views/content/lib/mutation-feedback.ts @@ -15,20 +15,6 @@ export type ContentErrorKey = | "validation" | "version_conflict"; -/** - * Turns a generated route's status into something a person can act on. - * - * The generated routes answer with a status and a generic sentence - never a - * driver message - so the AdminCP can tell "you typed something invalid" from - * "this row is still referenced" from "the server fell over" without ever - * echoing what Postgres said. Anything unrecognised falls through to `null`, - * which the caller renders as the global server-error message. - * - * An editorial route sends a JSON body with a `code` on the two statuses a - * client has to branch on, and that wins when present: 409 alone cannot - * distinguish "someone saved first" from "that value is taken", and the two - * need different words *and* different buttons. - */ export const contentErrorKey = ( status: number | undefined, structured?: { diff --git a/packages/vitnode/src/views/admin/views/content/lib/options-query.test.ts b/packages/vitnode/src/views/admin/views/content/lib/options-query.test.ts index c6ed6985c..79ee6eec1 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/options-query.test.ts +++ b/packages/vitnode/src/views/admin/views/content/lib/options-query.test.ts @@ -10,20 +10,6 @@ import { contentOptionsTarget, } from "./options-query"; -/** - * Where a `relation`/`user` picker's options are cached, as a key. - * - * Two failures live here and they pull in opposite directions: - * - * - **Too specific** and one mutation cannot expire the pickers it invalidated, - * so a deleted category stays on offer in every article form until a reload. - * - **Too loose** and two different pickers - or the same picker in two - * languages - share an entry, so one shows the other's rows. - * - * The rule that resolves both is that every key here is a *literal prefix - * extension* of `contentOptionsQueryRoot(target)`, and nothing else is. - */ - const field = ( name: string, targetContentTypeId?: string, diff --git a/packages/vitnode/src/views/admin/views/content/lib/options-query.ts b/packages/vitnode/src/views/admin/views/content/lib/options-query.ts index 521b79fff..56faad260 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/options-query.ts +++ b/packages/vitnode/src/views/admin/views/content/lib/options-query.ts @@ -13,73 +13,16 @@ import { export const contentOptionsTarget = (spec: ContentFormFieldSpec): string => spec.targetContentTypeId ?? CONTENT_USER_TARGET; -/** - * Where one `relation`/`user` picker's options are cached. - * - * The key itself lives in `../content-query`, with the rest of the Content - * Engine's family; this is the spec-shaped door onto it, because a field - * component has a {@link ContentFormFieldSpec} in hand rather than a target id - * and a field name. It resolves the target and forwards - it does not spell a - * second key, which is how the family stayed reachable by one prefix. - * - * [...contentOptionsQueryRoot(target), field, locale] ← this - * + {search} ← the combobox - * - * Why the locale is in it and the search is not is the key's own business, and - * `contentOptionsQueryKey` in `../content-query` states both. What this door - * adds is the one decision a spec makes: a `user` field has no target content - * type, so it goes to {@link CONTENT_USER_TARGET} rather than falling back to - * the field name - a token no content type id can collide with, which is what - * stops a content mutation ever matching a people picker. - * - * The locale it costs: a content type whose labels are *not* localized gets one - * cache entry per AdminCP language holding identical options. That is the cheap - * side of the trade - the alternative is a picker showing names in a language - * the rest of the screen is not - and a field spec carries nothing that could - * tell the two cases apart. - * - * ## This family used to sit outside the AdminCP cache root - * - * It was `["content-options", target, field]` - a bare string matching no prefix - * anything drops. `removeAdminShellQueries` clears `["vitnode","admin"]` on - * sign-out and never collected these, so one administrator's picker results - * stayed in memory and were served to the next person to sign in on that tab. - * Moving the family under the admin root fixes that with no list for anybody to - * remember, which is the only kind of fix that stays fixed. - */ export const contentOptionsQueryKey = ( spec: ContentFormFieldSpec, locale: string, ): readonly unknown[] => contentOptionsKeyFor(contentOptionsTarget(spec), spec.name, locale); -/** - * Every picker that offers rows of one content type. - * - * A prefix of {@link contentOptionsQueryKey}, which is the whole point: TanStack - * Query matches keys by prefix, so this reaches the category picker on an - * article without either screen having to know the other exists. - */ export const contentOptionsQueryKeyFor = ( contentTypeId: string, ): readonly unknown[] => contentOptionsQueryRoot(contentTypeId); -/** - * Drops the cached options of every picker that offers rows of one content - * type - what a create, an edit or a delete owes the rest of the AdminCP. - * - * The query client is one client for the whole app and it outlives every - * navigation, so without this a category created on the categories screen is - * simply absent from the article form's picker until the next full page load - - * and a renamed one keeps its old name, and a deleted one stays on offer. - * - * `removeQueries` rather than `invalidateQueries`, and that is not a detail: the - * AdminCP's client is configured `refetchOnMount: false`, so a query that is - * merely *marked* stale is still served from the cache when the form mounts. A - * picker nobody is looking at has to lose its data, not its freshness flag. - * Removing it also means the refetch happens when somebody opens the form rather - * than for every form they have opened today. - */ export const useInvalidateContentOptions = (): (( contentTypeId: string, ) => void) => { diff --git a/packages/vitnode/src/views/admin/views/content/lib/reference-options.ts b/packages/vitnode/src/views/admin/views/content/lib/reference-options.ts index 682a235bf..614dafd34 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/reference-options.ts +++ b/packages/vitnode/src/views/admin/views/content/lib/reference-options.ts @@ -2,18 +2,6 @@ import React from "react"; import type { ContentOption, ContentOptionsLoader } from "./field-component"; -/** - * The options a to-many picker has learned, and the loader that fills the gaps. - * - * A form opens holding identifiers and nothing else - a to-many field has no - * column on the row for a label to have been joined onto - so the options are - * fetched once, for exactly the ids in hand, and then kept as the editor picks - * more. Without this an article's co-authors would open as `7` and `12`. - * - * Whole options rather than labels, because a name is not all a picker draws: a - * person has a face and a handle, a category has a colour, and both arrive on - * the same object the search returned. - */ export const useReferenceOptions = ({ field, ids, @@ -24,14 +12,7 @@ export const useReferenceOptions = ({ load: ContentOptionsLoader; }) => { const [known, setKnown] = React.useState<Record<number, ContentOption>>({}); - /** - * The ids a lookup has already come back for, whatever it came back with. - * - * Separate from `known` because the two answer different questions: `known` - * is "what does this id read as", and this is "has anybody asked yet". An id - * whose target has since been deleted is never in `known` and always in here, - * so it settles on its number instead of shimmering forever. - */ + const [asked, setAsked] = React.useState<ReadonlySet<number>>( () => new Set(), ); diff --git a/packages/vitnode/src/views/admin/views/content/lib/user-option.ts b/packages/vitnode/src/views/admin/views/content/lib/user-option.ts index 7d44784a9..3a264757c 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/user-option.ts +++ b/packages/vitnode/src/views/admin/views/content/lib/user-option.ts @@ -2,15 +2,6 @@ import type { UserOption } from "@/components/form/fields/input-users"; import type { ContentOption } from "./field-component"; -/** - * A picker option, as a person. - * - * The options route sends `avatarColor` and `nameCode` for a `user` field and - * for nothing else, so this is where the generic `{ label, value }` an option - * always is becomes the four things a face needs. Shared by the single people - * picker and the to-many one, because a person who rendered differently in the - * two would be the same person twice. - */ export const contentOptionToUser = (option: ContentOption): UserOption => ({ avatarColor: option.avatarColor ?? "", id: Number(option.value), diff --git a/packages/vitnode/src/views/admin/views/content/page/content-form-page.tsx b/packages/vitnode/src/views/admin/views/content/page/content-form-page.tsx index b0fbd5a8d..1d0d25334 100644 --- a/packages/vitnode/src/views/admin/views/content/page/content-form-page.tsx +++ b/packages/vitnode/src/views/admin/views/content/page/content-form-page.tsx @@ -47,16 +47,6 @@ export const ContentFormPage = ({ return ( <ContentForm - /** - * What the form is editing, as a key. - * - * It was the pathname until Stage 13, which is the same fact spelled in - * one framework's terms: a content form page's URL *is* its content type - * plus its action plus its record. Spelling it from the spec instead - * remounts on exactly the same transitions - a different record, a - * different content type, create becoming edit - under a router with no - * `usePathname`, and without a pathname the form otherwise has no use for. - */ key={`${props.spec.contentTypeId}#${data?.id ?? "new"}`} {...props} data={data} diff --git a/packages/vitnode/src/views/admin/views/content/table/cells.tsx b/packages/vitnode/src/views/admin/views/content/table/cells.tsx index 2f5b7bac5..d8622c8d0 100644 --- a/packages/vitnode/src/views/admin/views/content/table/cells.tsx +++ b/packages/vitnode/src/views/admin/views/content/table/cells.tsx @@ -21,13 +21,6 @@ export interface ContentRowData extends Record<string, unknown> { translation?: null | { values?: Record<string, unknown> }; } -/** - * What a cell reads: the row itself, or its translation for a localized field. - * - * `undefined` when the record has no translation in this language, which the - * cell renders as the missing state rather than as a blank - a record nobody has - * translated yet is exactly the row worth spotting in a list. - */ export const contentCellValue = ( row: ContentRowData, spec: ContentColumnSpec, diff --git a/packages/vitnode/src/views/admin/views/content/table/columns.test.ts b/packages/vitnode/src/views/admin/views/content/table/columns.test.ts index 284e9274e..17586da94 100644 --- a/packages/vitnode/src/views/admin/views/content/table/columns.test.ts +++ b/packages/vitnode/src/views/admin/views/content/table/columns.test.ts @@ -18,16 +18,6 @@ import { contentTableSearchEnabled, } from "./columns"; -/** - * The column model both AdminCPs build their table from. - * - * Everything here is a *transformation*: a definition and a registration go in, - * a description of the table comes out. Nothing renders, which is the point - - * "which columns exist, which of them a plugin took over, what a row is called" - * are the questions that have to answer identically in two frameworks, and none - * of them needs a DOM to ask. - */ - const articles = defineContentType({ id: "blog.post", tableName: "blog_post", diff --git a/packages/vitnode/src/views/admin/views/content/table/columns.tsx b/packages/vitnode/src/views/admin/views/content/table/columns.tsx index bb3dc53b1..b779bab2e 100644 --- a/packages/vitnode/src/views/admin/views/content/table/columns.tsx +++ b/packages/vitnode/src/views/admin/views/content/table/columns.tsx @@ -9,48 +9,12 @@ import type { ContentRowData } from "./cells"; import { ContentCell } from "./cells"; -/** - * A generated content list's columns, built once for both AdminCPs. - * - * A plain function rather than a component, and that is what makes it shareable - * at all. The Next.js list assembles its table in a Server Component, so its - * `cell` closures run on the server; the TanStack Start list assembles the same - * table in a client one. Neither can hand the other a component through props - - * a plugin's column override is a React component, and those do not cross an RSC - * boundary - but both can *call* this, in their own environment, with the - * registration they can already see. - * - * Everything locale-dependent arrives as a resolved string for the same reason: - * one host has `getTranslations()` and the other has `useTranslations()`, and a - * function that took either would only work in one of them. - * - * Next.js content-table-view.tsx getTranslations + server fetch - * TanStack Start tanstack/admin/content useTranslations + useSuspenseQuery - * \ / - * buildContentTableColumns - */ - /** The actions column is as wide as its buttons and no wider. */ const ACTION_WIDTH = "w-0 whitespace-nowrap"; /** A plugin's replacement for one generated cell. */ export type ContentColumnOverride = (props: { row: never }) => React.ReactNode; -/** - * One column, paired with the override that replaces its cell - or nothing. - * - * Pure and separately testable, because "which columns exist, in which order, - * and which of them a plugin took over" is the whole of what a registration can - * change about a table, and it is worth being able to state that without - * rendering anything. - * - * The lookup is by column *name*, which is the field name - so an override - * survives a column being relabelled, reordered, or translated, and an override - * naming a column the content type does not have is simply never reached rather - * than being an error. `contentTypeAdmin()` already type-checks the names - * against the definition, so an unreachable one is a plugin editing its - * definition without editing its overrides. - */ export const contentColumnEntries = ( columnSpecs: readonly ContentColumnSpec[], registration: Pick<ContentTypeFrontendRegistration, "columns">, @@ -61,14 +25,6 @@ export const contentColumnEntries = ( return override ? { cell: override.cell, spec } : { spec }; }); -/** - * How many columns the table has, including the actions one. - * - * The skeleton's only input, and it is here so the shape that is rendered while - * the rows load is the shape they arrive into. The Next.js page has always - * spelled this `columnSpecs.length + 1`; naming it is what stops the two hosts - * disagreeing by one column. - */ export const contentTableColumnCount = ( columnSpecs: readonly ContentColumnSpec[], ): number => columnSpecs.length + 1; @@ -78,14 +34,6 @@ export const contentTableSearchEnabled = ( definition: AnyContentTypeDefinition, ): boolean => definition.admin.list.searchableFields.length > 0; -/** - * Which columns sort, and how the list is sorted before anybody asks. - * - * Column names as plain strings rather than `keyof` anything: a content type's - * fields are its own, and the row type a table infers is whatever its response - * schema produced. `DataTable` widens them per instantiation, which is what lets - * one function serve every content type. - */ export const contentTableOrder = ( definition: AnyContentTypeDefinition, ): { @@ -99,20 +47,6 @@ export const contentTableOrder = ( }, }); -/** - * What a row is called, for the sentences an action has to write. - * - * "Delete <title>?" and "Publish <title>?" both name the record, and a record - * with no title field - or one whose title has not been translated into the - * language being read - still has to be named something. `#42` is that - * something: it is unambiguous, it matches the `id` column already on screen, - * and it is what the Next.js list has always fallen back to. - * - * The localized branch is the one worth stating: a localized title lives on the - * translation rather than on the row, so it is read from `translation.values`. - * A record nobody has translated into this language has no translation at all, - * and falls back like an untitled one. - */ export const contentRowTitle = ( definition: AnyContentTypeDefinition, row: ContentRowData, diff --git a/packages/vitnode/src/views/admin/views/content/table/list-mutations.ts b/packages/vitnode/src/views/admin/views/content/table/list-mutations.ts index 4a09ac348..575200eb4 100644 --- a/packages/vitnode/src/views/admin/views/content/table/list-mutations.ts +++ b/packages/vitnode/src/views/admin/views/content/table/list-mutations.ts @@ -6,54 +6,6 @@ import type { ContentApiTarget } from "../content-request"; import { contentApiFetchInBrowser } from "../content-request"; import { contentFailureResult } from "../lib/api-result"; -/** - * The three writes a content **list row** performs, from the browser. - * - * Publish, unpublish and delete - the ones that live on a row rather than in a - * form, and the only ones this module owns. Creating and editing are the form's - * (`../actions/`), and restoring a revision, scheduling and delivery are the - * editorial panels'. - * - * ## Why these exist beside the Server Actions rather than replacing them - * - * `../actions/mutation-api.server.ts` does the same three calls and then does - * something no browser can: `revalidatePath` and `revalidateContent` expire - * Next's *data cache*, which is what makes a published record appear on the - * public site. A TanStack Start application has no such cache - its public pages - * are rendered per request - so the equivalent work is exactly the API call plus - * a query invalidation, and there is nothing to skip. - * - * ## A result, not a throw - * - * The opposite of `readContentApiJson`, deliberately, and the reason is the - * dialog. A *read* that fails must reject, or the table renders empty and looks - * like an installation with nothing in it. A *write* that fails is answered by - * the confirmation dialog that is still open: it has to tell the difference - * between "someone saved first" (offer to reload), "you may not do that" and - * "the server fell over" - and it has to keep the dialog open for the first, - * which a thrown error inside an error boundary cannot do. - * - * `rawApiFetch` throws on a `500` rather than answering, so that is caught and - * turned back into a status. Everything else already arrives as one. - * - * ## The refusal is read by the shared mapper - * - * `contentFailureResult` in `../lib/api-result.ts`, which is the same five - * parsers the form's transport and the editorial panels read a refusal with. A - * row write is not a lesser kind of write: deleting a record can be refused with - * `CONTENT_VERSION_CONFLICT` exactly as a save can, and a delete that moves a - * slug can be refused with `CONTENT_DELIVERY_SLUG_RESERVED`. Reading only the - * first of those - which this module did until Stage 13 - meant a row's toast - * fell back to "the server fell over" for a condition the API had named. - */ - -/** - * What a row write answered. `error` is absent exactly when it worked. - * - * `status` is required here where {@link ContentMutationResult} leaves it - * optional, because every one of these results comes from a request that was - * actually sent - there is no `unchanged` short-circuit on a row. - */ export type ContentRowMutationResult = ContentMutationResult & { status: number; }; @@ -91,13 +43,6 @@ export interface ContentRowMutationArgs { target: ContentApiTarget; } -/** - * Moves one record into or out of the published state. - * - * Idempotent at the API, which is what makes it safe to fire from a row whose - * status may already have changed under the administrator: publishing a - * published record is a `200` that changed nothing rather than a `409`. - */ export const setContentPublicationInBrowser = async ({ action, id, diff --git a/packages/vitnode/src/views/admin/views/content/table/list-query.test.ts b/packages/vitnode/src/views/admin/views/content/table/list-query.test.ts index b32824650..148e359cb 100644 --- a/packages/vitnode/src/views/admin/views/content/table/list-query.test.ts +++ b/packages/vitnode/src/views/admin/views/content/table/list-query.test.ts @@ -8,18 +8,6 @@ import { zodContentListPage, } from "./list-query"; -/** - * The Content Engine list's query contract. - * - * Two claims, and the whole suite is about them: - * - * - **The key is the request.** Everything that changes the rows is in it, and - * nothing that cannot is. Two spellings of one request are one entry; two - * different requests are never one. - * - **The response is checked.** A body the content type does not describe is a - * rejected read, not a half-rendered row. - */ - const target = { permissionModule: "posts", pluginId: "@vitnode/blog" }; const request = ( diff --git a/packages/vitnode/src/views/admin/views/content/table/list-query.ts b/packages/vitnode/src/views/admin/views/content/table/list-query.ts index 2c22612f3..f6ee8e4a6 100644 --- a/packages/vitnode/src/views/admin/views/content/table/list-query.ts +++ b/packages/vitnode/src/views/admin/views/content/table/list-query.ts @@ -17,58 +17,9 @@ import { readContentApiJson, } from "../content-request"; -/** - * One page of a Content Engine list, as the **one** query definition every - * caller shares. - * - * Framework-neutral and transport-free in the same sense the rest of - * `views/admin/views/content/` is: it says what the request is, what comes back - * and where the answer is cached, and takes *how to fetch it* as an argument. A - * TanStack Start host binds that to an isomorphic function - * (`tanstack/admin/content/query.ts`); the Next.js list stays on its own server - * fetch, because a Server Component has no query cache to read from. - * - * ## The key is the request, and the request is the key - * - * ["vitnode","admin","content","blog.post","list",{first:"25",locale:"pl"}] - * - * {@link contentListWireQuery} builds that object once and it is used for both: - * the query string that goes on the wire and the last segment of the cache key. - * They cannot drift, which is the whole point - a parameter that reaches the API - * without reaching the key makes two different requests share one entry, and the - * table then renders the other one's rows. - * - * Everything in it is a string. There is deliberately no function, no component, - * no query client and no registration object anywhere near a key: TanStack Query - * hashes keys structurally, so a component reference in one would make every - * render a cache miss, and a registration object would put a plugin's whole - * override map in the cache index. - * - * `target` is *not* in the key even though it is part of the request, because it - * is a function of the content type id - one content type is served by exactly - * one generated module - and a key segment that can never vary independently is - * noise the hash pays for on every lookup. - * - * ## A failed read throws - * - * {@link readContentApiJson} rejects on a non-2xx and on a body the content type - * does not describe. That is the difference between a list that says something - * went wrong and a list that renders zero rows - and zero rows is - * indistinguishable from a content type nobody has created a record in yet, - * which is the one thing an operational screen must never look like. - */ - /** One page of an admin content list. */ export type ContentListPage = AdminTablePage<ContentRowData>; -/** - * The pager's own state. - * - * Declared here rather than imported from `api/lib/with-pagination`: that module - * is the API's, and it reaches Drizzle and Hono. A browser bundle has no - * business holding either, and the list screen is in one. The assignment below - * is what keeps this reading of the schema honest. - */ const zodPageInfo = z.object({ count: z.number(), endCursor: z.string().nullable(), @@ -78,19 +29,6 @@ const zodPageInfo = z.object({ totalCount: z.number(), }); -/** - * One row, as loosely as a *generic* list can describe one. - * - * The three keys named are the ones every content type's list response carries - * and every cell may read; the rest of the row is the content type's own fields, - * which this schema cannot enumerate and must not drop - `.loose()` is what - * carries them, `files` included. - * - * `translation` is present only on a localized content type, which is why it is - * optional as well as nullable: absent means "this content type has no - * translations", `null` means "this record has none in the language being read", - * and the cell renderer says different things about the two. - */ const zodListRow = z .object({ id: z.number(), @@ -112,52 +50,17 @@ export const zodContentListPage = z.object({ pageInfo: zodPageInfo, }); -/** - * The schema really does describe the page the rest of the AdminCP renders. - * - * A compile-time assignment rather than a test, because it costs nothing and - * catches the only way these two drift: renaming a field on one side. If the API - * grows a page-info field, this stops compiling here rather than rendering a - * pager with a missing button. - */ const _pageInfoMatches: AdminTablePageInfo = {} as z.infer<typeof zodPageInfo>; void _pageInfoMatches; -/** - * Everything that decides which rows come back. - * - * `query` is the URL contract already flattened - pagination, ordering, search - * and this content type's filters, as `contentListQuery` in - * `tanstack/admin/content/route-search.ts` produces it. It arrives flattened - * rather than as the structured params because *this* module has no business - * knowing which keys a content type accepts as filters; that is a question about - * a definition, and the URL contract already answered it. - */ export interface ContentListRequest { contentTypeId: string; - /** - * The language the list is read in, for a localized content type. - * - * Not a filter and never in the URL: it is the administrator's own AdminCP - * language, and the API is explicit that it does not hide records nobody has - * translated yet - it resolves one translation per row and says so. It is in - * the cache key because it changes every localized cell on the page. - * - * Left out entirely for a content type without localization, so those lists - * do not get one cache entry per language holding identical rows. - */ + locale?: string; query: Record<string, string | undefined>; target: ContentApiTarget; } -/** - * The request as one flat, sorted, string-valued object. - * - * Sorted so two spellings of one request hash to one entry, and `undefined` - * dropped so "no search" is the absence of a key rather than a key whose value - * happens to be missing - the two hash differently. - */ export const contentListWireQuery = ({ locale, query, @@ -186,13 +89,6 @@ export const contentListApiRequest = ( target: request.target, }); -/** - * How a page is actually fetched. - * - * The seam between this module and a host: the browser implementation below is - * the whole of it in a client-rendered navigation, and a TanStack Start server - * render swaps in one that forwards the incoming request's admin cookie. - */ export type ContentListPageFetcher = ( request: ContentListRequest, /** diff --git a/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-query.ts b/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-query.ts index 393dc11f7..ee13c8aaf 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-query.ts +++ b/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-query.ts @@ -16,36 +16,11 @@ import { } from "@/views/admin/admin-request"; import { adminQueryRoot } from "@/views/admin/table/query"; -/** - * The AdminCP cron list, as one query definition. - * - * Everything about *what* the list is - the request, which columns it sorts by, - * the cache entry it lands in, and what counts as a refusal - lives here and - * nowhere else. The Next.js Server Component and the TanStack Start loader both - * read through it, so `?orderBy=lastRun` is one request rather than two that - * happen to look alike. - * - * The transport is deliberately *not* fixed: a loader running on a server and a - * component running in a browser cannot reach the API the same way, so - * {@link cronQueryOptions} takes a `fetchPage` and defaults it to the browser's - - * the only one a shared module can assume. - * - * Hono is still the boundary. `GET /api/@vitnode/core/admin/advanced/cron` - * declares `adminStaffPermission: { module: "cron", permission: "can_view" }`, - * re-checked against the staff tables on every request, so nothing below - * authorizes anything. - */ - export const cronAdminModuleRef = adminModuleRef<typeof cronAdminModule>(); /** The module is mounted under `/admin/advanced`, not at the plugin root. */ export const CRON_PREFIX_PATH = "/admin/advanced"; -/** - * The columns this list sorts by - the `orderBy` enum on `getCronsRoute`, - * restated on the frontend so an unrecognised value is dropped here instead of - * being answered with a `400`. - */ export const CRON_ORDER_BY = ["createdAt", "lastRun", "nextRun"] as const; export type CronOrderBy = (typeof CRON_ORDER_BY)[number]; @@ -56,24 +31,6 @@ export const CRON_TABLE_CONTRACT: AdminTableContract<CronOrderBy> = { export type CronParams = AdminTableParams<CronOrderBy>; -/** - * One page of the list, as arguments to whichever fetcher is carrying it. - * - * `withPagination` is deliberately absent - see `views/admin/table/params.ts` - * for why an invisible default is a cache-key bug rather than a convenience. - */ -/** - * One row of the table, as JSON delivers it. - * - * Declared rather than inferred off the fetcher, because the inferred type - * cannot be named across a declaration-emit boundary. It stays honest anyway: - * {@link fetchCronPageInBrowser} is typed as {@link CronPageFetcher} and returns - * the response's own inferred shape, so a column renamed in `getCronsRoute` - * stops this file compiling rather than rendering `undefined`. - * - * `createdAt`, `lastRun` and `nextRun` are `Date | string` for the same reason - * `MyFile` is: the schema says `z.date()`, and JSON delivers an ISO string. - */ export interface CronJobRow { createdAt: Date | string; description: null | string; @@ -92,13 +49,6 @@ export type CronPage = AdminTablePage<CronJobRow>; /** How a page is actually fetched. See {@link cronQueryOptions}. */ export type CronPageFetcher = (params: CronParams) => Promise<CronPage>; -/** - * One page, fetched from the browser. - * - * `fetcherClient` builds the same same-origin URL every other VitNode client - * call uses, so the browser attaches the admin cookie itself and a `429` is - * routed to the global rate-limit notice on the way through. - */ export const fetchCronPageInBrowser: CronPageFetcher = async params => { const response = await fetcherClient(cronAdminModuleRef, { args: { query: params }, @@ -122,33 +72,9 @@ export const fetchCronPageInBrowser: CronPageFetcher = async params => { /** The root every cached page of the cron list hangs off. */ export const cronQueryRoot = adminQueryRoot("cron"); -/** - * The cache entry one page of the list reads and writes. - * - * The normalised parameters, and nothing else: everything that changes which - * rows come back is in there - page, size, sort - and nothing that does not. The - * locale is absent on purpose, because a cron job's name, schedule and run times - * are identical in every language; only the column headings are translated, and - * the renderer resolves those. - * - * An object in a key is safe - Query hashes keys structurally - which is exactly - * why the object has to be the *normalised* one. - */ export const cronQueryKey = (params: CronParams) => [...cronQueryRoot, params] as const; -/** - * The cron list, as the one query definition every caller shares. - * - * loader: ensureQueryData(cronQueryOptions({ fetchPage, params })) - * component: useSuspenseQuery(cronQueryOptions({ params })) - * after a run: invalidate `cronQueryRoot`, and it refetches - * - * `retry: false`, against Query's default of three attempts: every failure this - * read can produce is made worse by repeating it. A `429` is answered by sending - * the same request twice more, and a `403` is not going to become a `200` - * because we asked again. - */ export const cronQueryOptions = ({ fetchPage = fetchCronPageInBrowser, params, diff --git a/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-table-content.tsx b/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-table-content.tsx index 0eba1a860..ce499940d 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-table-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-table-content.tsx @@ -10,30 +10,6 @@ import type { RunCron } from "./run-action/run-cron"; import { RunActionCronTable } from "./run-action/run-action"; -/** - * The cron list, as a table both frameworks render. - * - * Everything that used to make this a Next.js Server Component has been taken - * out and turned into a parameter: it no longer fetches and no longer calls - * `getTranslations`. What is left is the part that was always worth sharing - - * the seven columns, the relative dates, the "never" states and which columns - * are sortable. - * - * Next.js cron-table-view.tsx fetch + server action - * TanStack Start routes/_admin/…/advanced/cron loader + useSuspenseQuery + browser run - * \ / - * CronTableContent - * - * `ContentDataTable` rather than `DataTable`: `DataTable` *is* the Next.js - * wiring, and the caller mounts the navigation seam it needs - * (`NextDataTableNavigation` there, `DataTableNavigationProvider` here). - * - * The one thing this costs Next.js is `"use client"`, so the `cell` functions - * run in the browser rather than on the server. It is close to free on this - * table: `DateFormat` and the run button were already client components, so the - * only thing that newly reaches the browser is the column list. The rendered - * HTML is unchanged - Next.js server-renders client components too. - */ export const CronTableContent = ({ data, onRun, diff --git a/packages/vitnode/src/views/admin/views/core/advanced/cron/run-action/run-action.tsx b/packages/vitnode/src/views/admin/views/core/advanced/cron/run-action/run-action.tsx index cf14a69b4..a73b2ee1f 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/cron/run-action/run-action.tsx +++ b/packages/vitnode/src/views/admin/views/core/advanced/cron/run-action/run-action.tsx @@ -12,20 +12,6 @@ import { CONFIG_PLUGIN } from "@/config"; import type { RunCron } from "./run-cron"; -/** - * The "run now" button on a cron row. - * - * `onRun` is the one thing it cannot decide for itself: in Next.js the run ends - * in `revalidatePath` and so has to be a server action, and in TanStack Start it - * is a browser call followed by a query invalidation. Both are - * `(id) => Promise<RunCronResult>`, so the button takes one and stops caring - - * see `run-cron.ts`. - * - * The permission check is unchanged and stays here rather than moving to the - * caller: the table renders one of these per row, and hiding the control is the - * AdminCP's established way of saying "not yours". It is a rendering decision - - * the API re-checks `cron.can_run` on the request itself. - */ export const RunActionCronTable = ({ id, onRun, diff --git a/packages/vitnode/src/views/admin/views/core/advanced/queue/queue-query.ts b/packages/vitnode/src/views/admin/views/core/advanced/queue/queue-query.ts index 02a8f0c3f..dc48629c5 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/queue/queue-query.ts +++ b/packages/vitnode/src/views/admin/views/core/advanced/queue/queue-query.ts @@ -87,21 +87,9 @@ export const fetchQueuePageInBrowser: QueuePageFetcher = async params => { /** The root every cached page of the queue list hangs off. */ export const queueQueryRoot = adminQueryRoot("queue"); -/** - * The cache entry one page of the list reads and writes. - * - * The normalised parameters, which for this table includes the status filter - - * two filters are two different sets of rows, so they have to be two entries. - */ export const queueQueryKey = (params: QueueParams) => [...queueQueryRoot, params] as const; -/** - * The queue list, as the one query definition every caller shares. - * - * `retry: false` for the same reason the cron list refuses to retry: every - * failure this read can produce is made worse by repeating it. - */ export const queueQueryOptions = ({ fetchPage = fetchQueuePageInBrowser, params, diff --git a/packages/vitnode/src/views/admin/views/core/advanced/queue/queue-table-content.tsx b/packages/vitnode/src/views/admin/views/core/advanced/queue/queue-table-content.tsx index ad95fcea4..9d1d6f349 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/queue/queue-table-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/advanced/queue/queue-table-content.tsx @@ -10,21 +10,6 @@ import type { QueuePage, QueueTaskRow } from "./queue-query"; import { QueueStatusBadge } from "./badges/status-badge"; import { QUEUE_STATUSES } from "./queue-query"; -/** - * The queue list, as a table both frameworks render. - * - * The seven columns, the status badge, the attempt counter, the truncated last - * error and the status filter - all of it shared, with fetching and translation - * lifted out to whoever is rendering it. - * - * Next.js queue-table-view.tsx fetch - * TanStack Start routes/_admin/…/advanced/queue loader + useSuspenseQuery - * \ / - * QueueTableContent - * - * `ContentDataTable` rather than `DataTable`, because `DataTable` *is* the - * Next.js wiring; the caller mounts the navigation seam it needs. - */ export const QueueTableContent = ({ data }: { data: QueuePage }) => { const t = useTranslations("admin.advanced.queue"); diff --git a/packages/vitnode/src/views/admin/views/core/advanced/search/collection-status.test.ts b/packages/vitnode/src/views/admin/views/core/advanced/search/collection-status.test.ts index 006327bbf..613b24d59 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/search/collection-status.test.ts +++ b/packages/vitnode/src/views/admin/views/core/advanced/search/collection-status.test.ts @@ -13,11 +13,6 @@ const registered = (indexed: number, total: number) => ({ total, }); -/** - * A collection with no rebuild indexer, and so no source count. It may still be - * kept current by live `search.index()` writes - that is why the status only - * claims it is outside the rebuild system. - */ const unmanaged = (indexed: number) => ({ hasIndexer: false, indexed, diff --git a/packages/vitnode/src/views/admin/views/core/advanced/search/collection-status.ts b/packages/vitnode/src/views/admin/views/core/advanced/search/collection-status.ts index 6cb3ca571..0af3d8c8f 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/search/collection-status.ts +++ b/packages/vitnode/src/views/admin/views/core/advanced/search/collection-status.ts @@ -20,16 +20,6 @@ export interface SearchCollection { total: null | number; } -/** - * What `total` is actually counting for this collection. - * - * A multi-language collection is indexed once per translation, and its indexer - * counts published *translations* - so comparing that against distinct items - * would report a fully-indexed collection with three languages as 33% covered. - * One rule, read off the data rather than configured, so a collection that gains - * a second language starts being measured correctly without anything being - * switched on. - */ export const getCollectionIndexedCount = ( collection: Partial<Pick<SearchCollection, "documents" | "languages">> & Pick<SearchCollection, "indexed">, @@ -43,21 +33,6 @@ export const getCollectionIndexedCount = ( export type CollectionStatus = "empty" | "indexed" | "stale" | "unmanaged"; -/** - * Nothing indexed, exactly covered, out of step, or outside the rebuild system. - * - * "Out of step" is any mismatch, in **either** direction. Fewer documents than - * source records means something was missed; more means documents survive for - * records that no longer qualify - and calling that one healthy is how a stale - * index stays invisible. - * - * "Unmanaged" comes first and does not look at the counts at all: without an - * indexer there is no source to compare against, so `indexed` matching `total` - * would only mean the fallback matched itself. It says nothing about the plugin - - * registering an indexer is optional, and a plugin that only ever calls - * `search.index()` keeps its collection perfectly current without one. All that - * is known is that a rebuild cannot reproduce it. - */ export const getCollectionStatus = ( collection: Partial<Pick<SearchCollection, "documents" | "languages">> & Pick<SearchCollection, "hasIndexer" | "indexed" | "total">, @@ -71,13 +46,6 @@ export const getCollectionStatus = ( return "stale"; }; -/** - * Indexed items as a percentage of source items, or `null` when there is no - * source count to divide by. - * - * Can exceed 100 - that is the point, and the number is shown as it is. Use - * {@link getCollectionCoverageBar} for the width of anything drawn. - */ export const getCollectionCoverage = ( collection: Partial<Pick<SearchCollection, "documents" | "languages">> & Pick<SearchCollection, "indexed" | "total">, diff --git a/packages/vitnode/src/views/admin/views/core/advanced/search/collections-table-content.tsx b/packages/vitnode/src/views/admin/views/core/advanced/search/collections-table-content.tsx index 9d6b3b6d4..63370fbe6 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/search/collections-table-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/advanced/search/collections-table-content.tsx @@ -60,21 +60,6 @@ const statusStyles: Record< }, }; -/** - * Every collection in the search index, as a table both frameworks render. - * - * Was a Server Component; the fetch and the translator have been lifted out, and - * the two row actions arrive as `actions` rather than importing server actions - * directly. Nothing else about it changed - the same four columns, the same - * coverage bar, the same client-side filtering. - * - * `search` is the table's own search box, and it filters *in this component* - * rather than in a request: the whole collection list arrives in one status read - * (there are as many rows as there are content types), so paging or querying the - * API for it would be a round trip to filter a list already on screen. That is - * why the `pageInfo` below is synthesised - the table is a renderer here, not a - * pager. - */ export const CollectionsTableContent = ({ actions, collections, diff --git a/packages/vitnode/src/views/admin/views/core/advanced/search/reindex-action.tsx b/packages/vitnode/src/views/admin/views/core/advanced/search/reindex-action.tsx index c7380af98..78283bf8f 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/search/reindex-action.tsx +++ b/packages/vitnode/src/views/admin/views/core/advanced/search/reindex-action.tsx @@ -10,12 +10,6 @@ import { cn } from "@/lib/utils"; import type { RebuildSearchIndex } from "./search-index-mutations"; -/** - * "Reindex", on the row of a collection that has an indexer. - * - * The same mutation the header's button calls, narrowed to one item type - and - * the same seam, carrying its own refresh. - */ export const ReindexCollectionAction = ({ itemType, label, diff --git a/packages/vitnode/src/views/admin/views/core/advanced/search/remove-documents-action.tsx b/packages/vitnode/src/views/admin/views/core/advanced/search/remove-documents-action.tsx index f01470ee3..f7036b383 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/search/remove-documents-action.tsx +++ b/packages/vitnode/src/views/admin/views/core/advanced/search/remove-documents-action.tsx @@ -9,13 +9,6 @@ import { Button } from "@/components/ui/button"; import type { ClearSearchCollection } from "./search-index-mutations"; -/** - * "Remove documents", on the row of a collection with no rebuild indexer. - * - * Destructive and offered only where a reindex is not: without an indexer there - * is nothing to rebuild the documents from, so the confirmation says as much. - * `onClear` carries its own refresh - see `search-index-mutations.ts`. - */ export const RemoveCollectionDocumentsAction = ({ itemType, label, diff --git a/packages/vitnode/src/views/admin/views/core/advanced/search/search-header-actions.tsx b/packages/vitnode/src/views/admin/views/core/advanced/search/search-header-actions.tsx index fbeb40c2b..ba3dd95d3 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/search/search-header-actions.tsx +++ b/packages/vitnode/src/views/admin/views/core/advanced/search/search-header-actions.tsx @@ -10,14 +10,6 @@ import { cn } from "@/lib/utils"; import type { RebuildSearchIndex } from "./search-index-mutations"; -/** - * "Rebuild the whole index", in the screen's header. - * - * `onRebuild` is the only thing it cannot decide for itself, and it carries the - * refresh with it - `router.refresh()` in Next.js, a query invalidation in - * TanStack Start. See `search-index-mutations.ts` for why that is folded into - * the callback rather than passed alongside it. - */ export const SearchHeaderActions = ({ onRebuild, }: { diff --git a/packages/vitnode/src/views/admin/views/core/advanced/search/search-index-content.tsx b/packages/vitnode/src/views/admin/views/core/advanced/search/search-index-content.tsx index 878836e02..3bc0daab5 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/search/search-index-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/advanced/search/search-index-content.tsx @@ -22,27 +22,6 @@ import { CollectionsTableContent } from "./collections-table-content"; import { CronWarning } from "./cron-warning"; import { SyncErrorsCardContent } from "./sync-errors-card-content"; -/** - * The search index screen's body, as both frameworks render it. - * - * Four stat cards, the cron warning, the sync-error list and the collections - * table. Fetching and translation are lifted out; the two mutations arrive as - * `actions`, each carrying its own refresh. - * - * Next.js search-view.tsx fetch + router.refresh() - * TanStack Start routes/_admin/…/advanced/search loader + query invalidation - * \ / - * SearchIndexContent - * - * `labels` names the collections a Content Engine content type contributes. - * Resolved from the *frontend* content-type registry, which is server-side - * config: the Next.js page reads it directly, and a TanStack Start host passes - * it only if it has a browser-side registry - which in Stage 12 it does not, so - * those collections fall back to the search renderer's own label. Optional for - * exactly that reason, and never silently wrong: an unnamed collection still - * shows its `itemType`. - */ - const StatCard = ({ accent, icon, @@ -166,13 +145,6 @@ export const SearchIndexContent = ({ ); }; -/** - * The screen's shape before its data arrives. - * - * Framework-free and beside the screen rather than inside it, because a Suspense - * fallback is rendered *outside* the component it stands in for - the Next.js - * page mounts it above `<SearchAdminView>`. - */ export const SearchAdminViewSkeleton = () => ( <div className="flex flex-col gap-6"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4"> diff --git a/packages/vitnode/src/views/admin/views/core/advanced/search/search-index-mutations.ts b/packages/vitnode/src/views/admin/views/core/advanced/search/search-index-mutations.ts index ea7805e87..63d8669e5 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/search/search-index-mutations.ts +++ b/packages/vitnode/src/views/admin/views/core/advanced/search/search-index-mutations.ts @@ -2,29 +2,6 @@ import { fetcherClient } from "@/lib/fetcher-client"; import { searchDebugAdminModuleRef } from "./search-index-query"; -/** - * The two things this screen can do to the index, as contracts both frameworks - * satisfy. - * - * Both endpoints declare - * `adminStaffPermission: { module: "system", permission: "can_view" }` and - * re-check it on every request, so the browser may call them directly. - * - * ## Each callback refreshes on success, and that is part of the contract - * - * Both mutations change what the status read reports, so the screen has to - * re-read it - and *how* is the one genuinely framework-shaped step: - * `router.refresh()` in Next.js, a query invalidation in TanStack Start. Folding - * it into the callback rather than passing a second `onRefresh` prop is what - * keeps the buttons below identical in both: they await one function and then - * show a toast. - * - * The Next.js side additionally expires the public browse feed's cache tag - * (`updateTag(SEARCH_FEED_TAG)`), because `/search` and `/discover` are cached - * reads of this index there. A TanStack Start host has no such cache, so there - * is nothing to expire - see `tanstack/admin/search-index/query.ts`. - */ - /** What a mutation reports back. `error` is the API's own text. */ export interface SearchIndexMutationResult { data?: unknown; @@ -36,14 +13,6 @@ export type RebuildSearchIndex = ( itemType?: string, ) => Promise<SearchIndexMutationResult>; -/** - * Drop the documents of a collection with no rebuild indexer. - * - * Destructive: nothing rebuilds them afterwards, though the owning plugin may - * write them again live. The API refuses it for any collection that *has* an - * indexer, which is why the table offers this and "reindex" as alternatives - * rather than as a pair. - */ export type ClearSearchCollection = ( itemType: string, ) => Promise<SearchIndexMutationResult>; @@ -54,13 +23,6 @@ export interface SearchIndexActions { rebuild: RebuildSearchIndex; } -/** - * Queues a rebuild from the browser. - * - * Never rejects: a refusal is something the administrator has to be told in a - * toast, and `rawApiFetch` throws on a `500` with the server's own error text, - * which has already been logged where a log belongs. - */ export const rebuildSearchIndexInBrowser: RebuildSearchIndex = async itemType => { try { diff --git a/packages/vitnode/src/views/admin/views/core/advanced/search/search-index-query.ts b/packages/vitnode/src/views/admin/views/core/advanced/search/search-index-query.ts index 02ded0e99..b52b7ffbd 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/search/search-index-query.ts +++ b/packages/vitnode/src/views/admin/views/core/advanced/search/search-index-query.ts @@ -11,29 +11,9 @@ import { ADMIN_DEBUG_PREFIX_PATH } from "@/views/admin/views/core/system/integra import type { SearchCollection } from "./collection-status"; import type { SearchSyncError } from "./sync-errors"; -/** - * The search index's health, as one query definition. - * - * One read of `GET /admin/debug/search/status`: which engine is active, whether - * it is reachable, how much of each collection is indexed, when it last was, and - * the ten most recent sync failures. - * - * The route declares `adminStaffPermission: { module: "system", permission: - * "can_view" }` and re-checks it on every request, so nothing below authorizes - * anything. - */ - export const searchDebugAdminModuleRef = adminModuleRef<typeof debugAdminModule>(); -/** - * The screen's data, exactly as the route's `200` schema declares it. - * - * Declared rather than inferred off the fetcher, because the inferred type - * cannot be named across a declaration-emit boundary. `SearchCollection` and - * `SearchSyncError` already existed for the table and the errors card, and they - * are reused here rather than restated. - */ export interface SearchIndexStatus { collections: SearchCollection[]; engine: string; @@ -69,13 +49,6 @@ export const fetchSearchIndexStatusInBrowser: SearchIndexStatusFetcher = /** The cache entry this screen reads and writes. */ export const searchIndexQueryKey = adminQueryRoot("search-index"); -/** - * The search index status, as the one query definition every caller shares. - * - * `retry: false`. This screen exists to report whether the search engine is - * healthy, and retrying a refused *status* read three times before saying so - * makes the one page that should fail fast the slowest page in the panel. - */ export const searchIndexQueryOptions = ({ fetchStatus = fetchSearchIndexStatusInBrowser, }: { diff --git a/packages/vitnode/src/views/admin/views/core/advanced/search/sync-errors-card-content.tsx b/packages/vitnode/src/views/admin/views/core/advanced/search/sync-errors-card-content.tsx index 324022365..a0b2af689 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/search/sync-errors-card-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/advanced/search/sync-errors-card-content.tsx @@ -16,13 +16,6 @@ import type { SearchSyncError } from "./sync-errors"; import { parseSearchSyncError } from "./sync-errors"; -/** - * The ten most recent search sync failures, or nothing at all. - * - * Was a Server Component; only the translator moved. It renders `null` for an - * empty list rather than an empty card, which is what keeps a healthy screen - * from carrying a heading about problems it does not have. - */ export const SyncErrorsCardContent = ({ errors, labels, diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/grid/board-provider.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/grid/board-provider.tsx index 08d24bd30..f9957ddfa 100644 --- a/packages/vitnode/src/views/admin/views/core/dashboard/grid/board-provider.tsx +++ b/packages/vitnode/src/views/admin/views/core/dashboard/grid/board-provider.tsx @@ -37,13 +37,6 @@ import { panelWidgetId } from "./panel-drag-id"; import { WidgetCardContent } from "./widget-card"; interface DashboardBoardContextProps { - /** - * The four things the board can do, handed down rather than imported. - * - * The settings dialog is three components below this provider and needs two of - * them; reading them from the board's own context is what stops - * `DashboardGrid` and `WidgetCard` having to carry props they do not use. - */ actions: DashboardActions; available: DashboardWidgetOption[]; dispatch: React.Dispatch<DashboardLayoutAction>; @@ -86,17 +79,6 @@ interface DashboardBoardProviderProps { managedIds: string[]; } -/** - * The board: its layout state, its drag-and-drop, and the four actions it can - * perform. - * - * Framework-free since Stage 12. What used to be imported here - two server - * actions and `next-intl`'s router - arrives as {@link DashboardActions}, whose - * two saves are responsible for refreshing on success (`revalidatePath` in - * Next.js, a query invalidation in TanStack Start). Everything else about the - * board is unchanged, including the reducer, the drag sensors and the rule that - * a stored layout resets the working copy when it comes back changed. - */ export const DashboardBoardProvider = ({ actions, catalog, diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-settings-dialog.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-settings-dialog.tsx index e72b6d126..88426cfe2 100644 --- a/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-settings-dialog.tsx +++ b/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-settings-dialog.tsx @@ -51,16 +51,6 @@ const WidgetSettingsForm = ({ form: Promise<React.ReactNode>; }): React.ReactNode => React.use(form); -/** - * One widget's settings, in a dialog. - * - * The form itself is not shipped with the board: it is loaded on first open, so - * an ordinary dashboard load pays for no settings form at all. Both the load and - * the save come from the board's `actions` - in Next.js they are server actions - * that render the widget's `settingsComponent` on the server and end in - * `revalidatePath`; in TanStack Start they are a browser render and a call to - * the same Hono route. See `../widgets/dashboard-actions.ts`. - */ export const WidgetSettingsDialog = ({ onSaved, widget, diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/build-board.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/build-board.tsx index 93cc6d572..ecd4bb225 100644 --- a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/build-board.tsx +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/build-board.tsx @@ -9,20 +9,6 @@ import type { import { widgetIdOf } from "./instance-id"; import { normalizeLayout } from "./normalize-layout"; -/** - * The four things `DashboardBoardProvider` is given, built from the two things - * that were fetched. - * - * Lifted out of the Next.js `DashboardBoard` unchanged, because every line of it - * is arithmetic over a widget list and a stored layout - which is identical in - * both applications. What differs is only where the two inputs came from: a - * Server Component's `fetcher()` in one, a route loader's query in the other. - * - * Rendering a widget is a plain `<Widget settings widgetId />` here as it was - * there. In Next.js that call happens on the server, so a widget may be a Server - * Component and only its output crosses the boundary; in a browser it is an - * ordinary render. The board never learns which, because it is handed nodes. - */ export interface DashboardBoard { catalog: DashboardWidgetCatalogEntry[]; content: Record<string, React.ReactNode>; @@ -42,11 +28,6 @@ export const buildDashboardBoard = ({ const widgetIds = new Set(widgets.map(({ id }) => id)); - /** - * Every stored id this board is speaking for - what it placed, plus what it - * found stored and recognised. The API needs it to tell a widget the admin - * removed from one this board never knew about; see `zodDashboardLayout`. - */ const managedIds = [ ...new Set([ ...saved diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/dashboard-actions.ts b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/dashboard-actions.ts index 8c255b474..25c683a14 100644 --- a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/dashboard-actions.ts +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/dashboard-actions.ts @@ -1,43 +1,11 @@ import type { DashboardLayoutItem } from "./types"; -/** - * Everything the dashboard board can *do*, as one object both frameworks - * satisfy. - * - * Four callbacks, and they are grouped rather than passed as four props because - * every one of them has to reach the same place - the settings dialog, three - * components below the provider - and threading four values through - * `DashboardGrid` and `WidgetCard` would be four chances to forget one. - * - * Next.js server actions; the two saves end in `revalidatePath`, - * and the two loads return a *rendered* widget from the - * server, because a widget's `component` may be a Server - * Component - * TanStack Start browser calls to the same Hono routes; the two saves end - * in a query invalidation, and the two loads render the - * widget from a browser registry - * - * ## Why the loads return a node rather than settings - * - * In Next.js a widget's `component` runs on the server and only its *output* can - * cross the boundary - a component function is not serialisable. So "refresh - * this widget" is necessarily "ask the server to render it again", and the board - * stores the promise of a node. A browser can do better, but it cannot do - * *differently* without the board growing two code paths, so it satisfies the - * same shape and resolves immediately. - */ export interface DashboardActions { /** One widget, re-rendered against whatever settings are stored now. */ loadWidgetContent: (widgetId: string) => Promise<React.ReactNode>; /** One widget's settings form, or `null` when it has none. */ loadWidgetSettings: (widgetId: string) => Promise<React.ReactNode>; - /** - * Persist the board, and refresh it on success. - * - * `managed` is every stored id this board spoke for, so the API can tell a - * widget the admin removed from a widget this board never knew about - see - * `zodDashboardLayout`. - */ + saveLayout: (args: { managed: string[]; widgets: DashboardLayoutItem[]; @@ -49,10 +17,4 @@ export interface DashboardActions { }) => Promise<DashboardMutationResult>; } -/** - * What a dashboard mutation reports back. - * - * `undefined` for success, because that is what the Next.js server actions - * already return and every caller already writes `if (res?.error)`. - */ export type DashboardMutationResult = undefined | { error?: string }; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/layout-query.ts b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/layout-query.ts index f597fbe1c..9a7a01dfa 100644 --- a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/layout-query.ts +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/layout-query.ts @@ -15,26 +15,6 @@ import { import type { DashboardMutationResult } from "./dashboard-actions"; import type { DashboardLayoutItem } from "./types"; -/** - * The signed-in administrator's own dashboard layout, as one query definition. - * - * `GET /admin/admin/dashboard` returns the widgets *this* administrator has - * arranged - `dashboard.can_view`, re-checked on every request, and scoped to - * the session's own user id by the handler. - * - * ## A failed read is an empty layout, and only here - * - * Every other AdminCP read in this migration throws on a refusal, because an - * empty table is indistinguishable from an empty installation. This one is the - * exception, and it is the exception the Next.js board already makes: - * `res.ok ? (await res.json()).widgets : []`. An administrator without - * `dashboard.can_view` is not being shown "no widgets" - they are being shown - * the *default* board, which is what `normalizeLayout` produces from an empty - * stored layout, and which is the correct dashboard for somebody who has never - * arranged one. Failing the page instead would take the panel's landing screen - * away from them entirely. - */ - const adminModuleClientRef = adminModuleRef<typeof adminModule>(); /** The read, as arguments to whichever fetcher is carrying it. */ @@ -57,38 +37,9 @@ export const fetchDashboardLayoutInBrowser: DashboardLayoutFetcher = return (await response.json()).widgets; }; -/** - * The cache entry the board reads and writes, for one administrator. - * - * Scoped by identity, and it is the one AdminCP key where that is about the data - * being *owned* rather than being *shaped* - see `admin-scope.ts`. There is one - * row per administrator in `core_admin_dashboard`, so there is one entry per - * administrator here, and a second one signing in on the same tab addresses a - * different key rather than inheriting the first one's board. - * - * A root and a key at once: a layout takes no parameters, so this screen has - * exactly one entry per identity and nothing hangs below it. - * - * Removal still applies on top of this - `removeAdminShellQueries` drops the - * whole `["vitnode","admin"]` prefix - because partitioning stops a second - * identity *reading* the first one's entry and removal stops it being in memory - * at all. Both, for the reason `admin-scope.ts` gives. - */ export const dashboardLayoutQueryKey = (adminUserId: AdminIdentity) => adminScopedQueryRoot(ADMIN_DASHBOARD_SCREEN, adminUserId); -/** - * The stored layout, as the one query definition every caller shares. - * - * No `retry`: the fetcher already answers a refusal with an empty layout, so - * there is nothing for a retry to turn into a success. A transport failure still - * rejects, and the route's error boundary owns that. - * - * `adminUserId` is required rather than defaulted, and deliberately so: a - * default would let a caller that had not resolved the session yet write into - * some other identity's entry - which is the failure this partition exists to - * make impossible. `null` is the real key for a read with no granted session. - */ export const dashboardLayoutQueryOptions = ({ adminUserId, fetchLayout = fetchDashboardLayoutInBrowser, @@ -103,18 +54,6 @@ export const dashboardLayoutQueryOptions = ({ staleTime: RECORD_STALE_TIME, }); -/** - * The board, saved from the browser. - * - * `PUT /admin/admin/dashboard/layout` declares - * `adminStaffPermission: { module: "dashboard", permission: "can_edit" }`. - * `managed` is every stored id this board spoke for, so the API can tell a - * widget the admin removed from one this board never knew about. - * - * Only the three fields the API stores are sent: `settings` belong to the widget - * and are written by its own settings dialog, so a layout save must not carry - - * and therefore cannot overwrite - them. That is the Next.js action's rule too. - */ export const saveDashboardLayoutInBrowser = async ({ managed, widgets, diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/normalize-layout.ts b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/normalize-layout.ts index bf27bf583..5e7604003 100644 --- a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/normalize-layout.ts +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/normalize-layout.ts @@ -14,17 +14,6 @@ const clamp = (value: unknown, min: number, fallback: number): number => { return Math.min(3, Math.max(min, Math.round(value))); }; -/** - * Reconciles what an admin saved with what is actually installed right now. - * - * Widgets disappear when a plugin is uninstalled or a permission is revoked, - * and new ones show up when a plugin is added - so the stored layout is treated - * as a preference, never as the source of truth. - * - * An entry may also arrive unsized, when a widget's own settings write had to - * create it before the admin ever arranged the board. Sizing falls back to the - * widget's defaults, the same way an out-of-range value does. - */ export const normalizeLayout = ({ saved, widgets, diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/resolve-widgets.ts b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/resolve-widgets.ts index e1a567e11..7b3db2e10 100644 --- a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/resolve-widgets.ts +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/resolve-widgets.ts @@ -7,21 +7,6 @@ import { CONFIG_PLUGIN } from "@/config"; import type { ResolvedDashboardWidget } from "./types"; -/** - * Every dashboard widget this administrator may see, translated and filtered. - * - * Pure: it takes the sources, the permission set and a translator, and returns - * the list. There is no config read, no session read and no framework in it, - * which is what lets the same rules run in a Next.js Server Component - * (`get-dashboard-widgets.tsx`, which reads both from the request scope) and in - * a browser, where the admin session is already in a query and the widget list - * comes from a host-supplied registry. - * - * Splitting it out changed no rule. What was a single async function that fetched - * two things and then applied five rules is now the five rules, with the two - * fetches at the call site. - */ - /** One plugin's contribution, already keyed to its own message namespace. */ export interface DashboardWidgetSource { /** Where this plugin's widget strings live, e.g. `blog.admin.dashboard.widgets`. */ @@ -32,14 +17,6 @@ export interface DashboardWidgetSource { widgets: AdminDashboardWidget[]; } -/** - * The narrowest slice of a translator these rules need. - * - * `has` as well as the call, because every label below is optional: a widget - * that has not been translated falls back rather than rendering a raw key, and - * `t.has` is the only way to ask without throwing. Both `next-intl`'s - * `getTranslations()` and `use-intl`'s `createTranslator()` satisfy it. - */ export interface DashboardWidgetTranslator { (key: string): string; has: (key: string) => boolean; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/types.ts b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/types.ts index 5ee0af2de..81468d5c4 100644 --- a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/types.ts +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/types.ts @@ -20,12 +20,6 @@ export interface DashboardLayoutItem extends AdminDashboardWidgetLayoutItem { span: AdminDashboardWidgetSpan; } -/** - * The dashboard's own heading, resolved above the board's `<Suspense>` - * boundary so the fallback and the board render the same one. The board cannot - * own it: `DashboardEditActions` sits in its action slot and reads the board's - * context, so a fallback rendering the header alone would throw. - */ export interface DashboardHeaderContent { desc: React.ReactNode; h1: React.ReactNode; @@ -70,37 +64,16 @@ export interface DashboardWidgetOption { title: string; } -/** - * What the client grid receives. The widget's `component` runs on the server - * and only its output (`content`) crosses the boundary - a component function - * could never be serialized. - */ export interface DashboardWidgetCatalogEntry extends DashboardWidgetOption { content: React.ReactNode; - /** - * Whether the widget registered a `settingsComponent` - what the card keys - * "does this widget have a gear?" on. The form itself does not ride along: - * it is rendered on the server only once a dialog asks for it, so an ordinary - * dashboard load pays for no settings form at all. - */ + hasSettings?: boolean; } /** One card on the board: a catalog entry sized by the admin's layout. */ export interface DashboardWidgetView extends DashboardWidgetCatalogEntry { - /** - * Changes whenever this copy is rendered against different settings - by the - * server on load, or by the board after its settings dialog saved. The card's - * body is keyed on it, so the widget's own client state starts again from - * what was actually saved instead of holding on to what it first rendered - * with. - */ contentKey: string; - /** - * Identifies this *copy*. Equal to `id` for the first copy of a widget, and - * what the board keys drag, resize and remove on - `id` alone would collide - * once a widget is placed twice. - */ + instanceId: string; rows: AdminDashboardWidgetRows; span: AdminDashboardWidgetSpan; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/widget-mutations.ts b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/widget-mutations.ts index 51596e9f0..16195aa65 100644 --- a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/widget-mutations.ts +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/widget-mutations.ts @@ -8,26 +8,6 @@ import { adminModuleRef } from "@/views/admin/admin-request"; import type { DashboardMutationResult } from "./dashboard-actions"; -/** - * The two mutations a *widget* performs from inside itself, as browser calls. - * - * Distinct from `DashboardActions`, which is what the board does: these are what - * the notes widget's autosave and the send-notification widget's button do, and - * they need no seam at all. - * - * Both were `"use server"` modules whose entire body was one `fetcher()` call - - * no `revalidatePath`, no cookie to set, nothing a server was needed for. As - * server actions they were a `POST` back to the application which then called - * Hono; as browser calls they are the same authenticated request with one hop - * instead of two. The admin cookie travels either way, because the call is - * same-origin and the browser attaches it. - * - * `PUT /admin/admin/dashboard/widget-settings` declares - * `adminStaffPermission: { module: "dashboard", permission: "can_edit" }`, and - * `POST /admin/admin/notifications/send` declares its own; both are re-checked - * against the staff tables on every request. - */ - const adminModuleClientRef = adminModuleRef<typeof adminModule>(); /** One widget's settings, saved. */ diff --git a/packages/vitnode/src/views/admin/views/core/debug/actions/clear-cache/clear-cache.tsx b/packages/vitnode/src/views/admin/views/core/debug/actions/clear-cache/clear-cache.tsx index 5ad7423d3..82b8c8114 100644 --- a/packages/vitnode/src/views/admin/views/core/debug/actions/clear-cache/clear-cache.tsx +++ b/packages/vitnode/src/views/admin/views/core/debug/actions/clear-cache/clear-cache.tsx @@ -7,25 +7,6 @@ import { useTranslations } from "use-intl"; import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog"; import { Button } from "@/components/ui/button"; -/** - * "Clear the cache", in the debug panel's header. - * - * `onClearCache` is the whole of the framework difference, and this is the one - * action on Wave 1 that does not end at an API call - there is no clear-cache - * endpoint, because the thing being cleared is the *frontend's* cache: - * - * Next.js revalidatePath("/", "layout") in a server action - * TanStack Start invalidate every query, then invalidate the router - * - * Both mean "everything you are holding is stale, read it again". Neither is a - * server-side purge, and the button has never claimed to be one. - * - * It rejects rather than returning an error, which is why the caller is wrapped - * in a `try`: the Next.js server action throws `Forbidden` for an administrator - * without `debug.can_clear_cache`, and that shape is kept so the two behave - * identically. The gate around this button hides it from them in the first - * place - the throw is the boundary behind the gate. - */ export const ClearCacheAction = ({ onClearCache, }: { diff --git a/packages/vitnode/src/views/admin/views/core/debug/debug-query.ts b/packages/vitnode/src/views/admin/views/core/debug/debug-query.ts index 1be1aa0a0..a25e3809a 100644 --- a/packages/vitnode/src/views/admin/views/core/debug/debug-query.ts +++ b/packages/vitnode/src/views/admin/views/core/debug/debug-query.ts @@ -17,22 +17,6 @@ import { } from "@/views/admin/admin-request"; import { adminQueryRoot } from "@/views/admin/table/query"; -/** - * The two reads behind `/admin/core/debug`: the system log, and a snapshot of - * the queue. - * - * They live in one module because they are one screen and share one permission - - * both routes declare - * `adminStaffPermission: { module: "debug", permission: "can_view" }` - but they - * are two cache entries, because the log pages and the snapshot does not. - * - * The queue snapshot here is *not* the queue list at - * `/admin/core/advanced/queue`: that one is paginated, filterable and gated on - * `queue.can_view`; this one is four counters and whatever is currently pending - * or processing, gated on `debug.can_view`. Two endpoints, two permissions, two - * cache entries. - */ - export const debugAdminModuleRef = adminModuleRef<typeof debugAdminModule>(); /** The debug module is mounted under `/admin`, not at the plugin root. */ @@ -100,11 +84,6 @@ export const debugLogsQueryRoot = adminQueryRoot("debug-logs"); export const debugLogsQueryKey = (params: DebugLogsParams) => [...debugLogsQueryRoot, params] as const; -/** - * The system log, as the one query definition every caller shares. - * - * `retry: false`, for the reason every AdminCP read refuses to retry. - */ export const debugLogsQueryOptions = ({ fetchPage = fetchDebugLogsPageInBrowser, params, diff --git a/packages/vitnode/src/views/admin/views/core/debug/queue/queue-view-content.tsx b/packages/vitnode/src/views/admin/views/core/debug/queue/queue-view-content.tsx index 0e9e0d0b9..625743bc9 100644 --- a/packages/vitnode/src/views/admin/views/core/debug/queue/queue-view-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/debug/queue/queue-view-content.tsx @@ -23,14 +23,6 @@ const COUNT_STATUSES = [ "failed", ] as const; -/** - * A snapshot of the queue - four counters and whatever is currently in flight - - * as both frameworks render it. - * - * Not a `DataTable`: there is no paging, no sorting and no URL state, because - * the list is "what is happening right now" rather than a browsable history. - * `/admin/core/advanced/queue` is the browsable one. - */ export const QueueViewContent = ({ data }: { data: DebugQueueSnapshot }) => { const t = useTranslations("admin.debug.queue"); diff --git a/packages/vitnode/src/views/admin/views/core/debug/system-logs/actions/more/content.tsx b/packages/vitnode/src/views/admin/views/core/debug/system-logs/actions/more/content.tsx index d441ef3bd..04ae57c7e 100644 --- a/packages/vitnode/src/views/admin/views/core/debug/system-logs/actions/more/content.tsx +++ b/packages/vitnode/src/views/admin/views/core/debug/system-logs/actions/more/content.tsx @@ -22,17 +22,6 @@ import type { DebugLogRow } from "../../../debug-query"; import { BadgeStatus } from "../../badges/badge-status"; import { BadgeTypeLog } from "../../badges/badge-type-log"; -/** - * One log line, in full. - * - * `LinkComponent` is the one thing it cannot decide for itself: the only link on - * the dialog points at `/admin/core/users/{id}`, and turning that into a - * navigation is the question whose answer differs between the two frameworks - - * `next-intl`'s locale-aware `Link` in one, and in the other a link that asks - * the route tree whether this application can render the destination at all. - * During Stage 12 it cannot: the users screen is still the Next.js AdminCP's, so - * the host's migration link renders a document navigation to it. - */ export const ContentMoreActionSystemLogs = ({ content, ipAddress, diff --git a/packages/vitnode/src/views/admin/views/core/debug/system-logs/actions/more/more.tsx b/packages/vitnode/src/views/admin/views/core/debug/system-logs/actions/more/more.tsx index eefcb1f82..f17c3e803 100644 --- a/packages/vitnode/src/views/admin/views/core/debug/system-logs/actions/more/more.tsx +++ b/packages/vitnode/src/views/admin/views/core/debug/system-logs/actions/more/more.tsx @@ -16,12 +16,6 @@ import { import { Loader } from "@/components/ui/loader"; import { TooltipWithContent } from "@/components/ui/tooltip"; -/** - * `React.lazy` rather than `next/dynamic`, so this dialog renders in both - * applications. The two do the same thing here - split the chunk and load it on - * first render - and the `<React.Suspense>` below was always the boundary either - * of them suspends against. - */ const ContentMoreActionSystemLogs = React.lazy(async () => import("./content").then(module => ({ default: module.ContentMoreActionSystemLogs, diff --git a/packages/vitnode/src/views/admin/views/core/debug/system-logs/system-logs-content.tsx b/packages/vitnode/src/views/admin/views/core/debug/system-logs/system-logs-content.tsx index 0f3ac7482..07f37ec8a 100644 --- a/packages/vitnode/src/views/admin/views/core/debug/system-logs/system-logs-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/debug/system-logs/system-logs-content.tsx @@ -16,21 +16,6 @@ import { BadgeTypeLog } from "./badges/badge-type-log"; /** How much of a log line is shown before it is cut. */ const CONTENT_CHARACTERS = 50; -/** - * The system log, as a table both frameworks render. - * - * The six columns, the two badges, the truncated message and the detail dialog - - * shared. Fetching and translation are lifted out to whoever is rendering it. - * - * Next.js system-logs-view.tsx fetch - * TanStack Start routes/_admin/…/debug loader + useSuspenseQuery - * \ / - * SystemLogsContent - * - * `LinkComponent` reaches the detail dialog, which is the only thing on this - * table that navigates: it links a log line to the user who caused it, at - * `/admin/core/users/{id}`. See `actions/more/content.tsx`. - */ export const SystemLogsContent = ({ data, LinkComponent, diff --git a/packages/vitnode/src/views/admin/views/core/shared/admin-mutation.ts b/packages/vitnode/src/views/admin/views/core/shared/admin-mutation.ts index bc9306d77..01e5147c0 100644 --- a/packages/vitnode/src/views/admin/views/core/shared/admin-mutation.ts +++ b/packages/vitnode/src/views/admin/views/core/shared/admin-mutation.ts @@ -1,40 +1,3 @@ -/** - * One AdminCP write, and every way it can end. - * - * The Next.js AdminCP performs its writes through `"use server"` actions that - * finish with `revalidatePath`. A TanStack Start route has neither, and needs - * neither: the admin session is a `path=/` cookie, so a click handler can call - * Hono directly, and "the list on screen is now wrong" is a React Query - * invalidation - which the *caller* performs, because only the caller knows - * which administrator's cache partition to invalidate. - * - * So a mutation here does exactly one thing: send the request, and describe the - * answer. No cache, no navigation, no toast, no redirect. - * - * ## Why they return instead of throwing - * - * Every AdminCP write has a specific failure the screen has to show in a - * specific place: `409` on a name code is a field error beside that field, `403` - * on a staff edit is "you cannot edit your own permissions", `404` is a stale - * link. A thrown error has lost which one it was by the time it reaches a - * `catch`, so the status comes back as data and the screen decides. - * - * ## Why every call is wrapped - * - * `rawApiFetch` *throws* on a `500` rather than returning it, and a fetch to an - * API that is not listening rejects. Both have to become a toast rather than an - * unhandled rejection that takes the AdminCP down with them, so both arrive as - * `{ error: { status: 500 } }`. - */ - -/** - * What a mutation answers with: the data, or the refusal that stopped it. - * - * `message` is the API's own body, present when it sent one. Several AdminCP - * routes answer `409` with a sentence rather than a code - "Email already - * exists" - and which field that belongs beside is a decision the screen makes, - * so the sentence has to survive as far as the screen. - */ export interface AdminMutationError { message?: string; status: number; @@ -50,13 +13,6 @@ export const isAdminMutationError = <TData>( result: AdminMutationResult<TData>, ): result is { error: AdminMutationError } => "error" in result; -/** - * The API's body, if it can be read. - * - * Never throws and never rejects: this runs while something has *already* gone - * wrong, and a body that cannot be read must not replace the status - which is - * the part a caller can always act on - with a second failure. - */ const readErrorMessage = async ( response: Response, ): Promise<string | undefined> => { @@ -69,24 +25,13 @@ const readErrorMessage = async ( } }; -/** - * Runs one admin write and turns every outcome into a result. - * - * `expected` is named rather than inferred from "not an error status" because - * the AdminCP's routes are not uniform - a create answers `201` and everything - * else `200` - and treating an unexpected `202` as success would be a guess. - */ export const runAdminApiMutation = async <TData>({ expected, parse, request, }: { expected: number; - /** - * Awaitable, but not required to be. Several of these routes answer with no - * body at all - a `200` and nothing else - and forcing those to be `async` - * only to satisfy a signature is noise the linter is right about. - */ + parse: (response: Response) => Promise<TData> | TData; request: () => Promise<Response>; }): Promise<AdminMutationResult<TData>> => { diff --git a/packages/vitnode/src/views/admin/views/core/shared/admin-permission-parity.test.ts b/packages/vitnode/src/views/admin/views/core/shared/admin-permission-parity.test.ts index 8c0b2d85f..51013bce6 100644 --- a/packages/vitnode/src/views/admin/views/core/shared/admin-permission-parity.test.ts +++ b/packages/vitnode/src/views/admin/views/core/shared/admin-permission-parity.test.ts @@ -12,34 +12,6 @@ import { staffPermissionModuleFor, } from "./admin-permissions"; -/** - * The frontend's permission tuples, held to the API routes that enforce them. - * - * `admin-permissions.ts` exists because these tuples used to be written out - * inline everywhere one is checked - a Server Component's gate, a client - * component's hook, a route loader - and a permission spelled `can_edit_admin` - * in one place and `can_edit_admins` in another is a button shown to somebody - * the API refuses, or hidden from somebody it would allow. Neither failure is - * visible from the code that causes it, and neither is a type error. - * - * ## Why this reads source rather than importing the routes - * - * `buildRoute` does not keep `adminStaffPermission` on the object it returns - - * it turns the declaration into middleware and drops it - so there is nothing to - * import. The declarations are still the single source of truth, so they are - * read where they are written. The staff module needs the same treatment for a - * second reason: its writes call `assertStaffPermission` *inside* the handler, - * because the permission depends on the `{type}` in the path, so no route - * declaration exists to import even in principle. - * - * ## What it does not claim - * - * That the frontend gate is the boundary. `api/config.ts` puts - * `globalAdminMiddleware()` in front of every `/admin/` request and each handler - * re-checks the staff tables. What is asserted here is that the two halves agree - * about *names*. - */ - const here = dirname(fileURLToPath(import.meta.url)); const apiModules = resolve(here, "../../../../../api/modules/admin"); diff --git a/packages/vitnode/src/views/admin/views/core/shared/admin-permissions.ts b/packages/vitnode/src/views/admin/views/core/shared/admin-permissions.ts index 92bf96b01..dceb37f70 100644 --- a/packages/vitnode/src/views/admin/views/core/shared/admin-permissions.ts +++ b/packages/vitnode/src/views/admin/views/core/shared/admin-permissions.ts @@ -1,26 +1,3 @@ -/** - * Every staff permission the AdminCP's users, roles and staff screens check, - * written down once. - * - * These are not new rules. Each tuple already exists twice - as an - * `adminStaffPermission` on the Hono route that enforces it, and as an - * `<AdminStaffPermissionGate>` or an `<AdminPermissionRequired>` in the Next.js - * page that hides the control. Writing them a third time, inline, per framework, - * is how a button ends up shown to somebody the API refuses, or hidden from - * somebody it would allow. - * - * So they live here, beside the queries they gate, and the Next.js pages, the - * TanStack loaders and the shared components all read the same objects. - * `admin-permission-parity.test.ts` holds them to the API's own declarations. - * - * ## What they are not - * - * Not the boundary. `api/config.ts` puts `globalAdminMiddleware()` in front of - * every request whose path contains `/admin/`, each handler re-checks the staff - * tables, and `SessionAdminModel.getUser()` re-runs `checkIfUserIsAdmin` on - * every request. These decide what to *render*. - */ - import type { PermissionsStaffArgs, PermissionStaffType, @@ -35,14 +12,6 @@ const core = (module: string, permission: string): PermissionsStaffArgs => ({ plugin: CONFIG_PLUGIN.pluginId, }); -/** - * The users list and the user page. - * - * view the list, the detail page, and both API reads - * create the create dialog, and `POST /admin/users/create` - * edit the row actions, the in-place editors, and every write - * editAdmin additionally, when the *target* is an administrator - */ export const ADMIN_USER_PERMISSIONS = { create: core("users", "can_create"), edit: core("users", "can_edit"), @@ -50,17 +19,6 @@ export const ADMIN_USER_PERMISSIONS = { view: core("users", "can_view"), } as const; -/** - * The roles list. - * - * `view` is a *frontend* gate and only that: `listRolesAdminRoute` declares no - * `adminStaffPermission`, deliberately, because a role *picker* has to work for - * an administrator who cannot open the roles *screen*. The other five are on the - * API as well. - * - * `editAdmin` and `deleteAdmin` are the elevated pair, required on top of the - * ordinary one when the role grants administrator access. - */ export const ADMIN_ROLE_PERMISSIONS = { create: core("roles", "can_create"), delete: core("roles", "can_delete"), @@ -70,26 +28,11 @@ export const ADMIN_ROLE_PERMISSIONS = { view: core("roles", "can_view"), } as const; -/** - * Which permission module governs one staff group. - * - * `staffPermissionModuleByType` on the API, restated: that module lives under - * `api/modules/**` and importing it would pull zod-openapi and the route tree - * into a browser bundle. The two are a pair, and - * `staff-permission-parity.test.ts` is what keeps them one. - */ export const staffPermissionModuleFor = ( type: PermissionStaffType, ): "staff_admins" | "staff_moderators" => type === "admin" ? "staff_admins" : "staff_moderators"; -/** - * The four permissions that govern a staff group, for whichever group it is. - * - * A function rather than two constants because the two groups differ only in - * their module, and a table with both spelled out is a table two edits can - * disagree in. - */ export const adminStaffPermissions = (type: PermissionStaffType) => { const module = staffPermissionModuleFor(type); diff --git a/packages/vitnode/src/views/admin/views/core/shared/admin-scope.ts b/packages/vitnode/src/views/admin/views/core/shared/admin-scope.ts index f84d72190..419bab617 100644 --- a/packages/vitnode/src/views/admin/views/core/shared/admin-scope.ts +++ b/packages/vitnode/src/views/admin/views/core/shared/admin-scope.ts @@ -1,67 +1,7 @@ -/** - * Cache keys for the AdminCP screens whose answer depends on *who is asking*. - * - * `adminQueryRoot("cron")` is enough for the operational screens: a cron job's - * schedule is the same fact for every administrator who is allowed to read it, - * and a sign-out drops the whole `["vitnode", "admin"]` prefix anyway. - * - * The permission-sensitive screens are not like that. What comes back from the - * users, roles and staff endpoints is *shaped by the reader's own permissions* - - * `users:can_edit_admin` decides whether a row may be edited, `self` on a staff - * entry is computed against the caller's own roles, and `users:can_view` decides - * whether there is a list at all. Two administrators asking the same question - * get two different answers, so they must not share a cache entry. - * - * The dashboard is here for a different reason, and it is worth stating because - * the two look alike and are not. Its layout is not *shaped* by permissions - it - * is simply somebody's property. `core_admin_dashboard` holds one row per - * administrator under a `UNIQUE` constraint on `userId`, and the handler scopes - * the read to the session's own id, so the answer is not a different view of one - * fact but a different fact entirely. That makes it the sharpest case on this - * list rather than the mildest: a shared key does not merely risk showing the - * wrong emphasis, it shows another person's board - and a save from that board - * writes their widget ids into the reader's row. - * - * ["vitnode", "admin", "users", 7, "list", { first: "10" }] - * \______________________________/ | \__________________/ - * the AdminCP root | the request - * the identity it was read for - * - * ## Both mechanisms, not one - * - * - **Partitioning** stops a second identity *reading* the first one's entry. - * It works immediately and without anything having to run: a different admin - * means a different key, and a different key means a fetch. - * - **Removal** stops it being in memory at all. `removeAdminShellQueries` drops - * `["vitnode", "admin"]` on sign-out, and because every key below starts with - * that prefix it collects these too - no list to extend, nothing to remember. - * - * ## The id never leaves the browser - * - * It is a *cache* concern and never a request one. The AdminCP API derives who - * is asking from the `vitnode_auth_admin` cookie on every single request, so a - * user id in a query string would be a second, weaker answer to a question the - * cookie already settles - and one the caller controls. It appears in a key and - * nowhere near a fetcher. - */ - import { adminQueryRoot } from "@/views/admin/table/query"; -/** - * `null` is a real partition rather than a missing one. - * - * It is the key a read performed with no granted admin session would use - the - * sign-in screen's tolerant prefetch, a failed session read - and it must not - * collide with any administrator's. - */ export type AdminIdentity = null | number; -/** - * The whole of one screen's cache, for one administrator. - * - * What a mutation invalidates: every page, sort and filter of the list it - * changed, for the admin looking at it, and nothing belonging to anyone else. - */ export const adminScopedQueryRoot = ( screen: string, adminUserId: AdminIdentity, diff --git a/packages/vitnode/src/views/admin/views/core/staff/create/create-staff-form-content.tsx b/packages/vitnode/src/views/admin/views/core/staff/create/create-staff-form-content.tsx index 85cb6d93f..95080a74c 100644 --- a/packages/vitnode/src/views/admin/views/core/staff/create/create-staff-form-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/staff/create/create-staff-form-content.tsx @@ -23,24 +23,6 @@ import { Button } from "@/components/ui/button"; import { SelectableCard } from "../selectable-card"; -/** - * Adding a role or a user to a staff group. - * - * Two questions and one button: *which kind of subject*, and *which one*. The - * API takes exactly one of `roleId` or `userId` - a refinement, not a - * convention - so the two pickers are alternatives rather than fields, which is - * what the pair of cards is saying. - * - * The entry it creates grants **nothing**. Permissions are chosen on the edit - * screen, which is why a successful create navigates there rather than back to - * the list: a two-step flow, and landing on the list would look like the create - * had silently done nothing. - * - * Every framework-specific decision arrives as a prop - the two searches, the - * write, and where to go afterwards - so the same component serves the Next.js - * page and the TanStack route. - */ - export interface CreateStaffFormProps { /** Performs the write. Exactly one of `roleId`/`userId` is ever set. */ onCreate: (args: { diff --git a/packages/vitnode/src/views/admin/views/core/staff/edit/edit-staff-form-content.tsx b/packages/vitnode/src/views/admin/views/core/staff/edit/edit-staff-form-content.tsx index f84bd18cc..f15a70bf3 100644 --- a/packages/vitnode/src/views/admin/views/core/staff/edit/edit-staff-form-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/staff/edit/edit-staff-form-content.tsx @@ -39,40 +39,7 @@ import { import { SelectableCard } from "../selectable-card"; -/** - * Choosing what a staff entry may do. - * - * Every rule this form applies - which permissions exist, what depends on what, - * what a toggle cascades to, and what is finally sent - lives in - * `staff-model.ts` and is tested there without React. What is left here is the - * screen: two modes, a plugin sidebar, collapsible modules, and a save. - * - * ## Unrestricted is a mode, not a checkbox - * - * "Unrestricted" means *everything, including permissions that do not exist - * yet* - a plugin installed tomorrow is covered without anybody revisiting this - * entry. That is why it replaces the tree rather than sitting above it, and why - * a save in that mode sends no permission list at all. - * - * ## What the API does to the same set - * - * `update-permissions.route.ts` drops anything not in the catalog and anything - * whose dependencies are missing, repeatedly, until the set is stable. - * `staffPermissionsForSubmit` applies exactly those two rules before sending, so - * an administrator is never shown one thing and given another. - */ - export interface EditStaffFormProps { - /** - * The keys this entry already holds, as `plugin:module:permission`. - * - * Handed in rather than folded into `plugins`, because the two answer - * different questions: `plugins` is what the *installation* declares, and this - * is what the *entry* was granted. A permission the entry holds that the - * catalog no longer declares is therefore simply absent from the tree - it - * cannot be re-granted by rendering, and the save drops it, which is exactly - * what the API would do with it anyway. - */ grantedKeys: readonly string[]; id: string; /** Performs the write. */ @@ -278,13 +245,6 @@ export const EditStaffFormContent = ({ [plugins], ); - /** - * What is granted right now. - * - * Seeded once, from the entry. Re-deriving it from `grantedKeys` on every - * render would throw away every toggle the administrator has made since the - * page loaded. - */ const [checked, setChecked] = React.useState<Set<string>>( () => new Set(grantedKeys), ); diff --git a/packages/vitnode/src/views/admin/views/core/staff/staff-model.ts b/packages/vitnode/src/views/admin/views/core/staff/staff-model.ts index d451cea4d..e7598ac58 100644 --- a/packages/vitnode/src/views/admin/views/core/staff/staff-model.ts +++ b/packages/vitnode/src/views/admin/views/core/staff/staff-model.ts @@ -1,34 +1,3 @@ -/** - * The staff permission model, with nothing in it that needs a browser. - * - * Two staff groups - administrators and moderators - each a table of entries - * that grant a *role* or a *user* a set of permissions. What the AdminCP's - * create and edit screens do to that model is entirely arithmetic: - * - * catalog + granted -> the checkbox tree the form renders - * a toggle -> the next checked set, dependencies honoured - * the checked set -> the permission list the API is sent - * - * All three are here, pure, because all three have to behave identically in the - * Next.js AdminCP and the TanStack one, and because getting any of them wrong - * grants or revokes access silently. `staff-model.test.ts` is the specification. - * - * ## The dependency rule, stated once - * - * A catalog entry may declare `dependsOn: ["can_view"]` - "this permission is - * meaningless without that one". The API enforces it on write - * (`update-permissions.route.ts` drops any grant whose dependencies are missing, - * repeatedly, until the set is stable) and the form has to agree, or an - * administrator ticks a box, saves, and finds it un-ticked with no explanation. - * - * Two halves, and both are needed: - * - * - **Turning one off turns off everything that depended on it**, transitively. - * Otherwise the form would submit a set the API is about to prune. - * - **A permission whose dependencies are not all granted is locked**, not - * merely unchecked, so the reason is visible before the save rather than after. - */ - import type { PermissionsStaffArgs, PermissionStaffType, @@ -40,14 +9,6 @@ import { staffPermissionKey } from "@/api/lib/staff-permission"; /* Where things live */ /* -------------------------------------------------------------------------- */ -/** - * The URL segment each staff type lives under. - * - * `admin`/`moderator` is the API's vocabulary (the `{type}` path parameter and - * the permission-catalog key); `admins`/`moderators` is the URL's. They differ - * by a letter, which is exactly why the mapping is written down instead of - * spelled out at each of the eight places that needed it. - */ export const STAFF_TYPE_SEGMENT = { admin: "admins", moderator: "moderators", diff --git a/packages/vitnode/src/views/admin/views/core/staff/staff-mutations.ts b/packages/vitnode/src/views/admin/views/core/staff/staff-mutations.ts index b7196298a..fbc87994f 100644 --- a/packages/vitnode/src/views/admin/views/core/staff/staff-mutations.ts +++ b/packages/vitnode/src/views/admin/views/core/staff/staff-mutations.ts @@ -10,39 +10,12 @@ import { } from "@/views/admin/views/core/shared/admin-mutation"; import { adminModuleRef } from "@/views/admin/views/core/users/list/users-query"; -/** - * The three writes the staff screens make, as browser requests. - * - * Create an entry, replace its permissions, remove it. Each returns the status - * rather than throwing it, because each has a refusal the screen has to explain - * rather than merely report: - * - * 409 on create that role or user is already staff - * 403 on edit the entry is protected, or it governs your own access - * 403 on delete the same two, and the API will not let you demote yourself - * 404 the entry was removed while the form was open - * - * The `403`s are the interesting ones and they are *deliberately* server-side - * only. `update-permissions.route.ts` and `delete.route.ts` both re-derive the - * caller's own roles and refuse an entry that governs them, so an administrator - * cannot escalate or lock themselves out. The forms hide those rows using the - * `self` and `protected` flags the list already carries, but hiding is a - * courtesy - the refusal is the rule. - */ - export interface CreateStaffEntryInput { roleId?: number; type: PermissionStaffType; userId?: number; } -/** - * Adds a role or a user to a staff group. Exactly one of the two, which the API - * enforces with a refinement - sending both, or neither, is a `400`. - * - * The new entry grants nothing until its permissions are chosen, which is why - * the id comes back: the screen navigates straight to the edit form. - */ export const createStaffEntry = async ({ roleId, type, @@ -68,19 +41,6 @@ export interface UpdateStaffPermissionsInput { unrestricted: boolean; } -/** - * Replaces an entry's whole permission set. - * - * A replacement rather than a diff, matching the API: the form holds the - * complete intended set, and "add these, remove those" would let two - * administrators editing at once produce a set neither of them chose. - * - * The API answers with the set it actually stored, after dropping anything not - * in the catalog and anything whose dependencies are missing. That is returned - * rather than discarded so a caller can compare it against what it sent - - * `staffPermissionsForSubmit` applies the same two rules, so the two should - * agree, and a disagreement means the catalog changed under the form. - */ export const updateStaffPermissions = async ({ id, permissions, diff --git a/packages/vitnode/src/views/admin/views/core/staff/staff-query.ts b/packages/vitnode/src/views/admin/views/core/staff/staff-query.ts index b70b36f38..41e85a6e5 100644 --- a/packages/vitnode/src/views/admin/views/core/staff/staff-query.ts +++ b/packages/vitnode/src/views/admin/views/core/staff/staff-query.ts @@ -55,13 +55,6 @@ export { } from "@/views/admin/views/core/shared/admin-permissions"; import { RECORD_STALE_TIME } from "@/lib/query-freshness"; -/** - * One page of a staff list. - * - * Two literal paths rather than one interpolated string, because the fetcher's - * types are keyed on the path literal - `/admins` and `/moderators` are two - * routes, and a template literal would infer as neither. - */ /** A role reference as a staff row renders it. */ export interface AdminStaffRole { color: null | string; @@ -76,12 +69,7 @@ export interface AdminStaffRow { /** Managed by the installation - it cannot be edited or removed. */ protected: boolean; role: AdminStaffRole | null; - /** - * This entry governs the *reading* administrator's own access - their user - * entry, or an entry for a role they hold. Computed by the API against the - * caller, which is why a staff list is one of the reads that must never be - * shared between two administrators' cache partitions. - */ + self: boolean; unrestricted: boolean; updatedAt: Date | string; @@ -96,25 +84,12 @@ export interface AdminStaffRow { export type AdminStaffPage = AdminTablePage<AdminStaffRow>; -/** - * How a page is actually fetched. - * - * The third argument is the read's cancellation, and it is optional so the SSR - * branch - handed no signal, deliberately - satisfies this with two parameters. - * See {@link adminStaffQueryOptions}. - */ export type AdminStaffPageFetcher = ( type: PermissionStaffType, params: AdminStaffParams, options?: { signal?: AbortSignal }, ) => Promise<AdminStaffPage>; -/** - * One page, fetched from the browser. - * - * A refusal throws, and so does an abort: `fetch` rejects before there is a - * response, so a cancelled sort cannot arrive as an empty staff list. - */ export const fetchAdminStaffPageInBrowser: AdminStaffPageFetcher = async ( type, params, diff --git a/packages/vitnode/src/views/admin/views/core/staff/table/staff-table-content.tsx b/packages/vitnode/src/views/admin/views/core/staff/table/staff-table-content.tsx index 132b5a374..5e0355b7d 100644 --- a/packages/vitnode/src/views/admin/views/core/staff/table/staff-table-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/staff/table/staff-table-content.tsx @@ -29,19 +29,6 @@ import { ADMIN_STAFF_DEFAULT_ORDER } from "@/views/admin/views/core/staff/staff- import { StaffUserFormatContent } from "./staff-user-format-content"; -/** - * The administrators and moderators tables, with no framework in them. - * - * One component for both groups: they are the same five columns over two - * tables, and which permission module governs them is the only difference - - * `staff_admins` or `staff_moderators`, resolved by - * {@link adminStaffPermissions} so a button is hidden for exactly the reason the - * API would refuse it. - * - * The delete arrives as a prop, and what to do after it (revalidate a path, - * invalidate a query key) is the caller's. - */ - export interface StaffTableProps { data: AdminStaffPage; LinkComponent: AuthLinkComponent; @@ -53,18 +40,6 @@ export interface StaffTableProps { type: PermissionStaffType; } -/** - * A row's two buttons, or the padlock that replaces both. - * - * `protected` is an entry the installation manages, and `self` is the entry that - * governs *the reading administrator's own* access - their user entry, or an - * entry for a role they hold. The API refuses to edit or delete either - * (`update-permissions.route.ts` and `delete.route.ts` both re-derive the - * caller's roles), so an administrator can neither escalate themselves nor lock - * themselves out. Showing a padlock with the reason is the honest rendering of a - * rule that is enforced elsewhere; hiding the row would make the entry look - * missing. - */ const StaffRowActions = ({ LinkComponent, onDelete, diff --git a/packages/vitnode/src/views/admin/views/core/staff/table/staff-user-format-content.tsx b/packages/vitnode/src/views/admin/views/core/staff/table/staff-user-format-content.tsx index 59e7c3129..5d92094fa 100644 --- a/packages/vitnode/src/views/admin/views/core/staff/table/staff-user-format-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/staff/table/staff-user-format-content.tsx @@ -7,14 +7,6 @@ import type { RoleNameEntry } from "@/components/role-name"; import { Avatar } from "@/components/avatar"; import { resolveRoleName } from "@/components/role-name"; -/** - * A staff entry's user, coloured by their role. - * - * The framework-neutral half of `StaffUserFormat`, which is an async Server - * Component built on `next-intl/server`'s `getLocale()`. Same reasoning as - * `RoleFormatContent`: one string lookup was making every screen that renders a - * staff row Next-only. - */ export const StaffUserFormatContent = ({ user, }: { diff --git a/packages/vitnode/src/views/admin/views/core/system/files/actions/file-row-actions.tsx b/packages/vitnode/src/views/admin/views/core/system/files/actions/file-row-actions.tsx index ac67a4e65..676bc8826 100644 --- a/packages/vitnode/src/views/admin/views/core/system/files/actions/file-row-actions.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/files/actions/file-row-actions.tsx @@ -16,20 +16,6 @@ import type { DeleteAdminFile } from "../files-delete"; import { filesAdminModuleRef } from "../files-query"; -/** - * Download and delete, on one row of the AdminCP file table. - * - * `onDelete` is the one thing it cannot decide for itself: in Next.js the delete - * ends in `revalidatePath` and so has to be a server action, and in TanStack - * Start it is a browser call followed by a query invalidation. Both are - * `DeleteAdminFile`, so the row takes one and stops caring - see - * `../files-delete.ts`. - * - * The *download* is not a seam. It is already a browser fetch that turns the - * response into an object URL and clicks an anchor at it, which is the only way - * to do it in either framework: a server action cannot hand a browser a file to - * save. - */ export const FileRowActions = ({ canDelete, canDownload, diff --git a/packages/vitnode/src/views/admin/views/core/system/files/actions/files-bulk-actions.tsx b/packages/vitnode/src/views/admin/views/core/system/files/actions/files-bulk-actions.tsx index 9d3925169..0e3ebc554 100644 --- a/packages/vitnode/src/views/admin/views/core/system/files/actions/files-bulk-actions.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/files/actions/files-bulk-actions.tsx @@ -13,14 +13,6 @@ import { Button } from "@/components/ui/button"; import type { DeleteAdminFiles } from "../files-delete"; -/** - * "Delete selected", over the rows the table has ticked. - * - * `onDeleteFiles` is the framework seam - a server action in Next.js, a browser - * call plus a query invalidation in TanStack Start. Everything else, including - * the two-step force pass for files only retained revisions are holding, is the - * same in both. - */ export const FilesBulkActions = ({ onDeleteFiles, }: { diff --git a/packages/vitnode/src/views/admin/views/core/system/files/files-delete.ts b/packages/vitnode/src/views/admin/views/core/system/files/files-delete.ts index d3475a5c1..4be2c8a10 100644 --- a/packages/vitnode/src/views/admin/views/core/system/files/files-delete.ts +++ b/packages/vitnode/src/views/admin/views/core/system/files/files-delete.ts @@ -8,23 +8,6 @@ import { ADMIN_FILES_PREFIX_PATH } from "@/views/admin/views/core/system/files/f import { filesAdminModuleRef } from "./files-query"; -/** - * Deleting an uploaded file from the AdminCP, as a contract both frameworks - * satisfy. - * - * `DELETE /admin/files/{id}` declares - * `adminStaffPermission: { module: "files", permission: "can_delete" }` and - * re-checks it on every request, so the browser may call it directly - same - * origin, admin cookie attached by the browser itself - and there is no server - * function in between. One would be a `POST` back to the app that then calls - * Hono: two round trips and a second place to get the `409` handling wrong. - * - * The Next.js app keeps its server actions, which is not a contradiction: there - * the delete has to end with `revalidatePath`. What both sides share is the - * shape below, the `409` handling and the bulk accounting, so one table can be - * handed either. - */ - /** Deleting one file. `force` releases retained revisions; see `FileInUse`. */ export interface DeleteAdminFileArgs { force?: boolean; @@ -37,12 +20,6 @@ export interface DeleteAdminFilesArgs { ids: number[]; } -/** - * What the shared table is handed instead of a mutation. - * - * Plain async functions returning closed results, so a Next.js server action and - * a browser fetch are the same prop. - */ export type DeleteAdminFile = ( args: DeleteAdminFileArgs, ) => Promise<DeleteFileResult>; @@ -51,24 +28,6 @@ export type DeleteAdminFiles = ( args: DeleteAdminFilesArgs, ) => Promise<BulkDeleteFilesResult>; -/** - * One delete, as arguments to whichever fetcher is carrying it. - * - * `force` is omitted rather than sent as `"false"`, so the URL of an ordinary - * delete says nothing about forcing at all - the route's schema accepts both, - * but a request that never mentions it cannot be misread by a proxy or a log. - */ -/** - * Deletes one uploaded file from the browser. - * - * Never rejects, and that is the contract rather than an oversight. Every way - * this can fail is something the administrator has to be told in the dialog they - * are standing in: a `409` is offered back as a confirmation, a `404` means - * somebody already deleted it, and anything else is "try again". The `catch` - * exists because `rawApiFetch` throws on a `500` with the failing URL attached - - * a server error like any other, reported as `status: 500` rather than as a - * crashed dialog. - */ export const deleteAdminFileInBrowser: DeleteAdminFile = async ({ force = false, id, @@ -101,14 +60,6 @@ export const deleteAdminFileInBrowser: DeleteAdminFile = async ({ } }; -/** - * Deletes a selection from the browser, one request per file. - * - * `runBulkFileDelete` is core's own, unchanged and shared with the Next.js - * server action: there is no bulk endpoint, so each id is the same single-file - * delete the row action calls, the per-file semantics stay identical, and the - * fan-out is capped. - */ export const deleteAdminFilesInBrowser: DeleteAdminFiles = async ({ force = false, ids, diff --git a/packages/vitnode/src/views/admin/views/core/system/files/files-query.ts b/packages/vitnode/src/views/admin/views/core/system/files/files-query.ts index d96e9f2b7..b006c4c15 100644 --- a/packages/vitnode/src/views/admin/views/core/system/files/files-query.ts +++ b/packages/vitnode/src/views/admin/views/core/system/files/files-query.ts @@ -16,20 +16,6 @@ import { } from "@/views/admin/admin-request"; import { adminQueryRoot } from "@/views/admin/table/query"; -/** - * Every file uploaded to the installation, as one query definition. - * - * The AdminCP's counterpart to `views/files/my-files-query.ts`, and deliberately - * a separate one: that list is `GET /users/files`, scoped to the signed-in - * visitor by their session cookie, and this is `GET /admin/files`, which returns - * everybody's and is gated on `files.can_view`. Two endpoints, two permissions, - * two cache families - sharing either would be a way for one to answer for the - * other. - * - * `GET /api/@vitnode/core/admin/files` re-checks that permission against the - * staff tables on every request, so nothing below authorizes anything. - */ - export const filesAdminModuleRef = adminModuleRef<typeof filesAdminModule>(); /** The module is mounted under `/admin`, not at the plugin root. */ @@ -108,25 +94,9 @@ export const fetchAdminFilesPageInBrowser: AdminFilesPageFetcher = /** The root every cached page of the admin file list hangs off. */ export const adminFilesQueryRoot = adminQueryRoot("files"); -/** - * The cache entry one page of the list reads and writes. - * - * The normalised parameters, search included. No owner segment, and that is the - * difference from `/files`: this list is not partitioned by who is looking at - * it, because it is not their data - it is the installation's, and everyone who - * can open the screen sees the same rows. `removeAdminShellQueries` is what - * takes it out of the browser at sign-out. - */ export const adminFilesQueryKey = (params: AdminFilesParams) => [...adminFilesQueryRoot, params] as const; -/** - * The admin file list, as the one query definition every caller shares. - * - * `retry: false`, for the reason every AdminCP read refuses to retry: a `429` is - * answered by sending the same request two more times, and a `403` is not going - * to become a `200` because we asked again. - */ export const adminFilesQueryOptions = ({ fetchPage = fetchAdminFilesPageInBrowser, params, diff --git a/packages/vitnode/src/views/admin/views/core/system/files/files-table-content.tsx b/packages/vitnode/src/views/admin/views/core/system/files/files-table-content.tsx index f4b61505f..dee613848 100644 --- a/packages/vitnode/src/views/admin/views/core/system/files/files-table-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/files/files-table-content.tsx @@ -16,36 +16,6 @@ import type { AdminFileRow, AdminFilesPage } from "./files-query"; import { FileRowActions } from "./actions/file-row-actions"; import { FilesBulkActions } from "./actions/files-bulk-actions"; -/** - * Every file uploaded to the installation, as a table both frameworks render. - * - * The nine columns, the preview, the metadata popover, the uploader, the empty - * state and which columns sort - all shared. Fetching, translation and the two - * deletes are lifted out to whoever is rendering it. - * - * Next.js files-table-view.tsx fetch + notFound + server actions - * TanStack Start routes/_admin/…/system/files loader + useSuspenseQuery + browser deletes - * \ / - * FilesTableContent - * - * ## What it does not own - * - * **Permissions.** `canDelete` and `canDownload` arrive as props rather than - * being read from the permission context here, and that is deliberate: the - * Next.js page resolves them on the server with `checkAdminPermissionApi`, and - * reading them from a React context instead would suspend this component on a - * promise the AdminCP layout is still holding. The TanStack route reads them - * from the same admin session the guard already resolved. Either way they hide a - * control - the API re-checks both tuples on the request itself. - * - * **Deleting.** Two callbacks, because the two frameworks genuinely differ: one - * ends in `revalidatePath`, the other in a query invalidation. What they share - - * the `409` handling, the force pass, the bulk accounting - is `files-delete.ts` - * and `lib/files/`, so the difference really is only the last line. - * - * **Navigating.** Sorting, paging and searching rewrite the URL, and this - * component never learns how; the caller mounts the navigation seam. - */ export const FilesTableContent = ({ canDelete, canDownload, diff --git a/packages/vitnode/src/views/admin/views/core/system/integrations/integrations-content.tsx b/packages/vitnode/src/views/admin/views/core/system/integrations/integrations-content.tsx index 3df12b794..26e9fa900 100644 --- a/packages/vitnode/src/views/admin/views/core/system/integrations/integrations-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/integrations/integrations-content.tsx @@ -23,35 +23,6 @@ import { SendTestEmailAction } from "./send-test-email/send-test-email"; import { TestAIAction } from "./test-ai/test-ai"; import { TestStorageAction } from "./test-storage/test-storage"; -/** - * The integrations board, as a grid both frameworks render. - * - * Nine cards, three of which carry a test action, and the small amount of logic - * that turns each subsystem's report into one of three statuses - `active`, - * `warning`, `inactive`. Fetching and translation are lifted out to whoever is - * rendering it. - * - * Next.js integrations-view.tsx fetch + permission reads - * TanStack Start routes/_admin/…/system/integrations loader + useSuspenseQuery - * \ / - * IntegrationsContent - * - * The three `can*` flags arrive as props rather than being read from the - * permission context here, for the same reason the file table's do: the Next.js - * page resolves them on the server with `checkAdminPermissionApi`, and reading - * them from a React context instead would suspend this component on a promise - * the AdminCP layout is still holding. Either way they hide a control - the API - * re-checks `system.can_send_test_email`, `system.can_test_storage` and - * `system.can_test_ai` on the requests themselves. - */ - -/** - * Where each card's "read more" goes. - * - * Absolute `vitnode.com` links rather than in-app routes: these are the - * framework's own documentation, they are the same for every installation, and - * they open in a new tab. - */ const DOCS_URLS = { ai: "https://vitnode.com/docs/dev/ai", captcha: "https://vitnode.com/docs/dev/captcha", diff --git a/packages/vitnode/src/views/admin/views/core/system/integrations/integrations-query.ts b/packages/vitnode/src/views/admin/views/core/system/integrations/integrations-query.ts index eae13a19e..0fcfb6eea 100644 --- a/packages/vitnode/src/views/admin/views/core/system/integrations/integrations-query.ts +++ b/packages/vitnode/src/views/admin/views/core/system/integrations/integrations-query.ts @@ -7,19 +7,6 @@ import { RECORD_STALE_TIME } from "@/lib/query-freshness"; import { adminModuleRef, AdminRequestError } from "@/views/admin/admin-request"; import { adminQueryRoot } from "@/views/admin/table/query"; -/** - * Which of VitNode's integrations are configured and running, as one query - * definition. - * - * One read of `GET /admin/debug/integrations`, which reports on nine - * subsystems - AI, WebSocket, Redis, email, storage, cron, content preview, the - * queue and captcha - each as a small object the board turns into a status. - * - * The route declares `adminStaffPermission: { module: "system", permission: - * "can_view" }` and re-checks it on every request, so nothing below authorizes - * anything. - */ - export const debugAdminModuleRef = adminModuleRef<typeof debugAdminModule>(); /** The debug module is mounted under `/admin`, not at the plugin root. */ @@ -31,15 +18,6 @@ export interface AdminIntegrationModel { name: string; } -/** - * The board's data, exactly as the route's `200` schema declares it. - * - * Declared rather than inferred off the fetcher, because the inferred type - * cannot be named across a declaration-emit boundary. It stays honest anyway: - * {@link fetchIntegrationsInBrowser} is typed as {@link IntegrationsFetcher} and - * returns the response's own inferred shape, so a field renamed in - * `integrationsDebugAdminRoute` stops this file compiling. - */ export interface AdminIntegrations { ai: { active: boolean; models: AdminIntegrationModel[] }; captcha: { @@ -90,13 +68,6 @@ export const fetchIntegrationsInBrowser: IntegrationsFetcher = async () => { /** The cache entry the board reads and writes. */ export const integrationsQueryKey = adminQueryRoot("integrations"); -/** - * The integrations board, as the one query definition every caller shares. - * - * `retry: false`, for the reason every AdminCP read refuses to retry: repeating - * a `429` is the thing the rate limiter is asking the app to stop doing, and a - * `403` is not going to become a `200` because we asked again. - */ export const integrationsQueryOptions = ({ fetchIntegrations = fetchIntegrationsInBrowser, }: { diff --git a/packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/send-test-email-mutation.ts b/packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/send-test-email-mutation.ts index 4103f17dc..2db86fbf0 100644 --- a/packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/send-test-email-mutation.ts +++ b/packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/send-test-email-mutation.ts @@ -5,20 +5,6 @@ import type { zodSendTestEmailSchema } from "@/api/modules/admin/debug/routes/se import { fetcherClient } from "@/lib/fetcher-client"; import { debugAdminModuleRef } from "@/views/admin/views/core/system/integrations/integrations-query"; -/** - * Sending a test email, as a contract both frameworks satisfy. - * - * `POST /admin/debug/send-test-email` declares - * `adminStaffPermission: { module: "system", permission: "can_send_test_email" }` - * and re-checks it on every request, so the browser may call it directly and - * there is no server function in between. - * - * The Next.js app keeps its server action - not because the mutation needs a - * server, but because changing how the Next.js AdminCP sends this request is not - * part of migrating the TanStack one. Both satisfy {@link SendTestEmail}, so the - * dialog takes one and stops caring. - */ - export type SendTestEmailBody = z.infer<typeof zodSendTestEmailSchema>; /** What the dialog is handed instead of a mutation. */ diff --git a/packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/use-form.ts b/packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/use-form.ts index 3d952623b..456ac9b5e 100644 --- a/packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/use-form.ts +++ b/packages/vitnode/src/views/admin/views/core/system/integrations/send-test-email/use-form.ts @@ -8,13 +8,6 @@ import { useDialog } from "@/components/ui/dialog"; import type { SendTestEmail } from "./send-test-email-mutation"; -/** - * The test-email form: its schema, and what submitting it does. - * - * `onSend` is the framework seam - the Next.js server action or the browser - * mutation - and everything else here is shared: the same three fields, the same - * validation messages, the same success toast and the same dialog closing. - */ export const useFormSendTestEmail = (onSend: SendTestEmail) => { const t = useTranslations("admin.system.integrations.email.test"); const tError = useTranslations("core.global.errors"); diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-detail-content.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-detail-content.tsx index 7cce67937..143d42df2 100644 --- a/packages/vitnode/src/views/admin/views/core/users/detail/user-detail-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-detail-content.tsx @@ -23,25 +23,6 @@ import { } from "./user-fields-content"; import { UserRolesCardContent } from "./user-roles-content"; -/** - * The AdminCP user page, with no framework in it. - * - * The profile card, the in-place field editors, the roles card and the two tabs - * - lifted out of the Next.js Server Component so a TanStack route renders the - * same screen. Four things arrive as props, because four things differ: - * - * canEdit decided from the reader's permissions and the target's - * `isAdmin`, by `canEditAdminUser` - the same rule in both - * LinkComponent how this application renders an internal link - * onUpdate* the two writes, and what the app does about the cache - * timeline the activity feed, mounted by the caller because its query - * needs a locale and a transport this component has neither - * of - * - * `canEdit` is a *display* decision. `assertCanEditAdminTarget` on the API is - * the rule, re-checked on every write, so an administrator who edits this value - * in devtools gets visible buttons and a `403`. - */ export interface UserDetailProps { canEdit: boolean; LinkComponent: AuthLinkComponent; diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-fields-content.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-fields-content.tsx index be9939dc7..d8f1f48d6 100644 --- a/packages/vitnode/src/views/admin/views/core/users/detail/user-fields-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-fields-content.tsx @@ -42,28 +42,12 @@ import { import { Input } from "@/components/ui/input"; import { TooltipWithContent } from "@/components/ui/tooltip"; -/** - * The editable fields on the AdminCP user page, with no framework in them. - * - * A rename, an email change and a name-code change are three requests to the - * same `PATCH`, and each is edited in place with its own pencil rather than in a - * form-wide save - which is why the write arrives as one prop and each field - * decides what to do with the answer. - */ - /** How the page performs a user update. Supplied by whichever app mounts this. */ export type UpdateAdminUser = ( id: number, input: AdminUserUpdateInput, ) => Promise<AdminMutationResult<{ nameCode: string }>>; -/** - * A single-field editor: read, pencil, input, save. - * - * Saving an unchanged or empty value closes the editor without a request, which - * is both an optimisation and the correct behaviour - `zodUpdateUserAdminSchema` - * refuses a body with nothing in it. - */ export const EditUserFieldContent = ({ as: Tag = "span", canEdit = true, @@ -228,13 +212,6 @@ export const EditImageButtonContent = ({ label }: { label: string }) => { ); }; -/** - * Changing a name code, behind a confirmation. - * - * A name code is the user's public URL, so changing it breaks every link anybody - * ever shared. The form makes that deliberate: the current code has to be typed - * back before the new one is accepted, and the warning says why. - */ const NameCodeForm = ({ id, nameCode, diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-query.ts b/packages/vitnode/src/views/admin/views/core/users/detail/user-query.ts index d7cfa25c6..ebe0b2699 100644 --- a/packages/vitnode/src/views/admin/views/core/users/detail/user-query.ts +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-query.ts @@ -1,12 +1,3 @@ -/** - * One user, as the AdminCP detail screen reads them. - * - * `/admin/core/users/123` in the address bar, `$id` in the route tree, and a - * `{id}` path parameter on a Hono route that answers `404` for anything that is - * not a row. Between those three there is exactly one interesting decision, and - * it is {@link normalizeAdminUserId}. - */ - import { queryOptions } from "@tanstack/react-query"; import type { StaffPermissionSet } from "@/api/lib/permission-staff"; @@ -24,33 +15,8 @@ import { } from "@/views/admin/views/core/shared/admin-scope"; import { adminModuleRef } from "@/views/admin/views/core/users/list/users-query"; -/** - * Postgres `integer`. A `bigint`-looking id is not a row, it is a probe. - * - * Checked here rather than left to the API because the value also becomes a - * cache key and a `<title>`, and `Number("9999999999999")` is a perfectly finite - * number that the database would reject with a driver error rather than a 404. - */ const MAX_USER_ID = 2_147_483_647; -/** - * The id in the URL, or `null` if it cannot be one. - * - * `$id` matches *any* segment, so this receives whatever was typed: - * `/admin/core/users/abc`, `/admin/core/users/1e3`, `/admin/core/users/-1`, - * `/admin/core/users/007`. `Number()` accepts all four - as `NaN`, `1000`, `-1` - * and `7` - and the first would reach Hono as `?id=NaN`, which is a request - * nobody meant to make. - * - * A strict decimal test instead, with no sign, no exponent and no leading zero, - * so exactly one string maps to each id. That last rule is what keeps the cache - * honest: `007` and `7` are the same user, and accepting both would be two cache - * entries and two fetches for one row. - * - * Returns the *string*, because that is what the path parameter takes and what a - * route's params hold - converting to a number here would only mean converting - * back at every call site. - */ export const normalizeAdminUserId = ( raw: null | string | string[] | undefined, ): null | string => { @@ -82,13 +48,6 @@ export interface AdminUserDetail { export type AdminUserFetcher = (id: string) => Promise<AdminUserDetail>; -/** - * One user, fetched from the browser. - * - * A refusal throws, carrying its status: `404` is a link to somebody who has - * been deleted and belongs on a not-found screen, `403` is an administrator who - * may no longer look, and the two must not render the same way. - */ export const fetchAdminUserInBrowser: AdminUserFetcher = async id => { const response = await fetcherClient(adminModuleRef, { args: { params: { id } }, diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-roles-content.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-roles-content.tsx index 2f80cbd3e..310af7f48 100644 --- a/packages/vitnode/src/views/admin/views/core/users/detail/user-roles-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-roles-content.tsx @@ -56,15 +56,6 @@ import { import { Spinner } from "@/components/ui/spinner"; import { cn } from "@/lib/utils"; -/** - * A user's roles on the AdminCP detail page, with no framework in them. - * - * One primary role and any number of secondary ones. The dialog holds the - * *complete* intended set and sends it as a replacement, matching the API - "add - * these, remove those" would let two administrators editing at once produce a - * set neither of them chose. - */ - export type UpdateAdminUserRoles = ( id: number, input: { roleId: number; secondaryRoleIds: number[] }, @@ -201,12 +192,6 @@ const EditRolesForm = ({ const [secondaryIds, setSecondaryIds] = React.useState<number[]>(initialSecondaryIds); - /** - * Roles seen so far, by id. - * - * The picker returns a page at a time, so a role chosen ten searches ago has - * to stay renderable in the badge list after the options have been replaced. - */ const [knownRoles, setKnownRoles] = React.useState<Map<number, UserRoleRef>>( () => new Map( diff --git a/packages/vitnode/src/views/admin/views/core/users/list/create-user-content.tsx b/packages/vitnode/src/views/admin/views/core/users/list/create-user-content.tsx index 109f1f129..dd321d3b5 100644 --- a/packages/vitnode/src/views/admin/views/core/users/list/create-user-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/list/create-user-content.tsx @@ -21,19 +21,6 @@ import { } from "@/components/ui/dialog"; import { Loader } from "@/components/ui/loader"; -/** - * Creating a user from the AdminCP, with no framework in it. - * - * The dialog stays a shell and the form is loaded on demand - the Next.js - * version does the same with `next/dynamic`, and for the same reason: the form - * drags `AutoForm`, `react-hook-form` and zod behind it, and none of that is - * worth downloading to look at a list of users. `React.lazy` is the - * framework-neutral spelling. - * - * The write arrives as a prop so a Next.js page can hand it a Server Action - * while a TanStack route hands it a browser call plus a query invalidation. - */ - export type CreateAdminUser = ( input: AdminUserCreateInput, ) => Promise<AdminMutationResult<AdminUserCreated>>; diff --git a/packages/vitnode/src/views/admin/views/core/users/list/create-user-form.tsx b/packages/vitnode/src/views/admin/views/core/users/list/create-user-form.tsx index 6db91fd0f..78d73449b 100644 --- a/packages/vitnode/src/views/admin/views/core/users/list/create-user-form.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/list/create-user-form.tsx @@ -14,18 +14,6 @@ import type { CreateAdminUser } from "./create-user-content"; import { adminUserCreateConflictField } from "../users-mutations"; -/** - * The create-user form itself - the lazy half of `CreateUserAdminContent`. - * - * Its own module so `React.lazy` has something to split on. Everything the - * dialog needs before the form is opened stays in the other file; everything - * here (`AutoForm`, `react-hook-form`, zod) arrives when somebody clicks. - * - * The three validation rules are the API's, restated: `zodCreateUserAdminSchema` - * requires a name of at least three characters, a real email and a password of - * at least eight. Restating them is what turns a `400` into an inline message - * beside the field that caused it. - */ export const CreateUserForm = ({ onCreate }: { onCreate: CreateAdminUser }) => { const t = useTranslations("admin.user.create"); const tError = useTranslations("core.global.errors"); diff --git a/packages/vitnode/src/views/admin/views/core/users/list/users-query.ts b/packages/vitnode/src/views/admin/views/core/users/list/users-query.ts index 24e93d933..01e46842d 100644 --- a/packages/vitnode/src/views/admin/views/core/users/list/users-query.ts +++ b/packages/vitnode/src/views/admin/views/core/users/list/users-query.ts @@ -23,28 +23,6 @@ import { adminScopedQueryRoot, } from "@/views/admin/views/core/shared/admin-scope"; -/** - * The AdminCP users list, as one query definition. - * - * `GET /api/@vitnode/core/admin/users/list`, behind - * `adminStaffPermission: { module: "users", permission: "can_view" }` - re-checked - * against the staff tables on every request, so nothing in this file authorizes - * anything. What lives here is *what* to ask for: the page, the sort, the search - * term and the role filter, normalised once so the Next.js Server Component and - * the TanStack Start loader send the same request rather than two that look - * alike. - * - * The transport is not fixed - a loader on a server and a component in a browser - * cannot reach the API the same way - so {@link adminUsersQueryOptions} takes a - * `fetchPage` and defaults it to the browser's. - */ - -/** - * Every AdminCP module hangs off the one root `adminModule`, so the reference is - * the root's and the module is named per request (`admin/users`). Imported as a - * *type*, so route literals and response schemas still infer while the browser - * bundle carries only a plugin id. - */ export const adminModuleRef = buildAdminModuleRef<typeof adminModule>(); /** The columns `listUsersAdminRoute` sorts by. Anything else is a `400`. */ @@ -64,15 +42,6 @@ export const ADMIN_USERS_TABLE_CONTRACT: AdminTableContract<AdminUsersOrderBy> = search: true, }; -/** - * The users list's request. - * - * `AdminTableParams` plus the one parameter no other admin table has: `roleId`, - * a comma-separated set of primary-role ids written by the filter dropdown. - * Extended here rather than added to the shared contract because it is this - * screen's, and a shared type that grows a field per screen stops describing - * anything. - */ export interface AdminUsersParams extends AdminTableParams<AdminUsersOrderBy> { roleId?: string; } @@ -88,18 +57,6 @@ const readOne = (value: null | string | string[] | undefined): string => { return value ?? ""; }; -/** - * The role filter, reduced to ids that could be ids. - * - * The dropdown writes `?roleId=2,5`; a hand-edited `?roleId=abc` would reach - * `Number()` in the handler and filter by `NaN`, which matches nothing and looks - * exactly like "there are no users with that role". Duplicates and order are - * normalised for the same reason the queue's status filter is: `?roleId=5,2` and - * `?roleId=2,5` are one query and must be one cache entry. - * - * `undefined` for an empty selection, so the parameter is *absent* - an empty - * `?roleId=` is not the same request as no filter at all. - */ export const normalizeAdminRoleFilter = ( value: null | string | string[] | undefined, ): string | undefined => { @@ -115,13 +72,6 @@ export const normalizeAdminRoleFilter = ( return ids.length > 0 ? ids.join(",") : undefined; }; -/** - * The request this URL is asking for. - * - * Total and idempotent, like every normaliser in this layer: a `validateSearch` - * that throws turns a hand-edited query string into a router error screen, and - * the router re-validates the location every navigation produces. - */ export const normalizeAdminUsersParams = ( raw: RawAdminUsersParams = {}, ): AdminUsersParams => { @@ -135,12 +85,6 @@ export const normalizeAdminUsersParams = ( return params; }; -/** - * One page of the list, as arguments to whichever fetcher is carrying it. - * - * `withPagination` is deliberately absent - see `views/admin/table/params.ts` - * for why an invisible default is a cache-key bug rather than a convenience. - */ /** One role, with every translation of its name - resolved where it is rendered. */ export interface AdminUserRole { color: null | string; @@ -148,15 +92,6 @@ export interface AdminUserRole { name: { languageCode: string; name: string }[]; } -/** - * One row of the users table, as JSON delivers it. - * - * Declared rather than inferred off the fetcher, because an inferred type cannot - * be named across a declaration-emit boundary. It stays honest anyway: - * {@link fetchAdminUsersPageInBrowser} is typed as {@link AdminUsersPageFetcher} - * and returns the response's own inferred shape, so a column renamed in - * `listUsersAdminRoute` stops this file compiling. - */ export interface AdminUserRow { avatarColor: string; birthday: Date | null | string; @@ -175,28 +110,11 @@ export interface AdminUserRow { export type AdminUsersPage = AdminTablePage<AdminUserRow>; -/** - * How a page is actually fetched. - * - * The second argument is the read's cancellation, and it is optional so the SSR - * branch - which is handed no signal, deliberately - satisfies this with one - * parameter. See {@link adminUsersQueryOptions}. - */ export type AdminUsersPageFetcher = ( params: AdminUsersParams, options?: { signal?: AbortSignal }, ) => Promise<AdminUsersPage>; -/** - * One page, fetched from the browser. - * - * A refusal *throws*. An empty table is what an installation with no users looks - * like, and a `403` - this administrator lost `users:can_view` while the page - * was open - must never render as that. An **abort** throws for the same reason - * and by the same route: `fetch` rejects before there is a response to read, so - * nothing below runs and no `catch` here turns a cancelled sort into an - * installation with no users. - */ export const fetchAdminUsersPageInBrowser: AdminUsersPageFetcher = async ( params, { signal } = {}, @@ -220,12 +138,6 @@ export const fetchAdminUsersPageInBrowser: AdminUsersPageFetcher = async ( return await response.json(); }; -/** - * Every page, sort and filter of the users list, for one administrator. - * - * The unit a create, an edit or an email verification invalidates: the row that - * changed may be on any page, under any sort. - */ export const adminUsersQueryRoot = (adminUserId: AdminIdentity) => adminScopedQueryRoot(ADMIN_USERS_SCREEN, adminUserId); @@ -237,27 +149,6 @@ export const adminUsersQueryKey = ({ params: AdminUsersParams; }) => adminScopedQueryKey(ADMIN_USERS_SCREEN, adminUserId, "list", params); -/** - * The users list, as the one query definition every caller shares. - * - * loader: ensureQueryData(adminUsersQueryOptions({ fetchPage, ... })) - * component: useSuspenseQuery(adminUsersQueryOptions({ ... })) - * mutation: invalidate `adminUsersQueryRoot(adminUserId)` - * - * `retry: false`: a `403` will not become a `200` because we asked again, and a - * `429` is answered by sending the same request twice more. - * - * The `queryFn` **reads** `signal` off the context, which is what makes the read - * cancellable at all - Query marks a query cancellable only when its function - * actually touches that getter. Re-sorting the table three times now leaves one - * request in flight rather than three, and the two that lost reject with an - * `AbortError` rather than resolving late over the answer somebody is reading. - * - * It is safe here because the failure path throws: the abort rejects inside - * `fetch`, before there is a response to inspect, so it cannot be mistaken for - * an empty page or a refusal. A fetcher whose `catch` returns a fallback would - * have to re-throw the abort first; this one has no `catch` at all. - */ export const adminUsersQueryOptions = ({ adminUserId, fetchPage = fetchAdminUsersPageInBrowser, @@ -306,17 +197,6 @@ export const adminUserOptionsFrom = ( nameCode, })); -/** - * Users matching `search`, read straight from Hono. - * - * Behind `users:can_view`, like the list itself - so a staff form offers only - * the people this administrator may already see, and the permission check is the - * route's rather than a second one here. - * - * An empty list rather than a throw, for the same reason the role search does - * it: a picker is a control inside a working form, and taking the form down - * because a lookup failed loses whatever else was chosen. - */ export const searchAdminUsersInBrowser: AdminUserSearchOptions = async search => { try { diff --git a/packages/vitnode/src/views/admin/views/core/users/list/users-table-content.tsx b/packages/vitnode/src/views/admin/views/core/users/list/users-table-content.tsx index 3cad92051..703263542 100644 --- a/packages/vitnode/src/views/admin/views/core/users/list/users-table-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/list/users-table-content.tsx @@ -22,29 +22,6 @@ import type { AdminUserRow, AdminUsersPage } from "./users-query"; import { ADMIN_USERS_DEFAULT_ORDER } from "./users-query"; -/** - * The AdminCP users table, with no framework in it. - * - * The same columns, the same empty state, the same sort and the same role - * filter the Next.js `UsersAdminView` renders - lifted out of the Server - * Component so a TanStack Start route can render them too. What the Server - * Component keeps is the part only it can do: reading the page with the request's - * own cookies. - * - * Three things arrive as props because they are the three a shared table cannot - * resolve for itself: - * - * data the page, from whichever transport fetched it - * LinkComponent how this application renders an internal link - * onVerifyEmail the row action's write, and what to do about the cache - * searchRoles how the role filter looks roles up - * - * `ContentDataTable` rather than `DataTable`: the latter is the Next.js binding - * and mounts `NextDataTableNavigation`. The caller mounts a - * `DataTableNavigationProvider` of its own instead, which is what makes the - * URL controls work in either router. - */ - /** Verifying one user's email, from wherever the caller gets it done. */ export type VerifyAdminUserEmail = ( id: number, @@ -57,13 +34,6 @@ export interface UsersAdminTableProps { searchRoles: AdminRoleSearch; } -/** - * The row actions: verify an unverified email, and open the user. - * - * Gated on `users:can_edit` read from the AdminCP permission context, which both - * shells mount from the same admin session - so the button is hidden for exactly - * the administrators the API would refuse. - */ const UserRowActions = ({ LinkComponent, onVerifyEmail, diff --git a/packages/vitnode/src/views/admin/views/core/users/roles/role-form-content.tsx b/packages/vitnode/src/views/admin/views/core/users/roles/role-form-content.tsx index 391c755d3..93ba1e559 100644 --- a/packages/vitnode/src/views/admin/views/core/users/roles/role-form-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/roles/role-form-content.tsx @@ -15,23 +15,6 @@ import { AutoFormSwitch } from "@/components/form/fields/switch"; import { useDialog } from "@/components/ui/dialog"; import { multiLangValueSchema } from "@/lib/helpers/multi-lang"; -/** - * Creating and editing a role, as one form both frameworks mount. - * - * The Next.js version of this component reached for two things a package cannot - * assume: `next-intl`'s `useTranslations`, and a `"use server"` module. The - * strings now come from `use-intl` - the context `NextIntlClientProvider` and - * `RouteMessages` both provide - and the write arrives as a prop, so a Next.js - * page can keep handing it a Server Action while a TanStack route hands it a - * browser call. - * - * `onSaved` is separate from `onSave` on purpose. The write and *what the - * application does about it* are different questions with different answers per - * framework: Next.js revalidates a path inside its action and then re-renders - * the pathname, while TanStack invalidates a query key. Neither belongs in a - * form. - */ - /** The shape the roles API takes, as the form produces it. */ export interface AdminRoleFormValues { allowUploadFiles: boolean; diff --git a/packages/vitnode/src/views/admin/views/core/users/roles/roles-mutations.ts b/packages/vitnode/src/views/admin/views/core/users/roles/roles-mutations.ts index 07c4a748d..ec8c33bc7 100644 --- a/packages/vitnode/src/views/admin/views/core/users/roles/roles-mutations.ts +++ b/packages/vitnode/src/views/admin/views/core/users/roles/roles-mutations.ts @@ -1,17 +1,3 @@ -/** - * Creating, editing and deleting a role, as browser requests. - * - * Same shape and same reasoning as `users-mutations.ts`: one request per - * function, the status back as data, and no cache or navigation decided here. - * - * ## Deleting a role is not just a delete - * - * A role with members cannot simply vanish - every one of them needs a role - so - * the API takes `?moveToRoleId=` and reassigns them. That is a rule about the - * *request*, so {@link deleteAdminRoleArgs} states it once and both the dialog - * and the test read it from there. - */ - import type { z } from "zod"; import type { zodCreateRoleAdminSchema } from "@/api/modules/admin/roles/routes/create.route"; @@ -58,16 +44,6 @@ export const updateAdminRole = async ( }), }); -/** - * The arguments one role deletion sends. - * - * `moveToRoleId` is *omitted* rather than sent empty when there is nobody to - * move: `?moveToRoleId=` is a value the API would try to parse, and an empty - * query object is the same request as no query at all. - * - * Pure, so the "a role with members must name a destination" rule is testable - * without a network. - */ export const deleteAdminRoleArgs = ({ id, moveToRoleId, diff --git a/packages/vitnode/src/views/admin/views/core/users/roles/roles-query.ts b/packages/vitnode/src/views/admin/views/core/users/roles/roles-query.ts index c7040245f..1613289f2 100644 --- a/packages/vitnode/src/views/admin/views/core/users/roles/roles-query.ts +++ b/packages/vitnode/src/views/admin/views/core/users/roles/roles-query.ts @@ -22,34 +22,6 @@ import { } from "@/views/admin/views/core/shared/admin-scope"; import { adminModuleRef } from "@/views/admin/views/core/users/list/users-query"; -/** - * Roles, as the AdminCP reads them - the list screen, and every role picker. - * - * Two questions, one API route: - * - * /admin/core/users/roles a paginated, searchable, sortable table - * role search the first 20 matches, for a filter or a picker - * - * The second is why this module exists at all. The Next.js AdminCP answers it - * with three near-identical `"use server"` actions - one for the users table's - * role filter, one for the user detail's role dialog, one for the "move members - * to" picker in the delete dialog - each fetching `/admin/roles/list` and - * mapping the result its own way. - * - * A server action is a Next.js primitive: a TanStack Start route cannot call - * one. Re-declaring it as a `createServerFn` would be a *second* RPC hop in - * front of a plain authenticated `GET` - the browser posts to the app, the app - * calls Hono - in exchange for nothing, because this read needs no server-only - * secret and sets no cookie. So the read is a direct Hono call, the mapping is a - * pure function, and both are stated once. - * - * `listRolesAdminRoute` declares no `adminStaffPermission` of its own: any - * administrator may read it, which is what lets a role *picker* work for someone - * who cannot open the roles *screen*. The screen's own gate is - * `roles:can_view`, applied on the frontend and repeated below only as which - * page is reachable. - */ - /** The columns `listRolesAdminRoute` sorts by. */ export const ADMIN_ROLES_ORDER_BY = ["id", "createdAt", "updatedAt"] as const; export type AdminRolesOrderBy = (typeof ADMIN_ROLES_ORDER_BY)[number]; @@ -153,13 +125,6 @@ export const adminRolesQueryOptions = ({ /* Role search */ /* -------------------------------------------------------------------------- */ -/** - * One role, as a picker or a filter needs it. - * - * `name` stays the raw per-language list: the server has no business deciding - * which language the person clicking reads in, so it is resolved against the - * active locale where it is rendered. - */ export interface AdminRoleOption { color: null | string; id: number; @@ -169,16 +134,6 @@ export interface AdminRoleOption { /** The signature every role picker takes, wherever the read comes from. */ export type AdminRoleSearch = (search: string) => Promise<AdminRoleOption[]>; -/** - * The rows a search should offer, out of the page the API returned. - * - * The guest role is dropped, and that is not cosmetic: it is the role a request - * has when it has *no account*, so it is never something to assign to anybody - * and never something to filter a list of accounts by. All three of the server - * actions this replaces wanted it gone, so it is gone once, here. - * - * Pure, so the rule is testable without a network. - */ export const adminRoleOptionsFrom = ( page: Pick<AdminRolesPage, "edges">, ): AdminRoleOption[] => @@ -186,16 +141,6 @@ export const adminRoleOptionsFrom = ( .filter(role => !role.guest) .map(({ color, id, name }) => ({ color, id, name })); -/** - * Roles matching `search`, read straight from Hono. - * - * An empty list rather than a throw for every failure, and that is the one place - * this layer deliberately differs from the list reads above: a picker is a - * *control inside* a working screen. A table that renders empty on a `403` is - * lying about the installation; a dropdown that offers nothing is telling the - * truth about itself, and taking the whole dialog down with an error boundary - * because a search failed would lose whatever else was typed into it. - */ export const searchAdminRolesInBrowser: AdminRoleSearch = async search => { try { const response = await fetcherClient(adminModuleRef, { diff --git a/packages/vitnode/src/views/admin/views/core/users/roles/roles-table-content.tsx b/packages/vitnode/src/views/admin/views/core/users/roles/roles-table-content.tsx index 706386c73..8fdcf58a1 100644 --- a/packages/vitnode/src/views/admin/views/core/users/roles/roles-table-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/roles/roles-table-content.tsx @@ -69,20 +69,6 @@ import type { import { ADMIN_ROLES_DEFAULT_ORDER } from "./roles-query"; -/** - * The AdminCP roles table, with no framework in it. - * - * Columns, the members link, the edit dialog and the delete flow - lifted out of - * the Next.js Server Component so a TanStack route renders the same screen. The - * three writes and the role search arrive as props, because how a write ends - * (revalidate a path, or invalidate a query key) is the application's question - * rather than the table's. - * - * The form itself is loaded lazily, exactly as the Next.js version loads it with - * `next/dynamic`: it drags `AutoForm`, the colour picker and the multi-language - * inputs behind it, and none of that is worth downloading to look at a list. - * `React.lazy` is the framework-neutral spelling of the same thing. - */ const AdminRoleFormContent = React.lazy(async () => import("./role-form-content").then(module => ({ default: module.AdminRoleFormContent, @@ -101,12 +87,6 @@ export interface RolesAdminTableProps { searchRoles: AdminRoleSearch; } -/** - * The role a deleted role's members are moved into. - * - * The role being deleted is excluded, because moving members into the role that - * is about to stop existing is the one choice that cannot work. - */ const MoveRolePicker = ({ excludeId, onSelect, @@ -210,13 +190,6 @@ const MoveRolePicker = ({ ); }; -/** - * Deleting a role. - * - * A role with members cannot simply vanish - everybody needs a role - so the - * dialog will not submit until a destination is chosen, and the toast says how - * many people moved where. A role with no members skips all of it. - */ const DeleteRoleAction = ({ onDelete, onSaved, @@ -357,15 +330,6 @@ const EditRoleAction = ({ ); }; -/** - * Which of the two buttons a row shows. - * - * Four permissions and one structural rule, exactly as the Next.js version has - * them: a system role (protected, default, root or guest) can never be deleted - * whatever the permission set says, and a role that *grants administrator - * access* needs the elevated `can_edit_admin` / `can_delete_admin` on top of the - * ordinary one. Both are re-checked by the API. - */ const RoleRowActions = ({ onDelete, onSave, diff --git a/packages/vitnode/src/views/admin/views/core/users/users-mutations.ts b/packages/vitnode/src/views/admin/views/core/users/users-mutations.ts index 4971dc354..040eb97ce 100644 --- a/packages/vitnode/src/views/admin/views/core/users/users-mutations.ts +++ b/packages/vitnode/src/views/admin/views/core/users/users-mutations.ts @@ -1,11 +1,3 @@ -/** - * Everything the AdminCP writes about a user, as browser requests. - * - * One function per write, each returning the status rather than throwing it - - * the shape and the reasoning are `shared/admin-mutation.ts`, which every - * AdminCP mutation module in this stage shares. - */ - import { fetcherClient } from "@/lib/fetcher-client"; import { type AdminMutationResult, @@ -14,13 +6,6 @@ import { import { adminModuleRef } from "./list/users-query"; -/** - * Every column `PATCH /admin/users/{id}` accepts, all optional. - * - * One body type rather than one per caller: `zodUpdateUserAdminSchema` is a - * `.partial()` with an "at least one field" refinement, so what makes a request - * valid is that *something* is set, not which screen set it. - */ export interface AdminUserUpdateInput { email?: string; name?: string; @@ -36,14 +21,6 @@ export interface AdminUserUpdated { nameCode: string; } -/** - * Rename a user, change their email, or change their name code. - * - * One request per edit rather than a form-wide save, because that is what the - * screen does: each field has its own pencil, its own optimistic close and its - * own error. `zodUpdateUserAdminSchema` is `.partial()` with a "at least one - * field" refinement, so sending one key is the intended shape. - */ export const updateAdminUser = async ( id: number, body: AdminUserUpdateInput, @@ -61,14 +38,6 @@ export const updateAdminUser = async ( }), }); -/** - * Replace a user's primary role and their whole secondary set. - * - * The same `PATCH` as the field edits - the API decides which columns a body - * touches - and deliberately a *replacement* rather than a diff: the dialog - * holds the complete intended set, and sending "add these, remove those" would - * make two administrators editing at once produce a set neither of them chose. - */ export const updateAdminUserRoles = async ( id: number, body: { roleId: number; secondaryRoleIds: number[] }, @@ -109,14 +78,6 @@ export interface AdminUserCreated { name: string; } -/** - * Create a user from the AdminCP. - * - * `201`, not `200`. The `409` the API answers for a taken email or name is - * returned rather than thrown so the dialog can put the message on the right - * field - which of the two it was comes back in the body, and is decoded by - * {@link adminUserCreateConflictField}. - */ export const createAdminUser = async ( body: AdminUserCreateInput, ): Promise<AdminMutationResult<AdminUserCreated>> => @@ -133,16 +94,6 @@ export const createAdminUser = async ( }), }); -/** - * Which field a create conflict was about. - * - * The API answers `409` with a plain-text message rather than a code, so the - * mapping is a string comparison and has to live somewhere both frontends can - * see it - the Next.js dialog matches the same two sentences today. - * - * Anything else is `null`, which the caller shows as a generic error rather than - * attaching to a field that may not be the cause. - */ export const adminUserCreateConflictField = ( message: string, ): "email" | "name" | null => { diff --git a/packages/vitnode/src/views/auth/auth-boundaries.test.ts b/packages/vitnode/src/views/auth/auth-boundaries.test.ts index 6d033794c..376e7bee0 100644 --- a/packages/vitnode/src/views/auth/auth-boundaries.test.ts +++ b/packages/vitnode/src/views/auth/auth-boundaries.test.ts @@ -14,15 +14,6 @@ import { const here = dirname(fileURLToPath(import.meta.url)); -/** - * The auth screens, split down the middle. - * - * The same boundary `feed-boundaries.test.ts` draws around the search feed, for - * the same reason and with the same machinery: a shared component that reaches - * `@/lib/navigation` - or anything else built on Next's request scope - cannot - * be rendered by a TanStack Start route, and nothing about that failure is - * visible until somebody tries. - */ const SHARED = { breadcrumbTrail: join(here, "../breadcrumb/breadcrumb-trail-content.tsx"), card: join(here, "sign-in/sign-in-content.tsx"), @@ -51,13 +42,7 @@ const SHARED = { }; /** The Next.js half: server actions, `next/cache`, locale-aware navigation. */ -/** - * The Next.js half, by path, so its absence can be asserted. - * - * Named rather than deleted along with the assertions that used them: each was - * the one place a Next.js API was allowed to appear in this subtree, and a test - * that stops naming them cannot notice one coming back. - */ + const DELETED_NEXT_HALF = { breadcrumbTrail: join(here, "../breadcrumb/breadcrumb-main.tsx"), card: join(here, "sign-in/sign-in-card.tsx"), @@ -194,21 +179,6 @@ describe("the shared views take their framework parts as props", () => { }); }); -/** - * The settings screens, split the same way. - * - * `SettingsShell` was visually reusable and structurally Next-only: it read - * `usePathname` to decide the narrow-screen behaviour, it imported `next-intl`'s - * `Link` for the back link, and it imported the navigation, which read the same - * pathname a second time for the active item. Three separate reasons a TanStack - * Start layout route could not render it, and none of them visible in what it - * looks like. - * - * What replaced them is one rule: the frame and the menu are *told* where the - * visitor is and how to build a link. The assertions below are about that shape - * as well as about the absence of a specifier, because a shared component can - * also fail by taking the wrong thing as a prop. - */ describe("the settings frame is told its framework parts", () => { const withoutComments = (path: string): string => readFileSync(path, "utf8") diff --git a/packages/vitnode/src/views/auth/auth-link.ts b/packages/vitnode/src/views/auth/auth-link.ts index c6a7ba68a..f9749eb95 100644 --- a/packages/vitnode/src/views/auth/auth-link.ts +++ b/packages/vitnode/src/views/auth/auth-link.ts @@ -1,45 +1,9 @@ -/** - * The one thing the auth screens cannot decide for themselves. - * - * Every link on the login card points somewhere VitNode owns - `/register`, - * `/login/reset-password`, `/login` - and turning one of those paths into a - * navigation is the single question whose answer differs by host: a TanStack - * Start app wants the router's own `Link` (`RouterLink`), and a host that mounts - * VitNode differently wants its own. Each is a component taking - * {@link AuthLinkProps}, so the shared views take one and stop caring - and - * importing none of them is what keeps this directory host-neutral, and what - * lets a TanStack Start route render the login card at all. - * - * The same boundary `SearchFeedContent` and `HeaderContent` already draw, for - * the same reason. - */ - -/** - * The anchor a shared auth link ends up rendering. - * - * Every prop of one, not just `href`: `SSOCallbackContent` puts a link inside a - * Base UI `render`, which clones the element with the children, the class name - * and the ref it needs to stay a button. A wrapper that accepted only `href` - * would drop all three, so the type says so. - */ export interface AuthLinkProps extends Omit<React.ComponentProps<"a">, "href"> { href: string; } export type AuthLinkComponent = (props: AuthLinkProps) => React.ReactNode; -/** - * Where the auth screens link to by default. - * - * Ordinary data rather than a route table: a caller that mounts the login card - * somewhere else overrides the one href it moved, and nothing here has to know - * about it. - * - * Nothing here records which framework renders any of them either, and that is - * the point rather than an omission. All three were Next.js pages when this was - * written and all three are TanStack Start routes now, and the change that moved - * them was route files and no edit to this record. - */ export const AUTH_HREF = { resetPassword: "/login/reset-password", signIn: "/login", diff --git a/packages/vitnode/src/views/auth/password-reset/change-password-form/change-password-form-content.tsx b/packages/vitnode/src/views/auth/password-reset/change-password-form/change-password-form-content.tsx index 4938f6351..770c5b420 100644 --- a/packages/vitnode/src/views/auth/password-reset/change-password-form/change-password-form-content.tsx +++ b/packages/vitnode/src/views/auth/password-reset/change-password-form/change-password-form-content.tsx @@ -20,23 +20,6 @@ import { export type { ChangePasswordSubmit }; -/** - * The second half of password recovery - shared. - * - * One field, and two props that are the framework boundary: the mutation, and - * what to do once the password has changed. The form no longer imports a server - * action or `@/lib/navigation`, so a TanStack Start route renders exactly the - * card the Next.js page renders. - * - * `link` is already parsed - see `../recovery-link.ts`. A route that could not - * parse one must render the request form instead, which is a decision for the - * page rather than for this component: there is no such thing as this screen - * without a link. - * - * No captcha: the API's change-password route does not ask for one - * (`withCaptcha` is absent), because the token in the link is the thing being - * checked. - */ export const ChangePasswordFormContent = ({ link, onChanged, diff --git a/packages/vitnode/src/views/auth/password-reset/change-password-form/schema.ts b/packages/vitnode/src/views/auth/password-reset/change-password-form/schema.ts index 8f9d087c7..275fb3a09 100644 --- a/packages/vitnode/src/views/auth/password-reset/change-password-form/schema.ts +++ b/packages/vitnode/src/views/auth/password-reset/change-password-form/schema.ts @@ -5,17 +5,6 @@ import type { RecoveryLink } from "../recovery-link"; import { createPasswordZodSchema } from "../../sign-up/form/schema"; -/** - * The "choose a new password" form's shape and its failure vocabulary, with no - * React in sight. - * - * The password rules are *imported* rather than restated. They are the - * registration form's rules - one function of two translated strings in - * `sign-up/form/schema.ts` - and a second copy here would be a second answer to - * "what is a strong enough password", which is precisely the kind of pair that - * drifts. - */ - export type ChangePasswordFormMessages = PasswordFieldMessages; export const createChangePasswordFormSchema = ( @@ -30,40 +19,11 @@ export type ChangePasswordFormSchema = ReturnType< >; export type ChangePasswordFormValues = z.infer<ChangePasswordFormSchema>; -/** - * What the form sends: the new password, plus the link it is acting on. - * - * The link travels as a {@link RecoveryLink} - already parsed, `userId` already - * a number - rather than as the raw search parameters, so a screen cannot hand - * the transport a `userId` of `"abc"` and no layer has to coerce one. See - * `../recovery-link.ts`. - */ export type ChangePasswordSubmitValues = RecoveryLink & { password: string }; -/** - * What the API told us about a password change. - * - * `undefined` is success. `'invalid_token'` is the API's `400`: the row it looks - * up by `userId` + `token` + an unexpired `expiresAt` was not there, which means - * the link was wrong, already used, or older than thirty minutes. It is kept - * apart from the generic failure because it is the one a visitor can act on - - * ask for a fresh link - whereas a `500` is nothing they can do anything about. - * - * The API's own message (`"Invalid token"`) never travels; only this literal - * does. - */ export type ChangePasswordMutationResult = undefined | { message: "internal_server_error" | "invalid_token" }; -/** - * What a submit result means for the screen. - * - * - `"success"` - raise the success toast and leave for the login page. The API - * does *not* sign the visitor in (`users/routes/change-password.route.ts` - * mints no session), so the next step is genuinely to log in. - * - `"toast"` - a failure toast, with `reason` deciding which message. The form - * stays where it is either way. - */ export const changePasswordFormOutcome = ( result: ChangePasswordMutationResult, ): diff --git a/packages/vitnode/src/views/auth/password-reset/change-password-form/use-change-password-form.ts b/packages/vitnode/src/views/auth/password-reset/change-password-form/use-change-password-form.ts index 2c2e5bc70..1a3dbf669 100644 --- a/packages/vitnode/src/views/auth/password-reset/change-password-form/use-change-password-form.ts +++ b/packages/vitnode/src/views/auth/password-reset/change-password-form/use-change-password-form.ts @@ -19,39 +19,10 @@ import { export type { ChangePasswordSubmitValues }; -/** - * How the form sets a new password. - * - * The whole of the framework boundary for password recovery's second half. It - * takes the new password together with the already-parsed link it is acting on, - * and answers what happened. Next.js calls a server action; TanStack Start calls - * a server function. - */ export type ChangePasswordSubmit = ( values: ChangePasswordSubmitValues, ) => Promise<ChangePasswordMutationResult>; -/** - * The change-password form's behaviour, with no idea which framework is - * rendering it. - * - * Two props, and both are things this side cannot answer: - * - * - `onChangePassword` - the mutation. - * - `onChanged` - where to go afterwards. The API mints **no session** on a - * successful change, so the visitor is still signed out and the only sensible - * destination is the login page - but *how* to get there is `useRouter().replace` - * in Next.js and a router navigation in TanStack Start, so the caller does it. - * - * `link` is a {@link RecoveryLink}, which means it has already been through - * `parseRecoveryLink`: this hook never sees a raw search parameter and never - * coerces one. - * - * The toasts stay on this side deliberately - they are the same two messages for - * the same two reasons in both frameworks. An expired or already-used link gets - * the `400` copy rather than the generic internal-error copy, because it is the - * one failure a visitor can act on: ask for a fresh link. - */ export const useChangePasswordForm = ({ link, onChanged, diff --git a/packages/vitnode/src/views/auth/password-reset/form/password-reset-form-content.tsx b/packages/vitnode/src/views/auth/password-reset/form/password-reset-form-content.tsx index ff328774e..e32753b2f 100644 --- a/packages/vitnode/src/views/auth/password-reset/form/password-reset-form-content.tsx +++ b/packages/vitnode/src/views/auth/password-reset/form/password-reset-form-content.tsx @@ -26,13 +26,6 @@ import { export type { PasswordResetSubmit }; -/** - * "We have sent you a link", and the address it went to. - * - * Shown for every accepted request, including one for an address with no - * account: the API answers `201` either way, so this screen is the only thing a - * visitor - or somebody probing for registered addresses - ever sees. - */ const ConfirmationView = ({ email }: { email: string }) => { const t = useTranslations("core.auth.reset_password"); const tSignUp = useTranslations("core.auth.sign_up"); @@ -63,14 +56,6 @@ const ConfirmationView = ({ email }: { email: string }) => { ); }; -/** - * The first half of password recovery - shared. - * - * One field, one captcha and one callback: {@link PasswordResetSubmit} is the - * only framework-specific part, and it is a prop. The form no longer imports a - * server action, so a TanStack Start route renders exactly the card the Next.js - * page renders. - */ export const PasswordResetFormContent = ({ captcha, onRequestReset, diff --git a/packages/vitnode/src/views/auth/password-reset/form/schema.ts b/packages/vitnode/src/views/auth/password-reset/form/schema.ts index 9374424fa..5bdf9244b 100644 --- a/packages/vitnode/src/views/auth/password-reset/form/schema.ts +++ b/packages/vitnode/src/views/auth/password-reset/form/schema.ts @@ -1,14 +1,5 @@ import { z } from "zod"; -/** - * The "send me a reset link" form's shape and its failure vocabulary, with no - * React in sight. - * - * One field and two outcomes, so this is a small module - but it is the same - * split the sign-in and sign-up forms make, and it is what lets the interesting - * half be checked without a renderer or a request. - */ - export interface PasswordResetFormMessages { /** Shown when the email field is not an email address. */ invalidEmail: string; @@ -32,34 +23,9 @@ export interface PasswordResetSubmitValues { email: string; } -/** - * What the API told us about a reset request. - * - * `undefined` means accepted - and *only* that. The API deliberately answers - * `201` whether or not the address belongs to an account, and whether or not it - * decided to skip the send because one was already requested in the last five - * minutes (`users/routes/reset-passowrd.route.ts`). That is the product's - * anti-enumeration behaviour, so this type has no shape in which "no such - * account" could be expressed: there is nothing to report but "we have taken - * your request". - * - * `{ message: 'Internal Server Error' }` is a request that did not reach that - * point at all - the transport failed, the rate limiter refused it, the API - * errored - and the screen raises the internal-error toast rather than claiming - * an email is on its way. - */ export type PasswordResetMutationResult = undefined | { message: "Internal Server Error" }; -/** - * What a submit result means for the screen. - * - * - `"confirmation"` - swap the card for "check your email", printing the - * address the visitor typed. Reached for *every* accepted request, which is - * exactly why it reveals nothing. - * - `"toast"` - the internal-error toast; the form stays as it is so the visitor - * can try again. - */ export const passwordResetFormOutcome = ( result: PasswordResetMutationResult, ): { kind: "confirmation" } | { kind: "toast" } => diff --git a/packages/vitnode/src/views/auth/password-reset/form/use-password-reset-form.ts b/packages/vitnode/src/views/auth/password-reset/form/use-password-reset-form.ts index 79831e2d4..7910e6c42 100644 --- a/packages/vitnode/src/views/auth/password-reset/form/use-password-reset-form.ts +++ b/packages/vitnode/src/views/auth/password-reset/form/use-password-reset-form.ts @@ -19,28 +19,10 @@ import { export type { PasswordResetSubmitValues }; -/** - * How the form asks for a reset link. - * - * The whole of the framework boundary for password recovery's first half: an - * address and a captcha token in, "it was accepted" or "it failed" out. Next.js - * calls a server action; TanStack Start calls a server function. Neither is - * imported here. - */ export type PasswordResetSubmit = ( values: PasswordResetSubmitValues, ) => Promise<PasswordResetMutationResult>; -/** - * The reset-request form's behaviour, with no idea which framework is rendering - * it. - * - * `sentEmail` is the whole of its state, and it is local on purpose: the - * confirmation screen prints the address the visitor typed, which this side - * already has, so nothing needs to come back from the server for it. Which is - * also what makes the screen say the same thing for an address that exists and - * one that does not. - */ export const usePasswordResetForm = ({ onRequestReset, }: { diff --git a/packages/vitnode/src/views/auth/password-reset/password-reset-content.tsx b/packages/vitnode/src/views/auth/password-reset/password-reset-content.tsx index 0fdb45d47..1d0c6c90d 100644 --- a/packages/vitnode/src/views/auth/password-reset/password-reset-content.tsx +++ b/packages/vitnode/src/views/auth/password-reset/password-reset-content.tsx @@ -1,17 +1,6 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; -/** - * The card both recovery screens live in - shared. - * - * Thin on purpose: the two forms render their own `CardHeader` and - * `CardContent`, so all this owns is the page's measure and the card around it. - * It exists so that "the reset-password page" is one layout rather than two that - * have to be kept looking alike, in the same way `SignInContent` is. - * - * Not a client component. It has no hooks and no strings, which lets the Next.js - * page keep rendering it on the server with its `<Suspense>` boundary inside. - */ export const PasswordResetContent = ({ children, }: { diff --git a/packages/vitnode/src/views/auth/password-reset/recovery-link.ts b/packages/vitnode/src/views/auth/password-reset/recovery-link.ts index f7cbbf758..149a5fa61 100644 --- a/packages/vitnode/src/views/auth/password-reset/recovery-link.ts +++ b/packages/vitnode/src/views/auth/password-reset/recovery-link.ts @@ -1,64 +1,9 @@ -/** - * The two values a password-recovery email puts in the URL, judged before - * anything is done with them. - * - * `/login/reset-password?token=...&userId=...` is a link in an email, which means - * the query is the least trustworthy input on the recovery screens: anyone can - * craft one, and the page decides *which form to render* from whether both - * values are present. So the rule is a schema rather than a truthiness check, - * and it lives here - pure, framework-free, with no React and no fetcher - so - * both the Next.js view and a TanStack Start route reach the same verdict from - * the same code. - * - * ## What it is not - * - * Not authentication. The API is the boundary and stays the boundary: it looks - * the row up by `userId` *and* `token` *and* an unexpired `expiresAt`, and - * answers `400 Invalid token` when any of the three does not match - * (`users/routes/change-password.route.ts`). Nothing here can grant a password - * change; it only decides whether a request is worth making at all, and stops a - * crafted URL from turning into a request carrying an unbounded string or a - * `userId` the API would have to coerce. - * - * ## Why the rule is written out rather than declared with `zod` - * - * Because of *where this runs*. A TanStack Start route's `validateSearch` and - * `loaderDeps` are evaluated during path matching, before any chunk is fetched, - * so they live in the client entry - the bundle every page of the application - * downloads first. `passwordResetMode` calls this function from `loaderDeps`, - * which made this module the client entry's only reason to hold `zod`: 112 kB - * of schema library, on the critical path of the front page, for two values on - * one auth route. Measured on vitnode.com, removing this edge was the single - * largest saving left after the route graph itself was split. - * - * The rules below are the same rules, in the same order, with the same answers - - * `recovery-link.test.ts` pins every case the schema version was written - * against, including the ones that only differ if the coercion moves - * (`Number("")` is `0`, `Number(true)` is `1`, and `Number("9007199254740993")` - * lands one past the safe range). `zod` remains the right tool everywhere it is - * not being paid for by a page that never reaches this flow: the auth contract's - * mutation inputs, the API's route schemas and the Content Engine all keep it. - */ - /** A recovery link this app is willing to act on. */ export interface RecoveryLink { token: string; userId: number; } -/** - * The recovery token, as it may appear in a URL. - * - * The API generates it as `randomBytes(32).toString("base64url")` - 43 - * characters of `[A-Za-z0-9_-]` - so the character class is a true statement - * about the value rather than a guess, and it is what excludes whitespace, - * control characters and path separators. The length bounds are deliberately - * loose around the real 43 so a change to the API's token generation widens - * rather than breaks this. - * - * Anchored at both ends, which is what makes the class exhaustive: an unanchored - * test would accept `../../<token>` on the strength of the token inside it. - */ const RECOVERY_TOKEN_PATTERN = /^[A-Za-z0-9_-]+$/; const RECOVERY_TOKEN_MIN_LENGTH = 16; const RECOVERY_TOKEN_MAX_LENGTH = 512; @@ -71,22 +16,6 @@ const recoveryToken = (value: unknown): null | string => ? value : null; -/** - * The account the link belongs to. - * - * A query parameter arrives as a string, and `Number("")` is `0` while - * `Number(true)` is `1` - so the digits are checked *before* the coercion rather - * than after, and only a string of digits or an actual number is accepted. The - * cap is `Number.MAX_SAFE_INTEGER` because past it two different ids compare - * equal, which is not a value to send to a lookup - and it is checked after the - * coercion, because that is where the collapse happens: - * `Number("9007199254740993")` is `9007199254740992`, one past the range. - * - * `Number.isInteger` is what rejects `NaN`, `Infinity` and a fractional number - * that arrived as a number rather than as a string - the router's default search - * parsing is `JSON.parse` per value, so `?userId=1.5` reaches this as the number - * `1.5` and never as a string. - */ const RECOVERY_USER_ID_PATTERN = /^\d+$/; const recoveryUserId = (value: unknown): null | number => { @@ -105,18 +34,6 @@ const recoveryUserId = (value: unknown): null | number => { : null; }; -/** - * The link's two values, normalised, or `null`. - * - * `null` is the answer for every unusable shape - missing, empty, malformed, out - * of range - because the screens have exactly one thing to do about all of them: - * render the "request a reset link" form instead of the "choose a new password" - * one. Which is what the Next.js view already does with `if (token && userId)`, - * only spelled as a rule that a crafted `?token=%20&userId=0` cannot walk past. - * - * Both halves are judged before either is used, and a single `null` fails the - * pair: there is no shape in which half a link travels onward. - */ export const parseRecoveryLink = (input: { token?: unknown; userId?: unknown; diff --git a/packages/vitnode/src/views/auth/settings/devices/device-item.tsx b/packages/vitnode/src/views/auth/settings/devices/device-item.tsx index c9afe64a4..aedfa91d7 100644 --- a/packages/vitnode/src/views/auth/settings/devices/device-item.tsx +++ b/packages/vitnode/src/views/auth/settings/devices/device-item.tsx @@ -19,20 +19,6 @@ const icons = { tablet: TabletIcon, } as const; -/** - * One device, as a card both frameworks render. - * - * Everything that used to make this a Next.js Server Component has been taken - * out: it no longer awaits `getTranslations`, and the revoke it offers arrives as - * a prop instead of being imported. What is left is the part that was always - * worth sharing - the icon, the current-device badge, the relative last-seen - * date, the three details and the layout of all of it. - * - * The row is handed over whole rather than spread as eight props, which is what - * lets `isRevokableDevice` read it: the rule about the current device is one - * statement in `devices-revoke.ts` and this is the only place it is applied to a - * button. - */ export const DeviceItem = ({ device, onRevoke, 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 index 6a7a78bb0..5b5d1ba5d 100644 --- a/packages/vitnode/src/views/auth/settings/devices/devices-boundaries.test.ts +++ b/packages/vitnode/src/views/auth/settings/devices/devices-boundaries.test.ts @@ -14,18 +14,6 @@ import { const here = dirname(fileURLToPath(import.meta.url)); -/** - * `/settings/devices`, split down the middle. - * - * The same boundary `files-boundaries.test.ts` and `auth-boundaries.test.ts` - * draw, with the same machinery and for the same reason: a shared module that - * reaches `next/headers`, a server action or `@/lib/navigation` cannot be loaded - * by a TanStack Start route, and nothing about that failure is visible until - * somebody tries. A scan is the only way to state it, because the offending - * import is usually two files away from the one being written - this feature's - * would have been the server action, imported by the revoke button, behind the - * list. - */ const SHARED = { item: join(here, "device-item.tsx"), list: join(here, "devices-content.tsx"), @@ -36,13 +24,7 @@ const SHARED = { }; /** The Next.js half: `next/navigation`, `next/cache`, `fetcher()`, the action. */ -/** - * The Next.js half, by path, so its absence can be asserted. - * - * Named rather than deleted along with the assertions that used them: each was - * the one place a Next.js API was allowed to appear in this subtree, and a test - * that stops naming them cannot notice one coming back. - */ + const DELETED_NEXT_HALF = { list: join(here, "devices-list.tsx"), page: join(here, "devices.tsx"), diff --git a/packages/vitnode/src/views/auth/settings/devices/devices-content.tsx b/packages/vitnode/src/views/auth/settings/devices/devices-content.tsx index 4b7517458..7aabe518e 100644 --- a/packages/vitnode/src/views/auth/settings/devices/devices-content.tsx +++ b/packages/vitnode/src/views/auth/settings/devices/devices-content.tsx @@ -7,36 +7,6 @@ import type { RevokeDevice } from "./devices-revoke"; import { DeviceItem } from "./device-item"; -/** - * The visitor's devices, as a list both frameworks render. - * - * The presentation half of `/settings/devices`, and the whole of it: the cards, - * the spacing between them, and the sentence that stands in for an empty list. - * - * Next.js devices-list.tsx fetch + notFound + server action - * TanStack Start routes/.../settings/devices loader + useSuspenseQuery + browser revoke - * \ / - * DevicesContent - * - * ## What it does not own - * - * **Fetching.** It is handed a list. Which list, and how it was fetched, is - * `devices-query.ts`'s - the same definition a TanStack loader warms and a - * Next.js Server Component awaits. That is also why an API failure never reaches - * here: it is a rejected query, not an empty array, so this component's "no - * devices" state means only that the API said so. - * - * **Revoking.** One callback, because the two frameworks genuinely differ: one - * ends in `revalidatePath`, the other in a query invalidation, and neither can - * exist in the other's runtime. The request, the status mapping and the rule - * about the current device are shared - see `devices-revoke.ts`. - * - * **The heading.** Deliberately outside, in each framework's own page. The - * Next.js page renders `HeaderContent` above a `<Suspense>` whose fallback is - * `DevicesListSkeleton`, so the title is on screen while the list is still - * streaming; folding the heading in here would put it behind the same boundary - * and lose that. - */ export const DevicesContent = ({ devices, onRevoke, 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 index 17dc8c7a1..5fa1873a7 100644 --- a/packages/vitnode/src/views/auth/settings/devices/devices-query.test.ts +++ b/packages/vitnode/src/views/auth/settings/devices/devices-query.test.ts @@ -16,16 +16,6 @@ import { shouldRefreshAfterRevoke, } from "./devices-revoke"; -/** - * The pure half of the devices contract. - * - * Everything below is a function over plain values: a request is built, a - * response status becomes either a list or an error, a revoke's status becomes a - * result, and a result becomes a yes-or-no about refreshing. Nothing here opens a - * socket or renders a component - the API has its own suite, and how the cards - * look is Playwright's. - */ - 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]); @@ -45,15 +35,6 @@ describe("one list per visitor, one cache entry each", () => { ); }); - /** - * The privacy invariant, as the key contract rather than as a browser test. - * - * The browser's `QueryClient` outlives a sign-out, so one document can hold - * two visitors. Under the `["devices", "me"]` this replaces, B's loader asked - * for the entry A had already filled - and with `refetchOnMount` off, nothing - * refetched it, so no request was made and Hono never saw the read it would - * have refused. - */ 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))); @@ -78,18 +59,6 @@ describe("one list per visitor, one cache entry each", () => { }); }); -/** - * The other half of the same rule: the id is a cache address, not a claim. - * - * It is not asserted here any more, because it is no longer assertable - it is - * enforced. Both calls are written inline at their fetchers, so `FetcherParams` - * decides what may travel: `GET /devices` declares no body, params or query, so - * an `args` on it does not compile, and the revoke declares `publicId` and - * nothing else, so a second key is an excess-property error. A type that - * forbids the value is a stronger statement than a test that looks for its - * absence. - */ - 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", diff --git a/packages/vitnode/src/views/auth/settings/devices/devices-query.ts b/packages/vitnode/src/views/auth/settings/devices/devices-query.ts index 4ae0d2f80..00647dcd1 100644 --- a/packages/vitnode/src/views/auth/settings/devices/devices-query.ts +++ b/packages/vitnode/src/views/auth/settings/devices/devices-query.ts @@ -6,41 +6,6 @@ import { CONFIG_PLUGIN } from "@/config"; import { clientModule, fetcherClient } from "@/lib/fetcher-client"; import { RECORD_STALE_TIME } from "@/lib/query-freshness"; -/** - * The devices the signed-in visitor is logged in on, as one query definition. - * - * Everything about *what* that list is lives here and nowhere else: the request, - * the shape that comes back, what counts as a refusal, and the cache entry the - * whole thing lands in. A view renders whatever this produces and owns none of - * it. - * - * The split is the one `my-files-query.ts` already paid for. When a component - * built one request and a loader built another, the two agreed on the cache key - * and on nothing else - so the server-rendered page came from one contract and - * every navigation after hydration came from a second one with different - * defaults and no status checking. Sharing a key is not sharing a contract. - * - * The one thing deliberately *not* fixed here is the transport: a loader running - * on a server and a component running in a browser cannot reach the API the same - * way. So {@link devicesQueryOptions} takes a `fetchDevices` and defaults it to - * the browser's, which is the only one a shared module can assume. - * - * ## Hono is still the boundary - * - * Nothing below authorizes anything. `GET /api/@vitnode/core/users/devices` - * derives the user from the session cookie, scopes the query to their sessions, - * and marks the row matching the device cookie as `isCurrent` - so a request - * this module builds for a visitor who has just been signed out comes back `401`, - * and {@link DevicesRequestError} is what makes that a failed query rather than - * an empty list. - */ - -/** - * The users module as a value the fetchers can carry without pulling the API - * into either bundle. The module is imported as a *type* only, so route - * literals, methods and response schemas all still infer; `clientModule` - * supplies the one field the fetcher reads at runtime. - */ export const usersModuleRef = clientModule<typeof usersModule>( CONFIG_PLUGIN.pluginId, ); @@ -49,34 +14,12 @@ export const usersModuleRef = clientModule<typeof usersModule>( export const DEVICE_TYPES = ["desktop", "tablet", "mobile"] as const; export type DeviceType = (typeof DEVICE_TYPES)[number]; -/** - * One row of the list, as JSON delivers it. - * - * `expiresAt` and `lastSeen` are declared as `Date | string` because both are - * true: the route's schema says `z.date()` and a Next.js Server Component that - * awaited the fetcher is handed exactly that, while anything that crossed the - * wire as JSON - the browser fetch, and the dehydrated SSR payload a TanStack - * Start page rehydrates - has an ISO string. `DateFormat` accepts either, which - * is why this is a widened type rather than a normalisation step. - */ export interface Device { browser: string; deviceType: DeviceType; expiresAt: Date | string; ipAddress: string; - /** - * Whether this row is the session doing the asking. - * - * The API decides it, by comparing each row's `publicId` to the device cookie - * on the request - so it is a property of *this* request rather than of the - * device, and it is the reason the cookie has to reach the API on both - * transports. A render that forwarded no cookie would mark every row - * `isCurrent: false` and offer to revoke the session doing the rendering. - * - * `DELETE /users/devices/{publicId}` refuses that with a `400` regardless, so - * this flag is what the list uses to not offer the button - not the rule - * itself. See {@link isRevokableDevice}. - */ + isCurrent: boolean; lastSeen: Date | string; os: string; @@ -88,54 +31,12 @@ export interface DevicesApi { devices: Device[]; } -/** - * The list, as arguments to whichever fetcher is carrying it. - * - * No parameters at all: the route takes none, and derives whose devices these - * are from the session cookie. - * - * Worth reading against {@link devicesQueryKey}, which *does* carry a user id. - * The two are not in tension - the key says which cache slot an answer is filed - * under, this says what is asked for, and only the cookie says whose devices - * come back. Adding an owner here would move authorization onto a value the - * browser supplies. - */ /** How the list is actually fetched. See {@link devicesQueryOptions}. */ export type DevicesFetcher = () => Promise<DevicesApi>; /** The `name` every {@link DevicesRequestError} carries. See below. */ const DEVICES_REQUEST_ERROR = "DevicesRequestError"; -/** - * The devices API refused, and this is what it refused with. - * - * A thrown error rather than a returned one, because the alternative is the bug - * this class exists to prevent. `getDevicesApi()` - the module this replaces - - * called `res.json()` on whatever came back, and a `401`, `403` or `429` body - * parses perfectly happily; read as a list it has no `devices`, so the page - * rendered "No active devices." A visitor whose session had just ended, or who - * had tripped the rate limiter, was told they were signed in nowhere - which is - * the single most alarming thing this page can say, and it was saying it about - * an outage. - * - * `status` is on the error rather than folded into the message so a caller can - * tell the finite cases apart without parsing English: `401` and `403` mean the - * session ended or was never allowed - the route guard is a navigation rule, not - * the boundary, so this is the *authorization* answer and it can arrive on a - * page the guard already let through. `429` is the rate limiter. A `500` never - * reaches here at all: `rawApiFetch` throws on those with the body attached. - * - * Deliberately *not* a redirect to the login page. A failed read is not a - * signed-out visitor - the same rule `#/lib/session` states at length - and the - * guard on the route already owns that decision from the one canonical session - * entry. Turning every API failure into a sign-out is how a rate limit becomes a - * logout. - * - * Recognised by `name` rather than by `instanceof`, and that is not fussiness. - * `@vitnode/core` is imported from `dist` by the apps and from `src` by its own - * tests, so two copies of this class can exist in one process and `instanceof` - * would answer `false` across them. - */ export class DevicesRequestError extends Error { constructor(status: number) { super(`The devices API answered ${status} for the current user's devices.`); @@ -151,14 +52,6 @@ export const isDevicesRequestError = ( ): error is DevicesRequestError => error instanceof Error && error.name === DEVICES_REQUEST_ERROR; -/** - * The list, fetched from the browser. - * - * `fetcherClient` builds the same same-origin `/api/@vitnode/core/users/devices` - * URL every other VitNode client call uses, so the browser attaches the session - * and device cookies itself - which is what makes `isCurrent` correct - and a - * `429` is routed to the global rate-limit notice on the way through. - */ export const fetchDevicesInBrowser: DevicesFetcher = async () => { const response = await fetcherClient(usersModuleRef, { method: "get", @@ -171,106 +64,11 @@ export const fetchDevicesInBrowser: DevicesFetcher = async () => { return await response.json(); }; -/** - * Every visitor's devices, as one prefix above the per-owner entries. - * - * {@link devicesQueryKey} is one owner's entry - the thing a revoke invalidates. - * This is the prefix above all of them, and its only caller is the public - * identity cleanup in `tanstack/auth/queries`: a sign-out cannot name whose - * partition to drop, because the point is that none of them stays behind. - * - * The entry it collects is the most sensitive private read in the public app - - * operating systems, browsers, IP addresses and sign-in times - so leaving one - * in a browser for `gcTime` after its owner signed out is exactly the residency - * the AdminCP has refused since Stage 12. Partitioning by owner (below) already - * stops the *next* visitor reading it; this is what stops it being there at all. - */ export const DEVICES_IDENTITY_ROOT = ["devices", "user"] as const; -/** - * The cache entry one visitor's list reads and writes, and the target an - * invalidation names. - * - * A factory over the owner's id rather than the constant `["devices", "me"]` it - * replaces. The reasoning was wrong in one specific way and it is worth keeping - * the correction visible: it argued that the request carries no user, so the key - * needs none, and that "the QueryClient is per request on the server and per - * browser on the client, so there is no client holding two visitors' lists". - * - * The last clause is the mistake. *Per browser* is not per visitor - the browser - * client is created once per document and outlives a sign-out: - * - * A signs in -> /settings/devices -> ["devices","me"] holds A's devices - * A signs out - * B signs in -> /settings/devices -> the loader asks for the same entry - * - * which is already populated, and with `refetchOnMount` off nothing refetches - * it. B would be shown A's operating systems, browsers and IP addresses without - * a single request being made - so Hono never sees the read it would have - * refused. Keyed by owner, B's entry is empty and the fetch happens. - * - * The locale is deliberately absent. Operating system, browser, IP address and - * both timestamps are the same data in every language; the only translated - * things on the page are the labels and the relative date, which the renderer - * resolves from the provider it is under. A locale in the key would mean a - * language switch silently refetched a list that had not changed. - * - * ## The id addresses a cache, it does not identify a caller - * - * `GET /users/devices` still takes no parameters and still derives the user from - * the session cookie - {@link devicesRequest} is unchanged. So this id decides - * which cache slot the answer is filed under and authorizes nothing; sending it - * would turn a cache key into an access-control parameter, which is the one - * thing it must never become. - * - * There is one entry per visitor and it has no sub-keys, so this is both the key - * and the family an invalidation names. - */ export const devicesQueryKey = (userId: number) => [...DEVICES_IDENTITY_ROOT, userId] as const; -/** - * The visitor's devices, as the one query definition every caller shares. - * - * A route loader warms it before the component renders: - * - * context.queryClient.ensureQueryData( - * devicesQueryOptions({ fetchDevices, userId }), - * ) - * - * and the component reads the very same options back: - * - * const { data } = useSuspenseQuery(devicesQuery(userId)) - * - * Same key, same request, same status checking - so the loader's list is the - * list the component renders, and a revoke that invalidates - * {@link devicesQueryKey} refetches through the identical contract. - * - * `userId` addresses the cache and nothing else - see {@link devicesQueryKey}. - * It is required, and the whole parameter object with it, because there is no - * honest default: falling back to a shared entry is the bug this closes. Both - * callers take it from the one place that knows it, the `_authenticated` route - * context, so the loader and the component cannot land on two partitions. - * - * `fetchDevices` is the seam. It defaults to the browser's fetcher, which is what - * a hydrated page wants; an app that also fetches during SSR passes one that can - * do both. It is a plain async function rather than anything framework-shaped, so - * nothing about this module knows which framework is rendering it. - * - * ## It asks once - * - * `retry: false`, against Query's default of three attempts. Every failure this - * read can produce is made worse by repeating it: a `429` is answered by sending - * the same request two more times, which is the thing the limiter is asking this - * app to stop doing, and a `401` is not going to become a `200` because we asked - * again. The visitor retries by reloading - a decision they can make and a rate - * limiter can see coming. - * - * No `staleTime`. Freshness is whatever the API's own caching gives, plus - * VitNode's client defaults (`refetchOnMount` and `refetchOnWindowFocus` both - * off), so a hydrated list is not refetched behind the reader; a revoke is what - * makes it stale, explicitly. - */ export const devicesQueryOptions = ({ fetchDevices = fetchDevicesInBrowser, userId, @@ -288,11 +86,4 @@ export const devicesQueryOptions = ({ staleTime: RECORD_STALE_TIME, }); -/** - * What the shared list accepts, and the reason it accepts only this. - * - * Typed as the factory's own return type on purpose: a caller cannot hand the - * list a hand-rolled options object that happens to type-check, so "one query - * definition" is enforced by the compiler rather than by review. - */ export type DevicesQueryOptions = ReturnType<typeof devicesQueryOptions>; diff --git a/packages/vitnode/src/views/auth/settings/devices/devices-revoke.ts b/packages/vitnode/src/views/auth/settings/devices/devices-revoke.ts index 9cce8a563..969099992 100644 --- a/packages/vitnode/src/views/auth/settings/devices/devices-revoke.ts +++ b/packages/vitnode/src/views/auth/settings/devices/devices-revoke.ts @@ -4,59 +4,11 @@ import type { Device } from "./devices-query"; import { usersModuleRef } from "./devices-query"; -/** - * Signing one device out, as a contract both frameworks satisfy. - * - * The API already accepts an authenticated `DELETE` from anywhere: it derives the - * user from the session cookie, scopes the lookup to their own sessions, and - * refuses the device the request itself is coming from. So the browser calls it - * directly - same origin, cookie attached by the browser itself - and there is - * deliberately no server function in between. A server function here would be a - * `POST` back to the app that then calls Hono, which is two round trips and a - * second place to get the semantics wrong, in exchange for nothing: this - * mutation needs no server-only secret, and it sets no cookie that would have to - * be copied onto a response. - * - * The Next.js app keeps its server action, which is not a contradiction. There - * the revoke has to end with `revalidatePath`, and that only exists on a server; - * see `revoke-action.server.ts`. What both sides share is the *shape* - the - * callback type below, the request, the status mapping and the refresh rule - so - * one list component can be handed either. - * - * ## What it cannot do - * - * Revoke the current device. `DELETE /users/devices/{publicId}` compares the id - * to the requester's own device cookie and answers `400` before it deletes - * anything, so there is no path through this module that can end the session - * making the call. That is why nothing here touches the session cache: the one - * mutation that would invalidate it is the one the API refuses. See - * {@link isRevokableDevice} and {@link REVOKE_CURRENT_DEVICE_STATUS}. - * - * The guard has no gap, and that is worth stating because "the device cookie was - * missing, so no row was current" would be one. `SessionModel.getUser()` - which - * is what fills `c.get("user")` for every request - resolves the device from that - * same cookie and looks the session up by `(token, deviceId)`. A request with no - * usable device cookie therefore has no user at all and is answered `401` before - * either route reads the cookie. So on every response these two routes can - * actually produce, the cookie names the device holding the requesting session: - * exactly one row is `isCurrent`, and it is precisely the one that cannot be - * revoked. - */ - /** Signing out one device. The id is the row's own `publicId`. */ export interface RevokeDeviceArgs { publicId: string; } -/** - * The finite outcome of one revoke. - * - * A closed result rather than a rejection, so a Next.js server action and a - * browser fetch are the same prop: the caller is standing in a confirm dialog - * and has to say something either way. `status` carries which refusal it was, - * because the three that matter read differently - see - * {@link REVOKE_CURRENT_DEVICE_STATUS}. - */ export interface RevokeDeviceResult { data?: true; error?: { @@ -64,97 +16,23 @@ export interface RevokeDeviceResult { }; } -/** - * What the shared list is handed instead of a mutation. - * - * A plain async function returning a closed result. Nothing framework-shaped - * survives in either direction. - */ export type RevokeDevice = ( args: RevokeDeviceArgs, ) => Promise<RevokeDeviceResult>; -/** - * The status the API answers when asked to revoke the device doing the asking. - * - * Named rather than spelled `400` at the call site because it is the one refusal - * with a meaning instead of a cause: the request was well-formed and the device - * exists, and the answer is "not that one". The list does not offer the button - * for it, so reaching this means the row was stale - the same device cookie was - * re-issued, or another tab signed in - and the honest repair is to refetch, - * which is what {@link shouldRefreshAfterRevoke} does. - */ export const REVOKE_CURRENT_DEVICE_STATUS = 400; -/** - * Whether a row may be signed out at all. - * - * The current device may not, and the API is the one enforcing it. This is the - * *display* half of that rule, kept next to the request so the two cannot drift: - * a list that offered the button anyway would put a `400` behind it, and the only - * thing the person would learn is that something went wrong. - */ export const isRevokableDevice = (device: Pick<Device, "isCurrent">): boolean => !device.isCurrent; -/** - * The public ids this module will send, and the shape of one it will not. - * - * `randomBytes(16).toString("hex")` is what `DeviceModel` mints, so a real id is - * 32 hex characters; the pattern is deliberately wider than that - any URL-safe - * token up to 128 characters - so a deployment whose ids were minted by an - * earlier scheme keeps working. What it rules out is the two shapes that are - * never an id and would be sent into a path segment: empty, and anything - * carrying `/`, `.` or a percent-escape. - * - * Refusing locally rather than letting the API answer is the point. The route's - * own `z.string()` accepts `""` and `../session`, and the fetcher interpolates - * the value into `/devices/{publicId}` - so an empty id addresses the *list* - * route with a `DELETE` and a traversal addresses a sibling. Both come back as - * some other status, which the dialog would report as a mysterious failure. - */ const DEVICE_PUBLIC_ID = /^[A-Za-z0-9_-]{1,128}$/; export const isDevicePublicId = (publicId: string): boolean => DEVICE_PUBLIC_ID.test(publicId); -/** - * One revoke, as arguments to whichever fetcher is carrying it. - * - * Shared with the Next.js server action, so a revoke is the same request in both - * applications rather than two places that merely look alike. - */ -/** - * The result a refused status becomes. - * - * Its own function because both transports have to agree on it, and because - * "which statuses count as done" is the kind of rule that grows a second - * spelling the moment it is inlined twice. `200` is the only success the route - * declares - it answers with an empty body - and everything else is the status, - * verbatim, for the caller to phrase. - */ export const revokeResultFromStatus = (status: number): RevokeDeviceResult => status === 200 ? { data: true } : { error: { status } }; -/** - * Signs one device out from the browser. - * - * Never rejects, and that is the contract rather than an oversight. Every way - * this can fail is something the person has to be told in the dialog they are - * standing in, and a rejected promise would have to be caught by every caller to - * say the same thing. - * - * The `catch` is why the `500` case is not special: `rawApiFetch` throws on those - * with the failing URL and the server's own error text attached, and that throw - * is a server error like any other - reported as `status: 500`, not as a crashed - * dialog. - * - * A locally-refused id is reported as `400`, which is both the honest status - - * the request was malformed, and never sent - and the same one the route's own - * schema would have produced had it been. It coincides with - * {@link REVOKE_CURRENT_DEVICE_STATUS} and that costs nothing: both mean the row - * on screen does not match the server, and both are answered by refetching. - */ export const revokeDeviceInBrowser: RevokeDevice = async ({ publicId }) => { if (!isDevicePublicId(publicId)) return { error: { status: 400 } }; @@ -173,26 +51,6 @@ export const revokeDeviceInBrowser: RevokeDevice = async ({ publicId }) => { } }; -/** - * Whether a finished revoke changed what the list is showing. - * - * Two cases, and the second is the one worth stating: - * - * - **It worked.** The row is gone, so the list is stale. - * - **`404`, or `400`.** The row was already wrong. A device somebody else - * revoked first is a `404`, and a row the list believed was revokable but the - * API considers current is a `400` - in both cases what is on screen does not - * match the server, and refetching is the repair. - * - * A `401`, `403`, `429` or `500` is deliberately *not* a refresh. Nothing was - * deleted, and 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 second `401` that blanks the list the person is looking - * at. The dialog says it failed and the list stays exactly as it was. - * - * The Next.js action applies the same rule before it calls `revalidatePath`, so - * both frameworks refresh on the same condition. - */ export const shouldRefreshAfterRevoke = ({ data, error, diff --git a/packages/vitnode/src/views/auth/settings/devices/revoke-device-button.tsx b/packages/vitnode/src/views/auth/settings/devices/revoke-device-button.tsx index bd28eba97..16fe96f8d 100644 --- a/packages/vitnode/src/views/auth/settings/devices/revoke-device-button.tsx +++ b/packages/vitnode/src/views/auth/settings/devices/revoke-device-button.tsx @@ -9,28 +9,6 @@ import { Button } from "@/components/ui/button"; import type { RevokeDevice } from "./devices-revoke"; -/** - * Signing one device out, as a button both frameworks render. - * - * What used to make this Next.js-only was one import: the server action, which - * ends in `revalidatePath` and drags `next/headers` and the whole API module - * graph behind it. It is a prop now - `onRevoke` - so the Next.js page passes - * the action and the TanStack Start route passes a browser fetch that ends in a - * query invalidation, and everything visible here is the same in both. - * - * `useTranslations` from `use-intl` rather than from `next-intl`, for the same - * reason: `next-intl`'s root entry re-exports these APIs and is framework-free, - * but naming it here would be one more thing a non-Next app has to happen to - * resolve. The strings come from whichever provider is above - `I18nProvider` in - * Next.js, `RouteMessages` in TanStack Start - and both mount `core.global` - * alongside `core.auth.settings`, which is what the confirm dialog's own buttons - * need. - * - * The result is *reported*, never thrown. `onRevoke` returns a closed - * `RevokeDeviceResult` in both applications, so this component's whole error - * handling is one branch, and it stays identical whether the failure was a - * refused status or a server that was not listening. - */ export const RevokeDeviceButton = ({ onRevoke, os, diff --git a/packages/vitnode/src/views/auth/settings/nav-content.tsx b/packages/vitnode/src/views/auth/settings/nav-content.tsx index 92a3b47d3..2447115db 100644 --- a/packages/vitnode/src/views/auth/settings/nav-content.tsx +++ b/packages/vitnode/src/views/auth/settings/nav-content.tsx @@ -16,21 +16,6 @@ import type { SettingsNavKey } from "./settings-nav"; import { isSettingsNavItemActive, SETTINGS_NAV_ITEMS } from "./settings-nav"; -/** - * The settings navigation, with the two things it cannot resolve for itself - * handed in. - * - * `pathname` rather than a hook, and `LinkComponent` rather than an import: both - * are the same seam `HeaderContent` and `SearchFeedContent` already draw, and - * both exist for the same reason. `usePathname` and a locale-aware `Link` come - * from `next-intl` in the Next.js app and from the router in TanStack Start, and - * importing either here would make this module Next-only - which is exactly what - * `views/auth/auth-boundaries.test.ts` pins. - * - * The pathname is *internal* - no locale prefix. Each framework's wrapper hands - * over the spelling its own router uses, and nothing here localizes an href - * either: `LinkComponent` does that, once. - */ const ICONS: Record<SettingsNavKey, React.ComponentType> = { devices: MonitorSmartphoneIcon, overview: UserRoundIcon, diff --git a/packages/vitnode/src/views/auth/settings/overview/overview.tsx b/packages/vitnode/src/views/auth/settings/overview/overview.tsx index 1e5530083..6c6988bf9 100644 --- a/packages/vitnode/src/views/auth/settings/overview/overview.tsx +++ b/packages/vitnode/src/views/auth/settings/overview/overview.tsx @@ -4,22 +4,6 @@ import { useTranslations } from "use-intl"; import { HeaderContent } from "@/components/ui/header-content"; -/** - * The overview panel, which is currently a heading. - * - * Rendered by two URLs in each framework: `/settings`, whose root screen shows - * the overview rather than redirecting to it, and `/settings/overview`. See - * `SETTINGS_NAV_ITEMS` for why the root is an alias and not a redirect. - * - * A client component reading `use-intl` rather than a Server Component reading - * `next-intl/server`, which is what lets a TanStack Start route render it: the - * strings come from whichever provider is above it - `I18nProvider` in Next.js, - * `RouteMessages` in TanStack Start - and both mount `core.auth.settings`. - * - * There is deliberately nothing else here. Profile editing, email changes and - * the rest are not features VitNode has yet, and the route name is not a - * specification. - */ export const OverviewSettings = () => { const t = useTranslations("core.auth.settings.nav"); diff --git a/packages/vitnode/src/views/auth/settings/security/security.tsx b/packages/vitnode/src/views/auth/settings/security/security.tsx index 5cde30e18..b38344f42 100644 --- a/packages/vitnode/src/views/auth/settings/security/security.tsx +++ b/packages/vitnode/src/views/auth/settings/security/security.tsx @@ -4,18 +4,6 @@ import { useTranslations } from "use-intl"; import { HeaderContent } from "@/components/ui/header-content"; -/** - * The security panel, which is currently a heading. - * - * A client component reading `use-intl` rather than a Server Component reading - * `next-intl/server`, for the reason `OverviewSettings` explains: it is rendered - * by a Next.js page and by a TanStack Start route, and only one of those has a - * request scope. - * - * Passwords, two-factor enrolment, passkeys and a session log are not features - * VitNode has yet. This file is what `/settings/security` does today, and the - * route name is not a specification. - */ export const SecuritySettings = () => { const t = useTranslations("core.auth.settings.nav"); diff --git a/packages/vitnode/src/views/auth/settings/settings-breadcrumb-content.tsx b/packages/vitnode/src/views/auth/settings/settings-breadcrumb-content.tsx index cf1e8c4c5..81f04bd2d 100644 --- a/packages/vitnode/src/views/auth/settings/settings-breadcrumb-content.tsx +++ b/packages/vitnode/src/views/auth/settings/settings-breadcrumb-content.tsx @@ -7,20 +7,6 @@ export interface SettingsBreadcrumbContentProps { navKey?: SettingsNavKey; } -/** - * One crumb of the settings trail - a label, and nothing else. - * - * The frame at `/settings` contributes "Settings" and each panel contributes its - * own name, so `/settings/devices` reads `Settings / Devices` without either - * route knowing how deep it is. The shell owns the separator, the link and the - * `aria-current`; a crumb that built its own href would need a router, which is - * exactly what a view may not import. - * - * The strings are `core.auth.settings`, the same namespace the panels and the - * navigation read, so a host has one set to warm rather than a second one for - * the crumb - and the panel names come from `…settings.nav`, so the trail and the - * menu name a panel identically in every language. - */ export const SettingsBreadcrumbContent = ({ navKey, }: SettingsBreadcrumbContentProps) => { diff --git a/packages/vitnode/src/views/auth/settings/settings-nav.ts b/packages/vitnode/src/views/auth/settings/settings-nav.ts index 1df8e4f27..384e94885 100644 --- a/packages/vitnode/src/views/auth/settings/settings-nav.ts +++ b/packages/vitnode/src/views/auth/settings/settings-nav.ts @@ -1,53 +1,17 @@ import { normalizeUrl } from "@/lib/utils"; -/** - * The settings screens, as data rather than as markup. - * - * Two decisions live here and nowhere else: which panels the settings navigation - * offers, and which one of them a given path is on. Both are plain functions - * over strings - no router, no request, no React - because both frameworks have - * to reach the same answer from the URL each of them happens to hold, and a - * highlighted nav item disagreeing with the panel on screen is the kind of bug - * that only shows up on one of the two. - * - * What this is *not*: a route table. Neither framework learns which routes exist - * from this file - Next.js has `routes/main/settings/*` and TanStack Start has - * `routes/_main/_authenticated/settings/*`, and a panel that is not routed - * simply renders a link to a 404. The list is the navigation's contents, which - * is a product decision, and it is shared so the two navigations cannot offer - * different menus. - */ - /** Where the settings screens are rooted, and the mobile "back" destination. */ export const SETTINGS_ROOT_HREF = "/settings"; export type SettingsNavKey = "devices" | "overview" | "security"; export interface SettingsNavItem { - /** - * Paths that light this item up without being its own href. - * - * `/settings` is the only one, and it exists because the root path renders the - * overview panel rather than redirecting to it - see the note on - * {@link SETTINGS_NAV_ITEMS}. Without the alias, the root screen would show a - * navigation with nothing selected. - */ aliases: readonly string[]; href: string; /** The `core.auth.settings.nav` key this item's label comes from. */ key: SettingsNavKey; } -/** - * The settings navigation, in the order it is rendered. - * - * `/settings` is an alias of the overview panel rather than a redirect to it, - * and that is deliberate on both sides of the seam. The shell shows the - * navigation *instead of* the panel on a narrow screen (see - * {@link isSettingsRootPath}), so a visitor who lands on `/settings` from a - * phone is looking at a menu; redirecting them to `/settings/overview` would - * skip the menu entirely and leave the back link as the only way to reach it. - */ export const SETTINGS_NAV_ITEMS: readonly SettingsNavItem[] = [ { aliases: [SETTINGS_ROOT_HREF], @@ -58,15 +22,6 @@ export const SETTINGS_NAV_ITEMS: readonly SettingsNavItem[] = [ { aliases: [], href: "/settings/security", key: "security" }, ]; -/** - * Whether `pathname` is the settings root. - * - * The pathname must already be *internal* - no locale prefix. Next.js gets that - * from `next-intl`'s `usePathname`, TanStack Start from a router location the - * Stage 3 rewrite has stripped. Nothing here localizes anything, and nothing - * here may start to: a rule that compared against `/pl/settings` would be a - * second copy of the locale routing. - */ export const isSettingsRootPath = (pathname: string): boolean => normalizeUrl(pathname) === SETTINGS_ROOT_HREF; @@ -79,14 +34,6 @@ export const isSettingsNavItemActive = ( href => normalizeUrl(href) === normalizeUrl(pathname), ); -/** - * Which panel `pathname` is on, or nothing. - * - * `undefined` for a path outside the settings screens, and for a settings path - * with no navigation entry - a future panel reachable by URL before it is - * listed. The navigation renders nothing selected in both cases, which is the - * honest answer. - */ export const activeSettingsNavKey = ( pathname: string, ): SettingsNavKey | undefined => diff --git a/packages/vitnode/src/views/auth/settings/shell-content.tsx b/packages/vitnode/src/views/auth/settings/shell-content.tsx index ba8b21bb3..98156ab42 100644 --- a/packages/vitnode/src/views/auth/settings/shell-content.tsx +++ b/packages/vitnode/src/views/auth/settings/shell-content.tsx @@ -12,37 +12,6 @@ import type { AuthLinkComponent } from "../auth-link"; import { SETTINGS_ROOT_HREF } from "./settings-nav"; -/** - * The settings screens' frame: the heading, the navigation card, and the panel - * every settings page renders inside. - * - * Presentation only, and framework-free on purpose - it reaches nothing from - * `next/*`, from `next-intl`'s Next-only entries or from `@/lib/navigation`, so - * a TanStack Start layout route renders exactly the frame the Next.js layout - * renders. - * - * Two things arrive from outside, and they are the only two: - * - * - `nav`, a slot. Each framework builds its own navigation because each has its - * own `Link` and its own way of knowing where it is; what the menu *contains* - * is shared, in `settings-nav.ts`. - * - `BackLink`, a component. The mobile back link's markup is presentation and - * stays here, so the two frameworks cannot drift into two different buttons - - * only the anchor underneath it differs. - * - * ## `isRoot` is a prop, not a hook call - * - * The whole of the mobile behaviour: on a narrow screen `/settings` shows the - * heading and the menu, and a panel path shows the panel with a link back to the - * menu. Both cards render in both cases and one of the two is hidden, so a - * desktop layout is one grid rather than two - which is why this is a class name - * rather than a branch. - * - * Deciding it needs the current path, which is the one thing this module must - * not read for itself (see {@link SettingsNavContent}). `isSettingsRootPath` in - * `settings-nav.ts` is the shared rule; each framework applies it to the - * pathname its own router holds. - */ export const SettingsShellContent = ({ BackLink, children, diff --git a/packages/vitnode/src/views/auth/sign-in/form/schema.ts b/packages/vitnode/src/views/auth/sign-in/form/schema.ts index 844967abb..e457d7f48 100644 --- a/packages/vitnode/src/views/auth/sign-in/form/schema.ts +++ b/packages/vitnode/src/views/auth/sign-in/form/schema.ts @@ -1,14 +1,5 @@ import { z } from "zod"; -/** - * The sign-in form's shape and its failure vocabulary, with no React in sight. - * - * Pulled out of the hook so both halves are testable as what they are: the - * schema is a function of two already-translated strings, and the error mapping - * is a function of whatever the submit callback returned. Neither needs a - * renderer, a provider or a request to be checked. - */ - export interface SignInFormMessages { /** Shown when the email field is not an email address. */ invalidEmail: string; @@ -16,13 +7,6 @@ export interface SignInFormMessages { passwordRequired: string; } -/** - * The API's answer to a sign-in attempt, as the UI cares about it. - * - * `access_denied` is the one failure with a screen of its own; anything else is - * a server problem the visitor cannot act on. Kept as the literals the route - * already returns so a wrapper stays a thin translation of a status code. - */ export type SignInMutationResult = undefined | { message: "access_denied" | "Internal Server Error" }; @@ -41,22 +25,6 @@ export const createSignInFormSchema = ({ export type SignInFormSchema = ReturnType<typeof createSignInFormSchema>; export type SignInFormValues = z.infer<SignInFormSchema>; -/** - * What a submit result means for the screen. - * - * - `"field"` - a failure the visitor can fix, rendered as the alert above the - * form. Only `access_denied` qualifies today. - * - `"toast"` - a server error, rendered as the internal-error toast. - * - `null` - nothing to show: either the sign-in worked, or the caller - * navigated away and never returned a result at all. - * - * A success is deliberately indistinguishable from "returned nothing". Both the - * Next.js server action and a TanStack Start mutation redirect on success, so - * the resolved value on the happy path is `undefined` in both - which is why - * the type says `undefined` rather than `void`: a callback with nothing to - * report has to say so, and an `async` function that only ever returns a - * failure already infers exactly this. - */ export const signInFormOutcome = ( result: SignInMutationResult, ): null | { error: SignInFormError; kind: "field" } | { kind: "toast" } => { diff --git a/packages/vitnode/src/views/auth/sign-in/form/sign-in-form-content.tsx b/packages/vitnode/src/views/auth/sign-in/form/sign-in-form-content.tsx index 7a56f897c..90c476bee 100644 --- a/packages/vitnode/src/views/auth/sign-in/form/sign-in-form-content.tsx +++ b/packages/vitnode/src/views/auth/sign-in/form/sign-in-form-content.tsx @@ -17,32 +17,12 @@ import { type SignInSubmit, useSignInForm } from "./use-sign-in-form"; export type { SignInSubmit }; -/** - * The two fields, their validation and their failure states - shared. - * - * Everything that used to be Next-only here has become one prop. The form no - * longer imports a server action, `next/navigation` or `next-intl/navigation`: - * it is handed {@link SignInSubmit} and a way to render a link, and those are - * the only two things it cannot answer for itself. - * - * What it keeps is the whole of the experience: `AutoForm`'s per-field shake and - * submit-button state, the `access_denied` alert with its own shake, and the - * internal-error toast (in {@link useSignInForm}). The admin sign-in screen is - * the same component with no reset link, exactly as before - the "is this the - * AdminCP" flag now lives with the mutation, which is the only thing that ever - * cared. - */ export const SignInFormContent = ({ LinkComponent, onSignIn, resetPasswordHref = AUTH_HREF.resetPassword, showResetPassword = false, }: { - /** - * Required only alongside {@link showResetPassword}: written as an optional - * pair rather than a union because the flag is deployment configuration - * (`isEmail`) read at runtime, not something a call site knows statically. - */ LinkComponent?: AuthLinkComponent; onSignIn: SignInSubmit; resetPasswordHref?: string; diff --git a/packages/vitnode/src/views/auth/sign-in/form/use-sign-in-form.ts b/packages/vitnode/src/views/auth/sign-in/form/use-sign-in-form.ts index 3263d9089..0d7ca0a53 100644 --- a/packages/vitnode/src/views/auth/sign-in/form/use-sign-in-form.ts +++ b/packages/vitnode/src/views/auth/sign-in/form/use-sign-in-form.ts @@ -15,32 +15,10 @@ import type { import { createSignInFormSchema, signInFormOutcome } from "./schema"; -/** - * How the form asks for a session. - * - * The whole of the framework boundary for signing in, and deliberately one - * function: it takes the two field values and answers what went wrong, or - * nothing at all. What it does on success - set a cookie, revalidate a layout, - * navigate - is entirely the caller's business, which is why nothing here - * handles it. Next.js redirects from a server action; TanStack Start calls the - * API and moves the router. - */ export type SignInSubmit = ( values: SignInFormValues, ) => Promise<SignInMutationResult>; -/** - * The sign-in form's behaviour, with no idea which framework is rendering it. - * - * `use-intl` rather than `next-intl` for the strings - the same record either - * way (`next-intl`'s client entry *is* `use-intl` re-exported), so a Next.js - * page under `NextIntlClientProvider` and a TanStack Start route under - * `IntlProvider` both resolve them. - * - * The schema is rebuilt on every render, as it always was: its messages are - * translated strings, so a memoised one would keep the previous language after - * a switch. - */ export const useSignInForm = ({ onSignIn }: { onSignIn: SignInSubmit }) => { const [error, setError] = React.useState<SignInFormError>(""); const t = useTranslations("core.auth.sign_in"); diff --git a/packages/vitnode/src/views/auth/sign-in/sign-in-content.tsx b/packages/vitnode/src/views/auth/sign-in/sign-in-content.tsx index 13a0f61b1..604deecb0 100644 --- a/packages/vitnode/src/views/auth/sign-in/sign-in-content.tsx +++ b/packages/vitnode/src/views/auth/sign-in/sign-in-content.tsx @@ -8,22 +8,6 @@ import type { AuthLinkComponent } from "../auth-link"; import { AUTH_HREF } from "../auth-link"; -/** - * The login card - the heading, the copy, and the two slots that fill it. - * - * Presentation only, and framework-free on purpose: it reaches nothing from - * `next/*`, from `next-intl`'s Next-only entries or from `@/lib/navigation`, so - * a TanStack Start route renders exactly the card the Next.js page renders. - * - * `form` and `sso` are slots rather than imports because *when* each arrives - * differs by framework, not what it looks like. Next.js reads the deployment - * configuration in a Server Component and hands each one down inside its own - * `<Suspense>` (the skeletons live with the components they stand in for, so - * both frameworks get them); a TanStack Start route has the same data from its - * loader before this renders at all, and passes the finished elements. - * - * Everything else - the strings, the layout, the footer - is here once. - */ export const SignInContent = ({ LinkComponent, form, diff --git a/packages/vitnode/src/views/auth/sign-up/form/schema.ts b/packages/vitnode/src/views/auth/sign-up/form/schema.ts index f422034d3..486c23566 100644 --- a/packages/vitnode/src/views/auth/sign-up/form/schema.ts +++ b/packages/vitnode/src/views/auth/sign-up/form/schema.ts @@ -1,17 +1,5 @@ import { z } from "zod"; -/** - * The registration form's shape and its failure vocabulary, with no React in - * sight. - * - * The same split `sign-in/form/schema.ts` makes, for the same reason: the schema - * is a function of already-translated strings, and the outcome mapping is a - * function of whatever the submit callback returned. Neither needs a renderer, a - * provider or a request to be checked - which matters more here than on the - * login form, because registration has four outcomes rather than two and one of - * them replaces the whole page. - */ - /** The password rules, as messages rather than as copy. */ export interface PasswordFieldMessages { /** Shown when the field is missing entirely. */ @@ -31,20 +19,6 @@ export interface SignUpFormMessages extends PasswordFieldMessages { termsRequired: string; } -/** - * The password field, shared by registration and password recovery. - * - * Four separate `.regex()` calls carrying the *same* message, which is - * deliberate: `PasswordInput` renders a live checklist of the four rules from - * its own copies of these expressions, so the message a failing password - * produces is always "too weak" and the checklist is what says which rule. - * Collapsing them into one expression would change nothing on screen and lose - * the ability to say which rule a value breaks. - * - * The API is stricter than this only in that it accepts *less*: `zodSignUpSchema` - * asks for eight characters and nothing else, so every value this schema admits - * is one the API admits too. - */ export const createPasswordZodSchema = ({ fieldRequired, invalidPassword, @@ -86,16 +60,6 @@ export const createSignUpFormSchema = ({ export type SignUpFormSchema = ReturnType<typeof createSignUpFormSchema>; export type SignUpFormValues = z.infer<SignUpFormSchema>; -/** - * What registration sends, once the form has dropped the parts the API has no - * field for. - * - * `terms` is absent on purpose - the tick is a local precondition, not something - * the API stores - and `captchaToken` is present because the sign-up route is - * `withCaptcha: true`, so a caller that could not attach one has nothing to - * send. Both are the reason this is its own type rather than - * {@link SignUpFormValues}. - */ export interface SignUpSubmitValues { captchaToken: string; email: string; @@ -104,26 +68,6 @@ export interface SignUpSubmitValues { password: string; } -/** - * What the API told us about a registration attempt, as the UI cares about it. - * - * Four outcomes, because registration genuinely has four: - * - * - `undefined` - it worked *and* the caller has already navigated. The account - * was created with `emailVerified: true`, the API minted a session on the same - * response, and there is nothing left for the form to render. - * - `{ emailConfirmation }` - it worked and the visitor is *not* signed in: this - * deployment has an email adapter, so the account waits on a confirmation - * link. The address travels back because the confirmation screen prints it. - * - `{ message: 'email_exists' | 'name_exists' }` - a conflict the visitor can - * fix, and the two are distinguished because they mark different fields. - * - `{ message: 'Internal Server Error' }` - anything else, rendered as the - * internal-error toast. - * - * Spelled as literals the transport can produce rather than as the API's own - * body, so no backend string reaches a screen: the API answers a 409 with - * `"Email already exists"`, and classifying that text is the transport's job. - */ export type SignUpMutationResult = | undefined | { emailConfirmation: string; message?: never } @@ -135,20 +79,6 @@ export type SignUpMutationResult = /** Which field a conflict belongs to. */ export type SignUpConflictField = "email" | "name"; -/** - * What a submit result means for the screen. - * - * - `"confirmation"` - swap the card for the "check your email" view. - * - `"field"` - mark one field and focus it; the hook supplies the message, - * because it is the half that has translations. - * - `"toast"` - the internal-error toast. - * - `null` - nothing to show: it worked and the caller navigated. - * - * A success is deliberately indistinguishable from "returned nothing", exactly - * as on the login form: both the Next.js server action and a TanStack Start - * mutation leave the page on the happy path, so the resolved value is - * `undefined` in both. - */ export const signUpFormOutcome = ( result: SignUpMutationResult, ): @@ -193,31 +123,6 @@ const unwrapApiMessage = (body: string): string => { return body; }; -/** - * Which unique constraint a `409` hit, or `"unknown"`. - * - * The API answers a conflict with a bare `HTTPException`, whose body is the - * message and nothing else - `"Email already exists"` or `"Name already - * exists"` (`api/models/user/sign-up.ts`). Two things follow, and this function - * is where both are handled: - * - * 1. **The distinction is worth keeping.** They mark different fields, and the - * visitor's next move differs - pick another address, or pick another name. - * 2. **The string itself must not travel.** It is an internal message in a fixed - * language, so it is classified here and never forwarded; a body that matches - * neither becomes `"unknown"` and the caller renders its generic failure - * rather than printing something a backend wrote. - * - * Lives with the schema, framework-free, because both transports have to make - * the identical judgement: the Next.js server action reads `res.text()`, and the - * TanStack Start server function reads the same body off the same route. One - * classifier rather than two that can drift. - * - * Tolerant about *packaging* and strict about content: a body may arrive as - * plain text, as a JSON string, or as `{ "error": ... }` / `{ "message": ... }` - * (which is how VitNode's other conflict routes answer), and only the two known - * sentences are recognised once unwrapped. - */ export const signUpConflictReason = ( body: string, ): "email_exists" | "name_exists" | "unknown" => { diff --git a/packages/vitnode/src/views/auth/sign-up/form/sign-up-form-content.tsx b/packages/vitnode/src/views/auth/sign-up/form/sign-up-form-content.tsx index e05dd5e84..6f6346022 100644 --- a/packages/vitnode/src/views/auth/sign-up/form/sign-up-form-content.tsx +++ b/packages/vitnode/src/views/auth/sign-up/form/sign-up-form-content.tsx @@ -22,19 +22,6 @@ import { type SignUpSubmit, useSignUpForm } from "./use-sign-up-form"; export type { SignUpSubmit }; -/** - * The registration fields, their validation and their failure states - shared. - * - * Everything that used to be Next-only here has become a prop. The form no - * longer imports a server action or `@/lib/navigation`: it is handed - * {@link SignUpSubmit} and a way to render a link, and those are the only two - * things it cannot answer for itself. - * - * What it keeps is the whole of the experience: `AutoForm`'s per-field shake and - * submit-button state, the live user-code preview under the username, the - * password checklist tooltip, the captcha widget, and the newsletter checkbox - * that only appears on a deployment with an email adapter. - */ export const SignUpFormContent = ({ captcha, isEmail, @@ -43,11 +30,7 @@ export const SignUpFormContent = ({ termsHref = "/terms", }: { captcha: z.infer<typeof routeMiddlewareSchema>["captcha"]; - /** - * Whether this deployment has an email adapter. It decides two things at once: - * whether the newsletter checkbox is offered, and - on the API's side - whether - * a new account starts verified or waits on a confirmation link. - */ + isEmail: boolean; LinkComponent: AuthLinkComponent; onSignUp: SignUpSubmit; diff --git a/packages/vitnode/src/views/auth/sign-up/form/use-sign-up-form.ts b/packages/vitnode/src/views/auth/sign-up/form/use-sign-up-form.ts index 9f5529c82..0f8787de5 100644 --- a/packages/vitnode/src/views/auth/sign-up/form/use-sign-up-form.ts +++ b/packages/vitnode/src/views/auth/sign-up/form/use-sign-up-form.ts @@ -17,41 +17,10 @@ import { createSignUpFormSchema, signUpFormOutcome } from "./schema"; export type { SignUpSubmitValues }; -/** - * How the form asks for an account. - * - * The whole of the framework boundary for registering, and deliberately one - * function: it takes the field values and answers what happened, or nothing at - * all. What it does on success - copy a session cookie, refresh a cached - * session, navigate - is entirely the caller's business, which is why nothing - * here handles it. Next.js redirects from a server action; TanStack Start calls - * a server function, refreshes the canonical session query and moves the router. - */ export type SignUpSubmit = ( values: SignUpSubmitValues, ) => Promise<SignUpMutationResult>; -/** - * The registration form's behaviour, with no idea which framework is rendering - * it. - * - * `use-intl` rather than `next-intl` for the strings - the same module record - * either way - so a Next.js page under `NextIntlClientProvider` and a TanStack - * Start route under `IntlProvider` both resolve them. - * - * The schema is rebuilt on every render, as it always was: its messages are - * translated strings, so a memoised one would keep the previous language after a - * switch. - * - * ## Where the confirmation screen comes from - * - * `useWrapperSignUp` - the context {@link WrapperSignUp} mounts, which - * {@link SignUpContent} renders for both frameworks. When the account was - * created but not verified, this hands it the address and the wrapper swaps the - * card for the "check your email" view. Nothing about that is Next-specific, - * which is why it stayed a context rather than becoming a fifth prop: the form - * is several levels below the component that has to change shape. - */ export const useSignUpForm = ({ onSignUp }: { onSignUp: SignUpSubmit }) => { const t = useTranslations("core.auth.sign_up"); const tErrors = useTranslations("core.global.errors"); diff --git a/packages/vitnode/src/views/auth/sign-up/sign-up-content.tsx b/packages/vitnode/src/views/auth/sign-up/sign-up-content.tsx index 3338d9366..1363562ba 100644 --- a/packages/vitnode/src/views/auth/sign-up/sign-up-content.tsx +++ b/packages/vitnode/src/views/auth/sign-up/sign-up-content.tsx @@ -9,29 +9,6 @@ import type { AuthLinkComponent } from "../auth-link"; import { AUTH_HREF } from "../auth-link"; import { WrapperSignUp } from "./wrapper"; -/** - * The registration card - the heading, the copy, and the two slots that fill it. - * - * The counterpart of `SignInContent`, and framework-free for the same reason: it - * reaches nothing from `next/*`, from `next-intl`'s Next-only entries or from - * `@/lib/navigation`, so a TanStack Start route renders exactly the card the - * Next.js page renders. - * - * `form` and `sso` are slots rather than imports because *when* each arrives - * differs by framework, not what it looks like. Next.js reads the deployment - * configuration in a Server Component and hands each one down inside its own - * `<Suspense>`; a TanStack Start route has the same data from its loader before - * this renders at all, and passes the finished elements. - * - * ## Why the wrapper is inside - * - * {@link WrapperSignUp} is here rather than left to each caller because the - * "check your email" screen *replaces this card*, and a caller that forgot to - * mount it would get a form that succeeds and then appears to do nothing. It is - * ordinary client React - `useState` and a context - so both frameworks mount - * the same one, and the confirmation state lives exactly one level above the - * thing it hides. - */ export const SignUpContent = ({ form, LinkComponent, diff --git a/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.tsx b/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.tsx index b6279e9af..b4131d824 100644 --- a/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.tsx +++ b/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.tsx @@ -8,31 +8,10 @@ import { Skeleton } from "@/components/ui/skeleton"; import type { SSOProvider } from "../providers"; -/** - * What starting an SSO flow answers with. - * - * A message means it failed; the shared row turns that into the internal-error - * toast. On the happy path the browser has already been sent to the provider, - * so there is nothing to return - the promise simply never resolves to anything - * the row can render. - */ export type SSOStartResult = undefined | { message?: string }; export type SSOSelectProvider = (providerId: string) => Promise<SSOStartResult>; -/** - * The provider buttons, separated from what pressing one does. - * - * The whole of the framework boundary is `onSelectProvider`: it takes a - * provider id and answers whether the flow could be started. Next.js calls a - * server action that redirects; TanStack Start calls the API and moves the - * browser itself. Neither is imported here. - * - * The failure toast stays on this side deliberately. It is the same message for - * the same reason in both frameworks - "we could not reach the provider" - and - * a callback that only reports the failure keeps every wrapper from having to - * re-implement it. - */ export const SSOButtonsContent = ({ onSelectProvider, providers, diff --git a/packages/vitnode/src/views/auth/sso/callback/sso-callback-content.tsx b/packages/vitnode/src/views/auth/sso/callback/sso-callback-content.tsx index 478cb78a3..b56d15ba7 100644 --- a/packages/vitnode/src/views/auth/sso/callback/sso-callback-content.tsx +++ b/packages/vitnode/src/views/auth/sso/callback/sso-callback-content.tsx @@ -12,19 +12,6 @@ import type { SSOCallbackState } from "./use-sso-callback"; import { AUTH_HREF } from "../../auth-link"; -/** - * The callback screen, as a function of which of four things happened. - * - * No request and no navigation: {@link useSSOCallback} runs the exchange and - * answers with a state, and this renders it. That split is the whole point - - * the states and their copy are identical in both frameworks, while starting a - * mutation and moving the browser are not. - * - * `errorActions` is the "go back / go home" pair, which is navigation and - * therefore the wrapper's to supply. `LinkComponent` is the one link with copy - * of its own: after an email conflict the visitor is sent to the login page to - * sign in the way they did the first time. - */ export const SSOCallbackContent = ({ LinkComponent, errorActions, diff --git a/packages/vitnode/src/views/auth/sso/callback/sso-callback-result.ts b/packages/vitnode/src/views/auth/sso/callback/sso-callback-result.ts index a54128d35..91cf88d62 100644 --- a/packages/vitnode/src/views/auth/sso/callback/sso-callback-result.ts +++ b/packages/vitnode/src/views/auth/sso/callback/sso-callback-result.ts @@ -1,24 +1,7 @@ -/** - * What came back from exchanging an OAuth code for a session, as the callback - * screen cares about it. - * - * Two failures and nothing else: an email that already belongs to another - * account, which the visitor can act on, and everything else, which they - * cannot. Kept as codes rather than as the sentences they used to be - the - * screen compared `error.message === "Email already exists"`, so an edit to - * that string in one file silently changed which screen the other one rendered. - */ export type SSOCallbackFailure = "email_exists" | "unknown"; export type SSOCallbackResult = undefined | { failure?: SSOCallbackFailure }; -/** - * The API's status code, read as an outcome. - * - * Pure, so the mapping is checkable without a request: 200 signed the visitor - * in, 409 is the email conflict, and anything else is a failure they cannot - * resolve. - */ export const ssoCallbackResultFromStatus = ( status: number, ): SSOCallbackResult => { diff --git a/packages/vitnode/src/views/auth/sso/callback/use-sso-callback.ts b/packages/vitnode/src/views/auth/sso/callback/use-sso-callback.ts index de2efcb60..cce384ba8 100644 --- a/packages/vitnode/src/views/auth/sso/callback/use-sso-callback.ts +++ b/packages/vitnode/src/views/auth/sso/callback/use-sso-callback.ts @@ -8,25 +8,6 @@ import type { SSOCallbackResult } from "./sso-callback-result"; export type SSOCallbackState = "access_denied" | "email_exists" | "error" | "pending"; -/** - * The exchange, run once, with the framework parts held at arm's length. - * - * Two callbacks and no imports beyond Query, which is framework-free and - * already mounted in both apps: - * - * - `onCallback` sends the code and the state to the API and answers what - * happened. Next.js calls a server action; TanStack Start calls the API. - * - `onSignedIn` runs once that succeeded. Both frameworks send the visitor to - * the front page, by their own means. - * - * `retry: false` because an authorization code is single-use: a second attempt - * cannot succeed, and would turn a clear "that email is taken" into a generic - * failure. The query key carries the provider and the code, so a re-render - * never re-runs the exchange and a genuinely new callback always does. - * - * A provider that reported `access_denied` in the URL never gets that far - - * there is no code to exchange, so the query does not run at all. - */ export const useSSOCallback = ({ code, oauthError, diff --git a/packages/vitnode/src/views/auth/sso/providers.ts b/packages/vitnode/src/views/auth/sso/providers.ts index 445ce1ac1..df5b40138 100644 --- a/packages/vitnode/src/views/auth/sso/providers.ts +++ b/packages/vitnode/src/views/auth/sso/providers.ts @@ -1,12 +1,3 @@ -/** - * An SSO provider, as the auth screens need it: something to click and - * something to call it. - * - * Ordinary typed data rather than a registry lookup. The list is deployment - * configuration - it comes from the middleware route, which derives it from - * `vitnode.api.config.ts` - so both frameworks fetch the same JSON and hand it - * straight to the shared button row. - */ export interface SSOProvider { id: string; name: string; @@ -19,19 +10,6 @@ const isProvider = (value: unknown): value is SSOProvider => typeof (value as { name?: unknown }).name === "string" && (value as { id: string }).id !== ""; -/** - * The provider list, made safe to render. - * - * Every caller already holds a parsed API response, so this is not validation - * so much as a single place for the three questions a button row would - * otherwise ask inline: is there a list at all (a loader that has not resolved, - * a deployment with no adapters), does each entry have the two fields a button - * needs, and is any provider listed twice - which React answers with a - * duplicate-key warning and a row that renders one button too many. - * - * Order is preserved: it is the order the adapters were registered in, which is - * the order the deployment chose. - */ export const normalizeSSOProviders = (value: unknown): SSOProvider[] => { if (!Array.isArray(value)) return []; diff --git a/packages/vitnode/src/views/breadcrumb/breadcrumb-render-content.tsx b/packages/vitnode/src/views/breadcrumb/breadcrumb-render-content.tsx index 1da816020..925f35f4d 100644 --- a/packages/vitnode/src/views/breadcrumb/breadcrumb-render-content.tsx +++ b/packages/vitnode/src/views/breadcrumb/breadcrumb-render-content.tsx @@ -10,25 +10,6 @@ import { import type { AuthLinkComponent } from "../auth/auth-link"; import type { BreadcrumbCrumb } from "./crumb"; -/** - * A run of crumbs, as list items - with the one thing they cannot decide for - * themselves handed in. - * - * Items only, and no `<Breadcrumb>` wrapper: the shell renders exactly one `nav` - * and one `<ol>` for the whole trail, and every contributor's items go inside - * it. Rendering a second list here would nest a navigation landmark inside - * another one, and would put a route's crumbs in an `<ol>` of their own where a - * screen reader announces them as a separate list. - * - * Turning `/settings` into a navigation is the only framework-specific part of a - * breadcrumb, so this takes a `LinkComponent` and stops caring. `AuthLinkComponent` - * is reused rather than redeclared: it is already "every prop of an anchor, plus - * a required `href`", which is exactly what a crumb needs and what `RouterLink` - * in `@vitnode/core/tanstack/layout` already satisfies. - * - * Deliberately not a client component. It renders no hooks and takes its link - * component as a prop, so it can be rendered from anywhere. - */ export const BreadcrumbCrumbItems = ({ crumbs, LinkComponent, diff --git a/packages/vitnode/src/views/breadcrumb/breadcrumb-trail-content.tsx b/packages/vitnode/src/views/breadcrumb/breadcrumb-trail-content.tsx index 17fc5d57a..f4173553d 100644 --- a/packages/vitnode/src/views/breadcrumb/breadcrumb-trail-content.tsx +++ b/packages/vitnode/src/views/breadcrumb/breadcrumb-trail-content.tsx @@ -12,13 +12,6 @@ import { cn } from "@/lib/utils"; import type { AuthLinkComponent } from "../auth/auth-link"; -/** - * One contributor's place in a rendered trail. - * - * Structural rather than imported from `@vitnode/core/tanstack/breadcrumb`, - * because this file is a view: it renders the trail a router-aware shell - * collected, and may not import the router-aware half that collects it. - */ export interface BreadcrumbTrailContentEntry { content: React.ReactNode; href: string; @@ -28,23 +21,6 @@ export interface BreadcrumbTrailContentEntry { spansItems: boolean; } -/** - * The whole breadcrumb trail: one navigation landmark, one list, one separator - * between neighbours. - * - * This is the half that owns the semantics, and the reason a route contributes a - * *label* rather than a trail: the `nav`, the ordered list, the separators and - * the `aria-current` on the last crumb are decided here, once, so every crumb in - * every shell reads the same way and a plugin never has to know it is the third - * of five. - * - * A crumb that is not the current page is a link to its own matched route's URL, - * built with the `LinkComponent` a shell hands in - which is what makes the trail - * locale-aware without a single crumb mentioning a locale. - * - * `spansItems` is the AdminCP's case: those crumbs come from one route and are - * rendered by `BreadcrumbCrumbItems`, which emits its own items into this list. - */ export const BreadcrumbTrailContent = ({ entries, LinkComponent, diff --git a/packages/vitnode/src/views/error/error-content.tsx b/packages/vitnode/src/views/error/error-content.tsx index 09c5fd7f3..e3dec3c4b 100644 --- a/packages/vitnode/src/views/error/error-content.tsx +++ b/packages/vitnode/src/views/error/error-content.tsx @@ -1,18 +1,3 @@ -/** - * An error screen: a status code, what it means, and what to do about it. - * - * Presentation and nothing else - no translations and no navigation, which is - * what makes it renderable by both frameworks and, just as importantly, by a - * React Server Component. `ErrorView` (this file's Next.js wrapper) is rendered - * by `not-found.tsx` on the server *and* by the SSO callback in the browser, so - * the strings have to be looked up by whoever knows which of the two it is: - * `next-intl` reads Next's request scope in one and its client context in the - * other, while a TanStack Start route reads `use-intl`. Both then hand the - * finished text here. - * - * `actions` is a slot for the same reason: "go back" and "go home" are - * navigation, and navigation is the framework's business. - */ export const ErrorContent = ({ actions, code, diff --git a/packages/vitnode/src/views/files/actions/file-row-actions.tsx b/packages/vitnode/src/views/files/actions/file-row-actions.tsx index 820a52cf3..fa06dc90c 100644 --- a/packages/vitnode/src/views/files/actions/file-row-actions.tsx +++ b/packages/vitnode/src/views/files/actions/file-row-actions.tsx @@ -16,21 +16,6 @@ import { clientModule, fetcherClient } from "@/lib/fetcher-client"; import type { DeleteMyFile } from "../my-files-delete"; -/** - * Download and delete, for one row of the visitor's files. - * - * The delete arrives as a prop. It used to import the Next.js server action - * directly, which is what made this component - and therefore the whole table - - * unrenderable outside Next.js: importing a `"use server"` module pulls the - * fetcher, `next/headers` and the entire API module graph in behind it. The two - * frameworks end a delete differently (`revalidatePath` against a query - * invalidation) and agree on everything before that, so the last step is the - * prop and the rest is `my-files-delete.ts`. - * - * The download is *not* a prop, and the asymmetry is deliberate: it is a - * same-origin `GET` whose body becomes a blob in this browser, so there is - * nothing a server could do with it and nothing to invalidate afterwards. - */ export const MyFileRowActions = ({ id, name, diff --git a/packages/vitnode/src/views/files/actions/files-bulk-actions.tsx b/packages/vitnode/src/views/files/actions/files-bulk-actions.tsx index 631b2aa5e..98495a982 100644 --- a/packages/vitnode/src/views/files/actions/files-bulk-actions.tsx +++ b/packages/vitnode/src/views/files/actions/files-bulk-actions.tsx @@ -13,20 +13,6 @@ import { Button } from "@/components/ui/button"; import type { DeleteMyFiles } from "../my-files-delete"; -/** - * Delete, for every ticked row of the visitor's files. - * - * The two-pass shape is the point and it is unchanged: the first pass deletes - * what it can and comes back with the ids that only *history* is holding, and - * the dialog stays open offering to force past exactly those - never the whole - * selection again. Files a live page still points at are refused in both passes, - * because there is no version of "delete anyway" that leaves a published page - * unbroken. - * - * The run itself is a prop, for the reason set out in `my-files-delete.ts`: the - * accounting is shared and only the last step - revalidate or invalidate - - * belongs to a framework. - */ export const MyFilesBulkActions = ({ onDeleteFiles, }: { diff --git a/packages/vitnode/src/views/files/files-boundaries.test.ts b/packages/vitnode/src/views/files/files-boundaries.test.ts index a468de83b..75af263d9 100644 --- a/packages/vitnode/src/views/files/files-boundaries.test.ts +++ b/packages/vitnode/src/views/files/files-boundaries.test.ts @@ -14,17 +14,6 @@ import { const here = dirname(fileURLToPath(import.meta.url)); -/** - * `/files`, split down the middle. - * - * The same boundary `auth-boundaries.test.ts` and `feed-boundaries.test.ts` - * draw, with the same machinery and for the same reason: a shared module that - * reaches `next/headers`, a server action or `@/lib/navigation` cannot be loaded - * by a TanStack Start route, and nothing about that failure is visible until - * somebody tries. A scan is the only way to state it, because the offending - * import is usually three files away from the one being written - this feature's - * was `next/dynamic`, inside the confirm dialog, behind the delete button. - */ const SHARED = { bulkActions: join(here, "actions/files-bulk-actions.tsx"), deletes: join(here, "my-files-delete.ts"), @@ -34,13 +23,7 @@ const SHARED = { }; /** The Next.js half: `next/headers`, `notFound`, and the server actions. */ -/** - * The Next.js half, by path, so its absence can be asserted. - * - * Named rather than deleted along with the assertions that used them: each was - * the one place a Next.js API was allowed to appear in this subtree, and a test - * that stops naming them cannot notice one coming back. - */ + const DELETED_NEXT_HALF = join(here, "my-files-table-view.tsx"); const sharedEntries = Object.entries(SHARED).map(([name, path]) => ({ diff --git a/packages/vitnode/src/views/files/my-files-query.ts b/packages/vitnode/src/views/files/my-files-query.ts index afbaef7b5..8ad9b131d 100644 --- a/packages/vitnode/src/views/files/my-files-query.ts +++ b/packages/vitnode/src/views/files/my-files-query.ts @@ -7,41 +7,6 @@ import { CONFIG_PLUGIN } from "@/config"; import { clientModule, fetcherClient } from "@/lib/fetcher-client"; import { RECORD_STALE_TIME } from "@/lib/query-freshness"; -/** - * The signed-in visitor's own files, as one query definition. - * - * Everything about *what* that list is lives here and nowhere else: which URL - * parameters mean something, what a page is, how a refusal is told apart from a - * page, and the cache entry the whole thing lands in. A view renders whatever - * this produces and owns none of it. - * - * The split is the lesson `search-feed-query.ts` already paid for. When a - * component built one request and a loader built another, the two agreed on the - * cache key and on nothing else - so the server-rendered page came from one - * contract and every navigation after hydration came from a second one with - * different defaults and no status checking. Sharing a key is not sharing a - * contract. - * - * The one thing deliberately *not* fixed here is the transport: a loader running - * on a server and a component running in a browser cannot reach the API the same - * way. So {@link myFilesQueryOptions} takes a `fetchPage` and defaults it to the - * browser's, which is the only one a shared module can assume. - * - * ## Hono is still the boundary - * - * Nothing below authorizes anything. `GET /api/@vitnode/core/users/files` - * derives the owner from the session cookie and scopes the query to it, so a - * request this module builds for a visitor who has just been signed out comes - * back `401` - and {@link MyFilesRequestError} is what makes that a failed query - * rather than an empty table. - */ - -/** - * The files module as a value the fetchers can carry without pulling the API - * into either bundle. The module is imported as a *type* only, so route - * literals, methods and response schemas all still infer; `clientModule` - * supplies the one field the fetcher reads at runtime. - */ export const userFilesModuleRef = clientModule<typeof userFilesModule>( CONFIG_PLUGIN.pluginId, ); @@ -56,24 +21,8 @@ export type MyFilesOrderBy = (typeof MY_FILES_ORDER_BY)[number]; export const MY_FILES_ORDER = ["asc", "desc"] as const; export type MyFilesOrder = (typeof MY_FILES_ORDER)[number]; -/** - * The largest page the API will serve, whatever the URL asks for. - * - * Mirrors `MAX_PAGE_SIZE` in `@/api/lib/with-pagination`. Clamping here as well - * as there is not redundancy: past this number the API answers `400`, and a - * table that 400s because somebody typed `?first=5000` is a broken page rather - * than a refused one. - */ export const MY_FILES_MAX_PAGE_SIZE = 100; -/** - * The list route's query, after normalisation - and therefore also the shape - * that identifies a cache entry. - * - * Every field is optional and every present field is known-good: this is what - * {@link normalizeMyFilesParams} produces and the only thing the request builder - * and the query key accept. - */ export interface MyFilesParams { cursor?: string; first?: string; @@ -83,14 +32,6 @@ export interface MyFilesParams { search?: string; } -/** - * The URL as either framework hands it over, before anything has checked it. - * - * Values are widened to `string[]` because a query string may repeat a key and - * both routers surface that as an array; they are widened to `null` because - * `URLSearchParams.get` returns one. None of it is trusted - see - * {@link normalizeMyFilesParams}. - */ export type RawMyFilesParams = Partial< Record<keyof MyFilesParams, null | string | string[] | undefined> >; @@ -102,14 +43,6 @@ const readOne = (value: null | string | string[] | undefined): string => { return value ?? ""; }; -/** - * A page size, or `undefined` when the URL did not ask for a usable one. - * - * `?first=abc`, `?first=0` and `?first=-1` are all refused by the API with a - * `400`, so accepting them here would turn a hand-edited URL into an error - * screen. They fall back to the default page size instead, which is the page the - * visitor would have got had they not edited anything. - */ const readPageSize = (raw: string): string | undefined => { if (!/^\d+$/.test(raw)) return undefined; @@ -119,33 +52,6 @@ const readPageSize = (raw: string): string | undefined => { return String(Math.min(size, MY_FILES_MAX_PAGE_SIZE)); }; -/** - * The URL's parameters, reduced to the ones this list actually has. - * - * Pure, and the single place a raw query string becomes a request. Both - * frameworks call it - Next.js on `searchParams`, TanStack Start on the route's - * validated search - so a hand-edited URL behaves identically in both, and the - * cache key below is built from the *result* rather than from whatever was - * typed. That last part is what makes `?first=10`, `?first=010` and no `first` - * at all one cache entry rather than three. - * - * The rules, each of which exists because the alternative is a broken page: - * - * - **A page size is always present.** The fetcher used to lean on - * `withPagination`, which quietly wrote `first=10` inside the URL builder - * where the query key could not see it. Two requests that differed only in - * that invisible default shared a key. - * - **`first` wins over `last`.** They are mutually exclusive and the API - * `400`s on both; the table never emits both, so a URL that has them was - * written by hand and forward is the direction it would have meant. - * - **`search` is trimmed and dropped when empty.** The API trims it too and - * treats blank as no filter, so `?search=` and no `search` must not be two - * entries holding the same rows. - * - **A cursor is shape-checked only.** It is opaque and belongs to the - * ordering that minted it; whether it decodes is the API's business. A value - * that cannot be one is dropped rather than sent, since the API answers `400` - * for it and the honest reading of a corrupt cursor is "start again". - */ export const normalizeMyFilesParams = ( raw: RawMyFilesParams = {}, ): MyFilesParams => { @@ -177,15 +83,6 @@ export const normalizeMyFilesParams = ( return params; }; -/** - * One page of the list, as arguments to whichever fetcher is carrying it. - * - * `withPagination` is deliberately absent. That flag makes the URL builder - * invent `first` and an empty `search` at the last possible moment, which is - * exactly the invisible state the query key cannot include - so the defaults are - * applied by {@link normalizeMyFilesParams} instead, in a value both the request - * and the key are built from. - */ /** One row of the table, as JSON delivers it. */ export interface MyFile { /** ISO string over the wire; a `Date` when a Next.js render passes it in. */ @@ -213,12 +110,6 @@ export interface MyFilesPage { }; } -/** - * How a page is actually fetched. See {@link myFilesQueryOptions}. - * - * The second argument is the read's cancellation, and it is optional so the SSR - * branch - handed no signal, deliberately - satisfies this with one parameter. - */ export type MyFilesPageFetcher = ( params: MyFilesParams, options?: { signal?: AbortSignal }, @@ -227,13 +118,6 @@ export type MyFilesPageFetcher = ( /** The `name` every {@link MyFilesRequestError} carries. See below. */ const MY_FILES_REQUEST_ERROR = "MyFilesRequestError"; -/** - * The parameters a failed request was carrying, for its message. - * - * Its own function because an error message is the only trace a production - * failure leaves, and "which page was it asking for" is the first question - * anyone reading one has. - */ export const describeMyFilesParams = (params: MyFilesParams): string => Object.entries(params) .map(([key, value]) => `${key}=${value}`) @@ -282,14 +166,6 @@ export const isMyFilesRequestError = ( ): error is MyFilesRequestError => error instanceof Error && error.name === MY_FILES_REQUEST_ERROR; -/** - * One page, fetched from the browser. - * - * `fetcherClient` builds the same same-origin `/api/@vitnode/core/users/files` - * URL every other VitNode client call uses, so the browser attaches the session - * cookie itself and a `429` is routed to the global rate-limit notice on the way - * through. - */ export const fetchMyFilesPageInBrowser: MyFilesPageFetcher = async ( params, { signal } = {}, @@ -308,80 +184,11 @@ export const fetchMyFilesPageInBrowser: MyFilesPageFetcher = async ( return await response.json(); }; -/** - * Every visitor's files, as one prefix above the per-owner partitions. - * - * {@link myFilesQueryRoot} narrows this to one owner and is what a *mutation* - * invalidates. This is the wider one, and it has exactly one caller: the public - * identity cleanup in `tanstack/auth/queries`, which runs when the person at the - * keyboard may have changed and therefore cannot name whose partition to drop - - * the point is that *no* previous visitor's rows stay in the browser, and the - * one being signed out is not necessarily the only one in there. - * - * Partitioning already stops B *reading* A's entry (see below). This is the - * other half, and it is the half the AdminCP has had since Stage 12: an entry - * nobody can read is still a copy of A's file names, folders and sizes sitting - * in the heap of a browser A has walked away from, for `gcTime` after they left. - * The two halves of the app should not disagree about whether that is acceptable. - * - * Written as its own constant rather than sliced off a key at the call site so - * that the prefix and the keys it must match are one edit: a partition scheme - * that changed here without changing {@link myFilesQueryRoot} would leave a - * cleanup that quietly collects nothing. - */ export const MY_FILES_IDENTITY_ROOT = ["files", "user"] as const; -/** - * The root every cache entry for one visitor's files hangs off. - * - * A factory over the owner's id rather than the constant `["files", "me"]` it - * replaces, and the difference is a privacy one rather than a tidiness one. - * - * ## Why `"me"` was unsafe - * - * `"me"` is only stable for as long as "me" is. The browser's `QueryClient` is - * created once per document and outlives a sign-out, so one browser can hold two - * visitors in one session: - * - * A signs in -> /files -> ["files","me",params] holds A's file names - * A signs out - * B signs in -> /files -> the loader asks for ["files","me",params] - * - * and that entry is already populated. With `refetchOnMount` and - * `refetchOnWindowFocus` both off in VitNode's client defaults, nothing would - * have refetched it, so B would read A's private data with no API request made - * at all - which is exactly why Hono cannot defend against it. There is no - * request for it to authorize. - * - * Keyed by owner the two visitors address different entries, B's is empty, the - * fetch happens, and the API answers it from B's own session cookie. - * - * ## The id is a cache address, never a claim - * - * Nothing about this reaches the network. {@link myFilesRequest} takes no owner - * and `GET /users/files` derives it from the session cookie, exactly as before - - * so a tampered id partitions a cache differently and authorizes nothing. Were - * it ever sent, this would stop being a cache key and become an access-control - * parameter, which is the one thing it must not be. - */ export const myFilesQueryRoot = (userId: number) => [...MY_FILES_IDENTITY_ROOT, userId] as const; -/** - * The cache entry one page of one visitor's list reads and writes. - * - * The owner, then the normalised parameters. Everything that changes which rows - * come back is in there - page, size, sort, search - and nothing that does not. - * - * The locale is deliberately absent. File names, folders, sizes and metadata are - * the visitor's own data and identical in every language; the only translated - * things on the page are the column headings, which the renderer resolves. Two - * entries holding identical rows would mean a language switch silently refetched - * a list that had not changed. - * - * An object in a key is safe - Query hashes keys structurally rather than by - * identity - which is exactly why the object has to be the *normalised* one. - */ export const myFilesQueryKey = ({ params, userId, @@ -390,61 +197,6 @@ export const myFilesQueryKey = ({ userId: number; }) => [...myFilesQueryRoot(userId), params] as const; -/** - * The visitor's files, as the one query definition every caller shares. - * - * A route loader warms it before the component renders: - * - * context.queryClient.ensureQueryData( - * myFilesQueryOptions({ params, userId }), - * ) - * - * and the component reads the very same options back: - * - * const { data } = useQuery(myFilesQueryOptions({ params, userId })) - * - * Same key, same request, same status checking - so the loader's page is the - * page the component renders, and a delete that invalidates - * {@link myFilesQueryRoot} refetches through the identical contract. - * - * `userId` addresses the cache and nothing else - see {@link myFilesQueryRoot}. - * It is required rather than defaulted because there is no honest default: a - * fallback would be one shared entry again, which is the bug the parameter - * exists to close. Both callers take it from the one place that knows it, the - * `_authenticated` route context, so the loader and the component cannot drift - * onto two different partitions. - * - * `fetchPage` is the seam. It defaults to the browser's fetcher, which is what a - * hydrated page wants; an app that also fetches during SSR passes one that can - * do both. It is a plain async function rather than anything framework-shaped, - * so nothing about this module knows which framework is rendering it. - * - * ## It asks once - * - * `retry: false`, against Query's default of three attempts. Every failure this - * read can produce is made worse by repeating it: a `429` is answered by sending - * the same request two more times, which is the thing the limiter is asking this - * app to stop doing, and a `401` is not going to become a `200` because we asked - * again. A page that has genuinely gone wrong should say so on the first - * attempt, and the visitor retries by reloading - a decision they can make and a - * rate limiter can see coming. - * - * No `staleTime`. Freshness is whatever the API's own caching gives, plus - * VitNode's client defaults (`refetchOnMount` and `refetchOnWindowFocus` both - * off), so a hydrated table is not refetched behind the reader; a delete is what - * makes it stale, explicitly. - * - * ## And it can be given up on - * - * The `queryFn` **reads** `signal` off the context, which is the only thing that - * marks a query cancellable. Paging through a large library leaves one request - * in flight rather than one per press, and the abandoned ones reject rather than - * landing late on top of the page being read. - * - * Safe because the fetcher throws: the abort rejects inside `fetch`, so it can - * never arrive as `MyFilesRequestError` (a refusal), as an empty page (an - * account with nothing uploaded) or as a `401` (a session that ended). - */ export const myFilesQueryOptions = ({ fetchPage = fetchMyFilesPageInBrowser, params, @@ -464,11 +216,4 @@ export const myFilesQueryOptions = ({ staleTime: RECORD_STALE_TIME, }); -/** - * What {@link MyFilesTableContent} accepts, and the reason it accepts only this. - * - * Typed as the factory's own return type on purpose: a caller cannot hand the - * table a hand-rolled options object that happens to type-check, so "one query - * definition" is enforced by the compiler rather than by review. - */ export type MyFilesQueryOptions = ReturnType<typeof myFilesQueryOptions>; diff --git a/packages/vitnode/src/views/files/my-files-table-content.tsx b/packages/vitnode/src/views/files/my-files-table-content.tsx index 4e23d3881..655d1c89c 100644 --- a/packages/vitnode/src/views/files/my-files-table-content.tsx +++ b/packages/vitnode/src/views/files/my-files-table-content.tsx @@ -15,54 +15,6 @@ import type { MyFile, MyFilesPage } from "./my-files-query"; import { MyFileRowActions } from "./actions/file-row-actions"; import { MyFilesBulkActions } from "./actions/files-bulk-actions"; -/** - * The visitor's own files, as a table both frameworks render. - * - * Everything that used to make this a Next.js Server Component has been taken - * out and turned into a parameter: it no longer fetches, no longer calls - * `getTranslations`, and no longer reaches for `notFound()`. What is left is the - * part that was always worth sharing - the eight columns, the preview, the - * metadata popover, the relative date, the empty state, and which columns are - * sortable - and the three things it cannot resolve for itself arrive as props. - * - * Next.js my-files-table-view.tsx fetch + notFound + server actions - * TanStack Start routes/_authenticated/files loader + useQuery + browser deletes - * \ / - * MyFilesTableContent - * - * ## What it does not own - * - * **Fetching.** It is handed a page. Which page, and how it was fetched, is - * `my-files-query.ts`'s - the same definition a TanStack loader warms and a - * Next.js Server Component awaits. - * - * **Deleting.** Two callbacks, because the two frameworks genuinely differ: one - * ends in `revalidatePath`, the other in a query invalidation, and neither can - * be expressed in the other's runtime. What they share - the `409` handling, the - * force pass, the bulk accounting - is in `my-files-delete.ts` and in - * `lib/files/`, so the difference really is only the last line. - * - * **Navigating.** Sorting, paging and searching rewrite the URL, and this - * component never learns how. `ContentDataTable`'s controls read that from - * `DataTableNavigationProvider`, which the caller mounts - `DataTable` does it - * for Next.js, a TanStack route does it with `router.navigate`. Rendering - * `ContentDataTable` rather than `DataTable` is the whole of the difference: - * `DataTable` *is* the Next.js wiring. - * - * ## The one thing this costs Next.js - * - * `"use client"`, so the eight `cell` functions run in the browser rather than - * on the server as they did - which is what `DataTable` passing its table as - * `children` exists to preserve for the AdminCP's tables. It is paid here and - * nowhere else, and it is close to free on this particular table: `FilePreview`, - * `MetadataCell`, `DateFormat` and both action components were already client - * components, so the only thing that newly reaches the browser is the column - * list itself. The rendered HTML is unchanged, because Next.js server-renders - * client components too. - * - * The alternative was a second set of columns for Next.js to render on the - * server, which is the duplication this whole file exists to remove. - */ export const MyFilesTableContent = ({ data, onDeleteFile, diff --git a/packages/vitnode/src/views/layouts/providers.boundaries.test.ts b/packages/vitnode/src/views/layouts/providers.boundaries.test.ts index 18d7632e8..4daee2b70 100644 --- a/packages/vitnode/src/views/layouts/providers.boundaries.test.ts +++ b/packages/vitnode/src/views/layouts/providers.boundaries.test.ts @@ -7,23 +7,6 @@ import { NEXT_INTL, NEXT_ONLY } from "@/tests/import-graph"; const srcRoot = resolve(import.meta.dirname, "../.."); -/** - * Everything the shared provider layer is allowed to reach, transitively. - * - * The reason this scan exists has outlived the two-host situation that prompted - * it. It was written because `apps/docs` (Next.js) and `apps/web` (TanStack - * Start) both mounted this tree, so a `next/*` import anywhere under it was a - * build failure in the app without Next.js - surfacing in whichever module - * happened to import it, a long way from the line that caused it. There is one - * host now, and the same import would instead be a package that silently - * requires a framework it no longer declares. - * - * `NEXT_INTL` covers the root entry too, which it deliberately did not before: - * that entry re-exports `use-intl` and resolves fine, which is exactly what let - * it survive - and what made two components read a `use-intl` context from a - * module record nothing provided into. - */ - const EXTENSIONS = [".ts", ".tsx", "/index.ts", "/index.tsx"]; const resolveModule = (base: string): null | string => { diff --git a/packages/vitnode/src/views/layouts/providers.tsx b/packages/vitnode/src/views/layouts/providers.tsx index b2e6d62f3..98c706429 100644 --- a/packages/vitnode/src/views/layouts/providers.tsx +++ b/packages/vitnode/src/views/layouts/providers.tsx @@ -13,14 +13,6 @@ import { CONFIG } from "@/lib/config"; import { RateLimitListener } from "./rate-limit-listener"; -/** - * The slice of the config the provider tree needs. - * - * Deliberately not the whole `VitNodeConfig`: `plugins` and `i18n.messages` hold - * functions, which neither React nor a route loader can carry to the browser - - * and on a framework without Server Components, importing the module they live - * in is what drags an admin form into the client bundle. - */ export interface VitNodeProvidersConfig extends Pick< VitNodeConfig, "debug" | "theme" @@ -28,19 +20,6 @@ export interface VitNodeProvidersConfig extends Pick< locales: LocaleConfig[]; } -/** - * Every provider a VitNode page needs and no framework does. - * - * Theme, toasts, the rate-limit listener, tooltips and the language list: none - * of them know which router rendered them, so both the Next.js app and the - * TanStack Start app mount this same tree. What each framework *does* own stays - * outside it - the progress bar and Query integration in Next.js - * (`views/layouts/provider.tsx`), the router-owned QueryClient in TanStack Start. - * - * `ThemeProvider` sits outermost because `Toaster` reads the resolved theme, and - * the no-flash script that paints the theme before React exists is rendered by - * the shell instead (see `components/theme-script.tsx`). - */ export const VitNodeProviders = ({ children, toaster, diff --git a/packages/vitnode/src/views/layouts/theme/header/header-boundaries.test.ts b/packages/vitnode/src/views/layouts/theme/header/header-boundaries.test.ts index 450862355..d90bdce54 100644 --- a/packages/vitnode/src/views/layouts/theme/header/header-boundaries.test.ts +++ b/packages/vitnode/src/views/layouts/theme/header/header-boundaries.test.ts @@ -15,22 +15,6 @@ import { const here = dirname(fileURLToPath(import.meta.url)); const srcRoot = resolve(here, "../../../.."); -/** - * The main header, split down the middle. - * - * The same boundary `theme-boundaries.test.ts` draws around the shell one level - * up, and it is the header that makes it worth drawing twice: the shell is four - * slots and no imports, while the header is the design system - a link, a - * button, two dropdowns and a theme toggle. One import that only resolves inside - * a Next.js app anywhere in that graph turns the whole `apps/web` shell into a - * build error nobody sees until they try it. That is not hypothetical: - * `HeaderContent` was Next-only for one back button, and `use-captcha` made - * every `AutoForm` Next-only for one navigation import. - * - * The shared half is the bar, the logo placement, the nav and the action area. - * The Next half is `getTranslations`, `next-intl`'s locale-aware `Link` and the - * async user slot - and it is the control that proves this scan can see them. - */ const SHARED = { header: join(here, "header-content.tsx"), languageSwitcher: join( @@ -44,13 +28,6 @@ const SHARED = { ), }; -/** - * The Next.js half, by path, so its absence can be asserted. - * - * Named rather than deleted along with the assertions: each of these was the - * only place a Next.js API was allowed to appear, and a test that stops naming - * them cannot notice one coming back. - */ const DELETED_NEXT_HALF = { header: join(here, "header.tsx"), headerLink: join(here, "header-next.tsx"), @@ -161,19 +138,6 @@ describe("the shared language switcher takes the navigation as a callback", () = }); it("reads no URL itself, so no host has to wrap it in Suspense", () => { - /** - * What replaced a Next-specific structural requirement. - * - * The wrapper had to render `<React.Suspense>` around its own URL reads: - * Next 16 refuses to prerender a client component that reads URL data - * outside one, so the AdminCP's prerendered routes failed `next build` - * without it. The requirement came from where the hooks were, not from the - * control - and with the hooks gone from this package entirely, the shared - * control has no URL to read and nothing to wrap. - * - * Asserted rather than assumed because the tempting way to "restore" the - * language switcher's convenience is to put a router hook back into it. - */ expect(code).not.toContain("usePathname"); expect(code).not.toContain("useRouter"); expect(code).not.toContain("React.Suspense"); diff --git a/packages/vitnode/src/views/layouts/theme/header/header-content.tsx b/packages/vitnode/src/views/layouts/theme/header/header-content.tsx index bc97965c9..645baf103 100644 --- a/packages/vitnode/src/views/layouts/theme/header/header-content.tsx +++ b/packages/vitnode/src/views/layouts/theme/header/header-content.tsx @@ -8,43 +8,6 @@ import type { HeaderLinkComponent, HeaderNavItem } from "./header-nav"; import { HEADER_HREF } from "./header-nav"; -/** - * The main header - the bar itself, the logo, the nav and the action area. - * - * Presentation only, and framework-free on purpose: it reaches nothing from - * `next/*`, nothing from `next-intl`'s Next-only entries, nothing from - * `@/lib/navigation` and nothing from TanStack Router. So a TanStack Start route - * renders exactly the header the Next.js pages render, down to the class names, - * instead of a second copy of this markup drifting alongside it. - * - * Not to be confused with `components/ui/header-content.tsx`, which is a *page* - * heading (`<h1>`, description, back button). This is the site header. - * - * ## What it takes, and why each one is a prop - * - * - `LinkComponent` - the only genuinely framework-specific piece. See - * {@link HeaderLinkComponent}. - * - `logo` - an element, because the mark is the application's, not core's. - * - `navigation` - data rather than translated in here, because the two - * frameworks resolve strings in different places: Next.js on the server, where - * they cost the client bundle nothing, and TanStack Start in the browser. - * Built by `headerNavItems` on both sides, so the links and their order are - * shared even though the lookup is not. - * - `languageSwitcher` - an element, because *switching* language is navigation - * and therefore framework-specific. The dropdown itself is shared - * (`components/switchers/langs/language-switcher-content.tsx`); only the two - * lines that move the URL differ. Omitted entirely when a deployment serves - * one language, which is the caller's question to answer. - * - `user` - an element. In Next.js it is an async Server Component inside its - * own `<Suspense>`; in TanStack Start it is whatever the session slot renders. - * Either way the header only needs somewhere to put it. - * - * `ThemeSwitcher` is *not* a prop: it reads the theme from `VitNodeProviders`, - * which both apps mount, and translates through `use-intl`'s context, which both - * apps provide. It was already framework-neutral - `apps/web` renders it - * unchanged - so injecting it would be a prop every caller has to pass and - * nobody gets to answer differently. - */ export interface HeaderLayoutContentProps extends Omit< React.ComponentProps<"header">, "children" diff --git a/packages/vitnode/src/views/layouts/theme/header/header-nav.test.ts b/packages/vitnode/src/views/layouts/theme/header/header-nav.test.ts index 3a087e90a..7e865c3a9 100644 --- a/packages/vitnode/src/views/layouts/theme/header/header-nav.test.ts +++ b/packages/vitnode/src/views/layouts/theme/header/header-nav.test.ts @@ -6,15 +6,6 @@ import { headerNavItems, } from "./header-nav"; -/** - * The main nav, as the two frameworks build it. - * - * `headerNavItems` is the whole of what they share: a label from a translator - * each resolves in its own way, paired with an href neither is allowed to spell - * itself. What is pinned here is that pairing - the destinations, the order, and - * that the hrefs stay internal - because a difference in any of the three is a - * header that looks migrated and navigates somewhere else. - */ describe("the main nav", () => { const labels = { discover: "Discover", search: "Search" }; diff --git a/packages/vitnode/src/views/layouts/theme/header/header-nav.ts b/packages/vitnode/src/views/layouts/theme/header/header-nav.ts index 10ef52195..ba8872523 100644 --- a/packages/vitnode/src/views/layouts/theme/header/header-nav.ts +++ b/packages/vitnode/src/views/layouts/theme/header/header-nav.ts @@ -1,27 +1,3 @@ -/** - * The main header's links, as data. - * - * Two routes today - Discover and Search - and the reason they are a list rather - * than two hard-coded `<Link>`s is that the header is now rendered by two - * frameworks. Both have to agree on *where* the nav points and *what order* it - * is in; only the component that turns an href into a navigation differs. So the - * destinations live here, the labels are resolved by whoever has a translator, - * and {@link headerNavItems} puts the two together in one place. - * - * This is deliberately not a navigation framework and not a route registry. - * There is no active-state resolution, no nesting and no permissions here: the - * header renders the same two links it always has, and a plugin that wants a - * third one is a design question this stage does not answer. - */ - -/** - * The anchor a header link ends up rendering. - * - * Every prop of one, not just `href`: a caller may hand the logo link a class - * name, and the nav links get `buttonVariants(...)`. The same shape - * `AuthLinkProps` and `HeaderContentBackLinkProps` already use, for the same - * reason - a wrapper that accepted only `href` would silently drop the rest. - */ export interface HeaderLinkProps extends Omit< React.ComponentProps<"a">, "href" @@ -29,16 +5,6 @@ export interface HeaderLinkProps extends Omit< href: string; } -/** - * The one thing the header cannot decide for itself. - * - * Turning `/discover` into a client-side navigation is the single question whose - * answer differs between the two frameworks: Next.js wants `next-intl`'s - * locale-aware `Link` (`@/lib/navigation`), TanStack Start wants the router's own - * - and TanStack Start wants the router's own (`RouterLink`). Both are a component - * taking {@link HeaderLinkProps}, so the header takes one and stops caring - and - * importing neither is what lets a TanStack Start route render it. - */ export type HeaderLinkComponent = (props: HeaderLinkProps) => React.ReactNode; /** Where the main header points. Internal paths, with no locale prefix in them. */ @@ -48,17 +14,6 @@ export const HEADER_HREF = { search: "/search", } as const; -/** - * The keys the labels come from, under `core.search`. - * - * Named here so the two wrappers cannot spell them differently, and left as - * literals (`as const`) so a typed translator still checks them at the call - * site - which is why the *keys* are shared and the translator is not. Next.js - * resolves them on the server with `getTranslations`, TanStack Start in the - * browser with `useTranslations`; the two translator types are not - * interchangeable, and passing one through a shared signature would mean giving - * up key checking in both. - */ export const HEADER_NAV_MESSAGE_KEYS = { discover: "nav.discover", search: "nav.search", @@ -76,12 +31,6 @@ export interface HeaderNavLabels { search: string; } -/** - * The main nav, in the order it renders. - * - * Pure, and the only place that pairs a destination with its label - so the two - * frameworks cannot drift into a different set of links or a different order. - */ export const headerNavItems = ({ discover, search, diff --git a/packages/vitnode/src/views/layouts/theme/header/user/user-header-boundaries.test.ts b/packages/vitnode/src/views/layouts/theme/header/user/user-header-boundaries.test.ts index 769217eb5..9a4a8763a 100644 --- a/packages/vitnode/src/views/layouts/theme/header/user/user-header-boundaries.test.ts +++ b/packages/vitnode/src/views/layouts/theme/header/user/user-header-boundaries.test.ts @@ -14,32 +14,11 @@ import { const here = dirname(fileURLToPath(import.meta.url)); -/** - * The user header, split down the middle. - * - * The same boundary `theme-boundaries.test.ts` and `auth-boundaries.test.ts` - * draw, for the same reason and with the same machinery: `UserHeaderContent` is - * rendered by a TanStack Start route as well as by Next.js, and one import that - * only resolves inside a Next.js app turns that route into a failure nobody sees - * until they try it. This is the header slot most likely to acquire one - it is - * the part with links, a session and a mutation in it. - * - * `next-user-header.tsx` is the control: it provably reaches the locale-aware - * `Link` and the sign-out server action, which is exactly what the shared half - * must not. - */ const SHARED = { content: join(here, "user-header-content.tsx"), model: join(here, "user-header-model.ts"), }; -/** - * The Next.js half, by path, so its absence can be asserted. - * - * Named rather than deleted along with the assertions that used them: each was - * the one place a Next.js API was allowed to appear in this subtree, and a test - * that stops naming them cannot notice one coming back. - */ const DELETED_NEXT_HALF = join(here, "next-user-header.tsx"); const sharedEntries = Object.entries(SHARED).map(([name, path]) => ({ diff --git a/packages/vitnode/src/views/layouts/theme/header/user/user-header-content.tsx b/packages/vitnode/src/views/layouts/theme/header/user/user-header-content.tsx index 82d208f3b..217eb52a0 100644 --- a/packages/vitnode/src/views/layouts/theme/header/user/user-header-content.tsx +++ b/packages/vitnode/src/views/layouts/theme/header/user/user-header-content.tsx @@ -24,49 +24,8 @@ import type { import { USER_HEADER_HREF, userHeaderMenu } from "./user-header-model"; -/** - * The user area of the main header, rendered by both applications. - * - * Presentation only, and framework-free on purpose: it reaches nothing from - * `next/*`, nothing from `next-intl`'s Next-only entries and no server action, - * so a TanStack Start route renders exactly what the Next.js header renders. The - * three things it cannot decide for itself - the session, how a path becomes a - * navigation, and what ends a session - arrive as props. - * - * UserHeaderContent - * state === "loading" -> the placeholder, at the size of the real thing - * state === "anonymous" -> log in, register - * state === "authenticated" -> avatar -> account links, staff link, sign out - * - * It does not fetch the session. That is the whole reason it is reusable: the two - * applications get it from places that have nothing in common - a Server - * Component awaiting `getSessionApi()`, and the one canonical session query a - * router's guards already read - and a component that asked for it itself would - * be a second source of truth in the app that already has one. - * - * The same boundary `SearchFeedContent`, `HeaderContent` and the auth screens - * draw, for the same reason. - */ - -/** - * What stands in for the user area while the session is unknown. - * - * `h-9 w-32` is the size of the two guest buttons, which is the wider of the two - * outcomes - so the header settles into its final width rather than growing when - * the session lands. Exported because the Next.js header renders it as a - * `<Suspense>` fallback *above* this component, before any state exists. - */ export const UserHeaderSkeleton = () => <Skeleton className="h-9 w-32" />; -/** - * Ending the session, as the header asks for it. - * - * Nothing is returned, because what happens next is entirely the caller's - * business and the two answers share nothing: a Next.js server action - * revalidates the layout and redirects, while TanStack Start replaces the cached - * session and invalidates the router so the guards notice. The header's only job - * is to say when. - */ export type UserHeaderSignOut = () => Promise<void> | void; const AnonymousUserHeader = ({ diff --git a/packages/vitnode/src/views/layouts/theme/header/user/user-header-model.ts b/packages/vitnode/src/views/layouts/theme/header/user/user-header-model.ts index 823c34f35..a05177bf2 100644 --- a/packages/vitnode/src/views/layouts/theme/header/user/user-header-model.ts +++ b/packages/vitnode/src/views/layouts/theme/header/user/user-header-model.ts @@ -2,37 +2,6 @@ import type { LucideIcon } from "lucide-react"; import { FileIcon, KeyRoundIcon, SettingsIcon, UserIcon } from "lucide-react"; -/** - * The user area of the main header, as data. - * - * No JSX, no framework and no I/O, so the three questions the header actually - * answers - who is asking, which items they get, and where each one leads - can - * be stated and tested without a router, a session or a DOM. The component in - * `user-header-content.tsx` renders exactly what this returns and decides - * nothing itself. - * - * That split is what makes the same header work in both applications. The - * Next.js app resolves the session in a Server Component; the TanStack Start app - * reads it from the canonical session query (`#/lib/auth/query` in `apps/web`). - * Both end up handing a {@link UserHeaderState} to one component. - * - * ## What it is not - * - * Not an authorization rule. `isAdmin` decides whether to *draw a link*, and - * nothing more: the AdminCP runs on its own session with its own sign-in, and - * every private read is authorized by Hono from the session cookie. A visitor - * who edits a cached session gets an extra menu item and an API that still - * refuses them. - */ - -/** - * The anchor a user-header link ends up rendering. - * - * Every prop of one, not just `href`: the menu items put a link inside a Base UI - * `render`, which clones the element with the children, the class name and the - * ref it needs to stay a menu item. A wrapper accepting only `href` would drop - * all three, so the type says so. - */ export interface UserHeaderLinkProps extends Omit< React.ComponentProps<"a">, "href" @@ -40,32 +9,10 @@ export interface UserHeaderLinkProps extends Omit< href: string; } -/** - * The one thing this header cannot decide for itself. - * - * Turning `/settings` into a navigation is the single question whose answer - * differs by host: a TanStack Start app wants the router's own `Link`, and a - * host that mounts VitNode differently wants its own. Each is a component taking - * {@link UserHeaderLinkProps}, so the header takes one and stops caring - and - * importing none of them is what keeps this file host-neutral. - * - * Required rather than defaulting to `<a>`: a missing wrapper would degrade - * silently into a full document reload on every menu item. - */ export type UserHeaderLinkComponent = ( props: UserHeaderLinkProps, ) => React.ReactNode; -/** - * The visitor, as the header needs them - four fields and no more. - * - * A *requirement* rather than a copy of the session response: both applications' - * `SessionApi["user"]` satisfy it structurally, so neither has to reshape - * anything and a field renamed in `api/modules/users/routes/session.route.ts` - * fails at the two call sites rather than being silently rendered as - * `undefined`. The same shape `Avatar` already asks for, plus the one flag the - * menu branches on. - */ export interface UserHeaderUser { avatarColor: string; isAdmin: boolean; @@ -73,35 +20,11 @@ export interface UserHeaderUser { nameCode: string; } -/** - * A session as the state the header renders. - * - * Three states rather than a nullable user, because "we do not know yet" is a - * real one and the header is on every page: the Next.js app answers it with a - * `<Suspense>` fallback while the Server Component awaits the session, and the - * TanStack Start app with a query that has not resolved. Both need a placeholder - * of the right size, and a `user: null` that meant both "signed out" and "still - * loading" would render the login buttons for a moment to somebody who is signed - * in. - */ export type UserHeaderState = | { status: "anonymous" } | { status: "authenticated"; user: UserHeaderUser } | { status: "loading" }; -/** - * Where the header links to. - * - * Ordinary data, not a route table - nothing here derives an href from a route - * file, and nothing here is conditional on a route existing. The *link - * component* turns each one into a navigation, so a route that moves needs no - * edit here. - * - * That is a claim worth having been tested rather than asserted, and it has - * been: every href in this record has changed which framework renders it at - * least once, and none of those changes edited this file. - * `apps/web/src/tests/header-navigation.test.ts` pins it. - */ export const USER_HEADER_HREF = { adminCp: "/admin", files: "/files", @@ -110,13 +33,6 @@ export const USER_HEADER_HREF = { signUp: "/register", } as const; -/** - * A visitor's own profile page. - * - * `encodeURIComponent` because a name code reaches this from the API and a path - * segment is not a place to interpolate an unescaped string. Today's codes are - * slug-safe and it is a no-op for all of them. - */ export const userProfileHref = (nameCode: string): string => `/users/${encodeURIComponent(nameCode)}`; @@ -184,33 +100,6 @@ export const userHeaderMenu = ( ]; }; -/** - * A session read as the state the header renders. - * - * Total and pure, and the one place the three states are decided: - * - * a session -> its `user` decides: signed in, or anonymous - * no session yet -> loading, unless the read has already failed - * a failed read -> anonymous - * - * ## A failed read shows the guest controls - * - * Which is the existing behaviour rather than a new decision: the Next.js - * `getSessionApi()` answers `{ user: null }` for any non-200, so an outage has - * always rendered the login buttons. The header has to draw *something* and a - * permanent skeleton is not it. - * - * Note what it is not: this is not a route guard, and it must not be used as - * one. `#/lib/auth/query`'s `ensureAuthState` deliberately *rejects* on a failed - * read so that a guard never signs anybody out because of a 500 - see the long - * note in `#/lib/auth/shared`. Drawing a login button for a visitor who is - * actually signed in costs them one click; sending them to the login page costs - * them the page they were on. - * - * A session already in hand wins over an error, so a signed-in visitor keeps - * their header through a failed *refetch* rather than flickering to anonymous - * and back. - */ export const userHeaderState = ({ isError = false, session, diff --git a/packages/vitnode/src/views/layouts/theme/layout-content.tsx b/packages/vitnode/src/views/layouts/theme/layout-content.tsx index bb1d88918..6e90a3140 100644 --- a/packages/vitnode/src/views/layouts/theme/layout-content.tsx +++ b/packages/vitnode/src/views/layouts/theme/layout-content.tsx @@ -1,27 +1,3 @@ -/** - * The main application shell, as a document structure and nothing else. - * - * Four slots and one `<main>`. Everything that decides *what* goes in a slot - - * reading the session, subscribing to a WebSocket, resolving a breadcrumb from - * the router - is the framework's business and stays in the framework's half: - * `layout.tsx` fills these slots from Server Components, `apps/web`'s `_main` - * route fills them from its router. What both of them get from here is the same - * element order and the same semantic `<main>`, so the two runtimes cannot - * quietly drift into different documents. - * - * It imports nothing. That is deliberate rather than incidental: the moment this - * reaches for `@/lib/navigation`, `next-intl/server` or a `"use server"` module - * it stops being renderable outside Next.js, which is the failure - * `theme-boundaries.test.ts` exists to catch. - * - * ## Why `<main>` is here rather than in each page - * - * There is exactly one `<main>` per document, and a page that renders its own - * inside a shell that also renders one produces two - invalid HTML, and a - * screen reader that now has two "main" landmarks to choose from. The shell owns - * the landmark; a page owns its width, its padding and its vertical rhythm, in - * whatever container it likes. - */ export const ThemeLayoutContent = ({ breadcrumb, children, @@ -33,11 +9,7 @@ export const ThemeLayoutContent = ({ children: React.ReactNode; /** The site header. A slot, because its contents are framework-bound. */ header?: React.ReactNode; - /** - * Components that render nothing and only subscribe - notification toasts, - * the WebSocket's sign-in/sign-out resync. First in the tree so they are - * mounted before anything that can produce an event for them. - */ + listeners?: React.ReactNode; }) => ( <> diff --git a/packages/vitnode/src/views/layouts/theme/theme-boundaries.test.ts b/packages/vitnode/src/views/layouts/theme/theme-boundaries.test.ts index 162be2ee7..8d57a213f 100644 --- a/packages/vitnode/src/views/layouts/theme/theme-boundaries.test.ts +++ b/packages/vitnode/src/views/layouts/theme/theme-boundaries.test.ts @@ -14,27 +14,8 @@ import { const here = dirname(fileURLToPath(import.meta.url)); -/** - * The main shell, split down the middle. - * - * The same boundary `auth-boundaries.test.ts` draws around the login screens, - * for the same reason and with the same machinery: `ThemeLayoutContent` is - * rendered by a TanStack Start route as well as by Next.js, and a single import - * that only resolves inside a Next.js app turns that route into a build error - * nobody sees until they try it. - * - * The shared half is the *structure* - the slot order and the `<main>` landmark. - * Everything that fills a slot is the framework's, and `layout.tsx` is the proof - * that the Next.js half really does reach the things the shared half must not. - */ const SHARED_ENTRY = join(here, "layout-content.tsx"); -/** - * The Next.js half, by path, so its absence can be asserted. - * - * Named rather than deleted along with the assertions that used them: each was - * the one place a Next.js API was allowed to appear in this subtree, and a test - * that stops naming them cannot notice one coming back. - */ + const DELETED_NEXT_HALF = join(here, "layout.tsx"); describe("the shared main shell is framework-neutral", () => { diff --git a/packages/vitnode/src/views/layouts/theme/web-socket-auth-sync.tsx b/packages/vitnode/src/views/layouts/theme/web-socket-auth-sync.tsx index 9e2de9fdd..406ba6a87 100644 --- a/packages/vitnode/src/views/layouts/theme/web-socket-auth-sync.tsx +++ b/packages/vitnode/src/views/layouts/theme/web-socket-auth-sync.tsx @@ -12,19 +12,6 @@ import type { VitNodeSocketUserId } from "@/ws/auth-sync"; import { shouldReconnectForUser } from "@/ws/auth-sync"; import { useVitNodeWebSocketContext } from "@/ws/provider"; -/** - * Keeps the shared WebSocket authenticated as the visitor the app currently - * believes in. - * - * Renders nothing and holds no state of its own: it is a client effect driven by - * one input. Which is what makes it framework-neutral - the app decides where - * `userId` comes from (`getSessionApi()` in Next.js, the canonical session query - * in TanStack Start) and this only reacts to it changing. - * - * `undefined` means the session is not known yet, and is the normal first value - * on a framework that reads it in the browser. It is deliberately not the same - * as `null`; see {@link shouldReconnectForUser}, which owns that distinction. - */ export const WebSocketAuthSync = ({ userId, }: { diff --git a/packages/vitnode/src/views/search/feed-boundaries.test.ts b/packages/vitnode/src/views/search/feed-boundaries.test.ts index 2bc402c15..561e5dad0 100644 --- a/packages/vitnode/src/views/search/feed-boundaries.test.ts +++ b/packages/vitnode/src/views/search/feed-boundaries.test.ts @@ -15,37 +15,14 @@ import { const here = dirname(fileURLToPath(import.meta.url)); const SHARED_ENTRY = join(here, "search-feed-content.tsx"); -/** - * The Next.js half, by path, so its absence can be asserted. - * - * Named rather than deleted along with the assertions that used them: each was - * the one place a Next.js API was allowed to appear in this subtree, and a test - * that stops naming them cannot notice one coming back. - */ + const DELETED_NEXT_HALF = { controls: join(here, "search-controls.tsx"), feed: join(here, "search-feed.tsx"), }; -/** - * The search page's controls, and the Next wrapper they were split out of. - * - * The same boundary as the feed's, one level up and with more at stake: the - * controls render an input group, a native select and a row of buttons, so this - * is where a stray Next.js import inside the *design system* would show up. That - * is not hypothetical - `HeaderContent` was Next-only for one back button, and - * `use-captcha` made every `AutoForm` Next-only for one navigation import. - */ const SHARED_CONTROLS = join(here, "search-controls-content.tsx"); -/** - * The other half of what a migrated feed page renders. - * - * Scanned here rather than in a file of its own because it is the same boundary - * for the same reason: `/discover` is a heading and a feed, and either one - * reaching `next-intl/navigation` makes the whole page Next-only. This one did, - * until the back link became a prop. - */ const HEADER_CONTENT = join(here, "../../components/ui/header-content.tsx"); describe("the shared search feed is framework-neutral", () => { diff --git a/packages/vitnode/src/views/search/search-controls-content.tsx b/packages/vitnode/src/views/search/search-controls-content.tsx index a7cc3cca0..8b256bc24 100644 --- a/packages/vitnode/src/views/search/search-controls-content.tsx +++ b/packages/vitnode/src/views/search/search-controls-content.tsx @@ -38,52 +38,10 @@ import { sortForAppliedTerm, } from "./search-params"; -/** - * The search feed a set of controls is currently asking for. - * - * A factory rather than a finished options object, because the controls are what - * *changes* the request: the visitor types, toggles a type or picks a sort, and - * the feed has to become a different query. So the caller supplies the one thing - * only it knows - how a page is fetched in this app - and the controls supply the - * parameters. - * - * Both frameworks build it from the same `searchFeedQueryOptions`, so the query - * key, the cursor rule, the response check and the paging behaviour are the - * feed's, not this component's. See {@link SearchControlsContent}. - */ export type SearchFeedQueryFactory = ( params: SearchFeedParams, ) => SearchFeedQueryOptions; -/** - * The search page's controls and its results, with nothing framework-shaped in - * them. - * - * This is the whole of the search *interaction* - the debounced term, the type - * filters, the sort, and the feed underneath - and it runs unchanged under - * Next.js and under TanStack Start. Exactly two things are injected, and they - * are the same two {@link SearchFeedContent} already needed: - * - * - **`feedQuery`.** See {@link SearchFeedQueryFactory}. The transport is the - * only part of a feed that genuinely differs between a server rendering a - * request and a browser, so it is the only part that crosses this boundary. - * - **`LinkComponent`.** A search hit points wherever the indexed content lives, - * and turning that into a client-side navigation is the router's job. - * - * Translations come from `use-intl` directly - the framework-free half of - * `next-intl`, and the same instance `NextIntlClientProvider` provides into - so - * the Next.js app needs no extra provider for this to work. - * - * ## The URL is deliberately not written to - * - * The term, the types and the sort are component state. Only the *initial* term - * comes from outside, as `defaultParams`, which is how the page has always - * worked: `/search?search=hello` opens on a search for "hello" and everything - * after that is local. A route that wants the URL to follow the controls has to - * decide what a shareable search URL is - which sort belongs in it, whether every - * keystroke is a history entry - and that is a product question, not a migration - * one. - */ export const SearchControlsContent = ({ LinkComponent, defaultParams, @@ -107,14 +65,6 @@ export const SearchControlsContent = ({ () => defaultParams.sort ?? defaultSearchSort(defaultParams.search), ); - /** - * The searched-for term, a moment after the visitor stops typing. - * - * `appliedSearchTerm` owns the decision - including "do nothing", which is - * what a one- or two-character term gets - so this only has to act on it. A - * term that *is* applied also moves the sort off the browse default, and only - * off that one: see `sortForAppliedTerm`. - */ const applyTerm = useDebouncedCallback((value: string) => { const next = appliedSearchTerm(value); @@ -130,12 +80,6 @@ export const SearchControlsContent = ({ ); }; - /** - * Rebuilt on every render, deliberately: it is derived from state that changes - * as the visitor types, so memoising on it would be memoising on a moving - * value. Query hashes keys structurally, so an equal object is the same cache - * entry. - */ const params = searchFeedParamsFor({ search: appliedTerm, sort, types }); return ( diff --git a/packages/vitnode/src/views/search/search-feed-content.test.tsx b/packages/vitnode/src/views/search/search-feed-content.test.tsx index 679ff6d2d..f4de1e84e 100644 --- a/packages/vitnode/src/views/search/search-feed-content.test.tsx +++ b/packages/vitnode/src/views/search/search-feed-content.test.tsx @@ -10,12 +10,6 @@ import type { } from "./search-feed-content"; import type { SearchFeedPage, SearchResultItem } from "./types"; -/** - * The one boundary a render test cannot cross: the feed talks to the search API. - * - * `clientModule` is mocked alongside it because the real one is imported at - * module scope, before any test has run. - */ const fetcherClient = vi.fn(); vi.mock("@/lib/fetcher-client", () => ({ @@ -50,13 +44,6 @@ const plMessages = { }, }; -/** - * The link the shared feed is handed, standing in for a framework's own. - * - * It records what it was asked to render, which is how the tests below tell an - * internal href (delegated here, and so client-side navigable) from an external - * one (a bare `<a>`). - */ const routedHrefs: string[] = []; const TestLink = ({ children, className, href }: SearchFeedLinkProps) => { @@ -429,13 +416,6 @@ describe("the loading state", () => { }); }); -/** - * The handle a prefetching framework needs. - * - * A TanStack Start route loader warms the cache before this component exists, so - * the key it writes and the key the component reads have to be the same one - - * otherwise the feed mounts, misses, and fetches page one all over again. - */ describe("the exported query key", () => { it("is the entry the feed actually stores its pages under", () => { const params: SearchFeedParams = { sort: "newest" }; @@ -463,14 +443,6 @@ describe("the exported query key", () => { }); }); -/** - * What a search result is allowed to link to. - * - * A document's `url` is written by whichever plugin indexed it, so it is data - * arriving from a database rather than a literal in this repository. The rule - * this replaced treated *any* scheme as "external, render it in an `<a href>`", - * which passed `javascript:` and `data:` through untouched. - */ describe("the URL scheme allowlist", () => { const ALLOWED = [ "http://example.com/post", diff --git a/packages/vitnode/src/views/search/search-feed-content.tsx b/packages/vitnode/src/views/search/search-feed-content.tsx index ae455ebd2..6c2279059 100644 --- a/packages/vitnode/src/views/search/search-feed-content.tsx +++ b/packages/vitnode/src/views/search/search-feed-content.tsx @@ -21,44 +21,18 @@ const SNIPPET_LENGTH = 240; export type SearchFeedVariant = "list" | "timeline"; -/** - * Re-exported so `search-feed.tsx` and `search-controls.tsx` keep importing - * their parameter type from where they always have. The definition now lives - * with the query it parameterises. - */ export type { SearchFeedParams, SearchFeedQueryOptions, } from "./search-feed-query"; export { searchFeedQueryKey } from "./search-feed-query"; -/** - * Everything the feed ever asks a link to be. - * - * Deliberately three props and no more. A search hit is a title and a - * destination - it never needs prefetch hints, scroll behaviour or an active - * state - so widening this later is a decision somebody has to make on purpose - * rather than one that leaks in. - */ export interface SearchFeedLinkProps { children: React.ReactNode; className?: string; href: string; } -/** - * The one thing this feed cannot decide for itself. - * - * A search result carries an app-internal path, and turning a path into a - * client-side navigation is the single question whose answer differs between - * the two frameworks: Next.js wants `next-intl`'s locale-aware `Link`, TanStack - * Start wants the router's own. Both are a component taking - * {@link SearchFeedLinkProps}, so the feed takes one and stops caring. - * - * It is a required prop rather than one defaulting to `<a>`: a missing wrapper - * would otherwise degrade silently into a full document reload, which is the - * kind of regression nobody notices until someone measures it. - */ export type SearchFeedLinkComponent = ( props: SearchFeedLinkProps, ) => React.ReactNode; @@ -128,13 +102,6 @@ export const classifySearchFeedHref = (href: string): SearchFeedHrefKind => { return SAFE_EXTERNAL_SCHEMES.has(scheme) ? "external" : "unsafe"; }; -/** - * A result's destination, rendered by whatever is allowed to render it. - * - * An `unsafe` href falls back to the children with no anchor at all, so a - * hostile document degrades to plain text instead of to a link nobody should - * click - and, just as importantly, is never handed to a router either. - */ const ResultLink = ({ LinkComponent, children, @@ -294,28 +261,6 @@ const SearchResultCard = ({ ); }; -/** - * The search feed, with nothing framework-shaped left in it. - * - * This is the whole of the rendering and paging behaviour - infinite scroll, the - * load-more fallback, both variants, the empty and loading states - and it runs - * unchanged under Next.js and under TanStack Start. Exactly two things are - * pulled out, and they are the only two that ever needed to be: - * - * - **`queryOptions`**, built by `searchFeedQueryOptions`. There is one - * `useInfiniteQuery` in this file and it is handed its definition, so the page - * a route loader prefetched and the page `fetchNextPage()` asks for come from - * the same request, the same cursor rule and the same status checking. This - * component used to build its own, which agreed with a loader on the cache key - * and on nothing else - a 400 on page two was parsed as a page and the feed - * quietly emptied itself. The locale and the search parameters left with it, - * because both are things the *query* needs rather than the markup. - * - **`LinkComponent`**. See {@link SearchFeedLinkComponent}. - * - * Translations come from `use-intl` directly - the framework-free half of - * `next-intl`, and the same instance `NextIntlClientProvider` provides into, so - * the Next.js app needs no extra provider for this to work. - */ export const SearchFeedContent = ({ LinkComponent, queryOptions, diff --git a/packages/vitnode/src/views/search/search-feed-query.ts b/packages/vitnode/src/views/search/search-feed-query.ts index 52168168a..17af123b8 100644 --- a/packages/vitnode/src/views/search/search-feed-query.ts +++ b/packages/vitnode/src/views/search/search-feed-query.ts @@ -8,37 +8,9 @@ import { RECORD_STALE_TIME } from "@/lib/query-freshness"; import type { SearchFeedPage } from "./types"; -/** - * The search feed, as one query definition. - * - * Everything about *fetching* a feed lives here and nowhere else: the request, - * the page size, the cursor rule, what counts as a failure, and the cache entry - * it all lands in. `SearchFeedContent` renders whatever this produces and owns - * none of it. - * - * That split exists because the alternative was tried and does not hold. When - * the component built its own `useInfiniteQuery` and a TanStack Start loader - * built another, the two agreed on the cache *key* and on nothing else - so the - * server-rendered first page came from one contract and every `fetchNextPage()` - * after hydration came from a second one with a different cursor rule and no - * status checking. A 400 arrived as `{ message }`, was read as a page, and the - * feed silently rendered as empty. Sharing a key is not sharing a contract. - * - * The one thing deliberately *not* fixed here is the transport: a loader running - * on a server and a component running in a browser cannot reach the API the same - * way. So {@link searchFeedQueryOptions} takes a `fetchPage` and defaults it to - * the browser's, which is the only one a shared component can assume. - */ - /** How many hits one page holds, wherever that page is fetched from. */ export const SEARCH_FEED_PAGE_SIZE = 20; -/** - * Where a page starts. `null` is the first one, spelled as the *absence* of a - * cursor rather than an empty one: the route's schema rejects `cursor=` - * outright (`.min(1)`), so sending it empty would 400 the first page of every - * visit. - */ export type SearchFeedCursor = null | string; /** The first page carries no cursor. Named, because a test has to say so too. */ @@ -53,34 +25,17 @@ export interface SearchFeedParams { types?: string; } -/** - * The search module, as a value the fetchers can carry without pulling the API - * into either bundle. The module is imported as a *type* only, so route - * literals, methods and response schemas all still infer; `clientModule` - * supplies the one field the fetcher reads at runtime. - */ export const searchModuleRef = clientModule<typeof searchModule>( CONFIG_PLUGIN.pluginId, ); export interface SearchFeedPageArgs { cursor: SearchFeedCursor; - /** - * The language the page is rendered in. Required rather than defaulted: a - * feed that quietly falls back to the default locale is a Polish page full of - * English posts, and nothing about the response says so. - */ + locale: string; params: SearchFeedParams; } -/** - * One page of a feed, as the query string the API reads it from. - * - * `first` is a string because the query schema reads it off a query string, and - * every optional key is omitted rather than set to `undefined` so it never - * reaches the URL at all. - */ export const searchFeedQuery = ({ cursor, locale, @@ -102,24 +57,6 @@ export const searchFeedQuery = ({ return query; }; -/** - * Refuses a response that is not a search page. - * - * The fetchers hand non-2xx responses back rather than throwing on them - a - * rejected cursor is a 400, a rate-limited visitor a 429 - and `json()` would - * happily parse either one's `{ message }` body. Read as a page it has no - * `edges`, so the feed renders as empty: a failure that looks exactly like a - * community with nothing in it. Query can only retry, report, or keep the last - * good page if the promise actually rejects. - * - * A 500 never reaches here; `rawApiFetch` throws on those with the body - * attached. A 429 does, *after* `fetcherClient` has already raised the - * global rate-limit notice - so the visitor is told, and the query still fails - * rather than appending an error object as a page. - * - * Takes a plain `Response` so the caller keeps its typed one: passing the typed - * response in widens `ok` to a boolean and leaves `json()` alone. - */ export const assertSearchFeedResponse = ( response: Response, { cursor, locale }: SearchFeedPageArgs, diff --git a/packages/vitnode/src/views/search/search-params.test.ts b/packages/vitnode/src/views/search/search-params.test.ts index 636c4ea3d..15df7d4d9 100644 --- a/packages/vitnode/src/views/search/search-params.test.ts +++ b/packages/vitnode/src/views/search/search-params.test.ts @@ -19,16 +19,6 @@ import { TERM_SEARCH_SORT, } from "./search-params"; -/** - * What a search request means, asserted where it is decided. - * - * Every one of these is a pure function over data, which is the whole reason - * they were pulled out of the two components and the two route files that used - * to answer these questions inline. Nothing here mounts React, reaches a - * database or builds a URL: the feed's request is `searchFeedRequest`'s job and - * the API's own behaviour is the API's. - */ - describe("a term from a URL", () => { it("keeps a term somebody actually typed", () => { expect(normalizeSearchTerm("hono")).toBe("hono"); diff --git a/packages/vitnode/src/views/search/search-params.ts b/packages/vitnode/src/views/search/search-params.ts index b0f1a7abe..cd1db4f7f 100644 --- a/packages/vitnode/src/views/search/search-params.ts +++ b/packages/vitnode/src/views/search/search-params.ts @@ -1,42 +1,7 @@ import type { SearchFeedParams } from "./search-feed-query"; -/** - * What a search *request* is, decided once, as pure functions. - * - * The search page has three inputs - a term, a set of content types, a sort - - * and three places that have to agree on what they mean: a Next.js Server - * Component reading `searchParams`, a TanStack Start route reading its own - * search schema, and the controls the visitor drives in the browser. Every one - * of them used to answer separately, in a line or two of inline logic, and the - * answers were only accidentally the same: `search-view.tsx` decided the default - * sort with `search ? "relevance" : "newest"` while `search-controls.tsx` - * decided it with `defaultParams.sort ?? "newest"`. - * - * So the decisions live here, and nowhere else. Nothing in this module renders, - * fetches or knows which framework is asking - which is also what makes it the - * part of the search slice that is worth testing directly. - * - * {@link SearchFeedParams} - what these produce - is the shared feed's own - * parameter type, and `searchFeedRequest` in `./search-feed-query` is the only - * thing that turns one into a URL. - */ - -/** - * The shortest term the controls will search on. - * - * Two characters is almost always a prefix of something the visitor is still - * typing, and searching on it costs a full-text query per keystroke for a page - * of results nobody reads. - */ export const MIN_SEARCH_TERM_LENGTH = 3; -/** - * The longest term that reaches the API. - * - * A term arrives from a URL, so its length is whatever somebody put there. The - * API takes `search` as an unbounded string and hands it to the full-text - * engine, so this is the one place a 40KB query string stops being one. - */ export const MAX_SEARCH_TERM_LENGTH = 256; /** How long the controls wait after a keystroke before searching. */ @@ -61,19 +26,6 @@ export const isSearchSort = (value: unknown): value is SearchSort => typeof value === "string" && (SEARCH_SORT_VALUES as readonly string[]).includes(value); -/** - * A term worth searching for, or nothing at all. - * - * The input is deliberately `unknown`: `?search=a&search=b` parses to an array, - * a bare `?search` to an empty string, and both reach this from a URL nobody - * here wrote. Anything that is not a usable string becomes `undefined`, which is - * the browse feed - a malformed parameter should render the page, not break the - * route. - * - * Trimmed twice on purpose. The first trim is the term itself; the cut at - * {@link MAX_SEARCH_TERM_LENGTH} can land mid-space, so the second tidies the - * tail rather than sending a term ending in a run of blanks. - */ export const normalizeSearchTerm = (value: unknown): string | undefined => { if (typeof value !== "string") return undefined; @@ -82,14 +34,6 @@ export const normalizeSearchTerm = (value: unknown): string | undefined => { return term.length > 0 ? term : undefined; }; -/** - * The sort a feed takes when nobody has chosen one. - * - * Relevance is meaningless without a term - every row scores the same - so a - * browse feed is newest-first and a search is relevance-first. This is the rule - * `search-view.tsx` has always applied; it is stated here so the controls and a - * route loader apply the same one. - */ export const defaultSearchSort = (search?: string): SearchSort => search ? TERM_SEARCH_SORT : BROWSE_SEARCH_SORT; @@ -99,19 +43,6 @@ export const normalizeSearchSort = ( search?: string, ): SearchSort => (isSearchSort(value) ? value : defaultSearchSort(search)); -/** - * The content types a feed is filtered to, as a list. - * - * Accepts either shape the filter is held in - the API's comma-separated string - * or the array the controls keep in state - because both ends of the round trip - * come through here. Blank entries and duplicates are dropped rather than sent: - * `types=,blog_post,` reaches the API as three filters, two of which match - * nothing, and `blog_post,blog_post` is a filter applied twice. - * - * Unknown types are *not* dropped. A type is only unknown to the renderer - * registry - a plugin can index one this build has no icon for - and refusing it - * here would silently ignore a filter the API would have honoured. - */ export const parseSearchTypes = (value: unknown): string[] => { const raw = typeof value === "string" @@ -131,40 +62,12 @@ export const parseSearchTypes = (value: unknown): string[] => { return [...types]; }; -/** - * {@link parseSearchTypes}, back in the shape the API's query takes. - * - * Sorted, and that is the load-bearing part. This string ends up in the query - * key, so `blog_post,topic` and `topic,blog_post` would otherwise be two cache - * entries holding the same results - the API applies the filter as a set and - * does not care what order it arrived in, but Query hashes the string. Two - * visitors who ticked the same two boxes in a different order are looking at one - * feed. - * - * The *display* order is unaffected: the controls hold their filter as an array - * and only ever ask it whether a type is in it. - */ export const serializeSearchTypes = (value: unknown): string | undefined => { const types = parseSearchTypes(value).sort((a, b) => a.localeCompare(b)); return types.length > 0 ? types.join(",") : undefined; }; -/** - * A term, a sort and a set of types, as the shared feed's parameters. - * - * The one function that builds a {@link SearchFeedParams}, and therefore the one - * thing that decides which cache entry a search lands in: the object is part of - * the query key. Every key it omits is omitted *entirely* rather than set to - * `undefined`, so a route loader calling this with `{ search }` and a component - * calling it with `{ search, sort, types }` produce the same object - and Query, - * which hashes keys structurally, reads them as one entry. - * - * With no term at all this returns `{ sort: "newest" }`, which is exactly the - * browse feed `/discover` asks for. That is not a coincidence to be designed - * away: they are the same request over the same documents, so they share a cache - * entry. - */ export const searchFeedParamsFor = ({ search, sort, @@ -187,25 +90,6 @@ export const searchFeedParamsFor = ({ return params; }; -/** - * The term the feed should search on after a keystroke - or `null` for "not - * yet", which is the answer that matters. - * - * Three cases, and the middle one is the reason this is a function rather than a - * comparison at the call site: - * - * "wid" -> "wid" long enough to search - * "" -> "" cleared, so go back to browsing - * "wi" -> null keep showing what is on screen - * - * A one- or two-character term leaves the feed alone rather than resetting it to - * the browse feed, so backspacing through a word does not flash a page of - * unrelated results on the way. - * - * Length is measured on the raw value, not on a trimmed one: that is the - * behaviour the control has always had, and a term of blanks is normalised away - * later by {@link normalizeSearchTerm} anyway. - */ export const appliedSearchTerm = (value: string): null | string => { if (value.length >= MIN_SEARCH_TERM_LENGTH) return value; if (value.length === 0) return ""; @@ -213,12 +97,5 @@ export const appliedSearchTerm = (value: string): null | string => { return null; }; -/** - * The sort to use once a term has been typed. - * - * Only the browse default moves. A visitor who explicitly picked "oldest" keeps - * it - overriding a deliberate choice because they then typed something is the - * kind of helpfulness that reads as a bug. - */ export const sortForAppliedTerm = (sort: SearchSort): SearchSort => sort === BROWSE_SEARCH_SORT ? TERM_SEARCH_SORT : sort; diff --git a/packages/vitnode/src/vitnode.config.ts b/packages/vitnode/src/vitnode.config.ts index 30fef1eb8..39a060246 100644 --- a/packages/vitnode/src/vitnode.config.ts +++ b/packages/vitnode/src/vitnode.config.ts @@ -24,27 +24,6 @@ import type { BuildPluginReturn } from "./lib/plugin"; export type { LocaleConfig }; -/** - * An installation's shared configuration - `src/vitnode.config.ts`. - * - * **Browser-safe, and that is a contract rather than a happy accident.** The - * document shell reads `metadata`, `theme` and `debug`; the locale runtime reads - * `i18n`; the Vite plugin registry reads `plugins` while Vite is still loading - * its config. So every value here has to survive being bundled for a browser and - * being executed by `jiti` in Node - which means plain data and plugin - * *identity*, never a `() => import(...)` message loader and never a module that - * reaches a database. - * - * `plugins` holds each enabled plugin's registration - normally the plugin's own - * factory, `blogPlugin()`. A Next.js host walks that list directly. A TanStack - * Start host reads the same declarations back through build-time projections of - * each plugin's `admin/nav` and `admin/content` exports, which is what gets an - * editing screen loaded with the route that renders it rather than with the - * config; `buildPlugin({ pluginId })` is the minimum such a host needs. - * - * Everything that cannot honour the browser-safe contract lives in - * {@link VitNodeServerConfig}. - */ export interface VitNodeConfig< AppLocales extends LocaleConfig[] = LocaleConfig[], > { @@ -58,75 +37,23 @@ export interface VitNodeConfig< >; } -/** - * The half of an installation's configuration a browser may never hold - - * `src/vitnode.server.config.ts`. - * - * Two things, and both are functions that read files out of a package's build - * output: the app's own message overrides and the per-package loaders a bundled - * runtime has to declare for itself (see `BundledMessagesOptions.packageMessages`). - * Putting them beside `metadata` and `theme` is what used to force an app to - * keep two configs that agreed until they didn't. - * - * It holds the shared config rather than repeating any of it, so the locale - * list a message loader is resolved against is the same object the router and - * the document shell read. - */ export interface VitNodeServerConfig< AppLocales extends LocaleConfig[] = LocaleConfig[], > { /** The shared config this app also serves to the browser. */ config: VitNodeConfig<AppLocales>; - /** - * Translations owned by the app rather than by a package, keyed by locale and - * then by the plugin whose namespace they extend. Files live in - * `src/locales/<pluginId>/<locale>.json`. - */ + messages?: AppMessagesMap; - /** - * Where each installed package's translations are read from, keyed by plugin - * id - core included. - * - * A bundled runtime cannot use the locale barrel a package ships, because - * Rollup will not follow its `import("./en.json", { with: { type: "json" } })`. - * An app declares static specifiers a bundler can follow instead. - */ + packageMessages?: Record<string, LocaleMessagesMap | undefined>; } export interface VitNodeApiConfig { - /** - * AI models for the Vercel AI SDK. Declare them inline - each `model` is a - * Gateway id string (e.g. `"anthropic/claude-sonnet-5"`, no extra packages, - * uses `AI_GATEWAY_API_KEY`) or a provider instance (e.g. - * `anthropic("claude-sonnet-5")` from `@ai-sdk/anthropic`). The first entry is - * the default. In a route, resolve a model with `c.get("ai").model(id?)` and - * pass it to the native SDK functions, e.g. - * `generateText({ model: c.get("ai").model(), prompt })`. Leave undefined to - * disable AI - resolving a model then throws "No AI models configured". - */ ai?: AIConfig; authorization?: { adminCookieExpires?: number; adminCookieName?: string; - /** - * `Domain` to stamp on the session, admin, device and SSO cookies. - * - * Leave it unset - the default - and no `Domain` is sent at all, making the - * cookies *host-only*: valid on exactly the host that issued them. That is - * what a normal VitNode install wants, because the web app serves `/api/*` - * on its own origin, and it is the only setting that survives a hostname - * nobody configured, such as a per-branch preview deployment. - * - * Set it only to share one session across subdomains - `".example.com"` for - * `app.example.com` and `admin.example.com`. A value the response's own host - * does not fall under is rejected by the browser, so an install that gets - * this wrong cannot sign anybody in. - * - * Deliberately not derived from `NEXT_PUBLIC_WEB_URL`: that names where the - * front end lives, which is not the same question, and guessing it is how a - * preview deployment ends up sending `Domain=localhost`. - */ + cookieDomain?: string; cookieExpires?: number; cookieName?: string; @@ -142,18 +69,6 @@ export interface VitNodeApiConfig { }; /** Content Engine settings that are deployment-shaped rather than per type. */ content?: { - /** - * Web origins to notify when background work changes what is public. - * - * Opt-in, and empty by default: only a front end that holds its own render - * cache has anything to expire, and only that front end knows it. Set it to - * the origins that serve `POST /api/vitnode/content/revalidate`, and each is - * posted independently when one API serves several of them. - * - * Left unset, a background publish invalidates nothing beyond the API's own - * cache - which is the whole story for a front end that reads through the - * public content routes rather than caching renders of them. - */ revalidateOrigins?: string[]; }; cron?: CronAdapter; @@ -163,63 +78,25 @@ export interface VitNodeApiConfig { logo?: DefaultTemplateEmailProps["templateProps"]["logo"]; tailwindConfig?: DefaultTemplateEmailProps["templateProps"]["tailwindConfig"]; }; - /** - * Transport for domain events emitted via `c.get("events").emit(...)`. Ships - * a zero-config Local adapter used when `adapter` is omitted: listeners run - * sequentially in the emitting request, on the emitting instance only - * (single-process delivery). Swap the adapter to publish events to an - * external broker (e.g. Redis Streams, NATS) for cross-instance delivery. - */ + events?: { adapter?: EventsApiPlugin; }; - /** - * Languages the API renders in - today that means emails, and anything a - * route translates through `c.get("i18n")`. - * - * Optional: with no `i18n` block the locale list is derived from what the - * installed packages ship and `defaultLocale` is `en`. When an app serves the - * web and the API together, point this and `buildConfig` at the same object. - */ + i18n?: VitNodeApiI18nConfig; metadata: VitNodeMetadata; plugins: BuildPluginApiReturn[]; rateLimiter?: Omit<IRateLimiterOptions, "keyPrefix">; - /** - * Redis connection used as a shared cache (via `c.get("cache")`) and, when - * set, as the storage backend for the rate limiter. Leave undefined to run - * without Redis - the cache degrades to no-ops and the rate limiter falls - * back to in-memory storage. - */ + redis?: CacheConfig; - /** - * Search engine backing content discovery (`c.get("search")`). Ships a - * zero-config Postgres full-text provider used when `adapter` is omitted; an - * external Elasticsearch adapter is available as `@vitnode/elasticsearch`. The - * canonical index always lives in `core_search_index`, so switching engines is - * a config change followed by a rebuild. - */ + search?: { adapter?: SearchProviderApiPlugin; }; - /** - * Object storage backend used for file uploads, reached in route handlers via - * `c.get("storage").upload(...)`. Ships a zero-config Local (disk) adapter; - * cloud adapters are available as `@vitnode/s3` (AWS S3 + Cloudflare R2) and - * `@vitnode/supabase-storage`. Leave undefined to disable uploads. - */ + storage?: { adapter?: StorageApiPlugin; - /** - * Re-encode uploaded images with `sharp` before storing them, to shrink file - * size. Set to enable; `quality` defaults to 85 (1–100). Applies to JPEG, - * PNG, WebP, AVIF and TIFF - other files (incl. SVG/GIF) are stored as-is. - * - * Processed images are also converted to WebP by default (smaller than JPEG - * or PNG at the same quality); set `webp: false` to keep each image in its - * original format. Their pixel dimensions are recorded in - * `core_files.metadata.dimensions` for display in the admin panel. - */ + image?: { quality?: number; webp?: boolean; @@ -229,16 +106,6 @@ export interface VitNodeApiConfig { let registeredVitNodeConfig: undefined | VitNodeConfig; -/** - * Builds an installation's shared config - the one call in - * `src/vitnode.config.ts`. - * - * `const AppLocales` is what keeps `locales` a tuple of literal types through - * inference, so `Locale` derived from the result is `"en" | "pl"` rather than - * `string` and `defaultLocale` is checked against the list beside it. Without - * it every code widens to `string` and each app has to write `as const` on - * every entry. - */ export function buildConfig<const AppLocales extends LocaleConfig[]>( args: VitNodeConfig<AppLocales>, ): VitNodeConfig<AppLocales> { @@ -257,26 +124,12 @@ export function buildConfig<const AppLocales extends LocaleConfig[]>( return config; } -/** - * Builds the server-only companion to {@link buildConfig} - the one call in - * `src/vitnode.server.config.ts`. - * - * Identity, deliberately: there is nothing to normalise, and the value of the - * function is the type it pins and the file it names. Nothing registers it - * process-wide either, because everything that reads it is already on the - * server and can import it. - */ export function buildServerConfig<const AppLocales extends LocaleConfig[]>( args: VitNodeServerConfig<AppLocales>, ): VitNodeServerConfig<AppLocales> { return args; } -/** - * Returns the app's VitNodeConfig registered by {@link buildConfig} (called once - * in the app's `vitnode.config.ts`). Used by framework route files that need the - * config but aren't passed it as a prop. - */ export const getVitNodeConfig = (): VitNodeConfig => { if (!registeredVitNodeConfig) { throw new Error( diff --git a/packages/vitnode/src/ws/auth-sync.test.ts b/packages/vitnode/src/ws/auth-sync.test.ts index baacf5f2d..b0bd31ea4 100644 --- a/packages/vitnode/src/ws/auth-sync.test.ts +++ b/packages/vitnode/src/ws/auth-sync.test.ts @@ -2,17 +2,6 @@ import { describe, expect, it } from "vitest"; import { shouldReconnectForUser } from "./auth-sync"; -/** - * The transitions `WebSocketAuthSync` has to follow, and the ones it must leave - * alone. - * - * Worth stating as tests because both mistakes here are silent. Missing a - * transition means the server keeps delivering the previous visitor's - * notifications to a browser that has signed out of them - a leak, and one no - * error surfaces. Reconnecting when nothing changed means dropping the shared - * connection, which the manager relays to every tab of the origin, on a signal - * that arrives on every page load. - */ describe("shouldReconnectForUser", () => { it("reconnects when a guest signs in", () => { expect(shouldReconnectForUser(null, 7)).toBe(true); diff --git a/packages/vitnode/src/ws/auth-sync.ts b/packages/vitnode/src/ws/auth-sync.ts index 6d475ffd1..0579650af 100644 --- a/packages/vitnode/src/ws/auth-sync.ts +++ b/packages/vitnode/src/ws/auth-sync.ts @@ -1,57 +1,5 @@ -/** - * Whether a change of signed-in user requires the shared WebSocket to be - * re-opened - the one decision behind `WebSocketAuthSync`, as a pure function. - * - * It lives here, next to the manager it ultimately drives, rather than inside - * the component: the rule is about the socket's handshake, it is identical in - * every framework that mounts the component, and it is the part that can be - * wrong without anything failing loudly. - */ - -/** - * The user a WebSocket connection is authenticated as, as far as the client - * knows. - * - * `number` is a signed-in visitor, `null` a guest - the server derives the same - * value from the session cookie on the upgrade request (`handleVitNodeWebSocket` - * tags each connection with `c.get("user")?.id ?? null`) so it can deliver a - * per-user payload to the right connections. - */ export type VitNodeSocketUserId = null | number; -/** - * Whether the socket has to be dropped and re-opened, given the last identity - * it was known to carry and the one it should carry now. - * - * The connection authenticates once, during its HTTP upgrade, from the cookies - * the browser sent with it. Nothing afterwards can change who the server thinks - * it belongs to - so the only way to follow a sign-in or a sign-out is a fresh - * handshake, and {@link WebSocketManager.reconnect} is what performs one. - * - * ## `undefined` is "not known yet", and it must not reconnect - * - * A framework that resolves the session on the server before rendering - Next.js - * with `getSessionApi()` - always passes a known value, so this case never - * arises there. A client-side session read does: the component's first render - * happens before the query has answered, and the identity arrives one render - * later. - * - * Both directions of that are handled here, and both matter: - * - * - `next === undefined` - the session has become unknown again (a cleared cache - * entry). Nothing is learned, so nothing is done, and the caller keeps the - * last identity it *did* know rather than forgetting it. - * - `previous === undefined` - the first identity this client has learned. The - * socket already opened with the visitor's cookies attached, so the server has - * had the right user all along and there is nothing to correct. Reconnecting - * here would tear down the shared connection - and, because the manager - * relays a reconnect to the leader tab, every tab's connection with it - on - * every single page load. - * - * Once both sides are known it is a plain inequality: guest to user, user to - * guest, and one user to another are each a new handshake; the same id answered - * twice is not. - */ export const shouldReconnectForUser = ( previous: undefined | VitNodeSocketUserId, next: undefined | VitNodeSocketUserId, diff --git a/packages/vitnode/src/ws/handle.ts b/packages/vitnode/src/ws/handle.ts index 6fd5b03b6..f2a1d2f31 100644 --- a/packages/vitnode/src/ws/handle.ts +++ b/packages/vitnode/src/ws/handle.ts @@ -7,11 +7,6 @@ import type { VitNodeWSMessage } from "@/ws/types"; import { markWebSocketEnabled, wsRegistry } from "@/ws/registry"; import { getWebSocketId } from "@/ws/types"; -/** - * Parse an incoming raw WebSocket message into a {@link VitNodeWSMessage} - * envelope. Returns `undefined` for anything that is not a JSON object - * carrying a string `id` (e.g. plain text or binary frames). - */ const parseMessage = (raw: unknown): undefined | VitNodeWSMessage => { if (typeof raw !== "string") return undefined; @@ -33,28 +28,6 @@ const parseMessage = (raw: unknown): undefined | VitNodeWSMessage => { return undefined; }; -/** - * Handle VitNode WebSockets for any Hono runtime that exposes - * `upgradeWebSocket` (node.js `@hono/node-server`, Bun, ...). - * - * A single `/ws` connection is multiplexed across every registered socket. - * Each message is an envelope `{ id, data }` where `id` is - * `{pluginId}_{module}_{id}`. On every message this handler looks up the - * registered WebSocket whose composed id matches and calls its `onMessage` - * handler with the parsed `data` (and a `send` helper that wraps the reply in - * the same envelope). The registered handlers receive the request - * `Context<EnvVitNode>`, so they can reach the database, the authenticated - * user, the logger, etc. - * - * The optional `createEvents` callback lets you add connection-level behavior - * (`onOpen`, `onClose`, `onError`, or a fallback `onMessage` for messages that - * do not match any registered socket) with access to the typed context. - * - * @example - * ```ts - * app.get("/ws", upgradeWebSocket(handleVitNodeWebSocket())); - * ``` - */ export function handleVitNodeWebSocket( createEvents?: (c: Context<EnvVitNode>) => Promise<WSEvents> | WSEvents, ) { diff --git a/packages/vitnode/src/ws/manager.ts b/packages/vitnode/src/ws/manager.ts index 11ea9f16b..a2b35b54f 100644 --- a/packages/vitnode/src/ws/manager.ts +++ b/packages/vitnode/src/ws/manager.ts @@ -26,31 +26,11 @@ export interface WebSocketManagerOptions { export interface WebSocketManager { destroy: () => void; getReadyState: () => number; - /** - * Drop the shared connection and open a fresh one. The new socket - * re-runs the handshake, so the server re-reads the session cookie - use - * this after sign-in/sign-out so the connection's user changes. - */ + reconnect: () => void; send: (message: VitNodeWSMessage) => void; } -/** - * Manages a single WebSocket connection shared across every tab of the same - * origin. - * - * One tab is elected "leader" via the Web Locks API and owns the actual socket; - * the other tabs are "followers". Outgoing messages from followers are relayed - * to the leader, and every incoming server message is fanned out to all tabs - * over a {@link BroadcastChannel}. This means data is shared across tabs with - * exactly one connection and no duplicated messages. - * - * When the leader tab closes, its Web Lock is released and a follower is - * automatically promoted, re-opening the socket. - * - * If the browser lacks the Web Locks API or `BroadcastChannel`, the manager - * gracefully falls back to a per-tab connection. - */ export const createWebSocketManager = ({ url, onMessage, diff --git a/packages/vitnode/src/ws/notifications.ts b/packages/vitnode/src/ws/notifications.ts index 0f4a7dee5..49ad1b167 100644 --- a/packages/vitnode/src/ws/notifications.ts +++ b/packages/vitnode/src/ws/notifications.ts @@ -11,13 +11,6 @@ export interface VitNodeNotification { type?: VitNodeNotificationType; } -/** - * Per-user notification channel. Every user subscribes to the same id, but the - * server delivers each notification only to the target user's connections (see - * `realtime.sendToUser`), so notifications never leak to other users. - * - * Public id: `@vitnode/core_notifications_inbox`. - */ export const notificationsChannel = createWebSocketChannel< never, VitNodeNotification diff --git a/packages/vitnode/src/ws/pubsub-lifetime.test.ts b/packages/vitnode/src/ws/pubsub-lifetime.test.ts index 9f01c396e..bf95dc2ff 100644 --- a/packages/vitnode/src/ws/pubsub-lifetime.test.ts +++ b/packages/vitnode/src/ws/pubsub-lifetime.test.ts @@ -4,41 +4,6 @@ import type { CacheClient } from "@/api/lib/cache"; import { initRealtimePubSub, isRealtimePubSubEnabled } from "./registry"; -/** - * The realtime bridge's *lifetime*, which is the half a dev server exercises and - * production does not. - * - * In a deployment `initRealtimePubSub` is called once, at boot, and the question - * never arises. Under `vite dev` the module that boots the API is re-evaluated - * whenever anything it imports changes - so the call happens again, and again, - * for the life of the session. Each one that got through would `duplicate()` the - * cache client, open a second connection and `subscribe` a second handler to the - * same channel, and neither the first subscriber nor the first connection has - * anywhere to be cleaned up from: nothing holds a reference to them. - * - * The symptom is not a crash. It is every realtime message arriving twice, then - * three times, then four - one notification toast per reload since the server - * started - plus a Redis connection leaked per reload. And it is invisible until - * a developer happens to be signed in with a socket open, which is why it is - * pinned here rather than left to be noticed. - * - * The guard is one line in `initRealtimePubSub` (`if (!client || publisher) - * return`), and this is what it means. Nothing is redesigned to test it: the - * fake client below counts `duplicate()` calls, which is the only observable - * this needs, and no Redis is involved. - * - * ## The browser half needs no equivalent - * - * `VitNodeWebSocketProvider` creates its manager inside an effect and - * `destroy()`s it from that effect's cleanup, and `useVitNodeWebSocket` - * subscribes and unsubscribes the same way - so React tears both down on a fast - * refresh before the replacement mounts. The message handler is kept in a ref - * that is rewritten on every render, so a hot-reloaded listener cannot close - * over the previous module either. That is a React lifecycle, not a pure one; - * see `apps/web/src/tests/realtime-listeners.test.ts` for what is assertable - * about it without rendering. - */ - /** Just enough of a cache client to count what the bridge does with one. */ const fakeClient = () => { const duplicates: { channels: string[] }[] = []; @@ -131,11 +96,6 @@ describe("initializing again, as a hot reload does", () => { expect(isRealtimePubSubEnabled()).toBe(true); }); - /** - * Including the case a reload passes `null` - a config edit that removed the - * Redis URL, say. The bridge already established is kept rather than half - * torn down, which is the same "no-op without Redis" rule read the other way. - */ it("keeps the established bridge when a later boot has no client", () => { initRealtimePubSub(null); diff --git a/packages/vitnode/src/ws/registry.ts b/packages/vitnode/src/ws/registry.ts index 754727d0a..73e402878 100644 --- a/packages/vitnode/src/ws/registry.ts +++ b/packages/vitnode/src/ws/registry.ts @@ -4,25 +4,8 @@ import type { CacheClient } from "@/api/lib/cache"; import type { VitNodeWSChannel, VitNodeWSMessage } from "./types"; -/** - * In-memory registry of the WebSocket connections open on the current server - * process, used to push messages to clients. Each connection is tagged with the - * id of the authenticated user it belongs to (or `null` when anonymous), so a - * payload can be delivered to a single user across all of their browsers. - * - * This map is per-process. When several instances run behind a load balancer, - * {@link initRealtimePubSub} bridges them with Redis pub/sub so `broadcast` and - * `sendToUser` also reach clients connected to the other instances. - */ const connections = new Map<WSContext, null | number>(); -/** - * Flipped to `true` the first time the app mounts the WebSocket handler (see - * {@link markWebSocketEnabled}). WebSockets are wired up outside `VitNodeAPI` - * (the app calls `upgradeWebSocket(handleVitNodeWebSocket())`), so this is the - * only reliable signal of whether the `/ws` endpoint exists in a given - * deployment. Read by the admin integrations panel. - */ let webSocketEnabled = false; /** Called once when the app mounts the `/ws` handler. */ @@ -60,11 +43,6 @@ export const wsRegistry = { }, }; -/** - * Redis pub/sub channel every instance publishes realtime messages to and - * subscribes for, so a `broadcast`/`sendToUser` reaches clients connected to - * other instances behind a load balancer. - */ const REALTIME_PUBSUB_CHANNEL = "vitnode:ws"; /** @@ -114,12 +92,6 @@ const publish = (message: Omit<RealtimePubSubMessage, "origin">): void => { }); }; -/** - * Enable cross-instance realtime delivery using Redis pub/sub. Call once at - * boot with the shared cache client (see `VitNodeAPI`). Passing `null` (Redis - * not configured) keeps realtime in single-process mode - messages then only - * reach clients connected to the current instance. - */ export const initRealtimePubSub = (client: CacheClient | null): void => { // No-op without Redis, or if already initialized. if (!client || publisher) return; @@ -155,11 +127,6 @@ export const initRealtimePubSub = (client: CacheClient | null): void => { }; export interface VitNodeRealtime { - /** - * Push a payload to **every** client subscribed to `channel`. Delivered to - * the matching {@link useVitNodeWebSocket} subscribers (only views currently - * on screen react). Use for non-sensitive "data changed" signals. - */ broadcast: <Receive>( channel: VitNodeWSChannel<unknown, Receive>, data: Receive, @@ -175,13 +142,6 @@ export interface VitNodeRealtime { ) => void; } -/** - * The realtime helper exposed on the request context as `c.get("realtime")`. - * - * Each call delivers to this instance's clients immediately, then (when Redis - * pub/sub is enabled via {@link initRealtimePubSub}) fans the message out to the - * other instances so their clients receive it too. - */ export const realtime: VitNodeRealtime = { broadcast: (channel, data) => { wsRegistry.broadcast(channel.id, data); diff --git a/packages/vitnode/src/ws/types.ts b/packages/vitnode/src/ws/types.ts index 55233a7a1..079516419 100644 --- a/packages/vitnode/src/ws/types.ts +++ b/packages/vitnode/src/ws/types.ts @@ -1,30 +1,8 @@ -/** - * Shared WebSocket types used by both the server (`@vitnode/core/ws/handle`, - * `@vitnode/core/api/lib/websocket`) and the client (`@vitnode/core/ws/provider`, - * `@vitnode/core/ws/use-websocket`). This module must stay free of any - * server-only imports so it is safe to bundle on the client. - */ - -/** - * Envelope for every message exchanged over the VitNode WebSocket. - * - * The single `/ws` connection is multiplexed: each message carries the `id` of - * the socket it targets (`{pluginId}_{module}_{id}`), so the server can - * dispatch it to the right handler and the client can route it to the right - * subscriber. - */ export interface VitNodeWSMessage<TData = unknown> { data: TData; id: string; } -/** - * A typed contract for a WebSocket channel, shared between the server handler - * and the client hook so both sides agree on the id and the message shapes. - * - * - `Send` - what the client sends to the server. - * - `Receive` - what the client receives from the server. - */ export interface VitNodeWSChannel<Send = unknown, Receive = unknown> { /** * Phantom field - never present at runtime. It only exists to carry the diff --git a/packages/vitnode/src/ws/use-websocket.ts b/packages/vitnode/src/ws/use-websocket.ts index 88ec1633b..476dbfd43 100644 --- a/packages/vitnode/src/ws/use-websocket.ts +++ b/packages/vitnode/src/ws/use-websocket.ts @@ -16,20 +16,6 @@ export interface UseVitNodeWebSocketReturn<Send> { send: (data: Send) => void; } -/** - * Subscribe to a VitNode WebSocket over the shared `/ws` connection. - * - * Pass a {@link VitNodeWSChannel} (recommended - it carries the id and the - * message types) or a raw id string. Messages are routed by id, so only the - * payloads addressed to this channel reach `options.onMessage`. - * - * @example - * ```tsx - * const { send, readyState } = useVitNodeWebSocket(echoChannel, { - * onMessage: data => setMessages(prev => [...prev, data]), - * }); - * ``` - */ export function useVitNodeWebSocket<Send = unknown, Receive = unknown>( channel: string | VitNodeWSChannel<Send, Receive>, options?: { diff --git a/packages/vitnode/test-fixtures/config-split/vitnode.config.ts b/packages/vitnode/test-fixtures/config-split/vitnode.config.ts index 08a373e94..4a085f06b 100644 --- a/packages/vitnode/test-fixtures/config-split/vitnode.config.ts +++ b/packages/vitnode/test-fixtures/config-split/vitnode.config.ts @@ -1,14 +1,6 @@ import { buildConfig } from "../../src/vitnode.config"; -/** - * A shared config exactly as an app writes one - the browser-safe half. - * - * Imports nothing but `buildConfig`, which is the whole claim under test: Vite - * loads this file with `jiti` to discover the configured plugins, and the - * document shell holds it in the browser bundle. `vitnode.server.config.ts` - * beside it throws on import, so a discovery pass that reached the server half - * would fail loudly instead of merely being slower than it should be. - */ + export const vitNodeConfig = buildConfig({ i18n: { defaultLocale: "en", diff --git a/packages/vitnode/test-fixtures/config-split/vitnode.server.config.ts b/packages/vitnode/test-fixtures/config-split/vitnode.server.config.ts index a0c45a3b3..04e402901 100644 --- a/packages/vitnode/test-fixtures/config-split/vitnode.server.config.ts +++ b/packages/vitnode/test-fixtures/config-split/vitnode.server.config.ts @@ -1,13 +1,4 @@ -/** - * The server-only half, rigged to fail if anything build-time reaches it. - * - * A real one carries `() => import("./locales/...")` loaders that read a - * package's build output, plus the `server-only` marker. This throws at module - * scope instead, which is how `shared-config-discovery.test.ts` proves the - * plugin generator never loads it: an assertion about a call count nobody owns - * would pass the day the generator started importing this file and stopped - * calling anything on it. - */ + throw new Error( "the server-only config was loaded by something that should only read the shared one", ); diff --git a/packages/vitnode/test-fixtures/next-specimen/entry.ts b/packages/vitnode/test-fixtures/next-specimen/entry.ts index 8b61e35d5..4f9d1a92c 100644 --- a/packages/vitnode/test-fixtures/next-specimen/entry.ts +++ b/packages/vitnode/test-fixtures/next-specimen/entry.ts @@ -1,21 +1,2 @@ -/** - * A deliberate Next.js import graph, so the scanner has something to find. - * - * Every "reaches nothing from next/*" assertion in this package is a *negative* - * one, and a scanner that silently matches nothing satisfies all of them. The - * controls used to point at real Next.js modules; Stage 17 deleted those, which - * would have left the whole suite asserting the absence of a thing it could no - * longer prove it was able to detect. - * - * So the specimen is a fixture instead. It lives outside `tsconfig.json`'s - * `include`, so `next` never has to be installed for tsc to accept it, and - * outside `src`, so nothing can import it by accident. It exists to be scanned - * and for no other reason - which is also why it can never rot: production code - * moving on cannot take its specimen with it. - * - * It is one hop deep on purpose. A one-file fixture would still pass a scanner - * that read only the entry and never followed an edge, and "the offending import - * is three files away from the one being written" is the whole reason these - * scans exist. - */ + export { viaHop } from "./hop"; diff --git a/plugins/blog/src/admin/content.tsx b/plugins/blog/src/admin/content.tsx index 9ccf37d4b..93b60ae2c 100644 --- a/plugins/blog/src/admin/content.tsx +++ b/plugins/blog/src/admin/content.tsx @@ -10,40 +10,6 @@ import { BlogCategoryColorField } from "@/views/admin/category/color-field"; import { blogCategoryNav, blogPostNav } from "./nav"; -/** - * The blog's Content Engine registration - two content types and four - * overrides. - * - * The **browser-safe** half of the plugin's frontend integration, and the - * canonical declaration of every override: `config.tsx` spreads this rather than - * repeating it, so every AdminCP that reads this registration renders the - * same components from the same list. There is no second copy to drift. - * - * Three modules, three questions: - * - * admin/nav.tsx what exists definitions and icons - * admin/content.tsx how it edits the above, plus the overrides - * config.tsx the plugin the above, plus messages and API wiring - * - * Each layer spreads the one below it. `admin/nav` stays separate because - * drawing a sidebar does not need an editor: an application that only wants - * links imports the smaller module and never pulls this one - or Tiptap - into - * its bundle. - * - * ## What an override may and may not be - * - * The three escape hatches, one of each kind. `fields` replaces an input, - * `columns` replaces a table cell, and `forms.layout` replaces the arrangement - * of a whole form - never its behaviour. The Content Engine still owns the - * schema, the validation, the defaults, the mutation, the version precondition, - * the structured errors, the toast and the cache invalidation. - * - * Everything reachable from here is framework-neutral by contract: no - * framework package, no router, no server action. `BlogArticleEditorField` draws its - * own lazy boundary with `React.lazy`, which is what keeps the editor out of the - * initial AdminCP bundle now that a generated registry imports this module - * eagerly. - */ export const adminContent = { pluginId: CONFIG_PLUGIN.pluginId, contentTypes: [ diff --git a/plugins/blog/src/admin/nav.tsx b/plugins/blog/src/admin/nav.tsx index 06d1f8e24..c26d1af7b 100644 --- a/plugins/blog/src/admin/nav.tsx +++ b/plugins/blog/src/admin/nav.tsx @@ -6,35 +6,6 @@ import { CONFIG_PLUGIN } from "@/const"; import { blogCategoryContentType } from "@/content/category"; import { blogPostContentType } from "@/content/post"; -/** - * Everything this plugin puts in the AdminCP sidebar, and nothing that renders a - * screen. - * - * The narrowest of the plugin's three frontend modules, and the split is about - * *weight* rather than about what a framework can hold: `./content` registers - * the same two content types with their editing screens attached - the Tiptap - * field, the form layout, the colour cell - and an application that only wants - * a sidebar has no business fetching an editor for it. A content type - * definition is zod and plain data, and an icon is an element from an icon set. - * - * There is no second list. `./content` builds its registrations *from these - * two*, adding only the overrides, and `config.tsx` spreads that - so the - * definition and the icon are written once and the two AdminCPs cannot show - * different sidebars. - * - * Exported individually as well as together because that is what makes the - * single-source claim hold: `contentTypeAdmin` infers a content type's field - * names from the `definition` it is handed, and it can only do that from a value - * whose type has not been widened by an array. - * - * The blog declares no `admin.nav` of its own - its whole AdminCP presence is - * the two generated Content Engine screens - so the hrefs here point into - * `/admin/content/*`. They said exactly that before Stage 13 moved that - * namespace into the TanStack router and they say it still: the host's link - * component decides per href which application serves one, and navigation - * describes what exists rather than what a given router happens to render. - */ - /** The post content type, as the sidebar reads it. */ export const blogPostNav = { definition: blogPostContentType, diff --git a/plugins/blog/src/api/lib/events.ts b/plugins/blog/src/api/lib/events.ts index 015ae2bcc..b01622b2b 100644 --- a/plugins/blog/src/api/lib/events.ts +++ b/plugins/blog/src/api/lib/events.ts @@ -10,19 +10,6 @@ import type { blogCategoryContentType } from "@/content/category"; import { blogPostContentType } from "@/content/post"; import { postContent } from "@/database/posts"; -/** - * The blog's own event names, kept as **adapters** over the Content Engine's. - * - * There is one mutation pipeline now - the engine's - and these listeners - * translate its events into the names the blog has always published, so a plugin - * listening for `blog.post.created` keeps working without the blog keeping a - * second way to write a row. - * - * They are a compatibility layer with a shelf life. New listeners should use - * `content.blog.post.*` and `content.blog.category.*`, which carry more: changed - * fields, revision ids, publication transitions, per-locale translation events - * and slug history - none of which the blog's own names ever had. - */ declare module "@vitnode/core/api/models/events" { interface VitNodeEvents extends @@ -33,40 +20,17 @@ declare module "@vitnode/core/api/models/events" { }; "blog.category.deleted": { categoryId: number; - /** - * Always empty. - * - * It always effectively was: the foreign key from `blog_posts` refuses a - * category that still has articles, so a category deletion that succeeds - * is one that had none. The field stays so existing listeners still - * compile. - */ + postIds: number[]; }; "blog.category.updated": { categoryId: number; }; "blog.post.created": { - /** - * The article's **first** category. - * - * An article is in many categories now, and this payload has room for one. - * The first is the honest answer to "which category is this in?" for a - * listener written when there could only be one - and a listener that - * needs all of them should watch `content.blog.post.created`, which - * carries the whole set. - */ categoryId: number; postId: number; }; - /** - * No `categoryId`, unlike the other two. - * - * The row is gone by the time this is emitted, so there is nothing left to - * read it from - and inventing one would put a wrong category id into an - * audit trail. A listener that needs it should watch - * `content.blog.post.deleted` and keep its own index. - */ + "blog.post.deleted": { postId: number; }; @@ -80,13 +44,6 @@ declare module "@vitnode/core/api/models/events" { const POST = blogPostContentType.id; const CATEGORY = "blog.category"; -/** - * The first category an article is in, read back for the legacy payload. - * - * A junction read rather than a column read: an article's categories are a set - * now, and `null` covers both "no such article" and "no categories yet" - the - * two cases where the legacy event has nothing true to say and is not emitted. - */ const categoryOf = async ( c: Context<EnvVitNode>, postId: number, diff --git a/plugins/blog/src/api/modules/admin/admin.module.ts b/plugins/blog/src/api/modules/admin/admin.module.ts index 0435b25c0..79b10572c 100644 --- a/plugins/blog/src/api/modules/admin/admin.module.ts +++ b/plugins/blog/src/api/modules/admin/admin.module.ts @@ -6,16 +6,6 @@ import { CONFIG_PLUGIN } from "@/const"; import { categoryContent } from "@/database/categories"; import { postContent } from "@/database/posts"; -/** - * Every admin route the blog has, generated from two content types. - * - * The generated content module is nested here rather than mounted by the engine: - * Hono serves only the last sub-app mounted at a prefix, so a second top-level - * `/admin` would silently shadow this one. - * - * Routes land at `/api/@vitnode/blog/admin/content/{posts,categories}`, and the - * staff permissions they check are the modules the blog has always used. - */ export const adminModule = buildModule({ pluginId: CONFIG_PLUGIN.pluginId, name: "admin", diff --git a/plugins/blog/src/config.api.ts b/plugins/blog/src/config.api.ts index f06d8dbb1..b0a60f669 100644 --- a/plugins/blog/src/config.api.ts +++ b/plugins/blog/src/config.api.ts @@ -6,21 +6,6 @@ import { CONFIG_PLUGIN } from "@/const"; import { categoryContent } from "@/database/categories"; import { postContent } from "@/database/posts"; -/** - * No `contentTypes` here: `buildApiPlugin` walks the module tree, so the content - * types declared in `admin.module.ts` also drive the registry and the derived - * `can_view` / `can_create` / `can_edit` / `can_delete` / `can_publish` / - * `can_restore` permissions. - * - * No `searchIndexers` either. The article's `search` block is the indexer now - - * one document per published translation, written by the engine in the same - * transaction as the mutation that caused it. - * - * `permissionStaff` names the same two modules the blog always used, so an - * existing role's stored permissions still address the right thing. Only the - * generated additions - publish, restore, translate - are new, and a role that - * does not have them is denied by default. - */ export const blogApiPlugin = () => buildApiPlugin({ pluginId: CONFIG_PLUGIN.pluginId, diff --git a/plugins/blog/src/config.test-d.ts b/plugins/blog/src/config.test-d.ts index cdaebce37..501a35af8 100644 --- a/plugins/blog/src/config.test-d.ts +++ b/plugins/blog/src/config.test-d.ts @@ -8,13 +8,6 @@ import { blogPostContentType } from "@/content/post"; import { adminContent } from "./admin/content"; -/** - * What the frontend registration will and will not accept. - * - * The registration is checked against the definition's own field names, so a - * renamed field is a compile error at the override rather than an input that - * silently stops being overridden. - */ describe("blog content admin registration", () => { it("accepts overrides for fields the content type has", () => { contentTypeAdmin({ @@ -44,14 +37,6 @@ describe("blog content admin registration", () => { }); }); - /** - * The browser-safe module an application's generated registry imports. - * - * `satisfies` in the module itself is what checks it; this states the - * contract from the consumer's side, which is the side that breaks silently - - * a plugin that renamed the export or widened its `pluginId` would still build - * and would simply contribute nothing. - */ it("exports a content source the generated registry can consume", () => { expectTypeOf(adminContent).toExtend<ContentFrontendPluginSource>(); expectTypeOf(adminContent.pluginId).toEqualTypeOf<"@vitnode/blog">(); diff --git a/plugins/blog/src/config.tsx b/plugins/blog/src/config.tsx index b6b1f3507..47680108f 100644 --- a/plugins/blog/src/config.tsx +++ b/plugins/blog/src/config.tsx @@ -3,27 +3,6 @@ import { buildPlugin } from "@vitnode/core/lib/plugin"; import { adminContent } from "./admin/content"; import messages from "./locales"; -/** - * The blog's entire frontend integration. - * - * Two content types, three component overrides and one layout - and that is the - * AdminCP: the nav items, the breadcrumbs, the list, the create and edit screens - * and the delete confirmation are all generated. No page under - * `src/routes/admin` renders a table any more, and no view calls a mutation. - * - * None of it is written here. `./admin/content` is the canonical declaration - - * the content types with their overrides - and this spreads it, so the two - * AdminCPs read one list through two doors: - * - * admin/nav.tsx browser-safe what exists: definitions and icons - * admin/content.tsx browser-safe how it edits: fields, columns, layouts - * config.tsx server the whole plugin: the above, plus messages - * - * A host registers this file and walks the registry in its render - * pass. A TanStack Start application cannot - its config is server-side on - * purpose - so it imports `admin/content` through a generated registry of - * literal specifiers instead. Same declarations, same components, two doors. - */ export const blogPlugin = () => buildPlugin({ ...adminContent, diff --git a/plugins/blog/src/content/category.ts b/plugins/blog/src/content/category.ts index 701fd6575..bf3190643 100644 --- a/plugins/blog/src/content/category.ts +++ b/plugins/blog/src/content/category.ts @@ -1,24 +1,5 @@ import { defineContentType, field } from "@vitnode/core/content"; -/** - * Blog categories, as a Content Engine content type. - * - * The **simple** reference implementation: two fields, generated CRUD, and - * dialog create/edit. It exists to show that a small record needs no page-mode - * editor - and, between them, that `admin.create.mode` really is per content - * type rather than per install. - * - * `tableName` is deliberately the table the plugin has always used. The - * Content Engine's generated schema for this shape *is* `blog_categories` plus a - * translation table, so the migration adds rather than replaces: no ids move, no - * rows are copied between tables, and an install with categories keeps them. - * - * `name` is localized because it always was - the blog stored category titles in - * `core_languages_words`, one row per language - and it moves into - * `blog_categories_translations`, which is where the engine keeps the same idea. - * `color` is shared, because a colour is a property of the category and not of - * the language somebody is reading it in. - */ export const blogCategoryContentType = defineContentType({ id: "blog.category", tableName: "blog_categories", diff --git a/plugins/blog/src/content/content-types.test.ts b/plugins/blog/src/content/content-types.test.ts index 4c2db8d81..8e07335aa 100644 --- a/plugins/blog/src/content/content-types.test.ts +++ b/plugins/blog/src/content/content-types.test.ts @@ -4,14 +4,6 @@ import { describe, expect, it } from "vitest"; import { blogCategoryContentType } from "./category"; import { blogPostContentType } from "./post"; -/** - * What the blog's two content types promise, stated as facts rather than as a - * snapshot of the descriptor. - * - * Every assertion here is something an install would notice if it changed: a - * table name, a permission module, a public URL, a presentation mode. They are - * the compatibility contract of the migration. - */ describe("blog content types", () => { describe("compatibility with the pre-migration plugin", () => { it("keeps the table names, so no data has to move", () => { @@ -166,11 +158,6 @@ describe("blog content types", () => { ]); }); - /** - * The cover image, as a pair: one **shared** file and one **localized** - * description of it. That split is the whole point - the image is the same - * image in every language, and the alt text is not. - */ describe("the cover image", () => { const cover = blogPostContentType.fields.coverImage; diff --git a/plugins/blog/src/content/post.ts b/plugins/blog/src/content/post.ts index c999f4907..e21f44e5b 100644 --- a/plugins/blog/src/content/post.ts +++ b/plugins/blog/src/content/post.ts @@ -2,24 +2,6 @@ import { defineContentType, field } from "@vitnode/core/content"; import { blogCategoryContentType } from "./category"; -/** - * Blog articles, as a Content Engine content type. - * - * The **rich** reference implementation: page-mode create and edit, a custom - * AdminCP layout, `AutoFormEditor` for the body, a native relation to the - * category, an author, publication, editorial history, search and delivery. - * Everything a real CMS entry needs, and not one line of bespoke CRUD. - * - * The id stays `blog.post` and the table stays `blog_posts`. "Article" is what - * the AdminCP calls it, because that is what people call it - but the content - * type id is part of the event names, the permission keys and the admin URL, and - * renaming a public contract for a nicer noun is churn with no payer. - * - * The field names are the column names the plugin already had: `categoryId` and - * `authorId`, not `category` and `author`. The engine names a column after its - * field, so keeping the field names keeps the columns, the foreign keys and - * their constraint names exactly where they are. - */ export const blogPostContentType = defineContentType({ id: "blog.post", tableName: "blog_posts", @@ -31,25 +13,8 @@ export const blogPostContentType = defineContentType({ fallback: "default", }, - /** - * Draft and published, which the blog did not have and now does. - * - * Every article that exists today is publicly readable - the old public route - * returned every row and the search index marked every document - * `isPublic: true` - so the migration backfills them all as published, with - * `publishedAt` set to `createdAt`. That is the one publication fact the old - * schema can actually prove; nothing else about their history is invented. - */ publication: { enabled: true }, - /** - * Versions, revisions, preview links and scheduling. - * - * `editorial` is also what `delivery.redirects` is gated on: slug history has - * to be written in the same transaction as the slug change, and only the - * editorial mutation paths own such a transaction. An article's URL is the - * thing most worth not breaking, so both are on. - */ editorial: { enabled: true, revisions: { retention: 20 }, @@ -58,23 +23,6 @@ export const blogPostContentType = defineContentType({ }, fields: { - /** - * Which categories an article is in - **many**, and the field name stays - * singular because it is the API key, the event key and the message key - * every existing client already reads. Its column is gone either way: a set - * lives in `blog_posts_category_id`, one row per membership. - * - * Unordered: an article filed under both "Releases" and "Engineering" is not - * more one than the other, so the engine stores the set in ascending id - * order and nobody has to maintain an order that means nothing. - * - * **Not `required`, and it cannot be** - a to-many reference is never - * required, because the empty set is what "no categories" looks like to a - * junction table. `min: 1` is the rule the blog actually wants, and it is a - * rule about the *article*: an article has to be filed somewhere. It is - * enforced by the generated schema, so the API answers 400 and the AdminCP - * form refuses to submit - rather than by a check one of the two would skip. - */ categoryId: field.relation({ min: 1, multiple: true, @@ -84,20 +32,7 @@ export const blogPostContentType = defineContentType({ onDelete: "restrict", target: () => blogCategoryContentType, }), - /** - * Who wrote it - **many**, and ordered, because a byline is a sentence: "by - * Ada and Grace" is not the same as "by Grace and Ada", and the first name - * on a piece is a thing people care about. - * - * `min: 1`, for the same reason the categories have it: an article with no - * byline is not a state the blog wants, and a to-many field cannot say that - * with `required`. - * - * `onDelete` defaults to `cascade` here rather than the `set null` a single - * author had: a junction row has no column to null, so forgetting a deleted - * account's authorship means deleting the membership. The article survives - * either way, which is what the nullable column was protecting. - */ + authorId: field.user({ min: 1, multiple: true, ordered: true }), // Localized: exactly the three variables the plugin kept in @@ -117,41 +52,12 @@ export const blogPostContentType = defineContentType({ }), content: field.textarea({ localized: true, required: true }), - /** - * The article's cover image - **shared**, and one file. - * - * The column is a `core_files.id` with `ON DELETE RESTRICT`, so Postgres - * itself refuses to delete an image an article is still using, and nothing - * about the file is copied onto the row: no URL, no storage key, no size. One - * fact in one place, which is what makes replacing the image a single write. - * - * `maxBytes` is mandatory on every file field and this one says five - * megabytes. Both allowlists are stated, and they have to *both* match: a - * `hero.png` declared `image/gif` is refused, which an extension-only check - * would wave through. - * - * `.webp` is in the extension list for a reason worth knowing: with - * `storage.image` configured, VitNode re-encodes uploaded images to WebP, so - * the stored file is `hero.webp` whatever was chosen. A field that allowed - * only `.png` would accept the upload and then refuse the save. - * - * Not localized, and it cannot be: a cover image is one image whatever - * language somebody reads the article in. The *alt text* is the part that - * differs, and that is the field below. - */ coverImage: field.file({ maxBytes: 5 * 1024 * 1024, allowedExtensions: [".jpg", ".jpeg", ".png", ".webp", ".avif"], allowedMimeTypes: ["image/jpeg", "image/png", "image/webp", "image/avif"], }), - /** - * What a screen reader says instead of the cover image - **per language**. - * - * The pairing is the point: one shared file, one localized description of it. - * `nullable: true` because an article with no cover has nothing to describe, - * and because alt text is written after the image is chosen rather than at - * the same moment. - */ + coverImageAlt: field.text({ localized: true, nullable: true, @@ -159,17 +65,6 @@ export const blogPostContentType = defineContentType({ }), }, - /** - * The public read layer. - * - * `path: "blog"` keeps the public URL shape the plugin already published under - * - `/blog/...` - now as a canonical delivery address the engine owns. - * - * `authorId` is **not** exposed, and cannot be: a `user` field is not one of - * the publicly exposable kinds, because publishing a staff account's display - * name is a decision the core users table gets to make rather than a side - * effect of an article having an author. - */ publicApi: { enabled: true, path: "blog", @@ -200,15 +95,6 @@ export const blogPostContentType = defineContentType({ defaultOrder: "desc", }, - /** - * One search document per published translation. - * - * Replaces `api/lib/search.ts` entirely. That file emitted one document per - * *enabled language* whether or not a translation existed, falling back to the - * default language's copy - so a Polish search could return an English article - * at a Polish URL. The engine indexes translations that actually exist, which - * is both less code and a better answer. - */ search: { enabled: true, titleField: "title", @@ -216,14 +102,6 @@ export const blogPostContentType = defineContentType({ pathTemplate: "/{locale}/blog/{slug}", }, - /** - * Canonical URLs, slug history, redirects, SEO and the sitemap. - * - * `redirects` is the reason the old friendly-URL uniqueness check is gone: - * the engine reserves every address an article has ever been published at, so - * renaming one 308s the old URL instead of leaving it dead - and a second - * article cannot quietly claim an address the first one still redirects from. - */ delivery: { enabled: true, redirects: { enabled: true }, diff --git a/plugins/blog/src/views/admin/article/editor-field.tsx b/plugins/blog/src/views/admin/article/editor-field.tsx index 7029653e9..8222605f5 100644 --- a/plugins/blog/src/views/admin/article/editor-field.tsx +++ b/plugins/blog/src/views/admin/article/editor-field.tsx @@ -6,26 +6,6 @@ import { Loader } from "@vitnode/core/components/ui/loader"; import React from "react"; import { useTranslations } from "use-intl"; -/** - * The rich-text editor, behind its own lazy boundary. - * - * `React.lazy` rather than `next/dynamic`, and that is what makes this field - * usable from any AdminCP that renders this plugin's content screens. The two - * behave the same here - one chunk, fetched when the field first renders - and - * only one of them exists outside Next. - * - * The boundary matters more than which API draws it. Tiptap and its extensions - * are the single largest thing the blog contributes to a bundle, and this keeps - * them out of every screen that is not the article form, including the article - * *list* that sits one navigation away. That is why the plugin owns the split - * rather than leaving it to whatever imports the registration: an application's - * generated content registry imports this module eagerly, and what it gets is a - * label and a `lazy()` call. - * - * No `ssr: false` counterpart is needed. `components/ui/editor` already creates - * the editor with `immediatelyRender: false`, which is Tiptap's own answer to - * server rendering, so the fallback below is the whole of the loading state. - */ const AutoFormEditor = React.lazy( async () => await import("@vitnode/core/components/form/fields/editor").then(mod => ({ diff --git a/plugins/example/src/admin/content.tsx b/plugins/example/src/admin/content.tsx index 0c16399d9..4d30a7088 100644 --- a/plugins/example/src/admin/content.tsx +++ b/plugins/example/src/admin/content.tsx @@ -4,25 +4,6 @@ import { CONFIG_PLUGIN } from "@/const"; import { exampleArticleNav, exampleCategoryNav } from "./nav"; -/** - * This plugin's Content Engine registration. - * - * The **browser-safe** module an application's generated content registry - * imports, and the half of the frontend integration that renders screens rather - * than links. `config.tsx` spreads it, so there is one list and no second copy. - * - * This plugin overrides nothing, which is the point worth stating: the AdminCP - * generates the list, the create screen, the edit screen and the delete - * confirmation for both content types from their definitions alone. So the two - * entries here are exactly the pairs `./nav` already declares - referenced - * rather than retyped, which is why that module exports each one individually. - * A plugin that later needs a custom editor field adds it *here*, on top of the - * same pair, and nothing else in the plugin changes. - * - * The separation from `./nav` still earns its keep even with no overrides: an - * application drawing a sidebar imports that module and never loads this one, - * and this is the module that grows a Tiptap editor the day somebody adds one. - */ export const adminContent = { pluginId: CONFIG_PLUGIN.pluginId, contentTypes: [exampleArticleNav, exampleCategoryNav], diff --git a/plugins/example/src/admin/nav.tsx b/plugins/example/src/admin/nav.tsx index 75e22a68e..351d575c0 100644 --- a/plugins/example/src/admin/nav.tsx +++ b/plugins/example/src/admin/nav.tsx @@ -6,37 +6,6 @@ import { CONFIG_PLUGIN } from "@/const"; import { articleContentType } from "@/content/article"; import { categoryContentType } from "@/content/category"; -/** - * Everything this plugin puts in the AdminCP sidebar, and nothing that renders a - * screen. - * - * The narrowest of the plugin's three frontend modules, split out so an - * application can draw the sidebar without importing the other half. - * `./content` registers content types *with their editing screens* attached - - * for a plugin with field overrides, a whole form stack - and a list of links - * does not need any of it: an href is a string, a permission is three strings, a - * content type definition is zod and plain data, and an icon is an element from - * an icon set. - * - * There is no second list. `config.tsx` spreads this straight into - * `buildPlugin`, so the navigation a config-registering host renders and the one a - * TanStack app renders are the same declarations read through two doors. - * - * The app that installs this plugin never imports it by name: the - * `vitnode:plugin-routes` build step writes one literal import per configured - * plugin that exports `admin/nav` into `src/admin-nav.gen.ts`. - * - * ## Navigation and routes are separate lists, on purpose - * - * `overview` below points at `/admin/example`, which this plugin also declares - * as an admin-area route in `routes/manifest.ts` - but neither list is derived - * from the other, and neither has to be complete for the other to work. The - * content type entries here point into `/admin/content/*`, the namespace the - * Content Engine owns outright and no plugin route may claim; and `guide-topic` - * over in the manifest is a page reached from a link on another page, with no - * sidebar entry at all. - */ - /** The article content type, as the sidebar reads it. */ export const exampleArticleNav = { definition: articleContentType, @@ -55,20 +24,6 @@ export const adminNav = { admin: { nav: [ { - /** - * Titled from `@vitnode/example.admin.nav.overview`, which the shell - * loads because the declaration says where the string is - see - * `adminNavNamespaces`. - * - * No `permission`, and that is a decision rather than an omission. A - * permission here is a `plugin`/`module`/`permission` triple that has to - * exist in the staff tables, and this plugin's only modules are its two - * content types - so gating an overview page on `article.can_view` - * would invent a security relationship that is not real. The page is - * offered to anybody the AdminCP has already let in, which is what the - * shell's own session guard decides, and it exposes nothing an - * administrator may not see. - */ href: "/admin/example", icon: <FlaskConicalIcon />, id: "overview", diff --git a/plugins/example/src/api/lib/events.ts b/plugins/example/src/api/lib/events.ts index 433742ab6..5cb80ade5 100644 --- a/plugins/example/src/api/lib/events.ts +++ b/plugins/example/src/api/lib/events.ts @@ -3,13 +3,6 @@ import type { ContentEventsFor } from "@vitnode/core/content"; import type { articleContentType } from "@/content/article"; import type { categoryContentType } from "@/content/category"; -/** - * Grafts the generated content events onto the global event map. - * - * One line per content type is all it takes: `ContentEventsFor` expands to - * `content.example.article.created | .updated | .deleted` with typed payloads, - * so `changedFields` narrows to this content type's own field names. - */ declare module "@vitnode/core/api/models/events" { interface VitNodeEvents extends diff --git a/plugins/example/src/api/modules/admin/admin.module.ts b/plugins/example/src/api/modules/admin/admin.module.ts index d7b24868f..b8c2c566f 100644 --- a/plugins/example/src/api/modules/admin/admin.module.ts +++ b/plugins/example/src/api/modules/admin/admin.module.ts @@ -7,13 +7,6 @@ import { articleContent } from "@/database/articles"; import { categoryContent } from "@/database/categories"; import { localizedArticleContent } from "@/database/localized-articles"; -/** - * The generated content module is nested here rather than mounted by the - * engine: Hono serves only the last sub-app mounted at a prefix, so a second - * top-level `/admin` would silently shadow this one. - * - * Routes land at `/api/@vitnode/example/admin/content/{module}`. - */ export const adminModule = buildModule({ pluginId: CONFIG_PLUGIN.pluginId, name: "admin", diff --git a/plugins/example/src/config.api.ts b/plugins/example/src/config.api.ts index 943b6fbb2..d45f0f405 100644 --- a/plugins/example/src/config.api.ts +++ b/plugins/example/src/config.api.ts @@ -9,21 +9,6 @@ import { categoryContent } from "@/database/categories"; import { localizedArticleContent } from "@/database/localized-articles"; import "@/api/lib/events"; -/** - * No `contentTypes` here: `buildApiPlugin` walks the module tree, so the - * content types declared in `admin.module.ts` also drive the registry and the - * derived `can_view` / `can_create` / `can_edit` / `can_delete` permissions. - * - * `buildContentPublicModule` is top-level on purpose - its paths must stay out - * of `/admin/`, which the global admin gate matches as a substring. It skips - * any content type without `publicApi`, so `categoryContent` contributes - * nothing, and it registers no content types of its own (that would be a - * duplicate registration). - * - * Public routes land at `/api/@vitnode/example/content/articles/` and, for the - * localized fixture, `/api/@vitnode/example/content/localized-articles/` - the - * same two shapes, with `?locale=` deciding which language they answer in. - */ export const exampleApiPlugin = () => buildApiPlugin({ pluginId: CONFIG_PLUGIN.pluginId, diff --git a/plugins/example/src/config.tsx b/plugins/example/src/config.tsx index 0ce8779b1..c553fd7ad 100644 --- a/plugins/example/src/config.tsx +++ b/plugins/example/src/config.tsx @@ -5,28 +5,6 @@ import { adminNav } from "./admin/nav"; import messages from "./locales"; import { routes } from "./routes"; -/** - * Registering the content types is the whole frontend integration: the AdminCP - * screens, the nav items and the breadcrumbs are all generated from here. - * - * Nothing about the AdminCP is written here. Both halves are spread in from - * **browser-safe** modules, which is the point of the split - an application - * that cannot hold this file's graph can still draw the sidebar and render the - * content screens: - * - * ./admin/nav what exists ids, hrefs, permissions, icons, definitions - * ./admin/content how it edits the above, plus field/column/form overrides - * - * One list per question, read through two doors, so the two AdminCPs cannot - * drift. A plugin with field or form overrides declares them in `./admin/content` - * on top of the same pairs; this one has none, so that module is the pairs - * themselves. - * - * `contentTypes` is named explicitly rather than left to spread order: - * `adminNav` carries the same key with the narrower, screen-less projection in - * it, and which of two spreads wins is not something a reader should have to - * work out. - */ export const examplePlugin = () => buildPlugin({ ...adminNav, diff --git a/plugins/example/src/const.ts b/plugins/example/src/const.ts index 4708ade42..932c7c025 100644 --- a/plugins/example/src/const.ts +++ b/plugins/example/src/const.ts @@ -1,19 +1,5 @@ export const CONFIG_PLUGIN = { pluginId: "@vitnode/example" as const }; -/** - * Every committed migration in the docs app that touches `example_*`, in the - * order the migrator applies them. - * - * Named by **tag**, not by file. Drizzle Kit v3 stores each migration in its own - * `<timestamp>_<tag>/` directory rather than as one numbered `.sql`, and the - * timestamp is assigned at generation time - so the tag is the half that is - * stable enough to write down. - * - * The two database test suites both replay this list - one asserts the DDL as - * text, the other runs it against a real Postgres - so a new migration only has - * to be added here. It lives outside `src/database/` on purpose: Drizzle Kit - * globs that folder and executes everything it finds. - */ export const EXAMPLE_MIGRATIONS = [ "add_example_content", "add_publication_to_example_articles", diff --git a/plugins/example/src/content/advanced-article.ts b/plugins/example/src/content/advanced-article.ts index 69966c75f..e59af4428 100644 --- a/plugins/example/src/content/advanced-article.ts +++ b/plugins/example/src/content/advanced-article.ts @@ -2,39 +2,6 @@ import { defineContentType, field } from "@vitnode/core/content"; import { categoryContentType } from "./category"; -/** - * The Stage 6 reference: every advanced modeling shape on one content type. - * - * - **`categories`** - an unordered to-many relation. Its values live in - * `example_advanced_articles_categories`, a junction table with a real - * foreign key at each end; `onDelete: "restrict"` means Postgres itself - * refuses to delete a category that is still in use, rather than a check in - * service code that a direct `DELETE` would walk past. - * - **`relatedArticles`** - an **ordered self-relation**, declared with - * `self: true` rather than `target: () => advancedArticleContentType`. The - * difference is not stylistic: a definition whose field map mentions its own - * inferred type is circular, and TypeScript resolves that by widening the - * whole definition to `any` - silently taking every nested value type with - * it. `ordered: true` keeps the author's order, and `UNIQUE (itemId, - * position)` is what makes that order a fact rather than a hope. - * - **`seo`** - a **localized** group. Its leaves are stored as `seoTitle` and - * `seoDescription` on the *translation* table, so every language gets its own - * SEO copy - and the value stays nested (`row.seo.title`) whatever the columns - * are called. - * - **`syndication`** - a **shared** group. Same mechanics, on the base table. - * Kept separate from `seo` on purpose: localization is a property of the whole - * group, so a group cannot have one localized leaf and one shared one. Two - * groups is the shape that says which is which. - * - **`faq`** - a repeatable. Its children live in - * `example_advanced_articles_faq`, each with a `serial` primary key of its own - * so identity survives a reorder, and `search.contentFields` indexes their - * prose in position order. - * - * What is deliberately **not** here is the combination Stage 6 refuses: - * `field.repeatable({ localized: true })`, and a `localized: true` leaf inside - * either kind. See - * `apps/web/content/docs/dev/content-engine/relations-and-advanced-modeling.mdx`. - */ export const advancedArticleContentType = defineContentType({ id: "example.advanced-article", tableName: "example_advanced_articles", @@ -99,14 +66,7 @@ export const advancedArticleContentType = defineContentType({ syndication: field.group({ fields: { indexable: field.boolean({ defaultValue: true }), - /** - * The Stage 8 `noIndexField`, and shared rather than localized on purpose. - * - * Sitemap exclusion and the `robots` metadata are driven by the same - * boolean, so they cannot disagree - and a per-locale value would give one - * record one answer per language while it has a single canonical decision. - * `delivery` refuses a localized field here for exactly that reason. - */ + noIndex: field.boolean({ defaultValue: false }), priority: field.number({ integer: true, @@ -127,21 +87,6 @@ export const advancedArticleContentType = defineContentType({ }), }, - /** - * Leaf-level allowlisting. - * - * `seo.title` and `seo.description` are public; `syndication.priority` is - * public and `syndication.indexable` is **not**, which is the whole point of - * naming leaves rather than groups: exposing one leaf must not expose its - * neighbours, and a leaf added later stays private until somebody says - * otherwise. - * - * `categories` is exposed as identifiers. Not as expanded rows: a category has - * its own public API, its own allowlist and its own publication state, and - * publishing another content type's data because two records are related is - * not a decision this allowlist gets to make. `relatedArticles` is private - * altogether. - */ publicApi: { enabled: true, path: "advanced-articles", @@ -171,11 +116,6 @@ export const advancedArticleContentType = defineContentType({ defaultOrder: "desc", }, - /** - * A document per published translation, built from three kinds of value at - * once: a plain localized field, a localized group leaf, and a repeatable's - * children joined in position order. - */ search: { enabled: true, titleField: "title", @@ -184,22 +124,6 @@ export const advancedArticleContentType = defineContentType({ pathTemplate: "/{locale}/advanced-articles/{slug}", }, - /** - * The Stage 8 reference for a **localized** content type. - * - * Its canonical path carries the locale - `/pl/advanced-articles/moj-artykul` - - * and so does its slug history: the slug is `localized: true`, so each language - * gets its own reservation and changing the English URL creates no Polish - * redirect. - * - * `seo` reads the localized group, so every language has its own title and - * description, with `fallbackTitleField: "title"` filling in when `seo.title` is - * empty - which it usually is, because nobody writes one twice. - * - * `hreflang.xDefault` points at the default locale's canonical path, and only when - * that language is genuinely published: an `x-default` pointing at a translation - * this record does not have would be a hint to crawl a 404. - */ delivery: { enabled: true, redirects: { enabled: true }, diff --git a/plugins/example/src/content/article.ts b/plugins/example/src/content/article.ts index 5253246c4..964e20f0c 100644 --- a/plugins/example/src/content/article.ts +++ b/plugins/example/src/content/article.ts @@ -16,74 +16,16 @@ export const articleContentType = defineContentType({ excerpt: field.textarea({ maxLength: 500, nullable: true }), views: field.number({ integer: true, min: 0, defaultValue: 0 }), featured: field.boolean({ defaultValue: false }), - /** - * The Stage 8 `noIndexField`, and **nullable** on purpose. - * - * `example.advanced-article` models the other shape - a `NOT NULL` boolean - * with a default - so between the two every state a `noIndexField` can be in - * is exercised against real Postgres. Nullable is the one that matters, - * because `null` has to mean the same thing in two places at once: the - * `robots` metadata reads `value !== true`, and the sitemap predicate has to - * agree with it. A column added to an existing table arrives full of nulls, - * so this is also what an upgrade actually looks like. - */ + noIndex: field.boolean({ nullable: true }), author: field.user(), - /** - * The **extension-only** reference: a GIF, and nothing else. - * - * Both allowlists name exactly one thing, and both have to match, which is - * what makes this the interesting case: - * - * - `banner.gif` declared `image/gif` -> accepted; - * - `banner.png` declared `image/png` -> refused, wrong extension *and* wrong - * type; - * - a PNG **renamed** to `banner.gif` -> refused, because the browser still - * declares `image/png`. An extension-only check would store it; - * - a real GIF over 10 MB -> refused, before a byte reaches the adapter. - * - * A GIF is also the format that proves the storage pipeline is not quietly - * rewriting the rules: `sharp` deliberately does not re-encode GIF, so the - * stored file keeps its extension and its animation. An allowlist of `.png` - * would *not* be safe in the same way - with `storage.image` on, a PNG is - * converted to WebP, and the field would have to allow `.webp` too. - * - * Nullable, which is `field.file`'s default: an article without an animation - * is the ordinary case. - */ + animation: field.file({ maxBytes: 10 * 1024 * 1024, allowedExtensions: [".gif"], allowedMimeTypes: ["image/gif"], }), - /** - * The **many-files** reference: an ordered gallery, on its own junction table. - * - * `multiple: true` is the whole difference from `animation` above, and it - * moves the value off the row entirely - `example_articles_gallery` holds one - * row per image with `(itemId, relatedItemId, position)`, exactly as a to-many - * relation does. So: - * - * - it is neither `required` nor `nullable` - the empty gallery is what "no - * images" looks like, and `min: 1` is how "at least one" is actually said; - * - `max: 8` is the ceiling the AdminCP enforces at pick time and the API - * enforces again on save, so nobody discovers it after spending the upload; - * - `maxBytes` is still **per file**. Eight images at 5 MB is eight uploads, - * not one 40 MB budget; - * - the order is the editor's, because `ordered` defaults to `true` for a file - * collection. Without it the API would sort by `core_files.id`, which is - * upload order rather than anything anybody chose. - * - * The extension list names `.webp` alongside the formats a person picks: with - * `storage.image` configured every upload is re-encoded, so the *stored* file - * is `photo.webp` whatever was chosen - and a field that allowed only `.png` - * would refuse the file it had just created. - * - * Every image is pinned by each retained revision that names it, so removing - * one from the gallery does not make it deletable while an older version still - * shows it. `field.file` with `multiple: true` gets that for free: the pin - * table is keyed by (revision, file), not by field. - */ + gallery: field.file({ multiple: true, min: 1, @@ -139,22 +81,6 @@ export const articleContentType = defineContentType({ pathTemplate: "/articles/{slug}", }, - /** - * The Stage 8 reference for a **nonlocalized** content type. - * - * Its canonical path has no locale segment - `/articles/my-article` - and its slug - * history has no language either: `languageId` is `NULL`, so one reservation - * covers the one URL the record has. - * - * `redirects` is what makes an old address keep working. Change the slug of a - * *published* article and `/articles/old-slug` answers 308 to the new one, for as - * long as the article stays published; change it while it is still a draft and - * nothing is recorded, because the URL was never live. - * - * `seo` projects two fields the public API already exposes. There is no - * `fallbackTitleField` here because `title` is the primary and it is - * `required: true` - a fallback would never be reached. - */ delivery: { enabled: true, redirects: { enabled: true }, diff --git a/plugins/example/src/content/category.ts b/plugins/example/src/content/category.ts index bf7bc1d26..84c07500d 100644 --- a/plugins/example/src/content/category.ts +++ b/plugins/example/src/content/category.ts @@ -1,11 +1,5 @@ import { defineContentType, field } from "@vitnode/core/content"; -/** - * The simplest possible content type: one text field. - * - * It exists mostly so `example.article` has something to relate to, which is - * what proves the `relation` field end to end. - */ export const categoryContentType = defineContentType({ id: "example.category", tableName: "example_categories", diff --git a/plugins/example/src/content/file-fields.test.ts b/plugins/example/src/content/file-fields.test.ts index 805d8f7b1..7a58b589b 100644 --- a/plugins/example/src/content/file-fields.test.ts +++ b/plugins/example/src/content/file-fields.test.ts @@ -11,18 +11,6 @@ import { describe, expect, it } from "vitest"; import { articleContentType } from "./article"; -/** - * The extension-only reference field, exercised as a matrix. - * - * `example.article.animation` states exactly one extension and exactly one media - * type, which makes it the field where "both rules have to match" is visible: a - * PNG renamed to `.gif` passes the filename check and fails the type check, and - * that is the only reason it is refused. - * - * A GIF is also the format that proves the storage pipeline is not quietly - * changing the rules - `sharp` never re-encodes GIF, so the stored file keeps - * its extension. - */ const animation = articleContentType.fields.animation; const constraints = contentFileConstraints(animation); diff --git a/plugins/example/src/content/localized-article.ts b/plugins/example/src/content/localized-article.ts index 4d6b6a4ae..9c9dc8f5f 100644 --- a/plugins/example/src/content/localized-article.ts +++ b/plugins/example/src/content/localized-article.ts @@ -1,22 +1,5 @@ import { defineContentType, field } from "@vitnode/core/content"; -/** - * The localization reference: one content type with both halves of the partition, - * and - from Stage 5B - the whole editorial layer on top of it. - * - * `featured` is shared, so it lives on `example_localized_articles`. `title`, - * `slug` and `body` are localized, so they live on - * `example_localized_articles_translations` - one row per language, each with its - * own `version`, its own `status`, its own `publishedAt` and its own revision - * history, and a unique `(languageId, slug)` index so `/en/hello` and `/pl/hello` - * can both exist while a second English `hello` is a 409. - * - * From Stage 5C it is public as well: `publicApi` exposes the localized `title`, - * `slug` and `body` alongside the shared `featured`, and a public read resolves one - * language - explicitly, negotiated or the default - with `fallback: "default"` - * serving English to a locale that has no translation of its own. `search` - * indexes one document per published translation rather than one per record. - */ export const localizedArticleContentType = defineContentType({ id: "example.localized-article", tableName: "example_localized_articles", @@ -38,16 +21,6 @@ export const localizedArticleContentType = defineContentType({ // publishing the English copy of a draft article puts nothing on the internet. publication: { enabled: true }, - /** - * The public read layer, over both halves of the partition. - * - * `orderableFields` names shared columns only, and that is a rule rather than - * an oversight: a list ordered by a localized title would reshuffle itself for - * every language, and a cursor would mean two different positions across a - * fallback set. `searchableFields` and `filterableFields` *may* name localized - * fields - both are evaluated against the one translation the reader is being - * served, so they can never match a language nobody will see. - */ publicApi: { enabled: true, path: "localized-articles", @@ -59,17 +32,6 @@ export const localizedArticleContentType = defineContentType({ defaultOrder: "desc", }, - /** - * One search document per **published** translation. - * - * `titleField` and `contentFields` name localized fields, which is the whole - * point: an index built from the base row would hold no prose at all here, - * since every text field on this content type is localized. - * - * `{locale}` in `pathTemplate` is required rather than optional - two languages - * routinely answer to the same slug, so a template without it would give every - * translation of a record the same link. - */ search: { enabled: true, titleField: "title", diff --git a/plugins/example/src/database/advanced-routes.test.ts b/plugins/example/src/database/advanced-routes.test.ts index f94116e0e..a333343ef 100644 --- a/plugins/example/src/database/advanced-routes.test.ts +++ b/plugins/example/src/database/advanced-routes.test.ts @@ -13,16 +13,6 @@ import { CONFIG_PLUGIN } from "@/const"; import { advancedArticleContent } from "./advanced-articles"; -/** - * The generated routes for a content type with every advanced shape on it. - * - * Registration is the assertion: `OpenAPIHono` walks each route's request and - * response schemas and converts them to JSON Schema when it mounts them, so a - * nested group or a repeatable array that Zod could not describe would throw - * here rather than at the first request. The form schema is checked the same - * way, because `AutoForm` runs `z.toJSONSchema` on it in the browser - and Zod - * v4 throws on a `z.date()` anywhere inside. - */ describe("advanced article: generated routes", () => { const mount = ( routes: readonly { diff --git a/plugins/example/src/database/advanced-tables.test.ts b/plugins/example/src/database/advanced-tables.test.ts index 5e06c8774..297708a4b 100644 --- a/plugins/example/src/database/advanced-tables.test.ts +++ b/plugins/example/src/database/advanced-tables.test.ts @@ -10,15 +10,6 @@ import { example_advanced_articles_translations, } from "./advanced-articles"; -/** - * What Stage 6 actually generates, read off Drizzle's own table metadata. - * - * The same shape of test `tables.test.ts` runs for the base and translation - * tables, and for the same reason: the migration is generated from these - * objects, so an assertion here is an assertion about the SQL - one that fails - * at `pnpm test` rather than at `drizzle-kit generate` three commits later. - */ - const base = getTableConfig(example_advanced_articles); const junction = getTableConfig(example_advanced_articles_categories); const selfJunction = getTableConfig(example_advanced_articles_related_articles); diff --git a/plugins/example/src/database/tables.test.ts b/plugins/example/src/database/tables.test.ts index dca1d5602..5105c08d8 100644 --- a/plugins/example/src/database/tables.test.ts +++ b/plugins/example/src/database/tables.test.ts @@ -74,13 +74,6 @@ const MIGRATIONS_DIR = resolve( "../../../../apps/api/migrations", ); -/** - * Resolves one migration tag to its `migration.sql`. - * - * Drizzle Kit v3 puts every migration in its own `<timestamp>_<tag>/` directory, - * and the timestamp is assigned when the migration is generated - so the tag is - * matched as a suffix rather than the whole name being written down. - */ const migrationSql = (tag: string): string => { const dir = readdirSync(MIGRATIONS_DIR).find(name => name.endsWith(`_${tag}`), diff --git a/plugins/example/src/pages/admin-example-page.tsx b/plugins/example/src/pages/admin-example-page.tsx index 1826c80ae..2d55d4a62 100644 --- a/plugins/example/src/pages/admin-example-page.tsx +++ b/plugins/example/src/pages/admin-example-page.tsx @@ -1,37 +1,6 @@ import { definePluginRoute } from "@vitnode/core/routing"; import { useTranslations } from "use-intl"; -/** - * A plugin page inside the AdminCP - `area: "admin"`, and the smallest real one - * that can be written. - * - * It exists to prove the runtime rather than to do a job: one `page()` in - * `routes.ts` becomes a route mounted under the host's `_admin` shell and a URL - * at `/admin/example` with the sidebar, the breadcrumb and the admin session - * guard around it, with this module in a chunk of its own. Nothing about the page itself is special, which is the point - the - * contract is the same one a public page has. - * - * ## What is *not* here - * - * **A framework.** The same two imports a public plugin page has: the routing - * package, which is data and types with no framework in it, and `use-intl`, - * which VitNode itself renders through. No router, no framework package, no host-specific - * module - so this file is renderable by whichever application installs the - * plugin. - * - * **A `<main>`.** `area: "admin"` puts this inside the AdminCP shell, and the - * shell's `SidebarInset` *is* the document's one `<main>`. A page that rendered - * its own would produce two landmarks for a screen reader to choose between. - * - * **A permission gate.** This page shows nothing an administrator may not see, - * and the example plugin has no staff permission that would honestly describe - * "may open the overview" - its only permission modules are its two content - * types. Inventing one would be a security relationship that does not exist. A - * plugin page that *does* hold privileged data gates its own content with - * `AdminStaffPermissionGate` and, more importantly, is refused by the API: - * `requires` is not the tool, because it is about the public session and the - * AdminCP runs on its own. - */ const AdminExamplePage = () => { const t = useTranslations("@vitnode/example.admin.overview"); @@ -56,13 +25,6 @@ const AdminExamplePage = () => { ); }; -/** - * The crumb the AdminCP header renders for this page. - * - * Owned by the plugin, exactly as a public plugin route's is: this route - * contributes one item to the trail and the shell renders it. A label rather - * than a link - the shell owns the href, the separators and the `aria-current`. - */ const AdminExampleBreadcrumb = () => { const t = useTranslations("@vitnode/example.admin.overview"); diff --git a/plugins/example/src/pages/browse-page.tsx b/plugins/example/src/pages/browse-page.tsx index 856d19b95..0cf3d45d7 100644 --- a/plugins/example/src/pages/browse-page.tsx +++ b/plugins/example/src/pages/browse-page.tsx @@ -7,29 +7,6 @@ import type { BrowseSearch } from "./browse-search"; import { BROWSE_LAST_PAGE, BROWSE_PAGE_SIZE } from "./browse-search"; -/** - * A page whose URL *is* its state, and the reason a route may declare `search`. - * - * Its twin is `guide-topic-page.tsx`, which reads its query string through the - * module's own lazy `parseSearch`. That is the right default and covers most - * pages: a parameter the page reads, normalised once the chunk has arrived. - * - * This one cannot use it. The page number is not something the page reads *about* - * itself - it decides which page exists at all, so it has to be validated before - * the router matches, not after. Declaring `search` in `routes.ts` is what buys - * that: the schema is in the initial bundle, the router gets a real - * `validateSearch`, and `?page=999` is clamped to the last real page before - * anything renders. The page module itself stays in its own chunk. - * - * What that changes here, all of it visible in the props: - * - * - `search` is the schema's own type rather than "whatever survived", so - * `search.page` is a number and there is nothing to guard. - * - `navigate` changes the query string of the URL the page is already on. It is - * the whole of the navigation a plugin is offered, deliberately: a plugin - * handed a router's own `navigate` would be handed that router's route table - * with it. - */ const ITEMS = [ "pages", "layouts", @@ -108,26 +85,9 @@ const BrowsePage = ({ ); }; -/** - * No `parseSearch` here, and that is not an omission. - * - * A route with a `search` schema has already had its query string validated by - * the router, and the runtime hands that value straight through - so a - * `parseSearch` beside it would normalise a normalised value, with the module's - * answer silently disagreeing with the one the router built its links and its - * match id from. One route, one search contract. - */ export const route = definePluginRoute({ head: () => ({ title: "Browse" }), - /** - * No crumb, said on purpose. - * - * A route that declares nothing contributes nothing either - `false` is for - * saying so where a reader would otherwise wonder, which here is a page whose - * own heading is the only name it has. Every crumb a plugin does declare is - * one item of the shell's trail; the shell owns the separators, the links and - * the current-page semantics. - */ + breadcrumb: false, }); diff --git a/plugins/example/src/pages/browse-search.ts b/plugins/example/src/pages/browse-search.ts index 5cf98f99c..555f8a35f 100644 --- a/plugins/example/src/pages/browse-search.ts +++ b/plugins/example/src/pages/browse-search.ts @@ -1,32 +1,3 @@ -/** - * The query string of `/example/browse`, validated by the **router**. - * - * A module of its own, and that is the whole reason it is not inside - * `browse-page.tsx`: `routes.ts` imports it, so it is in the initial bundle - - * which is exactly what a `search` schema has to be. A router's `validateSearch` - * runs while it matches the URL, before any chunk is fetched, so a schema that - * lived in the lazy page module would arrive long after the URL had been - * matched. - * - * ## The contract on this file - * - * It exports the schema and nothing that renders. No React, no component, no - * import of the page it belongs to - because everything reachable from here is - * in the initial bundle with it. Keep it to the schema. - * - * It is also **total**. TanStack calls it during matching, on whatever somebody - * typed or pasted, and a throw there is a router error screen rather than a - * page. So `?page=banana` becomes page 1 and `?page=999` is clamped - neither is - * rejected. - * - * ## Why a number rather than a string - * - * The router's default search serialisation JSON-encodes what it does not - * recognise, so a `page` kept as `"2"` comes back as `?page=%222%22`. Parsing to - * a number here is what keeps the URL `?page=2`, and it is why the clamp below - * can be arithmetic rather than string handling. - */ - /** How many items the page shows, and what `page` is an index into. */ export const BROWSE_PAGE_SIZE = 3; diff --git a/plugins/example/src/pages/example-page.tsx b/plugins/example/src/pages/example-page.tsx index 2d04deab4..7f665200c 100644 --- a/plugins/example/src/pages/example-page.tsx +++ b/plugins/example/src/pages/example-page.tsx @@ -1,26 +1,3 @@ -/** - * The page `routes.ts` declares, and the first plugin route module a - * VitNode app bundles rather than copies. - * - * Zero imports, which is the point rather than an accident. It is compiled into - * this package's `dist` and imported by the app as - * `@vitnode/example/routes/example-page`, so it has to be renderable by whatever - * framework the app happens to use. Anything from a router, a framework's data - * APIs or a host-bound i18n package would pin it to one of them; a component that - * only needs JSX is pinned to none. - * - * It exports a default component because that is how every VitNode plugin page - * already exports itself, and because a default export is the one name a - * generated registry can rely on without being told. - * - * No `<main>`, and that is part of the contract rather than a style choice. A - * plugin route renders inside the application shell - and the shell renders the - * document's one `<main>` landmark. A page - * that renders its own produces `<main><main>`: invalid HTML, and two "main" - * landmarks for a screen reader to choose between. A plugin page owns its - * container - its width, its padding, its vertical rhythm - and nothing above - * it. - */ const ExamplePage = () => ( <div className="container mx-auto flex max-w-2xl flex-col gap-4 p-4"> <h1 className="text-2xl font-semibold tracking-tight text-balance"> diff --git a/plugins/example/src/pages/guide-index-page.tsx b/plugins/example/src/pages/guide-index-page.tsx index f95868e78..b6035c611 100644 --- a/plugins/example/src/pages/guide-index-page.tsx +++ b/plugins/example/src/pages/guide-index-page.tsx @@ -1,23 +1,5 @@ import { useTranslations } from "use-intl"; -/** - * What `/example/guide` renders inside the layout beside it. - * - * The `page.tsx` next to a `layout.tsx`, said as a tree: `index()` inside the - * layout's `children`. It renders no heading of its own - the frame owns that - and no `<main>`, because the application shell owns the document's - * one `main` landmark and a plugin page that rendered a second would give a - * screen reader two to choose between. - * - * It declares no `route` export at all, which is the point of it sitting next to - * `guide-layout.tsx`: the whole of this page's behaviour - its robots directive, - * its breadcrumb, its message namespaces - is inherited. A module whose default - * export is a component is a complete module. - * - * The strings come from `@vitnode/example.guide`, which the *layout* declares in - * `routes.ts`. Message namespaces are inherited by every descendant, so a page - * renders in strings it never had to ask for - and the host warms them alongside this - * module's chunk rather than in a round trip after it. - */ const GuideIndexPage = () => { const t = useTranslations("@vitnode/example.guide"); diff --git a/plugins/example/src/pages/guide-layout.tsx b/plugins/example/src/pages/guide-layout.tsx index 6a0962f1c..35bfbf324 100644 --- a/plugins/example/src/pages/guide-layout.tsx +++ b/plugins/example/src/pages/guide-layout.tsx @@ -1,26 +1,6 @@ import { definePluginRoute } from "@vitnode/core/routing"; import { useTranslations } from "use-intl"; -/** - * The frame `routes.ts` declares with `layout()`. - * - * A layout claims no URL of its own. It is only ever reached through one of its - * children, which is why VitNode rejects one with no `children`, and why the - * index page beside it - `guide-index-page` - is a separate `index()` route - * rather than something this file renders itself. - * - * `children` arrives as a **prop**, not as an `<Outlet />` this module imports, - * and that is the one thing keeping a plugin layout framework-neutral: an - * `Outlet` belongs to a router, and a plugin that imported one could be - * installed into exactly one kind of application. It is also the same shape as a - * a framework's own nested-layout file, so a plugin writes the frame once. - * - * Two imports, and both are deliberate. `@vitnode/core/routing` is data and - * types only - it is the same module a Node build reads, with no framework in - * it. `use-intl` is the library VitNode itself renders through; a plugin that - * reached for a host-bound i18n package or a router's hooks would pin itself to one - * host. - */ const GuideLayout = ({ children }: { children: React.ReactNode }) => { const t = useTranslations("@vitnode/example.guide"); @@ -41,37 +21,12 @@ const GuideLayout = ({ children }: { children: React.ReactNode }) => { ); }; -/** - * This frame's own crumb - one item of the trail, not the trail. - * - * A component rather than an element, because the label is translated and so has - * to be able to call a hook. Every matched route contributes its own crumb in - * parent-to-child order, so `/example/guide/:topic` reads `Plugin routing guide / Layouts` - * without either route knowing about the other. - * - * A label, not a link: the shell turns each crumb into a locale-aware link to - * its own route's URL, and adds the separators and the `aria-current`. A plugin - * route module is handed nothing to build a link with, and needs nothing. - */ const GuideBreadcrumb = () => { const t = useTranslations("@vitnode/example.guide"); return <span>{t("title")}</span>; }; -/** - * What this frame contributes to every page inside it. - * - * `head` is merged down the matched routes and the deepest wins per field, so - * declaring `robots` here is how the whole subtree inherits it by saying - * nothing. A child that needs a different directive overrides just that field. - * The crumb is not merged: it is this route's own item in the trail. - * - * `definePluginRoute` rather than a `satisfies` clause: it infers the types this - * object's own members share. Nothing on this layout needs that yet - it is used - * here for the same reason the child below uses it, which is that there is one - * way to write a `route` export and not two. - */ export const route = definePluginRoute({ head: () => ({ robots: "index, follow" }), breadcrumb: GuideBreadcrumb, diff --git a/plugins/example/src/pages/guide-topic-page.tsx b/plugins/example/src/pages/guide-topic-page.tsx index 909db8cf3..446580e37 100644 --- a/plugins/example/src/pages/guide-topic-page.tsx +++ b/plugins/example/src/pages/guide-topic-page.tsx @@ -6,27 +6,6 @@ import type { import { definePluginRoute } from "@vitnode/core/routing"; import { useTranslations } from "use-intl"; -/** - * The three seams of a route, in one file: what it accepts, what it resolves, - * and what it renders. - * - * This is the page `routes.ts` declares at `:topic` inside the guide layout, and - * it is here to be the smallest honest example of a route that is *about* - * something. Everything it needs arrives through the contract rather than - * through a router: `load` is handed the parsed `params`, the component is - * handed what `load` returned, and neither imports anything that knows what a - * router is. - */ - -/** - * Stand-in for whatever a real plugin reads. - * - * A record here rather than a fetch, because what this example is demonstrating - * is the shape of the seams, and a database would only make that harder to see. - * A real plugin's `load` awaits its own Hono route through the host's - * `QueryClient` - the API boundary is unchanged, and this is where the read is - * *awaited*, not a second transport. - */ const TOPICS: Record<string, { body: string; title: string }> = { layouts: { body: "A layout claims no URL of its own: it frames its children, and the index() route inside it renders at the layout's own path.", @@ -42,18 +21,6 @@ const TOPICS: Record<string, { body: string; title: string }> = { }, }; -/** - * What the loader resolved, rendered. - * - * `loaderData` is not optional here, and the asymmetry with `head` below is - * worth knowing rather than working around: a match does not render until its - * loader has resolved, so by the time this component exists the data is in hand. - * `head` runs on passes where it has not, which is why its `loaderData` is - * optional and this one is not. - * - * `params` and `search` arrive the same way and under the same names they have - * in `load` and `head`, so there is one vocabulary for a route rather than three. - */ const GuideTopicPage = ({ loaderData, search, @@ -87,41 +54,11 @@ interface TopicSearch { from: string; } -/** - * The crumb this page contributes, read from what its loader resolved. - * - * One item of the trail, not the trail: the layout above contributes "Plugin - * routing guide" and this adds the topic's own title after it, so the shell - * renders `Plugin routing guide / Layouts` without either route knowing about - * the other. The props are the ones `load` and the component get - this route's - * own, and typed by the same loader. - */ const GuideTopicBreadcrumb = ({ loaderData, }: PluginRouteBreadcrumbProps<Topic>) => loaderData.title; -/** - * `load` above `head`, and that order is load-bearing. - * - * TypeScript resolves an object literal's context-sensitive members in the order - * they are written, so `head`'s `loaderData` is only typed once `load` has been - * read. Written the other way round, `definePluginRoute` reports it in those - * words rather than as `Property 'title' does not exist on type '{}'`. - */ export const route = definePluginRoute({ - /** - * Total, never throwing. - * - * A pasted or hand-edited query string has to render the page it would have - * rendered anyway - throwing here turns `?from=whatever` into a router error - * screen. It returns only what this route recognises, so nothing else is - * carried forward. - * - * It normalises the query string for `load`, `head` and the component, and it - * is not the router's `validateSearch`: this module is lazy, so by the time it - * exists the URL has long since been matched. No link is checked against this - * and no URL is rejected by it. - */ parseSearch: (input: unknown): TopicSearch => { const from = (input as null | Partial<TopicSearch>)?.from; @@ -132,13 +69,7 @@ export const route = definePluginRoute({ body: "", title: params.topic, }, - /** - * The `<title>` and the `<h1>` are the same string by construction, because - * both read the one object the loader returned. - * - * No `robots`: the layout above declares it, the router merges `head` down the - * matched routes, and a child inherits by saying nothing. - */ + head: ({ loaderData }) => ({ title: loaderData?.title }), breadcrumb: GuideTopicBreadcrumb, }); diff --git a/plugins/example/src/routes.ts b/plugins/example/src/routes.ts index 7937b329f..2ecc45c29 100644 --- a/plugins/example/src/routes.ts +++ b/plugins/example/src/routes.ts @@ -8,91 +8,17 @@ import { import { browseSearch } from "./pages/browse-search"; -/** - * The routes this plugin contributes to whatever app installs it. - * - * A tree, and browser-safe by construction: a path, the shell it renders in, the - * message namespaces it needs, and one `lazy(() => import(...))` per page. - * Nothing here imports a router and nothing here imports a page - the `import()` - * inside `lazy` is a literal Vite follows at build time and Rollup gives its own - * chunk, and it does not run until the route is matched or preloaded. - * - * That is what lets an app read this list in Node while it builds, with no React - * in the process, and still end up with one chunk per page: the tree is the - * declaration, the imports are the code, and the two live in the same file - * without being loaded at the same time. - * - * `config.tsx` hands this same array to `buildPlugin({ routes })`, so a host that - * registers the plugin through its config declares exactly the same routes as one - * that reads this module at build time - one list, read by both paths. - * - * ## What this file is an example of - * - * Two things, deliberately separated, because a plugin only ever needs the first - * one and every plugin needs to be able to find out what the second costs. - * - * `/example` is the **minimum**: a path and a component. Its module is a bare - * `export default`. A plugin with one public page writes this and stops. - * - * `/example/guide` is the rest of the contract, as small as it can be written - * and still be real: a `layout()` that frames its children, the `index()` page - * inside it, a dynamic child, the message namespaces those three render, and - in - * the modules themselves - a loader, page metadata, a breadcrumb and a search - * contract. One tree rather than four separate examples, because what is worth - * showing is how they fit together. - * - * Note what is *not* here: no locale. `/example` and `/pl/example` are one route, - * because the host strips the prefix before matching and writes it back into - * every link it builds. A plugin declares the logical path only. - */ export const routes = definePluginRoutes([ page("/example", { component: lazy(() => import("./pages/example-page")), }), - /** - * A page whose query string is validated by the **router**, not by its module. - * - * `search` is the one field of a route that is deliberately eager, and the - * only thing in this file with a cost: it is a function, so it lives in this - * module rather than in the page's chunk, and everything it imports is in the - * initial bundle with it. That is the price of being early - a router's - * `validateSearch` runs while it matches the URL, before any chunk is fetched, - * so a schema in the lazy page module would arrive long after the answer was - * needed. - * - * Declare it only for a page whose URL *is* its state: a paginated list whose - * `?page=999` has to be clamped, a filter whose links have to be typed. - * `/example/guide/:topic` below is the ordinary case - it reads its query - * string through its own module's lazy `parseSearch`, and adds nothing to the - * initial bundle. - */ page("/example/browse", { component: lazy(() => import("./pages/browse-page")), messages: ["@vitnode/example.browse"], search: browseSearch, }), - /** - * A frame, and no URL of its own. - * - * `layout()` is what makes `/example/guide` two routes rather than one - * ambiguous one: this renders the heading and the surrounding chrome, and the - * `index()` inside it renders what goes at that same path - the `layout.tsx` - * and `page.tsx` pair, said as a tree. A layout may not be a leaf, so removing - * its children is a build error rather than a route nothing can reach. - * - * The messages sit here rather than on each child. They are declared on the - * route because they have to be known *before* the page's chunk is fetched - - * the strings and the code are two requests, and a list that lived inside the - * code could only be read after downloading the page it describes. A child - * inherits every namespace its ancestors declare, so naming them once on the - * frame is what stops three routes repeating one list. - * - * Every path below is **relative** to this one. The layout's is absolute - * because it is a top-level route; a child adds what it adds, and VitNode - * joins the two - so moving this subtree is one edit rather than four. - */ layout("/example/guide", { component: lazy(() => import("./pages/guide-layout")), messages: ["@vitnode/example.guide"], @@ -102,40 +28,12 @@ export const routes = definePluginRoutes([ component: lazy(() => import("./pages/guide-index-page")), }), - /** - * A dynamic child: `:topic`, in VitNode's spelling. - * - * Neither Next's `[topic]` nor TanStack's `$topic` - the host converts. - * Relative to the layout, so the full path is `/example/guide/:topic` and - * nothing here repeats the parent's segments. - * - * It declares no messages of its own. The frame above declares the set all - * three routes render in, and a child inherits every namespace its - * ancestors declare, so this route ships one line and still has its - * strings in flight beside its chunk. - */ page(":topic", { component: lazy(() => import("./pages/guide-topic-page")), }), ], }), - /** - * A page in the **AdminCP**, and the whole of what that costs: one field. - * - * `area: "admin"` names the shell the page is framed by - the sidebar, the - * breadcrumb area, the command palette and the admin session guard - and the - * host mounts it under whichever route renders that shell. It does *not* put - * `/admin` in front of the path: both shells are pathless, so an area frames a - * page rather than moving it, and the URL below is written out in full. That - * is also why a `main` route at `/admin/example` would be a collision with - * this rather than a second page - one URL is one URL whichever frame draws - * it. - * - * `requires` is absent and may not be present: it is about the *public* - * session, and this page is already behind the AdminCP's own, under its own - * cookie. A staff permission gates the page's content, inside the module. - */ page("/admin/example", { area: "admin", component: lazy(() => import("./pages/admin-example-page")), diff --git a/scripts/bump-version/bump-version.ts b/scripts/bump-version/bump-version.ts index d016328d0..4ef531b9c 100644 --- a/scripts/bump-version/bump-version.ts +++ b/scripts/bump-version/bump-version.ts @@ -1,10 +1,4 @@ -/** - * VitNode Bump Version Script - * - * This script handles version bumping and file synchronization for VitNode packages. - * It copies files and folders from the main web app to the create-vitnode-app template - * and manages package version updates across the workspace. - */ + import { validateEnvironment } from "./environment.ts"; import { FileCopyManager } from "./files/file-copy-manager.ts"; From a453d38934e292e7307b5292834140e702f870b1 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Thu, 3 Sep 2026 19:05:06 +0200 Subject: [PATCH 11/12] docs: Add pending component --- apps/web/scripts/check-docs.mjs | 142 --------------------------- apps/web/src/docs/pending.tsx | 118 ++++++++++++++++++++++ apps/web/src/routes/_docs.tsx | 2 + apps/web/src/routes/_docs/docs.$.tsx | 2 + 4 files changed, 122 insertions(+), 142 deletions(-) delete mode 100644 apps/web/scripts/check-docs.mjs create mode 100644 apps/web/src/docs/pending.tsx diff --git a/apps/web/scripts/check-docs.mjs b/apps/web/scripts/check-docs.mjs deleted file mode 100644 index 6be02df37..000000000 --- a/apps/web/scripts/check-docs.mjs +++ /dev/null @@ -1,142 +0,0 @@ -import { readdir, readFile } from 'node:fs/promises' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' - -const docsRoot = join( - dirname(fileURLToPath(import.meta.url)), - '../content/docs', -) -const errors = [] - -const walk = async (directory) => { - const entries = await readdir(directory, { withFileTypes: true }) - const files = await Promise.all( - entries.map(async (entry) => { - const path = join(directory, entry.name) - return entry.isDirectory() ? await walk(path) : [path] - }), - ) - - return files.flat() -} - -const frontmatterValue = (frontmatter, field) => { - const match = frontmatter.match(new RegExp(`^${field}:\\s*(.+)$`, 'm')) - return match?.[1]?.trim().replace(/^['"]|['"]$/g, '') -} - -const checkFrontmatter = (file, source) => { - const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/) - - if (!match) { - errors.push(`${file}: missing frontmatter`) - return - } - - const title = frontmatterValue(match[1], 'title') - const description = frontmatterValue(match[1], 'description') - const icon = frontmatterValue(match[1], 'icon') - - for (const [field, value] of [ - ['title', title], - ['description', description], - ['icon', icon], - ]) { - if (!value) errors.push(`${file}: missing ${field}`) - } - - if (description && (description.length < 50 || description.length > 170)) { - errors.push( - `${file}: description should be 50–170 characters for search snippets`, - ) - } -} - -const checkCategory = (file, source) => { - let category - - try { - category = JSON.parse(source) - } catch { - errors.push(`${file}: invalid JSON`) - return - } - - for (const field of ['title', 'description', 'icon']) { - if (!category[field]) errors.push(`${file}: missing ${field}`) - } - - if ( - category.description && - (category.description.length < 35 || category.description.length > 170) - ) { - errors.push( - `${file}: description should be 35–170 characters for navigation and search`, - ) - } -} - -const checkPackageManagerTabs = (file, source) => { - const tabs = [...source.matchAll(/<Tabs\b[\s\S]*?<\/Tabs>/g)] - const blocks = source.matchAll( - /^```(?:bash|sh|shell|zsh)([^\n]*)\n([\s\S]*?)^```/gm, - ) - const checkedTabs = new Set() - - for (const block of blocks) { - const [, _info, body] = block - if (!/\b(?:bun|pnpm|npm)\b/.test(body)) continue - - const start = block.index ?? -1 - const container = tabs.find((tab) => { - const tabStart = tab.index ?? -1 - return start >= tabStart && start < tabStart + tab[0].length - }) - - if (!container) { - errors.push( - `${file}: package-manager command needs Bun, pnpm, and npm tabs`, - ) - continue - } - - const key = container.index ?? -1 - if (checkedTabs.has(key)) continue - checkedTabs.add(key) - - const labels = new Set( - [...container[0].matchAll(/```[^\n]*\btab=["']([^"']+)["']/g)].map( - (tab) => tab[1], - ), - ) - const missing = ['bun', 'pnpm', 'npm'].filter((label) => !labels.has(label)) - - if (missing.length > 0) { - errors.push( - `${file}: package-manager tabs are missing ${missing.join(', ')}`, - ) - } - } -} - -const files = await walk(docsRoot) - -for (const file of files) { - if (!file.endsWith('.mdx') && !file.endsWith('meta.json')) continue - - const source = await readFile(file, 'utf8') - if (file.endsWith('.mdx')) { - checkFrontmatter(file, source) - checkPackageManagerTabs(file, source) - } else { - checkCategory(file, source) - } -} - -if (errors.length > 0) { - console.error(`Docs check found ${errors.length} issue(s):`) - for (const error of errors) console.error(`- ${error}`) - process.exitCode = 1 -} else { - console.log(`Docs check passed for ${files.length} files.`) -} diff --git a/apps/web/src/docs/pending.tsx b/apps/web/src/docs/pending.tsx new file mode 100644 index 000000000..3ae855f90 --- /dev/null +++ b/apps/web/src/docs/pending.tsx @@ -0,0 +1,118 @@ +import { Skeleton } from '@vitnode/core/components/ui/skeleton' +import { cn } from '@vitnode/core/lib/utils' +import { DocsPage } from 'fumadocs-ui/page' + +const DocsHeadingPendingSkeleton = () => ( + <div aria-hidden="true" className="flex flex-col gap-4"> + <div className="flex items-center gap-2"> + <Skeleton className="h-3.5 w-16" /> + <Skeleton className="h-3.5 w-24" /> + </div> + + <div className="flex flex-wrap items-center justify-between gap-4"> + <Skeleton className="h-9 w-3/5 max-w-xs sm:h-10" /> + <Skeleton className="h-8 w-24 rounded-md" /> + </div> + + <Skeleton className="h-5 w-4/5 max-w-md" /> + </div> +) + +const DocsProsePendingSkeleton = () => ( + <div aria-hidden="true" className="flex flex-col gap-8"> + <div className="flex flex-col gap-3"> + <Skeleton className="h-3.5 w-full" /> + <Skeleton className="h-3.5 w-11/12" /> + <Skeleton className="h-3.5 w-4/5" /> + </div> + + <Skeleton className="h-40 w-full rounded-xl" /> + + <div className="flex flex-col gap-3"> + <Skeleton className="h-5 w-2/5" /> + <Skeleton className="h-3.5 w-full" /> + <Skeleton className="h-3.5 w-10/12" /> + </div> + + <div className="flex flex-col gap-3"> + <Skeleton className="h-5 w-1/3" /> + <Skeleton className="h-3.5 w-full" /> + <Skeleton className="h-3.5 w-9/12" /> + <Skeleton className="h-3.5 w-2/3 max-sm:hidden" /> + </div> + </div> +) + +const DocsPendingStatus = () => ( + <span className="sr-only" role="status"> + Loading documentation + </span> +) + +export const DocsPagePendingSkeleton = () => ( + <DocsPage + aria-busy="true" + breadcrumb={{ enabled: false }} + data-slot="route-pending" + footer={{ enabled: false }} + tableOfContentPopover={{ enabled: true }} + toc={[]} + > + <DocsPendingStatus /> + <DocsHeadingPendingSkeleton /> + <DocsProsePendingSkeleton /> + </DocsPage> +) + +const SIDEBAR_LINK_WIDTHS = ['w-4/5', 'w-3/5', 'w-2/3', 'w-1/2'] + +const DocsSidebarGroupPendingSkeleton = ({ links }: { links: number }) => ( + <div className="flex flex-col gap-3"> + <Skeleton className="h-3 w-24" /> + + {SIDEBAR_LINK_WIDTHS.slice(0, links).map((width) => ( + <Skeleton className={cn('h-3.5', width)} key={width} /> + ))} + </div> +) + +export const DocsShellPendingSkeleton = () => ( + <div + aria-busy="true" + className="flex min-h-dvh flex-col" + data-slot="route-pending" + > + <DocsPendingStatus /> + + <header + aria-hidden="true" + className="flex h-14 shrink-0 items-center gap-4 border-b px-4 md:px-6" + > + <Skeleton className="h-6 w-30" /> + <Skeleton className="h-8 w-full max-w-sm rounded-xl max-md:hidden" /> + + <div className="ms-auto flex items-center gap-3"> + <Skeleton className="h-4 w-24 max-lg:hidden" /> + <Skeleton className="size-8 rounded-md" /> + </div> + </header> + + <div className="flex flex-1"> + <aside + aria-hidden="true" + className="bg-card flex w-67 shrink-0 flex-col gap-6 border-e p-4 max-md:hidden" + > + <Skeleton className="h-11 w-full rounded-lg" /> + + <DocsSidebarGroupPendingSkeleton links={4} /> + <DocsSidebarGroupPendingSkeleton links={3} /> + <DocsSidebarGroupPendingSkeleton links={2} /> + </aside> + + <div className="mx-auto flex w-full max-w-225 flex-col gap-4 px-4 py-6 md:px-6 md:pt-8 xl:px-8 xl:pt-14"> + <DocsHeadingPendingSkeleton /> + <DocsProsePendingSkeleton /> + </div> + </div> + </div> +) diff --git a/apps/web/src/routes/_docs.tsx b/apps/web/src/routes/_docs.tsx index 859d6a647..f0c4103ea 100644 --- a/apps/web/src/routes/_docs.tsx +++ b/apps/web/src/routes/_docs.tsx @@ -1,6 +1,7 @@ import { createFileRoute, Outlet } from '@tanstack/react-router' import { DOCS_TREE_STALE_TIME } from '#/docs/freshness' +import { DocsShellPendingSkeleton } from '#/docs/pending' import { DocsShellContent } from '#/docs/shell-content' import { getDocsPageTree } from '#/docs/transport' @@ -8,6 +9,7 @@ export const Route = createFileRoute('/_docs')({ loader: async () => ({ pageTree: await getDocsPageTree() }), staleTime: DOCS_TREE_STALE_TIME, component: DocsShell, + pendingComponent: DocsShellPendingSkeleton, }) function DocsShell() { diff --git a/apps/web/src/routes/_docs/docs.$.tsx b/apps/web/src/routes/_docs/docs.$.tsx index 5bee30995..c8ca64144 100644 --- a/apps/web/src/routes/_docs/docs.$.tsx +++ b/apps/web/src/routes/_docs/docs.$.tsx @@ -2,6 +2,7 @@ import { createFileRoute, redirect } from '@tanstack/react-router' import { movedDocsSlug } from '#/docs/moved-pages' import { DocsPageContent } from '#/docs/page-content' +import { DocsPagePendingSkeleton } from '#/docs/pending' import { getDocsPage } from '#/docs/transport' import { pageHead } from '#/lib/page-head' @@ -38,6 +39,7 @@ export const Route = createFileRoute('/_docs/docs/$')({ title: loaderData?.metaTitle, }), component: DocsRoute, + pendingComponent: DocsPagePendingSkeleton, }) function DocsRoute() { From ee867ccd6489def89b3a1d0d4ae7941cfc349a33 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <aXenDeveloper@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:06:31 +0000 Subject: [PATCH 12/12] ci: version bump to v2.0.0-canary.4 --- apps/api/package.json | 2 +- apps/web/package.json | 2 +- packages/config/package.json | 2 +- packages/create-vitnode-app/package.json | 2 +- packages/elasticsearch/package.json | 2 +- packages/node-cron/package.json | 2 +- packages/nodemailer/package.json | 2 +- packages/resend/package.json | 2 +- packages/s3/package.json | 2 +- packages/supabase-storage/package.json | 2 +- packages/vitnode/package.json | 2 +- packages/vitnode/src/config.ts | 2 +- plugins/blog/package.json | 2 +- plugins/example/package.json | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 9bb560c9b..4599d6a44 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "api", - "version": "2.0.0-canary.3", + "version": "2.0.0-canary.4", "private": true, "type": "module", "scripts": { diff --git a/apps/web/package.json b/apps/web/package.json index 7efb77a58..e17ed5dfe 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "2.0.0-canary.3", + "version": "2.0.0-canary.4", "private": true, "type": "module", "imports": { diff --git a/packages/config/package.json b/packages/config/package.json index f54ccc09e..1ed9c0d3f 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/config", - "version": "2.0.0-canary.3", + "version": "2.0.0-canary.4", "description": "ESLint, Prettier, TypeScript (TSConfig) config for VitNode", "author": "VitNode Team", "license": "MIT", diff --git a/packages/create-vitnode-app/package.json b/packages/create-vitnode-app/package.json index 7d6183589..1ed55aa7e 100644 --- a/packages/create-vitnode-app/package.json +++ b/packages/create-vitnode-app/package.json @@ -1,6 +1,6 @@ { "name": "create-vitnode-app", - "version": "2.0.0-canary.3", + "version": "2.0.0-canary.4", "description": "Create a new VitNode app in seconds.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/elasticsearch/package.json b/packages/elasticsearch/package.json index dba6d2eb5..1f138a737 100644 --- a/packages/elasticsearch/package.json +++ b/packages/elasticsearch/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/elasticsearch", - "version": "2.0.0-canary.3", + "version": "2.0.0-canary.4", "description": "Elasticsearch search engine adapter for VitNode content discovery.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/node-cron/package.json b/packages/node-cron/package.json index 08de855cc..13cf21ab1 100644 --- a/packages/node-cron/package.json +++ b/packages/node-cron/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/node-cron", - "version": "2.0.0-canary.3", + "version": "2.0.0-canary.4", "description": "Node-cron adapter for VitNode, enabling cron job scheduling and management.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/nodemailer/package.json b/packages/nodemailer/package.json index a4580d532..9870e022a 100644 --- a/packages/nodemailer/package.json +++ b/packages/nodemailer/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/nodemailer", - "version": "2.0.0-canary.3", + "version": "2.0.0-canary.4", "description": "Nodemailer integration package for VitNode, enabling email functionalities.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/resend/package.json b/packages/resend/package.json index f05d53661..ed1a8ab91 100644 --- a/packages/resend/package.json +++ b/packages/resend/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/resend", - "version": "2.0.0-canary.3", + "version": "2.0.0-canary.4", "description": "Resend adapter for VitNode, enabling email sending capabilities through the Resend service.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/s3/package.json b/packages/s3/package.json index 18a8e83e5..0e318bb58 100644 --- a/packages/s3/package.json +++ b/packages/s3/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/s3", - "version": "2.0.0-canary.3", + "version": "2.0.0-canary.4", "description": "AWS S3 and Cloudflare R2 storage adapter for VitNode file uploads.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/supabase-storage/package.json b/packages/supabase-storage/package.json index 41ff658b8..0fd0c1ced 100644 --- a/packages/supabase-storage/package.json +++ b/packages/supabase-storage/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/supabase-storage", - "version": "2.0.0-canary.3", + "version": "2.0.0-canary.4", "description": "Supabase Storage adapter for VitNode file uploads.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 1f65620b0..150fad327 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/core", - "version": "2.0.0-canary.3", + "version": "2.0.0-canary.4", "description": "Core package for VitNode, providing essential functionalities and configurations.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/vitnode/src/config.ts b/packages/vitnode/src/config.ts index e8c1ea4f8..7e3446f8f 100644 --- a/packages/vitnode/src/config.ts +++ b/packages/vitnode/src/config.ts @@ -1,4 +1,4 @@ export const CONFIG_PLUGIN = { pluginId: "@vitnode/core" as const, - version: "2.0.0-canary.3", + version: "2.0.0-canary.4", }; diff --git a/plugins/blog/package.json b/plugins/blog/package.json index 2431850cf..832351af6 100644 --- a/plugins/blog/package.json +++ b/plugins/blog/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/blog", - "version": "2.0.0-canary.3", + "version": "2.0.0-canary.4", "description": "Blog plugin for VitNode, providing a blogging platform on Hono.js.", "author": "VitNode Team", "license": "MIT", diff --git a/plugins/example/package.json b/plugins/example/package.json index 8741462c5..97a8e3621 100644 --- a/plugins/example/package.json +++ b/plugins/example/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/example", - "version": "2.0.0-canary.3", + "version": "2.0.0-canary.4", "description": "Reference plugin exercising the VitNode Content Engine end to end.", "license": "MIT", "private": true,