webcake-landing-mcp 1.0.47 → 1.0.49
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 +3 -3
- package/dist/changelog.json +14 -14
- package/dist/core/compact.js +158 -0
- package/dist/domains/landing/elements/content.js +9 -12
- package/dist/domains/landing/elements/form.js +6 -8
- package/dist/domains/landing/elements/layout.js +8 -8
- package/dist/domains/landing/guide.js +2 -2
- package/dist/domains/landing/index.js +9 -0
- package/dist/domains/landing/instructions.js +5 -2
- package/dist/persistence/draft-cache.js +58 -0
- package/dist/smoke.js +34 -2
- package/dist/tools/generation.js +6 -2
- package/dist/tools/persistence.js +129 -35
- package/dist/tools/reference.js +6 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -443,13 +443,13 @@ Both `create_page` and `update_page` **default to `dry_run=true`** (validate and
|
|
|
443
443
|
|------|-------------|
|
|
444
444
|
| `get_generation_guide` | **Read FIRST.** Output shape, coordinate system, event vocabulary, workflow. |
|
|
445
445
|
| `list_elements` | All element types by category (summary + when-to-use + container?). |
|
|
446
|
-
| `get_element` | One type: hints, key `specials`,
|
|
446
|
+
| `get_element` | One type (or many at once): hints, key `specials`, a SPARSE skeleton (the exact shape to emit — the server hydrates omitted boilerplate), filled example. |
|
|
447
447
|
| `get_page_schema` | Full JSON Schema (Draft 2020-12) of a page source. |
|
|
448
448
|
|
|
449
449
|
### Generation
|
|
450
450
|
| Tool | Description |
|
|
451
451
|
|------|-------------|
|
|
452
|
-
| `new_element` | A
|
|
452
|
+
| `new_element` | A default node for a type (fresh id) in the SPARSE authoring shape — copy it as-is; omitted boilerplate is hydrated server-side. |
|
|
453
453
|
| `new_page_skeleton` | An empty but complete top-level source `{ page, popup, settings, options, cartConfigs }`. |
|
|
454
454
|
| `validate_page` | Structural + semantic validation (ids, event targets, containers, `field_name`). |
|
|
455
455
|
|
|
@@ -465,7 +465,7 @@ Both `create_page` and `update_page` **default to `dry_run=true`** (validate and
|
|
|
465
465
|
| `create_page` | Persist a generated source as a new page (source-only). **Defaults to `dry_run=true`.** |
|
|
466
466
|
| `list_pages` | List the account's pages (id, name, organization_id, updated_at) to pick one to edit. |
|
|
467
467
|
| `find_pages` | Search the account's pages by name, domain, and/or page id (AND-combined) to locate one to edit; returns id, name, org, custom/default domain, updated_at. |
|
|
468
|
-
| `get_page` | Fetch an existing page's decoded source tree
|
|
468
|
+
| `get_page` | Fetch an existing page's decoded source tree, COMPACTED to the sparse authoring shape (factory-default boilerplate stripped — far fewer tokens; `compact:false` for the raw tree). Edit and send back as-is. |
|
|
469
469
|
| `update_page` | Overwrite an existing page's source with an edited tree. **Defaults to `dry_run=true`.** |
|
|
470
470
|
|
|
471
471
|
---
|
package/dist/changelog.json
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "1.0.49",
|
|
4
|
+
"d": "10/06/2026",
|
|
5
|
+
"type": "Changed",
|
|
6
|
+
"en": "get_page now returns a compacted source by default: factory-default boilerplate (properties, runtime, empty events/children, per-breakpoint config,…",
|
|
7
|
+
"vi": "get_page nay trả về source đã compacted theo mặc định: các boilerplate theo factory-default (properties, runtime, events/children rỗng, config theo…"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"v": "1.0.48",
|
|
11
|
+
"d": "10/06/2026",
|
|
12
|
+
"type": "Added",
|
|
13
|
+
"en": "create_page now caches the expanded source in an in-memory draft store when validation fails and returns a draft_id alongside the validation errors,…",
|
|
14
|
+
"vi": "create_page nay lưu source đã expand vào bộ nhớ draft khi xác thực thất bại và trả về draft_id kèm theo danh sách lỗi, cho phép agent chỉ sửa các…"
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"v": "1.0.47",
|
|
4
18
|
"d": "10/06/2026",
|
|
@@ -26,19 +40,5 @@
|
|
|
26
40
|
"type": "Added",
|
|
27
41
|
"en": "New find_pages tool searches the account's pages by name, domain (matches custom_domain or default_domain), and/or page id (filters are…",
|
|
28
42
|
"vi": "Công cụ find_pages mới tìm kiếm các trang trong tài khoản theo tên, domain (khớp với custom_domain hoặc default_domain), và/hoặc page id (các bộ lọc…"
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
"v": "1.0.43",
|
|
32
|
-
"d": "09/06/2026",
|
|
33
|
-
"type": "Changed",
|
|
34
|
-
"en": "The GET / web guide page has refreshed copy throughout: updated page title and meta description, simplified FAQ answers in both English and…",
|
|
35
|
-
"vi": "Trang hướng dẫn GET / được làm mới toàn bộ nội dung: cập nhật tiêu đề trang và meta description, đơn giản hóa câu trả lời FAQ bằng cả tiếng Anh và…"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"v": "1.0.42",
|
|
39
|
-
"d": "09/06/2026",
|
|
40
|
-
"type": "Fixed",
|
|
41
|
-
"en": "validate_page now raises an error when a countdown element's specials.language is set to a value outside the eight supported word-values (vietnam,…",
|
|
42
|
-
"vi": "validate_page nay phát lỗi khi element countdown có specials.language là giá trị nằm ngoài tám word-value được hỗ trợ (vietnam, english, filipino,…"
|
|
43
43
|
}
|
|
44
44
|
]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full-node compaction (the inverse of ./expand.ts).
|
|
3
|
+
*
|
|
4
|
+
* `compactNode` strips from a FULL element node everything that `expandNode`
|
|
5
|
+
* would re-create identically from the type's factory default: `properties`
|
|
6
|
+
* keys equal to the seed (movable/sync/default name), `runtime` when it equals
|
|
7
|
+
* the seed's, empty `events`, empty seed-equal `children`, and each
|
|
8
|
+
* breakpoint's `config`/`styles` keys whose values match the seed (notloaded +
|
|
9
|
+
* the default animation block). What remains is the SPARSE authoring shape the
|
|
10
|
+
* model is asked to emit — so `get_page` can return a compacted tree and the
|
|
11
|
+
* model sees (and learns to write) sparse nodes instead of boilerplate.
|
|
12
|
+
*
|
|
13
|
+
* Invariant (smoke-tested): expand(compact(x)) persists the SAME tree as
|
|
14
|
+
* expand(x). Compaction only removes data the expansion seed restores.
|
|
15
|
+
* Unknown types and non-element values pass through untouched.
|
|
16
|
+
*/
|
|
17
|
+
import { base } from "./element.js";
|
|
18
|
+
const STD_KEYS = ["id", "type", "properties", "specials", "runtime", "events", "responsive", "children"];
|
|
19
|
+
const isObj = (v) => v != null && typeof v === "object" && !Array.isArray(v);
|
|
20
|
+
/** JSON-ish deep equality (objects, arrays, primitives — no cycles). */
|
|
21
|
+
export function deepEq(a, b) {
|
|
22
|
+
if (a === b)
|
|
23
|
+
return true;
|
|
24
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
25
|
+
return a.length === b.length && a.every((v, i) => deepEq(v, b[i]));
|
|
26
|
+
}
|
|
27
|
+
if (isObj(a) && isObj(b)) {
|
|
28
|
+
const ka = Object.keys(a);
|
|
29
|
+
const kb = Object.keys(b);
|
|
30
|
+
return ka.length === kb.length && ka.every((k) => deepEq(a[k], b[k]));
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
/** Keys of `input` whose values differ from `seed`'s; undefined when nothing differs. */
|
|
35
|
+
function diffShallow(input, seed) {
|
|
36
|
+
if (!isObj(input))
|
|
37
|
+
return undefined;
|
|
38
|
+
const out = {};
|
|
39
|
+
const seedObj = isObj(seed) ? seed : {};
|
|
40
|
+
for (const k of Object.keys(input)) {
|
|
41
|
+
if (!deepEq(input[k], seedObj[k]))
|
|
42
|
+
out[k] = input[k];
|
|
43
|
+
}
|
|
44
|
+
return Object.keys(out).length ? out : undefined;
|
|
45
|
+
}
|
|
46
|
+
/** Strip from ONE (full) element node everything its factory seed re-creates. */
|
|
47
|
+
export function compactNode(input, createElement) {
|
|
48
|
+
if (!isObj(input))
|
|
49
|
+
return input;
|
|
50
|
+
const type = input.type;
|
|
51
|
+
if (typeof type !== "string" || type === "")
|
|
52
|
+
return input;
|
|
53
|
+
// Seed with the DEFAULT name (no override): a custom properties.name must
|
|
54
|
+
// survive the diff, because expandNode re-seeds from input.properties.name.
|
|
55
|
+
let seed;
|
|
56
|
+
try {
|
|
57
|
+
seed = createElement(type);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return input;
|
|
61
|
+
}
|
|
62
|
+
const out = {};
|
|
63
|
+
if (typeof input.id === "string")
|
|
64
|
+
out.id = input.id;
|
|
65
|
+
out.type = type;
|
|
66
|
+
const props = diffShallow(input.properties, seed.properties);
|
|
67
|
+
if (props)
|
|
68
|
+
out.properties = props;
|
|
69
|
+
const specials = diffShallow(input.specials, seed.specials);
|
|
70
|
+
if (specials)
|
|
71
|
+
out.specials = specials;
|
|
72
|
+
// runtime/events are replaced WHOLESALE by expandNode when provided, so they
|
|
73
|
+
// can only be dropped when they equal the seed's (typically {} / []).
|
|
74
|
+
if (isObj(input.runtime) && !deepEq(input.runtime, seed.runtime))
|
|
75
|
+
out.runtime = input.runtime;
|
|
76
|
+
if (Array.isArray(input.events) && !deepEq(input.events, seed.events))
|
|
77
|
+
out.events = input.events;
|
|
78
|
+
const responsive = {};
|
|
79
|
+
for (const bp of ["desktop", "mobile"]) {
|
|
80
|
+
const inBp = isObj(input.responsive) ? input.responsive[bp] : undefined;
|
|
81
|
+
if (!isObj(inBp))
|
|
82
|
+
continue; // absent → expand restores the seed breakpoint
|
|
83
|
+
const seedBp = seed.responsive[bp];
|
|
84
|
+
const bpOut = {};
|
|
85
|
+
const cfg = diffShallow(inBp.config, seedBp.config);
|
|
86
|
+
if (cfg)
|
|
87
|
+
bpOut.config = cfg;
|
|
88
|
+
const sty = diffShallow(inBp.styles, seedBp.styles);
|
|
89
|
+
if (sty)
|
|
90
|
+
bpOut.styles = sty;
|
|
91
|
+
for (const k of Object.keys(inBp)) {
|
|
92
|
+
if (k === "config" || k === "styles")
|
|
93
|
+
continue;
|
|
94
|
+
if (!deepEq(inBp[k], seedBp[k]))
|
|
95
|
+
bpOut[k] = inBp[k];
|
|
96
|
+
}
|
|
97
|
+
if (Object.keys(bpOut).length)
|
|
98
|
+
responsive[bp] = bpOut;
|
|
99
|
+
}
|
|
100
|
+
if (Object.keys(responsive).length)
|
|
101
|
+
out.responsive = responsive;
|
|
102
|
+
// children: expandNode falls back to seed.children when omitted, so a
|
|
103
|
+
// seed-equal children array (e.g. an empty []) can be dropped entirely.
|
|
104
|
+
if (Array.isArray(input.children) && !deepEq(input.children, seed.children)) {
|
|
105
|
+
out.children = input.children.map((c) => compactNode(c, createElement));
|
|
106
|
+
}
|
|
107
|
+
// carry over any non-standard keys (expandNode round-trips them too).
|
|
108
|
+
for (const k of Object.keys(input)) {
|
|
109
|
+
if (!STD_KEYS.includes(k))
|
|
110
|
+
out[k] = input[k];
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The sparse AUTHORING TEMPLATE of a factory node — what get_element/new_element
|
|
116
|
+
* hand the model to copy. Unlike `compactNode` (which diffs against the type's
|
|
117
|
+
* own seed and so reduces a fresh factory node to nothing), this KEEPS the
|
|
118
|
+
* seeded meaningful values — styles, specials, non-default config, children —
|
|
119
|
+
* and drops only the boilerplate the server hydrates on persist: `properties`,
|
|
120
|
+
* `runtime`, empty `events`, and each breakpoint's base config (notloaded +
|
|
121
|
+
* the default animation block). Both breakpoints stay visible: the model must
|
|
122
|
+
* always provide desktop AND mobile styles.
|
|
123
|
+
*/
|
|
124
|
+
export function sparseTemplate(node) {
|
|
125
|
+
const blank = base();
|
|
126
|
+
const out = { id: node.id, type: node.type };
|
|
127
|
+
if (isObj(node.specials) && Object.keys(node.specials).length)
|
|
128
|
+
out.specials = node.specials;
|
|
129
|
+
const responsive = {};
|
|
130
|
+
for (const bp of ["desktop", "mobile"]) {
|
|
131
|
+
const nBp = node.responsive[bp];
|
|
132
|
+
const bpOut = {};
|
|
133
|
+
const cfg = diffShallow(nBp?.config, blank.responsive[bp].config);
|
|
134
|
+
if (cfg)
|
|
135
|
+
bpOut.config = cfg;
|
|
136
|
+
bpOut.styles = isObj(nBp?.styles) ? nBp.styles : {};
|
|
137
|
+
responsive[bp] = bpOut;
|
|
138
|
+
}
|
|
139
|
+
out.responsive = responsive;
|
|
140
|
+
if (Array.isArray(node.events) && node.events.length)
|
|
141
|
+
out.events = node.events;
|
|
142
|
+
if (Array.isArray(node.children))
|
|
143
|
+
out.children = node.children.map(sparseTemplate);
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
/** Compact every node in a page source ({ page, popup, dynamic_pages }). */
|
|
147
|
+
export function compactSource(source, createElement) {
|
|
148
|
+
if (!isObj(source))
|
|
149
|
+
return source;
|
|
150
|
+
const out = { ...source };
|
|
151
|
+
if (Array.isArray(source.page))
|
|
152
|
+
out.page = source.page.map((s) => compactNode(s, createElement));
|
|
153
|
+
if (Array.isArray(source.popup))
|
|
154
|
+
out.popup = source.popup.map((p) => compactNode(p, createElement));
|
|
155
|
+
if (Array.isArray(source.dynamic_pages))
|
|
156
|
+
out.dynamic_pages = source.dynamic_pages.map((p) => compactNode(p, createElement));
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
@@ -22,15 +22,15 @@ export const CONTENT = [
|
|
|
22
22
|
el.specials.text = "hello world";
|
|
23
23
|
el.specials.tag = "p";
|
|
24
24
|
},
|
|
25
|
+
// Examples are in the SPARSE authoring shape — the server hydrates
|
|
26
|
+
// properties/runtime/events/config from factory defaults on validate/persist.
|
|
25
27
|
example: {
|
|
26
28
|
id: "headline1", type: "text-block",
|
|
27
|
-
properties: { name: "Headline", movable: true, sync: true },
|
|
28
29
|
responsive: {
|
|
29
|
-
desktop: {
|
|
30
|
-
mobile: {
|
|
30
|
+
desktop: { styles: { top: 80, left: 180, width: 600, fontSize: 44, fontWeight: "bold", color: "rgba(26,32,44,1)", textAlign: "center" } },
|
|
31
|
+
mobile: { styles: { top: 60, left: 20, width: 380, fontSize: 28, fontWeight: "bold", color: "rgba(26,32,44,1)", textAlign: "center" } },
|
|
31
32
|
},
|
|
32
33
|
specials: { text: "Bán hàng dễ hơn với Webcake", tag: "h1" },
|
|
33
|
-
runtime: {}, events: [],
|
|
34
34
|
},
|
|
35
35
|
},
|
|
36
36
|
{
|
|
@@ -67,13 +67,11 @@ export const CONTENT = [
|
|
|
67
67
|
},
|
|
68
68
|
example: {
|
|
69
69
|
id: "hero_img", type: "image-block",
|
|
70
|
-
properties: { name: "Image Block", movable: true, sync: true },
|
|
71
70
|
responsive: {
|
|
72
|
-
desktop: {
|
|
73
|
-
mobile: {
|
|
71
|
+
desktop: { styles: { top: 40, left: 540, width: 360, height: 300, position: "absolute" } },
|
|
72
|
+
mobile: { styles: { top: 260, left: 60, width: 300, height: 240, position: "absolute" } },
|
|
74
73
|
},
|
|
75
74
|
specials: { src: "https://placehold.co/360x300?text=Product", imageCompression: true },
|
|
76
|
-
runtime: {}, events: [],
|
|
77
75
|
},
|
|
78
76
|
},
|
|
79
77
|
{
|
|
@@ -117,12 +115,11 @@ export const CONTENT = [
|
|
|
117
115
|
},
|
|
118
116
|
example: {
|
|
119
117
|
id: "cta_main", type: "button",
|
|
120
|
-
properties: { name: "CTA", movable: true, sync: true },
|
|
121
118
|
responsive: {
|
|
122
|
-
desktop: {
|
|
123
|
-
mobile: {
|
|
119
|
+
desktop: { styles: { top: 300, left: 405, width: 150, height: 44, background: "rgba(246,4,87,1)", color: "rgba(255,255,255,1)", borderRadius: "8px", textAlign: "center", fontWeight: "bold" } },
|
|
120
|
+
mobile: { styles: { top: 200, left: 135, width: 150, height: 44, background: "rgba(246,4,87,1)", color: "rgba(255,255,255,1)", borderRadius: "8px", textAlign: "center", fontWeight: "bold" } },
|
|
124
121
|
},
|
|
125
|
-
specials: { text: "Đăng ký ngay" },
|
|
122
|
+
specials: { text: "Đăng ký ngay" },
|
|
126
123
|
events: [{ id: "ev_cta", type: "click", action: "scroll_to", target: "form_section" }],
|
|
127
124
|
},
|
|
128
125
|
},
|
|
@@ -77,15 +77,15 @@ export const FORM = [
|
|
|
77
77
|
setBox(el, 150, 36);
|
|
78
78
|
el.specials.field_name = `input_${el.id}`;
|
|
79
79
|
},
|
|
80
|
+
// Examples are in the SPARSE authoring shape — the server hydrates
|
|
81
|
+
// properties/runtime/events/config from factory defaults on validate/persist.
|
|
80
82
|
example: {
|
|
81
83
|
id: "in_phone", type: "input",
|
|
82
|
-
properties: { name: "Input", movable: true, sync: true },
|
|
83
84
|
responsive: {
|
|
84
|
-
desktop: {
|
|
85
|
-
mobile: {
|
|
85
|
+
desktop: { styles: { top: 60, left: 20, width: 360, height: 40 } },
|
|
86
|
+
mobile: { styles: { top: 60, left: 20, width: 360, height: 40 } },
|
|
86
87
|
},
|
|
87
88
|
specials: { field_name: "phone", field_placeholder: "Số điện thoại", field_type: "phone", required: true },
|
|
88
|
-
runtime: {}, events: [],
|
|
89
89
|
},
|
|
90
90
|
},
|
|
91
91
|
{
|
|
@@ -127,10 +127,9 @@ export const FORM = [
|
|
|
127
127
|
},
|
|
128
128
|
example: {
|
|
129
129
|
id: "sel_attend", type: "select",
|
|
130
|
-
properties: { name: "Select", movable: true, sync: true },
|
|
131
130
|
responsive: {
|
|
132
|
-
desktop: {
|
|
133
|
-
mobile: {
|
|
131
|
+
desktop: { styles: { top: 0, left: 0, width: 300, height: 44 } },
|
|
132
|
+
mobile: { styles: { top: 0, left: 0, width: 280, height: 44 } },
|
|
134
133
|
},
|
|
135
134
|
// options use {id, name} — NOT {label, value}. field_placeholder is required.
|
|
136
135
|
specials: {
|
|
@@ -142,7 +141,6 @@ export const FORM = [
|
|
|
142
141
|
{ id: "opt_no", name: "Rất tiếc, tôi không thể đến" },
|
|
143
142
|
],
|
|
144
143
|
},
|
|
145
|
-
runtime: {}, events: [],
|
|
146
144
|
},
|
|
147
145
|
},
|
|
148
146
|
{
|
|
@@ -133,22 +133,22 @@ export const LAYOUT = [
|
|
|
133
133
|
el.properties.movable = false;
|
|
134
134
|
setBox(el, 400, 250);
|
|
135
135
|
},
|
|
136
|
+
// Example is in the SPARSE authoring shape — the server hydrates
|
|
137
|
+
// properties/runtime/events/config from factory defaults on validate/persist.
|
|
136
138
|
example: {
|
|
137
139
|
id: "popthanks", type: "popup",
|
|
138
|
-
properties: { name: "Thank you"
|
|
140
|
+
properties: { name: "Thank you" },
|
|
139
141
|
responsive: {
|
|
140
|
-
desktop: {
|
|
141
|
-
mobile: {
|
|
142
|
+
desktop: { styles: { width: 420, height: 220, background: "rgba(255,255,255,1)", borderRadius: "12px" } },
|
|
143
|
+
mobile: { styles: { width: 360, height: 220, background: "rgba(255,255,255,1)", borderRadius: "12px" } },
|
|
142
144
|
},
|
|
143
|
-
specials: {}, runtime: {}, events: [],
|
|
144
145
|
children: [
|
|
145
146
|
{ id: "popclose", type: "button",
|
|
146
|
-
properties: { name: "Close", movable: true, sync: true },
|
|
147
147
|
responsive: {
|
|
148
|
-
desktop: {
|
|
149
|
-
mobile: {
|
|
148
|
+
desktop: { styles: { top: 150, left: 160, width: 100, height: 40, background: "rgba(76,175,80,1)", color: "rgba(255,255,255,1)", borderRadius: "8px", textAlign: "center" } },
|
|
149
|
+
mobile: { styles: { top: 150, left: 130, width: 100, height: 40, background: "rgba(76,175,80,1)", color: "rgba(255,255,255,1)", borderRadius: "8px", textAlign: "center" } },
|
|
150
150
|
},
|
|
151
|
-
specials: { text: "Đóng" },
|
|
151
|
+
specials: { text: "Đóng" },
|
|
152
152
|
events: [{ id: "ev1", type: "click", action: "close_popup", target: "popthanks" }] },
|
|
153
153
|
],
|
|
154
154
|
},
|
|
@@ -23,7 +23,7 @@ ELEMENT NODE (every element)
|
|
|
23
23
|
"specials": { ...type-specific CONTENT... }, "runtime": {}, "events": [],
|
|
24
24
|
"children": [ ... ] } // children ONLY on container types
|
|
25
25
|
- Cross-cutting config keys apply to EVERY element via the per-breakpoint config (responsive.<bp>.config): sticky/stickyPosition/stickyTop/stickyBottom/stickyLeft/stickyRight/stickyWidth/stickyHeight/stickyUnpinAtSections…, animation, hide, lock. The full per-element specials reference (every renderer-read key, including the rich select/checkbox-group/radio/survey option-object schema) lives in docs/element-specials-reference.md.
|
|
26
|
-
- COMPACT AUTHORING (emit FEWER tokens): the server hydrates each element from its type's factory defaults, so you may OMIT boilerplate — \`properties\`, \`runtime\`, empty \`events\`/\`children\`, and each breakpoint's \`config\` (the default animation). Emit only id, type, the meaningful responsive.<bp>.styles for BOTH breakpoints, specials, and events when present. e.g. { "type":"text-block","id":"h1","responsive":{"desktop":{"styles":{"top":120,"left":80,"width":500,"height":70,"fontSize":48,"color":"rgba(20,30,25,1)"}},"mobile":{"styles":{"top":100,"left":20,"width":380,"height":60,"fontSize":32}}},"specials":{"text":"…"} } hydrates into the full node. A complete node still works.
|
|
26
|
+
- COMPACT AUTHORING (emit FEWER tokens): the server hydrates each element from its type's factory defaults, so you may OMIT boilerplate — \`properties\`, \`runtime\`, empty \`events\`/\`children\`, and each breakpoint's \`config\` (the default animation). Emit only id, type, the meaningful responsive.<bp>.styles for BOTH breakpoints, specials, and events when present. e.g. { "type":"text-block","id":"h1","responsive":{"desktop":{"styles":{"top":120,"left":80,"width":500,"height":70,"fontSize":48,"color":"rgba(20,30,25,1)"}},"mobile":{"styles":{"top":100,"left":20,"width":380,"height":60,"fontSize":32}}},"specials":{"text":"…"} } hydrates into the full node. A complete node still works. The whole loop is sparse: get_element skeletons/examples + new_element already come in this shape (copy them as-is), and get_page returns sources COMPACTED the same way — edit and send back without re-adding boilerplate.
|
|
27
27
|
|
|
28
28
|
COORDINATE SYSTEM (critical)
|
|
29
29
|
- Absolute-positioning canvas (NOT flexbox). Children carry top/left/width/height in px (numbers).
|
|
@@ -138,5 +138,5 @@ EDITING an existing page
|
|
|
138
138
|
- get_page(page_id) → you get the live { page, popup, settings, ... }. Edit it surgically: change only the elements the user asked for (text/styles/specials/events); keep every other element, its id, and coordinates intact. Never regenerate the whole tree for a small change.
|
|
139
139
|
- SMALL edit → PREFER patch_page(page_id, patches): send ONLY the changed elements by id, not the whole source. Ops — {op:'update',id,specials?,styles?:{desktop?,mobile?},config?:{desktop?,mobile?},events?,properties?} (shallow-merge; op defaults to 'update'), {op:'replace',id,element}, {op:'remove',id}, {op:'add',parent_id,element}. The MCP fetches the live source, applies the ops, validates the whole tree, and saves. Reserve update_page(page_id, full source) for when you're rewriting most of the page.
|
|
140
140
|
- To add an element: give it a unique id + top/left/width/height, then patch_page({op:'add', parent_id:<section id>, element:<node>}).
|
|
141
|
-
- FIX-AFTER-ERROR: when create_page/update_page/add_section reports validation errors, fix ONLY the offending element ids with patch_page
|
|
141
|
+
- FIX-AFTER-ERROR (never rebuild the whole source): when create_page/update_page/add_section reports validation errors, fix ONLY the offending element ids with patch_page. If create_page failed, its error returns a draft_id (source cached) → patch_page({ draft_id, patches, dry_run:false }) fixes + creates the page (a wrong type → { op:'update', id, type:'<allowed type>' }). If update_page/add_section failed, the page has a page_id → patch_page({ page_id, patches }).
|
|
142
142
|
- patch_page/update_page default to dry_run=true (preview); pass dry_run:false to save.`;
|
|
@@ -5,6 +5,7 @@ import { LIBRARY, ELEMENT_TYPES, CONTAINER_TYPES, FIELD_TYPES, createElement } f
|
|
|
5
5
|
import { createPageSource } from "./page.js";
|
|
6
6
|
import { validatePage, coercePage, pageSchema } from "./validate.js";
|
|
7
7
|
import { expandSource } from "../../core/expand.js";
|
|
8
|
+
import { compactSource } from "../../core/compact.js";
|
|
8
9
|
/** The payload returned by the get_generation_guide tool. */
|
|
9
10
|
export const guidePayload = {
|
|
10
11
|
guide: GENERATION_GUIDE,
|
|
@@ -36,5 +37,13 @@ export const landingDomain = {
|
|
|
36
37
|
return input; // bad JSON — let validate report it
|
|
37
38
|
}
|
|
38
39
|
},
|
|
40
|
+
compact: (input) => {
|
|
41
|
+
try {
|
|
42
|
+
return compactSource(coercePage(input), createElement);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return input; // bad JSON — return as-is
|
|
46
|
+
}
|
|
47
|
+
},
|
|
39
48
|
schema: pageSchema,
|
|
40
49
|
};
|
|
@@ -14,14 +14,17 @@ RULES (follow for every request):
|
|
|
14
14
|
- create_page and update_page DEFAULT to dry_run=true (a safety net for ambiguous requests). When the user's intent is clear AND validate_page already passed (no errors), SKIP the dry-run and call with dry_run=false directly — saves one round-trip. Use dry_run=true only when (a) the request is ambiguous about target/content, (b) the user explicitly asks to "preview" or "xem trước", (c) this is an update_page that overwrites significant existing content, or (d) you genuinely need to inspect the redacted payload. Never loop dry-runs to "check" the source — validate_page is the validator. Do not run dry-run then dry-run again before the real write.
|
|
15
15
|
- LARGE PAGES (4+ sections) — build INCREMENTALLY to avoid the giant single create_page payload that can drop the connection: create_page with a SMALL skeleton (empty/near-empty page) to get a page_id, then call add_section once per section (each call ships ONLY that section; the backend appends it server-side and rejects duplicate ids — no whole-source get+put). Small pages can still go in one create_page pass.
|
|
16
16
|
- EDIT existing pages surgically: find_pages (locate the page by name/domain/id when you don't already have a page_id) → get_page → change ONLY what was asked → keep every other element, its id, and coordinates. For a SMALL edit, PREFER patch_page over update_page: send only the changed elements by id (ops: update/replace/remove/add) instead of re-shipping the whole tree — the MCP fetches the live source, merges, validates and saves server-side. Use update_page only when you're rewriting most of the page. Never regenerate the whole tree for a small change.
|
|
17
|
-
- FIX-AFTER-ERROR (don't rebuild): when create_page / update_page / add_section returns validation errors,
|
|
17
|
+
- FIX-AFTER-ERROR (don't rebuild — this is the #1 time-waster): when create_page / update_page / add_section returns validation errors, DO NOT regenerate and re-send the whole source. Send only the fix via patch_page:
|
|
18
|
+
· create_page failed → its error returns a draft_id (the source is cached server-side). Call patch_page({ draft_id, patches:[…], dry_run:false }) with ops fixing ONLY the listed elements; it re-validates the merged tree and CREATES the page. A wrong element type is the most common error → { op:'update', id:'<element id>', type:'<allowed type>' } (run list_elements if unsure). The draft keeps your fixes across rounds and expires in ~30 min.
|
|
19
|
+
· update_page / add_section failed → the page already has a page_id → patch_page({ page_id, patches:[…] }) the offending ids.
|
|
20
|
+
Either way you ship a tiny diff, not the large payload that drops the connection.
|
|
18
21
|
- Organizations: call list_organizations and ask which to use; default to the is_default org. Endpoints are owner-scoped (only the account's own pages).
|
|
19
22
|
- REFERENCE INPUT — if the user provides a layout reference, USE it as the layout anchor (don't ignore it, don't re-invent from scratch). Three input modes: (1) IMAGE/screenshot attached in chat → analyze it natively (no tool call): identify section flow (hero/features/form/cta/footer), heading hierarchy, dominant colors, font feel, then map sections to Webcake elements. (2) HTML string → call ingest_html(html) to get a compact AST. (3) URL → call ingest_url(url) for the same AST. The AST classifies sections by role and lists headings/subheadings/ctas/images/form_fields plus brand hints (colors/fonts) — use it for LAYOUT + HIERARCHY, then generate FRESH content tailored to the user's brand (don't 1:1 copy text). intent='clone' only when the user explicitly asks to mirror the original; default intent='adapt'. The reference workflow PRESERVES craft rules above (centering, page margin, premium spacing, real images) — apply them on top of the reference layout, don't bypass them.
|
|
20
23
|
|
|
21
24
|
MODEL (essentials):
|
|
22
25
|
- Top-level: { page:[sections], popup:[popups], dynamic_pages:[], settings:{}, options:{mobileOnly,versionID}, cartConfigs:{isActive:false}, svariations:[] }. Popups are a SEPARATE top-level array, NOT inside page; currency lives in settings.currency (not options). Leave dynamic_pages/svariations as [] for a static page, but keep them on edit round-trips.
|
|
23
26
|
- Element: { id, type, properties, responsive:{desktop,mobile:{config,styles}}, specials, children, runtime, events }. Absolute canvas: children carry numeric top/left/width/height (px) per breakpoint (canvas width desktop=960, mobile=420); sections own a height.
|
|
24
|
-
- COMPACT AUTHORING (emit FEWER tokens — faster, cheaper): the server HYDRATES every element from its type's factory defaults, so OMIT the boilerplate. Send only: id, type, the meaningful responsive.desktop.styles + responsive.mobile.styles (positions/sizes/colors/font — provide BOTH breakpoints), specials (text/src/field_name…), and events ONLY when the element actually has them. You may DROP: properties, runtime, empty events/children, and each breakpoint's config (animation). A full node still works (it's just overlaid on the seed). Applies to create_page, update_page, add_section, validate_page — ~halves the JSON you emit per element.
|
|
27
|
+
- COMPACT AUTHORING (emit FEWER tokens — faster, cheaper): the server HYDRATES every element from its type's factory defaults, so OMIT the boilerplate. Send only: id, type, the meaningful responsive.desktop.styles + responsive.mobile.styles (positions/sizes/colors/font — provide BOTH breakpoints), specials (text/src/field_name…), and events ONLY when the element actually has them. You may DROP: properties, runtime, empty events/children, and each breakpoint's config (animation). A full node still works (it's just overlaid on the seed). Applies to create_page, update_page, add_section, patch_page, validate_page — ~halves the JSON you emit per element. The whole loop is sparse: get_element skeletons/examples and new_element already come in this shape (copy them as-is), and get_page returns sources COMPACTED the same way — edit the compacted tree and send it back without re-adding boilerplate.
|
|
25
28
|
- CENTERING (the #1 layout defect — do the math, don't eyeball): to center a box compute left = round((canvas - width)/2) — 960 desktop, 420 mobile. textAlign:center only centers text inside the box, not the box itself. For a row of N items, center the whole row block (startLeft = round((canvas - (N*item + (N-1)*gap))/2)). Keep 0 ≤ left and left+width ≤ canvas on each breakpoint.
|
|
26
29
|
- PAGE MARGIN (one shared axis — fixes the ragged/header-misaligned look): every section AND the header use the SAME column — left edge at 80 desktop / 20 mobile, right edge at 880 / 400 (content width 800 / 380). Header: logo at left=80, CTA right edge at 880 (its left = 880 − width). Never let one band start at left=80 and the next at left=140.
|
|
27
30
|
- PREMIUM CRAFT (read "sang"): generous whitespace (don't cram; ~48–72px above each band's first element, ≥16–24px between elements); clear type scale (H1 40–56 / body 16–18, big jump); ONE accent used sparingly + neutrals; snap spacing to an 8px grid; reuse the same content width / margin / card+button radius across sections.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny in-memory store for page sources that FAILED create_page validation, so the
|
|
3
|
+
* model can fix ONLY the broken elements (patch_page with a draft_id) instead of
|
|
4
|
+
* re-emitting the whole source. The create-before-save gap: a failed create has no
|
|
5
|
+
* page_id to patch against, so we hold the source here keyed by a random draft_id.
|
|
6
|
+
*
|
|
7
|
+
* Bounded + TTL'd; a lost draft (process restart, eviction, expiry) just means the
|
|
8
|
+
* model falls back to re-sending the full source via create_page — never a failure.
|
|
9
|
+
* Process-global, but draft_ids are random/unguessable AND persisting still uses the
|
|
10
|
+
* CALLER's own creds, so a draft only ever yields a page in the caller's account.
|
|
11
|
+
*/
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
const TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
14
|
+
const MAX_ENTRIES = 50;
|
|
15
|
+
const store = new Map();
|
|
16
|
+
function sweep(now) {
|
|
17
|
+
for (const [id, d] of store)
|
|
18
|
+
if (now - d.created > TTL_MS)
|
|
19
|
+
store.delete(id);
|
|
20
|
+
while (store.size > MAX_ENTRIES) {
|
|
21
|
+
let oldestId;
|
|
22
|
+
let oldestTs = Infinity;
|
|
23
|
+
for (const [id, d] of store) {
|
|
24
|
+
if (d.created < oldestTs) {
|
|
25
|
+
oldestTs = d.created;
|
|
26
|
+
oldestId = id;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (oldestId)
|
|
30
|
+
store.delete(oldestId);
|
|
31
|
+
else
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Cache a failed source. Returns the draft_id to hand back to the model. */
|
|
36
|
+
export function putDraft(draft) {
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
sweep(now);
|
|
39
|
+
const id = `draft_${randomUUID().replace(/-/g, "").slice(0, 20)}`;
|
|
40
|
+
store.set(id, { ...draft, created: now });
|
|
41
|
+
return id;
|
|
42
|
+
}
|
|
43
|
+
/** Replace a draft's source after applying patches (refreshes its TTL). */
|
|
44
|
+
export function updateDraft(id, source) {
|
|
45
|
+
const d = store.get(id);
|
|
46
|
+
if (d) {
|
|
47
|
+
d.source = source;
|
|
48
|
+
d.created = Date.now();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Fetch a live (non-expired) draft, or null if missing/expired. */
|
|
52
|
+
export function getDraft(id) {
|
|
53
|
+
sweep(Date.now());
|
|
54
|
+
return store.get(id) ?? null;
|
|
55
|
+
}
|
|
56
|
+
export function deleteDraft(id) {
|
|
57
|
+
store.delete(id);
|
|
58
|
+
}
|
package/dist/smoke.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { createElement, CONTAINER_TYPES, FIELD_TYPES, LIBRARY, ELEMENT_TYPES, ELEMENTS, } from "./domains/landing/elements/index.js";
|
|
6
6
|
import { validatePage, pageSchema } from "./domains/landing/validate.js";
|
|
7
7
|
import { expandSource } from "./core/expand.js";
|
|
8
|
+
import { compactSource, deepEq, sparseTemplate } from "./core/compact.js";
|
|
8
9
|
import { parseHtml } from "./persistence/html-ingest.js";
|
|
9
10
|
import { readConfig, resolveEnv, ENV_NAMES } from "./persistence/config.js";
|
|
10
11
|
import { toEditorUrl } from "./persistence/webcake-client.js";
|
|
@@ -162,6 +163,35 @@ check("expand preserves provided styles", eTxt.responsive.desktop.styles.fontSiz
|
|
|
162
163
|
check("expand keeps id/type/specials", eTxt.id === "t_h1" && eTxt.type === "text-block" && eTxt.specials.text === "Sparse hero", eTxt);
|
|
163
164
|
check("expanded sparse page validates", validatePage(exp).valid, validatePage(exp).errors);
|
|
164
165
|
check("expand(full good page) still valid", validatePage(expandSource(good, createElement)).valid);
|
|
166
|
+
console.log("== compact: the inverse of expand (round-trip persists the same tree) ==");
|
|
167
|
+
{
|
|
168
|
+
const cGood = compactSource(good, createElement);
|
|
169
|
+
const cBtn = cGood.page[0].children[0];
|
|
170
|
+
check("compact strips runtime + breakpoint config boilerplate", cBtn.runtime === undefined && cBtn.responsive.desktop.config === undefined, cBtn);
|
|
171
|
+
check("compact keeps real events", Array.isArray(cBtn.events) && cBtn.events.length === 1, cBtn.events);
|
|
172
|
+
check("compact keeps only non-default properties (custom name)", deepEq(cBtn.properties, { name: "CTA" }), cBtn.properties);
|
|
173
|
+
check("compact drops empty popup events/children/specials", cGood.page[1].events === undefined && cGood.page[1].children === undefined && cGood.page[1].specials === undefined, cGood.page[1]);
|
|
174
|
+
check("round-trip: expand(compact(x)) deep-equals expand(x)", deepEq(expandSource(cGood, createElement), expandSource(good, createElement)));
|
|
175
|
+
const cmpSparse = compactSource(exp, createElement); // compact(expand(sparse))
|
|
176
|
+
check("round-trip from sparse: expand(compact(expand(s))) == expand(s)", deepEq(expandSource(cmpSparse, createElement), expandSource(sparse, createElement)));
|
|
177
|
+
check("compact tolerates unknown types (pass-through)", compactSource({ page: [{ id: "x", type: "nope" }] }, createElement).page[0].id === "x");
|
|
178
|
+
}
|
|
179
|
+
console.log("== sparseTemplate: the authoring shape get_element/new_element hand out ==");
|
|
180
|
+
{
|
|
181
|
+
const tplText = sparseTemplate(createElement("text-block"));
|
|
182
|
+
check("template strips properties/runtime/empty events", tplText.properties === undefined && tplText.runtime === undefined && tplText.events === undefined, tplText);
|
|
183
|
+
check("template keeps seeded styles + specials on BOTH breakpoints", tplText.responsive.desktop.styles.width === 200 && tplText.responsive.mobile.styles.width === 200 && tplText.specials.text === "hello world", tplText);
|
|
184
|
+
check("template drops base config (notloaded/animation)", tplText.responsive.desktop.config === undefined, tplText.responsive.desktop);
|
|
185
|
+
const tplList = sparseTemplate(createElement("list-paragraph"));
|
|
186
|
+
check("template keeps non-default seeded config (list icons)", tplList.responsive.desktop.config?.iconSize === 12, tplList.responsive.desktop.config);
|
|
187
|
+
check("template keeps container children", Array.isArray(sparseTemplate(createElement("form")).children));
|
|
188
|
+
const wrapped = {
|
|
189
|
+
page: [{ id: "tsec", type: "section", responsive: { desktop: { styles: { height: 800 } }, mobile: { styles: { height: 800 } } }, children: [{ ...tplText, id: "ttext" }] }],
|
|
190
|
+
settings: { title: "t", description: "d", keywords: "k", lang: "vi" },
|
|
191
|
+
};
|
|
192
|
+
const tr = validatePage(expandSource(wrapped, createElement));
|
|
193
|
+
check("template node expands to a valid page", tr.valid, tr.errors);
|
|
194
|
+
}
|
|
165
195
|
console.log("== ingest: parseHtml extracts a compact AST ==");
|
|
166
196
|
const sampleHtml = `<!DOCTYPE html><html lang="en"><head>
|
|
167
197
|
<title>Brew Coffee</title>
|
|
@@ -209,10 +239,12 @@ check("ingest: empty input → warning", (empty.warnings?.length ?? 0) > 0, empt
|
|
|
209
239
|
const csr = parseHtml(`<html><head><title>SPA</title></head><body><div id="root"></div></body></html>`);
|
|
210
240
|
check("ingest: CSR shell → warning", (csr.warnings?.[0] ?? "").includes("client-rendered"), csr.warnings);
|
|
211
241
|
check("ingest: CSR shell → title still extracted", csr.title === "SPA", csr.title);
|
|
212
|
-
console.log("== library: each example
|
|
242
|
+
console.log("== library: each (sparse) example expands to a valid element subtree ==");
|
|
213
243
|
for (const [type, doc] of Object.entries(LIBRARY)) {
|
|
214
244
|
if (!doc.example)
|
|
215
245
|
continue;
|
|
246
|
+
// Examples are authored SPARSE (the shape the model should emit), so they go
|
|
247
|
+
// through expand first — the same path validate_page/create_page take.
|
|
216
248
|
const wrapped = {
|
|
217
249
|
page: [
|
|
218
250
|
{
|
|
@@ -227,7 +259,7 @@ for (const [type, doc] of Object.entries(LIBRARY)) {
|
|
|
227
259
|
},
|
|
228
260
|
],
|
|
229
261
|
};
|
|
230
|
-
const rr = validatePage(wrapped);
|
|
262
|
+
const rr = validatePage(expandSource(wrapped, createElement));
|
|
231
263
|
check(`example ${type} valid`, rr.valid, rr.errors);
|
|
232
264
|
}
|
|
233
265
|
console.log("== schema enum stays in sync with LIBRARY (single source of truth) ==");
|
package/dist/tools/generation.js
CHANGED
|
@@ -4,17 +4,21 @@
|
|
|
4
4
|
* the injected Domain.
|
|
5
5
|
*/
|
|
6
6
|
import { z } from "zod";
|
|
7
|
+
import { sparseTemplate } from "../core/compact.js";
|
|
7
8
|
import { text } from "../mcp/response.js";
|
|
8
9
|
export function registerGenerationTools(server, domain) {
|
|
9
10
|
// 5) New element ------------------------------------------------------------
|
|
10
|
-
server.tool("new_element", "Returns a
|
|
11
|
+
server.tool("new_element", "Returns a default element node for a type in the SPARSE authoring shape (fresh id, both breakpoints' seeded styles, seeded specials). Emit elements exactly like this — fill in specials + top/left coordinates; OMIT properties/runtime/empty events/config (the server hydrates them from factory defaults on validate/persist).", {
|
|
11
12
|
type: z.string().describe("Element type to create."),
|
|
12
13
|
name: z.string().optional().describe("Optional properties.name override (layer label)."),
|
|
13
14
|
}, { title: "New Element Node", readOnlyHint: true, openWorldHint: false }, async ({ type, name }) => {
|
|
14
15
|
if (!domain.catalog[type]) {
|
|
15
16
|
return text({ error: `Unknown element type "${type}".`, valid_types: domain.elementTypes });
|
|
16
17
|
}
|
|
17
|
-
|
|
18
|
+
const el = sparseTemplate(domain.createElement(type));
|
|
19
|
+
if (name)
|
|
20
|
+
el.properties = { name };
|
|
21
|
+
return text(el);
|
|
18
22
|
});
|
|
19
23
|
// 6) New page skeleton ------------------------------------------------------
|
|
20
24
|
server.tool("new_page_skeleton", "Returns an empty but complete top-level page source { page:[], popup:[], settings:{...defaults}, options:{...}, cartConfigs:{} } matching the real editor shape.", { mobileOnly: z.boolean().optional().describe("true if the page renders mobile-only.") }, { title: "New Page Skeleton", readOnlyHint: true, openWorldHint: false }, async ({ mobileOnly }) => text(domain.createPageSource({ mobileOnly: mobileOnly ?? false })));
|
|
@@ -13,6 +13,7 @@ import { z } from "zod";
|
|
|
13
13
|
import { text } from "../mcp/response.js";
|
|
14
14
|
import { readConfig, configFromHeaders } from "../persistence/config.js";
|
|
15
15
|
import { buildRequestRedacted, buildUpdateRequestRedacted, buildAppendRequestRedacted, createPage, listOrganizations, listPages, searchPages, getPageSource, updatePageSource, appendSection, } from "../persistence/webcake-client.js";
|
|
16
|
+
import { putDraft, getDraft, updateDraft, deleteDraft } from "../persistence/draft-cache.js";
|
|
16
17
|
export function registerPersistenceTools(server, domain) {
|
|
17
18
|
// Resolve config from THIS request's headers (remote per-user JWT) first, then env.
|
|
18
19
|
const cfgFor = (extra) => readConfig(configFromHeaders(extra?.requestInfo?.headers));
|
|
@@ -33,7 +34,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
33
34
|
server.tool("create_page", "Persists a page source to the configured Webcake backend: creates a NEW page and saves the source (source-only — opens in the editor where re-saving renders it). Validates first. DEFAULTS to dry_run=true (returns the HTTP request it WOULD send, token masked); dry_run=false to actually create. The page lands in `organization_id` if given; without an org the page is personal (org=null). Real writes need WEBCAKE_API_BASE + WEBCAKE_JWT.", {
|
|
34
35
|
source: z
|
|
35
36
|
.any()
|
|
36
|
-
.describe("
|
|
37
|
+
.describe("Page source { page, popup, settings, options, cartConfigs } (object or JSON string). Author elements SPARSE — only id, type, responsive.<bp>.styles for BOTH breakpoints, specials, and real events; OMIT properties/runtime/empty events+children/per-breakpoint config — the server hydrates them from factory defaults (a full node also works)."),
|
|
37
38
|
name: z.string().optional().describe("Page name (default 'AI Page')."),
|
|
38
39
|
organization_id: z
|
|
39
40
|
.union([z.string(), z.number()])
|
|
@@ -52,12 +53,17 @@ export function registerPersistenceTools(server, domain) {
|
|
|
52
53
|
const expanded = domain.expand(source);
|
|
53
54
|
const result = domain.validate(expanded);
|
|
54
55
|
if (!result.valid) {
|
|
56
|
+
// Cache the failed source so the model can fix ONLY the broken elements via
|
|
57
|
+
// patch_page({ draft_id }) instead of regenerating + re-shipping the whole
|
|
58
|
+
// source (there is no page_id yet, so patch_page can't target a live page).
|
|
59
|
+
const draft_id = putDraft({ source: expanded, name: pageName, organization_id: orgId });
|
|
55
60
|
return text({
|
|
56
61
|
created: false,
|
|
57
62
|
reason: "validation_failed",
|
|
58
63
|
errors: result.errors,
|
|
59
64
|
warnings: result.warnings,
|
|
60
|
-
|
|
65
|
+
draft_id,
|
|
66
|
+
hint: "Do NOT rebuild the whole source — it is cached as draft_id. Fix ONLY the listed elements with patch_page({ draft_id, patches:[…], dry_run:false }); it re-validates the merged tree and creates the page. A wrong element type → { op:'update', id:'<element id>', type:'<allowed type>' } (run list_elements/get_element if unsure of the exact type name). The draft expires in ~30 min.",
|
|
61
67
|
});
|
|
62
68
|
}
|
|
63
69
|
const parsed = domain.coerce(expanded);
|
|
@@ -154,18 +160,32 @@ export function registerPersistenceTools(server, domain) {
|
|
|
154
160
|
});
|
|
155
161
|
});
|
|
156
162
|
// 11) Get page (read source) ------------------------------------------------
|
|
157
|
-
server.tool("get_page", "Fetches an existing page's decoded source tree { page, popup, settings, options, cartConfigs } plus name and organization_id.
|
|
163
|
+
server.tool("get_page", "Fetches an existing page's decoded source tree { page, popup, settings, options, cartConfigs } plus name and organization_id. By DEFAULT the source is COMPACTED: boilerplate every element shares (properties/runtime/empty events+children/per-breakpoint config + factory-default style keys) is stripped, leaving the sparse authoring shape — edit it and send it back as-is; update_page/patch_page re-hydrate from factory defaults. Pass compact:false for the raw stored tree. Needs WEBCAKE_API_BASE + WEBCAKE_JWT.", {
|
|
164
|
+
page_id: z.string().describe("The page id (from list_pages or a URL)."),
|
|
165
|
+
compact: z
|
|
166
|
+
.boolean()
|
|
167
|
+
.optional()
|
|
168
|
+
.describe("Default TRUE — strip factory-default boilerplate from every element (sparse shape, far fewer tokens). false returns the raw stored tree."),
|
|
169
|
+
}, { title: "Get Webcake Page Source", readOnlyHint: true, openWorldHint: true }, async ({ page_id, compact }, extra) => {
|
|
158
170
|
const { config, missing } = cfgFor(extra);
|
|
159
171
|
if (!config)
|
|
160
172
|
return text({ ok: false, reason: "missing_env", missing_env: missing });
|
|
161
|
-
|
|
173
|
+
const res = await getPageSource(config, page_id);
|
|
174
|
+
if (!res.ok || compact === false || res.source == null)
|
|
175
|
+
return text(res);
|
|
176
|
+
return text({
|
|
177
|
+
...res,
|
|
178
|
+
source: domain.compact(res.source),
|
|
179
|
+
compacted: true,
|
|
180
|
+
note: "Source is COMPACTED (factory-default boilerplate stripped). Edit elements in this same sparse shape — keep ids — and send the edited tree back to update_page (or use patch_page for small edits); the server re-hydrates omitted boilerplate.",
|
|
181
|
+
});
|
|
162
182
|
});
|
|
163
183
|
// 12) Update page (edit existing) -------------------------------------------
|
|
164
184
|
server.tool("update_page", "Overwrites an EXISTING page's source with an edited tree (source-only; re-render in the editor for preview/publish). Validates first. DEFAULTS to dry_run=true (preview the request, token masked); dry_run=false to actually save. Needs WEBCAKE_API_BASE + WEBCAKE_JWT.", {
|
|
165
185
|
page_id: z.string().describe("The page id to update (must be owned by the account)."),
|
|
166
186
|
source: z
|
|
167
187
|
.any()
|
|
168
|
-
.describe("The
|
|
188
|
+
.describe("The edited page source { page, popup, settings, options, cartConfigs } (object or JSON string). The compacted tree from get_page can be edited and sent back AS-IS — sparse nodes are re-hydrated from factory defaults (a full tree also works)."),
|
|
169
189
|
dry_run: z.boolean().optional().describe("Default TRUE — preview without sending. Set false to actually save."),
|
|
170
190
|
}, { title: "Update Webcake Page (Overwrite)", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async ({ page_id, source, dry_run }, extra) => {
|
|
171
191
|
const isDry = dry_run !== false;
|
|
@@ -237,7 +257,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
237
257
|
page_id: z.string().describe("The page id to append to (from create_page or list_pages; must be owned by the account)."),
|
|
238
258
|
sections: z
|
|
239
259
|
.any()
|
|
240
|
-
.describe("One section node, or an array of section nodes, to append to the END of `page` (object/array or JSON string). Each is a normal section element { id, type:'section', responsive, children, … } with a UNIQUE id; they stack vertically after the existing sections."),
|
|
260
|
+
.describe("One section node, or an array of section nodes, to append to the END of `page` (object/array or JSON string). Each is a normal section element { id, type:'section', responsive, children, … } with a UNIQUE id; they stack vertically after the existing sections. Author SPARSE nodes — omit properties/runtime/empty events+children/per-breakpoint config; the server hydrates them from factory defaults."),
|
|
241
261
|
dry_run: z
|
|
242
262
|
.boolean()
|
|
243
263
|
.optional()
|
|
@@ -430,48 +450,66 @@ export function registerPersistenceTools(server, domain) {
|
|
|
430
450
|
}
|
|
431
451
|
return touched;
|
|
432
452
|
};
|
|
433
|
-
server.tool("patch_page", "Edits
|
|
434
|
-
page_id: z.string().describe("
|
|
453
|
+
server.tool("patch_page", "Edits a page by element id WITHOUT re-sending the whole source — the surgical-edit and fix-after-error path. Targets EITHER a live page (page_id) OR a cached failed-create source (draft_id, returned by a create_page that failed validation). Send only a list of per-element ops; the MCP loads the source, applies them, validates the WHOLE merged tree (blocks on errors), and saves (update for page_id; create for draft_id). Ops: {op:'update',id,type?,specials?,styles?:{desktop?,mobile?},config?:{desktop?,mobile?},events?,properties?} (shallow-merges; op defaults to 'update'; `type` fixes a wrong element type), {op:'replace',id,element}, {op:'remove',id}, {op:'add',parent_id,element}. Use this to fix the elements a failed create_page/update_page/add_section reported (e.g. a bad type → {op:'update',id,type:'button'}) instead of rebuilding the page. DEFAULTS to dry_run=true (loads + merges + validates + previews, no write); dry_run=false to save. Needs WEBCAKE_API_BASE + WEBCAKE_JWT (a draft_id patch only needs creds to actually create; a page_id patch reads the live page so needs creds even on dry_run).", {
|
|
454
|
+
page_id: z.string().optional().describe("Edit a LIVE page by id (from create_page, list_pages, or find_pages; must be owned by the account). Provide page_id OR draft_id."),
|
|
455
|
+
draft_id: z.string().optional().describe("Fix a CACHED source from a create_page that failed validation (the create_page error returns draft_id). The patched tree is created as a new page once valid. Provide page_id OR draft_id."),
|
|
435
456
|
patches: z
|
|
436
457
|
.any()
|
|
437
|
-
.describe("One op object or an array of them (object/array or JSON string). Each targets an element by id: {op:'update',id,specials?,styles?:{desktop?,mobile?},config?:{desktop?,mobile?},events?,properties?} merges fields into the element (op may be omitted); {op:'replace',id,element} swaps the node; {op:'remove',id} deletes it; {op:'add',parent_id,element} appends a child to a container."),
|
|
458
|
+
.describe("One op object or an array of them (object/array or JSON string). Each targets an element by id: {op:'update',id,type?,specials?,styles?:{desktop?,mobile?},config?:{desktop?,mobile?},events?,properties?} merges fields into the element (op may be omitted; set `type` to fix a wrong element type); {op:'replace',id,element} swaps the node; {op:'remove',id} deletes it; {op:'add',parent_id,element} appends a child to a container. `element` may be a SPARSE node (id/type/styles/specials/events only) — the server hydrates omitted boilerplate from factory defaults."),
|
|
438
459
|
dry_run: z
|
|
439
460
|
.boolean()
|
|
440
461
|
.optional()
|
|
441
|
-
.describe("Default TRUE —
|
|
442
|
-
}, { title: "Patch Webcake Page (by element id)", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async ({ page_id, patches, dry_run }, extra) => {
|
|
462
|
+
.describe("Default TRUE — load, merge, validate and preview the resulting save WITHOUT writing. Set false to actually save."),
|
|
463
|
+
}, { title: "Patch Webcake Page (by element id)", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async ({ page_id, draft_id, patches, dry_run }, extra) => {
|
|
443
464
|
const isDry = dry_run !== false; // default true (safe)
|
|
444
465
|
const ops = asArray(patches).filter((p) => p != null && typeof p === "object");
|
|
445
466
|
if (ops.length === 0) {
|
|
446
467
|
return text({ patched: false, reason: "no_patches", hint: "Pass an op object or a non-empty array of { op, id, … } ops." });
|
|
447
468
|
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
if (!config) {
|
|
451
|
-
return text({
|
|
452
|
-
patched: false,
|
|
453
|
-
reason: "missing_env",
|
|
454
|
-
missing_env: missing,
|
|
455
|
-
hint: "Configure WEBCAKE_API_BASE and WEBCAKE_JWT (env), or send the x-webcake-jwt header (remote), then retry.",
|
|
456
|
-
});
|
|
469
|
+
if (!page_id && !draft_id) {
|
|
470
|
+
return text({ patched: false, reason: "no_target", hint: "Pass page_id (a live page) or draft_id (the cached source from a failed create_page)." });
|
|
457
471
|
}
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
472
|
+
const { config, missing } = cfgFor(extra);
|
|
473
|
+
// Resolve the base source: a cached draft (create-before-save), else a live page.
|
|
474
|
+
let base;
|
|
475
|
+
const draft = draft_id ? getDraft(draft_id) : null;
|
|
476
|
+
if (draft_id) {
|
|
477
|
+
if (!draft) {
|
|
478
|
+
return text({
|
|
479
|
+
patched: false,
|
|
480
|
+
reason: "draft_expired",
|
|
481
|
+
hint: "The cached draft is gone (expired after ~30 min or evicted). Re-send the full source via create_page.",
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
base = draft.source; // already an expanded full tree
|
|
467
485
|
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
486
|
+
else {
|
|
487
|
+
if (!config) {
|
|
488
|
+
return text({
|
|
489
|
+
patched: false,
|
|
490
|
+
reason: "missing_env",
|
|
491
|
+
missing_env: missing,
|
|
492
|
+
hint: "Configure WEBCAKE_API_BASE and WEBCAKE_JWT (env), or send the x-webcake-jwt header (remote), then retry.",
|
|
493
|
+
});
|
|
472
494
|
}
|
|
473
|
-
|
|
474
|
-
|
|
495
|
+
const current = await getPageSource(config, page_id);
|
|
496
|
+
if (!current.ok || current.source == null) {
|
|
497
|
+
return text({
|
|
498
|
+
patched: false,
|
|
499
|
+
reason: "fetch_failed",
|
|
500
|
+
status: current.status,
|
|
501
|
+
error: current.error ?? "Page source not found.",
|
|
502
|
+
hint: "Check the page_id (find_pages/list_pages) and that the account owns it.",
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
base = current.source;
|
|
506
|
+
if (typeof base === "string") {
|
|
507
|
+
try {
|
|
508
|
+
base = JSON.parse(base);
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
return text({ patched: false, reason: "bad_source", hint: "The stored page source could not be parsed." });
|
|
512
|
+
}
|
|
475
513
|
}
|
|
476
514
|
}
|
|
477
515
|
const treeRoots = [base.page, base.popup].filter((a) => Array.isArray(a));
|
|
@@ -533,6 +571,10 @@ export function registerPersistenceTools(server, domain) {
|
|
|
533
571
|
else {
|
|
534
572
|
// update (default)
|
|
535
573
|
const changed = [];
|
|
574
|
+
if (typeof p.type === "string" && p.type.trim() !== "") {
|
|
575
|
+
hit.node.type = p.type;
|
|
576
|
+
changed.push("type");
|
|
577
|
+
}
|
|
536
578
|
if (p.specials && typeof p.specials === "object") {
|
|
537
579
|
hit.node.specials = { ...(hit.node.specials ?? {}), ...p.specials };
|
|
538
580
|
changed.push("specials");
|
|
@@ -564,6 +606,58 @@ export function registerPersistenceTools(server, domain) {
|
|
|
564
606
|
// Validate the WHOLE merged tree (hydrate sparse replaced/added nodes first).
|
|
565
607
|
const expanded = domain.expand(base);
|
|
566
608
|
const result = domain.validate(expanded);
|
|
609
|
+
// DRAFT path: the source came from a failed create_page. Keep the applied fixes
|
|
610
|
+
// cached between rounds; once valid, CREATE the page (no page_id yet).
|
|
611
|
+
if (draft_id && draft) {
|
|
612
|
+
if (!result.valid) {
|
|
613
|
+
updateDraft(draft_id, base); // persist the partial fixes for the next patch round
|
|
614
|
+
return text({
|
|
615
|
+
patched: false,
|
|
616
|
+
reason: "validation_failed",
|
|
617
|
+
errors: result.errors,
|
|
618
|
+
warnings: result.warnings,
|
|
619
|
+
patches_applied: applied,
|
|
620
|
+
draft_id,
|
|
621
|
+
hint: "Still invalid — fix the remaining errors with another patch_page({ draft_id, … }). Your applied fixes are kept in the draft.",
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
const parsed = domain.coerce(expanded);
|
|
625
|
+
if (isDry) {
|
|
626
|
+
updateDraft(draft_id, base);
|
|
627
|
+
return text({
|
|
628
|
+
dry_run: true,
|
|
629
|
+
draft_id,
|
|
630
|
+
patches_applied: applied,
|
|
631
|
+
validation: { valid: true, warnings: result.warnings, stats: result.stats },
|
|
632
|
+
env_ready: missing.length === 0,
|
|
633
|
+
missing_env: missing,
|
|
634
|
+
request: config
|
|
635
|
+
? buildRequestRedacted(config, draft.name ?? "AI Page", parsed, draft.organization_id)
|
|
636
|
+
: { note: "Set WEBCAKE_API_BASE + WEBCAKE_JWT (env) or send the x-webcake-jwt header to enable creation." },
|
|
637
|
+
hint: "Draft is now valid. Re-run with dry_run=false to create the page from it.",
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
if (!config) {
|
|
641
|
+
updateDraft(draft_id, base); // keep the now-valid draft so a creds-ready retry can persist it
|
|
642
|
+
return text({ patched: false, reason: "missing_env", missing_env: missing, hint: "Add WEBCAKE_API_BASE + WEBCAKE_JWT, then retry patch_page({ draft_id, dry_run:false })." });
|
|
643
|
+
}
|
|
644
|
+
const outcome = await createPage(config, draft.name ?? "AI Page", parsed, draft.organization_id);
|
|
645
|
+
if (outcome.ok)
|
|
646
|
+
deleteDraft(draft_id); // created — drop the draft
|
|
647
|
+
return text({
|
|
648
|
+
patched: outcome.ok,
|
|
649
|
+
created: outcome.ok,
|
|
650
|
+
from_draft: draft_id,
|
|
651
|
+
patches_applied: applied,
|
|
652
|
+
page_id: outcome.page_id,
|
|
653
|
+
editor_url: outcome.editor_url,
|
|
654
|
+
preview_url: outcome.preview_url,
|
|
655
|
+
status: outcome.status,
|
|
656
|
+
error: outcome.error,
|
|
657
|
+
warnings: result.warnings,
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
// LIVE-PAGE path: edit an existing page (page_id) and update it in place.
|
|
567
661
|
if (!result.valid) {
|
|
568
662
|
return text({
|
|
569
663
|
patched: false,
|
package/dist/tools/reference.js
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* Schema. All driven by the injected Domain, so they work for any domain.
|
|
5
5
|
*/
|
|
6
6
|
import { z } from "zod";
|
|
7
|
+
import { sparseTemplate } from "../core/compact.js";
|
|
7
8
|
import { text } from "../mcp/response.js";
|
|
9
|
+
const SPARSE_NOTE = "Skeletons and examples are in the SPARSE authoring shape — emit elements exactly like this (id, type, BOTH breakpoints' styles, specials, real events). OMIT properties/runtime/empty events+children/config: the server hydrates them from factory defaults on validate/persist.";
|
|
8
10
|
export function registerReferenceTools(server, domain) {
|
|
9
11
|
// 1) Generation guide -------------------------------------------------------
|
|
10
12
|
server.tool("get_generation_guide", "Returns the page-building conventions reference: output shape, the absolute-positioning coordinate system, event vocabulary, and the recommended workflow.", { title: "Get Generation Guide", readOnlyHint: true, openWorldHint: false }, async () => text(domain.guide));
|
|
@@ -23,7 +25,7 @@ export function registerReferenceTools(server, domain) {
|
|
|
23
25
|
return text({ total: domain.elementTypes.length, categories: byCategory });
|
|
24
26
|
});
|
|
25
27
|
// 3) Get element ------------------------------------------------------------
|
|
26
|
-
server.tool("get_element", "Returns detailed usage for one element type — or for many in a single call (BATCH MODE): summary, when to use it, key `specials` fields, a
|
|
28
|
+
server.tool("get_element", "Returns detailed usage for one element type — or for many in a single call (BATCH MODE): summary, when to use it, key `specials` fields, a SPARSE skeleton node (the exact shape to emit — the server hydrates omitted boilerplate), and (for common types) a filled example. Pass `types: [...]` to fetch a whole section's worth of element types at once (e.g. ['section','text-block','image-block','button']) — returns { elements: { [type]: details } } and saves a round-trip per type. `type` (single) returns the doc directly for backward compatibility.", {
|
|
27
29
|
type: z.string().optional().describe("Single element type — backward-compat. Prefer `types` when fetching more than one."),
|
|
28
30
|
types: z
|
|
29
31
|
.array(z.string())
|
|
@@ -49,7 +51,7 @@ export function registerReferenceTools(server, domain) {
|
|
|
49
51
|
summary: doc.summary,
|
|
50
52
|
useWhen: doc.useWhen,
|
|
51
53
|
keySpecials: doc.keySpecials,
|
|
52
|
-
skeleton: domain.createElement(t),
|
|
54
|
+
skeleton: sparseTemplate(domain.createElement(t)),
|
|
53
55
|
example: doc.example ?? null,
|
|
54
56
|
};
|
|
55
57
|
}
|
|
@@ -57,9 +59,10 @@ export function registerReferenceTools(server, domain) {
|
|
|
57
59
|
if (!types && type) {
|
|
58
60
|
if (unknown.length)
|
|
59
61
|
return text({ error: `Unknown element type "${unknown[0]}".`, valid_types: domain.elementTypes });
|
|
60
|
-
return text(elements[type]);
|
|
62
|
+
return text({ ...elements[type], authoring: SPARSE_NOTE });
|
|
61
63
|
}
|
|
62
64
|
return text({
|
|
65
|
+
authoring: SPARSE_NOTE,
|
|
63
66
|
elements,
|
|
64
67
|
unknown: unknown.length ? unknown : undefined,
|
|
65
68
|
valid_types: unknown.length ? domain.elementTypes : undefined,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webcake-landing-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.49",
|
|
4
4
|
"description": "MCP server exposing Webcake landing-page element schemas + AI usage hints, and persisting LLM-generated page sources to a Webcake backend.",
|
|
5
5
|
"mcpName": "io.github.vuluu2k/webcake-landing-mcp",
|
|
6
6
|
"type": "module",
|