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,1033 @@
|
|
|
1
|
+
import { n as flatten, t as createHelpers } from "./helpers-BPuPGjQP.js";
|
|
2
|
+
import { E as tagsOf, g as singularOf, w as parseMessage, y as argsOf } from "./config-D--RITk2.js";
|
|
3
|
+
import { E as localeExpression, I as readMessage, L as writeMessage, M as loadCatalog, O as CatalogError, T as compileLocale, a as looksLikeCode, k as KEY_PATTERN } from "./scan-Ch2BfIUD.js";
|
|
4
|
+
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { Scalar, isMap, parseDocument, stringify } from "yaml";
|
|
8
|
+
import { parseSync } from "oxc-parser";
|
|
9
|
+
// src/tools/types.ts
|
|
10
|
+
/**
|
|
11
|
+
* What kind of value an expression in JSX holds, so `extract` can write `{n, number}` for a number and
|
|
12
|
+
* `{d, date, medium}` for a date instead of a bare `{x}`, which is text and prints a number unformatted.
|
|
13
|
+
*
|
|
14
|
+
* The project's own TypeScript (7 or later, through its `unstable/async` API) answers where it can; the
|
|
15
|
+
* syntax of the expression answers where it can't: a JavaScript project, a file outside any tsconfig,
|
|
16
|
+
* or no TypeScript installed. Not the `unstable/sync` API: on Linux and macOS it reads the compiler's
|
|
17
|
+
* pipes through a Node internal (`stdout._handle.fd`) that Bun doesn't have, so under Bun it throws.
|
|
18
|
+
*/
|
|
19
|
+
/** The nearest `tsconfig.json` at or above the file's folder. */
|
|
20
|
+
function tsconfigFor(file) {
|
|
21
|
+
for (let dir = dirname(resolve(file));; dir = dirname(dir)) {
|
|
22
|
+
const candidate = join(dir, "tsconfig.json");
|
|
23
|
+
if (existsSync(candidate)) return candidate;
|
|
24
|
+
if (dirname(dir) === dir) return void 0;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const ANY_OR_UNKNOWN = 3;
|
|
28
|
+
const NUMBER_LIKE = 71872;
|
|
29
|
+
const STRING_LIKE = 12583968;
|
|
30
|
+
const UNION = 134217728;
|
|
31
|
+
const OBJECT = 1048576;
|
|
32
|
+
/**
|
|
33
|
+
* An oracle over the projects the files belong to, or nothing where TypeScript 7 isn't installed or
|
|
34
|
+
* none of the files has a tsconfig. One compiler process serves every project and is closed by `close`.
|
|
35
|
+
*/
|
|
36
|
+
async function typescriptOracle(root, files) {
|
|
37
|
+
const configs = [...new Set(files.map(tsconfigFor).filter((c) => c !== void 0))];
|
|
38
|
+
if (configs.length === 0) return void 0;
|
|
39
|
+
let ts;
|
|
40
|
+
try {
|
|
41
|
+
ts = await import("typescript/unstable/async");
|
|
42
|
+
} catch {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
let api;
|
|
46
|
+
let snapshot;
|
|
47
|
+
try {
|
|
48
|
+
api = new ts.API({ cwd: root });
|
|
49
|
+
snapshot = await api.updateSnapshot({ openProjects: configs });
|
|
50
|
+
} catch {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const classify = async (checker, type) => {
|
|
54
|
+
if (type === void 0) return void 0;
|
|
55
|
+
const t = await checker.getNonNullableType(type) ?? type;
|
|
56
|
+
if ((t.flags & ANY_OR_UNKNOWN) !== 0) return "unknown";
|
|
57
|
+
if ((t.flags & UNION) !== 0) {
|
|
58
|
+
const members = await t.getTypes() ?? [];
|
|
59
|
+
const kinds = new Set(await Promise.all(members.map((m) => classify(checker, m))));
|
|
60
|
+
if (kinds.size === 1) return [...kinds][0];
|
|
61
|
+
return kinds.has("node") ? "node" : "unknown";
|
|
62
|
+
}
|
|
63
|
+
if ((t.flags & NUMBER_LIKE) !== 0) return "number";
|
|
64
|
+
if ((t.flags & STRING_LIKE) !== 0) return "string";
|
|
65
|
+
if (await checker.typeToString(t) === "Date") return "date";
|
|
66
|
+
if ((t.flags & OBJECT) !== 0) return "node";
|
|
67
|
+
return "unknown";
|
|
68
|
+
};
|
|
69
|
+
return {
|
|
70
|
+
source: "TypeScript",
|
|
71
|
+
async kinds(file, spans) {
|
|
72
|
+
const project = await snapshot.getDefaultProjectForFile(resolve(file));
|
|
73
|
+
const sourceFile = await project?.program.getSourceFile(resolve(file));
|
|
74
|
+
if (project === void 0 || sourceFile === void 0) return spans.map(() => void 0);
|
|
75
|
+
const nodes = spans.map(({ start, end }) => {
|
|
76
|
+
let node = sourceFile;
|
|
77
|
+
for (;;) {
|
|
78
|
+
let next;
|
|
79
|
+
node.forEachChild((child) => {
|
|
80
|
+
if (next === void 0 && child.pos <= start && child.end >= end) next = child;
|
|
81
|
+
});
|
|
82
|
+
if (next === void 0) return node;
|
|
83
|
+
node = next;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
const types = await project.checker.getTypeAtLocation(nodes);
|
|
87
|
+
return Promise.all(types.map((type) => classify(project.checker, type)));
|
|
88
|
+
},
|
|
89
|
+
close() {
|
|
90
|
+
return api.close();
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** The kind an expression's syntax gives away, where it does: `items.length`, `a * b`, `new Date()`. */
|
|
95
|
+
function syntaxKind(expr) {
|
|
96
|
+
switch (expr.type) {
|
|
97
|
+
case "Literal": return typeof expr.value === "number" ? "number" : typeof expr.value === "string" ? "string" : void 0;
|
|
98
|
+
case "TemplateLiteral": return "string";
|
|
99
|
+
case "MemberExpression": return !expr.computed && [
|
|
100
|
+
"length",
|
|
101
|
+
"size",
|
|
102
|
+
"count"
|
|
103
|
+
].includes(expr.property?.name) ? "number" : void 0;
|
|
104
|
+
case "BinaryExpression": return [
|
|
105
|
+
"-",
|
|
106
|
+
"*",
|
|
107
|
+
"/",
|
|
108
|
+
"%",
|
|
109
|
+
"**"
|
|
110
|
+
].includes(expr.operator) ? "number" : void 0;
|
|
111
|
+
case "NewExpression": return expr.callee?.type === "Identifier" && expr.callee.name === "Date" ? "date" : void 0;
|
|
112
|
+
case "CallExpression": return expr.callee?.type === "MemberExpression" && expr.callee.object?.name === "Math" ? "number" : void 0;
|
|
113
|
+
default: return;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// src/tools/extract.ts
|
|
117
|
+
/**
|
|
118
|
+
* `prosebook extract`: moves hardcoded JSX text into the catalog and rewrites the code to read it.
|
|
119
|
+
*
|
|
120
|
+
* It works on runs, not fragments. The children of an element that together make one sentence, text,
|
|
121
|
+
* simple expressions and inline elements holding only text, become one message: `Hi {user.name}, <a
|
|
122
|
+
* href="/x">go</a>!` is `Hi {name}, <a>go</a>!`, so a translator can reorder the whole sentence. A run
|
|
123
|
+
* becomes `<Text id=… />`, which needs no hook; a readable attribute becomes `t('…')`, with `useCopy()`
|
|
124
|
+
* added to its component. Keys, kinds, surfaces and contexts are suggestions, marked for review.
|
|
125
|
+
*/
|
|
126
|
+
const MODULE = "prosebook/react";
|
|
127
|
+
const NON_PROSE = /* @__PURE__ */ new Set([
|
|
128
|
+
"code",
|
|
129
|
+
"pre",
|
|
130
|
+
"kbd",
|
|
131
|
+
"samp",
|
|
132
|
+
"script",
|
|
133
|
+
"style",
|
|
134
|
+
"svg",
|
|
135
|
+
"path",
|
|
136
|
+
"Text"
|
|
137
|
+
]);
|
|
138
|
+
const LETTER = /\p{L}/u;
|
|
139
|
+
/** Elements that sit inside a sentence, and so become a tag in its message rather than ending it. */
|
|
140
|
+
const INLINE = /* @__PURE__ */ new Set([
|
|
141
|
+
"a",
|
|
142
|
+
"abbr",
|
|
143
|
+
"b",
|
|
144
|
+
"bdi",
|
|
145
|
+
"cite",
|
|
146
|
+
"del",
|
|
147
|
+
"dfn",
|
|
148
|
+
"em",
|
|
149
|
+
"i",
|
|
150
|
+
"ins",
|
|
151
|
+
"kbd",
|
|
152
|
+
"mark",
|
|
153
|
+
"q",
|
|
154
|
+
"s",
|
|
155
|
+
"small",
|
|
156
|
+
"span",
|
|
157
|
+
"strong",
|
|
158
|
+
"sub",
|
|
159
|
+
"sup",
|
|
160
|
+
"time",
|
|
161
|
+
"u",
|
|
162
|
+
"var"
|
|
163
|
+
]);
|
|
164
|
+
const isInline = (name) => INLINE.has(name) || /^[A-Z]\w*Link$|^Link$/.test(name);
|
|
165
|
+
/** Elements whose text has an obvious kind and surface. */
|
|
166
|
+
const ROLES = {
|
|
167
|
+
button: ["action", "action"],
|
|
168
|
+
h1: ["name", "heading"],
|
|
169
|
+
h2: ["name", "heading"],
|
|
170
|
+
h3: ["name", "heading"],
|
|
171
|
+
h4: ["name", "heading"],
|
|
172
|
+
h5: ["name", "heading"],
|
|
173
|
+
h6: ["name", "heading"],
|
|
174
|
+
th: ["name", "heading"],
|
|
175
|
+
legend: ["name", "heading"],
|
|
176
|
+
caption: ["name", "heading"],
|
|
177
|
+
label: ["name", "label"],
|
|
178
|
+
option: ["name", "choice"],
|
|
179
|
+
title: ["name", "title"]
|
|
180
|
+
};
|
|
181
|
+
/** Readable attributes: the kind and surface their value has, and the key suffix that says which it is. */
|
|
182
|
+
const ATTRIBUTE_ROLES = {
|
|
183
|
+
placeholder: [
|
|
184
|
+
void 0,
|
|
185
|
+
"placeholder",
|
|
186
|
+
"placeholder"
|
|
187
|
+
],
|
|
188
|
+
title: [
|
|
189
|
+
"name",
|
|
190
|
+
"tooltip",
|
|
191
|
+
"tooltip"
|
|
192
|
+
],
|
|
193
|
+
"aria-label": [
|
|
194
|
+
"name",
|
|
195
|
+
"aria",
|
|
196
|
+
"aria"
|
|
197
|
+
],
|
|
198
|
+
"aria-description": [
|
|
199
|
+
"description",
|
|
200
|
+
"aria",
|
|
201
|
+
"aria-description"
|
|
202
|
+
],
|
|
203
|
+
"aria-placeholder": [
|
|
204
|
+
void 0,
|
|
205
|
+
"aria",
|
|
206
|
+
"aria-placeholder"
|
|
207
|
+
],
|
|
208
|
+
alt: [
|
|
209
|
+
void 0,
|
|
210
|
+
"aria",
|
|
211
|
+
"alt"
|
|
212
|
+
],
|
|
213
|
+
label: [
|
|
214
|
+
"name",
|
|
215
|
+
"label",
|
|
216
|
+
"label"
|
|
217
|
+
]
|
|
218
|
+
};
|
|
219
|
+
/** The area a file's strings go in: its name in kebab case, or its folder's for an `index`. */
|
|
220
|
+
function areaOf(file) {
|
|
221
|
+
let name = basename(file, extname(file));
|
|
222
|
+
if ([
|
|
223
|
+
"index",
|
|
224
|
+
"page",
|
|
225
|
+
"layout",
|
|
226
|
+
"route",
|
|
227
|
+
"main"
|
|
228
|
+
].includes(name)) name = basename(dirname(file));
|
|
229
|
+
const kebab = name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z])([A-Z][a-z])/g, "$1-$2").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
230
|
+
return kebab === "" ? "app" : kebab;
|
|
231
|
+
}
|
|
232
|
+
/** A key segment from the words of a message: its first four, plain ASCII, lowercase. */
|
|
233
|
+
function slugOf(message) {
|
|
234
|
+
const words = message.replace(/<\/?[\w-]+>|\{[^}]*\}/g, " ").replace(/ı/g, "i").normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim().split(" ").filter(Boolean).slice(0, 4);
|
|
235
|
+
return words.length === 0 ? "text" : words.join("-");
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* A count followed by a plural noun, `{n, number} files`, as the plural it needs: `{n, plural, one {#
|
|
239
|
+
* file} other {# files}}`. English only, and a draft: the TODO above the entry asks for a look.
|
|
240
|
+
*/
|
|
241
|
+
function draftPlurals(message) {
|
|
242
|
+
return message.replace(/\{(\w+), number\} (\p{L}+)(?![\p{L}'])/gu, (whole, name, word) => {
|
|
243
|
+
const one = singularOf(word);
|
|
244
|
+
return one === void 0 ? whole : `{${name}, plural, one {# ${one}} other {# ${word}}}`;
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
/** A message built as MF1, as the catalog writes messages: an MF2 catalog gets it in MF2. */
|
|
248
|
+
function inSyntax(config, mf1) {
|
|
249
|
+
return config.messageFormat === 2 ? writeMessage(2, parseMessage(mf1), config.source) : mf1;
|
|
250
|
+
}
|
|
251
|
+
/** Text written literally inside an ICU message. */
|
|
252
|
+
function escapeIcu(text) {
|
|
253
|
+
return text.replace(/[{}<]/g, (c) => `'${c}'`);
|
|
254
|
+
}
|
|
255
|
+
/** JSX text as React renders it: lines trimmed where they meet a line break, blank lines dropped. */
|
|
256
|
+
function jsxText(value) {
|
|
257
|
+
const lines = value.split(/\r\n|\n|\r/);
|
|
258
|
+
let lastNonEmpty = 0;
|
|
259
|
+
lines.forEach((line, i) => {
|
|
260
|
+
if (/[^ \t]/.test(line)) lastNonEmpty = i;
|
|
261
|
+
});
|
|
262
|
+
let out = "";
|
|
263
|
+
lines.forEach((line, i) => {
|
|
264
|
+
let trimmed = line.replace(/\t/g, " ");
|
|
265
|
+
if (i !== 0) trimmed = trimmed.replace(/^ +/, "");
|
|
266
|
+
if (i !== lines.length - 1) trimmed = trimmed.replace(/ +$/, "");
|
|
267
|
+
if (trimmed === "") return;
|
|
268
|
+
out += i === lastNonEmpty ? trimmed : `${trimmed} `;
|
|
269
|
+
});
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
const SIMPLE_EXPRESSIONS = /* @__PURE__ */ new Set([
|
|
273
|
+
"Identifier",
|
|
274
|
+
"MemberExpression",
|
|
275
|
+
"CallExpression",
|
|
276
|
+
"TemplateLiteral",
|
|
277
|
+
"Literal",
|
|
278
|
+
"BinaryExpression",
|
|
279
|
+
"TSNonNullExpression",
|
|
280
|
+
"ChainExpression"
|
|
281
|
+
]);
|
|
282
|
+
/** A value that reads as a placeholder: no JSX and no functions inside it, which would be markup or logic. */
|
|
283
|
+
function simple(expr) {
|
|
284
|
+
if (!SIMPLE_EXPRESSIONS.has(expr.type)) return false;
|
|
285
|
+
let found = false;
|
|
286
|
+
const walk = (node) => {
|
|
287
|
+
if (found) return;
|
|
288
|
+
if (/^JSX|FunctionExpression$/.test(node.type)) found = true;
|
|
289
|
+
for (const value of Object.values(node)) if (Array.isArray(value)) value.forEach((v) => isNode(v) && walk(v));
|
|
290
|
+
else if (isNode(value)) walk(value);
|
|
291
|
+
};
|
|
292
|
+
walk(expr);
|
|
293
|
+
return !found;
|
|
294
|
+
}
|
|
295
|
+
/** Every name bound inside a node: declarations, parameters, catch clauses, imports. With `deep` false, only its own scope's. */
|
|
296
|
+
function bindingsIn(root, deep = true) {
|
|
297
|
+
const names = /* @__PURE__ */ new Set();
|
|
298
|
+
const pattern = (p) => {
|
|
299
|
+
if (!p) return;
|
|
300
|
+
if (p.type === "Identifier") names.add(p.name);
|
|
301
|
+
else if (p.type === "ObjectPattern") for (const prop of p.properties) pattern(prop.type === "RestElement" ? prop : prop.value);
|
|
302
|
+
else if (p.type === "ArrayPattern") for (const el of p.elements) pattern(el);
|
|
303
|
+
else if (p.type === "AssignmentPattern") pattern(p.left);
|
|
304
|
+
else if (p.type === "RestElement") pattern(p.argument);
|
|
305
|
+
};
|
|
306
|
+
const walk = (node, top) => {
|
|
307
|
+
if (node.type === "VariableDeclarator") pattern(node.id);
|
|
308
|
+
if (node.type === "ImportSpecifier" || node.type === "ImportDefaultSpecifier" || node.type === "ImportNamespaceSpecifier") pattern(node.local);
|
|
309
|
+
if ((node.type === "FunctionDeclaration" || node.type === "ClassDeclaration") && node.id) pattern(node.id);
|
|
310
|
+
if (node.type === "CatchClause") pattern(node.param);
|
|
311
|
+
const isFunction = /Function(?:Declaration|Expression)$|^ArrowFunctionExpression$/.test(node.type);
|
|
312
|
+
if (isFunction && (deep || top)) for (const param of node.params) pattern(param);
|
|
313
|
+
if (isFunction && !deep && !top) return;
|
|
314
|
+
for (const value of Object.values(node)) if (Array.isArray(value)) {
|
|
315
|
+
for (const child of value) if (isNode(child)) walk(child, false);
|
|
316
|
+
} else if (isNode(value)) walk(value, false);
|
|
317
|
+
};
|
|
318
|
+
walk(root, true);
|
|
319
|
+
return names;
|
|
320
|
+
}
|
|
321
|
+
function elementName(el) {
|
|
322
|
+
const name = el.openingElement?.name;
|
|
323
|
+
return name?.type === "JSXIdentifier" ? name.name : void 0;
|
|
324
|
+
}
|
|
325
|
+
function isNode(value) {
|
|
326
|
+
return typeof value === "object" && value !== null && typeof value.type === "string";
|
|
327
|
+
}
|
|
328
|
+
/** Plans the extraction from one file: the edits and the entries, without writing anything. */
|
|
329
|
+
function planFile(path, ctx) {
|
|
330
|
+
const code = readFileSync(path, "utf8");
|
|
331
|
+
const file = relative(ctx.config.root, path).replaceAll("\\", "/");
|
|
332
|
+
const plan = {
|
|
333
|
+
path,
|
|
334
|
+
file,
|
|
335
|
+
code,
|
|
336
|
+
edits: [],
|
|
337
|
+
extractions: [],
|
|
338
|
+
warnings: []
|
|
339
|
+
};
|
|
340
|
+
const { program } = parseSync(file, code);
|
|
341
|
+
const topLevel = bindingsIn(program, false);
|
|
342
|
+
const lines = code.split("\n");
|
|
343
|
+
const lineStarts = [];
|
|
344
|
+
let offset = 0;
|
|
345
|
+
for (const line of lines) {
|
|
346
|
+
lineStarts.push(offset);
|
|
347
|
+
offset += line.length + 1;
|
|
348
|
+
}
|
|
349
|
+
const position = (at) => {
|
|
350
|
+
let line = lineStarts.findIndex((s) => s > at);
|
|
351
|
+
line = line === -1 ? lineStarts.length : line;
|
|
352
|
+
return {
|
|
353
|
+
line,
|
|
354
|
+
column: at - lineStarts[line - 1] + 1
|
|
355
|
+
};
|
|
356
|
+
};
|
|
357
|
+
const ignored = (at) => {
|
|
358
|
+
const { line } = position(at);
|
|
359
|
+
return (lines[line - 1] ?? "").includes("prosebook-ignore") || (lines[line - 2] ?? "").includes("prosebook-ignore");
|
|
360
|
+
};
|
|
361
|
+
let needText = false;
|
|
362
|
+
let needUseCopy = false;
|
|
363
|
+
/** Per component, by where it starts: the call that reads the catalog there. */
|
|
364
|
+
const hooked = /* @__PURE__ */ new Map();
|
|
365
|
+
/** Children now inside a `<Text>`, which the walk does not enter. */
|
|
366
|
+
const consumed = /* @__PURE__ */ new Set();
|
|
367
|
+
/** Enclosing functions, innermost last: the component each belongs to, and whether it is one. */
|
|
368
|
+
const components = [];
|
|
369
|
+
const componentName = (fn, parent) => {
|
|
370
|
+
if (fn.id?.name) return fn.id.name;
|
|
371
|
+
if (parent?.type === "VariableDeclarator" && parent.id?.type === "Identifier") return parent.id.name;
|
|
372
|
+
};
|
|
373
|
+
const entryFor = (message, args, role, context, suffix) => {
|
|
374
|
+
for (const [key, text] of ctx.known) if (text === message && key.startsWith(`${ctx.area}.`)) return {
|
|
375
|
+
key,
|
|
376
|
+
reused: true
|
|
377
|
+
};
|
|
378
|
+
const base = `${ctx.area}.${slugOf(message)}${suffix ? `-${suffix}` : ""}`;
|
|
379
|
+
let key = base;
|
|
380
|
+
for (let n = 2; ctx.known.has(key); n++) key = `${base}-${n}`;
|
|
381
|
+
ctx.known.set(key, message);
|
|
382
|
+
const [kind, surface] = role;
|
|
383
|
+
ctx.entries.push({
|
|
384
|
+
key,
|
|
385
|
+
area: ctx.area,
|
|
386
|
+
message,
|
|
387
|
+
...kind !== void 0 && kind in ctx.config.kinds ? { kind } : {},
|
|
388
|
+
...surface !== void 0 && surface in ctx.config.surfaces ? { surface } : {},
|
|
389
|
+
context,
|
|
390
|
+
args
|
|
391
|
+
});
|
|
392
|
+
return {
|
|
393
|
+
key,
|
|
394
|
+
reused: false
|
|
395
|
+
};
|
|
396
|
+
};
|
|
397
|
+
const where = (element) => {
|
|
398
|
+
const component = components.at(-1)?.name;
|
|
399
|
+
return `${component ? `In ${component}` : `In ${file}`}${element ? `, the text of a <${element}>` : ""}.`;
|
|
400
|
+
};
|
|
401
|
+
/** The children of one element, split into runs at anything that cannot sit inside a sentence. */
|
|
402
|
+
const extractChildren = (parent, children) => {
|
|
403
|
+
const parentName = parent.type === "JSXElement" ? elementName(parent) : void 0;
|
|
404
|
+
let run = [];
|
|
405
|
+
const flush = () => {
|
|
406
|
+
if (run.length > 0) extractRun(run, parentName);
|
|
407
|
+
run = [];
|
|
408
|
+
};
|
|
409
|
+
for (const child of children) if (inlineable(child)) run.push(child);
|
|
410
|
+
else flush();
|
|
411
|
+
flush();
|
|
412
|
+
};
|
|
413
|
+
const inlineable = (child) => {
|
|
414
|
+
if (child.type === "JSXText") return true;
|
|
415
|
+
if (child.type === "JSXExpressionContainer") return simple(child.expression);
|
|
416
|
+
if (child.type === "JSXElement") {
|
|
417
|
+
const name = elementName(child);
|
|
418
|
+
if (name === void 0 || child.openingElement.selfClosing || !isInline(name)) return false;
|
|
419
|
+
return child.children.every(inlineable);
|
|
420
|
+
}
|
|
421
|
+
return false;
|
|
422
|
+
};
|
|
423
|
+
const extractRun = (run, parentName) => {
|
|
424
|
+
const blank = (n) => n.type === "JSXText" && jsxText(n.value).trim() === "";
|
|
425
|
+
const from = run.findIndex((n) => !blank(n));
|
|
426
|
+
if (from === -1) return;
|
|
427
|
+
const nodes = run.slice(from, run.findLastIndex((n) => !blank(n)) + 1);
|
|
428
|
+
if (nodes.every((n) => n.type === "JSXElement" || blank(n))) return;
|
|
429
|
+
const first = nodes[0];
|
|
430
|
+
const last = nodes.at(-1);
|
|
431
|
+
const values = /* @__PURE__ */ new Map();
|
|
432
|
+
const argDocs = {};
|
|
433
|
+
const names = /* @__PURE__ */ new Map();
|
|
434
|
+
const tags = /* @__PURE__ */ new Set();
|
|
435
|
+
const argName = (expr) => {
|
|
436
|
+
const source = code.slice(expr.start, expr.end);
|
|
437
|
+
const known = names.get(source);
|
|
438
|
+
if (known !== void 0) return known;
|
|
439
|
+
let inner = expr;
|
|
440
|
+
while (inner.type === "TSNonNullExpression" || inner.type === "ChainExpression") inner = inner.expression;
|
|
441
|
+
let name = inner.type === "Identifier" ? inner.name : inner.type === "MemberExpression" && !inner.computed && inner.property?.type === "Identifier" ? inner.property.name : "value";
|
|
442
|
+
const base = name;
|
|
443
|
+
for (let n = 2; values.has(name) || tags.has(name); n++) name = `${base}${n}`;
|
|
444
|
+
names.set(source, name);
|
|
445
|
+
values.set(name, source);
|
|
446
|
+
argDocs[name] = `TODO: what \`${source}\` holds`;
|
|
447
|
+
return name;
|
|
448
|
+
};
|
|
449
|
+
const piece = (node) => {
|
|
450
|
+
if (node.type === "JSXText") return escapeIcu(jsxText(node.value));
|
|
451
|
+
if (node.type === "JSXElement") {
|
|
452
|
+
let tag = elementName(node).toLowerCase();
|
|
453
|
+
const base = tag;
|
|
454
|
+
for (let n = 2; tags.has(tag) || values.has(tag); n++) tag = `${base}${n}`;
|
|
455
|
+
tags.add(tag);
|
|
456
|
+
const open = code.slice(node.openingElement.start, node.openingElement.end);
|
|
457
|
+
const close = code.slice(node.closingElement.start, node.closingElement.end);
|
|
458
|
+
values.set(tag, `(chunks) => ${open}{chunks}${close}`);
|
|
459
|
+
return `<${tag}>${node.children.map(piece).join("")}</${tag}>`;
|
|
460
|
+
}
|
|
461
|
+
const expr = node.expression;
|
|
462
|
+
if (expr.type === "Literal" && typeof expr.value === "string") return escapeIcu(expr.value);
|
|
463
|
+
if (expr.type === "TemplateLiteral" && expr.expressions.length === 0) return escapeIcu(expr.quasis[0].value.cooked);
|
|
464
|
+
const kind = ctx.kinds.get(`${expr.start}:${expr.end}`) ?? syntaxKind(expr);
|
|
465
|
+
if (kind === "node") markup.push(code.slice(expr.start, expr.end));
|
|
466
|
+
return `{${argName(expr)}${kind === "number" ? ", number" : kind === "date" ? ", date, medium" : ""}}`;
|
|
467
|
+
};
|
|
468
|
+
const markup = [];
|
|
469
|
+
let message = nodes.map(piece).join("").trim();
|
|
470
|
+
if (markup.length > 0) {
|
|
471
|
+
const at = position(first.start);
|
|
472
|
+
plan.warnings.push(`${file}:${at.line}:${at.column} {${markup.join("}, {")}} holds markup, not text, so this sentence was left as it is: make it a tag, or give the text around it entries of its own`);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (/^en\b/.test(ctx.config.source)) message = draftPlurals(message);
|
|
476
|
+
const words = message.replace(/\{[^}]*\}|<\/?[\w-]+>|'[{}<]'/g, "");
|
|
477
|
+
if (!LETTER.test(words) || values.size === 0 && looksLikeCode(message)) return;
|
|
478
|
+
const start = first.type === "JSXText" ? first.start + (/^\s*/.exec(first.value)?.[0].length ?? 0) : first.start;
|
|
479
|
+
const end = last.type === "JSXText" ? last.end - (/\s*$/.exec(last.value)?.[0].length ?? 0) : last.end;
|
|
480
|
+
if (ignored(start)) return;
|
|
481
|
+
message = inSyntax(ctx.config, message);
|
|
482
|
+
const role = parentName !== void 0 ? ROLES[parentName] : void 0;
|
|
483
|
+
const { key, reused } = entryFor(message, argDocs, role ?? [void 0, void 0], where(parentName));
|
|
484
|
+
const props = [...values].map(([name, source]) => `${/^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name)}: ${source}`);
|
|
485
|
+
const replacement = `<Text id=${JSON.stringify(key)}${props.length > 0 ? ` values={{ ${props.join(", ")} }}` : ""} />`;
|
|
486
|
+
plan.edits.push({
|
|
487
|
+
start,
|
|
488
|
+
end,
|
|
489
|
+
text: replacement
|
|
490
|
+
});
|
|
491
|
+
for (const n of nodes) consumed.add(n);
|
|
492
|
+
plan.extractions.push({
|
|
493
|
+
file,
|
|
494
|
+
...position(start),
|
|
495
|
+
key,
|
|
496
|
+
message,
|
|
497
|
+
reused,
|
|
498
|
+
where: "text",
|
|
499
|
+
replacement
|
|
500
|
+
});
|
|
501
|
+
needText = true;
|
|
502
|
+
};
|
|
503
|
+
const extractAttribute = (attr, element) => {
|
|
504
|
+
const name = attr.name?.type === "JSXIdentifier" ? attr.name.name : "";
|
|
505
|
+
if (!ctx.config.scan.attributes.includes(name) || attr.value === null) return;
|
|
506
|
+
const value = attr.value;
|
|
507
|
+
let text;
|
|
508
|
+
if (value.type === "Literal" && typeof value.value === "string") text = value.value;
|
|
509
|
+
else if (value.type === "JSXExpressionContainer") {
|
|
510
|
+
const expr = value.expression;
|
|
511
|
+
if (expr.type === "Literal" && typeof expr.value === "string") text = expr.value;
|
|
512
|
+
if (expr.type === "TemplateLiteral" && expr.expressions.length === 0) text = expr.quasis[0].value.cooked;
|
|
513
|
+
}
|
|
514
|
+
if (text === void 0 || !LETTER.test(text) || looksLikeCode(text) || ignored(attr.start)) return;
|
|
515
|
+
const at = position(value.start);
|
|
516
|
+
const fn = components.findLast((c) => c.isComponent)?.fn;
|
|
517
|
+
if (fn === void 0 || fn.body?.type !== "BlockStatement") {
|
|
518
|
+
plan.warnings.push(`${file}:${at.line}:${at.column} ${name}="${text}" is not inside a component with a body, so it has no t() to read; left as it is`);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const call = hookFor(fn);
|
|
522
|
+
if (call === void 0) {
|
|
523
|
+
plan.warnings.push(`${file}:${at.line}:${at.column} ${name}="${text}": its component binds both \`t\` and \`copy\` already; left as it is`);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const [kind, surface, suffix] = ATTRIBUTE_ROLES[name] ?? [
|
|
527
|
+
void 0,
|
|
528
|
+
void 0,
|
|
529
|
+
name
|
|
530
|
+
];
|
|
531
|
+
const tag = elementName(element) ?? "element";
|
|
532
|
+
const component = components.at(-1)?.name;
|
|
533
|
+
const context = `${component ? `In ${component}` : `In ${file}`}, the ${name} of a <${tag}>.`;
|
|
534
|
+
const message = inSyntax(ctx.config, escapeIcu(text));
|
|
535
|
+
const { key, reused } = entryFor(message, {}, [kind, surface], context, suffix);
|
|
536
|
+
const replacement = `{${call}(${JSON.stringify(key)})}`;
|
|
537
|
+
plan.edits.push({
|
|
538
|
+
start: value.start,
|
|
539
|
+
end: value.end,
|
|
540
|
+
text: replacement
|
|
541
|
+
});
|
|
542
|
+
plan.extractions.push({
|
|
543
|
+
file,
|
|
544
|
+
...at,
|
|
545
|
+
key,
|
|
546
|
+
message,
|
|
547
|
+
reused,
|
|
548
|
+
where: name,
|
|
549
|
+
replacement
|
|
550
|
+
});
|
|
551
|
+
};
|
|
552
|
+
/**
|
|
553
|
+
* Makes sure the component can read the catalog, and returns the call that does: `t` from
|
|
554
|
+
* `useCopy()`, or `copy.t` where the component or the module already binds a `t` of its own.
|
|
555
|
+
*/
|
|
556
|
+
const hookFor = (fn) => {
|
|
557
|
+
const chosen = hooked.get(fn.start);
|
|
558
|
+
if (chosen !== void 0) return chosen;
|
|
559
|
+
const taken = /* @__PURE__ */ new Set([...topLevel, ...bindingsIn(fn)]);
|
|
560
|
+
const body = code.slice(fn.body.start, fn.body.end);
|
|
561
|
+
const insert = (text) => plan.edits.push({
|
|
562
|
+
start: fn.body.start + 1,
|
|
563
|
+
end: fn.body.start + 1,
|
|
564
|
+
text
|
|
565
|
+
});
|
|
566
|
+
const firstStatement = fn.body.body[0];
|
|
567
|
+
const indent = firstStatement ? " ".repeat(position(firstStatement.start).column - 1) : " ";
|
|
568
|
+
let call;
|
|
569
|
+
const destructured = /\{([^{}]*)\}\s*=\s*useCopy\(\)/.exec(body);
|
|
570
|
+
const whole = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*useCopy\(\)/.exec(body);
|
|
571
|
+
if (destructured !== null && /(^|[\s,])t(\s*[,}]|\s*$)/.test(destructured[1])) call = "t";
|
|
572
|
+
else if (whole !== null) call = `${whole[1]}.t`;
|
|
573
|
+
else if (destructured !== null && !taken.has("t")) {
|
|
574
|
+
const at = fn.body.start + destructured.index + 1;
|
|
575
|
+
plan.edits.push({
|
|
576
|
+
start: at,
|
|
577
|
+
end: at,
|
|
578
|
+
text: " t,"
|
|
579
|
+
});
|
|
580
|
+
call = "t";
|
|
581
|
+
} else if (!taken.has("t")) {
|
|
582
|
+
insert(`
|
|
583
|
+
${indent}const { t } = useCopy();`);
|
|
584
|
+
call = "t";
|
|
585
|
+
} else if (!taken.has("copy")) {
|
|
586
|
+
insert(`
|
|
587
|
+
${indent}const copy = useCopy();`);
|
|
588
|
+
call = "copy.t";
|
|
589
|
+
}
|
|
590
|
+
if (call !== void 0 && destructured === null && whole === null) needUseCopy = true;
|
|
591
|
+
if (call !== void 0) hooked.set(fn.start, call);
|
|
592
|
+
return call;
|
|
593
|
+
};
|
|
594
|
+
const visit = (node, parent) => {
|
|
595
|
+
const isFunction = node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression";
|
|
596
|
+
if (isFunction) {
|
|
597
|
+
const own = componentName(node, parent);
|
|
598
|
+
const isComponent = own !== void 0 && /^(?:[A-Z]|use[A-Z])/.test(own);
|
|
599
|
+
components.push({
|
|
600
|
+
fn: node,
|
|
601
|
+
name: isComponent ? own : components.at(-1)?.name,
|
|
602
|
+
isComponent
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
if (node.type === "JSXElement" || node.type === "JSXFragment") {
|
|
606
|
+
const name = node.type === "JSXElement" ? elementName(node) : void 0;
|
|
607
|
+
if (node.type === "JSXElement") for (const attr of node.openingElement.attributes) {
|
|
608
|
+
if (attr.type !== "JSXAttribute") continue;
|
|
609
|
+
extractAttribute(attr, node);
|
|
610
|
+
if (attr.value?.type === "JSXExpressionContainer") visit(attr.value, attr);
|
|
611
|
+
}
|
|
612
|
+
if (name === void 0 || !NON_PROSE.has(name)) {
|
|
613
|
+
extractChildren(node, node.children);
|
|
614
|
+
for (const child of node.children) if (!consumed.has(child)) visit(child, node);
|
|
615
|
+
}
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
for (const value of Object.values(node)) if (Array.isArray(value)) {
|
|
619
|
+
for (const child of value) if (isNode(child)) visit(child, node);
|
|
620
|
+
} else if (isNode(value)) visit(value, node);
|
|
621
|
+
if (isFunction) components.pop();
|
|
622
|
+
};
|
|
623
|
+
visit(program, void 0);
|
|
624
|
+
plan.extractions.sort((a, b) => a.line !== b.line ? a.line - b.line : a.column - b.column);
|
|
625
|
+
if (needText || needUseCopy) importEdit(program, code, plan, [...needText ? ["Text"] : [], ...needUseCopy ? ["useCopy"] : []]);
|
|
626
|
+
return plan;
|
|
627
|
+
}
|
|
628
|
+
/** Adds the names to an existing `prosebook/react` import, or a new import after the last one. */
|
|
629
|
+
function importEdit(program, code, plan, wanted) {
|
|
630
|
+
const imports = program.body.filter((s) => s.type === "ImportDeclaration");
|
|
631
|
+
const existing = imports.find((s) => s.source.value === MODULE && s.importKind !== "type");
|
|
632
|
+
if (existing) {
|
|
633
|
+
const named = existing.specifiers.filter((s) => s.type === "ImportSpecifier");
|
|
634
|
+
const have = new Set(named.map((s) => s.local.name));
|
|
635
|
+
const missing = wanted.filter((w) => !have.has(w));
|
|
636
|
+
if (missing.length === 0) return;
|
|
637
|
+
if (named.length > 0) {
|
|
638
|
+
plan.edits.push({
|
|
639
|
+
start: named.at(-1).end,
|
|
640
|
+
end: named.at(-1).end,
|
|
641
|
+
text: `, ${missing.join(", ")}`
|
|
642
|
+
});
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
const line = `import { ${wanted.join(", ")} } from '${MODULE}';`;
|
|
647
|
+
const last = imports.at(-1);
|
|
648
|
+
if (last) plan.edits.push({
|
|
649
|
+
start: last.end,
|
|
650
|
+
end: last.end,
|
|
651
|
+
text: `\n${line}`
|
|
652
|
+
});
|
|
653
|
+
else {
|
|
654
|
+
const directive = /^(?:\s*(['"])use \w+\1;?\s*\n)*/.exec(code)?.[0] ?? "";
|
|
655
|
+
plan.edits.push({
|
|
656
|
+
start: directive.length,
|
|
657
|
+
end: directive.length,
|
|
658
|
+
text: `${line}\n${directive === "" ? "\n" : ""}`
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
/** The spans of every expression inside JSX, which are the ones that may become placeholders. */
|
|
663
|
+
function jsxExpressions(file, code) {
|
|
664
|
+
const spans = [];
|
|
665
|
+
const walk = (node) => {
|
|
666
|
+
if (node.type === "JSXExpressionContainer" && node.expression.type !== "JSXEmptyExpression") spans.push({
|
|
667
|
+
start: node.expression.start,
|
|
668
|
+
end: node.expression.end
|
|
669
|
+
});
|
|
670
|
+
for (const value of Object.values(node)) if (Array.isArray(value)) {
|
|
671
|
+
for (const child of value) if (isNode(child)) walk(child);
|
|
672
|
+
} else if (isNode(value)) walk(value);
|
|
673
|
+
};
|
|
674
|
+
walk(parseSync(file, code).program);
|
|
675
|
+
return spans;
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Plans an extraction over the files in `paths` against the current catalog. With `types` (the
|
|
679
|
+
* default), the project's TypeScript says which placeholders are numbers and dates.
|
|
680
|
+
*/
|
|
681
|
+
async function planExtract(config, paths, options = {}) {
|
|
682
|
+
if (options.area !== void 0 && !KEY_PATTERN.test(`${options.area}.x`)) throw new Error(`prosebook: "${options.area}" is not an area: lowercase words joined by -`);
|
|
683
|
+
const known = /* @__PURE__ */ new Map();
|
|
684
|
+
if (existsSync(config.catalog)) try {
|
|
685
|
+
for (const m of loadCatalog(config).messages) known.set(m.key, m.text);
|
|
686
|
+
} catch (e) {
|
|
687
|
+
if (!(e instanceof CatalogError)) throw e;
|
|
688
|
+
throw new Error(`prosebook: fix the catalog before extracting into it:\n${e.message}`, { cause: e });
|
|
689
|
+
}
|
|
690
|
+
const entries = [];
|
|
691
|
+
const oracle = options.types === false ? void 0 : await typescriptOracle(config.root, paths);
|
|
692
|
+
try {
|
|
693
|
+
const files = [];
|
|
694
|
+
for (const path of paths) {
|
|
695
|
+
const kinds = /* @__PURE__ */ new Map();
|
|
696
|
+
if (oracle) {
|
|
697
|
+
const spans = jsxExpressions(path, readFileSync(path, "utf8"));
|
|
698
|
+
(await oracle.kinds(path, spans)).forEach((kind, i) => {
|
|
699
|
+
if (kind !== void 0 && kind !== "unknown") kinds.set(`${spans[i].start}:${spans[i].end}`, kind);
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
files.push(planFile(path, {
|
|
703
|
+
config,
|
|
704
|
+
area: options.area ?? areaOf(path),
|
|
705
|
+
known,
|
|
706
|
+
entries,
|
|
707
|
+
kinds
|
|
708
|
+
}));
|
|
709
|
+
}
|
|
710
|
+
return {
|
|
711
|
+
files,
|
|
712
|
+
entries,
|
|
713
|
+
types: oracle?.source ?? "syntax"
|
|
714
|
+
};
|
|
715
|
+
} finally {
|
|
716
|
+
await oracle?.close();
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
/** Applies a plan: rewrites the files and appends the new entries to their area's catalog file. */
|
|
720
|
+
function applyExtract(config, plan) {
|
|
721
|
+
const written = [];
|
|
722
|
+
for (const file of plan.files) {
|
|
723
|
+
if (file.edits.length === 0) continue;
|
|
724
|
+
let code = file.code;
|
|
725
|
+
for (const edit of [...file.edits].sort((a, b) => a.start !== b.start ? b.start - a.start : b.end - a.end)) code = code.slice(0, edit.start) + edit.text + code.slice(edit.end);
|
|
726
|
+
writeFileSync(file.path, code);
|
|
727
|
+
written.push(file.path);
|
|
728
|
+
}
|
|
729
|
+
const byArea = /* @__PURE__ */ new Map();
|
|
730
|
+
for (const entry of plan.entries) byArea.set(entry.area, [...byArea.get(entry.area) ?? [], entry]);
|
|
731
|
+
for (const [area, entries] of byArea) {
|
|
732
|
+
const path = join(config.catalog, `${area}.yaml`);
|
|
733
|
+
const blocks = entries.map((e) => {
|
|
734
|
+
const body = {};
|
|
735
|
+
if (e.kind) body.kind = e.kind;
|
|
736
|
+
if (e.surface) body.surface = e.surface;
|
|
737
|
+
body.context = e.context;
|
|
738
|
+
if (Object.keys(e.args).length > 0) body.args = e.args;
|
|
739
|
+
body[config.source] = e.message;
|
|
740
|
+
return `# TODO(prosebook extract): check the key, kind and context.\n${stringify({ [e.key]: body }, {
|
|
741
|
+
lineWidth: 0,
|
|
742
|
+
version: "1.1"
|
|
743
|
+
})}`;
|
|
744
|
+
});
|
|
745
|
+
mkdirSync(config.catalog, { recursive: true });
|
|
746
|
+
const current = existsSync(path) ? readFileSync(path, "utf8") : "# yaml-language-server: $schema=./catalog.schema.json\n";
|
|
747
|
+
writeFileSync(path, `${current.replace(/\n*$/, "\n")}\n${blocks.join("\n")}`);
|
|
748
|
+
written.push(path);
|
|
749
|
+
}
|
|
750
|
+
return written;
|
|
751
|
+
}
|
|
752
|
+
// src/tools/diff.ts
|
|
753
|
+
const TOKEN = /\s+|[\p{L}\p{N}_'’]+|[^\s\p{L}\p{N}_]/gu;
|
|
754
|
+
/** Past this many cells the table costs more than the diff is worth: show the whole change instead. */
|
|
755
|
+
const MAX_CELLS = 25e4;
|
|
756
|
+
function wordDiff(before, after) {
|
|
757
|
+
const a = before.match(TOKEN) ?? [];
|
|
758
|
+
const b = after.match(TOKEN) ?? [];
|
|
759
|
+
if (a.length * b.length > MAX_CELLS) return merge([{
|
|
760
|
+
op: "del",
|
|
761
|
+
text: before
|
|
762
|
+
}, {
|
|
763
|
+
op: "add",
|
|
764
|
+
text: after
|
|
765
|
+
}]);
|
|
766
|
+
const lcs = Array.from({ length: a.length + 1 }, () => Array.from({ length: b.length + 1 }, () => 0));
|
|
767
|
+
for (let i = a.length - 1; i >= 0; i--) for (let j = b.length - 1; j >= 0; j--) lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
768
|
+
const parts = [];
|
|
769
|
+
let i = 0;
|
|
770
|
+
let j = 0;
|
|
771
|
+
while (i < a.length && j < b.length) if (a[i] === b[j]) {
|
|
772
|
+
parts.push({
|
|
773
|
+
op: "same",
|
|
774
|
+
text: a[i]
|
|
775
|
+
});
|
|
776
|
+
i++;
|
|
777
|
+
j++;
|
|
778
|
+
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) parts.push({
|
|
779
|
+
op: "del",
|
|
780
|
+
text: a[i++]
|
|
781
|
+
});
|
|
782
|
+
else parts.push({
|
|
783
|
+
op: "add",
|
|
784
|
+
text: b[j++]
|
|
785
|
+
});
|
|
786
|
+
while (i < a.length) parts.push({
|
|
787
|
+
op: "del",
|
|
788
|
+
text: a[i++]
|
|
789
|
+
});
|
|
790
|
+
while (j < b.length) parts.push({
|
|
791
|
+
op: "add",
|
|
792
|
+
text: b[j++]
|
|
793
|
+
});
|
|
794
|
+
return merge(parts);
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Adjacent parts of one kind joined, and a space shared by two changes folded into them, so a
|
|
798
|
+
* replaced phrase reads `[-old words-]{+new words+}` rather than alternating word by word.
|
|
799
|
+
*/
|
|
800
|
+
function merge(parts) {
|
|
801
|
+
const out = [];
|
|
802
|
+
for (let k = 0; k < parts.length; k++) {
|
|
803
|
+
const part = parts[k];
|
|
804
|
+
const prev = out.at(-1);
|
|
805
|
+
const next = parts[k + 1];
|
|
806
|
+
if (part.op === "same" && /^\s+$/.test(part.text) && prev && prev.op !== "same" && next && next.op !== "same") {
|
|
807
|
+
out.push({
|
|
808
|
+
op: "del",
|
|
809
|
+
text: part.text
|
|
810
|
+
}, {
|
|
811
|
+
op: "add",
|
|
812
|
+
text: part.text
|
|
813
|
+
});
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
out.push({ ...part });
|
|
817
|
+
}
|
|
818
|
+
const joined = [];
|
|
819
|
+
for (const part of out.filter((p) => p.text !== "")) {
|
|
820
|
+
const last = joined.at(-1);
|
|
821
|
+
if (last && last.op === part.op) last.text += part.text;
|
|
822
|
+
else if (part.op === "del" && last?.op === "add" && joined.at(-2)?.op === "del") joined.at(-2).text += part.text;
|
|
823
|
+
else joined.push({ ...part });
|
|
824
|
+
}
|
|
825
|
+
return joined;
|
|
826
|
+
}
|
|
827
|
+
/** The diff as one line: `Save [-the-]{+a+} draft`, the notation of `git diff --word-diff`. */
|
|
828
|
+
function formatDiff(parts) {
|
|
829
|
+
return parts.map((p) => p.op === "same" ? p.text : p.op === "del" ? `[-${p.text}-]` : `{+${p.text}+}`).join("");
|
|
830
|
+
}
|
|
831
|
+
// src/tools/migrate.ts
|
|
832
|
+
/**
|
|
833
|
+
* `prosebook migrate`: a catalog and its translations rewritten from one syntax to the other. Each
|
|
834
|
+
* message is written in the target syntax, read back, and rendered both ways over sample values, so
|
|
835
|
+
* one that can't be written, or would come out different, is reported before anything changes.
|
|
836
|
+
*/
|
|
837
|
+
function planMigrate(config, to) {
|
|
838
|
+
const catalog = loadCatalog(config);
|
|
839
|
+
const plan = {
|
|
840
|
+
to,
|
|
841
|
+
rewrites: [],
|
|
842
|
+
blocked: [],
|
|
843
|
+
changed: []
|
|
844
|
+
};
|
|
845
|
+
for (const m of catalog.messages) {
|
|
846
|
+
const versions = [{
|
|
847
|
+
locale: config.source,
|
|
848
|
+
text: m.text,
|
|
849
|
+
ast: m.ast,
|
|
850
|
+
file: m.file
|
|
851
|
+
}, ...[...m.translations].map(([locale, t]) => ({
|
|
852
|
+
locale,
|
|
853
|
+
text: t.text,
|
|
854
|
+
ast: t.ast,
|
|
855
|
+
file: t.file
|
|
856
|
+
}))];
|
|
857
|
+
for (const { locale, text, ast, file } of versions) {
|
|
858
|
+
const at = {
|
|
859
|
+
key: m.key,
|
|
860
|
+
locale,
|
|
861
|
+
file
|
|
862
|
+
};
|
|
863
|
+
let after;
|
|
864
|
+
let back;
|
|
865
|
+
try {
|
|
866
|
+
after = writeMessage(to, ast, locale);
|
|
867
|
+
back = readMessage(to, after, locale);
|
|
868
|
+
} catch (e) {
|
|
869
|
+
plan.blocked.push({
|
|
870
|
+
...at,
|
|
871
|
+
reason: e.message
|
|
872
|
+
});
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
const difference = firstDifference(ast, back, locale);
|
|
876
|
+
if (difference !== void 0) plan.changed.push({
|
|
877
|
+
...at,
|
|
878
|
+
...difference
|
|
879
|
+
});
|
|
880
|
+
if (after !== text) plan.rewrites.push({
|
|
881
|
+
...at,
|
|
882
|
+
before: text,
|
|
883
|
+
after
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
return plan;
|
|
888
|
+
}
|
|
889
|
+
/** Writes the plan's rewrites into the YAML files, keeping their comments and layout. Returns the files written. */
|
|
890
|
+
function applyMigrate(config, plan) {
|
|
891
|
+
if (plan.blocked.length > 0) throw new Error("prosebook: some messages have no equivalent in the target syntax; nothing was written");
|
|
892
|
+
const byFile = Map.groupBy(plan.rewrites, (r) => r.file);
|
|
893
|
+
for (const [file, rewrites] of byFile) {
|
|
894
|
+
const path = join(config.root, file);
|
|
895
|
+
const doc = parseDocument(readFileSync(path, "utf8"), { version: "1.1" });
|
|
896
|
+
for (const r of rewrites) {
|
|
897
|
+
const value = doc.createNode(r.after);
|
|
898
|
+
if (r.after.includes("\n")) value.type = Scalar.BLOCK_LITERAL;
|
|
899
|
+
const entry = doc.get(r.key, true);
|
|
900
|
+
if (isMap(entry)) entry.set(config.source, value);
|
|
901
|
+
else doc.set(r.key, value);
|
|
902
|
+
}
|
|
903
|
+
writeFileSync(path, doc.toString({ lineWidth: 0 }));
|
|
904
|
+
}
|
|
905
|
+
return [...byFile.keys()];
|
|
906
|
+
}
|
|
907
|
+
const NUMBERS = [
|
|
908
|
+
0,
|
|
909
|
+
1,
|
|
910
|
+
2,
|
|
911
|
+
3,
|
|
912
|
+
5,
|
|
913
|
+
11,
|
|
914
|
+
21,
|
|
915
|
+
101,
|
|
916
|
+
1.5,
|
|
917
|
+
1234.5
|
|
918
|
+
];
|
|
919
|
+
const DATE = new Date(Date.UTC(2026, 0, 5, 14, 30, 15));
|
|
920
|
+
function samplesOf(type) {
|
|
921
|
+
if (type === "number") return NUMBERS;
|
|
922
|
+
if (type === "Date | number") return [DATE];
|
|
923
|
+
const named = [...type.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => JSON.parse(`"${m[1]}"`));
|
|
924
|
+
return named.length > 0 ? [...named, "zzz"] : ["Ada"];
|
|
925
|
+
}
|
|
926
|
+
/** Every combination of sample values, or each argument varied alone where there are too many. */
|
|
927
|
+
function* samples(a, b) {
|
|
928
|
+
const types = /* @__PURE__ */ new Map();
|
|
929
|
+
for (const [name, type] of [...argsOf(a), ...argsOf(b)]) {
|
|
930
|
+
const set = types.get(name) ?? /* @__PURE__ */ new Set();
|
|
931
|
+
for (const value of samplesOf(type)) set.add(value);
|
|
932
|
+
types.set(name, set);
|
|
933
|
+
}
|
|
934
|
+
const tags = Object.fromEntries([.../* @__PURE__ */ new Set([...tagsOf(a), ...tagsOf(b)])].map((t) => [t, (c) => `<${t}>${c.join("")}</${t}>`]));
|
|
935
|
+
const names = [...types.keys()];
|
|
936
|
+
const lists = names.map((n) => [...types.get(n)]);
|
|
937
|
+
if (lists.reduce((n, l) => n * l.length, 1) <= 1e3) {
|
|
938
|
+
let combos = [[]];
|
|
939
|
+
for (const list of lists) combos = combos.flatMap((c) => list.map((v) => [...c, v]));
|
|
940
|
+
for (const combo of combos) yield {
|
|
941
|
+
...tags,
|
|
942
|
+
...Object.fromEntries(names.map((n, i) => [n, combo[i]]))
|
|
943
|
+
};
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
const base = Object.fromEntries(names.map((n, i) => [n, lists[i][0]]));
|
|
947
|
+
for (const [i, n] of names.entries()) for (const value of lists[i]) yield {
|
|
948
|
+
...tags,
|
|
949
|
+
...base,
|
|
950
|
+
[n]: value
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
function renderer(ast, locale) {
|
|
954
|
+
const message = new Function(`return ${localeExpression(compileLocale([["m", ast]]))};`)().m;
|
|
955
|
+
const h = createHelpers(locale);
|
|
956
|
+
return (values) => typeof message === "string" ? message : flatten(message(values, h)).join("");
|
|
957
|
+
}
|
|
958
|
+
function firstDifference(a, b, locale) {
|
|
959
|
+
const before = renderer(a, locale);
|
|
960
|
+
const after = renderer(b, locale);
|
|
961
|
+
for (const values of samples(a, b)) {
|
|
962
|
+
const x = before(values);
|
|
963
|
+
const y = after(values);
|
|
964
|
+
if (x !== y) return {
|
|
965
|
+
values: `{ ${Object.entries(values).filter(([, v]) => typeof v !== "function").map(([k, v]) => `${k}: ${v instanceof Date ? v.toISOString() : JSON.stringify(v)}`).join(", ")} }`,
|
|
966
|
+
before: x,
|
|
967
|
+
after: y
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
// src/tools/sarif.ts
|
|
972
|
+
/**
|
|
973
|
+
* Findings as SARIF 2.1.0, the static-analysis format code scanning reads (GitHub's among them), so
|
|
974
|
+
* `lint`, `scan`, `status` and `check` can annotate a pull request on the line the string is written.
|
|
975
|
+
*
|
|
976
|
+
* Paths are relative to the project root under `%SRCROOT%`, which the log maps to the root itself:
|
|
977
|
+
* a project that isn't at the top of its repository still resolves to the right files.
|
|
978
|
+
*/
|
|
979
|
+
/** Collects what a command found, then writes it as one SARIF run. */
|
|
980
|
+
var SarifReport = class {
|
|
981
|
+
results = [];
|
|
982
|
+
rules = /* @__PURE__ */ new Map();
|
|
983
|
+
/** Describes a rule in one sentence; the first description given for an id stands. */
|
|
984
|
+
rule(id, description) {
|
|
985
|
+
if (this.rules.get(id) === void 0) this.rules.set(id, description);
|
|
986
|
+
}
|
|
987
|
+
add(result) {
|
|
988
|
+
if (!this.rules.has(result.rule)) this.rules.set(result.rule, void 0);
|
|
989
|
+
this.results.push(result);
|
|
990
|
+
}
|
|
991
|
+
toJSON(root, tool) {
|
|
992
|
+
const ids = [...this.rules.keys()];
|
|
993
|
+
return {
|
|
994
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
995
|
+
version: "2.1.0",
|
|
996
|
+
runs: [{
|
|
997
|
+
tool: { driver: {
|
|
998
|
+
...tool,
|
|
999
|
+
rules: ids.map((id) => {
|
|
1000
|
+
const description = this.rules.get(id);
|
|
1001
|
+
return description === void 0 ? { id } : {
|
|
1002
|
+
id,
|
|
1003
|
+
shortDescription: { text: description }
|
|
1004
|
+
};
|
|
1005
|
+
})
|
|
1006
|
+
} },
|
|
1007
|
+
originalUriBaseIds: { "%SRCROOT%": { uri: pathToFileURL(root).href.replace(/\/?$/, "/") } },
|
|
1008
|
+
results: this.results.map((r) => ({
|
|
1009
|
+
ruleId: r.rule,
|
|
1010
|
+
ruleIndex: ids.indexOf(r.rule),
|
|
1011
|
+
level: r.level,
|
|
1012
|
+
message: { text: r.message },
|
|
1013
|
+
locations: r.file === void 0 ? [] : [location(r.file, r.line, r.column)]
|
|
1014
|
+
}))
|
|
1015
|
+
}]
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
};
|
|
1019
|
+
function location(file, line, column) {
|
|
1020
|
+
return { physicalLocation: {
|
|
1021
|
+
artifactLocation: {
|
|
1022
|
+
uri: encodeURI(file),
|
|
1023
|
+
uriBaseId: "%SRCROOT%"
|
|
1024
|
+
},
|
|
1025
|
+
...line === void 0 ? {} : { region: {
|
|
1026
|
+
startLine: line,
|
|
1027
|
+
startColumn: column ?? 1
|
|
1028
|
+
} }
|
|
1029
|
+
} };
|
|
1030
|
+
}
|
|
1031
|
+
export { wordDiff as a, draftPlurals as c, syntaxKind as d, typescriptOracle as f, formatDiff as i, planExtract as l, applyMigrate as n, applyExtract as o, planMigrate as r, areaOf as s, SarifReport as t, slugOf as u };
|
|
1032
|
+
|
|
1033
|
+
//# sourceMappingURL=sarif-CI8HOokm.js.map
|