saykit 0.9.0 → 0.10.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 +18 -7
- package/dist/index.d.mts +558 -0
- package/dist/{runtime.mjs → index.mjs} +220 -104
- package/package.json +3 -3
- package/dist/runtime.d.mts +0 -316
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
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
|
|
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.
|
|
8
8
|
|
|
9
9
|
You author messages with the `` say`...` `` tagged template (and `say.plural`, `say.ordinal`, `say.select`); a SayKit build-tool plugin rewrites them at build time into small runtime calls.
|
|
10
10
|
|
|
@@ -19,21 +19,32 @@ You will normally also want [`@saykit/config`](https://github.com/k0d13/saykit/t
|
|
|
19
19
|
## Usage
|
|
20
20
|
|
|
21
21
|
```ts
|
|
22
|
-
import {
|
|
22
|
+
import { createCatalogue } from 'saykit';
|
|
23
23
|
import en from './locales/en.po';
|
|
24
24
|
import fr from './locales/fr.po';
|
|
25
25
|
|
|
26
|
-
const
|
|
27
|
-
locales: ['en', 'fr'],
|
|
28
|
-
messages: { en, fr },
|
|
29
|
-
});
|
|
26
|
+
const catalogue = createCatalogue({ en, fr });
|
|
30
27
|
|
|
31
|
-
say.
|
|
28
|
+
const say = catalogue.locale('en');
|
|
32
29
|
|
|
33
30
|
say`Hello, ${name}!`;
|
|
34
31
|
say.plural(count, { one: '1 item', other: `${count} items` });
|
|
35
32
|
```
|
|
36
33
|
|
|
34
|
+
In a browser, where the locale can change, hold a store instead of a view:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { createStore } from 'saykit';
|
|
38
|
+
|
|
39
|
+
const store = createStore(catalogue, 'en');
|
|
40
|
+
|
|
41
|
+
store.subscribe((say) => render(say));
|
|
42
|
+
|
|
43
|
+
await store.set('fr');
|
|
44
|
+
|
|
45
|
+
store.say`Hello, ${name}!`;
|
|
46
|
+
```
|
|
47
|
+
|
|
37
48
|
## Documentation
|
|
38
49
|
|
|
39
50
|
Full guide at [saykit.js.org](https://saykit.js.org).
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
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>;
|
|
5
|
+
/**
|
|
6
|
+
* A value wrapped in the name its ICU placeholder should take, written inline
|
|
7
|
+
* as a single-key object: `` say`Total: ${{ cartTotal: getTotal() }}` ``. The
|
|
8
|
+
* transform reads the key at build time and compiles only the value, so this
|
|
9
|
+
* is never a real object at runtime.
|
|
10
|
+
*/
|
|
11
|
+
type Named<T> = {
|
|
12
|
+
[name: string]: T;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Branches of a choice, keyed by CLDR category or by exact number.
|
|
16
|
+
*
|
|
17
|
+
* `Branch` is what a case may be written as: text everywhere the message is
|
|
18
|
+
* text, widening in JSX, where a case showing the number has to be a fragment.
|
|
19
|
+
*/
|
|
20
|
+
interface NumeralOptions<Branch = string> extends Omit<Partial<Record<Intl.LDMLPluralRule, Branch>>, 'other'> {
|
|
21
|
+
other: Branch;
|
|
22
|
+
[digit: number]: Branch;
|
|
23
|
+
/**
|
|
24
|
+
* Subtracted from the value before `#` is formatted, so "You and 2 others"
|
|
25
|
+
* can select on a total of three. Reserved, it never names a branch.
|
|
26
|
+
*/
|
|
27
|
+
offset?: number;
|
|
28
|
+
}
|
|
29
|
+
interface SelectOptions<Branch = string> {
|
|
30
|
+
other: Branch;
|
|
31
|
+
[match: string | number]: Branch;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* An ICU skeleton: a `::`-prefixed description of the parts a value is written
|
|
35
|
+
* with, rather than a name for a whole format. `::currency/EUR` and
|
|
36
|
+
* `::yyyyMMdd` say what to show and leave the arrangement to the locale.
|
|
37
|
+
*
|
|
38
|
+
* A skeleton is where the formats the named styles have no word for live:
|
|
39
|
+
* currency, compact notation, a year and month with no day.
|
|
40
|
+
*/
|
|
41
|
+
type Skeleton = `::${string}`;
|
|
42
|
+
/**
|
|
43
|
+
* Formatting for a `{arg, number}` placeholder.
|
|
44
|
+
*
|
|
45
|
+
* A literal `NumberFormat` pattern such as `#,##0.00` is also accepted, for the
|
|
46
|
+
* cases neither the named styles nor a skeleton spell more clearly.
|
|
47
|
+
*/
|
|
48
|
+
interface NumberOptions {
|
|
49
|
+
style?: 'integer' | 'percent' | Skeleton | (string & {});
|
|
50
|
+
}
|
|
51
|
+
/** Formatting for a `{arg, date}` or `{arg, time}` placeholder. */
|
|
52
|
+
interface DateTimeOptions {
|
|
53
|
+
style?: 'short' | 'medium' | 'long' | 'full' | Skeleton;
|
|
54
|
+
}
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region src/view.d.ts
|
|
57
|
+
declare namespace View {
|
|
58
|
+
type Messages = {
|
|
59
|
+
[key: string]: string;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* One locale, bound to the messages it formats against.
|
|
64
|
+
*
|
|
65
|
+
* Callable, immutable and memoised: `catalogue.locale('en')` hands back the
|
|
66
|
+
* same value every time. A view has no reference back to its catalogue and so
|
|
67
|
+
* cannot switch locale; switching belongs to whoever owns the catalogue.
|
|
68
|
+
*/
|
|
69
|
+
interface View<Locale extends string = string> {
|
|
70
|
+
/**
|
|
71
|
+
* Define a message.
|
|
72
|
+
*
|
|
73
|
+
* An interpolated variable is named after itself. Anything else is numbered,
|
|
74
|
+
* unless written as a single-key object, which names it.
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```ts
|
|
78
|
+
* say`Hello, ${name}!`
|
|
79
|
+
* say`Your total is ${{ cartTotal: getCartTotal() }}`
|
|
80
|
+
* ```
|
|
81
|
+
*
|
|
82
|
+
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
83
|
+
*/
|
|
84
|
+
(strings: TemplateStringsArray, ...placeholders: unknown[]): string;
|
|
85
|
+
/**
|
|
86
|
+
* Give the message a custom id, or a context to disambiguate identical
|
|
87
|
+
* strings that mean different things.
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* ```ts
|
|
91
|
+
* say({ context: 'direction' })`Right`
|
|
92
|
+
* say({ context: 'correctness' })`Right`
|
|
93
|
+
* ```
|
|
94
|
+
*
|
|
95
|
+
* @param descriptor Object containing optional `id` and `context` properties
|
|
96
|
+
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
97
|
+
*/
|
|
98
|
+
(descriptor: {
|
|
99
|
+
id?: string;
|
|
100
|
+
context?: string;
|
|
101
|
+
}): View<Locale>;
|
|
102
|
+
/** The locale this view is bound to. */
|
|
103
|
+
readonly locale: Locale;
|
|
104
|
+
/** The messages this view formats against. */
|
|
105
|
+
readonly messages: Readonly<View.Messages>;
|
|
106
|
+
/**
|
|
107
|
+
* Format the message a descriptor names.
|
|
108
|
+
*
|
|
109
|
+
* @param descriptor Descriptor to format
|
|
110
|
+
* @returns The formatted message
|
|
111
|
+
* @throws If the id has no message
|
|
112
|
+
*/
|
|
113
|
+
call(descriptor: {
|
|
114
|
+
id: string;
|
|
115
|
+
[match: string | number]: unknown;
|
|
116
|
+
}): string;
|
|
117
|
+
/**
|
|
118
|
+
* Define a pluralised message.
|
|
119
|
+
*
|
|
120
|
+
* Interpolating the selector into a branch extracts as ICU's `#`. A `#` you
|
|
121
|
+
* write yourself is text.
|
|
122
|
+
*
|
|
123
|
+
* @example
|
|
124
|
+
* ```ts
|
|
125
|
+
* say.plural(count, {
|
|
126
|
+
* one: 'You have 1 item',
|
|
127
|
+
* other: `You have ${count} items`,
|
|
128
|
+
* })
|
|
129
|
+
* ```
|
|
130
|
+
*
|
|
131
|
+
* @param _ Number to determine the plural form of
|
|
132
|
+
* @param options Pluralisation rules keyed by CLDR categories or specific numbers
|
|
133
|
+
* @returns The plural form of the number
|
|
134
|
+
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
135
|
+
*/
|
|
136
|
+
plural(_: number | Named<number>, options: Disallow<NumeralOptions, 'id' | 'context'>): string;
|
|
137
|
+
/**
|
|
138
|
+
* Define an ordinal message ("1st", "2nd", "3rd").
|
|
139
|
+
*
|
|
140
|
+
* Interpolating the selector into a branch extracts as ICU's `#`. A `#` you
|
|
141
|
+
* write yourself is text.
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* ```ts
|
|
145
|
+
* say.ordinal(position, {
|
|
146
|
+
* 1: `${position}st`,
|
|
147
|
+
* 2: `${position}nd`,
|
|
148
|
+
* 3: `${position}rd`,
|
|
149
|
+
* other: `${position}th`,
|
|
150
|
+
* })
|
|
151
|
+
* ```
|
|
152
|
+
*
|
|
153
|
+
* @param _ Number to determine the ordinal form of
|
|
154
|
+
* @param options Ordinal rules keyed by CLDR categories or specific numbers
|
|
155
|
+
* @returns The ordinal form of the number
|
|
156
|
+
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
157
|
+
*/
|
|
158
|
+
ordinal(_: number | Named<number>, options: Disallow<NumeralOptions, 'id' | 'context'>): string;
|
|
159
|
+
/**
|
|
160
|
+
* Define a select message, for gender, status, or other categories.
|
|
161
|
+
*
|
|
162
|
+
* @example
|
|
163
|
+
* ```ts
|
|
164
|
+
* say.select(gender, {
|
|
165
|
+
* male: 'He',
|
|
166
|
+
* female: 'She',
|
|
167
|
+
* other: 'They',
|
|
168
|
+
* })
|
|
169
|
+
* ```
|
|
170
|
+
*
|
|
171
|
+
* @param _ Selector value to determine which option is chosen
|
|
172
|
+
* @param options A mapping of possible selector values to message strings
|
|
173
|
+
* @returns The select form of the value
|
|
174
|
+
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
175
|
+
*/
|
|
176
|
+
select(_: string | number | Named<string | number>, options: Disallow<SelectOptions, 'id' | 'context'>): string;
|
|
177
|
+
/**
|
|
178
|
+
* Format a number the way this view's locale writes one, with its own
|
|
179
|
+
* grouping separators and decimal mark.
|
|
180
|
+
*
|
|
181
|
+
* Unlike `plural`, `ordinal` and `select`, this is a fragment rather than a
|
|
182
|
+
* whole message, and is normally written inside one.
|
|
183
|
+
*
|
|
184
|
+
* @example
|
|
185
|
+
* ```ts
|
|
186
|
+
* say`You have ${say.number(items.length)} items`
|
|
187
|
+
* say`Battery at ${say.number(level, { style: 'percent' })}`
|
|
188
|
+
* say`Total: ${say.number({ cartTotal: getTotal() }, { style: '#,##0.00' })}`
|
|
189
|
+
* say`Total: ${say.number(total, { style: '::currency/EUR' })}`
|
|
190
|
+
* ```
|
|
191
|
+
*
|
|
192
|
+
* @param _ Number to format
|
|
193
|
+
* @param options Formatting style: a named style, an ICU skeleton such as
|
|
194
|
+
* `::currency/EUR`, or a pattern such as `#,##0.00`
|
|
195
|
+
* @returns The formatted number
|
|
196
|
+
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
197
|
+
*/
|
|
198
|
+
number(_: number | Named<number>, options?: Disallow<NumberOptions, 'id' | 'context'>): string;
|
|
199
|
+
/**
|
|
200
|
+
* Format the date portion of a value the way this view's locale writes one.
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* ```ts
|
|
204
|
+
* say`Published ${say.date(post.publishedAt)}`
|
|
205
|
+
* say`Published ${say.date(post.publishedAt, { style: 'full' })}`
|
|
206
|
+
* say`Published ${say.date(post.publishedAt, { style: '::yMMMM' })}`
|
|
207
|
+
* ```
|
|
208
|
+
*
|
|
209
|
+
* @param _ Date to format
|
|
210
|
+
* @param options A named style or an ICU skeleton such as `::yyyyMMdd`
|
|
211
|
+
* @returns The formatted date
|
|
212
|
+
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
213
|
+
*/
|
|
214
|
+
date(_: Date | number | Named<Date | number>, options?: Disallow<DateTimeOptions, 'id' | 'context'>): string;
|
|
215
|
+
/**
|
|
216
|
+
* Format the time portion of a value the way this view's locale writes one.
|
|
217
|
+
*
|
|
218
|
+
* @example
|
|
219
|
+
* ```ts
|
|
220
|
+
* say`Doors open at ${say.time(opensAt)}`
|
|
221
|
+
* say`Doors open at ${say.time(opensAt, { style: 'short' })}`
|
|
222
|
+
* say`Doors open at ${say.time(opensAt, { style: '::Hm' })}`
|
|
223
|
+
* ```
|
|
224
|
+
*
|
|
225
|
+
* @param _ Date to format
|
|
226
|
+
* @param options A named style or an ICU skeleton such as `::Hm`
|
|
227
|
+
* @returns The formatted time
|
|
228
|
+
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
229
|
+
*/
|
|
230
|
+
time(_: Date | number | Named<Date | number>, options?: Disallow<DateTimeOptions, 'id' | 'context'>): string;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Create a view over one locale and the messages it formats against.
|
|
234
|
+
*
|
|
235
|
+
* A catalogue memoises one per locale, which is how application code usually
|
|
236
|
+
* reaches one; a single-locale app can build one directly. The format cache
|
|
237
|
+
* belongs to the view, so a view built over one set of messages can never be
|
|
238
|
+
* served a format compiled from another.
|
|
239
|
+
*
|
|
240
|
+
* @param locale The locale to bind to
|
|
241
|
+
* @param messages The messages this view formats against
|
|
242
|
+
* @returns The view
|
|
243
|
+
*/
|
|
244
|
+
declare function createView<Locale extends string>(locale: Locale, messages: View.Messages): View<Locale>;
|
|
245
|
+
//#endregion
|
|
246
|
+
//#region src/catalogue.d.ts
|
|
247
|
+
declare namespace Catalogue {
|
|
248
|
+
/**
|
|
249
|
+
* One guess at a locale, or several. Guesses are read from cookies, headers
|
|
250
|
+
* and URL segments, none of which is guaranteed to be there, so an absent
|
|
251
|
+
* one is allowed and skipped.
|
|
252
|
+
*/
|
|
253
|
+
type Guess = string | null | undefined | readonly (string | null | undefined)[];
|
|
254
|
+
/**
|
|
255
|
+
* Where one locale's messages come from: the messages themselves, or a
|
|
256
|
+
* function that produces them the first time the locale is asked for.
|
|
257
|
+
*
|
|
258
|
+
* A thunk is normally a dynamic import, which is what splits a locale into
|
|
259
|
+
* its own chunk: `fr: () => import('./locales/fr.po')`. Written per locale
|
|
260
|
+
* rather than as one function keyed by locale, so a bundler can see each
|
|
261
|
+
* import statically.
|
|
262
|
+
*/
|
|
263
|
+
type Source = View.Messages | (() => Catalogue.Produced | Promise<Catalogue.Produced>);
|
|
264
|
+
/**
|
|
265
|
+
* What a thunk produces: a locale's messages, or the module a dynamic import
|
|
266
|
+
* resolves to, which holds them as its default export.
|
|
267
|
+
*/
|
|
268
|
+
type Produced = View.Messages | {
|
|
269
|
+
default: View.Messages;
|
|
270
|
+
};
|
|
271
|
+
/**
|
|
272
|
+
* Where each locale's messages come from, keyed by locale.
|
|
273
|
+
*
|
|
274
|
+
* The keys are the catalogue's locales, in the order they are written, and
|
|
275
|
+
* the first of them is the default locale.
|
|
276
|
+
*/
|
|
277
|
+
type Options<Locale extends string> = Record<Locale, Source>;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Everything an application knows how to say: its locales and where their
|
|
281
|
+
* messages come from.
|
|
282
|
+
*
|
|
283
|
+
* A catalogue never formats anything and has no active locale. Formatting is
|
|
284
|
+
* what a {@link View} does, and a view is what `locale` hands back.
|
|
285
|
+
*/
|
|
286
|
+
interface Catalogue<Locale extends string = string> {
|
|
287
|
+
/**
|
|
288
|
+
* All available locales, in the order they were written.
|
|
289
|
+
*
|
|
290
|
+
* Never empty, and the first of them is the fallback: the locale
|
|
291
|
+
* {@link match} resolves to when nothing else does.
|
|
292
|
+
*/
|
|
293
|
+
readonly locales: readonly [Locale, ...Locale[]];
|
|
294
|
+
/**
|
|
295
|
+
* A view bound to one locale: callable, immutable, and memoised, so asking
|
|
296
|
+
* twice returns the same view.
|
|
297
|
+
*
|
|
298
|
+
* @param locale Locale to bind to
|
|
299
|
+
* @returns The view for the locale
|
|
300
|
+
* @throws If the locale's messages are not here yet, as for a thunk nobody
|
|
301
|
+
* has {@link load}ed
|
|
302
|
+
*/
|
|
303
|
+
locale(locale: Locale): View<Locale>;
|
|
304
|
+
/**
|
|
305
|
+
* Whether a locale's messages are here, and so whether
|
|
306
|
+
* {@link Catalogue.locale} hands back a view for it. Inline messages are
|
|
307
|
+
* here from the start; a thunk's once it has been {@link load}ed.
|
|
308
|
+
*
|
|
309
|
+
* @param locale Locale to check
|
|
310
|
+
*/
|
|
311
|
+
loaded(locale: Locale): boolean;
|
|
312
|
+
/**
|
|
313
|
+
* Calls a locale's thunk, if it has one and nothing has called it yet, and
|
|
314
|
+
* hands back the locale's view.
|
|
315
|
+
*
|
|
316
|
+
* If the thunk returns a promise, so does this. A locale whose messages are
|
|
317
|
+
* already here does not go near its thunk and comes back synchronously,
|
|
318
|
+
* which keeps a switch between loaded locales in one tick.
|
|
319
|
+
*
|
|
320
|
+
* A thunk is called once and a locale is filled once, so nothing can replace
|
|
321
|
+
* the messages a view was built over.
|
|
322
|
+
*
|
|
323
|
+
* @param locale Locale to load
|
|
324
|
+
* @returns The view for the locale
|
|
325
|
+
*/
|
|
326
|
+
load(locale: Locale): View<Locale> | Promise<View<Locale>>;
|
|
327
|
+
/**
|
|
328
|
+
* Matches the best locale from a list of guesses.
|
|
329
|
+
*
|
|
330
|
+
* An absent or empty guess is skipped rather than throwing, which lets a
|
|
331
|
+
* caller write `match(fromCookie, fromHeader)` without narrowing each first.
|
|
332
|
+
*
|
|
333
|
+
* @param guesses List of locale guesses
|
|
334
|
+
* @returns The best matching locale, or the first of {@link locales} if no
|
|
335
|
+
* matches are found
|
|
336
|
+
*/
|
|
337
|
+
match(...guesses: Catalogue.Guess[]): Locale;
|
|
338
|
+
/**
|
|
339
|
+
* Every locale, paired with its view.
|
|
340
|
+
*
|
|
341
|
+
* @throws If any locale's messages have not been produced yet
|
|
342
|
+
*/
|
|
343
|
+
[Symbol.iterator](): IterableIterator<[Locale, View<Locale>]>;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Create a {@link Catalogue}.
|
|
347
|
+
*
|
|
348
|
+
* @example
|
|
349
|
+
* ```ts
|
|
350
|
+
* const catalogue = createCatalogue({
|
|
351
|
+
* en,
|
|
352
|
+
* fr: () => import('./locales/fr.po'),
|
|
353
|
+
* pl: () => import('./locales/pl.po'),
|
|
354
|
+
* });
|
|
355
|
+
*
|
|
356
|
+
* const say = catalogue.locale('en');
|
|
357
|
+
* ```
|
|
358
|
+
*
|
|
359
|
+
* @param messages Where each locale's messages come from, keyed by locale
|
|
360
|
+
* @returns The catalogue
|
|
361
|
+
*/
|
|
362
|
+
declare function createCatalogue<const Locale extends string = string>(messages: Catalogue.Options<Locale>): Catalogue<Locale>;
|
|
363
|
+
//#endregion
|
|
364
|
+
//#region src/store.d.ts
|
|
365
|
+
declare namespace Store {
|
|
366
|
+
/** Called after a successful switch, with the view that is now current. */
|
|
367
|
+
type Listener<Locale extends string> = (view: View<Locale>) => void;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Which view is current, and when that changed.
|
|
371
|
+
*
|
|
372
|
+
* A catalogue owns the messages and a view formats one locale's; neither of
|
|
373
|
+
* them mutates. A store is the role that does: it holds one view at a time,
|
|
374
|
+
* swaps it for another, and tells whoever is listening.
|
|
375
|
+
*
|
|
376
|
+
* That mutation is the point, so a store is a browser value: one locale, one
|
|
377
|
+
* user, module scope. A server handles several locales at once and should
|
|
378
|
+
* reach a view through the request instead.
|
|
379
|
+
*/
|
|
380
|
+
interface Store<Locale extends string = string> {
|
|
381
|
+
/**
|
|
382
|
+
* The current view: callable, immutable, and safe to hold onto until the
|
|
383
|
+
* next switch.
|
|
384
|
+
*
|
|
385
|
+
* It is named for the tag a message is written against, so a store can be
|
|
386
|
+
* formatted through directly, and it is read at the moment of access rather
|
|
387
|
+
* than bound once. Read it per call: a `const say = store.say` held across a
|
|
388
|
+
* switch is the old view.
|
|
389
|
+
*
|
|
390
|
+
* Its identity changes when the locale does, which is what lets a subscriber
|
|
391
|
+
* compare snapshots. Because a catalogue memoises views, switching away and
|
|
392
|
+
* back hands the same view back.
|
|
393
|
+
*
|
|
394
|
+
* @example
|
|
395
|
+
* ```ts
|
|
396
|
+
* store.say`Hello, ${name}!`;
|
|
397
|
+
* store.say.locale; // 'en'
|
|
398
|
+
* ```
|
|
399
|
+
*/
|
|
400
|
+
readonly say: View<Locale>;
|
|
401
|
+
/**
|
|
402
|
+
* Switch to another locale, loading its messages first if the catalogue does
|
|
403
|
+
* not have them yet.
|
|
404
|
+
*
|
|
405
|
+
* Like {@link Catalogue.load}, this returns a promise only when the thunk
|
|
406
|
+
* does: a locale that is already loaded switches synchronously, so a
|
|
407
|
+
* subscriber sees the new view in the same tick.
|
|
408
|
+
*
|
|
409
|
+
* Switching to the current locale does nothing, though asking again for a
|
|
410
|
+
* locale still being switched to hands back the switch already in flight. A
|
|
411
|
+
* load that throws leaves the current view where it was.
|
|
412
|
+
*
|
|
413
|
+
* @param locale Locale to switch to
|
|
414
|
+
* @returns Nothing, or a promise that resolves once the switch is done
|
|
415
|
+
*/
|
|
416
|
+
set(locale: Locale): void | Promise<void>;
|
|
417
|
+
/**
|
|
418
|
+
* Listen for switches. The listener is called after {@link Store.say} has
|
|
419
|
+
* changed, and not on subscribe: the current view is already readable.
|
|
420
|
+
*
|
|
421
|
+
* @param listener Called with the view that is now current
|
|
422
|
+
* @returns A function that removes the listener
|
|
423
|
+
*/
|
|
424
|
+
subscribe(listener: Store.Listener<Locale>): () => void;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Create a {@link Store} over a catalogue.
|
|
428
|
+
*
|
|
429
|
+
* @example
|
|
430
|
+
* ```ts
|
|
431
|
+
* const store = createStore(catalogue, 'en');
|
|
432
|
+
*
|
|
433
|
+
* store.subscribe(render);
|
|
434
|
+
* await store.set('fr');
|
|
435
|
+
* store.say`Hello, ${name}!`;
|
|
436
|
+
* store.say.locale; // 'fr'
|
|
437
|
+
* ```
|
|
438
|
+
*
|
|
439
|
+
* @param catalogue The catalogue to take views from
|
|
440
|
+
* @param locale The locale to start on, defaults to the first of
|
|
441
|
+
* {@link Catalogue.locales}
|
|
442
|
+
* @returns The store
|
|
443
|
+
* @throws If the starting locale has no messages loaded
|
|
444
|
+
*/
|
|
445
|
+
declare function createStore<Locale extends string>(catalogue: Catalogue<Locale>, locale?: Locale): Store<Locale>;
|
|
446
|
+
//#endregion
|
|
447
|
+
//#region src/scope.d.ts
|
|
448
|
+
declare namespace Scope {
|
|
449
|
+
/**
|
|
450
|
+
* Where a scope holds the view a piece of work is running under.
|
|
451
|
+
*
|
|
452
|
+
* This is `AsyncLocalStorage`'s own shape, so node's is passed directly and
|
|
453
|
+
* any polyfill fits without an adapter. saykit imports none of them: which
|
|
454
|
+
* storage to use, if any, is the application's choice.
|
|
455
|
+
*
|
|
456
|
+
* @example
|
|
457
|
+
* ```ts
|
|
458
|
+
* import { AsyncLocalStorage } from 'node:async_hooks';
|
|
459
|
+
*
|
|
460
|
+
* const scope = createScope(new AsyncLocalStorage());
|
|
461
|
+
* ```
|
|
462
|
+
*/
|
|
463
|
+
interface Storage {
|
|
464
|
+
/** The view established by the innermost enclosing {@link Scope.run}. */
|
|
465
|
+
getStore(): View | undefined;
|
|
466
|
+
/** Run a callback with a view in place. */
|
|
467
|
+
run<Args extends unknown[], Return>(view: View, callback: (...args: Args) => Return, ...args: Args): Return;
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Where a scope reads its view outside {@link Scope.run}: the view itself,
|
|
471
|
+
* or a store to read {@link Store.say} from on every access, so a scope
|
|
472
|
+
* follows that store's switches without subscribing to it.
|
|
473
|
+
*/
|
|
474
|
+
type Source<Locale extends string = string> = View<Locale> | Store<Locale>;
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Which view the work running right now is saying things in.
|
|
478
|
+
*
|
|
479
|
+
* A scope is how code reaches a view without being handed it:
|
|
480
|
+
* {@link Scope.say} is imported once at the top of a module and resolves, on
|
|
481
|
+
* every use, to the view established around the call.
|
|
482
|
+
*
|
|
483
|
+
* On a server a {@link Scope.Storage} makes that one view per request, kept
|
|
484
|
+
* across every await and invisible to the requests beside it. A browser has
|
|
485
|
+
* one locale at a time, so a scope built without a storage holds one view,
|
|
486
|
+
* and {@link Scope.use} is how a store establishes it.
|
|
487
|
+
*/
|
|
488
|
+
interface Scope {
|
|
489
|
+
/**
|
|
490
|
+
* The current view, read through the scope on every use.
|
|
491
|
+
*
|
|
492
|
+
* Callable and carrying the macros like any `View`, but not bound to a
|
|
493
|
+
* locale: each read resolves the view established around the call, which is
|
|
494
|
+
* what lets a module import it once and still say the right thing for the
|
|
495
|
+
* request it ends up serving.
|
|
496
|
+
*
|
|
497
|
+
* @throws On use, if no view is in scope
|
|
498
|
+
*/
|
|
499
|
+
readonly say: View;
|
|
500
|
+
/**
|
|
501
|
+
* Run a callback with a view in scope. Everything it calls, at any depth,
|
|
502
|
+
* reads that view from {@link say}.
|
|
503
|
+
*
|
|
504
|
+
* With a {@link Scope.Storage}, each call is isolated and keeps its view
|
|
505
|
+
* across every await. Without one, the previous view is put back when the
|
|
506
|
+
* callback returns, so an async callback keeps its view only until its
|
|
507
|
+
* first await.
|
|
508
|
+
*
|
|
509
|
+
* @param view View to establish for the duration
|
|
510
|
+
* @param callback Callback to run
|
|
511
|
+
* @param args Arguments to call it with
|
|
512
|
+
* @returns Whatever the callback returns
|
|
513
|
+
*/
|
|
514
|
+
run<Args extends unknown[], Return>(view: View, callback: (...args: Args) => Return, ...args: Args): Return;
|
|
515
|
+
/**
|
|
516
|
+
* Establish the view to read outside any {@link run}.
|
|
517
|
+
*
|
|
518
|
+
* A browser has no request to hang a scope on, so this is how it establishes
|
|
519
|
+
* one, usually from a store, which is read on every access and so follows
|
|
520
|
+
* its switches.
|
|
521
|
+
*
|
|
522
|
+
* A server should prefer a scope per request. This is still useful there for
|
|
523
|
+
* a process that only ever serves one locale, such as a CLI or a worker.
|
|
524
|
+
*
|
|
525
|
+
* @param source The view to read, or a store to read it from, or `undefined`
|
|
526
|
+
* to leave none
|
|
527
|
+
* @returns A function that puts back whatever was there before
|
|
528
|
+
*/
|
|
529
|
+
use(source: Scope.Source | undefined): () => void;
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Create a {@link Scope}.
|
|
533
|
+
*
|
|
534
|
+
* @example
|
|
535
|
+
* ```ts
|
|
536
|
+
* // A server: one view per request, and `say` anywhere inside it
|
|
537
|
+
* export const scope = createScope(new AsyncLocalStorage());
|
|
538
|
+
* export const { say } = scope;
|
|
539
|
+
*
|
|
540
|
+
* scope.run(catalogue.locale(locale), handler);
|
|
541
|
+
* ```
|
|
542
|
+
*
|
|
543
|
+
* @example
|
|
544
|
+
* ```ts
|
|
545
|
+
* // A browser: one locale at a time, read from a store
|
|
546
|
+
* export const { say, use } = createScope();
|
|
547
|
+
*
|
|
548
|
+
* use(store);
|
|
549
|
+
* ```
|
|
550
|
+
*
|
|
551
|
+
* @param storage Where to hold the view, normally an `AsyncLocalStorage`. Left
|
|
552
|
+
* out, the scope holds one view for the whole program, which is what a
|
|
553
|
+
* browser wants
|
|
554
|
+
* @returns The scope
|
|
555
|
+
*/
|
|
556
|
+
declare function createScope(storage?: Scope.Storage): Scope;
|
|
557
|
+
//#endregion
|
|
558
|
+
export { Awaitable, type Catalogue, DateTimeOptions, Disallow, Named, NumberOptions, NumeralOptions, type Scope, SelectOptions, Skeleton, type Store, Tuple, type View, createCatalogue, createScope, createStore, createView };
|
|
@@ -424,121 +424,237 @@ function compile(locale, source) {
|
|
|
424
424
|
return new MessageFormat(locale, toMessage(parse(source)), { functions });
|
|
425
425
|
}
|
|
426
426
|
//#endregion
|
|
427
|
-
//#region src/
|
|
427
|
+
//#region src/view.ts
|
|
428
428
|
function resolveDescriptorValues(descriptor) {
|
|
429
429
|
return Object.fromEntries(Object.entries(descriptor).filter(([key]) => key !== "id").map(([key, value]) => [key.startsWith("_") ? key.slice(1) : key, value]));
|
|
430
430
|
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
if (options.messages) this.assign(options.messages);
|
|
443
|
-
}
|
|
444
|
-
get locale() {
|
|
445
|
-
if (!this.#active) throw new Error("No active locale");
|
|
446
|
-
return this.#active;
|
|
447
|
-
}
|
|
448
|
-
get messages() {
|
|
449
|
-
/* v8 ignore next */
|
|
450
|
-
if (!this.#messages.has(this.locale)) throw new Error("No messages loaded for locale");
|
|
451
|
-
return this.#messages.get(this.locale);
|
|
452
|
-
}
|
|
453
|
-
get locales() {
|
|
454
|
-
return this.#locales;
|
|
455
|
-
}
|
|
456
|
-
load(...locales) {
|
|
457
|
-
if (Object.isFrozen(this)) throw new Error("Cannot load messages on a frozen Say");
|
|
458
|
-
if (locales.length === 0) locales = this.#locales;
|
|
459
|
-
const tasks = [];
|
|
460
|
-
for (const locale of locales) {
|
|
461
|
-
if (this.#messages.has(locale)) continue;
|
|
462
|
-
if (!this.#loader) throw new Error("No loader provided, cannot load messages");
|
|
463
|
-
const result = this.#loader(locale);
|
|
464
|
-
if (result instanceof Promise) {
|
|
465
|
-
const task = result.then((m) => this.assign(locale, m));
|
|
466
|
-
tasks.push(task);
|
|
467
|
-
} else this.assign(locale, result);
|
|
468
|
-
}
|
|
469
|
-
return tasks.length > 0 ? Promise.all(tasks).then(() => this) : this;
|
|
470
|
-
}
|
|
471
|
-
assign(localeOrMessages, maybeMessages) {
|
|
472
|
-
if (Object.isFrozen(this)) throw new Error("Cannot assign messages on a frozen Say");
|
|
473
|
-
if (typeof localeOrMessages === "string") this.#messages.set(localeOrMessages, maybeMessages);
|
|
474
|
-
else for (const locale in localeOrMessages) this.#messages.set(locale, localeOrMessages[locale]);
|
|
475
|
-
return this;
|
|
476
|
-
}
|
|
477
|
-
activate(locale) {
|
|
478
|
-
if (Object.isFrozen(this)) throw new Error("Cannot activate locale on a frozen Say");
|
|
479
|
-
if (!this.#messages.has(locale)) throw new Error("No messages loaded for locale");
|
|
480
|
-
this.#active = locale;
|
|
481
|
-
return this;
|
|
482
|
-
}
|
|
483
|
-
clone() {
|
|
484
|
-
const copy = new Say({
|
|
485
|
-
locales: this.#locales,
|
|
486
|
-
messages: Object.fromEntries(this.#messages),
|
|
487
|
-
loader: this.#loader
|
|
488
|
-
});
|
|
489
|
-
copy.#active = this.#active;
|
|
490
|
-
return copy;
|
|
491
|
-
}
|
|
492
|
-
freeze() {
|
|
493
|
-
return Object.freeze(this);
|
|
494
|
-
}
|
|
495
|
-
*[Symbol.iterator]() {
|
|
496
|
-
for (const l of this.#locales) yield [this.clone().activate(l).freeze(), l];
|
|
497
|
-
}
|
|
498
|
-
match(...guesses) {
|
|
499
|
-
const flat = guesses.flat();
|
|
500
|
-
if (flat.length === 0) return this.#locales[0];
|
|
501
|
-
for (const guess of flat) {
|
|
502
|
-
if (this.#locales.includes(guess)) return guess;
|
|
503
|
-
const prefix = guess.split("-")[0];
|
|
504
|
-
if (!prefix) continue;
|
|
505
|
-
const match = this.#locales.find((l) => l.startsWith(prefix));
|
|
506
|
-
if (match) return match;
|
|
507
|
-
}
|
|
508
|
-
return this.#locales[0];
|
|
509
|
-
}
|
|
510
|
-
call(descriptor) {
|
|
511
|
-
return this.#call(this.locale, this.messages, descriptor);
|
|
512
|
-
}
|
|
513
|
-
#call(locale, messages, descriptor) {
|
|
514
|
-
const message = messages[descriptor.id];
|
|
431
|
+
function macro(name) {
|
|
432
|
+
throw new Error(`'say.${name}' is a macro and must be used with the relevant saykit plugin`);
|
|
433
|
+
}
|
|
434
|
+
function createView(locale, messages) {
|
|
435
|
+
const own = Object.freeze({ ...messages });
|
|
436
|
+
const formats = /* @__PURE__ */ new Map();
|
|
437
|
+
const say = (() => {
|
|
438
|
+
throw new Error("'say' is a macro and must be used with the relevant saykit plugin");
|
|
439
|
+
});
|
|
440
|
+
function call(descriptor) {
|
|
441
|
+
const message = own[descriptor.id];
|
|
515
442
|
if (typeof message !== "string") throw new Error(`Message for ${descriptor.id} is not a string`);
|
|
516
|
-
|
|
517
|
-
|
|
443
|
+
let format = formats.get(descriptor.id);
|
|
444
|
+
if (!format) formats.set(descriptor.id, format = compile(locale, message));
|
|
518
445
|
return String(format.format(resolveDescriptorValues(descriptor)));
|
|
519
446
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
447
|
+
return Object.freeze(Object.defineProperties(say, {
|
|
448
|
+
locale: {
|
|
449
|
+
value: locale,
|
|
450
|
+
enumerable: true
|
|
451
|
+
},
|
|
452
|
+
messages: {
|
|
453
|
+
value: own,
|
|
454
|
+
enumerable: true
|
|
455
|
+
},
|
|
456
|
+
call: { value: call },
|
|
457
|
+
plural: { value: () => macro("plural") },
|
|
458
|
+
ordinal: { value: () => macro("ordinal") },
|
|
459
|
+
select: { value: () => macro("select") },
|
|
460
|
+
number: { value: () => macro("number") },
|
|
461
|
+
date: { value: () => macro("date") },
|
|
462
|
+
time: { value: () => macro("time") },
|
|
463
|
+
[Symbol.for("nodejs.util.inspect.custom")]: { value: (_depth, context, inspect) => `View<${inspect(locale, context)}> {}` }
|
|
464
|
+
}));
|
|
465
|
+
}
|
|
466
|
+
//#endregion
|
|
467
|
+
//#region src/catalogue.ts
|
|
468
|
+
function createCatalogue(messages) {
|
|
469
|
+
const sources = Object.assign(Object.create(null), messages);
|
|
470
|
+
const keys = Object.freeze(Object.keys(sources));
|
|
471
|
+
if (keys.length === 0) throw new Error("A catalogue needs at least one locale, none were given");
|
|
472
|
+
const locales = keys;
|
|
473
|
+
const store = /* @__PURE__ */ new Map();
|
|
474
|
+
const views = /* @__PURE__ */ new Map();
|
|
475
|
+
const loading = /* @__PURE__ */ new Map();
|
|
476
|
+
function fill(locale, produced) {
|
|
477
|
+
const messages = typeof produced.default === "object" ? produced.default : produced;
|
|
478
|
+
if (!store.has(locale)) store.set(locale, messages);
|
|
479
|
+
return catalogue.locale(locale);
|
|
523
480
|
}
|
|
524
|
-
|
|
525
|
-
|
|
481
|
+
const catalogue = {
|
|
482
|
+
locales,
|
|
483
|
+
locale(locale) {
|
|
484
|
+
const messages = store.get(locale);
|
|
485
|
+
if (!messages) throw new Error(typeof sources[locale] === "function" ? `Messages for locale '${locale}' have not been loaded yet` : `No messages for locale '${locale}'`);
|
|
486
|
+
let view = views.get(locale);
|
|
487
|
+
if (!view) views.set(locale, view = createView(locale, messages));
|
|
488
|
+
return view;
|
|
489
|
+
},
|
|
490
|
+
loaded(locale) {
|
|
491
|
+
return store.has(locale);
|
|
492
|
+
},
|
|
493
|
+
load(locale) {
|
|
494
|
+
if (store.has(locale)) return catalogue.locale(locale);
|
|
495
|
+
const pending = loading.get(locale);
|
|
496
|
+
if (pending) return pending;
|
|
497
|
+
const source = sources[locale];
|
|
498
|
+
if (typeof source !== "function") throw new Error(`No messages for locale '${locale}'`);
|
|
499
|
+
const produced = source();
|
|
500
|
+
if (!(produced instanceof Promise)) return fill(locale, produced);
|
|
501
|
+
const task = produced.then((messages) => {
|
|
502
|
+
loading.delete(locale);
|
|
503
|
+
return fill(locale, messages);
|
|
504
|
+
}, (error) => {
|
|
505
|
+
loading.delete(locale);
|
|
506
|
+
throw error;
|
|
507
|
+
});
|
|
508
|
+
loading.set(locale, task);
|
|
509
|
+
return task;
|
|
510
|
+
},
|
|
511
|
+
match(...guesses) {
|
|
512
|
+
const flat = guesses.flat().filter((guess) => typeof guess === "string" && guess !== "");
|
|
513
|
+
if (flat.length === 0) return locales[0];
|
|
514
|
+
for (const guess of flat) {
|
|
515
|
+
if (locales.includes(guess)) return guess;
|
|
516
|
+
const prefix = guess.split("-")[0];
|
|
517
|
+
if (!prefix) continue;
|
|
518
|
+
const match = locales.find((l) => l.startsWith(prefix));
|
|
519
|
+
if (match) return match;
|
|
520
|
+
}
|
|
521
|
+
return locales[0];
|
|
522
|
+
},
|
|
523
|
+
*[Symbol.iterator]() {
|
|
524
|
+
for (const locale of locales) yield [locale, catalogue.locale(locale)];
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
for (const locale of locales) {
|
|
528
|
+
const source = sources[locale];
|
|
529
|
+
if (typeof source !== "function") fill(locale, source);
|
|
526
530
|
}
|
|
527
|
-
|
|
528
|
-
|
|
531
|
+
return Object.freeze(catalogue);
|
|
532
|
+
}
|
|
533
|
+
//#endregion
|
|
534
|
+
//#region src/scope.ts
|
|
535
|
+
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
|
+
function createVariableStorage() {
|
|
537
|
+
let running;
|
|
538
|
+
return {
|
|
539
|
+
getStore: () => running,
|
|
540
|
+
run(view, callback, ...args) {
|
|
541
|
+
const previous = running;
|
|
542
|
+
running = view;
|
|
543
|
+
try {
|
|
544
|
+
return callback(...args);
|
|
545
|
+
} finally {
|
|
546
|
+
running = previous;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
function createScope(storage = createVariableStorage()) {
|
|
552
|
+
let source;
|
|
553
|
+
let calls = 0;
|
|
554
|
+
let call = 0;
|
|
555
|
+
function peek() {
|
|
556
|
+
const running = storage.getStore();
|
|
557
|
+
if (running) return running;
|
|
558
|
+
if (!source) return void 0;
|
|
559
|
+
return typeof source === "function" ? source : source.say;
|
|
529
560
|
}
|
|
530
|
-
|
|
531
|
-
|
|
561
|
+
function resolve() {
|
|
562
|
+
const view = peek();
|
|
563
|
+
if (!view) throw new Error(NO_VIEW);
|
|
564
|
+
return view;
|
|
532
565
|
}
|
|
533
|
-
|
|
534
|
-
|
|
566
|
+
const target = (() => {});
|
|
567
|
+
const scope = {
|
|
568
|
+
say: new Proxy(target, {
|
|
569
|
+
apply: (_target, _this, args) => resolve()(...args),
|
|
570
|
+
get: (_target, property) => Reflect.get(resolve(), property),
|
|
571
|
+
has: (_target, property) => Reflect.has(resolve(), property),
|
|
572
|
+
ownKeys: () => Reflect.ownKeys(resolve()),
|
|
573
|
+
getOwnPropertyDescriptor: (_target, property) => {
|
|
574
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(resolve(), property);
|
|
575
|
+
return descriptor && {
|
|
576
|
+
...descriptor,
|
|
577
|
+
configurable: true
|
|
578
|
+
};
|
|
579
|
+
},
|
|
580
|
+
set: () => false,
|
|
581
|
+
defineProperty: () => false,
|
|
582
|
+
deleteProperty: () => false
|
|
583
|
+
}),
|
|
584
|
+
run(view, callback, ...args) {
|
|
585
|
+
return storage.run(view, callback, ...args);
|
|
586
|
+
},
|
|
587
|
+
use(next) {
|
|
588
|
+
const previous = source;
|
|
589
|
+
const previousCall = call;
|
|
590
|
+
const mine = ++calls;
|
|
591
|
+
source = next;
|
|
592
|
+
call = mine;
|
|
593
|
+
return () => {
|
|
594
|
+
if (call !== mine) return;
|
|
595
|
+
source = previous;
|
|
596
|
+
call = previousCall;
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
return Object.freeze(Object.defineProperties(scope, { [Symbol.for("nodejs.util.inspect.custom")]: { value: (_depth, context, inspect) => {
|
|
601
|
+
const view = peek();
|
|
602
|
+
return `Scope<${view ? inspect(view.locale, context) : "unset"}> {}`;
|
|
603
|
+
} } }));
|
|
604
|
+
}
|
|
605
|
+
//#endregion
|
|
606
|
+
//#region src/store.ts
|
|
607
|
+
function createStore(catalogue, locale = catalogue.locales[0]) {
|
|
608
|
+
let current = catalogue.locale(locale);
|
|
609
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
610
|
+
let generation = 0;
|
|
611
|
+
let intended = current.locale;
|
|
612
|
+
let pending;
|
|
613
|
+
function settle(at) {
|
|
614
|
+
if (at === generation) pending = void 0;
|
|
535
615
|
}
|
|
536
|
-
|
|
537
|
-
|
|
616
|
+
function swap(view, at) {
|
|
617
|
+
if (at !== generation) return;
|
|
618
|
+
if (view === current) return;
|
|
619
|
+
current = view;
|
|
620
|
+
for (const listener of listeners) listener(current);
|
|
538
621
|
}
|
|
539
|
-
|
|
540
|
-
|
|
622
|
+
function undo(at) {
|
|
623
|
+
if (at === generation) intended = current.locale;
|
|
541
624
|
}
|
|
542
|
-
|
|
625
|
+
return Object.freeze(Object.defineProperties({
|
|
626
|
+
get say() {
|
|
627
|
+
return current;
|
|
628
|
+
},
|
|
629
|
+
set(target) {
|
|
630
|
+
if (target === intended) return pending;
|
|
631
|
+
const at = ++generation;
|
|
632
|
+
intended = target;
|
|
633
|
+
try {
|
|
634
|
+
const loading = catalogue.load(target);
|
|
635
|
+
if (loading instanceof Promise) return pending = loading.then((view) => {
|
|
636
|
+
settle(at);
|
|
637
|
+
swap(view, at);
|
|
638
|
+
}, (error) => {
|
|
639
|
+
settle(at);
|
|
640
|
+
undo(at);
|
|
641
|
+
throw error;
|
|
642
|
+
});
|
|
643
|
+
pending = void 0;
|
|
644
|
+
swap(loading, at);
|
|
645
|
+
} catch (error) {
|
|
646
|
+
pending = void 0;
|
|
647
|
+
undo(at);
|
|
648
|
+
throw error;
|
|
649
|
+
}
|
|
650
|
+
},
|
|
651
|
+
subscribe(listener) {
|
|
652
|
+
listeners.add(listener);
|
|
653
|
+
return () => {
|
|
654
|
+
listeners.delete(listener);
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
}, { [Symbol.for("nodejs.util.inspect.custom")]: { value: (_depth, context, inspect) => `Store<${inspect(current.locale, context)}> {}` } }));
|
|
658
|
+
}
|
|
543
659
|
//#endregion
|
|
544
|
-
export {
|
|
660
|
+
export { createCatalogue, createScope, createStore, createView };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "saykit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Type-safe i18n library with compile-time macro transforms",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"i18n",
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"sideEffects": false,
|
|
29
29
|
"exports": {
|
|
30
30
|
".": {
|
|
31
|
-
"types": "./dist/
|
|
32
|
-
"default": "./dist/
|
|
31
|
+
"types": "./dist/index.d.mts",
|
|
32
|
+
"default": "./dist/index.mjs"
|
|
33
33
|
}
|
|
34
34
|
},
|
|
35
35
|
"publishConfig": {
|
package/dist/runtime.d.mts
DELETED
|
@@ -1,316 +0,0 @@
|
|
|
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>;
|
|
5
|
-
/**
|
|
6
|
-
* A value wrapped in the name its ICU placeholder should take, written inline
|
|
7
|
-
* as a single-key object: `` say`Total: ${{ cartTotal: getTotal() }}` ``. The
|
|
8
|
-
* transform reads the key at build time and compiles only the value, so this
|
|
9
|
-
* is never a real object at runtime.
|
|
10
|
-
*/
|
|
11
|
-
type Named<T> = {
|
|
12
|
-
[name: string]: T;
|
|
13
|
-
};
|
|
14
|
-
/**
|
|
15
|
-
* Branches of a choice, keyed by CLDR category or by exact number.
|
|
16
|
-
*
|
|
17
|
-
* `Branch` is what a case may be written as. It is text everywhere the message
|
|
18
|
-
* is text, and widens in JSX, where a case that shows the number has to be a
|
|
19
|
-
* fragment — a string attribute has nowhere to put a value.
|
|
20
|
-
*/
|
|
21
|
-
interface NumeralOptions<Branch = string> extends Omit<Partial<Record<Intl.LDMLPluralRule, Branch>>, 'other'> {
|
|
22
|
-
other: Branch;
|
|
23
|
-
[digit: number]: Branch;
|
|
24
|
-
/**
|
|
25
|
-
* Subtracted from the value before `#` is formatted, so "You and 2 others"
|
|
26
|
-
* can select on a total of three. Reserved — it never names a branch.
|
|
27
|
-
*/
|
|
28
|
-
offset?: number;
|
|
29
|
-
}
|
|
30
|
-
interface SelectOptions<Branch = string> {
|
|
31
|
-
other: Branch;
|
|
32
|
-
[match: string | number]: Branch;
|
|
33
|
-
}
|
|
34
|
-
/**
|
|
35
|
-
* An ICU skeleton: a `::`-prefixed description of the parts a value is written
|
|
36
|
-
* with, rather than a name for a whole format. `::currency/EUR` and
|
|
37
|
-
* `::yyyyMMdd` say what to show and leave the arrangement to the locale.
|
|
38
|
-
*
|
|
39
|
-
* A skeleton is where the formats the named styles have no word for live —
|
|
40
|
-
* currency, compact notation, a year and month with no day — so every argument
|
|
41
|
-
* type accepts one alongside its named styles.
|
|
42
|
-
*/
|
|
43
|
-
type Skeleton = `::${string}`;
|
|
44
|
-
/**
|
|
45
|
-
* Formatting for a `{arg, number}` placeholder.
|
|
46
|
-
*
|
|
47
|
-
* A literal `NumberFormat` pattern such as `#,##0.00` is also accepted, for the
|
|
48
|
-
* cases neither the named styles nor a skeleton spell more clearly.
|
|
49
|
-
*/
|
|
50
|
-
interface NumberOptions {
|
|
51
|
-
style?: 'integer' | 'percent' | Skeleton | (string & {});
|
|
52
|
-
}
|
|
53
|
-
/**
|
|
54
|
-
* Formatting for a `{arg, date}` or `{arg, time}` placeholder.
|
|
55
|
-
*/
|
|
56
|
-
interface DateTimeOptions {
|
|
57
|
-
style?: 'short' | 'medium' | 'long' | 'full' | Skeleton;
|
|
58
|
-
}
|
|
59
|
-
//#endregion
|
|
60
|
-
//#region src/runtime.d.ts
|
|
61
|
-
declare namespace Say {
|
|
62
|
-
type Messages = {
|
|
63
|
-
[key: string]: string;
|
|
64
|
-
};
|
|
65
|
-
type Loader<Locale extends string> = (locale: Locale) => Messages | Promise<Messages>;
|
|
66
|
-
type Options<Locale extends string, Loader extends Say.Loader<Locale> | undefined> = {
|
|
67
|
-
locales: Locale[];
|
|
68
|
-
} & ({
|
|
69
|
-
messages: Record<Locale, Messages>;
|
|
70
|
-
loader?: Loader;
|
|
71
|
-
} | {
|
|
72
|
-
messages?: Partial<Record<Locale, Messages>>;
|
|
73
|
-
loader: Loader;
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
interface Say {
|
|
77
|
-
/**
|
|
78
|
-
* Define a message.
|
|
79
|
-
*
|
|
80
|
-
* An interpolated variable is named after itself. Anything else is numbered,
|
|
81
|
-
* unless it is written as a single-key object, which names it.
|
|
82
|
-
*
|
|
83
|
-
* @example
|
|
84
|
-
* ```ts
|
|
85
|
-
* say`Hello, ${name}!`
|
|
86
|
-
* say`Your total is ${{ cartTotal: getCartTotal() }}`
|
|
87
|
-
* ```
|
|
88
|
-
*
|
|
89
|
-
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
90
|
-
*/
|
|
91
|
-
(strings: TemplateStringsArray, ...placeholders: unknown[]): string;
|
|
92
|
-
/**
|
|
93
|
-
* Provide a custom id or context for the message, the latter used to disambiguate
|
|
94
|
-
* identical strings that have different meanings depending on usage.
|
|
95
|
-
*
|
|
96
|
-
* @example
|
|
97
|
-
* ```ts
|
|
98
|
-
* say({ context: 'direction' })`Right`
|
|
99
|
-
* say({ context: 'correctness' })`Right`
|
|
100
|
-
* ```
|
|
101
|
-
*
|
|
102
|
-
* @param descriptor Object containing optional `id` and `context` properties
|
|
103
|
-
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
104
|
-
*/
|
|
105
|
-
(descriptor: {
|
|
106
|
-
id?: string;
|
|
107
|
-
context?: string;
|
|
108
|
-
}): Say;
|
|
109
|
-
}
|
|
110
|
-
type ReadonlySay<Locale extends string = string, Loader extends Say.Loader<Locale> | undefined = Say.Loader<Locale> | undefined> = Say<Locale, Loader> & {
|
|
111
|
-
activate: never;
|
|
112
|
-
load: never;
|
|
113
|
-
assign: never;
|
|
114
|
-
};
|
|
115
|
-
declare class Say<Locale extends string = string, Loader extends Say.Loader<Locale> | undefined = Say.Loader<Locale> | undefined> {
|
|
116
|
-
#private;
|
|
117
|
-
constructor(options: Say.Options<Locale, Loader>);
|
|
118
|
-
/**
|
|
119
|
-
* The currently active locale.
|
|
120
|
-
*
|
|
121
|
-
* @throws If no locale is active
|
|
122
|
-
*/
|
|
123
|
-
get locale(): Locale;
|
|
124
|
-
/**
|
|
125
|
-
* All available messages mapped by locale.
|
|
126
|
-
*
|
|
127
|
-
* @throws If no locale is active
|
|
128
|
-
* @throws If no messages are available for the active locale
|
|
129
|
-
*/
|
|
130
|
-
get messages(): Say.Messages;
|
|
131
|
-
/**
|
|
132
|
-
* All available locales.
|
|
133
|
-
*/
|
|
134
|
-
get locales(): Locale[];
|
|
135
|
-
/**
|
|
136
|
-
* Loads messages for the given locales.
|
|
137
|
-
* If no locales are provided, all available locales are loaded.
|
|
138
|
-
* Requires a {@link Say.Loader} to be provided.
|
|
139
|
-
* If `loader` returns a promise, so will this method.
|
|
140
|
-
*
|
|
141
|
-
* @param locales Locales to load messages for, defaults to {@link Say.locales}
|
|
142
|
-
* @returns This
|
|
143
|
-
*/
|
|
144
|
-
load(...locales: Locale[]): this | Promise<this>;
|
|
145
|
-
/**
|
|
146
|
-
* Manually bulk assign messages.
|
|
147
|
-
*
|
|
148
|
-
* @param messages Messages map to assign
|
|
149
|
-
*/
|
|
150
|
-
assign(messages: Partial<Record<Locale, Say.Messages>>): this;
|
|
151
|
-
/**
|
|
152
|
-
* Manually assign messages to a locale.
|
|
153
|
-
*
|
|
154
|
-
* @param locale Locale to assign messages to
|
|
155
|
-
* @param messages Messages to assign
|
|
156
|
-
* @returns This
|
|
157
|
-
*/
|
|
158
|
-
assign(locale: Locale, messages: Say.Messages): this;
|
|
159
|
-
/**
|
|
160
|
-
* Set the active locale.
|
|
161
|
-
*
|
|
162
|
-
* @param locale Locale to set
|
|
163
|
-
* @returns This
|
|
164
|
-
* @throws If locale is not available
|
|
165
|
-
*/
|
|
166
|
-
activate(locale: Locale): this;
|
|
167
|
-
/**
|
|
168
|
-
* Creates a clone of the Say instance, with the same locales and messages.
|
|
169
|
-
*
|
|
170
|
-
* @returns A clone of the Say instance
|
|
171
|
-
*/
|
|
172
|
-
clone(): this;
|
|
173
|
-
/**
|
|
174
|
-
* Make this `Say` instance immutable.
|
|
175
|
-
*/
|
|
176
|
-
freeze(): ReadonlySay<Locale, Loader>;
|
|
177
|
-
[Symbol.iterator](): Generator<[ReadonlySay<Locale, Loader>, Locale], void, unknown>;
|
|
178
|
-
/**
|
|
179
|
-
* Matches the best locale from a list of guesses.
|
|
180
|
-
*
|
|
181
|
-
* @param guesses List of locale guesses
|
|
182
|
-
*
|
|
183
|
-
* @returns The best matching locale, or the first locale if no matches are found
|
|
184
|
-
*/
|
|
185
|
-
match(...guesses: (string | string[])[]): Locale;
|
|
186
|
-
/**
|
|
187
|
-
* Get the translation for a descriptor.
|
|
188
|
-
*
|
|
189
|
-
* @param descriptor Descriptor to get the translation for
|
|
190
|
-
* @returns The translation string for the descriptor
|
|
191
|
-
* @throws If no locale is active
|
|
192
|
-
* @throws If no messages are available for the active locale
|
|
193
|
-
* @throws If descriptor id is not found
|
|
194
|
-
*/
|
|
195
|
-
call(descriptor: {
|
|
196
|
-
id: string;
|
|
197
|
-
[match: string | number]: unknown;
|
|
198
|
-
}): string;
|
|
199
|
-
/**
|
|
200
|
-
* Define a pluralised message.
|
|
201
|
-
*
|
|
202
|
-
* @example
|
|
203
|
-
* ```ts
|
|
204
|
-
* say.plural(count, {
|
|
205
|
-
* one: 'You have 1 item',
|
|
206
|
-
* other: `You have ${count} items`,
|
|
207
|
-
* })
|
|
208
|
-
* ```
|
|
209
|
-
*
|
|
210
|
-
* Interpolating the selector into a branch extracts as ICU's `#`, the number
|
|
211
|
-
* the message branched on. A `#` you write yourself is text.
|
|
212
|
-
* @param _ Number to determine the plural form of
|
|
213
|
-
* @param options Pluralisation rules keyed by CLDR categories or specific numbers
|
|
214
|
-
* @returns The plural form of the number
|
|
215
|
-
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
216
|
-
*/
|
|
217
|
-
plural(_: number | Named<number>, options: Disallow<NumeralOptions, 'id' | 'context'>): string;
|
|
218
|
-
/**
|
|
219
|
-
* Define an ordinal message (e.g. "1st", "2nd", "3rd").
|
|
220
|
-
*
|
|
221
|
-
* Interpolating the selector into a branch extracts as ICU's `#`, the number
|
|
222
|
-
* the message branched on. A `#` you write yourself is text.
|
|
223
|
-
*
|
|
224
|
-
* @example
|
|
225
|
-
* ```ts
|
|
226
|
-
* say.ordinal(position, {
|
|
227
|
-
* 1: `${position}st`,
|
|
228
|
-
* 2: `${position}nd`,
|
|
229
|
-
* 3: `${position}rd`,
|
|
230
|
-
* other: `${position}th`,
|
|
231
|
-
* })
|
|
232
|
-
* ```
|
|
233
|
-
*
|
|
234
|
-
* @param _ Number to determine the ordinal form of
|
|
235
|
-
* @param options Ordinal rules keyed by CLDR categories or specific numbers
|
|
236
|
-
* @returns The ordinal form of the number
|
|
237
|
-
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
238
|
-
*/
|
|
239
|
-
ordinal(_: number | Named<number>, options: Disallow<NumeralOptions, 'id' | 'context'>): string;
|
|
240
|
-
/**
|
|
241
|
-
* Define a select message, useful for handling gender, status, or other categories.
|
|
242
|
-
*
|
|
243
|
-
* @example
|
|
244
|
-
* ```ts
|
|
245
|
-
* say.select(gender, {
|
|
246
|
-
* male: 'He',
|
|
247
|
-
* female: 'She',
|
|
248
|
-
* other: 'They',
|
|
249
|
-
* })
|
|
250
|
-
* ```
|
|
251
|
-
*
|
|
252
|
-
* @param _ Selector value to determine which option is chosen
|
|
253
|
-
* @param options A mapping of possible selector values to message strings
|
|
254
|
-
* @returns The select form of the value
|
|
255
|
-
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
256
|
-
*/
|
|
257
|
-
select(_: string | number | Named<string | number>, options: Disallow<SelectOptions, 'id' | 'context'>): string;
|
|
258
|
-
/**
|
|
259
|
-
* Format a number the way the active locale writes one, with its own grouping
|
|
260
|
-
* separators and decimal mark.
|
|
261
|
-
*
|
|
262
|
-
* Unlike `plural`, `ordinal`, and `select`, this is a fragment rather than a
|
|
263
|
-
* whole message, and is normally written inside one.
|
|
264
|
-
*
|
|
265
|
-
* @example
|
|
266
|
-
* ```ts
|
|
267
|
-
* say`You have ${say.number(items.length)} items`
|
|
268
|
-
* say`Battery at ${say.number(level, { style: 'percent' })}`
|
|
269
|
-
* say`Total: ${say.number({ cartTotal: getTotal() }, { style: '#,##0.00' })}`
|
|
270
|
-
* say`Total: ${say.number(total, { style: '::currency/EUR' })}`
|
|
271
|
-
* ```
|
|
272
|
-
*
|
|
273
|
-
* @param _ Number to format
|
|
274
|
-
* @param options Formatting style: a named style, an ICU skeleton such as
|
|
275
|
-
* `::currency/EUR`, or a literal number pattern such as `#,##0.00`
|
|
276
|
-
* @returns The formatted number
|
|
277
|
-
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
278
|
-
*/
|
|
279
|
-
number(_: number | Named<number>, options?: Disallow<NumberOptions, 'id' | 'context'>): string;
|
|
280
|
-
/**
|
|
281
|
-
* Format the date portion of a value the way the active locale writes one.
|
|
282
|
-
*
|
|
283
|
-
* @example
|
|
284
|
-
* ```ts
|
|
285
|
-
* say`Published ${say.date(post.publishedAt)}`
|
|
286
|
-
* say`Published ${say.date(post.publishedAt, { style: 'full' })}`
|
|
287
|
-
* say`Published ${say.date(post.publishedAt, { style: '::yMMMM' })}`
|
|
288
|
-
* ```
|
|
289
|
-
*
|
|
290
|
-
* @param _ Date to format
|
|
291
|
-
* @param options Formatting style, either a named style or an ICU skeleton
|
|
292
|
-
* such as `::yyyyMMdd`
|
|
293
|
-
* @returns The formatted date
|
|
294
|
-
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
295
|
-
*/
|
|
296
|
-
date(_: Date | number | Named<Date | number>, options?: Disallow<DateTimeOptions, 'id' | 'context'>): string;
|
|
297
|
-
/**
|
|
298
|
-
* Format the time portion of a value the way the active locale writes one.
|
|
299
|
-
*
|
|
300
|
-
* @example
|
|
301
|
-
* ```ts
|
|
302
|
-
* say`Doors open at ${say.time(opensAt)}`
|
|
303
|
-
* say`Doors open at ${say.time(opensAt, { style: 'short' })}`
|
|
304
|
-
* say`Doors open at ${say.time(opensAt, { style: '::Hm' })}`
|
|
305
|
-
* ```
|
|
306
|
-
*
|
|
307
|
-
* @param _ Date to format
|
|
308
|
-
* @param options Formatting style, either a named style or an ICU skeleton
|
|
309
|
-
* such as `::Hm`
|
|
310
|
-
* @returns The formatted time
|
|
311
|
-
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
312
|
-
*/
|
|
313
|
-
time(_: Date | number | Named<Date | number>, options?: Disallow<DateTimeOptions, 'id' | 'context'>): string;
|
|
314
|
-
}
|
|
315
|
-
//#endregion
|
|
316
|
-
export { Awaitable, DateTimeOptions, Disallow, Named, NumberOptions, NumeralOptions, ReadonlySay, Say, SelectOptions, Skeleton, Tuple };
|