@madojs/mado 0.9.0 → 0.10.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 (74) hide show
  1. package/AGENTS.md +58 -7
  2. package/CHANGELOG.md +121 -1
  3. package/README.md +21 -5
  4. package/dist/src/component.d.ts +2 -12
  5. package/dist/src/component.js +2 -29
  6. package/dist/src/component.js.map +1 -1
  7. package/dist/src/diagnostics.d.ts +0 -4
  8. package/dist/src/diagnostics.js +1 -0
  9. package/dist/src/diagnostics.js.map +1 -1
  10. package/dist/src/html/bindings.js +3 -0
  11. package/dist/src/html/bindings.js.map +1 -1
  12. package/dist/src/html/template.js +10 -0
  13. package/dist/src/html/template.js.map +1 -1
  14. package/dist/src/resource.d.ts +3 -6
  15. package/dist/src/resource.js +59 -10
  16. package/dist/src/resource.js.map +1 -1
  17. package/dist/src/router/manifest.d.ts +0 -3
  18. package/dist/src/router/manifest.js +1 -0
  19. package/dist/src/router/manifest.js.map +1 -1
  20. package/dist/src/router.d.ts +1 -1
  21. package/dist/src/router.js +1 -1
  22. package/dist/src/router.js.map +1 -1
  23. package/dist/src/signal.d.ts +0 -4
  24. package/dist/src/signal.js +1 -0
  25. package/dist/src/signal.js.map +1 -1
  26. package/docs/en/02-project-layout.md +3 -2
  27. package/docs/en/03-static-bake.md +1 -2
  28. package/docs/en/06-for-backenders.md +5 -0
  29. package/docs/en/08-llm-zero-history-test.md +5 -0
  30. package/docs/en/13-deployment.md +10 -7
  31. package/docs/en/16-bake-cookbook.md +10 -2
  32. package/docs/en/18-api-freeze-map.md +63 -0
  33. package/docs/en/19-reactivity-ordering.md +93 -0
  34. package/docs/en/20-v1-stability.md +83 -0
  35. package/docs/en/README.md +3 -0
  36. package/docs/fr/02-project-layout.md +20 -13
  37. package/docs/fr/03-static-bake.md +1 -2
  38. package/docs/fr/06-for-backenders.md +6 -0
  39. package/docs/fr/08-llm-zero-history-test.md +5 -0
  40. package/docs/fr/13-deployment.md +33 -12
  41. package/docs/fr/16-bake-cookbook.md +57 -4
  42. package/docs/fr/18-api-freeze-map.md +63 -0
  43. package/docs/fr/19-reactivity-ordering.md +97 -0
  44. package/docs/fr/20-v1-stability.md +88 -0
  45. package/docs/fr/README.md +3 -0
  46. package/docs/ru/02-project-layout.md +22 -15
  47. package/docs/ru/03-static-bake.md +2 -3
  48. package/docs/ru/06-for-backenders.md +6 -0
  49. package/docs/ru/08-llm-zero-history-test.md +5 -0
  50. package/docs/ru/13-deployment.md +23 -13
  51. package/docs/ru/16-bake-cookbook.md +42 -8
  52. package/docs/ru/18-api-freeze-map.md +62 -0
  53. package/docs/ru/19-reactivity-ordering.md +95 -0
  54. package/docs/ru/20-v1-stability.md +82 -0
  55. package/docs/ru/README.md +3 -0
  56. package/docs/uk/06-for-backenders.md +5 -0
  57. package/docs/uk/08-llm-zero-history-test.md +5 -0
  58. package/docs/uk/18-api-freeze-map.md +61 -0
  59. package/docs/uk/19-reactivity-ordering.md +95 -0
  60. package/docs/uk/20-v1-stability.md +83 -0
  61. package/docs/uk/README.md +3 -0
  62. package/llms.txt +59 -5
  63. package/package.json +8 -3
  64. package/scripts/bake.mjs +4 -2
  65. package/scripts/cli.mjs +83 -5
  66. package/scripts/llm-zero-history-smoke.mjs +93 -0
  67. package/scripts/new.mjs +1 -1
  68. package/scripts/package-smoke.mjs +74 -0
  69. package/scripts/preview.mjs +7 -27
  70. package/scripts/size-budget.mjs +88 -0
  71. package/starters/admin/README.md +2 -2
  72. package/starters/admin/package.json +2 -2
  73. package/starters/crud/package.json +2 -2
  74. package/starters/minimal/package.json +2 -2
