@jay-framework/jay-stack-cli 0.15.6 → 0.16.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.
@@ -0,0 +1,126 @@
1
+ # Dev Server Service API
2
+
3
+ The dev server exposes a `DevServerService` for design board applications, CLI tools, and plugins. It provides route listing, param discovery, and freeze management.
4
+
5
+ ## Service Marker
6
+
7
+ Registered as `DEV_SERVER_SERVICE` — injectable in actions and components:
8
+
9
+ ```typescript
10
+ import { DEV_SERVER_SERVICE } from '@jay-framework/dev-server';
11
+
12
+ export const listAllRoutes = makeJayAction('admin.listRoutes')
13
+ .withServices(DEV_SERVER_SERVICE)
14
+ .withHandler(async (_input, devServer) => {
15
+ return devServer.listRoutes();
16
+ });
17
+ ```
18
+
19
+ ## Direct Access
20
+
21
+ Also returned from `mkDevServer()` for CLI usage:
22
+
23
+ ```typescript
24
+ const { service } = await mkDevServer(options);
25
+ ```
26
+
27
+ ## Routes
28
+
29
+ ### listRoutes()
30
+
31
+ Returns all page routes in the project:
32
+
33
+ ```typescript
34
+ const routes = service.listRoutes();
35
+ // [{ path: '/products/kitan/[[category]]', jayHtmlPath: '...', compPath: '...' }]
36
+ ```
37
+
38
+ ### loadRouteParams(route, onBatch)
39
+
40
+ Async generator that yields param batches from the route's `loadParams`:
41
+
42
+ ```typescript
43
+ for await (const batch of service.loadRouteParams('/products/kitan/[[category]]')) {
44
+ console.log(batch); // [{ category: 'shirts' }, { category: 'pants' }]
45
+ }
46
+ ```
47
+
48
+ Returns empty if the route has no `page.ts` or no `loadParams`. Throws if the route doesn't exist.
49
+
50
+ ## Freeze Management
51
+
52
+ ### FreezeStore
53
+
54
+ Accessible via `service.freezeStore`:
55
+
56
+ ```typescript
57
+ const store = service.freezeStore;
58
+
59
+ // Save a ViewState snapshot
60
+ const entry = await store.save('/products/kitan', viewState);
61
+
62
+ // List freezes for a route
63
+ const freezes = await store.list('/products/kitan');
64
+
65
+ // Get a specific freeze
66
+ const freeze = await store.get('abc123');
67
+
68
+ // Rename
69
+ await store.rename('abc123', 'in-stock state');
70
+
71
+ // Delete
72
+ await store.delete('abc123');
73
+ ```
74
+
75
+ ## Editor Protocol
76
+
77
+ These APIs are also exposed via the editor protocol (Socket.IO) for design board applications:
78
+
79
+ | Protocol Message | Service Method |
80
+ | ----------------- | ----------------------------------------- |
81
+ | `listRoutes` | `service.listRoutes()` |
82
+ | `listFreezes` | `service.freezeStore.list(route)` |
83
+ | `renameFreeze` | `service.freezeStore.rename(id, name)` |
84
+ | `deleteFreeze` | `service.freezeStore.delete(id)` |
85
+ | `loadRouteParams` | `service.loadRouteParams(route, onBatch)` |
86
+
87
+ ### Streaming Events
88
+
89
+ `loadRouteParams` streams batches via `routeParamsBatch` socket events:
90
+
91
+ ```typescript
92
+ // Client sends: { type: 'loadRouteParams', route: '/products/[slug]' }
93
+ // Server responds: { type: 'loadRouteParams', success: true }
94
+ // Server emits: { type: 'routeParamsBatch', route: '...', params: [...], hasMore: true }
95
+ // Server emits: { type: 'routeParamsBatch', route: '...', params: [...], hasMore: true }
96
+ // Server emits: { type: 'routeParamsBatch', route: '...', params: [], hasMore: false }
97
+ ```
98
+
99
+ ### Freeze Changed Event
100
+
101
+ The `freezeChanged` socket event is emitted when jay-html or CSS files change. Design board applications should listen for this to refresh their frozen views:
102
+
103
+ ```typescript
104
+ socket.on('freezeChanged', () => {
105
+ // Re-fetch frozen page fragments
106
+ });
107
+ ```
108
+
109
+ ## Iframe / Embed Mode
110
+
111
+ When a page is loaded inside an iframe with `?_jay_embed=true` (e.g., by the AIditor), the Alt+S shortcut is disabled (parent owns it). Freeze is triggered via `postMessage`:
112
+
113
+ ```typescript
114
+ // Parent → iframe: request freeze
115
+ iframe.contentWindow.postMessage({ type: 'jay:requestFreeze' }, '*');
116
+
117
+ // Iframe → parent: freeze done
118
+ // { type: 'jay:freeze', id: string, route: string }
119
+ ```
120
+
121
+ The parent constructs the frozen page URL:
122
+
123
+ ```
124
+ route + '?_jay_freeze=' + id // full page
125
+ route + '?_jay_freeze=' + id + '&format=fragment' // shadow DOM fragment
126
+ ```
@@ -81,6 +81,37 @@ export const page = makeJayStackComponent<ProductPageContract>()
81
81
  });
