@k8ordo/i18n 0.1.0 → 1.0.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 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 are
12
- `Intl.PluralRules`.
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
- | Package | Version | Needed for |
32
- | ------------ | ------- | ----------------------------- |
33
- | `typescript` | ≥7.0.2 | the shipped type declarations |
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/static` and `@k8ordo/server`
38
- run for a route's params. On a server the current locale rides on
39
- `AsyncLocalStorage` (Node ≥ 24).
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, registered once so every message is held
44
- to it:
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(['ja', 'en']);
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.paramsSchema; // /fr/… is a 404
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 * as nav from '../../messages/nav';
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>{nav.home()}</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 * as nav from '../messages/nav';
117
+ import { greeting } from '../messages/nav';
98
118
 
99
119
  export function Toolbar() {
100
- return <button>{nav.greeting('k8o')}</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 expands `[locale]` from `locales.all`.
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
- This package ships its documentation so an AI coding assistant reads the exact
113
- installed version:
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
- - `node_modules/@k8ordo/i18n/docs/GUIDE.md` — the design guide
116
- - `node_modules/@k8ordo/i18n/docs/llms.txt` — the index
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 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.
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
@@ -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 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.
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
@@ -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 };
@@ -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 current locale as a string, or `null` when nothing names one: no
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 currentLocale: () => string | null;
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
- //#endregion
47
- export { LocaleStorage, RegisteredSet, currentLocale, inBrowser, localeStorage, register, registeredDefault, setRegistered };
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
- /** The first segment of the browser's URL, whatever it spells. */
29
- const browserSegment = () => global.location?.pathname.split("/")[1] ?? "";
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 current locale as a string, or `null` when nothing names one: no
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 currentLocale = () => {
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
@@ -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 };
@@ -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/static` / `@k8ordo/server` read it for a route's `paramsSchema`.
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 given; the first is the default unless told otherwise. */
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
- /** `'/ui'` → `'/en/ui'`, `'/'` → `'/en'`. The pathname must not already carry a locale segment. */
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/static` / `@k8ordo/server` run: `export const paramsSchema =
109
+ * `@k8ordo/framework` runs: `export const paramsSchema =
65
110
  * 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.
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 Server Action, code outside a `[locale]` route. Server only:
80
- * in the browser the URL is the locale.
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 LocalesOptions<D extends string> = {
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(['ja', 'en']);
98
- * export const locales = defineLocales(['en-US', 'en-GB', 'ja'], { default: 'ja' });
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 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 };
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