@edgekits/tanstack-i18n 0.2.1 → 0.3.0
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 +32 -0
- package/README.md +29 -4
- package/dist/loader.d.ts +6 -3
- package/dist/loader.js +10 -4
- package/dist/middleware.d.ts +3 -1
- package/dist/middleware.js +25 -1
- package/dist/react/language-switcher.js +1 -1
- package/dist/request-locale.d.ts +7 -1
- package/dist/request-locale.js +29 -2
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,38 @@ All notable changes to this package are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the package uses
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## 0.3.0 — 2026-10-28
|
|
8
|
+
|
|
9
|
+
### Upgrading from 0.2.x
|
|
10
|
+
|
|
11
|
+
- **The loader returns `{ locale, translationLocale, translations }`.** `locale` is the UI locale (`<html lang>`, the provider); the translations are read in `translationLocale` (the default locale for a locale without translations). A `LoaderI18n` you build yourself needs `resolveLocaleForTranslations`.
|
|
12
|
+
- **A throwing or rejecting `userPreference` no longer fails the request:** it counts as no preference and logs a `console.warn`; pass `onUserPreferenceError` to report it your way.
|
|
13
|
+
- **The switcher writes `Secure` only on https**, so the lang cookie works on `http://localhost`.
|
|
14
|
+
- **`.i18n-hashes.json` changes shape.** It now holds one baseline per KV namespace id (`{ "<kvId>": { "<locale>:<ns>": "<hash>" } }`). An old flat file is the baseline of the top-level `TRANSLATIONS` id (what 0.2.x wrote), whichever environment migrates first, and is rewritten in the new shape. Commit it as before.
|
|
15
|
+
- **The edge cache starts cold once.** Cache URLs carry the entry format (`https://<keyPrefix>/i18n:v2:<locale>:<ns>`, encoded); 0.2.x entries are never read again and expire on their own. Expect one KV read per namespace and data center right after the deploy.
|
|
16
|
+
- **`migrate` takes about a minute longer and can exit 1.** It purges, waits `--purge-delay` seconds (default 65) for KV to settle, and purges the same URLs again. It exits 1 when KV was updated but the edge cache was not purged (no `CLOUDFLARE_ZONE_ID` or token, or a failed purge); `.i18n-hashes.json` is then not written and the next run retries. A CI step that passed without purge credentials now fails.
|
|
17
|
+
- **Regenerate and commit the generated files** (`npx edgekits-i18n bundle --fallbacks`, or `seed`). `I18n.Schema` types numbers and booleans as such and a list of objects as one merged object type (a key every element has is required, any other optional, a key of differing types a union); a list that mixes strings and objects stops the run. A `null` value and a namespace named `locale` are rejected. Hashes of namespaces with mixed-case keys change once (code-point key order), so the first `migrate` purges those namespaces too.
|
|
18
|
+
- **`keyPrefix` must be a plain lower-case host** (`example.com`, `my-site`): no scheme, path, port syntax, space or upper case. `defineI18nConfig` throws otherwise.
|
|
19
|
+
- **The CLI is stricter.** An unknown argument (`-e staging`) exits 1 naming it; `--purge-delay` is at most 600; `CLOUDFLARE_ZONE_ID` must be 32 hex characters. With `--env <name>`, `.dev.vars.<name>` replaces `.dev.vars` when it exists, as in wrangler. `migrate` compares only the baseline of the KV namespace it writes with the committed file, so `migrate --env staging && migrate` runs in one job.
|
|
20
|
+
- **Node `>=22.23.4`.**
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- The loader fetches in `i18n.resolveLocaleForTranslations(locale)` and returns `translationLocale` beside `locale` and `translations`.
|
|
25
|
+
- A `userPreference` that throws or rejects falls through to Accept-Language.
|
|
26
|
+
- `userPreference` runs once per Request: the middleware and every loader of the request share its answer, and a failure is reported once.
|
|
27
|
+
- `i18nMiddleware` adds `Vary: Cookie, Accept-Language` to every HTML response (merged with its own `Vary`).
|
|
28
|
+
- `LanguageSwitcher` writes `Secure` only when `location.protocol` is `https:`.
|
|
29
|
+
- `engines.node` `>=22.23.4`; built on `@edgekits/i18n-core` 0.3.0 (raw KV values in the edge cache, the CLI changes above).
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- `onUserPreferenceError(error, request)` on `RequestLocaleOptions`; absent, one `console.warn` per failure, whatever `DEBUG_I18N` says.
|
|
34
|
+
|
|
35
|
+
## 0.2.2 — 2026-10-27
|
|
36
|
+
|
|
37
|
+
No changes; released in lockstep.
|
|
38
|
+
|
|
7
39
|
## 0.2.1 — 2026-10-27
|
|
8
40
|
|
|
9
41
|
No changes; released in lockstep with the plugin rename (see `@edgekits/astro-i18n` 0.2.1).
|
package/README.md
CHANGED
|
@@ -21,9 +21,11 @@ and TanStack agree. Nothing here sets a cookie or redirects; the switcher writes
|
|
|
21
21
|
npm install @edgekits/tanstack-i18n @edgekits/i18n-core
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
Peers: `hono` 4 and `react` 19. Write `edgekits-i18n.config.ts` and `locales/<locale>/<namespace>.json`
|
|
24
|
+
Requires Node `>=22.23.4`. Peers: `hono` 4 and `react` 19. Write `edgekits-i18n.config.ts` and `locales/<locale>/<namespace>.json`
|
|
25
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
|
|
26
|
+
describes, bind the `TRANSLATIONS` KV namespace in `wrangler.jsonc`, then generate and seed.
|
|
27
|
+
The CLI runs the Worker's own wrangler, found from the directory of that `wrangler.jsonc`:
|
|
28
|
+
install it in the Worker's package (`npm i -D wrangler`, `^4.152.0`); a global one is never used.
|
|
27
29
|
|
|
28
30
|
```sh
|
|
29
31
|
npx edgekits-i18n seed
|
|
@@ -85,6 +87,20 @@ export default app
|
|
|
85
87
|
Hand `c.req.raw` to TanStack unchanged: server functions live under `/_serverFn/`, and that
|
|
86
88
|
path is case-sensitive.
|
|
87
89
|
|
|
90
|
+
The same URL renders in whichever locale the cookie, the preference or `Accept-Language`
|
|
91
|
+
chose, so after `next()` the middleware adds `Vary: Cookie, Accept-Language` to every HTML
|
|
92
|
+
response (merged with a `Vary` it already has). Cloudflare's cache does not store a Worker's
|
|
93
|
+
HTML by default; a cache rule that does must bypass requests with the lang cookie (or key on
|
|
94
|
+
it) and on `Accept-Language`.
|
|
95
|
+
|
|
96
|
+
A `userPreference` that throws or rejects counts as no preference: the request resolves through
|
|
97
|
+
Accept-Language, and the error is logged once with `console.warn` (whatever `DEBUG_I18N` says: a
|
|
98
|
+
failing hook is an application error). To report it your own way, pass
|
|
99
|
+
`onUserPreferenceError: (error, request) => …` next to `userPreference`, to the middleware and
|
|
100
|
+
to the loader alike; it is called once per failure, instead of the warning. The hook runs once
|
|
101
|
+
per `Request`: the middleware and every loader of one request (the SSR document's server
|
|
102
|
+
function gets the same `Request`) share its answer, so a failure is reported once.
|
|
103
|
+
|
|
88
104
|
## 3. The server function: `src/i18n/server.ts`
|
|
89
105
|
|
|
90
106
|
```ts
|
|
@@ -155,6 +171,12 @@ function Root() {
|
|
|
155
171
|
The config and `localesOf` are pure, safe to import on the client. `languages` and `cookie` are
|
|
156
172
|
given once, here.
|
|
157
173
|
|
|
174
|
+
The loader returns `{ locale, translationLocale, translations }`. `locale` is the UI locale:
|
|
175
|
+
`<html lang>`, the provider and the switcher take it. `translationLocale` is the locale the
|
|
176
|
+
translations were read in: `locale` when it has translations (the generated
|
|
177
|
+
`LOCALES_WITH_TRANSLATIONS`), otherwise the default locale; compare the two to show a "not
|
|
178
|
+
translated yet" notice.
|
|
179
|
+
|
|
158
180
|
## 5. A page: `src/routes/index.tsx`
|
|
159
181
|
|
|
160
182
|
```tsx
|
|
@@ -210,7 +232,8 @@ Headless: a trigger button and one item per language (the label carries `lang`,
|
|
|
210
232
|
`aria-current`), class names only from `classes` (slots `root`, `trigger`, `triggerIcon`,
|
|
211
233
|
`dropdown`, `item`, `itemActive`, `itemLabel`), `data-ls-*` and `data-open` hooks for styling, no
|
|
212
234
|
styles. Escape and a click outside close it. Choosing a language writes the lang cookie
|
|
213
|
-
(`path=/`, `max-age` from `cookie.maxAge`, `SameSite=Lax
|
|
235
|
+
(`path=/`, `max-age` from `cookie.maxAge`, `SameSite=Lax`, and `Secure` on an https page), then
|
|
236
|
+
calls `afterChange`,
|
|
214
237
|
or reloads the page without one; `router.invalidate()` re-runs the loaders in place. With one
|
|
215
238
|
language it renders nothing.
|
|
216
239
|
|
|
@@ -230,7 +253,9 @@ declare module '@edgekits/i18n-core' {
|
|
|
230
253
|
}
|
|
231
254
|
```
|
|
232
255
|
|
|
233
|
-
`c.var.locale`
|
|
256
|
+
`c.var.locale` has the type you give `I18nVariables` in the app's Hono generic: `I18nVariables`
|
|
257
|
+
alone is `Locale` (`string`, or the union this declaration lists), and
|
|
258
|
+
`I18nVariables<'en' | 'de' | 'es'>` names the locales directly.
|
|
234
259
|
|
|
235
260
|
## Monorepo
|
|
236
261
|
|
package/dist/loader.d.ts
CHANGED
|
@@ -2,15 +2,18 @@ import type { Namespace, PickSchema } from '@edgekits/i18n-core';
|
|
|
2
2
|
import { type RequestI18n, type RequestLocaleOptions } from './request-locale.ts';
|
|
3
3
|
export type LoaderI18n<L extends string> = RequestI18n<L> & {
|
|
4
4
|
fetchTranslations<N extends Namespace>(locale: L, namespaces: readonly N[]): Promise<PickSchema<N>>;
|
|
5
|
+
resolveLocaleForTranslations(locale: L): L;
|
|
5
6
|
};
|
|
6
7
|
export type LoadedTranslations<L extends string, N extends Namespace> = {
|
|
7
8
|
locale: L;
|
|
9
|
+
translationLocale: L;
|
|
8
10
|
translations: PickSchema<N>;
|
|
9
11
|
};
|
|
10
12
|
/**
|
|
11
13
|
* 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
|
|
13
|
-
* the browser: validate them with
|
|
14
|
-
* dropped here as well, for a caller
|
|
14
|
+
* cascade as the middleware) and the namespaces asked for in it, read in that locale's
|
|
15
|
+
* translation locale. The namespaces come from the browser: validate them with
|
|
16
|
+
* `isNamespaceList` before they reach this. Repeats are dropped here as well, for a caller
|
|
17
|
+
* that skips the validator.
|
|
15
18
|
*/
|
|
16
19
|
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
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import { resolveRequestLocale } from "./request-locale.js";
|
|
2
2
|
/**
|
|
3
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
|
|
5
|
-
* the browser: validate them with
|
|
6
|
-
* dropped here as well, for a caller
|
|
4
|
+
* cascade as the middleware) and the namespaces asked for in it, read in that locale's
|
|
5
|
+
* translation locale. The namespaces come from the browser: validate them with
|
|
6
|
+
* `isNamespaceList` before they reach this. Repeats are dropped here as well, for a caller
|
|
7
|
+
* that skips the validator.
|
|
7
8
|
*/
|
|
8
9
|
export function createTranslationsLoader(i18n, options = {}) {
|
|
9
10
|
return async (request, namespaces) => {
|
|
10
11
|
const locale = await resolveRequestLocale(i18n, request, options);
|
|
11
|
-
|
|
12
|
+
const translationLocale = i18n.resolveLocaleForTranslations(locale);
|
|
13
|
+
return {
|
|
14
|
+
locale,
|
|
15
|
+
translationLocale,
|
|
16
|
+
translations: await i18n.fetchTranslations(translationLocale, [...new Set(namespaces)]),
|
|
17
|
+
};
|
|
12
18
|
};
|
|
13
19
|
}
|
package/dist/middleware.d.ts
CHANGED
|
@@ -8,7 +8,9 @@ export type I18nVariables<L extends string = Locale> = {
|
|
|
8
8
|
/**
|
|
9
9
|
* Sets `c.var.locale` from the request (see `resolveRequestLocale`) and hands on. It never
|
|
10
10
|
* sets a cookie and never redirects: the URL carries no locale, and the lang cookie is
|
|
11
|
-
* written by the language switcher alone.
|
|
11
|
+
* written by the language switcher alone. An HTML response comes back with
|
|
12
|
+
* `Vary: Cookie, Accept-Language` (merged with its own `Vary`): the same URL renders in
|
|
13
|
+
* whichever locale those headers chose.
|
|
12
14
|
*/
|
|
13
15
|
export declare function i18nMiddleware<L extends string>(i18n: RequestI18n<L>, options?: RequestLocaleOptions): MiddlewareHandler<{
|
|
14
16
|
Variables: I18nVariables<L>;
|
package/dist/middleware.js
CHANGED
|
@@ -1,12 +1,36 @@
|
|
|
1
1
|
import { resolveRequestLocale } from "./request-locale.js";
|
|
2
|
+
// The request headers an HTML page's locale is chosen from (`userPreference` reads whatever
|
|
3
|
+
// the app keys it on, usually a session cookie).
|
|
4
|
+
const NEGOTIATED = ['Cookie', 'Accept-Language'];
|
|
2
5
|
/**
|
|
3
6
|
* Sets `c.var.locale` from the request (see `resolveRequestLocale`) and hands on. It never
|
|
4
7
|
* sets a cookie and never redirects: the URL carries no locale, and the lang cookie is
|
|
5
|
-
* written by the language switcher alone.
|
|
8
|
+
* written by the language switcher alone. An HTML response comes back with
|
|
9
|
+
* `Vary: Cookie, Accept-Language` (merged with its own `Vary`): the same URL renders in
|
|
10
|
+
* whichever locale those headers chose.
|
|
6
11
|
*/
|
|
7
12
|
export function i18nMiddleware(i18n, options = {}) {
|
|
8
13
|
return async (c, next) => {
|
|
9
14
|
c.set('locale', await resolveRequestLocale(i18n, c.req.raw, options));
|
|
10
15
|
await next();
|
|
16
|
+
if (!(c.res.headers.get('Content-Type') ?? '').includes('text/html'))
|
|
17
|
+
return;
|
|
18
|
+
try {
|
|
19
|
+
varyOnNegotiated(c.res.headers);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// Immutable headers (a response passed through from fetch()): vary on a copy.
|
|
23
|
+
c.res = new Response(c.res.body, c.res);
|
|
24
|
+
varyOnNegotiated(c.res.headers);
|
|
25
|
+
}
|
|
11
26
|
};
|
|
12
27
|
}
|
|
28
|
+
function varyOnNegotiated(headers) {
|
|
29
|
+
const vary = headers.get('Vary');
|
|
30
|
+
const fields = (vary ?? '').split(',').map((field) => field.trim().toLowerCase());
|
|
31
|
+
if (fields.includes('*'))
|
|
32
|
+
return;
|
|
33
|
+
const missing = NEGOTIATED.filter((name) => !fields.includes(name.toLowerCase()));
|
|
34
|
+
if (missing.length > 0)
|
|
35
|
+
headers.set('Vary', [...(vary ? [vary] : []), ...missing].join(', '));
|
|
36
|
+
}
|
|
@@ -38,7 +38,7 @@ export function LanguageSwitcher({ classes = {}, texts, afterChange }) {
|
|
|
38
38
|
}
|
|
39
39
|
const select = (next) => {
|
|
40
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`;
|
|
41
|
+
document.cookie = `${cookie.name}=${next}; path=/; max-age=${cookie.maxAge}; SameSite=Lax${location.protocol === 'https:' ? '; Secure' : ''}`;
|
|
42
42
|
setOpen(false);
|
|
43
43
|
if (afterChange)
|
|
44
44
|
afterChange(next);
|
package/dist/request-locale.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export type RequestI18n<L extends string> = {
|
|
|
11
11
|
};
|
|
12
12
|
export type RequestLocaleOptions = {
|
|
13
13
|
userPreference?: (request: Request) => string | null | undefined | Promise<string | null | undefined>;
|
|
14
|
+
onUserPreferenceError?: (error: unknown, request: Request) => void;
|
|
14
15
|
};
|
|
15
16
|
/**
|
|
16
17
|
* The locale of a request, from the request alone: the middleware and the loader both call
|
|
@@ -19,6 +20,11 @@ export type RequestLocaleOptions = {
|
|
|
19
20
|
* `userPreference`, then Accept-Language, then the default locale.
|
|
20
21
|
*
|
|
21
22
|
* `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
|
+
* when the cookie carries no valid locale. A hook that throws or rejects counts as no
|
|
24
|
+
* preference (see `onUserPreferenceError`).
|
|
25
|
+
*
|
|
26
|
+
* The hook runs once per Request: the middleware and every loader of one request (the SSR
|
|
27
|
+
* document's server functions get the same Request through getRequest()) share its answer,
|
|
28
|
+
* and a failure is reported once. The first caller's options decide.
|
|
23
29
|
*/
|
|
24
30
|
export declare function resolveRequestLocale<L extends string>(i18n: RequestI18n<L>, request: Request, options?: RequestLocaleOptions): Promise<L>;
|
package/dist/request-locale.js
CHANGED
|
@@ -6,12 +6,17 @@ import { getCookie } from 'hono/cookie';
|
|
|
6
6
|
* `userPreference`, then Accept-Language, then the default locale.
|
|
7
7
|
*
|
|
8
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.
|
|
9
|
+
* when the cookie carries no valid locale. A hook that throws or rejects counts as no
|
|
10
|
+
* preference (see `onUserPreferenceError`).
|
|
11
|
+
*
|
|
12
|
+
* The hook runs once per Request: the middleware and every loader of one request (the SSR
|
|
13
|
+
* document's server functions get the same Request through getRequest()) share its answer,
|
|
14
|
+
* and a failure is reported once. The first caller's options decide.
|
|
10
15
|
*/
|
|
11
16
|
export async function resolveRequestLocale(i18n, request, options = {}) {
|
|
12
17
|
// `getCookie` reads `c.req.raw` only: the loader has a Request and no Hono context.
|
|
13
18
|
const cookieLocale = i18n.parseLocale(getCookie({ req: { raw: request } }, i18n.LANG_COOKIE_NAME));
|
|
14
|
-
const preferred = cookieLocale ? null : i18n.parseLocale((await
|
|
19
|
+
const preferred = cookieLocale ? null : i18n.parseLocale((await readUserPreference(request, options)) ?? null);
|
|
15
20
|
return i18n.resolveFallbackLocale({
|
|
16
21
|
cookieLocale: () => cookieLocale,
|
|
17
22
|
userPreference: () => preferred,
|
|
@@ -19,3 +24,25 @@ export async function resolveRequestLocale(i18n, request, options = {}) {
|
|
|
19
24
|
country: () => undefined,
|
|
20
25
|
});
|
|
21
26
|
}
|
|
27
|
+
// One `userPreference` answer per Request, kept while the Request lives.
|
|
28
|
+
const preferences = new WeakMap();
|
|
29
|
+
function readUserPreference(request, options) {
|
|
30
|
+
let preference = preferences.get(request);
|
|
31
|
+
if (!preference) {
|
|
32
|
+
preference = callUserPreference(request, options);
|
|
33
|
+
preferences.set(request, preference);
|
|
34
|
+
}
|
|
35
|
+
return preference;
|
|
36
|
+
}
|
|
37
|
+
async function callUserPreference(request, options) {
|
|
38
|
+
try {
|
|
39
|
+
return await options.userPreference?.(request);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (options.onUserPreferenceError)
|
|
43
|
+
options.onUserPreferenceError(error, request);
|
|
44
|
+
else
|
|
45
|
+
console.warn('[@edgekits/tanstack-i18n] userPreference failed; falling back to Accept-Language', error);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgekits/tanstack-i18n",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
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
5
|
"keywords": [
|
|
6
6
|
"tanstack",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"url": "https://github.com/EdgeKits/i18n/issues"
|
|
27
27
|
},
|
|
28
28
|
"engines": {
|
|
29
|
-
"node": ">=22"
|
|
29
|
+
"node": ">=22.23.4"
|
|
30
30
|
},
|
|
31
31
|
"exports": {
|
|
32
32
|
".": {
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"typecheck": "tsc --noEmit"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@edgekits/i18n-core": "0.
|
|
58
|
+
"@edgekits/i18n-core": "0.3.0"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
61
|
"hono": "^4",
|