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.
- package/LICENSE +21 -0
- package/README.md +604 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +508 -0
- package/dist/cli.js.map +1 -0
- package/dist/config-D--RITk2.js +1208 -0
- package/dist/config-D--RITk2.js.map +1 -0
- package/dist/config-_4HfnJQu.d.ts +517 -0
- package/dist/config-_4HfnJQu.d.ts.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-CI8HOokm.js +1033 -0
- package/dist/sarif-CI8HOokm.js.map +1 -0
- package/dist/scan-Ch2BfIUD.js +2561 -0
- package/dist/scan-Ch2BfIUD.js.map +1 -0
- package/dist/tools.d.ts +377 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +4 -0
- package/dist/vite.d.ts +24 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +264 -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 +121 -0
- package/src/tools/config.ts +211 -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 +257 -0
- package/src/tools/icu.ts +211 -0
- package/src/tools/index.ts +91 -0
- package/src/tools/lint.ts +865 -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 +303 -0
- package/src/tools/syntax.ts +24 -0
- package/src/tools/types.ts +137 -0
- package/src/vite.ts +296 -0
- package/index.js +0 -1
package/src/cli.ts
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The `prosebook` command. Each subcommand is a function of the resolved config; `check` runs the
|
|
4
|
+
* ones CI needs in one go.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, watch, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join, relative, resolve } from 'node:path';
|
|
8
|
+
import { parseArgs } from 'node:util';
|
|
9
|
+
|
|
10
|
+
import { stringify } from 'yaml';
|
|
11
|
+
|
|
12
|
+
import { CatalogError, entryLocator, whereOf, type Catalog, type Problem } from './tools/catalog.ts';
|
|
13
|
+
import { CONFIG_FILES, loadConfig, type ResolvedConfig } from './tools/config.ts';
|
|
14
|
+
import { formatDiff, wordDiff } from './tools/diff.ts';
|
|
15
|
+
import { applyFixes } from './tools/entry.ts';
|
|
16
|
+
import { applyExtract, planExtract } from './tools/extract.ts';
|
|
17
|
+
import { generate, render } from './tools/generate.ts';
|
|
18
|
+
import { lint } from './tools/lint.ts';
|
|
19
|
+
import type { StaleTranslation } from './tools/lock.ts';
|
|
20
|
+
import { applyMigrate, planMigrate } from './tools/migrate.ts';
|
|
21
|
+
import { SarifReport } from './tools/sarif.ts';
|
|
22
|
+
import { isReferenced, scan, sourceFiles } from './tools/scan.ts';
|
|
23
|
+
import { SYNTAX_NAME } from './tools/syntax.ts';
|
|
24
|
+
|
|
25
|
+
const HELP = `prosebook: every string your app shows, in one typed catalog
|
|
26
|
+
|
|
27
|
+
Usage: prosebook <command> [options]
|
|
28
|
+
|
|
29
|
+
Commands:
|
|
30
|
+
init Create a config and a first catalog file
|
|
31
|
+
generate Compile the catalog into typed modules (--check: fail if out of date, --watch)
|
|
32
|
+
lint Check the messages and their translations against the writing rules
|
|
33
|
+
(--fix: write the fixes the rules know, such as spellings and …)
|
|
34
|
+
status Translation coverage and stale translations per locale (--strict, --verbose)
|
|
35
|
+
accept <locale> [keys] Mark stale translations as still right (--all for every stale one)
|
|
36
|
+
todo <locale> Print the missing and stale entries of a locale, ready to translate
|
|
37
|
+
scan Find hardcoded text in JSX and catalog keys no code reads (--strict)
|
|
38
|
+
extract [paths] Move hardcoded JSX text into the catalog and rewrite the code to read it
|
|
39
|
+
(dry run unless --write; --area <name> puts every entry in one area)
|
|
40
|
+
migrate <1|2> Rewrite the catalog and translations in ICU MessageFormat (1) or MessageFormat 2
|
|
41
|
+
(dry run unless --write)
|
|
42
|
+
check generate --check, lint and status --strict together, for CI
|
|
43
|
+
|
|
44
|
+
Options:
|
|
45
|
+
--root <dir> Project root (default: the current directory)
|
|
46
|
+
--format <text|sarif> With lint, scan, status and check: sarif writes a SARIF 2.1.0 log to stdout
|
|
47
|
+
for code scanning, and the usual text to stderr
|
|
48
|
+
-h, --help Show this help
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
const SARIF_COMMANDS = ['lint', 'scan', 'status', 'check'];
|
|
52
|
+
|
|
53
|
+
/** What the checks that aren't lint rules hold the project to, for the rules in a SARIF log. */
|
|
54
|
+
const RULES = {
|
|
55
|
+
catalog: 'The catalog and its translations parse, and each translation keeps the placeholders of its source.',
|
|
56
|
+
'out-of-date': 'The generated modules match the catalog: run `prosebook generate`.',
|
|
57
|
+
'missing-translation': 'Every locale translates every entry.',
|
|
58
|
+
'stale-translation': 'A translation was made from the source text as it is now.',
|
|
59
|
+
'hardcoded-text': 'Text a person reads comes from the catalog, not from a literal in JSX.',
|
|
60
|
+
'unused-key': 'Code reads every catalog key.',
|
|
61
|
+
allow: "An entry's exemption names a rule the entry breaks, and says why in a sentence.",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Text for people. Under `--format sarif` stdout holds only the log, so this goes to stderr. */
|
|
65
|
+
let out = (s = '') => process.stdout.write(`${s}\n`);
|
|
66
|
+
const err = (s = '') => process.stderr.write(`${s}\n`);
|
|
67
|
+
|
|
68
|
+
async function main(argv: string[]): Promise<number> {
|
|
69
|
+
const { values, positionals } = parseArgs({
|
|
70
|
+
args: argv,
|
|
71
|
+
allowPositionals: true,
|
|
72
|
+
options: {
|
|
73
|
+
root: { type: 'string' },
|
|
74
|
+
check: { type: 'boolean' },
|
|
75
|
+
watch: { type: 'boolean' },
|
|
76
|
+
strict: { type: 'boolean' },
|
|
77
|
+
verbose: { type: 'boolean' },
|
|
78
|
+
all: { type: 'boolean' },
|
|
79
|
+
write: { type: 'boolean' },
|
|
80
|
+
fix: { type: 'boolean' },
|
|
81
|
+
area: { type: 'string' },
|
|
82
|
+
format: { type: 'string' },
|
|
83
|
+
help: { type: 'boolean', short: 'h' },
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
const [command, ...rest] = positionals;
|
|
87
|
+
if (values.help || command === undefined) {
|
|
88
|
+
out(HELP);
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
if (values.format !== undefined && values.format !== 'text' && values.format !== 'sarif') {
|
|
92
|
+
err(`prosebook: --format is text or sarif, not "${values.format}"`);
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
const report = values.format === 'sarif' ? new SarifReport() : undefined;
|
|
96
|
+
if (report !== undefined) {
|
|
97
|
+
if (!SARIF_COMMANDS.includes(command)) {
|
|
98
|
+
err(`prosebook: --format sarif works with ${SARIF_COMMANDS.join(', ')}`);
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
out = err;
|
|
102
|
+
}
|
|
103
|
+
const root = values.root ?? process.cwd();
|
|
104
|
+
if (command === 'init') return init(root);
|
|
105
|
+
const config = await loadConfig(root);
|
|
106
|
+
let code: number;
|
|
107
|
+
try {
|
|
108
|
+
switch (command) {
|
|
109
|
+
case 'generate':
|
|
110
|
+
case 'gen':
|
|
111
|
+
if (values.watch) return watchMode(config);
|
|
112
|
+
return runGenerate(config, values.check === true);
|
|
113
|
+
case 'lint':
|
|
114
|
+
code = runLint(config, report, values.fix === true);
|
|
115
|
+
break;
|
|
116
|
+
case 'status':
|
|
117
|
+
code = runStatus(config, values.strict === true, values.verbose === true, report);
|
|
118
|
+
break;
|
|
119
|
+
case 'accept':
|
|
120
|
+
return runAccept(config, rest, values.all === true);
|
|
121
|
+
case 'todo':
|
|
122
|
+
return runTodo(config, rest[0]);
|
|
123
|
+
case 'scan':
|
|
124
|
+
code = runScan(config, values.strict === true, report);
|
|
125
|
+
break;
|
|
126
|
+
case 'extract':
|
|
127
|
+
return runExtract(config, rest, values.write === true, values.area);
|
|
128
|
+
case 'migrate':
|
|
129
|
+
return runMigrate(config, rest[0], values.write === true);
|
|
130
|
+
case 'check': {
|
|
131
|
+
const results = [runGenerate(config, true, report), runLint(config, report), runStatus(config, true, false, report)];
|
|
132
|
+
code = results.some((r) => r !== 0) ? 1 : 0;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
default:
|
|
136
|
+
err(`prosebook: no command "${command}"\n`);
|
|
137
|
+
out(HELP);
|
|
138
|
+
return 1;
|
|
139
|
+
}
|
|
140
|
+
} catch (e) {
|
|
141
|
+
if (!(e instanceof CatalogError)) throw e;
|
|
142
|
+
reportProblems(config, e.problems, report);
|
|
143
|
+
err(`\n${e.problems.filter((p) => p.severity === 'error').length} error(s) in the catalog.`);
|
|
144
|
+
code = 1;
|
|
145
|
+
}
|
|
146
|
+
if (report !== undefined) process.stdout.write(`${JSON.stringify(report.toJSON(config.root, tool()), null, 2)}\n`);
|
|
147
|
+
return code;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function tool() {
|
|
151
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string; homepage: string };
|
|
152
|
+
return { name: 'prosebook', version: pkg.version, informationUri: pkg.homepage };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function reportProblems(config: ResolvedConfig, problems: Problem[], report?: SarifReport) {
|
|
156
|
+
for (const p of problems) err(`${p.severity === 'error' ? 'error' : 'warn '} ${whereOf(p)}: ${p.message}`);
|
|
157
|
+
if (report === undefined) return;
|
|
158
|
+
const locate = entryLocator(config.root);
|
|
159
|
+
report.rule('catalog', RULES.catalog);
|
|
160
|
+
for (const p of problems) {
|
|
161
|
+
report.add({
|
|
162
|
+
rule: 'catalog',
|
|
163
|
+
level: p.severity === 'error' ? 'error' : 'warning',
|
|
164
|
+
message: p.message,
|
|
165
|
+
file: p.file,
|
|
166
|
+
...(p.file !== undefined && p.key !== undefined ? locate(p.file, p.key) : {}),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function rel(config: ResolvedConfig, path: string) {
|
|
172
|
+
return relative(config.root, path).replaceAll('\\', '/');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function runGenerate(config: ResolvedConfig, check: boolean, report?: SarifReport): number {
|
|
176
|
+
const { changed, catalog, stale } = generate(config, { check });
|
|
177
|
+
reportProblems(config, catalog.problems, report);
|
|
178
|
+
const staleCount = [...stale.values()].reduce((n, s) => n + s.size, 0);
|
|
179
|
+
const summary = `${catalog.messages.length} messages, ${catalog.locales.length + 1} locale(s)${staleCount > 0 ? `, ${staleCount} stale translation(s)` : ''}`;
|
|
180
|
+
if (check) {
|
|
181
|
+
if (changed.length === 0) {
|
|
182
|
+
out(`prosebook: generated files are up to date (${summary})`);
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
err(`prosebook: out of date, run \`prosebook generate\`:\n ${changed.map((p) => rel(config, p)).join('\n ')}`);
|
|
186
|
+
report?.rule('out-of-date', RULES['out-of-date']);
|
|
187
|
+
for (const p of changed)
|
|
188
|
+
report?.add({ rule: 'out-of-date', level: 'error', message: 'out of date: run `prosebook generate`', file: rel(config, p) });
|
|
189
|
+
return 1;
|
|
190
|
+
}
|
|
191
|
+
out(`prosebook: ${changed.length === 0 ? 'nothing changed' : `wrote ${changed.length} file(s)`} (${summary})`);
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function watchMode(config: ResolvedConfig): Promise<number> {
|
|
196
|
+
const regenerate = () => {
|
|
197
|
+
try {
|
|
198
|
+
runGenerate(config, false);
|
|
199
|
+
} catch (e) {
|
|
200
|
+
if (e instanceof CatalogError) reportProblems(config, e.problems);
|
|
201
|
+
else err(String(e));
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
regenerate();
|
|
205
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
206
|
+
for (const dir of [config.catalog, config.locales]) {
|
|
207
|
+
if (!existsSync(dir)) continue;
|
|
208
|
+
watch(dir, { recursive: true }, (_event, file) => {
|
|
209
|
+
if (file === null || !file.endsWith('.yaml')) return;
|
|
210
|
+
clearTimeout(timer);
|
|
211
|
+
timer = setTimeout(regenerate, 50);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
out('prosebook: watching the catalog for changes');
|
|
215
|
+
return new Promise(() => {});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function runLint(config: ResolvedConfig, report?: SarifReport, fix = false): number {
|
|
219
|
+
let { catalog } = render(config);
|
|
220
|
+
let findings = lint(catalog.messages, config);
|
|
221
|
+
if (fix) {
|
|
222
|
+
const { fixed, problems } = applyFixes(config, catalog, findings);
|
|
223
|
+
if (problems.length > 0) {
|
|
224
|
+
reportProblems(config, problems, report);
|
|
225
|
+
err('The fixes would break the catalog, so nothing was written.');
|
|
226
|
+
return 1;
|
|
227
|
+
}
|
|
228
|
+
if (fixed > 0) {
|
|
229
|
+
out(`prosebook: fixed ${fixed} finding(s); run prosebook generate to compile them`);
|
|
230
|
+
({ catalog } = render(config));
|
|
231
|
+
findings = lint(catalog.messages, config);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const locate = entryLocator(config.root);
|
|
235
|
+
const placed = findings.map((f) => ({ ...f, ...locate(f.file, f.key, f.at?.start, config.source) }));
|
|
236
|
+
for (const f of placed) {
|
|
237
|
+
const severity = f.severity === 'error' ? 'error' : 'warn ';
|
|
238
|
+
err(`${severity} ${f.file}:${f.line}:${f.column} ${f.key}: [${f.rule}] ${f.message}${f.fix === undefined ? '' : ' (fixable)'}`);
|
|
239
|
+
}
|
|
240
|
+
if (report !== undefined) {
|
|
241
|
+
for (const [name, { rule }] of Object.entries(config.lint.rules)) report.rule(name, rule.description);
|
|
242
|
+
report.rule('allow', RULES.allow);
|
|
243
|
+
for (const f of placed) {
|
|
244
|
+
report.add({
|
|
245
|
+
rule: f.rule,
|
|
246
|
+
level: f.severity === 'error' ? 'error' : 'warning',
|
|
247
|
+
message: `${f.key}: ${f.message}`,
|
|
248
|
+
file: f.file,
|
|
249
|
+
line: f.line,
|
|
250
|
+
column: f.column,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const errors = findings.filter((f) => f.severity === 'error').length;
|
|
255
|
+
const fixable = findings.filter((f) => f.fix !== undefined).length;
|
|
256
|
+
if (findings.length === 0) {
|
|
257
|
+
out(`prosebook: ${catalog.messages.length} messages pass the writing rules`);
|
|
258
|
+
} else {
|
|
259
|
+
err(
|
|
260
|
+
`\n${errors} error(s), ${findings.length - errors} warning(s). Fix the string, or add \`allow: { <rule>: <why> }\` where the rule is wrong about it.`,
|
|
261
|
+
);
|
|
262
|
+
if (fixable > 0) err(`${fixable} of them can be fixed with prosebook lint --fix.`);
|
|
263
|
+
}
|
|
264
|
+
return errors > 0 ? 1 : 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function missingKeys(catalog: Catalog, locale: string): string[] {
|
|
268
|
+
return catalog.messages.filter((m) => !m.translations.has(locale)).map((m) => m.key);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function runStatus(config: ResolvedConfig, strict: boolean, verbose: boolean, report?: SarifReport): number {
|
|
272
|
+
const { catalog, stale } = render(config);
|
|
273
|
+
if (report !== undefined) reportStatus(config, catalog, stale, strict ? 'error' : 'warning', report);
|
|
274
|
+
const total = catalog.messages.length;
|
|
275
|
+
if (catalog.locales.length === 0) {
|
|
276
|
+
out(`prosebook: ${total} messages in ${config.source}, no translations yet`);
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
279
|
+
let incomplete = false;
|
|
280
|
+
out(`${'locale'.padEnd(10)} ${'translated'.padStart(12)} ${'missing'.padStart(8)} ${'stale'.padStart(6)}`);
|
|
281
|
+
for (const locale of catalog.locales) {
|
|
282
|
+
const missing = missingKeys(catalog, locale);
|
|
283
|
+
const staleHere = stale.get(locale) ?? new Map<string, StaleTranslation>();
|
|
284
|
+
const staleKeys = [...staleHere.keys()];
|
|
285
|
+
const done = total - missing.length;
|
|
286
|
+
if (missing.length > 0 || staleKeys.length > 0) incomplete = true;
|
|
287
|
+
const pct = total === 0 ? 100 : Math.floor((done / total) * 100);
|
|
288
|
+
out(
|
|
289
|
+
`${locale.padEnd(10)} ${`${done}/${total} ${String(pct).padStart(3)}%`.padStart(12)} ${String(missing.length).padStart(8)} ${String(staleKeys.length).padStart(6)}`,
|
|
290
|
+
);
|
|
291
|
+
if (verbose) {
|
|
292
|
+
for (const key of missing) out(` missing ${key}`);
|
|
293
|
+
for (const [key, s] of staleHere) out(` stale ${key}: ${changeOf(s)}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (incomplete && !verbose)
|
|
297
|
+
out('\n`prosebook status --verbose` lists the keys; `prosebook todo <locale>` prints them ready to translate.');
|
|
298
|
+
return strict && incomplete ? 1 : 0;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function reportStatus(
|
|
302
|
+
config: ResolvedConfig,
|
|
303
|
+
catalog: Catalog,
|
|
304
|
+
stale: Map<string, Map<string, StaleTranslation>>,
|
|
305
|
+
level: 'error' | 'warning',
|
|
306
|
+
report: SarifReport,
|
|
307
|
+
) {
|
|
308
|
+
const locate = entryLocator(config.root);
|
|
309
|
+
report.rule('missing-translation', RULES['missing-translation']);
|
|
310
|
+
report.rule('stale-translation', RULES['stale-translation']);
|
|
311
|
+
for (const locale of catalog.locales) {
|
|
312
|
+
for (const m of catalog.messages) {
|
|
313
|
+
if (m.translations.has(locale)) continue;
|
|
314
|
+
const message = `${m.key} has no ${locale} translation: \`prosebook todo ${locale}\` prints it ready to translate`;
|
|
315
|
+
report.add({ rule: 'missing-translation', level, message, file: m.file, ...locate(m.file, m.key) });
|
|
316
|
+
}
|
|
317
|
+
for (const [key, s] of stale.get(locale) ?? []) {
|
|
318
|
+
const file = catalog.messages.find((m) => m.key === key)?.translations.get(locale)?.file;
|
|
319
|
+
if (file === undefined) continue;
|
|
320
|
+
const message = `the ${locale} translation of ${key} was made from an earlier source (${changeOf(s)}): update it, or \`prosebook accept ${locale} ${key}\` if it is still right`;
|
|
321
|
+
report.add({ rule: 'stale-translation', level, message, file, ...locate(file, key) });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function runAccept(config: ResolvedConfig, args: string[], all: boolean): number {
|
|
327
|
+
const [locale, ...keys] = args;
|
|
328
|
+
if (locale === undefined || (keys.length === 0 && !all)) {
|
|
329
|
+
err('Usage: prosebook accept <locale> <key>... | prosebook accept <locale> --all');
|
|
330
|
+
return 1;
|
|
331
|
+
}
|
|
332
|
+
const before = render(config).stale.get(locale) ?? new Map<string, StaleTranslation>();
|
|
333
|
+
const accepting = all ? new Set(before.keys()) : new Set(keys);
|
|
334
|
+
for (const key of accepting) if (!before.has(key)) err(`warn ${locale} ${key} is not stale`);
|
|
335
|
+
generate(config, { accept: new Map([[locale, all ? true : new Set(keys)]]) });
|
|
336
|
+
out(`prosebook: accepted ${[...accepting].filter((k) => before.has(k)).length} translation(s) in ${locale}`);
|
|
337
|
+
return 0;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** What changed in the source of a stale translation, as a word diff, or why that isn't known. */
|
|
341
|
+
function changeOf(stale: StaleTranslation): string {
|
|
342
|
+
if (stale.was === undefined) return "the earlier text isn't recorded (the lock predates prosebook 0.2)";
|
|
343
|
+
return formatDiff(wordDiff(stale.was, stale.now)).replaceAll('\n', ' ');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function runTodo(config: ResolvedConfig, locale: string | undefined): number {
|
|
347
|
+
if (locale === undefined) {
|
|
348
|
+
err('Usage: prosebook todo <locale>');
|
|
349
|
+
return 1;
|
|
350
|
+
}
|
|
351
|
+
const { catalog, stale } = render(config);
|
|
352
|
+
const staleKeys = stale.get(locale) ?? new Map<string, StaleTranslation>();
|
|
353
|
+
const byArea = new Map<string, string[]>();
|
|
354
|
+
let count = 0;
|
|
355
|
+
for (const m of catalog.messages) {
|
|
356
|
+
const current = m.translations.get(locale);
|
|
357
|
+
if (current !== undefined && !staleKeys.has(m.key)) continue;
|
|
358
|
+
count += 1;
|
|
359
|
+
const lines = byArea.get(m.area) ?? [];
|
|
360
|
+
lines.push('');
|
|
361
|
+
if (m.context) lines.push(`# ${m.context}`);
|
|
362
|
+
for (const [arg, said] of m.argDocs) lines.push(`# {${arg}}: ${said}`);
|
|
363
|
+
lines.push(`# ${config.source}: ${m.text.replaceAll('\n', ' ')}`);
|
|
364
|
+
const change = staleKeys.get(m.key);
|
|
365
|
+
if (change !== undefined) lines.push(`# stale, the source changed: ${changeOf(change)}`);
|
|
366
|
+
lines.push(stringify({ [m.key]: current?.text ?? '' }, { lineWidth: 0 }).trimEnd());
|
|
367
|
+
byArea.set(m.area, lines);
|
|
368
|
+
}
|
|
369
|
+
if (count === 0) {
|
|
370
|
+
out(`# ${locale} is complete`);
|
|
371
|
+
return 0;
|
|
372
|
+
}
|
|
373
|
+
out(
|
|
374
|
+
`# prosebook todo: ${count} entr${count === 1 ? 'y' : 'ies'} for ${locale}. Fill them in and paste each block into ${rel(config, config.locales)}/${locale}/<area>.yaml (or ${locale}.yaml).`,
|
|
375
|
+
);
|
|
376
|
+
for (const [area, lines] of byArea) {
|
|
377
|
+
out(`\n# ── ${area}.yaml ──`);
|
|
378
|
+
out(lines.join('\n'));
|
|
379
|
+
}
|
|
380
|
+
return 0;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function runScan(config: ResolvedConfig, strict: boolean, report?: SarifReport): number {
|
|
384
|
+
const { catalog } = render(config);
|
|
385
|
+
const result = scan(config.scan.dirs, config.scan.extensions, config.scan.attributes, config.root, [config.out]);
|
|
386
|
+
for (const h of result.hardcoded)
|
|
387
|
+
err(`${h.file}:${h.line}:${h.column} hardcoded ${h.where === 'text' ? 'text' : `${h.where}=`} "${h.text}"`);
|
|
388
|
+
const unused = catalog.messages.filter((m) => !isReferenced(m.key, result.refs));
|
|
389
|
+
for (const m of unused) err(`${m.file} ${m.key}: no code reads this key`);
|
|
390
|
+
if (report !== undefined) {
|
|
391
|
+
const level = strict ? 'error' : 'warning';
|
|
392
|
+
const locate = entryLocator(config.root);
|
|
393
|
+
report.rule('hardcoded-text', RULES['hardcoded-text']);
|
|
394
|
+
report.rule('unused-key', RULES['unused-key']);
|
|
395
|
+
for (const h of result.hardcoded) {
|
|
396
|
+
const what = `${h.where === 'text' ? '' : `${h.where}=`}"${h.text}"`;
|
|
397
|
+
const message = `${what} is hardcoded: move it into the catalog with \`prosebook extract\`, or mark the line \`prosebook-ignore\``;
|
|
398
|
+
report.add({ rule: 'hardcoded-text', level, message, file: h.file, line: h.line, column: h.column });
|
|
399
|
+
}
|
|
400
|
+
for (const m of unused)
|
|
401
|
+
report.add({ rule: 'unused-key', level, message: `no code reads ${m.key}: remove it`, file: m.file, ...locate(m.file, m.key) });
|
|
402
|
+
}
|
|
403
|
+
out(
|
|
404
|
+
`prosebook: scanned ${result.files} file(s): ${result.hardcoded.length} hardcoded string(s), ${unused.length} unused key(s)` +
|
|
405
|
+
(result.hardcoded.length > 0
|
|
406
|
+
? '. `prosebook extract` moves the text into the catalog; mark a deliberate line `prosebook-ignore`.'
|
|
407
|
+
: ''),
|
|
408
|
+
);
|
|
409
|
+
return strict && result.hardcoded.length + unused.length > 0 ? 1 : 0;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function runExtract(config: ResolvedConfig, paths: string[], write: boolean, area: string | undefined): Promise<number> {
|
|
413
|
+
const targets = paths.length > 0 ? paths.map((p) => resolve(config.root, p)) : config.scan.dirs;
|
|
414
|
+
const files = sourceFiles(targets, ['.tsx', '.jsx'], [config.out]).concat(targets.filter((t) => /\.[jt]sx$/.test(t) && existsSync(t)));
|
|
415
|
+
const plan = await planExtract(config, [...new Set(files)], { area });
|
|
416
|
+
const extractions = plan.files.flatMap((f) => f.extractions);
|
|
417
|
+
for (const x of extractions) {
|
|
418
|
+
out(`${x.file}:${x.line}:${x.column} ${x.where === 'text' ? '' : `${x.where}=`}"${x.message}"`);
|
|
419
|
+
out(` → ${x.key}${x.reused ? ' (already in the catalog)' : ''}: ${x.replacement.trim()}`);
|
|
420
|
+
}
|
|
421
|
+
for (const f of plan.files) for (const w of f.warnings) err(`warn ${w}`);
|
|
422
|
+
if (extractions.length === 0) {
|
|
423
|
+
out('prosebook: no hardcoded text to extract');
|
|
424
|
+
return 0;
|
|
425
|
+
}
|
|
426
|
+
const changedFiles = plan.files.filter((f) => f.edits.length > 0).length;
|
|
427
|
+
const areas = [...new Set(plan.entries.map((e) => e.area))].map((a) => `${a}.yaml`);
|
|
428
|
+
const summary = `${extractions.length} string(s) in ${changedFiles} file(s), ${plan.entries.length} new entr${plan.entries.length === 1 ? 'y' : 'ies'}${areas.length > 0 ? ` in ${areas.join(', ')}` : ''}`;
|
|
429
|
+
out(`
|
|
430
|
+
Placeholder types: ${plan.types === 'syntax' ? 'read from the code alone (no TypeScript 7 project found)' : `from ${plan.types}`}.`);
|
|
431
|
+
if (!write) {
|
|
432
|
+
out(`prosebook: would extract ${summary}. Run again with --write to apply.`);
|
|
433
|
+
return 0;
|
|
434
|
+
}
|
|
435
|
+
applyExtract(config, plan);
|
|
436
|
+
out(`
|
|
437
|
+
prosebook: extracted ${summary}.`);
|
|
438
|
+
out('The new entries are marked `TODO(prosebook extract)`: check each key, kind, context and argument, then run `prosebook lint`.');
|
|
439
|
+
return runGenerate(config, false);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function runMigrate(config: ResolvedConfig, target: string | undefined, write: boolean): number {
|
|
443
|
+
const to = target === '1' ? 1 : target === '2' ? 2 : undefined;
|
|
444
|
+
if (to === undefined) {
|
|
445
|
+
err('Usage: prosebook migrate <1|2> [--write]');
|
|
446
|
+
return 1;
|
|
447
|
+
}
|
|
448
|
+
if (config.messageFormat === to) {
|
|
449
|
+
out(`prosebook: the catalog is already in ${SYNTAX_NAME[to]}`);
|
|
450
|
+
return 0;
|
|
451
|
+
}
|
|
452
|
+
const plan = planMigrate(config, to);
|
|
453
|
+
const where = (x: { file: string; key: string; locale: string }) =>
|
|
454
|
+
`${x.file} ${x.key}${x.locale === config.source ? '' : ` (${x.locale})`}`;
|
|
455
|
+
for (const r of plan.rewrites) out(`${where(r)}\n ${r.after.replaceAll('\n', '\n ')}`);
|
|
456
|
+
for (const c of plan.changed) err(`warn ${where(c)}: renders differently for ${c.values}: "${c.before}" becomes "${c.after}"`);
|
|
457
|
+
for (const b of plan.blocked) err(`error ${where(b)}: ${b.reason}`);
|
|
458
|
+
if (plan.blocked.length > 0) {
|
|
459
|
+
err(`
|
|
460
|
+
prosebook: ${plan.blocked.length} message(s) have no ${SYNTAX_NAME[to]} equivalent. Change them, then migrate again; nothing was written.`);
|
|
461
|
+
return 1;
|
|
462
|
+
}
|
|
463
|
+
const files = new Set(plan.rewrites.map((r) => r.file)).size;
|
|
464
|
+
const summary = `${plan.rewrites.length} message(s) in ${files} file(s) in ${SYNTAX_NAME[to]}`;
|
|
465
|
+
if (!write) {
|
|
466
|
+
out(`
|
|
467
|
+
prosebook: would rewrite ${summary}. Run again with --write to apply.`);
|
|
468
|
+
return 0;
|
|
469
|
+
}
|
|
470
|
+
applyMigrate(config, plan);
|
|
471
|
+
out(`
|
|
472
|
+
prosebook: rewrote ${summary}.`);
|
|
473
|
+
out(`Now set \`messageFormat: ${to}\` in the config, then run \`prosebook generate\`.`);
|
|
474
|
+
return 0;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const SAMPLE_CONFIG = `import { defineConfig } from 'prosebook/config';
|
|
478
|
+
|
|
479
|
+
export default defineConfig({
|
|
480
|
+
catalog: 'copy/catalog',
|
|
481
|
+
locales: 'copy/locales',
|
|
482
|
+
out: 'src/copy',
|
|
483
|
+
source: 'en',
|
|
484
|
+
// Write messages in Unicode MessageFormat 2 rather than ICU MessageFormat:
|
|
485
|
+
// messageFormat: 2,
|
|
486
|
+
});
|
|
487
|
+
`;
|
|
488
|
+
|
|
489
|
+
const SAMPLE_CATALOG = `# yaml-language-server: $schema=./catalog.schema.json
|
|
490
|
+
app.title:
|
|
491
|
+
kind: name
|
|
492
|
+
surface: title
|
|
493
|
+
context: The app's name, in the window title and the header.
|
|
494
|
+
en: My app
|
|
495
|
+
|
|
496
|
+
app.greeting:
|
|
497
|
+
kind: state
|
|
498
|
+
surface: status
|
|
499
|
+
context: Greets the person signed in, at the top of the home page.
|
|
500
|
+
args:
|
|
501
|
+
name: the person's first name
|
|
502
|
+
en: Welcome back, {name}.
|
|
503
|
+
`;
|
|
504
|
+
|
|
505
|
+
function init(root: string): number {
|
|
506
|
+
const configured = CONFIG_FILES.find((f) => existsSync(join(root, f)));
|
|
507
|
+
if (configured === undefined) {
|
|
508
|
+
writeFileSync(join(root, 'prosebook.config.ts'), SAMPLE_CONFIG);
|
|
509
|
+
out('created prosebook.config.ts');
|
|
510
|
+
}
|
|
511
|
+
const catalogDir = join(root, 'copy', 'catalog');
|
|
512
|
+
if (!existsSync(catalogDir)) {
|
|
513
|
+
mkdirSync(catalogDir, { recursive: true });
|
|
514
|
+
mkdirSync(join(root, 'copy', 'locales'), { recursive: true });
|
|
515
|
+
writeFileSync(join(catalogDir, 'app.yaml'), SAMPLE_CATALOG);
|
|
516
|
+
out('created copy/catalog/app.yaml');
|
|
517
|
+
}
|
|
518
|
+
out('\nNext: `prosebook generate`, then `createCopy(catalog)` from the generated src/copy.');
|
|
519
|
+
return 0;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
main(process.argv.slice(2)).then(
|
|
523
|
+
(code) => process.exit(code),
|
|
524
|
+
(e: unknown) => {
|
|
525
|
+
err(e instanceof Error ? (e.stack ?? e.message) : String(e));
|
|
526
|
+
process.exit(1);
|
|
527
|
+
},
|
|
528
|
+
);
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `prosebook`: every string an app shows, in one typed catalog.
|
|
3
|
+
*
|
|
4
|
+
* The catalog is YAML, one entry per string, with the context a translator needs beside it.
|
|
5
|
+
* `prosebook generate` compiles it into typed modules, one per locale, whose messages are plain
|
|
6
|
+
* functions: this runtime looks them up, picks the locale and falls back, and parses nothing.
|
|
7
|
+
*/
|
|
8
|
+
export { createCopy, defineCatalog, type Catalog, type Copy, type CopyOptions } from './runtime/copy.ts';
|
|
9
|
+
export { chainOf, negotiate, preferredLocales } from './runtime/locale.ts';
|
|
10
|
+
export { createHelpers, flatten } from './runtime/helpers.ts';
|
|
11
|
+
export type {
|
|
12
|
+
AnyTagFn,
|
|
13
|
+
ArgsParam,
|
|
14
|
+
Chunks,
|
|
15
|
+
Compiled,
|
|
16
|
+
CompiledMessages,
|
|
17
|
+
DefaultTags,
|
|
18
|
+
Helpers,
|
|
19
|
+
DevHooks,
|
|
20
|
+
HotHook,
|
|
21
|
+
MessageShape,
|
|
22
|
+
MessageShapes,
|
|
23
|
+
ReadHook,
|
|
24
|
+
Register,
|
|
25
|
+
RegisteredLocale,
|
|
26
|
+
RegisteredMessages,
|
|
27
|
+
RichKey,
|
|
28
|
+
RichParam,
|
|
29
|
+
RichValues,
|
|
30
|
+
TagFn,
|
|
31
|
+
} from './runtime/types.ts';
|