@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.
- package/AGENTS.md +26 -0
- package/CHANGELOG.md +153 -0
- package/MADO_V1_PLAN.md +179 -0
- package/README.md +31 -13
- package/ROADMAP.md +28 -7
- package/TODO.md +72 -0
- package/dist/src/forms.d.ts +37 -4
- package/dist/src/forms.js +331 -57
- package/dist/src/forms.js.map +1 -1
- package/dist/src/html/bindings.d.ts +41 -0
- package/dist/src/html/bindings.js +163 -6
- package/dist/src/html/bindings.js.map +1 -1
- package/dist/src/html.d.ts +2 -0
- package/dist/src/html.js +1 -0
- package/dist/src/html.js.map +1 -1
- package/dist/src/index.d.ts +6 -6
- package/dist/src/index.js +2 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/page.d.ts +56 -0
- package/dist/src/page.js +17 -0
- package/dist/src/page.js.map +1 -1
- package/dist/src/router/manifest.d.ts +16 -1
- package/dist/src/router/manifest.js +181 -38
- package/dist/src/router/manifest.js.map +1 -1
- package/dist/src/router/match.d.ts +7 -2
- package/dist/src/router/match.js +14 -4
- package/dist/src/router/match.js.map +1 -1
- package/dist/src/router/navigation.d.ts +10 -0
- package/dist/src/router/navigation.js +71 -3
- package/dist/src/router/navigation.js.map +1 -1
- package/dist/src/signal.d.ts +15 -1
- package/dist/src/signal.js +112 -16
- package/dist/src/signal.js.map +1 -1
- package/docs/en/02-project-layout.md +99 -40
- package/docs/en/10-app-architecture.md +141 -0
- package/docs/en/11-layouts.md +115 -0
- package/docs/en/12-auth-and-api.md +217 -0
- package/docs/en/13-deployment.md +192 -0
- package/docs/en/14-testing.md +82 -0
- package/docs/en/15-error-handling.md +100 -0
- package/docs/en/16-bake-cookbook.md +93 -0
- package/docs/en/README.md +7 -0
- package/docs/fr/10-app-architecture.md +61 -0
- package/docs/fr/11-layouts.md +35 -0
- package/docs/fr/12-auth-and-api.md +35 -0
- package/docs/fr/13-deployment.md +39 -0
- package/docs/fr/14-testing.md +41 -0
- package/docs/fr/15-error-handling.md +50 -0
- package/docs/fr/16-bake-cookbook.md +35 -0
- package/docs/fr/README.md +7 -0
- package/docs/ru/10-app-architecture.md +100 -0
- package/docs/ru/11-layouts.md +47 -0
- package/docs/ru/12-auth-and-api.md +53 -0
- package/docs/ru/13-deployment.md +60 -0
- package/docs/ru/14-testing.md +50 -0
- package/docs/ru/15-error-handling.md +56 -0
- package/docs/ru/16-bake-cookbook.md +55 -0
- package/docs/ru/README.md +7 -0
- package/docs/uk/10-app-architecture.md +56 -0
- package/docs/uk/11-layouts.md +34 -0
- package/docs/uk/12-auth-and-api.md +34 -0
- package/docs/uk/13-deployment.md +39 -0
- package/docs/uk/14-testing.md +34 -0
- package/docs/uk/15-error-handling.md +32 -0
- package/docs/uk/16-bake-cookbook.md +36 -0
- package/docs/uk/README.md +7 -0
- package/llms.txt +9 -1
- package/package.json +3 -1
- package/scripts/_config.mjs +224 -0
- package/scripts/bake.mjs +217 -120
- package/scripts/bundle.mjs +110 -67
- package/scripts/cli.mjs +119 -15
- package/scripts/preview.mjs +22 -12
- package/server/serve.mjs +82 -4
- package/starters/admin/README.md +63 -0
- package/starters/admin/index.html +21 -0
- package/starters/admin/mado.config.json +22 -0
- package/starters/admin/package.json +22 -0
- package/starters/admin/public/favicon.svg +4 -0
- package/starters/admin/src/components/x-button.ts +55 -0
- package/starters/admin/src/components/x-input.ts +74 -0
- package/starters/admin/src/layouts/app.ts +101 -0
- package/starters/admin/src/layouts/auth.ts +41 -0
- package/starters/admin/src/lib/api.ts +133 -0
- package/starters/admin/src/lib/auth.ts +83 -0
- package/starters/admin/src/main.ts +15 -0
- package/starters/admin/src/pages/admin/dashboard.ts +48 -0
- package/starters/admin/src/pages/admin/order-detail.ts +78 -0
- package/starters/admin/src/pages/admin/orders.ts +117 -0
- package/starters/admin/src/pages/home.ts +25 -0
- package/starters/admin/src/pages/login.ts +70 -0
- package/starters/admin/src/pages/not-found.ts +12 -0
- package/starters/admin/src/routes.ts +40 -0
- package/starters/admin/src/styles/global.ts +86 -0
- package/starters/admin/tsconfig.json +15 -0
- package/starters/crud/mado.config.json +20 -0
- package/starters/crud/package.json +8 -4
- package/starters/crud/src/routes.ts +4 -2
- package/starters/minimal/mado.config.json +20 -0
- package/starters/minimal/package.json +7 -3
- package/starters/minimal/src/routes.ts +4 -2
package/scripts/bundle.mjs
CHANGED
|
@@ -1,40 +1,86 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Production bundle through esbuild. No build config files.
|
|
2
2
|
//
|
|
3
3
|
// Usage:
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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 →
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
36
|
+
const { flags } = parseFlags(process.argv.slice(2));
|
|
37
|
+
const cfg = loadConfig({});
|
|
34
38
|
|
|
35
|
-
|
|
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:
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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(
|
|
107
|
+
const mainBundle = basename(entryOutput[0]);
|
|
63
108
|
|
|
64
|
-
//
|
|
65
|
-
const allJs = (await readdir(
|
|
109
|
+
// Collect all js chunks in the assets dir.
|
|
110
|
+
const allJs = (await readdir(ASSETS_DIR)).filter((f) => f.endsWith(".js"));
|
|
66
111
|
|
|
67
|
-
//
|
|
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(
|
|
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
|
-
//
|
|
89
|
-
const mainBuf = await readFile(join(
|
|
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
|
-
//
|
|
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
|
-
|
|
96
|
-
|
|
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="
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
//
|
|
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(
|
|
141
|
-
const gz = (await stat(join(
|
|
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
|
-
|
|
278
|
-
mado
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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) {
|
package/scripts/preview.mjs
CHANGED
|
@@ -1,28 +1,38 @@
|
|
|
1
|
-
// Preview: a tiny production-like server that
|
|
1
|
+
// Preview: a tiny production-like server that serves exactly out/ on node:http.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// mado preview
|
|
4
4
|
//
|
|
5
5
|
// What it does:
|
|
6
|
-
// 1.
|
|
7
|
-
// 2.
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
const
|
|
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 {
|
|
17
|
-
import {
|
|
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
|
-
|
|
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).
|