prosebook 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +288 -28
  2. package/dist/bind-BIZNv8nU.js +62 -0
  3. package/dist/bind-BIZNv8nU.js.map +1 -0
  4. package/dist/bind-ZS86jHXk.d.ts +31 -0
  5. package/dist/bind-ZS86jHXk.d.ts.map +1 -0
  6. package/dist/bun.d.ts +11 -2
  7. package/dist/bun.d.ts.map +1 -1
  8. package/dist/bun.js +77 -26
  9. package/dist/bun.js.map +1 -1
  10. package/dist/{bundle-CpUIWb9j.d.ts → bundle-CLHY_97D.d.ts} +2 -2
  11. package/dist/bundle-CLHY_97D.d.ts.map +1 -0
  12. package/dist/bundle-Ut6Y2X6N.js +217 -0
  13. package/dist/bundle-Ut6Y2X6N.js.map +1 -0
  14. package/dist/cli.js +55 -12
  15. package/dist/cli.js.map +1 -1
  16. package/dist/{config-nWZf9QmF.js → config-7T4hX_f7.js} +5 -3
  17. package/dist/{config-nWZf9QmF.js.map → config-7T4hX_f7.js.map} +1 -1
  18. package/dist/config-CzaJJQ60.d.ts.map +1 -1
  19. package/dist/config.js +1 -1
  20. package/dist/{copy-B3V_QRkT.d.ts → copy-BXn0aYQn.d.ts} +37 -3
  21. package/dist/copy-BXn0aYQn.d.ts.map +1 -0
  22. package/dist/copy-Bv6S-Hr2.js +235 -0
  23. package/dist/copy-Bv6S-Hr2.js.map +1 -0
  24. package/dist/endpoints-Dhgowovz.js +217 -0
  25. package/dist/endpoints-Dhgowovz.js.map +1 -0
  26. package/dist/generate-1BeQAd1O.d.ts +72 -0
  27. package/dist/generate-1BeQAd1O.d.ts.map +1 -0
  28. package/dist/{generate-BW3N8EGb.js → generate-CKv86Iki.js} +1091 -309
  29. package/dist/generate-CKv86Iki.js.map +1 -0
  30. package/dist/index.d.ts +2 -2
  31. package/dist/index.js +2 -2
  32. package/dist/next-loader.d.ts +9 -2
  33. package/dist/next-loader.d.ts.map +1 -1
  34. package/dist/next-loader.js +12 -3
  35. package/dist/next-loader.js.map +1 -1
  36. package/dist/next.d.ts +10 -2
  37. package/dist/next.d.ts.map +1 -1
  38. package/dist/next.js +94 -21
  39. package/dist/next.js.map +1 -1
  40. package/dist/preact/index.d.ts +34 -0
  41. package/dist/preact/index.d.ts.map +1 -0
  42. package/dist/preact/index.js +72 -0
  43. package/dist/preact/index.js.map +1 -0
  44. package/dist/react/index.d.ts +5 -14
  45. package/dist/react/index.d.ts.map +1 -1
  46. package/dist/react/index.js +9 -33
  47. package/dist/react/index.js.map +1 -1
  48. package/dist/react/server.d.ts +1 -1
  49. package/dist/react/server.js +1 -1
  50. package/dist/{sarif-B5nrMqrT.js → sarif-BnwYC78_.js} +8 -98
  51. package/dist/sarif-BnwYC78_.js.map +1 -0
  52. package/dist/{scan-Bx0_yDSi.js → scan-C0ap4Mpp.js} +145 -20
  53. package/dist/scan-C0ap4Mpp.js.map +1 -0
  54. package/dist/solid/index.d.ts +29 -0
  55. package/dist/solid/index.d.ts.map +1 -0
  56. package/dist/solid/index.js +48 -0
  57. package/dist/solid/index.js.map +1 -0
  58. package/dist/svelte/Text.svelte +13 -0
  59. package/dist/svelte/Text.svelte.d.ts +20 -0
  60. package/dist/svelte/index.d.ts +31 -0
  61. package/dist/svelte/index.d.ts.map +1 -0
  62. package/dist/svelte/index.js +35 -0
  63. package/dist/svelte/index.js.map +1 -0
  64. package/dist/tools.d.ts +43 -64
  65. package/dist/tools.d.ts.map +1 -1
  66. package/dist/tools.js +5 -5
  67. package/dist/vite.d.ts +12 -2
  68. package/dist/vite.d.ts.map +1 -1
  69. package/dist/vite.js +82 -25
  70. package/dist/vite.js.map +1 -1
  71. package/dist/vue/index.d.ts +43 -0
  72. package/dist/vue/index.d.ts.map +1 -0
  73. package/dist/vue/index.js +76 -0
  74. package/dist/vue/index.js.map +1 -0
  75. package/package.json +63 -7
  76. package/src/bun.ts +77 -20
  77. package/src/cli.ts +62 -8
  78. package/src/index.ts +1 -1
  79. package/src/next-loader.ts +17 -5
  80. package/src/next.ts +120 -16
  81. package/src/preact/index.ts +91 -0
  82. package/src/react/index.tsx +12 -42
  83. package/src/runtime/bind.ts +96 -0
  84. package/src/runtime/copy.ts +148 -24
  85. package/src/solid/index.ts +67 -0
  86. package/src/svelte/Text.svelte +13 -0
  87. package/src/svelte/Text.svelte.d.ts +20 -0
  88. package/src/svelte/index.ts +65 -0
  89. package/src/tools/bundle.ts +143 -2
  90. package/src/tools/catalog.ts +10 -1
  91. package/src/tools/chunks.ts +142 -0
  92. package/src/tools/config.ts +1 -1
  93. package/src/tools/entry.ts +19 -20
  94. package/src/tools/exchange.ts +275 -0
  95. package/src/tools/extract.ts +2 -2
  96. package/src/tools/generate.ts +7 -1
  97. package/src/tools/index.ts +2 -1
  98. package/src/tools/native/android.ts +10 -25
  99. package/src/tools/native/apple.ts +70 -8
  100. package/src/tools/native/date.ts +219 -0
  101. package/src/tools/native/kotlin.ts +47 -6
  102. package/src/tools/native/model.ts +42 -16
  103. package/src/tools/native/printf.ts +46 -4
  104. package/src/tools/native/rust.ts +86 -5
  105. package/src/tools/native/swift.ts +62 -6
  106. package/src/tools/scan.ts +146 -25
  107. package/src/tools/todo.ts +6 -1
  108. package/src/vite.ts +121 -28
  109. package/src/vue/index.ts +117 -0
  110. package/dist/bundle-CpUIWb9j.d.ts.map +0 -1
  111. package/dist/bundle-DboItAre.js +0 -111
  112. package/dist/bundle-DboItAre.js.map +0 -1
  113. package/dist/compile-ClK9htKr.d.ts +0 -16
  114. package/dist/compile-ClK9htKr.d.ts.map +0 -1
  115. package/dist/copy-B3V_QRkT.d.ts.map +0 -1
  116. package/dist/copy-Bo_FIoLy.js +0 -154
  117. package/dist/copy-Bo_FIoLy.js.map +0 -1
  118. package/dist/endpoints-BgG_5Vpm.js +0 -96
  119. package/dist/endpoints-BgG_5Vpm.js.map +0 -1
  120. package/dist/generate-BW3N8EGb.js.map +0 -1
  121. package/dist/sarif-B5nrMqrT.js.map +0 -1
  122. package/dist/scan-Bx0_yDSi.js.map +0 -1
