prosebook 0.4.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 (122) hide show
  1. package/README.md +288 -28
  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 +11 -2
  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-CpUIWb9j.d.ts → bundle-CLHY_97D.d.ts} +2 -2
  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-nWZf9QmF.js → config-7T4hX_f7.js} +5 -3
  17. package/dist/{config-nWZf9QmF.js.map → config-7T4hX_f7.js.map} +1 -1
  18. package/dist/config-CzaJJQ60.d.ts.map +1 -1
  19. package/dist/config.js +1 -1
  20. package/dist/{copy-B3V_QRkT.d.ts → copy-BXn0aYQn.d.ts} +37 -3
  21. package/dist/copy-BXn0aYQn.d.ts.map +1 -0
  22. package/dist/copy-Bv6S-Hr2.js +235 -0
  23. package/dist/copy-Bv6S-Hr2.js.map +1 -0
  24. package/dist/endpoints-Dhgowovz.js +217 -0
  25. package/dist/endpoints-Dhgowovz.js.map +1 -0
  26. package/dist/generate-1BeQAd1O.d.ts +72 -0
  27. package/dist/generate-1BeQAd1O.d.ts.map +1 -0
  28. package/dist/{generate-BW3N8EGb.js → generate-CKv86Iki.js} +1091 -309
  29. package/dist/generate-CKv86Iki.js.map +1 -0
  30. package/dist/index.d.ts +2 -2
  31. package/dist/index.js +2 -2
  32. package/dist/next-loader.d.ts +9 -2
  33. package/dist/next-loader.d.ts.map +1 -1
  34. package/dist/next-loader.js +12 -3
  35. package/dist/next-loader.js.map +1 -1
  36. package/dist/next.d.ts +10 -2
  37. package/dist/next.d.ts.map +1 -1
  38. package/dist/next.js +94 -21
  39. package/dist/next.js.map +1 -1
  40. package/dist/preact/index.d.ts +34 -0
  41. package/dist/preact/index.d.ts.map +1 -0
  42. package/dist/preact/index.js +72 -0
  43. package/dist/preact/index.js.map +1 -0
  44. package/dist/react/index.d.ts +5 -14
  45. package/dist/react/index.d.ts.map +1 -1
  46. package/dist/react/index.js +9 -33
  47. package/dist/react/index.js.map +1 -1
  48. package/dist/react/server.d.ts +1 -1
  49. package/dist/react/server.js +1 -1
  50. package/dist/{sarif-B5nrMqrT.js → sarif-BnwYC78_.js} +8 -98
  51. package/dist/sarif-BnwYC78_.js.map +1 -0
  52. package/dist/{scan-Bx0_yDSi.js → scan-C0ap4Mpp.js} +145 -20
  53. package/dist/scan-C0ap4Mpp.js.map +1 -0
  54. package/dist/solid/index.d.ts +29 -0
  55. package/dist/solid/index.d.ts.map +1 -0
  56. package/dist/solid/index.js +48 -0
  57. package/dist/solid/index.js.map +1 -0
  58. package/dist/svelte/Text.svelte +13 -0
  59. package/dist/svelte/Text.svelte.d.ts +20 -0
  60. package/dist/svelte/index.d.ts +31 -0
  61. package/dist/svelte/index.d.ts.map +1 -0
  62. package/dist/svelte/index.js +35 -0
  63. package/dist/svelte/index.js.map +1 -0
  64. package/dist/tools.d.ts +43 -64
  65. package/dist/tools.d.ts.map +1 -1
  66. package/dist/tools.js +5 -5
  67. package/dist/vite.d.ts +12 -2
  68. package/dist/vite.d.ts.map +1 -1
  69. package/dist/vite.js +82 -25
  70. package/dist/vite.js.map +1 -1
  71. package/dist/vue/index.d.ts +43 -0
  72. package/dist/vue/index.d.ts.map +1 -0
  73. package/dist/vue/index.js +76 -0
  74. package/dist/vue/index.js.map +1 -0
  75. package/package.json +63 -7
  76. package/src/bun.ts +77 -20
  77. package/src/cli.ts +62 -8
  78. package/src/index.ts +1 -1
  79. package/src/next-loader.ts +17 -5
  80. package/src/next.ts +120 -16
  81. package/src/preact/index.ts +91 -0
  82. package/src/react/index.tsx +12 -42
  83. package/src/runtime/bind.ts +96 -0
  84. package/src/runtime/copy.ts +148 -24
  85. package/src/solid/index.ts +67 -0
  86. package/src/svelte/Text.svelte +13 -0
  87. package/src/svelte/Text.svelte.d.ts +20 -0
  88. package/src/svelte/index.ts +65 -0
  89. package/src/tools/bundle.ts +143 -2
  90. package/src/tools/catalog.ts +10 -1
  91. package/src/tools/chunks.ts +142 -0
  92. package/src/tools/config.ts +1 -1
  93. package/src/tools/entry.ts +19 -20
  94. package/src/tools/exchange.ts +275 -0
  95. package/src/tools/extract.ts +2 -2
  96. package/src/tools/generate.ts +7 -1
  97. package/src/tools/index.ts +2 -1
  98. package/src/tools/native/android.ts +10 -25
  99. package/src/tools/native/apple.ts +70 -8
  100. package/src/tools/native/date.ts +219 -0
  101. package/src/tools/native/kotlin.ts +47 -6
  102. package/src/tools/native/model.ts +42 -16
  103. package/src/tools/native/printf.ts +46 -4
  104. package/src/tools/native/rust.ts +86 -5
  105. package/src/tools/native/swift.ts +62 -6
  106. package/src/tools/scan.ts +146 -25
  107. package/src/tools/todo.ts +6 -1
  108. package/src/vite.ts +121 -28
  109. package/src/vue/index.ts +117 -0
  110. package/dist/bundle-CpUIWb9j.d.ts.map +0 -1
  111. package/dist/bundle-DboItAre.js +0 -111
  112. package/dist/bundle-DboItAre.js.map +0 -1
  113. package/dist/compile-ClK9htKr.d.ts +0 -16
  114. package/dist/compile-ClK9htKr.d.ts.map +0 -1
  115. package/dist/copy-B3V_QRkT.d.ts.map +0 -1
  116. package/dist/copy-Bo_FIoLy.js +0 -154
  117. package/dist/copy-Bo_FIoLy.js.map +0 -1
  118. package/dist/endpoints-BgG_5Vpm.js +0 -96
  119. package/dist/endpoints-BgG_5Vpm.js.map +0 -1
  120. package/dist/generate-BW3N8EGb.js.map +0 -1
  121. package/dist/sarif-B5nrMqrT.js.map +0 -1
  122. package/dist/scan-Bx0_yDSi.js.map +0 -1
