prosebook 0.2.0 → 0.4.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 (115) hide show
  1. package/README.md +345 -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-CpUIWb9j.d.ts +6 -0
  7. package/dist/bundle-CpUIWb9j.d.ts.map +1 -0
  8. package/dist/bundle-DboItAre.js +111 -0
  9. package/dist/bundle-DboItAre.js.map +1 -0
  10. package/dist/cli.js +51 -56
  11. package/dist/cli.js.map +1 -1
  12. package/dist/compile-ClK9htKr.d.ts +16 -0
  13. package/dist/compile-ClK9htKr.d.ts.map +1 -0
  14. package/dist/{config-_4HfnJQu.d.ts → config-CzaJJQ60.d.ts} +75 -2
  15. package/dist/config-CzaJJQ60.d.ts.map +1 -0
  16. package/dist/{config-D--RITk2.js → config-nWZf9QmF.js} +42 -212
  17. package/dist/config-nWZf9QmF.js.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-BgG_5Vpm.js +96 -0
  25. package/dist/endpoints-BgG_5Vpm.js.map +1 -0
  26. package/dist/generate-BW3N8EGb.js +5488 -0
  27. package/dist/generate-BW3N8EGb.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-B5nrMqrT.js} +103 -87
  57. package/dist/sarif-B5nrMqrT.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 +57 -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/android.ts +201 -0
  94. package/src/tools/native/apple.ts +139 -0
  95. package/src/tools/native/emit.ts +87 -0
  96. package/src/tools/native/index.ts +48 -0
  97. package/src/tools/native/kotlin.ts +269 -0
  98. package/src/tools/native/model.ts +292 -0
  99. package/src/tools/native/number.ts +210 -0
  100. package/src/tools/native/plurals.ts +103 -0
  101. package/src/tools/native/printf.ts +95 -0
  102. package/src/tools/native/rust.ts +367 -0
  103. package/src/tools/native/swift.ts +326 -0
  104. package/src/tools/syntax.ts +3 -3
  105. package/src/tools/todo.ts +118 -0
  106. package/src/vite.ts +28 -114
  107. package/dist/config-D--RITk2.js.map +0 -1
  108. package/dist/config-_4HfnJQu.d.ts.map +0 -1
  109. package/dist/copy-DXnzXsJa.d.ts.map +0 -1
  110. package/dist/helpers-BPuPGjQP.js +0 -61
  111. package/dist/helpers-BPuPGjQP.js.map +0 -1
  112. package/dist/index.js.map +0 -1
  113. package/dist/sarif-CI8HOokm.js.map +0 -1
  114. package/dist/scan-Ch2BfIUD.js +0 -2561
  115. package/dist/scan-Ch2BfIUD.js.map +0 -1
@@ -14,23 +14,23 @@ type M = RegisteredMessages;
14
14
  type L = RegisteredLocale;
15
15
 
16
16
  /** `Copy`, with `rich` returning a `ReactNode`. */
