@zerotal/arch 1.7.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/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- package/src/tools/searchDocs.ts +345 -0
package/docs/view.md
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: View
|
|
3
|
+
description: Render server-side HTML from typed JSX components that escape untrusted data by default.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# View
|
|
7
|
+
|
|
8
|
+
Zerotal's view layer is a JSX server-side rendering engine built into
|
|
9
|
+
`@zerotal/core` — the same package that provides `ctx.view()`. It compiles JSX
|
|
10
|
+
to plain HTML strings at request time: no virtual DOM, no hydration, and no
|
|
11
|
+
client JavaScript unless you opt in.
|
|
12
|
+
|
|
13
|
+
## Getting Started
|
|
14
|
+
|
|
15
|
+
The view runtime ships inside `@zerotal/core`, so there is no package to install
|
|
16
|
+
or provider to register — you only point the TypeScript JSX transform at it. Set
|
|
17
|
+
it once in `tsconfig.json` and every `.tsx` file in the project is covered:
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
// tsconfig.json
|
|
21
|
+
{
|
|
22
|
+
"compilerOptions": {
|
|
23
|
+
"jsx": "react-jsx",
|
|
24
|
+
"jsxImportSource": "zerotal"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Scaffolded projects already have this — `bun create zerotal` writes it into the
|
|
30
|
+
template's `tsconfig.json`, so the JSX examples below compile as-is.
|
|
31
|
+
|
|
32
|
+
## Basic usage
|
|
33
|
+
|
|
34
|
+
Components are plain TypeScript functions that return `SafeHtml`. Type them with
|
|
35
|
+
`FC<Props>` for the standard `(props, children) => SafeHtml` shape:
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
// resources/views/Card.tsx
|
|
39
|
+
import type { FC } from "zerotal/view";
|
|
40
|
+
|
|
41
|
+
interface CardProps {
|
|
42
|
+
title: string;
|
|
43
|
+
children?: unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const Card: FC<CardProps> = ({ title, children }) => (
|
|
47
|
+
<div class="card">
|
|
48
|
+
<h2>{title}</h2>
|
|
49
|
+
<div class="card-body">{children}</div>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
All string children are HTML-escaped automatically. To embed pre-rendered HTML,
|
|
55
|
+
use the [`safe()` helper or `Raw` component](#embedding-raw-html).
|
|
56
|
+
|
|
57
|
+
> **Tip** — Prefer native HTML attribute names (`class`, `for`, `tabindex`, …).
|
|
58
|
+
> React-style `className` and `htmlFor` are accepted and mapped to `class`/`for`,
|
|
59
|
+
> but native names are idiomatic here.
|
|
60
|
+
|
|
61
|
+
> **Warning** — SSR only, no client interactivity. The runtime emits plain HTML
|
|
62
|
+
> strings; event-handler props like `onClick` are not serialized and are silently
|
|
63
|
+
> dropped. For interactive UI, reach for [Flow](/docs/flow) (server-driven)
|
|
64
|
+
> or [Inertia](/docs/inertia) (React/Vue) instead.
|
|
65
|
+
|
|
66
|
+
## Attributes
|
|
67
|
+
|
|
68
|
+
The runtime renders attributes from props with a few rules:
|
|
69
|
+
|
|
70
|
+
```tsx
|
|
71
|
+
// in a component
|
|
72
|
+
<input type="text" value={name} disabled={isLocked} required={false} data-id={42} />
|
|
73
|
+
// → <input type="text" value="…" disabled data-id="42">
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- **Boolean `true`** renders the bare attribute (`disabled`); **`false`, `null`,
|
|
77
|
+
and `undefined`** omit it entirely — ideal for conditional attributes.
|
|
78
|
+
- Only **string** and **number** values are rendered as `key="value"` (both
|
|
79
|
+
escaped).
|
|
80
|
+
- `className` → `class` and `htmlFor` → `for` are mapped; every other key is used
|
|
81
|
+
verbatim.
|
|
82
|
+
- **No object values.** `style` must be a string (`style="color:red"`), not an
|
|
83
|
+
object — non-string, non-number values are dropped.
|
|
84
|
+
- `key` is ignored in output (it's a JSX hint, not an HTML attribute).
|
|
85
|
+
|
|
86
|
+
## How children render
|
|
87
|
+
|
|
88
|
+
Children are rendered by type, which is the engine's security boundary:
|
|
89
|
+
|
|
90
|
+
| Child value | Output |
|
|
91
|
+
| --------------------------------------- | --------------------------------------- |
|
|
92
|
+
| `SafeHtml` (from JSX / `safe()`) | Passed through unescaped |
|
|
93
|
+
| `string` | **HTML-escaped** (treated as untrusted) |
|
|
94
|
+
| `number` | Stringified (inert — no escaping) |
|
|
95
|
+
| `true` / `false` / `null` / `undefined` | Renders nothing |
|
|
96
|
+
| `Array` | Each item rendered and concatenated |
|
|
97
|
+
|
|
98
|
+
Two consequences worth remembering:
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
// in a component
|
|
102
|
+
{
|
|
103
|
+
user && <Welcome name={user.name} />;
|
|
104
|
+
}
|
|
105
|
+
{
|
|
106
|
+
/* false/null → nothing, so guards just work */
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
{
|
|
110
|
+
count;
|
|
111
|
+
}
|
|
112
|
+
{
|
|
113
|
+
/* 0 renders "0" — numbers are never blank */
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Void elements (`<br>`, `<img>`, `<input>`, `<hr>`, `<meta>`, …) render without a
|
|
118
|
+
closing tag automatically.
|
|
119
|
+
|
|
120
|
+
## Rendering in a controller
|
|
121
|
+
|
|
122
|
+
Call `view()` from a controller action to set the response. It accepts any value
|
|
123
|
+
whose `.toString()` returns HTML — a `SafeHtml` instance from JSX is the normal
|
|
124
|
+
case:
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
// app/controllers/PostController.tsx
|
|
128
|
+
import { view } from "zerotal";
|
|
129
|
+
import type { HttpContext } from "zerotal";
|
|
130
|
+
import { Post } from "../models/Post.ts";
|
|
131
|
+
import { PostList } from "../resources/views/PostList.tsx";
|
|
132
|
+
|
|
133
|
+
export class PostController {
|
|
134
|
+
async index(ctx: HttpContext) {
|
|
135
|
+
const posts = await Post.query().latest().get();
|
|
136
|
+
view(<PostList posts={posts} title="All Posts" />);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Passing a component and props
|
|
142
|
+
|
|
143
|
+
You can also hand `view()` (or `ctx.view()`) the **component itself** plus its
|
|
144
|
+
props. The component receives the request `HttpContext` as its first argument and
|
|
145
|
+
your props as its second; route params and model bindings reach it through
|
|
146
|
+
`ctx.params`:
|
|
147
|
+
|
|
148
|
+
```tsx
|
|
149
|
+
// resources/views/Welcome.tsx
|
|
150
|
+
import type { HttpContext } from "zerotal";
|
|
151
|
+
|
|
152
|
+
export default function Welcome(ctx: HttpContext, { title }: { title: string }) {
|
|
153
|
+
return (
|
|
154
|
+
<div>
|
|
155
|
+
<h1>{title}</h1>
|
|
156
|
+
<p>{ctx.url.pathname}</p>
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
// routes/index.ts — the HttpContext is injected; you only pass the extra props
|
|
164
|
+
import { Router } from "zerotal";
|
|
165
|
+
import Welcome from "../resources/views/Welcome.tsx";
|
|
166
|
+
|
|
167
|
+
Router.get("/", () => view(Welcome, { title: "Welcome to Zerotal" }));
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The component may be async — `view()` awaits it before setting the response.
|
|
171
|
+
|
|
172
|
+
> **Warning** — JSX renders synchronously. A JSX expression evaluates to
|
|
173
|
+
> `SafeHtml` right away, so components used inline (`<PostList … />`) can't be
|
|
174
|
+
> `async`. If you need to load data while rendering, use the component + props
|
|
175
|
+
> form above (which awaits an async component), or do the loading in the
|
|
176
|
+
> controller or a [`Router.view`](#controller-less-pages) props factory and pass
|
|
177
|
+
> the resolved data in as props.
|
|
178
|
+
|
|
179
|
+
### Which view form should I use?
|
|
180
|
+
|
|
181
|
+
| You have… | Use |
|
|
182
|
+
| ------------------------------------------------------------- | --------------------------------------- |
|
|
183
|
+
| Already-rendered JSX, data loaded synchronously | `view(<Page … />)` |
|
|
184
|
+
| An async component, or want the `HttpContext`/params injected | `view(Page, props)` |
|
|
185
|
+
| A page with no controller at all | [`Router.view`](#controller-less-pages) |
|
|
186
|
+
| A page resolved from the filesystem | [File-based pages](#file-based-pages) |
|
|
187
|
+
|
|
188
|
+
## Layouts
|
|
189
|
+
|
|
190
|
+
Bind a shared layout to page components so every page doesn't need to manually
|
|
191
|
+
wrap its content. `defineLayout(Layout)` returns a `wrap(Page)` factory; the
|
|
192
|
+
wrapped page merges the layout's props (minus `children`) with its own:
|
|
193
|
+
|
|
194
|
+
```tsx
|
|
195
|
+
// resources/views/layouts/AppLayout.tsx
|
|
196
|
+
|
|
197
|
+
interface AppLayoutProps {
|
|
198
|
+
title: string;
|
|
199
|
+
children?: unknown;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function AppLayout({ title, children }: AppLayoutProps) {
|
|
203
|
+
return (
|
|
204
|
+
<html lang="en">
|
|
205
|
+
<head>
|
|
206
|
+
<meta charset="UTF-8" />
|
|
207
|
+
<title>{title} — My App</title>
|
|
208
|
+
<link rel="stylesheet" href="/assets/app.css" />
|
|
209
|
+
</head>
|
|
210
|
+
<body>{children}</body>
|
|
211
|
+
</html>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
```tsx
|
|
217
|
+
// resources/views/About.tsx
|
|
218
|
+
import { defineLayout } from "zerotal/view";
|
|
219
|
+
import { AppLayout } from "./layouts/AppLayout.tsx";
|
|
220
|
+
|
|
221
|
+
const wrap = defineLayout(AppLayout);
|
|
222
|
+
|
|
223
|
+
// AboutPage receives { title } (from AppLayout) merged with its own props
|
|
224
|
+
export const AboutPage = wrap<{ heading: string }>(({ heading }) => (
|
|
225
|
+
<main>
|
|
226
|
+
<h1>{heading}</h1>
|
|
227
|
+
<p>We build things.</p>
|
|
228
|
+
</main>
|
|
229
|
+
));
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
// routes/index.ts
|
|
234
|
+
import { Router } from "zerotal";
|
|
235
|
+
import { AboutPage } from "../resources/views/About.tsx";
|
|
236
|
+
|
|
237
|
+
Router.view("/about", AboutPage, { title: "About Us", heading: "Hello" });
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
`Router.view()` calls the component and responds with the HTML string.
|
|
241
|
+
|
|
242
|
+
## Controller-less pages
|
|
243
|
+
|
|
244
|
+
For server-rendered pages that don't need a controller, register the component
|
|
245
|
+
directly with `Router.view()`. The third argument is the props — a **static
|
|
246
|
+
object**, or a **per-request factory** that receives the `HttpContext` and may be
|
|
247
|
+
async:
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
// routes/index.ts
|
|
251
|
+
// Static props — evaluated once at registration (marketing / info pages):
|
|
252
|
+
Router.view("/about", AboutPage, { title: "About Us" });
|
|
253
|
+
|
|
254
|
+
// Dynamic props — resolved per request, can be async:
|
|
255
|
+
Router.view("/dashboard", DashboardPage, async (ctx) => ({
|
|
256
|
+
user: ctx.user,
|
|
257
|
+
posts: await Post.query().where("user_id", ctx.user!.id).get(),
|
|
258
|
+
}));
|
|
259
|
+
|
|
260
|
+
// No props:
|
|
261
|
+
Router.view("/privacy", PrivacyPage);
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
The returned registration is chainable:
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
// routes/index.ts
|
|
268
|
+
Router.view("/terms", TermsPage)
|
|
269
|
+
.name("terms") // name the route for url() generation
|
|
270
|
+
.withLayout(AppLayout); // wrap the output in a layout component
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
## File-based pages
|
|
274
|
+
|
|
275
|
+
Under [file-based routing](/docs/routing), a page file's **default export**
|
|
276
|
+
becomes a `GET` route automatically. The page is a function of `(http, params)`
|
|
277
|
+
and may be async:
|
|
278
|
+
|
|
279
|
+
```tsx
|
|
280
|
+
// app/views/posts/[slug].tsx → GET /posts/:slug
|
|
281
|
+
import type { HttpContext } from "zerotal";
|
|
282
|
+
import { Post } from "../../models/Post.ts";
|
|
283
|
+
|
|
284
|
+
export default async function PostPage(http: HttpContext, params: { slug: string }) {
|
|
285
|
+
const post = await Post.query().where("slug", params.slug).firstOrFail();
|
|
286
|
+
return (
|
|
287
|
+
<article>
|
|
288
|
+
<h1>{post.title}</h1>
|
|
289
|
+
<p>{post.excerpt}</p>
|
|
290
|
+
</article>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Optional — wrap every page in this directory with a layout:
|
|
295
|
+
export { AppLayout as layout } from "../layouts/AppLayout.tsx";
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
In a `.tsx`/`.jsx` file the default-exported function is detected as a page
|
|
299
|
+
automatically. If your page lives in a plain `.ts` file, mark it with
|
|
300
|
+
`definePage()` so the resolver recognizes it:
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
// app/views/greet.ts
|
|
304
|
+
import { definePage } from "zerotal/view";
|
|
305
|
+
|
|
306
|
+
export default definePage((http, params) => `<h1>Hello ${params.name}</h1>`);
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
## Embedding raw HTML
|
|
310
|
+
|
|
311
|
+
When you have pre-rendered markup (e.g. from a Markdown renderer) use `safe()` or
|
|
312
|
+
`<Raw>` to bypass escaping:
|
|
313
|
+
|
|
314
|
+
```tsx
|
|
315
|
+
// in a component
|
|
316
|
+
import { safe, Raw } from "zerotal/view";
|
|
317
|
+
|
|
318
|
+
// Option 1 — safe(): wrap a string as SafeHtml inline
|
|
319
|
+
<article>{safe(markdownToHtml(post.body))}</article>
|
|
320
|
+
|
|
321
|
+
// Option 2 — Raw component: composable, e.g. passed as a child
|
|
322
|
+
<article><Raw html={markdownToHtml(post.body)} /></article>
|
|
323
|
+
|
|
324
|
+
// Option 3 — dangerouslySetInnerHTML on an element (best for a single element)
|
|
325
|
+
<article dangerouslySetInnerHTML={{ __html: markdownToHtml(post.body) }} />
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
`dangerouslySetInnerHTML` replaces the element's children entirely.
|
|
329
|
+
|
|
330
|
+
> **Danger** — All three bypass escaping. Only pass HTML you trust or have
|
|
331
|
+
> sanitized; rendering untrusted markup this way is an XSS hole.
|
|
332
|
+
|
|
333
|
+
## Escaping outside JSX
|
|
334
|
+
|
|
335
|
+
`esc()` escapes a value for use in raw string templates, where automatic JSX
|
|
336
|
+
escaping isn't available:
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
// in a helper
|
|
340
|
+
import { esc } from "zerotal/view";
|
|
341
|
+
|
|
342
|
+
const snippet = `<p>Hello, ${esc(user.name)}!</p>`;
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
## Fragments
|
|
346
|
+
|
|
347
|
+
Use `Fragment` (or the `<>...</>` shorthand) to return multiple root elements:
|
|
348
|
+
|
|
349
|
+
```tsx
|
|
350
|
+
// in a component
|
|
351
|
+
import { Fragment } from "zerotal/view";
|
|
352
|
+
|
|
353
|
+
const Items = ({ items }: { items: string[] }) => (
|
|
354
|
+
<>
|
|
355
|
+
{items.map((item) => (
|
|
356
|
+
<li>{item}</li>
|
|
357
|
+
))}
|
|
358
|
+
</>
|
|
359
|
+
);
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
## Testing
|
|
363
|
+
|
|
364
|
+
Set your suite up once as described in [Testing](/docs/testing). A view is a
|
|
365
|
+
function returning a node that stringifies, so most of it tests without a server.
|
|
366
|
+
|
|
367
|
+
**Render the component and assert on the string.** No request, no router, no
|
|
368
|
+
browser:
|
|
369
|
+
|
|
370
|
+
```typescript
|
|
371
|
+
// tests/views/PostCard.test.ts
|
|
372
|
+
import { test, expect } from "bun:test";
|
|
373
|
+
import { PostCard } from "../../resources/views/PostCard.tsx";
|
|
374
|
+
|
|
375
|
+
test("renders the title and author", () => {
|
|
376
|
+
const html = String(PostCard({ post: { title: "Hello", author: "Jane" } }));
|
|
377
|
+
|
|
378
|
+
expect(html).toContain("Hello");
|
|
379
|
+
expect(html).toContain("Jane");
|
|
380
|
+
});
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
**Escaping is the test that matters.** Every view that renders user input has one
|
|
384
|
+
job beyond looking right, and it is the job that becomes a security incident:
|
|
385
|
+
|
|
386
|
+
```typescript
|
|
387
|
+
// tests/views/PostCard.test.ts
|
|
388
|
+
test("escapes markup in user-supplied text", () => {
|
|
389
|
+
const html = String(PostCard({ post: { title: "<script>alert(1)</script>" } }));
|
|
390
|
+
|
|
391
|
+
expect(html).not.toContain("<script>alert(1)</script>");
|
|
392
|
+
expect(html).toContain("<script>");
|
|
393
|
+
});
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
Write the same test for anything passed through `safe()` or `<Raw>` — those opt
|
|
397
|
+
out of escaping deliberately, so the test documents that the value is trusted and
|
|
398
|
+
fails if someone later routes user input into it.
|
|
399
|
+
|
|
400
|
+
**Through a route, assert the rendered text** rather than the markup around it.
|
|
401
|
+
`assertSee` survives a class rename; a full-HTML comparison does not:
|
|
402
|
+
|
|
403
|
+
```typescript
|
|
404
|
+
// tests/http/posts.test.ts
|
|
405
|
+
const res = await app.get("/posts");
|
|
406
|
+
|
|
407
|
+
res.assertOk();
|
|
408
|
+
res.assertSee("Hello");
|
|
409
|
+
res.assertDontSee("Draft"); // unpublished posts stay hidden
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
> **Note** — `assertSee` matches anywhere in the body, so a short string can
|
|
413
|
+
> match an attribute or a class name and pass for the wrong reason. Assert on
|
|
414
|
+
> something distinctive enough to only appear in the content you mean.
|
|
415
|
+
|
|
416
|
+
## References
|
|
417
|
+
|
|
418
|
+
### Helpers
|
|
419
|
+
|
|
420
|
+
| Export | Signature | Description |
|
|
421
|
+
| ----------------- | -------------------------------------------------- | -------------------------------------------------------------------- |
|
|
422
|
+
| `safe(html)` | `(html: string) => SafeHtml` | Wrap a trusted HTML string as `SafeHtml` so it isn't re-escaped. |
|
|
423
|
+
| `Raw({ html })` | `(props: { html: string }) => SafeHtml` | Component form of `safe()` — composable as a child. |
|
|
424
|
+
| `esc(value)` | `(value: unknown) => string` | Escape a value for raw string templates (not needed inside JSX). |
|
|
425
|
+
| `Fragment` | `(props: { children?: unknown }) => SafeHtml` | Group multiple roots; `<>…</>` is shorthand. |
|
|
426
|
+
| `defineLayout(L)` | `(Layout) => (Page) => Component` | Bind a layout to pages; returns a `wrap(Page)` factory. |
|
|
427
|
+
| `definePage(fn)` | `(fn: (http, params) => SafeHtml \| string) => fn` | Mark a `(http, params)` function as a file-route page (`.ts` files). |
|
|
428
|
+
|
|
429
|
+
### Response and routing helpers
|
|
430
|
+
|
|
431
|
+
| Member | Signature | Description |
|
|
432
|
+
| ------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
|
433
|
+
| `view` | `view(markup, status?): void` | Set a pre-rendered view as the current response. |
|
|
434
|
+
| `view` | `view(component, props?, status?): void \| Promise<void>` | Render a component called with the `HttpContext` and your `props`. |
|
|
435
|
+
| `Router.view` | `Router.view(path, component, props?, middleware?): ViewRegistration` | Register a controller-less GET route that renders a view component. |
|
|
436
|
+
|
|
437
|
+
### Types
|
|
438
|
+
|
|
439
|
+
| Export | Description |
|
|
440
|
+
| ---------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
|
441
|
+
| `SafeHtml` | Opaque wrapper for already-escaped HTML — the return type of every JSX expression. |
|
|
442
|
+
| `Html` | Alias for `SafeHtml` — useful in controller return-type annotations. |
|
|
443
|
+
| `FC<P>` | Functional component type — `(props: P & { children?: unknown }) => SafeHtml`. |
|
|
444
|
+
| `Children` | Type for the `children` prop. |
|
|
445
|
+
| `HttpContext<T>` | The request context a view component or controller action receives; route params and model bindings live on `ctx.params`. |
|
|
446
|
+
|
|
447
|
+
## Next steps
|
|
448
|
+
|
|
449
|
+
- [Flow](/docs/flow) — server-driven interactive components over WebSocket.
|
|
450
|
+
- [Inertia](/docs/inertia) — React/Vue SPA pages backed by your controllers.
|
|
451
|
+
- [Routing](/docs/routing) — `Router.view()` and file-based page routing.
|
|
452
|
+
- [Responses](/docs/responses) — `view()` and the other response helpers.
|
|
453
|
+
- [Controllers](/docs/controllers) — move view logic into controller actions.
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zerotal/arch",
|
|
3
|
+
"version": "1.7.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"maturity": "beta",
|
|
6
|
+
"private": false,
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./src/index.ts",
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.ts",
|
|
12
|
+
"./mcp": "./src/mcp/index.ts"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"zerotal-arch": "./src/bin/mcp.ts"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"CHANGELOG.md",
|
|
19
|
+
"api-surface.md",
|
|
20
|
+
"docs",
|
|
21
|
+
"src",
|
|
22
|
+
"!src/**/*.test.ts",
|
|
23
|
+
"!src/**/*.test.tsx",
|
|
24
|
+
"!src/**/*.spec.ts",
|
|
25
|
+
"!src/**/__fixtures__/**"
|
|
26
|
+
],
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"bun": ">=1.3.14"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "bun test",
|
|
35
|
+
"typecheck": "tsc --noEmit"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@zerotal/core": "1.7.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"typescript": "^5.8.0",
|
|
42
|
+
"@zerotal/orm": "1.7.0"
|
|
43
|
+
},
|
|
44
|
+
"description": "The Zerotal agent surface — an MCP server that hands coding agents the framework's machine-readable truth: exact API signatures, live routes and schema, version-matched docs, and `zt doctor`.",
|
|
45
|
+
"keywords": [
|
|
46
|
+
"zerotal",
|
|
47
|
+
"bun",
|
|
48
|
+
"typescript",
|
|
49
|
+
"framework",
|
|
50
|
+
"mcp",
|
|
51
|
+
"agent"
|
|
52
|
+
],
|
|
53
|
+
"repository": {
|
|
54
|
+
"type": "git",
|
|
55
|
+
"url": "git+https://github.com/zerotaldev/zerotal.git",
|
|
56
|
+
"directory": "packages/arch"
|
|
57
|
+
},
|
|
58
|
+
"homepage": "https://github.com/zerotaldev/zerotal/tree/main/packages/arch#readme",
|
|
59
|
+
"bugs": "https://github.com/zerotaldev/zerotal/issues"
|
|
60
|
+
}
|
package/src/bin/mcp.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* The MCP server, as a process.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this is a bin and not a `zt` command
|
|
6
|
+
*
|
|
7
|
+
* The stdio binding says the server MUST NOT write anything to stdout that is
|
|
8
|
+
* not an MCP message, and a stray line does not degrade the session — it
|
|
9
|
+
* desynchronises the client's line parser and corrupts every frame after it.
|
|
10
|
+
* Every `bun zt <cmd>` boots the application first, and a booted application
|
|
11
|
+
* prints: dev banners, provider notices, warnings from packages that noticed
|
|
12
|
+
* something. There is no way to hold that guarantee from inside a command whose
|
|
13
|
+
* own boot has already happened.
|
|
14
|
+
*
|
|
15
|
+
* So this process never boots an app. It resolves nothing from the container,
|
|
16
|
+
* registers no providers, and reads no config. The tools that need a booted app
|
|
17
|
+
* spawn one — see `tools/_probe.ts` — which has the second, larger benefit: the
|
|
18
|
+
* answer describes the code as it is now, not as it was when a long-lived server
|
|
19
|
+
* started. The caller is an agent editing that code between calls.
|
|
20
|
+
*
|
|
21
|
+
* Run by `.mcp.json` as:
|
|
22
|
+
*
|
|
23
|
+
* bun node_modules/@zerotal/arch/src/bin/mcp.ts
|
|
24
|
+
*/
|
|
25
|
+
import { dirname, resolve } from "node:path";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
import { McpServer } from "../mcp/server.ts";
|
|
28
|
+
import { serveStdio } from "../mcp/stdio.ts";
|
|
29
|
+
import { archTools } from "../tools/index.ts";
|
|
30
|
+
import { vendoredDocsDir } from "../tools/context.ts";
|
|
31
|
+
import { findApp, spawnProbe } from "../tools/_probe.ts";
|
|
32
|
+
|
|
33
|
+
/** This package's own version, for `serverInfo`. */
|
|
34
|
+
async function ownVersion(): Promise<string> {
|
|
35
|
+
try {
|
|
36
|
+
const manifest = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
|
|
37
|
+
const parsed = (await Bun.file(manifest).json()) as Record<string, unknown>;
|
|
38
|
+
return typeof parsed["version"] === "string" ? parsed["version"] : "0.0.0";
|
|
39
|
+
} catch {
|
|
40
|
+
return "0.0.0";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const cwd = process.cwd();
|
|
45
|
+
const app = await findApp(cwd);
|
|
46
|
+
|
|
47
|
+
// stderr, never stdout. The spec permits it and tells clients not to read it as
|
|
48
|
+
// failure, which makes it the only place a diagnostic can go.
|
|
49
|
+
if (app) {
|
|
50
|
+
process.stderr.write(`[arch] serving ${app.root}\n`);
|
|
51
|
+
} else {
|
|
52
|
+
process.stderr.write(
|
|
53
|
+
`[arch] no Zerotal app found at or above ${cwd}. Tools that need a booted app will say so; ` +
|
|
54
|
+
`docs search and API surface still work.\n`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const root = app?.root ?? cwd;
|
|
59
|
+
|
|
60
|
+
await serveStdio({
|
|
61
|
+
server: new McpServer({
|
|
62
|
+
identity: {
|
|
63
|
+
name: "zerotal-arch",
|
|
64
|
+
title: "Zerotal",
|
|
65
|
+
version: await ownVersion(),
|
|
66
|
+
},
|
|
67
|
+
tools: archTools({
|
|
68
|
+
root,
|
|
69
|
+
docsDir: vendoredDocsDir(),
|
|
70
|
+
probe: spawnProbe({ cwd: root }),
|
|
71
|
+
}),
|
|
72
|
+
}),
|
|
73
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { deepMerge } from "@zerotal/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What `zt arch:install` writes, and under what name.
|
|
5
|
+
*
|
|
6
|
+
* Only the install side is configurable. The MCP server itself never boots an
|
|
7
|
+
* application, so it can read no config — which is the point of it, and the
|
|
8
|
+
* reason there is nothing here about tools or transports.
|
|
9
|
+
*/
|
|
10
|
+
export interface ArchConfigShape {
|
|
11
|
+
/**
|
|
12
|
+
* Write `AGENTS.md`, the cross-tool instruction file. Default: `true`.
|
|
13
|
+
*
|
|
14
|
+
* Read natively by Cursor, Copilot, Codex, Gemini CLI, Aider, Windsurf and
|
|
15
|
+
* Zed, so it is where the guidelines belong even in a single-tool project.
|
|
16
|
+
*/
|
|
17
|
+
agentsFile: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Write a `CLAUDE.md` that imports `AGENTS.md`. Default: `true`.
|
|
20
|
+
*
|
|
21
|
+
* A one-line shim rather than a second copy: Claude Code does not read
|
|
22
|
+
* `AGENTS.md` natively, and two files of guidance drift apart.
|
|
23
|
+
*/
|
|
24
|
+
claudeFile: boolean;
|
|
25
|
+
/** Write the MCP client configuration. Default: `true`. */
|
|
26
|
+
mcpConfig: boolean;
|
|
27
|
+
/** Path of the MCP client config, relative to the project root. */
|
|
28
|
+
mcpConfigPath: string;
|
|
29
|
+
/**
|
|
30
|
+
* The key this server is registered under in the MCP config. Default: `zerotal`.
|
|
31
|
+
*
|
|
32
|
+
* Worth changing only when a project already has a server by that name.
|
|
33
|
+
*/
|
|
34
|
+
serverName: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const defaults: ArchConfigShape = {
|
|
38
|
+
agentsFile: true,
|
|
39
|
+
claudeFile: true,
|
|
40
|
+
mcpConfig: true,
|
|
41
|
+
mcpConfigPath: ".mcp.json",
|
|
42
|
+
serverName: "zerotal",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build an {@link ArchConfigShape} with defaults applied.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```ts
|
|
50
|
+
* // config/arch.ts
|
|
51
|
+
* import { ArchConfig } from "@zerotal/arch";
|
|
52
|
+
* export default ArchConfig({ claudeFile: false });
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export function ArchConfig(options: Partial<ArchConfigShape> = {}): ArchConfigShape {
|
|
56
|
+
return deepMerge(defaults, options);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
declare module "@zerotal/core" {
|
|
60
|
+
interface ConfigRegistry {
|
|
61
|
+
arch: ArchConfigShape;
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ZerotalError } from "@zerotal/core";
|
|
2
|
+
|
|
3
|
+
/** Base class for all @zerotal/arch errors. */
|
|
4
|
+
export class ArchError extends ZerotalError {
|
|
5
|
+
constructor(message: string, code = "E_ARCH", status = 500, context?: Record<string, unknown>) {
|
|
6
|
+
super(message, code, status, context);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `arch:install` was run somewhere that is not a project.
|
|
12
|
+
*
|
|
13
|
+
* Thrown rather than warned: writing agent instructions into whatever directory
|
|
14
|
+
* happened to be current is not a recoverable mistake for whoever has to find
|
|
15
|
+
* the stray files later.
|
|
16
|
+
*/
|
|
17
|
+
export class NoProjectRootError extends ArchError {
|
|
18
|
+
constructor(dir: string) {
|
|
19
|
+
super(
|
|
20
|
+
`[Zerotal Arch] No package.json at or above ${dir}, so there is no project to install ` +
|
|
21
|
+
`into. Run this from the root of a Zerotal app.`,
|
|
22
|
+
"E_ARCH_NO_PROJECT_ROOT",
|
|
23
|
+
500,
|
|
24
|
+
{ dir },
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
}
|