prosebook 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +288 -28
  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 +11 -2
  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-CpUIWb9j.d.ts → bundle-CLHY_97D.d.ts} +2 -2
  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-nWZf9QmF.js → config-7T4hX_f7.js} +5 -3
  17. package/dist/{config-nWZf9QmF.js.map → config-7T4hX_f7.js.map} +1 -1
  18. package/dist/config-CzaJJQ60.d.ts.map +1 -1
  19. package/dist/config.js +1 -1
  20. package/dist/{copy-B3V_QRkT.d.ts → copy-BXn0aYQn.d.ts} +37 -3
  21. package/dist/copy-BXn0aYQn.d.ts.map +1 -0
  22. package/dist/copy-Bv6S-Hr2.js +235 -0
  23. package/dist/copy-Bv6S-Hr2.js.map +1 -0
  24. package/dist/endpoints-Dhgowovz.js +217 -0
  25. package/dist/endpoints-Dhgowovz.js.map +1 -0
  26. package/dist/generate-1BeQAd1O.d.ts +72 -0
  27. package/dist/generate-1BeQAd1O.d.ts.map +1 -0
  28. package/dist/{generate-BW3N8EGb.js → generate-CKv86Iki.js} +1091 -309
  29. package/dist/generate-CKv86Iki.js.map +1 -0
  30. package/dist/index.d.ts +2 -2
  31. package/dist/index.js +2 -2
  32. package/dist/next-loader.d.ts +9 -2
  33. package/dist/next-loader.d.ts.map +1 -1
  34. package/dist/next-loader.js +12 -3
  35. package/dist/next-loader.js.map +1 -1
  36. package/dist/next.d.ts +10 -2
  37. package/dist/next.d.ts.map +1 -1
  38. package/dist/next.js +94 -21
  39. package/dist/next.js.map +1 -1
  40. package/dist/preact/index.d.ts +34 -0
  41. package/dist/preact/index.d.ts.map +1 -0
  42. package/dist/preact/index.js +72 -0
  43. package/dist/preact/index.js.map +1 -0
  44. package/dist/react/index.d.ts +5 -14
  45. package/dist/react/index.d.ts.map +1 -1
  46. package/dist/react/index.js +9 -33
  47. package/dist/react/index.js.map +1 -1
  48. package/dist/react/server.d.ts +1 -1
  49. package/dist/react/server.js +1 -1
  50. package/dist/{sarif-B5nrMqrT.js → sarif-BnwYC78_.js} +8 -98
  51. package/dist/sarif-BnwYC78_.js.map +1 -0
  52. package/dist/{scan-Bx0_yDSi.js → scan-C0ap4Mpp.js} +145 -20
  53. package/dist/scan-C0ap4Mpp.js.map +1 -0
  54. package/dist/solid/index.d.ts +29 -0
  55. package/dist/solid/index.d.ts.map +1 -0
  56. package/dist/solid/index.js +48 -0
  57. package/dist/solid/index.js.map +1 -0
  58. package/dist/svelte/Text.svelte +13 -0
  59. package/dist/svelte/Text.svelte.d.ts +20 -0
  60. package/dist/svelte/index.d.ts +31 -0
  61. package/dist/svelte/index.d.ts.map +1 -0
  62. package/dist/svelte/index.js +35 -0
  63. package/dist/svelte/index.js.map +1 -0
  64. package/dist/tools.d.ts +43 -64
  65. package/dist/tools.d.ts.map +1 -1
  66. package/dist/tools.js +5 -5
  67. package/dist/vite.d.ts +12 -2
  68. package/dist/vite.d.ts.map +1 -1
  69. package/dist/vite.js +82 -25
  70. package/dist/vite.js.map +1 -1
  71. package/dist/vue/index.d.ts +43 -0
  72. package/dist/vue/index.d.ts.map +1 -0
  73. package/dist/vue/index.js +76 -0
  74. package/dist/vue/index.js.map +1 -0
  75. package/package.json +63 -7
  76. package/src/bun.ts +77 -20
  77. package/src/cli.ts +62 -8
  78. package/src/index.ts +1 -1
  79. package/src/next-loader.ts +17 -5
  80. package/src/next.ts +120 -16
  81. package/src/preact/index.ts +91 -0
  82. package/src/react/index.tsx +12 -42
  83. package/src/runtime/bind.ts +96 -0
  84. package/src/runtime/copy.ts +148 -24
  85. package/src/solid/index.ts +67 -0
  86. package/src/svelte/Text.svelte +13 -0
  87. package/src/svelte/Text.svelte.d.ts +20 -0
  88. package/src/svelte/index.ts +65 -0
  89. package/src/tools/bundle.ts +143 -2
  90. package/src/tools/catalog.ts +10 -1
  91. package/src/tools/chunks.ts +142 -0
  92. package/src/tools/config.ts +1 -1
  93. package/src/tools/entry.ts +19 -20
  94. package/src/tools/exchange.ts +275 -0
  95. package/src/tools/extract.ts +2 -2
  96. package/src/tools/generate.ts +7 -1
  97. package/src/tools/index.ts +2 -1
  98. package/src/tools/native/android.ts +10 -25
  99. package/src/tools/native/apple.ts +70 -8
  100. package/src/tools/native/date.ts +219 -0
  101. package/src/tools/native/kotlin.ts +47 -6
  102. package/src/tools/native/model.ts +42 -16
  103. package/src/tools/native/printf.ts +46 -4
  104. package/src/tools/native/rust.ts +86 -5
  105. package/src/tools/native/swift.ts +62 -6
  106. package/src/tools/scan.ts +146 -25
  107. package/src/tools/todo.ts +6 -1
  108. package/src/vite.ts +121 -28
  109. package/src/vue/index.ts +117 -0
  110. package/dist/bundle-CpUIWb9j.d.ts.map +0 -1
  111. package/dist/bundle-DboItAre.js +0 -111
  112. package/dist/bundle-DboItAre.js.map +0 -1
  113. package/dist/compile-ClK9htKr.d.ts +0 -16
  114. package/dist/compile-ClK9htKr.d.ts.map +0 -1
  115. package/dist/copy-B3V_QRkT.d.ts.map +0 -1
  116. package/dist/copy-Bo_FIoLy.js +0 -154
  117. package/dist/copy-Bo_FIoLy.js.map +0 -1
  118. package/dist/endpoints-BgG_5Vpm.js +0 -96
  119. package/dist/endpoints-BgG_5Vpm.js.map +0 -1
  120. package/dist/generate-BW3N8EGb.js.map +0 -1
  121. package/dist/sarif-B5nrMqrT.js.map +0 -1
  122. 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-nWZf9QmF.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$2(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$2(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,6 +1817,505 @@ function entryLocator(root) {
1810
1817
  function previewOf(message) {
1811
1818
  return staticLiteral(message.ast) ?? message.text;
1812
1819
  }
1820
+ // src/tools/diff.ts
1821
+ const TOKEN = /\s+|[\p{L}\p{N}_'’]+|[^\s\p{L}\p{N}_]/gu;
1822
+ /** Past this many cells the table costs more than the diff is worth: show the whole change instead. */
1823
+ const MAX_CELLS = 25e4;
1824
+ function wordDiff(before, after) {
1825
+ const a = before.match(TOKEN) ?? [];
1826
+ const b = after.match(TOKEN) ?? [];
1827
+ if (a.length * b.length > MAX_CELLS) return merge([{
1828
+ op: "del",
1829
+ text: before
1830
+ }, {
1831
+ op: "add",
1832
+ text: after
1833
+ }]);
1834
+ const lcs = Array.from({ length: a.length + 1 }, () => Array.from({ length: b.length + 1 }, () => 0));
1835
+ for (let i = a.length - 1; i >= 0; i--) for (let j = b.length - 1; j >= 0; j--) lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
1836
+ const parts = [];
1837
+ let i = 0;
1838
+ let j = 0;
1839
+ while (i < a.length && j < b.length) if (a[i] === b[j]) {
1840
+ parts.push({
1841
+ op: "same",
1842
+ text: a[i]
1843
+ });
1844
+ i++;
1845
+ j++;
1846
+ } else if (lcs[i + 1][j] >= lcs[i][j + 1]) parts.push({
1847
+ op: "del",
1848
+ text: a[i++]
1849
+ });
1850
+ else parts.push({
1851
+ op: "add",
1852
+ text: b[j++]
1853
+ });
1854
+ while (i < a.length) parts.push({
1855
+ op: "del",
1856
+ text: a[i++]
1857
+ });
1858
+ while (j < b.length) parts.push({
1859
+ op: "add",
1860
+ text: b[j++]
1861
+ });
1862
+ return merge(parts);
1863
+ }
1864
+ /**
1865
+ * Adjacent parts of one kind joined, and a space shared by two changes folded into them, so a
1866
+ * replaced phrase reads `[-old words-]{+new words+}` rather than alternating word by word.
1867
+ */
1868
+ function merge(parts) {
1869
+ const out = [];
1870
+ for (let k = 0; k < parts.length; k++) {
1871
+ const part = parts[k];
1872
+ const prev = out.at(-1);
1873
+ const next = parts[k + 1];
1874
+ if (part.op === "same" && /^\s+$/.test(part.text) && prev && prev.op !== "same" && next && next.op !== "same") {
1875
+ out.push({
1876
+ op: "del",
1877
+ text: part.text
1878
+ }, {
1879
+ op: "add",
1880
+ text: part.text
1881
+ });
1882
+ continue;
1883
+ }
1884
+ out.push({ ...part });
1885
+ }
1886
+ const joined = [];
1887
+ for (const part of out.filter((p) => p.text !== "")) {
1888
+ const last = joined.at(-1);
1889
+ if (last && last.op === part.op) last.text += part.text;
1890
+ else if (part.op === "del" && last?.op === "add" && joined.at(-2)?.op === "del") joined.at(-2).text += part.text;
1891
+ else joined.push({ ...part });
1892
+ }
1893
+ return joined;
1894
+ }
1895
+ /** The diff as one line: `Save [-the-]{+a+} draft`, the notation of `git diff --word-diff`. */
1896
+ function formatDiff(parts) {
1897
+ return parts.map((p) => p.op === "same" ? p.text : p.op === "del" ? `[-${p.text}-]` : `{+${p.text}+}`).join("");
1898
+ }
1899
+ // src/tools/lock.ts
1900
+ /**
1901
+ * Stale translations: a translation made from source text that has since changed.
1902
+ *
1903
+ * The lock records, per translation, a hash of the source it was made from and a hash of the
1904
+ * translation itself. Editing the translation re-anchors it to the current source; editing only
1905
+ * the source leaves the old anchor in place, which is what marks it stale. Nobody has to remember
1906
+ * to flag anything: `prosebook status` lists what is stale, and `prosebook accept` re-anchors a
1907
+ * translation that was still right.
1908
+ *
1909
+ * The lock also keeps the text behind each anchor, once per distinct text, so a stale translation
1910
+ * can show its translator what changed rather than only what the source says now.
1911
+ */
1912
+ function hash(text) {
1913
+ return createHash("sha256").update(text).digest("hex").slice(0, 10);
1914
+ }
1915
+ function emptyLock() {
1916
+ return {
1917
+ version: 2,
1918
+ sources: {},
1919
+ translations: {}
1920
+ };
1921
+ }
1922
+ /** The lock at `path`. A version 1 lock, which held only the translations, reads with no source texts. */
1923
+ function readLock(path) {
1924
+ if (!existsSync(path)) return emptyLock();
1925
+ const raw = JSON.parse(readFileSync(path, "utf8"));
1926
+ if (raw.version === 2) return {
1927
+ ...emptyLock(),
1928
+ ...raw
1929
+ };
1930
+ return {
1931
+ version: 2,
1932
+ sources: {},
1933
+ translations: raw
1934
+ };
1935
+ }
1936
+ const sortedEntries = (record) => Object.fromEntries(Object.entries(record).sort(([a], [b]) => a < b ? -1 : 1));
1937
+ function serializeLock(lock) {
1938
+ const translations = Object.fromEntries(Object.keys(lock.translations).sort().map((locale) => [locale, sortedEntries(lock.translations[locale])]));
1939
+ return `${JSON.stringify({
1940
+ version: 2,
1941
+ sources: sortedEntries(lock.sources),
1942
+ translations
1943
+ }, null, 2)}\n`;
1944
+ }
1945
+ /** What changed in the source of a stale translation, as a word diff, or why that isn't known. */
1946
+ function changeOf(stale) {
1947
+ if (stale.was === void 0) return "the earlier text isn't recorded (the lock predates prosebook 0.2)";
1948
+ return formatDiff(wordDiff(stale.was, stale.now)).replaceAll("\n", " ");
1949
+ }
1950
+ /**
1951
+ * The lock brought up to date with the catalog, and what is stale. `accept` re-anchors the given
1952
+ * translations (per locale, keys, or `true` for all of them) to the current source.
1953
+ */
1954
+ function reconcile(catalog, previous, accept = /* @__PURE__ */ new Map()) {
1955
+ const lock = emptyLock();
1956
+ const stale = /* @__PURE__ */ new Map();
1957
+ for (const message of catalog.messages) {
1958
+ const source = hash(message.text);
1959
+ for (const [locale, translation] of message.translations) {
1960
+ const text = hash(translation.text);
1961
+ const [anchor, recorded] = (previous.translations[locale]?.[message.key] ?? "").split(":");
1962
+ const accepted = accept.get(locale);
1963
+ const reanchor = anchor === void 0 || anchor === "" || recorded !== text || accepted === true || accepted?.has(message.key) === true;
1964
+ const kept = reanchor ? source : anchor;
1965
+ (lock.translations[locale] ??= {})[message.key] = `${kept}:${text}`;
1966
+ const keptText = kept === source ? message.text : previous.sources[kept];
1967
+ if (keptText !== void 0) lock.sources[kept] = keptText;
1968
+ if (!reanchor && anchor !== source) {
1969
+ let keys = stale.get(locale);
1970
+ if (keys === void 0) stale.set(locale, keys = /* @__PURE__ */ new Map());
1971
+ keys.set(message.key, {
1972
+ was: keptText,
1973
+ now: message.text,
1974
+ translation: translation.text
1975
+ });
1976
+ }
1977
+ }
1978
+ }
1979
+ return {
1980
+ lock,
1981
+ stale
1982
+ };
1983
+ }
1984
+ // src/tools/todo.ts
1985
+ /**
1986
+ * `prosebook todo`: a locale's missing and stale entries, each with what a translator needs above it,
1987
+ * as the YAML to fill in. `prosebook import` reads it back filled.
1988
+ *
1989
+ * With `prompt`, it leads with what a model must be told to reply in that form. prosebook writes the
1990
+ * prompt and checks the reply; which model answers it, and how, is left to whoever runs it.
1991
+ */
1992
+ function todoOf(config, catalog, stale, locale, prompt = false) {
1993
+ const wanted = todoMessages(catalog, stale, locale);
1994
+ if (wanted.length === 0) return {
1995
+ count: 0,
1996
+ text: `# ${locale} is complete`
1997
+ };
1998
+ const byArea = /* @__PURE__ */ new Map();
1999
+ for (const m of wanted) {
2000
+ const lines = byArea.get(m.area) ?? [];
2001
+ lines.push("");
2002
+ if (m.context) lines.push(`# ${m.context}`);
2003
+ const what = [m.kind && `kind: ${m.kind}`, m.surface && `surface: ${m.surface}`].filter(Boolean).join(", ");
2004
+ if (what) lines.push(`# ${what}`);
2005
+ for (const [arg, said] of m.argDocs) lines.push(`# {${arg}}: ${said}`);
2006
+ lines.push(`# ${config.source}: ${m.text.replaceAll("\n", " ")}`);
2007
+ const change = stale.get(m.key);
2008
+ if (change !== void 0) lines.push(`# stale, the source changed: ${changeOf(change)}`);
2009
+ lines.push(stringify({ [m.key]: m.translations.get(locale)?.text ?? "" }, { lineWidth: 0 }).trimEnd());
2010
+ byArea.set(m.area, lines);
2011
+ }
2012
+ const entries = [...byArea].map(([area, lines]) => `\n# ── ${area}.yaml ──\n${lines.join("\n")}`).join("\n");
2013
+ const count = wanted.length;
2014
+ if (prompt) return {
2015
+ count,
2016
+ text: `${promptFor(config, wanted, locale, wanted.some((m) => stale.has(m.key)))}\n${entries.trimStart()}\n\`\`\``
2017
+ };
2018
+ return {
2019
+ count,
2020
+ text: `${`# prosebook todo: ${count} entr${count === 1 ? "y" : "ies"} for ${locale}. Fill them in, then \`prosebook import ${locale} <this file>\`.`}\n${entries}`
2021
+ };
2022
+ }
2023
+ /** The entries a locale wants a translation of: those it lacks, and those gone stale. */
2024
+ function todoMessages(catalog, stale, locale) {
2025
+ return catalog.messages.filter((m) => !m.translations.has(locale) || stale.has(m.key));
2026
+ }
2027
+ function promptFor(config, wanted, locale, anyStale) {
2028
+ const mf2 = config.messageFormat === 2;
2029
+ const [target, source] = [nameOf(locale), nameOf(config.source)];
2030
+ const plurals = {
2031
+ cardinal: false,
2032
+ ordinal: false
2033
+ };
2034
+ for (const m of wanted) walk(m.ast, (el) => {
2035
+ if (el.type === TYPE$1.plural) plurals[el.pluralType === "ordinal" ? "ordinal" : "cardinal"] = true;
2036
+ });
2037
+ const categories = (type) => {
2038
+ const forms = new Intl.PluralRules(locale, { type }).resolvedOptions().pluralCategories;
2039
+ return forms.length === 1 ? `only the form ${forms[0]}` : `the forms ${new Intl.ListFormat("en").format(forms)}`;
2040
+ };
2041
+ const exact = mf2 ? "`0`" : "`=0`";
2042
+ const rules = [
2043
+ mf2 ? `Each message is in MessageFormat 2. Keep every {$placeholder}, every markup tag ({#b}…{/b}) around the same words, and every .input, .local and .match line; translate the text, not the names.` : `Each message is in ICU MessageFormat. Keep every {placeholder}, and every <tag> around the same words; translate the text inside plural and select arms, not the arm or argument names.`,
2044
+ plurals.cardinal && `A plural in ${target} takes ${categories("cardinal")}: give each its own ${mf2 ? "variant" : "arm"}, and keep exact ones such as ${exact} where the ${source} has them.`,
2045
+ plurals.ordinal && `An ordinal in ${target} takes ${categories("ordinal")}.`,
2046
+ "The comments above an entry say where it appears, what kind of string it is, and what each placeholder holds. Hold each to its kind, as described below: a button stays as short as the source.",
2047
+ anyStale && "An entry marked stale has a translation made from an earlier source. The diff shows what changed in the source ({+added+}, [-removed-]): change the translation only as much as that needs, and give it back unchanged if it still holds.",
2048
+ "Quote a value in single quotes when it starts with { or holds \": \", as YAML needs, and double any single quote inside it."
2049
+ ].filter((r) => typeof r === "string");
2050
+ const kinds = new Set(wanted.flatMap((m) => m.kind === void 0 ? [] : [m.kind]));
2051
+ const surfaces = new Set(wanted.flatMap((m) => m.surface === void 0 ? [] : [m.surface]));
2052
+ const legend = [
2053
+ ...kinds.size > 0 ? ["", "Kinds of string:"] : [],
2054
+ ...[...kinds].map((k) => `- ${k}: ${config.kinds[k]?.doc ?? ""}`),
2055
+ ...surfaces.size > 0 ? ["", "Surfaces:"] : [],
2056
+ ...[...surfaces].map((s) => `- ${s}: ${config.surfaces[s] ?? ""}`)
2057
+ ];
2058
+ return [
2059
+ `Translate the entries below from ${source} (${config.source}) into ${target} (${locale}). They are the text of an app's interface.`,
2060
+ "",
2061
+ "Reply with one YAML code block holding every key below, each with its translation as the value, and nothing else in the block.",
2062
+ "",
2063
+ ...rules.map((r) => `- ${r}`),
2064
+ ...legend,
2065
+ "",
2066
+ `The entries, in ${SYNTAX_NAME[config.messageFormat]}:`,
2067
+ "",
2068
+ "```yaml"
2069
+ ].join("\n");
2070
+ }
2071
+ function nameOf(locale) {
2072
+ try {
2073
+ return new Intl.DisplayNames(["en"], { type: "language" }).of(locale) ?? locale;
2074
+ } catch {
2075
+ return locale;
2076
+ }
2077
+ }
2078
+ // src/tools/exchange.ts
2079
+ /**
2080
+ * `prosebook export`: a locale's entries as XLIFF 2.0 or JSON, for translation tools, and the reader
2081
+ * `prosebook import` takes every format back through, todo's YAML and a model's reply included.
2082
+ *
2083
+ * Messages travel as their ICU or MF2 text, not as XLIFF's inline codes: a plural or a select has no
2084
+ * inline form, and the tools that take these files read ICU. A unit carries its source as exported, so
2085
+ * a translation of a source that has changed since is refused rather than written as current.
2086
+ */
2087
+ /**
2088
+ * The entries `todo` would list for `locale`, or every one with `all`, as a file a translation tool
2089
+ * reads. The source locale exports every entry, untranslated, for a tool to take as its source.
2090
+ */
2091
+ function exportOf(config, catalog, stale, locale, format, all = false) {
2092
+ const isSource = locale === config.source;
2093
+ const messages = all || isSource ? catalog.messages : todoMessages(catalog, stale, locale);
2094
+ if (messages.length === 0) return {
2095
+ count: 0,
2096
+ text: ""
2097
+ };
2098
+ const textOf = (m) => isSource ? m.text : m.translations.get(locale)?.text;
2099
+ if (format === "json") {
2100
+ const json = JSON.stringify(Object.fromEntries(messages.map((m) => [m.key, textOf(m) ?? ""])), null, 2);
2101
+ return {
2102
+ count: messages.length,
2103
+ text: `${json}\n`
2104
+ };
2105
+ }
2106
+ const byArea = /* @__PURE__ */ new Map();
2107
+ for (const m of messages) byArea.set(m.area, [...byArea.get(m.area) ?? [], m]);
2108
+ const lines = ["<?xml version=\"1.0\" encoding=\"UTF-8\"?>", `<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="${escape$2(config.source)}"${isSource ? "" : ` trgLang="${escape$2(locale)}"`}>`];
2109
+ for (const [area, list] of byArea) {
2110
+ lines.push(` <file id="${escape$2(area)}" original="${escape$2(list[0].file)}" xml:space="preserve">`);
2111
+ for (const m of list) {
2112
+ lines.push(` <unit id="${m.key}">`);
2113
+ const notes = [
2114
+ ["context", m.context],
2115
+ ["kind", m.kind],
2116
+ ["surface", m.surface],
2117
+ ...[...m.argDocs].map(([arg, said]) => ["placeholder", `{${arg}}: ${said}`]),
2118
+ ["stale", isSource || !stale.has(m.key) ? void 0 : `the source changed: ${changeOf(stale.get(m.key))}`]
2119
+ ].filter((n) => n[1] !== void 0);
2120
+ if (notes.length > 0) {
2121
+ lines.push(" <notes>");
2122
+ for (const [category, text] of notes) lines.push(` <note category="${category}">${escape$2(text)}</note>`);
2123
+ lines.push(" </notes>");
2124
+ }
2125
+ const target = textOf(m);
2126
+ const state = isSource ? "" : ` state="${target === void 0 || stale.has(m.key) ? "initial" : "translated"}"`;
2127
+ lines.push(` <segment${state}>`, ` <source>${escape$2(m.text)}</source>`);
2128
+ if (!isSource && target !== void 0) lines.push(` <target>${escape$2(target)}</target>`);
2129
+ lines.push(" </segment>", " </unit>");
2130
+ }
2131
+ lines.push(" </file>");
2132
+ }
2133
+ lines.push("</xliff>");
2134
+ return {
2135
+ count: messages.length,
2136
+ text: `${lines.join("\n")}\n`
2137
+ };
2138
+ }
2139
+ /**
2140
+ * The translations in a file `import` was given: XLIFF 2.0 when it starts as XML, otherwise YAML,
2141
+ * which reads JSON too. Text around fenced blocks is left aside, as a model's reply has it. A nested
2142
+ * mapping, as some tools write JSON, joins its keys with dots.
2143
+ */
2144
+ function readReturned(text, locale, source) {
2145
+ if (text.trimStart().startsWith("<")) return readXliff(text, locale, source);
2146
+ const fenced = [...text.matchAll(/^```[^\n]*\n([\s\S]*?)^```/gm)].map((m) => m[1]);
2147
+ const doc = parseDocument(fenced.length > 0 ? fenced.join("\n") : text);
2148
+ if (doc.errors.length > 0 || !(isMap(doc.contents) || doc.contents === null)) return { problem: `the reply isn't YAML or JSON of \`key: translation\` (${doc.errors[0]?.message.split("\n", 1)[0] ?? "it is not a mapping"}): fix it, or ask for one YAML block` };
2149
+ const returned = [];
2150
+ const flatten = (map, prefix) => {
2151
+ for (const pair of map.items) {
2152
+ const key = prefix + (isScalar(pair.key) ? String(pair.key.value) : String(pair.key));
2153
+ if (isMap(pair.value)) flatten(pair.value, `${key}.`);
2154
+ else returned.push({
2155
+ key,
2156
+ value: isScalar(pair.value) ? pair.value.value : pair.value,
2157
+ checked: true
2158
+ });
2159
+ }
2160
+ };
2161
+ if (isMap(doc.contents)) flatten(doc.contents, "");
2162
+ return { returned };
2163
+ }
2164
+ function readXliff(text, locale, source) {
2165
+ let root;
2166
+ try {
2167
+ root = parseXml(text);
2168
+ } catch (e) {
2169
+ return { problem: `the file isn't XML (${messageOf(e)}): give it back as the tool exported it` };
2170
+ }
2171
+ if (root.name !== "xliff") return { problem: `the file is <${root.name}>, not XLIFF: export it from the tool as XLIFF 2.0` };
2172
+ if (!root.attrs.version?.startsWith("2.")) return { problem: `the file is XLIFF ${root.attrs.version ?? "of no version"}: export it from the tool as XLIFF 2.0` };
2173
+ const { srcLang, trgLang } = root.attrs;
2174
+ if (srcLang !== void 0 && srcLang.toLowerCase() !== source.toLowerCase()) return { problem: `the file translates from ${srcLang}, and the source is ${source}: export it again from prosebook` };
2175
+ if (trgLang === void 0) return { problem: `the file names no trgLang: give it the ${locale} translations, or set trgLang="${locale}"` };
2176
+ if (trgLang.toLowerCase() !== locale.toLowerCase()) return { problem: `the file translates into ${trgLang}, not ${locale}: import it as ${trgLang}` };
2177
+ const returned = [];
2178
+ const units = (el) => elements(el).flatMap((child) => child.name === "unit" ? [child] : child.name === "file" || child.name === "group" ? units(child) : []);
2179
+ for (const unit of units(root)) {
2180
+ const key = unit.attrs.id ?? "";
2181
+ const parts = elements(unit).filter((el) => el.name === "segment" || el.name === "ignorable");
2182
+ const segments = parts.filter((el) => el.name === "segment");
2183
+ const sourceOf = (el) => elements(el).find((c) => c.name === "source");
2184
+ const targetOf = (el) => elements(el).find((c) => c.name === "target");
2185
+ try {
2186
+ const unitSource = parts.map((p) => textOf(sourceOf(p))).join("");
2187
+ const translated = segments.filter((s) => targetOf(s) !== void 0).length;
2188
+ if (translated === 0) {
2189
+ returned.push({
2190
+ key,
2191
+ value: null,
2192
+ checked: false,
2193
+ source: unitSource
2194
+ });
2195
+ continue;
2196
+ }
2197
+ if (translated < segments.length) throw new Error(`${translated} of its ${segments.length} segments are translated: translate the rest`);
2198
+ const value = parts.map((p) => textOf(targetOf(p) ?? sourceOf(p))).join("");
2199
+ const checked = segments.every((s) => (s.attrs.state ?? "initial") !== "initial");
2200
+ returned.push({
2201
+ key,
2202
+ value,
2203
+ checked,
2204
+ source: unitSource
2205
+ });
2206
+ } catch (e) {
2207
+ returned.push({
2208
+ key,
2209
+ value: null,
2210
+ checked: false,
2211
+ problem: messageOf(e)
2212
+ });
2213
+ }
2214
+ }
2215
+ return { returned };
2216
+ }
2217
+ /** The text of a <source> or <target>, refusing XLIFF's inline codes, which prosebook never writes. */
2218
+ function textOf(el) {
2219
+ if (el === void 0) return "";
2220
+ return el.children.map((child) => {
2221
+ if (typeof child === "string") return child;
2222
+ if (child.name === "cp" && child.attrs.hex !== void 0) return String.fromCodePoint(Number.parseInt(child.attrs.hex, 16));
2223
+ throw new Error(`the ${el.name} holds an inline <${child.name}>: have the tool keep placeholders as the text they were exported as`);
2224
+ }).join("");
2225
+ }
2226
+ const messageOf = (e) => e instanceof Error ? e.message : String(e);
2227
+ const escape$2 = (s) => s.replaceAll("&", "&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);
1813
2319
  // src/tools/entry.ts
1814
2320
  /**
1815
2321
  * One entry as the dev inspector's dialog edits it, in every locale: as fields, or as the YAML written
@@ -1928,10 +2434,10 @@ function applyFixes(config, catalog, findings) {
1928
2434
  };
1929
2435
  }
1930
2436
  /**
1931
- * Translations into `locale` from `prosebook todo`'s YAML filled in, by a person or a model, 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.
2437
+ * Translations into `locale` from `prosebook todo`'s YAML filled in, by a person or a model, or from
2438
+ * a translation tool's XLIFF or JSON (`readReturned`), each written to the file its locale and area
2439
+ * put it in. An empty value is one left for later. An entry that would break the catalog is left out
2440
+ * with its problem, so a reply with one bad entry still lands the others.
1935
2441
  */
1936
2442
  function importTranslations(config, locale, reply) {
1937
2443
  const failed = (message, key) => ({
@@ -1944,36 +2450,41 @@ function importTranslations(config, locale, reply) {
1944
2450
  unchanged: [],
1945
2451
  problems: [failed(`${locale} is the source: import takes translations`)]
1946
2452
  };
1947
- const 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();
@@ -2059,263 +2570,99 @@ function viewOf(config, catalog, key, unsaved) {
2059
2570
  /** Where an entry is written: from the start of its key's line to the end of its value. */
2060
2571
  function snippetOf(file, key) {
2061
2572
  const doc = parseDocument(file);
2062
- if (!isMap(doc.contents)) return void 0;
2063
- const pair = doc.contents.items.find((p) => isScalar(p.key) && p.key.value === key);
2064
- const keyStart = isScalar(pair?.key) ? pair.key.range?.[0] : void 0;
2065
- const valueEnd = (pair?.value)?.range?.[1];
2066
- if (keyStart === void 0 || valueEnd === void 0) return void 0;
2067
- const start = file.lastIndexOf("\n", keyStart - 1) + 1;
2068
- const end = start + file.slice(start, valueEnd).trimEnd().length;
2069
- return {
2070
- start,
2071
- end,
2072
- text: file.slice(start, end)
2073
- };
2074
- }
2075
- /** The file with the entry's text replaced, removed when `yaml` is empty, or added at the end. */
2076
- function withSnippet(file, key, yaml) {
2077
- const text = yaml.replace(/\n+$/, "");
2078
- const at = snippetOf(file, key);
2079
- if (at !== void 0) {
2080
- if (text.trim() === "") return file.slice(0, at.start) + file.slice(at.end).replace(/^\n/, "");
2081
- return file.slice(0, at.start) + text + file.slice(at.end);
2082
- }
2083
- if (text.trim() === "") return file;
2084
- return file.trim() === "" ? `${text}\n` : `${file.replace(/\n*$/, "\n")}${text}\n`;
2085
- }
2086
- function parse$2(file) {
2087
- const doc = parseDocument(file, { version: "1.1" });
2088
- if (doc.contents === null) doc.contents = doc.createNode({});
2089
- return doc;
2090
- }
2091
- const print = (doc) => doc.toString({
2092
- lineWidth: 0,
2093
- singleQuote: true
2094
- });
2095
- /** A value written where one was, keeping how it was quoted; text over several lines as a block. */
2096
- function setValue(doc, map, name, value) {
2097
- const node = map.get(name, true);
2098
- if (isScalar(node) && typeof value === "string") {
2099
- node.value = value;
2100
- if (value.includes("\n")) node.type = Scalar.BLOCK_LITERAL;
2101
- return;
2102
- }
2103
- map.set(name, doc.createNode(value));
2104
- }
2105
- function withFields(file, key, fields, source) {
2106
- const doc = parse$2(file);
2107
- let entry = doc.get(key, true);
2108
- if (!isMap(entry)) {
2109
- entry = doc.createNode({ [source]: fields.message });
2110
- doc.set(key, entry);
2111
- }
2112
- const map = entry;
2113
- const args = Object.fromEntries(Object.entries(fields.args).filter(([, v]) => v.trim() !== ""));
2114
- const wanted = [
2115
- ["kind", fields.kind.trim() || void 0],
2116
- ["surface", fields.surface.trim() || void 0],
2117
- ["context", fields.context.trim() || void 0],
2118
- ["args", Object.keys(args).length > 0 ? args : void 0],
2119
- ["fragment", fields.fragment || void 0],
2120
- ["comment", fields.comment.trim() || void 0],
2121
- ["remove", fields.remove || void 0]
2122
- ];
2123
- for (const [name, value] of wanted) {
2124
- if (value === void 0) {
2125
- map.delete(name);
2126
- continue;
2127
- }
2128
- if (map.has(name)) {
2129
- setValue(doc, map, name, value);
2130
- continue;
2131
- }
2132
- const at = map.items.findIndex((p) => isScalar(p.key) && p.key.value === source);
2133
- map.items.splice(at === -1 ? map.items.length : at, 0, doc.createPair(name, value));
2134
- }
2135
- setValue(doc, map, source, fields.message);
2136
- return print(doc);
2137
- }
2138
- /** The source message replaced, and nothing else: a `key: message` entry stays one. */
2139
- function withMessage(file, key, message, source) {
2140
- const doc = parse$2(file);
2141
- const entry = doc.get(key, true);
2142
- if (!isMap(entry)) return withTranslation(file, key, message);
2143
- setValue(doc, entry, source, message);
2144
- return print(doc);
2145
- }
2146
- function withTranslation(file, key, translation) {
2147
- const doc = parse$2(file);
2148
- const node = doc.get(key, true);
2149
- if (translation.trim() === "") doc.delete(key);
2150
- else if (isScalar(node)) {
2151
- node.value = translation;
2152
- if (translation.includes("\n")) node.type = Scalar.BLOCK_LITERAL;
2153
- } else doc.set(key, translation);
2154
- return isMap(doc.contents) && doc.contents.items.length === 0 ? "" : print(doc);
2155
- }
2156
- // src/tools/diff.ts
2157
- const TOKEN = /\s+|[\p{L}\p{N}_'’]+|[^\s\p{L}\p{N}_]/gu;
2158
- /** Past this many cells the table costs more than the diff is worth: show the whole change instead. */
2159
- const MAX_CELLS = 25e4;
2160
- function wordDiff(before, after) {
2161
- const a = before.match(TOKEN) ?? [];
2162
- const b = after.match(TOKEN) ?? [];
2163
- if (a.length * b.length > MAX_CELLS) return merge([{
2164
- op: "del",
2165
- text: before
2166
- }, {
2167
- op: "add",
2168
- text: after
2169
- }]);
2170
- const lcs = Array.from({ length: a.length + 1 }, () => Array.from({ length: b.length + 1 }, () => 0));
2171
- for (let i = a.length - 1; i >= 0; i--) for (let j = b.length - 1; j >= 0; j--) lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
2172
- const parts = [];
2173
- let i = 0;
2174
- let j = 0;
2175
- while (i < a.length && j < b.length) if (a[i] === b[j]) {
2176
- parts.push({
2177
- op: "same",
2178
- text: a[i]
2179
- });
2180
- i++;
2181
- j++;
2182
- } else if (lcs[i + 1][j] >= lcs[i][j + 1]) parts.push({
2183
- op: "del",
2184
- text: a[i++]
2185
- });
2186
- else parts.push({
2187
- op: "add",
2188
- text: b[j++]
2189
- });
2190
- while (i < a.length) parts.push({
2191
- op: "del",
2192
- text: a[i++]
2193
- });
2194
- while (j < b.length) parts.push({
2195
- op: "add",
2196
- text: b[j++]
2197
- });
2198
- return merge(parts);
2199
- }
2200
- /**
2201
- * Adjacent parts of one kind joined, and a space shared by two changes folded into them, so a
2202
- * replaced phrase reads `[-old words-]{+new words+}` rather than alternating word by word.
2203
- */
2204
- function merge(parts) {
2205
- const out = [];
2206
- for (let k = 0; k < parts.length; k++) {
2207
- const part = parts[k];
2208
- const prev = out.at(-1);
2209
- const next = parts[k + 1];
2210
- if (part.op === "same" && /^\s+$/.test(part.text) && prev && prev.op !== "same" && next && next.op !== "same") {
2211
- out.push({
2212
- op: "del",
2213
- text: part.text
2214
- }, {
2215
- op: "add",
2216
- text: part.text
2217
- });
2218
- continue;
2219
- }
2220
- out.push({ ...part });
2221
- }
2222
- const joined = [];
2223
- for (const part of out.filter((p) => p.text !== "")) {
2224
- const last = joined.at(-1);
2225
- if (last && last.op === part.op) last.text += part.text;
2226
- else if (part.op === "del" && last?.op === "add" && joined.at(-2)?.op === "del") joined.at(-2).text += part.text;
2227
- else joined.push({ ...part });
2228
- }
2229
- return joined;
2230
- }
2231
- /** The diff as one line: `Save [-the-]{+a+} draft`, the notation of `git diff --word-diff`. */
2232
- function formatDiff(parts) {
2233
- return parts.map((p) => p.op === "same" ? p.text : p.op === "del" ? `[-${p.text}-]` : `{+${p.text}+}`).join("");
2234
- }
2235
- // src/tools/lock.ts
2236
- /**
2237
- * Stale translations: a translation made from source text that has since changed.
2238
- *
2239
- * The lock records, per translation, a hash of the source it was made from and a hash of the
2240
- * translation itself. Editing the translation re-anchors it to the current source; editing only
2241
- * the source leaves the old anchor in place, which is what marks it stale. Nobody has to remember
2242
- * to flag anything: `prosebook status` lists what is stale, and `prosebook accept` re-anchors a
2243
- * translation that was still right.
2244
- *
2245
- * The lock also keeps the text behind each anchor, once per distinct text, so a stale translation
2246
- * can show its translator what changed rather than only what the source says now.
2247
- */
2248
- function hash(text) {
2249
- return createHash("sha256").update(text).digest("hex").slice(0, 10);
2250
- }
2251
- function emptyLock() {
2252
- return {
2253
- version: 2,
2254
- sources: {},
2255
- translations: {}
2256
- };
2257
- }
2258
- /** The lock at `path`. A version 1 lock, which held only the translations, reads with no source texts. */
2259
- function readLock(path) {
2260
- if (!existsSync(path)) return emptyLock();
2261
- const raw = JSON.parse(readFileSync(path, "utf8"));
2262
- if (raw.version === 2) return {
2263
- ...emptyLock(),
2264
- ...raw
2265
- };
2573
+ if (!isMap(doc.contents)) return void 0;
2574
+ const pair = doc.contents.items.find((p) => isScalar(p.key) && p.key.value === key);
2575
+ const keyStart = isScalar(pair?.key) ? pair.key.range?.[0] : void 0;
2576
+ const valueEnd = (pair?.value)?.range?.[1];
2577
+ if (keyStart === void 0 || valueEnd === void 0) return void 0;
2578
+ const start = file.lastIndexOf("\n", keyStart - 1) + 1;
2579
+ const end = start + file.slice(start, valueEnd).trimEnd().length;
2266
2580
  return {
2267
- version: 2,
2268
- sources: {},
2269
- translations: raw
2581
+ start,
2582
+ end,
2583
+ text: file.slice(start, end)
2270
2584
  };
2271
2585
  }
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`;
2586
+ /** The file with the entry's text replaced, removed when `yaml` is empty, or added at the end. */
2587
+ function withSnippet(file, key, yaml) {
2588
+ const text = yaml.replace(/\n+$/, "");
2589
+ const at = snippetOf(file, key);
2590
+ if (at !== void 0) {
2591
+ if (text.trim() === "") return file.slice(0, at.start) + file.slice(at.end).replace(/^\n/, "");
2592
+ return file.slice(0, at.start) + text + file.slice(at.end);
2593
+ }
2594
+ if (text.trim() === "") return file;
2595
+ return file.trim() === "" ? `${text}\n` : `${file.replace(/\n*$/, "\n")}${text}\n`;
2280
2596
  }
2281
- /** 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", " ");
2597
+ function parse$2(file) {
2598
+ const doc = parseDocument(file, { version: "1.1" });
2599
+ if (doc.contents === null) doc.contents = doc.createNode({});
2600
+ return doc;
2285
2601
  }
2286
- /**
2287
- * 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
- }
2602
+ const print = (doc) => doc.toString({
2603
+ lineWidth: 0,
2604
+ singleQuote: true
2605
+ });
2606
+ /** A value written where one was, keeping how it was quoted; text over several lines as a block. */
2607
+ function setValue(doc, map, name, value) {
2608
+ const node = map.get(name, true);
2609
+ if (isScalar(node) && typeof value === "string") {
2610
+ node.value = value;
2611
+ if (value.includes("\n")) node.type = Scalar.BLOCK_LITERAL;
2612
+ return;
2613
+ }
2614
+ map.set(name, doc.createNode(value));
2615
+ }
2616
+ function withFields(file, key, fields, source) {
2617
+ const doc = parse$2(file);
2618
+ let entry = doc.get(key, true);
2619
+ if (!isMap(entry)) {
2620
+ entry = doc.createNode({ [source]: fields.message });
2621
+ doc.set(key, entry);
2622
+ }
2623
+ const map = entry;
2624
+ const args = Object.fromEntries(Object.entries(fields.args).filter(([, v]) => v.trim() !== ""));
2625
+ const wanted = [
2626
+ ["kind", fields.kind.trim() || void 0],
2627
+ ["surface", fields.surface.trim() || void 0],
2628
+ ["context", fields.context.trim() || void 0],
2629
+ ["args", Object.keys(args).length > 0 ? args : void 0],
2630
+ ["fragment", fields.fragment || void 0],
2631
+ ["comment", fields.comment.trim() || void 0],
2632
+ ["remove", fields.remove || void 0]
2633
+ ];
2634
+ for (const [name, value] of wanted) {
2635
+ if (value === void 0) {
2636
+ map.delete(name);
2637
+ continue;
2638
+ }
2639
+ if (map.has(name)) {
2640
+ setValue(doc, map, name, value);
2641
+ continue;
2313
2642
  }
2643
+ const at = map.items.findIndex((p) => isScalar(p.key) && p.key.value === source);
2644
+ map.items.splice(at === -1 ? map.items.length : at, 0, doc.createPair(name, value));
2314
2645
  }
2315
- return {
2316
- lock,
2317
- stale
2318
- };
2646
+ setValue(doc, map, source, fields.message);
2647
+ return print(doc);
2648
+ }
2649
+ /** The source message replaced, and nothing else: a `key: message` entry stays one. */
2650
+ function withMessage(file, key, message, source) {
2651
+ const doc = parse$2(file);
2652
+ const entry = doc.get(key, true);
2653
+ if (!isMap(entry)) return withTranslation(file, key, message);
2654
+ setValue(doc, entry, source, message);
2655
+ return print(doc);
2656
+ }
2657
+ function withTranslation(file, key, translation) {
2658
+ const doc = parse$2(file);
2659
+ const node = doc.get(key, true);
2660
+ if (translation.trim() === "") doc.delete(key);
2661
+ else if (isScalar(node)) {
2662
+ node.value = translation;
2663
+ if (translation.includes("\n")) node.type = Scalar.BLOCK_LITERAL;
2664
+ } else doc.set(key, translation);
2665
+ return isMap(doc.contents) && doc.contents.items.length === 0 ? "" : print(doc);
2319
2666
  }
2320
2667
  // src/tools/native/emit.ts
2321
2668
  /** Words from an identifier written any way: `userName`, `user-name` and `user_name` are user, name. */
@@ -2371,6 +2718,195 @@ function checkNames(language, named, taken) {
2371
2718
  seen.set(name, key);
2372
2719
  }
2373
2720
  }
2721
+ // src/tools/native/date.ts
2722
+ /** Dates the spec must format as `Intl` does: every month and weekday, midnight, noon and the hours around them, and two-digit years. */
2723
+ const SAMPLES$1 = [
2724
+ Date.UTC(2001, 0, 5, 1, 2, 3),
2725
+ Date.UTC(2001, 10, 25, 13, 42, 53),
2726
+ Date.UTC(1999, 11, 31, 23, 59, 59),
2727
+ Date.UTC(2e3, 1, 29, 0, 0, 0),
2728
+ Date.UTC(2024, 6, 4, 12, 0, 0),
2729
+ Date.UTC(2024, 6, 4, 12, 30, 5),
2730
+ Date.UTC(1970, 0, 1, 0, 0, 0),
2731
+ Date.UTC(2099, 8, 9, 9, 9, 9),
2732
+ Date.UTC(2010, 4, 15, 18, 45, 0),
2733
+ Date.UTC(2026, 2, 10, 6, 15, 30),
2734
+ Date.UTC(2005, 9, 31, 21, 7, 41),
2735
+ ...Array.from({ length: 12 }, (_, m) => Date.UTC(2023, m, 1 + m * 2, m * 2, m * 5, m * 4)),
2736
+ ...Array.from({ length: 24 }, (_, h) => Date.UTC(2022, 3, 3 + h % 7, h, 30, 0))
2737
+ ];
2738
+ /** The fields of a date in UTC, which the spec is checked in. */
2739
+ function fieldsOf(date) {
2740
+ const d = new Date(date);
2741
+ return {
2742
+ year: d.getUTCFullYear(),
2743
+ month: d.getUTCMonth() + 1,
2744
+ day: d.getUTCDate(),
2745
+ hour: d.getUTCHours(),
2746
+ minute: d.getUTCMinutes(),
2747
+ second: d.getUTCSeconds(),
2748
+ weekday: d.getUTCDay()
2749
+ };
2750
+ }
2751
+ const HOUR_CYCLES = /* @__PURE__ */ new Set([
2752
+ "h11",
2753
+ "h12",
2754
+ "h23",
2755
+ "h24"
2756
+ ]);
2757
+ /**
2758
+ * What a format with `options` takes in `locale`. Throws a sentence saying what a native target
2759
+ * can't reproduce, which the caller puts in front of a fix.
2760
+ */
2761
+ function dateSpec(locale, options = {}) {
2762
+ if (options.timeZone !== void 0) throw new Error(`it names the time zone ${options.timeZone}, which native code can't look up`);
2763
+ const format = new Intl.DateTimeFormat(locale, {
2764
+ ...options,
2765
+ timeZone: "UTC"
2766
+ });
2767
+ const resolved = format.resolvedOptions();
2768
+ if (resolved.calendar !== "gregory" && resolved.calendar !== "iso8601") throw new Error(`it counts in the ${resolved.calendar} calendar`);
2769
+ const native = new Intl.NumberFormat(locale, {
2770
+ numberingSystem: resolved.numberingSystem,
2771
+ useGrouping: false
2772
+ });
2773
+ const digits = Array.from({ length: 10 }, (_, d) => native.format(d));
2774
+ const ascii = (text) => text.replace(/./gu, (c) => digits.includes(c) ? String(digits.indexOf(c)) : c);
2775
+ /** The text `type` takes at `date`, in ASCII digits. */
2776
+ const partAt = (date, type) => ascii(format.formatToParts(date).find((p) => p.type === type)?.value ?? "");
2777
+ const isNumber = (text) => /^[0-9]+$/.test(text);
2778
+ const padOf = (date, type) => partAt(date, type).startsWith("0") ? 2 : 1;
2779
+ const pieces = [];
2780
+ for (const part of format.formatToParts(SAMPLES$1[1])) switch (part.type) {
2781
+ case "literal":
2782
+ pieces.push({
2783
+ type: "text",
2784
+ value: part.value
2785
+ });
2786
+ break;
2787
+ case "year":
2788
+ pieces.push(ascii(part.value) === "01" ? {
2789
+ type: "number",
2790
+ field: "year2",
2791
+ pad: 2
2792
+ } : {
2793
+ type: "number",
2794
+ field: "year",
2795
+ pad: 1
2796
+ });
2797
+ break;
2798
+ case "month": {
2799
+ const names = Array.from({ length: 12 }, (_, m) => format.formatToParts(Date.UTC(2023, m, 15, 12)).find((p) => p.type === "month").value);
2800
+ if (names.every((name) => isNumber(ascii(name)))) pieces.push({
2801
+ type: "number",
2802
+ field: "month",
2803
+ pad: padOf(Date.UTC(2023, 0, 15, 12), "month")
2804
+ });
2805
+ else pieces.push({
2806
+ type: "name",
2807
+ field: "month",
2808
+ names
2809
+ });
2810
+ break;
2811
+ }
2812
+ case "day":
2813
+ pieces.push({
2814
+ type: "number",
2815
+ field: "day",
2816
+ pad: padOf(Date.UTC(2023, 0, 5, 12), "day")
2817
+ });
2818
+ break;
2819
+ case "weekday":
2820
+ pieces.push({
2821
+ type: "name",
2822
+ field: "weekday",
2823
+ names: Array.from({ length: 7 }, (_, d) => format.formatToParts(Date.UTC(2023, 0, 1 + d, 12)).find((p) => p.type === "weekday").value)
2824
+ });
2825
+ break;
2826
+ case "hour": {
2827
+ const cycle = resolved.hourCycle;
2828
+ if (cycle === void 0 || !HOUR_CYCLES.has(cycle)) throw new Error("its hours follow no clock native code knows");
2829
+ pieces.push({
2830
+ type: "hour",
2831
+ cycle,
2832
+ pad: padOf(Date.UTC(2023, 0, 5, 1), "hour")
2833
+ });
2834
+ break;
2835
+ }
2836
+ case "minute":
2837
+ case "second":
2838
+ pieces.push({
2839
+ type: "number",
2840
+ field: part.type,
2841
+ pad: padOf(Date.UTC(2023, 0, 5, 1, 5, 5), part.type)
2842
+ });
2843
+ break;
2844
+ case "dayPeriod": {
2845
+ const byHour = Array.from({ length: 24 }, (_, hour) => format.formatToParts(Date.UTC(2023, 0, 5, hour, 30)).find((p) => p.type === "dayPeriod").value);
2846
+ const halves = byHour.every((name, hour) => name === byHour[hour < 12 ? 0 : 12]);
2847
+ pieces.push({
2848
+ type: "name",
2849
+ field: "dayPeriod",
2850
+ names: halves ? [byHour[0], byHour[12]] : byHour
2851
+ });
2852
+ break;
2853
+ }
2854
+ default: throw new Error(`it writes the ${part.type}, which native code can't`);
2855
+ }
2856
+ const spec = {
2857
+ pieces,
2858
+ ...digits.join("") !== "0123456789" && { digits }
2859
+ };
2860
+ for (const sample of SAMPLES$1) {
2861
+ const expected = format.format(sample);
2862
+ const actual = formatDate(spec, fieldsOf(sample));
2863
+ if (actual !== expected) throw new Error(`it writes ${new Date(sample).toISOString()} as "${expected}", which native code would write as "${actual}"`);
2864
+ }
2865
+ return spec;
2866
+ }
2867
+ /** The hour as a clock with `cycle` shows it. */
2868
+ function hourOn(cycle, hour) {
2869
+ if (cycle === "h23") return hour;
2870
+ if (cycle === "h24") return hour === 0 ? 24 : hour;
2871
+ if (cycle === "h11") return hour % 12;
2872
+ return (hour + 11) % 12 + 1;
2873
+ }
2874
+ /**
2875
+ * A piece's number, or a name piece's index into its names, as an expression in the integer
2876
+ * arithmetic Rust, Swift and Kotlin share. Keep in step with `formatDate`.
2877
+ */
2878
+ function pieceExpr(piece, at) {
2879
+ if (piece.type === "hour") {
2880
+ const hour = at("hour");
2881
+ if (piece.cycle === "h23") return hour;
2882
+ if (piece.cycle === "h24") return `(${hour} + 23) % 24 + 1`;
2883
+ return piece.cycle === "h11" ? `${hour} % 12` : `(${hour} + 11) % 12 + 1`;
2884
+ }
2885
+ if (piece.type === "number") return piece.field === "year2" ? `(${at("year")} % 100 + 100) % 100` : at(piece.field);
2886
+ if (piece.field === "month") return `${at("month")} - 1`;
2887
+ if (piece.field === "weekday") return at("weekday");
2888
+ return piece.names.length === 24 ? at("hour") : `${at("hour")} / 12`;
2889
+ }
2890
+ /** Which of a name piece's `count` names `date` takes. */
2891
+ function nameIndex(field, count, date) {
2892
+ if (field === "month") return date.month - 1;
2893
+ if (field === "weekday") return date.weekday;
2894
+ return count === 24 ? date.hour : date.hour < 12 ? 0 : 1;
2895
+ }
2896
+ /** `date` as the spec writes it: the reference for the code each target generates. */
2897
+ function formatDate(spec, date) {
2898
+ const digits = spec.digits;
2899
+ const number = (value, pad) => {
2900
+ const text = String(value).padStart(pad, "0");
2901
+ return digits === void 0 ? text : text.replace(/[0-9]/g, (d) => digits[Number(d)]);
2902
+ };
2903
+ return spec.pieces.map((piece) => {
2904
+ if (piece.type === "text") return piece.value;
2905
+ if (piece.type === "hour") return number(hourOn(piece.cycle, date.hour), piece.pad);
2906
+ if (piece.type === "number") return number(piece.field === "year2" ? (date.year % 100 + 100) % 100 : date[piece.field], piece.pad);
2907
+ return piece.names[nameIndex(piece.field, piece.names.length, date)];
2908
+ }).join("");
2909
+ }
2374
2910
  // src/tools/native/number.ts
2375
2911
  const NUMBER_PARTS = /* @__PURE__ */ new Set([
2376
2912
  "integer",
@@ -3810,6 +4346,7 @@ function nativeModel(target, catalog, locales, entries) {
3810
4346
  const source = locales[0];
3811
4347
  const asts = new Map([...entries].map(([locale, list]) => [locale, new Map(list)]));
3812
4348
  const formats = new Numbered();
4349
+ const dates = new Numbered();
3813
4350
  const rules = new Numbered();
3814
4351
  const problems = [];
3815
4352
  const messages = [];
@@ -3829,7 +4366,11 @@ function nativeModel(target, catalog, locales, entries) {
3829
4366
  try {
3830
4367
  bodies.set(found, {
3831
4368
  locales: [locale],
3832
- parts: parts(asts.get(found).get(m.key), found, formats, rules, null)
4369
+ parts: parts(asts.get(found).get(m.key), found, {
4370
+ formats,
4371
+ dates,
4372
+ rules
4373
+ }, null)
3833
4374
  });
3834
4375
  } catch (e) {
3835
4376
  failed = true;
@@ -3848,7 +4389,7 @@ function nativeModel(target, catalog, locales, entries) {
3848
4389
  doc: docOf(m),
3849
4390
  args: [...m.args].map(([name, type]) => ({
3850
4391
  name,
3851
- type: type === "number" ? "number" : "string",
4392
+ type: type === "number" ? "number" : type === "Date | number" ? "date" : "string",
3852
4393
  doc: m.argDocs.has(name) ? docText(m.argDocs.get(name)) : void 0
3853
4394
  })),
3854
4395
  bodies: [...bodies.values()]
@@ -3860,6 +4401,7 @@ function nativeModel(target, catalog, locales, entries) {
3860
4401
  source,
3861
4402
  messages,
3862
4403
  formats: formats.list,
4404
+ dates: dates.list,
3863
4405
  rules: rules.list
3864
4406
  },
3865
4407
  problems
@@ -3878,6 +4420,22 @@ function docOf(m) {
3878
4420
  const LEAVE_OUT = "or leave the key out of this target's `keys`";
3879
4421
  /** Specs by locale and options, kept across runs: taking a format apart checks it against `Intl` dozens of times. */
3880
4422
  const SPECS = /* @__PURE__ */ new Map();
4423
+ /** Date specs as `SPECS` keeps number specs. */
4424
+ const DATE_SPECS = /* @__PURE__ */ new Map();
4425
+ function date(dates, locale, options) {
4426
+ const key = JSON.stringify([locale, options ?? {}]);
4427
+ let spec = DATE_SPECS.get(key);
4428
+ if (spec === void 0) {
4429
+ try {
4430
+ spec = dateSpec(locale, options);
4431
+ } catch (e) {
4432
+ spec = new Error(`native code can't format this date in ${locale}: ${e.message}. Format it another way, ${LEAVE_OUT}`, { cause: e });
4433
+ }
4434
+ DATE_SPECS.set(key, spec);
4435
+ }
4436
+ if (spec instanceof Error) throw spec;
4437
+ return dates.add(spec);
4438
+ }
3881
4439
  function format(formats, locale, options) {
3882
4440
  const key = JSON.stringify([locale, options ?? {}]);
3883
4441
  let spec = SPECS.get(key);
@@ -3892,7 +4450,8 @@ function format(formats, locale, options) {
3892
4450
  if (spec instanceof Error) throw spec;
3893
4451
  return formats.add(spec);
3894
4452
  }
3895
- function parts(elements, locale, formats, rules, pound) {
4453
+ function parts(elements, locale, declared, pound) {
4454
+ const { formats, dates, rules } = declared;
3896
4455
  const out = [];
3897
4456
  const text = (value) => {
3898
4457
  const last = out.at(-1);
@@ -3910,11 +4469,18 @@ function parts(elements, locale, formats, rules, pound) {
3910
4469
  };
3911
4470
  const arms = (options, own) => Object.entries(options).map(([key, option]) => ({
3912
4471
  key,
3913
- parts: parts(option.value, locale, formats, rules, own)
4472
+ parts: parts(option.value, locale, declared, own)
3914
4473
  }));
3915
4474
  for (const el of elements) {
3916
4475
  if (Object.keys(optionArgsOf(el) ?? {}).length > 0) throw new Error(`{${"value" in el ? el.value : ""}} takes a formatting option from an argument: native code formats with options the message fixes, ${LEAVE_OUT}`);
3917
- if (isDateElement(el) || isTimeElement(el)) 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
+ }
3918
4484
  if (isNumberElement(el)) {
3919
4485
  const fallback = placeholderOf(el, el.value).fallback;
3920
4486
  placed(el, el.value, {
@@ -4004,7 +4570,7 @@ function parts(elements, locale, formats, rules, pound) {
4004
4570
  // src/tools/native/printf.ts
4005
4571
  /**
4006
4572
  * Messages as printf-style format strings, for the targets whose platform fills values in itself:
4007
- * Android's resources and Apple's `.strings` files. Text and plain `{name}` placeholders, as `%N$s`
4573
+ * Android's resources and Apple's `.strings` and `.stringsdict` files. Text and plain `{name}` placeholders, as `%N$s`
4008
4574
  * with N the argument's place in the message's arguments, so a translation can put them in another
4009
4575
  * order; anything `Intl` would format fails with the fix.
4010
4576
  */
@@ -4019,7 +4585,20 @@ function isCount(el, count) {
4019
4585
  const options = Object.entries(numberOptions(el) ?? {});
4020
4586
  return options.length === 0 || options.length === 1 && options[0][0] === "maximumFractionDigits" && options[0][1] === 0;
4021
4587
  }
4022
- /** `elements` as a format string over `args`; inside a plural, its `count` is `%N$d`. */
4588
+ /**
4589
+ * Throws, with the fix, where a plural asks for more than a platform's plurals pick on: one count,
4590
+ * as an integer, by its cardinal category.
4591
+ */
4592
+ function checkPlural(plural, holder) {
4593
+ const named = `{${plural.value}}`;
4594
+ if (plural.pluralType === "ordinal") throw new Error(`${named} is an ordinal, and ${holder.plurals} are cardinal only: write a key per case and pick one in code, ${LEAVE_OUT}`);
4595
+ if (plural.offset !== 0) throw new Error(`${named} has an offset, which ${holder.plurals} don't: count without it, and say the rest in each arm, ${LEAVE_OUT}`);
4596
+ const selection = selectionOf(plural);
4597
+ if (selection !== void 0 && (selection.fn.name !== "integer" || Object.keys(selection.fn.options).length > 0)) throw new Error(`${named} selects with :${selection.fn.name} and its options, and ${holder.platform} selects on the count as an integer: select with a bare :number or :integer, ${LEAVE_OUT}`);
4598
+ const exact = Object.keys(plural.options).filter((k) => k.startsWith("="));
4599
+ if (exact.length > 0) throw new Error(`${named} has the arm ${exact.join(", ")}, and ${holder.plurals} have none for one number${holder.exact}: say it in a category's arm, or write a key for it and pick that in code, ${LEAVE_OUT}`);
4600
+ }
4601
+ /** `elements` as a format string over `args`; inside a plural, its `count` is `%N$d` (or `lld`). */
4023
4602
  function formatString(elements, args, printf, count) {
4024
4603
  const formatted = args.length > 0;
4025
4604
  const place = (el, name, conversion) => {
@@ -4032,7 +4611,7 @@ function formatString(elements, args, printf, count) {
4032
4611
  let out = "";
4033
4612
  for (const el of elements) {
4034
4613
  if (count !== void 0 && isCount(el, count)) {
4035
- out += place(el, count, "d");
4614
+ out += place(el, count, printf.count);
4036
4615
  continue;
4037
4616
  }
4038
4617
  if (isDateElement(el) || isTimeElement(el)) throw new Error(`{${el.value}} is a date, which ${printf.holder} don't format: pass it formatted, as a plain {${el.value}}, ${LEAVE_OUT}`);
@@ -4100,9 +4679,15 @@ const PRINTF$1 = {
4100
4679
  formatter: "String.format",
4101
4680
  holder: "Android resources",
4102
4681
  string: "s",
4682
+ count: "d",
4103
4683
  escape: escape$1,
4104
4684
  plural: (name) => `{${name}} is a second plural in the message, and Android's <plurals> picks on one count: split it into a key per count, ${LEAVE_OUT}`
4105
4685
  };
4686
+ const PLURALS$1 = {
4687
+ platform: "Android",
4688
+ plurals: "Android's <plurals>",
4689
+ exact: " (its zero is a category, which most languages never pick)"
4690
+ };
4106
4691
  /**
4107
4692
  * A message as `<string>`, or `<plurals>` when it holds a plural. Android's picks between whole
4108
4693
  * messages, so the text around the plural goes into each arm: the pseudo-locale's brackets, or a
@@ -4116,13 +4701,7 @@ function resource(name, ast, args) {
4116
4701
  return ` <string name="${name}"${args.length === 0 && written.includes("%") ? " formatted=\"false\"" : ""}>${value$3(written)}</string>`;
4117
4702
  }
4118
4703
  const [before, after] = [ast.slice(0, at), ast.slice(at + 1)];
4119
- const named = `{${plural.value}}`;
4120
- if (plural.pluralType === "ordinal") throw new Error(`${named} is an ordinal, and Android's <plurals> are cardinal only: write a key per case and pick one in code, ${LEAVE_OUT}`);
4121
- if (plural.offset !== 0) throw new Error(`${named} has an offset, which Android's <plurals> don't: count without it, and say the rest in each arm, ${LEAVE_OUT}`);
4122
- const selection = selectionOf(plural);
4123
- if (selection !== void 0 && (selection.fn.name !== "integer" || Object.keys(selection.fn.options).length > 0)) throw new Error(`${named} selects with :${selection.fn.name} and its options, and Android selects on the count as an integer: select with a bare :number or :integer, ${LEAVE_OUT}`);
4124
- const exact = Object.keys(plural.options).filter((k) => k.startsWith("="));
4125
- if (exact.length > 0) throw new Error(`${named} has the arm ${exact.join(", ")}, and Android's <plurals> have none for one number (its zero is a category, which most languages never pick): say it in a category's arm, or write a key for it and pick that in code, ${LEAVE_OUT}`);
4704
+ checkPlural(plural, PLURALS$1);
4126
4705
  return ` <plurals name="${name}">\n${Object.entries(plural.options).map(([category, option]) => ` <item quantity="${category}">${value$3(formatString([
4127
4706
  ...before,
4128
4707
  ...option.value,
@@ -4211,9 +4790,46 @@ const PRINTF = {
4211
4790
  formatter: "String(format:)",
4212
4791
  holder: ".strings files",
4213
4792
  string: "@",
4793
+ count: "lld",
4214
4794
  escape,
4215
- plural: (name) => `{${name}} is a plural, which a .strings file can't hold (it takes a .stringsdict, which this target doesn't write): show it from the Swift target, ${LEAVE_OUT}`
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: ""
4216
4808
  };
4809
+ /**
4810
+ * A message holding plurals as a `.stringsdict` entry: its format, with each plural a variable
4811
+ * (`%1$#@count@`) whose arms the system picks by the count's category, from its own CLDR rules.
4812
+ */
4813
+ function dictEntry(name, ast, args) {
4814
+ const variables = [];
4815
+ const taken = /* @__PURE__ */ new Map();
4816
+ let format = "";
4817
+ for (const el of ast) {
4818
+ if (el.type !== TYPE$1.plural) {
4819
+ format += formatString([el], args, DICT);
4820
+ continue;
4821
+ }
4822
+ checkPlural(el, PLURALS);
4823
+ const base = el.value.replace(/\W/g, "_");
4824
+ const seen = (taken.get(base) ?? 0) + 1;
4825
+ taken.set(base, seen);
4826
+ const variable = seen === 1 ? base : `${base}${seen}`;
4827
+ format += `%${args.indexOf(el.value) + 1}$#@${variable}@`;
4828
+ const arms = Object.entries(el.options).map(([category, option]) => ` <key>${category}</key>\n <string>${formatString(option.value, args, DICT, el.value)}</string>\n`);
4829
+ variables.push(` <key>${variable}</key>\n <dict>\n <key>NSStringFormatSpecTypeKey</key>\n <string>NSStringPluralRuleType</string>\n <key>NSStringFormatValueTypeKey</key>\n <string>${DICT.count}</string>\n${arms.join("")} </dict>\n`);
4830
+ }
4831
+ return ` <key>${xml(name)}</key>\n <dict>\n <key>NSStringLocalizedFormatKey</key>\n <string>${format}</string>\n${variables.join("")} </dict>\n`;
4832
+ }
4217
4833
  const ENTITIES = {
4218
4834
  amp: "&",
4219
4835
  lt: "<",
@@ -4271,32 +4887,47 @@ function appleStrings(target, root, catalog, locales, entries, banner) {
4271
4887
  if (text !== void 0 && text !== strings.get(name)) error(m, `${shown} says ${JSON.stringify(strings.get(name))} for ${name}: make it ${JSON.stringify(text)}, as the catalog says`);
4272
4888
  }
4273
4889
  const lines = new Map(locales.map((l) => [l, []]));
4890
+ const dicts = new Map(locales.map((l) => [l, []]));
4274
4891
  const failed = new Set(problems.map((p) => p.key));
4275
4892
  for (const { m, name } of taken) {
4276
4893
  if (failed.has(m.key)) continue;
4277
4894
  const args = [...m.args.keys()];
4278
4895
  const note = noteOf(m);
4279
4896
  const head = note === "" ? "" : `/* ${note.replaceAll("*/", "* /")} */\n`;
4897
+ const comment = note === "" ? "" : ` <!-- ${note.replace(/-{2,}/g, "-")} -->\n`;
4280
4898
  const written = /* @__PURE__ */ new Map();
4281
4899
  for (const locale of locales) {
4282
4900
  const found = foundIn(m.key, locale, locales, asts);
4283
4901
  if (found === void 0) continue;
4284
4902
  let entry = written.get(found);
4285
4903
  if (entry === void 0) {
4904
+ const ast = asts.get(found).get(m.key);
4905
+ const dict = ast.some((el) => el.type === TYPE$1.plural);
4286
4906
  try {
4287
- entry = `${head}"${escape(name, false)}" = "${formatString(asts.get(found).get(m.key), args, PRINTF)}";\n`;
4907
+ entry = dict ? {
4908
+ dict,
4909
+ entry: `${comment}${dictEntry(name, ast, args)}`
4910
+ } : {
4911
+ dict,
4912
+ entry: `${head}"${escape(name, false)}" = "${formatString(ast, args, PRINTF)}";\n`
4913
+ };
4288
4914
  } catch (e) {
4289
4915
  error(m, e.message, fileOf(m, found, source));
4290
4916
  break;
4291
4917
  }
4292
4918
  written.set(found, entry);
4293
4919
  }
4294
- lines.get(locale).push(entry);
4920
+ (entry.dict ? dicts : lines).get(locale).push(entry.entry);
4295
4921
  }
4296
4922
  }
4297
- const top = `/* ${banner.replace(/^\/\/ /, "")} */\n`;
4923
+ const said = banner.replace(/^\/\/ /, "");
4924
+ const files = new Map(locales.map((locale) => [join(target.out, `${locale}.lproj`, `${target.name}.strings`), [`/* ${said} */\n`, ...lines.get(locale)].join("\n")]));
4925
+ if ([...dicts.values()].some((d) => d.length > 0)) for (const locale of locales) {
4926
+ const body = dicts.get(locale).join("");
4927
+ files.set(join(target.out, `${locale}.lproj`, `${target.name}.stringsdict`), `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<!-- ${said.replace(/-{2,}/g, "-")} -->\n<plist version="1.0">\n<dict>\n${body}</dict>\n</plist>\n`);
4928
+ }
4298
4929
  return {
4299
- files: new Map(locales.map((locale) => [join(target.out, `${locale}.lproj`, `${target.name}.strings`), [top, ...lines.get(locale)].join("\n")])),
4930
+ files,
4300
4931
  problems
4301
4932
  };
4302
4933
  }
@@ -4318,6 +4949,7 @@ function value$2(part, indent, rt) {
4318
4949
  const inner = `${indent} `;
4319
4950
  if (part.type === "string") return argName$2(part.arg);
4320
4951
  if (part.type === "number") return `${rt}.number(${part.offset === 0 ? argName$2(part.arg) : `${argName$2(part.arg)} - ${float(part.offset)}`}, ${rt}.F${part.format}, ${lit$2(part.fallback)})`;
4952
+ if (part.type === "date") return `${rt}.d${part.format}(${argName$2(part.arg)})`;
4321
4953
  if (part.type === "select") {
4322
4954
  const arms = part.arms.map((a) => `${inner}${lit$2(a.key)} -> ${expr$2(a.parts, inner, rt)}\n`).join("");
4323
4955
  return `when (${argName$2(part.arg)}) {\n${arms}${inner}else -> ${expr$2(part.other, inner, rt)}\n${indent}}`;
@@ -4343,7 +4975,7 @@ function member$1(m, name, rt) {
4343
4975
  ] : m.doc, " ");
4344
4976
  const body = `when (this) {\n${branches} }`;
4345
4977
  if (m.args.length === 0) return `${head} val ${name}: String\n get() = ${body}\n`;
4346
- return `${head} fun ${name}(${m.args.map((a) => `${argName$2(a.name)}: ${a.type === "number" ? "Number" : "String"}`).join(", ")}): String {\n${m.args.filter((a) => a.type === "number").map((a) => ` val ${argName$2(a.name)} = ${argName$2(a.name)}.toDouble()\n`).join("")} return ${body}\n }\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`;
4347
4979
  }
4348
4980
  function formatConst$2(spec, i) {
4349
4981
  const affix = ([prefix, suffix]) => `${lit$2(prefix)} to ${lit$2(suffix)}`;
@@ -4366,6 +4998,33 @@ function formatConst$2(spec, i) {
4366
4998
  lit$2(spec.negativeInfinity)
4367
4999
  ].map((f) => ` ${f}`).join(",\n")},\n )\n`;
4368
5000
  }
5001
+ const FIELDS = {
5002
+ year: "t.year",
5003
+ month: "t.monthValue",
5004
+ day: "t.dayOfMonth",
5005
+ hour: "t.hour",
5006
+ minute: "t.minute",
5007
+ second: "t.second",
5008
+ weekday: "(t.dayOfWeek.value % 7)"
5009
+ };
5010
+ /** A date format as a function of the date in the system's time zone. */
5011
+ function dateFn$2(spec, i) {
5012
+ const digits = spec.digits === void 0 ? "null" : `G${i}`;
5013
+ const at = (field) => FIELDS[field];
5014
+ const lines = spec.pieces.map((piece) => {
5015
+ if (piece.type === "text") return `append(${lit$2(piece.value)})`;
5016
+ if (piece.type === "name") return `append(arrayOf(${piece.names.map(lit$2).join(", ")})[${pieceExpr(piece, at)}])`;
5017
+ return `append(pad(${pieceExpr(piece, at)}, ${piece.pad}, ${digits}))`;
5018
+ });
5019
+ return `${spec.digits === void 0 ? "" : ` private val G${i} = arrayOf(${spec.digits.map(lit$2).join(", ")})\n`} fun d${i}(t: java.time.ZonedDateTime): String = buildString {\n${lines.map((l) => ` ${l}\n`).join("")} }\n`;
5020
+ }
5021
+ const DATE$2 = `
5022
+ /** \`value\` written with at least \`width\` digits, in \`digits\` where the locale has its own. */
5023
+ private fun pad(value: Int, width: Int, digits: Array<String>?): String {
5024
+ val text = value.toString().padStart(width, '0')
5025
+ return if (digits == null) text else text.map { if (it in '0'..'9') digits[it - '0'] else it.toString() }.joinToString("")
5026
+ }
5027
+ `;
4369
5028
  const NUMBER$2 = `
4370
5029
  /** A number format, taken apart when the file was generated. */
4371
5030
  class Format(
@@ -4501,10 +5160,10 @@ function kotlinFile(model, target, banner) {
4501
5160
  within: (v, from, to) => `within(${v}, ${from}, ${to})`
4502
5161
  })}) return ${lit$2(category)}\n`).join("")} return "other"\n }\n`;
4503
5162
  }).join("\n");
4504
- const runtime = model.formats.length + model.rules.length === 0 ? "" : `
5163
+ const runtime = model.formats.length + model.rules.length + model.dates.length === 0 ? "" : `
4505
5164
  /** What formats the messages, generated with them. */
4506
- private object ${rt} {${NUMBER$2}${model.rules.length > 0 ? PLURAL$2 : ""}
4507
- ${model.formats.map(formatConst$2).join("\n")}${rules === "" ? "" : `\n${rules}`}}
5165
+ private object ${rt} {${model.formats.length + model.rules.length > 0 ? NUMBER$2 : ""}${model.rules.length > 0 ? PLURAL$2 : ""}${model.dates.length > 0 ? DATE$2 : ""}
5166
+ ${[...model.formats.map(formatConst$2), ...model.dates.map(dateFn$2)].join("\n")}${rules === "" ? "" : `\n${rules}`}}
4508
5167
  `;
4509
5168
  return `${banner}
4510
5169
  @file:Suppress("unused", "NAME_SHADOWING", "KotlinConstantConditions", "ConvertTwoComparisonsToRangeCheck")
@@ -4573,6 +5232,7 @@ function value$1(part, indent) {
4573
5232
  const inner = `${indent} `;
4574
5233
  if (part.type === "string") return argName$1(part.arg);
4575
5234
  if (part.type === "number") return `rt::number(${part.offset === 0 ? argName$1(part.arg) : `${argName$1(part.arg)} - ${float(part.offset)}`}, &rt::F${part.format}, ${lit$1(part.fallback)})`;
5235
+ if (part.type === "date") return `rt::D${part.format}(&${argName$1(part.arg)})`;
4576
5236
  if (part.type === "select") {
4577
5237
  const arms = part.arms.map((a) => `${inner}${lit$1(a.key)} => ${expr$1(a.parts, inner)},\n`).join("");
4578
5238
  return `match ${argName$1(part.arg)} {\n${arms}${inner}_ => ${expr$1(part.other, inner)},\n${indent}}`;
@@ -4592,7 +5252,7 @@ function method(m, name, enumName) {
4592
5252
  const head = doc$2(m.doc, " ");
4593
5253
  const arms = (render) => m.bodies.map((b) => ` ${b.locales.map((l) => `${enumName}::${variant(l)}`).join(" | ")} => ${render(b.parts)},\n`).join("");
4594
5254
  if (m.bodies.every((b) => b.parts.every((p) => p.type === "text")) && m.args.length === 0) return `${head} pub fn ${name}(self) -> &'static str {\n match self {\n${arms((parts) => lit$1(parts[0]?.type === "text" ? parts[0].value : ""))} }\n }\n`;
4595
- const params = m.args.map((a) => `, ${argName$1(a.name)}: ${a.type === "number" ? "impl Number" : "&str"}`).join("");
5255
+ const params = m.args.map((a) => `, ${argName$1(a.name)}: ${a.type === "number" ? "impl Number" : a.type === "date" ? "DateTime" : "&str"}`).join("");
4596
5256
  const argDocs = m.args.filter((a) => a.doc !== void 0).map((a) => `* \`${argName$1(a.name)}\`: ${a.doc}`);
4597
5257
  return `${argDocs.length > 0 ? `${head} ///\n${doc$2(argDocs, " ")}` : head} pub fn ${name}(self${params}) -> String {\n${m.args.filter((a) => a.type === "number").map((a) => ` let ${argName$1(a.name)} = ${argName$1(a.name)}.as_f64();\n`).join("")} match self {\n${arms((parts) => expr$1(parts, " "))} }\n }\n`;
4598
5258
  }
@@ -4617,6 +5277,76 @@ function formatConst$1(spec, i) {
4617
5277
  `negative_infinity: ${lit$1(spec.negativeInfinity)}`
4618
5278
  ].map((f) => ` ${f},\n`).join("")} };\n`;
4619
5279
  }
5280
+ /** A date format as a function of the date. */
5281
+ function dateFn$1(spec, i) {
5282
+ const digits = spec.digits === void 0 ? "None" : `Some(G${i})`;
5283
+ const at = (field) => field === "weekday" ? "t.weekday() as i64" : `t.${field} as i64`;
5284
+ const lines = spec.pieces.map((piece) => {
5285
+ if (piece.type === "text") return `s += ${lit$1(piece.value)};`;
5286
+ if (piece.type === "name") return `s += [${piece.names.map(lit$1).join(", ")}][(${pieceExpr(piece, at)}) as usize];`;
5287
+ return `s += &pad(${pieceExpr(piece, at)}, ${piece.pad}, ${digits});`;
5288
+ });
5289
+ return `${spec.digits === void 0 ? "" : ` const G${i}: [&str; 10] = [${spec.digits.map(lit$1).join(", ")}];\n`} pub fn D${i}(t: &super::DateTime) -> String {\n let mut s = String::new();\n${lines.map((l) => ` ${l}\n`).join("")} s\n }\n`;
5290
+ }
5291
+ const DATE$1 = `
5292
+ /// \`value\` written with at least \`width\` digits, in \`digits\` where the locale has its own.
5293
+ fn pad(value: i64, width: usize, digits: Option<[&'static str; 10]>) -> String {
5294
+ let text = format!("{:0width$}", value, width = width);
5295
+ match digits {
5296
+ Some(digits) => text.bytes().map(|b| if b.is_ascii_digit() { digits[(b - b'0') as usize] } else { "-" }).collect(),
5297
+ None => text,
5298
+ }
5299
+ }
5300
+ `;
5301
+ const DATE_TIME = `
5302
+ /// A date and time as a clock shows it, in the time zone it is shown in.
5303
+ #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
5304
+ pub struct DateTime {
5305
+ pub year: i32,
5306
+ /// 1 to 12.
5307
+ pub month: u8,
5308
+ pub day: u8,
5309
+ /// 0 to 23.
5310
+ pub hour: u8,
5311
+ pub minute: u8,
5312
+ pub second: u8,
5313
+ }
5314
+
5315
+ impl DateTime {
5316
+ /// The time \`seconds\` after the Unix epoch, on a clock \`offset\` seconds ahead of UTC: the
5317
+ /// local time's offset where it is shown, which Rust's standard library can't look up.
5318
+ pub fn from_unix(seconds: i64, offset: i32) -> DateTime {
5319
+ let local = seconds + offset as i64;
5320
+ let (days, time) = (local.div_euclid(86_400), local.rem_euclid(86_400));
5321
+ // Howard Hinnant's civil_from_days: the Gregorian date \`days\` after 1970-01-01.
5322
+ let z = days + 719_468;
5323
+ let (era, doe) = (z.div_euclid(146_097), z.rem_euclid(146_097));
5324
+ let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
5325
+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
5326
+ let mp = (5 * doy + 2) / 153;
5327
+ let month = if mp < 10 { mp + 3 } else { mp - 9 };
5328
+ DateTime {
5329
+ year: (yoe + era * 400 + if month <= 2 { 1 } else { 0 }) as i32,
5330
+ month: month as u8,
5331
+ day: (doy - (153 * mp + 2) / 5 + 1) as u8,
5332
+ hour: (time / 3600) as u8,
5333
+ minute: (time / 60 % 60) as u8,
5334
+ second: (time % 60) as u8,
5335
+ }
5336
+ }
5337
+
5338
+ /// The day of the week, 0 for Sunday.
5339
+ pub fn weekday(self) -> u8 {
5340
+ // days_from_civil, its inverse.
5341
+ let m = self.month as i64;
5342
+ let y = self.year as i64 - if m <= 2 { 1 } else { 0 };
5343
+ let (era, yoe) = (y.div_euclid(400), y.rem_euclid(400));
5344
+ let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + self.day as i64 - 1;
5345
+ let days = era * 146_097 + yoe * 365 + yoe / 4 - yoe / 100 + doy - 719_468;
5346
+ (days + 4).rem_euclid(7) as u8
5347
+ }
5348
+ }
5349
+ `;
4620
5350
  const NUMBER$1 = `
4621
5351
  /// A number format, taken apart when the file was generated.
4622
5352
  pub struct Format {
@@ -4807,6 +5537,8 @@ function rustModule(model, target, banner) {
4807
5537
  const tags = model.locales.map((l) => ` ${name}::${variant(l)} => ${lit$1(l)},\n`).join("");
4808
5538
  const methods = model.messages.map((m, i) => method(m, names[i][1], name)).join("\n");
4809
5539
  const usesNumbers = model.messages.some((m) => m.args.some((a) => a.type === "number"));
5540
+ const usesDates = model.dates.length > 0;
5541
+ const usesFormats = model.formats.length + model.rules.length > 0;
4810
5542
  const rules = model.rules.map(({ name: rule, rules }, i) => {
4811
5543
  const checks = rules.map(([category, condition]) => ` if ${conditionCode(condition, {
4812
5544
  field: (op) => `o.${op}`,
@@ -4815,14 +5547,14 @@ function rustModule(model, target, banner) {
4815
5547
  })} {\n return ${lit$1(category)};\n }\n`);
4816
5548
  return ` /// ${rule}\n pub fn R${i}(${rules.length > 0 ? "o" : "_o"}: &Operands) -> &'static str {\n${checks.join("")} "other"\n }\n`;
4817
5549
  }).join("\n");
4818
- const runtime = model.formats.length + model.rules.length === 0 ? "" : `
5550
+ const runtime = !usesFormats && !usesDates ? "" : `
4819
5551
  /// What formats the messages, generated with them.
4820
- mod rt {${NUMBER$1}${model.rules.length > 0 ? PLURAL$1 : ""}
4821
- ${model.formats.map(formatConst$1).join("\n")}${rules === "" ? "" : `\n${rules}`}}
5552
+ mod rt {${usesFormats ? NUMBER$1 : ""}${model.rules.length > 0 ? PLURAL$1 : ""}${usesDates ? DATE$1 : ""}
5553
+ ${[...model.formats.map(formatConst$1), ...model.dates.map(dateFn$1)].join("\n")}${rules === "" ? "" : `\n${rules}`}}
4822
5554
  `;
4823
5555
  return `${banner}
4824
5556
  #![allow(dead_code, non_snake_case, clippy::all)]
4825
- ${usesNumbers ? NUMBER_TRAIT : ""}
5557
+ ${usesNumbers ? NUMBER_TRAIT : ""}${usesDates ? DATE_TIME : ""}
4826
5558
  /// A locale the catalog is written or translated in, and its messages.
4827
5559
  #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
4828
5560
  pub enum ${name} {
@@ -4886,6 +5618,7 @@ function value(part, indent, rt) {
4886
5618
  const inner = `${indent} `;
4887
5619
  if (part.type === "string") return argName(part.arg);
4888
5620
  if (part.type === "number") return `${rt}.number(${part.offset === 0 ? argName(part.arg) : `${argName(part.arg)} - ${float(part.offset)}`}, ${rt}.f${part.format}, ${lit(part.fallback)})`;
5621
+ if (part.type === "date") return `${rt}.d${part.format}(${argName(part.arg)})`;
4889
5622
  if (part.type === "select") {
4890
5623
  const arms = part.arms.map((a) => `${inner}case ${lit(a.key)}: return ${expr(a.parts, inner, rt)}\n`).join("");
4891
5624
  return `{ () -> String in\n${inner}switch ${argName(part.arg)} {\n${arms}${inner}default: return ${expr(part.other, inner, rt)}\n${inner}}\n${indent}}()`;
@@ -4911,7 +5644,7 @@ function member(m, name, rt, number) {
4911
5644
  ] : m.doc, " ");
4912
5645
  const body = ` switch self {\n${cases((parts) => expr(parts, " ", rt))} }\n`;
4913
5646
  if (m.args.length === 0) return `${head} public var ${name}: String {\n${body} }\n`;
4914
- return `${head} public func ${name}(${m.args.map((a) => `${argName(a.name)}: ${a.type === "number" ? `some ${number.type}` : "String"}`).join(", ")}) -> String {\n${m.args.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`;
4915
5648
  }
4916
5649
  function formatConst(spec, i) {
4917
5650
  const affix = ([prefix, suffix]) => `(${lit(prefix)}, ${lit(suffix)})`;
@@ -4934,6 +5667,47 @@ function formatConst(spec, i) {
4934
5667
  `negativeInfinity: ${lit(spec.negativeInfinity)}`
4935
5668
  ].map((f) => ` ${f}`).join(",\n")}\n )\n`;
4936
5669
  }
5670
+ /** A date format as a function of the clock's fields. */
5671
+ function dateFn(spec, i) {
5672
+ const digits = spec.digits === void 0 ? "nil" : `g${i}`;
5673
+ const at = (field) => `t.${field}`;
5674
+ const lines = spec.pieces.map((piece) => {
5675
+ if (piece.type === "text") return `s += ${lit(piece.value)}`;
5676
+ if (piece.type === "name") return `s += [${piece.names.map(lit).join(", ")}][${pieceExpr(piece, at)}]`;
5677
+ return `s += pad(${pieceExpr(piece, at)}, ${piece.pad}, ${digits})`;
5678
+ });
5679
+ return `${spec.digits === void 0 ? "" : ` static let g${i} = [${spec.digits.map(lit).join(", ")}]\n`} static func d${i}(_ t: Clock) -> String {\n var s = ""\n${lines.map((l) => ` ${l}\n`).join("")} return s\n }\n`;
5680
+ }
5681
+ const DATE = `
5682
+ /// A date as the clock shows it in the device's time zone; \`weekday\` is 0 for Sunday.
5683
+ struct Clock {
5684
+ let year: Int
5685
+ let month: Int
5686
+ let day: Int
5687
+ let hour: Int
5688
+ let minute: Int
5689
+ let second: Int
5690
+ let weekday: Int
5691
+ }
5692
+
5693
+ static func clock(_ date: Date) -> Clock {
5694
+ var calendar = Calendar(identifier: .gregorian)
5695
+ calendar.timeZone = .current
5696
+ let c = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second, .weekday], from: date)
5697
+ return Clock(
5698
+ year: c.year ?? 0, month: c.month ?? 1, day: c.day ?? 1, hour: c.hour ?? 0, minute: c.minute ?? 0,
5699
+ second: c.second ?? 0, weekday: (c.weekday ?? 1) - 1
5700
+ )
5701
+ }
5702
+
5703
+ /// \`value\` written with at least \`width\` digits, in \`digits\` where the locale has its own.
5704
+ private static func pad(_ value: Int, _ width: Int, _ digits: [String]?) -> String {
5705
+ var text = String(value)
5706
+ while text.count < width { text = "0" + text }
5707
+ guard let digits else { return text }
5708
+ return text.map { c in c.wholeNumberValue.map { digits[$0] } ?? String(c) }.joined()
5709
+ }
5710
+ `;
4937
5711
  const NUMBER = `
4938
5712
  /// A number format, taken apart when the file was generated.
4939
5713
  struct Format {
@@ -5105,7 +5879,10 @@ function swiftFile(model, target, banner) {
5105
5879
  value: `${name.charAt(0).toLowerCase()}${name.slice(1)}Value`
5106
5880
  };
5107
5881
  const members = model.messages.map((m, i) => member(m, names[i][1], rt, number)).join("\n");
5108
- const 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 ? "" : `
5109
5886
  /// A number a message formats: any of Swift's integers or floats.
5110
5887
  public protocol ${number.type} {
5111
5888
  var ${number.value}: Double { get }
@@ -5120,10 +5897,10 @@ ${NUMBER_TYPES.map((t) => `extension ${t}: ${number.type} { public var ${number.
5120
5897
  within: (v, from, to) => `within(${v}, ${from}, ${to})`
5121
5898
  })} { return ${lit(category)} }\n`).join("")} return "other"\n }\n`;
5122
5899
  }).join("\n");
5123
- const runtime = model.formats.length + model.rules.length === 0 ? "" : `
5900
+ const runtime = !usesFormats && !usesDates ? "" : `
5124
5901
  /// What formats the messages, generated with them.
5125
- private enum ${rt} {${NUMBER}${model.rules.length > 0 ? PLURAL : ""}
5126
- ${model.formats.map(formatConst).join("\n")}${rules === "" ? "" : `\n${rules}`}}
5902
+ private enum ${rt} {${usesFormats ? NUMBER : ""}${model.rules.length > 0 ? PLURAL : ""}${usesDates ? DATE : ""}
5903
+ ${[...model.formats.map(formatConst), ...model.dates.map(dateFn)].join("\n")}${rules === "" ? "" : `\n${rules}`}}
5127
5904
  `;
5128
5905
  return `${banner}
5129
5906
  import Foundation
@@ -5455,6 +6232,11 @@ function render(config, accept) {
5455
6232
  }
5456
6233
  for (const [path, content] of nativeFiles(config, catalog, locales, entries, head)) files.set(path, content);
5457
6234
  files.set(join(config.catalog, "catalog.schema.json"), `${JSON.stringify(catalogSchema(config), null, 2)}\n`);
6235
+ for (const file of yamlFiles(config.catalog)) {
6236
+ const path = join(config.catalog, file);
6237
+ const text = readFileSync(path, "utf8");
6238
+ if (withSchemaLine(text) !== text) files.set(path, withSchemaLine(text));
6239
+ }
5458
6240
  if (Object.keys(lock.translations).length > 0 || existsSync(config.lock)) files.set(config.lock, serializeLock(lock));
5459
6241
  return {
5460
6242
  catalog,
@@ -5483,6 +6265,6 @@ function generate(config, options = {}) {
5483
6265
  changed: writeFiles(generated.files, options.check)
5484
6266
  };
5485
6267
  }
5486
- export { 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 };
5487
6269
 
5488
- //# sourceMappingURL=generate-BW3N8EGb.js.map
6270
+ //# sourceMappingURL=generate-CKv86Iki.js.map