prosebook 0.1.0 → 0.3.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 (114) hide show
  1. package/README.md +311 -57
  2. package/dist/bun.d.ts +34 -0
  3. package/dist/bun.d.ts.map +1 -0
  4. package/dist/bun.js +98 -0
  5. package/dist/bun.js.map +1 -0
  6. package/dist/bundle-DboItAre.js +111 -0
  7. package/dist/bundle-DboItAre.js.map +1 -0
  8. package/dist/bundle-KD-DiiFa.d.ts +6 -0
  9. package/dist/bundle-KD-DiiFa.d.ts.map +1 -0
  10. package/dist/cli.js +51 -56
  11. package/dist/cli.js.map +1 -1
  12. package/dist/compile-xF3F4oIp.d.ts +16 -0
  13. package/dist/compile-xF3F4oIp.d.ts.map +1 -0
  14. package/dist/{config-CxVMn19f.js → config-BPSmoYel.js} +82 -196
  15. package/dist/config-BPSmoYel.js.map +1 -0
  16. package/dist/{config-CF7HziDa.d.ts → config-DdMkKt_N.d.ts} +85 -7
  17. package/dist/config-DdMkKt_N.d.ts.map +1 -0
  18. package/dist/config.d.ts +2 -2
  19. package/dist/config.js +1 -1
  20. package/dist/{copy-DXnzXsJa.d.ts → copy-B3V_QRkT.d.ts} +20 -7
  21. package/dist/copy-B3V_QRkT.d.ts.map +1 -0
  22. package/dist/copy-Bo_FIoLy.js +154 -0
  23. package/dist/copy-Bo_FIoLy.js.map +1 -0
  24. package/dist/endpoints-CnUfCkIk.js +96 -0
  25. package/dist/endpoints-CnUfCkIk.js.map +1 -0
  26. package/dist/generate-9pR3i5Bj.js +5173 -0
  27. package/dist/generate-9pR3i5Bj.js.map +1 -0
  28. package/dist/helpers-BWGcD0kS.js +96 -0
  29. package/dist/helpers-BWGcD0kS.js.map +1 -0
  30. package/dist/icu-Dej0mNsF.js +218 -0
  31. package/dist/icu-Dej0mNsF.js.map +1 -0
  32. package/dist/index.d.ts +12 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +4 -188
  35. package/dist/inspector.d.ts +4 -1
  36. package/dist/inspector.d.ts.map +1 -1
  37. package/dist/inspector.js +7 -2
  38. package/dist/inspector.js.map +1 -1
  39. package/dist/locale-DpQA1pqH.js +70 -0
  40. package/dist/locale-DpQA1pqH.js.map +1 -0
  41. package/dist/next-loader.d.ts +11 -0
  42. package/dist/next-loader.d.ts.map +1 -0
  43. package/dist/next-loader.js +17 -0
  44. package/dist/next-loader.js.map +1 -0
  45. package/dist/next.d.ts +30 -0
  46. package/dist/next.d.ts.map +1 -0
  47. package/dist/next.js +160 -0
  48. package/dist/next.js.map +1 -0
  49. package/dist/react/index.d.ts +3 -3
  50. package/dist/react/index.d.ts.map +1 -1
  51. package/dist/react/index.js.map +1 -1
  52. package/dist/react/server.d.ts +20 -0
  53. package/dist/react/server.d.ts.map +1 -0
  54. package/dist/react/server.js +34 -0
  55. package/dist/react/server.js.map +1 -0
  56. package/dist/{sarif-PkPvsB07.js → sarif-CWyTAQ0y.js} +103 -87
  57. package/dist/sarif-CWyTAQ0y.js.map +1 -0
  58. package/dist/scan-Bx0_yDSi.js +387 -0
  59. package/dist/scan-Bx0_yDSi.js.map +1 -0
  60. package/dist/tools.d.ts +83 -64
  61. package/dist/tools.d.ts.map +1 -1
  62. package/dist/tools.js +5 -4
  63. package/dist/vite.d.ts +14 -4
  64. package/dist/vite.d.ts.map +1 -1
  65. package/dist/vite.js +60 -114
  66. package/dist/vite.js.map +1 -1
  67. package/package.json +37 -4
  68. package/src/bun.ts +132 -0
  69. package/src/cli.ts +60 -58
  70. package/src/index.ts +1 -1
  71. package/src/inspector.ts +8 -3
  72. package/src/next-loader.ts +16 -0
  73. package/src/next.ts +188 -0
  74. package/src/react/index.tsx +6 -6
  75. package/src/react/server.ts +37 -0
  76. package/src/runtime/copy.ts +22 -3
  77. package/src/runtime/helpers.ts +54 -18
  78. package/src/runtime/locale.ts +19 -0
  79. package/src/runtime/types.ts +21 -6
  80. package/src/tools/bundle.ts +131 -0
  81. package/src/tools/catalog.ts +2 -2
  82. package/src/tools/compile.ts +61 -13
  83. package/src/tools/config.ts +58 -3
  84. package/src/tools/endpoints.ts +121 -0
  85. package/src/tools/entry.ts +77 -0
  86. package/src/tools/generate.ts +32 -32
  87. package/src/tools/icu.ts +93 -8
  88. package/src/tools/index.ts +20 -4
  89. package/src/tools/lint.ts +49 -12
  90. package/src/tools/lock.ts +7 -0
  91. package/src/tools/mf1.ts +21 -7
  92. package/src/tools/mf2.ts +249 -57
  93. package/src/tools/migrate.ts +3 -2
  94. package/src/tools/native/emit.ts +87 -0
  95. package/src/tools/native/index.ts +36 -0
  96. package/src/tools/native/kotlin.ts +269 -0
  97. package/src/tools/native/model.ts +280 -0
  98. package/src/tools/native/number.ts +210 -0
  99. package/src/tools/native/plurals.ts +103 -0
  100. package/src/tools/native/rust.ts +367 -0
  101. package/src/tools/native/swift.ts +326 -0
  102. package/src/tools/scan.ts +134 -4
  103. package/src/tools/syntax.ts +3 -3
  104. package/src/tools/todo.ts +118 -0
  105. package/src/vite.ts +74 -116
  106. package/dist/config-CF7HziDa.d.ts.map +0 -1
  107. package/dist/config-CxVMn19f.js.map +0 -1
  108. package/dist/copy-DXnzXsJa.d.ts.map +0 -1
  109. package/dist/helpers-BPuPGjQP.js +0 -61
  110. package/dist/helpers-BPuPGjQP.js.map +0 -1
  111. package/dist/index.js.map +0 -1
  112. package/dist/sarif-PkPvsB07.js.map +0 -1
  113. package/dist/scan-B0tZ7y-D.js +0 -2447
  114. package/dist/scan-B0tZ7y-D.js.map +0 -1
