prosebook 0.1.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 (114) hide show
  1. package/README.md +311 -57
  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-CxVMn19f.js → config-BPSmoYel.js} +82 -196
  15. package/dist/config-BPSmoYel.js.map +1 -0
  16. package/dist/{config-CF7HziDa.d.ts → config-DdMkKt_N.d.ts} +85 -7
  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-PkPvsB07.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 -64
  61. package/dist/tools.d.ts.map +1 -1
  62. package/dist/tools.js +5 -4
  63. package/dist/vite.d.ts +14 -4
  64. package/dist/vite.d.ts.map +1 -1
  65. package/dist/vite.js +60 -114
  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 +61 -13
  83. package/src/tools/config.ts +58 -3
  84. package/src/tools/endpoints.ts +121 -0
  85. package/src/tools/entry.ts +77 -0
  86. package/src/tools/generate.ts +32 -32
  87. package/src/tools/icu.ts +93 -8
  88. package/src/tools/index.ts +20 -4
  89. package/src/tools/lint.ts +49 -12
  90. package/src/tools/lock.ts +7 -0
  91. package/src/tools/mf1.ts +21 -7
  92. package/src/tools/mf2.ts +249 -57
  93. package/src/tools/migrate.ts +3 -2
  94. package/src/tools/native/emit.ts +87 -0
  95. package/src/tools/native/index.ts +36 -0
  96. package/src/tools/native/kotlin.ts +269 -0
  97. package/src/tools/native/model.ts +280 -0
  98. package/src/tools/native/number.ts +210 -0
  99. package/src/tools/native/plurals.ts +103 -0
  100. package/src/tools/native/rust.ts +367 -0
  101. package/src/tools/native/swift.ts +326 -0
  102. package/src/tools/scan.ts +134 -4
  103. package/src/tools/syntax.ts +3 -3
  104. package/src/tools/todo.ts +118 -0
  105. package/src/vite.ts +74 -116
  106. package/dist/config-CF7HziDa.d.ts.map +0 -1
  107. package/dist/config-CxVMn19f.js.map +0 -1
  108. package/dist/copy-DXnzXsJa.d.ts.map +0 -1
  109. package/dist/helpers-BPuPGjQP.js +0 -61
  110. package/dist/helpers-BPuPGjQP.js.map +0 -1
  111. package/dist/index.js.map +0 -1
  112. package/dist/sarif-PkPvsB07.js.map +0 -1
  113. package/dist/scan-B0tZ7y-D.js +0 -2447
  114. package/dist/scan-B0tZ7y-D.js.map +0 -1
@@ -1,34 +1,61 @@
1
1
  import type { Arms, Helpers, Values } from './types.ts';
2
2
 
3
+ /** Formatters by options object, then by the options arguments gave, as JSON (`''` for none). */
4
+ type Formatters<F> = Map<object | undefined, Map<string, F>>;
5
+
3
6
  /**
4
7
  * The helpers compiled messages call, for one locale. Formatters are built once per options object:
5
8
  * the generator hoists each message's options to a module-level constant, so identity is a stable key.
9
+ * Options an argument gives are built per call, so those are told apart by value.
10
+ *
11
+ * A value missing, or one `Intl` refuses, never throws: `n` and `d` give nothing back, and the message
12
+ * shows the placeholder's fallback through `e`, which hands `report` what went wrong.
6
13
  */
