prosebook 0.0.1 → 0.2.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 (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +604 -0
  3. package/dist/cli.d.ts +1 -0
  4. package/dist/cli.js +508 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/config-D--RITk2.js +1208 -0
  7. package/dist/config-D--RITk2.js.map +1 -0
  8. package/dist/config-_4HfnJQu.d.ts +517 -0
  9. package/dist/config-_4HfnJQu.d.ts.map +1 -0
  10. package/dist/config.d.ts +2 -0
  11. package/dist/config.js +1 -0
  12. package/dist/copy-DXnzXsJa.d.ts +162 -0
  13. package/dist/copy-DXnzXsJa.d.ts.map +1 -0
  14. package/dist/helpers-BPuPGjQP.js +61 -0
  15. package/dist/helpers-BPuPGjQP.js.map +1 -0
  16. package/dist/index.d.ts +24 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +188 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/inspector.d.ts +37 -0
  21. package/dist/inspector.d.ts.map +1 -0
  22. package/dist/inspector.js +572 -0
  23. package/dist/inspector.js.map +1 -0
  24. package/dist/react/index.d.ts +37 -0
  25. package/dist/react/index.d.ts.map +1 -0
  26. package/dist/react/index.js +86 -0
  27. package/dist/react/index.js.map +1 -0
  28. package/dist/sarif-CI8HOokm.js +1033 -0
  29. package/dist/sarif-CI8HOokm.js.map +1 -0
  30. package/dist/scan-Ch2BfIUD.js +2561 -0
  31. package/dist/scan-Ch2BfIUD.js.map +1 -0
  32. package/dist/tools.d.ts +377 -0
  33. package/dist/tools.d.ts.map +1 -0
  34. package/dist/tools.js +4 -0
  35. package/dist/vite.d.ts +24 -0
  36. package/dist/vite.d.ts.map +1 -0
  37. package/dist/vite.js +264 -0
  38. package/dist/vite.js.map +1 -0
  39. package/package.json +148 -15
  40. package/src/cli.ts +528 -0
  41. package/src/index.ts +31 -0
  42. package/src/inspector-dialog.ts +450 -0
  43. package/src/inspector.ts +228 -0
  44. package/src/react/index.tsx +112 -0
  45. package/src/runtime/copy.ts +221 -0
  46. package/src/runtime/helpers.ts +66 -0
  47. package/src/runtime/locale.ts +49 -0
  48. package/src/runtime/types.ts +106 -0
  49. package/src/tools/catalog.ts +449 -0
  50. package/src/tools/compile.ts +121 -0
  51. package/src/tools/config.ts +211 -0
  52. package/src/tools/diff.ts +84 -0
  53. package/src/tools/entry.ts +325 -0
  54. package/src/tools/extract.ts +694 -0
  55. package/src/tools/generate.ts +257 -0
  56. package/src/tools/icu.ts +211 -0
  57. package/src/tools/index.ts +91 -0
  58. package/src/tools/lint.ts +865 -0
  59. package/src/tools/lock.ts +95 -0
  60. package/src/tools/mf1.ts +246 -0
  61. package/src/tools/mf2.ts +752 -0
  62. package/src/tools/migrate.ts +138 -0
  63. package/src/tools/presets.ts +159 -0
  64. package/src/tools/pseudo.ts +40 -0
  65. package/src/tools/sarif.ts +80 -0
  66. package/src/tools/scan.ts +303 -0
  67. package/src/tools/syntax.ts +24 -0
  68. package/src/tools/types.ts +137 -0
  69. package/src/vite.ts +296 -0
  70. package/index.js +0 -1
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gokhan Kurt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,604 @@
1
+ # prosebook
2
+
3
+ Every string your app shows, in one typed catalog. For React and any JavaScript.
4
+
5
+ [Homepage](https://gkurt.com/prosebook/) · [Try it on the page](https://gkurt.com/prosebook/#in-context)
6
+
7
+ UX writers keep every string in a product in one document, the copy deck. prosebook makes that
8
+ book of your app's prose the source of truth: the words live in YAML, one entry per string, with the
9
+ context a translator needs beside them, and the code holds only keys. Changing what the app says is an edit to the
10
+ catalog, not a code change.
11
+
12
+ ```yaml
13
+ # copy/catalog/cart.yaml
14
+ cart.items:
15
+ kind: state
16
+ surface: status
17
+ context: Under the cart heading, counts what is in the cart.
18
+ args:
19
+ count: how many items are in the cart
20
+ en: '{count, plural, =0 {Your cart is empty.} one {# item in your cart.} other {# items in your cart.}}'
21
+ ```
22
+
23
+ ```tsx
24
+ const { t } = useCopy();
25
+ <p>{t('cart.items', { count })}</p>; // "3 items in your cart."
26
+ ```
27
+
28
+ - **Typed.** A missing key, a misspelled key or a missing argument is a type error. Completing a key
29
+ shows its English text and context.
30
+ - **Compiled.** `prosebook generate` turns each message, in ICU MessageFormat or MessageFormat 2, into
31
+ a plain function. The browser parses nothing: no parser ships, no `eval`, and the runtime is about
32
+ 2.5 KB gzipped.
33
+ - **Checked.** Placeholders that drift between the source and a translation fail the build. A
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.
37
+ - **Written well.** An optional lint holds the words to a writing guide: word budgets per kind of
38
+ string, periods, sentence case, banned filler, and `{count} files` outside a plural. Plugins add your
39
+ own rules, scoped by kind, surface and locale, and translations are held to their source.
40
+
41
+ ## Quick start
42
+
43
+ ```bash
44
+ bun add prosebook
45
+ ```
46
+
47
+ ```bash
48
+ bunx prosebook init
49
+ ```
50
+
51
+ `init` writes `prosebook.config.ts` and `copy/catalog/app.yaml`. Then:
52
+
53
+ ```bash
54
+ bunx prosebook generate
55
+ ```
56
+
57
+ This writes `src/copy/`: one module per locale and an `index.ts` with the types. With Vite, add the
58
+ plugin instead and generation happens on start and on every catalog edit, and the page shows the new
59
+ text without reloading, in the locale and state it was in:
60
+
61
+ ```ts
62
+ // vite.config.ts
63
+ import { prosebook } from 'prosebook/vite';
64
+
65
+ export default defineConfig({ plugins: [react(), prosebook()] });
66
+ ```
67
+
68
+ Several apps that share one catalog each point the plugin at it: `prosebook({ root: '../../packages/copy' })`
69
+ reads the config there and resolves its paths from there, so every app generates the same files.
70
+
71
+ ### In React
72
+
73
+ ```tsx
74
+ import { createCopy, preferredLocales } from 'prosebook';
75
+ import { CopyProvider, Text, useCopy } from 'prosebook/react';
76
+ import { catalog } from './copy';
77
+
78
+ const copy = createCopy(catalog, { locale: preferredLocales() });
79
+
80
+ root.render(
81
+ <CopyProvider copy={copy} tags={{ link: (chunks) => <a href="/terms">{chunks}</a> }}>
82
+ <App />
83
+ </CopyProvider>,
84
+ );
85
+
86
+ function Cart({ count }: { count: number }) {
87
+ const copy = useCopy(); // re-renders when the locale changes
88
+ return (
89
+ <>
90
+ <h2>{copy.t('cart.title')}</h2>
91
+ <Text id="cart.items" values={{ count }} />
92
+ <p>{copy.rich('cart.terms')}</p>
93
+ <button onClick={() => copy.setLocale('de')}>Deutsch</button>
94
+ </>
95
+ );
96
+ }
97
+ ```
98
+
99
+ ### In plain JavaScript
100
+
101
+ ```js
102
+ import { createCopy } from 'prosebook';
103
+ import { catalog } from './copy/index.js';
104
+
105
+ const copy = createCopy(catalog, { locale: navigator.languages });
106
+ await copy.ready;
107
+ document.title = copy.t('app.title');
108
+ copy.subscribe(() => render()); // after a locale switch, and in dev after a catalog edit
109
+ ```
110
+
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.
113
+
114
+ ## The catalog
115
+
116
+ One file per area, `copy/catalog/<area>.yaml`. A key's first segment names its file, so
117
+ `cart.items` lives in `cart.yaml` and nowhere else; searching for a key finds its entry.
118
+
119
+ | Field | |
120
+ | ---------- | -------------------------------------------------------------------------------------------------------- |
121
+ | `en` | The message, in ICU MessageFormat or MessageFormat 2. The field is named after `source` in the config. |
122
+ | `context` | One sentence telling a translator where the string appears. Required by default. |
123
+ | `args` | What each placeholder holds, one line each. Required for every placeholder when `context` is. |
124
+ | `kind` | What sort of string it is: `name`, `action`, `description`, `state`. The lint holds each to its budget. |
125
+ | `surface` | Where it sits: `label`, `heading`, `tooltip`, `error`, `empty`, `hint`, ... |
126
+ | `fragment` | `true` where it completes something beside it, so sentence rules don't apply. |
127
+ | `allow` | Lint rules the entry is exempt from, each with the reason. |
128
+ | `comment` | A change asked of whoever edits the entry next. A warning until it is made and the comment deleted. |
129
+ | `remove` | `true` asks for the entry to go, with its translations and the code that reads it. A warning until then. |
130
+
131
+ `key: message` is the shorthand for an entry with nothing to say about itself (set `require: []`;
132
+ the schema offers it only then).
133
+ Kinds and surfaces are configurable; `prosebook generate` writes `catalog.schema.json` beside the
134
+ catalog, so an editor with the YAML language server completes and explains every field:
135
+
136
+ ```yaml
137
+ # yaml-language-server: $schema=./catalog.schema.json
138
+ ```
139
+
140
+ ### Messages
141
+
142
+ ICU MessageFormat, checked at build time:
143
+
144
+ ```yaml
145
+ en: 'Hello, {name}.' # name: string
146
+ en: '{n, plural, =0 {No files} one {# file} other {# files}}' # n: number
147
+ en: '{rank, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}'
148
+ en: '{mode, select, dark {Dark} other {Light}}' # mode: 'dark' | (string & {})
149
+ en: '{size, number, ::.0 unit/megabyte}' # 1.4 MB
150
+ en: '{total, number, ::currency/EUR}' # €9.00
151
+ en: '{when, date, medium} at {when, time, short}' # Oct 28, 2026 at 2:05 PM
152
+ en: '{when, date, ::yMMMdjmm}' # Oct 28, 2026, 2:05 PM; 28. Okt. 2026, 14:05 in German
153
+ en: 'By checking out you agree to our <link>terms</link>.' # a tag: an element inside the sentence
154
+ ```
155
+
156
+ A placeholder holds a number, a name or something a person typed, never a translated fragment: a
157
+ word that changes with the sentence belongs in a `plural` or `select` arm. A skeleton's `j` is the
158
+ hour as each locale writes it, 12 or 24. A skeleton the formatter would silently drop
159
+ (`::compact-shortt`, a date's `J`) fails the build instead of reaching the screen unformatted.
160
+
161
+ **Tags** keep a sentence with a link or bold run inside it as one entry, so a translator can move the
162
+ element. `t()` renders a tag as its text; `rich()` and `<Text>` render it through a function. Tags
163
+ the provider renders by default don't have to be passed at each call. Tell the types which ones those
164
+ are:
165
+
166
+ ```ts
167
+ declare module 'prosebook' {
168
+ interface Register {
169
+ tags: 'link' | 'b';
170
+ }
171
+ }
172
+ ```
173
+
174
+ ### MessageFormat 2
175
+
176
+ Set `messageFormat: 2` and the catalog and its translations are written in
177
+ [Unicode MessageFormat 2](https://messageformat.unicode.org) instead. Everything past reading is the
178
+ same: the types, the checks, the lint, the pseudo-locale and the compiled functions, so the runtime
179
+ doesn't change and nothing extra ships.
180
+
181
+ ```yaml
182
+ en: 'Hello, {$name}.' # name: string
183
+ en: 'Total: {$total :currency currency=EUR}' # €9.00
184
+ en: '{$size :unit unit=megabyte maximumFractionDigits=1}' # 1.4 MB
185
+ en: '{$when :datetime dateLength=long}' # October 28, 2026 at 2:05 PM
186
+ en: 'By checking out you agree to our {#link}terms{/link}.' # a tag
187
+ en: |-
188
+ .input {$n :number}
189
+ .match $n
190
+ 0 {{No files}}
191
+ one {{{$n} file}}
192
+ * {{{$n} files}}
193
+ en: |-
194
+ .input {$mode :string}
195
+ .match $mode
196
+ dark {{Dark}}
197
+ * {{Light}}
198
+ ```
199
+
200
+ A message with `.match` holds whole sentences per variant, so a translator never stitches a sentence
201
+ 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.
204
+
205
+ The functions are MF2's defaults: `:string`, `:number`, `:integer`, `:percent`, `:currency`, `:unit`,
206
+ `: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.
210
+
211
+ **Switching.** `prosebook migrate 2` rewrites the catalog and every translation in MF2, and
212
+ `prosebook migrate 1` goes back. It's a dry run unless `--write`; then set `messageFormat` in the
213
+ config. Before writing, it renders each message both ways over sample values and reports any that
214
+ would come out different, such as German's medium date, numeric in MF1 and spelled out in MF2's
215
+ nearest. A message the other syntax can't say (`::compact-short` has no MF2 word) stops the write
216
+ and is named, so nothing changes until it's resolved. `prosebook extract` writes new entries in
217
+ whichever syntax the catalog uses.
218
+
219
+ ## Translations
220
+
221
+ `copy/locales/<locale>/<area>.yaml` (or one `copy/locales/<locale>.yaml`) holds only
222
+ `key: translation`. The metadata stays in the catalog, so a translator merges against nothing.
223
+
224
+ ```yaml
225
+ # copy/locales/de/cart.yaml
226
+ cart.items: '{count, plural, =0 {Dein Warenkorb ist leer.} other {# Artikel im Warenkorb.}}'
227
+ ```
228
+
229
+ A key a locale leaves out falls back, key by key, through its parents (`pt-BR` → `pt`) to the
230
+ source, and the fallback is formatted in its own locale. A translation whose placeholders or tags
231
+ differ from the source fails the build.
232
+
233
+ **Stale translations.** `copy/lock.json` records which source text each translation was made from.
234
+ Edit an English message and its translations are stale until someone edits them, or confirms they
235
+ still hold:
236
+
237
+ ```bash
238
+ prosebook status # coverage and stale counts per locale
239
+ prosebook todo de # missing and stale entries, with context, ready to translate
240
+ prosebook accept de cart.items
241
+ ```
242
+
243
+ The lock also keeps the text each translation was made from, so a translator sees what changed,
244
+ in the notation of `git diff --word-diff`:
245
+
246
+ ```text
247
+ $ prosebook status --verbose
248
+ de 13/14 92% 1 2
249
+ stale cart.add: Add {+one +}to cart
250
+ stale cart.checkout: [-Check out-]{+Pay now+}
251
+
252
+ $ prosebook todo de
253
+ # The button on a product that adds one to the cart.
254
+ # en: Add one to cart
255
+ # stale, the source changed: Add {+one +}to cart
256
+ cart.add: In den Warenkorb
257
+ ```
258
+
259
+ Stale translations still show by default; `stale: 'fallback'` shows the source instead.
260
+
261
+ **Pseudo-locale.** `pseudo: 'en-XA'` generates `⟦Ýóóúúŕ çááŕţ⟧` from the source. Switch to it and
262
+ any plain text on screen is hardcoded, a missing bracket is clipped text, and brackets inside
263
+ brackets are a sentence spliced from fragments.
264
+
265
+ ## Checks
266
+
267
+ ```bash
268
+ prosebook lint # the words against the writing rules (--fix writes the fixes they offer)
269
+ prosebook scan # hardcoded text in JSX, and catalog keys no code reads
270
+ prosebook extract # move that text into the catalog (see below)
271
+ prosebook migrate # rewrite the catalog in ICU MessageFormat (1) or MessageFormat 2
272
+ prosebook check # generate --check, lint and status --strict, for CI
273
+ ```
274
+
275
+ **Lint.** `recommended` checks shape: word budgets per kind, periods, bare names, whitespace. It
276
+ reads the message format too:
277
+
278
+ | Rule | Catches |
279
+ | ------------------- | ----------------------------------------------------------------------------------------- |
280
+ | `plural-count` | `{count} files` outside a plural, which reads "1 files" (English) |
281
+ | `plural-one` | a `one` arm that says what `other` says, or writes `1` where `#` belongs |
282
+ | `arm-endings` | plural or select arms where some end with a stop and some don't |
283
+ | `formatted-by-hand` | `${price}` or `{rate}%` typed around a placeholder, and digits grouped by hand (`10,000`) |
284
+ | `source-ending` | a translation that ends with a stop where its source doesn't, or the other way round |
285
+
286
+ Translations are linted too, by the rules that hold in any language: `whitespace`, `ellipsis`,
287
+ `arm-endings`, `formatted-by-hand`, `source-ending`, and `plural-one`'s `#` (not its `one`
288
+ and `other` saying the same, which Turkish does: `# dosya`). The rest read the source only. A
289
+ finding names the translation's file.
290
+
291
+ A finding points at the words it is about, `file:line:column`, when the message is written in the
292
+ file as it reads (plain, or quoted on one line); otherwise at the entry's key. Spellings, the one word
293
+ per concept, contractions, `…` and doubled spaces are marked `(fixable)`: `prosebook lint --fix`
294
+ writes them, in the case of the words they replace, wherever in the message they stand. Every fix is
295
+ checked as the whole catalog first, and nothing is written if any would break it. Only the message
296
+ changes: a `key: message` entry stays one, and quoting and comments elsewhere stay as they were.
297
+
298
+ `strict` adds sentence case, contractions, em-dashes, `…`, American spelling, paths written with one
299
+ separator (`Settings › General`, not `→` or `>`; set `pathSeparator`) and a list of filler and
300
+ marketing words. Add your own `banned`, `spelling` and `concepts` (one word per concept:
301
+ `{ tablet: 'device' }`), and `properNouns` for sentence case. Where a rule is wrong about an
302
+ entry, say why:
303
+
304
+ ```yaml
305
+ legal.privacy:
306
+ kind: name
307
+ context: Links to the privacy policy.
308
+ allow:
309
+ sentence-case: The title of a legal document, which keeps its published capitals.
310
+ en: Privacy Policy
311
+ ```
312
+
313
+ A reason that is a shrug (`Intentional.`) fails, and so does an exemption the entry no longer needs.
314
+ An exemption covers the entry in every locale, and is needed while any of them needs it.
315
+
316
+ Where a rule is wrong about a whole area, set its severity there. A key's first segment is its area:
317
+
318
+ ```ts
319
+ lint: { areas: { native: { 'sentence-case': 'off' } } }, // menu bars and trays write Title Case
320
+ ```
321
+
322
+ **Lint plugins.** A plugin's rules are named `<plugin>/<rule>` in `lint.rules` and `allow`. A rule
323
+ is given the entry and its readings: `primary` takes every `other` arm, `variants` holds one reading
324
+ per plural or select arm, and `skeleton` only the words every arm carries. Placeholders and verbatim
325
+ tags (`code`, `kbd`, ...) read as `HOLE`. `on` limits a rule to kinds, surfaces or areas, and says which
326
+ locales it reads: the source when unset, `'all'`, or a list of languages (`['de']` covers `de-CH`).
327
+ A rule is then given the `locale`, that locale's `ast` and `text`, and the `source` readings to
328
+ compare with. `recommended` is its severity under that preset (off when unset), `strict` under
329
+ strict (`error` when unset).
330
+
331
+ ```ts
332
+ import { defineConfig, definePlugin, words } from 'prosebook/config';
333
+
334
+ const house = definePlugin({
335
+ name: 'house',
336
+ rules: {
337
+ 'error-says-what-to-do': {
338
+ description: 'An error says what failed and what to do next.',
339
+ on: { surface: ['error'] },
340
+ recommended: 'warn',
341
+ check: ({ text }) => (words(text.primary).length < 4 ? ['say what to do next, not only what failed'] : []),
342
+ },
343
+ },
344
+ });
345
+
346
+ export default defineConfig({ lint: { plugins: [house], rules: { 'house/error-says-what-to-do': 'error' } } });
347
+ ```
348
+
349
+ `words`, `sentences` and `phrase` are the helpers the core rules count and match with. A rule can
350
+ return `{ message, match, fix }` instead of a string: `match` is the words as a reading has them,
351
+ which the finding then points at, and `fix` what `--fix` writes in their place. Data rules do this
352
+ themselves: `existence` and `capitalization` point at what they found, and `substitution` fixes it.
353
+
354
+ A rule can also be data, after [Vale](https://vale.sh)'s rule types, which a `prosebook.config.json`
355
+ can carry. Words are matched whole and in any case; a pattern is a regular expression (`u` flag,
356
+ `ignoreCase` to add `i`). Words and patterns are looked for in every plural and select arm; counts
357
+ and case read the `other` arms. A message names what was found with `{match}`.
358
+
359
+ | `type` | Fields | Fails when |
360
+ | ---------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
361
+ | `existence` | `words`, `patterns`, `message` | the string says one of them |
362
+ | `substitution` | `swap: { word: use }`, `message` (`{use}`) | the string says a word the product writes another way |
363
+ | `occurrence` | `count` (`words`, `sentences`, `characters`), `max`, `min`, `relative` | the count is out of bounds (`{count}`); placeholders count as words, not characters. With `relative`, `max` and `min` are multiples of the source's count (`{source}`) |
364
+ | `capitalization` | `case` (`sentence`, `title`, `lower`, `upper`), `exceptions` | a word is out of case; `lint.properNouns` keep theirs too |
365
+ | `conditional` | `when`, `require`, `message` | the string matches `when` (or `when` is unset) but not `require` |
366
+
367
+ ```json
368
+ {
369
+ "lint": {
370
+ "plugins": [
371
+ {
372
+ "name": "house",
373
+ "rules": {
374
+ "toast-budget": {
375
+ "type": "occurrence",
376
+ "description": "A toast is read in passing.",
377
+ "on": { "surface": ["toast"] },
378
+ "count": "characters",
379
+ "max": 40,
380
+ "recommended": "warn"
381
+ },
382
+ "button-growth": {
383
+ "type": "occurrence",
384
+ "description": "A translated button is half again as long as the source at most.",
385
+ "on": { "surface": ["action"], "locale": "all" },
386
+ "count": "characters",
387
+ "max": 1.5,
388
+ "relative": true,
389
+ "recommended": "warn"
390
+ },
391
+ "sign-in": { "type": "substitution", "description": "One verb for signing in.", "swap": { "log in": "sign in" } }
392
+ }
393
+ }
394
+ ]
395
+ }
396
+ }
397
+ ```
398
+
399
+ Every rule is checked when the config loads: a name in `lint.rules` that no rule has, a plugin
400
+ added twice, a pattern that is not a regular expression, or a rule missing what its type needs.
401
+
402
+ **Scan** reports JSX text and readable attributes (`title`, `alt`, `placeholder`, `aria-label`, ...)
403
+ written as literals, and catalog keys no code names. Mark a deliberate literal with
404
+ `prosebook-ignore` on its line or the line above.
405
+
406
+ **SARIF.** `lint`, `scan`, `status` and `check` take `--format sarif` and write a
407
+ [SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html) log to stdout for
408
+ code scanning; the usual text goes to stderr and the exit code is the same. Each result points at
409
+ the line it is about: the entry in the catalog or translation file, or the JSX for hardcoded text.
410
+ Lint results carry the rule's name and description; the other checks are `catalog`, `out-of-date`,
411
+ `missing-translation`, `stale-translation`, `hardcoded-text` and `unused-key`. In GitHub Actions:
412
+
413
+ ```yaml
414
+ - run: npx prosebook check --format sarif > prosebook.sarif
415
+ - uses: github/codeql-action/upload-sarif@v4
416
+ if: always()
417
+ with:
418
+ sarif_file: prosebook.sarif
419
+ category: prosebook
420
+ ```
421
+
422
+ Paths are relative to the project root, which the log names as `%SRCROOT%`, so a project in a
423
+ subfolder of its repository resolves too. `scan` writes a log of its own; upload it with its own
424
+ `category`.
425
+
426
+ ## Extracting hardcoded text
427
+
428
+ `prosebook extract` moves what `scan` finds into the catalog and rewrites the code to read it. It
429
+ prints the plan by default and changes nothing until you pass `--write`:
430
+
431
+ ```bash
432
+ prosebook extract # every .tsx/.jsx under scan.dirs
433
+ prosebook extract src/Cart.tsx --write
434
+ prosebook extract src/settings --area settings --write
435
+ ```
436
+
437
+ It extracts whole sentences, not fragments. The text, simple expressions and inline elements
438
+ (`a`, `b`, `strong`, `em`, `span`, `*Link`, ...) that sit together in one element become one
439
+ message, so a translator can reorder all of it:
440
+
441
+ ```tsx
442
+ // before
443
+ <p>
444
+ Hi {user.name}, read the <a href="/terms">terms of <b>sale</b></a>.
445
+ </p>
446
+ <input placeholder="Search items" />
447
+
448
+ // after
449
+ <p>
450
+ <Text id="profile.hi-read-the-terms" values={{ name: user.name, a: (chunks) => <a href="/terms">{chunks}</a>, b: (chunks) => <b>{chunks}</b> }} />
451
+ </p>
452
+ <input placeholder={t("profile.search-items-placeholder")} />
453
+ ```
454
+
455
+ ```yaml
456
+ # TODO(prosebook extract): check the key, kind and context.
457
+ profile.hi-read-the-terms:
458
+ context: In Profile, the text of a <p>.
459
+ args:
460
+ name: 'TODO: what `user.name` holds'
461
+ en: Hi {name}, read the <a>terms of <b>sale</b></a>.
462
+ ```
463
+
464
+ - Text becomes `<Text>`, which needs no hook, so it works in class components and in callbacks too.
465
+ A readable attribute becomes `t('…')`. extract adds `const { t } = useCopy()` to the component,
466
+ joins an existing `useCopy()` call, or uses `copy.t` where the component already binds a `t` of its
467
+ own. It adds the imports too.
468
+ - The area is the file's name in kebab case (`CartPanel.tsx` → `cart-panel`), or its folder's for an
469
+ `index`; `--area` overrides it. The key adds the message's first words, and a suffix for an
470
+ attribute (`-placeholder`, `-tooltip`, `-aria`). Text identical to an entry already in the area
471
+ reuses that key.
472
+ - `kind` and `surface` come from the element where it's clear (`<button>` → action, `<h2>` →
473
+ heading, `placeholder` → placeholder). `context` names the component and the element.
474
+ - Whitespace renders exactly as before. Code-like tokens (`form.json()`, `src/index.ts`), text in
475
+ `<code>` and `<pre>`, and lines marked `prosebook-ignore` stay where they are.
476
+
477
+ **Typed placeholders.** extract asks the project's TypeScript (7 or later, found through the nearest
478
+ `tsconfig.json`) what each expression holds:
479
+
480
+ | The value is | The placeholder becomes |
481
+ | ---------------------------------------------- | ----------------------------------------------------------------------- |
482
+ | text | `{name}` |
483
+ | a number | `{count, number}`, so it's formatted for the locale (`1,234`, `1.234`) |
484
+ | a number followed by a plural noun, in English | `{count, plural, one {# file} other {# files}}` |
485
+ | a `Date` | `{when, date, medium}` |
486
+ | markup (`ReactNode`, elements) | nothing: the sentence is left as it is, with a warning to make it a tag |
487
+
488
+ Without TypeScript (a JavaScript project, a file outside any tsconfig) the syntax decides where it can:
489
+ `items.length`, arithmetic and number literals are numbers, `new Date()` is a date. The report says
490
+ which of the two it used.
491
+
492
+ Every new entry is marked `TODO(prosebook extract)`. The key is a suggestion: rename it to say what
493
+ the string is for, then fill in the context a translator needs. Check a drafted plural's arms too:
494
+ the singular is the plural less its `s`, `es` or `ies`.
495
+
496
+ ## Pruning
497
+
498
+ 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;
500
+ ``t(`status.${state}`)`` and `t('status.' + state)` keep every key under `status.`. For keys built
501
+ some other way, list them: `prosebook({ keep: ['plan.*'] })`.
502
+
503
+ A message the build leaves out shows as its key, so by default a build fails on any read whose key
504
+ it can't see: `t(item.key)`, ``rich(`${area}.title`)``, `<Text id={id} />`. Write the key at the read,
505
+ or, where every key it reads is written elsewhere (in an array, say) or listed in `keep`, mark the
506
+ line with `prosebook-ignore`. Reads are known by name: `t` and `rich` alone or as methods, the names
507
+ they're destructured to (`const { rich: tRich } = copy`) in any module, and `<Text>` from
508
+ `prosebook/react`. `prune: 'loose'` prunes without the check, and `prune: false` ships everything.
509
+
510
+ ## Finding a string's entry
511
+
512
+ In dev, hold Alt (Option on a Mac) and point at text on the page: the Vite plugin outlines the
513
+ string and names its key and the locale it came from. Alt-click opens its entry in your editor: the
514
+ translation for a translated string, the catalog entry for one that fell back. Alt-Shift-click opens
515
+ the catalog entry either way. Save, and the string changes on the page, which keeps its locale and
516
+ state. Only adding or removing a locale reloads it.
517
+
518
+ Alt-right-click edits the entry without leaving the page. A dialog opens beside the string with its
519
+ fields: the source message and its translation in every locale (the one the string was read in
520
+ first), context, kind, surface, fragment and what each placeholder holds. A YAML tab shows the entry
521
+ as written in each file. A translation left empty is none, so the locale falls back; filling one in
522
+ adds it to that locale's file, the area's file for a locale kept in a folder. Save checks the whole
523
+ catalog with the edit first, so a broken message or a missing argument doc shows in the dialog and
524
+ nothing is written; a clean edit is written into the file, which keeps its comments and layout, and
525
+ the page updates in place. <kbd>Ctrl</kbd>/<kbd>⌘</kbd>+<kbd>Enter</kbd> saves, <kbd>Esc</kbd> closes.
526
+
527
+ As you type, the dialog has the edit checked unsaved and shows what lint says of each locale under
528
+ its text, with every rule the config has, plugins included. Clicking a finding selects its words;
529
+ Fix makes the fix `prosebook lint --fix` would. Findings don't stop a save: they are the writing
530
+ guide's, not the catalog's.
531
+
532
+ To ask for a change rather than make it, write a comment, or press Mark for removal. Both are written
533
+ into the catalog entry (`comment:`, `remove: true`), and every `prosebook generate` and `check` lists
534
+ them as warnings with their file and key, so a coding agent asked to "act on the prosebook comments"
535
+ finds each one, makes the change, and deletes the comment or the entry. Hovering the key at its call
536
+ site shows them too.
537
+
538
+ Nothing is added to the page to make this work. In dev the runtime reports each string it hands out,
539
+ and the inspector matches the text under the pointer back to it, so `t()`, `<Text>`, a sentence with
540
+ a link inside it and readable attributes (`placeholder`, `title`, `aria-label`, `alt`) all work. Text
541
+ 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 })`.
546
+
547
+ ## Config
548
+
549
+ ```ts
550
+ // prosebook.config.ts
551
+ import { defineConfig } from 'prosebook/config';
552
+
553
+ export default defineConfig({
554
+ catalog: 'copy/catalog', // source YAML
555
+ locales: 'copy/locales', // translations
556
+ out: 'src/copy', // generated modules
557
+ lock: 'copy/lock.json',
558
+ source: 'en',
559
+ messageFormat: 1, // or 2, for Unicode MessageFormat 2
560
+ bundled: [], // locales shipped in the main bundle; the rest load on first use
561
+ pseudo: false, // or 'en-XA'
562
+ stale: 'use', // or 'fallback'
563
+ language: 'ts', // or 'js'
564
+ importExtension: '.js', // '.ts' for Node's type stripping, '' for bundler resolution
565
+ require: ['context'],
566
+ kinds: {/* name, action, description, state */},
567
+ surfaces: {/* label, heading, tooltip, ... */},
568
+ lint: {
569
+ extends: 'recommended',
570
+ plugins: [],
571
+ rules: {},
572
+ areas: {}, // severities per area: { native: { 'sentence-case': 'off' } }
573
+ banned: [],
574
+ spelling: {},
575
+ concepts: {},
576
+ properNouns: [],
577
+ pathSeparator: '›',
578
+ },
579
+ scan: { dirs: ['src'] },
580
+ });
581
+ ```
582
+
583
+ ## Example
584
+
585
+ [`examples/react-app`](examples/react-app) is a small shop in English, German, a part-translated
586
+ Turkish and the pseudo-locale.
587
+
588
+ ```bash
589
+ bun run example
590
+ ```
591
+
592
+ ## Further
593
+
594
+ Ideas not built yet, roughly in order of value:
595
+
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.
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {}