@aeratic/pagetailor 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,14 +2,56 @@
2
2
 
3
3
  Pages as data. Define your app's pages in YAML, bind them to a typed catalog of components and data sources, tailor them per role and per person with small patches, and let an AI agent compose them through a validation gate. Rendering is done by [json-render](https://github.com/vercel-labs/json-render).
4
4
 
5
- ```
6
- npm init -y
5
+ ## Start here
6
+
7
+ ```bash
8
+ # 1. Install and create the project
7
9
  npm install @aeratic/pagetailor react react-dom
8
10
  npx pagetailor init
11
+
12
+ # 2. Check that everything fits together
9
13
  npx pagetailor validate
10
- npx pagetailor dev # http://localhost:4747/?user=giulia&role=marketing
14
+
15
+ # 3. Run the preview and the Studio
16
+ npx pagetailor dev
17
+ # http://localhost:4747/?user=giulia&role=marketing the page as Giulia sees it
18
+ # http://localhost:4747/?role=magazzino the same page for the warehouse role
19
+ # http://localhost:4747/?view=studio visual editor of the whole project
20
+
21
+ # 4. Enable the AI agent
22
+ ANTHROPIC_API_KEY=sk-... npx pagetailor dev
23
+
24
+ # 5. After editing components/registry.yaml, regenerate the component index
25
+ npx pagetailor registry
11
26
  ```
12
27
 
28
+ Three things to try in the first ten minutes:
29
+
30
+ - **Change one user's layout.** Open `pagetailor/overrides/user-giulia.yaml`, move a row in `areas`, save: the browser reloads with her new page. Nobody else's page changes.
31
+ - **Break something on purpose.** Put a data source that does not exist in `pages/home.yaml`. `validate` refuses it, and in the preview that widget becomes a red box with the reason while the rest of the page still renders.
32
+ - **Ask the agent.** Type "put the KPI at the top" in the *Change this page…* box. The patch it writes is an ordinary override, checked by the same validator as your own edits.
33
+
34
+ What pagetailor is not: a CMS, a page builder for end users, or an authenticated server. It is an engine for declarative pages with a gate that humans, the Studio and agents all go through. `pagetailor dev` writes project files over an unauthenticated API and binds to localhost only.
35
+
36
+ ## CLI
37
+
38
+ | Command | What it does |
39
+ | --- | --- |
40
+ | `pagetailor init [--force]` | create `pagetailor/` with manifest, tokens, data catalog, a sample page, overrides and base components |
41
+ | `pagetailor validate` | check manifest, pages and overrides against the component registry and the data catalog; exit 1 on problems |
42
+ | `pagetailor registry` | write `pagetailor/components/index.js` exporting every component in `registry.yaml` |
43
+ | `pagetailor dev [--port 4747] [--host 127.0.0.1]` | preview server with live reload and the Studio; pass `--host 0.0.0.0` to expose it |
44
+ | `pagetailor help`, `pagetailor --version` | this text, the version |
45
+
46
+ ## Contents
47
+
48
+ - [What `init` creates](#what-init-creates)
49
+ - [A page](#a-page), [An override](#an-override), [The gate](#the-gate), [Component props](#component-props)
50
+ - [Next.js](#nextjs), [Caching](#caching)
51
+ - [Proposals and approval](#proposals-and-approval), [Reorder editor](#reorder-editor)
52
+ - [The agent](#the-agent), [Pages as answers](#pages-as-answers)
53
+ - [Studio](#studio), [Status](#status)
54
+
13
55
  ## What `init` creates
14
56
 
15
57
  ```
@@ -106,6 +148,8 @@ Changes go through `propose(root, { name, override, author, role })`, which firs
106
148
  - Each page lists the roles allowed to approve it in `manifest.yaml`: `owners: { home: [admin] }`.
107
149
  - `approve(root, { name, version, approver, approverRole })` re-runs the gate and commits a new applied version with `approvedFrom`; the proposal is marked `approved`. `reject(root, { name, version, approver, approverRole, reason })` marks it `rejected`. `listPending(root)` lists open proposals, newest first.
108
150
 
151
+ Roles named in a proposal (the target role of a `role-*` override, the `role` of a user proposal, the `approverRole` of an approval) must exist in `manifest.roles`.
152
+
109
153
  Dev server routes: `POST /api/propose`, `GET /api/pending`, `POST /api/approve`, `POST /api/reject` (400 with `{ ok: false, errors }` when the gate or owner check fails). `POST /api/override` still commits directly.
110
154
 
111
155
  ## Reorder editor
@@ -127,10 +171,31 @@ A user asks in natural language ("put the KPI at the top"); the agent turns it i
127
171
 
128
172
  `complete` is any `async ({ system, user }) => text`, so any model or SDK works; pagetailor depends on none. `anthropicComplete({ apiKey, model })` is a built-in fetch call to the Anthropic Messages API (`claude-sonnet-5-5` by default).
129
173
 
