@madojs/mado 0.10.1 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/AGENTS.md +24 -26
  2. package/CHANGELOG.md +95 -0
  3. package/README.md +22 -47
  4. package/TODO.md +52 -48
  5. package/dist/src/component.d.ts +2 -1
  6. package/dist/src/component.js +5 -2
  7. package/dist/src/component.js.map +1 -1
  8. package/dist/src/each.d.ts +1 -1
  9. package/dist/src/each.js +1 -1
  10. package/dist/src/each.js.map +1 -1
  11. package/dist/src/html/bindings.js +3 -3
  12. package/dist/src/html/bindings.js.map +1 -1
  13. package/dist/src/index.d.ts +11 -6
  14. package/dist/src/index.js +5 -3
  15. package/dist/src/index.js.map +1 -1
  16. package/dist/src/lazy.d.ts +1 -1
  17. package/dist/src/lazy.js +1 -1
  18. package/dist/src/lazy.js.map +1 -1
  19. package/dist/src/page.d.ts +17 -21
  20. package/dist/src/page.js +7 -12
  21. package/dist/src/page.js.map +1 -1
  22. package/dist/src/router/manifest.d.ts +1 -1
  23. package/dist/src/router/manifest.js +21 -13
  24. package/dist/src/router/manifest.js.map +1 -1
  25. package/dist/src/router/match.d.ts +2 -2
  26. package/dist/src/router/match.js +3 -3
  27. package/dist/src/router/match.js.map +1 -1
  28. package/dist/src/router/navigation.js +1 -1
  29. package/dist/src/router/navigation.js.map +1 -1
  30. package/dist/src/vite/index.d.ts +10 -0
  31. package/dist/src/vite/index.js +33 -0
  32. package/dist/src/vite/index.js.map +1 -0
  33. package/docs/en/00-the-mado-way.md +25 -12
  34. package/docs/en/01-routing.md +90 -142
  35. package/docs/en/02-project-layout.md +59 -53
  36. package/docs/en/03-static-bake.md +5 -6
  37. package/docs/en/05-why-mado.md +6 -6
  38. package/docs/en/06-for-backenders.md +18 -22
  39. package/docs/en/08-llm-zero-history-test.md +9 -14
  40. package/docs/en/09-shadow-vs-light-dom.md +28 -36
  41. package/docs/en/10-app-architecture.md +158 -96
  42. package/docs/en/11-layouts.md +22 -24
  43. package/docs/en/12-auth-and-api.md +89 -182
  44. package/docs/en/13-deployment.md +18 -22
  45. package/docs/en/14-testing.md +4 -4
  46. package/docs/en/16-bake-cookbook.md +11 -12
  47. package/docs/en/18-api-freeze-map.md +6 -4
  48. package/docs/en/20-v1-stability.md +1 -1
  49. package/docs/fr/00-the-mado-way.md +55 -90
  50. package/docs/fr/01-routing.md +70 -152
  51. package/docs/fr/02-project-layout.md +61 -42
  52. package/docs/fr/03-static-bake.md +1 -1
  53. package/docs/fr/05-why-mado.md +6 -6
  54. package/docs/fr/06-for-backenders.md +7 -7
  55. package/docs/fr/08-llm-zero-history-test.md +21 -48
  56. package/docs/fr/09-shadow-vs-light-dom.md +43 -162
  57. package/docs/fr/10-app-architecture.md +110 -33
  58. package/docs/fr/11-layouts.md +24 -12
  59. package/docs/fr/12-auth-and-api.md +63 -22
  60. package/docs/fr/13-deployment.md +7 -10
  61. package/docs/fr/14-testing.md +1 -1
  62. package/docs/fr/16-bake-cookbook.md +2 -2
  63. package/docs/fr/18-api-freeze-map.md +1 -1
  64. package/docs/fr/20-v1-stability.md +1 -1
  65. package/docs/recipes/nginx/README.md +13 -0
  66. package/docs/ru/00-the-mado-way.md +53 -75
  67. package/docs/ru/01-routing.md +68 -143
  68. package/docs/ru/02-project-layout.md +61 -41
  69. package/docs/ru/03-static-bake.md +2 -2
  70. package/docs/ru/05-why-mado.md +6 -6
  71. package/docs/ru/06-for-backenders.md +7 -7
  72. package/docs/ru/08-llm-zero-history-test.md +9 -14
  73. package/docs/ru/09-shadow-vs-light-dom.md +43 -178
  74. package/docs/ru/10-app-architecture.md +115 -63
  75. package/docs/ru/11-layouts.md +24 -24
  76. package/docs/ru/12-auth-and-api.md +57 -35
  77. package/docs/ru/13-deployment.md +7 -11
  78. package/docs/ru/14-testing.md +1 -1
  79. package/docs/ru/16-bake-cookbook.md +12 -6
  80. package/docs/ru/18-api-freeze-map.md +5 -3
  81. package/docs/ru/20-v1-stability.md +1 -1
  82. package/docs/uk/00-the-mado-way.md +70 -44
  83. package/docs/uk/01-routing.md +41 -47
  84. package/docs/uk/02-project-layout.md +68 -41
  85. package/docs/uk/03-static-bake.md +1 -2
  86. package/docs/uk/06-for-backenders.md +3 -3
  87. package/docs/uk/08-llm-zero-history-test.md +22 -24
  88. package/docs/uk/09-shadow-vs-light-dom.md +37 -86
  89. package/docs/uk/10-app-architecture.md +72 -31
  90. package/docs/uk/11-layouts.md +25 -12
  91. package/docs/uk/12-auth-and-api.md +58 -22
  92. package/docs/uk/13-deployment.md +4 -3
  93. package/docs/uk/14-testing.md +1 -1
  94. package/docs/uk/18-api-freeze-map.md +1 -1
  95. package/docs/uk/20-v1-stability.md +1 -1
  96. package/llms.txt +14 -15
  97. package/package.json +18 -11
  98. package/scripts/_config.mjs +15 -161
  99. package/scripts/bake.mjs +74 -63
  100. package/scripts/cli/generate.mjs +348 -0
  101. package/scripts/cli/help.mjs +27 -0
  102. package/scripts/cli/index.mjs +79 -0
  103. package/scripts/cli/init.mjs +153 -0
  104. package/scripts/cli/release.mjs +152 -0
  105. package/scripts/cli/run.mjs +96 -0
  106. package/scripts/cli.mjs +2 -621
  107. package/scripts/package-smoke.mjs +4 -1
  108. package/scripts/preview.mjs +13 -37
  109. package/scripts/size-budget.mjs +5 -2
  110. package/scripts/vite.default.mjs +11 -0
  111. package/starters/default/.editorconfig +12 -0
  112. package/starters/default/README.md +74 -0
  113. package/starters/default/eslint.config.mjs +256 -0
  114. package/starters/default/index.html +13 -0
  115. package/starters/default/package.json +30 -0
  116. package/starters/default/public/favicon.svg +4 -0
  117. package/starters/default/src/app.routes.ts +39 -0
  118. package/starters/default/src/layouts/app-shell.layout.ts +35 -0
  119. package/starters/default/src/layouts/auth-shell.layout.ts +17 -0
  120. package/starters/default/src/main.ts +16 -0
  121. package/starters/default/src/modules/auth/_contracts/auth-api.types.ts +17 -0
  122. package/starters/default/src/modules/auth/auth.connector.ts +45 -0
  123. package/starters/default/src/modules/auth/auth.guard.ts +22 -0
  124. package/starters/default/src/modules/auth/auth.public.ts +9 -0
  125. package/starters/default/src/modules/auth/auth.routes.ts +8 -0
  126. package/starters/default/src/modules/auth/auth.service.ts +71 -0
  127. package/starters/default/src/modules/auth/auth.types.ts +15 -0
  128. package/starters/default/src/modules/auth/login.page.ts +62 -0
  129. package/starters/default/src/modules/billing/_contracts/stripe.types.ts +17 -0
  130. package/starters/default/src/modules/billing/api/stripe.connector.ts +71 -0
  131. package/starters/default/src/modules/billing/billing.public.ts +5 -0
  132. package/starters/default/src/modules/billing/billing.routes.ts +9 -0
  133. package/starters/default/src/modules/billing/billing.types.ts +15 -0
  134. package/starters/default/src/modules/billing/components/invoice-status-badge.component.ts +43 -0
  135. package/starters/default/src/modules/billing/data/invoices.resource.ts +35 -0
  136. package/starters/default/src/modules/billing/pages/invoice-detail.page.ts +70 -0
  137. package/starters/default/src/modules/billing/pages/invoices-list.page.ts +73 -0
  138. package/starters/default/src/modules/home/home.page.ts +34 -0
  139. package/starters/default/src/modules/home/not-found.page.ts +11 -0
  140. package/starters/default/src/shared/http/http-client.ts +86 -0
  141. package/starters/default/src/shared/http/http-error.ts +37 -0
  142. package/starters/default/src/shared/http/interceptors.ts +59 -0
  143. package/starters/default/src/shared/lib/format-date.ts +19 -0
  144. package/starters/default/src/shared/styles/content.css +70 -0
  145. package/starters/default/src/shared/styles/reset.css +32 -0
  146. package/starters/default/src/shared/styles/shell.css +57 -0
  147. package/starters/default/src/shared/styles/tokens.css +44 -0
  148. package/starters/default/src/shared/ui/x-button.component.ts +49 -0
  149. package/starters/default/src/shared/ui/x-spinner.component.ts +22 -0
  150. package/starters/default/src/styles.d.ts +1 -0
  151. package/starters/default/src/vite-env.d.ts +1 -0
  152. package/starters/default/tsconfig.json +24 -0
  153. package/starters/default/vite.config.ts +9 -0
  154. package/MADO_V1_PLAN.md +0 -179
  155. package/ROADMAP.md +0 -178
  156. package/dist/src/html.d.ts +0 -18
  157. package/dist/src/html.js +0 -17
  158. package/dist/src/html.js.map +0 -1
  159. package/dist/src/router.d.ts +0 -13
  160. package/dist/src/router.js +0 -13
  161. package/dist/src/router.js.map +0 -1
  162. package/scripts/bundle.mjs +0 -212
  163. package/scripts/llm-zero-history-smoke.mjs +0 -93
  164. package/scripts/new.mjs +0 -80
  165. package/scripts/showcase-regression.mjs +0 -392
  166. package/server/serve.mjs +0 -455
  167. package/starters/admin/README.md +0 -63
  168. package/starters/admin/index.html +0 -28
  169. package/starters/admin/mado.config.json +0 -22
  170. package/starters/admin/package.json +0 -24
  171. package/starters/admin/public/favicon.svg +0 -4
  172. package/starters/admin/src/components/x-button.ts +0 -82
  173. package/starters/admin/src/components/x-input.ts +0 -105
  174. package/starters/admin/src/layouts/app.ts +0 -101
  175. package/starters/admin/src/layouts/auth.ts +0 -41
  176. package/starters/admin/src/lib/api.ts +0 -184
  177. package/starters/admin/src/lib/auth.ts +0 -83
  178. package/starters/admin/src/main.ts +0 -15
  179. package/starters/admin/src/pages/admin/dashboard.ts +0 -48
  180. package/starters/admin/src/pages/admin/order-detail.ts +0 -80
  181. package/starters/admin/src/pages/admin/orders.ts +0 -117
  182. package/starters/admin/src/pages/home.ts +0 -34
  183. package/starters/admin/src/pages/login.ts +0 -70
  184. package/starters/admin/src/pages/not-found.ts +0 -12
  185. package/starters/admin/src/routes.ts +0 -40
  186. package/starters/admin/src/styles/global.ts +0 -86
  187. package/starters/admin/tsconfig.json +0 -15
  188. package/starters/crud/README.md +0 -33
  189. package/starters/crud/index.html +0 -28
  190. package/starters/crud/mado.config.json +0 -20
  191. package/starters/crud/package.json +0 -24
  192. package/starters/crud/src/components/app-shell.ts +0 -56
  193. package/starters/crud/src/components/ticket-detail.ts +0 -33
  194. package/starters/crud/src/components/ticket-form.ts +0 -69
  195. package/starters/crud/src/components/ticket-list.ts +0 -66
  196. package/starters/crud/src/lib/api.ts +0 -76
  197. package/starters/crud/src/main.ts +0 -9
  198. package/starters/crud/src/pages/home.ts +0 -34
  199. package/starters/crud/src/pages/not-found.ts +0 -12
  200. package/starters/crud/src/pages/ticket-detail.ts +0 -7
  201. package/starters/crud/src/pages/ticket-new.ts +0 -7
  202. package/starters/crud/src/pages/tickets.ts +0 -7
  203. package/starters/crud/src/routes.ts +0 -11
  204. package/starters/crud/src/styles/global.ts +0 -155
  205. package/starters/crud/tsconfig.json +0 -15
  206. package/starters/minimal/README.md +0 -21
  207. package/starters/minimal/index.html +0 -28
  208. package/starters/minimal/mado.config.json +0 -20
  209. package/starters/minimal/package.json +0 -24
  210. package/starters/minimal/src/components/app-counter.ts +0 -31
  211. package/starters/minimal/src/main.ts +0 -9
  212. package/starters/minimal/src/pages/home.ts +0 -35
  213. package/starters/minimal/src/pages/not-found.ts +0 -14
  214. package/starters/minimal/src/routes.ts +0 -8
  215. package/starters/minimal/src/styles/global.ts +0 -60
  216. package/starters/minimal/tsconfig.json +0 -15
  217. package/templates/page-detail.ts +0 -63
  218. package/templates/page-form.ts +0 -94
  219. package/templates/page-list.ts +0 -79
