prosebook 0.1.0 → 0.3.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/README.md +311 -57
- package/dist/bun.d.ts +34 -0
- package/dist/bun.d.ts.map +1 -0
- package/dist/bun.js +98 -0
- package/dist/bun.js.map +1 -0
- package/dist/bundle-DboItAre.js +111 -0
- package/dist/bundle-DboItAre.js.map +1 -0
- package/dist/bundle-KD-DiiFa.d.ts +6 -0
- package/dist/bundle-KD-DiiFa.d.ts.map +1 -0
- package/dist/cli.js +51 -56
- package/dist/cli.js.map +1 -1
- package/dist/compile-xF3F4oIp.d.ts +16 -0
- package/dist/compile-xF3F4oIp.d.ts.map +1 -0
- package/dist/{config-CxVMn19f.js → config-BPSmoYel.js} +82 -196
- package/dist/config-BPSmoYel.js.map +1 -0
- package/dist/{config-CF7HziDa.d.ts → config-DdMkKt_N.d.ts} +85 -7
- package/dist/config-DdMkKt_N.d.ts.map +1 -0
- package/dist/config.d.ts +2 -2
- package/dist/config.js +1 -1
- package/dist/{copy-DXnzXsJa.d.ts → copy-B3V_QRkT.d.ts} +20 -7
- package/dist/copy-B3V_QRkT.d.ts.map +1 -0
- package/dist/copy-Bo_FIoLy.js +154 -0
- package/dist/copy-Bo_FIoLy.js.map +1 -0
- package/dist/endpoints-CnUfCkIk.js +96 -0
- package/dist/endpoints-CnUfCkIk.js.map +1 -0
- package/dist/generate-9pR3i5Bj.js +5173 -0
- package/dist/generate-9pR3i5Bj.js.map +1 -0
- package/dist/helpers-BWGcD0kS.js +96 -0
- package/dist/helpers-BWGcD0kS.js.map +1 -0
- package/dist/icu-Dej0mNsF.js +218 -0
- package/dist/icu-Dej0mNsF.js.map +1 -0
- package/dist/index.d.ts +12 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -188
- package/dist/inspector.d.ts +4 -1
- package/dist/inspector.d.ts.map +1 -1
- package/dist/inspector.js +7 -2
- package/dist/inspector.js.map +1 -1
- package/dist/locale-DpQA1pqH.js +70 -0
- package/dist/locale-DpQA1pqH.js.map +1 -0
- package/dist/next-loader.d.ts +11 -0
- package/dist/next-loader.d.ts.map +1 -0
- package/dist/next-loader.js +17 -0
- package/dist/next-loader.js.map +1 -0
- package/dist/next.d.ts +30 -0
- package/dist/next.d.ts.map +1 -0
- package/dist/next.js +160 -0
- package/dist/next.js.map +1 -0
- package/dist/react/index.d.ts +3 -3
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js.map +1 -1
- package/dist/react/server.d.ts +20 -0
- package/dist/react/server.d.ts.map +1 -0
- package/dist/react/server.js +34 -0
- package/dist/react/server.js.map +1 -0
- package/dist/{sarif-PkPvsB07.js → sarif-CWyTAQ0y.js} +103 -87
- package/dist/sarif-CWyTAQ0y.js.map +1 -0
- package/dist/scan-Bx0_yDSi.js +387 -0
- package/dist/scan-Bx0_yDSi.js.map +1 -0
- package/dist/tools.d.ts +83 -64
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +5 -4
- package/dist/vite.d.ts +14 -4
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +60 -114
- package/dist/vite.js.map +1 -1
- package/package.json +37 -4
- package/src/bun.ts +132 -0
- package/src/cli.ts +60 -58
- package/src/index.ts +1 -1
- package/src/inspector.ts +8 -3
- package/src/next-loader.ts +16 -0
- package/src/next.ts +188 -0
- package/src/react/index.tsx +6 -6
- package/src/react/server.ts +37 -0
- package/src/runtime/copy.ts +22 -3
- package/src/runtime/helpers.ts +54 -18
- package/src/runtime/locale.ts +19 -0
- package/src/runtime/types.ts +21 -6
- package/src/tools/bundle.ts +131 -0
- package/src/tools/catalog.ts +2 -2
- package/src/tools/compile.ts +61 -13
- package/src/tools/config.ts +58 -3
- package/src/tools/endpoints.ts +121 -0
- package/src/tools/entry.ts +77 -0
- package/src/tools/generate.ts +32 -32
- package/src/tools/icu.ts +93 -8
- package/src/tools/index.ts +20 -4
- package/src/tools/lint.ts +49 -12
- package/src/tools/lock.ts +7 -0
- package/src/tools/mf1.ts +21 -7
- package/src/tools/mf2.ts +249 -57
- package/src/tools/migrate.ts +3 -2
- package/src/tools/native/emit.ts +87 -0
- package/src/tools/native/index.ts +36 -0
- package/src/tools/native/kotlin.ts +269 -0
- package/src/tools/native/model.ts +280 -0
- package/src/tools/native/number.ts +210 -0
- package/src/tools/native/plurals.ts +103 -0
- package/src/tools/native/rust.ts +367 -0
- package/src/tools/native/swift.ts +326 -0
- package/src/tools/scan.ts +134 -4
- package/src/tools/syntax.ts +3 -3
- package/src/tools/todo.ts +118 -0
- package/src/vite.ts +74 -116
- package/dist/config-CF7HziDa.d.ts.map +0 -1
- package/dist/config-CxVMn19f.js.map +0 -1
- package/dist/copy-DXnzXsJa.d.ts.map +0 -1
- package/dist/helpers-BPuPGjQP.js +0 -61
- package/dist/helpers-BPuPGjQP.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/sarif-PkPvsB07.js.map +0 -1
- package/dist/scan-B0tZ7y-D.js +0 -2447
- package/dist/scan-B0tZ7y-D.js.map +0 -1
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dev server's half of the inspector (`inspector.ts`, `inspector-dialog.ts`): `__prosebook/open`,
|
|
3
|
+
* which opens an entry in the editor, and `__prosebook/entry`, which reads and edits one. Written over
|
|
4
|
+
* `Request` and `Response`, so each integration mounts them its own way: Vite as middleware, Bun as
|
|
5
|
+
* `Bun.serve` routes, Next behind a rewrite to a server of their own. Each opens the editor its dev
|
|
6
|
+
* server already launches.
|
|
7
|
+
*/
|
|
8
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
9
|
+
|
|
10
|
+
import { CatalogError, entryLocation, loadCatalog, type EntryLocation } from './catalog.ts';
|
|
11
|
+
import type { ResolvedConfig } from './config.ts';
|
|
12
|
+
import { editEntry, readEntry, type EntryEdit } from './entry.ts';
|
|
13
|
+
|
|
14
|
+
export interface EndpointOptions {
|
|
15
|
+
/** The path the endpoints sit under, ending in `/`: `/` by default. */
|
|
16
|
+
base?: string;
|
|
17
|
+
/** Opens an entry in the editor: a redirect to the dev server's own launcher, or a reply once it has. */
|
|
18
|
+
open: (at: EntryLocation) => Response | Promise<Response>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Answers the inspector's requests, and nothing for any other path. */
|
|
22
|
+
export function inspectorEndpoints(
|
|
23
|
+
config: ResolvedConfig | Promise<ResolvedConfig>,
|
|
24
|
+
{ base = '/', open }: EndpointOptions,
|
|
25
|
+
): (req: Request) => Promise<Response | undefined> {
|
|
26
|
+
return async (req) => {
|
|
27
|
+
const url = new URL(req.url);
|
|
28
|
+
if (url.pathname === `${base}__prosebook/open`) return openEntry(await config, url, open);
|
|
29
|
+
if (url.pathname === `${base}__prosebook/entry`) return entry(await config, req, url);
|
|
30
|
+
return undefined;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `?key=cart.items&locale=de`: the translation's entry, or the catalog's with `catalog` or when the
|
|
36
|
+
* locale has none. Read from disk, so a key added since the page loaded is found.
|
|
37
|
+
*/
|
|
38
|
+
async function openEntry(config: ResolvedConfig, url: URL, open: EndpointOptions['open']): Promise<Response> {
|
|
39
|
+
const key = url.searchParams.get('key') ?? '';
|
|
40
|
+
let message;
|
|
41
|
+
try {
|
|
42
|
+
message = loadCatalog(config).messages.find((m) => m.key === key);
|
|
43
|
+
} catch (e) {
|
|
44
|
+
return failed(e);
|
|
45
|
+
}
|
|
46
|
+
if (message === undefined)
|
|
47
|
+
return new Response(`no entry for "${key}" in the catalog: has it been renamed since the page loaded?`, { status: 404 });
|
|
48
|
+
const locale = url.searchParams.has('catalog') ? undefined : (url.searchParams.get('locale') ?? undefined);
|
|
49
|
+
return open(entryLocation(config, message, locale));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The inspector's dialog: GET `?key=…` reads an entry in every locale, POST `{ key, edit, write }`
|
|
54
|
+
* applies an edit (`entry.ts`). A POST writes files, so only the page itself may send one: same origin,
|
|
55
|
+
* and JSON, which a form on another site can't send without asking first.
|
|
56
|
+
*/
|
|
57
|
+
async function entry(config: ResolvedConfig, req: Request, url: URL): Promise<Response> {
|
|
58
|
+
if (req.method === 'GET') {
|
|
59
|
+
try {
|
|
60
|
+
return Response.json(readEntry(config, url.searchParams.get('key') ?? ''));
|
|
61
|
+
} catch (e) {
|
|
62
|
+
return failed(e);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const origin = req.headers.get('origin');
|
|
66
|
+
const sameOrigin = origin === null || new URL(origin).host === url.host;
|
|
67
|
+
if (req.method !== 'POST' || !sameOrigin || req.headers.get('content-type')?.startsWith('application/json') !== true)
|
|
68
|
+
return Response.json({ problems: [{ severity: 'error', message: 'edits come from the page, as JSON' }] }, { status: 403 });
|
|
69
|
+
try {
|
|
70
|
+
const { key, edit, write } = (await req.json()) as { key: string; edit: EntryEdit; write?: boolean };
|
|
71
|
+
const result = editEntry(config, key, edit, write === true);
|
|
72
|
+
return Response.json(result, { status: result.problems.length > 0 ? 422 : 200 });
|
|
73
|
+
} catch (e) {
|
|
74
|
+
return failed(e);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A redirect within the page's origin, to a launcher the dev server answers itself. */
|
|
79
|
+
export const redirect = (path: string) => new Response(null, { status: 307, headers: { Location: path } });
|
|
80
|
+
|
|
81
|
+
function failed(e: unknown): Response {
|
|
82
|
+
return Response.json(
|
|
83
|
+
{ problems: e instanceof CatalogError ? e.problems : [{ severity: 'error', message: e instanceof Error ? e.message : String(e) }] },
|
|
84
|
+
{ status: e instanceof CatalogError ? 409 : 400 },
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The endpoints as Node middleware, for Vite's server and Next's. The request's host is the one the
|
|
90
|
+
* page was served from: a proxy's `x-forwarded-host`, as Next's rewrite sets, before its own.
|
|
91
|
+
*/
|
|
92
|
+
export function nodeMiddleware(
|
|
93
|
+
handle: (req: Request) => Promise<Response | undefined>,
|
|
94
|
+
): (req: IncomingMessage, res: ServerResponse, next: () => void) => void {
|
|
95
|
+
return (req, res, next) => {
|
|
96
|
+
// Before reading the body, which a request for anything else leaves to whoever answers it.
|
|
97
|
+
if (req.url?.includes('/__prosebook/') !== true) return next();
|
|
98
|
+
const forwarded = req.headers['x-forwarded-host'];
|
|
99
|
+
const host = (Array.isArray(forwarded) ? forwarded[0] : forwarded) ?? req.headers.host ?? 'localhost';
|
|
100
|
+
const headers = new Headers();
|
|
101
|
+
for (const [name, value] of Object.entries(req.headers))
|
|
102
|
+
for (const v of Array.isArray(value) ? value : value === undefined ? [] : [value]) headers.append(name, v);
|
|
103
|
+
|
|
104
|
+
const chunks: Buffer[] = [];
|
|
105
|
+
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
106
|
+
req.on('end', () => {
|
|
107
|
+
const body = req.method === 'GET' || req.method === 'HEAD' ? undefined : Buffer.concat(chunks);
|
|
108
|
+
handle(new Request(`http://${host}${req.url}`, { method: req.method, headers, body }))
|
|
109
|
+
.then(async (response) => {
|
|
110
|
+
if (response === undefined) return next();
|
|
111
|
+
res.statusCode = response.status;
|
|
112
|
+
response.headers.forEach((value, name) => res.setHeader(name, value));
|
|
113
|
+
res.end(Buffer.from(await response.arrayBuffer()));
|
|
114
|
+
})
|
|
115
|
+
.catch((e: unknown) => {
|
|
116
|
+
res.statusCode = 500;
|
|
117
|
+
res.end(e instanceof Error ? e.message : String(e));
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
}
|
package/src/tools/entry.ts
CHANGED
|
@@ -168,6 +168,83 @@ export function applyFixes(config: ResolvedConfig, catalog: Catalog, findings: F
|
|
|
168
168
|
return { fixed, problems: [] };
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
export interface Imported {
|
|
172
|
+
/** Keys whose translation was written. */
|
|
173
|
+
written: string[];
|
|
174
|
+
/** Keys given back as they were: each still holds, so a stale one can be accepted. */
|
|
175
|
+
unchanged: string[];
|
|
176
|
+
/** What was left out, and why. The rest was written regardless. */
|
|
177
|
+
problems: Problem[];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Translations into `locale` from `prosebook todo`'s YAML filled in, by a person or a model, each
|
|
182
|
+
* written to the file its locale and area put it in. Text around a fenced block is left aside, as a
|
|
183
|
+
* model's reply has it, and an empty value is one left for later. An entry that would break the
|
|
184
|
+
* catalog is left out with its problem, so a reply with one bad entry still lands the others.
|
|
185
|
+
*/
|
|
186
|
+
export function importTranslations(config: ResolvedConfig, locale: string, reply: string): Imported {
|
|
187
|
+
const failed = (message: string, key?: string): Problem => ({ severity: 'error', key, message });
|
|
188
|
+
if (locale === config.source)
|
|
189
|
+
return { written: [], unchanged: [], problems: [failed(`${locale} is the source: import takes translations`)] };
|
|
190
|
+
const fenced = [...reply.matchAll(/^```[^\n]*\n([\s\S]*?)^```/gm)].map((m) => m[1]);
|
|
191
|
+
const doc = parseDocument(fenced.length > 0 ? fenced.join('\n') : reply);
|
|
192
|
+
if (doc.errors.length > 0 || !(isMap(doc.contents) || doc.contents === null)) {
|
|
193
|
+
const why = doc.errors[0]?.message.split('\n', 1)[0] ?? 'it is not a mapping';
|
|
194
|
+
return {
|
|
195
|
+
written: [],
|
|
196
|
+
unchanged: [],
|
|
197
|
+
problems: [failed(`the reply isn't YAML of \`key: translation\` (${why}): fix it, or ask for one YAML block`)],
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const catalog = loadCatalog(config);
|
|
202
|
+
const problems: Problem[] = [];
|
|
203
|
+
const unchanged: string[] = [];
|
|
204
|
+
const pending = new Map<string, { file: string; text: string }>();
|
|
205
|
+
for (const pair of isMap(doc.contents) ? doc.contents.items : []) {
|
|
206
|
+
const key = isScalar(pair.key) ? String(pair.key.value) : String(pair.key);
|
|
207
|
+
const message = catalog.messages.find((m) => m.key === key);
|
|
208
|
+
if (message === undefined) {
|
|
209
|
+
problems.push(failed('no entry has this key: translate only the keys `prosebook todo` printed', key));
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const value = isScalar(pair.value) ? pair.value.value : pair.value;
|
|
213
|
+
if (value === null || value === '') continue;
|
|
214
|
+
if (typeof value !== 'string' && typeof value !== 'number') {
|
|
215
|
+
problems.push(failed('the translation is not text: give the message as one string', key));
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const text = String(value);
|
|
219
|
+
if (text === message.translations.get(locale)?.text) unchanged.push(key);
|
|
220
|
+
else pending.set(key, { file: translationFile(config, message, locale), text });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// An entry whose own file reports it is left out, until what is left loads clean. A problem
|
|
224
|
+
// anywhere else means the catalog was broken before the reply, so nothing is written.
|
|
225
|
+
for (;;) {
|
|
226
|
+
const unsaved = new Map<string, string>();
|
|
227
|
+
for (const [key, { file, text }] of pending)
|
|
228
|
+
unsaved.set(resolve(config.root, file), withTranslation(contentOf(config, file, unsaved), key, text));
|
|
229
|
+
try {
|
|
230
|
+
loadCatalog(config, unsaved);
|
|
231
|
+
} catch (e) {
|
|
232
|
+
if (!(e instanceof CatalogError)) throw e;
|
|
233
|
+
const errors = e.problems.filter((p) => p.severity === 'error');
|
|
234
|
+
const own = errors.filter((p) => p.key !== undefined && pending.get(p.key)?.file === p.file);
|
|
235
|
+
if (own.length === 0) return { written: [], unchanged: [], problems: [...problems, ...errors] };
|
|
236
|
+
for (const p of own) if (p.key !== undefined) pending.delete(p.key);
|
|
237
|
+
problems.push(...own);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
for (const [path, content] of unsaved) {
|
|
241
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
242
|
+
writeFileSync(path, content);
|
|
243
|
+
}
|
|
244
|
+
return { written: [...pending.keys()], unchanged, problems };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
171
248
|
/** A file as the edit has it, as it is on disk, or empty when it is still to be made. */
|
|
172
249
|
function contentOf(config: ResolvedConfig, file: string, unsaved: Map<string, string>): string {
|
|
173
250
|
const path = resolve(config.root, file);
|
package/src/tools/generate.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { loadCatalog, type Catalog, type Message } from './catalog.ts';
|
|
|
12
12
|
import { compileLocale, localeModule, type Entry } from './compile.ts';
|
|
13
13
|
import type { ResolvedConfig } from './config.ts';
|
|
14
14
|
import { readLock, reconcile, serializeLock, type Lock, type StaleTranslation } from './lock.ts';
|
|
15
|
+
import { nativeFiles } from './native/index.ts';
|
|
15
16
|
import { pseudoAst } from './pseudo.ts';
|
|
16
17
|
import { SYNTAX_NAME } from './syntax.ts';
|
|
17
18
|
|
|
@@ -127,44 +128,42 @@ function catalogLiteral(config: ResolvedConfig, locales: string[], ext: string):
|
|
|
127
128
|
|
|
128
129
|
/** The JSON Schema an editor reads to complete and explain catalog entries. */
|
|
129
130
|
export function catalogSchema(config: ResolvedConfig): object {
|
|
131
|
+
// The shorthand satisfies no required field, so it's offered only where none is.
|
|
132
|
+
const shorthand = { type: 'string', description: `The message, in ${SYNTAX_NAME[config.messageFormat]}, with nothing said about it.` };
|
|
133
|
+
const entry = {
|
|
134
|
+
type: 'object',
|
|
135
|
+
required: [config.source, ...config.require],
|
|
136
|
+
additionalProperties: false,
|
|
137
|
+
properties: {
|
|
138
|
+
[config.source]: { type: 'string', description: `The message, in ${SYNTAX_NAME[config.messageFormat]}.` },
|
|
139
|
+
kind: {
|
|
140
|
+
description: 'What sort of string it is.',
|
|
141
|
+
oneOf: Object.entries(config.kinds).map(([k, r]) => ({ const: k, description: r.doc })),
|
|
142
|
+
},
|
|
143
|
+
surface: {
|
|
144
|
+
description: 'Where the string sits.',
|
|
145
|
+
oneOf: Object.entries(config.surfaces).map(([k, d]) => ({ const: k, description: d })),
|
|
146
|
+
},
|
|
147
|
+
context: { type: 'string', description: 'One sentence telling a translator where the string appears and what it answers to.' },
|
|
148
|
+
args: { type: 'object', description: 'What each placeholder holds.', additionalProperties: { type: 'string' } },
|
|
149
|
+
fragment: { type: 'boolean', description: 'Completes something beside it rather than standing as a sentence.' },
|
|
150
|
+
comment: { type: 'string', description: 'A change asked of whoever edits the entry next. Make it, then delete the comment.' },
|
|
151
|
+
remove: { type: 'boolean', description: 'Asks for the entry to go: delete it, its translations and the code that reads it.' },
|
|
152
|
+
allow: {
|
|
153
|
+
type: 'object',
|
|
154
|
+
description: 'Lint rules this entry is exempt from, each with the reason.',
|
|
155
|
+
propertyNames: { enum: Object.keys(config.lint.rules) },
|
|
156
|
+
additionalProperties: { type: 'string' },
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
};
|
|
130
160
|
return {
|
|
131
161
|
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
132
162
|
title: 'prosebook catalog',
|
|
133
163
|
description: 'Generated by `prosebook generate` from the config. Do not edit.',
|
|
134
164
|
type: 'object',
|
|
135
165
|
propertyNames: { pattern: '^[a-z0-9]+(?:[-_][a-z0-9]+)*(?:\\.[a-z0-9]+(?:[-_][a-z0-9]+)*)+$' },
|
|
136
|
-
additionalProperties: {
|
|
137
|
-
oneOf: [
|
|
138
|
-
{ type: 'string', description: `The message, in ${SYNTAX_NAME[config.messageFormat]}, with nothing said about it.` },
|
|
139
|
-
{
|
|
140
|
-
type: 'object',
|
|
141
|
-
required: [config.source, ...config.require],
|
|
142
|
-
additionalProperties: false,
|
|
143
|
-
properties: {
|
|
144
|
-
[config.source]: { type: 'string', description: `The message, in ${SYNTAX_NAME[config.messageFormat]}.` },
|
|
145
|
-
kind: {
|
|
146
|
-
description: 'What sort of string it is.',
|
|
147
|
-
oneOf: Object.entries(config.kinds).map(([k, r]) => ({ const: k, description: r.doc })),
|
|
148
|
-
},
|
|
149
|
-
surface: {
|
|
150
|
-
description: 'Where the string sits.',
|
|
151
|
-
oneOf: Object.entries(config.surfaces).map(([k, d]) => ({ const: k, description: d })),
|
|
152
|
-
},
|
|
153
|
-
context: { type: 'string', description: 'One sentence telling a translator where the string appears and what it answers to.' },
|
|
154
|
-
args: { type: 'object', description: 'What each placeholder holds.', additionalProperties: { type: 'string' } },
|
|
155
|
-
fragment: { type: 'boolean', description: 'Completes something beside it rather than standing as a sentence.' },
|
|
156
|
-
comment: { type: 'string', description: 'A change asked of whoever edits the entry next. Make it, then delete the comment.' },
|
|
157
|
-
remove: { type: 'boolean', description: 'Asks for the entry to go: delete it, its translations and the code that reads it.' },
|
|
158
|
-
allow: {
|
|
159
|
-
type: 'object',
|
|
160
|
-
description: 'Lint rules this entry is exempt from, each with the reason.',
|
|
161
|
-
propertyNames: { enum: Object.keys(config.lint.rules) },
|
|
162
|
-
additionalProperties: { type: 'string' },
|
|
163
|
-
},
|
|
164
|
-
},
|
|
165
|
-
},
|
|
166
|
-
],
|
|
167
|
-
},
|
|
166
|
+
additionalProperties: config.require.length === 0 ? { oneOf: [shorthand, entry] } : entry,
|
|
168
167
|
};
|
|
169
168
|
}
|
|
170
169
|
|
|
@@ -231,6 +230,7 @@ export function render(config: ResolvedConfig, accept?: Map<string, Set<string>
|
|
|
231
230
|
);
|
|
232
231
|
}
|
|
233
232
|
|
|
233
|
+
for (const [path, content] of nativeFiles(config, catalog, locales, entries, head)) files.set(path, content);
|
|
234
234
|
files.set(join(config.catalog, 'catalog.schema.json'), `${JSON.stringify(catalogSchema(config), null, 2)}\n`);
|
|
235
235
|
if (Object.keys(lock.translations).length > 0 || existsSync(config.lock)) files.set(config.lock, serializeLock(lock));
|
|
236
236
|
return { catalog, files, lock, stale, entries };
|
package/src/tools/icu.ts
CHANGED
|
@@ -9,7 +9,33 @@ export type { MessageFormatElement };
|
|
|
9
9
|
export { TYPE };
|
|
10
10
|
|
|
11
11
|
export function parseMessage(text: string): MessageFormatElement[] {
|
|
12
|
-
|
|
12
|
+
const ast = parse(text, { shouldParseSkeletons: true, requiresOtherClause: true });
|
|
13
|
+
walk(ast, (el) => {
|
|
14
|
+
if ((isDateElement(el) || isTimeElement(el)) && typeof el.style === 'object' && el.style !== null && el.style.pattern.includes('j'))
|
|
15
|
+
el.style.parsedOptions = localHour(el.style.pattern);
|
|
16
|
+
});
|
|
17
|
+
return ast;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A skeleton's `j` is the hour as the locale writes it, 12 or 24. The parser resolves it only when
|
|
22
|
+
* given a locale and drops it otherwise, so `::yMMMdjmm` showed the minute alone. Read here as an
|
|
23
|
+
* hour with no cycle, which `Intl` gives the cycle of whatever locale formats it: "2:05 PM", "14:05".
|
|
24
|
+
*/
|
|
25
|
+
function localHour(pattern: string): Intl.DateTimeFormatOptions {
|
|
26
|
+
const run = /j+/.exec(pattern)![0];
|
|
27
|
+
const rest = pattern.replaceAll('j', '');
|
|
28
|
+
return { ...(rest === '' ? {} : dateFields(rest)), hour: run.length % 2 === 0 ? '2-digit' : 'numeric' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What the parser reads from a date skeleton on its own; nothing where it can't parse it. */
|
|
32
|
+
function dateFields(skeleton: string): Intl.DateTimeFormatOptions {
|
|
33
|
+
try {
|
|
34
|
+
const el = parse(`{d, date, ::${skeleton}}`, { shouldParseSkeletons: true })[0];
|
|
35
|
+
return el !== undefined && isDateElement(el) && typeof el.style === 'object' && el.style !== null ? el.style.parsedOptions : {};
|
|
36
|
+
} catch {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
13
39
|
}
|
|
14
40
|
|
|
15
41
|
/**
|
|
@@ -39,6 +65,50 @@ function argType(el: MessageFormatElement): string | undefined {
|
|
|
39
65
|
}
|
|
40
66
|
}
|
|
41
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Formatting options read from arguments as the message renders, by `Intl` option: MF2's
|
|
70
|
+
* `{$price :currency currency=$code}`. MF1 has no way to say this, so only a tree read from MF2 has them.
|
|
71
|
+
*/
|
|
72
|
+
export type OptionArgs = Record<string, { arg: string; type: string }>;
|
|
73
|
+
|
|
74
|
+
export function optionArgsOf(el: MessageFormatElement): OptionArgs | undefined {
|
|
75
|
+
if (el.type === TYPE.plural) return selectionOf(el)?.args;
|
|
76
|
+
if (!isNumberElement(el) && !isDateElement(el) && !isTimeElement(el)) return undefined;
|
|
77
|
+
const style = el.style;
|
|
78
|
+
return typeof style === 'object' && style !== null && 'args' in style ? (style as { args: OptionArgs }).args : undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* How an MF2 `.match` selects on a number, beyond its plural type: on the number as formatted, so
|
|
83
|
+
* the selector's digit options (`Intl.PluralRules` takes the same ones, some from arguments) decide
|
|
84
|
+
* the category, and on the value `:integer` rounds or `:percent` multiplies by 100. Kept with the
|
|
85
|
+
* selector's function as written, to write it back. MF1 selects on the bare value.
|
|
86
|
+
*/
|
|
87
|
+
export interface Selection {
|
|
88
|
+
fn: { name: string; options: Record<string, string>; args?: Record<string, string> };
|
|
89
|
+
options: Intl.PluralRulesOptions;
|
|
90
|
+
args?: OptionArgs;
|
|
91
|
+
value?: 'round' | 'percent';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function selectionOf(el: MessageFormatElement): Selection | undefined {
|
|
95
|
+
return el.type === TYPE.plural ? (el as { selection?: Selection }).selection : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* How a placeholder renders around its value: the text it shows when it has none, or one `Intl`
|
|
100
|
+
* can't format, and the marks isolating its direction from the text around it. MF2 sets these
|
|
101
|
+
* (`{$name}`, and isolation in a right-to-left locale); an MF1 placeholder shows `{name}`.
|
|
102
|
+
*/
|
|
103
|
+
export interface Placeholder {
|
|
104
|
+
fallback: string;
|
|
105
|
+
isolate?: readonly [open: string, close: string];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function placeholderOf(el: MessageFormatElement, name: string): Placeholder {
|
|
109
|
+
return (el as { placeholder?: Placeholder }).placeholder ?? { fallback: `{${name}}` };
|
|
110
|
+
}
|
|
111
|
+
|
|
42
112
|
/** Calls `visit` on every element, inside plural and select arms and tags too. */
|
|
43
113
|
export function walk(elements: MessageFormatElement[], visit: (el: MessageFormatElement) => void): void {
|
|
44
114
|
for (const el of elements) {
|
|
@@ -50,12 +120,15 @@ export function walk(elements: MessageFormatElement[], visit: (el: MessageFormat
|
|
|
50
120
|
|
|
51
121
|
export function argsOf(elements: MessageFormatElement[], clash: (name: string) => void = () => {}): Map<string, string> {
|
|
52
122
|
const args = new Map<string, string>();
|
|
123
|
+
const add = (name: string, type: string) => {
|
|
124
|
+
const seen = args.get(name);
|
|
125
|
+
if (seen !== undefined && seen !== type) clash(name);
|
|
126
|
+
else args.set(name, type);
|
|
127
|
+
};
|
|
53
128
|
walk(elements, (el) => {
|
|
129
|
+
for (const { arg, type } of Object.values(optionArgsOf(el) ?? {})) add(arg, type);
|
|
54
130
|
const type = argType(el);
|
|
55
|
-
if (type
|
|
56
|
-
const seen = args.get(el.value);
|
|
57
|
-
if (seen !== undefined && seen !== type) clash(el.value);
|
|
58
|
-
else args.set(el.value, type);
|
|
131
|
+
if (type !== undefined && 'value' in el && typeof el.value === 'string') add(el.value, type);
|
|
59
132
|
});
|
|
60
133
|
return args;
|
|
61
134
|
}
|
|
@@ -95,8 +168,12 @@ export function numberOptions(el: NumberElement): Intl.NumberFormatOptions | und
|
|
|
95
168
|
const unknown = style.tokens.filter((t) => !stemIsKnown(t.stem, t.options)).map((t) => t.stem);
|
|
96
169
|
if (unknown.length > 0) throw new Error(`{${el.value}} has skeleton stems the formatter drops without a word: ${unknown.join(', ')}`);
|
|
97
170
|
const options = style.parsedOptions as Intl.NumberFormatOptions;
|
|
171
|
+
// An option an argument gives is checked with a stand-in: `Intl` refuses a currency style without
|
|
172
|
+
// its currency. Whether the argument's value suits it is known only as the message renders.
|
|
173
|
+
const args = optionArgsOf(el) ?? {};
|
|
174
|
+
const given = { ...(args.currency && { currency: 'EUR' }), ...(args.unit && { unit: 'meter' }) };
|
|
98
175
|
try {
|
|
99
|
-
new Intl.NumberFormat('en', options).resolvedOptions();
|
|
176
|
+
new Intl.NumberFormat('en', { ...options, ...given }).resolvedOptions();
|
|
100
177
|
} catch (e) {
|
|
101
178
|
throw new Error(`{${el.value}} has a skeleton Intl refuses: ${(e as Error).message}`, { cause: e });
|
|
102
179
|
}
|
|
@@ -115,6 +192,9 @@ export function dateOptions(el: DateElement | TimeElement): Intl.DateTimeFormatO
|
|
|
115
192
|
}
|
|
116
193
|
return { [field]: style };
|
|
117
194
|
}
|
|
195
|
+
// Every field the parser understands sets an option, and one it doesn't is dropped without a word.
|
|
196
|
+
const dropped = (style.pattern.match(/([A-Za-z])*/g) ?? []).filter((f) => !f.startsWith('j') && Object.keys(dateFields(f)).length === 0);
|
|
197
|
+
if (dropped.length > 0) throw new Error(`{${el.value}} has date fields the formatter drops without a word: ${dropped.join(', ')}`);
|
|
118
198
|
if (Object.keys(style.parsedOptions).length === 0)
|
|
119
199
|
throw new Error(`{${el.value}} has a date skeleton the formatter reads nothing from: "${style.pattern}"`);
|
|
120
200
|
try {
|
|
@@ -167,8 +247,13 @@ export function formatsOf(ast: MessageFormatElement[]): Map<string, string> {
|
|
|
167
247
|
said = `${kind} \`${style}\``;
|
|
168
248
|
} else if ('mf2' in style) {
|
|
169
249
|
// Read from MF2: the function as written.
|
|
170
|
-
const {
|
|
171
|
-
|
|
250
|
+
const {
|
|
251
|
+
name,
|
|
252
|
+
options,
|
|
253
|
+
args = {},
|
|
254
|
+
} = (style as { mf2: { name: string; options: Record<string, string>; args?: Record<string, string> } }).mf2;
|
|
255
|
+
const written = [...Object.entries(options), ...Object.entries(args).map(([k, v]) => [k, `$${v}`])];
|
|
256
|
+
said = `\`${[`:${name}`, ...written.map(([k, v]) => `${k}=${v}`)].join(' ')}\``;
|
|
172
257
|
} else if ('tokens' in style) {
|
|
173
258
|
said = `${kind} \`::${style.tokens.map((t) => [t.stem, ...t.options].join('/')).join(' ')}\``;
|
|
174
259
|
} else {
|
package/src/tools/index.ts
CHANGED
|
@@ -30,14 +30,16 @@ export {
|
|
|
30
30
|
export {
|
|
31
31
|
applyFixes,
|
|
32
32
|
editEntry,
|
|
33
|
+
importTranslations,
|
|
33
34
|
readEntry,
|
|
35
|
+
type Imported,
|
|
34
36
|
type EntryEdit,
|
|
35
37
|
type EntryFields,
|
|
36
38
|
type EntryFinding,
|
|
37
39
|
type EntryView,
|
|
38
40
|
type EntryYaml,
|
|
39
41
|
} from './entry.ts';
|
|
40
|
-
export { defineConfig, loadConfig, resolveConfig, type ProsebookConfig, type ResolvedConfig } from './config.ts';
|
|
42
|
+
export { defineConfig, loadConfig, resolveConfig, type NativeTarget, type ProsebookConfig, type ResolvedConfig } from './config.ts';
|
|
41
43
|
export { catalogSchema, generate, localeEntries, localesOf, render, writeFiles, type Generated } from './generate.ts';
|
|
42
44
|
export {
|
|
43
45
|
applyEdits,
|
|
@@ -66,13 +68,27 @@ export {
|
|
|
66
68
|
type SubstitutionRule,
|
|
67
69
|
} from './lint.ts';
|
|
68
70
|
export { formatDiff, wordDiff, type DiffPart } from './diff.ts';
|
|
69
|
-
export { emptyLock, hash, readLock, reconcile, serializeLock, type Lock, type StaleTranslation } from './lock.ts';
|
|
71
|
+
export { changeOf, emptyLock, hash, readLock, reconcile, serializeLock, type Lock, type StaleTranslation } from './lock.ts';
|
|
70
72
|
export { DEFAULT_KINDS, DEFAULT_SURFACES, STRICT_LISTS, type KindRule, type Severity } from './presets.ts';
|
|
71
73
|
export { pseudoAst, pseudoText } from './pseudo.ts';
|
|
72
|
-
export { intlDate, intlNumber, parseMf2, toMf2, type Mf2Function } from './mf2.ts';
|
|
74
|
+
export { intlDate, intlNumber, parseMf2, toMf2, type Isolate, type Mf2Function } from './mf2.ts';
|
|
73
75
|
export { toMf1 } from './mf1.ts';
|
|
74
76
|
export { applyMigrate, planMigrate, type MigratePlan } from './migrate.ts';
|
|
75
77
|
export { readMessage, SYNTAX_NAME, writeMessage, type MessageFormatVersion } from './syntax.ts';
|
|
76
78
|
export { syntaxKind, typescriptOracle, type Span, type TypeOracle, type ValueKind } from './types.ts';
|
|
79
|
+
export { todoOf, type Todo } from './todo.ts';
|
|
77
80
|
export { SarifReport, type SarifResult, type SarifTool } from './sarif.ts';
|
|
78
|
-
export {
|
|
81
|
+
export {
|
|
82
|
+
hardcodedText,
|
|
83
|
+
isReferenced,
|
|
84
|
+
keyRefs,
|
|
85
|
+
looksLikeCode,
|
|
86
|
+
READERS,
|
|
87
|
+
scan,
|
|
88
|
+
sourceFiles,
|
|
89
|
+
unseenReads,
|
|
90
|
+
type Hardcoded,
|
|
91
|
+
type KeyRefs,
|
|
92
|
+
type Reads,
|
|
93
|
+
type UnseenRead,
|
|
94
|
+
} from './scan.ts';
|
package/src/tools/lint.ts
CHANGED
|
@@ -37,11 +37,12 @@ interface RuleBase {
|
|
|
37
37
|
/** Its severity under `extends: 'strict'`; `error` when unset. */
|
|
38
38
|
strict?: Severity;
|
|
39
39
|
/**
|
|
40
|
-
* Only entries of these kinds,
|
|
41
|
-
*
|
|
42
|
-
* `
|
|
40
|
+
* Only entries of these kinds, on these surfaces, or in these areas (a key's first segment); an
|
|
41
|
+
* entry that says no kind or surface is skipped by a rule that names some. Only the source unless
|
|
42
|
+
* `locale` says otherwise, since most writing rules are written for one language: `all` reads
|
|
43
|
+
* every locale, and a list those languages and their regions (`de` covers `de-CH`).
|
|
43
44
|
*/
|
|
44
|
-
on?: { kind?: string[]; surface?: string[]; locale?: 'all' | string[] };
|
|
45
|
+
on?: { kind?: string[]; surface?: string[]; area?: string[]; locale?: 'all' | string[] };
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
/** A rule written as code. */
|
|
@@ -273,10 +274,14 @@ const NOT_PLURAL = new Set([
|
|
|
273
274
|
'series',
|
|
274
275
|
]);
|
|
275
276
|
|
|
276
|
-
/**
|
|
277
|
+
/**
|
|
278
|
+
* The singular of an English plural noun, or nothing where it doesn't look like one. A word with no
|
|
279
|
+
* vowel is a unit or an abbreviation (`kbps`, `fps`, `hrs`), which reads the same after 1.
|
|
280
|
+
*/
|
|
277
281
|
export function singularOf(word: string): string | undefined {
|
|
278
282
|
const lower = word.toLowerCase();
|
|
279
283
|
if (word.length < 3 || !lower.endsWith('s') || lower.endsWith('ss') || NOT_PLURAL.has(lower)) return undefined;
|
|
284
|
+
if (!/[aeiouy]/.test(lower)) return undefined;
|
|
280
285
|
if (/[^aeiou]ies$/.test(lower)) return `${word.slice(0, -3)}y`;
|
|
281
286
|
if (/(?:s|x|z|ch|sh)es$/.test(lower)) return word.slice(0, -2);
|
|
282
287
|
return word.slice(0, -1);
|
|
@@ -323,12 +328,14 @@ function eachRun(
|
|
|
323
328
|
const COUNT_NAME = /^(?:n|num|count|total|amount|quantity)$|(?:count|total)$/i;
|
|
324
329
|
|
|
325
330
|
/**
|
|
326
|
-
* Whether an arm ends with a stop; nothing for an arm with no words
|
|
327
|
-
*
|
|
331
|
+
* Whether an arm ends with a stop; nothing for an arm with no words, or one that trails off in an
|
|
332
|
+
* ellipsis (`Saving…`, `Save as…`), which is neither. Which stop is the arm's own: a question in
|
|
333
|
+
* `one` and a second sentence after it in `other` both end well.
|
|
328
334
|
*/
|
|
329
335
|
function endsWithStop(arm: MessageFormatElement[]): boolean | undefined {
|
|
330
336
|
const text = prose(renderAs(arm, 'other')).trim();
|
|
331
|
-
|
|
337
|
+
if (text.replaceAll(HOLE, '').trim() === '' || /(?:…|\.{3})["')”»」』]*$/u.test(text)) return undefined;
|
|
338
|
+
return hasStop(text);
|
|
332
339
|
}
|
|
333
340
|
|
|
334
341
|
/** Ends with a stop in any script (`。`, `؟`, `;` as Greek writes `?`), though not a comma. */
|
|
@@ -336,6 +343,17 @@ const hasStop = (text: string) => /(?![,,、،])[\p{Terminal_Punctuation}…][
|
|
|
336
343
|
|
|
337
344
|
const CURRENCY_BEFORE = /[$€£¥₹]\s?$/;
|
|
338
345
|
|
|
346
|
+
/**
|
|
347
|
+
* The ways a path through menus is written, the separator in a group: an arrow or `›` however
|
|
348
|
+
* spaced, and `>` or `/` only spaced between words or placeholders, with a capital after a slash,
|
|
349
|
+
* since "2 > 1" and "and / or" are no paths. The match takes the spaces around, so the fix spaces
|
|
350
|
+
* the separator.
|
|
351
|
+
*/
|
|
352
|
+
const PATH_SEPARATORS = new RegExp(
|
|
353
|
+
String.raw`\s*(→|->|›)\s*|(?<=[\p{L}${HOLE}])\s+(>)\s+(?=[\p{L}${HOLE}])|(?<=\p{L})\s+(\/)\s+(?=\p{Lu})`,
|
|
354
|
+
'gu',
|
|
355
|
+
);
|
|
356
|
+
|
|
339
357
|
const OFF_SCREEN = new Set(['aria']);
|
|
340
358
|
|
|
341
359
|
const an = (word = 'string') => `${/^[aeiou]/i.test(word) ? 'an' : 'a'} ${word}`;
|
|
@@ -447,6 +465,19 @@ export const CORE_RULES = {
|
|
|
447
465
|
return match === undefined ? [] : [{ message: 'no em-dash: a comma, a period or a colon does the job', match }];
|
|
448
466
|
},
|
|
449
467
|
},
|
|
468
|
+
path: {
|
|
469
|
+
description: 'A path through menus or settings is written with `lint.pathSeparator`, spaced: Settings › General.',
|
|
470
|
+
check: ({ text, config }) => {
|
|
471
|
+
const use = config.lint.pathSeparator;
|
|
472
|
+
for (const v of text.variants) {
|
|
473
|
+
for (const m of v.matchAll(PATH_SEPARATORS)) {
|
|
474
|
+
if ((m[1] ?? m[2] ?? m[3]) === use) continue;
|
|
475
|
+
return [{ message: `a path is written with a spaced ${use}`, match: m[0], fix: ` ${use} ` }];
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return [];
|
|
479
|
+
},
|
|
480
|
+
},
|
|
450
481
|
contractions: {
|
|
451
482
|
description: '`could not`, `is not` and `cannot` are written as contractions.',
|
|
452
483
|
check: ({ text }) =>
|
|
@@ -704,14 +735,19 @@ const readsLocale = (locales: 'all' | string[] | undefined, locale: string, sour
|
|
|
704
735
|
locales.some((l) => locale.toLowerCase() === l.toLowerCase() || locale.toLowerCase().startsWith(`${l.toLowerCase()}-`));
|
|
705
736
|
|
|
706
737
|
function applies(rule: RuleBase, message: Message, locale: string, sourceLocale: string): boolean {
|
|
707
|
-
const { kind, surface, locale: locales } = rule.on ?? {};
|
|
738
|
+
const { kind, surface, area, locale: locales } = rule.on ?? {};
|
|
708
739
|
return (
|
|
709
740
|
readsLocale(locales, locale, sourceLocale) &&
|
|
710
741
|
(kind === undefined || (message.kind !== undefined && kind.includes(message.kind))) &&
|
|
711
|
-
(surface === undefined || (message.surface !== undefined && surface.includes(message.surface)))
|
|
742
|
+
(surface === undefined || (message.surface !== undefined && surface.includes(message.surface))) &&
|
|
743
|
+
(area === undefined || area.includes(message.area))
|
|
712
744
|
);
|
|
713
745
|
}
|
|
714
746
|
|
|
747
|
+
/** A rule's severity for an entry: its area's, where `lint.areas` sets one. */
|
|
748
|
+
export const severityOf = (config: ResolvedConfig, name: string, message: Message): Severity | undefined =>
|
|
749
|
+
config.lint.areas[message.area]?.[name] ?? config.lint.rules[name]?.severity;
|
|
750
|
+
|
|
715
751
|
interface Literal {
|
|
716
752
|
start: number;
|
|
717
753
|
value: string;
|
|
@@ -784,7 +820,8 @@ export function lint(messages: Message[], config: ResolvedConfig): Finding[] {
|
|
|
784
820
|
const text = locale === config.source ? source : readings(ast);
|
|
785
821
|
const context: RuleContext = { message, locale, ast, kind, text, source, config };
|
|
786
822
|
let literals: Literal[] | undefined;
|
|
787
|
-
for (const [name, { rule
|
|
823
|
+
for (const [name, { rule }] of rules) {
|
|
824
|
+
const severity = severityOf(config, name, message)!;
|
|
788
825
|
if (severity === 'off' || !applies(rule, message, locale, config.source)) continue;
|
|
789
826
|
let said: (string | Report)[];
|
|
790
827
|
try {
|
|
@@ -814,7 +851,7 @@ export function lint(messages: Message[], config: ResolvedConfig): Finding[] {
|
|
|
814
851
|
const rule = config.lint.rules[name];
|
|
815
852
|
if (rule === undefined) {
|
|
816
853
|
findings.push({ ...at, message: `allow.${name} is not a rule: ${rules.map(([n]) => n).join(', ')}` });
|
|
817
|
-
} else if (
|
|
854
|
+
} else if (severityOf(config, name, message) === 'off') {
|
|
818
855
|
continue;
|
|
819
856
|
} else if (!fired.has(name)) {
|
|
820
857
|
findings.push({ ...at, message: `allow.${name} exempts the entry from a rule it passes: remove it` });
|
package/src/tools/lock.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { createHash } from 'node:crypto';
|
|
|
14
14
|
import { existsSync, readFileSync } from 'node:fs';
|
|
15
15
|
|
|
16
16
|
import type { Catalog } from './catalog.ts';
|
|
17
|
+
import { formatDiff, wordDiff } from './diff.ts';
|
|
17
18
|
|
|
18
19
|
export interface Lock {
|
|
19
20
|
version: 2;
|
|
@@ -59,6 +60,12 @@ export interface StaleTranslation {
|
|
|
59
60
|
translation: string;
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
/** What changed in the source of a stale translation, as a word diff, or why that isn't known. */
|
|
64
|
+
export function changeOf(stale: StaleTranslation): string {
|
|
65
|
+
if (stale.was === undefined) return "the earlier text isn't recorded (the lock predates prosebook 0.2)";
|
|
66
|
+
return formatDiff(wordDiff(stale.was, stale.now)).replaceAll('\n', ' ');
|
|
67
|
+
}
|
|
68
|
+
|
|
62
69
|
export interface Reconciled {
|
|
63
70
|
lock: Lock;
|
|
64
71
|
/** Per locale, per key: the translations made from a source that has changed since. */
|