saykit 0.10.0 → 0.11.1

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
@@ -6,7 +6,7 @@
6
6
 
7
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
 
@@ -29,6 +29,8 @@ const say = catalogue.locale('en');
29
29
 
30
30
  say`Hello, ${name}!`;
31
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' });
32
34
  ```
33
35
 
34
36
  In a browser, where the locale can change, hold a store instead of a view:
package/dist/index.d.mts CHANGED
@@ -58,6 +58,32 @@ declare namespace View {
58
58
  type Messages = {
59
59
  [key: string]: string;
60
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
+ interface Options {
74
+ /**
75
+ * Whether a substituted value is wrapped in Unicode bidi isolation marks,
76
+ * U+2068 and U+2069, so right-to-left text cannot reorder the sentence
77
+ * around it.
78
+ *
79
+ * `'none'` leaves values byte-for-byte as given, for output that is parsed
80
+ * after it is formatted: a Discord mention such as `<#123>` stops being
81
+ * one once the id inside it is isolated.
82
+ *
83
+ * @default 'default'
84
+ */
85
+ bidiIsolation?: 'default' | 'none';
86
+ }
61
87
  }
62
88
  /**
63
89
  * One locale, bound to the messages it formats against.
@@ -104,16 +130,22 @@ interface View<Locale extends string = string> {
104
130
  /** The messages this view formats against. */
105
131
  readonly messages: Readonly<View.Messages>;
106
132
  /**
107
- * Format the message a descriptor names.
133
+ * How this view formats its messages. Plain data, so it can cross a
134
+ * server/client boundary alongside {@link locale} and {@link messages}.
135
+ */
136
+ readonly options: Readonly<View.Options>;
137
+ /**
138
+ * Format the message a descriptor names, or the message it carries.
139
+ *
140
+ * A message with nothing to translate, a lone `say.date(x)` with no text
141
+ * around it, is never extracted: the transform writes the ICU into the call
142
+ * instead of an id.
108
143
  *
109
144
  * @param descriptor Descriptor to format
110
145
  * @returns The formatted message
111
146
  * @throws If the id has no message
112
147
  */
113
- call(descriptor: {
114
- id: string;
115
- [match: string | number]: unknown;
116
- }): string;
148
+ call(descriptor: View.Descriptor): string;
117
149
  /**
118
150
  * Define a pluralised message.
119
151
  *
@@ -239,9 +271,10 @@ interface View<Locale extends string = string> {
239
271
  *
240
272
  * @param locale The locale to bind to
241
273
  * @param messages The messages this view formats against
274
+ * @param options How this view formats its messages
242
275
  * @returns The view
243
276
  */
244
- declare function createView<Locale extends string>(locale: Locale, messages: View.Messages): View<Locale>;
277
+ declare function createView<Locale extends string>(locale: Locale, messages: View.Messages, options?: View.Options): View<Locale>;
245
278
  //#endregion
246
279
  //#region src/catalogue.d.ts
