webcake-landing-mcp 1.0.1

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.
@@ -0,0 +1,266 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://webcake/landing-page/page-schema.json",
4
+ "title": "WebcakePage",
5
+ "description": "Schema of a Webcake landing page source (the JSON stored in PageSource.source). A page is a list of absolutely-positioned section trees plus global settings. See docs/ai/page-element-schema.md for full conventions. NOTE: this is a descriptive/validation schema (not OpenAI strict-mode); specials and styles are intentionally open (additionalProperties:true) because the editor model is freeform.",
6
+ "type": "object",
7
+ "required": ["page"],
8
+ "additionalProperties": true,
9
+ "properties": {
10
+ "page": {
11
+ "type": "array",
12
+ "description": "Top-level SECTIONS stacked vertically (index 0 = top). Items are section (or dynamic_page). Popups go in the separate `popup` array, NOT here.",
13
+ "items": { "$ref": "#/$defs/element" }
14
+ },
15
+ "popup": {
16
+ "type": "array",
17
+ "description": "Separate top-level array of popup elements (type:\"popup\"). Triggered by events { action:\"open_popup\", target:<popup id> }.",
18
+ "items": { "$ref": "#/$defs/element" }
19
+ },
20
+ "dynamic_pages": {
21
+ "type": "array",
22
+ "description": "Dynamic (dataset-bound) page sections. Empty/absent for static pages.",
23
+ "items": { "$ref": "#/$defs/element" }
24
+ },
25
+ "options": {
26
+ "type": "object",
27
+ "description": "Editor options.",
28
+ "additionalProperties": true,
29
+ "properties": {
30
+ "currency": { "type": "string", "examples": ["VND", "USD"] },
31
+ "mobileOnly": { "type": "boolean", "description": "true = page only renders the mobile breakpoint." },
32
+ "versionID": { "type": ["string", "null"] }
33
+ }
34
+ },
35
+ "cartConfigs": { "type": "object", "additionalProperties": true, "description": "Cart/commerce config; {} when unused." },
36
+ "settings": {
37
+ "type": "object",
38
+ "description": "Page-wide config + SEO + fonts + tracking + custom code (real pages carry ~20-40 keys).",
39
+ "additionalProperties": true,
40
+ "properties": {
41
+ "title": { "type": "string", "description": "SEO title, <= 60 chars." },
42
+ "description": { "type": "string", "description": "SEO meta description, <= 160 chars." },
43
+ "keywords": { "type": "string", "description": "Comma-separated keywords." },
44
+ "favicon": { "type": "string" },
45
+ "thumbnail": { "type": "string", "description": "Social/share thumbnail URL." },
46
+ "fontGeneral": { "type": "string", "description": "Default page font-family, e.g. \"'Roboto', sans-serif\"." },
47
+ "width_section": {
48
+ "type": "object",
49
+ "description": "Canvas reference widths.",
50
+ "properties": { "desktop": { "type": "number" }, "mobile": { "type": "number" } }
51
+ },
52
+ "country": { "type": "string", "description": "Dialing/locale code, e.g. \"84\"." },
53
+ "fb_tracking_code": { "type": "string", "description": "Facebook pixel id." },
54
+ "tiktok_script": { "type": "string" },
55
+ "global_track_ids": { "type": "array" },
56
+ "extra_css": { "type": "string", "description": "Custom CSS injected into the page." },
57
+ "extra_script": { "type": "string", "description": "Custom JS injected into the page." },
58
+ "auto_save_draft": { "type": "boolean" },
59
+ "auto_save_info_user": { "type": "boolean" },
60
+ "send_info_to_thank_page": { "type": "boolean" }
61
+ }
62
+ }
63
+ },
64
+ "$defs": {
65
+ "id": {
66
+ "type": "string",
67
+ "pattern": "^[A-Za-z0-9_]{4,32}$",
68
+ "description": "Unique id within the whole page (~8 chars). Referenced by event.target."
69
+ },
70
+ "elementType": {
71
+ "type": "string",
72
+ "description": "Element kind. Container types (have children): section, dynamic_page, group, grid, grid-item, carousel, slide, popup, form, gallery, checkbox-group, radio, group-select.",
73
+ "enum": [
74
+ "section", "dynamic_page", "group", "grid", "grid-item", "carousel", "slide", "popup",
75
+ "text-block", "list-paragraph", "image-block", "rectangle", "line", "button", "video",
76
+ "gallery", "html-box", "editor-blog",
77
+ "form", "input", "textarea", "select", "checkbox", "checkbox-group", "radio", "address",
78
+ "country-select", "quantity_input", "input-datetime", "input-file", "signature",
79
+ "verify-code", "group-select", "group-select-item",
80
+ "list-product", "search-list-product", "cart-items", "cart-quantity", "product-select", "table",
81
+ "countdown", "timegroup", "auto-number", "random-number", "notify", "spin-wheel", "survey",
82
+ "alertMessage"
83
+ ]
84
+ },
85
+ "styles": {
86
+ "type": "object",
87
+ "description": "Per-breakpoint CSS. Positioning is absolute (px numbers). additionalProperties allowed for any camelCase CSS property.",
88
+ "additionalProperties": true,
89
+ "properties": {
90
+ "top": { "type": "number", "description": "px, absolute within container. Omit on section." },
91
+ "left": { "type": "number", "description": "px, absolute within container. Omit on section." },
92
+ "width": { "type": "number", "description": "px." },
93
+ "height": { "type": "number", "description": "px. Section uses this as canvas height." },
94
+ "position": { "type": "string", "enum": ["absolute", "relative", "fixed", "static"] },
95
+ "zIndex": { "type": "number" },
96
+ "background": { "type": "string", "description": "rgba(...) or gradient." },
97
+ "backgroundImage": { "type": "string", "description": "url(...)." },
98
+ "color": { "type": "string", "description": "rgba(r,g,b,a)." },
99
+ "fontSize": { "type": "number", "description": "px." },
100
+ "fontFamily": { "type": "string" },
101
+ "fontWeight": { "type": ["string", "number"] },
102
+ "fontStyle": { "type": "string" },
103
+ "textAlign": { "type": "string", "enum": ["start", "center", "end", "left", "right", "justify"] },
104
+ "lineHeight": { "type": ["number", "string"] },
105
+ "letterSpacing": { "type": ["number", "string"] },
106
+ "textTransform": { "type": "string" },
107
+ "textDecoration": { "type": "string" },
108
+ "borderStyle": { "type": "string", "enum": ["solid", "dashed", "dotted", "double", "none"] },
109
+ "borderWidth": { "type": "number", "description": "px." },
110
+ "borderColor": { "type": "string" },
111
+ "borderRadius": { "type": ["string", "number"] },
112
+ "boxShadow": { "type": "string" },
113
+ "opacity": { "type": "number", "minimum": 0, "maximum": 1 },
114
+ "mixBlendMode": { "type": "string" },
115
+ "transform": { "type": "string" },
116
+ "padding": { "type": ["number", "string"] },
117
+ "margin": { "type": ["number", "string"] }
118
+ }
119
+ },
120
+ "config": {
121
+ "type": "object",
122
+ "description": "Per-breakpoint non-CSS config (lazy-load flags, grid/carousel/countdown options).",
123
+ "additionalProperties": true,
124
+ "properties": {
125
+ "notloaded": { "type": "boolean" },
126
+ "animation": {
127
+ "type": "object",
128
+ "description": "Entrance animation for this element on this breakpoint.",
129
+ "additionalProperties": true,
130
+ "properties": {
131
+ "name": { "type": "string", "description": "animate.css name, or \"none\"." },
132
+ "delay": { "type": "number" },
133
+ "duration": { "type": "number" },
134
+ "repeat": { "type": ["number", "null"] }
135
+ }
136
+ },
137
+ "virtualHeight": { "type": "number" },
138
+ "overlay": { "type": "string", "description": "Overlay color rgba(...)." },
139
+ "hiddenEffect": { "type": "object", "additionalProperties": true },
140
+ "svgMask": { "type": "string" },
141
+ "lock": { "type": "boolean" },
142
+ "column": { "type": "number" },
143
+ "row": { "type": "number" },
144
+ "slideWidth": { "type": "number" },
145
+ "iconSize": { "type": "number" },
146
+ "iconTop": { "type": "number" },
147
+ "linePaddingLeft": { "type": "number" },
148
+ "topBgImage": { "type": "number" },
149
+ "leftBgImage": { "type": "number" },
150
+ "widthBgImage": { "type": "number" },
151
+ "heightBgImage": { "type": "number" }
152
+ }
153
+ },
154
+ "responsive": {
155
+ "type": "object",
156
+ "required": ["desktop", "mobile"],
157
+ "additionalProperties": false,
158
+ "properties": {
159
+ "desktop": {
160
+ "type": "object",
161
+ "additionalProperties": false,
162
+ "properties": {
163
+ "config": { "$ref": "#/$defs/config" },
164
+ "styles": { "$ref": "#/$defs/styles" }
165
+ }
166
+ },
167
+ "mobile": {
168
+ "type": "object",
169
+ "additionalProperties": false,
170
+ "properties": {
171
+ "config": { "$ref": "#/$defs/config" },
172
+ "styles": { "$ref": "#/$defs/styles" }
173
+ }
174
+ }
175
+ }
176
+ },
177
+ "properties": {
178
+ "type": "object",
179
+ "additionalProperties": true,
180
+ "properties": {
181
+ "name": { "type": "string", "description": "Display label in the layer panel." },
182
+ "movable": { "type": "boolean", "description": "false for section/slide/grid-item/popup." },
183
+ "sync": { "type": "boolean" },
184
+ "thumbnail": { "type": "string" }
185
+ }
186
+ },
187
+ "event": {
188
+ "type": "object",
189
+ "description": "Interaction. type=trigger, action=behavior, target=id/url/text depending on action.",
190
+ "required": ["type", "action"],
191
+ "additionalProperties": true,
192
+ "properties": {
193
+ "id": { "type": "string", "description": "Event id (any string; ~8 chars in real data)." },
194
+ "type": {
195
+ "type": "string",
196
+ "enum": ["click", "hover", "success", "unset"],
197
+ "description": "Trigger."
198
+ },
199
+ "action": {
200
+ "type": "string",
201
+ "description": "Behavior. Common click actions and what target holds: none(-), open_link(URL), open_popup(popup id), close_popup(popup id), scroll_to(element/section id), show_section/hide_section(section id), show_hide_element(element id), change_tab(id), lightbox(image id/url), copy(text), collapse(id), set_field_value(field_name + set_value), back_to/back_home(url), share(url), play_audio/stop_audio(id), open_cart/add_to_cart(product id), open_app(provider: botcake|whatsapp|mess_prefill|tiktok_prefill|line_prefill), change_color, custom_js. Hover actions: change_color, change_background, change_text_color, change_underline, change_overline, change_image, animation_hover, show_hide_element.",
202
+ "examples": [
203
+ "none", "open_link", "open_popup", "close_popup", "scroll_to",
204
+ "show_section", "hide_section", "show_hide_element", "copy",
205
+ "set_field_value", "add_to_cart", "open_app"
206
+ ]
207
+ },
208
+ "target": {
209
+ "type": ["string", "null"],
210
+ "description": "Element id, URL, or text depending on action (null for some, e.g. animation_hover)."
211
+ },
212
+ "appTarget": {
213
+ "type": "string",
214
+ "description": "Secondary target for open_app / link behaviors (often empty string)."
215
+ },
216
+ "hoverColor": {
217
+ "type": "string",
218
+ "description": "Color for hover color-change actions (often empty string)."
219
+ },
220
+ "set_value": {
221
+ "type": "string",
222
+ "description": "Value to assign when action = set_field_value."
223
+ }
224
+ }
225
+ },
226
+ "specials": {
227
+ "type": "object",
228
+ "description": "Type-specific content/config. The user-visible CONTENT lives here, NOT in styles. Open object; key fields by type: section{globalSection,globalSectionName,custom_class,imageCompression}; text-block{text(HTML),tag}; list-paragraph{text(<li>..)}; image-block{src,resize}; button{text,required,format,connectedSurvey}; video{typeVideo,video_cdn,img,autoReplay}; gallery{media[]}; form{field_type,form_type,sheetOrder,validate,submit_success,fb_event_type,fb_conversion_value,fb_tracking_currency,tiktok_conversion_value,tiktok_tracking_currency}; input/textarea/select/checkbox/radio/address/...{field_name,field_placeholder,field_type,required,options}; countdown{type,duration,startTime,endTime,showDay,showSecond,showText,language,customTranslation}; survey{options[{id,image,title,value,field_name}],type,multiOption,selectedBackground,selectedBorder}; list-product{format_title,numerical_order,remain_quantity_text}.",
229
+ "additionalProperties": true,
230
+ "properties": {
231
+ "text": { "type": "string", "description": "text-block/button label/list-paragraph (may contain HTML)." },
232
+ "tag": { "type": "string", "enum": ["p", "h1", "h2", "h3", "h4", "h5", "h6", "span", "div"] },
233
+ "src": { "type": "string", "description": "image-block image URL." },
234
+ "media": { "type": "array", "description": "gallery items (URLs or objects)." },
235
+ "field_name": { "type": "string", "description": "REQUIRED & unique for form inputs — the submitted data column." },
236
+ "field_placeholder": { "type": "string" },
237
+ "field_type": { "type": "string", "examples": ["text", "email", "phone", "number"] },
238
+ "required": { "type": "boolean" },
239
+ "options": { "type": "array", "description": "choices for select/radio/checkbox-group/survey/group-select-item." },
240
+ "typeVideo": { "type": "string", "examples": ["youtube", "upload", "vimeo"] },
241
+ "type": { "type": "string", "description": "sub-type for countdown/survey/etc." },
242
+ "duration": { "type": ["string", "number"] },
243
+ "imageCompression": { "type": "boolean" }
244
+ }
245
+ },
246
+ "element": {
247
+ "type": "object",
248
+ "required": ["id", "type", "properties", "responsive", "specials"],
249
+ "additionalProperties": true,
250
+ "properties": {
251
+ "id": { "$ref": "#/$defs/id" },
252
+ "type": { "$ref": "#/$defs/elementType" },
253
+ "properties": { "$ref": "#/$defs/properties" },
254
+ "responsive": { "$ref": "#/$defs/responsive" },
255
+ "specials": { "$ref": "#/$defs/specials" },
256
+ "runtime": { "type": "object", "description": "Always {} when generating.", "additionalProperties": true },
257
+ "events": { "type": "array", "items": { "$ref": "#/$defs/event" } },
258
+ "children": {
259
+ "type": "array",
260
+ "description": "Only on container types. Each child is itself an element.",
261
+ "items": { "$ref": "#/$defs/element" }
262
+ }
263
+ }
264
+ }
265
+ }
266
+ }
package/dist/smoke.js ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Offline smoke test (no MCP transport): exercises the pure logic so we can
3
+ * verify the server's building blocks without a client. Run: npm run smoke
4
+ */
5
+ import { createElement, CONTAINER_TYPES } from "./factory.js";
6
+ import { LIBRARY } from "./library.js";
7
+ import { validatePage } from "./validate.js";
8
+ let failures = 0;
9
+ const check = (name, cond, extra) => {
10
+ if (cond) {
11
+ console.log(` ok ${name}`);
12
+ }
13
+ else {
14
+ failures++;
15
+ console.log(`FAIL ${name}`, extra ?? "");
16
+ }
17
+ };
18
+ console.log("== factory: every library type produces a valid skeleton ==");
19
+ for (const type of Object.keys(LIBRARY)) {
20
+ const el = createElement(type);
21
+ const okBase = typeof el.id === "string" &&
22
+ el.type === type &&
23
+ !!el.responsive.desktop &&
24
+ !!el.responsive.mobile &&
25
+ typeof el.specials === "object";
26
+ const okChildren = CONTAINER_TYPES.has(type) ? Array.isArray(el.children) : el.children === undefined;
27
+ check(`skeleton ${type}`, okBase && okChildren, el);
28
+ }
29
+ console.log("== validate: a good page passes ==");
30
+ const good = {
31
+ page: [
32
+ {
33
+ id: "sec1",
34
+ type: "section",
35
+ properties: { name: "Hero", movable: false, sync: true },
36
+ responsive: {
37
+ desktop: { config: {}, styles: { position: "relative", height: 600, background: "rgba(17,24,39,1)" } },
38
+ mobile: { config: {}, styles: { position: "relative", height: 520, background: "rgba(17,24,39,1)" } },
39
+ },
40
+ specials: {},
41
+ runtime: {},
42
+ events: [],
43
+ children: [
44
+ {
45
+ id: "btn1",
46
+ type: "button",
47
+ properties: { name: "CTA", movable: true, sync: true },
48
+ responsive: {
49
+ desktop: { config: {}, styles: { top: 300, left: 400, width: 160, height: 44 } },
50
+ mobile: { config: {}, styles: { top: 200, left: 130, width: 160, height: 44 } },
51
+ },
52
+ specials: { text: "Mở popup" },
53
+ runtime: {},
54
+ events: [{ id: "e1", type: "click", action: "open_popup", target: "pop1" }],
55
+ },
56
+ ],
57
+ },
58
+ {
59
+ id: "pop1",
60
+ type: "popup",
61
+ properties: { name: "Thanks", movable: false, sync: true },
62
+ responsive: {
63
+ desktop: { config: {}, styles: { width: 420, height: 220 } },
64
+ mobile: { config: {}, styles: { width: 360, height: 220 } },
65
+ },
66
+ specials: {},
67
+ runtime: {},
68
+ events: [],
69
+ children: [],
70
+ },
71
+ ],
72
+ settings: { title: "Demo", description: "d", keywords: "a,b", lang: "vi" },
73
+ };
74
+ const r1 = validatePage(good);
75
+ check("good page valid", r1.valid, r1.errors);
76
+ check("good page has no dangling-target warnings", r1.warnings.length === 0, r1.warnings);
77
+ check("good page stats", r1.stats.sections === 2 && r1.stats.ids === 3, r1.stats);
78
+ console.log("== validate: catches problems ==");
79
+ const bad = {
80
+ page: [
81
+ {
82
+ id: "dup",
83
+ type: "text-block", // not a container, but has children -> error
84
+ properties: { name: "x" },
85
+ responsive: { desktop: { config: {}, styles: {} } }, // missing mobile -> error
86
+ specials: {},
87
+ children: [{ id: "dup", type: "input", properties: {}, responsive: { desktop: { config: {}, styles: {} }, mobile: { config: {}, styles: {} } }, specials: {} }],
88
+ },
89
+ ],
90
+ };
91
+ const r2 = validatePage(bad);
92
+ check("bad page invalid", !r2.valid, r2);
93
+ check("bad page detects duplicate id", r2.errors.some((e) => e.includes("Duplicate id")), r2.errors);
94
+ check("bad page detects children-on-noncontainer", r2.errors.some((e) => e.includes("not a container")), r2.errors);
95
+ check("bad page detects missing mobile", r2.errors.some((e) => e.toLowerCase().includes("mobile")), r2.errors);
96
+ check("bad page warns missing field_name", r2.warnings.some((w) => w.includes("field_name")), r2.warnings);
97
+ console.log("== validate: accepts JSON string input ==");
98
+ const r3 = validatePage(JSON.stringify(good));
99
+ check("string input parsed & valid", r3.valid, r3.errors);
100
+ console.log("== library: each example validates as a single element subtree ==");
101
+ for (const [type, doc] of Object.entries(LIBRARY)) {
102
+ if (!doc.example)
103
+ continue;
104
+ const wrapped = {
105
+ page: [
106
+ {
107
+ id: "wrapsec",
108
+ type: "section",
109
+ properties: { name: "w", movable: false, sync: true },
110
+ responsive: { desktop: { config: {}, styles: { position: "relative", height: 800 } }, mobile: { config: {}, styles: { position: "relative", height: 800 } } },
111
+ specials: {},
112
+ runtime: {},
113
+ events: [],
114
+ children: [doc.example],
115
+ },
116
+ ],
117
+ };
118
+ const rr = validatePage(wrapped);
119
+ check(`example ${type} valid`, rr.valid, rr.errors);
120
+ }
121
+ console.log(`\n${failures === 0 ? "ALL GOOD" : failures + " FAILURE(S)"}`);
122
+ process.exit(failures === 0 ? 0 : 1);
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Page validation: JSON-Schema structural check (ajv, draft 2020-12) plus
3
+ * semantic checks the schema can't express (unique ids, dangling event targets,
4
+ * children only on containers, missing field_name, top-level types).
5
+ */
6
+ import { readFileSync } from "node:fs";
7
+ import Ajv2020Module from "ajv/dist/2020.js";
8
+ import { CONTAINER_TYPES, FIELD_TYPES } from "./factory.js";
9
+ // ajv ships as CJS; under Node16 ESM the constructor is on `.default`.
10
+ const Ajv2020 = Ajv2020Module.default ?? Ajv2020Module;
11
+ // Loaded at runtime (the build copies src/page-schema.json -> dist/page-schema.json)
12
+ // to avoid JSON-import-attribute differences across Node versions.
13
+ export const pageSchema = JSON.parse(readFileSync(new URL("./page-schema.json", import.meta.url), "utf8"));
14
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
15
+ const validateSchema = ajv.compile(pageSchema);
16
+ // Actions whose `target` is expected to be an element id (vs a URL / text).
17
+ const ELEMENT_TARGET_ACTIONS = new Set([
18
+ "open_popup", "close_popup", "scroll_to", "show_section", "hide_section",
19
+ "show_hide_element", "change_tab", "play_audio", "stop_audio",
20
+ ]);
21
+ const TOP_LEVEL_TYPES = new Set(["section", "dynamic_page", "popup"]);
22
+ // Fixed canvas reference (matches library CANVAS) used for the layout/bounds check.
23
+ const CANVAS_DESKTOP = 960;
24
+ const CANVAS_MOBILE = 420;
25
+ const DEFAULT_SECTION_HEIGHT = 800;
26
+ const BOUNDS_TOL = 1; // px tolerance for rounding
27
+ const MAX_LAYOUT_WARNINGS = 12;
28
+ /** Coerce a style value (number or "300px"/"300") to a finite number, else undefined. */
29
+ function num(v) {
30
+ if (typeof v === "number")
31
+ return Number.isFinite(v) ? v : undefined;
32
+ if (typeof v === "string") {
33
+ const n = parseFloat(v);
34
+ return Number.isFinite(n) ? n : undefined;
35
+ }
36
+ return undefined;
37
+ }
38
+ /** Accept an object or a JSON string. Returns the parsed page or throws. */
39
+ export function coercePage(input) {
40
+ if (typeof input === "string")
41
+ return JSON.parse(input);
42
+ return input;
43
+ }
44
+ export function validatePage(input) {
45
+ const errors = [];
46
+ const warnings = [];
47
+ let page;
48
+ try {
49
+ page = coercePage(input);
50
+ }
51
+ catch (e) {
52
+ return { valid: false, errors: [`Invalid JSON: ${e.message}`], warnings: [], stats: { sections: 0, popups: 0, elements: 0, ids: 0 } };
53
+ }
54
+ // 1) Structural (JSON Schema)
55
+ const ok = validateSchema(page);
56
+ if (!ok && validateSchema.errors) {
57
+ for (const err of validateSchema.errors) {
58
+ errors.push(`schema ${err.instancePath || "/"} ${err.message}`);
59
+ }
60
+ }
61
+ // 2) Semantic
62
+ const ids = new Map();
63
+ const eventTargets = [];
64
+ let elementCount = 0;
65
+ const topList = Array.isArray(page?.page)
66
+ ? page.page
67
+ : page?.page
68
+ ? [page.page]
69
+ : [];
70
+ if (topList.length === 0)
71
+ errors.push('Root "page" must be a non-empty array of sections.');
72
+ topList.forEach((sec, i) => {
73
+ if (sec && sec.type && !TOP_LEVEL_TYPES.has(sec.type)) {
74
+ warnings.push(`page[${i}] is type "${sec.type}"; top-level items are normally section/dynamic_page/popup.`);
75
+ }
76
+ });
77
+ const walk = (node, path) => {
78
+ if (!node || typeof node !== "object")
79
+ return;
80
+ elementCount++;
81
+ if (typeof node.id === "string")
82
+ ids.set(node.id, (ids.get(node.id) || 0) + 1);
83
+ else
84
+ errors.push(`${path}: missing string "id".`);
85
+ const type = node.type;
86
+ if (typeof type !== "string")
87
+ errors.push(`${path}: missing "type".`);
88
+ // responsive presence
89
+ if (!node.responsive?.desktop || !node.responsive?.mobile) {
90
+ errors.push(`${path} (${type}): must have responsive.desktop AND responsive.mobile.`);
91
+ }
92
+ // children only on containers
93
+ if (Array.isArray(node.children) && node.children.length > 0 && type && !CONTAINER_TYPES.has(type)) {
94
+ errors.push(`${path} (${type}): has children but "${type}" is not a container type.`);
95
+ }
96
+ // form fields need field_name
97
+ if (type && FIELD_TYPES.has(type)) {
98
+ const fn = node.specials?.field_name;
99
+ if (!fn || typeof fn !== "string" || fn.trim() === "") {
100
+ warnings.push(`${path} (${type}): form input should have a unique specials.field_name.`);
101
+ }
102
+ }
103
+ // collect events
104
+ if (Array.isArray(node.events)) {
105
+ for (const ev of node.events) {
106
+ if (ev && typeof ev.action === "string" && typeof ev.target === "string") {
107
+ eventTargets.push({ from: node.id ?? path, action: ev.action, target: ev.target });
108
+ }
109
+ }
110
+ }
111
+ if (Array.isArray(node.children)) {
112
+ node.children.forEach((c, idx) => walk(c, `${path}.children[${idx}]`));
113
+ }
114
+ };
115
+ topList.forEach((sec, i) => walk(sec, `page[${i}]`));
116
+ // popups + dynamic_pages are SEPARATE top-level element arrays (not inside `page`)
117
+ const popups = Array.isArray(page?.popup) ? page.popup : [];
118
+ const dynPages = Array.isArray(page?.dynamic_pages) ? page.dynamic_pages : [];
119
+ popups.forEach((p, i) => {
120
+ if (p && p.type && p.type !== "popup") {
121
+ warnings.push(`popup[${i}] has type "${p.type}"; entries of "popup" should be type "popup".`);
122
+ }
123
+ walk(p, `popup[${i}]`);
124
+ });
125
+ dynPages.forEach((p, i) => walk(p, `dynamic_pages[${i}]`));
126
+ // duplicate ids
127
+ for (const [id, count] of ids) {
128
+ if (count > 1)
129
+ errors.push(`Duplicate id "${id}" used ${count} times — ids must be unique.`);
130
+ }
131
+ // dangling element-target events
132
+ for (const t of eventTargets) {
133
+ if (ELEMENT_TARGET_ACTIONS.has(t.action)) {
134
+ const cleaned = t.target.replace(/^#?w-/, "");
135
+ if (!ids.has(t.target) && !ids.has(cleaned)) {
136
+ warnings.push(`event on "${t.from}" action="${t.action}" target="${t.target}" does not match any element id.`);
137
+ }
138
+ }
139
+ }
140
+ // 3) Layout bounds — flag children that fall off their container's canvas (a
141
+ // common cause of "off-center / misaligned" pages). Warnings only.
142
+ let layoutWarnings = 0;
143
+ const widthSection = page?.settings?.width_section ?? {};
144
+ const rootCanvasD = num(widthSection.desktop) ?? CANVAS_DESKTOP;
145
+ const rootCanvasM = num(widthSection.mobile) ?? CANVAS_MOBILE;
146
+ const checkBounds = (container, canvasWD, canvasHD, canvasWM, canvasHM, path) => {
147
+ if (!container || !Array.isArray(container.children))
148
+ return;
149
+ container.children.forEach((child, idx) => {
150
+ if (!child || typeof child !== "object")
151
+ return;
152
+ const cpath = `${path}.children[${idx}]`;
153
+ const label = `${cpath} (${child.type ?? "?"})`;
154
+ for (const [bp, canvasW, canvasH] of [
155
+ ["desktop", canvasWD, canvasHD],
156
+ ["mobile", canvasWM, canvasHM],
157
+ ]) {
158
+ const styles = child?.responsive?.[bp]?.styles;
159
+ if (!styles)
160
+ continue;
161
+ const left = num(styles.left) ?? 0;
162
+ const top = num(styles.top) ?? 0;
163
+ const width = num(styles.width);
164
+ const height = num(styles.height);
165
+ if (layoutWarnings < MAX_LAYOUT_WARNINGS) {
166
+ if (left < -BOUNDS_TOL) {
167
+ warnings.push(`${label} ${bp}: left=${left} is negative (off-canvas left). Set left ≥ 0.`);
168
+ layoutWarnings++;
169
+ }
170
+ else if (width != null && left + width > canvasW + BOUNDS_TOL) {
171
+ warnings.push(`${label} ${bp}: left+width=${left + width} exceeds canvas ${canvasW} (overflows right). To center: left = round((${canvasW} - ${width})/2) = ${Math.round((canvasW - width) / 2)}.`);
172
+ layoutWarnings++;
173
+ }
174
+ }
175
+ if (layoutWarnings < MAX_LAYOUT_WARNINGS && top < -BOUNDS_TOL) {
176
+ warnings.push(`${label} ${bp}: top=${top} is negative (above its section). Set top ≥ 0.`);
177
+ layoutWarnings++;
178
+ }
179
+ if (layoutWarnings < MAX_LAYOUT_WARNINGS &&
180
+ canvasH > 0 &&
181
+ height != null &&
182
+ top + height > canvasH + BOUNDS_TOL) {
183
+ warnings.push(`${label} ${bp}: top+height=${top + height} exceeds container height ${canvasH} (extends below). Move it up or increase the section/container height.`);
184
+ layoutWarnings++;
185
+ }
186
+ }
187
+ // Recurse into nested containers using the child's own box as the canvas.
188
+ if (Array.isArray(child.children) && child.children.length > 0) {
189
+ const ds = child?.responsive?.desktop?.styles ?? {};
190
+ const ms = child?.responsive?.mobile?.styles ?? {};
191
+ checkBounds(child, num(ds.width) ?? canvasWD, num(ds.height) ?? canvasHD, num(ms.width) ?? canvasWM, num(ms.height) ?? canvasHM, cpath);
192
+ }
193
+ });
194
+ };
195
+ topList.forEach((sec, i) => {
196
+ const ds = sec?.responsive?.desktop?.styles ?? {};
197
+ const ms = sec?.responsive?.mobile?.styles ?? {};
198
+ checkBounds(sec, rootCanvasD, num(ds.height) ?? DEFAULT_SECTION_HEIGHT, rootCanvasM, num(ms.height) ?? DEFAULT_SECTION_HEIGHT, `page[${i}]`);
199
+ });
200
+ popups.forEach((p, i) => {
201
+ const ds = p?.responsive?.desktop?.styles ?? {};
202
+ const ms = p?.responsive?.mobile?.styles ?? {};
203
+ checkBounds(p, num(ds.width) ?? rootCanvasD, num(ds.height) ?? DEFAULT_SECTION_HEIGHT, num(ms.width) ?? rootCanvasM, num(ms.height) ?? DEFAULT_SECTION_HEIGHT, `popup[${i}]`);
204
+ });
205
+ return {
206
+ valid: errors.length === 0,
207
+ errors,
208
+ warnings,
209
+ stats: { sections: topList.length, popups: popups.length, elements: elementCount, ids: ids.size },
210
+ };
211
+ }