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