82
82
  ```
83
83
 
84
+ ## Calling File Upload Actions
85
+
86
+ Actions created with `.withFiles()` accept browser `File` objects directly. Use `oninput` events on file inputs to drive signals, then pass them to the action:
87
+
88
+ ```typescript
89
+ import { createSignal } from '@jay-framework/component';
90
+ import { uploadPhoto } from '../actions/upload.actions';
91
+
92
+ .withInteractive(function UploadPage(props, refs, fastViewState) {
93
+ const [result, setResult] = createSignal('');
94
+ const [selectedFile, setSelectedFile] = createSignal<File | undefined>(undefined);
95
+
96
+ refs.fileInput.oninput(({ event }) => {
97
+ setSelectedFile((event.target as HTMLInputElement).files?.[0]);
98
+ });
99
+
100
+ refs.uploadBtn.onclick(async () => {
101
+ const file = selectedFile();
102
+ if (!file) return;
103
+ const res = await uploadPhoto({ caption: 'My photo', photo: file });
104
+ setResult(res.message);
105
+ });
106
+
107
+ return {
108
+ render: () => ({ result: result() }),
109
+ };
110
+ })
111
+ ```
112
+
113
+ No casting needed — browser `File` is assignable to `JayFile`.
114
+
84
115
  ## Combining with Headless Plugins
85
116
 
86
117
  A page component handles page-level data. Plugin headless components handle their own data independently. Both render into the same page:
@@ -159,3 +159,17 @@ URL query parameters (`?page=2&sort=price`) are available in the **fast render p
159
159
  - `props.query` is `Record<string, string>` — empty `{}` when no query string
160
160
  - Not available in the slow phase (compile error) — slow results are cached by path params only
161
161
  - In the interactive phase, use `new URLSearchParams(window.location.search)` directly
162
+
163
+ ## Plugin Routes
164
+
165
+ Plugins can provide their own pages via `routes` in `plugin.yaml`. These are backoffice tools, admin dashboards, or editors with boxed designs that don't need per-site customization.
166
+
167
+ Plugin routes appear alongside project routes. If your project defines the same route path in `src/pages/`, your page takes precedence — the plugin's page is skipped.
168
+
169
+ To override a plugin route, simply create a page at the same path:
170
+
171
+ ```
172
+ # Plugin provides /admin/products
173
+ # To customize, create:
174
+ src/pages/admin/products/page.jay-html
175
+ ```
@@ -1,18 +1,19 @@
1
1
  # Jay Plugin Development — Agent Kit
2
2
 
3
- This folder contains guides for creating jay-stack plugins: contracts, headless components, server actions, and services.
3
+ This folder contains guides for creating jay-stack plugins: contracts, headless components, server actions, services, and plugin-provided routes.
4
4
 
5
5
  ## What is a Jay Plugin?
6
6
 
7
- A plugin provides headless components (data + interactions, no UI) that project designers use via contracts. Plugins can be standalone npm packages or inline within a project (see `examples/jay-stack/fake-shop`).
7
+ A plugin provides headless components (data + interactions, no UI) that project designers use via contracts. Plugins can also provide complete pages (backoffice tools, admin dashboards) via routes. Plugins can be standalone npm packages or inline within a project (see `examples/jay-stack/fake-shop`).
8
8
 
9
9
  ## Workflow
10
10
 
11
11
  1. **Define contracts first** — the contract is the source of truth
12
12
  2. **Implement components** matching the contracts
13
13
  3. **Define actions** with `.jay-action` metadata
14
- 4. **Set up `plugin.yaml`**
15
- 5. **Validate** with `jay-stack validate-plugin`
14
+ 4. **Optionally add routes** — pages for admin tools and dashboards
15
+ 5. **Set up `plugin.yaml`** — list contracts, actions, services, contexts, routes
16
+ 6. **Validate** with `jay-stack validate-plugin`
16
17
 
17
18
  ## Guides
18
19
 
@@ -28,8 +29,10 @@ A plugin provides headless components (data + interactions, no UI) that project
28
29
  | [render-results.md](render-results.md) | phaseOutput, RenderPipeline, errors, redirects |
29
30
  | [actions-guide.md](actions-guide.md) | makeJayAction, makeJayQuery, .jay-action files |
30
31
  | [services-guide.md](services-guide.md) | createJayService, makeJayInit |
32
+ | [plugin-routes.md](plugin-routes.md) | Plugin-provided pages: routes, jay-html templates, page components |
31
33
  | [seo-guide.md](seo-guide.md) | SEO head tags: title, meta, OG, canonical via phaseOutput |
32
34
  | [validation.md](validation.md) | jay-stack validate-plugin usage |
35
+ | [dev-server-service.md](dev-server-service.md) | Dev server service API: routes, params, freeze management |
33
36
  | `../references/<plugin>/` | Plugin reference data |
34
37
 
35
38
  ## Key Principles
@@ -38,12 +38,99 @@ makeJayAction('name')
38
38
  .withServices(SERVICE1, SERVICE2) // Inject services
39
39
  .withMethod('PUT') // Override HTTP method (default: POST for actions)
40
40
  .withCaching({ maxAge: 60 }) // Enable caching (queries only)
41
+ .withFiles({ maxFileSize: 5_000_000 }) // Accept file uploads (multipart/form-data)
41
42
  .withHandler(async (input, svc1, svc2) => {
42
43
  // Define handler
43
44
  return result;
44
45
  });
45
46
  ```