130
- In `pagetailor dev`, set `ANTHROPIC_API_KEY` (and optionally `PAGETAILOR_MODEL`) and use the "Ask the agent…" box in the header: `POST /api/agent` with `{ page, user, role, request }` (503 without the key). `GET /api/agent/prompt?page=&user=&role=&request=` returns the built prompt for debugging.
174
+ In `pagetailor dev`, set `ANTHROPIC_API_KEY` (and optionally `PAGETAILOR_MODEL`) and use the "Change this page…" box in the header: `POST /api/agent` with `{ page, user, role, request }` (503 without the key). `GET /api/agent/prompt?page=&user=&role=&request=` returns the built prompt for debugging.
175
+
176
+ ## Pages as answers
177
+
178
+ A user asks a question ("how did sales go this week?") and the agent composes a brand-new page from the catalog instead of changing an existing one.
179
+
180
+ 1. `runAsk(root, { user, role, request, complete })` starts from an empty `layout/stack`, asks the model for `{ "title", "why", "patches" }` using only `insert` ops (cards, grids, widgets), applies them in memory and resolves the tree with `resolve(root, "answer", { user, role, tree })`. Gate or resolve errors are sent back once, like `runAgent`.
181
+ 2. The result (`{ title, why, tree, resolved }`) is rendered as is. Nothing is written to disk until the user decides.
182
+ 3. Keep as page: `keepAnswer(root, { user, role, title, tree, why })` writes `pages/<slug>.yaml` with `owner: <user>` and adds the slug to `manifest.pages` (slugs are made unique with `-2`, `-3`).
183
+ 4. Pin to home: `pinAnswer(root, { user, role, title, tree, why })` proposes, as `user-<user>`, an `insert` of a `layout/card` with the answer's widgets into the home's main container; on a grid it gets its own full-width row at the bottom. Same gate and proposal rules as any override.
184
+
185
+ Both go through the gate with the user's role first. In `pagetailor dev` use the "Ask…" box: `POST /api/ask` `{ user, role, request }` (503 without `ANTHROPIC_API_KEY`), `POST /api/ask/keep` and `POST /api/ask/pin` with `{ user, role, title, tree, why }`.
186
+
187
+ ## Studio
188
+
189
+ `pagetailor dev` has two views in the top bar: **Preview** (the app as rendered, with the editor and agent boxes) and **Studio**, a visual editor for the whole `pagetailor/` project (`http://localhost:4747/?view=studio`). The first tab, **App**, edits `manifest.yaml`: name, home, navigation position, pages (adding one creates `pages/<slug>.yaml` with an empty `layout/stack`; the home page cannot be removed), roles (a role still used as a page owner or by a data source cannot be removed) and page owners.
190
+
191
+ The **Pages** tab edits `pages/<slug>.yaml`: a tree of the page's components (with the child name under a grid and the bound data source) and an inspector for the selected node. The component select lists every `name@version` in the registry (switching keeps the props the new schema still declares), props are form fields generated from the registry schema (free JSON when a component has none), the data select offers only the catalog sources whose type the component accepts, with their params. Containers can add, remove and reorder children; a grid renames and removes named children together with their cells in `areas`, and a new child gets a full-width row. The tree is validated live with the same `validateTree` as the server, so problems show before Save.
192
+
193
+ The files stay the source of truth: each tab loads its file into a draft, the YAML pane at the bottom shows the draft as it will be written, and **Save** writes the file through the same validator as `pagetailor validate` (errors are shown in red and nothing is written; saving a page also re-applies every override that extends it, so renaming a child that an override targets is refused with the override's name). Saves rewrite the file keeping its top-level comments. Every save is versioned under `.history/files/<name>/NNNN.json` with the content before and after and a `why` (versions are allocated exclusively, so concurrent writers never collide); **Undo** restores the previous content as a new version. The **auto-save** checkbox saves 1.5 s after the last change.
194
+
195
+ Routes: `GET /api/project`, `GET|PUT|DELETE /api/files/<name>`, `GET /api/files/<name>/history`, `POST /api/files/<name>/undo`, where `<name>` is `manifest`, `tokens`, `data-catalog`, `components/registry`, `pages/<slug>` or `overrides/<name>`. The same functions are exported: `readProjectFile`, `writeProjectFile`, `deleteProjectFile`, `listFileHistory`, `undoFile`, `scaffoldSource`, `scaffoldComponent`.
131
196
 
132
197
  ## Status
133
198
 
134
- 0.3: engine, CLI (`init`, `validate`, `registry`, `dev`), history with rollback, proposals with owner approval, reorder editor, agent with a pluggable model adapter.
199
+ 0.4: engine, CLI (`init`, `validate`, `registry`, `dev`), history with rollback, proposals with owner approval, reorder editor, agent with a pluggable model adapter, pages as answers.
135
200
 
136
201
  License: MIT.
package/bin/cli.js CHANGED
@@ -3,21 +3,28 @@ import { init } from "../src/init.js";
3
3
  import { validate } from "../src/validate.js";
4
4
  import { dev } from "../src/dev.js";
5
5
  import { writeRegistryFile } from "../src/registry-file.js";
6
+ import { readFile } from "node:fs/promises";
6
7
 
7
8
  const [cmd, ...args] = process.argv.slice(2);
8
9
  const flag = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : d; };
10
+ const version = async () => JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")).version;
9
11
  const help = `pagetailor <command>
10
12
 
11
13
  init [--force] create pagetailor/ with manifest, tokens, data catalog, a sample page, overrides and base components
12
14
  validate check manifest, pages and overrides against the component registry and the data catalog
13
15
  registry write pagetailor/components/index.js exporting every component in registry.yaml
14
- dev [--port 4747] preview server with live reload; ?page=home&user=giulia&role=marketing
16
+ dev [--port 4747] [--host 127.0.0.1]
17
+ preview server with live reload (localhost only unless --host is given); ?page=home&user=giulia&role=marketing
18
+ help this text
19
+ --version, -v print the version
15
20
  `;
16
21
 
