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,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A number format taken apart at build time, so native code can put it back together with no
|
|
3
|
+
* formatter of its own: the digits to round to, the symbols, how integers group, and the text around
|
|
4
|
+
* the number for each sign. Read from `Intl.NumberFormat` and checked against it before any code is
|
|
5
|
+
* written, so a native target says what the browser says, or the build says why it can't.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The text before and after the number. */
|
|
9
|
+
export type Affix = readonly [prefix: string, suffix: string];
|
|
10
|
+
|
|
11
|
+
export interface NumberSpec {
|
|
12
|
+
minInt: number;
|
|
13
|
+
minFrac: number;
|
|
14
|
+
maxFrac: number;
|
|
15
|
+
/** How far the decimal point moves right before rounding: 2 for a percent. */
|
|
16
|
+
shift: number;
|
|
17
|
+
/** Integer digits grouped from the right, `primary` then `secondary` at a time, once there are `primary + min` of them; `min` 0 never groups. */
|
|
18
|
+
grouping: { primary: number; secondary: number; min: number };
|
|
19
|
+
decimal: string;
|
|
20
|
+
group: string;
|
|
21
|
+
/** The numbering system's digits, 0 to 9, where it isn't ASCII's. */
|
|
22
|
+
digits?: string[];
|
|
23
|
+
positive: Affix;
|
|
24
|
+
negative: Affix;
|
|
25
|
+
/** A value that rounds to zero, by its sign. */
|
|
26
|
+
zero: Affix;
|
|
27
|
+
negativeZero: Affix;
|
|
28
|
+
nan: string;
|
|
29
|
+
infinity: string;
|
|
30
|
+
negativeInfinity: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The plural operands' digits: rounded as `Intl.PluralRules` rounds before it selects. */
|
|
34
|
+
export interface Rounding {
|
|
35
|
+
minFrac: number;
|
|
36
|
+
maxFrac: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const NUMBER_PARTS = new Set(['integer', 'group', 'decimal', 'fraction']);
|
|
40
|
+
|
|
41
|
+
/** Values the spec must format as `Intl` does: signs, zeros, ties, grouping thresholds, every plural form a unit could take. */
|
|
42
|
+
const SAMPLES = [
|
|
43
|
+
0,
|
|
44
|
+
-0,
|
|
45
|
+
1,
|
|
46
|
+
-1,
|
|
47
|
+
2,
|
|
48
|
+
3,
|
|
49
|
+
5,
|
|
50
|
+
11,
|
|
51
|
+
21,
|
|
52
|
+
0.5,
|
|
53
|
+
1.5,
|
|
54
|
+
2.5,
|
|
55
|
+
-2.5,
|
|
56
|
+
0.125,
|
|
57
|
+
0.0001,
|
|
58
|
+
-0.0001,
|
|
59
|
+
0.07,
|
|
60
|
+
1.005,
|
|
61
|
+
12.345,
|
|
62
|
+
999.9999,
|
|
63
|
+
1000,
|
|
64
|
+
10000,
|
|
65
|
+
100000,
|
|
66
|
+
1234.5678,
|
|
67
|
+
-1234.5678,
|
|
68
|
+
1e6,
|
|
69
|
+
123456789.987,
|
|
70
|
+
1e21,
|
|
71
|
+
NaN,
|
|
72
|
+
Infinity,
|
|
73
|
+
-Infinity,
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
function affixOf(parts: Intl.NumberFormatPart[]): Affix {
|
|
77
|
+
const first = parts.findIndex((p) => NUMBER_PARTS.has(p.type));
|
|
78
|
+
const last = parts.findLastIndex((p) => NUMBER_PARTS.has(p.type));
|
|
79
|
+
const text = (from: number, to: number) =>
|
|
80
|
+
parts
|
|
81
|
+
.slice(from, to)
|
|
82
|
+
.map((p) => p.value)
|
|
83
|
+
.join('');
|
|
84
|
+
return [text(0, first), text(last + 1, parts.length)];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function partOf(parts: Intl.NumberFormatPart[], type: string): string | undefined {
|
|
88
|
+
return parts.find((p) => p.type === type)?.value;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What a format with `options` takes in `locale`. Throws a sentence saying what a native target
|
|
93
|
+
* can't reproduce, which the caller puts in front of a fix.
|
|
94
|
+
*/
|
|
95
|
+
export function numberSpec(locale: string, options: Intl.NumberFormatOptions = {}): NumberSpec {
|
|
96
|
+
const format = new Intl.NumberFormat(locale, options);
|
|
97
|
+
const resolved = format.resolvedOptions();
|
|
98
|
+
if (resolved.notation !== 'standard') throw new Error(`${resolved.notation} notation reads the size of the number at runtime`);
|
|
99
|
+
if (resolved.minimumSignificantDigits !== undefined) throw new Error('significant digits round by the size of the number');
|
|
100
|
+
if (resolved.roundingIncrement !== 1 || resolved.roundingMode !== 'halfExpand')
|
|
101
|
+
throw new Error('it rounds other than half away from zero');
|
|
102
|
+
if (resolved.trailingZeroDisplay !== 'auto') throw new Error('it drops trailing zeros by value');
|
|
103
|
+
|
|
104
|
+
const at = (more: Intl.NumberFormatOptions) => new Intl.NumberFormat(locale, { ...options, ...more });
|
|
105
|
+
const whole = at({ minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
|
106
|
+
const integers = whole
|
|
107
|
+
.formatToParts(1234567891234)
|
|
108
|
+
.filter((p) => p.type === 'integer')
|
|
109
|
+
.map((p) => p.value.length);
|
|
110
|
+
const grouped = (digits: number) =>
|
|
111
|
+
whole.formatToParts(10 ** (digits - 1 - (resolved.style === 'percent' ? 2 : 0))).some((p) => p.type === 'group');
|
|
112
|
+
const primary = integers.at(-1) ?? 3;
|
|
113
|
+
const secondary = integers.length > 2 ? integers.at(-2)! : primary;
|
|
114
|
+
const min = integers.length < 2 ? 0 : ([1, 2, 3, 4].find((m) => grouped(primary + m)) ?? 0);
|
|
115
|
+
|
|
116
|
+
const native = new Intl.NumberFormat(locale, { numberingSystem: resolved.numberingSystem, useGrouping: false });
|
|
117
|
+
const digits = Array.from({ length: 10 }, (_, d) => native.format(d));
|
|
118
|
+
const spec: NumberSpec = {
|
|
119
|
+
minInt: resolved.minimumIntegerDigits,
|
|
120
|
+
minFrac: resolved.minimumFractionDigits ?? 0,
|
|
121
|
+
maxFrac: resolved.maximumFractionDigits ?? 0,
|
|
122
|
+
shift: resolved.style === 'percent' ? 2 : 0,
|
|
123
|
+
grouping: { primary, secondary, min },
|
|
124
|
+
decimal: partOf(at({ minimumFractionDigits: 1, maximumFractionDigits: 1 }).formatToParts(1.5), 'decimal') ?? '.',
|
|
125
|
+
group: partOf(whole.formatToParts(1234567891234), 'group') ?? ',',
|
|
126
|
+
...(digits.join('') !== '0123456789' && { digits }),
|
|
127
|
+
positive: affixOf(format.formatToParts(1)),
|
|
128
|
+
negative: affixOf(format.formatToParts(-1)),
|
|
129
|
+
zero: affixOf(format.formatToParts(0)),
|
|
130
|
+
negativeZero: affixOf(format.formatToParts(-0)),
|
|
131
|
+
nan: format.format(NaN),
|
|
132
|
+
infinity: format.format(Infinity),
|
|
133
|
+
negativeInfinity: format.format(-Infinity),
|
|
134
|
+
};
|
|
135
|
+
for (const sample of SAMPLES) {
|
|
136
|
+
const expected = format.format(sample);
|
|
137
|
+
const actual = formatNumber(spec, sample);
|
|
138
|
+
if (actual !== expected) throw new Error(`it writes ${sample} as "${expected}", which native code would write as "${actual}"`);
|
|
139
|
+
}
|
|
140
|
+
return spec;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** How `Intl.PluralRules` rounds before it selects, with `options` (MF2's selector options) in `locale`. */
|
|
144
|
+
export function roundingOf(locale: string, options: Intl.PluralRulesOptions = {}): Rounding {
|
|
145
|
+
const resolved = new Intl.PluralRules(locale, options).resolvedOptions();
|
|
146
|
+
if (resolved.minimumSignificantDigits !== undefined) throw new Error('significant digits select by the size of the number');
|
|
147
|
+
return { minFrac: resolved.minimumFractionDigits ?? 0, maxFrac: resolved.maximumFractionDigits ?? 3 };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The digits of a finite, non-negative double as the shortest decimal that reads back as it, which
|
|
152
|
+
* is what `Intl` rounds: 1.005 is 1.00499… in binary, and still rounds to 1.01. Every target's
|
|
153
|
+
* shortest form (`String()` here) may use an exponent, which moves the point.
|
|
154
|
+
*/
|
|
155
|
+
export function shortestDigits(value: number): { int: string; frac: string } {
|
|
156
|
+
const [mantissa = '', exponent = '0'] = String(value).split('e');
|
|
157
|
+
const [int = '', frac = ''] = mantissa.split('.');
|
|
158
|
+
const point = int.length + Number(exponent);
|
|
159
|
+
const all = int + frac;
|
|
160
|
+
if (point <= 0) return { int: '0', frac: '0'.repeat(-point) + all };
|
|
161
|
+
return { int: all.slice(0, point).padEnd(point, '0'), frac: all.slice(point) };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Exact digits moved `shift` places right, rounded half away from zero to `maxFrac`, with trailing
|
|
166
|
+
* zeros cut down to `minFrac`. The algorithm every target writes out; keep them in step.
|
|
167
|
+
*/
|
|
168
|
+
export function roundDigits(int: string, frac: string, shift: number, minFrac: number, maxFrac: number): { int: string; frac: string } {
|
|
169
|
+
const padded = frac.padEnd(shift, '0');
|
|
170
|
+
const whole = int + padded.slice(0, shift);
|
|
171
|
+
const rest = padded.slice(shift);
|
|
172
|
+
const kept = (whole + rest.slice(0, maxFrac).padEnd(maxFrac, '0')).split('').map(Number);
|
|
173
|
+
if ((rest[maxFrac] ?? '0') >= '5') {
|
|
174
|
+
let at = kept.length - 1;
|
|
175
|
+
while (at >= 0 && kept[at] === 9) kept[at--] = 0;
|
|
176
|
+
if (at < 0) kept.unshift(1);
|
|
177
|
+
else kept[at]!++;
|
|
178
|
+
}
|
|
179
|
+
const all = kept.join('');
|
|
180
|
+
let outFrac = all.slice(all.length - maxFrac);
|
|
181
|
+
while (outFrac.length > minFrac && outFrac.endsWith('0')) outFrac = outFrac.slice(0, -1);
|
|
182
|
+
return { int: all.slice(0, all.length - maxFrac).replace(/^0+/, ''), frac: outFrac };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function groupDigits(int: string, { primary, secondary, min }: NumberSpec['grouping'], separator: string): string {
|
|
186
|
+
if (min === 0 || int.length < primary + min) return int;
|
|
187
|
+
const groups = [int.slice(-primary)];
|
|
188
|
+
let rest = int.slice(0, -primary);
|
|
189
|
+
while (rest.length > secondary) {
|
|
190
|
+
groups.unshift(rest.slice(-secondary));
|
|
191
|
+
rest = rest.slice(0, -secondary);
|
|
192
|
+
}
|
|
193
|
+
groups.unshift(rest);
|
|
194
|
+
return groups.join(separator);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** `value` as the spec writes it: the reference for the code each target generates. */
|
|
198
|
+
export function formatNumber(spec: NumberSpec, value: number): string {
|
|
199
|
+
if (Number.isNaN(value)) return spec.nan;
|
|
200
|
+
if (!Number.isFinite(value)) return value > 0 ? spec.infinity : spec.negativeInfinity;
|
|
201
|
+
const shortest = shortestDigits(Math.abs(value));
|
|
202
|
+
const { int, frac } = roundDigits(shortest.int, shortest.frac, spec.shift, spec.minFrac, spec.maxFrac);
|
|
203
|
+
const negative = value < 0 || Object.is(value, -0);
|
|
204
|
+
const zero = /^0*$/.test(int + frac);
|
|
205
|
+
const [prefix, suffix] = zero ? (negative ? spec.negativeZero : spec.zero) : negative ? spec.negative : spec.positive;
|
|
206
|
+
const shown =
|
|
207
|
+
groupDigits(int.padStart(Math.max(spec.minInt, 1), '0'), spec.grouping, spec.group) + (frac === '' ? '' : spec.decimal + frac);
|
|
208
|
+
const digits = spec.digits;
|
|
209
|
+
return prefix + (digits === undefined ? shown : shown.replace(/[0-9]/g, (d) => digits[Number(d)]!)) + suffix;
|
|
210
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLDR's plural rules, read into conditions a native target writes as code. Swift, the JVM and Rust
|
|
3
|
+
* have no plural rules of their own that every platform carries, so each generated file brings the
|
|
4
|
+
* rules of the locales it formats, from the same CLDR data `Intl.PluralRules` is built on.
|
|
5
|
+
*/
|
|
6
|
+
import ordinals from 'cldr-core/supplemental/ordinals.json' with { type: 'json' };
|
|
7
|
+
import cardinals from 'cldr-core/supplemental/plurals.json' with { type: 'json' };
|
|
8
|
+
|
|
9
|
+
/** CLDR's operands: `n` the absolute value, `i` its integer digits, `v`/`w` the count of fraction digits with and without trailing zeros, `f`/`t` those digits, `c`/`e` the compact exponent. */
|
|
10
|
+
export type Operand = 'n' | 'i' | 'v' | 'w' | 'f' | 't' | 'c' | 'e';
|
|
11
|
+
|
|
12
|
+
/** `i % 10 = 2..4, 7`: the operand, less its modulus, in (or with `negated`, not in) the ranges. A single value is a range of one. */
|
|
13
|
+
export interface Relation {
|
|
14
|
+
operand: Operand;
|
|
15
|
+
mod?: number;
|
|
16
|
+
negated: boolean;
|
|
17
|
+
ranges: (readonly [number, number])[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Relations joined by `and`, those joined by `or`. */
|
|
21
|
+
export type Condition = Relation[][];
|
|
22
|
+
|
|
23
|
+
/** A locale's categories in CLDR's order, each with its condition. `other` holds wherever none does, so it has none. */
|
|
24
|
+
export type PluralRules = (readonly [category: string, Condition])[];
|
|
25
|
+
|
|
26
|
+
export type Operands = Record<Operand, number>;
|
|
27
|
+
|
|
28
|
+
type RuleData = Record<string, Record<string, string>>;
|
|
29
|
+
|
|
30
|
+
const DATA = {
|
|
31
|
+
cardinal: cardinals.supplemental['plurals-type-cardinal'] as RuleData,
|
|
32
|
+
ordinal: ordinals.supplemental['plurals-type-ordinal'] as RuleData,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The rules for `locale`, found as `Intl.PluralRules` finds its locale, then by its parents
|
|
37
|
+
* (`pt-PT`, then `pt`), so the generated code picks the category the browser would.
|
|
38
|
+
*/
|
|
39
|
+
export function pluralRules(locale: string, type: 'cardinal' | 'ordinal'): PluralRules {
|
|
40
|
+
const data = DATA[type];
|
|
41
|
+
let tag = new Intl.PluralRules(locale, { type }).resolvedOptions().locale;
|
|
42
|
+
while (!(tag in data) && tag.includes('-')) tag = tag.slice(0, tag.lastIndexOf('-'));
|
|
43
|
+
const rules = data[tag] ?? data['root'] ?? {};
|
|
44
|
+
return Object.entries(rules).flatMap(([name, rule]) => {
|
|
45
|
+
const category = name.slice('pluralRule-count-'.length);
|
|
46
|
+
return category === 'other' ? [] : [[category, parseCondition(rule.split('@', 1)[0]!)] as const];
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `v = 0 and i % 10 = 1 or n = 0..1`, its samples already cut. */
|
|
51
|
+
export function parseCondition(source: string): Condition {
|
|
52
|
+
return source
|
|
53
|
+
.trim()
|
|
54
|
+
.split(/\s+or\s+/)
|
|
55
|
+
.map((and) => and.split(/\s+and\s+/).map(parseRelation));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseRelation(source: string): Relation {
|
|
59
|
+
const match = /^([nivwftce])\s*(?:%\s*(\d+))?\s*(!?=)\s*(.+)$/.exec(source.trim());
|
|
60
|
+
if (match === null) throw new Error(`prosebook: a CLDR plural rule it can't read: "${source}"`);
|
|
61
|
+
const [, operand, mod, op, list] = match;
|
|
62
|
+
return {
|
|
63
|
+
operand: operand as Operand,
|
|
64
|
+
...(mod !== undefined && { mod: Number(mod) }),
|
|
65
|
+
negated: op === '!=',
|
|
66
|
+
ranges: list!.split(',').map((range) => {
|
|
67
|
+
const [from, to = from] = range.trim().split('..');
|
|
68
|
+
return [Number(from), Number(to)] as const;
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A range of one is an equality; a wider range holds integers only, as CLDR's `in` does. */
|
|
74
|
+
export function inRange(value: number, [from, to]: readonly [number, number]): boolean {
|
|
75
|
+
return from === to ? value === from : Number.isInteger(value) && value >= from && value <= to;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function holds(relation: Relation, operands: Operands): boolean {
|
|
79
|
+
const raw = operands[relation.operand];
|
|
80
|
+
const value = relation.mod === undefined ? raw : raw % relation.mod;
|
|
81
|
+
return relation.ranges.some((range) => inRange(value, range)) !== relation.negated;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The category `rules` give, as the generated code picks it. */
|
|
85
|
+
export function categoryOf(rules: PluralRules, operands: Operands): string {
|
|
86
|
+
for (const [category, condition] of rules) if (condition.some((and) => and.every((r) => holds(r, operands)))) return category;
|
|
87
|
+
return 'other';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The operands of a number written with `int` and `frac` digits, as CLDR reads them. */
|
|
91
|
+
export function operandsOf(int: string, frac: string): Operands {
|
|
92
|
+
const trimmed = frac.replace(/0+$/, '');
|
|
93
|
+
return {
|
|
94
|
+
n: Number(`${int}.${frac || '0'}`),
|
|
95
|
+
i: Number(int),
|
|
96
|
+
v: frac.length,
|
|
97
|
+
w: trimmed.length,
|
|
98
|
+
f: Number(frac || '0'),
|
|
99
|
+
t: Number(trimmed || '0'),
|
|
100
|
+
c: 0,
|
|
101
|
+
e: 0,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -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
|
+
}
|