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,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model as a Kotlin file: an enum class of locales with a property or function per message, and
|
|
3
|
+
* a file-private object holding what formats them. For the JVM and Android.
|
|
4
|
+
*/
|
|
5
|
+
import type { NativeTarget } from '#src/tools/config.ts';
|
|
6
|
+
import { camel, checkNames, conditionCode, float, leading, stringLiteral, upper, wordsOf } from './emit.ts';
|
|
7
|
+
import type { NativeMessage, NativeModel, Part } from './model.ts';
|
|
8
|
+
import type { NumberSpec } from './number.ts';
|
|
9
|
+
|
|
10
|
+
const KEYWORDS = new Set(
|
|
11
|
+
'as break class continue do else false for fun if in interface is null object package return super this throw true try typealias typeof val var when while'.split(
|
|
12
|
+
' ',
|
|
13
|
+
),
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const ident = (name: string) => (KEYWORDS.has(name) ? `\`${name}\`` : name);
|
|
17
|
+
const lit = (value: string) => stringLiteral(value, 'kotlin');
|
|
18
|
+
const entry = (tag: string) => leading(upper(wordsOf(tag)));
|
|
19
|
+
const argName = (name: string) => ident(leading(camel(wordsOf(name))));
|
|
20
|
+
|
|
21
|
+
function doc(lines: string[], indent: string): string {
|
|
22
|
+
const safe = lines.map((l) => l.replaceAll('*/', '*\\/'));
|
|
23
|
+
return `${indent}/**\n${safe.map((l) => (l === '' ? `${indent} *` : `${indent} * ${l}`)).join('\n')}\n${indent} */\n`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The parts as an expression of type `String`, with `rt` the runtime object. */
|
|
27
|
+
function expr(parts: Part[], indent: string, rt: string): string {
|
|
28
|
+
if (parts.length === 0) return '""';
|
|
29
|
+
return parts.map((p) => (p.type === 'text' ? lit(p.value) : value(p, indent, rt))).join(' + ');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function value(part: Exclude<Part, { type: 'text' }>, indent: string, rt: string): string {
|
|
33
|
+
const inner = `${indent} `;
|
|
34
|
+
if (part.type === 'string') return argName(part.arg);
|
|
35
|
+
if (part.type === 'number') {
|
|
36
|
+
const of = part.offset === 0 ? argName(part.arg) : `${argName(part.arg)} - ${float(part.offset)}`;
|
|
37
|
+
return `${rt}.number(${of}, ${rt}.F${part.format}, ${lit(part.fallback)})`;
|
|
38
|
+
}
|
|
39
|
+
if (part.type === 'select') {
|
|
40
|
+
const arms = part.arms.map((a) => `${inner}${lit(a.key)} -> ${expr(a.parts, inner, rt)}\n`).join('');
|
|
41
|
+
return `when (${argName(part.arg)}) {\n${arms}${inner}else -> ${expr(part.other, inner, rt)}\n${indent}}`;
|
|
42
|
+
}
|
|
43
|
+
// A plural, the one kind left. With only `other`, there is nothing to select on.
|
|
44
|
+
if (part.arms.length + part.exact.length === 0) return expr(part.other, indent, rt);
|
|
45
|
+
const arg = argName(part.arg);
|
|
46
|
+
const on = part.on === 'round' ? `${rt}.round(${arg})` : part.on === 'percent' ? `${arg} * 100.0` : arg;
|
|
47
|
+
const { minFrac, maxFrac } = part.rounding;
|
|
48
|
+
const exact = part.exact.map((e) => `${float(e.value)} to ${lit(`=${e.value}`)}`).join(', ');
|
|
49
|
+
const arms = [...part.exact.map((e) => ({ key: `=${e.value}`, parts: e.parts })), ...part.arms]
|
|
50
|
+
.map((a) => `${inner}${lit(a.key)} -> ${expr(a.parts, inner, rt)}\n`)
|
|
51
|
+
.join('');
|
|
52
|
+
const pick = `${rt}.plural(${on}, ${float(part.offset)}, listOf(${exact}), ${rt}::r${part.rules}, ${minFrac}, ${maxFrac})`;
|
|
53
|
+
return `when (${pick}) {\n${arms}${inner}else -> ${expr(part.other, inner, rt)}\n${indent}}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function member(m: NativeMessage, name: string, rt: string): string {
|
|
57
|
+
const branches = m.bodies.map((b) => ` ${b.locales.map(entry).join(', ')} -> ${expr(b.parts, ' ', rt)}\n`).join('');
|
|
58
|
+
const argDocs = m.args.filter((a) => a.doc !== undefined).map((a) => `@param ${argName(a.name)} ${a.doc}`);
|
|
59
|
+
const head = doc(argDocs.length > 0 ? [...m.doc, '', ...argDocs] : m.doc, ' ');
|
|
60
|
+
const body = `when (this) {\n${branches} }`;
|
|
61
|
+
if (m.args.length === 0) return `${head} val ${name}: String\n get() = ${body}\n`;
|
|
62
|
+
const params = m.args.map((a) => `${argName(a.name)}: ${a.type === 'number' ? 'Number' : 'String'}`).join(', ');
|
|
63
|
+
const numbers = m.args
|
|
64
|
+
.filter((a) => a.type === 'number')
|
|
65
|
+
.map((a) => ` val ${argName(a.name)} = ${argName(a.name)}.toDouble()\n`)
|
|
66
|
+
.join('');
|
|
67
|
+
return `${head} fun ${name}(${params}): String {\n${numbers} return ${body}\n }\n`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function formatConst(spec: NumberSpec, i: number): string {
|
|
71
|
+
const affix = ([prefix, suffix]: readonly [string, string]) => `${lit(prefix)} to ${lit(suffix)}`;
|
|
72
|
+
const fields = [
|
|
73
|
+
spec.minInt,
|
|
74
|
+
spec.minFrac,
|
|
75
|
+
spec.maxFrac,
|
|
76
|
+
spec.shift,
|
|
77
|
+
spec.grouping.primary,
|
|
78
|
+
spec.grouping.secondary,
|
|
79
|
+
spec.grouping.min,
|
|
80
|
+
lit(spec.decimal),
|
|
81
|
+
lit(spec.group),
|
|
82
|
+
spec.digits === undefined ? 'null' : `listOf(${spec.digits.map(lit).join(', ')})`,
|
|
83
|
+
affix(spec.positive),
|
|
84
|
+
affix(spec.negative),
|
|
85
|
+
affix(spec.zero),
|
|
86
|
+
affix(spec.negativeZero),
|
|
87
|
+
lit(spec.infinity),
|
|
88
|
+
lit(spec.negativeInfinity),
|
|
89
|
+
];
|
|
90
|
+
return ` val F${i} = Format(\n${fields.map((f) => ` ${f}`).join(',\n')},\n )\n`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const NUMBER = `
|
|
94
|
+
/** A number format, taken apart when the file was generated. */
|
|
95
|
+
class Format(
|
|
96
|
+
val minInt: Int,
|
|
97
|
+
val minFrac: Int,
|
|
98
|
+
val maxFrac: Int,
|
|
99
|
+
val shift: Int,
|
|
100
|
+
val primary: Int,
|
|
101
|
+
val secondary: Int,
|
|
102
|
+
val minGroup: Int,
|
|
103
|
+
val decimal: String,
|
|
104
|
+
val group: String,
|
|
105
|
+
val digits: List<String>?,
|
|
106
|
+
val positive: Pair<String, String>,
|
|
107
|
+
val negative: Pair<String, String>,
|
|
108
|
+
val zero: Pair<String, String>,
|
|
109
|
+
val negativeZero: Pair<String, String>,
|
|
110
|
+
val infinity: String,
|
|
111
|
+
val negativeInfinity: String,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
/** \`value\` as \`format\` writes it, or \`fallback\` where it isn't a number. */
|
|
115
|
+
fun number(value: Double, format: Format, fallback: String): String {
|
|
116
|
+
if (value.isNaN()) return fallback
|
|
117
|
+
if (value.isInfinite()) return if (value > 0) format.infinity else format.negativeInfinity
|
|
118
|
+
val (written, frac) = digits(Math.abs(value), format.shift, format.minFrac, format.maxFrac)
|
|
119
|
+
val zero = (written + frac).all { it == '0' }
|
|
120
|
+
val negative = value.toRawBits() < 0
|
|
121
|
+
val (prefix, suffix) = if (zero) (if (negative) format.negativeZero else format.zero) else (if (negative) format.negative else format.positive)
|
|
122
|
+
val int = written.padStart(maxOf(format.minInt, 1), '0')
|
|
123
|
+
val grouped = format.minGroup > 0 && int.length >= format.primary + format.minGroup
|
|
124
|
+
val out = StringBuilder(prefix)
|
|
125
|
+
for ((i, digit) in int.withIndex()) {
|
|
126
|
+
val left = int.length - i
|
|
127
|
+
if (grouped && i > 0 && (left == format.primary || (left > format.primary && (left - format.primary) % format.secondary == 0))) {
|
|
128
|
+
out.append(format.group)
|
|
129
|
+
}
|
|
130
|
+
out.append(shown(digit, format))
|
|
131
|
+
}
|
|
132
|
+
if (frac.isNotEmpty()) {
|
|
133
|
+
out.append(format.decimal)
|
|
134
|
+
for (digit in frac) out.append(shown(digit, format))
|
|
135
|
+
}
|
|
136
|
+
return out.append(suffix).toString()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private fun shown(digit: Char, format: Format): String = format.digits?.get(digit - '0') ?: digit.toString()
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The shortest digits that read back as \`value\`, as \`Intl\` rounds them: moved \`shift\` places
|
|
143
|
+
* right, rounded half away from zero to \`max\` fraction digits, and trailing zeros cut to \`min\`.
|
|
144
|
+
*/
|
|
145
|
+
fun digits(value: Double, shift: Int, min: Int, max: Int): Pair<String, String> {
|
|
146
|
+
// \`toString\` writes the shortest digits that read back, with an exponent past a size.
|
|
147
|
+
val plain = java.math.BigDecimal(value.toString()).toPlainString()
|
|
148
|
+
val int = plain.substringBefore('.')
|
|
149
|
+
val all = (int + plain.substringAfter('.', "")).map { it - '0' }.toMutableList()
|
|
150
|
+
var point = int.length + shift
|
|
151
|
+
while (all.size <= point + max) all.add(0)
|
|
152
|
+
val next = all[point + max]
|
|
153
|
+
while (all.size > point + max) all.removeAt(all.size - 1)
|
|
154
|
+
if (next >= 5) {
|
|
155
|
+
var at = all.size - 1
|
|
156
|
+
while (at >= 0 && all[at] == 9) all[at--] = 0
|
|
157
|
+
if (at < 0) {
|
|
158
|
+
all.add(0, 1)
|
|
159
|
+
point += 1
|
|
160
|
+
} else {
|
|
161
|
+
all[at] += 1
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
val frac = all.subList(point, all.size).toMutableList()
|
|
165
|
+
while (frac.size > min && frac.last() == 0) frac.removeAt(frac.size - 1)
|
|
166
|
+
return all.subList(0, point).dropWhile { it == 0 }.joinToString("") to frac.joinToString("")
|
|
167
|
+
}
|
|
168
|
+
`;
|
|
169
|
+
|
|
170
|
+
const PLURAL = `
|
|
171
|
+
/** CLDR's plural operands of a number as written. */
|
|
172
|
+
class Operands(val n: Double, val i: Double, val v: Double, val w: Double, val f: Double, val t: Double, val c: Double, val e: Double)
|
|
173
|
+
|
|
174
|
+
/** The arm a plural takes: the \`=N\` that \`value\` equals, else the category of \`value\` less \`offset\`. */
|
|
175
|
+
fun plural(value: Double, offset: Double, exact: List<Pair<Double, String>>, rules: (Operands) -> String, min: Int, max: Int): String =
|
|
176
|
+
exact.firstOrNull { it.first == value }?.second ?: category(rules, value - offset, min, max)
|
|
177
|
+
|
|
178
|
+
/** The category \`rules\` give \`value\`, rounded to between \`min\` and \`max\` fraction digits. */
|
|
179
|
+
private fun category(rules: (Operands) -> String, value: Double, min: Int, max: Int): String {
|
|
180
|
+
if (!value.isFinite()) return "other"
|
|
181
|
+
val (written, frac) = digits(Math.abs(value), 0, min, max)
|
|
182
|
+
val int = written.ifEmpty { "0" }
|
|
183
|
+
val trimmed = frac.trimEnd('0')
|
|
184
|
+
val read = { s: String -> s.toDoubleOrNull() ?: 0.0 }
|
|
185
|
+
return rules(
|
|
186
|
+
Operands(
|
|
187
|
+
n = read(if (frac.isEmpty()) int else "$int.$frac"),
|
|
188
|
+
i = read(int),
|
|
189
|
+
v = frac.length.toDouble(),
|
|
190
|
+
w = trimmed.length.toDouble(),
|
|
191
|
+
f = read(frac),
|
|
192
|
+
t = read(trimmed),
|
|
193
|
+
c = 0.0,
|
|
194
|
+
e = 0.0,
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** An integer in the range, as CLDR's ranges hold. */
|
|
200
|
+
private fun within(value: Double, from: Double, to: Double): Boolean = value % 1.0 == 0.0 && value >= from && value <= to
|
|
201
|
+
|
|
202
|
+
/** \`Math.round\`: half up, toward positive infinity. */
|
|
203
|
+
fun round(value: Double): Double {
|
|
204
|
+
val floor = Math.floor(value)
|
|
205
|
+
return if (value - floor >= 0.5) floor + 1 else floor
|
|
206
|
+
}
|
|
207
|
+
`;
|
|
208
|
+
|
|
209
|
+
export function kotlinFile(model: NativeModel, target: NativeTarget & { name: string }, banner: string): string {
|
|
210
|
+
const name = target.name;
|
|
211
|
+
const rt = `${name}Runtime`;
|
|
212
|
+
const names = model.messages.map((m): [string, string] => [m.key, ident(leading(camel(m.words)))]);
|
|
213
|
+
checkNames('kotlin', names, ['tag', 'name', 'ordinal', 'entries', 'negotiate', 'SOURCE']);
|
|
214
|
+
const entries = model.locales
|
|
215
|
+
.map((l) => ` /** \`${l}\`${l === model.source ? ', the source' : ''}. */\n ${entry(l)}(${lit(l)})`)
|
|
216
|
+
.join(',\n');
|
|
217
|
+
const members = model.messages.map((m, i) => member(m, names[i]![1], rt)).join('\n');
|
|
218
|
+
const rules = model.rules
|
|
219
|
+
.map(({ name: rule, rules }, i) => {
|
|
220
|
+
const checks = rules.map(
|
|
221
|
+
([category, condition]) =>
|
|
222
|
+
` if (${conditionCode(condition, { field: (op) => `o.${op}`, mod: (v, by) => `${v} % ${by}`, within: (v, from, to) => `within(${v}, ${from}, ${to})` })}) return ${lit(category)}\n`,
|
|
223
|
+
);
|
|
224
|
+
return ` /** ${rule} */\n fun r${i}(o: Operands): String {\n${checks.join('')} return "other"\n }\n`;
|
|
225
|
+
})
|
|
226
|
+
.join('\n');
|
|
227
|
+
const runtime =
|
|
228
|
+
model.formats.length + model.rules.length === 0
|
|
229
|
+
? ''
|
|
230
|
+
: `
|
|
231
|
+
/** What formats the messages, generated with them. */
|
|
232
|
+
private object ${rt} {${NUMBER}${model.rules.length > 0 ? PLURAL : ''}
|
|
233
|
+
${model.formats.map(formatConst).join('\n')}${rules === '' ? '' : `\n${rules}`}}
|
|
234
|
+
`;
|
|
235
|
+
|
|
236
|
+
return `${banner}
|
|
237
|
+
@file:Suppress("unused", "NAME_SHADOWING", "KotlinConstantConditions", "ConvertTwoComparisonsToRangeCheck")
|
|
238
|
+
${target.package === undefined ? '' : `\npackage ${target.package}\n`}
|
|
239
|
+
/** A locale the catalog is written or translated in, and its messages. */
|
|
240
|
+
enum class ${name}(val tag: String) {
|
|
241
|
+
${entries};
|
|
242
|
+
|
|
243
|
+
${members}
|
|
244
|
+
companion object {
|
|
245
|
+
/** The locale the catalog is written in, which every message falls back to. */
|
|
246
|
+
val SOURCE: ${name} = ${entry(model.source)}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The best locale for what was asked, most preferred first: each tag tries an exact match, then
|
|
250
|
+
* its parents (\`pt-BR\` finds \`pt\`), then any locale of its language. The source if none is.
|
|
251
|
+
*/
|
|
252
|
+
fun negotiate(requested: List<String>): ${name} {
|
|
253
|
+
fun language(tag: String) = tag.substringBefore('-').lowercase()
|
|
254
|
+
for (want in requested) {
|
|
255
|
+
var tag = want
|
|
256
|
+
while (true) {
|
|
257
|
+
entries.firstOrNull { it.tag.equals(tag, ignoreCase = true) }?.let { return it }
|
|
258
|
+
val cut = tag.lastIndexOf('-')
|
|
259
|
+
if (cut < 0) break
|
|
260
|
+
tag = tag.substring(0, cut)
|
|
261
|
+
}
|
|
262
|
+
entries.firstOrNull { language(it.tag) == language(want) }?.let { return it }
|
|
263
|
+
}
|
|
264
|
+
return SOURCE
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
${runtime}`;
|
|
269
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The catalog as a native target writes it, before any language: per message, its arguments and one
|
|
3
|
+
* tree per locale it renders in, with every fallback resolved here, so the generated code has no
|
|
4
|
+
* lookup to do. Number formats and plural rules are read from `Intl` and CLDR once, and numbered.
|
|
5
|
+
*/
|
|
6
|
+
import { isDateElement, isNumberElement, isTimeElement } from '@formatjs/icu-messageformat-parser';
|
|
7
|
+
|
|
8
|
+
import { chainOf } from '#src/runtime/locale.ts';
|
|
9
|
+
import type { Catalog, Message, Problem } from '#src/tools/catalog.ts';
|
|
10
|
+
import type { Entry } from '#src/tools/compile.ts';
|
|
11
|
+
import type { NativeTarget } from '#src/tools/config.ts';
|
|
12
|
+
import { numberOptions, optionArgsOf, placeholderOf, selectionOf, TYPE, type MessageFormatElement } from '#src/tools/icu.ts';
|
|
13
|
+
import { docText } from './emit.ts';
|
|
14
|
+
import { numberSpec, roundingOf, type NumberSpec, type Rounding } from './number.ts';
|
|
15
|
+
import { pluralRules, type PluralRules } from './plurals.ts';
|
|
16
|
+
|
|
17
|
+
export type Part =
|
|
18
|
+
| { type: 'text'; value: string }
|
|
19
|
+
| { type: 'string'; arg: string }
|
|
20
|
+
/** The argument less `offset`, in format `format`; `fallback` where it isn't a number. */
|
|
21
|
+
| { type: 'number'; arg: string; offset: number; format: number; fallback: string }
|
|
22
|
+
| { type: 'select'; arg: string; arms: Arm[]; other: Part[] }
|
|
23
|
+
| {
|
|
24
|
+
type: 'plural';
|
|
25
|
+
arg: string;
|
|
26
|
+
/** What it selects on: the value, rounded as `Math.round` does (MF2's `:integer`), or as a percent. */
|
|
27
|
+
on: 'value' | 'round' | 'percent';
|
|
28
|
+
offset: number;
|
|
29
|
+
/** `=N` arms, matched on the value before its offset. */
|
|
30
|
+
exact: { value: number; parts: Part[] }[];
|
|
31
|
+
rules: number;
|
|
32
|
+
rounding: Rounding;
|
|
33
|
+
arms: Arm[];
|
|
34
|
+
other: Part[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export interface Arm {
|
|
38
|
+
key: string;
|
|
39
|
+
parts: Part[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface NativeMessage {
|
|
43
|
+
key: string;
|
|
44
|
+
/** The key less the prefix that took it in, as words: `tray.quit-app` is tray, quit, app. */
|
|
45
|
+
words: string[];
|
|
46
|
+
/** The source text, its context, and what's asked of it. */
|
|
47
|
+
doc: string[];
|
|
48
|
+
args: { name: string; type: 'string' | 'number'; doc?: string }[];
|
|
49
|
+
/** A tree per locale that renders it its own way; every locale is in one. */
|
|
50
|
+
bodies: { locales: string[]; parts: Part[] }[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface NativeModel {
|
|
54
|
+
/** Every locale, the source first. */
|
|
55
|
+
locales: string[];
|
|
56
|
+
source: string;
|
|
57
|
+
messages: NativeMessage[];
|
|
58
|
+
formats: NumberSpec[];
|
|
59
|
+
rules: { name: string; rules: PluralRules }[];
|
|
60
|
+
}
|
|
61
|
+
|
|
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));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Values a file declares once each, numbered by what they hold, so locales that format alike share one. */
|
|
85
|
+
class Numbered<T> {
|
|
86
|
+
readonly list: T[] = [];
|
|
87
|
+
private readonly byJson = new Map<string, number>();
|
|
88
|
+
|
|
89
|
+
add(value: T, json = JSON.stringify(value)): number {
|
|
90
|
+
let at = this.byJson.get(json);
|
|
91
|
+
if (at === undefined) {
|
|
92
|
+
this.byJson.set(json, (at = this.list.length));
|
|
93
|
+
this.list.push(value);
|
|
94
|
+
}
|
|
95
|
+
return at;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The model of what `target` takes from the catalog, with a problem for every message it can't render. */
|
|
100
|
+
export function nativeModel(
|
|
101
|
+
target: NativeTarget,
|
|
102
|
+
catalog: Catalog,
|
|
103
|
+
locales: string[],
|
|
104
|
+
entries: Map<string, Entry[]>,
|
|
105
|
+
): { model: NativeModel; problems: Problem[] } {
|
|
106
|
+
const source = locales[0]!;
|
|
107
|
+
const asts = new Map([...entries].map(([locale, list]) => [locale, new Map(list)]));
|
|
108
|
+
const formats = new Numbered<NumberSpec>();
|
|
109
|
+
const rules = new Numbered<{ name: string; rules: PluralRules }>();
|
|
110
|
+
const problems: Problem[] = [];
|
|
111
|
+
const messages: NativeMessage[] = [];
|
|
112
|
+
|
|
113
|
+
for (const m of catalog.messages) {
|
|
114
|
+
const words = wordsFor(target, m.key);
|
|
115
|
+
if (words === undefined) continue;
|
|
116
|
+
const bodies = new Map<string, { locales: string[]; parts: Part[] }>();
|
|
117
|
+
let failed = false;
|
|
118
|
+
for (const locale of locales) {
|
|
119
|
+
const found = foundIn(m.key, locale, locales, asts);
|
|
120
|
+
if (found === undefined) continue;
|
|
121
|
+
const body = bodies.get(found);
|
|
122
|
+
if (body !== undefined) {
|
|
123
|
+
body.locales.push(locale);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
bodies.set(found, { locales: [locale], parts: parts(asts.get(found)!.get(m.key)!, found, formats, rules, null) });
|
|
128
|
+
} catch (e) {
|
|
129
|
+
failed = true;
|
|
130
|
+
problems.push({
|
|
131
|
+
severity: 'error',
|
|
132
|
+
file: fileOf(m, found, source),
|
|
133
|
+
key: m.key,
|
|
134
|
+
message: `${target.language}: ${(e as Error).message}`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (failed) continue;
|
|
139
|
+
messages.push({
|
|
140
|
+
key: m.key,
|
|
141
|
+
words,
|
|
142
|
+
doc: docOf(m),
|
|
143
|
+
args: [...m.args].map(([name, type]) => ({
|
|
144
|
+
name,
|
|
145
|
+
type: type === 'number' ? 'number' : 'string',
|
|
146
|
+
doc: m.argDocs.has(name) ? docText(m.argDocs.get(name)!) : undefined,
|
|
147
|
+
})),
|
|
148
|
+
bodies: [...bodies.values()],
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return { model: { locales, source, messages, formats: formats.list, rules: rules.list }, problems };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function fileOf(m: Message, locale: string, source: string): string {
|
|
155
|
+
return locale === source ? m.file : (m.translations.get(locale)?.file ?? m.file);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function docOf(m: Message): string[] {
|
|
159
|
+
const lines = (m.text.length > 400 ? `${m.text.slice(0, 397)}...` : m.text).split('\n');
|
|
160
|
+
if (m.context) lines.push('', m.context);
|
|
161
|
+
if (m.remove) lines.push('', 'Marked for removal in the catalog.');
|
|
162
|
+
if (m.comment) lines.push('', `Comment: ${m.comment}`);
|
|
163
|
+
return lines.map(docText);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const LEAVE_OUT = "or leave the key out of this target's `keys`";
|
|
167
|
+
|
|
168
|
+
/** Specs by locale and options, kept across runs: taking a format apart checks it against `Intl` dozens of times. */
|
|
169
|
+
const SPECS = new Map<string, NumberSpec | Error>();
|
|
170
|
+
|
|
171
|
+
function format(formats: Numbered<NumberSpec>, locale: string, options: Intl.NumberFormatOptions | undefined): number {
|
|
172
|
+
const key = JSON.stringify([locale, options ?? {}]);
|
|
173
|
+
let spec = SPECS.get(key);
|
|
174
|
+
if (spec === undefined) {
|
|
175
|
+
try {
|
|
176
|
+
spec = numberSpec(locale, options);
|
|
177
|
+
} catch (e) {
|
|
178
|
+
spec = new Error(`native code can't format this number in ${locale}: ${(e as Error).message}. Format it another way, ${LEAVE_OUT}`, {
|
|
179
|
+
cause: e,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
SPECS.set(key, spec);
|
|
183
|
+
}
|
|
184
|
+
if (spec instanceof Error) throw spec;
|
|
185
|
+
return formats.add(spec);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
type Pound = { arg: string; offset: number } | null;
|
|
189
|
+
|
|
190
|
+
function parts(
|
|
191
|
+
elements: MessageFormatElement[],
|
|
192
|
+
locale: string,
|
|
193
|
+
formats: Numbered<NumberSpec>,
|
|
194
|
+
rules: Numbered<{ name: string; rules: PluralRules }>,
|
|
195
|
+
pound: Pound,
|
|
196
|
+
): Part[] {
|
|
197
|
+
const out: Part[] = [];
|
|
198
|
+
const text = (value: string) => {
|
|
199
|
+
const last = out.at(-1);
|
|
200
|
+
if (last?.type === 'text') last.value += value;
|
|
201
|
+
else if (value !== '') out.push({ type: 'text', value });
|
|
202
|
+
};
|
|
203
|
+
const placed = (el: MessageFormatElement, name: string, part: Part) => {
|
|
204
|
+
const { isolate } = placeholderOf(el, name);
|
|
205
|
+
if (isolate !== undefined) text(isolate[0]);
|
|
206
|
+
out.push(part);
|
|
207
|
+
if (isolate !== undefined) text(isolate[1]);
|
|
208
|
+
};
|
|
209
|
+
const arms = (options: Record<string, { value: MessageFormatElement[] }>, own: Pound) =>
|
|
210
|
+
Object.entries(options).map(([key, option]) => ({ key, parts: parts(option.value, locale, formats, rules, own) }));
|
|
211
|
+
|
|
212
|
+
for (const el of elements) {
|
|
213
|
+
if (Object.keys(optionArgsOf(el) ?? {}).length > 0) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`{${'value' in el ? el.value : ''}} takes a formatting option from an argument: native code formats with options the message fixes, ${LEAVE_OUT}`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (isDateElement(el) || isTimeElement(el)) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`{${el.value}} is a date, which native code doesn't format: pass it formatted, as a plain {${el.value}}, ${LEAVE_OUT}`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
if (isNumberElement(el)) {
|
|
224
|
+
const fallback = placeholderOf(el, el.value).fallback;
|
|
225
|
+
placed(el, el.value, { type: 'number', arg: el.value, offset: 0, format: format(formats, locale, numberOptions(el)), fallback });
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
switch (el.type) {
|
|
229
|
+
case TYPE.literal:
|
|
230
|
+
text(el.value);
|
|
231
|
+
break;
|
|
232
|
+
case TYPE.argument:
|
|
233
|
+
placed(el, el.value, { type: 'string', arg: el.value });
|
|
234
|
+
break;
|
|
235
|
+
case TYPE.pound:
|
|
236
|
+
if (pound === null) {
|
|
237
|
+
text('#');
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
placed(el, pound.arg, {
|
|
241
|
+
type: 'number',
|
|
242
|
+
arg: pound.arg,
|
|
243
|
+
offset: pound.offset,
|
|
244
|
+
format: format(formats, locale, undefined),
|
|
245
|
+
fallback: placeholderOf(el, pound.arg).fallback,
|
|
246
|
+
});
|
|
247
|
+
break;
|
|
248
|
+
case TYPE.select: {
|
|
249
|
+
const all = arms(el.options, pound);
|
|
250
|
+
out.push({
|
|
251
|
+
type: 'select',
|
|
252
|
+
arg: el.value,
|
|
253
|
+
arms: all.filter((a) => a.key !== 'other'),
|
|
254
|
+
other: all.find((a) => a.key === 'other')!.parts,
|
|
255
|
+
});
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
case TYPE.plural: {
|
|
259
|
+
const selection = selectionOf(el);
|
|
260
|
+
const type = el.pluralType === 'ordinal' ? 'ordinal' : 'cardinal';
|
|
261
|
+
const all = arms(el.options, { arg: el.value, offset: el.offset });
|
|
262
|
+
let rounding: Rounding;
|
|
263
|
+
try {
|
|
264
|
+
rounding = roundingOf(locale, { ...selection?.options, type });
|
|
265
|
+
} catch (e) {
|
|
266
|
+
throw new Error(`{${el.value}} can't select in native code: ${(e as Error).message}, ${LEAVE_OUT}`, { cause: e });
|
|
267
|
+
}
|
|
268
|
+
const found = pluralRules(locale, type);
|
|
269
|
+
out.push({
|
|
270
|
+
type: 'plural',
|
|
271
|
+
arg: el.value,
|
|
272
|
+
on: selection?.value ?? 'value',
|
|
273
|
+
offset: el.offset,
|
|
274
|
+
// Matched as the runtime does, by the value's own `String()`, so `=1.50` never matches.
|
|
275
|
+
exact: all
|
|
276
|
+
.filter((a) => a.key.startsWith('=') && String(Number(a.key.slice(1))) === a.key.slice(1))
|
|
277
|
+
.map((a) => ({ value: Number(a.key.slice(1)), parts: a.parts })),
|
|
278
|
+
rules: rules.add({ name: `${type}, as ${locale} has them`, rules: found }, JSON.stringify(found)),
|
|
279
|
+
rounding,
|
|
280
|
+
arms: all.filter((a) => !a.key.startsWith('=') && a.key !== 'other'),
|
|
281
|
+
other: all.find((a) => a.key === 'other')!.parts,
|
|
282
|
+
});
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
case TYPE.tag:
|
|
286
|
+
throw new Error(`<${el.value}> is a tag, and native code writes plain text: say it without the tag, ${LEAVE_OUT}`);
|
|
287
|
+
default:
|
|
288
|
+
throw new Error(`prosebook: no native writer for ICU element type ${(el as MessageFormatElement).type}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return out;
|
|
292
|
+
}
|