webcake-landing-mcp 1.0.3 → 1.0.4
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/index.js +5 -2
- package/dist/library.js +53 -23
- package/dist/smoke.js +57 -0
- package/dist/validate.js +89 -3
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
18
18
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
19
19
|
import { z } from "zod";
|
|
20
20
|
import { createElement, createPageSource } from "./factory.js";
|
|
21
|
-
import { LIBRARY, GENERATION_GUIDE, CANVAS, CLICK_ACTIONS, HOVER_ACTIONS, EVENT_TRIGGERS, } from "./library.js";
|
|
21
|
+
import { LIBRARY, GENERATION_GUIDE, CANVAS, CLICK_ACTIONS, HOVER_ACTIONS, SUCCESS_ACTIONS, ERROR_ACTIONS, DELAY_ACTIONS, EVENT_TRIGGERS, } from "./library.js";
|
|
22
22
|
import { validatePage, coercePage, pageSchema } from "./validate.js";
|
|
23
23
|
import { readConfig, buildRequestRedacted, createPage, listOrganizations, listPages, getPageSource, updatePageSource, buildUpdateRequestRedacted, } from "./webcake.js";
|
|
24
24
|
const ALL_TYPES = Object.keys(LIBRARY);
|
|
@@ -52,6 +52,9 @@ server.tool("get_generation_guide", "Read this FIRST. Conventions for building a
|
|
|
52
52
|
event_triggers: EVENT_TRIGGERS,
|
|
53
53
|
click_actions: CLICK_ACTIONS,
|
|
54
54
|
hover_actions: HOVER_ACTIONS,
|
|
55
|
+
success_actions: SUCCESS_ACTIONS,
|
|
56
|
+
error_actions: ERROR_ACTIONS,
|
|
57
|
+
delay_actions: DELAY_ACTIONS,
|
|
55
58
|
}));
|
|
56
59
|
// 2) List elements ------------------------------------------------------------
|
|
57
60
|
server.tool("list_elements", "List every supported element type, grouped by category, with a one-line summary and whether it is a container (can hold children).", async () => {
|
|
@@ -100,7 +103,7 @@ server.tool("new_element", "Return a structurally-valid default element node for
|
|
|
100
103
|
// 5) Page schema --------------------------------------------------------------
|
|
101
104
|
server.tool("get_page_schema", "Return the full JSON Schema (Draft 2020-12) of a Webcake page source object { page: [...], settings: {...} }. Use it to understand the exact structure or for your own validation.", async () => text(pageSchema));
|
|
102
105
|
// 6) Validate page ------------------------------------------------------------
|
|
103
|
-
server.tool("validate_page", "Validate a generated page source against the schema + semantic rules (unique ids, dangling event targets, children only on containers, missing field_name, top-level types). Returns errors (must fix) and warnings. ALWAYS run before returning the final page.", {
|
|
106
|
+
server.tool("validate_page", "Validate a generated 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 (must fix) and warnings. ALWAYS run before returning the final page.", {
|
|
104
107
|
page: z
|
|
105
108
|
.any()
|
|
106
109
|
.describe("The page source object { page:[...], settings:{} } OR a JSON string of it."),
|
package/dist/library.js
CHANGED
|
@@ -6,42 +6,72 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export const CANVAS = { desktopWidth: 960, mobileWidth: 420, defaultSectionHeight: 800 };
|
|
8
8
|
export const EVENT_TRIGGERS = ["click", "hover", "success", "error", "unset", "delay"];
|
|
9
|
+
// Click-trigger actions. "Extra:" lists the action-specific event-object fields
|
|
10
|
+
// the dispatcher reads beyond { id, type, action, target } (render_v4/event/index.js).
|
|
9
11
|
export const CLICK_ACTIONS = {
|
|
10
12
|
none: "Do nothing.",
|
|
11
|
-
open_link: "Open a URL. target = URL (
|
|
12
|
-
open_popup: "Open a popup. target = popup element id.",
|
|
13
|
-
close_popup: "Close a popup. target = popup element id.",
|
|
14
|
-
scroll_to: "Smooth-scroll to an element. target = element/section id.",
|
|
13
|
+
open_link: "Open a URL. target = URL. Extra: targetURL ('_blank'|'_self'), open_link_with_params (bool), send_to_thank_page (bool), delayTime (seconds).",
|
|
14
|
+
open_popup: "Open a popup. target = popup element id. Extra: animation, reverseAnimation.",
|
|
15
|
+
close_popup: "Close a popup. target = popup element id. Extra: animation.",
|
|
16
|
+
scroll_to: "Smooth-scroll to an element. target = element/section id. Extra: scrollMore (bonus px offset).",
|
|
15
17
|
show_section: "Show a hidden section. target = section id.",
|
|
16
18
|
hide_section: "Hide a section. target = section id.",
|
|
17
|
-
show_hide_element: "Toggle element visibility. target = element id.",
|
|
18
|
-
change_tab: "Switch tab. target = id.",
|
|
19
|
-
lightbox: "Open
|
|
20
|
-
copy: "Copy to clipboard. target = the text; OR an element id when copyType='elementValue'.",
|
|
21
|
-
collapse: "Collapse/expand. target = id.",
|
|
22
|
-
set_field_value: "Set a form field value. target = field_name
|
|
19
|
+
show_hide_element: "Toggle element visibility. target = element id (comma-separated list allowed). Extra: onlyMode ('show'|'hide'), animation, animationOut.",
|
|
20
|
+
change_tab: "Switch tab/slide in a gallery/carousel. target = container id. Extra: moveTo ('prev'|'next'|'index'), tabIndex.",
|
|
21
|
+
lightbox: "Open in a lightbox. target = image/video/iframe URL. Extra: typeLightbox ('image'|'video'|'iframe'), alt.",
|
|
22
|
+
copy: "Copy to clipboard. target = the text; OR an element id when copyType='elementValue'. Extra: copyType.",
|
|
23
|
+
collapse: "Collapse/expand. target = element id.",
|
|
24
|
+
set_field_value: "Set a form field value. target = field_name (or w-<element id>). Extra: set_value (the value to set).",
|
|
23
25
|
back_to: "Go back in browser history (history.back()). target = none.",
|
|
24
26
|
share: "Share the current page URL. target = platform name: 'Facebook'|'Twitter'|'Custom'.",
|
|
25
27
|
play_audio: "Play audio. target = audio file URL (NOT an element id).",
|
|
26
28
|
stop_audio: "Stop audio. target = the same audio file URL (NOT an element id).",
|
|
27
|
-
open_sms: "Send SMS. target = phone number
|
|
29
|
+
open_sms: "Send SMS. target = phone number. Extra: smsBody (message body).",
|
|
28
30
|
send_email: "Open mail client. target = email address (mailto:).",
|
|
29
|
-
download_file: "Download a file. target = file URL
|
|
31
|
+
download_file: "Download a file. target = file URL. Extra: nameFile (overrides the saved filename).",
|
|
30
32
|
close_webview: "Close a Facebook/Messenger in-app webview. target = none.",
|
|
31
|
-
open_cart: "Open cart.",
|
|
32
|
-
add_to_cart: "Add product to cart.
|
|
33
|
-
open_app: "Open chat/app. event.appTarget selects the provider (botcake|botcake_dynamic|whatsapp|mess_prefill|tiktok_prefill|line_prefill|others); target = destination URL/phone/ref.",
|
|
34
|
-
change_color: "Change color.",
|
|
35
|
-
custom_js: "Run custom JS.",
|
|
33
|
+
open_cart: "Open the cart drawer (WCart).",
|
|
34
|
+
add_to_cart: "Add a product to the cart. Uses specials.sprod/svariant/squantity (or event.sprod_id/svariant/squantity); target unused.",
|
|
35
|
+
open_app: "Open chat/app. event.appTarget selects the provider (botcake|botcake_dynamic|whatsapp|mess_prefill|tiktok_prefill|line_prefill|others); target = destination URL/phone/ref. Extra: wa_custom_text, line_custom_text, formIdLink (per provider).",
|
|
36
|
+
change_color: "Change a color. Acts on the trigger element, or target_element for a cross-element change. Extra: change_color_type, change_color, target_mode, target_element.",
|
|
37
|
+
custom_js: "Run custom JS. Extra: custom_js (the code string).",
|
|
36
38
|
};
|
|
37
39
|
export const HOVER_ACTIONS = {
|
|
38
|
-
change_color: "Change color on hover.",
|
|
39
|
-
change_background: "Change background on hover.",
|
|
40
|
-
change_text_color: "Change text color on hover.",
|
|
40
|
+
change_color: "Change color on hover. Extra: change_color, change_color_type, hoverText, hoverBorder, target_mode, target_element.",
|
|
41
|
+
change_background: "Change background on hover. Extra: hoverColor (applied via --hover-color).",
|
|
42
|
+
change_text_color: "Change text color on hover. Extra: hoverText.",
|
|
41
43
|
change_underline: "Underline on hover.",
|
|
42
44
|
change_overline: "Overline on hover.",
|
|
43
|
-
animation_hover: "Play a hover animation.",
|
|
44
|
-
show_hide_element: "Reveal/hide a target element on hover.",
|
|
45
|
+
animation_hover: "Play a hover animation. target = none.",
|
|
46
|
+
show_hide_element: "Reveal/hide a target element on hover. target = element id. Extra: animation, animationOut.",
|
|
47
|
+
};
|
|
48
|
+
// Actions on a FORM's own events array, fired AFTER a successful submit (type:"success").
|
|
49
|
+
// target semantics match the click action of the same name.
|
|
50
|
+
export const SUCCESS_ACTIONS = {
|
|
51
|
+
phone_call: "Call a number. target = phone number (tel:).",
|
|
52
|
+
open_sms: "Send SMS. target = phone number. Extra: smsBody.",
|
|
53
|
+
send_email: "Open mail client. target = email address.",
|
|
54
|
+
open_link: "Open a URL. target = URL. Extra: targetURL ('_blank'|'_self').",
|
|
55
|
+
scroll_to: "Scroll to an element. target = element id. Extra: scrollMore.",
|
|
56
|
+
open_popup: "Open a popup. target = popup id.",
|
|
57
|
+
close_popup: "Close a popup. target = popup id.",
|
|
58
|
+
download_file: "Download a file. target = file URL. Extra: nameFile.",
|
|
59
|
+
show_hide_element: "Toggle visibility. target = element id. Extra: onlyMode.",
|
|
60
|
+
show_section: "Show a section. target = section id.",
|
|
61
|
+
hide_section: "Hide a section. target = section id.",
|
|
62
|
+
close_webview: "Close a Facebook/Messenger webview. target = none.",
|
|
63
|
+
change_tab: "Switch tab/slide. target = container id. Extra: moveTo, tabIndex.",
|
|
64
|
+
};
|
|
65
|
+
// Actions on a FORM's events array, fired when validation FAILS (type:"error").
|
|
66
|
+
export const ERROR_ACTIONS = {
|
|
67
|
+
open_popup: "Open a popup. target = popup id.",
|
|
68
|
+
close_popup: "Close a popup. target = popup id.",
|
|
69
|
+
show_hide_element: "Toggle visibility. target = element id. Extra: onlyMode.",
|
|
70
|
+
};
|
|
71
|
+
// Actions on ANY element's events array, fired when it scrolls into view (type:"delay").
|
|
72
|
+
export const DELAY_ACTIONS = {
|
|
73
|
+
show_element: "Reveal this element after a delay. Extra: delay_multiplier (ms, default 1000).",
|
|
74
|
+
hide_element: "Hide this element after a delay. Extra: delay_multiplier (ms, default 1000).",
|
|
45
75
|
};
|
|
46
76
|
export const LIBRARY = {
|
|
47
77
|
// ---------------- layout / containers ----------------
|
|
@@ -717,7 +747,7 @@ RULES
|
|
|
717
747
|
- CONTRAST: text must contrast with the section background (dark text on light sections, light text on dark sections). Don't put light-gray text on white or faint text on a dark background.
|
|
718
748
|
- movable:false for section/slide/grid-item/popup; otherwise true. runtime is always {}.
|
|
719
749
|
- Every form input MUST have a unique specials.field_name.
|
|
720
|
-
- events item: { "id", "type"
|
|
750
|
+
- events item: { "id", "type", "action", "target", ...action-specific extra fields }. TRIGGER (type): click & hover on any element; success & error on a FORM (success = after a successful submit, error = on validation failure); delay on any element (when it scrolls into view); unset on init. Action vocab per trigger: click→CLICK_ACTIONS, hover→HOVER_ACTIONS, success→SUCCESS_ACTIONS, error→ERROR_ACTIONS, delay→DELAY_ACTIONS (all returned by get_generation_guide). For element-targeting actions (open_popup, close_popup, scroll_to, show_section, hide_section, show_hide_element, change_tab, collapse) target = the target element's id; open_link/download_file target = URL; open_sms/send_email/phone_call target = phone/email; copy target = text (or element id when copyType='elementValue'); set_field_value target = field_name; target may be null (e.g. animation_hover). Each action also reads extra fields (e.g. open_link→targetURL/delayTime, scroll_to→scrollMore, change_tab→moveTo/tabIndex, lightbox→typeLightbox/alt, show_hide_element→onlyMode, open_app→appTarget+provider fields, set_field_value→set_value) — see the action maps for the full list.
|
|
721
751
|
- ANIMATION: each breakpoint's config has config.animation = { "name":"none", "delay":0, "duration":3, "repeat":null }. Keep "none" unless an entrance animation is wanted.
|
|
722
752
|
- Do NOT invent prices, phone numbers, addresses, or statistics. Output text in the requested language.
|
|
723
753
|
|
package/dist/smoke.js
CHANGED
|
@@ -118,5 +118,62 @@ for (const [type, doc] of Object.entries(LIBRARY)) {
|
|
|
118
118
|
const rr = validatePage(wrapped);
|
|
119
119
|
check(`example ${type} valid`, rr.valid, rr.errors);
|
|
120
120
|
}
|
|
121
|
+
console.log("== validate: form-data binding checks ==");
|
|
122
|
+
const mkBox = () => ({ desktop: { config: {}, styles: {} }, mobile: { config: {}, styles: {} } });
|
|
123
|
+
const bindingsBad = {
|
|
124
|
+
page: [
|
|
125
|
+
{
|
|
126
|
+
id: "secf", type: "section",
|
|
127
|
+
properties: { name: "F", movable: false, sync: true },
|
|
128
|
+
responsive: { desktop: { config: {}, styles: { position: "relative", height: 800 } }, mobile: { config: {}, styles: { position: "relative", height: 800 } } },
|
|
129
|
+
specials: {}, runtime: {}, events: [],
|
|
130
|
+
children: [
|
|
131
|
+
{
|
|
132
|
+
id: "frm1", type: "form",
|
|
133
|
+
properties: { name: "Form", movable: true, sync: true },
|
|
134
|
+
responsive: mkBox(), specials: {}, runtime: {}, events: [],
|
|
135
|
+
children: [
|
|
136
|
+
{ id: "i1", type: "input", properties: {}, responsive: mkBox(), specials: { field_name: "phone_number" }, events: [] },
|
|
137
|
+
{ id: "i2", type: "input", properties: {}, responsive: mkBox(), specials: { field_name: "phone_number" }, events: [] },
|
|
138
|
+
{ id: "rad1", type: "radio", properties: {}, responsive: mkBox(),
|
|
139
|
+
specials: { field_name: "opt", options: [{ id: "o1", events_option: [{ id: "e", type: "showhide", promoId: "ghost_target" }] }] },
|
|
140
|
+
runtime: {}, events: [], children: [] },
|
|
141
|
+
{ id: "sv1", type: "survey", properties: {}, responsive: mkBox(), specials: { field_name: "sv", connectedForm: "missing_field" }, events: [] },
|
|
142
|
+
{ id: "b1", type: "button", properties: {}, responsive: mkBox(), specials: { text: "X" },
|
|
143
|
+
events: [{ id: "ev", type: "click", action: "set_field_value", target: "w-nope" }] },
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
};
|
|
150
|
+
const rbb = validatePage(bindingsBad);
|
|
151
|
+
check("dup field_name in form warned", rbb.warnings.some((w) => w.includes('field_name "phone_number"') && w.includes("used 2")), rbb.warnings);
|
|
152
|
+
check("dangling option promoId warned", rbb.warnings.some((w) => w.includes("promoId") && w.includes("ghost_target")), rbb.warnings);
|
|
153
|
+
check("dangling connectedForm warned", rbb.warnings.some((w) => w.includes("connectedForm") && w.includes("missing_field")), rbb.warnings);
|
|
154
|
+
check("dangling set_field_value element ref warned", rbb.warnings.some((w) => w.includes("set_field_value") && w.includes("w-nope")), rbb.warnings);
|
|
155
|
+
const bindingsGood = {
|
|
156
|
+
page: [
|
|
157
|
+
{
|
|
158
|
+
id: "secg", type: "section",
|
|
159
|
+
properties: { name: "G", movable: false, sync: true },
|
|
160
|
+
responsive: { desktop: { config: {}, styles: { position: "relative", height: 800 } }, mobile: { config: {}, styles: { position: "relative", height: 800 } } },
|
|
161
|
+
specials: {}, runtime: {}, events: [],
|
|
162
|
+
children: [
|
|
163
|
+
{
|
|
164
|
+
id: "frm2", type: "form",
|
|
165
|
+
properties: { name: "Form", movable: true, sync: true },
|
|
166
|
+
responsive: mkBox(), specials: {}, runtime: {}, events: [],
|
|
167
|
+
children: [
|
|
168
|
+
{ id: "n1", type: "input", properties: {}, responsive: mkBox(), specials: { field_name: "full_name" }, events: [] },
|
|
169
|
+
{ id: "p1", type: "input", properties: {}, responsive: mkBox(), specials: { field_name: "phone_number" }, events: [] },
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
};
|
|
176
|
+
const rbg = validatePage(bindingsGood);
|
|
177
|
+
check("clean form has no binding warnings", rbg.warnings.length === 0, rbg.warnings);
|
|
121
178
|
console.log(`\n${failures === 0 ? "ALL GOOD" : failures + " FAILURE(S)"}`);
|
|
122
179
|
process.exit(failures === 0 ? 0 : 1);
|
package/dist/validate.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Page validation: JSON-Schema structural check (ajv, draft 2020-12) plus
|
|
3
3
|
* semantic checks the schema can't express (unique ids, dangling event targets,
|
|
4
|
-
* children only on containers, missing field_name, top-level types).
|
|
4
|
+
* children only on containers, missing field_name, top-level types). Also checks
|
|
5
|
+
* form-data bindings: duplicate field_name within a single form, and dangling
|
|
6
|
+
* option-level event targets (specials.options[].events_option promoId) and
|
|
7
|
+
* survey/field cross-wiring (connectedSurvey / connectedForm / set_field_value).
|
|
5
8
|
*/
|
|
6
9
|
import { readFileSync } from "node:fs";
|
|
7
10
|
import Ajv2020Module from "ajv/dist/2020.js";
|
|
@@ -66,6 +69,12 @@ export function validatePage(input) {
|
|
|
66
69
|
// 2) Semantic
|
|
67
70
|
const ids = new Map();
|
|
68
71
|
const eventTargets = [];
|
|
72
|
+
// option-level events (specials.options[].events_option) targeting an element id
|
|
73
|
+
const optionTargets = [];
|
|
74
|
+
// survey/field cross-wiring (specials.connectedSurvey / connectedForm)
|
|
75
|
+
const connectRefs = [];
|
|
76
|
+
// form nodes — used to check field_name uniqueness within each form's scope
|
|
77
|
+
const forms = [];
|
|
69
78
|
let elementCount = 0;
|
|
70
79
|
const topList = Array.isArray(page?.page)
|
|
71
80
|
? page.page
|
|
@@ -113,6 +122,33 @@ export function validatePage(input) {
|
|
|
113
122
|
}
|
|
114
123
|
}
|
|
115
124
|
}
|
|
125
|
+
// collect form-data bindings: option-level events (showhide/collapse promoId)
|
|
126
|
+
// and survey/field cross-wiring; and remember form scopes for field_name checks.
|
|
127
|
+
const sp = node.specials;
|
|
128
|
+
if (sp && typeof sp === "object") {
|
|
129
|
+
if (Array.isArray(sp.options)) {
|
|
130
|
+
for (const opt of sp.options) {
|
|
131
|
+
if (!opt || !Array.isArray(opt.events_option))
|
|
132
|
+
continue;
|
|
133
|
+
for (const ev of opt.events_option) {
|
|
134
|
+
if (ev &&
|
|
135
|
+
(ev.type === "showhide" || ev.type === "collapse") &&
|
|
136
|
+
typeof ev.promoId === "string" &&
|
|
137
|
+
ev.promoId.trim() !== "") {
|
|
138
|
+
optionTargets.push({ from: node.id ?? path, kind: ev.type, target: ev.promoId });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
for (const key of ["connectedSurvey", "connectedForm"]) {
|
|
144
|
+
const v = sp[key];
|
|
145
|
+
if (typeof v === "string" && v.trim() !== "") {
|
|
146
|
+
connectRefs.push({ from: node.id ?? path, key, target: v });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (type === "form")
|
|
151
|
+
forms.push(node);
|
|
116
152
|
if (Array.isArray(node.children)) {
|
|
117
153
|
node.children.forEach((c, idx) => walk(c, `${path}.children[${idx}]`));
|
|
118
154
|
}
|
|
@@ -133,14 +169,64 @@ export function validatePage(input) {
|
|
|
133
169
|
if (count > 1)
|
|
134
170
|
errors.push(`Duplicate id "${id}" used ${count} times — ids must be unique.`);
|
|
135
171
|
}
|
|
172
|
+
// Does `target` fail to resolve to any element id? (ids may be stored with or
|
|
173
|
+
// without the runtime `w-`/`#w-` prefix.)
|
|
174
|
+
const danglesId = (target) => {
|
|
175
|
+
const cleaned = target.replace(/^#?w-/, "");
|
|
176
|
+
return !ids.has(target) && !ids.has(cleaned);
|
|
177
|
+
};
|
|
136
178
|
// dangling element-target events
|
|
137
179
|
for (const t of eventTargets) {
|
|
138
180
|
if (ELEMENT_TARGET_ACTIONS.has(t.action)) {
|
|
139
|
-
|
|
140
|
-
if (!ids.has(t.target) && !ids.has(cleaned)) {
|
|
181
|
+
if (danglesId(t.target)) {
|
|
141
182
|
warnings.push(`event on "${t.from}" action="${t.action}" target="${t.target}" does not match any element id.`);
|
|
142
183
|
}
|
|
143
184
|
}
|
|
185
|
+
else if (t.action === "set_field_value" && /^#?w-/.test(t.target) && danglesId(t.target)) {
|
|
186
|
+
// set_field_value target is a field_name OR an element id; only an explicit
|
|
187
|
+
// element ref (w- prefix) can dangle — a bare field_name is not an id.
|
|
188
|
+
warnings.push(`event on "${t.from}" action="set_field_value" target="${t.target}" looks like an element id but matches none.`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// dangling option-level event targets (specials.options[].events_option promoId)
|
|
192
|
+
for (const t of optionTargets) {
|
|
193
|
+
if (danglesId(t.target)) {
|
|
194
|
+
warnings.push(`option event on "${t.from}" type="${t.kind}" promoId="${t.target}" does not match any element id.`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// dangling survey/field cross-wiring
|
|
198
|
+
for (const r of connectRefs) {
|
|
199
|
+
if (danglesId(r.target)) {
|
|
200
|
+
warnings.push(`"${r.from}" specials.${r.key}="${r.target}" does not match any element id.`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// field_name uniqueness WITHIN each form — duplicate names collide in the
|
|
204
|
+
// submitted data. (A nested form is its own data scope, so stop at one.)
|
|
205
|
+
const collectFieldNames = (n, acc) => {
|
|
206
|
+
if (!n || !Array.isArray(n.children))
|
|
207
|
+
return;
|
|
208
|
+
for (const c of n.children) {
|
|
209
|
+
if (!c || typeof c !== "object")
|
|
210
|
+
continue;
|
|
211
|
+
if (c.type === "form")
|
|
212
|
+
continue;
|
|
213
|
+
const fn = c.specials?.field_name;
|
|
214
|
+
if (typeof fn === "string" && fn.trim() !== "")
|
|
215
|
+
acc.push(fn.trim());
|
|
216
|
+
collectFieldNames(c, acc);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
for (const form of forms) {
|
|
220
|
+
const names = [];
|
|
221
|
+
collectFieldNames(form, names);
|
|
222
|
+
const counts = new Map();
|
|
223
|
+
for (const fn of names)
|
|
224
|
+
counts.set(fn, (counts.get(fn) || 0) + 1);
|
|
225
|
+
for (const [fn, count] of counts) {
|
|
226
|
+
if (count > 1) {
|
|
227
|
+
warnings.push(`form "${form.id ?? "?"}": field_name "${fn}" used ${count} times — inputs in one form need a unique field_name (data collides on submit).`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
144
230
|
}
|
|
145
231
|
// 3) Layout bounds — flag children that fall off their container's canvas (a
|
|
146
232
|
// common cause of "off-center / misaligned" pages). Warnings only.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webcake-landing-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
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
|
"type": "module",
|
|
6
6
|
"bin": {
|