@madojs/mado 0.8.0 → 0.10.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.
- package/AGENTS.md +81 -4
- package/CHANGELOG.md +202 -1
- package/README.md +184 -242
- package/ROADMAP.md +174 -79
- package/TODO.md +8 -5
- package/dist/src/component.d.ts +2 -12
- package/dist/src/component.js +30 -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/forms.js +17 -0
- package/dist/src/forms.js.map +1 -1
- package/dist/src/html/bindings.js +35 -3
- package/dist/src/html/bindings.js.map +1 -1
- package/dist/src/html/parser.js +60 -3
- package/dist/src/html/parser.js.map +1 -1
- package/dist/src/lifecycle.js +18 -0
- package/dist/src/lifecycle.js.map +1 -1
- package/dist/src/persisted.js +43 -9
- package/dist/src/persisted.js.map +1 -1
- package/dist/src/resource.d.ts +13 -6
- package/dist/src/resource.js +83 -16
- package/dist/src/resource.js.map +1 -1
- package/dist/src/router/manifest.d.ts +0 -3
- package/dist/src/router/manifest.js +23 -2
- package/dist/src/router/manifest.js.map +1 -1
- package/dist/src/router/navigation.js +56 -2
- package/dist/src/router/navigation.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 +56 -7
- package/dist/src/signal.js.map +1 -1
- package/docs/en/00-the-mado-way.md +23 -12
- package/docs/en/03-static-bake.md +1 -2
- package/docs/en/05-why-mado.md +78 -68
- package/docs/en/06-for-backenders.md +80 -55
- package/docs/en/07-llm-pitfalls.md +101 -0
- package/docs/en/08-llm-zero-history-test.md +5 -0
- 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/00-the-mado-way.md +25 -13
- package/docs/fr/03-static-bake.md +1 -2
- package/docs/fr/06-for-backenders.md +6 -0
- package/docs/fr/07-llm-pitfalls.md +2 -0
- package/docs/fr/08-llm-zero-history-test.md +5 -0
- 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/00-the-mado-way.md +24 -11
- package/docs/ru/03-static-bake.md +2 -3
- package/docs/ru/06-for-backenders.md +6 -0
- package/docs/ru/07-llm-pitfalls.md +2 -0
- package/docs/ru/08-llm-zero-history-test.md +5 -0
- 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/00-the-mado-way.md +3 -1
- package/docs/uk/06-for-backenders.md +5 -0
- package/docs/uk/07-llm-pitfalls.md +2 -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 +63 -7
- package/package.json +10 -5
- package/scripts/bake.mjs +0 -1
- package/scripts/bundle.mjs +6 -6
- package/scripts/cli.mjs +17 -0
- package/scripts/llm-zero-history-smoke.mjs +93 -0
- package/scripts/new.mjs +1 -1
- package/scripts/package-smoke.mjs +74 -0
- package/scripts/size-budget.mjs +88 -0
- package/starters/admin/package.json +2 -2
- package/starters/crud/package.json +2 -2
- package/starters/minimal/package.json +2 -2
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Порядок reactivity
|
|
2
|
+
|
|
3
|
+
> Малий набір ordering-гарантій, які Mado вважає публічною поведінкою.
|
|
4
|
+
|
|
5
|
+
Reactivity у Mado синхронна для читання і планована для side effects. Мета —
|
|
6
|
+
передбачувані UI updates без великої scheduling-моделі.
|
|
7
|
+
|
|
8
|
+
## Signals
|
|
9
|
+
|
|
10
|
+
`signal(value)` повертає getter-функцію. `set(next)` змінює значення одразу,
|
|
11
|
+
якщо `Object.is(previous, next)` не дорівнює `true`.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
const count = signal(0);
|
|
15
|
+
count.set(1);
|
|
16
|
+
count(); // 1, одразу
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Computed values позначаються до запуску effects, тому effect, який читає
|
|
20
|
+
computed, бачить актуальні dependencies, а не застарілий cache.
|
|
21
|
+
|
|
22
|
+
## Effects
|
|
23
|
+
|
|
24
|
+
`effect(fn)` запускається один раз одразу. Подальші зміни dependencies планують
|
|
25
|
+
один запуск effect у microtask. Тести можуть викликати `flushSync()`, щоб
|
|
26
|
+
синхронно очистити чергу.
|
|
27
|
+
|
|
28
|
+
Якщо effect повертає cleanup-функцію, Mado запускає її перед наступним запуском
|
|
29
|
+
effect і ще раз при виклику disposer.
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
const stop = effect(() => {
|
|
33
|
+
const id = setInterval(tick, 1000);
|
|
34
|
+
return () => clearInterval(id);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
stop();
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
У компонентах і pages для unmount cleanup надавайте перевагу
|
|
41
|
+
`ctx.onDispose()` / page `onDispose()`. Cleanup effect — це cleanup між runs.
|
|
42
|
+
|
|
43
|
+
## Batch
|
|
44
|
+
|
|
45
|
+
`batch(fn)` групує записи signals в один subscriber pass. Effects не
|
|
46
|
+
запускаються до виходу з найзовнішнього batch, включно з вкладеними batches.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
batch(() => {
|
|
50
|
+
first.set("Ada");
|
|
51
|
+
batch(() => last.set("Lovelace"));
|
|
52
|
+
});
|
|
53
|
+
// effects бачать тільки фінальну пару
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Спостережувані `computed({ equals })` також зберігають batch atomicity: вони
|
|
57
|
+
перераховуються один раз після зовнішнього batch на повністю застосованому
|
|
58
|
+
state. Вони не повинні бачити напівзастосований batch на кшталт
|
|
59
|
+
`(new x, old y)`.
|
|
60
|
+
|
|
61
|
+
## DOM updates
|
|
62
|
+
|
|
63
|
+
`render(result, container)` перевикористовує наявний template instance, коли
|
|
64
|
+
наступний render має ті самі template strings. Для child bindings, що
|
|
65
|
+
повертають вкладений `html```, діє те саме правило: ті самі strings оновлюються
|
|
66
|
+
на місці, інші strings перебудовують гілку.
|
|
67
|
+
|
|
68
|
+
Це означає, що непов'язані зміни signals не пересоздають `<input>` у
|
|
69
|
+
стабільному вкладеному template, тому focus, DOM state і listeners
|
|
70
|
+
зберігаються.
|
|
71
|
+
|
|
72
|
+
Списки повинні використовувати `each(items, key, renderItem)`. Keys задають DOM
|
|
73
|
+
identity. Duplicate keys попереджають у development і отримують positional
|
|
74
|
+
suffix, щоб кожен item все одно рендерився, але duplicate keys — це data bug.
|
|
75
|
+
|
|
76
|
+
## Teardown компонентів
|
|
77
|
+
|
|
78
|
+
Custom elements можуть отримати `disconnectedCallback()`, а потім
|
|
79
|
+
`connectedCallback()` під час same-tick move. Mado відкладає teardown компонента
|
|
80
|
+
до microtask і скасовує його при reconnect, тому keyed reorders зберігають
|
|
81
|
+
state компонента. Справжнє видалення все одно запускає lifecycle cleanup на
|
|
82
|
+
наступній microtask.
|
|
83
|
+
|
|
84
|
+
## Не гарантується
|
|
85
|
+
|
|
86
|
+
Mado не гарантує точну кількість internal scheduler microtasks, порядок
|
|
87
|
+
незалежних effects без спільних dependencies, форму generated bundle або
|
|
88
|
+
внутрішній module layout. Це implementation details.
|
|
89
|
+
|
|
90
|
+
Invariant tests для цього контракту:
|
|
91
|
+
|
|
92
|
+
- `test/reactivity-ordering.test.mjs`
|
|
93
|
+
- `test/signal-batch-equals.test.mjs`
|
|
94
|
+
- `test/update-nested-reuse.test.mjs`
|
|
95
|
+
- `test/each-component-state.test.mjs`
|
|
@@ -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,9 +1,9 @@
|
|
|
1
1
|
# Mado
|
|
2
2
|
|
|
3
|
-
>
|
|
4
|
-
>
|
|
3
|
+
> A calm browser-native SPA framework for internal tools, admin panels and business apps.
|
|
4
|
+
> Routing, forms, state, data fetching and prerendering. Zero runtime dependencies; generated apps use `typescript`, `esbuild` and `linkedom` as dev tooling.
|
|
5
5
|
|
|
6
|
-
Mado is a
|
|
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
|
|
|
8
8
|
## Key things an AI assistant needs to know
|
|
9
9
|
|
|
@@ -13,7 +13,12 @@ Mado is a narrowly-focused frontend framework that deliberately avoids React pat
|
|
|
13
13
|
- **Components are Web Components.** Registered via `component('x-name', setupFn, options)`. Names must include a hyphen (`x-foo`, `my-button`).
|
|
14
14
|
- **Component files register tags as side effects.** The browser does not auto-import files by tag name. If `<x-card>` works, some imported module already ran `customElements.define("x-card", ...)`.
|
|
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
|
+
- **`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 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.
|
|
17
22
|
|
|
18
23
|
## Critical template rules
|
|
19
24
|
|
|
@@ -45,6 +50,24 @@ Mado is a narrowly-focused frontend framework that deliberately avoids React pat
|
|
|
45
50
|
html`<ul>${() => each(users(), u => u.id, u => html`<li>${u.name}</li>`)}</ul>`
|
|
46
51
|
```
|
|
47
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
|
+
|
|
48
71
|
## Canonical imports
|
|
49
72
|
|
|
50
73
|
```ts
|
|
@@ -112,6 +135,35 @@ const stats = resource(() => "/api/admin/stats", apiFetcher<Stats>());
|
|
|
112
135
|
Unlike `jsonFetcher()`, `apiFetcher()` attaches the Bearer token from memory.
|
|
113
136
|
Use `jsonFetcher()` for public endpoints, `apiFetcher()` for anything behind auth.
|
|
114
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
|
+
|
|
115
167
|
## Canonical "Hello world"
|
|
116
168
|
|
|
117
169
|
```ts
|
|
@@ -199,6 +251,9 @@ export default page({
|
|
|
199
251
|
- docs/en/15-error-handling.md — route/data/action error boundaries
|
|
200
252
|
- docs/en/16-bake-cookbook.md — static bake recipes and failure modes
|
|
201
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
|
|
202
257
|
- examples/basic/ — minimal API tour
|
|
203
258
|
- examples/tickets/ — LLM zero-history CRUD validation
|
|
204
259
|
- examples/showcase/ — flagship CRM pressure app (auth, nested routes, forms, mutations)
|
|
@@ -208,7 +263,7 @@ export default page({
|
|
|
208
263
|
|
|
209
264
|
- ❌ JSX → tagged templates instead
|
|
210
265
|
- ❌ Virtual DOM → fine-grained signal updates
|
|
211
|
-
- ❌ SSR with hydration → `bake`
|
|
266
|
+
- ❌ SSR with hydration → `bake` static meta-shell or edge-prerender for SEO
|
|
212
267
|
- ❌ Hooks and rules of hooks → signals
|
|
213
268
|
- ❌ Mandatory Webpack/Vite → only `tsc`
|
|
214
269
|
- ❌ React-Router / TanStack → built-in 500-line `routes()`
|
|
@@ -219,8 +274,9 @@ export default page({
|
|
|
219
274
|
|
|
220
275
|
## Version
|
|
221
276
|
|
|
222
|
-
`0.
|
|
223
|
-
|
|
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.
|
|
224
280
|
|
|
225
281
|
## License
|
|
226
282
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@madojs/mado",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Mado — a
|
|
3
|
+
"version": "0.10.0",
|
|
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",
|
|
7
7
|
"keywords": [
|
|
@@ -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",
|
|
@@ -80,4 +85,4 @@
|
|
|
80
85
|
"engines": {
|
|
81
86
|
"node": ">=20"
|
|
82
87
|
}
|
|
83
|
-
}
|
|
88
|
+
}
|
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) {
|
package/scripts/bundle.mjs
CHANGED
|
@@ -120,14 +120,14 @@ const result = await build({
|
|
|
120
120
|
legalComments: "none",
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
)
|
|
126
|
-
|
|
127
|
-
|
|
123
|
+
// With splitting: true, esbuild marks all dynamic-import chunks as having
|
|
124
|
+
// entryPoint. We identify the real app entry by the `entryNames` prefix "main-".
|
|
125
|
+
const mainBundle = (await readdir(ASSETS_DIR))
|
|
126
|
+
.find((f) => f.startsWith("main-") && f.endsWith(".js") && !f.endsWith(".js.map"));
|
|
127
|
+
if (!mainBundle) {
|
|
128
|
+
console.error("[bundle] entry not found in outputs (no main-*.js in assets dir)");
|
|
128
129
|
process.exit(1);
|
|
129
130
|
}
|
|
130
|
-
const mainBundle = basename(entryOutput[0]);
|
|
131
131
|
|
|
132
132
|
// Collect all js chunks in the assets dir.
|
|
133
133
|
const allJs = (await readdir(ASSETS_DIR)).filter((f) => f.endsWith(".js"));
|
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)) {
|
|
@@ -331,6 +332,22 @@ async function ensureStarterGitignore(target) {
|
|
|
331
332
|
await writeFile(file, "node_modules\ndist\nout\n.DS_Store\n*.log\n");
|
|
332
333
|
}
|
|
333
334
|
|
|
335
|
+
async function ensureStarterPackageJson(target) {
|
|
336
|
+
const file = join(target, "package.json");
|
|
337
|
+
if (!existsSync(file)) return;
|
|
338
|
+
|
|
339
|
+
const pkg = JSON.parse(await readFile(file, "utf8"));
|
|
340
|
+
const rootDev = PACKAGE_JSON.devDependencies ?? {};
|
|
341
|
+
pkg.devDependencies = {
|
|
342
|
+
...(pkg.devDependencies ?? {}),
|
|
343
|
+
typescript: rootDev.typescript ?? "^6.0.3",
|
|
344
|
+
esbuild: rootDev.esbuild ?? "^0.28.0",
|
|
345
|
+
linkedom: rootDev.linkedom ?? "^0.18.12",
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
await writeFile(file, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
349
|
+
}
|
|
350
|
+
|
|
334
351
|
async function runNodeBin(bin, args) {
|
|
335
352
|
await run(process.execPath, [resolveBin(bin), ...args]);
|
|
336
353
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { build } from "esbuild";
|
|
4
|
+
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { gzipSync } from "node:zlib";
|
|
8
|
+
|
|
9
|
+
const API_ENTRY = "src/index.ts";
|
|
10
|
+
const SAMPLE_ENTRY = "examples/showcase/main.ts";
|
|
11
|
+
const API_GZIP_LIMIT = readLimit("MADO_SIZE_API_GZIP_LIMIT", 16 * 1024);
|
|
12
|
+
const SAMPLE_GZIP_LIMIT = readLimit("MADO_SIZE_SAMPLE_GZIP_LIMIT", 42 * 1024);
|
|
13
|
+
|
|
14
|
+
let failed = false;
|
|
15
|
+
|
|
16
|
+
const api = await bundlePublicApi();
|
|
17
|
+
report("public API", api.gzip, API_GZIP_LIMIT);
|
|
18
|
+
|
|
19
|
+
const sample = await bundleSampleApp();
|
|
20
|
+
report("showcase app", sample.gzip, SAMPLE_GZIP_LIMIT);
|
|
21
|
+
|
|
22
|
+
if (failed) process.exit(1);
|
|
23
|
+
|
|
24
|
+
async function bundlePublicApi() {
|
|
25
|
+
const result = await build({
|
|
26
|
+
entryPoints: [API_ENTRY],
|
|
27
|
+
bundle: true,
|
|
28
|
+
minify: true,
|
|
29
|
+
format: "esm",
|
|
30
|
+
target: "es2022",
|
|
31
|
+
platform: "browser",
|
|
32
|
+
legalComments: "none",
|
|
33
|
+
write: false,
|
|
34
|
+
});
|
|
35
|
+
const js = result.outputFiles[0]?.contents;
|
|
36
|
+
if (!js) throw new Error("[size] esbuild produced no public API output");
|
|
37
|
+
return { gzip: gzipSync(js, { level: 9 }).length };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function bundleSampleApp() {
|
|
41
|
+
const outdir = await mkdtemp(join(tmpdir(), "mado-size-"));
|
|
42
|
+
try {
|
|
43
|
+
await build({
|
|
44
|
+
entryPoints: [SAMPLE_ENTRY],
|
|
45
|
+
bundle: true,
|
|
46
|
+
minify: true,
|
|
47
|
+
format: "esm",
|
|
48
|
+
target: "es2022",
|
|
49
|
+
platform: "browser",
|
|
50
|
+
splitting: true,
|
|
51
|
+
outdir,
|
|
52
|
+
legalComments: "none",
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
let gzip = 0;
|
|
56
|
+
for (const file of await readdir(outdir)) {
|
|
57
|
+
if (!file.endsWith(".js")) continue;
|
|
58
|
+
const js = await readFile(join(outdir, file));
|
|
59
|
+
gzip += gzipSync(js, { level: 9 }).length;
|
|
60
|
+
}
|
|
61
|
+
return { gzip };
|
|
62
|
+
} finally {
|
|
63
|
+
await rm(outdir, { recursive: true, force: true });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function report(label, actual, limit) {
|
|
68
|
+
const ok = actual < limit;
|
|
69
|
+
const mark = ok ? "ok" : "FAIL";
|
|
70
|
+
console.log(
|
|
71
|
+
`[size] ${label.padEnd(12)} ${mark} ${kib(actual)} KiB gzip < ${kib(limit)} KiB`,
|
|
72
|
+
);
|
|
73
|
+
if (!ok) failed = true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readLimit(name, fallback) {
|
|
77
|
+
const raw = process.env[name];
|
|
78
|
+
if (!raw) return fallback;
|
|
79
|
+
const n = Number(raw);
|
|
80
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
81
|
+
throw new Error(`[size] ${name} must be a positive byte count`);
|
|
82
|
+
}
|
|
83
|
+
return n;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function kib(bytes) {
|
|
87
|
+
return (bytes / 1024).toFixed(2);
|
|
88
|
+
}
|