@aeratic/pagetailor 0.4.0 → 0.6.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,58 @@
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 with any provider: Anthropic, OpenAI, Gemini, Groq, Mistral, DeepSeek, xAI, OpenRouter, Ollama…
22
+ ANTHROPIC_API_KEY=sk-... npx pagetailor dev
23
+ OPENAI_API_KEY=sk-... npx pagetailor dev
24
+ PAGETAILOR_PROVIDER=ollama PAGETAILOR_MODEL=llama3.3 npx pagetailor dev
25
+
26
+ # 5. After editing components/registry.yaml, regenerate the component index
27
+ npx pagetailor registry
11
28
  ```
12
29
 
30
+ Three things to try in the first ten minutes:
31
+
32
+ - **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.
33
+ - **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.
34
+ - **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.
35
+
36
+ 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.
37
+
38
+ ## CLI
39
+
40
+ | Command | What it does |
41
+ | --- | --- |
42
+ | `pagetailor init [--force]` | create `pagetailor/` with manifest, tokens, data catalog, a sample page, overrides and base components |
43
+ | `pagetailor validate` | check manifest, pages and overrides against the component registry and the data catalog; exit 1 on problems |
44
+ | `pagetailor registry` | write `pagetailor/components/index.js` exporting every component in `registry.yaml` |
45
+ | `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 |
46
+ | `pagetailor help`, `pagetailor --version` | this text, the version |
47
+
48
+ ## Contents
49
+
50
+ - [What `init` creates](#what-init-creates)
51
+ - [A page](#a-page), [An override](#an-override), [The gate](#the-gate), [Component props](#component-props)
52
+ - [Next.js](#nextjs), [Caching](#caching)
53
+ - [Proposals and approval](#proposals-and-approval), [Reorder editor](#reorder-editor)
54
+ - [The agent](#the-agent), [Pages as answers](#pages-as-answers)
55
+ - [Studio](#studio), [Status](#status)
56
+
13
57
  ## What `init` creates
14
58
 
15
59
  ```
@@ -106,6 +150,8 @@ Changes go through `propose(root, { name, override, author, role })`, which firs
106
150
  - Each page lists the roles allowed to approve it in `manifest.yaml`: `owners: { home: [admin] }`.
107
151
  - `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
152
 
153
+ 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`.
154
+
109
155
  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
156
 
111
157
  ## Reorder editor
@@ -125,9 +171,17 @@ A user asks in natural language ("put the KPI at the top"); the agent turns it i
125
171
  3. `runAgent(root, { page, user, role, request, complete })` appends the patches to the user's existing override for that page (same merge rule as the editor) and calls `propose` as `user-<user>`: applied at once for the own user, pending otherwise.
126
172
  4. If the gate rejects, the gate errors are sent back once ("The previous patch was rejected: ... Fix it.") and the result is proposed again. The result reports `attempts`.
127
173
 
128
- `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).
174
+ `complete` is any `async ({ system, user }) => text`, so any model or SDK works; pagetailor depends on none. Built-in adapters over plain `fetch`: `anthropicComplete`, `openaiComplete` (OpenAI and every provider speaking its chat-completions format), `googleComplete` (Gemini). `createComplete({ provider, model, apiKey, baseUrl })` picks one by name: `anthropic`, `openai`, `google`, `groq`, `mistral`, `deepseek`, `xai`, `openrouter`, `ollama` (local, no key) or `compatible` (any URL). `completeFromEnv()` reads the same from the environment and is what `pagetailor dev` uses:
175
+
176
+ | Variable | Meaning |
177
+ | --- | --- |
178
+ | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `GROQ_API_KEY`, `MISTRAL_API_KEY`, `DEEPSEEK_API_KEY`, `XAI_API_KEY`, `OPENROUTER_API_KEY` | the key of the provider to use; with one key set nothing else is needed |
179
+ | `PAGETAILOR_PROVIDER` | force a provider when several keys are set, or choose `ollama` / `compatible` |
180
+ | `PAGETAILOR_MODEL` | the model; defaults exist for anthropic, openai, google, mistral, deepseek, required for the others |
181
+ | `PAGETAILOR_LLM_URL` | base URL for `compatible`, or to override a provider's URL (a proxy, an Ollama on another host) |
182
+ | `PAGETAILOR_LLM_KEY` | the key for `compatible` |
129
183
 
130
- 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.
184
+ In `pagetailor dev`, set one of the variables above and use the "Change this page…" box in the header: `POST /api/agent` with `{ page, user, role, request }` (503 when no provider is configured). `GET /api/agent/prompt?page=&user=&role=&request=` returns the built prompt for debugging.
131
185
 
132
186
  ## Pages as answers
133
187
 
@@ -138,7 +192,17 @@ A user asks a question ("how did sales go this week?") and the agent composes a
138
192
  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`).
139
193
  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.
140
194
 
141
- 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 }`.
195
+ 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 when no provider is configured), `POST /api/ask/keep` and `POST /api/ask/pin` with `{ user, role, title, tree, why }`.
196
+
197
+ ## Studio
198
+
199
+ `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.
200
+
201
+ 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.
202
+
203
+ 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.
204
+
205
+ 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`.
142
206
 
143
207
  ## Status
144
208
 
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.4.0",
3
+ "version": "0.6.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,6 +1,5 @@
1
1
  import { join } from "node:path";
2
- import { writeFile } from "node:fs/promises";
3
- import { stringify } from "yaml";
2
+ import { writeYaml } from "./yaml-write.js";
4
3
  import { loadProject, loadPage, loadOverrides, loadYaml, exists, listPages } from "./loader.js";
5
4
  import { applyOverride, mergePatch } from "./overrides.js";
6
5
  import { splitRef, validateTree } from "./validator.js";
@@ -185,10 +184,10 @@ export async function keepAnswer(root, { user, role, title, tree, why }) {
185
184
  const taken = new Set([...(await listPages(root)), ...(project.manifest.pages ?? [])]);
186
185
  const base = slugify(title); let slug = base;
187
186
  for (let n = 2; taken.has(slug); n++) slug = `${base}-${n}`;
188
- await writeFile(join(root, "pages", `${slug}.yaml`), stringify({ page: slug, title, owner: user, ...(why ? { why } : {}), layout: tree }));
187
+ await writeYaml(join(root, "pages", `${slug}.yaml`), { page: slug, title, owner: user, ...(why ? { why } : {}), layout: tree });
189
188
  const mfile = join(root, "manifest.yaml");
190
189
  const manifest = (await loadYaml(mfile)) ?? {};
191
- await writeFile(mfile, stringify({ ...manifest, pages: [...(manifest.pages ?? []), slug] }));
190
+ await writeYaml(mfile, { ...manifest, pages: [...(manifest.pages ?? []), slug] });
192
191
  return { ok: true, page: slug };
193
192
  }
194
193
 
package/src/dev.js CHANGED
@@ -10,11 +10,13 @@ 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
12
  import { buildAgentPrompt, runAgent, runAsk, keepAnswer, pinAnswer } from "./agent.js";
13
- import { anthropicComplete } from "./llm.js";
13
+ import { completeFromEnv, LLM_HELP } 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,94 +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
- const [question, setQuestion] = useState(""), [answer, setAnswer] = useState(null), [answerStatus, setAnswerStatus] = useState(null);
66
- const post = (path, b) => fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(b) }).then(r => r.json());
67
- const sendQuestion = async e => {
68
- e.preventDefault(); if (!question.trim()) return;
69
- setAnswer(null); setAnswerStatus({ text: "composing…" });
70
- try {
71
- const r = await post("/api/ask", { user, role: role || undefined, request: question });
72
- if (r.ok) { setAnswer(r); setAnswerStatus(null); }
73
- else setAnswerStatus({ error: true, text: r.refuse ?? (r.errors ?? [r.error ?? "ask failed"]).join("; ") });
74
- } catch (err) { setAnswerStatus({ error: true, text: err.message }); }
75
- };
76
- const answerBody = () => ({ user, role: role || undefined, title: answer.title, tree: answer.tree, why: answer.why });
77
- const keep = async () => {
78
- const r = await post("/api/ask/keep", answerBody());
79
- if (r.ok) { setAnswer(null); setAnswerStatus(null); setPage(r.page); } else setAnswerStatus({ error: true, text: (r.errors ?? [r.error]).join("; ") });
80
- };
81
- const pin = async () => {
82
- const r = await post("/api/ask/pin", answerBody());
83
- setAnswerStatus(r.ok ? { text: r.pending ? "sent for approval" : "applied to home" } : { error: true, text: (r.errors ?? [r.error]).join("; ") });
84
- };
85
- useEffect(() => { const es = new EventSource("/events"); es.onmessage = () => location.reload(); return () => es.close(); }, []);
86
- if (!state) return null;
87
- const pages = state.manifest.pages, roles = state.manifest.roles ?? [];
88
- return <>
89
- <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 }}>
90
- <strong>{state.manifest.app}</strong>
91
- <label>page <select value={state.page} onChange={e => setPage(e.target.value)}>{pages.map(p => <option key={p}>{p}</option>)}</select></label>
92
- <label>user <input value={user} placeholder="giulia" onChange={e => setUser(e.target.value)} style={{ width: 90 }} /></label>
93
- <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>
94
- <button onClick={() => editor.setEditing(!editor.editing)} style={{ fontWeight: editor.editing ? 700 : 400 }}>{editor.editing ? "Done editing" : "Edit layout"}</button>
95
- {editor.status === "saving" && <span>saving…</span>}
96
- {editor.status === "pending" && <span>sent for approval</span>}
97
- {editor.status === "error" && <span style={{ color: "var(--pt-color-danger)" }}>{editor.errors.join("; ")}</span>}
98
- <form onSubmit={sendAsk} style={{ display: "flex", gap: 6 }}><input value={ask} placeholder="Change this page…" onChange={e => setAsk(e.target.value)} style={{ width: 220 }} /><button type="submit">Ask</button></form>
99
- <form onSubmit={sendQuestion} style={{ display: "flex", gap: 6 }}><input value={question} placeholder="Ask…" onChange={e => setQuestion(e.target.value)} style={{ width: 220 }} /><button type="submit">Ask</button></form>
100
- {agent && <span style={{ color: agent.error ? "var(--pt-color-danger)" : undefined }}>{agent.text}</span>}
101
- {state.errors.length > 0 && <span style={{ color: "var(--pt-color-danger)" }}>{state.errors.length} problem(s), see console</span>}
102
- </header>
103
- {(answer || answerStatus) && <div style={{ display: "flex", gap: 12, alignItems: "center", padding: "8px 16px", borderBottom: "1px solid var(--pt-color-border)", fontSize: 13 }}>
104
- {answer && <><strong>{answer.title}</strong><span>{answer.why}</span><button onClick={keep}>Keep as page</button><button onClick={pin}>Pin to home</button><button onClick={() => { setAnswer(null); setAnswerStatus(null); }}>Back</button></>}
105
- {answerStatus && <span style={{ color: answerStatus.error ? "var(--pt-color-danger)" : undefined }}>{answerStatus.text}</span>}
106
- </div>}
107
- <main style={{ padding: 16, maxWidth: 1100, margin: "0 auto" }}>{answer
108
- ? <Page resolved={{ ...state, spec: answer.resolved.spec, errors: answer.resolved.errors }} registry={registry} />
109
- : <EditorContext.Provider value={{ editing: editor.editing, propose: editor.propose }}><Page resolved={state} registry={registry} /></EditorContext.Provider>}</main>
110
- </>;
111
- }
112
- 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
+ },
113
49
  };