17
- export interface ReactCopy extends Omit<Copy<M, L>, 'rich'> {
17
+ export interface ReactCopy extends Omit<Copy, 'rich'> {
18
18
  rich<K extends RichKey<M>>(this: void, key: K, ...values: RichParam<M[K], ReactNode>): ReactNode;
19
19
  }
20
20
 
21
- const CopyContext = createContext<{ copy: Copy<M, L>; tags: Tags } | null>(null);
21
+ const CopyContext = createContext<{ copy: Copy; tags: Tags } | null>(null);
22
22
 
23
23
  type Tags = { readonly [tag: string]: TagFn<ReactNode> };
24
24
 
25
25
  export interface CopyProviderProps {
26
- copy: Copy<M, L> | Copy;
26
+ copy: Copy;
27
27
  /** Tag renderers every rich read under this provider gets, beside the copy's own. */
28
28
  tags?: Tags;
29
29
  children?: ReactNode;
30
30
  }
31
31
 
32
32
  export function CopyProvider({ copy, tags, children }: CopyProviderProps) {
33
- const value = useMemo(() => ({ copy: copy as Copy<M, L>, tags: { ...(copy.tags as unknown as Tags), ...tags } }), [copy, tags]);
33
+ const value = useMemo(() => ({ copy: copy, tags: { ...(copy.tags as unknown as Tags), ...tags } }), [copy, tags]);
34
34
  return <CopyContext value={value}>{children}</CopyContext>;
35
35
  }
36
36
 
@@ -59,7 +59,7 @@ export function useLocale(): L {
59
59
  }
60
60
 
61
61
  // Takes the version only so the reader is rebuilt, and its identity changes, when what it reads does.
62
- function readerFor(copy: Copy<M, L>, tags: Tags, version: number): ReactCopy {
62
+ function readerFor(copy: Copy, tags: Tags, version: number): ReactCopy {
63
63
  return {
64
64
  locale: copy.locale,
65
65
  version,
@@ -108,5 +108,5 @@ export type TextProps<K extends Extract<keyof M, string>> = { id: K } & ValuesPr
108
108
  export function Text<K extends Extract<keyof M, string>>(props: TextProps<K>) {
109
109
  const copy = useCopy();
110
110
  const values = (props as { values?: object }).values;
111
- return <>{copy.rich(props.id as RichKey<M>, values)}</>;
111
+ return <>{copy.rich(props.id, values as never)}</>;
112
112
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `prosebook/react/server`: the catalog in React Server Components, which can't use context. Apart
3
+ * from `prosebook/react`, whose module makes a context as it loads, which the server build refuses.
4
+ */
5
+ import { cache } from 'react';
6
+
7
+ import { createCopy, type Catalog, type Copy, type CopyOptions } from '#src/runtime/copy.ts';
8
+ import type { MessageShapes } from '#src/runtime/types.ts';
9
+
10
+ /** What a request asks for: a locale, or the locales it prefers, most preferred first. */
11
+ type Asked<L extends string> = L | (string & {}) | readonly string[];
12
+
13
+ /**
14
+ * A reader of one copy per request, ready to use: `locale` is asked once per request, and every
15
+ * server component rendering in it gets the same copy, in the locale negotiated from the answer.
16
+ * React's `cache` keeps it per request, so one request's locale never reaches another's.
17
+ *
18
+ * ```ts
19
+ * export const serverCopy = createServerCopy(catalog, async () => {
20
+ * const picked = (await cookies()).get('locale')?.value;
21
+ * const asked = acceptedLocales((await headers()).get('accept-language'));
22
+ * return picked === undefined ? asked : [picked, ...asked];
23
+ * });
24
+ * const { t, locale } = await serverCopy();
25
+ * ```
26
+ */
27
+ export function createServerCopy<M extends MessageShapes, L extends string>(
28
+ catalog: Catalog<M, L>,
29
+ locale: () => Asked<L> | Promise<Asked<L>>,
30
+ options: Omit<CopyOptions<L>, 'locale'> = {},
31
+ ): () => Promise<Copy<M, L>> {
32
+ return cache(async () => {
33
+ const copy = createCopy(catalog, { ...options, locale: await locale() });
34
+ await copy.ready;
35
+ return copy;
36
+ });
37
+ }
@@ -43,6 +43,11 @@ export interface CopyOptions<L extends string> {
43
43
  tags?: { [tag: string]: AnyTagFn };
44
44
  /** What a key the catalog lacks reads as. Defaults to the key itself. */
45
45
  onMissing?: (key: string, locale: L) => string;
46
+ /**
47
+ * Told of a placeholder that had no value, or one `Intl` refused (`currency: 'euro'`): the message
48
+ * still renders, showing `{name}` there. Defaults to `console.error`.
49
+ */
50
+ onError?: (error: Error, key: string, locale: L) => void;
46
51
  }
47
52
 
48
53
  /** Its methods don't use `this`, so they can be passed around and destructured: `const { t } = copy`. */
@@ -92,9 +97,17 @@ export function createCopy<M extends MessageShapes, L extends string>(catalog: C
92
97
  const listeners = new Set<() => void>();
93
98
  const defaultTags = options.tags ?? {};
94
99
  const formatters = new Map<string, Intl.NumberFormat | Intl.DateTimeFormat | Intl.ListFormat | Intl.RelativeTimeFormat>();
100
+ // The key being rendered, which a helper's report names; none while the inspector renders it again.
101
+ let reading: string | undefined;
102
+ const helpers = (l: L) =>
103
+ createHelpers(l, (error) => {
104
+ if (reading === undefined) return;
105
+ if (options.onError) options.onError(error, reading, l);
106
+ else console.error(`prosebook: ${reading} in ${l}: ${error.message}`);
107
+ });
95
108
 
96
109
  for (const [locale, messages] of Object.entries(catalog.messages) as [L, CompiledMessages][])
97
- loaded.set(locale, { messages, h: createHelpers(locale) });
110
+ loaded.set(locale, { messages, h: helpers(locale) });
98
111
 
99
112
  let locale = catalog.source;
100
113
  let chain: Layer[] = layers(chainOf(locale, catalog.locales, catalog.source));
@@ -127,7 +140,7 @@ export function createCopy<M extends MessageShapes, L extends string>(catalog: C
127
140
  const loader = catalog.loaders[l];
128
141
  if (loader === undefined) return Promise.reject(new Error(`prosebook: no messages for locale "${l}"`));
129
142
  p = loader().then((mod) => {
130
- const layer = { messages: mod.default, h: createHelpers(l) };
143
+ const layer = { messages: mod.default, h: helpers(l) };
131
144
  loaded.set(l, layer);
132
145
  pending.delete(l);
133
146
  return layer;
@@ -163,7 +176,13 @@ export function createCopy<M extends MessageShapes, L extends string>(catalog: C
163
176
  const found = find(key);
164
177
  if (found === undefined) return options.onMissing ? options.onMissing(key, locale) : key;
165
178
  const [message, h] = found;
166
- const out = typeof message === 'string' ? message : message(values, h);
179
+ reading = key;
180
+ let out: unknown;
181
+ try {
182
+ out = typeof message === 'string' ? message : message(values, h);
183
+ } finally {
184
+ reading = undefined;
185
+ }
167
186
  const read = (globalThis as DevHooks).__PROSEBOOK_READ__;
168
187
  if (read !== undefined) read(key, typeof out === 'string' ? out : textOf(message, values, h), h.locale);
169
188
  return out;
@@ -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,13 +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`. One that calls no helper is `(a) => …`, so
7
- * the generated TypeScript passes `noUnusedParameters`. 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
8
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.
9
12
  */
10
13
  import { isDateElement, isNumberElement, isTimeElement } from '@formatjs/icu-messageformat-parser';
11
14
 
12
- import { dateOptions, numberOptions, staticLiteral, TYPE, walk, 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';
13
26
 
14
27
  export type Entry = readonly [key: string, ast: MessageFormatElement[]];
15
28
 
@@ -36,34 +49,67 @@ function access(name: string): string {
36
49
  return /^[A-Za-z_$][\w$]*$/.test(name) ? `a.${name}` : `a[${JSON.stringify(name)}]`;
37
50
  }
38
51
 
39
- 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 {
40
56
  if (elements.length === 0) return '""';
41
57
  const parts = elements.map((el) => node(el, hoisted, pound));
42
58
  return parts.length === 1 ? parts[0]! : `[${parts.join(', ')}]`;
43
59
  }
44
60
 
45
- 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 {
46
62
  const body = Object.entries(options).map(([arm, option]) => `${JSON.stringify(arm)}: () => ${nodes(option.value, hoisted, pound)}`);
47
63
  return `{ ${body.join(', ')} }`;
48
64
  }
49
65
 
50
- function node(el: MessageFormatElement, hoisted: Hoisted, pound: string | null): string {
51
- if (isNumberElement(el)) return `h.n(${access(el.value)}${hoisted.arg(numberOptions(el))})`;
52
- 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));
53
94
  switch (el.type) {
54
95
  case TYPE.literal:
55
96
  return JSON.stringify(el.value);
56
97
  case TYPE.argument:
57
- return access(el.value);
98
+ return placed(el, el.value, access(el.value));
58
99
  case TYPE.pound:
59
- 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}` : ''})`);
60
103
  case TYPE.select:
61
104
  return `h.s(${access(el.value)}, ${arms(el.options, hoisted, pound)})`;
62
105
  case TYPE.plural: {
63
- // `#` 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);
64
108
  const value = access(el.value);
65
- const hash = `h.n(${value}${el.offset !== 0 ? ` - ${el.offset}` : ''})`;
66
- 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})`;
67
113
  }
68
114
  case TYPE.tag:
69
115
  return `h.t(a, ${JSON.stringify(el.value)}, ${nodes(el.children, hoisted, pound)})`;
@@ -72,19 +118,10 @@ function node(el: MessageFormatElement, hoisted: Hoisted, pound: string | null):
72
118
  }
73
119
  }
74
120
 
75
- /** Whether the message calls a helper: everything but literals and plain placeholders does. */
76
- function usesHelpers(ast: MessageFormatElement[]): boolean {
77
- let uses = false;
78
- walk(ast, (el) => {
79
- if (el.type !== TYPE.literal && el.type !== TYPE.argument) uses = true;
80
- });
81
- return uses;
82
- }
83
-
84
121
  function message(ast: MessageFormatElement[], hoisted: Hoisted): string {
85
122
  const literal = staticLiteral(ast);
86
123
  if (literal !== undefined) return JSON.stringify(literal);
87
- return `(${usesHelpers(ast) ? 'a, h' : 'a'}) => ${nodes(ast, hoisted, null)}`;
124
+ return `(a, h) => ${nodes(ast, hoisted, null)}`;
88
125
  }
89
126
 
90
127
  export interface CompiledLocale {