@madojs/mado 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/AGENTS.md +291 -0
- package/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +371 -0
- package/ROADMAP.md +52 -0
- package/dist/src/component.d.ts +48 -0
- package/dist/src/component.js +140 -0
- package/dist/src/component.js.map +1 -0
- package/dist/src/context.d.ts +40 -0
- package/dist/src/context.js +67 -0
- package/dist/src/context.js.map +1 -0
- package/dist/src/css.d.ts +54 -0
- package/dist/src/css.js +137 -0
- package/dist/src/css.js.map +1 -0
- package/dist/src/devtools.d.ts +22 -0
- package/dist/src/devtools.js +63 -0
- package/dist/src/devtools.js.map +1 -0
- package/dist/src/diagnostics.d.ts +11 -0
- package/dist/src/diagnostics.js +28 -0
- package/dist/src/diagnostics.js.map +1 -0
- package/dist/src/each.d.ts +39 -0
- package/dist/src/each.js +35 -0
- package/dist/src/each.js.map +1 -0
- package/dist/src/forms.d.ts +71 -0
- package/dist/src/forms.js +161 -0
- package/dist/src/forms.js.map +1 -0
- package/dist/src/head.d.ts +19 -0
- package/dist/src/head.js +97 -0
- package/dist/src/head.js.map +1 -0
- package/dist/src/html/bindings.d.ts +78 -0
- package/dist/src/html/bindings.js +304 -0
- package/dist/src/html/bindings.js.map +1 -0
- package/dist/src/html/parser.d.ts +64 -0
- package/dist/src/html/parser.js +521 -0
- package/dist/src/html/parser.js.map +1 -0
- package/dist/src/html/template-types.d.ts +27 -0
- package/dist/src/html/template-types.js +8 -0
- package/dist/src/html/template-types.js.map +1 -0
- package/dist/src/html/template.d.ts +45 -0
- package/dist/src/html/template.js +119 -0
- package/dist/src/html/template.js.map +1 -0
- package/dist/src/html.d.ts +16 -0
- package/dist/src/html.js +16 -0
- package/dist/src/html.js.map +1 -0
- package/dist/src/index.d.ts +35 -0
- package/dist/src/index.js +39 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/lazy.d.ts +38 -0
- package/dist/src/lazy.js +73 -0
- package/dist/src/lazy.js.map +1 -0
- package/dist/src/lifecycle.d.ts +45 -0
- package/dist/src/lifecycle.js +66 -0
- package/dist/src/lifecycle.js.map +1 -0
- package/dist/src/page.d.ts +161 -0
- package/dist/src/page.js +38 -0
- package/dist/src/page.js.map +1 -0
- package/dist/src/persisted.d.ts +47 -0
- package/dist/src/persisted.js +119 -0
- package/dist/src/persisted.js.map +1 -0
- package/dist/src/resource.d.ts +120 -0
- package/dist/src/resource.js +275 -0
- package/dist/src/resource.js.map +1 -0
- package/dist/src/router/manifest.d.ts +56 -0
- package/dist/src/router/manifest.js +302 -0
- package/dist/src/router/manifest.js.map +1 -0
- package/dist/src/router/match.d.ts +62 -0
- package/dist/src/router/match.js +117 -0
- package/dist/src/router/match.js.map +1 -0
- package/dist/src/router/navigation.d.ts +89 -0
- package/dist/src/router/navigation.js +263 -0
- package/dist/src/router/navigation.js.map +1 -0
- package/dist/src/router.d.ts +13 -0
- package/dist/src/router.js +13 -0
- package/dist/src/router.js.map +1 -0
- package/dist/src/signal.d.ts +67 -0
- package/dist/src/signal.js +238 -0
- package/dist/src/signal.js.map +1 -0
- package/docs/README.md +12 -0
- package/docs/en/00-the-mado-way.md +106 -0
- package/docs/en/01-routing.md +204 -0
- package/docs/en/02-project-layout.md +58 -0
- package/docs/en/03-static-bake.md +251 -0
- package/docs/en/04-ide-setup.md +162 -0
- package/docs/en/05-why-mado.md +193 -0
- package/docs/en/06-for-backenders.md +422 -0
- package/docs/en/07-llm-pitfalls.md +486 -0
- package/docs/en/08-llm-zero-history-test.md +56 -0
- package/docs/en/09-shadow-vs-light-dom.md +122 -0
- package/docs/en/README.md +16 -0
- package/docs/fr/00-the-mado-way.md +108 -0
- package/docs/fr/01-routing.md +202 -0
- package/docs/fr/02-project-layout.md +58 -0
- package/docs/fr/03-static-bake.md +290 -0
- package/docs/fr/04-ide-setup.md +162 -0
- package/docs/fr/05-why-mado.md +193 -0
- package/docs/fr/06-for-backenders.md +432 -0
- package/docs/fr/07-llm-pitfalls.md +487 -0
- package/docs/fr/08-llm-zero-history-test.md +60 -0
- package/docs/fr/09-shadow-vs-light-dom.md +121 -0
- package/docs/fr/README.md +16 -0
- package/docs/ru/00-the-mado-way.md +93 -0
- package/docs/ru/01-routing.md +194 -0
- package/docs/ru/02-project-layout.md +57 -0
- package/docs/ru/03-static-bake.md +251 -0
- package/docs/ru/04-ide-setup.md +144 -0
- package/docs/ru/05-why-mado.md +193 -0
- package/docs/ru/06-for-backenders.md +422 -0
- package/docs/ru/07-llm-pitfalls.md +485 -0
- package/docs/ru/08-llm-zero-history-test.md +56 -0
- package/docs/ru/09-shadow-vs-light-dom.md +122 -0
- package/docs/ru/README.md +14 -0
- package/docs/uk/00-the-mado-way.md +54 -0
- package/docs/uk/01-routing.md +82 -0
- package/docs/uk/02-project-layout.md +46 -0
- package/docs/uk/03-static-bake.md +49 -0
- package/docs/uk/04-ide-setup.md +26 -0
- package/docs/uk/05-why-mado.md +34 -0
- package/docs/uk/06-for-backenders.md +50 -0
- package/docs/uk/07-llm-pitfalls.md +82 -0
- package/docs/uk/08-llm-zero-history-test.md +31 -0
- package/docs/uk/09-shadow-vs-light-dom.md +40 -0
- package/docs/uk/README.md +16 -0
- package/llms.txt +155 -0
- package/package.json +81 -0
- package/scripts/bake.mjs +406 -0
- package/scripts/bundle.mjs +146 -0
- package/scripts/cli.mjs +382 -0
- package/scripts/new.mjs +80 -0
- package/scripts/preview.mjs +176 -0
- package/scripts/release-notes.mjs +66 -0
- package/scripts/showcase-regression.mjs +392 -0
- package/server/serve.mjs +292 -0
- package/starters/crud/README.md +21 -0
- package/starters/crud/index.html +20 -0
- package/starters/crud/package.json +17 -0
- package/starters/crud/src/components/app-shell.ts +51 -0
- package/starters/crud/src/components/ticket-detail.ts +33 -0
- package/starters/crud/src/components/ticket-form.ts +69 -0
- package/starters/crud/src/components/ticket-list.ts +66 -0
- package/starters/crud/src/lib/api.ts +76 -0
- package/starters/crud/src/main.ts +12 -0
- package/starters/crud/src/pages/home.ts +18 -0
- package/starters/crud/src/pages/not-found.ts +12 -0
- package/starters/crud/src/pages/ticket-detail.ts +6 -0
- package/starters/crud/src/pages/ticket-new.ts +6 -0
- package/starters/crud/src/pages/tickets.ts +6 -0
- package/starters/crud/src/routes.ts +9 -0
- package/starters/crud/src/styles/global.ts +155 -0
- package/starters/crud/tsconfig.json +15 -0
- package/starters/minimal/README.md +19 -0
- package/starters/minimal/index.html +20 -0
- package/starters/minimal/package.json +17 -0
- package/starters/minimal/src/components/app-counter.ts +31 -0
- package/starters/minimal/src/main.ts +9 -0
- package/starters/minimal/src/pages/home.ts +18 -0
- package/starters/minimal/src/pages/not-found.ts +14 -0
- package/starters/minimal/src/routes.ts +6 -0
- package/starters/minimal/src/styles/global.ts +60 -0
- package/starters/minimal/tsconfig.json +15 -0
- package/templates/page-detail.ts +63 -0
- package/templates/page-form.ts +94 -0
- package/templates/page-list.ts +79 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// Browser regression for examples/showcase.
|
|
2
|
+
//
|
|
3
|
+
// Optional by design: regular `npm test` stays Node/linkedom-only. Run this
|
|
4
|
+
// when you want real browser confidence for routing, forms and Shadow DOM.
|
|
5
|
+
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { setTimeout as wait } from "node:timers/promises";
|
|
12
|
+
|
|
13
|
+
const PORT = Number(process.env.PORT ?? 5181);
|
|
14
|
+
const BASE = `http://localhost:${PORT}`;
|
|
15
|
+
|
|
16
|
+
async function loadPlaywright() {
|
|
17
|
+
try {
|
|
18
|
+
return await import("playwright-core");
|
|
19
|
+
} catch {
|
|
20
|
+
console.warn("[showcase-regression] playwright-core is not installed; skipped.");
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function waitForServer() {
|
|
26
|
+
for (let i = 0; i < 60; i++) {
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch(BASE);
|
|
29
|
+
if (res.ok) return;
|
|
30
|
+
} catch {
|
|
31
|
+
/* server not ready */
|
|
32
|
+
}
|
|
33
|
+
await wait(100);
|
|
34
|
+
}
|
|
35
|
+
throw new Error(`Server did not start at ${BASE}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const server = spawn("node", ["server/serve.mjs"], {
|
|
39
|
+
env: {
|
|
40
|
+
...process.env,
|
|
41
|
+
PORT: String(PORT),
|
|
42
|
+
EXAMPLE: "showcase",
|
|
43
|
+
NO_HMR: "1",
|
|
44
|
+
},
|
|
45
|
+
stdio: "pipe",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
let browser;
|
|
49
|
+
let chrome;
|
|
50
|
+
let tmpProfile;
|
|
51
|
+
try {
|
|
52
|
+
await waitForServer();
|
|
53
|
+
const pw = await loadPlaywright();
|
|
54
|
+
if (pw) {
|
|
55
|
+
try {
|
|
56
|
+
browser = await pw.chromium.launch();
|
|
57
|
+
const page = await browser.newPage();
|
|
58
|
+
await runWithPage(page);
|
|
59
|
+
await page.close();
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.warn(
|
|
62
|
+
`[showcase-regression] Playwright Chromium could not start; falling back to system Chrome. ${error.message}`,
|
|
63
|
+
);
|
|
64
|
+
if (browser) await browser.close();
|
|
65
|
+
browser = undefined;
|
|
66
|
+
const result = await runWithChromeCdp();
|
|
67
|
+
chrome = result.chrome;
|
|
68
|
+
tmpProfile = result.tmpProfile;
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
const result = await runWithChromeCdp();
|
|
72
|
+
chrome = result.chrome;
|
|
73
|
+
tmpProfile = result.tmpProfile;
|
|
74
|
+
}
|
|
75
|
+
console.log("[showcase-regression] passed");
|
|
76
|
+
} finally {
|
|
77
|
+
if (browser) await browser.close();
|
|
78
|
+
if (chrome) {
|
|
79
|
+
chrome.kill("SIGTERM");
|
|
80
|
+
await wait(300);
|
|
81
|
+
}
|
|
82
|
+
if (tmpProfile) {
|
|
83
|
+
try {
|
|
84
|
+
await rm(tmpProfile, { recursive: true, force: true, maxRetries: 3, retryDelay: 120 });
|
|
85
|
+
} catch {
|
|
86
|
+
/* best-effort cleanup */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
server.kill("SIGTERM");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function expectSingle(page, selector) {
|
|
93
|
+
const count = await page.locator(`#app ${selector}`).count();
|
|
94
|
+
assert.equal(count, 1, `${selector} should be the only active page host`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function runWithPage(page) {
|
|
98
|
+
await page.goto(BASE);
|
|
99
|
+
await page.waitForSelector("x-hero");
|
|
100
|
+
assert.equal(await page.locator("#app x-hero").count(), 1);
|
|
101
|
+
|
|
102
|
+
await page.click("x-nav >>> a[href='/app/login']");
|
|
103
|
+
await page.waitForSelector("x-login");
|
|
104
|
+
await page.fill("x-login >>> input[name='email']", "anna@example.com");
|
|
105
|
+
await page.fill("x-login >>> input[name='password']", "demo");
|
|
106
|
+
await page.click("x-login >>> button[type='submit']");
|
|
107
|
+
await page.waitForSelector("x-dashboard");
|
|
108
|
+
await expectSingle(page, "x-dashboard");
|
|
109
|
+
|
|
110
|
+
await page.click("x-app-layout a[href='/app/accounts']");
|
|
111
|
+
await page.waitForSelector("x-accounts-list");
|
|
112
|
+
await page.fill("x-accounts-list >>> input[type='search']", "Northwind");
|
|
113
|
+
await page.waitForTimeout(350);
|
|
114
|
+
await expectSingle(page, "x-accounts-list");
|
|
115
|
+
|
|
116
|
+
await page.click("x-accounts-list >>> a[href='/app/accounts/new']");
|
|
117
|
+
await page.waitForSelector("x-account-new");
|
|
118
|
+
await page.fill("x-account-new >>> input[name='name']", "Browser Test Co");
|
|
119
|
+
await page.fill("x-account-new >>> input[name='domain']", "browser.example");
|
|
120
|
+
await page.fill("x-account-new >>> input[name='mrr']", "6200");
|
|
121
|
+
await page.fill("x-account-new >>> textarea[name='notes']", "Created by browser regression.");
|
|
122
|
+
await page.click("x-account-new >>> button[type='submit']");
|
|
123
|
+
await page.waitForSelector("x-account-detail");
|
|
124
|
+
await expectSingle(page, "x-account-detail");
|
|
125
|
+
|
|
126
|
+
await page.click("x-account-detail >>> button:has-text('New deal')");
|
|
127
|
+
await page.fill("x-account-detail >>> input[name='title']", "Browser pipeline");
|
|
128
|
+
await page.fill("x-account-detail >>> input[name='value']", "88000");
|
|
129
|
+
await page.fill("x-account-detail >>> textarea[name='notes']", "Created through modal regression.");
|
|
130
|
+
await page.click("x-account-detail >>> button:has-text('Create deal')");
|
|
131
|
+
await page.waitForTimeout(500);
|
|
132
|
+
await expectSingle(page, "x-account-detail");
|
|
133
|
+
|
|
134
|
+
await page.click("x-app-layout a[href='/app/deals']");
|
|
135
|
+
await page.waitForSelector("x-deals-list");
|
|
136
|
+
await expectSingle(page, "x-deals-list");
|
|
137
|
+
|
|
138
|
+
const activeAppHosts = await page.locator(
|
|
139
|
+
"#app x-dashboard,#app x-accounts-list,#app x-account-new,#app x-account-detail,#app x-deals-list,#app x-deal-detail,#app x-settings,#app x-login",
|
|
140
|
+
).count();
|
|
141
|
+
assert.equal(activeAppHosts, 1);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function runWithChromeCdp() {
|
|
145
|
+
if (typeof WebSocket === "undefined") {
|
|
146
|
+
console.warn("[showcase-regression] no WebSocket for CDP fallback; skipped.");
|
|
147
|
+
process.exit(0);
|
|
148
|
+
}
|
|
149
|
+
const debugPort = Number(process.env.CHROME_DEBUG_PORT ?? 9228);
|
|
150
|
+
const chromeBin = process.env.CHROME_BIN ?? "google-chrome";
|
|
151
|
+
const profile = await mkdtemp(join(tmpdir(), "mado-showcase-chrome-"));
|
|
152
|
+
const chromeProc = spawn(
|
|
153
|
+
chromeBin,
|
|
154
|
+
[
|
|
155
|
+
"--headless=new",
|
|
156
|
+
"--disable-gpu",
|
|
157
|
+
"--no-sandbox",
|
|
158
|
+
"--disable-dev-shm-usage",
|
|
159
|
+
`--remote-debugging-port=${debugPort}`,
|
|
160
|
+
`--user-data-dir=${profile}`,
|
|
161
|
+
BASE,
|
|
162
|
+
],
|
|
163
|
+
{ stdio: "ignore" },
|
|
164
|
+
);
|
|
165
|
+
chromeProc.on("error", () => {
|
|
166
|
+
console.warn("[showcase-regression] google-chrome is not available; skipped.");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const wsUrl = await waitForCdp(debugPort);
|
|
170
|
+
if (!wsUrl) {
|
|
171
|
+
chromeProc.kill("SIGTERM");
|
|
172
|
+
await rm(profile, { recursive: true, force: true });
|
|
173
|
+
console.warn("[showcase-regression] Chrome CDP did not start; skipped.");
|
|
174
|
+
process.exit(0);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const cdp = await connectCdp(wsUrl);
|
|
178
|
+
await cdp.send("Runtime.enable");
|
|
179
|
+
await cdp.send("Page.enable");
|
|
180
|
+
await cdp.send("Page.navigate", { url: BASE });
|
|
181
|
+
await waitForCdpSelector(cdp, "x-hero");
|
|
182
|
+
assert.equal(await cdpCount(cdp, "x-hero"), 1);
|
|
183
|
+
|
|
184
|
+
await cdpClick(cdp, "x-nav >>> a[href='/app/login']");
|
|
185
|
+
await waitForCdpSelector(cdp, "x-login");
|
|
186
|
+
await cdpFill(cdp, "x-login >>> input[name='email']", "anna@example.com");
|
|
187
|
+
await cdpFill(cdp, "x-login >>> input[name='password']", "demo");
|
|
188
|
+
await cdpClick(cdp, "x-login >>> button[type='submit']");
|
|
189
|
+
await waitForCdpSelector(cdp, "x-dashboard");
|
|
190
|
+
assert.equal(await cdpCount(cdp, "x-dashboard"), 1);
|
|
191
|
+
|
|
192
|
+
await cdpClick(cdp, "x-app-layout >>> a[href='/app/accounts']");
|
|
193
|
+
await waitForCdpSelector(cdp, "x-accounts-list");
|
|
194
|
+
await cdpFill(cdp, "x-accounts-list >>> input[type='search']", "Northwind");
|
|
195
|
+
await wait(350);
|
|
196
|
+
assert.equal(await cdpCount(cdp, "x-accounts-list"), 1);
|
|
197
|
+
|
|
198
|
+
await cdpClick(cdp, "x-accounts-list >>> a[href='/app/accounts/new']");
|
|
199
|
+
await waitForCdpSelector(cdp, "x-account-new");
|
|
200
|
+
await cdpFill(cdp, "x-account-new >>> input[name='name']", "Browser Test Co");
|
|
201
|
+
await cdpFill(cdp, "x-account-new >>> input[name='domain']", "browser.example");
|
|
202
|
+
await cdpFill(cdp, "x-account-new >>> input[name='mrr']", "6200");
|
|
203
|
+
await cdpFill(cdp, "x-account-new >>> textarea[name='notes']", "Created by browser regression.");
|
|
204
|
+
await cdpClick(cdp, "x-account-new >>> button[type='submit']");
|
|
205
|
+
await waitForCdpSelector(cdp, "x-account-detail");
|
|
206
|
+
assert.equal(await cdpCount(cdp, "x-account-detail"), 1);
|
|
207
|
+
|
|
208
|
+
await cdpClickText(cdp, "x-account-detail >>> button", "New deal");
|
|
209
|
+
await cdpFill(cdp, "x-account-detail >>> input[name='title']", "Browser pipeline");
|
|
210
|
+
await cdpFill(cdp, "x-account-detail >>> input[name='value']", "88000");
|
|
211
|
+
await cdpFill(cdp, "x-account-detail >>> textarea[name='notes']", "Created through modal regression.");
|
|
212
|
+
await cdpClickText(cdp, "x-account-detail >>> button", "Create deal");
|
|
213
|
+
await wait(500);
|
|
214
|
+
assert.equal(await cdpCount(cdp, "x-account-detail"), 1);
|
|
215
|
+
|
|
216
|
+
await cdpClick(cdp, "x-app-layout >>> a[href='/app/deals']");
|
|
217
|
+
await waitForCdpSelector(cdp, "x-deals-list");
|
|
218
|
+
assert.equal(await cdpCount(cdp, "x-deals-list"), 1);
|
|
219
|
+
const active = await cdpCount(
|
|
220
|
+
cdp,
|
|
221
|
+
"x-dashboard,x-accounts-list,x-account-new,x-account-detail,x-deals-list,x-deal-detail,x-settings,x-login",
|
|
222
|
+
);
|
|
223
|
+
assert.equal(active, 1);
|
|
224
|
+
cdp.close();
|
|
225
|
+
return { chrome: chromeProc, tmpProfile: profile };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function waitForCdp(debugPort) {
|
|
229
|
+
for (let i = 0; i < 50; i++) {
|
|
230
|
+
try {
|
|
231
|
+
const list = await fetch(`http://127.0.0.1:${debugPort}/json/list`).then((r) => r.json());
|
|
232
|
+
for (const item of list) {
|
|
233
|
+
if (item.type === "page" && item.webSocketDebuggerUrl) return item.webSocketDebuggerUrl;
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
/* not ready */
|
|
237
|
+
}
|
|
238
|
+
await wait(100);
|
|
239
|
+
}
|
|
240
|
+
return "";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function connectCdp(url) {
|
|
244
|
+
const ws = new WebSocket(url);
|
|
245
|
+
let seq = 1;
|
|
246
|
+
const pending = new Map();
|
|
247
|
+
ws.onmessage = (event) => {
|
|
248
|
+
const msg = JSON.parse(event.data);
|
|
249
|
+
if (!msg.id) return;
|
|
250
|
+
const entry = pending.get(msg.id);
|
|
251
|
+
if (!entry) return;
|
|
252
|
+
pending.delete(msg.id);
|
|
253
|
+
if (msg.error) entry.reject(new Error(msg.error.message));
|
|
254
|
+
else entry.resolve(msg.result);
|
|
255
|
+
};
|
|
256
|
+
return new Promise((resolve, reject) => {
|
|
257
|
+
ws.onerror = () => reject(new Error("CDP websocket failed"));
|
|
258
|
+
ws.onopen = () => {
|
|
259
|
+
resolve({
|
|
260
|
+
send(method, params = {}) {
|
|
261
|
+
const id = seq++;
|
|
262
|
+
ws.send(JSON.stringify({ id, method, params }));
|
|
263
|
+
return new Promise((res, rej) => pending.set(id, { resolve: res, reject: rej }));
|
|
264
|
+
},
|
|
265
|
+
close() {
|
|
266
|
+
ws.close();
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
};
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function helperScript() {
|
|
274
|
+
return `
|
|
275
|
+
(() => {
|
|
276
|
+
window.__deepFind = (selector, root = document) => {
|
|
277
|
+
const direct = root.querySelector(selector);
|
|
278
|
+
if (direct) return direct;
|
|
279
|
+
for (const el of root.querySelectorAll('*')) {
|
|
280
|
+
if (!el.shadowRoot) continue;
|
|
281
|
+
const found = window.__deepFind(selector, el.shadowRoot);
|
|
282
|
+
if (found) return found;
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
};
|
|
286
|
+
window.__deepCount = (selector, root = document) => {
|
|
287
|
+
let count = root.querySelectorAll(selector).length;
|
|
288
|
+
for (const el of root.querySelectorAll('*')) {
|
|
289
|
+
if (el.shadowRoot) count += window.__deepCount(selector, el.shadowRoot);
|
|
290
|
+
}
|
|
291
|
+
return count;
|
|
292
|
+
};
|
|
293
|
+
window.__pq = (path) => {
|
|
294
|
+
let root = document;
|
|
295
|
+
let node = null;
|
|
296
|
+
for (const raw of path.split('>>>')) {
|
|
297
|
+
const part = raw.trim();
|
|
298
|
+
node = root === document ? window.__deepFind(part, root) : root.querySelector(part);
|
|
299
|
+
if (!node) return null;
|
|
300
|
+
root = node.shadowRoot || node;
|
|
301
|
+
}
|
|
302
|
+
return node;
|
|
303
|
+
};
|
|
304
|
+
window.__pqAllCount = (selector) => window.__deepCount(selector);
|
|
305
|
+
})();
|
|
306
|
+
`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function cdpEval(cdp, expression) {
|
|
310
|
+
const result = await cdp.send("Runtime.evaluate", {
|
|
311
|
+
expression,
|
|
312
|
+
awaitPromise: true,
|
|
313
|
+
returnByValue: true,
|
|
314
|
+
});
|
|
315
|
+
if (result.exceptionDetails) {
|
|
316
|
+
const detail =
|
|
317
|
+
result.exceptionDetails.exception?.description ??
|
|
318
|
+
result.exceptionDetails.text ??
|
|
319
|
+
"Runtime.evaluate failed";
|
|
320
|
+
throw new Error(detail);
|
|
321
|
+
}
|
|
322
|
+
return result.result?.value;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function installHelper(cdp) {
|
|
326
|
+
await cdpEval(cdp, helperScript());
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function waitForCdpSelector(cdp, selector) {
|
|
330
|
+
await installHelper(cdp);
|
|
331
|
+
for (let i = 0; i < 80; i++) {
|
|
332
|
+
if (await cdpEval(cdp, `Boolean(window.__pq(${JSON.stringify(selector)}))`)) return;
|
|
333
|
+
await wait(100);
|
|
334
|
+
}
|
|
335
|
+
const snapshot = await cdpEval(
|
|
336
|
+
cdp,
|
|
337
|
+
`JSON.stringify({ href: location.href, title: document.title, body: document.body.innerHTML.slice(0, 500) })`,
|
|
338
|
+
);
|
|
339
|
+
throw new Error(`Selector not found: ${selector}; page=${snapshot}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function cdpClick(cdp, selector) {
|
|
343
|
+
await installHelper(cdp);
|
|
344
|
+
await cdpEval(cdp, `(() => {
|
|
345
|
+
const el = window.__pq(${JSON.stringify(selector)});
|
|
346
|
+
if (!el) throw new Error("missing ${selector}");
|
|
347
|
+
el.click();
|
|
348
|
+
return true;
|
|
349
|
+
})()`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function cdpClickText(cdp, selector, text) {
|
|
353
|
+
await installHelper(cdp);
|
|
354
|
+
for (let i = 0; i < 80; i++) {
|
|
355
|
+
const clicked = await cdpEval(cdp, `(() => {
|
|
356
|
+
const rootPath = ${JSON.stringify(selector)};
|
|
357
|
+
const parts = rootPath.split('>>>');
|
|
358
|
+
let root = document;
|
|
359
|
+
const last = parts.pop().trim();
|
|
360
|
+
for (const raw of parts) {
|
|
361
|
+
const part = raw.trim();
|
|
362
|
+
const node = root === document ? window.__deepFind(part, root) : root.querySelector(part);
|
|
363
|
+
if (!node) return false;
|
|
364
|
+
root = node.shadowRoot || node;
|
|
365
|
+
}
|
|
366
|
+
const el = Array.from(root.querySelectorAll(last)).find((x) => x.textContent.includes(${JSON.stringify(text)}));
|
|
367
|
+
if (!el) return false;
|
|
368
|
+
el.click();
|
|
369
|
+
return true;
|
|
370
|
+
})()`);
|
|
371
|
+
if (clicked) return;
|
|
372
|
+
await wait(100);
|
|
373
|
+
}
|
|
374
|
+
throw new Error(`Text button not found: ${selector} ${text}`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function cdpFill(cdp, selector, value) {
|
|
378
|
+
await installHelper(cdp);
|
|
379
|
+
await cdpEval(cdp, `(() => {
|
|
380
|
+
const el = window.__pq(${JSON.stringify(selector)});
|
|
381
|
+
if (!el) throw new Error("missing ${selector}");
|
|
382
|
+
el.value = ${JSON.stringify(value)};
|
|
383
|
+
el.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
|
|
384
|
+
el.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
|
|
385
|
+
return true;
|
|
386
|
+
})()`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function cdpCount(cdp, selector) {
|
|
390
|
+
await installHelper(cdp);
|
|
391
|
+
return cdpEval(cdp, `window.__pqAllCount(${JSON.stringify(selector)})`);
|
|
392
|
+
}
|
package/server/serve.mjs
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// Tiny static server on node:http with dev features (ETag + HMR through SSE).
|
|
2
|
+
// No dependencies.
|
|
3
|
+
//
|
|
4
|
+
// PORT=5173 node server/serve.mjs
|
|
5
|
+
// NO_HMR=1 node server/serve.mjs # disable HMR
|
|
6
|
+
// node server/serve.mjs basic # mount examples/basic/ at /
|
|
7
|
+
// EXAMPLE=showcase node server/serve.mjs # mount examples/showcase/ at /
|
|
8
|
+
//
|
|
9
|
+
// Without EXAMPLE, / serves examples/index.html (example index).
|
|
10
|
+
// With EXAMPLE, all extensionless and /index.html requests fall back to
|
|
11
|
+
// examples/<EXAMPLE>/index.html so the client router works from root, just
|
|
12
|
+
// like a production SPA deploy.
|
|
13
|
+
|
|
14
|
+
import { createServer } from "node:http";
|
|
15
|
+
import { readFile, readdir, stat } from "node:fs/promises";
|
|
16
|
+
import { watch, existsSync } from "node:fs";
|
|
17
|
+
import { extname, join, resolve, sep } from "node:path";
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
|
|
20
|
+
const ROOT = resolve(process.cwd());
|
|
21
|
+
const PORT = Number(process.env.PORT ?? 5173);
|
|
22
|
+
const HMR = process.env.NO_HMR !== "1";
|
|
23
|
+
|
|
24
|
+
const EXAMPLE = process.argv[2] ?? process.env.MADO_EXAMPLE ?? process.env.EXAMPLE ?? "";
|
|
25
|
+
const EXAMPLE_DIR = EXAMPLE
|
|
26
|
+
? resolve(join(ROOT, "examples", EXAMPLE))
|
|
27
|
+
: "";
|
|
28
|
+
const EXAMPLE_INDEX = EXAMPLE ? join(EXAMPLE_DIR, "index.html") : "";
|
|
29
|
+
|
|
30
|
+
if (EXAMPLE) {
|
|
31
|
+
if (!existsSync(EXAMPLE_INDEX)) {
|
|
32
|
+
console.error(
|
|
33
|
+
`[serve] EXAMPLE=${EXAMPLE}: file not found: ${EXAMPLE_INDEX}`,
|
|
34
|
+
);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const MIME = {
|
|
40
|
+
".html": "text/html; charset=utf-8",
|
|
41
|
+
".js": "text/javascript; charset=utf-8",
|
|
42
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
43
|
+
".css": "text/css; charset=utf-8",
|
|
44
|
+
".json": "application/json; charset=utf-8",
|
|
45
|
+
".svg": "image/svg+xml",
|
|
46
|
+
".ico": "image/x-icon",
|
|
47
|
+
".map": "application/json; charset=utf-8",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ---------- HMR through Server-Sent Events ----------
|
|
51
|
+
//
|
|
52
|
+
// Open SSE connections live in a Set. Any file change broadcasts "reload".
|
|
53
|
+
// The client reloads the page. This is deliberately full reload rather than
|
|
54
|
+
// true HMR: we do not need preserved state, and the behavior stays simple.
|
|
55
|
+
|
|
56
|
+
const sseClients = new Set();
|
|
57
|
+
|
|
58
|
+
function broadcast(event, data) {
|
|
59
|
+
for (const res of sseClients) {
|
|
60
|
+
res.write(`event: ${event}\ndata: ${data}\n\n`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (HMR) {
|
|
65
|
+
// Debounce reload: tsc -w often changes several files in a row.
|
|
66
|
+
let timer = null;
|
|
67
|
+
const trigger = () => {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
timer = setTimeout(() => {
|
|
70
|
+
console.log(`[hmr] reload ${sseClients.size} client(s)`);
|
|
71
|
+
broadcast("reload", Date.now());
|
|
72
|
+
}, 80);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
for (const dir of ["dist", "examples"]) {
|
|
76
|
+
try {
|
|
77
|
+
watch(join(ROOT, dir), { recursive: true }, trigger);
|
|
78
|
+
} catch {
|
|
79
|
+
/* dir may not exist on startup */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const HMR_CLIENT = `
|
|
85
|
+
// Mado HMR client (auto-injected by serve.mjs)
|
|
86
|
+
(() => {
|
|
87
|
+
if (window.__madoHmr) return;
|
|
88
|
+
window.__madoHmr = true;
|
|
89
|
+
const es = new EventSource('/__hmr');
|
|
90
|
+
es.addEventListener('reload', () => location.reload());
|
|
91
|
+
es.addEventListener('error', () => {
|
|
92
|
+
// The server may be gone; try reconnecting after 1s.
|
|
93
|
+
setTimeout(() => location.reload(), 1000);
|
|
94
|
+
});
|
|
95
|
+
})();
|
|
96
|
+
`.trim();
|
|
97
|
+
|
|
98
|
+
// ---------- Server ----------
|
|
99
|
+
|
|
100
|
+
const server = createServer(async (req, res) => {
|
|
101
|
+
const started = Date.now();
|
|
102
|
+
let pathname = "/";
|
|
103
|
+
let reason = "";
|
|
104
|
+
res.on("finish", () => {
|
|
105
|
+
const ms = Date.now() - started;
|
|
106
|
+
const type = res.getHeader("content-type") ?? "-";
|
|
107
|
+
const suffix = reason ? ` ${reason}` : "";
|
|
108
|
+
console.log(
|
|
109
|
+
`[serve] ${req.method ?? "GET"} ${pathname} ${res.statusCode} ${ms}ms ${type}${suffix}`,
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
115
|
+
pathname = decodeURIComponent(url.pathname);
|
|
116
|
+
|
|
117
|
+
// SSE endpoint for HMR.
|
|
118
|
+
if (pathname === "/__hmr") {
|
|
119
|
+
res.writeHead(200, {
|
|
120
|
+
"content-type": "text/event-stream",
|
|
121
|
+
"cache-control": "no-cache",
|
|
122
|
+
connection: "keep-alive",
|
|
123
|
+
});
|
|
124
|
+
res.write("retry: 1000\n\n");
|
|
125
|
+
sseClients.add(res);
|
|
126
|
+
console.log(`[hmr] client connected (${sseClients.size})`);
|
|
127
|
+
req.on("close", () => {
|
|
128
|
+
sseClients.delete(res);
|
|
129
|
+
console.log(`[hmr] client disconnected (${sseClients.size})`);
|
|
130
|
+
});
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// A mounted example owns root and SPA fallback. Otherwise serve the
|
|
135
|
+
// examples index page.
|
|
136
|
+
const fallbackIndex = EXAMPLE
|
|
137
|
+
? EXAMPLE_INDEX
|
|
138
|
+
: join(ROOT, "examples", "index.html");
|
|
139
|
+
|
|
140
|
+
if (pathname === "/") {
|
|
141
|
+
// Resolved through fallback below.
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const filePath =
|
|
145
|
+
pathname === "/" ? fallbackIndex : resolve(join(ROOT, pathname));
|
|
146
|
+
|
|
147
|
+
if (filePath !== fallbackIndex) {
|
|
148
|
+
if (!filePath.startsWith(ROOT + sep) && filePath !== ROOT) {
|
|
149
|
+
reason = "forbidden path";
|
|
150
|
+
res.writeHead(403).end("forbidden");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let target = filePath;
|
|
156
|
+
try {
|
|
157
|
+
const s = await stat(target);
|
|
158
|
+
if (s.isDirectory()) target = join(target, "index.html");
|
|
159
|
+
} catch {
|
|
160
|
+
if (!extname(pathname)) {
|
|
161
|
+
target = fallbackIndex;
|
|
162
|
+
} else {
|
|
163
|
+
reason = "file not found";
|
|
164
|
+
res.writeHead(404).end("not found");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let data = await readFile(target);
|
|
170
|
+
|
|
171
|
+
// ETag: content hash. If-None-Match → 304.
|
|
172
|
+
const etag = `"${createHash("sha1").update(data).digest("base64url")}"`;
|
|
173
|
+
if (req.headers["if-none-match"] === etag) {
|
|
174
|
+
res.writeHead(304, { etag });
|
|
175
|
+
res.end();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// HMR injector: add the client script before </body>.
|
|
180
|
+
const type =
|
|
181
|
+
MIME[extname(target).toLowerCase()] ?? "application/octet-stream";
|
|
182
|
+
|
|
183
|
+
if (type.startsWith("text/html")) {
|
|
184
|
+
let text = data.toString("utf8");
|
|
185
|
+
// modulepreload hints: tell the browser to fetch the framework core and
|
|
186
|
+
// the mounted example's pages while HTML is still being parsed.
|
|
187
|
+
const preload = await buildPreloadHints();
|
|
188
|
+
if (preload) {
|
|
189
|
+
text = text.replace(/<\/head>/i, `${preload}\n </head>`);
|
|
190
|
+
}
|
|
191
|
+
if (HMR) {
|
|
192
|
+
text = text.replace(
|
|
193
|
+
/<\/body>/i,
|
|
194
|
+
`<script>${HMR_CLIENT}</script>\n </body>`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
data = Buffer.from(text);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
res.writeHead(200, {
|
|
201
|
+
"content-type": type,
|
|
202
|
+
etag,
|
|
203
|
+
"cache-control": "no-cache",
|
|
204
|
+
});
|
|
205
|
+
res.end(data);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
reason = "unhandled error";
|
|
208
|
+
console.error("[serve] error:", err);
|
|
209
|
+
res.writeHead(500).end(String(err));
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// ---------- modulepreload hints ----------
|
|
214
|
+
//
|
|
215
|
+
// Simple heuristic, no AST parsing:
|
|
216
|
+
// 1. /dist/src/index.js — framework core, always needed
|
|
217
|
+
// 2. /dist/examples/<EXAMPLE>/main.js — app entry
|
|
218
|
+
// 3. /dist/examples/<EXAMPLE>/routes.js — route manifest
|
|
219
|
+
// 4. /dist/examples/<EXAMPLE>/pages/*.js — all pages
|
|
220
|
+
// (without them the first router click waterfalls)
|
|
221
|
+
//
|
|
222
|
+
// Disable with PRELOAD=0. Limit to the core with PRELOAD=core.
|
|
223
|
+
// Default is full (all pages).
|
|
224
|
+
|
|
225
|
+
const PRELOAD = process.env.PRELOAD ?? "full";
|
|
226
|
+
|
|
227
|
+
let cachedPreloadHints = null;
|
|
228
|
+
let cachedPreloadAt = 0;
|
|
229
|
+
const PRELOAD_CACHE_MS = HMR ? 1000 : 60_000;
|
|
230
|
+
|
|
231
|
+
async function buildPreloadHints() {
|
|
232
|
+
if (PRELOAD === "0" || PRELOAD === "off" || PRELOAD === "false") return "";
|
|
233
|
+
const now = Date.now();
|
|
234
|
+
if (cachedPreloadHints !== null && now - cachedPreloadAt < PRELOAD_CACHE_MS) {
|
|
235
|
+
return cachedPreloadHints;
|
|
236
|
+
}
|
|
237
|
+
const hrefs = [];
|
|
238
|
+
// core
|
|
239
|
+
if (existsSync(join(ROOT, "dist/src/index.js"))) {
|
|
240
|
+
hrefs.push("/dist/src/index.js");
|
|
241
|
+
}
|
|
242
|
+
if (EXAMPLE) {
|
|
243
|
+
const exampleDist = join(ROOT, "dist", "examples", EXAMPLE);
|
|
244
|
+
for (const f of ["main.js", "routes.js"]) {
|
|
245
|
+
if (existsSync(join(exampleDist, f))) {
|
|
246
|
+
hrefs.push(`/dist/examples/${EXAMPLE}/${f}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (PRELOAD === "full") {
|
|
250
|
+
const pagesDir = join(exampleDist, "pages");
|
|
251
|
+
if (existsSync(pagesDir)) {
|
|
252
|
+
try {
|
|
253
|
+
for (const file of await readdir(pagesDir)) {
|
|
254
|
+
if (file.endsWith(".js")) {
|
|
255
|
+
hrefs.push(`/dist/examples/${EXAMPLE}/pages/${file}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
} catch {
|
|
259
|
+
/* ignore */
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
cachedPreloadHints = hrefs
|
|
265
|
+
.map((h) => ` <link rel="modulepreload" href="${h}">`)
|
|
266
|
+
.join("\n");
|
|
267
|
+
cachedPreloadAt = now;
|
|
268
|
+
return cachedPreloadHints;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
server.on("error", (err) => {
|
|
272
|
+
console.error(`[serve] failed to listen on port ${PORT}: ${err.message}`);
|
|
273
|
+
process.exit(1);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
server.listen(PORT, () => {
|
|
277
|
+
const distReady = existsSync(join(ROOT, "dist/src/index.js"));
|
|
278
|
+
console.log("");
|
|
279
|
+
console.log("Mado dev server");
|
|
280
|
+
console.log(` url: http://localhost:${PORT}/`);
|
|
281
|
+
console.log(` root: ${ROOT}`);
|
|
282
|
+
console.log(` example: ${EXAMPLE ? `examples/${EXAMPLE}/ -> /` : "examples/index.html landing"}`);
|
|
283
|
+
console.log(` hmr: ${HMR ? "on" : "off"}`);
|
|
284
|
+
console.log(` preload: ${PRELOAD}`);
|
|
285
|
+
console.log(` dist: ${distReady ? "ready" : "missing (run mado build)"}`);
|
|
286
|
+
if (!EXAMPLE) {
|
|
287
|
+
console.log(" try: mado serve basic");
|
|
288
|
+
console.log(" mado serve showcase");
|
|
289
|
+
console.log(" mado serve tickets");
|
|
290
|
+
}
|
|
291
|
+
console.log("");
|
|
292
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# __APP_NAME__
|
|
2
|
+
|
|
3
|
+
Generated with the Mado CRUD starter.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install
|
|
7
|
+
npm run build
|
|
8
|
+
npm run serve
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Open http://localhost:5173.
|
|
12
|
+
|
|
13
|
+
This starter demonstrates:
|
|
14
|
+
|
|
15
|
+
- lazy routes;
|
|
16
|
+
- Web Components through `component()`;
|
|
17
|
+
- query params through `queryParam()`;
|
|
18
|
+
- async data through `resource()`;
|
|
19
|
+
- mutations with invalidation;
|
|
20
|
+
- forms through `useForm()`;
|
|
21
|
+
- keyed tables through `each()`.
|