@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 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 };
@@ -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 };
@@ -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,4 @@
1
+ import { parseAcceptLanguage } from "./accept-language.mjs";
2
+ import { defineLocales } from "./locales.mjs";
3
+ import { message } from "./message.mjs";
4
+ export { defineLocales, message, parseAcceptLanguage };
@@ -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 };
@@ -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 };
@@ -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
+ }