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
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `prosebook/config`: where the catalog lives, what an entry may say about itself, and how strictly
|
|
3
|
+
* its words are checked. Written as `prosebook.config.ts` (or `.js`, `.mjs`, `.json`) at the project root.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
|
|
9
|
+
import { compileRule, CORE_RULES, type CheckRule, type CoreRule, type Plugin } from './lint.ts';
|
|
10
|
+
import { DEFAULT_KINDS, DEFAULT_SURFACES, STRICT_LISTS, type KindRule, type Severity } from './presets.ts';
|
|
11
|
+
|
|
12
|
+
export type { KindRule, Severity };
|
|
13
|
+
export {
|
|
14
|
+
definePlugin,
|
|
15
|
+
HOLE,
|
|
16
|
+
phrase,
|
|
17
|
+
sentences,
|
|
18
|
+
words,
|
|
19
|
+
type CapitalizationRule,
|
|
20
|
+
type CheckRule,
|
|
21
|
+
type ConditionalRule,
|
|
22
|
+
type ExistenceRule,
|
|
23
|
+
type OccurrenceRule,
|
|
24
|
+
type Plugin,
|
|
25
|
+
type Readings,
|
|
26
|
+
type Report,
|
|
27
|
+
type Rule,
|
|
28
|
+
type RuleContext,
|
|
29
|
+
type SubstitutionRule,
|
|
30
|
+
} from './lint.ts';
|
|
31
|
+
|
|
32
|
+
export interface WordLists {
|
|
33
|
+
/** Words a string never uses, in groups that say why. */
|
|
34
|
+
banned?: { words: string[]; why: string }[];
|
|
35
|
+
/** A spelling, and the one the product uses instead. */
|
|
36
|
+
spelling?: Record<string, string>;
|
|
37
|
+
/** A near-synonym, and the one word the product uses for that concept. */
|
|
38
|
+
concepts?: Record<string, string>;
|
|
39
|
+
/** Words that keep their capital inside a sentence-case string: product names, platforms. */
|
|
40
|
+
properNouns?: string[];
|
|
41
|
+
/** What a path through menus or settings is written with, spaced: `›` by default, as in Settings › General. */
|
|
42
|
+
pathSeparator?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type RuleSeverities = Partial<Record<CoreRule, Severity>> & Record<string, Severity>;
|
|
46
|
+
|
|
47
|
+
export interface LintConfig extends WordLists {
|
|
48
|
+
/** `recommended` (the default) checks shape; `strict` adds the word lists and rules of a house style guide. */
|
|
49
|
+
extends?: 'recommended' | 'strict' | 'none';
|
|
50
|
+
/** Rules of your own, as `<plugin>/<rule>`. Each takes the severity it gives itself under `extends`. */
|
|
51
|
+
plugins?: Plugin[];
|
|
52
|
+
rules?: RuleSeverities;
|
|
53
|
+
/** Severities for one area's entries (a key's first segment) over `rules`: `{ native: { 'sentence-case': 'off' } }`. */
|
|
54
|
+
areas?: Record<string, RuleSeverities>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ProsebookConfig {
|
|
58
|
+
/** The source-language catalog: one `<area>.yaml` per area. */
|
|
59
|
+
catalog?: string;
|
|
60
|
+
/** Translations: `<locale>/<area>.yaml` or `<locale>.yaml`, holding `key: message`. */
|
|
61
|
+
locales?: string;
|
|
62
|
+
/** Where the generated modules go. */
|
|
63
|
+
out?: string;
|
|
64
|
+
/** Records which source text each translation was made from, so a changed source marks it stale. */
|
|
65
|
+
lock?: string;
|
|
66
|
+
/** The locale the catalog is written in. Its code is also the field holding the message. */
|
|
67
|
+
source?: string;
|
|
68
|
+
/**
|
|
69
|
+
* The syntax messages are written in: ICU MessageFormat (1) or Unicode MessageFormat 2. Source and
|
|
70
|
+
* translations alike; `prosebook migrate` rewrites a catalog from one to the other.
|
|
71
|
+
*/
|
|
72
|
+
messageFormat?: 1 | 2;
|
|
73
|
+
/** Locales that ship in the main bundle beside the source, rather than loading when first used. */
|
|
74
|
+
bundled?: string[];
|
|
75
|
+
/** A pseudo-locale generated from the source (accented and lengthened) to find layout bugs and hardcoded text. */
|
|
76
|
+
pseudo?: string | false;
|
|
77
|
+
/** What a translation whose source changed since does: still show (`use`) or show the source (`fallback`). */
|
|
78
|
+
stale?: 'use' | 'fallback';
|
|
79
|
+
/** Generated modules as TypeScript or as JavaScript with a `.d.ts` beside the index. */
|
|
80
|
+
language?: 'ts' | 'js';
|
|
81
|
+
/** The extension generated imports name: `.js` suits most TypeScript setups, `.ts` suits Node's type stripping. */
|
|
82
|
+
importExtension?: '.js' | '.ts' | '';
|
|
83
|
+
/** The fields every entry must have. A shorthand `key: message` entry satisfies none of them. */
|
|
84
|
+
require?: ('context' | 'kind' | 'surface')[];
|
|
85
|
+
/** The kinds of string an entry may say it is, with the shape each one is held to. */
|
|
86
|
+
kinds?: Record<string, KindRule>;
|
|
87
|
+
/** Where a string may sit, with what each surface means. */
|
|
88
|
+
surfaces?: Record<string, string>;
|
|
89
|
+
lint?: LintConfig;
|
|
90
|
+
/** Where `prosebook scan` looks for hardcoded text and catalog keys. */
|
|
91
|
+
scan?: {
|
|
92
|
+
dirs?: string[];
|
|
93
|
+
extensions?: string[];
|
|
94
|
+
/** JSX attributes whose literal value a person reads. */
|
|
95
|
+
attributes?: string[];
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface ResolvedConfig {
|
|
100
|
+
root: string;
|
|
101
|
+
catalog: string;
|
|
102
|
+
locales: string;
|
|
103
|
+
out: string;
|
|
104
|
+
lock: string;
|
|
105
|
+
source: string;
|
|
106
|
+
messageFormat: 1 | 2;
|
|
107
|
+
bundled: string[];
|
|
108
|
+
pseudo: string | false;
|
|
109
|
+
stale: 'use' | 'fallback';
|
|
110
|
+
language: 'ts' | 'js';
|
|
111
|
+
importExtension: '.js' | '.ts' | '';
|
|
112
|
+
require: ('context' | 'kind' | 'surface')[];
|
|
113
|
+
kinds: Record<string, KindRule>;
|
|
114
|
+
surfaces: Record<string, string>;
|
|
115
|
+
lint: {
|
|
116
|
+
rules: Record<string, { rule: CheckRule; severity: Severity }>;
|
|
117
|
+
areas: Record<string, Record<string, Severity>>;
|
|
118
|
+
} & Required<WordLists>;
|
|
119
|
+
scan: { dirs: string[]; extensions: string[]; attributes: string[] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Typed config, for `prosebook.config.ts`. */
|
|
123
|
+
export function defineConfig(config: ProsebookConfig): ProsebookConfig {
|
|
124
|
+
return config;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const CONFIG_FILES = [
|
|
128
|
+
'prosebook.config.ts',
|
|
129
|
+
'prosebook.config.mts',
|
|
130
|
+
'prosebook.config.js',
|
|
131
|
+
'prosebook.config.mjs',
|
|
132
|
+
'prosebook.config.json',
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
/** Finds and reads the config at `root`, or the defaults when there is none. */
|
|
136
|
+
export async function loadConfig(root: string, inline?: ProsebookConfig): Promise<ResolvedConfig> {
|
|
137
|
+
if (inline) return resolveConfig(root, inline);
|
|
138
|
+
for (const name of CONFIG_FILES) {
|
|
139
|
+
const path = join(root, name);
|
|
140
|
+
if (!existsSync(path)) continue;
|
|
141
|
+
if (name.endsWith('.json')) return resolveConfig(root, JSON.parse(readFileSync(path, 'utf8')) as ProsebookConfig);
|
|
142
|
+
const mod = (await import(pathToFileURL(path).href)) as { default?: ProsebookConfig };
|
|
143
|
+
return resolveConfig(root, mod.default ?? {});
|
|
144
|
+
}
|
|
145
|
+
return resolveConfig(root, {});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function resolveConfig(root: string, config: ProsebookConfig): ResolvedConfig {
|
|
149
|
+
const at = (path: string) => (isAbsolute(path) ? path : resolve(root, path));
|
|
150
|
+
const lint = config.lint ?? {};
|
|
151
|
+
const base = lint.extends ?? 'recommended';
|
|
152
|
+
const strict = base === 'strict';
|
|
153
|
+
const rules = lintRules(lint);
|
|
154
|
+
for (const [area, severities] of Object.entries(lint.areas ?? {})) {
|
|
155
|
+
for (const name of Object.keys(severities))
|
|
156
|
+
if (!(name in rules)) throw new Error(`lint.areas.${area}.${name} is not a rule: name one of ${Object.keys(rules).join(', ')}`);
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
root,
|
|
160
|
+
catalog: at(config.catalog ?? 'copy/catalog'),
|
|
161
|
+
locales: at(config.locales ?? 'copy/locales'),
|
|
162
|
+
out: at(config.out ?? 'src/copy'),
|
|
163
|
+
lock: at(config.lock ?? 'copy/lock.json'),
|
|
164
|
+
source: config.source ?? 'en',
|
|
165
|
+
messageFormat: config.messageFormat ?? 1,
|
|
166
|
+
bundled: config.bundled ?? [],
|
|
167
|
+
pseudo: config.pseudo ?? false,
|
|
168
|
+
stale: config.stale ?? 'use',
|
|
169
|
+
language: config.language ?? 'ts',
|
|
170
|
+
importExtension: config.importExtension ?? '.js',
|
|
171
|
+
require: config.require ?? ['context'],
|
|
172
|
+
kinds: config.kinds ?? DEFAULT_KINDS,
|
|
173
|
+
surfaces: config.surfaces ?? DEFAULT_SURFACES,
|
|
174
|
+
lint: {
|
|
175
|
+
rules,
|
|
176
|
+
areas: lint.areas ?? {},
|
|
177
|
+
banned: [...(strict ? STRICT_LISTS.banned : []), ...(lint.banned ?? [])],
|
|
178
|
+
spelling: { ...(strict ? STRICT_LISTS.spelling : {}), ...lint.spelling },
|
|
179
|
+
concepts: { ...lint.concepts },
|
|
180
|
+
properNouns: lint.properNouns ?? [],
|
|
181
|
+
pathSeparator: lint.pathSeparator ?? '›',
|
|
182
|
+
},
|
|
183
|
+
scan: {
|
|
184
|
+
dirs: (config.scan?.dirs ?? ['src']).map(at),
|
|
185
|
+
extensions: config.scan?.extensions ?? ['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs'],
|
|
186
|
+
attributes: config.scan?.attributes ?? ['alt', 'title', 'placeholder', 'label', 'aria-label', 'aria-description', 'aria-placeholder'],
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function lintRules(lint: LintConfig): ResolvedConfig['lint']['rules'] {
|
|
192
|
+
const base = lint.extends ?? 'recommended';
|
|
193
|
+
const all: [string, CheckRule][] = Object.entries(CORE_RULES);
|
|
194
|
+
const seen = new Set<string>();
|
|
195
|
+
for (const plugin of lint.plugins ?? []) {
|
|
196
|
+
if (!/^[a-z][a-z0-9-]*$/i.test(plugin.name))
|
|
197
|
+
throw new Error(`lint.plugins: "${plugin.name}" is not a plugin name: use letters, digits and dashes`);
|
|
198
|
+
if (seen.has(plugin.name)) throw new Error(`lint.plugins: "${plugin.name}" is added twice: remove one`);
|
|
199
|
+
seen.add(plugin.name);
|
|
200
|
+
for (const [name, rule] of Object.entries(plugin.rules)) {
|
|
201
|
+
const full = `${plugin.name}/${name}`;
|
|
202
|
+
all.push([full, compileRule(full, rule)]);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const preset = (rule: CheckRule): Severity =>
|
|
206
|
+
base === 'none' ? 'off' : base === 'strict' ? (rule.strict ?? 'error') : (rule.recommended ?? 'off');
|
|
207
|
+
const rules = Object.fromEntries(all.map(([name, rule]) => [name, { rule, severity: lint.rules?.[name] ?? preset(rule) }]));
|
|
208
|
+
for (const name of Object.keys(lint.rules ?? {}))
|
|
209
|
+
if (!(name in rules)) throw new Error(`lint.rules.${name} is not a rule: name one of ${Object.keys(rules).join(', ')}`);
|
|
210
|
+
return rules;
|
|
211
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A word diff, for showing a translator what changed in the source since they translated it. Words,
|
|
3
|
+
* runs of whitespace and single punctuation marks are the units, so an ICU brace or a period changes
|
|
4
|
+
* on its own rather than taking its word with it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface DiffPart {
|
|
8
|
+
op: 'same' | 'add' | 'del';
|
|
9
|
+
text: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const TOKEN = /\s+|[\p{L}\p{N}_'’]+|[^\s\p{L}\p{N}_]/gu;
|
|
13
|
+
|
|
14
|
+
/** Past this many cells the table costs more than the diff is worth: show the whole change instead. */
|
|
15
|
+
const MAX_CELLS = 250_000;
|
|
16
|
+
|
|
17
|
+
export function wordDiff(before: string, after: string): DiffPart[] {
|
|
18
|
+
const a = before.match(TOKEN) ?? [];
|
|
19
|
+
const b = after.match(TOKEN) ?? [];
|
|
20
|
+
if (a.length * b.length > MAX_CELLS) {
|
|
21
|
+
const whole: DiffPart[] = [
|
|
22
|
+
{ op: 'del', text: before },
|
|
23
|
+
{ op: 'add', text: after },
|
|
24
|
+
];
|
|
25
|
+
return merge(whole);
|
|
26
|
+
}
|
|
27
|
+
// The longest common subsequence, by suffix, so the walk below reads front to back.
|
|
28
|
+
const lcs: number[][] = Array.from({ length: a.length + 1 }, () => Array.from({ length: b.length + 1 }, () => 0));
|
|
29
|
+
for (let i = a.length - 1; i >= 0; i--) {
|
|
30
|
+
for (let j = b.length - 1; j >= 0; j--)
|
|
31
|
+
lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const parts: DiffPart[] = [];
|
|
35
|
+
let i = 0;
|
|
36
|
+
let j = 0;
|
|
37
|
+
while (i < a.length && j < b.length) {
|
|
38
|
+
if (a[i] === b[j]) {
|
|
39
|
+
parts.push({ op: 'same', text: a[i]! });
|
|
40
|
+
i++;
|
|
41
|
+
j++;
|
|
42
|
+
} else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) {
|
|
43
|
+
parts.push({ op: 'del', text: a[i++]! });
|
|
44
|
+
} else {
|
|
45
|
+
parts.push({ op: 'add', text: b[j++]! });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
while (i < a.length) parts.push({ op: 'del', text: a[i++]! });
|
|
49
|
+
while (j < b.length) parts.push({ op: 'add', text: b[j++]! });
|
|
50
|
+
return merge(parts);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Adjacent parts of one kind joined, and a space shared by two changes folded into them, so a
|
|
55
|
+
* replaced phrase reads `[-old words-]{+new words+}` rather than alternating word by word.
|
|
56
|
+
*/
|
|
57
|
+
function merge(parts: DiffPart[]): DiffPart[] {
|
|
58
|
+
const out: DiffPart[] = [];
|
|
59
|
+
for (let k = 0; k < parts.length; k++) {
|
|
60
|
+
const part = parts[k]!;
|
|
61
|
+
const prev = out.at(-1);
|
|
62
|
+
const next = parts[k + 1];
|
|
63
|
+
if (part.op === 'same' && /^\s+$/.test(part.text) && prev && prev.op !== 'same' && next && next.op !== 'same') {
|
|
64
|
+
// Whitespace between two changes belongs to both.
|
|
65
|
+
out.push({ op: 'del', text: part.text }, { op: 'add', text: part.text });
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
out.push({ ...part });
|
|
69
|
+
}
|
|
70
|
+
const joined: DiffPart[] = [];
|
|
71
|
+
for (const part of out.filter((p) => p.text !== '')) {
|
|
72
|
+
// Gather each stretch of changes as all its deletions, then all its additions.
|
|
73
|
+
const last = joined.at(-1);
|
|
74
|
+
if (last && last.op === part.op) last.text += part.text;
|
|
75
|
+
else if (part.op === 'del' && last?.op === 'add' && joined.at(-2)?.op === 'del') joined.at(-2)!.text += part.text;
|
|
76
|
+
else joined.push({ ...part });
|
|
77
|
+
}
|
|
78
|
+
return joined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The diff as one line: `Save [-the-]{+a+} draft`, the notation of `git diff --word-diff`. */
|
|
82
|
+
export function formatDiff(parts: DiffPart[]): string {
|
|
83
|
+
return parts.map((p) => (p.op === 'same' ? p.text : p.op === 'del' ? `[-${p.text}-]` : `{+${p.text}+}`)).join('');
|
|
84
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One entry as the dev inspector's dialog edits it, in every locale: as fields, or as the YAML written
|
|
3
|
+
* in its files.
|
|
4
|
+
*
|
|
5
|
+
* An edit is checked as the whole catalog, with the edited files read from memory, before anything is
|
|
6
|
+
* written: a save that would break the build comes back as problems instead, and the dev server never
|
|
7
|
+
* sees it. Fields are written into the parsed document, so comments, order and quoting elsewhere in the
|
|
8
|
+
* file stay as they were; YAML replaces the entry's text as typed. A translation left empty is no
|
|
9
|
+
* translation: one that was there is removed, and the locale falls back.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { isMap, isScalar, parseDocument, Scalar, type Document, type YAMLMap } from 'yaml';
|
|
15
|
+
|
|
16
|
+
import { CatalogError, loadCatalog, type Catalog, type Message, type Problem } from './catalog.ts';
|
|
17
|
+
import type { ResolvedConfig } from './config.ts';
|
|
18
|
+
import { applyEdits, lint, type Edit, type Finding } from './lint.ts';
|
|
19
|
+
|
|
20
|
+
export interface EntryFields {
|
|
21
|
+
/** The source message. */
|
|
22
|
+
message: string;
|
|
23
|
+
/** By locale. Empty or missing is no translation, so the locale falls back. */
|
|
24
|
+
translations: Record<string, string>;
|
|
25
|
+
context: string;
|
|
26
|
+
kind: string;
|
|
27
|
+
surface: string;
|
|
28
|
+
fragment: boolean;
|
|
29
|
+
/** What each placeholder holds, by name. */
|
|
30
|
+
args: Record<string, string>;
|
|
31
|
+
/** A change asked of whoever edits next; empty is none. */
|
|
32
|
+
comment: string;
|
|
33
|
+
/** Asks for the entry to go, with the code that reads it. */
|
|
34
|
+
remove: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface EntryYaml {
|
|
38
|
+
/** The entry in the catalog, from its key to the end of its value. */
|
|
39
|
+
catalog: string;
|
|
40
|
+
/** By locale, the entry in that locale's file. Empty is no translation. */
|
|
41
|
+
translations?: Record<string, string>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface EntryView {
|
|
45
|
+
key: string;
|
|
46
|
+
/** The source locale. */
|
|
47
|
+
source: string;
|
|
48
|
+
/** Every translated locale, the source aside. */
|
|
49
|
+
locales: string[];
|
|
50
|
+
/** Relative to the project root. A locale without the entry names the file it would go in. */
|
|
51
|
+
files: { catalog: string; translations: Record<string, string> };
|
|
52
|
+
/** With every locale in `translations`, empty where it has none. */
|
|
53
|
+
fields: EntryFields;
|
|
54
|
+
yaml: Required<EntryYaml>;
|
|
55
|
+
/** The source message's placeholders. */
|
|
56
|
+
argNames: string[];
|
|
57
|
+
kinds: string[];
|
|
58
|
+
surfaces: string[];
|
|
59
|
+
/** What lint says of the entry as it stands, in every locale. */
|
|
60
|
+
findings: EntryFinding[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface EntryFinding extends Finding {
|
|
64
|
+
/** The locale's text with this finding fixed, so the page can make the fix without lint's code. */
|
|
65
|
+
fixed?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type EntryEdit = { fields: EntryFields } | { yaml: EntryYaml };
|
|
69
|
+
|
|
70
|
+
/** The entry for `key`, with its translation in every locale. */
|
|
71
|
+
export function readEntry(config: ResolvedConfig, key: string): EntryView {
|
|
72
|
+
return viewOf(config, loadCatalog(config), key, new Map());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Applies an edit and checks the catalog with it. With `write`, a clean edit is written; without, it
|
|
77
|
+
* only comes back as the view, which is how the dialog turns fields into YAML and back.
|
|
78
|
+
*/
|
|
79
|
+
export function editEntry(config: ResolvedConfig, key: string, edit: EntryEdit, write: boolean): { view?: EntryView; problems: Problem[] } {
|
|
80
|
+
const current = viewOf(config, loadCatalog(config), key, new Map());
|
|
81
|
+
const unsaved = new Map<string, string>();
|
|
82
|
+
const text = (file: string) => contentOf(config, file, unsaved);
|
|
83
|
+
const put = (file: string, content: string) => unsaved.set(resolve(config.root, file), content);
|
|
84
|
+
// Locales the edit gives a translation, which the checked catalog must then have.
|
|
85
|
+
const translated: string[] = [];
|
|
86
|
+
|
|
87
|
+
if ('yaml' in edit) {
|
|
88
|
+
put(current.files.catalog, withSnippet(text(current.files.catalog), key, edit.yaml.catalog));
|
|
89
|
+
for (const locale of current.locales) {
|
|
90
|
+
const yaml = edit.yaml.translations?.[locale]?.trim() ?? current.yaml.translations[locale]!;
|
|
91
|
+
if (yaml !== '') translated.push(locale);
|
|
92
|
+
if (yaml === current.yaml.translations[locale]) continue;
|
|
93
|
+
put(current.files.translations[locale]!, withSnippet(text(current.files.translations[locale]!), key, yaml));
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
put(current.files.catalog, withFields(text(current.files.catalog), key, edit.fields, config.source));
|
|
97
|
+
for (const locale of current.locales) {
|
|
98
|
+
const next = edit.fields.translations[locale] ?? '';
|
|
99
|
+
if (next.trim() !== '') translated.push(locale);
|
|
100
|
+
if (next === current.fields.translations[locale]) continue;
|
|
101
|
+
put(current.files.translations[locale]!, withTranslation(text(current.files.translations[locale]!), key, next));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let catalog: Catalog;
|
|
106
|
+
try {
|
|
107
|
+
catalog = loadCatalog(config, unsaved);
|
|
108
|
+
} catch (e) {
|
|
109
|
+
if (!(e instanceof CatalogError)) throw e;
|
|
110
|
+
return { problems: e.problems.filter((p) => p.severity === 'error') };
|
|
111
|
+
}
|
|
112
|
+
const message = catalog.messages.find((m) => m.key === key);
|
|
113
|
+
const renamed = [
|
|
114
|
+
...(message === undefined ? [current.files.catalog] : []),
|
|
115
|
+
...translated.filter((l) => message?.translations.has(l) !== true).map((l) => current.files.translations[l]!),
|
|
116
|
+
];
|
|
117
|
+
if (renamed.length > 0) {
|
|
118
|
+
const problem = `the entry is still ${key}: rename a key in the code and the catalog together`;
|
|
119
|
+
return { problems: renamed.map((file) => ({ severity: 'error', file, key, message: problem })) };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (write) {
|
|
123
|
+
for (const [path, content] of unsaved) {
|
|
124
|
+
if (existsSync(path) && readFileSync(path, 'utf8') === content) continue;
|
|
125
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
126
|
+
writeFileSync(path, content);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { view: viewOf(config, catalog, key, unsaved), problems: [] };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Makes the edits that lint's findings carry, each message in its own file, and writes them only if
|
|
134
|
+
* the catalog they make loads clean: the problems come back instead, and nothing is written.
|
|
135
|
+
*/
|
|
136
|
+
export function applyFixes(config: ResolvedConfig, catalog: Catalog, findings: Finding[]): { fixed: number; problems: Problem[] } {
|
|
137
|
+
const unsaved = new Map<string, string>();
|
|
138
|
+
const entries = new Map<string, { file: string; key: string; locale: string; edits: Edit[]; count: number }>();
|
|
139
|
+
for (const f of findings) {
|
|
140
|
+
if (f.fix === undefined) continue;
|
|
141
|
+
const id = `${f.locale}\u0000${f.key}`;
|
|
142
|
+
const entry = entries.get(id) ?? { file: f.file, key: f.key, locale: f.locale, edits: [], count: 0 };
|
|
143
|
+
entry.edits.push(...f.fix);
|
|
144
|
+
entry.count += 1;
|
|
145
|
+
entries.set(id, entry);
|
|
146
|
+
}
|
|
147
|
+
let fixed = 0;
|
|
148
|
+
for (const { file, key, locale, edits, count } of entries.values()) {
|
|
149
|
+
const message = catalog.messages.find((m) => m.key === key);
|
|
150
|
+
const written = locale === config.source ? message?.text : message?.translations.get(locale)?.text;
|
|
151
|
+
if (written === undefined) continue;
|
|
152
|
+
const next = applyEdits(written, edits);
|
|
153
|
+
if (next === written) continue;
|
|
154
|
+
const content = contentOf(config, file, unsaved);
|
|
155
|
+
unsaved.set(
|
|
156
|
+
resolve(config.root, file),
|
|
157
|
+
locale === config.source ? withMessage(content, key, next, config.source) : withTranslation(content, key, next),
|
|
158
|
+
);
|
|
159
|
+
fixed += count;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
loadCatalog(config, unsaved);
|
|
163
|
+
} catch (e) {
|
|
164
|
+
if (!(e instanceof CatalogError)) throw e;
|
|
165
|
+
return { fixed: 0, problems: e.problems.filter((p) => p.severity === 'error') };
|
|
166
|
+
}
|
|
167
|
+
for (const [path, content] of unsaved) writeFileSync(path, content);
|
|
168
|
+
return { fixed, problems: [] };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A file as the edit has it, as it is on disk, or empty when it is still to be made. */
|
|
172
|
+
function contentOf(config: ResolvedConfig, file: string, unsaved: Map<string, string>): string {
|
|
173
|
+
const path = resolve(config.root, file);
|
|
174
|
+
return unsaved.get(path) ?? (existsSync(path) ? readFileSync(path, 'utf8') : '');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The file a translation goes in: beside the locale's others, in its folder or its one file. */
|
|
178
|
+
function translationFile(config: ResolvedConfig, message: Message, locale: string): string {
|
|
179
|
+
const existing = message.translations.get(locale)?.file;
|
|
180
|
+
if (existing !== undefined) return existing;
|
|
181
|
+
const folder = join(config.locales, locale);
|
|
182
|
+
const path = existsSync(folder) && statSync(folder).isDirectory() ? join(folder, `${message.area}.yaml`) : `${folder}.yaml`;
|
|
183
|
+
return relative(config.root, path).replaceAll('\\', '/');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function viewOf(config: ResolvedConfig, catalog: Catalog, key: string, unsaved: Map<string, string>): EntryView {
|
|
187
|
+
const message = catalog.messages.find((m) => m.key === key);
|
|
188
|
+
if (message === undefined) throw new Error(`no entry for "${key}" in the catalog: has it been renamed since the page loaded?`);
|
|
189
|
+
const files = Object.fromEntries(catalog.locales.map((l) => [l, translationFile(config, message, l)]));
|
|
190
|
+
return {
|
|
191
|
+
key,
|
|
192
|
+
source: config.source,
|
|
193
|
+
locales: catalog.locales,
|
|
194
|
+
files: { catalog: message.file, translations: files },
|
|
195
|
+
fields: {
|
|
196
|
+
message: message.text,
|
|
197
|
+
translations: Object.fromEntries(catalog.locales.map((l) => [l, message.translations.get(l)?.text ?? ''])),
|
|
198
|
+
context: message.context ?? '',
|
|
199
|
+
kind: message.kind ?? '',
|
|
200
|
+
surface: message.surface ?? '',
|
|
201
|
+
fragment: message.fragment,
|
|
202
|
+
args: Object.fromEntries(message.argDocs),
|
|
203
|
+
comment: message.comment ?? '',
|
|
204
|
+
remove: message.remove,
|
|
205
|
+
},
|
|
206
|
+
yaml: {
|
|
207
|
+
catalog: snippetOf(contentOf(config, message.file, unsaved), key)?.text ?? '',
|
|
208
|
+
translations: Object.fromEntries(catalog.locales.map((l) => [l, snippetOf(contentOf(config, files[l]!, unsaved), key)?.text ?? ''])),
|
|
209
|
+
},
|
|
210
|
+
argNames: [...message.args.keys()],
|
|
211
|
+
kinds: Object.keys(config.kinds),
|
|
212
|
+
surfaces: Object.keys(config.surfaces),
|
|
213
|
+
findings: lint([message], config).map((f) => {
|
|
214
|
+
const written = f.locale === config.source ? message.text : message.translations.get(f.locale)?.text;
|
|
215
|
+
return f.fix === undefined || written === undefined ? f : { ...f, fixed: applyEdits(written, f.fix) };
|
|
216
|
+
}),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Where an entry is written: from the start of its key's line to the end of its value. */
|
|
221
|
+
function snippetOf(file: string, key: string): { start: number; end: number; text: string } | undefined {
|
|
222
|
+
const doc = parseDocument(file);
|
|
223
|
+
if (!isMap(doc.contents)) return undefined;
|
|
224
|
+
const pair = doc.contents.items.find((p) => isScalar(p.key) && p.key.value === key);
|
|
225
|
+
const keyStart = isScalar(pair?.key) ? pair.key.range?.[0] : undefined;
|
|
226
|
+
const valueEnd = (pair?.value as { range?: [number, number, number] } | null | undefined)?.range?.[1];
|
|
227
|
+
if (keyStart === undefined || valueEnd === undefined) return undefined;
|
|
228
|
+
const start = file.lastIndexOf('\n', keyStart - 1) + 1;
|
|
229
|
+
// The last value in a file runs to its final newline; the snippet stops at the text.
|
|
230
|
+
const end = start + file.slice(start, valueEnd).trimEnd().length;
|
|
231
|
+
return { start, end, text: file.slice(start, end) };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The file with the entry's text replaced, removed when `yaml` is empty, or added at the end. */
|
|
235
|
+
function withSnippet(file: string, key: string, yaml: string): string {
|
|
236
|
+
const text = yaml.replace(/\n+$/, '');
|
|
237
|
+
const at = snippetOf(file, key);
|
|
238
|
+
if (at !== undefined) {
|
|
239
|
+
if (text.trim() === '') return file.slice(0, at.start) + file.slice(at.end).replace(/^\n/, '');
|
|
240
|
+
return file.slice(0, at.start) + text + file.slice(at.end);
|
|
241
|
+
}
|
|
242
|
+
if (text.trim() === '') return file;
|
|
243
|
+
return file.trim() === '' ? `${text}\n` : `${file.replace(/\n*$/, '\n')}${text}\n`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// As YAML 1.1, so a new value that 1.1 would read differently, like `n`, is quoted (as in migrate.ts).
|
|
247
|
+
// An empty file starts as a plain mapping: under 1.1 a key set on nothing makes an `!!omap`.
|
|
248
|
+
function parse(file: string): Document {
|
|
249
|
+
const doc: Document = parseDocument(file, { version: '1.1' });
|
|
250
|
+
if (doc.contents === null) doc.contents = doc.createNode({});
|
|
251
|
+
return doc;
|
|
252
|
+
}
|
|
253
|
+
// New values quoted the way catalogs are; values already there keep how they were quoted.
|
|
254
|
+
const print = (doc: Document) => doc.toString({ lineWidth: 0, singleQuote: true });
|
|
255
|
+
|
|
256
|
+
/** A value written where one was, keeping how it was quoted; text over several lines as a block. */
|
|
257
|
+
function setValue(doc: Document, map: YAMLMap, name: string, value: unknown): void {
|
|
258
|
+
const node = map.get(name, true);
|
|
259
|
+
if (isScalar(node) && typeof value === 'string') {
|
|
260
|
+
node.value = value;
|
|
261
|
+
if (value.includes('\n')) node.type = Scalar.BLOCK_LITERAL;
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
map.set(name, doc.createNode(value));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function withFields(file: string, key: string, fields: EntryFields, source: string): string {
|
|
268
|
+
const doc = parse(file);
|
|
269
|
+
let entry = doc.get(key, true);
|
|
270
|
+
// `key: message` gains a mapping once it has anything to say about itself.
|
|
271
|
+
if (!isMap(entry)) {
|
|
272
|
+
entry = doc.createNode({ [source]: fields.message });
|
|
273
|
+
doc.set(key, entry);
|
|
274
|
+
}
|
|
275
|
+
const map = entry as YAMLMap;
|
|
276
|
+
const args = Object.fromEntries(Object.entries(fields.args).filter(([, v]) => v.trim() !== ''));
|
|
277
|
+
const wanted: [string, unknown][] = [
|
|
278
|
+
['kind', fields.kind.trim() || undefined],
|
|
279
|
+
['surface', fields.surface.trim() || undefined],
|
|
280
|
+
['context', fields.context.trim() || undefined],
|
|
281
|
+
['args', Object.keys(args).length > 0 ? args : undefined],
|
|
282
|
+
['fragment', fields.fragment || undefined],
|
|
283
|
+
['comment', fields.comment.trim() || undefined],
|
|
284
|
+
['remove', fields.remove || undefined],
|
|
285
|
+
];
|
|
286
|
+
for (const [name, value] of wanted) {
|
|
287
|
+
if (value === undefined) {
|
|
288
|
+
map.delete(name);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (map.has(name)) {
|
|
292
|
+
setValue(doc, map, name, value);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
// A field the entry didn't have goes before the message, where the others are.
|
|
296
|
+
const at = map.items.findIndex((p) => isScalar(p.key) && p.key.value === source);
|
|
297
|
+
map.items.splice(at === -1 ? map.items.length : at, 0, doc.createPair(name, value));
|
|
298
|
+
}
|
|
299
|
+
setValue(doc, map, source, fields.message);
|
|
300
|
+
return print(doc);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** The source message replaced, and nothing else: a `key: message` entry stays one. */
|
|
304
|
+
function withMessage(file: string, key: string, message: string, source: string): string {
|
|
305
|
+
const doc = parse(file);
|
|
306
|
+
const entry = doc.get(key, true);
|
|
307
|
+
if (!isMap(entry)) return withTranslation(file, key, message);
|
|
308
|
+
setValue(doc, entry, source, message);
|
|
309
|
+
return print(doc);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function withTranslation(file: string, key: string, translation: string): string {
|
|
313
|
+
const doc = parse(file);
|
|
314
|
+
const node = doc.get(key, true);
|
|
315
|
+
if (translation.trim() === '') {
|
|
316
|
+
doc.delete(key);
|
|
317
|
+
} else if (isScalar(node)) {
|
|
318
|
+
node.value = translation;
|
|
319
|
+
if (translation.includes('\n')) node.type = Scalar.BLOCK_LITERAL;
|
|
320
|
+
} else {
|
|
321
|
+
doc.set(key, translation);
|
|
322
|
+
}
|
|
323
|
+
// A file left with nothing in it reads as no translations, not as `{}`.
|
|
324
|
+
return isMap(doc.contents) && doc.contents.items.length === 0 ? '' : print(doc);
|
|
325
|
+
}
|