prosebook 0.2.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 (112) hide show
  1. package/README.md +274 -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-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-D--RITk2.js → config-BPSmoYel.js} +36 -212
  15. package/dist/config-BPSmoYel.js.map +1 -0
  16. package/dist/{config-_4HfnJQu.d.ts → config-DdMkKt_N.d.ts} +61 -2
  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-CI8HOokm.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 -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 +39 -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/emit.ts +87 -0
  94. package/src/tools/native/index.ts +36 -0
  95. package/src/tools/native/kotlin.ts +269 -0
  96. package/src/tools/native/model.ts +280 -0
  97. package/src/tools/native/number.ts +210 -0
  98. package/src/tools/native/plurals.ts +103 -0
  99. package/src/tools/native/rust.ts +367 -0
  100. package/src/tools/native/swift.ts +326 -0
  101. package/src/tools/syntax.ts +3 -3
  102. package/src/tools/todo.ts +118 -0
  103. package/src/vite.ts +28 -114
  104. package/dist/config-D--RITk2.js.map +0 -1
  105. package/dist/config-_4HfnJQu.d.ts.map +0 -1
  106. package/dist/copy-DXnzXsJa.d.ts.map +0 -1
  107. package/dist/helpers-BPuPGjQP.js +0 -61
  108. package/dist/helpers-BPuPGjQP.js.map +0 -1
  109. package/dist/index.js.map +0 -1
  110. package/dist/sarif-CI8HOokm.js.map +0 -1
  111. package/dist/scan-Ch2BfIUD.js +0 -2561
  112. package/dist/scan-Ch2BfIUD.js.map +0 -1
package/README.md CHANGED
@@ -32,11 +32,13 @@ const { t } = useCopy();
32
32
  2.5 KB gzipped.
33
33
  - **Checked.** Placeholders that drift between the source and a translation fail the build. A
34
34
  translation whose source has changed since is marked stale, automatically.
35
- - **Pruned.** A Vite build ships only the messages the code names, and fails on a read whose key
36
- it can't see rather than show a key where a message was.
35
+ - **Pruned.** A Vite, Next.js or Bun build ships only the messages the code names, and fails on a
36
+ read whose key it can't see rather than show a key where a message was.
37
37
  - **Written well.** An optional lint holds the words to a writing guide: word budgets per kind of
38
38
  string, periods, sentence case, banned filler, and `{count} files` outside a plural. Plugins add your
39
39
  own rules, scoped by kind, surface and locale, and translations are held to their source.
40
+ - **Native too.** The same catalog generates Rust, Swift and Kotlin for a tray, a menu bar or a CLI,
41
+ with plurals and number formats that say what the browser says.
40
42
 
41
43
  ## Quick start
42
44
 
@@ -68,6 +70,63 @@ export default defineConfig({ plugins: [react(), prosebook()] });
68
70
  Several apps that share one catalog each point the plugin at it: `prosebook({ root: '../../packages/copy' })`
69
71
  reads the config there and resolves its paths from there, so every app generates the same files.
70
72
 
