@madojs/mado 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/AGENTS.md +26 -0
  2. package/CHANGELOG.md +153 -0
  3. package/MADO_V1_PLAN.md +179 -0
  4. package/README.md +31 -13
  5. package/ROADMAP.md +28 -7
  6. package/TODO.md +72 -0
  7. package/dist/src/forms.d.ts +37 -4
  8. package/dist/src/forms.js +331 -57
  9. package/dist/src/forms.js.map +1 -1
  10. package/dist/src/html/bindings.d.ts +41 -0
  11. package/dist/src/html/bindings.js +163 -6
  12. package/dist/src/html/bindings.js.map +1 -1
  13. package/dist/src/html.d.ts +2 -0
  14. package/dist/src/html.js +1 -0
  15. package/dist/src/html.js.map +1 -1
  16. package/dist/src/index.d.ts +6 -6
  17. package/dist/src/index.js +2 -2
  18. package/dist/src/index.js.map +1 -1
  19. package/dist/src/page.d.ts +56 -0
  20. package/dist/src/page.js +17 -0
  21. package/dist/src/page.js.map +1 -1
  22. package/dist/src/router/manifest.d.ts +16 -1
  23. package/dist/src/router/manifest.js +181 -38
  24. package/dist/src/router/manifest.js.map +1 -1
  25. package/dist/src/router/match.d.ts +7 -2
  26. package/dist/src/router/match.js +14 -4
  27. package/dist/src/router/match.js.map +1 -1
  28. package/dist/src/router/navigation.d.ts +10 -0
  29. package/dist/src/router/navigation.js +71 -3
  30. package/dist/src/router/navigation.js.map +1 -1
  31. package/dist/src/signal.d.ts +15 -1
  32. package/dist/src/signal.js +112 -16
  33. package/dist/src/signal.js.map +1 -1
  34. package/docs/en/02-project-layout.md +99 -40
  35. package/docs/en/10-app-architecture.md +141 -0
  36. package/docs/en/11-layouts.md +115 -0
  37. package/docs/en/12-auth-and-api.md +217 -0
  38. package/docs/en/13-deployment.md +192 -0
  39. package/docs/en/14-testing.md +82 -0
  40. package/docs/en/15-error-handling.md +100 -0
  41. package/docs/en/16-bake-cookbook.md +93 -0
  42. package/docs/en/README.md +7 -0
  43. package/docs/fr/10-app-architecture.md +61 -0
  44. package/docs/fr/11-layouts.md +35 -0
  45. package/docs/fr/12-auth-and-api.md +35 -0
  46. package/docs/fr/13-deployment.md +39 -0
  47. package/docs/fr/14-testing.md +41 -0
  48. package/docs/fr/15-error-handling.md +50 -0
  49. package/docs/fr/16-bake-cookbook.md +35 -0
  50. package/docs/fr/README.md +7 -0
  51. package/docs/ru/10-app-architecture.md +100 -0
  52. package/docs/ru/11-layouts.md +47 -0
  53. package/docs/ru/12-auth-and-api.md +53 -0
  54. package/docs/ru/13-deployment.md +60 -0
  55. package/docs/ru/14-testing.md +50 -0
  56. package/docs/ru/15-error-handling.md +56 -0
  57. package/docs/ru/16-bake-cookbook.md +55 -0
  58. package/docs/ru/README.md +7 -0
  59. package/docs/uk/10-app-architecture.md +56 -0
  60. package/docs/uk/11-layouts.md +34 -0
  61. package/docs/uk/12-auth-and-api.md +34 -0
  62. package/docs/uk/13-deployment.md +39 -0
  63. package/docs/uk/14-testing.md +34 -0
  64. package/docs/uk/15-error-handling.md +32 -0
  65. package/docs/uk/16-bake-cookbook.md +36 -0
  66. package/docs/uk/README.md +7 -0
  67. package/llms.txt +9 -1
  68. package/package.json +3 -1
  69. package/scripts/_config.mjs +224 -0
  70. package/scripts/bake.mjs +217 -120
  71. package/scripts/bundle.mjs +110 -67
  72. package/scripts/cli.mjs +119 -15
  73. package/scripts/preview.mjs +22 -12
  74. package/server/serve.mjs +82 -4
  75. package/starters/admin/README.md +63 -0
  76. package/starters/admin/index.html +21 -0
  77. package/starters/admin/mado.config.json +22 -0
  78. package/starters/admin/package.json +22 -0
  79. package/starters/admin/public/favicon.svg +4 -0
  80. package/starters/admin/src/components/x-button.ts +55 -0
  81. package/starters/admin/src/components/x-input.ts +74 -0
  82. package/starters/admin/src/layouts/app.ts +101 -0
  83. package/starters/admin/src/layouts/auth.ts +41 -0
  84. package/starters/admin/src/lib/api.ts +133 -0
  85. package/starters/admin/src/lib/auth.ts +83 -0
  86. package/starters/admin/src/main.ts +15 -0
  87. package/starters/admin/src/pages/admin/dashboard.ts +48 -0
  88. package/starters/admin/src/pages/admin/order-detail.ts +78 -0
  89. package/starters/admin/src/pages/admin/orders.ts +117 -0
  90. package/starters/admin/src/pages/home.ts +25 -0
  91. package/starters/admin/src/pages/login.ts +70 -0
  92. package/starters/admin/src/pages/not-found.ts +12 -0
  93. package/starters/admin/src/routes.ts +40 -0
  94. package/starters/admin/src/styles/global.ts +86 -0
  95. package/starters/admin/tsconfig.json +15 -0
  96. package/starters/crud/mado.config.json +20 -0
  97. package/starters/crud/package.json +8 -4
  98. package/starters/crud/src/routes.ts +4 -2
  99. package/starters/minimal/mado.config.json +20 -0
  100. package/starters/minimal/package.json +7 -3
  101. package/starters/minimal/src/routes.ts +4 -2
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
+ });
51
+
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"));
30
58
 
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";
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,60 @@ 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("[bake] package 'linkedom' is required: npm i -D linkedom");
43
89
  }