@@ -0,0 +1,83 @@
1
+ # Стабільність v1
2
+
3
+ > Що Mado обіцяє після v1, і що лишається вільним для розвитку.
4
+
5
+ Mado v1 означає, що публічний app-facing contract достатньо стабільний для
6
+ реальних business apps. Це не означає, що кожен internal file, generated byte,
7
+ starter copy або diagnostic string заморожені назавжди.
8
+
9
+ Читайте разом із:
10
+
11
+ - [Карта замороження API](./18-api-freeze-map.md)
12
+ - [Порядок reactivity](./19-reactivity-ordering.md)
13
+
14
+ ## Стабільно під SemVer
15
+
16
+ Після v1 Mado вважає SemVer-protected:
17
+
18
+ - Public exports з `@madojs/mado`.
19
+ - Public TypeScript types з `@madojs/mado`.
20
+ - Side-effect subpath `@madojs/mado/devtools.js`.
21
+ - Template binding syntax: child `${}`, `@event`, `.prop`, `?boolean`,
22
+ attribute bindings, directives і `each()`.
23
+ - Signal semantics, описані в reactivity ordering guide.
24
+ - Component lifecycle semantics: setup один раз за connection lifetime,
25
+ deferred teardown для same-tick moves, cleanup через `ctx.onDispose`.
26
+ - Router/page/resource/form contracts, описані в English docs.
27
+ - Імена CLI commands і широкий сенс команд (`build`, `dev`, `release`, `bake`,
28
+ `bundle`, `preview`, `init`, `new`).
29
+
30
+ Ламати це можна тільки в major version.
31
+
32
+ ## Дозволено в minor releases
33
+
34
+ Minor releases можуть додавати:
35
+
36
+ - New root exports.
37
+ - New options на наявних API.
38
+ - New diagnostics і warnings.
39
+ - New starters, examples, docs і CLI flags.
40
+ - Performance improvements і internal implementation rewrites.
41
+
42
+ Minor release не має вимагати змін у вже коректних apps.
43
+
44
+ ## Дозволено в patch releases
45
+
46
+ Patch releases можуть виправляти bugs, посилювати diagnostics, покращувати docs
47
+ і робити сумісні implementation changes. Patch може змінити timing тільки якщо
48
+ старий timing був незадокументованим bug і зміна зберігає reactivity ordering
49
+ contract.
50
+
51
+ ## Нестабільно
52
+
53
+ Це навмисно не захищено SemVer:
54
+
55
+ - Internal package subpaths крім `@madojs/mado/devtools.js`.
56
+ - Файли під `src/`, `dist/src/` і implementation module boundaries.
57
+ - `_testHooks`, diagnostics internals і warning codes.
58
+ - Точний generated JavaScript text, chunk names, sourcemap content і bundle
59
+ byte layout.
60
+ - Internal parser, binding, router і resource cache data structures.
61
+ - Starter app visual copy і demo data.
62
+
63
+ Apps не повинні імпортувати internal files або перевіряти точний bundle output.
64
+
65
+ ## Bundle і release output
66
+
67
+ Mado триматиме size budget і deterministic release tests, але v1 stability не
68
+ заморожує byte-for-byte bundler output. Hashes, chunk boundaries і generated
69
+ asset names можуть змінюватися, якщо задокументований deployment contract
70
+ продовжує працювати.
71
+
72
+ ## Якщо release вас зламав
73
+
74
+ Якщо update ламає код, який використовує тільки public exports і
75
+ задокументовану поведінку, вважайте це bug. Відкрийте issue з:
76
+
77
+ - версією Mado до і після;
78
+ - задіяним public API;
79
+ - мінімальною репродукцією;
80
+ - чи це runtime behaviour, TypeScript types, CLI output або docs.
81
+
82
+ Якщо поломка залежить від internal subpath або точного generated output, про це
83
+ все одно варто повідомити, але це не вважається SemVer break.
package/docs/uk/README.md CHANGED
@@ -22,3 +22,6 @@
22
22
  | Обробка помилок | [15-error-handling.md](./15-error-handling.md) |
23
23
  | Рецепти bake | [16-bake-cookbook.md](./16-bake-cookbook.md) |
24
24
  | Shadow DOM + форми | [17-shadow-dom-forms.md](./17-shadow-dom-forms.md) |