46
47
 
48
+ ## .withFiles() — File Uploads
49
+
50
+ Actions can receive binary files (images, documents, etc.) via multipart/form-data.
51
+ Add `.withFiles()` to the builder chain to enable file uploads.
52
+
53
+ ```typescript
54
+ import { makeJayAction, type JayFile } from '@jay-framework/fullstack-component';
55
+ import fs from 'fs';
56
+
57
+ export const uploadPhoto = makeJayAction('photos.upload')
58
+ .withFiles({ maxFileSize: 5 * 1024 * 1024 }) // 5MB limit, 10 files max (default)
59
+ .withHandler(async (input: { caption: string; photo: JayFile }) => {
60
+ // JayFile provides: name, type, size, path (temp file on disk)
61
+ const data = fs.readFileSync(input.photo.path);
62
+ // Process file...
63
+ return { fileName: input.photo.name, size: input.photo.size };
64
+ // Temp file is automatically cleaned up after handler returns
65
+ });
66
+ ```
67
+
68
+ ### JayFile type
69
+
70
+ ```typescript
71
+ interface JayFile {
72
+ name: string; // Original filename
73
+ type: string; // MIME type (e.g., 'image/png')
74
+ size: number; // File size in bytes
75
+ path: string; // Absolute path to temp file on disk
76
+ }
77
+ ```
78
+
79
+ ### Multiple files
80
+
81
+ Use an array type for the field — files with the same field name are grouped:
82
+
83
+ ```typescript
84
+ .withHandler(async (input: { images: JayFile[] }) => {
85
+ for (const img of input.images) {
86
+ // Process each file
87
+ }
88
+ });
89
+ ```
90
+
91
+ **File fields must be top-level properties** — not nested inside objects. Dynamic file fields via index signatures are supported:
92
+
93
+ ```typescript
94
+ { notes: string; screenshot: JayFile; extras: JayFile[] } // OK
95
+ { notes: string; [key: string]: string | JayFile | undefined } // OK
96
+ { meta: { photo: JayFile } } // NOT supported
97
+ ```
98
+
99
+ ### Streaming with files
100
+
101
+ `makeJayStream` also supports `.withFiles()`:
102
+
103
+ ```typescript
104
+ export const processImages = makeJayStream('images.process')
105
+ .withFiles()
106
+ .withHandler(async function* (input: { images: JayFile[] }) {
107
+ for (const img of input.images) {
108
+ yield { step: 'processing', fileName: img.name };
109
+ }
110
+ yield { step: 'done' };
111
+ });
112
+ ```
113
+
114
+ ### Client-side usage
115
+
116
+ The client automatically sends `FormData` when `File` or `Blob` objects are present:
117
+
118
+ ```typescript
119
+ refs.uploadBtn.onClick(async () => {
120
+ const fileInput = refs.fileInput.element as HTMLInputElement;
121
+ const file = fileInput.files?.[0];
122
+ const result = await uploadPhoto({ caption: 'My photo', photo: file });
123
+ });
124
+ ```
125
+
126
+ ### FileUploadOptions
127
+
128
+ ```typescript
129
+ .withFiles() // Defaults: 10MB per file, 10 files max
130
+ .withFiles({ maxFileSize: 2 * 1024 * 1024 }) // 2MB limit
131
+ .withFiles({ maxFileSize: 20_000_000, maxFiles: 5 }) // 20MB, 5 files
132
+ ```
133
+
47
134
  ## ActionError
48
135
 
49
136
  Throw typed errors from action handlers:
@@ -109,17 +196,106 @@ outputSchema:
109
196
  | `prop: string` | Required string |
110
197
  | `prop?: number` | Optional number |
111
198
  | `prop: boolean` | Required boolean |
199
+ | `prop: file` | File upload (`JayFile`) |
200
+ | `prop: file[]` | Multiple file uploads |
112
201
  | `prop: enum(a \| b \| c)` | Required enum |
113
202
  | `prop:` + nested block | Nested object |
114
203
  | `prop:` + `- child: type` | Array of objects |
115
204
  | `prop: record(T)` | Record with typed values |
116
205
  | `prop: importedName` | Type from `import:` block |
117
206
 