44
90
 
45
91
  let esbuild;
46
92
  try {
47
93
  esbuild = await import("esbuild");
48
94
  } catch {
49
- console.error("[bake] package 'esbuild' is required: npm i -D esbuild");
50
- process.exit(1);
95
+ fatal("[bake] package 'esbuild' is required: npm i -D esbuild");
51
96
  }
52
97
 
53
98
  // ---------- DOM polyfills for Node ----------
54
99
  //
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.
100
+ // router.ts / component.ts / css.ts touch window/document/location/customElements
101
+ // at module top-level. Node has none, so install linkedom stubs before importing
102
+ // the app graph.
58
103
 
59
- const baseHtml = await readFile("examples/index.html", "utf8").catch(
60
- () => "<!doctype html><html><head></head><body></body></html>",
61
- );
104
+ const baseHtml = await readFile(TEMPLATE, "utf8");
62
105
  const { window: linkedomWindow } = parseHTML(baseHtml);
63
106
 
64
107
  globalThis.window = linkedomWindow;
65
108
  globalThis.document = linkedomWindow.document;
66
109
  globalThis.location = new URL("http://localhost/");
67
- globalThis.history = {
68
- pushState: () => {},
69
- replaceState: () => {},
70
- };
110
+ globalThis.history = { pushState: () => {}, replaceState: () => {} };
71
111
  globalThis.customElements = {
72
112
  define: () => {},
73
113
  get: () => undefined,
74
114
  whenDefined: () => Promise.resolve(),
75
115
  };
76
116
  globalThis.HTMLElement = linkedomWindow.HTMLElement ?? class {};
