@verbatra/sdk 0.1.0 → 0.2.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 +6 -0
- package/dist/index.cjs +669 -30
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +102 -1
- package/dist/index.d.ts +102 -1
- package/dist/index.js +665 -31
- package/dist/index.js.map +1 -1
- package/package.json +6 -3
package/dist/index.js
CHANGED
|
@@ -10,6 +10,8 @@ import { GoogleGenAI } from '@google/genai';
|
|
|
10
10
|
import OpenAI from 'openai';
|
|
11
11
|
import { access, writeFile, rename, rm, open } from 'fs/promises';
|
|
12
12
|
import { parse, TYPE } from '@formatjs/icu-messageformat-parser';
|
|
13
|
+
import ExcelJS from 'exceljs';
|
|
14
|
+
import JSZip from 'jszip';
|
|
13
15
|
import { watch as watch$1 } from 'chokidar';
|
|
14
16
|
|
|
15
17
|
// src/config/define-config.ts
|
|
@@ -880,9 +882,41 @@ async function readBounded(path, maxBytes) {
|
|
|
880
882
|
await handle.close();
|
|
881
883
|
}
|
|
882
884
|
}
|
|
885
|
+
async function readBoundedBytesInto(handle, size) {
|
|
886
|
+
const buffer = Buffer.allocUnsafeSlow(size);
|
|
887
|
+
let offset = 0;
|
|
888
|
+
while (offset < size) {
|
|
889
|
+
const { bytesRead } = await handle.read(buffer, offset, size - offset, offset);
|
|
890
|
+
if (bytesRead === 0) {
|
|
891
|
+
break;
|
|
892
|
+
}
|
|
893
|
+
offset += bytesRead;
|
|
894
|
+
}
|
|
895
|
+
return new Uint8Array(buffer.buffer, buffer.byteOffset, offset);
|
|
896
|
+
}
|
|
897
|
+
async function readBoundedBytes(path, maxBytes) {
|
|
898
|
+
let handle;
|
|
899
|
+
try {
|
|
900
|
+
handle = await open(path, "r");
|
|
901
|
+
} catch {
|
|
902
|
+
return { kind: "missing" };
|
|
903
|
+
}
|
|
904
|
+
try {
|
|
905
|
+
const info = await handle.stat();
|
|
906
|
+
if (!info.isFile()) {
|
|
907
|
+
return { kind: "missing" };
|
|
908
|
+
}
|
|
909
|
+
if (info.size > maxBytes) {
|
|
910
|
+
return { kind: "too-large" };
|
|
911
|
+
}
|
|
912
|
+
return { kind: "ok", bytes: await readBoundedBytesInto(handle, info.size) };
|
|
913
|
+
} finally {
|
|
914
|
+
await handle.close();
|
|
915
|
+
}
|
|
916
|
+
}
|
|
883
917
|
async function atomicWrite(path, data) {
|
|
884
918
|
const tmp = join(dirname(path), `.${basename(path)}.tmp-${process.pid}-${Date.now()}`);
|
|
885
|
-
await writeFile(tmp, data, "utf8");
|
|
919
|
+
await (typeof data === "string" ? writeFile(tmp, data, "utf8") : writeFile(tmp, data));
|
|
886
920
|
try {
|
|
887
921
|
await rename(tmp, path);
|
|
888
922
|
} catch (error) {
|
|
@@ -900,7 +934,9 @@ var defaultFs = {
|
|
|
900
934
|
}
|
|
901
935
|
},
|
|
902
936
|
readFileBounded: (path, maxBytes) => readBounded(path, maxBytes),
|
|
903
|
-
|
|
937
|
+
readBytesBounded: (path, maxBytes) => readBoundedBytes(path, maxBytes),
|
|
938
|
+
writeFile: (path, data) => atomicWrite(path, data),
|
|
939
|
+
writeBytes: (path, data) => atomicWrite(path, data)
|
|
904
940
|
};
|
|
905
941
|
var LOCK_FILE_NAME = "verbatra.lock.json";
|
|
906
942
|
var CURRENT_VERSION = 1;
|
|
@@ -1147,6 +1183,7 @@ function createJsonFileAdapter(options) {
|
|
|
1147
1183
|
deriveEntry,
|
|
1148
1184
|
extractPlaceholders: extractPlaceholders2,
|
|
1149
1185
|
computeInvalidIcuKeys: computeInvalidIcuKeys2,
|
|
1186
|
+
validateMessage: validateMessage2,
|
|
1150
1187
|
validateTree,
|
|
1151
1188
|
buildWriteTree
|
|
1152
1189
|
} = options;
|
|
@@ -1154,6 +1191,8 @@ function createJsonFileAdapter(options) {
|
|
|
1154
1191
|
format,
|
|
1155
1192
|
canHandle,
|
|
1156
1193
|
extractPlaceholders: extractPlaceholders2,
|
|
1194
|
+
// Non-ICU formats supply no validator: every value is valid for their syntax.
|
|
1195
|
+
validateMessage: validateMessage2 ?? (() => true),
|
|
1157
1196
|
async read(filePath, locale) {
|
|
1158
1197
|
const outcome = await readBounded2(filePath);
|
|
1159
1198
|
if (outcome.kind === "not-a-file") {
|
|
@@ -1269,10 +1308,13 @@ function analyzeIcuValue(value) {
|
|
|
1269
1308
|
function extractPlaceholders(value) {
|
|
1270
1309
|
return analyzeIcuValue(value).placeholders;
|
|
1271
1310
|
}
|
|
1311
|
+
function validateMessage(value) {
|
|
1312
|
+
return analyzeIcuValue(value).valid;
|
|
1313
|
+
}
|
|
1272
1314
|
function computeInvalidIcuKeys(entries) {
|
|
1273
1315
|
const invalid = [];
|
|
1274
1316
|
for (const [key, entry] of entries) {
|
|
1275
|
-
if (!
|
|
1317
|
+
if (!validateMessage(entry.value)) {
|
|
1276
1318
|
invalid.push(key);
|
|
1277
1319
|
}
|
|
1278
1320
|
}
|
|
@@ -1286,7 +1328,8 @@ function createNextIntlJsonAdapter() {
|
|
|
1286
1328
|
const analysis = analyzeIcuValue(value);
|
|
1287
1329
|
return { placeholders: analysis.placeholders, isPlural: analysis.isPlural };
|
|
1288
1330
|
},
|
|
1289
|
-
computeInvalidIcuKeys
|
|
1331
|
+
computeInvalidIcuKeys,
|
|
1332
|
+
validateMessage
|
|
1290
1333
|
});
|
|
1291
1334
|
}
|
|
1292
1335
|
function assertNotMixed(tree) {
|
|
@@ -1453,6 +1496,33 @@ function selectProvider(config, createProvider = buildProvider) {
|
|
|
1453
1496
|
}
|
|
1454
1497
|
}
|
|
1455
1498
|
|
|
1499
|
+
// src/flow/locale-failure.ts
|
|
1500
|
+
function describeError(error) {
|
|
1501
|
+
if (error instanceof Error) {
|
|
1502
|
+
const code = error.code;
|
|
1503
|
+
return { code: typeof code === "string" ? code : "LOCALE_FAILED", message: error.message };
|
|
1504
|
+
}
|
|
1505
|
+
return { code: "LOCALE_FAILED", message: String(error) };
|
|
1506
|
+
}
|
|
1507
|
+
function failureSummary(locale, error) {
|
|
1508
|
+
return {
|
|
1509
|
+
locale,
|
|
1510
|
+
status: "failed",
|
|
1511
|
+
translated: [],
|
|
1512
|
+
unchanged: [],
|
|
1513
|
+
orphaned: [],
|
|
1514
|
+
invalidIcuSource: [],
|
|
1515
|
+
integrityMismatches: [],
|
|
1516
|
+
notices: [],
|
|
1517
|
+
error: describeError(error)
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
function partition(locales) {
|
|
1521
|
+
const succeeded = locales.filter((s) => s.status === "succeeded").map((s) => s.locale);
|
|
1522
|
+
const failed = locales.filter((s) => s.status === "failed").map((s) => s.locale);
|
|
1523
|
+
return { succeeded, failed };
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1456
1526
|
// src/flow/notices.ts
|
|
1457
1527
|
function isNotice(value) {
|
|
1458
1528
|
return typeof value === "object" && value !== null && typeof value.code === "string" && typeof value.message === "string";
|
|
@@ -1578,27 +1648,7 @@ function computeLockEntries(params, merged, withheld) {
|
|
|
1578
1648
|
return lockEntries;
|
|
1579
1649
|
}
|
|
1580
1650
|
|
|
1581
|
-
// src/flow/
|
|
1582
|
-
function describeError(error) {
|
|
1583
|
-
if (error instanceof Error) {
|
|
1584
|
-
const code = error.code;
|
|
1585
|
-
return { code: typeof code === "string" ? code : "LOCALE_FAILED", message: error.message };
|
|
1586
|
-
}
|
|
1587
|
-
return { code: "LOCALE_FAILED", message: String(error) };
|
|
1588
|
-
}
|
|
1589
|
-
function failureSummary(locale, error) {
|
|
1590
|
-
return {
|
|
1591
|
-
locale,
|
|
1592
|
-
status: "failed",
|
|
1593
|
-
translated: [],
|
|
1594
|
-
unchanged: [],
|
|
1595
|
-
orphaned: [],
|
|
1596
|
-
invalidIcuSource: [],
|
|
1597
|
-
integrityMismatches: [],
|
|
1598
|
-
notices: [],
|
|
1599
|
-
error: describeError(error)
|
|
1600
|
-
};
|
|
1601
|
-
}
|
|
1651
|
+
// src/flow/source.ts
|
|
1602
1652
|
async function readSource(config, cwd, fs, adapter) {
|
|
1603
1653
|
const sourcePath = localeFilePath(cwd, config.files.pattern, config.sourceLocale);
|
|
1604
1654
|
if (!await fs.fileExists(sourcePath)) {
|
|
@@ -1617,6 +1667,8 @@ async function readSource(config, cwd, fs, adapter) {
|
|
|
1617
1667
|
);
|
|
1618
1668
|
}
|
|
1619
1669
|
}
|
|
1670
|
+
|
|
1671
|
+
// src/flow/translate-project.ts
|
|
1620
1672
|
async function translate2(input, deps = {}) {
|
|
1621
1673
|
const config = input.config;
|
|
1622
1674
|
const cwd = input.cwd ?? process.cwd();
|
|
@@ -1655,12 +1707,594 @@ async function translate2(input, deps = {}) {
|
|
|
1655
1707
|
summaries.push(failureSummary(targetLocale, error));
|
|
1656
1708
|
}
|
|
1657
1709
|
}
|
|
1658
|
-
|
|
1710
|
+
const { succeeded, failed } = partition(summaries);
|
|
1711
|
+
return { dryRun, locales: summaries, succeeded, failed };
|
|
1659
1712
|
}
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1713
|
+
var ExchangeError = class extends Error {
|
|
1714
|
+
code;
|
|
1715
|
+
constructor(code, message) {
|
|
1716
|
+
super(message);
|
|
1717
|
+
this.name = "ExchangeError";
|
|
1718
|
+
this.code = code;
|
|
1719
|
+
}
|
|
1720
|
+
};
|
|
1721
|
+
var INSTRUCTIONS_LINES = [
|
|
1722
|
+
"How to use this workbook",
|
|
1723
|
+
"",
|
|
1724
|
+
"1. There is one sheet per language. Open the sheet named for the language you are translating.",
|
|
1725
|
+
"2. Fill ONLY the 'Translation' column. Leave every other column unchanged.",
|
|
1726
|
+
"3. An empty 'Translation' cell means 'not translated yet'. It is skipped, never written as an empty string.",
|
|
1727
|
+
"4. Keep placeholders exactly as they appear. A token such as {name} or {count} must stay verbatim,",
|
|
1728
|
+
" in your translation, with the same spelling. Do not translate or remove it.",
|
|
1729
|
+
"5. For ICU messages (for example {count, plural, one {# item} other {# items}}), keep the ICU",
|
|
1730
|
+
" structure and the argument names exactly. Translate only the human-readable text.",
|
|
1731
|
+
"6. Do not edit the 'Key' column or the hidden 'Source hash' column. They map your translation",
|
|
1732
|
+
" back to the right string. You may sort or filter rows freely; mapping does not depend on row order.",
|
|
1733
|
+
"",
|
|
1734
|
+
"Status values:",
|
|
1735
|
+
" new - this string has no translation yet.",
|
|
1736
|
+
" changed - the source string changed and the translation needs updating.",
|
|
1737
|
+
"",
|
|
1738
|
+
"When you are done, save the file and send it back. verbatra checks every value on import:",
|
|
1739
|
+
"a value with a broken placeholder, invalid ICU, or a source that changed since export is",
|
|
1740
|
+
"withheld (not written) and reported so you can fix and resubmit it."
|
|
1741
|
+
];
|
|
1742
|
+
var COLUMN = {
|
|
1743
|
+
key: 1,
|
|
1744
|
+
source: 2,
|
|
1745
|
+
current: 3,
|
|
1746
|
+
status: 4,
|
|
1747
|
+
translation: 5,
|
|
1748
|
+
sourceHash: 6
|
|
1749
|
+
};
|
|
1750
|
+
var HEADERS = [
|
|
1751
|
+
"Key",
|
|
1752
|
+
"Source",
|
|
1753
|
+
"Current translation",
|
|
1754
|
+
"Status",
|
|
1755
|
+
"Translation",
|
|
1756
|
+
"Source hash"
|
|
1757
|
+
];
|
|
1758
|
+
var HEADER_ROW = 1;
|
|
1759
|
+
var INSTRUCTIONS_SHEET_NAME = "Instructions";
|
|
1760
|
+
var READ_ONLY_FILL = {
|
|
1761
|
+
type: "pattern",
|
|
1762
|
+
pattern: "solid",
|
|
1763
|
+
fgColor: { argb: "FFF1F3F5" }
|
|
1764
|
+
};
|
|
1765
|
+
var HEADER_FILL = {
|
|
1766
|
+
type: "pattern",
|
|
1767
|
+
pattern: "solid",
|
|
1768
|
+
fgColor: { argb: "FFDDE3EA" }
|
|
1769
|
+
};
|
|
1770
|
+
var COLUMN_WIDTHS = {
|
|
1771
|
+
[COLUMN.key]: 36,
|
|
1772
|
+
[COLUMN.source]: 50,
|
|
1773
|
+
[COLUMN.current]: 50,
|
|
1774
|
+
[COLUMN.status]: 12,
|
|
1775
|
+
[COLUMN.translation]: 50
|
|
1776
|
+
};
|
|
1777
|
+
function styleHeader(sheet) {
|
|
1778
|
+
const header = sheet.getRow(HEADER_ROW);
|
|
1779
|
+
HEADERS.forEach((label, index) => {
|
|
1780
|
+
const cell = header.getCell(index + 1);
|
|
1781
|
+
cell.value = label;
|
|
1782
|
+
cell.font = { bold: true };
|
|
1783
|
+
cell.fill = HEADER_FILL;
|
|
1784
|
+
});
|
|
1785
|
+
header.commit();
|
|
1786
|
+
}
|
|
1787
|
+
function applyColumnGeometry(sheet) {
|
|
1788
|
+
for (const [column, width] of Object.entries(COLUMN_WIDTHS)) {
|
|
1789
|
+
sheet.getColumn(Number(column)).width = width;
|
|
1790
|
+
}
|
|
1791
|
+
sheet.getColumn(COLUMN.sourceHash).hidden = true;
|
|
1792
|
+
sheet.views = [{ state: "frozen", ySplit: HEADER_ROW }];
|
|
1793
|
+
}
|
|
1794
|
+
function writeRow(sheet, sheetRow) {
|
|
1795
|
+
const row = sheet.addRow([]);
|
|
1796
|
+
row.getCell(COLUMN.key).value = sheetRow.key;
|
|
1797
|
+
row.getCell(COLUMN.source).value = sheetRow.source;
|
|
1798
|
+
row.getCell(COLUMN.current).value = sheetRow.currentTarget;
|
|
1799
|
+
row.getCell(COLUMN.status).value = sheetRow.status;
|
|
1800
|
+
row.getCell(COLUMN.translation).value = sheetRow.translation === "" ? null : sheetRow.translation;
|
|
1801
|
+
row.getCell(COLUMN.sourceHash).value = sheetRow.sourceHash;
|
|
1802
|
+
for (let column = COLUMN.key; column <= COLUMN.sourceHash; column += 1) {
|
|
1803
|
+
const cell = row.getCell(column);
|
|
1804
|
+
cell.protection = { locked: column !== COLUMN.translation };
|
|
1805
|
+
if (column !== COLUMN.translation) {
|
|
1806
|
+
cell.fill = READ_ONLY_FILL;
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
row.commit();
|
|
1810
|
+
}
|
|
1811
|
+
var MAX_WORKSHEET_NAME_LENGTH = 31;
|
|
1812
|
+
var FORBIDDEN_WORKSHEET_NAME_CHARS = /[:\\/?*[\]]/;
|
|
1813
|
+
function assertValidWorksheetName(locale) {
|
|
1814
|
+
if (locale.length === 0 || locale.length > MAX_WORKSHEET_NAME_LENGTH) {
|
|
1815
|
+
throw new ExchangeError(
|
|
1816
|
+
"WORKBOOK_INVALID",
|
|
1817
|
+
`The locale "${locale}" cannot be an Excel worksheet name: it must be 1 to ${MAX_WORKSHEET_NAME_LENGTH} characters.`
|
|
1818
|
+
);
|
|
1819
|
+
}
|
|
1820
|
+
if (FORBIDDEN_WORKSHEET_NAME_CHARS.test(locale)) {
|
|
1821
|
+
throw new ExchangeError(
|
|
1822
|
+
"WORKBOOK_INVALID",
|
|
1823
|
+
`The locale "${locale}" cannot be an Excel worksheet name: it must not contain any of : \\ / ? * [ ].`
|
|
1824
|
+
);
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
async function buildDataSheet(workbook, sheet) {
|
|
1828
|
+
assertValidWorksheetName(sheet.locale);
|
|
1829
|
+
const worksheet = workbook.addWorksheet(sheet.locale);
|
|
1830
|
+
styleHeader(worksheet);
|
|
1831
|
+
for (const row of sheet.rows) {
|
|
1832
|
+
writeRow(worksheet, row);
|
|
1833
|
+
}
|
|
1834
|
+
applyColumnGeometry(worksheet);
|
|
1835
|
+
await worksheet.protect("", {
|
|
1836
|
+
spinCount: 0,
|
|
1837
|
+
selectLockedCells: true,
|
|
1838
|
+
selectUnlockedCells: true,
|
|
1839
|
+
formatColumns: true,
|
|
1840
|
+
sort: true,
|
|
1841
|
+
autoFilter: true
|
|
1842
|
+
});
|
|
1843
|
+
}
|
|
1844
|
+
function buildInstructionsSheet(workbook) {
|
|
1845
|
+
const sheet = workbook.addWorksheet(INSTRUCTIONS_SHEET_NAME);
|
|
1846
|
+
sheet.getColumn(1).width = 110;
|
|
1847
|
+
for (const line of INSTRUCTIONS_LINES) {
|
|
1848
|
+
sheet.addRow([line]);
|
|
1849
|
+
}
|
|
1850
|
+
sheet.getRow(1).font = { bold: true };
|
|
1851
|
+
}
|
|
1852
|
+
async function buildWorkbook(model) {
|
|
1853
|
+
const workbook = new ExcelJS.Workbook();
|
|
1854
|
+
buildInstructionsSheet(workbook);
|
|
1855
|
+
for (const sheet of model.sheets) {
|
|
1856
|
+
await buildDataSheet(workbook, sheet);
|
|
1857
|
+
}
|
|
1858
|
+
try {
|
|
1859
|
+
const buffer = await workbook.xlsx.writeBuffer();
|
|
1860
|
+
const view = buffer;
|
|
1861
|
+
return Uint8Array.prototype.slice.call(view);
|
|
1862
|
+
} catch {
|
|
1863
|
+
throw new ExchangeError("WORKBOOK_INVALID", "The workbook could not be serialized.");
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
var DEFAULT_WORKBOOK_LIMITS = {
|
|
1867
|
+
maxDecompressedBytes: 64 * 1024 * 1024,
|
|
1868
|
+
maxEntryCount: 1024,
|
|
1869
|
+
maxSheetCount: 256,
|
|
1870
|
+
maxRowsPerSheet: 1e5,
|
|
1871
|
+
maxCellsPerRow: 64
|
|
1872
|
+
};
|
|
1873
|
+
function assertNoDoctype(name, xml) {
|
|
1874
|
+
if (/<!DOCTYPE/i.test(xml) || /<!ENTITY/i.test(xml)) {
|
|
1875
|
+
throw new ExchangeError(
|
|
1876
|
+
"WORKBOOK_INVALID",
|
|
1877
|
+
`A workbook XML part (${name}) declares a DTD or entity, which is not permitted.`
|
|
1878
|
+
);
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
function declaredSize(file) {
|
|
1882
|
+
const data = file._data;
|
|
1883
|
+
const size = data?.uncompressedSize;
|
|
1884
|
+
return typeof size === "number" && Number.isFinite(size) ? size : void 0;
|
|
1885
|
+
}
|
|
1886
|
+
async function guardWorkbookBytes(bytes, limits) {
|
|
1887
|
+
let zip;
|
|
1888
|
+
try {
|
|
1889
|
+
zip = await JSZip.loadAsync(bytes);
|
|
1890
|
+
} catch {
|
|
1891
|
+
throw new ExchangeError("WORKBOOK_INVALID", "The workbook is not a readable xlsx container.");
|
|
1892
|
+
}
|
|
1893
|
+
const files = Object.values(zip.files).filter((file) => !file.dir);
|
|
1894
|
+
if (files.length > limits.maxEntryCount) {
|
|
1895
|
+
throw new ExchangeError(
|
|
1896
|
+
"WORKBOOK_INVALID",
|
|
1897
|
+
`The workbook has more than the maximum of ${limits.maxEntryCount} entries.`
|
|
1898
|
+
);
|
|
1899
|
+
}
|
|
1900
|
+
let declaredTotal = 0;
|
|
1901
|
+
for (const file of files) {
|
|
1902
|
+
const size = declaredSize(file);
|
|
1903
|
+
if (size !== void 0) {
|
|
1904
|
+
declaredTotal += size;
|
|
1905
|
+
if (declaredTotal > limits.maxDecompressedBytes) {
|
|
1906
|
+
throw new ExchangeError(
|
|
1907
|
+
"WORKBOOK_INVALID",
|
|
1908
|
+
`The workbook decompresses to more than the maximum of ${limits.maxDecompressedBytes} bytes.`
|
|
1909
|
+
);
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
let actualTotal = 0;
|
|
1914
|
+
for (const file of files) {
|
|
1915
|
+
let content;
|
|
1916
|
+
try {
|
|
1917
|
+
content = await file.async("string");
|
|
1918
|
+
} catch {
|
|
1919
|
+
throw new ExchangeError("WORKBOOK_INVALID", "A workbook entry could not be decompressed.");
|
|
1920
|
+
}
|
|
1921
|
+
actualTotal += Buffer.byteLength(content, "utf8");
|
|
1922
|
+
if (actualTotal > limits.maxDecompressedBytes) {
|
|
1923
|
+
throw new ExchangeError(
|
|
1924
|
+
"WORKBOOK_INVALID",
|
|
1925
|
+
`The workbook decompresses to more than the maximum of ${limits.maxDecompressedBytes} bytes.`
|
|
1926
|
+
);
|
|
1927
|
+
}
|
|
1928
|
+
assertNoDoctype(file.name, content);
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
var rowSchema = z.object({
|
|
1932
|
+
key: z.string().min(1),
|
|
1933
|
+
source: z.string(),
|
|
1934
|
+
currentTarget: z.string(),
|
|
1935
|
+
status: z.enum(["new", "changed"]),
|
|
1936
|
+
sourceHash: z.string(),
|
|
1937
|
+
translation: z.string()
|
|
1938
|
+
});
|
|
1939
|
+
function cellString(cell) {
|
|
1940
|
+
const value = cell.value;
|
|
1941
|
+
if (value === null || value === void 0) {
|
|
1942
|
+
return "";
|
|
1943
|
+
}
|
|
1944
|
+
if (typeof value === "string") {
|
|
1945
|
+
return value;
|
|
1946
|
+
}
|
|
1947
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
1948
|
+
return String(value);
|
|
1949
|
+
}
|
|
1950
|
+
return typeof cell.text === "string" ? cell.text : "";
|
|
1951
|
+
}
|
|
1952
|
+
function assertHeader(sheet) {
|
|
1953
|
+
const header = sheet.getRow(HEADER_ROW);
|
|
1954
|
+
const key = cellString(header.getCell(COLUMN.key));
|
|
1955
|
+
const sourceHash = cellString(header.getCell(COLUMN.sourceHash));
|
|
1956
|
+
if (key !== HEADERS[COLUMN.key - 1] || sourceHash !== HEADERS[COLUMN.sourceHash - 1]) {
|
|
1957
|
+
throw new ExchangeError(
|
|
1958
|
+
"WORKBOOK_INVALID",
|
|
1959
|
+
`The sheet "${sheet.name}" is missing the expected Key and Source hash columns.`
|
|
1960
|
+
);
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
function parseRow(sheet, row) {
|
|
1964
|
+
const candidate = {
|
|
1965
|
+
key: cellString(row.getCell(COLUMN.key)),
|
|
1966
|
+
source: cellString(row.getCell(COLUMN.source)),
|
|
1967
|
+
currentTarget: cellString(row.getCell(COLUMN.current)),
|
|
1968
|
+
status: cellString(row.getCell(COLUMN.status)),
|
|
1969
|
+
sourceHash: cellString(row.getCell(COLUMN.sourceHash)),
|
|
1970
|
+
translation: cellString(row.getCell(COLUMN.translation))
|
|
1971
|
+
};
|
|
1972
|
+
const result = rowSchema.safeParse(candidate);
|
|
1973
|
+
if (!result.success) {
|
|
1974
|
+
throw new ExchangeError(
|
|
1975
|
+
"WORKBOOK_INVALID",
|
|
1976
|
+
`The sheet "${sheet.name}" has a row with a missing key or an unrecognized status.`
|
|
1977
|
+
);
|
|
1978
|
+
}
|
|
1979
|
+
return result.data;
|
|
1980
|
+
}
|
|
1981
|
+
function readDataSheet(sheet, limits) {
|
|
1982
|
+
assertHeader(sheet);
|
|
1983
|
+
if (sheet.rowCount - HEADER_ROW > limits.maxRowsPerSheet) {
|
|
1984
|
+
throw new ExchangeError(
|
|
1985
|
+
"WORKBOOK_INVALID",
|
|
1986
|
+
`The sheet "${sheet.name}" has more than the maximum of ${limits.maxRowsPerSheet} rows.`
|
|
1987
|
+
);
|
|
1988
|
+
}
|
|
1989
|
+
const rows = [];
|
|
1990
|
+
for (let rowNumber = HEADER_ROW + 1; rowNumber <= sheet.rowCount; rowNumber += 1) {
|
|
1991
|
+
const row = sheet.getRow(rowNumber);
|
|
1992
|
+
if (row.cellCount > limits.maxCellsPerRow) {
|
|
1993
|
+
throw new ExchangeError(
|
|
1994
|
+
"WORKBOOK_INVALID",
|
|
1995
|
+
`The sheet "${sheet.name}" has a row with more than the maximum of ${limits.maxCellsPerRow} cells.`
|
|
1996
|
+
);
|
|
1997
|
+
}
|
|
1998
|
+
if (cellString(row.getCell(COLUMN.key)) === "") {
|
|
1999
|
+
continue;
|
|
2000
|
+
}
|
|
2001
|
+
rows.push(parseRow(sheet, row));
|
|
2002
|
+
}
|
|
2003
|
+
return { locale: sheet.name, rows };
|
|
2004
|
+
}
|
|
2005
|
+
async function loadWorkbook(bytes) {
|
|
2006
|
+
const workbook = new ExcelJS.Workbook();
|
|
2007
|
+
try {
|
|
2008
|
+
const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
2009
|
+
await workbook.xlsx.load(buffer);
|
|
2010
|
+
} catch {
|
|
2011
|
+
throw new ExchangeError("WORKBOOK_INVALID", "The workbook could not be parsed as xlsx.");
|
|
2012
|
+
}
|
|
2013
|
+
return workbook;
|
|
2014
|
+
}
|
|
2015
|
+
async function readWorkbook(bytes, options = {}) {
|
|
2016
|
+
const limits = options.limits ?? DEFAULT_WORKBOOK_LIMITS;
|
|
2017
|
+
await guardWorkbookBytes(bytes, limits);
|
|
2018
|
+
const workbook = await loadWorkbook(bytes);
|
|
2019
|
+
if (workbook.worksheets.length > limits.maxSheetCount) {
|
|
2020
|
+
throw new ExchangeError(
|
|
2021
|
+
"WORKBOOK_INVALID",
|
|
2022
|
+
`The workbook has more than the maximum of ${limits.maxSheetCount} sheets.`
|
|
2023
|
+
);
|
|
2024
|
+
}
|
|
2025
|
+
const sheets = [];
|
|
2026
|
+
for (const sheet of workbook.worksheets) {
|
|
2027
|
+
if (sheet.name === INSTRUCTIONS_SHEET_NAME) {
|
|
2028
|
+
continue;
|
|
2029
|
+
}
|
|
2030
|
+
sheets.push(readDataSheet(sheet, limits));
|
|
2031
|
+
}
|
|
2032
|
+
return { sheets };
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
// src/flow/workbook/export-workbook.ts
|
|
2036
|
+
var DEFAULT_WORKBOOK_PATH = "verbatra-translations.xlsx";
|
|
2037
|
+
async function readTarget2(cwd, config, adapter, fs, locale) {
|
|
2038
|
+
const path = localeFilePath(cwd, config.files.pattern, locale);
|
|
2039
|
+
if (!await fs.fileExists(path)) {
|
|
2040
|
+
return { locale, namespace: "", format: config.format, entries: /* @__PURE__ */ new Map() };
|
|
2041
|
+
}
|
|
2042
|
+
return (await adapter.read(path, locale)).resource;
|
|
2043
|
+
}
|
|
2044
|
+
function buildRows(source, target, baseline, includeUnchanged) {
|
|
2045
|
+
const diff = diffResources(source, target, { baseline });
|
|
2046
|
+
const rows = [];
|
|
2047
|
+
const add = (keys, status) => {
|
|
2048
|
+
for (const key of keys) {
|
|
2049
|
+
const sourceEntry = source.entries.get(key);
|
|
2050
|
+
if (sourceEntry === void 0) {
|
|
2051
|
+
continue;
|
|
2052
|
+
}
|
|
2053
|
+
rows.push({
|
|
2054
|
+
key,
|
|
2055
|
+
source: sourceEntry.value,
|
|
2056
|
+
currentTarget: target.entries.get(key)?.value ?? "",
|
|
2057
|
+
status,
|
|
2058
|
+
sourceHash: contentHash(sourceEntry),
|
|
2059
|
+
translation: ""
|
|
2060
|
+
});
|
|
2061
|
+
}
|
|
2062
|
+
};
|
|
2063
|
+
add(diff.missing, "new");
|
|
2064
|
+
add(diff.changed, "changed");
|
|
2065
|
+
if (includeUnchanged) {
|
|
2066
|
+
add(diff.unchanged, "changed");
|
|
2067
|
+
}
|
|
2068
|
+
return [...rows].sort((a, b) => a.key < b.key ? -1 : 1);
|
|
2069
|
+
}
|
|
2070
|
+
function selectedLocales(config, requested) {
|
|
2071
|
+
if (requested === void 0) {
|
|
2072
|
+
return config.targetLocales;
|
|
2073
|
+
}
|
|
2074
|
+
const wanted = new Set(requested);
|
|
2075
|
+
return config.targetLocales.filter((locale) => wanted.has(locale));
|
|
2076
|
+
}
|
|
2077
|
+
async function exportWorkbook(input, deps = {}) {
|
|
2078
|
+
const config = input.config;
|
|
2079
|
+
const cwd = input.cwd ?? process.cwd();
|
|
2080
|
+
const fs = deps.fs ?? defaultFs;
|
|
2081
|
+
const adapter = selectAdapter(config.format, deps.adapterRegistry);
|
|
2082
|
+
const source = await readSource(config, cwd, fs, adapter);
|
|
2083
|
+
const lock = await readLockFile(lockFilePath(cwd), fs);
|
|
2084
|
+
const locales = selectedLocales(config, input.locales);
|
|
2085
|
+
const sheets = await Promise.all(
|
|
2086
|
+
locales.map(async (locale) => {
|
|
2087
|
+
const target = await readTarget2(cwd, config, adapter, fs, locale);
|
|
2088
|
+
const rows = buildRows(
|
|
2089
|
+
source.resource,
|
|
2090
|
+
target,
|
|
2091
|
+
baselineFor(lock, locale),
|
|
2092
|
+
input.includeUnchanged ?? false
|
|
2093
|
+
);
|
|
2094
|
+
return { locale, rows };
|
|
2095
|
+
})
|
|
2096
|
+
);
|
|
2097
|
+
const model = { sheets };
|
|
2098
|
+
const bytes = await buildWorkbook(model);
|
|
2099
|
+
const path = resolve(cwd, input.out ?? DEFAULT_WORKBOOK_PATH);
|
|
2100
|
+
await fs.writeBytes(path, bytes);
|
|
2101
|
+
return {
|
|
2102
|
+
path,
|
|
2103
|
+
locales: sheets.map((sheet) => ({ locale: sheet.locale, rows: sheet.rows.length }))
|
|
2104
|
+
};
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
// src/flow/workbook/import-locale.ts
|
|
2108
|
+
var UnknownKeyError = class extends Error {
|
|
2109
|
+
key;
|
|
2110
|
+
constructor(key) {
|
|
2111
|
+
super(`The workbook has a row with key "${key}" that maps to no known source or target key.`);
|
|
2112
|
+
this.name = "UnknownKeyError";
|
|
2113
|
+
this.key = key;
|
|
2114
|
+
}
|
|
2115
|
+
};
|
|
2116
|
+
function isUnknownKey(row, source, target) {
|
|
2117
|
+
return !source.entries.has(row.key) && !target.entries.has(row.key);
|
|
2118
|
+
}
|
|
2119
|
+
function judge(row, sourceEntry, adapter) {
|
|
2120
|
+
if (contentHash(sourceEntry) !== row.sourceHash) {
|
|
2121
|
+
return "drift";
|
|
2122
|
+
}
|
|
2123
|
+
const integrity = checkPlaceholders(
|
|
2124
|
+
sourceEntry.placeholders,
|
|
2125
|
+
adapter.extractPlaceholders(row.translation)
|
|
2126
|
+
);
|
|
2127
|
+
if (!integrity.matches) {
|
|
2128
|
+
return "placeholder";
|
|
2129
|
+
}
|
|
2130
|
+
if (!adapter.validateMessage(row.translation)) {
|
|
2131
|
+
return "icu";
|
|
2132
|
+
}
|
|
2133
|
+
return void 0;
|
|
2134
|
+
}
|
|
2135
|
+
function classifyRows(params, buckets) {
|
|
2136
|
+
for (const row of params.sheet.rows) {
|
|
2137
|
+
if (row.translation === "") {
|
|
2138
|
+
continue;
|
|
2139
|
+
}
|
|
2140
|
+
if (isUnknownKey(row, params.source, params.target)) {
|
|
2141
|
+
throw new UnknownKeyError(row.key);
|
|
2142
|
+
}
|
|
2143
|
+
const sourceEntry = params.source.entries.get(row.key);
|
|
2144
|
+
if (sourceEntry === void 0) {
|
|
2145
|
+
continue;
|
|
2146
|
+
}
|
|
2147
|
+
const reason = judge(row, sourceEntry, params.adapter);
|
|
2148
|
+
if (reason === void 0) {
|
|
2149
|
+
buckets.accepted.set(row.key, { value: row.translation, source: sourceEntry });
|
|
2150
|
+
} else {
|
|
2151
|
+
buckets.mismatches.push(row.key);
|
|
2152
|
+
buckets.withheld.add(row.key);
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
function importLocale(params) {
|
|
2157
|
+
const diff = diffResources(params.source, params.target, { baseline: params.baseline });
|
|
2158
|
+
const buckets = { accepted: /* @__PURE__ */ new Map(), mismatches: [], withheld: /* @__PURE__ */ new Set() };
|
|
2159
|
+
classifyRows(params, buckets);
|
|
2160
|
+
const rowKeys = new Set(params.sheet.rows.map((row) => row.key));
|
|
2161
|
+
const invalidIcuSource = [...new Set(params.sourceInvalidIcuKeys)].filter((key) => rowKeys.has(key)).sort();
|
|
2162
|
+
const summary = {
|
|
2163
|
+
locale: params.sheet.locale,
|
|
2164
|
+
status: "succeeded",
|
|
2165
|
+
translated: [...buckets.accepted.keys()].sort(),
|
|
2166
|
+
unchanged: diff.unchanged,
|
|
2167
|
+
orphaned: diff.orphaned,
|
|
2168
|
+
invalidIcuSource,
|
|
2169
|
+
integrityMismatches: [...buckets.mismatches].sort(),
|
|
2170
|
+
notices: []
|
|
2171
|
+
};
|
|
2172
|
+
return { summary, accepted: buckets.accepted, withheld: buckets.withheld };
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
// src/flow/workbook/import-workbook.ts
|
|
2176
|
+
var MAX_WORKBOOK_FILE_BYTES = 64 * 1024 * 1024;
|
|
2177
|
+
async function readWorkbookBytes(path, fs) {
|
|
2178
|
+
const read = await fs.readBytesBounded(path, MAX_WORKBOOK_FILE_BYTES);
|
|
2179
|
+
if (read.kind === "missing") {
|
|
2180
|
+
throw new SdkError("SOURCE_UNREADABLE", `The workbook was not found at ${path}.`);
|
|
2181
|
+
}
|
|
2182
|
+
if (read.kind === "too-large") {
|
|
2183
|
+
throw new SdkError(
|
|
2184
|
+
"SOURCE_INVALID",
|
|
2185
|
+
`The workbook at ${path} exceeds the maximum allowed size of ${MAX_WORKBOOK_FILE_BYTES} bytes.`
|
|
2186
|
+
);
|
|
2187
|
+
}
|
|
2188
|
+
return read.bytes;
|
|
2189
|
+
}
|
|
2190
|
+
async function readTarget3(cwd, config, adapter, fs, locale) {
|
|
2191
|
+
const path = localeFilePath(cwd, config.files.pattern, locale);
|
|
2192
|
+
if (!await fs.fileExists(path)) {
|
|
2193
|
+
return { locale, namespace: "", format: config.format, entries: /* @__PURE__ */ new Map() };
|
|
2194
|
+
}
|
|
2195
|
+
return (await adapter.read(path, locale)).resource;
|
|
2196
|
+
}
|
|
2197
|
+
function mergeAccepted(target, accepted) {
|
|
2198
|
+
const merged = new Map(target.entries);
|
|
2199
|
+
for (const [key, { value, source }] of accepted) {
|
|
2200
|
+
merged.set(key, { ...source, value, namespace: target.namespace });
|
|
2201
|
+
}
|
|
2202
|
+
return merged;
|
|
2203
|
+
}
|
|
2204
|
+
function computeLockEntries2(source, merged, baseline, withheld) {
|
|
2205
|
+
const entries = {};
|
|
2206
|
+
for (const key of merged.keys()) {
|
|
2207
|
+
const sourceEntry = source.entries.get(key);
|
|
2208
|
+
if (sourceEntry === void 0) {
|
|
2209
|
+
continue;
|
|
2210
|
+
}
|
|
2211
|
+
if (withheld.has(key)) {
|
|
2212
|
+
const prior = baseline.get(key);
|
|
2213
|
+
if (prior !== void 0) {
|
|
2214
|
+
entries[key] = prior;
|
|
2215
|
+
}
|
|
2216
|
+
continue;
|
|
2217
|
+
}
|
|
2218
|
+
entries[key] = contentHash(sourceEntry);
|
|
2219
|
+
}
|
|
2220
|
+
return entries;
|
|
2221
|
+
}
|
|
2222
|
+
async function runSheet(ctx, sheet, lock) {
|
|
2223
|
+
if (!ctx.config.targetLocales.includes(sheet.locale)) {
|
|
2224
|
+
throw new SdkError(
|
|
2225
|
+
"CONFIG_INVALID",
|
|
2226
|
+
`The workbook has a sheet for locale "${sheet.locale}", which is not a configured target locale.`
|
|
2227
|
+
);
|
|
2228
|
+
}
|
|
2229
|
+
const target = await readTarget3(ctx.cwd, ctx.config, ctx.adapter, ctx.fs, sheet.locale);
|
|
2230
|
+
const baseline = baselineFor(lock, sheet.locale);
|
|
2231
|
+
const { summary, accepted, withheld } = importLocale({
|
|
2232
|
+
sheet,
|
|
2233
|
+
source: ctx.source,
|
|
2234
|
+
target,
|
|
2235
|
+
baseline,
|
|
2236
|
+
adapter: ctx.adapter,
|
|
2237
|
+
sourceInvalidIcuKeys: ctx.sourceInvalidIcuKeys
|
|
2238
|
+
});
|
|
2239
|
+
if (ctx.dryRun) {
|
|
2240
|
+
return { summary, lockEntries: {} };
|
|
2241
|
+
}
|
|
2242
|
+
const merged = mergeAccepted(target, accepted);
|
|
2243
|
+
if (accepted.size > 0) {
|
|
2244
|
+
const path = localeFilePath(ctx.cwd, ctx.config.files.pattern, sheet.locale);
|
|
2245
|
+
await ctx.adapter.write(
|
|
2246
|
+
{
|
|
2247
|
+
locale: sheet.locale,
|
|
2248
|
+
namespace: target.namespace,
|
|
2249
|
+
format: ctx.config.format,
|
|
2250
|
+
entries: merged
|
|
2251
|
+
},
|
|
2252
|
+
path
|
|
2253
|
+
);
|
|
2254
|
+
}
|
|
2255
|
+
return { summary, lockEntries: computeLockEntries2(ctx.source, merged, baseline, withheld) };
|
|
2256
|
+
}
|
|
2257
|
+
async function importWorkbook(input, deps = {}) {
|
|
2258
|
+
const config = input.config;
|
|
2259
|
+
const cwd = input.cwd ?? process.cwd();
|
|
2260
|
+
const dryRun = input.dryRun ?? false;
|
|
2261
|
+
const fs = deps.fs ?? defaultFs;
|
|
2262
|
+
const adapter = selectAdapter(config.format, deps.adapterRegistry);
|
|
2263
|
+
const source = await readSource(config, cwd, fs, adapter);
|
|
2264
|
+
const workbookPath = resolve(cwd, input.workbook);
|
|
2265
|
+
const bytes = await readWorkbookBytes(workbookPath, fs);
|
|
2266
|
+
let data;
|
|
2267
|
+
try {
|
|
2268
|
+
data = await readWorkbook(bytes);
|
|
2269
|
+
} catch (error) {
|
|
2270
|
+
throw new SdkError("SOURCE_INVALID", error.message);
|
|
2271
|
+
}
|
|
2272
|
+
const lockPath = lockFilePath(cwd);
|
|
2273
|
+
let lock = await readLockFile(lockPath, fs);
|
|
2274
|
+
const ctx = {
|
|
2275
|
+
config,
|
|
2276
|
+
cwd,
|
|
2277
|
+
adapter,
|
|
2278
|
+
fs,
|
|
2279
|
+
source: source.resource,
|
|
2280
|
+
sourceInvalidIcuKeys: source.invalidIcuKeys,
|
|
2281
|
+
dryRun
|
|
2282
|
+
};
|
|
2283
|
+
const summaries = [];
|
|
2284
|
+
for (const sheet of data.sheets) {
|
|
2285
|
+
try {
|
|
2286
|
+
const { summary, lockEntries } = await runSheet(ctx, sheet, lock);
|
|
2287
|
+
if (!dryRun) {
|
|
2288
|
+
lock = updateLockLocale(lock, sheet.locale, lockEntries);
|
|
2289
|
+
await writeLockFile(lockPath, lock, fs);
|
|
2290
|
+
}
|
|
2291
|
+
summaries.push(summary);
|
|
2292
|
+
} catch (error) {
|
|
2293
|
+
summaries.push(failureSummary(sheet.locale, error));
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
const { succeeded, failed } = partition(summaries);
|
|
2297
|
+
return { dryRun, locales: summaries, succeeded, failed };
|
|
1664
2298
|
}
|
|
1665
2299
|
var defaultCreateWatcher = (paths) => {
|
|
1666
2300
|
const fsWatcher = watch$1([...paths], { persistent: true, ignoreInitial: true });
|
|
@@ -1768,6 +2402,6 @@ async function watch(input, deps = {}) {
|
|
|
1768
2402
|
return { stop };
|
|
1769
2403
|
}
|
|
1770
2404
|
|
|
1771
|
-
export { SdkError, defineConfig, loadConfig, translate2 as translate, verbatraConfigSchema, watch };
|
|
2405
|
+
export { DEFAULT_WORKBOOK_PATH, SdkError, defineConfig, exportWorkbook, importWorkbook, loadConfig, translate2 as translate, verbatraConfigSchema, watch };
|
|
1772
2406
|
//# sourceMappingURL=index.js.map
|
|
1773
2407
|
//# sourceMappingURL=index.js.map
|