prosebook 0.4.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 +288 -28
- 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 +11 -2
- package/dist/bun.d.ts.map +1 -1
- package/dist/bun.js +77 -26
- package/dist/bun.js.map +1 -1
- package/dist/{bundle-CpUIWb9j.d.ts → bundle-CLHY_97D.d.ts} +2 -2
- 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-nWZf9QmF.js → config-7T4hX_f7.js} +5 -3
- package/dist/{config-nWZf9QmF.js.map → config-7T4hX_f7.js.map} +1 -1
- package/dist/config-CzaJJQ60.d.ts.map +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-BW3N8EGb.js → generate-CKv86Iki.js} +1091 -309
- 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 +10 -2
- 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-B5nrMqrT.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 +43 -64
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +5 -5
- package/dist/vite.d.ts +12 -2
- 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 +1 -1
- 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 +10 -25
- package/src/tools/native/apple.ts +70 -8
- package/src/tools/native/date.ts +219 -0
- package/src/tools/native/kotlin.ts +47 -6
- package/src/tools/native/model.ts +42 -16
- package/src/tools/native/printf.ts +46 -4
- 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-CpUIWb9j.d.ts.map +0 -1
- package/dist/bundle-DboItAre.js +0 -111
- package/dist/bundle-DboItAre.js.map +0 -1
- package/dist/compile-ClK9htKr.d.ts +0 -16
- package/dist/compile-ClK9htKr.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-BgG_5Vpm.js +0 -96
- package/dist/endpoints-BgG_5Vpm.js.map +0 -1
- package/dist/generate-BW3N8EGb.js.map +0 -1
- package/dist/sarif-B5nrMqrT.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$
|
|
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$
|
|
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,6 +1817,505 @@ function entryLocator(root) {
|
|
|
1810
1817
|
function previewOf(message) {
|
|
1811
1818
|
return staticLiteral(message.ast) ?? message.text;
|
|
1812
1819
|
}
|
|
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);
|
|
1863
|
+
}
|
|
1864
|
+
/**
|
|
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.
|
|
1867
|
+
*/
|
|
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;
|
|
1883
|
+
}
|
|
1884
|
+
out.push({ ...part });
|
|
1885
|
+
}
|
|
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 });
|
|
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() {
|
|
1916
|
+
return {
|
|
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
|
|
1934
|
+
};
|
|
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
|
+
}
|
|
1950
|
+
/**
|
|
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.
|
|
1953
|
+
*/
|
|
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
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
return {
|
|
1980
|
+
lock,
|
|
1981
|
+
stale
|
|
1982
|
+
};
|
|
1983
|
+
}
|
|
1984
|
+
// src/tools/todo.ts
|
|
1985
|
+
/**
|
|
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.
|
|
1991
|
+
*/
|
|
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);
|
|
1813
2319
|
// src/tools/entry.ts
|
|
1814
2320
|
/**
|
|
1815
2321
|
* One entry as the dev inspector's dialog edits it, in every locale: as fields, or as the YAML written
|
|
@@ -1928,10 +2434,10 @@ function applyFixes(config, catalog, findings) {
|
|
|
1928
2434
|
};
|
|
1929
2435
|
}
|
|
1930
2436
|
/**
|
|
1931
|
-
* Translations into `locale` from `prosebook todo`'s YAML filled in, by a person or a model,
|
|
1932
|
-
* written to the file its locale and area
|
|
1933
|
-
*
|
|
1934
|
-
*
|
|
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.
|
|
1935
2441
|
*/
|
|
1936
2442
|
function importTranslations(config, locale, reply) {
|
|
1937
2443
|
const failed = (message, 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();
|
|
@@ -2059,263 +2570,99 @@ function viewOf(config, catalog, key, unsaved) {
|
|
|
2059
2570
|
/** Where an entry is written: from the start of its key's line to the end of its value. */
|
|
2060
2571
|
function snippetOf(file, key) {
|
|
2061
2572
|
const doc = parseDocument(file);
|
|
2062
|
-
if (!isMap(doc.contents)) return void 0;
|
|
2063
|
-
const pair = doc.contents.items.find((p) => isScalar(p.key) && p.key.value === key);
|
|
2064
|
-
const keyStart = isScalar(pair?.key) ? pair.key.range?.[0] : void 0;
|
|
2065
|
-
const valueEnd = (pair?.value)?.range?.[1];
|
|
2066
|
-
if (keyStart === void 0 || valueEnd === void 0) return void 0;
|
|
2067
|
-
const start = file.lastIndexOf("\n", keyStart - 1) + 1;
|
|
2068
|
-
const end = start + file.slice(start, valueEnd).trimEnd().length;
|
|
2069
|
-
return {
|
|
2070
|
-
start,
|
|
2071
|
-
end,
|
|
2072
|
-
text: file.slice(start, end)
|
|
2073
|
-
};
|
|
2074
|
-
}
|
|
2075
|
-
/** The file with the entry's text replaced, removed when `yaml` is empty, or added at the end. */
|
|
2076
|
-
function withSnippet(file, key, yaml) {
|
|
2077
|
-
const text = yaml.replace(/\n+$/, "");
|
|
2078
|
-
const at = snippetOf(file, key);
|
|
2079
|
-
if (at !== void 0) {
|
|
2080
|
-
if (text.trim() === "") return file.slice(0, at.start) + file.slice(at.end).replace(/^\n/, "");
|
|
2081
|
-
return file.slice(0, at.start) + text + file.slice(at.end);
|
|
2082
|
-
}
|
|
2083
|
-
if (text.trim() === "") return file;
|
|
2084
|
-
return file.trim() === "" ? `${text}\n` : `${file.replace(/\n*$/, "\n")}${text}\n`;
|
|
2085
|
-
}
|
|
2086
|
-
function parse$2(file) {
|
|
2087
|
-
const doc = parseDocument(file, { version: "1.1" });
|
|
2088
|
-
if (doc.contents === null) doc.contents = doc.createNode({});
|
|
2089
|
-
return doc;
|
|
2090
|
-
}
|
|
2091
|
-
const print = (doc) => doc.toString({
|
|
2092
|
-
lineWidth: 0,
|
|
2093
|
-
singleQuote: true
|
|
2094
|
-
});
|
|
2095
|
-
/** A value written where one was, keeping how it was quoted; text over several lines as a block. */
|
|
2096
|
-
function setValue(doc, map, name, value) {
|
|
2097
|
-
const node = map.get(name, true);
|
|
2098
|
-
if (isScalar(node) && typeof value === "string") {
|
|
2099
|
-
node.value = value;
|
|
2100
|
-
if (value.includes("\n")) node.type = Scalar.BLOCK_LITERAL;
|
|
2101
|
-
return;
|
|
2102
|
-
}
|
|
2103
|
-
map.set(name, doc.createNode(value));
|
|
2104
|
-
}
|
|
2105
|
-
function withFields(file, key, fields, source) {
|
|
2106
|
-
const doc = parse$2(file);
|
|
2107
|
-
let entry = doc.get(key, true);
|
|
2108
|
-
if (!isMap(entry)) {
|
|
2109
|
-
entry = doc.createNode({ [source]: fields.message });
|
|
2110
|
-
doc.set(key, entry);
|
|
2111
|
-
}
|
|
2112
|
-
const map = entry;
|
|
2113
|
-
const args = Object.fromEntries(Object.entries(fields.args).filter(([, v]) => v.trim() !== ""));
|
|
2114
|
-
const wanted = [
|
|
2115
|
-
["kind", fields.kind.trim() || void 0],
|
|
2116
|
-
["surface", fields.surface.trim() || void 0],
|
|
2117
|
-
["context", fields.context.trim() || void 0],
|
|
2118
|
-
["args", Object.keys(args).length > 0 ? args : void 0],
|
|
2119
|
-
["fragment", fields.fragment || void 0],
|
|
2120
|
-
["comment", fields.comment.trim() || void 0],
|
|
2121
|
-
["remove", fields.remove || void 0]
|
|
2122
|
-
];
|
|
2123
|
-
for (const [name, value] of wanted) {
|
|
2124
|
-
if (value === void 0) {
|
|
2125
|
-
map.delete(name);
|
|
2126
|
-
continue;
|
|
2127
|
-
}
|
|
2128
|
-
if (map.has(name)) {
|
|
2129
|
-
setValue(doc, map, name, value);
|
|
2130
|
-
continue;
|
|
2131
|
-
}
|
|
2132
|
-
const at = map.items.findIndex((p) => isScalar(p.key) && p.key.value === source);
|
|
2133
|
-
map.items.splice(at === -1 ? map.items.length : at, 0, doc.createPair(name, value));
|
|
2134
|
-
}
|
|
2135
|
-
setValue(doc, map, source, fields.message);
|
|
2136
|
-
return print(doc);
|
|
2137
|
-
}
|
|
2138
|
-
/** The source message replaced, and nothing else: a `key: message` entry stays one. */
|
|
2139
|
-
function withMessage(file, key, message, source) {
|
|
2140
|
-
const doc = parse$2(file);
|
|
2141
|
-
const entry = doc.get(key, true);
|
|
2142
|
-
if (!isMap(entry)) return withTranslation(file, key, message);
|
|
2143
|
-
setValue(doc, entry, source, message);
|
|
2144
|
-
return print(doc);
|
|
2145
|
-
}
|
|
2146
|
-
function withTranslation(file, key, translation) {
|
|
2147
|
-
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
|
-
};
|
|
2573
|
+
if (!isMap(doc.contents)) return void 0;
|
|
2574
|
+
const pair = doc.contents.items.find((p) => isScalar(p.key) && p.key.value === key);
|
|
2575
|
+
const keyStart = isScalar(pair?.key) ? pair.key.range?.[0] : void 0;
|
|
2576
|
+
const valueEnd = (pair?.value)?.range?.[1];
|
|
2577
|
+
if (keyStart === void 0 || valueEnd === void 0) return void 0;
|
|
2578
|
+
const start = file.lastIndexOf("\n", keyStart - 1) + 1;
|
|
2579
|
+
const end = start + file.slice(start, valueEnd).trimEnd().length;
|
|
2266
2580
|
return {
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2581
|
+
start,
|
|
2582
|
+
end,
|
|
2583
|
+
text: file.slice(start, end)
|
|
2270
2584
|
};
|
|
2271
2585
|
}
|
|
2272
|
-
|
|
2273
|
-
function
|
|
2274
|
-
const
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
}
|
|
2586
|
+
/** The file with the entry's text replaced, removed when `yaml` is empty, or added at the end. */
|
|
2587
|
+
function withSnippet(file, key, yaml) {
|
|
2588
|
+
const text = yaml.replace(/\n+$/, "");
|
|
2589
|
+
const at = snippetOf(file, key);
|
|
2590
|
+
if (at !== void 0) {
|
|
2591
|
+
if (text.trim() === "") return file.slice(0, at.start) + file.slice(at.end).replace(/^\n/, "");
|
|
2592
|
+
return file.slice(0, at.start) + text + file.slice(at.end);
|
|
2593
|
+
}
|
|
2594
|
+
if (text.trim() === "") return file;
|
|
2595
|
+
return file.trim() === "" ? `${text}\n` : `${file.replace(/\n*$/, "\n")}${text}\n`;
|
|
2280
2596
|
}
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
if (
|
|
2284
|
-
return
|
|
2597
|
+
function parse$2(file) {
|
|
2598
|
+
const doc = parseDocument(file, { version: "1.1" });
|
|
2599
|
+
if (doc.contents === null) doc.contents = doc.createNode({});
|
|
2600
|
+
return doc;
|
|
2285
2601
|
}
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
const
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2602
|
+
const print = (doc) => doc.toString({
|
|
2603
|
+
lineWidth: 0,
|
|
2604
|
+
singleQuote: true
|
|
2605
|
+
});
|
|
2606
|
+
/** A value written where one was, keeping how it was quoted; text over several lines as a block. */
|
|
2607
|
+
function setValue(doc, map, name, value) {
|
|
2608
|
+
const node = map.get(name, true);
|
|
2609
|
+
if (isScalar(node) && typeof value === "string") {
|
|
2610
|
+
node.value = value;
|
|
2611
|
+
if (value.includes("\n")) node.type = Scalar.BLOCK_LITERAL;
|
|
2612
|
+
return;
|
|
2613
|
+
}
|
|
2614
|
+
map.set(name, doc.createNode(value));
|
|
2615
|
+
}
|
|
2616
|
+
function withFields(file, key, fields, source) {
|
|
2617
|
+
const doc = parse$2(file);
|
|
2618
|
+
let entry = doc.get(key, true);
|
|
2619
|
+
if (!isMap(entry)) {
|
|
2620
|
+
entry = doc.createNode({ [source]: fields.message });
|
|
2621
|
+
doc.set(key, entry);
|
|
2622
|
+
}
|
|
2623
|
+
const map = entry;
|
|
2624
|
+
const args = Object.fromEntries(Object.entries(fields.args).filter(([, v]) => v.trim() !== ""));
|
|
2625
|
+
const wanted = [
|
|
2626
|
+
["kind", fields.kind.trim() || void 0],
|
|
2627
|
+
["surface", fields.surface.trim() || void 0],
|
|
2628
|
+
["context", fields.context.trim() || void 0],
|
|
2629
|
+
["args", Object.keys(args).length > 0 ? args : void 0],
|
|
2630
|
+
["fragment", fields.fragment || void 0],
|
|
2631
|
+
["comment", fields.comment.trim() || void 0],
|
|
2632
|
+
["remove", fields.remove || void 0]
|
|
2633
|
+
];
|
|
2634
|
+
for (const [name, value] of wanted) {
|
|
2635
|
+
if (value === void 0) {
|
|
2636
|
+
map.delete(name);
|
|
2637
|
+
continue;
|
|
2638
|
+
}
|
|
2639
|
+
if (map.has(name)) {
|
|
2640
|
+
setValue(doc, map, name, value);
|
|
2641
|
+
continue;
|
|
2313
2642
|
}
|
|
2643
|
+
const at = map.items.findIndex((p) => isScalar(p.key) && p.key.value === source);
|
|
2644
|
+
map.items.splice(at === -1 ? map.items.length : at, 0, doc.createPair(name, value));
|
|
2314
2645
|
}
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2646
|
+
setValue(doc, map, source, fields.message);
|
|
2647
|
+
return print(doc);
|
|
2648
|
+
}
|
|
2649
|
+
/** The source message replaced, and nothing else: a `key: message` entry stays one. */
|
|
2650
|
+
function withMessage(file, key, message, source) {
|
|
2651
|
+
const doc = parse$2(file);
|
|
2652
|
+
const entry = doc.get(key, true);
|
|
2653
|
+
if (!isMap(entry)) return withTranslation(file, key, message);
|
|
2654
|
+
setValue(doc, entry, source, message);
|
|
2655
|
+
return print(doc);
|
|
2656
|
+
}
|
|
2657
|
+
function withTranslation(file, key, translation) {
|
|
2658
|
+
const doc = parse$2(file);
|
|
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);
|
|
2319
2666
|
}
|
|
2320
2667
|
// src/tools/native/emit.ts
|
|
2321
2668
|
/** Words from an identifier written any way: `userName`, `user-name` and `user_name` are user, name. */
|
|
@@ -2371,6 +2718,195 @@ function checkNames(language, named, taken) {
|
|
|
2371
2718
|
seen.set(name, key);
|
|
2372
2719
|
}
|
|
2373
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("");
|
|
2909
|
+
}
|
|
2374
2910
|
// src/tools/native/number.ts
|
|
2375
2911
|
const NUMBER_PARTS = /* @__PURE__ */ new Set([
|
|
2376
2912
|
"integer",
|
|
@@ -3810,6 +4346,7 @@ function nativeModel(target, catalog, locales, entries) {
|
|
|
3810
4346
|
const source = locales[0];
|
|
3811
4347
|
const asts = new Map([...entries].map(([locale, list]) => [locale, new Map(list)]));
|
|
3812
4348
|
const formats = new Numbered();
|
|
4349
|
+
const dates = new Numbered();
|
|
3813
4350
|
const rules = new Numbered();
|
|
3814
4351
|
const problems = [];
|
|
3815
4352
|
const messages = [];
|
|
@@ -3829,7 +4366,11 @@ function nativeModel(target, catalog, locales, entries) {
|
|
|
3829
4366
|
try {
|
|
3830
4367
|
bodies.set(found, {
|
|
3831
4368
|
locales: [locale],
|
|
3832
|
-
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)
|
|
3833
4374
|
});
|
|
3834
4375
|
} catch (e) {
|
|
3835
4376
|
failed = true;
|
|
@@ -3848,7 +4389,7 @@ function nativeModel(target, catalog, locales, entries) {
|
|
|
3848
4389
|
doc: docOf(m),
|
|
3849
4390
|
args: [...m.args].map(([name, type]) => ({
|
|
3850
4391
|
name,
|
|
3851
|
-
type: type === "number" ? "number" : "string",
|
|
4392
|
+
type: type === "number" ? "number" : type === "Date | number" ? "date" : "string",
|
|
3852
4393
|
doc: m.argDocs.has(name) ? docText(m.argDocs.get(name)) : void 0
|
|
3853
4394
|
})),
|
|
3854
4395
|
bodies: [...bodies.values()]
|
|
@@ -3860,6 +4401,7 @@ function nativeModel(target, catalog, locales, entries) {
|
|
|
3860
4401
|
source,
|
|
3861
4402
|
messages,
|
|
3862
4403
|
formats: formats.list,
|
|
4404
|
+
dates: dates.list,
|
|
3863
4405
|
rules: rules.list
|
|
3864
4406
|
},
|
|
3865
4407
|
problems
|
|
@@ -3878,6 +4420,22 @@ function docOf(m) {
|
|
|
3878
4420
|
const LEAVE_OUT = "or leave the key out of this target's `keys`";
|
|
3879
4421
|
/** Specs by locale and options, kept across runs: taking a format apart checks it against `Intl` dozens of times. */
|
|
3880
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
|
+
}
|
|
3881
4439
|
function format(formats, locale, options) {
|
|
3882
4440
|
const key = JSON.stringify([locale, options ?? {}]);
|
|
3883
4441
|
let spec = SPECS.get(key);
|
|
@@ -3892,7 +4450,8 @@ function format(formats, locale, options) {
|
|
|
3892
4450
|
if (spec instanceof Error) throw spec;
|
|
3893
4451
|
return formats.add(spec);
|
|
3894
4452
|
}
|
|
3895
|
-
function parts(elements, locale,
|
|
4453
|
+
function parts(elements, locale, declared, pound) {
|
|
4454
|
+
const { formats, dates, rules } = declared;
|
|
3896
4455
|
const out = [];
|
|
3897
4456
|
const text = (value) => {
|
|
3898
4457
|
const last = out.at(-1);
|
|
@@ -3910,11 +4469,18 @@ function parts(elements, locale, formats, rules, pound) {
|
|
|
3910
4469
|
};
|
|
3911
4470
|
const arms = (options, own) => Object.entries(options).map(([key, option]) => ({
|
|
3912
4471
|
key,
|
|
3913
|
-
parts: parts(option.value, locale,
|
|
4472
|
+
parts: parts(option.value, locale, declared, own)
|
|
3914
4473
|
}));
|
|
3915
4474
|
for (const el of elements) {
|
|
3916
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}`);
|
|
3917
|
-
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
|
+
}
|
|
3918
4484
|
if (isNumberElement(el)) {
|
|
3919
4485
|
const fallback = placeholderOf(el, el.value).fallback;
|
|
3920
4486
|
placed(el, el.value, {
|
|
@@ -4004,7 +4570,7 @@ function parts(elements, locale, formats, rules, pound) {
|
|
|
4004
4570
|
// src/tools/native/printf.ts
|
|
4005
4571
|
/**
|
|
4006
4572
|
* Messages as printf-style format strings, for the targets whose platform fills values in itself:
|
|
4007
|
-
* Android's resources and Apple's `.strings` files. Text and plain `{name}` placeholders, as `%N$s`
|
|
4573
|
+
* Android's resources and Apple's `.strings` and `.stringsdict` files. Text and plain `{name}` placeholders, as `%N$s`
|
|
4008
4574
|
* with N the argument's place in the message's arguments, so a translation can put them in another
|
|
4009
4575
|
* order; anything `Intl` would format fails with the fix.
|
|
4010
4576
|
*/
|
|
@@ -4019,7 +4585,20 @@ function isCount(el, count) {
|
|
|
4019
4585
|
const options = Object.entries(numberOptions(el) ?? {});
|
|
4020
4586
|
return options.length === 0 || options.length === 1 && options[0][0] === "maximumFractionDigits" && options[0][1] === 0;
|
|
4021
4587
|
}
|
|
4022
|
-
/**
|
|
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`). */
|
|
4023
4602
|
function formatString(elements, args, printf, count) {
|
|
4024
4603
|
const formatted = args.length > 0;
|
|
4025
4604
|
const place = (el, name, conversion) => {
|
|
@@ -4032,7 +4611,7 @@ function formatString(elements, args, printf, count) {
|
|
|
4032
4611
|
let out = "";
|
|
4033
4612
|
for (const el of elements) {
|
|
4034
4613
|
if (count !== void 0 && isCount(el, count)) {
|
|
4035
|
-
out += place(el, count,
|
|
4614
|
+
out += place(el, count, printf.count);
|
|
4036
4615
|
continue;
|
|
4037
4616
|
}
|
|
4038
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}`);
|
|
@@ -4100,9 +4679,15 @@ const PRINTF$1 = {
|
|
|
4100
4679
|
formatter: "String.format",
|
|
4101
4680
|
holder: "Android resources",
|
|
4102
4681
|
string: "s",
|
|
4682
|
+
count: "d",
|
|
4103
4683
|
escape: escape$1,
|
|
4104
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}`
|
|
4105
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
|
+
};
|
|
4106
4691
|
/**
|
|
4107
4692
|
* A message as `<string>`, or `<plurals>` when it holds a plural. Android's picks between whole
|
|
4108
4693
|
* messages, so the text around the plural goes into each arm: the pseudo-locale's brackets, or a
|
|
@@ -4116,13 +4701,7 @@ function resource(name, ast, args) {
|
|
|
4116
4701
|
return ` <string name="${name}"${args.length === 0 && written.includes("%") ? " formatted=\"false\"" : ""}>${value$3(written)}</string>`;
|
|
4117
4702
|
}
|
|
4118
4703
|
const [before, after] = [ast.slice(0, at), ast.slice(at + 1)];
|
|
4119
|
-
|
|
4120
|
-
if (plural.pluralType === "ordinal") throw new Error(`${named} is an ordinal, and Android's <plurals> are cardinal only: write a key per case and pick one in code, ${LEAVE_OUT}`);
|
|
4121
|
-
if (plural.offset !== 0) throw new Error(`${named} has an offset, which Android's <plurals> don't: count without it, and say the rest in each arm, ${LEAVE_OUT}`);
|
|
4122
|
-
const selection = selectionOf(plural);
|
|
4123
|
-
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 Android selects on the count as an integer: select with a bare :number or :integer, ${LEAVE_OUT}`);
|
|
4124
|
-
const exact = Object.keys(plural.options).filter((k) => k.startsWith("="));
|
|
4125
|
-
if (exact.length > 0) throw new Error(`${named} has the arm ${exact.join(", ")}, and Android's <plurals> have none for one number (its zero is a category, which most languages never pick): say it in a category's arm, or write a key for it and pick that in code, ${LEAVE_OUT}`);
|
|
4704
|
+
checkPlural(plural, PLURALS$1);
|
|
4126
4705
|
return ` <plurals name="${name}">\n${Object.entries(plural.options).map(([category, option]) => ` <item quantity="${category}">${value$3(formatString([
|
|
4127
4706
|
...before,
|
|
4128
4707
|
...option.value,
|
|
@@ -4211,9 +4790,46 @@ const PRINTF = {
|
|
|
4211
4790
|
formatter: "String(format:)",
|
|
4212
4791
|
holder: ".strings files",
|
|
4213
4792
|
string: "@",
|
|
4793
|
+
count: "lld",
|
|
4214
4794
|
escape,
|
|
4215
|
-
plural: (name) => `{${name}} is a plural, which a .strings file can't hold
|
|
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: ""
|
|
4216
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
|
+
}
|
|
4217
4833
|
const ENTITIES = {
|
|
4218
4834
|
amp: "&",
|
|
4219
4835
|
lt: "<",
|
|
@@ -4271,32 +4887,47 @@ function appleStrings(target, root, catalog, locales, entries, banner) {
|
|
|
4271
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`);
|
|
4272
4888
|
}
|
|
4273
4889
|
const lines = new Map(locales.map((l) => [l, []]));
|
|
4890
|
+
const dicts = new Map(locales.map((l) => [l, []]));
|
|
4274
4891
|
const failed = new Set(problems.map((p) => p.key));
|
|
4275
4892
|
for (const { m, name } of taken) {
|
|
4276
4893
|
if (failed.has(m.key)) continue;
|
|
4277
4894
|
const args = [...m.args.keys()];
|
|
4278
4895
|
const note = noteOf(m);
|
|
4279
4896
|
const head = note === "" ? "" : `/* ${note.replaceAll("*/", "* /")} */\n`;
|
|
4897
|
+
const comment = note === "" ? "" : ` <!-- ${note.replace(/-{2,}/g, "-")} -->\n`;
|
|
4280
4898
|
const written = /* @__PURE__ */ new Map();
|
|
4281
4899
|
for (const locale of locales) {
|
|
4282
4900
|
const found = foundIn(m.key, locale, locales, asts);
|
|
4283
4901
|
if (found === void 0) continue;
|
|
4284
4902
|
let entry = written.get(found);
|
|
4285
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);
|
|
4286
4906
|
try {
|
|
4287
|
-
entry =
|
|
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
|
+
};
|
|
4288
4914
|
} catch (e) {
|
|
4289
4915
|
error(m, e.message, fileOf(m, found, source));
|
|
4290
4916
|
break;
|
|
4291
4917
|
}
|
|
4292
4918
|
written.set(found, entry);
|
|
4293
4919
|
}
|
|
4294
|
-
lines.get(locale).push(entry);
|
|
4920
|
+
(entry.dict ? dicts : lines).get(locale).push(entry.entry);
|
|
4295
4921
|
}
|
|
4296
4922
|
}
|
|
4297
|
-
const
|
|
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
|
+
}
|
|
4298
4929
|
return {
|
|
4299
|
-
files
|
|
4930
|
+
files,
|
|
4300
4931
|
problems
|
|
4301
4932
|
};
|
|
4302
4933
|
}
|
|
@@ -4318,6 +4949,7 @@ function value$2(part, indent, rt) {
|
|
|
4318
4949
|
const inner = `${indent} `;
|
|
4319
4950
|
if (part.type === "string") return argName$2(part.arg);
|
|
4320
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)})`;
|
|
4321
4953
|
if (part.type === "select") {
|
|
4322
4954
|
const arms = part.arms.map((a) => `${inner}${lit$2(a.key)} -> ${expr$2(a.parts, inner, rt)}\n`).join("");
|
|
4323
4955
|
return `when (${argName$2(part.arg)}) {\n${arms}${inner}else -> ${expr$2(part.other, inner, rt)}\n${indent}}`;
|
|
@@ -4343,7 +4975,7 @@ function member$1(m, name, rt) {
|
|
|
4343
4975
|
] : m.doc, " ");
|
|
4344
4976
|
const body = `when (this) {\n${branches} }`;
|
|
4345
4977
|
if (m.args.length === 0) return `${head} val ${name}: String\n get() = ${body}\n`;
|
|
4346
|
-
return `${head} fun ${name}(${m.args.map((a) => `${argName$2(a.name)}: ${a.type === "number" ? "Number" : "String"}`).join(", ")}): String {\n${m.args.
|
|
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`;
|
|
4347
4979
|
}
|
|
4348
4980
|
function formatConst$2(spec, i) {
|
|
4349
4981
|
const affix = ([prefix, suffix]) => `${lit$2(prefix)} to ${lit$2(suffix)}`;
|
|
@@ -4366,6 +4998,33 @@ function formatConst$2(spec, i) {
|
|
|
4366
4998
|
lit$2(spec.negativeInfinity)
|
|
4367
4999
|
].map((f) => ` ${f}`).join(",\n")},\n )\n`;
|
|
4368
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
|
+
`;
|
|
4369
5028
|
const NUMBER$2 = `
|
|
4370
5029
|
/** A number format, taken apart when the file was generated. */
|
|
4371
5030
|
class Format(
|
|
@@ -4501,10 +5160,10 @@ function kotlinFile(model, target, banner) {
|
|
|
4501
5160
|
within: (v, from, to) => `within(${v}, ${from}, ${to})`
|
|
4502
5161
|
})}) return ${lit$2(category)}\n`).join("")} return "other"\n }\n`;
|
|
4503
5162
|
}).join("\n");
|
|
4504
|
-
const runtime = model.formats.length + model.rules.length === 0 ? "" : `
|
|
5163
|
+
const runtime = model.formats.length + model.rules.length + model.dates.length === 0 ? "" : `
|
|
4505
5164
|
/** What formats the messages, generated with them. */
|
|
4506
|
-
private object ${rt} {${NUMBER$2}${model.rules.length > 0 ? PLURAL$2 : ""}
|
|
4507
|
-
${model.formats.map(formatConst$2).join("\n")}${rules === "" ? "" : `\n${rules}`}}
|
|
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}`}}
|
|
4508
5167
|
`;
|
|
4509
5168
|
return `${banner}
|
|
4510
5169
|
@file:Suppress("unused", "NAME_SHADOWING", "KotlinConstantConditions", "ConvertTwoComparisonsToRangeCheck")
|
|
@@ -4573,6 +5232,7 @@ function value$1(part, indent) {
|
|
|
4573
5232
|
const inner = `${indent} `;
|
|
4574
5233
|
if (part.type === "string") return argName$1(part.arg);
|
|
4575
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)})`;
|
|
4576
5236
|
if (part.type === "select") {
|
|
4577
5237
|
const arms = part.arms.map((a) => `${inner}${lit$1(a.key)} => ${expr$1(a.parts, inner)},\n`).join("");
|
|
4578
5238
|
return `match ${argName$1(part.arg)} {\n${arms}${inner}_ => ${expr$1(part.other, inner)},\n${indent}}`;
|
|
@@ -4592,7 +5252,7 @@ function method(m, name, enumName) {
|
|
|
4592
5252
|
const head = doc$2(m.doc, " ");
|
|
4593
5253
|
const arms = (render) => m.bodies.map((b) => ` ${b.locales.map((l) => `${enumName}::${variant(l)}`).join(" | ")} => ${render(b.parts)},\n`).join("");
|
|
4594
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`;
|
|
4595
|
-
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("");
|
|
4596
5256
|
const argDocs = m.args.filter((a) => a.doc !== void 0).map((a) => `* \`${argName$1(a.name)}\`: ${a.doc}`);
|
|
4597
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`;
|
|
4598
5258
|
}
|
|
@@ -4617,6 +5277,76 @@ function formatConst$1(spec, i) {
|
|
|
4617
5277
|
`negative_infinity: ${lit$1(spec.negativeInfinity)}`
|
|
4618
5278
|
].map((f) => ` ${f},\n`).join("")} };\n`;
|
|
4619
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
|
+
`;
|
|
4620
5350
|
const NUMBER$1 = `
|
|
4621
5351
|
/// A number format, taken apart when the file was generated.
|
|
4622
5352
|
pub struct Format {
|
|
@@ -4807,6 +5537,8 @@ function rustModule(model, target, banner) {
|
|
|
4807
5537
|
const tags = model.locales.map((l) => ` ${name}::${variant(l)} => ${lit$1(l)},\n`).join("");
|
|
4808
5538
|
const methods = model.messages.map((m, i) => method(m, names[i][1], name)).join("\n");
|
|
4809
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;
|
|
4810
5542
|
const rules = model.rules.map(({ name: rule, rules }, i) => {
|
|
4811
5543
|
const checks = rules.map(([category, condition]) => ` if ${conditionCode(condition, {
|
|
4812
5544
|
field: (op) => `o.${op}`,
|
|
@@ -4815,14 +5547,14 @@ function rustModule(model, target, banner) {
|
|
|
4815
5547
|
})} {\n return ${lit$1(category)};\n }\n`);
|
|
4816
5548
|
return ` /// ${rule}\n pub fn R${i}(${rules.length > 0 ? "o" : "_o"}: &Operands) -> &'static str {\n${checks.join("")} "other"\n }\n`;
|
|
4817
5549
|
}).join("\n");
|
|
4818
|
-
const runtime =
|
|
5550
|
+
const runtime = !usesFormats && !usesDates ? "" : `
|
|
4819
5551
|
/// What formats the messages, generated with them.
|
|
4820
|
-
mod rt {${NUMBER$1}${model.rules.length > 0 ? PLURAL$1 : ""}
|
|
4821
|
-
${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}`}}
|
|
4822
5554
|
`;
|
|
4823
5555
|
return `${banner}
|
|
4824
5556
|
#![allow(dead_code, non_snake_case, clippy::all)]
|
|
4825
|
-
${usesNumbers ? NUMBER_TRAIT : ""}
|
|
5557
|
+
${usesNumbers ? NUMBER_TRAIT : ""}${usesDates ? DATE_TIME : ""}
|
|
4826
5558
|
/// A locale the catalog is written or translated in, and its messages.
|
|
4827
5559
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
|
4828
5560
|
pub enum ${name} {
|
|
@@ -4886,6 +5618,7 @@ function value(part, indent, rt) {
|
|
|
4886
5618
|
const inner = `${indent} `;
|
|
4887
5619
|
if (part.type === "string") return argName(part.arg);
|
|
4888
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)})`;
|
|
4889
5622
|
if (part.type === "select") {
|
|
4890
5623
|
const arms = part.arms.map((a) => `${inner}case ${lit(a.key)}: return ${expr(a.parts, inner, rt)}\n`).join("");
|
|
4891
5624
|
return `{ () -> String in\n${inner}switch ${argName(part.arg)} {\n${arms}${inner}default: return ${expr(part.other, inner, rt)}\n${inner}}\n${indent}}()`;
|
|
@@ -4911,7 +5644,7 @@ function member(m, name, rt, number) {
|
|
|
4911
5644
|
] : m.doc, " ");
|
|
4912
5645
|
const body = ` switch self {\n${cases((parts) => expr(parts, " ", rt))} }\n`;
|
|
4913
5646
|
if (m.args.length === 0) return `${head} public var ${name}: String {\n${body} }\n`;
|
|
4914
|
-
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`;
|
|
4915
5648
|
}
|
|
4916
5649
|
function formatConst(spec, i) {
|
|
4917
5650
|
const affix = ([prefix, suffix]) => `(${lit(prefix)}, ${lit(suffix)})`;
|
|
@@ -4934,6 +5667,47 @@ function formatConst(spec, i) {
|
|
|
4934
5667
|
`negativeInfinity: ${lit(spec.negativeInfinity)}`
|
|
4935
5668
|
].map((f) => ` ${f}`).join(",\n")}\n )\n`;
|
|
4936
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
|
+
`;
|
|
4937
5711
|
const NUMBER = `
|
|
4938
5712
|
/// A number format, taken apart when the file was generated.
|
|
4939
5713
|
struct Format {
|
|
@@ -5105,7 +5879,10 @@ function swiftFile(model, target, banner) {
|
|
|
5105
5879
|
value: `${name.charAt(0).toLowerCase()}${name.slice(1)}Value`
|
|
5106
5880
|
};
|
|
5107
5881
|
const members = model.messages.map((m, i) => member(m, names[i][1], rt, number)).join("\n");
|
|
5108
|
-
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 ? "" : `
|
|
5109
5886
|
/// A number a message formats: any of Swift's integers or floats.
|
|
5110
5887
|
public protocol ${number.type} {
|
|
5111
5888
|
var ${number.value}: Double { get }
|
|
@@ -5120,10 +5897,10 @@ ${NUMBER_TYPES.map((t) => `extension ${t}: ${number.type} { public var ${number.
|
|
|
5120
5897
|
within: (v, from, to) => `within(${v}, ${from}, ${to})`
|
|
5121
5898
|
})} { return ${lit(category)} }\n`).join("")} return "other"\n }\n`;
|
|
5122
5899
|
}).join("\n");
|
|
5123
|
-
const runtime =
|
|
5900
|
+
const runtime = !usesFormats && !usesDates ? "" : `
|
|
5124
5901
|
/// What formats the messages, generated with them.
|
|
5125
|
-
private enum ${rt} {${NUMBER}${model.rules.length > 0 ? PLURAL : ""}
|
|
5126
|
-
${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}`}}
|
|
5127
5904
|
`;
|
|
5128
5905
|
return `${banner}
|
|
5129
5906
|
import Foundation
|
|
@@ -5455,6 +6232,11 @@ function render(config, accept) {
|
|
|
5455
6232
|
}
|
|
5456
6233
|
for (const [path, content] of nativeFiles(config, catalog, locales, entries, head)) files.set(path, content);
|
|
5457
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
|
+
}
|
|
5458
6240
|
if (Object.keys(lock.translations).length > 0 || existsSync(config.lock)) files.set(config.lock, serializeLock(lock));
|
|
5459
6241
|
return {
|
|
5460
6242
|
catalog,
|
|
@@ -5483,6 +6265,6 @@ function generate(config, options = {}) {
|
|
|
5483
6265
|
changed: writeFiles(generated.files, options.check)
|
|
5484
6266
|
};
|
|
5485
6267
|
}
|
|
5486
|
-
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 };
|
|
5487
6269
|
|
|
5488
|
-
//# sourceMappingURL=generate-
|
|
6270
|
+
//# sourceMappingURL=generate-CKv86Iki.js.map
|