@madojs/mado 0.6.0 → 0.7.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.
Files changed (44) hide show
  1. package/AGENTS.md +82 -30
  2. package/CHANGELOG.md +208 -1
  3. package/dist/src/component.d.ts +17 -4
  4. package/dist/src/component.js +26 -4
  5. package/dist/src/component.js.map +1 -1
  6. package/dist/src/resource.js +11 -0
  7. package/dist/src/resource.js.map +1 -1
  8. package/dist/src/router/manifest.js +29 -2
  9. package/dist/src/router/manifest.js.map +1 -1
  10. package/docs/en/07-llm-pitfalls.md +197 -60
  11. package/docs/en/08-llm-zero-history-test.md +1 -1
  12. package/docs/en/17-shadow-dom-forms.md +192 -0
  13. package/docs/en/README.md +20 -19
  14. package/docs/fr/07-llm-pitfalls.md +196 -60
  15. package/docs/fr/17-shadow-dom-forms.md +196 -0
  16. package/docs/fr/README.md +20 -19
  17. package/docs/ru/07-llm-pitfalls.md +198 -61
  18. package/docs/ru/08-llm-zero-history-test.md +39 -38
  19. package/docs/ru/09-shadow-vs-light-dom.md +97 -81
  20. package/docs/ru/17-shadow-dom-forms.md +193 -0
  21. package/docs/ru/README.md +20 -19
  22. package/docs/uk/07-llm-pitfalls.md +64 -3
  23. package/docs/uk/17-shadow-dom-forms.md +193 -0
  24. package/docs/uk/README.md +20 -19
  25. package/llms.txt +50 -1
  26. package/package.json +2 -2
  27. package/scripts/bake.mjs +76 -22
  28. package/scripts/bundle.mjs +24 -1
  29. package/scripts/cli.mjs +98 -45
  30. package/scripts/preview.mjs +104 -10
  31. package/server/serve.mjs +80 -7
  32. package/starters/admin/index.html +10 -3
  33. package/starters/admin/package.json +3 -1
  34. package/starters/admin/src/components/x-button.ts +40 -13
  35. package/starters/admin/src/components/x-input.ts +50 -19
  36. package/starters/admin/src/lib/api.ts +55 -4
  37. package/starters/admin/src/pages/admin/order-detail.ts +4 -2
  38. package/starters/admin/src/pages/home.ts +10 -1
  39. package/starters/crud/index.html +12 -4
  40. package/starters/crud/package.json +3 -1
  41. package/starters/crud/src/pages/home.ts +16 -0
  42. package/starters/minimal/index.html +12 -4
  43. package/starters/minimal/package.json +2 -0
  44. package/starters/minimal/src/pages/home.ts +17 -0
package/scripts/cli.mjs CHANGED
@@ -53,10 +53,10 @@ switch (command) {
53
53
  }
54
54
  break;
55
55
  case "serve":
56
- await runServe(args[0] ?? "");
56
+ await runServe(args);
57
57
  break;
58
58
  case "dev":
59
- await runDev(args[0] ?? "");
59
+ await runDev(args);
60
60
  break;
61
61
  case "bake":
62
62
  await runNodeScript("scripts/bake.mjs", args);
@@ -87,15 +87,26 @@ switch (command) {
87
87
  process.exit(1);
88
88
  }
89
89
 
