myphp-runtime 0.0.0-stage → 1.28.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
@@ -1,3 +1,60 @@
1
- # Temporary Holding Version
1
+ # myphp-runtime
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ The package is not published: an app depends on it from the MyPHP checkout,
18
+ `"myphp-runtime": "file:<checkout>/packages/runtime"`, as `myphp init
19
+ -template web` writes it. Its modules are TypeScript, which Vite and Bun
20
+ 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,18 @@
1
1
  {
2
2
  "name": "myphp-runtime",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.28.0",
4
+ "description": "Typed access to the MyPHP runtime in the pages of MyPHP desktop apps",
5
+ "keywords": ["myphp", "desktop", "webview", "php"],
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "sideEffects": false,
9
+ "exports": {
10
+ ".": "./src/index.ts",
11
+ "./i18n": "./src/i18n.ts"
12
+ },
13
+ "types": "./src/index.ts",
14
+ "files": ["src", "README.md"],
15
+ "scripts": {
16
+ "test": "bun test"
17
+ }
18
+ }
@@ -0,0 +1,148 @@
1
+ // The formatter of messages in pages (i18n.ts, mygo-runtime's i18n.test.ts):
2
+ // the fixtures MyPHP\Message checks too (tests/i18n/messages.json), so that
3
+ // PHP and pages say the same, catalogs, and the module `myphp generate`
4
+ // writes (tests/i18n/app/locales.ts, which tests/i18ngen_test.php checks
5
+ // against the generator). bun test, here.
6
+ import { afterEach, describe, expect, test } from "bun:test";
7
+ import { readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import {
10
+ createCatalog,
11
+ currentLocale,
12
+ formatMessage,
13
+ isPseudoLocale,
14
+ MessageSyntaxError,
15
+ parseMessage,
16
+ skeletonOptions,
17
+ type CatalogData,
18
+ } from "./i18n";
19
+
20
+ const fixtures = JSON.parse(readFileSync(join(import.meta.dir, "../../../tests/i18n/messages.json"), "utf8")) as {
21
+ format: { message: string; locale?: string; args?: Record<string, unknown>; want: string }[];
22
+ errors: string[];
23
+ pseudo: { message: string; locale: string; args?: Record<string, unknown>; want: string }[];
24
+ };
25
+ const g = globalThis as { myphp?: unknown };
26
+
27
+ describe("formatMessage", () => {
28
+ test("has the fixtures", () => {
29
+ expect(fixtures.format.length).toBeGreaterThan(50);
30
+ expect(fixtures.errors.length).toBeGreaterThan(20);
31
+ });
32
+ for (const c of [...fixtures.format, ...fixtures.pseudo]) {
33
+ test(`${c.message} in ${c.locale ?? "en"} with ${JSON.stringify(c.args ?? {})}`, () => {
34
+ expect(formatMessage(c.locale ?? "en", c.message, c.args)).toBe(c.want);
35
+ });
36
+ }
37
+ for (const s of fixtures.errors) {
38
+ test(`${JSON.stringify(s)} does not parse`, () => {
39
+ expect(() => parseMessage(s)).toThrow(MessageSyntaxError);
40
+ });
41
+ }
42
+ test("formats numbers and dates with Intl", () => {
43
+ expect(formatMessage("de", "{n, plural, one {# Datei} other {# Dateien}}", { n: 1000 })).toBe("1.000 Dateien");
44
+ expect(formatMessage("en", "{n, number, ::currency/EUR}", { n: 3 })).toBe("€3.00");
45
+ const day = new Date(Date.UTC(2026, 0, 2, 15, 4));
46
+ expect(formatMessage("en", "{d, date, short}", { d: day })).toBe(new Intl.DateTimeFormat("en", { dateStyle: "short" }).format(day));
47
+ expect(formatMessage("en", "{d}", { d: day })).toBe(new Intl.DateTimeFormat("en", { dateStyle: "short", timeStyle: "short" }).format(day));
48
+ expect(formatMessage("en", "{d, date}", { d: "tomorrow" })).toBe("tomorrow");
49
+ });
50
+ test("writes other values", () => {
51
+ expect(formatMessage("en", "{e}", { e: new Error("boom") })).toBe("boom");
52
+ expect(formatMessage("en", "{v}", { v: null })).toBe("");
53
+ expect(formatMessage("en", "{n, plural, one {#} other {# x}}", { n: 2n })).toBe("2 x");
54
+ });
55
+ test("reports where a message does not parse", () => {
56
+ expect(() => parseMessage("Hello {name")).toThrow(MessageSyntaxError);
57
+ try {
58
+ parseMessage("Hello {name");
59
+ } catch (err) {
60
+ expect((err as MessageSyntaxError).offset).toBe(11);
61
+ }
62
+ });
63
+ });
64
+
65
+ const data: CatalogData = {
66
+ source: "en",
67
+ languages: ["en", "de", "en-001", "pt", "pt-PT", "zh-Hans", "zh-Hant"],
68
+ fallbacks: { en: ["en"], de: ["de", "en"], "en-001": ["en-001", "en"], pt: ["pt", "en"], "pt-PT": ["pt-PT", "pt", "en"], "zh-Hans": ["zh-Hans", "en"], "zh-Hant": ["zh-Hant", "en"] },
69
+ params: { hello: ["name"], color: [], files: ["n"], broken: [] },
70
+ messages: {
71
+ en: { hello: "Hello, {name}!", color: "Color", files: "{n, plural, one {# file} other {# files}}", broken: "Fine in English" },
72
+ "en-001": { color: "Colour" },
73
+ de: { hello: "Hallo, {name}!", files: "{n, plural, one {# Datei} other {# Dateien}}", broken: "{kaputt" },
74
+ "zh-Hans": { hello: "你好,{name}!" },
75
+ "zh-Hant": { hello: "你好,{name}!", color: "顏色" },
76
+ pt: { color: "Cor" },
77
+ "pt-PT": { hello: "Olá, {name}!" },
78
+ },
79
+ };
80
+
81
+ interface Messages {
82
+ hello(name: string): string;
83
+ color(): string;
84
+ files(n: number): string;
85
+ broken(): string;
86
+ }
87
+
88
+ describe("createCatalog", () => {
89
+ const catalog = createCatalog<Messages>(data);
90
+ afterEach(() => {
91
+ delete g.myphp;
92
+ });
93
+ test("falls back along the languages", () => {
94
+ const cases: [string, string, Record<string, unknown>, string][] = [
95
+ ["en", "hello", { name: "Ada" }, "Hello, Ada!"], ["de", "hello", { name: "Ada" }, "Hallo, Ada!"], ["de-CH", "hello", { name: "Ada" }, "Hallo, Ada!"],
96
+ ["de", "color", {}, "Color"], ["de", "broken", {}, "broken"], ["en-001", "color", {}, "Colour"], ["zh-TW", "color", {}, "顏色"], ["zh-Hant-HK", "color", {}, "顏色"],
97
+ ["zh-CN", "color", {}, "Color"], ["pt-PT", "color", {}, "Cor"], ["fr", "hello", { name: "Ada" }, "Hello, Ada!"], ["de", "missing", {}, "missing"],
98
+ ["de", "toString", {}, "toString"], ["de", "constructor", {}, "constructor"], ["de", "files", { n: 1 }, "1 Datei"], ["fr", "files", { n: 0 }, "0 files"],
99
+ ["en-XA", "color", {}, "[Çöļöŕ one]"], ["ar-XB", "color", {}, "‏‮Color‬‏"], ["", "color", {}, "Color"],
100
+ ];
101
+ for (const [locale, key, args, want] of cases) expect(catalog.format(locale, key, args), `${locale} ${key}`).toBe(want);
102
+ expect([catalog.resolve("zh-TW"), catalog.resolve("en-AU"), catalog.resolve("xx")]).toEqual(["zh-Hant", "en", "en"]);
103
+ });
104
+ test("binds the messages to the language the page shows", () => {
105
+ const messages = catalog.messages();
106
+ g.myphp = { locale: "de" };
107
+ expect(currentLocale()).toBe("de");
108
+ expect(messages.files(3)).toBe("3 Dateien");
109
+ g.myphp = { locale: "zh-Hant" };
110
+ expect(messages.hello("Ada")).toBe("你好,Ada!");
111
+ expect(catalog.messagesIn("en").files(1)).toBe("1 file");
112
+ });
113
+ });
114
+
115
+ test("isPseudoLocale", () => {
116
+ expect([isPseudoLocale("en-XA"), isPseudoLocale("ar-xb"), isPseudoLocale("en"), isPseudoLocale("not a tag")]).toEqual([true, true, false, false]);
117
+ });
118
+
119
+ test("skeletons as Intl's options", () => {
120
+ expect(skeletonOptions("yMMMd")).toEqual({ year: "numeric", month: "short", day: "numeric" });
121
+ expect(skeletonOptions("Hmm")).toEqual({ hour: "numeric", hourCycle: "h23", minute: "2-digit" });
122
+ expect(skeletonOptions("Kms")).toEqual({ hour: "numeric", hourCycle: "h11", minute: "numeric", second: "numeric" });
123
+ });
124
+
125
+ describe("the module myphp generate writes", () => {
126
+ const file = join(import.meta.dir, "../../../tests/i18n/app/locales.ts");
127
+ test("type-checks", () => {
128
+ // The repository's tsconfig.json, which resolves myphp-runtime/i18n to its source, includes it.
129
+ const tsc = Bun.spawnSync(["tsc", "-p", join(import.meta.dir, "../../../tsconfig.json")]);
130
+ expect(tsc.stdout.toString() + tsc.stderr.toString()).toBe("");
131
+ expect(tsc.exitCode).toBe(0);
132
+ });
133
+ test("says the messages", async () => {
134
+ const locales = await import(file);
135
+ expect(locales.languages).toEqual(["en", "de", "en-GB", "zh-Hant"]);
136
+ const de = locales.messagesIn("de");
137
+ expect([de.title(), de.filesSelected(0), de.filesSelected(1), de.progress(0.5)]).toEqual(["Notizen", "Keine Dateien ausgewählt", "1 Datei ausgewählt", "50 % done"]);
138
+ expect(locales.messagesIn("en-GB").progress(0.5)).toBe("50% complete");
139
+ expect(locales.messagesIn("zh-TW").filesSelected(2)).toBe("已選取 2 個檔案");
140
+ expect([locales.messagesIn("en").guests("Ada", 3), locales.messagesIn("en").place(22), locales.messagesIn("en").positional("a", "b"), locales.messagesIn("en").keyword("x", "y", "z", "w"), locales.messagesIn("en-XA").title()]).toEqual(["Ada and 2 others", "You came 22nd", "a of b", "x y z w", "[Ñöţéš one]"]);
141
+ g.myphp = { locale: "de" };
142
+ try {
143
+ expect(locales.messages.greeting("Ada")).toBe("Hallo, Ada!");
144
+ } finally {
145
+ delete g.myphp;
146
+ }
147
+ });
148
+ });