@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 +70 -6
- package/bin/cli.js +10 -3
- package/package.json +1 -1
- package/src/agent.js +3 -4
- package/src/dev.js +44 -93
- package/src/files.js +157 -0
- package/src/history.js +4 -8
- package/src/index.js +2 -1
- package/src/llm.js +71 -11
- package/src/loader.js +2 -0
- package/src/proposals.js +6 -1
- package/src/studio/App.jsx +21 -0
- package/src/studio/Preview.jsx +71 -0
- package/src/studio/Studio.jsx +40 -0
- package/src/studio/entry.jsx +5 -0
- package/src/studio/tabs/AppTab.jsx +85 -0
- package/src/studio/tabs/ComponentsTab.jsx +143 -0
- package/src/studio/tabs/DataTab.jsx +123 -0
- package/src/studio/tabs/PagesTab.jsx +222 -0
- package/src/studio/tabs/PeopleTab.jsx +211 -0
- package/src/studio/tabs/TokensTab.jsx +97 -0
- package/src/studio/ui.jsx +118 -0
- package/src/yaml-write.js +45 -0
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
|
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
|
|
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]
|
|
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
|
|
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
|
+
"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 {
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
import {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
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
|
|
12
|
-
|
|
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";
|