@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,40 +1,109 @@
1
- // Optional production bundle through esbuild. No config files.
1
+ // Production bundle through esbuild. No build config files.
2
2
  //
3
3
  // Usage:
4
- // node scripts/bundle.mjs # → out/<hash>.js + chunks + out/index.html
5
- // ENTRY=src/index.ts HTML=examples/index.html node scripts/bundle.mjs
4
+ // mado bundle
5
+ // mado bundle --entry src/main.ts --html index.html --out out
6
+ //
7
+ // Configuration precedence: built-in defaults < mado.config.json < CLI flags
8
+ // < legacy env vars (ENTRY, HTML, OUT_DIR).
6
9
  //
7
10
  // What it does:
8
- // 1. Bundles entry with code splitting (each dynamic import → a chunk).
9
- // 2. Writes out/ index.html with modulepreload for critical chunks.
10
- // 3. Computes SRI hashes and writes integrity="...".
11
- // 4. Creates .gz and .br next to each .js for nginx gzip_static.
11
+ // 1. Bundles `entry` with code splitting (each dynamic import → chunk),
12
+ // writing hashed `main-<hash>.js` and `chunk-<hash>.js` into <out>/assets/.
13
+ // 2. Computes SRI for the entry bundle.
14
+ // 3. Rewrites `html` so its <script type=module> points at the hashed entry,
15
+ // removes the dev importmap, and adds <link rel=modulepreload> for the
16
+ // entry and all chunks. Writes the result to <out>/index.html.
17
+ // 4. Pre-compresses every .js into .gz and .br for nginx_gzip_static and
18
+ // Cloudflare/Netlify Accept-Encoding.
19
+ // 5. Copies optional `favicon.ico`/`favicon.svg`/`assets/` from the project
20
+ // root if they exist (kept for backwards compatibility; new apps should
21
+ // put public assets in `public/` so `mado release` copies them).
12
22
  //
13
- // Dependency: esbuild (devDep, only needed when running bundle).
23
+ // In repo-mode (the framework repo itself) the defaults still point at
24
+ // examples/showcase so the framework can dogfood its bundle pipeline against
25
+ // its biggest example.
14
26
 
15
27
  import { build } from "esbuild";
16
- import {
17
- readFile,
18
- writeFile,
19
- mkdir,
20
- cp,
21
- stat,
22
- readdir,
23
- } from "node:fs/promises";
28
+ import { readFile, writeFile, mkdir, cp, stat, readdir, rm } from "node:fs/promises";
24
29
  import { createHash } from "node:crypto";
25
30
  import { gzipSync, brotliCompressSync, constants as zlibConst } from "node:zlib";
26
- import { join, basename } from "node:path";
31
+ import { join, basename, resolve, dirname } from "node:path";
27
32
  import { existsSync } from "node:fs";
28
33
 
29
- const ENTRY = process.env.ENTRY ?? "examples/showcase/main.ts";
30
- const OUT_DIR = process.env.OUT_DIR ?? "out";
31
- const HTML = process.env.HTML ?? "examples/showcase/index.html";
34
+ import { loadConfig, parseFlags, resolveProjectPath } from "./_config.mjs";
32
35
 