77
- globalThis.CSSStyleSheet =
78
- globalThis.CSSStyleSheet ??
79
- class {
80
- cssRules = [];
81
- replaceSync() {}
82
- };
117
+ globalThis.CSSStyleSheet = globalThis.CSSStyleSheet ?? class {
118
+ cssRules = [];
119
+ replaceSync() {}
120
+ };
83
121
  globalThis.matchMedia = () => ({
84
122
  matches: false,
85
123
  addEventListener: () => {},
86
124
  removeEventListener: () => {},
87
125
  });
88
- if (!globalThis.queueMicrotask) globalThis.queueMicrotask = (fn) => Promise.resolve().then(fn);
126
+ if (!globalThis.queueMicrotask) {
127
+ globalThis.queueMicrotask = (fn) => Promise.resolve().then(fn);
128
+ }
89
129
 
90
- // ---------- Manifest import ----------
130
+ // ---------- Bundle the routes module for Node ----------
91
131
  //
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.
132
+ // In repo-mode the framework dogfoods its own source; alias `@madojs/mado`
133
+ // to ./src/index.ts. In app-mode the package is resolved from node_modules.
94
134
 
95
135
  const tmpFile = join(tmpdir(), `mado-bake-${Date.now()}.mjs`);
136
+ const aliases = cfg.context === "repo"
137
+ ? { "@madojs/mado": resolve(cfg.projectRoot, "src/index.ts") }
138
+ : {};
139
+
140
+ const tsconfigCandidate = join(cfg.projectRoot, "tsconfig.json");
141
+ const tsconfig = existsSync(tsconfigCandidate) ? tsconfigCandidate : undefined;
96
142
 