package/src/cli.ts CHANGED
@@ -3,24 +3,23 @@
3
3
  * The `prosebook` command. Each subcommand is a function of the resolved config; `check` runs the
4
4
  * ones CI needs in one go.
5
5
  */
6
- import { existsSync, mkdirSync, readFileSync, watch, writeFileSync } from 'node:fs';
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
7
  import { join, relative, resolve } from 'node:path';
8
8
  import { parseArgs } from 'node:util';
9
9
 
10
- import { stringify } from 'yaml';
11
-
10
+ import { watchCatalog } from './tools/bundle.ts';
12
11
  import { CatalogError, entryLocator, whereOf, type Catalog, type Problem } from './tools/catalog.ts';
13
12
  import { CONFIG_FILES, loadConfig, type ResolvedConfig } from './tools/config.ts';
14
- import { formatDiff, wordDiff } from './tools/diff.ts';
15
- import { applyFixes } from './tools/entry.ts';
13
+ import { applyFixes, importTranslations } from './tools/entry.ts';
16
14
  import { applyExtract, planExtract } from './tools/extract.ts';
17
15
  import { generate, render } from './tools/generate.ts';
18
- import { lint } from './tools/lint.ts';
19
- import type { StaleTranslation } from './tools/lock.ts';
16
+ import { lint, type Finding } from './tools/lint.ts';
17
+ import { changeOf, type StaleTranslation } from './tools/lock.ts';
20
18
  import { applyMigrate, planMigrate } from './tools/migrate.ts';
21
19
  import { SarifReport } from './tools/sarif.ts';
22
20
  import { isReferenced, scan, sourceFiles } from './tools/scan.ts';
23
21
  import { SYNTAX_NAME } from './tools/syntax.ts';
22
+ import { todoOf } from './tools/todo.ts';
24
23
 
