@madojs/mado 0.5.1 → 0.6.1

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 (107) hide show
  1. package/AGENTS.md +26 -0
  2. package/CHANGELOG.md +265 -0
  3. package/MADO_V1_PLAN.md +179 -0
  4. package/README.md +31 -13
  5. package/ROADMAP.md +28 -7
  6. package/TODO.md +72 -0
  7. package/dist/src/forms.d.ts +37 -4
  8. package/dist/src/forms.js +331 -57
  9. package/dist/src/forms.js.map +1 -1
  10. package/dist/src/html/bindings.d.ts +41 -0
  11. package/dist/src/html/bindings.js +163 -6
  12. package/dist/src/html/bindings.js.map +1 -1
  13. package/dist/src/html.d.ts +2 -0
  14. package/dist/src/html.js +1 -0
  15. package/dist/src/html.js.map +1 -1
  16. package/dist/src/index.d.ts +6 -6
  17. package/dist/src/index.js +2 -2
  18. package/dist/src/index.js.map +1 -1
  19. package/dist/src/page.d.ts +56 -0
  20. package/dist/src/page.js +17 -0
  21. package/dist/src/page.js.map +1 -1
  22. package/dist/src/resource.js +11 -0
  23. package/dist/src/resource.js.map +1 -1
  24. package/dist/src/router/manifest.d.ts +16 -1
  25. package/dist/src/router/manifest.js +210 -40
  26. package/dist/src/router/manifest.js.map +1 -1
  27. package/dist/src/router/match.d.ts +7 -2
  28. package/dist/src/router/match.js +14 -4
  29. package/dist/src/router/match.js.map +1 -1
  30. package/dist/src/router/navigation.d.ts +10 -0
  31. package/dist/src/router/navigation.js +71 -3
  32. package/dist/src/router/navigation.js.map +1 -1
  33. package/dist/src/signal.d.ts +15 -1
  34. package/dist/src/signal.js +112 -16
  35. package/dist/src/signal.js.map +1 -1
  36. package/docs/en/02-project-layout.md +99 -40
  37. package/docs/en/10-app-architecture.md +141 -0
  38. package/docs/en/11-layouts.md +115 -0
  39. package/docs/en/12-auth-and-api.md +217 -0
  40. package/docs/en/13-deployment.md +192 -0
  41. package/docs/en/14-testing.md +82 -0
  42. package/docs/en/15-error-handling.md +100 -0
  43. package/docs/en/16-bake-cookbook.md +93 -0
  44. package/docs/en/README.md +7 -0
  45. package/docs/fr/10-app-architecture.md +61 -0
  46. package/docs/fr/11-layouts.md +35 -0
  47. package/docs/fr/12-auth-and-api.md +35 -0
  48. package/docs/fr/13-deployment.md +39 -0
  49. package/docs/fr/14-testing.md +41 -0
  50. package/docs/fr/15-error-handling.md +50 -0
  51. package/docs/fr/16-bake-cookbook.md +35 -0
  52. package/docs/fr/README.md +7 -0
  53. package/docs/ru/10-app-architecture.md +100 -0
  54. package/docs/ru/11-layouts.md +47 -0
  55. package/docs/ru/12-auth-and-api.md +53 -0
  56. package/docs/ru/13-deployment.md +60 -0
  57. package/docs/ru/14-testing.md +50 -0
  58. package/docs/ru/15-error-handling.md +56 -0
  59. package/docs/ru/16-bake-cookbook.md +55 -0
  60. package/docs/ru/README.md +7 -0
  61. package/docs/uk/10-app-architecture.md +56 -0
  62. package/docs/uk/11-layouts.md +34 -0
  63. package/docs/uk/12-auth-and-api.md +34 -0
  64. package/docs/uk/13-deployment.md +39 -0
  65. package/docs/uk/14-testing.md +34 -0
  66. package/docs/uk/15-error-handling.md +32 -0
  67. package/docs/uk/16-bake-cookbook.md +36 -0
  68. package/docs/uk/README.md +7 -0
  69. package/llms.txt +9 -1
  70. package/package.json +3 -1
  71. package/scripts/_config.mjs +224 -0
  72. package/scripts/bake.mjs +266 -121
  73. package/scripts/bundle.mjs +133 -67
  74. package/scripts/cli.mjs +195 -27
  75. package/scripts/preview.mjs +125 -21
  76. package/server/serve.mjs +161 -10
  77. package/starters/admin/README.md +63 -0
  78. package/starters/admin/index.html +28 -0
  79. package/starters/admin/mado.config.json +22 -0
  80. package/starters/admin/package.json +24 -0
  81. package/starters/admin/public/favicon.svg +4 -0
  82. package/starters/admin/src/components/x-button.ts +55 -0
  83. package/starters/admin/src/components/x-input.ts +74 -0
  84. package/starters/admin/src/layouts/app.ts +101 -0
  85. package/starters/admin/src/layouts/auth.ts +41 -0
  86. package/starters/admin/src/lib/api.ts +133 -0
  87. package/starters/admin/src/lib/auth.ts +83 -0
  88. package/starters/admin/src/main.ts +15 -0
  89. package/starters/admin/src/pages/admin/dashboard.ts +48 -0
  90. package/starters/admin/src/pages/admin/order-detail.ts +80 -0
  91. package/starters/admin/src/pages/admin/orders.ts +117 -0
  92. package/starters/admin/src/pages/home.ts +34 -0
  93. package/starters/admin/src/pages/login.ts +70 -0
  94. package/starters/admin/src/pages/not-found.ts +12 -0
  95. package/starters/admin/src/routes.ts +40 -0
  96. package/starters/admin/src/styles/global.ts +86 -0
  97. package/starters/admin/tsconfig.json +15 -0
  98. package/starters/crud/index.html +12 -4
  99. package/starters/crud/mado.config.json +20 -0
  100. package/starters/crud/package.json +9 -3
  101. package/starters/crud/src/pages/home.ts +16 -0
  102. package/starters/crud/src/routes.ts +4 -2
  103. package/starters/minimal/index.html +12 -4
  104. package/starters/minimal/mado.config.json +20 -0
  105. package/starters/minimal/package.json +9 -3
  106. package/starters/minimal/src/pages/home.ts +17 -0
  107. package/starters/minimal/src/routes.ts +4 -2
