From 7d45285ba7d51e7901f4ae273db3ef05f7a09f54 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Thu, 27 Aug 2026 14:15:24 +0200 Subject: [PATCH 1/2] feat: Add i18n for tanstack start --- apps/web/package.json | 1 + apps/web/src/components/language-switcher.tsx | 67 ++++ apps/web/src/i18n.ts | 11 +- apps/web/src/lib/i18n.ts | 36 -- apps/web/src/lib/i18n/client.ts | 217 ++++++++++++ apps/web/src/lib/i18n/query.ts | 99 ++++++ apps/web/src/lib/i18n/shared.ts | 31 ++ apps/web/src/locales.ts | 38 --- apps/web/src/locales/@vitnode/core/pl.json | 11 + apps/web/src/locales/app.ts | 22 ++ apps/web/src/locales/packages.ts | 53 +++ apps/web/src/routeTree.gen.ts | 3 +- apps/web/src/router.tsx | 17 + apps/web/src/routes/__root.tsx | 115 +++++-- apps/web/src/routes/index.tsx | 105 ++++-- apps/web/src/server/locale.server.ts | 92 +++++ apps/web/src/server/messages.server.ts | 66 ++-- apps/web/src/start.ts | 56 ++++ apps/web/src/tests/intl-provider.test.ts | 45 +++ apps/web/src/tests/intl-query.test.ts | 113 +++++++ apps/web/src/tests/isolation.test.ts | 112 ++++++- apps/web/src/tests/locale-rewrite.test.ts | 241 +++++++++++++ apps/web/src/tests/locale-routing.test.ts | 146 ++++++++ apps/web/src/tests/locale-ssr.test.ts | 193 +++++++++++ apps/web/src/tests/messages.test.ts | 122 +++++++ apps/web/src/tests/router-query.test.ts | 8 +- apps/web/src/tests/shell-config.test.ts | 35 +- .../src/tests/start-runtime/ssr-handler.ts | 31 ++ .../src/tests/start-runtime/start-entry.ts | 10 +- apps/web/src/vitnode.config.ts | 9 +- apps/web/vitest.config.ts | 20 +- .../src/lib/i18n/locale-cookie.test.ts | 59 ++++ .../vitnode/src/lib/i18n/locale-cookie.ts | 85 +++++ .../src/lib/i18n/locale-routing.test.ts | 317 ++++++++++++++++++ .../vitnode/src/lib/i18n/locale-routing.ts | 292 ++++++++++++++++ packages/vitnode/src/lib/i18n/sources.ts | 17 + pnpm-lock.yaml | 3 + 37 files changed, 2681 insertions(+), 217 deletions(-) create mode 100644 apps/web/src/components/language-switcher.tsx delete mode 100644 apps/web/src/lib/i18n.ts create mode 100644 apps/web/src/lib/i18n/client.ts create mode 100644 apps/web/src/lib/i18n/query.ts create mode 100644 apps/web/src/lib/i18n/shared.ts delete mode 100644 apps/web/src/locales.ts create mode 100644 apps/web/src/locales/@vitnode/core/pl.json create mode 100644 apps/web/src/locales/app.ts create mode 100644 apps/web/src/locales/packages.ts create mode 100644 apps/web/src/server/locale.server.ts create mode 100644 apps/web/src/start.ts create mode 100644 apps/web/src/tests/intl-provider.test.ts create mode 100644 apps/web/src/tests/intl-query.test.ts create mode 100644 apps/web/src/tests/locale-rewrite.test.ts create mode 100644 apps/web/src/tests/locale-routing.test.ts create mode 100644 apps/web/src/tests/locale-ssr.test.ts create mode 100644 apps/web/src/tests/messages.test.ts create mode 100644 apps/web/src/tests/start-runtime/ssr-handler.ts create mode 100644 packages/vitnode/src/lib/i18n/locale-cookie.test.ts create mode 100644 packages/vitnode/src/lib/i18n/locale-cookie.ts create mode 100644 packages/vitnode/src/lib/i18n/locale-routing.test.ts create mode 100644 packages/vitnode/src/lib/i18n/locale-routing.ts diff --git a/apps/web/package.json b/apps/web/package.json index 158b6c3c5..437a29c31 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -45,6 +45,7 @@ "shadcn": "^4.14.0", "sonner": "^2.0.7", "tailwindcss": "^4.1.18", + "use-intl": "^4.13.7", "zod": "^4.4.3" }, "devDependencies": { diff --git a/apps/web/src/components/language-switcher.tsx b/apps/web/src/components/language-switcher.tsx new file mode 100644 index 000000000..675118b42 --- /dev/null +++ b/apps/web/src/components/language-switcher.tsx @@ -0,0 +1,67 @@ +import { useLanguages } from '@vitnode/core/components/languages-provider' +import { Button } from '@vitnode/core/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@vitnode/core/components/ui/dropdown-menu' +import { CheckIcon, LanguagesIcon } from 'lucide-react' +import { useTranslations } from 'use-intl' + +import type { Locale } from '#/lib/i18n/shared' + +import { useLocale, useSwitchLocale } from '#/lib/i18n/client' + +/** + * VitNode's language switcher, for TanStack Router. + * + * The same control as `@vitnode/core`'s - the same dropdown, the same icons, the + * same `core.global.language_switcher` label - over a different navigation + * layer. Core's version is built on `next-intl/navigation`'s `useRouter`, which + * is Next.js all the way down; this one is built on the router that is actually + * mounted here. Sharing the markup and forking the two lines that navigate is + * cheaper than a navigation abstraction that has to satisfy both. + * + * What it preserves is the whole point: the route, its params, its search + * string and its hash. Only the locale prefix changes. + */ +export const LanguageSwitcher = () => { + const languages = useLanguages() + const locale = useLocale() + const switchLocale = useSwitchLocale() + const t = useTranslations('core.global') + + return ( + + + } + > + + + + + {languages.map((language) => ( + { + switchLocale(language.code as Locale) + }} + > + {language.name} + + {language.code === locale && ( + + )} + + ))} + + + ) +} diff --git a/apps/web/src/i18n.ts b/apps/web/src/i18n.ts index 3c14e3749..eb65c5a97 100644 --- a/apps/web/src/i18n.ts +++ b/apps/web/src/i18n.ts @@ -13,7 +13,7 @@ import type { VitNodeI18nConfig } from '@vitnode/core/lib/i18n/types' * reword something without forking the package that owns it. */ export const i18n = { - defaultLocale: 'en', + 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 @@ -21,13 +21,18 @@ export const i18n = { * 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', + code: 'en' as const, name: 'English', }, { - code: 'pl', + code: 'pl' as const, name: 'Polski', }, ], diff --git a/apps/web/src/lib/i18n.ts b/apps/web/src/lib/i18n.ts deleted file mode 100644 index d80016006..000000000 --- a/apps/web/src/lib/i18n.ts +++ /dev/null @@ -1,36 +0,0 @@ -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/lib/i18n/client.ts b/apps/web/src/lib/i18n/client.ts new file mode 100644 index 000000000..4abe2455a --- /dev/null +++ b/apps/web/src/lib/i18n/client.ts @@ -0,0 +1,217 @@ +import type { QueryClient } from '@tanstack/react-query' +import type { AnyRouter, LocationRewrite } from '@tanstack/react-router' + +import { useRouter, useRouterState } from '@tanstack/react-router' +import { createIsomorphicFn } from '@tanstack/react-start' +import { getRequestHeader } from '@tanstack/react-start/server' +import { + readLocaleCookie, + serializeLocaleCookie, +} from '@vitnode/core/lib/i18n/locale-cookie' + +import type { Locale } from './shared' + +import { intlQueryOptions } from './query' +import { localeRouting } from './shared' + +/** + * 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: the Start compiler keeps only the branch for the bundle + * it is building, so the browser never sees `getRequestHeader` and the server + * never touches `document`. Un-compiled - in tests, under plain Node - the stub + * falls back to the server branch, which is the right default for a test run. + */ +const readCookieLocale = createIsomorphicFn() + .server(() => { + try { + return readLocaleCookie(getRequestHeader('cookie')) + } catch { + // `getRequestHeader` throws outside a request scope, which is where a + // prerender pass and a test both build links from. No request means no + // stored preference, which is exactly what the default locale is for. + return undefined + } + }) + // The locale cookie is deliberately not `HttpOnly`, so this read works: the + // switcher writes it in the browser and the server reads the same one back. + .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, ``, 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 cast is safe by construction: the answer is either a code this app was + * configured with or the default, never anything from the URL. + */ +export const resolveLocale = (publicPathname: string): Locale => + localeRouting.resolveLocale(publicPathname, { + // A getter, so a public path - which is every request this app serves + // today - never reads a cookie at all. + get cookieLocale() { + return readCookieLocale() + }, + }) as Locale + +/** + * 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 + +/** + * The router's half of locale routing: one route tree, two public URL shapes. + * + * browser /pl/discover --input--> /discover route tree + * --output-> /pl/discover rendered href + * + * `input` is why no route file ever mentions a locale. It is also why an unknown + * first segment still 404s: only a prefix this app actually writes gets + * stripped, so `/xx/discover` reaches the route tree intact and matches nothing. + * + * `output` reads the locale from the router's own current location rather than + * from `window` or a module variable. That is the same value on the server (a + * memory history seeded with the request) as in the browser (the address bar), + * which is what keeps the `href` React renders during SSR identical to the one + * it renders after hydration. + */ +export const createLocaleRewrite = ( + getRouter: () => AnyRouter | undefined, +): LocationRewrite => ({ + input: ({ url }) => localeRouting.deLocalizeUrl(url), + output: ({ url }) => { + const location = getRouter()?.latestLocation + // Before the router has parsed a location there is nothing to read a locale + // from - and no link has been built yet either. + if (!location) return url + + return localeRouting.localizeUrl( + url, + resolveLocale(publicPathnameOf(location)), + ) + }, +}) + +/** + * The language the page is currently in, as reactive state. + * + * Subscribed to the router's location rather than read off `window`, so a + * language switch re-renders everything downstream of it - the provider, the + * message query, `` - with no reload and no second source of truth. + */ +export const useLocale = (): Locale => + useRouterState({ + select: (state) => resolveLocale(publicPathnameOf(state.location)), + }) + +/** + * Puts a language's messages in the cache before anything renders in it. + * + * Failure is deliberately not fatal: the switch still happens, and the + * 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: Locale) => { + const { queryClient } = router.options.context as { + queryClient?: QueryClient + } + + try { + await queryClient?.ensureQueryData(intlQueryOptions({ locale })) + } catch { + /* empty */ + } +} + +/** + * 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: Locale, +): Promise => { + if (!localeRouting.isSupportedLocale(locale)) return + + // Fetched before the URL moves, not after. The location store updates the + // moment history does, so the provider re-renders under the new locale - and + // therefore the new query key - while the root loader is still resolving it. + // That is a suspend, and a suspend caused by a store update cannot be + // deferred: the page would blank for a round trip. Warmed first, the switch + // is a re-render with the messages already in hand. + await warmMessages(router, locale) + + const current = new URL(router.latestLocation.publicHref, RELATIVE_BASE) + const next = localeRouting.localizeUrl(current, locale) + + if (next.href !== current.href) { + router.history.push(`${next.pathname}${next.search}${next.hash}`) + } + + await router.invalidate() +} + +/** {@link switchLocaleOn}, bound to the mounted router, plus the cookie write. */ +export const useSwitchLocale = () => { + const router = useRouter() + + return (locale: Locale) => { + // Remembered for the routes whose URL carries no locale - `/admin` - and for + // the next visit. `Secure` only over HTTPS: set on plain `http://localhost` + // the browser drops it without a word, and the choice never sticks. + if (localeRouting.isSupportedLocale(locale)) { + globalThis.document.cookie = serializeLocaleCookie(locale, { + secure: globalThis.location.protocol === 'https:', + }) + } + + void switchLocaleOn(router, locale) + } +} diff --git a/apps/web/src/lib/i18n/query.ts b/apps/web/src/lib/i18n/query.ts new file mode 100644 index 000000000..6ffeb8ed0 --- /dev/null +++ b/apps/web/src/lib/i18n/query.ts @@ -0,0 +1,99 @@ +import { queryOptions } from '@tanstack/react-query' +import { createServerFn } from '@tanstack/react-start' + +import { loadIntlMessages } from '#/server/messages.server' + +import type { Locale } from './shared' + +import { localeRouting } from './shared' + +/** The strings every page needs, whatever else it renders. */ +export const GLOBAL_NAMESPACE = 'core.global' + +/** + * 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. + */ +const normalizeNamespaces = (namespaces: readonly string[]): string[] => + [...new Set(namespaces)] + .filter((namespace) => typeof namespace === 'string') + .sort((a, b) => a.localeCompare(b)) + +/** + * More than any page has ever needed, and few enough that a caller asking for + * thousands is refused rather than served. + */ +const MAX_NAMESPACES = 16 + +/** + * What the server function will accept. + * + * A built server function is a public endpoint, so neither argument is trusted. + * An unknown locale degrades to the default rather than failing - a stale link + * to a language that has since been removed should render, not 500 - but an + * unbounded namespace list is refused, because nothing legitimate sends one. + */ +const validateIntlInput = ({ + locale, + namespaces, +}: { + locale: string + namespaces: readonly string[] +}) => { + const requested = normalizeNamespaces(namespaces) + + if (requested.length > MAX_NAMESPACES) { + throw new Error(`At most ${MAX_NAMESPACES} namespaces may be requested.`) + } + + return { + locale: localeRouting.isSupportedLocale(locale) + ? locale + : localeRouting.defaultLocale, + namespaces: requested, + } +} + +/** + * One language's messages for one set of namespaces, 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 getIntlMessages = createServerFn() + .validator(validateIntlInput) + .handler(async ({ data }) => await loadIntlMessages(data)) + +/** + * The same request, as a query - and the only way the app should ask for it. + * + * The locale is a required argument and part of the key. That is the whole + * point: two languages coexist in one QueryClient, a language switch changes the + * key rather than the value under it, and nothing ever resolves "the current + * locale" from inside a query function, where it would be whatever the last + * render happened to leave behind. + * + * `staleTime: Infinity` - a locale's messages change when the app is redeployed. + */ +export const intlQueryOptions = ({ + locale, + namespaces = [GLOBAL_NAMESPACE], +}: { + locale: Locale + namespaces?: readonly string[] +}) => { + const normalized = normalizeNamespaces(namespaces) + + return queryOptions({ + queryFn: async () => + await getIntlMessages({ data: { locale, namespaces: normalized } }), + queryKey: ['vitnode', 'intl', locale, ...normalized] as const, + staleTime: Infinity, + }) +} diff --git a/apps/web/src/lib/i18n/shared.ts b/apps/web/src/lib/i18n/shared.ts new file mode 100644 index 000000000..e9f9351b6 --- /dev/null +++ b/apps/web/src/lib/i18n/shared.ts @@ -0,0 +1,31 @@ +import type { LocaleRouting } from '@vitnode/core/lib/i18n/locale-routing' + +import { localeRoutingFromConfig } from '@vitnode/core/lib/i18n/locale-routing' + +import { i18n } from '#/i18n' + +/** + * A language this app serves, as a type. `"en" | "pl"`, derived from the config + * rather than written twice. + */ +export type Locale = (typeof i18n.locales)[number]['code'] + +/** + * How this app's URLs carry a language, and the only place that decides it. + * + * Pure string transforms, built from `src/i18n.ts` - no `Request`, no cookies, + * no router, no `window`. That is what lets the same rules run in four places + * that cannot import each other's runtimes: the server middleware that + * canonicalises incoming URLs, the router rewrite that hides the prefix from the + * route tree, the language switcher in the browser, and the tests. + * + * `/admin` and `/api` are outside all of it - see `DEFAULT_IGNORED_LOCALE_PATHS` + * in core for why - and this app takes that default as-is. + */ +export const localeRouting: LocaleRouting = localeRoutingFromConfig(i18n) + +export const { defaultLocale } = localeRouting + +/** Narrows a string - a URL segment, a cookie, a `