prosebook 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +357 -26
  2. package/dist/bind-BIZNv8nU.js +62 -0
  3. package/dist/bind-BIZNv8nU.js.map +1 -0
  4. package/dist/bind-ZS86jHXk.d.ts +31 -0
  5. package/dist/bind-ZS86jHXk.d.ts.map +1 -0
  6. package/dist/bun.d.ts +12 -3
  7. package/dist/bun.d.ts.map +1 -1
  8. package/dist/bun.js +77 -26
  9. package/dist/bun.js.map +1 -1
  10. package/dist/{bundle-KD-DiiFa.d.ts → bundle-CLHY_97D.d.ts} +3 -3
  11. package/dist/bundle-CLHY_97D.d.ts.map +1 -0
  12. package/dist/bundle-Ut6Y2X6N.js +217 -0
  13. package/dist/bundle-Ut6Y2X6N.js.map +1 -0
  14. package/dist/cli.js +55 -12
  15. package/dist/cli.js.map +1 -1
  16. package/dist/{config-BPSmoYel.js → config-7T4hX_f7.js} +13 -5
  17. package/dist/{config-BPSmoYel.js.map → config-7T4hX_f7.js.map} +1 -1
  18. package/dist/{config-DdMkKt_N.d.ts → config-CzaJJQ60.d.ts} +19 -5
  19. package/dist/{config-DdMkKt_N.d.ts.map → config-CzaJJQ60.d.ts.map} +1 -1
  20. package/dist/config.d.ts +1 -1
  21. package/dist/config.js +1 -1
  22. package/dist/{copy-B3V_QRkT.d.ts → copy-BXn0aYQn.d.ts} +37 -3
  23. package/dist/copy-BXn0aYQn.d.ts.map +1 -0
  24. package/dist/copy-Bv6S-Hr2.js +235 -0
  25. package/dist/copy-Bv6S-Hr2.js.map +1 -0
  26. package/dist/endpoints-Dhgowovz.js +217 -0
  27. package/dist/endpoints-Dhgowovz.js.map +1 -0
  28. package/dist/generate-1BeQAd1O.d.ts +72 -0
  29. package/dist/generate-1BeQAd1O.d.ts.map +1 -0
  30. package/dist/{generate-9pR3i5Bj.js → generate-CKv86Iki.js} +1724 -627
  31. package/dist/generate-CKv86Iki.js.map +1 -0
  32. package/dist/index.d.ts +2 -2
  33. package/dist/index.js +2 -2
  34. package/dist/next-loader.d.ts +9 -2
  35. package/dist/next-loader.d.ts.map +1 -1
  36. package/dist/next-loader.js +12 -3
  37. package/dist/next-loader.js.map +1 -1
  38. package/dist/next.d.ts +11 -3
  39. package/dist/next.d.ts.map +1 -1
  40. package/dist/next.js +94 -21
  41. package/dist/next.js.map +1 -1
  42. package/dist/preact/index.d.ts +34 -0
  43. package/dist/preact/index.d.ts.map +1 -0
  44. package/dist/preact/index.js +72 -0
  45. package/dist/preact/index.js.map +1 -0
  46. package/dist/react/index.d.ts +5 -14
  47. package/dist/react/index.d.ts.map +1 -1
  48. package/dist/react/index.js +9 -33
  49. package/dist/react/index.js.map +1 -1
  50. package/dist/react/server.d.ts +1 -1
  51. package/dist/react/server.js +1 -1
  52. package/dist/{sarif-CWyTAQ0y.js → sarif-BnwYC78_.js} +8 -98
  53. package/dist/sarif-BnwYC78_.js.map +1 -0
  54. package/dist/{scan-Bx0_yDSi.js → scan-C0ap4Mpp.js} +145 -20
  55. package/dist/scan-C0ap4Mpp.js.map +1 -0
  56. package/dist/solid/index.d.ts +29 -0
  57. package/dist/solid/index.d.ts.map +1 -0
  58. package/dist/solid/index.js +48 -0
  59. package/dist/solid/index.js.map +1 -0
  60. package/dist/svelte/Text.svelte +13 -0
  61. package/dist/svelte/Text.svelte.d.ts +20 -0
  62. package/dist/svelte/index.d.ts +31 -0
  63. package/dist/svelte/index.d.ts.map +1 -0
  64. package/dist/svelte/index.js +35 -0
  65. package/dist/svelte/index.js.map +1 -0
  66. package/dist/tools.d.ts +44 -65
  67. package/dist/tools.d.ts.map +1 -1
  68. package/dist/tools.js +5 -5
  69. package/dist/vite.d.ts +13 -3
  70. package/dist/vite.d.ts.map +1 -1
  71. package/dist/vite.js +82 -25
  72. package/dist/vite.js.map +1 -1
  73. package/dist/vue/index.d.ts +43 -0
  74. package/dist/vue/index.d.ts.map +1 -0
  75. package/dist/vue/index.js +76 -0
  76. package/dist/vue/index.js.map +1 -0
  77. package/package.json +63 -7
  78. package/src/bun.ts +77 -20
  79. package/src/cli.ts +62 -8
  80. package/src/index.ts +1 -1
  81. package/src/next-loader.ts +17 -5
  82. package/src/next.ts +120 -16
  83. package/src/preact/index.ts +91 -0
  84. package/src/react/index.tsx +12 -42
  85. package/src/runtime/bind.ts +96 -0
  86. package/src/runtime/copy.ts +148 -24
  87. package/src/solid/index.ts +67 -0
  88. package/src/svelte/Text.svelte +13 -0
  89. package/src/svelte/Text.svelte.d.ts +20 -0
  90. package/src/svelte/index.ts +65 -0
  91. package/src/tools/bundle.ts +143 -2
  92. package/src/tools/catalog.ts +10 -1
  93. package/src/tools/chunks.ts +142 -0
  94. package/src/tools/config.ts +25 -7
  95. package/src/tools/entry.ts +19 -20
  96. package/src/tools/exchange.ts +275 -0
  97. package/src/tools/extract.ts +2 -2
  98. package/src/tools/generate.ts +7 -1
  99. package/src/tools/index.ts +2 -1
  100. package/src/tools/native/android.ts +186 -0
  101. package/src/tools/native/apple.ts +201 -0
  102. package/src/tools/native/date.ts +219 -0
  103. package/src/tools/native/index.ts +17 -5
  104. package/src/tools/native/kotlin.ts +47 -6
  105. package/src/tools/native/model.ts +67 -29
  106. package/src/tools/native/printf.ts +137 -0
  107. package/src/tools/native/rust.ts +86 -5
  108. package/src/tools/native/swift.ts +62 -6
  109. package/src/tools/scan.ts +146 -25
  110. package/src/tools/todo.ts +6 -1
  111. package/src/vite.ts +121 -28
  112. package/src/vue/index.ts +117 -0
  113. package/dist/bundle-DboItAre.js +0 -111
  114. package/dist/bundle-DboItAre.js.map +0 -1
  115. package/dist/bundle-KD-DiiFa.d.ts.map +0 -1
  116. package/dist/compile-xF3F4oIp.d.ts +0 -16
  117. package/dist/compile-xF3F4oIp.d.ts.map +0 -1
  118. package/dist/copy-B3V_QRkT.d.ts.map +0 -1
  119. package/dist/copy-Bo_FIoLy.js +0 -154
  120. package/dist/copy-Bo_FIoLy.js.map +0 -1
  121. package/dist/endpoints-CnUfCkIk.js +0 -96
  122. package/dist/endpoints-CnUfCkIk.js.map +0 -1
  123. package/dist/generate-9pR3i5Bj.js.map +0 -1
  124. package/dist/sarif-CWyTAQ0y.js.map +0 -1
  125. package/dist/scan-Bx0_yDSi.js.map +0 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # prosebook
