saykit 0.11.0 → 0.12.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
@@ -4,9 +4,9 @@
4
4
 
5
5
  [![Coverage](https://codecov.io/gh/k0d13/saykit/graph/badge.svg?flag=integration)](https://codecov.io/gh/k0d13/saykit?flags%5B0%5D=integration)
6
6
 
7
- The core runtime for [SayKit](https://saykit.js.org). Exports `createCatalogue`, which holds your locales and where each one's messages come from, `createView`, which binds one locale and formats messages using ICU MessageFormat, and `createStore`, which holds the current view and swaps it when you switch locale.
7
+ The core runtime for [SayKit](https://saykit.js.org). Exports `createCatalogue`, which holds your locales and where each one's messages come from, `createView`, which binds one locale and formats its compiled messages, and `createStore`, which holds the current view and swaps it when you switch locale.
8
8
 
9
- You author messages with the `` say`...` `` tagged template (and `say.plural`, `say.ordinal`, `say.select`), and format numbers, dates and times for the locale with `say.number`, `say.date` and `say.time`; a SayKit build-tool plugin rewrites them at build time into small runtime calls.
9
+ You author messages with the `` say`...` `` tagged template (and `say.plural`, `say.ordinal`, `say.select`), and format numbers, dates and times for the locale with `say.number`, `say.date` and `say.time`; a SayKit build-tool plugin rewrites them at build time into small runtime calls, and `saykit compile` turns each catalogue into a `.js` module of data that you import.
10
10
 
11
11
  ## Install
12
12
 
@@ -20,8 +20,8 @@ You will normally also want [`@saykit/config`](https://github.com/k0d13/saykit/t
20
20
 
21
21
  ```ts
22
22
  import { createCatalogue } from 'saykit';
23
- import en from './locales/en.po';
24
- import fr from './locales/fr.po';
23
+ import en from './locales/en.js';
24
+ import fr from './locales/fr.js';
25
25
 
26
26
  const catalogue = createCatalogue({ en, fr });
27
27
 
package/dist/index.d.mts CHANGED
@@ -1,14 +1,63 @@
1
- //#region src/types.d.ts
2
- type Tuple = [any, ...any[]];
3
- type Disallow<T, K extends PropertyKey> = T & Partial<Record<K, never>>;
4
- type Awaitable<T> = T | PromiseLike<T>;
1
+ /**
2
+ * What each node in a compiled message is, by the number it starts with.
3
+ *
4
+ * The numbers are the format, so a kind is only ever appended, never renumbered or reused.
5
+ */
6
+ export declare const Kind: {
7
+ readonly Value: 0;
8
+ readonly Number: 1;
9
+ readonly DateTime: 2;
10
+ readonly Plural: 3;
11
+ readonly Ordinal: 4;
12
+ readonly Select: 5;
13
+ readonly Hash: 6;
14
+ };
15
+ export type Kind = (typeof Kind)[keyof typeof Kind];
16
+ /**
17
+ * A compiled message.
18
+ *
19
+ * The CLI compiles a catalogue to this: strings and arrays, with styles already resolved to
20
+ * `Intl` options. It is plain JSON, so it can cross a server/client boundary.
21
+ *
22
+ * A node is an array starting with its {@link Kind}; an array starting with anything else is a
23
+ * concatenation. Operands are descriptor keys, never nodes. A message with no placeholders is its
24
+ * own string.
25
+ */
26
+ type Message = string | Message[] | [typeof Kind.Value, string] | [typeof Kind.Number, string, Intl.NumberFormatOptions?, number?] | [typeof Kind.DateTime, string, Intl.DateTimeFormatOptions?] | [typeof Kind.Plural, string, Message.Cases, number?] | [typeof Kind.Ordinal, string, Message.Cases, number?] | [typeof Kind.Select, string, Message.Cases] | [typeof Kind.Hash];
27
+ declare namespace Message {
28
+ /**
29
+ * A plural's or a select's branches, by key. A plural's keys are CLDR
30
+ * categories or an exact `=n`, and `other` is the fallback for both.
31
+ */
32
+ type Cases = {
33
+ [key: string]: Message;
34
+ };
35
+ /** What a compiled message is called with: the descriptor the transform built. */
36
+ type Values = Record<string, unknown>;
37
+ }
38
+ /**
39
+ * Compile one message into the function that formats it.
40
+ *
41
+ * Called once per message per locale; a view memoises the result.
42
+ *
43
+ * @param message The compiled message, as the CLI emitted it
44
+ * @param locale The locale to format in
45
+ * @param options Whether written values are wrapped in bidi isolation marks
46
+ * @returns A function from a descriptor to the formatted string
47
+ */
48
+ export declare function compileMessage(message: Message, locale: string, options?: {
49
+ bidiIsolation?: 'default' | 'none';
50
+ }): (values: Message.Values) => string;
51
+ export type Tuple = [any, ...any[]];
52
+ export type Disallow<T, K extends PropertyKey> = T & Partial<Record<K, never>>;
53
+ export type Awaitable<T> = T | PromiseLike<T>;
5
54
  /**
6
55
  * A value wrapped in the name its ICU placeholder should take, written inline
7
56
  * as a single-key object: `` say`Total: ${{ cartTotal: getTotal() }}` ``. The
8
57
  * transform reads the key at build time and compiles only the value, so this
9
58
  * is never a real object at runtime.
10
59
  */
11
- type Named<T> = {
60
+ export type Named<T> = {
12
61
  [name: string]: T;
13
62
  };
14
63
  /**
@@ -17,7 +66,7 @@ type Named<T> = {
17
66
  * `Branch` is what a case may be written as: text everywhere the message is
18
67
  * text, widening in JSX, where a case showing the number has to be a fragment.
19
68
  */
20
- interface NumeralOptions<Branch = string> extends Omit<Partial<Record<Intl.LDMLPluralRule, Branch>>, 'other'> {
69
+ export interface NumeralOptions<Branch = string> extends Omit<Partial<Record<Intl.LDMLPluralRule, Branch>>, 'other'> {
21
70
  other: Branch;
22
71
  [digit: number]: Branch;
23
72
  /**
@@ -26,7 +75,7 @@ interface NumeralOptions<Branch = string> extends Omit<Partial<Record<Intl.LDMLP
26
75
  */
27
76
  offset?: number;
28
77
  }
29
- interface SelectOptions<Branch = string> {
78
+ export interface SelectOptions<Branch = string> {
30
79
  other: Branch;
31
80
  [match: string | number]: Branch;
32
81
  }
@@ -38,38 +87,55 @@ interface SelectOptions<Branch = string> {
38
87
  * A skeleton is where the formats the named styles have no word for live:
39
88
  * currency, compact notation, a year and month with no day.
40
89
  */
41
- type Skeleton = `::${string}`;
90
+ export type Skeleton = `::${string}`;
42
91
  /**
43
92
  * Formatting for a `{arg, number}` placeholder.
44
93
  *
45
94
  * A literal `NumberFormat` pattern such as `#,##0.00` is also accepted, for the
46
95
  * cases neither the named styles nor a skeleton spell more clearly.
47
96
  */
48
- interface NumberOptions {
97
+ export interface NumberOptions {
49
98
  style?: 'integer' | 'percent' | Skeleton | (string & {});
50
99
  }
51
100
  /** Formatting for a `{arg, date}` or `{arg, time}` placeholder. */
52
- interface DateTimeOptions {
101
+ export interface DateTimeOptions {
53
102
  style?: 'short' | 'medium' | 'long' | 'full' | Skeleton;
54
103
  }
55
- //#endregion
56
- //#region src/view.d.ts
57
104
  declare namespace View {
105
+ /**
106
+ * One locale's messages, compiled.
107
+ *
108
+ * Each is a {@link Message} the CLI compiled from the catalogue. They are plain data, so a
109
+ * server can hand them to a client tree.
110
+ */
58
111
  type Messages = {
59
- [key: string]: string;
112
+ [key: string]: Message;
60
113
  };
61
114
  /**
62
- * What the transform compiles a message down to: the id it was extracted
63
- * under, or the ICU message itself when there was nothing to extract, with
64
- * its values behind one underscore each.
115
+ * What the transform compiles a message down to: its extracted id, or the compiled message
116
+ * itself when there was nothing to extract. Values sit behind one underscore each.
65
117
  */
66
118
  type Descriptor = ({
67
119
  id: string;
68
120
  } | {
69
- message: string;
121
+ message: Message;
70
122
  }) & {
71
123
  [match: string | number]: unknown;
72
124
  };
125
+ interface Options {
126
+ /**
127
+ * Whether a substituted value is wrapped in Unicode bidi isolation marks,
128
+ * U+2068 and U+2069, so right-to-left text cannot reorder the sentence
129
+ * around it.
130
+ *
131
+ * `'none'` leaves values byte-for-byte as given, for output that is parsed
132
+ * after it is formatted: a Discord mention such as `<#123>` stops being
133
+ * one once the id inside it is isolated.
134
+ *
135
+ * @default 'default'
136
+ */
137
+ bidiIsolation?: 'default' | 'none';
138
+ }
73
139
  }
74
140
  /**
75
141
  * One locale, bound to the messages it formats against.
@@ -115,12 +181,16 @@ interface View<Locale extends string = string> {
115
181
  readonly locale: Locale;
116
182
  /** The messages this view formats against. */
117
183
  readonly messages: Readonly<View.Messages>;
184
+ /**
185
+ * How this view formats its messages. Plain data, so it can cross a
186
+ * server/client boundary alongside {@link locale} and {@link messages}.
187
+ */
188
+ readonly options: Readonly<View.Options>;
118
189
  /**
119
190
  * Format the message a descriptor names, or the message it carries.
120
191
  *
121
- * A message with nothing to translate, a lone `say.date(x)` with no text
122
- * around it, is never extracted: the transform writes the ICU into the call
123
- * instead of an id.
192
+ * A message with nothing to translate, such as a lone `say.date(x)`, is never extracted:
193
+ * the transform writes the compiled message into the call instead of an id.
124
194
  *
125
195
  * @param descriptor Descriptor to format
126
196
  * @returns The formatted message
@@ -246,17 +316,14 @@ interface View<Locale extends string = string> {
246
316
  * Create a view over one locale and the messages it formats against.
247
317
  *
248
318
  * A catalogue memoises one per locale, which is how application code usually
249
- * reaches one; a single-locale app can build one directly. The format cache
250
- * belongs to the view, so a view built over one set of messages can never be
251
- * served a format compiled from another.
319
+ * reaches one; a single-locale app can build one directly.
252
320
  *
253
321
  * @param locale The locale to bind to
254
322
  * @param messages The messages this view formats against
323
+ * @param options How this view formats its messages
255
324
  * @returns The view
256
325
  */
257
- declare function createView<Locale extends string>(locale: Locale, messages: View.Messages): View<Locale>;
258
- //#endregion
259
- //#region src/catalogue.d.ts
326
+ export declare function createView<Locale extends string>(locale: Locale, messages: View.Messages, options?: View.Options): View<Locale>;
260
327
  declare namespace Catalogue {
261
328
  /**
262
329
  * One guess at a locale, or several. Guesses are read from cookies, headers
@@ -281,6 +348,8 @@ declare namespace Catalogue {
281
348
  type Produced = View.Messages | {
282
349
  default: View.Messages;
283
350
  };
351
+ /** How every view the catalogue hands back formats its messages. */
352
+ type Options = View.Options;
284
353
  }
285
354
  /**
286
355
  * Everything an application knows how to say: its locales and where their
@@ -365,11 +434,11 @@ interface Catalogue<Locale extends string = string> {
365
434
  * @param messages Where each locale's messages come from, keyed by locale. The
366
435
  * keys are the catalogue's locales, in the order they are written, and the
367
436
  * first of them is the default locale
437
+ * @param options How every view the catalogue hands back formats its
438
+ * messages, such as `{ bidiIsolation: 'none' }`
368
439
  * @returns The catalogue
369
440
  */
370
- declare function createCatalogue<const Locale extends string = string>(messages: Record<Locale, Catalogue.Source>): Catalogue<Locale>;
371
- //#endregion
372
- //#region src/store.d.ts
441
+ export declare function createCatalogue<const Locale extends string = string>(messages: Record<Locale, Catalogue.Source>, options?: Catalogue.Options): Catalogue<Locale>;
373
442
  declare namespace Store {
374
443
  /** Called after a successful switch, with the view that is now current. */
375
444
  type Listener<Locale extends string> = (view: View<Locale>) => void;
@@ -450,9 +519,7 @@ interface Store<Locale extends string = string> {
450
519
  * @returns The store
451
520
  * @throws If the starting locale has no messages loaded
452
521
  */
453
- declare function createStore<Locale extends string>(catalogue: Catalogue<Locale>, locale?: Locale): Store<Locale>;
454
- //#endregion
455
- //#region src/scope.d.ts
522
+ export declare function createStore<Locale extends string>(catalogue: Catalogue<Locale>, locale?: Locale): Store<Locale>;
456
523
  declare namespace Scope {
457
524
  /**
458
525
  * Where a scope holds the view a piece of work is running under.
@@ -561,6 +628,5 @@ interface Scope {
561
628
  * browser wants
562
629
  * @returns The scope
563
630
  */
564
- declare function createScope(storage?: Scope.Storage): Scope;
565
- //#endregion
566
- export { Awaitable, type Catalogue, DateTimeOptions, Disallow, Named, NumberOptions, NumeralOptions, type Scope, SelectOptions, Skeleton, type Store, Tuple, type View, createCatalogue, createScope, createStore, createView };
631
+ export declare function createScope(storage?: Scope.Storage): Scope;
632
+ export type { Catalogue, Message, Scope, Store, View };
package/dist/index.mjs CHANGED
@@ -1,448 +1,205 @@
1
- import { parse } from "@messageformat/parser";
2
- import { MessageFormat } from "messageformat";
3
- import { getDateTimeFormatOptions, parseDateTokens } from "@messageformat/date-skeleton";
4
- import { getNumberFormatOptions, parseNumberPattern, parseNumberSkeleton } from "@messageformat/number-skeleton";
5
- import { getLocaleDir } from "messageformat/functions";
6
- //#region src/messageformat/options.ts
7
- const literal = (value) => ({
8
- type: "literal",
9
- value
10
- });
11
- function options(value) {
12
- return typeof value === "object" && value !== null ? value : {};
1
+ // src/runtime.ts
2
+ const formatters = /* @__PURE__ */ new Map();
3
+ function memoise(key, make) {
4
+ let formatter = formatters.get(key);
5
+ if (formatter === void 0) formatters.set(key, formatter = make());
6
+ return formatter;
13
7
  }
14
- //#endregion
15
- //#region src/messageformat/styles.ts
16
- var StyleError = class extends Error {};
17
- const DATE_STYLES = {
18
- short: {
19
- year: "numeric",
20
- month: "numeric",
21
- day: "numeric"
22
- },
23
- medium: {
24
- year: "numeric",
25
- month: "short",
26
- day: "numeric"
27
- },
28
- long: {
29
- year: "numeric",
30
- month: "long",
31
- day: "numeric"
32
- },
33
- full: {
34
- year: "numeric",
35
- month: "long",
36
- day: "numeric",
37
- weekday: "long"
38
- }
39
- };
40
- const TIME_STYLES = {
41
- short: {
42
- hour: "numeric",
43
- minute: "numeric"
44
- },
45
- medium: {
46
- hour: "numeric",
47
- minute: "numeric",
48
- second: "numeric"
49
- },
50
- long: {
51
- hour: "numeric",
52
- minute: "numeric",
53
- second: "numeric",
54
- timeZoneName: "short"
55
- },
56
- full: {
57
- hour: "numeric",
58
- minute: "numeric",
59
- second: "numeric",
60
- timeZoneName: "long"
61
- }
62
- };
63
- function dateTimeStyle(kind, style) {
64
- const named = kind === "date" ? DATE_STYLES : TIME_STYLES;
65
- if (style.startsWith("::")) {
66
- const errors = [];
67
- const opt = getDateTimeFormatOptions(parseDateTokens(style.slice(2)), (_type, message) => errors.push(message));
68
- if (errors.length > 0) throw new StyleError(errors[0]);
69
- if (Object.keys(opt).length === 0) throw new StyleError(`Empty skeleton ${style}`);
70
- return opt;
71
- }
72
- if (style === "") return named.medium;
73
- if (Object.hasOwn(named, style)) return named[style];
74
- throw new StyleError(`Unsupported ${kind} style ${style}`);
8
+ function numberFormat(locale, options) {
9
+ return memoise(`n${locale}${JSON.stringify(options ?? null)}`, () => new Intl.NumberFormat(locale, options));
75
10
  }
76
- function numberStyle(style) {
77
- if (style === "") return {};
78
- if (style === "integer") return { maximumFractionDigits: 0 };
79
- if (style === "percent") return { style: "percent" };
80
- const errors = [];
81
- const onError = (error) => errors.push(String(error));
82
- const skeleton = style.startsWith("::") ? parseNumberSkeleton(style.slice(2), onError) : parseNumberPattern(style, "XXX", onError);
83
- let scale;
84
- const opt = getNumberFormatOptions(skeleton, (stem, option) => {
85
- if (stem === "scale") scale = Number(option);
86
- else errors.push(`Unsupported number stem ${stem}`);
87
- });
88
- if (errors.length > 0) throw new StyleError(errors[0]);
89
- return scale === void 0 ? opt : {
90
- ...opt,
91
- scale
92
- };
11
+ function dateTimeFormat(locale, options) {
12
+ return memoise(`d${locale}${JSON.stringify(options ?? null)}`, () => new Intl.DateTimeFormat(locale, options));
93
13
  }
94
- //#endregion
95
- //#region src/messageformat/convert.ts
96
- const isSelect = (token) => token.type === "plural" || token.type === "select" || token.type === "selectordinal";
97
- const asKey = (key) => /^=\d+$/.test(key) ? Number(key.slice(1)) : key;
98
- function functionRef(token) {
99
- let style = "";
100
- for (const part of token.param ?? []) {
101
- if (part.type !== "content") throw new Error(`Unsupported style part: ${part.type}`);
102
- style += part.value;
103
- }
104
- style = style.trim();
105
- const ref = (name, value) => ({
106
- type: "function",
107
- name,
108
- ...value === void 0 ? {} : { options: { options: literal(value) } }
109
- });
110
- try {
111
- switch (token.key) {
112
- case "date":
113
- case "time": return ref("say:datetime", dateTimeStyle(token.key, style));
114
- case "number": return ref("say:number", numberStyle(style));
115
- case "duration": return ref("say:duration");
116
- default: throw new StyleError(`Unsupported argument type ${token.key}`);
117
- }
118
- } catch (error) {
119
- /* v8 ignore next */
120
- if (!(error instanceof StyleError)) throw error;
121
- switch (token.key) {
122
- case "date":
123
- case "time": return ref("say:datetime", dateTimeStyle(token.key, ""));
124
- case "number": return ref("say:number", {});
125
- default: return ref("say:string");
126
- }
127
- }
14
+ function pluralRules(locale, type) {
15
+ return memoise(`p${locale}${type}`, () => new Intl.PluralRules(locale, { type }));
128
16
  }
129
- function toPart(token, plural) {
130
- switch (token.type) {
131
- case "content": return token.value;
132
- case "argument": return {
133
- type: "expression",
134
- arg: {
135
- type: "variable",
136
- name: token.arg
137
- }
138
- };
139
- case "function": return {
140
- type: "expression",
141
- arg: {
142
- type: "variable",
143
- name: token.arg
144
- },
145
- functionRef: functionRef(token)
146
- };
147
- case "octothorpe":
148
- /* v8 ignore next */
149
- return plural ? {
150
- type: "expression",
151
- arg: {
152
- type: "variable",
153
- name: plural
154
- }
155
- } : "#";
156
- /* v8 ignore next 2 -- the token union has no other members */
157
- default: throw new Error(`Unsupported token type: ${token.type}`);
158
- }
159
- }
160
- const sameSelector = (a) => (b) => a.arg === b.arg && a.type === b.type && a.offset === b.offset;
161
- function findSelectors(tokens) {
162
- const selectors = [];
163
- const add = (selector) => {
164
- const existing = selectors.find(sameSelector(selector));
165
- if (existing) {
166
- existing.keys.push(...selector.keys);
167
- return;
168
- }
169
- const taken = selectors.filter((s) => s.arg === selector.arg).length;
170
- const name = taken === 0 ? selector.arg : `${selector.arg}#${taken}`;
171
- selectors.push({
172
- ...selector,
173
- name
174
- });
175
- };
176
- for (const token of tokens) {
177
- if (!isSelect(token)) continue;
178
- add({
179
- arg: token.arg,
180
- type: token.type,
181
- offset: token.pluralOffset ?? 0,
182
- keys: token.cases.map((c) => token.type === "select" ? c.key : asKey(c.key))
183
- });
184
- for (const c of token.cases) for (const inner of findSelectors(c.tokens)) add(inner);
185
- }
186
- return selectors;
187
- }
188
- function declaration({ arg, name, type, offset }) {
189
- const functionRef = type === "select" ? {
190
- type: "function",
191
- name: "say:string"
192
- } : {
193
- type: "function",
194
- name: "say:plural",
195
- options: { options: literal({
196
- offset,
197
- ordinal: type === "selectordinal"
198
- }) }
199
- };
200
- const value = {
201
- type: "expression",
202
- arg: {
203
- type: "variable",
204
- name: arg
205
- },
206
- functionRef
207
- };
208
- return name === arg ? {
209
- type: "input",
210
- name,
211
- value
212
- } : {
213
- type: "local",
214
- name,
215
- value
216
- };
17
+ function toNumeric(value) {
18
+ const raw = typeof value === "object" && value !== null ? value.valueOf() : value;
19
+ if (typeof raw === "bigint") return raw;
20
+ if (raw === null || raw === "") return void 0;
21
+ const number = Number(raw);
22
+ return Number.isNaN(number) ? void 0 : number;
217
23
  }
218
- function sortKeys(keys) {
219
- const rank = (key) => typeof key === "number" ? 0 : key === "other" ? 2 : 1;
220
- return Array.from(new Set(keys)).sort((a, b) => rank(a) - rank(b));
24
+ function toDate(value) {
25
+ let raw = value;
26
+ if (typeof raw === "object" && raw !== null && !(raw instanceof Date)) raw = raw.valueOf();
27
+ if (typeof raw === "number" || typeof raw === "string") raw = new Date(raw);
28
+ return raw instanceof Date && !Number.isNaN(raw.getTime()) ? raw : void 0;
221
29
  }
222
- function toMessage(ast) {
223
- const selectors = findSelectors(ast);
224
- if (selectors.length === 0) return {
225
- type: "message",
226
- declarations: [],
227
- pattern: ast.map((t) => toPart(t, null))
228
- };
229
- let tuples = [[]];
230
- for (const selector of selectors) {
231
- const keys = sortKeys(selector.keys);
232
- tuples = tuples.flatMap((tuple) => keys.map((key) => [...tuple, key]));
233
- }
234
- const variants = tuples.map((tuple) => ({
235
- keys: tuple.map((key) => key === "other" ? { type: "*" } : {
236
- type: "literal",
237
- quoted: false,
238
- value: String(key)
239
- }),
240
- value: []
241
- }));
242
- function fill(tokens, plural, filter) {
243
- for (const token of tokens) {
244
- if (isSelect(token)) {
245
- const index = selectors.findIndex(sameSelector({
246
- arg: token.arg,
247
- type: token.type,
248
- offset: token.pluralOffset ?? 0
249
- }));
250
- const inner = token.type === "select" ? plural : selectors[index].name;
251
- for (const c of token.cases) {
252
- const key = token.type === "select" ? c.key : asKey(c.key);
253
- fill(c.tokens, inner, [...filter, {
254
- index,
255
- key
256
- }]);
257
- }
258
- continue;
259
- }
260
- for (const variant of variants) {
261
- if (!filter.every(({ index, key }) => {
262
- const vk = variant.keys[index];
263
- return vk.type === "*" ? key === "other" : String(key) === vk.value;
264
- })) continue;
265
- const part = toPart(token, plural);
266
- const last = variant.value.length - 1;
267
- if (typeof part === "string" && typeof variant.value[last] === "string") variant.value[last] += part;
268
- else variant.value.push(part);
269
- }
270
- }
271
- }
272
- fill(ast, null, []);
273
- return {
274
- type: "select",
275
- declarations: selectors.map(declaration),
276
- selectors: selectors.map((s) => ({
277
- type: "variable",
278
- name: s.name
279
- })),
280
- variants
281
- };
30
+ // src/message.ts
31
+ const Kind = {
32
+ Value: 0,
33
+ Number: 1,
34
+ DateTime: 2,
35
+ Plural: 3,
36
+ Ordinal: 4,
37
+ Select: 5,
38
+ Hash: 6
39
+ };
40
+ const FSI = String.fromCharCode(8296);
41
+ const RLI = String.fromCharCode(8295);
42
+ const PDI = String.fromCharCode(8297);
43
+ const RTL = /* @__PURE__ */ new Set([
44
+ "Adlm",
45
+ "Arab",
46
+ "Hebr",
47
+ "Mand",
48
+ "Nkoo",
49
+ "Rohg",
50
+ "Syrc",
51
+ "Thaa"
52
+ ]);
53
+ function localeDir(locale) {
54
+ try {
55
+ const script = new Intl.Locale(locale).maximize().script;
56
+ if (script) return RTL.has(script) ? "rtl" : "ltr";
57
+ } catch {}
58
+ return "auto";
282
59
  }
283
- //#endregion
284
- //#region src/messageformat/values.ts
285
- function part(type, locale, parts) {
286
- const dir = getLocaleDir(locale);
287
- /* v8 ignore next 3 */
288
- return dir === "ltr" || dir === "rtl" ? {
289
- type,
290
- dir,
291
- locale,
292
- parts
293
- } : {
294
- type,
295
- locale,
296
- parts
297
- };
60
+ function isolate(ctx, written, localised) {
61
+ if (!ctx.dir) return written;
62
+ if (!localised) return FSI + written + PDI;
63
+ if (ctx.dir === "ltr") return written;
64
+ return (ctx.dir === "rtl" ? RLI : FSI) + written + PDI;
298
65
  }
299
- function numeric(operand) {
300
- const value = typeof operand === "object" && operand !== null ? operand.valueOf() : operand;
301
- if (typeof value === "bigint") return value;
302
- const number = Number(value);
303
- if (Number.isNaN(number) && value !== void 0) throw new Error("Input is not numeric");
304
- return number;
66
+ function unformattable(kind, value, error) {
67
+ console.warn(`saykit: cannot format ${String(value)} as a ${kind}`, ...error ? [error] : []);
68
+ return String(value);
305
69
  }
306
- function temporal(operand) {
307
- let value = operand;
308
- if (typeof value === "object" && value !== null && !(value instanceof Date)) value = value.valueOf();
309
- if (typeof value === "number" || typeof value === "string") value = new Date(value);
310
- if (!(value instanceof Date) || Number.isNaN(value.getTime())) throw new Error("Input is not a valid date");
311
- return value;
70
+ function prepare(kind, make) {
71
+ try {
72
+ return make();
73
+ } catch (error) {
74
+ console.warn(`saykit: cannot build a ${kind} formatter`, error);
75
+ return;
76
+ }
312
77
  }
313
- function number(locales, value, opt) {
314
- let nf;
315
- let locale;
316
- const format = () => nf ??= new Intl.NumberFormat(locales, opt);
317
- const resolved = () => locale ??= format().resolvedOptions().locale;
318
- return {
319
- type: "number",
320
- get dir() {
321
- return getLocaleDir(resolved());
322
- },
323
- get options() {
324
- return { ...opt };
325
- },
326
- toParts: () => [part("number", resolved(), format().formatToParts(value))],
327
- toString: () => format().format(value),
328
- valueOf: () => value
78
+ function formatNumber(ctx, value, out, options, scale) {
79
+ const format = prepare("number", () => numberFormat(ctx.locale, options));
80
+ return (values) => {
81
+ const raw = value(values);
82
+ if (raw === void 0) return void 0;
83
+ const number = toNumeric(raw);
84
+ if (number === void 0 || !format) return unformattable("number", raw);
85
+ let scaled = number;
86
+ if (scale !== void 0) scaled = typeof number === "bigint" && Number.isInteger(scale) ? number * BigInt(scale) : Number(number) * scale;
87
+ return out ? isolate(ctx, format.format(scaled), true) : format.format(scaled);
329
88
  };
330
89
  }
331
- function datetime(locales, value, opt) {
332
- let dtf;
333
- let locale;
334
- const format = () => dtf ??= new Intl.DateTimeFormat(locales, opt);
335
- const resolved = () => locale ??= format().resolvedOptions().locale;
90
+ function arms(cases, ctx, out) {
91
+ const built = /* @__PURE__ */ new Map();
92
+ for (const [key, node] of Object.entries(cases)) built.set(key, build(node, ctx, out));
336
93
  return {
337
- type: "datetime",
338
- get dir() {
339
- return getLocaleDir(resolved());
340
- },
341
- get options() {
342
- return { ...opt };
343
- },
344
- toParts: () => [part("datetime", resolved(), format().formatToParts(value))],
345
- toString: () => format().format(value),
346
- valueOf: () => value
94
+ built,
95
+ other: built.get("other")
347
96
  };
348
97
  }
349
- function duration(seconds) {
350
- if (!Number.isFinite(seconds)) return String(seconds);
351
- const sign = seconds < 0 ? "-" : "";
352
- const total = Math.round(Math.abs(seconds) * 1e3) / 1e3;
353
- const secs = total % 60;
354
- const minutes = Math.floor(total / 60);
355
- const hours = Math.floor(minutes / 60);
356
- const written = Math.round(secs) === secs ? String(secs) : secs.toFixed(3);
357
- const parts = hours > 0 ? [
358
- hours,
359
- minutes % 60,
360
- written
361
- ] : [minutes, written];
362
- const first = parts.shift();
363
- const pad = (n) => Number(n) < 10 ? `0${n}` : String(n);
364
- return sign + [first, ...parts.map(pad)].join(":");
365
- }
366
- //#endregion
367
- //#region src/messageformat/functions.ts
368
- const functions = {
369
- "say:number": (ctx, opt, operand) => {
370
- const { scale, ...nf } = options(opt.options);
371
- const value = numeric(operand);
372
- return number(ctx.locales, scale ? Number(value) * scale : value, nf);
373
- },
374
- "say:datetime": (ctx, opt, operand) => datetime(ctx.locales, temporal(operand), options(opt.options)),
375
- "say:duration": (_ctx, _opt, operand) => {
376
- const value = Number(numeric(operand));
377
- const str = duration(value);
378
- return {
379
- type: "say:duration",
380
- toParts: () => [{
381
- type: "say:duration",
382
- value: str
383
- }],
384
- toString: () => str,
385
- valueOf: () => value
386
- };
387
- },
388
- "say:plural": (ctx, opt, operand) => {
389
- const { offset = 0, ordinal = false } = options(opt.options);
390
- const value = numeric(operand);
391
- const shifted = typeof value === "bigint" ? value - BigInt(offset) : value - offset;
392
- const result = number(ctx.locales, shifted, {});
393
- result.valueOf = () => value;
394
- let rules;
395
- result.selectKey = (keys) => {
396
- const exact = String(value);
397
- if (keys.has(exact)) return exact;
398
- rules ??= new Intl.PluralRules(ctx.locales, {
399
- localeMatcher: ctx.localeMatcher,
400
- type: ordinal ? "ordinal" : "cardinal"
401
- });
402
- const category = rules.select(Number(shifted));
403
- return keys.has(category) ? category : null;
404
- };
405
- return result;
406
- },
407
- "say:string": (_ctx, _opt, operand) => {
408
- const str = operand === void 0 ? "" : String(operand);
409
- return {
410
- type: "string",
411
- selectKey: (keys) => keys.has(str) ? str : null,
412
- toParts: () => [{
413
- type: "string",
414
- value: str
415
- }],
416
- toString: () => str,
417
- valueOf: () => str
98
+ function build(node, ctx, out) {
99
+ if (typeof node === "string") return () => node;
100
+ if (typeof node[0] !== "number") {
101
+ const parts = node.map((part) => build(part, ctx, out));
102
+ return (values) => {
103
+ let written = "";
104
+ for (const part of parts) written += part(values) ?? "";
105
+ return written;
418
106
  };
419
107
  }
420
- };
421
- //#endregion
422
- //#region src/messageformat/index.ts
423
- function compile(locale, source) {
424
- return new MessageFormat(locale, toMessage(parse(source)), { functions });
108
+ const read = (key) => (values) => values[key];
109
+ switch (node[0]) {
110
+ case Kind.Value: {
111
+ const key = node[1];
112
+ if (!out) return read(key);
113
+ return (values) => {
114
+ const value = values[key];
115
+ return value === void 0 ? value : isolate(ctx, String(value), false);
116
+ };
117
+ }
118
+ case Kind.Number: return formatNumber(ctx, read(node[1]), out, node[2], node[3]);
119
+ case Kind.DateTime: {
120
+ const value = read(node[1]);
121
+ const format = prepare("date", () => dateTimeFormat(ctx.locale, node[2]));
122
+ return (values) => {
123
+ const raw = value(values);
124
+ if (raw === void 0) return void 0;
125
+ const date = toDate(raw);
126
+ if (!date || !format) return unformattable("date", raw);
127
+ return out ? isolate(ctx, format.format(date), true) : format.format(date);
128
+ };
129
+ }
130
+ case Kind.Plural:
131
+ case Kind.Ordinal: {
132
+ const value = read(node[1]);
133
+ const offset = node[3] ?? 0;
134
+ const rules = pluralRules(ctx.locale, node[0] === Kind.Ordinal ? "ordinal" : "cardinal");
135
+ const shift = (number) => offset === 0 ? number : typeof number === "bigint" ? number - BigInt(offset) : number - offset;
136
+ const hash = (values) => {
137
+ const number = toNumeric(value(values));
138
+ return number === void 0 ? void 0 : shift(number);
139
+ };
140
+ const { built, other } = arms(node[2], {
141
+ ...ctx,
142
+ hash
143
+ }, out);
144
+ return (values) => {
145
+ const number = toNumeric(value(values));
146
+ let arm;
147
+ if (number !== void 0) {
148
+ arm = built.get(`=${String(number)}`);
149
+ arm ??= built.get(rules.select(Number(shift(number))));
150
+ }
151
+ return (arm ?? other)?.(values);
152
+ };
153
+ }
154
+ case Kind.Select: {
155
+ const value = read(node[1]);
156
+ const { built, other } = arms(node[2], ctx, out);
157
+ return (values) => {
158
+ const raw = value(values);
159
+ return ((raw === void 0 ? void 0 : built.get(String(raw))) ?? other)?.(values);
160
+ };
161
+ }
162
+ default:
163
+ if (!ctx.hash) return () => "#";
164
+ return formatNumber(ctx, ctx.hash, out);
165
+ }
425
166
  }
426
- //#endregion
427
- //#region src/view.ts
428
- function resolveDescriptorValues(descriptor) {
429
- return Object.fromEntries(Object.entries(descriptor).filter(([key]) => key !== "id" && key !== "message").map(([key, value]) => [key.startsWith("_") ? key.slice(1) : key, value]));
167
+ function compileMessage(message, locale, options = {}) {
168
+ if (typeof message === "string") return () => message;
169
+ const formatter = build(message, {
170
+ locale,
171
+ dir: options.bidiIsolation === "none" ? void 0 : localeDir(locale),
172
+ hash: void 0
173
+ }, true);
174
+ return (values) => String(formatter(values) ?? "");
430
175
  }
176
+ // src/view.ts
431
177
  function macro(name) {
432
178
  throw new Error(`'say.${name}' is a macro and must be used with the relevant saykit plugin`);
433
179
  }
434
- function createView(locale, messages) {
435
- const own = Object.freeze({ ...messages });
436
- const formats = /* @__PURE__ */ new Map();
180
+ function snapshot(value) {
181
+ if (typeof value !== "object" || value === null) return value;
182
+ if (Array.isArray(value)) return Object.freeze(value.map(snapshot));
183
+ return Object.freeze(Object.fromEntries(Object.entries(value).map(([key, node]) => [key, snapshot(node)])));
184
+ }
185
+ function createView(locale, messages, options = {}) {
186
+ const { bidiIsolation } = options;
187
+ const ownOptions = Object.freeze(bidiIsolation ? { bidiIsolation } : {});
188
+ const own = snapshot(messages);
437
189
  const say = (() => {
438
190
  throw new Error("'say' is a macro and must be used with the relevant saykit plugin");
439
191
  });
192
+ const compiled = /* @__PURE__ */ new Map();
440
193
  function call(descriptor) {
441
- const { id, message = own[id] } = descriptor;
442
- if (typeof message !== "string") throw new Error(`Message for ${id} is not a string`);
443
- let format = formats.get(message);
444
- if (!format) formats.set(message, format = compile(locale, message));
445
- return String(format.format(resolveDescriptorValues(descriptor)));
194
+ const { id, message } = descriptor;
195
+ if (message !== void 0) return compileMessage(message, locale, ownOptions)(descriptor);
196
+ let format = compiled.get(id);
197
+ if (!format) {
198
+ const message = Object.hasOwn(own, id) ? own[id] : void 0;
199
+ if (message === void 0) throw new Error(`No message for ${id} in locale '${locale}'`);
200
+ compiled.set(id, format = compileMessage(message, locale, ownOptions));
201
+ }
202
+ return format(descriptor);
446
203
  }
447
204
  return Object.freeze(Object.defineProperties(say, {
448
205
  locale: {
@@ -453,6 +210,10 @@ function createView(locale, messages) {
453
210
  value: own,
454
211
  enumerable: true
455
212
  },
213
+ options: {
214
+ value: ownOptions,
215
+ enumerable: true
216
+ },
456
217
  call: { value: call },
457
218
  plural: { value: () => macro("plural") },
458
219
  ordinal: { value: () => macro("ordinal") },
@@ -463,9 +224,9 @@ function createView(locale, messages) {
463
224
  [Symbol.for("nodejs.util.inspect.custom")]: { value: (_depth, context, inspect) => `View<${inspect(locale, context)}> {}` }
464
225
  }));
465
226
  }
466
- //#endregion
467
- //#region src/catalogue.ts
468
- function createCatalogue(messages) {
227
+ // src/catalogue.ts
228
+ function createCatalogue(messages, options = {}) {
229
+ const { bidiIsolation } = options;
469
230
  const sources = Object.assign(Object.create(null), messages);
470
231
  const keys = Object.freeze(Object.keys(sources));
471
232
  if (keys.length === 0) throw new Error("A catalogue needs at least one locale, none were given");
@@ -474,7 +235,8 @@ function createCatalogue(messages) {
474
235
  const views = /* @__PURE__ */ new Map();
475
236
  const loading = /* @__PURE__ */ new Map();
476
237
  function fill(locale, produced) {
477
- const messages = typeof produced.default === "object" ? produced.default : produced;
238
+ const { default: inner } = produced;
239
+ const messages = typeof inner === "object" && !Array.isArray(inner) ? inner : produced;
478
240
  if (!store.has(locale)) store.set(locale, messages);
479
241
  return catalogue.locale(locale);
480
242
  }
@@ -484,7 +246,7 @@ function createCatalogue(messages) {
484
246
  const messages = store.get(locale);
485
247
  if (!messages) throw new Error(typeof sources[locale] === "function" ? `Messages for locale '${locale}' have not been loaded yet` : `No messages for locale '${locale}'`);
486
248
  let view = views.get(locale);
487
- if (!view) views.set(locale, view = createView(locale, messages));
249
+ if (!view) views.set(locale, view = createView(locale, messages, { bidiIsolation }));
488
250
  return view;
489
251
  },
490
252
  loaded(locale) {
@@ -530,8 +292,7 @@ function createCatalogue(messages) {
530
292
  }
531
293
  return Object.freeze(catalogue);
532
294
  }
533
- //#endregion
534
- //#region src/scope.ts
295
+ // src/scope.ts
535
296
  const NO_VIEW = "No view is in scope for 'say'. Run the work inside 'scope.run(view, callback)', or establish one with 'scope.use(store)'.";
536
297
  function createVariableStorage() {
537
298
  let running;
@@ -602,8 +363,7 @@ function createScope(storage = createVariableStorage()) {
602
363
  return `Scope<${view ? inspect(view.locale, context) : "unset"}> {}`;
603
364
  } } }));
604
365
  }
605
- //#endregion
606
- //#region src/store.ts
366
+ // src/store.ts
607
367
  function createStore(catalogue, locale = catalogue.locales[0]) {
608
368
  let current = catalogue.locale(locale);
609
369
  const listeners = /* @__PURE__ */ new Set();
@@ -656,5 +416,4 @@ function createStore(catalogue, locale = catalogue.locales[0]) {
656
416
  }
657
417
  }, { [Symbol.for("nodejs.util.inspect.custom")]: { value: (_depth, context, inspect) => `Store<${inspect(current.locale, context)}> {}` } }));
658
418
  }
659
- //#endregion
660
- export { createCatalogue, createScope, createStore, createView };
419
+ export { Kind, compileMessage, createCatalogue, createScope, createStore, createView };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "saykit",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Type-safe i18n library with compile-time macro transforms",
5
5
  "keywords": [
6
6
  "i18n",
@@ -22,7 +22,8 @@
22
22
  },
23
23
  "files": [
24
24
  "dist",
25
- "!dist/**/*.map"
25
+ "!dist/**/*.map",
26
+ "skills"
26
27
  ],
27
28
  "type": "module",
28
29
  "sideEffects": false,
@@ -36,12 +37,6 @@
36
37
  "access": "public",
37
38
  "provenance": true
38
39
  },
39
- "dependencies": {
40
- "@messageformat/date-skeleton": "2.0.0-0",
41
- "@messageformat/number-skeleton": "2.0.0-0",
42
- "@messageformat/parser": "^5.1.1",
43
- "messageformat": "^4.0.0"
44
- },
45
40
  "scripts": {
46
41
  "check": "tsc --noEmit",
47
42
  "build": "tsdown"
@@ -0,0 +1,363 @@
1
+ ---
2
+ name: saykit
3
+ description: Use when adding, changing, or debugging internationalisation with SayKit (the `saykit`, `@saykit/*`, `unplugin-saykit`, or `babel-plugin-saykit` packages), or when a project has a saykit.config.ts. Covers setup, the say and <Say> macros, plurals and formatting, catalogues, views and stores, React client and server usage, extraction, and the mistakes agents make most.
4
+ ---
5
+
6
+ # SayKit
7
+
8
+ Compile-time i18n. Messages are written inline, `` say`Hello, ${name}!` `` or `<Say>Hello, {name}!</Say>`. The CLI extracts them to translation files and compiles each locale into a `.js` module of data; a build plugin rewrites each macro to `say.call({ id, ... })`. Nothing parses ICU at runtime, a small evaluator formats the compiled data with `Intl`.
9
+
10
+ Docs: https://saykit.js.org. Source and end-to-end examples (`examples/*`): https://github.com/k0d13/saykit.
11
+
12
+ All SayKit packages are released together and must share one version. Pre-1.0, so APIs still move between minors.
13
+
14
+ ## Packages
15
+
16
+ | Package | Purpose | When |
17
+ | ----------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------ |
18
+ | `saykit` | Runtime: `createCatalogue`, `createStore`, the `say` macros, formatting | Always, a runtime dependency |
19
+ | `@saykit/config` | `defineConfig` and the `saykit` CLI (`extract`, `compile`, `clean`) | Always, dev dependency |
20
+ | `@saykit/transform-js` | Extracts and rewrites `say` macros in `.js`/`.ts` | Always |
21
+ | `@saykit/transform-jsx` | Same for `<Say>` in `.jsx`/`.tsx` | Any JSX |
22
+ | `@saykit/format-po` | Gettext PO catalogues | Default choice, translators and TMSes know it |
23
+ | `@saykit/format-json` | JSON catalogues; `arb` and `webextension` dialects | Flutter-style ARB, browser extension `_locales/`, plain JSON |
24
+ | `unplugin-saykit` | Bundler plugin | Vite, Rollup, Rolldown, Webpack, Rspack, esbuild, Farm, Bun |
25
+ | `babel-plugin-saykit` | Babel plugin | Next.js, Expo, React Native, any Babel pipeline |
26
+ | `@saykit/react` | `<Say>`, `SayProvider`, `useSay`, `withSay`, `getSay` | React |
27
+ | `@saykit/carbon` | `SayPlugin`, `withSay`, `interaction.say`, `guild.say` | Carbon Discord bots |
28
+
29
+ A project needs the runtime, config, one formatter, the transformers for its file types, and one plugin. Use the project's own package manager.
30
+
31
+ ## Setup
32
+
33
+ ### `saykit.config.ts`
34
+
35
+ At the project root. A TypeScript config needs Node 22.18+, or Bun, Deno or tsx.
36
+
37
+ ```ts
38
+ import { defineConfig } from '@saykit/config';
39
+ import po from '@saykit/format-po';
40
+ import js from '@saykit/transform-js';
41
+ import jsx from '@saykit/transform-jsx';
42
+
43
+ export default defineConfig({
44
+ // The first locale is the source locale: the language the code is written
45
+ // in, the only one extraction writes, and the final fallback for every other
46
+ locales: ['en', 'en-GB', 'en-NZ', 'fr'],
47
+ // Optional chains between a locale and the source, most specific first.
48
+ // Every key and value must also appear in `locales`
49
+ fallbackLocales: { 'en-NZ': ['en-GB'] },
50
+ buckets: [
51
+ {
52
+ include: ['src/**/*.{ts,tsx}'],
53
+ exclude: ['src/**/*.d.*.ts'],
54
+ // Must contain {locale} and end in .{extension}
55
+ output: 'src/locales/{locale}.{extension}',
56
+ formatter: po(),
57
+ transformer: [js(), jsx()],
58
+ // Optional: strings with no call site (a manifest, a store listing), keyed by id
59
+ messages: { extensionName: 'Reading Time' },
60
+ },
61
+ ],
62
+ });
63
+ ```
64
+
65
+ A bucket is "these files in, this catalogue out". One bucket is the norm, including for a Next.js app where server and client components share a catalogue. Add more only when two areas genuinely want separate files, such as transactional emails beside a UI.
66
+
67
+ ### Bundler
68
+
69
+ Vite, and every other bundler `unplugin` supports (change only the import path: `/rollup`, `/rolldown`, `/webpack`, `/rspack`, `/esbuild`, `/farm`, `/bun`):
70
+
71
+ ```ts title="vite.config.ts"
72
+ import react from '@vitejs/plugin-react';
73
+ import saykit from 'unplugin-saykit/vite';
74
+ import { defineConfig } from 'vite';
75
+
76
+ export default defineConfig({
77
+ plugins: [saykit(), react()],
78
+ });
79
+ ```
80
+
81
+ Next.js, Expo, React Native and plain Babel. Adding a `.babelrc` switches Next from SWC to Babel; there is no SWC plugin:
82
+
83
+ ```json title=".babelrc"
84
+ {
85
+ "presets": ["next/babel"],
86
+ "plugins": ["saykit"]
87
+ }
88
+ ```
89
+
90
+ ```js title="babel.config.js"
91
+ module.exports = function (api) {
92
+ api.cache(true);
93
+ return {
94
+ presets: ['babel-preset-expo'],
95
+ plugins: ['saykit'],
96
+ };
97
+ };
98
+ ```
99
+
100
+ A plugin only rewrites macros. Catalogues are never loaded through it: no `next.config` or `metro.config` wrapper, no loader options.
101
+
102
+ ### Compiled locale modules
103
+
104
+ `saykit compile` writes a `{locale}.js` beside each catalogue (`src/locales/fr.po` → `src/locales/fr.js`), with the `fallbackLocales` chain already merged in, plus a `{locale}.d.ts` declaring `Record<string, Message>`. `saykit extract` compiles too, and `extract --watch` recompiles when a catalogue changes. Gitignore the `.js` modules and commit the `.d.ts`, which never changes, so `tsc` passes on a fresh clone. Since the modules are not committed, run compile before anything that imports a locale:
105
+
106
+ ```json title="package.json"
107
+ {
108
+ "scripts": {
109
+ "predev": "saykit compile",
110
+ "prebuild": "saykit compile",
111
+ "pretest": "saykit compile"
112
+ }
113
+ }
114
+ ```
115
+
116
+ A formatter whose extension is `.js` cannot be used: its module would overwrite the catalogue.
117
+
118
+ ## Writing messages
119
+
120
+ ### TypeScript
121
+
122
+ ```ts
123
+ say`Hello, ${name}!`; // Hello, {name}!
124
+ say`Total: ${{ cartTotal: getTotal() }}`; // Total: {cartTotal}
125
+ say`Hello, ${user.name}`; // Hello, {0} (not an identifier, so numbered)
126
+
127
+ say.plural(count, { 0: 'No items', one: '1 item', other: `${count} items` });
128
+ say.ordinal(n, { one: `${n}st`, two: `${n}nd`, few: `${n}rd`, other: `${n}th` });
129
+ say.select(status, { active: 'Active', archived: 'Archived', other: 'Unknown' });
130
+ ```
131
+
132
+ A placeholder is named after the variable when the expression is a bare identifier. Anything else is numbered, which tells a translator nothing; name it with a single-key object, `${{ name: user.name }}`, or lift it into a local first. Keep a sentence in one message: ``say`Hello, ` + name`` gives a translator half a sentence.
133
+
134
+ `plural`, `ordinal` and `select` need `other`. Exact-number branches are plain digits. Interpolating the selector inside a branch extracts as ICU `#`. Branch keys are ICU keys, identifiers with no hyphens or spaces: `'sold-out'` fails the build, and the fix is to rename the discriminant at its source to `soldOut`, since `select` compares literal strings and a renamed branch would never match.
135
+
136
+ ### Context and comments
137
+
138
+ Identical strings that mean different things get a `context`, which gives them separate catalogue entries and shows the translator which is which. A custom `id` gives an optional readable key
139
+
140
+ ```ts
141
+ say({ context: 'noun' })`Post`;
142
+ say({ context: 'verb' })`Post`;
143
+ say({ id: 'checkout.review' })`Review your order`;
144
+ say({ id: 'nav.right', context: 'direction' })`Right`;
145
+ ```
146
+
147
+ A comment starting with `TRANSLATORS:` directly above a message is extracted alongside it; use it for tone, length limits and anything a translator could not infer. Other comments stay in code.
148
+
149
+ ```ts
150
+ // TRANSLATORS: Button text, keep under 20 characters
151
+ say`Continue`;
152
+ ```
153
+
154
+ ### Numbers, dates and times
155
+
156
+ ```ts
157
+ say`Published ${say.date(post.at, { style: 'long' })}`; // Published {0, date, long}
158
+ say`Total ${say.number(total, { style: '::currency/EUR' })}`; // Total {0, number, ::currency/EUR}
159
+ say.number(1234.5); // "1,234.5" in en, "1 234,5" in fr
160
+ say.time(opensAt, { style: 'short' });
161
+ ```
162
+
163
+ Inside a message the format lands in the catalogue, so a locale can move the number. Alone, the value is formatted for the view's locale but nothing is extracted, there is nothing to translate. `number` styles: `integer`, `percent`, a skeleton (`::currency/EUR`, `::compact-short`), or a pattern (`#,##0.00`). `date` and `time` styles: `short`, `medium`, `long`, `full`, or a skeleton (`::yMMMM`, `::Hm`). There is no `currency` style, only the skeleton. Unknown styles fail the build.
164
+
165
+ ### JSX
166
+
167
+ ```tsx
168
+ import { Say } from '@saykit/react';
169
+
170
+ <Say>Hello, {name}!</Say>
171
+ <Say>Hello, {{ name: user.name }}!</Say>
172
+ <Say context="noun">Post</Say>
173
+ <Say id="checkout.review">Review your order</Say>
174
+
175
+ {/* TRANSLATORS: Shown on an empty board */}
176
+ <Say>Nothing here yet.</Say>
177
+
178
+ // Elements become tags a translator can move: "Read the <0>docs</0>".
179
+ // say-tag names one: "Read the <link>docs</link>". Name it after what it does to the text.
180
+ <Say>Read the <a href="/docs" say-tag="link">docs</a></Say>
181
+
182
+ <Say.Plural _={count} _0="Nothing here" one={<>{count} item</>} other={<>{count} items</>} />
183
+ <Say.Ordinal _={n} one={<>{n}st</>} two={<>{n}nd</>} few={<>{n}rd</>} other={<>{n}th</>} />
184
+ <Say.Select _={status} active="Active" archived="Archived" other="Unknown" />
185
+
186
+ <Say>Due <Say.Date _={dueAt} style="medium" /></Say>
187
+ <Say.Number _={row.total} style="::currency/EUR" />
188
+ ```
189
+
190
+ `_` is the selector. Exact-number branches are `_0`, `_1` because a prop cannot start with a digit; extraction strips the underscore. A branch that shows the number must be a fragment, not a string. `{' '}` inside `<Say>` folds into the text. `whitespace` defaults to `true`; pass `whitespace={false}` in React Native when `<Text>` elements sit next to each other, otherwise a bare string outside `<Text>` throws.
191
+
192
+ ## Runtime
193
+
194
+ Three objects. A **catalogue** holds locales and where their messages come from. A **view** is one locale bound to its messages: callable, immutable, and what application code holds. A **store** holds the current view and swaps it, which is what switching locale in a browser is.
195
+
196
+ ```ts title="src/i18n.ts"
197
+ import { createCatalogue, createStore } from 'saykit';
198
+ import en from './locales/en.js';
199
+
200
+ export const catalogue = createCatalogue({
201
+ en, // inline
202
+ fr: () => import('./locales/fr.js'), // lazy: one chunk, fetched on first load
203
+ });
204
+
205
+ // No Locale type is exported; derive it
206
+ export type Locale = (typeof catalogue.locales)[number];
207
+ ```
208
+
209
+ `createCatalogue` takes a record whose keys are the locales; the first is the fallback `match` resolves to. Never write the messages by hand: keys are content hashes the transform chose, and values are compiled trees, so a hand-written `{ greeting: 'Hello' }` matches nothing. Import the compiled module. A second argument sets view options: `{ bidiIsolation: 'none' }` turns off the FSI/PDI marks written around interpolated values (on by default, as in MessageFormat 2).
210
+
211
+ ```ts
212
+ catalogue.locales; // ['en', 'fr']
213
+ catalogue.match(navigator.languages, cookieLocale); // exact, then language prefix, then locales[0]; nullish guesses are skipped
214
+ catalogue.loaded('fr'); // false until loaded
215
+ const say = await catalogue.load('fr'); // calls the thunk once; synchronous when already loaded
216
+ catalogue.locale('fr'); // synchronous; throws if a lazy locale has not been loaded
217
+ for (const [locale, say] of catalogue) {
218
+ } // every locale's view
219
+
220
+ say.locale; // 'fr'
221
+ say`Hello`; // formats against fr
222
+ ```
223
+
224
+ `fallbackLocales` and `match` are different fallbacks. `fallbackLocales` is build time, it decides which files are merged into a locale's catalogue so an untranslated key resolves to another locale's string. `match` is runtime, it decides which key of the catalogue a guess maps to, and never resolves to a locale that is not a key.
225
+
226
+ Browser, where the locale changes:
227
+
228
+ ```ts
229
+ const initial = catalogue.match(navigator.languages);
230
+ await catalogue.load(initial); // no-op for an eager locale; required when `initial` is lazy
231
+ export const store = createStore(catalogue, initial);
232
+
233
+ store.say`Hello`; // read per call; identity changes on switch
234
+ store.subscribe((say) => {
235
+ document.documentElement.lang = say.locale;
236
+ });
237
+ await store.set('fr'); // loads if needed; last switch wins; a failed load keeps the current view
238
+ ```
239
+
240
+ A store exposes `say`, `set` and `subscribe` only; keep the catalogue for `locales`. Do not hold `const say = store.say` across a switch. `createStore` throws when its initial locale is lazy and unloaded, so `await catalogue.load(initial)` before building the store whenever any locale is lazy, including a mixed catalogue.
241
+
242
+ Server: no store. Look a view up per request with `catalogue.load(catalogue.match(guesses))`, splitting an `Accept-Language` header into its tags first (`header.split(',').map((s) => s.split(';')[0].trim())`), since `match` compares whole strings; views are safe to share between requests. For code that cannot be handed a view, `createScope(new AsyncLocalStorage())` from `saykit` gives a `scope.say` resolving to whatever `scope.run(view, fn)` established.
243
+
244
+ ## React
245
+
246
+ ### Client
247
+
248
+ ```tsx title="src/main.tsx"
249
+ import { SayProvider } from '@saykit/react/client';
250
+ import { store } from './i18n';
251
+
252
+ <SayProvider store={store}>
253
+ <App />
254
+ </SayProvider>;
255
+ ```
256
+
257
+ ```tsx title="src/locale-picker.tsx"
258
+ import { useSay } from '@saykit/react/client';
259
+ import { catalogue, store, type Locale } from './i18n';
260
+
261
+ export function LocalePicker() {
262
+ const say = useSay(); // the current View; re-renders on switch
263
+ return (
264
+ <select value={say.locale} onChange={(event) => store.set(event.target.value as Locale)}>
265
+ {catalogue.locales.map((locale) => (
266
+ <option key={locale} value={locale}>
267
+ {locale}
268
+ </option>
269
+ ))}
270
+ </select>
271
+ );
272
+ }
273
+ ```
274
+
275
+ There is no store hook; the store is a module value, import it. `<SayProvider locale={locale} messages={messages}>` is the single-locale form a server hands across the boundary; it cannot switch.
276
+
277
+ ### Server components
278
+
279
+ ```ts title="src/i18n.ts"
280
+ import { createWithSay } from '@saykit/react/server';
281
+ import { createCatalogue } from 'saykit';
282
+ import en from './locales/en.js';
283
+ import fr from './locales/fr.js';
284
+
285
+ export const catalogue = createCatalogue({ en, fr });
286
+ export const withSay = createWithSay(catalogue);
287
+ ```
288
+
289
+ ```tsx title="app/[locale]/layout.tsx"
290
+ import { SayProvider } from '@saykit/react/client';
291
+ import { getSay } from '@saykit/react/server';
292
+ import { withSay } from '../../i18n';
293
+
294
+ function Layout({ children }: LayoutProps<'/[locale]'>) {
295
+ return (
296
+ <html lang={getSay().locale}>
297
+ <body>
298
+ <SayProvider>{children}</SayProvider>
299
+ </body>
300
+ </html>
301
+ );
302
+ }
303
+
304
+ export default withSay(Layout, (p) => p.params.then((p) => p.locale));
305
+ ```
306
+
307
+ `withSay(Component, getLocale)` runs `getLocale` (returns a string, array, or nullish, sync or async), passes it through `catalogue.match`, loads the view and establishes it in React's per-request `cache()` before rendering. Wrap **every** layout and page that renders messages, not only the root: Next renders a page before its layout, so an unwrapped page's `getSay()` throws `'getSay' must be called below a 'withSay'`. `<SayProvider>` written in a server component takes no props; the `react-server` build hands the established locale and messages, plain data, to the client. `getSay()` is the server `useSay()`, for the locale as data or a string in a handler. `setSay(view)` establishes a view you resolved yourself. One view per request: a second locale replaces the first for everything rendered after it.
308
+
309
+ ## Carbon
310
+
311
+ ```ts
312
+ import { Client, Command, type CommandInteraction } from '@buape/carbon';
313
+ import { createWithSay, SayPlugin } from '@saykit/carbon';
314
+
315
+ export const withSay = createWithSay(catalogue);
316
+
317
+ class Ping extends withSay(Command) {
318
+ constructor() {
319
+ super((say) => ({ name: say`ping`, description: say`Ping the bot!` }));
320
+ }
321
+
322
+ async run(interaction: CommandInteraction) {
323
+ await interaction.reply({ content: interaction.say`Pong!` });
324
+ }
325
+ }
326
+
327
+ new Client(options, { commands: [new Ping()] }, [new SayPlugin(catalogue)]);
328
+ ```
329
+
330
+ `withSay` builds command metadata once per locale. `interaction.say` is the interaction locale's view, `guild.say` the guild's.
331
+
332
+ ## Workflow
333
+
334
+ 1. Write or change a message.
335
+ 2. `saykit extract` (or `saykit extract --watch`). Only the source locale file is written; other locales are created header-only and never touched afterwards. Every locale is then compiled.
336
+ 3. Translate the other locales by hand or through a TMS. A locale file needs only the entries actually translated; the rest fall back through `fallbackLocales` to the source string at compile time. After pulling translations, `saykit compile`.
337
+ 4. `saykit clean` when source strings were removed or reworded: it drops orphaned and empty entries from non-source locales and never adds anything.
338
+
339
+ Commit the catalogues and `.d.ts` files, not the compiled `.js`. CI check that extraction was run:
340
+
341
+ ```yaml
342
+ - run: pnpm saykit extract
343
+ - run: git diff --exit-code -- 'src/locales/*.po'
344
+ ```
345
+
346
+ Macros throw without the plugin, so a test runner needs it too; Vitest picks it up from `vite.config.ts`.
347
+
348
+ Custom formats: a `Formatter` is `{ extension: '.yaml', parse(content) => Message[], stringify(messages, meta) => string }` and a `Transformer` is `{ match(id) => boolean, extract(code, id) => Message[], transform(code, id) => string }`, where `Message` (from `@saykit/config`, not the runtime type) is `{ message, translation?, id?, context?, comments, references }`. See `examples/custom-formatter`.
349
+
350
+ ## Errors
351
+
352
+ | Symptom | Cause and fix |
353
+ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
354
+ | `'say' is a macro and must be used with the relevant saykit plugin` | The file was not transformed. Check the bucket `include` glob and that the plugin is in the bundler and test config |
355
+ | `No message for <id> in locale 'x'` | Stale or hand-written module. Run `saykit extract`; import the compiled file, never declare messages |
356
+ | `Cannot find module './locales/en.js'` | Not compiled yet. `saykit compile`, and add it as a `pre` script |
357
+ | `Messages for locale 'x' have not been loaded yet` | `catalogue.locale('x')` on a lazy locale. `await catalogue.load('x')` or `store.set('x')` first |
358
+ | `No messages for locale 'x'` | `x` is not a key of the catalogue. Run guesses through `match` first |
359
+ | `'getSay' must be called below a 'withSay'` | This segment is not wrapped. Wrap every layout and page, not only the root |
360
+ | Edited a `.po`, dev server shows old strings | The module was not recompiled. Run `saykit extract --watch` alongside the dev server, or `saykit compile` |
361
+ | Translator sees `{0}` | Placeholder is not an identifier. Name it: `${{ name: value }}` |
362
+ | Build fails on a branch key such as `sold-out` | ICU keys are identifiers. Rename the discriminant at its source |
363
+ | `Text strings must be rendered within a <Text> component` | React Native. `<Say whitespace={false}>` |