webcake-landing-mcp 1.0.74 → 1.0.76
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/changelog.json +14 -14
- package/dist/domains/landing/guide.js +4 -3
- package/dist/domains/landing/instructions.js +2 -2
- package/dist/domains/landing/page-schema.json +5 -2
- package/dist/domains/landing/validate.js +85 -2
- package/dist/domains/landing/vocab.js +14 -1
- package/dist/persistence/icon-client.js +81 -0
- package/dist/persistence/ingest/semantic.js +105 -12
- package/dist/smoke.js +140 -0
- package/dist/tools/generation.js +10 -1
- package/dist/tools/media.js +31 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -172,7 +172,7 @@ server, the `login` browser flow (+ backend contract), and how to grab a JWT by
|
|
|
172
172
|
|-------|---------------|
|
|
173
173
|
| **[Connect your IDE / claude.ai](docs/connect-mcp.md)** | Step-by-step connection for every client (npx & hosted URL), troubleshooting table. |
|
|
174
174
|
| **[Configuration](docs/configuration.md)** | Env vars, `--env` presets, browser `login`, per-request headers, getting a JWT. |
|
|
175
|
-
| **[Tools reference](docs/tools.md)** | All
|
|
175
|
+
| **[Tools reference](docs/tools.md)** | All 21 tools in detail + the step-by-step workflow + model notes. |
|
|
176
176
|
| **[Usage examples](docs/usage-examples.md)** | Three end-to-end walkthroughs: build from a brief, surgical edit, inspect a type. |
|
|
177
177
|
| **[Manual / advanced install](docs/manual-install.md)** | Shell installers, cloned builds, hand-written per-IDE config. |
|
|
178
178
|
| **[Page-element schema](docs/page-element-schema.md)** | The full element-model reference (+ [every special/event](docs/element-specials-reference.md)). |
|
|
@@ -181,13 +181,13 @@ server, the `login` browser flow (+ backend contract), and how to grab a JWT by
|
|
|
181
181
|
|
|
182
182
|
## 🧰 The tools at a glance
|
|
183
183
|
|
|
184
|
-
|
|
184
|
+
21 tools in five groups — full descriptions in **[docs/tools.md](docs/tools.md)**:
|
|
185
185
|
|
|
186
186
|
| Group | Tools | Needs |
|
|
187
187
|
|-------|-------|-------|
|
|
188
188
|
| **Reference** | `get_generation_guide` · `list_elements` · `get_element` · `get_page_schema` | nothing |
|
|
189
189
|
| **Generation** | `new_element` · `new_page_skeleton` · `validate_page` | nothing |
|
|
190
|
-
| **Media** | `search_images` (real Pexels stock photos) · `upload_images` (re-host external images, data: URIs, or local file paths from the user's machine) | nothing |
|
|
190
|
+
| **Media** | `search_images` (real Pexels stock photos) · `get_icon_svg` (Material Symbols / Font Awesome icon names → inline SVG via Iconify) · `upload_images` (re-host external images, data: URIs, or local file paths from the user's machine) | nothing |
|
|
191
191
|
| **Ingest** | `ingest_html` · `ingest_url` (recreate an existing page) | nothing |
|
|
192
192
|
| **Persistence** | `list_organizations` · `create_page` · `list_pages` · `find_pages` · `get_page` · `update_page` · `add_section` · `patch_page` · `publish_page` | `WEBCAKE_API_BASE` + `WEBCAKE_JWT` |
|
|
193
193
|
|
package/dist/changelog.json
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "1.0.76",
|
|
4
|
+
"d": "15/06/2026",
|
|
5
|
+
"type": "Added",
|
|
6
|
+
"en": "validate_page now warns when specials.custom_css sets layout or structural CSS properties (position, top, left, right, bottom, inset, width, height,…",
|
|
7
|
+
"vi": "validate_page nay cảnh báo khi specials.custom_css đặt các thuộc tính CSS layout hoặc cấu trúc (position, top, left, right, bottom, inset, width,…"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"v": "1.0.75",
|
|
11
|
+
"d": "15/06/2026",
|
|
12
|
+
"type": "Added",
|
|
13
|
+
"en": "New get_icon_svg tool resolves Material Symbols (ms:<name>) and Font Awesome (fa:<name>) icon-font references to real inline SVG markup via the…",
|
|
14
|
+
"vi": "Tool mới get_icon_svg resolve tên icon-font Material Symbols (ms:<name>) và Font Awesome (fa:<name>) thành SVG inline thực sự qua Iconify API công…"
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"v": "1.0.74",
|
|
4
18
|
"d": "13/06/2026",
|
|
@@ -26,19 +40,5 @@
|
|
|
26
40
|
"type": "Added",
|
|
27
41
|
"en": "ingest_html and ingest_url now automatically convert absolute-canvas builder exports (LadiPage-family / Webcake-published HTML) into a ready-to-save…",
|
|
28
42
|
"vi": "ingest_html và ingest_url nay tự động chuyển đổi các bản export từ builder absolute-canvas (LadiPage-family / Webcake-published HTML) thành source…"
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
"v": "1.0.70",
|
|
32
|
-
"d": "13/06/2026",
|
|
33
|
-
"type": "Changed",
|
|
34
|
-
"en": "upload_images default changed from dry_run:true to dry_run:false — the tool now uploads images and returns the hosted URL map on every call without…",
|
|
35
|
-
"vi": "Mặc định của upload_images được đổi từ dry_run:true thành dry_run:false — tool này nay upload ảnh và trả về bản đồ URL trong mọi lần gọi mà không…"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"v": "1.0.69",
|
|
39
|
-
"d": "12/06/2026",
|
|
40
|
-
"type": "Changed",
|
|
41
|
-
"en": "upload_images dry-run response now returns an action_required field (replacing the previous soft hint) that explicitly blocks the model from…",
|
|
42
|
-
"vi": "Phản hồi dry-run của upload_images nay trả về trường action_required (thay thế hint mềm trước đó) để chặn model lắp ráp trang hoặc dùng placeholder…"
|
|
43
43
|
}
|
|
44
44
|
]
|
|
@@ -15,7 +15,7 @@ OUTPUT (top-level page source — matches the real editor shape)
|
|
|
15
15
|
- "page" is an array of SECTIONS stacked vertically (index 0 = top). Each item MUST be type "section" (or "dynamic_page").
|
|
16
16
|
- "popup" is a SEPARATE top-level array of popup elements — do NOT nest popups inside "page". A button opens one via a click event { action:"open_popup", target:"<popup id>" }.
|
|
17
17
|
- All other elements (text, image, button, form…) live inside a section's "children".
|
|
18
|
-
- "settings" carries SEO + page config: title, description, keywords, robots, canonical, favicon, fontGeneral, width_section {desktop:960,mobile:420}, country, currency, fb_tracking_code, tiktok_script, extra_css, extra_script, bhet (head code), bbet (body-end code) (call new_page_skeleton for a ready default).
|
|
18
|
+
- "settings" carries SEO + page config: title, description, keywords, robots, canonical, favicon, fontGeneral, width_section {desktop:960|1200, mobile:420|360 — the canvas width CHOICE; see the canvas-width rule below}, country, currency, fb_tracking_code, tiktok_script, extra_css, extra_script, bhet (head code), bbet (body-end code) (call new_page_skeleton for a ready default).
|
|
19
19
|
|
|
20
20
|
ELEMENT NODE (every element)
|
|
21
21
|
{ "id": "<unique ~8-char [A-Za-z0-9_]>", "type": "<type>",
|
|
@@ -34,7 +34,7 @@ FULL ELEMENT CATALOG (all ${ELEMENT_TYPES.length} types — the complete menu, n
|
|
|
34
34
|
COORDINATE SYSTEM (critical)
|
|
35
35
|
- Absolute-positioning canvas (NOT flexbox). Children carry top/left/width/height in px (numbers).
|
|
36
36
|
- section has NO top/left; it has height (canvas height, default ${CANVAS.defaultSectionHeight}) and position:"relative".
|
|
37
|
-
- Canvas width is
|
|
37
|
+
- Canvas width is a per-breakpoint CHOICE you set in settings.width_section — desktop ${CANVAS.desktopWidthOptions.join(" or ")}px, mobile ${CANVAS.mobileWidthOptions.join(" or ")}px (these are the ONLY allowed values; editor default ${CANVAS.desktopWidth}/${CANVAS.mobileWidth}). PICK the width to fit the design, then place EVERY element's top/left/width in THAT coordinate space (changing width does NOT rescale elements — coords are absolute in the chosen width): use desktop 1200 for wide, multi-column, or editorial layouts and ALWAYS when cloning a reference drawn wider than 960 (e.g. a Google Stitch screen at ~1280 — clone at 1200 and map its coords by ×1200/sourceWidth, so 2-/3-column bands aren't crushed into 960 leaving big empty gaps); use 960 for simple, single-column, text-led pages. Mobile: 360 to match a ~360–390 design, else 420. Set settings.width_section.desktop/mobile to your choice up front. Provide BOTH breakpoints; do not overlap elements within a section.
|
|
38
38
|
- Every child must stay on-canvas: 0 ≤ left and left + width ≤ canvas width (${CANVAS.desktopWidth} desktop / ${CANVAS.mobileWidth} mobile). Same for top + height ≤ section height.
|
|
39
39
|
|
|
40
40
|
CENTERING & ALIGNMENT (do the math — do NOT eyeball \`left\`; off-center layouts are the #1 defect)
|
|
@@ -120,7 +120,8 @@ RULES
|
|
|
120
120
|
- Every form input MUST have a unique specials.field_name.
|
|
121
121
|
- 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.
|
|
122
122
|
- ANIMATION: each breakpoint's config has config.animation = { "name":"none", "delay":0, "duration":3, "repeat":null }. Animations only run on these 9 element types: group, image-block, text-block, rectangle, button, countdown, line, list-paragraph, notify (renderer contract: landing_page_build/render/build/animate.js). Any other type with a non-"none" name renders stuck/dim in its pre-animation state — keep "none" on all other types. The name must be from the editor's animate.css set; common entrance families: fadeIn* (fadeInUp, fadeInDown, fadeInLeft, fadeInRight…), slideIn* (slideInUp, slideInDown, slideInLeft, slideInRight), zoomIn* (zoomIn, zoomInUp, zoomInDown…), bounceIn* (bounceIn, bounceInUp…), backIn* (backInDown, backInLeft…), flipIn* (flipInX, flipInY), lightSpeedIn* (lightSpeedInLeft, lightSpeedInRight), rotateIn* (rotateIn, rotateInDownLeft…), rollIn, jackInTheBox; attention seekers: bounce, pulse, tada, headShake, wobble, jello, heartBeat, rubberBand, shakeX, shakeY. The full set is enforced by validate_page — use an invalid name and the element renders stuck. NEVER set styles.opacity < 1 for a "subtle" or "muted" look — opacity is permanent and renders the element and all its content faded forever; use rgba() alpha on the color or background property instead.
|
|
123
|
-
- BEYOND ELEMENT CAPABILITY (custom CSS / class / JS — use these so the page is as COMPLETE as possible instead of dropping an effect the built-in specials can't express): the renderer supports real escape hatches — REACH FOR THEM when a reference (esp. a Google Stitch / Tailwind page) has hover/transition effects, gradients, glassmorphism, custom shadows, gradient/clipped text, sticky tricks, or keyframe animations outside the 9-type entrance set. Tools, in order of preference: (1) element specials.custom_css — extra CSS DECLARATIONS injected INTO that element's own rule "#w-<id>{ … }" (declarations only — NO selector, NO :hover, NO @keyframes). Requires specials.customAdvance:true (without it the renderer drops it). Use for: "background:linear-gradient(...)" (gradient button/hero — also reproduces the AST's gradients), "box-shadow:0 20px 40px rgba(0,0,0,.08)", "backdrop-filter:blur(20px)" (glass header — pair with an rgba background), "transition:transform .3s ease", "-webkit-background-clip:text;-webkit-text-fill-color:transparent" (gradient text). (2) element specials.custom_class (comma-separated, also needs customAdvance:true) — adds class names to "#w-<id>" so settings.extra_css can target a group of elements. (3) page settings.extra_css — a FULL stylesheet injected raw into <head>: this is where SELECTORS live — :hover, :focus, @keyframes, media queries. The DOM id of any element is "#w-<its id>". THIS is how you implement the AST's per-section hover_effects: scale → "#w-<id>{transition:transform .3s} #w-<id>:hover{transform:scale(1.05)}"; card lift → "#w-<id>:hover{transform:translateY(-8px)}"; image-zoom in a card → "#w-<cardId>{overflow:hidden} #w-<imgId>{transition:transform .5s} #w-<cardId>:hover #w-<imgId>{transform:scale(1.1)}"; color/underline on hover are also fine as a hover EVENT (change_color / change_underline) — pick whichever, but DON'T silently drop the effect. (4) page settings.extra_script — raw JS injected before </body> for behavior the event vocab can't express. (5) page settings.bhet ('before </head>') and settings.bbet ('before </body>') — raw HTML BLOCKS (not just CSS/JS): bhet for <link> webfonts/stylesheets, <style>, <meta>, verification tags, analytics/pixels (GTM, FB); bbet for chat widgets, deferred <script>, GTM <noscript>, third-party embeds. Use these when you need a real font file, a tag/pixel, or an external widget — extra_css/extra_script are for raw CSS/JS only, bhet/bbet take arbitrary HTML (valid markup only).
|
|
123
|
+
- BEYOND ELEMENT CAPABILITY (custom CSS / class / JS — use these so the page is as COMPLETE as possible instead of dropping an effect the built-in specials can't express): the renderer supports real escape hatches — REACH FOR THEM when a reference (esp. a Google Stitch / Tailwind page) has hover/transition effects, gradients, glassmorphism, custom shadows, gradient/clipped text, sticky tricks, or keyframe animations outside the 9-type entrance set. Tools, in order of preference: (1) element specials.custom_css — extra CSS DECLARATIONS injected INTO that element's own rule "#w-<id>{ … }" (declarations only — NO selector, NO :hover, NO @keyframes). Requires specials.customAdvance:true (without it the renderer drops it). Use for: "background:linear-gradient(...)" (gradient button/hero — also reproduces the AST's gradients), "box-shadow:0 20px 40px rgba(0,0,0,.08)", "backdrop-filter:blur(20px)" (glass header — pair with an rgba background), "transition:transform .3s ease", "-webkit-background-clip:text;-webkit-text-fill-color:transparent" (gradient text). (2) element specials.custom_class (comma-separated, also needs customAdvance:true) — adds class names to "#w-<id>" so settings.extra_css can target a group of elements. (3) page settings.extra_css — a FULL stylesheet injected raw into <head>: this is where SELECTORS live — :hover, :focus, @keyframes, media queries. The DOM id of any element is "#w-<its id>". THIS is how you implement the AST's per-section hover_effects: scale → "#w-<id>{transition:transform .3s} #w-<id>:hover{transform:scale(1.05)}"; card lift → "#w-<id>:hover{transform:translateY(-8px)}"; image-zoom in a card → "#w-<cardId>{overflow:hidden} #w-<imgId>{transition:transform .5s} #w-<cardId>:hover #w-<imgId>{transform:scale(1.1)}"; color/underline on hover are also fine as a hover EVENT (change_color / change_underline) — pick whichever, but DON'T silently drop the effect. (4) page settings.extra_script — raw JS injected before </body> for behavior the event vocab can't express. (5) page settings.bhet ('before </head>') and settings.bbet ('before </body>') — raw HTML BLOCKS (not just CSS/JS): bhet for <link> webfonts/stylesheets, <style>, <meta>, verification tags, analytics/pixels (GTM, FB); bbet for chat widgets, deferred <script>, GTM <noscript>, third-party embeds. Use these when you need a real font file, a tag/pixel, or an external widget — extra_css/extra_script are for raw CSS/JS only, bhet/bbet take arbitrary HTML (valid markup only). SAFETY — custom is RAW and global, so sloppy custom is the #1 way to BREAK the UI; obey these or the page shatters (validate_page warns on each): (a) SCOPE every settings.extra_css rule to a specific element — "#w-<id>" or a specials.custom_class you added. NEVER write a bare-tag (body, div, section, p, img, a, button, ul, li, h1…), universal "*", or Webcake-internal-class (.section-container, .section-wrapper, .pageview, .rectangle-css, .text-block-css, .image-block-css, .button-css, .group-*, .gallery-*, .overlay, .full-width/.full-height…) selector — those restyle the WHOLE page and wreck the layout. (b) custom_css is VISUAL props ONLY (background, box-shadow, border, border-radius, filter, backdrop-filter, transition, transform, -webkit-background-clip) and declarations-only (no selector / :hover / @keyframes) and needs customAdvance:true — NEVER put position / top / left / right / bottom / inset / width / height / display / float / flex / grid in custom_css (they override the element box and break the absolute canvas; set geometry via responsive.<bp>.styles). (c) bhet/bbet must be VALID, fully-CLOSED HTML (an unclosed <script>/<style>/<div> swallows the rest of the page); put CSS in extra_css and JS in extra_script (bhet/bbet only for real HTML like <link>/<meta>/widgets), and any <style> inside them follows rule (a). (d) extra_script: wrap in try/catch, run on DOMContentLoaded, and NEVER remove/restructure "#w-…" elements (the renderer owns them) — only attach behavior. (e) balance your braces. (f) don't use custom to replace a capability a built-in special already has (entrance animation → config.animation; click/hover behavior → events; size/position → styles).
|
|
124
|
+
- ICONS (icon-font glyphs — don't drop them, and DON'T blind-svg-mask them): references (esp. Google Stitch) render icons with an icon-font CLASS, not images or inline SVG — Material Symbols (<span class="material-symbols-outlined">verified</span>) or Font Awesome (<i class="fa-solid fa-check">). ingest gives only the NAME as block.icon "ms:<name>" / "fa:<name>" (e.g. "ms:support_agent"). render it as Webcake's NATIVE icon element — a RECTANGLE with an svg mask: STEP 1 — call get_icon_svg with those refs to get each REAL <svg> (Material Symbols outlined / Font Awesome) via Iconify (NEVER invent an SVG from a name). STEP 2 — make a rectangle and put that svg in BOTH responsive.desktop.config.svgMask AND responsive.mobile.config.svgMask, set styles.background = the icon color, and use a SQUARE box (width === height). Why each rule: the svg is only a MASK (its own fill is IGNORED) — visible pixels come ENTIRELY from styles.background, so WITHOUT a solid background the icon is BLANK (that's the "svg in a rectangle doesn't show" bug); the renderer reads each breakpoint's config separately (no fallback), so the mask must be in BOTH; and it forces preserveAspectRatio='none', so a non-square box stretches the icon. validate_page warns if background / viewBox / both breakpoints are missing. If get_icon_svg can't resolve a ref, fall back to an emoji inline in a sentence or skip that slot — NEVER leave a feature card iconless.
|
|
124
125
|
- Real data the page DISPLAYS must come from the user — never invent it: phone/hotline/Zalo, price (+ original price), address, shop/brand name, links/URLs, email, opening hours, exact stats/social-proof numbers. If a value the page needs is missing, ASK for it (in intake, or pause before generating); use a clearly-labelled placeholder ONLY when the user explicitly declines, and tell them exactly what to fill. Write ALL page copy in the SAME language the user is chatting in (mirror it), with FULL, CORRECT diacritics/accents — for Vietnamese this means proper dấu (e.g. "Trân Trọng Kính Mời", "Ngày 15 Tháng 08 Năm 2025"), NEVER accent-stripped "không dấu" text. Do not romanize, transliterate, or drop accent marks from any language.
|
|
125
126
|
|
|
126
127
|
INTAKE — act as a DESIGN CONSULTANT, not a form. Goal: understand what the customer actually wants, then design as close to their intent as possible. Ask BEFORE generating, EVERY time (even a "quick"/"test" page).
|
|
@@ -39,7 +39,7 @@ MODEL (essentials):
|
|
|
39
39
|
- Visible content lives in specials (text, src, field_name…), never in styles. Colors as rgba(). Animation in config.animation={name,delay,duration,repeat}. Form inputs need a unique specials.field_name (use canonical keys: full_name, phone_number, email, address, quantity).
|
|
40
40
|
- IMAGES: include them (hero/product, feature icons, about photo). The server AUTO-HOSTS external image URLs on every save: any real http(s) image URL you put in specials.src / a url(...) background / gallery item.link / video poster is downloaded and re-hosted to the Webcake CDN automatically by create_page/update_page/add_section/patch_page (the result's rehost field reports how many) — so you do NOT have to pre-call upload_images for reference or web images. SOURCE PRIORITY: (1) images the user supplied or that exist in the reference HTML/URL → put the REAL source URL straight into specials.src and let the save host it (use those exact images, never swap them for stock photos; the original URL must reach the save un-altered — NEVER replace a real source image URL with a placeholder). The ONE case you MUST still call upload_images yourself: LOCAL FILE PATHS from the user's computer (pass the path directly in upload_images urls — the save can't read local files; NEVER upload a user's local file to a third-party host like catbox or imgur first), then use the returned URL. (2) only for slots with NO source image → call search_images with a short English subject (e.g. 'fresh coffee cup') and put a returned URL (src.large for a hero/banner, src.medium for a card/thumb) into the image-block specials.src; it works out of the box (a shared proxy supplies images); (3) if search_images returns ok:false / is unreachable / has no fitting photo → find a real image YOURSELF using whatever web search/fetch capability you have (brand site, product page, free-to-use source) and re-host it via upload_images; (4) a PLACEHOLDER sized to the box (https://placehold.co/<width>x<height>) is the LAST resort, only after (2) AND (3) both failed. (gallery.media = array of OBJECTS {type:'image',link:'<url>',linkVideo:'',typeVideo:'youtube',imageCompression:true} — NOT plain strings, the gallery reads item.link; video.specials.img = poster). NEVER leave src empty (renders blank). Ensure text contrasts with its section background.
|
|
41
41
|
|
|
42
|
-
- BEYOND ELEMENT CAPABILITY: when a reference effect can't be expressed with an element's built-in specials (hover scale/lift/zoom & transitions, gradients, glassmorphism/backdrop-blur, custom shadows, gradient/clipped text, keyframe animations outside the 9 entrance types) DON'T drop it — use the escape hatches so the page is as complete as possible: element specials.custom_css (extra DECLARATIONS in #w-<id>{…}; declarations-only) + specials.custom_class (both need specials.customAdvance:true) for per-element styling, and page settings.extra_css (a full raw stylesheet in <head> — where :hover/@keyframes/media-queries live, target #w-<element id>) + settings.extra_script (raw JS) + settings.bhet/bbet (raw HTML blocks at end of <head>/<body> — for webfont <link>s, <meta>/verification, analytics/pixels, chat widgets, third-party embeds). get_generation_guide has the recipes; validate_page
|
|
42
|
+
- BEYOND ELEMENT CAPABILITY: when a reference effect can't be expressed with an element's built-in specials (hover scale/lift/zoom & transitions, gradients, glassmorphism/backdrop-blur, custom shadows, gradient/clipped text, keyframe animations outside the 9 entrance types) DON'T drop it — use the escape hatches so the page is as complete as possible: element specials.custom_css (extra DECLARATIONS in #w-<id>{…}; declarations-only) + specials.custom_class (both need specials.customAdvance:true) for per-element styling, and page settings.extra_css (a full raw stylesheet in <head> — where :hover/@keyframes/media-queries live, target #w-<element id>) + settings.extra_script (raw JS) + settings.bhet/bbet (raw HTML blocks at end of <head>/<body> — for webfont <link>s, <meta>/verification, analytics/pixels, chat widgets, third-party embeds). get_generation_guide has the recipes. SAFETY (custom is raw + global → sloppy custom BREAKS the whole UI): SCOPE every extra_css rule to "#w-<id>" or a specials.custom_class — NEVER a bare tag / "*" / a Webcake-internal class (.section-container/.rectangle-css/.text-block-css/.group-*…); keep custom_css to VISUAL props only (no position/top/left/width/height/display/float); close every bhet/bbet tag; don't restructure "#w-…" elements in extra_script. validate_page flags unscoped selectors, layout props in custom_css, unbalanced braces/tags, missing customAdvance, and CSS/JS in the wrong field — fix every such warning.
|
|
43
43
|
- PREVIEW vs PUBLISH: for review share the EDITOR url (the builder renders the raw source). The editor_url SIGNS THE BROWSER IN automatically (it routes through the builder's /transport with the account token), so it works even when the user isn't logged in — but for the same reason it must go to the PAGE OWNER ONLY, never into anything public. create_page AUTO-PUBLISHES on success (builds the rendered app + publish_html), so a fresh page's preview_url renders right away — but the preview host (preview.localhost:5800 local / staging.webcake.me staging / www.webcake.me prod) only serves it for ~10 MINUTES after the last publish, then shows "Preview page is expired". The EDIT routes (update_page/add_section/patch_page) store source only — after finishing a round of edits, run publish_page({ page_id, dry_run:false }) to rebuild the rendered app (else the preview shows the STALE pre-edit build). ONLY a custom_domain (publish_page({ page_id, custom_domain, dry_run:false })) gives a permanent public URL; without one the page has just the ephemeral preview link — say so and suggest attaching a domain the user already points at Webcake.
|
|
44
44
|
|
|
45
|
-
Start by calling get_generation_guide. Tools: get_generation_guide, list_elements, get_element, new_element, new_page_skeleton, get_page_schema, validate_page, search_images, upload_images, ingest_html, ingest_url, list_organizations, create_page, list_pages, find_pages, get_page, update_page, add_section, patch_page, publish_page.`;
|
|
45
|
+
Start by calling get_generation_guide. Tools: get_generation_guide, list_elements, get_element, new_element, new_page_skeleton, get_page_schema, validate_page, search_images, get_icon_svg, upload_images, ingest_html, ingest_url, list_organizations, create_page, list_pages, find_pages, get_page, update_page, add_section, patch_page, publish_page.`;
|
|
@@ -46,8 +46,11 @@
|
|
|
46
46
|
"fontGeneral": { "type": "string", "description": "Default page font-family, e.g. \"'Roboto', sans-serif\"." },
|
|
47
47
|
"width_section": {
|
|
48
48
|
"type": "object",
|
|
49
|
-
"description": "
|
|
50
|
-
"properties": {
|
|
49
|
+
"description": "The canvas width per breakpoint (px) — the coordinate space every element's top/left/width lives in. Editor-allowed values ONLY: desktop 960 or 1200, mobile 420 or 360 (default 960/420). Choose 1200 for wide/multi-column/editorial layouts or when cloning a reference wider than 960 (e.g. Google Stitch ~1280) so columns aren't squished; 360 mobile to match a ~360–390 design. Changing it does NOT rescale existing coords.",
|
|
50
|
+
"properties": {
|
|
51
|
+
"desktop": { "type": "number", "enum": [960, 1200], "description": "960 or 1200." },
|
|
52
|
+
"mobile": { "type": "number", "enum": [420, 360], "description": "420 or 360." }
|
|
53
|
+
}
|
|
51
54
|
},
|
|
52
55
|
"country": { "type": "string", "description": "Dialing/locale code, e.g. \"84\"." },
|
|
53
56
|
"currency": { "type": "string", "description": "Page currency (the editor's canonical home for currency).", "examples": ["VND", "USD"] },
|
|
@@ -315,6 +315,17 @@ export function validatePage(input) {
|
|
|
315
315
|
if (typeof sp.custom_css === "string" && /[{}]|@keyframes|:hover|:focus|::/.test(sp.custom_css)) {
|
|
316
316
|
warnings.push(`${path} (${type}): specials.custom_css is injected as plain declarations inside #w-${node.id}{…} — a selector/:hover/@keyframes/media-query there breaks the rule. Keep declarations only here (e.g. "box-shadow:0 20px 40px rgba(0,0,0,.08);backdrop-filter:blur(20px);"); put hover/keyframes/media rules in settings.extra_css targeting #w-${node.id} (or a specials.custom_class).`);
|
|
317
317
|
}
|
|
318
|
+
// Layout/structural props in custom_css fight the absolute-canvas system
|
|
319
|
+
// (the renderer sets the element's box + display) → the element jumps or the
|
|
320
|
+
// page breaks. Geometry belongs in responsive.<bp>.styles, not custom_css.
|
|
321
|
+
if (typeof sp.custom_css === "string") {
|
|
322
|
+
const badProps = (sp.custom_css.match(/(?:^|[;{]\s*)(position|top|left|right|bottom|inset|width|height|display|float|flex|grid)\s*:/gi) ?? [])
|
|
323
|
+
.map((m) => m.replace(/[;{:\s]/g, ""))
|
|
324
|
+
.filter((p, i, a) => a.indexOf(p) === i);
|
|
325
|
+
if (badProps.length) {
|
|
326
|
+
warnings.push(`${path} (${type}): specials.custom_css sets layout prop(s) ${badProps.join(", ")} — these override the element's box/display and break the absolute-canvas layout. Set size/position via responsive.<bp>.styles (top/left/width/height per breakpoint); keep custom_css to VISUAL props only (background, box-shadow, border, filter, backdrop-filter, transition, transform).`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
318
329
|
}
|
|
319
330
|
}
|
|
320
331
|
// animation contract — checked per breakpoint
|
|
@@ -419,8 +430,12 @@ export function validatePage(input) {
|
|
|
419
430
|
// variation selector-16 (FE0F), keycap (20E3), whitespace
|
|
420
431
|
/^(?:\p{Extended_Pictographic}|\p{Emoji_Component}|[\u200D\uFE0F\u20E3\s])+$/u.test(visible);
|
|
421
432
|
if (onlyEmoji) {
|
|
422
|
-
warnings.push(`${path} (text-block): specials.text is only the emoji "${visible}" — keyboard emoji as standalone icons look unprofessional and render inconsistently across devices. Use a rectangle with per-breakpoint config.svgMask
|
|
433
|
+
warnings.push(`${path} (text-block): specials.text is only the emoji "${visible}" — keyboard emoji as standalone icons look unprofessional and render inconsistently across devices. Use a real icon instead: call get_icon_svg to fetch the <svg>, then a rectangle with that svg in per-breakpoint config.svgMask + styles.background set to the accent color (the native Webcake icon). Emoji are fine inline within sentences, never as card icons.`);
|
|
423
434
|
}
|
|
435
|
+
// An icon-font text-block (Material Symbols / Font Awesome span) is a
|
|
436
|
+
// SINGLE glyph, not wrapping text — the ligature name ("verified") is not
|
|
437
|
+
// displayed, so the text-metrics estimate is meaningless here. Skip it.
|
|
438
|
+
const isIconGlyph = /\b(material-symbols|material-icons)\b/.test(rawText) || /<i\b[^>]*\bfa-/.test(rawText);
|
|
424
439
|
// Wrapped-text overflow: live text height is AUTO — text that wraps to
|
|
425
440
|
// more lines than the declared box spills DOWN and overlaps the element
|
|
426
441
|
// below (the classic "2-line card title over the card body" defect).
|
|
@@ -429,6 +444,8 @@ export function validatePage(input) {
|
|
|
429
444
|
// 1-line-tall box — slip through, while body text (16px → 22px line)
|
|
430
445
|
// keeps its old tolerance against the rough estimate.
|
|
431
446
|
for (const bp of ["desktop", "mobile"]) {
|
|
447
|
+
if (isIconGlyph)
|
|
448
|
+
break;
|
|
432
449
|
const styles = node.responsive?.[bp]?.styles;
|
|
433
450
|
const w = num(styles?.width);
|
|
434
451
|
const h = num(styles?.height);
|
|
@@ -854,7 +871,9 @@ export function validatePage(input) {
|
|
|
854
871
|
return;
|
|
855
872
|
const cpath = `${path}.children[${idx}]`;
|
|
856
873
|
const rawText = child.type === "text-block" ? child.specials?.text : undefined;
|
|
857
|
-
|
|
874
|
+
// Icon-font glyph (Material Symbols / Font Awesome) — one glyph, not wrapping text.
|
|
875
|
+
const isIconGlyph = typeof rawText === "string" && (/\b(material-symbols|material-icons)\b/.test(rawText) || /<i\b[^>]*\bfa-/.test(rawText));
|
|
876
|
+
if (typeof rawText === "string" && !isIconGlyph) {
|
|
858
877
|
for (const bp of ["desktop", "mobile"]) {
|
|
859
878
|
if (overlapWarnings >= MAX_OVERLAP_WARNINGS)
|
|
860
879
|
break;
|
|
@@ -1067,6 +1086,70 @@ export function validatePage(input) {
|
|
|
1067
1086
|
const ms = p?.responsive?.mobile?.styles ?? {};
|
|
1068
1087
|
checkBounds(p, num(ds.width) ?? rootCanvasD, num(ds.height) ?? DEFAULT_SECTION_HEIGHT, num(ms.width) ?? rootCanvasM, num(ms.height) ?? DEFAULT_SECTION_HEIGHT, `popup[${i}]`);
|
|
1069
1088
|
});
|
|
1089
|
+
// ── custom-code safety: settings.extra_css / extra_script / bhet / bbet ──────
|
|
1090
|
+
// These inject RAW into the page (extra_css in <head>, extra_script before
|
|
1091
|
+
// </body>, bhet at end of <head>, bbet at end of <body>) — unscoped/broken
|
|
1092
|
+
// custom code is the #1 way "custom breaks the UI". Flag the dangerous shapes.
|
|
1093
|
+
{
|
|
1094
|
+
// Webcake's own runtime class names — restyling them globally breaks the layout.
|
|
1095
|
+
const INTERNAL_CLASS_RE = /\.(section-container|section-wrapper|pageview|rectangle-css|text-block-css|image-block-css|button-css|group-[\w-]*|gallery-[\w-]*|popup-[\w-]*|overlay|lazy|full-mask-size|mask-position|full-(?:width|height)|ladi-[\w-]*)\b/;
|
|
1096
|
+
// Bare element selectors that, unscoped, restyle the WHOLE page.
|
|
1097
|
+
const BARE_TAG_SEL_RE = /(?:^|[,{}>+~\s])(html|body|section|article|header|footer|nav|main|aside|div|span|p|a|button|ul|ol|li|img|table|tr|td|input|form|h[1-6])\s*(?:[,{>+~:.[]|$)/i;
|
|
1098
|
+
/** Selectors in a raw stylesheet not scoped to #w-… that would restyle the whole page (broad tags, *, Webcake internals). */
|
|
1099
|
+
const broadCssSelectors = (css) => {
|
|
1100
|
+
const out = new Set();
|
|
1101
|
+
const ruleRe = /([^{}]+)\{[^{}]*\}/g;
|
|
1102
|
+
let m;
|
|
1103
|
+
while ((m = ruleRe.exec(css)) !== null && out.size < 5) {
|
|
1104
|
+
for (const selRaw of m[1].split(",")) {
|
|
1105
|
+
const sel = selRaw.trim();
|
|
1106
|
+
if (!sel || sel.startsWith("@") || sel.includes("#w-"))
|
|
1107
|
+
continue; // scoped/at-rule → safe
|
|
1108
|
+
if (/\*/.test(sel) || BARE_TAG_SEL_RE.test(sel) || INTERNAL_CLASS_RE.test(sel))
|
|
1109
|
+
out.add(sel.slice(0, 60));
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
return [...out];
|
|
1113
|
+
};
|
|
1114
|
+
const settings = page?.settings;
|
|
1115
|
+
if (settings && typeof settings === "object") {
|
|
1116
|
+
const extraCss = settings.extra_css;
|
|
1117
|
+
if (typeof extraCss === "string" && extraCss.trim()) {
|
|
1118
|
+
const opens = (extraCss.match(/{/g) ?? []).length;
|
|
1119
|
+
const closes = (extraCss.match(/}/g) ?? []).length;
|
|
1120
|
+
if (opens !== closes) {
|
|
1121
|
+
warnings.push(`settings.extra_css has unbalanced braces (${opens} '{' vs ${closes} '}') — a malformed rule leaks into the rest of the page CSS and breaks the layout. Fix the braces.`);
|
|
1122
|
+
}
|
|
1123
|
+
const broad = broadCssSelectors(extraCss);
|
|
1124
|
+
if (broad.length) {
|
|
1125
|
+
warnings.push(`settings.extra_css has UNSCOPED selector(s) that restyle the whole page and break the UI: ${broad.join(" | ")}. Scope EVERY rule to a specific element — #w-<element id> (or a specials.custom_class you added) — never bare tags, '*', or Webcake's own classes (.section-container / .rectangle-css / .text-block-css / .group-* / …).`);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
for (const field of ["bhet", "bbet"]) {
|
|
1129
|
+
const v = settings[field];
|
|
1130
|
+
if (typeof v !== "string" || !v.trim())
|
|
1131
|
+
continue;
|
|
1132
|
+
if (!v.includes("<")) {
|
|
1133
|
+
warnings.push(`settings.${field} contains no HTML tags — it is injected as raw HTML (${field === "bhet" ? "end of <head>" : "end of <body>"}), not CSS/JS. Put CSS in settings.extra_css and JS in settings.extra_script, or wrap this in <style>…</style> / <script>…</script>.`);
|
|
1134
|
+
}
|
|
1135
|
+
for (const sb of v.match(/<style[^>]*>([\s\S]*?)<\/style>/gi) ?? []) {
|
|
1136
|
+
const broad = broadCssSelectors(sb.replace(/<\/?style[^>]*>/gi, ""));
|
|
1137
|
+
if (broad.length) {
|
|
1138
|
+
warnings.push(`settings.${field} has a <style> with UNSCOPED selector(s) that break the page UI: ${broad.join(" | ")}. Scope every rule to #w-<id> / a custom_class, or move page-wide CSS into settings.extra_css with scoped selectors.`);
|
|
1139
|
+
break;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
for (const tag of ["script", "style", "div"]) {
|
|
1143
|
+
const o = (v.match(new RegExp(`<${tag}\\b`, "gi")) ?? []).length;
|
|
1144
|
+
const c = (v.match(new RegExp(`</${tag}>`, "gi")) ?? []).length;
|
|
1145
|
+
if (o !== c) {
|
|
1146
|
+
warnings.push(`settings.${field} has an unbalanced <${tag}> tag (${o} open vs ${c} close) — an unclosed tag swallows the rest of the page and breaks rendering. Close every tag.`);
|
|
1147
|
+
break;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1070
1153
|
return {
|
|
1071
1154
|
valid: errors.length === 0,
|
|
1072
1155
|
errors,
|
|
@@ -4,7 +4,20 @@
|
|
|
4
4
|
* fields the render_v4 dispatcher reads beyond { id, type, action, target }.
|
|
5
5
|
* Derived from assets/render_v4/event/index.js.
|
|
6
6
|
*/
|
|
7
|
-
|
|
7
|
+
// Canvas width is a per-breakpoint CHOICE (settings.width_section), NOT a single
|
|
8
|
+
// fixed value — the editor's Page Configuration offers desktop 960|1200 and
|
|
9
|
+
// mobile 420|360. desktopWidth/mobileWidth are the DEFAULTS (the editor default);
|
|
10
|
+
// desktopWidthOptions/mobileWidthOptions are the full allowed sets. Pick 1200 for
|
|
11
|
+
// wide/multi-column/editorial layouts and when cloning a reference wider than 960
|
|
12
|
+
// (e.g. Google Stitch ~1280) so columns aren't squished; pick 360 mobile to
|
|
13
|
+
// match a ~360–390 mobile design. Source: landing_page_backend Page Configuration.
|
|
14
|
+
export const CANVAS = {
|
|
15
|
+
desktopWidth: 960,
|
|
16
|
+
mobileWidth: 420,
|
|
17
|
+
defaultSectionHeight: 800,
|
|
18
|
+
desktopWidthOptions: [960, 1200],
|
|
19
|
+
mobileWidthOptions: [420, 360],
|
|
20
|
+
};
|
|
8
21
|
/**
|
|
9
22
|
* Element types the runtime animator handles.
|
|
10
23
|
* Source: landing_page_build/render/build/animate.js — the switch statement
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Icon resolver: turn an icon-font NAME (Material Symbols / Font Awesome, the form
|
|
3
|
+
* ingest surfaces as block.icon "ms:<name>" / "fa:<name>") into a real inline SVG,
|
|
4
|
+
* via the public Iconify API (https://iconify.design — unifies both icon sets).
|
|
5
|
+
*
|
|
6
|
+
* This is what lets a clone reproduce a Stitch icon FAITHFULLY and SELF-CONTAINED:
|
|
7
|
+
* the SVG is embedded straight into a text-block (fill="currentColor", colored by
|
|
8
|
+
* the element's styles.color) — no webfont to load, no svg-mask background trap.
|
|
9
|
+
*
|
|
10
|
+
* Pure network helper (no Webcake creds). Mirrors the search_images pattern.
|
|
11
|
+
*/
|
|
12
|
+
const ICONIFY_BASE = "https://api.iconify.design";
|
|
13
|
+
const ICON_FETCH_TIMEOUT_MS = 8_000;
|
|
14
|
+
/** Normalize a Material/FA icon name to the Iconify token form (underscores → hyphens, lowercased, trimmed). */
|
|
15
|
+
function normName(name) {
|
|
16
|
+
return name.trim().toLowerCase().replace(/[\s_]+/g, "-").replace(/[^a-z0-9-]/g, "");
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Ordered Iconify "prefix/name" candidates for an icon reference. Tries the most
|
|
20
|
+
* faithful variant first, then sensible fallbacks:
|
|
21
|
+
* - ms:NAME → material-symbols/NAME-outline (the "-outlined" class look), then the filled base
|
|
22
|
+
* - fa:NAME / fas: → fa6-solid, fa-solid
|
|
23
|
+
* - far: / fab: → regular / brands variants
|
|
24
|
+
* - prefix:NAME → used as-is (already an Iconify id)
|
|
25
|
+
* - NAME (bare) → assumed Material Symbols
|
|
26
|
+
*/
|
|
27
|
+
export function iconifyCandidates(ref) {
|
|
28
|
+
const raw = ref.trim();
|
|
29
|
+
// Already an Iconify id "prefix:name" (but NOT our ms:/fa: shorthands).
|
|
30
|
+
const m = /^([a-z0-9]+(?:-[a-z0-9]+)*):(.+)$/i.exec(raw);
|
|
31
|
+
const kind = m ? m[1].toLowerCase() : "";
|
|
32
|
+
const rest = m ? normName(m[2]) : normName(raw);
|
|
33
|
+
if (!rest)
|
|
34
|
+
return [];
|
|
35
|
+
if (kind === "ms" || kind === "material-symbols" || kind === "msr") {
|
|
36
|
+
return [`material-symbols/${rest}-outline`, `material-symbols/${rest}`];
|
|
37
|
+
}
|
|
38
|
+
if (kind === "fa" || kind === "fas" || kind === "fa-solid" || kind === "fa6-solid") {
|
|
39
|
+
return [`fa6-solid/${rest}`, `fa-solid/${rest}`, `fa6-regular/${rest}`, `fa6-brands/${rest}`];
|
|
40
|
+
}
|
|
41
|
+
if (kind === "far" || kind === "fa-regular")
|
|
42
|
+
return [`fa6-regular/${rest}`, `fa-regular/${rest}`];
|
|
43
|
+
if (kind === "fab" || kind === "fa-brands")
|
|
44
|
+
return [`fa6-brands/${rest}`, `fa-brands/${rest}`];
|
|
45
|
+
// A real Iconify prefix (e.g. mdi:home, lucide:check) → use verbatim, then bare-as-material.
|
|
46
|
+
if (kind)
|
|
47
|
+
return [`${kind}/${rest}`];
|
|
48
|
+
// Bare name → assume Material Symbols (the Stitch default).
|
|
49
|
+
return [`material-symbols/${rest}-outline`, `material-symbols/${rest}`];
|
|
50
|
+
}
|
|
51
|
+
/** Fetch one Iconify "prefix/name" → SVG string, or null when it 404s / isn't an SVG. */
|
|
52
|
+
async function fetchOne(prefixName) {
|
|
53
|
+
let res;
|
|
54
|
+
try {
|
|
55
|
+
res = await fetch(`${ICONIFY_BASE}/${prefixName}.svg`, { signal: AbortSignal.timeout(ICON_FETCH_TIMEOUT_MS) });
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
if (!res.ok)
|
|
61
|
+
return null;
|
|
62
|
+
const body = (await res.text()).trim();
|
|
63
|
+
// Unknown icons return a non-SVG body ("404"/"Not found"); only accept real SVG markup.
|
|
64
|
+
return body.startsWith("<svg") ? body : null;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Resolve an icon reference ("ms:verified" / "fa:chart-line" / "mdi:home" / a bare
|
|
68
|
+
* name) to an inline SVG. The SVG keeps fill="currentColor" so the embedding
|
|
69
|
+
* element's styles.color decides the icon color.
|
|
70
|
+
*/
|
|
71
|
+
export async function resolveIconSvg(ref) {
|
|
72
|
+
const candidates = iconifyCandidates(ref);
|
|
73
|
+
if (!candidates.length)
|
|
74
|
+
return { ok: false, error: `Empty/invalid icon ref: "${ref}"` };
|
|
75
|
+
for (const c of candidates) {
|
|
76
|
+
const svg = await fetchOne(c);
|
|
77
|
+
if (svg)
|
|
78
|
+
return { ok: true, svg, iconify: c };
|
|
79
|
+
}
|
|
80
|
+
return { ok: false, error: `No icon found for "${ref}" (tried: ${candidates.join(", ")})` };
|
|
81
|
+
}
|
|
@@ -49,12 +49,38 @@ function isPricingSection(el) {
|
|
|
49
49
|
// Currency symbol + per-period pattern anywhere in the element.
|
|
50
50
|
return /[$€£¥₫]\s*\d/.test(text) && /\/(month|mo\b|year|yr\b|annual)/i.test(text);
|
|
51
51
|
}
|
|
52
|
+
/** A top navigation bar without a <header> tag: <nav>, or a sticky/fixed top bar (no heading) with a logo + links. Stitch desktops use <nav class="fixed top-0 …">. A BOTTOM-pinned bar (mobile sticky action/nav bar, <nav class="fixed bottom-0 …">) is NOT the header. */
|
|
53
|
+
function looksLikeTopBar(el) {
|
|
54
|
+
const cls = (el.getAttribute("class") ?? "").toLowerCase();
|
|
55
|
+
if (/\bbottom-0\b|bottom:\s*0/.test(cls))
|
|
56
|
+
return false; // bottom action/nav bar, not the page header
|
|
57
|
+
const tag = el.tagName?.toLowerCase();
|
|
58
|
+
if (tag === "nav")
|
|
59
|
+
return true;
|
|
60
|
+
const pinnedTop = /\b(fixed|sticky)\b/.test(cls) && /\btop-0\b|top:\s*0/.test(cls);
|
|
61
|
+
if (!pinnedTop)
|
|
62
|
+
return false;
|
|
63
|
+
if (el.querySelector(HEADING_TAGS.join(",")))
|
|
64
|
+
return false; // a pinned hero/banner, not a nav bar
|
|
65
|
+
return !!el.querySelector("nav") || el.querySelectorAll("a").length >= 2;
|
|
66
|
+
}
|
|
67
|
+
/** A repeating grid of ≥2 card-like siblings that each carry a title (heading/strong) or an icon — a feature/benefit grid. Catches 2-column Stitch grids that the ≥3 count misses. */
|
|
68
|
+
function looksLikeCardGrid(el) {
|
|
69
|
+
const found = findRepeatingContainer(el);
|
|
70
|
+
if (!found || found.items.length < 2)
|
|
71
|
+
return false;
|
|
72
|
+
const titled = found.items.filter((it) => it.querySelector(HEADING_TAGS.join(",")) || it.querySelector("strong, b") || detectIconFont(it)).length;
|
|
73
|
+
return titled >= 2;
|
|
74
|
+
}
|
|
52
75
|
export function classifySection(el, detail) {
|
|
53
76
|
const tag = el.tagName?.toLowerCase();
|
|
54
77
|
if (tag === "header")
|
|
55
78
|
return classifyHeader(el, detail);
|
|
56
79
|
if (tag === "footer")
|
|
57
80
|
return classifyFooter(el, detail);
|
|
81
|
+
// A top nav bar without a <header> tag (Stitch uses <nav class="fixed top-0…">) is the header.
|
|
82
|
+
if (looksLikeTopBar(el))
|
|
83
|
+
return classifyHeader(el, detail);
|
|
58
84
|
const form = el.querySelector("form");
|
|
59
85
|
if (form)
|
|
60
86
|
return classifyForm(el, form, detail);
|
|
@@ -82,7 +108,10 @@ export function classifySection(el, detail) {
|
|
|
82
108
|
}
|
|
83
109
|
return sec;
|
|
84
110
|
}
|
|
85
|
-
|
|
111
|
+
const headingTag = heading?.tagName?.toLowerCase();
|
|
112
|
+
// features: the classic ≥3 count OR a ≥2-card repeating grid (Stitch 2-column
|
|
113
|
+
// benefit grids) — but never steal an h1 section (that's the hero).
|
|
114
|
+
if (countFeatureBlocks(el) >= 3 || (headingTag !== "h1" && looksLikeCardGrid(el))) {
|
|
86
115
|
const sec = { role: "features", heading: elText(heading), subheading, ctas: ctas.length ? ctas : undefined };
|
|
87
116
|
if (detail === "full") {
|
|
88
117
|
const blocks = detectBlocks(el);
|
|
@@ -94,7 +123,7 @@ export function classifySection(el, detail) {
|
|
|
94
123
|
}
|
|
95
124
|
return sec;
|
|
96
125
|
}
|
|
97
|
-
if (
|
|
126
|
+
if (headingTag === "h1" && (imgSrcs.length > 0 || ctas.length > 0)) {
|
|
98
127
|
const sec = {
|
|
99
128
|
role: "hero",
|
|
100
129
|
heading: elText(heading),
|
|
@@ -105,8 +134,35 @@ export function classifySection(el, detail) {
|
|
|
105
134
|
};
|
|
106
135
|
return sec;
|
|
107
136
|
}
|
|
108
|
-
|
|
109
|
-
|
|
137
|
+
// CTA band: a heading + call-to-action with no card grid and no imagery — allow
|
|
138
|
+
// a couple of supporting paragraphs (a closing "ready to start?" band), not just one.
|
|
139
|
+
if (ctas.length > 0 && imgSrcs.length === 0 && paragraphs.length <= 3) {
|
|
140
|
+
return {
|
|
141
|
+
role: "cta",
|
|
142
|
+
heading: elText(heading),
|
|
143
|
+
subheading,
|
|
144
|
+
paragraphs: paragraphs.length ? paragraphs.slice(0, detail === "full" ? 2 : 1) : undefined,
|
|
145
|
+
ctas,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
// Content/about band: a heading with real prose (and maybe an image) — a far more
|
|
149
|
+
// useful role than "unknown" for the rebuild. Reserve "unknown" for sections with
|
|
150
|
+
// no heading AND no paragraphs.
|
|
151
|
+
if (elText(heading) && paragraphs.length >= 1) {
|
|
152
|
+
const sec = {
|
|
153
|
+
role: "about",
|
|
154
|
+
heading: elText(heading),
|
|
155
|
+
subheading,
|
|
156
|
+
paragraphs: paragraphs.slice(0, detail === "full" ? 6 : 3),
|
|
157
|
+
images: detail === "full" ? images.slice(0, 4) : images.slice(0, 4),
|
|
158
|
+
ctas: ctas.length ? ctas : undefined,
|
|
159
|
+
};
|
|
160
|
+
if (detail === "full") {
|
|
161
|
+
const blocks = detectBlocks(el);
|
|
162
|
+
if (blocks.length)
|
|
163
|
+
sec.blocks = blocks;
|
|
164
|
+
}
|
|
165
|
+
return sec;
|
|
110
166
|
}
|
|
111
167
|
const sec = {
|
|
112
168
|
role: "unknown",
|
|
@@ -330,31 +386,69 @@ function findRepeatingContainer(root, maxDepth = 4) {
|
|
|
330
386
|
* text is ≤4 chars (emoji/badge) — surfaced separately so the model can map
|
|
331
387
|
* it to a Webcake svg-mask rectangle.
|
|
332
388
|
*/
|
|
389
|
+
// Font-Awesome style classes that are NOT the icon name (fa-solid, fa-2x, …).
|
|
390
|
+
const FA_STYLE_TOKENS = new Set(["solid", "regular", "light", "thin", "duotone", "brands", "sharp", "fw", "lg", "sm", "xs", "xl", "2xs", "1x", "2x", "3x", "4x", "5x", "6x", "7x", "8x", "9x", "10x", "rotate", "flip", "spin", "pulse", "border", "pull", "stack"]);
|
|
391
|
+
/**
|
|
392
|
+
* A Material-Symbols / Material-Icons / Font-Awesome icon inside `scope`,
|
|
393
|
+
* normalized to "ms:<name>" / "fa:<name>" (the icon NAME, not a glyph). These
|
|
394
|
+
* are font ligatures the clone can't render as plain text — surfaced so the model
|
|
395
|
+
* loads the icon font (settings.bhet) and renders each as a text-block/html-box,
|
|
396
|
+
* instead of dropping the icon or pasting the raw ligature word.
|
|
397
|
+
*/
|
|
398
|
+
function detectIconFont(scope) {
|
|
399
|
+
const ms = scope.querySelector('[class*="material-symbols"],[class*="material-icons"]');
|
|
400
|
+
if (ms) {
|
|
401
|
+
const name = ms.textContent.trim().toLowerCase().replace(/\s+/g, "_");
|
|
402
|
+
if (name && /^[a-z0-9_]+$/.test(name))
|
|
403
|
+
return `ms:${name}`;
|
|
404
|
+
}
|
|
405
|
+
const fa = scope.querySelector('i[class*="fa-"],span[class*="fa-"]');
|
|
406
|
+
if (fa) {
|
|
407
|
+
const name = (fa.getAttribute("class") ?? "")
|
|
408
|
+
.split(/\s+/)
|
|
409
|
+
.map((c) => /^fa-([a-z0-9-]+)$/i.exec(c)?.[1])
|
|
410
|
+
.find((n) => !!n && !FA_STYLE_TOKENS.has(n));
|
|
411
|
+
if (name)
|
|
412
|
+
return `fa:${name}`;
|
|
413
|
+
}
|
|
414
|
+
return undefined;
|
|
415
|
+
}
|
|
333
416
|
function detectBlocks(el) {
|
|
334
417
|
const found = findRepeatingContainer(el);
|
|
335
418
|
if (!found || found.items.length < 2)
|
|
336
419
|
return [];
|
|
337
420
|
return found.items.slice(0, 12).map((c) => {
|
|
338
421
|
const kids = elementChildren(c);
|
|
339
|
-
//
|
|
422
|
+
// True when a child IS or CONTAINS an icon-font glyph (so it's never mistaken
|
|
423
|
+
// for the title/body — Material Symbols ligature text like "verified" reads as
|
|
424
|
+
// a word but is an icon).
|
|
425
|
+
const containsIcon = (k) => /material-symbols|material-icons|\bfa-/.test(k.getAttribute("class") ?? "") ||
|
|
426
|
+
!!k.querySelector('[class*="material-symbols"],[class*="material-icons"],i[class*="fa-"]');
|
|
427
|
+
// ── icon slot ── prefer a real icon-font glyph (Material Symbols / Font
|
|
428
|
+
// Awesome), captured as "ms:<name>"/"fa:<name>" regardless of name length;
|
|
429
|
+
// fall back to an emoji / short-text / icon-class child.
|
|
430
|
+
const iconFont = detectIconFont(c);
|
|
340
431
|
const iconEl = kids.find((k) => {
|
|
341
432
|
const cls = (k.getAttribute("class") ?? "").toLowerCase();
|
|
342
|
-
if (/icon|emoji|badge|img/.test(cls))
|
|
433
|
+
if (/icon|emoji|badge|img|material-symbols|material-icons/.test(cls))
|
|
434
|
+
return true;
|
|
435
|
+
if (containsIcon(k))
|
|
343
436
|
return true;
|
|
344
437
|
const t = k.textContent.trim();
|
|
345
438
|
return t.length > 0 && t.length <= 4; // likely a single emoji
|
|
346
439
|
});
|
|
347
|
-
const icon = iconEl?.textContent?.trim() || undefined;
|
|
440
|
+
const icon = iconFont ?? (iconEl?.textContent?.trim() || undefined);
|
|
441
|
+
const isIcon = (k) => k === iconEl || containsIcon(k);
|
|
348
442
|
// ── title ──
|
|
349
443
|
const headingEl = c.querySelector(HEADING_TAGS.join(","));
|
|
350
444
|
const titleClassEl = kids.find((k) => {
|
|
351
445
|
const cls = (k.getAttribute("class") ?? "").toLowerCase();
|
|
352
|
-
return /title|name|heading|label/.test(cls) && k
|
|
446
|
+
return /title|name|heading|label/.test(cls) && !isIcon(k);
|
|
353
447
|
});
|
|
354
448
|
const strongEl = c.querySelector("strong") || c.querySelector("b");
|
|
355
449
|
// fallback: first non-icon short-text child
|
|
356
450
|
const fallbackTitleEl = kids.find((k) => {
|
|
357
|
-
if (k
|
|
451
|
+
if (isIcon(k))
|
|
358
452
|
return false;
|
|
359
453
|
const cls = (k.getAttribute("class") ?? "").toLowerCase();
|
|
360
454
|
if (/icon|emoji|badge/.test(cls))
|
|
@@ -367,15 +461,14 @@ function detectBlocks(el) {
|
|
|
367
461
|
// ── body ──
|
|
368
462
|
// Prefer <p> tags; fall back to div children whose class contains body|desc|text|content
|
|
369
463
|
const titleText = title ?? "";
|
|
370
|
-
const iconText = icon ?? "";
|
|
371
464
|
const bodyFromP = c.querySelectorAll("p")
|
|
372
465
|
.map((p) => p.text.trim())
|
|
373
|
-
.filter((t) => t && t !== titleText && t
|
|
466
|
+
.filter((t) => t && t !== titleText && t.length > 5)
|
|
374
467
|
.join(" ")
|
|
375
468
|
.slice(0, 250);
|
|
376
469
|
const bodyFromDiv = !bodyFromP ? kids
|
|
377
470
|
.filter((k) => {
|
|
378
|
-
if (k
|
|
471
|
+
if (isIcon(k) || k === titleEl)
|
|
379
472
|
return false;
|
|
380
473
|
const cls = (k.getAttribute("class") ?? "").toLowerCase();
|
|
381
474
|
return /body|desc|text|content|copy/.test(cls);
|
package/dist/smoke.js
CHANGED
|
@@ -15,6 +15,7 @@ import { normalizePhoto, resolvePexelsKey, pexelsKeyFromHeaders, resolvePexelsPr
|
|
|
15
15
|
import { putDraft, getDraft, updateDraft, deleteDraft } from "./persistence/draft-cache.js";
|
|
16
16
|
import { buildConnectUrl, parseCallback } from "./auth/login.js";
|
|
17
17
|
import { isLocalPath, resolveLocalPath, sniffMime, localContentType } from "./tools/media.js";
|
|
18
|
+
import { iconifyCandidates } from "./persistence/icon-client.js";
|
|
18
19
|
import { collectExternalImageUrls, rewriteImageUrls, isRehostableImageUrl } from "./persistence/rehost.js";
|
|
19
20
|
let failures = 0;
|
|
20
21
|
const check = (name, cond, extra) => {
|
|
@@ -176,6 +177,28 @@ console.log("== expand: relocates misplaced responsive.<bp>.animation into confi
|
|
|
176
177
|
console.log("== validate: accepts JSON string input ==");
|
|
177
178
|
const r3 = validatePage(JSON.stringify(good));
|
|
178
179
|
check("string input parsed & valid", r3.valid, r3.errors);
|
|
180
|
+
console.log("== canvas width is a CHOICE (settings.width_section 960|1200 / 420|360) ==");
|
|
181
|
+
{
|
|
182
|
+
// createPageSource sets the chosen width up front.
|
|
183
|
+
const wide = landingDomain.createPageSource({ settings: { width_section: { desktop: 1200, mobile: 360 } } });
|
|
184
|
+
check("width: createPageSource honors width_section override", wide.settings.width_section.desktop === 1200 && wide.settings.width_section.mobile === 360, wide.settings.width_section);
|
|
185
|
+
// An element at left:1000 width:180 (right edge 1180) overflows a 960 canvas but FITS a 1200 canvas.
|
|
186
|
+
const mk = (deskW) => {
|
|
187
|
+
const p = JSON.parse(JSON.stringify(good));
|
|
188
|
+
p.settings.width_section = { desktop: deskW, mobile: 420 };
|
|
189
|
+
p.page[0].children[0].responsive.desktop.styles = { top: 100, left: 1000, width: 180, height: 44 };
|
|
190
|
+
return p;
|
|
191
|
+
};
|
|
192
|
+
const at960 = validatePage(mk(960));
|
|
193
|
+
check("width: overflow flagged against the 960 canvas", at960.warnings.some((w) => /exceeds canvas 960/.test(w)), at960.warnings);
|
|
194
|
+
const at1200 = validatePage(mk(1200));
|
|
195
|
+
check("width: SAME element fits the 1200 canvas (no overflow warning)", !at1200.warnings.some((w) => /exceeds canvas/.test(w)), at1200.warnings);
|
|
196
|
+
check("width: 1200 canvas page is valid", at1200.valid, at1200.errors);
|
|
197
|
+
// Only the editor-allowed widths pass — a stray width is a schema error.
|
|
198
|
+
const badW = JSON.parse(JSON.stringify(good));
|
|
199
|
+
badW.settings.width_section = { desktop: 1000, mobile: 420 };
|
|
200
|
+
check("width: non-allowed desktop width (1000) rejected by schema enum", !validatePage(badW).valid, validatePage(badW).errors);
|
|
201
|
+
}
|
|
179
202
|
console.log("== validate: custom CSS/class/JS escape hatches (beyond-element capability) ==");
|
|
180
203
|
{
|
|
181
204
|
const clone = () => JSON.parse(JSON.stringify(good));
|
|
@@ -201,6 +224,75 @@ console.log("== validate: custom CSS/class/JS escape hatches (beyond-element cap
|
|
|
201
224
|
check("escape hatch: warns selector/:hover inside custom_css", selR.warnings.some((w) => /declarations inside/.test(w)), selR.warnings);
|
|
202
225
|
check("escape hatch: declarations-only misuse does NOT block (warning, not error)", selR.valid, selR.errors);
|
|
203
226
|
}
|
|
227
|
+
console.log("== validate: custom-code SAFETY (broad/broken custom breaks the UI) ==");
|
|
228
|
+
{
|
|
229
|
+
const cloneG = () => JSON.parse(JSON.stringify(good));
|
|
230
|
+
const W = (r, re) => r.warnings.filter((w) => re.test(w));
|
|
231
|
+
// settings.extra_css with bare-tag + Webcake-internal selectors → unscoped warning; scoped #w- rule does NOT trip it.
|
|
232
|
+
const broad = cloneG();
|
|
233
|
+
broad.settings.extra_css = "body{margin:0} .rectangle-css{opacity:.5} #w-btn1:hover{transform:scale(1.02)}";
|
|
234
|
+
const broadR = validatePage(broad);
|
|
235
|
+
check("custom-safety: unscoped extra_css selectors (body/.rectangle-css) flagged", W(broadR, /UNSCOPED selector/).length > 0, broadR.warnings);
|
|
236
|
+
check("custom-safety: a #w- scoped rule is NOT flagged", !/#w-btn1/.test(W(broadR, /UNSCOPED selector/)[0] ?? ""), W(broadR, /UNSCOPED/));
|
|
237
|
+
// unbalanced braces in extra_css.
|
|
238
|
+
const braces = cloneG();
|
|
239
|
+
braces.settings.extra_css = "#w-btn1{color:red";
|
|
240
|
+
check("custom-safety: unbalanced extra_css braces flagged", W(validatePage(braces), /unbalanced braces/).length > 0);
|
|
241
|
+
// bhet holding raw CSS (no tags) → wrong-field warning.
|
|
242
|
+
const bhetCss = cloneG();
|
|
243
|
+
bhetCss.settings.bhet = "body{margin:0}";
|
|
244
|
+
check("custom-safety: bhet with no HTML tags flagged (belongs in extra_css)", W(validatePage(bhetCss), /no HTML tags/).length > 0);
|
|
245
|
+
// bbet with an unclosed <script> → swallow warning.
|
|
246
|
+
const bbetBad = cloneG();
|
|
247
|
+
bbetBad.settings.bbet = "<script>init()";
|
|
248
|
+
check("custom-safety: bbet unclosed <script> flagged", W(validatePage(bbetBad), /unbalanced <script>/).length > 0);
|
|
249
|
+
// element custom_css with layout props → break-layout warning (visual props alone do NOT trip it).
|
|
250
|
+
const layoutCss = cloneG();
|
|
251
|
+
layoutCss.page[0].children[0].specials = { text: "X", customAdvance: true, custom_css: "width:100%;display:flex;box-shadow:0 2px 8px rgba(0,0,0,.1);" };
|
|
252
|
+
check("custom-safety: custom_css layout props (width/display) flagged", W(validatePage(layoutCss), /layout prop/).length > 0, validatePage(layoutCss).warnings);
|
|
253
|
+
const visualCss = cloneG();
|
|
254
|
+
visualCss.page[0].children[0].specials = { text: "X", customAdvance: true, custom_css: "box-shadow:0 2px 8px rgba(0,0,0,.1);backdrop-filter:blur(8px);" };
|
|
255
|
+
check("custom-safety: visual-only custom_css is NOT flagged", W(validatePage(visualCss), /layout prop/).length === 0, validatePage(visualCss).warnings);
|
|
256
|
+
// a correct, fully-scoped custom setup → none of these warnings.
|
|
257
|
+
const clean = cloneG();
|
|
258
|
+
clean.settings.extra_css = "#w-btn1{transition:transform .3s}#w-btn1:hover{transform:translateY(-2px)}";
|
|
259
|
+
clean.settings.bhet = "<link href='https://fonts.googleapis.com/css2?family=Inter' rel='stylesheet'>";
|
|
260
|
+
clean.page[0].children[0].specials = { text: "X", customAdvance: true, custom_css: "box-shadow:0 8px 24px rgba(0,0,0,.08);" };
|
|
261
|
+
check("custom-safety: correctly-scoped custom triggers no safety warning", W(validatePage(clean), /UNSCOPED|unbalanced|no HTML tags|layout prop/).length === 0, validatePage(clean).warnings);
|
|
262
|
+
}
|
|
263
|
+
console.log("== validate: icon rendering (svg-mask needs background; font-class route is clean) ==");
|
|
264
|
+
{
|
|
265
|
+
const cloneG = () => JSON.parse(JSON.stringify(good));
|
|
266
|
+
const maskChild = (bg) => ({
|
|
267
|
+
id: "icon1", type: "rectangle", properties: { name: "icon", movable: true, sync: true },
|
|
268
|
+
specials: {}, runtime: {}, events: [],
|
|
269
|
+
responsive: {
|
|
270
|
+
desktop: { config: { svgMask: "<svg viewBox='0 0 24 24'><path d='M4 4h16v16H4z'/></svg>" }, styles: { top: 10, left: 10, width: 40, height: 40, ...(bg ? { background: bg } : {}) } },
|
|
271
|
+
mobile: { config: { svgMask: "<svg viewBox='0 0 24 24'><path d='M4 4h16v16H4z'/></svg>" }, styles: { top: 10, left: 10, width: 40, height: 40, ...(bg ? { background: bg } : {}) } },
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
// svg-mask WITHOUT a background fill → invisible-icon warning (the "svg in a rectangle doesn't show" bug).
|
|
275
|
+
const noBg = cloneG();
|
|
276
|
+
noBg.page[0].children = [maskChild()];
|
|
277
|
+
check("icon-render: svg-mask rectangle without background → invisible warning", validatePage(noBg).warnings.some((w) => /svgMask is set but styles\.background/.test(w)), validatePage(noBg).warnings);
|
|
278
|
+
// svg-mask WITH a solid background → no invisible warning.
|
|
279
|
+
const withBg = cloneG();
|
|
280
|
+
withBg.page[0].children = [maskChild("rgba(0,88,188,1)")];
|
|
281
|
+
check("icon-render: svg-mask rectangle WITH background → no invisible warning", !validatePage(withBg).warnings.some((w) => /svgMask is set but styles\.background/.test(w)), validatePage(withBg).warnings);
|
|
282
|
+
// font-class route (the Stitch-faithful one): text-block with a Material Symbols span + the font loaded via bhet → valid & clean.
|
|
283
|
+
const fontRoute = cloneG();
|
|
284
|
+
fontRoute.settings.bhet = "<link href='https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined' rel='stylesheet'>";
|
|
285
|
+
fontRoute.page[0].children = [{
|
|
286
|
+
id: "icon2", type: "text-block", properties: { name: "icon", movable: true, sync: true },
|
|
287
|
+
specials: { text: "<span class=\"material-symbols-outlined\">verified</span>" }, runtime: {}, events: [],
|
|
288
|
+
responsive: {
|
|
289
|
+
desktop: { config: {}, styles: { top: 10, left: 10, width: 40, height: 40, fontSize: 32, color: "rgba(0,88,188,1)" } },
|
|
290
|
+
mobile: { config: {}, styles: { top: 10, left: 10, width: 40, height: 40, fontSize: 28, color: "rgba(0,88,188,1)" } },
|
|
291
|
+
},
|
|
292
|
+
}];
|
|
293
|
+
const fr = validatePage(fontRoute);
|
|
294
|
+
check("icon-render: font-class text-block icon validates clean (no svg/emoji warning)", fr.valid && !fr.warnings.some((w) => /svgMask|emoji/.test(w)), { errors: fr.errors, warnings: fr.warnings });
|
|
295
|
+
}
|
|
204
296
|
console.log("== expand: hydrates sparse nodes ==");
|
|
205
297
|
const sparse = {
|
|
206
298
|
page: [
|
|
@@ -303,6 +395,31 @@ check("ingest: hero image captured", (hero?.images?.length ?? 0) > 0, hero?.imag
|
|
|
303
395
|
const form = ast.sections.find((s) => s.role === "form");
|
|
304
396
|
check("ingest: form fields captured", (form?.form_fields?.length ?? 0) >= 2, form?.form_fields);
|
|
305
397
|
check("ingest: form submit CTA captured", !!form?.ctas?.[0]?.text?.includes("Place"), form?.ctas);
|
|
398
|
+
console.log("== ingest: Stitch-structure section classification (generalizes to any Stitch HTML) ==");
|
|
399
|
+
{
|
|
400
|
+
// Mirrors the real Stitch shape: <nav> top bar (no <header>), <main> with a
|
|
401
|
+
// h1 hero, 2-column card grids, a card-less CTA band, <footer>, and a sticky
|
|
402
|
+
// BOTTOM <nav> action bar.
|
|
403
|
+
const stitchStruct = `<!DOCTYPE html><html><head></head><body>
|
|
404
|
+
<nav class="fixed top-0 w-full z-50 bg-white/80"><div class="font-bold">BrandName</div><div><a href="#">Pricing</a><a href="#">Docs</a></div><a class="px-5 py-2 rounded-xl bg-primary" href="#">Sign in</a></nav>
|
|
405
|
+
<main class="pt-20">
|
|
406
|
+
<section class="pt-24 pb-32"><div class="grid lg:grid-cols-12"><div class="lg:col-span-7"><h1>Become a strategic partner</h1><p>Earn recurring commission on every customer you refer to us.</p><a class="px-8 py-4 rounded-xl bg-primary" href="#">Join now</a></div><div class="lg:col-span-5"><img src="https://x/hero.jpg"/></div></div></section>
|
|
407
|
+
<section class="py-24 bg-surface-container-low"><div class="text-center"><h2>Transparent income</h2></div><div class="grid md:grid-cols-2 gap-6"><div class="card p-8 rounded-xl"><span class="material-symbols-outlined">payments</span><h3>Fast payouts</h3><p>Money in your account within days, not months at all.</p></div><div class="card p-8 rounded-xl"><span class="material-symbols-outlined">monitoring</span><h3>Live tracking</h3><p>Watch every referral convert in a real-time dashboard.</p></div></div></section>
|
|
408
|
+
<section class="py-20"><div class="rounded-3xl p-12 text-center"><h2>Ready to boost your income?</h2><p>Join thousands of partners already earning with us.</p><p>No setup fee, cancel anytime, get started in minutes.</p><a class="px-8 py-4 rounded-xl bg-primary" href="#">Get started</a></div></section>
|
|
409
|
+
</main>
|
|
410
|
+
<footer class="pt-12 pb-8"><div class="grid grid-cols-4"><a href="#">About</a><a href="#">Blog</a><a href="#">Terms</a><a href="#">Contact</a></div><p>© 2026 BrandName</p></footer>
|
|
411
|
+
<nav class="fixed bottom-0 left-0 right-0 z-50 bg-white"><a class="px-6 py-3 rounded-xl bg-primary" href="#">Join now</a></nav>
|
|
412
|
+
</body></html>`;
|
|
413
|
+
const ss = parseHtml(stitchStruct, "full");
|
|
414
|
+
const roles = ss.sections.map((s) => s.role);
|
|
415
|
+
check("stitch-struct: <nav> top bar (no <header>) → header", roles[0] === "header", roles);
|
|
416
|
+
check("stitch-struct: h1 section → hero", ss.sections.some((s) => s.role === "hero" && /strategic partner/.test(s.heading ?? "")), roles);
|
|
417
|
+
check("stitch-struct: 2-card grid → features (not unknown)", ss.sections.some((s) => s.role === "features" && (s.blocks?.length ?? 0) === 2), roles);
|
|
418
|
+
check("stitch-struct: card-less CTA band with 2 paragraphs → cta (not unknown)", ss.sections.some((s) => s.role === "cta" && /Ready to boost/.test(s.heading ?? "")), roles);
|
|
419
|
+
check("stitch-struct: <footer> → footer", roles.includes("footer"), roles);
|
|
420
|
+
check("stitch-struct: sticky BOTTOM <nav> is NOT a second header", roles.filter((r) => r === "header").length === 1, roles);
|
|
421
|
+
check("stitch-struct: no 'unknown' sections left", !roles.includes("unknown"), roles);
|
|
422
|
+
}
|
|
306
423
|
console.log("== ingest: size_hint (desktop section heights) ==");
|
|
307
424
|
check("ingest: every section has a size_hint", ast.sections.every((s) => (s.size_hint?.height ?? 0) > 0), ast.sections.map((s) => s.size_hint));
|
|
308
425
|
const headerHint = ast.sections.find((s) => s.role === "header")?.size_hint;
|
|
@@ -491,6 +608,23 @@ const arbGrad = parseHtml(`<!DOCTYPE html><html><head>${fxCfg}</head><body><main
|
|
|
491
608
|
check("fx: arbitrary [#hex] gradient stop resolved", (arbGrad.gradients ?? []).includes("linear-gradient(to right, #ff0000, #0058bc)"), arbGrad.gradients);
|
|
492
609
|
// no tailwind config → no gradients from this path, no hover noise on a plain page.
|
|
493
610
|
check("fx: plain page (no config, no hover classes) has no gradients/hover", (() => { const a = parseHtml(stylesheetHtml, "compact"); return a.gradients === undefined && a.sections.every((s) => s.hover_effects === undefined); })());
|
|
611
|
+
console.log("== ingest: icon-font extraction (Material Symbols / Font Awesome) ==");
|
|
612
|
+
{
|
|
613
|
+
// Feature cards with Material Symbols icons (a long ligature name + a nested-wrapper icon) and one Font Awesome card.
|
|
614
|
+
const iconHtml = `<!DOCTYPE html><html><head></head><body><main><section><h2>Why choose us</h2>
|
|
615
|
+
<div class="grid"><div class="card"><span class="material-symbols-outlined">verified</span><h3>Trusted</h3><p>Verified by thousands of happy partners every month.</p></div>
|
|
616
|
+
<div class="card"><div class="icon-wrap"><span class="material-symbols-outlined">support_agent</span></div><h3>Support</h3><p>A dedicated manager helps you grow revenue fast.</p></div>
|
|
617
|
+
<div class="card"><i class="fa-solid fa-chart-line fa-2x"></i><h3>Analytics</h3><p>Track every referral conversion in real time dashboards.</p></div></div>
|
|
618
|
+
</section></main></body></html>`;
|
|
619
|
+
const ia = parseHtml(iconHtml, "full");
|
|
620
|
+
const fsec = ia.sections.find((s) => (s.blocks || []).length >= 3);
|
|
621
|
+
const blocks = fsec?.blocks ?? [];
|
|
622
|
+
check("icon: Material Symbols long ligature captured as ms:<name>", blocks[0]?.icon === "ms:verified", blocks[0]);
|
|
623
|
+
check("icon: nested-wrapper Material Symbol captured", blocks[1]?.icon === "ms:support_agent", blocks[1]);
|
|
624
|
+
check("icon: Font Awesome captured as fa:<name> (style tokens skipped)", blocks[2]?.icon === "fa:chart-line", blocks[2]);
|
|
625
|
+
check("icon: ligature name does NOT leak into the card title", blocks[0]?.title === "Trusted" && blocks[1]?.title === "Support", blocks.map((b) => b.title));
|
|
626
|
+
check("icon: real body kept (icon excluded)", /Verified by thousands/.test(blocks[0]?.body ?? ""), blocks[0]?.body);
|
|
627
|
+
}
|
|
494
628
|
console.log("== ingest: full mode — blocks detection, gradients, images-as-objects ==");
|
|
495
629
|
const fullAst = parseHtml(stylesheetHtml, "full");
|
|
496
630
|
check("ingest: full mode palette present", fullAst.palette?.["primary"] === "#0A7C6E", fullAst.palette);
|
|
@@ -1599,6 +1733,12 @@ console.log("== rehost: external-image URL collect + rewrite (pure, offline) =="
|
|
|
1599
1733
|
}
|
|
1600
1734
|
console.log("== upload_images: local-path detector (pure, offline) ==");
|
|
1601
1735
|
{
|
|
1736
|
+
// get_icon_svg name → Iconify candidate mapping (pure; the fetch itself is networked)
|
|
1737
|
+
check("icon-svg: ms:<name> → material-symbols outline-first, underscore→hyphen", JSON.stringify(iconifyCandidates("ms:support_agent")) === JSON.stringify(["material-symbols/support-agent-outline", "material-symbols/support-agent"]), iconifyCandidates("ms:support_agent"));
|
|
1738
|
+
check("icon-svg: fa:<name> → fa6-solid first with fallbacks", iconifyCandidates("fa:chart-line")[0] === "fa6-solid/chart-line", iconifyCandidates("fa:chart-line"));
|
|
1739
|
+
check("icon-svg: bare name assumed Material Symbols", iconifyCandidates("verified")[0] === "material-symbols/verified-outline", iconifyCandidates("verified"));
|
|
1740
|
+
check("icon-svg: real Iconify id passes through", iconifyCandidates("mdi:home")[0] === "mdi/home", iconifyCandidates("mdi:home"));
|
|
1741
|
+
check("icon-svg: empty ref → no candidates", iconifyCandidates("")?.length === 0, iconifyCandidates(""));
|
|
1602
1742
|
// isLocalPath: recognised forms
|
|
1603
1743
|
check("localPath: absolute POSIX /…", isLocalPath("/home/user/photo.jpg"));
|
|
1604
1744
|
check("localPath: home-dir ~/…", isLocalPath("~/Pictures/logo.png"));
|
package/dist/tools/generation.js
CHANGED
|
@@ -21,7 +21,16 @@ export function registerGenerationTools(server, domain) {
|
|
|
21
21
|
return text(el);
|
|
22
22
|
});
|
|
23
23
|
// 6) New page skeleton ------------------------------------------------------
|
|
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.
|
|
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. Pass desktopWidth/mobileWidth to set the canvas width (settings.width_section) up front — pick desktop 1200 for wide/multi-column/editorial pages or when cloning a reference wider than 960 (e.g. Google Stitch ~1280), else 960; then place every element's coords in that width's space.", {
|
|
25
|
+
mobileOnly: z.boolean().optional().describe("true if the page renders mobile-only."),
|
|
26
|
+
desktopWidth: z.union([z.literal(960), z.literal(1200)]).optional().describe("Desktop canvas width (settings.width_section.desktop). 960 (default, simple/narrow) or 1200 (wide/multi-column/editorial, or cloning a >960 reference)."),
|
|
27
|
+
mobileWidth: z.union([z.literal(420), z.literal(360)]).optional().describe("Mobile canvas width (settings.width_section.mobile). 420 (default) or 360 (to match a ~360–390 mobile design)."),
|
|
28
|
+
}, { title: "New Page Skeleton", readOnlyHint: true, openWorldHint: false }, async ({ mobileOnly, desktopWidth, mobileWidth }) => text(domain.createPageSource({
|
|
29
|
+
mobileOnly: mobileOnly ?? false,
|
|
30
|
+
settings: desktopWidth || mobileWidth
|
|
31
|
+
? { width_section: { desktop: desktopWidth ?? 960, mobile: mobileWidth ?? 420 } }
|
|
32
|
+
: undefined,
|
|
33
|
+
})));
|
|
25
34
|
// 7) Validate page ----------------------------------------------------------
|
|
26
35
|
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
36
|
page: z
|
package/dist/tools/media.js
CHANGED
|
@@ -29,6 +29,7 @@ import { fileURLToPath } from "node:url";
|
|
|
29
29
|
import { text } from "../mcp/response.js";
|
|
30
30
|
import { searchPexels, searchImagesViaProxy, resolvePexelsKey, resolvePexelsProxyBase, pexelsKeyFromHeaders, } from "../persistence/pexels-client.js";
|
|
31
31
|
import { uploadImageMultipart } from "../persistence/webcake-client.js";
|
|
32
|
+
import { resolveIconSvg } from "../persistence/icon-client.js";
|
|
32
33
|
import { configFromHeaders, ENVIRONMENTS, stripTrailingSlash } from "../persistence/config.js";
|
|
33
34
|
/** Resolve just the API base (no JWT required) from per-request headers → env → WEBCAKE_ENV preset → prod default. */
|
|
34
35
|
function resolveApiBase(headers) {
|
|
@@ -221,6 +222,36 @@ export function registerMediaTools(server, allowLocalFiles = true) {
|
|
|
221
222
|
}
|
|
222
223
|
return text({ queries: out });
|
|
223
224
|
});
|
|
225
|
+
// 13b) Resolve icon-font names to real inline SVGs --------------------------
|
|
226
|
+
server.tool("get_icon_svg", "Resolves icon-font NAMES into real inline SVG markup via the public Iconify API — so a clone reproduces a reference's icons (esp. Google Stitch, which renders icons with a Material Symbols / Font Awesome CLASS, not an image). ingest_html/ingest_url surface those icons as block.icon \"ms:<name>\" (Material Symbols) / \"fa:<name>\" (Font Awesome); pass them here to get the SVG. ACCEPTS: \"ms:verified\", \"fa:chart-line\", a real Iconify id (\"mdi:home\"), or a bare name (assumed Material Symbols); underscores are normalized to hyphens, and Material Symbols resolve to the OUTLINED variant (the Stitch look) with a filled fallback. Returns { icons: { \"<ref>\": { ok, svg, iconify } } }. RENDER each svg as Webcake's native icon element — a RECTANGLE: put the svg in BOTH responsive.desktop.config.svgMask AND responsive.mobile.config.svgMask, set styles.background = the icon color, and keep the box SQUARE (width === height). The svg is only a MASK (its own fill is ignored), so the icon is BLANK without a solid styles.background; the renderer reads each breakpoint's svgMask separately (no fallback) and forces preserveAspectRatio='none' (a non-square box stretches it). No Webcake credentials needed.", {
|
|
227
|
+
icons: z
|
|
228
|
+
.array(z.string())
|
|
229
|
+
.min(1)
|
|
230
|
+
.max(40)
|
|
231
|
+
.describe("Icon references to resolve (1–40), e.g. [\"ms:verified\", \"ms:support_agent\", \"fa:chart-line\"] — typically the block.icon values from an ingest result."),
|
|
232
|
+
}, { title: "Resolve Icon SVGs", readOnlyHint: true, openWorldHint: true }, async ({ icons }) => {
|
|
233
|
+
const unique = [...new Set(icons)];
|
|
234
|
+
const results = await Promise.all(unique.map(async (ref) => [ref, await resolveIconSvg(ref)]));
|
|
235
|
+
const out = {};
|
|
236
|
+
let resolved = 0;
|
|
237
|
+
let failed = 0;
|
|
238
|
+
for (const [ref, r] of results) {
|
|
239
|
+
if (r.ok) {
|
|
240
|
+
resolved++;
|
|
241
|
+
out[ref] = { ok: true, svg: r.svg, iconify: r.iconify };
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
failed++;
|
|
245
|
+
out[ref] = { ok: false, error: r.error };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return text({
|
|
249
|
+
icons: out,
|
|
250
|
+
resolved,
|
|
251
|
+
failed,
|
|
252
|
+
usage: "For each icons[<ref>].svg, make a rectangle in that card's icon slot: copy the svg into BOTH responsive.desktop.config.svgMask AND responsive.mobile.config.svgMask, set styles.background to the icon color, and keep the box SQUARE (width === height) — without styles.background the masked icon is invisible, and a non-square box stretches it. For any ok:false ref, fall back to an emoji inline or skip — never leave a feature card iconless.",
|
|
253
|
+
});
|
|
254
|
+
});
|
|
224
255
|
// 14) Upload images to Webcake -----------------------------------------------
|
|
225
256
|
server.tool("upload_images", "Converts external image URLs (typically collected from ingest_html/ingest_url results), data: URIs, or LOCAL FILE PATHS from the user's computer into Webcake-hosted URLs (statics.pancake.vn) by reading/downloading each image and re-uploading it to the Webcake backend via multipart upload (200 MB backend limit). Use this whenever the page is built from a reference HTML/URL (BOTH intents — adapt AND clone), the user supplies their own image URLs, OR the user provides local image files from their machine — pass the path directly in `urls`; NEVER upload a user's local file to a third-party host (catbox, imgur, transfer.sh…) to obtain a URL first. The returned URLs go directly into specials.src — same as search_images results. Processes up to 20 entries per call in parallel, with a 200 MB per-image cap. No Webcake credentials required (the upload endpoint is public). UPLOADS BY DEFAULT (dry_run defaults to FALSE — unlike the page-persistence tools, this touches no account data, so the default is the real upload): the call downloads/reads each entry, uploads it, and returns the images map (original URL → hosted URL); WAIT for that map before assembling the page and never fall back to a placeholder for a slot whose upload succeeded. Pass dry_run:true only to preview what would be processed without any network/filesystem activity. Use search_images instead when you need stock photos. Local file paths are only permitted when the MCP server runs locally (stdio mode); on the remote HTTP transport they are rejected per-entry.", {
|
|
226
257
|
urls: z
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webcake-landing-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.76",
|
|
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",
|