prosebook 0.2.0 → 0.3.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.
Files changed (112) hide show
  1. package/README.md +274 -29
  2. package/dist/bun.d.ts +34 -0
  3. package/dist/bun.d.ts.map +1 -0
  4. package/dist/bun.js +98 -0
  5. package/dist/bun.js.map +1 -0
  6. package/dist/bundle-DboItAre.js +111 -0
  7. package/dist/bundle-DboItAre.js.map +1 -0
  8. package/dist/bundle-KD-DiiFa.d.ts +6 -0
  9. package/dist/bundle-KD-DiiFa.d.ts.map +1 -0
  10. package/dist/cli.js +51 -56
  11. package/dist/cli.js.map +1 -1
  12. package/dist/compile-xF3F4oIp.d.ts +16 -0
  13. package/dist/compile-xF3F4oIp.d.ts.map +1 -0
  14. package/dist/{config-D--RITk2.js → config-BPSmoYel.js} +36 -212
  15. package/dist/config-BPSmoYel.js.map +1 -0
  16. package/dist/{config-_4HfnJQu.d.ts → config-DdMkKt_N.d.ts} +61 -2
  17. package/dist/config-DdMkKt_N.d.ts.map +1 -0
  18. package/dist/config.d.ts +2 -2
  19. package/dist/config.js +1 -1
  20. package/dist/{copy-DXnzXsJa.d.ts → copy-B3V_QRkT.d.ts} +20 -7
  21. package/dist/copy-B3V_QRkT.d.ts.map +1 -0
  22. package/dist/copy-Bo_FIoLy.js +154 -0
  23. package/dist/copy-Bo_FIoLy.js.map +1 -0
  24. package/dist/endpoints-CnUfCkIk.js +96 -0
  25. package/dist/endpoints-CnUfCkIk.js.map +1 -0
  26. package/dist/generate-9pR3i5Bj.js +5173 -0
  27. package/dist/generate-9pR3i5Bj.js.map +1 -0
  28. package/dist/helpers-BWGcD0kS.js +96 -0
  29. package/dist/helpers-BWGcD0kS.js.map +1 -0
  30. package/dist/icu-Dej0mNsF.js +218 -0
  31. package/dist/icu-Dej0mNsF.js.map +1 -0
  32. package/dist/index.d.ts +12 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +4 -188
  35. package/dist/inspector.d.ts +4 -1
  36. package/dist/inspector.d.ts.map +1 -1
  37. package/dist/inspector.js +7 -2
  38. package/dist/inspector.js.map +1 -1
  39. package/dist/locale-DpQA1pqH.js +70 -0
  40. package/dist/locale-DpQA1pqH.js.map +1 -0
  41. package/dist/next-loader.d.ts +11 -0
  42. package/dist/next-loader.d.ts.map +1 -0
  43. package/dist/next-loader.js +17 -0
  44. package/dist/next-loader.js.map +1 -0
  45. package/dist/next.d.ts +30 -0
  46. package/dist/next.d.ts.map +1 -0
  47. package/dist/next.js +160 -0
  48. package/dist/next.js.map +1 -0
  49. package/dist/react/index.d.ts +3 -3
  50. package/dist/react/index.d.ts.map +1 -1
  51. package/dist/react/index.js.map +1 -1
  52. package/dist/react/server.d.ts +20 -0
  53. package/dist/react/server.d.ts.map +1 -0
  54. package/dist/react/server.js +34 -0
  55. package/dist/react/server.js.map +1 -0
  56. package/dist/{sarif-CI8HOokm.js → sarif-CWyTAQ0y.js} +103 -87
  57. package/dist/sarif-CWyTAQ0y.js.map +1 -0
  58. package/dist/scan-Bx0_yDSi.js +387 -0
  59. package/dist/scan-Bx0_yDSi.js.map +1 -0
  60. package/dist/tools.d.ts +83 -90
  61. package/dist/tools.d.ts.map +1 -1
  62. package/dist/tools.js +5 -4
  63. package/dist/vite.d.ts +3 -2
  64. package/dist/vite.d.ts.map +1 -1
  65. package/dist/vite.js +29 -109
  66. package/dist/vite.js.map +1 -1
  67. package/package.json +37 -4
  68. package/src/bun.ts +132 -0
  69. package/src/cli.ts +60 -58
  70. package/src/index.ts +1 -1
  71. package/src/inspector.ts +8 -3
  72. package/src/next-loader.ts +16 -0
  73. package/src/next.ts +188 -0
  74. package/src/react/index.tsx +6 -6
  75. package/src/react/server.ts +37 -0
  76. package/src/runtime/copy.ts +22 -3
  77. package/src/runtime/helpers.ts +54 -18
  78. package/src/runtime/locale.ts +19 -0
  79. package/src/runtime/types.ts +21 -6
  80. package/src/tools/bundle.ts +131 -0
  81. package/src/tools/catalog.ts +2 -2
  82. package/src/tools/compile.ts +60 -23
  83. package/src/tools/config.ts +39 -0
  84. package/src/tools/endpoints.ts +121 -0
  85. package/src/tools/entry.ts +77 -0
  86. package/src/tools/generate.ts +2 -0
  87. package/src/tools/icu.ts +63 -7
  88. package/src/tools/index.ts +6 -3
  89. package/src/tools/lock.ts +7 -0
  90. package/src/tools/mf1.ts +21 -7
  91. package/src/tools/mf2.ts +249 -57
  92. package/src/tools/migrate.ts +3 -2
  93. package/src/tools/native/emit.ts +87 -0
  94. package/src/tools/native/index.ts +36 -0
  95. package/src/tools/native/kotlin.ts +269 -0
  96. package/src/tools/native/model.ts +280 -0
  97. package/src/tools/native/number.ts +210 -0
  98. package/src/tools/native/plurals.ts +103 -0
  99. package/src/tools/native/rust.ts +367 -0
  100. package/src/tools/native/swift.ts +326 -0
  101. package/src/tools/syntax.ts +3 -3
  102. package/src/tools/todo.ts +118 -0
  103. package/src/vite.ts +28 -114
  104. package/dist/config-D--RITk2.js.map +0 -1
  105. package/dist/config-_4HfnJQu.d.ts.map +0 -1
  106. package/dist/copy-DXnzXsJa.d.ts.map +0 -1
  107. package/dist/helpers-BPuPGjQP.js +0 -61
  108. package/dist/helpers-BPuPGjQP.js.map +0 -1
  109. package/dist/index.js.map +0 -1
  110. package/dist/sarif-CI8HOokm.js.map +0 -1
  111. package/dist/scan-Ch2BfIUD.js +0 -2561
  112. package/dist/scan-Ch2BfIUD.js.map +0 -1
