@madojs/mado 0.5.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 +291 -0
- package/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +371 -0
- package/ROADMAP.md +52 -0
- package/dist/src/component.d.ts +48 -0
- package/dist/src/component.js +140 -0
- package/dist/src/component.js.map +1 -0
- package/dist/src/context.d.ts +40 -0
- package/dist/src/context.js +67 -0
- package/dist/src/context.js.map +1 -0
- package/dist/src/css.d.ts +54 -0
- package/dist/src/css.js +137 -0
- package/dist/src/css.js.map +1 -0
- package/dist/src/devtools.d.ts +22 -0
- package/dist/src/devtools.js +63 -0
- package/dist/src/devtools.js.map +1 -0
- package/dist/src/diagnostics.d.ts +11 -0
- package/dist/src/diagnostics.js +28 -0
- package/dist/src/diagnostics.js.map +1 -0
- package/dist/src/each.d.ts +39 -0
- package/dist/src/each.js +35 -0
- package/dist/src/each.js.map +1 -0
- package/dist/src/forms.d.ts +71 -0
- package/dist/src/forms.js +161 -0
- package/dist/src/forms.js.map +1 -0
- package/dist/src/head.d.ts +19 -0
- package/dist/src/head.js +97 -0
- package/dist/src/head.js.map +1 -0
- package/dist/src/html/bindings.d.ts +78 -0
- package/dist/src/html/bindings.js +304 -0
- package/dist/src/html/bindings.js.map +1 -0
- package/dist/src/html/parser.d.ts +64 -0
- package/dist/src/html/parser.js +521 -0
- package/dist/src/html/parser.js.map +1 -0
- package/dist/src/html/template-types.d.ts +27 -0
- package/dist/src/html/template-types.js +8 -0
- package/dist/src/html/template-types.js.map +1 -0
- package/dist/src/html/template.d.ts +45 -0
- package/dist/src/html/template.js +119 -0
- package/dist/src/html/template.js.map +1 -0
- package/dist/src/html.d.ts +16 -0
- package/dist/src/html.js +16 -0
- package/dist/src/html.js.map +1 -0
- package/dist/src/index.d.ts +35 -0
- package/dist/src/index.js +39 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/lazy.d.ts +38 -0
- package/dist/src/lazy.js +73 -0
- package/dist/src/lazy.js.map +1 -0
- package/dist/src/lifecycle.d.ts +45 -0
- package/dist/src/lifecycle.js +66 -0
- package/dist/src/lifecycle.js.map +1 -0
- package/dist/src/page.d.ts +161 -0
- package/dist/src/page.js +38 -0
- package/dist/src/page.js.map +1 -0
- package/dist/src/persisted.d.ts +47 -0
- package/dist/src/persisted.js +119 -0
- package/dist/src/persisted.js.map +1 -0
- package/dist/src/resource.d.ts +120 -0
- package/dist/src/resource.js +275 -0
- package/dist/src/resource.js.map +1 -0
- package/dist/src/router/manifest.d.ts +56 -0
- package/dist/src/router/manifest.js +302 -0
- package/dist/src/router/manifest.js.map +1 -0
- package/dist/src/router/match.d.ts +62 -0
- package/dist/src/router/match.js +117 -0
- package/dist/src/router/match.js.map +1 -0
- package/dist/src/router/navigation.d.ts +89 -0
- package/dist/src/router/navigation.js +263 -0
- package/dist/src/router/navigation.js.map +1 -0
- package/dist/src/router.d.ts +13 -0
- package/dist/src/router.js +13 -0
- package/dist/src/router.js.map +1 -0
- package/dist/src/signal.d.ts +67 -0
- package/dist/src/signal.js +238 -0
- package/dist/src/signal.js.map +1 -0
- package/docs/README.md +12 -0
- package/docs/en/00-the-mado-way.md +106 -0
- package/docs/en/01-routing.md +204 -0
- package/docs/en/02-project-layout.md +58 -0
- package/docs/en/03-static-bake.md +251 -0
- package/docs/en/04-ide-setup.md +162 -0
- package/docs/en/05-why-mado.md +193 -0
- package/docs/en/06-for-backenders.md +422 -0
- package/docs/en/07-llm-pitfalls.md +486 -0
- package/docs/en/08-llm-zero-history-test.md +56 -0
- package/docs/en/09-shadow-vs-light-dom.md +122 -0
- package/docs/en/README.md +16 -0
- package/docs/fr/00-the-mado-way.md +108 -0
- package/docs/fr/01-routing.md +202 -0
- package/docs/fr/02-project-layout.md +58 -0
- package/docs/fr/03-static-bake.md +290 -0
- package/docs/fr/04-ide-setup.md +162 -0
- package/docs/fr/05-why-mado.md +193 -0
- package/docs/fr/06-for-backenders.md +432 -0
- package/docs/fr/07-llm-pitfalls.md +487 -0
- package/docs/fr/08-llm-zero-history-test.md +60 -0
- package/docs/fr/09-shadow-vs-light-dom.md +121 -0
- package/docs/fr/README.md +16 -0
- package/docs/ru/00-the-mado-way.md +93 -0
- package/docs/ru/01-routing.md +194 -0
- package/docs/ru/02-project-layout.md +57 -0
- package/docs/ru/03-static-bake.md +251 -0
- package/docs/ru/04-ide-setup.md +144 -0
- package/docs/ru/05-why-mado.md +193 -0
- package/docs/ru/06-for-backenders.md +422 -0
- package/docs/ru/07-llm-pitfalls.md +485 -0
- package/docs/ru/08-llm-zero-history-test.md +56 -0
- package/docs/ru/09-shadow-vs-light-dom.md +122 -0
- package/docs/ru/README.md +14 -0
- package/docs/uk/00-the-mado-way.md +54 -0
- package/docs/uk/01-routing.md +82 -0
- package/docs/uk/02-project-layout.md +46 -0
- package/docs/uk/03-static-bake.md +49 -0
- package/docs/uk/04-ide-setup.md +26 -0
- package/docs/uk/05-why-mado.md +34 -0
- package/docs/uk/06-for-backenders.md +50 -0
- package/docs/uk/07-llm-pitfalls.md +82 -0
- package/docs/uk/08-llm-zero-history-test.md +31 -0
- package/docs/uk/09-shadow-vs-light-dom.md +40 -0
- package/docs/uk/README.md +16 -0
- package/llms.txt +155 -0
- package/package.json +81 -0
- package/scripts/bake.mjs +406 -0
- package/scripts/bundle.mjs +146 -0
- package/scripts/cli.mjs +382 -0
- package/scripts/new.mjs +80 -0
- package/scripts/preview.mjs +176 -0
- package/scripts/release-notes.mjs +66 -0
- package/scripts/showcase-regression.mjs +392 -0
- package/server/serve.mjs +292 -0
- package/starters/crud/README.md +21 -0
- package/starters/crud/index.html +20 -0
- package/starters/crud/package.json +17 -0
- package/starters/crud/src/components/app-shell.ts +51 -0
- package/starters/crud/src/components/ticket-detail.ts +33 -0
- package/starters/crud/src/components/ticket-form.ts +69 -0
- package/starters/crud/src/components/ticket-list.ts +66 -0
- package/starters/crud/src/lib/api.ts +76 -0
- package/starters/crud/src/main.ts +12 -0
- package/starters/crud/src/pages/home.ts +18 -0
- package/starters/crud/src/pages/not-found.ts +12 -0
- package/starters/crud/src/pages/ticket-detail.ts +6 -0
- package/starters/crud/src/pages/ticket-new.ts +6 -0
- package/starters/crud/src/pages/tickets.ts +6 -0
- package/starters/crud/src/routes.ts +9 -0
- package/starters/crud/src/styles/global.ts +155 -0
- package/starters/crud/tsconfig.json +15 -0
- package/starters/minimal/README.md +19 -0
- package/starters/minimal/index.html +20 -0
- package/starters/minimal/package.json +17 -0
- package/starters/minimal/src/components/app-counter.ts +31 -0
- package/starters/minimal/src/main.ts +9 -0
- package/starters/minimal/src/pages/home.ts +18 -0
- package/starters/minimal/src/pages/not-found.ts +14 -0
- package/starters/minimal/src/routes.ts +6 -0
- package/starters/minimal/src/styles/global.ts +60 -0
- package/starters/minimal/tsconfig.json +15 -0
- package/templates/page-detail.ts +63 -0
- package/templates/page-form.ts +94 -0
- package/templates/page-list.ts +79 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# The Mado Way
|
|
2
|
+
|
|
3
|
+
> Один правильный путь. Жёсткие контракты. Никакой магии.
|
|
4
|
+
|
|
5
|
+
Mado — это не просто фреймворк, это **набор соглашений**. Если ты следуешь им, проект остаётся понятным даже когда в нём 200 экранов и 5 разработчиков. Если нарушаешь — типы и линтер скажут об этом сразу.
|
|
6
|
+
|
|
7
|
+
## Принципы
|
|
8
|
+
|
|
9
|
+
1. **Один способ.** Для каждой задачи — один правильный путь, не пять. Если ты пишешь странное — спроси себя, не существует ли уже идиоматичный хелпер.
|
|
10
|
+
2. **Явность над магией.** Никаких сканеров файлов, неявных глобалов, скрытых side-effects. Всё, что делает фреймворк, можно прочитать в одном файле.
|
|
11
|
+
3. **Платформа сначала.** Если в браузере уже есть фича — используем её напрямую. Своих абстракций над `fetch`, `<form>`, History API, Shadow DOM не плодим.
|
|
12
|
+
4. **Жёсткие типы.** `tsc --strict --noUncheckedIndexedAccess` всегда. Если что-то не типизируется — это сигнал что API кривой.
|
|
13
|
+
5. **Никаких рантайм-зависимостей.** Любая зависимость — обязательство на годы; экосистема Web Components этого не требует.
|
|
14
|
+
|
|
15
|
+
## Соглашения
|
|
16
|
+
|
|
17
|
+
### Структура проекта
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
src/
|
|
21
|
+
├── routes.ts ← манифест маршрутов, один файл на проект
|
|
22
|
+
├── main.ts ← точка входа: провайдеры + монтаж <x-app>
|
|
23
|
+
├── pages/ ← одна страница = один файл = `export default page({...})`
|
|
24
|
+
├── components/ ← переиспользуемые компоненты, side-effect-регистрация
|
|
25
|
+
├── lib/ ← контексты, API-клиенты, бизнес-логика без UI
|
|
26
|
+
└── styles/ ← общие стили (если нужны), .ts с css``
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Это **обязательно**, не "по желанию". Если у проекта будет 10 разработчиков — они все должны писать одинаково.
|
|
30
|
+
|
|
31
|
+
### Один компонент = один файл
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
// src/components/user-card.ts
|
|
35
|
+
import { component, html, css } from '@madojs/mado';
|
|
36
|
+
|
|
37
|
+
component('x-user-card', () => {
|
|
38
|
+
return () => html`<div class="card"><slot/></div>`;
|
|
39
|
+
}, {
|
|
40
|
+
styles: css`.card { padding: 1rem; }`,
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Импорт `import './components/user-card.js'` **регистрирует** компонент через `customElements.define`. Это side-effect. Где компонент нужен — там и импортируем.
|
|
45
|
+
|
|
46
|
+
### Один способ загрузки данных
|
|
47
|
+
|
|
48
|
+
❌ Не зовём `fetch()` напрямую из компонента. Всегда через:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
// чтение → resource
|
|
52
|
+
const user = resource(() => `/api/users/${id()}`, jsonFetcher());
|
|
53
|
+
|
|
54
|
+
// запись → mutation
|
|
55
|
+
const save = mutation(api.save, { invalidates: ['/api/users*'] });
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Это даёт кеш, отмену, обработку ошибок, авто-инвалидацию.
|
|
59
|
+
|
|
60
|
+
### Один способ описать страницу
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
// src/pages/user-profile.ts
|
|
64
|
+
import { page, html, resource, jsonFetcher } from '@madojs/mado';
|
|
65
|
+
|
|
66
|
+
export default page({
|
|
67
|
+
title: ({ id }) => `User #${id}`,
|
|
68
|
+
view: ({ params }) => html`...`,
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Три слота — `title`, `load`, `view`. Других нет. Хочешь что-то ещё — это уже компонент или хелпер.
|
|
73
|
+
|
|
74
|
+
### Один способ объявить роуты
|
|
75
|
+
|
|
76
|
+
См. [`01-routing.md`](./01-routing.md).
|
|
77
|
+
|
|
78
|
+
## Чего НЕ делаем
|
|
79
|
+
|
|
80
|
+
- ❌ Не пишем компоненты без дефиса. Это правило браузера для custom elements: `user-card` ок, `usercard` нет.
|
|
81
|
+
- `x-*` — только convention для примеров и тестов Mado, не брендовый стандарт. В production лучше брать префикс домена: `app-*`, `crm-*`, `ticket-*`, `admin-*`.
|
|
82
|
+
- ❌ Не используем `innerHTML` напрямую. Только через `html\`\``.
|
|
83
|
+
- ❌ Не вызываем `setTimeout`/`setInterval` без cleanup. Только внутри `effect()`.
|
|
84
|
+
- ❌ Не храним глобальный мутируемый state. Используем сигналы и `context`.
|
|
85
|
+
- ❌ Не подключаем pkg без обсуждения. Каждая зависимость — обязательство.
|
|
86
|
+
|
|
87
|
+
## Когда сомневаешься
|
|
88
|
+
|
|
89
|
+
Если ты задаёшься вопросом "а как тут лучше?" — это сигнал, что:
|
|
90
|
+
1. Либо есть встроенный хелпер, который ты не знаешь (загляни в `docs/`).
|
|
91
|
+
2. Либо это новая ситуация — её надо обсудить и **зафиксировать** в этом документе как ещё одно соглашение.
|
|
92
|
+
|
|
93
|
+
«Лучше единый-окей, чем разный-идеальный.»
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# Routing
|
|
2
|
+
|
|
3
|
+
> Один файл-манифест. Никаких сканеров папок. Никаких спецсимволов.
|
|
4
|
+
|
|
5
|
+
## Зачем не file-based
|
|
6
|
+
|
|
7
|
+
В Next/SvelteKit/SolidStart роут возникает «магически» по имени файла. У этого есть плюсы (видно структуру URL по `pages/`), но в проде это означает:
|
|
8
|
+
|
|
9
|
+
- Невидимый плагин-сканер в билде. Без него файлы — просто файлы.
|
|
10
|
+
- Спецсимволы в путях: `[id]`, `(group)`, `_layout`, `+page.svelte`, `...slug`.
|
|
11
|
+
- Server-route vs client-route путаются.
|
|
12
|
+
- Тестировать роутинг — мука: нужен эмулятор сборщика.
|
|
13
|
+
|
|
14
|
+
Mado считает это **слишком магией**. Мы делаем иначе.
|
|
15
|
+
|
|
16
|
+
## Манифест
|
|
17
|
+
|
|
18
|
+
Один файл — `src/routes.ts`. В нём один объект. Читается сверху вниз.
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
// src/routes.ts
|
|
22
|
+
import { routes } from '@madojs/mado';
|
|
23
|
+
|
|
24
|
+
export default routes({
|
|
25
|
+
'/': () => import('./pages/home.js'),
|
|
26
|
+
'/about': () => import('./pages/about.js'),
|
|
27
|
+
'/users/:id': () => import('./pages/user-profile.js'),
|
|
28
|
+
'/users/:id/edit':() => import('./pages/user-edit.js'),
|
|
29
|
+
'*': () => import('./pages/not-found.js'),
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Хочешь увидеть все роуты? Открой `routes.ts`. Никаких сюрпризов.
|
|
34
|
+
|
|
35
|
+
## Что справа от пути
|
|
36
|
+
|
|
37
|
+
Любая запись — это **одно из трёх**:
|
|
38
|
+
|
|
39
|
+
### 1. Lazy import (рекомендовано)
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
'/posts': () => import('./pages/posts.js'),
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- Браузер сам сделает chunk при бандлинге (esbuild --bundle --splitting).
|
|
46
|
+
- Модуль загрузится только при заходе на роут.
|
|
47
|
+
- Между навигациями результат кэшируется.
|
|
48
|
+
|
|
49
|
+
### 2. Готовая Page (eager)
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import about from './pages/about.js';
|
|
53
|
+
|
|
54
|
+
'/about': about,
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Сразу в графе, без задержек. Используй для критичных страниц (home, login).
|
|
58
|
+
|
|
59
|
+
### 3. Nested с layout
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { routes, nested } from '@madojs/mado';
|
|
63
|
+
|
|
64
|
+
export default routes({
|
|
65
|
+
'/': () => import('./pages/home.js'),
|
|
66
|
+
|
|
67
|
+
'/admin/*': nested({
|
|
68
|
+
layout: () => import('./layouts/admin.js'),
|
|
69
|
+
routes: {
|
|
70
|
+
'': () => import('./pages/admin/dashboard.js'),
|
|
71
|
+
'users': () => import('./pages/admin/users.js'),
|
|
72
|
+
'logs': () => import('./pages/admin/logs.js'),
|
|
73
|
+
},
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Layout — это **обычный** `page({...})`, который рендерит `ctx.child` куда хочет:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
// src/layouts/admin.ts
|
|
82
|
+
import { page, html, css, component } from '@madojs/mado';
|
|
83
|
+
|
|
84
|
+
export default page({
|
|
85
|
+
view: ({ child }) => html`
|
|
86
|
+
<div class="admin">
|
|
87
|
+
<aside><nav>...</nav></aside>
|
|
88
|
+
<main>${child}</main>
|
|
89
|
+
</div>
|
|
90
|
+
`,
|
|
91
|
+
});
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Контракт страницы
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
import { page, html, resource, jsonFetcher } from '@madojs/mado';
|
|
98
|
+
|
|
99
|
+
export default page({
|
|
100
|
+
title: ({ id }) => `User #${id}`, // string | (params) => string
|
|
101
|
+
load: ({ id }) => resource(...), // опц., возвращает Resource или данные
|
|
102
|
+
view: ({ params, data, path, child }) => html`...`, // ОБЯЗАТЕЛЬНО
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Три слота, всё. Если ты экспортируешь не `page({...})`, а просто функцию — `routes()` кинет понятную ошибку:
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
[Mado] Lazy-роут не вернул page({...}) как default-экспорт.
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Параметры URL
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
'/users/:id': () => import('./pages/user.js'),
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
export default page<{ id: string }>({
|
|
120
|
+
title: ({ id }) => `User ${id}`,
|
|
121
|
+
view: ({ params }) => html`<h1>${params.id}</h1>`,
|
|
122
|
+
});
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Типы передаются в `page<Params>` — `tsc` проверит что вы не обратились к `params.foo`, которого нет в роуте.
|
|
126
|
+
|
|
127
|
+
## Глобальные опции
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
export default routes(
|
|
131
|
+
{ '/': home, '/about': about, '*': nf },
|
|
132
|
+
{
|
|
133
|
+
titleSuffix: ' · MyApp', // → "Главная · MyApp"
|
|
134
|
+
loading: () => html`<x-spinner/>`, // пока модуль грузится
|
|
135
|
+
error: (err) => html`<x-fatal-error .err=${err}/>`,
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Программная навигация
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
import route from './routes.js';
|
|
144
|
+
|
|
145
|
+
route.navigate('/posts');
|
|
146
|
+
route.navigate('/posts?page=2');
|
|
147
|
+
route.navigate('/posts', { replace: true });
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Клики по `<a href="/foo" data-link>` перехватываются глобально (без атрибута — браузер сделает full reload, как и положено для внешних ссылок).
|
|
151
|
+
|
|
152
|
+
## Query-параметры
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
import { queryParam } from '@madojs/mado';
|
|
156
|
+
|
|
157
|
+
const page = queryParam('page', '1');
|
|
158
|
+
page(); // '1'
|
|
159
|
+
page.set('2'); // history.replaceState + перерисовка
|
|
160
|
+
page.set(null); // удалить параметр
|
|
161
|
+
page.set('3', { push: true }); // history.pushState
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`queryParam` — обычный сигнал. Использовать можно где угодно: в страницах, компонентах, computed.
|
|
165
|
+
|
|
166
|
+
## Что осознанно отсутствует
|
|
167
|
+
|
|
168
|
+
- ❌ Авто-сканирование `pages/`. **Один файл-манифест явный**.
|
|
169
|
+
- ❌ Спецсимволы в путях (`[id]`, `(group)`, `_layout`). **Параметры — только `:name`, ничего больше**.
|
|
170
|
+
- ❌ Server-side роутинг в этом же манифесте. Mado — клиентский фреймворк.
|
|
171
|
+
- ❌ Auto-prefetch при наведении. Если очень нужно — можно сделать вручную: `link.addEventListener('mouseenter', loader)`. Но обычно лишнее.
|
|
172
|
+
|
|
173
|
+
## FAQ
|
|
174
|
+
|
|
175
|
+
**А если у меня 100 роутов? Не разрастётся ли файл?**
|
|
176
|
+
Разрастётся до ~150 строк. Это всё ещё **один источник правды** против сотни файлов в `pages/` с магическими именами. На практике даже у больших проектов (1000+ страниц) можно бить на feature-манифесты:
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
import { routes } from '@madojs/mado';
|
|
180
|
+
import adminRoutes from './features/admin/routes.js';
|
|
181
|
+
import billingRoutes from './features/billing/routes.js';
|
|
182
|
+
|
|
183
|
+
export default routes({
|
|
184
|
+
...adminRoutes,
|
|
185
|
+
...billingRoutes,
|
|
186
|
+
'*': () => import('./pages/not-found.js'),
|
|
187
|
+
});
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
**Как тестировать роутинг?**
|
|
191
|
+
Импортируешь `routes.ts` — это просто объект. Подставляешь свой mock-router. Никакой эмуляции сборщика не нужно.
|
|
192
|
+
|
|
193
|
+
**Code splitting работает?**
|
|
194
|
+
Да. При `esbuild --bundle --splitting --format=esm` каждый `() => import('./pages/x.js')` становится отдельным chunk'ом.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Project layout
|
|
2
|
+
|
|
3
|
+
Каждый new-проект на Mado имеет одинаковую структуру. Это **обязательное** соглашение.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
my-app/
|
|
7
|
+
├── package.json # ровно 1 dep: typescript (esbuild опц.)
|
|
8
|
+
├── tsconfig.json # с paths "@madojs/mado" → импорт без относительных путей
|
|
9
|
+
├── Dockerfile + nginx.conf # копируем из Mado/ при scaffold
|
|
10
|
+
├── .gitlab-ci.yml | .github/workflows/ci.yml
|
|
11
|
+
├── server/serve.mjs # dev-сервер из Mado, без deps
|
|
12
|
+
├── scripts/
|
|
13
|
+
│ ├── bundle.mjs # esbuild прод-бандл
|
|
14
|
+
│ └── new.mjs # скаффолд страницы
|
|
15
|
+
├── templates/ # шаблоны для new.mjs
|
|
16
|
+
├── docs/ # проектные доки (можно копировать наши гайды)
|
|
17
|
+
├── public/ # статика (favicon, манифесты)
|
|
18
|
+
└── src/
|
|
19
|
+
├── main.ts # точка входа: провайдеры + монтаж <x-app>
|
|
20
|
+
├── routes.ts # манифест роутов
|
|
21
|
+
├── pages/ # одна страница = один файл = `export default page({...})`
|
|
22
|
+
├── components/ # переиспользуемые компоненты (x-*)
|
|
23
|
+
├── layouts/ # layout-страницы (для nested)
|
|
24
|
+
└── lib/
|
|
25
|
+
├── api.ts # все fetch-обёртки
|
|
26
|
+
├── contexts.ts # createContext(...)
|
|
27
|
+
├── theme.ts # темы
|
|
28
|
+
└── ... # утилиты, типы, бизнес-правила
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Куда положить новый файл?
|
|
32
|
+
|
|
33
|
+
| Что | Куда |
|
|
34
|
+
|---|---|
|
|
35
|
+
| Страница на новый URL | `src/pages/foo.ts` + добавить в `src/routes.ts` |
|
|
36
|
+
| Переиспользуемая UI-штука | `src/components/foo-bar.ts` |
|
|
37
|
+
| Обёртка над API | `src/lib/api.ts` (добавить метод) |
|
|
38
|
+
| Глобальный контекст (тема, юзер, i18n) | `src/lib/<name>.ts` |
|
|
39
|
+
| Чистая функция без UI | `src/lib/util/<name>.ts` |
|
|
40
|
+
|
|
41
|
+
Если не понимаешь куда — это сигнал что **архитектура страдает**. Спроси команду, **зафиксируй** ответ в `docs/`.
|
|
42
|
+
|
|
43
|
+
## Правила именования
|
|
44
|
+
|
|
45
|
+
| Что | Стиль | Пример |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| Файл | kebab-case | `user-profile.ts` |
|
|
48
|
+
| Тэг компонента | `x-` + kebab | `<x-user-profile>` |
|
|
49
|
+
| Контекст | PascalCase + `Ctx` | `ThemeCtx`, `AuthCtx` |
|
|
50
|
+
| Сигнал | camelCase | `userId`, `isLoggedIn` |
|
|
51
|
+
| Page-функция (внутренний компонент) | `x-<route>-page` | `<x-posts-page>` |
|
|
52
|
+
|
|
53
|
+
## Что НЕ кладём в src/
|
|
54
|
+
|
|
55
|
+
- ❌ Конфиги билдеров (webpack, rollup, vite) — у нас их нет.
|
|
56
|
+
- ❌ `.env`-файлы — env читается из `process.env`/`import.meta.env` в `lib/config.ts`.
|
|
57
|
+
- ❌ Тесты вперемешку с кодом — все в `test/`.
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# Smart Static (`bake`)
|
|
2
|
+
|
|
3
|
+
> Выпекание HTML для SEO без runtime SSR. **Идея: данные и view в одном файле, статика на выходе.**
|
|
4
|
+
|
|
5
|
+
`bake` — это **build-time prerender**, не SSR. На выходе — статические `*.html` файлы, которые любой nginx/Cloudflare раздаёт как обычную статику. На клиенте Web Components оживают и работает SPA-навигация дальше.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Когда `bake` подходит
|
|
10
|
+
|
|
11
|
+
- **Маркетинговые страницы**: лендинги, landing/sub-landings под рекламные кампании, страницы продуктов.
|
|
12
|
+
- **Каталог с относительно стабильным набором страниц**: блог, документация, портфолио, e-commerce до **~10k SKU** с обновлениями реже раза в час.
|
|
13
|
+
- **Контент, общий для всех пользователей**: вся страница одинакова для гостя и для авторизованного юзера (или авторизация дорисовывается через `effect()` уже на клиенте).
|
|
14
|
+
- Нужен **хороший SEO** (rich snippets, OG, JSON-LD) и **быстрый first paint** без поднятия node-серверов в проде.
|
|
15
|
+
|
|
16
|
+
## Когда `bake` **не** подходит
|
|
17
|
+
|
|
18
|
+
- **Сотни тысяч страниц с частыми изменениями**. `bake` обходит все `paths` синхронно за один прогон. На 100k+ страниц это минуты ребилда, и инвалидация одной страницы либо требует полного rebake, либо отдельной CI-логики (см. ниже про точечный revalidate).
|
|
19
|
+
- **Персонализированный контент в HTML**. Если на странице должно быть "Привет, Иван" в `<title>` или в meta — это не для `bake`. Авторизованный кабинет, личный фид, корзина с реальными ценами для юзера — оставляйте SPA.
|
|
20
|
+
- **Нужны server-only API в render**: cookies, headers, реальные сетевые запросы к закрытым API. На bake-стороне доступен только `linkedom`, никакого Node-окружения для компонентов.
|
|
21
|
+
- **A/B-тесты и flag'и, которые меняют разметку в первом paint**. `bake` зафиксирует один вариант. Динамику делайте на клиенте через `effect()`.
|
|
22
|
+
- **Real-time / часто меняющиеся данные** (биржевые котировки, остатки на складе минута-в-минуту). `bake.revalidate` — это метаданные, не runtime: фреймворк сам ничего не перевыпекает.
|
|
23
|
+
- Контент **под авторизацией** (admin, internal tools). Незачем; используйте SPA-режим.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Концепция
|
|
28
|
+
|
|
29
|
+
`page({...})` имеет четыре опциональных слота, относящихся к `bake`:
|
|
30
|
+
|
|
31
|
+
- `head` — meta, OG, JSON-LD.
|
|
32
|
+
- `bake.paths` — список URL-параметров для генерации (build-time, может быть `async`).
|
|
33
|
+
- `bake.data` — данные для конкретного URL (build-time, может быть `async`).
|
|
34
|
+
- `bake.revalidate` — через сколько секунд кэш устарел (записывается в `<meta>`, реальную инвалидацию решает ваша CI/CDN).
|
|
35
|
+
|
|
36
|
+
Команда `npm run bake` обходит все `page` с `bake`, генерит HTML через `linkedom`, складывает в `out/<path>/index.html`. **Никакого Chromium не нужно** — `linkedom` весит ~50 КБ.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Пример
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
// src/pages/product.ts
|
|
44
|
+
import { page, component, html } from "@madojs/mado";
|
|
45
|
+
import { findProduct, products, type Product } from "../lib/products.js";
|
|
46
|
+
|
|
47
|
+
component("x-product-page", ({ host }) => {
|
|
48
|
+
return () => {
|
|
49
|
+
const p = findProduct(host.dataset.slug);
|
|
50
|
+
return p
|
|
51
|
+
? html`<h1>${p.name}</h1><p>${p.description}</p>`
|
|
52
|
+
: html`<p>Не найдено.</p>`;
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export default page<{ slug: string }, Product | undefined>({
|
|
57
|
+
title: ({ slug }) => `${findProduct(slug)?.name} — MyShop`,
|
|
58
|
+
|
|
59
|
+
head: ({ slug }, baked) => {
|
|
60
|
+
const p = baked ?? findProduct(slug);
|
|
61
|
+
if (!p) return {};
|
|
62
|
+
return {
|
|
63
|
+
description: p.description,
|
|
64
|
+
canonical: `/product/${p.slug}`,
|
|
65
|
+
og: { title: p.name, image: p.image, type: "product" },
|
|
66
|
+
jsonLd: {
|
|
67
|
+
"@context": "https://schema.org",
|
|
68
|
+
"@type": "Product",
|
|
69
|
+
name: p.name,
|
|
70
|
+
offers: { "@type": "Offer", price: p.price, priceCurrency: p.currency },
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
bake: {
|
|
76
|
+
paths: () => products.map((p) => ({ slug: p.slug })),
|
|
77
|
+
data: ({ slug }) => findProduct(slug),
|
|
78
|
+
revalidate: 3600,
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
view: ({ params }) =>
|
|
82
|
+
html`<x-product-page data-slug=${params.slug}></x-product-page>`,
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
// src/routes.ts
|
|
88
|
+
import { routes, type RoutesMap } from "@madojs/mado";
|
|
89
|
+
|
|
90
|
+
// Экспортируем И default (RouterApi для рантайма), И manifest (для bake-скрипта).
|
|
91
|
+
export const manifest: RoutesMap = {
|
|
92
|
+
"/": () => import("./pages/home.js"),
|
|
93
|
+
"/product/:slug": () => import("./pages/product.js"),
|
|
94
|
+
"*": () => import("./pages/not-found.js"),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export default routes(manifest, { titleSuffix: " · MyShop" });
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Запуск
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
npm install -D linkedom esbuild
|
|
104
|
+
npm run build
|
|
105
|
+
npm run bake
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Получаешь:
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
out/
|
|
112
|
+
├── product/
|
|
113
|
+
│ ├── mado-mug/index.html ← HTML с meta + JSON-LD
|
|
114
|
+
│ ├── raw-bundler/index.html
|
|
115
|
+
│ └── shadow-dom/index.html
|
|
116
|
+
└── sitemap.xml
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Что внутри сгенерированного HTML
|
|
120
|
+
|
|
121
|
+
```html
|
|
122
|
+
<head>
|
|
123
|
+
<title>Mado-кружка — MyShop</title>
|
|
124
|
+
<meta name="description" content="..." data-mado-head="baked">
|
|
125
|
+
<link rel="canonical" href="/product/mado-mug" data-mado-head="baked">
|
|
126
|
+
<meta property="og:title" content="Mado-кружка" data-mado-head="baked">
|
|
127
|
+
<meta property="og:image" content="..." data-mado-head="baked">
|
|
128
|
+
<script type="application/ld+json" data-mado-head="baked">
|
|
129
|
+
{"@context":"https://schema.org","@type":"Product","..."}
|
|
130
|
+
</script>
|
|
131
|
+
<meta name="bake-revalidate" content="3600" data-mado-head="baked">
|
|
132
|
+
<meta name="bake-stamp" content="1234567890" data-mado-head="baked">
|
|
133
|
+
</head>
|
|
134
|
+
<body>
|
|
135
|
+
<div id="app">
|
|
136
|
+
<x-product-page data-slug="mado-mug">
|
|
137
|
+
<h1>Mado-кружка</h1>
|
|
138
|
+
<p>Кружка с надписью «zero dependencies».</p>
|
|
139
|
+
<strong>12 EUR</strong>
|
|
140
|
+
</x-product-page>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<!-- предзагруженные данные для гидрации -->
|
|
144
|
+
<script id="bake" type="application/json">
|
|
145
|
+
{"slug":"mado-mug","name":"Mado-кружка","price":12,"..."}
|
|
146
|
+
</script>
|
|
147
|
+
|
|
148
|
+
<script type="module" src="/dist/examples/main.js"></script>
|
|
149
|
+
</body>
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
После загрузки JS:
|
|
153
|
+
|
|
154
|
+
1. Web Components `<x-product-page>` оживают (браузер уже знает кастомные элементы).
|
|
155
|
+
2. `page.load()` получает `baked` как initialData — никаких лишних `fetch`.
|
|
156
|
+
3. SPA-навигация работает дальше как обычно.
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## Cookbook: типовые сценарии
|
|
161
|
+
|
|
162
|
+
### Блог (Markdown → bake)
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
// src/lib/posts.ts
|
|
166
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
167
|
+
// (этот модуль импортируется только из bake-скрипта,
|
|
168
|
+
// в браузере не должен попасть в граф — выносите в lib/server/ или ifdef'ьте)
|
|
169
|
+
|
|
170
|
+
export const allPosts = () =>
|
|
171
|
+
readdirSync("content/blog").map((file) => ({
|
|
172
|
+
slug: file.replace(/\.md$/, ""),
|
|
173
|
+
...parseFrontmatter(readFileSync(`content/blog/${file}`, "utf-8")),
|
|
174
|
+
}));
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
// src/pages/blog-post.ts
|
|
179
|
+
import { page, html } from "@madojs/mado";
|
|
180
|
+
import { allPosts } from "../lib/posts.js";
|
|
181
|
+
|
|
182
|
+
export default page<{ slug: string }>({
|
|
183
|
+
title: ({ slug }) => allPosts().find((p) => p.slug === slug)?.title ?? slug,
|
|
184
|
+
head: ({ slug }, post) => ({
|
|
185
|
+
description: post?.excerpt,
|
|
186
|
+
canonical: `/blog/${slug}`,
|
|
187
|
+
og: { title: post?.title, type: "article" },
|
|
188
|
+
}),
|
|
189
|
+
bake: {
|
|
190
|
+
paths: () => allPosts().map(({ slug }) => ({ slug })),
|
|
191
|
+
data: ({ slug }) => allPosts().find((p) => p.slug === slug),
|
|
192
|
+
},
|
|
193
|
+
view: ({ params }) =>
|
|
194
|
+
html`<x-blog-post data-slug=${params.slug}></x-blog-post>`,
|
|
195
|
+
});
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Каталог продуктов (e-commerce, ≤ 10k SKU)
|
|
199
|
+
|
|
200
|
+
- `bake.paths` → SELECT slug FROM products
|
|
201
|
+
- `bake.data` → SELECT * FROM products WHERE slug=?
|
|
202
|
+
- Полная инвалидация раз в N часов через cron + `npm run bake`
|
|
203
|
+
- Точечная инвалидация одного товара: webhook → пересборка только этого `paths`
|
|
204
|
+
|
|
205
|
+
### Документация
|
|
206
|
+
|
|
207
|
+
- `bake.paths` — обход файловой системы `docs/**/*.md`
|
|
208
|
+
- `bake.data` — парсинг Markdown
|
|
209
|
+
- `head.jsonLd` — `TechArticle`
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## Revalidate / CDN
|
|
214
|
+
|
|
215
|
+
`bake.revalidate: 3600` пишет в HTML `<meta name="bake-revalidate" content="3600">` и `bake-stamp`. Это **метаданные** — фреймворк сам ничего не перевыпекает. Стратегии:
|
|
216
|
+
|
|
217
|
+
1. **Простейший вариант**: cron в CI — `npm run bake && rsync out/ origin:/var/www/`.
|
|
218
|
+
2. **Через CDN** (Cloudflare/Fastly): кладёте HTML с `Cache-Control: max-age=3600`. CDN сам инвалидирует.
|
|
219
|
+
3. **Webhook-триггер**: API magazin → POST `/_revalidate?path=/product/mado-mug` → CI пере-выпекает только эту страницу (можно сделать `bake.paths`, который возвращает точечный список по env-параметру).
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## Сравнение с альтернативами
|
|
224
|
+
|
|
225
|
+
| | Next.js SSG/ISR | playwright-prerender | **mado bake** |
|
|
226
|
+
|--------------------------|--------------------|----------------------|-----------------|
|
|
227
|
+
| Нужен Chrome в CI | нет | **да** (~300 МБ) | **нет** |
|
|
228
|
+
| Нужен node в проде | для ISR | нет | **нет** |
|
|
229
|
+
| Время на 1000 страниц | минуты | минуты | **секунды** |
|
|
230
|
+
| Magic-уровень | high | low | **zero** |
|
|
231
|
+
| HTML-парсер | React-renderer | браузер | linkedom (~50 КБ) |
|
|
232
|
+
| Конфигурация | next.config.js + … | один скрипт | один скрипт |
|
|
233
|
+
| Источник правды view+data | разные | страница | **одна страница** |
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## Ограничения и подводные камни
|
|
238
|
+
|
|
239
|
+
- **В bake-стороне нет браузера.** Не работают: `setTimeout` (точнее работает, но bake завершается раньше), `fetch` к относительным URL, любые `effect()`/`signal()` побочки, реальные `requestAnimationFrame`. Render-функция должна быть детерминирована от `params` / `data`.
|
|
240
|
+
- **`linkedom` ≠ браузер.** Не все API DOM поддержаны (например, `HTMLElement.click()` ведёт себя проще). Тяжёлая логика в Web Components будет выполнена только в браузере после `connectedCallback`; в baked HTML попадёт только то, что синхронно отрендерилось при первом проходе.
|
|
241
|
+
- **Динамичный контент дорисовывайте на клиенте.** Текущее время, A/B-тест, гео-баннер, корзина юзера — не должны быть в выпеченном HTML. Используйте `effect()` для дорисовки.
|
|
242
|
+
- **Серверные импорты не должны попадать в клиентский граф.** Если `lib/posts.ts` импортит `node:fs`, его нельзя импортить из `view`. Держите такие модули в отдельной папке (`lib/build/`) и используйте только из `bake.paths`/`bake.data`.
|
|
243
|
+
- **`paths` и `data` исполняются на каждый bake-прогон.** Если за ними тяжёлый запрос к БД — кешируйте на уровне скрипта.
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## TL;DR
|
|
248
|
+
|
|
249
|
+
Если страница **общая для всех пользователей**, имеет **относительно стабильный набор URL'ов** и важен **SEO + первый paint** — добавьте `bake: { paths, data }` и получите статический HTML с meta/JSON-LD/sitemap за миллисекунды. Без node-сервера, без Chrome, без магии.
|
|
250
|
+
|
|
251
|
+
Если страница персонализирована, или URL'ов миллион, или контент меняется в реальном времени — `bake` не ваш инструмент. Оставляйте SPA или подключайте отдельный SSR-фреймворк.
|