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.
- package/LICENSE +21 -0
- package/README.md +595 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +508 -0
- package/dist/cli.js.map +1 -0
- package/dist/config-CF7HziDa.d.ts +498 -0
- package/dist/config-CF7HziDa.d.ts.map +1 -0
- package/dist/config-CxVMn19f.js +1146 -0
- package/dist/config-CxVMn19f.js.map +1 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +1 -0
- package/dist/copy-DXnzXsJa.d.ts +162 -0
- package/dist/copy-DXnzXsJa.d.ts.map +1 -0
- package/dist/helpers-BPuPGjQP.js +61 -0
- package/dist/helpers-BPuPGjQP.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +188 -0
- package/dist/index.js.map +1 -0
- package/dist/inspector.d.ts +37 -0
- package/dist/inspector.d.ts.map +1 -0
- package/dist/inspector.js +572 -0
- package/dist/inspector.js.map +1 -0
- package/dist/react/index.d.ts +37 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +86 -0
- package/dist/react/index.js.map +1 -0
- package/dist/sarif-PkPvsB07.js +1033 -0
- package/dist/sarif-PkPvsB07.js.map +1 -0
- package/dist/scan-B0tZ7y-D.js +2447 -0
- package/dist/scan-B0tZ7y-D.js.map +1 -0
- package/dist/tools.d.ts +351 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +4 -0
- package/dist/vite.d.ts +15 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +238 -0
- package/dist/vite.js.map +1 -0
- package/package.json +148 -15
- package/src/cli.ts +528 -0
- package/src/index.ts +31 -0
- package/src/inspector-dialog.ts +450 -0
- package/src/inspector.ts +228 -0
- package/src/react/index.tsx +112 -0
- package/src/runtime/copy.ts +221 -0
- package/src/runtime/helpers.ts +66 -0
- package/src/runtime/locale.ts +49 -0
- package/src/runtime/types.ts +106 -0
- package/src/tools/catalog.ts +449 -0
- package/src/tools/compile.ts +110 -0
- package/src/tools/config.ts +195 -0
- package/src/tools/diff.ts +84 -0
- package/src/tools/entry.ts +325 -0
- package/src/tools/extract.ts +694 -0
- package/src/tools/generate.ts +259 -0
- package/src/tools/icu.ts +182 -0
- package/src/tools/index.ts +78 -0
- package/src/tools/lint.ts +828 -0
- package/src/tools/lock.ts +95 -0
- package/src/tools/mf1.ts +246 -0
- package/src/tools/mf2.ts +752 -0
- package/src/tools/migrate.ts +138 -0
- package/src/tools/presets.ts +159 -0
- package/src/tools/pseudo.ts +40 -0
- package/src/tools/sarif.ts +80 -0
- package/src/tools/scan.ts +173 -0
- package/src/tools/syntax.ts +24 -0
- package/src/tools/types.ts +137 -0
- package/src/vite.ts +252 -0
- 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 {}
|