@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 +19 -0
- package/LICENSE +21 -0
- package/README.md +249 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.js +5 -0
- package/dist/loader.d.ts +16 -0
- package/dist/loader.js +13 -0
- package/dist/middleware.d.ts +15 -0
- package/dist/middleware.js +12 -0
- package/dist/namespaces.d.ts +15 -0
- package/dist/namespaces.js +19 -0
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.js +4 -0
- package/dist/react/language-switcher.d.ts +14 -0
- package/dist/react/language-switcher.js +54 -0
- package/dist/react/provider.d.ts +28 -0
- package/dist/react/provider.js +43 -0
- package/dist/request-locale.d.ts +24 -0
- package/dist/request-locale.js +21 -0
- package/package.json +69 -4
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
|
-
#
|
|
1
|
+
# @edgekits/tanstack-i18n
|
|
2
2
|
|
|
3
|
-
|
|
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).
|
package/dist/index.d.ts
ADDED
|
@@ -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";
|
package/dist/loader.d.ts
ADDED
|
@@ -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,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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|