@madojs/mado 0.5.0 → 0.6.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 (122) hide show
  1. package/AGENTS.md +49 -1
  2. package/CHANGELOG.md +188 -0
  3. package/MADO_V1_PLAN.md +179 -0
  4. package/README.md +53 -14
  5. package/ROADMAP.md +36 -5
  6. package/TODO.md +72 -0
  7. package/dist/src/forms.d.ts +41 -7
  8. package/dist/src/forms.js +334 -59
  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/router/manifest.d.ts +16 -1
  23. package/dist/src/router/manifest.js +181 -38
  24. package/dist/src/router/manifest.js.map +1 -1
  25. package/dist/src/router/match.d.ts +7 -2
  26. package/dist/src/router/match.js +14 -4
  27. package/dist/src/router/match.js.map +1 -1
  28. package/dist/src/router/navigation.d.ts +10 -0
  29. package/dist/src/router/navigation.js +73 -12
  30. package/dist/src/router/navigation.js.map +1 -1
  31. package/dist/src/signal.d.ts +15 -1
  32. package/dist/src/signal.js +112 -16
  33. package/dist/src/signal.js.map +1 -1
  34. package/docs/en/02-project-layout.md +99 -40
  35. package/docs/en/05-why-mado.md +1 -1
  36. package/docs/en/06-for-backenders.md +1 -1
  37. package/docs/en/07-llm-pitfalls.md +1 -1
  38. package/docs/en/09-shadow-vs-light-dom.md +60 -0
  39. package/docs/en/10-app-architecture.md +141 -0
  40. package/docs/en/11-layouts.md +115 -0
  41. package/docs/en/12-auth-and-api.md +217 -0
  42. package/docs/en/13-deployment.md +192 -0
  43. package/docs/en/14-testing.md +82 -0
  44. package/docs/en/15-error-handling.md +100 -0
  45. package/docs/en/16-bake-cookbook.md +93 -0
  46. package/docs/en/README.md +7 -0
  47. package/docs/fr/05-why-mado.md +1 -1
  48. package/docs/fr/06-for-backenders.md +1 -1
  49. package/docs/fr/07-llm-pitfalls.md +1 -1
  50. package/docs/fr/09-shadow-vs-light-dom.md +63 -0
  51. package/docs/fr/10-app-architecture.md +61 -0
  52. package/docs/fr/11-layouts.md +35 -0
  53. package/docs/fr/12-auth-and-api.md +35 -0
  54. package/docs/fr/13-deployment.md +39 -0
  55. package/docs/fr/14-testing.md +41 -0
  56. package/docs/fr/15-error-handling.md +50 -0
  57. package/docs/fr/16-bake-cookbook.md +35 -0
  58. package/docs/fr/README.md +7 -0
  59. package/docs/ru/05-why-mado.md +2 -2
  60. package/docs/ru/06-for-backenders.md +1 -1
  61. package/docs/ru/09-shadow-vs-light-dom.md +60 -0
  62. package/docs/ru/10-app-architecture.md +100 -0
  63. package/docs/ru/11-layouts.md +47 -0
  64. package/docs/ru/12-auth-and-api.md +53 -0
  65. package/docs/ru/13-deployment.md +60 -0
  66. package/docs/ru/14-testing.md +50 -0
  67. package/docs/ru/15-error-handling.md +56 -0
  68. package/docs/ru/16-bake-cookbook.md +55 -0
  69. package/docs/ru/README.md +7 -0
  70. package/docs/uk/06-for-backenders.md +2 -2
  71. package/docs/uk/09-shadow-vs-light-dom.md +91 -24
  72. package/docs/uk/10-app-architecture.md +56 -0
  73. package/docs/uk/11-layouts.md +34 -0
  74. package/docs/uk/12-auth-and-api.md +34 -0
  75. package/docs/uk/13-deployment.md +39 -0
  76. package/docs/uk/14-testing.md +34 -0
  77. package/docs/uk/15-error-handling.md +32 -0
  78. package/docs/uk/16-bake-cookbook.md +36 -0
  79. package/docs/uk/README.md +7 -0
  80. package/llms.txt +24 -1
  81. package/package.json +3 -1
  82. package/scripts/_config.mjs +224 -0
  83. package/scripts/bake.mjs +217 -120
  84. package/scripts/bundle.mjs +110 -67
  85. package/scripts/cli.mjs +127 -16
  86. package/scripts/preview.mjs +22 -12
  87. package/server/serve.mjs +101 -11
  88. package/starters/admin/README.md +63 -0
  89. package/starters/admin/index.html +21 -0
  90. package/starters/admin/mado.config.json +22 -0
  91. package/starters/admin/package.json +22 -0
  92. package/starters/admin/public/favicon.svg +4 -0
  93. package/starters/admin/src/components/x-button.ts +55 -0
  94. package/starters/admin/src/components/x-input.ts +74 -0
  95. package/starters/admin/src/layouts/app.ts +101 -0
  96. package/starters/admin/src/layouts/auth.ts +41 -0
  97. package/starters/admin/src/lib/api.ts +133 -0
  98. package/starters/admin/src/lib/auth.ts +83 -0
  99. package/starters/admin/src/main.ts +15 -0
  100. package/starters/admin/src/pages/admin/dashboard.ts +48 -0
  101. package/starters/admin/src/pages/admin/order-detail.ts +78 -0
  102. package/starters/admin/src/pages/admin/orders.ts +117 -0
  103. package/starters/admin/src/pages/home.ts +25 -0
  104. package/starters/admin/src/pages/login.ts +70 -0
  105. package/starters/admin/src/pages/not-found.ts +12 -0
  106. package/starters/admin/src/routes.ts +40 -0
  107. package/starters/admin/src/styles/global.ts +86 -0
  108. package/starters/admin/tsconfig.json +15 -0
  109. package/starters/crud/README.md +14 -2
  110. package/starters/crud/mado.config.json +20 -0
  111. package/starters/crud/package.json +9 -4
  112. package/starters/crud/src/components/app-shell.ts +13 -8
  113. package/starters/crud/src/main.ts +1 -4
  114. package/starters/crud/src/pages/ticket-detail.ts +1 -0
  115. package/starters/crud/src/pages/ticket-new.ts +1 -0
  116. package/starters/crud/src/pages/tickets.ts +1 -0
  117. package/starters/crud/src/routes.ts +4 -2
  118. package/starters/minimal/README.md +4 -2
  119. package/starters/minimal/mado.config.json +20 -0
  120. package/starters/minimal/package.json +8 -3
  121. package/starters/minimal/src/components/app-counter.ts +1 -1
  122. package/starters/minimal/src/routes.ts +4 -2
