saykit 0.11.1 → 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 +4 -4
- package/dist/index.d.mts +77 -35
- package/dist/index.mjs +179 -430
- package/package.json +3 -8
- package/skills/saykit/SKILL.md +363 -0
package/README.md
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
[](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
|
|
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.
|
|
24
|
-
import fr from './locales/fr.
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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,35 +87,38 @@ 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]:
|
|
112
|
+
[key: string]: Message;
|
|
60
113
|
};
|
|
61
114
|
/**
|
|
62
|
-
* What the transform compiles a message down to:
|
|
63
|
-
*
|
|
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:
|
|
121
|
+
message: Message;
|
|
70
122
|
}) & {
|
|
71
123
|
[match: string | number]: unknown;
|
|
72
124
|
};
|
|
@@ -137,9 +189,8 @@ interface View<Locale extends string = string> {
|
|
|
137
189
|
/**
|
|
138
190
|
* Format the message a descriptor names, or the message it carries.
|
|
139
191
|
*
|
|
140
|
-
* A message with nothing to translate, a lone `say.date(x)
|
|
141
|
-
*
|
|
142
|
-
* 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.
|
|
143
194
|
*
|
|
144
195
|
* @param descriptor Descriptor to format
|
|
145
196
|
* @returns The formatted message
|
|
@@ -265,18 +316,14 @@ interface View<Locale extends string = string> {
|
|
|
265
316
|
* Create a view over one locale and the messages it formats against.
|
|
266
317
|
*
|
|
267
318
|
* A catalogue memoises one per locale, which is how application code usually
|
|
268
|
-
* reaches one; a single-locale app can build one directly.
|
|
269
|
-
* belongs to the view, so a view built over one set of messages can never be
|
|
270
|
-
* served a format compiled from another.
|
|
319
|
+
* reaches one; a single-locale app can build one directly.
|
|
271
320
|
*
|
|
272
321
|
* @param locale The locale to bind to
|
|
273
322
|
* @param messages The messages this view formats against
|
|
274
323
|
* @param options How this view formats its messages
|
|
275
324
|
* @returns The view
|
|
276
325
|
*/
|
|
277
|
-
declare function createView<Locale extends string>(locale: Locale, messages: View.Messages, options?: View.Options): View<Locale>;
|
|
278
|
-
//#endregion
|
|
279
|
-
//#region src/catalogue.d.ts
|
|
326
|
+
export declare function createView<Locale extends string>(locale: Locale, messages: View.Messages, options?: View.Options): View<Locale>;
|
|
280
327
|
declare namespace Catalogue {
|
|
281
328
|
/**
|
|
282
329
|
* One guess at a locale, or several. Guesses are read from cookies, headers
|
|
@@ -391,9 +438,7 @@ interface Catalogue<Locale extends string = string> {
|
|
|
391
438
|
* messages, such as `{ bidiIsolation: 'none' }`
|
|
392
439
|
* @returns The catalogue
|
|
393
440
|
*/
|
|
394
|
-
declare function createCatalogue<const Locale extends string = string>(messages: Record<Locale, Catalogue.Source>, options?: Catalogue.Options): Catalogue<Locale>;
|
|
395
|
-
//#endregion
|
|
396
|
-
//#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>;
|
|
397
442
|
declare namespace Store {
|
|
398
443
|
/** Called after a successful switch, with the view that is now current. */
|
|
399
444
|
type Listener<Locale extends string> = (view: View<Locale>) => void;
|
|
@@ -474,9 +519,7 @@ interface Store<Locale extends string = string> {
|
|
|
474
519
|
* @returns The store
|
|
475
520
|
* @throws If the starting locale has no messages loaded
|
|
476
521
|
*/
|
|
477
|
-
declare function createStore<Locale extends string>(catalogue: Catalogue<Locale>, locale?: Locale): Store<Locale>;
|
|
478
|
-
//#endregion
|
|
479
|
-
//#region src/scope.d.ts
|
|
522
|
+
export declare function createStore<Locale extends string>(catalogue: Catalogue<Locale>, locale?: Locale): Store<Locale>;
|
|
480
523
|
declare namespace Scope {
|
|
481
524
|
/**
|
|
482
525
|
* Where a scope holds the view a piece of work is running under.
|
|
@@ -585,6 +628,5 @@ interface Scope {
|
|
|
585
628
|
* browser wants
|
|
586
629
|
* @returns The scope
|
|
587
630
|
*/
|
|
588
|
-
declare function createScope(storage?: Scope.Storage): Scope;
|
|
589
|
-
|
|
590
|
-
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,453 +1,205 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
|
77
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
338
|
-
get
|
|
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
|
|
350
|
-
if (
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
+
}
|
|
428
166
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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) ?? "");
|
|
433
175
|
}
|
|
176
|
+
// src/view.ts
|
|
434
177
|
function macro(name) {
|
|
435
178
|
throw new Error(`'say.${name}' is a macro and must be used with the relevant saykit plugin`);
|
|
436
179
|
}
|
|
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
|
+
}
|
|
437
185
|
function createView(locale, messages, options = {}) {
|
|
438
186
|
const { bidiIsolation } = options;
|
|
439
187
|
const ownOptions = Object.freeze(bidiIsolation ? { bidiIsolation } : {});
|
|
440
|
-
const own =
|
|
441
|
-
const formats = /* @__PURE__ */ new Map();
|
|
188
|
+
const own = snapshot(messages);
|
|
442
189
|
const say = (() => {
|
|
443
190
|
throw new Error("'say' is a macro and must be used with the relevant saykit plugin");
|
|
444
191
|
});
|
|
192
|
+
const compiled = /* @__PURE__ */ new Map();
|
|
445
193
|
function call(descriptor) {
|
|
446
|
-
const { id, message
|
|
447
|
-
if (
|
|
448
|
-
let format =
|
|
449
|
-
if (!format)
|
|
450
|
-
|
|
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);
|
|
451
203
|
}
|
|
452
204
|
return Object.freeze(Object.defineProperties(say, {
|
|
453
205
|
locale: {
|
|
@@ -472,8 +224,7 @@ function createView(locale, messages, options = {}) {
|
|
|
472
224
|
[Symbol.for("nodejs.util.inspect.custom")]: { value: (_depth, context, inspect) => `View<${inspect(locale, context)}> {}` }
|
|
473
225
|
}));
|
|
474
226
|
}
|
|
475
|
-
|
|
476
|
-
//#region src/catalogue.ts
|
|
227
|
+
// src/catalogue.ts
|
|
477
228
|
function createCatalogue(messages, options = {}) {
|
|
478
229
|
const { bidiIsolation } = options;
|
|
479
230
|
const sources = Object.assign(Object.create(null), messages);
|
|
@@ -484,7 +235,8 @@ function createCatalogue(messages, options = {}) {
|
|
|
484
235
|
const views = /* @__PURE__ */ new Map();
|
|
485
236
|
const loading = /* @__PURE__ */ new Map();
|
|
486
237
|
function fill(locale, produced) {
|
|
487
|
-
const
|
|
238
|
+
const { default: inner } = produced;
|
|
239
|
+
const messages = typeof inner === "object" && !Array.isArray(inner) ? inner : produced;
|
|
488
240
|
if (!store.has(locale)) store.set(locale, messages);
|
|
489
241
|
return catalogue.locale(locale);
|
|
490
242
|
}
|
|
@@ -540,8 +292,7 @@ function createCatalogue(messages, options = {}) {
|
|
|
540
292
|
}
|
|
541
293
|
return Object.freeze(catalogue);
|
|
542
294
|
}
|
|
543
|
-
|
|
544
|
-
//#region src/scope.ts
|
|
295
|
+
// src/scope.ts
|
|
545
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)'.";
|
|
546
297
|
function createVariableStorage() {
|
|
547
298
|
let running;
|
|
@@ -612,8 +363,7 @@ function createScope(storage = createVariableStorage()) {
|
|
|
612
363
|
return `Scope<${view ? inspect(view.locale, context) : "unset"}> {}`;
|
|
613
364
|
} } }));
|
|
614
365
|
}
|
|
615
|
-
|
|
616
|
-
//#region src/store.ts
|
|
366
|
+
// src/store.ts
|
|
617
367
|
function createStore(catalogue, locale = catalogue.locales[0]) {
|
|
618
368
|
let current = catalogue.locale(locale);
|
|
619
369
|
const listeners = /* @__PURE__ */ new Set();
|
|
@@ -666,5 +416,4 @@ function createStore(catalogue, locale = catalogue.locales[0]) {
|
|
|
666
416
|
}
|
|
667
417
|
}, { [Symbol.for("nodejs.util.inspect.custom")]: { value: (_depth, context, inspect) => `Store<${inspect(current.locale, context)}> {}` } }));
|
|
668
418
|
}
|
|
669
|
-
|
|
670
|
-
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.
|
|
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}>` |
|