25
+ | Карта замороження API | [18-api-freeze-map.md](./18-api-freeze-map.md) |
26
+ | Порядок reactivity | [19-reactivity-ordering.md](./19-reactivity-ordering.md) |
27
+ | Стабільність v1 | [20-v1-stability.md](./20-v1-stability.md) |
package/llms.txt CHANGED
@@ -1,7 +1,7 @@
1
1
  # Mado
2
2
 
3
3
  > A calm browser-native SPA framework for internal tools, admin panels and business apps.
4
- > Routing, forms, state, data fetching and prerendering. TypeScript-only build (`tsc`), zero runtime dependencies.
4
+ > Routing, forms, state, data fetching and prerendering. Zero runtime dependencies; generated apps use `typescript`, `esbuild` and `linkedom` as dev tooling.
5
5
 
6
6
  Mado is a focused frontend framework for admin panels, internal tools and CRUD-heavy SPA. It deliberately avoids React patterns (no JSX, no hooks, no VDOM, no Vite). Target audience: backend developers and small teams who want a complete app stack without frontend infrastructure overhead.
7
7
 
@@ -15,7 +15,10 @@ Mado is a focused frontend framework for admin panels, internal tools and CRUD-h
15
15
  - **Cleanup via `ctx.onDispose(fn)`** in setup, not via return from effect.
16
16
  - **Page cleanup via `onDispose`** in view: `view: ({ onDispose }) => { ... onDispose(() => cleanup()); }`. Only needed for raw APIs (setInterval, WebSocket). `resource()`/`effect()` auto-cleanup.
17
17
  - **`untracked()` in page view async** — functions called synchronously in `view()` that read signals must wrap reads in `untracked()` to avoid effect cycles with the router.
18
- - **Reactive attributes via `ctx.attr(name, default?)`** — returns a Signal<string> that auto-updates when the attribute changes. Uses `observedAttributes` when available, falls back to a per-instance `MutationObserver` for attrs registered during setup. No boilerplate needed.
18
+ - **Reactive attributes via `ctx.attr(name, default?)`** — returns a Signal<string> that auto-updates when the attribute changes via a per-instance `MutationObserver`. No `observedAttributes` option, no boilerplate.
19
+ - **Public imports only.** App code imports from `@madojs/mado` and optionally side-effect `@madojs/mado/devtools.js`. Other package subpaths and `dist/src/*` are internal.
20
+ - **Layouts are stateless wrappers.** Use route-manifest `layout()` and render `${child}` inside shared chrome. Put per-page state in pages/components/resources, not in layout view locals keyed by route identity.
21
+ - **Bake is a static meta-shell/prerender pass.** It is not SSR with hydration and not a Next-style SSG runtime.
19
22
 
20
23
  ## Critical template rules
21
24
 
@@ -47,6 +50,24 @@ Mado is a focused frontend framework for admin panels, internal tools and CRUD-h
47
50
  html`<ul>${() => each(users(), u => u.id, u => html`<li>${u.name}</li>`)}</ul>`
48
51
  ```
49
52
 
53
+ 4. **Parser hard errors:**
54
+ - no dynamic `${...}` child slots inside `<script>`, `<style>`, `<textarea>`, or `<title>`;
55
+ - no nested SVG-only `html` templates inside an outer `<svg>`.
56
+
57
+ ```ts
58
+ // ❌ throws: RAW_TEXT elements cannot host child slots
59
+ html`<textarea>${draft}</textarea>`;
60
+
61
+ // ✅ bind the DOM property instead
62
+ html`<textarea .value=${draft}></textarea>`;
63
+
64
+ // ❌ throws: nested SVG template loses namespace context
65
+ html`<svg>${html`<circle r="5"></circle>`}</svg>`;
66
+
67
+ // ✅ keep SVG internals in one template
68
+ html`<svg viewBox="0 0 10 10"><circle r="5"></circle></svg>`;
69
+ ```
70
+
50
71
  ## Canonical imports
51
72
 
52
73
  ```ts
@@ -114,6 +135,35 @@ const stats = resource(() => "/api/admin/stats", apiFetcher<Stats>());
114
135
  Unlike `jsonFetcher()`, `apiFetcher()` attaches the Bearer token from memory.
115
136
  Use `jsonFetcher()` for public endpoints, `apiFetcher()` for anything behind auth.
116
137
 
