@madojs/mado 0.5.1 → 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 (101) hide show
  1. package/AGENTS.md +26 -0
  2. package/CHANGELOG.md +153 -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/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 +71 -3
  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/10-app-architecture.md +141 -0
  36. package/docs/en/11-layouts.md +115 -0
  37. package/docs/en/12-auth-and-api.md +217 -0
  38. package/docs/en/13-deployment.md +192 -0
  39. package/docs/en/14-testing.md +82 -0
  40. package/docs/en/15-error-handling.md +100 -0
  41. package/docs/en/16-bake-cookbook.md +93 -0
  42. package/docs/en/README.md +7 -0
  43. package/docs/fr/10-app-architecture.md +61 -0
  44. package/docs/fr/11-layouts.md +35 -0
  45. package/docs/fr/12-auth-and-api.md +35 -0
  46. package/docs/fr/13-deployment.md +39 -0
  47. package/docs/fr/14-testing.md +41 -0
  48. package/docs/fr/15-error-handling.md +50 -0
  49. package/docs/fr/16-bake-cookbook.md +35 -0
  50. package/docs/fr/README.md +7 -0
  51. package/docs/ru/10-app-architecture.md +100 -0
  52. package/docs/ru/11-layouts.md +47 -0
  53. package/docs/ru/12-auth-and-api.md +53 -0
  54. package/docs/ru/13-deployment.md +60 -0
  55. package/docs/ru/14-testing.md +50 -0
  56. package/docs/ru/15-error-handling.md +56 -0
  57. package/docs/ru/16-bake-cookbook.md +55 -0
  58. package/docs/ru/README.md +7 -0
  59. package/docs/uk/10-app-architecture.md +56 -0
  60. package/docs/uk/11-layouts.md +34 -0
  61. package/docs/uk/12-auth-and-api.md +34 -0
  62. package/docs/uk/13-deployment.md +39 -0
  63. package/docs/uk/14-testing.md +34 -0
  64. package/docs/uk/15-error-handling.md +32 -0
  65. package/docs/uk/16-bake-cookbook.md +36 -0
  66. package/docs/uk/README.md +7 -0
  67. package/llms.txt +9 -1
  68. package/package.json +3 -1
  69. package/scripts/_config.mjs +224 -0
  70. package/scripts/bake.mjs +217 -120
  71. package/scripts/bundle.mjs +110 -67
  72. package/scripts/cli.mjs +119 -15
  73. package/scripts/preview.mjs +22 -12
  74. package/server/serve.mjs +82 -4
  75. package/starters/admin/README.md +63 -0
  76. package/starters/admin/index.html +21 -0
  77. package/starters/admin/mado.config.json +22 -0
  78. package/starters/admin/package.json +22 -0
  79. package/starters/admin/public/favicon.svg +4 -0
  80. package/starters/admin/src/components/x-button.ts +55 -0
  81. package/starters/admin/src/components/x-input.ts +74 -0
  82. package/starters/admin/src/layouts/app.ts +101 -0
  83. package/starters/admin/src/layouts/auth.ts +41 -0
  84. package/starters/admin/src/lib/api.ts +133 -0
  85. package/starters/admin/src/lib/auth.ts +83 -0
  86. package/starters/admin/src/main.ts +15 -0
  87. package/starters/admin/src/pages/admin/dashboard.ts +48 -0
  88. package/starters/admin/src/pages/admin/order-detail.ts +78 -0
  89. package/starters/admin/src/pages/admin/orders.ts +117 -0
  90. package/starters/admin/src/pages/home.ts +25 -0
  91. package/starters/admin/src/pages/login.ts +70 -0
  92. package/starters/admin/src/pages/not-found.ts +12 -0
  93. package/starters/admin/src/routes.ts +40 -0
  94. package/starters/admin/src/styles/global.ts +86 -0
  95. package/starters/admin/tsconfig.json +15 -0
  96. package/starters/crud/mado.config.json +20 -0
  97. package/starters/crud/package.json +8 -4
  98. package/starters/crud/src/routes.ts +4 -2
  99. package/starters/minimal/mado.config.json +20 -0
  100. package/starters/minimal/package.json +7 -3
  101. 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=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 } 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({});
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,6 +46,8 @@ 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
  }
@@ -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;
@@ -198,6 +210,79 @@ async function runDev(example) {
198
210
  });
199
211
  }