package/src/tools/mf2.ts CHANGED
@@ -5,8 +5,10 @@
5
5
  *
6
6
  * The tree holds what a message does, not how it was written. `.input {$n :number}` becomes a number
7
7
  * element at each `{$n}`, and `.match` becomes plural and select elements, one level per selector,
8
- * built so the runtime's ICU-style selection picks the variant MF2's would (see `matchTree`). Option
9
- * values are literals, resolved here into `Intl` options, so the runtime is the same for both.
8
+ * built so the runtime's ICU-style selection picks the variant MF2's would (see `matchTree`). Literal
9
+ * option values are resolved here into `Intl` options, so the runtime is the same for both. An option
10
+ * an argument gives (`currency=$code`, of those in `FROM_ARGUMENT`) rides on the style as the `Intl`
11
+ * option it sets, which the compiled message passes beside the hoisted ones.
10
12
  */
11
13
  import { SKELETON_TYPE, TYPE } from '@formatjs/icu-messageformat-parser';
12
14
  import type {
@@ -21,7 +23,7 @@ import type {
21
23
  } from '@formatjs/icu-messageformat-parser';
22
24
  import { MessageSyntaxError, parseMessage, stringifyMessage, validate, type Model } from 'messageformat';
23
25
 
24
- import { walk, type MessageFormatElement } from './icu.ts';
26
+ import { selectionOf, walk, type MessageFormatElement, type OptionArgs, type Placeholder, type Selection } from './icu.ts';
25
27
 
26
28
  type Element = MessageFormatElement;
27
29
 
@@ -29,6 +31,10 @@ type Element = MessageFormatElement;
29
31
  export interface Mf2Function {
30
32
  name: string;
31
33
  options: Record<string, string>;
34
+ /** Options given by an argument, as `currency=$code`: the option's name to the argument's. */
35
+ args?: Record<string, string>;
36
+ /** `u:dir`: the direction the value is written in, which picks the mark isolating it. */
37
+ dir?: 'ltr' | 'rtl' | 'auto';
32
38
  }
33
39
 
34
40
  type Value = { kind: 'literal'; value: string; fn?: Mf2Function } | { kind: 'variable'; name: string; fn?: Mf2Function; offset: number };
@@ -39,8 +45,23 @@ interface Selector {
39
45
  name: string;
40
46
  kind: 'plural' | 'ordinal' | 'exact' | 'string';
41
47
  offset: number;
48
+ /** For a number selected on other than as a bare `:number`. */
49
+ selection?: Selection;
42
50
  }
43
51
 
52
+ /** The `Intl.NumberFormat` options `Intl.PluralRules` takes too, which change a number's category. */
53
+ const PLURAL_OPTIONS = new Set([
54
+ 'minimumIntegerDigits',
55
+ 'minimumFractionDigits',
56
+ 'maximumFractionDigits',
57
+ 'minimumSignificantDigits',
58
+ 'maximumSignificantDigits',
59
+ 'roundingIncrement',
60
+ 'roundingMode',
61
+ 'roundingPriority',
62
+ 'trailingZeroDisplay',
63
+ ]);
64
+
44
65
  export const NUMBER_LITERAL = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][-+]?\d+)?$/;
45
66
  const CATEGORIES = new Set(['zero', 'one', 'two', 'few', 'many', 'other']);
46
67
  const NAME = /^[\p{L}_][\p{L}\p{N}_.-]*$/u;
@@ -96,6 +117,26 @@ const ENUMS: Record<string, readonly string[]> = {
96
117
  hour12: ['true', 'false'],
97
118
  };
98
119
 
120
+ const oneOf = (values: readonly string[]) => values.map((v) => JSON.stringify(v)).join(' | ');
121
+
122
+ /**
123
+ * Options an argument may give, with the `Intl` options each sets and the argument's type. These pass
124
+ * to `Intl` as they are, so the runtime merges them in; the rest change what the formatter is (a
125
+ * date's fields, how a number selects) and are settled when the message compiles.
126
+ */
127
+ const FROM_ARGUMENT: Record<string, { intl: readonly string[]; type: string }> = {
128
+ currency: { intl: ['currency'], type: 'string' },
129
+ currencyDisplay: { intl: ['currencyDisplay'], type: oneOf(ENUMS.currencyDisplay!) },
130
+ currencySign: { intl: ['currencySign'], type: oneOf(['standard', 'accounting']) },
131
+ unit: { intl: ['unit'], type: 'string' },
132
+ unitDisplay: { intl: ['unitDisplay'], type: oneOf(['long', 'short', 'narrow']) },
133
+ signDisplay: { intl: ['signDisplay'], type: oneOf(['auto', 'always', 'exceptZero', 'negative', 'never']) },
134
+ fractionDigits: { intl: ['minimumFractionDigits', 'maximumFractionDigits'], type: 'number' },
135
+ ...Object.fromEntries(DIGITS.filter((d) => d !== 'roundingIncrement').map((d) => [d, { intl: [d], type: 'number' }])),
136
+ timeZone: { intl: ['timeZone'], type: 'string' },
137
+ calendar: { intl: ['calendar'], type: 'string' },
138
+ };
139
+
99
140
  /** Options an operand's function doesn't pass on to these, per the spec. */
