@aeratic/pagetailor 0.3.0 → 0.4.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 +13 -2
- package/package.json +1 -1
- package/src/agent.js +108 -11
- package/src/dev.js +39 -3
- package/src/index.js +1 -1
- package/src/resolver.js +6 -4
package/README.md
CHANGED
|
@@ -127,10 +127,21 @@ A user asks in natural language ("put the KPI at the top"); the agent turns it i
|
|
|
127
127
|
|
|
128
128
|
`complete` is any `async ({ system, user }) => text`, so any model or SDK works; pagetailor depends on none. `anthropicComplete({ apiKey, model })` is a built-in fetch call to the Anthropic Messages API (`claude-sonnet-5-5` by default).
|
|
129
129
|
|
|
130
|
-
In `pagetailor dev`, set `ANTHROPIC_API_KEY` (and optionally `PAGETAILOR_MODEL`) and use the "
|
|
130
|
+
In `pagetailor dev`, set `ANTHROPIC_API_KEY` (and optionally `PAGETAILOR_MODEL`) and use the "Change this page…" box in the header: `POST /api/agent` with `{ page, user, role, request }` (503 without the key). `GET /api/agent/prompt?page=&user=&role=&request=` returns the built prompt for debugging.
|
|
131
|
+
|
|
132
|
+
## Pages as answers
|
|
133
|
+
|
|
134
|
+
A user asks a question ("how did sales go this week?") and the agent composes a brand-new page from the catalog instead of changing an existing one.
|
|
135
|
+
|
|
136
|
+
1. `runAsk(root, { user, role, request, complete })` starts from an empty `layout/stack`, asks the model for `{ "title", "why", "patches" }` using only `insert` ops (cards, grids, widgets), applies them in memory and resolves the tree with `resolve(root, "answer", { user, role, tree })`. Gate or resolve errors are sent back once, like `runAgent`.
|
|
137
|
+
2. The result (`{ title, why, tree, resolved }`) is rendered as is. Nothing is written to disk until the user decides.
|
|
138
|
+
3. Keep as page: `keepAnswer(root, { user, role, title, tree, why })` writes `pages/<slug>.yaml` with `owner: <user>` and adds the slug to `manifest.pages` (slugs are made unique with `-2`, `-3`).
|
|
139
|
+
4. Pin to home: `pinAnswer(root, { user, role, title, tree, why })` proposes, as `user-<user>`, an `insert` of a `layout/card` with the answer's widgets into the home's main container; on a grid it gets its own full-width row at the bottom. Same gate and proposal rules as any override.
|
|
140
|
+
|
|
141
|
+
Both go through the gate with the user's role first. In `pagetailor dev` use the "Ask…" box: `POST /api/ask` `{ user, role, request }` (503 without `ANTHROPIC_API_KEY`), `POST /api/ask/keep` and `POST /api/ask/pin` with `{ user, role, title, tree, why }`.
|
|
131
142
|
|
|
132
143
|
## Status
|
|
133
144
|
|
|
134
|
-
0.
|
|
145
|
+
0.4: engine, CLI (`init`, `validate`, `registry`, `dev`), history with rollback, proposals with owner approval, reorder editor, agent with a pluggable model adapter, pages as answers.
|
|
135
146
|
|
|
136
147
|
License: MIT.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aeratic/pagetailor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Pages as data: declarative YAML pages, typed data catalog, per-user overrides, validated for AI agents. Renders with json-render.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/agent.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import {
|
|
2
|
+
import { writeFile } from "node:fs/promises";
|
|
3
|
+
import { stringify } from "yaml";
|
|
4
|
+
import { loadProject, loadPage, loadOverrides, loadYaml, exists, listPages } from "./loader.js";
|
|
3
5
|
import { applyOverride, mergePatch } from "./overrides.js";
|
|
4
|
-
import { splitRef } from "./validator.js";
|
|
6
|
+
import { splitRef, validateTree } from "./validator.js";
|
|
5
7
|
import { propose } from "./proposals.js";
|
|
8
|
+
import { resolve } from "./resolver.js";
|
|
6
9
|
|
|
7
10
|
// The agent never writes files: it turns a request into override patches and hands them to propose().
|
|
8
11
|
const OPS = [
|
|
@@ -56,22 +59,27 @@ export async function describePage(root, page, { user, role } = {}) {
|
|
|
56
59
|
return `PAGE ${page} (user ${user ?? "none"}, role ${role ?? "none"})\n${lines.join("\n")}`;
|
|
57
60
|
}
|
|
58
61
|
|
|
62
|
+
const RULES = `- Use only the components, data sources and ops listed below; refs include the version (name@version).
|
|
63
|
+
- Respect types: a component bound to data must accept the type the source returns. Use only params listed for a source.
|
|
64
|
+
- Respect the role: never use a data source that is not listed (the role cannot read it).`;
|
|
65
|
+
const GRID_RULE = "- A grid with areas must place every child exactly once";
|
|
66
|
+
const reply = shape => `Answer ONLY with a JSON object, no prose:
|
|
67
|
+
${shape}
|
|
68
|
+
or, when the request cannot be satisfied with this catalog or this user's role:
|
|
69
|
+
{ "refuse": string }`;
|
|
70
|
+
const LANG = (...f) => `- Write ${f.map(x => `"${x}"`).join(", ")} (and "refuse") in the user's language.`;
|
|
71
|
+
|
|
59
72
|
export async function buildAgentPrompt(root, { page, user, role, request }) {
|
|
60
73
|
const project = await loadProject(root);
|
|
61
74
|
const system = `You modify a declarative UI page for one user by writing override patches.
|
|
62
|
-
|
|
63
|
-
{ "why": string, "patches": [ ...override ops... ] }
|
|
64
|
-
or, when the request cannot be satisfied with this catalog or this user's role:
|
|
65
|
-
{ "refuse": string }
|
|
75
|
+
${reply('{ "why": string, "patches": [ ...override ops... ] }')}
|
|
66
76
|
|
|
67
77
|
RULES
|
|
68
|
-
|
|
69
|
-
- Respect types: a component bound to data must accept the type the source returns. Use only params listed for a source.
|
|
70
|
-
- Respect the role: never use a data source that is not listed (the role cannot read it).
|
|
78
|
+
${RULES}
|
|
71
79
|
- Targets are existing child names or component names from the current page.
|
|
72
|
-
|
|
80
|
+
${GRID_RULE}: when you insert, remove or replace grid children, update areas too.
|
|
73
81
|
- Keep patches minimal: change only what the request asks for. Patches are appended to the user's existing ones.
|
|
74
|
-
|
|
82
|
+
${LANG("why")}
|
|
75
83
|
|
|
76
84
|
${describeCatalog(project, { role })}
|
|
77
85
|
|
|
@@ -79,6 +87,28 @@ CURRENT ${await describePage(root, page, { user, role })}`;
|
|
|
79
87
|
return { system, user: request };
|
|
80
88
|
}
|
|
81
89
|
|
|
90
|
+
export const ANSWER_SKELETON = { ref: "layout/stack@1.0.0", children: [] };
|
|
91
|
+
|
|
92
|
+
export async function buildAskPrompt(root, { user, role, request }) {
|
|
93
|
+
const project = await loadProject(root);
|
|
94
|
+
const system = `You answer a user's question by composing a brand-new UI page from a catalog of components and data sources.
|
|
95
|
+
${reply('{ "title": string, "why": string, "patches": [ ...insert ops only... ] }')}
|
|
96
|
+
|
|
97
|
+
RULES
|
|
98
|
+
${RULES}
|
|
99
|
+
- Use ONLY "insert" ops, onto the page's root layout/stack (target "layout/stack") or onto a layout/grid or layout/card you inserted.
|
|
100
|
+
- Add a layout/card (with a title) or a layout/grid to group widgets as needed. To fill a grid, insert it with named children and areas in one node.
|
|
101
|
+
${GRID_RULE}.
|
|
102
|
+
- "title" names the page in a few words; "why" says briefly how the page answers the question.
|
|
103
|
+
${LANG("title", "why")}
|
|
104
|
+
|
|
105
|
+
${describeCatalog(project, { role })}
|
|
106
|
+
|
|
107
|
+
CURRENT PAGE answer (empty, user ${user ?? "none"}, role ${role ?? "none"})
|
|
108
|
+
layout/stack: layout/stack@1.0.0`;
|
|
109
|
+
return { system, user: request };
|
|
110
|
+
}
|
|
111
|
+
|
|
82
112
|
export function parseAgentReply(text) {
|
|
83
113
|
const s = String(text ?? "");
|
|
84
114
|
const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(s);
|
|
@@ -121,3 +151,70 @@ export async function runAgent(root, { page, user, role, request, complete }) {
|
|
|
121
151
|
reply = undefined; errors = [];
|
|
122
152
|
}
|
|
123
153
|
}
|
|
154
|
+
|
|
155
|
+
// Pages as answers: compose a page in memory; nothing is written until keepAnswer or pinAnswer.
|
|
156
|
+
export async function runAsk(root, { user, role, request, complete }) {
|
|
157
|
+
const prompt = await buildAskPrompt(root, { user, role, request });
|
|
158
|
+
let message = prompt.user, reply, errors = [];
|
|
159
|
+
for (let attempts = 1; attempts <= 2; attempts++) {
|
|
160
|
+
const text = await complete({ system: prompt.system, user: message });
|
|
161
|
+
try { reply = parseAgentReply(text); } catch (e) { errors = [e.message]; }
|
|
162
|
+
if (reply?.refuse) return { ok: false, refuse: reply.refuse, reply, attempts };
|
|
163
|
+
if (reply && !errors.length) {
|
|
164
|
+
if (!Array.isArray(reply.patches)) errors = ["reply has no patches array"];
|
|
165
|
+
else if (reply.patches.some(p => p?.op !== "insert")) errors = ["only insert ops are allowed"];
|
|
166
|
+
else {
|
|
167
|
+
const r = applyOverride(ANSWER_SKELETON, { name: "answer", patches: reply.patches });
|
|
168
|
+
const resolved = await resolve(root, "answer", { user, role, tree: r.tree });
|
|
169
|
+
errors = [...r.errors, ...resolved.errors].map(e => `${e.path}: ${e.message}`);
|
|
170
|
+
if (!errors.length) return { ok: true, title: reply.title ?? "Answer", why: reply.why, tree: r.tree, resolved: { spec: resolved.spec, errors: resolved.errors }, attempts };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (attempts === 2) return { ok: false, errors, reply, attempts };
|
|
174
|
+
message = `${prompt.user}\n\nThe previous page was rejected: ${errors.join("; ")}. Fix it.`;
|
|
175
|
+
reply = undefined; errors = [];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const slugify = s => String(s ?? "").normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "answer";
|
|
180
|
+
|
|
181
|
+
export async function keepAnswer(root, { user, role, title, tree, why }) {
|
|
182
|
+
const project = await loadProject(root);
|
|
183
|
+
const errors = validateTree(tree, project, { role: role ?? null });
|
|
184
|
+
if (errors.length) return { ok: false, errors: errors.map(e => `${e.path}: ${e.message}`) };
|
|
185
|
+
const taken = new Set([...(await listPages(root)), ...(project.manifest.pages ?? [])]);
|
|
186
|
+
const base = slugify(title); let slug = base;
|
|
187
|
+
for (let n = 2; taken.has(slug); n++) slug = `${base}-${n}`;
|
|
188
|
+
await writeFile(join(root, "pages", `${slug}.yaml`), stringify({ page: slug, title, owner: user, ...(why ? { why } : {}), layout: tree }));
|
|
189
|
+
const mfile = join(root, "manifest.yaml");
|
|
190
|
+
const manifest = (await loadYaml(mfile)) ?? {};
|
|
191
|
+
await writeFile(mfile, stringify({ ...manifest, pages: [...(manifest.pages ?? []), slug] }));
|
|
192
|
+
return { ok: true, page: slug };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// The home's main container: the root, or the single grid it wraps.
|
|
196
|
+
function mainContainer(tree) {
|
|
197
|
+
let node = tree;
|
|
198
|
+
while (Array.isArray(node.children) && node.children.length === 1 && node.children[0]?.children && !Array.isArray(node.children[0].children)) node = node.children[0];
|
|
199
|
+
return node;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function pinAnswer(root, { user, role, page, title, tree, why }) {
|
|
203
|
+
if (!user) return { ok: false, errors: ["user is required"] };
|
|
204
|
+
page ??= (await loadProject(root)).manifest.home;
|
|
205
|
+
const name = `user-${user}`;
|
|
206
|
+
const file = join(root, "overrides", `${name}.yaml`);
|
|
207
|
+
const existing = await exists(file) ? (await loadYaml(file)) ?? {} : {};
|
|
208
|
+
const base = existing.extends === page ? existing.patches ?? [] : [];
|
|
209
|
+
const target = mainContainer(await currentTree(root, page, { user, role }));
|
|
210
|
+
const card = { ref: "layout/card@1.0.0", props: { title: title ?? "Answer" }, children: structuredClone(tree.children ?? []) };
|
|
211
|
+
const insert = { op: "insert", target: splitRef(target.ref)[0], node: card };
|
|
212
|
+
if (target.children && !Array.isArray(target.children)) {
|
|
213
|
+
let n = 1; while (`answer-${n}` in target.children) n++;
|
|
214
|
+
insert.name = `answer-${n}`;
|
|
215
|
+
const areas = String(target.props?.areas ?? "").trimEnd();
|
|
216
|
+
const cols = areas ? areas.split("\n")[0].trim().split(/\s+/).length : 1;
|
|
217
|
+
insert.area = `${areas ? areas + "\n" : ""}${Array(cols).fill(insert.name).join(" ")}\n`;
|
|
218
|
+
}
|
|
219
|
+
return propose(root, { name, author: user, role, override: { extends: page, why: why ?? `pinned: ${title ?? "answer"}`, patches: mergePatch(base, insert) } });
|
|
220
|
+
}
|
package/src/dev.js
CHANGED
|
@@ -9,7 +9,7 @@ import { loadProject, loadYaml, listPages } from "./loader.js";
|
|
|
9
9
|
import { commitOverride, listHistory, rollback } from "./history.js";
|
|
10
10
|
import { propose, listPending, approve, reject } from "./proposals.js";
|
|
11
11
|
import { tokensToCss } from "./tokens.js";
|
|
12
|
-
import { buildAgentPrompt, runAgent } from "./agent.js";
|
|
12
|
+
import { buildAgentPrompt, runAgent, runAsk, keepAnswer, pinAnswer } from "./agent.js";
|
|
13
13
|
import { anthropicComplete } from "./llm.js";
|
|
14
14
|
|
|
15
15
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -62,6 +62,26 @@ function App() {
|
|
|
62
62
|
else setAgent({ error: true, text: r.refuse ?? (r.errors ?? [r.error ?? "agent failed"]).join("; ") });
|
|
63
63
|
} catch (err) { setAgent({ error: true, text: err.message }); }
|
|
64
64
|
};
|
|
65
|
+
const [question, setQuestion] = useState(""), [answer, setAnswer] = useState(null), [answerStatus, setAnswerStatus] = useState(null);
|
|
66
|
+
const post = (path, b) => fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(b) }).then(r => r.json());
|
|
67
|
+
const sendQuestion = async e => {
|
|
68
|
+
e.preventDefault(); if (!question.trim()) return;
|
|
69
|
+
setAnswer(null); setAnswerStatus({ text: "composing…" });
|
|
70
|
+
try {
|
|
71
|
+
const r = await post("/api/ask", { user, role: role || undefined, request: question });
|
|
72
|
+
if (r.ok) { setAnswer(r); setAnswerStatus(null); }
|
|
73
|
+
else setAnswerStatus({ error: true, text: r.refuse ?? (r.errors ?? [r.error ?? "ask failed"]).join("; ") });
|
|
74
|
+
} catch (err) { setAnswerStatus({ error: true, text: err.message }); }
|
|
75
|
+
};
|
|
76
|
+
const answerBody = () => ({ user, role: role || undefined, title: answer.title, tree: answer.tree, why: answer.why });
|
|
77
|
+
const keep = async () => {
|
|
78
|
+
const r = await post("/api/ask/keep", answerBody());
|
|
79
|
+
if (r.ok) { setAnswer(null); setAnswerStatus(null); setPage(r.page); } else setAnswerStatus({ error: true, text: (r.errors ?? [r.error]).join("; ") });
|
|
80
|
+
};
|
|
81
|
+
const pin = async () => {
|
|
82
|
+
const r = await post("/api/ask/pin", answerBody());
|
|
83
|
+
setAnswerStatus(r.ok ? { text: r.pending ? "sent for approval" : "applied to home" } : { error: true, text: (r.errors ?? [r.error]).join("; ") });
|
|
84
|
+
};
|
|
65
85
|
useEffect(() => { const es = new EventSource("/events"); es.onmessage = () => location.reload(); return () => es.close(); }, []);
|
|
66
86
|
if (!state) return null;
|
|
67
87
|
const pages = state.manifest.pages, roles = state.manifest.roles ?? [];
|
|
@@ -75,11 +95,18 @@ function App() {
|
|
|
75
95
|
{editor.status === "saving" && <span>saving…</span>}
|
|
76
96
|
{editor.status === "pending" && <span>sent for approval</span>}
|
|
77
97
|
{editor.status === "error" && <span style={{ color: "var(--pt-color-danger)" }}>{editor.errors.join("; ")}</span>}
|
|
78
|
-
<form onSubmit={sendAsk} style={{ display: "flex", gap: 6 }}><input value={ask} placeholder="
|
|
98
|
+
<form onSubmit={sendAsk} style={{ display: "flex", gap: 6 }}><input value={ask} placeholder="Change this page…" onChange={e => setAsk(e.target.value)} style={{ width: 220 }} /><button type="submit">Ask</button></form>
|
|
99
|
+
<form onSubmit={sendQuestion} style={{ display: "flex", gap: 6 }}><input value={question} placeholder="Ask…" onChange={e => setQuestion(e.target.value)} style={{ width: 220 }} /><button type="submit">Ask</button></form>
|
|
79
100
|
{agent && <span style={{ color: agent.error ? "var(--pt-color-danger)" : undefined }}>{agent.text}</span>}
|
|
80
101
|
{state.errors.length > 0 && <span style={{ color: "var(--pt-color-danger)" }}>{state.errors.length} problem(s), see console</span>}
|
|
81
102
|
</header>
|
|
82
|
-
<
|
|
103
|
+
{(answer || answerStatus) && <div style={{ display: "flex", gap: 12, alignItems: "center", padding: "8px 16px", borderBottom: "1px solid var(--pt-color-border)", fontSize: 13 }}>
|
|
104
|
+
{answer && <><strong>{answer.title}</strong><span>{answer.why}</span><button onClick={keep}>Keep as page</button><button onClick={pin}>Pin to home</button><button onClick={() => { setAnswer(null); setAnswerStatus(null); }}>Back</button></>}
|
|
105
|
+
{answerStatus && <span style={{ color: answerStatus.error ? "var(--pt-color-danger)" : undefined }}>{answerStatus.text}</span>}
|
|
106
|
+
</div>}
|
|
107
|
+
<main style={{ padding: 16, maxWidth: 1100, margin: "0 auto" }}>{answer
|
|
108
|
+
? <Page resolved={{ ...state, spec: answer.resolved.spec, errors: answer.resolved.errors }} registry={registry} />
|
|
109
|
+
: <EditorContext.Provider value={{ editing: editor.editing, propose: editor.propose }}><Page resolved={state} registry={registry} /></EditorContext.Provider>}</main>
|
|
83
110
|
</>;
|
|
84
111
|
}
|
|
85
112
|
createRoot(document.getElementById("root")).render(<App />);`;
|
|
@@ -144,6 +171,15 @@ createRoot(document.getElementById("root")).render(<App />);`;
|
|
|
144
171
|
const r = await runAgent(root, { page: b.page, user: b.user, role: b.role || undefined, request: b.request, complete });
|
|
145
172
|
return json(res, r.ok ? 200 : 400, r);
|
|
146
173
|
}
|
|
174
|
+
if (url.pathname === "/api/ask" && req.method === "POST") {
|
|
175
|
+
if (!process.env.ANTHROPIC_API_KEY) return json(res, 503, { error: "set ANTHROPIC_API_KEY to use the agent" });
|
|
176
|
+
const b = await body(req);
|
|
177
|
+
const complete = anthropicComplete({ apiKey: process.env.ANTHROPIC_API_KEY, ...(process.env.PAGETAILOR_MODEL ? { model: process.env.PAGETAILOR_MODEL } : {}) });
|
|
178
|
+
const r = await runAsk(root, { user: b.user || undefined, role: b.role || undefined, request: b.request, complete });
|
|
179
|
+
return json(res, r.ok ? 200 : 400, r);
|
|
180
|
+
}
|
|
181
|
+
const answer = { "/api/ask/keep": keepAnswer, "/api/ask/pin": pinAnswer }[url.pathname];
|
|
182
|
+
if (answer && req.method === "POST") { const b = await body(req); const r = await answer(root, { ...b, user: b.user || undefined, role: b.role || undefined }); return json(res, r.ok ? 200 : 400, r); }
|
|
147
183
|
if (url.pathname === "/api/pending") return json(res, 200, await listPending(root));
|
|
148
184
|
const flow = { "/api/propose": propose, "/api/approve": approve, "/api/reject": reject }[url.pathname];
|
|
149
185
|
if (flow && req.method === "POST") { const r = await flow(root, await body(req)); return json(res, r.ok ? 200 : 400, r); }
|
package/src/index.js
CHANGED
|
@@ -14,5 +14,5 @@ export async function dev(...args) {
|
|
|
14
14
|
}
|
|
15
15
|
export { writeRegistryFile } from "./registry-file.js";
|
|
16
16
|
export { tokensToCss } from "./tokens.js";
|
|
17
|
-
export { describeCatalog, describePage, buildAgentPrompt, parseAgentReply, runAgent } from "./agent.js";
|
|
17
|
+
export { describeCatalog, describePage, buildAgentPrompt, parseAgentReply, runAgent, buildAskPrompt, runAsk, keepAnswer, pinAnswer } from "./agent.js";
|
|
18
18
|
export { anthropicComplete } from "./llm.js";
|
package/src/resolver.js
CHANGED
|
@@ -14,7 +14,9 @@ const fingerprint = (root, page, user, role) => Promise.all([
|
|
|
14
14
|
].map(f => mtime(join(root, f)))).then(a => Math.max(...a));
|
|
15
15
|
|
|
16
16
|
// YAML page + overrides + data -> json-render flat spec { root, elements, state }
|
|
17
|
-
|
|
17
|
+
// opts.tree: an in-memory layout (e.g. an agent answer), no page file, no overrides, no cache
|
|
18
|
+
export async function resolve(root, page, { user, role, sources, tree, fresh = false, maxAge = 0 } = {}) {
|
|
19
|
+
if (tree) return build(root, page, { user, role, sources, tree });
|
|
18
20
|
const key = `${root}|${page}|${user}|${role}`, fp = await fingerprint(root, page, user, role);
|
|
19
21
|
const hit = cache.get(key);
|
|
20
22
|
if (!fresh && hit && hit.fp === fp && (!maxAge || Date.now() - hit.at < maxAge)) return hit.result;
|
|
@@ -23,12 +25,12 @@ export async function resolve(root, page, { user, role, sources, fresh = false,
|
|
|
23
25
|
return result;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
async function build(root, page, { user, role, sources }) {
|
|
28
|
+
async function build(root, page, { user, role, sources, tree: given }) {
|
|
27
29
|
const project = await loadProject(root);
|
|
28
30
|
const fetchers = sources ?? await loadSources(root);
|
|
29
|
-
let tree = (await loadPage(root, page)).layout;
|
|
31
|
+
let tree = given ?? (await loadPage(root, page)).layout;
|
|
30
32
|
const errors = [];
|
|
31
|
-
for (const ov of await loadOverrides(root, page, user, role)) {
|
|
33
|
+
if (!given) for (const ov of await loadOverrides(root, page, user, role)) {
|
|
32
34
|
const r = applyOverride(tree, ov); tree = r.tree; errors.push(...r.errors);
|
|
33
35
|
}
|
|
34
36
|
errors.push(...validateTree(tree, project, { role: role ?? null }));
|