247
280
  declare namespace Catalogue {
@@ -268,13 +301,8 @@ declare namespace Catalogue {
268
301
  type Produced = View.Messages | {
269
302
  default: View.Messages;
270
303
  };
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>;
304
+ /** How every view the catalogue hands back formats its messages. */
305
+ type Options = View.Options;
278
306
  }
279
307
  /**
280
308
  * Everything an application knows how to say: its locales and where their
@@ -356,10 +384,14 @@ interface Catalogue<Locale extends string = string> {
356
384
  * const say = catalogue.locale('en');
357
385
  * ```
358
386
  *
359
- * @param messages Where each locale's messages come from, keyed by locale
387
+ * @param messages Where each locale's messages come from, keyed by locale. The
388
+ * keys are the catalogue's locales, in the order they are written, and the
389
+ * first of them is the default locale
390
+ * @param options How every view the catalogue hands back formats its
391
+ * messages, such as `{ bidiIsolation: 'none' }`
360
392
  * @returns The catalogue
361
393
  */
362
- declare function createCatalogue<const Locale extends string = string>(messages: Catalogue.Options<Locale>): Catalogue<Locale>;
394
+ declare function createCatalogue<const Locale extends string = string>(messages: Record<Locale, Catalogue.Source>, options?: Catalogue.Options): Catalogue<Locale>;
363
395
  //#endregion
364
396
  //#region src/store.d.ts
365
397
  declare namespace Store {
package/dist/index.mjs CHANGED
@@ -420,28 +420,33 @@ const functions = {
420
420
  };
421
421
  //#endregion
422
422
  //#region src/messageformat/index.ts
423
- function compile(locale, source) {
424
- return new MessageFormat(locale, toMessage(parse(source)), { functions });
423
+ function compile(locale, source, options) {
424
+ return new MessageFormat(locale, toMessage(parse(source)), {
425
+ ...options,
426
+ functions
427
+ });
425
428
  }
426
429
  //#endregion
427
430
  //#region src/view.ts
428
431
  function resolveDescriptorValues(descriptor) {
429
- return Object.fromEntries(Object.entries(descriptor).filter(([key]) => key !== "id").map(([key, value]) => [key.startsWith("_") ? key.slice(1) : key, value]));
432
+ return Object.fromEntries(Object.entries(descriptor).filter(([key]) => key !== "id" && key !== "message").map(([key, value]) => [key.startsWith("_") ? key.slice(1) : key, value]));
430
433
  }
431
434
  function macro(name) {
432
435
  throw new Error(`'say.${name}' is a macro and must be used with the relevant saykit plugin`);
433
436
  }
434
- function createView(locale, messages) {
437
+ function createView(locale, messages, options = {}) {
438
+ const { bidiIsolation } = options;
439
+ const ownOptions = Object.freeze(bidiIsolation ? { bidiIsolation } : {});
435
440
  const own = Object.freeze({ ...messages });
436
441
  const formats = /* @__PURE__ */ new Map();
437
442
  const say = (() => {
438
443
  throw new Error("'say' is a macro and must be used with the relevant saykit plugin");
439
444
  });
440
445
  function call(descriptor) {
441
- const message = own[descriptor.id];
442
- if (typeof message !== "string") throw new Error(`Message for ${descriptor.id} is not a string`);
443
- let format = formats.get(descriptor.id);
444
- if (!format) formats.set(descriptor.id, format = compile(locale, message));
446
+ const { id, message = own[id] } = descriptor;
447
+ if (typeof message !== "string") throw new Error(`Message for ${id} is not a string`);
448
+ let format = formats.get(message);
449
+ if (!format) formats.set(message, format = compile(locale, message, { bidiIsolation }));
445
450
  return String(format.format(resolveDescriptorValues(descriptor)));
446
451
  }
447
452
  return Object.freeze(Object.defineProperties(say, {
@@ -453,6 +458,10 @@ function createView(locale, messages) {
453
458
  value: own,
454
459
  enumerable: true
455
460
  },
461
+ options: {
462
+ value: ownOptions,
463
+ enumerable: true
464
+ },
456
465
  call: { value: call },
457
466
  plural: { value: () => macro("plural") },
458
467
  ordinal: { value: () => macro("ordinal") },
@@ -465,7 +474,8 @@ function createView(locale, messages) {
465
474
  }
466
475
  //#endregion
467
476
  //#region src/catalogue.ts
468
- function createCatalogue(messages) {
477
+ function createCatalogue(messages, options = {}) {
478
+ const { bidiIsolation } = options;
469
479
  const sources = Object.assign(Object.create(null), messages);
470
480
  const keys = Object.freeze(Object.keys(sources));
471
481
  if (keys.length === 0) throw new Error("A catalogue needs at least one locale, none were given");
@@ -484,7 +494,7 @@ function createCatalogue(messages) {
484
494
  const messages = store.get(locale);
485
495
  if (!messages) throw new Error(typeof sources[locale] === "function" ? `Messages for locale '${locale}' have not been loaded yet` : `No messages for locale '${locale}'`);
486
496
  let view = views.get(locale);
487
- if (!view) views.set(locale, view = createView(locale, messages));
497
+ if (!view) views.set(locale, view = createView(locale, messages, { bidiIsolation }));
488
498
  return view;
489
499
  },
490
500
  loaded(locale) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "saykit",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Type-safe i18n library with compile-time macro transforms",
5
5
  "keywords": [
6
6
  "i18n",