prosebook 0.2.0 → 0.4.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 (115) hide show
  1. package/README.md +345 -29
  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-CpUIWb9j.d.ts +6 -0
  7. package/dist/bundle-CpUIWb9j.d.ts.map +1 -0
  8. package/dist/bundle-DboItAre.js +111 -0
  9. package/dist/bundle-DboItAre.js.map +1 -0
  10. package/dist/cli.js +51 -56
  11. package/dist/cli.js.map +1 -1
  12. package/dist/compile-ClK9htKr.d.ts +16 -0
  13. package/dist/compile-ClK9htKr.d.ts.map +1 -0
  14. package/dist/{config-_4HfnJQu.d.ts → config-CzaJJQ60.d.ts} +75 -2
  15. package/dist/config-CzaJJQ60.d.ts.map +1 -0
  16. package/dist/{config-D--RITk2.js → config-nWZf9QmF.js} +42 -212
  17. package/dist/config-nWZf9QmF.js.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-BgG_5Vpm.js +96 -0
  25. package/dist/endpoints-BgG_5Vpm.js.map +1 -0
  26. package/dist/generate-BW3N8EGb.js +5488 -0
  27. package/dist/generate-BW3N8EGb.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-CI8HOokm.js → sarif-B5nrMqrT.js} +103 -87
  57. package/dist/sarif-B5nrMqrT.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 -90
  61. package/dist/tools.d.ts.map +1 -1
  62. package/dist/tools.js +5 -4
  63. package/dist/vite.d.ts +3 -2
  64. package/dist/vite.d.ts.map +1 -1
  65. package/dist/vite.js +29 -109
  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 +60 -23
  83. package/src/tools/config.ts +57 -0
  84. package/src/tools/endpoints.ts +121 -0
  85. package/src/tools/entry.ts +77 -0
  86. package/src/tools/generate.ts +2 -0
  87. package/src/tools/icu.ts +63 -7
  88. package/src/tools/index.ts +6 -3
  89. package/src/tools/lock.ts +7 -0
  90. package/src/tools/mf1.ts +21 -7
  91. package/src/tools/mf2.ts +249 -57
  92. package/src/tools/migrate.ts +3 -2
  93. package/src/tools/native/android.ts +201 -0
  94. package/src/tools/native/apple.ts +139 -0
  95. package/src/tools/native/emit.ts +87 -0
  96. package/src/tools/native/index.ts +48 -0
  97. package/src/tools/native/kotlin.ts +269 -0
  98. package/src/tools/native/model.ts +292 -0
  99. package/src/tools/native/number.ts +210 -0
  100. package/src/tools/native/plurals.ts +103 -0
  101. package/src/tools/native/printf.ts +95 -0
  102. package/src/tools/native/rust.ts +367 -0
  103. package/src/tools/native/swift.ts +326 -0
  104. package/src/tools/syntax.ts +3 -3
  105. package/src/tools/todo.ts +118 -0
  106. package/src/vite.ts +28 -114
  107. package/dist/config-D--RITk2.js.map +0 -1
  108. package/dist/config-_4HfnJQu.d.ts.map +0 -1
  109. package/dist/copy-DXnzXsJa.d.ts.map +0 -1
  110. package/dist/helpers-BPuPGjQP.js +0 -61
  111. package/dist/helpers-BPuPGjQP.js.map +0 -1
  112. package/dist/index.js.map +0 -1
  113. package/dist/sarif-CI8HOokm.js.map +0 -1
  114. package/dist/scan-Ch2BfIUD.js +0 -2561
  115. package/dist/scan-Ch2BfIUD.js.map +0 -1
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "prosebook",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Every string your app shows, in one typed catalog: ICU messages compiled to plain functions, for React and any JavaScript.",
5
5
  "keywords": [
6
+ "bun",
6
7
  "i18n",
7
8
  "icu",
8
9
  "intl",
@@ -10,10 +11,12 @@
10
11
  "localization",
11
12
  "messageformat",
12
13
  "messageformat2",
14
+ "nextjs",
13
15
  "react",
14
16
  "translation",
15
17
  "typescript",
16
- "ux-writing"
18
+ "ux-writing",
19
+ "vite"
17
20
  ],
18
21
  "homepage": "https://gkurt.com/prosebook/",
