webcake-landing-mcp 1.0.74 → 1.0.75

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 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 20 tools in detail + the step-by-step workflow + model notes. |
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
- 20 tools in five groups — full descriptions in **[docs/tools.md](docs/tools.md)**:
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
 
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "v": "1.0.75",
4
+ "d": "15/06/2026",
5
+ "type": "Added",
6
+ "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…",
7
+ "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…"
8
+ },
2
9
  {
3
10
  "v": "1.0.74",
4
11
  "d": "13/06/2026",
@@ -33,12 +40,5 @@
33
40
  "type": "Changed",
34
41
  "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
42
  "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 FIXED: desktop = ${CANVAS.desktopWidth}px, mobile = ${CANVAS.mobileWidth}px (settings.width_section). Provide BOTH breakpoints; do not overlap elements within a section.
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)
@@ -121,6 +121,7 @@ RULES
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
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). RULES: custom_css is declarations-only (validate_page warns if you put a selector there) and needs customAdvance:true (validate_page warns if missing); extra_css/extra_script are raw and unscoped, so keep selectors specific to "#w-<id>" / your custom_class to avoid bleeding into the rest of the page; don't use these to replace a capability a built-in special already has (e.g. entrance animation → config.animation; click behavior → events).
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).
@@ -42,4 +42,4 @@ MODEL (essentials):
42
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 warns if custom_css lacks customAdvance or holds a selector.
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": "Canvas reference widths.",
50
- "properties": { "desktop": { "type": "number" }, "mobile": { "type": "number" } }
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"] },
@@ -419,8 +419,12 @@ export function validatePage(input) {
419
419
  // variation selector-16 (FE0F), keycap (20E3), whitespace
420
420
  /^(?:\p{Extended_Pictographic}|\p{Emoji_Component}|[\u200D\uFE0F\u20E3\s])+$/u.test(visible);
421
421
  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 (raw <svg> string) + styles.background set to the brand accent color instead (see the rectangle element's example). Emoji are fine inline within sentences, never as card icons.`);
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 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
423
  }
424
+ // An icon-font text-block (Material Symbols / Font Awesome span) is a
425
+ // SINGLE glyph, not wrapping text — the ligature name ("verified") is not
426
+ // displayed, so the text-metrics estimate is meaningless here. Skip it.
427
+ const isIconGlyph = /\b(material-symbols|material-icons)\b/.test(rawText) || /<i\b[^>]*\bfa-/.test(rawText);
424
428
  // Wrapped-text overflow: live text height is AUTO — text that wraps to
425
429
  // more lines than the declared box spills DOWN and overlaps the element
426
430
  // below (the classic "2-line card title over the card body" defect).
@@ -429,6 +433,8 @@ export function validatePage(input) {
429
433
  // 1-line-tall box — slip through, while body text (16px → 22px line)
430
434
  // keeps its old tolerance against the rough estimate.
431
435
  for (const bp of ["desktop", "mobile"]) {
436
+ if (isIconGlyph)
437
+ break;
432
438
  const styles = node.responsive?.[bp]?.styles;
433
439
  const w = num(styles?.width);
434
440
  const h = num(styles?.height);
@@ -854,7 +860,9 @@ export function validatePage(input) {
854
860
  return;
855
861
  const cpath = `${path}.children[${idx}]`;
856
862
  const rawText = child.type === "text-block" ? child.specials?.text : undefined;
857
- if (typeof rawText === "string") {
863
+ // Icon-font glyph (Material Symbols / Font Awesome) — one glyph, not wrapping text.
864
+ const isIconGlyph = typeof rawText === "string" && (/\b(material-symbols|material-icons)\b/.test(rawText) || /<i\b[^>]*\bfa-/.test(rawText));
865
+ if (typeof rawText === "string" && !isIconGlyph) {
858
866
  for (const bp of ["desktop", "mobile"]) {
859
867
  if (overlapWarnings >= MAX_OVERLAP_WARNINGS)
860
868
  break;
@@ -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
- export const CANVAS = { desktopWidth: 960, mobileWidth: 420, defaultSectionHeight: 800 };
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
- if (countFeatureBlocks(el) >= 3) {
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 (heading?.tagName?.toLowerCase() === "h1" && (imgSrcs.length > 0 || ctas.length > 0)) {
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
- if (ctas.length > 0 && paragraphs.length <= 1) {
109
- return { role: "cta", heading: elText(heading), subheading, ctas };
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
- // ── icon slot ──
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 !== iconEl;
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 === iconEl)
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 !== iconText && t.length > 5)
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 === iconEl || k === titleEl)
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,39 @@ 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: icon rendering (svg-mask needs background; font-class route is clean) ==");
228
+ {
229
+ const cloneG = () => JSON.parse(JSON.stringify(good));
230
+ const maskChild = (bg) => ({
231
+ id: "icon1", type: "rectangle", properties: { name: "icon", movable: true, sync: true },
232
+ specials: {}, runtime: {}, events: [],
233
+ responsive: {
234
+ 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 } : {}) } },
235
+ 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 } : {}) } },
236
+ },
237
+ });
238
+ // svg-mask WITHOUT a background fill → invisible-icon warning (the "svg in a rectangle doesn't show" bug).
239
+ const noBg = cloneG();
240
+ noBg.page[0].children = [maskChild()];
241
+ 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);
242
+ // svg-mask WITH a solid background → no invisible warning.
243
+ const withBg = cloneG();
244
+ withBg.page[0].children = [maskChild("rgba(0,88,188,1)")];
245
+ 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);
246
+ // font-class route (the Stitch-faithful one): text-block with a Material Symbols span + the font loaded via bhet → valid & clean.
247
+ const fontRoute = cloneG();
248
+ fontRoute.settings.bhet = "<link href='https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined' rel='stylesheet'>";
249
+ fontRoute.page[0].children = [{
250
+ id: "icon2", type: "text-block", properties: { name: "icon", movable: true, sync: true },
251
+ specials: { text: "<span class=\"material-symbols-outlined\">verified</span>" }, runtime: {}, events: [],
252
+ responsive: {
253
+ desktop: { config: {}, styles: { top: 10, left: 10, width: 40, height: 40, fontSize: 32, color: "rgba(0,88,188,1)" } },
254
+ mobile: { config: {}, styles: { top: 10, left: 10, width: 40, height: 40, fontSize: 28, color: "rgba(0,88,188,1)" } },
255
+ },
256
+ }];
257
+ const fr = validatePage(fontRoute);
258
+ 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 });
259
+ }
204
260
  console.log("== expand: hydrates sparse nodes ==");
