saykit 0.9.1 → 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 CHANGED
@@ -4,7 +4,7 @@
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
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 { 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` });
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).
@@ -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/runtime.ts
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
- 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);
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
- const key = `${locale}:${descriptor.id}`;
517
- const format = this.#formats.get(key) ?? this.#formats.set(key, compile(locale, message)).get(key);
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
- [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} {}`;
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
- plural(_, options) {
525
- throw new Error("'Say#plural' is a macro and must be used with the relevant saykit plugin");
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
- ordinal(_, options) {
528
- throw new Error("'Say#ordinal' is a macro and must be used with the relevant saykit plugin");
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
- 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.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/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 };