97
143
  await esbuild.build({
98
144
  entryPoints: [ENTRY],
@@ -101,12 +147,9 @@ await esbuild.build({
101
147
  platform: "node",
102
148
  target: "es2022",
103
149
  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
- },
150
+ absWorkingDir: cfg.projectRoot,
151
+ tsconfig,
152
+ alias: aliases,
110
153
  logLevel: "error",
111
154
  });
112
155
 
@@ -116,51 +159,59 @@ await rm(tmpFile).catch(() => {});
116
159
  const routeApi = routesModule.default;
117
160
 
118
161
  if (!routeApi) {
119
- console.error("[bake] routes.ts must default-export routes({...})");
120
- process.exit(1);
162
+ fatal(`[bake] ${ENTRY} must default-export routes({...})`);
121
163
  }
122
164
 
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
-
165
+ // Bake needs the source manifest (not the runtime RouterApi).
166
+ // routes.ts must therefore also `export const manifest = {...}`.
129
167
  const manifest = routesModule.manifest;
130
168
  if (!manifest) {
131
- console.error(
132
- "[bake] routes.ts must also `export const manifest = {...}` " +
169
+ fatal(
170
+ `[bake] ${ENTRY} must also \`export const manifest = {...}\` ` +
133
171
  "(the same object passed to routes()).",
134
172
  );
135
- process.exit(1);
136
173
  }
137
174
 
138
175
  // ---------- Main loop ----------
139
176
 
140
177
  await mkdir(OUT_DIR, { recursive: true });
141
178
 
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");
179
+ // Re-read template (already loaded for DOM polyfills, but we want a fresh copy
180
+ // per generated page so meta/link tags don't accumulate across iterations).
181
+ const TEMPLATE_HTML = baseHtml;
145
182
 
146
183
  const sitemapEntries = [];
147
184
  let total = 0;
185
+ let bakedErrors = 0;
148
186
 
149
187
  for (const [pattern, entry] of Object.entries(manifest)) {
150
188
  if (pattern === "*") continue;
151
189
 
152
- // entry can be Page, () => import, or nested. Bake currently handles direct
153
- // lazy imports and Page entries.
154
190
  const pg = await resolvePage(entry);
155
191
  if (!pg) continue;
156
192
  if (!pg.bake) continue;
157
193
 
158
194
  console.log(`[bake] ${pattern}`);
159
195
 
160
- const allParams = await pg.bake.paths();
196
+ let allParams;
197
+ try {
198
+ allParams = await pg.bake.paths();
199
+ } catch (err) {
200
+ error(`[bake] ${pattern}: bake.paths() failed:`, err.message);
201
+ bakedErrors++;
202
+ continue;
203
+ }
204
+
161
205
  for (const params of allParams) {
162
206
  const pathname = applyParams(pattern, params);
163
- const data = await pg.bake.data(params);
207
+ let data;
208
+ try {
209
+ data = await pg.bake.data(params);
210
+ } catch (err) {
211
+ error(`[bake] ${pathname}: bake.data() failed:`, err.message);
212
+ bakedErrors++;
213
+ continue;
214
+ }
164
215
  const headMeta = pg.head ? pg.head(params, data) : {};
165
216
 
166
217
  const tpl = pg.view({
@@ -170,7 +221,15 @@ for (const [pattern, entry] of Object.entries(manifest)) {
170
221
  child: null,
171
222
  });
172
223
 
173
- const bodyHtml = renderTemplate(tpl);
224
+ let bodyHtml;
225
+ try {
226
+ bodyHtml = renderTemplate(tpl, { route: pattern });
227
+ } catch (err) {
228
+ error(`[bake] ${pathname}: render failed:`, err.message);
229
+ bakedErrors++;
230
+ continue;
231
+ }
232
+
174
233
  const finalHtml = buildHtml({
175
234
  template: TEMPLATE_HTML,
176
235
  bodyHtml,
@@ -180,15 +239,14 @@ for (const [pattern, entry] of Object.entries(manifest)) {
180
239
  canonical: headMeta.canonical ?? `${BASE_URL}${pathname}`,
181
240
  });
182
241
 
183
- const file = join(OUT_DIR, pathname === "/" ? "/index.html" : `${pathname}/index.html`);
242
+ const file = join(
243
+ OUT_DIR,
244
+ pathname === "/" ? "/index.html" : `${pathname}/index.html`,
245
+ );
184
246
  await mkdir(dirname(file), { recursive: true });
185
247
  await writeFile(file, finalHtml);
186
248
  total++;
187
-
188
- sitemapEntries.push({
189
- loc: `${BASE_URL}${pathname}`,
190
- changefreq: "weekly",
191
- });
249
+ sitemapEntries.push({ loc: `${BASE_URL}${pathname}`, changefreq: "weekly" });
192
250
  }
193
251
  }
194
252
 
@@ -206,7 +264,10 @@ ${sitemapEntries
206
264
  `;
207
265
  await writeFile(join(OUT_DIR, "sitemap.xml"), sitemap);
208
266
 
209
- console.log(`[bake] done: ${total} pages + sitemap.xml`);
267
+ console.log(`[bake] done: ${total} pages + sitemap.xml → ${OUT_DIR}`);
268
+ if (bakedErrors > 0) {
269
+ fatal(`[bake] ${bakedErrors} route(s) failed; see errors above.`);
270
+ }
210
271
 
211
272
  // ---------- Helpers ----------
212
273
 
@@ -232,41 +293,44 @@ function applyParams(pattern, params) {
232
293
  });
233
294
  }
234
295
 
235
- async function exists(p) {
236
- try {
237
- await access(p);
238
- return true;
239
- } catch {
240
- return false;
241
- }
242
- }
243
-
244
296
  // ---------- Render TemplateResult → HTML string ----------
245
297
  //
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).
298
+ // Tiny SSR for TemplateResult. Supports the same shapes as html.ts but
299
+ // without events (@click is stripped) and without live signals (function
300
+ // values are called once).
249
301
 
250
- function renderTemplate(tpl) {
302
+ function renderTemplate(tpl, ctx) {
251
303
  if (tpl == null || tpl === false || tpl === true) return "";
252
304
  if (typeof tpl === "string") return escapeHtml(tpl);
253
305
  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
306
+ if (Array.isArray(tpl)) return tpl.map((x) => renderTemplate(x, ctx)).join("");
307
+ if (tpl && tpl._mado === true) return renderMadoTemplate(tpl, ctx);
308
+ // Unknown shapes (e.g. each() directive results) must NOT silently render
309
+ // as "[object Object]". Either each() unwraps to an array here, or we
310
+ // throw with a meaningful location.
311
+ if (tpl && typeof tpl === "object") {
312
+ throw new Error(
313
+ `bake cannot render value of type "${tpl?._type ?? tpl?.constructor?.name ?? "object"}" ` +
314
+ `in route ${ctx?.route ?? "?"}. ` +
315
+ "Hint: each() and other directives are not yet supported in bake. " +
316
+ "Use a plain array (items.map(render)) in baked views, or render this " +
317
+ "section only on the client.",
318
+ );
319
+ }
257
320
  return "";
258
321
  }
259
322
 
260
- function renderMadoTemplate(tpl) {
323
+ function renderMadoTemplate(tpl, ctx) {
261
324
  const { strings, values } = tpl;
262
325
  let html = "";
263
326
  for (let i = 0; i < strings.length; i++) {
264
327
  html += strings[i];
265
328
  if (i < strings.length - 1) {
266
- html += renderValue(values[i], inAttributeContext(html));
329
+ html += renderValue(values[i], inAttributeContext(html), ctx);
267
330
  }
268
331
  }
269
- // Remove event marker attributes (the client html.ts does this too).
332
+ // Strip event markers and normalize property / boolean-attribute forms
333
+ // (mirrors the runtime bindings in src/html/bindings.ts).
270
334
  return html
271
335
  .replace(/\s+@[\w-]+="[^"]*"/g, "")
272
336
  .replace(/\s+\.([\w-]+)="([^"]*)"/g, ' $1="$2"')
@@ -280,22 +344,71 @@ function inAttributeContext(html) {
280
344
  return lastOpen > lastClose;
281
345
  }
282
346
 
283
- function renderValue(v, inAttr) {
347
+ function renderValue(v, inAttr, ctx) {
284
348
  if (v == null || v === false) return "";
285
349
  if (v === true) return "";
286
350
  if (typeof v === "function") {
287
351
  try {
288
- return renderValue(v(), inAttr);
352
+ return renderValue(v(), inAttr, ctx);
289
353
  } catch {
290
354
  return "";
291
355
  }
292
356
  }
293
- if (Array.isArray(v)) return v.map((x) => renderValue(x, inAttr)).join("");
294
- if (v && v._mado === true) return renderMadoTemplate(v);
357
+ if (Array.isArray(v)) return v.map((x) => renderValue(x, inAttr, ctx)).join("");
358
+ if (v && v._mado === true) return renderMadoTemplate(v, ctx);
359
+ if (v && typeof v === "object" && typeof v._madoDirective === "string") {
360
+ return renderDirective(v, inAttr, ctx);
361
+ }
362
+ if (v && typeof v === "object") {
363
+ // Same defense as renderTemplate(): never silently coerce to "[object Object]".
364
+ throw new Error(
365
+ `bake cannot serialize value of type "${v?._type ?? v?.constructor?.name ?? "object"}" ` +
366
+ `in route ${ctx?.route ?? "?"}. ` +
367
+ "Hint: each() is not yet supported in bake. Use a plain array in baked views.",
368
+ );
369
+ }
295
370
  if (inAttr) return escapeAttr(String(v));
296
371
  return escapeHtml(String(v));
297
372
  }
298
373
 
374
+ function renderDirective(v, inAttr, ctx) {
375
+ switch (v._madoDirective) {
376
+ case "unsafeHTML": {
377
+ if (inAttr) {
378
+ throw new Error(
379
+ `bake cannot render unsafeHTML() inside an attribute in route ${ctx?.route ?? "?"}.`,
380
+ );
381
+ }
382
+ return String(v.value ?? "");
383
+ }
384
+ case "classMap": {
385
+ const value = Object.entries(v.value ?? {})
386
+ .filter(([, enabled]) => !!enabled)
387
+ .map(([className]) => className)
388
+ .join(" ");
389
+ return inAttr ? escapeAttr(value) : escapeHtml(value);
390
+ }
391
+ case "styleMap": {
392
+ const value = Object.entries(v.value ?? {})
393
+ .filter(([, raw]) => raw != null && raw !== false)
394
+ .map(([name, raw]) => `${toCssPropertyName(name)}:${String(raw)}`)
395
+ .join(";");
396
+ return inAttr ? escapeAttr(value) : escapeHtml(value);
397
+ }
398
+ case "ref":
399
+ return "";
400
+ default:
401
+ throw new Error(
402
+ `bake cannot render directive "${v._madoDirective}" in route ${ctx?.route ?? "?"}.`,
403
+ );
404
+ }
405
+ }
406
+
407
+ function toCssPropertyName(name) {
408
+ if (name.startsWith("--")) return name;
409
+ return name.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
410
+ }
411
+
299
412
  function escapeHtml(s) {
300
413
  return s
301
414
  .replace(/&/g, "&amp;")
@@ -318,7 +431,6 @@ function escapeXml(s) {
318
431
  function buildHtml({ template, bodyHtml, head, bakedData, revalidate, canonical }) {
319
432
  const { document } = parseHTML(template);
320
433
 
321
- // head
322
434
  if (head.title) document.title = head.title;
323
435
  if (head.description) {
324
436
  setMeta(document, { name: "description", content: head.description });
@@ -335,10 +447,7 @@ function buildHtml({ template, bodyHtml, head, bakedData, revalidate, canonical
335
447
  if (head.twitter || head.og) {
336
448
  const tw = head.twitter ?? {};
337
449
  const og = head.og ?? {};
338
- setMeta(document, {
339
- name: "twitter:card",
340
- content: tw.card ?? "summary",
341
- });
450
+ setMeta(document, { name: "twitter:card", content: tw.card ?? "summary" });
342
451
  if (tw.title ?? og.title)
343
452
  setMeta(document, { name: "twitter:title", content: tw.title ?? og.title });
344
453
  if (tw.description ?? og.description)
@@ -349,7 +458,6 @@ function buildHtml({ template, bodyHtml, head, bakedData, revalidate, canonical
349
458
  for (const m of head.meta ?? []) setMeta(document, m);
350
459
  for (const l of head.link ?? []) setLink(document, l);
351
460
 
352
- // JSON-LD
353
461
  if (head.jsonLd != null) {
354
462
  const s = document.createElement("script");
355
463
  s.setAttribute("type", "application/ld+json");
@@ -358,19 +466,11 @@ function buildHtml({ template, bodyHtml, head, bakedData, revalidate, canonical
358
466
  document.head.appendChild(s);
359
467
  }
360
468
 
361
- // revalidate meta: for CDN or manual CI re-bake logic
362
469
  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
- });
470
+ setMeta(document, { name: "bake-revalidate", content: String(revalidate) });
471
+ setMeta(document, { name: "bake-stamp", content: String(Date.now()) });
371
472
  }
372
473
 
373
- // Baked data: the client can use it as initialData.
374
474
  if (bakedData !== undefined) {
375
475
  const s = document.createElement("script");
376
476
  s.setAttribute("type", "application/json");
@@ -379,11 +479,8 @@ function buildHtml({ template, bodyHtml, head, bakedData, revalidate, canonical
379
479
  document.body.appendChild(s);
380
480
  }
381
481
 
382
- // body: insert baked HTML inside #app.
383
482
  const app = document.getElementById("app");
384
- if (app) {
385
- app.innerHTML = bodyHtml;
386
- }
483
+ if (app) app.innerHTML = bodyHtml;
387
484
 
388
485
  return "<!doctype html>\n" + document.documentElement.outerHTML;
389
486
  }