@madojs/mado 0.5.1 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +26 -0
- package/CHANGELOG.md +265 -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/resource.js +11 -0
- package/dist/src/resource.js.map +1 -1
- package/dist/src/router/manifest.d.ts +16 -1
- package/dist/src/router/manifest.js +210 -40
- 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 +266 -121
- package/scripts/bundle.mjs +133 -67
- package/scripts/cli.mjs +195 -27
- package/scripts/preview.mjs +125 -21
- package/server/serve.mjs +161 -10
- package/starters/admin/README.md +63 -0
- package/starters/admin/index.html +28 -0
- package/starters/admin/mado.config.json +22 -0
- package/starters/admin/package.json +24 -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 +80 -0
- package/starters/admin/src/pages/admin/orders.ts +117 -0
- package/starters/admin/src/pages/home.ts +34 -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/index.html +12 -4
- package/starters/crud/mado.config.json +20 -0
- package/starters/crud/package.json +9 -3
- package/starters/crud/src/pages/home.ts +16 -0
- package/starters/crud/src/routes.ts +4 -2
- package/starters/minimal/index.html +12 -4
- package/starters/minimal/mado.config.json +20 -0
- package/starters/minimal/package.json +9 -3
- package/starters/minimal/src/pages/home.ts +17 -0
- package/starters/minimal/src/routes.ts +4 -2
package/scripts/preview.mjs
CHANGED
|
@@ -1,28 +1,74 @@
|
|
|
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
|
-
|
|
25
|
-
|
|
25
|
+
import { loadConfig } from "./_config.mjs";
|
|
26
|
+
|
|
27
|
+
// Tiny argv parser. Supports --flag, --flag=value, --flag value.
|
|
28
|
+
function parsePreviewArgs(argv) {
|
|
29
|
+
const flags = {};
|
|
30
|
+
for (let i = 0; i < argv.length; i++) {
|
|
31
|
+
const a = argv[i];
|
|
32
|
+
if (a === "--") continue;
|
|
33
|
+
if (a.startsWith("--")) {
|
|
34
|
+
const eq = a.indexOf("=");
|
|
35
|
+
if (eq >= 0) {
|
|
36
|
+
flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
37
|
+
} else {
|
|
38
|
+
const name = a.slice(2);
|
|
39
|
+
const next = argv[i + 1];
|
|
40
|
+
if (next !== undefined && !next.startsWith("-")) {
|
|
41
|
+
flags[name] = next;
|
|
42
|
+
i++;
|
|
43
|
+
} else {
|
|
44
|
+
flags[name] = true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return flags;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const PREVIEW_FLAGS = parsePreviewArgs(process.argv.slice(2));
|
|
53
|
+
|
|
54
|
+
const cfg = loadConfig({});
|
|
55
|
+
const ROOT = cfg.projectRoot;
|
|
56
|
+
const OUT = resolve(
|
|
57
|
+
ROOT,
|
|
58
|
+
process.env.OUT_DIR ?? cfg.build.out ?? "out",
|
|
59
|
+
);
|
|
60
|
+
// Baked HTML lives in <out>/baked/ by default (see scripts/bake.mjs and
|
|
61
|
+
// mado.config.json bake.outDir). Preview serves it with priority over the
|
|
62
|
+
// SPA shell so URLs that have a prerendered HTML page render real markup
|
|
63
|
+
// instead of an empty <div id="app"></div>.
|
|
64
|
+
const BAKED = resolve(
|
|
65
|
+
ROOT,
|
|
66
|
+
process.env.BAKED_DIR ?? cfg.bake?.outDir ?? join(cfg.build.out ?? "out", "baked"),
|
|
67
|
+
);
|
|
68
|
+
const PORT = Number(PREVIEW_FLAGS.port ?? process.env.PORT ?? cfg.dev?.port ?? 4173);
|
|
69
|
+
const HOST = String(PREVIEW_FLAGS.host ?? process.env.HOST ?? cfg.dev?.host ?? "localhost");
|
|
70
|
+
const AUTOBUILD = process.env.PREVIEW_AUTOBUILD === "1";
|
|
71
|
+
const SKIP_BUILD = process.env.SKIP_BUILD === "1" || !AUTOBUILD;
|
|
26
72
|
|
|
27
73
|
const MIME = {
|
|
28
74
|
".html": "text/html; charset=utf-8",
|
|
@@ -56,7 +102,18 @@ if (!SKIP_BUILD) {
|
|
|
56
102
|
}
|
|
57
103
|
|
|
58
104
|
if (!(await exists(OUT))) {
|
|
59
|
-
console.error(
|
|
105
|
+
console.error(
|
|
106
|
+
`[preview] missing ${OUT}/ — run \`mado release\` (or \`mado bundle\`) first.`,
|
|
107
|
+
);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const spaShell = join(OUT, "index.html");
|
|
112
|
+
if (!(await exists(spaShell))) {
|
|
113
|
+
console.error(
|
|
114
|
+
`[preview] missing ${spaShell} — \`mado bundle\` did not produce an HTML entry.\n` +
|
|
115
|
+
`[preview] Without it any non-baked route will 404 instead of falling back to the SPA.`,
|
|
116
|
+
);
|
|
60
117
|
process.exit(1);
|
|
61
118
|
}
|
|
62
119
|
|
|
@@ -104,8 +161,31 @@ const server = createServer(async (req, res) => {
|
|
|
104
161
|
}
|
|
105
162
|
});
|
|
106
163
|
|
|
107
|
-
server.
|
|
108
|
-
|
|
164
|
+
server.on("error", (err) => {
|
|
165
|
+
if (err.code === "EPERM" || err.code === "EACCES") {
|
|
166
|
+
console.error(
|
|
167
|
+
`[preview] failed to bind ${HOST}:${PORT}: ${err.message}\n` +
|
|
168
|
+
`[preview] tip: this sandbox may disallow binding "${HOST}".\n` +
|
|
169
|
+
`[preview] try: mado preview --host 127.0.0.1`,
|
|
170
|
+
);
|
|
171
|
+
} else {
|
|
172
|
+
console.error(
|
|
173
|
+
`[preview] failed to listen on ${HOST}:${PORT}: ${err.message}`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
process.exit(1);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
server.listen(PORT, HOST, async () => {
|
|
180
|
+
const urlHost = HOST === "0.0.0.0" || HOST === "::" ? "localhost" : HOST;
|
|
181
|
+
const bakedReady = await exists(BAKED);
|
|
182
|
+
console.log("");
|
|
183
|
+
console.log("Mado preview (production-like)");
|
|
184
|
+
console.log(` url: http://${urlHost}:${PORT}/`);
|
|
185
|
+
console.log(` out: ${OUT}`);
|
|
186
|
+
console.log(` baked: ${bakedReady ? BAKED : "(none — SPA-only)"}`);
|
|
187
|
+
console.log(" (Ctrl-C to stop)");
|
|
188
|
+
console.log("");
|
|
109
189
|
});
|
|
110
190
|
|
|
111
191
|
// ---------- helpers ----------
|
|
@@ -135,14 +215,33 @@ function basenameSafe(p) {
|
|
|
135
215
|
async function resolveTarget(pathname) {
|
|
136
216
|
if (pathname === "/") pathname = "/index.html";
|
|
137
217
|
|
|
218
|
+
// 1) Baked HTML wins. `mado bake` writes prerendered pages into
|
|
219
|
+
// <out>/baked/<path>/index.html. Serve them with priority over the
|
|
220
|
+
// SPA shell so search engines AND human users hitting a prerendered
|
|
221
|
+
// URL see real content immediately. Without this branch preview
|
|
222
|
+
// served the empty SPA shell for every URL, which looked like a
|
|
223
|
+
// "blank page" bug even when bake had succeeded.
|
|
224
|
+
if (await exists(BAKED)) {
|
|
225
|
+
if (!extname(pathname) || pathname.endsWith("/index.html")) {
|
|
226
|
+
const bakedDir = join(BAKED, pathname.replace(/\/index\.html$/, ""));
|
|
227
|
+
const bakedIdx = join(bakedDir, "index.html");
|
|
228
|
+
if (await exists(bakedIdx)) return bakedIdx;
|
|
229
|
+
}
|
|
230
|
+
// Direct file (sitemap.xml etc.) from the baked dir.
|
|
231
|
+
const bakedFile = resolve(join(BAKED, pathname));
|
|
232
|
+
if (bakedFile.startsWith(BAKED + sep) && (await exists(bakedFile))) {
|
|
233
|
+
const s = await stat(bakedFile);
|
|
234
|
+
if (!s.isDirectory()) return bakedFile;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
138
238
|
const candidate = resolve(join(OUT, pathname));
|
|
139
239
|
if (!candidate.startsWith(OUT + sep) && candidate !== OUT) return null;
|
|
140
240
|
|
|
141
|
-
//
|
|
241
|
+
// 2) Exact match inside out/.
|
|
142
242
|
if (await exists(candidate)) {
|
|
143
243
|
const s = await stat(candidate);
|
|
144
244
|
if (s.isDirectory()) {
|
|
145
|
-
// Baked priority: /product/foo/ → /product/foo/index.html
|
|
146
245
|
const idx = join(candidate, "index.html");
|
|
147
246
|
if (await exists(idx)) return idx;
|
|
148
247
|
} else {
|
|
@@ -150,15 +249,20 @@ async function resolveTarget(pathname) {
|
|
|
150
249
|
}
|
|
151
250
|
}
|
|
152
251
|
|
|
153
|
-
//
|
|
252
|
+
// 3) /foo → /foo/index.html (for sub-folders without trailing slash).
|
|
154
253
|
if (!extname(pathname)) {
|
|
155
254
|
const asDir = join(OUT, pathname, "index.html");
|
|
156
255
|
if (await exists(asDir)) return asDir;
|
|
157
256
|
}
|
|
158
257
|
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
258
|
+
// 4) SPA-fallback: any non-asset path falls back to the SPA shell so
|
|
259
|
+
// client-side routing handles it. Asset-looking paths (with an
|
|
260
|
+
// extension) deliberately 404 instead — otherwise a 200 on
|
|
261
|
+
// /missing.png would mask real bugs.
|
|
262
|
+
if (!extname(pathname)) {
|
|
263
|
+
const spa = join(OUT, "index.html");
|
|
264
|
+
if (await exists(spa)) return spa;
|
|
265
|
+
}
|
|
162
266
|
|
|
163
267
|
return null;
|
|
164
268
|
}
|
package/server/serve.mjs
CHANGED
|
@@ -12,17 +12,72 @@
|
|
|
12
12
|
// examples/<EXAMPLE>/index.html so the client router works from root, just
|
|
13
13
|
// like a production SPA deploy.
|
|
14
14
|
|
|
15
|
-
import { createServer } from "node:http";
|
|
16
|
-
import {
|
|
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
|
-
const PORT = Number(process.env.PORT ?? 5173);
|
|
23
|
-
const HMR = process.env.NO_HMR !== "1";
|
|
24
23
|
|
|
25
|
-
|
|
24
|
+
// Optional mado.config.json — used for dev.proxy and dev.port. Read with a
|
|
25
|
+
// hand-rolled JSON parse to avoid a circular dep with scripts/_config.mjs
|
|
26
|
+
// (this server is launched from cli.mjs and runs in its own Node process).
|
|
27
|
+
const CONFIG = (() => {
|
|
28
|
+
try {
|
|
29
|
+
const file = join(ROOT, "mado.config.json");
|
|
30
|
+
if (!existsSync(file)) return {};
|
|
31
|
+
return JSON.parse(readFileSync(file, "utf8")) ?? {};
|
|
32
|
+
} catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
})();
|
|
36
|
+
const PROXY_RULES = Object.entries(CONFIG.dev?.proxy ?? {}); // [["/api", "http://localhost:3000"], ...]
|
|
37
|
+
|
|
38
|
+
// Tiny argv parser. Supports --flag, --flag=value, --flag value. The first
|
|
39
|
+
// non-flag positional is the EXAMPLE name (legacy behavior). Anything that
|
|
40
|
+
// looks like a flag is consumed before we pick the positional, so calls like
|
|
41
|
+
// `mado dev -- --host 127.0.0.1` work as documented.
|
|
42
|
+
function parseArgs(argv) {
|
|
43
|
+
const flags = {};
|
|
44
|
+
const positional = [];
|
|
45
|
+
for (let i = 0; i < argv.length; i++) {
|
|
46
|
+
const a = argv[i];
|
|
47
|
+
if (a === "--") continue;
|
|
48
|
+
if (a.startsWith("--")) {
|
|
49
|
+
const eq = a.indexOf("=");
|
|
50
|
+
if (eq >= 0) {
|
|
51
|
+
flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
52
|
+
} else {
|
|
53
|
+
const name = a.slice(2);
|
|
54
|
+
const next = argv[i + 1];
|
|
55
|
+
if (next !== undefined && !next.startsWith("-")) {
|
|
56
|
+
flags[name] = next;
|
|
57
|
+
i++;
|
|
58
|
+
} else {
|
|
59
|
+
flags[name] = true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
positional.push(a);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { flags, positional };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const { flags: CLI_FLAGS, positional: CLI_POSITIONAL } = parseArgs(
|
|
70
|
+
process.argv.slice(2),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const PORT = Number(CLI_FLAGS.port ?? process.env.PORT ?? CONFIG.dev?.port ?? 5173);
|
|
74
|
+
// HOST is opt-in. Default to "localhost" (loopback) which is friendlier in
|
|
75
|
+
// sandboxes that disallow binding 0.0.0.0 (EPERM on listen). Users can opt
|
|
76
|
+
// into LAN exposure with `mado dev --host 0.0.0.0` or HOST=0.0.0.0.
|
|
77
|
+
const HOST = String(CLI_FLAGS.host ?? process.env.HOST ?? CONFIG.dev?.host ?? "localhost");
|
|
78
|
+
const HMR = CLI_FLAGS.hmr !== false && process.env.NO_HMR !== "1";
|
|
79
|
+
|
|
80
|
+
const EXAMPLE = CLI_POSITIONAL[0] ?? process.env.MADO_EXAMPLE ?? process.env.EXAMPLE ?? "";
|
|
26
81
|
const EXAMPLE_DIR = EXAMPLE
|
|
27
82
|
? resolve(join(ROOT, "examples", EXAMPLE))
|
|
28
83
|
: "";
|
|
@@ -118,6 +173,16 @@ const server = createServer(async (req, res) => {
|
|
|
118
173
|
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
119
174
|
pathname = decodeURIComponent(url.pathname);
|
|
120
175
|
|
|
176
|
+
// Dev proxy: forward matching prefixes to an upstream backend, so the
|
|
177
|
+
// browser can reach the SPA and the API on a single origin without CORS.
|
|
178
|
+
const proxyRule = PROXY_RULES.find(([prefix]) => pathname.startsWith(prefix));
|
|
179
|
+
if (proxyRule) {
|
|
180
|
+
const [prefix, upstream] = proxyRule;
|
|
181
|
+
await proxyForward({ req, res, prefix, upstream, pathname, search: url.search });
|
|
182
|
+
reason = `proxy → ${upstream}`;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
121
186
|
// SSE endpoint for HMR.
|
|
122
187
|
if (pathname === "/__hmr") {
|
|
123
188
|
res.writeHead(200, {
|
|
@@ -159,7 +224,30 @@ const server = createServer(async (req, res) => {
|
|
|
159
224
|
const s = await stat(target);
|
|
160
225
|
if (s.isDirectory()) target = join(target, "index.html");
|
|
161
226
|
} catch {
|
|
162
|
-
|
|
227
|
+
// Public assets: in production `mado release` copies public/* into out/.
|
|
228
|
+
// In dev we surface them from public/ directly so favicon.svg, robots.txt,
|
|
229
|
+
// og-image.png, etc. don't 404 (and so dev/prod behavior matches).
|
|
230
|
+
const publicCandidate = resolve(join(ROOT, "public", pathname));
|
|
231
|
+
if (
|
|
232
|
+
publicCandidate.startsWith(resolve(join(ROOT, "public")) + sep) &&
|
|
233
|
+
existsSync(publicCandidate)
|
|
234
|
+
) {
|
|
235
|
+
try {
|
|
236
|
+
const ps = await stat(publicCandidate);
|
|
237
|
+
if (!ps.isDirectory()) {
|
|
238
|
+
target = publicCandidate;
|
|
239
|
+
reason = "public/";
|
|
240
|
+
} else if (!extname(pathname)) {
|
|
241
|
+
target = fallbackIndex;
|
|
242
|
+
} else {
|
|
243
|
+
reason = "file not found";
|
|
244
|
+
res.writeHead(404).end("not found");
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
target = fallbackIndex;
|
|
249
|
+
}
|
|
250
|
+
} else if (!extname(pathname)) {
|
|
163
251
|
target = fallbackIndex;
|
|
164
252
|
} else {
|
|
165
253
|
reason = "file not found";
|
|
@@ -275,11 +363,65 @@ async function buildPreloadHints() {
|
|
|
275
363
|
}
|
|
276
364
|
|
|
277
365
|
server.on("error", (err) => {
|
|
278
|
-
|
|
366
|
+
if (err.code === "EPERM" || err.code === "EACCES") {
|
|
367
|
+
console.error(
|
|
368
|
+
`[serve] failed to bind ${HOST}:${PORT}: ${err.message}\n` +
|
|
369
|
+
`[serve] tip: this sandbox may disallow binding "${HOST}".\n` +
|
|
370
|
+
`[serve] try: mado dev --host 127.0.0.1 (or HOST=127.0.0.1)`,
|
|
371
|
+
);
|
|
372
|
+
} else {
|
|
373
|
+
console.error(`[serve] failed to listen on ${HOST}:${PORT}: ${err.message}`);
|
|
374
|
+
}
|
|
279
375
|
process.exit(1);
|
|
280
376
|
});
|
|
281
377
|
|
|
282
|
-
|
|
378
|
+
async function proxyForward({ req, res, prefix, upstream, pathname, search }) {
|
|
379
|
+
// Strip the prefix only if the upstream URL itself ends with `/`; otherwise
|
|
380
|
+
// forward the full pathname so the backend sees /api/...
|
|
381
|
+
let upstreamUrl;
|
|
382
|
+
try {
|
|
383
|
+
upstreamUrl = new URL(upstream);
|
|
384
|
+
} catch {
|
|
385
|
+
res.writeHead(502).end(`bad upstream: ${upstream}`);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const target = new URL(upstream);
|
|
389
|
+
// Compose path: <upstream.pathname rstrip "/"> + <pathname> + <search>
|
|
390
|
+
const tail = pathname; // keep the original /api/... so backends route normally
|
|
391
|
+
target.pathname = (target.pathname.replace(/\/$/, "")) + tail;
|
|
392
|
+
target.search = search;
|
|
393
|
+
|
|
394
|
+
const lib = target.protocol === "https:" ? httpsRequest : httpRequest;
|
|
395
|
+
const upstreamReq = lib(
|
|
396
|
+
target,
|
|
397
|
+
{
|
|
398
|
+
method: req.method,
|
|
399
|
+
headers: {
|
|
400
|
+
...req.headers,
|
|
401
|
+
host: target.host,
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
(upstreamRes) => {
|
|
405
|
+
// Forward status and headers, then pipe the body.
|
|
406
|
+
res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers);
|
|
407
|
+
upstreamRes.pipe(res);
|
|
408
|
+
},
|
|
409
|
+
);
|
|
410
|
+
upstreamReq.on("error", (err) => {
|
|
411
|
+
console.error(`[serve] proxy error for ${pathname} → ${target.href}:`, err.message);
|
|
412
|
+
if (!res.headersSent) {
|
|
413
|
+
res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
|
|
414
|
+
res.end(`proxy upstream unavailable: ${target.host}\n${err.message}`);
|
|
415
|
+
} else {
|
|
416
|
+
res.end();
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
req.pipe(upstreamReq);
|
|
420
|
+
// Reference unused arg so lint is happy.
|
|
421
|
+
void prefix;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
server.listen(PORT, HOST, () => {
|
|
283
425
|
const distReady = existsSync(join(ROOT, "dist/src/index.js"))
|
|
284
426
|
|| existsSync(join(ROOT, "dist/main.js"));
|
|
285
427
|
const mount = EXAMPLE
|
|
@@ -287,14 +429,23 @@ server.listen(PORT, () => {
|
|
|
287
429
|
: existsSync(EXAMPLES_INDEX)
|
|
288
430
|
? "examples/index.html landing"
|
|
289
431
|
: "index.html app";
|
|
432
|
+
// Show "localhost" in the URL when bound to 0.0.0.0 — easier to click.
|
|
433
|
+
const urlHost = HOST === "0.0.0.0" || HOST === "::" ? "localhost" : HOST;
|
|
290
434
|
console.log("");
|
|
291
435
|
console.log("Mado dev server");
|
|
292
|
-
console.log(` url: http
|
|
436
|
+
console.log(` url: http://${urlHost}:${PORT}/`);
|
|
437
|
+
console.log(` host: ${HOST}`);
|
|
293
438
|
console.log(` root: ${ROOT}`);
|
|
294
439
|
console.log(` mount: ${mount}`);
|
|
295
440
|
console.log(` hmr: ${HMR ? "on" : "off"}`);
|
|
296
441
|
console.log(` preload: ${PRELOAD}`);
|
|
297
442
|
console.log(` dist: ${distReady ? "ready" : "missing (run mado build)"}`);
|
|
443
|
+
if (PROXY_RULES.length > 0) {
|
|
444
|
+
console.log(" proxy:");
|
|
445
|
+
for (const [prefix, upstream] of PROXY_RULES) {
|
|
446
|
+
console.log(` ${prefix.padEnd(10)} → ${upstream}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
298
449
|
if (!EXAMPLE && existsSync(EXAMPLES_INDEX)) {
|
|
299
450
|
console.log(" try: mado serve basic");
|
|
300
451
|
console.log(" mado serve showcase");
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# __APP_NAME__
|
|
2
|
+
|
|
3
|
+
A starter Mado admin app: nested routes, a guarded admin shell, a blessed API
|
|
4
|
+
client, and a one-shot release pipeline.
|
|
5
|
+
|
|
6
|
+
## What you get
|
|
7
|
+
|
|
8
|
+
- `src/main.ts` — 8 lines: mount the router into `#app`. Layouts are NOT
|
|
9
|
+
declared here, only in `src/routes.ts`.
|
|
10
|
+
- `src/routes.ts` — nested manifest with three groups:
|
|
11
|
+
- `/` → public landing (bakeable),
|
|
12
|
+
- `/login` → centered `auth` layout,
|
|
13
|
+
- `/admin` → `app` layout, **guarded** by `requireAuth`.
|
|
14
|
+
- `src/layouts/app.ts` — admin shell (top bar + sidebar + content slot).
|
|
15
|
+
- `src/layouts/auth.ts` — centered card for sign-in.
|
|
16
|
+
- `src/lib/api.ts` — `createApiClient(baseUrl)` with bearer token, 401-refresh
|
|
17
|
+
retry, JSON in/out and a typed `ApiError`.
|
|
18
|
+
- `src/lib/auth.ts` — memory-only `accessToken`, `restoreSession()` from an
|
|
19
|
+
HttpOnly refresh cookie, and the `requireAuth` guard.
|
|
20
|
+
- `src/components/` — tiny `x-button` and `x-input` Web Components.
|
|
21
|
+
- `mado.config.json` — one config file. Includes a `dev.proxy` for `/api`.
|
|
22
|
+
|
|
23
|
+
## Commands
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm run dev # tsc -w + dev server on http://localhost:5173, HMR on
|
|
27
|
+
npm run build # tsc → dist/
|
|
28
|
+
npm run typecheck # tsc --noEmit
|
|
29
|
+
npm run bundle # esbuild → out/assets/
|
|
30
|
+
npm run bake # prerender baked routes → out/baked/
|
|
31
|
+
npm run release # typecheck + build + bundle + bake + copy public/ → out/
|
|
32
|
+
npm run preview # serve out/ locally (production rehearsal)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
To deploy, run `npm run release` and upload the entire `out/` directory
|
|
36
|
+
anywhere static (nginx, Cloudflare Pages, S3, Netlify, GitHub Pages, …).
|
|
37
|
+
|
|
38
|
+
## Backend expectations
|
|
39
|
+
|
|
40
|
+
The blessed `api` client speaks JSON. The auth recipe expects:
|
|
41
|
+
|
|
42
|
+
- `POST /api/auth/login` → `{ accessToken: string }` (sets refresh cookie)
|
|
43
|
+
- `POST /api/auth/refresh` → `{ accessToken: string }` (reads refresh cookie)
|
|
44
|
+
- `POST /api/auth/logout` → 204 (clears refresh cookie)
|
|
45
|
+
|
|
46
|
+
Change `mado.config.json#dev.proxy` to point at your backend in development.
|
|
47
|
+
|
|
48
|
+
## Where things live
|
|
49
|
+
|
|
50
|
+
| What | Where |
|
|
51
|
+
|---------------------|--------------------------------------|
|
|
52
|
+
| New URL | `src/pages/*.ts` + add to `routes.ts`|
|
|
53
|
+
| New protected URL | inside the `/admin` layout block |
|
|
54
|
+
| New layout | `src/layouts/*.ts` |
|
|
55
|
+
| New reusable widget | `src/components/x-*.ts` |
|
|
56
|
+
| New API call | `src/lib/api.ts` (add a method) |
|
|
57
|
+
| New global signal | `src/lib/<name>.ts` |
|
|
58
|
+
| Static image | `public/<file>` |
|
|
59
|
+
|
|
60
|
+
See the framework docs:
|
|
61
|
+
[`docs/en/11-layouts.md`](https://github.com/madojs/mado/blob/main/docs/en/11-layouts.md),
|
|
62
|
+
[`docs/en/12-auth-and-api.md`](https://github.com/madojs/mado/blob/main/docs/en/12-auth-and-api.md),
|
|
63
|
+
[`docs/en/13-deployment.md`](https://github.com/madojs/mado/blob/main/docs/en/13-deployment.md).
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>__APP_NAME__</title>
|
|
7
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
8
|
+
<!--
|
|
9
|
+
Paths below MUST be root-absolute (start with "/"), not "./...".
|
|
10
|
+
Mado is an SPA: hard-refreshing /admin/orders/42 still serves this same
|
|
11
|
+
index.html, and the browser resolves "./dist/main.js" against the
|
|
12
|
+
current URL → /admin/orders/dist/main.js → 404 → blank page.
|
|
13
|
+
Root-absolute paths always resolve to /dist/main.js regardless of route.
|
|
14
|
+
-->
|
|
15
|
+
<script type="importmap">
|
|
16
|
+
{
|
|
17
|
+
"imports": {
|
|
18
|
+
"@madojs/mado": "/node_modules/@madojs/mado/dist/src/index.js",
|
|
19
|
+
"@madojs/mado/": "/node_modules/@madojs/mado/dist/src/"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
</script>
|
|
23
|
+
</head>
|
|
24
|
+
<body>
|
|
25
|
+
<div id="app"></div>
|
|
26
|
+
<script type="module" src="/dist/main.js"></script>
|
|
27
|
+
</body>
|
|
28
|
+
</html>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"dev": {
|
|
3
|
+
"port": 5173,
|
|
4
|
+
"proxy": {
|
|
5
|
+
"/api": "http://localhost:3000"
|
|
6
|
+
}
|
|
7
|
+
},
|
|
8
|
+
"build": {
|
|
9
|
+
"out": "out",
|
|
10
|
+
"dist": "dist",
|
|
11
|
+
"publicDir": "public"
|
|
12
|
+
},
|
|
13
|
+
"bake": {
|
|
14
|
+
"entry": "src/routes.ts",
|
|
15
|
+
"template": "index.html",
|
|
16
|
+
"baseUrl": "https://example.com"
|
|
17
|
+
},
|
|
18
|
+
"bundle": {
|
|
19
|
+
"splitting": true,
|
|
20
|
+
"compress": ["gz", "br"]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__PACKAGE_NAME__",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "mado build",
|
|
8
|
+
"typecheck": "mado typecheck",
|
|
9
|
+
"dev": "mado dev",
|
|
10
|
+
"serve": "mado serve",
|
|
11
|
+
"bundle": "mado bundle",
|
|
12
|
+
"bake": "mado bake",
|
|
13
|
+
"release": "mado release",
|
|
14
|
+
"preview": "mado preview"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@madojs/mado": "__MADOJS_VERSION__"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"esbuild": "^0.24.0",
|
|
21
|
+
"linkedom": "^0.18.0",
|
|
22
|
+
"typescript": "^6.0.3"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// <x-button variant="primary|ghost|danger" ?disabled>
|
|
2
|
+
//
|
|
3
|
+
// Wraps a native <button> so it can be slotted with text/icon and styled
|
|
4
|
+
// consistently across the app. Click events bubble naturally because Shadow
|
|
5
|
+
// DOM is `mode: open` and composed: true is the default for `click`.
|
|
6
|
+
|
|
7
|
+
import { component, css, html } from "@madojs/mado";
|
|
8
|
+
|
|
9
|
+
component(
|
|
10
|
+
"x-button",
|
|
11
|
+
({ host }) => () => {
|
|
12
|
+
const variant = host.getAttribute("variant") ?? "primary";
|
|
13
|
+
const disabled = host.hasAttribute("disabled");
|
|
14
|
+
return html`
|
|
15
|
+
<button data-variant=${variant} ?disabled=${disabled}>
|
|
16
|
+
<slot></slot>
|
|
17
|
+
</button>
|
|
18
|
+
`;
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
styles: css`
|
|
22
|
+
:host { display: inline-flex; }
|
|
23
|
+
button {
|
|
24
|
+
display: inline-flex;
|
|
25
|
+
align-items: center;
|
|
26
|
+
gap: var(--space-2);
|
|
27
|
+
padding: 8px 14px;
|
|
28
|
+
border-radius: var(--radius-sm);
|
|
29
|
+
border: 1px solid transparent;
|
|
30
|
+
font: inherit;
|
|
31
|
+
cursor: pointer;
|
|
32
|
+
background: var(--accent);
|
|
33
|
+
color: var(--accent-fg);
|
|
34
|
+
transition: filter .12s ease;
|
|
35
|
+
}
|
|
36
|
+
button:hover:not(:disabled) { filter: brightness(1.07); }
|
|
37
|
+
button:active:not(:disabled) { filter: brightness(.95); }
|
|
38
|
+
button:disabled { opacity: .55; cursor: not-allowed; }
|
|
39
|
+
|
|
40
|
+
button[data-variant="ghost"] {
|
|
41
|
+
background: transparent;
|
|
42
|
+
color: var(--fg);
|
|
43
|
+
border-color: var(--border);
|
|
44
|
+
}
|
|
45
|
+
button[data-variant="ghost"]:hover:not(:disabled) {
|
|
46
|
+
background: var(--bg-elevated);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
button[data-variant="danger"] {
|
|
50
|
+
background: var(--danger);
|
|
51
|
+
color: white;
|
|
52
|
+
}
|
|
53
|
+
`,
|
|
54
|
+
},
|
|
55
|
+
);
|