100
141
  const DISCARDED: Record<string, readonly string[]> = {
101
142
  integer: ['minimumFractionDigits', 'maximumFractionDigits', 'minimumSignificantDigits'],
@@ -144,7 +185,8 @@ function syntaxProblem(text: string, e: unknown): string {
144
185
  }
145
186
 
146
187
  /** An MF2 message as the element tree. Throws, with the fix, where it can't be read or compiled. */
147
- export function parseMf2(text: string, locale: string): Element[] {
188
+ export function parseMf2(text: string, locale: string, isolate: Isolate = 'rtl'): Element[] {
189
+ const reading: Reading = { locale, isolate, dir: directionOf(locale) };
148
190
  let message: Model.Message;
149
191
  try {
150
192
  message = parseMessage(text);
@@ -156,42 +198,93 @@ export function parseMf2(text: string, locale: string): Element[] {
156
198
  }
157
199
  const scope: Scope = new Map();
158
200
  for (const declaration of message.declarations)
159
- // `.input` reads the argument itself; a `.local` can build on what is declared before it.
160
- scope.set(declaration.name, resolve(declaration.value, declaration.type === 'input' ? new Map() : scope));
201
+ // `.input` reads the argument itself; a `.local` can build on what is declared before it. The
202
+ // options of either can name what is declared before them.
203
+ scope.set(declaration.name, resolve(declaration.value, declaration.type === 'input' ? new Map() : scope, scope));
161
204
 
162
- if (message.type === 'message') return pattern(message.pattern, scope, locale);
205
+ if (message.type === 'message') return pattern(message.pattern, scope, reading);
163
206
  const selectors = message.selectors.map((s) => selectorOf(s.name, scope));
164
207
  const candidates = message.variants.map((variant) => {
165
208
  const keys = variant.keys.map((k) => (k.type === '*' ? null : k.value));
166
209
  keys.forEach((key, i) => checkKey(selectors[i]!, key, message.selectors[i]!.name));
167
- return { keys, prefs: [], value: pattern(variant.value, scope, locale) };
210
+ return { keys, prefs: [], value: pattern(variant.value, scope, reading) };
168
211
  });
169
212
  return matchTree(selectors, candidates, 0, locale);
170
213
  }
171
214
 
172
- function functionOf(ref: Model.FunctionRef): Mf2Function {
215
+ /**
216
+ * When a placeholder is isolated from the text around it, so a value written the other way can't
217
+ * reorder it: `'rtl'` in a locale not known to run left to right, or where `u:dir` asks; `'always'`
218
+ * also for every value of unknown direction (a string), as the spec's default strategy has it.
219
+ */
220
+ export type Isolate = 'rtl' | 'always' | false;
221
+
222
+ type Direction = 'ltr' | 'rtl' | 'auto';
223
+
224
+ interface Reading {
225
+ locale: string;
226
+ isolate: Isolate;
227
+ /** The locale's direction, which the message's is. */
228
+ dir: Direction;
229
+ }
230
+
231
+ // The scripts CLDR writes right to left, as the `messageformat` package lists them.
232
+ const RTL_SCRIPTS = new Set(['Adlm', 'Arab', 'Hebr', 'Mand', 'Nkoo', 'Rohg', 'Syrc', 'Thaa']);
233
+ const [LRI, RLI, FSI, PDI] = ['\u2066', '\u2067', '\u2068', '\u2069'];
234
+
235
+ function directionOf(locale: string): Direction {
236
+ try {
237
+ const script = new Intl.Locale(locale).maximize().script;
238
+ return script === undefined ? 'auto' : RTL_SCRIPTS.has(script) ? 'rtl' : 'ltr';
239
+ } catch {
240
+ return 'auto';
241
+ }
242
+ }
243
+
244
+ /** The fallback a placeholder shows, and the marks isolating it where `isolate` and `u:dir` ask. */
245
+ function placeholderFor(name: string, fn: Mf2Function | undefined, r: Reading): Placeholder {
246
+ const fallback = `{$${name}}`;
247
+ if (r.isolate === false) return { fallback };
248
+ // A number or date is written as the locale writes; a string's direction is its own.
249
+ const dir = fn?.dir ?? (fn === undefined || fn.name === 'string' ? 'auto' : r.dir);
250
+ const isolate = r.dir !== 'ltr' || (dir !== 'ltr' && (r.isolate === 'always' || fn?.dir !== undefined));
251
+ if (!isolate) return { fallback };
252
+ return { fallback, isolate: [dir === 'ltr' ? LRI : dir === 'rtl' ? RLI : FSI, PDI] };
253
+ }
254
+
255
+ function functionOf(ref: Model.FunctionRef, scope: Scope): Mf2Function {
173
256
  const allowed = OPTIONS[ref.name];
174
257
  if (allowed === undefined) {
175
258
  const known = Object.keys(OPTIONS).map((n) => `:${n}`);
176
259
  throw new Error(`:${ref.name} is not a function prosebook knows: ${known.join(', ')}`);
177
260
  }
178
261
  const options: Record<string, string> = {};
262
+ const args: Record<string, string> = {};
263
+ let dir: Mf2Function['dir'];
179
264
  for (const [key, value] of Object.entries(ref.options ?? {})) {
180
- // `u:id` and `u:dir` say nothing about formatting here: there is no bidi isolation to steer.
181
- if (key === 'u:id' || key === 'u:dir') continue;
182
- if (value.type === 'variable')
183
- throw new Error(`:${ref.name} ${key}=$${value.name}: option values are literals here, since formatting is fixed at build time`);
265
+ // `u:id` names a part of the formatted output, which this runtime doesn't hand out.
266
+ if (key === 'u:id') continue;
267
+ if (key === 'u:dir') {
268
+ const dirs = ['ltr', 'rtl', 'auto', 'inherit'];
269
+ if (value.type !== 'literal' || !dirs.includes(value.value))
270
+ throw new Error(`:${ref.name} u:dir is one of ${dirs.join(', ')}, written in the message`);
271
+ // `inherit` is the direction it would have had anyway.
272
+ if (value.value !== 'inherit') dir = value.value as Mf2Function['dir'];
273
+ continue;
274
+ }
184
275
  if (!allowed.includes(key))
185
276
  throw new Error(`:${ref.name} has no option "${key}"${allowed.length > 0 ? `: ${allowed.join(', ')}` : ''}`);
277
+ const literal = value.type === 'literal' ? value.value : optionArg(ref.name, key, value.name, scope);
278
+ if (typeof literal !== 'string') {
279
+ args[key] = literal.arg;
280
+ continue;
281
+ }
186
282
  const values = ENUMS[key];
187
- if (values !== undefined && !values.includes(value.value))
188
- throw new Error(`:${ref.name} ${key}=${value.value} is not a value it takes: ${values.join(', ')}`);
189
- if (DIGITS.includes(key) && !/^\d+$/.test(value.value)) throw new Error(`:${ref.name} ${key}=${value.value} is not a whole number`);
190
- options[key] = value.value;
191
- }
192
- if (ref.name === 'currency' && options.currency === undefined)
193
- throw new Error(':currency needs its currency named: {$price :currency currency=EUR}');
194
- if (ref.name === 'unit' && options.unit === undefined) throw new Error(':unit needs its unit named: {$distance :unit unit=kilometer}');
283
+ if (values !== undefined && !values.includes(literal))
284
+ throw new Error(`:${ref.name} ${key}=${literal} is not a value it takes: ${values.join(', ')}`);
285
+ if (DIGITS.includes(key) && !/^\d+$/.test(literal)) throw new Error(`:${ref.name} ${key}=${literal} is not a whole number`);
286
+ options[key] = literal;
287
+ }
195
288
  if (ref.name === 'unit' && ref.options?.usage !== undefined)
196
289
  throw new Error(":unit usage converts between units, which `Intl` can't do: convert the value before passing it");
197
290
  if (options.timeZone === 'input') throw new Error(`:${ref.name} timeZone=input needs a zoned value, which a JavaScript Date isn't`);
@@ -200,14 +293,48 @@ function functionOf(ref: Model.FunctionRef): Mf2Function {
200
293
  if (given.length !== 1 || !/^\d+$/.test(options[given[0]!]!))
201
294
  throw new Error(':offset takes one of add or subtract, as a whole number: {$count :offset subtract=1}');
202
295
  }
203
- return { name: ref.name, options };
296
+ return { name: ref.name, options, ...(Object.keys(args).length > 0 && { args }), ...(dir && { dir }) };
204
297
  }
205
298
 
206
- function resolve(expr: Model.Expression, scope: Scope): Value {
207
- const fn = expr.functionRef === undefined ? undefined : functionOf(expr.functionRef);
299
+ /**
300
+ * The argument an option names, or the literal a `.local` it names holds. A variable declared with a
301
+ * function of its own is refused: what it would give the option is its formatted text.
302
+ */
303
+ function optionArg(fn: string, key: string, name: string, scope: Scope): string | { arg: string } {
304
+ const bound = scope.get(name);
305
+ const plain = bound?.fn === undefined || bound.fn.name === 'string';
306
+ if (bound?.kind === 'literal' && plain) return bound.value;
307
+ if (FROM_ARGUMENT[key] === undefined) {
308
+ const which = Object.keys(FROM_ARGUMENT).filter((k) => OPTIONS[fn]!.includes(k));
309
+ const can = which.length > 0 ? `: of :${fn}'s, only ${which.join(', ')} can` : '';
310
+ throw new Error(`:${fn} ${key}=$${name}: ${key} is settled as the message compiles, so write its value in the message${can}`);
311
+ }
312
+ if (bound === undefined) return { arg: name };
313
+ if (bound.kind === 'variable' && plain && bound.offset === 0) return { arg: bound.name };
314
+ throw new Error(`:${fn} ${key}=$${name}: $${name} is formatted, so it can't give an option: name the argument itself`);
315
+ }
316
+
317
+ function resolve(expr: Model.Expression, scope: Scope, optionScope = scope): Value {
318
+ const fn = expr.functionRef === undefined ? undefined : functionOf(expr.functionRef, optionScope);
208
319
  if (expr.arg === undefined) throw new Error(`{:${fn!.name}} has nothing to format: give it a value, {$value :${fn!.name}}`);
209
- if (expr.arg.type === 'literal') return apply({ kind: 'literal', value: expr.arg.value }, fn);
210
- return apply(scope.get(expr.arg.name) ?? { kind: 'variable', name: expr.arg.name, offset: 0 }, fn);
320
+ const base: Value =
321
+ expr.arg.type === 'literal'
322
+ ? { kind: 'literal', value: expr.arg.value }
323
+ : (scope.get(expr.arg.name) ?? { kind: 'variable', name: expr.arg.name, offset: 0 });
324
+ const value = apply(base, fn);
325
+ if (value.fn !== undefined) checkNamed(value.fn);
326
+ return value;
327
+ }
328
+
329
+ /**
330
+ * A currency or a unit named, on the function or on the one its operand was declared with: after
331
+ * `.input {$price :currency currency=EUR}`, `{$price :currency fractionDigits=0}` is still in euros.
332
+ */
333
+ function checkNamed(fn: Mf2Function): void {
334
+ const given = (key: string) => fn.options[key] !== undefined || fn.args?.[key] !== undefined;
335
+ if (fn.name === 'currency' && !given('currency'))
336
+ throw new Error(':currency needs its currency named: {$price :currency currency=EUR}, or currency=$code to take it from an argument');
337
+ if (fn.name === 'unit' && !given('unit')) throw new Error(':unit needs its unit named: {$distance :unit unit=kilometer}');
211
338
  }
212
339
 
213
340
  /** `fn` applied to a value that may already carry one: options carry over within a family, as the spec has it. */
@@ -221,8 +348,17 @@ function apply(base: Value, fn: Mf2Function | undefined): Value {
221
348
  }
222
349
  if (base.fn === undefined || family(base.fn.name) !== family(fn.name)) return { ...base, fn };
223
350
  const inherited = { ...base.fn.options };
224
- for (const key of DISCARDED[fn.name] ?? []) delete inherited[key];
225
- return { ...base, fn: { name: fn.name, options: { ...inherited, ...fn.options } } };
351
+ const inheritedArgs = { ...base.fn.args };
352
+ for (const key of DISCARDED[fn.name] ?? []) {
353
+ delete inherited[key];
354
+ delete inheritedArgs[key];
355
+ }
356
+ // An option given again replaces the one inherited, whether either is a literal or an argument.
357
+ for (const key of Object.keys(fn.options)) delete inheritedArgs[key];
358
+ for (const key of Object.keys(fn.args ?? {})) delete inherited[key];
359
+ const args = { ...inheritedArgs, ...fn.args };
360
+ const options = { ...inherited, ...fn.options };
361
+ return { ...base, fn: { name: fn.name, options, ...(Object.keys(args).length > 0 && { args }), ...(fn.dir && { dir: fn.dir }) } };
226
362
  }
227
363
 
228
364
  function literalNumber(value: string): string {
@@ -238,11 +374,27 @@ function selectorOf(name: string, scope: Scope): Selector {
238
374
  throw new Error(`.match $${name} selects on a literal, which always picks the same variant: use that variant's text`);
239
375
  const { fn } = value;
240
376
  if (fn.name === 'string') return { name: value.name, kind: 'string', offset: 0 };
241
- if (fn.name === 'number' || fn.name === 'integer') {
377
+ if (fn.name === 'number' || fn.name === 'integer' || fn.name === 'percent') {
242
378
  const kind = (fn.options.select ?? 'plural') as Selector['kind'];
243
- return { name: value.name, kind, offset: value.offset };
379
+ return { name: value.name, kind, offset: value.offset, ...selectionWith(fn) };
244
380
  }
245
- throw new Error(`.match $${name} selects with :${fn.name}, which prosebook can't select on: use :number, :integer or :string`);
381
+ throw new Error(`.match $${name} selects with :${fn.name}, which prosebook can't select on: use :number, :integer, :percent or :string`);
382
+ }
383
+
384
+ /**
385
+ * What a number is selected with beyond its kind, as the spec has it: its category as formatted
386
+ * (`1.0` is `other` in English), `:integer` on the value rounded, `:percent` on it times 100. None
387
+ * for a bare `:number`, which selects as MF1 does.
388
+ */
389
+ function selectionWith(fn: Mf2Function): { selection?: Selection } {
390
+ const pick = <T>(from: Record<string, T>) => Object.fromEntries(Object.entries(from).filter(([k]) => PLURAL_OPTIONS.has(k)));
391
+ const options = pick(intlNumber(fn) as Record<string, unknown>) as Intl.PluralRulesOptions;
392
+ const args = pick(optionArgs(fn).args ?? {});
393
+ const value = fn.name === 'integer' ? 'round' : fn.name === 'percent' ? 'percent' : undefined;
394
+ const plain = Object.keys(options).length === 0 && Object.keys(args).length === 0 && value === undefined;
395
+ // `select=exact` is kept too, so it is written back.
396
+ if (plain && fn.options.select !== 'exact') return {};
397
+ return { selection: { fn, options, ...(Object.keys(args).length > 0 && { args }), ...(value && { value }) } };
246
398
  }
247
399
 
248
400
  function checkKey(selector: Selector, key: string | null, name: string): void {
@@ -295,7 +447,7 @@ function matchTree(selectors: Selector[], candidates: Candidate[], i: number, lo
295
447
  arm('other', (key) => (key === null ? 1 : undefined));
296
448
  return [{ type: TYPE.select, value: s.name, options }];
297
449
  }
298
- const rules = new Intl.PluralRules(locale, { type: s.kind === 'ordinal' ? 'ordinal' : 'cardinal' });
450
+ const rules = new Intl.PluralRules(locale, { ...s.selection?.options, type: s.kind === 'ordinal' ? 'ordinal' : 'cardinal' });
299
451
  for (const k of keys.filter((key) => NUMBER_LITERAL.test(key))) {
300
452
  const category = s.kind === 'exact' ? undefined : rules.select(Number(k));
301
453
  // The runtime compares the value before its offset; the key is the value after it.
@@ -308,7 +460,8 @@ function matchTree(selectors: Selector[], candidates: Candidate[], i: number, lo
308
460
  for (const c of categories) arm(c, (key) => (key === c ? 1 : key === null ? 2 : undefined));
309
461
  }
310
462
  if (!('other' in options)) arm('other', (key) => (key === null ? 2 : undefined));
311
- return [{ type: TYPE.plural, value: s.name, offset: s.offset, pluralType: s.kind === 'ordinal' ? 'ordinal' : 'cardinal', options }];
463
+ const pluralType = s.kind === 'ordinal' ? 'ordinal' : 'cardinal';
464
+ return [{ type: TYPE.plural, value: s.name, offset: s.offset, pluralType, options, ...(s.selection && { selection: s.selection }) }];
312
465
  }
313
466
 
314
467
  function compare(a: number[], b: number[]): number {
@@ -316,7 +469,7 @@ function compare(a: number[], b: number[]): number {
316
469
  return 0;
317
470
  }
318
471
 
319
- function pattern(parts: Model.Pattern, scope: Scope, locale: string): Element[] {
472
+ function pattern(parts: Model.Pattern, scope: Scope, r: Reading): Element[] {
320
473
  const root: Element[] = [];
321
474
  const open: { name: string; children: Element[] }[] = [];
322
475
  const push = (el: Element) => {
@@ -329,7 +482,7 @@ function pattern(parts: Model.Pattern, scope: Scope, locale: string): Element[]
329
482
  if (typeof part === 'string') {
330
483
  push({ type: TYPE.literal, value: part });
331
484
  } else if (part.type === 'expression') {
332
- push(element(resolve(part, scope), locale));
485
+ push(element(resolve(part, scope), r));
333
486
  } else if (part.kind === 'close') {
334
487
  const top = open.pop();
335
488
  if (top?.name !== part.name) {
@@ -352,36 +505,55 @@ function pattern(parts: Model.Pattern, scope: Scope, locale: string): Element[]
352
505
  return root;
353
506
  }
354
507
 
355
- function element(value: Value, locale: string): Element {
356
- if (value.kind === 'literal') return { type: TYPE.literal, value: formatLiteral(value, locale) };
508
+ function element(value: Value, r: Reading): Element {
509
+ if (value.kind === 'literal') return { type: TYPE.literal, value: formatLiteral(value, r.locale) };
357
510
  const { name, fn, offset } = value;
511
+ const placeholder = placeholderFor(name, fn, r);
358
512
  // A value less its offset is what `#` prints inside a plural, so a plural with only `other` prints it.
359
- if (offset !== 0)
360
- return { type: TYPE.plural, value: name, offset, pluralType: 'cardinal', options: { other: { value: [{ type: TYPE.pound }] } } };
361
- if (fn === undefined || fn.name === 'string') return { type: TYPE.argument, value: name };
362
- if (NUMERIC.has(fn.name)) return { type: TYPE.number, value: name, style: numberStyle(fn) };
363
- return { type: TYPE.date, value: name, style: dateStyle(fn) };
513
+ if (offset !== 0) {
514
+ const pound = { type: TYPE.pound, placeholder } as Element;
515
+ return { type: TYPE.plural, value: name, offset, pluralType: 'cardinal', options: { other: { value: [pound] } } };
516
+ }
517
+ if (fn === undefined || fn.name === 'string')
518
+ return { type: TYPE.argument, value: name, placeholder, ...(fn?.dir && { dir: fn.dir }) } as Element;
519
+ if (NUMERIC.has(fn.name)) return { type: TYPE.number, value: name, style: numberStyle(fn), placeholder } as Element;
520
+ return { type: TYPE.date, value: name, style: dateStyle(fn), placeholder } as Element;
364
521
  }
365
522
 
366
523
  /** A literal with a function is the same in every render, so it is formatted now. */
367
524
  function formatLiteral(value: Extract<Value, { kind: 'literal' }>, locale: string): string {
368
525
  const { fn } = value;
369
526
  if (fn === undefined || fn.name === 'string') return value.value;
527
+ const [option, arg] = Object.entries(fn.args ?? {})[0] ?? [];
528
+ if (option !== undefined) {
529
+ const why = 'a literal is formatted as the message compiles, so its options are literals too';
530
+ throw new Error(`|${value.value}| :${fn.name} ${option}=$${arg}: ${why}`);
531
+ }
370
532
  if (NUMERIC.has(fn.name)) return new Intl.NumberFormat(locale, intlNumber(fn)).format(Number(literalNumber(value.value)));
371
533
  const date = new Date(value.value);
372
534
  if (Number.isNaN(date.getTime())) throw new Error(`|${value.value}| is not a date: write it as an ISO 8601 date, like 2026-01-05`);
373
535
  return new Intl.DateTimeFormat(locale, intlDate(fn)).format(date);
374
536
  }
375
537
 
376
- export type Mf2NumberSkeleton = NumberSkeleton & { mf2: Mf2Function };
377
- export type Mf2DateSkeleton = DateTimeSkeleton & { mf2: Mf2Function };
538
+ export type Mf2NumberSkeleton = NumberSkeleton & { mf2: Mf2Function; args?: OptionArgs };
539
+ export type Mf2DateSkeleton = DateTimeSkeleton & { mf2: Mf2Function; args?: OptionArgs };
378
540
 
379
541
  function numberStyle(fn: Mf2Function): Mf2NumberSkeleton {
380
- return { type: SKELETON_TYPE.number, tokens: [], parsedOptions: intlNumber(fn) as NumberSkeleton['parsedOptions'], mf2: fn };
542
+ const parsedOptions = intlNumber(fn) as NumberSkeleton['parsedOptions'];
543
+ return { type: SKELETON_TYPE.number, tokens: [], parsedOptions, mf2: fn, ...optionArgs(fn) };
381
544
  }
382
545
 
383
546
  function dateStyle(fn: Mf2Function): Mf2DateSkeleton {
384
- return { type: SKELETON_TYPE.dateTime, pattern: '', parsedOptions: intlDate(fn), mf2: fn };
547
+ return { type: SKELETON_TYPE.dateTime, pattern: '', parsedOptions: intlDate(fn), mf2: fn, ...optionArgs(fn) };
548
+ }
549
+
550
+ /** The function's options given by arguments, as the `Intl` options the runtime merges in. */
551
+ function optionArgs(fn: Mf2Function): { args?: OptionArgs } {
552
+ if (fn.args === undefined) return {};
553
+ const args: OptionArgs = {};
554
+ for (const [key, arg] of Object.entries(fn.args))
555
+ for (const intl of FROM_ARGUMENT[key]!.intl) args[intl] = { arg, type: FROM_ARGUMENT[key]!.type };
556
+ return { args };
385
557
  }
386
558
 
387
559
  /** The `Intl.NumberFormat` options a numeric function formats with. */
@@ -445,6 +617,8 @@ interface Found {
445
617
  offset: number;
446
618
  /** MF2 keys, in order of appearance; `*` is implied. */
447
619
  keys: string[];
620
+ /** The function it was selected with, read from MF2; a bare `:number` where none is kept. */
621
+ fn?: Mf2Function;
448
622
  }
449
623
 
450
624
  /** A message tree as MF2 text. Throws, saying what has no MF2 equivalent, where it can't be written. */
@@ -458,7 +632,11 @@ export function toMf2(ast: Element[], locale: string): string {
458
632
  const declarations: Model.Declaration[] = [];
459
633
  for (const s of found) {
460
634
  const fn: Model.FunctionRef =
461
- s.kind === 'string' ? functionRef('string') : functionRef('number', s.kind === 'ordinal' ? { select: 'ordinal' } : {});
635
+ s.kind === 'string'
636
+ ? functionRef('string')
637
+ : s.fn !== undefined
638
+ ? written(s.fn)
639
+ : functionRef('number', s.kind === 'ordinal' ? { select: 'ordinal' } : {});
462
640
  declarations.push({ type: 'input', name: s.arg, value: { type: 'expression', arg: variable(s.arg), functionRef: fn } });
463
641
  if (s.offset !== 0) {
464
642
  declarations.push({
@@ -473,7 +651,8 @@ export function toMf2(ast: Element[], locale: string): string {
473
651
  let combos: (string | null)[][] = [[]];
474
652
  for (const s of found) combos = combos.flatMap((c) => [...s.keys, null].map((k) => [...c, k]));
475
653
  const variables = new Map(found.map((s) => [s.arg, s.variable]));
476
- const numeric = new Set(found.filter((s) => s.kind !== 'string').map((s) => s.arg));
654
+ // Under a selector declared with more than a bare `:number`, a placeholder spells out its own.
655
+ const numeric = new Set(found.filter((s) => s.kind !== 'string' && s.fn === undefined).map((s) => s.arg));
477
656
  const render = (keys: (string | null)[]) =>
478
657
  toPattern(ast, { choice: new Map(found.map((s, i) => [s.arg, keys[i]!])), variables, numeric, locale });
479
658
  const variants = combos.map((keys) => ({ keys, pattern: render(keys), text: '' }));
@@ -506,7 +685,8 @@ function selectorsOf(ast: Element[]): Found[] {
506
685
  checkName(el.value);
507
686
  let local = `${el.value}_minus_${offset}`;
508
687
  while (names.has(local)) local += '_';
509
- s = { arg: el.value, variable: offset === 0 ? el.value : local, kind, offset, keys: [] };
688
+ const fn = selectionOf(el)?.fn;
689
+ s = { arg: el.value, variable: offset === 0 ? el.value : local, kind, offset, keys: [], ...(fn && { fn }) };
510
690
  found.set(el.value, s);
511
691
  } else if (s.kind !== kind || s.offset !== offset) {
512
692
  throw new Error(
@@ -578,10 +758,16 @@ function toPattern(elements: Element[], w: Writing, pound?: string): Model.Patte
578
758
  case TYPE.literal:
579
759
  text(el.value);
580
760
  break;
581
- case TYPE.argument:
761
+ case TYPE.argument: {
582
762
  checkName(el.value);
583
- out.push({ type: 'expression', arg: variable(el.value) });
763
+ const dir = (el as { dir?: Mf2Function['dir'] }).dir;
764
+ out.push({
765
+ type: 'expression',
766
+ arg: variable(el.value),
767
+ ...(dir && { functionRef: written({ name: 'string', options: {}, dir }) }),
768
+ });
584
769
  break;
770
+ }
585
771
  case TYPE.number: {
586
772
  checkName(el.value);
587
773
  const fn = numberFunction(el);
@@ -643,10 +829,17 @@ function variable(name: string): Model.VariableRef {
643
829
  return { type: 'variable', name };
644
830
  }
645
831
 
646
- function functionRef(name: string, options: Record<string, string> = {}): Model.FunctionRef {
647
- const entries = Object.entries(options);
832
+ function functionRef(name: string, options: Record<string, string> = {}, args: Record<string, string> = {}): Model.FunctionRef {
833
+ const entries: [string, Model.Literal | Model.VariableRef][] = [
834
+ ...Object.entries(options).map(([k, v]): [string, Model.Literal] => [k, { type: 'literal', value: v }]),
835
+ ...Object.entries(args).map(([k, v]): [string, Model.VariableRef] => [k, variable(v)]),
836
+ ];
648
837
  if (entries.length === 0) return { type: 'function', name };
649
- return { type: 'function', name, options: Object.fromEntries(entries.map(([k, v]) => [k, { type: 'literal', value: v }])) };
838
+ return { type: 'function', name, options: Object.fromEntries(entries) };
839
+ }
840
+
841
+ function written(fn: Mf2Function): Model.FunctionRef {
842
+ return functionRef(fn.name, { ...fn.options, ...(fn.dir && { 'u:dir': fn.dir }) }, fn.args);
650
843
  }
651
844
 
652
845
  function numberFunction(el: NumberElement): Model.FunctionRef {
@@ -656,7 +849,7 @@ function numberFunction(el: NumberElement): Model.FunctionRef {
656
849
  if (style === 'integer' || style === 'percent') return functionRef(style);
657
850
  throw new Error(`{${el.value}, number, ${style}} has no MessageFormat 2 equivalent`);
658
851
  }
659
- if ('mf2' in style) return functionRef((style as Mf2NumberSkeleton).mf2.name, (style as Mf2NumberSkeleton).mf2.options);
852
+ if ('mf2' in style) return written((style as Mf2NumberSkeleton).mf2);
660
853
  const {
661
854
  style: kind,
662
855
  currency,
@@ -690,8 +883,7 @@ function numberFunction(el: NumberElement): Model.FunctionRef {
690
883
  */
691
884
  function dateFunction(el: DateElement | TimeElement): Model.FunctionRef {
692
885
  const style = el.style;
693
- if (typeof style === 'object' && style !== null && 'mf2' in style)
694
- return functionRef((style as Mf2DateSkeleton).mf2.name, (style as Mf2DateSkeleton).mf2.options);
886
+ if (typeof style === 'object' && style !== null && 'mf2' in style) return written((style as Mf2DateSkeleton).mf2);
695
887
  if (el.type === TYPE.time && (style === null || style === undefined || typeof style === 'string')) {
696
888
  const time: Record<string, Record<string, string>> = {
697
889
  short: {},
@@ -34,7 +34,8 @@ export interface MigratePlan {
34
34
  }
35
35
 
36
36
  export function planMigrate(config: ResolvedConfig, to: MessageFormatVersion): MigratePlan {
37
- const catalog = loadCatalog(config);
37
+ // Both syntaxes read without isolation marks, which MF1 never has, so their renders compare.
38
+ const catalog = loadCatalog({ ...config, isolate: false });
38
39
  const plan: MigratePlan = { to, rewrites: [], blocked: [], changed: [] };
39
40
  for (const m of catalog.messages) {
40
41
  const versions = [
@@ -47,7 +48,7 @@ export function planMigrate(config: ResolvedConfig, to: MessageFormatVersion): M
47
48
  let back: MessageFormatElement[];
48
49
  try {
49
50
  after = writeMessage(to, ast, locale);
50
- back = readMessage(to, after, locale);
51
+ back = readMessage(to, after, locale, false);
51
52
  } catch (e) {
52
53
  plan.blocked.push({ ...at, reason: (e as Error).message });
53
54
  continue;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * What every native writer shares: words to identifiers, numbers and strings to literals, and the
3
+ * names a file can't give two things.
4
+ */
5
+ import type { Condition, Operand } from './plurals.ts';
6
+
7
+ /** Words from an identifier written any way: `userName`, `user-name` and `user_name` are user, name. */
8
+ export function wordsOf(name: string): string[] {
9
+ return name
10
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
11
+ .split(/[^A-Za-z0-9]+/)
12
+ .filter(Boolean)
13
+ .map((w) => w.toLowerCase());
14
+ }
15
+
16
+ const capital = (w: string) => w.charAt(0).toUpperCase() + w.slice(1);
17
+
18
+ export const snake = (words: string[]) => words.join('_');
19
+ export const pascal = (words: string[]) => words.map(capital).join('');
20
+ export const camel = (words: string[]) => words.map((w, i) => (i === 0 ? w : capital(w))).join('');
21
+ export const upper = (words: string[]) => words.join('_').toUpperCase();
22
+
23
+ /** An identifier can't start with a digit, so `404.title` is `_404Title`. */
24
+ export function leading(name: string): string {
25
+ return /^[0-9]/.test(name) ? `_${name}` : name;
26
+ }
27
+
28
+ /** A double literal every target reads as one: `0.0`, `1.5`, `1e21`. */
29
+ export function float(value: number): string {
30
+ const s = String(value).replace('e+', 'e');
31
+ return /[.e]/.test(s) ? s : `${s}.0`;
32
+ }
33
+
34
+ /** Characters that print nothing, or look like a space, written as escapes so the code shows them. */
35
+ const INVISIBLE = /[\u{a0}\u{61c}\u{180e}\u{2000}-\u{200f}\u{2028}-\u{202f}\u{205f}-\u{206f}\u{feff}]/gu;
36
+
37
+ /** Text for a doc comment, with what prints nothing written as `\u{…}`: Rust refuses direction marks there. */
38
+ export function docText(line: string): string {
39
+ return line.replace(INVISIBLE, (c) => `\\u{${c.charCodeAt(0).toString(16)}}`);
40
+ }
41
+
42
+ /** A string literal: JSON's escapes, `\u{…}` where the target writes them so (Rust, Swift), and `$` escaped in Kotlin. */
43
+ export function stringLiteral(value: string, language: 'rust' | 'swift' | 'kotlin'): string {
44
+ const json = JSON.stringify(value).replace(INVISIBLE, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
45
+ if (language === 'kotlin') return json.replaceAll('$', '\\$');
46
+ return json.replace(/\\u([0-9a-f]{4})/g, (_, hex: string) => `\\u{${hex.replace(/^0+/, '')}}`);
47
+ }
48
+
49
+ /**
50
+ * A plural condition as an expression over operands `o`: `field(op)` reads one, `mod` takes a
51
+ * remainder, `within` tests a range as CLDR does (integers only). `&&`, `||` and `!` are every
52
+ * target's.
53
+ */
54
+ export function conditionCode(
55
+ condition: Condition,
56
+ code: {
57
+ field: (op: Operand) => string;
58
+ mod: (value: string, by: string) => string;
59
+ within: (value: string, from: string, to: string) => string;
60
+ },
61
+ ): string {
62
+ const ors = condition.map((and) =>
63
+ and
64
+ .map((r) => {
65
+ const value = r.mod === undefined ? code.field(r.operand) : code.mod(code.field(r.operand), float(r.mod));
66
+ if (r.ranges.length === 1 && r.ranges[0]![0] === r.ranges[0]![1])
67
+ return `${value} ${r.negated ? '!=' : '=='} ${float(r.ranges[0]![0])}`;
68
+ const any = r.ranges
69
+ .map(([from, to]) => (from === to ? `${value} == ${float(from)}` : code.within(value, float(from), float(to))))
70
+ .join(' || ');
71
+ return r.negated ? `!(${any})` : r.ranges.length > 1 ? `(${any})` : any;
72
+ })
73
+ .join(' && '),
74
+ );
75
+ return ors.length > 1 ? ors.map((o) => (o.includes(' && ') ? `(${o})` : o)).join(' || ') : ors[0]!;
76
+ }
77
+
78
+ /** Fails with the keys whose names meet, or meet a name the file already has. */
79
+ export function checkNames(language: string, named: [key: string, name: string][], taken: string[]): void {
80
+ const seen = new Map<string, string>(taken.map((t) => [t, 'a name the file already has']));
81
+ for (const [key, name] of named) {
82
+ const other = seen.get(name);
83
+ if (other !== undefined)
84
+ throw new Error(`${language}: ${key} is named ${name}, as ${other} is: rename one, or change the target's keys`);
85
+ seen.set(name, key);
86
+ }
87
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The native files `generate` writes beside the TypeScript, one per target in the config's `native`.
3
+ * A message a target can't render fails the build with the reason, as a translation that drifted does.
4
+ */
5
+ import { CatalogError, type Catalog, type Problem } from '#src/tools/catalog.ts';
6
+ import type { Entry } from '#src/tools/compile.ts';
7
+ import type { ResolvedConfig } from '#src/tools/config.ts';
8
+ import { kotlinFile } from './kotlin.ts';
9
+ import { nativeModel } from './model.ts';
10
+ import { rustModule } from './rust.ts';
11
+ import { swiftFile } from './swift.ts';
12
+
13
+ const WRITERS = { rust: rustModule, swift: swiftFile, kotlin: kotlinFile };
14
+
15
+ export function nativeFiles(
16
+ config: ResolvedConfig,
17
+ catalog: Catalog,
18
+ locales: string[],
19
+ entries: Map<string, Entry[]>,
20
+ banner: string,
21
+ ): Map<string, string> {
22
+ const files = new Map<string, string>();
23
+ const problems: Problem[] = [];
24
+ for (const target of config.native) {
25
+ const { model, problems: found } = nativeModel(target, catalog, locales, entries);
26
+ problems.push(...found);
27
+ if (found.length > 0) continue;
28
+ try {
29
+ files.set(target.out, WRITERS[target.language](model, target, banner));
30
+ } catch (e) {
31
+ problems.push({ severity: 'error', message: (e as Error).message });
32
+ }
33
+ }
34
+ if (problems.length > 0) throw new CatalogError(problems);
35
+ return files;
36
+ }