25
24
  const HELP = `prosebook: every string your app shows, in one typed catalog
26
25
 
@@ -34,6 +33,9 @@ Commands:
34
33
  status Translation coverage and stale translations per locale (--strict, --verbose)
35
34
  accept <locale> [keys] Mark stale translations as still right (--all for every stale one)
36
35
  todo <locale> Print the missing and stale entries of a locale, ready to translate
36
+ (--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
37
39
  scan Find hardcoded text in JSX and catalog keys no code reads (--strict)
38
40
  extract [paths] Move hardcoded JSX text into the catalog and rewrite the code to read it
39
41
  (dry run unless --write; --area <name> puts every entry in one area)
@@ -78,6 +80,7 @@ async function main(argv: string[]): Promise<number> {
78
80
  all: { type: 'boolean' },
79
81
  write: { type: 'boolean' },
80
82
  fix: { type: 'boolean' },
83
+ prompt: { type: 'boolean' },
81
84
  area: { type: 'string' },
82
85
  format: { type: 'string' },
83
86
  help: { type: 'boolean', short: 'h' },
@@ -119,7 +122,9 @@ async function main(argv: string[]): Promise<number> {
119
122
  case 'accept':
120
123
  return runAccept(config, rest, values.all === true);
121
124
  case 'todo':
122
- return runTodo(config, rest[0]);
125
+ return runTodo(config, rest[0], values.prompt === true);
126
+ case 'import':
127
+ return runImport(config, rest[0], rest[1]);
123
128
  case 'scan':
124
129
  code = runScan(config, values.strict === true, report);
125
130
  break;
@@ -202,15 +207,7 @@ function watchMode(config: ResolvedConfig): Promise<number> {
202
207
  }
203
208
  };
204
209
  regenerate();
205
- let timer: ReturnType<typeof setTimeout> | undefined;
206
- for (const dir of [config.catalog, config.locales]) {
207
- if (!existsSync(dir)) continue;
208
- watch(dir, { recursive: true }, (_event, file) => {
209
- if (file === null || !file.endsWith('.yaml')) return;
210
- clearTimeout(timer);
211
- timer = setTimeout(regenerate, 50);
212
- });
213
- }
210
+ watchCatalog(config, regenerate);
214
211
  out('prosebook: watching the catalog for changes');
215
212
  return new Promise(() => {});
216
213
  }
@@ -231,12 +228,7 @@ function runLint(config: ResolvedConfig, report?: SarifReport, fix = false): num
231
228
  findings = lint(catalog.messages, config);
232
229
  }
233
230
  }
234
- const locate = entryLocator(config.root);
235
- const placed = findings.map((f) => ({ ...f, ...locate(f.file, f.key, f.at?.start, config.source) }));
236
- for (const f of placed) {
237
- const severity = f.severity === 'error' ? 'error' : 'warn ';
238
- err(`${severity} ${f.file}:${f.line}:${f.column} ${f.key}: [${f.rule}] ${f.message}${f.fix === undefined ? '' : ' (fixable)'}`);
239
- }
231
+ const placed = printFindings(config, findings);
240
232
  if (report !== undefined) {
241
233
  for (const [name, { rule }] of Object.entries(config.lint.rules)) report.rule(name, rule.description);
242
234
  report.rule('allow', RULES.allow);
@@ -264,6 +256,17 @@ function runLint(config: ResolvedConfig, report?: SarifReport, fix = false): num
264
256
  return errors > 0 ? 1 : 0;
265
257
  }
266
258
 
259
+ /** Each finding on its line, placed at its entry's words. */
260
+ function printFindings(config: ResolvedConfig, findings: Finding[]) {
261
+ const locate = entryLocator(config.root);
262
+ const placed = findings.map((f) => ({ ...f, ...locate(f.file, f.key, f.at?.start, config.source) }));
263
+ for (const f of placed) {
264
+ const severity = f.severity === 'error' ? 'error' : 'warn ';
265
+ err(`${severity} ${f.file}:${f.line}:${f.column} ${f.key}: [${f.rule}] ${f.message}${f.fix === undefined ? '' : ' (fixable)'}`);
266
+ }
267
+ return placed;
268
+ }
269
+
267
270
  function missingKeys(catalog: Catalog, locale: string): string[] {
268
271
  return catalog.messages.filter((m) => !m.translations.has(locale)).map((m) => m.key);
269
272
  }
@@ -337,47 +340,46 @@ function runAccept(config: ResolvedConfig, args: string[], all: boolean): number
337
340
  return 0;
338
341
  }
339
342
 
340
- /** What changed in the source of a stale translation, as a word diff, or why that isn't known. */
341
- function changeOf(stale: StaleTranslation): string {
342
- if (stale.was === undefined) return "the earlier text isn't recorded (the lock predates prosebook 0.2)";
343
- return formatDiff(wordDiff(stale.was, stale.now)).replaceAll('\n', ' ');
344
- }
345
-
346
- function runTodo(config: ResolvedConfig, locale: string | undefined): number {
343
+ function runTodo(config: ResolvedConfig, locale: string | undefined, prompt: boolean): number {
347
344
  if (locale === undefined) {
348
- err('Usage: prosebook todo <locale>');
345
+ err('Usage: prosebook todo <locale> [--prompt]');
349
346
  return 1;
350
347
  }
351
348
  const { catalog, stale } = render(config);
352
- const staleKeys = stale.get(locale) ?? new Map<string, StaleTranslation>();
353
- const byArea = new Map<string, string[]>();
354
- let count = 0;
355
- for (const m of catalog.messages) {
356
- const current = m.translations.get(locale);
357
- if (current !== undefined && !staleKeys.has(m.key)) continue;
358
- count += 1;
359
- const lines = byArea.get(m.area) ?? [];
360
- lines.push('');
361
- if (m.context) lines.push(`# ${m.context}`);
362
- for (const [arg, said] of m.argDocs) lines.push(`# {${arg}}: ${said}`);
363
- lines.push(`# ${config.source}: ${m.text.replaceAll('\n', ' ')}`);
364
- const change = staleKeys.get(m.key);
365
- if (change !== undefined) lines.push(`# stale, the source changed: ${changeOf(change)}`);
366
- lines.push(stringify({ [m.key]: current?.text ?? '' }, { lineWidth: 0 }).trimEnd());
367
- byArea.set(m.area, lines);
368
- }
369
- if (count === 0) {
370
- out(`# ${locale} is complete`);
371
- return 0;
349
+ const todo = todoOf(config, catalog, stale.get(locale) ?? new Map(), locale, prompt);
350
+ out(todo.text);
351
+ if (prompt && todo.count > 0) err(`\nHand this to a model, save its reply, and \`prosebook import ${locale} <reply>\`.`);
352
+ return 0;
353
+ }
354
+
355
+ function runImport(config: ResolvedConfig, locale: string | undefined, file: string | undefined): number {
356
+ if (locale === undefined || ((file === undefined || file === '-') && process.stdin.isTTY)) {
357
+ err('Usage: prosebook import <locale> <file>, or the reply on stdin');
358
+ return 1;
372
359
  }
373
- out(
374
- `# prosebook todo: ${count} entr${count === 1 ? 'y' : 'ies'} for ${locale}. Fill them in and paste each block into ${rel(config, config.locales)}/${locale}/<area>.yaml (or ${locale}.yaml).`,
375
- );
376
- for (const [area, lines] of byArea) {
377
- out(`\n# ── ${area}.yaml ──`);
378
- out(lines.join('\n'));
360
+ const reply = readFileSync(file === undefined || file === '-' ? 0 : resolve(file), 'utf8');
361
+ const { written, unchanged, problems } = importTranslations(config, locale, reply);
362
+ reportProblems(config, problems);
363
+ if (written.length + unchanged.length > 0) {
364
+ // A stale translation given back unchanged was looked at and still holds.
365
+ const stale = render(config).stale.get(locale) ?? new Map<string, StaleTranslation>();
366
+ const kept = unchanged.filter((key) => stale.has(key));
367
+ const { catalog } = generate(config, { accept: new Map([[locale, new Set(kept)]]) });
368
+ out(
369
+ `prosebook: imported ${written.length} translation(s) into ${locale}${kept.length > 0 ? `, and accepted ${kept.length} stale one(s) as still right` : ''}`,
370
+ );
371
+ const imported = new Set(written);
372
+ printFindings(
373
+ config,
374
+ lint(
375
+ catalog.messages.filter((m) => imported.has(m.key)),
376
+ config,
377
+ ).filter((f) => f.locale === locale),
378
+ );
379
379
  }
380
- return 0;
380
+ if (problems.length === 0) return 0;
381
+ err(`\n${problems.length} left out: fix them, or hand these lines back to the model, and import again.`);
382
+ return 1;
381
383
  }
382
384
 
383
385
  function runScan(config: ResolvedConfig, strict: boolean, report?: SarifReport): number {
package/src/index.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * functions: this runtime looks them up, picks the locale and falls back, and parses nothing.
7
7
  */
8
8
  export { createCopy, defineCatalog, type Catalog, type Copy, type CopyOptions } from './runtime/copy.ts';
9
- export { chainOf, negotiate, preferredLocales } from './runtime/locale.ts';
9
+ export { acceptedLocales, chainOf, negotiate, preferredLocales } from './runtime/locale.ts';
10
10
  export { createHelpers, flatten } from './runtime/helpers.ts';
11
11
  export type {
12
12
  AnyTagFn,
package/src/inspector.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The dev inspector the Vite plugin loads into the page: hold Alt and point at a string to see its
2
+ * The dev inspector each integration loads into the page: hold Alt and point at a string to see its
3
3
  * key, Alt-click to open the entry it came from, Alt-Shift-click to open the catalog entry instead of
4
4
  * the translation. Alt-right-click edits the entry on the page instead (`inspector-dialog.ts`).
5
5
  *
@@ -113,10 +113,15 @@ export interface InspectorOptions {
113
113
  base: string;
114
114
  }
115
115
 
116
- /** Starts listening for reads and for Alt on the page. Called by the module the Vite plugin serves. */
116
+ /**
117
+ * Starts listening for reads and for Alt on the page, once: Bun and Next call it from the generated
118
+ * index, which runs again whenever it changes.
119
+ */
117
120
  export function install({ base }: InspectorOptions): void {
121
+ const hooks = globalThis as DevHooks;
122
+ if (hooks.__PROSEBOOK_READ__ !== undefined) return;
118
123
  const reads: Reads = new Map();
119
- (globalThis as DevHooks).__PROSEBOOK_READ__ = (key, text, locale) => record(reads, key, text, locale);
124
+ hooks.__PROSEBOOK_READ__ = (key, text, locale) => record(reads, key, text, locale);
120
125
 
121
126
  const box = document.createElement('div');
122
127
  const label = document.createElement('div');
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The Turbopack loader `prosebook/next` sets on the generated modules. In a build it hands over a
3
+ * locale module `withProsebook` has already pruned, since that reads the source once for every
4
+ * locale. In dev, in the browser, it puts what `withProsebook` hands it before the index (the hot
5
+ * hooks, and the inspector), and makes each locale module update the copies holding it whenever Fast
6
+ * Refresh runs it again.
7
+ */
8
+ import { notifyingLocale } from './tools/bundle.ts';
9
+
10
+ export type LoaderOptions = { code: string } | { prepend: string } | { locale: string };
11
+
12
+ export default function loader(this: { getOptions(): LoaderOptions }, source: string): string {
13
+ const options = this.getOptions();
14
+ if ('code' in options) return options.code;
15
+ return 'prepend' in options ? options.prepend + source : notifyingLocale(source, options.locale);
16
+ }
package/src/next.ts ADDED
@@ -0,0 +1,188 @@
1
+ /**
2
+ * `prosebook/next`: the catalog as part of a Next.js app.
3
+ *
4
+ * Generates when `next dev` or `next build` loads the config, and in dev again whenever a catalog or
5
+ * translation file changes, so Fast Refresh picks up the new modules and a broken entry shows in the
6
+ * terminal. Server components read the new text on their next render; in the browser, each locale
7
+ * module hands its new messages to the copies holding it, which re-render in the locale and state
8
+ * they were in. `next build` ships only the messages the code names: each locale module is handed to a
9
+ * Turbopack loader (`next-loader.ts`) that returns it pruned. Turbopack loads a module before it has
10
+ * seen the code that reads it, so the keys come from the source under `scan.dirs` (`tools/bundle.ts`),
11
+ * read here once, and each pruned module travels to the loader as its options.
12
+ *
13
+ * In dev the browser's index also starts the inspector (`inspector.ts`). Its endpoints can't be a
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
+ * rewrite sends `/__prosebook/*` there; an entry opens through Next's own `/__nextjs_launch-editor`.
16
+ *
17
+ * Turbopack only: Next 16 fails a Turbopack build whose config has a `webpack` key, so none is added.
18
+ */
19
+ import { createServer } from 'node:http';
20
+ import type { AddressInfo } from 'node:net';
21
+ import { join, relative, resolve } from 'node:path';
22
+
23
+ import type { LoaderOptions } from './next-loader.ts';
24
+ import {
25
+ beside,
26
+ HOT_SET,
27
+ installInspector,
28
+ keeper,
29
+ prunedModule,
30
+ scanSources,
31
+ unseenMessage,
32
+ watchCatalog,
33
+ type Prune,
34
+ } from './tools/bundle.ts';
35
+ import { CatalogError } from './tools/catalog.ts';
36
+ import { loadConfig, type ProsebookConfig, type ResolvedConfig } from './tools/config.ts';
37
+ import { inspectorEndpoints, nodeMiddleware, redirect } from './tools/endpoints.ts';
38
+ import { generate, type Generated } from './tools/generate.ts';
39
+
40
+ export interface ProsebookNextOptions {
41
+ /** The config, inline. Without it, `prosebook.config.*` at `root` is read. */
42
+ config?: ProsebookConfig;
43
+ /** The folder the config's paths resolve from, and where its file is found: the Next app's when unset. */
44
+ root?: string;
45
+ /**
46
+ * Ship only the messages the code names. `strict`, the default, also fails the build on a read
47
+ * whose key it can't see, such as `t(item.key)`. `loose` prunes without that check; `false` ships
48
+ * every message.
49
+ */
50
+ prune?: Prune;
51
+ /** Keys a build always keeps, exactly or as `prefix.*`: for keys built at runtime from data. */
52
+ keep?: string[];
53
+ /** In dev, Alt-click a string on the page to open its entry in the editor. On by default. */
54
+ inspect?: boolean;
55
+ }
56
+
57
+ type Rules = { [glob: string]: unknown };
58
+ type Rewrite = { source: string; destination: string };
59
+ type Rewrites = Rewrite[] | { beforeFiles?: Rewrite[]; afterFiles?: Rewrite[]; fallback?: Rewrite[] };
60
+ type ConfigFunction<C> = (phase: string, context: { defaultConfig: C }) => C | Promise<C>;
61
+
62
+ const DEV = 'phase-development-server';
63
+ const BUILD = 'phase-production-build';
64
+
65
+ const loaderPath = beside(import.meta.url, 'next-loader');
66
+ const inspectorPath = beside(import.meta.url, 'inspector');
67
+
68
+ // Next loads its config more than once in a process; one watcher and one inspector server per catalog
69
+ // is enough.
70
+ const watching = new Set<string>();
71
+ const serving = new Map<string, Promise<number>>();
72
+
73
+ const describe = (e: unknown) => (e instanceof CatalogError ? e.message : e instanceof Error ? (e.stack ?? e.message) : String(e));
74
+ const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
75
+
76
+ /**
77
+ * Wraps the Next config: `export default withProsebook({ ... })`. Takes the config as an object or as
78
+ * the function Next calls with the phase, and returns the function.
79
+ */
80
+ export function withProsebook<C extends object>(
81
+ next: C | ConfigFunction<C> = {} as C,
82
+ options: ProsebookNextOptions = {},
83
+ ): (phase: string, context: { defaultConfig: C }) => Promise<C> {
84
+ return async (phase, context) => {
85
+ const resolved = typeof next === 'function' ? await next(phase, context) : next;
86
+ if (phase !== DEV && phase !== BUILD) return resolved;
87
+ const config = await loadConfig(resolve(options.root ?? process.cwd()), options.config);
88
+ if (config.language === 'ts' && config.importExtension === '.js')
89
+ throw new Error("prosebook: Turbopack doesn't resolve `./en.js` to `en.ts`: set `importExtension: ''` in the prosebook config");
90
+ const generated = generate(config);
91
+ if (phase === DEV) {
92
+ watch(config);
93
+ if (options.inspect === false) return withRules(resolved, hotRules(config, generated, HOT_SET));
94
+ const base = `${(resolved as { basePath?: string }).basePath ?? ''}/`;
95
+ // A relative import, which Turbopack resolves as it would the app's own.
96
+ const path = relative(config.out, inspectorPath).replaceAll('\\', '/');
97
+ const prepend = HOT_SET + installInspector(path.startsWith('.') ? path : `./${path}`, base);
98
+ return withRewrite(withRules(resolved, hotRules(config, generated, prepend)), await serveInspector(config, base));
99
+ }
100
+ const prune = options.prune ?? 'strict';
101
+ if (prune === false) return resolved;
102
+ return withRules(resolved, prunedRules(config, generated, prune, options.keep));
103
+ };
104
+ }
105
+
106
+ function withRules<C extends object>(resolved: C, rules: Rules): C {
107
+ const turbopack = (resolved as { turbopack?: { rules?: Rules } }).turbopack;
108
+ return { ...resolved, turbopack: { ...turbopack, rules: { ...turbopack?.rules, ...rules } } };
109
+ }
110
+
111
+ /** Sends `/__prosebook/*` to the inspector's server, before any of the app's own rewrites. */
112
+ function withRewrite<C extends object>(resolved: C, port: number): C {
113
+ const theirs = (resolved as { rewrites?: () => Promise<Rewrites> }).rewrites;
114
+ const ours = [{ source: '/__prosebook/:path*', destination: `http://127.0.0.1:${port}/__prosebook/:path*` }];
115
+ const rewrites = async (): Promise<Rewrites> => {
116
+ const r = (await theirs?.()) ?? [];
117
+ return Array.isArray(r) ? { beforeFiles: ours, afterFiles: r } : { ...r, beforeFiles: [...ours, ...(r.beforeFiles ?? [])] };
118
+ };
119
+ return { ...resolved, rewrites };
120
+ }
121
+
122
+ /**
123
+ * Serves the inspector's endpoints on a free local port, for as long as the process lives. Next's
124
+ * rewrite forwards the page's host, so an edit is still checked against the page's origin.
125
+ */
126
+ function serveInspector(config: ResolvedConfig, base: string): Promise<number> {
127
+ let port = serving.get(config.catalog);
128
+ if (port !== undefined) return port;
129
+ const handle = inspectorEndpoints(config, {
130
+ open: ({ file, line, column }) =>
131
+ redirect(`${base}__nextjs_launch-editor?${new URLSearchParams({ file, line1: String(line), column1: String(column) })}`),
132
+ });
133
+ const middleware = nodeMiddleware(handle);
134
+ const server = createServer((req, res) =>
135
+ middleware(req, res, () => {
136
+ res.statusCode = 404;
137
+ res.end();
138
+ }),
139
+ );
140
+ port = new Promise<number>((done, fail) => {
141
+ server.once('error', fail);
142
+ server.listen(0, '127.0.0.1', () => done((server.address() as AddressInfo).port));
143
+ });
144
+ server.unref();
145
+ serving.set(config.catalog, port);
146
+ return port;
147
+ }
148
+
149
+ function watch(config: ResolvedConfig): void {
150
+ if (watching.has(config.catalog)) return;
151
+ watching.add(config.catalog);
152
+ watchCatalog(config, () => {
153
+ try {
154
+ generate(config);
155
+ console.info(`prosebook: regenerated ${relative(config.root, config.out).replaceAll('\\', '/')}`);
156
+ } catch (e) {
157
+ console.error(describe(e));
158
+ }
159
+ });
160
+ }
161
+
162
+ /**
163
+ * A rule per generated module: matched by its file name, then by its path, which Turbopack gives from
164
+ * its own root, so only the end is known.
165
+ */
166
+ function rule(config: ResolvedConfig, name: string, options: LoaderOptions, browser = false): [string, unknown] {
167
+ const file = `${name}.${config.language === 'ts' ? 'ts' : 'js'}`;
168
+ const path = { path: new RegExp(`(?:^|/)${escape(join(relative(config.root, config.out), file).replaceAll('\\', '/'))}$`) };
169
+ return [file, { condition: browser ? { all: ['browser', path] } : path, loaders: [{ loader: loaderPath, options }] }];
170
+ }
171
+
172
+ /**
173
+ * In the browser: the index starts with `prepend`, the hot hooks and the inspector, there before any
174
+ * copy exists, and each locale updates the copies.
175
+ */
176
+ function hotRules(config: ResolvedConfig, generated: Generated, prepend: string): Rules {
177
+ const locales = [...generated.entries.keys()].map((locale) => rule(config, locale, { locale }, true));
178
+ return Object.fromEntries([rule(config, 'index', { prepend }, true), ...locales]);
179
+ }
180
+
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) })));
186
+ }
187
+
188
+ export default withProsebook;
@@ -14,23 +14,23 @@ type M = RegisteredMessages;
14
14
  type L = RegisteredLocale;
