prosebook 0.0.1 → 0.2.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 (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +604 -0
  3. package/dist/cli.d.ts +1 -0
  4. package/dist/cli.js +508 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/config-D--RITk2.js +1208 -0
  7. package/dist/config-D--RITk2.js.map +1 -0
  8. package/dist/config-_4HfnJQu.d.ts +517 -0
  9. package/dist/config-_4HfnJQu.d.ts.map +1 -0
  10. package/dist/config.d.ts +2 -0
  11. package/dist/config.js +1 -0
  12. package/dist/copy-DXnzXsJa.d.ts +162 -0
  13. package/dist/copy-DXnzXsJa.d.ts.map +1 -0
  14. package/dist/helpers-BPuPGjQP.js +61 -0
  15. package/dist/helpers-BPuPGjQP.js.map +1 -0
  16. package/dist/index.d.ts +24 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +188 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/inspector.d.ts +37 -0
  21. package/dist/inspector.d.ts.map +1 -0
  22. package/dist/inspector.js +572 -0
  23. package/dist/inspector.js.map +1 -0
  24. package/dist/react/index.d.ts +37 -0
  25. package/dist/react/index.d.ts.map +1 -0
  26. package/dist/react/index.js +86 -0
  27. package/dist/react/index.js.map +1 -0
  28. package/dist/sarif-CI8HOokm.js +1033 -0
  29. package/dist/sarif-CI8HOokm.js.map +1 -0
  30. package/dist/scan-Ch2BfIUD.js +2561 -0
  31. package/dist/scan-Ch2BfIUD.js.map +1 -0
  32. package/dist/tools.d.ts +377 -0
  33. package/dist/tools.d.ts.map +1 -0
  34. package/dist/tools.js +4 -0
  35. package/dist/vite.d.ts +24 -0
  36. package/dist/vite.d.ts.map +1 -0
  37. package/dist/vite.js +264 -0
  38. package/dist/vite.js.map +1 -0
  39. package/package.json +148 -15
  40. package/src/cli.ts +528 -0
  41. package/src/index.ts +31 -0
  42. package/src/inspector-dialog.ts +450 -0
  43. package/src/inspector.ts +228 -0
  44. package/src/react/index.tsx +112 -0
  45. package/src/runtime/copy.ts +221 -0
  46. package/src/runtime/helpers.ts +66 -0
  47. package/src/runtime/locale.ts +49 -0
  48. package/src/runtime/types.ts +106 -0
  49. package/src/tools/catalog.ts +449 -0
  50. package/src/tools/compile.ts +121 -0
  51. package/src/tools/config.ts +211 -0
  52. package/src/tools/diff.ts +84 -0
  53. package/src/tools/entry.ts +325 -0
  54. package/src/tools/extract.ts +694 -0
  55. package/src/tools/generate.ts +257 -0
  56. package/src/tools/icu.ts +211 -0
  57. package/src/tools/index.ts +91 -0
  58. package/src/tools/lint.ts +865 -0
  59. package/src/tools/lock.ts +95 -0
  60. package/src/tools/mf1.ts +246 -0
  61. package/src/tools/mf2.ts +752 -0
  62. package/src/tools/migrate.ts +138 -0
  63. package/src/tools/presets.ts +159 -0
  64. package/src/tools/pseudo.ts +40 -0
  65. package/src/tools/sarif.ts +80 -0
  66. package/src/tools/scan.ts +303 -0
  67. package/src/tools/syntax.ts +24 -0
  68. package/src/tools/types.ts +137 -0
  69. package/src/vite.ts +296 -0
  70. package/index.js +0 -1
@@ -0,0 +1,449 @@
1
+ /**
2
+ * Reads the catalog and its translations into one checked model.
3
+ *
4
+ * Every message is parsed as ICU here rather than at runtime, which is what lets the generator hand
5
+ * TypeScript exact argument types, compile messages to plain functions, and fail the build where a
6
+ * translation's placeholders drifted from the source instead of dropping a value off the screen.
7
+ */
8
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
9
+ import { join, relative, resolve } from 'node:path';
10
+
11
+ import { isMap, isScalar, LineCounter, parseDocument, parse as parseYaml, Scalar } from 'yaml';
12
+
13
+ import type { ResolvedConfig } from './config.ts';
14
+ import { argsOf, formatProblems, formatsOf, staticLiteral, tagsOf, type MessageFormatElement } from './icu.ts';
15
+ import { readMessage } from './syntax.ts';
16
+
17
+ export interface Translation {
18
+ text: string;
19
+ ast: MessageFormatElement[];
20
+ file: string;
21
+ }
22
+
23
+ export interface Message {
24
+ key: string;
25
+ /** The first segment of the key, which names its file. */
26
+ area: string;
27
+ file: string;
28
+ kind?: string;
29
+ surface?: string;
30
+ /** One sentence telling a translator where the string appears. */
31
+ context?: string;
32
+ /** What each placeholder holds, keyed by argument name. */
33
+ argDocs: Map<string, string>;
34
+ /** Completes something beside it rather than standing as a sentence, so sentence rules do not apply. */
35
+ fragment: boolean;
36
+ /** Lint rules the entry is exempt from, each with the reason. */
37
+ allow: Map<string, string>;
38
+ /** A change someone asked for, left for whoever edits the entry next, and deleted once it is made. */
39
+ comment?: string;
40
+ /** Asked to go: the entry, its translations and the code that reads it. */
41
+ remove: boolean;
42
+ /** The source message, in ICU MessageFormat. */
43
+ text: string;
44
+ ast: MessageFormatElement[];
45
+ /** Argument name to TypeScript type. */
46
+ args: Map<string, string>;
47
+ /** How each formatted argument is styled, for the doc comment at the call site. */
48
+ argFormats: Map<string, string>;
49
+ tags: string[];
50
+ /** Translations by locale. */
51
+ translations: Map<string, Translation>;
52
+ }
53
+
54
+ export interface Problem {
55
+ severity: 'error' | 'warn';
56
+ /** Relative to the project root; none for a problem that isn't in a file. */
57
+ file?: string;
58
+ /** The entry it is about, in `file`; none for the file as a whole. */
59
+ key?: string;
60
+ message: string;
61
+ }
62
+
63
+ /** `file key`, as the CLI names where a problem is. */
64
+ export function whereOf(problem: Problem): string {
65
+ return [problem.file, problem.key].filter((s) => s !== undefined).join(' ');
66
+ }
67
+
68
+ export interface Catalog {
69
+ messages: Message[];
70
+ /** The translated locales found under `locales`, without the source. */
71
+ locales: string[];
72
+ problems: Problem[];
73
+ }
74
+
75
+ export class CatalogError extends Error {
76
+ readonly problems: Problem[];
77
+
78
+ constructor(problems: Problem[]) {
79
+ super(`the catalog has ${problems.length} problem(s):\n ${problems.map((p) => `${whereOf(p)}: ${p.message}`).join('\n ')}`);
80
+ this.problems = problems;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * A key is dotted lowercase, two segments at least, and its first segment names the file it lives
86
+ * in. Keys are read at every call site, so they say what the string is for: `cart.checkout.title`.
87
+ */
88
+ export const KEY_PATTERN = /^[a-z0-9]+(?:[-_][a-z0-9]+)*(?:\.[a-z0-9]+(?:[-_][a-z0-9]+)*)+$/;
89
+
90
+ const FIELDS = new Set(['kind', 'surface', 'context', 'args', 'fragment', 'allow', 'comment', 'remove']);
91
+
92
+ /**
93
+ * Loads and checks the catalog. Throws a `CatalogError` when anything is an error; warnings ride along.
94
+ * `unsaved` holds file contents by absolute path to read in place of what is on disk, so an edit can be
95
+ * checked before it is written; a translation file in it that isn't on disk yet is read too.
96
+ */
97
+ export function loadCatalog(config: ResolvedConfig, unsaved?: ReadonlyMap<string, string>): Catalog {
98
+ const read = (path: string) => unsaved?.get(resolve(path)) ?? readFileSync(path, 'utf8');
99
+ const problems: Problem[] = [];
100
+ const error = (message: string, file?: string, key?: string) => problems.push({ severity: 'error', file, key, message });
101
+ const byKey = new Map<string, Message>();
102
+ const rel = (path: string) => relative(config.root, path).replaceAll('\\', '/');
103
+
104
+ if (!existsSync(config.catalog)) {
105
+ error('no catalog folder here: run `prosebook init`, or set `catalog` in the config', rel(config.catalog));
106
+ throw new CatalogError(problems);
107
+ }
108
+
109
+ for (const file of yamlFiles(config.catalog)) {
110
+ const area = file.slice(0, -'.yaml'.length);
111
+ const path = join(config.catalog, file);
112
+ const at = rel(path);
113
+ for (const [key, value] of Object.entries(readEntries(read(path), (m) => error(m, at)))) {
114
+ if (!KEY_PATTERN.test(key)) {
115
+ error('a key is dotted lowercase with two segments at least: `area.thing`', at, key);
116
+ continue;
117
+ }
118
+ if (key.slice(0, key.indexOf('.')) !== area) {
119
+ error(`a key in ${file} starts with "${area}."`, at, key);
120
+ continue;
121
+ }
122
+ if (byKey.has(key)) {
123
+ error('duplicate key', at, key);
124
+ continue;
125
+ }
126
+ const message = readEntry(key, area, at, value, config, (m) => error(m, at, key));
127
+ if (!message) continue;
128
+ byKey.set(key, message);
129
+ // Asked of whoever edits next, often an agent, which finds them among the warnings of every run.
130
+ const warn = (m: string) => problems.push({ severity: 'warn', file: at, key, message: m });
131
+ if (message.remove) warn('marked for removal: delete the entry, its translations and the code that reads it');
132
+ if (message.comment !== undefined) warn(`a comment to act on, then delete: ${message.comment}`);
133
+ }
134
+ }
135
+
136
+ const created = [...(unsaved?.keys() ?? [])].filter((path) => !existsSync(path));
137
+ const locales = readLocales(config, byKey, problems, rel, read, created);
138
+ if (problems.some((p) => p.severity === 'error')) throw new CatalogError(problems);
139
+ return { messages: [...byKey.values()].sort((a, b) => (a.key < b.key ? -1 : 1)), locales, problems };
140
+ }
141
+
142
+ function readEntry(
143
+ key: string,
144
+ area: string,
145
+ file: string,
146
+ value: unknown,
147
+ config: ResolvedConfig,
148
+ error: (message: string) => void,
149
+ ): Message | undefined {
150
+ // `key: message` is the shorthand for an entry with nothing to say about itself.
151
+ const entry = (typeof value === 'string' ? { [config.source]: value } : value) as Record<string, unknown> | null;
152
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
153
+ error(`an entry is a message, or a mapping holding \`${config.source}\` and what the string is`);
154
+ return undefined;
155
+ }
156
+ let ok = true;
157
+ const fail = (message: string) => {
158
+ error(message);
159
+ ok = false;
160
+ };
161
+ for (const field of Object.keys(entry))
162
+ if (!FIELDS.has(field) && field !== config.source) fail(`"${field}" is not a field: ${[...FIELDS, config.source].join(', ')}`);
163
+
164
+ const text = entry[config.source];
165
+ if (typeof text !== 'string' || text === '') {
166
+ fail(`${config.source} is the message, and it is missing`);
167
+ return undefined;
168
+ }
169
+ for (const field of config.require) if (entry[field] === undefined) fail(`${field} is required (\`require\` in the config)`);
170
+
171
+ const kind = optionalString(entry, 'kind', fail);
172
+ if (kind !== undefined && !(kind in config.kinds)) fail(`kind is one of ${Object.keys(config.kinds).join(', ')}`);
173
+ const surface = optionalString(entry, 'surface', fail);
174
+ if (surface !== undefined && !(surface in config.surfaces)) fail(`surface is one of ${Object.keys(config.surfaces).join(', ')}`);
175
+ const context = optionalString(entry, 'context', fail);
176
+ if (entry.fragment !== undefined && typeof entry.fragment !== 'boolean') fail('fragment is true or false');
177
+ const comment = optionalString(entry, 'comment', fail);
178
+ if (entry.remove !== undefined && typeof entry.remove !== 'boolean') fail('remove is true or false');
179
+
180
+ let ast: MessageFormatElement[];
181
+ try {
182
+ ast = readMessage(config.messageFormat, text, config.source);
183
+ } catch (e) {
184
+ fail(syntaxError(config, text, e));
185
+ return undefined;
186
+ }
187
+ for (const problem of formatProblems(ast)) fail(problem);
188
+ const args = argsOf(ast, (name) => fail(`"${name}" is used as two different types`));
189
+ const argDocs = stringMap(entry, 'args', fail);
190
+ // An undocumented placeholder is fine unless context is required; a doc for an argument that no
191
+ // longer exists is always stale.
192
+ if (config.require.includes('context'))
193
+ for (const name of args.keys()) if (!argDocs.has(name)) fail(`args.${name} is missing: say what the placeholder holds`);
194
+
195
+ for (const name of argDocs.keys()) if (!args.has(name)) fail(`args.${name} is not an argument of this message`);
196
+
197
+ if (!ok) return undefined;
198
+ return {
199
+ key,
200
+ area,
201
+ file,
202
+ kind,
203
+ surface,
204
+ context,
205
+ argDocs,
206
+ fragment: entry.fragment === true,
207
+ allow: stringMap(entry, 'allow', fail),
208
+ comment,
209
+ remove: entry.remove === true,
210
+ text,
211
+ ast,
212
+ args,
213
+ argFormats: formatsOf(ast),
214
+ tags: tagsOf(ast),
215
+ translations: new Map(),
216
+ };
217
+ }
218
+
219
+ function readLocales(
220
+ config: ResolvedConfig,
221
+ byKey: Map<string, Message>,
222
+ problems: Problem[],
223
+ rel: (p: string) => string,
224
+ read: (path: string) => string,
225
+ created: string[],
226
+ ): string[] {
227
+ if (!existsSync(config.locales)) return [];
228
+ const files = new Map<string, string[]>();
229
+ for (const name of readdirSync(config.locales).sort()) {
230
+ const path = join(config.locales, name);
231
+ const isDir = statSync(path).isDirectory();
232
+ if (!isDir && !name.endsWith('.yaml')) continue;
233
+ const locale = isDir ? name : name.slice(0, -'.yaml'.length);
234
+ const found = isDir ? yamlFiles(path).map((f) => join(path, f)) : [path];
235
+ files.set(locale, [...(files.get(locale) ?? []), ...found]);
236
+ }
237
+ // A translation file an edit would create, read as if it were there already.
238
+ for (const path of created) {
239
+ const inside = relative(config.locales, path).replaceAll('\\', '/');
240
+ if (inside.startsWith('..') || !inside.endsWith('.yaml')) continue;
241
+ const [first = '', second] = inside.split('/');
242
+ const locale = second === undefined ? first.slice(0, -'.yaml'.length) : first;
243
+ files.set(locale, [...(files.get(locale) ?? []), path]);
244
+ }
245
+ files.delete(config.source);
246
+
247
+ for (const [locale, paths] of files) {
248
+ for (const path of paths) {
249
+ const file = rel(path);
250
+ const error = (message: string, key?: string) => problems.push({ severity: 'error', file, key, message });
251
+ for (const [key, value] of Object.entries(readEntries(read(path), error))) {
252
+ const message = byKey.get(key);
253
+ if (message === undefined) {
254
+ problems.push({ severity: 'warn', file, key, message: 'no such key in the catalog, so this translation is unused' });
255
+ continue;
256
+ }
257
+ if (typeof value !== 'string' || value === '') {
258
+ error('a translation is the message, as a string', key);
259
+ continue;
260
+ }
261
+ let ast: MessageFormatElement[];
262
+ try {
263
+ ast = readMessage(config.messageFormat, value, locale);
264
+ } catch (e) {
265
+ error(syntaxError(config, value, e), key);
266
+ continue;
267
+ }
268
+ const drift = driftFrom(message, ast);
269
+ if (drift !== undefined) {
270
+ error(drift, key);
271
+ continue;
272
+ }
273
+ for (const problem of formatProblems(ast)) error(problem, key);
274
+ message.translations.set(locale, { text: value, ast, file });
275
+ }
276
+ }
277
+ }
278
+ return [...files.keys()];
279
+ }
280
+
281
+ /** How a translation's placeholders or tags differ from the source, or nothing. */
282
+ function driftFrom(message: Message, ast: MessageFormatElement[]): string | undefined {
283
+ const args = argsOf(ast);
284
+ const missing = [...message.args.keys()].filter((a) => !args.has(a));
285
+ const extra = [...args.keys()].filter((a) => !message.args.has(a));
286
+ const retyped = [...args]
287
+ .filter(([a, type]) => message.args.has(a) && message.args.get(a) !== type && !type.includes('string &'))
288
+ .map(([a]) => a);
289
+ const tags = tagsOf(ast);
290
+ const parts = [
291
+ missing.length > 0 ? `missing {${missing.join('}, {')}}` : '',
292
+ extra.length > 0 ? `unknown {${extra.join('}, {')}}` : '',
293
+ retyped.length > 0 ? `{${retyped.join('}, {')}} used as another type` : '',
294
+ tags.join() === message.tags.join()
295
+ ? ''
296
+ : `tags are <${tags.join('>, <') || 'none'}>, the source has <${message.tags.join('>, <') || 'none'}>`,
297
+ ].filter(Boolean);
298
+ return parts.length > 0 ? `differs from the source: ${parts.join('; ')}` : undefined;
299
+ }
300
+
301
+ function syntaxError(config: ResolvedConfig, text: string, e: unknown): string {
302
+ // MF2's reader words its own problems; see mf2.ts.
303
+ if (config.messageFormat === 2) return (e as Error).message;
304
+ return icuError(text, e);
305
+ }
306
+
307
+ function icuError(text: string, e: unknown): string {
308
+ const hyphenated = /select\s*,/.test(text) ? /[,{]\s*([a-z0-9]+(?:-[a-z0-9]+)+)\s*\{/.exec(text) : null;
309
+ if (hyphenated) return `names a select arm "${hyphenated[1]}": an arm's name takes no hyphen, so write it with an underscore`;
310
+ const reason = (e as Error).message;
311
+ if (/TAG/.test(reason)) return `is not valid ICU (${reason}): a literal < is written '<'`;
312
+ return `is not valid ICU MessageFormat: ${reason}`;
313
+ }
314
+
315
+ function yamlFiles(dir: string): string[] {
316
+ return readdirSync(dir)
317
+ .filter((f) => f.endsWith('.yaml'))
318
+ .sort();
319
+ }
320
+
321
+ /**
322
+ * The file's entries. YAML 1.1 and 1.2 disagree about bare scalars: 1.1 reads `n`, `on` and `yes` as
323
+ * booleans and `12:30` as a number. Translation tools are often 1.1, so a file that reads differently
324
+ * under each fails here, naming the value to quote, rather than corrupting quietly in the next tool.
325
+ */
326
+ function readEntries(text: string, error: (message: string) => void): Record<string, unknown> {
327
+ let raw: unknown;
328
+ try {
329
+ raw = parseYaml(text);
330
+ } catch (e) {
331
+ error(`is not valid YAML: ${(e as Error).message}`);
332
+ return {};
333
+ }
334
+ if (raw === null || raw === undefined) return {};
335
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
336
+ error('a catalog file is a mapping of key to entry');
337
+ return {};
338
+ }
339
+ try {
340
+ const found = divergence(raw, parseYaml(text, { version: '1.1' }), '');
341
+ if (found !== undefined) error(found);
342
+ } catch (e) {
343
+ error(`does not parse as YAML 1.1, which another tool may well be: ${(e as Error).message}`);
344
+ }
345
+ return raw as Record<string, unknown>;
346
+ }
347
+
348
+ function divergence(a: unknown, b: unknown, path: string): string | undefined {
349
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') {
350
+ if (Object.is(a, b)) return undefined;
351
+ return `${path} reads as ${JSON.stringify(a)} under YAML 1.2 and ${JSON.stringify(b)} under YAML 1.1: quote it`;
352
+ }
353
+ const left = a as Record<string, unknown>;
354
+ const right = b as Record<string, unknown>;
355
+ for (const key of new Set([...Object.keys(left), ...Object.keys(right)])) {
356
+ const here = path === '' ? key : `${path}.${key}`;
357
+ if (key in left !== key in right) return `${here} is a key under YAML ${key in left ? '1.2' : '1.1'} only: quote it`;
358
+ const found = divergence(left[key], right[key], here);
359
+ if (found !== undefined) return found;
360
+ }
361
+ return undefined;
362
+ }
363
+
364
+ function optionalString(entry: Record<string, unknown>, field: string, fail: (m: string) => void): string | undefined {
365
+ const value = entry[field];
366
+ if (value === undefined) return undefined;
367
+ if (typeof value !== 'string' || value.trim() === '') {
368
+ fail(`${field} is a non-empty string`);
369
+ return undefined;
370
+ }
371
+ return value.trim();
372
+ }
373
+
374
+ function stringMap(entry: Record<string, unknown>, field: string, fail: (m: string) => void): Map<string, string> {
375
+ const out = new Map<string, string>();
376
+ const value = entry[field];
377
+ if (value === undefined) return out;
378
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
379
+ fail(`${field} is a mapping of name to sentence`);
380
+ return out;
381
+ }
382
+ for (const [name, said] of Object.entries(value)) {
383
+ if (typeof said !== 'string' || said.trim() === '') fail(`${field}.${name} is a sentence`);
384
+ else out.set(name, said.trim());
385
+ }
386
+ return out;
387
+ }
388
+
389
+ export interface EntryLocation {
390
+ /** Absolute. */
391
+ file: string;
392
+ /** From 1. */
393
+ line: number;
394
+ /** From 1. */
395
+ column: number;
396
+ }
397
+
398
+ /**
399
+ * Where a message's entry is written: its translation in `locale` when there is one, else the catalog.
400
+ * Read from the file as it is now, so an entry moved since the catalog was loaded is still found.
401
+ */
402
+ export function entryLocation(config: ResolvedConfig, message: Message, locale?: string): EntryLocation {
403
+ const file = (locale === undefined ? undefined : message.translations.get(locale)?.file) ?? message.file;
404
+ return { file: join(config.root, file), ...entryLocator(config.root)(file, message.key) };
405
+ }
406
+
407
+ /**
408
+ * Finds a key's line and column in a catalog or translation file, given relative to `root`. Each file
409
+ * is read once, so a report that places many entries parses each file once. A key it can't find, or no
410
+ * key, is the top of the file.
411
+ *
412
+ * Given an `offset` into the message (under `field`, in an entry that is a mapping), it is that
413
+ * character, when the value is written in the file as it reads up to there: plain, or quoted on one
414
+ * line with nothing escaped before it. Otherwise it is the key.
415
+ */
416
+ export function entryLocator(root: string): (file: string, key?: string, offset?: number, field?: string) => Omit<EntryLocation, 'file'> {
417
+ const read = new Map<string, { doc: ReturnType<typeof parseDocument>; lines: LineCounter; source: string }>();
418
+ return (file, key, offset, field) => {
419
+ let parsed = read.get(file);
420
+ if (parsed === undefined) {
421
+ const path = join(root, file);
422
+ const lines = new LineCounter();
423
+ const source = existsSync(path) ? readFileSync(path, 'utf8') : '';
424
+ parsed = { doc: parseDocument(source, { lineCounter: lines }), lines, source };
425
+ read.set(file, parsed);
426
+ }
427
+ const { doc, lines, source } = parsed;
428
+ const pair =
429
+ key !== undefined && isMap(doc.contents) ? doc.contents.items.find((p) => isScalar(p.key) && p.key.value === key) : undefined;
430
+ const value = isMap(pair?.value) && field !== undefined ? pair.value.get(field, true) : pair?.value;
431
+ if (offset !== undefined && isScalar(value) && typeof value.value === 'string' && value.range) {
432
+ const quoted = value.type === Scalar.QUOTE_DOUBLE || value.type === Scalar.QUOTE_SINGLE;
433
+ const content = value.range[0] + (quoted ? 1 : 0);
434
+ if (source.slice(content, content + offset) === value.value.slice(0, offset)) {
435
+ const { line, col } = lines.linePos(content + offset);
436
+ return { line, column: col };
437
+ }
438
+ }
439
+ const start = isScalar(pair?.key) ? pair.key.range?.[0] : undefined;
440
+ if (start === undefined) return { line: 1, column: 1 };
441
+ const { line, col } = lines.linePos(start);
442
+ return { line, column: col };
443
+ };
444
+ }
445
+
446
+ /** The source message as a reader sees it with no placeholders filled: for docs and reports. */
447
+ export function previewOf(message: Message): string {
448
+ return staticLiteral(message.ast) ?? message.text;
449
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Compiles ICU ASTs to JavaScript.
3
+ *
4
+ * A message with no placeholders becomes its string. Any other becomes `(a, h) => …`, where `a` holds
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
8
+ * hoisted to module constants, so a skeleton costs nothing at runtime and its formatter is built once.
9
+ */
10
+ import { isDateElement, isNumberElement, isTimeElement } from '@formatjs/icu-messageformat-parser';
11
+
12
+ import { dateOptions, numberOptions, staticLiteral, TYPE, walk, type MessageFormatElement } from './icu.ts';
13
+
14
+ export type Entry = readonly [key: string, ast: MessageFormatElement[]];
15
+
16
+ /** Module-level constants for formatting options, one per distinct object. */
17
+ class Hoisted {
18
+ private readonly byJson = new Map<string, string>();
19
+
20
+ /** `, oN` naming the hoisted options, or nothing when there are none. */
21
+ arg(options: object | undefined): string {
22
+ // `{}` (a bare `:number`) formats as no options do, so it needs no constant.
23
+ if (options === undefined || Object.keys(options).length === 0) return '';
24
+ const json = JSON.stringify(options);
25
+ let name = this.byJson.get(json);
26
+ if (name === undefined) this.byJson.set(json, (name = `o${this.byJson.size}`));
27
+ return `, ${name}`;
28
+ }
29
+
30
+ declarations(): string[] {
31
+ return [...this.byJson].map(([json, name]) => `const ${name} = ${json};`);
32
+ }
33
+ }
34
+
35
+ function access(name: string): string {
36
+ return /^[A-Za-z_$][\w$]*$/.test(name) ? `a.${name}` : `a[${JSON.stringify(name)}]`;
37
+ }
38
+
39
+ function nodes(elements: MessageFormatElement[], hoisted: Hoisted, pound: string | null): string {
40
+ if (elements.length === 0) return '""';
41
+ const parts = elements.map((el) => node(el, hoisted, pound));
42
+ return parts.length === 1 ? parts[0]! : `[${parts.join(', ')}]`;
43
+ }
44
+
45
+ function arms(options: Record<string, { value: MessageFormatElement[] }>, hoisted: Hoisted, pound: string | null): string {
46
+ const body = Object.entries(options).map(([arm, option]) => `${JSON.stringify(arm)}: () => ${nodes(option.value, hoisted, pound)}`);
47
+ return `{ ${body.join(', ')} }`;
48
+ }
49
+
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))})`;
53
+ switch (el.type) {
54
+ case TYPE.literal:
55
+ return JSON.stringify(el.value);
56
+ case TYPE.argument:
57
+ return access(el.value);
58
+ case TYPE.pound:
59
+ return pound ?? '"#"';
60
+ case TYPE.select:
61
+ return `h.s(${access(el.value)}, ${arms(el.options, hoisted, pound)})`;
62
+ case TYPE.plural: {
63
+ // `#` is the plural's own value less its offset, formatted as a number.
64
+ 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)})`;
67
+ }
68
+ case TYPE.tag:
69
+ return `h.t(a, ${JSON.stringify(el.value)}, ${nodes(el.children, hoisted, pound)})`;
70
+ default:
71
+ throw new Error(`prosebook: no compiler for ICU element type ${(el as MessageFormatElement).type}`);
72
+ }
73
+ }
74
+
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
+ function message(ast: MessageFormatElement[], hoisted: Hoisted): string {
85
+ const literal = staticLiteral(ast);
86
+ if (literal !== undefined) return JSON.stringify(literal);
87
+ return `(${usesHelpers(ast) ? 'a, h' : 'a'}) => ${nodes(ast, hoisted, null)}`;
88
+ }
89
+
90
+ export interface CompiledLocale {
91
+ /** `const oN = {…};` lines the messages refer to. */
92
+ declarations: string[];
93
+ /** `"key": message,` lines. */
94
+ rows: string[];
95
+ }
96
+
97
+ /** One locale's messages as code, keeping only `keep` when given. */
98
+ export function compileLocale(entries: Iterable<Entry>, keep?: (key: string) => boolean): CompiledLocale {
99
+ const hoisted = new Hoisted();
100
+ const rows: string[] = [];
101
+ for (const [key, ast] of entries) {
102
+ if (keep && !keep(key)) continue;
103
+ rows.push(` ${JSON.stringify(key)}: ${message(ast, hoisted)},`);
104
+ }
105
+ return { declarations: hoisted.declarations(), rows };
106
+ }
107
+
108
+ /** The locale as an ES module whose default export is its messages. */
109
+ export function localeModule(compiled: CompiledLocale, banner: string, ts: boolean): string {
110
+ const head = ts ? `${banner}\nimport type { CompiledMessages } from 'prosebook';\n` : `${banner}\n`;
111
+ // `as const` keeps `style: "currency"` a literal, which is what `Intl`'s option types ask for.
112
+ const declarations = ts ? compiled.declarations.map((d) => d.replace(/;$/, ' as const;')) : compiled.declarations;
113
+ const consts = declarations.length > 0 ? `\n${declarations.join('\n')}\n` : '';
114
+ const close = ts ? '} satisfies CompiledMessages;' : '};';
115
+ return `${head}${consts}\nexport default {\n${compiled.rows.join('\n')}\n${close}\n`;
116
+ }
117
+
118
+ /** The locale as one expression, for a bundler to inline in place of the module. */
119
+ export function localeExpression(compiled: CompiledLocale): string {
120
+ return `(() => {\n${compiled.declarations.join('\n')}\nreturn {\n${compiled.rows.join('\n')}\n};\n})()`;
121
+ }