@ai-translate/apple 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 +129 -0
- package/dist/index.d.mts +38 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1262 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +61 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1262 @@
|
|
|
1
|
+
import * as path$1 from "node:path";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { convertPathToPattern, globby } from "globby";
|
|
4
|
+
import { addressToJsonPointer } from "@ai-translate/core/address";
|
|
5
|
+
import { applePrintfMessageFormat } from "@ai-translate/message-formats";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { promises } from "node:fs";
|
|
8
|
+
import { digestValue } from "@ai-translate/core/hash";
|
|
9
|
+
import { plainMessageFormat } from "@ai-translate/core/message-format";
|
|
10
|
+
import { isPluralCategory, pluralCategoriesFor, sortPluralCategories } from "@ai-translate/core/plural";
|
|
11
|
+
import { defineIntegration, findProjectFiles, isLocaleTag, readStringLiteral, resolveSourceLocale } from "@ai-translate/integrations";
|
|
12
|
+
import ignore from "ignore";
|
|
13
|
+
//#region src/files.ts
|
|
14
|
+
const pendingWrites = /* @__PURE__ */ new Map();
|
|
15
|
+
/** Serializes read/merge/write operations, including separate adapter instances. */
|
|
16
|
+
async function withFileLock(filePath, operation) {
|
|
17
|
+
const key = path$1.resolve(filePath);
|
|
18
|
+
const current = (pendingWrites.get(key) ?? Promise.resolve()).catch(() => void 0).then(operation);
|
|
19
|
+
pendingWrites.set(key, current);
|
|
20
|
+
try {
|
|
21
|
+
return await current;
|
|
22
|
+
} finally {
|
|
23
|
+
if (pendingWrites.get(key) === current) pendingWrites.delete(key);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async function readText(filePath) {
|
|
27
|
+
try {
|
|
28
|
+
return await promises.readFile(filePath, "utf8");
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error.code === "ENOENT") return null;
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function writeTextAtomic(filePath, contents) {
|
|
35
|
+
await promises.mkdir(path$1.dirname(filePath), { recursive: true });
|
|
36
|
+
const temporaryPath = path$1.join(path$1.dirname(filePath), `.${path$1.basename(filePath)}.${randomUUID()}`);
|
|
37
|
+
let mode;
|
|
38
|
+
try {
|
|
39
|
+
mode = (await promises.stat(filePath)).mode;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error.code !== "ENOENT") throw error;
|
|
42
|
+
}
|
|
43
|
+
let handle;
|
|
44
|
+
try {
|
|
45
|
+
handle = await promises.open(temporaryPath, "wx", mode);
|
|
46
|
+
await handle.writeFile(contents);
|
|
47
|
+
if (mode !== void 0) await handle.chmod(mode);
|
|
48
|
+
await handle.sync();
|
|
49
|
+
await handle.close();
|
|
50
|
+
handle = void 0;
|
|
51
|
+
await promises.rename(temporaryPath, filePath);
|
|
52
|
+
} finally {
|
|
53
|
+
await handle?.close();
|
|
54
|
+
await promises.rm(temporaryPath, { force: true });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region src/xcstrings-model.ts
|
|
59
|
+
function isObject(value) {
|
|
60
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
61
|
+
}
|
|
62
|
+
function own(object, key) {
|
|
63
|
+
return Object.hasOwn(object, key) ? object[key] : void 0;
|
|
64
|
+
}
|
|
65
|
+
function put(object, key, value) {
|
|
66
|
+
Object.defineProperty(object, key, {
|
|
67
|
+
configurable: true,
|
|
68
|
+
enumerable: true,
|
|
69
|
+
value,
|
|
70
|
+
writable: true
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
function requireObject(value, location) {
|
|
74
|
+
if (!isObject(value)) throw new Error(`Invalid String Catalog at ${location}: expected an object.`);
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
function validateNode(value, location, requireFallback, depth = 0) {
|
|
78
|
+
if (depth > 64) throw new Error(`String Catalog localization nesting exceeds 64 levels at ${location}.`);
|
|
79
|
+
const node = requireObject(value, location);
|
|
80
|
+
if (node.stringUnit === void 0 && node.variations === void 0) throw new Error(`Unsupported String Catalog localization at ${location}: expected stringUnit or variations.`);
|
|
81
|
+
if (node.stringUnit !== void 0) {
|
|
82
|
+
const unit = requireObject(node.stringUnit, `${location}.stringUnit`);
|
|
83
|
+
if (typeof unit.value !== "string" || typeof unit.state !== "string") throw new Error(`Invalid String Catalog stringUnit at ${location}: value and state must be strings.`);
|
|
84
|
+
}
|
|
85
|
+
if (node.variations !== void 0) {
|
|
86
|
+
const variations = requireObject(node.variations, `${location}.variations`);
|
|
87
|
+
if (Object.keys(variations).length === 0) throw new Error(`Empty String Catalog variations at ${location}.`);
|
|
88
|
+
for (const [dimension, rawArms] of Object.entries(variations)) {
|
|
89
|
+
if (dimension !== "plural" && dimension !== "device") throw new Error(`Unsupported String Catalog variation "${dimension}" at ${location}.`);
|
|
90
|
+
const arms = requireObject(rawArms, `${location}.variations.${dimension}`);
|
|
91
|
+
if (requireFallback && !Object.hasOwn(arms, "other")) throw new Error(`String Catalog ${dimension} variation requires an other arm at ${location}.`);
|
|
92
|
+
for (const [arm, child] of Object.entries(arms)) {
|
|
93
|
+
if (dimension === "plural" && !isPluralCategory(arm)) throw new Error(`Invalid String Catalog plural category "${arm}" at ${location}.`);
|
|
94
|
+
validateNode(child, `${location}.variations.${dimension}.${arm}`, requireFallback, depth + 1);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (node.substitutions !== void 0) {
|
|
99
|
+
const substitutions = requireObject(node.substitutions, `${location}.substitutions`);
|
|
100
|
+
for (const [name, rawSubstitution] of Object.entries(substitutions)) {
|
|
101
|
+
const substitution = requireObject(rawSubstitution, `${location}.substitutions.${name}`);
|
|
102
|
+
if (!Number.isSafeInteger(substitution.argNum) || substitution.argNum < 1 || typeof substitution.formatSpecifier !== "string" || substitution.formatSpecifier.length === 0) throw new Error(`Invalid String Catalog substitution "${name}" at ${location}: expected argNum and formatSpecifier.`);
|
|
103
|
+
validateNode(substitution, `${location}.substitutions.${name}`, requireFallback, depth + 1);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function parseCatalog(text, filePath, sourceLocale) {
|
|
108
|
+
let parsed;
|
|
109
|
+
try {
|
|
110
|
+
parsed = JSON.parse(text.replace(/^\uFEFF/u, ""));
|
|
111
|
+
} catch (error) {
|
|
112
|
+
throw new Error(`Invalid JSON in String Catalog ${filePath}.`, { cause: error });
|
|
113
|
+
}
|
|
114
|
+
const catalog = requireObject(parsed, filePath);
|
|
115
|
+
if (typeof catalog.version !== "string" || ![
|
|
116
|
+
"1.0",
|
|
117
|
+
"1.1",
|
|
118
|
+
"1.2",
|
|
119
|
+
"1.3"
|
|
120
|
+
].includes(catalog.version)) throw new Error(`Unsupported String Catalog version in ${filePath}: expected "1.0", "1.1", "1.2", or "1.3".`);
|
|
121
|
+
if (catalog.sourceLanguage !== sourceLocale) throw new Error(`String Catalog ${filePath} sourceLanguage must match sourceLocale "${sourceLocale}".`);
|
|
122
|
+
const strings = requireObject(catalog.strings, `${filePath}.strings`);
|
|
123
|
+
for (const [key, rawString] of Object.entries(strings)) {
|
|
124
|
+
const string = requireObject(rawString, `${filePath}.strings[${JSON.stringify(key)}]`);
|
|
125
|
+
if (string.shouldTranslate !== void 0 && typeof string.shouldTranslate !== "boolean") throw new Error(`Invalid shouldTranslate for String Catalog key ${JSON.stringify(key)} in ${filePath}.`);
|
|
126
|
+
if (string.comment !== void 0 && typeof string.comment !== "string") throw new Error(`Invalid comment for String Catalog key ${JSON.stringify(key)} in ${filePath}.`);
|
|
127
|
+
if (string.localizations !== void 0) {
|
|
128
|
+
const localizations = requireObject(string.localizations, `${filePath}.${key}.localizations`);
|
|
129
|
+
for (const [locale, localization] of Object.entries(localizations)) validateNode(localization, `${filePath}.${key}.localizations.${locale}`, locale === sourceLocale);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return catalog;
|
|
133
|
+
}
|
|
134
|
+
function isTranslatable(key, string) {
|
|
135
|
+
return key.length > 0 && string.shouldTranslate !== false && string.extractionState !== "stale";
|
|
136
|
+
}
|
|
137
|
+
function localeRoots(catalog, locale) {
|
|
138
|
+
return Object.fromEntries(Object.entries(catalog.strings).flatMap(([key, rawString]) => {
|
|
139
|
+
const string = rawString;
|
|
140
|
+
if (!isTranslatable(key, string)) return [];
|
|
141
|
+
const localizations = string.localizations;
|
|
142
|
+
const node = localizations === void 0 ? void 0 : own(localizations, locale);
|
|
143
|
+
if (node !== void 0) return [[key, structuredClone(node)]];
|
|
144
|
+
return locale === catalog.sourceLanguage ? [[key, { stringUnit: {
|
|
145
|
+
state: "translated",
|
|
146
|
+
value: key
|
|
147
|
+
} }]] : [];
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
function fallbackNode(node) {
|
|
151
|
+
if (isObject(node.stringUnit)) {
|
|
152
|
+
const fallback = structuredClone(node);
|
|
153
|
+
delete fallback.variations;
|
|
154
|
+
return fallback;
|
|
155
|
+
}
|
|
156
|
+
for (const arms of Object.values(isObject(node.variations) ? node.variations : {})) if (isObject(arms) && isObject(arms.other)) return fallbackNode(arms.other);
|
|
157
|
+
throw new Error("String Catalog source variation requires a string fallback.");
|
|
158
|
+
}
|
|
159
|
+
function assertSourceSubstitutions(source, target, location) {
|
|
160
|
+
const sourceBindings = isObject(source.substitutions) ? source.substitutions : {};
|
|
161
|
+
const targetBindings = isObject(target?.substitutions) ? target.substitutions : {};
|
|
162
|
+
for (const name of Object.keys(targetBindings)) if (!Object.hasOwn(sourceBindings, name)) throw new Error(`Unsupported target-only String Catalog substitution ${JSON.stringify(name)} at ${location}. Define the corresponding substitution in the source localization before translating; its fragment meaning cannot be derived safely from flat source text.`);
|
|
163
|
+
}
|
|
164
|
+
function expandNode(node, locale, target, location = "localization") {
|
|
165
|
+
assertSourceSubstitutions(node, target, `${location} (${locale})`);
|
|
166
|
+
const expanded = structuredClone(node);
|
|
167
|
+
const sourceVariations = isObject(node.variations) ? node.variations : {};
|
|
168
|
+
const targetVariations = isObject(target?.variations) ? target.variations : {};
|
|
169
|
+
const dimensions = [.../* @__PURE__ */ new Set([...Object.keys(sourceVariations), ...Object.keys(targetVariations)])];
|
|
170
|
+
if (dimensions.length > 0) {
|
|
171
|
+
const variations = {};
|
|
172
|
+
for (const dimension of dimensions) {
|
|
173
|
+
const arms = own(sourceVariations, dimension) ?? {};
|
|
174
|
+
const targetArms = own(targetVariations, dimension) ?? {};
|
|
175
|
+
const present = [.../* @__PURE__ */ new Set([
|
|
176
|
+
...Object.keys(arms),
|
|
177
|
+
...Object.keys(targetArms),
|
|
178
|
+
"other"
|
|
179
|
+
])];
|
|
180
|
+
const categories = dimension === "plural" ? sortPluralCategories([.../* @__PURE__ */ new Set([...present, ...pluralCategoriesFor(locale)])]) : present;
|
|
181
|
+
const fallback = own(arms, "other") ?? fallbackNode(node);
|
|
182
|
+
put(variations, dimension, Object.fromEntries(categories.map((category) => [category, expandNode(own(arms, category) ?? fallback, locale, own(targetArms, category), `${location}.variations.${dimension}.${category}`)])));
|
|
183
|
+
}
|
|
184
|
+
expanded.variations = variations;
|
|
185
|
+
if (Object.keys(sourceVariations).length === 0 && target?.stringUnit === void 0) delete expanded.stringUnit;
|
|
186
|
+
}
|
|
187
|
+
if (isObject(node.substitutions)) expanded.substitutions = Object.fromEntries(Object.entries(node.substitutions).map(([name, substitution]) => [name, expandNode(substitution, locale, isObject(target?.substitutions) ? own(target.substitutions, name) : void 0, `${location}.substitutions.${name}`)]));
|
|
188
|
+
return expanded;
|
|
189
|
+
}
|
|
190
|
+
function substitutionBindings(node) {
|
|
191
|
+
return Object.fromEntries(Object.entries(isObject(node.substitutions) ? node.substitutions : {}).map(([name, substitution]) => {
|
|
192
|
+
const binding = substitution;
|
|
193
|
+
return [name, [binding.argNum ?? null, binding.formatSpecifier ?? null]];
|
|
194
|
+
}));
|
|
195
|
+
}
|
|
196
|
+
function visitUnits(node, keys, visit, inheritedBindings = {}) {
|
|
197
|
+
const bindings = {
|
|
198
|
+
...inheritedBindings,
|
|
199
|
+
...substitutionBindings(node)
|
|
200
|
+
};
|
|
201
|
+
if (isObject(node.stringUnit)) visit(node.stringUnit, [
|
|
202
|
+
...keys,
|
|
203
|
+
"stringUnit",
|
|
204
|
+
"value"
|
|
205
|
+
], bindings);
|
|
206
|
+
if (isObject(node.variations)) for (const [dimension, arms] of Object.entries(node.variations)) for (const [arm, child] of Object.entries(arms)) visitUnits(child, [
|
|
207
|
+
...keys,
|
|
208
|
+
"variations",
|
|
209
|
+
dimension,
|
|
210
|
+
arm
|
|
211
|
+
], visit, bindings);
|
|
212
|
+
if (isObject(node.substitutions)) for (const [name, substitution] of Object.entries(node.substitutions)) visitUnits(substitution, [
|
|
213
|
+
...keys,
|
|
214
|
+
"substitutions",
|
|
215
|
+
name
|
|
216
|
+
], visit, bindings);
|
|
217
|
+
}
|
|
218
|
+
function buildCatalogEntries(catalog, roots, source, plainTextKeys) {
|
|
219
|
+
const entries = [];
|
|
220
|
+
for (const [key, root] of Object.entries(roots)) {
|
|
221
|
+
const string = own(catalog.strings, key);
|
|
222
|
+
const format = plainTextKeys?.has(key) === true ? plainMessageFormat : applePrintfMessageFormat;
|
|
223
|
+
visitUnits(root, [key], (unit, keys, unitBindings) => {
|
|
224
|
+
const bindings = JSON.stringify(Object.fromEntries(Object.entries(unitBindings).toSorted(([left], [right]) => left.localeCompare(right))));
|
|
225
|
+
const address = keys.map((part) => ({
|
|
226
|
+
kind: "key",
|
|
227
|
+
key: part
|
|
228
|
+
}));
|
|
229
|
+
const notes = [`Apple string key: ${JSON.stringify(key)}`];
|
|
230
|
+
if (typeof string.comment === "string" && string.comment.length > 0) notes.push(string.comment);
|
|
231
|
+
if (bindings !== "{}") notes.push(`Apple substitution arguments: ${bindings}.`);
|
|
232
|
+
const groupKeys = [...keys];
|
|
233
|
+
let plural = false;
|
|
234
|
+
for (let index = 1; index < keys.length; index += 1) if (keys[index] === "variations") {
|
|
235
|
+
const dimension = keys[index + 1];
|
|
236
|
+
const arm = keys[index + 2];
|
|
237
|
+
notes.push(`${dimension === "plural" ? "Plural category" : "Device"}: ${arm ?? ""}.`);
|
|
238
|
+
if (dimension === "plural") {
|
|
239
|
+
groupKeys[index + 2] = "*";
|
|
240
|
+
plural = true;
|
|
241
|
+
}
|
|
242
|
+
index += 2;
|
|
243
|
+
} else if (keys[index] === "substitutions") {
|
|
244
|
+
notes.push(`Substitution: ${keys[index + 1] ?? ""}.`);
|
|
245
|
+
index += 1;
|
|
246
|
+
}
|
|
247
|
+
const value = source || unit.state === "translated" ? unit.value : null;
|
|
248
|
+
entries.push({
|
|
249
|
+
address,
|
|
250
|
+
context: { notes: notes.join("\n") },
|
|
251
|
+
messageFormatId: format.id,
|
|
252
|
+
meta: {
|
|
253
|
+
appleSubstitutionBindings: bindings,
|
|
254
|
+
...typeof string.comment === "string" ? { comment: string.comment } : {},
|
|
255
|
+
...bindings === "{}" ? {} : { structureSignature: bindings },
|
|
256
|
+
...plural ? { structureGroup: addressToJsonPointer(groupKeys.map((part) => ({
|
|
257
|
+
kind: "key",
|
|
258
|
+
key: part
|
|
259
|
+
}))) } : {}
|
|
260
|
+
},
|
|
261
|
+
policy: "translate",
|
|
262
|
+
storage: "string",
|
|
263
|
+
...value === null ? {} : { tokens: [...format.tokenize(value)] },
|
|
264
|
+
value
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
return entries;
|
|
269
|
+
}
|
|
270
|
+
function structureDigest(entries) {
|
|
271
|
+
return digestValue(JSON.stringify([...new Set(entries.map((entry) => entry.meta?.structureGroup ?? addressToJsonPointer(entry.address)))].toSorted((left, right) => String(left).localeCompare(String(right)))));
|
|
272
|
+
}
|
|
273
|
+
function entryValues(entries) {
|
|
274
|
+
return new Map(entries.flatMap((entry) => typeof entry.value === "string" ? [[addressToJsonPointer(entry.address), entry.value]] : []));
|
|
275
|
+
}
|
|
276
|
+
function pendingNode(node) {
|
|
277
|
+
const pending = structuredClone(node);
|
|
278
|
+
visitUnits(pending, [], (unit) => {
|
|
279
|
+
unit.state = "new";
|
|
280
|
+
});
|
|
281
|
+
return pending;
|
|
282
|
+
}
|
|
283
|
+
function assertUnchanged(current, original) {
|
|
284
|
+
if (JSON.stringify(current) !== JSON.stringify(original)) throw new Error("String Catalog translation structure changed before writing.");
|
|
285
|
+
}
|
|
286
|
+
/** Keep target metadata, but replace structural fields removed by the source. */
|
|
287
|
+
function alignNode(node, template, original = {}) {
|
|
288
|
+
assertSourceSubstitutions(template, node, "target localization");
|
|
289
|
+
for (const field of [
|
|
290
|
+
"stringUnit",
|
|
291
|
+
"variations",
|
|
292
|
+
"substitutions"
|
|
293
|
+
]) if (template[field] === void 0 && node[field] !== void 0) {
|
|
294
|
+
assertUnchanged(node[field], original[field]);
|
|
295
|
+
delete node[field];
|
|
296
|
+
}
|
|
297
|
+
if (template.argNum !== void 0) {
|
|
298
|
+
for (const field of ["argNum", "formatSpecifier"]) if (node[field] !== template[field]) assertUnchanged(node[field], original[field]);
|
|
299
|
+
node.argNum = template.argNum;
|
|
300
|
+
node.formatSpecifier = template.formatSpecifier;
|
|
301
|
+
}
|
|
302
|
+
for (const field of ["variations", "substitutions"]) {
|
|
303
|
+
if (!isObject(template[field]) || !isObject(node[field])) continue;
|
|
304
|
+
const target = node[field];
|
|
305
|
+
const source = template[field];
|
|
306
|
+
const previous = isObject(original[field]) ? original[field] : {};
|
|
307
|
+
for (const [name, child] of Object.entries(target)) {
|
|
308
|
+
const sourceChild = own(source, name);
|
|
309
|
+
if (!isObject(sourceChild)) {
|
|
310
|
+
assertUnchanged(child, own(previous, name));
|
|
311
|
+
delete target[name];
|
|
312
|
+
} else if (isObject(child)) {
|
|
313
|
+
const originalChild = own(previous, name);
|
|
314
|
+
const previousChild = isObject(originalChild) ? originalChild : {};
|
|
315
|
+
if (field === "substitutions") alignNode(child, sourceChild, previousChild);
|
|
316
|
+
else for (const [arm, armNode] of Object.entries(child)) {
|
|
317
|
+
const sourceArm = own(sourceChild, arm);
|
|
318
|
+
const originalArm = own(previousChild, arm);
|
|
319
|
+
if (isObject(armNode) && isObject(sourceArm)) alignNode(armNode, sourceArm, isObject(originalArm) ? originalArm : {});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/** Sets one leaf without copying untranslated siblings from the source shape. */
|
|
326
|
+
function setUnit(root, template, keys, value, state) {
|
|
327
|
+
if (keys.length < 3 || keys.at(-2) !== "stringUnit" || keys.at(-1) !== "value") throw new Error(`Invalid String Catalog entry address ${JSON.stringify(keys)}.`);
|
|
328
|
+
let node = root;
|
|
329
|
+
let source = template;
|
|
330
|
+
for (const key of keys.slice(0, -2)) {
|
|
331
|
+
const sourceChild = own(source, key);
|
|
332
|
+
if (!isObject(sourceChild)) throw new Error(`Cannot write unknown String Catalog entry ${JSON.stringify(keys)}.`);
|
|
333
|
+
if (!isObject(own(node, key))) put(node, key, sourceChild.stringUnit !== void 0 || sourceChild.variations !== void 0 ? pendingNode(sourceChild) : {});
|
|
334
|
+
node = own(node, key);
|
|
335
|
+
source = sourceChild;
|
|
336
|
+
}
|
|
337
|
+
const sourceUnit = source.stringUnit;
|
|
338
|
+
if (!isObject(sourceUnit)) throw new Error(`Cannot write unknown String Catalog stringUnit ${JSON.stringify(keys)}.`);
|
|
339
|
+
node.stringUnit = {
|
|
340
|
+
...sourceUnit,
|
|
341
|
+
...isObject(node.stringUnit) ? node.stringUnit : {},
|
|
342
|
+
state,
|
|
343
|
+
value
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
//#endregion
|
|
347
|
+
//#region src/xcstrings.ts
|
|
348
|
+
const IGNORE = [
|
|
349
|
+
"**/node_modules/**",
|
|
350
|
+
"**/.git/**",
|
|
351
|
+
"**/.build/**",
|
|
352
|
+
"**/build/**",
|
|
353
|
+
"**/DerivedData/**",
|
|
354
|
+
"**/Pods/**",
|
|
355
|
+
"**/Carthage/**",
|
|
356
|
+
"**/release/**",
|
|
357
|
+
"**/dist/**",
|
|
358
|
+
"**/.expo/**",
|
|
359
|
+
"**/.next/**",
|
|
360
|
+
"**/.turbo/**",
|
|
361
|
+
"**/coverage/**",
|
|
362
|
+
"**/*.xcarchive/**"
|
|
363
|
+
];
|
|
364
|
+
function loadedDocument(ref, state, source, plainTextKeys) {
|
|
365
|
+
const entries = buildCatalogEntries(state.catalog, state.roots, source, plainTextKeys);
|
|
366
|
+
return {
|
|
367
|
+
entries,
|
|
368
|
+
ref,
|
|
369
|
+
state,
|
|
370
|
+
structureDigest: structureDigest(entries)
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
function localizedRoots(sourceRoots, locale, targetRoots = {}) {
|
|
374
|
+
return Object.fromEntries(Object.entries(sourceRoots).map(([key, node]) => [key, expandNode(node, locale, own(targetRoots, key), `key ${JSON.stringify(key)}`)]));
|
|
375
|
+
}
|
|
376
|
+
function unitAt(roots, keys) {
|
|
377
|
+
let node = roots;
|
|
378
|
+
for (const key of keys.slice(0, -1)) node = isObject(node) ? own(node, key) : void 0;
|
|
379
|
+
return node;
|
|
380
|
+
}
|
|
381
|
+
/** Native Xcode catalogs: one physical file, independently reconciled locales. */
|
|
382
|
+
function createAppleStringCatalog(options) {
|
|
383
|
+
const rootDir = path$1.resolve(options.rootDir);
|
|
384
|
+
const id = options.id ?? "apple-xcstrings";
|
|
385
|
+
const plainTextKeys = new Set(options.plainTextKeys);
|
|
386
|
+
function refFor(filePath, locale, unitId) {
|
|
387
|
+
return {
|
|
388
|
+
catalogId: id,
|
|
389
|
+
format: "xcstrings",
|
|
390
|
+
locale,
|
|
391
|
+
path: filePath,
|
|
392
|
+
unitId: unitId ?? path$1.relative(rootDir, filePath).split(path$1.sep).join("/").replace(/\.xcstrings$/u, "")
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
async function readCatalog(filePath) {
|
|
396
|
+
const text = await readText(filePath);
|
|
397
|
+
return text === null ? null : parseCatalog(text, filePath, options.sourceLocale);
|
|
398
|
+
}
|
|
399
|
+
const adapter = {
|
|
400
|
+
id,
|
|
401
|
+
messageFormats: [applePrintfMessageFormat],
|
|
402
|
+
createDocumentRef(sourceRef, locale) {
|
|
403
|
+
return refFor(sourceRef.path, locale, sourceRef.unitId);
|
|
404
|
+
},
|
|
405
|
+
async listDocumentRefs(locale) {
|
|
406
|
+
return (await globby([...options.include ?? ["**/*.xcstrings"]], {
|
|
407
|
+
absolute: true,
|
|
408
|
+
cwd: rootDir,
|
|
409
|
+
followSymbolicLinks: false,
|
|
410
|
+
ignore: IGNORE,
|
|
411
|
+
onlyFiles: true
|
|
412
|
+
})).toSorted().map((filePath) => refFor(filePath, locale));
|
|
413
|
+
},
|
|
414
|
+
async loadDocument(ref) {
|
|
415
|
+
const catalog = await readCatalog(ref.path);
|
|
416
|
+
if (catalog === null) return null;
|
|
417
|
+
const canonicalRoots = localeRoots(catalog, options.sourceLocale);
|
|
418
|
+
const sourceRoots = ref.locale === options.sourceLocale ? canonicalRoots : localizedRoots(canonicalRoots, ref.locale, localeRoots(catalog, ref.locale));
|
|
419
|
+
const roots = ref.locale === options.sourceLocale ? sourceRoots : localeRoots(catalog, ref.locale);
|
|
420
|
+
if (ref.locale !== options.sourceLocale && Object.keys(roots).length === 0 && Object.keys(canonicalRoots).length > 0) return null;
|
|
421
|
+
return loadedDocument(ref, {
|
|
422
|
+
catalog,
|
|
423
|
+
originalValues: entryValues(buildCatalogEntries(catalog, roots, ref.locale === options.sourceLocale, plainTextKeys)),
|
|
424
|
+
roots,
|
|
425
|
+
sourceRoots
|
|
426
|
+
}, ref.locale === options.sourceLocale, plainTextKeys);
|
|
427
|
+
},
|
|
428
|
+
localizeSourceDocument({ locale, source }) {
|
|
429
|
+
const sourceState = source.state;
|
|
430
|
+
const roots = localizedRoots(sourceState.sourceRoots, locale, localeRoots(sourceState.catalog, locale));
|
|
431
|
+
return Promise.resolve(loadedDocument(source.ref, {
|
|
432
|
+
...sourceState,
|
|
433
|
+
roots,
|
|
434
|
+
sourceRoots: roots
|
|
435
|
+
}, true, plainTextKeys));
|
|
436
|
+
},
|
|
437
|
+
reconcileDocument({ ref, source, target }) {
|
|
438
|
+
const sourceState = source.state;
|
|
439
|
+
const targetState = target?.state;
|
|
440
|
+
const targetEntries = new Map(target?.entries.map((entry) => [addressToJsonPointer(entry.address), entry]));
|
|
441
|
+
const values = entryValues(source.entries.flatMap((entry) => {
|
|
442
|
+
const existing = targetEntries.get(addressToJsonPointer(entry.address));
|
|
443
|
+
return existing?.meta?.structureSignature === entry.meta?.structureSignature && existing !== void 0 ? [existing] : [];
|
|
444
|
+
}));
|
|
445
|
+
const entries = source.entries.map((entry) => ({
|
|
446
|
+
...entry,
|
|
447
|
+
value: values.get(addressToJsonPointer(entry.address)) ?? null
|
|
448
|
+
}));
|
|
449
|
+
return Promise.resolve({
|
|
450
|
+
entries,
|
|
451
|
+
ref,
|
|
452
|
+
state: {
|
|
453
|
+
catalog: sourceState.catalog,
|
|
454
|
+
originalValues: values,
|
|
455
|
+
roots: targetState?.roots ?? {},
|
|
456
|
+
sourceRoots: sourceState.sourceRoots
|
|
457
|
+
},
|
|
458
|
+
structureDigest: structureDigest(entries)
|
|
459
|
+
});
|
|
460
|
+
},
|
|
461
|
+
createScaffoldDocument({ ref, source }) {
|
|
462
|
+
if (source.entries.length === 0) return Promise.resolve(null);
|
|
463
|
+
const sourceState = source.state;
|
|
464
|
+
const roots = localizedRoots(sourceState.roots, ref.locale);
|
|
465
|
+
return Promise.resolve(loadedDocument(ref, {
|
|
466
|
+
catalog: sourceState.catalog,
|
|
467
|
+
originalValues: /* @__PURE__ */ new Map(),
|
|
468
|
+
roots,
|
|
469
|
+
sourceRoots: roots,
|
|
470
|
+
writeState: "new"
|
|
471
|
+
}, true, plainTextKeys));
|
|
472
|
+
},
|
|
473
|
+
async scaffoldLocale(scaffoldOptions) {
|
|
474
|
+
const strategy = scaffoldOptions.strategy ?? "copy-source";
|
|
475
|
+
const refs = await adapter.listDocumentRefs(options.sourceLocale);
|
|
476
|
+
let createdDocuments = 0;
|
|
477
|
+
let skippedDocuments = 0;
|
|
478
|
+
const fromLocale = strategy === "copy-source" ? options.sourceLocale : scaffoldOptions.fromLocale ?? options.sourceLocale;
|
|
479
|
+
for (const sourceRef of refs) {
|
|
480
|
+
const targetRef = adapter.createDocumentRef(sourceRef, scaffoldOptions.locale);
|
|
481
|
+
if (strategy === "empty" || await adapter.loadDocument(targetRef) !== null) {
|
|
482
|
+
skippedDocuments += 1;
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
const source = await adapter.loadDocument(adapter.createDocumentRef(sourceRef, fromLocale));
|
|
486
|
+
if (source === null || source.entries.length === 0) {
|
|
487
|
+
skippedDocuments += 1;
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
const document = await adapter.createScaffoldDocument?.({
|
|
491
|
+
ref: targetRef,
|
|
492
|
+
source,
|
|
493
|
+
strategy
|
|
494
|
+
});
|
|
495
|
+
if (document !== void 0 && document !== null) {
|
|
496
|
+
await adapter.writeDocument(document);
|
|
497
|
+
createdDocuments += 1;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return {
|
|
501
|
+
catalogId: id,
|
|
502
|
+
createdDocuments,
|
|
503
|
+
locale: scaffoldOptions.locale,
|
|
504
|
+
skippedDocuments,
|
|
505
|
+
strategy
|
|
506
|
+
};
|
|
507
|
+
},
|
|
508
|
+
async writeDocument(document) {
|
|
509
|
+
if (document.ref.locale === options.sourceLocale) throw new Error(`Refusing to write the source locale "${options.sourceLocale}" in a String Catalog.`);
|
|
510
|
+
const state = document.state;
|
|
511
|
+
const changed = document.entries.filter((entry) => typeof entry.value === "string" && (state.writeState !== void 0 || entry.value !== state.originalValues.get(addressToJsonPointer(entry.address))));
|
|
512
|
+
if (changed.length === 0) return;
|
|
513
|
+
await withFileLock(document.ref.path, async () => {
|
|
514
|
+
const text = await readText(document.ref.path);
|
|
515
|
+
if (text === null) throw new Error(`String Catalog disappeared before writing: ${document.ref.path}.`);
|
|
516
|
+
const catalog = parseCatalog(text, document.ref.path, options.sourceLocale);
|
|
517
|
+
const targetRoots = localeRoots(catalog, document.ref.locale);
|
|
518
|
+
const originalTargets = structuredClone(targetRoots);
|
|
519
|
+
const existingKeys = new Set(Object.keys(targetRoots));
|
|
520
|
+
const originalSources = localeRoots(state.catalog, options.sourceLocale);
|
|
521
|
+
const currentSources = localeRoots(catalog, options.sourceLocale);
|
|
522
|
+
const aligned = /* @__PURE__ */ new Set();
|
|
523
|
+
let wrote = false;
|
|
524
|
+
for (const entry of changed) {
|
|
525
|
+
const keys = entry.address.map((segment) => {
|
|
526
|
+
if (segment.kind !== "key") throw new Error("String Catalog addresses must contain only object keys.");
|
|
527
|
+
return segment.key;
|
|
528
|
+
});
|
|
529
|
+
const key = keys[0];
|
|
530
|
+
const string = key === void 0 ? void 0 : own(catalog.strings, key);
|
|
531
|
+
if (key === void 0 || !isObject(string) || !isTranslatable(key, string)) throw new Error(`String Catalog source key changed before writing ${JSON.stringify(key)}.`);
|
|
532
|
+
if (state.writeState !== void 0 && existingKeys.has(key)) continue;
|
|
533
|
+
if (JSON.stringify(own(originalSources, key)) !== JSON.stringify(own(currentSources, key))) throw new Error(`String Catalog source changed before writing ${JSON.stringify(key)}.`);
|
|
534
|
+
if (!aligned.has(key)) {
|
|
535
|
+
const node = own(targetRoots, key);
|
|
536
|
+
const template = own(state.sourceRoots, key);
|
|
537
|
+
if (isObject(node) && isObject(template)) {
|
|
538
|
+
const original = own(state.roots, key);
|
|
539
|
+
alignNode(node, template, isObject(original) ? original : {});
|
|
540
|
+
}
|
|
541
|
+
aligned.add(key);
|
|
542
|
+
}
|
|
543
|
+
const currentUnit = unitAt(originalTargets, keys);
|
|
544
|
+
if (state.writeState === void 0 && JSON.stringify(currentUnit) !== JSON.stringify(unitAt(state.roots, keys)) && (!isObject(currentUnit) || currentUnit.value !== entry.value)) throw new Error(`String Catalog translation changed before writing ${JSON.stringify(keys)}.`);
|
|
545
|
+
setUnit(targetRoots, state.sourceRoots, keys, entry.value, state.writeState ?? "translated");
|
|
546
|
+
wrote = true;
|
|
547
|
+
}
|
|
548
|
+
if (!wrote) return;
|
|
549
|
+
for (const [key, target] of Object.entries(targetRoots)) {
|
|
550
|
+
const string = own(catalog.strings, key);
|
|
551
|
+
if (!isObject(string.localizations)) string.localizations = {};
|
|
552
|
+
put(string.localizations, document.ref.locale, target);
|
|
553
|
+
}
|
|
554
|
+
const next = `${text.startsWith("") ? "" : ""}${JSON.stringify(catalog, null, 2)}\n`;
|
|
555
|
+
if (next !== text) await writeTextAtomic(document.ref.path, next);
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
return adapter;
|
|
560
|
+
}
|
|
561
|
+
//#endregion
|
|
562
|
+
//#region src/strings-parser.ts
|
|
563
|
+
const ESCAPES = {
|
|
564
|
+
a: "\x07",
|
|
565
|
+
b: "\b",
|
|
566
|
+
f: "\f",
|
|
567
|
+
n: "\n",
|
|
568
|
+
r: "\r",
|
|
569
|
+
t: " ",
|
|
570
|
+
v: "\v",
|
|
571
|
+
"\"": "\"",
|
|
572
|
+
"'": "'",
|
|
573
|
+
"\\": "\\",
|
|
574
|
+
"?": "?"
|
|
575
|
+
};
|
|
576
|
+
const NEXTSTEP_HIGH = "\xA0ÀÁÂÃÄÅÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖÙÚÛÜÝÞµ×÷©¡¢£⁄¥ƒ§¤’“«‹›fifl®–†‡·¦¶•‚„”»…‰¬¿¹ˋ´ˆ˜¯˘˙¨²˚¸³˝˛ˇ—±¼½¾àáâãäåçèéêëìÆíªîïðñŁØŒºòóôõöæùúûıüýłøœßþÿ";
|
|
577
|
+
/** Parse Apple's textual property-list syntax while retaining source spans. */
|
|
578
|
+
function parseStrings(text, label = ".strings") {
|
|
579
|
+
const records = [];
|
|
580
|
+
const keys = /* @__PURE__ */ new Set();
|
|
581
|
+
let position = 0;
|
|
582
|
+
function fail(message) {
|
|
583
|
+
const line = text.slice(0, position).split(/\r\n|[\r\n]/u).length;
|
|
584
|
+
throw new Error(`${label}:${String(line)}: ${message}`);
|
|
585
|
+
}
|
|
586
|
+
function trivia() {
|
|
587
|
+
const comments = [];
|
|
588
|
+
while (position < text.length) if (/[ \t\r\n\f\v\u2028\u2029]/u.test(text[position] ?? "")) position += 1;
|
|
589
|
+
else if (text.startsWith("//", position)) {
|
|
590
|
+
const end = /[\r\n\u2028\u2029]/u.exec(text.slice(position + 2));
|
|
591
|
+
const next = end === null ? text.length : position + 2 + end.index;
|
|
592
|
+
comments.push(text.slice(position + 2, next).trim());
|
|
593
|
+
position = next;
|
|
594
|
+
} else if (text.startsWith("/*", position)) {
|
|
595
|
+
const end = text.indexOf("*/", position + 2);
|
|
596
|
+
if (end < 0) fail("Unterminated comment.");
|
|
597
|
+
comments.push(text.slice(position + 2, end).trim());
|
|
598
|
+
position = end + 2;
|
|
599
|
+
} else break;
|
|
600
|
+
return comments.filter(Boolean).join("\n");
|
|
601
|
+
}
|
|
602
|
+
function string() {
|
|
603
|
+
const quote = text[position];
|
|
604
|
+
if (quote !== "\"" && quote !== "'") {
|
|
605
|
+
const bare = /^[A-Za-z0-9_.$/:-]+/u.exec(text.slice(position));
|
|
606
|
+
if (bare === null) fail("Expected a quoted string or property-list identifier.");
|
|
607
|
+
position += bare[0].length;
|
|
608
|
+
return bare[0];
|
|
609
|
+
}
|
|
610
|
+
position += 1;
|
|
611
|
+
let value = "";
|
|
612
|
+
while (position < text.length) {
|
|
613
|
+
const character = text[position++];
|
|
614
|
+
if (character === quote) return value;
|
|
615
|
+
if (character !== "\\") {
|
|
616
|
+
value += character;
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
const escaped = text[position++];
|
|
620
|
+
if (escaped === void 0) fail("Unterminated escape sequence.");
|
|
621
|
+
if (escaped === "U") {
|
|
622
|
+
const hexadecimal = /^[\da-fA-F]{1,4}/u.exec(text.slice(position))?.[0];
|
|
623
|
+
if (hexadecimal === void 0) fail("Expected hexadecimal digits after a Unicode escape.");
|
|
624
|
+
value += String.fromCharCode(Number.parseInt(hexadecimal, 16));
|
|
625
|
+
position += hexadecimal.length;
|
|
626
|
+
} else if (/[0-7]/u.test(escaped)) {
|
|
627
|
+
const suffix = /^[0-7]{0,2}/u.exec(text.slice(position))?.[0] ?? "";
|
|
628
|
+
const byte = Number.parseInt(escaped + suffix, 8) % 256;
|
|
629
|
+
if (byte >= 254) fail("Undefined NEXTSTEP octal escape.");
|
|
630
|
+
value += byte < 128 ? String.fromCharCode(byte) : NEXTSTEP_HIGH[byte - 128];
|
|
631
|
+
position += suffix.length;
|
|
632
|
+
} else value += ESCAPES[escaped] ?? escaped;
|
|
633
|
+
}
|
|
634
|
+
return fail("Unterminated quoted string.");
|
|
635
|
+
}
|
|
636
|
+
const heading = trivia();
|
|
637
|
+
const wrapped = text[position] === "{";
|
|
638
|
+
if (wrapped) position += 1;
|
|
639
|
+
else position = 0;
|
|
640
|
+
let insertionPoint;
|
|
641
|
+
while (position < text.length) {
|
|
642
|
+
const start = position;
|
|
643
|
+
const comment = [wrapped && records.length === 0 ? heading : "", trivia()].filter(Boolean).join("\n");
|
|
644
|
+
if (wrapped && text[position] === "}") {
|
|
645
|
+
insertionPoint = start;
|
|
646
|
+
position += 1;
|
|
647
|
+
trivia();
|
|
648
|
+
if (position !== text.length) fail("Unexpected content after the closing dictionary brace.");
|
|
649
|
+
break;
|
|
650
|
+
}
|
|
651
|
+
if (position === text.length) break;
|
|
652
|
+
const key = string();
|
|
653
|
+
if (keys.has(key)) fail(`Duplicate key ${JSON.stringify(key)}.`);
|
|
654
|
+
keys.add(key);
|
|
655
|
+
const keyEnd = position;
|
|
656
|
+
trivia();
|
|
657
|
+
const implicitValue = text[position] === ";";
|
|
658
|
+
let valueStart = keyEnd;
|
|
659
|
+
let valueEnd = keyEnd;
|
|
660
|
+
let value = key;
|
|
661
|
+
if (!implicitValue) {
|
|
662
|
+
if (text[position++] !== "=") fail("Expected '=' or ';' after key.");
|
|
663
|
+
trivia();
|
|
664
|
+
valueStart = position;
|
|
665
|
+
value = string();
|
|
666
|
+
valueEnd = position;
|
|
667
|
+
trivia();
|
|
668
|
+
}
|
|
669
|
+
if (text[position++] !== ";") fail("Expected ';' after value.");
|
|
670
|
+
records.push({
|
|
671
|
+
comment,
|
|
672
|
+
end: position,
|
|
673
|
+
...implicitValue ? { implicitValue: true } : {},
|
|
674
|
+
key,
|
|
675
|
+
start,
|
|
676
|
+
value,
|
|
677
|
+
valueEnd,
|
|
678
|
+
valueStart
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
if (wrapped && insertionPoint === void 0) fail("Expected '}' after dictionary entries.");
|
|
682
|
+
return {
|
|
683
|
+
...insertionPoint === void 0 ? {} : { insertionPoint },
|
|
684
|
+
records,
|
|
685
|
+
text
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
function quoteStrings(value) {
|
|
689
|
+
return `"${value.replace(/["\\\x00-\x1f\x7f]/gu, (character) => {
|
|
690
|
+
if (character === "\"" || character === "\\") return `\\${character}`;
|
|
691
|
+
const escape = {
|
|
692
|
+
"\n": "n",
|
|
693
|
+
"\r": "r",
|
|
694
|
+
" ": "t"
|
|
695
|
+
}[character];
|
|
696
|
+
return escape === void 0 ? `\\U${character.charCodeAt(0).toString(16).padStart(4, "0")}` : `\\${escape}`;
|
|
697
|
+
})}"`;
|
|
698
|
+
}
|
|
699
|
+
/** Replace only value spans, preserving comments, whitespace, and unknown keys. */
|
|
700
|
+
function renderStrings(table, values, templates) {
|
|
701
|
+
const insertionPoint = table.insertionPoint ?? table.text.length;
|
|
702
|
+
let text = table.text.slice(0, insertionPoint);
|
|
703
|
+
const suffix = table.text.slice(insertionPoint);
|
|
704
|
+
const replacement = (record, value) => record.implicitValue === true ? value === record.value ? "" : ` = ${quoteStrings(value)}` : quoteStrings(value);
|
|
705
|
+
const known = new Set(table.records.map((record) => record.key));
|
|
706
|
+
for (const record of table.records.toReversed()) {
|
|
707
|
+
const value = values.get(record.key);
|
|
708
|
+
if (value !== void 0 && value !== record.value) text = text.slice(0, record.valueStart) + replacement(record, value) + text.slice(record.valueEnd);
|
|
709
|
+
}
|
|
710
|
+
const source = new Map(templates.records.map((record) => [record.key, record]));
|
|
711
|
+
for (const [key, value] of values) {
|
|
712
|
+
if (known.has(key)) continue;
|
|
713
|
+
const record = source.get(key);
|
|
714
|
+
const addition = record === void 0 ? `${quoteStrings(key)} = ${quoteStrings(value)};` : templates.text.slice(record.start, record.valueStart) + replacement(record, value) + templates.text.slice(record.valueEnd, record.end);
|
|
715
|
+
text += `${text.length > 0 && !text.endsWith("\n") ? "\n" : ""}${addition.startsWith("\n") || text.length === 0 ? "" : "\n"}${addition}\n`;
|
|
716
|
+
}
|
|
717
|
+
return text + suffix;
|
|
718
|
+
}
|
|
719
|
+
function decodeStrings(buffer) {
|
|
720
|
+
const encoding = buffer[0] === 255 && buffer[1] === 254 ? "utf16le" : buffer[0] === 254 && buffer[1] === 255 ? "utf16be" : buffer[0] === 239 && buffer[1] === 187 && buffer[2] === 191 ? "utf8-bom" : "utf8";
|
|
721
|
+
return {
|
|
722
|
+
encoding,
|
|
723
|
+
text: new TextDecoder(encoding === "utf16le" ? "utf-16le" : encoding === "utf16be" ? "utf-16be" : "utf-8", { fatal: true }).decode(buffer)
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
function encodeStrings(text, encoding) {
|
|
727
|
+
if (encoding === "utf8") return Buffer.from(text, "utf8");
|
|
728
|
+
if (encoding === "utf8-bom") return Buffer.concat([Buffer.from([
|
|
729
|
+
239,
|
|
730
|
+
187,
|
|
731
|
+
191
|
|
732
|
+
]), Buffer.from(text, "utf8")]);
|
|
733
|
+
const body = Buffer.from(text, "utf16le");
|
|
734
|
+
if (encoding === "utf16be") body.swap16();
|
|
735
|
+
return Buffer.concat([Buffer.from(encoding === "utf16le" ? [255, 254] : [254, 255]), body]);
|
|
736
|
+
}
|
|
737
|
+
//#endregion
|
|
738
|
+
//#region src/strings.ts
|
|
739
|
+
function segment(value, name) {
|
|
740
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/u.test(value) || value === "." || value === "..") throw new Error(`Invalid ${name}: ${JSON.stringify(value)}.`);
|
|
741
|
+
return value;
|
|
742
|
+
}
|
|
743
|
+
function unit(value) {
|
|
744
|
+
if (path.isAbsolute(value) || value.includes("\\") || value.split("/").some((part) => part === ".." || part === "." || part === "") || !value.endsWith(".strings")) throw new Error(`Invalid Apple strings unit: ${JSON.stringify(value)}.`);
|
|
745
|
+
return value;
|
|
746
|
+
}
|
|
747
|
+
async function readTable(filePath) {
|
|
748
|
+
try {
|
|
749
|
+
const decoded = decodeStrings(await promises.readFile(filePath));
|
|
750
|
+
return {
|
|
751
|
+
...parseStrings(decoded.text, filePath),
|
|
752
|
+
encoding: decoded.encoding
|
|
753
|
+
};
|
|
754
|
+
} catch (error) {
|
|
755
|
+
if (error.code === "ENOENT") return null;
|
|
756
|
+
throw error;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
function entry(record, value, plainTextKeys) {
|
|
760
|
+
const format = plainTextKeys.has(record.key) ? plainMessageFormat : applePrintfMessageFormat;
|
|
761
|
+
return {
|
|
762
|
+
address: [{
|
|
763
|
+
kind: "key",
|
|
764
|
+
key: record.key
|
|
765
|
+
}],
|
|
766
|
+
...record.comment.length === 0 ? {} : { context: { notes: record.comment } },
|
|
767
|
+
messageFormatId: format.id,
|
|
768
|
+
policy: "translate",
|
|
769
|
+
storage: "string",
|
|
770
|
+
tokens: value === null ? [] : [...format.tokenize(value)],
|
|
771
|
+
value
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
function document(ref, state, entries) {
|
|
775
|
+
return {
|
|
776
|
+
entries,
|
|
777
|
+
ref,
|
|
778
|
+
state,
|
|
779
|
+
structureDigest: digestValue(JSON.stringify(entries.map((item) => item.address)))
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
/** Translate .lproj tables without replacing comments or unrelated translations. */
|
|
783
|
+
function createAppleStringsCatalog(options) {
|
|
784
|
+
const id = options.id ?? "apple-strings";
|
|
785
|
+
const root = path.resolve(options.rootDir);
|
|
786
|
+
const plainTextKeys = new Set(options.plainTextKeys);
|
|
787
|
+
segment(options.sourceLocale, "source locale");
|
|
788
|
+
const sourceDirectory = segment(options.sourceLocaleDirectory ?? `${options.sourceLocale}.lproj`, "source locale directory");
|
|
789
|
+
const includes = typeof options.include === "string" ? [options.include] : [...options.include ?? ["**/*.strings"]];
|
|
790
|
+
for (const include of includes) {
|
|
791
|
+
const literalEscapesRemoved = include.replace(/\\[!()[\]{}*?+@]/gu, "_");
|
|
792
|
+
if (path.isAbsolute(include) || literalEscapesRemoved.includes("\\") || include.split("/").includes("..")) throw new Error(`Invalid Apple strings include: ${JSON.stringify(include)}.`);
|
|
793
|
+
}
|
|
794
|
+
function file(locale, unitId) {
|
|
795
|
+
const directory = locale === options.sourceLocale ? sourceDirectory : `${segment(locale, "locale")}.lproj`;
|
|
796
|
+
return path.join(root, directory, unit(unitId));
|
|
797
|
+
}
|
|
798
|
+
async function checkWithinRoot(filePath) {
|
|
799
|
+
const relative = path.relative(root, path.resolve(filePath));
|
|
800
|
+
if (relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return;
|
|
801
|
+
const realRoot = await promises.realpath(root);
|
|
802
|
+
let existing = filePath;
|
|
803
|
+
for (;;) try {
|
|
804
|
+
const actual = await promises.realpath(existing);
|
|
805
|
+
const remainder = path.relative(realRoot, actual);
|
|
806
|
+
if (remainder === ".." || remainder.startsWith(`..${path.sep}`) || path.isAbsolute(remainder)) throw new Error(`Apple strings path escapes rootDir: ${filePath}.`);
|
|
807
|
+
return;
|
|
808
|
+
} catch (error) {
|
|
809
|
+
if (error.code !== "ENOENT") throw error;
|
|
810
|
+
const parent = path.dirname(existing);
|
|
811
|
+
if (parent === existing) throw error;
|
|
812
|
+
existing = parent;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
const adapter = {
|
|
816
|
+
id,
|
|
817
|
+
messageFormats: [applePrintfMessageFormat],
|
|
818
|
+
createDocumentRef(sourceRef, locale) {
|
|
819
|
+
return {
|
|
820
|
+
catalogId: id,
|
|
821
|
+
format: "apple-strings",
|
|
822
|
+
locale,
|
|
823
|
+
path: file(locale, sourceRef.unitId),
|
|
824
|
+
unitId: sourceRef.unitId
|
|
825
|
+
};
|
|
826
|
+
},
|
|
827
|
+
createScaffoldDocument({ ref, source }) {
|
|
828
|
+
return Promise.resolve({
|
|
829
|
+
...source,
|
|
830
|
+
ref,
|
|
831
|
+
state: {
|
|
832
|
+
...source.state,
|
|
833
|
+
scaffold: true
|
|
834
|
+
}
|
|
835
|
+
});
|
|
836
|
+
},
|
|
837
|
+
async listDocumentRefs(locale) {
|
|
838
|
+
const directory = locale === options.sourceLocale ? sourceDirectory : `${segment(locale, "locale")}.lproj`;
|
|
839
|
+
const files = await globby(includes, {
|
|
840
|
+
cwd: path.join(root, directory),
|
|
841
|
+
followSymbolicLinks: false,
|
|
842
|
+
onlyFiles: true
|
|
843
|
+
});
|
|
844
|
+
const refs = [];
|
|
845
|
+
for (const unitId of files.toSorted()) {
|
|
846
|
+
const filePath = file(locale, unitId);
|
|
847
|
+
await checkWithinRoot(filePath);
|
|
848
|
+
refs.push({
|
|
849
|
+
catalogId: id,
|
|
850
|
+
format: "apple-strings",
|
|
851
|
+
locale,
|
|
852
|
+
path: filePath,
|
|
853
|
+
unitId
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
return refs;
|
|
857
|
+
},
|
|
858
|
+
async loadDocument(ref) {
|
|
859
|
+
unit(ref.unitId);
|
|
860
|
+
await checkWithinRoot(ref.path);
|
|
861
|
+
const table = await readTable(ref.path);
|
|
862
|
+
if (table === null) return null;
|
|
863
|
+
const sourcePath = file(options.sourceLocale, ref.unitId);
|
|
864
|
+
await checkWithinRoot(sourcePath);
|
|
865
|
+
const templates = ref.locale === options.sourceLocale ? table : await readTable(sourcePath) ?? table;
|
|
866
|
+
const sourceRecords = new Map(templates.records.map((record) => [record.key, record]));
|
|
867
|
+
const entries = table.records.flatMap((record) => {
|
|
868
|
+
const source = sourceRecords.get(record.key);
|
|
869
|
+
return source === void 0 ? [] : [entry(source, record.value, plainTextKeys)];
|
|
870
|
+
});
|
|
871
|
+
return document(ref, {
|
|
872
|
+
encoding: table.encoding,
|
|
873
|
+
table,
|
|
874
|
+
templates
|
|
875
|
+
}, entries);
|
|
876
|
+
},
|
|
877
|
+
reconcileDocument({ ref, source, target }) {
|
|
878
|
+
const sourceState = source.state;
|
|
879
|
+
const targetState = target?.state;
|
|
880
|
+
const values = new Map(target?.entries.map((item) => [item.address[0]?.kind === "key" ? item.address[0].key : "", item.value]));
|
|
881
|
+
const entries = sourceState.table.records.map((record) => entry(record, typeof values.get(record.key) === "string" ? values.get(record.key) : null, plainTextKeys));
|
|
882
|
+
return Promise.resolve(document(ref, {
|
|
883
|
+
encoding: targetState?.encoding ?? sourceState.encoding,
|
|
884
|
+
table: targetState?.table ?? {
|
|
885
|
+
records: [],
|
|
886
|
+
text: ""
|
|
887
|
+
},
|
|
888
|
+
templates: sourceState.table
|
|
889
|
+
}, entries));
|
|
890
|
+
},
|
|
891
|
+
async scaffoldLocale(scaffold) {
|
|
892
|
+
const strategy = scaffold.strategy ?? "copy-source";
|
|
893
|
+
const from = strategy === "copy-source" ? options.sourceLocale : scaffold.fromLocale ?? options.sourceLocale;
|
|
894
|
+
const refs = await adapter.listDocumentRefs(from);
|
|
895
|
+
let createdDocuments = 0;
|
|
896
|
+
let skippedDocuments = 0;
|
|
897
|
+
for (const ref of refs) {
|
|
898
|
+
const target = adapter.createDocumentRef(ref, scaffold.locale);
|
|
899
|
+
if (strategy === "empty") {
|
|
900
|
+
skippedDocuments += 1;
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
await checkWithinRoot(target.path);
|
|
904
|
+
await withFileLock(target.path, async () => {
|
|
905
|
+
if (await readTable(target.path) !== null) {
|
|
906
|
+
skippedDocuments += 1;
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
const contents = await promises.readFile(ref.path);
|
|
910
|
+
await writeTextAtomic(target.path, contents);
|
|
911
|
+
createdDocuments += 1;
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
return {
|
|
915
|
+
catalogId: id,
|
|
916
|
+
createdDocuments,
|
|
917
|
+
locale: scaffold.locale,
|
|
918
|
+
skippedDocuments,
|
|
919
|
+
strategy
|
|
920
|
+
};
|
|
921
|
+
},
|
|
922
|
+
async writeDocument(next) {
|
|
923
|
+
await checkWithinRoot(next.ref.path);
|
|
924
|
+
const state = next.state;
|
|
925
|
+
const originalValues = new Map(state.table.records.map(({ key, value }) => [key, value]));
|
|
926
|
+
const values = /* @__PURE__ */ new Map();
|
|
927
|
+
for (const item of next.entries) {
|
|
928
|
+
const address = item.address[0];
|
|
929
|
+
if (item.address.length !== 1 || address?.kind !== "key") throw new Error("Invalid Apple strings entry address.");
|
|
930
|
+
if (typeof item.value === "string" && (state.scaffold === true || item.value !== originalValues.get(address.key))) values.set(address.key, item.value);
|
|
931
|
+
}
|
|
932
|
+
if (values.size === 0 && state.scaffold !== true) return;
|
|
933
|
+
await withFileLock(next.ref.path, async () => {
|
|
934
|
+
const current = await readTable(next.ref.path);
|
|
935
|
+
if (state.scaffold === true && current !== null) return;
|
|
936
|
+
const currentValues = new Map(current?.records.map(({ key, value }) => [key, value]));
|
|
937
|
+
for (const [key, value] of values) {
|
|
938
|
+
const currentValue = currentValues.get(key);
|
|
939
|
+
if (state.scaffold !== true && currentValue !== originalValues.get(key) && currentValue !== value) throw new Error(`Apple strings translation changed before writing ${JSON.stringify(key)}.`);
|
|
940
|
+
}
|
|
941
|
+
const text = renderStrings(current ?? state.table, values, state.templates);
|
|
942
|
+
await writeTextAtomic(next.ref.path, encodeStrings(text, current?.encoding ?? state.encoding));
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
return adapter;
|
|
947
|
+
}
|
|
948
|
+
//#endregion
|
|
949
|
+
//#region src/integration.ts
|
|
950
|
+
const APPLE_INTEGRATION_ID = "apple";
|
|
951
|
+
function record(value) {
|
|
952
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
953
|
+
}
|
|
954
|
+
async function readCatalogMetadata(context, file) {
|
|
955
|
+
try {
|
|
956
|
+
const text = (await context.readFile(file) ?? "").replace(/^\uFEFF/u, "");
|
|
957
|
+
const parsed = JSON.parse(text);
|
|
958
|
+
if (!record(parsed) || typeof parsed.sourceLanguage !== "string" || !isLocaleTag(parsed.sourceLanguage) || !record(parsed.strings)) return null;
|
|
959
|
+
parseCatalog(text, file, parsed.sourceLanguage);
|
|
960
|
+
const locales = /* @__PURE__ */ new Set();
|
|
961
|
+
for (const entry of Object.values(parsed.strings)) if (record(entry) && record(entry.localizations)) {
|
|
962
|
+
for (const locale of Object.keys(entry.localizations)) if (isLocaleTag(locale)) locales.add(locale);
|
|
963
|
+
}
|
|
964
|
+
return {
|
|
965
|
+
file,
|
|
966
|
+
locales: [...locales],
|
|
967
|
+
sourceLocale: parsed.sourceLanguage
|
|
968
|
+
};
|
|
969
|
+
} catch {
|
|
970
|
+
return null;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
function inside(file, root) {
|
|
974
|
+
return root === "." || file === root || file.startsWith(`${root}/`);
|
|
975
|
+
}
|
|
976
|
+
function localeIdentity(locale) {
|
|
977
|
+
return Intl.getCanonicalLocales(locale)[0]?.toLowerCase() ?? locale.toLowerCase();
|
|
978
|
+
}
|
|
979
|
+
/** A single configured locale must address one physical spelling everywhere.
|
|
980
|
+
* Resource names take precedence over project metadata; conflicting resource
|
|
981
|
+
* aliases require normalization before they can safely share a target. */
|
|
982
|
+
function selectTargetLocales(resourceLocales, declaredLocales, sourceLocale, warnings) {
|
|
983
|
+
const source = localeIdentity(sourceLocale);
|
|
984
|
+
const spellings = /* @__PURE__ */ new Map();
|
|
985
|
+
for (const locale of resourceLocales) {
|
|
986
|
+
const identity = localeIdentity(locale);
|
|
987
|
+
if (identity === source) continue;
|
|
988
|
+
const names = spellings.get(identity) ?? /* @__PURE__ */ new Set();
|
|
989
|
+
names.add(locale);
|
|
990
|
+
spellings.set(identity, names);
|
|
991
|
+
}
|
|
992
|
+
const targets = /* @__PURE__ */ new Map();
|
|
993
|
+
for (const [identity, names] of spellings) {
|
|
994
|
+
const [locale] = names;
|
|
995
|
+
if (names.size > 1) warnings.push(`Locale spellings ${[...names].toSorted().join(", ")} identify the same language in existing resources. This language was omitted from targetLocales; normalize its resource names before syncing.`);
|
|
996
|
+
else if (locale !== void 0) targets.set(identity, locale);
|
|
997
|
+
}
|
|
998
|
+
for (const locale of declaredLocales) {
|
|
999
|
+
const identity = localeIdentity(locale);
|
|
1000
|
+
if (identity !== source && !spellings.has(identity)) targets.set(identity, Intl.getCanonicalLocales(locale)[0] ?? locale);
|
|
1001
|
+
}
|
|
1002
|
+
return [...targets.values()].toSorted();
|
|
1003
|
+
}
|
|
1004
|
+
function commentsRemoved(source) {
|
|
1005
|
+
let result = "";
|
|
1006
|
+
let index = 0;
|
|
1007
|
+
while (index < source.length) {
|
|
1008
|
+
const start = index;
|
|
1009
|
+
const quote = source[index];
|
|
1010
|
+
if (quote === "\"" || quote === "'") {
|
|
1011
|
+
index += 1;
|
|
1012
|
+
while (index < source.length) {
|
|
1013
|
+
const character = source[index++];
|
|
1014
|
+
if (character === "\\") index += 1;
|
|
1015
|
+
else if (character === quote) break;
|
|
1016
|
+
}
|
|
1017
|
+
result += source.slice(start, index);
|
|
1018
|
+
} else if (source.startsWith("//", index)) {
|
|
1019
|
+
while (index < source.length && !/[\r\n]/u.test(source[index] ?? "")) index += 1;
|
|
1020
|
+
result += " ";
|
|
1021
|
+
} else if (source.startsWith("/*", index)) {
|
|
1022
|
+
let depth = 1;
|
|
1023
|
+
index += 2;
|
|
1024
|
+
while (index < source.length && depth > 0) if (source.startsWith("/*", index)) {
|
|
1025
|
+
depth += 1;
|
|
1026
|
+
index += 2;
|
|
1027
|
+
} else if (source.startsWith("*/", index)) {
|
|
1028
|
+
depth -= 1;
|
|
1029
|
+
index += 2;
|
|
1030
|
+
} else index += 1;
|
|
1031
|
+
result += source.slice(start, index).replace(/[^\r\n]/gu, " ");
|
|
1032
|
+
} else result += source[index++];
|
|
1033
|
+
}
|
|
1034
|
+
return result;
|
|
1035
|
+
}
|
|
1036
|
+
function projectMetadata(file, source) {
|
|
1037
|
+
const tokens = [...commentsRemoved(source).matchAll(/"(?:\\.|[^"\\])*"|[A-Za-z_][A-Za-z0-9_-]*|[=;(),{}]/gu)].map(([token]) => token.replace(/^"|"$/gu, ""));
|
|
1038
|
+
let sourceLocale;
|
|
1039
|
+
const locales = [];
|
|
1040
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
1041
|
+
if (tokens[index + 1] !== "=") continue;
|
|
1042
|
+
if (tokens[index] === "developmentRegion" && tokens[index + 3] === ";") {
|
|
1043
|
+
const value = tokens[index + 2];
|
|
1044
|
+
if (value !== void 0 && isLocaleTag(value)) sourceLocale = value;
|
|
1045
|
+
}
|
|
1046
|
+
if (tokens[index] === "knownRegions" && tokens[index + 2] === "(") for (let cursor = index + 3; cursor < tokens.length && tokens[cursor] !== ")"; cursor += 1) {
|
|
1047
|
+
const value = tokens[cursor];
|
|
1048
|
+
if (value !== void 0 && isLocaleTag(value)) locales.push(value);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
return {
|
|
1052
|
+
file,
|
|
1053
|
+
rootDir: path.posix.dirname(path.posix.dirname(file)),
|
|
1054
|
+
sourceLocale,
|
|
1055
|
+
locales
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
function nearestProjects(file, projects) {
|
|
1059
|
+
const candidates = projects.filter((project) => inside(file, project.rootDir));
|
|
1060
|
+
const depth = Math.max(-1, ...candidates.map((project) => project.rootDir === "." ? 0 : project.rootDir.split("/").length));
|
|
1061
|
+
return candidates.filter((project) => (project.rootDir === "." ? 0 : project.rootDir.split("/").length) === depth);
|
|
1062
|
+
}
|
|
1063
|
+
async function expoDiscovery(context) {
|
|
1064
|
+
const expoRoots = /* @__PURE__ */ new Set();
|
|
1065
|
+
const rules = /* @__PURE__ */ new Map();
|
|
1066
|
+
const ignored = (file) => {
|
|
1067
|
+
let result = false;
|
|
1068
|
+
for (const [root, matcher] of rules) {
|
|
1069
|
+
if (!inside(file, root)) continue;
|
|
1070
|
+
const relative = root === "." ? file : file.slice(root.length + 1);
|
|
1071
|
+
if (relative.length === 0) continue;
|
|
1072
|
+
const match = matcher.test(relative);
|
|
1073
|
+
result = match.ignored || result && !match.unignored;
|
|
1074
|
+
}
|
|
1075
|
+
return result;
|
|
1076
|
+
};
|
|
1077
|
+
const generated = (file) => [...expoRoots].some((root) => inside(file, root === "." ? "ios" : `${root}/ios`)) && ignored(file);
|
|
1078
|
+
const visit = async (directory) => {
|
|
1079
|
+
if (generated(`${directory}/`)) return false;
|
|
1080
|
+
const root = directory || ".";
|
|
1081
|
+
const relative = (name) => root === "." ? name : `${root}/${name}`;
|
|
1082
|
+
const [manifest, gitignore] = await Promise.all([context.readFile(relative("package.json")), context.readFile(relative(".gitignore"))]);
|
|
1083
|
+
if (gitignore !== null) rules.set(root, ignore().add(gitignore));
|
|
1084
|
+
try {
|
|
1085
|
+
const parsed = JSON.parse((manifest ?? "").replace(/^\uFEFF/u, ""));
|
|
1086
|
+
if (record(parsed) && [
|
|
1087
|
+
"dependencies",
|
|
1088
|
+
"devDependencies",
|
|
1089
|
+
"peerDependencies",
|
|
1090
|
+
"optionalDependencies"
|
|
1091
|
+
].some((section) => record(parsed[section]) && Object.hasOwn(parsed[section], "expo"))) expoRoots.add(root);
|
|
1092
|
+
} catch {}
|
|
1093
|
+
return true;
|
|
1094
|
+
};
|
|
1095
|
+
await visit("");
|
|
1096
|
+
return {
|
|
1097
|
+
accept: (file) => !generated(file),
|
|
1098
|
+
visit
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
function adapterPlan(factory, id, include) {
|
|
1102
|
+
return {
|
|
1103
|
+
factory: {
|
|
1104
|
+
from: "@ai-translate/apple",
|
|
1105
|
+
name: factory
|
|
1106
|
+
},
|
|
1107
|
+
kind: "adapter",
|
|
1108
|
+
options: {
|
|
1109
|
+
id,
|
|
1110
|
+
rootDir: ".",
|
|
1111
|
+
...include === void 0 ? {} : { include }
|
|
1112
|
+
}
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
/** Native resources are shared by Swift, Objective-C, React Native and Expo
|
|
1116
|
+
* prebuild projects, so discovery follows authored resources rather than a UI framework. */
|
|
1117
|
+
const appleIntegration = defineIntegration({
|
|
1118
|
+
async detect(context) {
|
|
1119
|
+
const discovery = await expoDiscovery(context);
|
|
1120
|
+
const files = await findProjectFiles(context, (file) => discovery.accept(file) && (file.endsWith(".xcstrings") || /(?:^|\/)project\.pbxproj$/u.test(file) || /(?:^|\/)Package\.swift$/u.test(file) || /\.lproj\/.+\.strings$/u.test(file)), (directory) => discovery.visit(directory));
|
|
1121
|
+
const catalogFiles = files.filter((file) => file.endsWith(".xcstrings"));
|
|
1122
|
+
const legacyFiles = files.filter((file) => file.endsWith(".strings"));
|
|
1123
|
+
const projectFiles = files.filter((file) => file.endsWith("project.pbxproj"));
|
|
1124
|
+
const packageFiles = files.filter((file) => file.endsWith("Package.swift"));
|
|
1125
|
+
const projectSources = await Promise.all(projectFiles.map(async (file) => projectMetadata(file, await context.readFile(file) ?? "")));
|
|
1126
|
+
const applePackages = (await Promise.all(packageFiles.map(async (file) => ({
|
|
1127
|
+
file,
|
|
1128
|
+
text: commentsRemoved(await context.readFile(file) ?? "")
|
|
1129
|
+
})))).flatMap(({ file, text }) => {
|
|
1130
|
+
const declared = readStringLiteral(text, "defaultLocalization");
|
|
1131
|
+
const sourceLocale = declared !== null && isLocaleTag(declared) ? declared : void 0;
|
|
1132
|
+
const code = text.replace(/"(?:\\.|[^"\\])*"/gu, "\"\"");
|
|
1133
|
+
return sourceLocale !== void 0 || /\.(?:iOS|macOS|tvOS|watchOS|visionOS)\s*\(/u.test(code) ? [{
|
|
1134
|
+
file,
|
|
1135
|
+
rootDir: path.posix.dirname(file),
|
|
1136
|
+
sourceLocale,
|
|
1137
|
+
locales: []
|
|
1138
|
+
}] : [];
|
|
1139
|
+
});
|
|
1140
|
+
const projects = [...projectSources, ...applePackages];
|
|
1141
|
+
if (catalogFiles.length === 0 && legacyFiles.length === 0 && projectFiles.length === 0 && applePackages.length === 0) return null;
|
|
1142
|
+
const warnings = [];
|
|
1143
|
+
const metadata = (await Promise.all(catalogFiles.map((file) => readCatalogMetadata(context, file)))).filter((item) => item !== null);
|
|
1144
|
+
for (const file of catalogFiles) if (!metadata.some((item) => item.file === file)) warnings.push(`Could not read a supported String Catalog or its source language from ${file}; repair its JSON, version, and localization structure, then re-run init.`);
|
|
1145
|
+
const declaredSources = projects.flatMap(({ sourceLocale }) => sourceLocale === void 0 ? [] : [sourceLocale]);
|
|
1146
|
+
const rootSources = projects.filter(({ rootDir }) => rootDir === ".").flatMap(({ sourceLocale }) => sourceLocale === void 0 ? [] : [sourceLocale]);
|
|
1147
|
+
const legacyLocales = [...new Set(legacyFiles.flatMap((file) => {
|
|
1148
|
+
const locale = /(?:^|\/)([^/]+)\.lproj\//u.exec(file)?.[1];
|
|
1149
|
+
return locale !== void 0 && isLocaleTag(locale) ? [locale] : [];
|
|
1150
|
+
}))];
|
|
1151
|
+
const preferredSource = rootSources[0] ?? metadata[0]?.sourceLocale ?? declaredSources[0] ?? legacyLocales.find((locale) => localeIdentity(locale) === "en") ?? resolveSourceLocale(legacyLocales, null) ?? "en";
|
|
1152
|
+
const sourceLocale = metadata.find((item) => localeIdentity(item.sourceLocale) === localeIdentity(preferredSource))?.sourceLocale ?? preferredSource;
|
|
1153
|
+
if (metadata.length === 0 && declaredSources.length === 0) warnings.push(`No source language is declared; sourceLocale is provisionally "${sourceLocale}". Confirm it before syncing.`);
|
|
1154
|
+
const matchingCatalogs = metadata.filter((item) => item.sourceLocale === sourceLocale);
|
|
1155
|
+
for (const item of metadata.filter((catalog) => catalog.sourceLocale !== sourceLocale)) warnings.push(`${item.file} uses source language ${item.sourceLocale}; configure it separately from ${sourceLocale} catalogs.`);
|
|
1156
|
+
for (const project of projects) if (project.sourceLocale !== void 0 && localeIdentity(project.sourceLocale) !== localeIdentity(sourceLocale)) warnings.push(`${project.file} declares source language ${project.sourceLocale}; configure its resources separately from ${sourceLocale} resources.`);
|
|
1157
|
+
const resourceLanguages = new Set(matchingCatalogs.flatMap((item) => [...item.locales]));
|
|
1158
|
+
const declaredLanguages = new Set(projects.filter((project) => project.sourceLocale !== void 0 && localeIdentity(project.sourceLocale) === localeIdentity(sourceLocale) || project.sourceLocale === void 0 && (project.rootDir === "." || matchingCatalogs.some((catalog) => nearestProjects(catalog.file, projects).includes(project)))).flatMap((project) => [...project.locales]));
|
|
1159
|
+
const catalogs = [];
|
|
1160
|
+
if (matchingCatalogs.length > 0) catalogs.push(adapterPlan("createAppleStringCatalog", "apple-catalogs", matchingCatalogs.map((item) => convertPathToPattern(item.file))));
|
|
1161
|
+
const legacyRoots = /* @__PURE__ */ new Map();
|
|
1162
|
+
for (const file of legacyFiles) {
|
|
1163
|
+
const match = /^(?:(.*?)\/)?([^/]+)\.lproj\/(.+\.strings)$/u.exec(file);
|
|
1164
|
+
if (match === null) continue;
|
|
1165
|
+
const root = match[1] ?? ".";
|
|
1166
|
+
const languages = legacyRoots.get(root) ?? /* @__PURE__ */ new Map();
|
|
1167
|
+
const language = match[2] ?? "";
|
|
1168
|
+
const tables = languages.get(language) ?? /* @__PURE__ */ new Set();
|
|
1169
|
+
tables.add(match[3] ?? "");
|
|
1170
|
+
languages.set(language, tables);
|
|
1171
|
+
legacyRoots.set(root, languages);
|
|
1172
|
+
}
|
|
1173
|
+
for (const [rootDir, languages] of legacyRoots) {
|
|
1174
|
+
for (const language of languages.keys()) if (language !== "Base" && !isLocaleTag(language)) warnings.push(`Unsupported locale directory ${rootDir}/${language}.lproj. Generated configs require BCP 47 language tags such as en or pt-BR; rename legacy aliases or configure these resources manually.`);
|
|
1175
|
+
const owners = nearestProjects(rootDir, projects);
|
|
1176
|
+
if (owners.some((project) => project.sourceLocale !== void 0 && localeIdentity(project.sourceLocale) !== localeIdentity(sourceLocale))) {
|
|
1177
|
+
warnings.push(`Skipped ${rootDir} strings tables because their project declares a different source language. Run init from that project separately.`);
|
|
1178
|
+
continue;
|
|
1179
|
+
}
|
|
1180
|
+
const sourceLanguages = [...languages.keys()].filter((locale) => isLocaleTag(locale) && localeIdentity(locale) === localeIdentity(sourceLocale));
|
|
1181
|
+
if (sourceLanguages.length > 1) {
|
|
1182
|
+
warnings.push(`Skipped ${rootDir} strings tables because ${sourceLanguages.join(", ")} are ambiguous spellings of source language ${sourceLocale}. Normalize these source directories before syncing.`);
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
1185
|
+
const sourceLanguage = sourceLanguages[0];
|
|
1186
|
+
const sourceDirectory = sourceLanguage !== void 0 ? `${sourceLanguage}.lproj` : languages.has("Base") ? "Base.lproj" : void 0;
|
|
1187
|
+
if (sourceDirectory === void 0) {
|
|
1188
|
+
warnings.push(`No ${sourceLocale}.lproj or Base.lproj strings table was found in ${rootDir}; add a source-language table before configuring legacy strings.`);
|
|
1189
|
+
continue;
|
|
1190
|
+
}
|
|
1191
|
+
for (const language of languages.keys()) if (isLocaleTag(language)) resourceLanguages.add(language);
|
|
1192
|
+
for (const owner of owners) for (const locale of owner.locales) declaredLanguages.add(locale);
|
|
1193
|
+
const baseOnlyTables = [...languages.get("Base") ?? []].filter((table) => sourceLanguage === void 0 || !languages.get(sourceLanguage)?.has(table));
|
|
1194
|
+
const mixed = sourceLanguage !== void 0 && baseOnlyTables.length > 0;
|
|
1195
|
+
catalogs.push({
|
|
1196
|
+
...adapterPlan("createAppleStringsCatalog", `apple-strings:${rootDir}`),
|
|
1197
|
+
options: {
|
|
1198
|
+
id: `apple-strings:${rootDir}`,
|
|
1199
|
+
rootDir,
|
|
1200
|
+
...sourceDirectory !== `${sourceLocale}.lproj` ? { sourceLocaleDirectory: sourceDirectory } : {},
|
|
1201
|
+
include: [...languages.get(sourceLanguage ?? "Base") ?? []].map(convertPathToPattern)
|
|
1202
|
+
}
|
|
1203
|
+
});
|
|
1204
|
+
if (mixed) catalogs.push({
|
|
1205
|
+
...adapterPlan("createAppleStringsCatalog", `apple-strings:${rootDir}:Base`),
|
|
1206
|
+
options: {
|
|
1207
|
+
id: `apple-strings:${rootDir}:Base`,
|
|
1208
|
+
rootDir,
|
|
1209
|
+
sourceLocaleDirectory: "Base.lproj",
|
|
1210
|
+
include: baseOnlyTables.map(convertPathToPattern)
|
|
1211
|
+
}
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
const hasResources = catalogs.length > 0;
|
|
1215
|
+
if (!hasResources) {
|
|
1216
|
+
catalogs.push(adapterPlan("createAppleStringCatalog", "apple-catalogs", []));
|
|
1217
|
+
warnings.push("No supported source localization resources were found. Create and populate an Xcode String Catalog (.xcstrings), or extract localized Swift strings with Xcode, then re-run init to select the authored files before syncing. Hardcoded strings are not extracted by ai-translate.");
|
|
1218
|
+
}
|
|
1219
|
+
const targetLocales = selectTargetLocales(resourceLanguages, declaredLanguages, sourceLocale, warnings);
|
|
1220
|
+
if (targetLocales.length === 0) warnings.push("No target languages were found. Add the languages your app supports to targetLocales before syncing.");
|
|
1221
|
+
const evidence = [
|
|
1222
|
+
...matchingCatalogs.map((item) => ({
|
|
1223
|
+
detail: `Xcode String Catalog with source language ${item.sourceLocale}`,
|
|
1224
|
+
source: item.file
|
|
1225
|
+
})),
|
|
1226
|
+
...projectSources.map(({ file }) => ({
|
|
1227
|
+
detail: "Xcode project",
|
|
1228
|
+
source: file
|
|
1229
|
+
})),
|
|
1230
|
+
...applePackages.map(({ file }) => ({
|
|
1231
|
+
detail: "Swift package with Apple platform support",
|
|
1232
|
+
source: file
|
|
1233
|
+
})),
|
|
1234
|
+
...legacyFiles.length === 0 ? [] : [{
|
|
1235
|
+
detail: `${String(legacyFiles.length)} localized strings table(s)`,
|
|
1236
|
+
source: legacyFiles[0] ?? "."
|
|
1237
|
+
}]
|
|
1238
|
+
];
|
|
1239
|
+
const [catalog, ...additionalCatalogs] = catalogs;
|
|
1240
|
+
if (catalog === void 0) return null;
|
|
1241
|
+
return {
|
|
1242
|
+
confidence: hasResources ? .98 : .6,
|
|
1243
|
+
displayName: "Apple localization",
|
|
1244
|
+
evidence,
|
|
1245
|
+
integrationId: APPLE_INTEGRATION_ID,
|
|
1246
|
+
plan: {
|
|
1247
|
+
catalog,
|
|
1248
|
+
...additionalCatalogs.length === 0 ? {} : { additionalCatalogs },
|
|
1249
|
+
messageFormat: "plain",
|
|
1250
|
+
sourceLocale,
|
|
1251
|
+
targetLocales,
|
|
1252
|
+
warnings
|
|
1253
|
+
}
|
|
1254
|
+
};
|
|
1255
|
+
},
|
|
1256
|
+
displayName: "Apple localization (Xcode and Swift packages)",
|
|
1257
|
+
id: APPLE_INTEGRATION_ID
|
|
1258
|
+
});
|
|
1259
|
+
//#endregion
|
|
1260
|
+
export { appleIntegration, createAppleStringCatalog, createAppleStringsCatalog };
|
|
1261
|
+
|
|
1262
|
+
//# sourceMappingURL=index.mjs.map
|