@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 +21 -0
- package/README.md +23 -0
- package/dist/index.d.ts +215 -0
- package/dist/index.js +565 -0
- package/package.json +55 -0
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
|
package/dist/index.d.ts
ADDED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
102
|
+
}
|
|
103
|
+
function escapeText(value) {
|
|
104
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
+
}
|