@k8ordo/i18n 0.1.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/LICENSE +21 -0
- package/README.md +120 -0
- package/dist/accept-language.d.mts +11 -0
- package/dist/accept-language.mjs +31 -0
- package/dist/current.d.mts +47 -0
- package/dist/current.mjs +47 -0
- package/dist/index.d.mts +5 -0
- package/dist/index.mjs +4 -0
- package/dist/locales.d.mts +103 -0
- package/dist/locales.mjs +108 -0
- package/dist/message.d.mts +31 -0
- package/dist/message.mjs +13 -0
- package/dist/register.d.mts +23 -0
- package/dist/register.mjs +1 -0
- package/docs/GUIDE.md +301 -0
- package/docs/llms.txt +44 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Koki Sakano
|
|
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
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# @k8ordo/i18n
|
|
2
|
+
|
|
3
|
+
The locale axis of an application, owned. One locale set declares which
|
|
4
|
+
locales exist and which is the default, and derives the URL segment, the
|
|
5
|
+
params schema of the `[locale]` route, negotiation from `navigator.languages`
|
|
6
|
+
or `Accept-Language`, and the static build's path list. Each message is a
|
|
7
|
+
function — `message({ ja: 'ホーム', en: 'Home' })` — that reads the locale
|
|
8
|
+
where it is called: the request on the server, the URL in the browser. So the
|
|
9
|
+
same line renders in a Server Component and in a Client Component, there is
|
|
10
|
+
no provider and no hook, and a bundler keeps only the messages a client module
|
|
11
|
+
names. No message grammar: interpolation is a template literal and plurals are
|
|
12
|
+
`Intl.PluralRules`.
|
|
13
|
+
|
|
14
|
+
Like every [k8ordo](https://ordo.k8o.me) package it assumes React 19 and Server
|
|
15
|
+
Components, uses only what has reached Baseline newly available, and ships no
|
|
16
|
+
polyfills or legacy fallbacks.
|
|
17
|
+
|
|
18
|
+
- **Documentation**: https://ordo.k8o.me/i18n
|
|
19
|
+
- **Design guide**: [docs/GUIDE.md](docs/GUIDE.md) — shipped inside this package
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install @k8ordo/i18n
|
|
25
|
+
# or
|
|
26
|
+
pnpm add @k8ordo/i18n
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Peer Dependencies
|
|
30
|
+
|
|
31
|
+
| Package | Version | Needed for |
|
|
32
|
+
| ------------ | ------- | ----------------------------- |
|
|
33
|
+
| `typescript` | ≥7.0.2 | the shipped type declarations |
|
|
34
|
+
|
|
35
|
+
No React import and no schema library: a message is a plain function, and a
|
|
36
|
+
locale set is a list that checks membership itself. `locales.paramsSchema`
|
|
37
|
+
speaks Standard Schema, which is what `@k8ordo/static` and `@k8ordo/server`
|
|
38
|
+
run for a route's params. On a server the current locale rides on
|
|
39
|
+
`AsyncLocalStorage` (Node ≥ 24).
|
|
40
|
+
|
|
41
|
+
## Quick Start
|
|
42
|
+
|
|
43
|
+
The locale set in its own module, registered once so every message is held
|
|
44
|
+
to it:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
// i18n.ts
|
|
48
|
+
import { defineLocales } from '@k8ordo/i18n';
|
|
49
|
+
import type { LocaleOf } from '@k8ordo/i18n';
|
|
50
|
+
|
|
51
|
+
export const locales = defineLocales(['ja', 'en']);
|
|
52
|
+
|
|
53
|
+
declare module '@k8ordo/i18n' {
|
|
54
|
+
interface Register {
|
|
55
|
+
locale: LocaleOf<typeof locales>;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Each message is one export, with its text in every locale:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
// messages/nav.ts
|
|
64
|
+
import { message } from '@k8ordo/i18n';
|
|
65
|
+
|
|
66
|
+
export const home = message({ ja: 'ホーム', en: 'Home' });
|
|
67
|
+
export const greeting = message({
|
|
68
|
+
ja: (name: string) => `こんにちは、${name}さん`,
|
|
69
|
+
en: (name) => `Hello, ${name}`, // the arguments are typed by ja
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The `[locale]` route's layout declares the schema; accepting a locale makes it
|
|
74
|
+
the locale of that render:
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
// routes/[locale]/layout.tsx
|
|
78
|
+
import { locales } from '../../i18n';
|
|
79
|
+
|
|
80
|
+
export const paramsSchema = locales.paramsSchema; // /fr/… is a 404
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
And a message is called the same way everywhere:
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
// routes/[locale]/page.tsx — a Server Component
|
|
87
|
+
import * as nav from '../../messages/nav';
|
|
88
|
+
|
|
89
|
+
export default function Page() {
|
|
90
|
+
return <h1>{nav.home()}</h1>;
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
// components/toolbar.tsx — a Client Component, the same line
|
|
96
|
+
'use client';
|
|
97
|
+
import * as nav from '../messages/nav';
|
|
98
|
+
|
|
99
|
+
export function Toolbar() {
|
|
100
|
+
return <button>{nav.greeting('k8o')}</button>;
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Where a Server Component hands text to a Client Component as a prop, it calls
|
|
105
|
+
the message and passes the string: a function does not cross that boundary.
|
|
106
|
+
|
|
107
|
+
`/` negotiates and redirects; `<html lang>` and a language switcher read
|
|
108
|
+
`locales.getLocale()`; a static build expands `[locale]` from `locales.all`.
|
|
109
|
+
|
|
110
|
+
## AI Agent Documentation
|
|
111
|
+
|
|
112
|
+
This package ships its documentation so an AI coding assistant reads the exact
|
|
113
|
+
installed version:
|
|
114
|
+
|
|
115
|
+
- `node_modules/@k8ordo/i18n/docs/GUIDE.md` — the design guide
|
|
116
|
+
- `node_modules/@k8ordo/i18n/docs/llms.txt` — the index
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region src/accept-language.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The tags of an `Accept-Language` header, most preferred first: `q` weights
|
|
4
|
+
* decide the order (a tag without one weighs 1), ties keep the header's
|
|
5
|
+
* order, `q=0` and the `*` wildcard are dropped — the wildcard says "anything",
|
|
6
|
+
* which is what the default locale already means. Feed the result to
|
|
7
|
+
* `locales.negotiate`; a missing header (`null`) is an empty list.
|
|
8
|
+
*/
|
|
9
|
+
declare const parseAcceptLanguage: (header: string | null) => string[];
|
|
10
|
+
//#endregion
|
|
11
|
+
export { parseAcceptLanguage };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
//#region src/accept-language.ts
|
|
2
|
+
/**
|
|
3
|
+
* The tags of an `Accept-Language` header, most preferred first: `q` weights
|
|
4
|
+
* decide the order (a tag without one weighs 1), ties keep the header's
|
|
5
|
+
* order, `q=0` and the `*` wildcard are dropped — the wildcard says "anything",
|
|
6
|
+
* which is what the default locale already means. Feed the result to
|
|
7
|
+
* `locales.negotiate`; a missing header (`null`) is an empty list.
|
|
8
|
+
*/
|
|
9
|
+
const parseAcceptLanguage = (header) => {
|
|
10
|
+
if (header === null || header.trim() === "") return [];
|
|
11
|
+
return header.split(",").flatMap((part, index) => {
|
|
12
|
+
const [rawTag = "", ...params] = part.split(";");
|
|
13
|
+
const tag = rawTag.trim();
|
|
14
|
+
if (tag === "" || tag === "*") return [];
|
|
15
|
+
let quality = 1;
|
|
16
|
+
for (const param of params) {
|
|
17
|
+
const [name, value] = param.split("=");
|
|
18
|
+
if (name?.trim().toLowerCase() === "q" && value !== void 0) {
|
|
19
|
+
const parsed = Number(value.trim());
|
|
20
|
+
if (!Number.isNaN(parsed)) quality = parsed;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return quality > 0 ? [{
|
|
24
|
+
tag,
|
|
25
|
+
quality,
|
|
26
|
+
index
|
|
27
|
+
}] : [];
|
|
28
|
+
}).toSorted((a, b) => b.quality - a.quality || a.index - b.index).map((entry) => entry.tag);
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
31
|
+
export { parseAcceptLanguage };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
//#region src/current.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Where the current locale is kept, on each side of the network.
|
|
4
|
+
*
|
|
5
|
+
* On a server the request that `paramsSchema` accepted (or `locales.run`)
|
|
6
|
+
* names it, and it is carried by `AsyncLocalStorage` so concurrent renders
|
|
7
|
+
* stay apart. The RSC environment and the SSR environment (where client
|
|
8
|
+
* components run on the server) are separate module graphs in one process,
|
|
9
|
+
* so the storage — and the locale set that registered — live on
|
|
10
|
+
* `globalThis`, not in this module.
|
|
11
|
+
*
|
|
12
|
+
* In the browser the URL is the locale: its first segment, read when asked.
|
|
13
|
+
*/
|
|
14
|
+
type LocaleStorage = {
|
|
15
|
+
getStore: () => string | undefined;
|
|
16
|
+
enterWith: (locale: string) => void;
|
|
17
|
+
run: <T>(locale: string, fn: () => T) => T;
|
|
18
|
+
};
|
|
19
|
+
/** What `message()` needs from the locale set: the default, and membership. */
|
|
20
|
+
type RegisteredSet = {
|
|
21
|
+
readonly default: string;
|
|
22
|
+
readonly is: (value: unknown) => boolean;
|
|
23
|
+
};
|
|
24
|
+
declare const inBrowser: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Reached through `process.getBuiltinModule` rather than an import so the
|
|
27
|
+
* same build runs in the browser, where there is no request to scope to.
|
|
28
|
+
*/
|
|
29
|
+
declare const localeStorage: () => LocaleStorage | null;
|
|
30
|
+
/**
|
|
31
|
+
* The set messages read is the last one defined; an application has one.
|
|
32
|
+
* Last rather than first so that a dev server re-evaluating the module that
|
|
33
|
+
* defines the set (a locale added under HMR) is what messages see next.
|
|
34
|
+
*/
|
|
35
|
+
declare const register: (set: RegisteredSet) => void;
|
|
36
|
+
/** Whether a set has registered in this environment yet. */
|
|
37
|
+
declare const setRegistered: () => boolean;
|
|
38
|
+
/**
|
|
39
|
+
* The current locale as a string, or `null` when nothing names one: no
|
|
40
|
+
* segment in the URL, no request in progress. A registered set makes an
|
|
41
|
+
* unknown segment `null` too, so `/fr/…` does not read as a locale.
|
|
42
|
+
*/
|
|
43
|
+
declare const currentLocale: () => string | null;
|
|
44
|
+
/** The registered default, when a set registered in this environment. */
|
|
45
|
+
declare const registeredDefault: () => string | null;
|
|
46
|
+
//#endregion
|
|
47
|
+
export { LocaleStorage, RegisteredSet, currentLocale, inBrowser, localeStorage, register, registeredDefault, setRegistered };
|
package/dist/current.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
//#region src/current.ts
|
|
2
|
+
const STORAGE_KEY = Symbol.for("@k8ordo/i18n/storage");
|
|
3
|
+
const SET_KEY = Symbol.for("@k8ordo/i18n/locales");
|
|
4
|
+
const global = globalThis;
|
|
5
|
+
const inBrowser = typeof document !== "undefined";
|
|
6
|
+
/**
|
|
7
|
+
* Reached through `process.getBuiltinModule` rather than an import so the
|
|
8
|
+
* same build runs in the browser, where there is no request to scope to.
|
|
9
|
+
*/
|
|
10
|
+
const localeStorage = () => {
|
|
11
|
+
const existing = global[STORAGE_KEY];
|
|
12
|
+
if (existing !== void 0) return existing;
|
|
13
|
+
const getBuiltinModule = global.process?.getBuiltinModule;
|
|
14
|
+
if (typeof getBuiltinModule !== "function") return null;
|
|
15
|
+
const { AsyncLocalStorage } = getBuiltinModule("node:async_hooks");
|
|
16
|
+
const created = new AsyncLocalStorage();
|
|
17
|
+
global[STORAGE_KEY] = created;
|
|
18
|
+
return created;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* The set messages read is the last one defined; an application has one.
|
|
22
|
+
* Last rather than first so that a dev server re-evaluating the module that
|
|
23
|
+
* defines the set (a locale added under HMR) is what messages see next.
|
|
24
|
+
*/
|
|
25
|
+
const register = (set) => {
|
|
26
|
+
global[SET_KEY] = set;
|
|
27
|
+
};
|
|
28
|
+
/** The first segment of the browser's URL, whatever it spells. */
|
|
29
|
+
const browserSegment = () => global.location?.pathname.split("/")[1] ?? "";
|
|
30
|
+
/** Whether a set has registered in this environment yet. */
|
|
31
|
+
const setRegistered = () => global[SET_KEY] !== void 0;
|
|
32
|
+
/**
|
|
33
|
+
* The current locale as a string, or `null` when nothing names one: no
|
|
34
|
+
* segment in the URL, no request in progress. A registered set makes an
|
|
35
|
+
* unknown segment `null` too, so `/fr/…` does not read as a locale.
|
|
36
|
+
*/
|
|
37
|
+
const currentLocale = () => {
|
|
38
|
+
const set = global[SET_KEY];
|
|
39
|
+
const named = inBrowser ? browserSegment() : localeStorage()?.getStore();
|
|
40
|
+
if (named === void 0 || named === "") return null;
|
|
41
|
+
if (set !== void 0 && !set.is(named)) return null;
|
|
42
|
+
return named;
|
|
43
|
+
};
|
|
44
|
+
/** The registered default, when a set registered in this environment. */
|
|
45
|
+
const registeredDefault = () => global[SET_KEY]?.default ?? null;
|
|
46
|
+
//#endregion
|
|
47
|
+
export { currentLocale, inBrowser, localeStorage, register, registeredDefault, setRegistered };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { parseAcceptLanguage } from "./accept-language.mjs";
|
|
2
|
+
import { Delocalized, LocaleOf, LocaleParamsSchema, Locales, LocalesOptions, defineLocales } from "./locales.mjs";
|
|
3
|
+
import { Register, RegisteredLocale } from "./register.mjs";
|
|
4
|
+
import { Message, Variants, message } from "./message.mjs";
|
|
5
|
+
export { type Delocalized, type LocaleOf, type LocaleParamsSchema, type Locales, type LocalesOptions, type Message, type Register, type RegisteredLocale, type Variants, defineLocales, message, parseAcceptLanguage };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
//#region src/locales.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The one shape every validation library agrees on (Standard Schema), as far
|
|
4
|
+
* as `@k8ordo/static` / `@k8ordo/server` read it for a route's `paramsSchema`.
|
|
5
|
+
* Declared here rather than depended on so the package carries no schema
|
|
6
|
+
* library: a locale set is a list, and a list can check membership itself.
|
|
7
|
+
*/
|
|
8
|
+
type LocaleParamsSchema<L extends string> = {
|
|
9
|
+
readonly '~standard': {
|
|
10
|
+
readonly version: 1;
|
|
11
|
+
readonly vendor: '@k8ordo/i18n';
|
|
12
|
+
readonly validate: (value: unknown) => {
|
|
13
|
+
readonly value: {
|
|
14
|
+
readonly locale: L;
|
|
15
|
+
};
|
|
16
|
+
readonly issues?: undefined;
|
|
17
|
+
} | {
|
|
18
|
+
readonly issues: ReadonlyArray<{
|
|
19
|
+
readonly message: string;
|
|
20
|
+
readonly path: readonly ['locale'];
|
|
21
|
+
}>;
|
|
22
|
+
};
|
|
23
|
+
readonly types?: {
|
|
24
|
+
readonly input: {
|
|
25
|
+
readonly locale: string;
|
|
26
|
+
};
|
|
27
|
+
readonly output: {
|
|
28
|
+
readonly locale: L;
|
|
29
|
+
};
|
|
30
|
+
} | undefined;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
type Delocalized<L extends string> = {
|
|
34
|
+
/** The locale the first segment spelled, or `null` when it spelled none. */
|
|
35
|
+
readonly locale: L | null;
|
|
36
|
+
/** The pathname with that segment removed — never empty, `/` at least. */
|
|
37
|
+
readonly pathname: string;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* An application's locale set. `L` is the union of its tags, `D` the default
|
|
41
|
+
* among them.
|
|
42
|
+
*/
|
|
43
|
+
type Locales<L extends string = string, D extends L = L> = {
|
|
44
|
+
/** Every locale, in the order given; the first is the default unless told otherwise. */
|
|
45
|
+
readonly all: readonly L[];
|
|
46
|
+
/** The locale used when negotiation finds nothing better, and when nothing names one. */
|
|
47
|
+
readonly default: D;
|
|
48
|
+
/** Membership, as a type guard — the check `params.locale` and a pathname segment go through. */
|
|
49
|
+
readonly is: (value: unknown) => value is L;
|
|
50
|
+
/**
|
|
51
|
+
* The best supported locale for a preference list — `navigator.languages`
|
|
52
|
+
* in the browser, `parseAcceptLanguage(header)` on a server. Each requested
|
|
53
|
+
* tag is tried in order — the exact tag, then the first supported locale
|
|
54
|
+
* that speaks its language — and nothing matching is the default. A tag
|
|
55
|
+
* that is not BCP 47 is skipped, not thrown on: the list is user input.
|
|
56
|
+
*/
|
|
57
|
+
readonly negotiate: (requested: Iterable<string>) => L;
|
|
58
|
+
/** `'/ui'` → `'/en/ui'`, `'/'` → `'/en'`. The pathname must not already carry a locale segment. */
|
|
59
|
+
readonly localize: (pathname: string, locale: L) => string;
|
|
60
|
+
/** The inverse: `'/en/ui'` → `{ locale: 'en', pathname: '/ui' }`, `'/x'` → `{ locale: null, pathname: '/x' }`. */
|
|
61
|
+
readonly delocalize: (pathname: string) => Delocalized<L>;
|
|
62
|
+
/**
|
|
63
|
+
* A params schema for a `[locale]` route segment, in the shape
|
|
64
|
+
* `@k8ordo/static` / `@k8ordo/server` run: `export const paramsSchema =
|
|
65
|
+
* locales.paramsSchema` makes `/fr/…` a pathname the pattern does not
|
|
66
|
+
* answer. Accepting a locale also makes it the current one for the rest of
|
|
67
|
+
* that request's render, which is how `getLocale()` knows it on the server.
|
|
68
|
+
*/
|
|
69
|
+
readonly paramsSchema: LocaleParamsSchema<L>;
|
|
70
|
+
/**
|
|
71
|
+
* The locale of the render in progress. In the browser it is the first
|
|
72
|
+
* segment of `location.pathname`; on the server it is what `paramsSchema`
|
|
73
|
+
* accepted for this request, or what `run` set. Neither names one → the
|
|
74
|
+
* default. Not a hook: call it anywhere, including inside a message.
|
|
75
|
+
*/
|
|
76
|
+
readonly getLocale: () => L;
|
|
77
|
+
/**
|
|
78
|
+
* Runs `fn` with `locale` as the current one for everything it starts —
|
|
79
|
+
* a test, a Server Action, code outside a `[locale]` route. Server only:
|
|
80
|
+
* in the browser the URL is the locale.
|
|
81
|
+
*/
|
|
82
|
+
readonly run: <T>(locale: L, fn: () => T) => T;
|
|
83
|
+
};
|
|
84
|
+
type LocalesOptions<D extends string> = {
|
|
85
|
+
/** The default locale. Must be one of the list; the first entry when omitted. */
|
|
86
|
+
readonly default?: D;
|
|
87
|
+
};
|
|
88
|
+
/** The locale union of a `Locales` — `LocaleOf<typeof locales>`. */
|
|
89
|
+
type LocaleOf<Ls> = Ls extends Locales<infer L, infer _D> ? L : never;
|
|
90
|
+
/**
|
|
91
|
+
* Declares the locale set once. Everything that needs to know which locales
|
|
92
|
+
* exist — the params schema of the `[locale]` segment, the static build's
|
|
93
|
+
* path expansion, the language switcher, negotiation for a `/` redirect,
|
|
94
|
+
* every message — reads this value, so the list is spelled in one place.
|
|
95
|
+
*
|
|
96
|
+
* ```ts
|
|
97
|
+
* export const locales = defineLocales(['ja', 'en']);
|
|
98
|
+
* export const locales = defineLocales(['en-US', 'en-GB', 'ja'], { default: 'ja' });
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
declare const defineLocales: <const All extends readonly [string, ...string[]], D extends All[number] = All[0]>(all: All, options?: LocalesOptions<D>) => Locales<All[number], D>;
|
|
102
|
+
//#endregion
|
|
103
|
+
export { Delocalized, LocaleOf, LocaleParamsSchema, Locales, LocalesOptions, defineLocales };
|
package/dist/locales.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { inBrowser, localeStorage, register } from "./current.mjs";
|
|
2
|
+
//#region src/locales.ts
|
|
3
|
+
/** `Intl.Locale` throws a RangeError on a tag that is not BCP 47; that is the only check a tag needs. */
|
|
4
|
+
const parseTag = (tag) => {
|
|
5
|
+
try {
|
|
6
|
+
return new Intl.Locale(tag);
|
|
7
|
+
} catch {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
/** `'/ui'` → `'/en/ui'`; `'/'` and `''` → `'/en'`. Pure over strings, so it lives outside the set. */
|
|
12
|
+
const localize = (pathname, locale) => {
|
|
13
|
+
const rest = pathname === "/" || pathname === "" ? "" : pathname;
|
|
14
|
+
if (rest !== "" && !rest.startsWith("/")) throw new TypeError(`localize: ${JSON.stringify(pathname)} is not a pathname — it does not start with "/"`);
|
|
15
|
+
return `/${locale}${rest}`;
|
|
16
|
+
};
|
|
17
|
+
/** Server only: the browser has no request, and its URL already is the locale. */
|
|
18
|
+
const run = (locale, fn) => {
|
|
19
|
+
if (inBrowser) throw new Error("locales.run: in the browser the URL is the locale — navigate instead");
|
|
20
|
+
const storage = localeStorage();
|
|
21
|
+
if (storage === null) throw new Error("locales.run: this runtime has no AsyncLocalStorage to scope a locale to");
|
|
22
|
+
return storage.run(locale, fn);
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Declares the locale set once. Everything that needs to know which locales
|
|
26
|
+
* exist — the params schema of the `[locale]` segment, the static build's
|
|
27
|
+
* path expansion, the language switcher, negotiation for a `/` redirect,
|
|
28
|
+
* every message — reads this value, so the list is spelled in one place.
|
|
29
|
+
*
|
|
30
|
+
* ```ts
|
|
31
|
+
* export const locales = defineLocales(['ja', 'en']);
|
|
32
|
+
* export const locales = defineLocales(['en-US', 'en-GB', 'ja'], { default: 'ja' });
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
const defineLocales = (all, options) => {
|
|
36
|
+
const list = all;
|
|
37
|
+
if (new Set(list).size !== list.length) throw new TypeError(`defineLocales: a locale is listed twice in ${JSON.stringify(list)}`);
|
|
38
|
+
const fallback = options?.default ?? list[0];
|
|
39
|
+
if (!list.includes(fallback)) throw new TypeError(`defineLocales: the default ${JSON.stringify(fallback)} is not in ${JSON.stringify(list)}`);
|
|
40
|
+
const supported = list.map((tag) => {
|
|
41
|
+
const parsed = parseTag(tag);
|
|
42
|
+
if (parsed === null) throw new TypeError(`defineLocales: ${JSON.stringify(tag)} is not a BCP 47 language tag`);
|
|
43
|
+
return {
|
|
44
|
+
tag,
|
|
45
|
+
language: parsed.language
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
const byExactTag = new Map(supported.map((s) => [s.tag.toLowerCase(), s.tag]));
|
|
49
|
+
const is = (value) => typeof value === "string" && list.includes(value);
|
|
50
|
+
const negotiate = (requested) => {
|
|
51
|
+
for (const tag of requested) {
|
|
52
|
+
const exact = byExactTag.get(tag.toLowerCase());
|
|
53
|
+
if (exact !== void 0) return exact;
|
|
54
|
+
const parsed = parseTag(tag);
|
|
55
|
+
if (parsed === null) continue;
|
|
56
|
+
const spoken = supported.find((s) => s.language === parsed.language);
|
|
57
|
+
if (spoken !== void 0) return spoken.tag;
|
|
58
|
+
}
|
|
59
|
+
return fallback;
|
|
60
|
+
};
|
|
61
|
+
const delocalize = (pathname) => {
|
|
62
|
+
const first = pathname.split("/")[1] ?? "";
|
|
63
|
+
if (!is(first)) return {
|
|
64
|
+
locale: null,
|
|
65
|
+
pathname
|
|
66
|
+
};
|
|
67
|
+
const rest = pathname.slice(first.length + 1);
|
|
68
|
+
return {
|
|
69
|
+
locale: first,
|
|
70
|
+
pathname: rest === "" ? "/" : rest
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
const getLocale = () => {
|
|
74
|
+
if (inBrowser) return delocalize(location.pathname).locale ?? fallback;
|
|
75
|
+
const current = localeStorage()?.getStore();
|
|
76
|
+
return is(current) ? current : fallback;
|
|
77
|
+
};
|
|
78
|
+
const paramsSchema = { "~standard": {
|
|
79
|
+
version: 1,
|
|
80
|
+
vendor: "@k8ordo/i18n",
|
|
81
|
+
validate: (value) => {
|
|
82
|
+
const locale = typeof value === "object" && value !== null && "locale" in value ? value.locale : void 0;
|
|
83
|
+
if (!is(locale)) return { issues: [{
|
|
84
|
+
message: `expected one of ${JSON.stringify(list)}, received ${JSON.stringify(locale)}`,
|
|
85
|
+
path: ["locale"]
|
|
86
|
+
}] };
|
|
87
|
+
localeStorage()?.enterWith(locale);
|
|
88
|
+
return { value: { locale } };
|
|
89
|
+
}
|
|
90
|
+
} };
|
|
91
|
+
register({
|
|
92
|
+
default: fallback,
|
|
93
|
+
is
|
|
94
|
+
});
|
|
95
|
+
return {
|
|
96
|
+
all: list,
|
|
97
|
+
default: fallback,
|
|
98
|
+
is,
|
|
99
|
+
negotiate,
|
|
100
|
+
localize,
|
|
101
|
+
delocalize,
|
|
102
|
+
paramsSchema,
|
|
103
|
+
getLocale,
|
|
104
|
+
run
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
//#endregion
|
|
108
|
+
export { defineLocales };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { RegisteredLocale } from "./register.mjs";
|
|
2
|
+
//#region src/message.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* A message: a function of the values its text needs (none for a plain
|
|
5
|
+
* string), returning the text in the current locale. What a component that
|
|
6
|
+
* takes a message as a prop should accept.
|
|
7
|
+
*/
|
|
8
|
+
type Message<A extends readonly unknown[] = []> = (...args: A) => string;
|
|
9
|
+
/** One value per locale — every locale, no locale twice. */
|
|
10
|
+
type Variants<V> = Readonly<Record<RegisteredLocale, V>>;
|
|
11
|
+
/**
|
|
12
|
+
* Declares one message with its text in every locale. The result reads the
|
|
13
|
+
* locale where it is called — the request on a server, the URL in the
|
|
14
|
+
* browser — so the same call renders on either side, and a bundler keeps
|
|
15
|
+
* only the messages a client module names.
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* export const home = message({ ja: 'ホーム', en: 'Home' });
|
|
19
|
+
* export const greeting = message({
|
|
20
|
+
* ja: (name: string) => `こんにちは、${name}`,
|
|
21
|
+
* en: (name) => `Hello, ${name}`,
|
|
22
|
+
* });
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* When nothing names a locale, the text is the default locale's — the one
|
|
26
|
+
* `defineLocales` registered, else the first variant written.
|
|
27
|
+
*/
|
|
28
|
+
declare function message(variants: Variants<string>): Message;
|
|
29
|
+
declare function message<A extends readonly unknown[]>(variants: Variants<(...args: A) => string>): Message<A>;
|
|
30
|
+
//#endregion
|
|
31
|
+
export { Message, Variants, message };
|
package/dist/message.mjs
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { currentLocale, registeredDefault, setRegistered } from "./current.mjs";
|
|
2
|
+
//#region src/message.ts
|
|
3
|
+
function message(variants) {
|
|
4
|
+
return (...args) => {
|
|
5
|
+
const named = currentLocale();
|
|
6
|
+
const locale = named !== null && (setRegistered() || Object.hasOwn(variants, named)) ? named : registeredDefault();
|
|
7
|
+
const variant = locale === null ? variants[Object.keys(variants)[0] ?? ""] : variants[locale];
|
|
8
|
+
if (variant === void 0) throw new TypeError(`message: no text for ${JSON.stringify(locale)} in ${JSON.stringify(Object.keys(variants))}`);
|
|
9
|
+
return typeof variant === "function" ? variant(...args) : variant;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
//#endregion
|
|
13
|
+
export { message };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region src/register.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Merged by the application once, so `message()` knows which locales every
|
|
4
|
+
* message must carry — without each message naming the set:
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* declare module '@k8ordo/i18n' {
|
|
8
|
+
* interface Register {
|
|
9
|
+
* locale: LocaleOf<typeof locales>;
|
|
10
|
+
* }
|
|
11
|
+
* }
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* The one `interface` in this package, kept so because it exists to be
|
|
15
|
+
* merged (see the repository's conventions).
|
|
16
|
+
*/
|
|
17
|
+
interface Register {}
|
|
18
|
+
/** The application's locale union once `Register` is merged; `string` before. */
|
|
19
|
+
type RegisteredLocale = Register extends {
|
|
20
|
+
locale: infer L extends string;
|
|
21
|
+
} ? L : string;
|
|
22
|
+
//#endregion
|
|
23
|
+
export { Register, RegisteredLocale };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/docs/GUIDE.md
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# @k8ordo/i18n
|
|
2
|
+
|
|
3
|
+
The locale axis of an application, owned.
|
|
4
|
+
|
|
5
|
+
One locale set declares which locales exist and which is the default, and
|
|
6
|
+
derives everything that depends on the list: the URL segment, the params
|
|
7
|
+
schema of the `[locale]` route, negotiation from `navigator.languages` or
|
|
8
|
+
`Accept-Language`, and the static build's path list. Each message is a
|
|
9
|
+
function — `message({ ja: 'ホーム', en: 'Home' })` — that reads the locale
|
|
10
|
+
where it is called. On the server that is the request the `[locale]` schema
|
|
11
|
+
accepted; in the browser it is the URL. So the same call renders in a Server
|
|
12
|
+
Component and in a Client Component, there is no provider and no hook, and a
|
|
13
|
+
bundler keeps only the messages a client module names.
|
|
14
|
+
|
|
15
|
+
Like every k8ordo package it assumes React 19 and Server Components, uses
|
|
16
|
+
only what has reached Baseline newly available, and ships no polyfills or
|
|
17
|
+
legacy fallbacks.
|
|
18
|
+
|
|
19
|
+
## What it does not own
|
|
20
|
+
|
|
21
|
+
- **The pathname.** `@k8ordo/router` owns routes; this package owns the
|
|
22
|
+
locale segment in front of them and nothing after it.
|
|
23
|
+
- **A message grammar.** No placeholder syntax, no ICU. A message with
|
|
24
|
+
values is a function of those values: interpolation is a template
|
|
25
|
+
literal, plurals are `Intl.PluralRules`, dates and numbers are `Intl`.
|
|
26
|
+
TypeScript checks the arguments because they are arguments.
|
|
27
|
+
- **Loading.** Messages are ordinary exports. Which ones reach the browser is
|
|
28
|
+
decided by the bundler from what each client module imports, not by a
|
|
29
|
+
loader, a namespace list, or a provider prop.
|
|
30
|
+
|
|
31
|
+
## The shape of it
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
// i18n.ts — the one place the list is spelled
|
|
35
|
+
import { defineLocales } from '@k8ordo/i18n';
|
|
36
|
+
import type { LocaleOf } from '@k8ordo/i18n';
|
|
37
|
+
|
|
38
|
+
export const locales = defineLocales(['ja', 'en']);
|
|
39
|
+
|
|
40
|
+
declare module '@k8ordo/i18n' {
|
|
41
|
+
interface Register {
|
|
42
|
+
locale: LocaleOf<typeof locales>;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
// messages/nav.ts — each message is one export
|
|
49
|
+
import { message } from '@k8ordo/i18n';
|
|
50
|
+
|
|
51
|
+
export const home = message({ ja: 'ホーム', en: 'Home' });
|
|
52
|
+
export const greeting = message({
|
|
53
|
+
ja: (name: string) => `こんにちは、${name}さん`,
|
|
54
|
+
en: (name) => `Hello, ${name}`,
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
// routes/[locale]/layout.tsx — a Server Component
|
|
60
|
+
import { locales } from '../../i18n';
|
|
61
|
+
|
|
62
|
+
export const paramsSchema = locales.paramsSchema;
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Spell it as that assignment: the framework finds a route's schema by
|
|
66
|
+
reading the file for `export const paramsSchema`, so a destructuring export
|
|
67
|
+
(`export const { paramsSchema } = locales`) would go unnoticed and `/fr/…`
|
|
68
|
+
would render in the default locale. A linter's `prefer-destructuring`
|
|
69
|
+
autofix rewrites it into exactly that, so disable the rule on the line.
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
// anywhere — a Server Component or a Client Component, the same line
|
|
73
|
+
import * as nav from '../messages/nav';
|
|
74
|
+
|
|
75
|
+
<h1>{nav.home()}</h1>
|
|
76
|
+
<p>{nav.greeting(name)}</p>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
That is the whole surface: `defineLocales`, `Register`, `message`, and
|
|
80
|
+
`paramsSchema` on the `[locale]` segment.
|
|
81
|
+
|
|
82
|
+
## The locale set
|
|
83
|
+
|
|
84
|
+
`defineLocales(all, { default? })` returns the set. The first entry is the
|
|
85
|
+
default unless told otherwise; every tag must be BCP 47 (checked with
|
|
86
|
+
`Intl.Locale`); a repeated tag or a default outside the list throws at the
|
|
87
|
+
definition, not later.
|
|
88
|
+
|
|
89
|
+
| Member | What it is |
|
|
90
|
+
| ----------------- | --------------------------------------------------------------------------------- |
|
|
91
|
+
| `all` | The tags, in order. |
|
|
92
|
+
| `default` | The tag used when nothing names one. |
|
|
93
|
+
| `is(value)` | Membership as a type guard. |
|
|
94
|
+
| `negotiate(…)` | The best supported tag for a preference list. |
|
|
95
|
+
| `localize` | `'/ui'` → `'/en/ui'`; `'/'` → `'/en'`. |
|
|
96
|
+
| `delocalize` | `'/en/ui'` → `{ locale: 'en', pathname: '/ui' }`; `'/x'` → `{ locale: null, … }`. |
|
|
97
|
+
| `paramsSchema` | The `[locale]` segment's schema (Standard Schema; no schema library). |
|
|
98
|
+
| `getLocale()` | The locale of the render in progress. Not a hook. |
|
|
99
|
+
| `run(locale, fn)` | Server only: runs `fn` with `locale` current. |
|
|
100
|
+
|
|
101
|
+
`LocaleOf<typeof locales>` is the tag union. `delocalize` says `null` for a
|
|
102
|
+
first segment that is not a locale rather than guessing the default, so the
|
|
103
|
+
root layout and the 404 page choose the fallback visibly.
|
|
104
|
+
|
|
105
|
+
### Negotiation
|
|
106
|
+
|
|
107
|
+
`negotiate` takes the list as the browser or the header gives it and tries
|
|
108
|
+
each requested tag in order — the exact tag, then the first supported locale
|
|
109
|
+
that speaks its language — before falling back to the default. Nothing
|
|
110
|
+
matching across the whole list is the default; a tag that is not BCP 47 is
|
|
111
|
+
skipped, because the list is user input.
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
locales.negotiate(navigator.languages); // in the browser
|
|
115
|
+
locales.negotiate(parseAcceptLanguage(request.headers.get('accept-language')));
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`parseAcceptLanguage(header)` turns an `Accept-Language` header into that
|
|
119
|
+
list: `q` weights decide the order, ties keep the header's order, `q=0` and
|
|
120
|
+
`*` are dropped, and a missing header is an empty list.
|
|
121
|
+
|
|
122
|
+
## Messages
|
|
123
|
+
|
|
124
|
+
`message(variants)` declares one message with its text in every locale.
|
|
125
|
+
Once `Register` carries the app's locale union, a variant missing for any
|
|
126
|
+
locale is a type error at the declaration — that is the whole "untranslated
|
|
127
|
+
key does not compile" guarantee, per message, with no key list to maintain.
|
|
128
|
+
|
|
129
|
+
A message is either text in every locale or a function in every locale;
|
|
130
|
+
the two are not mixed within one message. For a function message the
|
|
131
|
+
arguments are typed by the variant you annotate, and every other variant
|
|
132
|
+
is held to the same parameters:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
export const items = message({
|
|
136
|
+
ja: (count: number) => `${String(count)} 件`,
|
|
137
|
+
en: (count) =>
|
|
138
|
+
`${String(count)} ${new Intl.PluralRules('en').select(count) === 'one' ? 'item' : 'items'}`,
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The result is a `Message<Args>`: `() => string` for text, `(…args) =>
|
|
143
|
+
string` for a function. That type is what a component that takes a message
|
|
144
|
+
as a prop should accept —
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
type NavItem = { path: string; label: Message };
|
|
148
|
+
const items: NavItem[] = [{ path: '/ui', label: nav.ui }];
|
|
149
|
+
…
|
|
150
|
+
<a href={item.path}>{item.label()}</a>
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
— so data can carry messages without carrying strings, and without any
|
|
154
|
+
component translating on another's behalf.
|
|
155
|
+
|
|
156
|
+
### Where messages live
|
|
157
|
+
|
|
158
|
+
Anywhere. A file per area (`messages/nav.ts`, `messages/form.ts`) with a
|
|
159
|
+
barrel that re-exports each as a namespace reads well at the call site:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
// messages/index.ts
|
|
163
|
+
export * as nav from './nav';
|
|
164
|
+
export * as form from './form';
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
```tsx
|
|
168
|
+
import * as m from '../messages';
|
|
169
|
+
<h1>{m.nav.home()}</h1>;
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
A message that belongs to one component can sit next to that component.
|
|
173
|
+
Grouping related messages in an object (`export const button = { label:
|
|
174
|
+
message(…), hint: message(…) }`) is fine too; the bundler then keeps the
|
|
175
|
+
group together.
|
|
176
|
+
|
|
177
|
+
### Across the Server Component boundary
|
|
178
|
+
|
|
179
|
+
A message is a function, and a function does not cross from a Server
|
|
180
|
+
Component to a Client Component as a prop. Where a Server Component hands
|
|
181
|
+
text to a `'use client'` component, it calls the message and passes the
|
|
182
|
+
string — `<Dialog title={m.dialog.title()} />` — which is the rule the rest
|
|
183
|
+
of k8ordo already follows: only input crosses the boundary. Nothing further
|
|
184
|
+
down needs the string threaded through it, because any component, on either
|
|
185
|
+
side, can import a message and call it itself.
|
|
186
|
+
|
|
187
|
+
A component without a directive is shared: rendered by a Server Component it
|
|
188
|
+
runs on the server, rendered by a Client Component it runs in the browser,
|
|
189
|
+
and in both cases a `Message` prop is fine because it never crosses. A
|
|
190
|
+
component that only reads props and messages — a page title, a landing
|
|
191
|
+
layout — is best left without `'use client'` for exactly this reason.
|
|
192
|
+
|
|
193
|
+
### What reaches the browser
|
|
194
|
+
|
|
195
|
+
`message()` has no side effect at the declaration — it returns a function
|
|
196
|
+
and touches nothing — so a bundler treats an unreferenced message as dead
|
|
197
|
+
code. What a client bundle carries is therefore exactly the messages that
|
|
198
|
+
`'use client'` modules name, in every locale, and nothing a Server Component
|
|
199
|
+
rendered. This is the reason messages are functions and exports rather than
|
|
200
|
+
entries in a dictionary object: a dictionary is kept or dropped whole.
|
|
201
|
+
|
|
202
|
+
Keep the rule of thumb in mind: text that a Server Component renders costs
|
|
203
|
+
the client nothing, whichever module declares it. A client component that
|
|
204
|
+
needs text names it and pays for that message alone.
|
|
205
|
+
|
|
206
|
+
## Where the locale comes from
|
|
207
|
+
|
|
208
|
+
**On the server**, `paramsSchema` accepting a locale makes it the current
|
|
209
|
+
one for the rest of that request's render — the Server Components, the
|
|
210
|
+
HTML they become, and the client components that run on the server for
|
|
211
|
+
that HTML. Concurrent renders stay apart (`AsyncLocalStorage`). Outside a
|
|
212
|
+
`[locale]` route — a test, code that runs before the route matched — use
|
|
213
|
+
`locales.run(locale, fn)`; and when nothing names a locale, messages render
|
|
214
|
+
in the default.
|
|
215
|
+
|
|
216
|
+
**In the browser**, the URL is the locale: the first segment of
|
|
217
|
+
`location.pathname`, read when a message is called. Changing locale is a
|
|
218
|
+
navigation to the same pathname under the other segment
|
|
219
|
+
(`locales.localize(locales.delocalize(pathname).pathname, 'en')`), which
|
|
220
|
+
re-renders the page; there is no state to keep in sync.
|
|
221
|
+
|
|
222
|
+
A first segment that is not one of the set's locales is no locale, and the
|
|
223
|
+
default applies; before the set has been defined in that environment, a
|
|
224
|
+
segment no message has text for is read the same way, so a 404 page never
|
|
225
|
+
throws on `/fr/…`.
|
|
226
|
+
|
|
227
|
+
`locales.getLocale()` reads the same source for code that needs the tag
|
|
228
|
+
itself: `<html lang>`, a language switcher, `Intl` formatters.
|
|
229
|
+
|
|
230
|
+
### The `/` page
|
|
231
|
+
|
|
232
|
+
`/` is the one URL without a locale. Render nothing there and redirect from
|
|
233
|
+
an effect:
|
|
234
|
+
|
|
235
|
+
```tsx
|
|
236
|
+
'use client';
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
navigation.navigate(
|
|
239
|
+
locales.localize('/', locales.negotiate(navigator.languages)),
|
|
240
|
+
{ history: 'replace' },
|
|
241
|
+
);
|
|
242
|
+
}, []);
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Under `@k8ordo/server` the same decision can be made on the server with
|
|
246
|
+
`parseAcceptLanguage` and a redirect.
|
|
247
|
+
|
|
248
|
+
### `<html lang>`
|
|
249
|
+
|
|
250
|
+
The root layout sits above `[locale]` and receives `pathname`; the schema
|
|
251
|
+
has already run for that request, so `locales.getLocale()` is right, and
|
|
252
|
+
`locales.delocalize(pathname).locale ?? locales.default` says the same thing
|
|
253
|
+
in terms of the URL alone.
|
|
254
|
+
|
|
255
|
+
## Static builds
|
|
256
|
+
|
|
257
|
+
`@k8ordo/static` asks for the pathnames of every pattern with a parameter.
|
|
258
|
+
Expand `[locale]` from the set — the list is spelled once:
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
framework({
|
|
262
|
+
paths: (patterns) =>
|
|
263
|
+
patterns.flatMap((pattern) =>
|
|
264
|
+
locales.all.map((locale) => pattern.replace(':locale', locale)),
|
|
265
|
+
),
|
|
266
|
+
});
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Each path is rendered as its own request, so the schema names the locale
|
|
270
|
+
for each and the messages come out in that locale. The `404.html` a static
|
|
271
|
+
host serves for everything else is rendered once, under the build's
|
|
272
|
+
sentinel locale; a client component on it reads the visitor's URL and
|
|
273
|
+
re-renders in theirs after hydration.
|
|
274
|
+
|
|
275
|
+
## Alongside the rest of k8ordo
|
|
276
|
+
|
|
277
|
+
- **`@k8ordo/router`**: `locales.localize` / `delocalize` are the only
|
|
278
|
+
things here that touch a pathname. A link component that prefixes the
|
|
279
|
+
current locale is `href(locales.localize(path, locales.getLocale()))`.
|
|
280
|
+
- **`@k8ordo/ui`**: its own built-in strings take a dictionary prop on
|
|
281
|
+
`UIProvider`; pass `en` on `/en/…` from `locales.getLocale()`.
|
|
282
|
+
- **`@k8ordo/form`**: constraint messages are messages — `message({ ja:
|
|
283
|
+
(max: number) => …, en: … })` — called where the constraint is declared.
|
|
284
|
+
|
|
285
|
+
## What it guarantees
|
|
286
|
+
|
|
287
|
+
- A locale outside the list never reaches a page: the schema refuses it.
|
|
288
|
+
- A message missing a locale does not compile once `Register` is merged.
|
|
289
|
+
From JavaScript, or through `as`, it throws where it is read, naming the
|
|
290
|
+
locale and the variants present — never `undefined`.
|
|
291
|
+
- Arguments to a function message are typed by the function.
|
|
292
|
+
- No render is ever in a locale the URL does not spell (browser) or the
|
|
293
|
+
request did not accept (server).
|
|
294
|
+
|
|
295
|
+
## Testing
|
|
296
|
+
|
|
297
|
+
Under Node, `locales.run('en', () => nav.home())` renders in `en`; without
|
|
298
|
+
it, the default. The `paramsSchema` sets the locale for the rest of its
|
|
299
|
+
scope too, so wrap a test that validates in `run` to keep tests apart. In a
|
|
300
|
+
browser environment, `history.replaceState(null, '', '/en/…')` is the
|
|
301
|
+
locale.
|
package/docs/llms.txt
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# @k8ordo/i18n
|
|
2
|
+
|
|
3
|
+
> The locale axis of an application, owned. `defineLocales(['ja', 'en'])`
|
|
4
|
+
> declares the set once and derives membership, negotiation, the URL segment
|
|
5
|
+
> (`localize` / `delocalize`), the `[locale]` params schema in the shape
|
|
6
|
+
> `@k8ordo/static` / `@k8ordo/server` run, and the current locale
|
|
7
|
+
> (`getLocale()`). Each message is a function — `message({ ja, en })` — that
|
|
8
|
+
> reads the locale where it is called: on the server the request the schema
|
|
9
|
+
> accepted, in the browser the URL. The same call renders in a Server
|
|
10
|
+
> Component and in a Client Component; there is no provider and no hook; a
|
|
11
|
+
> bundler keeps only the messages a client module names. `Register` carries
|
|
12
|
+
> the app's locale union so a message missing a locale does not compile.
|
|
13
|
+
>
|
|
14
|
+
> Like every k8ordo package it assumes React 19 and Server Components, uses
|
|
15
|
+
> only what has reached Baseline newly available, and ships no polyfills or
|
|
16
|
+
> legacy fallbacks.
|
|
17
|
+
|
|
18
|
+
When installed via npm, these docs live under `node_modules/@k8ordo/i18n/docs/`.
|
|
19
|
+
|
|
20
|
+
## Guide
|
|
21
|
+
|
|
22
|
+
- [Design Guide](GUIDE.md): what the package owns and does not (the locale
|
|
23
|
+
segment, not the pathname; no message grammar; no loader), the locale set
|
|
24
|
+
and negotiation, messages as functions and `Register`, the Server
|
|
25
|
+
Component boundary (a message is called before it is passed to a
|
|
26
|
+
`'use client'` component), what reaches the browser, where the locale comes
|
|
27
|
+
from on each side, `/` and `<html lang>`, static path expansion, and
|
|
28
|
+
pairing with `@k8ordo/router` / `@k8ordo/ui` / `@k8ordo/form`.
|
|
29
|
+
|
|
30
|
+
## Entry point
|
|
31
|
+
|
|
32
|
+
- `@k8ordo/i18n` — `defineLocales(all, { default? })` returns `{ all,
|
|
33
|
+
default, is, negotiate, localize, delocalize, paramsSchema, getLocale,
|
|
34
|
+
run }`; the first entry is the default unless told otherwise, every tag
|
|
35
|
+
must be BCP 47, `negotiate(list)` tries each requested tag in order (exact,
|
|
36
|
+
then language) before falling back to the default, `getLocale()` is the
|
|
37
|
+
locale of the render in progress (not a hook), and `run(locale, fn)`
|
|
38
|
+
(server only) runs `fn` under a locale. `parseAcceptLanguage(header)` turns
|
|
39
|
+
an `Accept-Language` header into a preference list. `message(variants)`
|
|
40
|
+
declares one message — text in every locale, or a function of the same
|
|
41
|
+
parameters in every locale — and returns `Message<Args>`, a function that
|
|
42
|
+
renders the current locale's text. Types: `Locales`, `LocaleOf`, `Message`,
|
|
43
|
+
`Variants`, `Register`, `RegisteredLocale`, `Delocalized`,
|
|
44
|
+
`LocaleParamsSchema`, `LocalesOptions`.
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@k8ordo/i18n",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The locale axis of an application, owned. One locale set derives the URL segment, negotiation, the params schema, the static paths, and the current locale on either side of the network; each message is a function that reads it, so a bundler keeps only what a page names.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"i18n",
|
|
7
|
+
"intl",
|
|
8
|
+
"locale",
|
|
9
|
+
"react",
|
|
10
|
+
"server-components",
|
|
11
|
+
"translation",
|
|
12
|
+
"typescript"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://ordo.k8o.me",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/k35o/k8ordo/issues"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"author": "k8o <kosakanoki@gmail.com>",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/k35o/k8ordo.git"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist/**",
|
|
26
|
+
"docs/**"
|
|
27
|
+
],
|
|
28
|
+
"type": "module",
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.mts",
|
|
33
|
+
"default": "./dist/index.mjs"
|
|
34
|
+
},
|
|
35
|
+
"./package.json": "./package.json"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public",
|
|
39
|
+
"registry": "https://registry.npmjs.org"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@arethetypeswrong/cli": "0.18.5",
|
|
43
|
+
"@vitest/browser-playwright": "5.0.0",
|
|
44
|
+
"publint": "0.3.24",
|
|
45
|
+
"typescript": "7.0.2",
|
|
46
|
+
"vite": "8.2.2",
|
|
47
|
+
"vite-plus": "0.3.0",
|
|
48
|
+
"vitest": "5.0.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"typescript": ">=7.0.2"
|
|
52
|
+
},
|
|
53
|
+
"peerDependenciesMeta": {
|
|
54
|
+
"typescript": {
|
|
55
|
+
"optional": true
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"build": "vp pack",
|
|
60
|
+
"test": "vp test",
|
|
61
|
+
"typecheck": "tsc --noEmit",
|
|
62
|
+
"check": "vp check",
|
|
63
|
+
"check:write": "vp check --fix",
|
|
64
|
+
"check:package": "publint && attw --pack . --profile esm-only"
|
|
65
|
+
}
|
|
66
|
+
}
|