prosebook 0.3.0 → 0.5.0

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