mygo-runtime 0.4.8 → 0.5.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 CHANGED
@@ -40,3 +40,16 @@ await done;
40
40
  A Go method returning an error rejects with a `CallError` (see
41
41
  `isCallError`). Outside a MyGo window, e.g. when the dev server is opened in a
42
42
  browser, calls reject and `isMyGo()` is false.
43
+
44
+ `mygo-runtime/i18n` formats the messages of the app's catalogs in its pages,
45
+ as package `i18n` does in Go: messages of ICU MessageFormat, with the plural
46
+ rules, numbers and dates of `Intl`. `mygo generate` writes a typed module of
47
+ the catalogs next to the client (`locales.ts`) on top of it:
48
+
49
+ ```ts
50
+ import { messages } from "./locales"; // generated
51
+
52
+ status.textContent = messages.filesSelected(3); // "3 files selected"
53
+ ```
54
+
55
+ `formatMessage(locale, message, args)` formats a message of your own.
package/dist/i18n.d.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Formats the messages of a MyGo app's catalogs in its pages: messages of
3
+ * ICU MessageFormat, as package i18n formats them in Go, with the plural
4
+ * rules, numbers and dates of the browser's `Intl`. The module
5
+ * `mygo generate` writes next to the TypeScript client (`locales.ts`) is
6
+ * built on it:
7
+ *
8
+ * ```ts
9
+ * import { messages } from "./locales";
10
+ *
11
+ * status.textContent = messages.filesSelected(3); // "3 files selected"
12
+ * ```
13
+ *
14
+ * Use {@link formatMessage} directly to format a message of your own.
15
+ *
16
+ * @module
17
+ */
18
+ /** A part of a parsed message. */
19
+ export type MessagePart = string | {
20
+ type: "pound";
21
+ } | {
22
+ type: "arg";
23
+ name: string;
24
+ } | {
25
+ type: "number" | "date" | "time";
26
+ name: string;
27
+ style: string;
28
+ } | {
29
+ type: "plural" | "selectordinal" | "select";
30
+ name: string;
31
+ offset: number;
32
+ cases: {
33
+ key: string;
34
+ parts: MessagePart[];
35
+ }[];
36
+ };
37
+ /** The error of a message that does not parse. */
38
+ export declare class MessageSyntaxError extends Error {
39
+ /** The message. */
40
+ readonly source: string;
41
+ /** Where in it the error is, in UTF-16 code units. */
42
+ readonly offset: number;
43
+ constructor(
44
+ /** The message. */
45
+ source: string,
46
+ /** Where in it the error is, in UTF-16 code units. */
47
+ offset: number, reason: string);
48
+ }
49
+ /**
50
+ * Parses a message of ICU MessageFormat, as package i18n's ParseMessage
51
+ * does: the same messages parse, and fail, in Go and in pages.
52
+ */
53
+ export declare function parseMessage(source: string): MessagePart[];
54
+ /** The hooks that write numbers and dates, which tests and apps may replace. */
55
+ export declare const formatters: {
56
+ /**
57
+ * Writes a number in an ICU number style: "" (the default), "integer",
58
+ * "percent", or a skeleton ("::percent .00"), of which this formatter
59
+ * reads percent, integer, precision-integer, the precision of fraction
60
+ * digits (.00, .0#, .##), group-off and currency/, and writes the rest
61
+ * as the default. Package i18n reads the same, but currencies.
62
+ */
63
+ number(locale: string, n: number, style: string, digits?: number): string;
64
+ /**
65
+ * Writes a time: its date, its time or both ("datetime"), in an ICU
66
+ * style, "short", "medium" (the default), "long" or "full", or a
67
+ * skeleton of CLDR ("::yMMMd"), which Intl finds the locale's pattern
68
+ * of as package i18n does.
69
+ */
70
+ date(locale: string, d: Date, kind: "date" | "time" | "datetime", style: string): string;
71
+ };
72
+ /**
73
+ * The options of Intl.DateTimeFormat of a skeleton of CLDR, "yMMMd" or
74
+ * "jm": V8 makes the skeleton back of them, and finds its pattern with
75
+ * ICU's DateTimePatternGenerator. An hour letter other than j is an
76
+ * hourCycle, as in package i18n. EEEEEE, CLDR's short weekday ("Mo"),
77
+ * has no option of Intl: it is the abbreviated one ("Mon"), where package
78
+ * i18n writes the short one.
79
+ */
80
+ export declare function skeletonOptions(skeleton: string): Intl.DateTimeFormatOptions;
81
+ /** The pseudo locales, which test layouts without translations. */
82
+ export declare const pseudoLocales: readonly ["en-XA", "ar-XB"];
83
+ /** Reports whether tag is a pseudo locale: en-XA or ar-XB. */
84
+ export declare function isPseudoLocale(tag: string): boolean;
85
+ /**
86
+ * Formats a message of ICU MessageFormat with the values of its arguments
87
+ * for locale, as package i18n's Message.Format does: its plural rules
88
+ * pick the cases, its conventions write numbers and dates, and the pseudo
89
+ * locales en-XA and ar-XB transform the message. An argument args lacks is
90
+ * written as it is, `{name}`. Throws a {@link MessageSyntaxError} for a
91
+ * message that does not parse.
92
+ */
93
+ export declare function formatMessage(locale: string, message: string, args?: Record<string, unknown>): string;
94
+ /** The data of a catalog, as the module `mygo generate` writes has it. */
95
+ export interface CatalogData {
96
+ /** The source language, which every message falls back to. */
97
+ source: string;
98
+ /** The languages of the catalog: the source, then the others. */
99
+ languages: string[];
100
+ /** For each language, the languages it falls back along: itself, its parents, the source. */
101
+ fallbacks: Record<string, string[]>;
102
+ /** The names of the arguments of each key, in the order its function takes them. */
103
+ params: Record<string, string[]>;
104
+ /** The messages of each language by key. */
105
+ messages: Record<string, Record<string, string>>;
106
+ }
107
+ /** A catalog of messages in a page, which {@link createCatalog} returns. */
108
+ export interface Catalog<M> {
109
+ /** The languages of the catalog: the source, then the others. */
110
+ readonly languages: readonly string[];
111
+ /** Formats the message of key in locale, or returns key without one. */
112
+ format(locale: string, key: string, args?: Record<string, unknown>): string;
113
+ /** Returns the language of the catalog whose messages locale says. */
114
+ resolve(locale: string): string;
115
+ /** The messages in the language the page shows, read at each call. */
116
+ messages(): M;
117
+ /** The messages in the language of locale. */
118
+ messagesIn(locale: string): M;
119
+ }
120
+ /**
121
+ * Returns the language the page shows: the app's, which the MyGo runtime
122
+ * holds (`window.mygo.locale`, set before the page's scripts run), else
123
+ * the browser's first preferred language.
124
+ */
125
+ export declare function currentLocale(): string;
126
+ /** Creates the catalog of a module `mygo generate` wrote. */
127
+ export declare function createCatalog<M>(data: CatalogData): Catalog<M>;
package/dist/i18n.js ADDED
@@ -0,0 +1,661 @@
1
+ // src/i18n.ts
2
+ class MessageSyntaxError extends Error {
3
+ source;
4
+ offset;
5
+ constructor(source, offset, reason) {
6
+ super(`${reason} at offset ${offset} of ${JSON.stringify(source)}`);
7
+ this.source = source;
8
+ this.offset = offset;
9
+ this.name = "MessageSyntaxError";
10
+ }
11
+ }
12
+ var pluralKeywords = ["zero", "one", "two", "few", "many", "other"];
13
+ function isPatternSpace(c) {
14
+ return /^[\t\n\v\f\r \u0085\u200e\u200f\u2028\u2029]$/.test(c);
15
+ }
16
+ function isPatternSyntax(c) {
17
+ const r = c.codePointAt(0);
18
+ if (r < 128) {
19
+ return r >= 33 && r <= 47 || r >= 58 && r <= 64 || r >= 91 && r <= 94 || r === 96 || r >= 123 && r <= 126;
20
+ }
21
+ return r >= 161 && r <= 167 || r === 169 || r === 171 || r === 172 || r === 174 || r === 176 || r === 177 || r === 182 || r === 187 || r === 191 || r === 215 || r === 247 || r >= 8208 && r <= 8231 || r >= 8240 && r <= 8254 || r >= 8257 && r <= 8275 || r >= 8277 && r <= 8286 || r >= 8592 && r <= 9311 || r >= 9472 && r <= 10101 || r >= 10132 && r <= 11263 || r >= 11776 && r <= 11903 || r >= 12289 && r <= 12291 || r >= 12296 && r <= 12320 || r === 12336 || r === 64830 || r === 64831 || r === 65093 || r === 65094;
22
+ }
23
+ var decimalRe = /^-?\d+(\.\d+)?$/;
24
+ function parseMessage(source) {
25
+ let i = 0;
26
+ const s = source;
27
+ const fail = (reason) => {
28
+ throw new MessageSyntaxError(source, i, reason);
29
+ };
30
+ const space = () => {
31
+ while (i < s.length && isPatternSpace(s[i]))
32
+ i++;
33
+ };
34
+ const word = () => {
35
+ const start = i;
36
+ while (i < s.length) {
37
+ const c = String.fromCodePoint(s.codePointAt(i));
38
+ if (isPatternSpace(c) || isPatternSyntax(c))
39
+ break;
40
+ i += c.length;
41
+ }
42
+ return s.slice(start, i);
43
+ };
44
+ const validName = (name) => /^\d/.test(name) ? /^(0|[1-9]\d*)$/.test(name) : /^[\p{L}_]/u.test(name);
45
+ const message = (depth, inPlural) => {
46
+ if (depth > 32)
47
+ fail("arguments nest too deep");
48
+ const parts = [];
49
+ let text = "";
50
+ const flush = () => {
51
+ if (text !== "")
52
+ parts.push(text);
53
+ text = "";
54
+ };
55
+ while (i < s.length) {
56
+ const c = s[i];
57
+ if (c === "'") {
58
+ i++;
59
+ if (s[i] === "'") {
60
+ text += "'";
61
+ i++;
62
+ } else if (i < s.length && (s[i] === "{" || s[i] === "}" || s[i] === "#" && inPlural)) {
63
+ while (i < s.length) {
64
+ if (s[i] === "'") {
65
+ if (s[i + 1] === "'") {
66
+ text += "'";
67
+ i += 2;
68
+ continue;
69
+ }
70
+ i++;
71
+ break;
72
+ }
73
+ text += s[i];
74
+ i++;
75
+ }
76
+ } else {
77
+ text += "'";
78
+ }
79
+ } else if (c === "{") {
80
+ flush();
81
+ parts.push(argument(depth));
82
+ } else if (c === "}") {
83
+ break;
84
+ } else if (c === "#" && inPlural) {
85
+ flush();
86
+ parts.push({ type: "pound" });
87
+ i++;
88
+ } else {
89
+ text += c;
90
+ i++;
91
+ }
92
+ }
93
+ flush();
94
+ return parts;
95
+ };
96
+ const argument = (depth) => {
97
+ i++;
98
+ space();
99
+ const name = word();
100
+ if (name === "")
101
+ fail("an argument has no name");
102
+ if (!validName(name))
103
+ fail(`${JSON.stringify(name)} is not an argument name`);
104
+ space();
105
+ if (i >= s.length)
106
+ fail(`unclosed argument {${name}`);
107
+ if (s[i] === "}") {
108
+ i++;
109
+ return { type: "arg", name };
110
+ }
111
+ if (s[i] !== ",")
112
+ fail(`expected , or } after the argument name ${JSON.stringify(name)}`);
113
+ i++;
114
+ space();
115
+ const type = word();
116
+ space();
117
+ if (type === "number" || type === "date" || type === "time") {
118
+ let style = "";
119
+ if (s[i] === ",") {
120
+ i++;
121
+ const start = i;
122
+ let nest = 0;
123
+ for (;; ) {
124
+ if (i >= s.length)
125
+ fail("unclosed style");
126
+ const c = s[i];
127
+ if (c === "'") {
128
+ const end = s.indexOf("'", i + 1);
129
+ if (end < 0)
130
+ fail("unclosed quote in a style");
131
+ i = end + 1;
132
+ continue;
133
+ }
134
+ if (c === "{")
135
+ nest++;
136
+ if (c === "}") {
137
+ if (nest === 0)
138
+ break;
139
+ nest--;
140
+ }
141
+ i++;
142
+ }
143
+ style = s.slice(start, i).replace(/^[\s\u0085\u200e\u200f]+|[\s\u0085\u200e\u200f]+$/g, "");
144
+ if (style === "")
145
+ fail(`the ${type} argument ${JSON.stringify(name)} has an empty style`);
146
+ }
147
+ if (s[i] !== "}")
148
+ fail(`unclosed argument {${name}, ${type}`);
149
+ i++;
150
+ return { type, name, style };
151
+ }
152
+ if (type === "")
153
+ fail(`the argument ${JSON.stringify(name)} has no type`);
154
+ if (type !== "plural" && type !== "selectordinal" && type !== "select") {
155
+ fail(`unknown argument type ${JSON.stringify(type)} (want number, date, time, plural, selectordinal or select)`);
156
+ }
157
+ if (s[i] !== ",")
158
+ fail(`expected , after ${type}`);
159
+ i++;
160
+ const plural = type !== "select";
161
+ let offset = 0;
162
+ space();
163
+ if (plural && s.startsWith("offset:", i)) {
164
+ i += "offset:".length;
165
+ space();
166
+ const start = i;
167
+ while (i < s.length && s[i] >= "0" && s[i] <= "9")
168
+ i++;
169
+ if (start === i || i - start > 15)
170
+ fail("offset: needs a number");
171
+ offset = Number(s.slice(start, i));
172
+ }
173
+ const cases = [];
174
+ for (;; ) {
175
+ space();
176
+ if (i >= s.length)
177
+ fail(`unclosed ${type} argument ${JSON.stringify(name)}`);
178
+ if (s[i] === "}") {
179
+ i++;
180
+ break;
181
+ }
182
+ let key;
183
+ if (plural && s[i] === "=") {
184
+ i++;
185
+ const start = i;
186
+ while (i < s.length && /[0-9.]/.test(s[i]))
187
+ i++;
188
+ const value = s.slice(start, i);
189
+ if (!/^\d+(\.\d+)?$/.test(value))
190
+ fail(`=${value} is not a number`);
191
+ key = "=" + value;
192
+ } else {
193
+ key = word();
194
+ if (key === "")
195
+ fail(`expected a case of the ${type} argument ${JSON.stringify(name)}`);
196
+ if (plural && !pluralKeywords.includes(key)) {
197
+ fail(`${JSON.stringify(key)} is not a plural category (zero, one, two, few, many, other)`);
198
+ }
199
+ }
200
+ if (cases.some((c) => c.key === key))
201
+ fail(`the ${type} argument ${JSON.stringify(name)} has the case ${key} twice`);
202
+ space();
203
+ if (s[i] !== "{")
204
+ fail(`expected { after the case ${key}`);
205
+ i++;
206
+ const parts = message(depth + 1, plural);
207
+ if (i >= s.length)
208
+ fail(`unclosed case ${key}`);
209
+ i++;
210
+ cases.push({ key, parts });
211
+ }
212
+ if (!cases.some((c) => c.key === "other"))
213
+ fail(`the ${type} argument ${JSON.stringify(name)} has no other case`);
214
+ return { type, name, offset, cases };
215
+ };
216
+ const parts = message(0, false);
217
+ if (i < s.length)
218
+ fail("unmatched }");
219
+ return parts;
220
+ }
221
+ function toNum(v) {
222
+ if (typeof v === "number")
223
+ return Number.isFinite(v) ? { value: v, text: String(v) } : undefined;
224
+ if (typeof v === "bigint")
225
+ return { value: Number(v), text: v.toString() };
226
+ if (typeof v === "string") {
227
+ const t = v.trim();
228
+ if (!decimalRe.test(t))
229
+ return;
230
+ return { value: Number(t), digits: t.includes(".") ? t.split(".")[1].length : 0, text: t };
231
+ }
232
+ return;
233
+ }
234
+ function minus(n, offset) {
235
+ if (offset === 0)
236
+ return n;
237
+ const value = n.value - offset;
238
+ if (n.digits !== undefined)
239
+ return { value, digits: n.digits, text: value.toFixed(n.digits) };
240
+ return { value, text: String(value) };
241
+ }
242
+ function exactEquals(text, exact) {
243
+ if (text.startsWith("-"))
244
+ return false;
245
+ const norm = (s) => {
246
+ let [i, f = ""] = s.split(".");
247
+ i = i.replace(/^0+(?=\d)/, "");
248
+ f = f.replace(/0+$/, "");
249
+ return f === "" ? i : `${i}.${f}`;
250
+ };
251
+ return /^\d+(\.\d+)?$/.test(text) && norm(text) === norm(exact);
252
+ }
253
+ var caches = new Map;
254
+ function cached(key, make) {
255
+ let v = caches.get(key);
256
+ if (!v) {
257
+ v = make();
258
+ if (caches.size > 256)
259
+ caches.clear();
260
+ caches.set(key, v);
261
+ }
262
+ return v;
263
+ }
264
+ function safeLocale(locale) {
265
+ try {
266
+ return Intl.getCanonicalLocales(locale)[0];
267
+ } catch {
268
+ return;
269
+ }
270
+ }
271
+ function pluralCategory(locale, n, type) {
272
+ const digits = n.digits;
273
+ const pr = cached(`p|${locale}|${type}|${digits ?? ""}`, () => new Intl.PluralRules(safeLocale(locale), digits === undefined ? { type } : { type, minimumFractionDigits: digits, maximumFractionDigits: digits }));
274
+ return pr.select(Math.abs(n.value));
275
+ }
276
+ var formatters = {
277
+ number(locale, n, style, digits) {
278
+ const options = {};
279
+ const stems = style.startsWith("::") ? style.slice(2).trim().split(/\s+/) : [style];
280
+ let fraction = false;
281
+ for (const stem of stems) {
282
+ if (stem === "integer" || stem === "precision-integer") {
283
+ options.maximumFractionDigits = 0;
284
+ options.roundingMode = "halfEven";
285
+ } else if (stem === "percent") {
286
+ options.style = "percent";
287
+ options.roundingMode = "halfEven";
288
+ } else if (stem === "group-off") {
289
+ options.useGrouping = false;
290
+ } else if (/^\.[0#]+$/.test(stem)) {
291
+ options.minimumFractionDigits = stem.split("0").length - 1;
292
+ options.maximumFractionDigits = stem.length - 1;
293
+ fraction = true;
294
+ } else if (stem.startsWith("currency/")) {
295
+ options.style = "currency";
296
+ options.currency = stem.slice("currency/".length);
297
+ }
298
+ }
299
+ if (digits !== undefined && !fraction && options.style === undefined && options.maximumFractionDigits === undefined) {
300
+ options.minimumFractionDigits = options.maximumFractionDigits = Math.min(digits, 100);
301
+ }
302
+ const nf = cached(`n|${locale}|${JSON.stringify(options)}`, () => new Intl.NumberFormat(safeLocale(locale), options));
303
+ return nf.format(n);
304
+ },
305
+ date(locale, d, kind, style) {
306
+ const s = ["short", "medium", "long", "full"].includes(style) ? style : "medium";
307
+ const options = style.startsWith("::") ? skeletonOptions(style.slice(2).trim()) : kind === "date" ? { dateStyle: s } : kind === "time" ? { timeStyle: s } : { dateStyle: "short", timeStyle: "short" };
308
+ const df = cached(`d|${locale}|${JSON.stringify(options)}`, () => new Intl.DateTimeFormat(safeLocale(locale), options));
309
+ return df.format(d);
310
+ }
311
+ };
312
+ function skeletonOptions(skeleton) {
313
+ const o = {};
314
+ for (const [field] of skeleton.matchAll(/([A-Za-z])\1*/g)) {
315
+ const n = field.length;
316
+ switch (field[0]) {
317
+ case "y":
318
+ o.year = n === 2 ? "2-digit" : "numeric";
319
+ break;
320
+ case "M":
321
+ case "L":
322
+ o.month = n === 1 ? "numeric" : n === 2 ? "2-digit" : n === 3 ? "short" : n === 4 ? "long" : "narrow";
323
+ break;
324
+ case "d":
325
+ o.day = n === 2 ? "2-digit" : "numeric";
326
+ break;
327
+ case "E":
328
+ case "c":
329
+ o.weekday = n === 4 ? "long" : n === 5 ? "narrow" : "short";
330
+ break;
331
+ case "j":
332
+ case "h":
333
+ case "H":
334
+ case "K":
335
+ case "k":
336
+ o.hour = n === 2 ? "2-digit" : "numeric";
337
+ if (field[0] === "h")
338
+ o.hourCycle = "h12";
339
+ if (field[0] === "H")
340
+ o.hourCycle = "h23";
341
+ if (field[0] === "K")
342
+ o.hourCycle = "h11";
343
+ if (field[0] === "k")
344
+ o.hourCycle = "h24";
345
+ break;
346
+ case "m":
347
+ o.minute = n === 2 ? "2-digit" : "numeric";
348
+ break;
349
+ case "s":
350
+ o.second = n === 2 ? "2-digit" : "numeric";
351
+ break;
352
+ case "z":
353
+ case "v":
354
+ o.timeZoneName = n >= 4 ? "long" : "short";
355
+ break;
356
+ }
357
+ }
358
+ return o;
359
+ }
360
+ function valueString(locale, v) {
361
+ if (typeof v === "string")
362
+ return v;
363
+ if (v instanceof Date)
364
+ return formatters.date(locale, v, "datetime", "short");
365
+ if (v === null || v === undefined)
366
+ return "";
367
+ if (typeof v === "number" || typeof v === "bigint") {
368
+ const n = toNum(v);
369
+ return n ? formatters.number(locale, n.value, "", n.digits) : String(v);
370
+ }
371
+ if (v instanceof Error)
372
+ return v.message;
373
+ return String(v);
374
+ }
375
+ var accented = {
376
+ a: "å",
377
+ b: "ƀ",
378
+ c: "ç",
379
+ d: "ð",
380
+ e: "é",
381
+ f: "ƒ",
382
+ g: "ĝ",
383
+ h: "ĥ",
384
+ i: "î",
385
+ j: "ĵ",
386
+ k: "ķ",
387
+ l: "ļ",
388
+ m: "ɱ",
389
+ n: "ñ",
390
+ o: "ö",
391
+ p: "þ",
392
+ q: "ǫ",
393
+ r: "ŕ",
394
+ s: "š",
395
+ t: "ţ",
396
+ u: "û",
397
+ v: "ṽ",
398
+ w: "ŵ",
399
+ x: "ẋ",
400
+ y: "ý",
401
+ z: "ž",
402
+ A: "Å",
403
+ B: "Ɓ",
404
+ C: "Ç",
405
+ D: "Ð",
406
+ E: "É",
407
+ F: "Ƒ",
408
+ G: "Ĝ",
409
+ H: "Ĥ",
410
+ I: "Î",
411
+ J: "Ĵ",
412
+ K: "Ķ",
413
+ L: "Ļ",
414
+ M: "Ṁ",
415
+ N: "Ñ",
416
+ O: "Ö",
417
+ P: "Þ",
418
+ Q: "Ǫ",
419
+ R: "Ŕ",
420
+ S: "Š",
421
+ T: "Ţ",
422
+ U: "Û",
423
+ V: "Ṽ",
424
+ W: "Ŵ",
425
+ X: "Ẋ",
426
+ Y: "Ý",
427
+ Z: "Ž"
428
+ };
429
+ var padWords = "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty".split(" ");
430
+ var RLM = "‏";
431
+ var RLO = "‮";
432
+ var PDF = "‬";
433
+ var pseudoLocales = ["en-XA", "ar-XB"];
434
+ function canonical(tag) {
435
+ return safeLocale(tag.replace(/_/g, "-").split(/[.@]/)[0]) ?? "";
436
+ }
437
+ function stripExtensions(tag) {
438
+ const parts = tag.split("-");
439
+ const i = parts.findIndex((p, i) => i > 0 && p.length === 1);
440
+ return i < 0 ? tag : parts.slice(0, i).join("-");
441
+ }
442
+ function isPseudoLocale(tag) {
443
+ return pseudoLocales.includes(stripExtensions(canonical(tag)));
444
+ }
445
+ function pseudoFor(locale) {
446
+ switch (stripExtensions(canonical(locale))) {
447
+ case "en-XA": {
448
+ let length = 0;
449
+ return {
450
+ text(s) {
451
+ let out = "";
452
+ for (const c of s) {
453
+ out += accented[c] ?? c;
454
+ length++;
455
+ }
456
+ return out;
457
+ },
458
+ placeholder: (s) => s,
459
+ wrap(s) {
460
+ const pad = Math.floor((length * 2 + 4) / 5);
461
+ let out = "[" + s;
462
+ for (let i = 0, n = 0;n < pad; i++) {
463
+ const w = padWords[i % padWords.length];
464
+ out += " " + w;
465
+ n += 1 + w.length;
466
+ }
467
+ return out + "]";
468
+ }
469
+ };
470
+ }
471
+ case "ar-XB":
472
+ return {
473
+ text: (s) => s.replace(/[^\s]+/gu, (w) => RLM + RLO + w + PDF + RLM),
474
+ placeholder: (s) => RLM + s + RLM,
475
+ wrap: (s) => s
476
+ };
477
+ }
478
+ return;
479
+ }
480
+ function formatParts(parts, locale, pluralLocale, args, pseudo, pound) {
481
+ let out = "";
482
+ const placeholder = (s) => pseudo ? pseudo.placeholder(s) : s;
483
+ for (const part of parts) {
484
+ if (typeof part === "string") {
485
+ out += pseudo ? pseudo.text(part) : part;
486
+ continue;
487
+ }
488
+ if (part.type === "pound") {
489
+ if (!pound) {
490
+ out += "#";
491
+ } else if (pound.num) {
492
+ out += placeholder(formatters.number(locale, pound.num.value, "", pound.num.digits));
493
+ } else {
494
+ out += placeholder(valueString(locale, pound.raw));
495
+ }
496
+ continue;
497
+ }
498
+ if (!Object.prototype.hasOwnProperty.call(args, part.name)) {
499
+ out += `{${part.name}}`;
500
+ continue;
501
+ }
502
+ const v = args[part.name];
503
+ switch (part.type) {
504
+ case "arg":
505
+ out += placeholder(valueString(locale, v));
506
+ break;
507
+ case "number": {
508
+ const n = toNum(v);
509
+ out += placeholder(n ? formatters.number(locale, n.value, part.style, n.digits) : valueString(locale, v));
510
+ break;
511
+ }
512
+ case "date":
513
+ case "time":
514
+ out += placeholder(v instanceof Date ? formatters.date(locale, v, part.type, part.style) : valueString(locale, v));
515
+ break;
516
+ case "select": {
517
+ const key = typeof v === "string" ? v : typeof v === "number" ? toNum(v)?.text ?? String(v) : String(v);
518
+ const c = part.cases.find((c) => c.key === key) ?? part.cases.find((c) => c.key === "other");
519
+ out += formatParts(c.parts, locale, pluralLocale, args, pseudo, pound);
520
+ break;
521
+ }
522
+ case "plural":
523
+ case "selectordinal": {
524
+ const n = toNum(v);
525
+ const other = part.cases.find((c) => c.key === "other");
526
+ if (!n) {
527
+ out += formatParts(other.parts, locale, pluralLocale, args, pseudo, { raw: v });
528
+ break;
529
+ }
530
+ let chosen = part.cases.find((c) => c.key.startsWith("=") && exactEquals(n.text, c.key.slice(1)));
531
+ const shown = minus(n, part.offset);
532
+ if (!chosen) {
533
+ const cat = pluralCategory(pluralLocale, shown, part.type === "plural" ? "cardinal" : "ordinal");
534
+ chosen = part.cases.find((c) => c.key === cat) ?? other;
535
+ }
536
+ out += formatParts(chosen.parts, locale, pluralLocale, args, pseudo, { num: shown });
537
+ break;
538
+ }
539
+ }
540
+ }
541
+ return out;
542
+ }
543
+ var parsedCache = new Map;
544
+ function parsed(message) {
545
+ let parts = parsedCache.get(message);
546
+ if (!parts) {
547
+ parts = parseMessage(message);
548
+ parsedCache.set(message, parts);
549
+ }
550
+ return parts;
551
+ }
552
+ function formatMessage(locale, message, args = {}) {
553
+ const pseudo = pseudoFor(locale);
554
+ return format(parsed(message), pseudo ? "en" : locale, pseudo ? "en" : locale, args, pseudo);
555
+ }
556
+ function format(parts, locale, pluralLocale, args, pseudo) {
557
+ const out = formatParts(parts, locale, pluralLocale, args, pseudo, undefined);
558
+ return pseudo ? pseudo.wrap(out) : out;
559
+ }
560
+ function currentLocale() {
561
+ const rt = globalThis.mygo;
562
+ const locale = typeof rt?.locale === "function" ? rt.locale() : rt?.locale;
563
+ if (typeof locale === "string" && locale !== "")
564
+ return locale;
565
+ if (typeof navigator !== "undefined")
566
+ return navigator.languages?.[0] ?? navigator.language ?? "";
567
+ return "";
568
+ }
569
+ function createCatalog(data) {
570
+ const resolved = new Map;
571
+ const resolve = (locale) => {
572
+ let r = resolved.get(locale);
573
+ if (r !== undefined)
574
+ return r;
575
+ r = data.source;
576
+ const tag = stripExtensions(canonical(locale));
577
+ if (data.languages.includes(tag)) {
578
+ r = tag;
579
+ } else if (tag !== "" && !isPseudoLocale(tag)) {
580
+ const found = truncations(tag).find((t) => data.languages.includes(t)) ?? sameScript(tag, data.languages);
581
+ if (found)
582
+ r = found;
583
+ }
584
+ resolved.set(locale, r);
585
+ return r;
586
+ };
587
+ const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
588
+ const lookup = (locale, key) => {
589
+ const lang = isPseudoLocale(locale) ? data.source : resolve(locale);
590
+ for (const t of data.fallbacks[lang] ?? [data.source]) {
591
+ const messages = has(data.messages, t) ? data.messages[t] : undefined;
592
+ const m = messages && has(messages, key) ? messages[key] : undefined;
593
+ if (m !== undefined)
594
+ return [m, t];
595
+ }
596
+ return;
597
+ };
598
+ const formatKey = (locale, key, args = {}) => {
599
+ const found = lookup(locale, key);
600
+ if (!found)
601
+ return key;
602
+ const [message, lang] = found;
603
+ let parts;
604
+ try {
605
+ parts = parsed(message);
606
+ } catch {
607
+ return key;
608
+ }
609
+ const pseudo = pseudoFor(locale);
610
+ const numbers = pseudo || locale === "" ? lang : locale;
611
+ return format(parts, numbers, lang, args, pseudo);
612
+ };
613
+ const bind = (locale) => {
614
+ const out = {};
615
+ for (const [key, names] of Object.entries(data.params)) {
616
+ out[key] = (...values) => {
617
+ const args = {};
618
+ names.forEach((name, i) => args[name] = values[i]);
619
+ return formatKey(locale(), key, args);
620
+ };
621
+ }
622
+ return out;
623
+ };
624
+ return {
625
+ languages: data.languages,
626
+ format: formatKey,
627
+ resolve,
628
+ messages: () => bind(currentLocale),
629
+ messagesIn: (locale) => bind(() => locale)
630
+ };
631
+ }
632
+ function truncations(tag) {
633
+ const parts = tag.split("-");
634
+ const out = [];
635
+ for (let n = parts.length;n > 0; n--)
636
+ out.push(parts.slice(0, n).join("-"));
637
+ return out;
638
+ }
639
+ function sameScript(tag, langs) {
640
+ try {
641
+ const want = new Intl.Locale(tag).maximize();
642
+ const same = langs.filter((l) => {
643
+ const have = new Intl.Locale(l).maximize();
644
+ return have.language === want.language && have.script === want.script;
645
+ });
646
+ return same.find((l) => new Intl.Locale(l).maximize().region === want.region) ?? same[0];
647
+ } catch {
648
+ return;
649
+ }
650
+ }
651
+ export {
652
+ MessageSyntaxError,
653
+ createCatalog,
654
+ currentLocale,
655
+ formatMessage,
656
+ formatters,
657
+ isPseudoLocale,
658
+ parseMessage,
659
+ pseudoLocales,
660
+ skeletonOptions
661
+ };
package/dist/index.d.ts CHANGED
@@ -13,8 +13,8 @@
13
13
  *
14
14
  * @module
15
15
  */
16
- import type { Channel as ChannelType, Runtime, WindowControls } from "./types";
17
- export type { Platform, Runtime, WindowControls } from "./types";
16
+ import type { Channel as ChannelType, Direction, Runtime, ThemePreferences, WindowControls } from "./types";
17
+ export type { Direction, LocaleChange, Platform, Runtime, ThemePreferences, WindowControls } from "./types";
18
18
  declare global {
19
19
  interface Window {
20
20
  /** The MyGo runtime; missing outside MyGo windows. */
@@ -56,6 +56,34 @@ export declare const Channel: new <T = unknown>(onmessage?: (value: T) => void)
56
56
  export declare function on<T = unknown>(name: string, listener: (payload: T) => void): () => void;
57
57
  /** Like {@link on}, but unsubscribes after the first event. */
58
58
  export declare function once<T = unknown>(name: string, listener: (payload: T) => void): () => void;
59
+ /**
60
+ * The language the app shows, a BCP 47 tag such as "en" or "zh-Hans"
61
+ * (`App.Locale`): the page has it from document start, so its first paint
62
+ * can be in it.
63
+ *
64
+ * ```ts
65
+ * document.documentElement.lang = locale();
66
+ * document.documentElement.dir = direction();
67
+ * ```
68
+ */
69
+ export declare function locale(): string;
70
+ /** The direction the language the app shows is written in: "ltr" or "rtl". */
71
+ export declare function direction(): Direction;
72
+ /**
73
+ * Subscribes to changes of the language the app shows, as `App.SetLocale`
74
+ * makes them, or the user's languages while the app follows them. The
75
+ * listener hears only of a language the page did not have: a page loaded
76
+ * after a change starts with the new one. Returns a function that
77
+ * unsubscribes.
78
+ *
79
+ * ```ts
80
+ * onLocaleChange((tag, dir) => {
81
+ * document.documentElement.lang = tag;
82
+ * document.documentElement.dir = dir;
83
+ * });
84
+ * ```
85
+ */
86
+ export declare function onLocaleChange(listener: (locale: string, dir: Direction) => void): () => void;
59
87
  /** A typed event sent by the Go side, declared with `mygo.NewEvent`. */
60
88
  export interface GoEvent<T> {
61
89
  readonly name: string;
@@ -95,5 +123,64 @@ export interface FileDrop {
95
123
  * ```
96
124
  */
97
125
  export declare function onFileDrop(listener: (drop: FileDrop) => void): () => void;
126
+ /** The document a window shows (mygo.NewDocuments), as Go describes it. */
127
+ export interface DocumentState {
128
+ /** The file name, or a name such as "Untitled 2" while untitled. */
129
+ name: string;
130
+ /** The file, "" while the document is untitled. */
131
+ path: string;
132
+ /** Whether the document has unsaved changes. */
133
+ edited: boolean;
134
+ /** Whether its file can't be written: saving asks for another. */
135
+ readOnly: boolean;
136
+ /** Whether another app changed its file while it had unsaved changes. */
137
+ conflict: boolean;
138
+ /** Whether its file was deleted: saving writes it again. */
139
+ deleted: boolean;
140
+ }
141
+ /**
142
+ * Subscribes to the state of the window's document: edited, saved,
143
+ * renamed, moved, changed by another app. Only the app's own pages get it.
144
+ * Returns a function that unsubscribes.
145
+ *
146
+ * ```ts
147
+ * onDocumentState(({ name, edited }) => {
148
+ * heading.textContent = edited ? `${name} (edited)` : name;
149
+ * });
150
+ * ```
151
+ */
152
+ export declare function onDocumentState(listener: (state: DocumentState) => void): () => void;
153
+ /**
154
+ * Subscribes to the window's document being read from its file again:
155
+ * reverted, or changed by another app while it had no unsaved changes.
156
+ * Fetch its content again, with the service bound to
157
+ * `Documents.Service()`. Returns a function that unsubscribes.
158
+ */
159
+ export declare function onDocumentReload(listener: () => void): () => void;
160
+ /**
161
+ * Returns the accent color and the accessibility settings of the desktop
162
+ * (`mygo.Theme.Preferences()` in Go), current from document start on.
163
+ * Throws outside MyGo, as {@link runtime} does.
164
+ *
165
+ * ```ts
166
+ * const { accent, reduceTransparency } = preferences();
167
+ * ```
168
+ *
169
+ * CSS gets the accent and the text scale from the `--mygo-accent` and
170
+ * `--mygo-text-scale` variables of `:root`.
171
+ */
172
+ export declare function preferences(): ThemePreferences;
173
+ /**
174
+ * Subscribes to changes of the desktop's preferences, which
175
+ * {@link preferences} returns; the listener gets the new ones. Returns a
176
+ * function that unsubscribes.
177
+ *
178
+ * ```ts
179
+ * const apply = (p: ThemePreferences) => root.classList.toggle("opaque", p.reduceTransparency);
180
+ * apply(preferences());
181
+ * onPreferences(apply);
182
+ * ```
183
+ */
184
+ export declare function onPreferences(listener: (preferences: ThemePreferences) => void): () => void;
98
185
  /** The window hosting the page, e.g. for a custom title bar. */
99
186
  export declare const currentWindow: WindowControls;
package/dist/index.js CHANGED
@@ -24,6 +24,15 @@ function on(name, listener) {
24
24
  function once(name, listener) {
25
25
  return runtime().once(name, listener);
26
26
  }
27
+ function locale() {
28
+ return runtime().locale;
29
+ }
30
+ function direction() {
31
+ return runtime().dir;
32
+ }
33
+ function onLocaleChange(listener) {
34
+ return on("mygo:locale", (p) => listener(p.locale, p.dir));
35
+ }
27
36
  function event(name) {
28
37
  return {
29
38
  name,
@@ -37,6 +46,18 @@ function isCallError(err) {
37
46
  function onFileDrop(listener) {
38
47
  return on("mygo:file-drop", listener);
39
48
  }
49
+ function onDocumentState(listener) {
50
+ return on("mygo:document-state", listener);
51
+ }
52
+ function onDocumentReload(listener) {
53
+ return on("mygo:document-reload", () => listener());
54
+ }
55
+ function preferences() {
56
+ return runtime().preferences;
57
+ }
58
+ function onPreferences(listener) {
59
+ return on("mygo:preferences", listener);
60
+ }
40
61
  var currentWindow = {
41
62
  minimize: async () => runtime().window.minimize(),
42
63
  maximize: async () => runtime().window.maximize(),
@@ -51,11 +72,18 @@ export {
51
72
  Channel,
52
73
  call,
53
74
  currentWindow,
75
+ direction,
54
76
  event,
55
77
  isCallError,
56
78
  isMyGo,
79
+ locale,
57
80
  on,
81
+ onDocumentReload,
82
+ onDocumentState,
58
83
  onFileDrop,
84
+ onLocaleChange,
85
+ onPreferences,
59
86
  once,
87
+ preferences,
60
88
  runtime
61
89
  };
package/dist/types.d.ts CHANGED
@@ -1,5 +1,14 @@
1
1
  /** The operating system, using Node.js' `process.platform` names. */
2
2
  export type Platform = "darwin" | "linux" | "win32";
3
+ /** The direction a language is written in: left to right, or right to left. */
4
+ export type Direction = "ltr" | "rtl";
5
+ /** The payload of the `mygo:locale` event. */
6
+ export interface LocaleChange {
7
+ /** The language the app shows now, e.g. "zh-Hans". */
8
+ locale: string;
9
+ /** The direction it is written in. */
10
+ dir: Direction;
11
+ }
3
12
  /** Controls for the window hosting the page, handy for custom title bars. */
4
13
  export interface WindowControls {
5
14
  minimize(): Promise<void>;
@@ -32,6 +41,26 @@ export interface Channel<T = unknown> extends AsyncIterable<T> {
32
41
  */
33
42
  close(): void;
34
43
  }
44
+ /**
45
+ * The accent color and the accessibility settings of the desktop, as
46
+ * `mygo.ThemePreferences` encodes them.
47
+ */
48
+ export interface ThemePreferences {
49
+ /** The accent color of the desktop's controls as "#rrggbb", or null where the desktop has none. */
50
+ accent: string | null;
51
+ /** Whether the desktop asks for less motion; `prefers-reduced-motion` tells it too. */
52
+ reduceMotion: boolean;
53
+ /**
54
+ * Whether the desktop asks for opaque surfaces in place of translucent
55
+ * ones: macOS's Reduce Transparency, Windows's transparency effects
56
+ * turned off. Linux desktops have no such setting.
57
+ */
58
+ reduceTransparency: boolean;
59
+ /** Whether the desktop asks for more contrast; `prefers-contrast` tells it too. */
60
+ highContrast: boolean;
61
+ /** How many times larger than usual text should be; 1 is usual. */
62
+ textScale: number;
63
+ }
35
64
  /** The runtime MyGo injects into every page as `window.mygo`. */
36
65
  export interface Runtime {
37
66
  /** Calls a bound Go method, e.g. `call("Greeter.Greet", "Ada")`. */
@@ -51,6 +80,20 @@ export interface Runtime {
51
80
  readonly windowId: number;
52
81
  /** MyGo version. */
53
82
  readonly version: string;
83
+ /**
84
+ * The desktop's preferences, current from document start on; the
85
+ * `mygo:preferences` event tells their changes.
86
+ */
87
+ readonly preferences: ThemePreferences;
88
+ /**
89
+ * The language the app shows, a BCP 47 tag such as "en" or "zh-Hans"
90
+ * (`App.Locale`): of the languages the app is written in, the one the
91
+ * user reads best, or the one `App.SetLocale` chose. A page has it from
92
+ * document start, and the `mygo:locale` event tells of changes.
93
+ */
94
+ readonly locale: string;
95
+ /** The direction `locale` is written in, for `<html dir>`. */
96
+ readonly dir: Direction;
54
97
  /** The window hosting this page. */
55
98
  readonly window: WindowControls;
56
99
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mygo-runtime",
3
- "version": "0.4.8",
3
+ "version": "0.5.0",
4
4
  "description": "Typed access to the MyGo runtime in the pages of MyGo desktop apps",
5
5
  "keywords": [
6
6
  "mygo",
@@ -20,6 +20,10 @@
20
20
  ".": {
21
21
  "types": "./dist/index.d.ts",
22
22
  "default": "./dist/index.js"
23
+ },
24
+ "./i18n": {
25
+ "types": "./dist/i18n.d.ts",
26
+ "default": "./dist/i18n.js"
23
27
  }
24
28
  },
25
29
  "types": "./dist/index.d.ts",