prosebook 0.3.0 → 0.5.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 +357 -26
- package/dist/bind-BIZNv8nU.js +62 -0
- package/dist/bind-BIZNv8nU.js.map +1 -0
- package/dist/bind-ZS86jHXk.d.ts +31 -0
- package/dist/bind-ZS86jHXk.d.ts.map +1 -0
- package/dist/bun.d.ts +12 -3
- package/dist/bun.d.ts.map +1 -1
- package/dist/bun.js +77 -26
- package/dist/bun.js.map +1 -1
- package/dist/{bundle-KD-DiiFa.d.ts → bundle-CLHY_97D.d.ts} +3 -3
- package/dist/bundle-CLHY_97D.d.ts.map +1 -0
- package/dist/bundle-Ut6Y2X6N.js +217 -0
- package/dist/bundle-Ut6Y2X6N.js.map +1 -0
- package/dist/cli.js +55 -12
- package/dist/cli.js.map +1 -1
- package/dist/{config-BPSmoYel.js → config-7T4hX_f7.js} +13 -5
- package/dist/{config-BPSmoYel.js.map → config-7T4hX_f7.js.map} +1 -1
- package/dist/{config-DdMkKt_N.d.ts → config-CzaJJQ60.d.ts} +19 -5
- package/dist/{config-DdMkKt_N.d.ts.map → config-CzaJJQ60.d.ts.map} +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/{copy-B3V_QRkT.d.ts → copy-BXn0aYQn.d.ts} +37 -3
- package/dist/copy-BXn0aYQn.d.ts.map +1 -0
- package/dist/copy-Bv6S-Hr2.js +235 -0
- package/dist/copy-Bv6S-Hr2.js.map +1 -0
- package/dist/endpoints-Dhgowovz.js +217 -0
- package/dist/endpoints-Dhgowovz.js.map +1 -0
- package/dist/generate-1BeQAd1O.d.ts +72 -0
- package/dist/generate-1BeQAd1O.d.ts.map +1 -0
- package/dist/{generate-9pR3i5Bj.js → generate-CKv86Iki.js} +1724 -627
- package/dist/generate-CKv86Iki.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/next-loader.d.ts +9 -2
- package/dist/next-loader.d.ts.map +1 -1
- package/dist/next-loader.js +12 -3
- package/dist/next-loader.js.map +1 -1
- package/dist/next.d.ts +11 -3
- package/dist/next.d.ts.map +1 -1
- package/dist/next.js +94 -21
- package/dist/next.js.map +1 -1
- package/dist/preact/index.d.ts +34 -0
- package/dist/preact/index.d.ts.map +1 -0
- package/dist/preact/index.js +72 -0
- package/dist/preact/index.js.map +1 -0
- package/dist/react/index.d.ts +5 -14
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +9 -33
- package/dist/react/index.js.map +1 -1
- package/dist/react/server.d.ts +1 -1
- package/dist/react/server.js +1 -1
- package/dist/{sarif-CWyTAQ0y.js → sarif-BnwYC78_.js} +8 -98
- package/dist/sarif-BnwYC78_.js.map +1 -0
- package/dist/{scan-Bx0_yDSi.js → scan-C0ap4Mpp.js} +145 -20
- package/dist/scan-C0ap4Mpp.js.map +1 -0
- package/dist/solid/index.d.ts +29 -0
- package/dist/solid/index.d.ts.map +1 -0
- package/dist/solid/index.js +48 -0
- package/dist/solid/index.js.map +1 -0
- package/dist/svelte/Text.svelte +13 -0
- package/dist/svelte/Text.svelte.d.ts +20 -0
- package/dist/svelte/index.d.ts +31 -0
- package/dist/svelte/index.d.ts.map +1 -0
- package/dist/svelte/index.js +35 -0
- package/dist/svelte/index.js.map +1 -0
- package/dist/tools.d.ts +44 -65
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +5 -5
- package/dist/vite.d.ts +13 -3
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +82 -25
- package/dist/vite.js.map +1 -1
- package/dist/vue/index.d.ts +43 -0
- package/dist/vue/index.d.ts.map +1 -0
- package/dist/vue/index.js +76 -0
- package/dist/vue/index.js.map +1 -0
- package/package.json +63 -7
- package/src/bun.ts +77 -20
- package/src/cli.ts +62 -8
- package/src/index.ts +1 -1
- package/src/next-loader.ts +17 -5
- package/src/next.ts +120 -16
- package/src/preact/index.ts +91 -0
- package/src/react/index.tsx +12 -42
- package/src/runtime/bind.ts +96 -0
- package/src/runtime/copy.ts +148 -24
- package/src/solid/index.ts +67 -0
- package/src/svelte/Text.svelte +13 -0
- package/src/svelte/Text.svelte.d.ts +20 -0
- package/src/svelte/index.ts +65 -0
- package/src/tools/bundle.ts +143 -2
- package/src/tools/catalog.ts +10 -1
- package/src/tools/chunks.ts +142 -0
- package/src/tools/config.ts +25 -7
- package/src/tools/entry.ts +19 -20
- package/src/tools/exchange.ts +275 -0
- package/src/tools/extract.ts +2 -2
- package/src/tools/generate.ts +7 -1
- package/src/tools/index.ts +2 -1
- package/src/tools/native/android.ts +186 -0
- package/src/tools/native/apple.ts +201 -0
- package/src/tools/native/date.ts +219 -0
- package/src/tools/native/index.ts +17 -5
- package/src/tools/native/kotlin.ts +47 -6
- package/src/tools/native/model.ts +67 -29
- package/src/tools/native/printf.ts +137 -0
- package/src/tools/native/rust.ts +86 -5
- package/src/tools/native/swift.ts +62 -6
- package/src/tools/scan.ts +146 -25
- package/src/tools/todo.ts +6 -1
- package/src/vite.ts +121 -28
- package/src/vue/index.ts +117 -0
- package/dist/bundle-DboItAre.js +0 -111
- package/dist/bundle-DboItAre.js.map +0 -1
- package/dist/bundle-KD-DiiFa.d.ts.map +0 -1
- package/dist/compile-xF3F4oIp.d.ts +0 -16
- package/dist/compile-xF3F4oIp.d.ts.map +0 -1
- package/dist/copy-B3V_QRkT.d.ts.map +0 -1
- package/dist/copy-Bo_FIoLy.js +0 -154
- package/dist/copy-Bo_FIoLy.js.map +0 -1
- package/dist/endpoints-CnUfCkIk.js +0 -96
- package/dist/endpoints-CnUfCkIk.js.map +0 -1
- package/dist/generate-9pR3i5Bj.js.map +0 -1
- package/dist/sarif-CWyTAQ0y.js.map +0 -1
- package/dist/scan-Bx0_yDSi.js.map +0 -1
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { n as chainOf } from "./locale-DpQA1pqH.js";
|
|
2
|
-
import { a as formatsOf, c as parseMessage$1, d as staticLiteral, f as tagsOf, i as formatProblems, l as placeholderOf, n as argsOf, o as numberOptions, p as walk, s as optionArgsOf, t as TYPE$1, u as selectionOf } from "./icu-Dej0mNsF.js";
|
|
3
|
-
import { d as
|
|
4
|
-
import { p as lint, u as applyEdits } from "./config-
|
|
5
|
-
import { dirname, join, relative, resolve } from "node:path";
|
|
2
|
+
import { a as formatsOf, c as parseMessage$1, d as staticLiteral, f as tagsOf, i as formatProblems, l as placeholderOf, n as argsOf, o as numberOptions, p as walk, r as dateOptions, s as optionArgsOf, t as TYPE$1, u as selectionOf } from "./icu-Dej0mNsF.js";
|
|
3
|
+
import { d as compileLocale, p as localeModule } from "./scan-C0ap4Mpp.js";
|
|
4
|
+
import { p as lint, u as applyEdits } from "./config-7T4hX_f7.js";
|
|
6
5
|
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
7
7
|
import { SKELETON_TYPE, TYPE, isDateElement, isNumberElement, isTimeElement, parse } from "@formatjs/icu-messageformat-parser";
|
|
8
|
-
import { LineCounter, Scalar, isMap, isScalar, parse as parse$1, parseDocument } from "yaml";
|
|
8
|
+
import { LineCounter, Scalar, isMap, isScalar, parse as parse$1, parseDocument, stringify } from "yaml";
|
|
9
9
|
import { MessageSyntaxError, parseMessage, stringifyMessage, validate } from "messageformat";
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
11
|
// src/tools/mf1.ts
|
|
@@ -33,7 +33,7 @@ const CATEGORY_ORDER = [
|
|
|
33
33
|
function print$1(elements, inPlural) {
|
|
34
34
|
return elements.map((el, i) => {
|
|
35
35
|
switch (el.type) {
|
|
36
|
-
case TYPE.literal: return escape(el.value, inPlural, elements[i + 1] === void 0);
|
|
36
|
+
case TYPE.literal: return escape$3(el.value, inPlural, elements[i + 1] === void 0);
|
|
37
37
|
case TYPE.argument: return `{${el.value}}`;
|
|
38
38
|
case TYPE.number:
|
|
39
39
|
case TYPE.date:
|
|
@@ -66,7 +66,7 @@ function print$1(elements, inPlural) {
|
|
|
66
66
|
* Literal text, quoted where ICU would read it as syntax. An apostrophe starts a quote only before a
|
|
67
67
|
* special character, so one before ordinary text (`don't`) stays single.
|
|
68
68
|
*/
|
|
69
|
-
function escape(text, inPlural, last) {
|
|
69
|
+
function escape$3(text, inPlural, last) {
|
|
70
70
|
const special = (c) => c === "{" || c === "}" || c === "<" || inPlural && c === "#";
|
|
71
71
|
let out = "";
|
|
72
72
|
for (let i = 0; i < text.length; i++) {
|
|
@@ -1462,7 +1462,7 @@ var CatalogError = class extends Error {
|
|
|
1462
1462
|
* in. Keys are read at every call site, so they say what the string is for: `cart.checkout.title`.
|
|
1463
1463
|
*/
|
|
1464
1464
|
const KEY_PATTERN = /^[a-z0-9]+(?:[-_][a-z0-9]+)*(?:\.[a-z0-9]+(?:[-_][a-z0-9]+)*)+$/;
|
|
1465
|
-
const FIELDS = /* @__PURE__ */ new Set([
|
|
1465
|
+
const FIELDS$1 = /* @__PURE__ */ new Set([
|
|
1466
1466
|
"kind",
|
|
1467
1467
|
"surface",
|
|
1468
1468
|
"context",
|
|
@@ -1541,7 +1541,7 @@ function readEntry$1(key, area, file, value, config, error) {
|
|
|
1541
1541
|
error(message);
|
|
1542
1542
|
ok = false;
|
|
1543
1543
|
};
|
|
1544
|
-
for (const field of Object.keys(entry)) if (!FIELDS.has(field) && field !== config.source) fail(`"${field}" is not a field: ${[...FIELDS, config.source].join(", ")}`);
|
|
1544
|
+
for (const field of Object.keys(entry)) if (!FIELDS$1.has(field) && field !== config.source) fail(`"${field}" is not a field: ${[...FIELDS$1, config.source].join(", ")}`);
|
|
1545
1545
|
const text = entry[config.source];
|
|
1546
1546
|
if (typeof text !== "string" || text === "") {
|
|
1547
1547
|
fail(`${config.source} is the message, and it is missing`);
|
|
@@ -1681,6 +1681,13 @@ function icuError(text, e) {
|
|
|
1681
1681
|
if (/TAG/.test(reason)) return `is not valid ICU (${reason}): a literal < is written '<'`;
|
|
1682
1682
|
return `is not valid ICU MessageFormat: ${reason}`;
|
|
1683
1683
|
}
|
|
1684
|
+
/** The first line of a catalog file: the YAML language server finds the schema `generate` writes only through it. */
|
|
1685
|
+
const SCHEMA_LINE = "# yaml-language-server: $schema=./catalog.schema.json";
|
|
1686
|
+
/** `text` opening with the schema line, unless it opens with a schema line of its own already. */
|
|
1687
|
+
function withSchemaLine(text) {
|
|
1688
|
+
if (text.startsWith("# yaml-language-server:")) return text;
|
|
1689
|
+
return `${SCHEMA_LINE}${text.includes("\r\n") ? "\r\n" : "\n"}${text}`;
|
|
1690
|
+
}
|
|
1684
1691
|
function yamlFiles(dir) {
|
|
1685
1692
|
return readdirSync(dir).filter((f) => f.endsWith(".yaml")).sort();
|
|
1686
1693
|
}
|
|
@@ -1810,130 +1817,629 @@ function entryLocator(root) {
|
|
|
1810
1817
|
function previewOf(message) {
|
|
1811
1818
|
return staticLiteral(message.ast) ?? message.text;
|
|
1812
1819
|
}
|
|
1813
|
-
// src/tools/
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1820
|
+
// src/tools/diff.ts
|
|
1821
|
+
const TOKEN = /\s+|[\p{L}\p{N}_'’]+|[^\s\p{L}\p{N}_]/gu;
|
|
1822
|
+
/** Past this many cells the table costs more than the diff is worth: show the whole change instead. */
|
|
1823
|
+
const MAX_CELLS = 25e4;
|
|
1824
|
+
function wordDiff(before, after) {
|
|
1825
|
+
const a = before.match(TOKEN) ?? [];
|
|
1826
|
+
const b = after.match(TOKEN) ?? [];
|
|
1827
|
+
if (a.length * b.length > MAX_CELLS) return merge([{
|
|
1828
|
+
op: "del",
|
|
1829
|
+
text: before
|
|
1830
|
+
}, {
|
|
1831
|
+
op: "add",
|
|
1832
|
+
text: after
|
|
1833
|
+
}]);
|
|
1834
|
+
const lcs = Array.from({ length: a.length + 1 }, () => Array.from({ length: b.length + 1 }, () => 0));
|
|
1835
|
+
for (let i = a.length - 1; i >= 0; i--) for (let j = b.length - 1; j >= 0; j--) lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
1836
|
+
const parts = [];
|
|
1837
|
+
let i = 0;
|
|
1838
|
+
let j = 0;
|
|
1839
|
+
while (i < a.length && j < b.length) if (a[i] === b[j]) {
|
|
1840
|
+
parts.push({
|
|
1841
|
+
op: "same",
|
|
1842
|
+
text: a[i]
|
|
1843
|
+
});
|
|
1844
|
+
i++;
|
|
1845
|
+
j++;
|
|
1846
|
+
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) parts.push({
|
|
1847
|
+
op: "del",
|
|
1848
|
+
text: a[i++]
|
|
1849
|
+
});
|
|
1850
|
+
else parts.push({
|
|
1851
|
+
op: "add",
|
|
1852
|
+
text: b[j++]
|
|
1853
|
+
});
|
|
1854
|
+
while (i < a.length) parts.push({
|
|
1855
|
+
op: "del",
|
|
1856
|
+
text: a[i++]
|
|
1857
|
+
});
|
|
1858
|
+
while (j < b.length) parts.push({
|
|
1859
|
+
op: "add",
|
|
1860
|
+
text: b[j++]
|
|
1861
|
+
});
|
|
1862
|
+
return merge(parts);
|
|
1827
1863
|
}
|
|
1828
1864
|
/**
|
|
1829
|
-
*
|
|
1830
|
-
*
|
|
1865
|
+
* Adjacent parts of one kind joined, and a space shared by two changes folded into them, so a
|
|
1866
|
+
* replaced phrase reads `[-old words-]{+new words+}` rather than alternating word by word.
|
|
1831
1867
|
*/
|
|
1832
|
-
function
|
|
1833
|
-
const
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
put(current.files.catalog, withFields(text(current.files.catalog), key, edit.fields, config.source));
|
|
1848
|
-
for (const locale of current.locales) {
|
|
1849
|
-
const next = edit.fields.translations[locale] ?? "";
|
|
1850
|
-
if (next.trim() !== "") translated.push(locale);
|
|
1851
|
-
if (next === current.fields.translations[locale]) continue;
|
|
1852
|
-
put(current.files.translations[locale], withTranslation(text(current.files.translations[locale]), key, next));
|
|
1868
|
+
function merge(parts) {
|
|
1869
|
+
const out = [];
|
|
1870
|
+
for (let k = 0; k < parts.length; k++) {
|
|
1871
|
+
const part = parts[k];
|
|
1872
|
+
const prev = out.at(-1);
|
|
1873
|
+
const next = parts[k + 1];
|
|
1874
|
+
if (part.op === "same" && /^\s+$/.test(part.text) && prev && prev.op !== "same" && next && next.op !== "same") {
|
|
1875
|
+
out.push({
|
|
1876
|
+
op: "del",
|
|
1877
|
+
text: part.text
|
|
1878
|
+
}, {
|
|
1879
|
+
op: "add",
|
|
1880
|
+
text: part.text
|
|
1881
|
+
});
|
|
1882
|
+
continue;
|
|
1853
1883
|
}
|
|
1884
|
+
out.push({ ...part });
|
|
1854
1885
|
}
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
if (
|
|
1860
|
-
|
|
1861
|
-
}
|
|
1862
|
-
const message = catalog.messages.find((m) => m.key === key);
|
|
1863
|
-
const renamed = [...message === void 0 ? [current.files.catalog] : [], ...translated.filter((l) => message?.translations.has(l) !== true).map((l) => current.files.translations[l])];
|
|
1864
|
-
if (renamed.length > 0) {
|
|
1865
|
-
const problem = `the entry is still ${key}: rename a key in the code and the catalog together`;
|
|
1866
|
-
return { problems: renamed.map((file) => ({
|
|
1867
|
-
severity: "error",
|
|
1868
|
-
file,
|
|
1869
|
-
key,
|
|
1870
|
-
message: problem
|
|
1871
|
-
})) };
|
|
1872
|
-
}
|
|
1873
|
-
if (write) for (const [path, content] of unsaved) {
|
|
1874
|
-
if (existsSync(path) && readFileSync(path, "utf8") === content) continue;
|
|
1875
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
1876
|
-
writeFileSync(path, content);
|
|
1886
|
+
const joined = [];
|
|
1887
|
+
for (const part of out.filter((p) => p.text !== "")) {
|
|
1888
|
+
const last = joined.at(-1);
|
|
1889
|
+
if (last && last.op === part.op) last.text += part.text;
|
|
1890
|
+
else if (part.op === "del" && last?.op === "add" && joined.at(-2)?.op === "del") joined.at(-2).text += part.text;
|
|
1891
|
+
else joined.push({ ...part });
|
|
1877
1892
|
}
|
|
1893
|
+
return joined;
|
|
1894
|
+
}
|
|
1895
|
+
/** The diff as one line: `Save [-the-]{+a+} draft`, the notation of `git diff --word-diff`. */
|
|
1896
|
+
function formatDiff(parts) {
|
|
1897
|
+
return parts.map((p) => p.op === "same" ? p.text : p.op === "del" ? `[-${p.text}-]` : `{+${p.text}+}`).join("");
|
|
1898
|
+
}
|
|
1899
|
+
// src/tools/lock.ts
|
|
1900
|
+
/**
|
|
1901
|
+
* Stale translations: a translation made from source text that has since changed.
|
|
1902
|
+
*
|
|
1903
|
+
* The lock records, per translation, a hash of the source it was made from and a hash of the
|
|
1904
|
+
* translation itself. Editing the translation re-anchors it to the current source; editing only
|
|
1905
|
+
* the source leaves the old anchor in place, which is what marks it stale. Nobody has to remember
|
|
1906
|
+
* to flag anything: `prosebook status` lists what is stale, and `prosebook accept` re-anchors a
|
|
1907
|
+
* translation that was still right.
|
|
1908
|
+
*
|
|
1909
|
+
* The lock also keeps the text behind each anchor, once per distinct text, so a stale translation
|
|
1910
|
+
* can show its translator what changed rather than only what the source says now.
|
|
1911
|
+
*/
|
|
1912
|
+
function hash(text) {
|
|
1913
|
+
return createHash("sha256").update(text).digest("hex").slice(0, 10);
|
|
1914
|
+
}
|
|
1915
|
+
function emptyLock() {
|
|
1878
1916
|
return {
|
|
1879
|
-
|
|
1880
|
-
|
|
1917
|
+
version: 2,
|
|
1918
|
+
sources: {},
|
|
1919
|
+
translations: {}
|
|
1920
|
+
};
|
|
1921
|
+
}
|
|
1922
|
+
/** The lock at `path`. A version 1 lock, which held only the translations, reads with no source texts. */
|
|
1923
|
+
function readLock(path) {
|
|
1924
|
+
if (!existsSync(path)) return emptyLock();
|
|
1925
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
1926
|
+
if (raw.version === 2) return {
|
|
1927
|
+
...emptyLock(),
|
|
1928
|
+
...raw
|
|
1929
|
+
};
|
|
1930
|
+
return {
|
|
1931
|
+
version: 2,
|
|
1932
|
+
sources: {},
|
|
1933
|
+
translations: raw
|
|
1881
1934
|
};
|
|
1882
1935
|
}
|
|
1936
|
+
const sortedEntries = (record) => Object.fromEntries(Object.entries(record).sort(([a], [b]) => a < b ? -1 : 1));
|
|
1937
|
+
function serializeLock(lock) {
|
|
1938
|
+
const translations = Object.fromEntries(Object.keys(lock.translations).sort().map((locale) => [locale, sortedEntries(lock.translations[locale])]));
|
|
1939
|
+
return `${JSON.stringify({
|
|
1940
|
+
version: 2,
|
|
1941
|
+
sources: sortedEntries(lock.sources),
|
|
1942
|
+
translations
|
|
1943
|
+
}, null, 2)}\n`;
|
|
1944
|
+
}
|
|
1945
|
+
/** What changed in the source of a stale translation, as a word diff, or why that isn't known. */
|
|
1946
|
+
function changeOf(stale) {
|
|
1947
|
+
if (stale.was === void 0) return "the earlier text isn't recorded (the lock predates prosebook 0.2)";
|
|
1948
|
+
return formatDiff(wordDiff(stale.was, stale.now)).replaceAll("\n", " ");
|
|
1949
|
+
}
|
|
1883
1950
|
/**
|
|
1884
|
-
*
|
|
1885
|
-
*
|
|
1951
|
+
* The lock brought up to date with the catalog, and what is stale. `accept` re-anchors the given
|
|
1952
|
+
* translations (per locale, keys, or `true` for all of them) to the current source.
|
|
1886
1953
|
*/
|
|
1887
|
-
function
|
|
1888
|
-
const
|
|
1889
|
-
const
|
|
1890
|
-
for (const
|
|
1891
|
-
|
|
1892
|
-
const
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
const content = contentOf(config, file, unsaved);
|
|
1912
|
-
unsaved.set(resolve(config.root, file), locale === config.source ? withMessage(content, key, next, config.source) : withTranslation(content, key, next));
|
|
1913
|
-
fixed += count;
|
|
1914
|
-
}
|
|
1915
|
-
try {
|
|
1916
|
-
loadCatalog(config, unsaved);
|
|
1917
|
-
} catch (e) {
|
|
1918
|
-
if (!(e instanceof CatalogError)) throw e;
|
|
1919
|
-
return {
|
|
1920
|
-
fixed: 0,
|
|
1921
|
-
problems: e.problems.filter((p) => p.severity === "error")
|
|
1922
|
-
};
|
|
1954
|
+
function reconcile(catalog, previous, accept = /* @__PURE__ */ new Map()) {
|
|
1955
|
+
const lock = emptyLock();
|
|
1956
|
+
const stale = /* @__PURE__ */ new Map();
|
|
1957
|
+
for (const message of catalog.messages) {
|
|
1958
|
+
const source = hash(message.text);
|
|
1959
|
+
for (const [locale, translation] of message.translations) {
|
|
1960
|
+
const text = hash(translation.text);
|
|
1961
|
+
const [anchor, recorded] = (previous.translations[locale]?.[message.key] ?? "").split(":");
|
|
1962
|
+
const accepted = accept.get(locale);
|
|
1963
|
+
const reanchor = anchor === void 0 || anchor === "" || recorded !== text || accepted === true || accepted?.has(message.key) === true;
|
|
1964
|
+
const kept = reanchor ? source : anchor;
|
|
1965
|
+
(lock.translations[locale] ??= {})[message.key] = `${kept}:${text}`;
|
|
1966
|
+
const keptText = kept === source ? message.text : previous.sources[kept];
|
|
1967
|
+
if (keptText !== void 0) lock.sources[kept] = keptText;
|
|
1968
|
+
if (!reanchor && anchor !== source) {
|
|
1969
|
+
let keys = stale.get(locale);
|
|
1970
|
+
if (keys === void 0) stale.set(locale, keys = /* @__PURE__ */ new Map());
|
|
1971
|
+
keys.set(message.key, {
|
|
1972
|
+
was: keptText,
|
|
1973
|
+
now: message.text,
|
|
1974
|
+
translation: translation.text
|
|
1975
|
+
});
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1923
1978
|
}
|
|
1924
|
-
for (const [path, content] of unsaved) writeFileSync(path, content);
|
|
1925
1979
|
return {
|
|
1926
|
-
|
|
1927
|
-
|
|
1980
|
+
lock,
|
|
1981
|
+
stale
|
|
1928
1982
|
};
|
|
1929
1983
|
}
|
|
1984
|
+
// src/tools/todo.ts
|
|
1930
1985
|
/**
|
|
1931
|
-
*
|
|
1932
|
-
*
|
|
1933
|
-
*
|
|
1934
|
-
*
|
|
1986
|
+
* `prosebook todo`: a locale's missing and stale entries, each with what a translator needs above it,
|
|
1987
|
+
* as the YAML to fill in. `prosebook import` reads it back filled.
|
|
1988
|
+
*
|
|
1989
|
+
* With `prompt`, it leads with what a model must be told to reply in that form. prosebook writes the
|
|
1990
|
+
* prompt and checks the reply; which model answers it, and how, is left to whoever runs it.
|
|
1935
1991
|
*/
|
|
1936
|
-
function
|
|
1992
|
+
function todoOf(config, catalog, stale, locale, prompt = false) {
|
|
1993
|
+
const wanted = todoMessages(catalog, stale, locale);
|
|
1994
|
+
if (wanted.length === 0) return {
|
|
1995
|
+
count: 0,
|
|
1996
|
+
text: `# ${locale} is complete`
|
|
1997
|
+
};
|
|
1998
|
+
const byArea = /* @__PURE__ */ new Map();
|
|
1999
|
+
for (const m of wanted) {
|
|
2000
|
+
const lines = byArea.get(m.area) ?? [];
|
|
2001
|
+
lines.push("");
|
|
2002
|
+
if (m.context) lines.push(`# ${m.context}`);
|
|
2003
|
+
const what = [m.kind && `kind: ${m.kind}`, m.surface && `surface: ${m.surface}`].filter(Boolean).join(", ");
|
|
2004
|
+
if (what) lines.push(`# ${what}`);
|
|
2005
|
+
for (const [arg, said] of m.argDocs) lines.push(`# {${arg}}: ${said}`);
|
|
2006
|
+
lines.push(`# ${config.source}: ${m.text.replaceAll("\n", " ")}`);
|
|
2007
|
+
const change = stale.get(m.key);
|
|
2008
|
+
if (change !== void 0) lines.push(`# stale, the source changed: ${changeOf(change)}`);
|
|
2009
|
+
lines.push(stringify({ [m.key]: m.translations.get(locale)?.text ?? "" }, { lineWidth: 0 }).trimEnd());
|
|
2010
|
+
byArea.set(m.area, lines);
|
|
2011
|
+
}
|
|
2012
|
+
const entries = [...byArea].map(([area, lines]) => `\n# ── ${area}.yaml ──\n${lines.join("\n")}`).join("\n");
|
|
2013
|
+
const count = wanted.length;
|
|
2014
|
+
if (prompt) return {
|
|
2015
|
+
count,
|
|
2016
|
+
text: `${promptFor(config, wanted, locale, wanted.some((m) => stale.has(m.key)))}\n${entries.trimStart()}\n\`\`\``
|
|
2017
|
+
};
|
|
2018
|
+
return {
|
|
2019
|
+
count,
|
|
2020
|
+
text: `${`# prosebook todo: ${count} entr${count === 1 ? "y" : "ies"} for ${locale}. Fill them in, then \`prosebook import ${locale} <this file>\`.`}\n${entries}`
|
|
2021
|
+
};
|
|
2022
|
+
}
|
|
2023
|
+
/** The entries a locale wants a translation of: those it lacks, and those gone stale. */
|
|
2024
|
+
function todoMessages(catalog, stale, locale) {
|
|
2025
|
+
return catalog.messages.filter((m) => !m.translations.has(locale) || stale.has(m.key));
|
|
2026
|
+
}
|
|
2027
|
+
function promptFor(config, wanted, locale, anyStale) {
|
|
2028
|
+
const mf2 = config.messageFormat === 2;
|
|
2029
|
+
const [target, source] = [nameOf(locale), nameOf(config.source)];
|
|
2030
|
+
const plurals = {
|
|
2031
|
+
cardinal: false,
|
|
2032
|
+
ordinal: false
|
|
2033
|
+
};
|
|
2034
|
+
for (const m of wanted) walk(m.ast, (el) => {
|
|
2035
|
+
if (el.type === TYPE$1.plural) plurals[el.pluralType === "ordinal" ? "ordinal" : "cardinal"] = true;
|
|
2036
|
+
});
|
|
2037
|
+
const categories = (type) => {
|
|
2038
|
+
const forms = new Intl.PluralRules(locale, { type }).resolvedOptions().pluralCategories;
|
|
2039
|
+
return forms.length === 1 ? `only the form ${forms[0]}` : `the forms ${new Intl.ListFormat("en").format(forms)}`;
|
|
2040
|
+
};
|
|
2041
|
+
const exact = mf2 ? "`0`" : "`=0`";
|
|
2042
|
+
const rules = [
|
|
2043
|
+
mf2 ? `Each message is in MessageFormat 2. Keep every {$placeholder}, every markup tag ({#b}…{/b}) around the same words, and every .input, .local and .match line; translate the text, not the names.` : `Each message is in ICU MessageFormat. Keep every {placeholder}, and every <tag> around the same words; translate the text inside plural and select arms, not the arm or argument names.`,
|
|
2044
|
+
plurals.cardinal && `A plural in ${target} takes ${categories("cardinal")}: give each its own ${mf2 ? "variant" : "arm"}, and keep exact ones such as ${exact} where the ${source} has them.`,
|
|
2045
|
+
plurals.ordinal && `An ordinal in ${target} takes ${categories("ordinal")}.`,
|
|
2046
|
+
"The comments above an entry say where it appears, what kind of string it is, and what each placeholder holds. Hold each to its kind, as described below: a button stays as short as the source.",
|
|
2047
|
+
anyStale && "An entry marked stale has a translation made from an earlier source. The diff shows what changed in the source ({+added+}, [-removed-]): change the translation only as much as that needs, and give it back unchanged if it still holds.",
|
|
2048
|
+
"Quote a value in single quotes when it starts with { or holds \": \", as YAML needs, and double any single quote inside it."
|
|
2049
|
+
].filter((r) => typeof r === "string");
|
|
2050
|
+
const kinds = new Set(wanted.flatMap((m) => m.kind === void 0 ? [] : [m.kind]));
|
|
2051
|
+
const surfaces = new Set(wanted.flatMap((m) => m.surface === void 0 ? [] : [m.surface]));
|
|
2052
|
+
const legend = [
|
|
2053
|
+
...kinds.size > 0 ? ["", "Kinds of string:"] : [],
|
|
2054
|
+
...[...kinds].map((k) => `- ${k}: ${config.kinds[k]?.doc ?? ""}`),
|
|
2055
|
+
...surfaces.size > 0 ? ["", "Surfaces:"] : [],
|
|
2056
|
+
...[...surfaces].map((s) => `- ${s}: ${config.surfaces[s] ?? ""}`)
|
|
2057
|
+
];
|
|
2058
|
+
return [
|
|
2059
|
+
`Translate the entries below from ${source} (${config.source}) into ${target} (${locale}). They are the text of an app's interface.`,
|
|
2060
|
+
"",
|
|
2061
|
+
"Reply with one YAML code block holding every key below, each with its translation as the value, and nothing else in the block.",
|
|
2062
|
+
"",
|
|
2063
|
+
...rules.map((r) => `- ${r}`),
|
|
2064
|
+
...legend,
|
|
2065
|
+
"",
|
|
2066
|
+
`The entries, in ${SYNTAX_NAME[config.messageFormat]}:`,
|
|
2067
|
+
"",
|
|
2068
|
+
"```yaml"
|
|
2069
|
+
].join("\n");
|
|
2070
|
+
}
|
|
2071
|
+
function nameOf(locale) {
|
|
2072
|
+
try {
|
|
2073
|
+
return new Intl.DisplayNames(["en"], { type: "language" }).of(locale) ?? locale;
|
|
2074
|
+
} catch {
|
|
2075
|
+
return locale;
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
// src/tools/exchange.ts
|
|
2079
|
+
/**
|
|
2080
|
+
* `prosebook export`: a locale's entries as XLIFF 2.0 or JSON, for translation tools, and the reader
|
|
2081
|
+
* `prosebook import` takes every format back through, todo's YAML and a model's reply included.
|
|
2082
|
+
*
|
|
2083
|
+
* Messages travel as their ICU or MF2 text, not as XLIFF's inline codes: a plural or a select has no
|
|
2084
|
+
* inline form, and the tools that take these files read ICU. A unit carries its source as exported, so
|
|
2085
|
+
* a translation of a source that has changed since is refused rather than written as current.
|
|
2086
|
+
*/
|
|
2087
|
+
/**
|
|
2088
|
+
* The entries `todo` would list for `locale`, or every one with `all`, as a file a translation tool
|
|
2089
|
+
* reads. The source locale exports every entry, untranslated, for a tool to take as its source.
|
|
2090
|
+
*/
|
|
2091
|
+
function exportOf(config, catalog, stale, locale, format, all = false) {
|
|
2092
|
+
const isSource = locale === config.source;
|
|
2093
|
+
const messages = all || isSource ? catalog.messages : todoMessages(catalog, stale, locale);
|
|
2094
|
+
if (messages.length === 0) return {
|
|
2095
|
+
count: 0,
|
|
2096
|
+
text: ""
|
|
2097
|
+
};
|
|
2098
|
+
const textOf = (m) => isSource ? m.text : m.translations.get(locale)?.text;
|
|
2099
|
+
if (format === "json") {
|
|
2100
|
+
const json = JSON.stringify(Object.fromEntries(messages.map((m) => [m.key, textOf(m) ?? ""])), null, 2);
|
|
2101
|
+
return {
|
|
2102
|
+
count: messages.length,
|
|
2103
|
+
text: `${json}\n`
|
|
2104
|
+
};
|
|
2105
|
+
}
|
|
2106
|
+
const byArea = /* @__PURE__ */ new Map();
|
|
2107
|
+
for (const m of messages) byArea.set(m.area, [...byArea.get(m.area) ?? [], m]);
|
|
2108
|
+
const lines = ["<?xml version=\"1.0\" encoding=\"UTF-8\"?>", `<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="${escape$2(config.source)}"${isSource ? "" : ` trgLang="${escape$2(locale)}"`}>`];
|
|
2109
|
+
for (const [area, list] of byArea) {
|
|
2110
|
+
lines.push(` <file id="${escape$2(area)}" original="${escape$2(list[0].file)}" xml:space="preserve">`);
|
|
2111
|
+
for (const m of list) {
|
|
2112
|
+
lines.push(` <unit id="${m.key}">`);
|
|
2113
|
+
const notes = [
|
|
2114
|
+
["context", m.context],
|
|
2115
|
+
["kind", m.kind],
|
|
2116
|
+
["surface", m.surface],
|
|
2117
|
+
...[...m.argDocs].map(([arg, said]) => ["placeholder", `{${arg}}: ${said}`]),
|
|
2118
|
+
["stale", isSource || !stale.has(m.key) ? void 0 : `the source changed: ${changeOf(stale.get(m.key))}`]
|
|
2119
|
+
].filter((n) => n[1] !== void 0);
|
|
2120
|
+
if (notes.length > 0) {
|
|
2121
|
+
lines.push(" <notes>");
|
|
2122
|
+
for (const [category, text] of notes) lines.push(` <note category="${category}">${escape$2(text)}</note>`);
|
|
2123
|
+
lines.push(" </notes>");
|
|
2124
|
+
}
|
|
2125
|
+
const target = textOf(m);
|
|
2126
|
+
const state = isSource ? "" : ` state="${target === void 0 || stale.has(m.key) ? "initial" : "translated"}"`;
|
|
2127
|
+
lines.push(` <segment${state}>`, ` <source>${escape$2(m.text)}</source>`);
|
|
2128
|
+
if (!isSource && target !== void 0) lines.push(` <target>${escape$2(target)}</target>`);
|
|
2129
|
+
lines.push(" </segment>", " </unit>");
|
|
2130
|
+
}
|
|
2131
|
+
lines.push(" </file>");
|
|
2132
|
+
}
|
|
2133
|
+
lines.push("</xliff>");
|
|
2134
|
+
return {
|
|
2135
|
+
count: messages.length,
|
|
2136
|
+
text: `${lines.join("\n")}\n`
|
|
2137
|
+
};
|
|
2138
|
+
}
|
|
2139
|
+
/**
|
|
2140
|
+
* The translations in a file `import` was given: XLIFF 2.0 when it starts as XML, otherwise YAML,
|
|
2141
|
+
* which reads JSON too. Text around fenced blocks is left aside, as a model's reply has it. A nested
|
|
2142
|
+
* mapping, as some tools write JSON, joins its keys with dots.
|
|
2143
|
+
*/
|
|
2144
|
+
function readReturned(text, locale, source) {
|
|
2145
|
+
if (text.trimStart().startsWith("<")) return readXliff(text, locale, source);
|
|
2146
|
+
const fenced = [...text.matchAll(/^```[^\n]*\n([\s\S]*?)^```/gm)].map((m) => m[1]);
|
|
2147
|
+
const doc = parseDocument(fenced.length > 0 ? fenced.join("\n") : text);
|
|
2148
|
+
if (doc.errors.length > 0 || !(isMap(doc.contents) || doc.contents === null)) return { problem: `the reply isn't YAML or JSON of \`key: translation\` (${doc.errors[0]?.message.split("\n", 1)[0] ?? "it is not a mapping"}): fix it, or ask for one YAML block` };
|
|
2149
|
+
const returned = [];
|
|
2150
|
+
const flatten = (map, prefix) => {
|
|
2151
|
+
for (const pair of map.items) {
|
|
2152
|
+
const key = prefix + (isScalar(pair.key) ? String(pair.key.value) : String(pair.key));
|
|
2153
|
+
if (isMap(pair.value)) flatten(pair.value, `${key}.`);
|
|
2154
|
+
else returned.push({
|
|
2155
|
+
key,
|
|
2156
|
+
value: isScalar(pair.value) ? pair.value.value : pair.value,
|
|
2157
|
+
checked: true
|
|
2158
|
+
});
|
|
2159
|
+
}
|
|
2160
|
+
};
|
|
2161
|
+
if (isMap(doc.contents)) flatten(doc.contents, "");
|
|
2162
|
+
return { returned };
|
|
2163
|
+
}
|
|
2164
|
+
function readXliff(text, locale, source) {
|
|
2165
|
+
let root;
|
|
2166
|
+
try {
|
|
2167
|
+
root = parseXml(text);
|
|
2168
|
+
} catch (e) {
|
|
2169
|
+
return { problem: `the file isn't XML (${messageOf(e)}): give it back as the tool exported it` };
|
|
2170
|
+
}
|
|
2171
|
+
if (root.name !== "xliff") return { problem: `the file is <${root.name}>, not XLIFF: export it from the tool as XLIFF 2.0` };
|
|
2172
|
+
if (!root.attrs.version?.startsWith("2.")) return { problem: `the file is XLIFF ${root.attrs.version ?? "of no version"}: export it from the tool as XLIFF 2.0` };
|
|
2173
|
+
const { srcLang, trgLang } = root.attrs;
|
|
2174
|
+
if (srcLang !== void 0 && srcLang.toLowerCase() !== source.toLowerCase()) return { problem: `the file translates from ${srcLang}, and the source is ${source}: export it again from prosebook` };
|
|
2175
|
+
if (trgLang === void 0) return { problem: `the file names no trgLang: give it the ${locale} translations, or set trgLang="${locale}"` };
|
|
2176
|
+
if (trgLang.toLowerCase() !== locale.toLowerCase()) return { problem: `the file translates into ${trgLang}, not ${locale}: import it as ${trgLang}` };
|
|
2177
|
+
const returned = [];
|
|
2178
|
+
const units = (el) => elements(el).flatMap((child) => child.name === "unit" ? [child] : child.name === "file" || child.name === "group" ? units(child) : []);
|
|
2179
|
+
for (const unit of units(root)) {
|
|
2180
|
+
const key = unit.attrs.id ?? "";
|
|
2181
|
+
const parts = elements(unit).filter((el) => el.name === "segment" || el.name === "ignorable");
|
|
2182
|
+
const segments = parts.filter((el) => el.name === "segment");
|
|
2183
|
+
const sourceOf = (el) => elements(el).find((c) => c.name === "source");
|
|
2184
|
+
const targetOf = (el) => elements(el).find((c) => c.name === "target");
|
|
2185
|
+
try {
|
|
2186
|
+
const unitSource = parts.map((p) => textOf(sourceOf(p))).join("");
|
|
2187
|
+
const translated = segments.filter((s) => targetOf(s) !== void 0).length;
|
|
2188
|
+
if (translated === 0) {
|
|
2189
|
+
returned.push({
|
|
2190
|
+
key,
|
|
2191
|
+
value: null,
|
|
2192
|
+
checked: false,
|
|
2193
|
+
source: unitSource
|
|
2194
|
+
});
|
|
2195
|
+
continue;
|
|
2196
|
+
}
|
|
2197
|
+
if (translated < segments.length) throw new Error(`${translated} of its ${segments.length} segments are translated: translate the rest`);
|
|
2198
|
+
const value = parts.map((p) => textOf(targetOf(p) ?? sourceOf(p))).join("");
|
|
2199
|
+
const checked = segments.every((s) => (s.attrs.state ?? "initial") !== "initial");
|
|
2200
|
+
returned.push({
|
|
2201
|
+
key,
|
|
2202
|
+
value,
|
|
2203
|
+
checked,
|
|
2204
|
+
source: unitSource
|
|
2205
|
+
});
|
|
2206
|
+
} catch (e) {
|
|
2207
|
+
returned.push({
|
|
2208
|
+
key,
|
|
2209
|
+
value: null,
|
|
2210
|
+
checked: false,
|
|
2211
|
+
problem: messageOf(e)
|
|
2212
|
+
});
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
return { returned };
|
|
2216
|
+
}
|
|
2217
|
+
/** The text of a <source> or <target>, refusing XLIFF's inline codes, which prosebook never writes. */
|
|
2218
|
+
function textOf(el) {
|
|
2219
|
+
if (el === void 0) return "";
|
|
2220
|
+
return el.children.map((child) => {
|
|
2221
|
+
if (typeof child === "string") return child;
|
|
2222
|
+
if (child.name === "cp" && child.attrs.hex !== void 0) return String.fromCodePoint(Number.parseInt(child.attrs.hex, 16));
|
|
2223
|
+
throw new Error(`the ${el.name} holds an inline <${child.name}>: have the tool keep placeholders as the text they were exported as`);
|
|
2224
|
+
}).join("");
|
|
2225
|
+
}
|
|
2226
|
+
const messageOf = (e) => e instanceof Error ? e.message : String(e);
|
|
2227
|
+
const escape$2 = (s) => s.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """);
|
|
2228
|
+
const elements = (el) => el.children.filter((c) => typeof c !== "string");
|
|
2229
|
+
const ENTITIES$1 = {
|
|
2230
|
+
amp: "&",
|
|
2231
|
+
lt: "<",
|
|
2232
|
+
gt: ">",
|
|
2233
|
+
quot: "\"",
|
|
2234
|
+
apos: "'"
|
|
2235
|
+
};
|
|
2236
|
+
function decode(s, at) {
|
|
2237
|
+
return s.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (_, ref) => {
|
|
2238
|
+
if (ref.startsWith("#x") || ref.startsWith("#X")) return String.fromCodePoint(Number.parseInt(ref.slice(2), 16));
|
|
2239
|
+
if (ref.startsWith("#")) return String.fromCodePoint(Number.parseInt(ref.slice(1), 10));
|
|
2240
|
+
const char = ENTITIES$1[ref];
|
|
2241
|
+
if (char === void 0) throw new Error(`&${ref}; near offset ${at} is no XML entity`);
|
|
2242
|
+
return char;
|
|
2243
|
+
});
|
|
2244
|
+
}
|
|
2245
|
+
/**
|
|
2246
|
+
* Just enough XML for XLIFF: elements, attributes, text, CDATA and character references. Comments,
|
|
2247
|
+
* processing instructions and a doctype are skipped; no DTD is read, so no entity of one is known.
|
|
2248
|
+
*/
|
|
2249
|
+
function parseXml(text) {
|
|
2250
|
+
const document = {
|
|
2251
|
+
name: "",
|
|
2252
|
+
attrs: {},
|
|
2253
|
+
children: []
|
|
2254
|
+
};
|
|
2255
|
+
const open = [document];
|
|
2256
|
+
let i = 0;
|
|
2257
|
+
const skip = (from, to) => {
|
|
2258
|
+
const end = text.indexOf(to, i + from.length);
|
|
2259
|
+
if (end === -1) throw new Error(`${from} at offset ${i} never ends`);
|
|
2260
|
+
const body = text.slice(i + from.length, end);
|
|
2261
|
+
i = end + to.length;
|
|
2262
|
+
return body;
|
|
2263
|
+
};
|
|
2264
|
+
while (i < text.length) {
|
|
2265
|
+
const lt = text.indexOf("<", i);
|
|
2266
|
+
const parent = open.at(-1);
|
|
2267
|
+
if (lt !== i) {
|
|
2268
|
+
const end = lt === -1 ? text.length : lt;
|
|
2269
|
+
if (open.length > 1) parent.children.push(decode(text.slice(i, end), i));
|
|
2270
|
+
else if (text.slice(i, end).trim() !== "") throw new Error(`text at offset ${i} is outside the root element`);
|
|
2271
|
+
i = end;
|
|
2272
|
+
continue;
|
|
2273
|
+
}
|
|
2274
|
+
if (text.startsWith("<!--", i)) {
|
|
2275
|
+
skip("<!--", "-->");
|
|
2276
|
+
continue;
|
|
2277
|
+
}
|
|
2278
|
+
if (text.startsWith("<?", i)) {
|
|
2279
|
+
skip("<?", "?>");
|
|
2280
|
+
continue;
|
|
2281
|
+
}
|
|
2282
|
+
if (text.startsWith("<![CDATA[", i)) {
|
|
2283
|
+
parent.children.push(skip("<![CDATA[", "]]>"));
|
|
2284
|
+
continue;
|
|
2285
|
+
}
|
|
2286
|
+
if (text.startsWith("<!", i)) {
|
|
2287
|
+
skip("<!", ">");
|
|
2288
|
+
continue;
|
|
2289
|
+
}
|
|
2290
|
+
if (text.startsWith("</", i)) {
|
|
2291
|
+
const at = i;
|
|
2292
|
+
const name = localName(skip("</", ">").trim());
|
|
2293
|
+
if (open.length === 1 || parent.name !== name) throw new Error(`</${name}> at offset ${at} closes nothing open`);
|
|
2294
|
+
open.pop();
|
|
2295
|
+
continue;
|
|
2296
|
+
}
|
|
2297
|
+
TAG.lastIndex = i;
|
|
2298
|
+
const tag = TAG.exec(text);
|
|
2299
|
+
if (tag === null) throw new Error(`the tag at offset ${i} is malformed`);
|
|
2300
|
+
const attrs = {};
|
|
2301
|
+
for (const [, name, double, single] of tag[2].matchAll(/([^\s=]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) attrs[localName(name)] = decode(double ?? single ?? "", i);
|
|
2302
|
+
const el = {
|
|
2303
|
+
name: localName(tag[1]),
|
|
2304
|
+
attrs,
|
|
2305
|
+
children: []
|
|
2306
|
+
};
|
|
2307
|
+
if (open.length === 1 && document.children.length > 0) throw new Error(`a second root element at offset ${i}`);
|
|
2308
|
+
parent.children.push(el);
|
|
2309
|
+
if (tag[3] !== "/") open.push(el);
|
|
2310
|
+
i += tag[0].length;
|
|
2311
|
+
}
|
|
2312
|
+
if (open.length > 1) throw new Error(`<${open.at(-1).name}> is never closed`);
|
|
2313
|
+
const root = elements(document)[0];
|
|
2314
|
+
if (root === void 0) throw new Error("there is no element");
|
|
2315
|
+
return root;
|
|
2316
|
+
}
|
|
2317
|
+
const TAG = /<([^\s/>]+)((?:\s+[^\s=/>]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>/y;
|
|
2318
|
+
const localName = (name) => name.slice(name.indexOf(":") + 1);
|
|
2319
|
+
// src/tools/entry.ts
|
|
2320
|
+
/**
|
|
2321
|
+
* One entry as the dev inspector's dialog edits it, in every locale: as fields, or as the YAML written
|
|
2322
|
+
* in its files.
|
|
2323
|
+
*
|
|
2324
|
+
* An edit is checked as the whole catalog, with the edited files read from memory, before anything is
|
|
2325
|
+
* written: a save that would break the build comes back as problems instead, and the dev server never
|
|
2326
|
+
* sees it. Fields are written into the parsed document, so comments, order and quoting elsewhere in the
|
|
2327
|
+
* file stay as they were; YAML replaces the entry's text as typed. A translation left empty is no
|
|
2328
|
+
* translation: one that was there is removed, and the locale falls back.
|
|
2329
|
+
*/
|
|
2330
|
+
/** The entry for `key`, with its translation in every locale. */
|
|
2331
|
+
function readEntry(config, key) {
|
|
2332
|
+
return viewOf(config, loadCatalog(config), key, /* @__PURE__ */ new Map());
|
|
2333
|
+
}
|
|
2334
|
+
/**
|
|
2335
|
+
* Applies an edit and checks the catalog with it. With `write`, a clean edit is written; without, it
|
|
2336
|
+
* only comes back as the view, which is how the dialog turns fields into YAML and back.
|
|
2337
|
+
*/
|
|
2338
|
+
function editEntry(config, key, edit, write) {
|
|
2339
|
+
const current = viewOf(config, loadCatalog(config), key, /* @__PURE__ */ new Map());
|
|
2340
|
+
const unsaved = /* @__PURE__ */ new Map();
|
|
2341
|
+
const text = (file) => contentOf(config, file, unsaved);
|
|
2342
|
+
const put = (file, content) => unsaved.set(resolve(config.root, file), content);
|
|
2343
|
+
const translated = [];
|
|
2344
|
+
if ("yaml" in edit) {
|
|
2345
|
+
put(current.files.catalog, withSnippet(text(current.files.catalog), key, edit.yaml.catalog));
|
|
2346
|
+
for (const locale of current.locales) {
|
|
2347
|
+
const yaml = edit.yaml.translations?.[locale]?.trim() ?? current.yaml.translations[locale];
|
|
2348
|
+
if (yaml !== "") translated.push(locale);
|
|
2349
|
+
if (yaml === current.yaml.translations[locale]) continue;
|
|
2350
|
+
put(current.files.translations[locale], withSnippet(text(current.files.translations[locale]), key, yaml));
|
|
2351
|
+
}
|
|
2352
|
+
} else {
|
|
2353
|
+
put(current.files.catalog, withFields(text(current.files.catalog), key, edit.fields, config.source));
|
|
2354
|
+
for (const locale of current.locales) {
|
|
2355
|
+
const next = edit.fields.translations[locale] ?? "";
|
|
2356
|
+
if (next.trim() !== "") translated.push(locale);
|
|
2357
|
+
if (next === current.fields.translations[locale]) continue;
|
|
2358
|
+
put(current.files.translations[locale], withTranslation(text(current.files.translations[locale]), key, next));
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
let catalog;
|
|
2362
|
+
try {
|
|
2363
|
+
catalog = loadCatalog(config, unsaved);
|
|
2364
|
+
} catch (e) {
|
|
2365
|
+
if (!(e instanceof CatalogError)) throw e;
|
|
2366
|
+
return { problems: e.problems.filter((p) => p.severity === "error") };
|
|
2367
|
+
}
|
|
2368
|
+
const message = catalog.messages.find((m) => m.key === key);
|
|
2369
|
+
const renamed = [...message === void 0 ? [current.files.catalog] : [], ...translated.filter((l) => message?.translations.has(l) !== true).map((l) => current.files.translations[l])];
|
|
2370
|
+
if (renamed.length > 0) {
|
|
2371
|
+
const problem = `the entry is still ${key}: rename a key in the code and the catalog together`;
|
|
2372
|
+
return { problems: renamed.map((file) => ({
|
|
2373
|
+
severity: "error",
|
|
2374
|
+
file,
|
|
2375
|
+
key,
|
|
2376
|
+
message: problem
|
|
2377
|
+
})) };
|
|
2378
|
+
}
|
|
2379
|
+
if (write) for (const [path, content] of unsaved) {
|
|
2380
|
+
if (existsSync(path) && readFileSync(path, "utf8") === content) continue;
|
|
2381
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
2382
|
+
writeFileSync(path, content);
|
|
2383
|
+
}
|
|
2384
|
+
return {
|
|
2385
|
+
view: viewOf(config, catalog, key, unsaved),
|
|
2386
|
+
problems: []
|
|
2387
|
+
};
|
|
2388
|
+
}
|
|
2389
|
+
/**
|
|
2390
|
+
* Makes the edits that lint's findings carry, each message in its own file, and writes them only if
|
|
2391
|
+
* the catalog they make loads clean: the problems come back instead, and nothing is written.
|
|
2392
|
+
*/
|
|
2393
|
+
function applyFixes(config, catalog, findings) {
|
|
2394
|
+
const unsaved = /* @__PURE__ */ new Map();
|
|
2395
|
+
const entries = /* @__PURE__ */ new Map();
|
|
2396
|
+
for (const f of findings) {
|
|
2397
|
+
if (f.fix === void 0) continue;
|
|
2398
|
+
const id = `${f.locale}\u0000${f.key}`;
|
|
2399
|
+
const entry = entries.get(id) ?? {
|
|
2400
|
+
file: f.file,
|
|
2401
|
+
key: f.key,
|
|
2402
|
+
locale: f.locale,
|
|
2403
|
+
edits: [],
|
|
2404
|
+
count: 0
|
|
2405
|
+
};
|
|
2406
|
+
entry.edits.push(...f.fix);
|
|
2407
|
+
entry.count += 1;
|
|
2408
|
+
entries.set(id, entry);
|
|
2409
|
+
}
|
|
2410
|
+
let fixed = 0;
|
|
2411
|
+
for (const { file, key, locale, edits, count } of entries.values()) {
|
|
2412
|
+
const message = catalog.messages.find((m) => m.key === key);
|
|
2413
|
+
const written = locale === config.source ? message?.text : message?.translations.get(locale)?.text;
|
|
2414
|
+
if (written === void 0) continue;
|
|
2415
|
+
const next = applyEdits(written, edits);
|
|
2416
|
+
if (next === written) continue;
|
|
2417
|
+
const content = contentOf(config, file, unsaved);
|
|
2418
|
+
unsaved.set(resolve(config.root, file), locale === config.source ? withMessage(content, key, next, config.source) : withTranslation(content, key, next));
|
|
2419
|
+
fixed += count;
|
|
2420
|
+
}
|
|
2421
|
+
try {
|
|
2422
|
+
loadCatalog(config, unsaved);
|
|
2423
|
+
} catch (e) {
|
|
2424
|
+
if (!(e instanceof CatalogError)) throw e;
|
|
2425
|
+
return {
|
|
2426
|
+
fixed: 0,
|
|
2427
|
+
problems: e.problems.filter((p) => p.severity === "error")
|
|
2428
|
+
};
|
|
2429
|
+
}
|
|
2430
|
+
for (const [path, content] of unsaved) writeFileSync(path, content);
|
|
2431
|
+
return {
|
|
2432
|
+
fixed,
|
|
2433
|
+
problems: []
|
|
2434
|
+
};
|
|
2435
|
+
}
|
|
2436
|
+
/**
|
|
2437
|
+
* Translations into `locale` from `prosebook todo`'s YAML filled in, by a person or a model, or from
|
|
2438
|
+
* a translation tool's XLIFF or JSON (`readReturned`), each written to the file its locale and area
|
|
2439
|
+
* put it in. An empty value is one left for later. An entry that would break the catalog is left out
|
|
2440
|
+
* with its problem, so a reply with one bad entry still lands the others.
|
|
2441
|
+
*/
|
|
2442
|
+
function importTranslations(config, locale, reply) {
|
|
1937
2443
|
const failed = (message, key) => ({
|
|
1938
2444
|
severity: "error",
|
|
1939
2445
|
key,
|
|
@@ -1944,36 +2450,41 @@ function importTranslations(config, locale, reply) {
|
|
|
1944
2450
|
unchanged: [],
|
|
1945
2451
|
problems: [failed(`${locale} is the source: import takes translations`)]
|
|
1946
2452
|
};
|
|
1947
|
-
const
|
|
1948
|
-
|
|
1949
|
-
if (doc.errors.length > 0 || !(isMap(doc.contents) || doc.contents === null)) return {
|
|
2453
|
+
const read = readReturned(reply, locale, config.source);
|
|
2454
|
+
if ("problem" in read) return {
|
|
1950
2455
|
written: [],
|
|
1951
2456
|
unchanged: [],
|
|
1952
|
-
problems: [failed(
|
|
2457
|
+
problems: [failed(read.problem)]
|
|
1953
2458
|
};
|
|
1954
2459
|
const catalog = loadCatalog(config);
|
|
1955
2460
|
const problems = [];
|
|
1956
2461
|
const unchanged = [];
|
|
1957
2462
|
const pending = /* @__PURE__ */ new Map();
|
|
1958
|
-
for (const
|
|
1959
|
-
const key = isScalar(pair.key) ? String(pair.key.value) : String(pair.key);
|
|
2463
|
+
for (const { key, value, checked, source, problem } of read.returned) {
|
|
1960
2464
|
const message = catalog.messages.find((m) => m.key === key);
|
|
1961
2465
|
if (message === void 0) {
|
|
1962
|
-
problems.push(failed("no entry has this key: translate only the keys `prosebook todo`
|
|
2466
|
+
problems.push(failed("no entry has this key: translate only the keys `prosebook todo` or `export` gave out", key));
|
|
2467
|
+
continue;
|
|
2468
|
+
}
|
|
2469
|
+
if (problem !== void 0) {
|
|
2470
|
+
problems.push(failed(problem, key));
|
|
1963
2471
|
continue;
|
|
1964
2472
|
}
|
|
1965
|
-
const value = isScalar(pair.value) ? pair.value.value : pair.value;
|
|
1966
2473
|
if (value === null || value === "") continue;
|
|
1967
2474
|
if (typeof value !== "string" && typeof value !== "number") {
|
|
1968
2475
|
problems.push(failed("the translation is not text: give the message as one string", key));
|
|
1969
2476
|
continue;
|
|
1970
2477
|
}
|
|
2478
|
+
if (source !== void 0 && source !== message.text) {
|
|
2479
|
+
problems.push(failed(`the source has changed since it was exported, to "${message.text}": export it again`, key));
|
|
2480
|
+
continue;
|
|
2481
|
+
}
|
|
1971
2482
|
const text = String(value);
|
|
1972
|
-
if (text
|
|
1973
|
-
else pending.set(key, {
|
|
2483
|
+
if (text !== message.translations.get(locale)?.text) pending.set(key, {
|
|
1974
2484
|
file: translationFile(config, message, locale),
|
|
1975
2485
|
text
|
|
1976
2486
|
});
|
|
2487
|
+
else if (checked) unchanged.push(key);
|
|
1977
2488
|
}
|
|
1978
2489
|
for (;;) {
|
|
1979
2490
|
const unsaved = /* @__PURE__ */ new Map();
|
|
@@ -2145,471 +2656,256 @@ function withMessage(file, key, message, source) {
|
|
|
2145
2656
|
}
|
|
2146
2657
|
function withTranslation(file, key, translation) {
|
|
2147
2658
|
const doc = parse$2(file);
|
|
2148
|
-
const node = doc.get(key, true);
|
|
2149
|
-
if (translation.trim() === "") doc.delete(key);
|
|
2150
|
-
else if (isScalar(node)) {
|
|
2151
|
-
node.value = translation;
|
|
2152
|
-
if (translation.includes("\n")) node.type = Scalar.BLOCK_LITERAL;
|
|
2153
|
-
} else doc.set(key, translation);
|
|
2154
|
-
return isMap(doc.contents) && doc.contents.items.length === 0 ? "" : print(doc);
|
|
2155
|
-
}
|
|
2156
|
-
// src/tools/diff.ts
|
|
2157
|
-
const TOKEN = /\s+|[\p{L}\p{N}_'’]+|[^\s\p{L}\p{N}_]/gu;
|
|
2158
|
-
/** Past this many cells the table costs more than the diff is worth: show the whole change instead. */
|
|
2159
|
-
const MAX_CELLS = 25e4;
|
|
2160
|
-
function wordDiff(before, after) {
|
|
2161
|
-
const a = before.match(TOKEN) ?? [];
|
|
2162
|
-
const b = after.match(TOKEN) ?? [];
|
|
2163
|
-
if (a.length * b.length > MAX_CELLS) return merge([{
|
|
2164
|
-
op: "del",
|
|
2165
|
-
text: before
|
|
2166
|
-
}, {
|
|
2167
|
-
op: "add",
|
|
2168
|
-
text: after
|
|
2169
|
-
}]);
|
|
2170
|
-
const lcs = Array.from({ length: a.length + 1 }, () => Array.from({ length: b.length + 1 }, () => 0));
|
|
2171
|
-
for (let i = a.length - 1; i >= 0; i--) for (let j = b.length - 1; j >= 0; j--) lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
2172
|
-
const parts = [];
|
|
2173
|
-
let i = 0;
|
|
2174
|
-
let j = 0;
|
|
2175
|
-
while (i < a.length && j < b.length) if (a[i] === b[j]) {
|
|
2176
|
-
parts.push({
|
|
2177
|
-
op: "same",
|
|
2178
|
-
text: a[i]
|
|
2179
|
-
});
|
|
2180
|
-
i++;
|
|
2181
|
-
j++;
|
|
2182
|
-
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) parts.push({
|
|
2183
|
-
op: "del",
|
|
2184
|
-
text: a[i++]
|
|
2185
|
-
});
|
|
2186
|
-
else parts.push({
|
|
2187
|
-
op: "add",
|
|
2188
|
-
text: b[j++]
|
|
2189
|
-
});
|
|
2190
|
-
while (i < a.length) parts.push({
|
|
2191
|
-
op: "del",
|
|
2192
|
-
text: a[i++]
|
|
2193
|
-
});
|
|
2194
|
-
while (j < b.length) parts.push({
|
|
2195
|
-
op: "add",
|
|
2196
|
-
text: b[j++]
|
|
2197
|
-
});
|
|
2198
|
-
return merge(parts);
|
|
2199
|
-
}
|
|
2200
|
-
/**
|
|
2201
|
-
* Adjacent parts of one kind joined, and a space shared by two changes folded into them, so a
|
|
2202
|
-
* replaced phrase reads `[-old words-]{+new words+}` rather than alternating word by word.
|
|
2203
|
-
*/
|
|
2204
|
-
function merge(parts) {
|
|
2205
|
-
const out = [];
|
|
2206
|
-
for (let k = 0; k < parts.length; k++) {
|
|
2207
|
-
const part = parts[k];
|
|
2208
|
-
const prev = out.at(-1);
|
|
2209
|
-
const next = parts[k + 1];
|
|
2210
|
-
if (part.op === "same" && /^\s+$/.test(part.text) && prev && prev.op !== "same" && next && next.op !== "same") {
|
|
2211
|
-
out.push({
|
|
2212
|
-
op: "del",
|
|
2213
|
-
text: part.text
|
|
2214
|
-
}, {
|
|
2215
|
-
op: "add",
|
|
2216
|
-
text: part.text
|
|
2217
|
-
});
|
|
2218
|
-
continue;
|
|
2219
|
-
}
|
|
2220
|
-
out.push({ ...part });
|
|
2221
|
-
}
|
|
2222
|
-
const joined = [];
|
|
2223
|
-
for (const part of out.filter((p) => p.text !== "")) {
|
|
2224
|
-
const last = joined.at(-1);
|
|
2225
|
-
if (last && last.op === part.op) last.text += part.text;
|
|
2226
|
-
else if (part.op === "del" && last?.op === "add" && joined.at(-2)?.op === "del") joined.at(-2).text += part.text;
|
|
2227
|
-
else joined.push({ ...part });
|
|
2228
|
-
}
|
|
2229
|
-
return joined;
|
|
2230
|
-
}
|
|
2231
|
-
/** The diff as one line: `Save [-the-]{+a+} draft`, the notation of `git diff --word-diff`. */
|
|
2232
|
-
function formatDiff(parts) {
|
|
2233
|
-
return parts.map((p) => p.op === "same" ? p.text : p.op === "del" ? `[-${p.text}-]` : `{+${p.text}+}`).join("");
|
|
2234
|
-
}
|
|
2235
|
-
// src/tools/lock.ts
|
|
2236
|
-
/**
|
|
2237
|
-
* Stale translations: a translation made from source text that has since changed.
|
|
2238
|
-
*
|
|
2239
|
-
* The lock records, per translation, a hash of the source it was made from and a hash of the
|
|
2240
|
-
* translation itself. Editing the translation re-anchors it to the current source; editing only
|
|
2241
|
-
* the source leaves the old anchor in place, which is what marks it stale. Nobody has to remember
|
|
2242
|
-
* to flag anything: `prosebook status` lists what is stale, and `prosebook accept` re-anchors a
|
|
2243
|
-
* translation that was still right.
|
|
2244
|
-
*
|
|
2245
|
-
* The lock also keeps the text behind each anchor, once per distinct text, so a stale translation
|
|
2246
|
-
* can show its translator what changed rather than only what the source says now.
|
|
2247
|
-
*/
|
|
2248
|
-
function hash(text) {
|
|
2249
|
-
return createHash("sha256").update(text).digest("hex").slice(0, 10);
|
|
2250
|
-
}
|
|
2251
|
-
function emptyLock() {
|
|
2252
|
-
return {
|
|
2253
|
-
version: 2,
|
|
2254
|
-
sources: {},
|
|
2255
|
-
translations: {}
|
|
2256
|
-
};
|
|
2257
|
-
}
|
|
2258
|
-
/** The lock at `path`. A version 1 lock, which held only the translations, reads with no source texts. */
|
|
2259
|
-
function readLock(path) {
|
|
2260
|
-
if (!existsSync(path)) return emptyLock();
|
|
2261
|
-
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
2262
|
-
if (raw.version === 2) return {
|
|
2263
|
-
...emptyLock(),
|
|
2264
|
-
...raw
|
|
2265
|
-
};
|
|
2266
|
-
return {
|
|
2267
|
-
version: 2,
|
|
2268
|
-
sources: {},
|
|
2269
|
-
translations: raw
|
|
2270
|
-
};
|
|
2271
|
-
}
|
|
2272
|
-
const sortedEntries = (record) => Object.fromEntries(Object.entries(record).sort(([a], [b]) => a < b ? -1 : 1));
|
|
2273
|
-
function serializeLock(lock) {
|
|
2274
|
-
const translations = Object.fromEntries(Object.keys(lock.translations).sort().map((locale) => [locale, sortedEntries(lock.translations[locale])]));
|
|
2275
|
-
return `${JSON.stringify({
|
|
2276
|
-
version: 2,
|
|
2277
|
-
sources: sortedEntries(lock.sources),
|
|
2278
|
-
translations
|
|
2279
|
-
}, null, 2)}\n`;
|
|
2280
|
-
}
|
|
2281
|
-
/** What changed in the source of a stale translation, as a word diff, or why that isn't known. */
|
|
2282
|
-
function changeOf(stale) {
|
|
2283
|
-
if (stale.was === void 0) return "the earlier text isn't recorded (the lock predates prosebook 0.2)";
|
|
2284
|
-
return formatDiff(wordDiff(stale.was, stale.now)).replaceAll("\n", " ");
|
|
2285
|
-
}
|
|
2286
|
-
/**
|
|
2287
|
-
* The lock brought up to date with the catalog, and what is stale. `accept` re-anchors the given
|
|
2288
|
-
* translations (per locale, keys, or `true` for all of them) to the current source.
|
|
2289
|
-
*/
|
|
2290
|
-
function reconcile(catalog, previous, accept = /* @__PURE__ */ new Map()) {
|
|
2291
|
-
const lock = emptyLock();
|
|
2292
|
-
const stale = /* @__PURE__ */ new Map();
|
|
2293
|
-
for (const message of catalog.messages) {
|
|
2294
|
-
const source = hash(message.text);
|
|
2295
|
-
for (const [locale, translation] of message.translations) {
|
|
2296
|
-
const text = hash(translation.text);
|
|
2297
|
-
const [anchor, recorded] = (previous.translations[locale]?.[message.key] ?? "").split(":");
|
|
2298
|
-
const accepted = accept.get(locale);
|
|
2299
|
-
const reanchor = anchor === void 0 || anchor === "" || recorded !== text || accepted === true || accepted?.has(message.key) === true;
|
|
2300
|
-
const kept = reanchor ? source : anchor;
|
|
2301
|
-
(lock.translations[locale] ??= {})[message.key] = `${kept}:${text}`;
|
|
2302
|
-
const keptText = kept === source ? message.text : previous.sources[kept];
|
|
2303
|
-
if (keptText !== void 0) lock.sources[kept] = keptText;
|
|
2304
|
-
if (!reanchor && anchor !== source) {
|
|
2305
|
-
let keys = stale.get(locale);
|
|
2306
|
-
if (keys === void 0) stale.set(locale, keys = /* @__PURE__ */ new Map());
|
|
2307
|
-
keys.set(message.key, {
|
|
2308
|
-
was: keptText,
|
|
2309
|
-
now: message.text,
|
|
2310
|
-
translation: translation.text
|
|
2311
|
-
});
|
|
2312
|
-
}
|
|
2313
|
-
}
|
|
2314
|
-
}
|
|
2315
|
-
return {
|
|
2316
|
-
lock,
|
|
2317
|
-
stale
|
|
2318
|
-
};
|
|
2319
|
-
}
|
|
2320
|
-
// src/tools/native/emit.ts
|
|
2321
|
-
/** Words from an identifier written any way: `userName`, `user-name` and `user_name` are user, name. */
|
|
2322
|
-
function wordsOf(name) {
|
|
2323
|
-
return name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean).map((w) => w.toLowerCase());
|
|
2324
|
-
}
|
|
2325
|
-
const capital = (w) => w.charAt(0).toUpperCase() + w.slice(1);
|
|
2326
|
-
const snake = (words) => words.join("_");
|
|
2327
|
-
const pascal = (words) => words.map(capital).join("");
|
|
2328
|
-
const camel = (words) => words.map((w, i) => i === 0 ? w : capital(w)).join("");
|
|
2329
|
-
const upper = (words) => words.join("_").toUpperCase();
|
|
2330
|
-
/** An identifier can't start with a digit, so `404.title` is `_404Title`. */
|
|
2331
|
-
function leading(name) {
|
|
2332
|
-
return /^[0-9]/.test(name) ? `_${name}` : name;
|
|
2333
|
-
}
|
|
2334
|
-
/** A double literal every target reads as one: `0.0`, `1.5`, `1e21`. */
|
|
2335
|
-
function float(value) {
|
|
2336
|
-
const s = String(value).replace("e+", "e");
|
|
2337
|
-
return /[.e]/.test(s) ? s : `${s}.0`;
|
|
2338
|
-
}
|
|
2339
|
-
/** Characters that print nothing, or look like a space, written as escapes so the code shows them. */
|
|
2340
|
-
const INVISIBLE = /[\u{a0}\u{61c}\u{180e}\u{2000}-\u{200f}\u{2028}-\u{202f}\u{205f}-\u{206f}\u{feff}]/gu;
|
|
2341
|
-
/** Text for a doc comment, with what prints nothing written as `\u{…}`: Rust refuses direction marks there. */
|
|
2342
|
-
function docText(line) {
|
|
2343
|
-
return line.replace(INVISIBLE, (c) => `\\u{${c.charCodeAt(0).toString(16)}}`);
|
|
2344
|
-
}
|
|
2345
|
-
/** A string literal: JSON's escapes, `\u{…}` where the target writes them so (Rust, Swift), and `$` escaped in Kotlin. */
|
|
2346
|
-
function stringLiteral(value, language) {
|
|
2347
|
-
const json = JSON.stringify(value).replace(INVISIBLE, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
2348
|
-
if (language === "kotlin") return json.replaceAll("$", "\\$");
|
|
2349
|
-
return json.replace(/\\u([0-9a-f]{4})/g, (_, hex) => `\\u{${hex.replace(/^0+/, "")}}`);
|
|
2350
|
-
}
|
|
2351
|
-
/**
|
|
2352
|
-
* A plural condition as an expression over operands `o`: `field(op)` reads one, `mod` takes a
|
|
2353
|
-
* remainder, `within` tests a range as CLDR does (integers only). `&&`, `||` and `!` are every
|
|
2354
|
-
* target's.
|
|
2355
|
-
*/
|
|
2356
|
-
function conditionCode(condition, code) {
|
|
2357
|
-
const ors = condition.map((and) => and.map((r) => {
|
|
2358
|
-
const value = r.mod === void 0 ? code.field(r.operand) : code.mod(code.field(r.operand), float(r.mod));
|
|
2359
|
-
if (r.ranges.length === 1 && r.ranges[0][0] === r.ranges[0][1]) return `${value} ${r.negated ? "!=" : "=="} ${float(r.ranges[0][0])}`;
|
|
2360
|
-
const any = r.ranges.map(([from, to]) => from === to ? `${value} == ${float(from)}` : code.within(value, float(from), float(to))).join(" || ");
|
|
2361
|
-
return r.negated ? `!(${any})` : r.ranges.length > 1 ? `(${any})` : any;
|
|
2362
|
-
}).join(" && "));
|
|
2363
|
-
return ors.length > 1 ? ors.map((o) => o.includes(" && ") ? `(${o})` : o).join(" || ") : ors[0];
|
|
2364
|
-
}
|
|
2365
|
-
/** Fails with the keys whose names meet, or meet a name the file already has. */
|
|
2366
|
-
function checkNames(language, named, taken) {
|
|
2367
|
-
const seen = new Map(taken.map((t) => [t, "a name the file already has"]));
|
|
2368
|
-
for (const [key, name] of named) {
|
|
2369
|
-
const other = seen.get(name);
|
|
2370
|
-
if (other !== void 0) throw new Error(`${language}: ${key} is named ${name}, as ${other} is: rename one, or change the target's keys`);
|
|
2371
|
-
seen.set(name, key);
|
|
2372
|
-
}
|
|
2373
|
-
}
|
|
2374
|
-
// src/tools/native/kotlin.ts
|
|
2375
|
-
const KEYWORDS$2 = new Set("as break class continue do else false for fun if in interface is null object package return super this throw true try typealias typeof val var when while".split(" "));
|
|
2376
|
-
const ident$2 = (name) => KEYWORDS$2.has(name) ? `\`${name}\`` : name;
|
|
2377
|
-
const lit$2 = (value) => stringLiteral(value, "kotlin");
|
|
2378
|
-
const entry = (tag) => leading(upper(wordsOf(tag)));
|
|
2379
|
-
const argName$2 = (name) => ident$2(leading(camel(wordsOf(name))));
|
|
2380
|
-
function doc$3(lines, indent) {
|
|
2381
|
-
return `${indent}/**\n${lines.map((l) => l.replaceAll("*/", "*\\/")).map((l) => l === "" ? `${indent} *` : `${indent} * ${l}`).join("\n")}\n${indent} */\n`;
|
|
2382
|
-
}
|
|
2383
|
-
/** The parts as an expression of type `String`, with `rt` the runtime object. */
|
|
2384
|
-
function expr$2(parts, indent, rt) {
|
|
2385
|
-
if (parts.length === 0) return "\"\"";
|
|
2386
|
-
return parts.map((p) => p.type === "text" ? lit$2(p.value) : value$2(p, indent, rt)).join(" + ");
|
|
2387
|
-
}
|
|
2388
|
-
function value$2(part, indent, rt) {
|
|
2389
|
-
const inner = `${indent} `;
|
|
2390
|
-
if (part.type === "string") return argName$2(part.arg);
|
|
2391
|
-
if (part.type === "number") return `${rt}.number(${part.offset === 0 ? argName$2(part.arg) : `${argName$2(part.arg)} - ${float(part.offset)}`}, ${rt}.F${part.format}, ${lit$2(part.fallback)})`;
|
|
2392
|
-
if (part.type === "select") {
|
|
2393
|
-
const arms = part.arms.map((a) => `${inner}${lit$2(a.key)} -> ${expr$2(a.parts, inner, rt)}\n`).join("");
|
|
2394
|
-
return `when (${argName$2(part.arg)}) {\n${arms}${inner}else -> ${expr$2(part.other, inner, rt)}\n${indent}}`;
|
|
2395
|
-
}
|
|
2396
|
-
if (part.arms.length + part.exact.length === 0) return expr$2(part.other, indent, rt);
|
|
2397
|
-
const arg = argName$2(part.arg);
|
|
2398
|
-
const on = part.on === "round" ? `${rt}.round(${arg})` : part.on === "percent" ? `${arg} * 100.0` : arg;
|
|
2399
|
-
const { minFrac, maxFrac } = part.rounding;
|
|
2400
|
-
const exact = part.exact.map((e) => `${float(e.value)} to ${lit$2(`=${e.value}`)}`).join(", ");
|
|
2401
|
-
const arms = [...part.exact.map((e) => ({
|
|
2402
|
-
key: `=${e.value}`,
|
|
2403
|
-
parts: e.parts
|
|
2404
|
-
})), ...part.arms].map((a) => `${inner}${lit$2(a.key)} -> ${expr$2(a.parts, inner, rt)}\n`).join("");
|
|
2405
|
-
return `when (${`${rt}.plural(${on}, ${float(part.offset)}, listOf(${exact}), ${rt}::r${part.rules}, ${minFrac}, ${maxFrac})`}) {\n${arms}${inner}else -> ${expr$2(part.other, inner, rt)}\n${indent}}`;
|
|
2406
|
-
}
|
|
2407
|
-
function member$1(m, name, rt) {
|
|
2408
|
-
const branches = m.bodies.map((b) => ` ${b.locales.map(entry).join(", ")} -> ${expr$2(b.parts, " ", rt)}\n`).join("");
|
|
2409
|
-
const argDocs = m.args.filter((a) => a.doc !== void 0).map((a) => `@param ${argName$2(a.name)} ${a.doc}`);
|
|
2410
|
-
const head = doc$3(argDocs.length > 0 ? [
|
|
2411
|
-
...m.doc,
|
|
2412
|
-
"",
|
|
2413
|
-
...argDocs
|
|
2414
|
-
] : m.doc, " ");
|
|
2415
|
-
const body = `when (this) {\n${branches} }`;
|
|
2416
|
-
if (m.args.length === 0) return `${head} val ${name}: String\n get() = ${body}\n`;
|
|
2417
|
-
return `${head} fun ${name}(${m.args.map((a) => `${argName$2(a.name)}: ${a.type === "number" ? "Number" : "String"}`).join(", ")}): String {\n${m.args.filter((a) => a.type === "number").map((a) => ` val ${argName$2(a.name)} = ${argName$2(a.name)}.toDouble()\n`).join("")} return ${body}\n }\n`;
|
|
2418
|
-
}
|
|
2419
|
-
function formatConst$2(spec, i) {
|
|
2420
|
-
const affix = ([prefix, suffix]) => `${lit$2(prefix)} to ${lit$2(suffix)}`;
|
|
2421
|
-
return ` val F${i} = Format(\n${[
|
|
2422
|
-
spec.minInt,
|
|
2423
|
-
spec.minFrac,
|
|
2424
|
-
spec.maxFrac,
|
|
2425
|
-
spec.shift,
|
|
2426
|
-
spec.grouping.primary,
|
|
2427
|
-
spec.grouping.secondary,
|
|
2428
|
-
spec.grouping.min,
|
|
2429
|
-
lit$2(spec.decimal),
|
|
2430
|
-
lit$2(spec.group),
|
|
2431
|
-
spec.digits === void 0 ? "null" : `listOf(${spec.digits.map(lit$2).join(", ")})`,
|
|
2432
|
-
affix(spec.positive),
|
|
2433
|
-
affix(spec.negative),
|
|
2434
|
-
affix(spec.zero),
|
|
2435
|
-
affix(spec.negativeZero),
|
|
2436
|
-
lit$2(spec.infinity),
|
|
2437
|
-
lit$2(spec.negativeInfinity)
|
|
2438
|
-
].map((f) => ` ${f}`).join(",\n")},\n )\n`;
|
|
2439
|
-
}
|
|
2440
|
-
const NUMBER$2 = `
|
|
2441
|
-
/** A number format, taken apart when the file was generated. */
|
|
2442
|
-
class Format(
|
|
2443
|
-
val minInt: Int,
|
|
2444
|
-
val minFrac: Int,
|
|
2445
|
-
val maxFrac: Int,
|
|
2446
|
-
val shift: Int,
|
|
2447
|
-
val primary: Int,
|
|
2448
|
-
val secondary: Int,
|
|
2449
|
-
val minGroup: Int,
|
|
2450
|
-
val decimal: String,
|
|
2451
|
-
val group: String,
|
|
2452
|
-
val digits: List<String>?,
|
|
2453
|
-
val positive: Pair<String, String>,
|
|
2454
|
-
val negative: Pair<String, String>,
|
|
2455
|
-
val zero: Pair<String, String>,
|
|
2456
|
-
val negativeZero: Pair<String, String>,
|
|
2457
|
-
val infinity: String,
|
|
2458
|
-
val negativeInfinity: String,
|
|
2459
|
-
)
|
|
2460
|
-
|
|
2461
|
-
/** \`value\` as \`format\` writes it, or \`fallback\` where it isn't a number. */
|
|
2462
|
-
fun number(value: Double, format: Format, fallback: String): String {
|
|
2463
|
-
if (value.isNaN()) return fallback
|
|
2464
|
-
if (value.isInfinite()) return if (value > 0) format.infinity else format.negativeInfinity
|
|
2465
|
-
val (written, frac) = digits(Math.abs(value), format.shift, format.minFrac, format.maxFrac)
|
|
2466
|
-
val zero = (written + frac).all { it == '0' }
|
|
2467
|
-
val negative = value.toRawBits() < 0
|
|
2468
|
-
val (prefix, suffix) = if (zero) (if (negative) format.negativeZero else format.zero) else (if (negative) format.negative else format.positive)
|
|
2469
|
-
val int = written.padStart(maxOf(format.minInt, 1), '0')
|
|
2470
|
-
val grouped = format.minGroup > 0 && int.length >= format.primary + format.minGroup
|
|
2471
|
-
val out = StringBuilder(prefix)
|
|
2472
|
-
for ((i, digit) in int.withIndex()) {
|
|
2473
|
-
val left = int.length - i
|
|
2474
|
-
if (grouped && i > 0 && (left == format.primary || (left > format.primary && (left - format.primary) % format.secondary == 0))) {
|
|
2475
|
-
out.append(format.group)
|
|
2476
|
-
}
|
|
2477
|
-
out.append(shown(digit, format))
|
|
2478
|
-
}
|
|
2479
|
-
if (frac.isNotEmpty()) {
|
|
2480
|
-
out.append(format.decimal)
|
|
2481
|
-
for (digit in frac) out.append(shown(digit, format))
|
|
2482
|
-
}
|
|
2483
|
-
return out.append(suffix).toString()
|
|
2484
|
-
}
|
|
2485
|
-
|
|
2486
|
-
private fun shown(digit: Char, format: Format): String = format.digits?.get(digit - '0') ?: digit.toString()
|
|
2487
|
-
|
|
2488
|
-
/**
|
|
2489
|
-
* The shortest digits that read back as \`value\`, as \`Intl\` rounds them: moved \`shift\` places
|
|
2490
|
-
* right, rounded half away from zero to \`max\` fraction digits, and trailing zeros cut to \`min\`.
|
|
2491
|
-
*/
|
|
2492
|
-
fun digits(value: Double, shift: Int, min: Int, max: Int): Pair<String, String> {
|
|
2493
|
-
// \`toString\` writes the shortest digits that read back, with an exponent past a size.
|
|
2494
|
-
val plain = java.math.BigDecimal(value.toString()).toPlainString()
|
|
2495
|
-
val int = plain.substringBefore('.')
|
|
2496
|
-
val all = (int + plain.substringAfter('.', "")).map { it - '0' }.toMutableList()
|
|
2497
|
-
var point = int.length + shift
|
|
2498
|
-
while (all.size <= point + max) all.add(0)
|
|
2499
|
-
val next = all[point + max]
|
|
2500
|
-
while (all.size > point + max) all.removeAt(all.size - 1)
|
|
2501
|
-
if (next >= 5) {
|
|
2502
|
-
var at = all.size - 1
|
|
2503
|
-
while (at >= 0 && all[at] == 9) all[at--] = 0
|
|
2504
|
-
if (at < 0) {
|
|
2505
|
-
all.add(0, 1)
|
|
2506
|
-
point += 1
|
|
2507
|
-
} else {
|
|
2508
|
-
all[at] += 1
|
|
2509
|
-
}
|
|
2510
|
-
}
|
|
2511
|
-
val frac = all.subList(point, all.size).toMutableList()
|
|
2512
|
-
while (frac.size > min && frac.last() == 0) frac.removeAt(frac.size - 1)
|
|
2513
|
-
return all.subList(0, point).dropWhile { it == 0 }.joinToString("") to frac.joinToString("")
|
|
2514
|
-
}
|
|
2515
|
-
`;
|
|
2516
|
-
const PLURAL$2 = `
|
|
2517
|
-
/** CLDR's plural operands of a number as written. */
|
|
2518
|
-
class Operands(val n: Double, val i: Double, val v: Double, val w: Double, val f: Double, val t: Double, val c: Double, val e: Double)
|
|
2519
|
-
|
|
2520
|
-
/** The arm a plural takes: the \`=N\` that \`value\` equals, else the category of \`value\` less \`offset\`. */
|
|
2521
|
-
fun plural(value: Double, offset: Double, exact: List<Pair<Double, String>>, rules: (Operands) -> String, min: Int, max: Int): String =
|
|
2522
|
-
exact.firstOrNull { it.first == value }?.second ?: category(rules, value - offset, min, max)
|
|
2523
|
-
|
|
2524
|
-
/** The category \`rules\` give \`value\`, rounded to between \`min\` and \`max\` fraction digits. */
|
|
2525
|
-
private fun category(rules: (Operands) -> String, value: Double, min: Int, max: Int): String {
|
|
2526
|
-
if (!value.isFinite()) return "other"
|
|
2527
|
-
val (written, frac) = digits(Math.abs(value), 0, min, max)
|
|
2528
|
-
val int = written.ifEmpty { "0" }
|
|
2529
|
-
val trimmed = frac.trimEnd('0')
|
|
2530
|
-
val read = { s: String -> s.toDoubleOrNull() ?: 0.0 }
|
|
2531
|
-
return rules(
|
|
2532
|
-
Operands(
|
|
2533
|
-
n = read(if (frac.isEmpty()) int else "$int.$frac"),
|
|
2534
|
-
i = read(int),
|
|
2535
|
-
v = frac.length.toDouble(),
|
|
2536
|
-
w = trimmed.length.toDouble(),
|
|
2537
|
-
f = read(frac),
|
|
2538
|
-
t = read(trimmed),
|
|
2539
|
-
c = 0.0,
|
|
2540
|
-
e = 0.0,
|
|
2541
|
-
),
|
|
2542
|
-
)
|
|
2543
|
-
}
|
|
2544
|
-
|
|
2545
|
-
/** An integer in the range, as CLDR's ranges hold. */
|
|
2546
|
-
private fun within(value: Double, from: Double, to: Double): Boolean = value % 1.0 == 0.0 && value >= from && value <= to
|
|
2547
|
-
|
|
2548
|
-
/** \`Math.round\`: half up, toward positive infinity. */
|
|
2549
|
-
fun round(value: Double): Double {
|
|
2550
|
-
val floor = Math.floor(value)
|
|
2551
|
-
return if (value - floor >= 0.5) floor + 1 else floor
|
|
2552
|
-
}
|
|
2553
|
-
`;
|
|
2554
|
-
function kotlinFile(model, target, banner) {
|
|
2555
|
-
const name = target.name;
|
|
2556
|
-
const rt = `${name}Runtime`;
|
|
2557
|
-
const names = model.messages.map((m) => [m.key, ident$2(leading(camel(m.words)))]);
|
|
2558
|
-
checkNames("kotlin", names, [
|
|
2559
|
-
"tag",
|
|
2560
|
-
"name",
|
|
2561
|
-
"ordinal",
|
|
2562
|
-
"entries",
|
|
2563
|
-
"negotiate",
|
|
2564
|
-
"SOURCE"
|
|
2565
|
-
]);
|
|
2566
|
-
const entries = model.locales.map((l) => ` /** \`${l}\`${l === model.source ? ", the source" : ""}. */\n ${entry(l)}(${lit$2(l)})`).join(",\n");
|
|
2567
|
-
const members = model.messages.map((m, i) => member$1(m, names[i][1], rt)).join("\n");
|
|
2568
|
-
const rules = model.rules.map(({ name: rule, rules }, i) => {
|
|
2569
|
-
return ` /** ${rule} */\n fun r${i}(o: Operands): String {\n${rules.map(([category, condition]) => ` if (${conditionCode(condition, {
|
|
2570
|
-
field: (op) => `o.${op}`,
|
|
2571
|
-
mod: (v, by) => `${v} % ${by}`,
|
|
2572
|
-
within: (v, from, to) => `within(${v}, ${from}, ${to})`
|
|
2573
|
-
})}) return ${lit$2(category)}\n`).join("")} return "other"\n }\n`;
|
|
2574
|
-
}).join("\n");
|
|
2575
|
-
const runtime = model.formats.length + model.rules.length === 0 ? "" : `
|
|
2576
|
-
/** What formats the messages, generated with them. */
|
|
2577
|
-
private object ${rt} {${NUMBER$2}${model.rules.length > 0 ? PLURAL$2 : ""}
|
|
2578
|
-
${model.formats.map(formatConst$2).join("\n")}${rules === "" ? "" : `\n${rules}`}}
|
|
2579
|
-
`;
|
|
2580
|
-
return `${banner}
|
|
2581
|
-
@file:Suppress("unused", "NAME_SHADOWING", "KotlinConstantConditions", "ConvertTwoComparisonsToRangeCheck")
|
|
2582
|
-
${target.package === void 0 ? "" : `\npackage ${target.package}\n`}
|
|
2583
|
-
/** A locale the catalog is written or translated in, and its messages. */
|
|
2584
|
-
enum class ${name}(val tag: String) {
|
|
2585
|
-
${entries};
|
|
2586
|
-
|
|
2587
|
-
${members}
|
|
2588
|
-
companion object {
|
|
2589
|
-
/** The locale the catalog is written in, which every message falls back to. */
|
|
2590
|
-
val SOURCE: ${name} = ${entry(model.source)}
|
|
2591
|
-
|
|
2592
|
-
/**
|
|
2593
|
-
* The best locale for what was asked, most preferred first: each tag tries an exact match, then
|
|
2594
|
-
* its parents (\`pt-BR\` finds \`pt\`), then any locale of its language. The source if none is.
|
|
2595
|
-
*/
|
|
2596
|
-
fun negotiate(requested: List<String>): ${name} {
|
|
2597
|
-
fun language(tag: String) = tag.substringBefore('-').lowercase()
|
|
2598
|
-
for (want in requested) {
|
|
2599
|
-
var tag = want
|
|
2600
|
-
while (true) {
|
|
2601
|
-
entries.firstOrNull { it.tag.equals(tag, ignoreCase = true) }?.let { return it }
|
|
2602
|
-
val cut = tag.lastIndexOf('-')
|
|
2603
|
-
if (cut < 0) break
|
|
2604
|
-
tag = tag.substring(0, cut)
|
|
2605
|
-
}
|
|
2606
|
-
entries.firstOrNull { language(it.tag) == language(want) }?.let { return it }
|
|
2607
|
-
}
|
|
2608
|
-
return SOURCE
|
|
2609
|
-
}
|
|
2610
|
-
}
|
|
2659
|
+
const node = doc.get(key, true);
|
|
2660
|
+
if (translation.trim() === "") doc.delete(key);
|
|
2661
|
+
else if (isScalar(node)) {
|
|
2662
|
+
node.value = translation;
|
|
2663
|
+
if (translation.includes("\n")) node.type = Scalar.BLOCK_LITERAL;
|
|
2664
|
+
} else doc.set(key, translation);
|
|
2665
|
+
return isMap(doc.contents) && doc.contents.items.length === 0 ? "" : print(doc);
|
|
2611
2666
|
}
|
|
2612
|
-
|
|
2667
|
+
// src/tools/native/emit.ts
|
|
2668
|
+
/** Words from an identifier written any way: `userName`, `user-name` and `user_name` are user, name. */
|
|
2669
|
+
function wordsOf(name) {
|
|
2670
|
+
return name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean).map((w) => w.toLowerCase());
|
|
2671
|
+
}
|
|
2672
|
+
const capital = (w) => w.charAt(0).toUpperCase() + w.slice(1);
|
|
2673
|
+
const snake = (words) => words.join("_");
|
|
2674
|
+
const pascal = (words) => words.map(capital).join("");
|
|
2675
|
+
const camel = (words) => words.map((w, i) => i === 0 ? w : capital(w)).join("");
|
|
2676
|
+
const upper = (words) => words.join("_").toUpperCase();
|
|
2677
|
+
/** An identifier can't start with a digit, so `404.title` is `_404Title`. */
|
|
2678
|
+
function leading(name) {
|
|
2679
|
+
return /^[0-9]/.test(name) ? `_${name}` : name;
|
|
2680
|
+
}
|
|
2681
|
+
/** A double literal every target reads as one: `0.0`, `1.5`, `1e21`. */
|
|
2682
|
+
function float(value) {
|
|
2683
|
+
const s = String(value).replace("e+", "e");
|
|
2684
|
+
return /[.e]/.test(s) ? s : `${s}.0`;
|
|
2685
|
+
}
|
|
2686
|
+
/** Characters that print nothing, or look like a space, written as escapes so the code shows them. */
|
|
2687
|
+
const INVISIBLE = /[\u{a0}\u{61c}\u{180e}\u{2000}-\u{200f}\u{2028}-\u{202f}\u{205f}-\u{206f}\u{feff}]/gu;
|
|
2688
|
+
/** Text for a doc comment, with what prints nothing written as `\u{…}`: Rust refuses direction marks there. */
|
|
2689
|
+
function docText(line) {
|
|
2690
|
+
return line.replace(INVISIBLE, (c) => `\\u{${c.charCodeAt(0).toString(16)}}`);
|
|
2691
|
+
}
|
|
2692
|
+
/** A string literal: JSON's escapes, `\u{…}` where the target writes them so (Rust, Swift), and `$` escaped in Kotlin. */
|
|
2693
|
+
function stringLiteral(value, language) {
|
|
2694
|
+
const json = JSON.stringify(value).replace(INVISIBLE, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
2695
|
+
if (language === "kotlin") return json.replaceAll("$", "\\$");
|
|
2696
|
+
return json.replace(/\\u([0-9a-f]{4})/g, (_, hex) => `\\u{${hex.replace(/^0+/, "")}}`);
|
|
2697
|
+
}
|
|
2698
|
+
/**
|
|
2699
|
+
* A plural condition as an expression over operands `o`: `field(op)` reads one, `mod` takes a
|
|
2700
|
+
* remainder, `within` tests a range as CLDR does (integers only). `&&`, `||` and `!` are every
|
|
2701
|
+
* target's.
|
|
2702
|
+
*/
|
|
2703
|
+
function conditionCode(condition, code) {
|
|
2704
|
+
const ors = condition.map((and) => and.map((r) => {
|
|
2705
|
+
const value = r.mod === void 0 ? code.field(r.operand) : code.mod(code.field(r.operand), float(r.mod));
|
|
2706
|
+
if (r.ranges.length === 1 && r.ranges[0][0] === r.ranges[0][1]) return `${value} ${r.negated ? "!=" : "=="} ${float(r.ranges[0][0])}`;
|
|
2707
|
+
const any = r.ranges.map(([from, to]) => from === to ? `${value} == ${float(from)}` : code.within(value, float(from), float(to))).join(" || ");
|
|
2708
|
+
return r.negated ? `!(${any})` : r.ranges.length > 1 ? `(${any})` : any;
|
|
2709
|
+
}).join(" && "));
|
|
2710
|
+
return ors.length > 1 ? ors.map((o) => o.includes(" && ") ? `(${o})` : o).join(" || ") : ors[0];
|
|
2711
|
+
}
|
|
2712
|
+
/** Fails with the keys whose names meet, or meet a name the file already has. */
|
|
2713
|
+
function checkNames(language, named, taken) {
|
|
2714
|
+
const seen = new Map(taken.map((t) => [t, "a name the file already has"]));
|
|
2715
|
+
for (const [key, name] of named) {
|
|
2716
|
+
const other = seen.get(name);
|
|
2717
|
+
if (other !== void 0) throw new Error(`${language}: ${key} is named ${name}, as ${other} is: rename one, or change the target's keys`);
|
|
2718
|
+
seen.set(name, key);
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
// src/tools/native/date.ts
|
|
2722
|
+
/** Dates the spec must format as `Intl` does: every month and weekday, midnight, noon and the hours around them, and two-digit years. */
|
|
2723
|
+
const SAMPLES$1 = [
|
|
2724
|
+
Date.UTC(2001, 0, 5, 1, 2, 3),
|
|
2725
|
+
Date.UTC(2001, 10, 25, 13, 42, 53),
|
|
2726
|
+
Date.UTC(1999, 11, 31, 23, 59, 59),
|
|
2727
|
+
Date.UTC(2e3, 1, 29, 0, 0, 0),
|
|
2728
|
+
Date.UTC(2024, 6, 4, 12, 0, 0),
|
|
2729
|
+
Date.UTC(2024, 6, 4, 12, 30, 5),
|
|
2730
|
+
Date.UTC(1970, 0, 1, 0, 0, 0),
|
|
2731
|
+
Date.UTC(2099, 8, 9, 9, 9, 9),
|
|
2732
|
+
Date.UTC(2010, 4, 15, 18, 45, 0),
|
|
2733
|
+
Date.UTC(2026, 2, 10, 6, 15, 30),
|
|
2734
|
+
Date.UTC(2005, 9, 31, 21, 7, 41),
|
|
2735
|
+
...Array.from({ length: 12 }, (_, m) => Date.UTC(2023, m, 1 + m * 2, m * 2, m * 5, m * 4)),
|
|
2736
|
+
...Array.from({ length: 24 }, (_, h) => Date.UTC(2022, 3, 3 + h % 7, h, 30, 0))
|
|
2737
|
+
];
|
|
2738
|
+
/** The fields of a date in UTC, which the spec is checked in. */
|
|
2739
|
+
function fieldsOf(date) {
|
|
2740
|
+
const d = new Date(date);
|
|
2741
|
+
return {
|
|
2742
|
+
year: d.getUTCFullYear(),
|
|
2743
|
+
month: d.getUTCMonth() + 1,
|
|
2744
|
+
day: d.getUTCDate(),
|
|
2745
|
+
hour: d.getUTCHours(),
|
|
2746
|
+
minute: d.getUTCMinutes(),
|
|
2747
|
+
second: d.getUTCSeconds(),
|
|
2748
|
+
weekday: d.getUTCDay()
|
|
2749
|
+
};
|
|
2750
|
+
}
|
|
2751
|
+
const HOUR_CYCLES = /* @__PURE__ */ new Set([
|
|
2752
|
+
"h11",
|
|
2753
|
+
"h12",
|
|
2754
|
+
"h23",
|
|
2755
|
+
"h24"
|
|
2756
|
+
]);
|
|
2757
|
+
/**
|
|
2758
|
+
* What a format with `options` takes in `locale`. Throws a sentence saying what a native target
|
|
2759
|
+
* can't reproduce, which the caller puts in front of a fix.
|
|
2760
|
+
*/
|
|
2761
|
+
function dateSpec(locale, options = {}) {
|
|
2762
|
+
if (options.timeZone !== void 0) throw new Error(`it names the time zone ${options.timeZone}, which native code can't look up`);
|
|
2763
|
+
const format = new Intl.DateTimeFormat(locale, {
|
|
2764
|
+
...options,
|
|
2765
|
+
timeZone: "UTC"
|
|
2766
|
+
});
|
|
2767
|
+
const resolved = format.resolvedOptions();
|
|
2768
|
+
if (resolved.calendar !== "gregory" && resolved.calendar !== "iso8601") throw new Error(`it counts in the ${resolved.calendar} calendar`);
|
|
2769
|
+
const native = new Intl.NumberFormat(locale, {
|
|
2770
|
+
numberingSystem: resolved.numberingSystem,
|
|
2771
|
+
useGrouping: false
|
|
2772
|
+
});
|
|
2773
|
+
const digits = Array.from({ length: 10 }, (_, d) => native.format(d));
|
|
2774
|
+
const ascii = (text) => text.replace(/./gu, (c) => digits.includes(c) ? String(digits.indexOf(c)) : c);
|
|
2775
|
+
/** The text `type` takes at `date`, in ASCII digits. */
|
|
2776
|
+
const partAt = (date, type) => ascii(format.formatToParts(date).find((p) => p.type === type)?.value ?? "");
|
|
2777
|
+
const isNumber = (text) => /^[0-9]+$/.test(text);
|
|
2778
|
+
const padOf = (date, type) => partAt(date, type).startsWith("0") ? 2 : 1;
|
|
2779
|
+
const pieces = [];
|
|
2780
|
+
for (const part of format.formatToParts(SAMPLES$1[1])) switch (part.type) {
|
|
2781
|
+
case "literal":
|
|
2782
|
+
pieces.push({
|
|
2783
|
+
type: "text",
|
|
2784
|
+
value: part.value
|
|
2785
|
+
});
|
|
2786
|
+
break;
|
|
2787
|
+
case "year":
|
|
2788
|
+
pieces.push(ascii(part.value) === "01" ? {
|
|
2789
|
+
type: "number",
|
|
2790
|
+
field: "year2",
|
|
2791
|
+
pad: 2
|
|
2792
|
+
} : {
|
|
2793
|
+
type: "number",
|
|
2794
|
+
field: "year",
|
|
2795
|
+
pad: 1
|
|
2796
|
+
});
|
|
2797
|
+
break;
|
|
2798
|
+
case "month": {
|
|
2799
|
+
const names = Array.from({ length: 12 }, (_, m) => format.formatToParts(Date.UTC(2023, m, 15, 12)).find((p) => p.type === "month").value);
|
|
2800
|
+
if (names.every((name) => isNumber(ascii(name)))) pieces.push({
|
|
2801
|
+
type: "number",
|
|
2802
|
+
field: "month",
|
|
2803
|
+
pad: padOf(Date.UTC(2023, 0, 15, 12), "month")
|
|
2804
|
+
});
|
|
2805
|
+
else pieces.push({
|
|
2806
|
+
type: "name",
|
|
2807
|
+
field: "month",
|
|
2808
|
+
names
|
|
2809
|
+
});
|
|
2810
|
+
break;
|
|
2811
|
+
}
|
|
2812
|
+
case "day":
|
|
2813
|
+
pieces.push({
|
|
2814
|
+
type: "number",
|
|
2815
|
+
field: "day",
|
|
2816
|
+
pad: padOf(Date.UTC(2023, 0, 5, 12), "day")
|
|
2817
|
+
});
|
|
2818
|
+
break;
|
|
2819
|
+
case "weekday":
|
|
2820
|
+
pieces.push({
|
|
2821
|
+
type: "name",
|
|
2822
|
+
field: "weekday",
|
|
2823
|
+
names: Array.from({ length: 7 }, (_, d) => format.formatToParts(Date.UTC(2023, 0, 1 + d, 12)).find((p) => p.type === "weekday").value)
|
|
2824
|
+
});
|
|
2825
|
+
break;
|
|
2826
|
+
case "hour": {
|
|
2827
|
+
const cycle = resolved.hourCycle;
|
|
2828
|
+
if (cycle === void 0 || !HOUR_CYCLES.has(cycle)) throw new Error("its hours follow no clock native code knows");
|
|
2829
|
+
pieces.push({
|
|
2830
|
+
type: "hour",
|
|
2831
|
+
cycle,
|
|
2832
|
+
pad: padOf(Date.UTC(2023, 0, 5, 1), "hour")
|
|
2833
|
+
});
|
|
2834
|
+
break;
|
|
2835
|
+
}
|
|
2836
|
+
case "minute":
|
|
2837
|
+
case "second":
|
|
2838
|
+
pieces.push({
|
|
2839
|
+
type: "number",
|
|
2840
|
+
field: part.type,
|
|
2841
|
+
pad: padOf(Date.UTC(2023, 0, 5, 1, 5, 5), part.type)
|
|
2842
|
+
});
|
|
2843
|
+
break;
|
|
2844
|
+
case "dayPeriod": {
|
|
2845
|
+
const byHour = Array.from({ length: 24 }, (_, hour) => format.formatToParts(Date.UTC(2023, 0, 5, hour, 30)).find((p) => p.type === "dayPeriod").value);
|
|
2846
|
+
const halves = byHour.every((name, hour) => name === byHour[hour < 12 ? 0 : 12]);
|
|
2847
|
+
pieces.push({
|
|
2848
|
+
type: "name",
|
|
2849
|
+
field: "dayPeriod",
|
|
2850
|
+
names: halves ? [byHour[0], byHour[12]] : byHour
|
|
2851
|
+
});
|
|
2852
|
+
break;
|
|
2853
|
+
}
|
|
2854
|
+
default: throw new Error(`it writes the ${part.type}, which native code can't`);
|
|
2855
|
+
}
|
|
2856
|
+
const spec = {
|
|
2857
|
+
pieces,
|
|
2858
|
+
...digits.join("") !== "0123456789" && { digits }
|
|
2859
|
+
};
|
|
2860
|
+
for (const sample of SAMPLES$1) {
|
|
2861
|
+
const expected = format.format(sample);
|
|
2862
|
+
const actual = formatDate(spec, fieldsOf(sample));
|
|
2863
|
+
if (actual !== expected) throw new Error(`it writes ${new Date(sample).toISOString()} as "${expected}", which native code would write as "${actual}"`);
|
|
2864
|
+
}
|
|
2865
|
+
return spec;
|
|
2866
|
+
}
|
|
2867
|
+
/** The hour as a clock with `cycle` shows it. */
|
|
2868
|
+
function hourOn(cycle, hour) {
|
|
2869
|
+
if (cycle === "h23") return hour;
|
|
2870
|
+
if (cycle === "h24") return hour === 0 ? 24 : hour;
|
|
2871
|
+
if (cycle === "h11") return hour % 12;
|
|
2872
|
+
return (hour + 11) % 12 + 1;
|
|
2873
|
+
}
|
|
2874
|
+
/**
|
|
2875
|
+
* A piece's number, or a name piece's index into its names, as an expression in the integer
|
|
2876
|
+
* arithmetic Rust, Swift and Kotlin share. Keep in step with `formatDate`.
|
|
2877
|
+
*/
|
|
2878
|
+
function pieceExpr(piece, at) {
|
|
2879
|
+
if (piece.type === "hour") {
|
|
2880
|
+
const hour = at("hour");
|
|
2881
|
+
if (piece.cycle === "h23") return hour;
|
|
2882
|
+
if (piece.cycle === "h24") return `(${hour} + 23) % 24 + 1`;
|
|
2883
|
+
return piece.cycle === "h11" ? `${hour} % 12` : `(${hour} + 11) % 12 + 1`;
|
|
2884
|
+
}
|
|
2885
|
+
if (piece.type === "number") return piece.field === "year2" ? `(${at("year")} % 100 + 100) % 100` : at(piece.field);
|
|
2886
|
+
if (piece.field === "month") return `${at("month")} - 1`;
|
|
2887
|
+
if (piece.field === "weekday") return at("weekday");
|
|
2888
|
+
return piece.names.length === 24 ? at("hour") : `${at("hour")} / 12`;
|
|
2889
|
+
}
|
|
2890
|
+
/** Which of a name piece's `count` names `date` takes. */
|
|
2891
|
+
function nameIndex(field, count, date) {
|
|
2892
|
+
if (field === "month") return date.month - 1;
|
|
2893
|
+
if (field === "weekday") return date.weekday;
|
|
2894
|
+
return count === 24 ? date.hour : date.hour < 12 ? 0 : 1;
|
|
2895
|
+
}
|
|
2896
|
+
/** `date` as the spec writes it: the reference for the code each target generates. */
|
|
2897
|
+
function formatDate(spec, date) {
|
|
2898
|
+
const digits = spec.digits;
|
|
2899
|
+
const number = (value, pad) => {
|
|
2900
|
+
const text = String(value).padStart(pad, "0");
|
|
2901
|
+
return digits === void 0 ? text : text.replace(/[0-9]/g, (d) => digits[Number(d)]);
|
|
2902
|
+
};
|
|
2903
|
+
return spec.pieces.map((piece) => {
|
|
2904
|
+
if (piece.type === "text") return piece.value;
|
|
2905
|
+
if (piece.type === "hour") return number(hourOn(piece.cycle, date.hour), piece.pad);
|
|
2906
|
+
if (piece.type === "number") return number(piece.field === "year2" ? (date.year % 100 + 100) % 100 : date[piece.field], piece.pad);
|
|
2907
|
+
return piece.names[nameIndex(piece.field, piece.names.length, date)];
|
|
2908
|
+
}).join("");
|
|
2613
2909
|
}
|
|
2614
2910
|
// src/tools/native/number.ts
|
|
2615
2911
|
const NUMBER_PARTS = /* @__PURE__ */ new Set([
|
|
@@ -4015,10 +4311,22 @@ function parseRelation(source) {
|
|
|
4015
4311
|
* tree per locale it renders in, with every fallback resolved here, so the generated code has no
|
|
4016
4312
|
* lookup to do. Number formats and plural rules are read from `Intl` and CLDR once, and numbered.
|
|
4017
4313
|
*/
|
|
4018
|
-
/**
|
|
4019
|
-
|
|
4020
|
-
|
|
4021
|
-
|
|
4314
|
+
/**
|
|
4315
|
+
* The words a target names `key` by, less the prefix that took it in (longest first): `tray.quit-app`
|
|
4316
|
+
* is quit, app under `['tray']`. Nothing when the target doesn't take the key.
|
|
4317
|
+
*/
|
|
4318
|
+
function wordsFor(target, key) {
|
|
4319
|
+
return restOf(target, key)?.split(/[.\-_]/).filter(Boolean);
|
|
4320
|
+
}
|
|
4321
|
+
/** `key` less the prefix that took it in, as written: `tray.quit-app` is `quit-app` under `['tray']`. */
|
|
4322
|
+
function restOf(target, key) {
|
|
4323
|
+
const prefix = target.keys === void 0 ? "" : [...target.keys].sort((a, b) => b.length - a.length).find((p) => key.startsWith(`${p}.`));
|
|
4324
|
+
if (prefix === void 0) return void 0;
|
|
4325
|
+
return key.slice(prefix === "" ? 0 : prefix.length + 1);
|
|
4326
|
+
}
|
|
4327
|
+
/** The locale whose text `locale` shows for `key`: its own, else the first down its fallback chain that has it. */
|
|
4328
|
+
function foundIn(key, locale, locales, asts) {
|
|
4329
|
+
return chainOf(locale, locales, locales[0]).find((l) => asts.get(l)?.has(key));
|
|
4022
4330
|
}
|
|
4023
4331
|
/** Values a file declares once each, numbered by what they hold, so locales that format alike share one. */
|
|
4024
4332
|
var Numbered = class {
|
|
@@ -4038,17 +4346,17 @@ function nativeModel(target, catalog, locales, entries) {
|
|
|
4038
4346
|
const source = locales[0];
|
|
4039
4347
|
const asts = new Map([...entries].map(([locale, list]) => [locale, new Map(list)]));
|
|
4040
4348
|
const formats = new Numbered();
|
|
4349
|
+
const dates = new Numbered();
|
|
4041
4350
|
const rules = new Numbered();
|
|
4042
4351
|
const problems = [];
|
|
4043
4352
|
const messages = [];
|
|
4044
4353
|
for (const m of catalog.messages) {
|
|
4045
|
-
const
|
|
4046
|
-
if (
|
|
4047
|
-
const words = m.key.slice(prefix === "" ? 0 : prefix.length + 1).split(/[.\-_]/).filter(Boolean);
|
|
4354
|
+
const words = wordsFor(target, m.key);
|
|
4355
|
+
if (words === void 0) continue;
|
|
4048
4356
|
const bodies = /* @__PURE__ */ new Map();
|
|
4049
4357
|
let failed = false;
|
|
4050
4358
|
for (const locale of locales) {
|
|
4051
|
-
const found =
|
|
4359
|
+
const found = foundIn(m.key, locale, locales, asts);
|
|
4052
4360
|
if (found === void 0) continue;
|
|
4053
4361
|
const body = bodies.get(found);
|
|
4054
4362
|
if (body !== void 0) {
|
|
@@ -4058,7 +4366,11 @@ function nativeModel(target, catalog, locales, entries) {
|
|
|
4058
4366
|
try {
|
|
4059
4367
|
bodies.set(found, {
|
|
4060
4368
|
locales: [locale],
|
|
4061
|
-
parts: parts(asts.get(found).get(m.key), found,
|
|
4369
|
+
parts: parts(asts.get(found).get(m.key), found, {
|
|
4370
|
+
formats,
|
|
4371
|
+
dates,
|
|
4372
|
+
rules
|
|
4373
|
+
}, null)
|
|
4062
4374
|
});
|
|
4063
4375
|
} catch (e) {
|
|
4064
4376
|
failed = true;
|
|
@@ -4077,7 +4389,7 @@ function nativeModel(target, catalog, locales, entries) {
|
|
|
4077
4389
|
doc: docOf(m),
|
|
4078
4390
|
args: [...m.args].map(([name, type]) => ({
|
|
4079
4391
|
name,
|
|
4080
|
-
type: type === "number" ? "number" : "string",
|
|
4392
|
+
type: type === "number" ? "number" : type === "Date | number" ? "date" : "string",
|
|
4081
4393
|
doc: m.argDocs.has(name) ? docText(m.argDocs.get(name)) : void 0
|
|
4082
4394
|
})),
|
|
4083
4395
|
bodies: [...bodies.values()]
|
|
@@ -4089,6 +4401,7 @@ function nativeModel(target, catalog, locales, entries) {
|
|
|
4089
4401
|
source,
|
|
4090
4402
|
messages,
|
|
4091
4403
|
formats: formats.list,
|
|
4404
|
+
dates: dates.list,
|
|
4092
4405
|
rules: rules.list
|
|
4093
4406
|
},
|
|
4094
4407
|
problems
|
|
@@ -4107,6 +4420,22 @@ function docOf(m) {
|
|
|
4107
4420
|
const LEAVE_OUT = "or leave the key out of this target's `keys`";
|
|
4108
4421
|
/** Specs by locale and options, kept across runs: taking a format apart checks it against `Intl` dozens of times. */
|
|
4109
4422
|
const SPECS = /* @__PURE__ */ new Map();
|
|
4423
|
+
/** Date specs as `SPECS` keeps number specs. */
|
|
4424
|
+
const DATE_SPECS = /* @__PURE__ */ new Map();
|
|
4425
|
+
function date(dates, locale, options) {
|
|
4426
|
+
const key = JSON.stringify([locale, options ?? {}]);
|
|
4427
|
+
let spec = DATE_SPECS.get(key);
|
|
4428
|
+
if (spec === void 0) {
|
|
4429
|
+
try {
|
|
4430
|
+
spec = dateSpec(locale, options);
|
|
4431
|
+
} catch (e) {
|
|
4432
|
+
spec = new Error(`native code can't format this date in ${locale}: ${e.message}. Format it another way, ${LEAVE_OUT}`, { cause: e });
|
|
4433
|
+
}
|
|
4434
|
+
DATE_SPECS.set(key, spec);
|
|
4435
|
+
}
|
|
4436
|
+
if (spec instanceof Error) throw spec;
|
|
4437
|
+
return dates.add(spec);
|
|
4438
|
+
}
|
|
4110
4439
|
function format(formats, locale, options) {
|
|
4111
4440
|
const key = JSON.stringify([locale, options ?? {}]);
|
|
4112
4441
|
let spec = SPECS.get(key);
|
|
@@ -4121,7 +4450,8 @@ function format(formats, locale, options) {
|
|
|
4121
4450
|
if (spec instanceof Error) throw spec;
|
|
4122
4451
|
return formats.add(spec);
|
|
4123
4452
|
}
|
|
4124
|
-
function parts(elements, locale,
|
|
4453
|
+
function parts(elements, locale, declared, pound) {
|
|
4454
|
+
const { formats, dates, rules } = declared;
|
|
4125
4455
|
const out = [];
|
|
4126
4456
|
const text = (value) => {
|
|
4127
4457
|
const last = out.at(-1);
|
|
@@ -4139,11 +4469,18 @@ function parts(elements, locale, formats, rules, pound) {
|
|
|
4139
4469
|
};
|
|
4140
4470
|
const arms = (options, own) => Object.entries(options).map(([key, option]) => ({
|
|
4141
4471
|
key,
|
|
4142
|
-
parts: parts(option.value, locale,
|
|
4472
|
+
parts: parts(option.value, locale, declared, own)
|
|
4143
4473
|
}));
|
|
4144
4474
|
for (const el of elements) {
|
|
4145
4475
|
if (Object.keys(optionArgsOf(el) ?? {}).length > 0) throw new Error(`{${"value" in el ? el.value : ""}} takes a formatting option from an argument: native code formats with options the message fixes, ${LEAVE_OUT}`);
|
|
4146
|
-
if (isDateElement(el) || isTimeElement(el))
|
|
4476
|
+
if (isDateElement(el) || isTimeElement(el)) {
|
|
4477
|
+
placed(el, el.value, {
|
|
4478
|
+
type: "date",
|
|
4479
|
+
arg: el.value,
|
|
4480
|
+
format: date(dates, locale, dateOptions(el))
|
|
4481
|
+
});
|
|
4482
|
+
continue;
|
|
4483
|
+
}
|
|
4147
4484
|
if (isNumberElement(el)) {
|
|
4148
4485
|
const fallback = placeholderOf(el, el.value).fallback;
|
|
4149
4486
|
placed(el, el.value, {
|
|
@@ -4228,7 +4565,639 @@ function parts(elements, locale, formats, rules, pound) {
|
|
|
4228
4565
|
default: throw new Error(`prosebook: no native writer for ICU element type ${el.type}`);
|
|
4229
4566
|
}
|
|
4230
4567
|
}
|
|
4231
|
-
return out;
|
|
4568
|
+
return out;
|
|
4569
|
+
}
|
|
4570
|
+
// src/tools/native/printf.ts
|
|
4571
|
+
/**
|
|
4572
|
+
* Messages as printf-style format strings, for the targets whose platform fills values in itself:
|
|
4573
|
+
* Android's resources and Apple's `.strings` and `.stringsdict` files. Text and plain `{name}` placeholders, as `%N$s`
|
|
4574
|
+
* with N the argument's place in the message's arguments, so a translation can put them in another
|
|
4575
|
+
* order; anything `Intl` would format fails with the fix.
|
|
4576
|
+
*/
|
|
4577
|
+
/** The context and what each argument holds, on one line, for the comment above an entry. */
|
|
4578
|
+
function noteOf(m) {
|
|
4579
|
+
return docText([m.context, ...[...m.args.keys()].map((a) => m.argDocs.has(a) ? `${a}: ${m.argDocs.get(a).replace(/\.$/, "")}.` : void 0)].filter((l) => l !== void 0 && l !== "").join(" "));
|
|
4580
|
+
}
|
|
4581
|
+
/** What a plural's arms may hold as its count: `#`, or the count as a plain `:number` or `:integer`. */
|
|
4582
|
+
function isCount(el, count) {
|
|
4583
|
+
if (el.type === TYPE$1.pound) return true;
|
|
4584
|
+
if (!isNumberElement(el) || el.value !== count) return false;
|
|
4585
|
+
const options = Object.entries(numberOptions(el) ?? {});
|
|
4586
|
+
return options.length === 0 || options.length === 1 && options[0][0] === "maximumFractionDigits" && options[0][1] === 0;
|
|
4587
|
+
}
|
|
4588
|
+
/**
|
|
4589
|
+
* Throws, with the fix, where a plural asks for more than a platform's plurals pick on: one count,
|
|
4590
|
+
* as an integer, by its cardinal category.
|
|
4591
|
+
*/
|
|
4592
|
+
function checkPlural(plural, holder) {
|
|
4593
|
+
const named = `{${plural.value}}`;
|
|
4594
|
+
if (plural.pluralType === "ordinal") throw new Error(`${named} is an ordinal, and ${holder.plurals} are cardinal only: write a key per case and pick one in code, ${LEAVE_OUT}`);
|
|
4595
|
+
if (plural.offset !== 0) throw new Error(`${named} has an offset, which ${holder.plurals} don't: count without it, and say the rest in each arm, ${LEAVE_OUT}`);
|
|
4596
|
+
const selection = selectionOf(plural);
|
|
4597
|
+
if (selection !== void 0 && (selection.fn.name !== "integer" || Object.keys(selection.fn.options).length > 0)) throw new Error(`${named} selects with :${selection.fn.name} and its options, and ${holder.platform} selects on the count as an integer: select with a bare :number or :integer, ${LEAVE_OUT}`);
|
|
4598
|
+
const exact = Object.keys(plural.options).filter((k) => k.startsWith("="));
|
|
4599
|
+
if (exact.length > 0) throw new Error(`${named} has the arm ${exact.join(", ")}, and ${holder.plurals} have none for one number${holder.exact}: say it in a category's arm, or write a key for it and pick that in code, ${LEAVE_OUT}`);
|
|
4600
|
+
}
|
|
4601
|
+
/** `elements` as a format string over `args`; inside a plural, its `count` is `%N$d` (or `lld`). */
|
|
4602
|
+
function formatString(elements, args, printf, count) {
|
|
4603
|
+
const formatted = args.length > 0;
|
|
4604
|
+
const place = (el, name, conversion) => {
|
|
4605
|
+
const at = args.indexOf(name);
|
|
4606
|
+
if (at < 0) throw new Error(`{${name}} is not an argument of this message: say it in the source text too`);
|
|
4607
|
+
const { isolate } = placeholderOf(el, name);
|
|
4608
|
+
const spec = `%${at + 1}$${conversion}`;
|
|
4609
|
+
return isolate === void 0 ? spec : `${printf.escape(isolate[0], formatted)}${spec}${printf.escape(isolate[1], formatted)}`;
|
|
4610
|
+
};
|
|
4611
|
+
let out = "";
|
|
4612
|
+
for (const el of elements) {
|
|
4613
|
+
if (count !== void 0 && isCount(el, count)) {
|
|
4614
|
+
out += place(el, count, printf.count);
|
|
4615
|
+
continue;
|
|
4616
|
+
}
|
|
4617
|
+
if (isDateElement(el) || isTimeElement(el)) throw new Error(`{${el.value}} is a date, which ${printf.holder} don't format: pass it formatted, as a plain {${el.value}}, ${LEAVE_OUT}`);
|
|
4618
|
+
if (isNumberElement(el)) throw new Error(`{${el.value}} is a formatted number, and ${printf.platform} fills values in with ${printf.formatter}, not Intl: pass it formatted, as a plain {${el.value}}, ${LEAVE_OUT}`);
|
|
4619
|
+
switch (el.type) {
|
|
4620
|
+
case TYPE$1.literal:
|
|
4621
|
+
out += printf.escape(el.value, formatted);
|
|
4622
|
+
break;
|
|
4623
|
+
case TYPE$1.argument:
|
|
4624
|
+
out += place(el, el.value, printf.string);
|
|
4625
|
+
break;
|
|
4626
|
+
case TYPE$1.pound:
|
|
4627
|
+
out += "#";
|
|
4628
|
+
break;
|
|
4629
|
+
case TYPE$1.select: throw new Error(`{${el.value}} is a select, which ${printf.holder} can't hold: write a key per case and pick one in code, ${LEAVE_OUT}`);
|
|
4630
|
+
case TYPE$1.plural: throw new Error(printf.plural(el.value));
|
|
4631
|
+
case TYPE$1.tag: throw new Error(`<${el.value}> is a tag, and ${printf.holder} here hold plain text: say it without the tag, ${LEAVE_OUT}`);
|
|
4632
|
+
default: throw new Error(`prosebook: no ${printf.platform} writer for ICU element type ${el.type}`);
|
|
4633
|
+
}
|
|
4634
|
+
}
|
|
4635
|
+
return out;
|
|
4636
|
+
}
|
|
4637
|
+
// src/tools/native/android.ts
|
|
4638
|
+
/**
|
|
4639
|
+
* The catalog as Android string resources: `values/<name>.xml` for the source and
|
|
4640
|
+
* `values-<qualifier>/<name>.xml` for every other locale, each holding what that locale shows, every
|
|
4641
|
+
* fallback resolved. An app with the Kotlin target still needs these: its manifest, shortcuts and
|
|
4642
|
+
* other XML resources name strings as `@string/…`, which only a resource answers.
|
|
4643
|
+
*
|
|
4644
|
+
* Android fills values in with `String.format` and picks a plural's arm itself, from its own CLDR
|
|
4645
|
+
* rules, so this takes a narrower subset than the other targets, written straight from the ICU tree:
|
|
4646
|
+
* text, plain `{name}` placeholders, and one cardinal plural, with the text around it in each arm.
|
|
4647
|
+
*/
|
|
4648
|
+
const KEYWORDS$3 = new Set("abstract assert boolean break byte case catch char class const continue default do double else enum extends false final finally float for goto if implements import instanceof int interface long native new null package private protected public return short static strictfp super switch synchronized this throw throws transient true try void volatile while".split(" "));
|
|
4649
|
+
/**
|
|
4650
|
+
* A locale as Android's resource qualifier: `de`, `pt-rBR`, and BCP 47's own form, `b+sr+Latn`,
|
|
4651
|
+
* past what the old form holds (a script, a numeric region, a variant).
|
|
4652
|
+
*/
|
|
4653
|
+
function qualifierOf(locale) {
|
|
4654
|
+
const [language = "", ...rest] = locale.split(/[-_]/);
|
|
4655
|
+
const subtags = [language.toLowerCase(), ...rest.map((s) => s.length === 4 && /^[a-z]+$/i.test(s) ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : s.length === 2 ? s.toUpperCase() : s.toLowerCase())];
|
|
4656
|
+
if (!(/^[a-z]{2,3}$/.test(subtags[0]) && (rest.length === 0 || rest.length === 1 && /^[A-Z]{2}$/.test(subtags[1])))) return `b+${subtags.join("+")}`;
|
|
4657
|
+
return rest.length === 0 ? subtags[0] : `${subtags[0]}-r${subtags[1]}`;
|
|
4658
|
+
}
|
|
4659
|
+
/** Whitespace as aapt2 reads it: collapsed to one space, and trimmed at the ends, outside double quotes. */
|
|
4660
|
+
const LOOSE = /^[ \t\n\v\f\r]|[ \t\n\v\f\r]$|[ \t\n\v\f\r]{2}/;
|
|
4661
|
+
/**
|
|
4662
|
+
* Text as a resource holds it: XML's escapes, then Android's (`\`, `'`, `"`, a newline or a tab).
|
|
4663
|
+
* In a string that is formatted, `%` is doubled, so `String.format` writes one.
|
|
4664
|
+
*/
|
|
4665
|
+
function escape$1(text, formatted) {
|
|
4666
|
+
const out = text.replaceAll("\\", "\\\\").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("'", "\\'").replaceAll("\"", "\\\"").replaceAll("\n", "\\n").replaceAll(" ", "\\t");
|
|
4667
|
+
return formatted ? out.replaceAll("%", "%%") : out;
|
|
4668
|
+
}
|
|
4669
|
+
/**
|
|
4670
|
+
* A whole value: a leading `@` or `?` escaped, since either would make it a reference, and the value
|
|
4671
|
+
* quoted where its spaces would otherwise be collapsed or trimmed.
|
|
4672
|
+
*/
|
|
4673
|
+
function value$3(written) {
|
|
4674
|
+
const out = /^[@?]/.test(written) ? `\\${written}` : written;
|
|
4675
|
+
return LOOSE.test(out) ? `"${out}"` : out;
|
|
4676
|
+
}
|
|
4677
|
+
const PRINTF$1 = {
|
|
4678
|
+
platform: "Android",
|
|
4679
|
+
formatter: "String.format",
|
|
4680
|
+
holder: "Android resources",
|
|
4681
|
+
string: "s",
|
|
4682
|
+
count: "d",
|
|
4683
|
+
escape: escape$1,
|
|
4684
|
+
plural: (name) => `{${name}} is a second plural in the message, and Android's <plurals> picks on one count: split it into a key per count, ${LEAVE_OUT}`
|
|
4685
|
+
};
|
|
4686
|
+
const PLURALS$1 = {
|
|
4687
|
+
platform: "Android",
|
|
4688
|
+
plurals: "Android's <plurals>",
|
|
4689
|
+
exact: " (its zero is a category, which most languages never pick)"
|
|
4690
|
+
};
|
|
4691
|
+
/**
|
|
4692
|
+
* A message as `<string>`, or `<plurals>` when it holds a plural. Android's picks between whole
|
|
4693
|
+
* messages, so the text around the plural goes into each arm: the pseudo-locale's brackets, or a
|
|
4694
|
+
* sentence the plural ends.
|
|
4695
|
+
*/
|
|
4696
|
+
function resource(name, ast, args) {
|
|
4697
|
+
const at = ast.findIndex((el) => el.type === TYPE$1.plural);
|
|
4698
|
+
const plural = ast[at];
|
|
4699
|
+
if (plural?.type !== TYPE$1.plural) {
|
|
4700
|
+
const written = formatString(ast, args, PRINTF$1);
|
|
4701
|
+
return ` <string name="${name}"${args.length === 0 && written.includes("%") ? " formatted=\"false\"" : ""}>${value$3(written)}</string>`;
|
|
4702
|
+
}
|
|
4703
|
+
const [before, after] = [ast.slice(0, at), ast.slice(at + 1)];
|
|
4704
|
+
checkPlural(plural, PLURALS$1);
|
|
4705
|
+
return ` <plurals name="${name}">\n${Object.entries(plural.options).map(([category, option]) => ` <item quantity="${category}">${value$3(formatString([
|
|
4706
|
+
...before,
|
|
4707
|
+
...option.value,
|
|
4708
|
+
...after
|
|
4709
|
+
], args, PRINTF$1, plural.value))}</item>`).join("\n")}\n </plurals>`;
|
|
4710
|
+
}
|
|
4711
|
+
/** The context and what each argument holds, as the comment above an entry. */
|
|
4712
|
+
function comment(m) {
|
|
4713
|
+
const said = noteOf(m);
|
|
4714
|
+
return said === "" ? "" : ` <!-- ${said.replace(/-{2,}/g, "-")} -->\n`;
|
|
4715
|
+
}
|
|
4716
|
+
/** The files `target` writes, by path, or a problem for every message it can't hold. */
|
|
4717
|
+
function androidResources(target, catalog, locales, entries, banner) {
|
|
4718
|
+
const source = locales[0];
|
|
4719
|
+
const asts = new Map([...entries].map(([locale, list]) => [locale, new Map(list)]));
|
|
4720
|
+
const taken = catalog.messages.flatMap((m) => {
|
|
4721
|
+
const words = wordsFor(target, m.key);
|
|
4722
|
+
return words === void 0 ? [] : [{
|
|
4723
|
+
m,
|
|
4724
|
+
name: leading(snake(words))
|
|
4725
|
+
}];
|
|
4726
|
+
});
|
|
4727
|
+
checkNames("android", taken.map(({ m, name }) => [m.key, name]), []);
|
|
4728
|
+
const problems = taken.filter((t) => KEYWORDS$3.has(t.name)).map(({ m, name }) => ({
|
|
4729
|
+
severity: "error",
|
|
4730
|
+
file: m.file,
|
|
4731
|
+
key: m.key,
|
|
4732
|
+
message: `android: ${m.key} is named ${name}, which Java keeps for itself: rename it`
|
|
4733
|
+
}));
|
|
4734
|
+
const bodies = new Map(locales.map((l) => [l, []]));
|
|
4735
|
+
const failed = /* @__PURE__ */ new Set();
|
|
4736
|
+
for (const { m, name } of taken) {
|
|
4737
|
+
const args = [...m.args.keys()];
|
|
4738
|
+
const written = /* @__PURE__ */ new Map();
|
|
4739
|
+
for (const locale of locales) {
|
|
4740
|
+
const found = foundIn(m.key, locale, locales, asts);
|
|
4741
|
+
if (failed.has(m.key)) break;
|
|
4742
|
+
if (found === void 0) continue;
|
|
4743
|
+
let entry = written.get(found);
|
|
4744
|
+
if (entry === void 0) {
|
|
4745
|
+
try {
|
|
4746
|
+
entry = `${comment(m)}${resource(name, asts.get(found).get(m.key), args)}`;
|
|
4747
|
+
} catch (e) {
|
|
4748
|
+
failed.add(m.key);
|
|
4749
|
+
problems.push({
|
|
4750
|
+
severity: "error",
|
|
4751
|
+
file: fileOf(m, found, source),
|
|
4752
|
+
key: m.key,
|
|
4753
|
+
message: `android: ${e.message}`
|
|
4754
|
+
});
|
|
4755
|
+
continue;
|
|
4756
|
+
}
|
|
4757
|
+
written.set(found, entry);
|
|
4758
|
+
}
|
|
4759
|
+
bodies.get(locale).push(entry);
|
|
4760
|
+
}
|
|
4761
|
+
}
|
|
4762
|
+
const head = `<!-- ${banner.replace(/^\/\/ /, "")} -->`;
|
|
4763
|
+
return {
|
|
4764
|
+
files: new Map(locales.map((locale) => {
|
|
4765
|
+
const folder = locale === source ? "values" : `values-${qualifierOf(locale)}`;
|
|
4766
|
+
const body = bodies.get(locale).join("\n");
|
|
4767
|
+
const xml = `<?xml version="1.0" encoding="utf-8"?>\n${head}\n<resources>\n${body}${body === "" ? "" : "\n"}</resources>\n`;
|
|
4768
|
+
return [join(target.out, folder, `${target.name}.xml`), xml];
|
|
4769
|
+
})),
|
|
4770
|
+
problems
|
|
4771
|
+
};
|
|
4772
|
+
}
|
|
4773
|
+
// src/tools/native/apple.ts
|
|
4774
|
+
/**
|
|
4775
|
+
* The catalog as Apple `.strings` tables: `<locale>.lproj/<name>.strings` per locale, each holding
|
|
4776
|
+
* what that locale shows, every fallback resolved. Plain `.strings` and not a String Catalog, which
|
|
4777
|
+
* only Xcode compiles: an app bundled by other tools copies these into its `Resources` as they are.
|
|
4778
|
+
*
|
|
4779
|
+
* The Swift target covers what code shows; these are for what the system reads from a bundle. With
|
|
4780
|
+
* `plist`, the table is `InfoPlist`: the permission prompts and names in that Info.plist, an entry
|
|
4781
|
+
* per key it has, which its own text must match in the source locale.
|
|
4782
|
+
*/
|
|
4783
|
+
/** Text inside a `.strings` file's quotes; `%` doubled where the string has arguments. */
|
|
4784
|
+
function escape(text, formatted) {
|
|
4785
|
+
const out = text.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("\n", "\\n").replaceAll("\r", "\\r").replaceAll(" ", "\\t");
|
|
4786
|
+
return formatted ? out.replaceAll("%", "%%") : out;
|
|
4787
|
+
}
|
|
4788
|
+
const PRINTF = {
|
|
4789
|
+
platform: "Apple",
|
|
4790
|
+
formatter: "String(format:)",
|
|
4791
|
+
holder: ".strings files",
|
|
4792
|
+
string: "@",
|
|
4793
|
+
count: "lld",
|
|
4794
|
+
escape,
|
|
4795
|
+
plural: (name) => `{${name}} is a plural inside another, which a .strings file can't hold: say it without, ${LEAVE_OUT}`
|
|
4796
|
+
};
|
|
4797
|
+
const xml = (text) => text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
4798
|
+
const DICT = {
|
|
4799
|
+
...PRINTF,
|
|
4800
|
+
holder: ".stringsdict files",
|
|
4801
|
+
escape: (text, formatted) => formatted ? xml(text).replaceAll("%", "%%") : xml(text),
|
|
4802
|
+
plural: (name) => `{${name}} is a plural inside another plural's arm, which a .stringsdict here can't hold: write a key per case and pick one in code, ${LEAVE_OUT}`
|
|
4803
|
+
};
|
|
4804
|
+
const PLURALS = {
|
|
4805
|
+
platform: "Apple",
|
|
4806
|
+
plurals: "a .stringsdict's plurals",
|
|
4807
|
+
exact: ""
|
|
4808
|
+
};
|
|
4809
|
+
/**
|
|
4810
|
+
* A message holding plurals as a `.stringsdict` entry: its format, with each plural a variable
|
|
4811
|
+
* (`%1$#@count@`) whose arms the system picks by the count's category, from its own CLDR rules.
|
|
4812
|
+
*/
|
|
4813
|
+
function dictEntry(name, ast, args) {
|
|
4814
|
+
const variables = [];
|
|
4815
|
+
const taken = /* @__PURE__ */ new Map();
|
|
4816
|
+
let format = "";
|
|
4817
|
+
for (const el of ast) {
|
|
4818
|
+
if (el.type !== TYPE$1.plural) {
|
|
4819
|
+
format += formatString([el], args, DICT);
|
|
4820
|
+
continue;
|
|
4821
|
+
}
|
|
4822
|
+
checkPlural(el, PLURALS);
|
|
4823
|
+
const base = el.value.replace(/\W/g, "_");
|
|
4824
|
+
const seen = (taken.get(base) ?? 0) + 1;
|
|
4825
|
+
taken.set(base, seen);
|
|
4826
|
+
const variable = seen === 1 ? base : `${base}${seen}`;
|
|
4827
|
+
format += `%${args.indexOf(el.value) + 1}$#@${variable}@`;
|
|
4828
|
+
const arms = Object.entries(el.options).map(([category, option]) => ` <key>${category}</key>\n <string>${formatString(option.value, args, DICT, el.value)}</string>\n`);
|
|
4829
|
+
variables.push(` <key>${variable}</key>\n <dict>\n <key>NSStringFormatSpecTypeKey</key>\n <string>NSStringPluralRuleType</string>\n <key>NSStringFormatValueTypeKey</key>\n <string>${DICT.count}</string>\n${arms.join("")} </dict>\n`);
|
|
4830
|
+
}
|
|
4831
|
+
return ` <key>${xml(name)}</key>\n <dict>\n <key>NSStringLocalizedFormatKey</key>\n <string>${format}</string>\n${variables.join("")} </dict>\n`;
|
|
4832
|
+
}
|
|
4833
|
+
const ENTITIES = {
|
|
4834
|
+
amp: "&",
|
|
4835
|
+
lt: "<",
|
|
4836
|
+
gt: ">",
|
|
4837
|
+
quot: "\"",
|
|
4838
|
+
apos: "'"
|
|
4839
|
+
};
|
|
4840
|
+
/** An XML plist's string values by key, read as its text is: every `<key>` followed by a `<string>`. */
|
|
4841
|
+
function plistStrings(xml) {
|
|
4842
|
+
const strings = /* @__PURE__ */ new Map();
|
|
4843
|
+
for (const [, key, value] of xml.matchAll(/<key>([^<]*)<\/key>\s*(?:<string>([^<]*)<\/string>|<string\/>)/g)) strings.set(key, (value ?? "").replace(/&(amp|lt|gt|quot|apos|#x?[0-9a-f]+);/gi, (_, e) => e.startsWith("#") ? String.fromCodePoint(Number.parseInt(e.slice(/x/i.test(e[1]) ? 2 : 1), /x/i.test(e[1]) ? 16 : 10)) : ENTITIES[e.toLowerCase()]));
|
|
4844
|
+
return strings;
|
|
4845
|
+
}
|
|
4846
|
+
/** The files `target` writes, by path, or a problem for every message it can't hold. */
|
|
4847
|
+
function appleStrings(target, root, catalog, locales, entries, banner) {
|
|
4848
|
+
const source = locales[0];
|
|
4849
|
+
const asts = new Map([...entries].map(([locale, list]) => [locale, new Map(list)]));
|
|
4850
|
+
const problems = [];
|
|
4851
|
+
const error = (m, message, file = m.file) => problems.push({
|
|
4852
|
+
severity: "error",
|
|
4853
|
+
file,
|
|
4854
|
+
key: m.key,
|
|
4855
|
+
message: `apple: ${message}`
|
|
4856
|
+
});
|
|
4857
|
+
const plist = target.plist;
|
|
4858
|
+
const shown = plist === void 0 ? "" : relative(root, plist).replaceAll("\\", "/");
|
|
4859
|
+
if (plist !== void 0 && !existsSync(plist)) throw new Error(`apple: ${shown} is missing: name the Info.plist the table translates`);
|
|
4860
|
+
const strings = plist === void 0 ? void 0 : plistStrings(readFileSync(plist, "utf8"));
|
|
4861
|
+
const byLower = new Map([...strings?.keys() ?? []].map((k) => [k.toLowerCase(), k]));
|
|
4862
|
+
const taken = catalog.messages.flatMap((m) => {
|
|
4863
|
+
const rest = restOf(target, m.key);
|
|
4864
|
+
if (rest === void 0) return [];
|
|
4865
|
+
if (strings === void 0) return [{
|
|
4866
|
+
m,
|
|
4867
|
+
name: rest
|
|
4868
|
+
}];
|
|
4869
|
+
const name = byLower.get(wordsFor(target, m.key).join("").toLowerCase());
|
|
4870
|
+
if (name === void 0) {
|
|
4871
|
+
error(m, `${m.key} names no string in ${shown}: spell one of its keys, as ns-camera-usage-description is NSCameraUsageDescription, or add it there`);
|
|
4872
|
+
return [];
|
|
4873
|
+
}
|
|
4874
|
+
return [{
|
|
4875
|
+
m,
|
|
4876
|
+
name
|
|
4877
|
+
}];
|
|
4878
|
+
});
|
|
4879
|
+
checkNames("apple", taken.map(({ m, name }) => [m.key, name]), []);
|
|
4880
|
+
for (const { m, name } of taken) {
|
|
4881
|
+
if (strings === void 0) continue;
|
|
4882
|
+
if (m.args.size > 0) {
|
|
4883
|
+
error(m, `${name} is shown as it is written, with nothing filled in: say it without {${[...m.args.keys()].join("}, {")}}`);
|
|
4884
|
+
continue;
|
|
4885
|
+
}
|
|
4886
|
+
const text = staticLiteral(asts.get(source)?.get(m.key) ?? []);
|
|
4887
|
+
if (text !== void 0 && text !== strings.get(name)) error(m, `${shown} says ${JSON.stringify(strings.get(name))} for ${name}: make it ${JSON.stringify(text)}, as the catalog says`);
|
|
4888
|
+
}
|
|
4889
|
+
const lines = new Map(locales.map((l) => [l, []]));
|
|
4890
|
+
const dicts = new Map(locales.map((l) => [l, []]));
|
|
4891
|
+
const failed = new Set(problems.map((p) => p.key));
|
|
4892
|
+
for (const { m, name } of taken) {
|
|
4893
|
+
if (failed.has(m.key)) continue;
|
|
4894
|
+
const args = [...m.args.keys()];
|
|
4895
|
+
const note = noteOf(m);
|
|
4896
|
+
const head = note === "" ? "" : `/* ${note.replaceAll("*/", "* /")} */\n`;
|
|
4897
|
+
const comment = note === "" ? "" : ` <!-- ${note.replace(/-{2,}/g, "-")} -->\n`;
|
|
4898
|
+
const written = /* @__PURE__ */ new Map();
|
|
4899
|
+
for (const locale of locales) {
|
|
4900
|
+
const found = foundIn(m.key, locale, locales, asts);
|
|
4901
|
+
if (found === void 0) continue;
|
|
4902
|
+
let entry = written.get(found);
|
|
4903
|
+
if (entry === void 0) {
|
|
4904
|
+
const ast = asts.get(found).get(m.key);
|
|
4905
|
+
const dict = ast.some((el) => el.type === TYPE$1.plural);
|
|
4906
|
+
try {
|
|
4907
|
+
entry = dict ? {
|
|
4908
|
+
dict,
|
|
4909
|
+
entry: `${comment}${dictEntry(name, ast, args)}`
|
|
4910
|
+
} : {
|
|
4911
|
+
dict,
|
|
4912
|
+
entry: `${head}"${escape(name, false)}" = "${formatString(ast, args, PRINTF)}";\n`
|
|
4913
|
+
};
|
|
4914
|
+
} catch (e) {
|
|
4915
|
+
error(m, e.message, fileOf(m, found, source));
|
|
4916
|
+
break;
|
|
4917
|
+
}
|
|
4918
|
+
written.set(found, entry);
|
|
4919
|
+
}
|
|
4920
|
+
(entry.dict ? dicts : lines).get(locale).push(entry.entry);
|
|
4921
|
+
}
|
|
4922
|
+
}
|
|
4923
|
+
const said = banner.replace(/^\/\/ /, "");
|
|
4924
|
+
const files = new Map(locales.map((locale) => [join(target.out, `${locale}.lproj`, `${target.name}.strings`), [`/* ${said} */\n`, ...lines.get(locale)].join("\n")]));
|
|
4925
|
+
if ([...dicts.values()].some((d) => d.length > 0)) for (const locale of locales) {
|
|
4926
|
+
const body = dicts.get(locale).join("");
|
|
4927
|
+
files.set(join(target.out, `${locale}.lproj`, `${target.name}.stringsdict`), `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<!-- ${said.replace(/-{2,}/g, "-")} -->\n<plist version="1.0">\n<dict>\n${body}</dict>\n</plist>\n`);
|
|
4928
|
+
}
|
|
4929
|
+
return {
|
|
4930
|
+
files,
|
|
4931
|
+
problems
|
|
4932
|
+
};
|
|
4933
|
+
}
|
|
4934
|
+
// src/tools/native/kotlin.ts
|
|
4935
|
+
const KEYWORDS$2 = new Set("as break class continue do else false for fun if in interface is null object package return super this throw true try typealias typeof val var when while".split(" "));
|
|
4936
|
+
const ident$2 = (name) => KEYWORDS$2.has(name) ? `\`${name}\`` : name;
|
|
4937
|
+
const lit$2 = (value) => stringLiteral(value, "kotlin");
|
|
4938
|
+
const entry = (tag) => leading(upper(wordsOf(tag)));
|
|
4939
|
+
const argName$2 = (name) => ident$2(leading(camel(wordsOf(name))));
|
|
4940
|
+
function doc$3(lines, indent) {
|
|
4941
|
+
return `${indent}/**\n${lines.map((l) => l.replaceAll("*/", "*\\/")).map((l) => l === "" ? `${indent} *` : `${indent} * ${l}`).join("\n")}\n${indent} */\n`;
|
|
4942
|
+
}
|
|
4943
|
+
/** The parts as an expression of type `String`, with `rt` the runtime object. */
|
|
4944
|
+
function expr$2(parts, indent, rt) {
|
|
4945
|
+
if (parts.length === 0) return "\"\"";
|
|
4946
|
+
return parts.map((p) => p.type === "text" ? lit$2(p.value) : value$2(p, indent, rt)).join(" + ");
|
|
4947
|
+
}
|
|
4948
|
+
function value$2(part, indent, rt) {
|
|
4949
|
+
const inner = `${indent} `;
|
|
4950
|
+
if (part.type === "string") return argName$2(part.arg);
|
|
4951
|
+
if (part.type === "number") return `${rt}.number(${part.offset === 0 ? argName$2(part.arg) : `${argName$2(part.arg)} - ${float(part.offset)}`}, ${rt}.F${part.format}, ${lit$2(part.fallback)})`;
|
|
4952
|
+
if (part.type === "date") return `${rt}.d${part.format}(${argName$2(part.arg)})`;
|
|
4953
|
+
if (part.type === "select") {
|
|
4954
|
+
const arms = part.arms.map((a) => `${inner}${lit$2(a.key)} -> ${expr$2(a.parts, inner, rt)}\n`).join("");
|
|
4955
|
+
return `when (${argName$2(part.arg)}) {\n${arms}${inner}else -> ${expr$2(part.other, inner, rt)}\n${indent}}`;
|
|
4956
|
+
}
|
|
4957
|
+
if (part.arms.length + part.exact.length === 0) return expr$2(part.other, indent, rt);
|
|
4958
|
+
const arg = argName$2(part.arg);
|
|
4959
|
+
const on = part.on === "round" ? `${rt}.round(${arg})` : part.on === "percent" ? `${arg} * 100.0` : arg;
|
|
4960
|
+
const { minFrac, maxFrac } = part.rounding;
|
|
4961
|
+
const exact = part.exact.map((e) => `${float(e.value)} to ${lit$2(`=${e.value}`)}`).join(", ");
|
|
4962
|
+
const arms = [...part.exact.map((e) => ({
|
|
4963
|
+
key: `=${e.value}`,
|
|
4964
|
+
parts: e.parts
|
|
4965
|
+
})), ...part.arms].map((a) => `${inner}${lit$2(a.key)} -> ${expr$2(a.parts, inner, rt)}\n`).join("");
|
|
4966
|
+
return `when (${`${rt}.plural(${on}, ${float(part.offset)}, listOf(${exact}), ${rt}::r${part.rules}, ${minFrac}, ${maxFrac})`}) {\n${arms}${inner}else -> ${expr$2(part.other, inner, rt)}\n${indent}}`;
|
|
4967
|
+
}
|
|
4968
|
+
function member$1(m, name, rt) {
|
|
4969
|
+
const branches = m.bodies.map((b) => ` ${b.locales.map(entry).join(", ")} -> ${expr$2(b.parts, " ", rt)}\n`).join("");
|
|
4970
|
+
const argDocs = m.args.filter((a) => a.doc !== void 0).map((a) => `@param ${argName$2(a.name)} ${a.doc}`);
|
|
4971
|
+
const head = doc$3(argDocs.length > 0 ? [
|
|
4972
|
+
...m.doc,
|
|
4973
|
+
"",
|
|
4974
|
+
...argDocs
|
|
4975
|
+
] : m.doc, " ");
|
|
4976
|
+
const body = `when (this) {\n${branches} }`;
|
|
4977
|
+
if (m.args.length === 0) return `${head} val ${name}: String\n get() = ${body}\n`;
|
|
4978
|
+
return `${head} fun ${name}(${m.args.map((a) => `${argName$2(a.name)}: ${a.type === "number" ? "Number" : a.type === "date" ? "java.time.Instant" : "String"}`).join(", ")}): String {\n${m.args.map((a) => a.type === "number" ? ` val ${argName$2(a.name)} = ${argName$2(a.name)}.toDouble()\n` : a.type === "date" ? ` val ${argName$2(a.name)} = ${argName$2(a.name)}.atZone(java.time.ZoneId.systemDefault())\n` : "").join("")} return ${body}\n }\n`;
|
|
4979
|
+
}
|
|
4980
|
+
function formatConst$2(spec, i) {
|
|
4981
|
+
const affix = ([prefix, suffix]) => `${lit$2(prefix)} to ${lit$2(suffix)}`;
|
|
4982
|
+
return ` val F${i} = Format(\n${[
|
|
4983
|
+
spec.minInt,
|
|
4984
|
+
spec.minFrac,
|
|
4985
|
+
spec.maxFrac,
|
|
4986
|
+
spec.shift,
|
|
4987
|
+
spec.grouping.primary,
|
|
4988
|
+
spec.grouping.secondary,
|
|
4989
|
+
spec.grouping.min,
|
|
4990
|
+
lit$2(spec.decimal),
|
|
4991
|
+
lit$2(spec.group),
|
|
4992
|
+
spec.digits === void 0 ? "null" : `listOf(${spec.digits.map(lit$2).join(", ")})`,
|
|
4993
|
+
affix(spec.positive),
|
|
4994
|
+
affix(spec.negative),
|
|
4995
|
+
affix(spec.zero),
|
|
4996
|
+
affix(spec.negativeZero),
|
|
4997
|
+
lit$2(spec.infinity),
|
|
4998
|
+
lit$2(spec.negativeInfinity)
|
|
4999
|
+
].map((f) => ` ${f}`).join(",\n")},\n )\n`;
|
|
5000
|
+
}
|
|
5001
|
+
const FIELDS = {
|
|
5002
|
+
year: "t.year",
|
|
5003
|
+
month: "t.monthValue",
|
|
5004
|
+
day: "t.dayOfMonth",
|
|
5005
|
+
hour: "t.hour",
|
|
5006
|
+
minute: "t.minute",
|
|
5007
|
+
second: "t.second",
|
|
5008
|
+
weekday: "(t.dayOfWeek.value % 7)"
|
|
5009
|
+
};
|
|
5010
|
+
/** A date format as a function of the date in the system's time zone. */
|
|
5011
|
+
function dateFn$2(spec, i) {
|
|
5012
|
+
const digits = spec.digits === void 0 ? "null" : `G${i}`;
|
|
5013
|
+
const at = (field) => FIELDS[field];
|
|
5014
|
+
const lines = spec.pieces.map((piece) => {
|
|
5015
|
+
if (piece.type === "text") return `append(${lit$2(piece.value)})`;
|
|
5016
|
+
if (piece.type === "name") return `append(arrayOf(${piece.names.map(lit$2).join(", ")})[${pieceExpr(piece, at)}])`;
|
|
5017
|
+
return `append(pad(${pieceExpr(piece, at)}, ${piece.pad}, ${digits}))`;
|
|
5018
|
+
});
|
|
5019
|
+
return `${spec.digits === void 0 ? "" : ` private val G${i} = arrayOf(${spec.digits.map(lit$2).join(", ")})\n`} fun d${i}(t: java.time.ZonedDateTime): String = buildString {\n${lines.map((l) => ` ${l}\n`).join("")} }\n`;
|
|
5020
|
+
}
|
|
5021
|
+
const DATE$2 = `
|
|
5022
|
+
/** \`value\` written with at least \`width\` digits, in \`digits\` where the locale has its own. */
|
|
5023
|
+
private fun pad(value: Int, width: Int, digits: Array<String>?): String {
|
|
5024
|
+
val text = value.toString().padStart(width, '0')
|
|
5025
|
+
return if (digits == null) text else text.map { if (it in '0'..'9') digits[it - '0'] else it.toString() }.joinToString("")
|
|
5026
|
+
}
|
|
5027
|
+
`;
|
|
5028
|
+
const NUMBER$2 = `
|
|
5029
|
+
/** A number format, taken apart when the file was generated. */
|
|
5030
|
+
class Format(
|
|
5031
|
+
val minInt: Int,
|
|
5032
|
+
val minFrac: Int,
|
|
5033
|
+
val maxFrac: Int,
|
|
5034
|
+
val shift: Int,
|
|
5035
|
+
val primary: Int,
|
|
5036
|
+
val secondary: Int,
|
|
5037
|
+
val minGroup: Int,
|
|
5038
|
+
val decimal: String,
|
|
5039
|
+
val group: String,
|
|
5040
|
+
val digits: List<String>?,
|
|
5041
|
+
val positive: Pair<String, String>,
|
|
5042
|
+
val negative: Pair<String, String>,
|
|
5043
|
+
val zero: Pair<String, String>,
|
|
5044
|
+
val negativeZero: Pair<String, String>,
|
|
5045
|
+
val infinity: String,
|
|
5046
|
+
val negativeInfinity: String,
|
|
5047
|
+
)
|
|
5048
|
+
|
|
5049
|
+
/** \`value\` as \`format\` writes it, or \`fallback\` where it isn't a number. */
|
|
5050
|
+
fun number(value: Double, format: Format, fallback: String): String {
|
|
5051
|
+
if (value.isNaN()) return fallback
|
|
5052
|
+
if (value.isInfinite()) return if (value > 0) format.infinity else format.negativeInfinity
|
|
5053
|
+
val (written, frac) = digits(Math.abs(value), format.shift, format.minFrac, format.maxFrac)
|
|
5054
|
+
val zero = (written + frac).all { it == '0' }
|
|
5055
|
+
val negative = value.toRawBits() < 0
|
|
5056
|
+
val (prefix, suffix) = if (zero) (if (negative) format.negativeZero else format.zero) else (if (negative) format.negative else format.positive)
|
|
5057
|
+
val int = written.padStart(maxOf(format.minInt, 1), '0')
|
|
5058
|
+
val grouped = format.minGroup > 0 && int.length >= format.primary + format.minGroup
|
|
5059
|
+
val out = StringBuilder(prefix)
|
|
5060
|
+
for ((i, digit) in int.withIndex()) {
|
|
5061
|
+
val left = int.length - i
|
|
5062
|
+
if (grouped && i > 0 && (left == format.primary || (left > format.primary && (left - format.primary) % format.secondary == 0))) {
|
|
5063
|
+
out.append(format.group)
|
|
5064
|
+
}
|
|
5065
|
+
out.append(shown(digit, format))
|
|
5066
|
+
}
|
|
5067
|
+
if (frac.isNotEmpty()) {
|
|
5068
|
+
out.append(format.decimal)
|
|
5069
|
+
for (digit in frac) out.append(shown(digit, format))
|
|
5070
|
+
}
|
|
5071
|
+
return out.append(suffix).toString()
|
|
5072
|
+
}
|
|
5073
|
+
|
|
5074
|
+
private fun shown(digit: Char, format: Format): String = format.digits?.get(digit - '0') ?: digit.toString()
|
|
5075
|
+
|
|
5076
|
+
/**
|
|
5077
|
+
* The shortest digits that read back as \`value\`, as \`Intl\` rounds them: moved \`shift\` places
|
|
5078
|
+
* right, rounded half away from zero to \`max\` fraction digits, and trailing zeros cut to \`min\`.
|
|
5079
|
+
*/
|
|
5080
|
+
fun digits(value: Double, shift: Int, min: Int, max: Int): Pair<String, String> {
|
|
5081
|
+
// \`toString\` writes the shortest digits that read back, with an exponent past a size.
|
|
5082
|
+
val plain = java.math.BigDecimal(value.toString()).toPlainString()
|
|
5083
|
+
val int = plain.substringBefore('.')
|
|
5084
|
+
val all = (int + plain.substringAfter('.', "")).map { it - '0' }.toMutableList()
|
|
5085
|
+
var point = int.length + shift
|
|
5086
|
+
while (all.size <= point + max) all.add(0)
|
|
5087
|
+
val next = all[point + max]
|
|
5088
|
+
while (all.size > point + max) all.removeAt(all.size - 1)
|
|
5089
|
+
if (next >= 5) {
|
|
5090
|
+
var at = all.size - 1
|
|
5091
|
+
while (at >= 0 && all[at] == 9) all[at--] = 0
|
|
5092
|
+
if (at < 0) {
|
|
5093
|
+
all.add(0, 1)
|
|
5094
|
+
point += 1
|
|
5095
|
+
} else {
|
|
5096
|
+
all[at] += 1
|
|
5097
|
+
}
|
|
5098
|
+
}
|
|
5099
|
+
val frac = all.subList(point, all.size).toMutableList()
|
|
5100
|
+
while (frac.size > min && frac.last() == 0) frac.removeAt(frac.size - 1)
|
|
5101
|
+
return all.subList(0, point).dropWhile { it == 0 }.joinToString("") to frac.joinToString("")
|
|
5102
|
+
}
|
|
5103
|
+
`;
|
|
5104
|
+
const PLURAL$2 = `
|
|
5105
|
+
/** CLDR's plural operands of a number as written. */
|
|
5106
|
+
class Operands(val n: Double, val i: Double, val v: Double, val w: Double, val f: Double, val t: Double, val c: Double, val e: Double)
|
|
5107
|
+
|
|
5108
|
+
/** The arm a plural takes: the \`=N\` that \`value\` equals, else the category of \`value\` less \`offset\`. */
|
|
5109
|
+
fun plural(value: Double, offset: Double, exact: List<Pair<Double, String>>, rules: (Operands) -> String, min: Int, max: Int): String =
|
|
5110
|
+
exact.firstOrNull { it.first == value }?.second ?: category(rules, value - offset, min, max)
|
|
5111
|
+
|
|
5112
|
+
/** The category \`rules\` give \`value\`, rounded to between \`min\` and \`max\` fraction digits. */
|
|
5113
|
+
private fun category(rules: (Operands) -> String, value: Double, min: Int, max: Int): String {
|
|
5114
|
+
if (!value.isFinite()) return "other"
|
|
5115
|
+
val (written, frac) = digits(Math.abs(value), 0, min, max)
|
|
5116
|
+
val int = written.ifEmpty { "0" }
|
|
5117
|
+
val trimmed = frac.trimEnd('0')
|
|
5118
|
+
val read = { s: String -> s.toDoubleOrNull() ?: 0.0 }
|
|
5119
|
+
return rules(
|
|
5120
|
+
Operands(
|
|
5121
|
+
n = read(if (frac.isEmpty()) int else "$int.$frac"),
|
|
5122
|
+
i = read(int),
|
|
5123
|
+
v = frac.length.toDouble(),
|
|
5124
|
+
w = trimmed.length.toDouble(),
|
|
5125
|
+
f = read(frac),
|
|
5126
|
+
t = read(trimmed),
|
|
5127
|
+
c = 0.0,
|
|
5128
|
+
e = 0.0,
|
|
5129
|
+
),
|
|
5130
|
+
)
|
|
5131
|
+
}
|
|
5132
|
+
|
|
5133
|
+
/** An integer in the range, as CLDR's ranges hold. */
|
|
5134
|
+
private fun within(value: Double, from: Double, to: Double): Boolean = value % 1.0 == 0.0 && value >= from && value <= to
|
|
5135
|
+
|
|
5136
|
+
/** \`Math.round\`: half up, toward positive infinity. */
|
|
5137
|
+
fun round(value: Double): Double {
|
|
5138
|
+
val floor = Math.floor(value)
|
|
5139
|
+
return if (value - floor >= 0.5) floor + 1 else floor
|
|
5140
|
+
}
|
|
5141
|
+
`;
|
|
5142
|
+
function kotlinFile(model, target, banner) {
|
|
5143
|
+
const name = target.name;
|
|
5144
|
+
const rt = `${name}Runtime`;
|
|
5145
|
+
const names = model.messages.map((m) => [m.key, ident$2(leading(camel(m.words)))]);
|
|
5146
|
+
checkNames("kotlin", names, [
|
|
5147
|
+
"tag",
|
|
5148
|
+
"name",
|
|
5149
|
+
"ordinal",
|
|
5150
|
+
"entries",
|
|
5151
|
+
"negotiate",
|
|
5152
|
+
"SOURCE"
|
|
5153
|
+
]);
|
|
5154
|
+
const entries = model.locales.map((l) => ` /** \`${l}\`${l === model.source ? ", the source" : ""}. */\n ${entry(l)}(${lit$2(l)})`).join(",\n");
|
|
5155
|
+
const members = model.messages.map((m, i) => member$1(m, names[i][1], rt)).join("\n");
|
|
5156
|
+
const rules = model.rules.map(({ name: rule, rules }, i) => {
|
|
5157
|
+
return ` /** ${rule} */\n fun r${i}(o: Operands): String {\n${rules.map(([category, condition]) => ` if (${conditionCode(condition, {
|
|
5158
|
+
field: (op) => `o.${op}`,
|
|
5159
|
+
mod: (v, by) => `${v} % ${by}`,
|
|
5160
|
+
within: (v, from, to) => `within(${v}, ${from}, ${to})`
|
|
5161
|
+
})}) return ${lit$2(category)}\n`).join("")} return "other"\n }\n`;
|
|
5162
|
+
}).join("\n");
|
|
5163
|
+
const runtime = model.formats.length + model.rules.length + model.dates.length === 0 ? "" : `
|
|
5164
|
+
/** What formats the messages, generated with them. */
|
|
5165
|
+
private object ${rt} {${model.formats.length + model.rules.length > 0 ? NUMBER$2 : ""}${model.rules.length > 0 ? PLURAL$2 : ""}${model.dates.length > 0 ? DATE$2 : ""}
|
|
5166
|
+
${[...model.formats.map(formatConst$2), ...model.dates.map(dateFn$2)].join("\n")}${rules === "" ? "" : `\n${rules}`}}
|
|
5167
|
+
`;
|
|
5168
|
+
return `${banner}
|
|
5169
|
+
@file:Suppress("unused", "NAME_SHADOWING", "KotlinConstantConditions", "ConvertTwoComparisonsToRangeCheck")
|
|
5170
|
+
${target.package === void 0 ? "" : `\npackage ${target.package}\n`}
|
|
5171
|
+
/** A locale the catalog is written or translated in, and its messages. */
|
|
5172
|
+
enum class ${name}(val tag: String) {
|
|
5173
|
+
${entries};
|
|
5174
|
+
|
|
5175
|
+
${members}
|
|
5176
|
+
companion object {
|
|
5177
|
+
/** The locale the catalog is written in, which every message falls back to. */
|
|
5178
|
+
val SOURCE: ${name} = ${entry(model.source)}
|
|
5179
|
+
|
|
5180
|
+
/**
|
|
5181
|
+
* The best locale for what was asked, most preferred first: each tag tries an exact match, then
|
|
5182
|
+
* its parents (\`pt-BR\` finds \`pt\`), then any locale of its language. The source if none is.
|
|
5183
|
+
*/
|
|
5184
|
+
fun negotiate(requested: List<String>): ${name} {
|
|
5185
|
+
fun language(tag: String) = tag.substringBefore('-').lowercase()
|
|
5186
|
+
for (want in requested) {
|
|
5187
|
+
var tag = want
|
|
5188
|
+
while (true) {
|
|
5189
|
+
entries.firstOrNull { it.tag.equals(tag, ignoreCase = true) }?.let { return it }
|
|
5190
|
+
val cut = tag.lastIndexOf('-')
|
|
5191
|
+
if (cut < 0) break
|
|
5192
|
+
tag = tag.substring(0, cut)
|
|
5193
|
+
}
|
|
5194
|
+
entries.firstOrNull { language(it.tag) == language(want) }?.let { return it }
|
|
5195
|
+
}
|
|
5196
|
+
return SOURCE
|
|
5197
|
+
}
|
|
5198
|
+
}
|
|
5199
|
+
}
|
|
5200
|
+
${runtime}`;
|
|
4232
5201
|
}
|
|
4233
5202
|
// src/tools/native/rust.ts
|
|
4234
5203
|
const KEYWORDS$1 = new Set("as async await break const continue crate dyn else enum extern false fn for if impl in let loop match mod move mut pub ref return self Self static struct super trait true type unsafe use where while abstract become box do final macro override priv try typeof unsized virtual yield gen".split(" "));
|
|
@@ -4263,6 +5232,7 @@ function value$1(part, indent) {
|
|
|
4263
5232
|
const inner = `${indent} `;
|
|
4264
5233
|
if (part.type === "string") return argName$1(part.arg);
|
|
4265
5234
|
if (part.type === "number") return `rt::number(${part.offset === 0 ? argName$1(part.arg) : `${argName$1(part.arg)} - ${float(part.offset)}`}, &rt::F${part.format}, ${lit$1(part.fallback)})`;
|
|
5235
|
+
if (part.type === "date") return `rt::D${part.format}(&${argName$1(part.arg)})`;
|
|
4266
5236
|
if (part.type === "select") {
|
|
4267
5237
|
const arms = part.arms.map((a) => `${inner}${lit$1(a.key)} => ${expr$1(a.parts, inner)},\n`).join("");
|
|
4268
5238
|
return `match ${argName$1(part.arg)} {\n${arms}${inner}_ => ${expr$1(part.other, inner)},\n${indent}}`;
|
|
@@ -4282,7 +5252,7 @@ function method(m, name, enumName) {
|
|
|
4282
5252
|
const head = doc$2(m.doc, " ");
|
|
4283
5253
|
const arms = (render) => m.bodies.map((b) => ` ${b.locales.map((l) => `${enumName}::${variant(l)}`).join(" | ")} => ${render(b.parts)},\n`).join("");
|
|
4284
5254
|
if (m.bodies.every((b) => b.parts.every((p) => p.type === "text")) && m.args.length === 0) return `${head} pub fn ${name}(self) -> &'static str {\n match self {\n${arms((parts) => lit$1(parts[0]?.type === "text" ? parts[0].value : ""))} }\n }\n`;
|
|
4285
|
-
const params = m.args.map((a) => `, ${argName$1(a.name)}: ${a.type === "number" ? "impl Number" : "&str"}`).join("");
|
|
5255
|
+
const params = m.args.map((a) => `, ${argName$1(a.name)}: ${a.type === "number" ? "impl Number" : a.type === "date" ? "DateTime" : "&str"}`).join("");
|
|
4286
5256
|
const argDocs = m.args.filter((a) => a.doc !== void 0).map((a) => `* \`${argName$1(a.name)}\`: ${a.doc}`);
|
|
4287
5257
|
return `${argDocs.length > 0 ? `${head} ///\n${doc$2(argDocs, " ")}` : head} pub fn ${name}(self${params}) -> String {\n${m.args.filter((a) => a.type === "number").map((a) => ` let ${argName$1(a.name)} = ${argName$1(a.name)}.as_f64();\n`).join("")} match self {\n${arms((parts) => expr$1(parts, " "))} }\n }\n`;
|
|
4288
5258
|
}
|
|
@@ -4307,6 +5277,76 @@ function formatConst$1(spec, i) {
|
|
|
4307
5277
|
`negative_infinity: ${lit$1(spec.negativeInfinity)}`
|
|
4308
5278
|
].map((f) => ` ${f},\n`).join("")} };\n`;
|
|
4309
5279
|
}
|
|
5280
|
+
/** A date format as a function of the date. */
|
|
5281
|
+
function dateFn$1(spec, i) {
|
|
5282
|
+
const digits = spec.digits === void 0 ? "None" : `Some(G${i})`;
|
|
5283
|
+
const at = (field) => field === "weekday" ? "t.weekday() as i64" : `t.${field} as i64`;
|
|
5284
|
+
const lines = spec.pieces.map((piece) => {
|
|
5285
|
+
if (piece.type === "text") return `s += ${lit$1(piece.value)};`;
|
|
5286
|
+
if (piece.type === "name") return `s += [${piece.names.map(lit$1).join(", ")}][(${pieceExpr(piece, at)}) as usize];`;
|
|
5287
|
+
return `s += &pad(${pieceExpr(piece, at)}, ${piece.pad}, ${digits});`;
|
|
5288
|
+
});
|
|
5289
|
+
return `${spec.digits === void 0 ? "" : ` const G${i}: [&str; 10] = [${spec.digits.map(lit$1).join(", ")}];\n`} pub fn D${i}(t: &super::DateTime) -> String {\n let mut s = String::new();\n${lines.map((l) => ` ${l}\n`).join("")} s\n }\n`;
|
|
5290
|
+
}
|
|
5291
|
+
const DATE$1 = `
|
|
5292
|
+
/// \`value\` written with at least \`width\` digits, in \`digits\` where the locale has its own.
|
|
5293
|
+
fn pad(value: i64, width: usize, digits: Option<[&'static str; 10]>) -> String {
|
|
5294
|
+
let text = format!("{:0width$}", value, width = width);
|
|
5295
|
+
match digits {
|
|
5296
|
+
Some(digits) => text.bytes().map(|b| if b.is_ascii_digit() { digits[(b - b'0') as usize] } else { "-" }).collect(),
|
|
5297
|
+
None => text,
|
|
5298
|
+
}
|
|
5299
|
+
}
|
|
5300
|
+
`;
|
|
5301
|
+
const DATE_TIME = `
|
|
5302
|
+
/// A date and time as a clock shows it, in the time zone it is shown in.
|
|
5303
|
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
|
5304
|
+
pub struct DateTime {
|
|
5305
|
+
pub year: i32,
|
|
5306
|
+
/// 1 to 12.
|
|
5307
|
+
pub month: u8,
|
|
5308
|
+
pub day: u8,
|
|
5309
|
+
/// 0 to 23.
|
|
5310
|
+
pub hour: u8,
|
|
5311
|
+
pub minute: u8,
|
|
5312
|
+
pub second: u8,
|
|
5313
|
+
}
|
|
5314
|
+
|
|
5315
|
+
impl DateTime {
|
|
5316
|
+
/// The time \`seconds\` after the Unix epoch, on a clock \`offset\` seconds ahead of UTC: the
|
|
5317
|
+
/// local time's offset where it is shown, which Rust's standard library can't look up.
|
|
5318
|
+
pub fn from_unix(seconds: i64, offset: i32) -> DateTime {
|
|
5319
|
+
let local = seconds + offset as i64;
|
|
5320
|
+
let (days, time) = (local.div_euclid(86_400), local.rem_euclid(86_400));
|
|
5321
|
+
// Howard Hinnant's civil_from_days: the Gregorian date \`days\` after 1970-01-01.
|
|
5322
|
+
let z = days + 719_468;
|
|
5323
|
+
let (era, doe) = (z.div_euclid(146_097), z.rem_euclid(146_097));
|
|
5324
|
+
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
|
5325
|
+
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
|
5326
|
+
let mp = (5 * doy + 2) / 153;
|
|
5327
|
+
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
|
5328
|
+
DateTime {
|
|
5329
|
+
year: (yoe + era * 400 + if month <= 2 { 1 } else { 0 }) as i32,
|
|
5330
|
+
month: month as u8,
|
|
5331
|
+
day: (doy - (153 * mp + 2) / 5 + 1) as u8,
|
|
5332
|
+
hour: (time / 3600) as u8,
|
|
5333
|
+
minute: (time / 60 % 60) as u8,
|
|
5334
|
+
second: (time % 60) as u8,
|
|
5335
|
+
}
|
|
5336
|
+
}
|
|
5337
|
+
|
|
5338
|
+
/// The day of the week, 0 for Sunday.
|
|
5339
|
+
pub fn weekday(self) -> u8 {
|
|
5340
|
+
// days_from_civil, its inverse.
|
|
5341
|
+
let m = self.month as i64;
|
|
5342
|
+
let y = self.year as i64 - if m <= 2 { 1 } else { 0 };
|
|
5343
|
+
let (era, yoe) = (y.div_euclid(400), y.rem_euclid(400));
|
|
5344
|
+
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + self.day as i64 - 1;
|
|
5345
|
+
let days = era * 146_097 + yoe * 365 + yoe / 4 - yoe / 100 + doy - 719_468;
|
|
5346
|
+
(days + 4).rem_euclid(7) as u8
|
|
5347
|
+
}
|
|
5348
|
+
}
|
|
5349
|
+
`;
|
|
4310
5350
|
const NUMBER$1 = `
|
|
4311
5351
|
/// A number format, taken apart when the file was generated.
|
|
4312
5352
|
pub struct Format {
|
|
@@ -4497,6 +5537,8 @@ function rustModule(model, target, banner) {
|
|
|
4497
5537
|
const tags = model.locales.map((l) => ` ${name}::${variant(l)} => ${lit$1(l)},\n`).join("");
|
|
4498
5538
|
const methods = model.messages.map((m, i) => method(m, names[i][1], name)).join("\n");
|
|
4499
5539
|
const usesNumbers = model.messages.some((m) => m.args.some((a) => a.type === "number"));
|
|
5540
|
+
const usesDates = model.dates.length > 0;
|
|
5541
|
+
const usesFormats = model.formats.length + model.rules.length > 0;
|
|
4500
5542
|
const rules = model.rules.map(({ name: rule, rules }, i) => {
|
|
4501
5543
|
const checks = rules.map(([category, condition]) => ` if ${conditionCode(condition, {
|
|
4502
5544
|
field: (op) => `o.${op}`,
|
|
@@ -4505,14 +5547,14 @@ function rustModule(model, target, banner) {
|
|
|
4505
5547
|
})} {\n return ${lit$1(category)};\n }\n`);
|
|
4506
5548
|
return ` /// ${rule}\n pub fn R${i}(${rules.length > 0 ? "o" : "_o"}: &Operands) -> &'static str {\n${checks.join("")} "other"\n }\n`;
|
|
4507
5549
|
}).join("\n");
|
|
4508
|
-
const runtime =
|
|
5550
|
+
const runtime = !usesFormats && !usesDates ? "" : `
|
|
4509
5551
|
/// What formats the messages, generated with them.
|
|
4510
|
-
mod rt {${NUMBER$1}${model.rules.length > 0 ? PLURAL$1 : ""}
|
|
4511
|
-
${model.formats.map(formatConst$1).join("\n")}${rules === "" ? "" : `\n${rules}`}}
|
|
5552
|
+
mod rt {${usesFormats ? NUMBER$1 : ""}${model.rules.length > 0 ? PLURAL$1 : ""}${usesDates ? DATE$1 : ""}
|
|
5553
|
+
${[...model.formats.map(formatConst$1), ...model.dates.map(dateFn$1)].join("\n")}${rules === "" ? "" : `\n${rules}`}}
|
|
4512
5554
|
`;
|
|
4513
5555
|
return `${banner}
|
|
4514
5556
|
#![allow(dead_code, non_snake_case, clippy::all)]
|
|
4515
|
-
${usesNumbers ? NUMBER_TRAIT : ""}
|
|
5557
|
+
${usesNumbers ? NUMBER_TRAIT : ""}${usesDates ? DATE_TIME : ""}
|
|
4516
5558
|
/// A locale the catalog is written or translated in, and its messages.
|
|
4517
5559
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
|
4518
5560
|
pub enum ${name} {
|
|
@@ -4576,6 +5618,7 @@ function value(part, indent, rt) {
|
|
|
4576
5618
|
const inner = `${indent} `;
|
|
4577
5619
|
if (part.type === "string") return argName(part.arg);
|
|
4578
5620
|
if (part.type === "number") return `${rt}.number(${part.offset === 0 ? argName(part.arg) : `${argName(part.arg)} - ${float(part.offset)}`}, ${rt}.f${part.format}, ${lit(part.fallback)})`;
|
|
5621
|
+
if (part.type === "date") return `${rt}.d${part.format}(${argName(part.arg)})`;
|
|
4579
5622
|
if (part.type === "select") {
|
|
4580
5623
|
const arms = part.arms.map((a) => `${inner}case ${lit(a.key)}: return ${expr(a.parts, inner, rt)}\n`).join("");
|
|
4581
5624
|
return `{ () -> String in\n${inner}switch ${argName(part.arg)} {\n${arms}${inner}default: return ${expr(part.other, inner, rt)}\n${inner}}\n${indent}}()`;
|
|
@@ -4601,7 +5644,7 @@ function member(m, name, rt, number) {
|
|
|
4601
5644
|
] : m.doc, " ");
|
|
4602
5645
|
const body = ` switch self {\n${cases((parts) => expr(parts, " ", rt))} }\n`;
|
|
4603
5646
|
if (m.args.length === 0) return `${head} public var ${name}: String {\n${body} }\n`;
|
|
4604
|
-
return `${head} public func ${name}(${m.args.map((a) => `${argName(a.name)}: ${a.type === "number" ? `some ${number.type}` : "String"}`).join(", ")}) -> String {\n${m.args.
|
|
5647
|
+
return `${head} public func ${name}(${m.args.map((a) => `${argName(a.name)}: ${a.type === "number" ? `some ${number.type}` : a.type === "date" ? "Date" : "String"}`).join(", ")}) -> String {\n${m.args.map((a) => a.type === "number" ? ` let ${argName(a.name)} = ${argName(a.name)}.${number.value}\n` : a.type === "date" ? ` let ${argName(a.name)} = ${rt}.clock(${argName(a.name)})\n` : "").join("")}${body} }\n`;
|
|
4605
5648
|
}
|
|
4606
5649
|
function formatConst(spec, i) {
|
|
4607
5650
|
const affix = ([prefix, suffix]) => `(${lit(prefix)}, ${lit(suffix)})`;
|
|
@@ -4624,6 +5667,47 @@ function formatConst(spec, i) {
|
|
|
4624
5667
|
`negativeInfinity: ${lit(spec.negativeInfinity)}`
|
|
4625
5668
|
].map((f) => ` ${f}`).join(",\n")}\n )\n`;
|
|
4626
5669
|
}
|
|
5670
|
+
/** A date format as a function of the clock's fields. */
|
|
5671
|
+
function dateFn(spec, i) {
|
|
5672
|
+
const digits = spec.digits === void 0 ? "nil" : `g${i}`;
|
|
5673
|
+
const at = (field) => `t.${field}`;
|
|
5674
|
+
const lines = spec.pieces.map((piece) => {
|
|
5675
|
+
if (piece.type === "text") return `s += ${lit(piece.value)}`;
|
|
5676
|
+
if (piece.type === "name") return `s += [${piece.names.map(lit).join(", ")}][${pieceExpr(piece, at)}]`;
|
|
5677
|
+
return `s += pad(${pieceExpr(piece, at)}, ${piece.pad}, ${digits})`;
|
|
5678
|
+
});
|
|
5679
|
+
return `${spec.digits === void 0 ? "" : ` static let g${i} = [${spec.digits.map(lit).join(", ")}]\n`} static func d${i}(_ t: Clock) -> String {\n var s = ""\n${lines.map((l) => ` ${l}\n`).join("")} return s\n }\n`;
|
|
5680
|
+
}
|
|
5681
|
+
const DATE = `
|
|
5682
|
+
/// A date as the clock shows it in the device's time zone; \`weekday\` is 0 for Sunday.
|
|
5683
|
+
struct Clock {
|
|
5684
|
+
let year: Int
|
|
5685
|
+
let month: Int
|
|
5686
|
+
let day: Int
|
|
5687
|
+
let hour: Int
|
|
5688
|
+
let minute: Int
|
|
5689
|
+
let second: Int
|
|
5690
|
+
let weekday: Int
|
|
5691
|
+
}
|
|
5692
|
+
|
|
5693
|
+
static func clock(_ date: Date) -> Clock {
|
|
5694
|
+
var calendar = Calendar(identifier: .gregorian)
|
|
5695
|
+
calendar.timeZone = .current
|
|
5696
|
+
let c = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second, .weekday], from: date)
|
|
5697
|
+
return Clock(
|
|
5698
|
+
year: c.year ?? 0, month: c.month ?? 1, day: c.day ?? 1, hour: c.hour ?? 0, minute: c.minute ?? 0,
|
|
5699
|
+
second: c.second ?? 0, weekday: (c.weekday ?? 1) - 1
|
|
5700
|
+
)
|
|
5701
|
+
}
|
|
5702
|
+
|
|
5703
|
+
/// \`value\` written with at least \`width\` digits, in \`digits\` where the locale has its own.
|
|
5704
|
+
private static func pad(_ value: Int, _ width: Int, _ digits: [String]?) -> String {
|
|
5705
|
+
var text = String(value)
|
|
5706
|
+
while text.count < width { text = "0" + text }
|
|
5707
|
+
guard let digits else { return text }
|
|
5708
|
+
return text.map { c in c.wholeNumberValue.map { digits[$0] } ?? String(c) }.joined()
|
|
5709
|
+
}
|
|
5710
|
+
`;
|
|
4627
5711
|
const NUMBER = `
|
|
4628
5712
|
/// A number format, taken apart when the file was generated.
|
|
4629
5713
|
struct Format {
|
|
@@ -4795,7 +5879,10 @@ function swiftFile(model, target, banner) {
|
|
|
4795
5879
|
value: `${name.charAt(0).toLowerCase()}${name.slice(1)}Value`
|
|
4796
5880
|
};
|
|
4797
5881
|
const members = model.messages.map((m, i) => member(m, names[i][1], rt, number)).join("\n");
|
|
4798
|
-
const
|
|
5882
|
+
const usesNumbers = model.messages.some((m) => m.args.some((a) => a.type === "number"));
|
|
5883
|
+
const usesDates = model.dates.length > 0;
|
|
5884
|
+
const usesFormats = model.formats.length + model.rules.length > 0;
|
|
5885
|
+
const numberProtocol = !usesNumbers ? "" : `
|
|
4799
5886
|
/// A number a message formats: any of Swift's integers or floats.
|
|
4800
5887
|
public protocol ${number.type} {
|
|
4801
5888
|
var ${number.value}: Double { get }
|
|
@@ -4810,10 +5897,10 @@ ${NUMBER_TYPES.map((t) => `extension ${t}: ${number.type} { public var ${number.
|
|
|
4810
5897
|
within: (v, from, to) => `within(${v}, ${from}, ${to})`
|
|
4811
5898
|
})} { return ${lit(category)} }\n`).join("")} return "other"\n }\n`;
|
|
4812
5899
|
}).join("\n");
|
|
4813
|
-
const runtime =
|
|
5900
|
+
const runtime = !usesFormats && !usesDates ? "" : `
|
|
4814
5901
|
/// What formats the messages, generated with them.
|
|
4815
|
-
private enum ${rt} {${NUMBER}${model.rules.length > 0 ? PLURAL : ""}
|
|
4816
|
-
${model.formats.map(formatConst).join("\n")}${rules === "" ? "" : `\n${rules}`}}
|
|
5902
|
+
private enum ${rt} {${usesFormats ? NUMBER : ""}${model.rules.length > 0 ? PLURAL : ""}${usesDates ? DATE : ""}
|
|
5903
|
+
${[...model.formats.map(formatConst), ...model.dates.map(dateFn)].join("\n")}${rules === "" ? "" : `\n${rules}`}}
|
|
4817
5904
|
`;
|
|
4818
5905
|
return `${banner}
|
|
4819
5906
|
import Foundation
|
|
@@ -4848,8 +5935,9 @@ ${runtime}`;
|
|
|
4848
5935
|
}
|
|
4849
5936
|
// src/tools/native/index.ts
|
|
4850
5937
|
/**
|
|
4851
|
-
* The native files `generate` writes beside the TypeScript, one per target in the config's `native
|
|
4852
|
-
*
|
|
5938
|
+
* The native files `generate` writes beside the TypeScript, one per target in the config's `native`
|
|
5939
|
+
* (Android's and Apple's string resources, a file per locale). A message a target can't render
|
|
5940
|
+
* fails the build with the reason, as a translation that drifted does.
|
|
4853
5941
|
*/
|
|
4854
5942
|
const WRITERS = {
|
|
4855
5943
|
rust: rustModule,
|
|
@@ -4859,18 +5947,22 @@ const WRITERS = {
|
|
|
4859
5947
|
function nativeFiles(config, catalog, locales, entries, banner) {
|
|
4860
5948
|
const files = /* @__PURE__ */ new Map();
|
|
4861
5949
|
const problems = [];
|
|
4862
|
-
for (const target of config.native) {
|
|
5950
|
+
for (const target of config.native) try {
|
|
5951
|
+
if (target.language === "android" || target.language === "apple") {
|
|
5952
|
+
const { files: written, problems: found } = target.language === "android" ? androidResources(target, catalog, locales, entries, banner) : appleStrings(target, config.root, catalog, locales, entries, banner);
|
|
5953
|
+
problems.push(...found);
|
|
5954
|
+
for (const [path, content] of written) files.set(path, content);
|
|
5955
|
+
continue;
|
|
5956
|
+
}
|
|
4863
5957
|
const { model, problems: found } = nativeModel(target, catalog, locales, entries);
|
|
4864
5958
|
problems.push(...found);
|
|
4865
5959
|
if (found.length > 0) continue;
|
|
4866
|
-
|
|
4867
|
-
|
|
4868
|
-
|
|
4869
|
-
|
|
4870
|
-
|
|
4871
|
-
|
|
4872
|
-
});
|
|
4873
|
-
}
|
|
5960
|
+
files.set(target.out, WRITERS[target.language](model, target, banner));
|
|
5961
|
+
} catch (e) {
|
|
5962
|
+
problems.push({
|
|
5963
|
+
severity: "error",
|
|
5964
|
+
message: e.message
|
|
5965
|
+
});
|
|
4874
5966
|
}
|
|
4875
5967
|
if (problems.length > 0) throw new CatalogError(problems);
|
|
4876
5968
|
return files;
|
|
@@ -5140,6 +6232,11 @@ function render(config, accept) {
|
|
|
5140
6232
|
}
|
|
5141
6233
|
for (const [path, content] of nativeFiles(config, catalog, locales, entries, head)) files.set(path, content);
|
|
5142
6234
|
files.set(join(config.catalog, "catalog.schema.json"), `${JSON.stringify(catalogSchema(config), null, 2)}\n`);
|
|
6235
|
+
for (const file of yamlFiles(config.catalog)) {
|
|
6236
|
+
const path = join(config.catalog, file);
|
|
6237
|
+
const text = readFileSync(path, "utf8");
|
|
6238
|
+
if (withSchemaLine(text) !== text) files.set(path, withSchemaLine(text));
|
|
6239
|
+
}
|
|
5143
6240
|
if (Object.keys(lock.translations).length > 0 || existsSync(config.lock)) files.set(config.lock, serializeLock(lock));
|
|
5144
6241
|
return {
|
|
5145
6242
|
catalog,
|
|
@@ -5168,6 +6265,6 @@ function generate(config, options = {}) {
|
|
|
5168
6265
|
changed: writeFiles(generated.files, options.check)
|
|
5169
6266
|
};
|
|
5170
6267
|
}
|
|
5171
|
-
export {
|
|
6268
|
+
export { loadCatalog as A, toMf1 as B, formatDiff as C, SCHEMA_LINE as D, KEY_PATTERN as E, writeMessage as F, intlDate as I, intlNumber as L, whereOf as M, SYNTAX_NAME as N, entryLocation as O, readMessage as P, parseMf2 as R, serializeLock as S, CatalogError as T, changeOf as _, render as a, readLock as b, pseudoText as c, importTranslations as d, readEntry as f, todoOf as g, todoMessages as h, localesOf as i, previewOf as j, entryLocator as k, applyFixes as l, readReturned as m, generate as n, writeFiles as o, exportOf as p, localeEntries as r, pseudoAst as s, catalogSchema as t, editEntry as u, emptyLock as v, wordDiff as w, reconcile as x, hash as y, toMf2 as z };
|
|
5172
6269
|
|
|
5173
|
-
//# sourceMappingURL=generate-
|
|
6270
|
+
//# sourceMappingURL=generate-CKv86Iki.js.map
|