@madojs/mado 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/AGENTS.md +26 -0
  2. package/CHANGELOG.md +265 -0
  3. package/MADO_V1_PLAN.md +179 -0
  4. package/README.md +31 -13
  5. package/ROADMAP.md +28 -7
  6. package/TODO.md +72 -0
  7. package/dist/src/forms.d.ts +37 -4
  8. package/dist/src/forms.js +331 -57
  9. package/dist/src/forms.js.map +1 -1
  10. package/dist/src/html/bindings.d.ts +41 -0
  11. package/dist/src/html/bindings.js +163 -6
  12. package/dist/src/html/bindings.js.map +1 -1
  13. package/dist/src/html.d.ts +2 -0
  14. package/dist/src/html.js +1 -0
  15. package/dist/src/html.js.map +1 -1
  16. package/dist/src/index.d.ts +6 -6
  17. package/dist/src/index.js +2 -2
  18. package/dist/src/index.js.map +1 -1
  19. package/dist/src/page.d.ts +56 -0
  20. package/dist/src/page.js +17 -0
  21. package/dist/src/page.js.map +1 -1
  22. package/dist/src/resource.js +11 -0
  23. package/dist/src/resource.js.map +1 -1
  24. package/dist/src/router/manifest.d.ts +16 -1
  25. package/dist/src/router/manifest.js +210 -40
  26. package/dist/src/router/manifest.js.map +1 -1
  27. package/dist/src/router/match.d.ts +7 -2
  28. package/dist/src/router/match.js +14 -4
  29. package/dist/src/router/match.js.map +1 -1
  30. package/dist/src/router/navigation.d.ts +10 -0
  31. package/dist/src/router/navigation.js +71 -3
  32. package/dist/src/router/navigation.js.map +1 -1
  33. package/dist/src/signal.d.ts +15 -1
  34. package/dist/src/signal.js +112 -16
  35. package/dist/src/signal.js.map +1 -1
  36. package/docs/en/02-project-layout.md +99 -40
  37. package/docs/en/10-app-architecture.md +141 -0
  38. package/docs/en/11-layouts.md +115 -0
  39. package/docs/en/12-auth-and-api.md +217 -0
  40. package/docs/en/13-deployment.md +192 -0
  41. package/docs/en/14-testing.md +82 -0
  42. package/docs/en/15-error-handling.md +100 -0
  43. package/docs/en/16-bake-cookbook.md +93 -0
  44. package/docs/en/README.md +7 -0
  45. package/docs/fr/10-app-architecture.md +61 -0
  46. package/docs/fr/11-layouts.md +35 -0
  47. package/docs/fr/12-auth-and-api.md +35 -0
  48. package/docs/fr/13-deployment.md +39 -0
  49. package/docs/fr/14-testing.md +41 -0
  50. package/docs/fr/15-error-handling.md +50 -0
  51. package/docs/fr/16-bake-cookbook.md +35 -0
  52. package/docs/fr/README.md +7 -0
  53. package/docs/ru/10-app-architecture.md +100 -0
  54. package/docs/ru/11-layouts.md +47 -0
  55. package/docs/ru/12-auth-and-api.md +53 -0
  56. package/docs/ru/13-deployment.md +60 -0
  57. package/docs/ru/14-testing.md +50 -0
  58. package/docs/ru/15-error-handling.md +56 -0
  59. package/docs/ru/16-bake-cookbook.md +55 -0
  60. package/docs/ru/README.md +7 -0
  61. package/docs/uk/10-app-architecture.md +56 -0
  62. package/docs/uk/11-layouts.md +34 -0
  63. package/docs/uk/12-auth-and-api.md +34 -0
  64. package/docs/uk/13-deployment.md +39 -0
  65. package/docs/uk/14-testing.md +34 -0
  66. package/docs/uk/15-error-handling.md +32 -0
  67. package/docs/uk/16-bake-cookbook.md +36 -0
  68. package/docs/uk/README.md +7 -0
  69. package/llms.txt +9 -1
  70. package/package.json +3 -1
  71. package/scripts/_config.mjs +224 -0
  72. package/scripts/bake.mjs +266 -121
  73. package/scripts/bundle.mjs +133 -67
  74. package/scripts/cli.mjs +195 -27
  75. package/scripts/preview.mjs +125 -21
  76. package/server/serve.mjs +161 -10
  77. package/starters/admin/README.md +63 -0
  78. package/starters/admin/index.html +28 -0
  79. package/starters/admin/mado.config.json +22 -0
  80. package/starters/admin/package.json +24 -0
  81. package/starters/admin/public/favicon.svg +4 -0
  82. package/starters/admin/src/components/x-button.ts +55 -0
  83. package/starters/admin/src/components/x-input.ts +74 -0
  84. package/starters/admin/src/layouts/app.ts +101 -0
  85. package/starters/admin/src/layouts/auth.ts +41 -0
  86. package/starters/admin/src/lib/api.ts +133 -0
  87. package/starters/admin/src/lib/auth.ts +83 -0
  88. package/starters/admin/src/main.ts +15 -0
  89. package/starters/admin/src/pages/admin/dashboard.ts +48 -0
  90. package/starters/admin/src/pages/admin/order-detail.ts +80 -0
  91. package/starters/admin/src/pages/admin/orders.ts +117 -0
  92. package/starters/admin/src/pages/home.ts +34 -0
  93. package/starters/admin/src/pages/login.ts +70 -0
  94. package/starters/admin/src/pages/not-found.ts +12 -0
  95. package/starters/admin/src/routes.ts +40 -0
  96. package/starters/admin/src/styles/global.ts +86 -0
  97. package/starters/admin/tsconfig.json +15 -0
  98. package/starters/crud/index.html +12 -4
  99. package/starters/crud/mado.config.json +20 -0
  100. package/starters/crud/package.json +9 -3
  101. package/starters/crud/src/pages/home.ts +16 -0
  102. package/starters/crud/src/routes.ts +4 -2
  103. package/starters/minimal/index.html +12 -4
  104. package/starters/minimal/mado.config.json +20 -0
  105. package/starters/minimal/package.json +9 -3
  106. package/starters/minimal/src/pages/home.ts +17 -0
  107. package/starters/minimal/src/routes.ts +4 -2
