@blocofy/cli 0.10.0 → 0.12.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 +208 -20
- package/bin/blocofy.mjs +424 -43
- package/lib/args.mjs +2 -1
- package/lib/http.mjs +4 -1
- package/lib/output.mjs +23 -4
- package/lib/site-state.mjs +33 -10
- package/lib/target.mjs +71 -13
- package/lib/theme-sync.mjs +163 -63
- package/lib/translations.mjs +1059 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1059 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #925 — translation packages (`blocofy-translation/1`) for `blocofy translations export|import`:
|
|
3
|
+
* GET /api/v1/translations/export (read scopes of the kinds) → one window of a package (JSON, `next_cursor`)
|
|
4
|
+
* POST /api/v1/translations/import (write scopes of the kinds) → an import report (≤ 500 units, or one group ≤ 5,000)
|
|
5
|
+
*
|
|
6
|
+
* The package rules (`readTranslationPackage`, `chunkPackage`) and the XLIFF 1.2 dialect (`toXliff`, `fromXliff`) are
|
|
7
|
+
* ports of the platform's (multisite-cms `packages/cms/src/i18n/translation-package.ts` and `translation-xliff.ts`).
|
|
8
|
+
* `toXliff` is byte-identical. The platform reads XLIFF with a linear pre-scan and then fast-xml-parser; the CLI has no
|
|
9
|
+
* dependencies, so the SAME pre-scan is followed by a small well-formedness parser of its own. Both are pinned by the
|
|
10
|
+
* shared fixtures `test/fixtures/translation-xliff-vector.{package.json,xlf,cases.json}`.
|
|
11
|
+
*
|
|
12
|
+
* Retries (lib/http.mjs): an export window is a read. An import converges when resent: a text already written answers
|
|
13
|
+
* `unchanged`, so a replay never writes twice. A wet import with `publish` is the exception and is sent once: a resend
|
|
14
|
+
* cannot see what the first call published (`ImportOutcomeUnknown`).
|
|
15
|
+
*/
|
|
16
|
+
import { fetchWithRetry } from "./http.mjs";
|
|
17
|
+
import { CliRefusal } from "./media-uses.mjs";
|
|
18
|
+
|
|
19
|
+
export const TRANSLATION_FORMAT = "blocofy-translation/1";
|
|
20
|
+
export const TRANSLATION_KINDS = ["page", "media", "entry", "navigation", "settings", "theme", "option"];
|
|
21
|
+
export const TRANSLATION_UNIT_TYPES = ["text", "richtext", "html", "url", "slug"];
|
|
22
|
+
export const TRANSLATION_STATES = ["missing", "stale", "translated"];
|
|
23
|
+
export const TRANSLATION_ONLY = ["all", "missing", "stale", "pending"];
|
|
24
|
+
export const IMPORT_UNIT_STATES = ["applied", "unchanged", "skipped_empty", "source_changed", "target_changed", "invalid", "blocked"];
|
|
25
|
+
export const EXPORT_MAX_LIMIT = 5000;
|
|
26
|
+
export const IMPORT_MAX_UNITS = 500;
|
|
27
|
+
/** One group may be sent whole up to this many units (the importer's page stamp needs the whole page). */
|
|
28
|
+
export const IMPORT_MAX_GROUP_UNITS = 5000;
|
|
29
|
+
/** The v1 import route refuses a JSON body longer than this many characters (413). */
|
|
30
|
+
export const MAX_IMPORT_BODY_CHARS = 8_000_000;
|
|
31
|
+
/** The converter's own bound on an XLIFF text (the platform's `XLIFF_MAX_CHARS`). */
|
|
32
|
+
export const XLIFF_MAX_CHARS = 8_000_000;
|
|
33
|
+
export const XLIFF_NAMESPACE = "urn:oasis:names:tc:xliff:document:1.2";
|
|
34
|
+
export const BLOCOFY_XLIFF_NAMESPACE = "https://blocofy.com/ns/translation/1";
|
|
35
|
+
|
|
36
|
+
export class XliffError extends Error {
|
|
37
|
+
constructor(message) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = "XliffError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── package: ids, reading, chunking (translation-package.ts) ────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
const SEGMENT = /^[A-Za-z0-9_-]+$/;
|
|
46
|
+
const CANONICAL_INT = /^(0|[1-9]\d*)$/;
|
|
47
|
+
const ITEM_PATH = /^(0|[1-9]\d*)(\.(0|[1-9]\d*))*$/;
|
|
48
|
+
const MAX_ID_LENGTH = 600;
|
|
49
|
+
const MAX_OPTION_VALUE = 200;
|
|
50
|
+
const PAGE_UNIT_FIELDS = ["title", "seo_title", "seo_description", "slug"];
|
|
51
|
+
|
|
52
|
+
const isIdSegment = (value) => typeof value === "string" && SEGMENT.test(value);
|
|
53
|
+
const dotted = (value) => value.split(".").every(isIdSegment);
|
|
54
|
+
|
|
55
|
+
function parseEntryId(text) {
|
|
56
|
+
if (!CANONICAL_INT.test(text) || text === "0") return null;
|
|
57
|
+
const n = Number(text);
|
|
58
|
+
return Number.isSafeInteger(n) ? n : null;
|
|
59
|
+
}
|
|
60
|
+
function parseItemPath(text) {
|
|
61
|
+
if (!ITEM_PATH.test(text)) return null;
|
|
62
|
+
const path = text.split(".").map(Number);
|
|
63
|
+
return path.every(Number.isSafeInteger) ? path : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function unitIdOf(p) {
|
|
67
|
+
const seg = (value) => {
|
|
68
|
+
if (!isIdSegment(value)) throw new Error(`Not an id segment: ${JSON.stringify(value)}`);
|
|
69
|
+
return value;
|
|
70
|
+
};
|
|
71
|
+
const path = (value) => {
|
|
72
|
+
if (!dotted(value)) throw new Error(`Not an id path: ${JSON.stringify(value)}`);
|
|
73
|
+
return value;
|
|
74
|
+
};
|
|
75
|
+
switch (p.kind) {
|
|
76
|
+
case "page":
|
|
77
|
+
return "node" in p ? `page:${seg(p.handle)}:${seg(p.node)}.${seg(p.setting)}` : `page:${seg(p.handle)}:@${p.page}`;
|
|
78
|
+
case "media":
|
|
79
|
+
return `media:${seg(p.handle)}:${path(p.path)}:${p.facet}`;
|
|
80
|
+
case "entry": {
|
|
81
|
+
if (parseEntryId(String(p.entryId)) === null) throw new Error(`Not an entry id: ${p.entryId}`);
|
|
82
|
+
const field = p.field === "@title" || p.field === "@slug" ? p.field : seg(p.field);
|
|
83
|
+
return `entry:${seg(p.collection)}:${p.entryId}:${field}`;
|
|
84
|
+
}
|
|
85
|
+
case "navigation": {
|
|
86
|
+
const text = p.itemPath.join(".");
|
|
87
|
+
if (parseItemPath(text) === null) throw new Error(`Not a menu item path: ${text}`);
|
|
88
|
+
return `nav:${seg(p.handle)}:${text}:${p.part}`;
|
|
89
|
+
}
|
|
90
|
+
case "settings":
|
|
91
|
+
return `settings:${seg(p.field)}`;
|
|
92
|
+
case "theme":
|
|
93
|
+
return `theme:${path(p.path)}`;
|
|
94
|
+
case "option":
|
|
95
|
+
return `option:${seg(p.collection)}:${seg(p.field)}:${p.value}`;
|
|
96
|
+
}
|
|
97
|
+
throw new Error(`Unknown kind: ${p.kind}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseUnitId(id) {
|
|
101
|
+
if (typeof id !== "string" || id.length === 0 || id.length > MAX_ID_LENGTH) return null;
|
|
102
|
+
const colon = id.indexOf(":");
|
|
103
|
+
if (colon < 0) return null;
|
|
104
|
+
const prefix = id.slice(0, colon);
|
|
105
|
+
const rest = id.slice(colon + 1);
|
|
106
|
+
switch (prefix) {
|
|
107
|
+
case "page": {
|
|
108
|
+
const i = rest.indexOf(":");
|
|
109
|
+
if (i < 0) return null;
|
|
110
|
+
const handle = rest.slice(0, i);
|
|
111
|
+
const field = rest.slice(i + 1);
|
|
112
|
+
if (!isIdSegment(handle)) return null;
|
|
113
|
+
if (field.startsWith("@")) {
|
|
114
|
+
const name = field.slice(1);
|
|
115
|
+
return PAGE_UNIT_FIELDS.includes(name) ? { kind: "page", handle, page: name } : null;
|
|
116
|
+
}
|
|
117
|
+
const dot = field.lastIndexOf(".");
|
|
118
|
+
if (dot <= 0) return null;
|
|
119
|
+
const node = field.slice(0, dot);
|
|
120
|
+
const setting = field.slice(dot + 1);
|
|
121
|
+
return isIdSegment(node) && isIdSegment(setting) ? { kind: "page", handle, node, setting } : null;
|
|
122
|
+
}
|
|
123
|
+
case "media": {
|
|
124
|
+
const i = rest.indexOf(":");
|
|
125
|
+
const j = rest.lastIndexOf(":");
|
|
126
|
+
if (i < 0 || j <= i) return null;
|
|
127
|
+
const handle = rest.slice(0, i);
|
|
128
|
+
const path = rest.slice(i + 1, j);
|
|
129
|
+
const facet = rest.slice(j + 1);
|
|
130
|
+
if (!isIdSegment(handle) || !dotted(path) || (facet !== "alt" && facet !== "caption")) return null;
|
|
131
|
+
return { kind: "media", handle, path, facet };
|
|
132
|
+
}
|
|
133
|
+
case "entry": {
|
|
134
|
+
const parts = rest.split(":");
|
|
135
|
+
if (parts.length !== 3) return null;
|
|
136
|
+
const [collection, idText, field] = parts;
|
|
137
|
+
const entryId = parseEntryId(idText);
|
|
138
|
+
if (!isIdSegment(collection) || entryId === null) return null;
|
|
139
|
+
if (!(field === "@title" || field === "@slug" || isIdSegment(field))) return null;
|
|
140
|
+
return { kind: "entry", collection, entryId, field };
|
|
141
|
+
}
|
|
142
|
+
case "nav": {
|
|
143
|
+
const parts = rest.split(":");
|
|
144
|
+
if (parts.length !== 3) return null;
|
|
145
|
+
const [handle, pathText, part] = parts;
|
|
146
|
+
const itemPath = parseItemPath(pathText);
|
|
147
|
+
if (!isIdSegment(handle) || itemPath === null || (part !== "label" && part !== "url")) return null;
|
|
148
|
+
return { kind: "navigation", handle, itemPath, part };
|
|
149
|
+
}
|
|
150
|
+
case "settings":
|
|
151
|
+
return isIdSegment(rest) ? { kind: "settings", field: rest } : null;
|
|
152
|
+
case "theme":
|
|
153
|
+
return rest.length > 0 && dotted(rest) ? { kind: "theme", path: rest } : null;
|
|
154
|
+
case "option": {
|
|
155
|
+
const i = rest.indexOf(":");
|
|
156
|
+
const j = i < 0 ? -1 : rest.indexOf(":", i + 1);
|
|
157
|
+
if (i < 0 || j < 0) return null;
|
|
158
|
+
const collection = rest.slice(0, i);
|
|
159
|
+
const field = rest.slice(i + 1, j);
|
|
160
|
+
const value = rest.slice(j + 1);
|
|
161
|
+
if (!isIdSegment(collection) || !isIdSegment(field) || value.length === 0 || value.length > MAX_OPTION_VALUE) return null;
|
|
162
|
+
return { kind: "option", collection, field, value };
|
|
163
|
+
}
|
|
164
|
+
default:
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function groupKeyOf(p) {
|
|
170
|
+
switch (p.kind) {
|
|
171
|
+
case "page":
|
|
172
|
+
case "media":
|
|
173
|
+
return `page:${p.handle}`;
|
|
174
|
+
case "entry":
|
|
175
|
+
return `entry:${p.collection}:${p.entryId}`;
|
|
176
|
+
case "navigation":
|
|
177
|
+
return `nav:${p.handle}`;
|
|
178
|
+
case "settings":
|
|
179
|
+
return "settings";
|
|
180
|
+
case "theme":
|
|
181
|
+
return "theme";
|
|
182
|
+
case "option":
|
|
183
|
+
return `option:${p.collection}`;
|
|
184
|
+
}
|
|
185
|
+
return "";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
189
|
+
const optionalText = (v) => (v === undefined || v === null ? "" : typeof v === "string" ? v : null);
|
|
190
|
+
const refuseRead = (message) => ({ ok: false, message });
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The platform's structural read of a package: only `id`, `target` and `source_hash` are required per unit; `kind`
|
|
194
|
+
* and `group` are derived from the id (a disagreeing declaration is refused). `{ ok, pkg }` or `{ ok: false, message }`.
|
|
195
|
+
*/
|
|
196
|
+
export function readTranslationPackage(raw) {
|
|
197
|
+
if (!isRecord(raw)) return refuseRead("A translation package must be an object.");
|
|
198
|
+
if (raw.format !== TRANSLATION_FORMAT) return refuseRead(`The package format must be "${TRANSLATION_FORMAT}".`);
|
|
199
|
+
if (typeof raw.target_locale !== "string" || raw.target_locale.trim() === "") return refuseRead("target_locale is required.");
|
|
200
|
+
if (typeof raw.source_locale !== "string" || raw.source_locale.trim() === "") return refuseRead("source_locale is required.");
|
|
201
|
+
const exportedAt = optionalText(raw.exported_at);
|
|
202
|
+
if (exportedAt === null) return refuseRead("exported_at must be text.");
|
|
203
|
+
const by = raw.by === undefined || raw.by === null ? "human" : raw.by;
|
|
204
|
+
if (by !== "human" && by !== "ai") return refuseRead("by must be 'human' or 'ai'.");
|
|
205
|
+
const nextCursor = raw.next_cursor === undefined || raw.next_cursor === null ? null : raw.next_cursor;
|
|
206
|
+
if (nextCursor !== null && typeof nextCursor !== "string") return refuseRead("next_cursor must be text or null.");
|
|
207
|
+
if (!Array.isArray(raw.units)) return refuseRead("units must be an array.");
|
|
208
|
+
|
|
209
|
+
const seen = new Set();
|
|
210
|
+
const units = [];
|
|
211
|
+
for (let i = 0; i < raw.units.length; i += 1) {
|
|
212
|
+
const u = raw.units[i];
|
|
213
|
+
const at = `units[${i}]`;
|
|
214
|
+
if (!isRecord(u)) return refuseRead(`${at} must be an object.`);
|
|
215
|
+
if (typeof u.id !== "string" || u.id === "") return refuseRead(`${at}.id is required.`);
|
|
216
|
+
const parsed = parseUnitId(u.id);
|
|
217
|
+
if (parsed === null) return refuseRead(`${at}.id is not recognised: ${u.id}`);
|
|
218
|
+
const canonicalId = unitIdOf(parsed);
|
|
219
|
+
if (seen.has(canonicalId)) return refuseRead(`${at}.id appears twice: ${u.id}`);
|
|
220
|
+
seen.add(canonicalId);
|
|
221
|
+
if (typeof u.target !== "string") return refuseRead(`${at}.target must be text.`);
|
|
222
|
+
if (typeof u.source_hash !== "string") return refuseRead(`${at}.source_hash is required.`);
|
|
223
|
+
if (u.kind !== undefined && u.kind !== null && u.kind !== parsed.kind) return refuseRead(`${at}.kind is invalid: the id says "${parsed.kind}".`);
|
|
224
|
+
const group = groupKeyOf(parsed);
|
|
225
|
+
if (u.group !== undefined && u.group !== null && u.group !== "" && u.group !== group) return refuseRead(`${at}.group does not match the id (it should be ${group}).`);
|
|
226
|
+
const type = u.type === undefined || u.type === null ? "text" : u.type;
|
|
227
|
+
if (!TRANSLATION_UNIT_TYPES.includes(type)) return refuseRead(`${at}.type is invalid.`);
|
|
228
|
+
const state = u.state === undefined || u.state === null ? (u.target.trim() ? "translated" : "missing") : u.state;
|
|
229
|
+
if (!TRANSLATION_STATES.includes(state)) return refuseRead(`${at}.state is invalid.`);
|
|
230
|
+
const groupText = optionalText(u.group);
|
|
231
|
+
const context = optionalText(u.context);
|
|
232
|
+
const source = optionalText(u.source);
|
|
233
|
+
const targetHash = optionalText(u.target_hash);
|
|
234
|
+
if (groupText === null || context === null || source === null || targetHash === null) return refuseRead(`${at} fields must be text.`);
|
|
235
|
+
units.push({ id: canonicalId, kind: parsed.kind, group, context, type, source, source_hash: u.source_hash, target: u.target, target_hash: targetHash, state });
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
ok: true,
|
|
239
|
+
pkg: { format: TRANSLATION_FORMAT, source_locale: raw.source_locale, target_locale: raw.target_locale, exported_at: exportedAt, by, units, next_cursor: nextCursor },
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The platform's `chunkPackage`: empty targets dropped (counted); consecutive units of one group (derived from the
|
|
245
|
+
* id) stay in one call; a group above `max` but within 5,000 units is a call of its own; only a larger group is sliced
|
|
246
|
+
* into calls of `max` (its key is listed in `splitGroups`: such a page cannot be stamped translated by one call).
|
|
247
|
+
*/
|
|
248
|
+
/** The group a unit belongs to, DERIVED from its id (a package without group fields must not split a page or entry). */
|
|
249
|
+
function groupOf(u) {
|
|
250
|
+
const parsed = parseUnitId(u.id);
|
|
251
|
+
return parsed ? groupKeyOf(parsed) : u.group;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function chunkPackage(pkg, max = IMPORT_MAX_UNITS) {
|
|
255
|
+
if (!Number.isInteger(max) || max < 1) throw new RangeError(`chunkPackage: max must be a positive integer (got ${max})`);
|
|
256
|
+
const filled = pkg.units.filter((u) => u.target.trim() !== "");
|
|
257
|
+
const chunks = [];
|
|
258
|
+
const splitGroups = [];
|
|
259
|
+
let current = [];
|
|
260
|
+
const flush = () => {
|
|
261
|
+
if (current.length > 0) chunks.push({ ...pkg, units: current, next_cursor: null });
|
|
262
|
+
current = [];
|
|
263
|
+
};
|
|
264
|
+
let i = 0;
|
|
265
|
+
while (i < filled.length) {
|
|
266
|
+
const group = groupOf(filled[i]);
|
|
267
|
+
let j = i;
|
|
268
|
+
while (j < filled.length && groupOf(filled[j]) === group) j += 1;
|
|
269
|
+
const run = filled.slice(i, j);
|
|
270
|
+
if (run.length > IMPORT_MAX_GROUP_UNITS) {
|
|
271
|
+
flush();
|
|
272
|
+
splitGroups.push(group);
|
|
273
|
+
for (let k = 0; k < run.length; k += max) chunks.push({ ...pkg, units: run.slice(k, k + max), next_cursor: null });
|
|
274
|
+
} else {
|
|
275
|
+
if (current.length + run.length > max) flush();
|
|
276
|
+
current.push(...run);
|
|
277
|
+
}
|
|
278
|
+
i = j;
|
|
279
|
+
}
|
|
280
|
+
flush();
|
|
281
|
+
return { chunks, skippedEmpty: pkg.units.length - filled.length, splitGroups };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Room for the import body's other fields (`{"package":…,"dry_run":false,"publish":false,"on_source_change":"apply"}`). */
|
|
285
|
+
const BODY_OVERHEAD = 100;
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The import route refuses a JSON body over `cap` characters (413). A chunk over it is re-split at group boundaries
|
|
289
|
+
* (a group is never split here); a single group over the cap cannot be sent and is refused before any request.
|
|
290
|
+
*/
|
|
291
|
+
export function fitChunksToBodyCap(chunks, cap = MAX_IMPORT_BODY_CHARS) {
|
|
292
|
+
const size = (units) => JSON.stringify({ ...chunks[0], units }).length + BODY_OVERHEAD;
|
|
293
|
+
const out = [];
|
|
294
|
+
for (const chunk of chunks) {
|
|
295
|
+
if (size(chunk.units) <= cap) {
|
|
296
|
+
out.push(chunk);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
const runs = [];
|
|
300
|
+
for (const unit of chunk.units) {
|
|
301
|
+
const last = runs[runs.length - 1];
|
|
302
|
+
const group = groupOf(unit);
|
|
303
|
+
if (last && last.group === group) last.units.push(unit);
|
|
304
|
+
else runs.push({ group, units: [unit] });
|
|
305
|
+
}
|
|
306
|
+
let current = [];
|
|
307
|
+
for (const run of runs) {
|
|
308
|
+
if (size(run.units) > cap) {
|
|
309
|
+
throw new Error(`The texts of group "${run.group}" do not fit in one import call (at most ${cap.toLocaleString("en-US")} characters).`);
|
|
310
|
+
}
|
|
311
|
+
if (current.length > 0 && size([...current, ...run.units]) > cap) {
|
|
312
|
+
out.push({ ...chunk, units: current });
|
|
313
|
+
current = [];
|
|
314
|
+
}
|
|
315
|
+
current.push(...run.units);
|
|
316
|
+
}
|
|
317
|
+
if (current.length > 0) out.push({ ...chunk, units: current });
|
|
318
|
+
}
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** A package file: XLIFF by extension (.xlf/.xliff) or a leading `<`, JSON otherwise. Throws with a readable message. */
|
|
323
|
+
export function readPackageFile(text, fileName = "") {
|
|
324
|
+
const xliff = /\.(xlf|xliff)$/i.test(fileName) || text.trimStart().startsWith("<");
|
|
325
|
+
if (xliff) return fromXliff(text);
|
|
326
|
+
let raw;
|
|
327
|
+
try {
|
|
328
|
+
raw = JSON.parse(text.replace(/^\uFEFF/, ""));
|
|
329
|
+
} catch (error) {
|
|
330
|
+
throw new Error(`The file is not valid JSON (${error?.message ?? error}).`);
|
|
331
|
+
}
|
|
332
|
+
const read = readTranslationPackage(raw);
|
|
333
|
+
if (!read.ok) throw new Error(read.message);
|
|
334
|
+
return read.pkg;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ── XLIFF 1.2: writing (translation-xliff.ts, byte-identical) ───────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
const STATE_TO_XLIFF = { missing: "new", stale: "needs-review-translation", translated: "translated" };
|
|
340
|
+
|
|
341
|
+
function stateFromXliff(value, target) {
|
|
342
|
+
if (value === "new") return "missing";
|
|
343
|
+
if (value === "needs-review-translation") return "stale";
|
|
344
|
+
return target.trim() === "" ? "missing" : "translated";
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** XML 1.0 `Char` production. */
|
|
348
|
+
function isXmlChar(code) {
|
|
349
|
+
return code === 0x9 || code === 0xa || code === 0xd || (code >= 0x20 && code <= 0xd7ff) || (code >= 0xe000 && code <= 0xfffd) || (code >= 0x10000 && code <= 0x10ffff);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function escapeXml(text, attribute) {
|
|
353
|
+
let out = "";
|
|
354
|
+
for (const ch of text) {
|
|
355
|
+
switch (ch) {
|
|
356
|
+
case "&": out += "&"; continue;
|
|
357
|
+
case "<": out += "<"; continue;
|
|
358
|
+
case ">": out += ">"; continue;
|
|
359
|
+
case '"': out += """; continue;
|
|
360
|
+
case "'": out += "'"; continue;
|
|
361
|
+
case "\t": out += "	"; continue;
|
|
362
|
+
case "\r": out += " "; continue;
|
|
363
|
+
case "\n": out += attribute ? " " : "\n"; continue;
|
|
364
|
+
}
|
|
365
|
+
if (!isXmlChar(ch.codePointAt(0))) {
|
|
366
|
+
throw new XliffError("A text contains a character XLIFF cannot carry; export this package as JSON.");
|
|
367
|
+
}
|
|
368
|
+
out += ch;
|
|
369
|
+
}
|
|
370
|
+
return out;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const attrs = (list) => list.map(([k, v]) => `${k}="${escapeXml(v, true)}"`).join(" ");
|
|
374
|
+
|
|
375
|
+
export function toXliff(pkg) {
|
|
376
|
+
const fileAttrs = [
|
|
377
|
+
["original", "blocofy"],
|
|
378
|
+
["datatype", "plaintext"],
|
|
379
|
+
["source-language", pkg.source_locale],
|
|
380
|
+
["target-language", pkg.target_locale],
|
|
381
|
+
["bf:format", pkg.format],
|
|
382
|
+
["bf:exported-at", pkg.exported_at],
|
|
383
|
+
["bf:by", pkg.by],
|
|
384
|
+
...(pkg.next_cursor !== null ? [["bf:next-cursor", pkg.next_cursor]] : []),
|
|
385
|
+
];
|
|
386
|
+
const lines = [
|
|
387
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
388
|
+
`<xliff version="1.2" xmlns="${XLIFF_NAMESPACE}" xmlns:bf="${BLOCOFY_XLIFF_NAMESPACE}">`,
|
|
389
|
+
` <file ${attrs(fileAttrs)}>`,
|
|
390
|
+
" <body>",
|
|
391
|
+
];
|
|
392
|
+
for (const u of pkg.units) {
|
|
393
|
+
const unitAttrs = [
|
|
394
|
+
["id", u.id],
|
|
395
|
+
["resname", u.id],
|
|
396
|
+
...(u.type === "richtext" || u.type === "html" ? [["datatype", "html"]] : []),
|
|
397
|
+
["bf:kind", u.kind],
|
|
398
|
+
["bf:group", u.group],
|
|
399
|
+
["bf:type", u.type],
|
|
400
|
+
["bf:source-hash", u.source_hash],
|
|
401
|
+
["bf:target-hash", u.target_hash],
|
|
402
|
+
];
|
|
403
|
+
lines.push(
|
|
404
|
+
` <trans-unit ${attrs(unitAttrs)}>`,
|
|
405
|
+
` <source>${escapeXml(u.source, false)}</source>`,
|
|
406
|
+
` <target state="${STATE_TO_XLIFF[u.state]}">${escapeXml(u.target, false)}</target>`,
|
|
407
|
+
` <note>${escapeXml(u.context, false)}</note>`,
|
|
408
|
+
" </trans-unit>",
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
lines.push(" </body>", " </file>", "</xliff>");
|
|
412
|
+
return `${lines.join("\n")}\n`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ── XLIFF 1.2: reading ──────────────────────────────────────────────────────────────────────────────────────
|
|
416
|
+
|
|
417
|
+
const lineOf = (xml, index) => {
|
|
418
|
+
let line = 1;
|
|
419
|
+
for (let i = xml.indexOf("\n"); i >= 0 && i < index; i = xml.indexOf("\n", i + 1)) line += 1;
|
|
420
|
+
return line;
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const CHAR_REF = /&(?:#([0-9]+)|#x([0-9a-fA-F]+)|(?:amp|lt|gt|quot|apos));/y;
|
|
424
|
+
const NAMED_REF = /&([A-Za-z][A-Za-z0-9]*);/y;
|
|
425
|
+
const DECLARATION =
|
|
426
|
+
/^xml[ \t\n]+version[ \t\n]*=[ \t\n]*(["'])([^"']*)\1(?:[ \t\n]+encoding[ \t\n]*=[ \t\n]*(["'])([^"']*)\3)?(?:[ \t\n]+standalone[ \t\n]*=[ \t\n]*(["'])(?:yes|no)\5)?[ \t\n]*$/;
|
|
427
|
+
|
|
428
|
+
const MAX_DEPTH = 32;
|
|
429
|
+
const MAX_ATTRIBUTES_PER_TAG = 64;
|
|
430
|
+
const MAX_DECLARATION_LENGTH = 200;
|
|
431
|
+
const MAX_TAG_OUTSIDE_VALUES = 1024;
|
|
432
|
+
const MAX_TAG_WHITESPACE_RUN = 64;
|
|
433
|
+
const tooMuchWhitespace = (line) => new XliffError(`Too much whitespace inside a tag (at most ${MAX_TAG_WHITESPACE_RUN} characters in a row; line ${line}).`);
|
|
434
|
+
const tagTooLong = (line) => new XliffError(`A tag is too long outside its quoted attribute values (at most 1,024 characters; line ${line}).`);
|
|
435
|
+
const isBlank = (code) => code === 0x20 || code === 0x9 || code === 0xa || code === 0xd;
|
|
436
|
+
const codePointLabel = (code) => `U+${code.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
437
|
+
|
|
438
|
+
function checkReference(xml, i) {
|
|
439
|
+
CHAR_REF.lastIndex = i;
|
|
440
|
+
const ref = CHAR_REF.exec(xml);
|
|
441
|
+
if (ref) {
|
|
442
|
+
const digits = ref[1] ?? ref[2];
|
|
443
|
+
if (digits !== undefined) {
|
|
444
|
+
const significant = digits.replace(/^0+/, "");
|
|
445
|
+
const code = significant.length > 7 ? -1 : significant === "" ? 0 : parseInt(significant, ref[1] !== undefined ? 10 : 16);
|
|
446
|
+
if (!isXmlChar(code)) {
|
|
447
|
+
throw new XliffError(`The XLIFF file has a character reference XML cannot carry: ${ref[0].slice(0, 24)} (line ${lineOf(xml, i)}).`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return i + ref[0].length;
|
|
451
|
+
}
|
|
452
|
+
NAMED_REF.lastIndex = i;
|
|
453
|
+
const named = NAMED_REF.exec(xml);
|
|
454
|
+
if (named) {
|
|
455
|
+
throw new XliffError(
|
|
456
|
+
`Named HTML character references such as "${named[0].slice(0, 24)}" are not supported (line ${lineOf(xml, i)}); write the character itself or use a numeric reference (e.g.  ).`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
throw new XliffError(`A bare "&" is not valid (line ${lineOf(xml, i)}); write "&".`);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function checkDeclaration(xml, start, end) {
|
|
463
|
+
if (end - start > MAX_DECLARATION_LENGTH) throw new XliffError(`The XML declaration (<?xml ...?>) is too long (line ${lineOf(xml, start)}).`);
|
|
464
|
+
const body = xml.slice(start + 2, end);
|
|
465
|
+
if (/[ \t\n\r]{65}/.test(body)) throw tooMuchWhitespace(lineOf(xml, start));
|
|
466
|
+
const m = DECLARATION.exec(body);
|
|
467
|
+
if (!m) throw new XliffError(`The XML declaration (<?xml ...?>) cannot be read (line ${lineOf(xml, start)}).`);
|
|
468
|
+
if (m[2] !== "1.0") throw new XliffError("Only XML 1.0 is supported.");
|
|
469
|
+
if (m[4] !== undefined && m[4].toLowerCase() !== "utf-8") throw new XliffError("An XLIFF file can only be UTF-8.");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* The platform's pre-scan, verbatim: ONE linear pass that refuses everything the dialect does not have (unclosed
|
|
474
|
+
* constructs, DOCTYPE / entity declarations, CDATA, any processing instruction but one leading XML declaration,
|
|
475
|
+
* `<` inside an attribute value, characters XML 1.0 cannot carry, bare `&` and named entities, `]]>` in text) and
|
|
476
|
+
* bounds depth, attribute count and the whitespace inside a tag.
|
|
477
|
+
*/
|
|
478
|
+
function refuseUnsupportedMarkup(xml) {
|
|
479
|
+
const n = xml.length;
|
|
480
|
+
|
|
481
|
+
for (let k = 0; k < n; k += 1) {
|
|
482
|
+
const c = xml.charCodeAt(k);
|
|
483
|
+
if ((c >= 0x20 && c <= 0xd7ff) || c === 0x9 || c === 0xa || c === 0xd || (c >= 0xe000 && c <= 0xfffd)) continue;
|
|
484
|
+
if (c >= 0xd800 && c <= 0xdbff) {
|
|
485
|
+
const low = xml.charCodeAt(k + 1);
|
|
486
|
+
if (low >= 0xdc00 && low <= 0xdfff) {
|
|
487
|
+
k += 1;
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
throw new XliffError(`The XLIFF file has a character XML cannot carry (${codePointLabel(c)}, line ${lineOf(xml, k)}).`);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
let i = 0;
|
|
495
|
+
let depth = 0;
|
|
496
|
+
let started = false;
|
|
497
|
+
let declared = false;
|
|
498
|
+
while (i < n) {
|
|
499
|
+
const c = xml.charCodeAt(i);
|
|
500
|
+
if (c === 0x3c /* < */) {
|
|
501
|
+
const next = xml.charCodeAt(i + 1);
|
|
502
|
+
if (next === 0x21 /* ! */) {
|
|
503
|
+
if (xml.startsWith("<!--", i)) {
|
|
504
|
+
const end = xml.indexOf("-->", i + 4);
|
|
505
|
+
if (end < 0) throw new XliffError(`Unclosed comment (<!--) (line ${lineOf(xml, i)}).`);
|
|
506
|
+
const inner = xml.indexOf("--", i + 4);
|
|
507
|
+
if (inner < end || (end > i + 4 && xml.charCodeAt(end - 1) === 0x2d)) {
|
|
508
|
+
throw new XliffError(`A comment cannot contain "--" (line ${lineOf(xml, i)}).`);
|
|
509
|
+
}
|
|
510
|
+
started = true;
|
|
511
|
+
i = end + 3;
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (xml.startsWith("<![CDATA[", i)) throw new XliffError(`CDATA is not supported; escape the text instead (line ${lineOf(xml, i)}).`);
|
|
515
|
+
const word = /^<!([A-Za-z]+)/.exec(xml.slice(i, i + 16))?.[1]?.toUpperCase();
|
|
516
|
+
if (word === "DOCTYPE" || word === "ENTITY" || word === "ELEMENT" || word === "ATTLIST" || word === "NOTATION") {
|
|
517
|
+
throw new XliffError(`An XLIFF file cannot contain a ${word} declaration (line ${lineOf(xml, i)}).`);
|
|
518
|
+
}
|
|
519
|
+
throw new XliffError(`The XLIFF file has an unsupported construct (line ${lineOf(xml, i)}).`);
|
|
520
|
+
}
|
|
521
|
+
if (next === 0x3f /* ? */) {
|
|
522
|
+
const end = xml.indexOf("?>", i + 2);
|
|
523
|
+
if (end < 0) throw new XliffError(`Unclosed processing instruction (line ${lineOf(xml, i)}).`);
|
|
524
|
+
const isDeclaration = xml.startsWith("xml", i + 2) && /[ \t\n]/.test(xml.charAt(i + 5));
|
|
525
|
+
if (!isDeclaration || started || declared) {
|
|
526
|
+
throw new XliffError(`An XLIFF file cannot contain a processing instruction (line ${lineOf(xml, i)}).`);
|
|
527
|
+
}
|
|
528
|
+
checkDeclaration(xml, i, end);
|
|
529
|
+
declared = true;
|
|
530
|
+
i = end + 2;
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
started = true;
|
|
534
|
+
const closing = next === 0x2f; /* / */
|
|
535
|
+
let attributes = 0;
|
|
536
|
+
let outside = 0;
|
|
537
|
+
let blanks = 0;
|
|
538
|
+
let j = i + 1;
|
|
539
|
+
for (;;) {
|
|
540
|
+
if (j >= n) throw new XliffError(`Unclosed tag (line ${lineOf(xml, i)}).`);
|
|
541
|
+
const t = xml.charCodeAt(j);
|
|
542
|
+
if (t === 0x3e /* > */) break;
|
|
543
|
+
if (t === 0x3c) throw new XliffError(`"<" cannot be used inside a tag (line ${lineOf(xml, j)}).`);
|
|
544
|
+
if (t === 0x22 || t === 0x27) {
|
|
545
|
+
const end = xml.indexOf(t === 0x22 ? '"' : "'", j + 1);
|
|
546
|
+
if (end < 0) throw new XliffError(`Unclosed attribute value (line ${lineOf(xml, j)}).`);
|
|
547
|
+
attributes += 1;
|
|
548
|
+
if (attributes > MAX_ATTRIBUTES_PER_TAG) throw new XliffError(`Too many attributes on one tag (line ${lineOf(xml, i)}).`);
|
|
549
|
+
for (let k = j + 1; k < end; k += 1) {
|
|
550
|
+
const v = xml.charCodeAt(k);
|
|
551
|
+
if (v === 0x3c) throw new XliffError(`"<" cannot be used in an attribute value; write "<" (line ${lineOf(xml, k)}).`);
|
|
552
|
+
if (v === 0x26) k = checkReference(xml, k) - 1;
|
|
553
|
+
}
|
|
554
|
+
j = end + 1;
|
|
555
|
+
blanks = 0;
|
|
556
|
+
const after = xml.charCodeAt(j);
|
|
557
|
+
if (!isBlank(after) && after !== 0x3e && after !== 0x2f) throw new XliffError(`Attributes must be separated by whitespace (line ${lineOf(xml, j)}).`);
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
outside += 1;
|
|
561
|
+
if (outside > MAX_TAG_OUTSIDE_VALUES) throw tagTooLong(lineOf(xml, i));
|
|
562
|
+
if (isBlank(t)) {
|
|
563
|
+
blanks += 1;
|
|
564
|
+
if (blanks > MAX_TAG_WHITESPACE_RUN) throw tooMuchWhitespace(lineOf(xml, i));
|
|
565
|
+
} else {
|
|
566
|
+
blanks = 0;
|
|
567
|
+
if (t === 0x3d /* = */) {
|
|
568
|
+
let q = j + 1;
|
|
569
|
+
while (q < n && q <= j + MAX_TAG_WHITESPACE_RUN && isBlank(xml.charCodeAt(q))) q += 1;
|
|
570
|
+
const quote = xml.charCodeAt(q);
|
|
571
|
+
if (quote !== 0x22 && quote !== 0x27) throw new XliffError(`"=" must be followed by a quoted attribute value (line ${lineOf(xml, j)}).`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
j += 1;
|
|
575
|
+
}
|
|
576
|
+
if (closing) depth -= 1;
|
|
577
|
+
else if (xml.charCodeAt(j - 1) !== 0x2f) depth += 1;
|
|
578
|
+
if (depth > MAX_DEPTH) throw new XliffError(`Elements are nested too deep (line ${lineOf(xml, i)}).`);
|
|
579
|
+
i = j + 1;
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
if (c === 0x26 /* & */) {
|
|
583
|
+
i = checkReference(xml, i);
|
|
584
|
+
started = true;
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
if (c === 0x5d /* ] */ && xml.startsWith("]]>", i)) throw new XliffError(`Text cannot contain "]]>" (line ${lineOf(xml, i)}).`);
|
|
588
|
+
if (c !== 0x20 && c !== 0x9 && c !== 0xa && c !== 0xd) started = true;
|
|
589
|
+
i += 1;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** Decode what the pre-scan let through: numeric references and the five XML entities, in ONE pass. */
|
|
594
|
+
function decodeReferences(text) {
|
|
595
|
+
if (!text.includes("&")) return text;
|
|
596
|
+
return text.replace(/&(?:#([0-9]+)|#x([0-9a-fA-F]+)|(amp|lt|gt|quot|apos));/g, (_m, dec, hex, name) => {
|
|
597
|
+
if (name !== undefined) return { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" }[name];
|
|
598
|
+
return String.fromCodePoint(parseInt(dec ?? hex, dec !== undefined ? 10 : 16));
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const NAME_START = ":A-Za-z_\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD";
|
|
603
|
+
const NAME = new RegExp(`[${NAME_START}][${NAME_START}\\-.\\d\\u00B7\\u0300-\\u036F\\u203F-\\u2040]*`, "y");
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Well-formedness after the pre-scan (the platform runs fast-xml-parser's validator here): one root element, names
|
|
607
|
+
* that are XML names, quoted and unique attributes, matching end tags without attributes, nothing but whitespace and
|
|
608
|
+
* comments outside the root, the XML declaration only at the very start. Linear: every step moves forward. Returns
|
|
609
|
+
* the root as `{ name, attrs: Map, elements: [], text: [] }` (text pieces with comments dropped).
|
|
610
|
+
*/
|
|
611
|
+
function parseElements(xml) {
|
|
612
|
+
const n = xml.length;
|
|
613
|
+
let i = 0;
|
|
614
|
+
const fail = (what) => {
|
|
615
|
+
throw new XliffError(`The XLIFF file is not well-formed XML (${what}, line ${lineOf(xml, Math.min(i, n))}).`);
|
|
616
|
+
};
|
|
617
|
+
const readName = () => {
|
|
618
|
+
NAME.lastIndex = i;
|
|
619
|
+
const m = NAME.exec(xml);
|
|
620
|
+
if (!m) fail("invalid name");
|
|
621
|
+
i += m[0].length;
|
|
622
|
+
return m[0];
|
|
623
|
+
};
|
|
624
|
+
const skipBlanks = () => {
|
|
625
|
+
while (i < n && isBlank(xml.charCodeAt(i))) i += 1;
|
|
626
|
+
};
|
|
627
|
+
const stack = [];
|
|
628
|
+
let root = null;
|
|
629
|
+
while (i < n) {
|
|
630
|
+
const lt = xml.indexOf("<", i);
|
|
631
|
+
const end = lt < 0 ? n : lt;
|
|
632
|
+
if (end > i) {
|
|
633
|
+
const text = xml.slice(i, end);
|
|
634
|
+
if (stack.length > 0) stack[stack.length - 1].text.push(text);
|
|
635
|
+
else if (/[^ \t\n\r]/.test(text)) fail("text outside the root element");
|
|
636
|
+
i = end;
|
|
637
|
+
if (lt < 0) break;
|
|
638
|
+
}
|
|
639
|
+
if (xml.startsWith("<!--", i)) {
|
|
640
|
+
i = xml.indexOf("-->", i + 4) + 3;
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
if (xml.startsWith("<?", i)) {
|
|
644
|
+
if (i !== 0) fail("the XML declaration can only be at the very start");
|
|
645
|
+
i = xml.indexOf("?>", i + 2) + 2;
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
if (xml.charCodeAt(i + 1) === 0x2f /* / */) {
|
|
649
|
+
i += 2;
|
|
650
|
+
const name = readName();
|
|
651
|
+
skipBlanks();
|
|
652
|
+
if (xml[i] !== ">") fail("malformed end tag");
|
|
653
|
+
i += 1;
|
|
654
|
+
const open = stack.pop();
|
|
655
|
+
if (!open || open.name !== name) fail(`unexpected </${name}>`);
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
i += 1;
|
|
659
|
+
const name = readName();
|
|
660
|
+
if (stack.length === 0 && root !== null) fail("more than one root element");
|
|
661
|
+
const element = { name, attrs: new Map(), elements: [], text: [] };
|
|
662
|
+
let selfClosing = false;
|
|
663
|
+
for (;;) {
|
|
664
|
+
const before = i;
|
|
665
|
+
skipBlanks();
|
|
666
|
+
if (xml[i] === ">") {
|
|
667
|
+
i += 1;
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
if (xml[i] === "/") {
|
|
671
|
+
if (xml[i + 1] !== ">") fail("malformed tag");
|
|
672
|
+
i += 2;
|
|
673
|
+
selfClosing = true;
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
if (i === before) fail("no whitespace between attributes");
|
|
677
|
+
const key = readName();
|
|
678
|
+
skipBlanks();
|
|
679
|
+
if (xml[i] !== "=") fail(`attribute "${key}" has no value`);
|
|
680
|
+
i += 1;
|
|
681
|
+
skipBlanks();
|
|
682
|
+
const quote = xml[i];
|
|
683
|
+
if (quote !== '"' && quote !== "'") fail("unquoted attribute value");
|
|
684
|
+
const close = xml.indexOf(quote, i + 1);
|
|
685
|
+
if (close < 0) fail("unclosed attribute value");
|
|
686
|
+
if (element.attrs.has(key)) fail(`attribute "${key}" given twice`);
|
|
687
|
+
element.attrs.set(key, xml.slice(i + 1, close));
|
|
688
|
+
i = close + 1;
|
|
689
|
+
}
|
|
690
|
+
if (stack.length > 0) stack[stack.length - 1].elements.push(element);
|
|
691
|
+
else root = element;
|
|
692
|
+
if (!selfClosing) stack.push(element);
|
|
693
|
+
}
|
|
694
|
+
if (stack.length > 0) fail(`<${stack[stack.length - 1].name}> is not closed`);
|
|
695
|
+
if (root === null) fail("no root element");
|
|
696
|
+
return root;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** An element the platform's parser reads as a plain string: no attributes and no child elements. */
|
|
700
|
+
const isPlain = (el) => el.attrs.size === 0 && el.elements.length === 0;
|
|
701
|
+
/** An attribute value as XML normalises it (a literal tab/newline is a space), references decoded. */
|
|
702
|
+
const attr = (el, name) => {
|
|
703
|
+
const v = el.attrs.get(name);
|
|
704
|
+
return typeof v === "string" ? decodeReferences(v.replace(/[\t\n\r]/g, " ")) : undefined;
|
|
705
|
+
};
|
|
706
|
+
const usesBf = (el) => [...el.attrs.keys()].some((k) => k.startsWith("bf:"));
|
|
707
|
+
const childrenNamed = (el, name) => el.elements.filter((c) => c.name === name);
|
|
708
|
+
|
|
709
|
+
const FILE_BF_ATTRS = ["bf:format", "bf:exported-at", "bf:by", "bf:next-cursor"];
|
|
710
|
+
const UNIT_BF_ATTRS = ["bf:kind", "bf:group", "bf:type", "bf:source-hash", "bf:target-hash"];
|
|
711
|
+
|
|
712
|
+
function checkAttributes(el, allowedBf, where) {
|
|
713
|
+
for (const key of el.attrs.keys()) {
|
|
714
|
+
if (key.startsWith("bf:") && !allowedBf.includes(key)) throw new XliffError(`Unknown attribute in <${where}>: ${key}.`);
|
|
715
|
+
}
|
|
716
|
+
const bound = attr(el, "xmlns:bf");
|
|
717
|
+
if (bound !== undefined && bound !== BLOCOFY_XLIFF_NAMESPACE) throw new XliffError(`The "bf" prefix cannot be bound to another namespace in <${where}>.`);
|
|
718
|
+
const defaults = attr(el, "xmlns");
|
|
719
|
+
if (defaults !== undefined && defaults !== XLIFF_NAMESPACE) throw new XliffError(`The default namespace cannot be changed in <${where}>.`);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function assertKnown(el, allowedChildren, allowedBf, where) {
|
|
723
|
+
for (const child of el.elements) {
|
|
724
|
+
if (!allowedChildren.includes(child.name)) throw new XliffError(`Unsupported element in <${where}>: <${child.name}>.`);
|
|
725
|
+
}
|
|
726
|
+
checkAttributes(el, allowedBf, where);
|
|
727
|
+
if (el.text.join("").trim() !== "") throw new XliffError(`Unexpected text in <${where}>.`);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** The text of the one `<where>` among `list` ("" when absent). */
|
|
731
|
+
function textOf(list, where) {
|
|
732
|
+
if (list.length === 0) return "";
|
|
733
|
+
if (list.length > 1) throw new XliffError(`<${where}> appears more than once in a unit.`);
|
|
734
|
+
const el = list[0];
|
|
735
|
+
checkAttributes(el, [], where);
|
|
736
|
+
if (el.elements.length > 0) {
|
|
737
|
+
throw new XliffError(`Inline elements are not supported in <${where}> (<${el.elements[0].name}>); write the text as plain or escaped HTML.`);
|
|
738
|
+
}
|
|
739
|
+
return decodeReferences(el.text.join(""));
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const UNBOUND_BF = `The "bf" prefix cannot be used without being bound to the namespace "${BLOCOFY_XLIFF_NAMESPACE}".`;
|
|
743
|
+
|
|
744
|
+
export function fromXliff(input) {
|
|
745
|
+
if (typeof input !== "string" || input.trim() === "") throw new XliffError("The XLIFF file is empty.");
|
|
746
|
+
if (input.length > XLIFF_MAX_CHARS) throw new XliffError("The XLIFF file is too large; split it into smaller files.");
|
|
747
|
+
// XML end-of-line handling (XML 1.0 §2.11): a literal CR / CRLF is a newline. An escaped ` ` stays a CR.
|
|
748
|
+
const xml = input.replace(/^/, "").replace(/\r\n?/g, "\n");
|
|
749
|
+
refuseUnsupportedMarkup(xml);
|
|
750
|
+
const root = parseElements(xml);
|
|
751
|
+
if (root.name !== "xliff" || isPlain(root)) throw new XliffError("The root element must be <xliff>.");
|
|
752
|
+
if (attr(root, "version") !== "1.2") throw new XliffError("Only XLIFF 1.2 is supported.");
|
|
753
|
+
if (attr(root, "xmlns") !== XLIFF_NAMESPACE) throw new XliffError(`<xliff> must declare the namespace "${XLIFF_NAMESPACE}".`);
|
|
754
|
+
const bfNamespace = attr(root, "xmlns:bf");
|
|
755
|
+
if (bfNamespace !== undefined && bfNamespace !== BLOCOFY_XLIFF_NAMESPACE) throw new XliffError(`The "bf" prefix must be bound to the namespace "${BLOCOFY_XLIFF_NAMESPACE}".`);
|
|
756
|
+
assertKnown(root, ["file"], [], "xliff");
|
|
757
|
+
const files = childrenNamed(root, "file");
|
|
758
|
+
if (files.length !== 1 || isPlain(files[0])) throw new XliffError("An XLIFF file must contain exactly one <file>.");
|
|
759
|
+
const file = files[0];
|
|
760
|
+
assertKnown(file, ["header", "body"], FILE_BF_ATTRS, "file");
|
|
761
|
+
if (bfNamespace === undefined && usesBf(file)) throw new XliffError(UNBOUND_BF);
|
|
762
|
+
const sourceLocale = attr(file, "source-language");
|
|
763
|
+
const targetLocale = attr(file, "target-language");
|
|
764
|
+
if (!sourceLocale || !targetLocale) throw new XliffError("<file> must carry source-language and target-language.");
|
|
765
|
+
|
|
766
|
+
const bodies = childrenNamed(file, "body");
|
|
767
|
+
if (bodies.length > 1) throw new XliffError("<body> cannot be read.");
|
|
768
|
+
const body = bodies[0];
|
|
769
|
+
let rawUnits = [];
|
|
770
|
+
if (body !== undefined && !(isPlain(body) && body.text.join("").trim() === "")) {
|
|
771
|
+
if (isPlain(body)) throw new XliffError("<body> cannot be read.");
|
|
772
|
+
assertKnown(body, ["trans-unit"], [], "body");
|
|
773
|
+
rawUnits = body.elements;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const units = rawUnits.map((raw, index) => {
|
|
777
|
+
if (isPlain(raw)) throw new XliffError(`<trans-unit> number ${index + 1} cannot be read.`);
|
|
778
|
+
assertKnown(raw, ["source", "target", "note"], UNIT_BF_ATTRS, "trans-unit");
|
|
779
|
+
if (bfNamespace === undefined && usesBf(raw)) throw new XliffError(UNBOUND_BF);
|
|
780
|
+
const id = attr(raw, "id");
|
|
781
|
+
if (!id) throw new XliffError(`<trans-unit> number ${index + 1} has no id.`);
|
|
782
|
+
const targets = childrenNamed(raw, "target");
|
|
783
|
+
const target = textOf(targets, "target");
|
|
784
|
+
const notes = childrenNamed(raw, "note");
|
|
785
|
+
return {
|
|
786
|
+
id,
|
|
787
|
+
kind: attr(raw, "bf:kind"),
|
|
788
|
+
group: attr(raw, "bf:group"),
|
|
789
|
+
context: notes.length > 0 ? textOf([notes[0]], "note") : "",
|
|
790
|
+
type: attr(raw, "bf:type") ?? (attr(raw, "datatype") === "html" ? "html" : "text"),
|
|
791
|
+
source: textOf(childrenNamed(raw, "source"), "source"),
|
|
792
|
+
source_hash: attr(raw, "bf:source-hash") ?? "",
|
|
793
|
+
target,
|
|
794
|
+
target_hash: attr(raw, "bf:target-hash") ?? "",
|
|
795
|
+
state: stateFromXliff(targets.length === 1 && !isPlain(targets[0]) ? attr(targets[0], "state") : undefined, target),
|
|
796
|
+
};
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
const read = readTranslationPackage({
|
|
800
|
+
format: attr(file, "bf:format") ?? TRANSLATION_FORMAT,
|
|
801
|
+
source_locale: sourceLocale,
|
|
802
|
+
target_locale: targetLocale,
|
|
803
|
+
exported_at: attr(file, "bf:exported-at"),
|
|
804
|
+
by: attr(file, "bf:by"),
|
|
805
|
+
next_cursor: attr(file, "bf:next-cursor"),
|
|
806
|
+
units,
|
|
807
|
+
});
|
|
808
|
+
if (!read.ok) throw new XliffError(read.message);
|
|
809
|
+
return read.pkg;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// ── v1 client ───────────────────────────────────────────────────────────────────────────────────────────────
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* `once`: send exactly one attempt. A 5xx or a lost connection throws `ImportOutcomeUnknown` (the server may have done
|
|
816
|
+
* part of the work); a 429 throws `ImportNotApplied` (the call was refused before any work).
|
|
817
|
+
*/
|
|
818
|
+
async function requestJson(url, init, onRetry, once = false) {
|
|
819
|
+
let res;
|
|
820
|
+
try {
|
|
821
|
+
res = await fetchWithRetry(url, init, { onRetry, retries: once ? 0 : undefined });
|
|
822
|
+
} catch (error) {
|
|
823
|
+
if (once) throw new ImportOutcomeUnknown(error?.name === "TimeoutError" ? "timeout" : error?.message ?? String(error));
|
|
824
|
+
throw error;
|
|
825
|
+
}
|
|
826
|
+
if (once && res.status === 429) {
|
|
827
|
+
await res.arrayBuffer().catch(() => {});
|
|
828
|
+
throw new ImportNotApplied(`HTTP ${res.status}`);
|
|
829
|
+
}
|
|
830
|
+
if (once && res.status >= 500) {
|
|
831
|
+
await res.arrayBuffer().catch(() => {});
|
|
832
|
+
throw new ImportOutcomeUnknown(`HTTP ${res.status}`);
|
|
833
|
+
}
|
|
834
|
+
const text = await res.text();
|
|
835
|
+
let json;
|
|
836
|
+
try {
|
|
837
|
+
json = text ? JSON.parse(text) : null;
|
|
838
|
+
} catch {
|
|
839
|
+
json = null;
|
|
840
|
+
}
|
|
841
|
+
if (res.ok) {
|
|
842
|
+
if (json === null) throw new Error(`HTTP ${res.status}: empty or non-JSON response`);
|
|
843
|
+
return json;
|
|
844
|
+
}
|
|
845
|
+
if (res.status >= 400 && res.status < 500) {
|
|
846
|
+
const error = json?.error && typeof json.error === "object" ? json.error : { code: `http_${res.status}`, message: text.slice(0, 200) || `HTTP ${res.status}` };
|
|
847
|
+
throw new CliRefusal(res.status, error);
|
|
848
|
+
}
|
|
849
|
+
const detail = json?.error?.message ?? (text ? text.slice(0, 200) : "");
|
|
850
|
+
throw Object.assign(new Error(`HTTP ${res.status}${detail ? `: ${detail}` : ""}`), { status: res.status });
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const base = (apiUrl) => String(apiUrl).replace(/\/+$/, "");
|
|
854
|
+
|
|
855
|
+
/** Append every `skipped` list of one export window to the running report (by section and reason). */
|
|
856
|
+
function mergeSkips(into, skipped) {
|
|
857
|
+
for (const section of ["groups", "texts", "kinds"]) {
|
|
858
|
+
const byReason = isRecord(skipped?.[section]) ? skipped[section] : {};
|
|
859
|
+
for (const [reason, list] of Object.entries(byReason)) {
|
|
860
|
+
if (!Array.isArray(list) || list.length === 0) continue;
|
|
861
|
+
(into[section][reason] ??= []).push(...list);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
return into;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Every window of the export for one language, merged into one package (`next_cursor: null`), plus the merged
|
|
869
|
+
* `skipped` report. A window may hold zero units and still carry a cursor: the loop follows the cursor until it is
|
|
870
|
+
* null (the JSON answer carries it in the body; `x-next-cursor` / `x-skipped` are the XLIFF answer's headers).
|
|
871
|
+
*/
|
|
872
|
+
export async function exportAllTranslations({ apiUrl, apiKey, locale, only = "all", onRetry = null, onPage = null, maxWindows = 1000 }) {
|
|
873
|
+
const units = [];
|
|
874
|
+
const skipped = { groups: {}, texts: {}, kinds: {} };
|
|
875
|
+
const seen = new Set();
|
|
876
|
+
let first = null;
|
|
877
|
+
let cursor = null;
|
|
878
|
+
for (let page = 1; ; page += 1) {
|
|
879
|
+
if (page > maxWindows) throw new Error(`The export was stopped after ${maxWindows} windows without reaching the end.`);
|
|
880
|
+
const query = new URLSearchParams({ target_locale: locale, format: "json" });
|
|
881
|
+
if (only !== "all") query.set("only", only);
|
|
882
|
+
if (cursor) query.set("cursor", cursor);
|
|
883
|
+
query.set("limit", String(EXPORT_MAX_LIMIT));
|
|
884
|
+
const data = await requestJson(`${base(apiUrl)}/api/v1/translations/export?${query}`, { method: "GET", headers: { authorization: `Bearer ${apiKey}`, accept: "application/json" } }, onRetry);
|
|
885
|
+
if (!isRecord(data) || data.format !== TRANSLATION_FORMAT || !Array.isArray(data.units)) throw new Error("The server's answer is not a translation package.");
|
|
886
|
+
first ??= data;
|
|
887
|
+
units.push(...data.units);
|
|
888
|
+
mergeSkips(skipped, data.skipped);
|
|
889
|
+
onPage?.({ page, units: units.length });
|
|
890
|
+
const next = typeof data.next_cursor === "string" && data.next_cursor !== "" ? data.next_cursor : null;
|
|
891
|
+
if (next === null) break;
|
|
892
|
+
// A cursor seen before (twice in a row, or in a cycle) would page forever.
|
|
893
|
+
if (seen.has(next)) throw new Error("The server returned a cursor it had already returned; the export was stopped.");
|
|
894
|
+
seen.add(next);
|
|
895
|
+
cursor = next;
|
|
896
|
+
}
|
|
897
|
+
const pkg = { format: TRANSLATION_FORMAT, source_locale: first.source_locale, target_locale: first.target_locale, exported_at: first.exported_at, by: first.by, units, next_cursor: null };
|
|
898
|
+
return { pkg, skipped };
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* A wet import with `publish` got no definite answer (a 5xx or a lost connection) and was NOT resent:
|
|
903
|
+
* a resend cannot see what the first call already published (its units would answer `unchanged` and nothing would
|
|
904
|
+
* count as published), so the outcome is reported as unknown instead.
|
|
905
|
+
*/
|
|
906
|
+
export class ImportOutcomeUnknown extends Error {
|
|
907
|
+
constructor(reason) {
|
|
908
|
+
super(`The import call got no definite answer (${reason}).`);
|
|
909
|
+
this.name = "ImportOutcomeUnknown";
|
|
910
|
+
this.reason = reason;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** A wet import with `publish` was refused with 429 before any work and was NOT resent (see `ImportOutcomeUnknown`). */
|
|
915
|
+
export class ImportNotApplied extends Error {
|
|
916
|
+
constructor(reason) {
|
|
917
|
+
super(`The import call was refused before it was applied (${reason}).`);
|
|
918
|
+
this.name = "ImportNotApplied";
|
|
919
|
+
this.reason = reason;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* POST one chunk. Dry runs and imports without `publish` are retried (lib/http.mjs): a resent write converges (a text
|
|
925
|
+
* already written answers `unchanged`). A wet import WITH `publish` is sent exactly once (`ImportOutcomeUnknown`).
|
|
926
|
+
*/
|
|
927
|
+
export function importTranslationChunk({ apiUrl, apiKey, chunk, dryRun, publish, onSourceChange = "skip", onRetry = null }) {
|
|
928
|
+
return requestJson(
|
|
929
|
+
`${base(apiUrl)}/api/v1/translations/import`,
|
|
930
|
+
{
|
|
931
|
+
method: "POST",
|
|
932
|
+
headers: { authorization: `Bearer ${apiKey}`, accept: "application/json", "content-type": "application/json" },
|
|
933
|
+
body: JSON.stringify({ package: chunk, dry_run: dryRun, publish, on_source_change: onSourceChange }),
|
|
934
|
+
},
|
|
935
|
+
onRetry,
|
|
936
|
+
!dryRun && publish === true,
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// ── customer copy ───────────────────────────────────────────────────────────────────────────────────────────
|
|
941
|
+
|
|
942
|
+
const EXPORT_SKIPPED_GROUPS = {
|
|
943
|
+
source_not_published: (n) => `${n} page(s) or record(s) left out: not published in the source language.`,
|
|
944
|
+
duplicate_source: (n) => `${n} translation group(s) left out: more than one version in the default language; resolve the duplicate first.`,
|
|
945
|
+
ambiguous_target: (n) => `${n} translation group(s) left out: more than one version in the target language; resolve the duplicate first.`,
|
|
946
|
+
target_unlinked: (n) => `${n} page(s) or record(s) left out: an unlinked version with the same URL exists in the target language; link the two first.`,
|
|
947
|
+
target_structure_differs: (n) => `${n} menu(s) differ in structure in the target language: their texts are in the package but are not written on import.`,
|
|
948
|
+
schema_unavailable: (n) => `${n} group(s) left out: the theme files could not be read; export again shortly.`,
|
|
949
|
+
source_gone: (n) => `${n} source(s) left out: deleted or changed during the export.`,
|
|
950
|
+
};
|
|
951
|
+
const EXPORT_SKIPPED_TEXTS = {
|
|
952
|
+
unaddressable_name: (n) => `${n} text(s) left out: their field name contains ":" or ".".`,
|
|
953
|
+
duplicate_node_id: (n) => `${n} text(s) left out: their sections share an id.`,
|
|
954
|
+
empty_node_id: (n) => `${n} text(s) left out: their sections have no id.`,
|
|
955
|
+
target_not_text: (n) => `${n} field(s) left out: bound to a dynamic source in the target language.`,
|
|
956
|
+
unknown_schema: (n) => `${n} field(s) left out: their section schema could not be read.`,
|
|
957
|
+
empty_value: (n) => `${n} option(s) left out: the option value is empty.`,
|
|
958
|
+
};
|
|
959
|
+
const KIND_LABELS = { page: "Page", entry: "Record", navigation: "Menu", settings: "Site settings", theme: "Theme", option: "Option" };
|
|
960
|
+
|
|
961
|
+
/** The merged export `skipped` report as lines (one per reason). */
|
|
962
|
+
export function exportSkipLines(skipped) {
|
|
963
|
+
const lines = [];
|
|
964
|
+
for (const [reason, list] of Object.entries(skipped.groups)) lines.push(EXPORT_SKIPPED_GROUPS[reason]?.(list.length) ?? `${list.length} item(s) left out (${reason}).`);
|
|
965
|
+
for (const [reason, list] of Object.entries(skipped.texts)) {
|
|
966
|
+
const count = list.reduce((sum, t) => sum + (Number.isFinite(t?.count) ? t.count : 1), 0);
|
|
967
|
+
lines.push(EXPORT_SKIPPED_TEXTS[reason]?.(count) ?? `${count} text(s) left out (${reason}).`);
|
|
968
|
+
}
|
|
969
|
+
for (const list of Object.values(skipped.kinds)) {
|
|
970
|
+
for (const k of list) lines.push(`${KIND_LABELS[k?.kind] ?? "Some"} texts were left out this time: the theme files could not be read. Export again shortly.`);
|
|
971
|
+
}
|
|
972
|
+
return lines;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
const PUBLISH_SKIPPED = {
|
|
976
|
+
target_archived: (n) => `${n} record(s) not published: archived (the translation was saved).`,
|
|
977
|
+
target_gone: (n) => `${n} record(s) not published: no longer found.`,
|
|
978
|
+
};
|
|
979
|
+
const NOTE_RESULTS = new Set(["source_changed", "target_changed", "blocked"]);
|
|
980
|
+
const addOnce = (list, value) => (typeof value === "string" && value !== "" && !list.includes(value) ? [...list, value] : list);
|
|
981
|
+
|
|
982
|
+
export function emptyImportTally() {
|
|
983
|
+
return {
|
|
984
|
+
counts: Object.fromEntries(IMPORT_UNIT_STATES.map((state) => [state, 0])),
|
|
985
|
+
created: 0,
|
|
986
|
+
stamped: 0,
|
|
987
|
+
published: 0,
|
|
988
|
+
publishBlocked: 0,
|
|
989
|
+
publishSkipped: {},
|
|
990
|
+
notes: [],
|
|
991
|
+
hints: [],
|
|
992
|
+
cacheDegraded: false,
|
|
993
|
+
cacheHints: [],
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/** One chunk's import report folded into the run's tally (the panel's `addReport`). Pure. */
|
|
998
|
+
export function addImportReport(tally, report) {
|
|
999
|
+
const counts = { ...tally.counts };
|
|
1000
|
+
let notes = tally.notes;
|
|
1001
|
+
for (const unit of Array.isArray(report?.units) ? report.units : []) {
|
|
1002
|
+
if (Object.hasOwn(counts, unit.result)) counts[unit.result] += 1;
|
|
1003
|
+
if (NOTE_RESULTS.has(unit.result)) notes = addOnce(notes, unit.message);
|
|
1004
|
+
}
|
|
1005
|
+
let { created, stamped, hints } = tally;
|
|
1006
|
+
const publishSkipped = { ...tally.publishSkipped };
|
|
1007
|
+
for (const group of Array.isArray(report?.groups) ? report.groups : []) {
|
|
1008
|
+
if (group.created) created += 1;
|
|
1009
|
+
if (group.stamped === true && !group.updated) stamped += 1;
|
|
1010
|
+
if (group.publish_reason) publishSkipped[group.publish_reason] = (publishSkipped[group.publish_reason] ?? 0) + 1;
|
|
1011
|
+
else notes = addOnce(notes, group.message);
|
|
1012
|
+
hints = addOnce(hints, group.details?.hint);
|
|
1013
|
+
}
|
|
1014
|
+
const cache = report?.cache;
|
|
1015
|
+
const degraded = isRecord(cache) && cache.ok === false;
|
|
1016
|
+
return {
|
|
1017
|
+
counts,
|
|
1018
|
+
created,
|
|
1019
|
+
stamped,
|
|
1020
|
+
published: tally.published + (report?.publication?.published ?? 0),
|
|
1021
|
+
publishBlocked: tally.publishBlocked + (report?.publication?.blocked ?? 0),
|
|
1022
|
+
publishSkipped,
|
|
1023
|
+
notes,
|
|
1024
|
+
hints,
|
|
1025
|
+
cacheDegraded: tally.cacheDegraded || degraded,
|
|
1026
|
+
cacheHints: degraded ? addOnce(tally.cacheHints, [cache.code, cache.reason].filter(Boolean).join(": ")) : tally.cacheHints,
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/** The summary of a whole import run (dry or wet), as lines. The counts use the report's state names. */
|
|
1031
|
+
export function importSummaryLines(tally, { dryRun, publish, locale, splitGroups = [] }) {
|
|
1032
|
+
const parts = IMPORT_UNIT_STATES.filter((state) => tally.counts[state] > 0).map((state) => `${tally.counts[state]} ${state}`);
|
|
1033
|
+
const lines = [`${dryRun ? "Dry run" : "Imported"} ${locale}: ${parts.length > 0 ? parts.join(", ") : "nothing to do"}`];
|
|
1034
|
+
if (tally.created > 0) lines.push(`${dryRun ? "Would create" : "Created"} ${tally.created} new page(s), record(s) or menu(s) in ${locale}.`);
|
|
1035
|
+
if (tally.stamped > 0) lines.push(`${dryRun ? "Would mark" : "Marked"} ${tally.stamped} page(s) as translated.`);
|
|
1036
|
+
if (!dryRun && publish) {
|
|
1037
|
+
lines.push(`Published ${tally.published} page(s) and record(s).${tally.publishBlocked > 0 ? ` ${tally.publishBlocked} could not be published and stay drafts.` : ""}`);
|
|
1038
|
+
}
|
|
1039
|
+
for (const [reason, count] of Object.entries(tally.publishSkipped)) {
|
|
1040
|
+
lines.push(PUBLISH_SKIPPED[reason]?.(count) ?? `${count} page(s) or record(s) not published (the translation was saved).`);
|
|
1041
|
+
}
|
|
1042
|
+
if (!dryRun && !publish && tally.counts.applied > 0) {
|
|
1043
|
+
lines.push("Page content and new pages and records were written as drafts; publish them with --publish or from the admin panel.");
|
|
1044
|
+
}
|
|
1045
|
+
if (splitGroups.length > 0) {
|
|
1046
|
+
lines.push(`${splitGroups.length} page(s) or record(s) hold more than 5,000 texts and ${dryRun ? "would be" : "were"} sent in several calls, so they are not marked translated.`);
|
|
1047
|
+
}
|
|
1048
|
+
if (tally.cacheDegraded) lines.push("The translations were saved, but the site cache could not be refreshed; visitors may see the change only after a few minutes.");
|
|
1049
|
+
if (tally.notes.length > 0) {
|
|
1050
|
+
lines.push("Notes:");
|
|
1051
|
+
for (const note of tally.notes) lines.push(` - ${note}`);
|
|
1052
|
+
}
|
|
1053
|
+
const technical = [...tally.hints, ...tally.cacheHints];
|
|
1054
|
+
if (technical.length > 0) {
|
|
1055
|
+
lines.push("Details:");
|
|
1056
|
+
for (const hint of technical) lines.push(` ${hint}`);
|
|
1057
|
+
}
|
|
1058
|
+
return lines;
|
|
1059
|
+
}
|