prosebook 0.3.0 → 0.5.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 (125) hide show
  1. package/README.md +357 -26
  2. package/dist/bind-BIZNv8nU.js +62 -0
  3. package/dist/bind-BIZNv8nU.js.map +1 -0
  4. package/dist/bind-ZS86jHXk.d.ts +31 -0
  5. package/dist/bind-ZS86jHXk.d.ts.map +1 -0
  6. package/dist/bun.d.ts +12 -3
  7. package/dist/bun.d.ts.map +1 -1
  8. package/dist/bun.js +77 -26
  9. package/dist/bun.js.map +1 -1
  10. package/dist/{bundle-KD-DiiFa.d.ts → bundle-CLHY_97D.d.ts} +3 -3
  11. package/dist/bundle-CLHY_97D.d.ts.map +1 -0
  12. package/dist/bundle-Ut6Y2X6N.js +217 -0
  13. package/dist/bundle-Ut6Y2X6N.js.map +1 -0
  14. package/dist/cli.js +55 -12
  15. package/dist/cli.js.map +1 -1
  16. package/dist/{config-BPSmoYel.js → config-7T4hX_f7.js} +13 -5
  17. package/dist/{config-BPSmoYel.js.map → config-7T4hX_f7.js.map} +1 -1
  18. package/dist/{config-DdMkKt_N.d.ts → config-CzaJJQ60.d.ts} +19 -5
  19. package/dist/{config-DdMkKt_N.d.ts.map → config-CzaJJQ60.d.ts.map} +1 -1
  20. package/dist/config.d.ts +1 -1
  21. package/dist/config.js +1 -1
  22. package/dist/{copy-B3V_QRkT.d.ts → copy-BXn0aYQn.d.ts} +37 -3
  23. package/dist/copy-BXn0aYQn.d.ts.map +1 -0
  24. package/dist/copy-Bv6S-Hr2.js +235 -0
  25. package/dist/copy-Bv6S-Hr2.js.map +1 -0
  26. package/dist/endpoints-Dhgowovz.js +217 -0
  27. package/dist/endpoints-Dhgowovz.js.map +1 -0
  28. package/dist/generate-1BeQAd1O.d.ts +72 -0
  29. package/dist/generate-1BeQAd1O.d.ts.map +1 -0
  30. package/dist/{generate-9pR3i5Bj.js → generate-CKv86Iki.js} +1724 -627
  31. package/dist/generate-CKv86Iki.js.map +1 -0
  32. package/dist/index.d.ts +2 -2
  33. package/dist/index.js +2 -2
  34. package/dist/next-loader.d.ts +9 -2
  35. package/dist/next-loader.d.ts.map +1 -1
  36. package/dist/next-loader.js +12 -3
  37. package/dist/next-loader.js.map +1 -1
  38. package/dist/next.d.ts +11 -3
  39. package/dist/next.d.ts.map +1 -1
  40. package/dist/next.js +94 -21
  41. package/dist/next.js.map +1 -1
  42. package/dist/preact/index.d.ts +34 -0
  43. package/dist/preact/index.d.ts.map +1 -0
  44. package/dist/preact/index.js +72 -0
  45. package/dist/preact/index.js.map +1 -0
  46. package/dist/react/index.d.ts +5 -14
  47. package/dist/react/index.d.ts.map +1 -1
  48. package/dist/react/index.js +9 -33
  49. package/dist/react/index.js.map +1 -1
  50. package/dist/react/server.d.ts +1 -1
  51. package/dist/react/server.js +1 -1
  52. package/dist/{sarif-CWyTAQ0y.js → sarif-BnwYC78_.js} +8 -98
  53. package/dist/sarif-BnwYC78_.js.map +1 -0
  54. package/dist/{scan-Bx0_yDSi.js → scan-C0ap4Mpp.js} +145 -20
  55. package/dist/scan-C0ap4Mpp.js.map +1 -0
  56. package/dist/solid/index.d.ts +29 -0
  57. package/dist/solid/index.d.ts.map +1 -0
  58. package/dist/solid/index.js +48 -0
  59. package/dist/solid/index.js.map +1 -0
  60. package/dist/svelte/Text.svelte +13 -0
  61. package/dist/svelte/Text.svelte.d.ts +20 -0
  62. package/dist/svelte/index.d.ts +31 -0
  63. package/dist/svelte/index.d.ts.map +1 -0
  64. package/dist/svelte/index.js +35 -0
  65. package/dist/svelte/index.js.map +1 -0
  66. package/dist/tools.d.ts +44 -65
  67. package/dist/tools.d.ts.map +1 -1
  68. package/dist/tools.js +5 -5
  69. package/dist/vite.d.ts +13 -3
  70. package/dist/vite.d.ts.map +1 -1
  71. package/dist/vite.js +82 -25
  72. package/dist/vite.js.map +1 -1
  73. package/dist/vue/index.d.ts +43 -0
  74. package/dist/vue/index.d.ts.map +1 -0
  75. package/dist/vue/index.js +76 -0
  76. package/dist/vue/index.js.map +1 -0
  77. package/package.json +63 -7
  78. package/src/bun.ts +77 -20
  79. package/src/cli.ts +62 -8
  80. package/src/index.ts +1 -1
  81. package/src/next-loader.ts +17 -5
  82. package/src/next.ts +120 -16
  83. package/src/preact/index.ts +91 -0
  84. package/src/react/index.tsx +12 -42
  85. package/src/runtime/bind.ts +96 -0
  86. package/src/runtime/copy.ts +148 -24
  87. package/src/solid/index.ts +67 -0
  88. package/src/svelte/Text.svelte +13 -0
  89. package/src/svelte/Text.svelte.d.ts +20 -0
  90. package/src/svelte/index.ts +65 -0
  91. package/src/tools/bundle.ts +143 -2
  92. package/src/tools/catalog.ts +10 -1
  93. package/src/tools/chunks.ts +142 -0
  94. package/src/tools/config.ts +25 -7
  95. package/src/tools/entry.ts +19 -20
  96. package/src/tools/exchange.ts +275 -0
  97. package/src/tools/extract.ts +2 -2
  98. package/src/tools/generate.ts +7 -1
  99. package/src/tools/index.ts +2 -1
  100. package/src/tools/native/android.ts +186 -0
  101. package/src/tools/native/apple.ts +201 -0
  102. package/src/tools/native/date.ts +219 -0
  103. package/src/tools/native/index.ts +17 -5
  104. package/src/tools/native/kotlin.ts +47 -6
  105. package/src/tools/native/model.ts +67 -29
  106. package/src/tools/native/printf.ts +137 -0
  107. package/src/tools/native/rust.ts +86 -5
  108. package/src/tools/native/swift.ts +62 -6
  109. package/src/tools/scan.ts +146 -25
  110. package/src/tools/todo.ts +6 -1
  111. package/src/vite.ts +121 -28
  112. package/src/vue/index.ts +117 -0
  113. package/dist/bundle-DboItAre.js +0 -111
  114. package/dist/bundle-DboItAre.js.map +0 -1
  115. package/dist/bundle-KD-DiiFa.d.ts.map +0 -1
  116. package/dist/compile-xF3F4oIp.d.ts +0 -16
  117. package/dist/compile-xF3F4oIp.d.ts.map +0 -1
  118. package/dist/copy-B3V_QRkT.d.ts.map +0 -1
  119. package/dist/copy-Bo_FIoLy.js +0 -154
  120. package/dist/copy-Bo_FIoLy.js.map +0 -1
  121. package/dist/endpoints-CnUfCkIk.js +0 -96
  122. package/dist/endpoints-CnUfCkIk.js.map +0 -1
  123. package/dist/generate-9pR3i5Bj.js.map +0 -1
  124. package/dist/sarif-CWyTAQ0y.js.map +0 -1
  125. package/dist/scan-Bx0_yDSi.js.map +0 -1