@@ -1,28 +1,74 @@
1
- // Preview: a tiny production-like server that emulates nginx.conf on node:http.
1
+ // Preview: a tiny production-like server that serves exactly out/ on node:http.
2
2
  //
3
- // npm run preview
3
+ // mado preview
4
4
  //
5
5
  // What it does:
6
- // 1. npm run build (tsc)
7
- // 2. node scripts/bake.mjs (generates SEO HTML when bake pages exist)
8
- // 3. node scripts/bundle.mjs (esbuild splitting + .gz/.br)
9
- // 4. Starts a static server on :4173 with:
6
+ // 1. Reads `mado.config.json` to discover OUT (default `out/`) and PORT.
7
+ // 2. If `out/` is missing AND we are in a project root, refuses to run and
8
+ // points the user at `mado release`. (Old auto-build behavior is opt-in
9
+ // via PREVIEW_AUTOBUILD=1 to stay backward-compatible for the framework
10
+ // repo.)
11
+ // 3. Starts a static server with:
10
12
  // - immutable cache for hashed bundles;
11
13
  // - SPA fallback to index.html;
12
- // - baked HTML priority over index.html;
14
+ // - baked HTML priority over the SPA shell;
13
15
  // - precompressed .gz / .br serving via Accept-Encoding.
14
16
  //
15
- // Goal: see production-like output locally without Docker/nginx.
17
+ // Goal: see production-like output locally without Docker/nginx, identical to
18
+ // what a static host (nginx / Cloudflare Pages / S3) would serve.
16
19
 
17
20
  import { createServer } from "node:http";
18
21
  import { readFile, stat, access } from "node:fs/promises";
19
22
  import { extname, join, resolve, sep } from "node:path";
20
23
  import { spawnSync } from "node:child_process";
21
24
 
