saykit 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,9 +4,9 @@
4
4
 
5
5
  [![Coverage](https://codecov.io/gh/k0d13/saykit/graph/badge.svg?flag=integration)](https://codecov.io/gh/k0d13/saykit?flags%5B0%5D=integration)
6
6
 
7
- The core runtime for [SayKit](https://saykit.js.org). Exports the `Say` class, which stores your locales, loads message catalogues, and formats messages using ICU MessageFormat.
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
- 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.
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.
10
10
 
11
11
  ## Install
12
12
 
@@ -19,19 +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 { Say } from 'saykit';
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 say = new Say({
27
- locales: ['en', 'fr'],
28
- messages: { en, fr },
29
- });
26
+ const catalogue = createCatalogue({ en, fr });
30
27
 
31
- say.activate('en');
28
+ const say = catalogue.locale('en');
32
29
 
33
30
  say`Hello, ${name}!`;
34
31
  say.plural(count, { one: '1 item', other: `${count} items` });
32
+ say`Total: ${say.number(total, { style: '::currency/EUR' })}`;
33
+ say.date(new Date(), { style: 'long' });
34
+ ```
35
+
36
+ In a browser, where the locale can change, hold a store instead of a view:
37
+
38
+ ```ts
39
+ import { createStore } from 'saykit';
40
+
41
+ const store = createStore(catalogue, 'en');
42
+
43
+ store.subscribe((say) => render(say));
44
+
45
+ await store.set('fr');
46
+
47
+ store.say`Hello, ${name}!`;
35
48
  ```
36
49
 
37
50
  ## Documentation
@@ -0,0 +1,566 @@
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
+ * What the transform compiles a message down to: the id it was extracted
63
+ * under, or the ICU message itself when there was nothing to extract, with
64
+ * its values behind one underscore each.
65
+ */
66
+ type Descriptor = ({
67
+ id: string;
68
+ } | {
69
+ message: string;
70
+ }) & {
71
+ [match: string | number]: unknown;
72
+ };
73
+ }
74
+ /**
75
+ * One locale, bound to the messages it formats against.
76
+ *
77
+ * Callable, immutable and memoised: `catalogue.locale('en')` hands back the
78
+ * same value every time. A view has no reference back to its catalogue and so
79
+ * cannot switch locale; switching belongs to whoever owns the catalogue.
80
+ */
81
+ interface View<Locale extends string = string> {
82
+ /**
83
+ * Define a message.
84
+ *
85
+ * An interpolated variable is named after itself. Anything else is numbered,
86
+ * unless written as a single-key object, which names it.
87
+ *
88
+ * @example
89
+ * ```ts
90
+ * say`Hello, ${name}!`
91
+ * say`Your total is ${{ cartTotal: getCartTotal() }}`
92
+ * ```
93
+ *
94
+ * @remark This is a macro and must be used with the relevant saykit plugin
95
+ */
96
+ (strings: TemplateStringsArray, ...placeholders: unknown[]): string;
97
+ /**
98
+ * Give the message a custom id, or a context to disambiguate identical
99
+ * strings that mean different things.
100
+ *
101
+ * @example
102
+ * ```ts
103
+ * say({ context: 'direction' })`Right`
104
+ * say({ context: 'correctness' })`Right`
105
+ * ```
106
+ *
107
+ * @param descriptor Object containing optional `id` and `context` properties
108
+ * @remark This is a macro and must be used with the relevant saykit plugin
109
+ */
110
+ (descriptor: {
111
+ id?: string;
112
+ context?: string;
113
+ }): View<Locale>;
114
+ /** The locale this view is bound to. */
115
+ readonly locale: Locale;
116
+ /** The messages this view formats against. */
117
+ readonly messages: Readonly<View.Messages>;
118
+ /**
119
+ * Format the message a descriptor names, or the message it carries.
120
+ *
121
+ * A message with nothing to translate, a lone `say.date(x)` with no text
122
+ * around it, is never extracted: the transform writes the ICU into the call
123
+ * instead of an id.
124
+ *
125
+ * @param descriptor Descriptor to format
126
+ * @returns The formatted message
127
+ * @throws If the id has no message
128
+ */
129
+ call(descriptor: View.Descriptor): string;
130
+ /**
131
+ * Define a pluralised message.
132
+ *
133
+ * Interpolating the selector into a branch extracts as ICU's `#`. A `#` you
134
+ * write yourself is text.
135
+ *
136
+ * @example
137
+ * ```ts
138
+ * say.plural(count, {
139
+ * one: 'You have 1 item',
140
+ * other: `You have ${count} items`,
141
+ * })
142
+ * ```
143
+ *
144
+ * @param _ Number to determine the plural form of
145
+ * @param options Pluralisation rules keyed by CLDR categories or specific numbers
146
+ * @returns The plural form of the number
147
+ * @remark This is a macro and must be used with the relevant saykit plugin
148
+ */
149
+ plural(_: number | Named<number>, options: Disallow<NumeralOptions, 'id' | 'context'>): string;
150
+ /**
151
+ * Define an ordinal message ("1st", "2nd", "3rd").
152
+ *
153
+ * Interpolating the selector into a branch extracts as ICU's `#`. A `#` you
154
+ * write yourself is text.
155
+ *
156
+ * @example
157
+ * ```ts
158
+ * say.ordinal(position, {
159
+ * 1: `${position}st`,
160
+ * 2: `${position}nd`,
161
+ * 3: `${position}rd`,
162
+ * other: `${position}th`,
163
+ * })
164
+ * ```
165
+ *
166
+ * @param _ Number to determine the ordinal form of
167
+ * @param options Ordinal rules keyed by CLDR categories or specific numbers
168
+ * @returns The ordinal form of the number
169
+ * @remark This is a macro and must be used with the relevant saykit plugin
170
+ */
171
+ ordinal(_: number | Named<number>, options: Disallow<NumeralOptions, 'id' | 'context'>): string;
172
+ /**
173
+ * Define a select message, for gender, status, or other categories.
174
+ *
175
+ * @example
176
+ * ```ts
177
+ * say.select(gender, {
178
+ * male: 'He',
179
+ * female: 'She',
180
+ * other: 'They',
181
+ * })
182
+ * ```
183
+ *
184
+ * @param _ Selector value to determine which option is chosen
185
+ * @param options A mapping of possible selector values to message strings
186
+ * @returns The select form of the value
187
+ * @remark This is a macro and must be used with the relevant saykit plugin
188
+ */
189
+ select(_: string | number | Named<string | number>, options: Disallow<SelectOptions, 'id' | 'context'>): string;
190
+ /**
191
+ * Format a number the way this view's locale writes one, with its own
192
+ * grouping separators and decimal mark.
193
+ *
194
+ * Unlike `plural`, `ordinal` and `select`, this is a fragment rather than a
195
+ * whole message, and is normally written inside one.
196
+ *
197
+ * @example
198
+ * ```ts
199
+ * say`You have ${say.number(items.length)} items`
200
+ * say`Battery at ${say.number(level, { style: 'percent' })}`
201
+ * say`Total: ${say.number({ cartTotal: getTotal() }, { style: '#,##0.00' })}`
202
+ * say`Total: ${say.number(total, { style: '::currency/EUR' })}`
203
+ * ```
204
+ *
205
+ * @param _ Number to format
206
+ * @param options Formatting style: a named style, an ICU skeleton such as
207
+ * `::currency/EUR`, or a pattern such as `#,##0.00`
208
+ * @returns The formatted number
209
+ * @remark This is a macro and must be used with the relevant saykit plugin
210
+ */
211
+ number(_: number | Named<number>, options?: Disallow<NumberOptions, 'id' | 'context'>): string;
212
+ /**
213
+ * Format the date portion of a value the way this view's locale writes one.
214
+ *
215
+ * @example
216
+ * ```ts
217
+ * say`Published ${say.date(post.publishedAt)}`
218
+ * say`Published ${say.date(post.publishedAt, { style: 'full' })}`
219
+ * say`Published ${say.date(post.publishedAt, { style: '::yMMMM' })}`
220
+ * ```
221
+ *
222
+ * @param _ Date to format
223
+ * @param options A named style or an ICU skeleton such as `::yyyyMMdd`
224
+ * @returns The formatted date
225
+ * @remark This is a macro and must be used with the relevant saykit plugin
226
+ */
227
+ date(_: Date | number | Named<Date | number>, options?: Disallow<DateTimeOptions, 'id' | 'context'>): string;
228
+ /**
229
+ * Format the time portion of a value the way this view's locale writes one.
230
+ *
231
+ * @example
232
+ * ```ts
233
+ * say`Doors open at ${say.time(opensAt)}`
234
+ * say`Doors open at ${say.time(opensAt, { style: 'short' })}`
235
+ * say`Doors open at ${say.time(opensAt, { style: '::Hm' })}`
236
+ * ```
237
+ *
238
+ * @param _ Date to format
239
+ * @param options A named style or an ICU skeleton such as `::Hm`
240
+ * @returns The formatted time
241
+ * @remark This is a macro and must be used with the relevant saykit plugin
242
+ */
243
+ time(_: Date | number | Named<Date | number>, options?: Disallow<DateTimeOptions, 'id' | 'context'>): string;
244
+ }
245
+ /**
246
+ * Create a view over one locale and the messages it formats against.
247
+ *
248
+ * A catalogue memoises one per locale, which is how application code usually
249
+ * reaches one; a single-locale app can build one directly. The format cache
250
+ * belongs to the view, so a view built over one set of messages can never be
251
+ * served a format compiled from another.
252
+ *
253
+ * @param locale The locale to bind to
254
+ * @param messages The messages this view formats against
255
+ * @returns The view
256
+ */
257
+ declare function createView<Locale extends string>(locale: Locale, messages: View.Messages): View<Locale>;
258
+ //#endregion
259
+ //#region src/catalogue.d.ts
260
+ declare namespace Catalogue {
261
+ /**
262
+ * One guess at a locale, or several. Guesses are read from cookies, headers
263
+ * and URL segments, none of which is guaranteed to be there, so an absent
264
+ * one is allowed and skipped.
265
+ */
266
+ type Guess = string | null | undefined | readonly (string | null | undefined)[];
267
+ /**
268
+ * Where one locale's messages come from: the messages themselves, or a
269
+ * function that produces them the first time the locale is asked for.
270
+ *
271
+ * A thunk is normally a dynamic import, which is what splits a locale into
272
+ * its own chunk: `fr: () => import('./locales/fr.po')`. Written per locale
273
+ * rather than as one function keyed by locale, so a bundler can see each
274
+ * import statically.
275
+ */
276
+ type Source = View.Messages | (() => Catalogue.Produced | Promise<Catalogue.Produced>);
277
+ /**
278
+ * What a thunk produces: a locale's messages, or the module a dynamic import
279
+ * resolves to, which holds them as its default export.
280
+ */
281
+ type Produced = View.Messages | {
282
+ default: View.Messages;
283
+ };
284
+ }
285
+ /**
286
+ * Everything an application knows how to say: its locales and where their
287
+ * messages come from.
288
+ *
289
+ * A catalogue never formats anything and has no active locale. Formatting is
290
+ * what a {@link View} does, and a view is what `locale` hands back.
291
+ */
292
+ interface Catalogue<Locale extends string = string> {
293
+ /**
294
+ * All available locales, in the order they were written.
295
+ *
296
+ * Never empty, and the first of them is the fallback: the locale
297
+ * {@link match} resolves to when nothing else does.
298
+ */
299
+ readonly locales: readonly [Locale, ...Locale[]];
300
+ /**
301
+ * A view bound to one locale: callable, immutable, and memoised, so asking
302
+ * twice returns the same view.
303
+ *
304
+ * @param locale Locale to bind to
305
+ * @returns The view for the locale
306
+ * @throws If the locale's messages are not here yet, as for a thunk nobody
307
+ * has {@link load}ed
308
+ */
309
+ locale(locale: Locale): View<Locale>;
310
+ /**
311
+ * Whether a locale's messages are here, and so whether
312
+ * {@link Catalogue.locale} hands back a view for it. Inline messages are
313
+ * here from the start; a thunk's once it has been {@link load}ed.
314
+ *
315
+ * @param locale Locale to check
316
+ */
317
+ loaded(locale: Locale): boolean;
318
+ /**
319
+ * Calls a locale's thunk, if it has one and nothing has called it yet, and
320
+ * hands back the locale's view.
321
+ *
322
+ * If the thunk returns a promise, so does this. A locale whose messages are
323
+ * already here does not go near its thunk and comes back synchronously,
324
+ * which keeps a switch between loaded locales in one tick.
325
+ *
326
+ * A thunk is called once and a locale is filled once, so nothing can replace
327
+ * the messages a view was built over.
328
+ *
329
+ * @param locale Locale to load
330
+ * @returns The view for the locale
331
+ */
332
+ load(locale: Locale): View<Locale> | Promise<View<Locale>>;
333
+ /**
334
+ * Matches the best locale from a list of guesses.
335
+ *
336
+ * An absent or empty guess is skipped rather than throwing, which lets a
337
+ * caller write `match(fromCookie, fromHeader)` without narrowing each first.
338
+ *
339
+ * @param guesses List of locale guesses
340
+ * @returns The best matching locale, or the first of {@link locales} if no
341
+ * matches are found
342
+ */
343
+ match(...guesses: Catalogue.Guess[]): Locale;
344
+ /**
345
+ * Every locale, paired with its view.
346
+ *
347
+ * @throws If any locale's messages have not been produced yet
348
+ */
349
+ [Symbol.iterator](): IterableIterator<[Locale, View<Locale>]>;
350
+ }
351
+ /**
352
+ * Create a {@link Catalogue}.
353
+ *
354
+ * @example
355
+ * ```ts
356
+ * const catalogue = createCatalogue({
357
+ * en,
358
+ * fr: () => import('./locales/fr.po'),
359
+ * pl: () => import('./locales/pl.po'),
360
+ * });
361
+ *
362
+ * const say = catalogue.locale('en');
363
+ * ```
364
+ *
365
+ * @param messages Where each locale's messages come from, keyed by locale. The
366
+ * keys are the catalogue's locales, in the order they are written, and the
367
+ * first of them is the default locale
368
+ * @returns The catalogue
369
+ */
370
+ declare function createCatalogue<const Locale extends string = string>(messages: Record<Locale, Catalogue.Source>): Catalogue<Locale>;
371
+ //#endregion
372
+ //#region src/store.d.ts
373
+ declare namespace Store {
374
+ /** Called after a successful switch, with the view that is now current. */
375
+ type Listener<Locale extends string> = (view: View<Locale>) => void;
376
+ }
377
+ /**
378
+ * Which view is current, and when that changed.
379
+ *
380
+ * A catalogue owns the messages and a view formats one locale's; neither of
381
+ * them mutates. A store is the role that does: it holds one view at a time,
382
+ * swaps it for another, and tells whoever is listening.
383
+ *
384
+ * That mutation is the point, so a store is a browser value: one locale, one
385
+ * user, module scope. A server handles several locales at once and should
386
+ * reach a view through the request instead.
387
+ */
388
+ interface Store<Locale extends string = string> {
389
+ /**
390
+ * The current view: callable, immutable, and safe to hold onto until the
391
+ * next switch.
392
+ *
393
+ * It is named for the tag a message is written against, so a store can be
394
+ * formatted through directly, and it is read at the moment of access rather
395
+ * than bound once. Read it per call: a `const say = store.say` held across a
396
+ * switch is the old view.
397
+ *
398
+ * Its identity changes when the locale does, which is what lets a subscriber
399
+ * compare snapshots. Because a catalogue memoises views, switching away and
400
+ * back hands the same view back.
401
+ *
402
+ * @example
403
+ * ```ts
404
+ * store.say`Hello, ${name}!`;
405
+ * store.say.locale; // 'en'
406
+ * ```
407
+ */
408
+ readonly say: View<Locale>;
409
+ /**
410
+ * Switch to another locale, loading its messages first if the catalogue does
411
+ * not have them yet.
412
+ *
413
+ * Like {@link Catalogue.load}, this returns a promise only when the thunk
414
+ * does: a locale that is already loaded switches synchronously, so a
415
+ * subscriber sees the new view in the same tick.
416
+ *
417
+ * Switching to the current locale does nothing, though asking again for a
418
+ * locale still being switched to hands back the switch already in flight. A
419
+ * load that throws leaves the current view where it was.
420
+ *
421
+ * @param locale Locale to switch to
422
+ * @returns Nothing, or a promise that resolves once the switch is done
423
+ */
424
+ set(locale: Locale): void | Promise<void>;
425
+ /**
426
+ * Listen for switches. The listener is called after {@link Store.say} has
427
+ * changed, and not on subscribe: the current view is already readable.
428
+ *
429
+ * @param listener Called with the view that is now current
430
+ * @returns A function that removes the listener
431
+ */
432
+ subscribe(listener: Store.Listener<Locale>): () => void;
433
+ }
434
+ /**
435
+ * Create a {@link Store} over a catalogue.
436
+ *
437
+ * @example
438
+ * ```ts
439
+ * const store = createStore(catalogue, 'en');
440
+ *
441
+ * store.subscribe(render);
442
+ * await store.set('fr');
443
+ * store.say`Hello, ${name}!`;
444
+ * store.say.locale; // 'fr'
445
+ * ```
446
+ *
447
+ * @param catalogue The catalogue to take views from
448
+ * @param locale The locale to start on, defaults to the first of
449
+ * {@link Catalogue.locales}
450
+ * @returns The store
451
+ * @throws If the starting locale has no messages loaded
452
+ */
453
+ declare function createStore<Locale extends string>(catalogue: Catalogue<Locale>, locale?: Locale): Store<Locale>;
454
+ //#endregion
455
+ //#region src/scope.d.ts
456
+ declare namespace Scope {
457
+ /**
458
+ * Where a scope holds the view a piece of work is running under.
459
+ *
460
+ * This is `AsyncLocalStorage`'s own shape, so node's is passed directly and
461
+ * any polyfill fits without an adapter. saykit imports none of them: which
462
+ * storage to use, if any, is the application's choice.
463
+ *
464
+ * @example
465
+ * ```ts
466
+ * import { AsyncLocalStorage } from 'node:async_hooks';
467
+ *
468
+ * const scope = createScope(new AsyncLocalStorage());
469
+ * ```
470
+ */
471
+ interface Storage {
472
+ /** The view established by the innermost enclosing {@link Scope.run}. */
473
+ getStore(): View | undefined;
474
+ /** Run a callback with a view in place. */
475
+ run<Args extends unknown[], Return>(view: View, callback: (...args: Args) => Return, ...args: Args): Return;
476
+ }
477
+ /**
478
+ * Where a scope reads its view outside {@link Scope.run}: the view itself,
479
+ * or a store to read {@link Store.say} from on every access, so a scope
480
+ * follows that store's switches without subscribing to it.
481
+ */
482
+ type Source<Locale extends string = string> = View<Locale> | Store<Locale>;
483
+ }
484
+ /**
485
+ * Which view the work running right now is saying things in.
486
+ *
487
+ * A scope is how code reaches a view without being handed it:
488
+ * {@link Scope.say} is imported once at the top of a module and resolves, on
489
+ * every use, to the view established around the call.
490
+ *
491
+ * On a server a {@link Scope.Storage} makes that one view per request, kept
492
+ * across every await and invisible to the requests beside it. A browser has
493
+ * one locale at a time, so a scope built without a storage holds one view,
494
+ * and {@link Scope.use} is how a store establishes it.
495
+ */
496
+ interface Scope {
497
+ /**
498
+ * The current view, read through the scope on every use.
499
+ *
500
+ * Callable and carrying the macros like any `View`, but not bound to a
501
+ * locale: each read resolves the view established around the call, which is
502
+ * what lets a module import it once and still say the right thing for the
503
+ * request it ends up serving.
504
+ *
505
+ * @throws On use, if no view is in scope
506
+ */
507
+ readonly say: View;
508
+ /**
509
+ * Run a callback with a view in scope. Everything it calls, at any depth,
510
+ * reads that view from {@link say}.
511
+ *
512
+ * With a {@link Scope.Storage}, each call is isolated and keeps its view
513
+ * across every await. Without one, the previous view is put back when the
514
+ * callback returns, so an async callback keeps its view only until its
515
+ * first await.
516
+ *
517
+ * @param view View to establish for the duration
518
+ * @param callback Callback to run
519
+ * @param args Arguments to call it with
520
+ * @returns Whatever the callback returns
521
+ */
522
+ run<Args extends unknown[], Return>(view: View, callback: (...args: Args) => Return, ...args: Args): Return;
523
+ /**
524
+ * Establish the view to read outside any {@link run}.
525
+ *
526
+ * A browser has no request to hang a scope on, so this is how it establishes
527
+ * one, usually from a store, which is read on every access and so follows
528
+ * its switches.
529
+ *
530
+ * A server should prefer a scope per request. This is still useful there for
531
+ * a process that only ever serves one locale, such as a CLI or a worker.
532
+ *
533
+ * @param source The view to read, or a store to read it from, or `undefined`
534
+ * to leave none
535
+ * @returns A function that puts back whatever was there before
536
+ */
537
+ use(source: Scope.Source | undefined): () => void;
538
+ }
539
+ /**
540
+ * Create a {@link Scope}.
541
+ *
542
+ * @example
543
+ * ```ts
544
+ * // A server: one view per request, and `say` anywhere inside it
545
+ * export const scope = createScope(new AsyncLocalStorage());
546
+ * export const { say } = scope;
547
+ *
548
+ * scope.run(catalogue.locale(locale), handler);
549
+ * ```
550
+ *
551
+ * @example
552
+ * ```ts
553
+ * // A browser: one locale at a time, read from a store
554
+ * export const { say, use } = createScope();
555
+ *
556
+ * use(store);
557
+ * ```
558
+ *
559
+ * @param storage Where to hold the view, normally an `AsyncLocalStorage`. Left
560
+ * out, the scope holds one view for the whole program, which is what a
561
+ * browser wants
562
+ * @returns The scope
563
+ */
564
+ declare function createScope(storage?: Scope.Storage): Scope;
565
+ //#endregion
566
+ export { Awaitable, type Catalogue, DateTimeOptions, Disallow, Named, NumberOptions, NumeralOptions, type Scope, SelectOptions, Skeleton, type Store, Tuple, type View, createCatalogue, createScope, createStore, createView };
@@ -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/runtime.ts
427
+ //#region src/view.ts
428
428
  function resolveDescriptorValues(descriptor) {
429
- return Object.fromEntries(Object.entries(descriptor).filter(([key]) => key !== "id").map(([key, value]) => [key.startsWith("_") ? key.slice(1) : key, value]));
429
+ return Object.fromEntries(Object.entries(descriptor).filter(([key]) => key !== "id" && key !== "message").map(([key, value]) => [key.startsWith("_") ? key.slice(1) : key, value]));
430
430
  }
431
- var Say = class Say {
432
- #locales;
433
- #loader;
434
- #messages;
435
- #formats;
436
- #active;
437
- constructor(options) {
438
- this.#locales = options.locales;
439
- this.#loader = options.loader;
440
- this.#messages = /* @__PURE__ */ new Map();
441
- this.#formats = /* @__PURE__ */ new Map();
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);
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 { id, message = own[id] } = descriptor;
442
+ if (typeof message !== "string") throw new Error(`Message for ${id} is not a string`);
443
+ let format = formats.get(message);
444
+ if (!format) formats.set(message, format = compile(locale, message));
445
+ return String(format.format(resolveDescriptorValues(descriptor)));
452
446
  }
453
- get locales() {
454
- return this.#locales;
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);
455
480
  }
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);
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)];
468
525
  }
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];
526
+ };
527
+ for (const locale of locales) {
528
+ const source = sources[locale];
529
+ if (typeof source !== "function") fill(locale, source);
497
530
  }
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;
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
+ }
507
548
  }
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];
515
- if (typeof message !== "string") throw new Error(`Message for ${descriptor.id} is not a string`);
516
- const key = `${locale}:${descriptor.id}`;
517
- const format = this.#formats.get(key) ?? this.#formats.set(key, compile(locale, message)).get(key);
518
- return String(format.format(resolveDescriptorValues(descriptor)));
519
- }
520
- [Symbol.for("nodejs.util.inspect.custom")](_depth, context, inspect) {
521
- if (this.#active) return `${this.constructor.name}<${inspect(this.#active, context)}> {}`;
522
- else return `${this.constructor.name} {}`;
523
- }
524
- plural(_, options) {
525
- throw new Error("'Say#plural' is a macro and must be used with the relevant saykit plugin");
526
- }
527
- ordinal(_, options) {
528
- throw new Error("'Say#ordinal' is a macro and must be used with the relevant saykit plugin");
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
- select(_, options) {
531
- throw new Error("'Say#select' is a macro and must be used with the relevant saykit plugin");
561
+ function resolve() {
562
+ const view = peek();
563
+ if (!view) throw new Error(NO_VIEW);
564
+ return view;
532
565
  }
533
- number(_, options) {
534
- throw new Error("'Say#number' is a macro and must be used with the relevant saykit plugin");
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
- date(_, options) {
537
- throw new Error("'Say#date' is a macro and must be used with the relevant saykit plugin");
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
- time(_, options) {
540
- throw new Error("'Say#time' is a macro and must be used with the relevant saykit plugin");
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 { Say };
660
+ export { createCatalogue, createScope, createStore, createView };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "saykit",
3
- "version": "0.9.1",
3
+ "version": "0.11.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/runtime.d.mts",
32
- "default": "./dist/runtime.mjs"
31
+ "types": "./dist/index.d.mts",
32
+ "default": "./dist/index.mjs"
33
33
  }
34
34
  },
35
35
  "publishConfig": {
@@ -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 };