205
261
  const sparse = {
206
262
  page: [
@@ -303,6 +359,31 @@ check("ingest: hero image captured", (hero?.images?.length ?? 0) > 0, hero?.imag
303
359
  const form = ast.sections.find((s) => s.role === "form");
304
360
  check("ingest: form fields captured", (form?.form_fields?.length ?? 0) >= 2, form?.form_fields);
305
361
  check("ingest: form submit CTA captured", !!form?.ctas?.[0]?.text?.includes("Place"), form?.ctas);
362
+ console.log("== ingest: Stitch-structure section classification (generalizes to any Stitch HTML) ==");
363
+ {
364
+ // Mirrors the real Stitch shape: <nav> top bar (no <header>), <main> with a
365
+ // h1 hero, 2-column card grids, a card-less CTA band, <footer>, and a sticky
366
+ // BOTTOM <nav> action bar.
367
+ const stitchStruct = `<!DOCTYPE html><html><head></head><body>
368
+ <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>
369
+ <main class="pt-20">
370
+ <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>
371
+ <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>
372
+ <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>
373
+ </main>
374
+ <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>
375
+ <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>
376
+ </body></html>`;
377
+ const ss = parseHtml(stitchStruct, "full");
378
+ const roles = ss.sections.map((s) => s.role);
379
+ check("stitch-struct: <nav> top bar (no <header>) → header", roles[0] === "header", roles);
380
+ check("stitch-struct: h1 section → hero", ss.sections.some((s) => s.role === "hero" && /strategic partner/.test(s.heading ?? "")), roles);
381
+ check("stitch-struct: 2-card grid → features (not unknown)", ss.sections.some((s) => s.role === "features" && (s.blocks?.length ?? 0) === 2), roles);
382
+ 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);
383
+ check("stitch-struct: <footer> → footer", roles.includes("footer"), roles);
384
+ check("stitch-struct: sticky BOTTOM <nav> is NOT a second header", roles.filter((r) => r === "header").length === 1, roles);
385
+ check("stitch-struct: no 'unknown' sections left", !roles.includes("unknown"), roles);
386
+ }
306
387
  console.log("== ingest: size_hint (desktop section heights) ==");
307
388
  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
389
  const headerHint = ast.sections.find((s) => s.role === "header")?.size_hint;
@@ -491,6 +572,23 @@ const arbGrad = parseHtml(`<!DOCTYPE html><html><head>${fxCfg}</head><body><main
491
572
  check("fx: arbitrary [#hex] gradient stop resolved", (arbGrad.gradients ?? []).includes("linear-gradient(to right, #ff0000, #0058bc)"), arbGrad.gradients);
492
573
  // no tailwind config → no gradients from this path, no hover noise on a plain page.
493
574
  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); })());
575
+ console.log("== ingest: icon-font extraction (Material Symbols / Font Awesome) ==");
576
+ {
577
+ // Feature cards with Material Symbols icons (a long ligature name + a nested-wrapper icon) and one Font Awesome card.
578
+ const iconHtml = `<!DOCTYPE html><html><head></head><body><main><section><h2>Why choose us</h2>
579
+ <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>
580
+ <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>
581
+ <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>
582
+ </section></main></body></html>`;
583
+ const ia = parseHtml(iconHtml, "full");
584
+ const fsec = ia.sections.find((s) => (s.blocks || []).length >= 3);
585
+ const blocks = fsec?.blocks ?? [];
586
+ check("icon: Material Symbols long ligature captured as ms:<name>", blocks[0]?.icon === "ms:verified", blocks[0]);
587
+ check("icon: nested-wrapper Material Symbol captured", blocks[1]?.icon === "ms:support_agent", blocks[1]);
588
+ check("icon: Font Awesome captured as fa:<name> (style tokens skipped)", blocks[2]?.icon === "fa:chart-line", blocks[2]);
589
+ check("icon: ligature name does NOT leak into the card title", blocks[0]?.title === "Trusted" && blocks[1]?.title === "Support", blocks.map((b) => b.title));
590
+ check("icon: real body kept (icon excluded)", /Verified by thousands/.test(blocks[0]?.body ?? ""), blocks[0]?.body);
591
+ }
494
592
  console.log("== ingest: full mode — blocks detection, gradients, images-as-objects ==");
495
593
  const fullAst = parseHtml(stylesheetHtml, "full");
496
594
  check("ingest: full mode palette present", fullAst.palette?.["primary"] === "#0A7C6E", fullAst.palette);
@@ -1599,6 +1697,12 @@ console.log("== rehost: external-image URL collect + rewrite (pure, offline) =="
1599
1697
  }
1600
1698
  console.log("== upload_images: local-path detector (pure, offline) ==");
1601
1699
  {
1700
+ // get_icon_svg name → Iconify candidate mapping (pure; the fetch itself is networked)
1701
+ 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"));
1702
+ check("icon-svg: fa:<name> → fa6-solid first with fallbacks", iconifyCandidates("fa:chart-line")[0] === "fa6-solid/chart-line", iconifyCandidates("fa:chart-line"));
1703
+ check("icon-svg: bare name assumed Material Symbols", iconifyCandidates("verified")[0] === "material-symbols/verified-outline", iconifyCandidates("verified"));
1704
+ check("icon-svg: real Iconify id passes through", iconifyCandidates("mdi:home")[0] === "mdi/home", iconifyCandidates("mdi:home"));
1705
+ check("icon-svg: empty ref → no candidates", iconifyCandidates("")?.length === 0, iconifyCandidates(""));
1602
1706
  // isLocalPath: recognised forms
1603
1707
  check("localPath: absolute POSIX /…", isLocalPath("/home/user/photo.jpg"));
1604
1708
  check("localPath: home-dir ~/…", isLocalPath("~/Pictures/logo.png"));
@@ -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.", { mobileOnly: z.boolean().optional().describe("true if the page renders mobile-only.") }, { title: "New Page Skeleton", readOnlyHint: true, openWorldHint: false }, async ({ mobileOnly }) => text(domain.createPageSource({ mobileOnly: mobileOnly ?? false })));
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
@@ -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.74",
3
+ "version": "1.0.75",
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",