prosebook 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +288 -28
- package/dist/bind-BIZNv8nU.js +62 -0
- package/dist/bind-BIZNv8nU.js.map +1 -0
- package/dist/bind-ZS86jHXk.d.ts +31 -0
- package/dist/bind-ZS86jHXk.d.ts.map +1 -0
- package/dist/bun.d.ts +11 -2
- package/dist/bun.d.ts.map +1 -1
- package/dist/bun.js +77 -26
- package/dist/bun.js.map +1 -1
- package/dist/{bundle-CpUIWb9j.d.ts → bundle-CLHY_97D.d.ts} +2 -2
- package/dist/bundle-CLHY_97D.d.ts.map +1 -0
- package/dist/bundle-Ut6Y2X6N.js +217 -0
- package/dist/bundle-Ut6Y2X6N.js.map +1 -0
- package/dist/cli.js +55 -12
- package/dist/cli.js.map +1 -1
- package/dist/{config-nWZf9QmF.js → config-7T4hX_f7.js} +5 -3
- package/dist/{config-nWZf9QmF.js.map → config-7T4hX_f7.js.map} +1 -1
- package/dist/config-CzaJJQ60.d.ts.map +1 -1
- package/dist/config.js +1 -1
- package/dist/{copy-B3V_QRkT.d.ts → copy-BXn0aYQn.d.ts} +37 -3
- package/dist/copy-BXn0aYQn.d.ts.map +1 -0
- package/dist/copy-Bv6S-Hr2.js +235 -0
- package/dist/copy-Bv6S-Hr2.js.map +1 -0
- package/dist/endpoints-Dhgowovz.js +217 -0
- package/dist/endpoints-Dhgowovz.js.map +1 -0
- package/dist/generate-1BeQAd1O.d.ts +72 -0
- package/dist/generate-1BeQAd1O.d.ts.map +1 -0
- package/dist/{generate-BW3N8EGb.js → generate-CKv86Iki.js} +1091 -309
- package/dist/generate-CKv86Iki.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/next-loader.d.ts +9 -2
- package/dist/next-loader.d.ts.map +1 -1
- package/dist/next-loader.js +12 -3
- package/dist/next-loader.js.map +1 -1
- package/dist/next.d.ts +10 -2
- package/dist/next.d.ts.map +1 -1
- package/dist/next.js +94 -21
- package/dist/next.js.map +1 -1
- package/dist/preact/index.d.ts +34 -0
- package/dist/preact/index.d.ts.map +1 -0
- package/dist/preact/index.js +72 -0
- package/dist/preact/index.js.map +1 -0
- package/dist/react/index.d.ts +5 -14
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +9 -33
- package/dist/react/index.js.map +1 -1
- package/dist/react/server.d.ts +1 -1
- package/dist/react/server.js +1 -1
- package/dist/{sarif-B5nrMqrT.js → sarif-BnwYC78_.js} +8 -98
- package/dist/sarif-BnwYC78_.js.map +1 -0
- package/dist/{scan-Bx0_yDSi.js → scan-C0ap4Mpp.js} +145 -20
- package/dist/scan-C0ap4Mpp.js.map +1 -0
- package/dist/solid/index.d.ts +29 -0
- package/dist/solid/index.d.ts.map +1 -0
- package/dist/solid/index.js +48 -0
- package/dist/solid/index.js.map +1 -0
- package/dist/svelte/Text.svelte +13 -0
- package/dist/svelte/Text.svelte.d.ts +20 -0
- package/dist/svelte/index.d.ts +31 -0
- package/dist/svelte/index.d.ts.map +1 -0
- package/dist/svelte/index.js +35 -0
- package/dist/svelte/index.js.map +1 -0
- package/dist/tools.d.ts +43 -64
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +5 -5
- package/dist/vite.d.ts +12 -2
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +82 -25
- package/dist/vite.js.map +1 -1
- package/dist/vue/index.d.ts +43 -0
- package/dist/vue/index.d.ts.map +1 -0
- package/dist/vue/index.js +76 -0
- package/dist/vue/index.js.map +1 -0
- package/package.json +63 -7
- package/src/bun.ts +77 -20
- package/src/cli.ts +62 -8
- package/src/index.ts +1 -1
- package/src/next-loader.ts +17 -5
- package/src/next.ts +120 -16
- package/src/preact/index.ts +91 -0
- package/src/react/index.tsx +12 -42
- package/src/runtime/bind.ts +96 -0
- package/src/runtime/copy.ts +148 -24
- package/src/solid/index.ts +67 -0
- package/src/svelte/Text.svelte +13 -0
- package/src/svelte/Text.svelte.d.ts +20 -0
- package/src/svelte/index.ts +65 -0
- package/src/tools/bundle.ts +143 -2
- package/src/tools/catalog.ts +10 -1
- package/src/tools/chunks.ts +142 -0
- package/src/tools/config.ts +1 -1
- package/src/tools/entry.ts +19 -20
- package/src/tools/exchange.ts +275 -0
- package/src/tools/extract.ts +2 -2
- package/src/tools/generate.ts +7 -1
- package/src/tools/index.ts +2 -1
- package/src/tools/native/android.ts +10 -25
- package/src/tools/native/apple.ts +70 -8
- package/src/tools/native/date.ts +219 -0
- package/src/tools/native/kotlin.ts +47 -6
- package/src/tools/native/model.ts +42 -16
- package/src/tools/native/printf.ts +46 -4
- package/src/tools/native/rust.ts +86 -5
- package/src/tools/native/swift.ts +62 -6
- package/src/tools/scan.ts +146 -25
- package/src/tools/todo.ts +6 -1
- package/src/vite.ts +121 -28
- package/src/vue/index.ts +117 -0
- package/dist/bundle-CpUIWb9j.d.ts.map +0 -1
- package/dist/bundle-DboItAre.js +0 -111
- package/dist/bundle-DboItAre.js.map +0 -1
- package/dist/compile-ClK9htKr.d.ts +0 -16
- package/dist/compile-ClK9htKr.d.ts.map +0 -1
- package/dist/copy-B3V_QRkT.d.ts.map +0 -1
- package/dist/copy-Bo_FIoLy.js +0 -154
- package/dist/copy-Bo_FIoLy.js.map +0 -1
- package/dist/endpoints-BgG_5Vpm.js +0 -96
- package/dist/endpoints-BgG_5Vpm.js.map +0 -1
- package/dist/generate-BW3N8EGb.js.map +0 -1
- package/dist/sarif-B5nrMqrT.js.map +0 -1
- package/dist/scan-Bx0_yDSi.js.map +0 -1
|
@@ -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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
|
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);
|
package/src/tools/extract.ts
CHANGED
|
@@ -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') :
|
|
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
|
}
|
package/src/tools/generate.ts
CHANGED
|
@@ -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
|
}
|
package/src/tools/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
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 .
|
|
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
|
|
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 =
|
|
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
|
|
183
|
+
const said = banner.replace(/^\/\/ /, '');
|
|
135
184
|
const files = new Map(
|
|
136
|
-
locales.map((locale) => [
|
|
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
|
}
|