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
package/src/bun.ts CHANGED
@@ -11,6 +11,12 @@
11
11
  * locale module accepts its own update and hands the new messages to every copy, as under Vite. The
12
12
  * index starts the inspector on the page too, whose endpoints are `inspector()`'s routes, spread into
13
13
  * `Bun.serve`'s; an entry opens in the editor `Bun.openInEditor` finds.
14
+ *
15
+ * With `split`, the page's catalog is split by area as the Vite plugin's is (`tools/bundle.ts`): each
16
+ * source file under `scan.dirs` naming an area's keys imports the area, a module of the plugin's own
17
+ * namespace. Bun has no transform hook, so those files are read here and handed back. A build knows
18
+ * the keys it keeps before it loads anything, so each area loads already pruned, and which areas
19
+ * share a chunk (`tools/chunks.ts`), so a lazy locale's part is a file per group of them.
14
20
  */
15
21
  import { readFileSync } from 'node:fs';
16
22
  import { basename, resolve } from 'node:path';
@@ -18,19 +24,29 @@ import { basename, resolve } from 'node:path';
18
24
  import type { BunPlugin } from 'bun';
19
25
 
20
26
  import {
27
+ AREA,
28
+ areaModule,
29
+ areasNamed,
30
+ lazyAreas,
21
31
  beside,
22
32
  HOT_INDEX,
23
33
  HOT_SET,
24
34
  hotLocale,
35
+ importingAreas,
36
+ inAreas,
25
37
  installInspector,
26
38
  keeper,
39
+ membersOf,
27
40
  prunedModule,
28
41
  scanSources,
42
+ splitIndex,
29
43
  unseenMessage,
30
44
  watchCatalog,
31
45
  type Prune,
32
46
  } from './tools/bundle.ts';
33
47
  import { CatalogError } from './tools/catalog.ts';
48
+ import { areaGroups } from './tools/chunks.ts';
49
+ import { compileLocale, localeExpression, type Entry } from './tools/compile.ts';
34
50
  import { loadConfig, type ProsebookConfig } from './tools/config.ts';
35
51
  import { inspectorEndpoints } from './tools/endpoints.ts';
36
52
  import { generate } from './tools/generate.ts';
@@ -46,16 +62,28 @@ export interface ProsebookBunOptions {
46
62
  * check; `false` ships every message.
47
63
  */
48
64
  prune?: Prune;
49
- /** Keys a build always keeps, exactly or as `prefix.*`: for keys built at runtime from data. */
65
+ /**
66
+ * Keys a build always keeps, exactly or as `prefix.*`: for keys built at runtime from data. With
67
+ * `split`, their areas load with the catalog.
68
+ */
50
69
  keep?: string[];
70
+ /**
71
+ * Split each locale by area in the page, as the Vite plugin's `split` does, so a lazy route loads
72
+ * only the messages it reads. `Bun.build` needs `splitting: true`. A server's own code loads whole
73
+ * locales.
74
+ */
75
+ split?: boolean;
51
76
  /** Under the dev server, Alt-click a string on the page to open its entry in the editor. On by default. */
52
77
  inspect?: boolean;
53
78
  }
54
79
 
55
80
  const inspectorPath = beside(import.meta.url, 'inspector');
81
+ const NAMESPACE = 'prosebook-area';
56
82
 
57
83
  const describe = (e: unknown) => (e instanceof CatalogError ? e.message : e instanceof Error ? (e.stack ?? e.message) : String(e));
58
84
  const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
85
+ /** A folder's path as a pattern, by either separator: Bun gives paths with either on Windows. */
86
+ const folderPattern = (path: string) => path.split(/[\\/]/).map(escape).join('[\\\\/]');
59
87
 