33
- await mkdir(OUT_DIR, { recursive: true });
36
+ const { flags } = parseFlags(process.argv.slice(2));
37
+ const cfg = loadConfig({});
38
+
39
+ // Defaults are context-aware: in repo-mode they continue to bundle the
40
+ // showcase example; in app-mode they assume the canonical layout.
41
+ const defaultEntry = cfg.context === "repo"
42
+ ? "examples/showcase/main.ts"
43
+ : "src/main.ts";
44
+ const defaultHtml = cfg.context === "repo"
45
+ ? "examples/showcase/index.html"
46
+ : "index.html";
47
+
48
+ const ENTRY = resolveProjectPath(
49
+ cfg,
50
+ typeof flags.entry === "string" ? flags.entry : process.env.ENTRY ?? defaultEntry,
51
+ );
52
+ const HTML = resolveProjectPath(
53
+ cfg,
54
+ typeof flags.html === "string" ? flags.html : process.env.HTML ?? defaultHtml,
55
+ );
56
+ const OUT_DIR = resolveProjectPath(
57
+ cfg,
58
+ typeof flags.out === "string" ? flags.out : process.env.OUT_DIR ?? cfg.build.out ?? "out",
59
+ );
60
+ // Where the hashed bundles land. Apps want them under /assets/* to match
61
+ // nginx.conf and _headers; in repo-mode we keep the historical out/main-*.js
62
+ // layout so existing showcase pages continue to work.
63
+ const ASSETS_REL = cfg.context === "repo" ? "" : "assets";
64
+ const ASSETS_DIR = ASSETS_REL ? join(OUT_DIR, ASSETS_REL) : OUT_DIR;
65
+
66
+ if (!existsSync(ENTRY)) {
67
+ console.error(`[bundle] entry not found: ${ENTRY}`);
68
+ console.error("[bundle] set bundle entry in mado.config.json or pass --entry <file>");
69
+ process.exit(1);
70
+ }
71
+ if (!existsSync(HTML)) {
72
+ console.error(`[bundle] html template not found: ${HTML}`);
73
+ console.error("[bundle] pass --html <file> or place index.html at the project root");
74
+ process.exit(1);
75
+ }
76
+
77
+ // Clean stale assets from previous bundles.
78
+ //
79
+ // Without this step, hashed chunks from prior runs (main-<oldhash>.js,
80
+ // chunk-<oldhash>.js) accumulate in ASSETS_DIR. We later list ASSETS_DIR
81
+ // (via readdir below) and emit <link rel="modulepreload"> for every
82
+ // .js file we find — so stale chunks would be preloaded as if they were
83
+ // still part of the app, polluting the production HTML and shipping dead
84
+ // code over the wire. SRI is also only computed for the fresh entry, so
85
+ // stale preloads would lack integrity checks.
86
+ //
87
+ // In app-mode the entire <out>/assets/ folder is owned by the bundler,
88
+ // so wiping it is safe. In repo-mode the historical layout drops assets
89
+ // directly into <out>/ alongside non-bundle artifacts, so we only remove
90
+ // the recognisable hashed files there.
91
+ if (ASSETS_REL) {
92
+ await rm(ASSETS_DIR, { recursive: true, force: true });
93
+ } else if (existsSync(ASSETS_DIR)) {
94
+ for (const f of await readdir(ASSETS_DIR)) {
95
+ if (/^(main|chunk|asset)-[A-Z0-9]+\.(js|css)(\.map|\.gz|\.br)?$/i.test(f)) {
96
+ await rm(join(ASSETS_DIR, f), { force: true });
97
+ }
98
+ }
99
+ }
100
+ await mkdir(ASSETS_DIR, { recursive: true });
34
101
 
35
- console.log(`[bundle] entry: ${ENTRY}`);
102
+ console.log(`[bundle] entry: ${ENTRY}`);
103
+ console.log(`[bundle] html: ${HTML}`);
104
+ console.log(`[bundle] out: ${OUT_DIR}`);
105
+ if (ASSETS_REL) console.log(`[bundle] assets: ${ASSETS_DIR}`);
36
106
 
