@aeratic/pagetailor 0.0.1 → 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 +69 -5
- package/bin/cli.js +6 -2
- package/package.json +17 -35
- package/src/client.jsx +26 -0
- package/src/dev.js +110 -0
- package/src/history.js +27 -0
- package/src/index.js +6 -0
- package/src/init.js +17 -9
- package/src/loader.js +54 -0
- package/src/overrides.js +43 -0
- package/src/resolver.js +44 -0
- package/src/tokens.js +12 -0
- package/src/validate.js +22 -36
- package/src/validator.js +56 -0
- package/templates/components/Card.jsx +8 -0
- package/templates/components/Grid.jsx +13 -0
- package/templates/components/Kpi.jsx +9 -0
- package/templates/components/LineChart.jsx +19 -2
- package/templates/components/Row.jsx +3 -0
- package/templates/components/Stack.jsx +2 -2
- package/templates/components/Table.jsx +12 -2
- package/templates/components/Text.jsx +2 -2
- package/templates/components/TodoList.jsx +10 -2
- package/templates/components/registry.yaml +14 -4
- package/templates/data/sources.mjs +19 -0
- package/templates/data-catalog.yaml +16 -3
- package/templates/manifest.yaml +1 -1
- package/templates/overrides/role-magazzino.yaml +15 -0
- package/templates/overrides/user-giulia.yaml +9 -0
- package/templates/pages/home.yaml +13 -7
- package/templates/tokens.yaml +6 -1
- package/templates/overrides/.gitkeep +0 -0
- package/templates/overrides/README.md +0 -6
package/README.md
CHANGED
|
@@ -1,11 +1,75 @@
|
|
|
1
1
|
# @aeratic/pagetailor
|
|
2
2
|
|
|
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.
|
|
4
|
-
|
|
5
|
-
Status: 0.0.1, name reservation. The engine (built on json-render) lands in 0.1.
|
|
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).
|
|
6
4
|
|
|
7
5
|
```
|
|
8
6
|
npm init -y
|
|
9
|
-
|
|
10
|
-
npx
|
|
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"
|
|
11
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
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { init } from "../src/init.js";
|
|
3
3
|
import { validate } from "../src/validate.js";
|
|
4
|
+
import { dev } from "../src/dev.js";
|
|
4
5
|
|
|
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; };
|
|
6
8
|
const help = `pagetailor <command>
|
|
7
9
|
|
|
8
|
-
init
|
|
9
|
-
validate
|
|
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
|
|
10
13
|
`;
|
|
11
14
|
|
|
12
15
|
try {
|
|
13
16
|
if (cmd === "init") await init(process.cwd(), args);
|
|
14
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)) });
|
|
15
19
|
else { console.log(help); if (cmd && cmd !== "help") process.exitCode = 1; }
|
|
16
20
|
} catch (e) { console.error("pagetailor:", e.message); process.exitCode = 1; }
|
package/package.json
CHANGED
|
@@ -1,42 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aeratic/pagetailor",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "Pages as data: declarative, per-user
|
|
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
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"bin": {
|
|
8
|
-
"pagetailor": "bin/cli.js"
|
|
9
|
-
},
|
|
7
|
+
"bin": { "pagetailor": "bin/cli.js" },
|
|
10
8
|
"main": "./src/index.js",
|
|
11
|
-
"exports": {
|
|
12
|
-
|
|
13
|
-
},
|
|
14
|
-
"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"templates",
|
|
18
|
-
"README.md"
|
|
19
|
-
],
|
|
20
|
-
"engines": {
|
|
21
|
-
"node": ">=18"
|
|
22
|
-
},
|
|
23
|
-
"keywords": [
|
|
24
|
-
"server-driven-ui",
|
|
25
|
-
"generative-ui",
|
|
26
|
-
"declarative-ui",
|
|
27
|
-
"json-render",
|
|
28
|
-
"yaml",
|
|
29
|
-
"personalization",
|
|
30
|
-
"ai-agent"
|
|
31
|
-
],
|
|
32
|
-
"repository": {
|
|
33
|
-
"type": "git",
|
|
34
|
-
"url": "git+https://github.com/aeratic/pagetailor.git"
|
|
35
|
-
},
|
|
36
|
-
"publishConfig": {
|
|
37
|
-
"access": "public"
|
|
38
|
-
},
|
|
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" },
|
|
39
15
|
"dependencies": {
|
|
40
|
-
"
|
|
41
|
-
|
|
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" }
|
|
42
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
CHANGED
|
@@ -1,2 +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";
|
|
1
6
|
export { init } from "./init.js";
|
|
2
7
|
export { validate } from "./validate.js";
|
|
8
|
+
export { dev } from "./dev.js";
|
package/src/init.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cp, mkdir, access } from "node:fs/promises";
|
|
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
4
|
|
|
@@ -10,13 +10,21 @@ export async function init(cwd, args = []) {
|
|
|
10
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
11
|
await mkdir(target, { recursive: true });
|
|
12
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"]; }
|
|
13
19
|
console.log(`created ${target}/
|
|
14
|
-
manifest.yaml
|
|
15
|
-
tokens.yaml
|
|
16
|
-
data-catalog.yaml
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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`);
|
|
22
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
|
+
}
|
package/src/overrides.js
ADDED
|
@@ -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
|
+
}
|
package/src/resolver.js
ADDED
|
@@ -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
|
+
}
|
package/src/validate.js
CHANGED
|
@@ -1,43 +1,29 @@
|
|
|
1
|
-
import { readFile, readdir } from "node:fs/promises";
|
|
2
1
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
import { loadProject, loadPage, listPages, listOverrides, loadYaml } from "./loader.js";
|
|
3
|
+
import { validateTree, validateManifest } from "./validator.js";
|
|
4
|
+
import { applyOverride } from "./overrides.js";
|
|
6
5
|
|
|
7
6
|
export async function validate(cwd) {
|
|
8
7
|
const root = join(cwd, "pagetailor");
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
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}`);
|
|
27
25
|
}
|
|
28
|
-
(node.children ?? []).forEach((c, i) => walk(c, page, `${path}/${i}`));
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
for (const file of await readdir(join(root, "pages"))) {
|
|
32
|
-
if (!file.endsWith(".yaml")) continue;
|
|
33
|
-
const page = await load(join(root, "pages", file));
|
|
34
|
-
if (!manifest.pages?.includes(page.page)) errors.push(`${file}: page "${page.page}" is not listed in manifest.yaml`);
|
|
35
|
-
walk(page.layout, file);
|
|
36
26
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
console.log("all pages valid"); return true;
|
|
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;
|
|
40
29
|
}
|
|
41
|
-
|
|
42
|
-
// v0: exact match or "any"; a real type lattice comes later.
|
|
43
|
-
const compatible = (accepts, returns) => accepts === "any" || accepts === returns;
|
package/src/validator.js
ADDED
|
@@ -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
|
+
}
|
|
@@ -1,3 +1,20 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
+
);
|
|
3
20
|
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export default function Stack({ props,
|
|
2
|
-
return <div
|
|
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
3
|
}
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
-
export default function Table({ props
|
|
2
|
-
|
|
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
|
+
);
|
|
3
13
|
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export default function Text({ props
|
|
2
|
-
return <
|
|
1
|
+
export default function Text({ props }) {
|
|
2
|
+
return <p style={{ margin: 0, color: props.muted ? "var(--pt-color-muted)" : "inherit" }}>{props.content}</p>;
|
|
3
3
|
}
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
-
export default function TodoList({ props
|
|
2
|
-
|
|
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
|
+
);
|
|
3
11
|
}
|
|
@@ -1,14 +1,24 @@
|
|
|
1
|
-
# name ->
|
|
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.
|
|
2
3
|
layout/stack:
|
|
3
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 } }
|
|
4
16
|
charts/line:
|
|
5
17
|
accepts: timeseries<money>
|
|
6
18
|
versions: { "1.0.0": { impl: ./LineChart.jsx } }
|
|
7
19
|
tables/simple:
|
|
8
|
-
accepts: any
|
|
20
|
+
accepts: table<any>
|
|
9
21
|
versions: { "1.0.0": { impl: ./Table.jsx } }
|
|
10
22
|
todo/list:
|
|
11
23
|
accepts: list<task>
|
|
12
24
|
versions: { "1.0.0": { impl: ./TodoList.jsx } }
|
|
13
|
-
text/block:
|
|
14
|
-
versions: { "1.0.0": { impl: ./Text.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
|
+
};
|
|
@@ -1,10 +1,23 @@
|
|
|
1
|
-
# Typed data sources.
|
|
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.
|
|
2
3
|
analytics.revenue:
|
|
3
4
|
description: "Revenue over time"
|
|
4
5
|
params: { period: [7d, 30d, 90d] }
|
|
5
6
|
returns: timeseries<money>
|
|
6
|
-
roles: [admin]
|
|
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]
|
|
7
16
|
tasks.mine:
|
|
8
17
|
description: "Tasks assigned to the current user"
|
|
9
18
|
returns: list<task>
|
|
10
|
-
roles: [
|
|
19
|
+
roles: ["*"]
|
|
20
|
+
stock.levels:
|
|
21
|
+
description: "Warehouse stock levels"
|
|
22
|
+
returns: table<stock>
|
|
23
|
+
roles: [admin, magazzino]
|
package/templates/manifest.yaml
CHANGED
|
@@ -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
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
page: home
|
|
2
2
|
layout:
|
|
3
|
-
ref: layout/
|
|
3
|
+
ref: layout/card@1.0.0
|
|
4
|
+
props: { title: "Dashboard" }
|
|
4
5
|
children:
|
|
5
|
-
- ref:
|
|
6
|
-
props:
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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 } }
|
package/templates/tokens.yaml
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
color:
|
|
2
2
|
primary: "#1D9E75"
|
|
3
3
|
surface: "#FFFFFF"
|
|
4
|
+
background: "#F5F6F8"
|
|
4
5
|
text: "#1A1A1A"
|
|
6
|
+
muted: "#6B7280"
|
|
7
|
+
border: "#E5E7EB"
|
|
8
|
+
danger: "#C0392B"
|
|
5
9
|
spacing: { sm: 8, md: 16, lg: 24 }
|
|
6
|
-
|
|
10
|
+
radius: 8
|
|
11
|
+
font: { base: "Inter, system-ui, sans-serif", size: 15 }
|
|
File without changes
|