22
- const ROOT = resolve(process.cwd());
23
- const OUT = resolve(process.env.OUT_DIR ?? "out");
24
- const PORT = Number(process.env.PORT ?? 4173);
25
- const SKIP_BUILD = process.env.SKIP_BUILD === "1";
25
+ import { loadConfig } from "./_config.mjs";
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
+
54
+ const cfg = loadConfig({});
55
+ const ROOT = cfg.projectRoot;
56
+ const OUT = resolve(
57
+ ROOT,
58
+ process.env.OUT_DIR ?? cfg.build.out ?? "out",
59
+ );
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");
70
+ const AUTOBUILD = process.env.PREVIEW_AUTOBUILD === "1";
71
+ const SKIP_BUILD = process.env.SKIP_BUILD === "1" || !AUTOBUILD;
26
72
 
27
73
  const MIME = {
28
74
  ".html": "text/html; charset=utf-8",
@@ -56,7 +102,18 @@ if (!SKIP_BUILD) {
56
102
  }
57
103
 
58
104
  if (!(await exists(OUT))) {
59
- 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
+ );
60
117
  process.exit(1);
61
118
  }
62
119
 
@@ -104,8 +161,31 @@ const server = createServer(async (req, res) => {
104
161
  }
105
162
  });
106
163
 
107
- server.listen(PORT, () => {
108
- 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("");
109
189
  });
110
190
 
111
191
  // ---------- helpers ----------
@@ -135,14 +215,33 @@ function basenameSafe(p) {
135
215
  async function resolveTarget(pathname) {
136
216
  if (pathname === "/") pathname = "/index.html";
137
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
+
138
238
  const candidate = resolve(join(OUT, pathname));
139
239
  if (!candidate.startsWith(OUT + sep) && candidate !== OUT) return null;
140
240
 
141
- // 1) Exact match
241
+ // 2) Exact match inside out/.
142
242
  if (await exists(candidate)) {
143
243
  const s = await stat(candidate);
144
244
  if (s.isDirectory()) {
145
- // Baked priority: /product/foo/ → /product/foo/index.html
146
245
  const idx = join(candidate, "index.html");
147
246
  if (await exists(idx)) return idx;
148
247
  } else {
@@ -150,15 +249,20 @@ async function resolveTarget(pathname) {
150
249
  }
151
250
  }
152
251
 
153
- // 2) /foo → /foo/index.html (for baked pages without trailing slash)
252
+ // 3) /foo → /foo/index.html (for sub-folders without trailing slash).
154
253
  if (!extname(pathname)) {
155
254
  const asDir = join(OUT, pathname, "index.html");
156
255
  if (await exists(asDir)) return asDir;
157
256
  }
158
257
 
159
- // 3) SPA-fallback
160
- const spa = join(OUT, "index.html");
161
- 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
+ }
162
266
 
163
267
  return null;
164
268
  }
package/server/serve.mjs CHANGED
@@ -12,17 +12,72 @@
12
12
  // examples/<EXAMPLE>/index.html so the client router works from root, just
13
13
  // like a production SPA deploy.
14
14
 
15
- import { createServer } from "node:http";
16
- import { readFile, readdir, stat } from "node:fs/promises";
17
- import { watch, existsSync } from "node:fs";
15
+ import { createServer, request as httpRequest } from "node:http";
16
+ import { request as httpsRequest } from "node:https";
17
+ import { readFile, readdir, readFile as readFileAsync, stat } from "node:fs/promises";
18
+ import { watch, existsSync, readFileSync } from "node:fs";
18
19
  import { extname, join, resolve, sep } from "node:path";
19
20
  import { createHash } from "node:crypto";
20
21
 
21
22
  const ROOT = resolve(process.cwd());
22
- const PORT = Number(process.env.PORT ?? 5173);
23
- const HMR = process.env.NO_HMR !== "1";
24
23
 
25
- const EXAMPLE = process.argv[2] ?? process.env.MADO_EXAMPLE ?? process.env.EXAMPLE ?? "";
24
+ // Optional mado.config.json used for dev.proxy and dev.port. Read with a
25
+ // hand-rolled JSON parse to avoid a circular dep with scripts/_config.mjs
26
+ // (this server is launched from cli.mjs and runs in its own Node process).
27
+ const CONFIG = (() => {
28
+ try {
29
+ const file = join(ROOT, "mado.config.json");
30
+ if (!existsSync(file)) return {};
31
+ return JSON.parse(readFileSync(file, "utf8")) ?? {};
32
+ } catch {
33
+ return {};
34
+ }
35
+ })();
36
+ const PROXY_RULES = Object.entries(CONFIG.dev?.proxy ?? {}); // [["/api", "http://localhost:3000"], ...]
37
+
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
+ );
72
+
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 ?? "";
26
81
  const EXAMPLE_DIR = EXAMPLE
