From 694e7a2997f22e460724bbc18bdaf6420294f005 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Wed, 26 Aug 2026 22:24:40 +0200 Subject: [PATCH] feat: Add providers to tanstack start --- apps/web/package.json | 8 + apps/web/src/i18n.ts | 34 +++ apps/web/src/lib/i18n.ts | 36 +++ apps/web/src/locales.ts | 38 ++++ apps/web/src/router.tsx | 37 ++- apps/web/src/routes/__root.tsx | 148 +++++++++--- apps/web/src/routes/index.tsx | 118 +++++++++- apps/web/src/server/messages.server.ts | 66 ++++++ apps/web/src/styles.css | 150 ++++++++++++- apps/web/src/tests/isolation.test.ts | 153 ++++++++++++- apps/web/src/tests/router-query.test.ts | 119 ++++++++++ apps/web/src/tests/shell-config.test.ts | 178 +++++++++++++++ apps/web/src/vitnode.config.ts | 45 ++++ apps/web/src/vitnode.shell.config.ts | 35 +++ .../vitnode/src/components/i18n-provider.tsx | 36 +-- .../src/components/progress-bar.test-d.ts | 23 ++ .../vitnode/src/components/progress-bar.ts | 60 +++++ .../vitnode/src/components/theme-provider.tsx | 91 +------- .../src/components/theme-script-next.tsx | 28 +++ .../vitnode/src/components/theme-script.tsx | 129 +++++++++++ .../vitnode/src/components/theme.test.tsx | 212 ++++++++++++++++++ .../i18n/pick-messages.test.ts} | 10 +- .../vitnode/src/lib/i18n/pick-messages.ts | 35 +++ packages/vitnode/src/lib/metadata.ts | 28 +++ packages/vitnode/src/lib/query-client.test.ts | 50 +++++ packages/vitnode/src/lib/query-client.ts | 36 +++ .../src/views/error/global-error-view.tsx | 5 + .../vitnode/src/views/layouts/provider.tsx | 105 ++++----- .../layouts/providers.boundaries.test.ts | 167 ++++++++++++++ .../vitnode/src/views/layouts/providers.tsx | 78 +++++++ .../vitnode/src/views/layouts/root-layout.tsx | 7 +- packages/vitnode/src/vitnode.config.ts | 13 +- pnpm-lock.yaml | 106 +++++++-- 33 files changed, 2121 insertions(+), 263 deletions(-) create mode 100644 apps/web/src/i18n.ts create mode 100644 apps/web/src/lib/i18n.ts create mode 100644 apps/web/src/locales.ts create mode 100644 apps/web/src/server/messages.server.ts create mode 100644 apps/web/src/tests/router-query.test.ts create mode 100644 apps/web/src/tests/shell-config.test.ts create mode 100644 apps/web/src/vitnode.config.ts create mode 100644 apps/web/src/vitnode.shell.config.ts create mode 100644 packages/vitnode/src/components/progress-bar.test-d.ts create mode 100644 packages/vitnode/src/components/progress-bar.ts create mode 100644 packages/vitnode/src/components/theme-script-next.tsx create mode 100644 packages/vitnode/src/components/theme-script.tsx create mode 100644 packages/vitnode/src/components/theme.test.tsx rename packages/vitnode/src/{components/i18n-provider.test.ts => lib/i18n/pick-messages.test.ts} (85%) create mode 100644 packages/vitnode/src/lib/i18n/pick-messages.ts create mode 100644 packages/vitnode/src/lib/metadata.ts create mode 100644 packages/vitnode/src/lib/query-client.test.ts create mode 100644 packages/vitnode/src/lib/query-client.ts create mode 100644 packages/vitnode/src/views/layouts/providers.boundaries.test.ts create mode 100644 packages/vitnode/src/views/layouts/providers.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 7f699e9f8..158b6c3c5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,8 +23,11 @@ "@hono/zod-openapi": "^1.5.1", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-devtools": "^0.10.12", + "@tanstack/react-query": "^5.101.4", + "@tanstack/react-query-devtools": "^5.102.3", "@tanstack/react-router": "^1.170.32", "@tanstack/react-router-devtools": "^1.167.1", + "@tanstack/react-router-ssr-query": "^1.167.1", "@tanstack/react-start": "^1.168.49", "@vitnode/blog": "workspace:*", "@vitnode/core": "workspace:*", @@ -33,10 +36,14 @@ "drizzle-kit": "1.0.0-rc.4", "drizzle-orm": "1.0.0-rc.4", "hono": "^4.12.31", + "lucide-react": "^1.25.0", + "motion": "^12.42.2", "next-intl": "^4.13.7", "nitro": "3.0.260610-beta", "react": "^19.2.0", "react-dom": "^19.2.0", + "shadcn": "^4.14.0", + "sonner": "^2.0.7", "tailwindcss": "^4.1.18", "zod": "^4.4.3" }, @@ -50,6 +57,7 @@ "@vitejs/plugin-react": "^6.0.1", "@vitnode/config": "workspace:*", "eslint": "^10.7.0", + "tw-animate-css": "^1.4.0", "typescript": "^6.0.2", "vite": "^8.0.0", "vitest": "^4.1.10" diff --git a/apps/web/src/i18n.ts b/apps/web/src/i18n.ts new file mode 100644 index 000000000..3c14e3749 --- /dev/null +++ b/apps/web/src/i18n.ts @@ -0,0 +1,34 @@ +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', + /** + * 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', + locales: [ + { + code: 'en', + name: 'English', + }, + { + code: 'pl', + name: 'Polski', + }, + ], +} satisfies VitNodeI18nConfig diff --git a/apps/web/src/lib/i18n.ts b/apps/web/src/lib/i18n.ts new file mode 100644 index 000000000..d80016006 --- /dev/null +++ b/apps/web/src/lib/i18n.ts @@ -0,0 +1,36 @@ +import { queryOptions } from '@tanstack/react-query' +import { createServerFn } from '@tanstack/react-start' + +import { loadShellIntl } from '#/server/messages.server' + +/** + * The shell's locale and its `core.global` strings, fetched on the server. + * + * A server function rather than a plain loader: the messages are read from JSON + * inside each package's `dist`, which only exists on the server, and the plugin + * registry they are merged from must never reach the browser bundle. Start + * strips the handler - and everything only it imports - out of the client build. + */ +export const getShellIntl = createServerFn().handler( + async () => await loadShellIntl(), +) + +/** + * The same request, as a query. + * + * Going through the QueryClient rather than returning it from the loader is what + * makes the shell's copy of it *the* copy: the root loader warms it on the + * server, the SSR integration dehydrates it into the HTML, and the component + * reads it out of the hydrated cache instead of asking the server again. It is + * also the first real exercise of the Stage 2 pipeline - router context, loader, + * `ensureQueryData`, dehydrate, hydrate - which is worth having under something + * the page visibly needs rather than a synthetic query. + * + * `staleTime: Infinity`: a locale's messages change when the app is redeployed. + */ +export const shellIntlQueryOptions = () => + queryOptions({ + queryFn: async () => await getShellIntl(), + queryKey: ['vitnode', 'shell-intl'] as const, + staleTime: Infinity, + }) diff --git a/apps/web/src/locales.ts b/apps/web/src/locales.ts new file mode 100644 index 000000000..894283d88 --- /dev/null +++ b/apps/web/src/locales.ts @@ -0,0 +1,38 @@ +import type { LocaleMessagesMap } from '@vitnode/core/lib/i18n/types' + +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" } })`. That is exactly right under + * Node, which is how `apps/api` and `apps/docs` read them, and unusable here: a + * bundler resolves that specifier relative to whichever chunk the barrel ended up + * in, the JSON is not next to it, and the built server silently loads nothing - + * every string 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. + * + * The cost is a line here per language a package ships. `apps/docs` already + * declares its own overrides this way, so the shape is not new - but it is a + * copy, and worth removing: make the barrels statically analysable and this file + * becomes one call to `buildMessagesSources`. + */ +export const packageMessages: Record = { + [BLOG.pluginId]: { + en: async () => await import('@vitnode/blog/locales/en.json'), + }, + [CORE.pluginId]: { + en: async () => await import('@vitnode/core/locales/en.json'), + }, + [EXAMPLE.pluginId]: { + en: async () => await import('@vitnode/example/locales/en.json'), + }, +} diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index e7b1c4d2a..c4a4cdb7d 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -1,14 +1,47 @@ import { createRouter as createTanStackRouter } from '@tanstack/react-router' +import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query' +import { createVitNodeQueryClient } from '@vitnode/core/lib/query-client' + import { routeTree } from './routeTree.gen' +/** + * 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. + */ export function getRouter() { + const queryClient = createVitNodeQueryClient() + const router = createTanStackRouter({ - routeTree, - scrollRestoration: true, + context: { queryClient }, defaultPreload: 'intent', defaultPreloadStaleTime: 0, + routeTree, + scrollRestoration: true, }) + setupRouterSsrQueryIntegration({ queryClient, router }) + return router } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index f4b63b109..bdcdf9f05 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,52 +1,136 @@ -import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router' -import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools' +import type { QueryClient } from '@tanstack/react-query' + import { TanStackDevtools } from '@tanstack/react-devtools' +import { useSuspenseQuery } from '@tanstack/react-query' +import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools' +import { + createRootRouteWithContext, + HeadContent, + Outlet, + Scripts, +} from '@tanstack/react-router' +import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools' +import { ThemeScript } from '@vitnode/core/components/theme-script' +import { VitNodeProviders } from '@vitnode/core/views/layouts/providers' +import { VitNodeWebSocketProvider } from '@vitnode/core/ws/provider' +import { IntlProvider } from 'next-intl' + +import { shellIntlQueryOptions } from '#/lib/i18n' +import { vitNodeShellConfig } from '#/vitnode.shell.config' import appCss from '../styles.css?url' -export const Route = createRootRoute({ +const { debug, i18n, metadata, theme } = vitNodeShellConfig + +/** + * What every route in this app can count on having: the QueryClient the router + * owns. A loader reads it as `context.queryClient`. + */ +export interface RootRouterContext { + queryClient: QueryClient +} + +export const Route = createRootRouteWithContext()({ + component: RootComponent, head: () => ({ + links: [{ href: appCss, rel: 'stylesheet' }], meta: [ - { - charSet: 'utf-8', - }, - { - name: 'viewport', - content: 'width=device-width, initial-scale=1', - }, - { - title: 'TanStack Start Starter', - }, - ], - links: [ - { - rel: 'stylesheet', - href: appCss, - }, + { 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 `" - "` instead, through `formatPageTitle` - + // the same rule Next.js applies through `title.template`. + { title: metadata.title }, ], }), + /** + * Warm the shell's translations before anything renders. + * + * The one line that proves the Stage 2 pipeline: the loader reaches the + * QueryClient through the router context, and the component below reads the + * result out of the cache rather than fetching it again. + */ + loader: async ({ context }) => { + await context.queryClient.ensureQueryData(shellIntlQueryOptions()) + }, shellComponent: RootDocument, }) +/** + * The VitNode provider tree. + * + * Every provider here is shared with the Next.js app - `VitNodeProviders` is the + * same module `apps/docs` mounts - except the intl provider, which is this + * app's stand-in until Stage 3 brings the real locale runtime. `IntlProvider` is + * `use-intl`'s own provider, re-exported by `next-intl`; nothing in that entry + * imports `next/*`. The locale it is handed is the app's default one today, and + * the request's in Stage 3 - by then this reads it off the route rather than off + * the config. + * + * The QueryClient is deliberately absent: the router owns it and the SSR + * integration mounts its provider above this tree. + */ +function RootComponent() { + const { data: intl } = useSuspenseQuery(shellIntlQueryOptions()) + + return ( + + + + + + + + ) +} + +/** + * The document itself. + * + * `lang` comes from the app's configured default locale, not from the request: + * resolving a visitor's locale is Stage 3's job, and this is the smallest thing + * that is correct for a single-language install and honest about it for any + * other. When Stage 3 lands, this reads the matched route's locale instead. + * + * `ThemeScript` has to be in the head, and it has to be inline: it applies the + * stored theme to `` 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 }) { return ( - + + - + + {children} - , - }, - ]} - /> + + {import.meta.env.DEV ? ( + , + }, + { + name: 'TanStack Query', + render: , + }, + ]} + /> + ) : null} + diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 667864957..f4c21d04f 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -1,14 +1,118 @@ +import { useQuery } from '@tanstack/react-query' import { createFileRoute } from '@tanstack/react-router' +import { useLanguages } from '@vitnode/core/components/languages-provider' +import { ThemeSwitcher } from '@vitnode/core/components/switchers/themes/theme-switcher' +import { Button } from '@vitnode/core/components/ui/button' +import { TooltipWithContent } from '@vitnode/core/components/ui/tooltip' +import { formatPageTitle } from '@vitnode/core/lib/metadata' +import { useTranslations } from 'next-intl' +import { toast } from 'sonner' -export const Route = createFileRoute('/')({ component: Home }) +import { shellIntlQueryOptions } from '#/lib/i18n' +import { vitNodeShellConfig } from '#/vitnode.shell.config' + +/** + * Stage 2's verification page, and nothing more. + * + * No VitNode feature route is migrated yet - `/discover`, search, auth and the + * AdminCP all still live in the Next.js app. What this renders is the shell + * itself: if the switcher flips the palette without a flash on reload, the toast + * arrives styled, the tooltip opens, the language list is the configured one and + * the query below is already resolved on the first paint, then config, metadata, + * providers, theme, QueryClient and hydration are all wired up. + * + * It is a scaffold. Stage 3 replaces it with the real homepage. + */ +export const Route = createFileRoute('/')({ + component: Home, + // Per-route metadata, through the same title rule Next.js applies through + // `title.template`: "Stage 2 - VitNode". + head: () => ({ + meta: [{ title: formatPageTitle(vitNodeShellConfig.metadata, 'Stage 2') }], + }), +}) + +const Row = ({ + children, + label, +}: { + children: React.ReactNode + label: string +}) => ( +
+ + {label} + + + {children} +
+) function Home() { + const languages = useLanguages() + const t = useTranslations('core.global') + + /** + * The root route's loader already put this in the cache, and the SSR + * integration carried it into the page - so `isFetching` is false on the very + * first render, on the server and after hydration. A second QueryClient + * anywhere in the tree would show up right here as a refetch. + */ + const { data: intl, isFetching } = useQuery(shellIntlQueryOptions()) + return ( -
-

Welcome to TanStack Start

-

- Edit src/routes/index.tsx to get started. -

-
+
+
+

+ {vitNodeShellConfig.metadata.title} on TanStack Start +

+ +

+ The VitNode application shell, rendering outside Next.js. Stage 2 is + infrastructure only - no feature route has moved yet. +

+
+ +
+ + + + + + + + + + + + + + + + + {languages.map((language) => language.name).join(', ')} + + + + + {t('close')} + + + + + {isFetching ? 'fetching' : 'served from the cache'} + + +
+
) } diff --git a/apps/web/src/server/messages.server.ts b/apps/web/src/server/messages.server.ts new file mode 100644 index 000000000..134395e63 --- /dev/null +++ b/apps/web/src/server/messages.server.ts @@ -0,0 +1,66 @@ +import '@tanstack/react-start/server-only' +import type { MessagesSource } from '@vitnode/core/lib/i18n/types' + +import { CONFIG_PLUGIN as CORE } from '@vitnode/core/config' +import { loadMessages } from '@vitnode/core/lib/i18n/load-messages' +import { pickMessages } from '@vitnode/core/lib/i18n/pick-messages' + +import { packageMessages } from '#/locales' +import { vitNodeConfig } from '#/vitnode.config' + +export interface ShellIntl { + locale: string + messages: object +} + +/** + * Everything that contributes translations, in the order they merge. + * + * Core first, then each registered plugin - later sources win, so a plugin can + * reword a core string. That is `buildMessagesSources`' order, written out rather + * than called: that function reaches for core's own locale barrel, which is the + * one thing this app cannot load (see `src/locales.ts`). + * + * `scope` is part of `loadMessages`' cache key, and this app serves its API in + * the same process - so the web tree has to be marked as one, or whichever of the + * two loaded first would be served to both. + */ +const sources: MessagesSource[] = [ + { id: CORE.pluginId, messages: packageMessages[CORE.pluginId], scope: 'web' }, + ...vitNodeConfig.plugins.map(({ messages, pluginId }) => ({ + id: pluginId, + messages, + scope: 'web', + })), +] + +/** + * The translations the app shell needs, for the app's default locale. + * + * A stand-in, and a deliberately small one. VitNode's real locale resolution + * reads the `[locale]` route segment, negotiates against the visitor's + * `Accept-Language` and prefixes every URL accordingly - that is the i18n + * migration, and it belongs to Stage 3. Until then the shell renders in + * `i18n.defaultLocale`, which is what a single-language install gets anyway. + * + * **Stage 3 replaces the locale below with the request's.** Nothing else about + * this pipeline should need to change: `loadMessages` already merges every source + * for whichever locale it is handed, with the default locale underneath as a + * per-key fallback, and the query that calls this is already keyed by locale. + * + * Only `core.global` crosses to the browser. The merged tree holds every plugin's + * AdminCP copy, and a page that renders none of it should not ship it - the same + * rule `I18nProvider` applies in the Next.js app, through the same function. + * + * Server-side because the message files live inside each package's build output. + * The `server-only` import above turns "somebody imported this from a component" + * into a build error rather than a mystery in the browser bundle. + */ +export const loadShellIntl = async (): Promise => { + const { defaultLocale } = vitNodeConfig.i18n + const locale = defaultLocale + + const merged = await loadMessages({ defaultLocale, locale, sources }) + + return { locale, messages: pickMessages(merged, ['core.global']) } +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 50dba6e82..d84549e54 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1,17 +1,149 @@ - @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 - the same two lines `apps/docs` needs. + */ +@source "../node_modules/@vitnode/core/dist/src/components"; +@source "../node_modules/@vitnode/core/dist/src/views"; + +/* + * The design tokens every VitNode component reads. `.dark` is what + * `ThemeScript` and `ThemeProvider` put on ``, so the whole palette + * switches from that one class. + */ +:root:not(.dark) { + --background: oklch(0.97 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.51 0.16 262.61); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --warn: oklch(0.54 0.12 82.58); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.705 0.015 286.067); + --chart-1: oklch(0.871 0.006 286.286); + --chart-2: oklch(0.552 0.016 285.938); + --chart-3: oklch(0.442 0.017 285.786); + --chart-4: oklch(0.37 0.013 285.805); + --chart-5: oklch(0.274 0.006 286.033); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.21 0.006 285.885); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.705 0.015 286.067); +} -* { - box-sizing: border-box; +.dark { + --background: oklch(0.14 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.6 0.18 262.65); + --primary-foreground: oklch(0.98 0 0); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --warn: oklch(0.76 0.18 81.84); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.552 0.016 285.938); + --chart-1: oklch(0.871 0.006 286.286); + --chart-2: oklch(0.552 0.016 285.938); + --chart-3: oklch(0.442 0.017 285.786); + --chart-4: oklch(0.37 0.013 285.805); + --chart-5: oklch(0.274 0.006 286.033); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.552 0.016 285.938); } -html, -body, -#app { - min-height: 100%; +:root { + --radius: 0.625rem; } -body { - margin: 0; +@theme inline { + --font-heading: var(--font-sans); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-warn: var(--warn); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); } +@layer base { + * { + @apply border-border outline-ring/50; + } + + html, + body { + min-height: 100%; + } + + body { + @apply bg-background text-foreground antialiased; + } +} diff --git a/apps/web/src/tests/isolation.test.ts b/apps/web/src/tests/isolation.test.ts index 1527e2dcd..3ba749108 100644 --- a/apps/web/src/tests/isolation.test.ts +++ b/apps/web/src/tests/isolation.test.ts @@ -35,10 +35,18 @@ const filesUnder = (directory: string): string[] => { return entries } +/** + * Every specifier a file imports. + * + * Written to tolerate compiled output as well as source: a package's `dist` is + * minified onto one line, so `from"./x.js"` carries no whitespace and its + * statements are separated by `;` rather than by newlines. The `[^\w$.]` guard + * before `from` keeps a property access such as `Object.from` out. + */ const importsFrom = (path: string): string[] => [ ...readFileSync(path, 'utf8').matchAll( - /from\s+["']([^"']+)["']|import\s*\(\s*["']([^"']+)["']|(?:^|\n)\s*import\s+["']([^"']+)["']/g, + /(?:^|[^\w$.])from\s*["']([^"']+)["']|import\s*\(\s*["']([^"']+)["']|(?:^|[\n;}])\s*import\s*["']([^"']+)["']/g, ), ] .map((match) => match[1] ?? match[2] ?? match[3]) @@ -198,3 +206,146 @@ describe('the TanStack Start application stays Next-free', () => { expect(manifest.dependencies?.next).toBeUndefined() }) }) + +/** + * The graph the bundler walks, rather than the files this app happens to own. + * + * The tests above scan `apps/web/src` for a forbidden `import`, which catches a + * line written here. It does not catch the more likely mistake: importing + * something from `@vitnode/core` or a plugin whose *own* imports reach Next.js. + * That is not hypothetical - a plugin's frontend entry + * (`blogPlugin()`) registers AdminCP screens, which reach `next/dynamic` and + * `next-intl/navigation`, and `apps/web/src/vitnode.config.ts` exists in the + * shape it does because of it. + * + * So this walks the real thing, transitively: this app's source, then every + * package module it reaches, as the *built* files - which is what a bundler and + * the Nitro server actually load, and which have their type-only imports already + * erased. + */ +describe('the whole graph this app imports stays Next-free', () => { + const DIST_OF: Record = { + '@vitnode/blog': join(repoRoot, 'plugins/blog/dist/src'), + '@vitnode/core': join(repoRoot, 'packages/vitnode/dist/src'), + '@vitnode/example': join(repoRoot, 'plugins/example/dist/src'), + } + const appSrc = join(repoRoot, 'apps/web/src') + const CANDIDATES = [ + '', + '.ts', + '.tsx', + '.js', + '/index.ts', + '/index.tsx', + '/index.js', + ] + + const resolveFile = (base: string): null | string => { + for (const suffix of CANDIDATES) { + const path = `${base}${suffix}` + if (existsSync(path) && statSync(path).isFile()) return path + } + + return null + } + + const resolveSpecifier = ( + specifier: string, + importer: string, + ): null | string => { + if (specifier.startsWith('.')) { + return resolveFile(resolve(dirname(importer), specifier)) + } + + if (specifier.startsWith('#/')) { + return resolveFile(join(appSrc, specifier.slice(2))) + } + + const pkg = Object.keys(DIST_OF).find( + (name) => specifier === name || specifier.startsWith(`${name}/`), + ) + if (!pkg) return null + + return resolveFile(join(DIST_OF[pkg], specifier.slice(pkg.length + 1))) + } + + /** Every package specifier reachable from `entries`, with how it got there. */ + const reachableExternals = (entries: string[]) => { + const visited = new Set() + const externals = new Map() + + const walk = (path: string, chain: string[]) => { + if (visited.has(path)) return + visited.add(path) + + for (const specifier of importsFrom(path)) { + const target = resolveSpecifier(specifier, path) + + if (target) { + walk(target, [...chain, relative(repoRoot, target)]) + } else if (!specifier.startsWith('.') && !externals.has(specifier)) { + externals.set(specifier, chain) + } + } + } + + for (const entry of entries) { + const path = resolveFile(join(repoRoot, entry)) + expect(path, `${entry} exists`).not.toBeNull() + if (path) walk(path, [entry]) + } + + return { externals, visited } + } + + const offenders = (entries: string[], forbidden: string[]): string[] => + [...reachableExternals(entries).externals] + .filter(([specifier]) => + forbidden.some((entry) => matches(specifier, entry)), + ) + .map(([specifier, chain]) => `${specifier} via ${chain.join(' -> ')}`) + + /** Everything the app reaches, from every entry point it has. */ + const ENTRIES = [ + 'apps/web/src/router.tsx', + 'apps/web/src/routes/__root.tsx', + 'apps/web/src/routes/index.tsx', + 'apps/web/src/server/messages.server.ts', + 'apps/web/src/vitnode.config.ts', + 'apps/web/src/vitnode.shell.config.ts', + ] + + it('needs the packages to be built to mean anything', () => { + // A missing `dist` makes every assertion below vacuous: nothing resolves, so + // nothing is walked. `turbo` builds the packages before this app's tests. + expect( + existsSync(join(DIST_OF['@vitnode/core'], 'views/layouts/providers.js')), + 'run `turbo build:plugins` first', + ).toBe(true) + }) + + it('walks into the packages rather than stopping at the app', () => { + const { visited } = reachableExternals(ENTRIES) + + expect( + [...visited].filter((path) => path.includes('packages/vitnode/dist')) + .length, + ).toBeGreaterThan(5) + }) + + it('finds Next.js in a plugin frontend entry, which is why one is not imported', () => { + // The control, and the reason `vitnode.config.ts` registers plugins by id + // and messages instead of calling `blogPlugin()`. + expect( + offenders(['plugins/blog/dist/src/config.js'], NEXT_ONLY), + ).not.toEqual([]) + }) + + it('reaches no next/* and no server-only', () => { + expect(offenders(ENTRIES, NEXT_ONLY)).toEqual([]) + }) + + it("reaches none of next-intl's Next-only entries", () => { + expect(offenders(ENTRIES, NEXT_INTL_RUNTIME)).toEqual([]) + }) +}) diff --git a/apps/web/src/tests/router-query.test.ts b/apps/web/src/tests/router-query.test.ts new file mode 100644 index 000000000..b394c0330 --- /dev/null +++ b/apps/web/src/tests/router-query.test.ts @@ -0,0 +1,119 @@ +import { readFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +import { getRouter } from '#/router' + +const here = dirname(fileURLToPath(import.meta.url)) +const appSrc = resolve(here, '..') + +/** + * The Stage 2 Query architecture: + * + * request/router -> QueryClient -> router context -> loader + * -> context.queryClient.ensureQueryData(...) + * + * Everything here is about *where the client lives*, because that is the part + * that fails silently. A shared client serves one visitor's data to the next; a + * second client makes a loader's cache invisible to the component reading it, + * which looks like nothing worse than an extra request. + */ +describe('the router owns the QueryClient', () => { + it('exposes it through the router context', () => { + const router = getRouter() + + expect(router.options.context.queryClient).toBeDefined() + expect(typeof router.options.context.queryClient.ensureQueryData).toBe( + 'function', + ) + }) + + it('lets a route loader read and warm it', async () => { + // Exactly what a loader does - `({ context }) => context.queryClient...` - + // with a query of the test's own so nothing depends on a real endpoint. + const { context } = getRouter().options + + const data = await context.queryClient.ensureQueryData({ + queryFn: async () => await Promise.resolve('loaded'), + queryKey: ['test', 'loader'], + }) + + expect(data).toBe('loaded') + expect(context.queryClient.getQueryData(['test', 'loader'])).toBe('loaded') + }) + + it('carries the VitNode query defaults', () => { + const { queries } = + getRouter().options.context.queryClient.getDefaultOptions() + + expect(queries?.refetchOnMount).toBe(false) + expect(queries?.refetchOnWindowFocus).toBe(false) + }) +}) + +/** + * `getRouter` is called once per server request. A client created anywhere else - + * at module scope, in a provider - would be shared by every visitor rendering at + * once. + */ +describe('a server-side QueryClient is never shared across requests', () => { + it('gives each router its own client', () => { + const first = getRouter().options.context.queryClient + const second = getRouter().options.context.queryClient + + expect(first).not.toBe(second) + }) + + it('does not leak cached data from one request into the next', () => { + const first = getRouter().options.context.queryClient + first.setQueryData(['session'], { user: 'someone' }) + + const second = getRouter().options.context.queryClient + + expect(second.getQueryData(['session'])).toBeUndefined() + }) +}) + +/** + * The SSR integration, which is what carries a loader's cache into the HTML and + * back out of it on the client. + */ +describe('the Query SSR integration is installed', () => { + it('wraps the app in exactly one QueryClientProvider', () => { + // `setupRouterSsrQueryIntegration` installs it as the router's `Wrap`, so + // the provider exists without any component mounting one. Which is why + // nothing else may: two providers is two clients. + expect(typeof getRouter().options.Wrap).toBe('function') + }) + + it('registers the dehydrate hook that streams the cache into the page', () => { + // This test process is the server, so the integration takes its server + // branch: the router's `dehydrate` now carries the query stream. + expect(typeof getRouter().options.dehydrate).toBe('function') + }) + + it('is set up once, next to the client it is given', () => { + const source = readFileSync(join(appSrc, 'router.tsx'), 'utf8') + + expect(source.match(/setupRouterSsrQueryIntegration\(/g)?.length).toBe(1) + expect(source.match(/createVitNodeQueryClient\(/g)?.length).toBe(1) + }) +}) + +/** + * Hydration creates a second client only if this app asks for one, and the way + * it would ask is a `QueryClientProvider` or a `new QueryClient` in a component. + * Nothing in the app may hold either. + */ +describe('nothing but the router creates a query client', () => { + const appFiles = ['routes/__root.tsx', 'routes/index.tsx', 'lib/i18n.ts'] + + it.each(appFiles)('%s mounts no QueryClientProvider', (file) => { + const source = readFileSync(join(appSrc, file), 'utf8') + + expect(source).not.toContain('QueryClientProvider') + expect(source).not.toContain('new QueryClient') + expect(source).not.toContain('createVitNodeQueryClient') + }) +}) diff --git a/apps/web/src/tests/shell-config.test.ts b/apps/web/src/tests/shell-config.test.ts new file mode 100644 index 000000000..fefc60c54 --- /dev/null +++ b/apps/web/src/tests/shell-config.test.ts @@ -0,0 +1,178 @@ +import { formatPageTitle, titleTemplate } from '@vitnode/core/lib/metadata' +import { describe, expect, it } from 'vitest' + +import { Route as RootRoute } from '#/routes/__root' +import { Route as IndexRoute } from '#/routes/index' +import { vitNodeShellConfig } from '#/vitnode.shell.config' + +type HeadTag = Record + +/** + * A route's `head()` output, as plain tag objects. + * + * The context argument is `never` because nothing under test reads it: these + * routes derive their head from the app's config, which is the property being + * pinned. A route that starts reading `loaderData` will fail here loudly rather + * than quietly asserting nothing. + */ +const headTags = async (route: { + options: { head?: (ctx: never) => unknown } +}) => { + const head = ((await route.options.head?.(undefined as never)) ?? {}) as { + links?: HeadTag[] + meta?: HeadTag[] + } + + return { links: head.links ?? [], meta: head.meta ?? [] } +} + +/** + * The root document's metadata comes from the app's VitNode config, not from the + * starter it replaced - which hardcoded `"TanStack Start Starter"` and + * `lang="en"`. + */ +describe('root metadata', () => { + it('takes its title from the VitNode config', async () => { + const { meta } = await headTags(RootRoute) + + expect(meta.map((tag) => tag.title)).toContain( + vitNodeShellConfig.metadata.title, + ) + }) + + it('carries no starter metadata', async () => { + const { meta } = await headTags(RootRoute) + + expect(JSON.stringify(meta)).not.toContain('Starter') + }) + + it('declares the charset and the viewport', async () => { + const { meta } = await headTags(RootRoute) + + expect(meta).toEqual( + expect.arrayContaining([ + { charSet: 'utf-8' }, + { content: 'width=device-width, initial-scale=1', name: 'viewport' }, + ]), + ) + }) + + it('links the app stylesheet', async () => { + const { links } = await headTags(RootRoute) + + expect(links.some((link) => link.rel === 'stylesheet')).toBe(true) + }) +}) + +/** + * The short-title rule, which is core's and is shared with Next.js: there it is + * handed to `title.template`, here it is applied directly. Both produce the same + * string, which is the point of `formatPageTitle` existing. + */ +describe('per-route titles follow the config short title', () => { + it('renders " - "', () => { + const { metadata } = vitNodeShellConfig + + expect(formatPageTitle(metadata, 'Stage 2')).toBe( + `Stage 2 - ${metadata.shortTitle ?? metadata.title}`, + ) + expect(titleTemplate(metadata)).toBe( + `%s - ${metadata.shortTitle ?? metadata.title}`, + ) + }) + + it('is what the home route sets', async () => { + const { meta } = await headTags(IndexRoute) + + expect(meta.map((tag) => tag.title)).toContain( + formatPageTitle(vitNodeShellConfig.metadata, 'Stage 2'), + ) + }) +}) + +/** + * The slice of the config the browser is allowed to see. + * + * `plugins` is the field that must not be here: a plugin registration carries + * message loaders today and its AdminCP components tomorrow, and this module is + * imported by the document shell, which renders in the browser too. + */ +describe('the shell config', () => { + it('holds what the shell renders from', () => { + expect(vitNodeShellConfig.metadata.title).toBeTruthy() + expect(vitNodeShellConfig.i18n.defaultLocale).toBeTruthy() + expect(vitNodeShellConfig.i18n.locales.length).toBeGreaterThan(0) + expect(vitNodeShellConfig.theme).toBeDefined() + expect(vitNodeShellConfig.debug).toBe(false) + }) + + it('holds no plugin registry', () => { + expect(vitNodeShellConfig).not.toHaveProperty('plugins') + }) + + it('is serializable, because it crosses to the browser', () => { + expect(() => JSON.stringify(vitNodeShellConfig)).not.toThrow() + expect(JSON.parse(JSON.stringify(vitNodeShellConfig))).toEqual( + vitNodeShellConfig, + ) + }) +}) + +/** + * The full config, which the server reads. It is built by core's `buildConfig` + * from the same slice, so the two cannot disagree about the app's name. + */ +describe('the full config', () => { + it('extends the shell config with the plugin registry', async () => { + const { vitNodeConfig } = await import('#/vitnode.config') + + expect(vitNodeConfig.metadata).toEqual(vitNodeShellConfig.metadata) + expect(vitNodeConfig.i18n.defaultLocale).toBe( + vitNodeShellConfig.i18n.defaultLocale, + ) + expect(vitNodeConfig.plugins.map((plugin) => plugin.pluginId)).toEqual([ + '@vitnode/blog', + '@vitnode/example', + ]) + }) + + it('gives every registered plugin its translations', async () => { + const { vitNodeConfig } = await import('#/vitnode.config') + + for (const plugin of vitNodeConfig.plugins) { + expect( + Object.keys(plugin.messages ?? {}), + `${plugin.pluginId} ships messages`, + ).not.toEqual([]) + } + }) +}) + +/** + * The shell's translations, loaded the way the server loads them. + * + * This is the assertion that catches the failure mode of loading messages at + * all: every loader is a dynamic import of a file inside another package's build + * output, and when one cannot be resolved the tree comes back empty and every + * string on the page silently renders as its own key. + */ +describe('shell translations', () => { + it('loads core.global for the default locale', async () => { + const { loadShellIntl } = await import('#/server/messages.server') + + const { locale, messages } = await loadShellIntl() + + expect(locale).toBe(vitNodeShellConfig.i18n.defaultLocale) + expect(messages).toHaveProperty('core.global.close') + }) + + it('ships only core.global to the browser', async () => { + const { loadShellIntl } = await import('#/server/messages.server') + + const { messages } = await loadShellIntl() + const core = (messages as { core: Record }).core + + expect(Object.keys(messages)).toEqual(['core']) + expect(Object.keys(core)).toEqual(['global']) + }) +}) diff --git a/apps/web/src/vitnode.config.ts b/apps/web/src/vitnode.config.ts new file mode 100644 index 000000000..9eb397d9b --- /dev/null +++ b/apps/web/src/vitnode.config.ts @@ -0,0 +1,45 @@ +import { CONFIG_PLUGIN as BLOG } from '@vitnode/blog/const' +import { buildPlugin } from '@vitnode/core/lib/plugin' +import { buildConfig } from '@vitnode/core/vitnode.config' +import { CONFIG_PLUGIN as EXAMPLE } from '@vitnode/example/const' + +import { packageMessages } from './locales' +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. Those register AdminCP content + * types, and to do that they import their admin screens - a Tiptap editor field, + * a form layout, a table cell - which reach core's form stack and from there + * `next/dynamic` and `next-intl/navigation`. This app has no Next.js and no + * AdminCP, so registering them would mean importing an admin panel that cannot + * render in order to get at a message file. + * + * **Stage 4 replaces the two `buildPlugin` calls with `blogPlugin()` and + * `examplePlugin()`**, once plugin routing and the AdminCP move over and the form + * stack no longer needs Next. Nothing else has to change: everything downstream + * reads `pluginId` and `messages` off whatever is in this list, and it is the + * same list either way. + * + * 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, + plugins: [ + buildPlugin({ + messages: packageMessages[BLOG.pluginId], + pluginId: BLOG.pluginId, + }), + buildPlugin({ + messages: packageMessages[EXAMPLE.pluginId], + pluginId: EXAMPLE.pluginId, + }), + ], +}) diff --git a/apps/web/src/vitnode.shell.config.ts b/apps/web/src/vitnode.shell.config.ts new file mode 100644 index 000000000..91a4b72c9 --- /dev/null +++ b/apps/web/src/vitnode.shell.config.ts @@ -0,0 +1,35 @@ +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 diff --git a/packages/vitnode/src/components/i18n-provider.tsx b/packages/vitnode/src/components/i18n-provider.tsx index e8d31db66..fd6a167ee 100644 --- a/packages/vitnode/src/components/i18n-provider.tsx +++ b/packages/vitnode/src/components/i18n-provider.tsx @@ -4,41 +4,9 @@ import { NextIntlClientProvider } from "next-intl"; import { getLocale, getMessages } from "next-intl/server"; import "server-only"; -/** - * 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. - */ -export const pickMessages = (obj: object, paths: readonly string[]) => { - const result = {}; - for (const path of paths) { - const keys = path.split("."); - let src: object | undefined = obj; - let dest = result; - for (let i = 0; i < keys.length; i++) { - const key = keys[i]; - if (src && Object.hasOwn(src, key)) { - if (i === keys.length - 1) { - dest[key] = src[key]; - } else { - dest[key] ??= {}; - dest = dest[key]; - src = src[key]; - } - } else { - break; - } - } - } +import { pickMessages } from "@/lib/i18n/pick-messages"; - return result; -}; +export { pickMessages }; export async function I18nProvider< NestedKey extends NamespaceKeys> = never, diff --git a/packages/vitnode/src/components/progress-bar.test-d.ts b/packages/vitnode/src/components/progress-bar.test-d.ts new file mode 100644 index 000000000..4298cdc3c --- /dev/null +++ b/packages/vitnode/src/components/progress-bar.test-d.ts @@ -0,0 +1,23 @@ +import type { ProgressProvider } from "@bprogress/next/app"; +import type React from "react"; + +import { describe, expectTypeOf, it } from "vitest"; + +import type { ProgressBarConfig } from "./progress-bar"; + +/** + * The one thing the framework-neutral progress bar config has to keep doing: + * reach `@bprogress/next` unchanged. + * + * `views/layouts/provider.tsx` spreads the config straight into + * `ProgressProvider`, so a field that is renamed, retyped or widened out of + * shape breaks the Next.js app. Asserting the assignment here fails + * `pnpm test:types` instead, which is where a type mistake belongs. + */ +describe("ProgressBarConfig", () => { + it("is assignable to the Next.js progress provider's props", () => { + expectTypeOf().toExtend< + React.ComponentProps + >(); + }); +}); diff --git a/packages/vitnode/src/components/progress-bar.ts b/packages/vitnode/src/components/progress-bar.ts new file mode 100644 index 000000000..ffefe57eb --- /dev/null +++ b/packages/vitnode/src/components/progress-bar.ts @@ -0,0 +1,60 @@ +/** + * BProgress' own options, minus the ones a config file cannot carry. + * + * Left out on purpose: `parent` (an `HTMLElement`), `barSelector`, + * `indeterminateSelector`, `spinnerSelector` and `positionUsing`. The first is + * a DOM node, which never survives the server-to-client hop a Next.js layout + * hands this config over; the rest name internals of one implementation, which + * is the opposite of what belongs in an app's configuration. + */ +export interface ProgressBarOptions { + direction?: "ltr" | "rtl"; + easing?: string; + indeterminate?: boolean; + maximum?: number; + minimum?: number; + showSpinner?: boolean; + speed?: number; + template?: null | string; + trickle?: boolean; + trickleSpeed?: number; +} + +/** + * How the navigation progress bar looks and behaves. + * + * This used to be typed as `React.ComponentProps` from + * `@bprogress/next`, which made the whole of `VitNodeConfig` unreadable outside + * a Next.js app: every consumer of the config type - including one that never + * renders a progress bar - had to resolve a package that only exists there. + * + * These are the fields an install actually sets, so the config now describes + * the progress bar instead of naming somebody's implementation of it. The + * Next.js implementation still receives it whole: `progress-bar.test-d.ts` + * holds this type to being assignable to `ProgressProvider`'s props, so a field + * that drifts out of shape fails `pnpm test:types` rather than a build. + */ +export interface ProgressBarConfig { + /** CSS colour of the bar. Defaults to `var(--primary)`. */ + color?: string; + /** Wait this many ms before showing the bar, so fast navigations show none. */ + delay?: number; + /** Skip the bar when the target URL is the current one. Defaults to `true`. */ + disableSameURL?: boolean; + /** Ship no CSS for the bar, to style it entirely from the app's own sheet. */ + disableStyle?: boolean; + /** Height of the bar, e.g. `"4px"`. */ + height?: string; + /** `nonce` for the injected `