17
22
  try {
18
23
  if (cmd === "init") await init(process.cwd(), args);
19
24
  else if (cmd === "validate") process.exitCode = (await validate(process.cwd())) ? 0 : 1;
20
25
  else if (cmd === "registry") { const r = await writeRegistryFile(process.cwd()); console.log(`wrote ${r.path} (${r.count} components)`); }
21
- else if (cmd === "dev") await dev(process.cwd(), { port: Number(flag("--port", 4747)) });
22
- else { console.log(help); if (cmd && cmd !== "help") process.exitCode = 1; }
26
+ else if (cmd === "dev") await dev(process.cwd(), { port: Number(flag("--port", 4747)), host: flag("--host", "127.0.0.1") });
27
+ else if (cmd === "--version" || cmd === "-v" || cmd === "version") console.log(await version());
28
+ else if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") console.log(help);
29
+ else { console.error(`pagetailor: unknown command "${cmd}"\n`); console.log(help); process.exitCode = 1; }
23
30
  } catch (e) { console.error("pagetailor:", e.message); process.exitCode = 1; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aeratic/pagetailor",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Pages as data: declarative YAML pages, typed data catalog, per-user overrides, validated for AI agents. Renders with json-render.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/agent.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { join } from "node:path";
2
- import { loadProject, loadPage, loadOverrides, loadYaml, exists } from "./loader.js";
2
+ import { writeYaml } from "./yaml-write.js";
3
+ import { loadProject, loadPage, loadOverrides, loadYaml, exists, listPages } from "./loader.js";
3
4
  import { applyOverride, mergePatch } from "./overrides.js";
4
- import { splitRef } from "./validator.js";
5
+ import { splitRef, validateTree } from "./validator.js";
5
6
  import { propose } from "./proposals.js";
7
+ import { resolve } from "./resolver.js";
6
8
 
7
9
  // The agent never writes files: it turns a request into override patches and hands them to propose().
8
10
  const OPS = [
@@ -56,22 +58,27 @@ export async function describePage(root, page, { user, role } = {}) {
56
58
  return `PAGE ${page} (user ${user ?? "none"}, role ${role ?? "none"})\n${lines.join("\n")}`;
57
59
  }
58
60
 
61
+ const RULES = `- Use only the components, data sources and ops listed below; refs include the version (name@version).
62
+ - Respect types: a component bound to data must accept the type the source returns. Use only params listed for a source.
63
+ - Respect the role: never use a data source that is not listed (the role cannot read it).`;
64
+ const GRID_RULE = "- A grid with areas must place every child exactly once";
65
+ const reply = shape => `Answer ONLY with a JSON object, no prose:
66
+ ${shape}
67
+ or, when the request cannot be satisfied with this catalog or this user's role:
68
+ { "refuse": string }`;
69
+ const LANG = (...f) => `- Write ${f.map(x => `"${x}"`).join(", ")} (and "refuse") in the user's language.`;
70
+
59
71
  export async function buildAgentPrompt(root, { page, user, role, request }) {
60
72
  const project = await loadProject(root);
61
73
  const system = `You modify a declarative UI page for one user by writing override patches.
62
- Answer ONLY with a JSON object, no prose:
63
- { "why": string, "patches": [ ...override ops... ] }
64
- or, when the request cannot be satisfied with this catalog or this user's role:
65
- { "refuse": string }
74
+ ${reply('{ "why": string, "patches": [ ...override ops... ] }')}
66
75
 
67
76
  RULES
68
- - Use only the components, data sources and ops listed below; refs include the version (name@version).
69
- - Respect types: a component bound to data must accept the type the source returns. Use only params listed for a source.
70
- - Respect the role: never use a data source that is not listed (the role cannot read it).
77
+ ${RULES}
71
78
  - Targets are existing child names or component names from the current page.
72
- - A grid with areas must place every child exactly once: when you insert, remove or replace grid children, update areas too.
79
+ ${GRID_RULE}: when you insert, remove or replace grid children, update areas too.
73
80
  - Keep patches minimal: change only what the request asks for. Patches are appended to the user's existing ones.
74
- - Write "why" (and "refuse") in the user's language.
81
+ ${LANG("why")}
75
82
 
76
83
  ${describeCatalog(project, { role })}
77
84
 
@@ -79,6 +86,28 @@ CURRENT ${await describePage(root, page, { user, role })}`;
79
86
  return { system, user: request };
80
87
  }
81
88
 
89
+ export const ANSWER_SKELETON = { ref: "layout/stack@1.0.0", children: [] };
90
+
91
+ export async function buildAskPrompt(root, { user, role, request }) {
92
+ const project = await loadProject(root);
93
+ const system = `You answer a user's question by composing a brand-new UI page from a catalog of components and data sources.
94
+ ${reply('{ "title": string, "why": string, "patches": [ ...insert ops only... ] }')}
95
+
96
+ RULES
97
+ ${RULES}
98
+ - Use ONLY "insert" ops, onto the page's root layout/stack (target "layout/stack") or onto a layout/grid or layout/card you inserted.
99
+ - Add a layout/card (with a title) or a layout/grid to group widgets as needed. To fill a grid, insert it with named children and areas in one node.
100
+ ${GRID_RULE}.
101
+ - "title" names the page in a few words; "why" says briefly how the page answers the question.
102
+ ${LANG("title", "why")}
103
+
104
+ ${describeCatalog(project, { role })}
105
+
106
+ CURRENT PAGE answer (empty, user ${user ?? "none"}, role ${role ?? "none"})
107
+ layout/stack: layout/stack@1.0.0`;
108
+ return { system, user: request };
109
+ }
110
+
82
111
  export function parseAgentReply(text) {
83
112
  const s = String(text ?? "");
84
113
  const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(s);
@@ -121,3 +150,70 @@ export async function runAgent(root, { page, user, role, request, complete }) {
121
150
  reply = undefined; errors = [];
122
151
  }
123
152
  }
153
+
154
+ // Pages as answers: compose a page in memory; nothing is written until keepAnswer or pinAnswer.
155
+ export async function runAsk(root, { user, role, request, complete }) {
156
+ const prompt = await buildAskPrompt(root, { user, role, request });
157
+ let message = prompt.user, reply, errors = [];
158
+ for (let attempts = 1; attempts <= 2; attempts++) {
159
+ const text = await complete({ system: prompt.system, user: message });
160
+ try { reply = parseAgentReply(text); } catch (e) { errors = [e.message]; }
161
+ if (reply?.refuse) return { ok: false, refuse: reply.refuse, reply, attempts };
162
+ if (reply && !errors.length) {
163
+ if (!Array.isArray(reply.patches)) errors = ["reply has no patches array"];
164
+ else if (reply.patches.some(p => p?.op !== "insert")) errors = ["only insert ops are allowed"];
165
+ else {
166
+ const r = applyOverride(ANSWER_SKELETON, { name: "answer", patches: reply.patches });
167
+ const resolved = await resolve(root, "answer", { user, role, tree: r.tree });
168
+ errors = [...r.errors, ...resolved.errors].map(e => `${e.path}: ${e.message}`);
169
+ if (!errors.length) return { ok: true, title: reply.title ?? "Answer", why: reply.why, tree: r.tree, resolved: { spec: resolved.spec, errors: resolved.errors }, attempts };
170
+ }
171
+ }
172
+ if (attempts === 2) return { ok: false, errors, reply, attempts };
173
+ message = `${prompt.user}\n\nThe previous page was rejected: ${errors.join("; ")}. Fix it.`;
174
+ reply = undefined; errors = [];
175
+ }
176
+ }
177
+
178
+ const slugify = s => String(s ?? "").normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "answer";
179
+
180
+ export async function keepAnswer(root, { user, role, title, tree, why }) {
181
+ const project = await loadProject(root);
182
+ const errors = validateTree(tree, project, { role: role ?? null });
183
+ if (errors.length) return { ok: false, errors: errors.map(e => `${e.path}: ${e.message}`) };
184
+ const taken = new Set([...(await listPages(root)), ...(project.manifest.pages ?? [])]);
185
+ const base = slugify(title); let slug = base;
186
+ for (let n = 2; taken.has(slug); n++) slug = `${base}-${n}`;
187
+ await writeYaml(join(root, "pages", `${slug}.yaml`), { page: slug, title, owner: user, ...(why ? { why } : {}), layout: tree });
188
+ const mfile = join(root, "manifest.yaml");
189
+ const manifest = (await loadYaml(mfile)) ?? {};
190
+ await writeYaml(mfile, { ...manifest, pages: [...(manifest.pages ?? []), slug] });
191
+ return { ok: true, page: slug };
192
+ }
193
+
194
+ // The home's main container: the root, or the single grid it wraps.
195
+ function mainContainer(tree) {
196
+ let node = tree;
197
+ while (Array.isArray(node.children) && node.children.length === 1 && node.children[0]?.children && !Array.isArray(node.children[0].children)) node = node.children[0];
198
+ return node;
199
+ }
200
+
201
+ export async function pinAnswer(root, { user, role, page, title, tree, why }) {
202
+ if (!user) return { ok: false, errors: ["user is required"] };
203
+ page ??= (await loadProject(root)).manifest.home;
204
+ const name = `user-${user}`;
205
+ const file = join(root, "overrides", `${name}.yaml`);
206
+ const existing = await exists(file) ? (await loadYaml(file)) ?? {} : {};
207
+ const base = existing.extends === page ? existing.patches ?? [] : [];
208
+ const target = mainContainer(await currentTree(root, page, { user, role }));
209
+ const card = { ref: "layout/card@1.0.0", props: { title: title ?? "Answer" }, children: structuredClone(tree.children ?? []) };
210
+ const insert = { op: "insert", target: splitRef(target.ref)[0], node: card };
211
+ if (target.children && !Array.isArray(target.children)) {
212
+ let n = 1; while (`answer-${n}` in target.children) n++;
213
+ insert.name = `answer-${n}`;
214
+ const areas = String(target.props?.areas ?? "").trimEnd();
215
+ const cols = areas ? areas.split("\n")[0].trim().split(/\s+/).length : 1;
216
+ insert.area = `${areas ? areas + "\n" : ""}${Array(cols).fill(insert.name).join(" ")}\n`;
217
+ }
218
+ return propose(root, { name, author: user, role, override: { extends: page, why: why ?? `pinned: ${title ?? "answer"}`, patches: mergePatch(base, insert) } });
219
+ }
package/src/dev.js CHANGED
@@ -9,12 +9,14 @@ import { loadProject, loadYaml, listPages } from "./loader.js";
9
9
  import { commitOverride, listHistory, rollback } from "./history.js";
10
10
  import { propose, listPending, approve, reject } from "./proposals.js";
11
11
  import { tokensToCss } from "./tokens.js";
12
- import { buildAgentPrompt, runAgent } from "./agent.js";
12
+ import { buildAgentPrompt, runAgent, runAsk, keepAnswer, pinAnswer } from "./agent.js";
13
13
  import { anthropicComplete } from "./llm.js";
14
+ import { isProjectFile, readProjectFile, writeProjectFile, deleteProjectFile, listFileHistory, undoFile, readProject, scaffoldSource, scaffoldComponent } from "./files.js";
14
15
 
15
16
  const here = dirname(fileURLToPath(import.meta.url));
16
17
 
17
- export async function dev(cwd, { port = 4747 } = {}) {
18
+ // Binds to localhost only: the API writes project files and has no authentication. Pass host to expose it.
19
+ export async function dev(cwd, { port = 4747, host = "127.0.0.1" } = {}) {
18
20
  const root = join(cwd, "pagetailor");
19
21
  // Bundle one React only: the project's if installed, otherwise the one shipped with this package.
20
22
  const reqProject = createRequire(join(cwd, "package.json")), reqSelf = createRequire(import.meta.url);
@@ -26,67 +28,28 @@ export async function dev(cwd, { port = 4747 } = {}) {
26
28
  alias["@aeratic/pagetailor/client"] = join(here, "client.jsx");
27
29
  const nodePaths = [join(cwd, "node_modules"), join(here, "..", "node_modules")];
28
30
 
29
- // one generated entry: imports every component from registry.yaml, mounts <Page>
30
- const entry = async () => {
31
- const reg = await loadYaml(join(root, "components", "registry.yaml"));
32
- const imports = [], map = [];
33
- let i = 0;
34
- for (const [name, def] of Object.entries(reg)) for (const [ver, v] of Object.entries(def.versions ?? {})) {
35
- if (!v.impl) continue; const id = `C${i++}`;
36
- imports.push(`import ${id} from ${JSON.stringify(v.impl)};`);
37
- map.push(`${JSON.stringify(`${name}@${ver}`)}: ${id}`);
38
- }
39
- return `${imports.join("\n")}
40
- import { createRoot } from "react-dom/client";
41
- import { useCallback, useEffect, useState } from "react";
42
- import { Page, EditorContext, useEditor } from ${JSON.stringify(join(here, "client.jsx"))};
43
- const registry = { ${map.join(", ")} };
44
- function App() {
45
- const q = new URLSearchParams(location.search);
46
- const [page, setPage] = useState(q.get("page") || "__home__");
47
- const [user, setUser] = useState(q.get("user") || "");
48
- const [role, setRole] = useState(q.get("role") || "");
49
- const [state, setState] = useState(null);
50
- const load = useCallback(() => fetch(\`/api/page/\${page}?user=\${user}&role=\${role}\`).then(r => r.json()).then(setState), [page, user, role]);
51
- useEffect(() => { load(); }, [load]);
52
- // keep page/user/role in the URL: the file watcher reloads the tab after an override is written
53
- useEffect(() => { const u = new URLSearchParams({ page, user, role }); history.replaceState(null, "", "?" + u); }, [page, user, role]);
54
- const editor = useEditor({ page: state?.page, user, role, why: "reordered in the editor", onApplied: r => { if (!r.pending) load(); } });
55
- const [ask, setAsk] = useState(""), [agent, setAgent] = useState(null);
56
- const sendAsk = async e => {
57
- e.preventDefault(); if (!ask.trim()) return;
58
- setAgent({ text: "thinking…" });
59
- try {
60
- const r = await (await fetch("/api/agent", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ page: state.page, user, role: role || undefined, request: ask }) })).json();
61
- if (r.ok) setAgent({ text: (r.proposal.pending ? "sent for approval: " : "applied: ") + (r.reply.why ?? "") });
62
- else setAgent({ error: true, text: r.refuse ?? (r.errors ?? [r.error ?? "agent failed"]).join("; ") });
63
- } catch (err) { setAgent({ error: true, text: err.message }); }
64
- };
65
- useEffect(() => { const es = new EventSource("/events"); es.onmessage = () => location.reload(); return () => es.close(); }, []);
66
- if (!state) return null;
67
- const pages = state.manifest.pages, roles = state.manifest.roles ?? [];
68
- return <>
69
- <header style={{ display: "flex", gap: 12, alignItems: "center", padding: "10px 16px", borderBottom: "1px solid var(--pt-color-border)", background: "var(--pt-color-surface)", fontSize: 13 }}>
70
- <strong>{state.manifest.app}</strong>
71
- <label>page <select value={state.page} onChange={e => setPage(e.target.value)}>{pages.map(p => <option key={p}>{p}</option>)}</select></label>
72
- <label>user <input value={user} placeholder="giulia" onChange={e => setUser(e.target.value)} style={{ width: 90 }} /></label>
73
- <label>role <select value={role} onChange={e => setRole(e.target.value)}><option value="">(none)</option>{roles.map(r => <option key={r}>{r}</option>)}</select></label>
74
- <button onClick={() => editor.setEditing(!editor.editing)} style={{ fontWeight: editor.editing ? 700 : 400 }}>{editor.editing ? "Done editing" : "Edit layout"}</button>
75
- {editor.status === "saving" && <span>saving…</span>}
76
- {editor.status === "pending" && <span>sent for approval</span>}
77
- {editor.status === "error" && <span style={{ color: "var(--pt-color-danger)" }}>{editor.errors.join("; ")}</span>}
78
- <form onSubmit={sendAsk} style={{ display: "flex", gap: 6 }}><input value={ask} placeholder="Ask the agent…" onChange={e => setAsk(e.target.value)} style={{ width: 220 }} /><button type="submit">Ask</button></form>
79
- {agent && <span style={{ color: agent.error ? "var(--pt-color-danger)" : undefined }}>{agent.text}</span>}
80
- {state.errors.length > 0 && <span style={{ color: "var(--pt-color-danger)" }}>{state.errors.length} problem(s), see console</span>}
81
- </header>
82
- <main style={{ padding: 16, maxWidth: 1100, margin: "0 auto" }}><EditorContext.Provider value={{ editing: editor.editing, propose: editor.propose }}><Page resolved={state} registry={registry} /></EditorContext.Provider></main>
83
- </>;
84
- }
85
- createRoot(document.getElementById("root")).render(<App />);`;
31
+ // the Studio entry imports the project's components through a virtual module built from registry.yaml,
32
+ // regenerated on every rebuild
33
+ const registryPlugin = {
34
+ name: "pagetailor-registry",
35
+ setup(build) {
36
+ build.onResolve({ filter: /^pagetailor:registry$/ }, () => ({ path: "registry", namespace: "pagetailor" }));
37
+ build.onLoad({ filter: /.*/, namespace: "pagetailor" }, async () => {
38
+ const reg = await loadYaml(join(root, "components", "registry.yaml"));
39
+ const imports = [], map = [];
40
+ let i = 0;
41
+ for (const [name, def] of Object.entries(reg ?? {})) for (const [ver, v] of Object.entries(def.versions ?? {})) {
42
+ if (!v.impl) continue; const id = `C${i++}`;
43
+ imports.push(`import ${id} from ${JSON.stringify(v.impl)};`);
44
+ map.push(`${JSON.stringify(`${name}@${ver}`)}: ${id}`);
45
+ }
46
+ return { contents: `${imports.join("\n")}\nexport const registry = { ${map.join(", ")} };\n`, resolveDir: join(root, "components"), loader: "jsx" };
47
+ });
48
+ },
86
49
  };
87
50
 
88
51
  const ctx = await esbuild.context({
89
- stdin: { contents: await entry(), resolveDir: join(root, "components"), loader: "jsx", sourcefile: "pagetailor-entry.jsx" },
52
+ entryPoints: [join(here, "studio", "entry.jsx")], plugins: [registryPlugin],
90
53
  bundle: true, write: false, format: "esm", jsx: "automatic", alias, nodePaths, outfile: "bundle.js", sourcemap: "inline", logLevel: "silent",
91
54
  define: { "process.env.NODE_ENV": '"development"' },
92
55
  });
@@ -144,6 +107,30 @@ createRoot(document.getElementById("root")).render(<App />);`;
144
107
  const r = await runAgent(root, { page: b.page, user: b.user, role: b.role || undefined, request: b.request, complete });
145
108
  return json(res, r.ok ? 200 : 400, r);
146
109
  }
110
+ if (url.pathname === "/api/ask" && req.method === "POST") {
111
+ if (!process.env.ANTHROPIC_API_KEY) return json(res, 503, { error: "set ANTHROPIC_API_KEY to use the agent" });
112
+ const b = await body(req);
113
+ const complete = anthropicComplete({ apiKey: process.env.ANTHROPIC_API_KEY, ...(process.env.PAGETAILOR_MODEL ? { model: process.env.PAGETAILOR_MODEL } : {}) });
114
+ const r = await runAsk(root, { user: b.user || undefined, role: b.role || undefined, request: b.request, complete });
115
+ return json(res, r.ok ? 200 : 400, r);
116
+ }
117
+ const answer = { "/api/ask/keep": keepAnswer, "/api/ask/pin": pinAnswer }[url.pathname];
118
+ if (answer && req.method === "POST") { const b = await body(req); const r = await answer(root, { ...b, user: b.user || undefined, role: b.role || undefined }); return json(res, r.ok ? 200 : 400, r); }
119
+ if (url.pathname === "/api/project" && req.method === "GET") return json(res, 200, await readProject(root));
120
+ if (url.pathname.startsWith("/api/files/")) {
121
+ let name = decodeURIComponent(url.pathname.slice(11)), action = null;
122
+ const m = /^(.+)\/(history|undo)$/.exec(name);
123
+ if (m && isProjectFile(m[1])) { name = m[1]; action = m[2]; }
124
+ if (!isProjectFile(name)) return json(res, 400, { ok: false, errors: [`not a project file: ${name}`] });
125
+ if (action === "history" && req.method === "GET") return json(res, 200, await listFileHistory(root, name));
126
+ if (action === "undo" && req.method === "POST") { const r = await undoFile(root, name, { author: "studio" }); return json(res, r.ok ? 200 : 400, r); }
127
+ if (!action && req.method === "GET") return json(res, 200, { name, value: await readProjectFile(root, name) });
128
+ if (!action && req.method === "PUT") { const b = await body(req); const r = await writeProjectFile(root, name, b.value, { author: b.author ?? "studio", why: b.why ?? "edited in studio" }); return json(res, r.ok ? 200 : 400, r); }
129
+ if (!action && req.method === "DELETE") { const r = await deleteProjectFile(root, name, { author: "studio" }); return json(res, r.ok ? 200 : 400, r); }
130
+ return json(res, 405, { ok: false, errors: ["method not allowed"] });
131
+ }
132
+ if (url.pathname === "/api/scaffold/source" && req.method === "POST") { const b = await body(req); return json(res, 200, await scaffoldSource(root, b.name)); }
133
+ if (url.pathname === "/api/scaffold/component" && req.method === "POST") { const b = await body(req); return json(res, 200, await scaffoldComponent(root, b.name, { props: b.props, container: b.container })); }
147
134
  if (url.pathname === "/api/pending") return json(res, 200, await listPending(root));
148
135
  const flow = { "/api/propose": propose, "/api/approve": approve, "/api/reject": reject }[url.pathname];
149
136
  if (flow && req.method === "POST") { const r = await flow(root, await body(req)); return json(res, r.ok ? 200 : 400, r); }
@@ -156,7 +143,7 @@ createRoot(document.getElementById("root")).render(<App />);`;
156
143
  console.error(`pagetailor: port ${port} is already in use (try --port ${port + 1})`);
157
144
  process.exit(1);
158
145
  });
159
- await new Promise(r => server.listen(port, r));
160
- console.log(`pagetailor dev http://localhost:${port}\n pages from ${root}\n ?page=home&user=giulia&role=marketing`);
146
+ await new Promise(r => server.listen(port, host, r));
147
+ console.log(`pagetailor dev http://${host === "0.0.0.0" || host === "::" ? "localhost" : host}:${port}\n pages from ${root}\n ?page=home&user=giulia&role=marketing`);
161
148
  return server;
162
149
  }
package/src/files.js ADDED
@@ -0,0 +1,157 @@
1
+ import { mkdir, readdir, readFile, writeFile, rm } from "node:fs/promises";
2
+ import { join, dirname } from "node:path";
3
+ import { parse } from "yaml";
4
+ import { toYaml, writeYaml, appendVersion } from "./yaml-write.js";
5
+ export { toYaml };
6
+ import { loadProject, listPages, listOverrides, exists, forgetYaml } from "./loader.js";
7
+ import { validateTree, validateManifest } from "./validator.js";
8
+ import { clearResolveCache } from "./resolver.js";
9
+ import { applyOverride } from "./overrides.js";
10
+
11
+ // The YAML files of a project, read and written as plain objects; every write is versioned
12
+ // under .history/files/<name with / as __>/NNNN.json with the previous and the new content.
13
+ const FIXED = { manifest: "manifest.yaml", tokens: "tokens.yaml", "data-catalog": "data-catalog.yaml", "components/registry": "components/registry.yaml" };
14
+ const SLUG = /^[a-z0-9-]+$/;
15
+ const msg = e => typeof e === "string" ? e : `${e.path}: ${e.message}`;
16
+
17
+ export function isProjectFile(name) {
18
+ if (typeof name !== "string") return false;
19
+ if (FIXED[name]) return true;
20
+ const [dir, slug, extra] = name.split("/");
21
+ return (dir === "pages" || dir === "overrides") && extra === undefined && SLUG.test(slug ?? "");
22
+ }
23
+ const pathOf = (root, name) => {
24
+ if (!isProjectFile(name)) throw new Error(`not a project file: ${name}`);
25
+ return join(root, FIXED[name] ?? `${name}.yaml`);
26
+ };
27
+ const historyDir = (root, name) => join(root, ".history", "files", name.replace("/", "__"));
28
+
29
+ export async function readProjectFile(root, name) {
30
+ const p = pathOf(root, name);
31
+ if (!(await exists(p))) return null;
32
+ return parse(await readFile(p, "utf8")) ?? null;
33
+ }
34
+
35
+ async function validateFile(root, name, value) {
36
+ if (name === "tokens") return [];
37
+ if (!value || typeof value !== "object") return [`${name}: content must be an object`];
38
+ const project = await loadProject(root);
39
+ const pages = await listPages(root);
40
+ if (name === "manifest") return validateManifest(value, pages).map(msg);
41
+ if (name.startsWith("pages/")) {
42
+ if (!value.layout) return [`${name}: layout is required`];
43
+ const errors = validateTree(value.layout, project).map(msg);
44
+ // the overrides extending this page must still apply: a renamed child would break them silently
45
+ for (const ov of await listOverrides(root)) {
46
+ const o = await readProjectFile(root, `overrides/${ov}`);
47
+ if (!o || (o.extends && o.extends !== name.slice(6))) continue;
48
+ const r = applyOverride(value.layout, { name: ov, ...o });
49
+ for (const e of [...r.errors, ...validateTree(r.tree, project, ov.startsWith("role-") ? { role: ov.slice(5) } : {})]) errors.push(`overrides/${ov}: ${msg(e)}`);
50
+ }
51
+ return errors;
52
+ }
53
+ if (name.startsWith("overrides/")) {
54
+ // same check as the proposals gate; a role override is validated for its role, a user override with no role
55
+ const ov = name.slice(10);
56
+ if (!value.extends) return ["override.extends is required"];
57
+ const page = await readProjectFile(root, `pages/${value.extends}`);
58
+ if (!page) return [`page "${value.extends}" not found`];
59
+ const r = applyOverride(page.layout, { name: ov, ...value });
60
+ return [...r.errors, ...validateTree(r.tree, project, ov.startsWith("role-") ? { role: ov.slice(5) } : {})].map(msg);
61
+ }
62
+ // registry or catalog: every page must still validate against the new version
63
+ const next = { ...project, ...(name === "data-catalog" ? { catalog: value } : { registry: value }) };
64
+ const errors = [];
65
+ for (const page of pages) {
66
+ const doc = await readProjectFile(root, `pages/${page}`);
67
+ for (const e of validateTree(doc?.layout, next)) errors.push(`pages/${page} ${msg(e)}`);
68
+ }
69
+ return errors;
70
+ }
71
+
72
+ async function record(root, name, before, after, { author = "studio", why = "" } = {}) {
73
+ return appendVersion(historyDir(root, name), version => ({ version, at: new Date().toISOString(), author, why, before, after }));
74
+ }
75
+
76
+ async function put(root, name, value) {
77
+ const p = pathOf(root, name);
78
+ if (value === null) await rm(p, { force: true });
79
+ else { await mkdir(dirname(p), { recursive: true }); await writeYaml(p, value); }
80
+ forgetYaml(p); clearResolveCache();
81
+ }
82
+
83
+ export async function writeProjectFile(root, name, value, { author, why, skipValidation = false } = {}) {
84
+ if (!isProjectFile(name)) return { ok: false, errors: [`not a project file: ${name}`] };
85
+ const errors = skipValidation ? [] : await validateFile(root, name, value);
86
+ if (errors.length) return { ok: false, errors };
87
+ const before = await readProjectFile(root, name);
88
+ await put(root, name, value);
89
+ return { ok: true, entry: await record(root, name, before, value, { author, why }) };
90
+ }
91
+
92
+ export async function deleteProjectFile(root, name, { author, why = "deleted" } = {}) {
93
+ if (!isProjectFile(name) || !/^(pages|overrides)\//.test(name)) return { ok: false, errors: [`${name} cannot be deleted`] };
94
+ const before = await readProjectFile(root, name);
95
+ if (before === null) return { ok: false, errors: [`${name} does not exist`] };
96
+ if (name.startsWith("pages/") && (await loadProject(root)).manifest.home === name.slice(6))
97
+ return { ok: false, errors: [`${name.slice(6)} is the home page`] };
98
+ await put(root, name, null);
99
+ return { ok: true, entry: await record(root, name, before, null, { author, why }) };
100
+ }
101
+
102
+ export async function listFileHistory(root, name) {
103
+ if (!isProjectFile(name)) return [];
104
+ const dir = historyDir(root, name);
105
+ let files = []; try { files = (await readdir(dir)).filter(f => f.endsWith(".json")); } catch { return []; }
106
+ const all = await Promise.all(files.map(async f => JSON.parse(await readFile(join(dir, f), "utf8"))));
107
+ return all.sort((a, b) => b.version - a.version);
108
+ }
109
+
110
+ // Restores the content before the latest version; the restore itself is a new version.
111
+ export async function undoFile(root, name, { author } = {}) {
112
+ const [last] = await listFileHistory(root, name);
113
+ if (!last) return { ok: false, errors: [`${name} has no history`] };
114
+ const current = await readProjectFile(root, name);
115
+ await put(root, name, last.before);
116
+ return { ok: true, value: last.before, entry: await record(root, name, current, last.before, { author, why: "undo" }) };
117
+ }
118
+
119
+ // Adds a stub implementation to data/sources.mjs, before the closing `};` of the default export.
120
+ export async function scaffoldSource(root, name) {
121
+ const p = join(root, "data", "sources.mjs");
122
+ let src = (await exists(p)) ? await readFile(p, "utf8") : "export default {\n};\n";
123
+ if (src.includes(JSON.stringify(name))) return { created: false, path: p };
124
+ const at = src.lastIndexOf("};");
125
+ if (at < 0) throw new Error("data/sources.mjs: cannot find the end of the default export");
126
+ const stub = ` ${JSON.stringify(name)}: async (params, { user, role }) => { /* TODO: query */ return null; },\n`;
127
+ src = src.slice(0, at) + stub + src.slice(at);
128
+ await mkdir(dirname(p), { recursive: true }); await writeFile(p, src);
129
+ clearResolveCache();
130
+ return { created: true, path: p };
131
+ }
132
+
133
+ export const pascalName = name => String(name).split(/[^a-zA-Z0-9]+/).filter(Boolean).map(s => s[0].toUpperCase() + s.slice(1)).join("");
134
+
135
+ // Creates components/<PascalName>.jsx reading the declared props; never overwrites.
136
+ export async function scaffoldComponent(root, name, { props = {}, container = false } = {}) {
137
+ const file = `${pascalName(name)}.jsx`, p = join(root, "components", file);
138
+ if (await exists(p)) return { created: false, path: p, impl: `./${file}` };
139
+ const keys = Object.keys(props ?? {}).filter(k => /^[A-Za-z_$][\w$]*$/.test(k));
140
+ const read = keys.length ? ` const { ${keys.join(", ")} } = props;\n` : "";
141
+ const show = keys.map(k => ` <div><strong>${k}</strong>: {String(${k} ?? "")}</div>\n`).join("");
142
+ const src = `// ${name}: generated by pagetailor studio, edit freely.
143
+ export default function ${pascalName(name)}({ props, children }) {
144
+ ${read} return (
145
+ <div style={{ padding: "var(--pt-spacing-md)", border: "1px solid var(--pt-color-border)", borderRadius: "var(--pt-radius)" }}>
146
+ ${show}${container ? " {children}\n" : " {props.data !== undefined && <pre style={{ margin: 0, fontSize: 12 }}>{JSON.stringify(props.data, null, 2)}</pre>}\n"} </div>
147
+ );
148
+ }
149
+ `;
150
+ await mkdir(dirname(p), { recursive: true }); await writeFile(p, src);
151
+ return { created: true, path: p, impl: `./${file}` };
152
+ }
153
+
154
+ export async function readProject(root) {
155
+ const [project, pages, overrides] = await Promise.all([loadProject(root), listPages(root), listOverrides(root)]);
156
+ return { manifest: project.manifest, tokens: project.tokens, catalog: project.catalog, registry: project.registry, pages, overrides };
157
+ }
package/src/history.js CHANGED
@@ -1,18 +1,14 @@
1
- import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
1
+ import { readdir, readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { stringify } from "yaml";
3
+ import { writeYaml, appendVersion, pad } from "./yaml-write.js";
4
4
 
5
5
  // Every write to an override is a new numbered version under .history/<name>/NNNN.json.
6
6
  // The current override file is rewritten from the version being committed.
7
7
  const dirOf = (root, name) => join(root, ".history", name);
8
- const pad = n => String(n).padStart(4, "0");
9
8
 
10
9
  export async function commitOverride(root, name, override, { author = "system", status = "applied" } = {}) {
11
- const dir = dirOf(root, name); await mkdir(dir, { recursive: true });
12
- const n = (await readdir(dir)).length + 1;
13
- const entry = { version: n, at: new Date().toISOString(), author, status, override };
14
- await writeFile(join(dir, `${pad(n)}.json`), JSON.stringify(entry, null, 2));
15
- if (status === "applied") await writeFile(join(root, "overrides", `${name}.yaml`), stringify(override));
10
+ const entry = await appendVersion(dirOf(root, name), version => ({ version, at: new Date().toISOString(), author, status, override }));
11
+ if (status === "applied") await writeYaml(join(root, "overrides", `${name}.yaml`), override);
16
12
  return entry;
17
13
  }
18
14