prosebook 0.3.0 → 0.4.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 +74 -3
- package/dist/bun.d.ts +2 -2
- package/dist/bun.js +3 -3
- package/dist/{bundle-KD-DiiFa.d.ts → bundle-CpUIWb9j.d.ts} +3 -3
- package/dist/bundle-CpUIWb9j.d.ts.map +1 -0
- package/dist/cli.js +3 -3
- package/dist/{compile-xF3F4oIp.d.ts → compile-ClK9htKr.d.ts} +2 -2
- package/dist/{compile-xF3F4oIp.d.ts.map → compile-ClK9htKr.d.ts.map} +1 -1
- package/dist/{config-DdMkKt_N.d.ts → config-CzaJJQ60.d.ts} +19 -5
- package/dist/{config-DdMkKt_N.d.ts.map → config-CzaJJQ60.d.ts.map} +1 -1
- package/dist/{config-BPSmoYel.js → config-nWZf9QmF.js} +9 -3
- package/dist/{config-BPSmoYel.js.map → config-nWZf9QmF.js.map} +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/{endpoints-CnUfCkIk.js → endpoints-BgG_5Vpm.js} +2 -2
- package/dist/{endpoints-CnUfCkIk.js.map → endpoints-BgG_5Vpm.js.map} +1 -1
- package/dist/{generate-9pR3i5Bj.js → generate-BW3N8EGb.js} +590 -275
- package/dist/generate-BW3N8EGb.js.map +1 -0
- package/dist/next.d.ts +2 -2
- package/dist/next.js +3 -3
- package/dist/{sarif-CWyTAQ0y.js → sarif-B5nrMqrT.js} +3 -3
- package/dist/{sarif-CWyTAQ0y.js.map → sarif-B5nrMqrT.js.map} +1 -1
- package/dist/tools.d.ts +2 -2
- package/dist/tools.js +3 -3
- package/dist/vite.d.ts +2 -2
- package/dist/vite.js +3 -3
- package/package.json +1 -1
- package/src/tools/config.ts +24 -6
- package/src/tools/native/android.ts +201 -0
- package/src/tools/native/apple.ts +139 -0
- package/src/tools/native/index.ts +17 -5
- package/src/tools/native/model.ts +25 -13
- package/src/tools/native/printf.ts +95 -0
- package/dist/bundle-KD-DiiFa.d.ts.map +0 -1
- package/dist/generate-9pR3i5Bj.js.map +0 -1
|
@@ -0,0 +1,201 @@
|
|
|
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 { selectionOf, 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 { formatString, noteOf, 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('&', '&')
|
|
59
|
+
.replaceAll('<', '<')
|
|
60
|
+
.replaceAll('>', '>')
|
|
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
|
+
escape,
|
|
83
|
+
plural: (name) =>
|
|
84
|
+
`{${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
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A message as `<string>`, or `<plurals>` when it holds a plural. Android's picks between whole
|
|
89
|
+
* messages, so the text around the plural goes into each arm: the pseudo-locale's brackets, or a
|
|
90
|
+
* sentence the plural ends.
|
|
91
|
+
*/
|
|
92
|
+
function resource(name: string, ast: MessageFormatElement[], args: string[]): string {
|
|
93
|
+
const at = ast.findIndex((el) => el.type === TYPE.plural);
|
|
94
|
+
const plural = ast[at];
|
|
95
|
+
if (plural?.type !== TYPE.plural) {
|
|
96
|
+
const written = formatString(ast, args, PRINTF);
|
|
97
|
+
// Unformatted, `%` is written as it is, which aapt2 refuses where it reads as two placeholders.
|
|
98
|
+
const raw = args.length === 0 && written.includes('%') ? ' formatted="false"' : '';
|
|
99
|
+
return ` <string name="${name}"${raw}>${value(written)}</string>`;
|
|
100
|
+
}
|
|
101
|
+
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
|
+
}
|
|
125
|
+
const items = Object.entries(plural.options).map(
|
|
126
|
+
([category, option]) =>
|
|
127
|
+
` <item quantity="${category}">${value(formatString([...before, ...option.value, ...after], args, PRINTF, plural.value))}</item>`,
|
|
128
|
+
);
|
|
129
|
+
return ` <plurals name="${name}">\n${items.join('\n')}\n </plurals>`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The context and what each argument holds, as the comment above an entry. */
|
|
133
|
+
function comment(m: Message): string {
|
|
134
|
+
const said = noteOf(m);
|
|
135
|
+
// `--` ends a comment early, so XML refuses it inside one.
|
|
136
|
+
return said === '' ? '' : ` <!-- ${said.replace(/-{2,}/g, '-')} -->\n`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The files `target` writes, by path, or a problem for every message it can't hold. */
|
|
140
|
+
export function androidResources(
|
|
141
|
+
target: NativeTarget & { name: string },
|
|
142
|
+
catalog: Catalog,
|
|
143
|
+
locales: string[],
|
|
144
|
+
entries: Map<string, Entry[]>,
|
|
145
|
+
banner: string,
|
|
146
|
+
): { files: Map<string, string>; problems: Problem[] } {
|
|
147
|
+
const source = locales[0]!;
|
|
148
|
+
const asts = new Map([...entries].map(([locale, list]) => [locale, new Map(list)]));
|
|
149
|
+
const taken = catalog.messages.flatMap((m) => {
|
|
150
|
+
const words = wordsFor(target, m.key);
|
|
151
|
+
return words === undefined ? [] : [{ m, name: leading(snake(words)) }];
|
|
152
|
+
});
|
|
153
|
+
checkNames(
|
|
154
|
+
'android',
|
|
155
|
+
taken.map(({ m, name }) => [m.key, name]),
|
|
156
|
+
[],
|
|
157
|
+
);
|
|
158
|
+
const problems = taken
|
|
159
|
+
.filter((t) => KEYWORDS.has(t.name))
|
|
160
|
+
.map(({ m, name }): Problem => ({
|
|
161
|
+
severity: 'error',
|
|
162
|
+
file: m.file,
|
|
163
|
+
key: m.key,
|
|
164
|
+
message: `android: ${m.key} is named ${name}, which Java keeps for itself: rename it`,
|
|
165
|
+
}));
|
|
166
|
+
|
|
167
|
+
const bodies = new Map<string, string[]>(locales.map((l) => [l, []]));
|
|
168
|
+
const failed = new Set<string>();
|
|
169
|
+
for (const { m, name } of taken) {
|
|
170
|
+
const args = [...m.args.keys()];
|
|
171
|
+
const written = new Map<string, string>();
|
|
172
|
+
for (const locale of locales) {
|
|
173
|
+
const found = foundIn(m.key, locale, locales, asts);
|
|
174
|
+
if (failed.has(m.key)) break;
|
|
175
|
+
if (found === undefined) continue;
|
|
176
|
+
let entry = written.get(found);
|
|
177
|
+
if (entry === undefined) {
|
|
178
|
+
try {
|
|
179
|
+
entry = `${comment(m)}${resource(name, asts.get(found)!.get(m.key)!, args)}`;
|
|
180
|
+
} catch (e) {
|
|
181
|
+
failed.add(m.key);
|
|
182
|
+
problems.push({ severity: 'error', file: fileOf(m, found, source), key: m.key, message: `android: ${(e as Error).message}` });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
written.set(found, entry);
|
|
186
|
+
}
|
|
187
|
+
bodies.get(locale)!.push(entry);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const head = `<!-- ${banner.replace(/^\/\/ /, '')} -->`;
|
|
192
|
+
const files = new Map(
|
|
193
|
+
locales.map((locale) => {
|
|
194
|
+
const folder = locale === source ? 'values' : `values-${qualifierOf(locale)}`;
|
|
195
|
+
const body = bodies.get(locale)!.join('\n');
|
|
196
|
+
const xml = `<?xml version="1.0" encoding="utf-8"?>\n${head}\n<resources>\n${body}${body === '' ? '' : '\n'}</resources>\n`;
|
|
197
|
+
return [join(target.out, folder, `${target.name}.xml`), xml];
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
return { files, problems };
|
|
201
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The catalog as Apple `.strings` tables: `<locale>.lproj/<name>.strings` per locale, each holding
|
|
3
|
+
* what that locale shows, every fallback resolved. Plain `.strings` and not a String Catalog, which
|
|
4
|
+
* only Xcode compiles: an app bundled by other tools copies these into its `Resources` as they are.
|
|
5
|
+
*
|
|
6
|
+
* The Swift target covers what code shows; these are for what the system reads from a bundle. With
|
|
7
|
+
* `plist`, the table is `InfoPlist`: the permission prompts and names in that Info.plist, an entry
|
|
8
|
+
* per key it has, which its own text must match in the source locale.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { join, relative } 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 { staticLiteral } from '#src/tools/icu.ts';
|
|
17
|
+
import { checkNames } from './emit.ts';
|
|
18
|
+
import { fileOf, foundIn, LEAVE_OUT, restOf, wordsFor } from './model.ts';
|
|
19
|
+
import { formatString, noteOf, type Printf } from './printf.ts';
|
|
20
|
+
|
|
21
|
+
/** Text inside a `.strings` file's quotes; `%` doubled where the string has arguments. */
|
|
22
|
+
function escape(text: string, formatted: boolean): string {
|
|
23
|
+
const out = text.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n').replaceAll('\r', '\\r').replaceAll('\t', '\\t');
|
|
24
|
+
return formatted ? out.replaceAll('%', '%%') : out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const PRINTF: Printf = {
|
|
28
|
+
platform: 'Apple',
|
|
29
|
+
formatter: 'String(format:)',
|
|
30
|
+
holder: '.strings files',
|
|
31
|
+
string: '@',
|
|
32
|
+
escape,
|
|
33
|
+
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}`,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
|
|
38
|
+
|
|
39
|
+
/** An XML plist's string values by key, read as its text is: every `<key>` followed by a `<string>`. */
|
|
40
|
+
function plistStrings(xml: string): Map<string, string> {
|
|
41
|
+
const strings = new Map<string, string>();
|
|
42
|
+
for (const [, key, value] of xml.matchAll(/<key>([^<]*)<\/key>\s*(?:<string>([^<]*)<\/string>|<string\/>)/g)) {
|
|
43
|
+
strings.set(
|
|
44
|
+
key!,
|
|
45
|
+
(value ?? '').replace(/&(amp|lt|gt|quot|apos|#x?[0-9a-f]+);/gi, (_, e: string) =>
|
|
46
|
+
e.startsWith('#')
|
|
47
|
+
? String.fromCodePoint(Number.parseInt(e.slice(/x/i.test(e[1]!) ? 2 : 1), /x/i.test(e[1]!) ? 16 : 10))
|
|
48
|
+
: ENTITIES[e.toLowerCase()]!,
|
|
49
|
+
),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return strings;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The files `target` writes, by path, or a problem for every message it can't hold. */
|
|
56
|
+
export function appleStrings(
|
|
57
|
+
target: NativeTarget & { name: string },
|
|
58
|
+
root: string,
|
|
59
|
+
catalog: Catalog,
|
|
60
|
+
locales: string[],
|
|
61
|
+
entries: Map<string, Entry[]>,
|
|
62
|
+
banner: string,
|
|
63
|
+
): { files: Map<string, string>; problems: Problem[] } {
|
|
64
|
+
const source = locales[0]!;
|
|
65
|
+
const asts = new Map([...entries].map(([locale, list]) => [locale, new Map(list)]));
|
|
66
|
+
const problems: Problem[] = [];
|
|
67
|
+
const error = (m: Message, message: string, file = m.file) =>
|
|
68
|
+
problems.push({ severity: 'error', file, key: m.key, message: `apple: ${message}` });
|
|
69
|
+
|
|
70
|
+
const plist = target.plist;
|
|
71
|
+
const shown = plist === undefined ? '' : relative(root, plist).replaceAll('\\', '/');
|
|
72
|
+
if (plist !== undefined && !existsSync(plist)) throw new Error(`apple: ${shown} is missing: name the Info.plist the table translates`);
|
|
73
|
+
const strings = plist === undefined ? undefined : plistStrings(readFileSync(plist, 'utf8'));
|
|
74
|
+
const byLower = new Map([...(strings?.keys() ?? [])].map((k) => [k.toLowerCase(), k]));
|
|
75
|
+
|
|
76
|
+
// Under `plist`, a key's words spell the plist key, which catalog keys can't hold as written.
|
|
77
|
+
const taken = catalog.messages.flatMap((m) => {
|
|
78
|
+
const rest = restOf(target, m.key);
|
|
79
|
+
if (rest === undefined) return [];
|
|
80
|
+
if (strings === undefined) return [{ m, name: rest }];
|
|
81
|
+
const name = byLower.get(wordsFor(target, m.key)!.join('').toLowerCase());
|
|
82
|
+
if (name === undefined) {
|
|
83
|
+
error(
|
|
84
|
+
m,
|
|
85
|
+
`${m.key} names no string in ${shown}: spell one of its keys, as ns-camera-usage-description is NSCameraUsageDescription, or add it there`,
|
|
86
|
+
);
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
return [{ m, name }];
|
|
90
|
+
});
|
|
91
|
+
checkNames(
|
|
92
|
+
'apple',
|
|
93
|
+
taken.map(({ m, name }) => [m.key, name]),
|
|
94
|
+
[],
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
for (const { m, name } of taken) {
|
|
98
|
+
if (strings === undefined) continue;
|
|
99
|
+
if (m.args.size > 0) {
|
|
100
|
+
error(m, `${name} is shown as it is written, with nothing filled in: say it without {${[...m.args.keys()].join('}, {')}}`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
// The plist's own text shows where no .lproj matches, so it can't be left to say something else.
|
|
104
|
+
const text = staticLiteral(asts.get(source)?.get(m.key) ?? []);
|
|
105
|
+
if (text !== undefined && text !== strings.get(name))
|
|
106
|
+
error(m, `${shown} says ${JSON.stringify(strings.get(name))} for ${name}: make it ${JSON.stringify(text)}, as the catalog says`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const lines = new Map<string, string[]>(locales.map((l) => [l, []]));
|
|
110
|
+
const failed = new Set(problems.map((p) => p.key));
|
|
111
|
+
for (const { m, name } of taken) {
|
|
112
|
+
if (failed.has(m.key)) continue;
|
|
113
|
+
const args = [...m.args.keys()];
|
|
114
|
+
const note = noteOf(m);
|
|
115
|
+
const head = note === '' ? '' : `/* ${note.replaceAll('*/', '* /')} */\n`;
|
|
116
|
+
const written = new Map<string, string>();
|
|
117
|
+
for (const locale of locales) {
|
|
118
|
+
const found = foundIn(m.key, locale, locales, asts);
|
|
119
|
+
if (found === undefined) continue;
|
|
120
|
+
let entry = written.get(found);
|
|
121
|
+
if (entry === undefined) {
|
|
122
|
+
try {
|
|
123
|
+
entry = `${head}"${escape(name, false)}" = "${formatString(asts.get(found)!.get(m.key)!, args, PRINTF)}";\n`;
|
|
124
|
+
} catch (e) {
|
|
125
|
+
error(m, (e as Error).message, fileOf(m, found, source));
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
written.set(found, entry);
|
|
129
|
+
}
|
|
130
|
+
lines.get(locale)!.push(entry);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const top = `/* ${banner.replace(/^\/\/ /, '')} */\n`;
|
|
135
|
+
const files = new Map(
|
|
136
|
+
locales.map((locale) => [join(target.out, `${locale}.lproj`, `${target.name}.strings`), [top, ...lines.get(locale)!].join('\n')]),
|
|
137
|
+
);
|
|
138
|
+
return { files, problems };
|
|
139
|
+
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The native files `generate` writes beside the TypeScript, one per target in the config's `native
|
|
3
|
-
*
|
|
2
|
+
* The native files `generate` writes beside the TypeScript, one per target in the config's `native`
|
|
3
|
+
* (Android's and Apple's string resources, a file per locale). A message a target can't render
|
|
4
|
+
* fails the build with the reason, as a translation that drifted does.
|
|
4
5
|
*/
|
|
5
6
|
import { CatalogError, type Catalog, type Problem } from '#src/tools/catalog.ts';
|
|
6
7
|
import type { Entry } from '#src/tools/compile.ts';
|
|
7
8
|
import type { ResolvedConfig } from '#src/tools/config.ts';
|
|
9
|
+
import { androidResources } from './android.ts';
|
|
10
|
+
import { appleStrings } from './apple.ts';
|
|
8
11
|
import { kotlinFile } from './kotlin.ts';
|
|
9
12
|
import { nativeModel } from './model.ts';
|
|
10
13
|
import { rustModule } from './rust.ts';
|
|
@@ -22,10 +25,19 @@ export function nativeFiles(
|
|
|
22
25
|
const files = new Map<string, string>();
|
|
23
26
|
const problems: Problem[] = [];
|
|
24
27
|
for (const target of config.native) {
|
|
25
|
-
const { model, problems: found } = nativeModel(target, catalog, locales, entries);
|
|
26
|
-
problems.push(...found);
|
|
27
|
-
if (found.length > 0) continue;
|
|
28
28
|
try {
|
|
29
|
+
if (target.language === 'android' || target.language === 'apple') {
|
|
30
|
+
const { files: written, problems: found } =
|
|
31
|
+
target.language === 'android'
|
|
32
|
+
? androidResources(target, catalog, locales, entries, banner)
|
|
33
|
+
: appleStrings(target, config.root, catalog, locales, entries, banner);
|
|
34
|
+
problems.push(...found);
|
|
35
|
+
for (const [path, content] of written) files.set(path, content);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const { model, problems: found } = nativeModel(target, catalog, locales, entries);
|
|
39
|
+
problems.push(...found);
|
|
40
|
+
if (found.length > 0) continue;
|
|
29
41
|
files.set(target.out, WRITERS[target.language](model, target, banner));
|
|
30
42
|
} catch (e) {
|
|
31
43
|
problems.push({ severity: 'error', message: (e as Error).message });
|
|
@@ -59,10 +59,26 @@ export interface NativeModel {
|
|
|
59
59
|
rules: { name: string; rules: PluralRules }[];
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
/**
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
/**
|
|
63
|
+
* The words a target names `key` by, less the prefix that took it in (longest first): `tray.quit-app`
|
|
64
|
+
* is quit, app under `['tray']`. Nothing when the target doesn't take the key.
|
|
65
|
+
*/
|
|
66
|
+
export function wordsFor(target: NativeTarget, key: string): string[] | undefined {
|
|
67
|
+
return restOf(target, key)
|
|
68
|
+
?.split(/[.\-_]/)
|
|
69
|
+
.filter(Boolean);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** `key` less the prefix that took it in, as written: `tray.quit-app` is `quit-app` under `['tray']`. */
|
|
73
|
+
export function restOf(target: NativeTarget, key: string): string | undefined {
|
|
74
|
+
const prefix = target.keys === undefined ? '' : [...target.keys].sort((a, b) => b.length - a.length).find((p) => key.startsWith(`${p}.`));
|
|
75
|
+
if (prefix === undefined) return undefined;
|
|
76
|
+
return key.slice(prefix === '' ? 0 : prefix.length + 1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The locale whose text `locale` shows for `key`: its own, else the first down its fallback chain that has it. */
|
|
80
|
+
export function foundIn(key: string, locale: string, locales: string[], asts: Map<string, Map<string, MessageFormatElement[]>>) {
|
|
81
|
+
return chainOf(locale, locales, locales[0]!).find((l) => asts.get(l)?.has(key));
|
|
66
82
|
}
|
|
67
83
|
|
|
68
84
|
/** Values a file declares once each, numbered by what they hold, so locales that format alike share one. */
|
|
@@ -95,16 +111,12 @@ export function nativeModel(
|
|
|
95
111
|
const messages: NativeMessage[] = [];
|
|
96
112
|
|
|
97
113
|
for (const m of catalog.messages) {
|
|
98
|
-
const
|
|
99
|
-
if (
|
|
100
|
-
const words = m.key
|
|
101
|
-
.slice(prefix === '' ? 0 : prefix.length + 1)
|
|
102
|
-
.split(/[.\-_]/)
|
|
103
|
-
.filter(Boolean);
|
|
114
|
+
const words = wordsFor(target, m.key);
|
|
115
|
+
if (words === undefined) continue;
|
|
104
116
|
const bodies = new Map<string, { locales: string[]; parts: Part[] }>();
|
|
105
117
|
let failed = false;
|
|
106
118
|
for (const locale of locales) {
|
|
107
|
-
const found =
|
|
119
|
+
const found = foundIn(m.key, locale, locales, asts);
|
|
108
120
|
if (found === undefined) continue;
|
|
109
121
|
const body = bodies.get(found);
|
|
110
122
|
if (body !== undefined) {
|
|
@@ -139,7 +151,7 @@ export function nativeModel(
|
|
|
139
151
|
return { model: { locales, source, messages, formats: formats.list, rules: rules.list }, problems };
|
|
140
152
|
}
|
|
141
153
|
|
|
142
|
-
function fileOf(m: Message, locale: string, source: string): string {
|
|
154
|
+
export function fileOf(m: Message, locale: string, source: string): string {
|
|
143
155
|
return locale === source ? m.file : (m.translations.get(locale)?.file ?? m.file);
|
|
144
156
|
}
|
|
145
157
|
|
|
@@ -151,7 +163,7 @@ function docOf(m: Message): string[] {
|
|
|
151
163
|
return lines.map(docText);
|
|
152
164
|
}
|
|
153
165
|
|
|
154
|
-
const LEAVE_OUT = "or leave the key out of this target's `keys`";
|
|
166
|
+
export const LEAVE_OUT = "or leave the key out of this target's `keys`";
|
|
155
167
|
|
|
156
168
|
/** Specs by locale and options, kept across runs: taking a format apart checks it against `Intl` dozens of times. */
|
|
157
169
|
const SPECS = new Map<string, NumberSpec | Error>();
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Messages as printf-style format strings, for the targets whose platform fills values in itself:
|
|
3
|
+
* Android's resources and Apple's `.strings` files. Text and plain `{name}` placeholders, as `%N$s`
|
|
4
|
+
* with N the argument's place in the message's arguments, so a translation can put them in another
|
|
5
|
+
* order; anything `Intl` would format fails with the fix.
|
|
6
|
+
*/
|
|
7
|
+
import { isDateElement, isNumberElement, isTimeElement } from '@formatjs/icu-messageformat-parser';
|
|
8
|
+
|
|
9
|
+
import type { Message } from '#src/tools/catalog.ts';
|
|
10
|
+
import { numberOptions, placeholderOf, TYPE, type MessageFormatElement } from '#src/tools/icu.ts';
|
|
11
|
+
import { docText } from './emit.ts';
|
|
12
|
+
import { LEAVE_OUT } from './model.ts';
|
|
13
|
+
|
|
14
|
+
export interface Printf {
|
|
15
|
+
/** Who fills values in, and with what, as problems name them: `Android`, `String.format`. */
|
|
16
|
+
platform: string;
|
|
17
|
+
formatter: string;
|
|
18
|
+
/** What holds the text, as problems name it: `Android resources`. */
|
|
19
|
+
holder: string;
|
|
20
|
+
/** A string argument's conversion: `s`, or `@` for Apple's objects. */
|
|
21
|
+
string: string;
|
|
22
|
+
/** Text as the file holds it; `formatted` when the string has arguments, so `%` must be doubled. */
|
|
23
|
+
escape: (text: string, formatted: boolean) => string;
|
|
24
|
+
/** The problem with a plural the target can't hold where it is. */
|
|
25
|
+
plural: (name: string) => string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The context and what each argument holds, on one line, for the comment above an entry. */
|
|
29
|
+
export function noteOf(m: Message): string {
|
|
30
|
+
const lines = [
|
|
31
|
+
m.context,
|
|
32
|
+
...[...m.args.keys()].map((a) => (m.argDocs.has(a) ? `${a}: ${m.argDocs.get(a)!.replace(/\.$/, '')}.` : undefined)),
|
|
33
|
+
];
|
|
34
|
+
return docText(lines.filter((l) => l !== undefined && l !== '').join(' '));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What a plural's arms may hold as its count: `#`, or the count as a plain `:number` or `:integer`. */
|
|
38
|
+
function isCount(el: MessageFormatElement, count: string): boolean {
|
|
39
|
+
if (el.type === TYPE.pound) return true;
|
|
40
|
+
if (!isNumberElement(el) || el.value !== count) return false;
|
|
41
|
+
const options = Object.entries(numberOptions(el) ?? {});
|
|
42
|
+
return options.length === 0 || (options.length === 1 && options[0]![0] === 'maximumFractionDigits' && options[0]![1] === 0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `elements` as a format string over `args`; inside a plural, its `count` is `%N$d`. */
|
|
46
|
+
export function formatString(elements: MessageFormatElement[], args: string[], printf: Printf, count?: string): string {
|
|
47
|
+
const formatted = args.length > 0;
|
|
48
|
+
const place = (el: MessageFormatElement, name: string, conversion: string) => {
|
|
49
|
+
const at = args.indexOf(name);
|
|
50
|
+
if (at < 0) throw new Error(`{${name}} is not an argument of this message: say it in the source text too`);
|
|
51
|
+
const { isolate } = placeholderOf(el, name);
|
|
52
|
+
const spec = `%${at + 1}$${conversion}`;
|
|
53
|
+
return isolate === undefined ? spec : `${printf.escape(isolate[0], formatted)}${spec}${printf.escape(isolate[1], formatted)}`;
|
|
54
|
+
};
|
|
55
|
+
let out = '';
|
|
56
|
+
for (const el of elements) {
|
|
57
|
+
if (count !== undefined && isCount(el, count)) {
|
|
58
|
+
out += place(el, count, 'd');
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (isDateElement(el) || isTimeElement(el)) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`{${el.value}} is a date, which ${printf.holder} don't format: pass it formatted, as a plain {${el.value}}, ${LEAVE_OUT}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (isNumberElement(el)) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`{${el.value}} is a formatted number, and ${printf.platform} fills values in with ${printf.formatter}, not Intl: pass it formatted, as a plain {${el.value}}, ${LEAVE_OUT}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
switch (el.type) {
|
|
72
|
+
case TYPE.literal:
|
|
73
|
+
out += printf.escape(el.value, formatted);
|
|
74
|
+
break;
|
|
75
|
+
case TYPE.argument:
|
|
76
|
+
out += place(el, el.value, printf.string);
|
|
77
|
+
break;
|
|
78
|
+
case TYPE.pound:
|
|
79
|
+
// Outside a plural, `#` is text.
|
|
80
|
+
out += '#';
|
|
81
|
+
break;
|
|
82
|
+
case TYPE.select:
|
|
83
|
+
throw new Error(
|
|
84
|
+
`{${el.value}} is a select, which ${printf.holder} can't hold: write a key per case and pick one in code, ${LEAVE_OUT}`,
|
|
85
|
+
);
|
|
86
|
+
case TYPE.plural:
|
|
87
|
+
throw new Error(printf.plural(el.value));
|
|
88
|
+
case TYPE.tag:
|
|
89
|
+
throw new Error(`<${el.value}> is a tag, and ${printf.holder} here hold plain text: say it without the tag, ${LEAVE_OUT}`);
|
|
90
|
+
default:
|
|
91
|
+
throw new Error(`prosebook: no ${printf.platform} writer for ICU element type ${(el as MessageFormatElement).type}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"bundle-KD-DiiFa.d.ts","names":[],"sources":["../src/tools/bundle.ts"],"mappings":";;;KAiBY"}
|