15
15
 
16
16
  /** `Copy`, with `rich` returning a `ReactNode`. */
17
- export interface ReactCopy extends Omit<Copy<M, L>, 'rich'> {
17
+ export interface ReactCopy extends Omit<Copy, 'rich'> {
18
18
  rich<K extends RichKey<M>>(this: void, key: K, ...values: RichParam<M[K], ReactNode>): ReactNode;
19
19
  }
20
20
 
21
- const CopyContext = createContext<{ copy: Copy<M, L>; tags: Tags } | null>(null);
21
+ const CopyContext = createContext<{ copy: Copy; tags: Tags } | null>(null);
22
22
 
23
23
  type Tags = { readonly [tag: string]: TagFn<ReactNode> };
24
24
 
25
25
  export interface CopyProviderProps {
26
- copy: Copy<M, L> | Copy;
26
+ copy: Copy;
27
27
  /** Tag renderers every rich read under this provider gets, beside the copy's own. */
28
28
  tags?: Tags;
29
29
  children?: ReactNode;
30
30
  }
31
31
 
32
32
  export function CopyProvider({ copy, tags, children }: CopyProviderProps) {
33
- const value = useMemo(() => ({ copy: copy as Copy<M, L>, tags: { ...(copy.tags as unknown as Tags), ...tags } }), [copy, tags]);
33
+ const value = useMemo(() => ({ copy: copy, tags: { ...(copy.tags as unknown as Tags), ...tags } }), [copy, tags]);
34
34
  return <CopyContext value={value}>{children}</CopyContext>;
35
35
  }
36
36
 
@@ -59,7 +59,7 @@ export function useLocale(): L {
59
59
  }
60
60
 
61
61
  // Takes the version only so the reader is rebuilt, and its identity changes, when what it reads does.
62
- function readerFor(copy: Copy<M, L>, tags: Tags, version: number): ReactCopy {
62
+ function readerFor(copy: Copy, tags: Tags, version: number): ReactCopy {
63
63
  return {
64
64
  locale: copy.locale,
65
65
  version,
@@ -108,5 +108,5 @@ export type TextProps<K extends Extract<keyof M, string>> = { id: K } & ValuesPr
108
108
  export function Text<K extends Extract<keyof M, string>>(props: TextProps<K>) {
109
109
  const copy = useCopy();
110
110
  const values = (props as { values?: object }).values;
111
- return <>{copy.rich(props.id as RichKey<M>, values)}</>;
111
+ return <>{copy.rich(props.id, values as never)}</>;
112
112
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `prosebook/react/server`: the catalog in React Server Components, which can't use context. Apart
3
+ * from `prosebook/react`, whose module makes a context as it loads, which the server build refuses.
4
+ */
5
+ import { cache } from 'react';
6
+
7
+ import { createCopy, type Catalog, type Copy, type CopyOptions } from '#src/runtime/copy.ts';
8
+ import type { MessageShapes } from '#src/runtime/types.ts';
9
+
10
+ /** What a request asks for: a locale, or the locales it prefers, most preferred first. */
11
+ type Asked<L extends string> = L | (string & {}) | readonly string[];
12
+
13
+ /**
14
+ * A reader of one copy per request, ready to use: `locale` is asked once per request, and every
15
+ * server component rendering in it gets the same copy, in the locale negotiated from the answer.
16
+ * React's `cache` keeps it per request, so one request's locale never reaches another's.
17
+ *
18
+ * ```ts
19
+ * export const serverCopy = createServerCopy(catalog, async () => {
20
+ * const picked = (await cookies()).get('locale')?.value;
21
+ * const asked = acceptedLocales((await headers()).get('accept-language'));
22
+ * return picked === undefined ? asked : [picked, ...asked];
23
+ * });
24
+ * const { t, locale } = await serverCopy();
25
+ * ```
26
+ */
27
+ export function createServerCopy<M extends MessageShapes, L extends string>(
28
+ catalog: Catalog<M, L>,
29
+ locale: () => Asked<L> | Promise<Asked<L>>,
30
+ options: Omit<CopyOptions<L>, 'locale'> = {},
31
+ ): () => Promise<Copy<M, L>> {
32
+ return cache(async () => {
33
+ const copy = createCopy(catalog, { ...options, locale: await locale() });
34
+ await copy.ready;
35
+ return copy;
36
+ });
37
+ }
@@ -43,6 +43,11 @@ export interface CopyOptions<L extends string> {
43
43
  tags?: { [tag: string]: AnyTagFn };
44
44
  /** What a key the catalog lacks reads as. Defaults to the key itself. */
45
45
  onMissing?: (key: string, locale: L) => string;
46
+ /**
47
+ * Told of a placeholder that had no value, or one `Intl` refused (`currency: 'euro'`): the message
48
+ * still renders, showing `{name}` there. Defaults to `console.error`.
49
+ */
50
+ onError?: (error: Error, key: string, locale: L) => void;
46
51
  }
47
52
 
48
53
  /** Its methods don't use `this`, so they can be passed around and destructured: `const { t } = copy`. */
@@ -92,9 +97,17 @@ export function createCopy<M extends MessageShapes, L extends string>(catalog: C
92
97
  const listeners = new Set<() => void>();
93
98
  const defaultTags = options.tags ?? {};
94
99
  const formatters = new Map<string, Intl.NumberFormat | Intl.DateTimeFormat | Intl.ListFormat | Intl.RelativeTimeFormat>();
100
+ // The key being rendered, which a helper's report names; none while the inspector renders it again.
101
+ let reading: string | undefined;
102
+ const helpers = (l: L) =>
103
+ createHelpers(l, (error) => {
104
+ if (reading === undefined) return;
105
+ if (options.onError) options.onError(error, reading, l);
106
+ else console.error(`prosebook: ${reading} in ${l}: ${error.message}`);
107
+ });
95
108
 
96
109
  for (const [locale, messages] of Object.entries(catalog.messages) as [L, CompiledMessages][])
97
- loaded.set(locale, { messages, h: createHelpers(locale) });
110
+ loaded.set(locale, { messages, h: helpers(locale) });
98
111
 
99
112
  let locale = catalog.source;
100
113
  let chain: Layer[] = layers(chainOf(locale, catalog.locales, catalog.source));
@@ -127,7 +140,7 @@ export function createCopy<M extends MessageShapes, L extends string>(catalog: C
127
140
  const loader = catalog.loaders[l];
128
141
  if (loader === undefined) return Promise.reject(new Error(`prosebook: no messages for locale "${l}"`));
129
142
  p = loader().then((mod) => {
130
- const layer = { messages: mod.default, h: createHelpers(l) };
143
+ const layer = { messages: mod.default, h: helpers(l) };
131
144
  loaded.set(l, layer);
132
145
  pending.delete(l);
133
146
  return layer;
@@ -163,7 +176,13 @@ export function createCopy<M extends MessageShapes, L extends string>(catalog: C
163
176
  const found = find(key);
164
177
  if (found === undefined) return options.onMissing ? options.onMissing(key, locale) : key;
165
178
  const [message, h] = found;
166
- const out = typeof message === 'string' ? message : message(values, h);
179
+ reading = key;
180
+ let out: unknown;
181
+ try {
182
+ out = typeof message === 'string' ? message : message(values, h);
183
+ } finally {
184
+ reading = undefined;
185
+ }
167
186
  const read = (globalThis as DevHooks).__PROSEBOOK_READ__;
168
187
  if (read !== undefined) read(key, typeof out === 'string' ? out : textOf(message, values, h), h.locale);
169
188
  return out;