package/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, and the string resources Android and Apple read.
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
@@ -283,17 +398,29 @@ 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
 
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.
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
+ ```
292
409
 
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`.
410
+ The files need nothing beside them: each brings the plural rules of its locales, compiled from CLDR's
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`.
297
424
 
298
425
  #### Android resources
299
426
 
@@ -362,9 +489,35 @@ native: [{ language: 'apple', out: 'src-tauri/Resources', keys: ['macos'], plist
362
489
  Without `plist`, the table is `Localizable`, for what the system looks up by key, such as a push
363
490
  notification's `loc-key`. An entry is named for its key less the prefix, as written (`tray.quit` is
364
491
  `"quit"` under `['tray']`), and placeholders are written `%1$@` by their place in the source, filled
365
- in by `String(format:)`, not `Intl`. A table holds text and plain `{name}` placeholders; a plural (it
366
- would take a `.stringsdict`), a select, a date, a formatted number and a tag fail `generate`, and an
367
- Info.plist string takes no placeholders at all. Show those from the Swift target.
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.
368
521
 
369
522
  ## The catalog
370
523
 
@@ -392,6 +545,10 @@ catalog, so an editor with the YAML language server completes and explains every
392
545
  # yaml-language-server: $schema=./catalog.schema.json
393
546
  ```
