@nexpress/xliff 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nexpress
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,23 @@
1
+ # @nexpress/xliff
2
+
3
+ XLIFF translation export/import for
4
+ [NexPress](https://github.com/nexpress-cms/nexpress) i18n.
5
+
6
+ Round-trips translation strings between the NexPress i18n surface and
7
+ XLIFF 2.x — the format the major translation memory tools (memoQ,
8
+ Trados, Smartcat, Crowdin's XLIFF connector) understand.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pnpm add @nexpress/xliff
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ See the i18n guide:
19
+ [docs/i18n.md](https://github.com/nexpress-cms/nexpress/blob/main/docs/i18n.md).
20
+
21
+ ## License
22
+
23
+ MIT
@@ -0,0 +1,215 @@
1
+ import { NpAuthUser } from '@nexpress/core';
2
+
3
+ interface XliffExportOptions {
4
+ /**
5
+ * Restrict to specific collection slugs. Defaults to every
6
+ * registered i18n-enabled collection. Non-i18n collections are
7
+ * always skipped — they have no `locale` / `translation_group_id`
8
+ * to round-trip.
9
+ */
10
+ collections?: string[];
11
+ /**
12
+ * Locale to treat as the source. Defaults to the configured
13
+ * `defaultLocale`. Translators don't usually translate from a
14
+ * non-default locale, but the option is here for sites that
15
+ * author primarily in another language.
16
+ */
17
+ sourceLocale?: string;
18
+ /**
19
+ * Target locales to emit a file for. Defaults to every
20
+ * configured locale except the source.
21
+ */
22
+ targetLocales?: string[];
23
+ /**
24
+ * Operator running the export. Threaded into `findDocuments` so
25
+ * private-visibility rows still surface to a translator's bundle
26
+ * (#383). Without it, the pipeline's anonymous-visibility guard
27
+ * silently filters every `visibility = "private"` document out
28
+ * of both the source scan and the existing-target scan, so
29
+ * private docs can't round-trip through XLIFF at all.
30
+ */
31
+ user?: NpAuthUser;
32
+ }
33
+ interface XliffExportFile {
34
+ /** Suggested filename, e.g. `discussions-en-ko.xliff`. */
35
+ name: string;
36
+ collection: string;
37
+ sourceLocale: string;
38
+ targetLocale: string;
39
+ /** Number of (doc × translatable field) units in this file. */
40
+ unitCount: number;
41
+ xml: string;
42
+ }
43
+ interface XliffExportBundle {
44
+ files: XliffExportFile[];
45
+ summary: {
46
+ /** Number of source-locale documents seen across all files. */
47
+ docCount: number;
48
+ /** Sum of `unitCount` across `files`. */
49
+ fieldCount: number;
50
+ sourceLocale: string;
51
+ targetLocales: string[];
52
+ };
53
+ }
54
+ /**
55
+ * Walk every i18n-enabled collection (or the subset the caller
56
+ * named), pull every published source-locale row, and emit one
57
+ * XLIFF 1.2 file per (collection, target locale). Each file's
58
+ * `<file original=…>` attribute encodes routing back as
59
+ * `{collectionSlug}/{translationGroupId}` so the import path can
60
+ * resolve siblings without rescanning the registry.
61
+ *
62
+ * Pre-existing target translations (sibling rows already in the
63
+ * target locale) are loaded so their values populate `<target>`
64
+ * — this lets translators see what's already done and edit
65
+ * incrementally rather than re-translating from scratch on each
66
+ * round-trip.
67
+ */
68
+ declare function exportXliff(options?: XliffExportOptions): Promise<XliffExportBundle>;
69
+ declare class XliffExportError extends Error {
70
+ readonly name = "XliffExportError";
71
+ }
72
+
73
+ interface XliffImportOptions {
74
+ /** XLIFF 1.2 XML body to apply. */
75
+ xml: string;
76
+ /** Actor recorded on writes (createdBy / updatedBy / audit). */
77
+ user: NpAuthUser;
78
+ /**
79
+ * When true, parses + resolves siblings + reports what would
80
+ * happen, but writes nothing. Useful for previewing a
81
+ * translator's bundle before applying.
82
+ */
83
+ dryRun?: boolean;
84
+ }
85
+ interface XliffImportApplied {
86
+ collection: string;
87
+ /** Document id that was created or updated. */
88
+ docId: string;
89
+ locale: string;
90
+ operation: "create" | "update";
91
+ /** Number of trans-units actually written for this doc. */
92
+ unitCount: number;
93
+ }
94
+ interface XliffImportSkip {
95
+ reason: string;
96
+ collection?: string;
97
+ groupId?: string;
98
+ locale?: string;
99
+ }
100
+ interface XliffImportResult {
101
+ applied: XliffImportApplied[];
102
+ skipped: XliffImportSkip[];
103
+ /**
104
+ * Whether this run actually touched the database. False when
105
+ * `dryRun: true` or when every file matched a `skipped` rule.
106
+ */
107
+ wrote: boolean;
108
+ }
109
+ /**
110
+ * Apply a translator's XLIFF bundle. For each `<file>`:
111
+ *
112
+ * 1. Parse `original` as `{collectionSlug}/{translationGroupId}`.
113
+ * 2. Look up the source-locale sibling (used as the canonical
114
+ * shape when creating a new target row — non-translatable
115
+ * fields are copied across).
116
+ * 3. Look up the target-locale sibling. If found, UPDATE its
117
+ * translatable fields with each unit's `<target>`. If not,
118
+ * CREATE a new sibling using the source data + `<target>`
119
+ * values for translatable fields.
120
+ *
121
+ * Empty `<target>` text is skipped — a translator who hasn't yet
122
+ * translated that unit shouldn't blank out an existing target.
123
+ * If every unit in a file has an empty target, the file is
124
+ * recorded as skipped rather than landing an empty draft.
125
+ *
126
+ * Errors per file are isolated: a malformed `original` or a
127
+ * missing source sibling adds a `skipped` entry but the rest of
128
+ * the bundle still applies. Throwing is reserved for global
129
+ * problems (i18n not configured, malformed XML).
130
+ */
131
+ declare function importXliff(options: XliffImportOptions): Promise<XliffImportResult>;
132
+ declare class XliffImportError extends Error {
133
+ readonly name = "XliffImportError";
134
+ }
135
+
136
+ /**
137
+ * XLIFF 1.2 reader/writer scoped to the subset we round-trip.
138
+ *
139
+ * Shape contract:
140
+ * - One `<xliff version="1.2">` root with the standard namespace.
141
+ * - One or more `<file>` elements, each with `source-language`,
142
+ * `target-language`, `datatype="plaintext"`, and an `original`
143
+ * attribute that encodes the routing back to a NexPress doc:
144
+ * original = "{collectionSlug}/{translationGroupId}"
145
+ * - Each `<file>` contains a single `<body>` with a flat list of
146
+ * `<trans-unit>` elements:
147
+ * <trans-unit id="{fieldName}">
148
+ * <source>...</source>
149
+ * <target>...</target> // optional / empty pre-translation
150
+ * </trans-unit>
151
+ *
152
+ * We deliberately ignore segmentation (`<seg-source>`), inline
153
+ * markup (`<g>`, `<x>`, etc.), groups, alt-trans, and notes — XLIFF
154
+ * supports them but the round-trip we ship handles atomic-string
155
+ * fields only. Files that come back from a SaaS with extra inline
156
+ * markup will round-trip as if the markup were part of the target
157
+ * text; that's acceptable for v1 since we only export simple text.
158
+ */
159
+ interface XliffTransUnit {
160
+ id: string;
161
+ source: string;
162
+ target: string;
163
+ }
164
+ interface XliffFile {
165
+ /** `original` attribute — `{collectionSlug}/{translationGroupId}` */
166
+ original: string;
167
+ sourceLocale: string;
168
+ targetLocale: string;
169
+ units: XliffTransUnit[];
170
+ }
171
+ interface XliffDocument {
172
+ files: XliffFile[];
173
+ }
174
+ /**
175
+ * Render an XLIFF 1.2 XML document. Output is deterministic
176
+ * (stable element + attribute order, two-space indentation, LF
177
+ * line endings) so a round-tripped file compares clean against
178
+ * the original except for the translator's `<target>` edits.
179
+ */
180
+ declare function renderXliff(doc: XliffDocument): string;
181
+ /**
182
+ * Parse an XLIFF 1.2 XML body into the document shape. Throws on
183
+ * malformed XML or missing required attributes (`source-language`,
184
+ * `target-language`, `original`); per-unit `target` may be empty
185
+ * but the element itself must exist (XLIFF spec allows `<target>`
186
+ * to be omitted but our round-trip emits it always).
187
+ */
188
+ declare function parseXliff(xml: string): XliffDocument;
189
+ declare class XliffParseError extends Error {
190
+ readonly name = "XliffParseError";
191
+ }
192
+
193
+ /**
194
+ * IO seam — tests inject capture hooks; the apps/web shim wires
195
+ * `process.stdout` / `process.stderr` directly.
196
+ */
197
+ interface CliIo {
198
+ out(message: string): void;
199
+ err(message: string): void;
200
+ }
201
+ interface CliRunResult {
202
+ /** 0 = success, non-zero = failure (mirrors process exit codes). */
203
+ exitCode: number;
204
+ }
205
+ /**
206
+ * Parse the raw argv and execute. Returns an exit code rather
207
+ * than calling `process.exit` directly so the apps/web shim can
208
+ * decide how to surface errors (and tests can drive it without
209
+ * killing the runner).
210
+ */
211
+ declare function runCli(io: CliIo, args: string[], options: {
212
+ user: NpAuthUser;
213
+ }): Promise<CliRunResult>;
214
+
215
+ export { type CliIo, type CliRunResult, type XliffDocument, type XliffExportBundle, XliffExportError, type XliffExportFile, type XliffExportOptions, type XliffFile, type XliffImportApplied, XliffImportError, type XliffImportOptions, type XliffImportResult, type XliffImportSkip, XliffParseError, type XliffTransUnit, exportXliff, importXliff, parseXliff, renderXliff, runCli };
package/dist/index.js ADDED
@@ -0,0 +1,565 @@
1
+ // src/export.ts
2
+ import {
3
+ findDocuments,
4
+ getAllCollectionSlugs,
5
+ getCollectionConfig,
6
+ getI18nConfig
7
+ } from "@nexpress/core";
8
+
9
+ // src/format.ts
10
+ import { XMLParser } from "fast-xml-parser";
11
+ function renderXliff(doc) {
12
+ const lines = [
13
+ '<?xml version="1.0" encoding="UTF-8"?>',
14
+ '<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">'
15
+ ];
16
+ for (const file of doc.files) {
17
+ lines.push(
18
+ ` <file source-language="${escapeAttr(file.sourceLocale)}" target-language="${escapeAttr(file.targetLocale)}" datatype="plaintext" original="${escapeAttr(file.original)}">`
19
+ );
20
+ lines.push(" <body>");
21
+ for (const unit of file.units) {
22
+ lines.push(` <trans-unit id="${escapeAttr(unit.id)}">`);
23
+ lines.push(` <source>${escapeText(unit.source)}</source>`);
24
+ lines.push(` <target>${escapeText(unit.target)}</target>`);
25
+ lines.push(" </trans-unit>");
26
+ }
27
+ lines.push(" </body>");
28
+ lines.push(" </file>");
29
+ }
30
+ lines.push("</xliff>");
31
+ return lines.join("\n");
32
+ }
33
+ var PARSER = new XMLParser({
34
+ ignoreAttributes: false,
35
+ attributeNamePrefix: "@_",
36
+ // Preserve whitespace inside <source> / <target> — translators
37
+ // may want leading/trailing space for things like " — " or
38
+ // newlines between paragraphs.
39
+ trimValues: false,
40
+ // Keep elements parsed as objects even when there's only one
41
+ // child, so `file.body.trans-unit` is always an array we can
42
+ // iterate without runtime branching.
43
+ isArray: (name, jpath) => {
44
+ return jpath === "xliff.file" || jpath === "xliff.file.body.trans-unit";
45
+ }
46
+ });
47
+ function parseXliff(xml) {
48
+ let parsed;
49
+ try {
50
+ parsed = PARSER.parse(xml);
51
+ } catch (error) {
52
+ throw new XliffParseError(
53
+ `Malformed XLIFF XML: ${error.message}`
54
+ );
55
+ }
56
+ const root = parsed.xliff;
57
+ if (!root) {
58
+ throw new XliffParseError("Missing root <xliff> element");
59
+ }
60
+ const files = root.file ?? [];
61
+ const out = [];
62
+ for (const f of files) {
63
+ const sourceLocale = f["@_source-language"];
64
+ const targetLocale = f["@_target-language"];
65
+ const original = f["@_original"];
66
+ if (!sourceLocale || !targetLocale || !original) {
67
+ throw new XliffParseError(
68
+ "Each <file> must declare source-language, target-language, and original"
69
+ );
70
+ }
71
+ const units = [];
72
+ for (const u of f.body?.["trans-unit"] ?? []) {
73
+ const id = u["@_id"];
74
+ if (!id) {
75
+ throw new XliffParseError(
76
+ `<trans-unit> in file "${original}" is missing the id attribute`
77
+ );
78
+ }
79
+ units.push({
80
+ id,
81
+ source: extractText(u.source),
82
+ target: extractText(u.target)
83
+ });
84
+ }
85
+ out.push({ original, sourceLocale, targetLocale, units });
86
+ }
87
+ return { files: out };
88
+ }
89
+ var XliffParseError = class extends Error {
90
+ name = "XliffParseError";
91
+ };
92
+ function extractText(node) {
93
+ if (node === void 0 || node === null) return "";
94
+ if (typeof node === "string") return node;
95
+ if (typeof node === "object" && typeof node["#text"] === "string") {
96
+ return node["#text"];
97
+ }
98
+ return "";
99
+ }
100
+ function escapeAttr(value) {
101
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
102
+ }
103
+ function escapeText(value) {
104
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
105
+ }
106
+
107
+ // src/export.ts
108
+ var TRANSLATABLE_TYPES = /* @__PURE__ */ new Set(["text", "textarea", "email"]);
109
+ async function exportXliff(options = {}) {
110
+ const i18n = getI18nConfig();
111
+ if (!i18n) {
112
+ throw new XliffExportError(
113
+ "i18n is not configured \u2014 call setI18nConfig() before exporting XLIFF"
114
+ );
115
+ }
116
+ const sourceLocale = options.sourceLocale ?? i18n.defaultLocale;
117
+ if (!i18n.locales.includes(sourceLocale)) {
118
+ throw new XliffExportError(
119
+ `sourceLocale "${sourceLocale}" is not in the configured locale list`
120
+ );
121
+ }
122
+ const targetLocales = (options.targetLocales ?? i18n.locales.filter((l) => l !== sourceLocale)).filter((l) => l !== sourceLocale);
123
+ for (const t of targetLocales) {
124
+ if (!i18n.locales.includes(t)) {
125
+ throw new XliffExportError(
126
+ `targetLocale "${t}" is not in the configured locale list`
127
+ );
128
+ }
129
+ }
130
+ const slugs = options.collections ?? getAllCollectionSlugs();
131
+ const files = [];
132
+ let totalDocCount = 0;
133
+ let totalFieldCount = 0;
134
+ for (const slug of slugs) {
135
+ let config;
136
+ try {
137
+ config = getCollectionConfig(slug);
138
+ } catch {
139
+ continue;
140
+ }
141
+ if (!config.i18n) continue;
142
+ const translatableFields = config.fields.filter((f) => "name" in f && TRANSLATABLE_TYPES.has(f.type)).map((f) => f.name);
143
+ if (translatableFields.length === 0) continue;
144
+ const sourceResult = await findDocuments(
145
+ slug,
146
+ {
147
+ limit: 5e3,
148
+ page: 1,
149
+ where: { status: "published" },
150
+ locale: sourceLocale
151
+ },
152
+ options.user
153
+ );
154
+ const sourceDocs = sourceResult.docs;
155
+ if (sourceDocs.length === 0) continue;
156
+ totalDocCount += sourceDocs.length;
157
+ for (const targetLocale of targetLocales) {
158
+ const targetResult = await findDocuments(
159
+ slug,
160
+ {
161
+ limit: 5e3,
162
+ page: 1,
163
+ locale: targetLocale
164
+ },
165
+ options.user
166
+ );
167
+ const targetByGroupId = /* @__PURE__ */ new Map();
168
+ for (const doc of targetResult.docs) {
169
+ const groupId = doc.translationGroupId;
170
+ if (groupId) targetByGroupId.set(groupId, doc);
171
+ }
172
+ const fileUnits = [];
173
+ const filesByGroup = [];
174
+ for (const sourceDoc of sourceDocs) {
175
+ const groupId = sourceDoc.translationGroupId;
176
+ if (!groupId) continue;
177
+ const targetDoc = targetByGroupId.get(groupId) ?? null;
178
+ const docUnits = [];
179
+ for (const fieldName of translatableFields) {
180
+ const sourceValue = stringField(sourceDoc, fieldName);
181
+ if (sourceValue === "") continue;
182
+ const targetValue = targetDoc ? stringField(targetDoc, fieldName) : "";
183
+ docUnits.push({
184
+ id: fieldName,
185
+ source: sourceValue,
186
+ target: targetValue
187
+ });
188
+ }
189
+ if (docUnits.length === 0) continue;
190
+ filesByGroup.push({
191
+ original: `${slug}/${groupId}`,
192
+ sourceLocale,
193
+ targetLocale,
194
+ units: docUnits
195
+ });
196
+ fileUnits.push(...docUnits);
197
+ }
198
+ if (filesByGroup.length === 0) continue;
199
+ const xml = renderXliff({ files: filesByGroup });
200
+ files.push({
201
+ name: `${slug}-${sourceLocale}-${targetLocale}.xliff`,
202
+ collection: slug,
203
+ sourceLocale,
204
+ targetLocale,
205
+ unitCount: fileUnits.length,
206
+ xml
207
+ });
208
+ totalFieldCount += fileUnits.length;
209
+ }
210
+ }
211
+ return {
212
+ files,
213
+ summary: {
214
+ docCount: totalDocCount,
215
+ fieldCount: totalFieldCount,
216
+ sourceLocale,
217
+ targetLocales
218
+ }
219
+ };
220
+ }
221
+ var XliffExportError = class extends Error {
222
+ name = "XliffExportError";
223
+ };
224
+ function stringField(doc, fieldName) {
225
+ const v = doc[fieldName];
226
+ return typeof v === "string" ? v : "";
227
+ }
228
+
229
+ // src/import.ts
230
+ import {
231
+ findDocuments as findDocuments2,
232
+ getCollectionConfig as getCollectionConfig2,
233
+ getDocumentById,
234
+ getI18nConfig as getI18nConfig2,
235
+ saveDocument
236
+ } from "@nexpress/core";
237
+ var TRANSLATABLE_TYPES2 = /* @__PURE__ */ new Set(["text", "textarea", "email"]);
238
+ async function importXliff(options) {
239
+ const i18n = getI18nConfig2();
240
+ if (!i18n) {
241
+ throw new XliffImportError(
242
+ "i18n is not configured \u2014 call setI18nConfig() before importing XLIFF"
243
+ );
244
+ }
245
+ const doc = parseXliff(options.xml);
246
+ const applied = [];
247
+ const skipped = [];
248
+ for (const file of doc.files) {
249
+ const parsed = parseOriginal(file.original);
250
+ if (!parsed) {
251
+ skipped.push({
252
+ reason: `Malformed file original "${file.original}" (expected "{collection}/{groupId}")`
253
+ });
254
+ continue;
255
+ }
256
+ const { collection, groupId } = parsed;
257
+ if (!i18n.locales.includes(file.targetLocale)) {
258
+ skipped.push({
259
+ reason: `Target locale "${file.targetLocale}" is not configured`,
260
+ collection,
261
+ groupId,
262
+ locale: file.targetLocale
263
+ });
264
+ continue;
265
+ }
266
+ let config;
267
+ try {
268
+ config = getCollectionConfig2(collection);
269
+ } catch {
270
+ skipped.push({
271
+ reason: `Unknown collection "${collection}"`,
272
+ collection,
273
+ groupId,
274
+ locale: file.targetLocale
275
+ });
276
+ continue;
277
+ }
278
+ if (!config.i18n) {
279
+ skipped.push({
280
+ reason: `Collection "${collection}" is not i18n-enabled`,
281
+ collection,
282
+ groupId,
283
+ locale: file.targetLocale
284
+ });
285
+ continue;
286
+ }
287
+ const translatableNames = new Set(
288
+ config.fields.filter((f) => "name" in f && TRANSLATABLE_TYPES2.has(f.type)).map((f) => f.name)
289
+ );
290
+ const sourceSibling = await findSibling(
291
+ collection,
292
+ groupId,
293
+ file.sourceLocale,
294
+ options.user
295
+ );
296
+ if (!sourceSibling) {
297
+ skipped.push({
298
+ reason: `No source row for groupId=${groupId} locale=${file.sourceLocale}`,
299
+ collection,
300
+ groupId,
301
+ locale: file.targetLocale
302
+ });
303
+ continue;
304
+ }
305
+ const targetSibling = await findSibling(
306
+ collection,
307
+ groupId,
308
+ file.targetLocale,
309
+ options.user
310
+ );
311
+ const overrides = {};
312
+ const rejectedFieldIds = [];
313
+ for (const unit of file.units) {
314
+ if (unit.target.length === 0) continue;
315
+ if (!translatableNames.has(unit.id)) {
316
+ rejectedFieldIds.push(unit.id);
317
+ continue;
318
+ }
319
+ overrides[unit.id] = unit.target;
320
+ }
321
+ if (rejectedFieldIds.length > 0) {
322
+ skipped.push({
323
+ reason: `Ignored ${rejectedFieldIds.length} unit${rejectedFieldIds.length === 1 ? "" : "s"} with non-translatable id: ${rejectedFieldIds.join(", ")}`,
324
+ collection,
325
+ groupId,
326
+ locale: file.targetLocale
327
+ });
328
+ }
329
+ if (Object.keys(overrides).length === 0) {
330
+ skipped.push({
331
+ reason: "All <target> elements in this file were empty",
332
+ collection,
333
+ groupId,
334
+ locale: file.targetLocale
335
+ });
336
+ continue;
337
+ }
338
+ if (options.dryRun) {
339
+ applied.push({
340
+ collection,
341
+ docId: targetSibling ? targetSibling.id : "(would-create)",
342
+ locale: file.targetLocale,
343
+ operation: targetSibling ? "update" : "create",
344
+ unitCount: Object.keys(overrides).length
345
+ });
346
+ continue;
347
+ }
348
+ if (targetSibling) {
349
+ const merged = { ...targetSibling, ...overrides };
350
+ const cleaned = stripFrameworkFields(merged);
351
+ const result = await saveDocument(
352
+ collection,
353
+ targetSibling.id,
354
+ cleaned,
355
+ options.user
356
+ );
357
+ applied.push({
358
+ collection,
359
+ docId: result.doc.id,
360
+ locale: file.targetLocale,
361
+ operation: "update",
362
+ unitCount: Object.keys(overrides).length
363
+ });
364
+ } else {
365
+ const baseline = stripFrameworkFields({ ...sourceSibling });
366
+ baseline.locale = file.targetLocale;
367
+ baseline.translationGroupId = groupId;
368
+ const created = await saveDocument(
369
+ collection,
370
+ null,
371
+ baseline,
372
+ options.user,
373
+ { status: "draft" }
374
+ );
375
+ const newId = created.doc.id;
376
+ const result = await saveDocument(
377
+ collection,
378
+ newId,
379
+ { ...baseline, ...overrides },
380
+ options.user
381
+ );
382
+ applied.push({
383
+ collection,
384
+ docId: result.doc.id,
385
+ locale: file.targetLocale,
386
+ operation: "create",
387
+ unitCount: Object.keys(overrides).length
388
+ });
389
+ }
390
+ }
391
+ return {
392
+ applied,
393
+ skipped,
394
+ wrote: !options.dryRun && applied.length > 0
395
+ };
396
+ }
397
+ var XliffImportError = class extends Error {
398
+ name = "XliffImportError";
399
+ };
400
+ function parseOriginal(original) {
401
+ const idx = original.lastIndexOf("/");
402
+ if (idx <= 0 || idx === original.length - 1) return null;
403
+ const collection = original.slice(0, idx);
404
+ const groupId = original.slice(idx + 1);
405
+ if (!/^[a-z0-9_-]+$/.test(collection)) return null;
406
+ if (!isUuid(groupId)) return null;
407
+ return { collection, groupId };
408
+ }
409
+ function isUuid(value) {
410
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
411
+ value
412
+ );
413
+ }
414
+ async function findSibling(collection, groupId, locale, user) {
415
+ const result = await findDocuments2(
416
+ collection,
417
+ {
418
+ limit: 1,
419
+ page: 1,
420
+ where: { translationGroupId: groupId },
421
+ locale
422
+ },
423
+ user
424
+ );
425
+ const row = result.docs[0];
426
+ if (!row) return null;
427
+ const id = row.id;
428
+ if (!id) return null;
429
+ const full = await getDocumentById(collection, id, user);
430
+ return full ?? null;
431
+ }
432
+ var FRAMEWORK_FIELDS = /* @__PURE__ */ new Set([
433
+ "id",
434
+ "status",
435
+ "_status",
436
+ "createdAt",
437
+ "updatedAt",
438
+ "createdBy",
439
+ "updatedBy",
440
+ "searchVector"
441
+ ]);
442
+ function stripFrameworkFields(doc) {
443
+ const out = {};
444
+ for (const [key, value] of Object.entries(doc)) {
445
+ if (FRAMEWORK_FIELDS.has(key)) continue;
446
+ out[key] = value;
447
+ }
448
+ return out;
449
+ }
450
+
451
+ // src/cli.ts
452
+ import { mkdirSync, readFileSync, writeFileSync } from "fs";
453
+ import { dirname, join } from "path";
454
+ var USAGE = `Usage:
455
+ xliff export <out-dir> Write one .xliff file per (collection, locale-pair).
456
+ xliff import <file> [--dry-run] Apply a translator's XLIFF bundle.
457
+
458
+ Options:
459
+ --dry-run Validate + report without writing.
460
+
461
+ Notes:
462
+ - Source-locale rows must be \`status="published"\` to ship to translators.
463
+ - Imported targets land as \`status="draft"\` so a reviewer can publish.
464
+ - Run with apps/web's \`pnpm xliff\` shim so core services are wired.
465
+ `;
466
+ async function runCli(io, args, options) {
467
+ const [command, ...rest] = args;
468
+ if (!command || command === "--help" || command === "-h") {
469
+ io.out(USAGE);
470
+ return { exitCode: command ? 0 : 1 };
471
+ }
472
+ if (command === "export") {
473
+ return runExport(io, rest, options.user);
474
+ }
475
+ if (command === "import") {
476
+ return runImport(io, rest, options.user);
477
+ }
478
+ io.err(`Unknown command: ${command}
479
+
480
+ ${USAGE}`);
481
+ return { exitCode: 2 };
482
+ }
483
+ async function runExport(io, args, user) {
484
+ const outDir = args[0];
485
+ if (!outDir) {
486
+ io.err("xliff export: <out-dir> argument is required\n");
487
+ return { exitCode: 2 };
488
+ }
489
+ const bundle = await exportXliff({ user });
490
+ if (bundle.files.length === 0) {
491
+ io.out(
492
+ "xliff export: no i18n collections with translatable content \u2014 nothing written.\n"
493
+ );
494
+ return { exitCode: 0 };
495
+ }
496
+ for (const file of bundle.files) {
497
+ const path = join(outDir, file.name);
498
+ mkdirSync(dirname(path), { recursive: true });
499
+ writeFileSync(path, file.xml, "utf8");
500
+ io.out(` wrote ${path} (${file.unitCount} unit${file.unitCount === 1 ? "" : "s"})
501
+ `);
502
+ }
503
+ io.out(
504
+ `
505
+ Exported ${bundle.files.length} file${bundle.files.length === 1 ? "" : "s"} (${bundle.summary.docCount} doc${bundle.summary.docCount === 1 ? "" : "s"}, ${bundle.summary.fieldCount} unit${bundle.summary.fieldCount === 1 ? "" : "s"}, targets: ${bundle.summary.targetLocales.join(", ")}).
506
+ `
507
+ );
508
+ return { exitCode: 0 };
509
+ }
510
+ async function runImport(io, args, user) {
511
+ const positional = [];
512
+ let dryRun = false;
513
+ for (const arg of args) {
514
+ if (arg === "--dry-run") {
515
+ dryRun = true;
516
+ } else if (arg.startsWith("--")) {
517
+ io.err(`xliff import: unknown flag ${arg}
518
+ `);
519
+ return { exitCode: 2 };
520
+ } else {
521
+ positional.push(arg);
522
+ }
523
+ }
524
+ const filePath = positional[0];
525
+ if (!filePath) {
526
+ io.err("xliff import: <file> argument is required\n");
527
+ return { exitCode: 2 };
528
+ }
529
+ let xml;
530
+ try {
531
+ xml = readFileSync(filePath, "utf8");
532
+ } catch (error) {
533
+ io.err(`xliff import: cannot read ${filePath} \u2014 ${error.message}
534
+ `);
535
+ return { exitCode: 1 };
536
+ }
537
+ const result = await importXliff({ xml, user, dryRun });
538
+ for (const a of result.applied) {
539
+ const verb = dryRun ? `would ${a.operation}` : a.operation;
540
+ io.out(
541
+ ` ${verb} ${a.collection}/${a.docId} locale=${a.locale} units=${a.unitCount}
542
+ `
543
+ );
544
+ }
545
+ for (const s of result.skipped) {
546
+ io.out(` skip ${s.reason}
547
+ `);
548
+ }
549
+ io.out(
550
+ `
551
+ ${dryRun ? "(dry-run) " : ""}Applied ${result.applied.length}, skipped ${result.skipped.length}${result.wrote ? "" : " (no writes)"}.
552
+ `
553
+ );
554
+ return { exitCode: 0 };
555
+ }
556
+ export {
557
+ XliffExportError,
558
+ XliffImportError,
559
+ XliffParseError,
560
+ exportXliff,
561
+ importXliff,
562
+ parseXliff,
563
+ renderXliff,
564
+ runCli
565
+ };
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@nexpress/xliff",
3
+ "version": "0.1.0",
4
+ "description": "XLIFF translation export/import for NexPress i18n.",
5
+ "license": "MIT",
6
+ "author": "Nexpress",
7
+ "homepage": "https://github.com/nexpress-cms/nexpress#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/nexpress-cms/nexpress.git",
11
+ "directory": "packages/xliff"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/nexpress-cms/nexpress/issues"
15
+ },
16
+ "keywords": [
17
+ "nexpress",
18
+ "i18n",
19
+ "xliff",
20
+ "translation"
21
+ ],
22
+ "type": "module",
23
+ "main": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.js"
29
+ }
30
+ },
31
+ "files": [
32
+ "dist"
33
+ ],
34
+ "engines": {
35
+ "node": ">=20"
36
+ },
37
+ "dependencies": {
38
+ "fast-xml-parser": "^5.5.8",
39
+ "@nexpress/core": "0.1.0"
40
+ },
41
+ "devDependencies": {
42
+ "tsup": "^8.5.0",
43
+ "typescript": "^5.8.0",
44
+ "vitest": "^3.2.4"
45
+ },
46
+ "scripts": {
47
+ "build": "tsup",
48
+ "dev": "tsup --watch --no-clean",
49
+ "clean": "rm -rf dist",
50
+ "typecheck": "tsc --noEmit",
51
+ "lint": "eslint . --cache --cache-location node_modules/.cache/eslint",
52
+ "test": "vitest run",
53
+ "test:watch": "vitest"
54
+ }
55
+ }