rosetta-i18n 1.0.0-beta.2 → 1.0.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -611,7 +611,8 @@ export function App({ locale, children }: { locale: string; children: React.Reac
611
611
 
612
612
  Hooks: `useLocale()`, `useLocales()`, `useLocaleSwitch()`, `useLocaleContext()`.
613
613
  `switchTo(locale)` writes the cookie and navigates (full page by default; pass
614
- `navigate` to hook a router, `persist` to save server-side).
614
+ `navigate` to hook a router, `persist` to save server-side). Navigation does not
615
+ wait for `persist`, so `persist` must survive the page unloading — see below.
615
616
 
616
617
  ### Next.js (App Router)
617
618
 
@@ -641,8 +642,78 @@ export default function RootLayout({ children }: { children: React.ReactNode })
641
642
  ```
642
643
 
643
644
  `RosettaProvider` reads the locale the middleware forced (`getServerLocale(config)`)
644
- and provides it to the client. Persist the choice for signed-in users with a server
645
- action:
645
+ and provides it to the client. `RosettaProvider` itself takes no `persist`; to save
646
+ the choice for signed-in users, resolve the locale yourself and render
647
+ `LocaleProvider` with a keepalive request:
648
+
649
+ ```tsx
650
+ // components/locale-persist.tsx
651
+ "use client";
652
+ import { LocaleProvider } from "rosetta-i18n/react";
653
+ import { localeConfig } from "@/i18n/config";
654
+
655
+ // The switch navigates immediately, so the save must survive the unload:
656
+ // `keepalive` lets the request outlive the page. Skip it when signed out.
657
+ function persistLocale(locale: string): Promise<void> {
658
+ return fetch("/api/me", {
659
+ method: "PATCH",
660
+ keepalive: true,
661
+ headers: { "content-type": "application/json" },
662
+ body: JSON.stringify({ locale }),
663
+ }).then(
664
+ () => undefined,
665
+ () => undefined,
666
+ );
667
+ }
668
+
669
+ export function LocalePersist({
670
+ locale,
671
+ signedIn,
672
+ children,
673
+ }: {
674
+ locale: string;
675
+ signedIn: boolean;
676
+ children: React.ReactNode;
677
+ }) {
678
+ return (
679
+ <LocaleProvider
680
+ config={localeConfig}
681
+ locale={locale}
682
+ persist={signedIn ? persistLocale : undefined}
683
+ >
684
+ {children}
685
+ </LocaleProvider>
686
+ );
687
+ }
688
+ ```
689
+
690
+ Use it in the layout in place of `RosettaProvider`, passing the resolved locale:
691
+
692
+ ```tsx
693
+ // app/layout.tsx
694
+ import { getServerLocale } from "rosetta-i18n/next";
695
+ import { LocalePersist } from "@/components/locale-persist";
696
+
697
+ export default async function RootLayout({ children }: { children: React.ReactNode }) {
698
+ const locale = await getServerLocale(localeConfig);
699
+ const signedIn = /* your session check */ false;
700
+ return (
701
+ <html>
702
+ <body>
703
+ <LocalePersist locale={locale} signedIn={signedIn}>
704
+ {children}
705
+ </LocalePersist>
706
+ </body>
707
+ </html>
708
+ );
709
+ }
710
+ ```
711
+
712
+ Do **not** pass a server action as `persist`. The navigation aborts it, so the
713
+ account is often never updated, and the aborted response rejects.
714
+
715
+ For a settings screen where the user stays on the page, `createSetLocaleAction`
716
+ is the right tool — `await` it and refresh once it resolves:
646
717
 
647
718
  ```ts
648
719
  // app/locale-actions.ts
package/dist/esm/react.js CHANGED
@@ -68,18 +68,22 @@ function LocaleProvider({
68
68
  persist,
69
69
  children
70
70
  }) {
71
- const [pending, setPending] = useState(false);
71
+ const [pendingLocale, setPendingLocale] = useState(null);
72
+ const pending = pendingLocale !== null;
73
+ if (pendingLocale !== null && pendingLocale === locale) {
74
+ setPendingLocale(null);
75
+ }
72
76
  const switchTo = useCallback(
73
77
  (next) => {
74
78
  if (next === locale || pending) return;
75
79
  const pathname = typeof window === "undefined" ? "/" : window.location.pathname;
76
80
  const plan = planSwitch(config, pathname, next);
77
81
  if (!plan) return;
78
- setPending(true);
82
+ setPendingLocale(next);
79
83
  writeCookie(plan.cookie.name, plan.cookie.value, plan.cookie.options);
80
- try {
81
- void persist?.(next);
82
- } catch {
84
+ if (persist) {
85
+ Promise.resolve().then(() => persist(next)).catch(() => {
86
+ });
83
87
  }
84
88
  (navigate ?? defaultNavigate)(plan.href);
85
89
  },
@@ -1 +1 @@
1
- {"version":3,"file":"react.js","sources":["../../src/locale/core.ts","../../src/react.tsx"],"sourcesContent":["/**\n * Runtime locale layer — framework-agnostic core.\n *\n * The translation engine answers \"how do I translate these strings?\". This\n * module answers \"which locale is this request in, and how do I switch?\" — the\n * routing + cookie model that the React, Next, and Astro adapters are thin\n * wrappers around.\n *\n * The config is deliberately plain data (no functions) so the exact same object\n * can cross the server → client boundary. Resolution is deterministic and\n * one-directional:\n *\n * 1. a locale-prefixed URL (`/ja/pricing`) is authoritative,\n * 2. then a forced header (`x-rosetta-locale`, set by middleware),\n * 3. then the persisted cookie,\n * 4. then the default locale.\n *\n * Switching sets the cookie *and* navigates to the localized URL, so the URL and\n * the cookie always agree. There is no client-side guessing, no\n * refresh-after-push race, and no way for a stale request to re-pin the locale.\n */\n\nexport const LOCALE_HEADER = \"x-rosetta-locale\";\nexport const DEFAULT_LOCALE_COOKIE = \"locale\";\nexport const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365;\n\nexport interface LocaleConfig {\n\t/** Every locale, including the default. Order is the switcher order. */\n\tlocales: readonly string[];\n\t/** The locale served without a URL prefix. */\n\tdefaultLocale: string;\n\t/** Cookie that persists the choice. Default `\"locale\"`. */\n\tcookieName?: string;\n\t/** Cookie max-age in seconds. Default one year. */\n\tcookieMaxAgeSeconds?: number;\n\t/** Prefix the default locale too (`/en/...`). Default `false`. */\n\tprefixDefaultLocale?: boolean;\n\t/**\n\t * Canonical (unprefixed) paths that have localized variants — exact matches.\n\t * Omit both this and `localizedPathPrefixes` to localize every path.\n\t */\n\tlocalizedPaths?: readonly string[];\n\t/** Canonical paths that have localized variants — prefix matches (e.g. `/blog/`). */\n\tlocalizedPathPrefixes?: readonly string[];\n}\n\nexport type LocaleSource = \"url\" | \"header\" | \"cookie\" | \"default\";\n\nexport interface ResolvedLocale {\n\tlocale: string;\n\t/** The canonical, unprefixed path for this request. */\n\tpath: string;\n\tsource: LocaleSource;\n}\n\nexport interface LocaleCookieOptions {\n\tpath: string;\n\tmaxAge: number;\n\tsameSite: \"lax\";\n}\n\nexport interface LocaleSwitchPlan {\n\t/** Localized URL to navigate to. */\n\thref: string;\n\t/** Cookie to persist the choice. */\n\tcookie: { name: string; value: string; options: LocaleCookieOptions };\n}\n\nexport function localeCookieName(config: LocaleConfig): string {\n\treturn config.cookieName ?? DEFAULT_LOCALE_COOKIE;\n}\n\nexport function localeCookieOptions(config: LocaleConfig): LocaleCookieOptions {\n\treturn {\n\t\tpath: \"/\",\n\t\tmaxAge: config.cookieMaxAgeSeconds ?? ONE_YEAR_SECONDS,\n\t\tsameSite: \"lax\",\n\t};\n}\n\nexport function isLocale(\n\tconfig: LocaleConfig,\n\tvalue: unknown,\n): value is string {\n\treturn typeof value === \"string\" && config.locales.includes(value);\n}\n\nexport function isLocalizedPath(\n\tconfig: LocaleConfig,\n\tpathname: string,\n): boolean {\n\tconst hasRules =\n\t\t(config.localizedPaths?.length ?? 0) > 0 ||\n\t\t(config.localizedPathPrefixes?.length ?? 0) > 0;\n\tif (!hasRules) return true;\n\tif (config.localizedPaths?.includes(pathname)) return true;\n\treturn (\n\t\tconfig.localizedPathPrefixes?.some(\n\t\t\t(prefix) => pathname === prefix || pathname.startsWith(prefix),\n\t\t) ?? false\n\t);\n}\n\n/** The leading locale segment of a path, if it is one we know. */\nexport function localeFromPath(\n\tconfig: LocaleConfig,\n\tpathname: string,\n): string | null {\n\tconst segment = pathname.split(\"/\")[1];\n\treturn segment && isLocale(config, segment) ? segment : null;\n}\n\n/** Strip a leading locale segment, returning the canonical path. */\nexport function stripLocale(\n\tconfig: LocaleConfig,\n\tpathname: string,\n): { locale: string | null; path: string } {\n\tconst locale = localeFromPath(config, pathname);\n\tif (!locale) return { locale: null, path: pathname || \"/\" };\n\tconst rest = pathname.slice(locale.length + 1);\n\treturn { locale, path: rest || \"/\" };\n}\n\n/**\n * Prefix a canonical path for a locale. The default locale stays bare unless\n * `prefixDefaultLocale` is set.\n */\nexport function localizePath(\n\tconfig: LocaleConfig,\n\tpathname: string,\n\tlocale: string,\n): string {\n\tif (locale === config.defaultLocale && !config.prefixDefaultLocale) {\n\t\treturn pathname;\n\t}\n\treturn pathname === \"/\" ? `/${locale}` : `/${locale}${pathname}`;\n}\n\n/** The URL to navigate to when switching `pathname` to `locale`. */\nexport function switchHref(\n\tconfig: LocaleConfig,\n\tpathname: string,\n\tlocale: string,\n): string {\n\tconst { path } = stripLocale(config, pathname);\n\treturn localizePath(config, path, locale);\n}\n\n/**\n * Resolve the active locale for a request. Precedence: URL prefix → forced\n * header → cookie → default. `path` is always the canonical (unprefixed) path.\n */\nexport function resolveLocale(\n\tconfig: LocaleConfig,\n\tinput: { pathname: string; header?: string | null; cookie?: string | null },\n): ResolvedLocale {\n\tconst fromPath = localeFromPath(config, input.pathname);\n\tif (fromPath) {\n\t\treturn {\n\t\t\tlocale: fromPath,\n\t\t\tpath: stripLocale(config, input.pathname).path,\n\t\t\tsource: \"url\",\n\t\t};\n\t}\n\tif (isLocale(config, input.header)) {\n\t\treturn { locale: input.header, path: input.pathname, source: \"header\" };\n\t}\n\tif (isLocale(config, input.cookie)) {\n\t\treturn { locale: input.cookie, path: input.pathname, source: \"cookie\" };\n\t}\n\treturn {\n\t\tlocale: config.defaultLocale,\n\t\tpath: input.pathname,\n\t\tsource: \"default\",\n\t};\n}\n\n/** The `Set-Cookie` value that persists `locale`. */\nexport function serializeLocaleCookie(\n\tconfig: LocaleConfig,\n\tlocale: string,\n): string {\n\tconst options = localeCookieOptions(config);\n\treturn `${localeCookieName(config)}=${encodeURIComponent(locale)}; Path=${\n\t\toptions.path\n\t}; Max-Age=${options.maxAge}; SameSite=${capitalize(options.sameSite)}`;\n}\n\n/** Read a single cookie value from a `Cookie:` header. */\nexport function parseCookie(\n\theader: string | null | undefined,\n\tname: string,\n): string | null {\n\tif (!header) return null;\n\tfor (const part of header.split(\";\")) {\n\t\tconst [key, ...rest] = part.trim().split(\"=\");\n\t\tif (key === name) return decodeURIComponent(rest.join(\"=\"));\n\t}\n\treturn null;\n}\n\n/**\n * Everything a client needs to switch locale: set `cookie`, navigate to `href`.\n * Returns `null` when the current path has no localized variant (legal pages,\n * API routes, …), so the switcher can no-op instead of linking to a 404.\n */\nexport function planSwitch(\n\tconfig: LocaleConfig,\n\tpathname: string,\n\tlocale: string,\n): LocaleSwitchPlan | null {\n\tconst { path } = stripLocale(config, pathname);\n\tif (!isLocalizedPath(config, path)) return null;\n\treturn {\n\t\thref: localizePath(config, path, locale),\n\t\tcookie: {\n\t\t\tname: localeCookieName(config),\n\t\t\tvalue: locale,\n\t\t\toptions: localeCookieOptions(config),\n\t\t},\n\t};\n}\n\nfunction capitalize(value: string): string {\n\treturn value.charAt(0).toUpperCase() + value.slice(1);\n}\n","\"use client\";\n\n/**\n * React adapter: a server-resolved locale, provided to the client, with a\n * switcher that can't race.\n *\n * The server resolves the locale (see the Next/Astro adapters) and renders\n * `<LocaleProvider locale={locale}>`. The client only ever *reads* that locale\n * and, to switch, sets the cookie and navigates — it never guesses.\n */\n\nimport {\n\tcreateContext,\n\ttype ReactNode,\n\tuseCallback,\n\tuseContext,\n\tuseMemo,\n\tuseState,\n} from \"react\";\nimport { type LocaleConfig, planSwitch } from \"./locale/core\";\n\nexport interface LocaleContextValue {\n\tconfig: LocaleConfig;\n\t/** The active locale, resolved on the server for this request. */\n\tlocale: string;\n\t/** True while a switch is in flight. */\n\tpending: boolean;\n\t/** Switch locale: writes the cookie, then navigates. */\n\tswitchTo: (locale: string) => void;\n\t/** Localized href for the current page in `locale`, or `null` if it has none. */\n\threfFor: (locale: string) => string | null;\n}\n\nconst LocaleContext = createContext<LocaleContextValue | null>(null);\n\nexport interface LocaleProviderProps {\n\t/** Plain data, safe to pass from a server component. */\n\tconfig: LocaleConfig;\n\t/** The locale resolved for this request. */\n\tlocale: string;\n\t/**\n\t * Navigate to the localized URL. Defaults to a full-page `location.assign`,\n\t * which is the reliable choice: one request, URL and cookie in agreement.\n\t */\n\tnavigate?: (href: string) => void;\n\t/**\n\t * Persist the choice server-side (the cookie is already written). Use for\n\t * signed-in users, e.g. a server action that stores the locale.\n\t */\n\tpersist?: (locale: string) => void | Promise<void>;\n\tchildren: ReactNode;\n}\n\nfunction defaultNavigate(href: string): void {\n\twindow.location.assign(href);\n}\n\nfunction writeCookie(\n\tname: string,\n\tvalue: string,\n\toptions: { path: string; maxAge: number },\n): void {\n\t// biome-ignore lint/suspicious/noDocumentCookie: the Cookie Store API isn't available in Safari, so document.cookie is the portable choice.\n\tdocument.cookie = `${name}=${encodeURIComponent(value)}; Path=${options.path}; Max-Age=${\n\t\toptions.maxAge\n\t}; SameSite=Lax`;\n}\n\nexport function LocaleProvider({\n\tconfig,\n\tlocale,\n\tnavigate,\n\tpersist,\n\tchildren,\n}: LocaleProviderProps) {\n\tconst [pending, setPending] = useState(false);\n\n\tconst switchTo = useCallback(\n\t\t(next: string) => {\n\t\t\tif (next === locale || pending) return;\n\t\t\tconst pathname =\n\t\t\t\ttypeof window === \"undefined\" ? \"/\" : window.location.pathname;\n\t\t\tconst plan = planSwitch(config, pathname, next);\n\t\t\tif (!plan) return; // current path has no localized variant\n\t\t\tsetPending(true);\n\t\t\twriteCookie(plan.cookie.name, plan.cookie.value, plan.cookie.options);\n\t\t\ttry {\n\t\t\t\tvoid persist?.(next);\n\t\t\t} catch {\n\t\t\t\t/* persistence is best-effort */\n\t\t\t}\n\t\t\t(navigate ?? defaultNavigate)(plan.href);\n\t\t},\n\t\t[config, locale, pending, navigate, persist],\n\t);\n\n\tconst hrefFor = useCallback(\n\t\t(target: string): string | null => {\n\t\t\tconst pathname =\n\t\t\t\ttypeof window === \"undefined\" ? \"/\" : window.location.pathname;\n\t\t\treturn planSwitch(config, pathname, target)?.href ?? null;\n\t\t},\n\t\t[config],\n\t);\n\n\tconst value = useMemo<LocaleContextValue>(\n\t\t() => ({ config, locale, pending, switchTo, hrefFor }),\n\t\t[config, locale, pending, switchTo, hrefFor],\n\t);\n\n\treturn (\n\t\t<LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>\n\t);\n}\n\nexport function useLocaleContext(): LocaleContextValue {\n\tconst value = useContext(LocaleContext);\n\tif (!value) {\n\t\tthrow new Error(\n\t\t\t\"rosetta-i18n: no locale context — render <LocaleProvider> (or the framework provider) above this component.\",\n\t\t);\n\t}\n\treturn value;\n}\n\nexport function useLocale(): string {\n\treturn useLocaleContext().locale;\n}\n\nexport function useLocales(): readonly string[] {\n\treturn useLocaleContext().config.locales;\n}\n\n/** `[locale, switchTo]` — the switcher primitive. */\nexport function useLocaleSwitch(): (locale: string) => void {\n\treturn useLocaleContext().switchTo;\n}\n\nexport interface LocaleSwitcherRenderProps {\n\tlocales: readonly string[];\n\tlocale: string;\n\tpending: boolean;\n\tswitchTo: (locale: string) => void;\n\t/** Endonym for a locale (e.g. `\"日本語\"`), unless overridden by `labels`. */\n\tlabelFor: (locale: string) => string;\n}\n\nexport interface LocaleSwitcherProps {\n\t/** Render your own UI. Omit for a plain, unstyled `<select>`. */\n\trender?: (props: LocaleSwitcherRenderProps) => ReactNode;\n\t/** Display names per locale. Defaults to the locale's own name. */\n\tlabels?: Record<string, string>;\n}\n\nfunction defaultLabel(locale: string): string {\n\ttry {\n\t\treturn (\n\t\t\tnew Intl.DisplayNames([locale], { type: \"language\" }).of(locale) ?? locale\n\t\t);\n\t} catch {\n\t\treturn locale;\n\t}\n}\n\nexport function LocaleSwitcher({ render, labels }: LocaleSwitcherProps) {\n\tconst { config, locale, pending, switchTo } = useLocaleContext();\n\tconst labelFor = (value: string) => labels?.[value] ?? defaultLabel(value);\n\tconst props: LocaleSwitcherRenderProps = {\n\t\tlocales: config.locales,\n\t\tlocale,\n\t\tpending,\n\t\tswitchTo,\n\t\tlabelFor,\n\t};\n\tif (render) return <>{render(props)}</>;\n\treturn (\n\t\t<select\n\t\t\taria-label=\"Language\"\n\t\t\tvalue={locale}\n\t\t\tdisabled={pending}\n\t\t\tonChange={(event) => switchTo(event.target.value)}\n\t\t>\n\t\t\t{config.locales.map((value) => (\n\t\t\t\t<option key={value} value={value}>\n\t\t\t\t\t{labelFor(value)}\n\t\t\t\t</option>\n\t\t\t))}\n\t\t</select>\n\t);\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"react.js","sources":["../../src/locale/core.ts","../../src/react.tsx"],"sourcesContent":["/**\n * Runtime locale layer — framework-agnostic core.\n *\n * The translation engine answers \"how do I translate these strings?\". This\n * module answers \"which locale is this request in, and how do I switch?\" — the\n * routing + cookie model that the React, Next, and Astro adapters are thin\n * wrappers around.\n *\n * The config is deliberately plain data (no functions) so the exact same object\n * can cross the server → client boundary. Resolution is deterministic and\n * one-directional:\n *\n * 1. a locale-prefixed URL (`/ja/pricing`) is authoritative,\n * 2. then a forced header (`x-rosetta-locale`, set by middleware),\n * 3. then the persisted cookie,\n * 4. then the default locale.\n *\n * Switching sets the cookie *and* navigates to the localized URL, so the URL and\n * the cookie always agree. There is no client-side guessing, no\n * refresh-after-push race, and no way for a stale request to re-pin the locale.\n */\n\nexport const LOCALE_HEADER = \"x-rosetta-locale\";\nexport const DEFAULT_LOCALE_COOKIE = \"locale\";\nexport const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365;\n\nexport interface LocaleConfig {\n\t/** Every locale, including the default. Order is the switcher order. */\n\tlocales: readonly string[];\n\t/** The locale served without a URL prefix. */\n\tdefaultLocale: string;\n\t/** Cookie that persists the choice. Default `\"locale\"`. */\n\tcookieName?: string;\n\t/** Cookie max-age in seconds. Default one year. */\n\tcookieMaxAgeSeconds?: number;\n\t/** Prefix the default locale too (`/en/...`). Default `false`. */\n\tprefixDefaultLocale?: boolean;\n\t/**\n\t * Canonical (unprefixed) paths that have localized variants — exact matches.\n\t * Omit both this and `localizedPathPrefixes` to localize every path.\n\t */\n\tlocalizedPaths?: readonly string[];\n\t/** Canonical paths that have localized variants — prefix matches (e.g. `/blog/`). */\n\tlocalizedPathPrefixes?: readonly string[];\n}\n\nexport type LocaleSource = \"url\" | \"header\" | \"cookie\" | \"default\";\n\nexport interface ResolvedLocale {\n\tlocale: string;\n\t/** The canonical, unprefixed path for this request. */\n\tpath: string;\n\tsource: LocaleSource;\n}\n\nexport interface LocaleCookieOptions {\n\tpath: string;\n\tmaxAge: number;\n\tsameSite: \"lax\";\n}\n\nexport interface LocaleSwitchPlan {\n\t/** Localized URL to navigate to. */\n\thref: string;\n\t/** Cookie to persist the choice. */\n\tcookie: { name: string; value: string; options: LocaleCookieOptions };\n}\n\nexport function localeCookieName(config: LocaleConfig): string {\n\treturn config.cookieName ?? DEFAULT_LOCALE_COOKIE;\n}\n\nexport function localeCookieOptions(config: LocaleConfig): LocaleCookieOptions {\n\treturn {\n\t\tpath: \"/\",\n\t\tmaxAge: config.cookieMaxAgeSeconds ?? ONE_YEAR_SECONDS,\n\t\tsameSite: \"lax\",\n\t};\n}\n\nexport function isLocale(\n\tconfig: LocaleConfig,\n\tvalue: unknown,\n): value is string {\n\treturn typeof value === \"string\" && config.locales.includes(value);\n}\n\nexport function isLocalizedPath(\n\tconfig: LocaleConfig,\n\tpathname: string,\n): boolean {\n\tconst hasRules =\n\t\t(config.localizedPaths?.length ?? 0) > 0 ||\n\t\t(config.localizedPathPrefixes?.length ?? 0) > 0;\n\tif (!hasRules) return true;\n\tif (config.localizedPaths?.includes(pathname)) return true;\n\treturn (\n\t\tconfig.localizedPathPrefixes?.some(\n\t\t\t(prefix) => pathname === prefix || pathname.startsWith(prefix),\n\t\t) ?? false\n\t);\n}\n\n/** The leading locale segment of a path, if it is one we know. */\nexport function localeFromPath(\n\tconfig: LocaleConfig,\n\tpathname: string,\n): string | null {\n\tconst segment = pathname.split(\"/\")[1];\n\treturn segment && isLocale(config, segment) ? segment : null;\n}\n\n/** Strip a leading locale segment, returning the canonical path. */\nexport function stripLocale(\n\tconfig: LocaleConfig,\n\tpathname: string,\n): { locale: string | null; path: string } {\n\tconst locale = localeFromPath(config, pathname);\n\tif (!locale) return { locale: null, path: pathname || \"/\" };\n\tconst rest = pathname.slice(locale.length + 1);\n\treturn { locale, path: rest || \"/\" };\n}\n\n/**\n * Prefix a canonical path for a locale. The default locale stays bare unless\n * `prefixDefaultLocale` is set.\n */\nexport function localizePath(\n\tconfig: LocaleConfig,\n\tpathname: string,\n\tlocale: string,\n): string {\n\tif (locale === config.defaultLocale && !config.prefixDefaultLocale) {\n\t\treturn pathname;\n\t}\n\treturn pathname === \"/\" ? `/${locale}` : `/${locale}${pathname}`;\n}\n\n/** The URL to navigate to when switching `pathname` to `locale`. */\nexport function switchHref(\n\tconfig: LocaleConfig,\n\tpathname: string,\n\tlocale: string,\n): string {\n\tconst { path } = stripLocale(config, pathname);\n\treturn localizePath(config, path, locale);\n}\n\n/**\n * Resolve the active locale for a request. Precedence: URL prefix → forced\n * header → cookie → default. `path` is always the canonical (unprefixed) path.\n */\nexport function resolveLocale(\n\tconfig: LocaleConfig,\n\tinput: { pathname: string; header?: string | null; cookie?: string | null },\n): ResolvedLocale {\n\tconst fromPath = localeFromPath(config, input.pathname);\n\tif (fromPath) {\n\t\treturn {\n\t\t\tlocale: fromPath,\n\t\t\tpath: stripLocale(config, input.pathname).path,\n\t\t\tsource: \"url\",\n\t\t};\n\t}\n\tif (isLocale(config, input.header)) {\n\t\treturn { locale: input.header, path: input.pathname, source: \"header\" };\n\t}\n\tif (isLocale(config, input.cookie)) {\n\t\treturn { locale: input.cookie, path: input.pathname, source: \"cookie\" };\n\t}\n\treturn {\n\t\tlocale: config.defaultLocale,\n\t\tpath: input.pathname,\n\t\tsource: \"default\",\n\t};\n}\n\n/** The `Set-Cookie` value that persists `locale`. */\nexport function serializeLocaleCookie(\n\tconfig: LocaleConfig,\n\tlocale: string,\n): string {\n\tconst options = localeCookieOptions(config);\n\treturn `${localeCookieName(config)}=${encodeURIComponent(locale)}; Path=${\n\t\toptions.path\n\t}; Max-Age=${options.maxAge}; SameSite=${capitalize(options.sameSite)}`;\n}\n\n/** Read a single cookie value from a `Cookie:` header. */\nexport function parseCookie(\n\theader: string | null | undefined,\n\tname: string,\n): string | null {\n\tif (!header) return null;\n\tfor (const part of header.split(\";\")) {\n\t\tconst [key, ...rest] = part.trim().split(\"=\");\n\t\tif (key === name) return decodeURIComponent(rest.join(\"=\"));\n\t}\n\treturn null;\n}\n\n/**\n * Everything a client needs to switch locale: set `cookie`, navigate to `href`.\n * Returns `null` when the current path has no localized variant (legal pages,\n * API routes, …), so the switcher can no-op instead of linking to a 404.\n */\nexport function planSwitch(\n\tconfig: LocaleConfig,\n\tpathname: string,\n\tlocale: string,\n): LocaleSwitchPlan | null {\n\tconst { path } = stripLocale(config, pathname);\n\tif (!isLocalizedPath(config, path)) return null;\n\treturn {\n\t\thref: localizePath(config, path, locale),\n\t\tcookie: {\n\t\t\tname: localeCookieName(config),\n\t\t\tvalue: locale,\n\t\t\toptions: localeCookieOptions(config),\n\t\t},\n\t};\n}\n\nfunction capitalize(value: string): string {\n\treturn value.charAt(0).toUpperCase() + value.slice(1);\n}\n","\"use client\";\n\n/**\n * React adapter: a server-resolved locale, provided to the client, with a\n * switcher that can't race.\n *\n * The server resolves the locale (see the Next/Astro adapters) and renders\n * `<LocaleProvider locale={locale}>`. The client only ever *reads* that locale\n * and, to switch, sets the cookie and navigates — it never guesses.\n */\n\nimport {\n\tcreateContext,\n\ttype ReactNode,\n\tuseCallback,\n\tuseContext,\n\tuseMemo,\n\tuseState,\n} from \"react\";\nimport { type LocaleConfig, planSwitch } from \"./locale/core\";\n\nexport interface LocaleContextValue {\n\tconfig: LocaleConfig;\n\t/** The active locale, resolved on the server for this request. */\n\tlocale: string;\n\t/** True while a switch is in flight. */\n\tpending: boolean;\n\t/** Switch locale: writes the cookie, then navigates. */\n\tswitchTo: (locale: string) => void;\n\t/** Localized href for the current page in `locale`, or `null` if it has none. */\n\threfFor: (locale: string) => string | null;\n}\n\nconst LocaleContext = createContext<LocaleContextValue | null>(null);\n\nexport interface LocaleProviderProps {\n\t/** Plain data, safe to pass from a server component. */\n\tconfig: LocaleConfig;\n\t/** The locale resolved for this request. */\n\tlocale: string;\n\t/**\n\t * Navigate to the localized URL. Defaults to a full-page `location.assign`,\n\t * which is the reliable choice: one request, URL and cookie in agreement.\n\t */\n\tnavigate?: (href: string) => void;\n\t/**\n\t * Persist the choice server-side (the cookie is already written). Use for\n\t * signed-in users. Because `switchTo` navigates immediately and does NOT\n\t * wait for this to settle, `persist` must survive the page unloading: send\n\t * the locale with `fetch(url, { keepalive: true })` or `navigator.sendBeacon`.\n\t * Do not pass a server action — it is aborted when the page navigates, and a\n\t * rejected save would otherwise surface as an unhandled rejection.\n\t */\n\tpersist?: (locale: string) => void | Promise<void>;\n\tchildren: ReactNode;\n}\n\nfunction defaultNavigate(href: string): void {\n\twindow.location.assign(href);\n}\n\nfunction writeCookie(\n\tname: string,\n\tvalue: string,\n\toptions: { path: string; maxAge: number },\n): void {\n\t// biome-ignore lint/suspicious/noDocumentCookie: the Cookie Store API isn't available in Safari, so document.cookie is the portable choice.\n\tdocument.cookie = `${name}=${encodeURIComponent(value)}; Path=${options.path}; Max-Age=${\n\t\toptions.maxAge\n\t}; SameSite=Lax`;\n}\n\nexport function LocaleProvider({\n\tconfig,\n\tlocale,\n\tnavigate,\n\tpersist,\n\tchildren,\n}: LocaleProviderProps) {\n\tconst [pendingLocale, setPendingLocale] = useState<string | null>(null);\n\tconst pending = pendingLocale !== null;\n\t// Keyed to the target locale so it clears the moment the new locale lands: a\n\t// router-based `navigate` rerenders the provider with the new `locale`, while\n\t// the default `location.assign` unloads the page first and never needs it.\n\tif (pendingLocale !== null && pendingLocale === locale) {\n\t\tsetPendingLocale(null);\n\t}\n\n\tconst switchTo = useCallback(\n\t\t(next: string) => {\n\t\t\tif (next === locale || pending) return;\n\t\t\tconst pathname =\n\t\t\t\ttypeof window === \"undefined\" ? \"/\" : window.location.pathname;\n\t\t\tconst plan = planSwitch(config, pathname, next);\n\t\t\tif (!plan) return; // current path has no localized variant\n\t\t\tsetPendingLocale(next);\n\t\t\twriteCookie(plan.cookie.name, plan.cookie.value, plan.cookie.options);\n\t\t\t// Best-effort and never awaited: catches a synchronous throw AND a\n\t\t\t// rejected promise, so a failed save can't surface as an unhandled\n\t\t\t// rejection. Navigation must not wait on it (see the `persist` docs).\n\t\t\tif (persist) {\n\t\t\t\tPromise.resolve()\n\t\t\t\t\t.then(() => persist(next))\n\t\t\t\t\t.catch(() => {});\n\t\t\t}\n\t\t\t(navigate ?? defaultNavigate)(plan.href);\n\t\t},\n\t\t[config, locale, pending, navigate, persist],\n\t);\n\n\tconst hrefFor = useCallback(\n\t\t(target: string): string | null => {\n\t\t\tconst pathname =\n\t\t\t\ttypeof window === \"undefined\" ? \"/\" : window.location.pathname;\n\t\t\treturn planSwitch(config, pathname, target)?.href ?? null;\n\t\t},\n\t\t[config],\n\t);\n\n\tconst value = useMemo<LocaleContextValue>(\n\t\t() => ({ config, locale, pending, switchTo, hrefFor }),\n\t\t[config, locale, pending, switchTo, hrefFor],\n\t);\n\n\treturn (\n\t\t<LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>\n\t);\n}\n\nexport function useLocaleContext(): LocaleContextValue {\n\tconst value = useContext(LocaleContext);\n\tif (!value) {\n\t\tthrow new Error(\n\t\t\t\"rosetta-i18n: no locale context — render <LocaleProvider> (or the framework provider) above this component.\",\n\t\t);\n\t}\n\treturn value;\n}\n\nexport function useLocale(): string {\n\treturn useLocaleContext().locale;\n}\n\nexport function useLocales(): readonly string[] {\n\treturn useLocaleContext().config.locales;\n}\n\n/** The switcher primitive: `switchTo(locale)` writes the cookie and navigates. */\nexport function useLocaleSwitch(): (locale: string) => void {\n\treturn useLocaleContext().switchTo;\n}\n\nexport interface LocaleSwitcherRenderProps {\n\tlocales: readonly string[];\n\tlocale: string;\n\tpending: boolean;\n\tswitchTo: (locale: string) => void;\n\t/** Endonym for a locale (e.g. `\"日本語\"`), unless overridden by `labels`. */\n\tlabelFor: (locale: string) => string;\n}\n\nexport interface LocaleSwitcherProps {\n\t/** Render your own UI. Omit for a plain, unstyled `<select>`. */\n\trender?: (props: LocaleSwitcherRenderProps) => ReactNode;\n\t/** Display names per locale. Defaults to the locale's own name. */\n\tlabels?: Record<string, string>;\n}\n\nfunction defaultLabel(locale: string): string {\n\ttry {\n\t\treturn (\n\t\t\tnew Intl.DisplayNames([locale], { type: \"language\" }).of(locale) ?? locale\n\t\t);\n\t} catch {\n\t\treturn locale;\n\t}\n}\n\nexport function LocaleSwitcher({ render, labels }: LocaleSwitcherProps) {\n\tconst { config, locale, pending, switchTo } = useLocaleContext();\n\tconst labelFor = (value: string) => labels?.[value] ?? defaultLabel(value);\n\tconst props: LocaleSwitcherRenderProps = {\n\t\tlocales: config.locales,\n\t\tlocale,\n\t\tpending,\n\t\tswitchTo,\n\t\tlabelFor,\n\t};\n\tif (render) return <>{render(props)}</>;\n\treturn (\n\t\t<select\n\t\t\taria-label=\"Language\"\n\t\t\tvalue={locale}\n\t\t\tdisabled={pending}\n\t\t\tonChange={(event) => switchTo(event.target.value)}\n\t\t>\n\t\t\t{config.locales.map((value) => (\n\t\t\t\t<option key={value} value={value}>\n\t\t\t\t\t{labelFor(value)}\n\t\t\t\t</option>\n\t\t\t))}\n\t\t</select>\n\t);\n}\n"],"names":[],"mappings":";;;;AAuBO,MAAM,qBAAA,GAAwB,QAAA;AAC9B,MAAM,gBAAA,GAAmB,EAAA,GAAK,EAAA,GAAK,EAAA,GAAK,GAAA;AA4CxC,SAAS,iBAAiB,MAAA,EAA8B;AAC9D,EAAA,OAAO,OAAO,UAAA,IAAc,qBAAA;AAC7B;AAEO,SAAS,oBAAoB,MAAA,EAA2C;AAC9E,EAAA,OAAO;AAAA,IACN,IAAA,EAAM,GAAA;AAAA,IACN,MAAA,EAAQ,OAAO,mBAAA,IAAuB,gBAAA;AAAA,IACtC,QAAA,EAAU;AAAA,GACX;AACD;AAEO,SAAS,QAAA,CACf,QACA,KAAA,EACkB;AAClB,EAAA,OAAO,OAAO,KAAA,KAAU,QAAA,IAAY,MAAA,CAAO,OAAA,CAAQ,SAAS,KAAK,CAAA;AAClE;AAEO,SAAS,eAAA,CACf,QACA,QAAA,EACU;AACV,EAAA,MAAM,QAAA,GAAA,CACJ,OAAO,cAAA,EAAgB,MAAA,IAAU,KAAK,CAAA,IAAA,CACtC,MAAA,CAAO,qBAAA,EAAuB,MAAA,IAAU,CAAA,IAAK,CAAA;AAC/C,EAAA,IAAI,CAAC,UAAU,OAAO,IAAA;AACtB,EAAA,IAAI,MAAA,CAAO,cAAA,EAAgB,QAAA,CAAS,QAAQ,GAAG,OAAO,IAAA;AACtD,EAAA,OACC,OAAO,qBAAA,EAAuB,IAAA;AAAA,IAC7B,CAAC,MAAA,KAAW,QAAA,KAAa,MAAA,IAAU,QAAA,CAAS,WAAW,MAAM;AAAA,GAC9D,IAAK,KAAA;AAEP;AAGO,SAAS,cAAA,CACf,QACA,QAAA,EACgB;AAChB,EAAA,MAAM,OAAA,GAAU,QAAA,CAAS,KAAA,CAAM,GAAG,EAAE,CAAC,CAAA;AACrC,EAAA,OAAO,OAAA,IAAW,QAAA,CAAS,MAAA,EAAQ,OAAO,IAAI,OAAA,GAAU,IAAA;AACzD;AAGO,SAAS,WAAA,CACf,QACA,QAAA,EAC0C;AAC1C,EAAA,MAAM,MAAA,GAAS,cAAA,CAAe,MAAA,EAAQ,QAAQ,CAAA;AAC9C,EAAA,IAAI,CAAC,QAAQ,OAAO,EAAE,QAAQ,IAAA,EAAM,IAAA,EAAM,YAAY,GAAA,EAAI;AAC1D,EAAA,MAAM,IAAA,GAAO,QAAA,CAAS,KAAA,CAAM,MAAA,CAAO,SAAS,CAAC,CAAA;AAC7C,EAAA,OAAO,EAAE,MAAA,EAAQ,IAAA,EAAM,IAAA,IAAQ,GAAA,EAAI;AACpC;AAMO,SAAS,YAAA,CACf,MAAA,EACA,QAAA,EACA,MAAA,EACS;AACT,EAAA,IAAI,MAAA,KAAW,MAAA,CAAO,aAAA,IAAiB,CAAC,OAAO,mBAAA,EAAqB;AACnE,IAAA,OAAO,QAAA;AAAA,EACR;AACA,EAAA,OAAO,QAAA,KAAa,MAAM,CAAA,CAAA,EAAI,MAAM,KAAK,CAAA,CAAA,EAAI,MAAM,GAAG,QAAQ,CAAA,CAAA;AAC/D;AAsEO,SAAS,UAAA,CACf,MAAA,EACA,QAAA,EACA,MAAA,EAC0B;AAC1B,EAAA,MAAM,EAAE,IAAA,EAAK,GAAI,WAAA,CAAY,QAAQ,QAAQ,CAAA;AAC7C,EAAA,IAAI,CAAC,eAAA,CAAgB,MAAA,EAAQ,IAAI,GAAG,OAAO,IAAA;AAC3C,EAAA,OAAO;AAAA,IACN,IAAA,EAAM,YAAA,CAAa,MAAA,EAAQ,IAAA,EAAM,MAAM,CAAA;AAAA,IACvC,MAAA,EAAQ;AAAA,MACP,IAAA,EAAM,iBAAiB,MAAM,CAAA;AAAA,MAC7B,KAAA,EAAO,MAAA;AAAA,MACP,OAAA,EAAS,oBAAoB,MAAM;AAAA;AACpC,GACD;AACD;;AC5LA,MAAM,aAAA,GAAgB,cAAyC,IAAI,CAAA;AAwBnE,SAAS,gBAAgB,IAAA,EAAoB;AAC5C,EAAA,MAAA,CAAO,QAAA,CAAS,OAAO,IAAI,CAAA;AAC5B;AAEA,SAAS,WAAA,CACR,IAAA,EACA,KAAA,EACA,OAAA,EACO;AAEP,EAAA,QAAA,CAAS,MAAA,GAAS,CAAA,EAAG,IAAI,CAAA,CAAA,EAAI,kBAAA,CAAmB,KAAK,CAAC,CAAA,OAAA,EAAU,OAAA,CAAQ,IAAI,CAAA,UAAA,EAC3E,OAAA,CAAQ,MACT,CAAA,cAAA,CAAA;AACD;AAEO,SAAS,cAAA,CAAe;AAAA,EAC9B,MAAA;AAAA,EACA,MAAA;AAAA,EACA,QAAA;AAAA,EACA,OAAA;AAAA,EACA;AACD,CAAA,EAAwB;AACvB,EAAA,MAAM,CAAC,aAAA,EAAe,gBAAgB,CAAA,GAAI,SAAwB,IAAI,CAAA;AACtE,EAAA,MAAM,UAAU,aAAA,KAAkB,IAAA;AAIlC,EAAA,IAAI,aAAA,KAAkB,IAAA,IAAQ,aAAA,KAAkB,MAAA,EAAQ;AACvD,IAAA,gBAAA,CAAiB,IAAI,CAAA;AAAA,EACtB;AAEA,EAAA,MAAM,QAAA,GAAW,WAAA;AAAA,IAChB,CAAC,IAAA,KAAiB;AACjB,MAAA,IAAI,IAAA,KAAS,UAAU,OAAA,EAAS;AAChC,MAAA,MAAM,WACL,OAAO,MAAA,KAAW,WAAA,GAAc,GAAA,GAAM,OAAO,QAAA,CAAS,QAAA;AACvD,MAAA,MAAM,IAAA,GAAO,UAAA,CAAW,MAAA,EAAQ,QAAA,EAAU,IAAI,CAAA;AAC9C,MAAA,IAAI,CAAC,IAAA,EAAM;AACX,MAAA,gBAAA,CAAiB,IAAI,CAAA;AACrB,MAAA,WAAA,CAAY,IAAA,CAAK,OAAO,IAAA,EAAM,IAAA,CAAK,OAAO,KAAA,EAAO,IAAA,CAAK,OAAO,OAAO,CAAA;AAIpE,MAAA,IAAI,OAAA,EAAS;AACZ,QAAA,OAAA,CAAQ,OAAA,GACN,IAAA,CAAK,MAAM,QAAQ,IAAI,CAAC,CAAA,CACxB,KAAA,CAAM,MAAM;AAAA,QAAC,CAAC,CAAA;AAAA,MACjB;AACA,MAAA,CAAC,QAAA,IAAY,eAAA,EAAiB,IAAA,CAAK,IAAI,CAAA;AAAA,IACxC,CAAA;AAAA,IACA,CAAC,MAAA,EAAQ,MAAA,EAAQ,OAAA,EAAS,UAAU,OAAO;AAAA,GAC5C;AAEA,EAAA,MAAM,OAAA,GAAU,WAAA;AAAA,IACf,CAAC,MAAA,KAAkC;AAClC,MAAA,MAAM,WACL,OAAO,MAAA,KAAW,WAAA,GAAc,GAAA,GAAM,OAAO,QAAA,CAAS,QAAA;AACvD,MAAA,OAAO,UAAA,CAAW,MAAA,EAAQ,QAAA,EAAU,MAAM,GAAG,IAAA,IAAQ,IAAA;AAAA,IACtD,CAAA;AAAA,IACA,CAAC,MAAM;AAAA,GACR;AAEA,EAAA,MAAM,KAAA,GAAQ,OAAA;AAAA,IACb,OAAO,EAAE,MAAA,EAAQ,MAAA,EAAQ,OAAA,EAAS,UAAU,OAAA,EAAQ,CAAA;AAAA,IACpD,CAAC,MAAA,EAAQ,MAAA,EAAQ,OAAA,EAAS,UAAU,OAAO;AAAA,GAC5C;AAEA,EAAA,uBACC,GAAA,CAAC,aAAA,CAAc,QAAA,EAAd,EAAuB,OAAe,QAAA,EAAS,CAAA;AAElD;AAEO,SAAS,gBAAA,GAAuC;AACtD,EAAA,MAAM,KAAA,GAAQ,WAAW,aAAa,CAAA;AACtC,EAAA,IAAI,CAAC,KAAA,EAAO;AACX,IAAA,MAAM,IAAI,KAAA;AAAA,MACT;AAAA,KACD;AAAA,EACD;AACA,EAAA,OAAO,KAAA;AACR;AAEO,SAAS,SAAA,GAAoB;AACnC,EAAA,OAAO,kBAAiB,CAAE,MAAA;AAC3B;AAEO,SAAS,UAAA,GAAgC;AAC/C,EAAA,OAAO,gBAAA,GAAmB,MAAA,CAAO,OAAA;AAClC;AAGO,SAAS,eAAA,GAA4C;AAC3D,EAAA,OAAO,kBAAiB,CAAE,QAAA;AAC3B;AAkBA,SAAS,aAAa,MAAA,EAAwB;AAC7C,EAAA,IAAI;AACH,IAAA,OACC,IAAI,IAAA,CAAK,YAAA,CAAa,CAAC,MAAM,CAAA,EAAG,EAAE,IAAA,EAAM,UAAA,EAAY,CAAA,CAAE,EAAA,CAAG,MAAM,CAAA,IAAK,MAAA;AAAA,EAEtE,CAAA,CAAA,MAAQ;AACP,IAAA,OAAO,MAAA;AAAA,EACR;AACD;AAEO,SAAS,cAAA,CAAe,EAAE,MAAA,EAAQ,MAAA,EAAO,EAAwB;AACvE,EAAA,MAAM,EAAE,MAAA,EAAQ,MAAA,EAAQ,OAAA,EAAS,QAAA,KAAa,gBAAA,EAAiB;AAC/D,EAAA,MAAM,WAAW,CAAC,KAAA,KAAkB,SAAS,KAAK,CAAA,IAAK,aAAa,KAAK,CAAA;AACzE,EAAA,MAAM,KAAA,GAAmC;AAAA,IACxC,SAAS,MAAA,CAAO,OAAA;AAAA,IAChB,MAAA;AAAA,IACA,OAAA;AAAA,IACA,QAAA;AAAA,IACA;AAAA,GACD;AACA,EAAA,IAAI,MAAA,EAAQ,uBAAO,GAAA,CAAA,QAAA,EAAA,EAAG,QAAA,EAAA,MAAA,CAAO,KAAK,CAAA,EAAE,CAAA;AACpC,EAAA,uBACC,GAAA;AAAA,IAAC,QAAA;AAAA,IAAA;AAAA,MACA,YAAA,EAAW,UAAA;AAAA,MACX,KAAA,EAAO,MAAA;AAAA,MACP,QAAA,EAAU,OAAA;AAAA,MACV,UAAU,CAAC,KAAA,KAAU,QAAA,CAAS,KAAA,CAAM,OAAO,KAAK,CAAA;AAAA,MAE/C,QAAA,EAAA,MAAA,CAAO,OAAA,CAAQ,GAAA,CAAI,CAAC,KAAA,qBACpB,GAAA,CAAC,QAAA,EAAA,EAAmB,KAAA,EAClB,QAAA,EAAA,QAAA,CAAS,KAAK,CAAA,EAAA,EADH,KAEb,CACA;AAAA;AAAA,GACF;AAEF;;"}
@@ -31,7 +31,11 @@ export interface LocaleProviderProps {
31
31
  navigate?: (href: string) => void;
32
32
  /**
33
33
  * Persist the choice server-side (the cookie is already written). Use for
34
- * signed-in users, e.g. a server action that stores the locale.
34
+ * signed-in users. Because `switchTo` navigates immediately and does NOT
35
+ * wait for this to settle, `persist` must survive the page unloading: send
36
+ * the locale with `fetch(url, { keepalive: true })` or `navigator.sendBeacon`.
37
+ * Do not pass a server action — it is aborted when the page navigates, and a
38
+ * rejected save would otherwise surface as an unhandled rejection.
35
39
  */
36
40
  persist?: (locale: string) => void | Promise<void>;
37
41
  children: ReactNode;
@@ -40,7 +44,7 @@ export declare function LocaleProvider({ config, locale, navigate, persist, chil
40
44
  export declare function useLocaleContext(): LocaleContextValue;
41
45
  export declare function useLocale(): string;
42
46
  export declare function useLocales(): readonly string[];
43
- /** `[locale, switchTo]` — the switcher primitive. */
47
+ /** The switcher primitive: `switchTo(locale)` writes the cookie and navigates. */
44
48
  export declare function useLocaleSwitch(): (locale: string) => void;
45
49
  export interface LocaleSwitcherRenderProps {
46
50
  locales: readonly string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rosetta-i18n",
3
- "version": "1.0.0-beta.2",
3
+ "version": "1.0.0-beta.3",
4
4
  "author": "Ian Hunter <ian@01.studio>",
5
5
  "license": "MIT",
6
6
  "description": "Self-hosted AI translation engine — translate key-value content across locales through any OpenAI-compatible LLM, with brand voice and glossary enforcement.",
@@ -98,7 +98,10 @@
98
98
  "@biomejs/biome": "2.5.12",
99
99
  "@types/node": "22.15.3",
100
100
  "@types/react": "^19.3.0",
101
+ "@types/react-dom": "^19.3.0",
102
+ "jsdom": "^26.1.0",
101
103
  "react": "^19.3.0",
104
+ "react-dom": "^19.3.0",
102
105
  "rollup": "^4.63.1",
103
106
  "rollup-plugin-esbuild": "^6.1.1",
104
107
  "typescript": "5.9.2",