package/scripts/bake.mjs CHANGED
@@ -1,36 +1,83 @@
1
- // Smart Static: bake HTML for pages with `bake: { paths, data }`.
1
+ // Smart Static: bake HTML for pages whose `page({ bake })` is set.
2
2
  //
3
- // node scripts/bake.mjs # reads src/routes.ts (or examples/routes.ts)
4
- // ENTRY=examples/routes.ts node scripts/bake.mjs
3
+ // Usage:
4
+ // mado bake
5
+ // mado bake --entry src/routes.ts --template index.html --out out/baked
6
+ // mado bake --base-url https://example.com
7
+ //
8
+ // Configuration precedence (low → high):
9
+ // built-in defaults < mado.config.json (bake.*) < CLI flags < env vars
5
10
  //
6
11
  // What it does:
7
- // 1. Dynamically imports the routes manifest.
12
+ // 1. Bundles `entry` (routes module) with esbuild for Node consumption.
8
13
  // 2. For every route whose page has `bake`:
9
- // a) gets params through `bake.paths()`,
10
- // b) gets data for each params object through `bake.data(params)`,
11
- // c) renders TemplateResult into an HTML string (without a browser),
12
- // d) bakes head() into <meta>/<link>/<script type=json-ld>,
13
- // e) embeds baked data in <script id="bake" type="application/json">,
14
- // f) writes out/<path>/index.html.
15
- // 3. Generates out/sitemap.xml.
14
+ // a) gets params via `bake.paths()`,
15
+ // b) gets data per params via `bake.data(params)`,
16
+ // c) renders the TemplateResult to an HTML string (no browser),
17
+ // d) materializes head() into <meta>/<link>/<script type=json-ld>,
18
+ // e) inlines baked data in <script id="bake" type="application/json">,
19
+ // f) writes <out>/<path>/index.html.
20
+ // 3. Generates <out>/sitemap.xml.
16
21
  //
17
- // Dependency: linkedom (~50KB pure JS DOM in Node). If it is missing, print a
18
- // clear error.
22
+ // Context awareness:
23
+ // - In app-mode (default outside the framework repository) the bundler
24
+ // resolves `@madojs/mado` from node_modules normally.
25
+ // - In repo-mode (the framework repository itself) it aliases
26
+ // `@madojs/mado` → ./src/index.ts so the framework can dogfood itself.
19
27
  //