@@ -1,105 +0,0 @@
1
- // <x-input label name type placeholder required value error @input @blur>
2
- //
3
- // Labeled input that proxies its events. Use inside `useForm()`:
4
- //
5
- // <x-input name="email" type="email" required
6
- // @input=${form.onInput} @blur=${form.onBlur}></x-input>
7
- //
8
- // Shadow DOM integration notes:
9
- // - `name` and `value` are exposed as DOM properties on the host so that
10
- // event retargeting (e.target → <x-input>) still works with useForm().
11
- // useForm.onInput reads e.target.name and e.target.value — without these
12
- // getters the form silently receives undefined.
13
- // - The inner <input> dispatches its events with `composed: true` (native
14
- // behaviour), so @input/@blur bubble through the shadow boundary.
15
-
16
- import { component, css, html } from "@madojs/mado";
17
-
18
- component(
19
- "x-input",
20
- ({ host, attr }) => {
21
- const label = attr("label", "");
22
- const name = attr("name", "");
23
- const type = attr("type", "text");
24
- const placeholder = attr("placeholder", "");
25
- const required = attr("required");
26
- const value = attr("value", "");
27
- const error = attr("error");
28
-
29
- // Proxy properties so useForm().onInput can read e.target.name / .value
30
- // even after Shadow DOM retargets e.target from <input> to <x-input>.
31
- Object.defineProperty(host, "name", {
32
- get: () => host.getAttribute("name") ?? "",
33
- configurable: true,
34
- });
35
- Object.defineProperty(host, "value", {
36
- get: () => host.shadowRoot?.querySelector("input")?.value ?? "",
37
- set: (v: string) => {
38
- const input = host.shadowRoot?.querySelector("input");
39
- if (input) input.value = v;
40
- },
41
- configurable: true,
42
- });
43
-
44
- return () => html`
45
- <label>
46
- ${() =>
47
- label()
48
- ? html`<span class="lbl"
49
- >${label}${() =>
50
- required() !== "" ? html`<em>*</em>` : null}</span
51
- >`
52
- : null}
53
- <input
54
- name=${name}
55
- type=${type}
56
- placeholder=${placeholder}
57
- ?required=${() => required() !== ""}
58
- .value=${value}
59
- />
60
- ${() => (error() ? html`<small class="err">${error}</small>` : null)}
61
- </label>
62
- `;
63
- },
64
- {
65
- styles: css`
66
- :host {
67
- display: block;
68
- }
69
- label {
70
- display: block;
71
- }
72
- .lbl {
73
- display: block;
74
- font-size: 12px;
75
- color: var(--fg-muted);
76
- margin-bottom: var(--space-1);
77
- }
78
- .lbl em {
79
- color: var(--danger);
80
- font-style: normal;
81
- margin-left: 2px;
82
- }
83
- input {
84
- width: 100%;
85
- padding: 8px 10px;
86
- font: inherit;
87
- background: var(--bg);
88
- color: var(--fg);
89
- border: 1px solid var(--border);
90
- border-radius: var(--radius-sm);
91
- }
92
- input:focus {
93
- outline: none;
94
- border-color: var(--accent);
95
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent);
96
- }
97
- .err {
98
- display: block;
99
- color: var(--danger);
100
- font-size: 12px;
101
- margin-top: var(--space-1);
102
- }
103
- `,
104
- },
105
- );
@@ -1,101 +0,0 @@
1
- // Admin shell layout: top bar + sidebar + content slot.
2
- //
3
- // A layout is just a `page({ view })` whose view renders `${ctx.child}`. The
4
- // router wraps every page in the enclosing group with this template, so the
5
- // shell is ALWAYS the outer frame, never below the page content.
6
- //
7
- // Keep the shell stateless if you can. Auth/user signals belong in
8
- // src/lib/auth.ts; the layout reads them.
9
-
10
- import { component, css, html, navigate, page } from "@madojs/mado";
11
- import { accessToken } from "../lib/api.js";
12
- import { logout } from "../lib/auth.js";
13
- import "../components/x-button.js";
14
-
15
- component(
16
- "x-admin-shell",
17
- () => () => html`
18
- <header>
19
- <a href="/admin" data-link class="brand">__APP_NAME__</a>
20
- <nav>
21
- <a href="/admin" data-link>Dashboard</a>
22
- <a href="/admin/orders" data-link>Orders</a>
23
- </nav>
24
- <span class="spacer"></span>
25
- <span class="user">${() => maskToken(accessToken())}</span>
26
- <x-button
27
- variant="ghost"
28
- @click=${async () => {
29
- await logout();
30
- navigate("/login", { replace: true });
31
- }}
32
- >Sign out</x-button>
33
- </header>
34
- <aside>
35
- <nav>
36
- <a href="/admin" data-link>Overview</a>
37
- <a href="/admin/orders" data-link>Orders</a>
38
- </nav>
39
- </aside>
40
- <main>
41
- <slot></slot>
42
- </main>
43
- `,
44
- {
45
- styles: css`
46
- :host {
47
- display: grid;
48
- grid-template-columns: 220px 1fr;
49
- grid-template-rows: 56px 1fr;
50
- grid-template-areas:
51
- "topbar topbar"
52
- "side main";
53
- min-height: 100vh;
54
- }
55
- header {
56
- grid-area: topbar;
57
- display: flex;
58
- align-items: center;
59
- gap: var(--space-4);
60
- padding: 0 var(--space-5);
61
- background: var(--bg-elevated);
62
- border-bottom: 1px solid var(--border);
63
- }
64
- header .brand { font-weight: 700; color: var(--fg); }
65
- header nav { display: flex; gap: var(--space-3); }
66
- header nav a { color: var(--fg-muted); }
67
- header nav a:hover { color: var(--fg); text-decoration: none; }
68
- header .user { color: var(--fg-muted); font-variant-numeric: tabular-nums; }
69
- .spacer { flex: 1; }
70
- aside {
71
- grid-area: side;
72
- border-right: 1px solid var(--border);
73
- background: var(--bg-elevated);
74
- padding: var(--space-4);
75
- }
76
- aside nav { display: flex; flex-direction: column; gap: var(--space-2); }
77
- aside nav a {
78
- padding: var(--space-2) var(--space-3);
79
- border-radius: var(--radius-sm);
80
- color: var(--fg-muted);
81
- }
82
- aside nav a:hover {
83
- background: var(--bg);
84
- color: var(--fg);
85
- text-decoration: none;
86
- }
87
- main { grid-area: main; padding: var(--space-5); }
88
- `,
89
- },
90
- );
91
-
92
- function maskToken(token: string | null): string {
93
- if (!token) return "—";
94
- return `signed in · …${token.slice(-4)}`;
95
- }
96
-
97
- export default page({
98
- view: ({ child }) => html`
99
- <x-admin-shell>${child}</x-admin-shell>
100
- `,
101
- });
@@ -1,41 +0,0 @@
1
- // Centered card layout for auth pages (login, signup, password reset).
2
- // Identical shape to layouts/app.ts but a different shell.
3
-
4
- import { component, css, html, page } from "@madojs/mado";
5
-
6
- component(
7
- "x-auth-shell",
8
- () => () => html`
9
- <main>
10
- <div class="card">
11
- <slot></slot>
12
- </div>
13
- </main>
14
- `,
15
- {
16
- styles: css`
17
- :host {
18
- display: block;
19
- min-height: 100vh;
20
- }
21
- main {
22
- min-height: 100vh;
23
- display: grid;
24
- place-items: center;
25
- padding: var(--space-5);
26
- }
27
- .card {
28
- background: var(--bg-elevated);
29
- border: 1px solid var(--border);
30
- border-radius: var(--radius);
31
- box-shadow: var(--shadow-1);
32
- padding: var(--space-6);
33
- width: min(360px, 100%);
34
- }
35
- `,
36
- },
37
- );
38
-
39
- export default page({
40
- view: ({ child }) => html`<x-auth-shell>${child}</x-auth-shell>`,
41
- });
@@ -1,184 +0,0 @@
1
- // Blessed API client recipe.
2
- //
3
- // One JSON fetch wrapper. One error type. Bearer token from `accessToken`.
4
- // A refresh path that asks `/api/auth/refresh` (HttpOnly cookie) and retries
5
- // once. Aborts via `AbortSignal`. JSON in / JSON out.
6
- //
7
- // Copy and adjust to your backend. Keep `api` as the single fetch boundary so
8
- // that auth, refresh, error parsing and tracing live in exactly one place.
9
-
10
- import { signal } from "@madojs/mado";
11
-
12
- /** Memory-only access token. Refresh restores it from an HttpOnly cookie. */
13
- export const accessToken = signal<string | null>(null);
14
-
15
- /** Typed HTTP error with structured `body` for the UI to inspect. */
16
- export class ApiError extends Error {
17
- constructor(
18
- public status: number,
19
- public body: unknown,
20
- message: string,
21
- ) {
22
- super(message);
23
- this.name = "ApiError";
24
- }
25
- }
26
-
27
- export interface ApiInit extends Omit<RequestInit, "body"> {
28
- /** JSON-serialisable body. If set, `content-type: application/json` is added. */
29
- json?: unknown;
30
- /** Override the default base URL for this call. */
31
- baseUrl?: string;
32
- }
33
-
34
- /**
35
- * Join a base path and a relative path without losing prefixes.
36
- * Works with both relative (`/api`) and absolute (`https://...`) bases.
37
- */
38
- function joinUrl(base: string, path: string): string {
39
- if (/^https?:\/\//.test(path)) return path;
40
-
41
- const p = path.replace(/^\/+/, "");
42
-
43
- // Absolute URL base — keep its pathname prefix (for example /api).
44
- if (/^https?:\/\//.test(base)) {
45
- return new URL(p, base.replace(/\/+$/, "") + "/").href;
46
- }
47
-
48
- // Relative base (e.g. "/api") — simple string join, normalising slashes.
49
- const b = base.replace(/\/+$/, "");
50
- return p ? `${b}/${p}` : b || "/";
51
- }
52
-
53
- /**
54
- * Create an API client bound to a base URL. Returns an `api()` function that
55
- * speaks JSON, attaches the access token, and retries once after a 401 if a
56
- * refresh succeeds.
57
- *
58
- * export const api = createApiClient("/api");
59
- * const user = await api<User>("/users/me");
60
- * await api("/posts", { method: "POST", json: { title: "hi" } });
61
- */
62
- export function createApiClient(baseUrl: string) {
63
- let refreshing: Promise<boolean> | null = null;
64
-
65
- async function refresh(): Promise<boolean> {
66
- if (refreshing) return refreshing;
67
- refreshing = (async () => {
68
- try {
69
- const res = await fetch(joinUrl(baseUrl, "/auth/refresh"), {
70
- method: "POST",
71
- credentials: "include",
72
- });
73
- if (!res.ok) return false;
74
- const data = (await res.json().catch(() => null)) as {
75
- accessToken?: string;
76
- } | null;
77
- if (!data?.accessToken) return false;
78
- accessToken.set(data.accessToken);
79
- return true;
80
- } catch {
81
- return false;
82
- } finally {
83
- refreshing = null;
84
- }
85
- })();
86
- return refreshing;
87
- }
88
-
89
- async function request<T>(
90
- path: string,
91
- init: ApiInit = {},
92
- retried = false,
93
- ): Promise<T> {
94
- const url = joinUrl(init.baseUrl ?? baseUrl, path);
95
- const headers = new Headers(init.headers);
96
- if (init.json !== undefined && !headers.has("content-type")) {
97
- headers.set("content-type", "application/json");
98
- }
99
- const token = accessToken();
100
- if (token) headers.set("authorization", `Bearer ${token}`);
101
-
102
- const res = await fetch(url, {
103
- ...init,
104
- headers,
105
- credentials: init.credentials ?? "include",
106
- body:
107
- init.json !== undefined
108
- ? JSON.stringify(init.json)
109
- : (init as RequestInit).body,
110
- });
111
-
112
- if (res.status === 401) {
113
- if (!retried && (await refresh())) {
114
- return request<T>(path, init, true);
115
- }
116
- accessToken.set(null);
117
- throw new ApiError(401, null, "Unauthorized");
118
- }
119
- if (!res.ok) {
120
- const body = await res.json().catch(() => null);
121
- throw new ApiError(
122
- res.status,
123
- body,
124
- `HTTP ${res.status} ${res.statusText}`,
125
- );
126
- }
127
- if (res.status === 204) return null as unknown as T;
128
- return (await res.json()) as T;
129
- }
130
-
131
- return function api<T>(path: string, init: ApiInit = {}): Promise<T> {
132
- return request<T>(path, init);
133
- };
134
- }
135
-
136
- /** Default app-wide client. Change the base URL via mado.config.json dev.proxy. */
137
- export const api = createApiClient("/api");
138
-
139
- /**
140
- * Fetcher for resource() that attaches the Bearer token automatically.
141
- * Use this instead of jsonFetcher() for protected endpoints:
142
- *
143
- * const stats = resource(() => "/api/admin/stats", apiFetcher<Stats>());
144
- *
145
- * Unlike jsonFetcher(), this:
146
- * - Sends the access token from memory (no cookie-based auth needed).
147
- * - Does NOT prepend a base URL — the key is the full URL (matches
148
- * resource key semantics).
149
- * - Throws ApiError on non-2xx (consistent with api()).
150
- */
151
- export function apiFetcher<T>(): (
152
- url: string,
153
- signal: AbortSignal,
154
- ) => Promise<T> {
155
- return async (url, abortSignal) => {
156
- const token = accessToken();
157
- const headers = new Headers();
158
- headers.set("accept", "application/json");
159
- if (token) headers.set("authorization", `Bearer ${token}`);
160
-
161
- const res = await fetch(url, {
162
- signal: abortSignal,
163
- headers,
164
- credentials: "include",
165
- });
166
-
167
- if (!res.ok) {
168
- let body: unknown = null;
169
- try {
170
- const ct = res.headers.get("content-type") ?? "";
171
- body = ct.includes("json") ? await res.json() : await res.text();
172
- } catch {
173
- body = null;
174
- }
175
- throw new ApiError(
176
- res.status,
177
- body,
178
- `HTTP ${res.status} ${res.statusText}`,
179
- );
180
- }
181
- if (res.status === 204) return null as unknown as T;
182
- return (await res.json()) as T;
183
- };
184
- }
@@ -1,83 +0,0 @@
1
- // Blessed auth recipe: memory-only access token + HttpOnly-cookie refresh +
2
- // `requireAuth` guard for use in nested route groups.
3
- //
4
- // Usage in routes.ts:
5
- //
6
- // "/admin": layout({
7
- // layout: () => import("./layouts/app.js"),
8
- // guard: requireAuth,
9
- // routes: { ... },
10
- // })
11
- //
12
- // When a user lands on a protected route, requireAuth() tries to silently
13
- // restore the access token via the refresh cookie. If that fails, it redirects
14
- // to `/login?return=<original-url>`. The login page reads `return` and
15
- // navigates back after a successful sign-in.
16
-
17
- import type { Guard } from "@madojs/mado";
18
- import { accessToken, api, ApiError } from "./api.js";
19
-
20
- let restorePromise: Promise<boolean> | null = null;
21
-
22
- /**
23
- * Try once per session to restore the access token from the HttpOnly refresh
24
- * cookie. Subsequent calls reuse the same promise so a hard refresh that hits
25
- * five protected routes does not fire five refresh requests.
26
- */
27
- export async function restoreSession(): Promise<boolean> {
28
- if (accessToken()) return true;
29
- if (restorePromise) return restorePromise;
30
- restorePromise = (async () => {
31
- try {
32
- const data = await api<{ accessToken: string }>("/auth/refresh", {
33
- method: "POST",
34
- });
35
- accessToken.set(data.accessToken);
36
- return true;
37
- } catch (e) {
38
- // 401 is expected for unauthenticated visitors.
39
- if (e instanceof ApiError && e.status === 401) return false;
40
- return false;
41
- } finally {
42
- restorePromise = null;
43
- }
44
- })();
45
- return restorePromise;
46
- }
47
-
48
- /**
49
- * Route guard: only let the user in if they have a valid session. Otherwise
50
- * redirect to /login, preserving the original URL as `?return=`.
51
- */
52
- export const requireAuth: Guard = async ({ path }) => {
53
- if (accessToken()) return;
54
- if (await restoreSession()) return;
55
- return {
56
- redirect: `/login?return=${encodeURIComponent(path)}`,
57
- replace: true,
58
- };
59
- };
60
-
61
- export interface LoginCredentials {
62
- email: string;
63
- password: string;
64
- }
65
-
66
- /** Log in. Persists the access token in memory; refresh cookie is set by the server. */
67
- export async function login(creds: LoginCredentials): Promise<void> {
68
- const data = await api<{ accessToken: string }>("/auth/login", {
69
- method: "POST",
70
- json: creds,
71
- });
72
- accessToken.set(data.accessToken);
73
- }
74
-
75
- /** Log out everywhere: drop token in memory and tell the server to invalidate the refresh cookie. */
76
- export async function logout(): Promise<void> {
77
- try {
78
- await api("/auth/logout", { method: "POST" });
79
- } catch {
80
- // Best-effort; even if the network is offline we still clear locally.
81
- }
82
- accessToken.set(null);
83
- }
@@ -1,15 +0,0 @@
1
- // App entry point.
2
- //
3
- // The single job of main.ts is: mount the router into #app. Everything else
4
- // (layouts, guards, pages, auth) is declared in routes.ts and the modules it
5
- // imports. Do NOT wrap routes in a custom shell here — the shell is a `layout()`
6
- // (see src/layouts/) so it can be different per route group.
7
-
8
- import { html, render } from "@madojs/mado";
9
- import "./styles/global.js";
10
- import router from "./routes.js";
11
-
12
- const app = document.getElementById("app");
13
- if (!app) throw new Error("#app not found");
14
-
15
- render(html`${router.view}`, app);
@@ -1,48 +0,0 @@
1
- // Admin dashboard. Demonstrates `resource()` for loading data with
2
- // loading/error states.
3
-
4
- import { html, jsonFetcher, page, resource } from "@madojs/mado";
5
-
6
- interface Stats {
7
- orders: number;
8
- revenue: number;
9
- customers: number;
10
- }
11
-
12
- export default page({
13
- title: "Dashboard",
14
- view: () => {
15
- const stats = resource(() => "/api/admin/stats", jsonFetcher<Stats>(), {
16
- staleTime: 30_000,
17
- });
18
-
19
- return html`
20
- <h1 style="margin:0 0 24px;">Dashboard</h1>
21
- ${() => {
22
- if (stats.loading()) return html`<p class="muted">Loading…</p>`;
23
- if (stats.error())
24
- return html`<p style="color:var(--danger);">${stats.error()?.message}</p>`;
25
- const s = stats.data();
26
- if (!s) return null;
27
- return html`
28
- <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-4);">
29
- <div class="card">
30
- <div class="muted">Orders</div>
31
- <div style="font-size:24px;font-weight:700;">${s.orders}</div>
32
- </div>
33
- <div class="card">
34
- <div class="muted">Revenue</div>
35
- <div style="font-size:24px;font-weight:700;">
36
- $${s.revenue.toLocaleString()}
37
- </div>
38
- </div>
39
- <div class="card">
40
- <div class="muted">Customers</div>
41
- <div style="font-size:24px;font-weight:700;">${s.customers}</div>
42
- </div>
43
- </div>
44
- `;
45
- }}
46
- `;
47
- },
48
- });
@@ -1,80 +0,0 @@
1
- // Order detail page. Reads :id from params, fetches the order, and shows
2
- // inline loading/error/empty/ready states.
3
-
4
- import { each, html, jsonFetcher, page, resource } from "@madojs/mado";
5
-
6
- interface OrderDetail {
7
- id: string;
8
- customer: string;
9
- total: number;
10
- status: string;
11
- items: Array<{ sku: string; name: string; qty: number; price: number }>;
12
- }
13
-
14
- export default page<{ id: string }>({
15
- title: ({ id }) => `Order ${id}`,
16
- view: ({ params }) => {
17
- const order = resource(
18
- () => `/api/admin/orders/${params.id}`,
19
- jsonFetcher<OrderDetail>(),
20
- { staleTime: 15_000 },
21
- );
22
-
23
- return html`
24
- <p>
25
- <a href="/admin/orders" data-link>← All orders</a>
26
- </p>
27
- ${() => {
28
- if (order.loading() && !order.data())
29
- return html`<p class="muted">Loading order…</p>`;
30
- if (order.error())
31
- return html`<p style="color:var(--danger);">${order.error()?.message}</p>`;
32
- const o = order.data();
33
- if (!o)
34
- return html`<p class="muted">Order not found.</p>`;
35
- return html`
36
- <h1 style="margin:0 0 8px;">Order ${o.id}</h1>
37
- <p class="muted" style="margin:0 0 24px;">${o.customer} · ${o.status}</p>
38
- <div class="card">
39
- <table style="width:100%;border-collapse:collapse;">
40
- <thead>
41
- <tr style="text-align:left;border-bottom:1px solid var(--border);">
42
- <th style="padding:8px 0;">SKU</th>
43
- <th style="padding:8px 0;">Item</th>
44
- <th style="padding:8px 0;text-align:right;">Qty</th>
45
- <th style="padding:8px 0;text-align:right;">Price</th>
46
- </tr>
47
- </thead>
48
- <tbody>
49
- ${each(
50
- o.items,
51
- (it) => it.sku,
52
- (it) => html`
53
- <tr style="border-bottom:1px solid var(--border);">
54
- <td style="padding:8px 0;">${it.sku}</td>
55
- <td style="padding:8px 0;">${it.name}</td>
56
- <td style="padding:8px 0;text-align:right;">${it.qty}</td>
57
- <td style="padding:8px 0;text-align:right;font-variant-numeric:tabular-nums;">
58
- $${it.price.toFixed(2)}
59
- </td>
60
- </tr>
61
- `,
62
- )}
63
- </tbody>
64
- <tfoot>
65
- <tr>
66
- <td colspan="3" style="padding-top:12px;text-align:right;font-weight:600;">
67
- Total
68
- </td>
69
- <td style="padding-top:12px;text-align:right;font-weight:600;font-variant-numeric:tabular-nums;">
70
- $${o.total.toFixed(2)}
71
- </td>
72
- </tr>
73
- </tfoot>
74
- </table>
75
- </div>
76
- `;
77
- }}
78
- `;
79
- },
80
- });