@aeratic/pagetailor 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,75 @@
1
- # Temporary Holding Version
1
+ # @aeratic/pagetailor
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+
5
+ ```
6
+ npm init -y
7
+ npm install @aeratic/pagetailor react react-dom
8
+ npx pagetailor init
9
+ npx pagetailor validate
10
+ npx pagetailor dev # http://localhost:4747/?user=giulia&role=marketing
11
+ ```
12
+
13
+ ## What `init` creates
14
+
15
+ ```
16
+ pagetailor/
17
+ manifest.yaml pages, home, navigation, roles
18
+ tokens.yaml design tokens -> CSS variables --pt-*
19
+ data-catalog.yaml data sources: params, returned type, allowed roles
20
+ data/sources.mjs one async function per source (demo data)
21
+ pages/home.yaml a page: tree of components, data bindings, grid with named areas
22
+ overrides/ user-giulia.yaml, role-magazzino.yaml: patches over a page, with a `why`
23
+ components/ registry.yaml + base components (yours to edit)
24
+ ```
25
+
26
+ ## A page
27
+
28
+ ```yaml
29
+ page: home
30
+ layout:
31
+ ref: layout/card@1.0.0
32
+ props: { title: "Dashboard" }
33
+ children:
34
+ - ref: layout/grid@1.0.0
35
+ props:
36
+ areas: |
37
+ chart chart
38
+ table kpi
39
+ table todo
40
+ children:
41
+ chart: { ref: charts/line@1.0.0, data: { source: analytics.revenue, params: { period: 30d } } }
42
+ table: { ref: tables/simple@1.0.0, data: { source: sales.by_region } }
43
+ kpi: { ref: kpi/number@1.0.0, data: { source: sales.total } }
44
+ todo: { ref: todo/list@1.0.0, data: { source: tasks.mine } }
45
+ ```
46
+
47
+ ## An override
48
+
49
+ ```yaml
50
+ # overrides/user-giulia.yaml
51
+ extends: home
52
+ why: "Reports every Monday, wants the KPI on top"
53
+ patches:
54
+ - { op: set-areas, target: layout/grid, areas: "kpi kpi\nchart chart\ntable todo\n" }
55
+ ```
56
+
57
+ Ops: `set-areas`, `set-props`, `set-data`, `replace`, `remove`, `insert`, `move`. Targets are component names or child names, never element ids. Role overrides (`role-<name>.yaml`) apply first, user overrides (`user-<name>.yaml`) second.
58
+
59
+ ## The gate
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.
62
+
63
+ ## In your own server
64
+
65
+ ```js
66
+ import { resolve, commitOverride, listHistory, rollback } from "@aeratic/pagetailor";
67
+ const { spec, errors } = await resolve("./pagetailor", "home", { user: "giulia", role: "marketing" });
68
+ // send `spec` to the browser and render it with <Page resolved={...} registry={...}/> from "@aeratic/pagetailor/client"
69
+ ```
70
+
71
+ ## Status
72
+
73
+ 0.1: engine, CLI (`init`, `validate`, `dev`), history with rollback. Next: proposal/approval flow for shared pages and an agent endpoint.
74
+
75
+ License: MIT.
package/bin/cli.js ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ import { init } from "../src/init.js";
3
+ import { validate } from "../src/validate.js";
4
+ import { dev } from "../src/dev.js";
5
+
6
+ const [cmd, ...args] = process.argv.slice(2);
7
+ const flag = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : d; };
8
+ const help = `pagetailor <command>
9
+
10
+ init [--force] create pagetailor/ with manifest, tokens, data catalog, a sample page, overrides and base components
11
+ validate check manifest, pages and overrides against the component registry and the data catalog
12
+ dev [--port 4747] preview server with live reload; ?page=home&user=giulia&role=marketing
13
+ `;
14
+
15
+ try {
16
+ if (cmd === "init") await init(process.cwd(), args);
17
+ else if (cmd === "validate") process.exitCode = (await validate(process.cwd())) ? 0 : 1;
18
+ else if (cmd === "dev") await dev(process.cwd(), { port: Number(flag("--port", 4747)) });
19
+ else { console.log(help); if (cmd && cmd !== "help") process.exitCode = 1; }
20
+ } catch (e) { console.error("pagetailor:", e.message); process.exitCode = 1; }
package/package.json CHANGED
@@ -1,6 +1,24 @@
1
1
  {
2
2
  "name": "@aeratic/pagetailor",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Pages as data: declarative YAML pages, typed data catalog, per-user overrides, validated for AI agents. Renders with json-render.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": { "pagetailor": "bin/cli.js" },
8
+ "main": "./src/index.js",
9
+ "exports": { ".": "./src/index.js", "./client": "./src/client.jsx" },
10
+ "files": ["bin", "src", "templates", "README.md"],
11
+ "engines": { "node": ">=20" },
12
+ "keywords": ["server-driven-ui", "generative-ui", "declarative-ui", "json-render", "yaml", "personalization", "ai-agent"],
13
+ "repository": { "type": "git", "url": "git+https://github.com/aeratic/pagetailor.git" },
14
+ "publishConfig": { "access": "public" },
15
+ "dependencies": {
16
+ "@json-render/core": "0.21.1",
17
+ "@json-render/react": "0.21.1",
18
+ "esbuild": "^0.25.0",
19
+ "yaml": "^2.6.0",
20
+ "zod": "4.3.6"
21
+ },
22
+ "peerDependencies": { "react": ">=19.2.3", "react-dom": ">=19.2.3" },
23
+ "devDependencies": { "react": "19.2.3", "react-dom": "19.2.3" }
24
+ }
package/src/client.jsx ADDED
@@ -0,0 +1,26 @@
1
+ import { Renderer, JSONUIProvider } from "@json-render/react";
2
+
3
+ // Components are written as ({ props, children, slots, emit }); json-render calls them with { element, ... }.
4
+ // element.props already has $state bindings resolved by the renderer.
5
+ export function wrapRegistry(components) {
6
+ const out = {};
7
+ for (const [name, C] of Object.entries(components))
8
+ out[name] = ({ element, children, slots, emit, on, bindings, loading }) => <C props={element.props ?? {}} children={children} slots={slots} emit={emit} on={on} bindings={bindings} loading={loading} />;
9
+ return out;
10
+ }
11
+
12
+ export function Fallback({ props }) {
13
+ return <div style={{ border: "1px dashed var(--pt-color-danger, #C0392B)", color: "var(--pt-color-danger, #C0392B)", borderRadius: 8, padding: 12, fontSize: 13 }}>{props.reason}</div>;
14
+ }
15
+
16
+ // registry: { "charts/line@1.0.0": Component, ... } resolved: output of resolve()
17
+ export function Page({ resolved, registry }) {
18
+ const reg = wrapRegistry({ "pt/fallback": Fallback, ...registry });
19
+ return (
20
+ <JSONUIProvider registry={reg} initialState={resolved.spec.state} key={JSON.stringify(resolved.spec.state)}>
21
+ <Renderer spec={resolved.spec} registry={reg} fallback={Fallback} />
22
+ </JSONUIProvider>
23
+ );
24
+ }
25
+
26
+ export { tokensToCss } from "./tokens.js";
package/src/dev.js ADDED
@@ -0,0 +1,110 @@
1
+ import http from "node:http";
2
+ import { watch } from "node:fs";
3
+ import { join, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { createRequire } from "node:module";
6
+ import * as esbuild from "esbuild";
7
+ import { resolve } from "./resolver.js";
8
+ import { loadProject, loadYaml, listPages } from "./loader.js";
9
+ import { commitOverride, listHistory, rollback } from "./history.js";
10
+ import { tokensToCss } from "./tokens.js";
11
+
12
+ const here = dirname(fileURLToPath(import.meta.url));
13
+
14
+ export async function dev(cwd, { port = 4747 } = {}) {
15
+ const root = join(cwd, "pagetailor");
16
+ const req = createRequire(join(cwd, "package.json"));
17
+ const alias = {};
18
+ for (const m of ["react", "react-dom", "react/jsx-runtime", "react-dom/client", "@json-render/react", "@json-render/core"]) {
19
+ try { alias[m] = req.resolve(m); } catch {}
20
+ }
21
+
22
+ // one generated entry: imports every component from registry.yaml, mounts <Page>
23
+ const entry = async () => {
24
+ const reg = await loadYaml(join(root, "components", "registry.yaml"));
25
+ const imports = [], map = [];
26
+ let i = 0;
27
+ for (const [name, def] of Object.entries(reg)) for (const [ver, v] of Object.entries(def.versions ?? {})) {
28
+ if (!v.impl) continue; const id = `C${i++}`;
29
+ imports.push(`import ${id} from ${JSON.stringify(v.impl)};`);
30
+ map.push(`${JSON.stringify(`${name}@${ver}`)}: ${id}`);
31
+ }
32
+ return `${imports.join("\n")}
33
+ import { createRoot } from "react-dom/client";
34
+ import { useEffect, useState } from "react";
35
+ import { Page } from ${JSON.stringify(join(here, "client.jsx"))};
36
+ const registry = { ${map.join(", ")} };
37
+ function App() {
38
+ const q = new URLSearchParams(location.search);
39
+ const [page, setPage] = useState(q.get("page") || "__home__");
40
+ const [user, setUser] = useState(q.get("user") || "");
41
+ const [role, setRole] = useState(q.get("role") || "");
42
+ const [state, setState] = useState(null);
43
+ useEffect(() => { fetch(\`/api/page/\${page}?user=\${user}&role=\${role}\`).then(r => r.json()).then(setState); }, [page, user, role]);
44
+ useEffect(() => { const es = new EventSource("/events"); es.onmessage = () => location.reload(); return () => es.close(); }, []);
45
+ if (!state) return null;
46
+ const pages = state.manifest.pages, roles = state.manifest.roles ?? [];
47
+ return <>
48
+ <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 }}>
49
+ <strong>{state.manifest.app}</strong>
50
+ <label>page <select value={state.page} onChange={e => setPage(e.target.value)}>{pages.map(p => <option key={p}>{p}</option>)}</select></label>
51
+ <label>user <input value={user} placeholder="giulia" onChange={e => setUser(e.target.value)} style={{ width: 90 }} /></label>
52
+ <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>
53
+ {state.errors.length > 0 && <span style={{ color: "var(--pt-color-danger)" }}>{state.errors.length} problem(s), see console</span>}
54
+ </header>
55
+ <main style={{ padding: 16, maxWidth: 1100, margin: "0 auto" }}><Page resolved={state} registry={registry} /></main>
56
+ </>;
57
+ }
58
+ createRoot(document.getElementById("root")).render(<App />);`;
59
+ };
60
+
61
+ const ctx = await esbuild.context({
62
+ 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",
64
+ define: { "process.env.NODE_ENV": '"development"' },
65
+ });
66
+ let bundle = "", buildErrors = [];
67
+ const rebuild = async () => {
68
+ try { const r = await ctx.rebuild(); bundle = r.outputFiles[0].text; buildErrors = []; }
69
+ catch (e) { buildErrors = e.errors ?? [{ text: e.message }]; console.error("build:", buildErrors.map(x => x.text).join("\n")); }
70
+ };
71
+ await rebuild();
72
+
73
+ const clients = new Set();
74
+ let timer;
75
+ watch(root, { recursive: true }, (_, file) => {
76
+ if (file?.includes(".history")) return;
77
+ clearTimeout(timer);
78
+ timer = setTimeout(async () => { await rebuild(); for (const c of clients) c.write("data: reload\n\n"); console.log("reloaded:", file); }, 80);
79
+ });
80
+
81
+ const json = (res, code, body) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(body)); };
82
+ const body = req => new Promise(r => { let s = ""; req.on("data", c => s += c); req.on("end", () => r(s ? JSON.parse(s) : {})); });
83
+
84
+ const server = http.createServer(async (req, res) => {
85
+ const url = new URL(req.url, "http://x");
86
+ try {
87
+ if (url.pathname === "/") {
88
+ const { tokens, manifest } = await loadProject(root);
89
+ res.writeHead(200, { "content-type": "text/html" });
90
+ return res.end(`<!doctype html><html><head><meta charset="utf-8"><title>${manifest.app}</title><style>${tokensToCss(tokens)} body{margin:0;background:var(--pt-color-background);color:var(--pt-color-text);font-family:var(--pt-font-base);font-size:var(--pt-font-size)}</style></head><body><div id="root"></div><script type="module" src="/bundle.js"></script></body></html>`);
91
+ }
92
+ if (url.pathname === "/bundle.js") { res.writeHead(200, { "content-type": "text/javascript" }); return res.end(buildErrors.length ? `document.body.innerHTML='<pre style="color:#C0392B;padding:16px">'+${JSON.stringify(buildErrors.map(e => e.text).join("\n"))}+'</pre>'` : bundle); }
93
+ if (url.pathname === "/events") { res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" }); res.write("\n"); clients.add(res); req.on("close", () => clients.delete(res)); return; }
94
+ if (url.pathname.startsWith("/api/page/")) {
95
+ let page = decodeURIComponent(url.pathname.slice(10));
96
+ 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 });
98
+ if (r.errors.length) console.warn(`[${page}]`, r.errors.map(e => `${e.path}: ${e.message}`).join("\n "));
99
+ return json(res, 200, { page, ...r });
100
+ }
101
+ if (url.pathname.startsWith("/api/history/")) return json(res, 200, await listHistory(root, url.pathname.slice(13)));
102
+ 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 })); }
103
+ 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
+ json(res, 404, { error: "not found" });
105
+ } catch (e) { console.error(e); json(res, 500, { error: e.message }); }
106
+ });
107
+ await new Promise(r => server.listen(port, r));
108
+ console.log(`pagetailor dev http://localhost:${port}\n pages from ${root}\n ?page=home&user=giulia&role=marketing`);
109
+ return server;
110
+ }
package/src/history.js ADDED
@@ -0,0 +1,27 @@
1
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { stringify } from "yaml";
4
+
5
+ // Every write to an override is a new numbered version under .history/<name>/NNNN.json.
6
+ // The current override file is rewritten from the version being committed.
7
+ const dirOf = (root, name) => join(root, ".history", name);
8
+ const pad = n => String(n).padStart(4, "0");
9
+
10
+ export async function commitOverride(root, name, override, { author = "system", status = "applied" } = {}) {
11
+ const dir = dirOf(root, name); await mkdir(dir, { recursive: true });
12
+ const n = (await readdir(dir)).length + 1;
13
+ const entry = { version: n, at: new Date().toISOString(), author, status, override };
14
+ await writeFile(join(dir, `${pad(n)}.json`), JSON.stringify(entry, null, 2));
15
+ if (status === "applied") await writeFile(join(root, "overrides", `${name}.yaml`), stringify(override));
16
+ return entry;
17
+ }
18
+
19
+ export async function listHistory(root, name) {
20
+ try { const files = (await readdir(dirOf(root, name))).sort(); return Promise.all(files.map(async f => JSON.parse(await readFile(join(dirOf(root, name), f), "utf8")))); }
21
+ catch { return []; }
22
+ }
23
+
24
+ export async function rollback(root, name, version, { author = "system" } = {}) {
25
+ const entry = JSON.parse(await readFile(join(dirOf(root, name), `${pad(version)}.json`), "utf8"));
26
+ return commitOverride(root, name, { ...entry.override, why: `rollback to v${version}: ${entry.override.why ?? ""}`.trim() }, { author });
27
+ }
package/src/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export { resolve } from "./resolver.js";
2
+ export { validateTree, validateManifest, typeAccepts } from "./validator.js";
3
+ export { applyOverride } from "./overrides.js";
4
+ export { commitOverride, listHistory, rollback } from "./history.js";
5
+ export { loadProject, loadPage, loadOverrides, listPages, listOverrides, loadSources } from "./loader.js";
6
+ export { init } from "./init.js";
7
+ export { validate } from "./validate.js";
8
+ export { dev } from "./dev.js";
package/src/init.js ADDED
@@ -0,0 +1,30 @@
1
+ import { cp, mkdir, access, readFile } from "node:fs/promises";
2
+ import { join, dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const here = dirname(fileURLToPath(import.meta.url));
6
+
7
+ export async function init(cwd, args = []) {
8
+ const target = join(cwd, "pagetailor");
9
+ const force = args.includes("--force");
10
+ 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
+ await mkdir(target, { recursive: true });
12
+ await cp(join(here, "..", "templates"), target, { recursive: true, force });
13
+ let needs = [];
14
+ try {
15
+ const pkg = JSON.parse(await readFile(join(cwd, "package.json"), "utf8"));
16
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
17
+ for (const d of ["@aeratic/pagetailor", "react", "react-dom"]) if (!deps[d]) needs.push(d);
18
+ } catch { needs = ["@aeratic/pagetailor", "react", "react-dom"]; }
19
+ console.log(`created ${target}/
20
+ manifest.yaml app shape: pages, home, navigation, roles
21
+ tokens.yaml design tokens, exposed as CSS variables --pt-*
22
+ data-catalog.yaml typed data sources with roles
23
+ data/sources.mjs one async function per source (demo data, replace with your APIs)
24
+ pages/home.yaml a sample page: card > grid with named areas
25
+ overrides/ per-user (user-giulia) and per-role (role-magazzino) patches
26
+ components/ registry.yaml + 9 base components, yours to edit
27
+ `);
28
+ if (needs.length) console.log(`install the runtime:\n npm install ${needs.join(" ")}\n`);
29
+ console.log(`then:\n npx pagetailor validate\n npx pagetailor dev`);
30
+ }
package/src/loader.js ADDED
@@ -0,0 +1,54 @@
1
+ import { readFile, stat, readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { parse } from "yaml";
4
+
5
+ const cache = new Map(); // path -> { mtime, value }
6
+
7
+ export async function loadYaml(path) {
8
+ const { mtimeMs } = await stat(path);
9
+ const hit = cache.get(path);
10
+ if (hit && hit.mtime === mtimeMs) return hit.value;
11
+ const value = parse(await readFile(path, "utf8"));
12
+ cache.set(path, { mtime: mtimeMs, value });
13
+ return value;
14
+ }
15
+
16
+ export async function exists(path) { try { await stat(path); return true; } catch { return false; } }
17
+
18
+ export async function loadProject(root) {
19
+ const [manifest, tokens, catalog, registry] = await Promise.all([
20
+ loadYaml(join(root, "manifest.yaml")),
21
+ loadYaml(join(root, "tokens.yaml")),
22
+ loadYaml(join(root, "data-catalog.yaml")),
23
+ loadYaml(join(root, "components", "registry.yaml")),
24
+ ]);
25
+ return { root, manifest, tokens, catalog, registry };
26
+ }
27
+
28
+ export async function loadPage(root, page) { return loadYaml(join(root, "pages", `${page}.yaml`)); }
29
+
30
+ export async function loadOverrides(root, page, user, role) {
31
+ // role first, then user: each one a small patch file, missing file = no patch
32
+ const out = [];
33
+ for (const name of [role && `role-${role}`, user && `user-${user}`].filter(Boolean)) {
34
+ const p = join(root, "overrides", `${name}.yaml`);
35
+ if (await exists(p)) { const ov = await loadYaml(p); if (!ov.extends || ov.extends === page) out.push({ name, ...ov }); }
36
+ }
37
+ return out;
38
+ }
39
+
40
+ export async function listPages(root) {
41
+ return (await readdir(join(root, "pages"))).filter(f => f.endsWith(".yaml")).map(f => f.slice(0, -5));
42
+ }
43
+ export async function listOverrides(root) {
44
+ const dir = join(root, "overrides");
45
+ if (!(await exists(dir))) return [];
46
+ return (await readdir(dir)).filter(f => f.endsWith(".yaml")).map(f => f.slice(0, -5));
47
+ }
48
+
49
+ export async function loadSources(root) {
50
+ const p = join(root, "data", "sources.mjs");
51
+ if (!(await exists(p))) return {};
52
+ const mod = await import(`file://${p}?t=${Date.now()}`);
53
+ return mod.default ?? mod.sources ?? mod;
54
+ }
@@ -0,0 +1,43 @@
1
+ import { splitRef } from "./validator.js";
2
+
3
+ // Ops work on the YAML tree, by component name or by child name, never by element id.
4
+ // { op: set-areas, target: layout/grid, areas: "..." }
5
+ // { op: replace, target: chart, with: { ref, props, data } }
6
+ // { op: remove, target: kpi }
7
+ // { op: set-props, target: charts/line, props: { title: "..." } }
8
+ // { op: set-data, target: chart, data: { source, params } }
9
+ // { op: insert, target: <container>, name?: todo2, node: {...}, before?: <child name>, area?: "..." }
10
+ // { op: move, target: <child name>, to: 0 } (array children only)
11
+
12
+ export function applyOverride(tree, override) {
13
+ const t = structuredClone(tree);
14
+ const errors = [];
15
+ for (const [i, p] of (override.patches ?? []).entries()) {
16
+ const found = find(t, p.target);
17
+ if (!found) { errors.push({ path: `${override.name ?? "override"}/${i}`, message: `patch target "${p.target}" not found` }); continue; }
18
+ const { parent, key, node } = found;
19
+ switch (p.op) {
20
+ case "set-areas": (node.props ??= {}).areas = p.areas; break;
21
+ case "set-props": Object.assign(node.props ??= {}, p.props); break;
22
+ case "set-data": node.data = p.data; break;
23
+ case "replace": if (parent) parent[key] = p.with; else Object.assign(t, p.with); break;
24
+ case "remove": if (Array.isArray(parent)) parent.splice(key, 1); else delete parent[key]; break;
25
+ case "insert": {
26
+ if (Array.isArray(node.children)) { const at = p.before != null ? node.children.findIndex(c => splitRef(c.ref)[0] === p.before) : node.children.length; node.children.splice(at < 0 ? node.children.length : at, 0, p.node); }
27
+ else { (node.children ??= {})[p.name] = p.node; if (p.area) (node.props ??= {}).areas = p.area; }
28
+ break;
29
+ }
30
+ case "move": { if (!Array.isArray(parent)) { errors.push({ path: `${i}`, message: "move works on array children" }); break; } const [n] = parent.splice(key, 1); parent.splice(p.to, 0, n); break; }
31
+ default: errors.push({ path: `${i}`, message: `unknown op "${p.op}"` });
32
+ }
33
+ }
34
+ return { tree: t, errors };
35
+ }
36
+
37
+ export function find(node, target, parent = null, key = null) {
38
+ if (!node || typeof node !== "object") return null;
39
+ if (key === target || (node.ref && splitRef(node.ref)[0] === target) || node.ref === target) return { parent, key, node };
40
+ const kids = node.children; if (!kids) return null;
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
+ return null;
43
+ }
@@ -0,0 +1,44 @@
1
+ import { loadProject, loadPage, loadOverrides, loadSources } from "./loader.js";
2
+ import { validateTree } from "./validator.js";
3
+ import { applyOverride } from "./overrides.js";
4
+
5
+ // YAML page + overrides + data -> json-render flat spec { root, elements, state }
6
+ export async function resolve(root, page, { user, role, sources } = {}) {
7
+ const project = await loadProject(root);
8
+ const fetchers = sources ?? await loadSources(root);
9
+ let tree = (await loadPage(root, page)).layout;
10
+ const errors = [];
11
+ for (const ov of await loadOverrides(root, page, user, role)) {
12
+ const r = applyOverride(tree, ov); tree = r.tree; errors.push(...r.errors);
13
+ }
14
+ errors.push(...validateTree(tree, project, { role }));
15
+ const byPath = new Map(); for (const e of errors) byPath.set(e.path, [...(byPath.get(e.path) ?? []), e.message]);
16
+
17
+ const elements = {}, state = { data: {}, user: { id: user ?? null, role: role ?? null } };
18
+ let n = 0;
19
+ const walk = async (node, path) => {
20
+ const id = `e${n++}`;
21
+ const bad = byPath.get(path);
22
+ if (bad) { elements[id] = { type: "pt/fallback", props: { reason: bad.join("; ") } }; return id; }
23
+ const props = { ...(node.props ?? {}) };
24
+ if (node.data?.source) {
25
+ const key = `${node.data.source}${node.data.params ? ":" + JSON.stringify(node.data.params) : ""}`.replace(/[^\w]/g, "_");
26
+ if (!(key in state.data)) {
27
+ const fn = fetchers[node.data.source];
28
+ state.data[key] = fn ? await fn(node.data.params ?? {}, { user, role }) : null;
29
+ }
30
+ props.data = { $state: `/data/${key}` };
31
+ }
32
+ const el = { type: node.ref, props };
33
+ if (node.children) {
34
+ const named = !Array.isArray(node.children);
35
+ const entries = named ? Object.entries(node.children) : node.children.map((c, i) => [i, c]);
36
+ if (named) props.names = entries.map(([k]) => k);
37
+ el.children = [];
38
+ for (const [i, [, c]] of entries.entries()) el.children.push(await walk(c, `${path}/${i}`));
39
+ }
40
+ elements[id] = el; return id;
41
+ };
42
+ const rootId = await walk(tree, "root");
43
+ return { spec: { root: rootId, elements, state }, errors, tokens: project.tokens, manifest: project.manifest };
44
+ }
package/src/tokens.js ADDED
@@ -0,0 +1,12 @@
1
+ // Flatten tokens.yaml into CSS variables: color.primary -> --pt-color-primary, spacing.md -> 16px
2
+ export function tokensToCss(tokens, prefix = "--pt") {
3
+ const out = [];
4
+ const walk = (o, path) => {
5
+ for (const [k, v] of Object.entries(o ?? {})) {
6
+ if (v && typeof v === "object") walk(v, `${path}-${k}`);
7
+ else out.push(`${path}-${k}: ${typeof v === "number" ? v + "px" : v};`);
8
+ }
9
+ };
10
+ walk(tokens, prefix);
11
+ return `:root { ${out.join(" ")} }`;
12
+ }
@@ -0,0 +1,29 @@
1
+ import { join } from "node:path";
2
+ import { loadProject, loadPage, listPages, listOverrides, loadYaml } from "./loader.js";
3
+ import { validateTree, validateManifest } from "./validator.js";
4
+ import { applyOverride } from "./overrides.js";
5
+
6
+ export async function validate(cwd) {
7
+ const root = join(cwd, "pagetailor");
8
+ const project = await loadProject(root);
9
+ const pages = await listPages(root);
10
+ const problems = validateManifest(project.manifest, pages).map(e => `manifest.yaml: ${e.message}`);
11
+ const trees = {};
12
+ for (const p of pages) {
13
+ trees[p] = (await loadPage(root, p)).layout;
14
+ for (const e of validateTree(trees[p], project)) problems.push(`pages/${p}.yaml ${e.path}: ${e.message}`);
15
+ }
16
+ for (const name of await listOverrides(root)) {
17
+ const ov = await loadYaml(join(root, "overrides", `${name}.yaml`));
18
+ const targets = ov.extends ? [ov.extends] : pages;
19
+ const role = name.startsWith("role-") ? name.slice(5) : undefined;
20
+ for (const p of targets) {
21
+ if (!trees[p]) { problems.push(`overrides/${name}.yaml: extends unknown page "${p}"`); continue; }
22
+ const r = applyOverride(trees[p], { name, ...ov });
23
+ for (const e of r.errors) problems.push(`overrides/${name}.yaml ${e.path}: ${e.message}`);
24
+ for (const e of validateTree(r.tree, project, { role })) problems.push(`overrides/${name}.yaml on ${p} ${e.path}: ${e.message}`);
25
+ }
26
+ }
27
+ if (problems.length) { console.error(problems.join("\n")); console.error(`\n${problems.length} problem(s)`); return false; }
28
+ console.log(`all pages valid (${pages.length} page(s))`); return true;
29
+ }
@@ -0,0 +1,56 @@
1
+ // One validator used everywhere: CLI, resolver at runtime, agent gate.
2
+ // A node is { ref: "name@version", props?, data?: { source, params? }, children?: [] | {name: node} }
3
+
4
+ export const childrenOf = (node) => !node?.children ? [] : Array.isArray(node.children) ? node.children : Object.values(node.children);
5
+ export const splitRef = (ref) => { const i = String(ref).lastIndexOf("@"); return i < 0 ? [ref, null] : [ref.slice(0, i), ref.slice(i + 1)]; };
6
+
7
+ // v0 type lattice: exact match, "any" accepts everything, "list<any>" / "table<any>" accept any list / table
8
+ export function typeAccepts(accepts, returns) {
9
+ if (!accepts || accepts === "any") return true;
10
+ if (accepts === returns) return true;
11
+ const m = /^(\w+)<any>$/.exec(accepts);
12
+ return !!m && returns.startsWith(m[1] + "<");
13
+ }
14
+
15
+ export function validateTree(tree, project, { role } = {}) {
16
+ const errors = [];
17
+ const walk = (node, path) => {
18
+ if (!node || typeof node !== "object" || !node.ref) { errors.push({ path, message: "node has no ref" }); return; }
19
+ const [name, version] = splitRef(node.ref);
20
+ const comp = project.registry[name];
21
+ if (!comp) errors.push({ path, message: `unknown component "${name}"` });
22
+ else if (version && !comp.versions?.[version]) errors.push({ path, message: `"${name}" has no version ${version}` });
23
+ if (node.data?.source) {
24
+ const src = project.catalog[node.data.source];
25
+ if (!src) errors.push({ path, message: `unknown data source "${node.data.source}"` });
26
+ else {
27
+ if (src.roles && !src.roles.includes("*") && !src.roles.includes(role))
28
+ errors.push({ path, message: `role "${role ?? "none"}" cannot read "${node.data.source}"` });
29
+ if (comp?.accepts && src.returns && !typeAccepts(comp.accepts, src.returns))
30
+ errors.push({ path, message: `"${name}" accepts ${comp.accepts} but "${node.data.source}" returns ${src.returns}` });
31
+ for (const [k, v] of Object.entries(node.data.params ?? {})) {
32
+ const allowed = src.params?.[k];
33
+ if (allowed === undefined) errors.push({ path, message: `"${node.data.source}" has no param "${k}"` });
34
+ else if (Array.isArray(allowed) && !allowed.includes(v)) errors.push({ path, message: `param ${k}=${v} not in [${allowed.join(", ")}]` });
35
+ }
36
+ }
37
+ }
38
+ if (node.children && !Array.isArray(node.children) && node.props?.areas) {
39
+ const names = Object.keys(node.children);
40
+ const inAreas = new Set(String(node.props.areas).split(/\s+/).filter(Boolean).filter(a => a !== "."));
41
+ for (const a of inAreas) if (!names.includes(a)) errors.push({ path, message: `area "${a}" has no child` });
42
+ for (const n of names) if (!inAreas.has(n)) errors.push({ path, message: `child "${n}" is not placed in areas` });
43
+ }
44
+ childrenOf(node).forEach((c, i) => walk(c, `${path}/${i}`));
45
+ };
46
+ walk(tree, "root");
47
+ return errors;
48
+ }
49
+
50
+ export function validateManifest(manifest, pages) {
51
+ const errors = [];
52
+ 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
+ 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` });
54
+ if (manifest.home && !pages.includes(manifest.home)) errors.push({ path: "manifest", message: `home "${manifest.home}" is not a page` });
55
+ return errors;
56
+ }
@@ -0,0 +1,8 @@
1
+ export default function Card({ props, children }) {
2
+ return (
3
+ <section style={{ background: "var(--pt-color-surface)", border: "1px solid var(--pt-color-border)", borderRadius: "var(--pt-radius)", padding: "var(--pt-spacing-lg)" }}>
4
+ {props.title && <h2 style={{ margin: "0 0 var(--pt-spacing-md)", fontSize: 18 }}>{props.title}</h2>}
5
+ <div style={{ display: "flex", flexDirection: "column", gap: "var(--pt-spacing-md)" }}>{children}</div>
6
+ </section>
7
+ );
8
+ }
@@ -0,0 +1,13 @@
1
+ import { Children } from "react";
2
+
3
+ // props.areas is a multiline string like CSS grid-template-areas; props.names lists the children in order.
4
+ export default function Grid({ props, children }) {
5
+ const rows = String(props.areas).trim().split("\n").map(l => l.trim().split(/\s+/));
6
+ const cols = Math.max(...rows.map(r => r.length));
7
+ const kids = Children.toArray(children);
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
+ );
13
+ }
@@ -0,0 +1,9 @@
1
+ const fmt = new Intl.NumberFormat("it-IT", { style: "currency", currency: "EUR", maximumFractionDigits: 0 });
2
+ export default function Kpi({ props }) {
3
+ return (
4
+ <div style={{ padding: "var(--pt-spacing-md)", border: "1px solid var(--pt-color-border)", borderRadius: "var(--pt-radius)", height: "100%", boxSizing: "border-box" }}>
5
+ <div style={{ color: "var(--pt-color-muted)", fontSize: 13 }}>{props.label}</div>
6
+ <div style={{ fontSize: 32, fontWeight: 600, color: "var(--pt-color-primary)" }}>{typeof props.data === "number" ? fmt.format(props.data) : "–"}</div>
7
+ </div>
8
+ );
9
+ }
@@ -0,0 +1,20 @@
1
+ // A dependency-free SVG line chart for timeseries<money>: [{ date, value }]
2
+ export default function LineChart({ props }) {
3
+ const pts = Array.isArray(props.data) ? props.data : [];
4
+ const W = 600, H = 180, pad = 24;
5
+ const vals = pts.map(p => p.value), lo = Math.min(...vals, 0), hi = Math.max(...vals, 1);
6
+ const x = i => pad + (i / Math.max(pts.length - 1, 1)) * (W - 2 * pad);
7
+ const y = v => H - pad - ((v - lo) / (hi - lo)) * (H - 2 * pad);
8
+ const d = pts.map((p, i) => `${i ? "L" : "M"}${x(i).toFixed(1)} ${y(p.value).toFixed(1)}`).join(" ");
9
+ return (
10
+ <div style={{ border: "1px solid var(--pt-color-border)", borderRadius: "var(--pt-radius)", padding: "var(--pt-spacing-md)" }}>
11
+ {props.title && <div style={{ fontWeight: 600, marginBottom: 8 }}>{props.title}</div>}
12
+ <svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: "auto", display: "block" }} role="img" aria-label={props.title}>
13
+ <line x1={pad} y1={H - pad} x2={W - pad} y2={H - pad} stroke="var(--pt-color-border)" />
14
+ <path d={d} fill="none" stroke="var(--pt-color-primary)" strokeWidth="2" />
15
+ {pts.length > 0 && <text x={pad} y={14} fontSize="11" fill="var(--pt-color-muted)">{pts[0].date}</text>}
16
+ {pts.length > 1 && <text x={W - pad} y={14} fontSize="11" textAnchor="end" fill="var(--pt-color-muted)">{pts.at(-1).date}</text>}
17
+ </svg>
18
+ </div>
19
+ );
20
+ }
@@ -0,0 +1,3 @@
1
+ export default function Row({ props, children }) {
2
+ return <div style={{ display: "flex", flexDirection: "row", gap: props.gap ?? "var(--pt-spacing-md)", alignItems: "stretch" }}>{children}</div>;
3
+ }
@@ -0,0 +1,3 @@
1
+ export default function Stack({ props, children }) {
2
+ return <div style={{ display: "flex", flexDirection: "column", gap: props.gap ?? "var(--pt-spacing-md)" }}>{children}</div>;
3
+ }
@@ -0,0 +1,13 @@
1
+ export default function Table({ props }) {
2
+ const rows = Array.isArray(props.data) ? props.data : [];
3
+ const cols = rows.length ? Object.keys(rows[0]) : [];
4
+ return (
5
+ <div style={{ border: "1px solid var(--pt-color-border)", borderRadius: "var(--pt-radius)", padding: "var(--pt-spacing-md)", height: "100%", boxSizing: "border-box" }}>
6
+ {props.title && <div style={{ fontWeight: 600, marginBottom: 8 }}>{props.title}</div>}
7
+ <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
8
+ <thead><tr>{cols.map(c => <th key={c} style={{ textAlign: "left", padding: "4px 6px", color: "var(--pt-color-muted)", fontWeight: 500, borderBottom: "1px solid var(--pt-color-border)" }}>{c}</th>)}</tr></thead>
9
+ <tbody>{rows.map((r, i) => <tr key={i}>{cols.map(c => <td key={c} style={{ padding: "4px 6px" }}>{String(r[c])}</td>)}</tr>)}</tbody>
10
+ </table>
11
+ </div>
12
+ );
13
+ }
@@ -0,0 +1,3 @@
1
+ export default function Text({ props }) {
2
+ return <p style={{ margin: 0, color: props.muted ? "var(--pt-color-muted)" : "inherit" }}>{props.content}</p>;
3
+ }
@@ -0,0 +1,11 @@
1
+ export default function TodoList({ props }) {
2
+ const items = Array.isArray(props.data) ? props.data : [];
3
+ return (
4
+ <div style={{ border: "1px solid var(--pt-color-border)", borderRadius: "var(--pt-radius)", padding: "var(--pt-spacing-md)", height: "100%", boxSizing: "border-box" }}>
5
+ {props.title && <div style={{ fontWeight: 600, marginBottom: 8 }}>{props.title}</div>}
6
+ <ul style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 6 }}>
7
+ {items.map(t => <li key={t.id ?? t.title} style={{ textDecoration: t.done ? "line-through" : "none", color: t.done ? "var(--pt-color-muted)" : "inherit" }}>{t.done ? "☑" : "☐"} {t.title}</li>)}
8
+ </ul>
9
+ </div>
10
+ );
11
+ }
@@ -0,0 +1,24 @@
1
+ # name -> accepted data type (optional) and versions -> implementation file.
2
+ # Containers accept no data. Add a new version when props change in an incompatible way.
3
+ layout/stack:
4
+ versions: { "1.0.0": { impl: ./Stack.jsx } }
5
+ layout/row:
6
+ versions: { "1.0.0": { impl: ./Row.jsx } }
7
+ layout/card:
8
+ versions: { "1.0.0": { impl: ./Card.jsx } }
9
+ layout/grid:
10
+ versions: { "1.0.0": { impl: ./Grid.jsx } }
11
+ text/block:
12
+ versions: { "1.0.0": { impl: ./Text.jsx } }
13
+ kpi/number:
14
+ accepts: money
15
+ versions: { "1.0.0": { impl: ./Kpi.jsx } }
16
+ charts/line:
17
+ accepts: timeseries<money>
18
+ versions: { "1.0.0": { impl: ./LineChart.jsx } }
19
+ tables/simple:
20
+ accepts: table<any>
21
+ versions: { "1.0.0": { impl: ./Table.jsx } }
22
+ todo/list:
23
+ accepts: list<task>
24
+ versions: { "1.0.0": { impl: ./TodoList.jsx } }
@@ -0,0 +1,19 @@
1
+ // One async function per data source, same names as data-catalog.yaml.
2
+ // Receives the node's params and { user, role }. Replace the demo data with your API calls.
3
+ const days = (n, base) => Array.from({ length: n }, (_, i) => ({ date: `2026-10-${String(i + 1).padStart(2, "0")}`, value: Math.round(base + Math.sin(i / 2) * base * 0.2 + i * 15) }));
4
+
5
+ export default {
6
+ "analytics.revenue": async ({ period = "30d" }) => days({ "7d": 7, "30d": 30, "90d": 90 }[period], 1200),
7
+ "sales.total": async () => 48250,
8
+ "sales.by_region": async () => [
9
+ { region: "Nord", total: 21000 }, { region: "Centro", total: 15250 }, { region: "Sud", total: 12000 },
10
+ ],
11
+ "tasks.mine": async (_, { user }) => [
12
+ { id: 1, title: `Rivedere il report settimanale (${user ?? "anon"})`, done: false },
13
+ { id: 2, title: "Chiamare il fornitore", done: true },
14
+ { id: 3, title: "Preparare la riunione di lunedì", done: false },
15
+ ],
16
+ "stock.levels": async () => [
17
+ { sku: "A-100", name: "Vite M6", qty: 1200 }, { sku: "B-220", name: "Staffa", qty: 85 }, { sku: "C-310", name: "Guarnizione", qty: 0 },
18
+ ],
19
+ };
@@ -0,0 +1,23 @@
1
+ # Typed data sources. A component binds to a source only if it accepts the type the source returns
2
+ # and the user's role is listed. Implementations live in data/sources.mjs, keyed by the same names.
3
+ analytics.revenue:
4
+ description: "Revenue over time"
5
+ params: { period: [7d, 30d, 90d] }
6
+ returns: timeseries<money>
7
+ roles: [admin, marketing]
8
+ sales.total:
9
+ description: "Total sales for the current month"
10
+ returns: money
11
+ roles: [admin, marketing]
12
+ sales.by_region:
13
+ description: "Sales grouped by region"
14
+ returns: table<region>
15
+ roles: [admin, marketing]
16
+ tasks.mine:
17
+ description: "Tasks assigned to the current user"
18
+ returns: list<task>
19
+ roles: ["*"]
20
+ stock.levels:
21
+ description: "Warehouse stock levels"
22
+ returns: table<stock>
23
+ roles: [admin, magazzino]
@@ -0,0 +1,6 @@
1
+ app: my-app
2
+ home: home
3
+ nav:
4
+ position: left
5
+ pages: [home]
6
+ roles: [admin, marketing, magazzino]
@@ -0,0 +1,15 @@
1
+ extends: home
2
+ why: "Il magazzino non vede le vendite: al posto del grafico le giacenze"
3
+ patches:
4
+ - op: replace
5
+ target: chart
6
+ with: { ref: tables/simple@1.0.0, props: { title: "Giacenze" }, data: { source: stock.levels } }
7
+ - op: remove
8
+ target: table
9
+ - op: remove
10
+ target: kpi
11
+ - op: set-areas
12
+ target: layout/grid
13
+ areas: |
14
+ chart chart
15
+ todo todo
@@ -0,0 +1,9 @@
1
+ extends: home
2
+ why: "Giulia fa il report ogni lunedì: vuole il KPI grande in alto e il grafico sotto"
3
+ patches:
4
+ - op: set-areas
5
+ target: layout/grid
6
+ areas: |
7
+ kpi kpi
8
+ chart chart
9
+ table todo
@@ -0,0 +1,16 @@
1
+ page: home
2
+ layout:
3
+ ref: layout/card@1.0.0
4
+ props: { title: "Dashboard" }
5
+ children:
6
+ - ref: layout/grid@1.0.0
7
+ props:
8
+ areas: |
9
+ chart chart
10
+ table kpi
11
+ table todo
12
+ children:
13
+ chart: { ref: charts/line@1.0.0, props: { title: "Ricavi, ultimi 30 giorni" }, data: { source: analytics.revenue, params: { period: 30d } } }
14
+ table: { ref: tables/simple@1.0.0, props: { title: "Vendite per zona" }, data: { source: sales.by_region } }
15
+ kpi: { ref: kpi/number@1.0.0, props: { label: "Vendite del mese" }, data: { source: sales.total } }
16
+ todo: { ref: todo/list@1.0.0, props: { title: "Le mie attività" }, data: { source: tasks.mine } }
@@ -0,0 +1,11 @@
1
+ color:
2
+ primary: "#1D9E75"
3
+ surface: "#FFFFFF"
4
+ background: "#F5F6F8"
5
+ text: "#1A1A1A"
6
+ muted: "#6B7280"
7
+ border: "#E5E7EB"
8
+ danger: "#C0392B"
9
+ spacing: { sm: 8, md: 16, lg: 24 }
10
+ radius: 8
11
+ font: { base: "Inter, system-ui, sans-serif", size: 15 }