glotfox 0.0.0-stage → 0.1.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 +128 -2
- package/dist/_shared/formats.d.ts +18 -0
- package/dist/check.d.ts +23 -0
- package/dist/chunk-AQOIF7AF.js +1286 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +137 -0
- package/dist/config.d.ts +25 -0
- package/dist/extract.d.ts +45 -0
- package/dist/glob.d.ts +5 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +24 -0
- package/dist/init.d.ts +8 -0
- package/dist/scan.d.ts +18 -0
- package/dist/sync.d.ts +37 -0
- package/dist/types.d.ts +21 -0
- package/package.json +31 -3
|
@@ -0,0 +1,1286 @@
|
|
|
1
|
+
// src/config.ts
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
// ../shared/src/formats.ts
|
|
6
|
+
var FILE_FORMATS = ["json", "po", "yaml"];
|
|
7
|
+
var isFileFormat = (v) => FILE_FORMATS.includes(v);
|
|
8
|
+
var FORMAT_EXTENSION = { json: "json", po: "po", yaml: "yaml" };
|
|
9
|
+
var defaultPattern = (format) => `{locale}/{namespace}.${FORMAT_EXTENSION[format]}`;
|
|
10
|
+
var cmp = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
11
|
+
function parseMessages(format, text) {
|
|
12
|
+
if (format === "po") return parsePo(text);
|
|
13
|
+
if (format === "yaml") return parseYaml(text);
|
|
14
|
+
return parseJson(text);
|
|
15
|
+
}
|
|
16
|
+
function serializeMessages(format, messages, opts = {}) {
|
|
17
|
+
const keys = Object.keys(messages).sort(cmp);
|
|
18
|
+
if (format === "po") return serializePo(keys, messages, opts);
|
|
19
|
+
if (format === "yaml") return serializeYaml(keys, messages);
|
|
20
|
+
return JSON.stringify(Object.fromEntries(keys.map((k) => [k, messages[k]])), null, 2) + "\n";
|
|
21
|
+
}
|
|
22
|
+
function parseJson(text) {
|
|
23
|
+
let data;
|
|
24
|
+
try {
|
|
25
|
+
data = JSON.parse(text);
|
|
26
|
+
} catch (e) {
|
|
27
|
+
throw new Error(`invalid JSON (${e.message})`);
|
|
28
|
+
}
|
|
29
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) throw new Error("must be a JSON object");
|
|
30
|
+
for (const [k, v] of Object.entries(data)) if (typeof v !== "string") throw new Error(`value of "${k}" must be a string`);
|
|
31
|
+
return data;
|
|
32
|
+
}
|
|
33
|
+
var PO_ESCAPES = { n: "\n", t: " ", r: "\r", '"': '"', "\\": "\\" };
|
|
34
|
+
var poQuote = (s) => '"' + s.replace(/[\\"\n\t\r]/g, (c) => c === "\n" ? "\\n" : c === " " ? "\\t" : c === "\r" ? "\\r" : "\\" + c) + '"';
|
|
35
|
+
var poUnquote = (s, line) => {
|
|
36
|
+
if (!/^".*"$/.test(s)) throw new Error(`line ${line}: expected a quoted string`);
|
|
37
|
+
return s.slice(1, -1).replace(/\\(.)/g, (_, c) => PO_ESCAPES[c] ?? c);
|
|
38
|
+
};
|
|
39
|
+
var PLURAL_CATEGORIES = {
|
|
40
|
+
1: ["other"],
|
|
41
|
+
2: ["one", "other"],
|
|
42
|
+
3: ["one", "few", "other"],
|
|
43
|
+
4: ["one", "two", "few", "other"],
|
|
44
|
+
5: ["one", "two", "few", "many", "other"],
|
|
45
|
+
6: ["zero", "one", "two", "few", "many", "other"]
|
|
46
|
+
};
|
|
47
|
+
function parsePo(text) {
|
|
48
|
+
const out = {};
|
|
49
|
+
let nplurals = 2;
|
|
50
|
+
let entry = null;
|
|
51
|
+
let field = null;
|
|
52
|
+
const flush = () => {
|
|
53
|
+
const e = entry;
|
|
54
|
+
entry = null;
|
|
55
|
+
field = null;
|
|
56
|
+
if (!e || e.id === void 0) return;
|
|
57
|
+
if (e.ctxt === void 0 && e.id === "") {
|
|
58
|
+
const m = /Plural-Forms:[^\n]*nplurals\s*=\s*(\d+)/.exec(e.str ?? "");
|
|
59
|
+
if (m) nplurals = Number(m[1]);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const key = e.ctxt !== void 0 && e.ctxt !== "" ? e.ctxt : e.id;
|
|
63
|
+
if (key in out) throw new Error(`line ${e.line}: duplicate key "${key}"`);
|
|
64
|
+
if (e.plural.length > 0) {
|
|
65
|
+
const names = PLURAL_CATEGORIES[e.plural.length] ?? PLURAL_CATEGORIES[nplurals] ?? PLURAL_CATEGORIES[2];
|
|
66
|
+
const forms = e.plural.map((s, i) => `${names[i] ?? "other"} {${s.replace(/%d/g, "#")}}`);
|
|
67
|
+
out[key] = `{count, plural, ${forms.join(" ")}}`;
|
|
68
|
+
} else {
|
|
69
|
+
out[key] = e.str ?? "";
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const append = (e, f, s) => {
|
|
73
|
+
if (f === "ctxt") e.ctxt = (e.ctxt ?? "") + s;
|
|
74
|
+
else if (f === "id") e.id = (e.id ?? "") + s;
|
|
75
|
+
else if (f === "str") e.str = (e.str ?? "") + s;
|
|
76
|
+
else if (f === "plural_id") return;
|
|
77
|
+
else e.plural[f.plural] = (e.plural[f.plural] ?? "") + s;
|
|
78
|
+
};
|
|
79
|
+
const lines = text.split(/\r?\n/);
|
|
80
|
+
for (let i = 0; i < lines.length; i++) {
|
|
81
|
+
const n = i + 1;
|
|
82
|
+
const raw = lines[i].trim();
|
|
83
|
+
if (raw === "") {
|
|
84
|
+
flush();
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (raw.startsWith("#")) continue;
|
|
88
|
+
if (raw.startsWith('"')) {
|
|
89
|
+
if (!entry || field === null) throw new Error(`line ${n}: unexpected string`);
|
|
90
|
+
append(entry, field, poUnquote(raw, n));
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const m = /^(msgctxt|msgid_plural|msgid|msgstr(?:\[(\d+)\])?)\s+(".*")$/.exec(raw);
|
|
94
|
+
if (!m) throw new Error(`line ${n}: cannot parse "${raw.slice(0, 40)}"`);
|
|
95
|
+
const kw = m[1];
|
|
96
|
+
const value = poUnquote(m[3], n);
|
|
97
|
+
if (kw === "msgctxt" || kw === "msgid" && entry && (entry.id !== void 0 || entry.str !== void 0 || entry.plural.length)) {
|
|
98
|
+
flush();
|
|
99
|
+
}
|
|
100
|
+
entry ??= { plural: [], line: n };
|
|
101
|
+
if (kw === "msgctxt") field = "ctxt";
|
|
102
|
+
else if (kw === "msgid") field = "id";
|
|
103
|
+
else if (kw === "msgid_plural") field = "plural_id";
|
|
104
|
+
else if (m[2] !== void 0) field = { plural: Number(m[2]) };
|
|
105
|
+
else field = "str";
|
|
106
|
+
append(entry, field, value);
|
|
107
|
+
}
|
|
108
|
+
flush();
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
function serializePo(keys, messages, opts) {
|
|
112
|
+
const header = ["", "Content-Type: text/plain; charset=UTF-8", ...opts.locale ? [`Language: ${opts.locale}`] : []];
|
|
113
|
+
const blocks = ['msgid ""\nmsgstr ""\n' + header.map((h, i) => i === 0 ? "" : poQuote(h + "\n") + "\n").join("")];
|
|
114
|
+
for (const k of keys) {
|
|
115
|
+
blocks.push(`msgctxt ${poQuote(k)}
|
|
116
|
+
msgid ${poQuote(k)}
|
|
117
|
+
msgstr ${poQuote(messages[k])}
|
|
118
|
+
`);
|
|
119
|
+
}
|
|
120
|
+
return blocks.join("\n");
|
|
121
|
+
}
|
|
122
|
+
var YAML_RESERVED = /^(?:y|n|yes|no|true|false|on|off|null|~)$/i;
|
|
123
|
+
var yamlKey = (k) => /^[A-Za-z][A-Za-z0-9_-]*$/.test(k) && !YAML_RESERVED.test(k) ? k : JSON.stringify(k);
|
|
124
|
+
function serializeYaml(keys, messages) {
|
|
125
|
+
const root = { children: /* @__PURE__ */ new Map() };
|
|
126
|
+
for (const k of keys) {
|
|
127
|
+
let node = root;
|
|
128
|
+
for (const part of k.split(".")) {
|
|
129
|
+
if (node.value !== void 0) throw new Error(`key "${k}" conflicts with a shorter key (a key cannot also be a prefix)`);
|
|
130
|
+
let next = node.children.get(part);
|
|
131
|
+
if (!next) node.children.set(part, next = { children: /* @__PURE__ */ new Map() });
|
|
132
|
+
node = next;
|
|
133
|
+
}
|
|
134
|
+
if (node.children.size > 0) throw new Error(`key "${k}" conflicts with a longer key (a key cannot also be a prefix)`);
|
|
135
|
+
node.value = messages[k];
|
|
136
|
+
}
|
|
137
|
+
let out = "";
|
|
138
|
+
const emit = (node, indent) => {
|
|
139
|
+
for (const [name, child] of node.children) {
|
|
140
|
+
if (child.value !== void 0) out += `${indent}${yamlKey(name)}: ${JSON.stringify(child.value)}
|
|
141
|
+
`;
|
|
142
|
+
else {
|
|
143
|
+
out += `${indent}${yamlKey(name)}:
|
|
144
|
+
`;
|
|
145
|
+
emit(child, indent + " ");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
emit(root, "");
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
function yamlQuoted(s, line) {
|
|
153
|
+
const q = s[0];
|
|
154
|
+
let i = 1;
|
|
155
|
+
let value = "";
|
|
156
|
+
for (; i < s.length; i++) {
|
|
157
|
+
const c = s[i];
|
|
158
|
+
if (q === '"' && c === "\\") {
|
|
159
|
+
const end = s[i + 1] === "u" ? i + 6 : i + 2;
|
|
160
|
+
try {
|
|
161
|
+
value += JSON.parse(`"${s.slice(i, end)}"`);
|
|
162
|
+
} catch {
|
|
163
|
+
throw new Error(`line ${line}: unsupported escape sequence`);
|
|
164
|
+
}
|
|
165
|
+
i = end - 1;
|
|
166
|
+
} else if (c === q) {
|
|
167
|
+
if (q === "'" && s[i + 1] === "'") {
|
|
168
|
+
value += "'";
|
|
169
|
+
i++;
|
|
170
|
+
} else return { value, rest: s.slice(i + 1) };
|
|
171
|
+
} else value += c;
|
|
172
|
+
}
|
|
173
|
+
throw new Error(`line ${line}: unterminated quoted string`);
|
|
174
|
+
}
|
|
175
|
+
function parseYaml(text) {
|
|
176
|
+
const items = [];
|
|
177
|
+
text.split(/\r?\n/).forEach((raw, idx) => {
|
|
178
|
+
const line = idx + 1;
|
|
179
|
+
const content = raw.trimStart();
|
|
180
|
+
if (content === "" || content.startsWith("#")) return;
|
|
181
|
+
const indentStr = raw.slice(0, raw.length - content.length);
|
|
182
|
+
if (indentStr.includes(" ")) throw new Error(`line ${line}: tabs are not allowed for indentation`);
|
|
183
|
+
if (content === "---" || content === "...") return;
|
|
184
|
+
if (content.startsWith("- ") || content === "-") throw new Error(`line ${line}: lists are not supported`);
|
|
185
|
+
let key;
|
|
186
|
+
let rest;
|
|
187
|
+
if (content[0] === '"' || content[0] === "'") {
|
|
188
|
+
const q = yamlQuoted(content, line);
|
|
189
|
+
key = q.value;
|
|
190
|
+
rest = q.rest.trimStart();
|
|
191
|
+
if (!rest.startsWith(":")) throw new Error(`line ${line}: expected ":" after key`);
|
|
192
|
+
rest = rest.slice(1);
|
|
193
|
+
} else {
|
|
194
|
+
const m = /^([^\s:#][^:]*?)\s*:(?:\s+|$)(.*)$/.exec(content);
|
|
195
|
+
if (!m) throw new Error(`line ${line}: expected "key: value"`);
|
|
196
|
+
key = m[1];
|
|
197
|
+
rest = m[2];
|
|
198
|
+
}
|
|
199
|
+
rest = rest.trim();
|
|
200
|
+
let value;
|
|
201
|
+
if (rest === "" || rest.startsWith("#")) value = null;
|
|
202
|
+
else if (rest[0] === '"' || rest[0] === "'") {
|
|
203
|
+
const q = yamlQuoted(rest, line);
|
|
204
|
+
const tail = q.rest.trim();
|
|
205
|
+
if (tail !== "" && !tail.startsWith("#")) throw new Error(`line ${line}: unexpected text after quoted string`);
|
|
206
|
+
value = q.value;
|
|
207
|
+
} else if (/^[|>&*!\[{]/.test(rest)) {
|
|
208
|
+
throw new Error(`line ${line}: unsupported YAML value (only plain and quoted strings are supported)`);
|
|
209
|
+
} else value = rest.replace(/\s+#.*$/, "");
|
|
210
|
+
items.push({ indent: indentStr.length, key, value, line });
|
|
211
|
+
});
|
|
212
|
+
const out = {};
|
|
213
|
+
const walk2 = (pos, indent, prefix) => {
|
|
214
|
+
while (pos < items.length) {
|
|
215
|
+
const it = items[pos];
|
|
216
|
+
if (it.indent < indent) return pos;
|
|
217
|
+
if (it.indent > indent) throw new Error(`line ${it.line}: unexpected indentation`);
|
|
218
|
+
pos++;
|
|
219
|
+
const full = prefix + it.key;
|
|
220
|
+
if (it.value === null) {
|
|
221
|
+
const next = items[pos];
|
|
222
|
+
if (!next || next.indent <= indent) throw new Error(`line ${it.line}: "${full}" has no value`);
|
|
223
|
+
pos = walk2(pos, next.indent, full + ".");
|
|
224
|
+
} else {
|
|
225
|
+
if (full in out) throw new Error(`line ${it.line}: duplicate key "${full}"`);
|
|
226
|
+
out[full] = it.value;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return pos;
|
|
230
|
+
};
|
|
231
|
+
if (items.length > 0 && walk2(0, items[0].indent, "") < items.length) {
|
|
232
|
+
throw new Error(`line ${items[0].line}: inconsistent indentation`);
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// src/config.ts
|
|
238
|
+
var CONFIG_FILE = "glotfox.config.json";
|
|
239
|
+
var TOP_KEYS = /* @__PURE__ */ new Set([
|
|
240
|
+
"$schema",
|
|
241
|
+
"version",
|
|
242
|
+
"sourceLocale",
|
|
243
|
+
"targetLocales",
|
|
244
|
+
"defaultNamespace",
|
|
245
|
+
"include",
|
|
246
|
+
"exclude",
|
|
247
|
+
"output"
|
|
248
|
+
]);
|
|
249
|
+
var OUTPUT_KEYS = /* @__PURE__ */ new Set(["dir", "pattern", "format", "emptyValues", "types"]);
|
|
250
|
+
var NAMESPACE_RE = /^[a-z0-9][a-z0-9_-]*$/;
|
|
251
|
+
function strArray(v, name) {
|
|
252
|
+
if (!Array.isArray(v) || v.some((x) => typeof x !== "string")) throw new Error(`${name} must be an array of strings`);
|
|
253
|
+
return v;
|
|
254
|
+
}
|
|
255
|
+
function normalizeConfig(raw) {
|
|
256
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error("config must be a JSON object");
|
|
257
|
+
const r = raw;
|
|
258
|
+
for (const k of Object.keys(r)) if (!TOP_KEYS.has(k)) throw new Error(`unknown config field "${k}"`);
|
|
259
|
+
const version = r.version ?? 1;
|
|
260
|
+
if (version !== 1) throw new Error(`unsupported config version ${String(version)}`);
|
|
261
|
+
if (typeof r.sourceLocale !== "string" || !r.sourceLocale) throw new Error("sourceLocale is required");
|
|
262
|
+
const targetLocales = strArray(r.targetLocales, "targetLocales");
|
|
263
|
+
if (new Set(targetLocales).size !== targetLocales.length) throw new Error("targetLocales must not contain duplicates");
|
|
264
|
+
if (targetLocales.includes(r.sourceLocale)) throw new Error("targetLocales must not contain sourceLocale");
|
|
265
|
+
const defaultNamespace = r.defaultNamespace ?? "common";
|
|
266
|
+
if (typeof defaultNamespace !== "string" || !NAMESPACE_RE.test(defaultNamespace)) {
|
|
267
|
+
throw new Error("defaultNamespace must match [a-z0-9][a-z0-9_-]*");
|
|
268
|
+
}
|
|
269
|
+
const o = r.output ?? {};
|
|
270
|
+
if (typeof o !== "object" || o === null || Array.isArray(o)) throw new Error("output must be an object");
|
|
271
|
+
for (const k of Object.keys(o)) if (!OUTPUT_KEYS.has(k)) throw new Error(`unknown output field "${k}"`);
|
|
272
|
+
const format = o.format ?? "json";
|
|
273
|
+
if (!isFileFormat(format)) throw new Error(`output.format must be one of ${FILE_FORMATS.map((f) => `"${f}"`).join(", ")}`);
|
|
274
|
+
const pattern = o.pattern ?? defaultPattern(format);
|
|
275
|
+
if (typeof pattern !== "string" || !pattern.includes("{locale}") || !pattern.includes("{namespace}")) {
|
|
276
|
+
throw new Error("output.pattern must contain {locale} and {namespace}");
|
|
277
|
+
}
|
|
278
|
+
const emptyValues = o.emptyValues ?? "omit";
|
|
279
|
+
if (emptyValues !== "omit" && emptyValues !== "empty") throw new Error('output.emptyValues must be "omit" or "empty"');
|
|
280
|
+
const types = o.types ?? null;
|
|
281
|
+
if (types !== null && typeof types !== "string") throw new Error("output.types must be a string or null");
|
|
282
|
+
const dir = o.dir ?? "locales";
|
|
283
|
+
if (typeof dir !== "string") throw new Error("output.dir must be a string");
|
|
284
|
+
return {
|
|
285
|
+
version: 1,
|
|
286
|
+
sourceLocale: r.sourceLocale,
|
|
287
|
+
targetLocales,
|
|
288
|
+
defaultNamespace,
|
|
289
|
+
include: r.include === void 0 ? ["src/**/*"] : strArray(r.include, "include"),
|
|
290
|
+
exclude: r.exclude === void 0 ? ["**/node_modules/**"] : strArray(r.exclude, "exclude"),
|
|
291
|
+
output: { dir, pattern, format, emptyValues, types }
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
function loadConfig(cwd) {
|
|
295
|
+
let dir = path.resolve(cwd);
|
|
296
|
+
for (; ; ) {
|
|
297
|
+
const file = path.join(dir, CONFIG_FILE);
|
|
298
|
+
if (fs.existsSync(file)) {
|
|
299
|
+
try {
|
|
300
|
+
return { config: normalizeConfig(JSON.parse(fs.readFileSync(file, "utf8"))), root: dir };
|
|
301
|
+
} catch (e) {
|
|
302
|
+
throw new Error(`${file}: ${e.message}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const parent = path.dirname(dir);
|
|
306
|
+
if (parent === dir) throw new Error(`${CONFIG_FILE} not found. Run "glotfox init" first.`);
|
|
307
|
+
dir = parent;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// src/scan.ts
|
|
312
|
+
var KEY_RE = /^[A-Za-z][A-Za-z0-9_-]*(\.[A-Za-z][A-Za-z0-9_-]*){0,7}$/;
|
|
313
|
+
var MAX_KEY_LENGTH = 128;
|
|
314
|
+
var ESCAPES = { n: "\n", t: " ", r: "\r", b: "\b", f: "\f", v: "\v", "0": "\0" };
|
|
315
|
+
function readString(src, i) {
|
|
316
|
+
const q = src[i];
|
|
317
|
+
if (q !== "'" && q !== '"' && q !== "`") return null;
|
|
318
|
+
let value = "";
|
|
319
|
+
for (let j = i + 1; j < src.length; j++) {
|
|
320
|
+
const c = src[j];
|
|
321
|
+
if (c === "\\") {
|
|
322
|
+
const n = src[++j];
|
|
323
|
+
if (n === void 0) return null;
|
|
324
|
+
value += ESCAPES[n] ?? n;
|
|
325
|
+
} else if (c === q) return { value, end: j + 1 };
|
|
326
|
+
else if (q === "`" && c === "$" && src[j + 1] === "{") return null;
|
|
327
|
+
else if (c === "\n" && q !== "`") return null;
|
|
328
|
+
else value += c;
|
|
329
|
+
}
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
function skipWs(src, i) {
|
|
333
|
+
while (i < src.length && /\s/.test(src[i])) i++;
|
|
334
|
+
return i;
|
|
335
|
+
}
|
|
336
|
+
function readBraces(src, i) {
|
|
337
|
+
if (src[i] !== "{") return null;
|
|
338
|
+
let depth = 0;
|
|
339
|
+
for (let j = i; j < src.length; j++) {
|
|
340
|
+
const c = src[j];
|
|
341
|
+
if (c === "'" || c === '"' || c === "`") {
|
|
342
|
+
let k = j + 1;
|
|
343
|
+
while (k < src.length && src[k] !== c) k += src[k] === "\\" ? 2 : 1;
|
|
344
|
+
j = k;
|
|
345
|
+
} else if (c === "{") depth++;
|
|
346
|
+
else if (c === "}" && --depth === 0) return { text: src.slice(i, j + 1), end: j + 1 };
|
|
347
|
+
}
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
function objectString(obj, prop) {
|
|
351
|
+
const m = new RegExp(`(?:^|[\\s{,])${prop}\\s*:\\s*`).exec(obj);
|
|
352
|
+
if (!m) return void 0;
|
|
353
|
+
return readString(obj, m.index + m[0].length)?.value;
|
|
354
|
+
}
|
|
355
|
+
function objectNumber(obj, prop) {
|
|
356
|
+
const m = new RegExp(`(?:^|[\\s{,])${prop}\\s*:\\s*(\\d{1,6})\\s*(?=[,}])`).exec(obj);
|
|
357
|
+
const n = m ? Number(m[1]) : void 0;
|
|
358
|
+
return n !== void 0 && n > 0 ? n : void 0;
|
|
359
|
+
}
|
|
360
|
+
function splitNs(raw) {
|
|
361
|
+
const idx = raw.indexOf(":");
|
|
362
|
+
return idx > 0 ? { namespace: raw.slice(0, idx), key: raw.slice(idx + 1) } : { namespace: null, key: raw };
|
|
363
|
+
}
|
|
364
|
+
function lineAt(src, index) {
|
|
365
|
+
let n = 1;
|
|
366
|
+
for (let i = 0; i < index; i++) if (src.charCodeAt(i) === 10) n++;
|
|
367
|
+
return n;
|
|
368
|
+
}
|
|
369
|
+
function firstNamespace(src) {
|
|
370
|
+
const m = /\buseTranslation\s*\(\s*/.exec(src);
|
|
371
|
+
if (!m) return null;
|
|
372
|
+
let i = m.index + m[0].length;
|
|
373
|
+
if (src[i] === "[") i = skipWs(src, i + 1);
|
|
374
|
+
return readString(src, i)?.value ?? null;
|
|
375
|
+
}
|
|
376
|
+
function scanSource(src) {
|
|
377
|
+
const out = [];
|
|
378
|
+
const fileNs = firstNamespace(src);
|
|
379
|
+
const tCall = /(?:(?<![\w$.])|(?<=\.))t\s*\(\s*/g;
|
|
380
|
+
for (let m; m = tCall.exec(src); ) {
|
|
381
|
+
const first = readString(src, m.index + m[0].length);
|
|
382
|
+
if (!first) continue;
|
|
383
|
+
const { namespace, key } = splitNs(first.value);
|
|
384
|
+
const item = { namespace, key, line: lineAt(src, m.index) };
|
|
385
|
+
let i = skipWs(src, first.end);
|
|
386
|
+
if (src[i] === ",") {
|
|
387
|
+
i = skipWs(src, i + 1);
|
|
388
|
+
const str = readString(src, i);
|
|
389
|
+
if (str) {
|
|
390
|
+
item.defaultValue = str.value;
|
|
391
|
+
const j = skipWs(src, str.end);
|
|
392
|
+
if (src[j] === ",") {
|
|
393
|
+
const opts = readBraces(src, skipWs(src, j + 1));
|
|
394
|
+
if (opts) {
|
|
395
|
+
item.description = objectString(opts.text, "description");
|
|
396
|
+
item.maxLength = objectNumber(opts.text, "maxLength");
|
|
397
|
+
if (!namespace) item.namespace = objectString(opts.text, "ns") ?? null;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
} else {
|
|
401
|
+
const obj = readBraces(src, i);
|
|
402
|
+
if (obj) {
|
|
403
|
+
item.defaultValue = objectString(obj.text, "defaultValue");
|
|
404
|
+
item.description = objectString(obj.text, "description");
|
|
405
|
+
item.maxLength = objectNumber(obj.text, "maxLength");
|
|
406
|
+
if (!namespace) item.namespace = objectString(obj.text, "ns") ?? null;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
if (item.namespace === null) item.namespace = fileNs;
|
|
411
|
+
out.push(item);
|
|
412
|
+
}
|
|
413
|
+
const trans = /<Trans\b((?:[^>{]|\{[^}]*\})*?)(\/>|>([\s\S]*?)<\/Trans>)/g;
|
|
414
|
+
for (let m; m = trans.exec(src); ) {
|
|
415
|
+
const attrs = m[1] ?? "";
|
|
416
|
+
const attr = (name) => {
|
|
417
|
+
const a = new RegExp(`\\b${name}\\s*=\\s*(?:\\{\\s*)?`).exec(attrs);
|
|
418
|
+
return a ? readString(attrs, a.index + a[0].length)?.value : void 0;
|
|
419
|
+
};
|
|
420
|
+
const attrNumber = (name) => {
|
|
421
|
+
const a = new RegExp(`\\b${name}\\s*=\\s*\\{\\s*(\\d{1,6})\\s*\\}`).exec(attrs);
|
|
422
|
+
return a && Number(a[1]) > 0 ? Number(a[1]) : void 0;
|
|
423
|
+
};
|
|
424
|
+
const raw = attr("i18nKey");
|
|
425
|
+
if (raw === void 0) continue;
|
|
426
|
+
const { namespace, key } = splitNs(raw);
|
|
427
|
+
const children = m[3]?.trim();
|
|
428
|
+
const plainChildren = children && !/[<{]/.test(children) ? children.replace(/\s+/g, " ") : void 0;
|
|
429
|
+
out.push({
|
|
430
|
+
namespace: namespace ?? attr("ns") ?? fileNs,
|
|
431
|
+
key,
|
|
432
|
+
defaultValue: attr("defaults") ?? plainChildren,
|
|
433
|
+
description: attr("description"),
|
|
434
|
+
maxLength: attrNumber("maxLength"),
|
|
435
|
+
line: lineAt(src, m.index)
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
return out;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// src/extract.ts
|
|
442
|
+
import fs3 from "node:fs";
|
|
443
|
+
import path3 from "node:path";
|
|
444
|
+
|
|
445
|
+
// src/glob.ts
|
|
446
|
+
import fs2 from "node:fs";
|
|
447
|
+
import path2 from "node:path";
|
|
448
|
+
function globToRegExp(glob) {
|
|
449
|
+
let re = "";
|
|
450
|
+
let inBrace = 0;
|
|
451
|
+
for (let i = 0; i < glob.length; i++) {
|
|
452
|
+
const c = glob[i];
|
|
453
|
+
if (c === "*") {
|
|
454
|
+
if (glob[i + 1] === "*") {
|
|
455
|
+
i++;
|
|
456
|
+
if (glob[i + 1] === "/") {
|
|
457
|
+
i++;
|
|
458
|
+
re += "(?:.*/)?";
|
|
459
|
+
} else re += ".*";
|
|
460
|
+
} else re += "[^/]*";
|
|
461
|
+
} else if (c === "?") re += "[^/]";
|
|
462
|
+
else if (c === "{") {
|
|
463
|
+
inBrace++;
|
|
464
|
+
re += "(?:";
|
|
465
|
+
} else if (c === "}" && inBrace) {
|
|
466
|
+
inBrace--;
|
|
467
|
+
re += ")";
|
|
468
|
+
} else if (c === "," && inBrace) re += "|";
|
|
469
|
+
else re += c.replace(/[.+^$()|[\]\\]/g, "\\$&");
|
|
470
|
+
}
|
|
471
|
+
return new RegExp(`^${re}$`);
|
|
472
|
+
}
|
|
473
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git"]);
|
|
474
|
+
function walk(root, rel = "") {
|
|
475
|
+
const out = [];
|
|
476
|
+
let entries;
|
|
477
|
+
try {
|
|
478
|
+
entries = fs2.readdirSync(path2.join(root, rel), { withFileTypes: true });
|
|
479
|
+
} catch {
|
|
480
|
+
return out;
|
|
481
|
+
}
|
|
482
|
+
for (const e of entries) {
|
|
483
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
484
|
+
if (e.isDirectory()) {
|
|
485
|
+
if (!SKIP_DIRS.has(e.name)) out.push(...walk(root, r));
|
|
486
|
+
} else if (e.isFile()) out.push(r);
|
|
487
|
+
}
|
|
488
|
+
return out.sort();
|
|
489
|
+
}
|
|
490
|
+
function matchFiles(root, include, exclude) {
|
|
491
|
+
const inc = include.map(globToRegExp);
|
|
492
|
+
const exc = exclude.map(globToRegExp);
|
|
493
|
+
return walk(root).filter((f) => inc.some((r) => r.test(f)) && !exc.some((r) => r.test(f)));
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// src/extract.ts
|
|
497
|
+
var sortObject = (o) => Object.fromEntries(Object.entries(o).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
|
|
498
|
+
function filePath(root, config, locale, namespace) {
|
|
499
|
+
const rel = config.output.pattern.replace("{locale}", locale).replace("{namespace}", namespace);
|
|
500
|
+
return path3.resolve(root, config.output.dir, rel);
|
|
501
|
+
}
|
|
502
|
+
function metaFilePath(root, config, locale, namespace) {
|
|
503
|
+
const file = filePath(root, config, locale, `${namespace}.meta`);
|
|
504
|
+
return config.output.format === "json" ? file : file.replace(/\.[^./\\]+$/, ".json");
|
|
505
|
+
}
|
|
506
|
+
function readJsonObject(file) {
|
|
507
|
+
if (!fs3.existsSync(file)) return {};
|
|
508
|
+
let data;
|
|
509
|
+
try {
|
|
510
|
+
data = JSON.parse(fs3.readFileSync(file, "utf8"));
|
|
511
|
+
} catch (e) {
|
|
512
|
+
throw new Error(`${file}: invalid JSON (${e.message})`);
|
|
513
|
+
}
|
|
514
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) throw new Error(`${file}: must be a JSON object`);
|
|
515
|
+
return data;
|
|
516
|
+
}
|
|
517
|
+
function readMessages(file, config) {
|
|
518
|
+
if (!fs3.existsSync(file)) return {};
|
|
519
|
+
try {
|
|
520
|
+
return parseMessages(config.output.format, fs3.readFileSync(file, "utf8"));
|
|
521
|
+
} catch (e) {
|
|
522
|
+
throw new Error(`${file}: ${e.message}`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
function existingNamespaces(root, config, locale) {
|
|
526
|
+
const base = path3.resolve(root, config.output.dir);
|
|
527
|
+
const [pre, post] = config.output.pattern.replace("{locale}", locale).split("{namespace}");
|
|
528
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
529
|
+
const re = new RegExp(`^${esc(pre)}([a-z0-9][a-z0-9_-]*)${esc(post)}$`);
|
|
530
|
+
const names = [];
|
|
531
|
+
for (const f of walk(base)) {
|
|
532
|
+
const m = re.exec(f);
|
|
533
|
+
if (m) names.push(m[1]);
|
|
534
|
+
}
|
|
535
|
+
return names;
|
|
536
|
+
}
|
|
537
|
+
function write(file, text, opts, res) {
|
|
538
|
+
if (fs3.existsSync(file) && fs3.readFileSync(file, "utf8") === text) return;
|
|
539
|
+
res.written.push(file);
|
|
540
|
+
if (opts.dryRun) return;
|
|
541
|
+
fs3.mkdirSync(path3.dirname(file), { recursive: true });
|
|
542
|
+
fs3.writeFileSync(file, text);
|
|
543
|
+
}
|
|
544
|
+
function scanProject(root, config) {
|
|
545
|
+
const uses = [];
|
|
546
|
+
const warnings = [];
|
|
547
|
+
const files = matchFiles(root, config.include, config.exclude);
|
|
548
|
+
for (const rel of files) {
|
|
549
|
+
if (!/\.(?:[cm]?[jt]s|[jt]sx)$/.test(rel)) continue;
|
|
550
|
+
for (const item of scanSource(fs3.readFileSync(path3.join(root, rel), "utf8"))) {
|
|
551
|
+
const ns = item.namespace ?? config.defaultNamespace;
|
|
552
|
+
const where = `${rel}:${item.line}`;
|
|
553
|
+
if (!NAMESPACE_RE.test(ns)) {
|
|
554
|
+
warnings.push(`${where}: invalid namespace "${ns}", skipped`);
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
if (item.key.length > MAX_KEY_LENGTH || !KEY_RE.test(item.key)) {
|
|
558
|
+
warnings.push(`${where}: invalid key "${item.key}", skipped`);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
uses.push({
|
|
562
|
+
id: `${ns}:${item.key}`,
|
|
563
|
+
namespace: ns,
|
|
564
|
+
key: item.key,
|
|
565
|
+
file: rel.split(path3.sep).join("/"),
|
|
566
|
+
line: item.line,
|
|
567
|
+
defaultValue: item.defaultValue,
|
|
568
|
+
description: item.description,
|
|
569
|
+
maxLength: item.maxLength
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return { files: files.length, uses, warnings };
|
|
574
|
+
}
|
|
575
|
+
function extract(opts) {
|
|
576
|
+
const { config, root } = loadConfig(opts.cwd);
|
|
577
|
+
const res = { files: 0, found: [], added: [], unused: [], warnings: [], written: [] };
|
|
578
|
+
const found = /* @__PURE__ */ new Map();
|
|
579
|
+
const scanned = scanProject(root, config);
|
|
580
|
+
res.files = scanned.files;
|
|
581
|
+
res.warnings.push(...scanned.warnings);
|
|
582
|
+
for (const use of scanned.uses) {
|
|
583
|
+
const keys = found.get(use.namespace) ?? /* @__PURE__ */ new Map();
|
|
584
|
+
found.set(use.namespace, keys);
|
|
585
|
+
const prev = keys.get(use.key) ?? {};
|
|
586
|
+
keys.set(use.key, {
|
|
587
|
+
value: prev.value ?? use.defaultValue,
|
|
588
|
+
description: prev.description ?? use.description,
|
|
589
|
+
maxLength: prev.maxLength ?? use.maxLength
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
const src = config.sourceLocale;
|
|
593
|
+
const namespaces = /* @__PURE__ */ new Set([...found.keys(), ...existingNamespaces(root, config, src)]);
|
|
594
|
+
for (const ns of [...namespaces].sort()) {
|
|
595
|
+
const keys = found.get(ns) ?? /* @__PURE__ */ new Map();
|
|
596
|
+
const srcFile = filePath(root, config, src, ns);
|
|
597
|
+
const messages = readMessages(srcFile, config);
|
|
598
|
+
const meta = readJsonObject(metaFilePath(root, config, src, ns));
|
|
599
|
+
let metaChanged = false;
|
|
600
|
+
for (const [key, info] of [...keys].sort(([a], [b]) => a < b ? -1 : 1)) {
|
|
601
|
+
const id = `${ns}:${key}`;
|
|
602
|
+
res.found.push(id);
|
|
603
|
+
if (!(key in messages)) {
|
|
604
|
+
const clash = Object.keys(messages).find((k) => k.startsWith(`${key}.`) || key.startsWith(`${k}.`));
|
|
605
|
+
if (clash) {
|
|
606
|
+
res.warnings.push(`${id}: conflicts with existing key "${clash}" (a key cannot also be a prefix), skipped`);
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
if (info.value === void 0) res.warnings.push(`${id}: no default text found, using the key as placeholder`);
|
|
610
|
+
messages[key] = info.value ?? key;
|
|
611
|
+
res.added.push(id);
|
|
612
|
+
}
|
|
613
|
+
for (const field of ["description", "maxLength"]) {
|
|
614
|
+
const next = info[field];
|
|
615
|
+
if (next === void 0) continue;
|
|
616
|
+
const entry = meta[key] ??= {};
|
|
617
|
+
if (typeof entry !== "object" || entry === null) break;
|
|
618
|
+
if ((entry[field] === void 0 || opts.force) && entry[field] !== next) {
|
|
619
|
+
entry[field] = next;
|
|
620
|
+
metaChanged = true;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
for (const key of Object.keys(messages)) if (!keys.has(key)) res.unused.push(`${ns}:${key}`);
|
|
625
|
+
const render2 = (m, locale) => serializeMessages(config.output.format, m, { locale });
|
|
626
|
+
write(srcFile, render2(messages, src), opts, res);
|
|
627
|
+
if (metaChanged) write(metaFilePath(root, config, src, ns), JSON.stringify(sortObject(meta), null, 2) + "\n", opts, res);
|
|
628
|
+
for (const locale of config.targetLocales) {
|
|
629
|
+
const file = filePath(root, config, locale, ns);
|
|
630
|
+
const target = readMessages(file, config);
|
|
631
|
+
if (config.output.emptyValues === "empty") {
|
|
632
|
+
for (const key of Object.keys(messages)) if (!(key in target)) target[key] = "";
|
|
633
|
+
}
|
|
634
|
+
write(file, render2(target, locale), opts, res);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return res;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// src/init.ts
|
|
641
|
+
import fs4 from "node:fs";
|
|
642
|
+
import path4 from "node:path";
|
|
643
|
+
function init(opts) {
|
|
644
|
+
const file = path4.join(opts.cwd, CONFIG_FILE);
|
|
645
|
+
if (fs4.existsSync(file) && !opts.force) throw new Error(`${CONFIG_FILE} already exists (use --force to overwrite)`);
|
|
646
|
+
const dirs = ["src", "app", "pages", "components"].filter((d) => fs4.existsSync(path4.join(opts.cwd, d)));
|
|
647
|
+
const exts = "{ts,tsx,js,jsx,mjs,cjs}";
|
|
648
|
+
const config = {
|
|
649
|
+
version: 1,
|
|
650
|
+
sourceLocale: opts.sourceLocale ?? "en",
|
|
651
|
+
targetLocales: opts.targetLocales ?? [],
|
|
652
|
+
defaultNamespace: "common",
|
|
653
|
+
include: (dirs.length ? dirs : ["src"]).map((d) => `${d}/**/*.${exts}`),
|
|
654
|
+
exclude: ["**/*.test.*", "**/*.spec.*", "**/node_modules/**"],
|
|
655
|
+
output: { dir: "locales", pattern: "{locale}/{namespace}.json" }
|
|
656
|
+
};
|
|
657
|
+
normalizeConfig(config);
|
|
658
|
+
fs4.writeFileSync(file, JSON.stringify(config, null, 2) + "\n");
|
|
659
|
+
return file;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// src/types.ts
|
|
663
|
+
import fs5 from "node:fs";
|
|
664
|
+
import path5 from "node:path";
|
|
665
|
+
var ARG_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
666
|
+
var PRIMITIVE = "string | number";
|
|
667
|
+
var TAG = "(chunks: never[]) => unknown";
|
|
668
|
+
function messageArgs(message) {
|
|
669
|
+
const args = /* @__PURE__ */ new Map();
|
|
670
|
+
const add = (name, type) => {
|
|
671
|
+
if (!args.has(name)) args.set(name, /* @__PURE__ */ new Set());
|
|
672
|
+
args.get(name).add(type);
|
|
673
|
+
};
|
|
674
|
+
let i = 0;
|
|
675
|
+
const fail = (why) => {
|
|
676
|
+
throw new Error(`${why} at position ${i}`);
|
|
677
|
+
};
|
|
678
|
+
const skipWs2 = () => {
|
|
679
|
+
while (i < message.length && /\s/.test(message[i])) i++;
|
|
680
|
+
};
|
|
681
|
+
const readUntil = (re) => {
|
|
682
|
+
const start = i;
|
|
683
|
+
while (i < message.length && !re.test(message[i])) i++;
|
|
684
|
+
return message.slice(start, i).trim();
|
|
685
|
+
};
|
|
686
|
+
const parseText = (depth, inPlural) => {
|
|
687
|
+
while (i < message.length) {
|
|
688
|
+
const c = message[i];
|
|
689
|
+
if (c === "'") {
|
|
690
|
+
const n = message[i + 1];
|
|
691
|
+
if (n === "'") i += 2;
|
|
692
|
+
else if (n === "{" || n === "}" || inPlural && n === "#" || n === "<") {
|
|
693
|
+
i++;
|
|
694
|
+
while (i < message.length) {
|
|
695
|
+
if (message[i] === "'") {
|
|
696
|
+
if (message[i + 1] === "'") i += 2;
|
|
697
|
+
else {
|
|
698
|
+
i++;
|
|
699
|
+
break;
|
|
700
|
+
}
|
|
701
|
+
} else i++;
|
|
702
|
+
}
|
|
703
|
+
} else i++;
|
|
704
|
+
} else if (c === "}") {
|
|
705
|
+
if (depth === 0) fail("unmatched '}'");
|
|
706
|
+
return;
|
|
707
|
+
} else if (c === "{") {
|
|
708
|
+
i++;
|
|
709
|
+
parseArgument();
|
|
710
|
+
} else if (c === "<") {
|
|
711
|
+
const m = /^<(\/?)([A-Za-z][\w-]*)\s*\/?>/.exec(message.slice(i));
|
|
712
|
+
if (m) {
|
|
713
|
+
if (!m[1]) add(m[2], TAG);
|
|
714
|
+
i += m[0].length;
|
|
715
|
+
} else i++;
|
|
716
|
+
} else i++;
|
|
717
|
+
}
|
|
718
|
+
if (depth > 0) fail("missing '}'");
|
|
719
|
+
};
|
|
720
|
+
const parseArgument = () => {
|
|
721
|
+
const name = readUntil(/[,}]/);
|
|
722
|
+
if (!ARG_RE.test(name)) fail(`invalid argument name "${name}"`);
|
|
723
|
+
if (message[i] === "}") {
|
|
724
|
+
i++;
|
|
725
|
+
add(name, PRIMITIVE);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
if (i >= message.length) fail("missing '}'");
|
|
729
|
+
i++;
|
|
730
|
+
const type = readUntil(/[,}]/);
|
|
731
|
+
if (type === "number" || type === "date" || type === "time") {
|
|
732
|
+
add(name, type === "number" ? "number" : "Date | number");
|
|
733
|
+
let depth = 1;
|
|
734
|
+
while (i < message.length && depth > 0) {
|
|
735
|
+
if (message[i] === "{") depth++;
|
|
736
|
+
else if (message[i] === "}") depth--;
|
|
737
|
+
i++;
|
|
738
|
+
}
|
|
739
|
+
if (depth > 0) fail("missing '}'");
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
if (type !== "plural" && type !== "selectordinal" && type !== "select") fail(`unsupported argument type "${type}"`);
|
|
743
|
+
if (message[i] !== ",") fail("expected ','");
|
|
744
|
+
i++;
|
|
745
|
+
const isSelect = type === "select";
|
|
746
|
+
const selectors = [];
|
|
747
|
+
skipWs2();
|
|
748
|
+
const offset = /^offset:\s*\d+/.exec(message.slice(i));
|
|
749
|
+
if (offset) i += offset[0].length;
|
|
750
|
+
for (; ; ) {
|
|
751
|
+
skipWs2();
|
|
752
|
+
if (i >= message.length) fail("missing '}'");
|
|
753
|
+
if (message[i] === "}") break;
|
|
754
|
+
const sel = readUntil(/[\s{}]/);
|
|
755
|
+
skipWs2();
|
|
756
|
+
if (!sel || message[i] !== "{") fail("expected branch");
|
|
757
|
+
i++;
|
|
758
|
+
parseText(1, !isSelect);
|
|
759
|
+
i++;
|
|
760
|
+
selectors.push(sel);
|
|
761
|
+
}
|
|
762
|
+
i++;
|
|
763
|
+
if (!selectors.includes("other")) fail(`"${name}" is missing the required "other" branch`);
|
|
764
|
+
if (isSelect) {
|
|
765
|
+
const lits = selectors.filter((s) => s !== "other").map((s) => JSON.stringify(s));
|
|
766
|
+
add(name, [...lits, "(string & {})"].join(" | "));
|
|
767
|
+
} else add(name, "number");
|
|
768
|
+
};
|
|
769
|
+
parseText(0, false);
|
|
770
|
+
return args;
|
|
771
|
+
}
|
|
772
|
+
function argsType(args) {
|
|
773
|
+
if (args === null) return "{ [name: string]: unknown }";
|
|
774
|
+
if (args.size === 0) return "{}";
|
|
775
|
+
const fields = [...args].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([name, types]) => `${JSON.stringify(name)}: ${[...types].map((x) => `(${x})`).join(" | ")}`);
|
|
776
|
+
return `{ ${fields.join("; ")} }`;
|
|
777
|
+
}
|
|
778
|
+
function renderTypes(catalog, defaultNamespace, warnings = []) {
|
|
779
|
+
const lines = [];
|
|
780
|
+
let keys = 0;
|
|
781
|
+
for (const ns of [...catalog.keys()].sort()) {
|
|
782
|
+
const messages = catalog.get(ns);
|
|
783
|
+
for (const key of Object.keys(messages).sort()) {
|
|
784
|
+
let args = null;
|
|
785
|
+
try {
|
|
786
|
+
args = messageArgs(messages[key]);
|
|
787
|
+
} catch (e) {
|
|
788
|
+
warnings.push(`${ns}:${key}: cannot parse message (${e.message}); placeholders are untyped`);
|
|
789
|
+
}
|
|
790
|
+
const type = argsType(args);
|
|
791
|
+
lines.push(` ${JSON.stringify(`${ns}:${key}`)}: ${type};`);
|
|
792
|
+
if (ns === defaultNamespace) lines.push(` ${JSON.stringify(key)}: ${type};`);
|
|
793
|
+
keys++;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
const text = `// Generated by "glotfox types". Do not edit.
|
|
797
|
+
/* eslint-disable */
|
|
798
|
+
|
|
799
|
+
/** Placeholder values required by each translation key (\`namespace:key\`, or just \`key\` in the default namespace). */
|
|
800
|
+
export interface GlotfoxMessages {
|
|
801
|
+
${lines.join("\n")}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
export type TranslationKey = keyof GlotfoxMessages;
|
|
805
|
+
|
|
806
|
+
/** Options that may accompany any call. */
|
|
807
|
+
export interface TranslateOptions {
|
|
808
|
+
defaultValue?: string;
|
|
809
|
+
description?: string;
|
|
810
|
+
/** Character limit of the translation; read by "glotfox extract", ignored at runtime. */
|
|
811
|
+
maxLength?: number;
|
|
812
|
+
ns?: string;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
type TranslateArgs<K extends TranslationKey> = keyof GlotfoxMessages[K] extends never
|
|
816
|
+
? [options?: TranslateOptions]
|
|
817
|
+
: [values: GlotfoxMessages[K] & TranslateOptions];
|
|
818
|
+
|
|
819
|
+
/** Type-safe translate function: unknown keys and missing or mistyped placeholders are compile errors. */
|
|
820
|
+
export type TFunction = <K extends TranslationKey>(key: K, ...args: TranslateArgs<K>) => string;
|
|
821
|
+
`;
|
|
822
|
+
return { text, keys };
|
|
823
|
+
}
|
|
824
|
+
function generateTypes(opts) {
|
|
825
|
+
const { config, root } = loadConfig(opts.cwd);
|
|
826
|
+
const out = opts.out ?? config.output.types;
|
|
827
|
+
if (!out) throw new Error('no output file: set "output.types" in glotfox.config.json or pass --out <file>');
|
|
828
|
+
const catalog = /* @__PURE__ */ new Map();
|
|
829
|
+
for (const ns of existingNamespaces(root, config, config.sourceLocale)) {
|
|
830
|
+
if (ns.endsWith(".meta")) continue;
|
|
831
|
+
catalog.set(ns, readMessages(filePath(root, config, config.sourceLocale, ns), config));
|
|
832
|
+
}
|
|
833
|
+
const warnings = [];
|
|
834
|
+
const { text, keys } = renderTypes(catalog, config.defaultNamespace, warnings);
|
|
835
|
+
const file = path5.resolve(root, out);
|
|
836
|
+
let written = false;
|
|
837
|
+
if (!(fs5.existsSync(file) && fs5.readFileSync(file, "utf8") === text)) {
|
|
838
|
+
written = true;
|
|
839
|
+
if (!opts.dryRun) {
|
|
840
|
+
fs5.mkdirSync(path5.dirname(file), { recursive: true });
|
|
841
|
+
fs5.writeFileSync(file, text);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
return { file, keys, warnings, written };
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// src/sync.ts
|
|
848
|
+
import fs6 from "node:fs";
|
|
849
|
+
import path6 from "node:path";
|
|
850
|
+
var DEFAULT_API_URL = "https://glotfox.app.dukauskas.lt";
|
|
851
|
+
var PUSH_BATCH = 500;
|
|
852
|
+
var MAX_REFERENCES = 50;
|
|
853
|
+
var Api = class {
|
|
854
|
+
base;
|
|
855
|
+
key;
|
|
856
|
+
doFetch;
|
|
857
|
+
projectId;
|
|
858
|
+
constructor(opts) {
|
|
859
|
+
const key = opts.apiKey ?? process.env.GLOTFOX_API_KEY;
|
|
860
|
+
if (!key) throw new Error("missing API key: set GLOTFOX_API_KEY (create one in the GlotFox dashboard)");
|
|
861
|
+
this.key = key;
|
|
862
|
+
this.base = (opts.apiUrl ?? process.env.GLOTFOX_API_URL ?? DEFAULT_API_URL).replace(/\/+$/, "");
|
|
863
|
+
this.doFetch = opts.fetch ?? fetch;
|
|
864
|
+
}
|
|
865
|
+
async request(method, route, body) {
|
|
866
|
+
const res = await this.doFetch(`${this.base}/api/v1${route}`, {
|
|
867
|
+
method,
|
|
868
|
+
headers: { authorization: `Bearer ${this.key}`, ...body === void 0 ? {} : { "content-type": "application/json" } },
|
|
869
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
870
|
+
});
|
|
871
|
+
const text = await res.text();
|
|
872
|
+
let data;
|
|
873
|
+
try {
|
|
874
|
+
data = text ? JSON.parse(text) : void 0;
|
|
875
|
+
} catch {
|
|
876
|
+
data = void 0;
|
|
877
|
+
}
|
|
878
|
+
if (!res.ok) {
|
|
879
|
+
const msg = data?.error ?? res.statusText;
|
|
880
|
+
throw new Error(`${method} ${route} failed: ${res.status} ${msg}`);
|
|
881
|
+
}
|
|
882
|
+
return data;
|
|
883
|
+
}
|
|
884
|
+
async project(route) {
|
|
885
|
+
this.projectId ??= (await this.request("GET", "/project")).id;
|
|
886
|
+
return `/projects/${this.projectId}${route}`;
|
|
887
|
+
}
|
|
888
|
+
};
|
|
889
|
+
async function push(opts) {
|
|
890
|
+
const { config, root } = loadConfig(opts.cwd);
|
|
891
|
+
const src = config.sourceLocale;
|
|
892
|
+
const refs = /* @__PURE__ */ new Map();
|
|
893
|
+
for (const use of scanProject(root, config).uses) {
|
|
894
|
+
const list = refs.get(use.id) ?? [];
|
|
895
|
+
refs.set(use.id, list);
|
|
896
|
+
if (list.length < MAX_REFERENCES && !list.some((r) => r.file === use.file && r.line === use.line)) {
|
|
897
|
+
list.push({ file: use.file, line: use.line });
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
const keys = [];
|
|
901
|
+
for (const ns of existingNamespaces(root, config, src).sort()) {
|
|
902
|
+
if (ns.endsWith(".meta")) continue;
|
|
903
|
+
const messages = readMessages(filePath(root, config, src, ns), config);
|
|
904
|
+
const metaFile = metaFilePath(root, config, src, ns);
|
|
905
|
+
const meta = fs6.existsSync(metaFile) ? JSON.parse(fs6.readFileSync(metaFile, "utf8")) : {};
|
|
906
|
+
for (const [key, value] of Object.entries(messages)) {
|
|
907
|
+
if (value === "") throw new Error(`${ns}:${key}: source value must not be empty`);
|
|
908
|
+
const { description, maxLength } = meta[key] ?? {};
|
|
909
|
+
keys.push({
|
|
910
|
+
key: `${ns}:${key}`,
|
|
911
|
+
value,
|
|
912
|
+
...typeof description === "string" ? { context: description } : {},
|
|
913
|
+
...typeof maxLength === "number" && Number.isInteger(maxLength) && maxLength > 0 ? { maxLength } : {},
|
|
914
|
+
references: refs.get(`${ns}:${key}`) ?? []
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
const res = { keys: keys.length, created: 0, updated: 0 };
|
|
919
|
+
if (opts.dryRun) return res;
|
|
920
|
+
const api = new Api(opts);
|
|
921
|
+
for (let i = 0; i < keys.length; i += PUSH_BATCH) {
|
|
922
|
+
const r = await api.request("POST", await api.project("/translation-keys"), {
|
|
923
|
+
keys: keys.slice(i, i + PUSH_BATCH)
|
|
924
|
+
});
|
|
925
|
+
res.created += r.created;
|
|
926
|
+
res.updated += r.updated;
|
|
927
|
+
}
|
|
928
|
+
return res;
|
|
929
|
+
}
|
|
930
|
+
async function pull(opts) {
|
|
931
|
+
const { config, root } = loadConfig(opts.cwd);
|
|
932
|
+
const api = new Api(opts);
|
|
933
|
+
const res = { written: [], received: {} };
|
|
934
|
+
for (const locale of config.targetLocales) {
|
|
935
|
+
const { translations } = await api.request(
|
|
936
|
+
"GET",
|
|
937
|
+
await api.project(`/translations/${encodeURIComponent(locale)}?status=approved`)
|
|
938
|
+
);
|
|
939
|
+
res.received[locale] = translations.length;
|
|
940
|
+
const byNs = /* @__PURE__ */ new Map();
|
|
941
|
+
for (const t of translations) {
|
|
942
|
+
const i = t.key.indexOf(":");
|
|
943
|
+
const ns = t.key.slice(0, i);
|
|
944
|
+
byNs.set(ns, { ...byNs.get(ns), [t.key.slice(i + 1)]: t.value });
|
|
945
|
+
}
|
|
946
|
+
for (const [ns, approved] of [...byNs].sort(([a], [b]) => a < b ? -1 : 1)) {
|
|
947
|
+
const file = filePath(root, config, locale, ns);
|
|
948
|
+
const merged = { ...readMessages(file, config), ...approved };
|
|
949
|
+
const text = serializeMessages(config.output.format, merged, { locale });
|
|
950
|
+
if (fs6.existsSync(file) && fs6.readFileSync(file, "utf8") === text) continue;
|
|
951
|
+
res.written.push(file);
|
|
952
|
+
if (opts.dryRun) continue;
|
|
953
|
+
fs6.mkdirSync(path6.dirname(file), { recursive: true });
|
|
954
|
+
fs6.writeFileSync(file, text);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return res;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// ../shared/src/qa.ts
|
|
961
|
+
var ARG_RE2 = /^[A-Za-z0-9_.-]+$/;
|
|
962
|
+
var TAG_RE = /^<(\/?)([A-Za-z][\w-]*)\s*\/?>/;
|
|
963
|
+
var ParseError = class extends Error {
|
|
964
|
+
constructor(message, pos) {
|
|
965
|
+
super(message);
|
|
966
|
+
this.pos = pos;
|
|
967
|
+
}
|
|
968
|
+
};
|
|
969
|
+
function parseIcu(message) {
|
|
970
|
+
let i = 0;
|
|
971
|
+
const placeholders = /* @__PURE__ */ new Map();
|
|
972
|
+
const note = (p) => {
|
|
973
|
+
const id = `${p.kind}:${p.name}`;
|
|
974
|
+
if (!placeholders.has(id)) placeholders.set(id, p);
|
|
975
|
+
};
|
|
976
|
+
const fail = (why) => {
|
|
977
|
+
throw new ParseError(why, i);
|
|
978
|
+
};
|
|
979
|
+
const skipWs2 = () => {
|
|
980
|
+
while (i < message.length && /\s/.test(message[i])) i++;
|
|
981
|
+
};
|
|
982
|
+
const readUntil = (re) => {
|
|
983
|
+
const start = i;
|
|
984
|
+
while (i < message.length && !re.test(message[i])) i++;
|
|
985
|
+
return message.slice(start, i).trim();
|
|
986
|
+
};
|
|
987
|
+
const parseNodes = (depth, inPlural) => {
|
|
988
|
+
const nodes = [];
|
|
989
|
+
let text = "";
|
|
990
|
+
const flush = () => {
|
|
991
|
+
if (text) nodes.push({ t: "text", text });
|
|
992
|
+
text = "";
|
|
993
|
+
};
|
|
994
|
+
while (i < message.length) {
|
|
995
|
+
const c = message[i];
|
|
996
|
+
if (c === "'") {
|
|
997
|
+
const n = message[i + 1];
|
|
998
|
+
if (n === "'") {
|
|
999
|
+
text += "'";
|
|
1000
|
+
i += 2;
|
|
1001
|
+
} else if (n === "{" || n === "}" || n === "<" || inPlural && n === "#") {
|
|
1002
|
+
i++;
|
|
1003
|
+
while (i < message.length) {
|
|
1004
|
+
if (message[i] === "'") {
|
|
1005
|
+
if (message[i + 1] === "'") {
|
|
1006
|
+
text += "'";
|
|
1007
|
+
i += 2;
|
|
1008
|
+
} else {
|
|
1009
|
+
i++;
|
|
1010
|
+
break;
|
|
1011
|
+
}
|
|
1012
|
+
} else text += message[i++];
|
|
1013
|
+
}
|
|
1014
|
+
} else {
|
|
1015
|
+
text += c;
|
|
1016
|
+
i++;
|
|
1017
|
+
}
|
|
1018
|
+
} else if (c === "}") {
|
|
1019
|
+
if (depth === 0) fail("unmatched '}'");
|
|
1020
|
+
flush();
|
|
1021
|
+
return nodes;
|
|
1022
|
+
} else if (c === "{") {
|
|
1023
|
+
flush();
|
|
1024
|
+
i++;
|
|
1025
|
+
nodes.push(parseArgument());
|
|
1026
|
+
} else if (c === "<") {
|
|
1027
|
+
const m = TAG_RE.exec(message.slice(i));
|
|
1028
|
+
if (m) {
|
|
1029
|
+
if (!m[1]) note({ kind: "tag", name: m[2], text: m[0] });
|
|
1030
|
+
i += m[0].length;
|
|
1031
|
+
} else {
|
|
1032
|
+
text += c;
|
|
1033
|
+
i++;
|
|
1034
|
+
}
|
|
1035
|
+
} else {
|
|
1036
|
+
text += c;
|
|
1037
|
+
i++;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
if (depth > 0) fail("missing '}'");
|
|
1041
|
+
flush();
|
|
1042
|
+
return nodes;
|
|
1043
|
+
};
|
|
1044
|
+
const parseArgument = () => {
|
|
1045
|
+
const name = readUntil(/[,}]/);
|
|
1046
|
+
if (!ARG_RE2.test(name)) fail(`invalid argument name "${name}"`);
|
|
1047
|
+
note({ kind: "arg", name, text: `{${name}}` });
|
|
1048
|
+
if (message[i] === "}") {
|
|
1049
|
+
i++;
|
|
1050
|
+
return { t: "arg", name };
|
|
1051
|
+
}
|
|
1052
|
+
if (i >= message.length) fail("missing '}'");
|
|
1053
|
+
i++;
|
|
1054
|
+
const type = readUntil(/[,}]/);
|
|
1055
|
+
if (type === "number" || type === "date" || type === "time") {
|
|
1056
|
+
let depth = 1;
|
|
1057
|
+
while (i < message.length && depth > 0) {
|
|
1058
|
+
if (message[i] === "{") depth++;
|
|
1059
|
+
else if (message[i] === "}") depth--;
|
|
1060
|
+
i++;
|
|
1061
|
+
}
|
|
1062
|
+
if (depth > 0) fail("missing '}'");
|
|
1063
|
+
return { t: "arg", name };
|
|
1064
|
+
}
|
|
1065
|
+
if (type !== "plural" && type !== "selectordinal" && type !== "select") fail(`unsupported argument type "${type}"`);
|
|
1066
|
+
if (message[i] !== ",") fail("expected ','");
|
|
1067
|
+
i++;
|
|
1068
|
+
const isSelect = type === "select";
|
|
1069
|
+
skipWs2();
|
|
1070
|
+
const offset = /^offset:\s*\d+/.exec(message.slice(i));
|
|
1071
|
+
if (offset) i += offset[0].length;
|
|
1072
|
+
const branches = [];
|
|
1073
|
+
for (; ; ) {
|
|
1074
|
+
skipWs2();
|
|
1075
|
+
if (i >= message.length) fail("missing '}'");
|
|
1076
|
+
if (message[i] === "}") break;
|
|
1077
|
+
const sel = readUntil(/[\s{}]/);
|
|
1078
|
+
skipWs2();
|
|
1079
|
+
if (!sel || message[i] !== "{") fail("expected branch");
|
|
1080
|
+
if (!isSelect && !/^(=\d+|zero|one|two|few|many|other)$/.test(sel)) fail(`invalid plural selector "${sel}"`);
|
|
1081
|
+
i++;
|
|
1082
|
+
branches.push({ sel, nodes: parseNodes(1, !isSelect) });
|
|
1083
|
+
i++;
|
|
1084
|
+
}
|
|
1085
|
+
i++;
|
|
1086
|
+
if (branches.length === 0) fail(`"${name}" has no branches`);
|
|
1087
|
+
return { t: "choice", name, type, branches };
|
|
1088
|
+
};
|
|
1089
|
+
try {
|
|
1090
|
+
return { nodes: parseNodes(0, false), placeholders };
|
|
1091
|
+
} catch (e) {
|
|
1092
|
+
if (e instanceof ParseError) return { nodes: [], placeholders, error: { message: e.message, pos: e.pos } };
|
|
1093
|
+
throw e;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
function textOf(nodes, out = []) {
|
|
1097
|
+
for (const n of nodes) {
|
|
1098
|
+
if (n.t === "text") out.push(n.text);
|
|
1099
|
+
else if (n.t === "choice") for (const b of n.branches) textOf(b.nodes, out);
|
|
1100
|
+
}
|
|
1101
|
+
return out;
|
|
1102
|
+
}
|
|
1103
|
+
function render(nodes) {
|
|
1104
|
+
let s = "";
|
|
1105
|
+
for (const n of nodes) {
|
|
1106
|
+
if (n.t === "text") s += n.text;
|
|
1107
|
+
else if (n.t === "choice") {
|
|
1108
|
+
let best = "";
|
|
1109
|
+
for (const b of n.branches) {
|
|
1110
|
+
const r = render(b.nodes);
|
|
1111
|
+
if (r.length > best.length) best = r;
|
|
1112
|
+
}
|
|
1113
|
+
s += best;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
return s;
|
|
1117
|
+
}
|
|
1118
|
+
function choices(nodes, out = []) {
|
|
1119
|
+
for (const n of nodes) {
|
|
1120
|
+
if (n.t === "choice") {
|
|
1121
|
+
out.push(n);
|
|
1122
|
+
for (const b of n.branches) choices(b.nodes, out);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
return out;
|
|
1126
|
+
}
|
|
1127
|
+
var cpLength = (s) => [...s].length;
|
|
1128
|
+
var PUNCT_MAP = { "\u061F": "?", "\uFF1F": "?", "\uFF01": "!", "\u3002": ".", "\uFF1A": ":", "\u2026": "\u2026" };
|
|
1129
|
+
var ENDING_PUNCT = /[.!?:…؟?!。:]+$/u;
|
|
1130
|
+
function endingPunctuation(text) {
|
|
1131
|
+
const m = ENDING_PUNCT.exec(text.trimEnd());
|
|
1132
|
+
if (!m) return "";
|
|
1133
|
+
return [...m[0].replace(/\.{3,}/g, "\u2026")].map((c) => PUNCT_MAP[c] ?? c).join("");
|
|
1134
|
+
}
|
|
1135
|
+
function asciiDigits(s) {
|
|
1136
|
+
return s.replace(new RegExp("\\p{Nd}", "gu"), (c) => {
|
|
1137
|
+
const cp = c.codePointAt(0);
|
|
1138
|
+
let start = cp;
|
|
1139
|
+
while (new RegExp("^\\p{Nd}$", "u").test(String.fromCodePoint(start - 1))) start--;
|
|
1140
|
+
return String((cp - start) % 10);
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
function digitRuns(s) {
|
|
1144
|
+
return asciiDigits(s).match(/\d+/g) ?? [];
|
|
1145
|
+
}
|
|
1146
|
+
var escapeRe = (s) => s.replace(/[.*+?^$()|[\]\\{}]/g, "\\$&");
|
|
1147
|
+
var containsTerm = (text, term) => new RegExp(`(?<![\\p{L}\\p{N}])${escapeRe(term)}(?![\\p{L}\\p{N}])`, "iu").test(text);
|
|
1148
|
+
function requiredCategories(locale, type) {
|
|
1149
|
+
try {
|
|
1150
|
+
return new Intl.PluralRules(locale, { type }).resolvedOptions().pluralCategories;
|
|
1151
|
+
} catch {
|
|
1152
|
+
return null;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
function checkTranslation({ source, target, locale, maxLength, glossary }) {
|
|
1156
|
+
if (!target.trim()) return [];
|
|
1157
|
+
const issues = [];
|
|
1158
|
+
const add = (code, severity, message, range) => issues.push({ code, severity, message, ...range ? { range } : {} });
|
|
1159
|
+
const src = parseIcu(source);
|
|
1160
|
+
const tgt = parseIcu(target);
|
|
1161
|
+
if (tgt.error) {
|
|
1162
|
+
add("icu-invalid", "error", `Not valid ICU MessageFormat: ${tgt.error.message}`, {
|
|
1163
|
+
start: Math.min(tgt.error.pos, target.length),
|
|
1164
|
+
end: Math.min(tgt.error.pos + 1, target.length)
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
const bothParse = !src.error && !tgt.error;
|
|
1168
|
+
const srcPh = bothParse ? [...src.placeholders.values()] : [];
|
|
1169
|
+
const tgtPh = bothParse ? [...tgt.placeholders.values()] : [];
|
|
1170
|
+
const id = (p) => `${p.kind}:${p.name}`;
|
|
1171
|
+
const show = (p) => p.kind === "arg" ? `{${p.name}}` : `<${p.name}>`;
|
|
1172
|
+
const tgtIds = new Set(tgtPh.map(id));
|
|
1173
|
+
const srcIds = new Set(srcPh.map(id));
|
|
1174
|
+
for (const p of srcPh) if (!tgtIds.has(id(p))) add("placeholder-missing", "warning", `Placeholder ${show(p)} is missing from the translation`);
|
|
1175
|
+
for (const p of tgtPh) {
|
|
1176
|
+
if (srcIds.has(id(p))) continue;
|
|
1177
|
+
const start = target.indexOf(p.text);
|
|
1178
|
+
add("placeholder-extra", "warning", `Placeholder ${show(p)} is not in the source`, start < 0 ? void 0 : { start, end: start + p.text.length });
|
|
1179
|
+
}
|
|
1180
|
+
if (!tgt.error) {
|
|
1181
|
+
for (const c of choices(tgt.nodes)) {
|
|
1182
|
+
if (c.type === "select") {
|
|
1183
|
+
if (!c.branches.some((b) => b.sel === "other")) add("icu-invalid", "error", `"${c.name}" (select) is missing the required "other" branch`);
|
|
1184
|
+
continue;
|
|
1185
|
+
}
|
|
1186
|
+
const have2 = new Set(c.branches.map((b) => b.sel));
|
|
1187
|
+
if (!have2.has("other")) {
|
|
1188
|
+
add("plural-categories", "error", `"${c.name}" (${c.type}) is missing the required "other" category`);
|
|
1189
|
+
continue;
|
|
1190
|
+
}
|
|
1191
|
+
const need = requiredCategories(locale, c.type === "plural" ? "cardinal" : "ordinal");
|
|
1192
|
+
const missing = (need ?? []).filter((k) => !have2.has(k));
|
|
1193
|
+
if (missing.length) {
|
|
1194
|
+
add("plural-categories", "warning", `"${c.name}" (${c.type}) is missing ${locale} categories: ${missing.join(", ")}`);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
const rendered = tgt.error ? target : render(tgt.nodes);
|
|
1199
|
+
if (maxLength !== void 0 && cpLength(rendered) > maxLength) {
|
|
1200
|
+
add("too-long", "warning", `Translation is ${cpLength(rendered)} characters, over the limit of ${maxLength}`);
|
|
1201
|
+
}
|
|
1202
|
+
for (const t of glossary ?? []) {
|
|
1203
|
+
if (!t.term || !containsTerm(source, t.term)) continue;
|
|
1204
|
+
const expected = t.doNotTranslate ? t.term : t.translations[locale];
|
|
1205
|
+
if (expected && !target.toLowerCase().includes(expected.toLowerCase())) {
|
|
1206
|
+
add("glossary", "warning", `Glossary: "${t.term}" should be "${expected}"`);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
const lead = (s) => /^\s*/.exec(s)[0];
|
|
1210
|
+
const trail = (s) => /\s*$/.exec(s)[0];
|
|
1211
|
+
if (lead(source) !== lead(target)) {
|
|
1212
|
+
add("whitespace", "warning", "Leading whitespace differs from the source", { start: 0, end: lead(target).length });
|
|
1213
|
+
}
|
|
1214
|
+
if (trail(source) !== trail(target)) {
|
|
1215
|
+
add("whitespace", "warning", "Trailing whitespace differs from the source", { start: target.length - trail(target).length, end: target.length });
|
|
1216
|
+
}
|
|
1217
|
+
const sp = endingPunctuation(source);
|
|
1218
|
+
const tp = endingPunctuation(target);
|
|
1219
|
+
if (sp !== tp) {
|
|
1220
|
+
const end = target.trimEnd().length;
|
|
1221
|
+
add(
|
|
1222
|
+
"punctuation",
|
|
1223
|
+
"warning",
|
|
1224
|
+
sp ? tp ? `Ending punctuation "${tp}" differs from the source "${sp}"` : `Source ends with "${sp}"` : `Translation ends with "${tp}" but the source does not`,
|
|
1225
|
+
{ start: end - tp.length, end }
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
const srcText = src.error ? source : textOf(src.nodes).join(" ");
|
|
1229
|
+
const tgtText = tgt.error ? target : textOf(tgt.nodes).join(" ");
|
|
1230
|
+
const have = new Set(digitRuns(tgtText));
|
|
1231
|
+
const lostNumbers = [...new Set(digitRuns(srcText))].filter((n) => !have.has(n));
|
|
1232
|
+
if (lostNumbers.length) add("numbers", "warning", `Number(s) from the source missing: ${lostNumbers.join(", ")}`);
|
|
1233
|
+
const srcRendered = src.error ? source : render(src.nodes);
|
|
1234
|
+
if (target.trim() === source.trim() && new RegExp("\\p{L}", "u").test(srcRendered) && cpLength(srcRendered.trim()) >= 4) {
|
|
1235
|
+
add("untranslated", "warning", "Translation is identical to the source");
|
|
1236
|
+
}
|
|
1237
|
+
return issues;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// src/check.ts
|
|
1241
|
+
function qaFindings(cwd) {
|
|
1242
|
+
const { config, root } = loadConfig(cwd);
|
|
1243
|
+
const out = [];
|
|
1244
|
+
for (const locale of config.targetLocales) {
|
|
1245
|
+
for (const ns of existingNamespaces(root, config, locale)) {
|
|
1246
|
+
if (ns.endsWith(".meta")) continue;
|
|
1247
|
+
const source = readMessages(filePath(root, config, config.sourceLocale, ns), config);
|
|
1248
|
+
const target = readMessages(filePath(root, config, locale, ns), config);
|
|
1249
|
+
for (const [key, value] of Object.entries(target)) {
|
|
1250
|
+
if (typeof source[key] !== "string" || typeof value !== "string") continue;
|
|
1251
|
+
for (const issue of checkTranslation({ source: source[key], target: value, locale })) {
|
|
1252
|
+
out.push({ locale, key: `${ns}:${key}`, issue });
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
return out;
|
|
1258
|
+
}
|
|
1259
|
+
function check(opts) {
|
|
1260
|
+
const ex = extract({ cwd: opts.cwd, dryRun: true });
|
|
1261
|
+
const warnings = [...ex.warnings];
|
|
1262
|
+
let typesStale = false;
|
|
1263
|
+
let typesFile;
|
|
1264
|
+
if (loadConfig(opts.cwd).config.output.types) {
|
|
1265
|
+
const t = generateTypes({ cwd: opts.cwd, dryRun: true });
|
|
1266
|
+
warnings.push(...t.warnings);
|
|
1267
|
+
typesStale = t.written;
|
|
1268
|
+
typesFile = t.file;
|
|
1269
|
+
}
|
|
1270
|
+
const issues = qaFindings(opts.cwd);
|
|
1271
|
+
const failing = issues.some((f) => f.issue.severity === "error" || opts.strict);
|
|
1272
|
+
return { missing: ex.added, typesStale, typesFile, warnings, issues, ok: ex.added.length === 0 && !typesStale && !failing };
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
export {
|
|
1276
|
+
normalizeConfig,
|
|
1277
|
+
loadConfig,
|
|
1278
|
+
scanSource,
|
|
1279
|
+
extract,
|
|
1280
|
+
init,
|
|
1281
|
+
messageArgs,
|
|
1282
|
+
generateTypes,
|
|
1283
|
+
push,
|
|
1284
|
+
pull,
|
|
1285
|
+
check
|
|
1286
|
+
};
|