20
- // Design: no magic. This file does not call component methods like
21
- // connectedCallback; it only expands TemplateResult structures into HTML.
22
- // Web Components come alive on the client.
28
+ // Required dev deps: linkedom, esbuild. We print a clear error if missing.
23
29
 
24
- import { readFile, writeFile, mkdir, access, rm } from "node:fs/promises";
30
+ import { readFile, writeFile, mkdir, rm } from "node:fs/promises";
31
+ import { existsSync, writeSync } from "node:fs";
25
32
  import { join, dirname, resolve } from "node:path";
26
33
  import { pathToFileURL } from "node:url";
27
34
  import { tmpdir } from "node:os";
28
35
 
29
- // ---------- Options ----------
36
+ import { loadConfig, parseFlags, resolveProjectPath } from "./_config.mjs";
37
+
38
+ // ---------- Resolve options from config + flags + env ----------
39
+
40
+ const { flags } = parseFlags(process.argv.slice(2));
41
+ const cfg = loadConfig({
42
+ overrides: {
43
+ bake: {
44
+ entry: typeof flags.entry === "string" ? flags.entry : undefined,
45
+ template: typeof flags.template === "string" ? flags.template : undefined,
46
+ baseUrl: typeof flags["base-url"] === "string" ? flags["base-url"] : undefined,
47
+ outDir: typeof flags.out === "string" ? flags.out : undefined,
48
+ },
49
+ },
50
+ });
30
51
 
31
- const ENTRY = process.env.ENTRY ?? "examples/routes.ts";
32
- const OUT_DIR = process.env.OUT_DIR ?? "out";
33
- const BASE_URL = process.env.BASE_URL ?? "https://example.com";
52
+ // Env vars are legacy escape hatches (kept so old CI keeps working).
53
+ const ENTRY = process.env.ENTRY ?? resolveProjectPath(cfg, cfg.bake.entry);
54
+ const TEMPLATE = process.env.TEMPLATE ?? resolveProjectPath(cfg, cfg.bake.template);
55
+ const BASE_URL = process.env.BASE_URL ?? cfg.bake.baseUrl;
56
+ const OUT_DIR = process.env.OUT_DIR
57
+ ?? resolveProjectPath(cfg, cfg.bake.outDir ?? join(cfg.build.out, "baked"));
58
+
59
+ /** Write message to stderr and exit. Sync write keeps CI/execFile output reliable. */
60
+ function fatal(...msgs) {
61
+ writeSync(2, msgs.join("\n") + "\n");
62
+ process.exit(1);
63
+ }
64
+
65
+ function error(...msgs) {
66
+ writeSync(2, msgs.join(" ") + "\n");
67
+ }
68
+
69
+ if (!existsSync(ENTRY)) {
70
+ fatal(
71
+ `[bake] entry not found: ${ENTRY}`,
72
+ `[bake] set bake.entry in mado.config.json or pass --entry <file>`,
73
+ );
74
+ }
75
+ if (!existsSync(TEMPLATE)) {
76
+ fatal(
77
+ `[bake] template not found: ${TEMPLATE}`,
78
+ `[bake] set bake.template in mado.config.json or pass --template <file>`,
79
+ );
80
+ }
34
81
 
35
82
  // ---------- Optional dependencies ----------
36
83
 
@@ -38,61 +85,72 @@ let parseHTML;
38
85
  try {
39
86
  ({ parseHTML } = await import("linkedom"));
40
87
  } catch {
41
- console.error("[bake] package 'linkedom' is required: npm i -D linkedom");
42
- process.exit(1);
88
+ fatal(
89
+ "[bake] package 'linkedom' is required.",
90
+ "[bake] Install it as a dev dependency in this project:",
91
+ "[bake] npm i -D linkedom esbuild",
92
+ "[bake] (esbuild is also required, see next check).",
93
+ "[bake] These are not bundled into @madojs/mado on purpose: bake is an",
94
+ "[bake] optional build step and we don't want to add transitive deps to",
95
+ "[bake] every Mado install.",
96
+ );
43
97
  }
44
98
 
