webcake-landing-mcp 1.0.61 → 1.0.62
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/dist/changelog.json +7 -7
- package/dist/domains/landing/guide.js +1 -1
- package/dist/domains/landing/instructions.js +2 -2
- package/dist/domains/landing/validate.js +124 -25
- package/dist/mcp/response.js +12 -0
- package/dist/smoke.js +46 -0
- package/dist/tools/generation.js +3 -3
- package/dist/tools/persistence.js +25 -25
- package/package.json +1 -1
package/dist/changelog.json
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "1.0.62",
|
|
4
|
+
"d": "11/06/2026",
|
|
5
|
+
"type": "Added",
|
|
6
|
+
"en": "validate_page now warns when a text-block's estimated rendered height overflows onto a sibling element placed directly below its declared box; the…",
|
|
7
|
+
"vi": "validate_page nay cảnh báo khi chiều cao render ước tính của text-block tràn xuống phần tử anh em đặt ngay phía dưới khung khai báo; cảnh báo nêu…"
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"v": "1.0.61",
|
|
4
11
|
"d": "11/06/2026",
|
|
@@ -33,12 +40,5 @@
|
|
|
33
40
|
"type": "Added",
|
|
34
41
|
"en": "New upload_images tool re-hosts up to 20 external image URLs or data: URIs as Webcake-hosted URLs (statics.pancake.vn) by downloading and uploading…",
|
|
35
42
|
"vi": "Công cụ upload_images mới tải lại tối đa 20 URL ảnh ngoài hoặc data: URI thành URL do Webcake lưu trữ (statics.pancake.vn) bằng cách tải về và…"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"v": "1.0.56",
|
|
39
|
-
"d": "11/06/2026",
|
|
40
|
-
"type": "Added",
|
|
41
|
-
"en": "The expand pipeline now automatically derives styles.background from specials.src for every image-block node; the live published renderer reads only…",
|
|
42
|
-
"vi": "Pipeline expand nay tự động tính styles.background từ specials.src cho mọi node image-block; renderer trên trang published chỉ đọc…"
|
|
43
43
|
}
|
|
44
44
|
]
|
|
@@ -141,7 +141,7 @@ WORKFLOW (recommended)
|
|
|
141
141
|
3. Optionally call new_element to get a correct skeleton, then fill specials + coordinates.
|
|
142
142
|
3b. For every image the page needs (hero, product, about, feature, gallery): if the slot has a source image (user-supplied or from the reference HTML/URL), upload_images it and use the returned Webcake URL; otherwise call search_images and put a returned URL into specials.src / gallery item.link. Use placehold.co ONLY when search_images returns ok:false.
|
|
143
143
|
4. Assemble { page, popup, settings, options, cartConfigs }.
|
|
144
|
-
5. Call validate_page and fix every error.
|
|
144
|
+
5. Call validate_page and fix every error AND every warning — warnings are visible defects (text spilling onto the element below, off-canvas boxes, empty bands at a section's bottom, missing field_name, dead event targets), not advisory polish. Re-validate until the warning list is empty; only a demonstrably false positive may remain (tell the user which and why).
|
|
145
145
|
6. To save: call list_organizations. If the account has EXACTLY ONE organization, create_page will auto-select it — no need to ask. If there are MULTIPLE organizations, show them to the user and ask which to use (highlight is_default as the suggested default); pass the chosen organization_id to create_page. If the user explicitly wants to save without any organization, pass organization_id:"personal". Then create_page (dry_run first, then dry_run:false). Note: create_page itself enforces this — it refuses to guess between multiple orgs and returns the org list asking you to pick.
|
|
146
146
|
|
|
147
147
|
EDITING an existing page
|
|
@@ -9,9 +9,9 @@ RULES (follow for every request):
|
|
|
9
9
|
- INTAKE FIRST — do this EVERY time, even for a "quick"/"test" page. Do NOT jump straight to new_page_skeleton/create_page on the same turn as the request: ask the essentials, restate an outline, get a "yes", THEN build. Ask ONE short batch (3–6, with sensible defaults so the user answers fast) enough to understand the page's PURPOSE, name, look and layout: page purpose/goal, brand/page name, what they sell + price (sales/ads pages), primary color + logo/branding, sections & layout in order, primary CTA + destination, desktop+mobile or mobile-only, which organization. CONSULT, don't interrogate: SUGGEST so the user reacts to something concrete — propose a section flow (pick the archetype matching the page type) + a look (hero treatment + color/tone), and when the user is vague offer 2–3 directions to choose from; proactively suggest sections that fit their goal (social-proof, FAQ, countdown), but ask, don't silently add. Then restate the proposed design (section flow + CTA + color/tone) and WAIT for the user's confirmation, iterating until it matches their intent, before generating. Never assume or silently placeholder the page name, product, price, or colors — ask; only placeholder a core fact when the user explicitly declines to give it.
|
|
10
10
|
- ASK for any real data the page will display — never invent it, and don't silently placeholder it. This includes: phone/hotline/Zalo, price (+ original price), address, shop/brand name, links/URLs, email, opening hours, and exact stats/social-proof numbers. If a value the page needs is missing, ASK the user for it (in intake, or pause and ask before generating). Use a clearly-labelled placeholder ONLY when the user explicitly says to skip it — then tell them exactly what to fill in.
|
|
11
11
|
- LANGUAGE: write ALL page copy in the SAME language the user is chatting in, with FULL, CORRECT diacritics/accents. For Vietnamese, every word MUST carry its proper dấu (e.g. "Trân Trọng Kính Mời", "Ngày 15 Tháng 08 Năm 2025") — NEVER emit accent-stripped "không dấu" text. Never romanize or drop accent marks from any language.
|
|
12
|
-
- ALWAYS call validate_page and fix every error before create_page / update_page.
|
|
12
|
+
- ALWAYS call validate_page and fix every error before create_page / update_page. WARNINGS ARE A FIX LIST, NOT NOISE: each warning is a visible defect the customer will see (text spilling onto the element below, off-canvas boxes, empty bands, missing field_name, dead event targets). Fix every warning too — before the first save when building new, or via patch_page right after saving an edit — and re-validate until the list is empty. Only a warning you can demonstrate is a false positive may remain (name it to the user and say why). Never tell the user the page is done while warnings stand.
|
|
13
13
|
- BUILD THE SOURCE IN ONE PASS — gather everything you need BEFORE assembling the source, then build the FULL tree once. BATCH the reads: when a section needs several element types (section + text-block + image-block + button + form + input), call get_element({types:[…]}) ONCE instead of one call per type — same for images, call search_images({queries:[…]}) ONCE with one query per image slot (it dedups + parallelizes and returns one best photo per query). Across MULTIPLE pages in one conversation: reusing skeletons you already fetched is correct, but call get_element for any type you have NOT already fetched in this conversation — NEVER author an unfamiliar type from memory; and if the earlier skeletons are no longer in your context (long conversation, compacted history), re-fetch before building. Do NOT interleave get_element calls between create_page previews and rebuild. create_page/update_page take the entire source as input, so each call re-ships the whole page — re-previewing repeatedly wastes the request.
|
|
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.
|
|
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, warnings fixed), 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
17
|
- FIX-AFTER-ERROR / RETRY-AFTER-TIMEOUT (don't rebuild — this is the #1 time-waster): every mutating tool (create_page, update_page, add_section, patch_page) writes the payload to a draft cache BEFORE the network call and returns a draft_id. On validation failure, timeout, or any network error, the draft is kept — retry or fix without re-sending the full JSON:
|
|
@@ -61,6 +61,25 @@ function num(v) {
|
|
|
61
61
|
}
|
|
62
62
|
return undefined;
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Estimated rendered height (px) of a text-block's specials.text at a given
|
|
66
|
+
* fontSize/width — the guide's TEXT HEIGHT MATH (lines ≈ ceil(chars × fontSize
|
|
67
|
+
* × 0.55 / width), height ≈ lines × fontSize × 1.4; lines counted per explicit
|
|
68
|
+
* <br> segment). Returns undefined for empty text or template variables
|
|
69
|
+
* ({{…}}) whose rendered length is unknown.
|
|
70
|
+
*/
|
|
71
|
+
function estTextHeight(rawText, fs, w) {
|
|
72
|
+
if (rawText.includes("{{") || !(fs > 0) || !(w > 0))
|
|
73
|
+
return undefined;
|
|
74
|
+
const segments = rawText
|
|
75
|
+
.split(/<br\s*\/?>/i)
|
|
76
|
+
.map((s) => s.replace(/<[^>]*>/g, "").replace(/ | /g, " ").trim())
|
|
77
|
+
.filter((s) => s !== "");
|
|
78
|
+
if (segments.length === 0)
|
|
79
|
+
return undefined;
|
|
80
|
+
const lines = segments.reduce((acc, seg) => acc + Math.max(1, Math.ceil((seg.length * fs * 0.55) / w)), 0);
|
|
81
|
+
return Math.round(lines * fs * 1.4);
|
|
82
|
+
}
|
|
64
83
|
/**
|
|
65
84
|
* True when a CSS color string carries real hue — i.e. NOT white/black/grey/
|
|
66
85
|
* transparent. Used to flag a page that ships with no color at all (every band
|
|
@@ -325,31 +344,22 @@ export function validatePage(input) {
|
|
|
325
344
|
// Wrapped-text overflow: live text height is AUTO — text that wraps to
|
|
326
345
|
// more lines than the declared box spills DOWN and overlaps the element
|
|
327
346
|
// below (the classic "2-line card title over the card body" defect).
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
if (!w || !h || w <= 0 || fs <= 0)
|
|
345
|
-
continue;
|
|
346
|
-
const lines = segments.reduce((acc, seg) => acc + Math.max(1, Math.ceil((seg.length * fs * 0.55) / w)), 0);
|
|
347
|
-
const lineH = fs * 1.4;
|
|
348
|
-
const est = Math.round(lines * lineH);
|
|
349
|
-
if (est > h + lineH) {
|
|
350
|
-
warnings.push(`${path} (text-block) [${bp}]: text wraps to ~${lines} lines (~${est}px) but the box is only ${h}px tall — live text height is AUTO, so it will spill down and overlap the element below. Set height ≈ ${est}px and push the elements below down (estimate: lines ≈ ceil(chars × fontSize × 0.55 / width), height ≈ lines × fontSize × 1.4).`);
|
|
351
|
-
}
|
|
352
|
-
}
|
|
347
|
+
// Slack is capped at 24px: a full-line slack on a 40px heading (56px)
|
|
348
|
+
// is exactly what lets the most common defect — a 2-line H2 on a
|
|
349
|
+
// 1-line-tall box — slip through, while body text (16px → 22px line)
|
|
350
|
+
// keeps its old tolerance against the rough estimate.
|
|
351
|
+
for (const bp of ["desktop", "mobile"]) {
|
|
352
|
+
const styles = node.responsive?.[bp]?.styles;
|
|
353
|
+
const w = num(styles?.width);
|
|
354
|
+
const h = num(styles?.height);
|
|
355
|
+
const fs = num(styles?.fontSize) ?? 16;
|
|
356
|
+
if (!w || !h)
|
|
357
|
+
continue;
|
|
358
|
+
const est = estTextHeight(rawText, fs, w);
|
|
359
|
+
if (est == null)
|
|
360
|
+
continue;
|
|
361
|
+
if (est > h + Math.min(fs * 1.4, 24)) {
|
|
362
|
+
warnings.push(`${path} (text-block) [${bp}]: text wraps to ~${est}px but the box is only ${h}px tall — live text height is AUTO, so it will spill down and overlap the element below. Set height ≈ ${est}px and push the elements below down (estimate: lines ≈ ceil(chars × fontSize × 0.55 / width), height ≈ lines × fontSize × 1.4).`);
|
|
353
363
|
}
|
|
354
364
|
}
|
|
355
365
|
}
|
|
@@ -696,6 +706,95 @@ export function validatePage(input) {
|
|
|
696
706
|
const ms = sec?.responsive?.mobile?.styles ?? {};
|
|
697
707
|
checkBounds(sec, rootCanvasD, num(ds.height) ?? DEFAULT_SECTION_HEIGHT, rootCanvasM, num(ms.height) ?? DEFAULT_SECTION_HEIGHT, `page[${i}]`);
|
|
698
708
|
});
|
|
709
|
+
// 3c) Wrapped-text collision — live text height is AUTO, so a text-block whose
|
|
710
|
+
// content wraps past its declared box spills DOWN. When the declared layout
|
|
711
|
+
// puts a sibling directly below (boxes NOT overlapping — overlapping boxes
|
|
712
|
+
// are intentional layering), the spill lands ON that sibling: the classic
|
|
713
|
+
// broken-looking page of a 2-line H2 over its subheading or a wrapped card
|
|
714
|
+
// title over the card body. The own-box check (section 1) flags the text
|
|
715
|
+
// block itself; this geometric pass names the VICTIM and the exact fix.
|
|
716
|
+
let overlapWarnings = 0;
|
|
717
|
+
const MAX_OVERLAP_WARNINGS = 12;
|
|
718
|
+
const checkTextOverlap = (container, path) => {
|
|
719
|
+
if (!container || !Array.isArray(container.children))
|
|
720
|
+
return;
|
|
721
|
+
const kids = container.children;
|
|
722
|
+
kids.forEach((child, idx) => {
|
|
723
|
+
if (!child || typeof child !== "object")
|
|
724
|
+
return;
|
|
725
|
+
const cpath = `${path}.children[${idx}]`;
|
|
726
|
+
const rawText = child.type === "text-block" ? child.specials?.text : undefined;
|
|
727
|
+
if (typeof rawText === "string") {
|
|
728
|
+
for (const bp of ["desktop", "mobile"]) {
|
|
729
|
+
if (overlapWarnings >= MAX_OVERLAP_WARNINGS)
|
|
730
|
+
break;
|
|
731
|
+
const s = child.responsive?.[bp]?.styles;
|
|
732
|
+
const top = num(s?.top);
|
|
733
|
+
const left = num(s?.left) ?? 0;
|
|
734
|
+
const w = num(s?.width);
|
|
735
|
+
const h = num(s?.height);
|
|
736
|
+
const fs = num(s?.fontSize) ?? 16;
|
|
737
|
+
if (top == null || h == null || !w)
|
|
738
|
+
continue;
|
|
739
|
+
const est = estTextHeight(rawText, fs, w);
|
|
740
|
+
if (est == null || est <= h)
|
|
741
|
+
continue;
|
|
742
|
+
const estBottom = top + est;
|
|
743
|
+
// nearest sibling the declared layout places below this text block
|
|
744
|
+
let hit;
|
|
745
|
+
kids.forEach((sib, j) => {
|
|
746
|
+
if (j === idx || !sib || typeof sib !== "object")
|
|
747
|
+
return;
|
|
748
|
+
const ss = sib.responsive?.[bp]?.styles;
|
|
749
|
+
const st = num(ss?.top);
|
|
750
|
+
const sl = num(ss?.left) ?? 0;
|
|
751
|
+
const sw = num(ss?.width);
|
|
752
|
+
if (st == null || sw == null)
|
|
753
|
+
return;
|
|
754
|
+
if (st < top + h)
|
|
755
|
+
return; // declared boxes overlap or sibling is above → layering, skip
|
|
756
|
+
if (sl + sw <= left || sl >= left + w)
|
|
757
|
+
return; // no horizontal intersection
|
|
758
|
+
if (estBottom > st + 4 && (!hit || st < hit.t))
|
|
759
|
+
hit = { p: `${path}.children[${j}]`, t: st, type: sib.type ?? "?" };
|
|
760
|
+
});
|
|
761
|
+
if (hit) {
|
|
762
|
+
warnings.push(`${cpath} (text-block) [${bp}]: wrapped text renders ~${est}px tall (declared ${h}px) and will spill onto ${hit.p} (${hit.type}, top=${hit.t}) below it. Set this block's height ≈ ${est} and move the elements below to top ≥ ${estBottom + 8}.`);
|
|
763
|
+
overlapWarnings++;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
if (Array.isArray(child.children) && child.children.length > 0)
|
|
768
|
+
checkTextOverlap(child, cpath);
|
|
769
|
+
});
|
|
770
|
+
};
|
|
771
|
+
topList.forEach((sec, i) => checkTextOverlap(sec, `page[${i}]`));
|
|
772
|
+
// 3d) Trailing dead space — a section far taller than its lowest content
|
|
773
|
+
// renders as a big empty band, which reads as a broken/unfinished page.
|
|
774
|
+
// Threshold is generous (320px) since text auto-grow and bottom padding
|
|
775
|
+
// are legitimate; advisory only.
|
|
776
|
+
topList.forEach((sec, i) => {
|
|
777
|
+
if (!sec || !Array.isArray(sec.children) || sec.children.length === 0)
|
|
778
|
+
return;
|
|
779
|
+
for (const bp of ["desktop", "mobile"]) {
|
|
780
|
+
const sh = num(sec.responsive?.[bp]?.styles?.height);
|
|
781
|
+
if (sh == null)
|
|
782
|
+
continue;
|
|
783
|
+
let maxBottom;
|
|
784
|
+
for (const child of sec.children) {
|
|
785
|
+
const s = child?.responsive?.[bp]?.styles;
|
|
786
|
+
const t = num(s?.top);
|
|
787
|
+
const h = num(s?.height);
|
|
788
|
+
if (t == null || h == null)
|
|
789
|
+
continue;
|
|
790
|
+
if (maxBottom == null || t + h > maxBottom)
|
|
791
|
+
maxBottom = t + h;
|
|
792
|
+
}
|
|
793
|
+
if (maxBottom != null && sh > maxBottom + 320) {
|
|
794
|
+
warnings.push(`page[${i}] (section) [${bp}]: height=${sh} but the lowest child ends at ${maxBottom} — ~${sh - maxBottom}px of empty band at the bottom of the section. Reduce the section height to ≈ ${maxBottom + 80} (or move content down into the band).`);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
});
|
|
699
798
|
// 3b) Page margin axis — every band (header included) should put its
|
|
700
799
|
// left-anchored content on ONE shared left margin. A header/section that
|
|
701
800
|
// starts on a different left than the rest is the #1 "looks misaligned"
|
package/dist/mcp/response.js
CHANGED
|
@@ -6,3 +6,15 @@ export function text(value) {
|
|
|
6
6
|
const body = typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
|
7
7
|
return { content: [{ type: "text", text: body }] };
|
|
8
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* Directive shipped alongside every non-empty validation-warnings list.
|
|
11
|
+
* Warnings are design defects the customer WILL see (text overlapping the
|
|
12
|
+
* element below, off-canvas boxes, empty bands, dead event targets, missing
|
|
13
|
+
* field_name…) — without this, models treat them as advisory noise and save
|
|
14
|
+
* anyway.
|
|
15
|
+
*/
|
|
16
|
+
export const WARNINGS_NOTICE = "FIX THESE WARNINGS — each one is a visible defect the customer will see, not a suggestion. Apply the fix each warning prescribes (patch_page by element id is the cheap path), then re-validate until the list is empty. Only a warning you can demonstrate is a false positive may remain — name it to the user and say why. Do NOT report the page as done while warnings stand.";
|
|
17
|
+
/** Spread helper: {} when there are no warnings, else { warnings, warnings_notice }. */
|
|
18
|
+
export function warningsField(warnings) {
|
|
19
|
+
return warnings && warnings.length > 0 ? { warnings, warnings_notice: WARNINGS_NOTICE } : {};
|
|
20
|
+
}
|
package/dist/smoke.js
CHANGED
|
@@ -8,6 +8,7 @@ import { validatePage, pageSchema } from "./domains/landing/validate.js";
|
|
|
8
8
|
import { expandSource } from "./core/expand.js";
|
|
9
9
|
import { compactSource, deepEq, sparseTemplate } from "./core/compact.js";
|
|
10
10
|
import { parseHtml } from "./persistence/html-ingest.js";
|
|
11
|
+
import { warningsField } from "./mcp/response.js";
|
|
11
12
|
import { readConfig, resolveEnv, ENV_NAMES, configFromHeaders } from "./persistence/config.js";
|
|
12
13
|
import { toEditorUrl, toPreviewUrl, buildPublishRequestRedacted } from "./persistence/webcake-client.js";
|
|
13
14
|
import { normalizePhoto, resolvePexelsKey, pexelsKeyFromHeaders, resolvePexelsProxyBase, buildSearchQuery, PEXELS_PROXY_DEFAULT } from "./persistence/pexels-client.js";
|
|
@@ -964,5 +965,50 @@ console.log("== text-block styles.background warning (gradient-text-fill mode) =
|
|
|
964
965
|
});
|
|
965
966
|
check("text-block: no styles.background → no gradient-fill warning", !rNoBg2.warnings.some((w) => w.includes("gradient text-fill")), rNoBg2.warnings);
|
|
966
967
|
}
|
|
968
|
+
console.log("== warningsField: warnings ship with the fix-list directive ==");
|
|
969
|
+
{
|
|
970
|
+
const withW = warningsField(["page[0]: something"]);
|
|
971
|
+
check("warningsField: non-empty warnings carry warnings_notice", Array.isArray(withW.warnings) && typeof withW.warnings_notice === "string" && withW.warnings_notice.includes("FIX THESE WARNINGS"), withW);
|
|
972
|
+
check("warningsField: empty list adds nothing", Object.keys(warningsField([])).length === 0 && Object.keys(warningsField(undefined)).length === 0);
|
|
973
|
+
}
|
|
974
|
+
console.log("== validator: wrapped-text collision + trailing dead space ==");
|
|
975
|
+
{
|
|
976
|
+
const tb = (id, top, height, text, fontSize, extra = {}) => ({
|
|
977
|
+
id, type: "text-block",
|
|
978
|
+
responsive: {
|
|
979
|
+
desktop: { styles: { top, left: 80, width: 560, height, fontSize, ...extra } },
|
|
980
|
+
mobile: { styles: { top, left: 20, width: 380, height, fontSize: Math.round(fontSize * 0.7), ...extra } },
|
|
981
|
+
},
|
|
982
|
+
specials: { text, tag: "p" },
|
|
983
|
+
});
|
|
984
|
+
const sect = (children, height = 800) => ({
|
|
985
|
+
page: [{ id: "csec", type: "section", responsive: { desktop: { styles: { height } }, mobile: { styles: { height } } }, children }],
|
|
986
|
+
settings: { title: "t", description: "d", keywords: "k", lang: "vi" },
|
|
987
|
+
});
|
|
988
|
+
const headline = "The challenges every hotel faces today"; // wraps to 2 lines at 40px/560w
|
|
989
|
+
// 2-line H2 on a 1-line box with the subheading right under the declared box → both checks fire
|
|
990
|
+
const rClash = validatePage(expandSource(sect([tb("h2", 120, 50, headline, 40), tb("sub", 180, 60, "Guests reach out from everywhere and teams are stretched thin.", 16)]), createElement));
|
|
991
|
+
check("collision: 2-line H2 over subheading warned (names the victim)", rClash.warnings.some((w) => w.includes("spill onto") && w.includes("children[1]")), rClash.warnings);
|
|
992
|
+
check("own-box: 2-line H2 on 1-line box no longer slips the one-line slack", rClash.warnings.some((w) => w.includes("children[0]") && w.includes("spill down")), rClash.warnings);
|
|
993
|
+
// properly sized heading + subheading pushed below the estimated bottom → silent
|
|
994
|
+
const rOk = validatePage(expandSource(sect([tb("h2", 120, 112, headline, 40), tb("sub", 260, 60, "Guests reach out from everywhere and teams are stretched thin.", 16)]), createElement));
|
|
995
|
+
check("collision: sized heading + pushed-down subheading → no overlap warning", !rOk.warnings.some((w) => w.includes("spill")), rOk.warnings);
|
|
996
|
+
// layered background rectangle (declared boxes overlap) must NOT count as a victim
|
|
997
|
+
const card = {
|
|
998
|
+
id: "card", type: "group",
|
|
999
|
+
responsive: { desktop: { styles: { top: 100, left: 80, width: 280, height: 300 } }, mobile: { styles: { top: 100, left: 20, width: 280, height: 300 } } },
|
|
1000
|
+
children: [
|
|
1001
|
+
{ id: "bg", type: "rectangle", responsive: { desktop: { styles: { top: 0, left: 0, width: 280, height: 300 } }, mobile: { styles: { top: 0, left: 0, width: 280, height: 300 } } } },
|
|
1002
|
+
tb("title", 24, 30, "Guests who book once and disappear forever", 22, { left: 24, width: 232 }),
|
|
1003
|
+
],
|
|
1004
|
+
};
|
|
1005
|
+
const rCard = validatePage(expandSource(sect([card]), createElement));
|
|
1006
|
+
check("collision: layered card background is not a victim", !rCard.warnings.some((w) => w.includes("spill onto") && w.includes("rectangle")), rCard.warnings);
|
|
1007
|
+
// trailing dead space: section 900 tall, content ends at 300
|
|
1008
|
+
const rDead = validatePage(expandSource(sect([tb("h2", 200, 100, "Short", 40)], 900), createElement));
|
|
1009
|
+
check("dead space: 600px empty band at section bottom warned", rDead.warnings.some((w) => w.includes("empty band")), rDead.warnings);
|
|
1010
|
+
const rTight = validatePage(expandSource(sect([tb("h2", 200, 100, "Short", 40)], 500), createElement));
|
|
1011
|
+
check("dead space: 200px bottom padding not flagged", !rTight.warnings.some((w) => w.includes("empty band")), rTight.warnings);
|
|
1012
|
+
}
|
|
967
1013
|
console.log(`\n${failures === 0 ? "ALL GOOD" : failures + " FAILURE(S)"}`);
|
|
968
1014
|
process.exit(failures === 0 ? 0 : 1);
|
package/dist/tools/generation.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { sparseTemplate } from "../core/compact.js";
|
|
8
|
-
import { text } from "../mcp/response.js";
|
|
8
|
+
import { text, warningsField } from "../mcp/response.js";
|
|
9
9
|
export function registerGenerationTools(server, domain) {
|
|
10
10
|
// 5) New element ------------------------------------------------------------
|
|
11
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).", {
|
|
@@ -23,7 +23,7 @@ export function registerGenerationTools(server, domain) {
|
|
|
23
23
|
// 6) New page skeleton ------------------------------------------------------
|
|
24
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 })));
|
|
25
25
|
// 7) Validate page ----------------------------------------------------------
|
|
26
|
-
server.tool("validate_page", "Validates a page source against the schema + semantic rules (unique ids, dangling event targets, children only on containers, missing field_name, top-level types) plus form-data bindings (duplicate field_name within one form, dangling option-event promoId / connectedSurvey / connectedForm / set_field_value targets). Returns errors (blocking) and warnings (
|
|
26
|
+
server.tool("validate_page", "Validates a page source against the schema + semantic rules (unique ids, dangling event targets, children only on containers, missing field_name, top-level types) plus form-data bindings (duplicate field_name within one form, dangling option-event promoId / connectedSurvey / connectedForm / set_field_value targets). Returns errors (blocking — fix before persisting) and warnings (visible design defects — fix these too and re-validate to an empty list; only a demonstrably false positive may remain).", {
|
|
27
27
|
page: z
|
|
28
28
|
.any()
|
|
29
29
|
.describe("The page source object { page:[...], settings:{} } OR a JSON string of it."),
|
|
@@ -31,6 +31,6 @@ export function registerGenerationTools(server, domain) {
|
|
|
31
31
|
// Hydrate sparse nodes (the model may omit boilerplate) before validating,
|
|
32
32
|
// so what we check is the same full tree that create_page/add_section persist.
|
|
33
33
|
const result = domain.validate(domain.expand(page));
|
|
34
|
-
return text(result);
|
|
34
|
+
return text({ ...result, ...warningsField(result.warnings) });
|
|
35
35
|
});
|
|
36
36
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* hosted server is multi-user; in stdio/single-user mode they come from env.
|
|
11
11
|
*/
|
|
12
12
|
import { z } from "zod";
|
|
13
|
-
import { text } from "../mcp/response.js";
|
|
13
|
+
import { text, warningsField } from "../mcp/response.js";
|
|
14
14
|
import { readConfig, configFromHeaders } from "../persistence/config.js";
|
|
15
15
|
import { buildRequestRedacted, buildUpdateRequestRedacted, buildAppendRequestRedacted, buildPublishRequestRedacted, buildPageApp, 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";
|
|
@@ -102,7 +102,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
102
102
|
created: false,
|
|
103
103
|
reason: "validation_failed",
|
|
104
104
|
errors: result.errors,
|
|
105
|
-
|
|
105
|
+
...warningsField(result.warnings),
|
|
106
106
|
draft_id: existingDraftId,
|
|
107
107
|
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.",
|
|
108
108
|
});
|
|
@@ -143,7 +143,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
143
143
|
}
|
|
144
144
|
return text({
|
|
145
145
|
dry_run: true,
|
|
146
|
-
validation: { valid: true,
|
|
146
|
+
validation: { valid: true, ...warningsField(result.warnings), stats: result.stats },
|
|
147
147
|
...(largePageAdvisory ? { large_page_advisory: largePageAdvisory } : {}),
|
|
148
148
|
env_ready: missing.length === 0,
|
|
149
149
|
missing_env: missing,
|
|
@@ -241,7 +241,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
241
241
|
return text({
|
|
242
242
|
created: true,
|
|
243
243
|
...outcome,
|
|
244
|
-
|
|
244
|
+
...warningsField(result.warnings),
|
|
245
245
|
...(organizationAutoSelected ? { organization_auto_selected: true } : {}),
|
|
246
246
|
...(organizationNote ? { note: organizationNote } : {}),
|
|
247
247
|
});
|
|
@@ -251,7 +251,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
251
251
|
return text({
|
|
252
252
|
created: false,
|
|
253
253
|
...outcome,
|
|
254
|
-
|
|
254
|
+
...warningsField(result.warnings),
|
|
255
255
|
draft_id: existingDraftId,
|
|
256
256
|
hint: `Create failed — source is cached. Retry via create_page({ draft_id: "${existingDraftId}", dry_run: false }) or fix elements via patch_page({ draft_id: "${existingDraftId}", patches:[…], dry_run:false }). The draft expires in ~30 min.`,
|
|
257
257
|
});
|
|
@@ -392,7 +392,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
392
392
|
updated: false,
|
|
393
393
|
reason: "validation_failed",
|
|
394
394
|
errors: result.errors,
|
|
395
|
-
|
|
395
|
+
...warningsField(result.warnings),
|
|
396
396
|
draft_id: existingDraftId,
|
|
397
397
|
hint: `Fix the errors, then retry update_page({ draft_id: "${existingDraftId}", dry_run:false }) — no need to re-send source. Or use patch_page({ page_id: "${resolvedPageId}", patches:[…] }) for surgical fixes.`,
|
|
398
398
|
});
|
|
@@ -411,7 +411,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
411
411
|
return text({
|
|
412
412
|
dry_run: true,
|
|
413
413
|
page_id: resolvedPageId,
|
|
414
|
-
validation: { valid: true,
|
|
414
|
+
validation: { valid: true, ...warningsField(result.warnings), stats: result.stats },
|
|
415
415
|
env_ready: missing.length === 0,
|
|
416
416
|
missing_env: missing,
|
|
417
417
|
draft_id: existingDraftId,
|
|
@@ -433,13 +433,13 @@ export function registerPersistenceTools(server, domain) {
|
|
|
433
433
|
const outcome = await updatePageSource(config, resolvedPageId, parsed);
|
|
434
434
|
if (outcome.ok) {
|
|
435
435
|
deleteDraft(existingDraftId);
|
|
436
|
-
return text({ updated: true, ...outcome,
|
|
436
|
+
return text({ updated: true, ...outcome, ...warningsField(result.warnings) });
|
|
437
437
|
}
|
|
438
438
|
updateDraft(existingDraftId, expanded);
|
|
439
439
|
return text({
|
|
440
440
|
updated: false,
|
|
441
441
|
...outcome,
|
|
442
|
-
|
|
442
|
+
...warningsField(result.warnings),
|
|
443
443
|
draft_id: existingDraftId,
|
|
444
444
|
hint: `Update failed — source is cached. Retry via update_page({ draft_id: "${existingDraftId}", dry_run: false }) or fix elements via patch_page({ page_id: "${resolvedPageId}", patches:[…] }). The draft expires in ~30 min.`,
|
|
445
445
|
});
|
|
@@ -569,7 +569,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
569
569
|
added: false,
|
|
570
570
|
reason: "validation_failed",
|
|
571
571
|
errors: result.errors,
|
|
572
|
-
|
|
572
|
+
...warningsField(result.warnings),
|
|
573
573
|
draft_id: existingDraftId,
|
|
574
574
|
hint: "Do NOT rebuild the section batch — 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 shell and appends the sections. A wrong element type → { op:'update', id:'<element id>', type:'<allowed type>' }. The draft expires in ~30 min.",
|
|
575
575
|
});
|
|
@@ -597,7 +597,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
597
597
|
page_id,
|
|
598
598
|
sections_added: newSections.length,
|
|
599
599
|
section_labels: labels,
|
|
600
|
-
validation: { valid: true,
|
|
600
|
+
validation: { valid: true, ...warningsField(result.warnings), stats: result.stats },
|
|
601
601
|
draft_id: existingDraftId,
|
|
602
602
|
request: buildAppendRequestRedacted(config, page_id, newSections),
|
|
603
603
|
note: "The backend appends these to the END of `page` and rejects duplicate element ids across the live tree.",
|
|
@@ -632,7 +632,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
632
632
|
preview_url: outcome.preview_url,
|
|
633
633
|
status: outcome.status,
|
|
634
634
|
error: outcome.error,
|
|
635
|
-
|
|
635
|
+
...warningsField(result.warnings),
|
|
636
636
|
...(outcome.ok ? {} : {
|
|
637
637
|
draft_id: existingDraftId,
|
|
638
638
|
hint: "Append failed — the section batch is still cached. Fix the listed error (e.g. a duplicate id vs the live tree can be changed via patch_page({ draft_id, patches:[{op:'replace',id:'<old-id>',element:{…,id:'<new-id>'}}] })) then retry add_section({ page_id, draft_id, dry_run:false }).",
|
|
@@ -669,7 +669,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
669
669
|
added: false,
|
|
670
670
|
reason: "validation_failed",
|
|
671
671
|
errors: mergedResult.errors,
|
|
672
|
-
|
|
672
|
+
...warningsField(mergedResult.warnings),
|
|
673
673
|
page_section_count: counts,
|
|
674
674
|
hint: "Fix the section(s) — duplicate ids vs existing sections are a common cause — then retry.",
|
|
675
675
|
});
|
|
@@ -689,7 +689,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
689
689
|
preview_url: fbOutcome.preview_url,
|
|
690
690
|
status: fbOutcome.status,
|
|
691
691
|
error: fbOutcome.error,
|
|
692
|
-
|
|
692
|
+
...warningsField(mergedResult.warnings),
|
|
693
693
|
});
|
|
694
694
|
});
|
|
695
695
|
// 14) Patch page (surgical element edit / fix-after-error) -------------------
|
|
@@ -928,7 +928,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
928
928
|
patched: false,
|
|
929
929
|
reason: "validation_failed",
|
|
930
930
|
errors: result.errors,
|
|
931
|
-
|
|
931
|
+
...warningsField(result.warnings),
|
|
932
932
|
patches_applied: applied,
|
|
933
933
|
draft_id,
|
|
934
934
|
hint: "Still invalid — fix the remaining errors with another patch_page({ draft_id, patches:[…] }). Your applied fixes are kept in the draft.",
|
|
@@ -954,7 +954,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
954
954
|
draft_id,
|
|
955
955
|
page_id: targetPageId,
|
|
956
956
|
patches_applied: applied,
|
|
957
|
-
validation: { valid: true,
|
|
957
|
+
validation: { valid: true, ...warningsField(result.warnings), stats: result.stats },
|
|
958
958
|
env_ready: missing.length === 0,
|
|
959
959
|
missing_env: missing,
|
|
960
960
|
request: config
|
|
@@ -985,7 +985,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
985
985
|
preview_url: outcome.preview_url,
|
|
986
986
|
status: outcome.status,
|
|
987
987
|
error: outcome.error,
|
|
988
|
-
|
|
988
|
+
...warningsField(result.warnings),
|
|
989
989
|
...(outcome.ok ? {} : { draft_id, hint: `Append failed — fixes kept in draft. Retry patch_page({ draft_id: "${draft_id}", dry_run:false }) after resolving the error.` }),
|
|
990
990
|
});
|
|
991
991
|
}
|
|
@@ -1008,7 +1008,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
1008
1008
|
draft_id,
|
|
1009
1009
|
page_id: targetPageId,
|
|
1010
1010
|
patches_applied: applied,
|
|
1011
|
-
validation: { valid: true,
|
|
1011
|
+
validation: { valid: true, ...warningsField(result.warnings), stats: result.stats },
|
|
1012
1012
|
env_ready: missing.length === 0,
|
|
1013
1013
|
missing_env: missing,
|
|
1014
1014
|
request: config
|
|
@@ -1038,7 +1038,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
1038
1038
|
preview_url: outcome.preview_url,
|
|
1039
1039
|
status: outcome.status,
|
|
1040
1040
|
error: outcome.error,
|
|
1041
|
-
|
|
1041
|
+
...warningsField(result.warnings),
|
|
1042
1042
|
...(outcome.ok ? {} : { draft_id, hint: `Update failed — fixes kept in draft. Retry patch_page({ draft_id: "${draft_id}", dry_run:false }) after resolving the error.` }),
|
|
1043
1043
|
});
|
|
1044
1044
|
}
|
|
@@ -1049,7 +1049,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
1049
1049
|
dry_run: true,
|
|
1050
1050
|
draft_id,
|
|
1051
1051
|
patches_applied: applied,
|
|
1052
|
-
validation: { valid: true,
|
|
1052
|
+
validation: { valid: true, ...warningsField(result.warnings), stats: result.stats },
|
|
1053
1053
|
env_ready: missing.length === 0,
|
|
1054
1054
|
missing_env: missing,
|
|
1055
1055
|
request: config
|
|
@@ -1081,7 +1081,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
1081
1081
|
preview_url: outcome.preview_url,
|
|
1082
1082
|
status: outcome.status,
|
|
1083
1083
|
error: outcome.error,
|
|
1084
|
-
|
|
1084
|
+
...warningsField(result.warnings),
|
|
1085
1085
|
...(outcome.ok ? {} : { draft_id, hint: `Create failed — fixes kept in draft. Retry patch_page({ draft_id: "${draft_id}", dry_run:false }) or resolve the error first.` }),
|
|
1086
1086
|
});
|
|
1087
1087
|
}
|
|
@@ -1091,7 +1091,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
1091
1091
|
patched: false,
|
|
1092
1092
|
reason: "validation_failed",
|
|
1093
1093
|
errors: result.errors,
|
|
1094
|
-
|
|
1094
|
+
...warningsField(result.warnings),
|
|
1095
1095
|
patches_applied: applied,
|
|
1096
1096
|
hint: "The edit produced an invalid tree — fix the listed errors in your ops, then retry.",
|
|
1097
1097
|
});
|
|
@@ -1105,7 +1105,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
1105
1105
|
dry_run: true,
|
|
1106
1106
|
page_id,
|
|
1107
1107
|
patches_applied: applied,
|
|
1108
|
-
validation: { valid: true,
|
|
1108
|
+
validation: { valid: true, ...warningsField(result.warnings), stats: result.stats },
|
|
1109
1109
|
draft_id: liveDraftId,
|
|
1110
1110
|
request: buildUpdateRequestRedacted(config, page_id, parsed),
|
|
1111
1111
|
hint: `Re-run patch_page({ draft_id: "${liveDraftId}", dry_run: false }) — no need to re-send patches. Or re-run with page_id + dry_run:false.`,
|
|
@@ -1121,7 +1121,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
1121
1121
|
editor_url: outcome.editor_url,
|
|
1122
1122
|
preview_url: outcome.preview_url,
|
|
1123
1123
|
status: outcome.status,
|
|
1124
|
-
|
|
1124
|
+
...warningsField(result.warnings),
|
|
1125
1125
|
});
|
|
1126
1126
|
}
|
|
1127
1127
|
// Network failure / timeout: keep the update draft for retry.
|
|
@@ -1132,7 +1132,7 @@ export function registerPersistenceTools(server, domain) {
|
|
|
1132
1132
|
page_id: outcome.page_id ?? page_id,
|
|
1133
1133
|
status: outcome.status,
|
|
1134
1134
|
error: outcome.error,
|
|
1135
|
-
|
|
1135
|
+
...warningsField(result.warnings),
|
|
1136
1136
|
draft_id: liveDraftId,
|
|
1137
1137
|
hint: `Save failed — the patched source is cached. Retry via patch_page({ draft_id: "${liveDraftId}", dry_run: false }) with no patches. The draft expires in ~30 min.`,
|
|
1138
1138
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webcake-landing-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.62",
|
|
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",
|