207
+ ## makeJayStream — Streaming (POST, NDJSON)
208
+
209
+ Streaming actions return an async generator that yields chunks:
210
+
211
+ ```typescript
212
+ import { makeJayStream } from '@jay-framework/fullstack-component';
213
+
214
+ export const discoverParams = makeJayStream('routes.discoverParams')
215
+ .withServices(PRODUCTS_SERVICE)
216
+ .withHandler(async function* (input: { route: string }, productsService) {
217
+ let page = 1;
218
+ while (true) {
219
+ const products = await productsService.list({ page, pageSize: 100 });
220
+ yield products.map((p) => ({ slug: p.slug }));
221
+ if (!products.hasMore) break;
222
+ page++;
223
+ }
224
+ });
225
+ ```
226
+
227
+ ### Consuming on the client
228
+
229
+ ```typescript
230
+ for await (const batch of discoverParams({ route: '/products/[slug]' })) {
231
+ console.log(batch); // [{ slug: 'item-a' }, { slug: 'item-b' }]
232
+ }
233
+ ```
234
+
235
+ ### Wire format
236
+
237
+ The server responds with NDJSON (newline-delimited JSON). Each line is a complete JSON object:
238
+
239
+ ```
240
+ {"chunk":[{"slug":"item-a"},{"slug":"item-b"}]}
241
+ {"chunk":[{"slug":"item-c"}]}
242
+ {"done":true}
243
+ ```
244
+
245
+ ### .jay-action for streaming
246
+
247
+ Add `streaming: true` to the metadata file:
248
+
249
+ ```yaml
250
+ name: discoverParams
251
+ description: Discover URL params by querying the product catalog
252
+ streaming: true
253
+ inputSchema:
254
+ route: string
255
+ outputSchema:
256
+ - slug: string
257
+ ```
258
+
259
+ ### .jay-action for file uploads
260
+
261
+ Use `file` type for upload fields. The generated TypeScript uses `JayFile`:
262
+
263
+ ```yaml
264
+ name: uploadPhoto
265
+ description: Upload a product photo with caption
266
+ inputSchema:
267
+ caption: string
268
+ photo: file
269
+ attachments?: file[]
270
+ outputSchema:
271
+ fileId: string
272
+ message: string
273
+ ```
274
+
275
+ For dynamic file fields, use `record(file)`:
276
+
277
+ ```yaml
278
+ name: submitTask
279
+ description: Submit task with named file attachments
280
+ inputSchema:
281
+ notes: string
282
+ files: record(file)
283
+ ```
284
+
285
+ This generates `files: Record<string, JayFile>`.
286
+
118
287
  ## Type Helpers
119
288
 
120
289
  ```typescript
121
- import { ActionInput, ActionOutput, isJayAction } from '@jay-framework/fullstack-component';
290
+ import {
291
+ ActionInput,
292
+ ActionOutput,
293
+ isJayAction,
294
+ StreamChunk,
295
+ isJayStreamAction,
296
+ } from '@jay-framework/fullstack-component';
122
297
 
123
298
  type SearchInput = ActionInput<typeof searchProducts>;
124
299
  type SearchOutput = ActionOutput<typeof searchProducts>;
300
+ type ParamBatch = StreamChunk<typeof discoverParams>;
125
301
  ```