19
22
  "bugs": {
@@ -32,6 +35,9 @@
32
35
  "bin": {
33
36
  "prosebook": "./dist/cli.js"
34
37
  },
38
+ "workspaces": [
39
+ "examples/*"
40
+ ],
35
41
  "files": [
36
42
  "dist",
37
43
  "src",
@@ -59,6 +65,13 @@
59
65
  "default": "./dist/react/index.js"
60
66
  }
61
67
  },
68
+ "./react/server": {
69
+ "prosebook@dev": "./src/react/server.ts",
70
+ "import": {
71
+ "types": "./dist/react/server.d.ts",
72
+ "default": "./dist/react/server.js"
73
+ }
74
+ },
62
75
  "./vite": {
63
76
  "prosebook@dev": "./src/vite.ts",
64
77
  "import": {
@@ -66,6 +79,20 @@
66
79
  "default": "./dist/vite.js"
67
80
  }
68
81
  },
82
+ "./next": {
83
+ "prosebook@dev": "./src/next.ts",
84
+ "default": {
85
+ "types": "./dist/next.d.ts",
86
+ "default": "./dist/next.js"
87
+ }
88
+ },
89
+ "./bun": {
90
+ "prosebook@dev": "./src/bun.ts",
91
+ "import": {
92
+ "types": "./dist/bun.d.ts",
93
+ "default": "./dist/bun.js"
94
+ }
95
+ },
69
96
  "./config": {
70
97
  "prosebook@dev": "./src/tools/config.ts",
71
98
  "import": {
@@ -93,8 +120,9 @@
93
120
  "build": "tsdown",
94
121
  "prepack": "tsdown",
95
122
  "cli": "bun --conditions=prosebook@dev src/cli.ts",
96
- "example": "vite examples/react-app",
97
- "example:build": "vite build examples/react-app",
123
+ "example": "tsdown && bun run --cwd examples/vite dev",
124
+ "examples:generate": "tsdown && bun run --filter 'example-*' generate",
125
+ "examples:smoke": "bun scripts/smoke.mts",
98
126
  "site": "rm -rf _site && mkdir -p _site && cp -R site/* _site/",
99
127
  "prepare": "husky",
100
128
  "tegami": "bun scripts/tegami.mts"
@@ -111,6 +139,7 @@
111
139
  "@types/react": "^19.3.0",
112
140
  "@types/react-dom": "^19.3.0",
113
141
  "@vitejs/plugin-react": "^6.1.2",
142
+ "cldr-core": "48",
114
143
  "husky": "^9.1.7",
115
144
  "lint-staged": "^17.6.0",
116
145
  "oxfmt": "^0.75.0",
@@ -124,6 +153,7 @@
124
153
  "vite": "^8.3.5"
125
154
  },
126
155
  "peerDependencies": {
156
+ "next": ">=16",
127
157
  "react": ">=18",
128
158
  "typescript": ">=7",
129
159
  "vite": ">=6"
@@ -137,6 +167,9 @@
137
167
  },
138
168
  "typescript": {
139
169
  "optional": true
170
+ },
171
+ "next": {
172
+ "optional": true
140
173
  }
141
174
  },
