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
package/dist/scan-B0tZ7y-D.js
DELETED
|
@@ -1,2447 +0,0 @@
|
|
|
1
|
-
import { C as numberOptions, D as walk, E as tagsOf, S as formatsOf, T as staticLiteral, b as dateOptions, p as lint, u as applyEdits, v as TYPE$1, w as parseMessage$1, x as formatProblems, y as argsOf } from "./config-CxVMn19f.js";
|
|
2
|
-
import { dirname, extname, join, relative, resolve } from "node:path";
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { LineCounter, Scalar, isMap, isScalar, parse, parseDocument } from "yaml";
|
|
5
|
-
import { SKELETON_TYPE, TYPE, isDateElement, isNumberElement, isTimeElement, parse as parse$1 } from "@formatjs/icu-messageformat-parser";
|
|
6
|
-
import { MessageSyntaxError, parseMessage, stringifyMessage, validate } from "messageformat";
|
|
7
|
-
import { createHash } from "node:crypto";
|
|
8
|
-
import { parseSync } from "oxc-parser";
|
|
9
|
-
// src/tools/mf1.ts
|
|
10
|
-
/**
|
|
11
|
-
* The element tree written as MF1 (ICU MessageFormat) text, for `migrate`. A tree read from MF2 holds
|
|
12
|
-
* `Intl` options where MF1 holds skeletons, so each is written as the skeleton that parses back to the
|
|
13
|
-
* same options, or as the named style MF2's function was mapped from; where neither exists the
|
|
14
|
-
* message is reported rather than changed.
|
|
15
|
-
*/
|
|
16
|
-
function toMf1(ast) {
|
|
17
|
-
return print$1(native(ast, void 0), false);
|
|
18
|
-
}
|
|
19
|
-
const CATEGORY_ORDER = [
|
|
20
|
-
"zero",
|
|
21
|
-
"one",
|
|
22
|
-
"two",
|
|
23
|
-
"few",
|
|
24
|
-
"many",
|
|
25
|
-
"other"
|
|
26
|
-
];
|
|
27
|
-
/**
|
|
28
|
-
* Printed as a person would write it: `{n, plural, =0 {…} one {…} other {…}}`, exact arms first and
|
|
29
|
-
* `other` last. (formatjs's printer packs and reorders arms.)
|
|
30
|
-
*/
|
|
31
|
-
function print$1(elements, inPlural) {
|
|
32
|
-
return elements.map((el, i) => {
|
|
33
|
-
switch (el.type) {
|
|
34
|
-
case TYPE.literal: return escape(el.value, inPlural, elements[i + 1] === void 0);
|
|
35
|
-
case TYPE.argument: return `{${el.value}}`;
|
|
36
|
-
case TYPE.number:
|
|
37
|
-
case TYPE.date:
|
|
38
|
-
case TYPE.time: {
|
|
39
|
-
const kind = el.type === TYPE.number ? "number" : el.type === TYPE.date ? "date" : "time";
|
|
40
|
-
const style = el.style;
|
|
41
|
-
if (style === null || style === void 0) return `{${el.value}, ${kind}}`;
|
|
42
|
-
if (typeof style === "string") return `{${el.value}, ${kind}, ${style}}`;
|
|
43
|
-
const skeleton = "tokens" in style ? style.tokens.map((t) => [t.stem, ...t.options].join("/")).join(" ") : style.pattern;
|
|
44
|
-
return `{${el.value}, ${kind}, ::${skeleton}}`;
|
|
45
|
-
}
|
|
46
|
-
case TYPE.pound: return "#";
|
|
47
|
-
case TYPE.tag: return `<${el.value}>${print$1(el.children, inPlural)}</${el.value}>`;
|
|
48
|
-
case TYPE.select:
|
|
49
|
-
case TYPE.plural: {
|
|
50
|
-
const arms = Object.entries(el.options);
|
|
51
|
-
const rank = (arm) => arm.startsWith("=") ? -1 : arm === "other" ? 99 : Math.max(0, CATEGORY_ORDER.indexOf(arm));
|
|
52
|
-
if (el.type === TYPE.plural) arms.sort(([a], [b]) => rank(a) !== rank(b) ? rank(a) - rank(b) : Number(a.slice(1)) - Number(b.slice(1)));
|
|
53
|
-
else arms.sort(([a], [b]) => (a === "other" ? 1 : 0) - (b === "other" ? 1 : 0));
|
|
54
|
-
const head = el.type === TYPE.select ? "select" : el.pluralType === "ordinal" ? "selectordinal" : "plural";
|
|
55
|
-
const offset = el.type === TYPE.plural && el.offset !== 0 ? ` offset:${el.offset}` : "";
|
|
56
|
-
const body = arms.map(([arm, o]) => `${arm} {${print$1(o.value, el.type === TYPE.plural)}}`).join(" ");
|
|
57
|
-
return `{${el.value}, ${head},${offset} ${body}}`;
|
|
58
|
-
}
|
|
59
|
-
default: return "";
|
|
60
|
-
}
|
|
61
|
-
}).join("");
|
|
62
|
-
}
|
|
63
|
-
/**
|
|
64
|
-
* Literal text, quoted where ICU would read it as syntax. An apostrophe starts a quote only before a
|
|
65
|
-
* special character, so one before ordinary text (`don't`) stays single.
|
|
66
|
-
*/
|
|
67
|
-
function escape(text, inPlural, last) {
|
|
68
|
-
const special = (c) => c === "{" || c === "}" || c === "<" || inPlural && c === "#";
|
|
69
|
-
let out = "";
|
|
70
|
-
for (let i = 0; i < text.length; i++) {
|
|
71
|
-
const c = text[i];
|
|
72
|
-
if (special(c)) out += `'${c}'`;
|
|
73
|
-
else if (c === "'") out += special(text[i + 1]) || text[i + 1] === "'" || !last && i === text.length - 1 ? "''" : "'";
|
|
74
|
-
else out += c;
|
|
75
|
-
}
|
|
76
|
-
return out;
|
|
77
|
-
}
|
|
78
|
-
/** The tree with MF2 styles replaced by MF1 ones, and `#` where a plural prints its own value. */
|
|
79
|
-
function native(elements, plural) {
|
|
80
|
-
return elements.map((el) => {
|
|
81
|
-
if (isNumberElement(el)) {
|
|
82
|
-
if (plural?.offset === 0 && el.value === plural.value && plainNumber(el)) return { type: TYPE.pound };
|
|
83
|
-
return {
|
|
84
|
-
...el,
|
|
85
|
-
style: numberStyle$1(el)
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
if (isDateElement(el) || isTimeElement(el)) return dateElement(el);
|
|
89
|
-
if (el.type === TYPE.plural) {
|
|
90
|
-
const inner = {
|
|
91
|
-
value: el.value,
|
|
92
|
-
offset: el.offset
|
|
93
|
-
};
|
|
94
|
-
return {
|
|
95
|
-
...el,
|
|
96
|
-
options: Object.fromEntries(Object.entries(el.options).map(([k, o]) => [k, { value: native(o.value, inner) }]))
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
if (el.type === TYPE.select) return {
|
|
100
|
-
...el,
|
|
101
|
-
options: Object.fromEntries(Object.entries(el.options).map(([k, o]) => [k, { value: native(o.value, void 0) }]))
|
|
102
|
-
};
|
|
103
|
-
if (el.type === TYPE.tag) return {
|
|
104
|
-
...el,
|
|
105
|
-
children: native(el.children, plural)
|
|
106
|
-
};
|
|
107
|
-
return el;
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
function plainNumber(el) {
|
|
111
|
-
const style = el.style;
|
|
112
|
-
return style === null || style === void 0 || "mf2" in Object(style) && Object.keys(style.parsedOptions).length === 0;
|
|
113
|
-
}
|
|
114
|
-
function same(a, b) {
|
|
115
|
-
const norm = (o) => JSON.stringify(Object.entries(o).filter(([, v]) => v !== void 0).sort(([x], [y]) => x.localeCompare(y)));
|
|
116
|
-
return norm(a) === norm(b);
|
|
117
|
-
}
|
|
118
|
-
function numberStyle$1(el) {
|
|
119
|
-
const style = el.style;
|
|
120
|
-
if (style === null || style === void 0 || typeof style === "string" || !("mf2" in style)) return style;
|
|
121
|
-
const options = style.parsedOptions;
|
|
122
|
-
if (Object.keys(options).length === 0) return null;
|
|
123
|
-
if (same(options, { maximumFractionDigits: 0 })) return "integer";
|
|
124
|
-
if (same(options, { style: "percent" })) return "percent";
|
|
125
|
-
const skeleton = numberSkeleton(options);
|
|
126
|
-
const parsed = skeleton === void 0 ? void 0 : skeletonStyle(`{n, number, ::${skeleton}}`);
|
|
127
|
-
const relevant = (o) => {
|
|
128
|
-
const { currencyDisplay, unitDisplay, ...rest } = o;
|
|
129
|
-
return {
|
|
130
|
-
...rest,
|
|
131
|
-
...o.style === "currency" ? { currencyDisplay } : {},
|
|
132
|
-
...o.style === "unit" ? { unitDisplay } : {}
|
|
133
|
-
};
|
|
134
|
-
};
|
|
135
|
-
if (parsed === void 0 || !same(relevant(parsed.parsedOptions), relevant(options))) throw new Error(`{${el.value} ${mf2Text(style.mf2)}} formats in a way no MF1 skeleton says`);
|
|
136
|
-
return parsed;
|
|
137
|
-
}
|
|
138
|
-
function skeletonStyle(source) {
|
|
139
|
-
try {
|
|
140
|
-
const el = parse$1(source, { shouldParseSkeletons: true })[0];
|
|
141
|
-
return el !== void 0 && "style" in el && typeof el.style === "object" && el.style !== null ? el.style : void 0;
|
|
142
|
-
} catch {
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
/** The ICU number skeleton for `Intl` options, as far as formatjs reads skeletons back. */
|
|
147
|
-
function numberSkeleton(o) {
|
|
148
|
-
const tokens = [];
|
|
149
|
-
if (o.style === "percent") tokens.push("percent");
|
|
150
|
-
if (o.style === "currency") tokens.push(`currency/${String(o.currency)}`);
|
|
151
|
-
if (o.style === "unit") tokens.push(`unit/${String(o.unit)}`);
|
|
152
|
-
const width = o.style === "currency" ? {
|
|
153
|
-
narrowSymbol: "unit-width-narrow",
|
|
154
|
-
code: "unit-width-short",
|
|
155
|
-
name: "unit-width-full-name"
|
|
156
|
-
}[String(o.currencyDisplay)] : o.style === "unit" ? {
|
|
157
|
-
narrow: "unit-width-narrow",
|
|
158
|
-
short: "unit-width-short",
|
|
159
|
-
long: "unit-width-full-name"
|
|
160
|
-
}[String(o.unitDisplay)] : void 0;
|
|
161
|
-
if (width !== void 0) tokens.push(width);
|
|
162
|
-
if (o.currencySign === "accounting") tokens.push("sign-accounting");
|
|
163
|
-
const sign = {
|
|
164
|
-
always: "sign-always",
|
|
165
|
-
exceptZero: "sign-except-zero",
|
|
166
|
-
never: "sign-never"
|
|
167
|
-
}[String(o.signDisplay)];
|
|
168
|
-
if (sign !== void 0) tokens.push(sign);
|
|
169
|
-
if (o.useGrouping === false) tokens.push("group-off");
|
|
170
|
-
if (typeof o.minimumIntegerDigits === "number") tokens.push(`integer-width/*${"0".repeat(o.minimumIntegerDigits)}`);
|
|
171
|
-
const minF = o.minimumFractionDigits;
|
|
172
|
-
const maxF = o.maximumFractionDigits;
|
|
173
|
-
let fraction;
|
|
174
|
-
if (minF !== void 0 && maxF !== void 0) fraction = `.${"0".repeat(minF)}${"#".repeat(maxF - minF)}`;
|
|
175
|
-
else if (maxF !== void 0) fraction = maxF === 0 ? "." : `.${"#".repeat(maxF)}`;
|
|
176
|
-
else if (minF !== void 0) fraction = `.${"0".repeat(minF)}*`;
|
|
177
|
-
const minS = o.minimumSignificantDigits;
|
|
178
|
-
const maxS = o.maximumSignificantDigits;
|
|
179
|
-
let significant;
|
|
180
|
-
if (minS !== void 0 && maxS !== void 0) significant = `${"@".repeat(minS)}${"#".repeat(maxS - minS)}`;
|
|
181
|
-
else if (maxS !== void 0) significant = `@${"#".repeat(maxS - 1)}`;
|
|
182
|
-
else if (minS !== void 0) significant = `${"@".repeat(minS)}+`;
|
|
183
|
-
if (significant !== void 0) significant += o.roundingPriority === "morePrecision" ? "r" : o.roundingPriority === "lessPrecision" ? "s" : "";
|
|
184
|
-
if (fraction !== void 0) tokens.push(fraction + (o.trailingZeroDisplay === "stripIfInteger" ? "/w" : significant !== void 0 ? `/${significant}` : ""));
|
|
185
|
-
else if (significant !== void 0) tokens.push(significant);
|
|
186
|
-
const mode = {
|
|
187
|
-
floor: "rounding-mode-floor",
|
|
188
|
-
ceil: "rounding-mode-ceiling",
|
|
189
|
-
trunc: "rounding-mode-down",
|
|
190
|
-
expand: "rounding-mode-up",
|
|
191
|
-
halfEven: "rounding-mode-half-even",
|
|
192
|
-
halfTrunc: "rounding-mode-half-down",
|
|
193
|
-
halfExpand: "rounding-mode-half-up"
|
|
194
|
-
}[String(o.roundingMode)];
|
|
195
|
-
if (mode !== void 0) tokens.push(mode);
|
|
196
|
-
return tokens.length > 0 ? tokens.join(" ") : void 0;
|
|
197
|
-
}
|
|
198
|
-
/** MF2 date and time functions as the MF1 named style they were mapped from (see `dateFunction` in mf2.ts). */
|
|
199
|
-
const NAMED = {
|
|
200
|
-
"date length=short": [TYPE.date, "short"],
|
|
201
|
-
date: [TYPE.date, "medium"],
|
|
202
|
-
"date length=long": [TYPE.date, "long"],
|
|
203
|
-
"date fields=year-month-day-weekday length=long": [TYPE.date, "full"],
|
|
204
|
-
time: [TYPE.time, "short"],
|
|
205
|
-
"time precision=second": [TYPE.time, "medium"],
|
|
206
|
-
"time precision=second timeZoneStyle=short": [TYPE.time, "long"],
|
|
207
|
-
"time precision=second timeZoneStyle=long": [TYPE.time, "full"]
|
|
208
|
-
};
|
|
209
|
-
function dateElement(el) {
|
|
210
|
-
const style = el.style;
|
|
211
|
-
if (typeof style !== "object" || style === null || !("mf2" in style)) return el;
|
|
212
|
-
const fn = style.mf2;
|
|
213
|
-
const named = NAMED[mf2Text(fn, true).slice(1)];
|
|
214
|
-
if (named !== void 0) return {
|
|
215
|
-
type: named[0],
|
|
216
|
-
value: el.value,
|
|
217
|
-
style: named[1]
|
|
218
|
-
};
|
|
219
|
-
const skeleton = dateSkeleton(style.parsedOptions);
|
|
220
|
-
const parsed = skeleton === void 0 ? void 0 : skeletonStyle(`{d, date, ::${skeleton}}`);
|
|
221
|
-
if (parsed === void 0 || !same(parsed.parsedOptions, style.parsedOptions)) throw new Error(`{${el.value} ${mf2Text(fn)}} formats in a way no MF1 date skeleton says: use :date or :time with a length`);
|
|
222
|
-
return {
|
|
223
|
-
type: TYPE.date,
|
|
224
|
-
value: el.value,
|
|
225
|
-
style: parsed
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
function dateSkeleton(o) {
|
|
229
|
-
if (o.hour !== void 0 || o.timeZone !== void 0 || o.calendar !== void 0 || o.hour12 !== void 0) return void 0;
|
|
230
|
-
const letters = {
|
|
231
|
-
weekday: {
|
|
232
|
-
short: "EEE",
|
|
233
|
-
long: "EEEE",
|
|
234
|
-
narrow: "EEEEE"
|
|
235
|
-
},
|
|
236
|
-
year: {
|
|
237
|
-
numeric: "y",
|
|
238
|
-
"2-digit": "yy"
|
|
239
|
-
},
|
|
240
|
-
month: {
|
|
241
|
-
numeric: "M",
|
|
242
|
-
"2-digit": "MM",
|
|
243
|
-
short: "MMM",
|
|
244
|
-
long: "MMMM",
|
|
245
|
-
narrow: "MMMMM"
|
|
246
|
-
},
|
|
247
|
-
day: {
|
|
248
|
-
numeric: "d",
|
|
249
|
-
"2-digit": "dd"
|
|
250
|
-
}
|
|
251
|
-
};
|
|
252
|
-
let out = "";
|
|
253
|
-
for (const [field, forms] of Object.entries(letters)) {
|
|
254
|
-
const value = o[field];
|
|
255
|
-
if (value === void 0) continue;
|
|
256
|
-
const letter = forms[String(value)];
|
|
257
|
-
if (letter === void 0) return void 0;
|
|
258
|
-
out += letter;
|
|
259
|
-
}
|
|
260
|
-
return out === "" ? void 0 : out;
|
|
261
|
-
}
|
|
262
|
-
function mf2Text(fn, sortOptions = false) {
|
|
263
|
-
const options = Object.entries(fn.options);
|
|
264
|
-
if (sortOptions) options.sort(([a], [b]) => a.localeCompare(b));
|
|
265
|
-
return [`:${fn.name}`, ...options.map(([k, v]) => `${k}=${v}`)].join(" ");
|
|
266
|
-
}
|
|
267
|
-
// src/tools/mf2.ts
|
|
268
|
-
/**
|
|
269
|
-
* Unicode MessageFormat 2, read into the element tree MF1 messages parse to, so the compiler, the
|
|
270
|
-
* types, the lint and the pseudo-locale treat both syntaxes alike; and written back from that tree,
|
|
271
|
-
* for `extract` and `migrate`.
|
|
272
|
-
*
|
|
273
|
-
* The tree holds what a message does, not how it was written. `.input {$n :number}` becomes a number
|
|
274
|
-
* element at each `{$n}`, and `.match` becomes plural and select elements, one level per selector,
|
|
275
|
-
* built so the runtime's ICU-style selection picks the variant MF2's would (see `matchTree`). Option
|
|
276
|
-
* values are literals, resolved here into `Intl` options, so the runtime is the same for both.
|
|
277
|
-
*/
|
|
278
|
-
const NUMBER_LITERAL = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][-+]?\d+)?$/;
|
|
279
|
-
const CATEGORIES = /* @__PURE__ */ new Set([
|
|
280
|
-
"zero",
|
|
281
|
-
"one",
|
|
282
|
-
"two",
|
|
283
|
-
"few",
|
|
284
|
-
"many",
|
|
285
|
-
"other"
|
|
286
|
-
]);
|
|
287
|
-
const NAME = /^[\p{L}_][\p{L}\p{N}_.-]*$/u;
|
|
288
|
-
const DIGITS = [
|
|
289
|
-
"minimumIntegerDigits",
|
|
290
|
-
"minimumFractionDigits",
|
|
291
|
-
"maximumFractionDigits",
|
|
292
|
-
"minimumSignificantDigits",
|
|
293
|
-
"maximumSignificantDigits",
|
|
294
|
-
"roundingIncrement"
|
|
295
|
-
];
|
|
296
|
-
const ROUNDING = [
|
|
297
|
-
"trailingZeroDisplay",
|
|
298
|
-
"roundingPriority",
|
|
299
|
-
"roundingMode"
|
|
300
|
-
];
|
|
301
|
-
const SIGNIFICANT = ["minimumSignificantDigits", "maximumSignificantDigits"];
|
|
302
|
-
/** The options each default function takes, from the MF2 spec. */
|
|
303
|
-
const OPTIONS = {
|
|
304
|
-
string: [],
|
|
305
|
-
number: [
|
|
306
|
-
"select",
|
|
307
|
-
"signDisplay",
|
|
308
|
-
"useGrouping",
|
|
309
|
-
...DIGITS,
|
|
310
|
-
...ROUNDING
|
|
311
|
-
],
|
|
312
|
-
integer: [
|
|
313
|
-
"select",
|
|
314
|
-
"signDisplay",
|
|
315
|
-
"useGrouping",
|
|
316
|
-
"minimumIntegerDigits",
|
|
317
|
-
"maximumSignificantDigits"
|
|
318
|
-
],
|
|
319
|
-
currency: [
|
|
320
|
-
"currency",
|
|
321
|
-
"currencySign",
|
|
322
|
-
"currencyDisplay",
|
|
323
|
-
"useGrouping",
|
|
324
|
-
"minimumIntegerDigits",
|
|
325
|
-
"fractionDigits",
|
|
326
|
-
...SIGNIFICANT,
|
|
327
|
-
...ROUNDING,
|
|
328
|
-
"roundingIncrement"
|
|
329
|
-
],
|
|
330
|
-
percent: [
|
|
331
|
-
"signDisplay",
|
|
332
|
-
"useGrouping",
|
|
333
|
-
"minimumFractionDigits",
|
|
334
|
-
"maximumFractionDigits",
|
|
335
|
-
...SIGNIFICANT,
|
|
336
|
-
...ROUNDING
|
|
337
|
-
],
|
|
338
|
-
unit: [
|
|
339
|
-
"unit",
|
|
340
|
-
"unitDisplay",
|
|
341
|
-
"signDisplay",
|
|
342
|
-
"useGrouping",
|
|
343
|
-
...DIGITS,
|
|
344
|
-
...ROUNDING
|
|
345
|
-
],
|
|
346
|
-
offset: ["add", "subtract"],
|
|
347
|
-
date: [
|
|
348
|
-
"fields",
|
|
349
|
-
"length",
|
|
350
|
-
"calendar",
|
|
351
|
-
"timeZone"
|
|
352
|
-
],
|
|
353
|
-
time: [
|
|
354
|
-
"precision",
|
|
355
|
-
"timeZoneStyle",
|
|
356
|
-
"hour12",
|
|
357
|
-
"calendar",
|
|
358
|
-
"timeZone"
|
|
359
|
-
],
|
|
360
|
-
datetime: [
|
|
361
|
-
"dateFields",
|
|
362
|
-
"dateLength",
|
|
363
|
-
"timePrecision",
|
|
364
|
-
"timeZoneStyle",
|
|
365
|
-
"hour12",
|
|
366
|
-
"calendar",
|
|
367
|
-
"timeZone"
|
|
368
|
-
]
|
|
369
|
-
};
|
|
370
|
-
/** Options whose values are a fixed set, checked here because MF2's names differ from `Intl`'s. */
|
|
371
|
-
const DATE_FIELDS = [
|
|
372
|
-
"weekday",
|
|
373
|
-
"day-weekday",
|
|
374
|
-
"month-day",
|
|
375
|
-
"month-day-weekday",
|
|
376
|
-
"year-month-day",
|
|
377
|
-
"year-month-day-weekday"
|
|
378
|
-
];
|
|
379
|
-
const ENUMS = {
|
|
380
|
-
select: [
|
|
381
|
-
"plural",
|
|
382
|
-
"ordinal",
|
|
383
|
-
"exact"
|
|
384
|
-
],
|
|
385
|
-
useGrouping: [
|
|
386
|
-
"auto",
|
|
387
|
-
"always",
|
|
388
|
-
"never",
|
|
389
|
-
"min2"
|
|
390
|
-
],
|
|
391
|
-
currencyDisplay: [
|
|
392
|
-
"narrowSymbol",
|
|
393
|
-
"symbol",
|
|
394
|
-
"name",
|
|
395
|
-
"code"
|
|
396
|
-
],
|
|
397
|
-
fields: DATE_FIELDS,
|
|
398
|
-
dateFields: DATE_FIELDS,
|
|
399
|
-
length: [
|
|
400
|
-
"long",
|
|
401
|
-
"medium",
|
|
402
|
-
"short"
|
|
403
|
-
],
|
|
404
|
-
dateLength: [
|
|
405
|
-
"long",
|
|
406
|
-
"medium",
|
|
407
|
-
"short"
|
|
408
|
-
],
|
|
409
|
-
precision: [
|
|
410
|
-
"hour",
|
|
411
|
-
"minute",
|
|
412
|
-
"second"
|
|
413
|
-
],
|
|
414
|
-
timePrecision: [
|
|
415
|
-
"hour",
|
|
416
|
-
"minute",
|
|
417
|
-
"second"
|
|
418
|
-
],
|
|
419
|
-
timeZoneStyle: ["long", "short"],
|
|
420
|
-
hour12: ["true", "false"]
|
|
421
|
-
};
|
|
422
|
-
/** Options an operand's function doesn't pass on to these, per the spec. */
|
|
423
|
-
const DISCARDED = {
|
|
424
|
-
integer: [
|
|
425
|
-
"minimumFractionDigits",
|
|
426
|
-
"maximumFractionDigits",
|
|
427
|
-
"minimumSignificantDigits"
|
|
428
|
-
],
|
|
429
|
-
percent: [
|
|
430
|
-
"minimumIntegerDigits",
|
|
431
|
-
"roundingIncrement",
|
|
432
|
-
"select"
|
|
433
|
-
]
|
|
434
|
-
};
|
|
435
|
-
const NUMERIC = /* @__PURE__ */ new Set([
|
|
436
|
-
"number",
|
|
437
|
-
"integer",
|
|
438
|
-
"currency",
|
|
439
|
-
"percent",
|
|
440
|
-
"unit"
|
|
441
|
-
]);
|
|
442
|
-
const TEMPORAL = /* @__PURE__ */ new Set([
|
|
443
|
-
"date",
|
|
444
|
-
"time",
|
|
445
|
-
"datetime"
|
|
446
|
-
]);
|
|
447
|
-
const family = (name) => NUMERIC.has(name) ? "number" : TEMPORAL.has(name) ? "date" : name;
|
|
448
|
-
const HINTS = {
|
|
449
|
-
"missing-selector-annotation": "a .match variable needs a function that selects, declared first: .input {$count :number}",
|
|
450
|
-
"missing-fallback": "a .match needs a variant whose keys are all *",
|
|
451
|
-
"key-mismatch": "each variant has one key per .match variable",
|
|
452
|
-
"duplicate-variant": "two variants have the same keys",
|
|
453
|
-
"duplicate-declaration": "a variable is declared twice, or declared after a use",
|
|
454
|
-
"duplicate-option-name": "an option is given twice",
|
|
455
|
-
"duplicate-attribute": "an attribute is given twice",
|
|
456
|
-
"bad-escape": "only \\\\, \\{, \\} and \\| are escapes",
|
|
457
|
-
"bad-input-expression": ".input takes a variable: .input {$count :number}",
|
|
458
|
-
"extra-content": "something follows the end of the message: a message with .match ends with its last variant",
|
|
459
|
-
"empty-token": "a name or value is missing"
|
|
460
|
-
};
|
|
461
|
-
/** Thrown from `validate`'s callback, which reports a data-model error without throwing. */
|
|
462
|
-
var ModelError = class extends Error {
|
|
463
|
-
type;
|
|
464
|
-
constructor(type) {
|
|
465
|
-
super(type);
|
|
466
|
-
this.type = type;
|
|
467
|
-
}
|
|
468
|
-
};
|
|
469
|
-
function syntaxProblem(text, e) {
|
|
470
|
-
const type = e instanceof MessageSyntaxError || e instanceof ModelError ? e.type : void 0;
|
|
471
|
-
const hint = type === void 0 ? void 0 : HINTS[type];
|
|
472
|
-
let where = "";
|
|
473
|
-
if (e instanceof MessageSyntaxError) {
|
|
474
|
-
const before = text.slice(0, e.start).split("\n");
|
|
475
|
-
const near = text.slice(e.start, e.start + 16).split("\n")[0];
|
|
476
|
-
where = ` at line ${before.length}, column ${before.at(-1).length + 1}${near ? ` (near "${near}")` : ""}`;
|
|
477
|
-
}
|
|
478
|
-
const reason = hint ?? (e instanceof Error ? e.message.replace(/ at \d+$/, "") : String(e));
|
|
479
|
-
return `is not valid MessageFormat 2${where}: ${reason}`;
|
|
480
|
-
}
|
|
481
|
-
/** An MF2 message as the element tree. Throws, with the fix, where it can't be read or compiled. */
|
|
482
|
-
function parseMf2(text, locale) {
|
|
483
|
-
let message;
|
|
484
|
-
try {
|
|
485
|
-
message = parseMessage(text);
|
|
486
|
-
validate(message, (type) => {
|
|
487
|
-
throw new ModelError(type);
|
|
488
|
-
});
|
|
489
|
-
} catch (e) {
|
|
490
|
-
throw new Error(syntaxProblem(text, e), { cause: e });
|
|
491
|
-
}
|
|
492
|
-
const scope = /* @__PURE__ */ new Map();
|
|
493
|
-
for (const declaration of message.declarations) scope.set(declaration.name, resolve$1(declaration.value, declaration.type === "input" ? /* @__PURE__ */ new Map() : scope));
|
|
494
|
-
if (message.type === "message") return pattern(message.pattern, scope, locale);
|
|
495
|
-
const selectors = message.selectors.map((s) => selectorOf(s.name, scope));
|
|
496
|
-
return matchTree(selectors, message.variants.map((variant) => {
|
|
497
|
-
const keys = variant.keys.map((k) => k.type === "*" ? null : k.value);
|
|
498
|
-
keys.forEach((key, i) => checkKey(selectors[i], key, message.selectors[i].name));
|
|
499
|
-
return {
|
|
500
|
-
keys,
|
|
501
|
-
prefs: [],
|
|
502
|
-
value: pattern(variant.value, scope, locale)
|
|
503
|
-
};
|
|
504
|
-
}), 0, locale);
|
|
505
|
-
}
|
|
506
|
-
function functionOf(ref) {
|
|
507
|
-
const allowed = OPTIONS[ref.name];
|
|
508
|
-
if (allowed === void 0) {
|
|
509
|
-
const known = Object.keys(OPTIONS).map((n) => `:${n}`);
|
|
510
|
-
throw new Error(`:${ref.name} is not a function prosebook knows: ${known.join(", ")}`);
|
|
511
|
-
}
|
|
512
|
-
const options = {};
|
|
513
|
-
for (const [key, value] of Object.entries(ref.options ?? {})) {
|
|
514
|
-
if (key === "u:id" || key === "u:dir") continue;
|
|
515
|
-
if (value.type === "variable") throw new Error(`:${ref.name} ${key}=$${value.name}: option values are literals here, since formatting is fixed at build time`);
|
|
516
|
-
if (!allowed.includes(key)) throw new Error(`:${ref.name} has no option "${key}"${allowed.length > 0 ? `: ${allowed.join(", ")}` : ""}`);
|
|
517
|
-
const values = ENUMS[key];
|
|
518
|
-
if (values !== void 0 && !values.includes(value.value)) throw new Error(`:${ref.name} ${key}=${value.value} is not a value it takes: ${values.join(", ")}`);
|
|
519
|
-
if (DIGITS.includes(key) && !/^\d+$/.test(value.value)) throw new Error(`:${ref.name} ${key}=${value.value} is not a whole number`);
|
|
520
|
-
options[key] = value.value;
|
|
521
|
-
}
|
|
522
|
-
if (ref.name === "currency" && options.currency === void 0) throw new Error(":currency needs its currency named: {$price :currency currency=EUR}");
|
|
523
|
-
if (ref.name === "unit" && options.unit === void 0) throw new Error(":unit needs its unit named: {$distance :unit unit=kilometer}");
|
|
524
|
-
if (ref.name === "unit" && ref.options?.usage !== void 0) throw new Error(":unit usage converts between units, which `Intl` can't do: convert the value before passing it");
|
|
525
|
-
if (options.timeZone === "input") throw new Error(`:${ref.name} timeZone=input needs a zoned value, which a JavaScript Date isn't`);
|
|
526
|
-
if (ref.name === "offset") {
|
|
527
|
-
const given = ["add", "subtract"].filter((k) => options[k] !== void 0);
|
|
528
|
-
if (given.length !== 1 || !/^\d+$/.test(options[given[0]])) throw new Error(":offset takes one of add or subtract, as a whole number: {$count :offset subtract=1}");
|
|
529
|
-
}
|
|
530
|
-
return {
|
|
531
|
-
name: ref.name,
|
|
532
|
-
options
|
|
533
|
-
};
|
|
534
|
-
}
|
|
535
|
-
function resolve$1(expr, scope) {
|
|
536
|
-
const fn = expr.functionRef === void 0 ? void 0 : functionOf(expr.functionRef);
|
|
537
|
-
if (expr.arg === void 0) throw new Error(`{:${fn.name}} has nothing to format: give it a value, {$value :${fn.name}}`);
|
|
538
|
-
if (expr.arg.type === "literal") return apply({
|
|
539
|
-
kind: "literal",
|
|
540
|
-
value: expr.arg.value
|
|
541
|
-
}, fn);
|
|
542
|
-
return apply(scope.get(expr.arg.name) ?? {
|
|
543
|
-
kind: "variable",
|
|
544
|
-
name: expr.arg.name,
|
|
545
|
-
offset: 0
|
|
546
|
-
}, fn);
|
|
547
|
-
}
|
|
548
|
-
/** `fn` applied to a value that may already carry one: options carry over within a family, as the spec has it. */
|
|
549
|
-
function apply(base, fn) {
|
|
550
|
-
if (fn === void 0) return base;
|
|
551
|
-
if (fn.name === "offset") {
|
|
552
|
-
const by = Number(fn.options.subtract ?? 0) - Number(fn.options.add ?? 0);
|
|
553
|
-
const numeric = base.fn ?? {
|
|
554
|
-
name: "number",
|
|
555
|
-
options: {}
|
|
556
|
-
};
|
|
557
|
-
if (base.kind === "literal") return {
|
|
558
|
-
...base,
|
|
559
|
-
value: String(Number(literalNumber(base.value)) - by),
|
|
560
|
-
fn: numeric
|
|
561
|
-
};
|
|
562
|
-
return {
|
|
563
|
-
...base,
|
|
564
|
-
offset: base.offset + by,
|
|
565
|
-
fn: numeric
|
|
566
|
-
};
|
|
567
|
-
}
|
|
568
|
-
if (base.fn === void 0 || family(base.fn.name) !== family(fn.name)) return {
|
|
569
|
-
...base,
|
|
570
|
-
fn
|
|
571
|
-
};
|
|
572
|
-
const inherited = { ...base.fn.options };
|
|
573
|
-
for (const key of DISCARDED[fn.name] ?? []) delete inherited[key];
|
|
574
|
-
return {
|
|
575
|
-
...base,
|
|
576
|
-
fn: {
|
|
577
|
-
name: fn.name,
|
|
578
|
-
options: {
|
|
579
|
-
...inherited,
|
|
580
|
-
...fn.options
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
};
|
|
584
|
-
}
|
|
585
|
-
function literalNumber(value) {
|
|
586
|
-
if (!NUMBER_LITERAL.test(value)) throw new Error(`|${value}| is not a number: write it as one, like 42 or 1.5`);
|
|
587
|
-
return value;
|
|
588
|
-
}
|
|
589
|
-
function selectorOf(name, scope) {
|
|
590
|
-
const value = scope.get(name);
|
|
591
|
-
if (value === void 0 || value.fn === void 0) throw new Error(`.match $${name} needs a function: .input {$${name} :number}`);
|
|
592
|
-
if (value.kind === "literal") throw new Error(`.match $${name} selects on a literal, which always picks the same variant: use that variant's text`);
|
|
593
|
-
const { fn } = value;
|
|
594
|
-
if (fn.name === "string") return {
|
|
595
|
-
name: value.name,
|
|
596
|
-
kind: "string",
|
|
597
|
-
offset: 0
|
|
598
|
-
};
|
|
599
|
-
if (fn.name === "number" || fn.name === "integer") {
|
|
600
|
-
const kind = fn.options.select ?? "plural";
|
|
601
|
-
return {
|
|
602
|
-
name: value.name,
|
|
603
|
-
kind,
|
|
604
|
-
offset: value.offset
|
|
605
|
-
};
|
|
606
|
-
}
|
|
607
|
-
throw new Error(`.match $${name} selects with :${fn.name}, which prosebook can't select on: use :number, :integer or :string`);
|
|
608
|
-
}
|
|
609
|
-
function checkKey(selector, key, name) {
|
|
610
|
-
if (key === null) return;
|
|
611
|
-
if (selector.kind === "string") {
|
|
612
|
-
if (key === "other") throw new Error(`.match $${name} has a key "other", which can't be told from *: name it something else`);
|
|
613
|
-
return;
|
|
614
|
-
}
|
|
615
|
-
if (NUMBER_LITERAL.test(key)) {
|
|
616
|
-
if (String(Number(key)) !== key) throw new Error(`.match $${name} has the key ${key}: write it as ${Number(key)}, as values print`);
|
|
617
|
-
return;
|
|
618
|
-
}
|
|
619
|
-
if (selector.kind === "exact") throw new Error(`.match $${name} selects exactly, so its keys are numbers, not "${key}"`);
|
|
620
|
-
if (!CATEGORIES.has(key)) throw new Error(`.match $${name} has the key "${key}": a number key is a number or one of ${[...CATEGORIES].join(", ")}`);
|
|
621
|
-
}
|
|
622
|
-
/**
|
|
623
|
-
* `.match` as nested plural and select elements. MF2 picks, among the variants whose every key
|
|
624
|
-
* matches, the one whose keys match best, the first selector deciding first; an exact number beats
|
|
625
|
-
* a plural category, which beats `*`. The runtime picks an arm per selector instead, so each arm
|
|
626
|
-
* here holds every variant that can still match a value reaching it, and the leaf takes the best.
|
|
627
|
-
* An exact arm `=1` holds the variants keyed `one` as well, since 1 is also in that category: which
|
|
628
|
-
* category is the locale's, which is why this runs per locale.
|
|
629
|
-
*/
|
|
630
|
-
function matchTree(selectors, candidates, i, locale) {
|
|
631
|
-
if (i === selectors.length) return candidates.reduce((a, b) => compare(b.prefs, a.prefs) < 0 ? b : a).value;
|
|
632
|
-
const s = selectors[i];
|
|
633
|
-
const keys = [...new Set(candidates.map((c) => c.keys[i]).filter((k) => k !== null && k !== void 0))];
|
|
634
|
-
const options = {};
|
|
635
|
-
const arm = (name, preference) => {
|
|
636
|
-
const next = candidates.flatMap((c) => {
|
|
637
|
-
const p = preference(c.keys[i]);
|
|
638
|
-
return p === void 0 ? [] : [{
|
|
639
|
-
...c,
|
|
640
|
-
prefs: [...c.prefs, p]
|
|
641
|
-
}];
|
|
642
|
-
});
|
|
643
|
-
options[name] = { value: matchTree(selectors, next, i + 1, locale) };
|
|
644
|
-
};
|
|
645
|
-
if (s.kind === "string") {
|
|
646
|
-
for (const k of keys) arm(k, (key) => key === k ? 0 : key === null ? 1 : void 0);
|
|
647
|
-
arm("other", (key) => key === null ? 1 : void 0);
|
|
648
|
-
return [{
|
|
649
|
-
type: TYPE.select,
|
|
650
|
-
value: s.name,
|
|
651
|
-
options
|
|
652
|
-
}];
|
|
653
|
-
}
|
|
654
|
-
const rules = new Intl.PluralRules(locale, { type: s.kind === "ordinal" ? "ordinal" : "cardinal" });
|
|
655
|
-
for (const k of keys.filter((key) => NUMBER_LITERAL.test(key))) {
|
|
656
|
-
const category = s.kind === "exact" ? void 0 : rules.select(Number(k));
|
|
657
|
-
arm(`=${Number(k) + s.offset}`, (key) => key === k ? 0 : key === category ? 1 : key === null ? 2 : void 0);
|
|
658
|
-
}
|
|
659
|
-
if (s.kind !== "exact") {
|
|
660
|
-
const categories = keys.includes("other") ? rules.resolvedOptions().pluralCategories : keys.filter((k) => CATEGORIES.has(k));
|
|
661
|
-
for (const c of categories) arm(c, (key) => key === c ? 1 : key === null ? 2 : void 0);
|
|
662
|
-
}
|
|
663
|
-
if (!("other" in options)) arm("other", (key) => key === null ? 2 : void 0);
|
|
664
|
-
return [{
|
|
665
|
-
type: TYPE.plural,
|
|
666
|
-
value: s.name,
|
|
667
|
-
offset: s.offset,
|
|
668
|
-
pluralType: s.kind === "ordinal" ? "ordinal" : "cardinal",
|
|
669
|
-
options
|
|
670
|
-
}];
|
|
671
|
-
}
|
|
672
|
-
function compare(a, b) {
|
|
673
|
-
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return a[i] - b[i];
|
|
674
|
-
return 0;
|
|
675
|
-
}
|
|
676
|
-
function pattern(parts, scope, locale) {
|
|
677
|
-
const root = [];
|
|
678
|
-
const open = [];
|
|
679
|
-
const push = (el) => {
|
|
680
|
-
const into = open.at(-1)?.children ?? root;
|
|
681
|
-
const last = into.at(-1);
|
|
682
|
-
if (el.type === TYPE.literal && last?.type === TYPE.literal) last.value += el.value;
|
|
683
|
-
else into.push(el);
|
|
684
|
-
};
|
|
685
|
-
for (const part of parts) if (typeof part === "string") push({
|
|
686
|
-
type: TYPE.literal,
|
|
687
|
-
value: part
|
|
688
|
-
});
|
|
689
|
-
else if (part.type === "expression") push(element(resolve$1(part, scope), locale));
|
|
690
|
-
else if (part.kind === "close") {
|
|
691
|
-
const top = open.pop();
|
|
692
|
-
if (top?.name !== part.name) throw new Error(top ? `{/${part.name}} closes {#${top.name}}: close tags in the order they opened` : `{/${part.name}} closes nothing: open it with {#${part.name}}`);
|
|
693
|
-
push({
|
|
694
|
-
type: TYPE.tag,
|
|
695
|
-
value: top.name,
|
|
696
|
-
children: top.children
|
|
697
|
-
});
|
|
698
|
-
} else {
|
|
699
|
-
const options = Object.keys(part.options ?? {}).filter((k) => k !== "u:id");
|
|
700
|
-
if (options.length > 0) throw new Error(`{#${part.name}} has options (${options.join(", ")}): tags take none here, so give them to its function`);
|
|
701
|
-
if (part.kind === "open") open.push({
|
|
702
|
-
name: part.name,
|
|
703
|
-
children: []
|
|
704
|
-
});
|
|
705
|
-
else push({
|
|
706
|
-
type: TYPE.tag,
|
|
707
|
-
value: part.name,
|
|
708
|
-
children: []
|
|
709
|
-
});
|
|
710
|
-
}
|
|
711
|
-
if (open.length > 0) throw new Error(`{#${open.at(-1).name}} is never closed: end it with {/${open.at(-1).name}}`);
|
|
712
|
-
return root;
|
|
713
|
-
}
|
|
714
|
-
function element(value, locale) {
|
|
715
|
-
if (value.kind === "literal") return {
|
|
716
|
-
type: TYPE.literal,
|
|
717
|
-
value: formatLiteral(value, locale)
|
|
718
|
-
};
|
|
719
|
-
const { name, fn, offset } = value;
|
|
720
|
-
if (offset !== 0) return {
|
|
721
|
-
type: TYPE.plural,
|
|
722
|
-
value: name,
|
|
723
|
-
offset,
|
|
724
|
-
pluralType: "cardinal",
|
|
725
|
-
options: { other: { value: [{ type: TYPE.pound }] } }
|
|
726
|
-
};
|
|
727
|
-
if (fn === void 0 || fn.name === "string") return {
|
|
728
|
-
type: TYPE.argument,
|
|
729
|
-
value: name
|
|
730
|
-
};
|
|
731
|
-
if (NUMERIC.has(fn.name)) return {
|
|
732
|
-
type: TYPE.number,
|
|
733
|
-
value: name,
|
|
734
|
-
style: numberStyle(fn)
|
|
735
|
-
};
|
|
736
|
-
return {
|
|
737
|
-
type: TYPE.date,
|
|
738
|
-
value: name,
|
|
739
|
-
style: dateStyle(fn)
|
|
740
|
-
};
|
|
741
|
-
}
|
|
742
|
-
/** A literal with a function is the same in every render, so it is formatted now. */
|
|
743
|
-
function formatLiteral(value, locale) {
|
|
744
|
-
const { fn } = value;
|
|
745
|
-
if (fn === void 0 || fn.name === "string") return value.value;
|
|
746
|
-
if (NUMERIC.has(fn.name)) return new Intl.NumberFormat(locale, intlNumber(fn)).format(Number(literalNumber(value.value)));
|
|
747
|
-
const date = new Date(value.value);
|
|
748
|
-
if (Number.isNaN(date.getTime())) throw new Error(`|${value.value}| is not a date: write it as an ISO 8601 date, like 2026-01-05`);
|
|
749
|
-
return new Intl.DateTimeFormat(locale, intlDate(fn)).format(date);
|
|
750
|
-
}
|
|
751
|
-
function numberStyle(fn) {
|
|
752
|
-
return {
|
|
753
|
-
type: SKELETON_TYPE.number,
|
|
754
|
-
tokens: [],
|
|
755
|
-
parsedOptions: intlNumber(fn),
|
|
756
|
-
mf2: fn
|
|
757
|
-
};
|
|
758
|
-
}
|
|
759
|
-
function dateStyle(fn) {
|
|
760
|
-
return {
|
|
761
|
-
type: SKELETON_TYPE.dateTime,
|
|
762
|
-
pattern: "",
|
|
763
|
-
parsedOptions: intlDate(fn),
|
|
764
|
-
mf2: fn
|
|
765
|
-
};
|
|
766
|
-
}
|
|
767
|
-
/** The `Intl.NumberFormat` options a numeric function formats with. */
|
|
768
|
-
function intlNumber(fn) {
|
|
769
|
-
const allowed = OPTIONS[fn.name] ?? [];
|
|
770
|
-
const out = {};
|
|
771
|
-
for (const [key, value] of Object.entries(fn.options)) {
|
|
772
|
-
if (!allowed.includes(key) || key === "select") continue;
|
|
773
|
-
if (key === "useGrouping") out.useGrouping = value === "never" ? false : value;
|
|
774
|
-
else if (key === "fractionDigits") {
|
|
775
|
-
if (value !== "auto") out.minimumFractionDigits = out.maximumFractionDigits = Number(value);
|
|
776
|
-
} else if (DIGITS.includes(key)) out[key] = Number(value);
|
|
777
|
-
else out[key] = value;
|
|
778
|
-
}
|
|
779
|
-
if (fn.name === "integer") out.maximumFractionDigits = 0;
|
|
780
|
-
if (fn.name === "percent" || fn.name === "currency" || fn.name === "unit") out.style = fn.name;
|
|
781
|
-
return out;
|
|
782
|
-
}
|
|
783
|
-
/**
|
|
784
|
-
* The `Intl.DateTimeFormat` options a date or time function formats with. MF2's date and time
|
|
785
|
-
* functions are still a draft; this follows the spec's semantic options as the `messageformat`
|
|
786
|
-
* package does, so a message reads the same in both.
|
|
787
|
-
*/
|
|
788
|
-
function intlDate(fn) {
|
|
789
|
-
const o = fn.options;
|
|
790
|
-
const out = {};
|
|
791
|
-
if (o.calendar !== void 0) out.calendar = o.calendar;
|
|
792
|
-
if (fn.name !== "date" && o.hour12 !== void 0) out.hour12 = o.hour12 === "true";
|
|
793
|
-
if (o.timeZone !== void 0) out.timeZone = o.timeZone;
|
|
794
|
-
if (fn.name !== "time") {
|
|
795
|
-
const fields = new Set((o[fn.name === "date" ? "fields" : "dateFields"] ?? "year-month-day").split("-"));
|
|
796
|
-
const length = o[fn.name === "date" ? "length" : "dateLength"];
|
|
797
|
-
if (fields.has("year")) out.year = "numeric";
|
|
798
|
-
if (fields.has("month")) out.month = length === "long" ? "long" : length === "short" ? "numeric" : "short";
|
|
799
|
-
if (fields.has("day")) out.day = "numeric";
|
|
800
|
-
if (fields.has("weekday")) out.weekday = length === "long" ? "long" : "short";
|
|
801
|
-
}
|
|
802
|
-
if (fn.name !== "date") {
|
|
803
|
-
const precision = o[fn.name === "time" ? "precision" : "timePrecision"];
|
|
804
|
-
out.hour = "numeric";
|
|
805
|
-
if (precision !== "hour") out.minute = "numeric";
|
|
806
|
-
if (precision === "second") out.second = "numeric";
|
|
807
|
-
if (o.timeZoneStyle !== void 0) out.timeZoneName = o.timeZoneStyle;
|
|
808
|
-
}
|
|
809
|
-
return out;
|
|
810
|
-
}
|
|
811
|
-
/** A message tree as MF2 text. Throws, saying what has no MF2 equivalent, where it can't be written. */
|
|
812
|
-
function toMf2(ast, locale) {
|
|
813
|
-
const found = selectorsOf(ast);
|
|
814
|
-
if (found.length === 0) {
|
|
815
|
-
const pattern = toPattern(ast, {
|
|
816
|
-
choice: /* @__PURE__ */ new Map(),
|
|
817
|
-
variables: /* @__PURE__ */ new Map(),
|
|
818
|
-
numeric: /* @__PURE__ */ new Set(),
|
|
819
|
-
locale
|
|
820
|
-
});
|
|
821
|
-
return stringifyMessage({
|
|
822
|
-
type: "message",
|
|
823
|
-
declarations: [],
|
|
824
|
-
pattern
|
|
825
|
-
});
|
|
826
|
-
}
|
|
827
|
-
const declarations = [];
|
|
828
|
-
for (const s of found) {
|
|
829
|
-
const fn = s.kind === "string" ? functionRef("string") : functionRef("number", s.kind === "ordinal" ? { select: "ordinal" } : {});
|
|
830
|
-
declarations.push({
|
|
831
|
-
type: "input",
|
|
832
|
-
name: s.arg,
|
|
833
|
-
value: {
|
|
834
|
-
type: "expression",
|
|
835
|
-
arg: variable(s.arg),
|
|
836
|
-
functionRef: fn
|
|
837
|
-
}
|
|
838
|
-
});
|
|
839
|
-
if (s.offset !== 0) declarations.push({
|
|
840
|
-
type: "local",
|
|
841
|
-
name: s.variable,
|
|
842
|
-
value: {
|
|
843
|
-
type: "expression",
|
|
844
|
-
arg: variable(s.arg),
|
|
845
|
-
functionRef: functionRef("offset", { subtract: String(s.offset) })
|
|
846
|
-
}
|
|
847
|
-
});
|
|
848
|
-
}
|
|
849
|
-
let combos = [[]];
|
|
850
|
-
for (const s of found) combos = combos.flatMap((c) => [...s.keys, null].map((k) => [...c, k]));
|
|
851
|
-
const variables = new Map(found.map((s) => [s.arg, s.variable]));
|
|
852
|
-
const numeric = new Set(found.filter((s) => s.kind !== "string").map((s) => s.arg));
|
|
853
|
-
const render = (keys) => toPattern(ast, {
|
|
854
|
-
choice: new Map(found.map((s, i) => [s.arg, keys[i]])),
|
|
855
|
-
variables,
|
|
856
|
-
numeric,
|
|
857
|
-
locale
|
|
858
|
-
});
|
|
859
|
-
const variants = combos.map((keys) => ({
|
|
860
|
-
keys,
|
|
861
|
-
pattern: render(keys),
|
|
862
|
-
text: ""
|
|
863
|
-
}));
|
|
864
|
-
for (const v of variants) v.text = JSON.stringify(v.pattern);
|
|
865
|
-
const kept = prune(variants, found, locale);
|
|
866
|
-
return stringifyMessage({
|
|
867
|
-
type: "select",
|
|
868
|
-
declarations,
|
|
869
|
-
selectors: found.map((s) => variable(s.variable)),
|
|
870
|
-
variants: kept.map((v) => ({
|
|
871
|
-
keys: v.keys.map((k) => k === null ? { type: "*" } : {
|
|
872
|
-
type: "literal",
|
|
873
|
-
value: k
|
|
874
|
-
}),
|
|
875
|
-
value: v.pattern
|
|
876
|
-
}))
|
|
877
|
-
});
|
|
878
|
-
}
|
|
879
|
-
function selectorsOf(ast) {
|
|
880
|
-
const found = /* @__PURE__ */ new Map();
|
|
881
|
-
const names = /* @__PURE__ */ new Set();
|
|
882
|
-
walk(ast, (el) => {
|
|
883
|
-
if ("value" in el && typeof el.value === "string" && el.type !== TYPE.literal) names.add(el.value);
|
|
884
|
-
});
|
|
885
|
-
walk(ast, (el) => {
|
|
886
|
-
if (el.type !== TYPE.plural && el.type !== TYPE.select) return;
|
|
887
|
-
const kind = el.type === TYPE.select ? "string" : el.pluralType === "ordinal" ? "ordinal" : "plural";
|
|
888
|
-
const offset = el.type === TYPE.plural ? el.offset : 0;
|
|
889
|
-
let s = found.get(el.value);
|
|
890
|
-
if (s === void 0) {
|
|
891
|
-
checkName(el.value);
|
|
892
|
-
let local = `${el.value}_minus_${offset}`;
|
|
893
|
-
while (names.has(local)) local += "_";
|
|
894
|
-
s = {
|
|
895
|
-
arg: el.value,
|
|
896
|
-
variable: offset === 0 ? el.value : local,
|
|
897
|
-
kind,
|
|
898
|
-
offset,
|
|
899
|
-
keys: []
|
|
900
|
-
};
|
|
901
|
-
found.set(el.value, s);
|
|
902
|
-
} else if (s.kind !== kind || s.offset !== offset) throw new Error(`{${el.value}} is selected on in two ways (${s.kind}, ${kind}${offset !== s.offset ? ", with different offsets" : ""}), which one .match can't say: make them the same`);
|
|
903
|
-
for (const arm of Object.keys(el.options)) {
|
|
904
|
-
if (arm === "other") continue;
|
|
905
|
-
const key = el.type === TYPE.plural && arm.startsWith("=") ? String(Number(arm.slice(1)) - offset) : arm;
|
|
906
|
-
if (!s.keys.includes(key)) s.keys.push(key);
|
|
907
|
-
}
|
|
908
|
-
});
|
|
909
|
-
return [...found.values()];
|
|
910
|
-
}
|
|
911
|
-
/**
|
|
912
|
-
* The variants a reader needs: one that renders like the variant it would fall back to is dropped,
|
|
913
|
-
* as long as every combination of values still lands on a variant rendering what it should.
|
|
914
|
-
*/
|
|
915
|
-
function prune(variants, found, locale) {
|
|
916
|
-
const rules = found.map((s) => s.kind === "string" ? void 0 : new Intl.PluralRules(locale, { type: s.kind === "ordinal" ? "ordinal" : "cardinal" }));
|
|
917
|
-
const preference = (i, key, value) => {
|
|
918
|
-
if (key === null) return 2;
|
|
919
|
-
if (value === null) return void 0;
|
|
920
|
-
if (key === value) return NUMBER_LITERAL.test(key) || rules[i] === void 0 ? 0 : 1;
|
|
921
|
-
if (rules[i] !== void 0 && NUMBER_LITERAL.test(value) && rules[i].select(Number(value)) === key) return 1;
|
|
922
|
-
};
|
|
923
|
-
const pick = (kept, values) => {
|
|
924
|
-
let best;
|
|
925
|
-
for (const v of kept) {
|
|
926
|
-
const prefs = values.map((value, i) => preference(i, v.keys[i], value));
|
|
927
|
-
if (prefs.some((p) => p === void 0)) continue;
|
|
928
|
-
if (best === void 0 || compare(prefs, best.prefs) < 0) best = {
|
|
929
|
-
v,
|
|
930
|
-
prefs
|
|
931
|
-
};
|
|
932
|
-
}
|
|
933
|
-
return best?.v;
|
|
934
|
-
};
|
|
935
|
-
let kept = [...variants];
|
|
936
|
-
const specificFirst = [...variants].sort((a, b) => b.keys.filter((k) => k !== null).length - a.keys.filter((k) => k !== null).length);
|
|
937
|
-
for (const candidate of specificFirst) {
|
|
938
|
-
if (candidate.keys.every((k) => k === null)) continue;
|
|
939
|
-
const without = kept.filter((v) => v !== candidate);
|
|
940
|
-
if (variants.every((v) => pick(without, v.keys)?.text === v.text)) kept = without;
|
|
941
|
-
}
|
|
942
|
-
return kept;
|
|
943
|
-
}
|
|
944
|
-
function toPattern(elements, w, pound) {
|
|
945
|
-
const out = [];
|
|
946
|
-
const text = (value) => {
|
|
947
|
-
const last = out.at(-1);
|
|
948
|
-
if (typeof last === "string") out[out.length - 1] = last + value;
|
|
949
|
-
else if (value !== "") out.push(value);
|
|
950
|
-
};
|
|
951
|
-
for (const el of elements) switch (el.type) {
|
|
952
|
-
case TYPE.literal:
|
|
953
|
-
text(el.value);
|
|
954
|
-
break;
|
|
955
|
-
case TYPE.argument:
|
|
956
|
-
checkName(el.value);
|
|
957
|
-
out.push({
|
|
958
|
-
type: "expression",
|
|
959
|
-
arg: variable(el.value)
|
|
960
|
-
});
|
|
961
|
-
break;
|
|
962
|
-
case TYPE.number: {
|
|
963
|
-
checkName(el.value);
|
|
964
|
-
const fn = numberFunction(el);
|
|
965
|
-
const declared = w.numeric.has(el.value) && fn.name === "number" && fn.options === void 0;
|
|
966
|
-
out.push(declared ? {
|
|
967
|
-
type: "expression",
|
|
968
|
-
arg: variable(el.value)
|
|
969
|
-
} : {
|
|
970
|
-
type: "expression",
|
|
971
|
-
arg: variable(el.value),
|
|
972
|
-
functionRef: fn
|
|
973
|
-
});
|
|
974
|
-
break;
|
|
975
|
-
}
|
|
976
|
-
case TYPE.date:
|
|
977
|
-
case TYPE.time:
|
|
978
|
-
checkName(el.value);
|
|
979
|
-
out.push({
|
|
980
|
-
type: "expression",
|
|
981
|
-
arg: variable(el.value),
|
|
982
|
-
functionRef: dateFunction(el)
|
|
983
|
-
});
|
|
984
|
-
break;
|
|
985
|
-
case TYPE.pound:
|
|
986
|
-
out.push({
|
|
987
|
-
type: "expression",
|
|
988
|
-
arg: variable(pound ?? "")
|
|
989
|
-
});
|
|
990
|
-
break;
|
|
991
|
-
case TYPE.plural:
|
|
992
|
-
case TYPE.select: {
|
|
993
|
-
const arm = armFor(el, w.choice.get(el.value) ?? null, w.locale);
|
|
994
|
-
const own = w.variables.get(el.value) ?? el.value;
|
|
995
|
-
for (const part of toPattern(arm, w, el.type === TYPE.plural ? own : pound)) if (typeof part === "string") text(part);
|
|
996
|
-
else out.push(part);
|
|
997
|
-
break;
|
|
998
|
-
}
|
|
999
|
-
case TYPE.tag:
|
|
1000
|
-
checkName(el.value);
|
|
1001
|
-
out.push({
|
|
1002
|
-
type: "markup",
|
|
1003
|
-
kind: "open",
|
|
1004
|
-
name: el.value
|
|
1005
|
-
});
|
|
1006
|
-
for (const part of toPattern(el.children, w, pound)) if (typeof part === "string") text(part);
|
|
1007
|
-
else out.push(part);
|
|
1008
|
-
out.push({
|
|
1009
|
-
type: "markup",
|
|
1010
|
-
kind: "close",
|
|
1011
|
-
name: el.value
|
|
1012
|
-
});
|
|
1013
|
-
}
|
|
1014
|
-
return out;
|
|
1015
|
-
}
|
|
1016
|
-
function armFor(el, key, locale) {
|
|
1017
|
-
const other = el.options.other.value;
|
|
1018
|
-
if (key === null) return other;
|
|
1019
|
-
if (el.type === TYPE.plural && NUMBER_LITERAL.test(key)) {
|
|
1020
|
-
const exact = el.options[`=${Number(key) + el.offset}`];
|
|
1021
|
-
if (exact !== void 0) return exact.value;
|
|
1022
|
-
const category = new Intl.PluralRules(locale, { type: el.pluralType }).select(Number(key));
|
|
1023
|
-
return el.options[category]?.value ?? other;
|
|
1024
|
-
}
|
|
1025
|
-
return el.options[key]?.value ?? other;
|
|
1026
|
-
}
|
|
1027
|
-
function checkName(name) {
|
|
1028
|
-
if (!NAME.test(name)) throw new Error(`"${name}" can't be a MessageFormat 2 name: start it with a letter`);
|
|
1029
|
-
}
|
|
1030
|
-
function variable(name) {
|
|
1031
|
-
return {
|
|
1032
|
-
type: "variable",
|
|
1033
|
-
name
|
|
1034
|
-
};
|
|
1035
|
-
}
|
|
1036
|
-
function functionRef(name, options = {}) {
|
|
1037
|
-
const entries = Object.entries(options);
|
|
1038
|
-
if (entries.length === 0) return {
|
|
1039
|
-
type: "function",
|
|
1040
|
-
name
|
|
1041
|
-
};
|
|
1042
|
-
return {
|
|
1043
|
-
type: "function",
|
|
1044
|
-
name,
|
|
1045
|
-
options: Object.fromEntries(entries.map(([k, v]) => [k, {
|
|
1046
|
-
type: "literal",
|
|
1047
|
-
value: v
|
|
1048
|
-
}]))
|
|
1049
|
-
};
|
|
1050
|
-
}
|
|
1051
|
-
function numberFunction(el) {
|
|
1052
|
-
const style = el.style;
|
|
1053
|
-
if (style === null || style === void 0) return functionRef("number");
|
|
1054
|
-
if (typeof style === "string") {
|
|
1055
|
-
if (style === "integer" || style === "percent") return functionRef(style);
|
|
1056
|
-
throw new Error(`{${el.value}, number, ${style}} has no MessageFormat 2 equivalent`);
|
|
1057
|
-
}
|
|
1058
|
-
if ("mf2" in style) return functionRef(style.mf2.name, style.mf2.options);
|
|
1059
|
-
const { style: kind, currency, currencyDisplay, currencySign, unit, unitDisplay, ...rest } = style.parsedOptions;
|
|
1060
|
-
const name = kind === "percent" || kind === "currency" || kind === "unit" ? kind : "number";
|
|
1061
|
-
const options = {};
|
|
1062
|
-
const own = name === "currency" ? {
|
|
1063
|
-
currency,
|
|
1064
|
-
currencyDisplay,
|
|
1065
|
-
currencySign
|
|
1066
|
-
} : name === "unit" ? {
|
|
1067
|
-
unit,
|
|
1068
|
-
unitDisplay
|
|
1069
|
-
} : {};
|
|
1070
|
-
for (const [key, value] of Object.entries(own)) if (value !== void 0) options[key] = String(value);
|
|
1071
|
-
if (name === "currency" && rest.minimumFractionDigits !== void 0 && rest.minimumFractionDigits === rest.maximumFractionDigits) {
|
|
1072
|
-
options.fractionDigits = String(rest.minimumFractionDigits);
|
|
1073
|
-
delete rest.minimumFractionDigits;
|
|
1074
|
-
delete rest.maximumFractionDigits;
|
|
1075
|
-
}
|
|
1076
|
-
for (const [key, value] of Object.entries(rest)) {
|
|
1077
|
-
if (value === void 0) continue;
|
|
1078
|
-
if (key === "useGrouping") options.useGrouping = value === false ? "never" : String(value);
|
|
1079
|
-
else if (OPTIONS[name].includes(key) && key !== "select") options[key] = String(value);
|
|
1080
|
-
else throw new Error(`{${el.value}}'s ${key}: ${String(value)} has no MessageFormat 2 equivalent`);
|
|
1081
|
-
}
|
|
1082
|
-
return functionRef(name, options);
|
|
1083
|
-
}
|
|
1084
|
-
/**
|
|
1085
|
-
* The MF2 function for a date or time element. MF1's named styles have no exact counterpart, so they
|
|
1086
|
-
* map to the nearest; `migrate` renders both and reports any that come out different.
|
|
1087
|
-
*/
|
|
1088
|
-
function dateFunction(el) {
|
|
1089
|
-
const style = el.style;
|
|
1090
|
-
if (typeof style === "object" && style !== null && "mf2" in style) return functionRef(style.mf2.name, style.mf2.options);
|
|
1091
|
-
if (el.type === TYPE.time && (style === null || style === void 0 || typeof style === "string")) return functionRef("time", {
|
|
1092
|
-
short: {},
|
|
1093
|
-
medium: { precision: "second" },
|
|
1094
|
-
long: {
|
|
1095
|
-
precision: "second",
|
|
1096
|
-
timeZoneStyle: "short"
|
|
1097
|
-
},
|
|
1098
|
-
full: {
|
|
1099
|
-
precision: "second",
|
|
1100
|
-
timeZoneStyle: "long"
|
|
1101
|
-
}
|
|
1102
|
-
}[style ?? "short"] ?? {});
|
|
1103
|
-
if (style === null || style === void 0 || typeof style === "string") return functionRef("date", {
|
|
1104
|
-
default: { length: "short" },
|
|
1105
|
-
short: { length: "short" },
|
|
1106
|
-
medium: {},
|
|
1107
|
-
long: { length: "long" },
|
|
1108
|
-
full: {
|
|
1109
|
-
fields: "year-month-day-weekday",
|
|
1110
|
-
length: "long"
|
|
1111
|
-
}
|
|
1112
|
-
}[style ?? "default"] ?? {});
|
|
1113
|
-
const { calendar, timeZone, hour12, ...fields } = style.parsedOptions;
|
|
1114
|
-
const overrides = {};
|
|
1115
|
-
if (calendar !== void 0) overrides.calendar = calendar;
|
|
1116
|
-
if (timeZone !== void 0) overrides.timeZone = timeZone;
|
|
1117
|
-
if (hour12 !== void 0) overrides.hour12 = String(hour12);
|
|
1118
|
-
const wanted = JSON.stringify(sorted(fields));
|
|
1119
|
-
for (const candidate of dateCandidates()) {
|
|
1120
|
-
const { calendar: _c, timeZone: _t, hour12: _h, ...got } = intlDate(candidate);
|
|
1121
|
-
if (JSON.stringify(sorted(got)) === wanted) return functionRef(candidate.name, {
|
|
1122
|
-
...candidate.options,
|
|
1123
|
-
...overrides
|
|
1124
|
-
});
|
|
1125
|
-
}
|
|
1126
|
-
throw new Error(`{${el.value}}'s date skeleton "${style.pattern}" has no MessageFormat 2 equivalent: use a named style`);
|
|
1127
|
-
}
|
|
1128
|
-
function sorted(o) {
|
|
1129
|
-
return Object.entries(o).filter(([, v]) => v !== void 0).sort(([a], [b]) => a.localeCompare(b));
|
|
1130
|
-
}
|
|
1131
|
-
function* dateCandidates() {
|
|
1132
|
-
const lengths = [
|
|
1133
|
-
void 0,
|
|
1134
|
-
"long",
|
|
1135
|
-
"short"
|
|
1136
|
-
];
|
|
1137
|
-
const precisions = [
|
|
1138
|
-
void 0,
|
|
1139
|
-
"hour",
|
|
1140
|
-
"second"
|
|
1141
|
-
];
|
|
1142
|
-
const zones = [
|
|
1143
|
-
void 0,
|
|
1144
|
-
"long",
|
|
1145
|
-
"short"
|
|
1146
|
-
];
|
|
1147
|
-
const defined = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
|
|
1148
|
-
const dates = [void 0, ...DATE_FIELDS.filter((f) => f !== "year-month-day")].flatMap((fields) => lengths.map((length) => ({
|
|
1149
|
-
fields,
|
|
1150
|
-
length
|
|
1151
|
-
})));
|
|
1152
|
-
const times = precisions.flatMap((precision) => zones.map((timeZoneStyle) => ({
|
|
1153
|
-
precision,
|
|
1154
|
-
timeZoneStyle
|
|
1155
|
-
})));
|
|
1156
|
-
for (const d of dates) yield {
|
|
1157
|
-
name: "date",
|
|
1158
|
-
options: defined(d)
|
|
1159
|
-
};
|
|
1160
|
-
for (const t of times) yield {
|
|
1161
|
-
name: "time",
|
|
1162
|
-
options: defined(t)
|
|
1163
|
-
};
|
|
1164
|
-
for (const d of dates) for (const t of times) yield {
|
|
1165
|
-
name: "datetime",
|
|
1166
|
-
options: defined({
|
|
1167
|
-
dateFields: d.fields,
|
|
1168
|
-
dateLength: d.length,
|
|
1169
|
-
timePrecision: t.precision,
|
|
1170
|
-
timeZoneStyle: t.timeZoneStyle
|
|
1171
|
-
})
|
|
1172
|
-
};
|
|
1173
|
-
}
|
|
1174
|
-
// src/tools/syntax.ts
|
|
1175
|
-
/**
|
|
1176
|
-
* The two syntaxes a catalog can be written in, behind one pair of calls: read a message into the
|
|
1177
|
-
* element tree, and write a tree back out. Everything past reading works on the tree alone.
|
|
1178
|
-
*/
|
|
1179
|
-
const SYNTAX_NAME = {
|
|
1180
|
-
1: "ICU MessageFormat",
|
|
1181
|
-
2: "MessageFormat 2"
|
|
1182
|
-
};
|
|
1183
|
-
/**
|
|
1184
|
-
* A message as the element tree. MF2 is read per locale: which variants a value can reach depends
|
|
1185
|
-
* on the locale's plural rules (see `matchTree` in mf2.ts).
|
|
1186
|
-
*/
|
|
1187
|
-
function readMessage(version, text, locale) {
|
|
1188
|
-
return version === 2 ? parseMf2(text, locale) : parseMessage$1(text);
|
|
1189
|
-
}
|
|
1190
|
-
/** A tree as text in the given syntax. Throws, saying what the syntax can't express, where it can't. */
|
|
1191
|
-
function writeMessage(version, ast, locale) {
|
|
1192
|
-
return version === 2 ? toMf2(ast, locale) : toMf1(ast);
|
|
1193
|
-
}
|
|
1194
|
-
// src/tools/catalog.ts
|
|
1195
|
-
/**
|
|
1196
|
-
* Reads the catalog and its translations into one checked model.
|
|
1197
|
-
*
|
|
1198
|
-
* Every message is parsed as ICU here rather than at runtime, which is what lets the generator hand
|
|
1199
|
-
* TypeScript exact argument types, compile messages to plain functions, and fail the build where a
|
|
1200
|
-
* translation's placeholders drifted from the source instead of dropping a value off the screen.
|
|
1201
|
-
*/
|
|
1202
|
-
/** `file key`, as the CLI names where a problem is. */
|
|
1203
|
-
function whereOf(problem) {
|
|
1204
|
-
return [problem.file, problem.key].filter((s) => s !== void 0).join(" ");
|
|
1205
|
-
}
|
|
1206
|
-
var CatalogError = class extends Error {
|
|
1207
|
-
problems;
|
|
1208
|
-
constructor(problems) {
|
|
1209
|
-
super(`the catalog has ${problems.length} problem(s):\n ${problems.map((p) => `${whereOf(p)}: ${p.message}`).join("\n ")}`);
|
|
1210
|
-
this.problems = problems;
|
|
1211
|
-
}
|
|
1212
|
-
};
|
|
1213
|
-
/**
|
|
1214
|
-
* A key is dotted lowercase, two segments at least, and its first segment names the file it lives
|
|
1215
|
-
* in. Keys are read at every call site, so they say what the string is for: `cart.checkout.title`.
|
|
1216
|
-
*/
|
|
1217
|
-
const KEY_PATTERN = /^[a-z0-9]+(?:[-_][a-z0-9]+)*(?:\.[a-z0-9]+(?:[-_][a-z0-9]+)*)+$/;
|
|
1218
|
-
const FIELDS = /* @__PURE__ */ new Set([
|
|
1219
|
-
"kind",
|
|
1220
|
-
"surface",
|
|
1221
|
-
"context",
|
|
1222
|
-
"args",
|
|
1223
|
-
"fragment",
|
|
1224
|
-
"allow",
|
|
1225
|
-
"comment",
|
|
1226
|
-
"remove"
|
|
1227
|
-
]);
|
|
1228
|
-
/**
|
|
1229
|
-
* Loads and checks the catalog. Throws a `CatalogError` when anything is an error; warnings ride along.
|
|
1230
|
-
* `unsaved` holds file contents by absolute path to read in place of what is on disk, so an edit can be
|
|
1231
|
-
* checked before it is written; a translation file in it that isn't on disk yet is read too.
|
|
1232
|
-
*/
|
|
1233
|
-
function loadCatalog(config, unsaved) {
|
|
1234
|
-
const read = (path) => unsaved?.get(resolve(path)) ?? readFileSync(path, "utf8");
|
|
1235
|
-
const problems = [];
|
|
1236
|
-
const error = (message, file, key) => problems.push({
|
|
1237
|
-
severity: "error",
|
|
1238
|
-
file,
|
|
1239
|
-
key,
|
|
1240
|
-
message
|
|
1241
|
-
});
|
|
1242
|
-
const byKey = /* @__PURE__ */ new Map();
|
|
1243
|
-
const rel = (path) => relative(config.root, path).replaceAll("\\", "/");
|
|
1244
|
-
if (!existsSync(config.catalog)) {
|
|
1245
|
-
error("no catalog folder here: run `prosebook init`, or set `catalog` in the config", rel(config.catalog));
|
|
1246
|
-
throw new CatalogError(problems);
|
|
1247
|
-
}
|
|
1248
|
-
for (const file of yamlFiles(config.catalog)) {
|
|
1249
|
-
const area = file.slice(0, -5);
|
|
1250
|
-
const path = join(config.catalog, file);
|
|
1251
|
-
const at = rel(path);
|
|
1252
|
-
for (const [key, value] of Object.entries(readEntries(read(path), (m) => error(m, at)))) {
|
|
1253
|
-
if (!KEY_PATTERN.test(key)) {
|
|
1254
|
-
error("a key is dotted lowercase with two segments at least: `area.thing`", at, key);
|
|
1255
|
-
continue;
|
|
1256
|
-
}
|
|
1257
|
-
if (key.slice(0, key.indexOf(".")) !== area) {
|
|
1258
|
-
error(`a key in ${file} starts with "${area}."`, at, key);
|
|
1259
|
-
continue;
|
|
1260
|
-
}
|
|
1261
|
-
if (byKey.has(key)) {
|
|
1262
|
-
error("duplicate key", at, key);
|
|
1263
|
-
continue;
|
|
1264
|
-
}
|
|
1265
|
-
const message = readEntry$1(key, area, at, value, config, (m) => error(m, at, key));
|
|
1266
|
-
if (!message) continue;
|
|
1267
|
-
byKey.set(key, message);
|
|
1268
|
-
const warn = (m) => problems.push({
|
|
1269
|
-
severity: "warn",
|
|
1270
|
-
file: at,
|
|
1271
|
-
key,
|
|
1272
|
-
message: m
|
|
1273
|
-
});
|
|
1274
|
-
if (message.remove) warn("marked for removal: delete the entry, its translations and the code that reads it");
|
|
1275
|
-
if (message.comment !== void 0) warn(`a comment to act on, then delete: ${message.comment}`);
|
|
1276
|
-
}
|
|
1277
|
-
}
|
|
1278
|
-
const locales = readLocales(config, byKey, problems, rel, read, [...unsaved?.keys() ?? []].filter((path) => !existsSync(path)));
|
|
1279
|
-
if (problems.some((p) => p.severity === "error")) throw new CatalogError(problems);
|
|
1280
|
-
return {
|
|
1281
|
-
messages: [...byKey.values()].sort((a, b) => a.key < b.key ? -1 : 1),
|
|
1282
|
-
locales,
|
|
1283
|
-
problems
|
|
1284
|
-
};
|
|
1285
|
-
}
|
|
1286
|
-
function readEntry$1(key, area, file, value, config, error) {
|
|
1287
|
-
const entry = typeof value === "string" ? { [config.source]: value } : value;
|
|
1288
|
-
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
1289
|
-
error(`an entry is a message, or a mapping holding \`${config.source}\` and what the string is`);
|
|
1290
|
-
return;
|
|
1291
|
-
}
|
|
1292
|
-
let ok = true;
|
|
1293
|
-
const fail = (message) => {
|
|
1294
|
-
error(message);
|
|
1295
|
-
ok = false;
|
|
1296
|
-
};
|
|
1297
|
-
for (const field of Object.keys(entry)) if (!FIELDS.has(field) && field !== config.source) fail(`"${field}" is not a field: ${[...FIELDS, config.source].join(", ")}`);
|
|
1298
|
-
const text = entry[config.source];
|
|
1299
|
-
if (typeof text !== "string" || text === "") {
|
|
1300
|
-
fail(`${config.source} is the message, and it is missing`);
|
|
1301
|
-
return;
|
|
1302
|
-
}
|
|
1303
|
-
for (const field of config.require) if (entry[field] === void 0) fail(`${field} is required (\`require\` in the config)`);
|
|
1304
|
-
const kind = optionalString(entry, "kind", fail);
|
|
1305
|
-
if (kind !== void 0 && !(kind in config.kinds)) fail(`kind is one of ${Object.keys(config.kinds).join(", ")}`);
|
|
1306
|
-
const surface = optionalString(entry, "surface", fail);
|
|
1307
|
-
if (surface !== void 0 && !(surface in config.surfaces)) fail(`surface is one of ${Object.keys(config.surfaces).join(", ")}`);
|
|
1308
|
-
const context = optionalString(entry, "context", fail);
|
|
1309
|
-
if (entry.fragment !== void 0 && typeof entry.fragment !== "boolean") fail("fragment is true or false");
|
|
1310
|
-
const comment = optionalString(entry, "comment", fail);
|
|
1311
|
-
if (entry.remove !== void 0 && typeof entry.remove !== "boolean") fail("remove is true or false");
|
|
1312
|
-
let ast;
|
|
1313
|
-
try {
|
|
1314
|
-
ast = readMessage(config.messageFormat, text, config.source);
|
|
1315
|
-
} catch (e) {
|
|
1316
|
-
fail(syntaxError(config, text, e));
|
|
1317
|
-
return;
|
|
1318
|
-
}
|
|
1319
|
-
for (const problem of formatProblems(ast)) fail(problem);
|
|
1320
|
-
const args = argsOf(ast, (name) => fail(`"${name}" is used as two different types`));
|
|
1321
|
-
const argDocs = stringMap(entry, "args", fail);
|
|
1322
|
-
if (config.require.includes("context")) {
|
|
1323
|
-
for (const name of args.keys()) if (!argDocs.has(name)) fail(`args.${name} is missing: say what the placeholder holds`);
|
|
1324
|
-
}
|
|
1325
|
-
for (const name of argDocs.keys()) if (!args.has(name)) fail(`args.${name} is not an argument of this message`);
|
|
1326
|
-
if (!ok) return void 0;
|
|
1327
|
-
return {
|
|
1328
|
-
key,
|
|
1329
|
-
area,
|
|
1330
|
-
file,
|
|
1331
|
-
kind,
|
|
1332
|
-
surface,
|
|
1333
|
-
context,
|
|
1334
|
-
argDocs,
|
|
1335
|
-
fragment: entry.fragment === true,
|
|
1336
|
-
allow: stringMap(entry, "allow", fail),
|
|
1337
|
-
comment,
|
|
1338
|
-
remove: entry.remove === true,
|
|
1339
|
-
text,
|
|
1340
|
-
ast,
|
|
1341
|
-
args,
|
|
1342
|
-
argFormats: formatsOf(ast),
|
|
1343
|
-
tags: tagsOf(ast),
|
|
1344
|
-
translations: /* @__PURE__ */ new Map()
|
|
1345
|
-
};
|
|
1346
|
-
}
|
|
1347
|
-
function readLocales(config, byKey, problems, rel, read, created) {
|
|
1348
|
-
if (!existsSync(config.locales)) return [];
|
|
1349
|
-
const files = /* @__PURE__ */ new Map();
|
|
1350
|
-
for (const name of readdirSync(config.locales).sort()) {
|
|
1351
|
-
const path = join(config.locales, name);
|
|
1352
|
-
const isDir = statSync(path).isDirectory();
|
|
1353
|
-
if (!isDir && !name.endsWith(".yaml")) continue;
|
|
1354
|
-
const locale = isDir ? name : name.slice(0, -5);
|
|
1355
|
-
const found = isDir ? yamlFiles(path).map((f) => join(path, f)) : [path];
|
|
1356
|
-
files.set(locale, [...files.get(locale) ?? [], ...found]);
|
|
1357
|
-
}
|
|
1358
|
-
for (const path of created) {
|
|
1359
|
-
const inside = relative(config.locales, path).replaceAll("\\", "/");
|
|
1360
|
-
if (inside.startsWith("..") || !inside.endsWith(".yaml")) continue;
|
|
1361
|
-
const [first = "", second] = inside.split("/");
|
|
1362
|
-
const locale = second === void 0 ? first.slice(0, -5) : first;
|
|
1363
|
-
files.set(locale, [...files.get(locale) ?? [], path]);
|
|
1364
|
-
}
|
|
1365
|
-
files.delete(config.source);
|
|
1366
|
-
for (const [locale, paths] of files) for (const path of paths) {
|
|
1367
|
-
const file = rel(path);
|
|
1368
|
-
const error = (message, key) => problems.push({
|
|
1369
|
-
severity: "error",
|
|
1370
|
-
file,
|
|
1371
|
-
key,
|
|
1372
|
-
message
|
|
1373
|
-
});
|
|
1374
|
-
for (const [key, value] of Object.entries(readEntries(read(path), error))) {
|
|
1375
|
-
const message = byKey.get(key);
|
|
1376
|
-
if (message === void 0) {
|
|
1377
|
-
problems.push({
|
|
1378
|
-
severity: "warn",
|
|
1379
|
-
file,
|
|
1380
|
-
key,
|
|
1381
|
-
message: "no such key in the catalog, so this translation is unused"
|
|
1382
|
-
});
|
|
1383
|
-
continue;
|
|
1384
|
-
}
|
|
1385
|
-
if (typeof value !== "string" || value === "") {
|
|
1386
|
-
error("a translation is the message, as a string", key);
|
|
1387
|
-
continue;
|
|
1388
|
-
}
|
|
1389
|
-
let ast;
|
|
1390
|
-
try {
|
|
1391
|
-
ast = readMessage(config.messageFormat, value, locale);
|
|
1392
|
-
} catch (e) {
|
|
1393
|
-
error(syntaxError(config, value, e), key);
|
|
1394
|
-
continue;
|
|
1395
|
-
}
|
|
1396
|
-
const drift = driftFrom(message, ast);
|
|
1397
|
-
if (drift !== void 0) {
|
|
1398
|
-
error(drift, key);
|
|
1399
|
-
continue;
|
|
1400
|
-
}
|
|
1401
|
-
for (const problem of formatProblems(ast)) error(problem, key);
|
|
1402
|
-
message.translations.set(locale, {
|
|
1403
|
-
text: value,
|
|
1404
|
-
ast,
|
|
1405
|
-
file
|
|
1406
|
-
});
|
|
1407
|
-
}
|
|
1408
|
-
}
|
|
1409
|
-
return [...files.keys()];
|
|
1410
|
-
}
|
|
1411
|
-
/** How a translation's placeholders or tags differ from the source, or nothing. */
|
|
1412
|
-
function driftFrom(message, ast) {
|
|
1413
|
-
const args = argsOf(ast);
|
|
1414
|
-
const missing = [...message.args.keys()].filter((a) => !args.has(a));
|
|
1415
|
-
const extra = [...args.keys()].filter((a) => !message.args.has(a));
|
|
1416
|
-
const retyped = [...args].filter(([a, type]) => message.args.has(a) && message.args.get(a) !== type && !type.includes("string &")).map(([a]) => a);
|
|
1417
|
-
const tags = tagsOf(ast);
|
|
1418
|
-
const parts = [
|
|
1419
|
-
missing.length > 0 ? `missing {${missing.join("}, {")}}` : "",
|
|
1420
|
-
extra.length > 0 ? `unknown {${extra.join("}, {")}}` : "",
|
|
1421
|
-
retyped.length > 0 ? `{${retyped.join("}, {")}} used as another type` : "",
|
|
1422
|
-
tags.join() === message.tags.join() ? "" : `tags are <${tags.join(">, <") || "none"}>, the source has <${message.tags.join(">, <") || "none"}>`
|
|
1423
|
-
].filter(Boolean);
|
|
1424
|
-
return parts.length > 0 ? `differs from the source: ${parts.join("; ")}` : void 0;
|
|
1425
|
-
}
|
|
1426
|
-
function syntaxError(config, text, e) {
|
|
1427
|
-
if (config.messageFormat === 2) return e.message;
|
|
1428
|
-
return icuError(text, e);
|
|
1429
|
-
}
|
|
1430
|
-
function icuError(text, e) {
|
|
1431
|
-
const hyphenated = /select\s*,/.test(text) ? /[,{]\s*([a-z0-9]+(?:-[a-z0-9]+)+)\s*\{/.exec(text) : null;
|
|
1432
|
-
if (hyphenated) return `names a select arm "${hyphenated[1]}": an arm's name takes no hyphen, so write it with an underscore`;
|
|
1433
|
-
const reason = e.message;
|
|
1434
|
-
if (/TAG/.test(reason)) return `is not valid ICU (${reason}): a literal < is written '<'`;
|
|
1435
|
-
return `is not valid ICU MessageFormat: ${reason}`;
|
|
1436
|
-
}
|
|
1437
|
-
function yamlFiles(dir) {
|
|
1438
|
-
return readdirSync(dir).filter((f) => f.endsWith(".yaml")).sort();
|
|
1439
|
-
}
|
|
1440
|
-
/**
|
|
1441
|
-
* The file's entries. YAML 1.1 and 1.2 disagree about bare scalars: 1.1 reads `n`, `on` and `yes` as
|
|
1442
|
-
* booleans and `12:30` as a number. Translation tools are often 1.1, so a file that reads differently
|
|
1443
|
-
* under each fails here, naming the value to quote, rather than corrupting quietly in the next tool.
|
|
1444
|
-
*/
|
|
1445
|
-
function readEntries(text, error) {
|
|
1446
|
-
let raw;
|
|
1447
|
-
try {
|
|
1448
|
-
raw = parse(text);
|
|
1449
|
-
} catch (e) {
|
|
1450
|
-
error(`is not valid YAML: ${e.message}`);
|
|
1451
|
-
return {};
|
|
1452
|
-
}
|
|
1453
|
-
if (raw === null || raw === void 0) return {};
|
|
1454
|
-
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
1455
|
-
error("a catalog file is a mapping of key to entry");
|
|
1456
|
-
return {};
|
|
1457
|
-
}
|
|
1458
|
-
try {
|
|
1459
|
-
const found = divergence(raw, parse(text, { version: "1.1" }), "");
|
|
1460
|
-
if (found !== void 0) error(found);
|
|
1461
|
-
} catch (e) {
|
|
1462
|
-
error(`does not parse as YAML 1.1, which another tool may well be: ${e.message}`);
|
|
1463
|
-
}
|
|
1464
|
-
return raw;
|
|
1465
|
-
}
|
|
1466
|
-
function divergence(a, b, path) {
|
|
1467
|
-
if (a === null || b === null || typeof a !== "object" || typeof b !== "object") {
|
|
1468
|
-
if (Object.is(a, b)) return void 0;
|
|
1469
|
-
return `${path} reads as ${JSON.stringify(a)} under YAML 1.2 and ${JSON.stringify(b)} under YAML 1.1: quote it`;
|
|
1470
|
-
}
|
|
1471
|
-
const left = a;
|
|
1472
|
-
const right = b;
|
|
1473
|
-
for (const key of /* @__PURE__ */ new Set([...Object.keys(left), ...Object.keys(right)])) {
|
|
1474
|
-
const here = path === "" ? key : `${path}.${key}`;
|
|
1475
|
-
if (key in left !== key in right) return `${here} is a key under YAML ${key in left ? "1.2" : "1.1"} only: quote it`;
|
|
1476
|
-
const found = divergence(left[key], right[key], here);
|
|
1477
|
-
if (found !== void 0) return found;
|
|
1478
|
-
}
|
|
1479
|
-
}
|
|
1480
|
-
function optionalString(entry, field, fail) {
|
|
1481
|
-
const value = entry[field];
|
|
1482
|
-
if (value === void 0) return void 0;
|
|
1483
|
-
if (typeof value !== "string" || value.trim() === "") {
|
|
1484
|
-
fail(`${field} is a non-empty string`);
|
|
1485
|
-
return;
|
|
1486
|
-
}
|
|
1487
|
-
return value.trim();
|
|
1488
|
-
}
|
|
1489
|
-
function stringMap(entry, field, fail) {
|
|
1490
|
-
const out = /* @__PURE__ */ new Map();
|
|
1491
|
-
const value = entry[field];
|
|
1492
|
-
if (value === void 0) return out;
|
|
1493
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
1494
|
-
fail(`${field} is a mapping of name to sentence`);
|
|
1495
|
-
return out;
|
|
1496
|
-
}
|
|
1497
|
-
for (const [name, said] of Object.entries(value)) if (typeof said !== "string" || said.trim() === "") fail(`${field}.${name} is a sentence`);
|
|
1498
|
-
else out.set(name, said.trim());
|
|
1499
|
-
return out;
|
|
1500
|
-
}
|
|
1501
|
-
/**
|
|
1502
|
-
* Where a message's entry is written: its translation in `locale` when there is one, else the catalog.
|
|
1503
|
-
* Read from the file as it is now, so an entry moved since the catalog was loaded is still found.
|
|
1504
|
-
*/
|
|
1505
|
-
function entryLocation(config, message, locale) {
|
|
1506
|
-
const file = (locale === void 0 ? void 0 : message.translations.get(locale)?.file) ?? message.file;
|
|
1507
|
-
return {
|
|
1508
|
-
file: join(config.root, file),
|
|
1509
|
-
...entryLocator(config.root)(file, message.key)
|
|
1510
|
-
};
|
|
1511
|
-
}
|
|
1512
|
-
/**
|
|
1513
|
-
* Finds a key's line and column in a catalog or translation file, given relative to `root`. Each file
|
|
1514
|
-
* is read once, so a report that places many entries parses each file once. A key it can't find, or no
|
|
1515
|
-
* key, is the top of the file.
|
|
1516
|
-
*
|
|
1517
|
-
* Given an `offset` into the message (under `field`, in an entry that is a mapping), it is that
|
|
1518
|
-
* character, when the value is written in the file as it reads up to there: plain, or quoted on one
|
|
1519
|
-
* line with nothing escaped before it. Otherwise it is the key.
|
|
1520
|
-
*/
|
|
1521
|
-
function entryLocator(root) {
|
|
1522
|
-
const read = /* @__PURE__ */ new Map();
|
|
1523
|
-
return (file, key, offset, field) => {
|
|
1524
|
-
let parsed = read.get(file);
|
|
1525
|
-
if (parsed === void 0) {
|
|
1526
|
-
const path = join(root, file);
|
|
1527
|
-
const lines = new LineCounter();
|
|
1528
|
-
const source = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
1529
|
-
parsed = {
|
|
1530
|
-
doc: parseDocument(source, { lineCounter: lines }),
|
|
1531
|
-
lines,
|
|
1532
|
-
source
|
|
1533
|
-
};
|
|
1534
|
-
read.set(file, parsed);
|
|
1535
|
-
}
|
|
1536
|
-
const { doc, lines, source } = parsed;
|
|
1537
|
-
const pair = key !== void 0 && isMap(doc.contents) ? doc.contents.items.find((p) => isScalar(p.key) && p.key.value === key) : void 0;
|
|
1538
|
-
const value = isMap(pair?.value) && field !== void 0 ? pair.value.get(field, true) : pair?.value;
|
|
1539
|
-
if (offset !== void 0 && isScalar(value) && typeof value.value === "string" && value.range) {
|
|
1540
|
-
const quoted = value.type === Scalar.QUOTE_DOUBLE || value.type === Scalar.QUOTE_SINGLE;
|
|
1541
|
-
const content = value.range[0] + (quoted ? 1 : 0);
|
|
1542
|
-
if (source.slice(content, content + offset) === value.value.slice(0, offset)) {
|
|
1543
|
-
const { line, col } = lines.linePos(content + offset);
|
|
1544
|
-
return {
|
|
1545
|
-
line,
|
|
1546
|
-
column: col
|
|
1547
|
-
};
|
|
1548
|
-
}
|
|
1549
|
-
}
|
|
1550
|
-
const start = isScalar(pair?.key) ? pair.key.range?.[0] : void 0;
|
|
1551
|
-
if (start === void 0) return {
|
|
1552
|
-
line: 1,
|
|
1553
|
-
column: 1
|
|
1554
|
-
};
|
|
1555
|
-
const { line, col } = lines.linePos(start);
|
|
1556
|
-
return {
|
|
1557
|
-
line,
|
|
1558
|
-
column: col
|
|
1559
|
-
};
|
|
1560
|
-
};
|
|
1561
|
-
}
|
|
1562
|
-
/** The source message as a reader sees it with no placeholders filled: for docs and reports. */
|
|
1563
|
-
function previewOf(message) {
|
|
1564
|
-
return staticLiteral(message.ast) ?? message.text;
|
|
1565
|
-
}
|
|
1566
|
-
// src/tools/compile.ts
|
|
1567
|
-
/**
|
|
1568
|
-
* Compiles ICU ASTs to JavaScript.
|
|
1569
|
-
*
|
|
1570
|
-
* A message with no placeholders becomes its string. Any other becomes `(a, h) => …`, where `a` holds
|
|
1571
|
-
* the arguments and `h` the locale's helpers (see `runtime/helpers.ts`): a plural is a call picking an
|
|
1572
|
-
* arm, a number is a call to a cached `Intl.NumberFormat`. Formatting options are resolved here and
|
|
1573
|
-
* hoisted to module constants, so a skeleton costs nothing at runtime and its formatter is built once.
|
|
1574
|
-
*/
|
|
1575
|
-
/** Module-level constants for formatting options, one per distinct object. */
|
|
1576
|
-
var Hoisted = class {
|
|
1577
|
-
byJson = /* @__PURE__ */ new Map();
|
|
1578
|
-
/** `, oN` naming the hoisted options, or nothing when there are none. */
|
|
1579
|
-
arg(options) {
|
|
1580
|
-
if (options === void 0 || Object.keys(options).length === 0) return "";
|
|
1581
|
-
const json = JSON.stringify(options);
|
|
1582
|
-
let name = this.byJson.get(json);
|
|
1583
|
-
if (name === void 0) this.byJson.set(json, name = `o${this.byJson.size}`);
|
|
1584
|
-
return `, ${name}`;
|
|
1585
|
-
}
|
|
1586
|
-
declarations() {
|
|
1587
|
-
return [...this.byJson].map(([json, name]) => `const ${name} = ${json};`);
|
|
1588
|
-
}
|
|
1589
|
-
};
|
|
1590
|
-
function access(name) {
|
|
1591
|
-
return /^[A-Za-z_$][\w$]*$/.test(name) ? `a.${name}` : `a[${JSON.stringify(name)}]`;
|
|
1592
|
-
}
|
|
1593
|
-
function nodes(elements, hoisted, pound) {
|
|
1594
|
-
if (elements.length === 0) return "\"\"";
|
|
1595
|
-
const parts = elements.map((el) => node(el, hoisted, pound));
|
|
1596
|
-
return parts.length === 1 ? parts[0] : `[${parts.join(", ")}]`;
|
|
1597
|
-
}
|
|
1598
|
-
function arms(options, hoisted, pound) {
|
|
1599
|
-
return `{ ${Object.entries(options).map(([arm, option]) => `${JSON.stringify(arm)}: () => ${nodes(option.value, hoisted, pound)}`).join(", ")} }`;
|
|
1600
|
-
}
|
|
1601
|
-
function node(el, hoisted, pound) {
|
|
1602
|
-
if (isNumberElement(el)) return `h.n(${access(el.value)}${hoisted.arg(numberOptions(el))})`;
|
|
1603
|
-
if (isDateElement(el) || isTimeElement(el)) return `h.d(${access(el.value)}${hoisted.arg(dateOptions(el))})`;
|
|
1604
|
-
switch (el.type) {
|
|
1605
|
-
case TYPE$1.literal: return JSON.stringify(el.value);
|
|
1606
|
-
case TYPE$1.argument: return access(el.value);
|
|
1607
|
-
case TYPE$1.pound: return pound ?? "\"#\"";
|
|
1608
|
-
case TYPE$1.select: return `h.s(${access(el.value)}, ${arms(el.options, hoisted, pound)})`;
|
|
1609
|
-
case TYPE$1.plural: {
|
|
1610
|
-
const value = access(el.value);
|
|
1611
|
-
const hash = `h.n(${value}${el.offset !== 0 ? ` - ${el.offset}` : ""})`;
|
|
1612
|
-
return `h.p(${value}, ${el.offset}, ${el.pluralType === "ordinal"}, ${arms(el.options, hoisted, hash)})`;
|
|
1613
|
-
}
|
|
1614
|
-
case TYPE$1.tag: return `h.t(a, ${JSON.stringify(el.value)}, ${nodes(el.children, hoisted, pound)})`;
|
|
1615
|
-
default: throw new Error(`prosebook: no compiler for ICU element type ${el.type}`);
|
|
1616
|
-
}
|
|
1617
|
-
}
|
|
1618
|
-
function message(ast, hoisted) {
|
|
1619
|
-
const literal = staticLiteral(ast);
|
|
1620
|
-
return literal === void 0 ? `(a, h) => ${nodes(ast, hoisted, null)}` : JSON.stringify(literal);
|
|
1621
|
-
}
|
|
1622
|
-
/** One locale's messages as code, keeping only `keep` when given. */
|
|
1623
|
-
function compileLocale(entries, keep) {
|
|
1624
|
-
const hoisted = new Hoisted();
|
|
1625
|
-
const rows = [];
|
|
1626
|
-
for (const [key, ast] of entries) {
|
|
1627
|
-
if (keep && !keep(key)) continue;
|
|
1628
|
-
rows.push(` ${JSON.stringify(key)}: ${message(ast, hoisted)},`);
|
|
1629
|
-
}
|
|
1630
|
-
return {
|
|
1631
|
-
declarations: hoisted.declarations(),
|
|
1632
|
-
rows
|
|
1633
|
-
};
|
|
1634
|
-
}
|
|
1635
|
-
/** The locale as an ES module whose default export is its messages. */
|
|
1636
|
-
function localeModule(compiled, banner, ts) {
|
|
1637
|
-
const head = ts ? `${banner}\nimport type { CompiledMessages } from 'prosebook';\n` : `${banner}\n`;
|
|
1638
|
-
const declarations = ts ? compiled.declarations.map((d) => d.replace(/;$/, " as const;")) : compiled.declarations;
|
|
1639
|
-
const consts = declarations.length > 0 ? `\n${declarations.join("\n")}\n` : "";
|
|
1640
|
-
const close = ts ? "} satisfies CompiledMessages;" : "};";
|
|
1641
|
-
return `${head}${consts}\nexport default {\n${compiled.rows.join("\n")}\n${close}\n`;
|
|
1642
|
-
}
|
|
1643
|
-
/** The locale as one expression, for a bundler to inline in place of the module. */
|
|
1644
|
-
function localeExpression(compiled) {
|
|
1645
|
-
return `(() => {\n${compiled.declarations.join("\n")}\nreturn {\n${compiled.rows.join("\n")}\n};\n})()`;
|
|
1646
|
-
}
|
|
1647
|
-
// src/tools/entry.ts
|
|
1648
|
-
/**
|
|
1649
|
-
* One entry as the dev inspector's dialog edits it, in every locale: as fields, or as the YAML written
|
|
1650
|
-
* in its files.
|
|
1651
|
-
*
|
|
1652
|
-
* An edit is checked as the whole catalog, with the edited files read from memory, before anything is
|
|
1653
|
-
* written: a save that would break the build comes back as problems instead, and the dev server never
|
|
1654
|
-
* sees it. Fields are written into the parsed document, so comments, order and quoting elsewhere in the
|
|
1655
|
-
* file stay as they were; YAML replaces the entry's text as typed. A translation left empty is no
|
|
1656
|
-
* translation: one that was there is removed, and the locale falls back.
|
|
1657
|
-
*/
|
|
1658
|
-
/** The entry for `key`, with its translation in every locale. */
|
|
1659
|
-
function readEntry(config, key) {
|
|
1660
|
-
return viewOf(config, loadCatalog(config), key, /* @__PURE__ */ new Map());
|
|
1661
|
-
}
|
|
1662
|
-
/**
|
|
1663
|
-
* Applies an edit and checks the catalog with it. With `write`, a clean edit is written; without, it
|
|
1664
|
-
* only comes back as the view, which is how the dialog turns fields into YAML and back.
|
|
1665
|
-
*/
|
|
1666
|
-
function editEntry(config, key, edit, write) {
|
|
1667
|
-
const current = viewOf(config, loadCatalog(config), key, /* @__PURE__ */ new Map());
|
|
1668
|
-
const unsaved = /* @__PURE__ */ new Map();
|
|
1669
|
-
const text = (file) => contentOf(config, file, unsaved);
|
|
1670
|
-
const put = (file, content) => unsaved.set(resolve(config.root, file), content);
|
|
1671
|
-
const translated = [];
|
|
1672
|
-
if ("yaml" in edit) {
|
|
1673
|
-
put(current.files.catalog, withSnippet(text(current.files.catalog), key, edit.yaml.catalog));
|
|
1674
|
-
for (const locale of current.locales) {
|
|
1675
|
-
const yaml = edit.yaml.translations?.[locale]?.trim() ?? current.yaml.translations[locale];
|
|
1676
|
-
if (yaml !== "") translated.push(locale);
|
|
1677
|
-
if (yaml === current.yaml.translations[locale]) continue;
|
|
1678
|
-
put(current.files.translations[locale], withSnippet(text(current.files.translations[locale]), key, yaml));
|
|
1679
|
-
}
|
|
1680
|
-
} else {
|
|
1681
|
-
put(current.files.catalog, withFields(text(current.files.catalog), key, edit.fields, config.source));
|
|
1682
|
-
for (const locale of current.locales) {
|
|
1683
|
-
const next = edit.fields.translations[locale] ?? "";
|
|
1684
|
-
if (next.trim() !== "") translated.push(locale);
|
|
1685
|
-
if (next === current.fields.translations[locale]) continue;
|
|
1686
|
-
put(current.files.translations[locale], withTranslation(text(current.files.translations[locale]), key, next));
|
|
1687
|
-
}
|
|
1688
|
-
}
|
|
1689
|
-
let catalog;
|
|
1690
|
-
try {
|
|
1691
|
-
catalog = loadCatalog(config, unsaved);
|
|
1692
|
-
} catch (e) {
|
|
1693
|
-
if (!(e instanceof CatalogError)) throw e;
|
|
1694
|
-
return { problems: e.problems.filter((p) => p.severity === "error") };
|
|
1695
|
-
}
|
|
1696
|
-
const message = catalog.messages.find((m) => m.key === key);
|
|
1697
|
-
const renamed = [...message === void 0 ? [current.files.catalog] : [], ...translated.filter((l) => message?.translations.has(l) !== true).map((l) => current.files.translations[l])];
|
|
1698
|
-
if (renamed.length > 0) {
|
|
1699
|
-
const problem = `the entry is still ${key}: rename a key in the code and the catalog together`;
|
|
1700
|
-
return { problems: renamed.map((file) => ({
|
|
1701
|
-
severity: "error",
|
|
1702
|
-
file,
|
|
1703
|
-
key,
|
|
1704
|
-
message: problem
|
|
1705
|
-
})) };
|
|
1706
|
-
}
|
|
1707
|
-
if (write) for (const [path, content] of unsaved) {
|
|
1708
|
-
if (existsSync(path) && readFileSync(path, "utf8") === content) continue;
|
|
1709
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
1710
|
-
writeFileSync(path, content);
|
|
1711
|
-
}
|
|
1712
|
-
return {
|
|
1713
|
-
view: viewOf(config, catalog, key, unsaved),
|
|
1714
|
-
problems: []
|
|
1715
|
-
};
|
|
1716
|
-
}
|
|
1717
|
-
/**
|
|
1718
|
-
* Makes the edits that lint's findings carry, each message in its own file, and writes them only if
|
|
1719
|
-
* the catalog they make loads clean: the problems come back instead, and nothing is written.
|
|
1720
|
-
*/
|
|
1721
|
-
function applyFixes(config, catalog, findings) {
|
|
1722
|
-
const unsaved = /* @__PURE__ */ new Map();
|
|
1723
|
-
const entries = /* @__PURE__ */ new Map();
|
|
1724
|
-
for (const f of findings) {
|
|
1725
|
-
if (f.fix === void 0) continue;
|
|
1726
|
-
const id = `${f.locale}\u0000${f.key}`;
|
|
1727
|
-
const entry = entries.get(id) ?? {
|
|
1728
|
-
file: f.file,
|
|
1729
|
-
key: f.key,
|
|
1730
|
-
locale: f.locale,
|
|
1731
|
-
edits: [],
|
|
1732
|
-
count: 0
|
|
1733
|
-
};
|
|
1734
|
-
entry.edits.push(...f.fix);
|
|
1735
|
-
entry.count += 1;
|
|
1736
|
-
entries.set(id, entry);
|
|
1737
|
-
}
|
|
1738
|
-
let fixed = 0;
|
|
1739
|
-
for (const { file, key, locale, edits, count } of entries.values()) {
|
|
1740
|
-
const message = catalog.messages.find((m) => m.key === key);
|
|
1741
|
-
const written = locale === config.source ? message?.text : message?.translations.get(locale)?.text;
|
|
1742
|
-
if (written === void 0) continue;
|
|
1743
|
-
const next = applyEdits(written, edits);
|
|
1744
|
-
if (next === written) continue;
|
|
1745
|
-
const content = contentOf(config, file, unsaved);
|
|
1746
|
-
unsaved.set(resolve(config.root, file), locale === config.source ? withMessage(content, key, next, config.source) : withTranslation(content, key, next));
|
|
1747
|
-
fixed += count;
|
|
1748
|
-
}
|
|
1749
|
-
try {
|
|
1750
|
-
loadCatalog(config, unsaved);
|
|
1751
|
-
} catch (e) {
|
|
1752
|
-
if (!(e instanceof CatalogError)) throw e;
|
|
1753
|
-
return {
|
|
1754
|
-
fixed: 0,
|
|
1755
|
-
problems: e.problems.filter((p) => p.severity === "error")
|
|
1756
|
-
};
|
|
1757
|
-
}
|
|
1758
|
-
for (const [path, content] of unsaved) writeFileSync(path, content);
|
|
1759
|
-
return {
|
|
1760
|
-
fixed,
|
|
1761
|
-
problems: []
|
|
1762
|
-
};
|
|
1763
|
-
}
|
|
1764
|
-
/** A file as the edit has it, as it is on disk, or empty when it is still to be made. */
|
|
1765
|
-
function contentOf(config, file, unsaved) {
|
|
1766
|
-
const path = resolve(config.root, file);
|
|
1767
|
-
return unsaved.get(path) ?? (existsSync(path) ? readFileSync(path, "utf8") : "");
|
|
1768
|
-
}
|
|
1769
|
-
/** The file a translation goes in: beside the locale's others, in its folder or its one file. */
|
|
1770
|
-
function translationFile(config, message, locale) {
|
|
1771
|
-
const existing = message.translations.get(locale)?.file;
|
|
1772
|
-
if (existing !== void 0) return existing;
|
|
1773
|
-
const folder = join(config.locales, locale);
|
|
1774
|
-
const path = existsSync(folder) && statSync(folder).isDirectory() ? join(folder, `${message.area}.yaml`) : `${folder}.yaml`;
|
|
1775
|
-
return relative(config.root, path).replaceAll("\\", "/");
|
|
1776
|
-
}
|
|
1777
|
-
function viewOf(config, catalog, key, unsaved) {
|
|
1778
|
-
const message = catalog.messages.find((m) => m.key === key);
|
|
1779
|
-
if (message === void 0) throw new Error(`no entry for "${key}" in the catalog: has it been renamed since the page loaded?`);
|
|
1780
|
-
const files = Object.fromEntries(catalog.locales.map((l) => [l, translationFile(config, message, l)]));
|
|
1781
|
-
return {
|
|
1782
|
-
key,
|
|
1783
|
-
source: config.source,
|
|
1784
|
-
locales: catalog.locales,
|
|
1785
|
-
files: {
|
|
1786
|
-
catalog: message.file,
|
|
1787
|
-
translations: files
|
|
1788
|
-
},
|
|
1789
|
-
fields: {
|
|
1790
|
-
message: message.text,
|
|
1791
|
-
translations: Object.fromEntries(catalog.locales.map((l) => [l, message.translations.get(l)?.text ?? ""])),
|
|
1792
|
-
context: message.context ?? "",
|
|
1793
|
-
kind: message.kind ?? "",
|
|
1794
|
-
surface: message.surface ?? "",
|
|
1795
|
-
fragment: message.fragment,
|
|
1796
|
-
args: Object.fromEntries(message.argDocs),
|
|
1797
|
-
comment: message.comment ?? "",
|
|
1798
|
-
remove: message.remove
|
|
1799
|
-
},
|
|
1800
|
-
yaml: {
|
|
1801
|
-
catalog: snippetOf(contentOf(config, message.file, unsaved), key)?.text ?? "",
|
|
1802
|
-
translations: Object.fromEntries(catalog.locales.map((l) => [l, snippetOf(contentOf(config, files[l], unsaved), key)?.text ?? ""]))
|
|
1803
|
-
},
|
|
1804
|
-
argNames: [...message.args.keys()],
|
|
1805
|
-
kinds: Object.keys(config.kinds),
|
|
1806
|
-
surfaces: Object.keys(config.surfaces),
|
|
1807
|
-
findings: lint([message], config).map((f) => {
|
|
1808
|
-
const written = f.locale === config.source ? message.text : message.translations.get(f.locale)?.text;
|
|
1809
|
-
return f.fix === void 0 || written === void 0 ? f : {
|
|
1810
|
-
...f,
|
|
1811
|
-
fixed: applyEdits(written, f.fix)
|
|
1812
|
-
};
|
|
1813
|
-
})
|
|
1814
|
-
};
|
|
1815
|
-
}
|
|
1816
|
-
/** Where an entry is written: from the start of its key's line to the end of its value. */
|
|
1817
|
-
function snippetOf(file, key) {
|
|
1818
|
-
const doc = parseDocument(file);
|
|
1819
|
-
if (!isMap(doc.contents)) return void 0;
|
|
1820
|
-
const pair = doc.contents.items.find((p) => isScalar(p.key) && p.key.value === key);
|
|
1821
|
-
const keyStart = isScalar(pair?.key) ? pair.key.range?.[0] : void 0;
|
|
1822
|
-
const valueEnd = (pair?.value)?.range?.[1];
|
|
1823
|
-
if (keyStart === void 0 || valueEnd === void 0) return void 0;
|
|
1824
|
-
const start = file.lastIndexOf("\n", keyStart - 1) + 1;
|
|
1825
|
-
const end = start + file.slice(start, valueEnd).trimEnd().length;
|
|
1826
|
-
return {
|
|
1827
|
-
start,
|
|
1828
|
-
end,
|
|
1829
|
-
text: file.slice(start, end)
|
|
1830
|
-
};
|
|
1831
|
-
}
|
|
1832
|
-
/** The file with the entry's text replaced, removed when `yaml` is empty, or added at the end. */
|
|
1833
|
-
function withSnippet(file, key, yaml) {
|
|
1834
|
-
const text = yaml.replace(/\n+$/, "");
|
|
1835
|
-
const at = snippetOf(file, key);
|
|
1836
|
-
if (at !== void 0) {
|
|
1837
|
-
if (text.trim() === "") return file.slice(0, at.start) + file.slice(at.end).replace(/^\n/, "");
|
|
1838
|
-
return file.slice(0, at.start) + text + file.slice(at.end);
|
|
1839
|
-
}
|
|
1840
|
-
if (text.trim() === "") return file;
|
|
1841
|
-
return file.trim() === "" ? `${text}\n` : `${file.replace(/\n*$/, "\n")}${text}\n`;
|
|
1842
|
-
}
|
|
1843
|
-
function parse$2(file) {
|
|
1844
|
-
const doc = parseDocument(file, { version: "1.1" });
|
|
1845
|
-
if (doc.contents === null) doc.contents = doc.createNode({});
|
|
1846
|
-
return doc;
|
|
1847
|
-
}
|
|
1848
|
-
const print = (doc) => doc.toString({
|
|
1849
|
-
lineWidth: 0,
|
|
1850
|
-
singleQuote: true
|
|
1851
|
-
});
|
|
1852
|
-
/** A value written where one was, keeping how it was quoted; text over several lines as a block. */
|
|
1853
|
-
function setValue(doc, map, name, value) {
|
|
1854
|
-
const node = map.get(name, true);
|
|
1855
|
-
if (isScalar(node) && typeof value === "string") {
|
|
1856
|
-
node.value = value;
|
|
1857
|
-
if (value.includes("\n")) node.type = Scalar.BLOCK_LITERAL;
|
|
1858
|
-
return;
|
|
1859
|
-
}
|
|
1860
|
-
map.set(name, doc.createNode(value));
|
|
1861
|
-
}
|
|
1862
|
-
function withFields(file, key, fields, source) {
|
|
1863
|
-
const doc = parse$2(file);
|
|
1864
|
-
let entry = doc.get(key, true);
|
|
1865
|
-
if (!isMap(entry)) {
|
|
1866
|
-
entry = doc.createNode({ [source]: fields.message });
|
|
1867
|
-
doc.set(key, entry);
|
|
1868
|
-
}
|
|
1869
|
-
const map = entry;
|
|
1870
|
-
const args = Object.fromEntries(Object.entries(fields.args).filter(([, v]) => v.trim() !== ""));
|
|
1871
|
-
const wanted = [
|
|
1872
|
-
["kind", fields.kind.trim() || void 0],
|
|
1873
|
-
["surface", fields.surface.trim() || void 0],
|
|
1874
|
-
["context", fields.context.trim() || void 0],
|
|
1875
|
-
["args", Object.keys(args).length > 0 ? args : void 0],
|
|
1876
|
-
["fragment", fields.fragment || void 0],
|
|
1877
|
-
["comment", fields.comment.trim() || void 0],
|
|
1878
|
-
["remove", fields.remove || void 0]
|
|
1879
|
-
];
|
|
1880
|
-
for (const [name, value] of wanted) {
|
|
1881
|
-
if (value === void 0) {
|
|
1882
|
-
map.delete(name);
|
|
1883
|
-
continue;
|
|
1884
|
-
}
|
|
1885
|
-
if (map.has(name)) {
|
|
1886
|
-
setValue(doc, map, name, value);
|
|
1887
|
-
continue;
|
|
1888
|
-
}
|
|
1889
|
-
const at = map.items.findIndex((p) => isScalar(p.key) && p.key.value === source);
|
|
1890
|
-
map.items.splice(at === -1 ? map.items.length : at, 0, doc.createPair(name, value));
|
|
1891
|
-
}
|
|
1892
|
-
setValue(doc, map, source, fields.message);
|
|
1893
|
-
return print(doc);
|
|
1894
|
-
}
|
|
1895
|
-
/** The source message replaced, and nothing else: a `key: message` entry stays one. */
|
|
1896
|
-
function withMessage(file, key, message, source) {
|
|
1897
|
-
const doc = parse$2(file);
|
|
1898
|
-
const entry = doc.get(key, true);
|
|
1899
|
-
if (!isMap(entry)) return withTranslation(file, key, message);
|
|
1900
|
-
setValue(doc, entry, source, message);
|
|
1901
|
-
return print(doc);
|
|
1902
|
-
}
|
|
1903
|
-
function withTranslation(file, key, translation) {
|
|
1904
|
-
const doc = parse$2(file);
|
|
1905
|
-
const node = doc.get(key, true);
|
|
1906
|
-
if (translation.trim() === "") doc.delete(key);
|
|
1907
|
-
else if (isScalar(node)) {
|
|
1908
|
-
node.value = translation;
|
|
1909
|
-
if (translation.includes("\n")) node.type = Scalar.BLOCK_LITERAL;
|
|
1910
|
-
} else doc.set(key, translation);
|
|
1911
|
-
return isMap(doc.contents) && doc.contents.items.length === 0 ? "" : print(doc);
|
|
1912
|
-
}
|
|
1913
|
-
// src/tools/lock.ts
|
|
1914
|
-
/**
|
|
1915
|
-
* Stale translations: a translation made from source text that has since changed.
|
|
1916
|
-
*
|
|
1917
|
-
* The lock records, per translation, a hash of the source it was made from and a hash of the
|
|
1918
|
-
* translation itself. Editing the translation re-anchors it to the current source; editing only
|
|
1919
|
-
* the source leaves the old anchor in place, which is what marks it stale. Nobody has to remember
|
|
1920
|
-
* to flag anything: `prosebook status` lists what is stale, and `prosebook accept` re-anchors a
|
|
1921
|
-
* translation that was still right.
|
|
1922
|
-
*
|
|
1923
|
-
* The lock also keeps the text behind each anchor, once per distinct text, so a stale translation
|
|
1924
|
-
* can show its translator what changed rather than only what the source says now.
|
|
1925
|
-
*/
|
|
1926
|
-
function hash(text) {
|
|
1927
|
-
return createHash("sha256").update(text).digest("hex").slice(0, 10);
|
|
1928
|
-
}
|
|
1929
|
-
function emptyLock() {
|
|
1930
|
-
return {
|
|
1931
|
-
version: 2,
|
|
1932
|
-
sources: {},
|
|
1933
|
-
translations: {}
|
|
1934
|
-
};
|
|
1935
|
-
}
|
|
1936
|
-
/** The lock at `path`. A version 1 lock, which held only the translations, reads with no source texts. */
|
|
1937
|
-
function readLock(path) {
|
|
1938
|
-
if (!existsSync(path)) return emptyLock();
|
|
1939
|
-
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
1940
|
-
if (raw.version === 2) return {
|
|
1941
|
-
...emptyLock(),
|
|
1942
|
-
...raw
|
|
1943
|
-
};
|
|
1944
|
-
return {
|
|
1945
|
-
version: 2,
|
|
1946
|
-
sources: {},
|
|
1947
|
-
translations: raw
|
|
1948
|
-
};
|
|
1949
|
-
}
|
|
1950
|
-
const sortedEntries = (record) => Object.fromEntries(Object.entries(record).sort(([a], [b]) => a < b ? -1 : 1));
|
|
1951
|
-
function serializeLock(lock) {
|
|
1952
|
-
const translations = Object.fromEntries(Object.keys(lock.translations).sort().map((locale) => [locale, sortedEntries(lock.translations[locale])]));
|
|
1953
|
-
return `${JSON.stringify({
|
|
1954
|
-
version: 2,
|
|
1955
|
-
sources: sortedEntries(lock.sources),
|
|
1956
|
-
translations
|
|
1957
|
-
}, null, 2)}\n`;
|
|
1958
|
-
}
|
|
1959
|
-
/**
|
|
1960
|
-
* The lock brought up to date with the catalog, and what is stale. `accept` re-anchors the given
|
|
1961
|
-
* translations (per locale, keys, or `true` for all of them) to the current source.
|
|
1962
|
-
*/
|
|
1963
|
-
function reconcile(catalog, previous, accept = /* @__PURE__ */ new Map()) {
|
|
1964
|
-
const lock = emptyLock();
|
|
1965
|
-
const stale = /* @__PURE__ */ new Map();
|
|
1966
|
-
for (const message of catalog.messages) {
|
|
1967
|
-
const source = hash(message.text);
|
|
1968
|
-
for (const [locale, translation] of message.translations) {
|
|
1969
|
-
const text = hash(translation.text);
|
|
1970
|
-
const [anchor, recorded] = (previous.translations[locale]?.[message.key] ?? "").split(":");
|
|
1971
|
-
const accepted = accept.get(locale);
|
|
1972
|
-
const reanchor = anchor === void 0 || anchor === "" || recorded !== text || accepted === true || accepted?.has(message.key) === true;
|
|
1973
|
-
const kept = reanchor ? source : anchor;
|
|
1974
|
-
(lock.translations[locale] ??= {})[message.key] = `${kept}:${text}`;
|
|
1975
|
-
const keptText = kept === source ? message.text : previous.sources[kept];
|
|
1976
|
-
if (keptText !== void 0) lock.sources[kept] = keptText;
|
|
1977
|
-
if (!reanchor && anchor !== source) {
|
|
1978
|
-
let keys = stale.get(locale);
|
|
1979
|
-
if (keys === void 0) stale.set(locale, keys = /* @__PURE__ */ new Map());
|
|
1980
|
-
keys.set(message.key, {
|
|
1981
|
-
was: keptText,
|
|
1982
|
-
now: message.text,
|
|
1983
|
-
translation: translation.text
|
|
1984
|
-
});
|
|
1985
|
-
}
|
|
1986
|
-
}
|
|
1987
|
-
}
|
|
1988
|
-
return {
|
|
1989
|
-
lock,
|
|
1990
|
-
stale
|
|
1991
|
-
};
|
|
1992
|
-
}
|
|
1993
|
-
// src/tools/pseudo.ts
|
|
1994
|
-
/**
|
|
1995
|
-
* Pseudo-localization: the source with every letter accented, vowels doubled for the length a
|
|
1996
|
-
* translation usually adds, and brackets around the whole. A screen in the pseudo-locale shows at a
|
|
1997
|
-
* glance which text is hardcoded (it is plain), which is clipped (a bracket is missing), and which
|
|
1998
|
-
* splices fragments together (brackets inside brackets).
|
|
1999
|
-
*/
|
|
2000
|
-
const PLAIN = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
2001
|
-
const ACCENTED = Array.from("áƀçðéƒĝĥíĵķļɱñóþǫŕšţúṽŵẋýžÁƁÇÐÉƑĜĤÍĴĶĻṀÑÓÞǪŔŠŢÚṼŴẊÝŽ");
|
|
2002
|
-
function pseudoText(text) {
|
|
2003
|
-
return text.replace(/[A-Za-z]/g, (c) => {
|
|
2004
|
-
const accented = ACCENTED[PLAIN.indexOf(c)];
|
|
2005
|
-
return /[aeiou]/i.test(c) ? accented.repeat(2) : accented;
|
|
2006
|
-
});
|
|
2007
|
-
}
|
|
2008
|
-
function transform(elements) {
|
|
2009
|
-
return elements.map((el) => {
|
|
2010
|
-
if (el.type === TYPE$1.literal) return {
|
|
2011
|
-
...el,
|
|
2012
|
-
value: pseudoText(el.value)
|
|
2013
|
-
};
|
|
2014
|
-
if (el.type === TYPE$1.tag) return {
|
|
2015
|
-
...el,
|
|
2016
|
-
children: transform(el.children)
|
|
2017
|
-
};
|
|
2018
|
-
if (el.type === TYPE$1.select || el.type === TYPE$1.plural) {
|
|
2019
|
-
const options = Object.fromEntries(Object.entries(el.options).map(([arm, o]) => [arm, {
|
|
2020
|
-
...o,
|
|
2021
|
-
value: transform(o.value)
|
|
2022
|
-
}]));
|
|
2023
|
-
return {
|
|
2024
|
-
...el,
|
|
2025
|
-
options
|
|
2026
|
-
};
|
|
2027
|
-
}
|
|
2028
|
-
return el;
|
|
2029
|
-
});
|
|
2030
|
-
}
|
|
2031
|
-
function pseudoAst(ast) {
|
|
2032
|
-
const out = [];
|
|
2033
|
-
for (const el of [
|
|
2034
|
-
{
|
|
2035
|
-
type: TYPE$1.literal,
|
|
2036
|
-
value: "⟦"
|
|
2037
|
-
},
|
|
2038
|
-
...transform(ast),
|
|
2039
|
-
{
|
|
2040
|
-
type: TYPE$1.literal,
|
|
2041
|
-
value: "⟧"
|
|
2042
|
-
}
|
|
2043
|
-
]) {
|
|
2044
|
-
const last = out.at(-1);
|
|
2045
|
-
if (el.type === TYPE$1.literal && last?.type === TYPE$1.literal) out[out.length - 1] = {
|
|
2046
|
-
...last,
|
|
2047
|
-
value: last.value + el.value
|
|
2048
|
-
};
|
|
2049
|
-
else out.push(el);
|
|
2050
|
-
}
|
|
2051
|
-
return out;
|
|
2052
|
-
}
|
|
2053
|
-
// src/tools/generate.ts
|
|
2054
|
-
/**
|
|
2055
|
-
* Turns the catalog into the modules an app imports.
|
|
2056
|
-
*
|
|
2057
|
-
* Generated rather than loaded so that a missing key, a misspelled key or a missing argument is a type
|
|
2058
|
-
* error, and so the runtime needs neither a YAML nor an ICU parser. The index carries each key's source
|
|
2059
|
-
* text and context as a doc comment, so hovering a key at its call site shows what the screen says.
|
|
2060
|
-
*/
|
|
2061
|
-
const quote = (s) => JSON.stringify(s);
|
|
2062
|
-
const prop = (name) => /^[A-Za-z_$][\w$]*$/.test(name) ? name : quote(name);
|
|
2063
|
-
function banner(config) {
|
|
2064
|
-
return `// Generated by prosebook from ${relative(config.root, config.catalog).replaceAll("\\", "/")}. Do not edit: change the catalog and run \`prosebook generate\`.`;
|
|
2065
|
-
}
|
|
2066
|
-
/** Every locale the app can switch to, in order: the source, the translations, the pseudo-locale. */
|
|
2067
|
-
function localesOf(catalog, config) {
|
|
2068
|
-
return [
|
|
2069
|
-
config.source,
|
|
2070
|
-
...catalog.locales,
|
|
2071
|
-
...config.pseudo !== false ? [config.pseudo] : []
|
|
2072
|
-
];
|
|
2073
|
-
}
|
|
2074
|
-
/** Per locale, the messages its module holds. A translated locale holds only what it translates. */
|
|
2075
|
-
function localeEntries(catalog, config, stale) {
|
|
2076
|
-
const out = /* @__PURE__ */ new Map();
|
|
2077
|
-
out.set(config.source, catalog.messages.map((m) => [m.key, m.ast]));
|
|
2078
|
-
for (const locale of catalog.locales) {
|
|
2079
|
-
const skip = config.stale === "fallback" ? stale.get(locale) : void 0;
|
|
2080
|
-
out.set(locale, catalog.messages.flatMap((m) => {
|
|
2081
|
-
const t = m.translations.get(locale);
|
|
2082
|
-
return t === void 0 || skip?.has(m.key) === true ? [] : [[m.key, t.ast]];
|
|
2083
|
-
}));
|
|
2084
|
-
}
|
|
2085
|
-
if (config.pseudo !== false) out.set(config.pseudo, catalog.messages.map((m) => [m.key, pseudoAst(m.ast)]));
|
|
2086
|
-
return out;
|
|
2087
|
-
}
|
|
2088
|
-
function doc(lines, indent) {
|
|
2089
|
-
const safe = lines.map((l) => l.replaceAll("*/", "*\\/"));
|
|
2090
|
-
if (safe.length === 1) return `${indent}/** ${safe[0]} */\n`;
|
|
2091
|
-
return `${indent}/**\n${safe.map((l) => l === "" ? `${indent} *` : `${indent} * ${l}`).join("\n")}\n${indent} */\n`;
|
|
2092
|
-
}
|
|
2093
|
-
/** A doc written as a phrase (`the person's name`) reads as a sentence in a doc comment. */
|
|
2094
|
-
function sentence(text) {
|
|
2095
|
-
const capital = text.charAt(0).toUpperCase() + text.slice(1);
|
|
2096
|
-
return /[.!?]$/.test(capital) ? capital : `${capital}.`;
|
|
2097
|
-
}
|
|
2098
|
-
function shapeOf(m) {
|
|
2099
|
-
const tags = m.tags.length > 0 ? m.tags.map(quote).join(" | ") : "never";
|
|
2100
|
-
if (m.args.size === 0) return `{ args: {}; tags: ${tags} }`;
|
|
2101
|
-
return `{\n args: {\n${[...m.args].map(([name, type]) => {
|
|
2102
|
-
const held = m.argDocs.get(name);
|
|
2103
|
-
const said = [held === void 0 ? void 0 : sentence(held), m.argFormats.has(name) ? `Formatted as ${m.argFormats.get(name)}.` : void 0].filter(Boolean);
|
|
2104
|
-
return `${said.length > 0 ? doc([said.join(" ")], " ") : ""} readonly ${prop(name)}: ${type};`;
|
|
2105
|
-
}).join("\n")}\n };\n tags: ${tags};\n }`;
|
|
2106
|
-
}
|
|
2107
|
-
function typesBlock(catalog, locales) {
|
|
2108
|
-
const rows = catalog.messages.map((m) => {
|
|
2109
|
-
const lines = (m.text.length > 400 ? `${m.text.slice(0, 397)}...` : m.text).split("\n");
|
|
2110
|
-
if (m.context) lines.push("", m.context);
|
|
2111
|
-
if (m.remove) lines.push("", "Marked for removal in the catalog.");
|
|
2112
|
-
if (m.comment) lines.push("", `Comment: ${m.comment}`);
|
|
2113
|
-
return `${doc(lines, " ")} ${quote(m.key)}: ${shapeOf(m)};`;
|
|
2114
|
-
});
|
|
2115
|
-
return [
|
|
2116
|
-
`export type Locale = ${locales.map(quote).join(" | ")};`,
|
|
2117
|
-
"",
|
|
2118
|
-
"/** Every message in the catalog: the arguments it takes and the tags it wraps text in. */",
|
|
2119
|
-
`export type Messages = {\n${rows.join("\n")}\n};`
|
|
2120
|
-
].join("\n");
|
|
2121
|
-
}
|
|
2122
|
-
const REGISTER = `declare module 'prosebook' {
|
|
2123
|
-
interface Register {
|
|
2124
|
-
messages: Messages;
|
|
2125
|
-
locale: Locale;
|
|
2126
|
-
}
|
|
2127
|
-
}`;
|
|
2128
|
-
function catalogLiteral(config, locales, ext) {
|
|
2129
|
-
const eager = locales.filter((l) => l === config.source || config.bundled.includes(l));
|
|
2130
|
-
const lazy = locales.filter((l) => !eager.includes(l));
|
|
2131
|
-
const ident = (l) => `m_${l.replace(/\W/g, "_")}`;
|
|
2132
|
-
return {
|
|
2133
|
-
imports: eager.map((l) => `import ${ident(l)} from './${l}${ext}';`),
|
|
2134
|
-
literal: [
|
|
2135
|
-
"{",
|
|
2136
|
-
` source: ${quote(config.source)},`,
|
|
2137
|
-
` locales: [${locales.map(quote).join(", ")}],`,
|
|
2138
|
-
` messages: { ${eager.map((l) => `${prop(l)}: ${ident(l)}`).join(", ")} },`,
|
|
2139
|
-
` loaders: { ${lazy.map((l) => `${prop(l)}: () => import('./${l}${ext}')`).join(", ")} },`,
|
|
2140
|
-
"}"
|
|
2141
|
-
].join("\n")
|
|
2142
|
-
};
|
|
2143
|
-
}
|
|
2144
|
-
/** The JSON Schema an editor reads to complete and explain catalog entries. */
|
|
2145
|
-
function catalogSchema(config) {
|
|
2146
|
-
return {
|
|
2147
|
-
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
2148
|
-
title: "prosebook catalog",
|
|
2149
|
-
description: "Generated by `prosebook generate` from the config. Do not edit.",
|
|
2150
|
-
type: "object",
|
|
2151
|
-
propertyNames: { pattern: "^[a-z0-9]+(?:[-_][a-z0-9]+)*(?:\\.[a-z0-9]+(?:[-_][a-z0-9]+)*)+$" },
|
|
2152
|
-
additionalProperties: { oneOf: [{
|
|
2153
|
-
type: "string",
|
|
2154
|
-
description: `The message, in ${SYNTAX_NAME[config.messageFormat]}, with nothing said about it.`
|
|
2155
|
-
}, {
|
|
2156
|
-
type: "object",
|
|
2157
|
-
required: [config.source, ...config.require],
|
|
2158
|
-
additionalProperties: false,
|
|
2159
|
-
properties: {
|
|
2160
|
-
[config.source]: {
|
|
2161
|
-
type: "string",
|
|
2162
|
-
description: `The message, in ${SYNTAX_NAME[config.messageFormat]}.`
|
|
2163
|
-
},
|
|
2164
|
-
kind: {
|
|
2165
|
-
description: "What sort of string it is.",
|
|
2166
|
-
oneOf: Object.entries(config.kinds).map(([k, r]) => ({
|
|
2167
|
-
const: k,
|
|
2168
|
-
description: r.doc
|
|
2169
|
-
}))
|
|
2170
|
-
},
|
|
2171
|
-
surface: {
|
|
2172
|
-
description: "Where the string sits.",
|
|
2173
|
-
oneOf: Object.entries(config.surfaces).map(([k, d]) => ({
|
|
2174
|
-
const: k,
|
|
2175
|
-
description: d
|
|
2176
|
-
}))
|
|
2177
|
-
},
|
|
2178
|
-
context: {
|
|
2179
|
-
type: "string",
|
|
2180
|
-
description: "One sentence telling a translator where the string appears and what it answers to."
|
|
2181
|
-
},
|
|
2182
|
-
args: {
|
|
2183
|
-
type: "object",
|
|
2184
|
-
description: "What each placeholder holds.",
|
|
2185
|
-
additionalProperties: { type: "string" }
|
|
2186
|
-
},
|
|
2187
|
-
fragment: {
|
|
2188
|
-
type: "boolean",
|
|
2189
|
-
description: "Completes something beside it rather than standing as a sentence."
|
|
2190
|
-
},
|
|
2191
|
-
comment: {
|
|
2192
|
-
type: "string",
|
|
2193
|
-
description: "A change asked of whoever edits the entry next. Make it, then delete the comment."
|
|
2194
|
-
},
|
|
2195
|
-
remove: {
|
|
2196
|
-
type: "boolean",
|
|
2197
|
-
description: "Asks for the entry to go: delete it, its translations and the code that reads it."
|
|
2198
|
-
},
|
|
2199
|
-
allow: {
|
|
2200
|
-
type: "object",
|
|
2201
|
-
description: "Lint rules this entry is exempt from, each with the reason.",
|
|
2202
|
-
propertyNames: { enum: Object.keys(config.lint.rules) },
|
|
2203
|
-
additionalProperties: { type: "string" }
|
|
2204
|
-
}
|
|
2205
|
-
}
|
|
2206
|
-
}] }
|
|
2207
|
-
};
|
|
2208
|
-
}
|
|
2209
|
-
/** Everything `generate` writes, in memory. */
|
|
2210
|
-
function render(config, accept) {
|
|
2211
|
-
const catalog = loadCatalog(config);
|
|
2212
|
-
const { lock, stale } = reconcile(catalog, readLock(config.lock), accept);
|
|
2213
|
-
const locales = localesOf(catalog, config);
|
|
2214
|
-
const entries = localeEntries(catalog, config, stale);
|
|
2215
|
-
const ts = config.language === "ts";
|
|
2216
|
-
const head = banner(config);
|
|
2217
|
-
const files = /* @__PURE__ */ new Map();
|
|
2218
|
-
for (const [locale, list] of entries) files.set(join(config.out, `${locale}.${ts ? "ts" : "js"}`), localeModule(compileLocale(list), head, ts));
|
|
2219
|
-
const { imports, literal } = catalogLiteral(config, locales, config.importExtension);
|
|
2220
|
-
const types = typesBlock(catalog, locales);
|
|
2221
|
-
if (ts) files.set(join(config.out, "index.ts"), [
|
|
2222
|
-
head,
|
|
2223
|
-
"import { defineCatalog } from 'prosebook';",
|
|
2224
|
-
...imports,
|
|
2225
|
-
"",
|
|
2226
|
-
types,
|
|
2227
|
-
"",
|
|
2228
|
-
`export const catalog = defineCatalog<Messages, Locale>(${literal});`,
|
|
2229
|
-
"",
|
|
2230
|
-
REGISTER,
|
|
2231
|
-
""
|
|
2232
|
-
].join("\n"));
|
|
2233
|
-
else {
|
|
2234
|
-
files.set(join(config.out, "index.js"), [
|
|
2235
|
-
head,
|
|
2236
|
-
"import { defineCatalog } from 'prosebook';",
|
|
2237
|
-
...imports,
|
|
2238
|
-
"",
|
|
2239
|
-
`export const catalog = defineCatalog(${literal});`,
|
|
2240
|
-
""
|
|
2241
|
-
].join("\n"));
|
|
2242
|
-
files.set(join(config.out, "index.d.ts"), [
|
|
2243
|
-
head,
|
|
2244
|
-
"import type { Catalog } from 'prosebook';",
|
|
2245
|
-
"",
|
|
2246
|
-
types,
|
|
2247
|
-
"",
|
|
2248
|
-
"export declare const catalog: Catalog<Messages, Locale>;",
|
|
2249
|
-
"",
|
|
2250
|
-
REGISTER,
|
|
2251
|
-
""
|
|
2252
|
-
].join("\n"));
|
|
2253
|
-
}
|
|
2254
|
-
files.set(join(config.catalog, "catalog.schema.json"), `${JSON.stringify(catalogSchema(config), null, 2)}\n`);
|
|
2255
|
-
if (Object.keys(lock.translations).length > 0 || existsSync(config.lock)) files.set(config.lock, serializeLock(lock));
|
|
2256
|
-
return {
|
|
2257
|
-
catalog,
|
|
2258
|
-
files,
|
|
2259
|
-
lock,
|
|
2260
|
-
stale,
|
|
2261
|
-
entries
|
|
2262
|
-
};
|
|
2263
|
-
}
|
|
2264
|
-
/** Writes what changed. With `check`, writes nothing and returns what would have changed. */
|
|
2265
|
-
function writeFiles(files, check = false) {
|
|
2266
|
-
const changed = [];
|
|
2267
|
-
for (const [path, content] of files) {
|
|
2268
|
-
if ((existsSync(path) ? readFileSync(path, "utf8") : void 0) === content) continue;
|
|
2269
|
-
changed.push(path);
|
|
2270
|
-
if (check) continue;
|
|
2271
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
2272
|
-
writeFileSync(path, content);
|
|
2273
|
-
}
|
|
2274
|
-
return changed;
|
|
2275
|
-
}
|
|
2276
|
-
function generate(config, options = {}) {
|
|
2277
|
-
const generated = render(config, options.accept);
|
|
2278
|
-
return {
|
|
2279
|
-
...generated,
|
|
2280
|
-
changed: writeFiles(generated.files, options.check)
|
|
2281
|
-
};
|
|
2282
|
-
}
|
|
2283
|
-
// src/tools/scan.ts
|
|
2284
|
-
/**
|
|
2285
|
-
* What the code says: text a person reads that is still written in a component, and which catalog
|
|
2286
|
-
* keys the code names.
|
|
2287
|
-
*
|
|
2288
|
-
* Key references are found by reading string literals, not by tracing calls, so a key kept in an
|
|
2289
|
-
* array or a map still counts, and `nav.${id}` or `'nav.' + id` keeps every key under `nav.`. That
|
|
2290
|
-
* errs towards keeping a key, which is the safe direction both for `unused` and for pruning a bundle.
|
|
2291
|
-
*/
|
|
2292
|
-
const SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
2293
|
-
"node_modules",
|
|
2294
|
-
"dist",
|
|
2295
|
-
"build",
|
|
2296
|
-
"out",
|
|
2297
|
-
"coverage",
|
|
2298
|
-
".git"
|
|
2299
|
-
]);
|
|
2300
|
-
function sourceFiles(dirs, extensions, skip = []) {
|
|
2301
|
-
const files = [];
|
|
2302
|
-
const visit = (dir) => {
|
|
2303
|
-
let names;
|
|
2304
|
-
try {
|
|
2305
|
-
names = readdirSync(dir);
|
|
2306
|
-
} catch {
|
|
2307
|
-
return;
|
|
2308
|
-
}
|
|
2309
|
-
for (const name of names) {
|
|
2310
|
-
const path = join(dir, name);
|
|
2311
|
-
if (skip.some((s) => path === s || path.startsWith(`${s}/`) || path.startsWith(`${s}\\`))) continue;
|
|
2312
|
-
if (statSync(path).isDirectory()) {
|
|
2313
|
-
if (!SKIP_DIRS.has(name) && !name.startsWith(".")) visit(path);
|
|
2314
|
-
} else if (extensions.includes(extname(name)) && !name.endsWith(".d.ts")) files.push(path);
|
|
2315
|
-
}
|
|
2316
|
-
};
|
|
2317
|
-
for (const dir of dirs) visit(dir);
|
|
2318
|
-
return files.sort();
|
|
2319
|
-
}
|
|
2320
|
-
const LITERAL_KEY = /(['"`])([a-z0-9][\w-]*(?:\.[\w-]+)+)\1/g;
|
|
2321
|
-
const KEY_PREFIX = /(['"`])([a-z0-9][\w-]*(?:\.[\w-]+)*\.)(?:\1|\$\{)/g;
|
|
2322
|
-
function keyRefs(code, into = {
|
|
2323
|
-
exact: /* @__PURE__ */ new Set(),
|
|
2324
|
-
prefixes: /* @__PURE__ */ new Set()
|
|
2325
|
-
}) {
|
|
2326
|
-
for (const m of code.matchAll(LITERAL_KEY)) into.exact.add(m[2]);
|
|
2327
|
-
for (const m of code.matchAll(KEY_PREFIX)) into.prefixes.add(m[2]);
|
|
2328
|
-
return into;
|
|
2329
|
-
}
|
|
2330
|
-
function isReferenced(key, refs) {
|
|
2331
|
-
if (refs.exact.has(key)) return true;
|
|
2332
|
-
for (const prefix of refs.prefixes) if (key.startsWith(prefix)) return true;
|
|
2333
|
-
return false;
|
|
2334
|
-
}
|
|
2335
|
-
/**
|
|
2336
|
-
* Text that reads as code rather than prose: one token holding a path, a call, an identifier with an
|
|
2337
|
-
* underscore or a dotted name, as in `form.json()` or `src/index.ts`. Trailing punctuation doesn't
|
|
2338
|
-
* count, so `Done.` is still prose.
|
|
2339
|
-
*/
|
|
2340
|
-
function looksLikeCode(text) {
|
|
2341
|
-
const token = text.trim().replace(/[.!?:,…]+$/, "");
|
|
2342
|
-
return token !== "" && !/\s/.test(token) && /[/()_=<>{}[\].]|::/.test(token);
|
|
2343
|
-
}
|
|
2344
|
-
const NON_PROSE_ELEMENTS = /* @__PURE__ */ new Set([
|
|
2345
|
-
"code",
|
|
2346
|
-
"pre",
|
|
2347
|
-
"kbd",
|
|
2348
|
-
"samp",
|
|
2349
|
-
"script",
|
|
2350
|
-
"style",
|
|
2351
|
-
"svg",
|
|
2352
|
-
"path"
|
|
2353
|
-
]);
|
|
2354
|
-
const LETTER = /\p{L}/u;
|
|
2355
|
-
/** Literal text in JSX that a person reads, where it should be a catalog entry. */
|
|
2356
|
-
function hardcodedText(file, code, attributes) {
|
|
2357
|
-
const { program } = parseSync(file, code);
|
|
2358
|
-
const found = [];
|
|
2359
|
-
const lines = code.split("\n");
|
|
2360
|
-
const lineStarts = [];
|
|
2361
|
-
let offset = 0;
|
|
2362
|
-
for (const line of lines) {
|
|
2363
|
-
lineStarts.push(offset);
|
|
2364
|
-
offset += line.length + 1;
|
|
2365
|
-
}
|
|
2366
|
-
const position = (at) => {
|
|
2367
|
-
let lo = 0;
|
|
2368
|
-
let hi = lineStarts.length - 1;
|
|
2369
|
-
while (lo < hi) {
|
|
2370
|
-
const mid = lo + hi + 1 >> 1;
|
|
2371
|
-
if (lineStarts[mid] <= at) lo = mid;
|
|
2372
|
-
else hi = mid - 1;
|
|
2373
|
-
}
|
|
2374
|
-
return {
|
|
2375
|
-
line: lo + 1,
|
|
2376
|
-
column: at - lineStarts[lo] + 1
|
|
2377
|
-
};
|
|
2378
|
-
};
|
|
2379
|
-
const ignored = (line) => (lines[line - 1] ?? "").includes("prosebook-ignore") || (lines[line - 2] ?? "").includes("prosebook-ignore");
|
|
2380
|
-
const report = (node, text, where) => {
|
|
2381
|
-
const trimmed = text.replace(/\s+/g, " ").trim();
|
|
2382
|
-
if (!LETTER.test(trimmed) || looksLikeCode(trimmed)) return;
|
|
2383
|
-
const at = position(node.start + Math.max(0, code.slice(node.start, node.end).search(/\S/)));
|
|
2384
|
-
if (ignored(at.line)) return;
|
|
2385
|
-
found.push({
|
|
2386
|
-
file,
|
|
2387
|
-
...at,
|
|
2388
|
-
text: trimmed,
|
|
2389
|
-
where
|
|
2390
|
-
});
|
|
2391
|
-
};
|
|
2392
|
-
const visit = (node, inProse) => {
|
|
2393
|
-
let prose = inProse;
|
|
2394
|
-
if (node.type === "JSXElement") {
|
|
2395
|
-
const opening = node.openingElement;
|
|
2396
|
-
if (opening.name.type === "JSXIdentifier" && NON_PROSE_ELEMENTS.has(opening.name.name ?? "")) prose = false;
|
|
2397
|
-
}
|
|
2398
|
-
if (node.type === "JSXText" && prose) report(node, node.value, "text");
|
|
2399
|
-
if (node.type === "JSXExpressionContainer" && prose) {
|
|
2400
|
-
const expr = node.expression;
|
|
2401
|
-
if (expr.type === "Literal" && typeof expr.value === "string") report(expr, expr.value, "text");
|
|
2402
|
-
if (expr.type === "TemplateLiteral" && expr.expressions.length === 0) report(expr, String(expr.quasis[0]?.value.cooked), "text");
|
|
2403
|
-
}
|
|
2404
|
-
if (node.type === "JSXAttribute") {
|
|
2405
|
-
const name = node.name;
|
|
2406
|
-
const value = node.value;
|
|
2407
|
-
const attr = name.type === "JSXIdentifier" ? name.name ?? "" : "";
|
|
2408
|
-
if (attributes.includes(attr) && value !== null) {
|
|
2409
|
-
if (value.type === "Literal" && typeof value.value === "string") report(value, value.value, attr);
|
|
2410
|
-
if (value.type === "JSXExpressionContainer") {
|
|
2411
|
-
const expr = value.expression;
|
|
2412
|
-
if (expr.type === "Literal" && typeof expr.value === "string") report(expr, expr.value, attr);
|
|
2413
|
-
}
|
|
2414
|
-
}
|
|
2415
|
-
return;
|
|
2416
|
-
}
|
|
2417
|
-
for (const value of Object.values(node)) if (Array.isArray(value)) {
|
|
2418
|
-
for (const child of value) if (isNode(child)) visit(child, prose);
|
|
2419
|
-
} else if (isNode(value)) visit(value, prose);
|
|
2420
|
-
};
|
|
2421
|
-
visit(program, true);
|
|
2422
|
-
return found;
|
|
2423
|
-
}
|
|
2424
|
-
function isNode(value) {
|
|
2425
|
-
return typeof value === "object" && value !== null && typeof value.type === "string";
|
|
2426
|
-
}
|
|
2427
|
-
function scan(dirs, extensions, attributes, root, skip = []) {
|
|
2428
|
-
const refs = {
|
|
2429
|
-
exact: /* @__PURE__ */ new Set(),
|
|
2430
|
-
prefixes: /* @__PURE__ */ new Set()
|
|
2431
|
-
};
|
|
2432
|
-
const hardcoded = [];
|
|
2433
|
-
const files = sourceFiles(dirs, extensions, skip);
|
|
2434
|
-
for (const path of files) {
|
|
2435
|
-
const code = readFileSync(path, "utf8");
|
|
2436
|
-
keyRefs(code, refs);
|
|
2437
|
-
if (/\.[jt]sx$/.test(path)) hardcoded.push(...hardcodedText(relative(root, path).replaceAll("\\", "/"), code, attributes));
|
|
2438
|
-
}
|
|
2439
|
-
return {
|
|
2440
|
-
hardcoded,
|
|
2441
|
-
refs,
|
|
2442
|
-
files: files.length
|
|
2443
|
-
};
|
|
2444
|
-
}
|
|
2445
|
-
export { loadCatalog as A, toMf1 as B, compileLocale as C, KEY_PATTERN as D, CatalogError as E, writeMessage as F, intlDate as I, intlNumber as L, whereOf as M, SYNTAX_NAME as N, entryLocation as O, readMessage as P, parseMf2 as R, readEntry as S, localeModule as T, readLock as _, scan as a, applyFixes as b, generate as c, render as d, writeFiles as f, hash as g, emptyLock as h, looksLikeCode as i, previewOf as j, entryLocator as k, localeEntries as l, pseudoText as m, isReferenced as n, sourceFiles as o, pseudoAst as p, keyRefs as r, catalogSchema as s, hardcodedText as t, localesOf as u, reconcile as v, localeExpression as w, editEntry as x, serializeLock as y, toMf2 as z };
|
|
2446
|
-
|
|
2447
|
-
//# sourceMappingURL=scan-B0tZ7y-D.js.map
|