@@ -0,0 +1,137 @@
1
+ # Dev Server Service API
2
+
3
+ The dev server exposes a `DevServerService` for plugins, design board applications, and CLI tools. It provides route listing, param discovery, and freeze management.
4
+
5
+ ## Service Marker
6
+
7
+ Registered as `DEV_SERVER_SERVICE` — inject in actions and components:
8
+
9
+ ```typescript
10
+ import { DEV_SERVER_SERVICE } from '@jay-framework/dev-server';
11
+
12
+ export const listAllRoutes = makeJayAction('admin.listRoutes')
13
+ .withServices(DEV_SERVER_SERVICE)
14
+ .withHandler(async (_input, devServer) => {
15
+ return devServer.listRoutes();
16
+ });
17
+ ```
18
+
19
+ Or in a component:
20
+
21
+ ```typescript
22
+ makeJayStackComponent()
23
+ .withServices(DEV_SERVER_SERVICE)
24
+ .withFastRender(async (_props, devServer) => {
25
+ const routes = devServer.listRoutes();
26
+ return phaseOutput({ routes, routeCount: routes.length }, {});
27
+ });
28
+ ```
29
+
30
+ ## Direct Access
31
+
32
+ Also returned from `mkDevServer()` for CLI usage:
33
+
34
+ ```typescript
35
+ const { service } = await mkDevServer(options);
36
+ ```
37
+
38
+ ## Routes
39
+
40
+ ### listRoutes()
41
+
42
+ Returns all page routes in the project (including plugin-provided routes):
43
+
44
+ ```typescript
45
+ const routes = service.listRoutes();
46
+ // [{ path: '/products/kitan/[[category]]', jayHtmlPath: '...', compPath: '...' }]
47
+ ```
48
+
49
+ ### loadRouteParams(route)
50
+
51
+ Async generator that yields param batches from the route's `loadParams`:
52
+
53
+ ```typescript
54
+ for await (const batch of service.loadRouteParams('/products/kitan/[[category]]')) {
55
+ console.log(batch); // [{ category: 'shirts' }, { category: 'pants' }]
56
+ }
57
+ ```
58
+
59
+ Returns empty if the route has no `page.ts` or no `loadParams`. Throws if the route doesn't exist.
60
+
61
+ ## Freeze Management
62
+
63
+ ### FreezeStore
64
+
65
+ Accessible via `service.freezeStore`:
66
+
67
+ ```typescript
68
+ const store = service.freezeStore;
69
+
70
+ // Save a ViewState snapshot
71
+ const entry = await store.save('/products/kitan', viewState);
72
+
73
+ // List freezes for a route
74
+ const freezes = await store.list('/products/kitan');
75
+
76
+ // Get a specific freeze
77
+ const freeze = await store.get('abc123');
78
+
79
+ // Rename
80
+ await store.rename('abc123', 'in-stock state');
81
+
82
+ // Delete
83
+ await store.delete('abc123');
84
+ ```
85
+
86
+ ## Editor Protocol
87
+
88
+ These APIs are also exposed via the editor protocol (Socket.IO) for design board applications:
89
+
90
+ | Protocol Message | Service Method |
91
+ | ----------------- | ----------------------------------------- |
92
+ | `listRoutes` | `service.listRoutes()` |
93
+ | `listFreezes` | `service.freezeStore.list(route)` |
94
+ | `renameFreeze` | `service.freezeStore.rename(id, name)` |
95
+ | `deleteFreeze` | `service.freezeStore.delete(id)` |
96
+ | `loadRouteParams` | `service.loadRouteParams(route, onBatch)` |
97
+
98
+ ### Streaming Events
99
+
100
+ `loadRouteParams` streams batches via `routeParamsBatch` socket events:
101
+
102
+ ```typescript
103
+ // Client sends: { type: 'loadRouteParams', route: '/products/[slug]' }
104
+ // Server responds: { type: 'loadRouteParams', success: true }
105
+ // Server emits: { type: 'routeParamsBatch', route: '...', params: [...], hasMore: true }
106
+ // Server emits: { type: 'routeParamsBatch', route: '...', params: [...], hasMore: true }
107
+ // Server emits: { type: 'routeParamsBatch', route: '...', params: [], hasMore: false }
108
+ ```
109
+
110
+ ### Freeze Changed Event
111
+
112
+ The `freezeChanged` socket event is emitted when jay-html or CSS files change. Design board applications should listen for this to refresh their frozen views:
113
+
114
+ ```typescript
115
+ socket.on('freezeChanged', () => {
116
+ // Re-fetch frozen page fragments
117
+ });
118
+ ```
119
+
120
+ ## Iframe / Embed Mode
121
+
122
+ When a page is loaded inside an iframe with `?_jay_embed=true` (e.g., by the AIditor), the Alt+S shortcut is disabled (parent owns it). Freeze is triggered via `postMessage`:
123
+
124
+ ```typescript
125
+ // Parent → iframe: request freeze
126
+ iframe.contentWindow.postMessage({ type: 'jay:requestFreeze' }, '*');
127
+
128
+ // Iframe → parent: freeze done
129
+ // { type: 'jay:freeze', id: string, route: string }
130
+ ```
131
+
132
+ The parent constructs the frozen page URL:
133
+
134
+ ```
135
+ route + '?_jay_freeze=' + id // full page
136
+ route + '?_jay_freeze=' + id + '&format=fragment' // shadow DOM fragment
137
+ ```
@@ -0,0 +1,146 @@
1
+ # Plugin Routes
2
+
3
+ Plugins can provide complete pages served by the dev server. This is designed for backoffice tools, admin dashboards, and editors — pages with a boxed design that doesn't need per-site visual customization.
4
+
5
+ ## When to Use Plugin Routes
6
+
7
+ - **Admin dashboards** — product management, analytics, settings
8
+ - **Editor tools** — visual page editors, contract browsers
9
+ - **Developer tools** — debugging panels, state inspectors
10
+
11
+ Plugin routes are NOT for end-user pages that need visual customization per site. For those, provide headless components and let the project create its own pages.
12
+
13
+ ## Creating a Plugin Route
14
+
15
+ A plugin route is a **headless component + jay-html template + route path**. It uses the same rendering pipeline as project pages.
16
+
17
+ ### 1. Create the jay-html template
18
+
19
+ ```html
20
+ <!-- pages/admin/page.jay-html -->
21
+ <html>
22
+ <head>
23
+ <script type="application/jay-data">
24
+ data:
25
+ title: string
26
+ items:
27
+ - name: string
28
+ count: number
29
+ </script>
30
+ <style>
31
+ .admin {
32
+ max-width: 800px;
33
+ margin: 0 auto;
34
+ padding: 20px;
35
+ font-family: system-ui;
36
+ }
37
+ </style>
38
+ </head>
39
+ <body>
40
+ <div class="admin">
41
+ <h1>{title}</h1>
42
+ <div forEach="items" trackBy="name"><span>{name}</span>: <strong>{count}</strong></div>
43
+ </div>
44
+ </body>
45
+ </html>
46
+ ```
47
+
48
+ ### 2. Create the page component
49
+
50
+ ```typescript
51
+ // pages/admin/page.ts
52
+ import { makeJayStackComponent, phaseOutput } from '@jay-framework/fullstack-component';
53
+ import { MY_SERVICE, MyService } from '../../services';
54
+
55
+ export const page = makeJayStackComponent()
56
+ .withProps<{}>()
57
+ .withServices(MY_SERVICE)
58
+ .withFastRender(async (_props, myService: MyService) => {
59
+ const data = await myService.getDashboardData();
60
+ return phaseOutput({ title: 'Admin Dashboard', items: data.items }, {});
61
+ });
62
+ ```
63
+
64
+ The page component follows the same pattern as any `makeJayStackComponent` — supports `withSlowlyRender`, `withFastRender`, `withInteractive`, `withLoadParams`, and `withServices`.
65
+
66
+ ### 3. Declare the route in plugin.yaml
67
+
68
+ ```yaml
69
+ name: my-plugin
70
+ routes:
71
+ - path: /admin/dashboard
72
+ jayHtml: ./pages/admin/page.jay-html
73
+ component: ./pages/admin/page.ts
74
+ description: Admin dashboard showing key metrics
75
+ ```
76
+
77
+ For local plugins, `jayHtml` and `component` are relative paths. For NPM packages, use `package.json` export subpaths.
78
+
79
+ ### 4. For NPM packages — export the page files
80
+
81
+ ```json
82
+ {
83
+ "exports": {
84
+ "./admin-dashboard.jay-html": "./dist/pages/admin/page.jay-html",
85
+ "./admin-dashboard.css": "./dist/pages/admin/page.css"
86
+ }
87
+ }
88
+ ```
89
+
90
+ Then reference the export subpath in plugin.yaml:
91
+
92
+ ```yaml
93
+ routes:
94
+ - path: /admin/dashboard
95
+ jayHtml: admin-dashboard.jay-html
96
+ css: admin-dashboard.css
97
+ component: adminDashboard
98
+ ```
99
+
100
+ ## Route Parameters
101
+
102
+ Plugin routes support the same parameter patterns as project routes:
103
+
104
+ ```yaml
105
+ routes:
106
+ - path: /admin/products/[id]
107
+ jayHtml: ./pages/product-detail/page.jay-html
108
+ component: ./pages/product-detail/page.ts
109
+ ```
110
+
111
+ The page component can use `withLoadParams` for SSG parameter discovery:
112
+
113
+ ```typescript
114
+ export const page = makeJayStackComponent()
115
+ .withProps<{}>()
116
+ .withServices(PRODUCTS_SERVICE)
117
+ .withLoadParams<{ id: string }>(async function* (productsService) {
118
+ const products = await productsService.listAll();
119
+ yield products.map((p) => ({ id: p.id }));
120
+ })
121
+ .withSlowlyRender(async (props: { id: string }, productsService) => {
122
+ const product = await productsService.getById(props.id);
123
+ return phaseOutput({ name: product.name, price: product.price }, {});
124
+ });
125
+ ```
126
+
127
+ ## Route Priority
128
+
129
+ Project routes always take precedence. If the project creates a page at the same path, the plugin's route is skipped:
130
+
131
+ ```
132
+ src/pages/admin/dashboard/page.jay-html ← project wins
133
+ plugin provides /admin/dashboard ← skipped
134
+ ```
135
+
136
+ This lets projects override any plugin page without modifying the plugin.
137
+
138
+ ## Prefix Convention
139
+
140
+ Each plugin should choose a recognizable route prefix to avoid collisions:
141
+
142
+ - `/admin/...` — admin tools
143
+ - `/aiditor/...` — AIditor editor
144
+ - `/cms/...` — content management
145
+
146
+ There is no enforced convention — just pick a prefix that's unique and descriptive.
@@ -35,6 +35,12 @@ contexts:
35
35
  marker: MY_CART_CONTEXT