142
175
  "lint-staged": {
package/src/bun.ts ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * `prosebook/bun`: the catalog as part of a Bun build, or of Bun's dev server.
3
+ *
4
+ * Generates when the plugin is set up. Given to `Bun.build`, it ships only the messages the code
5
+ * names: Bun loads a locale module before it has seen the code that reads it, so the keys come from
6
+ * the source under `scan.dirs` (`tools/bundle.ts`) and each locale loads already pruned. The `bun
7
+ * build` command reads no plugins, so a build that prunes is a script that calls `Bun.build`.
8
+ *
9
+ * Named in bunfig.toml's `[serve.static] plugins`, it serves `Bun.serve`'s bundler instead, which
10
+ * names no entry points: it regenerates whenever a catalog or translation file changes, and each
11
+ * locale module accepts its own update and hands the new messages to every copy, as under Vite. The
12
+ * index starts the inspector on the page too, whose endpoints are `inspector()`'s routes, spread into
13
+ * `Bun.serve`'s; an entry opens in the editor `Bun.openInEditor` finds.
14
+ */
15
+ import { readFileSync } from 'node:fs';
16
+ import { basename, resolve } from 'node:path';
17
+
18
+ import type { BunPlugin } from 'bun';
19
+
20
+ import {
21
+ beside,
22
+ HOT_INDEX,
23
+ HOT_SET,
24
+ hotLocale,
25
+ installInspector,
26
+ keeper,
27
+ prunedModule,
28
+ scanSources,
29
+ unseenMessage,
30
+ watchCatalog,
31
+ type Prune,
32
+ } from './tools/bundle.ts';
33
+ import { CatalogError } from './tools/catalog.ts';
34
+ import { loadConfig, type ProsebookConfig } from './tools/config.ts';
35
+ import { inspectorEndpoints } from './tools/endpoints.ts';
36
+ import { generate } from './tools/generate.ts';
37
+
38
+ export interface ProsebookBunOptions {
39
+ /** The config, inline. Without it, `prosebook.config.*` at `root` is read. */
40
+ config?: ProsebookConfig;
41
+ /** The folder the config's paths resolve from, and where its file is found: the working directory when unset. */
42
+ root?: string;
43
+ /**
44
+ * In `Bun.build`, ship only the messages the code names. `strict`, the default, also fails the
45
+ * build on a read whose key it can't see, such as `t(item.key)`. `loose` prunes without that
46
+ * check; `false` ships every message.
47
+ */
48
+ prune?: Prune;
49
+ /** Keys a build always keeps, exactly or as `prefix.*`: for keys built at runtime from data. */
50
+ keep?: string[];
51
+ /** Under the dev server, Alt-click a string on the page to open its entry in the editor. On by default. */
52
+ inspect?: boolean;
53
+ }
54
+
55
+ const inspectorPath = beside(import.meta.url, 'inspector');
56
+
57
+ const describe = (e: unknown) => (e instanceof CatalogError ? e.message : e instanceof Error ? (e.stack ?? e.message) : String(e));
58
+ const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
59
+
60
+ export function prosebook(options: ProsebookBunOptions = {}): BunPlugin {
61
+ return {
62
+ name: 'prosebook',
63
+ async setup(build) {
64
+ const config = await loadConfig(resolve(options.root ?? process.cwd()), options.config);
65
+ 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$`);
69
+ const nameOf = (path: string) => basename(path).replace(/\.[jt]s$/, '');
70
+
71
+ if ('entrypoints' in build.config) {
72
+ const prune = options.prune ?? 'strict';
73
+ if (prune === false) return;
74
+ const { refs, unseen } = scanSources(config, prune === 'strict');
75
+ 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;
82
+ }
83
+
84
+ const dev = process.env.NODE_ENV !== 'production';
85
+ const inspector = dev && options.inspect !== false ? installInspector(inspectorPath, '/') : '';
86
+ if (dev) {
87
+ watchCatalog(config, () => {
88
+ try {
89
+ generated = generate(config);
90
+ } catch (e) {
91
+ console.error(describe(e));
92
+ }
93
+ });
94
+ }
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;
100
+ });
101
+ },
102
+ };
103
+ }
104
+
105
+ export interface InspectorRoutesOptions {
106
+ /** The config, inline. Without it, `prosebook.config.*` at `root` is read. */
107
+ config?: ProsebookConfig;
108
+ /** The folder the config's paths resolve from, and where its file is found: the working directory when unset. */
109
+ root?: string;
110
+ }
111
+
112
+ /**
113
+ * The inspector's endpoints as `Bun.serve` routes: `routes: { ...inspector(), '/': index }`. For dev
114
+ * only: they read and write the catalog.
115
+ */
116
+ export function inspector(options: InspectorRoutesOptions = {}): Record<string, (req: Request) => Promise<Response>> {
117
+ const handle = inspectorEndpoints(loadConfig(resolve(options.root ?? process.cwd()), options.config), {
118
+ open: ({ file, line, column }) => {
119
+ try {
120
+ Bun.openInEditor(file, { line, column });
121
+ return new Response(null, { status: 204 });
122
+ } catch (e) {
123
+ return new Response(`${e instanceof Error ? e.message : String(e)}: set VISUAL or EDITOR to the editor's command`, { status: 500 });
124
+ }
125
+ },
126
+ });
127
+ const route = async (req: Request) => (await handle(req)) ?? new Response(null, { status: 404 });
128
+ return { '/__prosebook/open': route, '/__prosebook/entry': route };
129
+ }
130
+
131
+ // bunfig.toml names the module, and Bun takes its default export as the plugin.
132
+ export default prosebook();
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;