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
@@ -0,0 +1,275 @@
1
+ /**
2
+ * `prosebook export`: a locale's entries as XLIFF 2.0 or JSON, for translation tools, and the reader
3
+ * `prosebook import` takes every format back through, todo's YAML and a model's reply included.
4
+ *
5
+ * Messages travel as their ICU or MF2 text, not as XLIFF's inline codes: a plural or a select has no
6
+ * inline form, and the tools that take these files read ICU. A unit carries its source as exported, so
7
+ * a translation of a source that has changed since is refused rather than written as current.
8
+ */
9
+ import { isMap, isScalar, parseDocument, type YAMLMap } from 'yaml';
10
+
11
+ import type { Catalog, Message } from './catalog.ts';
12
+ import type { ResolvedConfig } from './config.ts';
13
+ import { changeOf, type StaleTranslation } from './lock.ts';
14
+ import { todoMessages } from './todo.ts';
15
+
16
+ export type ExchangeFormat = 'xliff' | 'json';
17
+
18
+ export interface Exported {
19
+ /** How many entries the file holds. */
20
+ count: number;
21
+ text: string;
22
+ }
23
+
24
+ /**
25
+ * The entries `todo` would list for `locale`, or every one with `all`, as a file a translation tool
26
+ * reads. The source locale exports every entry, untranslated, for a tool to take as its source.
27
+ */
28
+ export function exportOf(
29
+ config: ResolvedConfig,
30
+ catalog: Catalog,
31
+ stale: ReadonlyMap<string, StaleTranslation>,
32
+ locale: string,
33
+ format: ExchangeFormat,
34
+ all = false,
35
+ ): Exported {
36
+ const isSource = locale === config.source;
37
+ const messages = all || isSource ? catalog.messages : todoMessages(catalog, stale, locale);
38
+ if (messages.length === 0) return { count: 0, text: '' };
39
+ const textOf = (m: Message) => (isSource ? m.text : m.translations.get(locale)?.text);
40
+ if (format === 'json') {
41
+ const json = JSON.stringify(Object.fromEntries(messages.map((m) => [m.key, textOf(m) ?? ''])), null, 2);
42
+ return { count: messages.length, text: `${json}\n` };
43
+ }
44
+
45
+ const byArea = new Map<string, Message[]>();
46
+ for (const m of messages) byArea.set(m.area, [...(byArea.get(m.area) ?? []), m]);
47
+ const lines = [
48
+ '<?xml version="1.0" encoding="UTF-8"?>',
49
+ `<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="${escape(config.source)}"${isSource ? '' : ` trgLang="${escape(locale)}"`}>`,
50
+ ];
51
+ for (const [area, list] of byArea) {
52
+ // Newlines and runs of spaces in a message are its own, which a tool may not normalize away.
53
+ lines.push(` <file id="${escape(area)}" original="${escape(list[0]!.file)}" xml:space="preserve">`);
54
+ for (const m of list) {
55
+ lines.push(` <unit id="${m.key}">`);
56
+ const notes = [
57
+ ['context', m.context],
58
+ ['kind', m.kind],
59
+ ['surface', m.surface],
60
+ ...[...m.argDocs].map(([arg, said]) => ['placeholder', `{${arg}}: ${said}`]),
61
+ ['stale', isSource || !stale.has(m.key) ? undefined : `the source changed: ${changeOf(stale.get(m.key)!)}`],
62
+ ].filter((n): n is [string, string] => n[1] !== undefined);
63
+ if (notes.length > 0) {
64
+ lines.push(' <notes>');
65
+ for (const [category, text] of notes) lines.push(` <note category="${category}">${escape(text)}</note>`);
66
+ lines.push(' </notes>');
67
+ }
68
+ const target = textOf(m);
69
+ // A stale translation goes out as `initial`: given back as `translated`, someone looked at it.
70
+ const state = isSource ? '' : ` state="${target === undefined || stale.has(m.key) ? 'initial' : 'translated'}"`;
71
+ lines.push(` <segment${state}>`, ` <source>${escape(m.text)}</source>`);
72
+ if (!isSource && target !== undefined) lines.push(` <target>${escape(target)}</target>`);
73
+ lines.push(' </segment>', ' </unit>');
74
+ }
75
+ lines.push(' </file>');
76
+ }
77
+ lines.push('</xliff>');
78
+ return { count: messages.length, text: `${lines.join('\n')}\n` };
79
+ }
80
+
81
+ /** A translation as a file gives it back, before it is checked against the catalog. */
82
+ export interface Returned {
83
+ key: string;
84
+ /** Null or empty is left for later. Anything but text or a number is refused. */
85
+ value: unknown;
86
+ /** Someone looked at it, so one given back unchanged still holds. A YAML or JSON reply always has. */
87
+ checked: boolean;
88
+ /** The source it was made from, where the file says. */
89
+ source?: string;
90
+ /** Why the file's entry can't be read, which leaves it out. */
91
+ problem?: string;
92
+ }
93
+
94
+ /**
95
+ * The translations in a file `import` was given: XLIFF 2.0 when it starts as XML, otherwise YAML,
96
+ * which reads JSON too. Text around fenced blocks is left aside, as a model's reply has it. A nested
97
+ * mapping, as some tools write JSON, joins its keys with dots.
98
+ */
99
+ export function readReturned(text: string, locale: string, source: string): { returned: Returned[] } | { problem: string } {
100
+ if (text.trimStart().startsWith('<')) return readXliff(text, locale, source);
101
+ const fenced = [...text.matchAll(/^```[^\n]*\n([\s\S]*?)^```/gm)].map((m) => m[1]);
102
+ const doc = parseDocument(fenced.length > 0 ? fenced.join('\n') : text);
103
+ if (doc.errors.length > 0 || !(isMap(doc.contents) || doc.contents === null)) {
104
+ const why = doc.errors[0]?.message.split('\n', 1)[0] ?? 'it is not a mapping';
105
+ return { problem: `the reply isn't YAML or JSON of \`key: translation\` (${why}): fix it, or ask for one YAML block` };
106
+ }
107
+ const returned: Returned[] = [];
108
+ const flatten = (map: YAMLMap, prefix: string) => {
109
+ for (const pair of map.items) {
110
+ const key = prefix + (isScalar(pair.key) ? String(pair.key.value) : String(pair.key));
111
+ if (isMap(pair.value)) flatten(pair.value, `${key}.`);
112
+ else returned.push({ key, value: isScalar(pair.value) ? pair.value.value : pair.value, checked: true });
113
+ }
114
+ };
115
+ if (isMap(doc.contents)) flatten(doc.contents, '');
116
+ return { returned };
117
+ }
118
+
119
+ function readXliff(text: string, locale: string, source: string): { returned: Returned[] } | { problem: string } {
120
+ let root: XmlElement;
121
+ try {
122
+ root = parseXml(text);
123
+ } catch (e) {
124
+ return { problem: `the file isn't XML (${messageOf(e)}): give it back as the tool exported it` };
125
+ }
126
+ if (root.name !== 'xliff') return { problem: `the file is <${root.name}>, not XLIFF: export it from the tool as XLIFF 2.0` };
127
+ if (!root.attrs.version?.startsWith('2.'))
128
+ return { problem: `the file is XLIFF ${root.attrs.version ?? 'of no version'}: export it from the tool as XLIFF 2.0` };
129
+ const { srcLang, trgLang } = root.attrs;
130
+ if (srcLang !== undefined && srcLang.toLowerCase() !== source.toLowerCase())
131
+ return { problem: `the file translates from ${srcLang}, and the source is ${source}: export it again from prosebook` };
132
+ if (trgLang === undefined)
133
+ return { problem: `the file names no trgLang: give it the ${locale} translations, or set trgLang="${locale}"` };
134
+ if (trgLang.toLowerCase() !== locale.toLowerCase())
135
+ return { problem: `the file translates into ${trgLang}, not ${locale}: import it as ${trgLang}` };
136
+
137
+ const returned: Returned[] = [];
138
+ const units = (el: XmlElement): XmlElement[] =>
139
+ elements(el).flatMap((child) =>
140
+ child.name === 'unit' ? [child] : child.name === 'file' || child.name === 'group' ? units(child) : [],
141
+ );
142
+ for (const unit of units(root)) {
143
+ const key = unit.attrs.id ?? '';
144
+ // A tool may split a unit into sentences: its text is every part's in order, what lies between them included.
145
+ const parts = elements(unit).filter((el) => el.name === 'segment' || el.name === 'ignorable');
146
+ const segments = parts.filter((el) => el.name === 'segment');
147
+ const sourceOf = (el: XmlElement) => elements(el).find((c) => c.name === 'source');
148
+ const targetOf = (el: XmlElement) => elements(el).find((c) => c.name === 'target');
149
+ try {
150
+ const unitSource = parts.map((p) => textOf(sourceOf(p))).join('');
151
+ const translated = segments.filter((s) => targetOf(s) !== undefined).length;
152
+ if (translated === 0) {
153
+ returned.push({ key, value: null, checked: false, source: unitSource });
154
+ continue;
155
+ }
156
+ if (translated < segments.length)
157
+ throw new Error(`${translated} of its ${segments.length} segments are translated: translate the rest`);
158
+ const value = parts.map((p) => textOf(targetOf(p) ?? sourceOf(p))).join('');
159
+ // XLIFF's default state is `initial`: nobody has looked at it yet.
160
+ const checked = segments.every((s) => (s.attrs.state ?? 'initial') !== 'initial');
161
+ returned.push({ key, value, checked, source: unitSource });
162
+ } catch (e) {
163
+ returned.push({ key, value: null, checked: false, problem: messageOf(e) });
164
+ }
165
+ }
166
+ return { returned };
167
+ }
168
+
169
+ /** The text of a <source> or <target>, refusing XLIFF's inline codes, which prosebook never writes. */
170
+ function textOf(el: XmlElement | undefined): string {
171
+ if (el === undefined) return '';
172
+ return el.children
173
+ .map((child) => {
174
+ if (typeof child === 'string') return child;
175
+ // <cp> holds a character XML can't.
176
+ if (child.name === 'cp' && child.attrs.hex !== undefined) return String.fromCodePoint(Number.parseInt(child.attrs.hex, 16));
177
+ throw new Error(`the ${el.name} holds an inline <${child.name}>: have the tool keep placeholders as the text they were exported as`);
178
+ })
179
+ .join('');
180
+ }
181
+
182
+ const messageOf = (e: unknown) => (e instanceof Error ? e.message : String(e));
183
+
184
+ const escape = (s: string) => s.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
185
+
186
+ interface XmlElement {
187
+ /** Without its namespace prefix. */
188
+ name: string;
189
+ attrs: Record<string, string>;
190
+ children: (XmlElement | string)[];
191
+ }
192
+
193
+ const elements = (el: XmlElement) => el.children.filter((c): c is XmlElement => typeof c !== 'string');
194
+
195
+ const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
196
+
197
+ function decode(s: string, at: number): string {
198
+ return s.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (_, ref: string) => {
199
+ if (ref.startsWith('#x') || ref.startsWith('#X')) return String.fromCodePoint(Number.parseInt(ref.slice(2), 16));
200
+ if (ref.startsWith('#')) return String.fromCodePoint(Number.parseInt(ref.slice(1), 10));
201
+ const char = ENTITIES[ref];
202
+ if (char === undefined) throw new Error(`&${ref}; near offset ${at} is no XML entity`);
203
+ return char;
204
+ });
205
+ }
206
+
207
+ /**
208
+ * Just enough XML for XLIFF: elements, attributes, text, CDATA and character references. Comments,
209
+ * processing instructions and a doctype are skipped; no DTD is read, so no entity of one is known.
210
+ */
211
+ function parseXml(text: string): XmlElement {
212
+ const document: XmlElement = { name: '', attrs: {}, children: [] };
213
+ const open: XmlElement[] = [document];
214
+ let i = 0;
215
+ const skip = (from: string, to: string) => {
216
+ const end = text.indexOf(to, i + from.length);
217
+ if (end === -1) throw new Error(`${from} at offset ${i} never ends`);
218
+ const body = text.slice(i + from.length, end);
219
+ i = end + to.length;
220
+ return body;
221
+ };
222
+ while (i < text.length) {
223
+ const lt = text.indexOf('<', i);
224
+ const parent = open.at(-1)!;
225
+ if (lt !== i) {
226
+ const end = lt === -1 ? text.length : lt;
227
+ if (open.length > 1) parent.children.push(decode(text.slice(i, end), i));
228
+ else if (text.slice(i, end).trim() !== '') throw new Error(`text at offset ${i} is outside the root element`);
229
+ i = end;
230
+ continue;
231
+ }
232
+ if (text.startsWith('<!--', i)) {
233
+ skip('<!--', '-->');
234
+ continue;
235
+ }
236
+ if (text.startsWith('<?', i)) {
237
+ skip('<?', '?>');
238
+ continue;
239
+ }
240
+ if (text.startsWith('<![CDATA[', i)) {
241
+ parent.children.push(skip('<![CDATA[', ']]>'));
242
+ continue;
243
+ }
244
+ if (text.startsWith('<!', i)) {
245
+ skip('<!', '>');
246
+ continue;
247
+ }
248
+ if (text.startsWith('</', i)) {
249
+ const at = i;
250
+ const name = localName(skip('</', '>').trim());
251
+ if (open.length === 1 || parent.name !== name) throw new Error(`</${name}> at offset ${at} closes nothing open`);
252
+ open.pop();
253
+ continue;
254
+ }
255
+ TAG.lastIndex = i;
256
+ const tag = TAG.exec(text);
257
+ if (tag === null) throw new Error(`the tag at offset ${i} is malformed`);
258
+ const attrs: Record<string, string> = {};
259
+ for (const [, name, double, single] of tag[2]!.matchAll(/([^\s=]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g))
260
+ attrs[localName(name!)] = decode(double ?? single ?? '', i);
261
+ const el: XmlElement = { name: localName(tag[1]!), attrs, children: [] };
262
+ if (open.length === 1 && document.children.length > 0) throw new Error(`a second root element at offset ${i}`);
263
+ parent.children.push(el);
264
+ if (tag[3] !== '/') open.push(el);
265
+ i += tag[0].length;
266
+ }
267
+ if (open.length > 1) throw new Error(`<${open.at(-1)!.name}> is never closed`);
268
+ const root = elements(document)[0];
269
+ if (root === undefined) throw new Error('there is no element');
270
+ return root;
271
+ }
272
+
273
+ const TAG = /<([^\s/>]+)((?:\s+[^\s=/>]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>/y;
274
+
275
+ const localName = (name: string) => name.slice(name.indexOf(':') + 1);
@@ -13,7 +13,7 @@ import { basename, dirname, extname, join, relative } from 'node:path';
13
13
  import { parseSync } from 'oxc-parser';
14
14
  import { stringify } from 'yaml';
15
15
 
16
- import { CatalogError, KEY_PATTERN, loadCatalog } from './catalog.ts';
16
+ import { CatalogError, KEY_PATTERN, loadCatalog, SCHEMA_LINE } from './catalog.ts';
17
17
  import type { ResolvedConfig } from './config.ts';
18
18
  import { parseMessage } from './icu.ts';
19
19
  import { singularOf } from './lint.ts';
@@ -686,7 +686,7 @@ export function applyExtract(config: ResolvedConfig, plan: ExtractPlan): string[
686
686
  return `# TODO(prosebook extract): check the key, kind and context.\n${stringify({ [e.key]: body }, { lineWidth: 0, version: '1.1' })}`;
687
687
  });
688
688
  mkdirSync(config.catalog, { recursive: true });
689
- const current = existsSync(path) ? readFileSync(path, 'utf8') : '# yaml-language-server: $schema=./catalog.schema.json\n';
689
+ const current = existsSync(path) ? readFileSync(path, 'utf8') : `${SCHEMA_LINE}\n`;
690
690
  writeFileSync(path, `${current.replace(/\n*$/, '\n')}\n${blocks.join('\n')}`);
691
691
  written.push(path);
692
692
  }
@@ -8,7 +8,7 @@
8
8
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
9
  import { dirname, join, relative } from 'node:path';
10
10
 
11
- import { loadCatalog, type Catalog, type Message } from './catalog.ts';
11
+ import { loadCatalog, withSchemaLine, yamlFiles, type Catalog, type Message } from './catalog.ts';
12
12
  import { compileLocale, localeModule, type Entry } from './compile.ts';
13
13
  import type { ResolvedConfig } from './config.ts';
14
14
  import { readLock, reconcile, serializeLock, type Lock, type StaleTranslation } from './lock.ts';
@@ -232,6 +232,12 @@ export function render(config: ResolvedConfig, accept?: Map<string, Set<string>
232
232
 
233
233
  for (const [path, content] of nativeFiles(config, catalog, locales, entries, head)) files.set(path, content);
234
234
  files.set(join(config.catalog, 'catalog.schema.json'), `${JSON.stringify(catalogSchema(config), null, 2)}\n`);
235
+ // Without its schema line a catalog file has no completion, so generate adds it and --check fails there.
236
+ for (const file of yamlFiles(config.catalog)) {
237
+ const path = join(config.catalog, file);
238
+ const text = readFileSync(path, 'utf8');
239
+ if (withSchemaLine(text) !== text) files.set(path, withSchemaLine(text));
240
+ }
235
241
  if (Object.keys(lock.translations).length > 0 || existsSync(config.lock)) files.set(config.lock, serializeLock(lock));
236
242
  return { catalog, files, lock, stale, entries };
237
243
  }
@@ -76,7 +76,8 @@ export { toMf1 } from './mf1.ts';
76
76
  export { applyMigrate, planMigrate, type MigratePlan } from './migrate.ts';
77
77
  export { readMessage, SYNTAX_NAME, writeMessage, type MessageFormatVersion } from './syntax.ts';
78
78
  export { syntaxKind, typescriptOracle, type Span, type TypeOracle, type ValueKind } from './types.ts';
79
- export { todoOf, type Todo } from './todo.ts';
79
+ export { todoMessages, todoOf, type Todo } from './todo.ts';
80
+ export { exportOf, readReturned, type ExchangeFormat, type Exported, type Returned } from './exchange.ts';
80
81
  export { SarifReport, type SarifResult, type SarifTool } from './sarif.ts';
81
82
  export {
82
83
  hardcodedText,
@@ -13,10 +13,10 @@ import { join } from 'node:path';
13
13
  import type { Catalog, Message, Problem } from '#src/tools/catalog.ts';
14
14
  import type { Entry } from '#src/tools/compile.ts';
15
15
  import type { NativeTarget } from '#src/tools/config.ts';
16
- import { selectionOf, TYPE, type MessageFormatElement } from '#src/tools/icu.ts';
16
+ import { TYPE, type MessageFormatElement } from '#src/tools/icu.ts';
17
17
  import { checkNames, leading, snake } from './emit.ts';
18
18
  import { fileOf, foundIn, LEAVE_OUT, wordsFor } from './model.ts';
19
- import { formatString, noteOf, type Printf } from './printf.ts';
19
+ import { checkPlural, formatString, noteOf, type PluralHolder, type Printf } from './printf.ts';
20
20
 
21
21
  const KEYWORDS = new Set(
22
22
  'abstract assert boolean break byte case catch char class const continue default do double else enum extends false final finally float for goto if implements import instanceof int interface long native new null package private protected public return short static strictfp super switch synchronized this throw throws transient true try void volatile while'.split(
@@ -79,11 +79,18 @@ const PRINTF: Printf = {
79
79
  formatter: 'String.format',
80
80
  holder: 'Android resources',
81
81
  string: 's',
82
+ count: 'd',
82
83
  escape,
83
84
  plural: (name) =>
84
85
  `{${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}`,
85
86
  };
86
87
 
88
+ const PLURALS: PluralHolder = {
89
+ platform: 'Android',
90
+ plurals: "Android's <plurals>",
91
+ exact: ' (its zero is a category, which most languages never pick)',
92
+ };
93
+
87
94
  /**
88
95
  * A message as `<string>`, or `<plurals>` when it holds a plural. Android's picks between whole
89
96
  * messages, so the text around the plural goes into each arm: the pseudo-locale's brackets, or a
@@ -99,29 +106,7 @@ function resource(name: string, ast: MessageFormatElement[], args: string[]): st
99
106
  return ` <string name="${name}"${raw}>${value(written)}</string>`;
100
107
  }
101
108
  const [before, after] = [ast.slice(0, at), ast.slice(at + 1)];
102
- const named = `{${plural.value}}`;
103
- if (plural.pluralType === 'ordinal') {
104
- throw new Error(
105
- `${named} is an ordinal, and Android's <plurals> are cardinal only: write a key per case and pick one in code, ${LEAVE_OUT}`,
106
- );
107
- }
108
- if (plural.offset !== 0) {
109
- throw new Error(
110
- `${named} has an offset, which Android's <plurals> don't: count without it, and say the rest in each arm, ${LEAVE_OUT}`,
111
- );
112
- }
113
- const selection = selectionOf(plural);
114
- if (selection !== undefined && (selection.fn.name !== 'integer' || Object.keys(selection.fn.options).length > 0)) {
115
- throw new Error(
116
- `${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}`,
117
- );
118
- }
119
- const exact = Object.keys(plural.options).filter((k) => k.startsWith('='));
120
- if (exact.length > 0) {
121
- throw new Error(
122
- `${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}`,
123
- );
124
- }
109
+ checkPlural(plural, PLURALS);
125
110
  const items = Object.entries(plural.options).map(
126
111
  ([category, option]) =>
127
112
  ` <item quantity="${category}">${value(formatString([...before, ...option.value, ...after], args, PRINTF, plural.value))}</item>`,
@@ -13,10 +13,10 @@ import { join, relative } from 'node:path';
13
13
  import type { Catalog, Message, Problem } from '#src/tools/catalog.ts';
14
14
  import type { Entry } from '#src/tools/compile.ts';
15
15
  import type { NativeTarget } from '#src/tools/config.ts';
16
- import { staticLiteral } from '#src/tools/icu.ts';
16
+ import { staticLiteral, TYPE, type MessageFormatElement } from '#src/tools/icu.ts';
17
17
  import { checkNames } from './emit.ts';
18
18
  import { fileOf, foundIn, LEAVE_OUT, restOf, wordsFor } from './model.ts';
19
- import { formatString, noteOf, type Printf } from './printf.ts';
19
+ import { checkPlural, formatString, noteOf, type PluralHolder, type Printf } from './printf.ts';
20
20
 
21
21
  /** Text inside a `.strings` file's quotes; `%` doubled where the string has arguments. */
22
22
  function escape(text: string, formatted: boolean): string {
@@ -29,11 +29,54 @@ const PRINTF: Printf = {
29
29
  formatter: 'String(format:)',
30
30
  holder: '.strings files',
31
31
  string: '@',
32
+ count: 'lld',
32
33
  escape,
34
+ // A plural at the top goes to the .stringsdict; one under a select never gets here, as the select fails first.
35
+ plural: (name) => `{${name}} is a plural inside another, which a .strings file can't hold: say it without, ${LEAVE_OUT}`,
36
+ };
37
+
38
+ const xml = (text: string) => text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
39
+
40
+ const DICT: Printf = {
41
+ ...PRINTF,
42
+ holder: '.stringsdict files',
43
+ escape: (text, formatted) => (formatted ? xml(text).replaceAll('%', '%%') : xml(text)),
33
44
  plural: (name) =>
34
- `{${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}`,
45
+ `{${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}`,
35
46
  };
36
47
 
48
+ const PLURALS: PluralHolder = { platform: 'Apple', plurals: "a .stringsdict's plurals", exact: '' };
49
+
50
+ /**
51
+ * A message holding plurals as a `.stringsdict` entry: its format, with each plural a variable
52
+ * (`%1$#@count@`) whose arms the system picks by the count's category, from its own CLDR rules.
53
+ */
54
+ function dictEntry(name: string, ast: MessageFormatElement[], args: string[]): string {
55
+ const variables: string[] = [];
56
+ const taken = new Map<string, number>();
57
+ let format = '';
58
+ for (const el of ast) {
59
+ if (el.type !== TYPE.plural) {
60
+ format += formatString([el], args, DICT);
61
+ continue;
62
+ }
63
+ checkPlural(el, PLURALS);
64
+ const base = el.value.replace(/\W/g, '_');
65
+ const seen = (taken.get(base) ?? 0) + 1;
66
+ taken.set(base, seen);
67
+ const variable = seen === 1 ? base : `${base}${seen}`;
68
+ format += `%${args.indexOf(el.value) + 1}$#@${variable}@`;
69
+ const arms = Object.entries(el.options).map(
70
+ ([category, option]) =>
71
+ ` <key>${category}</key>\n <string>${formatString(option.value, args, DICT, el.value)}</string>\n`,
72
+ );
73
+ variables.push(
74
+ ` <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`,
75
+ );
76
+ }
77
+ return ` <key>${xml(name)}</key>\n <dict>\n <key>NSStringLocalizedFormatKey</key>\n <string>${format}</string>\n${variables.join('')} </dict>\n`;
78
+ }
79
+
37
80
  const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
38
81
 
39
82
  /** An XML plist's string values by key, read as its text is: every `<key>` followed by a `<string>`. */
@@ -107,33 +150,52 @@ export function appleStrings(
107
150
  }
108
151
 
109
152
  const lines = new Map<string, string[]>(locales.map((l) => [l, []]));
153
+ const dicts = new Map<string, string[]>(locales.map((l) => [l, []]));
110
154
  const failed = new Set(problems.map((p) => p.key));
111
155
  for (const { m, name } of taken) {
112
156
  if (failed.has(m.key)) continue;
113
157
  const args = [...m.args.keys()];
114
158
  const note = noteOf(m);
115
159
  const head = note === '' ? '' : `/* ${note.replaceAll('*/', '* /')} */\n`;
116
- const written = new Map<string, string>();
160
+ const comment = note === '' ? '' : ` <!-- ${note.replace(/-{2,}/g, '-')} -->\n`;
161
+ const written = new Map<string, { dict: boolean; entry: string }>();
117
162
  for (const locale of locales) {
118
163
  const found = foundIn(m.key, locale, locales, asts);
119
164
  if (found === undefined) continue;
120
165
  let entry = written.get(found);
121
166
  if (entry === undefined) {
167
+ const ast = asts.get(found)!.get(m.key)!;
168
+ const dict = ast.some((el) => el.type === TYPE.plural);
122
169
  try {
123
- entry = `${head}"${escape(name, false)}" = "${formatString(asts.get(found)!.get(m.key)!, args, PRINTF)}";\n`;
170
+ entry = dict
171
+ ? { dict, entry: `${comment}${dictEntry(name, ast, args)}` }
172
+ : { dict, entry: `${head}"${escape(name, false)}" = "${formatString(ast, args, PRINTF)}";\n` };
124
173
  } catch (e) {
125
174
  error(m, (e as Error).message, fileOf(m, found, source));
126
175
  break;
127
176
  }
128
177
  written.set(found, entry);
129
178
  }
130
- lines.get(locale)!.push(entry);
179
+ (entry.dict ? dicts : lines).get(locale)!.push(entry.entry);
131
180
  }
132
181
  }
133
182
 
134
- const top = `/* ${banner.replace(/^\/\/ /, '')} */\n`;
183
+ const said = banner.replace(/^\/\/ /, '');
135
184
  const files = new Map(
136
- locales.map((locale) => [join(target.out, `${locale}.lproj`, `${target.name}.strings`), [top, ...lines.get(locale)!].join('\n')]),
185
+ locales.map((locale) => [
186
+ join(target.out, `${locale}.lproj`, `${target.name}.strings`),
187
+ [`/* ${said} */\n`, ...lines.get(locale)!].join('\n'),
188
+ ]),
137
189
  );
190
+ // The system looks a key up in the .stringsdict of the table's name first, so a plural lands there.
191
+ if ([...dicts.values()].some((d) => d.length > 0)) {
192
+ for (const locale of locales) {
193
+ const body = dicts.get(locale)!.join('');
194
+ files.set(
195
+ join(target.out, `${locale}.lproj`, `${target.name}.stringsdict`),
196
+ `<?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`,
197
+ );
198
+ }
199
+ }
138
200
  return { files, problems };
139
201
  }