36
36
  description: Client-side cart state (add/remove items, totals)
37
37
 
38
+ routes:
39
+ - path: /admin/dashboard
40
+ jayHtml: ./pages/admin/page.jay-html
41
+ component: ./pages/admin/page.ts
42
+ description: Admin dashboard with product stats
43
+
38
44
  setup:
39
45
  handler: setup-handler
40
46
  references: references-handler
@@ -93,6 +99,16 @@ services:
93
99
  }
94
100
  ```
95
101
 
102
+ ### Route Entry Fields
103
+
104
+ - `path` — Route path (e.g., `/admin/products`, `/dashboard/[section]`)
105
+ - `jayHtml` — Path to the page's jay-html template (relative to plugin root, or export subpath for NPM)
106
+ - `css` — (optional) Path to the page's CSS file
107
+ - `component` — Path to the page component (relative to plugin root, or exported member name for NPM)
108
+ - `description` — What this page does
109
+
110
+ Plugin routes are served by the dev server alongside project routes. If a project defines the same route path, the project's page takes precedence.
111
+
96
112
  ### Setup Fields
97
113
 
98
114
  - `handler` — Setup handler for `jay-stack setup` (handles config, credentials)
package/dist/index.js CHANGED
@@ -2441,7 +2441,7 @@ async function startDevServer(options = {}) {
2441
2441
  editorServer.onGetProjectInfo(handlers.onGetProjectInfo);
2442
2442
  editorServer.onExport(handlers.onExport);
2443
2443
  editorServer.onImport(handlers.onImport);
2444
- const { server, viteServer, routes } = await mkDevServer({
2444
+ const { server, viteServer, routes, service } = await mkDevServer({
2445
2445
  pagesRootFolder: path.resolve(resolvedConfig.devServer.pagesBase),
2446
2446
  projectRootFolder: process.cwd(),
2447
2447
  publicBaseUrlPath: "/",
@@ -2450,6 +2450,73 @@ async function startDevServer(options = {}) {
2450
2450
  httpServer
2451
2451
  });
2452
2452
  app.use(server);
2453
+ const { freezeStore } = service;
2454
+ editorServer.onListRoutes(async () => ({
2455
+ type: "listRoutes",
2456
+ success: true,
2457
+ routes: service.listRoutes()
2458
+ }));
2459
+ if (freezeStore) {
2460
+ editorServer.onListFreezes(async (params) => ({
2461
+ type: "listFreezes",
2462
+ success: true,
2463
+ freezes: (await freezeStore.list(params.route)).map(
2464
+ ({ id, name, route, routePattern, createdAt }) => ({
2465
+ id,
2466
+ name,
2467
+ route,
2468
+ routePattern,
2469
+ createdAt
2470
+ })
2471
+ )
2472
+ }));
2473
+ editorServer.onRenameFreeze(async (params) => ({
2474
+ type: "renameFreeze",
2475
+ success: await freezeStore.rename(params.id, params.name)
2476
+ }));
2477
+ editorServer.onDeleteFreeze(async (params) => ({
2478
+ type: "deleteFreeze",
2479
+ success: await freezeStore.delete(params.id)
2480
+ }));
2481
+ viteServer.watcher.on("change", (changedPath) => {
2482
+ if (changedPath.endsWith(".jay-html") || changedPath.endsWith(".css")) {
2483
+ editorServer.emitFreezeChanged();
2484
+ }
2485
+ });
2486
+ }
2487
+ editorServer.onLoadRouteParams(async (params) => {
2488
+ const routePath = params.route;
2489
+ try {
2490
+ (async () => {
2491
+ try {
2492
+ for await (const batch of service.loadRouteParams(routePath)) {
2493
+ editorServer.emitRouteParamsBatch({
2494
+ type: "routeParamsBatch",
2495
+ route: routePath,
2496
+ params: batch,
2497
+ hasMore: true
2498
+ });
2499
+ }
2500
+ editorServer.emitRouteParamsBatch({
2501
+ type: "routeParamsBatch",
2502
+ route: routePath,
2503
+ params: [],
2504
+ hasMore: false
2505
+ });
2506
+ } catch (err) {
2507
+ editorServer.emitRouteParamsBatch({
2508
+ type: "routeParamsBatch",
2509
+ route: routePath,
2510
+ params: [],
2511
+ hasMore: false
2512
+ });
2513
+ }
2514
+ })();
2515
+ return { type: "loadRouteParams", success: true };
2516
+ } catch (err) {
2517
+ return { type: "loadRouteParams", success: false, error: err.message };
2518
+ }
2519
+ });
2453
2520
  const publicPath = path.resolve(resolvedConfig.devServer.publicFolder);
2454
2521
  if (fs.existsSync(publicPath)) {
2455
2522
  app.use(express.static(publicPath));
@@ -3067,6 +3134,52 @@ async function validateSchema(context, result) {
3067
3134
  });
3068
3135
  }
3069
3136
  }
3137
+ if (manifest.routes) {
3138
+ if (!Array.isArray(manifest.routes)) {
3139
+ result.errors.push({
3140
+ type: "schema",
3141
+ message: 'Field "routes" must be an array',
3142
+ location: "plugin.yaml"
3143
+ });
3144
+ } else {
3145
+ manifest.routes.forEach((route, index) => {
3146
+ if (!route.path) {
3147
+ result.errors.push({
3148
+ type: "schema",
3149
+ message: `Route at index ${index} is missing "path" field`,
3150
+ location: "plugin.yaml"
3151
+ });
3152
+ }
3153
+ if (!route.jayHtml) {
3154
+ result.errors.push({
3155
+ type: "schema",
3156
+ message: `Route "${route.path || index}" is missing "jayHtml" field`,
3157
+ location: "plugin.yaml",
3158
+ suggestion: "Specify the export subpath for the jay-html file"
3159
+ });
3160
+ }
3161
+ if (!route.component) {
3162
+ result.errors.push({
3163
+ type: "schema",
3164
+ message: `Route "${route.path || index}" is missing "component" field`,
3165
+ location: "plugin.yaml",
3166
+ suggestion: "Specify the exported member name for the page component"
3167
+ });
3168
+ }
3169
+ if (route.jayHtml) {
3170
+ validateDocFile(
3171
+ route.jayHtml,
3172
+ `route "${route.path}" jayHtml`,
3173
+ context,
3174
+ result
3175
+ );
3176
+ }
3177
+ if (route.css) {
3178
+ validateDocFile(route.css, `route "${route.path}" css`, context, result);
3179
+ }
3180
+ });
3181
+ }
3182
+ }
3070
3183
  }
3071
3184
  function resolveContractFile(contractSpec, context) {
3072
3185
  if (context.isNpmPackage) {
@@ -4239,15 +4352,29 @@ async function runAction(actionRef, options, projectRoot, initializeServices) {
4239
4352
  async function runParams(contractRef, options, projectRoot, initializeServices) {
4240
4353
  let viteServer;
4241
4354
  try {
4242
- const slashIndex = contractRef.indexOf("/");
4243
- if (slashIndex === -1) {
4244
- getLogger().error(
4245
- chalk.red(" Invalid contract reference. Use format: <plugin>/<contract>")
4246
- );
4247
- process.exit(1);
4355
+ let pluginName;
4356
+ let contractName;
4357
+ if (contractRef.startsWith("@")) {
4358
+ const secondSlash = contractRef.indexOf("/", contractRef.indexOf("/") + 1);
4359
+ if (secondSlash === -1) {
4360
+ getLogger().error(
4361
+ chalk.red("❌ Invalid contract reference. Use format: @scope/plugin/contract")
4362
+ );
4363
+ process.exit(1);
4364
+ }
4365
+ pluginName = contractRef.substring(0, secondSlash);
4366
+ contractName = contractRef.substring(secondSlash + 1);
4367
+ } else {
4368
+ const slashIndex = contractRef.indexOf("/");
4369
+ if (slashIndex === -1) {
4370
+ getLogger().error(
4371
+ chalk.red("❌ Invalid contract reference. Use format: <plugin>/<contract>")
4372
+ );
4373
+ process.exit(1);
4374
+ }
4375
+ pluginName = contractRef.substring(0, slashIndex);
4376
+ contractName = contractRef.substring(slashIndex + 1);
4248
4377
  }
4249
- const pluginName = contractRef.substring(0, slashIndex);
4250
- const contractName = contractRef.substring(slashIndex + 1);
4251
4378
  if (options.verbose) {
4252
4379
  getLogger().info("Starting Vite for TypeScript support...");
4253
4380
  }
@@ -4282,21 +4409,21 @@ async function runParams(contractRef, options, projectRoot, initializeServices)
4282
4409
  }
4283
4410
  const { resolveServices } = await import("@jay-framework/stack-server-runtime");
4284
4411
  const resolvedServices = resolveServices(component.services || []);
4285
- const allParams = [];
4286
4412
  const paramsGenerator = component.loadParams(resolvedServices);
4413
+ let total = 0;
4287
4414
  for await (const batch of paramsGenerator) {
4288
- allParams.push(...batch);
4289
- }
4290
- if (options.yaml) {
4291
- getLogger().important(YAML.stringify(allParams));
4292
- } else {
4293
- getLogger().important(JSON.stringify(allParams, null, 2));
4415
+ for (const params of batch) {
4416
+ if (options.yaml) {
4417
+ getLogger().important(YAML.stringify([params]).trimEnd());
4418
+ } else {
4419
+ getLogger().important(JSON.stringify(params));
4420
+ }
4421
+ total++;
4422
+ }
4294
4423
  }
4295
4424
  if (!options.yaml) {
4296
- getLogger().important(
4297
- chalk.green(`
4298
- ✅ Found ${allParams.length} param combination(s)`)
4299
- );
4425
+ getLogger().important(chalk.green(`
4426
+ ✅ Found ${total} param combination(s)`));
4300
4427
  }
4301
4428
  } catch (error) {
4302
4429
  getLogger().error(chalk.red("❌ Failed to discover params:") + " " + error.message);
@@ -4671,8 +4798,8 @@ program.command("action <plugin/action>").description(
4671
4798
  await runAction(actionRef, options, process.cwd(), initializeServicesForCli);
4672
4799
  });
4673
4800
  program.command("params <plugin/contract>").description(
4674
- "Discover load param values for a contract (e.g., jay-stack params wix-stores/product-page)"
4675
- ).option("--yaml", "Output result as YAML instead of JSON").option("-v, --verbose", "Show detailed output").action(async (contractRef, options) => {
4801
+ "Discover load param values for a contract. Streams results as NDJSON (one JSON object per line) or YAML."
4802
+ ).option("--yaml", "Output as YAML instead of NDJSON").option("-v, --verbose", "Show detailed output").action(async (contractRef, options) => {
4676
4803
  await runParams(contractRef, options, process.cwd(), initializeServicesForCli);
4677
4804
  });
4678
4805
  program.parse(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jay-framework/jay-stack-cli",
3
- "version": "0.15.6",
3
+ "version": "0.16.1",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -24,14 +24,14 @@
24
24
  "test:watch": "vitest"
25
25
  },
26
26
  "dependencies": {
27
- "@jay-framework/compiler-jay-html": "^0.15.6",
28
- "@jay-framework/compiler-shared": "^0.15.6",
29
- "@jay-framework/dev-server": "^0.15.6",
30
- "@jay-framework/editor-server": "^0.15.6",
31
- "@jay-framework/fullstack-component": "^0.15.6",
32
- "@jay-framework/logger": "^0.15.6",
33
- "@jay-framework/plugin-validator": "^0.15.6",
34
- "@jay-framework/stack-server-runtime": "^0.15.6",
27
+ "@jay-framework/compiler-jay-html": "^0.16.1",
28
+ "@jay-framework/compiler-shared": "^0.16.1",
29
+ "@jay-framework/dev-server": "^0.16.1",
30
+ "@jay-framework/editor-server": "^0.16.1",
31
+ "@jay-framework/fullstack-component": "^0.16.1",
32
+ "@jay-framework/logger": "^0.16.1",
33
+ "@jay-framework/plugin-validator": "^0.16.1",
34
+ "@jay-framework/stack-server-runtime": "^0.16.1",
35
35
  "chalk": "^4.1.2",
36
36
  "commander": "^14.0.0",
37
37
  "express": "^5.0.1",
@@ -42,7 +42,7 @@
42
42
  "yaml": "^2.3.4"
43
43
  },
44
44
  "devDependencies": {
45
- "@jay-framework/dev-environment": "^0.15.6",
45
+ "@jay-framework/dev-environment": "^0.16.1",
46
46
  "@types/express": "^5.0.2",
47
47
  "@types/node": "^22.15.21",
48
48
  "nodemon": "^3.0.3",