37
- // 1) esbuild with code splitting
38
107
  const result = await build({
39
108
  entryPoints: [ENTRY],
40
109
  bundle: true,
@@ -43,7 +112,7 @@ const result = await build({
43
112
  format: "esm",
44
113
  target: "es2022",
45
114
  splitting: true,
46
- outdir: OUT_DIR,
115
+ outdir: ASSETS_DIR,
47
116
  entryNames: "main-[hash]",
48
117
  chunkNames: "chunk-[hash]",
49
118
  assetNames: "asset-[hash]",
@@ -51,79 +120,76 @@ const result = await build({
51
120
  legalComments: "none",
52
121
  });
53
122
 
54
- // 2) Find the main entry file
55
- const entryFile = Object.entries(result.metafile.outputs)
56
- .find(([name, info]) => info.entryPoint && name.endsWith(".js"))?.[0];
57
-
58
- if (!entryFile) {
123
+ const entryOutput = Object.entries(result.metafile.outputs).find(
124
+ ([name, info]) => info.entryPoint && name.endsWith(".js"),
125
+ );
126
+ if (!entryOutput) {
59
127
  console.error("[bundle] entry not found in outputs");
60
128
  process.exit(1);
61
129
  }
62
- const mainBundle = basename(entryFile);
130
+ const mainBundle = basename(entryOutput[0]);
63
131
 
64
- // 3) Collect all js chunks (including main)
65
- const allJs = (await readdir(OUT_DIR)).filter((f) => f.endsWith(".js"));
132
+ // Collect all js chunks in the assets dir.
133
+ const allJs = (await readdir(ASSETS_DIR)).filter((f) => f.endsWith(".js"));
66
134
 
67
- // 4) Compress every .js into .gz and .br (for nginx gzip_static)
135
+ // Pre-compress every .js into .gz and .br.
68
136
  let totalRaw = 0;
69
137
  let totalGz = 0;
70
138
  let totalBr = 0;
71
-
72
139
  for (const f of allJs) {
73
- const p = join(OUT_DIR, f);
140
+ const p = join(ASSETS_DIR, f);
74
141
  const buf = await readFile(p);
75
142
  totalRaw += buf.length;
76
-
77
143
  const gz = gzipSync(buf, { level: 9 });
78
144
  await writeFile(`${p}.gz`, gz);
79
145
  totalGz += gz.length;
80
-
81
- const br = brotliCompressSync(buf, {
82
- params: { [zlibConst.BROTLI_PARAM_QUALITY]: 11 },
83
- });
146
+ const br = brotliCompressSync(buf, { params: { [zlibConst.BROTLI_PARAM_QUALITY]: 11 } });
84
147
  await writeFile(`${p}.br`, br);
85
148
  totalBr += br.length;
86
149
  }
87
150
 
88
- // 5) SRI for the main bundle
89
- const mainBuf = await readFile(join(OUT_DIR, mainBundle));
151
+ // SRI for the main bundle.
152
+ const mainBuf = await readFile(join(ASSETS_DIR, mainBundle));
90
153
  const sri = "sha384-" + createHash("sha384").update(mainBuf).digest("base64");
91
154
 
92
- // 6) HTML: replace <script> and add modulepreload for main
155
+ // Rewrite HTML: drop dev importmap, swap the <script src>, add preloads.
156
+ const urlPrefix = ASSETS_REL ? `/${ASSETS_REL}/` : "/";
93
157
  let html = await readFile(HTML, "utf8");
94
158
 
95
- // modulepreload for main + other chunks. For now preload all chunks; this can
96
- // later be filtered from metafile analysis.
159
+ html = html.replace(/<script type="importmap">[\s\S]*?<\/script>/, "");
160
+
97
161
  const preloads = allJs
98
162
  .map(
99
163
  (f) =>
100
- ` <link rel="modulepreload" href="/${f}"${
164
+ ` <link rel="modulepreload" href="${urlPrefix}${f}"${
101
165
  f === mainBundle ? ` integrity="${sri}" crossorigin="anonymous"` : ""
102
166
  } />`,
103
167
  )
104
168
  .join("\n");
105
169
 
106
- // Remove the old importmap (it points to dev paths under /dist/src/...).
107
- html = html.replace(/<script type="importmap">[\s\S]*?<\/script>/, "");
108
-
109
- // Replace the script with the new one.
110
- html = html.replace(
111
- /<script\s+type="module"\s+src="[^"]+"[^>]*><\/script>/,
112
- `<script type="module" src="/${mainBundle}" integrity="${sri}" crossorigin="anonymous"></script>`,
113
- );
170
+ const scriptTag = `<script type="module" src="${urlPrefix}${mainBundle}" integrity="${sri}" crossorigin="anonymous"></script>`;
171
+ if (/<script\s+type="module"\s+src="[^"]+"[^>]*><\/script>/.test(html)) {
172
+ html = html.replace(
173
+ /<script\s+type="module"\s+src="[^"]+"[^>]*><\/script>/,
174
+ scriptTag,
175
+ );
176
+ } else {
177
+ // No matching dev <script> in the template: inject one before </body>.
178
+ html = html.replace(/<\/body>/i, ` ${scriptTag}\n </body>`);
179
+ }
114
180
 
115
- // Insert preloads before </head>.
116
- html = html.replace(
117
- /<\/head>/,
118
- `${preloads}\n </head>`,
119
- );
181
+ html = html.replace(/<\/head>/, `${preloads}\n </head>`);
120
182
 
183
+ await mkdir(OUT_DIR, { recursive: true });
121
184
  await writeFile(join(OUT_DIR, "index.html"), html);
122
185
 
123
- // 7) Static files
124
- for (const name of ["favicon.ico", "favicon.svg", "assets"]) {
125
- const src = join("examples", name);
126
- if (existsSync(src)) {
186
+ // Backwards-compatible asset copy (repo-mode only). In app-mode the
187
+ // `mado release` command copies the entire `public/` tree, which is the
188
+ // recommended path for new apps.
189
+ if (cfg.context === "repo") {
190
+ for (const name of ["favicon.ico", "favicon.svg", "assets"]) {
191
+ const src = join(cfg.projectRoot, "examples", name);
192
+ if (!existsSync(src)) continue;
127
193
  const s = await stat(src);
128
194
  if (s.isDirectory()) {
129
195
  await cp(src, join(OUT_DIR, name), { recursive: true });
@@ -133,14 +199,14 @@ for (const name of ["favicon.ico", "favicon.svg", "assets"]) {
133
199
  }
134
200
  }
135
201
 
136
- // 8) Stats
202
+ // Stats
137
203
  const kib = (n) => (n / 1024).toFixed(1);
138
204
  console.log(`[bundle] chunks: ${allJs.length}`);
139
205
  for (const f of allJs.sort()) {
140
- const sz = (await stat(join(OUT_DIR, f))).size;
141
- const gz = (await stat(join(OUT_DIR, `${f}.gz`))).size;
206
+ const sz = (await stat(join(ASSETS_DIR, f))).size;
207
+ const gz = (await stat(join(ASSETS_DIR, `${f}.gz`))).size;
142
208
  const star = f === mainBundle ? " *" : "";
143
209
  console.log(` ${f.padEnd(24)} ${kib(sz).padStart(6)} KB raw, ${kib(gz).padStart(5)} KB gz${star}`);
144
210
  }
145
211
  console.log(`[bundle] total: ${kib(totalRaw)} KB raw / ${kib(totalGz)} KB gz / ${kib(totalBr)} KB br`);
146
- console.log(`[bundle] entry SRI: ${sri}`);
212
+ console.log(`[bundle] entry SRI: ${sri}`);
package/scripts/cli.mjs CHANGED
@@ -2,23 +2,30 @@
2
2
 
3
3
  import { spawn } from "node:child_process";
4
4
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
5
- import { copyFile, cp, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
5
+ import { copyFile, cp, mkdir, readdir, readFile, writeFile, rm } from "node:fs/promises";
6
6
  import http from "node:http";
7
7
  import { dirname, join, resolve } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
 
10
+ import { detectContext, loadConfig } from "./_config.mjs";
11
+
10
12
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
11
13
  const PROJECT_ROOT = resolve(process.cwd());
12
14
  const PACKAGE_JSON = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"));
13
15
  const [, , rawCommand, ...args] = process.argv;
14
16
 
17
+ // Context detection lives in _config.mjs so every script agrees on what
18
+ // "repo" vs "app" means. CLI uses it to pick safer defaults.
19
+ const CONTEXT = detectContext(PROJECT_ROOT);
20
+ const IS_REPO = CONTEXT === "repo";
21
+
15
22
  const EXAMPLES = [
16
23
  ["basic", "minimal API tour"],
17
24
  ["tickets", "LLM zero-history CRUD validation"],
18
25
  ["showcase", "flagship SaaS CRM pressure app"],
19
26
  ["cloudflare", "Cloudflare Workers edge example"],
20
27
  ];
21
- const STARTERS = ["minimal", "crud"];
28
+ const STARTERS = ["minimal", "crud", "admin"];
22
29
 
23
30
  const command = rawCommand ?? "help";
24
31
 
@@ -39,15 +46,17 @@ switch (command) {
39
46
  if (args[0] === "browser") {
40
47
  await runNodeScript("scripts/showcase-regression.mjs", args.slice(1));
41
48
  } else {
49
+ // Ensure dist/ is fresh so tests that import from ../dist/ work.
50
+ await runNodeBin("typescript/bin/tsc", []);
42
51
  const files = await listTestFiles();
43
52
  await run(process.execPath, ["--test", "--test-timeout=20000", ...files, ...args]);
44
53
  }
45
54
  break;
46
55
  case "serve":
47
- await runServe(args[0] ?? "");
56
+ await runServe(args);
48
57
  break;
49
58
  case "dev":
50
- await runDev(args[0] ?? "");
59
+ await runDev(args);
51
60
  break;
52
61
  case "bake":
53
62
  await runNodeScript("scripts/bake.mjs", args);
@@ -58,6 +67,9 @@ switch (command) {
58
67
  case "preview":
59
68
  await runNodeScript("scripts/preview.mjs", args);
60
69
  break;
70
+ case "release":
71
+ await runRelease(args);
72
+ break;
61
73
  case "new":
62
74
  await runNodeScript("scripts/new.mjs", args);
63
75
  break;
@@ -75,15 +87,25 @@ switch (command) {
75
87
  process.exit(1);
76
88
  }
77
89
 
78
- async function runServe(example) {
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);
79
95
  if (!example && PROJECT_ROOT !== PACKAGE_ROOT) {
80
96
  await serveStaticProject(PROJECT_ROOT);
81
97
  return;
82
98
  }
83
99
  if (example) assertExample(example, { serveable: true });
84
- await run(process.execPath, [join(PACKAGE_ROOT, "server/serve.mjs"), example].filter(Boolean), {
85
- env: { ...process.env, EXAMPLE: example || process.env.EXAMPLE || "" },
86
- });
100
+ await run(
101
+ process.execPath,
102
+ [join(PACKAGE_ROOT, "server/serve.mjs"), example, ...forwarded].filter(
103
+ Boolean,
104
+ ),
105
+ {
106
+ env: { ...process.env, EXAMPLE: example || process.env.EXAMPLE || "" },
107
+ },
108
+ );
87
109
  }
88
110
 
89
111
  async function runInit(rawArgs) {
@@ -156,15 +178,25 @@ async function runInit(rawArgs) {
156
178
  console.log("");
157
179
  }
158
180
 
159
- async function runDev(example) {
181
+ async function runDev(rawArgs) {
182
+ // Forward unknown flags (e.g. --host, --port) to server/serve.mjs so callers
183
+ // can write `mado dev --host 127.0.0.1` without the CLI mistaking `--host`
184
+ // for an example name.
185
+ const { example, forwarded } = splitDevArgs(rawArgs);
160
186
  if (example) assertExample(example, { serveable: true });
161
187
 
162
188
  const env = { ...process.env, EXAMPLE: example || process.env.EXAMPLE || "" };
163
- const server = spawn(process.execPath, [join(PACKAGE_ROOT, "server/serve.mjs"), example].filter(Boolean), {
164
- cwd: PROJECT_ROOT,
165
- env,
166
- stdio: "inherit",
167
- });
189
+ const server = spawn(
190
+ process.execPath,
191
+ [join(PACKAGE_ROOT, "server/serve.mjs"), example, ...forwarded].filter(
192
+ Boolean,
193
+ ),
194
+ {
195
+ cwd: PROJECT_ROOT,
196
+ env,
197
+ stdio: "inherit",
198
+ },
199
+ );
168
200
  const tsc = spawn(process.execPath, [resolveBin("typescript/bin/tsc"), "-w"], {
169
201
  cwd: PROJECT_ROOT,
170
202
  stdio: "inherit",
@@ -198,6 +230,79 @@ async function runDev(example) {
198
230
  });
199
231
  }
200
232
 
233
+ async function runRelease(rawArgs) {
234
+ // Single "ship it" command. Composes the smaller steps so the user does not
235
+ // have to remember the order, and so the deploy artifact (out/) is always
236
+ // assembled the same way.
237
+ //
238
+ // mado release
239
+ // → mado typecheck
240
+ // → mado build (tsc → dist/)
241
+ // → mado bundle (esbuild → out/assets/, also copies index.html)
242
+ // → mado bake (HTML → out/baked/)
243
+ // → copy public/* → out/
244
+ //
245
+ // Flags are forwarded to bake/bundle.
246
+ const cfg = loadConfig({ projectRoot: PROJECT_ROOT });
247
+ const outDir = resolve(cfg.projectRoot, cfg.build.out ?? "out");
248
+ const publicDir = resolve(cfg.projectRoot, cfg.build.publicDir ?? "public");
249
+
250
+ console.log(`[release] context: ${cfg.context}`);
251
+ console.log(`[release] artifact: ${outDir}`);
252
+ console.log("");
253
+
254
+ console.log("[release] step 1/5 typecheck");
255
+ await runNodeBin("typescript/bin/tsc", ["--noEmit"]);
256
+
257
+ console.log("[release] step 2/5 build (tsc → dist/)");
258
+ await runNodeBin("typescript/bin/tsc", []);
259
+
260
+ console.log("[release] step 3/5 bundle (esbuild → out/assets/)");
261
+ await runNodeScript("scripts/bundle.mjs", rawArgs);
262
+
263
+ console.log("[release] step 4/5 bake (out/baked/)");
264
+ await runNodeScript("scripts/bake.mjs", rawArgs);
265
+
266
+ console.log("[release] step 5/5 copy public/ → out/");
267
+ if (existsSync(publicDir)) {
268
+ await mkdir(outDir, { recursive: true });
269
+ await cp(publicDir, outDir, { recursive: true });
270
+ console.log(`[release] copied ${publicDir} → ${outDir}`);
271
+ } else {
272
+ console.log(`[release] no ${publicDir}, skipping`);
273
+ }
274
+
275
+ // Optional CDN config files. Generated only when not already provided.
276
+ await writeIfMissing(
277
+ join(outDir, "_redirects"),
278
+ // Cloudflare Pages / Netlify: SPA fallback so deep links work after a
279
+ // hard refresh. Baked HTML files are matched first because of
280
+ // `force: false` / static-priority rules on these hosts.
281
+ "/* /index.html 200\n",
282
+ );
283
+ await writeIfMissing(
284
+ join(outDir, "_headers"),
285
+ [
286
+ "/assets/*",
287
+ " Cache-Control: public, max-age=31536000, immutable",
288
+ "",
289
+ "/*.html",
290
+ " Cache-Control: no-cache, must-revalidate",
291
+ "",
292
+ ].join("\n"),
293
+ );
294
+
295
+ console.log("");
296
+ console.log(`[release] done. Deploy artifact: ${outDir}`);
297
+ console.log("[release] try: mado preview");
298
+ }
299
+
300
+ async function writeIfMissing(path, content) {
301
+ if (existsSync(path)) return;
302
+ await writeFile(path, content);
303
+ console.log(`[release] wrote ${path}`);
304
+ }
305
+
201
306
  async function copyCanonicalAgentFiles(target) {
202
307
  for (const file of ["AGENTS.md", "llms.txt"]) {
203
308
  const source = join(PACKAGE_ROOT, file);
@@ -274,19 +379,38 @@ async function listTestFiles() {
274
379
  }
275
380
 
276
381
  function printHelp() {
277
- console.log(`mado commands:
278
- mado init <name> [--starter minimal|crud] [--force]
279
- mado build
280
- mado watch
281
- mado typecheck
282
- mado test [browser]
283
- mado serve [basic|tickets|showcase]
284
- mado dev [basic|tickets|showcase]
285
- mado bake
286
- mado bundle
287
- mado preview
288
- mado new <list|form|detail> <name>
289
- mado examples`);
382
+ const ctx = IS_REPO ? "repo-mode (framework repository)" : "app-mode";
383
+ console.log(`mado commands (${ctx}):
384
+
385
+ Project lifecycle:
386
+ mado init <name> [--starter minimal|crud|admin] [--force]
387
+ scaffold a new app
388
+ mado dev tsc -w + dev server with HMR
389
+ mado build tsc (writes dist/)
390
+ mado typecheck tsc --noEmit
391
+ mado test [browser] run unit tests (or browser regression)
392
+
393
+ Production:
394
+ mado bundle esbuild → out/assets/ (hashed bundles)
395
+ mado bake [--entry <file>] [--template <html>] [--out <dir>] [--base-url <url>]
396
+ prerender baked routes → out/baked/
397
+ mado release typecheck + build + bundle + bake + copy public/ → out/
398
+ ← the one command for "ship it"
399
+ mado preview serve exactly out/ locally (production rehearsal)
400
+ mado serve [example] simple static server (also runs in repo-mode for examples)
401
+
402
+ Generators:
403
+ mado new <list|form|detail> <name>
404
+
405
+ Misc:
406
+ mado examples list bundled examples
407
+ mado help this screen
408
+
409
+ Configuration:
410
+ mado reads ./mado.config.json (dev.proxy, build.out, bake.entry/template/baseUrl, …)
411
+ CLI flags > mado.config.json > built-in defaults.
412
+
413
+ See MADO_V1_PLAN.md for the road to v1.`);
290
414
  }
291
415
 
292
416
  function parseFlags(raw) {
@@ -307,6 +431,50 @@ function parseFlags(raw) {
307
431
  return { flags, positional };
308
432
  }
309
433
 
434
+ /**
435
+ * Split args for `mado dev` / `mado serve` into:
436
+ * - example: the first non-flag positional (or undefined)
437
+ * - forwarded: every remaining token (flags, their values, leftover
438
+ * positionals), preserved in order so server/serve.mjs sees them
439
+ * unchanged.
440
+ *
441
+ * This is what lets `mado dev -- --host 127.0.0.1` and
442
+ * `mado dev showcase --port 6000` both work without the CLI confusing
443
+ * `--host` for an example name.
444
+ */
445
+ function splitDevArgs(raw) {
446
+ if (!Array.isArray(raw) || raw.length === 0) {
447
+ return { example: "", forwarded: [] };
448
+ }
449
+ let example = "";
450
+ const forwarded = [];
451
+ let pickedExample = false;
452
+ for (let i = 0; i < raw.length; i++) {
453
+ const a = raw[i];
454
+ if (a === "--") {
455
+ forwarded.push(...raw.slice(i + 1));
456
+ break;
457
+ }
458
+ if (a.startsWith("-")) {
459
+ forwarded.push(a);
460
+ // Lookahead: if the next token is the flag's VALUE (does not start with
461
+ // "-"), forward it too — but only when the flag is in inline form
462
+ // (--flag value), not --flag=value.
463
+ if (!a.includes("=") && raw[i + 1] !== undefined && !raw[i + 1].startsWith("-")) {
464
+ forwarded.push(raw[++i]);
465
+ }
466
+ continue;
467
+ }
468
+ if (!pickedExample) {
469
+ example = a;
470
+ pickedExample = true;
471
+ } else {
472
+ forwarded.push(a);
473
+ }
474
+ }
475
+ return { example, forwarded };
476
+ }
477
+
310
478
  function packageNameFromDir(target) {
311
479
  return target
312
480
  .split(/[\\/]/)