114
50
 
115
51
  const ctx = await esbuild.context({
116
- stdin: { contents: await entry(), resolveDir: join(root, "components"), loader: "jsx", sourcefile: "pagetailor-entry.jsx" },
52
+ entryPoints: [join(here, "studio", "entry.jsx")], plugins: [registryPlugin],
117
53
  bundle: true, write: false, format: "esm", jsx: "automatic", alias, nodePaths, outfile: "bundle.js", sourcemap: "inline", logLevel: "silent",
118
54
  define: { "process.env.NODE_ENV": '"development"' },
119
55
  });
@@ -165,21 +101,36 @@ createRoot(document.getElementById("root")).render(<App />);`;
165
101
  return json(res, 200, await buildAgentPrompt(root, { page: q.page || "home", user: q.user || undefined, role: q.role || undefined, request: q.request ?? "" }));
166
102
  }
167
103
  if (url.pathname === "/api/agent" && req.method === "POST") {
168
- if (!process.env.ANTHROPIC_API_KEY) return json(res, 503, { error: "set ANTHROPIC_API_KEY to use the agent" });
104
+ const complete = completeFromEnv();
105
+ if (!complete) return json(res, 503, { error: LLM_HELP });
169
106
  const b = await body(req);
170
- const complete = anthropicComplete({ apiKey: process.env.ANTHROPIC_API_KEY, ...(process.env.PAGETAILOR_MODEL ? { model: process.env.PAGETAILOR_MODEL } : {}) });
171
107
  const r = await runAgent(root, { page: b.page, user: b.user, role: b.role || undefined, request: b.request, complete });
172
108
  return json(res, r.ok ? 200 : 400, r);
173
109
  }
174
110
  if (url.pathname === "/api/ask" && req.method === "POST") {
175
- if (!process.env.ANTHROPIC_API_KEY) return json(res, 503, { error: "set ANTHROPIC_API_KEY to use the agent" });
111
+ const complete = completeFromEnv();
112
+ if (!complete) return json(res, 503, { error: LLM_HELP });
176
113
  const b = await body(req);
177
- const complete = anthropicComplete({ apiKey: process.env.ANTHROPIC_API_KEY, ...(process.env.PAGETAILOR_MODEL ? { model: process.env.PAGETAILOR_MODEL } : {}) });
178
114
  const r = await runAsk(root, { user: b.user || undefined, role: b.role || undefined, request: b.request, complete });
179
115
  return json(res, r.ok ? 200 : 400, r);
180
116
  }
181
117
  const answer = { "/api/ask/keep": keepAnswer, "/api/ask/pin": pinAnswer }[url.pathname];
182
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 })); }
183
134
  if (url.pathname === "/api/pending") return json(res, 200, await listPending(root));
184
135
  const flow = { "/api/propose": propose, "/api/approve": approve, "/api/reject": reject }[url.pathname];
185
136
  if (flow && req.method === "POST") { const r = await flow(root, await body(req)); return json(res, r.ok ? 200 : 400, r); }
@@ -192,7 +143,7 @@ createRoot(document.getElementById("root")).render(<App />);`;
192
143
  console.error(`pagetailor: port ${port} is already in use (try --port ${port + 1})`);
193
144
  process.exit(1);
194
145
  });
195
- await new Promise(r => server.listen(port, r));
196
- 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`);
197
148
  return server;
198
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
 
package/src/index.js CHANGED
@@ -15,4 +15,5 @@ export async function dev(...args) {
15
15
  export { writeRegistryFile } from "./registry-file.js";
16
16
  export { tokensToCss } from "./tokens.js";
17
17
  export { describeCatalog, describePage, buildAgentPrompt, parseAgentReply, runAgent, buildAskPrompt, runAsk, keepAnswer, pinAnswer } from "./agent.js";
18
- export { anthropicComplete } from "./llm.js";
18
+ export { anthropicComplete, openaiComplete, googleComplete, createComplete, completeFromEnv, PROVIDERS } from "./llm.js";
19
+ export { readProjectFile, writeProjectFile, deleteProjectFile, listFileHistory, undoFile, scaffoldSource, scaffoldComponent, readProject } from "./files.js";