@edgekits/tanstack-i18n 0.0.0-stage → 0.2.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ All notable changes to this package are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the package uses
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## 0.2.1 — 2026-10-27
8
+
9
+ No changes; released in lockstep with the plugin rename (see `@edgekits/astro-i18n` 0.2.1).
10
+
11
+ ## 0.2.0 — 2026-10-27
12
+
13
+ First release, over `@edgekits/i18n-core` 0.2.0, for TanStack Start on a Hono Worker:
14
+
15
+ - `i18nMiddleware`: sets `c.var.locale` from the cookie, `userPreference`, Accept-Language, the default; never sets a cookie or redirects.
16
+ - `createTranslationsLoader`: the body of the app's own server function, the same cascade plus `fetchTranslations`; drops repeated namespaces.
17
+ - `isNamespaceList`: the validator for the namespace list from the browser: known names only, no repeats, non-empty, at most the generated count.
18
+ - `./react`: `I18nProvider` (nesting, inner namespaces merged over outer), `useI18n`, `useT` typed from the schema.
19
+ - `LanguageSwitcher`: headless, writes the lang cookie, then `afterChange` (e.g. `router.invalidate()`) or a reload.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gary Stupak
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,250 @@
1
- # Temporary Holding Version
1
+ # @edgekits/tanstack-i18n
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Edge-native i18n for TanStack Start on Cloudflare Workers, over the EdgeKits engine
4
+ [`@edgekits/i18n-core`](https://github.com/EdgeKits/i18n/tree/main/packages/core#readme):
5
+ translations in Workers KV, a per-namespace edge cache, typed keys. For an app whose Worker
6
+ entry is a Hono app that hands the request to TanStack Start, and whose URLs carry no locale.
7
+
8
+ - `@edgekits/tanstack-i18n` (server): `i18nMiddleware` for the Hono chain (sets `c.var.locale`),
9
+ `createTranslationsLoader` and `isNamespaceList` for the app's translations server function.
10
+ - `@edgekits/tanstack-i18n/react` (client-safe): `I18nProvider`, `useI18n`, `useT` and the
11
+ headless `LanguageSwitcher`.
12
+
13
+ The locale of a request comes from the request alone, in this order: the lang cookie, the
14
+ user's saved preference (a hook you supply), `Accept-Language` (a regional tag such as `de-DE`
15
+ matches `de`), the default locale. The middleware and the loader run the same cascade, so Hono
16
+ and TanStack agree. Nothing here sets a cookie or redirects; the switcher writes the cookie.
17
+
18
+ ## Install
19
+
20
+ ```sh
21
+ npm install @edgekits/tanstack-i18n @edgekits/i18n-core
22
+ ```
23
+
24
+ Peers: `hono` 4 and `react` 19. Write `edgekits-i18n.config.ts` and `locales/<locale>/<namespace>.json`
25
+ as the [core README](https://github.com/EdgeKits/i18n/tree/main/packages/core#readme)
26
+ describes, bind the `TRANSLATIONS` KV namespace in `wrangler.jsonc`, then generate and seed:
27
+
28
+ ```sh
29
+ npx edgekits-i18n seed
30
+ ```
31
+
32
+ ## 1. The instance: `src/i18n/instance.ts`
33
+
34
+ ```ts
35
+ import { env, waitUntil } from 'cloudflare:workers'
36
+ import { createI18n, parseBooleanFlag } from '@edgekits/i18n-core'
37
+
38
+ import config from '../../edgekits-i18n.config'
39
+ import * as fallbacks from './fallbacks.generated'
40
+ import * as runtimeConstants from './runtime-constants'
41
+
42
+ export const i18n = createI18n(
43
+ config,
44
+ { fallbacks, runtimeConstants },
45
+ {
46
+ get kv() {
47
+ return env.TRANSLATIONS
48
+ },
49
+ get cache() {
50
+ return caches.default ?? null
51
+ },
52
+ waitUntil,
53
+ get cacheEnabled() {
54
+ return parseBooleanFlag(env.I18N_CACHE, true)
55
+ },
56
+ get debug() {
57
+ return parseBooleanFlag(env.DEBUG_I18N, false)
58
+ },
59
+ },
60
+ )
61
+
62
+ // Optional: the user's saved locale, consulted after the cookie. Sync or async; return a
63
+ // locale string or null. The value is checked against your locales, never trusted.
64
+ export const userPreference = async (request: Request): Promise<string | null> => null
65
+ ```
66
+
67
+ ## 2. The middleware: `src/worker.ts`
68
+
69
+ ```ts
70
+ import { Hono } from 'hono'
71
+ import handler from '@tanstack/react-start/server-entry'
72
+ import { i18nMiddleware, type I18nVariables } from '@edgekits/tanstack-i18n'
73
+
74
+ import { i18n, userPreference } from './i18n/instance'
75
+
76
+ const app = new Hono<{ Variables: I18nVariables /* & your own */ }>()
77
+
78
+ app.use(i18nMiddleware(i18n, { userPreference })) // before middleware that reads c.var.locale
79
+ // …your session and tenant middleware…
80
+ app.all('*', (c) => handler.fetch(c.req.raw, { context: {} }))
81
+
82
+ export default app
83
+ ```
84
+
85
+ Hand `c.req.raw` to TanStack unchanged: server functions live under `/_serverFn/`, and that
86
+ path is case-sensitive.
87
+
88
+ ## 3. The server function: `src/i18n/server.ts`
89
+
90
+ ```ts
91
+ import { createServerFn } from '@tanstack/react-start'
92
+ import { getRequest, setResponseHeaders } from '@tanstack/react-start/server'
93
+ import { createTranslationsLoader, isNamespaceList } from '@edgekits/tanstack-i18n'
94
+
95
+ import { i18n, userPreference } from './instance'
96
+
97
+ const isNamespaces = isNamespaceList(i18n)
98
+ const load = createTranslationsLoader(i18n, { userPreference })
99
+
100
+ export const getTranslations = createServerFn({ method: 'GET' })
101
+ .validator((data: unknown) => {
102
+ if (!isNamespaces(data)) throw new Error('Unknown namespace')
103
+ return data
104
+ })
105
+ .handler(({ data }) => {
106
+ // The answer depends on the lang cookie: no shared cache may store it for everyone.
107
+ setResponseHeaders(new Headers({ 'Cache-Control': 'private, no-store', Vary: 'Cookie' }))
108
+ return load(getRequest(), data)
109
+ })
110
+ ```
111
+
112
+ The namespace list comes from the browser: `isNamespaceList` is true only for a non-empty
113
+ array of distinct namespaces the CLI generated, at most as many as there are, and the server
114
+ function rejects anything else, so an unknown name never reaches a KV key and one request
115
+ reads at most every namespace once. The package ships no server function of its own: Start compiles
116
+ one from a dependency too, but it is declared in the app so that the app's own request
117
+ helpers and kernel are in scope. The handler, the instance and the compiled fallbacks stay out
118
+ of the client bundle.
119
+
120
+ ## 4. The root route: `src/routes/__root.tsx`
121
+
122
+ ```tsx
123
+ import { Outlet, createRootRoute, HeadContent, Scripts } from '@tanstack/react-router'
124
+ import { localesOf } from '@edgekits/i18n-core'
125
+ import { I18nProvider } from '@edgekits/tanstack-i18n/react'
126
+
127
+ import config from '../../edgekits-i18n.config'
128
+ import { getTranslations } from '../i18n/server'
129
+
130
+ const { LANGUAGES } = localesOf(config)
131
+
132
+ export const Route = createRootRoute({
133
+ loader: () => getTranslations({ data: ['common'] }),
134
+ component: Root,
135
+ })
136
+
137
+ function Root() {
138
+ const { locale, translations } = Route.useLoaderData()
139
+ return (
140
+ <html lang={locale}>
141
+ <head>
142
+ <HeadContent />
143
+ </head>
144
+ <body>
145
+ <I18nProvider locale={locale} translations={translations} languages={LANGUAGES} cookie={config.cookie}>
146
+ <Outlet />
147
+ </I18nProvider>
148
+ <Scripts />
149
+ </body>
150
+ </html>
151
+ )
152
+ }
153
+ ```
154
+
155
+ The config and `localesOf` are pure, safe to import on the client. `languages` and `cookie` are
156
+ given once, here.
157
+
158
+ ## 5. A page: `src/routes/index.tsx`
159
+
160
+ ```tsx
161
+ import { createFileRoute } from '@tanstack/react-router'
162
+ import { I18nProvider, useT } from '@edgekits/tanstack-i18n/react'
163
+
164
+ import { getTranslations } from '../i18n/server'
165
+
166
+ export const Route = createFileRoute('/')({
167
+ loader: () => getTranslations({ data: ['home'] }),
168
+ component: Page,
169
+ })
170
+
171
+ function Page() {
172
+ return (
173
+ <I18nProvider translations={Route.useLoaderData().translations}>
174
+ <Home />
175
+ </I18nProvider>
176
+ )
177
+ }
178
+
179
+ function Home() {
180
+ const common = useT('common') // from the root route
181
+ const home = useT('home') // from this one
182
+ return (
183
+ <main>
184
+ <h1>{common.title}</h1>
185
+ <p>{home.hero}</p>
186
+ </main>
187
+ )
188
+ }
189
+ ```
190
+
191
+ Providers nest: an inner one merges its namespaces over the ones above and inherits the locale,
192
+ `languages` and `cookie`. `useT(namespace)` returns the namespace object, typed from your
193
+ default locale's JSON; a namespace no route loader above has loaded throws. `useI18n()` returns
194
+ `{ locale, languages, translations }`. Each route's dehydrated loader data carries only its own
195
+ namespaces, in one locale.
196
+
197
+ ## The language switcher
198
+
199
+ ```tsx
200
+ import { useRouter } from '@tanstack/react-router'
201
+ import { LanguageSwitcher } from '@edgekits/tanstack-i18n/react'
202
+
203
+ function Header() {
204
+ const router = useRouter()
205
+ return <LanguageSwitcher classes={{ root: 'ls', trigger: 'ls-trigger', dropdown: 'ls-menu', item: 'ls-item', itemActive: 'is-active' }} texts={{ trigger: 'Switch language' }} afterChange={() => router.invalidate()} />
206
+ }
207
+ ```
208
+
209
+ Headless: a trigger button and one item per language (the label carries `lang`, the active item
210
+ `aria-current`), class names only from `classes` (slots `root`, `trigger`, `triggerIcon`,
211
+ `dropdown`, `item`, `itemActive`, `itemLabel`), `data-ls-*` and `data-open` hooks for styling, no
212
+ styles. Escape and a click outside close it. Choosing a language writes the lang cookie
213
+ (`path=/`, `max-age` from `cookie.maxAge`, `SameSite=Lax; Secure`), then calls `afterChange`,
214
+ or reloads the page without one; `router.invalidate()` re-runs the loaders in place. With one
215
+ language it renders nothing.
216
+
217
+ ## A narrowed `Locale` (optional)
218
+
219
+ `Locale` is `string` until you list your locales, in any `.d.ts` your tsconfig includes:
220
+
221
+ ```ts
222
+ export {}
223
+
224
+ declare module '@edgekits/i18n-core' {
225
+ interface I18nLocales {
226
+ en: true
227
+ de: true
228
+ es: true
229
+ }
230
+ }
231
+ ```
232
+
233
+ `c.var.locale` is typed from the instance either way.
234
+
235
+ ## Monorepo
236
+
237
+ One translations folder for several Workers: the config, `locales/` and the generated files live
238
+ in a shared package, and each app imports them from there. The CLI takes the two paths:
239
+
240
+ ```sh
241
+ npx edgekits-i18n seed --config packages/i18n/edgekits-i18n.config.ts --wrangler apps/app/wrangler.jsonc
242
+ npx edgekits-i18n migrate --config packages/i18n/edgekits-i18n.config.ts --wrangler apps/app/wrangler.jsonc
243
+ ```
244
+
245
+ See the core README's
246
+ [monorepo section](https://github.com/EdgeKits/i18n/tree/main/packages/core#monorepo-one-translations-folder-several-workers).
247
+
248
+ ## License
249
+
250
+ MIT © Gary Stupak. See [LICENSE](LICENSE).
@@ -0,0 +1,4 @@
1
+ export { i18nMiddleware, type I18nVariables } from './middleware.ts';
2
+ export { createTranslationsLoader, type LoadedTranslations, type LoaderI18n } from './loader.ts';
3
+ export { isNamespaceList } from './namespaces.ts';
4
+ export type { RequestI18n, RequestLocaleOptions } from './request-locale.ts';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ // `@edgekits/tanstack-i18n`, the server entry: no React, no TanStack. The Hono middleware
2
+ // for the Worker's chain, and the loader and validator the app's own server function wraps.
3
+ export { i18nMiddleware } from "./middleware.js";
4
+ export { createTranslationsLoader } from "./loader.js";
5
+ export { isNamespaceList } from "./namespaces.js";
@@ -0,0 +1,16 @@
1
+ import type { Namespace, PickSchema } from '@edgekits/i18n-core';
2
+ import { type RequestI18n, type RequestLocaleOptions } from './request-locale.ts';
3
+ export type LoaderI18n<L extends string> = RequestI18n<L> & {
4
+ fetchTranslations<N extends Namespace>(locale: L, namespaces: readonly N[]): Promise<PickSchema<N>>;
5
+ };
6
+ export type LoadedTranslations<L extends string, N extends Namespace> = {
7
+ locale: L;
8
+ translations: PickSchema<N>;
9
+ };
10
+ /**
11
+ * The body of the app's translations server function: the request's locale (the same
12
+ * cascade as the middleware) and the namespaces asked for in it. The namespaces come from
13
+ * the browser: validate them with `isNamespaceList` before they reach this. Repeats are
14
+ * dropped here as well, for a caller that skips the validator.
15
+ */
16
+ export declare function createTranslationsLoader<L extends string>(i18n: LoaderI18n<L>, options?: RequestLocaleOptions): <N extends Namespace>(request: Request, namespaces: readonly N[]) => Promise<LoadedTranslations<L, N>>;
package/dist/loader.js ADDED
@@ -0,0 +1,13 @@
1
+ import { resolveRequestLocale } from "./request-locale.js";
2
+ /**
3
+ * The body of the app's translations server function: the request's locale (the same
4
+ * cascade as the middleware) and the namespaces asked for in it. The namespaces come from
5
+ * the browser: validate them with `isNamespaceList` before they reach this. Repeats are
6
+ * dropped here as well, for a caller that skips the validator.
7
+ */
8
+ export function createTranslationsLoader(i18n, options = {}) {
9
+ return async (request, namespaces) => {
10
+ const locale = await resolveRequestLocale(i18n, request, options);
11
+ return { locale, translations: await i18n.fetchTranslations(locale, [...new Set(namespaces)]) };
12
+ };
13
+ }
@@ -0,0 +1,15 @@
1
+ import type { Locale } from '@edgekits/i18n-core';
2
+ import type { MiddlewareHandler } from 'hono';
3
+ import { type RequestI18n, type RequestLocaleOptions } from './request-locale.ts';
4
+ /** The middleware's variables, for the app's `new Hono<{ Variables: I18nVariables & … }>()`. */
5
+ export type I18nVariables<L extends string = Locale> = {
6
+ locale: L;
7
+ };
8
+ /**
9
+ * Sets `c.var.locale` from the request (see `resolveRequestLocale`) and hands on. It never
10
+ * sets a cookie and never redirects: the URL carries no locale, and the lang cookie is
11
+ * written by the language switcher alone.
12
+ */
13
+ export declare function i18nMiddleware<L extends string>(i18n: RequestI18n<L>, options?: RequestLocaleOptions): MiddlewareHandler<{
14
+ Variables: I18nVariables<L>;
15
+ }>;
@@ -0,0 +1,12 @@
1
+ import { resolveRequestLocale } from "./request-locale.js";
2
+ /**
3
+ * Sets `c.var.locale` from the request (see `resolveRequestLocale`) and hands on. It never
4
+ * sets a cookie and never redirects: the URL carries no locale, and the lang cookie is
5
+ * written by the language switcher alone.
6
+ */
7
+ export function i18nMiddleware(i18n, options = {}) {
8
+ return async (c, next) => {
9
+ c.set('locale', await resolveRequestLocale(i18n, c.req.raw, options));
10
+ await next();
11
+ };
12
+ }
@@ -0,0 +1,15 @@
1
+ import type { Namespace } from '@edgekits/i18n-core';
2
+ /**
3
+ * The validator for a server function's namespace list: true only for a non-empty array of
4
+ * distinct strings, all in the generated `NAMESPACE_KEYS`, no longer than that list. The
5
+ * input comes from the browser, so on false the server function rejects:
6
+ * - an unknown namespace must never reach a KV key;
7
+ * - the bounds keep one request to what a page can need. The list becomes one batched
8
+ * KV read (a Workers KV batch takes at most 100 keys; a longer one fails and the
9
+ * request is served from the compiled fallbacks) and one cache lookup and write per
10
+ * entry, so repeats and oversized lists would multiply a request's work for nothing.
11
+ * An empty list asks for nothing.
12
+ */
13
+ export declare function isNamespaceList(i18n: {
14
+ readonly NAMESPACE_KEYS: readonly string[];
15
+ }): (data: unknown) => data is Namespace[];
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The validator for a server function's namespace list: true only for a non-empty array of
3
+ * distinct strings, all in the generated `NAMESPACE_KEYS`, no longer than that list. The
4
+ * input comes from the browser, so on false the server function rejects:
5
+ * - an unknown namespace must never reach a KV key;
6
+ * - the bounds keep one request to what a page can need. The list becomes one batched
7
+ * KV read (a Workers KV batch takes at most 100 keys; a longer one fails and the
8
+ * request is served from the compiled fallbacks) and one cache lookup and write per
9
+ * entry, so repeats and oversized lists would multiply a request's work for nothing.
10
+ * An empty list asks for nothing.
11
+ */
12
+ export function isNamespaceList(i18n) {
13
+ const known = new Set(i18n.NAMESPACE_KEYS);
14
+ return (data) => Array.isArray(data) &&
15
+ data.length > 0 &&
16
+ data.length <= known.size &&
17
+ new Set(data).size === data.length &&
18
+ data.every((ns) => typeof ns === 'string' && known.has(ns));
19
+ }
@@ -0,0 +1,2 @@
1
+ export { I18nProvider, useI18n, useT, type I18nCookie, type I18nLanguages, type I18nProviderProps, type NamespaceTranslations, } from './provider.tsx';
2
+ export { LanguageSwitcher, type LanguageSwitcherProps } from './language-switcher.tsx';
@@ -0,0 +1,4 @@
1
+ // `@edgekits/tanstack-i18n/react`: the provider a route's loader data feeds, the hooks over
2
+ // it and the headless language switcher. Client-safe: nothing here reaches KV.
3
+ export { I18nProvider, useI18n, useT, } from "./provider.js";
4
+ export { LanguageSwitcher } from "./language-switcher.js";
@@ -0,0 +1,14 @@
1
+ import type { Locale } from '@edgekits/i18n-core';
2
+ export type LanguageSwitcherProps = {
3
+ classes?: Partial<Record<'root' | 'trigger' | 'triggerIcon' | 'dropdown' | 'item' | 'itemActive' | 'itemLabel', string>>;
4
+ texts?: {
5
+ trigger?: string;
6
+ };
7
+ afterChange?: (locale: Locale) => unknown;
8
+ };
9
+ /**
10
+ * The languages of the root provider as a menu. Selecting one writes the lang cookie (the
11
+ * only place it is born; the middleware never sets it), then calls `afterChange` or
12
+ * reloads the page. Renders nothing with fewer than two languages.
13
+ */
14
+ export declare function LanguageSwitcher({ classes, texts, afterChange }: LanguageSwitcherProps): import("react").JSX.Element | null;
@@ -0,0 +1,54 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { useI18n, useI18nCookie } from "./provider.js";
4
+ const join = (...names) => names.filter(Boolean).join(' ') || undefined;
5
+ /**
6
+ * The languages of the root provider as a menu. Selecting one writes the lang cookie (the
7
+ * only place it is born; the middleware never sets it), then calls `afterChange` or
8
+ * reloads the page. Renders nothing with fewer than two languages.
9
+ */
10
+ export function LanguageSwitcher({ classes = {}, texts, afterChange }) {
11
+ const { locale, languages } = useI18n();
12
+ const cookie = useI18nCookie();
13
+ const [open, setOpen] = useState(false);
14
+ const root = useRef(null);
15
+ useEffect(() => {
16
+ if (!open)
17
+ return;
18
+ const onClick = (event) => {
19
+ if (!root.current?.contains(event.target))
20
+ setOpen(false);
21
+ };
22
+ const onKeyDown = (event) => {
23
+ if (event.key === 'Escape')
24
+ setOpen(false);
25
+ };
26
+ document.addEventListener('click', onClick);
27
+ document.addEventListener('keydown', onKeyDown);
28
+ return () => {
29
+ document.removeEventListener('click', onClick);
30
+ document.removeEventListener('keydown', onKeyDown);
31
+ };
32
+ }, [open]);
33
+ const entries = Object.entries(languages).flatMap(([code, language]) => (language ? [{ code, language }] : []));
34
+ if (entries.length < 2)
35
+ return null;
36
+ if (!cookie) {
37
+ throw new Error('[@edgekits/tanstack-i18n] LanguageSwitcher needs `cookie` on an I18nProvider above it');
38
+ }
39
+ const select = (next) => {
40
+ // Not httpOnly: the server reads the value through `parseLocale`, never trusts it.
41
+ document.cookie = `${cookie.name}=${next}; path=/; max-age=${cookie.maxAge}; SameSite=Lax; Secure`;
42
+ setOpen(false);
43
+ if (afterChange)
44
+ afterChange(next);
45
+ else
46
+ window.location.reload();
47
+ };
48
+ return (_jsxs("div", { ref: root, className: classes.root, "data-ls-root": "", "data-open": open ? 'true' : 'false', children: [_jsx("button", { type: "button", "data-ls-trigger": "", className: classes.trigger, "aria-haspopup": "true", "aria-expanded": open, "aria-label": texts?.trigger ?? 'Switch language', onClick: () => setOpen(!open), children: _jsxs("svg", { className: classes.triggerIcon, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1", "aria-hidden": "true", children: [_jsx("circle", { cx: "12", cy: "12", r: "9" }), _jsx("ellipse", { cx: "12", cy: "12", rx: "4", ry: "9" }), _jsx("path", { d: "M3 12h18" })] }) }), _jsx("div", { "data-ls-dropdown": "", className: classes.dropdown, role: "menu", hidden: !open, children: entries.map(({ code, language }) => {
49
+ const isActive = code === locale;
50
+ // The label is the language's own name: `lang` lets a screen reader say it in
51
+ // that language.
52
+ return (_jsx("button", { type: "button", className: join(classes.item, isActive && classes.itemActive), "data-ls-item": "", "data-lang": code, "data-active": isActive ? 'true' : undefined, "aria-current": isActive ? 'true' : undefined, role: "menuitem", disabled: isActive, onClick: () => select(code), children: _jsx("span", { className: classes.itemLabel, lang: code, children: language.name }) }, code));
53
+ }) })] }));
54
+ }
@@ -0,0 +1,28 @@
1
+ import type { I18nConfig, I18nLanguage, Locale, Namespace } from '@edgekits/i18n-core';
2
+ import { type ReactNode } from 'react';
3
+ export type I18nLanguages = Readonly<Partial<Record<Locale, I18nLanguage>>>;
4
+ export type I18nCookie = Readonly<I18nConfig['cookie']>;
5
+ export type NamespaceTranslations<N extends Namespace> = N extends keyof I18n.Schema ? I18n.Schema[N] : Record<string, unknown>;
6
+ type I18nContextValue = {
7
+ locale: Locale;
8
+ languages: I18nLanguages;
9
+ translations: Readonly<Record<string, unknown>>;
10
+ cookie: I18nCookie | undefined;
11
+ };
12
+ export type I18nProviderProps = {
13
+ locale?: Locale;
14
+ translations: Readonly<Record<string, unknown>>;
15
+ languages?: I18nLanguages;
16
+ cookie?: I18nCookie;
17
+ children?: ReactNode;
18
+ };
19
+ /**
20
+ * The translations a route's loader returned, for the components below it. Providers nest:
21
+ * the root route gives `common` (with `languages` and `cookie`), a page its own namespaces.
22
+ */
23
+ export declare function I18nProvider({ locale, translations, languages, cookie, children }: I18nProviderProps): import("react").JSX.Element;
24
+ export declare function useI18n(): Pick<I18nContextValue, 'locale' | 'languages' | 'translations'>;
25
+ export declare function useI18nCookie(): I18nCookie | undefined;
26
+ /** One namespace a route loader above this component has loaded. */
27
+ export declare function useT<N extends Namespace>(namespace: N): NamespaceTranslations<N>;
28
+ export {};
@@ -0,0 +1,43 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext, useMemo } from 'react';
3
+ const I18nContext = createContext(null);
4
+ /**
5
+ * The translations a route's loader returned, for the components below it. Providers nest:
6
+ * the root route gives `common` (with `languages` and `cookie`), a page its own namespaces.
7
+ */
8
+ export function I18nProvider({ locale, translations, languages, cookie, children }) {
9
+ const outer = useContext(I18nContext);
10
+ const resolvedLocale = locale ?? outer?.locale;
11
+ if (resolvedLocale === undefined) {
12
+ throw new Error('[@edgekits/tanstack-i18n] the outermost I18nProvider needs a `locale`');
13
+ }
14
+ const value = useMemo(() => ({
15
+ locale: resolvedLocale,
16
+ languages: languages ?? outer?.languages ?? {},
17
+ translations: outer ? { ...outer.translations, ...translations } : translations,
18
+ cookie: cookie ?? outer?.cookie,
19
+ }), [resolvedLocale, languages, cookie, translations, outer]);
20
+ return _jsx(I18nContext.Provider, { value: value, children: children });
21
+ }
22
+ function useI18nContext() {
23
+ const value = useContext(I18nContext);
24
+ if (!value)
25
+ throw new Error('[@edgekits/tanstack-i18n] no I18nProvider above this component');
26
+ return value;
27
+ }
28
+ export function useI18n() {
29
+ const { locale, languages, translations } = useI18nContext();
30
+ return { locale, languages, translations };
31
+ }
32
+ // The switcher's cookie settings; not part of `useI18n`.
33
+ export function useI18nCookie() {
34
+ return useI18nContext().cookie;
35
+ }
36
+ /** One namespace a route loader above this component has loaded. */
37
+ export function useT(namespace) {
38
+ const translations = useI18nContext().translations[namespace];
39
+ if (translations === undefined) {
40
+ throw new Error(`[@edgekits/tanstack-i18n] namespace "${namespace}" is not loaded by a route loader above this component`);
41
+ }
42
+ return translations;
43
+ }
@@ -0,0 +1,24 @@
1
+ import type { FallbackInputs } from '@edgekits/i18n-core';
2
+ /**
3
+ * What the server entry reads from the core's instance (`createI18n`), structurally: the
4
+ * locale type `L` is inferred from it, so a literal config types `c.var.locale` and the
5
+ * loader's `locale` with its own keys.
6
+ */
7
+ export type RequestI18n<L extends string> = {
8
+ readonly LANG_COOKIE_NAME: string;
9
+ parseLocale(value: unknown): L | null;
10
+ resolveFallbackLocale(inputs: FallbackInputs<L>): L;
11
+ };
12
+ export type RequestLocaleOptions = {
13
+ userPreference?: (request: Request) => string | null | undefined | Promise<string | null | undefined>;
14
+ };
15
+ /**
16
+ * The locale of a request, from the request alone: the middleware and the loader both call
17
+ * this, so Hono's chain and TanStack's server functions (which cannot reach Hono's context)
18
+ * give the same answer. The core's cascade without its country step: the lang cookie, then
19
+ * `userPreference`, then Accept-Language, then the default locale.
20
+ *
21
+ * `userPreference` may be async, the core's cascade is not: it is awaited here, and only
22
+ * when the cookie carries no valid locale.
23
+ */
24
+ export declare function resolveRequestLocale<L extends string>(i18n: RequestI18n<L>, request: Request, options?: RequestLocaleOptions): Promise<L>;
@@ -0,0 +1,21 @@
1
+ import { getCookie } from 'hono/cookie';
2
+ /**
3
+ * The locale of a request, from the request alone: the middleware and the loader both call
4
+ * this, so Hono's chain and TanStack's server functions (which cannot reach Hono's context)
5
+ * give the same answer. The core's cascade without its country step: the lang cookie, then
6
+ * `userPreference`, then Accept-Language, then the default locale.
7
+ *
8
+ * `userPreference` may be async, the core's cascade is not: it is awaited here, and only
9
+ * when the cookie carries no valid locale.
10
+ */
11
+ export async function resolveRequestLocale(i18n, request, options = {}) {
12
+ // `getCookie` reads `c.req.raw` only: the loader has a Request and no Hono context.
13
+ const cookieLocale = i18n.parseLocale(getCookie({ req: { raw: request } }, i18n.LANG_COOKIE_NAME));
14
+ const preferred = cookieLocale ? null : i18n.parseLocale((await options.userPreference?.(request)) ?? null);
15
+ return i18n.resolveFallbackLocale({
16
+ cookieLocale: () => cookieLocale,
17
+ userPreference: () => preferred,
18
+ acceptLanguage: () => request.headers.get('accept-language'),
19
+ country: () => undefined,
20
+ });
21
+ }
package/package.json CHANGED
@@ -1,6 +1,71 @@
1
1
  {
2
2
  "name": "@edgekits/tanstack-i18n",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.1",
4
+ "description": "Edge-native i18n for TanStack Start on Cloudflare Workers: a Hono locale middleware, a translations loader for server functions, and a React provider over the EdgeKits engine.",
5
+ "keywords": [
6
+ "tanstack",
7
+ "tanstack-start",
8
+ "react",
9
+ "hono",
10
+ "i18n",
11
+ "cloudflare",
12
+ "cloudflare-workers",
13
+ "kv",
14
+ "edge"
15
+ ],
16
+ "type": "module",
17
+ "license": "MIT",
18
+ "author": "Gary Stupak",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/EdgeKits/i18n.git",
22
+ "directory": "packages/tanstack"
23
+ },
24
+ "homepage": "https://github.com/EdgeKits/i18n/tree/main/packages/tanstack#readme",
25
+ "bugs": {
26
+ "url": "https://github.com/EdgeKits/i18n/issues"
27
+ },
28
+ "engines": {
29
+ "node": ">=22"
30
+ },
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.ts",
34
+ "default": "./dist/index.js"
35
+ },
36
+ "./react": {
37
+ "types": "./dist/react/index.d.ts",
38
+ "default": "./dist/react/index.js"
39
+ },
40
+ "./package.json": "./package.json"
41
+ },
42
+ "files": [
43
+ "dist",
44
+ "README.md",
45
+ "CHANGELOG.md",
46
+ "LICENSE"
47
+ ],
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "scripts": {
52
+ "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
53
+ "prepublishOnly": "npm run build",
54
+ "test": "node --import tsx --test test/**/*.test.ts test/**/*.test.tsx",
55
+ "typecheck": "tsc --noEmit"
56
+ },
57
+ "dependencies": {
58
+ "@edgekits/i18n-core": "0.2.1"
59
+ },
60
+ "peerDependencies": {
61
+ "hono": "^4",
62
+ "react": "^19"
63
+ },
64
+ "devDependencies": {
65
+ "@types/react": "^19.3.0",
66
+ "@types/react-dom": "^19.3.0",
67
+ "hono": "^4.13.14",
68
+ "react": "^19.3.1",
69
+ "react-dom": "^19.3.1"
70
+ }
71
+ }