7
- export function createHelpers(locale: string): Helpers {
8
- const numbers = new Map<Intl.NumberFormatOptions | undefined, Intl.NumberFormat>();
9
- const dates = new Map<Intl.DateTimeFormatOptions | undefined, Intl.DateTimeFormat>();
10
- let cardinal: Intl.PluralRules | undefined;
11
- let ordinal: Intl.PluralRules | undefined;
14
+ export function createHelpers(locale: string, report?: (error: Error) => void): Helpers {
15
+ const numbers: Formatters<Intl.NumberFormat> = new Map();
16
+ const dates: Formatters<Intl.DateTimeFormat> = new Map();
17
+ const rules: Formatters<Intl.PluralRules>[] = [new Map(), new Map()];
18
+ let failure: unknown;
19
+
20
+ function attempt(value: unknown, format: () => string): string | undefined {
21
+ failure = undefined;
22
+ // `value !== value`: NaN, which `Intl` prints as "NaN".
23
+ if (value === undefined || value === null || value !== value) return undefined;
24
+ try {
25
+ return format();
26
+ } catch (error) {
27
+ failure = error;
28
+ return undefined;
29
+ }
30
+ }
12
31
 
13
32
  return {
14
33
  locale,
15
- n(value, options) {
16
- let format = numbers.get(options);
17
- if (format === undefined) numbers.set(options, (format = new Intl.NumberFormat(locale, options)));
18
- return format.format(value);
34
+ n(value, options, given) {
35
+ return attempt(value, () => formatter(numbers, options, given, (o) => new Intl.NumberFormat(locale, o)).format(value));
19
36
  },
20
- d(value, options) {
21
- let format = dates.get(options);
22
- if (format === undefined) dates.set(options, (format = new Intl.DateTimeFormat(locale, options)));
23
- return format.format(value);
37
+ d(value, options, given) {
38
+ return attempt(value, () => formatter(dates, options, given, (o) => new Intl.DateTimeFormat(locale, o)).format(value));
24
39
  },
25
- p(value, offset, isOrdinal, arms) {
40
+ e(fallback) {
41
+ const why = failure instanceof Error ? `could not be formatted: ${failure.message}` : 'has no value';
42
+ report?.(new Error(`${fallback} ${why}`, { cause: failure }));
43
+ failure = undefined;
44
+ return fallback;
45
+ },
46
+ p(value, offset, isOrdinal, arms, options, given) {
26
47
  const exact = arms[`=${value}`];
27
48
  if (exact !== undefined) return exact();
28
- const rules = isOrdinal
29
- ? (ordinal ??= new Intl.PluralRules(locale, { type: 'ordinal' }))
30
- : (cardinal ??= new Intl.PluralRules(locale));
31
- return pick(arms, rules.select(value - offset));
49
+ let category = 'other';
50
+ try {
51
+ const type = isOrdinal ? 'ordinal' : 'cardinal';
52
+ category = formatter(rules[+isOrdinal]!, options, given, (o) => new Intl.PluralRules(locale, { ...o, type })).select(
53
+ value - offset,
54
+ );
55
+ } catch {
56
+ // Options an argument gave that `Intl` refuses: the catch-all arm, as MF2 falls back to `*`.
57
+ }
58
+ return pick(arms, category);
32
59
  },
33
60
  s(value, arms) {
34
61
  return pick(arms, String(value));
@@ -40,6 +67,15 @@ export function createHelpers(locale: string): Helpers {
40
67
  };
41
68
  }
42
69
 
70
+ function formatter<O extends object, F>(cache: Formatters<F>, options: O | undefined, given: O | undefined, make: (o?: O) => F): F {
71
+ let byGiven = cache.get(options);
72
+ if (byGiven === undefined) cache.set(options, (byGiven = new Map()));
73
+ const key = given === undefined ? '' : JSON.stringify(given);
74
+ let format = byGiven.get(key);
75
+ if (format === undefined) byGiven.set(key, (format = make(given === undefined ? options : { ...options, ...given })));
76
+ return format;
77
+ }
78
+
43
79
  function pick(arms: Arms, arm: string): unknown {
44
80
  // ICU requires an `other` arm, and the generator refuses a message without one.
45
81
  return (Object.hasOwn(arms, arm) ? arms[arm]! : arms['other']!)();
@@ -44,6 +44,25 @@ export function preferredLocales(): string[] {
44
44
  return resolved ? [resolved] : [];
45
45
  }
46
46
 
47
+ /**
48
+ * The locales an `Accept-Language` header asks for, most preferred first: ordered by `q`, ties in
49
+ * the header's order. `q=0` means "not this one", and `*` asks for nothing `negotiate`'s fallback
50
+ * doesn't already give, so both are left out, as is a part whose `q` isn't a number.
51
+ */
52
+ export function acceptedLocales(header: string | null | undefined): string[] {
53
+ const weighed: { tag: string; q: number }[] = [];
54
+ for (const part of (header ?? '').split(',')) {
55
+ const [tag = '', ...params] = part.split(';').map((s) => s.trim());
56
+ if (tag === '' || tag === '*') continue;
57
+ const weight = params.find((p) => /^q\s*=/i.test(p));
58
+ const q = weight === undefined ? 1 : Number(weight.slice(weight.indexOf('=') + 1).trim());
59
+ if (Number.isNaN(q) || q <= 0) continue;
60
+ weighed.push({ tag, q });
61
+ }
62
+ // Array.prototype.sort is stable, so equal weights keep the header's order.
63
+ return weighed.sort((a, b) => b.q - a.q).map((w) => w.tag);
64
+ }
65
+
47
66
  function languageOf(tag: string): string {
48
67
  return tag.split('-', 1)[0]!.toLowerCase();
49
68
  }
@@ -42,12 +42,27 @@ export type DefaultTags = Register extends { tags: infer T extends string } ? T
42
42
  */
43
43
  export interface Helpers {
44
44
  readonly locale: string;
45
- /** `{x, number, …}` and the `#` of a plural. */
46
- n(value: number, options?: Intl.NumberFormatOptions): string;
47
- /** `{x, date, …}` and `{x, time, …}`. */
48
- d(value: Date | number, options?: Intl.DateTimeFormatOptions): string;
49
- /** `{x, plural, …}` and `{x, selectordinal, …}`: picks an arm and renders it. */
50
- p(value: number, offset: number, ordinal: boolean, arms: Arms): unknown;
45
+ /**
46
+ * `{x, number, …}` and the `#` of a plural; `given` holds the options arguments give, in MF2.
47
+ * Nothing where there is no value or `Intl` refuses it, so the message shows `e`'s fallback.
48
+ */
49
+ n(value: number, options?: Intl.NumberFormatOptions, given?: Intl.NumberFormatOptions): string | undefined;
50
+ /** `{x, date, …}` and `{x, time, …}`, as `n`. */
51
+ d(value: Date | number, options?: Intl.DateTimeFormatOptions, given?: Intl.DateTimeFormatOptions): string | undefined;
52
+ /** A placeholder's fallback, `{x}`, where it has no value or `n` or `d` gave nothing: reports why. */
53
+ e(fallback: string): string;
54
+ /**
55
+ * `{x, plural, …}` and `{x, selectordinal, …}`: picks an arm and renders it. MF2 passes the
56
+ * options its selector formats with, since those decide the category: `1.0` is `other` in English.
57
+ */
58
+ p(
59
+ value: number,
60
+ offset: number,
61
+ ordinal: boolean,
62
+ arms: Arms,
63
+ options?: Intl.PluralRulesOptions,
64
+ given?: Intl.PluralRulesOptions,
65
+ ): unknown;
51
66
  /** `{x, select, …}`. */
52
67
  s(value: unknown, arms: Arms): unknown;
53
68
  /** `<tag>…</tag>`: the tag's function from the values, or the bare children when there is none. */
@@ -0,0 +1,131 @@
1
+ /**
2
+ * What the bundler integrations share: which messages a build ships, the reads that would show a key
3
+ * once their message is pruned, and the code a dev server appends so an edit swaps messages in place.
4
+ *
5
+ * Vite sees every module before it writes a chunk, so it prunes after the fact (`vite.ts`). Next's
6
+ * loaders and Bun's `onLoad` hand over a locale module before the code that reads it has been seen,
7
+ * so they read the source under `scan.dirs` first (`scanSources`) and load the locale already pruned.
8
+ */
9
+ import { existsSync, readFileSync, watch } from 'node:fs';
10
+ import { relative } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ import { compileLocale, localeModule, type Entry } from './compile.ts';
14
+ import type { ResolvedConfig } from './config.ts';
15
+ import { isReferenced, keyRefs, READERS, sourceFiles, unseenReads, type KeyRefs, type Reads, type UnseenRead } from './scan.ts';
16
+
17
+ /** How a build prunes: `strict` also fails on a read whose key it can't see. */
18
+ export type Prune = 'strict' | 'loose' | false;
19
+
20
+ /** Whether a build keeps a key: some module names it, or `keep` lists it, exactly or as `prefix.*`. */
21
+ export function keeper(refs: KeyRefs, keep: string[] = []): (key: string) => boolean {
22
+ return (key) => isReferenced(key, refs) || keep.some((k) => (k.endsWith('.*') ? key.startsWith(k.slice(0, -1)) : k === key));
23
+ }
24
+
25
+ /** The unseen reads that are a copy's: a name only counts once some module binds a reader to it. */
26
+ export function unseenOf(reads: Iterable<Reads>): UnseenRead[] {
27
+ const all = [...reads];
28
+ const readers = new Set([...READERS, ...all.flatMap((r) => [...r.aliases])]);
29
+ return all.flatMap((r) => r.unseen).filter((u) => u.name === 'Text' || readers.has(u.name));
30
+ }
31
+
32
+ /** The error a strict build fails with, each read at its path from `root`. */
33
+ export function unseenMessage(unseen: UnseenRead[], root: string): string {
34
+ const at = (file: string) => relative(root, file).replaceAll('\\', '/');
35
+ return [
36
+ `${unseen.length} read(s) whose key the build can't see, so their messages would be pruned and the key shown instead:`,
37
+ ...unseen.map((u) => ` ${at(u.file)}:${u.line}:${u.column} ${u.code}`),
38
+ 'Write each key at the read, as a literal or `area.${id}`; or, where every key it reads is written elsewhere or listed in `keep`, mark the line `prosebook-ignore`. `prune: "loose"` turns this check off.',
39
+ ].join('\n');
40
+ }
41
+
42
+ export interface SourceScan {
43
+ refs: KeyRefs;
44
+ unseen: UnseenRead[];
45
+ }
46
+
47
+ /**
48
+ * Every key the app's source names and, with `strict`, its unseen reads. The generated modules are
49
+ * left out, since they name every key.
50
+ */
51
+ export function scanSources(config: ResolvedConfig, strict: boolean): SourceScan {
52
+ const refs: KeyRefs = { exact: new Set(), prefixes: new Set() };
53
+ const reads: Reads[] = [];
54
+ for (const file of sourceFiles(config.scan.dirs, config.scan.extensions, [config.out])) {
55
+ const code = readFileSync(file, 'utf8');
56
+ keyRefs(code, refs);
57
+ if (strict) reads.push(unseenReads(file, code));
58
+ }
59
+ return { refs, unseen: unseenOf(reads) };
60
+ }
61
+
62
+ /** Calls `change` once a burst of edits to the catalog or its translations settles. Returns the stop. */
63
+ export function watchCatalog(config: ResolvedConfig, change: () => void): () => void {
64
+ let timer: ReturnType<typeof setTimeout> | undefined;
65
+ const watchers = [config.catalog, config.locales]
66
+ .filter((dir) => existsSync(dir))
67
+ .map((dir) =>
68
+ watch(dir, { recursive: true }, (_event, file) => {
69
+ if (file === null || !file.endsWith('.yaml')) return;
70
+ clearTimeout(timer);
71
+ timer = setTimeout(change, 50);
72
+ }),
73
+ );
74
+ return () => {
75
+ clearTimeout(timer);
76
+ for (const w of watchers) w.close();
77
+ };
78
+ }
79
+
80
+ /** A locale's module holding only the keys `kept`, as JavaScript, for a bundler to load in its place. */
81
+ export function prunedModule(entries: Entry[], kept: (key: string) => boolean): string {
82
+ return localeModule(compileLocale(entries, kept), '// Pruned by prosebook: only the messages the code names.', false);
83
+ }
84
+
85
+ /**
86
+ * Appended to a locale module in dev. Accepts its own update and hands the new messages to every
87
+ * copy (`HotHook`); a page loaded before the hooks existed reloads instead.
88
+ */
89
+ export const hotLocale = (locale: string) => `
90
+ if (import.meta.hot) {
91
+ import.meta.hot.accept((next) => {
92
+ const hot = globalThis.__PROSEBOOK_HOT__;
93
+ if (next === undefined || hot === undefined) return import.meta.hot.invalidate();
94
+ for (const update of hot) update(${JSON.stringify(locale)}, next.default);
95
+ });
96
+ }
97
+ `;
98
+
99
+ /** Appended to the index in dev: its doc comments change with every edit; only a new locale reloads. */
100
+ export const HOT_INDEX = `
101
+ if (import.meta.hot) {
102
+ import.meta.hot.accept((next) => {
103
+ if (next === undefined || next.catalog.locales.join() !== catalog.locales.join()) import.meta.hot.invalidate();
104
+ });
105
+ }
106
+ `;
107
+
108
+ /**
109
+ * A locale module in dev under a bundler that runs it again when it changes, as Turbopack's Fast
110
+ * Refresh does, but has no `import.meta.hot`: each run hands its messages to every copy. The first
111
+ * run finds no copy holding the locale yet, so it changes nothing.
112
+ */
113
+ export const notifyingLocale = (code: string, locale: string) =>
114
+ `${code.replace(/^export default /m, 'const messages = ')}
115
+ export default messages;
116
+ globalThis.__PROSEBOOK_HOT__?.forEach((update) => update(${JSON.stringify(locale)}, messages));
117
+ `;
118
+
119
+ /** Prepended to the index in dev, so the set every copy adds itself to exists before any copy does. */
120
+ export const HOT_SET = 'globalThis.__PROSEBOOK_HOT__ ??= new Set();\n';
121
+
122
+ /**
123
+ * A module of prosebook's own beside the one at `url`, which passes its `import.meta.url`: the source
124
+ * when run from source, the built file in dist.
125
+ */
126
+ export const beside = (url: string, name: string) =>
127
+ fileURLToPath(new URL(`./${name}${url.endsWith('.ts') ? '.ts' : '.js'}`, url)).replaceAll('\\', '/');
128
+
129
+ /** Code that starts the inspector (`inspector.ts`, imported from `path`) on the page, its endpoints under `base`. */
130
+ export const installInspector = (path: string, base: string) =>
131
+ `import { install as installInspector } from ${JSON.stringify(path)};\ninstallInspector(${JSON.stringify({ base })});\n`;
@@ -179,7 +179,7 @@ function readEntry(
179
179
 
180
180
  let ast: MessageFormatElement[];
181
181
  try {
182
- ast = readMessage(config.messageFormat, text, config.source);
182
+ ast = readMessage(config.messageFormat, text, config.source, config.isolate);
183
183
  } catch (e) {
184
184
  fail(syntaxError(config, text, e));
185
185
  return undefined;
@@ -260,7 +260,7 @@ function readLocales(
260
260
  }
261
261
  let ast: MessageFormatElement[];
262
262
  try {
263
- ast = readMessage(config.messageFormat, value, locale);
263
+ ast = readMessage(config.messageFormat, value, locale, config.isolate);
264
264
  } catch (e) {
265
265
  error(syntaxError(config, value, e), key);
266
266
  continue;
@@ -3,12 +3,26 @@
3
3
  *
4
4
  * A message with no placeholders becomes its string. Any other becomes `(a, h) => …`, where `a` holds
5
5
  * the arguments and `h` the locale's helpers (see `runtime/helpers.ts`): a plural is a call picking an
6
- * arm, a number is a call to a cached `Intl.NumberFormat`. Formatting options are resolved here and
6
+ * arm, a number is a call to a cached `Intl.NumberFormat`. A placeholder without a value, or with one
7
+ * `Intl` refuses, shows as `{name}` and is reported (`h.e`) rather than throwing, so every such
8
+ * message uses `h`. Formatting options are resolved here and
7
9
  * hoisted to module constants, so a skeleton costs nothing at runtime and its formatter is built once.
10
+ * Options an argument gives (MF2's `currency=$code`) follow them as an object built per call, and the
11
+ * runtime keeps a formatter per value.
8
12
  */
9
13
  import { isDateElement, isNumberElement, isTimeElement } from '@formatjs/icu-messageformat-parser';
10
14
 
11
- import { dateOptions, numberOptions, staticLiteral, TYPE, type MessageFormatElement } from './icu.ts';
15
+ import {
16
+ dateOptions,
17
+ numberOptions,
18
+ optionArgsOf,
19
+ placeholderOf,
20
+ selectionOf,
21
+ staticLiteral,
22
+ TYPE,
23
+ type MessageFormatElement,
24
+ type OptionArgs,
25
+ } from './icu.ts';
12
26
 
13
27
  export type Entry = readonly [key: string, ast: MessageFormatElement[]];
14
28
 
@@ -35,34 +49,67 @@ function access(name: string): string {
35
49
  return /^[A-Za-z_$][\w$]*$/.test(name) ? `a.${name}` : `a[${JSON.stringify(name)}]`;
36
50
  }
37
51
 
38
- function nodes(elements: MessageFormatElement[], hoisted: Hoisted, pound: string | null): string {
52
+ /** The plural a `#` prints the value of. */
53
+ type Pound = { name: string; offset: number } | null;
54
+
55
+ function nodes(elements: MessageFormatElement[], hoisted: Hoisted, pound: Pound): string {
39
56
  if (elements.length === 0) return '""';
40
57
  const parts = elements.map((el) => node(el, hoisted, pound));
41
58
  return parts.length === 1 ? parts[0]! : `[${parts.join(', ')}]`;
42
59
  }
43
60
 
44
- function arms(options: Record<string, { value: MessageFormatElement[] }>, hoisted: Hoisted, pound: string | null): string {
61
+ function arms(options: Record<string, { value: MessageFormatElement[] }>, hoisted: Hoisted, pound: Pound): string {
45
62
  const body = Object.entries(options).map(([arm, option]) => `${JSON.stringify(arm)}: () => ${nodes(option.value, hoisted, pound)}`);
46
63
  return `{ ${body.join(', ')} }`;
47
64
  }
48
65
 
49
- function node(el: MessageFormatElement, hoisted: Hoisted, pound: string | null): string {
50
- if (isNumberElement(el)) return `h.n(${access(el.value)}${hoisted.arg(numberOptions(el))})`;
51
- if (isDateElement(el) || isTimeElement(el)) return `h.d(${access(el.value)}${hoisted.arg(dateOptions(el))})`;
66
+ /** The hoisted options, then the options arguments give as `{ currency: a.code }`, where there are any. */
67
+ function formatting(options: object | undefined, args: OptionArgs | undefined, hoisted: Hoisted): string {
68
+ const given = Object.entries(args ?? {});
69
+ const fixed = hoisted.arg(options);
70
+ if (given.length === 0) return fixed;
71
+ return `${fixed || ', undefined'}, { ${given.map(([option, { arg }]) => `${option}: ${access(arg)}`).join(', ')} }`;
72
+ }
73
+
74
+ /** A string literal, its invisible direction marks escaped so the generated code shows them. */
75
+ function text(value: string): string {
76
+ return JSON.stringify(value).replace(/[\u2066-\u2069]/g, (c) => `\\u${c.charCodeAt(0).toString(16)}`);
77
+ }
78
+
79
+ /**
80
+ * A placeholder's value, or its fallback (`{name}`, reported through `h.e`) where it has none or
81
+ * `Intl` refuses it, between the marks isolating its direction where it has them.
82
+ */
83
+ function placed(el: MessageFormatElement, name: string, value: string): string {
84
+ const { fallback, isolate } = placeholderOf(el, name);
85
+ const shown = `${value} ?? h.e(${text(fallback)})`;
86
+ return isolate === undefined ? shown : `[${text(isolate[0])}, ${shown}, ${text(isolate[1])}]`;
87
+ }
88
+
89
+ function node(el: MessageFormatElement, hoisted: Hoisted, pound: Pound): string {
90
+ const formatted = (helper: string, name: string, options: object | undefined) =>
91
+ placed(el, name, `h.${helper}(${access(name)}${formatting(options, optionArgsOf(el), hoisted)})`);
92
+ if (isNumberElement(el)) return formatted('n', el.value, numberOptions(el));
93
+ if (isDateElement(el) || isTimeElement(el)) return formatted('d', el.value, dateOptions(el));
52
94
  switch (el.type) {
53
95
  case TYPE.literal:
54
96
  return JSON.stringify(el.value);
55
97
  case TYPE.argument:
56
- return access(el.value);
98
+ return placed(el, el.value, access(el.value));
57
99
  case TYPE.pound:
58
- return pound ?? '"#"';
100
+ // `#` is its plural's own value less its offset, formatted as a number.
101
+ if (pound === null) return '"#"';
102
+ return placed(el, pound.name, `h.n(${access(pound.name)}${pound.offset !== 0 ? ` - ${pound.offset}` : ''})`);
59
103
  case TYPE.select:
60
104
  return `h.s(${access(el.value)}, ${arms(el.options, hoisted, pound)})`;
61
105
  case TYPE.plural: {
62
- // `#` is the plural's own value less its offset, formatted as a number.
106
+ // Read from MF2, a plural selects on the number as formatted, and on what `:integer` and `:percent` make of it.
107
+ const selection = selectionOf(el);
63
108
  const value = access(el.value);
64
- const hash = `h.n(${value}${el.offset !== 0 ? ` - ${el.offset}` : ''})`;
65
- return `h.p(${value}, ${el.offset}, ${el.pluralType === 'ordinal'}, ${arms(el.options, hoisted, hash)})`;
109
+ const on = selection?.value === 'round' ? `Math.round(${value})` : selection?.value === 'percent' ? `${value} * 100` : value;
110
+ const rules = selection === undefined ? '' : formatting(selection.options, selection.args, hoisted);
111
+ const own = { name: el.value, offset: el.offset };
112
+ return `h.p(${on}, ${el.offset}, ${el.pluralType === 'ordinal'}, ${arms(el.options, hoisted, own)}${rules})`;
66
113
  }
67
114
  case TYPE.tag:
68
115
  return `h.t(a, ${JSON.stringify(el.value)}, ${nodes(el.children, hoisted, pound)})`;
@@ -73,7 +120,8 @@ function node(el: MessageFormatElement, hoisted: Hoisted, pound: string | null):
73
120
 
74
121
  function message(ast: MessageFormatElement[], hoisted: Hoisted): string {
75
122
  const literal = staticLiteral(ast);
76
- return literal === undefined ? `(a, h) => ${nodes(ast, hoisted, null)}` : JSON.stringify(literal);
123
+ if (literal !== undefined) return JSON.stringify(literal);
124
+ return `(a, h) => ${nodes(ast, hoisted, null)}`;
77
125
  }
78
126
 
79
127
  export interface CompiledLocale {
@@ -7,6 +7,7 @@ import { isAbsolute, join, resolve } from 'node:path';
7
7
  import { pathToFileURL } from 'node:url';
8
8
 
9
9
  import { compileRule, CORE_RULES, type CheckRule, type CoreRule, type Plugin } from './lint.ts';
10
+ import type { Isolate } from './mf2.ts';
10
11
  import { DEFAULT_KINDS, DEFAULT_SURFACES, STRICT_LISTS, type KindRule, type Severity } from './presets.ts';
11
12
 
12
13
  export type { KindRule, Severity };
@@ -38,14 +39,39 @@ export interface WordLists {
38
39
  concepts?: Record<string, string>;
39
40
  /** Words that keep their capital inside a sentence-case string: product names, platforms. */
40
41
  properNouns?: string[];
42
+ /** What a path through menus or settings is written with, spaced: `›` by default, as in Settings › General. */
43
+ pathSeparator?: string;
41
44
  }
42
45
 
46
+ type RuleSeverities = Partial<Record<CoreRule, Severity>> & Record<string, Severity>;
47
+
43
48
  export interface LintConfig extends WordLists {
44
49
  /** `recommended` (the default) checks shape; `strict` adds the word lists and rules of a house style guide. */
45
50
  extends?: 'recommended' | 'strict' | 'none';
46
51
  /** Rules of your own, as `<plugin>/<rule>`. Each takes the severity it gives itself under `extends`. */
47
52
  plugins?: Plugin[];
48
- rules?: Partial<Record<CoreRule, Severity>> & Record<string, Severity>;
53
+ rules?: RuleSeverities;
54
+ /** Severities for one area's entries (a key's first segment) over `rules`: `{ native: { 'sentence-case': 'off' } }`. */
55
+ areas?: Record<string, RuleSeverities>;
56
+ }
57
+
58
+ /**
59
+ * Native code generated from the catalog, for the parts of an app outside the browser: a tray, a
60
+ * menu bar, a CLI. One file, holding every locale, with a typed function per message.
61
+ */
62
+ export interface NativeTarget {
63
+ language: 'rust' | 'swift' | 'kotlin';
64
+ /** The file written. */
65
+ out: string;
66
+ /**
67
+ * Key prefixes the file holds: `['native']` takes every `native.*` key, which it names without the
68
+ * prefix (`native.tray.quit` is `tray_quit`). Every key, named in full, by default.
69
+ */
70
+ keys?: string[];
71
+ /** The type holding the locales and messages: `Locale` in Rust, `Copy` in Swift and Kotlin. */
72
+ name?: string;
73
+ /** Kotlin's package. */
74
+ package?: string;
49
75
  }
50
76
 
51
77
  export interface ProsebookConfig {
@@ -64,6 +90,13 @@ export interface ProsebookConfig {
64
90
  * translations alike; `prosebook migrate` rewrites a catalog from one to the other.
65
91
  */
66
92
  messageFormat?: 1 | 2;
93
+ /**
94
+ * In MF2, when a placeholder is wrapped in Unicode isolation marks, so a value written the other
95
+ * way (a Latin name in Hebrew) can't reorder the text around it. `'rtl'`, the default: in locales
96
+ * written right to left, and wherever `u:dir` asks. `'always'`: also every string in any locale,
97
+ * as the spec's default has it. `false`: never.
98
+ */
99
+ isolate?: Isolate;
67
100
  /** Locales that ship in the main bundle beside the source, rather than loading when first used. */
68
101
  bundled?: string[];
69
102
  /** A pseudo-locale generated from the source (accented and lengthened) to find layout bugs and hardcoded text. */
@@ -81,6 +114,8 @@ export interface ProsebookConfig {
81
114
  /** Where a string may sit, with what each surface means. */
82
115
  surfaces?: Record<string, string>;
83
116
  lint?: LintConfig;
117
+ /** Rust, Swift or Kotlin files `generate` writes beside the TypeScript. */
118
+ native?: NativeTarget[];
84
119
  /** Where `prosebook scan` looks for hardcoded text and catalog keys. */
85
120
  scan?: {
86
121
  dirs?: string[];
@@ -98,6 +133,7 @@ export interface ResolvedConfig {
98
133
  lock: string;
99
134
  source: string;
100
135
  messageFormat: 1 | 2;
136
+ isolate: Isolate;
101
137
  bundled: string[];
102
138
  pseudo: string | false;
103
139
  stale: 'use' | 'fallback';
@@ -106,10 +142,16 @@ export interface ResolvedConfig {
106
142
  require: ('context' | 'kind' | 'surface')[];
107
143
  kinds: Record<string, KindRule>;
108
144
  surfaces: Record<string, string>;
109
- lint: { rules: Record<string, { rule: CheckRule; severity: Severity }> } & Required<WordLists>;
145
+ lint: {
146
+ rules: Record<string, { rule: CheckRule; severity: Severity }>;
147
+ areas: Record<string, Record<string, Severity>>;
148
+ } & Required<WordLists>;
149
+ native: (NativeTarget & { name: string })[];
110
150
  scan: { dirs: string[]; extensions: string[]; attributes: string[] };
111
151
  }
112
152
 
153
+ const NATIVE_NAMES = { rust: 'Locale', swift: 'Copy', kotlin: 'Copy' };
154
+
113
155
  /** Typed config, for `prosebook.config.ts`. */
114
156
  export function defineConfig(config: ProsebookConfig): ProsebookConfig {
115
157
  return config;
@@ -141,6 +183,11 @@ export function resolveConfig(root: string, config: ProsebookConfig): ResolvedCo
141
183
  const lint = config.lint ?? {};
142
184
  const base = lint.extends ?? 'recommended';
143
185
  const strict = base === 'strict';
186
+ const rules = lintRules(lint);
187
+ for (const [area, severities] of Object.entries(lint.areas ?? {})) {
188
+ for (const name of Object.keys(severities))
189
+ if (!(name in rules)) throw new Error(`lint.areas.${area}.${name} is not a rule: name one of ${Object.keys(rules).join(', ')}`);
190
+ }
144
191
  return {
145
192
  root,
146
193
  catalog: at(config.catalog ?? 'copy/catalog'),
@@ -149,6 +196,7 @@ export function resolveConfig(root: string, config: ProsebookConfig): ResolvedCo
149
196
  lock: at(config.lock ?? 'copy/lock.json'),
150
197
  source: config.source ?? 'en',
151
198
  messageFormat: config.messageFormat ?? 1,
199
+ isolate: config.isolate ?? 'rtl',
152
200
  bundled: config.bundled ?? [],
153
201
  pseudo: config.pseudo ?? false,
154
202
  stale: config.stale ?? 'use',
@@ -158,12 +206,19 @@ export function resolveConfig(root: string, config: ProsebookConfig): ResolvedCo
158
206
  kinds: config.kinds ?? DEFAULT_KINDS,
159
207
  surfaces: config.surfaces ?? DEFAULT_SURFACES,
160
208
  lint: {
161
- rules: lintRules(lint),
209
+ rules,
210
+ areas: lint.areas ?? {},
162
211
  banned: [...(strict ? STRICT_LISTS.banned : []), ...(lint.banned ?? [])],
163
212
  spelling: { ...(strict ? STRICT_LISTS.spelling : {}), ...lint.spelling },
164
213
  concepts: { ...lint.concepts },
165
214
  properNouns: lint.properNouns ?? [],
215
+ pathSeparator: lint.pathSeparator ?? '›',
166
216
  },
217
+ native: (config.native ?? []).map((target, i) => {
218
+ if (!(target.language in NATIVE_NAMES))
219
+ throw new Error(`native[${i}].language is "${target.language}": name one of ${Object.keys(NATIVE_NAMES).join(', ')}`);
220
+ return { ...target, out: at(target.out), name: target.name ?? NATIVE_NAMES[target.language] };
221
+ }),
167
222
  scan: {
168
223
  dirs: (config.scan?.dirs ?? ['src']).map(at),
169
224
  extensions: config.scan?.extensions ?? ['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs'],