package/src/tools/scan.ts CHANGED
@@ -232,41 +232,73 @@ const nameOf = (node: Node | undefined): string | undefined =>
232
232
  ? String(node.value)
233
233
  : undefined;
234
234
 
235
+ /** Where `<Text>` comes from: each framework binding's. */
236
+ const TEXT_SOURCES = new Set(['prosebook/react', 'prosebook/preact', 'prosebook/solid', 'prosebook/vue', 'prosebook/svelte']);
237
+
238
+ /** Single-file components, whose reads are in their scripts and their markup. */
239
+ export const SFC = /\.(?:vue|svelte)$/;
240
+
241
+ /** Code inside a file, parsed apart: a script block, or an expression in markup, at its offset. */
242
+ interface Piece {
243
+ code: string;
244
+ at: number;
245
+ }
246
+
247
+ /** A single-file component's script blocks, each at its offset. */
248
+ export const scriptBlocks = (code: string): Piece[] =>
249
+ [...code.matchAll(/(<script\b[^>]*>)([\s\S]*?)<\/script>/g)].map((m) => ({ code: m[2]!, at: m.index + m[1]!.length }));
250
+
235
251
  /**
236
252
  * The reads in a module whose key a pruned build can't see, as in `t(item.key)` or `<Text id={id} />`:
237
253
  * the build would leave the message out, and the key would show in its place. A build has no types,
238
254
  * so readers are known by name: `t` and `rich` called alone or as methods, and any name imported here,
239
255
  * which may be bound to one elsewhere (`aliases`); the caller keeps those whose name turns out to be a
240
- * reader. `<Text>` counts where it is imported from `prosebook/react`. Lines marked `prosebook-ignore`
241
- * are skipped.
256
+ * reader. `<Text>` counts where it is imported from a prosebook binding. In a `.vue` or `.svelte` file,
257
+ * its scripts and the expressions in its markup are read. Lines marked `prosebook-ignore` are skipped.
242
258
  */