2
2
 
3
- Every string your app shows, in one typed catalog. For React and any JavaScript.
3
+ Every string your app shows, in one typed catalog. For React, Preact, Vue, Svelte, Solid and any JavaScript.
4
4
 
5
5
  [Homepage](https://gkurt.com/prosebook/) · [Try it on the page](https://gkurt.com/prosebook/#in-context)
6
6
 
@@ -38,7 +38,7 @@ const { t } = useCopy();
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
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.
41
+ with plurals, numbers and dates that say what the browser says, and the string resources Android and Apple read.
42
42
 
43
43
  ## Quick start
44
44
 
@@ -155,6 +155,121 @@ function Cart({ count }: { count: number }) {
155
155
  }
156
156
  ```
157
157
 
158
+ ### In Preact
159
+
160
+ `prosebook/preact` is the React binding for Preact, written on `preact/hooks`, so it needs no
161
+ `preact/compat`: `CopyProvider`, `useCopy`, `useLocale` and `Text`, used as above.
162
+
163
+ ```tsx
164
+ import { CopyProvider, Text, useCopy } from 'prosebook/preact';
165
+ ```
166
+
167
+ ### In Vue
168
+
169
+ Install the copy as a plugin, or give a subtree its own with `provideCopy(copy)` in `setup`. What
170
+ `useCopy()` returns reads through a ref, so a template or `computed` that reads a message runs again
171
+ when the locale changes. `rich` returns a `VNode`; in a template, `<Text>` renders a message and takes
172
+ each tag as a slot of the same name, which gets what the tag wraps as `chunks`:
173
+
174
+ ```ts
175
+ // main.ts
176
+ import { copyPlugin } from 'prosebook/vue';
177
+
178
+ createApp(App)
179
+ .use(copyPlugin(copy, { tags: { link: (chunks) => h('a', { href: '/terms' }, chunks) } }))
180
+ .mount('#app');
181
+ ```
182
+
183
+ ```vue
184
+ <script setup lang="ts">
185
+ import { Text, useCopy } from 'prosebook/vue';
186
+ import { ref } from 'vue';
187
+
188
+ const copy = useCopy();
189
+ const count = ref(2);
190
+ </script>
191
+
192
+ <template>
193
+ <h2>{{ copy.t('cart.title') }}</h2>
194
+ <Text id="cart.items" :values="{ count }" />
195
+ <Text id="cart.terms">
196
+ <template #link="{ chunks }"
197
+ ><RouterLink to="/terms"><component :is="chunks" /></RouterLink
198
+ ></template>
199
+ </Text>
200
+ <button @click="copy.setLocale('de')">Deutsch</button>
201
+ </template>
202
+ ```
203
+
204
+ Templates are typed with `vue-tsc`, which runs on TypeScript 6 until TypeScript 7 ships its API.
205
+
206
+ ### In Svelte
207
+
208
+ `setCopy(copy)` in a component gives it and everything below it the copy; `getCopy()` reads it. Its
209
+ reads are reactive, so markup, `$derived` and effects that read a message run again when the locale
210
+ changes. Tags render through snippets: give `<Text>` one of the same name, or `setCopy` one for every
211
+ message. A snippet gets what its tag wraps as `chunks`, which `<Text {chunks} />` renders:
212
+
213
+ ```svelte
214
+ <!-- App.svelte -->
215
+ <script lang="ts">
216
+ import { setCopy, Text, type RichPart } from 'prosebook/svelte';
217
+
218
+ let { copy } = $props();
219
+ setCopy(copy, { tags: { link } });
220
+ </script>
221
+
222
+ {#snippet link(chunks: RichPart[])}<a href="/terms"><Text {chunks} /></a>{/snippet}
223
+ ```
224
+
225
+ ```svelte
226
+ <!-- Cart.svelte -->
227
+ <script lang="ts">
228
+ import { getCopy, Text } from 'prosebook/svelte';
229
+
230
+ const copy = getCopy();
231
+ let count = $state(2);
232
+ </script>
233
+
234
+ <h2>{copy.t('cart.title')}</h2>
235
+ <Text id="cart.items" values={{ count }} />
236
+ <Text id="cart.terms" />
237
+ <button onclick={() => copy.setLocale('de')}>Deutsch</button>
238
+ ```
239
+
240
+ `Text` ships as a `.svelte` file for your Svelte compiler to build, so it needs Svelte 5 and its Vite
241
+ plugin (or SvelteKit). `svelte-check` types it, and runs on TypeScript 6 for now.
242
+
243
+ ### In Solid
244
+
245
+ Each read tracks a signal the copy sets, so what reads a message updates in place when the locale
246
+ changes, and components never run again. `rich` returns your tag renderers' elements beside the text.
247
+
248
+ ```tsx
249
+ import { CopyProvider, Text, useCopy } from 'prosebook/solid';
250
+
251
+ render(
252
+ () => (
253
+ <CopyProvider copy={copy} tags={{ link: (chunks) => <a href="/terms">{chunks}</a> }}>
254
+ <Cart />
255
+ </CopyProvider>
256
+ ),
257
+ root,
258
+ );
259
+
260
+ function Cart() {
261
+ const copy = useCopy();
262
+ const [count, setCount] = createSignal(2);
263
+ return (
264
+ <>
265
+ <h2>{copy.t('cart.title')}</h2>
266
+ <Text id="cart.items" values={{ count: count() }} />
267
+ <p>{copy.rich('cart.terms')}</p>
268
+ </>
269
+ );
270
+ }
271
+ ```
272
+
158
273
  ### In plain JavaScript
159
274
 
160
275
  ```js
@@ -238,7 +353,7 @@ function Ready({ copy, children }: { copy: Copy; children: ReactNode }) {
238
353
  }
239
354
  ```
240
355
 
241
- ### Outside the browser: Rust, Swift and Kotlin
356
+ ### Outside the browser: Rust, Swift, Kotlin, Android and Apple
242
357
 
243
358
  The parts of an app the browser doesn't draw, a tray, a menu bar, a CLI, an Android shell, can read
244
359
  the same catalog. `generate` writes one file per target in `native`, holding every locale:
@@ -283,17 +398,126 @@ A message with no placeholders is a `&'static str` in Rust and a property in Swi
283
398
  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
399
  is the source text, its context and what each argument holds.
285
400
 
401
+ A date is a `Date` in Swift and a `java.time.Instant` in Kotlin, shown in the device's time zone. Rust's
402
+ standard library has no time zones, so its file brings a `DateTime` holding the clock's fields, made
403
+ from a moment and the offset it is shown at:
404
+
405
+ ```rust
406
+ let printed = copy::DateTime::from_unix(seconds, offset); // offset in seconds east of UTC
407
+ copy.printed(printed); // "Printed Jan 5, 2026 at 2:05 PM."
408
+ ```
409
+
286
410
  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.
411
+ (the data `Intl.PluralRules` is built on), and its number and date formats, taken apart from `Intl`
412
+ when it is generated: the digits, the separators, how integers group, the text around a currency or a
413
+ percent; the order of a date's fields, the month and weekday names, AM and PM or the locale's periods
414
+ of the day, and its clock. Each format is checked against `Intl` before any code is written, so native
415
+ code writes what the browser does, Arabic digits and Indian grouping included. A key with no
416
+ translation falls back through its parents to the source as the runtime's do, resolved when the file
417
+ is written.
418
+
419
+ What can't be written that way fails `generate`, with the fix: a tag, compact notation, significant
420
+ digits, a unit or currency name that changes with the count (`unitDisplay: 'long'`), a date with its
421
+ time zone's name (`{when, time, full}`) or a time zone of its own, a locale that counts in another
422
+ calendar than the Gregorian (Thai's Buddhist, Persian), and an MF2 option read from an argument. Leave
423
+ such keys out of the target's `keys`.
424
+
425
+ #### Android resources
426
+
427
+ An Android app needs string resources even with the Kotlin target: its manifest, launcher shortcuts,
428
+ an accessibility service's description and every other XML resource name strings as `@string/…`, and
429
+ only a resource answers that. The `android` target writes them, with `out` naming the `res` folder:
292
430
 
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`.
431
+ ```ts
432
+ native: [{ language: 'android', out: 'android/app/src/main/res', keys: ['android'] }],
433
+ ```
434
+
435
+ `values/strings.xml` holds the source locale, and each other locale gets a folder named as Android
436
+ names it (`values-de`, `values-pt-rBR`, `values-b+sr+Latn` for a script), holding what that locale
437
+ shows, every fallback resolved. `name` renames the file, `strings` by default, so it can sit beside
438
+ strings you write yourself. A resource is named for its key, less the prefix, with `.` and `-` as
439
+ `_`: `android.sign-in.denied` is `R.string.sign_in_denied`. Above each is a comment with its context
440
+ and what each argument holds.
441
+
442
+ ```xml
443
+ <!-- Counts the cart. count: how many items the cart holds. -->
444
+ <plurals name="cart_items">
445
+ <item quantity="one">%1$d item in the cart.</item>
446
+ <item quantity="other">%1$d items in the cart.</item>
447
+ </plurals>
448
+ <!-- Greets the person signed in. firstName: the person's first name. -->
449
+ <string name="hello">Hello, %1$s!</string>
450
+ ```
451
+
452
+ Android fills placeholders in with `String.format` and picks a plural's arm itself, so a resource
453
+ holds less than the other targets: text, plain `{name}` placeholders, written `%1$s` by their place in
454
+ the source so a translation can reorder them, and one plural, which becomes `<plurals>` with its count
455
+ as `%1$d` (the text around it goes into each arm). Call it as Android does,
456
+ `resources.getQuantityString(R.plurals.cart_items, count, count)`. `String.format` isn't `Intl`, so
457
+ nothing here is checked against it: `%d` writes 1234 without grouping. Android picks the arm by the
458
+ device's language, so a plural a locale doesn't translate picks among the source's arms by that
459
+ language's rules. Escaping is done for you: quotes, apostrophes, `\`, `&`, `<`, a newline, a leading
460
+ `@` or `?`, `%` where the string is formatted, and spaces Android would collapse or trim.
461
+
462
+ What a resource can't hold fails `generate`, with the fix: a select, a date, a formatted number, a
463
+ second plural, an ordinal, an offset, an exact arm such as `=0` (Android's `zero` is a category, which
464
+ English never picks), a selector other than a bare `:number` or `:integer`, and a tag.
465
+
466
+ #### Apple string tables
467
+
468
+ An Apple app has the same gap: macOS and iOS read the permission prompts, the app's name and other
469
+ Info.plist strings from the bundle, never from Swift. The `apple` target writes `.strings` tables, a
470
+ `<locale>.lproj/<name>.strings` per locale under `out`, each holding what that locale shows. They are
471
+ plain `.strings` files, not a String Catalog, which only Xcode compiles: an app bundled by Tauri or a
472
+ script copies the `.lproj` folders into `Contents/Resources` as they are.
473
+
474
+ With `plist`, the table is `InfoPlist` and translates that Info.plist. A catalog key can't be written
475
+ `NSCameraUsageDescription`, so its words spell the plist key: `macos.ns-camera-usage-description` is
476
+ `NSCameraUsageDescription`. The key must be in the plist, since the system reads only the keys there,
477
+ and the plist's own text must be the source's, since it shows where no `.lproj` matches. Either way
478
+ round, `generate` fails with the fix:
479
+
480
+ ```ts
481
+ native: [{ language: 'apple', out: 'src-tauri/Resources', keys: ['macos'], plist: 'src-tauri/Info.plist' }],
482
+ ```
483
+
484
+ ```c
485
+ /* Asks for the camera. */
486
+ "NSCameraUsageDescription" = "Corner Shop scans a product's barcode with the camera.";
487
+ ```
488
+
489
+ Without `plist`, the table is `Localizable`, for what the system looks up by key, such as a push
490
+ notification's `loc-key`. An entry is named for its key less the prefix, as written (`tray.quit` is
491
+ `"quit"` under `['tray']`), and placeholders are written `%1$@` by their place in the source, filled
492
+ in by `String(format:)`, not `Intl`. A table holds text and plain `{name}` placeholders.
493
+
494
+ A message with a plural goes into a `.stringsdict` of the same name beside the table, where the
495
+ system looks first. Each plural is a variable in its format, so the text around it stays where it is
496
+ and a message can hold two; its count is `%N$lld`, for an `Int`, and the system picks the arm by its
497
+ own CLDR rules:
498
+
499
+ ```xml
500
+ <key>items</key>
501
+ <dict>
502
+ <key>NSStringLocalizedFormatKey</key>
503
+ <string>%1$@ has %2$#@count@ in the cart.</string>
504
+ <key>count</key>
505
+ <dict>
506
+ <key>NSStringFormatSpecTypeKey</key>
507
+ <string>NSStringPluralRuleType</string>
508
+ <key>NSStringFormatValueTypeKey</key>
509
+ <string>lld</string>
510
+ <key>one</key>
511
+ <string>%2$lld item</string>
512
+ <key>other</key>
513
+ <string>%2$lld items</string>
514
+ </dict>
515
+ </dict>
516
+ ```
517
+
518
+ A plural there is cardinal, on a bare count, with no offset and no exact arm such as `=0`, as on
519
+ Android; a select, a date, a formatted number, a tag and a plural inside another fail `generate`, and
520
+ an Info.plist string takes no placeholders at all. Show those from the Swift target.
297
521
 
298
522
  ## The catalog
299
523
 
@@ -321,6 +545,10 @@ catalog, so an editor with the YAML language server completes and explains every
321
545
  # yaml-language-server: $schema=./catalog.schema.json
322
546
  ```
323
547
 
548
+ That line is how the editor finds the schema, so `generate` starts each catalog file with it, and
549
+ `generate --check` (and `check`) fails on a file without it. A file that names a schema of its own on
550
+ its first line keeps it.
551
+
324
552
  ### Messages
325
553
 
326
554
  ICU MessageFormat, checked at build time:
@@ -487,6 +715,26 @@ translations are then linted, as a person's would be. An agent working in the re
487
715
  of this: it can read `prosebook todo de`, write the translation files itself, and run
488
716
  `prosebook check`.
489
717
 
718
+ **Translation tools.** `prosebook export` writes the same entries for a translation tool, as XLIFF
719
+ 2.0 or JSON by the file's extension (`--format xliff|json` on stdout), and `import` takes the tool's
720
+ file back as it takes a reply:
721
+
722
+ ```bash
723
+ prosebook export de de.xlf # what todo lists; --all for every entry
724
+ prosebook export en en.json # the source, every entry, for a tool that translates JSON
725
+ prosebook import de de.xlf # or de.json, flat or nested by key
726
+ ```
727
+
728
+ In XLIFF, each entry is a unit named for its key, in a file per area, with its context, kind, surface,
729
+ what each placeholder holds and, for a stale one, the word diff as notes. A message travels as its
730
+ ICU or MF2 text, which tools that take these formats read, not as XLIFF's inline codes: a target that
731
+ comes back with a `<ph>` is left out with the fix. A missing translation goes out `initial`, a stale
732
+ one `initial` with its translation, and a current one `translated`. A stale translation given back
733
+ unchanged is accepted only once the tool has moved it on from `initial`, since only then has someone
734
+ looked at it. Each unit carries its source as exported, so a translation of a source that has changed
735
+ since is left out until it is exported again. JSON is `key: text` in the locale, empty where it has
736
+ none, and carries no context: give the tool the XLIFF where it reads both.
737
+
490
738
  **Pseudo-locale.** `pseudo: 'en-XA'` generates `⟦Ýóóúúŕ çááŕţ⟧` from the source. Switch to it and
491
739
  any plain text on screen is hardcoded, a missing bracket is clipped text, and brackets inside
492
740
  brackets are a sentence spliced from fragments.
@@ -734,10 +982,92 @@ A message the build leaves out shows as its key, so by default a build fails on
734
982
  it can't see: `t(item.key)`, ``rich(`${area}.title`)``, `<Text id={id} />`. Write the key at the read,
735
983
  or, where every key it reads is written elsewhere (in an array, say) or listed in `keep`, mark the
736
984
  line with `prosebook-ignore`. Reads are known by name: `t` and `rich` alone or as methods, the names
737
- they're destructured to (`const { rich: tRich } = copy`) in any module, and `<Text>` from
738
- `prosebook/react`. `prune: 'loose'` prunes without the check, and `prune: false` ships everything.
985
+ they're destructured to (`const { rich: tRich } = copy`) in any module, and `<Text>` from any of the
986
+ bindings. In `.vue` and `.svelte` files the scripts and the expressions in the markup are read alike
987
+ (`{{ t(key) }}`, `:id="key"`, `{copy.t(key)}`, `<Text {id} />`), and `<!-- prosebook-ignore -->` marks
988
+ a line there. `prune: 'loose'` prunes without the check, and `prune: false` ships everything.
739
989
  `prune` and `keep` are options of each integration.
740
990
 
991
+ ## Splitting
992
+
993
+ Pruning drops what no code reads; splitting ships the rest with the code that reads it. With
994
+ `prosebook({ split: true })`, the Vite plugin splits each locale by area, the file a key's first
995
+ segment names, so a route loaded with `lazy(() => import('./Cart.tsx'))` brings the `cart` messages
996
+ with it and the main bundle holds none of them:
997
+
998
+ ```ts
999
+ // vite.config.ts
1000
+ export default defineConfig({
1001
+ plugins: [react(), prosebook({ split: true })],
1002
+ });
1003
+ ```
1004
+
1005
+ A module that names an area's keys imports that area, and the bundler puts it beside that module:
1006
+ in the main chunk when the shell reads it, in a route's chunk when only the route does, in a shared
1007
+ chunk when two routes do. An area holds the source's messages and the `bundled` locales'; every
1008
+ other locale loads area by area, as it is needed: switching language fetches only the areas
1009
+ already on the page. The area's module waits for that load (a top-level `await`), so a lazy route
1010
+ renders in the reader's language from its first frame, in every binding, with no change to the code
1011
+ reading it.
1012
+
1013
+ Areas the bundler puts in one chunk share a file in each such locale, so a route reading three areas
1014
+ across its modules loads German in one request, not three. Which areas share a chunk is read from
1015
+ the imports under `scan.dirs` as the build starts, as the bundler will chunk them. An import that
1016
+ can't be followed, such as one through an alias, can leave two areas of a chunk a file each: one
1017
+ more request, never a missing message.
1018
+
1019
+ The Bun plugin takes `split` too. `Bun.build` needs `splitting: true` for its lazy chunks, and in a
1020
+ full-stack build, one `Bun.build` of a server and the pages it imports, only the pages are split.
1021
+ bunfig.toml names a module whose default export is the plugin, so the dev server's comes from a
1022
+ file of the app's own. Under Bun, the files read for areas are those under `scan.dirs`, as for
1023
+ pruning:
1024
+
1025
+ ```ts
1026
+ // prosebook.plugin.ts, which bunfig.toml names: plugins = ["./prosebook.plugin.ts"]
1027
+ import { prosebook } from 'prosebook/bun';
1028
+
1029
+ export default prosebook({ split: true });
1030
+ ```
1031
+
1032
+ ```ts
1033
+ // build.ts
1034
+ await Bun.build({
1035
+ entrypoints: ['./src/server.ts'],
1036
+ target: 'bun',
1037
+ outdir: './dist',
1038
+ splitting: true,
1039
+ plugins: [prosebook({ split: true })],
1040
+ });
1041
+ ```
1042
+
1043
+ Under Next, `withProsebook(config, { split: true })` splits what client components read. Next
1044
+ already splits client code by route, so each route's brings only its areas, and a server component
1045
+ reads whole locales, as the server render does. Turbopack has no virtual modules, so the areas are
1046
+ files `next dev` and `next build` write under `node_modules/.prosebook`, and a loader over the app's
1047
+ source under `scan.dirs` adds their imports in the browser.
1048
+
1049
+ A read the build can't see, marked `prosebook-ignore`, finds its area only if some module loaded
1050
+ before it names one of the area's keys; one read before then shows its key and is reported through
1051
+ `onError`, with the fix. Areas `keep` names load with the catalog. Splitting runs in the browser
1052
+ only: a server render, and an app without a plugin, load whole locales. It needs a build target
1053
+ with top-level `await`, which Vite's, Bun's and Next's defaults have.
1054
+
1055
+ A page rendered on a server in a lazy locale hydrates once its areas' files have loaded, and the
1056
+ browser asks for them only after the code reading them has. Under Vite, the server can link them
1057
+ first. A split client build writes down each area's file per lazy locale, the server build after
1058
+ it lists them in its catalog, and a copy hands each file it reads from to `preload`, once:
1059
+
1060
+ ```tsx
1061
+ import { preloadModule } from 'react-dom';
1062
+
1063
+ const copy = createCopy(catalog, { locale, preload: (href) => preloadModule(href, { as: 'script' }) });
1064
+ ```
1065
+
1066
+ React puts a `<link rel="modulepreload">` in the page for each, so the browser fetches German
1067
+ beside the route's code. Without React, collect them and write the links yourself. Build the client
1068
+ before the server, as TanStack Start and other frameworks on Vite do; `vite build` warns where it
1069
+ built the server with no client to take the files from.
1070
+
741
1071
  ## Finding a string's entry
742
1072
 
743
1073
  In dev, hold Alt (Option on a Mac) and point at text on the page: the inspector outlines the
@@ -815,7 +1145,7 @@ export default defineConfig({
815
1145
  properNouns: [],
816
1146
  pathSeparator: '›',
817
1147
  },
818
- native: [], // Rust, Swift or Kotlin files: { language: 'rust', out: 'src/copy.rs', keys: ['tray'] }
1148
+ native: [], // Rust, Swift or Kotlin files, Android's res or Apple's .lproj: { language: 'rust', out: 'src/copy.rs', keys: ['tray'] }
819
1149
  scan: { dirs: ['src'] },
820
1150
  });
821
1151
  ```
@@ -826,13 +1156,17 @@ Each is a small shop in English, German and the pseudo-locale, and uses proseboo
826
1156
  from the package. CI generates, lints, typechecks and builds each one, then checks what its build
827
1157
  serves.
828
1158
 
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. |
1159
+ | Example | |
1160
+ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
1161
+ | [`examples/vite`](examples/vite) | React with Vite, a part-translated Turkish and the inspector. The cart is a lazy route, split by area. |
1162
+ | [`examples/preact`](examples/preact) | Preact with Vite's own JSX transform, the same app as the React one. |
1163
+ | [`examples/vue`](examples/vue) | Vue single-file components: `<Text>` in templates, typed by `vue-tsc`. |
1164
+ | [`examples/svelte`](examples/svelte) | Svelte 5 with runes: the copy set in the root, the link a snippet, typed by `svelte-check`. |
1165
+ | [`examples/solid`](examples/solid) | Solid: reads update in place on a locale switch, and the components run once. |
1166
+ | [`examples/next`](examples/next) | Next.js: server components read a copy per request, client components the provider's. The cart is a route of its own, split by area. |
1167
+ | [`examples/tanstack-start`](examples/tanstack-start) | TanStack Start, rendered on the server in the request's locale, split, with the page's German files linked ahead. |
1168
+ | [`examples/bun`](examples/bun) | `Bun.serve` with a React page, a route that answers in the request's locale, the inspector. The cart is a lazy route, split by area. |
1169
+ | [`examples/node`](examples/node) | No framework and no bundler: a Node script prints a receipt from the generated TypeScript. |
836
1170
 
837
1171
  ```bash
838
1172
  bun run build # the examples import prosebook from dist
@@ -843,7 +1177,4 @@ bun run --cwd examples/next dev
843
1177
 
844
1178
  Ideas not built yet, roughly in order of value:
845
1179
 
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.
1180
+ 1. **Preloading a split render's areas under Bun and Next**: Bun's chunks and Turbopack's name no module, so neither says which file holds an area.
@@ -0,0 +1,62 @@
1
+ // src/runtime/bind.ts
2
+ /** A reader of `copy` whose reads go through `track`, and whose rich reads get `tags` unless they give their own. */
3
+ function bindCopy(copy, tags, { track = () => {}, tag, whole }) {
4
+ const merged = {
5
+ ...copy.tags,
6
+ ...tags
7
+ };
8
+ return {
9
+ get locale() {
10
+ track();
11
+ return copy.locale;
12
+ },
13
+ get version() {
14
+ track();
15
+ return copy.version;
16
+ },
17
+ locales: copy.locales,
18
+ source: copy.source,
19
+ tags: copy.tags,
20
+ ready: copy.ready,
21
+ t(key, ...args) {
22
+ track();
23
+ return copy.t(key, ...args);
24
+ },
25
+ has(key) {
26
+ track();
27
+ return copy.has(key);
28
+ },
29
+ setLocale: copy.setLocale,
30
+ subscribe: copy.subscribe,
31
+ number(value, options) {
32
+ track();
33
+ return copy.number(value, options);
34
+ },
35
+ date(value, options) {
36
+ track();
37
+ return copy.date(value, options);
38
+ },
39
+ list(items, options) {
40
+ track();
41
+ return copy.list(items, options);
42
+ },
43
+ relative(value, unit, options) {
44
+ track();
45
+ return copy.relative(value, unit, options);
46
+ },
47
+ rich(key, ...values) {
48
+ track();
49
+ const all = {
50
+ ...merged,
51
+ ...values[0]
52
+ };
53
+ if (tag !== void 0) {
54
+ for (const [name, value] of Object.entries(all)) if (typeof value === "function") all[name] = (chunks) => tag(value, chunks);
55
+ }
56
+ return whole(copy.rich(key, all));
57
+ }
58
+ };
59
+ }
60
+ export { bindCopy as t };
61
+
62
+ //# sourceMappingURL=bind-BIZNv8nU.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bind-BIZNv8nU.js","names":[],"sources":["../src/runtime/bind.ts"],"sourcesContent":["/**\n * What every framework binding shares: a copy read through the framework's reactivity, with a\n * provider's tag renderers under each rich read's own.\n */\nimport type { Copy } from './copy.ts';\nimport type { MessageShape, RegisteredLocale, RegisteredMessages, RichKey, RichParam, TagFn } from './types.ts';\n\ntype M = RegisteredMessages;\n\n/**\n * `Copy`, with `rich` returning `R`, a node of the framework's, whose tag renderers make `N`s. The locale is\n * a parameter so it survives a lint that, where no catalog is registered, sees `RegisteredLocale` as the default.\n */\nexport interface BoundCopy<N, R = N, L extends string = RegisteredLocale> extends Omit<Copy<M, L>, 'rich'> {\n rich<K extends RichKey<M>>(this: void, key: K, ...values: RichParam<M[K], N>): R;\n}\n\nexport type Tags = { readonly [tag: string]: (chunks: never) => unknown };\n\nexport interface Binding<R> {\n /** Called on every read, so the framework knows the reader depends on the copy's version. */\n track?: () => void;\n /** What a tag's renderer is turned into, given what it wraps. Calls it by default. */\n tag?: (render: (chunks: never) => unknown, chunks: unknown[]) => unknown;\n /** What a rich read returns, given its parts. */\n whole: (parts: unknown[]) => R;\n}\n\n/** A reader of `copy` whose reads go through `track`, and whose rich reads get `tags` unless they give their own. */\nexport function bindCopy<N, R>(copy: Copy, tags: Tags, { track = () => {}, tag, whole }: Binding<R>): BoundCopy<N, R> {\n const merged = { ...copy.tags, ...tags };\n return {\n get locale() {\n track();\n return copy.locale;\n },\n get version() {\n track();\n return copy.version;\n },\n locales: copy.locales,\n source: copy.source,\n tags: copy.tags,\n ready: copy.ready,\n t(key, ...args) {\n track();\n return copy.t(key, ...args);\n },\n has(key): key is keyof M & string {\n track();\n return copy.has(key);\n },\n setLocale: copy.setLocale,\n subscribe: copy.subscribe,\n number(value, options) {\n track();\n return copy.number(value, options);\n },\n date(value, options) {\n track();\n return copy.date(value, options);\n },\n list(items, options) {\n track();\n return copy.list(items, options);\n },\n relative(value, unit, options) {\n track();\n return copy.relative(value, unit, options);\n },\n rich(key, ...values) {\n track();\n const all: Record<string, unknown> = { ...merged, ...(values[0] as object | undefined) };\n if (tag !== undefined) {\n for (const [name, value] of Object.entries(all))\n if (typeof value === 'function') all[name] = (chunks: unknown[]) => tag(value as (chunks: never) => unknown, chunks);\n }\n return whole(copy.rich(key, all as never));\n },\n };\n}\n\ntype NoValues<S extends MessageShape> = {} extends S['args'] & { [T in S['tags']]: unknown } ? true : false;\n\n/**\n * A `<Text>`'s props where the call site renders every tag the provider doesn't: `values` is needed\n * once the message takes arguments or wraps text in a tag.\n */\nexport type TextProps<K extends Extract<keyof M, string>, N> = { id: K } & (NoValues<M[K]> extends true\n ? { values?: M[K]['args'] & { [T in M[K]['tags']]?: TagFn<N> } }\n : { values: RichParam<M[K], N>[0] });\n\n/** A `<Text>`'s props where tags may come from elsewhere (slots, snippets): `values` holds arguments, and tags if you like. */\nexport type LooseTextProps<K extends Extract<keyof M, string>, N> = { id: K } & ({} extends M[K]['args']\n ? { values?: M[K]['args'] & { [T in M[K]['tags']]?: TagFn<N> } }\n : { values: M[K]['args'] & { [T in M[K]['tags']]?: TagFn<N> } });\n"],"mappings":";;AA6BA,SAAgB,SAAe,MAAY,MAAY,EAAE,cAAc,CAAC,GAAG,KAAK,SAAsC;CACpH,MAAM,SAAS;EAAE,GAAG,KAAK;EAAM,GAAG;CAAK;CACvC,OAAO;EACL,IAAI,SAAS;GACX,MAAM;GACN,OAAO,KAAK;EACd;EACA,IAAI,UAAU;GACZ,MAAM;GACN,OAAO,KAAK;EACd;EACA,SAAS,KAAK;EACd,QAAQ,KAAK;EACb,MAAM,KAAK;EACX,OAAO,KAAK;EACZ,EAAE,KAAK,GAAG,MAAM;GACd,MAAM;GACN,OAAO,KAAK,EAAE,KAAK,GAAG,IAAI;EAC5B;EACA,IAAI,KAA8B;GAChC,MAAM;GACN,OAAO,KAAK,IAAI,GAAG;EACrB;EACA,WAAW,KAAK;EAChB,WAAW,KAAK;EAChB,OAAO,OAAO,SAAS;GACrB,MAAM;GACN,OAAO,KAAK,OAAO,OAAO,OAAO;EACnC;EACA,KAAK,OAAO,SAAS;GACnB,MAAM;GACN,OAAO,KAAK,KAAK,OAAO,OAAO;EACjC;EACA,KAAK,OAAO,SAAS;GACnB,MAAM;GACN,OAAO,KAAK,KAAK,OAAO,OAAO;EACjC;EACA,SAAS,OAAO,MAAM,SAAS;GAC7B,MAAM;GACN,OAAO,KAAK,SAAS,OAAO,MAAM,OAAO;EAC3C;EACA,KAAK,KAAK,GAAG,QAAQ;GACnB,MAAM;GACN,MAAM,MAA+B;IAAE,GAAG;IAAQ,GAAI,OAAO;GAA0B;GACvF,IAAI,QAAQ,KAAA,GACL;SAAA,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,GAAG,GAC5C,IAAI,OAAO,UAAU,YAAY,IAAI,SAAS,WAAsB,IAAI,OAAqC,MAAM;GAAA;GAEvH,OAAO,MAAM,KAAK,KAAK,KAAK,GAAY,CAAC;EAC3C;CACF;AACF"}
@@ -0,0 +1,31 @@
1
+ import { C as RichParam, S as RichKey, T as TagFn, b as RegisteredLocale, g as MessageShape, n as Copy, x as RegisteredMessages } from "./copy-BXn0aYQn.js";
2
+ type M = RegisteredMessages;
3
+ /**
4
+ * `Copy`, with `rich` returning `R`, a node of the framework's, whose tag renderers make `N`s. The locale is
5
+ * a parameter so it survives a lint that, where no catalog is registered, sees `RegisteredLocale` as the default.
6
+ */
7
+ interface BoundCopy<N, R = N, L extends string = RegisteredLocale> extends Omit<Copy<M, L>, 'rich'> {
8
+ rich<K extends RichKey<M>>(this: void, key: K, ...values: RichParam<M[K], N>): R;
9
+ }
10
+ type NoValues<S extends MessageShape> = {} extends S['args'] & { [T in S['tags']]: unknown; } ? true : false;
11
+ /**
12
+ * A `<Text>`'s props where the call site renders every tag the provider doesn't: `values` is needed
13
+ * once the message takes arguments or wraps text in a tag.
14
+ */
15
+ type TextProps<K extends Extract<keyof M, string>, N> = {
16
+ id: K;
17
+ } & (NoValues<M[K]> extends true ? {
18
+ values?: M[K]['args'] & { [T in M[K]['tags']]?: TagFn<N>; };
19
+ } : {
20
+ values: RichParam<M[K], N>[0];
21
+ });
22
+ /** A `<Text>`'s props where tags may come from elsewhere (slots, snippets): `values` holds arguments, and tags if you like. */
23
+ type LooseTextProps<K extends Extract<keyof M, string>, N> = {
24
+ id: K;
25
+ } & ({} extends M[K]['args'] ? {
26
+ values?: M[K]['args'] & { [T in M[K]['tags']]?: TagFn<N>; };
27
+ } : {
28
+ values: M[K]['args'] & { [T in M[K]['tags']]?: TagFn<N>; };
29
+ });
30
+ export { LooseTextProps as n, TextProps as r, BoundCopy as t };
31
+ //# sourceMappingURL=bind-ZS86jHXk.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bind-ZS86jHXk.d.ts","names":[],"sources":["../src/runtime/bind.ts"],"mappings":";KAOK,IAAI;;;;;UAMQ,UAAU,GAAG,IAAI,GAAG,mBAAmB,0BAA0B,KAAK,KAAK,GAAG;EAC7F,KAAK,UAAU,QAAQ,IAAI,YAAY,KAAK,MAAM,QAAQ,UAAU,EAAE,IAAI,KAAK;;KAoE5E,SAAS,UAAU,2BAA2B,eAAe,KAAK;;;;;KAM3D,UAAU,UAAU,cAAc,YAAY;EAAO,IAAI;KAAO,SAAS,EAAE;EACjF,SAAS,EAAE,gBAAgB,KAAK,EAAE,cAAc,MAAM;;EACtD,QAAQ,UAAU,EAAE,IAAI;;;KAGlB,eAAe,UAAU,cAAc,YAAY;EAAO,IAAI;gBAAkB,EAAE;EACxF,SAAS,EAAE,gBAAgB,KAAK,EAAE,cAAc,MAAM;;EACtD,QAAQ,EAAE,gBAAgB,KAAK,EAAE,cAAc,MAAM"}
package/dist/bun.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { t as Prune } from "./bundle-KD-DiiFa.js";
2
- import { i as ProsebookConfig } from "./config-DdMkKt_N.js";
1
+ import { t as Prune } from "./bundle-CLHY_97D.js";
2
+ import { i as ProsebookConfig } from "./config-CzaJJQ60.js";
3
3
  import { BunPlugin } from "bun";
4
4
  export interface ProsebookBunOptions {
5
5
  /** The config, inline. Without it, `prosebook.config.*` at `root` is read. */
@@ -12,8 +12,17 @@ export interface ProsebookBunOptions {
12
12
  * check; `false` ships every message.
13
13
  */
14
14
  prune?: Prune;
15
- /** Keys a build always keeps, exactly or as `prefix.*`: for keys built at runtime from data. */
15
+ /**
16
+ * Keys a build always keeps, exactly or as `prefix.*`: for keys built at runtime from data. With
17
+ * `split`, their areas load with the catalog.
18
+ */
16
19
  keep?: string[];
20
+ /**
21
+ * Split each locale by area in the page, as the Vite plugin's `split` does, so a lazy route loads
22
+ * only the messages it reads. `Bun.build` needs `splitting: true`. A server's own code loads whole
23
+ * locales.
24
+ */
25
+ split?: boolean;
17
26
  /** Under the dev server, Alt-click a string on the page to open its entry in the editor. On by default. */
18
27
  inspect?: boolean;
19
28
  }
package/dist/bun.d.ts.map CHANGED
@@ -1 +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"}
1
+ {"version":3,"file":"bun.d.ts","names":[],"sources":["../src/bun.ts"],"mappings":";;;iBAqDiB;;EAEf,SAAS;;EAET;;;;;;EAMA,QAAQ;;;;;EAKR;;;;;;EAMA;;EAEA;;wBAWc,UAAU,UAAS,sBAA2B;iBA0E7C;;EAEf,SAAS;;EAET;;;;;;wBAOc,UAAU,UAAS,yBAA8B,gBAAgB,KAAK,YAAY,QAAQ"}