27
82
  ? resolve(join(ROOT, "examples", EXAMPLE))
28
83
  : "";
@@ -118,6 +173,16 @@ const server = createServer(async (req, res) => {
118
173
  const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
119
174
  pathname = decodeURIComponent(url.pathname);
120
175
 
176
+ // Dev proxy: forward matching prefixes to an upstream backend, so the
177
+ // browser can reach the SPA and the API on a single origin without CORS.
178
+ const proxyRule = PROXY_RULES.find(([prefix]) => pathname.startsWith(prefix));
179
+ if (proxyRule) {
180
+ const [prefix, upstream] = proxyRule;
181
+ await proxyForward({ req, res, prefix, upstream, pathname, search: url.search });
182
+ reason = `proxy → ${upstream}`;
183
+ return;
184
+ }
185
+
121
186
  // SSE endpoint for HMR.
122
187
  if (pathname === "/__hmr") {
123
188
  res.writeHead(200, {
@@ -159,7 +224,30 @@ const server = createServer(async (req, res) => {
159
224
  const s = await stat(target);
160
225
  if (s.isDirectory()) target = join(target, "index.html");
161
226
  } catch {
162
- 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)) {
163
251
  target = fallbackIndex;
164
252
  } else {
165
253
  reason = "file not found";
@@ -275,11 +363,65 @@ async function buildPreloadHints() {
275
363
  }
276
364
 
277
365
  server.on("error", (err) => {
278
- 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
+ }
279
375
  process.exit(1);
280
376
  });
281
377
 
282
- server.listen(PORT, () => {
378
+ async function proxyForward({ req, res, prefix, upstream, pathname, search }) {
379
+ // Strip the prefix only if the upstream URL itself ends with `/`; otherwise
380
+ // forward the full pathname so the backend sees /api/...
381
+ let upstreamUrl;
382
+ try {
383
+ upstreamUrl = new URL(upstream);
384
+ } catch {
385
+ res.writeHead(502).end(`bad upstream: ${upstream}`);
386
+ return;
387
+ }
388
+ const target = new URL(upstream);
389
+ // Compose path: <upstream.pathname rstrip "/"> + <pathname> + <search>
390
+ const tail = pathname; // keep the original /api/... so backends route normally
391
+ target.pathname = (target.pathname.replace(/\/$/, "")) + tail;
392
+ target.search = search;
393
+
394
+ const lib = target.protocol === "https:" ? httpsRequest : httpRequest;
395
+ const upstreamReq = lib(
396
+ target,
397
+ {
398
+ method: req.method,
399
+ headers: {
400
+ ...req.headers,
401
+ host: target.host,
402
+ },
403
+ },
404
+ (upstreamRes) => {
405
+ // Forward status and headers, then pipe the body.
406
+ res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers);
407
+ upstreamRes.pipe(res);
408
+ },
409
+ );
410
+ upstreamReq.on("error", (err) => {
411
+ console.error(`[serve] proxy error for ${pathname} → ${target.href}:`, err.message);
412
+ if (!res.headersSent) {
413
+ res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
414
+ res.end(`proxy upstream unavailable: ${target.host}\n${err.message}`);
415
+ } else {
416
+ res.end();
417
+ }
418
+ });
419
+ req.pipe(upstreamReq);
420
+ // Reference unused arg so lint is happy.
421
+ void prefix;
422
+ }
423
+
424
+ server.listen(PORT, HOST, () => {
283
425
  const distReady = existsSync(join(ROOT, "dist/src/index.js"))
284
426
  || existsSync(join(ROOT, "dist/main.js"));
285
427
  const mount = EXAMPLE
@@ -287,14 +429,23 @@ server.listen(PORT, () => {
287
429
  : existsSync(EXAMPLES_INDEX)
288
430
  ? "examples/index.html landing"
289
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;
290
434
  console.log("");
291
435
  console.log("Mado dev server");
292
- console.log(` url: http://localhost:${PORT}/`);
436
+ console.log(` url: http://${urlHost}:${PORT}/`);
437
+ console.log(` host: ${HOST}`);
293
438
  console.log(` root: ${ROOT}`);
294
439
  console.log(` mount: ${mount}`);
295
440
  console.log(` hmr: ${HMR ? "on" : "off"}`);
296
441
  console.log(` preload: ${PRELOAD}`);
297
442
  console.log(` dist: ${distReady ? "ready" : "missing (run mado build)"}`);
443
+ if (PROXY_RULES.length > 0) {
444
+ console.log(" proxy:");
445
+ for (const [prefix, upstream] of PROXY_RULES) {
446
+ console.log(` ${prefix.padEnd(10)} → ${upstream}`);
447
+ }
448
+ }
298
449
  if (!EXAMPLE && existsSync(EXAMPLES_INDEX)) {
299
450
  console.log(" try: mado serve basic");
300
451
  console.log(" mado serve showcase");
@@ -0,0 +1,63 @@
1
+ # __APP_NAME__
2
+
3
+ A starter Mado admin app: nested routes, a guarded admin shell, a blessed API
4
+ client, and a one-shot release pipeline.
5
+
6
+ ## What you get
7
+
8
+ - `src/main.ts` — 8 lines: mount the router into `#app`. Layouts are NOT
9
+ declared here, only in `src/routes.ts`.
10
+ - `src/routes.ts` — nested manifest with three groups:
11
+ - `/` → public landing (bakeable),
12
+ - `/login` → centered `auth` layout,
13
+ - `/admin` → `app` layout, **guarded** by `requireAuth`.
14
+ - `src/layouts/app.ts` — admin shell (top bar + sidebar + content slot).
15
+ - `src/layouts/auth.ts` — centered card for sign-in.
16
+ - `src/lib/api.ts` — `createApiClient(baseUrl)` with bearer token, 401-refresh
17
+ retry, JSON in/out and a typed `ApiError`.
18
+ - `src/lib/auth.ts` — memory-only `accessToken`, `restoreSession()` from an
19
+ HttpOnly refresh cookie, and the `requireAuth` guard.
20
+ - `src/components/` — tiny `x-button` and `x-input` Web Components.
21
+ - `mado.config.json` — one config file. Includes a `dev.proxy` for `/api`.
22
+
23
+ ## Commands
24
+
25
+ ```bash
26
+ npm run dev # tsc -w + dev server on http://localhost:5173, HMR on
27
+ npm run build # tsc → dist/
28
+ npm run typecheck # tsc --noEmit
29
+ npm run bundle # esbuild → out/assets/
30
+ npm run bake # prerender baked routes → out/baked/
31
+ npm run release # typecheck + build + bundle + bake + copy public/ → out/
32
+ npm run preview # serve out/ locally (production rehearsal)
33
+ ```
34
+
35
+ To deploy, run `npm run release` and upload the entire `out/` directory
36
+ anywhere static (nginx, Cloudflare Pages, S3, Netlify, GitHub Pages, …).
37
+
38
+ ## Backend expectations
39
+
40
+ The blessed `api` client speaks JSON. The auth recipe expects:
41
+
42
+ - `POST /api/auth/login` → `{ accessToken: string }` (sets refresh cookie)
43
+ - `POST /api/auth/refresh` → `{ accessToken: string }` (reads refresh cookie)
44
+ - `POST /api/auth/logout` → 204 (clears refresh cookie)
45
+
46
+ Change `mado.config.json#dev.proxy` to point at your backend in development.
47
+
48
+ ## Where things live
49
+
50
+ | What | Where |
51
+ |---------------------|--------------------------------------|
52
+ | New URL | `src/pages/*.ts` + add to `routes.ts`|
53
+ | New protected URL | inside the `/admin` layout block |
54
+ | New layout | `src/layouts/*.ts` |
55
+ | New reusable widget | `src/components/x-*.ts` |
56
+ | New API call | `src/lib/api.ts` (add a method) |
57
+ | New global signal | `src/lib/<name>.ts` |
58
+ | Static image | `public/<file>` |
59
+
60
+ See the framework docs:
61
+ [`docs/en/11-layouts.md`](https://github.com/madojs/mado/blob/main/docs/en/11-layouts.md),
62
+ [`docs/en/12-auth-and-api.md`](https://github.com/madojs/mado/blob/main/docs/en/12-auth-and-api.md),
63
+ [`docs/en/13-deployment.md`](https://github.com/madojs/mado/blob/main/docs/en/13-deployment.md).
@@ -0,0 +1,28 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>__APP_NAME__</title>
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
+ -->
15
+ <script type="importmap">
16
+ {
17
+ "imports": {
18
+ "@madojs/mado": "/node_modules/@madojs/mado/dist/src/index.js",
19
+ "@madojs/mado/": "/node_modules/@madojs/mado/dist/src/"
20
+ }
21
+ }
22
+ </script>
23
+ </head>
24
+ <body>
25
+ <div id="app"></div>
26
+ <script type="module" src="/dist/main.js"></script>
27
+ </body>
28
+ </html>
@@ -0,0 +1,22 @@
1
+ {
2
+ "dev": {
3
+ "port": 5173,
4
+ "proxy": {
5
+ "/api": "http://localhost:3000"
6
+ }
7
+ },
8
+ "build": {
9
+ "out": "out",
10
+ "dist": "dist",
11
+ "publicDir": "public"
12
+ },
13
+ "bake": {
14
+ "entry": "src/routes.ts",
15
+ "template": "index.html",
16
+ "baseUrl": "https://example.com"
17
+ },
18
+ "bundle": {
19
+ "splitting": true,
20
+ "compress": ["gz", "br"]
21
+ }
22
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "__PACKAGE_NAME__",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "build": "mado build",
8
+ "typecheck": "mado typecheck",
9
+ "dev": "mado dev",
10
+ "serve": "mado serve",
11
+ "bundle": "mado bundle",
12
+ "bake": "mado bake",
13
+ "release": "mado release",
14
+ "preview": "mado preview"
15
+ },
16
+ "dependencies": {
17
+ "@madojs/mado": "__MADOJS_VERSION__"
18
+ },
19
+ "devDependencies": {
20
+ "esbuild": "^0.24.0",
21
+ "linkedom": "^0.18.0",
22
+ "typescript": "^6.0.3"
23
+ }
24
+ }
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2
+ <rect width="32" height="32" rx="6" fill="#1f6feb"/>
3
+ <text x="16" y="22" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-weight="700" font-size="18" fill="white">M</text>
4
+ </svg>
@@ -0,0 +1,55 @@
1
+ // <x-button variant="primary|ghost|danger" ?disabled>
2
+ //
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`.
6
+
7
+ import { component, css, html } from "@madojs/mado";
8
+
9
+ component(
10
+ "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}>
16
+ <slot></slot>
17
+ </button>
18
+ `;
19
+ },
20
+ {
21
+ styles: css`
22
+ :host { display: inline-flex; }
23
+ button {
24
+ display: inline-flex;
25
+ align-items: center;
26
+ gap: var(--space-2);
27
+ padding: 8px 14px;
28
+ border-radius: var(--radius-sm);
29
+ border: 1px solid transparent;
30
+ font: inherit;
31
+ cursor: pointer;
32
+ background: var(--accent);
33
+ color: var(--accent-fg);
34
+ transition: filter .12s ease;
35
+ }
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
+
40
+ button[data-variant="ghost"] {
41
+ background: transparent;
42
+ color: var(--fg);
43
+ border-color: var(--border);
44
+ }
45
+ button[data-variant="ghost"]:hover:not(:disabled) {
46
+ background: var(--bg-elevated);
47
+ }
48
+
49
+ button[data-variant="danger"] {
50
+ background: var(--danger);
51
+ color: white;
52
+ }
53
+ `,
54
+ },
55
+ );