243
259
  export function unseenReads(file: string, code: string): Reads {
244
- const { program } = parseSync(file, code);
260
+ if (!SFC.test(file)) return readsIn(file, code, file, [{ code, at: 0 }]);
261
+ const vue = file.endsWith('.vue');
262
+ const markup = blank(code, /<script\b[\s\S]*?<\/script>|<style\b[\s\S]*?<\/style>|<!--[\s\S]*?-->/g);
263
+ const scripts = scriptBlocks(code);
264
+ const expressions = vue ? vueExpressions(markup) : svelteExpressions(markup);
265
+ // Scripts and markup alike may be TypeScript.
266
+ return readsIn(file, code, 'component.ts', [...scripts, ...expressions], (names, report) => {
267
+ for (const tag of markupTexts(markup, names, vue)) report(tag.at, tag.end, 'Text');
268
+ });
269
+ }
270
+
271
+ type Report = (start: number, end: number, name: string) => void;
272
+
273
+ function readsIn(file: string, code: string, as: string, pieces: Piece[], markup?: (texts: Set<string>, report: Report) => void): Reads {
274
+ const programs = pieces.map((piece) => ({ at: piece.at, program: parseSync(as, piece.code).program as unknown as Node }));
245
275
  const { position, ignored } = lineIndex(code);
246
276
  const unseen: UnseenRead[] = [];
247
277
  const aliases = new Set<string>();
248
278
  /** Local name to the name it was imported by. */
249
279
  const imported = new Map<string, string>();
250
280
  const texts = new Set<string>();
251
- for (const statement of (program as unknown as Node).body as Node[]) {
252
- if (statement.type !== 'ImportDeclaration') continue;
253
- for (const s of statement.specifiers as Node[]) {
254
- const name = s.type === 'ImportSpecifier' ? nameOf(s.imported as Node) : undefined;
255
- const local = nameOf(s.local as Node);
256
- if (name === undefined || local === undefined) continue;
257
- if (name === 'Text' && (statement.source as Node).value === 'prosebook/react') texts.add(local);
258
- else imported.set(local, name);
281
+ for (const { program } of programs) {
282
+ for (const statement of program.body as Node[]) {
283
+ if (statement.type !== 'ImportDeclaration') continue;
284
+ for (const s of statement.specifiers as Node[]) {
285
+ const name = s.type === 'ImportSpecifier' ? nameOf(s.imported as Node) : undefined;
286
+ const local = nameOf(s.local as Node);
287
+ if (name === undefined || local === undefined) continue;
288
+ if (name === 'Text' && TEXT_SOURCES.has(String((statement.source as Node).value))) texts.add(local);
289
+ else imported.set(local, name);
290
+ }
259
291
  }
260
292
  }
261
- const report = (node: Node, name: string) => {
262
- const at = position(node.start);
293
+ const report: Report = (start, end, name) => {
294
+ const at = position(start);
263
295
  if (ignored(at.line)) return;
264
- const text = code.slice(node.start, node.end).replace(/\s+/g, ' ');
296
+ const text = code.slice(start, end).replace(/\s+/g, ' ');
265
297
  unseen.push({ file, ...at, name, code: text.length > 80 ? `${text.slice(0, 77)}...` : text });
266
298
  };
267
299
  // Calls by a plain name, kept until every alias in the module is known.
268
- const calls: [Node, string][] = [];
269
- const visit = (node: Node) => {
300
+ const calls: [start: number, end: number, name: string][] = [];
301
+ const visit = (node: Node, at: number) => {
270
302
  if (node.type === 'ObjectPattern') {
271
303
  for (const p of node.properties as Node[]) {
272
304
  if (p.type === 'Property' && READERS.has(nameOf(p.key as Node) ?? '') && (p.value as Node).type === 'Identifier')
@@ -275,29 +307,118 @@ export function unseenReads(file: string, code: string): Reads {
275
307
  }
276
308
  if (node.type === 'CallExpression' && !keySeen((node.arguments as Node[])[0])) {
277
309
  const callee = node.callee as Node;
278
- if (callee.type === 'Identifier') calls.push([node, callee.name as string]);
310
+ if (callee.type === 'Identifier') calls.push([at + node.start, at + node.end, callee.name as string]);
279
311
  else if (callee.type === 'MemberExpression' && !callee.computed && READERS.has(nameOf(callee.property as Node) ?? ''))
280
- report(node, nameOf(callee.property as Node)!);
312
+ report(at + node.start, at + node.end, nameOf(callee.property as Node)!);
281
313
  }
282
314
  if (node.type === 'JSXOpeningElement' && texts.has(nameOf(node.name as Node) ?? '')) {
283
315
  const id = (node.attributes as Node[]).find((a) => a.type === 'JSXAttribute' && nameOf(a.name as Node) === 'id');
284
316
  const value = id?.value as Node | null | undefined;
285
317
  if (value !== null && value !== undefined && !keySeen(value.type === 'JSXExpressionContainer' ? (value.expression as Node) : value))
286
- report(node, 'Text');
318
+ report(at + node.start, at + node.end, 'Text');
287
319
  }
288
320
  for (const value of Object.values(node)) {
289
321
  if (Array.isArray(value)) {
290
- for (const child of value) if (isNode(child)) visit(child);
322
+ for (const child of value) if (isNode(child)) visit(child, at);
291
323
  } else if (isNode(value)) {
292
- visit(value);
324
+ visit(value, at);
293
325
  }
294
326
  }
295
327
  };
296
- visit(program as unknown as Node);
297
- for (const [node, local] of calls) {
298
- if (READERS.has(local) || aliases.has(local)) report(node, local);
299
- else if (imported.has(local)) report(node, imported.get(local)!);
328
+ for (const { program, at } of programs) visit(program, at);
329
+ markup?.(texts, report);
330
+ for (const [start, end, local] of calls) {
331
+ if (READERS.has(local) || aliases.has(local)) report(start, end, local);
332
+ else if (imported.has(local)) report(start, end, imported.get(local)!);
300
333
  }
301
334
  unseen.sort((a, b) => (a.line === b.line ? a.column - b.column : a.line - b.line));
302
335
  return { unseen, aliases };
303
336
  }
337
+
338
+ /** `code` with each match of `pattern` blanked to spaces, its lines kept, so offsets still point into the file. */
339
+ const blank = (code: string, pattern: RegExp) => code.replace(pattern, (m) => m.replace(/[^\n]/g, ' '));
340
+
341
+ /** A Vue template's expressions: `{{ ... }}`, and the values of bound attributes and directives. */
342
+ function vueExpressions(markup: string): Piece[] {
343
+ const pieces: Piece[] = [];
344
+ for (const m of markup.matchAll(/\{\{([\s\S]*?)\}\}/g)) pieces.push({ code: m[1]!, at: m.index + 2 });
345
+ for (const m of markup.matchAll(/\s((?::|@|v-)[^\s=>/"']*)\s*=\s*(["'])([\s\S]*?)\2/g)) {
346
+ // A loop's and a slot's values bind names; they read nothing.
347
+ if (/^v-(?:for|slot)\b/.test(m[1]!)) continue;
348
+ pieces.push({ code: m[3]!, at: m.index + m[0].length - m[3]!.length - 1 });
349
+ }
350
+ return pieces;
351
+ }
352
+
353
+ /** Where the `}` closing the `{` at `start` is, past strings and nested braces; -1 if none does. */
354
+ function closing(text: string, start: number): number {
355
+ let depth = 0;
356
+ for (let i = start; i < text.length; i++) {
357
+ const c = text[i]!;
358
+ if (c === '"' || c === "'" || c === '`') {
359
+ const end = text.indexOf(c, i + 1);
360
+ if (end === -1) return -1;
361
+ i = end;
362
+ } else if (c === '{') {
363
+ depth++;
364
+ } else if (c === '}' && --depth === 0) {
365
+ return i;
366
+ }
367
+ }
368
+ return -1;
369
+ }
370
+
371
+ /** A Svelte template's expressions: each `{...}`, a block's or tag's past its keyword. */
372
+ function svelteExpressions(markup: string): Piece[] {
373
+ const pieces: Piece[] = [];
374
+ for (let start = markup.indexOf('{'); start !== -1;) {
375
+ const end = closing(markup, start);
376
+ if (end === -1) break;
377
+ const at = start + 1;
378
+ const inner = markup.slice(at, end);
379
+ const tag = /^\s*([#:@/])(\w*)(\s+if\b)?/.exec(inner);
380
+ if (tag === null) {
381
+ // A spread, `{...props}`, reads as the object it spreads.
382
+ pieces.push({ code: inner.replace('...', ' '), at });
383
+ } else {
384
+ const [head, sigil, word, elseIf] = tag;
385
+ // `{/if}`, `{:else}` and `{:then x}` hold no expression, and `{#snippet}` names parameters.
386
+ const reads = sigil !== '/' && word !== 'snippet' && word !== 'then' && word !== 'catch' && (word !== 'else' || elseIf !== undefined);
387
+ // `{@const x = ...}` declares; `{#each xs as x}` and `{#await p then x}` bind names after the expression.
388
+ if (reads && word === 'const') pieces.push({ code: `var${inner.slice(head.length)}`, at: at + head.length - 3 });
389
+ else if (reads) pieces.push({ code: inner.slice(head.length).split(/\s+(?:as|then)\s/)[0]!, at: at + head.length });
390
+ }
391
+ start = markup.indexOf('{', end + 1);
392
+ }
393
+ return pieces;
394
+ }
395
+
396
+ /**
397
+ * Each `<Text>` in markup whose key the build can't see: Vue's `:id="..."`, Svelte's `id={...}`,
398
+ * `{id}` or `id="text {with} a placeholder"`. An `id` written as plain text is a key `keyRefs` reads.
399
+ */
400
+ function markupTexts(markup: string, names: Set<string>, vue: boolean): { at: number; end: number }[] {
401
+ if (names.size === 0) return [];
402
+ const found: { at: number; end: number }[] = [];
403
+ const tags = new RegExp(`<(?:${[...names].join('|')})(?=[\\s/>])((?:[^>"']|"[^"]*"|'[^']*')*)>`, 'g');
404
+ for (const m of markup.matchAll(tags)) {
405
+ const attributes = m[1]!;
406
+ let id: string | undefined;
407
+ if (vue) {
408
+ id = /(?:^|\s)(?::|v-bind:)id\s*=\s*(["'])([\s\S]*?)\1/.exec(attributes)?.[2];
409
+ } else {
410
+ const bound = /(?:^|\s)id\s*=\s*\{/.exec(attributes);
411
+ if (bound !== null) {
412
+ const open = bound.index + bound[0].length - 1;
413
+ id = attributes.slice(open + 1, closing(attributes, open));
414
+ } else if (/(?:^|\s)\{\s*id\s*\}/.test(attributes) || /(?:^|\s)id\s*=\s*(["'])[^"']*\{/.test(attributes)) {
415
+ id = 'id';
416
+ }
417
+ }
418
+ if (id === undefined) continue;
419
+ const [statement] = parseSync('id.ts', `(${id})`).program.body as unknown as Node[];
420
+ if (!keySeen(statement?.type === 'ExpressionStatement' ? (statement.expression as Node) : undefined))
421
+ found.push({ at: m.index, end: m.index + m[0].length });
422
+ }
423
+ return found;
424
+ }
package/src/tools/todo.ts CHANGED
@@ -27,7 +27,7 @@ export function todoOf(
27
27
  locale: string,
28
28
  prompt = false,
29
29
  ): Todo {
30
- const wanted = catalog.messages.filter((m) => !m.translations.has(locale) || stale.has(m.key));
30
+ const wanted = todoMessages(catalog, stale, locale);
31
31
  if (wanted.length === 0) return { count: 0, text: `# ${locale} is complete` };
32
32
  const byArea = new Map<string, string[]>();
33
33
  for (const m of wanted) {
@@ -58,6 +58,11 @@ export function todoOf(
58
58
  return { count, text: `${head}\n${entries}` };
59
59
  }
60
60
 
61
+ /** The entries a locale wants a translation of: those it lacks, and those gone stale. */
62
+ export function todoMessages(catalog: Catalog, stale: ReadonlyMap<string, StaleTranslation>, locale: string): Message[] {
63
+ return catalog.messages.filter((m) => !m.translations.has(locale) || stale.has(m.key));
64
+ }
65
+
61
66
  function promptFor(config: ResolvedConfig, wanted: Message[], locale: string, anyStale: boolean): string {
62
67
  const mf2 = config.messageFormat === 2;
63
68
  const [target, source] = [nameOf(locale), nameOf(config.source)];
package/src/vite.ts CHANGED
@@ -6,6 +6,15 @@
6
6
  * code names: each locale module is swapped for its messages less the keys no module references
7
7
  * (see `tools/scan.ts` for how a reference is found, and why it errs towards keeping).
8
8
  *
9
+ * With `split`, a client's catalog has no messages of its own: each module naming an area's keys
10
+ * imports a module for that area, which the bundler places beside it, and which registers the area
11
+ * (`addArea`) and waits until every copy holds it in its locale. Areas are found by the same
12
+ * references pruning reads, so a read pruning can't see is one splitting can't place either. A lazy
13
+ * locale's part is a file per group of areas the bundler will put in one chunk (`tools/chunks.ts`),
14
+ * read from the source when the build starts, as an area loads before every module naming it has.
15
+ * The client build writes down the file each area loads from per lazy locale, and the server build
16
+ * after it gives its catalog that list (`areaFiles`), so a server render can link what it read.
17
+ *
9
18
  * In dev an edit doesn't reload the page: each generated locale module accepts its own update and
10
19
  * hands the new messages to every copy (`HotHook`), which re-renders in the locale and state it was in.
11
20
  * The index accepts its own too, since its doc comments change with the source text, and reloads the
@@ -15,18 +24,39 @@
15
24
  * inspector asks for through Vite's own `/__open-in-editor`, so the editor is whichever Vite already
16
25
  * launches.
17
26
  */
18
- import { relative, resolve } from 'node:path';
27
+ import { existsSync, readFileSync } from 'node:fs';
28
+ import { join, relative, resolve } from 'node:path';
19
29
  import { fileURLToPath } from 'node:url';
20
30
 
21
31
  import type { Plugin } from 'vite';
22
32
 
23
- import { beside, HOT_INDEX, HOT_SET, hotLocale, installInspector, keeper, unseenMessage, unseenOf, type Prune } from './tools/bundle.ts';
33
+ import {
34
+ AREA,
35
+ areaModule,
36
+ areasNamed,
37
+ lazyAreas,
38
+ beside,
39
+ HOT_INDEX,
40
+ HOT_SET,
41
+ hotLocale,
42
+ importingAreas,
43
+ inAreas,
44
+ installInspector,
45
+ keeper,
46
+ membersOf,
47
+ splitIndex,
48
+ unseenMessage,
49
+ unseenOf,
50
+ type Groups,
51
+ type Prune,
52
+ } from './tools/bundle.ts';
24
53
  import { CatalogError, type EntryLocation } from './tools/catalog.ts';
54
+ import { areaGroups } from './tools/chunks.ts';
25
55
  import { compileLocale, localeExpression } from './tools/compile.ts';
26
56
  import { loadConfig, type ProsebookConfig, type ResolvedConfig } from './tools/config.ts';
27
57
  import { inspectorEndpoints, nodeMiddleware, redirect } from './tools/endpoints.ts';
28
- import { generate, type Generated } from './tools/generate.ts';
29
- import { keyRefs, unseenReads, type KeyRefs, type Reads } from './tools/scan.ts';
58
+ import { generate, writeFiles, type Generated } from './tools/generate.ts';
59
+ import { keyRefs, SFC, unseenReads, type KeyRefs, type Reads } from './tools/scan.ts';
30
60
 
31
61
  export interface ProsebookPluginOptions {
32
62
  /** The config, inline. Without it, `prosebook.config.*` at `root` is read. */
@@ -42,19 +72,37 @@ export interface ProsebookPluginOptions {
42
72
  * pruned. `loose` prunes without that check; `false` ships every message.
43
73
  */
44
74
  prune?: Prune;
45
- /** Keys a build always keeps, exactly or as `prefix.*`: for keys built at runtime from data. */
75
+ /**
76
+ * Keys a build always keeps, exactly or as `prefix.*`: for keys built at runtime from data. With
77
+ * `split`, their areas load with the catalog.
78
+ */
46
79
  keep?: string[];
80
+ /**
81
+ * Split each locale by area, so a lazy route loads only the messages it reads: a module that names
82
+ * an area's keys imports that area, which the bundler places beside it, and a lazy locale loads
83
+ * each area as it is needed. In the browser only: a server render loads whole locales, and its
84
+ * catalog lists each area's files, which a copy's `preload` hands on as the render reads them.
85
+ */
86
+ split?: boolean;
47
87
  /** In dev, Alt-click a string on the page to open its entry in the editor. On by default. */
48
88
  inspect?: boolean;
49
89
  }
50
90
 
51
91
  const PLACEHOLDER = /__PROSEBOOK_LOCALE_(\d+)__/g;
52
- const SCRIPT = /\.[cm]?[jt]sx?$/;
92
+ const SCRIPT = /\.(?:[cm]?[jt]sx?|vue|svelte)$/;
53
93
  const DEV = 'virtual:prosebook/dev';
54
94
  const DEV_ID = '\0prosebook/dev';
95
+ const AREA_ID = '\0prosebook/area/';
55
96
 
56
97
  const normalize = (path: string) => resolve(path).replaceAll('\\', '/');
57
98
 
99
+ /** Where a split client build leaves its area files for the server build: apart from the source and the output. */
100
+ const areaFilesPath = (root: string) => join(root, 'node_modules', '.prosebook', 'area-files.json');
101
+
102
+ /** The index with `areaFiles` among its catalog's fields. */
103
+ const withAreaFiles = (index: string, files: unknown) =>
104
+ index.replace(/defineCatalog(?:<[^>]*>)?\(\{/, (call) => `${call}\n areaFiles: ${JSON.stringify(files)},`);
105
+
58
106
  const inspectorPath = beside(import.meta.url, 'inspector');
59
107
  // prosebook's own modules, which read keys handed to them: `<Text>` calls `rich(props.id)`.
60
108
  const ownDir = normalize(fileURLToPath(new URL('.', import.meta.url)));
@@ -69,7 +117,9 @@ export function prosebook(options: ProsebookPluginOptions = {}): Plugin {
69
117
  let refs: KeyRefs = { exact: new Set(), prefixes: new Set() };
70
118
  const reads = new Map<string, Reads>();
71
119
  const prune = options.prune ?? 'strict';
72
- const placeholders: string[] = [];
120
+ // What each placeholder stands for: a locale's messages, or with `split`, some areas' of them.
121
+ const placeholders: { locale: string; areas?: string[] }[] = [];
122
+ let groups: Groups = new Map();
73
123
  const shipped = { total: 0, kept: 0 };
74
124
 
75
125
  const describe = (e: unknown) => (e instanceof CatalogError ? e.message : e instanceof Error ? (e.stack ?? e.message) : String(e));
@@ -88,6 +138,10 @@ export function prosebook(options: ProsebookPluginOptions = {}): Plugin {
88
138
  return path === `${out}/index.ts` || path === `${out}/index.js`;
89
139
  };
90
140
 
141
+ // Split in the browser only: a server render's copies, one per request, load whole locales.
142
+ const splitting = (ssr: boolean | undefined) => options.split === true && ssr !== true && generated !== undefined;
143
+ const placeholder = (locale: string, areas?: string[]) => `__PROSEBOOK_LOCALE_${placeholders.push({ locale, areas }) - 1}__`;
144
+
91
145
  return {
92
146
  name: 'prosebook',
93
147
 
@@ -99,7 +153,9 @@ export function prosebook(options: ProsebookPluginOptions = {}): Plugin {
99
153
  },
100
154
 
101
155
  resolveId(id) {
102
- return id === DEV ? DEV_ID : null;
156
+ if (id === DEV) return DEV_ID;
157
+ if (id.startsWith(AREA)) return AREA_ID + id.slice(AREA.length);
158
+ return null;
103
159
  },
104
160
 
105
161
  buildStart() {
@@ -109,8 +165,9 @@ export function prosebook(options: ProsebookPluginOptions = {}): Plugin {
109
165
  this.error(describe(e));
110
166
  }
111
167
  refs = { exact: new Set(), prefixes: new Set() };
112
- kept = keeper(refs, options.keep);
168
+ kept = prune === false ? () => true : keeper(refs, options.keep);
113
169
  reads.clear();
170
+ groups = building && options.split === true && generated !== undefined ? areaGroups(config, generated, options.keep) : new Map();
114
171
  placeholders.length = 0;
115
172
  shipped.total = shipped.kept = 0;
116
173
  },
@@ -123,6 +180,8 @@ export function prosebook(options: ProsebookPluginOptions = {}): Plugin {
123
180
  if (!path.endsWith('.yaml') || !dirs.some((d) => path.startsWith(`${d}/`))) return;
124
181
  try {
125
182
  generated = generate(config);
183
+ // An area's module names the locales, which an edit may have added to.
184
+ for (const [id, mod] of server.moduleGraph.idToModuleMap) if (id.startsWith(AREA_ID)) server.moduleGraph.invalidateModule(mod);
126
185
  server.config.logger.info(`prosebook: regenerated from ${relative(config.root, path).replaceAll('\\', '/')}`, {
127
186
  timestamp: true,
128
187
  });
@@ -142,16 +201,26 @@ export function prosebook(options: ProsebookPluginOptions = {}): Plugin {
142
201
  server.middlewares.use(nodeMiddleware(inspectorEndpoints(config, { base, open: editor })));
143
202
  },
144
203
 
145
- load(id) {
204
+ load(id, loadOptions) {
146
205
  if (id === DEV_ID) {
147
206
  if (options.inspect === false) return HOT_SET;
148
207
  return HOT_SET + installInspector(inspectorPath, base);
149
208
  }
209
+ if (id.startsWith(AREA_ID) && generated !== undefined) {
210
+ const [area = '', locale] = id.slice(AREA_ID.length).split('/');
211
+ if (locale !== undefined) return `export default ${placeholder(locale, membersOf(groups, area))};\n`;
212
+ return areaModule(config, generated, area, building ? (l) => placeholder(l, [area]) : undefined, undefined, groups);
213
+ }
214
+ if (splitting(loadOptions?.ssr) && isIndex(id)) return splitIndex(config, generated!, options.keep);
215
+ if (building && options.split === true && loadOptions?.ssr === true && isIndex(id)) {
216
+ const path = areaFilesPath(viteRoot);
217
+ if (existsSync(path)) return withAreaFiles(readFileSync(id.split('?', 1)[0]!, 'utf8'), JSON.parse(readFileSync(path, 'utf8')));
218
+ this.warn('no split client build to link area files from: build the client before the server, or `preload` is handed nothing');
219
+ }
150
220
  if (!building || prune === false || generated === undefined) return null;
151
221
  const locale = localeOf(id);
152
222
  if (locale === undefined) return null;
153
- placeholders.push(locale);
154
- return `export default __PROSEBOOK_LOCALE_${placeholders.length - 1}__;\n`;
223
+ return `export default ${placeholder(locale)};\n`;
155
224
  },
156
225
 
157
226
  // Before other plugins, so a read is found in the source as written: `<Text id>` still JSX, and
@@ -159,21 +228,31 @@ export function prosebook(options: ProsebookPluginOptions = {}): Plugin {
159
228
  transform: {
160
229
  order: 'pre',
161
230
  handler(code, id, transformOptions) {
231
+ const ssr = transformOptions?.ssr;
162
232
  if (!building) {
163
233
  const locale = localeOf(id);
164
234
  if (locale !== undefined) return { code: code + hotLocale(locale), map: null };
165
- if (!isIndex(id)) return null;
166
- // A server render has no page to inspect, and its copies are made per request.
167
- const dev = transformOptions?.ssr === true ? '' : `import ${JSON.stringify(DEV)};\n`;
168
- return { code: dev + code + HOT_INDEX, map: null };
235
+ if (isIndex(id)) {
236
+ // A server render has no page to inspect, and its copies are made per request.
237
+ const dev = ssr === true ? '' : `import ${JSON.stringify(DEV)};\n`;
238
+ return { code: dev + code + HOT_INDEX, map: null };
239
+ }
240
+ // The index's `Messages` type names every key, and is read here before its types are stripped.
241
+ } else if (localeOf(id) !== undefined || isIndex(id)) {
242
+ return null;
169
243
  }
170
- if (prune === false || id.includes('/node_modules/') || id.startsWith('\0')) return null;
171
- // The index's `Messages` type names every key, and is read here before its types are stripped.
172
- if (localeOf(id) !== undefined || isIndex(id)) return null;
173
- keyRefs(code, refs);
244
+ if (id.includes('/node_modules/') || id.startsWith('\0')) return null;
174
245
  const path = id.split('?', 1)[0]!;
175
- if (prune === 'strict' && SCRIPT.test(path) && !normalize(path).startsWith(`${ownDir}/`)) reads.set(id, unseenReads(path, code));
176
- return null;
246
+ // A component file is read whole, as written; the parts its compiler splits off (`?vue&type=script`) are not again.
247
+ const part = SFC.test(path) && path !== id;
248
+ const own = normalize(path).startsWith(`${ownDir}/`);
249
+ if (building && prune !== false) {
250
+ keyRefs(code, refs);
251
+ if (prune === 'strict' && SCRIPT.test(path) && !part && !own) reads.set(id, unseenReads(path, code));
252
+ }
253
+ if (!splitting(ssr) || !SCRIPT.test(path) || part || own) return null;
254
+ const areas = areasNamed(code, lazyAreas(generated!, options.keep));
255
+ return areas.length === 0 ? null : { code: importingAreas(code, path, areas), map: null };
177
256
  },
178
257
  },
179
258
 
@@ -188,8 +267,9 @@ export function prosebook(options: ProsebookPluginOptions = {}): Plugin {
188
267
  if (!code.includes('__PROSEBOOK_LOCALE_') || generated === undefined) return null;
189
268
  const entries = generated.entries;
190
269
  const replaced = code.replace(PLACEHOLDER, (_match, index: string) => {
191
- const locale = placeholders[Number(index)]!;
192
- const list = entries.get(locale) ?? [];
270
+ const { locale, areas } = placeholders[Number(index)]!;
271
+ const all = entries.get(locale) ?? [];
272
+ const list = areas === undefined ? all : inAreas(all, areas);
193
273
  const compiled = compileLocale(list, kept);
194
274
  shipped.total += list.length;
195
275
  shipped.kept += compiled.rows.length;
@@ -198,11 +278,24 @@ export function prosebook(options: ProsebookPluginOptions = {}): Plugin {
198
278
  return { code: replaced, map: null };
199
279
  },
200
280
 
201
- generateBundle() {
281
+ generateBundle(_output, bundle) {
282
+ if (building && options.split === true && this.environment.config.consumer === 'client') {
283
+ // Each lazy locale's part of a group, for the areas in it that locale translates.
284
+ const files: Record<string, Record<string, string>> = {};
285
+ for (const chunk of Object.values(bundle)) {
286
+ if (chunk.type !== 'chunk' || chunk.facadeModuleId?.startsWith(AREA_ID) !== true) continue;
287
+ const [lead = '', locale] = chunk.facadeModuleId.slice(AREA_ID.length).split('/');
288
+ if (locale === undefined) continue;
289
+ const own = generated?.entries.get(locale) ?? [];
290
+ for (const area of membersOf(groups, lead))
291
+ if (inAreas(own, [area]).length > 0) (files[area] ??= {})[locale] = base + chunk.fileName;
292
+ }
293
+ writeFiles(new Map([[areaFilesPath(viteRoot), `${JSON.stringify(files, null, 2)}\n`]]));
294
+ }
202
295
  if (shipped.total === 0) return;
203
- this.info(
204
- `shipped ${shipped.kept} of ${shipped.total} messages across ${placeholders.length} locale(s); pruned ${shipped.total - shipped.kept} no code names`,
205
- );
296
+ const locales = new Set(placeholders.map((p) => p.locale)).size;
297
+ const pruned = prune === false ? '' : `; pruned ${shipped.total - shipped.kept} no code names`;
298
+ this.info(`shipped ${shipped.kept} of ${shipped.total} messages across ${locales} locale(s)${pruned}`);
206
299
  },
207
300
  };
208
301
  }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * `prosebook/vue`: the catalog in Vue components.
3
+ *
4
+ * `useCopy()` reads through a ref the copy bumps on every change, so a template or `computed` that
5
+ * reads a message runs again when the locale switches, with nothing to watch by hand. `rich` returns a
6
+ * `VNode`, which a render function returns as it is and a template renders with
7
+ * `<component :is="...">`; `<Text>` renders one in a template, its tags from slots.
8
+ */
9
+ import {
10
+ computed,
11
+ defineComponent,
12
+ Fragment,
13
+ h,
14
+ inject,
15
+ onScopeDispose,
16
+ provide,
17
+ shallowRef,
18
+ type App,
19
+ type ComputedRef,
20
+ type InjectionKey,
21
+ type Plugin,
22
+ type SetupContext,
23
+ type Slot,
24
+ type VNode,
25
+ type VNodeChild,
26
+ } from 'vue';
27
+
28
+ import { bindCopy, type BoundCopy, type LooseTextProps } from '#src/runtime/bind.ts';
29
+ import type { Copy } from '#src/runtime/copy.ts';
30
+ import type { RegisteredLocale, RegisteredMessages, TagFn } from '#src/runtime/types.ts';
31
+
32
+ type M = RegisteredMessages;
33
+
34
+ /** `Copy`, with `rich` returning a `VNode`, whose reads re-render what calls them. */
35
+ export type VueCopy = BoundCopy<VNodeChild, VNode>;
36
+
37
+ type Tags = { readonly [tag: string]: TagFn<VNodeChild> };
38
+
39
+ export interface CopyOptions {
40
+ /** Tag renderers every rich read gets, beside the copy's own: `link: (chunks) => h('a', { href }, chunks)`. */
41
+ tags?: Tags;
42
+ }
43
+
44
+ const KEY: InjectionKey<VueCopy> = Symbol('prosebook');
45
+
46
+ /** The reader and what stops it following the copy. */
47
+ function bound(copy: Copy, tags: Tags = {}): [VueCopy, () => void] {
48
+ const version = shallowRef(copy.version);
49
+ const stop = copy.subscribe(() => {
50
+ version.value = copy.version;
51
+ });
52
+ const reader = bindCopy<VNodeChild, VNode>(copy, tags, {
53
+ track: () => void version.value,
54
+ whole: (parts) => h(Fragment, null, parts as VNodeChild[]),
55
+ });
56
+ return [reader, stop];
57
+ }
58
+
59
+ /** The copy for the whole app: `app.use(copyPlugin(copy, { tags }))`. */
60
+ export function copyPlugin(copy: Copy, options: CopyOptions = {}): Plugin {
61
+ return {
62
+ install(app: App) {
63
+ const [reader, stop] = bound(copy, options.tags);
64
+ app.provide(KEY, reader);
65
+ app.onUnmount(stop);
66
+ },
67
+ };
68
+ }
69
+
70
+ /** The copy for this component and those under it, in place of the app's. Call it in `setup`. */
71
+ export function provideCopy(copy: Copy, options: CopyOptions = {}): VueCopy {
72
+ const [reader, stop] = bound(copy, options.tags);
73
+ provide(KEY, reader);
74
+ onScopeDispose(stop);
75
+ return reader;
76
+ }
77
+
78
+ /** The catalog, in the current locale. A template or `computed` reading it runs again when the locale changes, or in dev the catalog. */
79
+ export function useCopy(): VueCopy {
80
+ const reader = inject(KEY, null);
81
+ if (reader === null) throw new Error('prosebook: useCopy() needs copyPlugin(copy) installed, or provideCopy(copy) above it');
82
+ return reader;
83
+ }
84
+
85
+ /** The current locale, as a ref. */
86
+ export function useLocale(): ComputedRef<RegisteredLocale> {
87
+ const reader = useCopy();
88
+ return computed(() => reader.locale);
89
+ }
90
+
91
+ /** A slot per tag: each gets what the tag wraps as `chunks`, a `VNode` to render with `<component :is="chunks" />`. */
92
+ export type TextSlots = { [tag: string]: Slot<{ chunks: VNode }> | undefined };
93
+
94
+ export type TextProps<K extends Extract<keyof M, string>> = LooseTextProps<K, VNodeChild>;
95
+
96
+ /**
97
+ * A message in a template: `<Text id="cart.items" :values="{ count }" />`. Its tags render through
98
+ * a slot of the same name, a function in `values` or the provider's renderers, in that order.
99
+ *
100
+ * ```vue
101
+ * <Text id="cart.terms">
102
+ * <template #link="{ chunks }"><a href="/terms"><component :is="chunks" /></a></template>
103
+ * </Text>
104
+ * ```
105
+ */
106
+ export const Text = defineComponent(
107
+ (props: { id: string; values?: object }, { slots }: SetupContext) => {
108
+ const copy = useCopy();
109
+ return () => {
110
+ const fromSlots: Record<string, unknown> = {};
111
+ for (const [name, slot] of Object.entries(slots))
112
+ if (slot !== undefined) fromSlots[name] = (chunks: VNodeChild[]) => slot({ chunks: h(Fragment, null, chunks) });
113
+ return copy.rich(props.id as never, { ...fromSlots, ...props.values } as never);
114
+ };
115
+ },
116
+ { name: 'Text', props: ['id', 'values'] },
117
+ ) as unknown as <K extends Extract<keyof M, string>>(props: TextProps<K>, context: { slots: TextSlots }) => VNode;