@verbaly/compiler 0.14.5 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -3
- package/dist/cli.js +305 -60
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +39 -1
- package/dist/index.js +266 -67
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { parse } from "@babel/parser";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { dirname, join, relative, resolve } from "node:path";
|
|
4
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
5
5
|
import { RICH_TAGS, createVerbaly, parse as parse$1, parseTags, safeHref } from "verbaly";
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
7
7
|
import { glob } from "tinyglobby";
|
|
@@ -402,18 +402,27 @@ const VIRTUAL_ID = "virtual:verbaly";
|
|
|
402
402
|
function generateRuntimeModule(cfg) {
|
|
403
403
|
const others = cfg.locales.filter((locale) => locale !== cfg.sourceLocale);
|
|
404
404
|
const src = JSON.stringify(cfg.sourceLocale);
|
|
405
|
-
const loaders = others.map((locale) => `
|
|
405
|
+
const loaders = others.map((locale) => ` ${JSON.stringify(locale)}: () => import('${VIRTUAL_ID}/locale/${locale}'),`).join("\n");
|
|
406
406
|
return `import { createVerbaly } from 'verbaly';
|
|
407
407
|
import source from '${VIRTUAL_ID}/locale/${cfg.sourceLocale}';
|
|
408
408
|
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
409
|
+
export const sourceLocale = ${src};
|
|
410
|
+
export const locales = ${JSON.stringify(cfg.locales)};
|
|
411
|
+
|
|
412
|
+
// per-request/per-instance factory (SSR) — the singleton below is browser/SPA-only
|
|
413
|
+
export function createInstance(options) {
|
|
414
|
+
return createVerbaly({
|
|
415
|
+
locale: ${src},
|
|
416
|
+
fallback: ${src},
|
|
417
|
+
messages: { [${src}]: source },
|
|
418
|
+
loaders: {
|
|
414
419
|
${loaders}
|
|
415
|
-
|
|
416
|
-
|
|
420
|
+
},
|
|
421
|
+
...options,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const v = createInstance();
|
|
417
426
|
|
|
418
427
|
export const verbaly = v;
|
|
419
428
|
export const t = v.t;
|
|
@@ -454,6 +463,12 @@ ${lines.join("\n")}
|
|
|
454
463
|
|
|
455
464
|
export const verbaly: import('verbaly').Verbaly<VerbalyKey>;
|
|
456
465
|
|
|
466
|
+
export const sourceLocale: string;
|
|
467
|
+
export const locales: string[];
|
|
468
|
+
export function createInstance(
|
|
469
|
+
options?: import('verbaly').VerbalyOptions<VerbalyKey>,
|
|
470
|
+
): import('verbaly').Verbaly<VerbalyKey>;
|
|
471
|
+
|
|
457
472
|
export function t<K extends VerbalyKey>(
|
|
458
473
|
key: K,
|
|
459
474
|
...args: [VerbalyMessages[K]] extends [never] ? [] : [VerbalyMessages[K]]
|
|
@@ -794,6 +809,247 @@ function readDeps(root) {
|
|
|
794
809
|
}
|
|
795
810
|
}
|
|
796
811
|
//#endregion
|
|
812
|
+
//#region src/translate.ts
|
|
813
|
+
async function translateCatalogs(cfg, catalogs, provider, options = {}) {
|
|
814
|
+
const batchSize = options.batchSize ?? 20;
|
|
815
|
+
const targets = (options.locales ?? cfg.locales).filter((locale) => locale !== cfg.sourceLocale && cfg.locales.includes(locale));
|
|
816
|
+
const source = catalogs[cfg.sourceLocale] ?? {};
|
|
817
|
+
const result = {
|
|
818
|
+
translated: {},
|
|
819
|
+
invalid: {},
|
|
820
|
+
pending: {}
|
|
821
|
+
};
|
|
822
|
+
for (const locale of targets) {
|
|
823
|
+
const catalog = catalogs[locale] ??= {};
|
|
824
|
+
const missing = Object.keys(source).filter((key) => source[key] && !catalog[key]);
|
|
825
|
+
if (missing.length === 0) continue;
|
|
826
|
+
if (options.dryRun) {
|
|
827
|
+
result.pending[locale] = missing;
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
for (let i = 0; i < missing.length; i += batchSize) {
|
|
831
|
+
const keys = missing.slice(i, i + batchSize);
|
|
832
|
+
const messages = Object.fromEntries(keys.map((key) => [key, source[key]]));
|
|
833
|
+
const out = await provider({
|
|
834
|
+
sourceLocale: cfg.sourceLocale,
|
|
835
|
+
targetLocale: locale,
|
|
836
|
+
messages
|
|
837
|
+
});
|
|
838
|
+
for (const key of keys) {
|
|
839
|
+
const text = out[key];
|
|
840
|
+
if (typeof text === "string" && text.trim() && structureMatches(source[key], text)) {
|
|
841
|
+
catalog[key] = text;
|
|
842
|
+
(result.translated[locale] ??= []).push(key);
|
|
843
|
+
} else (result.invalid[locale] ??= []).push(key);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
return result;
|
|
848
|
+
}
|
|
849
|
+
function structureMatches(source, translated) {
|
|
850
|
+
return sameMembers(paramNames(source), paramNames(translated)) && sameMembers(tagTokens(source), tagTokens(translated));
|
|
851
|
+
}
|
|
852
|
+
function paramNames(message) {
|
|
853
|
+
try {
|
|
854
|
+
return [...collectParams(message).keys()].sort();
|
|
855
|
+
} catch {
|
|
856
|
+
return ["\0invalid"];
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
const TAG = /<(\/?)([a-zA-Z][\w-]*)(\/?)>/g;
|
|
860
|
+
function tagTokens(message) {
|
|
861
|
+
const out = [];
|
|
862
|
+
for (const match of message.matchAll(TAG)) out.push(`${match[1]}${match[2]}${match[3]}`);
|
|
863
|
+
return out.sort();
|
|
864
|
+
}
|
|
865
|
+
function sameMembers(a, b) {
|
|
866
|
+
return a.length === b.length && a.every((value, i) => value === b[i]);
|
|
867
|
+
}
|
|
868
|
+
//#endregion
|
|
869
|
+
//#region src/exchange.ts
|
|
870
|
+
function exportCatalogs(cfg, catalogs, options = {}) {
|
|
871
|
+
const format = options.format ?? "xliff";
|
|
872
|
+
const dir = resolve(cfg.root, options.out ?? "verbaly-export");
|
|
873
|
+
const source = catalogs[cfg.sourceLocale] ?? {};
|
|
874
|
+
const targets = (options.locales ?? cfg.locales).filter((locale) => locale !== cfg.sourceLocale && cfg.locales.includes(locale));
|
|
875
|
+
const files = [];
|
|
876
|
+
mkdirSync(dir, { recursive: true });
|
|
877
|
+
for (const locale of targets) {
|
|
878
|
+
const catalog = catalogs[locale] ?? {};
|
|
879
|
+
let entries = Object.keys(source).filter((key) => source[key]).sort().map((key) => ({
|
|
880
|
+
key,
|
|
881
|
+
source: source[key],
|
|
882
|
+
target: catalog[key] ?? ""
|
|
883
|
+
}));
|
|
884
|
+
if (options.missing) entries = entries.filter((entry) => !entry.target);
|
|
885
|
+
const path = join(dir, `${locale}.${format === "csv" ? "csv" : "xlf"}`);
|
|
886
|
+
writeFileSync(path, format === "csv" ? toCsv(entries) : toXliff(cfg.sourceLocale, locale, entries));
|
|
887
|
+
files.push({
|
|
888
|
+
locale,
|
|
889
|
+
path,
|
|
890
|
+
total: entries.length,
|
|
891
|
+
untranslated: entries.filter((entry) => !entry.target).length
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
return {
|
|
895
|
+
format,
|
|
896
|
+
dir,
|
|
897
|
+
files
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
function importCatalogs(cfg, catalogs, files, options = {}) {
|
|
901
|
+
const source = catalogs[cfg.sourceLocale] ?? {};
|
|
902
|
+
const result = {
|
|
903
|
+
imported: {},
|
|
904
|
+
rejected: {},
|
|
905
|
+
skipped: {},
|
|
906
|
+
unknown: {}
|
|
907
|
+
};
|
|
908
|
+
for (const file of files) {
|
|
909
|
+
const parsed = parseExchangeFile(file, options.locale);
|
|
910
|
+
const locale = parsed.locale;
|
|
911
|
+
if (!/^[a-zA-Z]{2,3}([-_][a-zA-Z0-9]+)*$/.test(locale)) throw new Error(`[verbaly] ${file}: "${locale}" doesn't look like a locale — pass --locale <id>.`);
|
|
912
|
+
if (locale === cfg.sourceLocale) throw new Error(`[verbaly] ${file} targets the source locale "${locale}" — import fills translations, not the source. Pass --locale if the detection is wrong.`);
|
|
913
|
+
const catalog = catalogs[locale] ??= {};
|
|
914
|
+
for (const [key, text] of Object.entries(parsed.entries)) {
|
|
915
|
+
if (!text.trim()) continue;
|
|
916
|
+
if (!source[key]) {
|
|
917
|
+
(result.unknown[locale] ??= []).push(key);
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
if (catalog[key] && !options.overwrite) {
|
|
921
|
+
(result.skipped[locale] ??= []).push(key);
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
if (!structureMatches(source[key], text)) {
|
|
925
|
+
(result.rejected[locale] ??= []).push(key);
|
|
926
|
+
continue;
|
|
927
|
+
}
|
|
928
|
+
if (!options.dryRun) catalog[key] = text;
|
|
929
|
+
(result.imported[locale] ??= []).push(key);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
return result;
|
|
933
|
+
}
|
|
934
|
+
function parseExchangeFile(file, localeOverride) {
|
|
935
|
+
const content = readFileSync(file, "utf8");
|
|
936
|
+
if (/\.(xlf|xliff)$/i.test(file)) {
|
|
937
|
+
const parsed = parseXliff(content);
|
|
938
|
+
const locale = localeOverride ?? parsed.locale;
|
|
939
|
+
if (!locale) throw new Error(`[verbaly] ${file} has no trgLang/target-language — pass --locale <id>.`);
|
|
940
|
+
return {
|
|
941
|
+
locale,
|
|
942
|
+
entries: parsed.entries
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
if (/\.csv$/i.test(file)) return {
|
|
946
|
+
locale: localeOverride ?? basename(file).replace(/\.csv$/i, ""),
|
|
947
|
+
entries: parseCsv(content)
|
|
948
|
+
};
|
|
949
|
+
throw new Error(`[verbaly] ${file}: unsupported format — expected .xlf, .xliff or .csv.`);
|
|
950
|
+
}
|
|
951
|
+
function toXliff(sourceLocale, locale, entries) {
|
|
952
|
+
const units = entries.map(({ key, source, target }) => [
|
|
953
|
+
` <unit id="${escapeXml(key)}">`,
|
|
954
|
+
` <segment state="${target ? "translated" : "initial"}">`,
|
|
955
|
+
` <source>${escapeXml(source)}</source>`,
|
|
956
|
+
` <target>${escapeXml(target)}</target>`,
|
|
957
|
+
" </segment>",
|
|
958
|
+
" </unit>"
|
|
959
|
+
].join("\n")).join("\n");
|
|
960
|
+
return [
|
|
961
|
+
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
|
|
962
|
+
`<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="${escapeXml(sourceLocale)}" trgLang="${escapeXml(locale)}">`,
|
|
963
|
+
` <file id="verbaly" original="${escapeXml(`${locale}.json`)}">`,
|
|
964
|
+
units,
|
|
965
|
+
" </file>",
|
|
966
|
+
"</xliff>",
|
|
967
|
+
""
|
|
968
|
+
].join("\n");
|
|
969
|
+
}
|
|
970
|
+
function parseXliff(content) {
|
|
971
|
+
const locale = /\btrgLang\s*=\s*"([^"]+)"/.exec(content)?.[1] ?? /\btarget-language\s*=\s*"([^"]+)"/.exec(content)?.[1];
|
|
972
|
+
const entries = {};
|
|
973
|
+
for (const match of content.matchAll(/<(?:trans-)?unit\b([^>]*)>([\s\S]*?)<\/(?:trans-)?unit>/g)) {
|
|
974
|
+
const id = /\bid\s*=\s*"([^"]*)"/.exec(match[1])?.[1];
|
|
975
|
+
if (!id) continue;
|
|
976
|
+
const target = /<target\b[^>]*>([\s\S]*?)<\/target>/.exec(match[2])?.[1];
|
|
977
|
+
entries[unescapeXml(id)] = target === void 0 ? "" : unescapeXml(stripCdata(target));
|
|
978
|
+
}
|
|
979
|
+
return {
|
|
980
|
+
locale,
|
|
981
|
+
entries
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
function escapeXml(text) {
|
|
985
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
986
|
+
}
|
|
987
|
+
function stripCdata(text) {
|
|
988
|
+
return text.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1");
|
|
989
|
+
}
|
|
990
|
+
function unescapeXml(text) {
|
|
991
|
+
return text.replace(/&(lt|gt|amp|quot|apos|#x?[0-9a-fA-F]+);/g, (_, entity) => {
|
|
992
|
+
if (entity === "lt") return "<";
|
|
993
|
+
if (entity === "gt") return ">";
|
|
994
|
+
if (entity === "amp") return "&";
|
|
995
|
+
if (entity === "quot") return "\"";
|
|
996
|
+
if (entity === "apos") return "'";
|
|
997
|
+
const code = entity.startsWith("#x") ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10);
|
|
998
|
+
return String.fromCodePoint(code);
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
function toCsv(entries) {
|
|
1002
|
+
return [
|
|
1003
|
+
"key,source,target",
|
|
1004
|
+
...entries.map(({ key, source, target }) => `${csvField(key)},${csvField(source)},${csvField(target)}`),
|
|
1005
|
+
""
|
|
1006
|
+
].join("\r\n");
|
|
1007
|
+
}
|
|
1008
|
+
function csvField(text) {
|
|
1009
|
+
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, "\"\"")}"` : text;
|
|
1010
|
+
}
|
|
1011
|
+
function parseCsv(content) {
|
|
1012
|
+
const rows = csvRows(content);
|
|
1013
|
+
const header = rows[0]?.map((cell) => cell.trim().toLowerCase()) ?? [];
|
|
1014
|
+
const keyCol = header.indexOf("key");
|
|
1015
|
+
const targetCol = header.indexOf("target");
|
|
1016
|
+
if (keyCol === -1 || targetCol === -1) throw new Error("[verbaly] CSV needs a header row with \"key\" and \"target\" columns.");
|
|
1017
|
+
const entries = {};
|
|
1018
|
+
for (const row of rows.slice(1)) {
|
|
1019
|
+
const key = row[keyCol];
|
|
1020
|
+
if (key) entries[key] = row[targetCol] ?? "";
|
|
1021
|
+
}
|
|
1022
|
+
return entries;
|
|
1023
|
+
}
|
|
1024
|
+
function csvRows(content) {
|
|
1025
|
+
const rows = [];
|
|
1026
|
+
let row = [];
|
|
1027
|
+
let field = "";
|
|
1028
|
+
let quoted = false;
|
|
1029
|
+
for (let i = 0; i < content.length; i++) {
|
|
1030
|
+
const char = content[i];
|
|
1031
|
+
if (quoted) if (char === "\"") if (content[i + 1] === "\"") {
|
|
1032
|
+
field += "\"";
|
|
1033
|
+
i++;
|
|
1034
|
+
} else quoted = false;
|
|
1035
|
+
else field += char;
|
|
1036
|
+
else if (char === "\"") quoted = true;
|
|
1037
|
+
else if (char === ",") {
|
|
1038
|
+
row.push(field);
|
|
1039
|
+
field = "";
|
|
1040
|
+
} else if (char === "\n" || char === "\r") {
|
|
1041
|
+
if (char === "\r" && content[i + 1] === "\n") i++;
|
|
1042
|
+
row.push(field);
|
|
1043
|
+
field = "";
|
|
1044
|
+
if (row.some((cell) => cell !== "")) rows.push(row);
|
|
1045
|
+
row = [];
|
|
1046
|
+
} else field += char;
|
|
1047
|
+
}
|
|
1048
|
+
row.push(field);
|
|
1049
|
+
if (row.some((cell) => cell !== "")) rows.push(row);
|
|
1050
|
+
return rows;
|
|
1051
|
+
}
|
|
1052
|
+
//#endregion
|
|
797
1053
|
//#region src/providers/claude.ts
|
|
798
1054
|
const DEFAULT_MODEL = "claude-sonnet-5";
|
|
799
1055
|
const SYSTEM = `You translate UI strings for the Verbaly i18n library.
|
|
@@ -1258,63 +1514,6 @@ function transformCode(code, file, analysis) {
|
|
|
1258
1514
|
};
|
|
1259
1515
|
}
|
|
1260
1516
|
//#endregion
|
|
1261
|
-
|
|
1262
|
-
async function translateCatalogs(cfg, catalogs, provider, options = {}) {
|
|
1263
|
-
const batchSize = options.batchSize ?? 20;
|
|
1264
|
-
const targets = (options.locales ?? cfg.locales).filter((locale) => locale !== cfg.sourceLocale && cfg.locales.includes(locale));
|
|
1265
|
-
const source = catalogs[cfg.sourceLocale] ?? {};
|
|
1266
|
-
const result = {
|
|
1267
|
-
translated: {},
|
|
1268
|
-
invalid: {},
|
|
1269
|
-
pending: {}
|
|
1270
|
-
};
|
|
1271
|
-
for (const locale of targets) {
|
|
1272
|
-
const catalog = catalogs[locale] ??= {};
|
|
1273
|
-
const missing = Object.keys(source).filter((key) => source[key] && !catalog[key]);
|
|
1274
|
-
if (missing.length === 0) continue;
|
|
1275
|
-
if (options.dryRun) {
|
|
1276
|
-
result.pending[locale] = missing;
|
|
1277
|
-
continue;
|
|
1278
|
-
}
|
|
1279
|
-
for (let i = 0; i < missing.length; i += batchSize) {
|
|
1280
|
-
const keys = missing.slice(i, i + batchSize);
|
|
1281
|
-
const messages = Object.fromEntries(keys.map((key) => [key, source[key]]));
|
|
1282
|
-
const out = await provider({
|
|
1283
|
-
sourceLocale: cfg.sourceLocale,
|
|
1284
|
-
targetLocale: locale,
|
|
1285
|
-
messages
|
|
1286
|
-
});
|
|
1287
|
-
for (const key of keys) {
|
|
1288
|
-
const text = out[key];
|
|
1289
|
-
if (typeof text === "string" && text.trim() && structureMatches(source[key], text)) {
|
|
1290
|
-
catalog[key] = text;
|
|
1291
|
-
(result.translated[locale] ??= []).push(key);
|
|
1292
|
-
} else (result.invalid[locale] ??= []).push(key);
|
|
1293
|
-
}
|
|
1294
|
-
}
|
|
1295
|
-
}
|
|
1296
|
-
return result;
|
|
1297
|
-
}
|
|
1298
|
-
function structureMatches(source, translated) {
|
|
1299
|
-
return sameMembers(paramNames(source), paramNames(translated)) && sameMembers(tagTokens(source), tagTokens(translated));
|
|
1300
|
-
}
|
|
1301
|
-
function paramNames(message) {
|
|
1302
|
-
try {
|
|
1303
|
-
return [...collectParams(message).keys()].sort();
|
|
1304
|
-
} catch {
|
|
1305
|
-
return ["\0invalid"];
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
|
-
const TAG = /<(\/?)([a-zA-Z][\w-]*)(\/?)>/g;
|
|
1309
|
-
function tagTokens(message) {
|
|
1310
|
-
const out = [];
|
|
1311
|
-
for (const match of message.matchAll(TAG)) out.push(`${match[1]}${match[2]}${match[3]}`);
|
|
1312
|
-
return out.sort();
|
|
1313
|
-
}
|
|
1314
|
-
function sameMembers(a, b) {
|
|
1315
|
-
return a.length === b.length && a.every((value, i) => value === b[i]);
|
|
1316
|
-
}
|
|
1317
|
-
//#endregion
|
|
1318
|
-
export { MessageRegistry, PSEUDO_LOCALE, VIRTUAL_ID, analyze, catalogPath, check, claudeProvider, collectParams, detectBundler, doctor, extractProject, findConfigFile, formatCheckResult, generateDts, generateLocaleModule, generateRuntimeModule, init, loadCatalogs, loadConfig, loadConfigFile, pruneCatalogs, pseudoCatalogs, pseudoLocalize, readCatalog, renderHtml, renderParamType, renderSite, resolveConfig, serializeCatalog, stableKey, structureMatches, syncCatalogs, transformCode, translateCatalogs, writeCatalog, writeDts };
|
|
1517
|
+
export { MessageRegistry, PSEUDO_LOCALE, VIRTUAL_ID, analyze, catalogPath, check, claudeProvider, collectParams, detectBundler, doctor, exportCatalogs, extractProject, findConfigFile, formatCheckResult, generateDts, generateLocaleModule, generateRuntimeModule, importCatalogs, init, loadCatalogs, loadConfig, loadConfigFile, parseExchangeFile, pruneCatalogs, pseudoCatalogs, pseudoLocalize, readCatalog, renderHtml, renderParamType, renderSite, resolveConfig, serializeCatalog, stableKey, structureMatches, syncCatalogs, transformCode, translateCatalogs, writeCatalog, writeDts };
|
|
1319
1518
|
|
|
1320
1519
|
//# sourceMappingURL=index.js.map
|