@@ -1,40 +1,86 @@
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=examples/main.ts 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 } 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/main.ts";
30
- const OUT_DIR = process.env.OUT_DIR ?? "out";
31
- const HTML = process.env.HTML ?? "examples/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({});
34
38
 
35
- console.log(`[bundle] entry: ${ENTRY}`);
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
+ await mkdir(ASSETS_DIR, { recursive: true });
78
+
79
+ console.log(`[bundle] entry: ${ENTRY}`);
80
+ console.log(`[bundle] html: ${HTML}`);
81
+ console.log(`[bundle] out: ${OUT_DIR}`);
82
+ if (ASSETS_REL) console.log(`[bundle] assets: ${ASSETS_DIR}`);
36
83
 
37
- // 1) esbuild with code splitting
38
84
  const result = await build({
39
85
  entryPoints: [ENTRY],
40
86
  bundle: true,
@@ -43,7 +89,7 @@ const result = await build({
43
89
  format: "esm",
44
90
  target: "es2022",
45
91
  splitting: true,
46
- outdir: OUT_DIR,
92
+ outdir: ASSETS_DIR,
47
93
  entryNames: "main-[hash]",
48
94
  chunkNames: "chunk-[hash]",
49
95
  assetNames: "asset-[hash]",
@@ -51,79 +97,76 @@ const result = await build({
51
97
  legalComments: "none",
52
98
  });
53
99
 
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) {
100
+ const entryOutput = Object.entries(result.metafile.outputs).find(
101
+ ([name, info]) => info.entryPoint && name.endsWith(".js"),
102
+ );
103
+ if (!entryOutput) {
59
104
  console.error("[bundle] entry not found in outputs");
60
105
  process.exit(1);
61
106
  }
62
- const mainBundle = basename(entryFile);
107
+ const mainBundle = basename(entryOutput[0]);
63
108
 
64
- // 3) Collect all js chunks (including main)
65
- const allJs = (await readdir(OUT_DIR)).filter((f) => f.endsWith(".js"));
109
+ // Collect all js chunks in the assets dir.
110
+ const allJs = (await readdir(ASSETS_DIR)).filter((f) => f.endsWith(".js"));
66
111
 
67
- // 4) Compress every .js into .gz and .br (for nginx gzip_static)
112
+ // Pre-compress every .js into .gz and .br.
68
113
  let totalRaw = 0;
69
114
  let totalGz = 0;
70
115
  let totalBr = 0;
71
-
72
116
  for (const f of allJs) {
73
- const p = join(OUT_DIR, f);
117
+ const p = join(ASSETS_DIR, f);
74
118
  const buf = await readFile(p);
75
119
  totalRaw += buf.length;
76
-
77
120
  const gz = gzipSync(buf, { level: 9 });
78
121
  await writeFile(`${p}.gz`, gz);
79
122
  totalGz += gz.length;
80
-
81
- const br = brotliCompressSync(buf, {
82
- params: { [zlibConst.BROTLI_PARAM_QUALITY]: 11 },
83
- });
123
+ const br = brotliCompressSync(buf, { params: { [zlibConst.BROTLI_PARAM_QUALITY]: 11 } });
84
124
  await writeFile(`${p}.br`, br);
85
125
  totalBr += br.length;
86
126
  }
87
127
 
88
- // 5) SRI for the main bundle
89
- const mainBuf = await readFile(join(OUT_DIR, mainBundle));
128
+ // SRI for the main bundle.
129
+ const mainBuf = await readFile(join(ASSETS_DIR, mainBundle));
90
130
  const sri = "sha384-" + createHash("sha384").update(mainBuf).digest("base64");
91
131
 
92
- // 6) HTML: replace <script> and add modulepreload for main
132
+ // Rewrite HTML: drop dev importmap, swap the <script src>, add preloads.
133
+ const urlPrefix = ASSETS_REL ? `/${ASSETS_REL}/` : "/";
93
134
  let html = await readFile(HTML, "utf8");
94
135
 
95
- // modulepreload for main + other chunks. For now preload all chunks; this can
96
- // later be filtered from metafile analysis.
136
+ html = html.replace(/<script type="importmap">[\s\S]*?<\/script>/, "");
137
+
97
138
  const preloads = allJs
98
139
  .map(
99
140
  (f) =>
100
- ` <link rel="modulepreload" href="/${f}"${
141
+ ` <link rel="modulepreload" href="${urlPrefix}${f}"${
101
142
  f === mainBundle ? ` integrity="${sri}" crossorigin="anonymous"` : ""
102
143
  } />`,
103
144
  )
104
145
  .join("\n");
105
146
 
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
- );
147
+ const scriptTag = `<script type="module" src="${urlPrefix}${mainBundle}" integrity="${sri}" crossorigin="anonymous"></script>`;
148
+ if (/<script\s+type="module"\s+src="[^"]+"[^>]*><\/script>/.test(html)) {
149
+ html = html.replace(
150
+ /<script\s+type="module"\s+src="[^"]+"[^>]*><\/script>/,
151
+ scriptTag,
152
+ );
153
+ } else {
154
+ // No matching dev <script> in the template: inject one before </body>.
155
+ html = html.replace(/<\/body>/i, ` ${scriptTag}\n </body>`);
156
+ }
114
157
 
115
- // Insert preloads before </head>.
116
- html = html.replace(
117
- /<\/head>/,
118
- `${preloads}\n </head>`,
119
- );
158
+ html = html.replace(/<\/head>/, `${preloads}\n </head>`);
120
159
 
160
+ await mkdir(OUT_DIR, { recursive: true });
121
161
  await writeFile(join(OUT_DIR, "index.html"), html);
122
162
 
123
- // 7) Static files
124
- for (const name of ["favicon.ico", "favicon.svg", "assets"]) {
125
- const src = join("examples", name);
126
- if (existsSync(src)) {
163
+ // Backwards-compatible asset copy (repo-mode only). In app-mode the
164
+ // `mado release` command copies the entire `public/` tree, which is the
165
+ // recommended path for new apps.
166
+ if (cfg.context === "repo") {
167
+ for (const name of ["favicon.ico", "favicon.svg", "assets"]) {
168
+ const src = join(cfg.projectRoot, "examples", name);
169
+ if (!existsSync(src)) continue;
127
170
  const s = await stat(src);
128
171
  if (s.isDirectory()) {
129
172
  await cp(src, join(OUT_DIR, name), { recursive: true });
@@ -133,14 +176,14 @@ for (const name of ["favicon.ico", "favicon.svg", "assets"]) {
133
176
  }
134
177
  }
135
178
 
136
- // 8) Stats
179
+ // Stats
137
180
  const kib = (n) => (n / 1024).toFixed(1);
138
181
  console.log(`[bundle] chunks: ${allJs.length}`);
139
182
  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;
183
+ const sz = (await stat(join(ASSETS_DIR, f))).size;
184
+ const gz = (await stat(join(ASSETS_DIR, `${f}.gz`))).size;
142
185
  const star = f === mainBundle ? " *" : "";
143
186
  console.log(` ${f.padEnd(24)} ${kib(sz).padStart(6)} KB raw, ${kib(gz).padStart(5)} KB gz${star}`);
144
187
  }
145
188
  console.log(`[bundle] total: ${kib(totalRaw)} KB raw / ${kib(totalGz)} KB gz / ${kib(totalBr)} KB br`);
146
- console.log(`[bundle] entry SRI: ${sri}`);
189
+ 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,8 +46,10 @@ 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
- await run(process.execPath, ["--test", "--test-timeout=10000", ...files, ...args]);
52
+ await run(process.execPath, ["--test", "--test-timeout=20000", ...files, ...args]);
44
53
  }
45
54
  break;
46
55
  case "serve":
@@ -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;
@@ -120,6 +132,7 @@ async function runInit(rawArgs) {
120
132
  await mkdir(target, { recursive: true });
121
133
  await cp(source, target, { recursive: true, force: true });
122
134
  await copyCanonicalAgentFiles(target);
135
+ await ensureStarterGitignore(target);
123
136
 
124
137
  const packageName = packageNameFromDir(target);
125
138
  if (!isValidPackageName(packageName)) {
@@ -197,6 +210,79 @@ async function runDev(example) {
197
210
  });
198
211
  }
199
212
 
213
+ async function runRelease(rawArgs) {
214
+ // Single "ship it" command. Composes the smaller steps so the user does not
215
+ // have to remember the order, and so the deploy artifact (out/) is always
216
+ // assembled the same way.
217
+ //
218
+ // mado release
219
+ // → mado typecheck
220
+ // → mado build (tsc → dist/)
221
+ // → mado bundle (esbuild → out/assets/, also copies index.html)
222
+ // → mado bake (HTML → out/baked/)
223
+ // → copy public/* → out/
224
+ //
225
+ // Flags are forwarded to bake/bundle.
226
+ const cfg = loadConfig({ projectRoot: PROJECT_ROOT });
227
+ const outDir = resolve(cfg.projectRoot, cfg.build.out ?? "out");
228
+ const publicDir = resolve(cfg.projectRoot, cfg.build.publicDir ?? "public");
229
+
230
+ console.log(`[release] context: ${cfg.context}`);
231
+ console.log(`[release] artifact: ${outDir}`);
232
+ console.log("");
233
+
234
+ console.log("[release] step 1/5 typecheck");
235
+ await runNodeBin("typescript/bin/tsc", ["--noEmit"]);
236
+
237
+ console.log("[release] step 2/5 build (tsc → dist/)");
238
+ await runNodeBin("typescript/bin/tsc", []);
239
+
240
+ console.log("[release] step 3/5 bundle (esbuild → out/assets/)");
241
+ await runNodeScript("scripts/bundle.mjs", rawArgs);
242
+
243
+ console.log("[release] step 4/5 bake (out/baked/)");
244
+ await runNodeScript("scripts/bake.mjs", rawArgs);
245
+
246
+ console.log("[release] step 5/5 copy public/ → out/");
247
+ if (existsSync(publicDir)) {
248
+ await mkdir(outDir, { recursive: true });
249
+ await cp(publicDir, outDir, { recursive: true });
250
+ console.log(`[release] copied ${publicDir} → ${outDir}`);
251
+ } else {
252
+ console.log(`[release] no ${publicDir}, skipping`);
253
+ }
254
+
255
+ // Optional CDN config files. Generated only when not already provided.
256
+ await writeIfMissing(
257
+ join(outDir, "_redirects"),
258
+ // Cloudflare Pages / Netlify: SPA fallback so deep links work after a
259
+ // hard refresh. Baked HTML files are matched first because of
260
+ // `force: false` / static-priority rules on these hosts.
261
+ "/* /index.html 200\n",
262
+ );
263
+ await writeIfMissing(
264
+ join(outDir, "_headers"),
265
+ [
266
+ "/assets/*",
267
+ " Cache-Control: public, max-age=31536000, immutable",
268
+ "",
269
+ "/*.html",
270
+ " Cache-Control: no-cache, must-revalidate",
271
+ "",
272
+ ].join("\n"),
273
+ );
274
+
275
+ console.log("");
276
+ console.log(`[release] done. Deploy artifact: ${outDir}`);
277
+ console.log("[release] try: mado preview");
278
+ }
279
+
280
+ async function writeIfMissing(path, content) {
281
+ if (existsSync(path)) return;
282
+ await writeFile(path, content);
283
+ console.log(`[release] wrote ${path}`);
284
+ }
285
+
200
286
  async function copyCanonicalAgentFiles(target) {
201
287
  for (const file of ["AGENTS.md", "llms.txt"]) {
202
288
  const source = join(PACKAGE_ROOT, file);
@@ -204,6 +290,12 @@ async function copyCanonicalAgentFiles(target) {
204
290
  }
205
291
  }
206
292
 
293
+ async function ensureStarterGitignore(target) {
294
+ const file = join(target, ".gitignore");
295
+ if (existsSync(file)) return;
296
+ await writeFile(file, "node_modules\ndist\nout\n.DS_Store\n*.log\n");
297
+ }
298
+
207
299
  async function runNodeBin(bin, args) {
208
300
  await run(process.execPath, [resolveBin(bin), ...args]);
209
301
  }
@@ -267,19 +359,38 @@ async function listTestFiles() {
267
359
  }
268
360
 
269
361
  function printHelp() {
270
- console.log(`mado commands:
271
- mado init <name> [--starter minimal|crud] [--force]
272
- mado build
273
- mado watch
274
- mado typecheck
275
- mado test [browser]
276
- mado serve [basic|tickets|showcase]
277
- mado dev [basic|tickets|showcase]
278
- mado bake
279
- mado bundle
280
- mado preview
281
- mado new <list|form|detail> <name>
282
- mado examples`);
362
+ const ctx = IS_REPO ? "repo-mode (framework repository)" : "app-mode";
363
+ console.log(`mado commands (${ctx}):
364
+
365
+ Project lifecycle:
366
+ mado init <name> [--starter minimal|crud|admin] [--force]
367
+ scaffold a new app
368
+ mado dev tsc -w + dev server with HMR
369
+ mado build tsc (writes dist/)
370
+ mado typecheck tsc --noEmit
371
+ mado test [browser] run unit tests (or browser regression)
372
+
373
+ Production:
374
+ mado bundle esbuild → out/assets/ (hashed bundles)
375
+ mado bake [--entry <file>] [--template <html>] [--out <dir>] [--base-url <url>]
376
+ prerender baked routes → out/baked/
377
+ mado release typecheck + build + bundle + bake + copy public/ → out/
378
+ ← the one command for "ship it"
379
+ mado preview serve exactly out/ locally (production rehearsal)
380
+ mado serve [example] simple static server (also runs in repo-mode for examples)
381
+
382
+ Generators:
383
+ mado new <list|form|detail> <name>
384
+
385
+ Misc:
386
+ mado examples list bundled examples
387
+ mado help this screen
388
+
389
+ Configuration:
390
+ mado reads ./mado.config.json (dev.proxy, build.out, bake.entry/template/baseUrl, …)
391
+ CLI flags > mado.config.json > built-in defaults.
392
+
393
+ See MADO_V1_PLAN.md for the road to v1.`);
283
394
  }
284
395
 
285
396
  function parseFlags(raw) {
@@ -1,28 +1,38 @@
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
+ const cfg = loadConfig({});
28
+ const ROOT = cfg.projectRoot;
29
+ const OUT = resolve(
30
+ ROOT,
31
+ process.env.OUT_DIR ?? cfg.build.out ?? "out",
32
+ );
33
+ const PORT = Number(process.env.PORT ?? cfg.dev?.port ?? 4173);
34
+ const AUTOBUILD = process.env.PREVIEW_AUTOBUILD === "1";
35
+ const SKIP_BUILD = process.env.SKIP_BUILD === "1" || !AUTOBUILD;
26
36
 
27
37
  const MIME = {
28
38
  ".html": "text/html; charset=utf-8",