73
+ ### With Next.js
74
+
75
+ ```ts
76
+ // next.config.ts
77
+ import { withProsebook } from 'prosebook/next';
78
+
79
+ export default withProsebook({/* the rest of your Next config */});
80
+ ```
81
+
82
+ `next dev` and `next build` generate first, and dev regenerates on every catalog edit: server
83
+ components read the new text on their next render, and in the browser the messages change in
84
+ place. A build ships only the messages the code names. It works with Turbopack, Next's default
85
+ bundler, which doesn't resolve `./en.js` to `en.ts`, so set `importExtension: ''` in the prosebook
86
+ config. In dev the [inspector](#finding-a-strings-entry) comes with it, with nothing to add to the app.
87
+
88
+ ### With Bun
89
+
90
+ `Bun.serve`'s dev server bundles a page with the plugins bunfig.toml names, and regenerates and
91
+ swaps the messages in place with this one:
92
+
93
+ ```toml
94
+ # bunfig.toml
95
+ [serve.static]
96
+ plugins = ["prosebook/bun"]
97
+ ```
98
+
99
+ In dev the page gets the [inspector](#finding-a-strings-entry) too. Its endpoints are routes for
100
+ `Bun.serve` to spread into its own, imported only in dev:
101
+
102
+ ```ts
103
+ // src/server.ts
104
+ const dev = process.env.NODE_ENV !== 'production';
105
+
106
+ Bun.serve({
107
+ routes: { ...(dev ? (await import('prosebook/bun')).inspector() : {}), '/': index },
108
+ development: dev && { hmr: true },
109
+ });
110
+ ```
111
+
112
+ The `bun build` command reads no plugins, so a build that prunes calls `Bun.build`, with
113
+ `prosebook/bun` left `external` so the dev-only routes stay out of the bundle:
114
+
115
+ ```ts
116
+ // build.ts
117
+ import { prosebook } from 'prosebook/bun';
118
+
119
+ await Bun.build({
120
+ entrypoints: ['./src/server.ts'],
121
+ target: 'bun',
122
+ outdir: './dist',
123
+ external: ['prosebook/bun'],
124
+ plugins: [prosebook()],
125
+ });
126
+ ```
127
+
128
+ TanStack Start, React Router and other frameworks built on Vite take the Vite plugin as it is.
129
+
71
130
  ### In React
72
131
 
73
132
  ```tsx
@@ -108,8 +167,133 @@ document.title = copy.t('app.title');
108
167
  copy.subscribe(() => render()); // after a locale switch, and in dev after a catalog edit
109
168
  ```
110
169
 
111
- Set `language: 'js'` in the config to generate `.js` modules with a `.d.ts` beside the index. On a
112
- server, create one `copy` per request.
170
+ Set `language: 'js'` in the config to generate `.js` modules with a `.d.ts` beside the index, or
171
+ `importExtension: '.ts'` to run the generated TypeScript in Node as it is.
172
+
173
+ A message never throws. A placeholder with no value, or with one `Intl` refuses (an invalid date,
174
+ `currency: 'euro'`), shows as `{name}` (`{$name}` in MF2), and the rest of the message renders. The
175
+ problem goes to `console.error`, or to `onError` if you pass one:
176
+
177
+ ```js
178
+ createCopy(catalog, { onError: (error, key, locale) => report(error, { key, locale }) });
179
+ ```
180
+
181
+ ### On a server
182
+
183
+ Create one `copy` per request, in the locale it asks for, and wait for its messages.
184
+ `acceptedLocales` reads an `Accept-Language` header into the locales it asks for, by weight, so
185
+ `de;q=0.1, en;q=0.9` asks for English first:
186
+
187
+ ```ts
188
+ import { acceptedLocales, createCopy } from 'prosebook';
189
+
190
+ const copy = createCopy(catalog, { locale: acceptedLocales(request.headers.get('accept-language')) });
191
+ await copy.ready;
192
+ ```
193
+
194
+ **React Server Components** can't read context, so `prosebook/react/server` hands each request one
195
+ copy that every server component in it shares. Your function is asked for the locale once per
196
+ request, and React's `cache` keeps one request's copy from reaching another's:
197
+
198
+ ```ts
199
+ // server-copy.ts, here with Next.js
200
+ import { cookies, headers } from 'next/headers';
201
+ import { acceptedLocales } from 'prosebook';
202
+ import { createServerCopy } from 'prosebook/react/server';
203
+
204
+ export const serverCopy = createServerCopy(catalog, async () => {
205
+ const picked = (await cookies()).get('locale')?.value;
206
+ const asked = acceptedLocales((await headers()).get('accept-language'));
207
+ return picked === undefined ? asked : [picked, ...asked];
208
+ });
209
+ ```
210
+
211
+ ```tsx
212
+ export default async function Page() {
213
+ const { t, locale } = await serverCopy(); // ready, in the locale negotiated for the request
214
+ return <h1 lang={locale}>{t('app.title')}</h1>;
215
+ }
216
+ ```
217
+
218
+ It's its own entry because `prosebook/react` creates a context when it loads, which the server
219
+ build of React refuses. It needs React 19, where `cache` is.
220
+
221
+ A page rendered on the server must hydrate in the same locale, so the browser's copy waits for its
222
+ messages too. Create it above a `<Suspense>` boundary and wait below it: a component that suspends
223
+ before it first mounts loses its state, copy included.
224
+
225
+ ```tsx
226
+ function Providers({ locale, children }: { locale: Locale; children: ReactNode }) {
227
+ const [copy] = useState(() => createCopy(catalog, { locale }));
228
+ return (
229
+ <Suspense>
230
+ <Ready copy={copy}>{children}</Ready>
231
+ </Suspense>
232
+ );
233
+ }
234
+
235
+ function Ready({ copy, children }: { copy: Copy; children: ReactNode }) {
236
+ use(copy.ready);
237
+ return <CopyProvider copy={copy}>{children}</CopyProvider>;
238
+ }
239
+ ```
240
+
241
+ ### Outside the browser: Rust, Swift and Kotlin
242
+
243
+ The parts of an app the browser doesn't draw, a tray, a menu bar, a CLI, an Android shell, can read
244
+ the same catalog. `generate` writes one file per target in `native`, holding every locale:
245
+
246
+ ```ts
247
+ // prosebook.config.ts
248
+ export default defineConfig({
249
+ native: [
250
+ { language: 'rust', out: 'src-tauri/src/copy.rs', keys: ['tray', 'menu'] },
251
+ { language: 'swift', out: 'mac/Sources/Copy.swift' },
252
+ { language: 'kotlin', out: 'android/app/src/main/java/shop/Copy.kt', package: 'shop' },
253
+ ],
254
+ });
255
+ ```
256
+
257
+ Each is an enum of the locales with a member per message, named for its key (`tray.recordings` is
258
+ `tray_recordings` in Rust, `trayRecordings` in Swift and Kotlin). `keys` takes only the keys under
259
+ those prefixes and names them without the prefix, so `tray.quit` above is `quit`:
260
+
261
+ ```rust
262
+ mod copy;
263
+ use copy::Locale;
264
+
265
+ let copy = Locale::negotiate(&["de-AT", "en"]); // de-AT, then de, then the source
266
+ let quit: &'static str = copy.quit();
267
+ let count = copy.recordings(3); // "3 Aufnahmen": any integer or float
268
+ ```
269
+
270
+ ```swift
271
+ let copy = Copy.negotiate(Locale.preferredLanguages)
272
+ menu.title = copy.trayQuit
273
+ label.stringValue = copy.trayRecordings(count: 3)
274
+ ```
275
+
276
+ ```kotlin
277
+ val copy = Copy.negotiate(listOf(Locale.getDefault().toLanguageTag()))
278
+ item.title = copy.trayQuit
279
+ label.text = copy.trayRecordings(3)
280
+ ```
281
+
282
+ A message with no placeholders is a `&'static str` in Rust and a property in Swift and Kotlin; one
283
+ with placeholders is a function taking them in order, a string or a number of any integer or float type. Each member's doc comment
284
+ is the source text, its context and what each argument holds.
285
+
286
+ The files need nothing beside them: each brings the plural rules of its locales, compiled from CLDR's
287
+ (the data `Intl.PluralRules` is built on), and its number formats, taken apart from `Intl` when it is
288
+ generated: the digits, the separators, how integers group, the text around a currency or a percent.
289
+ Each format is checked against `Intl` before any code is written, so native code writes what the
290
+ browser does, Arabic digits and Indian grouping included. A key with no translation falls back
291
+ through its parents to the source as the runtime's do, resolved when the file is written.
292
+
293
+ What can't be written that way fails `generate`, with the fix: a date (pass it formatted, as a plain
294
+ `{when}`), a tag, compact notation, significant digits, a unit or currency name that changes with the
295
+ count (`unitDisplay: 'long'`), and an MF2 option read from an argument. Leave such keys out of the
296
+ target's `keys`.
113
297
 
114
298
  ## The catalog
115
299
 
@@ -199,14 +383,40 @@ en: |-
199
383
 
200
384
  A message with `.match` holds whole sentences per variant, so a translator never stitches a sentence
201
385
  from an arm and the text around it. Several selectors pick the variant MF2's rules pick: the best
202
- match on the first, then the next, an exact number beating its plural category. `:offset` covers
203
- MF1's plural offset.
386
+ match on the first, then the next, an exact number beating its plural category. A number is matched
387
+ as it's shown: under `.input {$n :number minimumFractionDigits=1}`, 1 shows as "1.0", which English
388
+ counts as `other`. `:integer` matches the value rounded, and `:percent` matches it times 100.
389
+ `:offset` covers MF1's plural offset.
204
390
 
205
391
  The functions are MF2's defaults: `:string`, `:number`, `:integer`, `:percent`, `:currency`, `:unit`,
206
392
  `:offset`, and the date and time functions `:date`, `:time` and `:datetime`, which are still a draft
207
- in the spec and follow it as the `messageformat` package does. Option values are literals, since
208
- formatting is settled at build time; `currency=$code` is refused with that reason. Placeholders
209
- aren't wrapped in bidi isolation marks.
393
+ in the spec and follow it as the `messageformat` package does. `.match` selects with `:number`,
394
+ `:integer`, `:percent` or `:string`.
395
+
396
+ **Text direction.** In a locale written right to left, each placeholder is wrapped in Unicode
397
+ isolation marks, so a value written the other way can't reorder the text around it. Without them, a
398
+ Latin name in a Hebrew sentence can pull the punctuation after it to the wrong side. `u:dir` on an
399
+ expression picks its direction (`{$name :string u:dir=ltr}`) and asks for isolation in any locale.
400
+ `isolate: 'always'` in the config also isolates every string placeholder in left-to-right locales,
401
+ as the spec's default does. `isolate: false` turns it off.
402
+
403
+ **Options from arguments.** An option can take its value from an argument, so one message formats
404
+ whatever currency the order is in:
405
+
406
+ ```yaml
407
+ en: 'Total: {$price :currency currency=$code}' # t('cart.total', { price: 9, code: 'USD' }): $9.00
408
+ en: 'Due {$when :time timeZone=$tz}'
409
+ ```
410
+
411
+ The argument is typed with the rest (`code: string`; `currencyDisplay=$d` takes only the values the
412
+ option does), and a translation must use it too. These options can come from an argument: `currency`,
413
+ `currencyDisplay`, `currencySign`, `unit`, `unitDisplay`, `signDisplay`, `fractionDigits` and the
414
+ other digit counts, `timeZone` and `calendar`. The rest, such as a date's `fields` or a number's
415
+ `select`, decide what the formatter is, so they're written in the message and the message says so.
416
+ Literal options are still settled at build time. The ones from arguments are merged in as the
417
+ message renders, and each locale keeps one formatter per distinct value. A value `Intl` refuses,
418
+ such as `code: 'euro'`, throws a `RangeError` there. MF1 has no way to say this, so `migrate 1`
419
+ stops at such a message.
210
420
 
211
421
  **Switching.** `prosebook migrate 2` rewrites the catalog and every translation in MF2, and
212
422
  `prosebook migrate 1` goes back. It's a dry run unless `--write`; then set `messageFormat` in the
@@ -236,7 +446,8 @@ still hold:
236
446
 
237
447
  ```bash
238
448
  prosebook status # coverage and stale counts per locale
239
- prosebook todo de # missing and stale entries, with context, ready to translate
449
+ prosebook todo de > de.yaml # missing and stale entries, with context, ready to translate
450
+ prosebook import de de.yaml # each filled-in entry into its file, checked first
240
451
  prosebook accept de cart.items
241
452
  ```
242
453
 
@@ -251,6 +462,7 @@ de 13/14 92% 1 2
251
462
 
252
463
  $ prosebook todo de
253
464
  # The button on a product that adds one to the cart.
465
+ # kind: action, surface: action
254
466
  # en: Add one to cart
255
467
  # stale, the source changed: Add {+one +}to cart
256
468
  cart.add: In den Warenkorb
@@ -258,6 +470,23 @@ cart.add: In den Warenkorb
258
470
 
259
471
  Stale translations still show by default; `stale: 'fallback'` shows the source instead.
260
472
 
473
+ **Translating with a model.** prosebook calls no model itself. `prosebook todo de --prompt` prints
474
+ the same entries as a prompt for whichever model you use: how to reply, the plural forms German
475
+ takes, what each kind of string and surface asks for, and how to treat a stale entry. Hand it to the
476
+ model and give its reply to `import`, as a file or on stdin:
477
+
478
+ ```bash
479
+ prosebook todo de --prompt | pbcopy # paste into a chat, or hand it to an agent
480
+ prosebook import de reply.md # the YAML block is read out of the reply
481
+ ```
482
+
483
+ `import` checks every entry as the whole catalog before writing it. One whose placeholders, tags
484
+ or syntax don't hold is left out with its fix, which you can give back to the model, and the rest
485
+ are written. A stale translation given back unchanged is accepted as still right. The imported
486
+ translations are then linted, as a person's would be. An agent working in the repository needs none
487
+ of this: it can read `prosebook todo de`, write the translation files itself, and run
488
+ `prosebook check`.
489
+
261
490
  **Pseudo-locale.** `pseudo: 'en-XA'` generates `⟦Ýóóúúŕ çááŕţ⟧` from the source. Switch to it and
262
491
  any plain text on screen is hardcoded, a missing bracket is clipped text, and brackets inside
263
492
  brackets are a sentence spliced from fragments.
@@ -496,7 +725,8 @@ the singular is the plural less its `s`, `es` or `ies`.
496
725
  ## Pruning
497
726
 
498
727
  The Vite plugin replaces each locale module in a build with only the messages some module
499
- references. A reference is any string literal equal to a key, so keys kept in arrays or maps count;
728
+ references. Next's Turbopack and Bun load a locale before the code that reads it, so their
729
+ integrations read the source under `scan.dirs` instead, and every key a file there names is kept. A reference is any string literal equal to a key, so keys kept in arrays or maps count;
500
730
  ``t(`status.${state}`)`` and `t('status.' + state)` keep every key under `status.`. For keys built
501
731
  some other way, list them: `prosebook({ keep: ['plan.*'] })`.
502
732
 
@@ -506,10 +736,11 @@ or, where every key it reads is written elsewhere (in an array, say) or listed i
506
736
  line with `prosebook-ignore`. Reads are known by name: `t` and `rich` alone or as methods, the names
507
737
  they're destructured to (`const { rich: tRich } = copy`) in any module, and `<Text>` from
508
738
  `prosebook/react`. `prune: 'loose'` prunes without the check, and `prune: false` ships everything.
739
+ `prune` and `keep` are options of each integration.
509
740
 
510
741
  ## Finding a string's entry
511
742
 
512
- In dev, hold Alt (Option on a Mac) and point at text on the page: the Vite plugin outlines the
743
+ In dev, hold Alt (Option on a Mac) and point at text on the page: the inspector outlines the
513
744
  string and names its key and the locale it came from. Alt-click opens its entry in your editor: the
514
745
  translation for a translated string, the catalog entry for one that fell back. Alt-Shift-click opens
515
746
  the catalog entry either way. Save, and the string changes on the page, which keeps its locale and
@@ -539,10 +770,17 @@ Nothing is added to the page to make this work. In dev the runtime reports each
539
770
  and the inspector matches the text under the pointer back to it, so `t()`, `<Text>`, a sentence with
540
771
  a link inside it and readable attributes (`placeholder`, `title`, `aria-label`, `alt`) all work. Text
541
772
  the code changes after reading it (`t('…').toUpperCase()`) can't be traced. Where two keys read the
542
- same text, the label says so and the click opens the one read last.
543
-
544
- The editor is the one Vite's `/__open-in-editor` launches: one it finds running, or the command in
545
- `LAUNCH_EDITOR`. Turn the inspector off with `prosebook({ inspect: false })`.
773
+ same text, the label says so and the click opens the one read last. Under Next.js, only text read in
774
+ the browser is found: a server component's reads happen where the inspector can't see them.
775
+
776
+ The inspector comes with each integration, in dev only, and opens the editor its dev server already
777
+ launches. Under Vite, and so in any framework built on it, TanStack Start among them, that is the one
778
+ `/__open-in-editor` launches: one it finds running, or the command in `LAUNCH_EDITOR`. Under Next.js
779
+ it is the one `/__nextjs_launch-editor` launches, from `REACT_EDITOR` or one it finds running; the
780
+ endpoints are served on a local port of their own, which a rewrite of `/__prosebook/*` sends to.
781
+ Under Bun it is the one `Bun.openInEditor` finds, from `VISUAL`, `EDITOR` or bunfig.toml's
782
+ `[debug] editor`, and the endpoints are the `inspector()` routes [above](#with-bun). Turn it off with
783
+ `inspect: false` in the options of the Vite plugin, `withProsebook` or the Bun plugin.
546
784
 
547
785
  ## Config
548
786
 
@@ -557,6 +795,7 @@ export default defineConfig({
557
795
  lock: 'copy/lock.json',
558
796
  source: 'en',
559
797
  messageFormat: 1, // or 2, for Unicode MessageFormat 2
798
+ isolate: 'rtl', // MF2: isolate placeholders in right-to-left locales; 'always', or false
560
799
  bundled: [], // locales shipped in the main bundle; the rest load on first use
561
800
  pseudo: false, // or 'en-XA'
562
801
  stale: 'use', // or 'fallback'
@@ -576,29 +815,35 @@ export default defineConfig({
576
815
  properNouns: [],
577
816
  pathSeparator: '›',
578
817
  },
818
+ native: [], // Rust, Swift or Kotlin files: { language: 'rust', out: 'src/copy.rs', keys: ['tray'] }
579
819
  scan: { dirs: ['src'] },
580
820
  });
581
821
  ```
582
822
 
583
- ## Example
823
+ ## Examples
824
+
825
+ Each is a small shop in English, German and the pseudo-locale, and uses prosebook as an app would,
826
+ from the package. CI generates, lints, typechecks and builds each one, then checks what its build
827
+ serves.
584
828
 
585
- [`examples/react-app`](examples/react-app) is a small shop in English, German, a part-translated
586
- Turkish and the pseudo-locale.
829
+ | Example | |
830
+ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------- |
831
+ | [`examples/vite`](examples/vite) | React with Vite, and a part-translated Turkish. The inspector is on. |
832
+ | [`examples/next`](examples/next) | Next.js: server components read a copy per request, client components the provider's. |
833
+ | [`examples/tanstack-start`](examples/tanstack-start) | TanStack Start, rendered on the server in the request's locale with the Vite plugin. |
834
+ | [`examples/bun`](examples/bun) | `Bun.serve` with a React page, a route that answers in the request's locale, the inspector. |
835
+ | [`examples/node`](examples/node) | No framework and no bundler: a Node script prints a receipt from the generated TypeScript. |
587
836
 
588
837
  ```bash
589
- bun run example
838
+ bun run build # the examples import prosebook from dist
839
+ bun run --cwd examples/next dev
590
840
  ```
591
841
 
592
842
  ## Further
593
843
 
594
844
  Ideas not built yet, roughly in order of value:
595
845
 
596
- 1. **Draft translations with an LLM**: `prosebook translate de --draft`, prompted with `context`,
597
- `kind`, `surface` and `args`, written to a draft file a person reviews before it ships.
598
- 2. **Exchange formats**: XLIFF 2 and JSON export and import for translation tools.
599
- 3. **Per-area splitting**: one chunk per area and locale, so a lazy route loads only its own messages.
600
- 4. **Server rendering helpers**: `Accept-Language` negotiation and a React Server Components reader.
601
- 5. **Native targets**: generate Rust, Swift or Kotlin from the same catalog, for the parts of an app
602
- outside the browser, such as a tray or native menus.
603
- 6. **Variable MF2 options**: `{$price :currency currency=$code}`, compiled to a formatter cached per
604
- value.
846
+ 1. **Exchange formats**: XLIFF 2 and JSON export and import for translation tools.
847
+ 2. **Per-area splitting**: one chunk per area and locale, so a lazy route loads only its own messages.
848
+ 3. **Dates in native targets**: date and time formats taken apart from `Intl` as number formats are,
849
+ so a Rust, Swift or Kotlin message can say when.
package/dist/bun.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { t as Prune } from "./bundle-KD-DiiFa.js";
2
+ import { i as ProsebookConfig } from "./config-DdMkKt_N.js";
3
+ import { BunPlugin } from "bun";
4
+ export interface ProsebookBunOptions {
5
+ /** The config, inline. Without it, `prosebook.config.*` at `root` is read. */
6
+ config?: ProsebookConfig;
7
+ /** The folder the config's paths resolve from, and where its file is found: the working directory when unset. */
8
+ root?: string;
9
+ /**
10
+ * In `Bun.build`, ship only the messages the code names. `strict`, the default, also fails the
11
+ * build on a read whose key it can't see, such as `t(item.key)`. `loose` prunes without that
12
+ * check; `false` ships every message.
13
+ */
14
+ prune?: Prune;
15
+ /** Keys a build always keeps, exactly or as `prefix.*`: for keys built at runtime from data. */
16
+ keep?: string[];
17
+ /** Under the dev server, Alt-click a string on the page to open its entry in the editor. On by default. */
18
+ inspect?: boolean;
19
+ }
20
+ export declare function prosebook(options?: ProsebookBunOptions): BunPlugin;
21
+ export interface InspectorRoutesOptions {
22
+ /** The config, inline. Without it, `prosebook.config.*` at `root` is read. */
23
+ config?: ProsebookConfig;
24
+ /** The folder the config's paths resolve from, and where its file is found: the working directory when unset. */
25
+ root?: string;
26
+ }
27
+ /**
28
+ * The inspector's endpoints as `Bun.serve` routes: `routes: { ...inspector(), '/': index }`. For dev
29
+ * only: they read and write the catalog.
30
+ */
31
+ export declare function inspector(options?: InspectorRoutesOptions): Record<string, (req: Request) => Promise<Response>>;
32
+ declare const _default: BunPlugin;
33
+ export { _default as default };
34
+ //# sourceMappingURL=bun.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bun.d.ts","names":[],"sources":["../src/bun.ts"],"mappings":";;;iBAqCiB;;EAEf,SAAS;;EAET;;;;;;EAMA,QAAQ;;EAER;;EAEA;;wBAQc,UAAU,UAAS,sBAA2B;iBA6C7C;;EAEf,SAAS;;EAET;;;;;;wBAOc,UAAU,UAAS,yBAA8B,gBAAgB,KAAK,YAAY,QAAQ"}
package/dist/bun.js ADDED
@@ -0,0 +1,98 @@
1
+ import { a as installInspector, c as prunedModule, f as watchCatalog, i as hotLocale, l as scanSources, n as HOT_SET, o as keeper, r as beside, t as HOT_INDEX, u as unseenMessage } from "./bundle-DboItAre.js";
2
+ import { n as generate, x as CatalogError } from "./generate-9pR3i5Bj.js";
3
+ import { r as loadConfig } from "./config-BPSmoYel.js";
4
+ import { t as inspectorEndpoints } from "./endpoints-CnUfCkIk.js";
5
+ import { basename, resolve } from "node:path";
6
+ import { readFileSync } from "node:fs";
7
+ // src/bun.ts
8
+ /**
9
+ * `prosebook/bun`: the catalog as part of a Bun build, or of Bun's dev server.
10
+ *
11
+ * Generates when the plugin is set up. Given to `Bun.build`, it ships only the messages the code
12
+ * names: Bun loads a locale module before it has seen the code that reads it, so the keys come from
13
+ * the source under `scan.dirs` (`tools/bundle.ts`) and each locale loads already pruned. The `bun
14
+ * build` command reads no plugins, so a build that prunes is a script that calls `Bun.build`.
15
+ *
16
+ * Named in bunfig.toml's `[serve.static] plugins`, it serves `Bun.serve`'s bundler instead, which
17
+ * names no entry points: it regenerates whenever a catalog or translation file changes, and each
18
+ * locale module accepts its own update and hands the new messages to every copy, as under Vite. The
19
+ * index starts the inspector on the page too, whose endpoints are `inspector()`'s routes, spread into
20
+ * `Bun.serve`'s; an entry opens in the editor `Bun.openInEditor` finds.
21
+ */
22
+ const inspectorPath = beside(import.meta.url, "inspector");
23
+ const describe = (e) => e instanceof CatalogError ? e.message : e instanceof Error ? e.stack ?? e.message : String(e);
24
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
25
+ function prosebook(options = {}) {
26
+ return {
27
+ name: "prosebook",
28
+ async setup(build) {
29
+ const config = await loadConfig(resolve(options.root ?? process.cwd()), options.config);
30
+ let generated = generate(config);
31
+ const folder = config.out.split(/[\\/]/).map(escape).join("[\\\\/]");
32
+ const filter = new RegExp(`^${folder}[\\\\/][^\\\\/]+\\.[jt]s$`);
33
+ const nameOf = (path) => basename(path).replace(/\.[jt]s$/, "");
34
+ if ("entrypoints" in build.config) {
35
+ const prune = options.prune ?? "strict";
36
+ if (prune === false) return;
37
+ const { refs, unseen } = scanSources(config, prune === "strict");
38
+ if (unseen.length > 0) throw new Error(unseenMessage(unseen, config.root));
39
+ const kept = keeper(refs, options.keep);
40
+ build.onLoad({ filter }, ({ path }) => {
41
+ const entries = generated.entries.get(nameOf(path));
42
+ return entries === void 0 ? void 0 : {
43
+ contents: prunedModule(entries, kept),
44
+ loader: "js"
45
+ };
46
+ });
47
+ return;
48
+ }
49
+ const dev = process.env.NODE_ENV !== "production";
50
+ const inspector = dev && options.inspect !== false ? installInspector(inspectorPath, "/") : "";
51
+ if (dev) watchCatalog(config, () => {
52
+ try {
53
+ generated = generate(config);
54
+ } catch (e) {
55
+ console.error(describe(e));
56
+ }
57
+ });
58
+ build.onLoad({ filter }, ({ path }) => {
59
+ const name = nameOf(path);
60
+ const loader = path.endsWith(".ts") ? "ts" : "js";
61
+ if (generated.entries.has(name)) return {
62
+ contents: readFileSync(path, "utf8") + hotLocale(name),
63
+ loader
64
+ };
65
+ return name === "index" ? {
66
+ contents: HOT_SET + inspector + readFileSync(path, "utf8") + HOT_INDEX,
67
+ loader
68
+ } : void 0;
69
+ });
70
+ }
71
+ };
72
+ }
73
+ /**
74
+ * The inspector's endpoints as `Bun.serve` routes: `routes: { ...inspector(), '/': index }`. For dev
75
+ * only: they read and write the catalog.
76
+ */
77
+ function inspector(options = {}) {
78
+ const handle = inspectorEndpoints(loadConfig(resolve(options.root ?? process.cwd()), options.config), { open: ({ file, line, column }) => {
79
+ try {
80
+ Bun.openInEditor(file, {
81
+ line,
82
+ column
83
+ });
84
+ return new Response(null, { status: 204 });
85
+ } catch (e) {
86
+ return new Response(`${e instanceof Error ? e.message : String(e)}: set VISUAL or EDITOR to the editor's command`, { status: 500 });
87
+ }
88
+ } });
89
+ const route = async (req) => await handle(req) ?? new Response(null, { status: 404 });
90
+ return {
91
+ "/__prosebook/open": route,
92
+ "/__prosebook/entry": route
93
+ };
94
+ }
95
+ var bun_default = prosebook();
96
+ export { bun_default as default, inspector, prosebook };
97
+
98
+ //# sourceMappingURL=bun.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bun.js","names":[],"sources":["../src/bun.ts"],"sourcesContent":["/**\n * `prosebook/bun`: the catalog as part of a Bun build, or of Bun's dev server.\n *\n * Generates when the plugin is set up. Given to `Bun.build`, it ships only the messages the code\n * names: Bun loads a locale module before it has seen the code that reads it, so the keys come from\n * the source under `scan.dirs` (`tools/bundle.ts`) and each locale loads already pruned. The `bun\n * build` command reads no plugins, so a build that prunes is a script that calls `Bun.build`.\n *\n * Named in bunfig.toml's `[serve.static] plugins`, it serves `Bun.serve`'s bundler instead, which\n * names no entry points: it regenerates whenever a catalog or translation file changes, and each\n * locale module accepts its own update and hands the new messages to every copy, as under Vite. The\n * index starts the inspector on the page too, whose endpoints are `inspector()`'s routes, spread into\n * `Bun.serve`'s; an entry opens in the editor `Bun.openInEditor` finds.\n */\nimport { readFileSync } from 'node:fs';\nimport { basename, resolve } from 'node:path';\n\nimport type { BunPlugin } from 'bun';\n\nimport {\n beside,\n HOT_INDEX,\n HOT_SET,\n hotLocale,\n installInspector,\n keeper,\n prunedModule,\n scanSources,\n unseenMessage,\n watchCatalog,\n type Prune,\n} from './tools/bundle.ts';\nimport { CatalogError } from './tools/catalog.ts';\nimport { loadConfig, type ProsebookConfig } from './tools/config.ts';\nimport { inspectorEndpoints } from './tools/endpoints.ts';\nimport { generate } from './tools/generate.ts';\n\nexport interface ProsebookBunOptions {\n /** The config, inline. Without it, `prosebook.config.*` at `root` is read. */\n config?: ProsebookConfig;\n /** The folder the config's paths resolve from, and where its file is found: the working directory when unset. */\n root?: string;\n /**\n * In `Bun.build`, ship only the messages the code names. `strict`, the default, also fails the\n * build on a read whose key it can't see, such as `t(item.key)`. `loose` prunes without that\n * check; `false` ships every message.\n */\n prune?: Prune;\n /** Keys a build always keeps, exactly or as `prefix.*`: for keys built at runtime from data. */\n keep?: string[];\n /** Under the dev server, Alt-click a string on the page to open its entry in the editor. On by default. */\n inspect?: boolean;\n}\n\nconst inspectorPath = beside(import.meta.url, 'inspector');\n\nconst describe = (e: unknown) => (e instanceof CatalogError ? e.message : e instanceof Error ? (e.stack ?? e.message) : String(e));\nconst escape = (s: string) => s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n\nexport function prosebook(options: ProsebookBunOptions = {}): BunPlugin {\n return {\n name: 'prosebook',\n async setup(build) {\n const config = await loadConfig(resolve(options.root ?? process.cwd()), options.config);\n let generated = generate(config);\n // The generated modules, by path as Bun gives it: either separator on Windows.\n const folder = config.out.split(/[\\\\/]/).map(escape).join('[\\\\\\\\/]');\n const filter = new RegExp(`^${folder}[\\\\\\\\/][^\\\\\\\\/]+\\\\.[jt]s$`);\n const nameOf = (path: string) => basename(path).replace(/\\.[jt]s$/, '');\n\n if ('entrypoints' in build.config) {\n const prune = options.prune ?? 'strict';\n if (prune === false) return;\n const { refs, unseen } = scanSources(config, prune === 'strict');\n if (unseen.length > 0) throw new Error(unseenMessage(unseen, config.root));\n const kept = keeper(refs, options.keep);\n build.onLoad({ filter }, ({ path }) => {\n const entries = generated.entries.get(nameOf(path));\n return entries === undefined ? undefined : { contents: prunedModule(entries, kept), loader: 'js' };\n });\n return;\n }\n\n const dev = process.env.NODE_ENV !== 'production';\n const inspector = dev && options.inspect !== false ? installInspector(inspectorPath, '/') : '';\n if (dev) {\n watchCatalog(config, () => {\n try {\n generated = generate(config);\n } catch (e) {\n console.error(describe(e));\n }\n });\n }\n build.onLoad({ filter }, ({ path }) => {\n const name = nameOf(path);\n const loader = path.endsWith('.ts') ? 'ts' : 'js';\n if (generated.entries.has(name)) return { contents: readFileSync(path, 'utf8') + hotLocale(name), loader };\n return name === 'index' ? { contents: HOT_SET + inspector + readFileSync(path, 'utf8') + HOT_INDEX, loader } : undefined;\n });\n },\n };\n}\n\nexport interface InspectorRoutesOptions {\n /** The config, inline. Without it, `prosebook.config.*` at `root` is read. */\n config?: ProsebookConfig;\n /** The folder the config's paths resolve from, and where its file is found: the working directory when unset. */\n root?: string;\n}\n\n/**\n * The inspector's endpoints as `Bun.serve` routes: `routes: { ...inspector(), '/': index }`. For dev\n * only: they read and write the catalog.\n */\nexport function inspector(options: InspectorRoutesOptions = {}): Record<string, (req: Request) => Promise<Response>> {\n const handle = inspectorEndpoints(loadConfig(resolve(options.root ?? process.cwd()), options.config), {\n open: ({ file, line, column }) => {\n try {\n Bun.openInEditor(file, { line, column });\n return new Response(null, { status: 204 });\n } catch (e) {\n return new Response(`${e instanceof Error ? e.message : String(e)}: set VISUAL or EDITOR to the editor's command`, { status: 500 });\n }\n },\n });\n const route = async (req: Request) => (await handle(req)) ?? new Response(null, { status: 404 });\n return { '/__prosebook/open': route, '/__prosebook/entry': route };\n}\n\n// bunfig.toml names the module, and Bun takes its default export as the plugin.\nexport default prosebook();\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAsDA,MAAM,gBAAgB,OAAO,YAAY,KAAK,WAAW;AAEzD,MAAM,YAAY,MAAgB,aAAa,eAAe,EAAE,UAAU,aAAa,QAAS,EAAE,SAAS,EAAE,UAAW,OAAO,CAAC;AAChI,MAAM,UAAU,MAAc,EAAE,QAAQ,uBAAuB,MAAM;AAErE,SAAgB,UAAU,UAA+B,CAAC,GAAc;CACtE,OAAO;EACL,MAAM;EACN,MAAM,MAAM,OAAO;GACjB,MAAM,SAAS,MAAM,WAAW,QAAQ,QAAQ,QAAQ,QAAQ,IAAI,CAAC,GAAG,QAAQ,MAAM;GACtF,IAAI,YAAY,SAAS,MAAM;GAE/B,MAAM,SAAS,OAAO,IAAI,MAAM,OAAO,CAAC,CAAC,IAAI,MAAM,CAAC,CAAC,KAAK,SAAS;GACnE,MAAM,SAAS,IAAI,OAAO,IAAI,OAAO,0BAA0B;GAC/D,MAAM,UAAU,SAAiB,SAAS,IAAI,CAAC,CAAC,QAAQ,YAAY,EAAE;GAEtE,IAAI,iBAAiB,MAAM,QAAQ;IACjC,MAAM,QAAQ,QAAQ,SAAS;IAC/B,IAAI,UAAU,OAAO;IACrB,MAAM,EAAE,MAAM,WAAW,YAAY,QAAQ,UAAU,QAAQ;IAC/D,IAAI,OAAO,SAAS,GAAG,MAAM,IAAI,MAAM,cAAc,QAAQ,OAAO,IAAI,CAAC;IACzE,MAAM,OAAO,OAAO,MAAM,QAAQ,IAAI;IACtC,MAAM,OAAO,EAAE,OAAO,IAAI,EAAE,WAAW;KACrC,MAAM,UAAU,UAAU,QAAQ,IAAI,OAAO,IAAI,CAAC;KAClD,OAAO,YAAY,KAAA,IAAY,KAAA,IAAY;MAAE,UAAU,aAAa,SAAS,IAAI;MAAG,QAAQ;KAAK;IACnG,CAAC;IACD;GACF;GAEA,MAAM,MAAM,QAAQ,IAAI,aAAa;GACrC,MAAM,YAAY,OAAO,QAAQ,YAAY,QAAQ,iBAAiB,eAAe,GAAG,IAAI;GAC5F,IAAI,KACF,aAAa,cAAc;IACzB,IAAI;KACF,YAAY,SAAS,MAAM;IAC7B,SAAS,GAAG;KACV,QAAQ,MAAM,SAAS,CAAC,CAAC;IAC3B;GACF,CAAC;GAEH,MAAM,OAAO,EAAE,OAAO,IAAI,EAAE,WAAW;IACrC,MAAM,OAAO,OAAO,IAAI;IACxB,MAAM,SAAS,KAAK,SAAS,KAAK,IAAI,OAAO;IAC7C,IAAI,UAAU,QAAQ,IAAI,IAAI,GAAG,OAAO;KAAE,UAAU,aAAa,MAAM,MAAM,IAAI,UAAU,IAAI;KAAG;IAAO;IACzG,OAAO,SAAS,UAAU;KAAE,UAAU,UAAU,YAAY,aAAa,MAAM,MAAM,IAAI;KAAW;IAAO,IAAI,KAAA;GACjH,CAAC;EACH;CACF;AACF;;;;;AAaA,SAAgB,UAAU,UAAkC,CAAC,GAAwD;CACnH,MAAM,SAAS,mBAAmB,WAAW,QAAQ,QAAQ,QAAQ,QAAQ,IAAI,CAAC,GAAG,QAAQ,MAAM,GAAG,EACpG,OAAO,EAAE,MAAM,MAAM,aAAa;EAChC,IAAI;GACF,IAAI,aAAa,MAAM;IAAE;IAAM;GAAO,CAAC;GACvC,OAAO,IAAI,SAAS,MAAM,EAAE,QAAQ,IAAI,CAAC;EAC3C,SAAS,GAAG;GACV,OAAO,IAAI,SAAS,GAAG,aAAa,QAAQ,EAAE,UAAU,OAAO,CAAC,EAAE,iDAAiD,EAAE,QAAQ,IAAI,CAAC;EACpI;CACF,EACF,CAAC;CACD,MAAM,QAAQ,OAAO,QAAkB,MAAM,OAAO,GAAG,KAAM,IAAI,SAAS,MAAM,EAAE,QAAQ,IAAI,CAAC;CAC/F,OAAO;EAAE,qBAAqB;EAAO,sBAAsB;CAAM;AACnE;AAGA,IAAA,cAAe,UAAU"}