@@ -3,6 +3,7 @@
3
3
  * message, and a file-private enum holding what formats them.
4
4
  */
5
5
  import type { NativeTarget } from '#src/tools/config.ts';
6
+ import { pieceExpr, type DateSpec } from './date.ts';
6
7
  import { camel, checkNames, conditionCode, float, leading, stringLiteral, wordsOf } from './emit.ts';
7
8
  import type { NativeMessage, NativeModel, Part } from './model.ts';
8
9
  import type { NumberSpec } from './number.ts';
@@ -36,6 +37,7 @@ function value(part: Exclude<Part, { type: 'text' }>, indent: string, rt: string
36
37
  const of = part.offset === 0 ? argName(part.arg) : `${argName(part.arg)} - ${float(part.offset)}`;
37
38
  return `${rt}.number(${of}, ${rt}.f${part.format}, ${lit(part.fallback)})`;
38
39
  }
40
+ if (part.type === 'date') return `${rt}.d${part.format}(${argName(part.arg)})`;
39
41
  if (part.type === 'select') {
40
42
  const arms = part.arms.map((a) => `${inner}case ${lit(a.key)}: return ${expr(a.parts, inner, rt)}\n`).join('');
41
43
  return `{ () -> String in\n${inner}switch ${argName(part.arg)} {\n${arms}${inner}default: return ${expr(part.other, inner, rt)}\n${inner}}\n${indent}}()`;
@@ -66,10 +68,17 @@ function member(m: NativeMessage, name: string, rt: string, number: NumberProtoc
66
68
  const head = doc(argDocs.length > 0 ? [...m.doc, '', ...argDocs] : m.doc, ' ');
67
69
  const body = ` switch self {\n${cases((parts) => expr(parts, ' ', rt))} }\n`;
68
70
  if (m.args.length === 0) return `${head} public var ${name}: String {\n${body} }\n`;
69
- const params = m.args.map((a) => `${argName(a.name)}: ${a.type === 'number' ? `some ${number.type}` : 'String'}`).join(', ');
71
+ const params = m.args
72
+ .map((a) => `${argName(a.name)}: ${a.type === 'number' ? `some ${number.type}` : a.type === 'date' ? 'Date' : 'String'}`)
73
+ .join(', ');
70
74
  const numbers = m.args
71
- .filter((a) => a.type === 'number')
72
- .map((a) => ` let ${argName(a.name)} = ${argName(a.name)}.${number.value}\n`)
75
+ .map((a) =>
76
+ a.type === 'number'
77
+ ? ` let ${argName(a.name)} = ${argName(a.name)}.${number.value}\n`
78
+ : a.type === 'date'
79
+ ? ` let ${argName(a.name)} = ${rt}.clock(${argName(a.name)})\n`
80
+ : '',
81
+ )
73
82
  .join('');
74
83
  return `${head} public func ${name}(${params}) -> String {\n${numbers}${body} }\n`;
75
84
  }
@@ -97,6 +106,51 @@ function formatConst(spec: NumberSpec, i: number): string {
97
106
  return ` static let f${i} = Format(\n${fields.map((f) => ` ${f}`).join(',\n')}\n )\n`;
98
107
  }
99
108
 
109
+ /** A date format as a function of the clock's fields. */
110
+ function dateFn(spec: DateSpec, i: number): string {
111
+ const digits = spec.digits === undefined ? 'nil' : `g${i}`;
112
+ const at = (field: string) => `t.${field}`;
113
+ const lines = spec.pieces.map((piece) => {
114
+ if (piece.type === 'text') return `s += ${lit(piece.value)}`;
115
+ if (piece.type === 'name') return `s += [${piece.names.map(lit).join(', ')}][${pieceExpr(piece, at)}]`;
116
+ return `s += pad(${pieceExpr(piece, at)}, ${piece.pad}, ${digits})`;
117
+ });
118
+ const table = spec.digits === undefined ? '' : ` static let g${i} = [${spec.digits.map(lit).join(', ')}]\n`;
119
+ const body = lines.map((l) => ` ${l}\n`).join('');
120
+ return `${table} static func d${i}(_ t: Clock) -> String {\n var s = ""\n${body} return s\n }\n`;
121
+ }
122
+
123
+ const DATE = `
124
+ /// A date as the clock shows it in the device's time zone; \`weekday\` is 0 for Sunday.
125
+ struct Clock {
126
+ let year: Int
127
+ let month: Int
128
+ let day: Int
129
+ let hour: Int
130
+ let minute: Int
131
+ let second: Int
132
+ let weekday: Int
133
+ }
134
+
135
+ static func clock(_ date: Date) -> Clock {
136
+ var calendar = Calendar(identifier: .gregorian)
137
+ calendar.timeZone = .current
138
+ let c = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second, .weekday], from: date)
139
+ return Clock(
140
+ year: c.year ?? 0, month: c.month ?? 1, day: c.day ?? 1, hour: c.hour ?? 0, minute: c.minute ?? 0,
141
+ second: c.second ?? 0, weekday: (c.weekday ?? 1) - 1
142
+ )
143
+ }
144
+
145
+ /// \`value\` written with at least \`width\` digits, in \`digits\` where the locale has its own.
146
+ private static func pad(_ value: Int, _ width: Int, _ digits: [String]?) -> String {
147
+ var text = String(value)
148
+ while text.count < width { text = "0" + text }
149
+ guard let digits else { return text }
150
+ return text.map { c in c.wholeNumberValue.map { digits[$0] } ?? String(c) }.joined()
151
+ }
152
+ `;
153
+
100
154
  const NUMBER = `
101
155
  /// A number format, taken apart when the file was generated.
102
156
  struct Format {
@@ -265,6 +319,8 @@ export function swiftFile(model: NativeModel, target: NativeTarget & { name: str
265
319
  const number = { type: `${name}Number`, value: `${name.charAt(0).toLowerCase()}${name.slice(1)}Value` };
266
320
  const members = model.messages.map((m, i) => member(m, names[i]![1], rt, number)).join('\n');
267
321
  const usesNumbers = model.messages.some((m) => m.args.some((a) => a.type === 'number'));
322
+ const usesDates = model.dates.length > 0;
323
+ const usesFormats = model.formats.length + model.rules.length > 0;
268
324
  const numberProtocol = !usesNumbers
269
325
  ? ''
270
326
  : `
@@ -285,12 +341,12 @@ ${NUMBER_TYPES.map((t) => `extension ${t}: ${number.type} { public var ${number.
285
341
  })
286
342
  .join('\n');
287
343
  const runtime =
288
- model.formats.length + model.rules.length === 0
344
+ !usesFormats && !usesDates
289
345
  ? ''
290
346
  : `
291
347
  /// What formats the messages, generated with them.
292
- private enum ${rt} {${NUMBER}${model.rules.length > 0 ? PLURAL : ''}
293
- ${model.formats.map(formatConst).join('\n')}${rules === '' ? '' : `\n${rules}`}}
348
+ private enum ${rt} {${usesFormats ? NUMBER : ''}${model.rules.length > 0 ? PLURAL : ''}${usesDates ? DATE : ''}
349
+ ${[...model.formats.map(formatConst), ...model.dates.map(dateFn)].join('\n')}${rules === '' ? '' : `\n${rules}`}}
294
350
  `;
295
351
 
296
352
  return `${banner}
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
  }