prosebook 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +357 -26
  2. package/dist/bind-BIZNv8nU.js +62 -0
  3. package/dist/bind-BIZNv8nU.js.map +1 -0
  4. package/dist/bind-ZS86jHXk.d.ts +31 -0
  5. package/dist/bind-ZS86jHXk.d.ts.map +1 -0
  6. package/dist/bun.d.ts +12 -3
  7. package/dist/bun.d.ts.map +1 -1
  8. package/dist/bun.js +77 -26
  9. package/dist/bun.js.map +1 -1
  10. package/dist/{bundle-KD-DiiFa.d.ts → bundle-CLHY_97D.d.ts} +3 -3
  11. package/dist/bundle-CLHY_97D.d.ts.map +1 -0
  12. package/dist/bundle-Ut6Y2X6N.js +217 -0
  13. package/dist/bundle-Ut6Y2X6N.js.map +1 -0
  14. package/dist/cli.js +55 -12
  15. package/dist/cli.js.map +1 -1
  16. package/dist/{config-BPSmoYel.js → config-7T4hX_f7.js} +13 -5
  17. package/dist/{config-BPSmoYel.js.map → config-7T4hX_f7.js.map} +1 -1
  18. package/dist/{config-DdMkKt_N.d.ts → config-CzaJJQ60.d.ts} +19 -5
  19. package/dist/{config-DdMkKt_N.d.ts.map → config-CzaJJQ60.d.ts.map} +1 -1
  20. package/dist/config.d.ts +1 -1
  21. package/dist/config.js +1 -1
  22. package/dist/{copy-B3V_QRkT.d.ts → copy-BXn0aYQn.d.ts} +37 -3
  23. package/dist/copy-BXn0aYQn.d.ts.map +1 -0
  24. package/dist/copy-Bv6S-Hr2.js +235 -0
  25. package/dist/copy-Bv6S-Hr2.js.map +1 -0
  26. package/dist/endpoints-Dhgowovz.js +217 -0
  27. package/dist/endpoints-Dhgowovz.js.map +1 -0
  28. package/dist/generate-1BeQAd1O.d.ts +72 -0
  29. package/dist/generate-1BeQAd1O.d.ts.map +1 -0
  30. package/dist/{generate-9pR3i5Bj.js → generate-CKv86Iki.js} +1724 -627
  31. package/dist/generate-CKv86Iki.js.map +1 -0
  32. package/dist/index.d.ts +2 -2
  33. package/dist/index.js +2 -2
  34. package/dist/next-loader.d.ts +9 -2
  35. package/dist/next-loader.d.ts.map +1 -1
  36. package/dist/next-loader.js +12 -3
  37. package/dist/next-loader.js.map +1 -1
  38. package/dist/next.d.ts +11 -3
  39. package/dist/next.d.ts.map +1 -1
  40. package/dist/next.js +94 -21
  41. package/dist/next.js.map +1 -1
  42. package/dist/preact/index.d.ts +34 -0
  43. package/dist/preact/index.d.ts.map +1 -0
  44. package/dist/preact/index.js +72 -0
  45. package/dist/preact/index.js.map +1 -0
  46. package/dist/react/index.d.ts +5 -14
  47. package/dist/react/index.d.ts.map +1 -1
  48. package/dist/react/index.js +9 -33
  49. package/dist/react/index.js.map +1 -1
  50. package/dist/react/server.d.ts +1 -1
  51. package/dist/react/server.js +1 -1
  52. package/dist/{sarif-CWyTAQ0y.js → sarif-BnwYC78_.js} +8 -98
  53. package/dist/sarif-BnwYC78_.js.map +1 -0
  54. package/dist/{scan-Bx0_yDSi.js → scan-C0ap4Mpp.js} +145 -20
  55. package/dist/scan-C0ap4Mpp.js.map +1 -0
  56. package/dist/solid/index.d.ts +29 -0
  57. package/dist/solid/index.d.ts.map +1 -0
  58. package/dist/solid/index.js +48 -0
  59. package/dist/solid/index.js.map +1 -0
  60. package/dist/svelte/Text.svelte +13 -0
  61. package/dist/svelte/Text.svelte.d.ts +20 -0
  62. package/dist/svelte/index.d.ts +31 -0
  63. package/dist/svelte/index.d.ts.map +1 -0
  64. package/dist/svelte/index.js +35 -0
  65. package/dist/svelte/index.js.map +1 -0
  66. package/dist/tools.d.ts +44 -65
  67. package/dist/tools.d.ts.map +1 -1
  68. package/dist/tools.js +5 -5
  69. package/dist/vite.d.ts +13 -3
  70. package/dist/vite.d.ts.map +1 -1
  71. package/dist/vite.js +82 -25
  72. package/dist/vite.js.map +1 -1
  73. package/dist/vue/index.d.ts +43 -0
  74. package/dist/vue/index.d.ts.map +1 -0
  75. package/dist/vue/index.js +76 -0
  76. package/dist/vue/index.js.map +1 -0
  77. package/package.json +63 -7
  78. package/src/bun.ts +77 -20
  79. package/src/cli.ts +62 -8
  80. package/src/index.ts +1 -1
  81. package/src/next-loader.ts +17 -5
  82. package/src/next.ts +120 -16
  83. package/src/preact/index.ts +91 -0
  84. package/src/react/index.tsx +12 -42
  85. package/src/runtime/bind.ts +96 -0
  86. package/src/runtime/copy.ts +148 -24
  87. package/src/solid/index.ts +67 -0
  88. package/src/svelte/Text.svelte +13 -0
  89. package/src/svelte/Text.svelte.d.ts +20 -0
  90. package/src/svelte/index.ts +65 -0
  91. package/src/tools/bundle.ts +143 -2
  92. package/src/tools/catalog.ts +10 -1
  93. package/src/tools/chunks.ts +142 -0
  94. package/src/tools/config.ts +25 -7
  95. package/src/tools/entry.ts +19 -20
  96. package/src/tools/exchange.ts +275 -0
  97. package/src/tools/extract.ts +2 -2
  98. package/src/tools/generate.ts +7 -1
  99. package/src/tools/index.ts +2 -1
  100. package/src/tools/native/android.ts +186 -0
  101. package/src/tools/native/apple.ts +201 -0
  102. package/src/tools/native/date.ts +219 -0
  103. package/src/tools/native/index.ts +17 -5
  104. package/src/tools/native/kotlin.ts +47 -6
  105. package/src/tools/native/model.ts +67 -29
  106. package/src/tools/native/printf.ts +137 -0
  107. package/src/tools/native/rust.ts +86 -5
  108. package/src/tools/native/swift.ts +62 -6
  109. package/src/tools/scan.ts +146 -25
  110. package/src/tools/todo.ts +6 -1
  111. package/src/vite.ts +121 -28
  112. package/src/vue/index.ts +117 -0
  113. package/dist/bundle-DboItAre.js +0 -111
  114. package/dist/bundle-DboItAre.js.map +0 -1
  115. package/dist/bundle-KD-DiiFa.d.ts.map +0 -1
  116. package/dist/compile-xF3F4oIp.d.ts +0 -16
  117. package/dist/compile-xF3F4oIp.d.ts.map +0 -1
  118. package/dist/copy-B3V_QRkT.d.ts.map +0 -1
  119. package/dist/copy-Bo_FIoLy.js +0 -154
  120. package/dist/copy-Bo_FIoLy.js.map +0 -1
  121. package/dist/endpoints-CnUfCkIk.js +0 -96
  122. package/dist/endpoints-CnUfCkIk.js.map +0 -1
  123. package/dist/generate-9pR3i5Bj.js.map +0 -1
  124. package/dist/sarif-CWyTAQ0y.js.map +0 -1
  125. package/dist/scan-Bx0_yDSi.js.map +0 -1