138
+ ## Resource and mutation semantics
139
+
140
+ - A `resource()` key is the cache identity. Same key means shared cache and
141
+ deduped in-flight request; use distinct keys for distinct data or auth scope.
142
+ - `mutation().run()` is concurrent by default. `loading()` stays true while any
143
+ run is in flight. Use `{ abortPrevious: true }` only for search-as-you-type or
144
+ "latest request wins" flows.
145
+ - `invalidates` runs after a successful mutation and is best-effort: errors are
146
+ logged, but the mutation result stays successful.
147
+
148
+ ## Layouts and bake
149
+
150
+ Use `layout()` in `routes.ts` for shared shells. A layout view should be a pure
151
+ wrapper around `${child}` and shared chrome:
152
+
153
+ ```ts
154
+ export default page({
155
+ view: ({ child }) => html`<x-app-shell>${child}</x-app-shell>`,
156
+ });
157
+ ```
158
+
159
+ Do not create route-specific state in layout view locals. Put it in pages,
160
+ components, resources, or app-level contexts.
161
+
162
+ `mado bake` renders selected routes to static HTML for SEO and first paint. It
163
+ does not hydrate server-rendered code. Baked views must be deterministic from
164
+ `params`, `bake.data`, and plain values. Avoid browser-only effects, timers,
165
+ relative `fetch`, and runtime directives like keyed `each()` during bake.
166
+
117
167
  ## Canonical "Hello world"
118
168
 
