webcake-landing-mcp 1.0.48 → 1.0.50
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 +12 -6
- 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 +1 -1
- package/dist/domains/landing/index.js +9 -0
- package/dist/domains/landing/instructions.js +4 -2
- package/dist/persistence/config.js +21 -5
- package/dist/persistence/webcake-client.js +140 -3
- package/dist/smoke.js +54 -5
- package/dist/tools/generation.js +6 -2
- package/dist/tools/persistence.js +68 -7
- package/dist/tools/reference.js +6 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -89,7 +89,7 @@ persists it (source-only — the page opens in the editor where re-saving render
|
|
|
89
89
|
| **npx (local)** — runs on your machine | Personal daily use, full control | browser `login`, a JWT, or none (reference tools) |
|
|
90
90
|
| **Hosted URL** — use our live server, nothing to install | No Node.js, teams, the claude.ai dialog | your personal `?jwt=` link / `x-webcake-jwt` header |
|
|
91
91
|
|
|
92
|
-
The **reference + generation tools** (`get_generation_guide`, `list_elements`, `validate_page`, …) and the **ingest tools** (`ingest_html`, `ingest_url` — turn an existing HTML or URL into a layout anchor so the AI can recreate or adapt it) work with **zero config**; only the **persistence tools** (`create_page`, `update_page`, `add_section`, `patch_page`, `list_pages`, `find_pages`, `get_page`, `list_organizations`) need a token. Credentials resolve in order: **per-request header → env var → saved `auth.json`** (`login`).
|
|
92
|
+
The **reference + generation tools** (`get_generation_guide`, `list_elements`, `validate_page`, …) and the **ingest tools** (`ingest_html`, `ingest_url` — turn an existing HTML or URL into a layout anchor so the AI can recreate or adapt it) work with **zero config**; only the **persistence tools** (`create_page`, `update_page`, `add_section`, `patch_page`, `publish_page`, `list_pages`, `find_pages`, `get_page`, `list_organizations`) need a token. Credentials resolve in order: **per-request header → env var → saved `auth.json`** (`login`).
|
|
93
93
|
|
|
94
94
|
> 🛠️ Prefer a shell-script installer (`install.sh`/`install.ps1`), a cloned local build, or hand-written per-IDE config? See **[docs/manual-install.md](docs/manual-install.md)**.
|
|
95
95
|
|
|
@@ -222,7 +222,8 @@ lands in logs). Any header that's missing falls back to the matching env var:
|
|
|
222
222
|
| `x-webcake-org-id` | `WEBCAKE_ORG_ID` | default org |
|
|
223
223
|
| `x-webcake-api-base` | `WEBCAKE_API_BASE` | overrides the env preset's API base |
|
|
224
224
|
| `x-webcake-app-base` | `WEBCAKE_APP_BASE` | overrides the env preset's SPA base (login connect page) |
|
|
225
|
-
| `x-webcake-builder-base` | `WEBCAKE_BUILDER_BASE` | overrides the builder host used for editor
|
|
225
|
+
| `x-webcake-builder-base` | `WEBCAKE_BUILDER_BASE` | overrides the builder host used for editor links |
|
|
226
|
+
| `x-webcake-preview-base` | `WEBCAKE_PREVIEW_BASE` | overrides the public preview host used for `/preview/<id>` links |
|
|
226
227
|
|
|
227
228
|
> The reference + generation tools (`get_generation_guide`, `list_elements`, `validate_page`, …) need **no
|
|
228
229
|
> token** — only the persistence tools (`create_page`, `update_page`, …) use it. Without a JWT, those return
|
|
@@ -292,7 +293,8 @@ flow can also be done entirely in the SPA, no backend route needed.)
|
|
|
292
293
|
| `WEBCAKE_JWT` | No* | Account JWT (dashboard auth). Required to persist — expires, refresh when needed. |
|
|
293
294
|
| `WEBCAKE_ORG_ID` | No | Default organization id for `create_page` (overridden by its `organization_id` arg). Omit → personal page. |
|
|
294
295
|
| `WEBCAKE_APP_BASE` | No | Optional SPA base — used for the browser `login` connect page. |
|
|
295
|
-
| `WEBCAKE_BUILDER_BASE` | No | Optional builder host for the editor
|
|
296
|
+
| `WEBCAKE_BUILDER_BASE` | No | Optional builder host for the editor links in the result. Defaults to the env preset, else derived from the API host (`api.x`→`builder.x`). |
|
|
297
|
+
| `WEBCAKE_PREVIEW_BASE` | No | Optional public preview host for the `/preview/<id>` links — NOT the builder subdomain. Defaults to the env preset (`preview.localhost:5800` local / `staging.webcake.me` staging / `www.webcake.me` prod). |
|
|
296
298
|
| `WEBCAKE_CONFIG_DIR` | No | Dir for the saved `auth.json` written by `login` (default `~/.webcake-landing-mcp`). |
|
|
297
299
|
|
|
298
300
|
> \* `WEBCAKE_API_BASE` and `WEBCAKE_JWT` are only needed for the persistence tools. The reference and
|
|
@@ -419,6 +421,9 @@ update_page({ page_id, source, dry_run: false }) # overwrite (dry_run=tr
|
|
|
419
421
|
create_page({ source: smallSkeleton, dry_run: false }) # → page_id
|
|
420
422
|
add_section({ page_id, sections: heroSection, dry_run: false }) # backend appends server-side (no whole-source get+put)
|
|
421
423
|
add_section({ page_id, sections: [formSection, footerSection], dry_run: false })
|
|
424
|
+
|
|
425
|
+
# Go LIVE (the preview link works without this — publish to attach a domain / set live status)
|
|
426
|
+
publish_page({ page_id, custom_domain: "shop.example.com", custom_path: "sale", dry_run: false })
|
|
422
427
|
```
|
|
423
428
|
|
|
424
429
|
`create_page` calls **`POST {WEBCAKE_API_BASE}/api/v1/ai/create_page_from_source`** on the backend.
|
|
@@ -443,13 +448,13 @@ Both `create_page` and `update_page` **default to `dry_run=true`** (validate and
|
|
|
443
448
|
|------|-------------|
|
|
444
449
|
| `get_generation_guide` | **Read FIRST.** Output shape, coordinate system, event vocabulary, workflow. |
|
|
445
450
|
| `list_elements` | All element types by category (summary + when-to-use + container?). |
|
|
446
|
-
| `get_element` | One type: hints, key `specials`,
|
|
451
|
+
| `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
452
|
| `get_page_schema` | Full JSON Schema (Draft 2020-12) of a page source. |
|
|
448
453
|
|
|
449
454
|
### Generation
|
|
450
455
|
| Tool | Description |
|
|
451
456
|
|------|-------------|
|
|
452
|
-
| `new_element` | A
|
|
457
|
+
| `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
458
|
| `new_page_skeleton` | An empty but complete top-level source `{ page, popup, settings, options, cartConfigs }`. |
|
|
454
459
|
| `validate_page` | Structural + semantic validation (ids, event targets, containers, `field_name`). |
|
|
455
460
|
|
|
@@ -465,8 +470,9 @@ Both `create_page` and `update_page` **default to `dry_run=true`** (validate and
|
|
|
465
470
|
| `create_page` | Persist a generated source as a new page (source-only). **Defaults to `dry_run=true`.** |
|
|
466
471
|
| `list_pages` | List the account's pages (id, name, organization_id, updated_at) to pick one to edit. |
|
|
467
472
|
| `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
|
|
473
|
+
| `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
474
|
| `update_page` | Overwrite an existing page's source with an edited tree. **Defaults to `dry_run=true`.** |
|
|
475
|
+
| `publish_page` | Publish a page (live status, optional custom domain/path). The preview link works WITHOUT publishing — publish only to go live. **Defaults to `dry_run=true`.** |
|
|
470
476
|
|
|
471
477
|
---
|
|
472
478
|
|
package/dist/changelog.json
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "1.0.50",
|
|
4
|
+
"d": "10/06/2026",
|
|
5
|
+
"type": "Added",
|
|
6
|
+
"en": "New publish_page tool makes a page live: reads the page's current stored source, saves it as a new version, and creates or updates the…",
|
|
7
|
+
"vi": "Công cụ publish_page mới giúp đưa trang lên live: đọc source đang lưu của trang, lưu thành phiên bản mới và tạo hoặc cập nhật bản ghi…"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"v": "1.0.49",
|
|
11
|
+
"d": "10/06/2026",
|
|
12
|
+
"type": "Changed",
|
|
13
|
+
"en": "get_page now returns a compacted source by default: factory-default boilerplate (properties, runtime, empty events/children, per-breakpoint config,…",
|
|
14
|
+
"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…"
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"v": "1.0.48",
|
|
4
18
|
"d": "10/06/2026",
|
|
@@ -26,19 +40,5 @@
|
|
|
26
40
|
"type": "Changed",
|
|
27
41
|
"en": "get_generation_guide workflow condensed to four steps: element-type reads and image fetches are now batched into single calls…",
|
|
28
42
|
"vi": "Workflow trong get_generation_guide được rút gọn xuống còn bốn bước: việc đọc loại phần tử và tìm ảnh nay được gộp thành các lần gọi batch duy nhất…"
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
"v": "1.0.44",
|
|
32
|
-
"d": "09/06/2026",
|
|
33
|
-
"type": "Added",
|
|
34
|
-
"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…",
|
|
35
|
-
"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…"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"v": "1.0.43",
|
|
39
|
-
"d": "09/06/2026",
|
|
40
|
-
"type": "Changed",
|
|
41
|
-
"en": "The GET / web guide page has refreshed copy throughout: updated page title and meta description, simplified FAQ answers in both English and…",
|
|
42
|
-
"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à…"
|
|
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).
|
|
@@ -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
|
};
|
|
@@ -24,7 +24,7 @@ RULES (follow for every request):
|
|
|
24
24
|
MODEL (essentials):
|
|
25
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.
|
|
26
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.
|
|
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, 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.
|
|
28
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.
|
|
29
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.
|
|
30
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.
|
|
@@ -32,4 +32,6 @@ MODEL (essentials):
|
|
|
32
32
|
- Visible content lives in specials (text, src, field_name…), never in styles. Colors as rgba(). Animation in config.animation={name,delay,duration,repeat}. Form inputs need a unique specials.field_name (use canonical keys: full_name, phone_number, email, address, quantity).
|
|
33
33
|
- IMAGES: include them (hero/product, feature icons, about photo). PREFER REAL PHOTOS — call search_images with a short English subject (e.g. 'fresh coffee cup') and put a returned URL (src.large for a hero/banner, src.medium for a card/thumb) into the image-block specials.src; it works out of the box (a shared proxy supplies images). Only if search_images returns ok:false, FALL BACK to a PLACEHOLDER sized to the box: https://placehold.co/<width>x<height>. (gallery.media = array of OBJECTS {type:'image',link:'<url>',linkVideo:'',typeVideo:'youtube',imageCompression:true} — NOT plain strings, the gallery reads item.link; video.specials.img = poster). NEVER leave src empty (renders blank). Ensure text contrasts with its section background.
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
- PREVIEW vs PUBLISH: the preview_url returned by create_page/update_page/add_section lives on the PREVIEW host (preview.localhost:5800 local / staging.webcake.me staging / www.webcake.me prod — NOT the builder subdomain) and renders the stored source immediately — share it as-is for review. When the user wants the page LIVE (public/published, optionally on their custom domain), call publish_page({ page_id, custom_domain?, custom_path?, dry_run:false }).
|
|
36
|
+
|
|
37
|
+
Start by calling get_generation_guide. Tools: get_generation_guide, list_elements, get_element, new_element, new_page_skeleton, get_page_schema, validate_page, search_images, ingest_html, ingest_url, list_organizations, create_page, list_pages, find_pages, get_page, update_page, add_section, patch_page, publish_page.`;
|
|
@@ -19,8 +19,11 @@
|
|
|
19
19
|
* WEBCAKE_JWT the account JWT (required to call the backend)
|
|
20
20
|
* WEBCAKE_ORG_ID optional default organization id for create_page
|
|
21
21
|
* WEBCAKE_APP_BASE optional SPA base (used for the login connect page)
|
|
22
|
-
* WEBCAKE_BUILDER_BASE optional builder host for editor
|
|
22
|
+
* WEBCAKE_BUILDER_BASE optional builder host for the editor URLs in the result
|
|
23
23
|
* (defaults to the env preset, else derived from the API host)
|
|
24
|
+
* WEBCAKE_PREVIEW_BASE optional public preview host for the /preview/<id> links —
|
|
25
|
+
* NOT the builder subdomain (defaults to the env preset:
|
|
26
|
+
* preview.localhost:5800 / staging.webcake.me / www.webcake.me)
|
|
24
27
|
* WEBCAKE_CONFIG_DIR optional dir for the saved auth.json (default ~/.webcake-landing-mcp)
|
|
25
28
|
*/
|
|
26
29
|
import { homedir } from "node:os";
|
|
@@ -36,9 +39,9 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
|
36
39
|
* after create/update (a distinct host — NOT the API and NOT the SPA).
|
|
37
40
|
*/
|
|
38
41
|
export const ENVIRONMENTS = {
|
|
39
|
-
local: { apiBase: "http://localhost:5800", appBase: "http://localhost:5173", builderBase: "http://builder.localhost:5800" },
|
|
40
|
-
staging: { apiBase: "https://api.staging.webcake.io", appBase: "https://staging.webcake.io", builderBase: "https://builder.staging.webcake.io" },
|
|
41
|
-
prod: { apiBase: "https://api.webcake.io", appBase: "https://webcake.io", builderBase: "https://builder.webcake.io" },
|
|
42
|
+
local: { apiBase: "http://localhost:5800", appBase: "http://localhost:5173", builderBase: "http://builder.localhost:5800", previewBase: "http://preview.localhost:5800" },
|
|
43
|
+
staging: { apiBase: "https://api.staging.webcake.io", appBase: "https://staging.webcake.io", builderBase: "https://builder.staging.webcake.io", previewBase: "https://staging.webcake.me" },
|
|
44
|
+
prod: { apiBase: "https://api.webcake.io", appBase: "https://webcake.io", builderBase: "https://builder.webcake.io", previewBase: "https://www.webcake.me" },
|
|
42
45
|
};
|
|
43
46
|
export const ENV_NAMES = Object.keys(ENVIRONMENTS);
|
|
44
47
|
/** True when `v` names a known environment (local|staging|prod). */
|
|
@@ -88,6 +91,16 @@ export function readConfig(overrides = {}) {
|
|
|
88
91
|
preset?.builderBase ??
|
|
89
92
|
saved.builderBase ??
|
|
90
93
|
deriveBuilderBase(cleanBase))?.replace(/\/+$/, "");
|
|
94
|
+
// The public preview link (/preview/<id>) is served on its OWN root host — NOT
|
|
95
|
+
// the builder subdomain (preview.localhost:5800 / staging.webcake.me /
|
|
96
|
+
// www.webcake.me). When nothing matches, default to the backend's own preview
|
|
97
|
+
// domain (its @preview_domain) so the link still lands on a host that serves
|
|
98
|
+
// the /preview/:id route.
|
|
99
|
+
const previewBase = (overrides.previewBase ??
|
|
100
|
+
process.env.WEBCAKE_PREVIEW_BASE ??
|
|
101
|
+
preset?.previewBase ??
|
|
102
|
+
saved.previewBase ??
|
|
103
|
+
"https://www.webcake.me").replace(/\/+$/, "");
|
|
91
104
|
return {
|
|
92
105
|
config: {
|
|
93
106
|
base: cleanBase,
|
|
@@ -95,6 +108,7 @@ export function readConfig(overrides = {}) {
|
|
|
95
108
|
orgId: overrides.orgId ?? process.env.WEBCAKE_ORG_ID ?? saved.orgId,
|
|
96
109
|
appBase: (overrides.appBase ?? process.env.WEBCAKE_APP_BASE ?? preset?.appBase ?? saved.appBase)?.replace(/\/+$/, ""),
|
|
97
110
|
builderBase,
|
|
111
|
+
previewBase,
|
|
98
112
|
},
|
|
99
113
|
missing: [],
|
|
100
114
|
};
|
|
@@ -111,7 +125,8 @@ function header(headers, name) {
|
|
|
111
125
|
* x-webcake-env named environment (local|staging|prod) for the base URLs
|
|
112
126
|
* x-webcake-api-base backend base URL (overrides the env preset)
|
|
113
127
|
* x-webcake-app-base SPA base used for the login connect page (overrides the preset)
|
|
114
|
-
* x-webcake-builder-base builder host for editor
|
|
128
|
+
* x-webcake-builder-base builder host for editor URLs (overrides the preset)
|
|
129
|
+
* x-webcake-preview-base public preview host for /preview/<id> links (overrides the preset)
|
|
115
130
|
* Any header that is absent falls back to the corresponding env var in readConfig.
|
|
116
131
|
*/
|
|
117
132
|
export function configFromHeaders(headers) {
|
|
@@ -123,6 +138,7 @@ export function configFromHeaders(headers) {
|
|
|
123
138
|
orgId: header(headers, "x-webcake-org-id"),
|
|
124
139
|
appBase: header(headers, "x-webcake-app-base"),
|
|
125
140
|
builderBase: header(headers, "x-webcake-builder-base"),
|
|
141
|
+
previewBase: header(headers, "x-webcake-preview-base"),
|
|
126
142
|
env: header(headers, "x-webcake-env"),
|
|
127
143
|
};
|
|
128
144
|
}
|
|
@@ -5,6 +5,13 @@ const SEARCH_PAGES_ENDPOINT = "/api/v1/ai/search_pages";
|
|
|
5
5
|
const PAGE_SOURCE_ENDPOINT = "/api/v1/ai/page_source";
|
|
6
6
|
const UPDATE_ENDPOINT = "/api/v1/ai/update_page_source";
|
|
7
7
|
const APPEND_ENDPOINT = "/api/v1/ai/append_section";
|
|
8
|
+
// The editor's own publish route (NOT under /api/v1/ai): saves the source as a
|
|
9
|
+
// new version and creates/updates the page_published record (+ optional custom
|
|
10
|
+
// domain/path) so the page goes live. NOTE: this scope is host-constrained to
|
|
11
|
+
// the BUILDER host (router scope `host: "builder."`), so the request goes to
|
|
12
|
+
// config.builderBase, not the API base.
|
|
13
|
+
const publishEndpoint = (pageId) => `/api/pages/${encodeURIComponent(pageId)}/edit/publish`;
|
|
14
|
+
const publishUrl = (config, pageId) => `${(config.builderBase ?? config.base).replace(/\/+$/, "")}${publishEndpoint(pageId)}`;
|
|
8
15
|
function authHeaders(config, orgId) {
|
|
9
16
|
const headers = {
|
|
10
17
|
"Content-Type": "application/json",
|
|
@@ -44,6 +51,33 @@ export function toEditorUrl(config, raw) {
|
|
|
44
51
|
pathQuery = `/${pathQuery}`;
|
|
45
52
|
return `${builder.replace(/\/+$/, "")}${pathQuery}`;
|
|
46
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Resolve the public preview link (`/preview/<page_id>`) onto the PREVIEW host
|
|
56
|
+
* (config.previewBase) — NOT the builder subdomain. The /preview/:id route only
|
|
57
|
+
* exists on the root preview hosts (preview.localhost:5800 local /
|
|
58
|
+
* staging.webcake.me staging / www.webcake.me prod); the v4 renderer there reads
|
|
59
|
+
* the stored page_source directly, so the link works without publishing.
|
|
60
|
+
*/
|
|
61
|
+
export function toPreviewUrl(config, raw) {
|
|
62
|
+
if (!raw)
|
|
63
|
+
return raw;
|
|
64
|
+
const preview = config.previewBase;
|
|
65
|
+
if (!preview)
|
|
66
|
+
return toEditorUrl(config, raw); // legacy fallback
|
|
67
|
+
let pathQuery = raw;
|
|
68
|
+
if (/^https?:\/\//i.test(raw)) {
|
|
69
|
+
try {
|
|
70
|
+
const u = new URL(raw);
|
|
71
|
+
pathQuery = u.pathname + u.search + u.hash;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
/* not a parseable URL — use as-is */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (!pathQuery.startsWith("/"))
|
|
78
|
+
pathQuery = `/${pathQuery}`;
|
|
79
|
+
return `${preview.replace(/\/+$/, "")}${pathQuery}`;
|
|
80
|
+
}
|
|
47
81
|
/** Build (but do not send) the create request — used for dry-run previews. */
|
|
48
82
|
export function buildRequest(config, name, source, orgId) {
|
|
49
83
|
return {
|
|
@@ -134,7 +168,7 @@ export async function createPage(config, name, source, orgId) {
|
|
|
134
168
|
status: res.status,
|
|
135
169
|
page_id: pageId,
|
|
136
170
|
editor_url: toEditorUrl(config, editorPath),
|
|
137
|
-
preview_url:
|
|
171
|
+
preview_url: toPreviewUrl(config, previewPath),
|
|
138
172
|
organization_id: (orgId ?? config.orgId) ?? null,
|
|
139
173
|
raw: data,
|
|
140
174
|
};
|
|
@@ -278,7 +312,7 @@ export async function appendSection(config, pageId, sections) {
|
|
|
278
312
|
status: res.status,
|
|
279
313
|
page_id: pageIdOut,
|
|
280
314
|
editor_url: toEditorUrl(config, data?.editor_url),
|
|
281
|
-
preview_url:
|
|
315
|
+
preview_url: toPreviewUrl(config, data?.preview_url),
|
|
282
316
|
organization_id: data?.organization_id ?? null,
|
|
283
317
|
section_count: data?.section_count,
|
|
284
318
|
sections_added: data?.sections_added,
|
|
@@ -323,8 +357,111 @@ export async function updatePageSource(config, pageId, source) {
|
|
|
323
357
|
status: res.status,
|
|
324
358
|
page_id: pageIdOut,
|
|
325
359
|
editor_url: toEditorUrl(config, data?.editor_url),
|
|
326
|
-
preview_url:
|
|
360
|
+
preview_url: toPreviewUrl(config, data?.preview_url),
|
|
327
361
|
organization_id: data?.organization_id ?? null,
|
|
328
362
|
raw: data,
|
|
329
363
|
};
|
|
330
364
|
}
|
|
365
|
+
function publishBody(sourceString, opts = {}) {
|
|
366
|
+
// The publish action expects `source` as a JSON STRING (it Jason.decode!s it),
|
|
367
|
+
// plus optional custom_domain/custom_path. is_publish marks the save as a
|
|
368
|
+
// publish in save_page_with_source.
|
|
369
|
+
return JSON.stringify({
|
|
370
|
+
source: sourceString,
|
|
371
|
+
custom_domain: opts.customDomain ?? "",
|
|
372
|
+
custom_path: opts.customPath ?? "",
|
|
373
|
+
is_publish: true,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
/** Build (but do not send) the publish request with the token masked — for dry-run previews. */
|
|
377
|
+
export function buildPublishRequestRedacted(config, pageId, sourceString, opts = {}) {
|
|
378
|
+
const body = publishBody(sourceString, opts);
|
|
379
|
+
return {
|
|
380
|
+
method: "POST",
|
|
381
|
+
url: publishUrl(config, pageId),
|
|
382
|
+
headers: { ...authHeaders(config), Authorization: "Bearer ***JWT***", Cookie: "jwt=***JWT***" },
|
|
383
|
+
body: body.replace(config.jwt, "***JWT***").slice(0, 400) + (body.length > 400 ? `… (${body.length} bytes)` : ""),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* POST to a host-scoped route. Node's fetch cannot reach `*.localhost` hosts
|
|
388
|
+
* (browsers special-case .localhost; Node's DNS does not, and undici forbids a
|
|
389
|
+
* manual Host header) — so for those we connect to loopback via node:http and
|
|
390
|
+
* carry the real host in the Host header. Everything else uses plain fetch.
|
|
391
|
+
*/
|
|
392
|
+
async function postToHost(url, headers, body) {
|
|
393
|
+
const u = new URL(url);
|
|
394
|
+
if (!u.hostname.endsWith(".localhost")) {
|
|
395
|
+
const res = await fetch(url, { method: "POST", headers, body });
|
|
396
|
+
return { status: res.status, text: await res.text() };
|
|
397
|
+
}
|
|
398
|
+
const { request } = await import("node:http");
|
|
399
|
+
return new Promise((resolve, reject) => {
|
|
400
|
+
const req = request({
|
|
401
|
+
host: "127.0.0.1",
|
|
402
|
+
port: u.port || 80,
|
|
403
|
+
path: u.pathname + u.search,
|
|
404
|
+
method: "POST",
|
|
405
|
+
headers: { ...headers, Host: u.host },
|
|
406
|
+
}, (res) => {
|
|
407
|
+
let data = "";
|
|
408
|
+
res.on("data", (c) => (data += c));
|
|
409
|
+
res.on("end", () => resolve({ status: res.statusCode ?? 0, text: data }));
|
|
410
|
+
});
|
|
411
|
+
req.on("error", reject);
|
|
412
|
+
req.end(body);
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Publish a page: saves the source as a new version and creates/updates the
|
|
417
|
+
* page_published record (live status + optional custom domain/path). Returns the
|
|
418
|
+
* resulting public URL — `https://<domain>/<path>` when a custom domain is
|
|
419
|
+
* attached, else the preview-host link (`<previewBase>/preview/<page_id>`).
|
|
420
|
+
*/
|
|
421
|
+
export async function publishPage(config, pageId, sourceString, opts = {}) {
|
|
422
|
+
const url = publishUrl(config, pageId);
|
|
423
|
+
let status;
|
|
424
|
+
let text;
|
|
425
|
+
try {
|
|
426
|
+
// The builder-host pipeline runs an `accepts ["html"]` plug (it serves the
|
|
427
|
+
// editor SPA); a literal application/json Accept gets a 406, so send */*
|
|
428
|
+
// like the browser does — the action still returns JSON.
|
|
429
|
+
({ status, text } = await postToHost(url, { ...authHeaders(config), Accept: "*/*" }, publishBody(sourceString, opts)));
|
|
430
|
+
}
|
|
431
|
+
catch (e) {
|
|
432
|
+
return { ok: false, status: 0, error: `Network error calling ${url}: ${e?.message ?? e}` };
|
|
433
|
+
}
|
|
434
|
+
let json = null;
|
|
435
|
+
try {
|
|
436
|
+
json = JSON.parse(text);
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
/* non-JSON */
|
|
440
|
+
}
|
|
441
|
+
const resOk = status >= 200 && status < 300;
|
|
442
|
+
const success = json?.success !== false && resOk;
|
|
443
|
+
if (!success) {
|
|
444
|
+
const backendMsg = json?.message ?? json?.reason ?? (json ? undefined : text.slice(0, 200));
|
|
445
|
+
return {
|
|
446
|
+
ok: false,
|
|
447
|
+
status,
|
|
448
|
+
raw: json ?? text.slice(0, 600),
|
|
449
|
+
error: `Backend returned ${status}${backendMsg ? `: ${backendMsg}` : ""}`,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
const data = json?.data ?? json;
|
|
453
|
+
const domain = data?.domain ?? null;
|
|
454
|
+
const path = data?.path ?? null;
|
|
455
|
+
const previewUrl = toPreviewUrl(config, `/preview/${pageId}`);
|
|
456
|
+
const publishedUrl = domain ? `https://${domain}${path ? `/${String(path).replace(/^\/+/, "")}` : ""}` : previewUrl;
|
|
457
|
+
return {
|
|
458
|
+
ok: true,
|
|
459
|
+
status,
|
|
460
|
+
page_id: pageId,
|
|
461
|
+
published_url: publishedUrl,
|
|
462
|
+
preview_url: previewUrl,
|
|
463
|
+
domain,
|
|
464
|
+
path,
|
|
465
|
+
raw: data,
|
|
466
|
+
};
|
|
467
|
+
}
|
package/dist/smoke.js
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
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
|
-
import { readConfig, resolveEnv, ENV_NAMES } from "./persistence/config.js";
|
|
10
|
-
import { toEditorUrl } from "./persistence/webcake-client.js";
|
|
10
|
+
import { readConfig, resolveEnv, ENV_NAMES, configFromHeaders } from "./persistence/config.js";
|
|
11
|
+
import { toEditorUrl, toPreviewUrl, buildPublishRequestRedacted } from "./persistence/webcake-client.js";
|
|
11
12
|
import { normalizePhoto, resolvePexelsKey, pexelsKeyFromHeaders, resolvePexelsProxyBase, buildSearchQuery, PEXELS_PROXY_DEFAULT } from "./persistence/pexels-client.js";
|
|
12
13
|
let failures = 0;
|
|
13
14
|
const check = (name, cond, extra) => {
|
|
@@ -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) ==");
|
|
@@ -295,7 +327,7 @@ check("clean form has no binding warnings", rbg.warnings.length === 0, rbg.warni
|
|
|
295
327
|
console.log("== config: named environment presets (local/staging/prod) ==");
|
|
296
328
|
{
|
|
297
329
|
// Deterministic: isolate from any ambient WEBCAKE_* and the saved auth.json on the dev box.
|
|
298
|
-
for (const k of ["WEBCAKE_API_BASE", "WEBCAKE_APP_BASE", "WEBCAKE_BUILDER_BASE", "WEBCAKE_ENV", "WEBCAKE_JWT", "WEBCAKE_ORG_ID"])
|
|
330
|
+
for (const k of ["WEBCAKE_API_BASE", "WEBCAKE_APP_BASE", "WEBCAKE_BUILDER_BASE", "WEBCAKE_PREVIEW_BASE", "WEBCAKE_ENV", "WEBCAKE_JWT", "WEBCAKE_ORG_ID"])
|
|
299
331
|
delete process.env[k];
|
|
300
332
|
process.env.WEBCAKE_CONFIG_DIR = "/nonexistent/webcake-smoke";
|
|
301
333
|
check("env names are local/staging/prod", setEq(new Set(ENV_NAMES), ["local", "staging", "prod"]), ENV_NAMES);
|
|
@@ -321,6 +353,23 @@ console.log("== config: named environment presets (local/staging/prod) ==");
|
|
|
321
353
|
check("editor url from a path → builder host", toEditorUrl(localCfg, "/editor/v2/abc") === "http://builder.localhost:5800/editor/v2/abc");
|
|
322
354
|
check("editor url from an absolute api url → builder host", toEditorUrl(localCfg, "http://localhost:5800/editor/v2/abc?x=1") === "http://builder.localhost:5800/editor/v2/abc?x=1");
|
|
323
355
|
check("editor url passthrough when empty", toEditorUrl(localCfg, undefined) === undefined);
|
|
356
|
+
// The PREVIEW link lives on its own root host (NOT the builder subdomain):
|
|
357
|
+
// preview.localhost:5800 / staging.webcake.me / www.webcake.me.
|
|
358
|
+
check("env presets carry preview bases", resolveEnv("local")?.previewBase === "http://preview.localhost:5800" && resolveEnv("staging")?.previewBase === "https://staging.webcake.me" && resolveEnv("prod")?.previewBase === "https://www.webcake.me");
|
|
359
|
+
check("readConfig(env=local) sets previewBase", localCfg.previewBase === "http://preview.localhost:5800", localCfg);
|
|
360
|
+
check("readConfig(env=prod) sets previewBase", readConfig({ env: "prod", jwt: "t" }).config?.previewBase === "https://www.webcake.me");
|
|
361
|
+
check("previewBase defaults to www.webcake.me without a preset", readConfig({ base: "https://api.example.com", jwt: "t" }).config?.previewBase === "https://www.webcake.me");
|
|
362
|
+
check("explicit previewBase overrides the preset", readConfig({ env: "prod", previewBase: "https://p.test/", jwt: "t" }).config?.previewBase === "https://p.test");
|
|
363
|
+
check("x-webcake-preview-base header parsed", configFromHeaders({ "x-webcake-preview-base": "https://p.example" }).previewBase === "https://p.example");
|
|
364
|
+
check("preview url from a path → preview host (not builder)", toPreviewUrl(localCfg, "/preview/abc") === "http://preview.localhost:5800/preview/abc");
|
|
365
|
+
check("preview url from an absolute api url → preview host", toPreviewUrl(localCfg, "http://localhost:5800/preview/abc?x=1") === "http://preview.localhost:5800/preview/abc?x=1");
|
|
366
|
+
check("preview url passthrough when empty", toPreviewUrl(localCfg, undefined) === undefined);
|
|
367
|
+
check("preview url falls back to builder when previewBase missing", toPreviewUrl({ ...localCfg, previewBase: undefined }, "/preview/abc") === "http://builder.localhost:5800/preview/abc");
|
|
368
|
+
// publish request preview: JWT must be masked everywhere.
|
|
369
|
+
const pub = buildPublishRequestRedacted({ ...localCfg, jwt: "SECRETJWT" }, "pg1", JSON.stringify({ page: [] }), { customDomain: "shop.example.com", customPath: "sale" });
|
|
370
|
+
check("publish request hits the editor publish route on the BUILDER host", pub.url === "http://builder.localhost:5800/api/pages/pg1/edit/publish", pub.url);
|
|
371
|
+
check("publish request masks the JWT", !JSON.stringify(pub).includes("SECRETJWT"), pub);
|
|
372
|
+
check("publish request carries domain/path + source string", pub.body.includes("shop.example.com") && pub.body.includes("custom_path") && pub.body.includes("is_publish"), pub.body);
|
|
324
373
|
}
|
|
325
374
|
console.log("== pexels: key resolution + photo normalization (offline, no network) ==");
|
|
326
375
|
{
|
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 })));
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { z } from "zod";
|
|
13
13
|
import { text } from "../mcp/response.js";
|
|
14
14
|
import { readConfig, configFromHeaders } from "../persistence/config.js";
|
|
15
|
-
import { buildRequestRedacted, buildUpdateRequestRedacted, buildAppendRequestRedacted, createPage, listOrganizations, listPages, searchPages, getPageSource, updatePageSource, appendSection, } from "../persistence/webcake-client.js";
|
|
15
|
+
import { buildRequestRedacted, buildUpdateRequestRedacted, buildAppendRequestRedacted, buildPublishRequestRedacted, createPage, listOrganizations, listPages, searchPages, getPageSource, updatePageSource, appendSection, publishPage, toPreviewUrl, } from "../persistence/webcake-client.js";
|
|
16
16
|
import { putDraft, getDraft, updateDraft, deleteDraft } from "../persistence/draft-cache.js";
|
|
17
17
|
export function registerPersistenceTools(server, domain) {
|
|
18
18
|
// Resolve config from THIS request's headers (remote per-user JWT) first, then env.
|
|
@@ -34,7 +34,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
34
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.", {
|
|
35
35
|
source: z
|
|
36
36
|
.any()
|
|
37
|
-
.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)."),
|
|
38
38
|
name: z.string().optional().describe("Page name (default 'AI Page')."),
|
|
39
39
|
organization_id: z
|
|
40
40
|
.union([z.string(), z.number()])
|
|
@@ -160,18 +160,32 @@ export function registerPersistenceTools(server, domain) {
|
|
|
160
160
|
});
|
|
161
161
|
});
|
|
162
162
|
// 11) Get page (read source) ------------------------------------------------
|
|
163
|
-
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) => {
|
|
164
170
|
const { config, missing } = cfgFor(extra);
|
|
165
171
|
if (!config)
|
|
166
172
|
return text({ ok: false, reason: "missing_env", missing_env: missing });
|
|
167
|
-
|
|
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
|
+
});
|
|
168
182
|
});
|
|
169
183
|
// 12) Update page (edit existing) -------------------------------------------
|
|
170
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.", {
|
|
171
185
|
page_id: z.string().describe("The page id to update (must be owned by the account)."),
|
|
172
186
|
source: z
|
|
173
187
|
.any()
|
|
174
|
-
.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)."),
|
|
175
189
|
dry_run: z.boolean().optional().describe("Default TRUE — preview without sending. Set false to actually save."),
|
|
176
190
|
}, { title: "Update Webcake Page (Overwrite)", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async ({ page_id, source, dry_run }, extra) => {
|
|
177
191
|
const isDry = dry_run !== false;
|
|
@@ -243,7 +257,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
243
257
|
page_id: z.string().describe("The page id to append to (from create_page or list_pages; must be owned by the account)."),
|
|
244
258
|
sections: z
|
|
245
259
|
.any()
|
|
246
|
-
.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."),
|
|
247
261
|
dry_run: z
|
|
248
262
|
.boolean()
|
|
249
263
|
.optional()
|
|
@@ -441,7 +455,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
441
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."),
|
|
442
456
|
patches: z
|
|
443
457
|
.any()
|
|
444
|
-
.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."),
|
|
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."),
|
|
445
459
|
dry_run: z
|
|
446
460
|
.boolean()
|
|
447
461
|
.optional()
|
|
@@ -677,4 +691,51 @@ export function registerPersistenceTools(server, domain) {
|
|
|
677
691
|
warnings: result.warnings,
|
|
678
692
|
});
|
|
679
693
|
});
|
|
694
|
+
// 15) Publish page (go live) -------------------------------------------------
|
|
695
|
+
server.tool("publish_page", "Publishes an EXISTING page: saves the stored source as a new version and creates/updates its page_published record (live status), optionally attaching a custom domain/path. NOT needed for the preview link — /preview/<page_id> on the preview host renders the stored source directly; publish when the user wants the page LIVE (custom domain, or the public published URL). Note: this publishes source-only (no editor-rendered HTML); pages last published from the editor with custom head/body should be re-published there. DEFAULTS to dry_run=true. Needs WEBCAKE_API_BASE + WEBCAKE_JWT.", {
|
|
696
|
+
page_id: z.string().describe("The page id to publish (must be owned by the account)."),
|
|
697
|
+
custom_domain: z
|
|
698
|
+
.string()
|
|
699
|
+
.optional()
|
|
700
|
+
.describe("Optional custom domain to serve the page at (e.g. 'shop.example.com' — must already point at Webcake). Omit to publish without a domain (served at the preview-host URL)."),
|
|
701
|
+
custom_path: z.string().optional().describe("Optional path under the custom domain (e.g. 'sale')."),
|
|
702
|
+
dry_run: z.boolean().optional().describe("Default TRUE — preview the request without sending. Set false to actually publish."),
|
|
703
|
+
}, { title: "Publish Webcake Page", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async ({ page_id, custom_domain, custom_path, dry_run }, extra) => {
|
|
704
|
+
const isDry = dry_run !== false; // default true (safe)
|
|
705
|
+
const { config, missing } = cfgFor(extra);
|
|
706
|
+
if (!config)
|
|
707
|
+
return text({ published: false, reason: "missing_env", missing_env: missing });
|
|
708
|
+
// Publish re-saves the page's CURRENT stored source (the publish endpoint
|
|
709
|
+
// requires the source in the request), so read it first — even on dry_run,
|
|
710
|
+
// to show the real payload.
|
|
711
|
+
const res = await getPageSource(config, page_id);
|
|
712
|
+
if (!res.ok || res.source == null) {
|
|
713
|
+
return text({ published: false, reason: "page_not_found", status: res.status, error: res.error ?? "No source on this page." });
|
|
714
|
+
}
|
|
715
|
+
const sourceString = JSON.stringify(res.source);
|
|
716
|
+
const opts = { customDomain: custom_domain, customPath: custom_path };
|
|
717
|
+
if (isDry) {
|
|
718
|
+
return text({
|
|
719
|
+
dry_run: true,
|
|
720
|
+
page_id,
|
|
721
|
+
name: res.name,
|
|
722
|
+
would_publish_to: custom_domain
|
|
723
|
+
? `https://${custom_domain}${custom_path ? `/${custom_path}` : ""}`
|
|
724
|
+
: toPreviewUrl(config, `/preview/${page_id}`),
|
|
725
|
+
request: buildPublishRequestRedacted(config, page_id, sourceString, opts),
|
|
726
|
+
hint: "Re-run with dry_run=false to actually publish.",
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
const outcome = await publishPage(config, page_id, sourceString, opts);
|
|
730
|
+
return text({
|
|
731
|
+
published: outcome.ok,
|
|
732
|
+
page_id,
|
|
733
|
+
url: outcome.published_url,
|
|
734
|
+
preview_url: outcome.preview_url,
|
|
735
|
+
domain: outcome.domain,
|
|
736
|
+
path: outcome.path,
|
|
737
|
+
status: outcome.status,
|
|
738
|
+
error: outcome.error,
|
|
739
|
+
});
|
|
740
|
+
});
|
|
680
741
|
}
|
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.50",
|
|
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",
|