@aeratic/pagetailor 0.1.0 → 0.3.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 +67 -6
- package/bin/cli.js +3 -0
- package/package.json +44 -10
- package/src/agent.js +123 -0
- package/src/client.jsx +1 -0
- package/src/dev.js +61 -9
- package/src/editor.jsx +68 -0
- package/src/index.js +13 -3
- package/src/init.js +3 -1
- package/src/llm.js +14 -0
- package/src/loader.js +1 -1
- package/src/overrides.js +7 -0
- package/src/proposals.js +66 -0
- package/src/props.js +65 -0
- package/src/registry-file.js +24 -0
- package/src/resolver.js +27 -5
- package/src/validator.js +8 -1
- package/templates/components/Grid.jsx +4 -6
- package/templates/components/registry.yaml +25 -2
- package/templates/manifest.yaml +1 -0
package/README.md
CHANGED
|
@@ -58,18 +58,79 @@ Ops: `set-areas`, `set-props`, `set-data`, `replace`, `remove`, `insert`, `move`
|
|
|
58
58
|
|
|
59
59
|
## The gate
|
|
60
60
|
|
|
61
|
-
Every page, override and agent proposal goes through the same validator: the component exists with that version, the data source exists, the user's role may read it, the source's returned type is accepted by the component, params are allowed, grid areas and children match. An invalid node renders as a fallback box with the reason; the rest of the page renders.
|
|
61
|
+
Every page, override and agent proposal goes through the same validator: the component exists with that version, the data source exists, the user's role may read it, the source's returned type is accepted by the component, params are allowed, props match the component schema, grid areas and children match. An invalid node renders as a fallback box with the reason; the rest of the page renders.
|
|
62
62
|
|
|
63
|
-
##
|
|
63
|
+
## Component props
|
|
64
|
+
|
|
65
|
+
Each entry in `components/registry.yaml` can declare a `props:` schema. Types: `string`, `number`, `boolean`, `text` (multiline string), `enum[a,b,c]`, `string[]`, `number[]`; a trailing `?` makes a prop optional. Undeclared props, missing required props, wrong types and values outside an enum are errors. Entries without `props:` accept anything; `data` and `names` are injected by the resolver and never checked. Containers set `container: true`.
|
|
66
|
+
|
|
67
|
+
```yaml
|
|
68
|
+
layout/card:
|
|
69
|
+
description: Card with an optional title wrapping its children
|
|
70
|
+
container: true
|
|
71
|
+
props: { title: string?, tone: "enum[info,warn]?" }
|
|
72
|
+
versions: { "1.0.0": { impl: ./Card.jsx } }
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`buildCatalog(registry, z)` turns the registry into a json-render catalog (Zod props, `slots: ["default"]` on containers) for `defineCatalog(schema, ...)`.
|
|
76
|
+
|
|
77
|
+
## Next.js
|
|
78
|
+
|
|
79
|
+
Full example in [`examples/nextjs`](examples/nextjs). Add `transpilePackages: ["@aeratic/pagetailor"]` to `next.config.js` and run `npx pagetailor registry` to generate `pagetailor/components/index.js` (rerun it when `registry.yaml` changes).
|
|
64
80
|
|
|
65
81
|
```js
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
82
|
+
// app/api/page/[page]/route.js (server only)
|
|
83
|
+
import { join } from "node:path";
|
|
84
|
+
import { resolve } from "@aeratic/pagetailor";
|
|
85
|
+
export async function GET(req, { params }) {
|
|
86
|
+
const q = req.nextUrl.searchParams, { page } = await params;
|
|
87
|
+
return Response.json({ page, ...(await resolve(join(process.cwd(), "pagetailor"), page, { user: q.get("user"), role: q.get("role") })) });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// app/PageClient.jsx ("use client": import only from /client, never the package root)
|
|
91
|
+
import { Page } from "@aeratic/pagetailor/client";
|
|
92
|
+
import { registry } from "../pagetailor/components/index.js";
|
|
93
|
+
// fetch(`/api/page/home?user=giulia&role=marketing`) then <Page resolved={json} registry={registry} />
|
|
69
94
|
```
|
|
70
95
|
|
|
96
|
+
## Caching
|
|
97
|
+
|
|
98
|
+
`resolve` keeps resolved pages in memory per page/user/role and returns them without calling data sources again while the project files involved are unchanged. Pass `{ fresh: true }` to bypass it, or `{ maxAge: ms }` to expire entries (default 0, no time limit; `pagetailor dev` uses 2000). `clearResolveCache()` empties it.
|
|
99
|
+
|
|
100
|
+
## Proposals and approval
|
|
101
|
+
|
|
102
|
+
Changes go through `propose(root, { name, override, author, role })`, which first runs the gate: the override is applied to its page (`extends` is required) and validated for the role it targets. A failing gate returns `{ ok: false, errors }` and records nothing.
|
|
103
|
+
|
|
104
|
+
- A user's own override (`user-ann` proposed by `ann`) is applied immediately.
|
|
105
|
+
- Anything shared (a `role-*` override, or another user's override) is stored as `pending` and the override file is left untouched.
|
|
106
|
+
- Each page lists the roles allowed to approve it in `manifest.yaml`: `owners: { home: [admin] }`.
|
|
107
|
+
- `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
|
+
|
|
109
|
+
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
|
+
|
|
111
|
+
## Reorder editor
|
|
112
|
+
|
|
113
|
+
In `pagetailor dev`, set a user, then click **Edit layout** in the header: the cells of every `layout/grid` get a dashed outline and become draggable. Drop one cell on another to swap their area names (all occurrences of both) in `areas`.
|
|
114
|
+
|
|
115
|
+
Each drop sends a `set-areas` patch through `POST /api/propose` as `user-<user>` (author = that user, `why: "reordered in the editor"`), merged into that user's existing override: an earlier `set-areas` on the same grid is replaced, other patches are kept. The user's own override applies at once and the page re-fetches; gate errors are shown in red.
|
|
116
|
+
|
|
117
|
+
In your own app wrap `<Page>` in `<EditorContext.Provider value={{ editing, propose }}>` with values from `useEditor({ page, user, role, why, onApplied })` (all from `@aeratic/pagetailor/client`); the base Grid uses `EditableGrid` and renders as usual outside editing. The resolver adds a hidden `__pt: { path, name, container }` prop to every element for this mapping. This first version only swaps whole cells; resizing and moving across grids are not supported yet.
|
|
118
|
+
|
|
119
|
+
## The agent
|
|
120
|
+
|
|
121
|
+
A user asks in natural language ("put the KPI at the top"); the agent turns it into override patches that go through the same gate as everything else. It never writes files itself.
|
|
122
|
+
|
|
123
|
+
1. `buildAgentPrompt(root, { page, user, role, request })` returns `{ system, user }`: the rules, the catalog visible to that role (`describeCatalog`: components with props and accepted type, readable data sources, the op vocabulary) and the current page for that user (`describePage`: the resolved tree with child names and grid areas).
|
|
124
|
+
2. The model answers with JSON only: `{ "why", "patches": [...] }`, or `{ "refuse" }` when the catalog or the role cannot satisfy the request. `parseAgentReply` tolerates code fences and surrounding prose.
|
|
125
|
+
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
|
+
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
|
+
|
|
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).
|
|
129
|
+
|
|
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.
|
|
131
|
+
|
|
71
132
|
## Status
|
|
72
133
|
|
|
73
|
-
0.
|
|
134
|
+
0.3: engine, CLI (`init`, `validate`, `registry`, `dev`), history with rollback, proposals with owner approval, reorder editor, agent with a pluggable model adapter.
|
|
74
135
|
|
|
75
136
|
License: MIT.
|
package/bin/cli.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { init } from "../src/init.js";
|
|
3
3
|
import { validate } from "../src/validate.js";
|
|
4
4
|
import { dev } from "../src/dev.js";
|
|
5
|
+
import { writeRegistryFile } from "../src/registry-file.js";
|
|
5
6
|
|
|
6
7
|
const [cmd, ...args] = process.argv.slice(2);
|
|
7
8
|
const flag = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : d; };
|
|
@@ -9,12 +10,14 @@ const help = `pagetailor <command>
|
|
|
9
10
|
|
|
10
11
|
init [--force] create pagetailor/ with manifest, tokens, data catalog, a sample page, overrides and base components
|
|
11
12
|
validate check manifest, pages and overrides against the component registry and the data catalog
|
|
13
|
+
registry write pagetailor/components/index.js exporting every component in registry.yaml
|
|
12
14
|
dev [--port 4747] preview server with live reload; ?page=home&user=giulia&role=marketing
|
|
13
15
|
`;
|
|
14
16
|
|
|
15
17
|
try {
|
|
16
18
|
if (cmd === "init") await init(process.cwd(), args);
|
|
17
19
|
else if (cmd === "validate") process.exitCode = (await validate(process.cwd())) ? 0 : 1;
|
|
20
|
+
else if (cmd === "registry") { const r = await writeRegistryFile(process.cwd()); console.log(`wrote ${r.path} (${r.count} components)`); }
|
|
18
21
|
else if (cmd === "dev") await dev(process.cwd(), { port: Number(flag("--port", 4747)) });
|
|
19
22
|
else { console.log(help); if (cmd && cmd !== "help") process.exitCode = 1; }
|
|
20
23
|
} catch (e) { console.error("pagetailor:", e.message); process.exitCode = 1; }
|
package/package.json
CHANGED
|
@@ -1,17 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aeratic/pagetailor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
7
|
-
"bin": {
|
|
7
|
+
"bin": {
|
|
8
|
+
"pagetailor": "bin/cli.js"
|
|
9
|
+
},
|
|
8
10
|
"main": "./src/index.js",
|
|
9
|
-
"exports": {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
"
|
|
14
|
-
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./src/index.js",
|
|
13
|
+
"./client": "./src/client.jsx"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin",
|
|
17
|
+
"src",
|
|
18
|
+
"templates",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=20"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"server-driven-ui",
|
|
26
|
+
"generative-ui",
|
|
27
|
+
"declarative-ui",
|
|
28
|
+
"json-render",
|
|
29
|
+
"yaml",
|
|
30
|
+
"personalization",
|
|
31
|
+
"ai-agent"
|
|
32
|
+
],
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/aeratic/pagetailor.git"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
15
40
|
"dependencies": {
|
|
16
41
|
"@json-render/core": "0.21.1",
|
|
17
42
|
"@json-render/react": "0.21.1",
|
|
@@ -19,6 +44,15 @@
|
|
|
19
44
|
"yaml": "^2.6.0",
|
|
20
45
|
"zod": "4.3.6"
|
|
21
46
|
},
|
|
22
|
-
"peerDependencies": {
|
|
23
|
-
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"react": ">=19.2.3",
|
|
49
|
+
"react-dom": ">=19.2.3"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"react": "19.2.3",
|
|
53
|
+
"react-dom": "19.2.3"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"test": "node --no-deprecation --import ./test/jsx-loader.mjs --test test/"
|
|
57
|
+
}
|
|
24
58
|
}
|
package/src/agent.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { loadProject, loadPage, loadOverrides, loadYaml, exists } from "./loader.js";
|
|
3
|
+
import { applyOverride, mergePatch } from "./overrides.js";
|
|
4
|
+
import { splitRef } from "./validator.js";
|
|
5
|
+
import { propose } from "./proposals.js";
|
|
6
|
+
|
|
7
|
+
// The agent never writes files: it turns a request into override patches and hands them to propose().
|
|
8
|
+
const OPS = [
|
|
9
|
+
["set-areas", { op: "set-areas", target: "layout/grid", areas: "kpi kpi\nchart chart\n" }],
|
|
10
|
+
["set-props", { op: "set-props", target: "kpi", props: { label: "Sales" } }],
|
|
11
|
+
["set-data", { op: "set-data", target: "chart", data: { source: "<source>", params: { "<param>": "<value>" } } }],
|
|
12
|
+
["replace", { op: "replace", target: "chart", with: { ref: "<component@version>", props: { title: "..." }, data: { source: "<source>" } } }],
|
|
13
|
+
["remove", { op: "remove", target: "kpi" }],
|
|
14
|
+
["insert", { op: "insert", target: "layout/grid", name: "notes", node: { ref: "text/block@1.0.0", props: { content: "Hi" } }, area: "notes notes\nchart chart\n" }],
|
|
15
|
+
["move", { op: "move", target: "todo/list", to: 0 }],
|
|
16
|
+
];
|
|
17
|
+
const readable = (src, role) => !src.roles || src.roles.includes("*") || (role != null && src.roles.includes(role));
|
|
18
|
+
|
|
19
|
+
export function describeCatalog(project, { role } = {}) {
|
|
20
|
+
const out = ["COMPONENTS"];
|
|
21
|
+
for (const [name, c] of Object.entries(project.registry ?? {})) for (const v of Object.keys(c.versions ?? {})) {
|
|
22
|
+
const props = Object.entries(c.props ?? {}).map(([k, t]) => `${k}: ${t}`).join(", ");
|
|
23
|
+
out.push(`- ${name}@${v}: ${c.description ?? name}${c.container ? " [container]" : ""}${c.accepts ? `; accepts ${c.accepts}` : ""}; props { ${props} }`);
|
|
24
|
+
}
|
|
25
|
+
out.push("", `DATA SOURCES (readable by role ${role ?? "none"})`);
|
|
26
|
+
for (const [name, s] of Object.entries(project.catalog ?? {})) {
|
|
27
|
+
if (!readable(s, role)) continue;
|
|
28
|
+
const params = Object.entries(s.params ?? {}).map(([k, v]) => `${k}: ${[].concat(v).join("|")}`).join(", ");
|
|
29
|
+
out.push(`- ${name}: ${s.description ?? name}; returns ${s.returns ?? "any"}${params ? `; params { ${params} }` : ""}`);
|
|
30
|
+
}
|
|
31
|
+
out.push("", "OVERRIDE OPS (target = a child name or a component name)");
|
|
32
|
+
for (const [op, ex] of OPS) out.push(`- ${op}: ${JSON.stringify(ex)}${op === "move" ? " (array children only)" : ""}`);
|
|
33
|
+
return out.join("\n");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function currentTree(root, page, { user, role } = {}) {
|
|
37
|
+
let tree = (await loadPage(root, page)).layout;
|
|
38
|
+
for (const ov of await loadOverrides(root, page, user, role)) tree = applyOverride(tree, ov).tree;
|
|
39
|
+
return tree;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function describePage(root, page, { user, role } = {}) {
|
|
43
|
+
const lines = [];
|
|
44
|
+
const walk = (node, name, depth) => {
|
|
45
|
+
if (!node || typeof node !== "object") return;
|
|
46
|
+
const data = node.data?.source ? ` (data: ${node.data.source}${node.data.params ? " " + JSON.stringify(node.data.params) : ""})` : "";
|
|
47
|
+
const props = Object.fromEntries(Object.entries(node.props ?? {}).filter(([k]) => k !== "areas"));
|
|
48
|
+
lines.push(`${" ".repeat(depth)}${name}: ${node.ref}${data}${Object.keys(props).length ? " props " + JSON.stringify(props) : ""}`);
|
|
49
|
+
if (node.props?.areas) lines.push(`${" ".repeat(depth + 1)}areas: ${JSON.stringify(String(node.props.areas))}`);
|
|
50
|
+
const kids = node.children;
|
|
51
|
+
if (Array.isArray(kids)) kids.forEach(c => walk(c, splitRef(c?.ref)[0], depth + 1));
|
|
52
|
+
else if (kids) for (const [k, c] of Object.entries(kids)) walk(c, k, depth + 1);
|
|
53
|
+
};
|
|
54
|
+
const tree = await currentTree(root, page, { user, role });
|
|
55
|
+
walk(tree, splitRef(tree.ref)[0], 0);
|
|
56
|
+
return `PAGE ${page} (user ${user ?? "none"}, role ${role ?? "none"})\n${lines.join("\n")}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function buildAgentPrompt(root, { page, user, role, request }) {
|
|
60
|
+
const project = await loadProject(root);
|
|
61
|
+
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 }
|
|
66
|
+
|
|
67
|
+
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).
|
|
71
|
+
- 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.
|
|
73
|
+
- 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.
|
|
75
|
+
|
|
76
|
+
${describeCatalog(project, { role })}
|
|
77
|
+
|
|
78
|
+
CURRENT ${await describePage(root, page, { user, role })}`;
|
|
79
|
+
return { system, user: request };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function parseAgentReply(text) {
|
|
83
|
+
const s = String(text ?? "");
|
|
84
|
+
const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(s);
|
|
85
|
+
const candidates = [fenced?.[1], s];
|
|
86
|
+
for (const c of candidates) {
|
|
87
|
+
if (!c) continue;
|
|
88
|
+
const a = c.indexOf("{"), b = c.lastIndexOf("}");
|
|
89
|
+
if (a < 0 || b <= a) continue;
|
|
90
|
+
try {
|
|
91
|
+
const v = JSON.parse(c.slice(a, b + 1));
|
|
92
|
+
if (v && typeof v === "object" && !Array.isArray(v)) return v;
|
|
93
|
+
} catch {}
|
|
94
|
+
}
|
|
95
|
+
throw new Error(`agent reply is not a JSON object: ${s.slice(0, 200)}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function runAgent(root, { page, user, role, request, complete }) {
|
|
99
|
+
if (!user) return { ok: false, errors: ["user is required"], attempts: 0 };
|
|
100
|
+
const name = `user-${user}`;
|
|
101
|
+
const prompt = await buildAgentPrompt(root, { page, user, role, request });
|
|
102
|
+
const file = join(root, "overrides", `${name}.yaml`);
|
|
103
|
+
const existing = await exists(file) ? (await loadYaml(file)) ?? {} : {};
|
|
104
|
+
const base = existing.extends === page ? existing.patches ?? [] : [];
|
|
105
|
+
let message = prompt.user, reply, errors = [];
|
|
106
|
+
for (let attempts = 1; attempts <= 2; attempts++) {
|
|
107
|
+
const text = await complete({ system: prompt.system, user: message });
|
|
108
|
+
try { reply = parseAgentReply(text); } catch (e) { errors = [e.message]; }
|
|
109
|
+
if (reply?.refuse) return { ok: false, refuse: reply.refuse, reply, attempts };
|
|
110
|
+
if (reply && !errors.length) {
|
|
111
|
+
if (!Array.isArray(reply.patches)) errors = ["reply has no patches array"];
|
|
112
|
+
else {
|
|
113
|
+
const patches = reply.patches.reduce(mergePatch, base);
|
|
114
|
+
const proposal = await propose(root, { name, author: user, role, override: { extends: page, why: reply.why, patches } });
|
|
115
|
+
if (proposal.ok) return { ok: true, reply, proposal, attempts };
|
|
116
|
+
errors = proposal.errors;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (attempts === 2) return { ok: false, errors, reply, attempts };
|
|
120
|
+
message = `${prompt.user}\n\nThe previous patch was rejected: ${errors.join("; ")}. Fix it.`;
|
|
121
|
+
reply = undefined; errors = [];
|
|
122
|
+
}
|
|
123
|
+
}
|
package/src/client.jsx
CHANGED
package/src/dev.js
CHANGED
|
@@ -7,17 +7,24 @@ import * as esbuild from "esbuild";
|
|
|
7
7
|
import { resolve } from "./resolver.js";
|
|
8
8
|
import { loadProject, loadYaml, listPages } from "./loader.js";
|
|
9
9
|
import { commitOverride, listHistory, rollback } from "./history.js";
|
|
10
|
+
import { propose, listPending, approve, reject } from "./proposals.js";
|
|
10
11
|
import { tokensToCss } from "./tokens.js";
|
|
12
|
+
import { buildAgentPrompt, runAgent } from "./agent.js";
|
|
13
|
+
import { anthropicComplete } from "./llm.js";
|
|
11
14
|
|
|
12
15
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
13
16
|
|
|
14
17
|
export async function dev(cwd, { port = 4747 } = {}) {
|
|
15
18
|
const root = join(cwd, "pagetailor");
|
|
16
|
-
|
|
19
|
+
// Bundle one React only: the project's if installed, otherwise the one shipped with this package.
|
|
20
|
+
const reqProject = createRequire(join(cwd, "package.json")), reqSelf = createRequire(import.meta.url);
|
|
17
21
|
const alias = {};
|
|
18
|
-
for (const m of ["react", "react-dom", "react/jsx-runtime", "react-dom/client"
|
|
19
|
-
try { alias[m] =
|
|
22
|
+
for (const m of ["react", "react-dom", "react/jsx-runtime", "react-dom/client"]) {
|
|
23
|
+
try { alias[m] = reqProject.resolve(m); } catch { try { alias[m] = reqSelf.resolve(m); } catch {} }
|
|
20
24
|
}
|
|
25
|
+
// components import "@aeratic/pagetailor/client": always the same file as the entry, so EditorContext is shared
|
|
26
|
+
alias["@aeratic/pagetailor/client"] = join(here, "client.jsx");
|
|
27
|
+
const nodePaths = [join(cwd, "node_modules"), join(here, "..", "node_modules")];
|
|
21
28
|
|
|
22
29
|
// one generated entry: imports every component from registry.yaml, mounts <Page>
|
|
23
30
|
const entry = async () => {
|
|
@@ -31,8 +38,8 @@ export async function dev(cwd, { port = 4747 } = {}) {
|
|
|
31
38
|
}
|
|
32
39
|
return `${imports.join("\n")}
|
|
33
40
|
import { createRoot } from "react-dom/client";
|
|
34
|
-
import { useEffect, useState } from "react";
|
|
35
|
-
import { Page } from ${JSON.stringify(join(here, "client.jsx"))};
|
|
41
|
+
import { useCallback, useEffect, useState } from "react";
|
|
42
|
+
import { Page, EditorContext, useEditor } from ${JSON.stringify(join(here, "client.jsx"))};
|
|
36
43
|
const registry = { ${map.join(", ")} };
|
|
37
44
|
function App() {
|
|
38
45
|
const q = new URLSearchParams(location.search);
|
|
@@ -40,7 +47,21 @@ function App() {
|
|
|
40
47
|
const [user, setUser] = useState(q.get("user") || "");
|
|
41
48
|
const [role, setRole] = useState(q.get("role") || "");
|
|
42
49
|
const [state, setState] = useState(null);
|
|
43
|
-
|
|
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
|
+
};
|
|
44
65
|
useEffect(() => { const es = new EventSource("/events"); es.onmessage = () => location.reload(); return () => es.close(); }, []);
|
|
45
66
|
if (!state) return null;
|
|
46
67
|
const pages = state.manifest.pages, roles = state.manifest.roles ?? [];
|
|
@@ -50,9 +71,15 @@ function App() {
|
|
|
50
71
|
<label>page <select value={state.page} onChange={e => setPage(e.target.value)}>{pages.map(p => <option key={p}>{p}</option>)}</select></label>
|
|
51
72
|
<label>user <input value={user} placeholder="giulia" onChange={e => setUser(e.target.value)} style={{ width: 90 }} /></label>
|
|
52
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>}
|
|
53
80
|
{state.errors.length > 0 && <span style={{ color: "var(--pt-color-danger)" }}>{state.errors.length} problem(s), see console</span>}
|
|
54
81
|
</header>
|
|
55
|
-
<main style={{ padding: 16, maxWidth: 1100, margin: "0 auto" }}><Page resolved={state} registry={registry} /></main>
|
|
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>
|
|
56
83
|
</>;
|
|
57
84
|
}
|
|
58
85
|
createRoot(document.getElementById("root")).render(<App />);`;
|
|
@@ -60,7 +87,7 @@ createRoot(document.getElementById("root")).render(<App />);`;
|
|
|
60
87
|
|
|
61
88
|
const ctx = await esbuild.context({
|
|
62
89
|
stdin: { contents: await entry(), resolveDir: join(root, "components"), loader: "jsx", sourcefile: "pagetailor-entry.jsx" },
|
|
63
|
-
bundle: true, write: false, format: "esm", jsx: "automatic", alias, outfile: "bundle.js", sourcemap: "inline", logLevel: "silent",
|
|
90
|
+
bundle: true, write: false, format: "esm", jsx: "automatic", alias, nodePaths, outfile: "bundle.js", sourcemap: "inline", logLevel: "silent",
|
|
64
91
|
define: { "process.env.NODE_ENV": '"development"' },
|
|
65
92
|
});
|
|
66
93
|
let bundle = "", buildErrors = [];
|
|
@@ -94,16 +121,41 @@ createRoot(document.getElementById("root")).render(<App />);`;
|
|
|
94
121
|
if (url.pathname.startsWith("/api/page/")) {
|
|
95
122
|
let page = decodeURIComponent(url.pathname.slice(10));
|
|
96
123
|
if (page === "__home__") page = (await loadProject(root)).manifest.home ?? (await listPages(root))[0];
|
|
97
|
-
const r = await resolve(root, page, { user: url.searchParams.get("user") || undefined, role: url.searchParams.get("role") || undefined });
|
|
124
|
+
const r = await resolve(root, page, { user: url.searchParams.get("user") || undefined, role: url.searchParams.get("role") || undefined, maxAge: 2000 });
|
|
98
125
|
if (r.errors.length) console.warn(`[${page}]`, r.errors.map(e => `${e.path}: ${e.message}`).join("\n "));
|
|
99
126
|
return json(res, 200, { page, ...r });
|
|
100
127
|
}
|
|
128
|
+
if (url.pathname.startsWith("/api/override/") && req.method === "GET") {
|
|
129
|
+
const name = decodeURIComponent(url.pathname.slice(14));
|
|
130
|
+
if (!/^[\w.-]+$/.test(name)) return json(res, 400, { error: "bad override name" });
|
|
131
|
+
let ov = {}; try { ov = (await loadYaml(join(root, "overrides", `${name}.yaml`))) ?? {}; } catch {}
|
|
132
|
+
return json(res, 200, ov);
|
|
133
|
+
}
|
|
101
134
|
if (url.pathname.startsWith("/api/history/")) return json(res, 200, await listHistory(root, url.pathname.slice(13)));
|
|
102
135
|
if (url.pathname === "/api/override" && req.method === "POST") { const b = await body(req); return json(res, 200, await commitOverride(root, b.name, b.override, { author: b.author })); }
|
|
136
|
+
if (url.pathname === "/api/agent/prompt" && req.method === "GET") {
|
|
137
|
+
const q = Object.fromEntries(url.searchParams);
|
|
138
|
+
return json(res, 200, await buildAgentPrompt(root, { page: q.page || "home", user: q.user || undefined, role: q.role || undefined, request: q.request ?? "" }));
|
|
139
|
+
}
|
|
140
|
+
if (url.pathname === "/api/agent" && req.method === "POST") {
|
|
141
|
+
if (!process.env.ANTHROPIC_API_KEY) return json(res, 503, { error: "set ANTHROPIC_API_KEY to use the agent" });
|
|
142
|
+
const b = await body(req);
|
|
143
|
+
const complete = anthropicComplete({ apiKey: process.env.ANTHROPIC_API_KEY, ...(process.env.PAGETAILOR_MODEL ? { model: process.env.PAGETAILOR_MODEL } : {}) });
|
|
144
|
+
const r = await runAgent(root, { page: b.page, user: b.user, role: b.role || undefined, request: b.request, complete });
|
|
145
|
+
return json(res, r.ok ? 200 : 400, r);
|
|
146
|
+
}
|
|
147
|
+
if (url.pathname === "/api/pending") return json(res, 200, await listPending(root));
|
|
148
|
+
const flow = { "/api/propose": propose, "/api/approve": approve, "/api/reject": reject }[url.pathname];
|
|
149
|
+
if (flow && req.method === "POST") { const r = await flow(root, await body(req)); return json(res, r.ok ? 200 : 400, r); }
|
|
103
150
|
if (url.pathname === "/api/rollback" && req.method === "POST") { const b = await body(req); return json(res, 200, await rollback(root, b.name, b.version, { author: b.author })); }
|
|
104
151
|
json(res, 404, { error: "not found" });
|
|
105
152
|
} catch (e) { console.error(e); json(res, 500, { error: e.message }); }
|
|
106
153
|
});
|
|
154
|
+
server.on("error", e => {
|
|
155
|
+
if (e.code !== "EADDRINUSE") throw e;
|
|
156
|
+
console.error(`pagetailor: port ${port} is already in use (try --port ${port + 1})`);
|
|
157
|
+
process.exit(1);
|
|
158
|
+
});
|
|
107
159
|
await new Promise(r => server.listen(port, r));
|
|
108
160
|
console.log(`pagetailor dev http://localhost:${port}\n pages from ${root}\n ?page=home&user=giulia&role=marketing`);
|
|
109
161
|
return server;
|
package/src/editor.jsx
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
|
2
|
+
import { mergePatch } from "./overrides.js";
|
|
3
|
+
export { mergePatch };
|
|
4
|
+
|
|
5
|
+
// Reorder editor: drag a grid cell onto another to swap their area names, written as a set-areas patch.
|
|
6
|
+
export const EditorContext = createContext({ editing: false, propose: null });
|
|
7
|
+
|
|
8
|
+
// Swap every occurrence of area names a and b; keeps line breaks, normalises spacing inside a row.
|
|
9
|
+
export function swapAreas(areas, a, b) {
|
|
10
|
+
return String(areas).trim().split("\n")
|
|
11
|
+
.map(l => l.trim().split(/\s+/).map(n => n === a ? b : n === b ? a : n).join(" "))
|
|
12
|
+
.join("\n") + "\n";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// props: the grid's props (areas, names, __pt); cells: one rendered child per entry of props.names.
|
|
16
|
+
export function EditableGrid({ props, cells, style }) {
|
|
17
|
+
const { editing, propose } = useContext(EditorContext);
|
|
18
|
+
const [dragging, setDragging] = useState(null), [over, setOver] = useState(null);
|
|
19
|
+
const names = props.names ?? [];
|
|
20
|
+
const on = editing && typeof propose === "function";
|
|
21
|
+
const drop = to => {
|
|
22
|
+
const from = dragging; setDragging(null); setOver(null);
|
|
23
|
+
if (!from || from === to) return;
|
|
24
|
+
propose({ op: "set-areas", target: props.__pt?.name ?? "layout/grid", areas: swapAreas(props.areas, from, to) });
|
|
25
|
+
};
|
|
26
|
+
return (
|
|
27
|
+
<div style={style}>
|
|
28
|
+
{cells.map((k, i) => {
|
|
29
|
+
const name = names[i];
|
|
30
|
+
const cell = { gridArea: name, minWidth: 0 };
|
|
31
|
+
if (!on || !name) return <div key={name ?? i} style={cell}>{k}</div>;
|
|
32
|
+
return (
|
|
33
|
+
<div key={name} draggable
|
|
34
|
+
onDragStart={e => { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", name); setDragging(name); }}
|
|
35
|
+
onDragEnd={() => { setDragging(null); setOver(null); }}
|
|
36
|
+
onDragOver={e => { e.preventDefault(); if (over !== name) setOver(name); }}
|
|
37
|
+
onDragLeave={() => setOver(o => o === name ? null : o)}
|
|
38
|
+
onDrop={e => { e.preventDefault(); drop(name); }}
|
|
39
|
+
style={{ ...cell, cursor: "grab", borderRadius: 8, outline: over === name && dragging !== name ? "2px solid var(--pt-color-primary, #2E6BE6)" : "1px dashed var(--pt-color-border, #999)", outlineOffset: 2, opacity: dragging === name ? 0.5 : 1 }}>
|
|
40
|
+
{k}
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
})}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// status: "idle" | "saving" | "pending" | "error"; errors holds the gate errors.
|
|
49
|
+
export function useEditor({ page, user, role, why, onApplied } = {}) {
|
|
50
|
+
const [editing, setEditing] = useState(false);
|
|
51
|
+
const [status, setStatus] = useState("idle"), [errors, setErrors] = useState([]), [pending, setPending] = useState(null);
|
|
52
|
+
const name = `user-${user}`;
|
|
53
|
+
const propose = useCallback(async patch => {
|
|
54
|
+
if (!user) { setStatus("error"); setErrors(["set a user to save layout changes"]); return; }
|
|
55
|
+
setStatus("saving"); setErrors([]);
|
|
56
|
+
try {
|
|
57
|
+
const current = await (await fetch(`/api/override/${encodeURIComponent(name)}`)).json();
|
|
58
|
+
const override = { extends: page, why, patches: mergePatch(current.extends === page ? current.patches ?? [] : [], patch) };
|
|
59
|
+
const r = await (await fetch("/api/propose", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name, author: user, role: role || undefined, override }) })).json();
|
|
60
|
+
if (!r.ok) { setStatus("error"); setErrors(r.errors ?? [r.error ?? "propose failed"]); return; }
|
|
61
|
+
if (r.pending) { setPending(r.entry); setStatus("pending"); }
|
|
62
|
+
else { setPending(null); setStatus("idle"); }
|
|
63
|
+
onApplied?.(r);
|
|
64
|
+
} catch (e) { setStatus("error"); setErrors([e.message]); }
|
|
65
|
+
}, [page, user, role, why, name, onApplied]);
|
|
66
|
+
useEffect(() => { setStatus("idle"); setErrors([]); setPending(null); }, [page, user, role]);
|
|
67
|
+
return { editing, setEditing, propose, status, errors, pending };
|
|
68
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
|
-
export { resolve } from "./resolver.js";
|
|
1
|
+
export { resolve, clearResolveCache } from "./resolver.js";
|
|
2
2
|
export { validateTree, validateManifest, typeAccepts } from "./validator.js";
|
|
3
|
-
export {
|
|
3
|
+
export { parseType, validateProps, propsToZod, buildCatalog } from "./props.js";
|
|
4
|
+
export { applyOverride, mergePatch } from "./overrides.js";
|
|
4
5
|
export { commitOverride, listHistory, rollback } from "./history.js";
|
|
6
|
+
export { propose, listPending, approve, reject } from "./proposals.js";
|
|
5
7
|
export { loadProject, loadPage, loadOverrides, listPages, listOverrides, loadSources } from "./loader.js";
|
|
6
8
|
export { init } from "./init.js";
|
|
7
9
|
export { validate } from "./validate.js";
|
|
8
|
-
|
|
10
|
+
// dev pulls esbuild: load it lazily so bundlers (Next.js) never trace it into an app
|
|
11
|
+
export async function dev(...args) {
|
|
12
|
+
const m = await import(/* webpackIgnore: true */ /* turbopackIgnore: true */ new URL("./dev.js", import.meta.url).href);
|
|
13
|
+
return m.dev(...args);
|
|
14
|
+
}
|
|
15
|
+
export { writeRegistryFile } from "./registry-file.js";
|
|
16
|
+
export { tokensToCss } from "./tokens.js";
|
|
17
|
+
export { describeCatalog, describePage, buildAgentPrompt, parseAgentReply, runAgent } from "./agent.js";
|
|
18
|
+
export { anthropicComplete } from "./llm.js";
|
package/src/init.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { cp, mkdir, access, readFile } from "node:fs/promises";
|
|
2
2
|
import { join, dirname } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { writeRegistryFile } from "./registry-file.js";
|
|
4
5
|
|
|
5
6
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
6
7
|
|
|
@@ -10,6 +11,7 @@ export async function init(cwd, args = []) {
|
|
|
10
11
|
try { await access(target); if (!force) throw new Error("pagetailor/ already exists (use --force to overwrite)"); } catch (e) { if (e.code !== "ENOENT") throw e; }
|
|
11
12
|
await mkdir(target, { recursive: true });
|
|
12
13
|
await cp(join(here, "..", "templates"), target, { recursive: true, force });
|
|
14
|
+
await writeRegistryFile(cwd);
|
|
13
15
|
let needs = [];
|
|
14
16
|
try {
|
|
15
17
|
const pkg = JSON.parse(await readFile(join(cwd, "package.json"), "utf8"));
|
|
@@ -23,7 +25,7 @@ export async function init(cwd, args = []) {
|
|
|
23
25
|
data/sources.mjs one async function per source (demo data, replace with your APIs)
|
|
24
26
|
pages/home.yaml a sample page: card > grid with named areas
|
|
25
27
|
overrides/ per-user (user-giulia) and per-role (role-magazzino) patches
|
|
26
|
-
components/ registry.yaml + 9 base components, yours to edit
|
|
28
|
+
components/ registry.yaml + 9 base components, yours to edit; index.js generated by "pagetailor registry"
|
|
27
29
|
`);
|
|
28
30
|
if (needs.length) console.log(`install the runtime:\n npm install ${needs.join(" ")}\n`);
|
|
29
31
|
console.log(`then:\n npx pagetailor validate\n npx pagetailor dev`);
|
package/src/llm.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Minimal Messages API call over global fetch; returns a `complete({ system, user })` for runAgent.
|
|
2
|
+
export function anthropicComplete({ apiKey, model = "claude-sonnet-5-5" } = {}) {
|
|
3
|
+
if (!apiKey) throw new Error("anthropicComplete: apiKey is required");
|
|
4
|
+
return async ({ system, user }) => {
|
|
5
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
6
|
+
method: "POST",
|
|
7
|
+
headers: { "content-type": "application/json", "x-api-key": apiKey, "anthropic-version": "2023-06-01" },
|
|
8
|
+
body: JSON.stringify({ model, max_tokens: 1024, system, messages: [{ role: "user", content: user }] }),
|
|
9
|
+
});
|
|
10
|
+
const text = await res.text();
|
|
11
|
+
if (!res.ok) throw new Error(`Anthropic API ${res.status}: ${text}`);
|
|
12
|
+
return (JSON.parse(text).content ?? []).filter(b => b.type === "text").map(b => b.text).join("");
|
|
13
|
+
};
|
|
14
|
+
}
|
package/src/loader.js
CHANGED
|
@@ -49,6 +49,6 @@ export async function listOverrides(root) {
|
|
|
49
49
|
export async function loadSources(root) {
|
|
50
50
|
const p = join(root, "data", "sources.mjs");
|
|
51
51
|
if (!(await exists(p))) return {};
|
|
52
|
-
const mod = await import(`file://${p}?t=${Date.now()}`);
|
|
52
|
+
const mod = await import(/* webpackIgnore: true */ /* turbopackIgnore: true */ `file://${p}?t=${Date.now()}`);
|
|
53
53
|
return mod.default ?? mod.sources ?? mod;
|
|
54
54
|
}
|
package/src/overrides.js
CHANGED
|
@@ -41,3 +41,10 @@ export function find(node, target, parent = null, key = null) {
|
|
|
41
41
|
for (const [k, v] of Object.entries(kids)) { const r = find(v, target, kids, Array.isArray(kids) ? Number(k) : k); if (r) return r; }
|
|
42
42
|
return null;
|
|
43
43
|
}
|
|
44
|
+
|
|
45
|
+
// A set-areas patch replaces an earlier set-areas on the same target; anything else is appended.
|
|
46
|
+
export function mergePatch(patches = [], patch) {
|
|
47
|
+
const i = patch.op === "set-areas" ? patches.findIndex(p => p.op === "set-areas" && p.target === patch.target) : -1;
|
|
48
|
+
if (i < 0) return [...patches, patch];
|
|
49
|
+
const out = [...patches]; out[i] = patch; return out;
|
|
50
|
+
}
|
package/src/proposals.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { loadProject, loadPage, loadOverrides } from "./loader.js";
|
|
4
|
+
import { applyOverride } from "./overrides.js";
|
|
5
|
+
import { validateTree } from "./validator.js";
|
|
6
|
+
import { commitOverride, listHistory } from "./history.js";
|
|
7
|
+
|
|
8
|
+
// Own user overrides apply at once; role overrides go pending until a page owner approves.
|
|
9
|
+
const file = (root, name, v) => join(root, ".history", name, `${String(v).padStart(4, "0")}.json`);
|
|
10
|
+
const msg = e => typeof e === "string" ? e : `${e.path}: ${e.message}`;
|
|
11
|
+
|
|
12
|
+
async function gate(root, name, override, role) {
|
|
13
|
+
if (!override?.extends) return ["override.extends is required"];
|
|
14
|
+
const project = await loadProject(root);
|
|
15
|
+
let page; try { page = await loadPage(root, override.extends); } catch { return [`page "${override.extends}" not found`]; }
|
|
16
|
+
const target = name.startsWith("role-") ? name.slice(5) : role ?? null;
|
|
17
|
+
let tree = page.layout;
|
|
18
|
+
if (name.startsWith("user-") && role) for (const ov of await loadOverrides(root, override.extends, null, role)) tree = applyOverride(tree, ov).tree;
|
|
19
|
+
const r = applyOverride(tree, { name, ...override });
|
|
20
|
+
return [...r.errors, ...validateTree(r.tree, project, { role: target })].map(msg);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function pendingEntry(root, name, version, approverRole) {
|
|
24
|
+
const entry = (await listHistory(root, name)).find(e => e.version === version);
|
|
25
|
+
if (!entry || entry.status !== "pending") return { errors: [`${name} v${version} is not pending`] };
|
|
26
|
+
const page = entry.override?.extends;
|
|
27
|
+
const owners = [].concat((await loadProject(root)).manifest.owners?.[page] ?? []);
|
|
28
|
+
if (!owners.includes(approverRole)) return { errors: [`role ${approverRole ?? "none"} cannot approve changes to page ${page}`] };
|
|
29
|
+
return { entry };
|
|
30
|
+
}
|
|
31
|
+
const mark = (root, name, entry, extra) => writeFile(file(root, name, entry.version), JSON.stringify({ ...entry, ...extra }, null, 2));
|
|
32
|
+
|
|
33
|
+
export async function propose(root, { name, override, author, role }) {
|
|
34
|
+
const errors = await gate(root, name, override, role);
|
|
35
|
+
if (errors.length) return { ok: false, errors };
|
|
36
|
+
if (name.startsWith("user-") && author === name.slice(5)) return { ok: true, entry: await commitOverride(root, name, override, { author }) };
|
|
37
|
+
const entry = await commitOverride(root, name, override, { author, status: "pending" });
|
|
38
|
+
if (role) { entry.role = role; await mark(root, name, entry, {}); }
|
|
39
|
+
return { ok: true, entry, pending: true };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function listPending(root) {
|
|
43
|
+
let names = []; try { names = await readdir(join(root, ".history")); } catch {}
|
|
44
|
+
const all = (await Promise.all(names.map(async name => (await listHistory(root, name)).map(e => ({ ...e, name }))))).flat();
|
|
45
|
+
return all.filter(e => e.status === "pending").sort((a, b) => b.at.localeCompare(a.at) || b.version - a.version);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function approve(root, { name, version, approver, approverRole }) {
|
|
49
|
+
const { entry, errors } = await pendingEntry(root, name, version, approverRole);
|
|
50
|
+
if (errors) return { ok: false, errors };
|
|
51
|
+
const gateErrors = await gate(root, name, entry.override, entry.role);
|
|
52
|
+
if (gateErrors.length) return { ok: false, errors: gateErrors };
|
|
53
|
+
const applied = await commitOverride(root, name, entry.override, { author: approver });
|
|
54
|
+
const out = { ...applied, approvedFrom: version };
|
|
55
|
+
await writeFile(file(root, name, applied.version), JSON.stringify(out, null, 2));
|
|
56
|
+
await mark(root, name, entry, { status: "approved" });
|
|
57
|
+
return { ok: true, entry: out };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function reject(root, { name, version, approver, approverRole, reason }) {
|
|
61
|
+
const { entry, errors } = await pendingEntry(root, name, version, approverRole);
|
|
62
|
+
if (errors) return { ok: false, errors };
|
|
63
|
+
const out = { ...entry, status: "rejected", reason, rejectedBy: approver };
|
|
64
|
+
await mark(root, name, entry, out);
|
|
65
|
+
return { ok: true, entry: out };
|
|
66
|
+
}
|
package/src/props.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Component props schema: { name: "string" | "number?" | "enum[a,b]" | "string[]" | "text" ... }
|
|
2
|
+
const RESERVED = new Set(["data", "names", "__pt"]);
|
|
3
|
+
const BASES = new Set(["string", "number", "boolean", "string[]", "number[]"]);
|
|
4
|
+
|
|
5
|
+
export function parseType(str) {
|
|
6
|
+
let s = String(str).trim();
|
|
7
|
+
const optional = s.endsWith("?");
|
|
8
|
+
if (optional) s = s.slice(0, -1).trim();
|
|
9
|
+
const m = /^enum\[(.*)\]$/.exec(s);
|
|
10
|
+
if (m) return { base: "enum", optional, values: m[1].split(",").map(v => v.trim()).filter(Boolean) };
|
|
11
|
+
if (s === "text") s = "string";
|
|
12
|
+
if (!BASES.has(s)) throw new Error(`unknown prop type "${str}"`);
|
|
13
|
+
return { base: s, optional };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const check = (t, v) => {
|
|
17
|
+
switch (t.base) {
|
|
18
|
+
case "string": return typeof v === "string" ? null : "expected string";
|
|
19
|
+
case "number": return typeof v === "number" && !Number.isNaN(v) ? null : "expected number";
|
|
20
|
+
case "boolean": return typeof v === "boolean" ? null : "expected boolean";
|
|
21
|
+
case "string[]": return Array.isArray(v) && v.every(x => typeof x === "string") ? null : "expected string[]";
|
|
22
|
+
case "number[]": return Array.isArray(v) && v.every(x => typeof x === "number") ? null : "expected number[]";
|
|
23
|
+
case "enum": return t.values.includes(String(v)) ? null : `value ${JSON.stringify(v)} not in [${t.values.join(", ")}]`;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function validateProps(props, schema) {
|
|
28
|
+
if (!schema) return [];
|
|
29
|
+
const errors = [], p = props ?? {};
|
|
30
|
+
const types = {};
|
|
31
|
+
for (const [k, s] of Object.entries(schema)) {
|
|
32
|
+
try { types[k] = parseType(s); } catch { types[k] = { bad: s }; }
|
|
33
|
+
}
|
|
34
|
+
for (const k of Object.keys(p)) if (!RESERVED.has(k) && !types[k]) errors.push(`prop "${k}": unknown prop`);
|
|
35
|
+
for (const [k, t] of Object.entries(types)) {
|
|
36
|
+
if (RESERVED.has(k)) continue;
|
|
37
|
+
if (t.bad !== undefined) { errors.push(`prop "${k}": unknown type "${t.bad}" in registry`); continue; }
|
|
38
|
+
const v = p[k];
|
|
39
|
+
if (v === undefined || v === null) { if (!t.optional) errors.push(`prop "${k}": missing required`); continue; }
|
|
40
|
+
const e = check(t, v); if (e) errors.push(`prop "${k}": ${e}`);
|
|
41
|
+
}
|
|
42
|
+
return errors;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const zodOf = (t, z) => {
|
|
46
|
+
const base = { string: () => z.string(), number: () => z.number(), boolean: () => z.boolean(),
|
|
47
|
+
"string[]": () => z.array(z.string()), "number[]": () => z.array(z.number()), enum: () => z.enum(t.values) }[t.base]();
|
|
48
|
+
return t.optional ? base.nullable().optional() : base;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function propsToZod(schema, z) {
|
|
52
|
+
if (!schema) return z.looseObject({});
|
|
53
|
+
return z.object(Object.fromEntries(Object.entries(schema).filter(([k]) => !RESERVED.has(k)).map(([k, s]) => [k, zodOf(parseType(s), z)])));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function buildCatalog(registry, z) {
|
|
57
|
+
const components = {};
|
|
58
|
+
for (const [name, entry] of Object.entries(registry ?? {}))
|
|
59
|
+
for (const version of Object.keys(entry.versions ?? {})) {
|
|
60
|
+
const c = { props: propsToZod(entry.props, z), description: entry.description ?? name };
|
|
61
|
+
if (entry.container) c.slots = ["default"];
|
|
62
|
+
components[`${name}@${version}`] = c;
|
|
63
|
+
}
|
|
64
|
+
return { components, actions: {} };
|
|
65
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { loadYaml } from "./loader.js";
|
|
4
|
+
|
|
5
|
+
// Writes pagetailor/components/index.js: imports every impl in registry.yaml, exports { "name@version": Component }.
|
|
6
|
+
export async function writeRegistryFile(cwd) {
|
|
7
|
+
const dir = join(cwd, "pagetailor", "components");
|
|
8
|
+
const reg = await loadYaml(join(dir, "registry.yaml"));
|
|
9
|
+
const entries = [];
|
|
10
|
+
for (const [name, def] of Object.entries(reg ?? {})) for (const [ver, v] of Object.entries(def.versions ?? {}))
|
|
11
|
+
if (v.impl) entries.push([`${name}@${ver}`, v.impl.startsWith(".") ? v.impl : `./${v.impl}`]);
|
|
12
|
+
entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
13
|
+
const src = `// generated by \`pagetailor registry\`, do not edit
|
|
14
|
+
${entries.map(([, impl], i) => `import C${i} from ${JSON.stringify(impl)};`).join("\n")}
|
|
15
|
+
|
|
16
|
+
export const registry = {
|
|
17
|
+
${entries.map(([key], i) => ` ${JSON.stringify(key)}: C${i},`).join("\n")}
|
|
18
|
+
};
|
|
19
|
+
export default registry;
|
|
20
|
+
`;
|
|
21
|
+
const out = join(dir, "index.js");
|
|
22
|
+
await writeFile(out, src);
|
|
23
|
+
return { path: out, count: entries.length };
|
|
24
|
+
}
|
package/src/resolver.js
CHANGED
|
@@ -1,9 +1,29 @@
|
|
|
1
1
|
import { loadProject, loadPage, loadOverrides, loadSources } from "./loader.js";
|
|
2
2
|
import { validateTree } from "./validator.js";
|
|
3
3
|
import { applyOverride } from "./overrides.js";
|
|
4
|
+
import { stat } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
// in-memory cache: data sources are not called again while the project files are unchanged
|
|
8
|
+
const cache = new Map();
|
|
9
|
+
export function clearResolveCache() { cache.clear(); }
|
|
10
|
+
const mtime = p => stat(p).then(s => s.mtimeMs, () => 0);
|
|
11
|
+
const fingerprint = (root, page, user, role) => Promise.all([
|
|
12
|
+
"manifest.yaml", "data-catalog.yaml", join("components", "registry.yaml"), join("pages", `${page}.yaml`),
|
|
13
|
+
join("overrides", `role-${role}.yaml`), join("overrides", `user-${user}.yaml`), join("data", "sources.mjs"),
|
|
14
|
+
].map(f => mtime(join(root, f)))).then(a => Math.max(...a));
|
|
4
15
|
|
|
5
16
|
// YAML page + overrides + data -> json-render flat spec { root, elements, state }
|
|
6
|
-
export async function resolve(root, page, { user, role, sources } = {}) {
|
|
17
|
+
export async function resolve(root, page, { user, role, sources, fresh = false, maxAge = 0 } = {}) {
|
|
18
|
+
const key = `${root}|${page}|${user}|${role}`, fp = await fingerprint(root, page, user, role);
|
|
19
|
+
const hit = cache.get(key);
|
|
20
|
+
if (!fresh && hit && hit.fp === fp && (!maxAge || Date.now() - hit.at < maxAge)) return hit.result;
|
|
21
|
+
const result = await build(root, page, { user, role, sources });
|
|
22
|
+
cache.set(key, { fp, at: Date.now(), result });
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function build(root, page, { user, role, sources }) {
|
|
7
27
|
const project = await loadProject(root);
|
|
8
28
|
const fetchers = sources ?? await loadSources(root);
|
|
9
29
|
let tree = (await loadPage(root, page)).layout;
|
|
@@ -11,16 +31,18 @@ export async function resolve(root, page, { user, role, sources } = {}) {
|
|
|
11
31
|
for (const ov of await loadOverrides(root, page, user, role)) {
|
|
12
32
|
const r = applyOverride(tree, ov); tree = r.tree; errors.push(...r.errors);
|
|
13
33
|
}
|
|
14
|
-
errors.push(...validateTree(tree, project, { role }));
|
|
34
|
+
errors.push(...validateTree(tree, project, { role: role ?? null }));
|
|
15
35
|
const byPath = new Map(); for (const e of errors) byPath.set(e.path, [...(byPath.get(e.path) ?? []), e.message]);
|
|
16
36
|
|
|
17
37
|
const elements = {}, state = { data: {}, user: { id: user ?? null, role: role ?? null } };
|
|
18
38
|
let n = 0;
|
|
19
|
-
const
|
|
39
|
+
const label = n => n?.ref ? n.ref.split("@")[0] : null;
|
|
40
|
+
// __pt maps an element back to its YAML target: own name (child name or component name) and the parent's.
|
|
41
|
+
const walk = async (node, path, name = label(node), container = null) => {
|
|
20
42
|
const id = `e${n++}`;
|
|
21
43
|
const bad = byPath.get(path);
|
|
22
44
|
if (bad) { elements[id] = { type: "pt/fallback", props: { reason: bad.join("; ") } }; return id; }
|
|
23
|
-
const props = { ...(node.props ?? {}) };
|
|
45
|
+
const props = { ...(node.props ?? {}), __pt: { path, name, container } };
|
|
24
46
|
if (node.data?.source) {
|
|
25
47
|
const key = `${node.data.source}${node.data.params ? ":" + JSON.stringify(node.data.params) : ""}`.replace(/[^\w]/g, "_");
|
|
26
48
|
if (!(key in state.data)) {
|
|
@@ -35,7 +57,7 @@ export async function resolve(root, page, { user, role, sources } = {}) {
|
|
|
35
57
|
const entries = named ? Object.entries(node.children) : node.children.map((c, i) => [i, c]);
|
|
36
58
|
if (named) props.names = entries.map(([k]) => k);
|
|
37
59
|
el.children = [];
|
|
38
|
-
for (const [i, [, c]] of entries.entries()) el.children.push(await walk(c, `${path}/${i}
|
|
60
|
+
for (const [i, [k, c]] of entries.entries()) el.children.push(await walk(c, `${path}/${i}`, named ? k : label(c), name));
|
|
39
61
|
}
|
|
40
62
|
elements[id] = el; return id;
|
|
41
63
|
};
|
package/src/validator.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { validateProps } from "./props.js";
|
|
2
|
+
|
|
1
3
|
// One validator used everywhere: CLI, resolver at runtime, agent gate.
|
|
2
4
|
// A node is { ref: "name@version", props?, data?: { source, params? }, children?: [] | {name: node} }
|
|
3
5
|
|
|
@@ -20,11 +22,12 @@ export function validateTree(tree, project, { role } = {}) {
|
|
|
20
22
|
const comp = project.registry[name];
|
|
21
23
|
if (!comp) errors.push({ path, message: `unknown component "${name}"` });
|
|
22
24
|
else if (version && !comp.versions?.[version]) errors.push({ path, message: `"${name}" has no version ${version}` });
|
|
25
|
+
if (comp) for (const message of validateProps(node.props, comp.props)) errors.push({ path, message });
|
|
23
26
|
if (node.data?.source) {
|
|
24
27
|
const src = project.catalog[node.data.source];
|
|
25
28
|
if (!src) errors.push({ path, message: `unknown data source "${node.data.source}"` });
|
|
26
29
|
else {
|
|
27
|
-
if (src.roles && !src.roles.includes("*") && !src.roles.includes(role))
|
|
30
|
+
if (role !== undefined && src.roles && !src.roles.includes("*") && !src.roles.includes(role))
|
|
28
31
|
errors.push({ path, message: `role "${role ?? "none"}" cannot read "${node.data.source}"` });
|
|
29
32
|
if (comp?.accepts && src.returns && !typeAccepts(comp.accepts, src.returns))
|
|
30
33
|
errors.push({ path, message: `"${name}" accepts ${comp.accepts} but "${node.data.source}" returns ${src.returns}` });
|
|
@@ -51,6 +54,10 @@ export function validateManifest(manifest, pages) {
|
|
|
51
54
|
const errors = [];
|
|
52
55
|
for (const p of manifest.pages ?? []) if (!pages.includes(p)) errors.push({ path: "manifest", message: `page "${p}" listed but pages/${p}.yaml is missing` });
|
|
53
56
|
for (const p of pages) if (!(manifest.pages ?? []).includes(p)) errors.push({ path: "manifest", message: `pages/${p}.yaml exists but is not listed in manifest.pages` });
|
|
57
|
+
for (const [page, roles] of Object.entries(manifest.owners ?? {})) {
|
|
58
|
+
if (!(manifest.pages ?? []).includes(page)) errors.push({ path: "manifest", message: `owners: page "${page}" is not in pages` });
|
|
59
|
+
for (const r of [].concat(roles ?? [])) if (!(manifest.roles ?? []).includes(r)) errors.push({ path: "manifest", message: `owners: role "${r}" of page "${page}" is not in roles` });
|
|
60
|
+
}
|
|
54
61
|
if (manifest.home && !pages.includes(manifest.home)) errors.push({ path: "manifest", message: `home "${manifest.home}" is not a page` });
|
|
55
62
|
return errors;
|
|
56
63
|
}
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import { Children } from "react";
|
|
2
|
+
import { EditableGrid } from "@aeratic/pagetailor/client";
|
|
2
3
|
|
|
3
4
|
// props.areas is a multiline string like CSS grid-template-areas; props.names lists the children in order.
|
|
5
|
+
// Inside an EditorContext with editing on, cells can be dragged onto each other to swap areas.
|
|
4
6
|
export default function Grid({ props, children }) {
|
|
5
7
|
const rows = String(props.areas).trim().split("\n").map(l => l.trim().split(/\s+/));
|
|
6
8
|
const cols = Math.max(...rows.map(r => r.length));
|
|
7
|
-
const
|
|
8
|
-
return (
|
|
9
|
-
<div style={{ display: "grid", gap: "var(--pt-spacing-md)", gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`, gridTemplateAreas: rows.map(r => `"${r.join(" ")}"`).join(" ") }}>
|
|
10
|
-
{kids.map((k, i) => <div key={props.names?.[i] ?? i} style={{ gridArea: props.names?.[i], minWidth: 0 }}>{k}</div>)}
|
|
11
|
-
</div>
|
|
12
|
-
);
|
|
9
|
+
const style = { display: "grid", gap: "var(--pt-spacing-md)", gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`, gridTemplateAreas: rows.map(r => `"${r.join(" ")}"`).join(" ") };
|
|
10
|
+
return <EditableGrid props={props} cells={Children.toArray(children)} style={style} />;
|
|
13
11
|
}
|
|
@@ -1,24 +1,47 @@
|
|
|
1
|
-
# name -> accepted data type (optional) and versions -> implementation file.
|
|
2
|
-
# Containers accept no data
|
|
1
|
+
# name -> accepted data type (optional), props schema (optional) and versions -> implementation file.
|
|
2
|
+
# Containers accept no data and have `container: true`. Add a new version when props change in an incompatible way.
|
|
3
|
+
# Prop types: string, number, boolean, text, enum[a,b], string[], number[]; a trailing ? makes it optional.
|
|
3
4
|
layout/stack:
|
|
5
|
+
description: Vertical stack of children
|
|
6
|
+
container: true
|
|
7
|
+
props: { gap: string? }
|
|
4
8
|
versions: { "1.0.0": { impl: ./Stack.jsx } }
|
|
5
9
|
layout/row:
|
|
10
|
+
description: Horizontal row of children
|
|
11
|
+
container: true
|
|
12
|
+
props: { gap: string? }
|
|
6
13
|
versions: { "1.0.0": { impl: ./Row.jsx } }
|
|
7
14
|
layout/card:
|
|
15
|
+
description: Card with an optional title wrapping its children
|
|
16
|
+
container: true
|
|
17
|
+
props: { title: string? }
|
|
8
18
|
versions: { "1.0.0": { impl: ./Card.jsx } }
|
|
9
19
|
layout/grid:
|
|
20
|
+
description: CSS grid with named areas, one named child per area
|
|
21
|
+
container: true
|
|
22
|
+
props: { areas: text }
|
|
10
23
|
versions: { "1.0.0": { impl: ./Grid.jsx } }
|
|
11
24
|
text/block:
|
|
25
|
+
description: Paragraph of text
|
|
26
|
+
props: { content: string, muted: boolean? }
|
|
12
27
|
versions: { "1.0.0": { impl: ./Text.jsx } }
|
|
13
28
|
kpi/number:
|
|
29
|
+
description: Single money figure with a label
|
|
14
30
|
accepts: money
|
|
31
|
+
props: { label: string? }
|
|
15
32
|
versions: { "1.0.0": { impl: ./Kpi.jsx } }
|
|
16
33
|
charts/line:
|
|
34
|
+
description: Line chart of a money timeseries
|
|
17
35
|
accepts: timeseries<money>
|
|
36
|
+
props: { title: string? }
|
|
18
37
|
versions: { "1.0.0": { impl: ./LineChart.jsx } }
|
|
19
38
|
tables/simple:
|
|
39
|
+
description: Simple table of rows
|
|
20
40
|
accepts: table<any>
|
|
41
|
+
props: { title: string? }
|
|
21
42
|
versions: { "1.0.0": { impl: ./Table.jsx } }
|
|
22
43
|
todo/list:
|
|
44
|
+
description: List of tasks
|
|
23
45
|
accepts: list<task>
|
|
46
|
+
props: { title: string? }
|
|
24
47
|
versions: { "1.0.0": { impl: ./TodoList.jsx } }
|