@@ -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,
@@ -0,0 +1,186 @@
1
+ /**
2
+ * The catalog as Android string resources: `values/<name>.xml` for the source and
3
+ * `values-<qualifier>/<name>.xml` for every other locale, each holding what that locale shows, every
4
+ * fallback resolved. An app with the Kotlin target still needs these: its manifest, shortcuts and
5
+ * other XML resources name strings as `@string/…`, which only a resource answers.
6
+ *
7
+ * Android fills values in with `String.format` and picks a plural's arm itself, from its own CLDR
8
+ * rules, so this takes a narrower subset than the other targets, written straight from the ICU tree:
9
+ * text, plain `{name}` placeholders, and one cardinal plural, with the text around it in each arm.
10
+ */
11
+ import { join } from 'node:path';
12
+
13
+ import type { Catalog, Message, Problem } from '#src/tools/catalog.ts';
14
+ import type { Entry } from '#src/tools/compile.ts';
15
+ import type { NativeTarget } from '#src/tools/config.ts';
16
+ import { TYPE, type MessageFormatElement } from '#src/tools/icu.ts';
17
+ import { checkNames, leading, snake } from './emit.ts';
18
+ import { fileOf, foundIn, LEAVE_OUT, wordsFor } from './model.ts';
19
+ import { checkPlural, formatString, noteOf, type PluralHolder, type Printf } from './printf.ts';
20
+
21
+ const KEYWORDS = new Set(
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(
23
+ ' ',
24
+ ),
25
+ );
26
+
27
+ /**
28
+ * A locale as Android's resource qualifier: `de`, `pt-rBR`, and BCP 47's own form, `b+sr+Latn`,
29
+ * past what the old form holds (a script, a numeric region, a variant).
30
+ */
31
+ export function qualifierOf(locale: string): string {
32
+ const [language = '', ...rest] = locale.split(/[-_]/);
33
+ const subtags = [
34
+ language.toLowerCase(),
35
+ ...rest.map((s) =>
36
+ s.length === 4 && /^[a-z]+$/i.test(s)
37
+ ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()
38
+ : s.length === 2
39
+ ? s.toUpperCase()
40
+ : s.toLowerCase(),
41
+ ),
42
+ ];
43
+ const old = /^[a-z]{2,3}$/.test(subtags[0]!) && (rest.length === 0 || (rest.length === 1 && /^[A-Z]{2}$/.test(subtags[1]!)));
44
+ if (!old) return `b+${subtags.join('+')}`;
45
+ return rest.length === 0 ? subtags[0]! : `${subtags[0]}-r${subtags[1]}`;
46
+ }
47
+
48
+ /** Whitespace as aapt2 reads it: collapsed to one space, and trimmed at the ends, outside double quotes. */
49
+ const LOOSE = /^[ \t\n\v\f\r]|[ \t\n\v\f\r]$|[ \t\n\v\f\r]{2}/;
50
+
51
+ /**
52
+ * Text as a resource holds it: XML's escapes, then Android's (`\`, `'`, `"`, a newline or a tab).
53
+ * In a string that is formatted, `%` is doubled, so `String.format` writes one.
54
+ */
55
+ function escape(text: string, formatted: boolean): string {
56
+ const out = text
57
+ .replaceAll('\\', '\\\\')
58
+ .replaceAll('&', '&amp;')
59
+ .replaceAll('<', '&lt;')
60
+ .replaceAll('>', '&gt;')
61
+ .replaceAll("'", "\\'")
62
+ .replaceAll('"', '\\"')
63
+ .replaceAll('\n', '\\n')
64
+ .replaceAll('\t', '\\t');
65
+ return formatted ? out.replaceAll('%', '%%') : out;
66
+ }
67
+
68
+ /**
69
+ * A whole value: a leading `@` or `?` escaped, since either would make it a reference, and the value
70
+ * quoted where its spaces would otherwise be collapsed or trimmed.
71
+ */
72
+ function value(written: string): string {
73
+ const out = /^[@?]/.test(written) ? `\\${written}` : written;
74
+ return LOOSE.test(out) ? `"${out}"` : out;
75
+ }
76
+
77
+ const PRINTF: Printf = {
78
+ platform: 'Android',
79
+ formatter: 'String.format',
80
+ holder: 'Android resources',
81
+ string: 's',
82
+ count: 'd',
83
+ escape,
84
+ plural: (name) =>
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}`,
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
+
94
+ /**
95
+ * A message as `<string>`, or `<plurals>` when it holds a plural. Android's picks between whole
96
+ * messages, so the text around the plural goes into each arm: the pseudo-locale's brackets, or a
97
+ * sentence the plural ends.
98
+ */
99
+ function resource(name: string, ast: MessageFormatElement[], args: string[]): string {
100
+ const at = ast.findIndex((el) => el.type === TYPE.plural);
101
+ const plural = ast[at];
102
+ if (plural?.type !== TYPE.plural) {
103
+ const written = formatString(ast, args, PRINTF);
104
+ // Unformatted, `%` is written as it is, which aapt2 refuses where it reads as two placeholders.
105
+ const raw = args.length === 0 && written.includes('%') ? ' formatted="false"' : '';
106
+ return ` <string name="${name}"${raw}>${value(written)}</string>`;
107
+ }
108
+ const [before, after] = [ast.slice(0, at), ast.slice(at + 1)];
109
+ checkPlural(plural, PLURALS);
110
+ const items = Object.entries(plural.options).map(
111
+ ([category, option]) =>
112
+ ` <item quantity="${category}">${value(formatString([...before, ...option.value, ...after], args, PRINTF, plural.value))}</item>`,
113
+ );
114
+ return ` <plurals name="${name}">\n${items.join('\n')}\n </plurals>`;
115
+ }
116
+
117
+ /** The context and what each argument holds, as the comment above an entry. */
118
+ function comment(m: Message): string {
119
+ const said = noteOf(m);
120
+ // `--` ends a comment early, so XML refuses it inside one.
121
+ return said === '' ? '' : ` <!-- ${said.replace(/-{2,}/g, '-')} -->\n`;
122
+ }
123
+
124
+ /** The files `target` writes, by path, or a problem for every message it can't hold. */
125
+ export function androidResources(
126
+ target: NativeTarget & { name: string },
127
+ catalog: Catalog,
128
+ locales: string[],
129
+ entries: Map<string, Entry[]>,
130
+ banner: string,
131
+ ): { files: Map<string, string>; problems: Problem[] } {
132
+ const source = locales[0]!;
133
+ const asts = new Map([...entries].map(([locale, list]) => [locale, new Map(list)]));
134
+ const taken = catalog.messages.flatMap((m) => {
135
+ const words = wordsFor(target, m.key);
136
+ return words === undefined ? [] : [{ m, name: leading(snake(words)) }];
137
+ });
138
+ checkNames(
139
+ 'android',
140
+ taken.map(({ m, name }) => [m.key, name]),
141
+ [],
142
+ );
143
+ const problems = taken
144
+ .filter((t) => KEYWORDS.has(t.name))
145
+ .map(({ m, name }): Problem => ({
146
+ severity: 'error',
147
+ file: m.file,
148
+ key: m.key,
149
+ message: `android: ${m.key} is named ${name}, which Java keeps for itself: rename it`,
150
+ }));
151
+
152
+ const bodies = new Map<string, string[]>(locales.map((l) => [l, []]));
153
+ const failed = new Set<string>();
154
+ for (const { m, name } of taken) {
155
+ const args = [...m.args.keys()];
156
+ const written = new Map<string, string>();
157
+ for (const locale of locales) {
158
+ const found = foundIn(m.key, locale, locales, asts);
159
+ if (failed.has(m.key)) break;
160
+ if (found === undefined) continue;
161
+ let entry = written.get(found);
162
+ if (entry === undefined) {
163
+ try {
164
+ entry = `${comment(m)}${resource(name, asts.get(found)!.get(m.key)!, args)}`;
165
+ } catch (e) {
166
+ failed.add(m.key);
167
+ problems.push({ severity: 'error', file: fileOf(m, found, source), key: m.key, message: `android: ${(e as Error).message}` });
168
+ continue;
169
+ }
170
+ written.set(found, entry);
171
+ }
172
+ bodies.get(locale)!.push(entry);
173
+ }
174
+ }
175
+
176
+ const head = `<!-- ${banner.replace(/^\/\/ /, '')} -->`;
177
+ const files = new Map(
178
+ locales.map((locale) => {
179
+ const folder = locale === source ? 'values' : `values-${qualifierOf(locale)}`;
180
+ const body = bodies.get(locale)!.join('\n');
181
+ const xml = `<?xml version="1.0" encoding="utf-8"?>\n${head}\n<resources>\n${body}${body === '' ? '' : '\n'}</resources>\n`;
182
+ return [join(target.out, folder, `${target.name}.xml`), xml];
183
+ }),
184
+ );
185
+ return { files, problems };
186
+ }