119
169
  ```ts
@@ -201,6 +251,9 @@ export default page({
201
251
  - docs/en/15-error-handling.md — route/data/action error boundaries
202
252
  - docs/en/16-bake-cookbook.md — static bake recipes and failure modes
203
253
  - docs/en/17-shadow-dom-forms.md — Shadow DOM + useForm() patterns (proxy properties, form submit bridge)
254
+ - docs/en/18-api-freeze-map.md — stable public API vs internal implementation details
255
+ - docs/en/19-reactivity-ordering.md — signal ordering, batching and teardown guarantees
256
+ - docs/en/20-v1-stability.md — v1 SemVer contract and what remains internal
204
257
  - examples/basic/ — minimal API tour
205
258
  - examples/tickets/ — LLM zero-history CRUD validation
206
259
  - examples/showcase/ — flagship CRM pressure app (auth, nested routes, forms, mutations)
@@ -210,7 +263,7 @@ export default page({
210
263
 
211
264
  - ❌ JSX → tagged templates instead
212
265
  - ❌ Virtual DOM → fine-grained signal updates
213
- - ❌ SSR with hydration → `bake` (static build) or edge-prerender for SEO
266
+ - ❌ SSR with hydration → `bake` static meta-shell or edge-prerender for SEO
214
267
  - ❌ Hooks and rules of hooks → signals
215
268
  - ❌ Mandatory Webpack/Vite → only `tsc`
216
269
  - ❌ React-Router / TanStack → built-in 500-line `routes()`
@@ -221,8 +274,9 @@ export default page({
221
274
 
222
275
  ## Version
223
276
 
224
- `0.8.0` — pre-1.0 product-surface release. API may still change before 1.0.
225
- Semver is not guaranteed on minor versions before 1.0.
277
+ `0.10.0` — pre-1.0 API-lock release. Phase B closed the public surface,
278
+ package exports, docs and CI gates. SemVer is not guaranteed on minor versions
279
+ before 1.0.
226
280
 
227
281
  ## License
228
282
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@madojs/mado",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "Mado — a calm browser-native SPA framework for internal tools, admin panels and business apps. Routing, forms, state and data fetching without frontend infrastructure overhead.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,8 +36,10 @@
36
36
  "types": "./dist/src/index.d.ts",
37
37
  "import": "./dist/src/index.js"
38
38
  },
39
- "./devtools.js": "./dist/src/devtools.js",
40
- "./*": "./dist/src/*"
39
+ "./devtools.js": {
40
+ "types": "./dist/src/devtools.d.ts",
41
+ "import": "./dist/src/devtools.js"
42
+ }
41
43
  },
42
44
  "files": [
43
45
  "dist/src",
@@ -66,6 +68,9 @@
66
68
  "test:browser": "node scripts/cli.mjs test browser",
67
69
  "new": "node scripts/cli.mjs new",
68
70
  "examples": "node scripts/cli.mjs examples",
71
+ "size": "node scripts/size-budget.mjs",
72
+ "package:smoke": "node scripts/package-smoke.mjs",
73
+ "llm:smoke": "node scripts/llm-zero-history-smoke.mjs",
69
74
  "test": "node scripts/cli.mjs test",
70
75
  "typecheck": "node scripts/cli.mjs typecheck",
71
76
  "clean": "rm -rf dist out",
package/scripts/bake.mjs CHANGED
@@ -522,7 +522,6 @@ function buildHtml({ template, bodyHtml, head, bakedData, revalidate, canonical
522
522
 
523
523
  if (revalidate) {
524
524
  setMeta(document, { name: "bake-revalidate", content: String(revalidate) });
525
- setMeta(document, { name: "bake-stamp", content: String(Date.now()) });
526
525
  }
527
526
 
528
527
  if (bakedData !== undefined) {
@@ -534,7 +533,10 @@ function buildHtml({ template, bodyHtml, head, bakedData, revalidate, canonical
534
533
  }
535
534
 
536
535
  const app = document.getElementById("app");
537
- if (app) app.innerHTML = bodyHtml;
536
+ if (app) {
537
+ app.setAttribute("data-mado-baked", "");
538
+ app.innerHTML = bodyHtml;
539
+ }
538
540
 
539
541
  return "<!doctype html>\n" + document.documentElement.outerHTML;
540
542
  }
package/scripts/cli.mjs CHANGED
@@ -144,6 +144,7 @@ async function runInit(rawArgs) {
144
144
  await cp(source, target, { recursive: true, force: true });
145
145
  await copyCanonicalAgentFiles(target);
146
146
  await ensureStarterGitignore(target);
147
+ await ensureStarterPackageJson(target);
147
148
 
148
149
  const packageName = packageNameFromDir(target);
149
150
  if (!isValidPackageName(packageName)) {
@@ -240,15 +241,28 @@ async function runRelease(rawArgs) {
240
241
  // → rm -rf out/ (unless --no-clean)
241
242
  // → mado typecheck
242
243
  // → mado build (tsc → dist/)
243
- // → mado bundle (esbuild → out/assets/, also copies index.html)
244
- // → mado bake (HTML → out/baked/)
244
+ // → mado bundle (esbuild → out/assets/, also writes out/index.html)
245
+ // → mado bake (HTML → out/baked/, using bundled out/index.html)
245
246
  // → copy public/* → out/
247
+ // → promote baked HTML + sitemap into out/ route paths
246
248
  //
247
249
  // Flags are forwarded to bake/bundle.
248
250
  const { flags: releaseFlags } = parseFlags(rawArgs);
249
251
  const cfg = loadConfig({ projectRoot: PROJECT_ROOT });
250
- const outDir = resolve(cfg.projectRoot, cfg.build.out ?? "out");
252
+ const outDir = resolve(
253
+ cfg.projectRoot,
254
+ typeof releaseFlags.out === "string" ? releaseFlags.out : cfg.build.out ?? "out",
255
+ );
251
256
  const publicDir = resolve(cfg.projectRoot, cfg.build.publicDir ?? "public");
257
+ const bundledHtml = join(outDir, "index.html");
258
+ const bakedDir = resolve(
259
+ cfg.projectRoot,
260
+ cfg.bake.outDir ??
261
+ join(
262
+ typeof releaseFlags.out === "string" ? releaseFlags.out : cfg.build.out ?? "out",
263
+ "baked",
264
+ ),
265
+ );
252
266
 
253
267
  console.log(`[release] context: ${cfg.context}`);
254
268
  console.log(`[release] artifact: ${outDir}`);
@@ -275,8 +289,14 @@ async function runRelease(rawArgs) {
275
289
  console.log("[release] step 3/5 bundle (esbuild → out/assets/)");
276
290
  await runNodeScript("scripts/bundle.mjs", rawArgs);
277
291
 
278
- console.log("[release] step 4/5 bake (out/baked/)");
279
- await runNodeScript("scripts/bake.mjs", rawArgs);
292
+ console.log("[release] step 4/5 bake (out/baked/, bundled shell)");
293
+ await runNodeScript("scripts/bake.mjs", [
294
+ ...rawArgs,
295
+ "--template",
296
+ bundledHtml,
297
+ "--out",
298
+ bakedDir,
299
+ ]);
280
300
 
281
301
  console.log("[release] step 5/5 copy public/ → out/");
282
302
  if (existsSync(publicDir)) {
@@ -287,6 +307,14 @@ async function runRelease(rawArgs) {
287
307
  console.log(`[release] no ${publicDir}, skipping`);
288
308
  }
289
309
 
310
+ const promoted = await promoteBakedHtml(bakedDir, outDir);
311
+ if (promoted.html > 0) {
312
+ console.log(`[release] promoted ${promoted.html} baked HTML page(s) into out/`);
313
+ }
314
+ if (promoted.sitemap) {
315
+ console.log(`[release] copied sitemap.xml → ${join(outDir, "sitemap.xml")}`);
316
+ }
317
+
290
318
  // Optional CDN config files. Generated only when not already provided.
291
319
  await writeIfMissing(
292
320
  join(outDir, "_redirects"),
@@ -318,6 +346,40 @@ async function writeIfMissing(path, content) {
318
346
  console.log(`[release] wrote ${path}`);
319
347
  }
320
348
 
349
+ async function promoteBakedHtml(bakedDir, outDir) {
350
+ if (!existsSync(bakedDir)) return { html: 0, sitemap: false };
351
+
352
+ let html = 0;
353
+
354
+ async function walk(dir, rel = "") {
355
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
356
+ const nextRel = rel ? `${rel}/${entry.name}` : entry.name;
357
+ const source = join(dir, entry.name);
358
+ if (entry.isDirectory()) {
359
+ await walk(source, nextRel);
360
+ continue;
361
+ }
362
+ if (!entry.isFile() || !entry.name.endsWith(".html")) continue;
363
+ const target = join(outDir, nextRel);
364
+ await mkdir(dirname(target), { recursive: true });
365
+ await copyFile(source, target);
366
+ html++;
367
+ }
368
+ }
369
+
370
+ await walk(bakedDir);
371
+
372
+ const bakedSitemap = join(bakedDir, "sitemap.xml");
373
+ const rootSitemap = join(outDir, "sitemap.xml");
374
+ let sitemap = false;
375
+ if (existsSync(bakedSitemap)) {
376
+ await copyFile(bakedSitemap, rootSitemap);
377
+ sitemap = true;
378
+ }
379
+
380
+ return { html, sitemap };
381
+ }
382
+
321
383
  async function copyCanonicalAgentFiles(target) {
322
384
  for (const file of ["AGENTS.md", "llms.txt"]) {
323
385
  const source = join(PACKAGE_ROOT, file);
@@ -331,6 +393,22 @@ async function ensureStarterGitignore(target) {
331
393
  await writeFile(file, "node_modules\ndist\nout\n.DS_Store\n*.log\n");
332
394
  }
333
395
 
396
+ async function ensureStarterPackageJson(target) {
397
+ const file = join(target, "package.json");
398
+ if (!existsSync(file)) return;
399
+
400
+ const pkg = JSON.parse(await readFile(file, "utf8"));
401
+ const rootDev = PACKAGE_JSON.devDependencies ?? {};
402
+ pkg.devDependencies = {
403
+ ...(pkg.devDependencies ?? {}),
404
+ typescript: rootDev.typescript ?? "^6.0.3",
405
+ esbuild: rootDev.esbuild ?? "^0.28.0",
406
+ linkedom: rootDev.linkedom ?? "^0.18.12",
407
+ };
408
+
409
+ await writeFile(file, `${JSON.stringify(pkg, null, 2)}\n`);
410
+ }
411
+
334
412
  async function runNodeBin(bin, args) {
335
413
  await run(process.execPath, [resolveBin(bin), ...args]);
336
414
  }
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFile } from "node:child_process";
4
+ import { readdir, readFile, stat } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { promisify } from "node:util";
7
+
8
+ const exec = promisify(execFile);
9
+ const root = process.cwd();
10
+ const ticketsDir = join(root, "examples", "tickets");
11
+
12
+ const llms = await read("llms.txt");
13
+ assertIncludes(llms, "This is NOT React", "llms.txt must keep the React warning");
14
+ assertIncludes(llms, "Canonical CRUD pattern", "llms.txt must keep the CRUD recipe");
15
+ assertIncludes(llms, "resource()", "llms.txt must document resource()");
16
+ assertIncludes(llms, "mutation", "llms.txt must document mutation()");
17
+ assertIncludes(llms, "useForm", "llms.txt must document useForm()");
18
+
19
+ const files = await collectTs(ticketsDir);
20
+ const code = (await Promise.all(files.map((file) => read(file)))).join("\n");
21
+ const routes = await read(join(ticketsDir, "routes.ts"));
22
+
23
+ for (const route of ['"/"', '"/tickets"', '"/tickets/new"', '"/tickets/:id"', '"*"']) {
24
+ assertIncludes(routes, route, `tickets routes must include ${route}`);
25
+ }
26
+
27
+ for (const api of [
28
+ "component(",
29
+ "html`",
30
+ "signal(",
31
+ "computed(",
32
+ "resource(",
33
+ "mutation(",
34
+ "invalidates",
35
+ "queryParam(",
36
+ "each(",
37
+ "useForm(",
38
+ ]) {
39
+ assertIncludes(code, api, `tickets example must exercise ${api}`);
40
+ }
41
+
42
+ const forbidden = [
43
+ /\buseState\s*\(/,
44
+ /\buseEffect\s*\(/,
45
+ /\$state\b/,
46
+ /\bref\s*\(/,
47
+ /from\s+["']react["']/,
48
+ /class\s+\w+\s+extends\s+HTMLElement/,
49
+ /<>\s*$/,
50
+ /(^|[^?.\w-])disabled=\$\{/,
51
+ /(^|[^?.\w-])checked=\$\{/,
52
+ ];
53
+
54
+ for (const pattern of forbidden) {
55
+ if (pattern.test(code)) {
56
+ throw new Error(`[llm-smoke] forbidden generated pattern: ${pattern}`);
57
+ }
58
+ }
59
+
60
+ await run(process.execPath, ["scripts/cli.mjs", "build"]);
61
+ await run(process.execPath, ["--test", "test/tickets-smoke.test.mjs"]);
62
+
63
+ console.log("[llm-smoke] ok examples/tickets follows llms.txt and passes smoke");
64
+
65
+ async function collectTs(dir) {
66
+ const out = [];
67
+ for (const entry of await readdir(dir)) {
68
+ const file = join(dir, entry);
69
+ const s = await stat(file);
70
+ if (s.isDirectory()) out.push(...await collectTs(file));
71
+ else if (file.endsWith(".ts")) out.push(file);
72
+ }
73
+ return out.sort();
74
+ }
75
+
76
+ async function read(file) {
77
+ return readFile(file, "utf8");
78
+ }
79
+
80
+ function assertIncludes(text, needle, message) {
81
+ if (!text.includes(needle)) throw new Error(`[llm-smoke] ${message}`);
82
+ }
83
+
84
+ async function run(cmd, args) {
85
+ console.log(`[llm-smoke] ${cmd} ${args.join(" ")}`);
86
+ try {
87
+ await exec(cmd, args, { cwd: root, maxBuffer: 20 * 1024 * 1024 });
88
+ } catch (err) {
89
+ if (err.stdout) process.stdout.write(err.stdout);
90
+ if (err.stderr) process.stderr.write(err.stderr);
91
+ throw err;
92
+ }
93
+ }
package/scripts/new.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
  // Result: examples/pages/<name>.ts (or src/pages/, when present)
8
8
  // with __name__ / __Name__ placeholders replaced.
9
9
  //
10
- // Zero dependencies.
10
+ // Zero runtime dependencies; generated apps use dev tooling only.
11
11
 
12
12
  import { readFile, writeFile, mkdir, access } from "node:fs/promises";
13
13
  import { dirname, join, resolve } from "node:path";
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFile } from "node:child_process";
4
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { basename, join, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { promisify } from "node:util";
9
+
10
+ const exec = promisify(execFile);
11
+ const repoRoot = resolve(fileURLToPath(new URL("..", import.meta.url)));
12
+ const tempRoot = await mkdtemp(join(tmpdir(), "mado-package-smoke-"));
13
+ let tarball = "";
14
+
15
+ try {
16
+ const packed = await exec("npm", ["pack", "--silent"], { cwd: repoRoot });
17
+ tarball = resolve(repoRoot, packed.stdout.trim().split(/\s+/).at(-1) ?? "");
18
+ if (!tarball) throw new Error("[package-smoke] npm pack did not return a tarball");
19
+
20
+ const installRoot = join(tempRoot, "installed");
21
+ await mkdir(installRoot, { recursive: true });
22
+ await run("npm", ["install", tarball], { cwd: installRoot });
23
+
24
+ await run(
25
+ process.execPath,
26
+ [
27
+ "--input-type=module",
28
+ "--eval",
29
+ `
30
+ import { html, signal } from "@madojs/mado";
31
+ import "@madojs/mado/devtools.js";
32
+ if (typeof html !== "function" || typeof signal !== "function") {
33
+ throw new Error("public root import failed");
34
+ }
35
+ try {
36
+ await import("@madojs/mado/lifecycle.js");
37
+ throw new Error("internal lifecycle subpath unexpectedly resolved");
38
+ } catch (err) {
39
+ if (err?.code !== "ERR_PACKAGE_PATH_NOT_EXPORTED") throw err;
40
+ }
41
+ `,
42
+ ],
43
+ { cwd: installRoot },
44
+ );
45
+
46
+ await run("npx", ["mado", "init", "smoke-app", "--starter", "minimal"], {
47
+ cwd: installRoot,
48
+ env: { ...process.env, MADO_PACKAGE_SPEC: tarball },
49
+ });
50
+
51
+ const appRoot = join(installRoot, "smoke-app");
52
+ await run("npm", ["install"], { cwd: appRoot });
53
+ await run("npm", ["run", "release"], { cwd: appRoot });
54
+
55
+ console.log(`[package-smoke] ok ${basename(tarball)}`);
56
+ } finally {
57
+ await rm(tempRoot, { recursive: true, force: true });
58
+ if (tarball) await rm(tarball, { force: true });
59
+ }
60
+
61
+ async function run(cmd, args, options) {
62
+ console.log(`[package-smoke] ${cmd} ${args.join(" ")}`);
63
+ try {
64
+ await exec(cmd, args, {
65
+ cwd: options.cwd,
66
+ env: options.env ?? process.env,
67
+ maxBuffer: 20 * 1024 * 1024,
68
+ });
69
+ } catch (err) {
70
+ if (err.stdout) process.stdout.write(err.stdout);
71
+ if (err.stderr) process.stderr.write(err.stderr);
72
+ throw err;
73
+ }
74
+ }
@@ -11,7 +11,7 @@
11
11
  // 3. Starts a static server with:
12
12
  // - immutable cache for hashed bundles;
13
13
  // - SPA fallback to index.html;
14
- // - baked HTML priority over the SPA shell;
14
+ // - exact `out/` route files before SPA fallback;
15
15
  // - precompressed .gz / .br serving via Accept-Encoding.
16
16
  //
17
17
  // Goal: see production-like output locally without Docker/nginx, identical to
@@ -58,9 +58,9 @@ const OUT = resolve(
58
58
  process.env.OUT_DIR ?? cfg.build.out ?? "out",
59
59
  );
60
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>.
61
+ // mado.config.json bake.outDir). `mado release` promotes those HTML files into
62
+ // real route paths inside <out>/, so preview can serve exactly what a static
63
+ // host sees instead of applying a preview-only virtual mapping.
64
64
  const BAKED = resolve(
65
65
  ROOT,
66
66
  process.env.BAKED_DIR ?? cfg.bake?.outDir ?? join(cfg.build.out ?? "out", "baked"),
@@ -215,30 +215,10 @@ function basenameSafe(p) {
215
215
  async function resolveTarget(pathname) {
216
216
  if (pathname === "/") pathname = "/index.html";
217
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
-
238
218
  const candidate = resolve(join(OUT, pathname));
239
219
  if (!candidate.startsWith(OUT + sep) && candidate !== OUT) return null;
240
220
 
241
- // 2) Exact match inside out/.
221
+ // 1) Exact match inside out/.
242
222
  if (await exists(candidate)) {
243
223
  const s = await stat(candidate);
244
224
  if (s.isDirectory()) {
@@ -249,13 +229,13 @@ async function resolveTarget(pathname) {
249
229
  }
250
230
  }
251
231
 
252
- // 3) /foo → /foo/index.html (for sub-folders without trailing slash).
232
+ // 2) /foo → /foo/index.html (for sub-folders without trailing slash).
253
233
  if (!extname(pathname)) {
254
234
  const asDir = join(OUT, pathname, "index.html");
255
235
  if (await exists(asDir)) return asDir;
256
236
  }
257
237
 
258
- // 4) SPA-fallback: any non-asset path falls back to the SPA shell so
238
+ // 3) SPA-fallback: any non-asset path falls back to the SPA shell so
259
239
  // client-side routing handles it. Asset-looking paths (with an
260
240
  // extension) deliberately 404 instead — otherwise a 200 on
261
241
  // /missing.png would mask real bugs.