45
99
  let esbuild;
46
100
  try {
47
101
  esbuild = await import("esbuild");
48
102
  } catch {
49
- console.error("[bake] package 'esbuild' is required: npm i -D esbuild");
50
- process.exit(1);
103
+ fatal(
104
+ "[bake] package 'esbuild' is required.",
105
+ "[bake] Install it as a dev dependency in this project:",
106
+ "[bake] npm i -D esbuild linkedom",
107
+ );
51
108
  }
52
109
 
53
110
  // ---------- DOM polyfills for Node ----------
54
111
  //
55
- // router.ts/component.ts/css.ts touch window, document, location and
56
- // customElements at module top-level. Node does not have those, so install
57
- // linkedom stubs before importing the app graph.
112
+ // router.ts / component.ts / css.ts touch window/document/location/customElements
113
+ // at module top-level. Node has none, so install linkedom stubs before importing
114
+ // the app graph.
58
115
 
59
- const baseHtml = await readFile("examples/index.html", "utf8").catch(
60
- () => "<!doctype html><html><head></head><body></body></html>",
61
- );
116
+ const baseHtml = await readFile(TEMPLATE, "utf8");
62
117
  const { window: linkedomWindow } = parseHTML(baseHtml);
63
118
 
64
119
  globalThis.window = linkedomWindow;
65
120
  globalThis.document = linkedomWindow.document;
66
121
  globalThis.location = new URL("http://localhost/");
67
- globalThis.history = {
68
- pushState: () => {},
69
- replaceState: () => {},
70
- };
122
+ globalThis.history = { pushState: () => {}, replaceState: () => {} };
71
123
  globalThis.customElements = {
72
124
  define: () => {},
73
125
  get: () => undefined,
74
126
  whenDefined: () => Promise.resolve(),
75
127
  };
76
128
  globalThis.HTMLElement = linkedomWindow.HTMLElement ?? class {};
77
- globalThis.CSSStyleSheet =
78
- globalThis.CSSStyleSheet ??
79
- class {
80
- cssRules = [];
81
- replaceSync() {}
82
- };
129
+ globalThis.CSSStyleSheet = globalThis.CSSStyleSheet ?? class {
130
+ cssRules = [];
131
+ replaceSync() {}
132
+ };
83
133
  globalThis.matchMedia = () => ({
84
134
  matches: false,
85
135
  addEventListener: () => {},
86
136
  removeEventListener: () => {},
87
137
  });
88
- if (!globalThis.queueMicrotask) globalThis.queueMicrotask = (fn) => Promise.resolve().then(fn);
138
+ if (!globalThis.queueMicrotask) {
139
+ globalThis.queueMicrotask = (fn) => Promise.resolve().then(fn);
140
+ }
89
141
 
90
- // ---------- Manifest import ----------
142
+ // ---------- Bundle the routes module for Node ----------
91
143
  //
92
- // Bundle routes.ts into a temporary CJS file so Node can import it without
93
- // caring about paths/importmap. Pages, lib and Mado itself are bundled too.
144
+ // In repo-mode the framework dogfoods its own source; alias `@madojs/mado`
145
+ // to ./src/index.ts. In app-mode the package is resolved from node_modules.
94
146
 
95
147
  const tmpFile = join(tmpdir(), `mado-bake-${Date.now()}.mjs`);
148
+ const aliases = cfg.context === "repo"
149
+ ? { "@madojs/mado": resolve(cfg.projectRoot, "src/index.ts") }
150
+ : {};
151
+
152
+ const tsconfigCandidate = join(cfg.projectRoot, "tsconfig.json");
153
+ const tsconfig = existsSync(tsconfigCandidate) ? tsconfigCandidate : undefined;
96
154
 