60
88
  export function prosebook(options: ProsebookBunOptions = {}): BunPlugin {
61
89
  return {
@@ -63,25 +91,25 @@ export function prosebook(options: ProsebookBunOptions = {}): BunPlugin {
63
91
  async setup(build) {
64
92
  const config = await loadConfig(resolve(options.root ?? process.cwd()), options.config);
65
93
  let generated = generate(config);
66
- // The generated modules, by path as Bun gives it: either separator on Windows.
67
- const folder = config.out.split(/[\\/]/).map(escape).join('[\\\\/]');
68
- const filter = new RegExp(`^${folder}[\\\\/][^\\\\/]+\\.[jt]s$`);
94
+ // The generated modules, by path as Bun gives it.
95
+ const filter = new RegExp(`^${folderPattern(config.out)}[\\\\/][^\\\\/]+\\.[jt]s$`);
69
96
  const nameOf = (path: string) => basename(path).replace(/\.[jt]s$/, '');
97
+ const building = 'entrypoints' in build.config;
98
+ const split = options.split === true;
99
+ if (building && split && build.config.splitting !== true)
100
+ throw new Error('prosebook: `split` needs `splitting: true` in Bun.build, or every area ships in the entry chunk');
101
+ // A full-stack build, one `Bun.build` of a server and the pages it imports, names the side it
102
+ // loads for (undocumented, and not in Bun's types); any other build is for its target.
103
+ const client = (args: object) => ('side' in args ? args.side === 'client' : (build.config.target ?? 'browser') === 'browser');
70
104
 
71
- if ('entrypoints' in build.config) {
72
- const prune = options.prune ?? 'strict';
73
- if (prune === false) return;
105
+ const prune = options.prune ?? 'strict';
106
+ let kept: (key: string) => boolean = () => true;
107
+ if (building && prune !== false) {
74
108
  const { refs, unseen } = scanSources(config, prune === 'strict');
75
109
  if (unseen.length > 0) throw new Error(unseenMessage(unseen, config.root));
76
- const kept = keeper(refs, options.keep);
77
- build.onLoad({ filter }, ({ path }) => {
78
- const entries = generated.entries.get(nameOf(path));
79
- return entries === undefined ? undefined : { contents: prunedModule(entries, kept), loader: 'js' };
80
- });
81
- return;
110
+ kept = keeper(refs, options.keep);
82
111
  }
83
-
84
- const dev = process.env.NODE_ENV !== 'production';
112
+ const dev = !building && process.env.NODE_ENV !== 'production';
85
113
  const inspector = dev && options.inspect !== false ? installInspector(inspectorPath, '/') : '';
86
114
  if (dev) {
87
115
  watchCatalog(config, () => {
@@ -92,11 +120,40 @@ export function prosebook(options: ProsebookBunOptions = {}): BunPlugin {
92
120
  }
93
121
  });
94
122
  }
95
- build.onLoad({ filter }, ({ path }) => {
96
- const name = nameOf(path);
97
- const loader = path.endsWith('.ts') ? 'ts' : 'js';
98
- if (generated.entries.has(name)) return { contents: readFileSync(path, 'utf8') + hotLocale(name), loader };
99
- return name === 'index' ? { contents: HOT_SET + inspector + readFileSync(path, 'utf8') + HOT_INDEX, loader } : undefined;
123
+
124
+ build.onLoad({ filter }, (args) => {
125
+ const name = nameOf(args.path);
126
+ if (name === 'index' && split && client(args)) {
127
+ const index = splitIndex(config, generated, options.keep);
128
+ return { contents: building ? index : HOT_SET + inspector + index + HOT_INDEX, loader: 'js' };
129
+ }
130
+ const entries = generated.entries.get(name);
131
+ if (building) return entries === undefined || prune === false ? undefined : { contents: prunedModule(entries, kept), loader: 'js' };
132
+ const loader = args.path.endsWith('.ts') ? 'ts' : 'js';
133
+ if (entries !== undefined) return { contents: readFileSync(args.path, 'utf8') + hotLocale(name), loader };
134
+ return name === 'index' ? { contents: HOT_SET + inspector + readFileSync(args.path, 'utf8') + HOT_INDEX, loader } : undefined;
135
+ });
136
+ if (!split) return;
137
+
138
+ const groups = building ? areaGroups(config, generated, options.keep) : new Map<string, string>();
139
+ // `cart`, or in a build `cart/de`, a lazy locale's part of the group `cart` leads.
140
+ build.onResolve({ filter: new RegExp(`^${escape(AREA)}`) }, ({ path }) => ({ path: path.slice(AREA.length), namespace: NAMESPACE }));
141
+ build.onLoad({ filter: /.*/, namespace: NAMESPACE }, ({ path }) => {
142
+ const [area = '', locale] = path.split('/');
143
+ if (locale !== undefined) {
144
+ const entries = inAreas(generated.entries.get(locale) ?? [], membersOf(groups, area));
145
+ return { contents: prunedModule(entries, kept), loader: 'js' };
146
+ }
147
+ const inline = building ? (_locale: string, entries: Entry[]) => localeExpression(compileLocale(entries, kept)) : undefined;
148
+ return { contents: areaModule(config, generated, area, inline, undefined, groups), loader: 'js' };
149
+ });
150
+ const dirs = config.scan.dirs.map((dir) => folderPattern(resolve(config.root, dir))).join('|');
151
+ const out = new RegExp(`^${folderPattern(config.out)}[\\\\/]`);
152
+ build.onLoad({ filter: new RegExp(`^(?:${dirs})[\\\\/].*\\.[cm]?[jt]sx?$`) }, (args) => {
153
+ if (!client(args) || out.test(args.path) || /[\\/]node_modules[\\/]/.test(args.path)) return undefined;
154
+ const code = readFileSync(args.path, 'utf8');
155
+ const areas = areasNamed(code, lazyAreas(generated, options.keep));
156
+ return areas.length === 0 ? undefined : { contents: importingAreas(code, args.path, areas), loader: args.loader };
100
157
  });
101
158
  },
102
159
  };
package/src/cli.ts CHANGED
@@ -4,13 +4,14 @@
4
4
  * ones CI needs in one go.
5
5
  */
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
- import { join, relative, resolve } from 'node:path';
7
+ import { dirname, extname, join, relative, resolve } from 'node:path';
8
8
  import { parseArgs } from 'node:util';
9
9
 
10
10
  import { watchCatalog } from './tools/bundle.ts';
11
- import { CatalogError, entryLocator, whereOf, type Catalog, type Problem } from './tools/catalog.ts';
11
+ import { CatalogError, entryLocator, SCHEMA_LINE, whereOf, type Catalog, type Problem } from './tools/catalog.ts';
12
12
  import { CONFIG_FILES, loadConfig, type ResolvedConfig } from './tools/config.ts';
13
13
  import { applyFixes, importTranslations } from './tools/entry.ts';
14
+ import { exportOf, type ExchangeFormat } from './tools/exchange.ts';
14
15
  import { applyExtract, planExtract } from './tools/extract.ts';
15
16
  import { generate, render } from './tools/generate.ts';
16
17
  import { lint, type Finding } from './tools/lint.ts';
@@ -34,8 +35,11 @@ Commands:
34
35
  accept <locale> [keys] Mark stale translations as still right (--all for every stale one)
35
36
  todo <locale> Print the missing and stale entries of a locale, ready to translate
36
37
  (--prompt: as a prompt for a model, saying how to reply)
37
- import <locale> [file] Write translations from todo's YAML filled in, or a model's reply to --prompt
38
- (stdin without a file); each is checked first, and one that fails is left out
38
+ export <locale> [file] Write the entries todo lists as XLIFF 2.0 or JSON, for a translation tool
39
+ (--all: every entry; the format from the file's extension, or --format)
40
+ import <locale> [file] Write translations from todo's YAML filled in, a model's reply to --prompt,
41
+ or a tool's XLIFF or JSON (stdin without a file); each is checked first,
42
+ and one that fails is left out
39
43
  scan Find hardcoded text in JSX and catalog keys no code reads (--strict)
40
44
  extract [paths] Move hardcoded JSX text into the catalog and rewrite the code to read it
41
45
  (dry run unless --write; --area <name> puts every entry in one area)
@@ -47,10 +51,12 @@ Options:
47
51
  --root <dir> Project root (default: the current directory)
48
52
  --format <text|sarif> With lint, scan, status and check: sarif writes a SARIF 2.1.0 log to stdout
49
53
  for code scanning, and the usual text to stderr
54
+ --format <xliff|json> With export: the file's format (XLIFF by default on stdout)
50
55
  -h, --help Show this help
51
56
  `;
52
57
 
53
58
  const SARIF_COMMANDS = ['lint', 'scan', 'status', 'check'];
59
+ const EXCHANGE_FORMATS: Record<string, ExchangeFormat> = { xliff: 'xliff', xlf: 'xliff', json: 'json' };
54
60
 
55
61
  /** What the checks that aren't lint rules hold the project to, for the rules in a SARIF log. */
56
62
  const RULES = {
@@ -91,7 +97,12 @@ async function main(argv: string[]): Promise<number> {
91
97
  out(HELP);
92
98
  return 0;
93
99
  }
94
- if (values.format !== undefined && values.format !== 'text' && values.format !== 'sarif') {
100
+ if (command === 'export') {
101
+ if (values.format !== undefined && EXCHANGE_FORMATS[values.format] === undefined) {
102
+ err(`prosebook: export writes xliff or json, not "${values.format}"`);
103
+ return 1;
104
+ }
105
+ } else if (values.format !== undefined && values.format !== 'text' && values.format !== 'sarif') {
95
106
  err(`prosebook: --format is text or sarif, not "${values.format}"`);
96
107
  return 1;
97
108
  }
@@ -123,6 +134,8 @@ async function main(argv: string[]): Promise<number> {
123
134
  return runAccept(config, rest, values.all === true);
124
135
  case 'todo':
125
136
  return runTodo(config, rest[0], values.prompt === true);
137
+ case 'export':
138
+ return runExport(config, rest[0], rest[1], values.format, values.all === true);
126
139
  case 'import':
127
140
  return runImport(config, rest[0], rest[1]);
128
141
  case 'scan':
@@ -187,10 +200,17 @@ function runGenerate(config: ResolvedConfig, check: boolean, report?: SarifRepor
187
200
  out(`prosebook: generated files are up to date (${summary})`);
188
201
  return 0;
189
202
  }
190
- err(`prosebook: out of date, run \`prosebook generate\`:\n ${changed.map((p) => rel(config, p)).join('\n ')}`);
203
+ // A catalog file is listed for its schema line, which generate adds.
204
+ const why = (p: string) =>
205
+ dirname(p) === config.catalog && p.endsWith('.yaml')
206
+ ? `its first line isn't \`${SCHEMA_LINE}\`, so editors can't find the schema`
207
+ : 'out of date';
208
+ err(
209
+ `prosebook: out of date, run \`prosebook generate\`:\n ${changed.map((p) => `${rel(config, p)}${why(p) === 'out of date' ? '' : `: ${why(p)}`}`).join('\n ')}`,
210
+ );
191
211
  report?.rule('out-of-date', RULES['out-of-date']);
192
212
  for (const p of changed)
193
- report?.add({ rule: 'out-of-date', level: 'error', message: 'out of date: run `prosebook generate`', file: rel(config, p) });
213
+ report?.add({ rule: 'out-of-date', level: 'error', message: `${why(p)}: run \`prosebook generate\``, file: rel(config, p) });
194
214
  return 1;
195
215
  }
196
216
  out(`prosebook: ${changed.length === 0 ? 'nothing changed' : `wrote ${changed.length} file(s)`} (${summary})`);
@@ -352,6 +372,40 @@ function runTodo(config: ResolvedConfig, locale: string | undefined, prompt: boo
352
372
  return 0;
353
373
  }
354
374
 
375
+ function runExport(
376
+ config: ResolvedConfig,
377
+ locale: string | undefined,
378
+ file: string | undefined,
379
+ named: string | undefined,
380
+ all: boolean,
381
+ ): number {
382
+ if (locale === undefined) {
383
+ err('Usage: prosebook export <locale> [file] [--all] [--format xliff|json]');
384
+ return 1;
385
+ }
386
+ const format = EXCHANGE_FORMATS[named ?? (file === undefined ? 'xliff' : extname(file).slice(1).toLowerCase())];
387
+ if (format === undefined) {
388
+ err(`prosebook: ${file} is neither .xlf nor .json: name the format with --format xliff or --format json`);
389
+ return 1;
390
+ }
391
+ const { catalog, stale } = render(config);
392
+ const exported = exportOf(config, catalog, stale.get(locale) ?? new Map(), locale, format, all);
393
+ if (exported.count === 0) {
394
+ err(`prosebook: ${locale} is complete; --all exports every entry`);
395
+ return 0;
396
+ }
397
+ if (file === undefined) {
398
+ process.stdout.write(exported.text);
399
+ return 0;
400
+ }
401
+ const path = resolve(file);
402
+ mkdirSync(dirname(path), { recursive: true });
403
+ writeFileSync(path, exported.text);
404
+ out(`prosebook: exported ${exported.count} entr${exported.count === 1 ? 'y' : 'ies'} for ${locale} to ${file}`);
405
+ if (locale !== config.source) out(`Translate them, then \`prosebook import ${locale} ${file}\`.`);
406
+ return 0;
407
+ }
408
+
355
409
  function runImport(config: ResolvedConfig, locale: string | undefined, file: string | undefined): number {
356
410
  if (locale === undefined || ((file === undefined || file === '-') && process.stdin.isTTY)) {
357
411
  err('Usage: prosebook import <locale> <file>, or the reply on stdin');
@@ -488,7 +542,7 @@ export default defineConfig({
488
542
  });
489
543
  `;
490
544
 
491
- const SAMPLE_CATALOG = `# yaml-language-server: $schema=./catalog.schema.json
545
+ const SAMPLE_CATALOG = `${SCHEMA_LINE}
492
546
  app.title:
493
547
  kind: name
494
548
  surface: title
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * `prosebook generate` compiles it into typed modules, one per locale, whose messages are plain
6
6
  * functions: this runtime looks them up, picks the locale and falls back, and parses nothing.
7
7
  */
8
- export { createCopy, defineCatalog, type Catalog, type Copy, type CopyOptions } from './runtime/copy.ts';
8
+ export { addArea, createCopy, defineCatalog, type Catalog, type Copy, type CopyOptions } from './runtime/copy.ts';
9
9
  export { acceptedLocales, chainOf, negotiate, preferredLocales } from './runtime/locale.ts';
10
10
  export { createHelpers, flatten } from './runtime/helpers.ts';
11
11
  export type {
@@ -3,14 +3,26 @@
3
3
  * locale module `withProsebook` has already pruned, since that reads the source once for every
4
4
  * locale. In dev, in the browser, it puts what `withProsebook` hands it before the index (the hot
5
5
  * hooks, and the inspector), and makes each locale module update the copies holding it whenever Fast
6
- * Refresh runs it again.
6
+ * Refresh runs it again. With `split`, it also runs over the app's own source in the browser, and
7
+ * appends to each file an import of every area whose keys it names, from the folder of area files
8
+ * `withProsebook` writes.
7
9
  */
8
- import { notifyingLocale } from './tools/bundle.ts';
10
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
9
11
 
10
- export type LoaderOptions = { code: string } | { prepend: string } | { locale: string };
12
+ import { areaFile, areasNamed, importingAreas, notifyingLocale, relativeImport } from './tools/bundle.ts';
11
13
 
12
- export default function loader(this: { getOptions(): LoaderOptions }, source: string): string {
14
+ export type LoaderOptions = { code: string } | { prepend: string } | { locale: string } | { areas: string[]; dir: string };
15
+
16
+ type Context = { getOptions(): LoaderOptions; resourcePath?: string; rootContext?: string };
17
+
18
+ export default function loader(this: Context, source: string): string {
19
+ const { resourcePath = '', rootContext = process.cwd() } = this;
13
20
  const options = this.getOptions();
14
21
  if ('code' in options) return options.code;
15
- return 'prepend' in options ? options.prepend + source : notifyingLocale(source, options.locale);
22
+ if ('prepend' in options) return options.prepend + source;
23
+ if ('locale' in options) return notifyingLocale(source, options.locale);
24
+ const areas = areasNamed(source, new Set(options.areas));
25
+ if (areas.length === 0) return source;
26
+ const file = isAbsolute(resourcePath) ? resourcePath : resolve(rootContext, resourcePath);
27
+ return importingAreas(source, file, areas, (area) => relativeImport(dirname(file), join(options.dir, areaFile(area))));
16
28
  }
package/src/next.ts CHANGED
@@ -14,6 +14,12 @@
14
14
  * route of the app without a file in it, so a server of their own answers them on a local port, and a
15
15
  * rewrite sends `/__prosebook/*` there; an entry opens through Next's own `/__nextjs_launch-editor`.
16
16
  *
17
+ * With `split`, client components get the catalog split by area, as under Vite (`tools/bundle.ts`).
18
+ * Turbopack has no virtual modules, so the areas are files, written under `node_modules/.prosebook`,
19
+ * where they are a dependency's code to Turbopack and not the app's; and it has no transform hook, so
20
+ * a loader rule over the app's own source in the browser appends the imports of the areas each file
21
+ * names. Server components and the server render read whole locales.
22
+ *
17
23
  * Turbopack only: Next 16 fails a Turbopack build whose config has a `webpack` key, so none is added.
18
24
  */
19
25
  import { createServer } from 'node:http';
@@ -22,20 +28,31 @@ import { join, relative, resolve } from 'node:path';
22
28
 
23
29
  import type { LoaderOptions } from './next-loader.ts';
24
30
  import {
31
+ areaFile,
32
+ areaModule,
33
+ areasOf,
25
34
  beside,
35
+ fileSpecifiers,
26
36
  HOT_SET,
37
+ inAreas,
27
38
  installInspector,
28
39
  keeper,
40
+ lazyAreas,
41
+ membersOf,
29
42
  prunedModule,
30
43
  scanSources,
44
+ splitIndex,
31
45
  unseenMessage,
32
46
  watchCatalog,
33
47
  type Prune,
34
48
  } from './tools/bundle.ts';
35
49
  import { CatalogError } from './tools/catalog.ts';
50
+ import { areaGroups } from './tools/chunks.ts';
51
+ import { compileLocale, localeExpression } from './tools/compile.ts';
52
+ import type { Entry } from './tools/compile.ts';
36
53
  import { loadConfig, type ProsebookConfig, type ResolvedConfig } from './tools/config.ts';
37
54
  import { inspectorEndpoints, nodeMiddleware, redirect } from './tools/endpoints.ts';
38
- import { generate, type Generated } from './tools/generate.ts';
55
+ import { generate, writeFiles, type Generated } from './tools/generate.ts';
39
56
 
40
57
  export interface ProsebookNextOptions {
41
58
  /** The config, inline. Without it, `prosebook.config.*` at `root` is read. */
@@ -48,8 +65,16 @@ export interface ProsebookNextOptions {
48
65
  * every message.
49
66
  */
50
67
  prune?: Prune;
51
- /** Keys a build always keeps, exactly or as `prefix.*`: for keys built at runtime from data. */
68
+ /**
69
+ * Keys a build always keeps, exactly or as `prefix.*`: for keys built at runtime from data. With
70
+ * `split`, their areas load with the catalog.
71
+ */
52
72
  keep?: string[];
73
+ /**
74
+ * Split each locale by area in client components, as the Vite plugin's `split` does, so a route's
75
+ * client code brings only the messages it reads. Server components read whole locales.
76
+ */
77
+ split?: boolean;
53
78
  /** In dev, Alt-click a string on the page to open its entry in the editor. On by default. */
54
79
  inspect?: boolean;
55
80
  }
@@ -89,23 +114,26 @@ export function withProsebook<C extends object>(
89
114
  throw new Error("prosebook: Turbopack doesn't resolve `./en.js` to `en.ts`: set `importExtension: ''` in the prosebook config");
90
115
  const generated = generate(config);
91
116
  if (phase === DEV) {
92
- watch(config);
93
- if (options.inspect === false) return withRules(resolved, hotRules(config, generated, HOT_SET));
117
+ watch(config, options);
118
+ if (options.inspect === false) return withRules(resolved, hotRules(config, generated, HOT_SET, options));
94
119
  const base = `${(resolved as { basePath?: string }).basePath ?? ''}/`;
95
120
  // A relative import, which Turbopack resolves as it would the app's own.
96
121
  const path = relative(config.out, inspectorPath).replaceAll('\\', '/');
97
122
  const prepend = HOT_SET + installInspector(path.startsWith('.') ? path : `./${path}`, base);
98
- return withRewrite(withRules(resolved, hotRules(config, generated, prepend)), await serveInspector(config, base));
123
+ return withRewrite(withRules(resolved, hotRules(config, generated, prepend, options)), await serveInspector(config, base));
99
124
  }
100
125
  const prune = options.prune ?? 'strict';
101
- if (prune === false) return resolved;
102
- return withRules(resolved, prunedRules(config, generated, prune, options.keep));
126
+ if (prune === false && options.split !== true) return resolved;
127
+ return withRules(resolved, buildRules(config, generated, prune, options));
103
128
  };
104
129
  }
105
130
 
131
+ /** The app's rules and ours: where both have one for a glob, theirs first, then ours, in a list. */
106
132
  function withRules<C extends object>(resolved: C, rules: Rules): C {
107
133
  const turbopack = (resolved as { turbopack?: { rules?: Rules } }).turbopack;
108
- return { ...resolved, turbopack: { ...turbopack, rules: { ...turbopack?.rules, ...rules } } };
134
+ const all: Rules = { ...turbopack?.rules };
135
+ for (const [glob, rule] of Object.entries(rules)) all[glob] = glob in all ? [all[glob], rule].flat() : rule;
136
+ return { ...resolved, turbopack: { ...turbopack, rules: all } };
109
137
  }
110
138
 
111
139
  /** Sends `/__prosebook/*` to the inspector's server, before any of the app's own rewrites. */
@@ -146,12 +174,13 @@ function serveInspector(config: ResolvedConfig, base: string): Promise<number> {
146
174
  return port;
147
175
  }
148
176
 
149
- function watch(config: ResolvedConfig): void {
177
+ function watch(config: ResolvedConfig, options: ProsebookNextOptions): void {
150
178
  if (watching.has(config.catalog)) return;
151
179
  watching.add(config.catalog);
152
180
  watchCatalog(config, () => {
153
181
  try {
154
- generate(config);
182
+ const generated = generate(config);
183
+ if (options.split === true) writeAreas(config, generated);
155
184
  console.info(`prosebook: regenerated ${relative(config.root, config.out).replaceAll('\\', '/')}`);
156
185
  } catch (e) {
157
186
  console.error(describe(e));
@@ -173,16 +202,91 @@ function rule(config: ResolvedConfig, name: string, options: LoaderOptions, brow
173
202
  * In the browser: the index starts with `prepend`, the hot hooks and the inspector, there before any
174
203
  * copy exists, and each locale updates the copies.
175
204
  */
176
- function hotRules(config: ResolvedConfig, generated: Generated, prepend: string): Rules {
205
+ function hotRules(config: ResolvedConfig, generated: Generated, prepend: string, options: ProsebookNextOptions): Rules {
177
206
  const locales = [...generated.entries.keys()].map((locale) => rule(config, locale, { locale }, true));
207
+ if (options.split === true) return Object.fromEntries([...locales, ...splitRules(config, generated, options, prepend)]);
178
208
  return Object.fromEntries([rule(config, 'index', { prepend }, true), ...locales]);
179
209
  }
180
210
 
181
- function prunedRules(config: ResolvedConfig, generated: Generated, prune: 'strict' | 'loose', keep?: string[]): Rules {
182
- const { refs, unseen } = scanSources(config, prune === 'strict');
183
- if (unseen.length > 0) throw new Error(unseenMessage(unseen, config.root));
184
- const kept = keeper(refs, keep);
185
- return Object.fromEntries([...generated.entries].map(([locale, entries]) => rule(config, locale, { code: prunedModule(entries, kept) })));
211
+ function buildRules(config: ResolvedConfig, generated: Generated, prune: Prune, options: ProsebookNextOptions): Rules {
212
+ let kept: (key: string) => boolean = () => true;
213
+ if (prune !== false) {
214
+ const { refs, unseen } = scanSources(config, prune === 'strict');
215
+ if (unseen.length > 0) throw new Error(unseenMessage(unseen, config.root));
216
+ kept = keeper(refs, options.keep);
217
+ }
218
+ const locales =
219
+ prune === false ? [] : [...generated.entries].map(([locale, entries]) => rule(config, locale, { code: prunedModule(entries, kept) }));
220
+ return Object.fromEntries([...locales, ...(options.split === true ? splitRules(config, generated, options, '', kept) : [])]);
221
+ }
222
+
223
+ /** Where a split catalog's area files go: out of the app's source, where Turbopack takes them for a dependency's. */
224
+ const areasDir = (config: ResolvedConfig) => join(config.root, 'node_modules', '.prosebook', 'areas');
225
+
226
+ /**
227
+ * Writes each area's file, and in a build, with `kept`, each lazy locale's part of each group of
228
+ * areas that share a chunk (`tools/chunks.ts`), pruned; in dev an area holds its locales' generated
229
+ * modules whole, so their own updates reach the copies.
230
+ */
231
+ function writeAreas(config: ResolvedConfig, generated: Generated, kept?: (key: string) => boolean, keep?: string[]): void {
232
+ const dir = areasDir(config);
233
+ const where = fileSpecifiers(dir, dir);
234
+ const inline = kept === undefined ? undefined : (_locale: string, entries: Entry[]) => localeExpression(compileLocale(entries, kept));
235
+ const groups = kept === undefined ? new Map<string, string>() : areaGroups(config, generated, keep);
236
+ const files = new Map<string, string>();
237
+ for (const area of areasOf(generated)) files.set(join(dir, areaFile(area)), areaModule(config, generated, area, inline, where, groups));
238
+ if (kept === undefined) {
239
+ writeFiles(files);
240
+ return;
241
+ }
242
+ for (const lead of new Set(groups.values())) {
243
+ for (const [locale, entries] of generated.entries) {
244
+ const parts = inAreas(entries, membersOf(groups, lead));
245
+ if (parts.length > 0 && locale !== config.source && !config.bundled.includes(locale))
246
+ files.set(join(dir, areaFile(lead, locale)), prunedModule(parts, kept));
247
+ }
248
+ }
249
+ writeFiles(files);
250
+ }
251
+
252
+ /**
253
+ * In the browser: the index of a split catalog after `prepend`, and a rule per kind of source file
254
+ * under `scan.dirs` that names an area's keys, which appends its imports of those areas.
255
+ */
256
+ function splitRules(
257
+ config: ResolvedConfig,
258
+ generated: Generated,
259
+ options: ProsebookNextOptions,
260
+ prepend: string,
261
+ kept?: (key: string) => boolean,
262
+ ) {
263
+ writeAreas(config, generated, kept, options.keep);
264
+ const index = rule(
265
+ config,
266
+ 'index',
267
+ { code: prepend + splitIndex(config, generated, options.keep, fileSpecifiers(config.out, areasDir(config))) },
268
+ true,
269
+ );
270
+ const areas = [...lazyAreas(generated, options.keep)];
271
+ if (areas.length === 0) return [index];
272
+ const under = (dir: string) => escape(relative(config.root, dir).replaceAll('\\', '/'));
273
+ const dirs = config.scan.dirs.map(under).filter((d) => d !== '');
274
+ const condition = {
275
+ all: [
276
+ 'browser',
277
+ { not: 'foreign' },
278
+ ...(dirs.length > 0 && dirs.length === config.scan.dirs.length ? [{ path: new RegExp(`(?:^|/)(?:${dirs.join('|')})/`) }] : []),
279
+ { not: { path: new RegExp(`(?:^|/)${under(config.out)}/`) } },
280
+ // As `keyRefs` reads a key: a quote, then the area.
281
+ { content: new RegExp(`['"\`](?:${areas.map(escape).join('|')})\\.`) },
282
+ ],
283
+ };
284
+ const loaderOptions: LoaderOptions = { areas, dir: areasDir(config) };
285
+ const sources = ['*.ts', '*.tsx', '*.js', '*.jsx'].map((glob): [string, unknown] => [
286
+ glob,
287
+ { condition, loaders: [{ loader: loaderPath, options: loaderOptions }], as: glob },
288
+ ]);
289
+ return [index, ...sources];
186
290
  }
187
291
 
188
292
  export default withProsebook;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `prosebook/preact`: the catalog in Preact components, as `prosebook/react` has it in React's.
3
+ *
4
+ * Written with `preact/hooks` alone, so it needs no `preact/compat`.
5
+ */
6
+ import { createContext, Fragment, h, isValidElement, type ComponentChildren, type VNode } from 'preact';
7
+ import { useContext, useEffect, useMemo, useState } from 'preact/hooks';
8
+
9
+ import { bindCopy, type BoundCopy, type TextProps as Props } from '#src/runtime/bind.ts';
10
+ import type { Copy } from '#src/runtime/copy.ts';
11
+ import type { RegisteredLocale, RegisteredMessages, TagFn } from '#src/runtime/types.ts';
12
+
13
+ type M = RegisteredMessages;
14
+
15
+ /** `Copy`, with `rich` returning a `VNode`. */
16
+ export type PreactCopy = BoundCopy<ComponentChildren, VNode>;
17
+
18
+ type Tags = { readonly [tag: string]: TagFn<ComponentChildren> };
19
+
20
+ const CopyContext = createContext<{ copy: Copy; tags: Tags } | null>(null);
21
+
22
+ export interface CopyProviderProps {
23
+ copy: Copy;
24
+ /** Tag renderers every rich read under this provider gets, beside the copy's own. */
25
+ tags?: Tags;
26
+ children?: ComponentChildren;
27
+ }
28
+
29
+ export function CopyProvider({ copy, tags = {}, children }: CopyProviderProps) {
30
+ const value = useMemo(() => ({ copy, tags }), [copy, tags]);
31
+ return h(CopyContext.Provider, { value }, children);
32
+ }
33
+
34
+ function useContextOf(hook: string) {
35
+ const context = useContext(CopyContext);
36
+ if (context === null) throw new Error(`prosebook: ${hook}() needs a <CopyProvider> above it`);
37
+ return context;
38
+ }
39
+
40
+ /** Renders the component again after each change to what the copy reads: a locale switch, or in dev a catalog edit. */
41
+ function useVersion(copy: Copy): number {
42
+ const [, rerender] = useState(0);
43
+ const version = copy.version;
44
+ // Preact has no useSyncExternalStore outside compat: the effect subscribes once the component mounts.
45
+ useEffect(() => {
46
+ const update = () => rerender((n) => n + 1);
47
+ const stop = copy.subscribe(update);
48
+ // A change between this render and the subscription would otherwise go unseen.
49
+ if (copy.version !== version) update();
50
+ return stop;
51
+ }, [copy]);
52
+ return version;
53
+ }
54
+
55
+ /** The catalog, in the current locale. Renders the component again when the locale changes, or in dev the catalog. */
56
+ export function useCopy(): PreactCopy {
57
+ const { copy, tags } = useContextOf('useCopy');
58
+ const version = useVersion(copy);
59
+ // Rebuilt on each version, so its identity changes when what it reads does.
60
+ return useMemo(() => bindCopy(copy, tags, { tag, whole }), [copy, tags, version]);
61
+ }
62
+
63
+ /** The current locale alone, for components that only format or switch. */
64
+ export function useLocale(): RegisteredLocale {
65
+ const { copy } = useContextOf('useLocale');
66
+ useVersion(copy);
67
+ return copy.locale;
68
+ }
69
+
70
+ const tag = (render: (chunks: never) => unknown, chunks: unknown[]) => render(keyed(chunks) as never);
71
+ const whole = (parts: unknown[]): VNode => h(Fragment, null, keyed(parts));
72
+
73
+ /** Text joined where it can be, and each element under its own key, as `prosebook/react` does. */
74
+ function keyed(parts: unknown[]): ComponentChildren[] {
75
+ if (parts.every((p) => typeof p === 'string')) return [parts.join('')];
76
+ return parts.map((p, i) =>
77
+ isValidElement(p) || typeof p === 'object' ? h(Fragment, { key: i }, p as ComponentChildren) : (p as ComponentChildren),
78
+ );
79
+ }
80
+
81
+ export type TextProps<K extends Extract<keyof M, string>> = Props<K, ComponentChildren>;
82
+
83
+ /**
84
+ * A message as an element: `<Text id="cart.items" values={{ count }} />`. Rich messages render their
85
+ * tags through the provider's renderers and any given in `values`.
86
+ */
87
+ export function Text<K extends Extract<keyof M, string>>(props: TextProps<K>): VNode {
88
+ const copy = useCopy();
89
+ const values = (props as { values?: object }).values;
90
+ return copy.rich(props.id as never, values as never);
91
+ }