394
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
+
395
552
  ### Messages
396
553
 
397
554
  ICU MessageFormat, checked at build time:
@@ -558,6 +715,26 @@ translations are then linted, as a person's would be. An agent working in the re
558
715
  of this: it can read `prosebook todo de`, write the translation files itself, and run
559
716
  `prosebook check`.
560
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
+
561
738
  **Pseudo-locale.** `pseudo: 'en-XA'` generates `⟦Ýóóúúŕ çááŕţ⟧` from the source. Switch to it and
562
739
  any plain text on screen is hardcoded, a missing bracket is clipped text, and brackets inside
563
740
  brackets are a sentence spliced from fragments.
@@ -805,10 +982,92 @@ A message the build leaves out shows as its key, so by default a build fails on
805
982
  it can't see: `t(item.key)`, ``rich(`${area}.title`)``, `<Text id={id} />`. Write the key at the read,
806
983
  or, where every key it reads is written elsewhere (in an array, say) or listed in `keep`, mark the
807
984
  line with `prosebook-ignore`. Reads are known by name: `t` and `rich` alone or as methods, the names
808
- they're destructured to (`const { rich: tRich } = copy`) in any module, and `<Text>` from
809
- `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.
810
989
  `prune` and `keep` are options of each integration.
811
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
+
812
1071
  ## Finding a string's entry
813
1072
 
814
1073
  In dev, hold Alt (Option on a Mac) and point at text on the page: the inspector outlines the
@@ -897,13 +1156,17 @@ Each is a small shop in English, German and the pseudo-locale, and uses proseboo
897
1156
  from the package. CI generates, lints, typechecks and builds each one, then checks what its build
898
1157
  serves.
899
1158
 
900
- | Example | |
901
- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------- |
902
- | [`examples/vite`](examples/vite) | React with Vite, and a part-translated Turkish. The inspector is on. |
903
- | [`examples/next`](examples/next) | Next.js: server components read a copy per request, client components the provider's. |
904
- | [`examples/tanstack-start`](examples/tanstack-start) | TanStack Start, rendered on the server in the request's locale with the Vite plugin. |
905
- | [`examples/bun`](examples/bun) | `Bun.serve` with a React page, a route that answers in the request's locale, the inspector. |
906
- | [`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. |
907
1170
 
908
1171
  ```bash
909
1172
  bun run build # the examples import prosebook from dist
@@ -914,7 +1177,4 @@ bun run --cwd examples/next dev
914
1177
 
915
1178
  Ideas not built yet, roughly in order of value:
916
1179
 
917
- 1. **Exchange formats**: XLIFF 2 and JSON export and import for translation tools.
918
- 2. **Per-area splitting**: one chunk per area and locale, so a lazy route loads only its own messages.
919
- 3. **Dates in native targets**: date and time formats taken apart from `Intl` as number formats are,
920
- 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,4 +1,4 @@
1
- import { t as Prune } from "./bundle-CpUIWb9j.js";
1
+ import { t as Prune } from "./bundle-CLHY_97D.js";
2
2
  import { i as ProsebookConfig } from "./config-CzaJJQ60.js";
3
3
  import { BunPlugin } from "bun";
4
4
  export interface ProsebookBunOptions {
@@ -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"}