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