200
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
+
201
286
  async function copyCanonicalAgentFiles(target) {
202
287
  for (const file of ["AGENTS.md", "llms.txt"]) {
203
288
  const source = join(PACKAGE_ROOT, file);
@@ -274,19 +359,38 @@ async function listTestFiles() {
274
359
  }
275
360
 
276
361
  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`);
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.`);
290
394
  }
291
395
 
292
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",
package/server/serve.mjs CHANGED
@@ -12,14 +12,30 @@
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
+
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
+ const PORT = Number(process.env.PORT ?? CONFIG.dev?.port ?? 5173);
23
39
  const HMR = process.env.NO_HMR !== "1";
24
40
 
25
41
  const EXAMPLE = process.argv[2] ?? process.env.MADO_EXAMPLE ?? process.env.EXAMPLE ?? "";
@@ -118,6 +134,16 @@ const server = createServer(async (req, res) => {
118
134
  const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
119
135
  pathname = decodeURIComponent(url.pathname);
120
136
 
137
+ // Dev proxy: forward matching prefixes to an upstream backend, so the
138
+ // browser can reach the SPA and the API on a single origin without CORS.
139
+ const proxyRule = PROXY_RULES.find(([prefix]) => pathname.startsWith(prefix));
140
+ if (proxyRule) {
141
+ const [prefix, upstream] = proxyRule;
142
+ await proxyForward({ req, res, prefix, upstream, pathname, search: url.search });
143
+ reason = `proxy → ${upstream}`;
144
+ return;
145
+ }
146
+
121
147
  // SSE endpoint for HMR.
122
148
  if (pathname === "/__hmr") {
123
149
  res.writeHead(200, {
@@ -279,6 +305,52 @@ server.on("error", (err) => {
279
305
  process.exit(1);
280
306
  });
281
307
 
308
+ async function proxyForward({ req, res, prefix, upstream, pathname, search }) {
309
+ // Strip the prefix only if the upstream URL itself ends with `/`; otherwise
310
+ // forward the full pathname so the backend sees /api/...
311
+ let upstreamUrl;
312
+ try {
313
+ upstreamUrl = new URL(upstream);
314
+ } catch {
315
+ res.writeHead(502).end(`bad upstream: ${upstream}`);
316
+ return;
317
+ }
318
+ const target = new URL(upstream);
319
+ // Compose path: <upstream.pathname rstrip "/"> + <pathname> + <search>
320
+ const tail = pathname; // keep the original /api/... so backends route normally
321
+ target.pathname = (target.pathname.replace(/\/$/, "")) + tail;
322
+ target.search = search;
323
+
324
+ const lib = target.protocol === "https:" ? httpsRequest : httpRequest;
325
+ const upstreamReq = lib(
326
+ target,
327
+ {
328
+ method: req.method,
329
+ headers: {
330
+ ...req.headers,
331
+ host: target.host,
332
+ },
333
+ },
334
+ (upstreamRes) => {
335
+ // Forward status and headers, then pipe the body.
336
+ res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers);
337
+ upstreamRes.pipe(res);
338
+ },
339
+ );
340
+ upstreamReq.on("error", (err) => {
341
+ console.error(`[serve] proxy error for ${pathname} → ${target.href}:`, err.message);
342
+ if (!res.headersSent) {
343
+ res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
344
+ res.end(`proxy upstream unavailable: ${target.host}\n${err.message}`);
345
+ } else {
346
+ res.end();
347
+ }
348
+ });
349
+ req.pipe(upstreamReq);
350
+ // Reference unused arg so lint is happy.
351
+ void prefix;
352
+ }
353
+
282
354
  server.listen(PORT, () => {
283
355
  const distReady = existsSync(join(ROOT, "dist/src/index.js"))
284
356
  || existsSync(join(ROOT, "dist/main.js"));
@@ -295,6 +367,12 @@ server.listen(PORT, () => {
295
367
  console.log(` hmr: ${HMR ? "on" : "off"}`);
296
368
  console.log(` preload: ${PRELOAD}`);
297
369
  console.log(` dist: ${distReady ? "ready" : "missing (run mado build)"}`);
370
+ if (PROXY_RULES.length > 0) {
371
+ console.log(" proxy:");
372
+ for (const [prefix, upstream] of PROXY_RULES) {
373
+ console.log(` ${prefix.padEnd(10)} → ${upstream}`);
374
+ }
375
+ }
298
376
  if (!EXAMPLE && existsSync(EXAMPLES_INDEX)) {
299
377
  console.log(" try: mado serve basic");
300
378
  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).