90
- async function runServe(example) {
91
- if (!example && PROJECT_ROOT !== PACKAGE_ROOT) {
92
- await serveStaticProject(PROJECT_ROOT);
93
- return;
94
- }
90
+ async function runServe(rawArgs) {
91
+ // Split args into [example?, ...flags]. The first non-flag positional is the
92
+ // example name; everything else (including `--host`, `--port`, etc.) is
93
+ // forwarded verbatim to server/serve.mjs.
94
+ const { example, forwarded } = splitDevArgs(rawArgs);
95
95
  if (example) assertExample(example, { serveable: true });
96
- await run(process.execPath, [join(PACKAGE_ROOT, "server/serve.mjs"), example].filter(Boolean), {
97
- env: { ...process.env, EXAMPLE: example || process.env.EXAMPLE || "" },
98
- });
96
+
97
+ // In app-mode (generated project, no example argument) we also go through
98
+ // server/serve.mjs to get config support (--host, --port, mado.config.json
99
+ // dev.proxy, HMR, etc.) — previously this fell back to serveStaticProject()
100
+ // which only read PORT from env and had no proxy/config/HMR.
101
+ await run(
102
+ process.execPath,
103
+ [join(PACKAGE_ROOT, "server/serve.mjs"), example, ...forwarded].filter(
104
+ Boolean,
105
+ ),
106
+ {
107
+ env: { ...process.env, EXAMPLE: example || process.env.EXAMPLE || "" },
108
+ },
109
+ );
99
110
  }
100
111
 
101
112
  async function runInit(rawArgs) {
@@ -168,15 +179,25 @@ async function runInit(rawArgs) {
168
179
  console.log("");
169
180
  }
170
181
 
171
- async function runDev(example) {
182
+ async function runDev(rawArgs) {
183
+ // Forward unknown flags (e.g. --host, --port) to server/serve.mjs so callers
184
+ // can write `mado dev --host 127.0.0.1` without the CLI mistaking `--host`
185
+ // for an example name.
186
+ const { example, forwarded } = splitDevArgs(rawArgs);
172
187
  if (example) assertExample(example, { serveable: true });
173
188
 
174
189
  const env = { ...process.env, EXAMPLE: example || process.env.EXAMPLE || "" };
175
- const server = spawn(process.execPath, [join(PACKAGE_ROOT, "server/serve.mjs"), example].filter(Boolean), {
176
- cwd: PROJECT_ROOT,
177
- env,
178
- stdio: "inherit",
179
- });
190
+ const server = spawn(
191
+ process.execPath,
192
+ [join(PACKAGE_ROOT, "server/serve.mjs"), example, ...forwarded].filter(
193
+ Boolean,
194
+ ),
195
+ {
196
+ cwd: PROJECT_ROOT,
197
+ env,
198
+ stdio: "inherit",
199
+ },
200
+ );
180
201
  const tsc = spawn(process.execPath, [resolveBin("typescript/bin/tsc"), "-w"], {
181
202
  cwd: PROJECT_ROOT,
182
203
  stdio: "inherit",
@@ -215,7 +236,8 @@ async function runRelease(rawArgs) {
215
236
  // have to remember the order, and so the deploy artifact (out/) is always
216
237
  // assembled the same way.
217
238
  //
218
- // mado release
239
+ // mado release [--no-clean]
240
+ // → rm -rf out/ (unless --no-clean)
219
241
  // → mado typecheck
220
242
  // → mado build (tsc → dist/)
221
243
  // → mado bundle (esbuild → out/assets/, also copies index.html)
@@ -223,6 +245,7 @@ async function runRelease(rawArgs) {
223
245
  // → copy public/* → out/
224
246
  //
225
247
  // Flags are forwarded to bake/bundle.
248
+ const { flags: releaseFlags } = parseFlags(rawArgs);
226
249
  const cfg = loadConfig({ projectRoot: PROJECT_ROOT });
227
250
  const outDir = resolve(cfg.projectRoot, cfg.build.out ?? "out");
228
251
  const publicDir = resolve(cfg.projectRoot, cfg.build.publicDir ?? "public");
@@ -231,6 +254,18 @@ async function runRelease(rawArgs) {
231
254
  console.log(`[release] artifact: ${outDir}`);
232
255
  console.log("");
233
256
 
257
+ // Deterministic builds: remove the entire output directory so stale assets,
258
+ // removed bake routes, and deleted public files don't linger in the deploy
259
+ // artifact. Use --no-clean to opt out (e.g. incremental CI workflows).
260
+ if (!releaseFlags["no-clean"]) {
261
+ if (existsSync(outDir)) {
262
+ await rm(outDir, { recursive: true, force: true });
263
+ console.log(`[release] cleaned ${outDir}`);
264
+ }
265
+ } else {
266
+ console.log("[release] --no-clean: keeping existing out/");
267
+ }
268
+
234
269
  console.log("[release] step 1/5 typecheck");
235
270
  await runNodeBin("typescript/bin/tsc", ["--noEmit"]);
236
271
 
@@ -411,6 +446,50 @@ function parseFlags(raw) {
411
446
  return { flags, positional };
412
447
  }
413
448
 
449
+ /**
450
+ * Split args for `mado dev` / `mado serve` into:
451
+ * - example: the first non-flag positional (or undefined)
452
+ * - forwarded: every remaining token (flags, their values, leftover
453
+ * positionals), preserved in order so server/serve.mjs sees them
454
+ * unchanged.
455
+ *
456
+ * This is what lets `mado dev -- --host 127.0.0.1` and
457
+ * `mado dev showcase --port 6000` both work without the CLI confusing
458
+ * `--host` for an example name.
459
+ */
460
+ function splitDevArgs(raw) {
461
+ if (!Array.isArray(raw) || raw.length === 0) {
462
+ return { example: "", forwarded: [] };
463
+ }
464
+ let example = "";
465
+ const forwarded = [];
466
+ let pickedExample = false;
467
+ for (let i = 0; i < raw.length; i++) {
468
+ const a = raw[i];
469
+ if (a === "--") {
470
+ forwarded.push(...raw.slice(i + 1));
471
+ break;
472
+ }
473
+ if (a.startsWith("-")) {
474
+ forwarded.push(a);
475
+ // Lookahead: if the next token is the flag's VALUE (does not start with
476
+ // "-"), forward it too — but only when the flag is in inline form
477
+ // (--flag value), not --flag=value.
478
+ if (!a.includes("=") && raw[i + 1] !== undefined && !raw[i + 1].startsWith("-")) {
479
+ forwarded.push(raw[++i]);
480
+ }
481
+ continue;
482
+ }
483
+ if (!pickedExample) {
484
+ example = a;
485
+ pickedExample = true;
486
+ } else {
487
+ forwarded.push(a);
488
+ }
489
+ }
490
+ return { example, forwarded };
491
+ }
492
+
414
493
  function packageNameFromDir(target) {
415
494
  return target
416
495
  .split(/[\\/]/)
@@ -463,31 +542,5 @@ function contentType(file) {
463
542
  }[ext] ?? "application/octet-stream";
464
543
  }
465
544
 
466
- async function serveStaticProject(rootDir) {
467
- const port = Number(process.env.PORT || 5173);
468
- const server = http.createServer((req, res) => {
469
- const url = new URL(req.url || "/", `http://localhost:${port}`);
470
- const pathname = decodeURIComponent(url.pathname);
471
- const normalized = pathname.replace(/^\/+/, "");
472
- let file = resolve(rootDir, normalized);
473
- if (pathname === "/" || !normalized.includes(".")) file = join(rootDir, "index.html");
474
- if (!file.startsWith(rootDir) || !existsSync(file) || statSync(file).isDirectory()) {
475
- file = join(rootDir, "index.html");
476
- }
477
-
478
- if (!existsSync(file)) {
479
- res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
480
- res.end("Not found");
481
- return;
482
- }
483
-
484
- res.writeHead(200, { "content-type": contentType(file) });
485
- res.end(readFileSync(file));
486
- });
487
-
488
- await new Promise((resolveListen) => {
489
- server.listen(port, resolveListen);
490
- });
491
- console.log(`[mado] serving ${rootDir}`);
492
- console.log(`[mado] http://localhost:${port}`);
493
- }
545
+ // serveStaticProject removed in v0.7 — mado serve now always goes through
546
+ // server/serve.mjs to get --host, --port, dev.proxy, and HMR support.
@@ -24,13 +24,49 @@ import { spawnSync } from "node:child_process";
24
24
 
25
25
  import { loadConfig } from "./_config.mjs";
26
26
 
27
+ // Tiny argv parser. Supports --flag, --flag=value, --flag value.
28
+ function parsePreviewArgs(argv) {
29
+ const flags = {};
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const a = argv[i];
32
+ if (a === "--") continue;
33
+ if (a.startsWith("--")) {
34
+ const eq = a.indexOf("=");
35
+ if (eq >= 0) {
36
+ flags[a.slice(2, eq)] = a.slice(eq + 1);
37
+ } else {
38
+ const name = a.slice(2);
39
+ const next = argv[i + 1];
40
+ if (next !== undefined && !next.startsWith("-")) {
41
+ flags[name] = next;
42
+ i++;
43
+ } else {
44
+ flags[name] = true;
45
+ }
46
+ }
47
+ }
48
+ }
49
+ return flags;
50
+ }
51
+
52
+ const PREVIEW_FLAGS = parsePreviewArgs(process.argv.slice(2));
53
+
27
54
  const cfg = loadConfig({});
28
55
  const ROOT = cfg.projectRoot;
29
56
  const OUT = resolve(
30
57
  ROOT,
31
58
  process.env.OUT_DIR ?? cfg.build.out ?? "out",
32
59
  );
33
- const PORT = Number(process.env.PORT ?? cfg.dev?.port ?? 4173);
60
+ // Baked HTML lives in <out>/baked/ by default (see scripts/bake.mjs and
61
+ // mado.config.json bake.outDir). Preview serves it with priority over the
62
+ // SPA shell so URLs that have a prerendered HTML page render real markup
63
+ // instead of an empty <div id="app"></div>.
64
+ const BAKED = resolve(
65
+ ROOT,
66
+ process.env.BAKED_DIR ?? cfg.bake?.outDir ?? join(cfg.build.out ?? "out", "baked"),
67
+ );
68
+ const PORT = Number(PREVIEW_FLAGS.port ?? process.env.PORT ?? cfg.dev?.port ?? 4173);
69
+ const HOST = String(PREVIEW_FLAGS.host ?? process.env.HOST ?? cfg.dev?.host ?? "localhost");
34
70
  const AUTOBUILD = process.env.PREVIEW_AUTOBUILD === "1";
35
71
  const SKIP_BUILD = process.env.SKIP_BUILD === "1" || !AUTOBUILD;
36
72
 
@@ -66,7 +102,18 @@ if (!SKIP_BUILD) {
66
102
  }
67
103
 
68
104
  if (!(await exists(OUT))) {
69
- console.error(`[preview] missing ${OUT}/ — check the steps above`);
105
+ console.error(
106
+ `[preview] missing ${OUT}/ — run \`mado release\` (or \`mado bundle\`) first.`,
107
+ );
108
+ process.exit(1);
109
+ }
110
+
111
+ const spaShell = join(OUT, "index.html");
112
+ if (!(await exists(spaShell))) {
113
+ console.error(
114
+ `[preview] missing ${spaShell} — \`mado bundle\` did not produce an HTML entry.\n` +
115
+ `[preview] Without it any non-baked route will 404 instead of falling back to the SPA.`,
116
+ );
70
117
  process.exit(1);
71
118
  }
72
119
 
@@ -114,8 +161,31 @@ const server = createServer(async (req, res) => {
114
161
  }
115
162
  });
116
163
 
117
- server.listen(PORT, () => {
118
- console.log(`\n[preview] http://localhost:${PORT}/ (Ctrl-C stop)\n`);
164
+ server.on("error", (err) => {
165
+ if (err.code === "EPERM" || err.code === "EACCES") {
166
+ console.error(
167
+ `[preview] failed to bind ${HOST}:${PORT}: ${err.message}\n` +
168
+ `[preview] tip: this sandbox may disallow binding "${HOST}".\n` +
169
+ `[preview] try: mado preview --host 127.0.0.1`,
170
+ );
171
+ } else {
172
+ console.error(
173
+ `[preview] failed to listen on ${HOST}:${PORT}: ${err.message}`,
174
+ );
175
+ }
176
+ process.exit(1);
177
+ });
178
+
179
+ server.listen(PORT, HOST, async () => {
180
+ const urlHost = HOST === "0.0.0.0" || HOST === "::" ? "localhost" : HOST;
181
+ const bakedReady = await exists(BAKED);
182
+ console.log("");
183
+ console.log("Mado preview (production-like)");
184
+ console.log(` url: http://${urlHost}:${PORT}/`);
185
+ console.log(` out: ${OUT}`);
186
+ console.log(` baked: ${bakedReady ? BAKED : "(none — SPA-only)"}`);
187
+ console.log(" (Ctrl-C to stop)");
188
+ console.log("");
119
189
  });
120
190
 
121
191
  // ---------- helpers ----------
@@ -145,14 +215,33 @@ function basenameSafe(p) {
145
215
  async function resolveTarget(pathname) {
146
216
  if (pathname === "/") pathname = "/index.html";
147
217
 
218
+ // 1) Baked HTML wins. `mado bake` writes prerendered pages into
219
+ // <out>/baked/<path>/index.html. Serve them with priority over the
220
+ // SPA shell so search engines AND human users hitting a prerendered
221
+ // URL see real content immediately. Without this branch preview
222
+ // served the empty SPA shell for every URL, which looked like a
223
+ // "blank page" bug even when bake had succeeded.
224
+ if (await exists(BAKED)) {
225
+ if (!extname(pathname) || pathname.endsWith("/index.html")) {
226
+ const bakedDir = join(BAKED, pathname.replace(/\/index\.html$/, ""));
227
+ const bakedIdx = join(bakedDir, "index.html");
228
+ if (await exists(bakedIdx)) return bakedIdx;
229
+ }
230
+ // Direct file (sitemap.xml etc.) from the baked dir.
231
+ const bakedFile = resolve(join(BAKED, pathname));
232
+ if (bakedFile.startsWith(BAKED + sep) && (await exists(bakedFile))) {
233
+ const s = await stat(bakedFile);
234
+ if (!s.isDirectory()) return bakedFile;
235
+ }
236
+ }
237
+
148
238
  const candidate = resolve(join(OUT, pathname));
149
239
  if (!candidate.startsWith(OUT + sep) && candidate !== OUT) return null;
150
240
 
151
- // 1) Exact match
241
+ // 2) Exact match inside out/.
152
242
  if (await exists(candidate)) {
153
243
  const s = await stat(candidate);
154
244
  if (s.isDirectory()) {
155
- // Baked priority: /product/foo/ → /product/foo/index.html
156
245
  const idx = join(candidate, "index.html");
157
246
  if (await exists(idx)) return idx;
158
247
  } else {
@@ -160,15 +249,20 @@ async function resolveTarget(pathname) {
160
249
  }
161
250
  }
162
251
 
163
- // 2) /foo → /foo/index.html (for baked pages without trailing slash)
252
+ // 3) /foo → /foo/index.html (for sub-folders without trailing slash).
164
253
  if (!extname(pathname)) {
165
254
  const asDir = join(OUT, pathname, "index.html");
166
255
  if (await exists(asDir)) return asDir;
167
256
  }
168
257
 
169
- // 3) SPA-fallback
170
- const spa = join(OUT, "index.html");
171
- if (await exists(spa)) return spa;
258
+ // 4) SPA-fallback: any non-asset path falls back to the SPA shell so
259
+ // client-side routing handles it. Asset-looking paths (with an
260
+ // extension) deliberately 404 instead — otherwise a 200 on
261
+ // /missing.png would mask real bugs.
262
+ if (!extname(pathname)) {
263
+ const spa = join(OUT, "index.html");
264
+ if (await exists(spa)) return spa;
265
+ }
172
266
 
173
267
  return null;
174
268
  }
package/server/serve.mjs CHANGED
@@ -35,10 +35,49 @@ const CONFIG = (() => {
35
35
  })();
36
36
  const PROXY_RULES = Object.entries(CONFIG.dev?.proxy ?? {}); // [["/api", "http://localhost:3000"], ...]
37
37
 
38
- const PORT = Number(process.env.PORT ?? CONFIG.dev?.port ?? 5173);
39
- const HMR = process.env.NO_HMR !== "1";
38
+ // Tiny argv parser. Supports --flag, --flag=value, --flag value. The first
39
+ // non-flag positional is the EXAMPLE name (legacy behavior). Anything that
40
+ // looks like a flag is consumed before we pick the positional, so calls like
41
+ // `mado dev -- --host 127.0.0.1` work as documented.
42
+ function parseArgs(argv) {
43
+ const flags = {};
44
+ const positional = [];
45
+ for (let i = 0; i < argv.length; i++) {
46
+ const a = argv[i];
47
+ if (a === "--") continue;
48
+ if (a.startsWith("--")) {
49
+ const eq = a.indexOf("=");
50
+ if (eq >= 0) {
51
+ flags[a.slice(2, eq)] = a.slice(eq + 1);
52
+ } else {
53
+ const name = a.slice(2);
54
+ const next = argv[i + 1];
55
+ if (next !== undefined && !next.startsWith("-")) {
56
+ flags[name] = next;
57
+ i++;
58
+ } else {
59
+ flags[name] = true;
60
+ }
61
+ }
62
+ } else {
63
+ positional.push(a);
64
+ }
65
+ }
66
+ return { flags, positional };
67
+ }
68
+
69
+ const { flags: CLI_FLAGS, positional: CLI_POSITIONAL } = parseArgs(
70
+ process.argv.slice(2),
71
+ );
40
72
 
41
- const EXAMPLE = process.argv[2] ?? process.env.MADO_EXAMPLE ?? process.env.EXAMPLE ?? "";
73
+ const PORT = Number(CLI_FLAGS.port ?? process.env.PORT ?? CONFIG.dev?.port ?? 5173);
74
+ // HOST is opt-in. Default to "localhost" (loopback) which is friendlier in
75
+ // sandboxes that disallow binding 0.0.0.0 (EPERM on listen). Users can opt
76
+ // into LAN exposure with `mado dev --host 0.0.0.0` or HOST=0.0.0.0.
77
+ const HOST = String(CLI_FLAGS.host ?? process.env.HOST ?? CONFIG.dev?.host ?? "localhost");
78
+ const HMR = CLI_FLAGS.hmr !== false && process.env.NO_HMR !== "1";
79
+
80
+ const EXAMPLE = CLI_POSITIONAL[0] ?? process.env.MADO_EXAMPLE ?? process.env.EXAMPLE ?? "";
42
81
  const EXAMPLE_DIR = EXAMPLE
43
82
  ? resolve(join(ROOT, "examples", EXAMPLE))
44
83
  : "";
@@ -185,7 +224,30 @@ const server = createServer(async (req, res) => {
185
224
  const s = await stat(target);
186
225
  if (s.isDirectory()) target = join(target, "index.html");
187
226
  } catch {
188
- if (!extname(pathname)) {
227
+ // Public assets: in production `mado release` copies public/* into out/.
228
+ // In dev we surface them from public/ directly so favicon.svg, robots.txt,
229
+ // og-image.png, etc. don't 404 (and so dev/prod behavior matches).
230
+ const publicCandidate = resolve(join(ROOT, "public", pathname));
231
+ if (
232
+ publicCandidate.startsWith(resolve(join(ROOT, "public")) + sep) &&
233
+ existsSync(publicCandidate)
234
+ ) {
235
+ try {
236
+ const ps = await stat(publicCandidate);
237
+ if (!ps.isDirectory()) {
238
+ target = publicCandidate;
239
+ reason = "public/";
240
+ } else if (!extname(pathname)) {
241
+ target = fallbackIndex;
242
+ } else {
243
+ reason = "file not found";
244
+ res.writeHead(404).end("not found");
245
+ return;
246
+ }
247
+ } catch {
248
+ target = fallbackIndex;
249
+ }
250
+ } else if (!extname(pathname)) {
189
251
  target = fallbackIndex;
190
252
  } else {
191
253
  reason = "file not found";
@@ -301,7 +363,15 @@ async function buildPreloadHints() {
301
363
  }
302
364
 
303
365
  server.on("error", (err) => {
304
- console.error(`[serve] failed to listen on port ${PORT}: ${err.message}`);
366
+ if (err.code === "EPERM" || err.code === "EACCES") {
367
+ console.error(
368
+ `[serve] failed to bind ${HOST}:${PORT}: ${err.message}\n` +
369
+ `[serve] tip: this sandbox may disallow binding "${HOST}".\n` +
370
+ `[serve] try: mado dev --host 127.0.0.1 (or HOST=127.0.0.1)`,
371
+ );
372
+ } else {
373
+ console.error(`[serve] failed to listen on ${HOST}:${PORT}: ${err.message}`);
374
+ }
305
375
  process.exit(1);
306
376
  });
307
377
 
@@ -351,7 +421,7 @@ async function proxyForward({ req, res, prefix, upstream, pathname, search }) {
351
421
  void prefix;
352
422
  }
353
423
 
354
- server.listen(PORT, () => {
424
+ server.listen(PORT, HOST, () => {
355
425
  const distReady = existsSync(join(ROOT, "dist/src/index.js"))
356
426
  || existsSync(join(ROOT, "dist/main.js"));
357
427
  const mount = EXAMPLE
@@ -359,9 +429,12 @@ server.listen(PORT, () => {
359
429
  : existsSync(EXAMPLES_INDEX)
360
430
  ? "examples/index.html landing"
361
431
  : "index.html app";
432
+ // Show "localhost" in the URL when bound to 0.0.0.0 — easier to click.
433
+ const urlHost = HOST === "0.0.0.0" || HOST === "::" ? "localhost" : HOST;
362
434
  console.log("");
363
435
  console.log("Mado dev server");
364
- console.log(` url: http://localhost:${PORT}/`);
436
+ console.log(` url: http://${urlHost}:${PORT}/`);
437
+ console.log(` host: ${HOST}`);
365
438
  console.log(` root: ${ROOT}`);
366
439
  console.log(` mount: ${mount}`);
367
440
  console.log(` hmr: ${HMR ? "on" : "off"}`);
@@ -5,17 +5,24 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>__APP_NAME__</title>
7
7
  <link rel="icon" type="image/svg+xml" href="/favicon.svg">
8
+ <!--
9
+ Paths below MUST be root-absolute (start with "/"), not "./...".
10
+ Mado is an SPA: hard-refreshing /admin/orders/42 still serves this same
11
+ index.html, and the browser resolves "./dist/main.js" against the
12
+ current URL → /admin/orders/dist/main.js → 404 → blank page.
13
+ Root-absolute paths always resolve to /dist/main.js regardless of route.
14
+ -->
8
15
  <script type="importmap">
9
16
  {
10
17
  "imports": {
11
- "@madojs/mado": "./node_modules/@madojs/mado/dist/src/index.js",
12
- "@madojs/mado/": "./node_modules/@madojs/mado/dist/src/"
18
+ "@madojs/mado": "/node_modules/@madojs/mado/dist/src/index.js",
19
+ "@madojs/mado/": "/node_modules/@madojs/mado/dist/src/"
13
20
  }
14
21
  }
15
22
  </script>
16
23
  </head>
17
24
  <body>
18
25
  <div id="app"></div>
19
- <script type="module" src="./dist/main.js"></script>
26
+ <script type="module" src="/dist/main.js"></script>
20
27
  </body>
21
28
  </html>
@@ -17,6 +17,8 @@
17
17
  "@madojs/mado": "__MADOJS_VERSION__"
18
18
  },
19
19
  "devDependencies": {
20
+ "esbuild": "^0.24.0",
21
+ "linkedom": "^0.18.0",
20
22
  "typescript": "^6.0.3"
21
23
  }
22
- }
24
+ }
@@ -1,25 +1,45 @@
1
1
  // <x-button variant="primary|ghost|danger" ?disabled>
2
2
  //
3
3
  // Wraps a native <button> so it can be slotted with text/icon and styled
4
- // consistently across the app. Click events bubble naturally because Shadow
5
- // DOM is `mode: open` and composed: true is the default for `click`.
4
+ // consistently across the app.
5
+ //
6
+ // Handles two Shadow DOM gotchas out of the box:
7
+ // 1. Reactive attributes via ctx.attr() — external ?disabled changes
8
+ // re-render the inner button automatically.
9
+ // 2. Form submit — a <button type="submit"> inside Shadow DOM cannot
10
+ // trigger <form> submit in Light DOM (spec limitation). We call
11
+ // form.requestSubmit() from a click handler to bridge this gap.
6
12
 
7
13
  import { component, css, html } from "@madojs/mado";
8
14
 
9
15
  component(
10
16
  "x-button",
11
- ({ host }) => () => {
12
- const variant = host.getAttribute("variant") ?? "primary";
13
- const disabled = host.hasAttribute("disabled");
14
- return html`
15
- <button data-variant=${variant} ?disabled=${disabled}>
17
+ ({ host, attr }) => {
18
+ const variant = attr("variant", "primary");
19
+ const disabled = attr("disabled");
20
+
21
+ const handleClick = () => {
22
+ const typeAttr = host.getAttribute("type");
23
+ if (typeAttr === "button" || typeAttr === "reset") return;
24
+ const form = host.closest("form");
25
+ if (form && !host.hasAttribute("disabled")) form.requestSubmit();
26
+ };
27
+
28
+ return () => html`
29
+ <button
30
+ data-variant=${variant()}
31
+ ?disabled=${() => disabled() !== ""}
32
+ @click=${handleClick}
33
+ >
16
34
  <slot></slot>
17
35
  </button>
18
36
  `;
19
37
  },
20
38
  {
21
39
  styles: css`
22
- :host { display: inline-flex; }
40
+ :host {
41
+ display: inline-flex;
42
+ }
23
43
  button {
24
44
  display: inline-flex;
25
45
  align-items: center;
@@ -31,11 +51,18 @@ component(
31
51
  cursor: pointer;
32
52
  background: var(--accent);
33
53
  color: var(--accent-fg);
34
- transition: filter .12s ease;
54
+ transition: filter 0.12s ease;
55
+ }
56
+ button:hover:not(:disabled) {
57
+ filter: brightness(1.07);
58
+ }
59
+ button:active:not(:disabled) {
60
+ filter: brightness(0.95);
61
+ }
62
+ button:disabled {
63
+ opacity: 0.55;
64
+ cursor: not-allowed;
35
65
  }
36
- button:hover:not(:disabled) { filter: brightness(1.07); }
37
- button:active:not(:disabled) { filter: brightness(.95); }
38
- button:disabled { opacity: .55; cursor: not-allowed; }
39
66
 
40
67
  button[data-variant="ghost"] {
41
68
  background: transparent;
@@ -52,4 +79,4 @@ component(
52
79
  }
53
80
  `,
54
81
  },
55
- );
82
+ );