saykit 0.8.0 → 0.9.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/dist/runtime.d.mts +24 -10
- package/dist/runtime.mjs +457 -242
- package/package.json +6 -2
package/dist/runtime.d.mts
CHANGED
|
@@ -31,22 +31,30 @@ interface SelectOptions<Branch = string> {
|
|
|
31
31
|
other: Branch;
|
|
32
32
|
[match: string | number]: Branch;
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* An ICU skeleton: a `::`-prefixed description of the parts a value is written
|
|
36
|
+
* with, rather than a name for a whole format. `::currency/EUR` and
|
|
37
|
+
* `::yyyyMMdd` say what to show and leave the arrangement to the locale.
|
|
38
|
+
*
|
|
39
|
+
* A skeleton is where the formats the named styles have no word for live —
|
|
40
|
+
* currency, compact notation, a year and month with no day — so every argument
|
|
41
|
+
* type accepts one alongside its named styles.
|
|
42
|
+
*/
|
|
43
|
+
type Skeleton = `::${string}`;
|
|
34
44
|
/**
|
|
35
45
|
* Formatting for a `{arg, number}` placeholder.
|
|
36
46
|
*
|
|
37
|
-
* `
|
|
38
|
-
*
|
|
39
|
-
* amount. A literal pattern such as `#,##0.00` is accepted for the cases the
|
|
40
|
-
* named styles do not cover.
|
|
47
|
+
* A literal `NumberFormat` pattern such as `#,##0.00` is also accepted, for the
|
|
48
|
+
* cases neither the named styles nor a skeleton spell more clearly.
|
|
41
49
|
*/
|
|
42
50
|
interface NumberOptions {
|
|
43
|
-
style?: 'integer' | 'percent' | (string & {});
|
|
51
|
+
style?: 'integer' | 'percent' | Skeleton | (string & {});
|
|
44
52
|
}
|
|
45
53
|
/**
|
|
46
54
|
* Formatting for a `{arg, date}` or `{arg, time}` placeholder.
|
|
47
55
|
*/
|
|
48
56
|
interface DateTimeOptions {
|
|
49
|
-
style?: 'short' | 'medium' | 'long' | 'full';
|
|
57
|
+
style?: 'short' | 'medium' | 'long' | 'full' | Skeleton;
|
|
50
58
|
}
|
|
51
59
|
//#endregion
|
|
52
60
|
//#region src/runtime.d.ts
|
|
@@ -259,10 +267,12 @@ declare class Say<Locale extends string = string, Loader extends Say.Loader<Loca
|
|
|
259
267
|
* say`You have ${say.number(items.length)} items`
|
|
260
268
|
* say`Battery at ${say.number(level, { style: 'percent' })}`
|
|
261
269
|
* say`Total: ${say.number({ cartTotal: getTotal() }, { style: '#,##0.00' })}`
|
|
270
|
+
* say`Total: ${say.number(total, { style: '::currency/EUR' })}`
|
|
262
271
|
* ```
|
|
263
272
|
*
|
|
264
273
|
* @param _ Number to format
|
|
265
|
-
* @param options Formatting style
|
|
274
|
+
* @param options Formatting style: a named style, an ICU skeleton such as
|
|
275
|
+
* `::currency/EUR`, or a literal number pattern such as `#,##0.00`
|
|
266
276
|
* @returns The formatted number
|
|
267
277
|
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
268
278
|
*/
|
|
@@ -274,10 +284,12 @@ declare class Say<Locale extends string = string, Loader extends Say.Loader<Loca
|
|
|
274
284
|
* ```ts
|
|
275
285
|
* say`Published ${say.date(post.publishedAt)}`
|
|
276
286
|
* say`Published ${say.date(post.publishedAt, { style: 'full' })}`
|
|
287
|
+
* say`Published ${say.date(post.publishedAt, { style: '::yMMMM' })}`
|
|
277
288
|
* ```
|
|
278
289
|
*
|
|
279
290
|
* @param _ Date to format
|
|
280
|
-
* @param options Formatting style
|
|
291
|
+
* @param options Formatting style, either a named style or an ICU skeleton
|
|
292
|
+
* such as `::yyyyMMdd`
|
|
281
293
|
* @returns The formatted date
|
|
282
294
|
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
283
295
|
*/
|
|
@@ -289,14 +301,16 @@ declare class Say<Locale extends string = string, Loader extends Say.Loader<Loca
|
|
|
289
301
|
* ```ts
|
|
290
302
|
* say`Doors open at ${say.time(opensAt)}`
|
|
291
303
|
* say`Doors open at ${say.time(opensAt, { style: 'short' })}`
|
|
304
|
+
* say`Doors open at ${say.time(opensAt, { style: '::Hm' })}`
|
|
292
305
|
* ```
|
|
293
306
|
*
|
|
294
307
|
* @param _ Date to format
|
|
295
|
-
* @param options Formatting style
|
|
308
|
+
* @param options Formatting style, either a named style or an ICU skeleton
|
|
309
|
+
* such as `::Hm`
|
|
296
310
|
* @returns The formatted time
|
|
297
311
|
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
298
312
|
*/
|
|
299
313
|
time(_: Date | number | Named<Date | number>, options?: Disallow<DateTimeOptions, 'id' | 'context'>): string;
|
|
300
314
|
}
|
|
301
315
|
//#endregion
|
|
302
|
-
export { Awaitable, DateTimeOptions, Disallow, Named, NumberOptions, NumeralOptions, ReadonlySay, Say, SelectOptions, Tuple };
|
|
316
|
+
export { Awaitable, DateTimeOptions, Disallow, Named, NumberOptions, NumeralOptions, ReadonlySay, Say, SelectOptions, Skeleton, Tuple };
|
package/dist/runtime.mjs
CHANGED
|
@@ -1,117 +1,466 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { parse } from "@messageformat/parser";
|
|
2
|
+
import { MessageFormat } from "messageformat";
|
|
3
|
+
import { getDateTimeFormatOptions, parseDateTokens } from "@messageformat/date-skeleton";
|
|
4
|
+
import { getNumberFormatOptions, parseNumberPattern, parseNumberSkeleton } from "@messageformat/number-skeleton";
|
|
5
|
+
import { getLocaleDir } from "messageformat/functions";
|
|
6
|
+
//#region src/messageformat/options.ts
|
|
7
|
+
const literal = (value) => ({
|
|
8
|
+
type: "literal",
|
|
9
|
+
value
|
|
10
|
+
});
|
|
11
|
+
function options(value) {
|
|
12
|
+
return typeof value === "object" && value !== null ? value : {};
|
|
5
13
|
}
|
|
6
14
|
//#endregion
|
|
7
|
-
//#region
|
|
8
|
-
|
|
9
|
-
|
|
15
|
+
//#region src/messageformat/styles.ts
|
|
16
|
+
var StyleError = class extends Error {};
|
|
17
|
+
const DATE_STYLES = {
|
|
18
|
+
short: {
|
|
19
|
+
year: "numeric",
|
|
20
|
+
month: "numeric",
|
|
21
|
+
day: "numeric"
|
|
22
|
+
},
|
|
23
|
+
medium: {
|
|
24
|
+
year: "numeric",
|
|
25
|
+
month: "short",
|
|
26
|
+
day: "numeric"
|
|
27
|
+
},
|
|
28
|
+
long: {
|
|
29
|
+
year: "numeric",
|
|
30
|
+
month: "long",
|
|
31
|
+
day: "numeric"
|
|
32
|
+
},
|
|
33
|
+
full: {
|
|
34
|
+
year: "numeric",
|
|
35
|
+
month: "long",
|
|
36
|
+
day: "numeric",
|
|
37
|
+
weekday: "long"
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const TIME_STYLES = {
|
|
41
|
+
short: {
|
|
42
|
+
hour: "numeric",
|
|
43
|
+
minute: "numeric"
|
|
44
|
+
},
|
|
45
|
+
medium: {
|
|
46
|
+
hour: "numeric",
|
|
47
|
+
minute: "numeric",
|
|
48
|
+
second: "numeric"
|
|
49
|
+
},
|
|
50
|
+
long: {
|
|
51
|
+
hour: "numeric",
|
|
52
|
+
minute: "numeric",
|
|
53
|
+
second: "numeric",
|
|
54
|
+
timeZoneName: "short"
|
|
55
|
+
},
|
|
56
|
+
full: {
|
|
57
|
+
hour: "numeric",
|
|
58
|
+
minute: "numeric",
|
|
59
|
+
second: "numeric",
|
|
60
|
+
timeZoneName: "long"
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
function dateTimeStyle(kind, style) {
|
|
64
|
+
const named = kind === "date" ? DATE_STYLES : TIME_STYLES;
|
|
65
|
+
if (style.startsWith("::")) {
|
|
66
|
+
const errors = [];
|
|
67
|
+
const opt = getDateTimeFormatOptions(parseDateTokens(style.slice(2)), (_type, message) => errors.push(message));
|
|
68
|
+
if (errors.length > 0) throw new StyleError(errors[0]);
|
|
69
|
+
if (Object.keys(opt).length === 0) throw new StyleError(`Empty skeleton ${style}`);
|
|
70
|
+
return opt;
|
|
71
|
+
}
|
|
72
|
+
if (style === "") return named.medium;
|
|
73
|
+
if (Object.hasOwn(named, style)) return named[style];
|
|
74
|
+
throw new StyleError(`Unsupported ${kind} style ${style}`);
|
|
10
75
|
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
76
|
+
function numberStyle(style) {
|
|
77
|
+
if (style === "") return {};
|
|
78
|
+
if (style === "integer") return { maximumFractionDigits: 0 };
|
|
79
|
+
if (style === "percent") return { style: "percent" };
|
|
80
|
+
const errors = [];
|
|
81
|
+
const onError = (error) => errors.push(String(error));
|
|
82
|
+
const skeleton = style.startsWith("::") ? parseNumberSkeleton(style.slice(2), onError) : parseNumberPattern(style, "XXX", onError);
|
|
83
|
+
let scale;
|
|
84
|
+
const opt = getNumberFormatOptions(skeleton, (stem, option) => {
|
|
85
|
+
if (stem === "scale") scale = Number(option);
|
|
86
|
+
else errors.push(`Unsupported number stem ${stem}`);
|
|
87
|
+
});
|
|
88
|
+
if (errors.length > 0) throw new StyleError(errors[0]);
|
|
89
|
+
return scale === void 0 ? opt : {
|
|
90
|
+
...opt,
|
|
91
|
+
scale
|
|
92
|
+
};
|
|
15
93
|
}
|
|
16
94
|
//#endregion
|
|
17
|
-
//#region
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
95
|
+
//#region src/messageformat/convert.ts
|
|
96
|
+
const isSelect = (token) => token.type === "plural" || token.type === "select" || token.type === "selectordinal";
|
|
97
|
+
const asKey = (key) => /^=\d+$/.test(key) ? Number(key.slice(1)) : key;
|
|
98
|
+
function functionRef(token) {
|
|
99
|
+
let style = "";
|
|
100
|
+
for (const part of token.param ?? []) {
|
|
101
|
+
if (part.type !== "content") throw new Error(`Unsupported style part: ${part.type}`);
|
|
102
|
+
style += part.value;
|
|
103
|
+
}
|
|
104
|
+
style = style.trim();
|
|
105
|
+
const ref = (name, value) => ({
|
|
106
|
+
type: "function",
|
|
107
|
+
name,
|
|
108
|
+
...value === void 0 ? {} : { options: { options: literal(value) } }
|
|
109
|
+
});
|
|
110
|
+
try {
|
|
111
|
+
switch (token.key) {
|
|
112
|
+
case "date":
|
|
113
|
+
case "time": return ref("say:datetime", dateTimeStyle(token.key, style));
|
|
114
|
+
case "number": return ref("say:number", numberStyle(style));
|
|
115
|
+
case "duration": return ref("say:duration");
|
|
116
|
+
default: throw new StyleError(`Unsupported argument type ${token.key}`);
|
|
117
|
+
}
|
|
118
|
+
} catch (error) {
|
|
119
|
+
/* v8 ignore next */
|
|
120
|
+
if (!(error instanceof StyleError)) throw error;
|
|
121
|
+
switch (token.key) {
|
|
122
|
+
case "date":
|
|
123
|
+
case "time": return ref("say:datetime", dateTimeStyle(token.key, ""));
|
|
124
|
+
case "number": return ref("say:number", {});
|
|
125
|
+
default: return ref("say:string");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function toPart(token, plural) {
|
|
130
|
+
switch (token.type) {
|
|
131
|
+
case "content": return token.value;
|
|
132
|
+
case "argument": return {
|
|
133
|
+
type: "expression",
|
|
134
|
+
arg: {
|
|
135
|
+
type: "variable",
|
|
136
|
+
name: token.arg
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
case "function": return {
|
|
140
|
+
type: "expression",
|
|
141
|
+
arg: {
|
|
142
|
+
type: "variable",
|
|
143
|
+
name: token.arg
|
|
144
|
+
},
|
|
145
|
+
functionRef: functionRef(token)
|
|
146
|
+
};
|
|
147
|
+
case "octothorpe":
|
|
148
|
+
/* v8 ignore next */
|
|
149
|
+
return plural ? {
|
|
150
|
+
type: "expression",
|
|
151
|
+
arg: {
|
|
152
|
+
type: "variable",
|
|
153
|
+
name: plural
|
|
154
|
+
}
|
|
155
|
+
} : "#";
|
|
156
|
+
/* v8 ignore next 2 -- the token union has no other members */
|
|
157
|
+
default: throw new Error(`Unsupported token type: ${token.type}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const sameSelector = (a) => (b) => a.arg === b.arg && a.type === b.type && a.offset === b.offset;
|
|
161
|
+
function findSelectors(tokens) {
|
|
162
|
+
const selectors = [];
|
|
163
|
+
const add = (selector) => {
|
|
164
|
+
const existing = selectors.find(sameSelector(selector));
|
|
165
|
+
if (existing) {
|
|
166
|
+
existing.keys.push(...selector.keys);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const taken = selectors.filter((s) => s.arg === selector.arg).length;
|
|
170
|
+
const name = taken === 0 ? selector.arg : `${selector.arg}#${taken}`;
|
|
171
|
+
selectors.push({
|
|
172
|
+
...selector,
|
|
173
|
+
name
|
|
174
|
+
});
|
|
175
|
+
};
|
|
176
|
+
for (const token of tokens) {
|
|
177
|
+
if (!isSelect(token)) continue;
|
|
178
|
+
add({
|
|
179
|
+
arg: token.arg,
|
|
180
|
+
type: token.type,
|
|
181
|
+
offset: token.pluralOffset ?? 0,
|
|
182
|
+
keys: token.cases.map((c) => token.type === "select" ? c.key : asKey(c.key))
|
|
183
|
+
});
|
|
184
|
+
for (const c of token.cases) for (const inner of findSelectors(c.tokens)) add(inner);
|
|
185
|
+
}
|
|
186
|
+
return selectors;
|
|
187
|
+
}
|
|
188
|
+
function declaration({ arg, name, type, offset }) {
|
|
189
|
+
const functionRef = type === "select" ? {
|
|
190
|
+
type: "function",
|
|
191
|
+
name: "say:string"
|
|
192
|
+
} : {
|
|
193
|
+
type: "function",
|
|
194
|
+
name: "say:plural",
|
|
195
|
+
options: { options: literal({
|
|
196
|
+
offset,
|
|
197
|
+
ordinal: type === "selectordinal"
|
|
198
|
+
}) }
|
|
199
|
+
};
|
|
200
|
+
const value = {
|
|
201
|
+
type: "expression",
|
|
202
|
+
arg: {
|
|
203
|
+
type: "variable",
|
|
204
|
+
name: arg
|
|
205
|
+
},
|
|
206
|
+
functionRef
|
|
207
|
+
};
|
|
208
|
+
return name === arg ? {
|
|
209
|
+
type: "input",
|
|
210
|
+
name,
|
|
211
|
+
value
|
|
212
|
+
} : {
|
|
213
|
+
type: "local",
|
|
214
|
+
name,
|
|
215
|
+
value
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
function sortKeys(keys) {
|
|
219
|
+
const rank = (key) => typeof key === "number" ? 0 : key === "other" ? 2 : 1;
|
|
220
|
+
return Array.from(new Set(keys)).sort((a, b) => rank(a) - rank(b));
|
|
221
|
+
}
|
|
222
|
+
function toMessage(ast) {
|
|
223
|
+
const selectors = findSelectors(ast);
|
|
224
|
+
if (selectors.length === 0) return {
|
|
225
|
+
type: "message",
|
|
226
|
+
declarations: [],
|
|
227
|
+
pattern: ast.map((t) => toPart(t, null))
|
|
228
|
+
};
|
|
229
|
+
let tuples = [[]];
|
|
230
|
+
for (const selector of selectors) {
|
|
231
|
+
const keys = sortKeys(selector.keys);
|
|
232
|
+
tuples = tuples.flatMap((tuple) => keys.map((key) => [...tuple, key]));
|
|
233
|
+
}
|
|
234
|
+
const variants = tuples.map((tuple) => ({
|
|
235
|
+
keys: tuple.map((key) => key === "other" ? { type: "*" } : {
|
|
236
|
+
type: "literal",
|
|
237
|
+
quoted: false,
|
|
238
|
+
value: String(key)
|
|
239
|
+
}),
|
|
240
|
+
value: []
|
|
241
|
+
}));
|
|
242
|
+
function fill(tokens, plural, filter) {
|
|
243
|
+
for (const token of tokens) {
|
|
244
|
+
if (isSelect(token)) {
|
|
245
|
+
const index = selectors.findIndex(sameSelector({
|
|
246
|
+
arg: token.arg,
|
|
247
|
+
type: token.type,
|
|
248
|
+
offset: token.pluralOffset ?? 0
|
|
249
|
+
}));
|
|
250
|
+
const inner = token.type === "select" ? plural : selectors[index].name;
|
|
251
|
+
for (const c of token.cases) {
|
|
252
|
+
const key = token.type === "select" ? c.key : asKey(c.key);
|
|
253
|
+
fill(c.tokens, inner, [...filter, {
|
|
254
|
+
index,
|
|
255
|
+
key
|
|
256
|
+
}]);
|
|
257
|
+
}
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
for (const variant of variants) {
|
|
261
|
+
if (!filter.every(({ index, key }) => {
|
|
262
|
+
const vk = variant.keys[index];
|
|
263
|
+
return vk.type === "*" ? key === "other" : String(key) === vk.value;
|
|
264
|
+
})) continue;
|
|
265
|
+
const part = toPart(token, plural);
|
|
266
|
+
const last = variant.value.length - 1;
|
|
267
|
+
if (typeof part === "string" && typeof variant.value[last] === "string") variant.value[last] += part;
|
|
268
|
+
else variant.value.push(part);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
fill(ast, null, []);
|
|
273
|
+
return {
|
|
274
|
+
type: "select",
|
|
275
|
+
declarations: selectors.map(declaration),
|
|
276
|
+
selectors: selectors.map((s) => ({
|
|
277
|
+
type: "variable",
|
|
278
|
+
name: s.name
|
|
279
|
+
})),
|
|
280
|
+
variants
|
|
281
|
+
};
|
|
21
282
|
}
|
|
22
283
|
//#endregion
|
|
23
|
-
//#region
|
|
24
|
-
function
|
|
25
|
-
|
|
284
|
+
//#region src/messageformat/values.ts
|
|
285
|
+
function part(type, locale, parts) {
|
|
286
|
+
const dir = getLocaleDir(locale);
|
|
287
|
+
/* v8 ignore next 3 */
|
|
288
|
+
return dir === "ltr" || dir === "rtl" ? {
|
|
289
|
+
type,
|
|
290
|
+
dir,
|
|
291
|
+
locale,
|
|
292
|
+
parts
|
|
293
|
+
} : {
|
|
294
|
+
type,
|
|
295
|
+
locale,
|
|
296
|
+
parts
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
function numeric(operand) {
|
|
300
|
+
const value = typeof operand === "object" && operand !== null ? operand.valueOf() : operand;
|
|
301
|
+
if (typeof value === "bigint") return value;
|
|
302
|
+
const number = Number(value);
|
|
303
|
+
if (Number.isNaN(number) && value !== void 0) throw new Error("Input is not numeric");
|
|
304
|
+
return number;
|
|
305
|
+
}
|
|
306
|
+
function temporal(operand) {
|
|
307
|
+
let value = operand;
|
|
308
|
+
if (typeof value === "object" && value !== null && !(value instanceof Date)) value = value.valueOf();
|
|
309
|
+
if (typeof value === "number" || typeof value === "string") value = new Date(value);
|
|
310
|
+
if (!(value instanceof Date) || Number.isNaN(value.getTime())) throw new Error("Input is not a valid date");
|
|
311
|
+
return value;
|
|
26
312
|
}
|
|
313
|
+
function number(locales, value, opt) {
|
|
314
|
+
let nf;
|
|
315
|
+
let locale;
|
|
316
|
+
const format = () => nf ??= new Intl.NumberFormat(locales, opt);
|
|
317
|
+
const resolved = () => locale ??= format().resolvedOptions().locale;
|
|
318
|
+
return {
|
|
319
|
+
type: "number",
|
|
320
|
+
get dir() {
|
|
321
|
+
return getLocaleDir(resolved());
|
|
322
|
+
},
|
|
323
|
+
get options() {
|
|
324
|
+
return { ...opt };
|
|
325
|
+
},
|
|
326
|
+
toParts: () => [part("number", resolved(), format().formatToParts(value))],
|
|
327
|
+
toString: () => format().format(value),
|
|
328
|
+
valueOf: () => value
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
function datetime(locales, value, opt) {
|
|
332
|
+
let dtf;
|
|
333
|
+
let locale;
|
|
334
|
+
const format = () => dtf ??= new Intl.DateTimeFormat(locales, opt);
|
|
335
|
+
const resolved = () => locale ??= format().resolvedOptions().locale;
|
|
336
|
+
return {
|
|
337
|
+
type: "datetime",
|
|
338
|
+
get dir() {
|
|
339
|
+
return getLocaleDir(resolved());
|
|
340
|
+
},
|
|
341
|
+
get options() {
|
|
342
|
+
return { ...opt };
|
|
343
|
+
},
|
|
344
|
+
toParts: () => [part("datetime", resolved(), format().formatToParts(value))],
|
|
345
|
+
toString: () => format().format(value),
|
|
346
|
+
valueOf: () => value
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
function duration(seconds) {
|
|
350
|
+
if (!Number.isFinite(seconds)) return String(seconds);
|
|
351
|
+
const sign = seconds < 0 ? "-" : "";
|
|
352
|
+
const total = Math.round(Math.abs(seconds) * 1e3) / 1e3;
|
|
353
|
+
const secs = total % 60;
|
|
354
|
+
const minutes = Math.floor(total / 60);
|
|
355
|
+
const hours = Math.floor(minutes / 60);
|
|
356
|
+
const written = Math.round(secs) === secs ? String(secs) : secs.toFixed(3);
|
|
357
|
+
const parts = hours > 0 ? [
|
|
358
|
+
hours,
|
|
359
|
+
minutes % 60,
|
|
360
|
+
written
|
|
361
|
+
] : [minutes, written];
|
|
362
|
+
const first = parts.shift();
|
|
363
|
+
const pad = (n) => Number(n) < 10 ? `0${n}` : String(n);
|
|
364
|
+
return sign + [first, ...parts.map(pad)].join(":");
|
|
365
|
+
}
|
|
366
|
+
//#endregion
|
|
367
|
+
//#region src/messageformat/functions.ts
|
|
368
|
+
const functions = {
|
|
369
|
+
"say:number": (ctx, opt, operand) => {
|
|
370
|
+
const { scale, ...nf } = options(opt.options);
|
|
371
|
+
const value = numeric(operand);
|
|
372
|
+
return number(ctx.locales, scale ? Number(value) * scale : value, nf);
|
|
373
|
+
},
|
|
374
|
+
"say:datetime": (ctx, opt, operand) => datetime(ctx.locales, temporal(operand), options(opt.options)),
|
|
375
|
+
"say:duration": (_ctx, _opt, operand) => {
|
|
376
|
+
const value = Number(numeric(operand));
|
|
377
|
+
const str = duration(value);
|
|
378
|
+
return {
|
|
379
|
+
type: "say:duration",
|
|
380
|
+
toParts: () => [{
|
|
381
|
+
type: "say:duration",
|
|
382
|
+
value: str
|
|
383
|
+
}],
|
|
384
|
+
toString: () => str,
|
|
385
|
+
valueOf: () => value
|
|
386
|
+
};
|
|
387
|
+
},
|
|
388
|
+
"say:plural": (ctx, opt, operand) => {
|
|
389
|
+
const { offset = 0, ordinal = false } = options(opt.options);
|
|
390
|
+
const value = numeric(operand);
|
|
391
|
+
const shifted = typeof value === "bigint" ? value - BigInt(offset) : value - offset;
|
|
392
|
+
const result = number(ctx.locales, shifted, {});
|
|
393
|
+
result.valueOf = () => value;
|
|
394
|
+
let rules;
|
|
395
|
+
result.selectKey = (keys) => {
|
|
396
|
+
const exact = String(value);
|
|
397
|
+
if (keys.has(exact)) return exact;
|
|
398
|
+
rules ??= new Intl.PluralRules(ctx.locales, {
|
|
399
|
+
localeMatcher: ctx.localeMatcher,
|
|
400
|
+
type: ordinal ? "ordinal" : "cardinal"
|
|
401
|
+
});
|
|
402
|
+
const category = rules.select(Number(shifted));
|
|
403
|
+
return keys.has(category) ? category : null;
|
|
404
|
+
};
|
|
405
|
+
return result;
|
|
406
|
+
},
|
|
407
|
+
"say:string": (_ctx, _opt, operand) => {
|
|
408
|
+
const str = operand === void 0 ? "" : String(operand);
|
|
409
|
+
return {
|
|
410
|
+
type: "string",
|
|
411
|
+
selectKey: (keys) => keys.has(str) ? str : null,
|
|
412
|
+
toParts: () => [{
|
|
413
|
+
type: "string",
|
|
414
|
+
value: str
|
|
415
|
+
}],
|
|
416
|
+
toString: () => str,
|
|
417
|
+
valueOf: () => str
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
};
|
|
27
421
|
//#endregion
|
|
28
|
-
//#region
|
|
29
|
-
function
|
|
30
|
-
return
|
|
422
|
+
//#region src/messageformat/index.ts
|
|
423
|
+
function compile(locale, source) {
|
|
424
|
+
return new MessageFormat(locale, toMessage(parse(source)), { functions });
|
|
31
425
|
}
|
|
32
426
|
//#endregion
|
|
33
427
|
//#region src/runtime.ts
|
|
34
|
-
let _Symbol$iterator;
|
|
35
|
-
let _Symbol$for;
|
|
36
|
-
/**
|
|
37
|
-
* Map a descriptor's keys back to the placeholders the message names. The
|
|
38
|
-
* transform emits every value with one underscore in front, which keeps a
|
|
39
|
-
* message's values out of the descriptor's own namespace, so stripping exactly
|
|
40
|
-
* one is the whole inverse — `_0` is `0`, and `__total` is a placeholder named
|
|
41
|
-
* `_total`. Keys without one are passed through, so a hand-written
|
|
42
|
-
* `call({ id, name })` still formats `{name}`.
|
|
43
|
-
*
|
|
44
|
-
* Built from the descriptor's own entries so a value named `__proto__` stays a
|
|
45
|
-
* placeholder rather than reaching through to the prototype.
|
|
46
|
-
*/
|
|
47
428
|
function resolveDescriptorValues(descriptor) {
|
|
48
429
|
return Object.fromEntries(Object.entries(descriptor).filter(([key]) => key !== "id").map(([key, value]) => [key.startsWith("_") ? key.slice(1) : key, value]));
|
|
49
430
|
}
|
|
50
|
-
var _locales = /* @__PURE__ */ new WeakMap();
|
|
51
|
-
var _loader = /* @__PURE__ */ new WeakMap();
|
|
52
|
-
var _messages = /* @__PURE__ */ new WeakMap();
|
|
53
|
-
var _formats = /* @__PURE__ */ new WeakMap();
|
|
54
|
-
var _active = /* @__PURE__ */ new WeakMap();
|
|
55
|
-
var _Say_brand = /* @__PURE__ */ new WeakSet();
|
|
56
|
-
_Symbol$iterator = Symbol.iterator;
|
|
57
|
-
_Symbol$for = Symbol.for("nodejs.util.inspect.custom");
|
|
58
431
|
var Say = class Say {
|
|
432
|
+
#locales;
|
|
433
|
+
#loader;
|
|
434
|
+
#messages;
|
|
435
|
+
#formats;
|
|
436
|
+
#active;
|
|
59
437
|
constructor(options) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
_classPrivateFieldInitSpec(this, _formats, void 0);
|
|
65
|
-
_classPrivateFieldInitSpec(this, _active, void 0);
|
|
66
|
-
_classPrivateFieldSet2(_locales, this, options.locales);
|
|
67
|
-
_classPrivateFieldSet2(_loader, this, options.loader);
|
|
68
|
-
_classPrivateFieldSet2(_messages, this, /* @__PURE__ */ new Map());
|
|
69
|
-
_classPrivateFieldSet2(_formats, this, /* @__PURE__ */ new Map());
|
|
438
|
+
this.#locales = options.locales;
|
|
439
|
+
this.#loader = options.loader;
|
|
440
|
+
this.#messages = /* @__PURE__ */ new Map();
|
|
441
|
+
this.#formats = /* @__PURE__ */ new Map();
|
|
70
442
|
if (options.messages) this.assign(options.messages);
|
|
71
443
|
}
|
|
72
|
-
/**
|
|
73
|
-
* The currently active locale.
|
|
74
|
-
*
|
|
75
|
-
* @throws If no locale is active
|
|
76
|
-
*/
|
|
77
444
|
get locale() {
|
|
78
|
-
if (!
|
|
79
|
-
return
|
|
445
|
+
if (!this.#active) throw new Error("No active locale");
|
|
446
|
+
return this.#active;
|
|
80
447
|
}
|
|
81
|
-
/**
|
|
82
|
-
* All available messages mapped by locale.
|
|
83
|
-
*
|
|
84
|
-
* @throws If no locale is active
|
|
85
|
-
* @throws If no messages are available for the active locale
|
|
86
|
-
*/
|
|
87
448
|
get messages() {
|
|
88
449
|
/* v8 ignore next */
|
|
89
|
-
if (!
|
|
90
|
-
return
|
|
450
|
+
if (!this.#messages.has(this.locale)) throw new Error("No messages loaded for locale");
|
|
451
|
+
return this.#messages.get(this.locale);
|
|
91
452
|
}
|
|
92
|
-
/**
|
|
93
|
-
* All available locales.
|
|
94
|
-
*/
|
|
95
453
|
get locales() {
|
|
96
|
-
return
|
|
454
|
+
return this.#locales;
|
|
97
455
|
}
|
|
98
|
-
/**
|
|
99
|
-
* Loads messages for the given locales.
|
|
100
|
-
* If no locales are provided, all available locales are loaded.
|
|
101
|
-
* Requires a {@link Say.Loader} to be provided.
|
|
102
|
-
* If `loader` returns a promise, so will this method.
|
|
103
|
-
*
|
|
104
|
-
* @param locales Locales to load messages for, defaults to {@link Say.locales}
|
|
105
|
-
* @returns This
|
|
106
|
-
*/
|
|
107
456
|
load(...locales) {
|
|
108
457
|
if (Object.isFrozen(this)) throw new Error("Cannot load messages on a frozen Say");
|
|
109
|
-
if (locales.length === 0) locales =
|
|
458
|
+
if (locales.length === 0) locales = this.#locales;
|
|
110
459
|
const tasks = [];
|
|
111
460
|
for (const locale of locales) {
|
|
112
|
-
if (
|
|
113
|
-
if (!
|
|
114
|
-
const result =
|
|
461
|
+
if (this.#messages.has(locale)) continue;
|
|
462
|
+
if (!this.#loader) throw new Error("No loader provided, cannot load messages");
|
|
463
|
+
const result = this.#loader(locale);
|
|
115
464
|
if (result instanceof Promise) {
|
|
116
465
|
const task = result.then((m) => this.assign(locale, m));
|
|
117
466
|
tasks.push(task);
|
|
@@ -121,209 +470,75 @@ var Say = class Say {
|
|
|
121
470
|
}
|
|
122
471
|
assign(localeOrMessages, maybeMessages) {
|
|
123
472
|
if (Object.isFrozen(this)) throw new Error("Cannot assign messages on a frozen Say");
|
|
124
|
-
if (typeof localeOrMessages === "string")
|
|
125
|
-
else for (const locale in localeOrMessages)
|
|
473
|
+
if (typeof localeOrMessages === "string") this.#messages.set(localeOrMessages, maybeMessages);
|
|
474
|
+
else for (const locale in localeOrMessages) this.#messages.set(locale, localeOrMessages[locale]);
|
|
126
475
|
return this;
|
|
127
476
|
}
|
|
128
|
-
/**
|
|
129
|
-
* Set the active locale.
|
|
130
|
-
*
|
|
131
|
-
* @param locale Locale to set
|
|
132
|
-
* @returns This
|
|
133
|
-
* @throws If locale is not available
|
|
134
|
-
*/
|
|
135
477
|
activate(locale) {
|
|
136
478
|
if (Object.isFrozen(this)) throw new Error("Cannot activate locale on a frozen Say");
|
|
137
|
-
if (!
|
|
138
|
-
|
|
479
|
+
if (!this.#messages.has(locale)) throw new Error("No messages loaded for locale");
|
|
480
|
+
this.#active = locale;
|
|
139
481
|
return this;
|
|
140
482
|
}
|
|
141
|
-
/**
|
|
142
|
-
* Creates a clone of the Say instance, with the same locales and messages.
|
|
143
|
-
*
|
|
144
|
-
* @returns A clone of the Say instance
|
|
145
|
-
*/
|
|
146
483
|
clone() {
|
|
147
484
|
const copy = new Say({
|
|
148
|
-
locales:
|
|
149
|
-
messages: Object.fromEntries(
|
|
150
|
-
loader:
|
|
485
|
+
locales: this.#locales,
|
|
486
|
+
messages: Object.fromEntries(this.#messages),
|
|
487
|
+
loader: this.#loader
|
|
151
488
|
});
|
|
152
|
-
|
|
489
|
+
copy.#active = this.#active;
|
|
153
490
|
return copy;
|
|
154
491
|
}
|
|
155
|
-
/**
|
|
156
|
-
* Make this `Say` instance immutable.
|
|
157
|
-
*/
|
|
158
492
|
freeze() {
|
|
159
493
|
return Object.freeze(this);
|
|
160
494
|
}
|
|
161
|
-
*[
|
|
162
|
-
for (const l of
|
|
495
|
+
*[Symbol.iterator]() {
|
|
496
|
+
for (const l of this.#locales) yield [this.clone().activate(l).freeze(), l];
|
|
163
497
|
}
|
|
164
|
-
/**
|
|
165
|
-
* Matches the best locale from a list of guesses.
|
|
166
|
-
*
|
|
167
|
-
* @param guesses List of locale guesses
|
|
168
|
-
*
|
|
169
|
-
* @returns The best matching locale, or the first locale if no matches are found
|
|
170
|
-
*/
|
|
171
498
|
match(...guesses) {
|
|
172
499
|
const flat = guesses.flat();
|
|
173
|
-
if (flat.length === 0) return
|
|
500
|
+
if (flat.length === 0) return this.#locales[0];
|
|
174
501
|
for (const guess of flat) {
|
|
175
|
-
if (
|
|
502
|
+
if (this.#locales.includes(guess)) return guess;
|
|
176
503
|
const prefix = guess.split("-")[0];
|
|
177
504
|
if (!prefix) continue;
|
|
178
|
-
const match =
|
|
505
|
+
const match = this.#locales.find((l) => l.startsWith(prefix));
|
|
179
506
|
if (match) return match;
|
|
180
507
|
}
|
|
181
|
-
return
|
|
508
|
+
return this.#locales[0];
|
|
182
509
|
}
|
|
183
|
-
/**
|
|
184
|
-
* Get the translation for a descriptor.
|
|
185
|
-
*
|
|
186
|
-
* @param descriptor Descriptor to get the translation for
|
|
187
|
-
* @returns The translation string for the descriptor
|
|
188
|
-
* @throws If no locale is active
|
|
189
|
-
* @throws If no messages are available for the active locale
|
|
190
|
-
* @throws If descriptor id is not found
|
|
191
|
-
*/
|
|
192
510
|
call(descriptor) {
|
|
193
|
-
return
|
|
511
|
+
return this.#call(this.locale, this.messages, descriptor);
|
|
194
512
|
}
|
|
195
|
-
|
|
196
|
-
|
|
513
|
+
#call(locale, messages, descriptor) {
|
|
514
|
+
const message = messages[descriptor.id];
|
|
515
|
+
if (typeof message !== "string") throw new Error(`Message for ${descriptor.id} is not a string`);
|
|
516
|
+
const key = `${locale}:${descriptor.id}`;
|
|
517
|
+
const format = this.#formats.get(key) ?? this.#formats.set(key, compile(locale, message)).get(key);
|
|
518
|
+
return String(format.format(resolveDescriptorValues(descriptor)));
|
|
519
|
+
}
|
|
520
|
+
[Symbol.for("nodejs.util.inspect.custom")](_depth, context, inspect) {
|
|
521
|
+
if (this.#active) return `${this.constructor.name}<${inspect(this.#active, context)}> {}`;
|
|
197
522
|
else return `${this.constructor.name} {}`;
|
|
198
523
|
}
|
|
199
|
-
/**
|
|
200
|
-
* Define a pluralised message.
|
|
201
|
-
*
|
|
202
|
-
* @example
|
|
203
|
-
* ```ts
|
|
204
|
-
* say.plural(count, {
|
|
205
|
-
* one: 'You have 1 item',
|
|
206
|
-
* other: `You have ${count} items`,
|
|
207
|
-
* })
|
|
208
|
-
* ```
|
|
209
|
-
*
|
|
210
|
-
* Interpolating the selector into a branch extracts as ICU's `#`, the number
|
|
211
|
-
* the message branched on. A `#` you write yourself is text.
|
|
212
|
-
* @param _ Number to determine the plural form of
|
|
213
|
-
* @param options Pluralisation rules keyed by CLDR categories or specific numbers
|
|
214
|
-
* @returns The plural form of the number
|
|
215
|
-
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
216
|
-
*/
|
|
217
524
|
plural(_, options) {
|
|
218
525
|
throw new Error("'Say#plural' is a macro and must be used with the relevant saykit plugin");
|
|
219
526
|
}
|
|
220
|
-
/**
|
|
221
|
-
* Define an ordinal message (e.g. "1st", "2nd", "3rd").
|
|
222
|
-
*
|
|
223
|
-
* Interpolating the selector into a branch extracts as ICU's `#`, the number
|
|
224
|
-
* the message branched on. A `#` you write yourself is text.
|
|
225
|
-
*
|
|
226
|
-
* @example
|
|
227
|
-
* ```ts
|
|
228
|
-
* say.ordinal(position, {
|
|
229
|
-
* 1: `${position}st`,
|
|
230
|
-
* 2: `${position}nd`,
|
|
231
|
-
* 3: `${position}rd`,
|
|
232
|
-
* other: `${position}th`,
|
|
233
|
-
* })
|
|
234
|
-
* ```
|
|
235
|
-
*
|
|
236
|
-
* @param _ Number to determine the ordinal form of
|
|
237
|
-
* @param options Ordinal rules keyed by CLDR categories or specific numbers
|
|
238
|
-
* @returns The ordinal form of the number
|
|
239
|
-
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
240
|
-
*/
|
|
241
527
|
ordinal(_, options) {
|
|
242
528
|
throw new Error("'Say#ordinal' is a macro and must be used with the relevant saykit plugin");
|
|
243
529
|
}
|
|
244
|
-
/**
|
|
245
|
-
* Define a select message, useful for handling gender, status, or other categories.
|
|
246
|
-
*
|
|
247
|
-
* @example
|
|
248
|
-
* ```ts
|
|
249
|
-
* say.select(gender, {
|
|
250
|
-
* male: 'He',
|
|
251
|
-
* female: 'She',
|
|
252
|
-
* other: 'They',
|
|
253
|
-
* })
|
|
254
|
-
* ```
|
|
255
|
-
*
|
|
256
|
-
* @param _ Selector value to determine which option is chosen
|
|
257
|
-
* @param options A mapping of possible selector values to message strings
|
|
258
|
-
* @returns The select form of the value
|
|
259
|
-
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
260
|
-
*/
|
|
261
530
|
select(_, options) {
|
|
262
531
|
throw new Error("'Say#select' is a macro and must be used with the relevant saykit plugin");
|
|
263
532
|
}
|
|
264
|
-
/**
|
|
265
|
-
* Format a number the way the active locale writes one, with its own grouping
|
|
266
|
-
* separators and decimal mark.
|
|
267
|
-
*
|
|
268
|
-
* Unlike `plural`, `ordinal`, and `select`, this is a fragment rather than a
|
|
269
|
-
* whole message, and is normally written inside one.
|
|
270
|
-
*
|
|
271
|
-
* @example
|
|
272
|
-
* ```ts
|
|
273
|
-
* say`You have ${say.number(items.length)} items`
|
|
274
|
-
* say`Battery at ${say.number(level, { style: 'percent' })}`
|
|
275
|
-
* say`Total: ${say.number({ cartTotal: getTotal() }, { style: '#,##0.00' })}`
|
|
276
|
-
* ```
|
|
277
|
-
*
|
|
278
|
-
* @param _ Number to format
|
|
279
|
-
* @param options Formatting style, either a named style or a literal number pattern
|
|
280
|
-
* @returns The formatted number
|
|
281
|
-
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
282
|
-
*/
|
|
283
533
|
number(_, options) {
|
|
284
534
|
throw new Error("'Say#number' is a macro and must be used with the relevant saykit plugin");
|
|
285
535
|
}
|
|
286
|
-
/**
|
|
287
|
-
* Format the date portion of a value the way the active locale writes one.
|
|
288
|
-
*
|
|
289
|
-
* @example
|
|
290
|
-
* ```ts
|
|
291
|
-
* say`Published ${say.date(post.publishedAt)}`
|
|
292
|
-
* say`Published ${say.date(post.publishedAt, { style: 'full' })}`
|
|
293
|
-
* ```
|
|
294
|
-
*
|
|
295
|
-
* @param _ Date to format
|
|
296
|
-
* @param options Formatting style
|
|
297
|
-
* @returns The formatted date
|
|
298
|
-
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
299
|
-
*/
|
|
300
536
|
date(_, options) {
|
|
301
537
|
throw new Error("'Say#date' is a macro and must be used with the relevant saykit plugin");
|
|
302
538
|
}
|
|
303
|
-
/**
|
|
304
|
-
* Format the time portion of a value the way the active locale writes one.
|
|
305
|
-
*
|
|
306
|
-
* @example
|
|
307
|
-
* ```ts
|
|
308
|
-
* say`Doors open at ${say.time(opensAt)}`
|
|
309
|
-
* say`Doors open at ${say.time(opensAt, { style: 'short' })}`
|
|
310
|
-
* ```
|
|
311
|
-
*
|
|
312
|
-
* @param _ Date to format
|
|
313
|
-
* @param options Formatting style
|
|
314
|
-
* @returns The formatted time
|
|
315
|
-
* @remark This is a macro and must be used with the relevant saykit plugin
|
|
316
|
-
*/
|
|
317
539
|
time(_, options) {
|
|
318
540
|
throw new Error("'Say#time' is a macro and must be used with the relevant saykit plugin");
|
|
319
541
|
}
|
|
320
542
|
};
|
|
321
|
-
function _call(locale, messages, descriptor) {
|
|
322
|
-
const message = messages[descriptor.id];
|
|
323
|
-
if (typeof message !== "string") throw new Error(`Message for ${descriptor.id} is not a string`);
|
|
324
|
-
const key = `${locale}:${descriptor.id}`;
|
|
325
|
-
const format = _classPrivateFieldGet2(_formats, this).get(key) ?? _classPrivateFieldGet2(_formats, this).set(key, mf1ToMessage(locale, message)).get(key);
|
|
326
|
-
return String(format.format(resolveDescriptorValues(descriptor)));
|
|
327
|
-
}
|
|
328
543
|
//#endregion
|
|
329
544
|
export { Say };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "saykit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Type-safe i18n library with compile-time macro transforms",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"i18n",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"!dist/**/*.map"
|
|
26
26
|
],
|
|
27
27
|
"type": "module",
|
|
28
|
+
"sideEffects": false,
|
|
28
29
|
"exports": {
|
|
29
30
|
".": {
|
|
30
31
|
"types": "./dist/runtime.d.mts",
|
|
@@ -36,7 +37,10 @@
|
|
|
36
37
|
"provenance": true
|
|
37
38
|
},
|
|
38
39
|
"dependencies": {
|
|
39
|
-
"@messageformat/
|
|
40
|
+
"@messageformat/date-skeleton": "2.0.0-0",
|
|
41
|
+
"@messageformat/number-skeleton": "2.0.0-0",
|
|
42
|
+
"@messageformat/parser": "^5.1.1",
|
|
43
|
+
"messageformat": "^4.0.0"
|
|
40
44
|
},
|
|
41
45
|
"scripts": {
|
|
42
46
|
"check": "tsc --noEmit",
|