prosebook 0.2.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 +345 -29
- package/dist/bun.d.ts +34 -0
- package/dist/bun.d.ts.map +1 -0
- package/dist/bun.js +98 -0
- package/dist/bun.js.map +1 -0
- package/dist/bundle-CpUIWb9j.d.ts +6 -0
- package/dist/bundle-CpUIWb9j.d.ts.map +1 -0
- package/dist/bundle-DboItAre.js +111 -0
- package/dist/bundle-DboItAre.js.map +1 -0
- package/dist/cli.js +51 -56
- package/dist/cli.js.map +1 -1
- package/dist/compile-ClK9htKr.d.ts +16 -0
- package/dist/compile-ClK9htKr.d.ts.map +1 -0
- package/dist/{config-_4HfnJQu.d.ts → config-CzaJJQ60.d.ts} +75 -2
- package/dist/config-CzaJJQ60.d.ts.map +1 -0
- package/dist/{config-D--RITk2.js → config-nWZf9QmF.js} +42 -212
- package/dist/config-nWZf9QmF.js.map +1 -0
- package/dist/config.d.ts +2 -2
- package/dist/config.js +1 -1
- package/dist/{copy-DXnzXsJa.d.ts → copy-B3V_QRkT.d.ts} +20 -7
- package/dist/copy-B3V_QRkT.d.ts.map +1 -0
- package/dist/copy-Bo_FIoLy.js +154 -0
- package/dist/copy-Bo_FIoLy.js.map +1 -0
- package/dist/endpoints-BgG_5Vpm.js +96 -0
- package/dist/endpoints-BgG_5Vpm.js.map +1 -0
- package/dist/generate-BW3N8EGb.js +5488 -0
- package/dist/generate-BW3N8EGb.js.map +1 -0
- package/dist/helpers-BWGcD0kS.js +96 -0
- package/dist/helpers-BWGcD0kS.js.map +1 -0
- package/dist/icu-Dej0mNsF.js +218 -0
- package/dist/icu-Dej0mNsF.js.map +1 -0
- package/dist/index.d.ts +12 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -188
- package/dist/inspector.d.ts +4 -1
- package/dist/inspector.d.ts.map +1 -1
- package/dist/inspector.js +7 -2
- package/dist/inspector.js.map +1 -1
- package/dist/locale-DpQA1pqH.js +70 -0
- package/dist/locale-DpQA1pqH.js.map +1 -0
- package/dist/next-loader.d.ts +11 -0
- package/dist/next-loader.d.ts.map +1 -0
- package/dist/next-loader.js +17 -0
- package/dist/next-loader.js.map +1 -0
- package/dist/next.d.ts +30 -0
- package/dist/next.d.ts.map +1 -0
- package/dist/next.js +160 -0
- package/dist/next.js.map +1 -0
- package/dist/react/index.d.ts +3 -3
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js.map +1 -1
- package/dist/react/server.d.ts +20 -0
- package/dist/react/server.d.ts.map +1 -0
- package/dist/react/server.js +34 -0
- package/dist/react/server.js.map +1 -0
- package/dist/{sarif-CI8HOokm.js → sarif-B5nrMqrT.js} +103 -87
- package/dist/sarif-B5nrMqrT.js.map +1 -0
- package/dist/scan-Bx0_yDSi.js +387 -0
- package/dist/scan-Bx0_yDSi.js.map +1 -0
- package/dist/tools.d.ts +83 -90
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +5 -4
- package/dist/vite.d.ts +3 -2
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +29 -109
- package/dist/vite.js.map +1 -1
- package/package.json +37 -4
- package/src/bun.ts +132 -0
- package/src/cli.ts +60 -58
- package/src/index.ts +1 -1
- package/src/inspector.ts +8 -3
- package/src/next-loader.ts +16 -0
- package/src/next.ts +188 -0
- package/src/react/index.tsx +6 -6
- package/src/react/server.ts +37 -0
- package/src/runtime/copy.ts +22 -3
- package/src/runtime/helpers.ts +54 -18
- package/src/runtime/locale.ts +19 -0
- package/src/runtime/types.ts +21 -6
- package/src/tools/bundle.ts +131 -0
- package/src/tools/catalog.ts +2 -2
- package/src/tools/compile.ts +60 -23
- package/src/tools/config.ts +57 -0
- package/src/tools/endpoints.ts +121 -0
- package/src/tools/entry.ts +77 -0
- package/src/tools/generate.ts +2 -0
- package/src/tools/icu.ts +63 -7
- package/src/tools/index.ts +6 -3
- package/src/tools/lock.ts +7 -0
- package/src/tools/mf1.ts +21 -7
- package/src/tools/mf2.ts +249 -57
- package/src/tools/migrate.ts +3 -2
- package/src/tools/native/android.ts +201 -0
- package/src/tools/native/apple.ts +139 -0
- package/src/tools/native/emit.ts +87 -0
- package/src/tools/native/index.ts +48 -0
- package/src/tools/native/kotlin.ts +269 -0
- package/src/tools/native/model.ts +292 -0
- package/src/tools/native/number.ts +210 -0
- package/src/tools/native/plurals.ts +103 -0
- package/src/tools/native/printf.ts +95 -0
- package/src/tools/native/rust.ts +367 -0
- package/src/tools/native/swift.ts +326 -0
- package/src/tools/syntax.ts +3 -3
- package/src/tools/todo.ts +118 -0
- package/src/vite.ts +28 -114
- package/dist/config-D--RITk2.js.map +0 -1
- package/dist/config-_4HfnJQu.d.ts.map +0 -1
- package/dist/copy-DXnzXsJa.d.ts.map +0 -1
- package/dist/helpers-BPuPGjQP.js +0 -61
- package/dist/helpers-BPuPGjQP.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/sarif-CI8HOokm.js.map +0 -1
- package/dist/scan-Ch2BfIUD.js +0 -2561
- package/dist/scan-Ch2BfIUD.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
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every native writer shares: words to identifiers, numbers and strings to literals, and the
|
|
3
|
+
* names a file can't give two things.
|
|
4
|
+
*/
|
|
5
|
+
import type { Condition, Operand } from './plurals.ts';
|
|
6
|
+
|
|
7
|
+
/** Words from an identifier written any way: `userName`, `user-name` and `user_name` are user, name. */
|
|
8
|
+
export function wordsOf(name: string): string[] {
|
|
9
|
+
return name
|
|
10
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
11
|
+
.split(/[^A-Za-z0-9]+/)
|
|
12
|
+
.filter(Boolean)
|
|
13
|
+
.map((w) => w.toLowerCase());
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const capital = (w: string) => w.charAt(0).toUpperCase() + w.slice(1);
|
|
17
|
+
|
|
18
|
+
export const snake = (words: string[]) => words.join('_');
|
|
19
|
+
export const pascal = (words: string[]) => words.map(capital).join('');
|
|
20
|
+
export const camel = (words: string[]) => words.map((w, i) => (i === 0 ? w : capital(w))).join('');
|
|
21
|
+
export const upper = (words: string[]) => words.join('_').toUpperCase();
|
|
22
|
+
|
|
23
|
+
/** An identifier can't start with a digit, so `404.title` is `_404Title`. */
|
|
24
|
+
export function leading(name: string): string {
|
|
25
|
+
return /^[0-9]/.test(name) ? `_${name}` : name;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A double literal every target reads as one: `0.0`, `1.5`, `1e21`. */
|
|
29
|
+
export function float(value: number): string {
|
|
30
|
+
const s = String(value).replace('e+', 'e');
|
|
31
|
+
return /[.e]/.test(s) ? s : `${s}.0`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Characters that print nothing, or look like a space, written as escapes so the code shows them. */
|
|
35
|
+
const INVISIBLE = /[\u{a0}\u{61c}\u{180e}\u{2000}-\u{200f}\u{2028}-\u{202f}\u{205f}-\u{206f}\u{feff}]/gu;
|
|
36
|
+
|
|
37
|
+
/** Text for a doc comment, with what prints nothing written as `\u{…}`: Rust refuses direction marks there. */
|
|
38
|
+
export function docText(line: string): string {
|
|
39
|
+
return line.replace(INVISIBLE, (c) => `\\u{${c.charCodeAt(0).toString(16)}}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A string literal: JSON's escapes, `\u{…}` where the target writes them so (Rust, Swift), and `$` escaped in Kotlin. */
|
|
43
|
+
export function stringLiteral(value: string, language: 'rust' | 'swift' | 'kotlin'): string {
|
|
44
|
+
const json = JSON.stringify(value).replace(INVISIBLE, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
45
|
+
if (language === 'kotlin') return json.replaceAll('$', '\\$');
|
|
46
|
+
return json.replace(/\\u([0-9a-f]{4})/g, (_, hex: string) => `\\u{${hex.replace(/^0+/, '')}}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A plural condition as an expression over operands `o`: `field(op)` reads one, `mod` takes a
|
|
51
|
+
* remainder, `within` tests a range as CLDR does (integers only). `&&`, `||` and `!` are every
|
|
52
|
+
* target's.
|
|
53
|
+
*/
|
|
54
|
+
export function conditionCode(
|
|
55
|
+
condition: Condition,
|
|
56
|
+
code: {
|
|
57
|
+
field: (op: Operand) => string;
|
|
58
|
+
mod: (value: string, by: string) => string;
|
|
59
|
+
within: (value: string, from: string, to: string) => string;
|
|
60
|
+
},
|
|
61
|
+
): string {
|
|
62
|
+
const ors = condition.map((and) =>
|
|
63
|
+
and
|
|
64
|
+
.map((r) => {
|
|
65
|
+
const value = r.mod === undefined ? code.field(r.operand) : code.mod(code.field(r.operand), float(r.mod));
|
|
66
|
+
if (r.ranges.length === 1 && r.ranges[0]![0] === r.ranges[0]![1])
|
|
67
|
+
return `${value} ${r.negated ? '!=' : '=='} ${float(r.ranges[0]![0])}`;
|
|
68
|
+
const any = r.ranges
|
|
69
|
+
.map(([from, to]) => (from === to ? `${value} == ${float(from)}` : code.within(value, float(from), float(to))))
|
|
70
|
+
.join(' || ');
|
|
71
|
+
return r.negated ? `!(${any})` : r.ranges.length > 1 ? `(${any})` : any;
|
|
72
|
+
})
|
|
73
|
+
.join(' && '),
|
|
74
|
+
);
|
|
75
|
+
return ors.length > 1 ? ors.map((o) => (o.includes(' && ') ? `(${o})` : o)).join(' || ') : ors[0]!;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Fails with the keys whose names meet, or meet a name the file already has. */
|
|
79
|
+
export function checkNames(language: string, named: [key: string, name: string][], taken: string[]): void {
|
|
80
|
+
const seen = new Map<string, string>(taken.map((t) => [t, 'a name the file already has']));
|
|
81
|
+
for (const [key, name] of named) {
|
|
82
|
+
const other = seen.get(name);
|
|
83
|
+
if (other !== undefined)
|
|
84
|
+
throw new Error(`${language}: ${key} is named ${name}, as ${other} is: rename one, or change the target's keys`);
|
|
85
|
+
seen.set(name, key);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
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.
|
|
5
|
+
*/
|
|
6
|
+
import { CatalogError, type Catalog, type Problem } from '#src/tools/catalog.ts';
|
|
7
|
+
import type { Entry } from '#src/tools/compile.ts';
|
|
8
|
+
import type { ResolvedConfig } from '#src/tools/config.ts';
|
|
9
|
+
import { androidResources } from './android.ts';
|
|
10
|
+
import { appleStrings } from './apple.ts';
|
|
11
|
+
import { kotlinFile } from './kotlin.ts';
|
|
12
|
+
import { nativeModel } from './model.ts';
|
|
13
|
+
import { rustModule } from './rust.ts';
|
|
14
|
+
import { swiftFile } from './swift.ts';
|
|
15
|
+
|
|
16
|
+
const WRITERS = { rust: rustModule, swift: swiftFile, kotlin: kotlinFile };
|
|
17
|
+
|
|
18
|
+
export function nativeFiles(
|
|
19
|
+
config: ResolvedConfig,
|
|
20
|
+
catalog: Catalog,
|
|
21
|
+
locales: string[],
|
|
22
|
+
entries: Map<string, Entry[]>,
|
|
23
|
+
banner: string,
|
|
24
|
+
): Map<string, string> {
|
|
25
|
+
const files = new Map<string, string>();
|
|
26
|
+
const problems: Problem[] = [];
|
|
27
|
+
for (const target of config.native) {
|
|
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;
|
|
41
|
+
files.set(target.out, WRITERS[target.language](model, target, banner));
|
|
42
|
+
} catch (e) {
|
|
43
|
+
problems.push({ severity: 'error', message: (e as Error).message });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (problems.length > 0) throw new CatalogError(problems);
|
|
47
|
+
return files;
|
|
48
|
+
}
|