@k8ordo/i18n 0.1.0 → 1.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/README.md +73 -28
- package/dist/accept-language.d.mts +7 -7
- package/dist/accept-language.mjs +5 -4
- package/dist/cookie.d.mts +10 -0
- package/dist/cookie.mjs +20 -0
- package/dist/current.d.mts +26 -23
- package/dist/current.mjs +31 -5
- package/dist/format.d.mts +32 -0
- package/dist/format.mjs +31 -0
- package/dist/index.d.mts +4 -2
- package/dist/index.mjs +2 -1
- package/dist/locales.d.mts +76 -19
- package/dist/locales.mjs +55 -12
- package/dist/message.d.mts +5 -6
- package/dist/message.mjs +2 -2
- package/dist/register.d.mts +6 -6
- package/docs/GUIDE.md +394 -113
- package/docs/llms.txt +37 -14
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -8,8 +8,10 @@ function — `message({ ja: 'ホーム', en: 'Home' })` — that reads the local
|
|
|
8
8
|
where it is called: the request on the server, the URL in the browser. So the
|
|
9
9
|
same line renders in a Server Component and in a Client Component, there is
|
|
10
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
|
|
12
|
-
`Intl
|
|
11
|
+
names. No message grammar: interpolation is a template literal, and plurals,
|
|
12
|
+
dates and numbers are `Intl`, drawn for the current locale by the set
|
|
13
|
+
(`locales.pluralRules()`, `locales.dateTimeFormat()`, …) — a date always in
|
|
14
|
+
that locale's time zone, so the server's HTML and the browser agree.
|
|
13
15
|
|
|
14
16
|
Like every [k8ordo](https://ordo.k8o.me) package it assumes React 19 and Server
|
|
15
17
|
Components, uses only what has reached Baseline newly available, and ships no
|
|
@@ -28,27 +30,45 @@ pnpm add @k8ordo/i18n
|
|
|
28
30
|
|
|
29
31
|
## Peer Dependencies
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
|
33
|
+
<!-- peers -->
|
|
34
|
+
|
|
35
|
+
| Package | Version | Required | Needed for |
|
|
36
|
+
| ------------ | ------- | -------- | ----------------------------- |
|
|
37
|
+
| `typescript` | ≥7.0.0 | optional | the shipped type declarations |
|
|
38
|
+
|
|
39
|
+
<!-- /peers -->
|
|
34
40
|
|
|
35
41
|
No React import and no schema library: a message is a plain function, and a
|
|
36
42
|
locale set is a list that checks membership itself. `locales.paramsSchema`
|
|
37
|
-
speaks Standard Schema, which is what `@k8ordo/
|
|
38
|
-
|
|
39
|
-
`AsyncLocalStorage`
|
|
43
|
+
speaks Standard Schema, which is what `@k8ordo/framework` runs for a
|
|
44
|
+
route's params. On a server the current locale rides on
|
|
45
|
+
`AsyncLocalStorage`, reached through `process.getBuiltinModule`, so a server
|
|
46
|
+
runtime has to provide that API; Node 24, which the framework modes require,
|
|
47
|
+
does.
|
|
40
48
|
|
|
41
49
|
## Quick Start
|
|
42
50
|
|
|
43
|
-
The locale set in its own module
|
|
44
|
-
|
|
51
|
+
The locale set in its own module — each locale with the time zone its dates
|
|
52
|
+
are shown in and the direction its text runs in:
|
|
45
53
|
|
|
46
54
|
```ts
|
|
47
|
-
// i18n.ts
|
|
55
|
+
// src/i18n.ts
|
|
48
56
|
import { defineLocales } from '@k8ordo/i18n';
|
|
49
|
-
import type { LocaleOf } from '@k8ordo/i18n';
|
|
50
57
|
|
|
51
|
-
export const locales = defineLocales(
|
|
58
|
+
export const locales = defineLocales({
|
|
59
|
+
ja: { timeZone: 'Asia/Tokyo', dir: 'ltr' },
|
|
60
|
+
en: { timeZone: 'UTC', dir: 'ltr' },
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Every message is held to the set through `Register`. Under
|
|
65
|
+
`@k8ordo/framework` that is generated from the `locales` export of
|
|
66
|
+
`src/i18n.ts`, and there is nothing to write. Anywhere else, register it
|
|
67
|
+
once, beside the set:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
// src/i18n.ts, outside @k8ordo/framework
|
|
71
|
+
import type { LocaleOf } from '@k8ordo/i18n';
|
|
52
72
|
|
|
53
73
|
declare module '@k8ordo/i18n' {
|
|
54
74
|
interface Register {
|
|
@@ -60,7 +80,7 @@ declare module '@k8ordo/i18n' {
|
|
|
60
80
|
Each message is one export, with its text in every locale:
|
|
61
81
|
|
|
62
82
|
```ts
|
|
63
|
-
// messages/nav.ts
|
|
83
|
+
// src/messages/nav.ts
|
|
64
84
|
import { message } from '@k8ordo/i18n';
|
|
65
85
|
|
|
66
86
|
export const home = message({ ja: 'ホーム', en: 'Home' });
|
|
@@ -74,30 +94,30 @@ The `[locale]` route's layout declares the schema; accepting a locale makes it
|
|
|
74
94
|
the locale of that render:
|
|
75
95
|
|
|
76
96
|
```tsx
|
|
77
|
-
// routes/[locale]/layout.tsx
|
|
97
|
+
// src/routes/[locale]/layout.tsx
|
|
78
98
|
import { locales } from '../../i18n';
|
|
79
99
|
|
|
80
|
-
export const paramsSchema = locales
|
|
100
|
+
export const { paramsSchema } = locales; // /fr/… is a 404
|
|
81
101
|
```
|
|
82
102
|
|
|
83
103
|
And a message is called the same way everywhere:
|
|
84
104
|
|
|
85
105
|
```tsx
|
|
86
|
-
// routes/[locale]/page.tsx — a Server Component
|
|
87
|
-
import
|
|
106
|
+
// src/routes/[locale]/page.tsx — a Server Component
|
|
107
|
+
import { home } from '../../messages/nav';
|
|
88
108
|
|
|
89
109
|
export default function Page() {
|
|
90
|
-
return <h1>{
|
|
110
|
+
return <h1>{home()}</h1>;
|
|
91
111
|
}
|
|
92
112
|
```
|
|
93
113
|
|
|
94
114
|
```tsx
|
|
95
|
-
// components/toolbar.tsx — a Client Component, the same line
|
|
115
|
+
// src/components/toolbar.tsx — a Client Component, the same line
|
|
96
116
|
'use client';
|
|
97
|
-
import
|
|
117
|
+
import { greeting } from '../messages/nav';
|
|
98
118
|
|
|
99
119
|
export function Toolbar() {
|
|
100
|
-
return <button>{
|
|
120
|
+
return <button>{greeting('k8o')}</button>;
|
|
101
121
|
}
|
|
102
122
|
```
|
|
103
123
|
|
|
@@ -105,16 +125,41 @@ Where a Server Component hands text to a Client Component as a prop, it calls
|
|
|
105
125
|
the message and passes the string: a function does not cross that boundary.
|
|
106
126
|
|
|
107
127
|
`/` negotiates and redirects; `<html lang>` and a language switcher read
|
|
108
|
-
`locales.getLocale()`; a static build
|
|
128
|
+
`locales.getLocale()`, and `<html dir>` its `locales.definitions[…].dir`; a static build passes `paths: locales.paths`. Links
|
|
129
|
+
stay the router's: `bindParams(() => ({ locale: locales.getLocale() }))`
|
|
130
|
+
(imported from `@k8ordo/framework` in a framework application) gives an `href` that spells `/:locale/…` patterns without the locale.
|
|
109
131
|
|
|
110
132
|
## AI Agent Documentation
|
|
111
133
|
|
|
112
|
-
|
|
113
|
-
installed
|
|
134
|
+
The docs ship **inside the package**, so an agent always reads the exact
|
|
135
|
+
version you installed — there is no snapshot to copy or re-sync on upgrade.
|
|
136
|
+
|
|
137
|
+
Point your agent at them once by pasting this into your project's `CLAUDE.md` /
|
|
138
|
+
`AGENTS.md`:
|
|
139
|
+
|
|
140
|
+
```markdown
|
|
141
|
+
Use `@k8ordo/i18n` for locales and translated text. Before adding a message
|
|
142
|
+
or touching a `[locale]` route, read `node_modules/@k8ordo/i18n/docs/GUIDE.md`.
|
|
143
|
+
The locale set is declared once with `defineLocales` as the `locales` export
|
|
144
|
+
of `src/i18n.ts`, and registered through `Register` — generated under
|
|
145
|
+
`@k8ordo/framework`, so never write it there; written once beside the set
|
|
146
|
+
elsewhere. Each message is a `message({ ja, en })` export, called where it
|
|
147
|
+
renders — the same call in a Server and a Client Component. There is no
|
|
148
|
+
provider, no hook and no message grammar: never pass the locale down as a
|
|
149
|
+
prop, interpolate inside the message's own function, format dates and numbers
|
|
150
|
+
with `locales.dateTimeFormat()` / `numberFormat()` / `pluralRules()` (never a
|
|
151
|
+
bare `Intl.DateTimeFormat` without the locale's time zone), and from a Server
|
|
152
|
+
Component hand a `'use client'` component the called string, not the message.
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
What each surface gives an agent:
|
|
114
156
|
|
|
115
|
-
|
|
116
|
-
|
|
157
|
+
| Surface | Where |
|
|
158
|
+
| -------------------------- | ---------------------------------------------- |
|
|
159
|
+
| Design guide (entry point) | `node_modules/@k8ordo/i18n/docs/GUIDE.md` |
|
|
160
|
+
| Docs index for LLMs | `docs/llms.txt` · https://ordo.k8o.me/llms.txt |
|
|
161
|
+
| Markdown twin on the web | https://ordo.k8o.me/i18n/docs/GUIDE.md |
|
|
117
162
|
|
|
118
163
|
## License
|
|
119
164
|
|
|
120
|
-
MIT
|
|
165
|
+
MIT License - see [LICENSE](https://github.com/k35o/k8ordo/blob/main/LICENSE) for details.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
//#region src/accept-language.d.ts
|
|
2
2
|
/**
|
|
3
3
|
* The tags of an `Accept-Language` header, most preferred first: `q` weights
|
|
4
|
-
* decide the order (a tag without one
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* decide the order (a tag without one, or with one that is not a number,
|
|
5
|
+
* weighs 1), ties keep the header's order, a weight of 0 or less (an empty
|
|
6
|
+
* `q=` reads as 0) and the `*` wildcard are dropped — the wildcard says
|
|
7
|
+
* "anything", which is what the default locale already means. Feed the result
|
|
8
|
+
* to `locales.negotiate`; a missing header (`null`) is an empty list.
|
|
8
9
|
*/
|
|
9
|
-
declare const parseAcceptLanguage: (header: string | null) => string[];
|
|
10
|
-
//#endregion
|
|
11
|
-
export { parseAcceptLanguage };
|
|
10
|
+
export declare const parseAcceptLanguage: (header: string | null) => string[];
|
|
11
|
+
//#endregion
|
package/dist/accept-language.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
//#region src/accept-language.ts
|
|
2
2
|
/**
|
|
3
3
|
* The tags of an `Accept-Language` header, most preferred first: `q` weights
|
|
4
|
-
* decide the order (a tag without one
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* decide the order (a tag without one, or with one that is not a number,
|
|
5
|
+
* weighs 1), ties keep the header's order, a weight of 0 or less (an empty
|
|
6
|
+
* `q=` reads as 0) and the `*` wildcard are dropped — the wildcard says
|
|
7
|
+
* "anything", which is what the default locale already means. Feed the result
|
|
8
|
+
* to `locales.negotiate`; a missing header (`null`) is an empty list.
|
|
8
9
|
*/
|
|
9
10
|
const parseAcceptLanguage = (header) => {
|
|
10
11
|
if (header === null || header.trim() === "") return [];
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
//#region src/cookie.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The value of one cookie in a `Cookie` header, or `null` when the header or
|
|
4
|
+
* the cookie is missing. The first cookie of that name wins — a browser sends
|
|
5
|
+
* the one with the most specific path first — and a value in double quotes
|
|
6
|
+
* (RFC 6265 allows them) comes back without them. Values are not
|
|
7
|
+
* percent-decoded: a locale tag needs no encoding.
|
|
8
|
+
*/
|
|
9
|
+
export declare const readCookie: (header: string | null, name: string) => string | null;
|
|
10
|
+
//#endregion
|
package/dist/cookie.mjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//#region src/cookie.ts
|
|
2
|
+
/**
|
|
3
|
+
* The value of one cookie in a `Cookie` header, or `null` when the header or
|
|
4
|
+
* the cookie is missing. The first cookie of that name wins — a browser sends
|
|
5
|
+
* the one with the most specific path first — and a value in double quotes
|
|
6
|
+
* (RFC 6265 allows them) comes back without them. Values are not
|
|
7
|
+
* percent-decoded: a locale tag needs no encoding.
|
|
8
|
+
*/
|
|
9
|
+
const readCookie = (header, name) => {
|
|
10
|
+
if (header === null) return null;
|
|
11
|
+
for (const pair of header.split(";")) {
|
|
12
|
+
const separator = pair.indexOf("=");
|
|
13
|
+
if (separator === -1 || pair.slice(0, separator).trim() !== name) continue;
|
|
14
|
+
const value = pair.slice(separator + 1).trim();
|
|
15
|
+
return value.length >= 2 && value.startsWith("\"") && value.endsWith("\"") ? value.slice(1, -1) : value;
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
};
|
|
19
|
+
//#endregion
|
|
20
|
+
export { readCookie };
|
package/dist/current.d.mts
CHANGED
|
@@ -1,47 +1,50 @@
|
|
|
1
|
+
import { RegisteredLocale } from "./register.mjs";
|
|
1
2
|
//#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 = {
|
|
3
|
+
export type LocaleStorage = {
|
|
15
4
|
getStore: () => string | undefined;
|
|
16
5
|
enterWith: (locale: string) => void;
|
|
17
6
|
run: <T>(locale: string, fn: () => T) => T;
|
|
18
7
|
};
|
|
19
8
|
/** What `message()` needs from the locale set: the default, and membership. */
|
|
20
|
-
type RegisteredSet = {
|
|
9
|
+
export type RegisteredSet = {
|
|
21
10
|
readonly default: string;
|
|
22
11
|
readonly is: (value: unknown) => boolean;
|
|
23
12
|
};
|
|
24
|
-
declare const inBrowser: boolean;
|
|
13
|
+
export declare const inBrowser: boolean;
|
|
25
14
|
/**
|
|
26
15
|
* Reached through `process.getBuiltinModule` rather than an import so the
|
|
27
16
|
* same build runs in the browser, where there is no request to scope to.
|
|
28
17
|
*/
|
|
29
|
-
declare const localeStorage: () => LocaleStorage | null;
|
|
18
|
+
export declare const localeStorage: () => LocaleStorage | null;
|
|
30
19
|
/**
|
|
31
20
|
* The set messages read is the last one defined; an application has one.
|
|
32
21
|
* Last rather than first so that a dev server re-evaluating the module that
|
|
33
22
|
* defines the set (a locale added under HMR) is what messages see next.
|
|
34
23
|
*/
|
|
35
|
-
declare const register: (set: RegisteredSet) => void;
|
|
24
|
+
export declare const register: (set: RegisteredSet) => void;
|
|
25
|
+
/**
|
|
26
|
+
* The browser's pathname in the application's terms: Vite's `base` taken
|
|
27
|
+
* off, so the first segment is the one the route table's `[locale]` names —
|
|
28
|
+
* `/docs/en/ui` is `/en/ui` under `base: '/docs/'`.
|
|
29
|
+
*/
|
|
30
|
+
export declare const browserPathname: () => string;
|
|
36
31
|
/** Whether a set has registered in this environment yet. */
|
|
37
|
-
declare const setRegistered: () => boolean;
|
|
32
|
+
export declare const setRegistered: () => boolean;
|
|
38
33
|
/**
|
|
39
|
-
* The
|
|
34
|
+
* The locale named where this runs, or `null` when nothing names one: no
|
|
40
35
|
* segment in the URL, no request in progress. A registered set makes an
|
|
41
36
|
* unknown segment `null` too, so `/fr/…` does not read as a locale.
|
|
42
37
|
*/
|
|
43
|
-
declare const
|
|
38
|
+
export declare const namedLocale: () => string | null;
|
|
44
39
|
/** The registered default, when a set registered in this environment. */
|
|
45
|
-
declare const registeredDefault: () => string | null;
|
|
46
|
-
|
|
47
|
-
|
|
40
|
+
export declare const registeredDefault: () => string | null;
|
|
41
|
+
/**
|
|
42
|
+
* The current locale as the application's set resolves it — the one named,
|
|
43
|
+
* else the set's default — or `null` when no set is defined in this
|
|
44
|
+
* environment. For a library rendering inside an application it does not
|
|
45
|
+
* know (`@k8ordo/ui`'s built-in text): an application that never defined a
|
|
46
|
+
* set has no locale, whatever its URL happens to start with, so the server
|
|
47
|
+
* and the browser both answer `null` and agree.
|
|
48
|
+
*/
|
|
49
|
+
export declare const currentLocale: () => RegisteredLocale | null;
|
|
50
|
+
//#endregion
|
package/dist/current.mjs
CHANGED
|
@@ -25,16 +25,29 @@ const localeStorage = () => {
|
|
|
25
25
|
const register = (set) => {
|
|
26
26
|
global[SET_KEY] = set;
|
|
27
27
|
};
|
|
28
|
-
/**
|
|
29
|
-
|
|
28
|
+
/**
|
|
29
|
+
* The browser's pathname in the application's terms: Vite's `base` taken
|
|
30
|
+
* off, so the first segment is the one the route table's `[locale]` names —
|
|
31
|
+
* `/docs/en/ui` is `/en/ui` under `base: '/docs/'`.
|
|
32
|
+
*/
|
|
33
|
+
const browserPathname = () => {
|
|
34
|
+
const pathname = global.location?.pathname ?? "/";
|
|
35
|
+
const base = import.meta.env === void 0 ? "/" : import.meta.env.BASE_URL;
|
|
36
|
+
if (!base.startsWith("/") || base === "/") return pathname;
|
|
37
|
+
const prefix = base.endsWith("/") ? base.slice(0, -1) : base;
|
|
38
|
+
if (pathname === prefix) return "/";
|
|
39
|
+
return pathname.startsWith(`${prefix}/`) ? pathname.slice(prefix.length) : pathname;
|
|
40
|
+
};
|
|
41
|
+
/** The first segment of the browser's URL below the base, whatever it spells. */
|
|
42
|
+
const browserSegment = () => browserPathname().split("/")[1] ?? "";
|
|
30
43
|
/** Whether a set has registered in this environment yet. */
|
|
31
44
|
const setRegistered = () => global[SET_KEY] !== void 0;
|
|
32
45
|
/**
|
|
33
|
-
* The
|
|
46
|
+
* The locale named where this runs, or `null` when nothing names one: no
|
|
34
47
|
* segment in the URL, no request in progress. A registered set makes an
|
|
35
48
|
* unknown segment `null` too, so `/fr/…` does not read as a locale.
|
|
36
49
|
*/
|
|
37
|
-
const
|
|
50
|
+
const namedLocale = () => {
|
|
38
51
|
const set = global[SET_KEY];
|
|
39
52
|
const named = inBrowser ? browserSegment() : localeStorage()?.getStore();
|
|
40
53
|
if (named === void 0 || named === "") return null;
|
|
@@ -43,5 +56,18 @@ const currentLocale = () => {
|
|
|
43
56
|
};
|
|
44
57
|
/** The registered default, when a set registered in this environment. */
|
|
45
58
|
const registeredDefault = () => global[SET_KEY]?.default ?? null;
|
|
59
|
+
/**
|
|
60
|
+
* The current locale as the application's set resolves it — the one named,
|
|
61
|
+
* else the set's default — or `null` when no set is defined in this
|
|
62
|
+
* environment. For a library rendering inside an application it does not
|
|
63
|
+
* know (`@k8ordo/ui`'s built-in text): an application that never defined a
|
|
64
|
+
* set has no locale, whatever its URL happens to start with, so the server
|
|
65
|
+
* and the browser both answer `null` and agree.
|
|
66
|
+
*/
|
|
67
|
+
const currentLocale = () => {
|
|
68
|
+
const set = global[SET_KEY];
|
|
69
|
+
if (set === void 0) return null;
|
|
70
|
+
return namedLocale() ?? set.default;
|
|
71
|
+
};
|
|
46
72
|
//#endregion
|
|
47
|
-
export { currentLocale, inBrowser, localeStorage, register, registeredDefault, setRegistered };
|
|
73
|
+
export { browserPathname, currentLocale, inBrowser, localeStorage, namedLocale, register, registeredDefault, setRegistered };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { LocaleDefinition } from "./locales.mjs";
|
|
2
|
+
//#region src/format.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* `Intl.DateTimeFormatOptions` without a `timeZone`: the locale's own is the
|
|
5
|
+
* only one a date is written in, so the server's HTML and the browser's
|
|
6
|
+
* render agree. A date that should follow each visitor's zone is `Intl`'s own
|
|
7
|
+
* business, in a part that renders in the browser only.
|
|
8
|
+
*/
|
|
9
|
+
export type LocaleDateTimeFormatOptions = Intl.DateTimeFormatOptions & {
|
|
10
|
+
readonly timeZone?: never;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* `Intl`, drawn for the current locale. Each returns the `Intl` object
|
|
14
|
+
* itself — `format`, `formatToParts`, `formatRange`, `select`,
|
|
15
|
+
* `resolvedOptions` are all there — made once per locale and options and
|
|
16
|
+
* reused after that.
|
|
17
|
+
*/
|
|
18
|
+
export type IntlFormats = {
|
|
19
|
+
/** `Intl.DateTimeFormat` in the current locale and that locale's `timeZone`. */
|
|
20
|
+
readonly dateTimeFormat: (options?: LocaleDateTimeFormatOptions) => Intl.DateTimeFormat;
|
|
21
|
+
/** `Intl.NumberFormat` in the current locale. */
|
|
22
|
+
readonly numberFormat: (options?: Intl.NumberFormatOptions) => Intl.NumberFormat;
|
|
23
|
+
/** `Intl.RelativeTimeFormat` in the current locale. */
|
|
24
|
+
readonly relativeTimeFormat: (options?: Intl.RelativeTimeFormatOptions) => Intl.RelativeTimeFormat;
|
|
25
|
+
/** `Intl.PluralRules` in the current locale. */
|
|
26
|
+
readonly pluralRules: (options?: Intl.PluralRulesOptions) => Intl.PluralRules;
|
|
27
|
+
/** `Intl.ListFormat` in the current locale. */
|
|
28
|
+
readonly listFormat: (options?: Intl.ListFormatOptions) => Intl.ListFormat;
|
|
29
|
+
};
|
|
30
|
+
/** The members of a locale set that draw `Intl` for its current locale. */
|
|
31
|
+
export declare const intlFormats: <L extends string>(getLocale: () => L, definitions: Readonly<Record<L, LocaleDefinition>>) => IntlFormats;
|
|
32
|
+
//#endregion
|
package/dist/format.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
//#region src/format.ts
|
|
2
|
+
/**
|
|
3
|
+
* An `Intl` constructor behind a cache keyed by locale and options. The
|
|
4
|
+
* options are keyed by their JSON: two spellings of the same options make two
|
|
5
|
+
* entries, which costs a duplicate, never a wrong answer.
|
|
6
|
+
*/
|
|
7
|
+
const cached = (current, create) => {
|
|
8
|
+
const made = /* @__PURE__ */ new Map();
|
|
9
|
+
return (options) => {
|
|
10
|
+
const locale = current();
|
|
11
|
+
const key = `${locale}\u0000${JSON.stringify(options ?? {})}`;
|
|
12
|
+
const existing = made.get(key);
|
|
13
|
+
if (existing !== void 0) return existing;
|
|
14
|
+
const created = create(locale, options);
|
|
15
|
+
made.set(key, created);
|
|
16
|
+
return created;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
/** The members of a locale set that draw `Intl` for its current locale. */
|
|
20
|
+
const intlFormats = (getLocale, definitions) => ({
|
|
21
|
+
dateTimeFormat: cached(getLocale, (locale, options) => new Intl.DateTimeFormat(locale, {
|
|
22
|
+
...options,
|
|
23
|
+
timeZone: definitions[locale].timeZone
|
|
24
|
+
})),
|
|
25
|
+
numberFormat: cached(getLocale, (locale, options) => new Intl.NumberFormat(locale, options)),
|
|
26
|
+
relativeTimeFormat: cached(getLocale, (locale, options) => new Intl.RelativeTimeFormat(locale, options)),
|
|
27
|
+
pluralRules: cached(getLocale, (locale, options) => new Intl.PluralRules(locale, options)),
|
|
28
|
+
listFormat: cached(getLocale, (locale, options) => new Intl.ListFormat(locale, options))
|
|
29
|
+
});
|
|
30
|
+
//#endregion
|
|
31
|
+
export { intlFormats };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { parseAcceptLanguage } from "./accept-language.mjs";
|
|
2
|
-
import { Delocalized, LocaleOf, LocaleParamsSchema, Locales, LocalesOptions, defineLocales } from "./locales.mjs";
|
|
3
2
|
import { Register, RegisteredLocale } from "./register.mjs";
|
|
3
|
+
import { currentLocale } from "./current.mjs";
|
|
4
|
+
import { Delocalized, LocaleDefinition, LocaleOf, LocaleParamsSchema, Locales, LocalesOptions, NegotiateRequestOptions, defineLocales } from "./locales.mjs";
|
|
5
|
+
import { IntlFormats, LocaleDateTimeFormatOptions } from "./format.mjs";
|
|
4
6
|
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 };
|
|
7
|
+
export { type Delocalized, type IntlFormats, type LocaleDateTimeFormatOptions, type LocaleDefinition, type LocaleOf, type LocaleParamsSchema, type Locales, type LocalesOptions, type Message, type NegotiateRequestOptions, type Register, type RegisteredLocale, type Variants, currentLocale, defineLocales, message, parseAcceptLanguage };
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseAcceptLanguage } from "./accept-language.mjs";
|
|
2
|
+
import { currentLocale } from "./current.mjs";
|
|
2
3
|
import { defineLocales } from "./locales.mjs";
|
|
3
4
|
import { message } from "./message.mjs";
|
|
4
|
-
export { defineLocales, message, parseAcceptLanguage };
|
|
5
|
+
export { currentLocale, defineLocales, message, parseAcceptLanguage };
|
package/dist/locales.d.mts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
+
import { IntlFormats } from "./format.mjs";
|
|
1
2
|
//#region src/locales.d.ts
|
|
2
3
|
/**
|
|
3
4
|
* The one shape every validation library agrees on (Standard Schema), as far
|
|
4
|
-
* as `@k8ordo/
|
|
5
|
+
* as `@k8ordo/framework` reads it for a route's `paramsSchema`.
|
|
5
6
|
* Declared here rather than depended on so the package carries no schema
|
|
6
7
|
* library: a locale set is a list, and a list can check membership itself.
|
|
7
8
|
*/
|
|
8
|
-
type LocaleParamsSchema<L extends string> = {
|
|
9
|
+
export type LocaleParamsSchema<L extends string> = {
|
|
9
10
|
readonly '~standard': {
|
|
10
11
|
readonly version: 1;
|
|
11
12
|
readonly vendor: '@k8ordo/i18n';
|
|
@@ -30,19 +31,37 @@ type LocaleParamsSchema<L extends string> = {
|
|
|
30
31
|
} | undefined;
|
|
31
32
|
};
|
|
32
33
|
};
|
|
33
|
-
type Delocalized<L extends string> = {
|
|
34
|
+
export type Delocalized<L extends string> = {
|
|
34
35
|
/** The locale the first segment spelled, or `null` when it spelled none. */
|
|
35
36
|
readonly locale: L | null;
|
|
36
37
|
/** The pathname with that segment removed — never empty, `/` at least. */
|
|
37
38
|
readonly pathname: string;
|
|
38
39
|
};
|
|
40
|
+
/**
|
|
41
|
+
* What a locale carries besides its tag. Both are declared rather than
|
|
42
|
+
* derived: the runtime's own time zone differs between a server and each
|
|
43
|
+
* visitor's browser, and `Intl.Locale#getTextInfo` has not reached every
|
|
44
|
+
* browser.
|
|
45
|
+
*/
|
|
46
|
+
export type LocaleDefinition = {
|
|
47
|
+
/**
|
|
48
|
+
* The IANA time zone this locale's dates are shown in (`'Asia/Tokyo'`) —
|
|
49
|
+
* the same one on the server and in every browser, so a date renders the
|
|
50
|
+
* same on both sides.
|
|
51
|
+
*/
|
|
52
|
+
readonly timeZone: string;
|
|
53
|
+
/** The direction its text runs in: `<html dir>`. */
|
|
54
|
+
readonly dir: 'ltr' | 'rtl';
|
|
55
|
+
};
|
|
39
56
|
/**
|
|
40
57
|
* An application's locale set. `L` is the union of its tags, `D` the default
|
|
41
58
|
* among them.
|
|
42
59
|
*/
|
|
43
|
-
type Locales<L extends string = string, D extends L = L> = {
|
|
44
|
-
/** Every locale, in the order
|
|
60
|
+
export type Locales<L extends string = string, D extends L = L> = IntlFormats & {
|
|
61
|
+
/** Every locale, in the order defined; the first is the default unless told otherwise. */
|
|
45
62
|
readonly all: readonly L[];
|
|
63
|
+
/** Each locale's definition, as given: `definitions[locale].dir` for `<html dir>`. */
|
|
64
|
+
readonly definitions: Readonly<Record<L, LocaleDefinition>>;
|
|
46
65
|
/** The locale used when negotiation finds nothing better, and when nothing names one. */
|
|
47
66
|
readonly default: D;
|
|
48
67
|
/** Membership, as a type guard — the check `params.locale` and a pathname segment go through. */
|
|
@@ -55,38 +74,75 @@ type Locales<L extends string = string, D extends L = L> = {
|
|
|
55
74
|
* that is not BCP 47 is skipped, not thrown on: the list is user input.
|
|
56
75
|
*/
|
|
57
76
|
readonly negotiate: (requested: Iterable<string>) => L;
|
|
58
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* The locale a request asks for, for a server that answers before any
|
|
79
|
+
* page renders — sending `/` to a locale. The cookie named in `options`
|
|
80
|
+
* comes first, when the request carries it: it is the visitor's own choice,
|
|
81
|
+
* written where they switched language. Then the `Accept-Language` header,
|
|
82
|
+
* in its order of preference. Each goes through `negotiate`, so a cookie
|
|
83
|
+
* holding a locale the set no longer has falls through to the header, and
|
|
84
|
+
* nothing matching is the default.
|
|
85
|
+
*/
|
|
86
|
+
readonly negotiateRequest: (request: Request, options?: NegotiateRequestOptions) => L;
|
|
87
|
+
/**
|
|
88
|
+
* `'/ui'` → `'/en/ui'`, `'/'` → `'/en'`. Hand it a pathname without a
|
|
89
|
+
* locale segment, as `delocalize` returns one: that is not checked, so
|
|
90
|
+
* `localize('/en/ui', 'ja')` is `'/ja/en/ui'`.
|
|
91
|
+
*/
|
|
59
92
|
readonly localize: (pathname: string, locale: L) => string;
|
|
60
93
|
/** The inverse: `'/en/ui'` → `{ locale: 'en', pathname: '/ui' }`, `'/x'` → `{ locale: null, pathname: '/x' }`. */
|
|
61
94
|
readonly delocalize: (pathname: string) => Delocalized<L>;
|
|
95
|
+
/**
|
|
96
|
+
* The static build's `paths` option: every pattern that has a `/:locale`
|
|
97
|
+
* segment, once per locale, so
|
|
98
|
+
* `framework({ mode: 'static', paths: locales.paths })` is the whole
|
|
99
|
+
* answer for a site whose only parameter is the locale. A pattern with
|
|
100
|
+
* another parameter comes back still holding it (`/ja/blog/:slug`), which
|
|
101
|
+
* the build renders only as the location of a `fallback.tsx`'s shell, and
|
|
102
|
+
* otherwise refuses: expand the rest in the same function. A shell runs
|
|
103
|
+
* only its layouts' schemas, so it renders in its locale when this set's
|
|
104
|
+
* `paramsSchema` sits on `[locale]/layout.tsx`.
|
|
105
|
+
*/
|
|
106
|
+
readonly paths: (patterns: readonly string[]) => string[];
|
|
62
107
|
/**
|
|
63
108
|
* A params schema for a `[locale]` route segment, in the shape
|
|
64
|
-
* `@k8ordo/
|
|
109
|
+
* `@k8ordo/framework` runs: `export const paramsSchema =
|
|
65
110
|
* locales.paramsSchema` makes `/fr/…` a pathname the pattern does not
|
|
66
|
-
* answer.
|
|
67
|
-
* that
|
|
111
|
+
* answer. On a server, accepting a locale also makes it the current one for
|
|
112
|
+
* the render of the page that accepted it, which is how `getLocale()` knows
|
|
113
|
+
* it there; a server runtime without `AsyncLocalStorage` throws instead.
|
|
68
114
|
*/
|
|
69
115
|
readonly paramsSchema: LocaleParamsSchema<L>;
|
|
70
116
|
/**
|
|
71
117
|
* 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`
|
|
118
|
+
* segment of `location.pathname` below Vite's `base`; on the server it is what `paramsSchema`
|
|
73
119
|
* accepted for this request, or what `run` set. Neither names one → the
|
|
74
120
|
* default. Not a hook: call it anywhere, including inside a message.
|
|
75
121
|
*/
|
|
76
122
|
readonly getLocale: () => L;
|
|
77
123
|
/**
|
|
78
124
|
* Runs `fn` with `locale` as the current one for everything it starts —
|
|
79
|
-
* a test, a
|
|
80
|
-
*
|
|
125
|
+
* a test, a job, code outside a `[locale]` route. A Server Action posted
|
|
126
|
+
* from a `[locale]` page already runs in that page's locale under the
|
|
127
|
+
* framework. Server only: in the browser the URL is the locale.
|
|
81
128
|
*/
|
|
82
129
|
readonly run: <T>(locale: L, fn: () => T) => T;
|
|
83
130
|
};
|
|
84
|
-
type
|
|
131
|
+
export type NegotiateRequestOptions = {
|
|
132
|
+
/**
|
|
133
|
+
* The cookie holding the visitor's choice (`'locale'`). The name is the
|
|
134
|
+
* application's: whatever writes it — a language switcher's
|
|
135
|
+
* `cookieStore.set` — uses the same one. Omitted, only `Accept-Language`
|
|
136
|
+
* is read.
|
|
137
|
+
*/
|
|
138
|
+
readonly cookie?: string;
|
|
139
|
+
};
|
|
140
|
+
export type LocalesOptions<D extends string> = {
|
|
85
141
|
/** The default locale. Must be one of the list; the first entry when omitted. */
|
|
86
142
|
readonly default?: D;
|
|
87
143
|
};
|
|
88
144
|
/** The locale union of a `Locales` — `LocaleOf<typeof locales>`. */
|
|
89
|
-
type LocaleOf<Ls> = Ls extends Locales<infer L, infer _D> ? L : never;
|
|
145
|
+
export type LocaleOf<Ls> = Ls extends Locales<infer L, infer _D> ? L : never;
|
|
90
146
|
/**
|
|
91
147
|
* Declares the locale set once. Everything that needs to know which locales
|
|
92
148
|
* exist — the params schema of the `[locale]` segment, the static build's
|
|
@@ -94,10 +150,11 @@ type LocaleOf<Ls> = Ls extends Locales<infer L, infer _D> ? L : never;
|
|
|
94
150
|
* every message — reads this value, so the list is spelled in one place.
|
|
95
151
|
*
|
|
96
152
|
* ```ts
|
|
97
|
-
* export const locales = defineLocales(
|
|
98
|
-
*
|
|
153
|
+
* export const locales = defineLocales({
|
|
154
|
+
* ja: { timeZone: 'Asia/Tokyo', dir: 'ltr' },
|
|
155
|
+
* en: { timeZone: 'America/New_York', dir: 'ltr' },
|
|
156
|
+
* });
|
|
99
157
|
* ```
|
|
100
158
|
*/
|
|
101
|
-
declare const defineLocales: <const
|
|
102
|
-
//#endregion
|
|
103
|
-
export { Delocalized, LocaleOf, LocaleParamsSchema, Locales, LocalesOptions, defineLocales };
|
|
159
|
+
export declare const defineLocales: <const Definitions extends Readonly<Record<string, LocaleDefinition>>, D extends keyof Definitions & string = keyof Definitions & string>(definitions: Definitions, options?: LocalesOptions<D>) => Locales<keyof Definitions & string, D>;
|
|
160
|
+
//#endregion
|