@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.
- package/AGENTS.md +58 -7
- package/CHANGELOG.md +121 -1
- package/README.md +21 -5
- package/dist/src/component.d.ts +2 -12
- package/dist/src/component.js +2 -29
- package/dist/src/component.js.map +1 -1
- package/dist/src/diagnostics.d.ts +0 -4
- package/dist/src/diagnostics.js +1 -0
- package/dist/src/diagnostics.js.map +1 -1
- package/dist/src/html/bindings.js +3 -0
- package/dist/src/html/bindings.js.map +1 -1
- package/dist/src/html/template.js +10 -0
- package/dist/src/html/template.js.map +1 -1
- package/dist/src/resource.d.ts +3 -6
- package/dist/src/resource.js +59 -10
- package/dist/src/resource.js.map +1 -1
- package/dist/src/router/manifest.d.ts +0 -3
- package/dist/src/router/manifest.js +1 -0
- package/dist/src/router/manifest.js.map +1 -1
- package/dist/src/router.d.ts +1 -1
- package/dist/src/router.js +1 -1
- package/dist/src/router.js.map +1 -1
- package/dist/src/signal.d.ts +0 -4
- package/dist/src/signal.js +1 -0
- package/dist/src/signal.js.map +1 -1
- package/docs/en/02-project-layout.md +3 -2
- package/docs/en/03-static-bake.md +1 -2
- package/docs/en/06-for-backenders.md +5 -0
- package/docs/en/08-llm-zero-history-test.md +5 -0
- package/docs/en/13-deployment.md +10 -7
- package/docs/en/16-bake-cookbook.md +10 -2
- package/docs/en/18-api-freeze-map.md +63 -0
- package/docs/en/19-reactivity-ordering.md +93 -0
- package/docs/en/20-v1-stability.md +83 -0
- package/docs/en/README.md +3 -0
- package/docs/fr/02-project-layout.md +20 -13
- package/docs/fr/03-static-bake.md +1 -2
- package/docs/fr/06-for-backenders.md +6 -0
- package/docs/fr/08-llm-zero-history-test.md +5 -0
- package/docs/fr/13-deployment.md +33 -12
- package/docs/fr/16-bake-cookbook.md +57 -4
- package/docs/fr/18-api-freeze-map.md +63 -0
- package/docs/fr/19-reactivity-ordering.md +97 -0
- package/docs/fr/20-v1-stability.md +88 -0
- package/docs/fr/README.md +3 -0
- package/docs/ru/02-project-layout.md +22 -15
- package/docs/ru/03-static-bake.md +2 -3
- package/docs/ru/06-for-backenders.md +6 -0
- package/docs/ru/08-llm-zero-history-test.md +5 -0
- package/docs/ru/13-deployment.md +23 -13
- package/docs/ru/16-bake-cookbook.md +42 -8
- package/docs/ru/18-api-freeze-map.md +62 -0
- package/docs/ru/19-reactivity-ordering.md +95 -0
- package/docs/ru/20-v1-stability.md +82 -0
- package/docs/ru/README.md +3 -0
- package/docs/uk/06-for-backenders.md +5 -0
- package/docs/uk/08-llm-zero-history-test.md +5 -0
- package/docs/uk/18-api-freeze-map.md +61 -0
- package/docs/uk/19-reactivity-ordering.md +95 -0
- package/docs/uk/20-v1-stability.md +83 -0
- package/docs/uk/README.md +3 -0
- package/llms.txt +59 -5
- package/package.json +8 -3
- package/scripts/bake.mjs +4 -2
- package/scripts/cli.mjs +83 -5
- package/scripts/llm-zero-history-smoke.mjs +93 -0
- package/scripts/new.mjs +1 -1
- package/scripts/package-smoke.mjs +74 -0
- package/scripts/preview.mjs +7 -27
- package/scripts/size-budget.mjs +88 -0
- package/starters/admin/README.md +2 -2
- package/starters/admin/package.json +2 -2
- package/starters/crud/package.json +2 -2
- 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.
|
|
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
|
|
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`
|
|
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.
|
|
225
|
-
|
|
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.
|
|
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":
|
|
40
|
-
|
|
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)
|
|
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
|
|
244
|
-
// → mado bake (HTML
|
|
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(
|
|
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",
|
|
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
|
+
}
|
package/scripts/preview.mjs
CHANGED
|
@@ -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
|
-
// -
|
|
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).
|
|
62
|
-
//
|
|
63
|
-
// instead of
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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.
|