97
155
  await esbuild.build({
98
156
  entryPoints: [ENTRY],
@@ -101,12 +159,9 @@ await esbuild.build({
101
159
  platform: "node",
102
160
  target: "es2022",
103
161
  outfile: tmpFile,
104
- // tsconfig paths are not read by esbuild here, so aliases are explicit.
105
- tsconfig: "tsconfig.json",
106
- // resolve '@madojs/mado' -> ./src/index.ts
107
- alias: {
108
- "@madojs/mado": resolve("src/index.ts"),
109
- },
162
+ absWorkingDir: cfg.projectRoot,
163
+ tsconfig,
164
+ alias: aliases,
110
165
  logLevel: "error",
111
166
  });
112
167
 
@@ -116,51 +171,65 @@ await rm(tmpFile).catch(() => {});
116
171
  const routeApi = routesModule.default;
117
172
 
118
173
  if (!routeApi) {
119
- console.error("[bake] routes.ts must default-export routes({...})");
120
- process.exit(1);
174
+ fatal(`[bake] ${ENTRY} must default-export routes({...})`);
121
175
  }
122
176
 
123
- // Bake needs the source manifest, not RouterApi (runtime API). Therefore
124
- // routes.ts must also export `manifest`.
125
- //
126
- // The chosen convention: routes.ts exports both `default` (RouterApi) and
127
- // `manifest` (the source object). See examples/routes.ts.
128
-
177
+ // Bake needs the source manifest (not the runtime RouterApi).
178
+ // routes.ts must therefore also `export const manifest = {...}`.
129
179
  const manifest = routesModule.manifest;
130
180
  if (!manifest) {
131
- console.error(
132
- "[bake] routes.ts must also `export const manifest = {...}` " +
181
+ fatal(
182
+ `[bake] ${ENTRY} must also \`export const manifest = {...}\` ` +
133
183
  "(the same object passed to routes()).",
134
184
  );
135
- process.exit(1);
136
185
  }
137
186
 
138
187
  // ---------- Main loop ----------
139
188
 
140
189
  await mkdir(OUT_DIR, { recursive: true });
141
190
 
142
- // Read the HTML template (the same index.html used by the app). Without it
143
- // there is nowhere to place baked output.
144
- const TEMPLATE_HTML = await readFile("examples/index.html", "utf8");
191
+ // Re-read template (already loaded for DOM polyfills, but we want a fresh copy
192
+ // per generated page so meta/link tags don't accumulate across iterations).
193
+ const TEMPLATE_HTML = baseHtml;
145
194
 
146
195
  const sitemapEntries = [];
147
196
  let total = 0;
197
+ let bakedErrors = 0;
198
+ let bakeablePages = 0;
199
+ const skippedNoBake = [];
148
200
 
149
201
  for (const [pattern, entry] of Object.entries(manifest)) {
150
202
  if (pattern === "*") continue;
151
203
 
152
- // entry can be Page, () => import, or nested. Bake currently handles direct
153
- // lazy imports and Page entries.
154
204
  const pg = await resolvePage(entry);
155
205
  if (!pg) continue;
156
- if (!pg.bake) continue;
206
+ if (!pg.bake) {
207
+ skippedNoBake.push(pattern);
208
+ continue;
209
+ }
210
+ bakeablePages++;
157
211
 
158
212
  console.log(`[bake] ${pattern}`);
159
213
 
160
- const allParams = await pg.bake.paths();
214
+ let allParams;
215
+ try {
216
+ allParams = await pg.bake.paths();
217
+ } catch (err) {
218
+ error(`[bake] ${pattern}: bake.paths() failed:`, err.message);
219
+ bakedErrors++;
220
+ continue;
221
+ }
222
+
161
223
  for (const params of allParams) {
162
224
  const pathname = applyParams(pattern, params);
163
- const data = await pg.bake.data(params);
225
+ let data;
226
+ try {
227
+ data = await pg.bake.data(params);
228
+ } catch (err) {
229
+ error(`[bake] ${pathname}: bake.data() failed:`, err.message);
230
+ bakedErrors++;
231
+ continue;
232
+ }
164
233
  const headMeta = pg.head ? pg.head(params, data) : {};
165
234
 
166
235
  const tpl = pg.view({
@@ -170,7 +239,15 @@ for (const [pattern, entry] of Object.entries(manifest)) {
170
239
  child: null,
171
240
  });
172
241
 
173
- const bodyHtml = renderTemplate(tpl);
242
+ let bodyHtml;
243
+ try {
244
+ bodyHtml = renderTemplate(tpl, { route: pattern });
245
+ } catch (err) {
246
+ error(`[bake] ${pathname}: render failed:`, err.message);
247
+ bakedErrors++;
248
+ continue;
249
+ }
250
+
174
251
  const finalHtml = buildHtml({
175
252
  template: TEMPLATE_HTML,
176
253
  bodyHtml,
@@ -180,15 +257,14 @@ for (const [pattern, entry] of Object.entries(manifest)) {
180
257
  canonical: headMeta.canonical ?? `${BASE_URL}${pathname}`,
181
258
  });
182
259
 
183
- const file = join(OUT_DIR, pathname === "/" ? "/index.html" : `${pathname}/index.html`);
260
+ const file = join(
261
+ OUT_DIR,
262
+ pathname === "/" ? "/index.html" : `${pathname}/index.html`,
263
+ );
184
264
  await mkdir(dirname(file), { recursive: true });
185
265
  await writeFile(file, finalHtml);
186
266
  total++;
187
-
188
- sitemapEntries.push({
189
- loc: `${BASE_URL}${pathname}`,
190
- changefreq: "weekly",
191
- });
267
+ sitemapEntries.push({ loc: `${BASE_URL}${pathname}`, changefreq: "weekly" });
192
268
  }
193
269
  }
194
270
 
@@ -206,7 +282,40 @@ ${sitemapEntries
206
282
  `;
207
283
  await writeFile(join(OUT_DIR, "sitemap.xml"), sitemap);
208
284
 
209
- console.log(`[bake] done: ${total} pages + sitemap.xml`);
285
+ console.log(`[bake] done: ${total} pages + sitemap.xml → ${OUT_DIR}`);
286
+ if (bakedErrors > 0) {
287
+ fatal(`[bake] ${bakedErrors} route(s) failed; see errors above.`);
288
+ }
289
+ // Loud diagnostic when the manifest exists but no page declares `bake`.
290
+ // Previously bake silently produced 0 pages + an empty sitemap and exited
291
+ // 0, which made `mado release` look successful while shipping no static
292
+ // HTML for crawlers. Fail loudly so the user notices.
293
+ if (bakeablePages === 0) {
294
+ error("");
295
+ error(
296
+ `[bake] WARNING: no page in ${ENTRY} declares \`bake: { paths, data }\`.`,
297
+ );
298
+ error(
299
+ `[bake] ${skippedNoBake.length} route(s) skipped: ${skippedNoBake
300
+ .slice(0, 6)
301
+ .join(", ")}${skippedNoBake.length > 6 ? ", …" : ""}`,
302
+ );
303
+ error("[bake] Add `bake` to at least one page (e.g. your landing route):");
304
+ error("[bake] export default page({");
305
+ error("[bake] view: …,");
306
+ error("[bake] bake: { paths: () => [{}], data: () => ({}) },");
307
+ error("[bake] });");
308
+ error(
309
+ "[bake] Without bake the build ships only the SPA shell — search engines",
310
+ );
311
+ error("[bake] and link previews see an empty <body>.");
312
+ // Exit non-zero so `mado release` halts and the user is forced to address
313
+ // it. If you intentionally have an SPA-only deploy, drop `mado bake` from
314
+ // the release pipeline (or set MADO_BAKE_ALLOW_EMPTY=1).
315
+ if (process.env.MADO_BAKE_ALLOW_EMPTY !== "1") {
316
+ process.exit(1);
317
+ }
318
+ }
210
319
 
211
320
  // ---------- Helpers ----------
212
321
 
@@ -232,41 +341,44 @@ function applyParams(pattern, params) {
232
341
  });
233
342
  }
234
343
 
235
- async function exists(p) {
236
- try {
237
- await access(p);
238
- return true;
239
- } catch {
240
- return false;
241
- }
242
- }
243
-
244
344
  // ---------- Render TemplateResult → HTML string ----------
245
345
  //
246
- // Tiny server-side renderer for TemplateResult. Supports the same shapes as
247
- // html.ts, but without events (@click is ignored) and without live signals
248
- // (function values are called once).
346
+ // Tiny SSR for TemplateResult. Supports the same shapes as html.ts but
347
+ // without events (@click is stripped) and without live signals (function
348
+ // values are called once).
249
349
 
250
- function renderTemplate(tpl) {
350
+ function renderTemplate(tpl, ctx) {
251
351
  if (tpl == null || tpl === false || tpl === true) return "";
252
352
  if (typeof tpl === "string") return escapeHtml(tpl);
253
353
  if (typeof tpl === "number") return String(tpl);
254
- if (Array.isArray(tpl)) return tpl.map(renderTemplate).join("");
255
- if (tpl && tpl._mado === true) return renderMadoTemplate(tpl);
256
- // unknown
354
+ if (Array.isArray(tpl)) return tpl.map((x) => renderTemplate(x, ctx)).join("");
355
+ if (tpl && tpl._mado === true) return renderMadoTemplate(tpl, ctx);
356
+ // Unknown shapes (e.g. each() directive results) must NOT silently render
357
+ // as "[object Object]". Either each() unwraps to an array here, or we
358
+ // throw with a meaningful location.
359
+ if (tpl && typeof tpl === "object") {
360
+ throw new Error(
361
+ `bake cannot render value of type "${tpl?._type ?? tpl?.constructor?.name ?? "object"}" ` +
362
+ `in route ${ctx?.route ?? "?"}. ` +
363
+ "Hint: each() and other directives are not yet supported in bake. " +
364
+ "Use a plain array (items.map(render)) in baked views, or render this " +
365
+ "section only on the client.",
366
+ );
367
+ }
257
368
  return "";
258
369
  }
259
370
 
260
- function renderMadoTemplate(tpl) {
371
+ function renderMadoTemplate(tpl, ctx) {
261
372
  const { strings, values } = tpl;
262
373
  let html = "";
263
374
  for (let i = 0; i < strings.length; i++) {
264
375
  html += strings[i];
265
376
  if (i < strings.length - 1) {
266
- html += renderValue(values[i], inAttributeContext(html));
377
+ html += renderValue(values[i], inAttributeContext(html), ctx);
267
378
  }
268
379
  }
269
- // Remove event marker attributes (the client html.ts does this too).
380
+ // Strip event markers and normalize property / boolean-attribute forms
381
+ // (mirrors the runtime bindings in src/html/bindings.ts).
270
382
  return html
271
383
  .replace(/\s+@[\w-]+="[^"]*"/g, "")
272
384
  .replace(/\s+\.([\w-]+)="([^"]*)"/g, ' $1="$2"')
@@ -280,22 +392,71 @@ function inAttributeContext(html) {
280
392
  return lastOpen > lastClose;
281
393
  }
282
394
 
283
- function renderValue(v, inAttr) {
395
+ function renderValue(v, inAttr, ctx) {
284
396
  if (v == null || v === false) return "";
285
397
  if (v === true) return "";
286
398
  if (typeof v === "function") {
287
399
  try {
288
- return renderValue(v(), inAttr);
400
+ return renderValue(v(), inAttr, ctx);
289
401
  } catch {
290
402
  return "";
291
403
  }
292
404
  }
293
- if (Array.isArray(v)) return v.map((x) => renderValue(x, inAttr)).join("");
294
- if (v && v._mado === true) return renderMadoTemplate(v);
405
+ if (Array.isArray(v)) return v.map((x) => renderValue(x, inAttr, ctx)).join("");
406
+ if (v && v._mado === true) return renderMadoTemplate(v, ctx);
407
+ if (v && typeof v === "object" && typeof v._madoDirective === "string") {
408
+ return renderDirective(v, inAttr, ctx);
409
+ }
410
+ if (v && typeof v === "object") {
411
+ // Same defense as renderTemplate(): never silently coerce to "[object Object]".
412
+ throw new Error(
413
+ `bake cannot serialize value of type "${v?._type ?? v?.constructor?.name ?? "object"}" ` +
414
+ `in route ${ctx?.route ?? "?"}. ` +
415
+ "Hint: each() is not yet supported in bake. Use a plain array in baked views.",
416
+ );
417
+ }
295
418
  if (inAttr) return escapeAttr(String(v));
296
419
  return escapeHtml(String(v));
297
420
  }
298
421
 
422
+ function renderDirective(v, inAttr, ctx) {
423
+ switch (v._madoDirective) {
424
+ case "unsafeHTML": {
425
+ if (inAttr) {
426
+ throw new Error(
427
+ `bake cannot render unsafeHTML() inside an attribute in route ${ctx?.route ?? "?"}.`,
428
+ );
429
+ }
430
+ return String(v.value ?? "");
431
+ }
432
+ case "classMap": {
433
+ const value = Object.entries(v.value ?? {})
434
+ .filter(([, enabled]) => !!enabled)
435
+ .map(([className]) => className)
436
+ .join(" ");
437
+ return inAttr ? escapeAttr(value) : escapeHtml(value);
438
+ }
439
+ case "styleMap": {
440
+ const value = Object.entries(v.value ?? {})
441
+ .filter(([, raw]) => raw != null && raw !== false)
442
+ .map(([name, raw]) => `${toCssPropertyName(name)}:${String(raw)}`)
443
+ .join(";");
444
+ return inAttr ? escapeAttr(value) : escapeHtml(value);
445
+ }
446
+ case "ref":
447
+ return "";
448
+ default:
449
+ throw new Error(
450
+ `bake cannot render directive "${v._madoDirective}" in route ${ctx?.route ?? "?"}.`,
451
+ );
452
+ }
453
+ }
454
+
455
+ function toCssPropertyName(name) {
456
+ if (name.startsWith("--")) return name;
457
+ return name.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
458
+ }
459
+
299
460
  function escapeHtml(s) {
300
461
  return s
301
462
  .replace(/&/g, "&amp;")
@@ -318,7 +479,6 @@ function escapeXml(s) {
318
479
  function buildHtml({ template, bodyHtml, head, bakedData, revalidate, canonical }) {
319
480
  const { document } = parseHTML(template);
320
481
 
321
- // head
322
482
  if (head.title) document.title = head.title;
323
483
  if (head.description) {
324
484
  setMeta(document, { name: "description", content: head.description });
@@ -335,10 +495,7 @@ function buildHtml({ template, bodyHtml, head, bakedData, revalidate, canonical
335
495
  if (head.twitter || head.og) {
336
496
  const tw = head.twitter ?? {};
337
497
  const og = head.og ?? {};
338
- setMeta(document, {
339
- name: "twitter:card",
340
- content: tw.card ?? "summary",
341
- });
498
+ setMeta(document, { name: "twitter:card", content: tw.card ?? "summary" });
342
499
  if (tw.title ?? og.title)
343
500
  setMeta(document, { name: "twitter:title", content: tw.title ?? og.title });
344
501
  if (tw.description ?? og.description)
@@ -349,7 +506,6 @@ function buildHtml({ template, bodyHtml, head, bakedData, revalidate, canonical
349
506
  for (const m of head.meta ?? []) setMeta(document, m);
350
507
  for (const l of head.link ?? []) setLink(document, l);
351
508
 
352
- // JSON-LD
353
509
  if (head.jsonLd != null) {
354
510
  const s = document.createElement("script");
355
511
  s.setAttribute("type", "application/ld+json");
@@ -358,19 +514,11 @@ function buildHtml({ template, bodyHtml, head, bakedData, revalidate, canonical
358
514
  document.head.appendChild(s);
359
515
  }
360
516
 
361
- // revalidate meta: for CDN or manual CI re-bake logic
362
517
  if (revalidate) {
363
- setMeta(document, {
364
- name: "bake-revalidate",
365
- content: String(revalidate),
366
- });
367
- setMeta(document, {
368
- name: "bake-stamp",
369
- content: String(Date.now()),
370
- });
518
+ setMeta(document, { name: "bake-revalidate", content: String(revalidate) });
519
+ setMeta(document, { name: "bake-stamp", content: String(Date.now()) });
371
520
  }
372
521
 
373
- // Baked data: the client can use it as initialData.
374
522
  if (bakedData !== undefined) {
375
523
  const s = document.createElement("script");
376
524
  s.setAttribute("type", "application/json");
@@ -379,11 +527,8 @@ function buildHtml({ template, bodyHtml, head, bakedData, revalidate, canonical
379
527
  document.body.appendChild(s);
380
528
  }
381
529
 
382
- // body: insert baked HTML inside #app.
383
530
  const app = document.getElementById("app");
384
- if (app) {
385
- app.innerHTML = bodyHtml;
386
- }
531
+ if (app) app.innerHTML = bodyHtml;
387
532
 
388
533
  return "<!doctype html>\n" + document.documentElement.outerHTML;
389
534
  }