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,694 @@
1
+ /**
2
+ * `prosebook extract`: moves hardcoded JSX text into the catalog and rewrites the code to read it.
3
+ *
4
+ * It works on runs, not fragments. The children of an element that together make one sentence, text,
5
+ * simple expressions and inline elements holding only text, become one message: `Hi {user.name}, <a
6
+ * href="/x">go</a>!` is `Hi {name}, <a>go</a>!`, so a translator can reorder the whole sentence. A run
7
+ * becomes `<Text id=… />`, which needs no hook; a readable attribute becomes `t('…')`, with `useCopy()`
8
+ * added to its component. Keys, kinds, surfaces and contexts are suggestions, marked for review.
9
+ */
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
11
+ import { basename, dirname, extname, join, relative } from 'node:path';
12
+
13
+ import { parseSync } from 'oxc-parser';
14
+ import { stringify } from 'yaml';
15
+
16
+ import { CatalogError, KEY_PATTERN, loadCatalog } from './catalog.ts';
17
+ import type { ResolvedConfig } from './config.ts';
18
+ import { parseMessage } from './icu.ts';
19
+ import { singularOf } from './lint.ts';
20
+ import { looksLikeCode } from './scan.ts';
21
+ import { writeMessage } from './syntax.ts';
22
+ import { syntaxKind, typescriptOracle, type Span, type ValueKind } from './types.ts';
23
+
24
+ // oxlint-disable-next-line typescript/no-explicit-any -- a loose ESTree node: the walk reads whichever fields each node type has
25
+ type Node = { type: string; start: number; end: number; [field: string]: any };
26
+
27
+ export interface Extraction {
28
+ file: string;
29
+ line: number;
30
+ column: number;
31
+ key: string;
32
+ /** The ICU message. */
33
+ message: string;
34
+ /** Whether an entry with this key and message already exists, so none is added. */
35
+ reused: boolean;
36
+ where: string;
37
+ /** The code that replaces the hardcoded text. */
38
+ replacement: string;
39
+ }
40
+
41
+ export interface NewEntry {
42
+ key: string;
43
+ area: string;
44
+ message: string;
45
+ kind?: string;
46
+ surface?: string;
47
+ context: string;
48
+ args: Record<string, string>;
49
+ }
50
+
51
+ interface Edit {
52
+ start: number;
53
+ end: number;
54
+ text: string;
55
+ }
56
+
57
+ export interface FilePlan {
58
+ path: string;
59
+ file: string;
60
+ code: string;
61
+ edits: Edit[];
62
+ extractions: Extraction[];
63
+ warnings: string[];
64
+ }
65
+
66
+ export interface ExtractPlan {
67
+ files: FilePlan[];
68
+ entries: NewEntry[];
69
+ /** What said which placeholders are numbers and dates: `TypeScript`, or `syntax` alone. */
70
+ types: string;
71
+ }
72
+
73
+ const MODULE = 'prosebook/react';
74
+ const NON_PROSE = new Set(['code', 'pre', 'kbd', 'samp', 'script', 'style', 'svg', 'path', 'Text']);
75
+ const LETTER = /\p{L}/u;
76
+
77
+ /** Elements that sit inside a sentence, and so become a tag in its message rather than ending it. */
78
+ const INLINE = new Set([
79
+ 'a',
80
+ 'abbr',
81
+ 'b',
82
+ 'bdi',
83
+ 'cite',
84
+ 'del',
85
+ 'dfn',
86
+ 'em',
87
+ 'i',
88
+ 'ins',
89
+ 'kbd',
90
+ 'mark',
91
+ 'q',
92
+ 's',
93
+ 'small',
94
+ 'span',
95
+ 'strong',
96
+ 'sub',
97
+ 'sup',
98
+ 'time',
99
+ 'u',
100
+ 'var',
101
+ ]);
102
+ const isInline = (name: string) => INLINE.has(name) || /^[A-Z]\w*Link$|^Link$/.test(name);
103
+
104
+ /** Elements whose text has an obvious kind and surface. */
105
+ const ROLES: Record<string, [kind: string, surface: string]> = {
106
+ button: ['action', 'action'],
107
+ h1: ['name', 'heading'],
108
+ h2: ['name', 'heading'],
109
+ h3: ['name', 'heading'],
110
+ h4: ['name', 'heading'],
111
+ h5: ['name', 'heading'],
112
+ h6: ['name', 'heading'],
113
+ th: ['name', 'heading'],
114
+ legend: ['name', 'heading'],
115
+ caption: ['name', 'heading'],
116
+ label: ['name', 'label'],
117
+ option: ['name', 'choice'],
118
+ title: ['name', 'title'],
119
+ };
120
+
121
+ /** Readable attributes: the kind and surface their value has, and the key suffix that says which it is. */
122
+ const ATTRIBUTE_ROLES: Record<string, [kind: string | undefined, surface: string, suffix: string]> = {
123
+ placeholder: [undefined, 'placeholder', 'placeholder'],
124
+ title: ['name', 'tooltip', 'tooltip'],
125
+ 'aria-label': ['name', 'aria', 'aria'],
126
+ 'aria-description': ['description', 'aria', 'aria-description'],
127
+ 'aria-placeholder': [undefined, 'aria', 'aria-placeholder'],
128
+ alt: [undefined, 'aria', 'alt'],
129
+ label: ['name', 'label', 'label'],
130
+ };
131
+
132
+ /** The area a file's strings go in: its name in kebab case, or its folder's for an `index`. */
133
+ export function areaOf(file: string): string {
134
+ let name = basename(file, extname(file));
135
+ if (['index', 'page', 'layout', 'route', 'main'].includes(name)) name = basename(dirname(file));
136
+ const kebab = name
137
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
138
+ .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
139
+ .toLowerCase()
140
+ .replace(/[^a-z0-9]+/g, '-')
141
+ .replace(/^-+|-+$/g, '');
142
+ return kebab === '' ? 'app' : kebab;
143
+ }
144
+
145
+ /** A key segment from the words of a message: its first four, plain ASCII, lowercase. */
146
+ export function slugOf(message: string): string {
147
+ const words = message
148
+ .replace(/<\/?[\w-]+>|\{[^}]*\}/g, ' ')
149
+ .replace(/ı/g, 'i')
150
+ .normalize('NFKD')
151
+ .replace(/[̀-ͯ]/g, '')
152
+ .toLowerCase()
153
+ .replace(/[^a-z0-9]+/g, ' ')
154
+ .trim()
155
+ .split(' ')
156
+ .filter(Boolean)
157
+ .slice(0, 4);
158
+ return words.length === 0 ? 'text' : words.join('-');
159
+ }
160
+
161
+ /**
162
+ * A count followed by a plural noun, `{n, number} files`, as the plural it needs: `{n, plural, one {#
163
+ * file} other {# files}}`. English only, and a draft: the TODO above the entry asks for a look.
164
+ */
165
+ export function draftPlurals(message: string): string {
166
+ return message.replace(/\{(\w+), number\} (\p{L}+)(?![\p{L}'])/gu, (whole, name: string, word: string) => {
167
+ const one = singularOf(word);
168
+ return one === undefined ? whole : `{${name}, plural, one {# ${one}} other {# ${word}}}`;
169
+ });
170
+ }
171
+
172
+ /** A message built as MF1, as the catalog writes messages: an MF2 catalog gets it in MF2. */
173
+ function inSyntax(config: ResolvedConfig, mf1: string): string {
174
+ return config.messageFormat === 2 ? writeMessage(2, parseMessage(mf1), config.source) : mf1;
175
+ }
176
+
177
+ /** Text written literally inside an ICU message. */
178
+ function escapeIcu(text: string): string {
179
+ return text.replace(/[{}<]/g, (c) => `'${c}'`);
180
+ }
181
+
182
+ /** JSX text as React renders it: lines trimmed where they meet a line break, blank lines dropped. */
183
+ function jsxText(value: string): string {
184
+ const lines = value.split(/\r\n|\n|\r/);
185
+ // As Babel does: a value with no visible characters still keeps its one line, so a lone space renders.
186
+ let lastNonEmpty = 0;
187
+ lines.forEach((line, i) => {
188
+ if (/[^ \t]/.test(line)) lastNonEmpty = i;
189
+ });
190
+ let out = '';
191
+ lines.forEach((line, i) => {
192
+ let trimmed = line.replace(/\t/g, ' ');
193
+ if (i !== 0) trimmed = trimmed.replace(/^ +/, '');
194
+ if (i !== lines.length - 1) trimmed = trimmed.replace(/ +$/, '');
195
+ if (trimmed === '') return;
196
+ out += i === lastNonEmpty ? trimmed : `${trimmed} `;
197
+ });
198
+ return out;
199
+ }
200
+
201
+ const SIMPLE_EXPRESSIONS = new Set([
202
+ 'Identifier',
203
+ 'MemberExpression',
204
+ 'CallExpression',
205
+ 'TemplateLiteral',
206
+ 'Literal',
207
+ 'BinaryExpression',
208
+ 'TSNonNullExpression',
209
+ 'ChainExpression',
210
+ ]);
211
+
212
+ /** A value that reads as a placeholder: no JSX and no functions inside it, which would be markup or logic. */
213
+ function simple(expr: Node): boolean {
214
+ if (!SIMPLE_EXPRESSIONS.has(expr.type)) return false;
215
+ let found = false;
216
+ const walk = (node: Node) => {
217
+ if (found) return;
218
+ if (/^JSX|FunctionExpression$/.test(node.type)) found = true;
219
+ for (const value of Object.values(node)) {
220
+ if (Array.isArray(value)) value.forEach((v) => isNode(v) && walk(v));
221
+ else if (isNode(value)) walk(value);
222
+ }
223
+ };
224
+ walk(expr);
225
+ return !found;
226
+ }
227
+
228
+ /** Every name bound inside a node: declarations, parameters, catch clauses, imports. With `deep` false, only its own scope's. */
229
+ function bindingsIn(root: Node, deep = true): Set<string> {
230
+ const names = new Set<string>();
231
+ const pattern = (p: Node | null | undefined) => {
232
+ if (!p) return;
233
+ if (p.type === 'Identifier') names.add(p.name as string);
234
+ else if (p.type === 'ObjectPattern')
235
+ for (const prop of p.properties as Node[]) pattern(prop.type === 'RestElement' ? prop : prop.value);
236
+ else if (p.type === 'ArrayPattern') for (const el of p.elements as (Node | null)[]) pattern(el);
237
+ else if (p.type === 'AssignmentPattern') pattern(p.left);
238
+ else if (p.type === 'RestElement') pattern(p.argument);
239
+ };
240
+ const walk = (node: Node, top: boolean) => {
241
+ if (node.type === 'VariableDeclarator') pattern(node.id);
242
+ if (node.type === 'ImportSpecifier' || node.type === 'ImportDefaultSpecifier' || node.type === 'ImportNamespaceSpecifier')
243
+ pattern(node.local);
244
+ if ((node.type === 'FunctionDeclaration' || node.type === 'ClassDeclaration') && node.id) pattern(node.id);
245
+ if (node.type === 'CatchClause') pattern(node.param);
246
+ const isFunction = /Function(?:Declaration|Expression)$|^ArrowFunctionExpression$/.test(node.type);
247
+ if (isFunction && (deep || top)) for (const param of node.params as Node[]) pattern(param);
248
+ if (isFunction && !deep && !top) return;
249
+ for (const value of Object.values(node)) {
250
+ if (Array.isArray(value)) {
251
+ for (const child of value) if (isNode(child)) walk(child, false);
252
+ } else if (isNode(value)) {
253
+ walk(value, false);
254
+ }
255
+ }
256
+ };
257
+ walk(root, true);
258
+ return names;
259
+ }
260
+
261
+ function elementName(el: Node): string | undefined {
262
+ const name = el.openingElement?.name as Node | undefined;
263
+ return name?.type === 'JSXIdentifier' ? (name.name as string) : undefined;
264
+ }
265
+
266
+ function isNode(value: unknown): value is Node {
267
+ return typeof value === 'object' && value !== null && typeof (value as Node).type === 'string';
268
+ }
269
+
270
+ interface Context {
271
+ config: ResolvedConfig;
272
+ area: string;
273
+ /** Every key's source message, the catalog's and those added in this run. */
274
+ known: Map<string, string>;
275
+ entries: NewEntry[];
276
+ /** The kind of value each JSX expression holds, by `start:end`. */
277
+ kinds: Map<string, ValueKind>;
278
+ }
279
+
280
+ /** Plans the extraction from one file: the edits and the entries, without writing anything. */
281
+ function planFile(path: string, ctx: Context): FilePlan {
282
+ const code = readFileSync(path, 'utf8');
283
+ const file = relative(ctx.config.root, path).replaceAll('\\', '/');
284
+ const plan: FilePlan = { path, file, code, edits: [], extractions: [], warnings: [] };
285
+ const { program } = parseSync(file, code);
286
+ const topLevel = bindingsIn(program, false);
287
+ const lines = code.split('\n');
288
+ const lineStarts: number[] = [];
289
+ let offset = 0;
290
+ for (const line of lines) {
291
+ lineStarts.push(offset);
292
+ offset += line.length + 1;
293
+ }
294
+ const position = (at: number) => {
295
+ let line = lineStarts.findIndex((s) => s > at);
296
+ line = line === -1 ? lineStarts.length : line;
297
+ return { line, column: at - lineStarts[line - 1]! + 1 };
298
+ };
299
+ const ignored = (at: number) => {
300
+ const { line } = position(at);
301
+ return (lines[line - 1] ?? '').includes('prosebook-ignore') || (lines[line - 2] ?? '').includes('prosebook-ignore');
302
+ };
303
+
304
+ let needText = false;
305
+ let needUseCopy = false;
306
+ /** Per component, by where it starts: the call that reads the catalog there. */
307
+ const hooked = new Map<number, string>();
308
+ /** Children now inside a `<Text>`, which the walk does not enter. */
309
+ const consumed = new Set<Node>();
310
+ /** Enclosing functions, innermost last: the component each belongs to, and whether it is one. */
311
+ const components: { fn: Node; name: string | undefined; isComponent: boolean }[] = [];
312
+
313
+ const componentName = (fn: Node, parent: Node | undefined): string | undefined => {
314
+ if (fn.id?.name) return fn.id.name as string;
315
+ if (parent?.type === 'VariableDeclarator' && parent.id?.type === 'Identifier') return parent.id.name as string;
316
+ return undefined;
317
+ };
318
+
319
+ const entryFor = (
320
+ message: string,
321
+ args: Record<string, string>,
322
+ role: [string | undefined, string | undefined],
323
+ context: string,
324
+ suffix?: string,
325
+ ) => {
326
+ for (const [key, text] of ctx.known) if (text === message && key.startsWith(`${ctx.area}.`)) return { key, reused: true };
327
+
328
+ const base = `${ctx.area}.${slugOf(message)}${suffix ? `-${suffix}` : ''}`;
329
+ let key = base;
330
+ for (let n = 2; ctx.known.has(key); n++) key = `${base}-${n}`;
331
+ ctx.known.set(key, message);
332
+ const [kind, surface] = role;
333
+ ctx.entries.push({
334
+ key,
335
+ area: ctx.area,
336
+ message,
337
+ ...(kind !== undefined && kind in ctx.config.kinds ? { kind } : {}),
338
+ ...(surface !== undefined && surface in ctx.config.surfaces ? { surface } : {}),
339
+ context,
340
+ args,
341
+ });
342
+ return { key, reused: false };
343
+ };
344
+
345
+ const where = (element: string | undefined) => {
346
+ const component = components.at(-1)?.name;
347
+ return `${component ? `In ${component}` : `In ${file}`}${element ? `, the text of a <${element}>` : ''}.`;
348
+ };
349
+
350
+ /** The children of one element, split into runs at anything that cannot sit inside a sentence. */
351
+ const extractChildren = (parent: Node, children: Node[]) => {
352
+ const parentName = parent.type === 'JSXElement' ? elementName(parent) : undefined;
353
+ let run: Node[] = [];
354
+ const flush = () => {
355
+ if (run.length > 0) extractRun(run, parentName);
356
+ run = [];
357
+ };
358
+ for (const child of children) {
359
+ if (inlineable(child)) run.push(child);
360
+ else flush();
361
+ }
362
+ flush();
363
+ };
364
+
365
+ const inlineable = (child: Node): boolean => {
366
+ if (child.type === 'JSXText') return true;
367
+ if (child.type === 'JSXExpressionContainer') return simple(child.expression as Node);
368
+ if (child.type === 'JSXElement') {
369
+ const name = elementName(child);
370
+ if (name === undefined || child.openingElement.selfClosing || !isInline(name)) return false;
371
+ return (child.children as Node[]).every(inlineable);
372
+ }
373
+ return false;
374
+ };
375
+
376
+ const extractRun = (run: Node[], parentName: string | undefined) => {
377
+ // Whitespace-only text at either end stays where it is, so the layout around the run is unchanged.
378
+ const blank = (n: Node) => n.type === 'JSXText' && jsxText(n.value as string).trim() === '';
379
+ const from = run.findIndex((n) => !blank(n));
380
+ if (from === -1) return;
381
+ const nodes = run.slice(from, run.findLastIndex((n) => !blank(n)) + 1);
382
+ // Elements with nothing but whitespace between them are layout, not a sentence: each one's text is
383
+ // its own, as `<li><a href>Home</a></li>` is "Home".
384
+ if (nodes.every((n) => n.type === 'JSXElement' || blank(n))) return;
385
+ const first = nodes[0]!;
386
+ const last = nodes.at(-1)!;
387
+ const values = new Map<string, string>();
388
+ const argDocs: Record<string, string> = {};
389
+ const names = new Map<string, string>();
390
+ const tags = new Set<string>();
391
+
392
+ const argName = (expr: Node): string => {
393
+ const source = code.slice(expr.start, expr.end);
394
+ const known = names.get(source);
395
+ if (known !== undefined) return known;
396
+ let inner = expr;
397
+ while (inner.type === 'TSNonNullExpression' || inner.type === 'ChainExpression') inner = inner.expression;
398
+ let name =
399
+ inner.type === 'Identifier'
400
+ ? (inner.name as string)
401
+ : inner.type === 'MemberExpression' && !inner.computed && inner.property?.type === 'Identifier'
402
+ ? (inner.property.name as string)
403
+ : 'value';
404
+ const base = name;
405
+ for (let n = 2; values.has(name) || tags.has(name); n++) name = `${base}${n}`;
406
+ names.set(source, name);
407
+ values.set(name, source);
408
+ argDocs[name] = `TODO: what \`${source}\` holds`;
409
+ return name;
410
+ };
411
+
412
+ const piece = (node: Node): string => {
413
+ if (node.type === 'JSXText') return escapeIcu(jsxText(node.value as string));
414
+ if (node.type === 'JSXElement') {
415
+ let tag = elementName(node)!.toLowerCase();
416
+ const base = tag;
417
+ for (let n = 2; tags.has(tag) || values.has(tag); n++) tag = `${base}${n}`;
418
+ tags.add(tag);
419
+ const open = code.slice(node.openingElement.start, node.openingElement.end);
420
+ const close = code.slice(node.closingElement.start, node.closingElement.end);
421
+ values.set(tag, `(chunks) => ${open}{chunks}${close}`);
422
+ return `<${tag}>${(node.children as Node[]).map(piece).join('')}</${tag}>`;
423
+ }
424
+ const expr = node.expression as Node;
425
+ if (expr.type === 'Literal' && typeof expr.value === 'string') return escapeIcu(expr.value);
426
+ if (expr.type === 'TemplateLiteral' && expr.expressions.length === 0) return escapeIcu(expr.quasis[0].value.cooked as string);
427
+ const kind = ctx.kinds.get(`${expr.start}:${expr.end}`) ?? syntaxKind(expr);
428
+ if (kind === 'node') markup.push(code.slice(expr.start, expr.end));
429
+ return `{${argName(expr)}${kind === 'number' ? ', number' : kind === 'date' ? ', date, medium' : ''}}`;
430
+ };
431
+
432
+ const markup: string[] = [];
433
+ let message = nodes.map(piece).join('').trim();
434
+ if (markup.length > 0) {
435
+ const at = position(first.start);
436
+ plan.warnings.push(
437
+ `${file}:${at.line}:${at.column} {${markup.join('}, {')}} holds markup, not text, so this sentence was left as it is: make it a tag, or give the text around it entries of its own`,
438
+ );
439
+ return;
440
+ }
441
+ if (/^en\b/.test(ctx.config.source)) message = draftPlurals(message);
442
+ // A run of spaces, placeholders and punctuation is layout, not words.
443
+ const words = message.replace(/\{[^}]*\}|<\/?[\w-]+>|'[{}<]'/g, '');
444
+ if (!LETTER.test(words) || (values.size === 0 && looksLikeCode(message))) return;
445
+ // The whitespace at either end stays in the code as it was: a space still renders as one, and a
446
+ // line break still renders as nothing.
447
+ const start = first.type === 'JSXText' ? first.start + (/^\s*/.exec(first.value as string)?.[0].length ?? 0) : first.start;
448
+ const end = last.type === 'JSXText' ? last.end - (/\s*$/.exec(last.value as string)?.[0].length ?? 0) : last.end;
449
+ if (ignored(start)) return;
450
+
451
+ message = inSyntax(ctx.config, message);
452
+ const role = parentName !== undefined ? ROLES[parentName] : undefined;
453
+ const { key, reused } = entryFor(message, argDocs, role ?? [undefined, undefined], where(parentName));
454
+ const props = [...values].map(([name, source]) => `${/^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name)}: ${source}`);
455
+ const replacement = `<Text id=${JSON.stringify(key)}${props.length > 0 ? ` values={{ ${props.join(', ')} }}` : ''} />`;
456
+ plan.edits.push({ start, end, text: replacement });
457
+ for (const n of nodes) consumed.add(n);
458
+ plan.extractions.push({ file, ...position(start), key, message, reused, where: 'text', replacement });
459
+ needText = true;
460
+ };
461
+
462
+ const extractAttribute = (attr: Node, element: Node) => {
463
+ const name = attr.name?.type === 'JSXIdentifier' ? (attr.name.name as string) : '';
464
+ if (!ctx.config.scan.attributes.includes(name) || attr.value === null) return;
465
+ const value = attr.value as Node;
466
+ let text: string | undefined;
467
+ if (value.type === 'Literal' && typeof value.value === 'string') {
468
+ text = value.value;
469
+ } else if (value.type === 'JSXExpressionContainer') {
470
+ const expr = value.expression as Node;
471
+ if (expr.type === 'Literal' && typeof expr.value === 'string') text = expr.value;
472
+ if (expr.type === 'TemplateLiteral' && expr.expressions.length === 0) text = expr.quasis[0].value.cooked as string;
473
+ }
474
+ if (text === undefined || !LETTER.test(text) || looksLikeCode(text) || ignored(attr.start)) return;
475
+ const at = position(value.start);
476
+ const fn = components.findLast((c) => c.isComponent)?.fn;
477
+ if (fn === undefined || fn.body?.type !== 'BlockStatement') {
478
+ plan.warnings.push(
479
+ `${file}:${at.line}:${at.column} ${name}="${text}" is not inside a component with a body, so it has no t() to read; left as it is`,
480
+ );
481
+ return;
482
+ }
483
+ const call = hookFor(fn);
484
+ if (call === undefined) {
485
+ plan.warnings.push(
486
+ `${file}:${at.line}:${at.column} ${name}="${text}": its component binds both \`t\` and \`copy\` already; left as it is`,
487
+ );
488
+ return;
489
+ }
490
+ const [kind, surface, suffix] = ATTRIBUTE_ROLES[name] ?? [undefined, undefined, name];
491
+ const tag = elementName(element) ?? 'element';
492
+ const component = components.at(-1)?.name;
493
+ const context = `${component ? `In ${component}` : `In ${file}`}, the ${name} of a <${tag}>.`;
494
+ const message = inSyntax(ctx.config, escapeIcu(text));
495
+ const { key, reused } = entryFor(message, {}, [kind, surface], context, suffix);
496
+ const replacement = `{${call}(${JSON.stringify(key)})}`;
497
+ plan.edits.push({ start: value.start, end: value.end, text: replacement });
498
+ plan.extractions.push({ file, ...at, key, message, reused, where: name, replacement });
499
+ };
500
+
501
+ /**
502
+ * Makes sure the component can read the catalog, and returns the call that does: `t` from
503
+ * `useCopy()`, or `copy.t` where the component or the module already binds a `t` of its own.
504
+ */
505
+ const hookFor = (fn: Node): string | undefined => {
506
+ const chosen = hooked.get(fn.start);
507
+ if (chosen !== undefined) return chosen;
508
+ const taken = new Set([...topLevel, ...bindingsIn(fn)]);
509
+ const body = code.slice(fn.body.start, fn.body.end);
510
+ const insert = (text: string) => plan.edits.push({ start: fn.body.start + 1, end: fn.body.start + 1, text });
511
+ const firstStatement = fn.body.body[0] as Node | undefined;
512
+ const indent = firstStatement ? ' '.repeat(position(firstStatement.start).column - 1) : ' ';
513
+ let call: string | undefined;
514
+ const destructured = /\{([^{}]*)\}\s*=\s*useCopy\(\)/.exec(body);
515
+ const whole = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*useCopy\(\)/.exec(body);
516
+ if (destructured !== null && /(^|[\s,])t(\s*[,}]|\s*$)/.test(destructured[1]!)) {
517
+ call = 't';
518
+ } else if (whole !== null) {
519
+ call = `${whole[1]}.t`;
520
+ } else if (destructured !== null && !taken.has('t')) {
521
+ const at = fn.body.start + destructured.index + 1;
522
+ plan.edits.push({ start: at, end: at, text: ' t,' });
523
+ call = 't';
524
+ } else if (!taken.has('t')) {
525
+ insert(`
526
+ ${indent}const { t } = useCopy();`);
527
+ call = 't';
528
+ } else if (!taken.has('copy')) {
529
+ insert(`
530
+ ${indent}const copy = useCopy();`);
531
+ call = 'copy.t';
532
+ }
533
+ if (call !== undefined && destructured === null && whole === null) needUseCopy = true;
534
+ if (call !== undefined) hooked.set(fn.start, call);
535
+ return call;
536
+ };
537
+
538
+ const visit = (node: Node, parent: Node | undefined) => {
539
+ const isFunction = node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression';
540
+ if (isFunction) {
541
+ const own = componentName(node, parent);
542
+ const isComponent = own !== undefined && /^(?:[A-Z]|use[A-Z])/.test(own);
543
+ components.push({ fn: node, name: isComponent ? own : components.at(-1)?.name, isComponent });
544
+ }
545
+ if (node.type === 'JSXElement' || node.type === 'JSXFragment') {
546
+ const name = node.type === 'JSXElement' ? elementName(node) : undefined;
547
+ if (node.type === 'JSXElement') {
548
+ for (const attr of node.openingElement.attributes as Node[]) {
549
+ if (attr.type !== 'JSXAttribute') continue;
550
+ extractAttribute(attr, node);
551
+ // `render={() => <p>Hi</p>}` holds JSX of its own.
552
+ if (attr.value?.type === 'JSXExpressionContainer') visit(attr.value as Node, attr);
553
+ }
554
+ }
555
+ if (name === undefined || !NON_PROSE.has(name)) {
556
+ extractChildren(node, node.children as Node[]);
557
+ for (const child of node.children as Node[]) if (!consumed.has(child)) visit(child, node);
558
+ }
559
+ return;
560
+ }
561
+ for (const value of Object.values(node)) {
562
+ if (Array.isArray(value)) {
563
+ for (const child of value) if (isNode(child)) visit(child, node);
564
+ } else if (isNode(value)) {
565
+ visit(value, node);
566
+ }
567
+ }
568
+ if (isFunction) components.pop();
569
+ };
570
+
571
+ visit(program, undefined);
572
+ plan.extractions.sort((a, b) => (a.line !== b.line ? a.line - b.line : a.column - b.column));
573
+ if (needText || needUseCopy) importEdit(program, code, plan, [...(needText ? ['Text'] : []), ...(needUseCopy ? ['useCopy'] : [])]);
574
+ return plan;
575
+ }
576
+
577
+ /** Adds the names to an existing `prosebook/react` import, or a new import after the last one. */
578
+ function importEdit(program: Node, code: string, plan: FilePlan, wanted: string[]) {
579
+ const imports = (program.body as Node[]).filter((s) => s.type === 'ImportDeclaration');
580
+ const existing = imports.find((s) => s.source.value === MODULE && s.importKind !== 'type');
581
+ if (existing) {
582
+ const named = (existing.specifiers as Node[]).filter((s) => s.type === 'ImportSpecifier');
583
+ const have = new Set(named.map((s) => s.local.name as string));
584
+ const missing = wanted.filter((w) => !have.has(w));
585
+ if (missing.length === 0) return;
586
+ if (named.length > 0) {
587
+ plan.edits.push({ start: named.at(-1)!.end, end: named.at(-1)!.end, text: `, ${missing.join(', ')}` });
588
+ return;
589
+ }
590
+ }
591
+ const line = `import { ${wanted.join(', ')} } from '${MODULE}';`;
592
+ const last = imports.at(-1);
593
+ if (last) {
594
+ plan.edits.push({ start: last.end, end: last.end, text: `\n${line}` });
595
+ } else {
596
+ const directive = /^(?:\s*(['"])use \w+\1;?\s*\n)*/.exec(code)?.[0] ?? '';
597
+ plan.edits.push({ start: directive.length, end: directive.length, text: `${line}\n${directive === '' ? '\n' : ''}` });
598
+ }
599
+ }
600
+
601
+ /** The spans of every expression inside JSX, which are the ones that may become placeholders. */
602
+ function jsxExpressions(file: string, code: string): Span[] {
603
+ const spans: Span[] = [];
604
+ const walk = (node: Node) => {
605
+ if (node.type === 'JSXExpressionContainer' && node.expression.type !== 'JSXEmptyExpression')
606
+ spans.push({ start: node.expression.start as number, end: node.expression.end as number });
607
+
608
+ for (const value of Object.values(node)) {
609
+ if (Array.isArray(value)) {
610
+ for (const child of value) if (isNode(child)) walk(child);
611
+ } else if (isNode(value)) {
612
+ walk(value);
613
+ }
614
+ }
615
+ };
616
+ walk(parseSync(file, code).program);
617
+ return spans;
618
+ }
619
+
620
+ /**
621
+ * Plans an extraction over the files in `paths` against the current catalog. With `types` (the
622
+ * default), the project's TypeScript says which placeholders are numbers and dates.
623
+ */
624
+ export async function planExtract(
625
+ config: ResolvedConfig,
626
+ paths: string[],
627
+ options: { area?: string; types?: boolean } = {},
628
+ ): Promise<ExtractPlan> {
629
+ if (options.area !== undefined && !KEY_PATTERN.test(`${options.area}.x`))
630
+ throw new Error(`prosebook: "${options.area}" is not an area: lowercase words joined by -`);
631
+ const known = new Map<string, string>();
632
+ if (existsSync(config.catalog)) {
633
+ try {
634
+ for (const m of loadCatalog(config).messages) known.set(m.key, m.text);
635
+ } catch (e) {
636
+ if (!(e instanceof CatalogError)) throw e;
637
+ throw new Error(`prosebook: fix the catalog before extracting into it:\n${e.message}`, { cause: e });
638
+ }
639
+ }
640
+ const entries: NewEntry[] = [];
641
+ const oracle = options.types === false ? undefined : await typescriptOracle(config.root, paths);
642
+ try {
643
+ // One file at a time: each plan sees the entries the ones before it added.
644
+ const files: FilePlan[] = [];
645
+ for (const path of paths) {
646
+ const kinds = new Map<string, ValueKind>();
647
+ if (oracle) {
648
+ const spans = jsxExpressions(path, readFileSync(path, 'utf8'));
649
+ (await oracle.kinds(path, spans)).forEach((kind, i) => {
650
+ if (kind !== undefined && kind !== 'unknown') kinds.set(`${spans[i]!.start}:${spans[i]!.end}`, kind);
651
+ });
652
+ }
653
+ files.push(planFile(path, { config, area: options.area ?? areaOf(path), known, entries, kinds }));
654
+ }
655
+ return { files, entries, types: oracle?.source ?? 'syntax' };
656
+ } finally {
657
+ await oracle?.close();
658
+ }
659
+ }
660
+
661
+ /** Applies a plan: rewrites the files and appends the new entries to their area's catalog file. */
662
+ export function applyExtract(config: ResolvedConfig, plan: ExtractPlan): string[] {
663
+ const written: string[] = [];
664
+ for (const file of plan.files) {
665
+ if (file.edits.length === 0) continue;
666
+ let code = file.code;
667
+ for (const edit of [...file.edits].sort((a, b) => (a.start !== b.start ? b.start - a.start : b.end - a.end)))
668
+ code = code.slice(0, edit.start) + edit.text + code.slice(edit.end);
669
+
670
+ writeFileSync(file.path, code);
671
+ written.push(file.path);
672
+ }
673
+ const byArea = new Map<string, NewEntry[]>();
674
+ for (const entry of plan.entries) byArea.set(entry.area, [...(byArea.get(entry.area) ?? []), entry]);
675
+ for (const [area, entries] of byArea) {
676
+ const path = join(config.catalog, `${area}.yaml`);
677
+ // Written as YAML 1.1, which quotes `n`, `yes` and `on`: they then read the same under 1.1 and 1.2,
678
+ // as the catalog requires.
679
+ const blocks = entries.map((e) => {
680
+ const body: Record<string, unknown> = {};
681
+ if (e.kind) body.kind = e.kind;
682
+ if (e.surface) body.surface = e.surface;
683
+ body.context = e.context;
684
+ if (Object.keys(e.args).length > 0) body.args = e.args;
685
+ body[config.source] = e.message;
686
+ return `# TODO(prosebook extract): check the key, kind and context.\n${stringify({ [e.key]: body }, { lineWidth: 0, version: '1.1' })}`;
687
+ });
688
+ mkdirSync(config.catalog, { recursive: true });
689
+ const current = existsSync(path) ? readFileSync(path, 'utf8') : '# yaml-language-server: $schema=./catalog.schema.json\n';
690
+ writeFileSync(path, `${current.replace(/\n*$/, '\n')}\n${blocks.join('\n')}`);
691
+ written.push(path);
692
+ }
693
+ return written;
694
+ }