webcake-landing-mcp 1.0.73 → 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 +3 -3
- package/dist/changelog.json +14 -14
- package/dist/domains/landing/guide.js +4 -2
- package/dist/domains/landing/instructions.js +3 -2
- package/dist/domains/landing/page-schema.json +10 -5
- package/dist/domains/landing/validate.js +29 -2
- package/dist/domains/landing/vocab.js +14 -1
- package/dist/persistence/html-ingest.js +11 -3
- package/dist/persistence/icon-client.js +81 -0
- package/dist/persistence/ingest/semantic.js +152 -14
- package/dist/persistence/ingest/tailwind.js +43 -0
- package/dist/smoke.js +161 -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.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
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"v": "1.0.74",
|
|
11
|
+
"d": "13/06/2026",
|
|
12
|
+
"type": "Added",
|
|
13
|
+
"en": "ingest_html and ingest_url now extract Tailwind gradient utilities (bg-gradient-to-*/from-*/via-*/to-*) from the page's class attributes, resolve…",
|
|
14
|
+
"vi": "ingest_html và ingest_url nay trích xuất các utility gradient Tailwind (bg-gradient-to-*/from-*/via-*/to-*) từ các thuộc tính class của trang,…"
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"v": "1.0.73",
|
|
4
18
|
"d": "13/06/2026",
|
|
@@ -26,19 +40,5 @@
|
|
|
26
40
|
"type": "Changed",
|
|
27
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…",
|
|
28
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…"
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
"v": "1.0.69",
|
|
32
|
-
"d": "12/06/2026",
|
|
33
|
-
"type": "Changed",
|
|
34
|
-
"en": "upload_images dry-run response now returns an action_required field (replacing the previous soft hint) that explicitly blocks the model from…",
|
|
35
|
-
"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…"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"v": "1.0.68",
|
|
39
|
-
"d": "12/06/2026",
|
|
40
|
-
"type": "Added",
|
|
41
|
-
"en": "ingest_html and ingest_url now auto-detect absolute-canvas builder exports (LadiPage-family pages and Webcake-published HTML): bare positioned-div…",
|
|
42
|
-
"vi": "ingest_html và ingest_url nay tự động phát hiện các bản export từ builder absolute-canvas (trang LadiPage-family và HTML đã publish của Webcake):…"
|
|
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,6 +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). 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.
|
|
123
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.
|
|
124
126
|
|
|
125
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).
|
|
@@ -25,7 +25,7 @@ RULES (follow for every request):
|
|
|
25
25
|
A wrong element type is the most common error → { op:'update', id:'<element id>', type:'<allowed type>' } (run list_elements if unsure). Drafts expire in ~2 h.
|
|
26
26
|
- DRY-RUN CACHE: create_page, update_page, and add_section dry_run=true all cache the validated payload and return a draft_id. Re-run the same tool with { draft_id, dry_run:false } — no need to re-send the source.
|
|
27
27
|
- Organizations: call list_organizations before saving. If the account has exactly ONE org, create_page auto-selects it — no need to ask. If there are MULTIPLE orgs, show them and ask the user which to use (is_default is the suggested default); pass the chosen organization_id to create_page. Pass organization_id:"personal" only when the user explicitly wants no org. create_page enforces this itself (it refuses to guess between multiple orgs). Endpoints are owner-scoped (only the account's own pages).
|
|
28
|
-
- REFERENCE INPUT — if the user provides a layout reference, USE it as the layout anchor (don't ignore it, don't re-invent from scratch). Three input modes: (1) IMAGE/screenshot attached in chat → analyze it natively (no tool call): identify section flow (hero/features/form/cta/footer), heading hierarchy, dominant colors, font feel, then map sections to Webcake elements using role→element hints (hero → section with background image/overlay + heading + paragraph + button; features → group blocks with icon/title/text; stats bar → group with heading+text per stat; pricing → group with text list + button; footer → section with text + links). When the reference contains a composite widget (phone/device mockup, chat thread, mini dashboard, browser frame) → rebuild it as ONE html-box (clone its HTML with all styles inlined), not as element soup. (2) HTML string → call ingest_html(html, detail:'full') to get the richer AST when cloning; use detail:'compact' (default) for a layout-only reference. (3) URL → call ingest_url(url, detail:'full') for the same richer AST. (4) GOOGLE STITCH design (when a Stitch MCP is connected and the user points at a Stitch screen/project) → bridge the two MCPs: call the Stitch tool get_screen, take the returned htmlCode.downloadUrl, and pass THAT url to ingest_url(detail:'full') (or fetch the HTML and use ingest_html) — Stitch output is a Tailwind-CDN page, so the AST comes back with a token-named palette + design_tokens (the real spacing/radii/type-scale); LOCK the design system from those tokens, reuse the page's image URLs (they auto-host on save — Stitch's googleusercontent images included), then create_page. ABSOLUTE-CANVAS builder exports (LadiPage-family / Webcake-published HTML) are AUTO-DETECTED by ingest_html/ingest_url and converted DETERMINISTICALLY into a ready-to-save source (folded into the response as source + clone_notes + clone_notice, the per-element geometry summarized to canvas_summary) — a faithful 1:1 clone on the matching 420/960 canvas: pass that source STRAIGHT to create_page (dry_run:true first to validate, then dry_run:false) instead of hand-rebuilding; its images auto-host on save (no upload_images), and clone_notes lists the few lossy approximations (fixed/floating elements, svg-less shapes, skipped social-proof toasts) to patch_page afterward. The AST classifies sections by role and lists headings/subheadings/ctas/images/form_fields plus brand hints (colors/fonts), a token-named palette + design_tokens (for Tailwind-CDN pages like Google Stitch, lifted from the page's tailwind.config: palette names every color token e.g. primary→#a43b38, and design_tokens carries the resolved spacing grid + corner radii + TYPE SCALE e.g. display-lg→48px — rebuild sizing/spacing/color FROM these tokens, not by guessing), background_images from stylesheets, and in full mode: per-section blocks (cards/tiles/steps with title/body/image/cta)
|
|
28
|
+
- REFERENCE INPUT — if the user provides a layout reference, USE it as the layout anchor (don't ignore it, don't re-invent from scratch). Three input modes: (1) IMAGE/screenshot attached in chat → analyze it natively (no tool call): identify section flow (hero/features/form/cta/footer), heading hierarchy, dominant colors, font feel, then map sections to Webcake elements using role→element hints (hero → section with background image/overlay + heading + paragraph + button; features → group blocks with icon/title/text; stats bar → group with heading+text per stat; pricing → group with text list + button; footer → section with text + links). When the reference contains a composite widget (phone/device mockup, chat thread, mini dashboard, browser frame) → rebuild it as ONE html-box (clone its HTML with all styles inlined), not as element soup. (2) HTML string → call ingest_html(html, detail:'full') to get the richer AST when cloning; use detail:'compact' (default) for a layout-only reference. (3) URL → call ingest_url(url, detail:'full') for the same richer AST. (4) GOOGLE STITCH design (when a Stitch MCP is connected and the user points at a Stitch screen/project) → bridge the two MCPs: call the Stitch tool get_screen, take the returned htmlCode.downloadUrl, and pass THAT url to ingest_url(detail:'full') (or fetch the HTML and use ingest_html) — Stitch output is a Tailwind-CDN page, so the AST comes back with a token-named palette + design_tokens (the real spacing/radii/type-scale) + gradients (Tailwind gradient utilities reconstructed as linear-gradient(...) with resolved color stops — apply them as the element's background, e.g. on CTA/hero) + per-section hover_effects (the interactions Stitch packs in: scale/image-zoom/lift/underline/color-change — REPRODUCE them via Webcake hover events: change_color (change_color_type text/background/border) for color/underline, the button's hovered* specials or animation_hover for scale/lift/zoom — don't ship a static page); LOCK the design system from those tokens, reuse the page's image URLs (they auto-host on save — Stitch's googleusercontent images included), then create_page. ABSOLUTE-CANVAS builder exports (LadiPage-family / Webcake-published HTML) are AUTO-DETECTED by ingest_html/ingest_url and converted DETERMINISTICALLY into a ready-to-save source (folded into the response as source + clone_notes + clone_notice, the per-element geometry summarized to canvas_summary) — a faithful 1:1 clone on the matching 420/960 canvas: pass that source STRAIGHT to create_page (dry_run:true first to validate, then dry_run:false) instead of hand-rebuilding; its images auto-host on save (no upload_images), and clone_notes lists the few lossy approximations (fixed/floating elements, svg-less shapes, skipped social-proof toasts) to patch_page afterward. The AST classifies sections by role and lists headings/subheadings/ctas/images/form_fields plus brand hints (colors/fonts), a token-named palette + design_tokens (for Tailwind-CDN pages like Google Stitch, lifted from the page's tailwind.config: palette names every color token e.g. primary→#a43b38, and design_tokens carries the resolved spacing grid + corner radii + TYPE SCALE e.g. display-lg→48px — rebuild sizing/spacing/color FROM these tokens, not by guessing), background_images from stylesheets, gradients (incl. reconstructed Tailwind gradient backgrounds), per-section hover_effects (reproduce them — via hover events, or the custom-CSS escape hatch below), and in full mode: per-section blocks (cards/tiles/steps with title/body/image/cta) and li lists — use it for LAYOUT + HIERARCHY, then generate FRESH content tailored to the user's brand (don't 1:1 copy text). IMAGES FROM THE REFERENCE ARE THE USER'S ASSETS — for BOTH intents (adapt AND clone), put every real image URL found in the AST (images, background_images, og_image, canvas src/background) straight into the corresponding slot's specials.src / url(...) background / gallery link; the save AUTO-HOSTS it to the Webcake CDN, so you do NOT need to call upload_images first — just carry the URL through un-altered (never drop it, never swap it for a search_images stock photo or a placeholder). Call search_images ONLY for slots that have no source image in the reference. intent='clone' only when the user explicitly asks to mirror the original; default intent='adapt' (adapt rewrites TEXT, not the imagery). The reference workflow PRESERVES craft rules above (centering, page margin, premium spacing, real images) — apply them on top of the reference layout, don't bypass them.
|
|
29
29
|
|
|
30
30
|
MODEL (essentials):
|
|
31
31
|
- Top-level: { page:[sections], popup:[popups], dynamic_pages:[], settings:{}, options:{mobileOnly,versionID}, cartConfigs:{isActive:false}, svariations:[] }. Popups are a SEPARATE top-level array, NOT inside page; currency lives in settings.currency (not options). Leave dynamic_pages/svariations as [] for a static page, but keep them on edit round-trips.
|
|
@@ -39,6 +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 warns if custom_css lacks customAdvance or holds a selector.
|
|
42
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.
|
|
43
44
|
|
|
44
|
-
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"] },
|
|
@@ -58,8 +61,10 @@
|
|
|
58
61
|
"fb_tracking_code": { "type": "string", "description": "Facebook pixel id." },
|
|
59
62
|
"tiktok_script": { "type": "string" },
|
|
60
63
|
"global_track_ids": { "type": "array" },
|
|
61
|
-
"extra_css": { "type": "string", "description": "
|
|
62
|
-
"extra_script": { "type": "string", "description": "
|
|
64
|
+
"extra_css": { "type": "string", "description": "Page-level custom CSS — a FULL stylesheet injected raw into <head>. The escape hatch for effects beyond element specials: SELECTORS, :hover/:focus, @keyframes, media queries. Target any element by its DOM id #w-<element id> (or a specials.custom_class). E.g. hover scale: '#w-abc{transition:transform .3s}#w-abc:hover{transform:scale(1.05)}'. Keep selectors specific (it is raw/unscoped) to avoid bleeding." },
|
|
65
|
+
"extra_script": { "type": "string", "description": "Page-level custom JavaScript injected raw before </body> — behavior beyond the event vocab." },
|
|
66
|
+
"bhet": { "type": "string", "description": "'Before </head>' — a raw HTML block injected at the END of <head>. Unlike extra_css (CSS-only)/extra_script (JS-only) this takes ARBITRARY HTML: <link> webfonts/stylesheets, <style>, <meta>, verification tags, analytics/pixels (GTM, FB). Rendered raw — valid HTML only." },
|
|
67
|
+
"bbet": { "type": "string", "description": "'Before </body>' — a raw HTML block injected at the END of <body>: chat widgets, deferred <script>, GTM <noscript>, third-party embeds. Rendered raw — valid HTML only." },
|
|
63
68
|
"auto_save_draft": { "type": "boolean" },
|
|
64
69
|
"auto_save_info_user": { "type": "boolean" },
|
|
65
70
|
"send_info_to_thank_page": { "type": "boolean" }
|
|
@@ -230,7 +235,7 @@
|
|
|
230
235
|
},
|
|
231
236
|
"specials": {
|
|
232
237
|
"type": "object",
|
|
233
|
-
"description": "Type-specific content/config. The user-visible CONTENT lives here, NOT in styles. Open object; key fields by type: section{globalSection,globalSectionName,custom_class,imageCompression}; text-block{text(HTML),tag}; list-paragraph{text(<li>..)}; image-block{src,resize}; button{text,required,format,connectedSurvey}; video{typeVideo,video_cdn,img,autoReplay}; gallery{media[]}; form{field_type,form_type,sheetOrder,validate,submit_success,fb_event_type,fb_conversion_value,fb_tracking_currency,tiktok_conversion_value,tiktok_tracking_currency}; input/textarea/select/checkbox/radio/address/...{field_name,field_placeholder,field_type,required,options}; countdown{type,duration,startTime,endTime,showDay,showSecond,showText,language(vietnam|english|filipino|khmer|lao|indonesian|thai|malay|custom — NOT a locale code; 'custom' uses customTranslation),customTranslation{day,hour,minute,second}}; survey{options[{id,image,title,value,field_name}],type,multiOption,selectedBackground,selectedBorder}; list-product{format_title,numerical_order,remain_quantity_text}.",
|
|
238
|
+
"description": "Type-specific content/config. The user-visible CONTENT lives here, NOT in styles. Open object; key fields by type: section{globalSection,globalSectionName,custom_class,imageCompression}; text-block{text(HTML),tag}; list-paragraph{text(<li>..)}; image-block{src,resize}; button{text,required,format,connectedSurvey}; video{typeVideo,video_cdn,img,autoReplay}; gallery{media[]}; form{field_type,form_type,sheetOrder,validate,submit_success,fb_event_type,fb_conversion_value,fb_tracking_currency,tiktok_conversion_value,tiktok_tracking_currency}; input/textarea/select/checkbox/radio/address/...{field_name,field_placeholder,field_type,required,options}; countdown{type,duration,startTime,endTime,showDay,showSecond,showText,language(vietnam|english|filipino|khmer|lao|indonesian|thai|malay|custom — NOT a locale code; 'custom' uses customTranslation),customTranslation{day,hour,minute,second}}; survey{options[{id,image,title,value,field_name}],type,multiOption,selectedBackground,selectedBorder}; list-product{format_title,numerical_order,remain_quantity_text}. UNIVERSAL escape-hatch keys (any element): customAdvance(boolean — MUST be true for the next two to apply, else the renderer drops them), custom_css(string — extra CSS DECLARATIONS injected inside this element's #w-<id>{…} rule; declarations only, no selector/:hover/@keyframes — those go in settings.extra_css), custom_class(string — comma-separated extra class names added to #w-<id>, to target from settings.extra_css). Section-only: isCustomTracking(boolean)+customTracking(string HTML/JS) for per-section tracking snippets.",
|
|
234
239
|
"additionalProperties": true,
|
|
235
240
|
"properties": {
|
|
236
241
|
"text": { "type": "string", "description": "text-block/button label/list-paragraph (may contain HTML)." },
|
|
@@ -298,6 +298,25 @@ export function validatePage(input) {
|
|
|
298
298
|
});
|
|
299
299
|
}
|
|
300
300
|
}
|
|
301
|
+
// custom CSS/class escape hatches (effects beyond an element's built-in specials).
|
|
302
|
+
// Renderer gates BOTH on specials.customAdvance===true (render/build/index.js
|
|
303
|
+
// custom_class + exportCss.js custom_css); without it they are silently dropped.
|
|
304
|
+
// And custom_css is injected as plain DECLARATIONS inside #w-<id>{…}, so a
|
|
305
|
+
// selector / :hover / @keyframes there corrupts the rule — those go in
|
|
306
|
+
// settings.extra_css (full stylesheet, injected raw into <head>).
|
|
307
|
+
{
|
|
308
|
+
const sp = node.specials;
|
|
309
|
+
if (sp && typeof sp === "object") {
|
|
310
|
+
const hasCustom = (typeof sp.custom_css === "string" && sp.custom_css.trim() !== "") ||
|
|
311
|
+
(typeof sp.custom_class === "string" && sp.custom_class.trim() !== "");
|
|
312
|
+
if (hasCustom && sp.customAdvance !== true) {
|
|
313
|
+
warnings.push(`${path} (${type}): specials.custom_css/custom_class is set but specials.customAdvance!==true — the renderer ignores both. Set "customAdvance": true.`);
|
|
314
|
+
}
|
|
315
|
+
if (typeof sp.custom_css === "string" && /[{}]|@keyframes|:hover|:focus|::/.test(sp.custom_css)) {
|
|
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
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
301
320
|
// animation contract — checked per breakpoint
|
|
302
321
|
// Source: landing_page_build/render/build/animate.js (animatable type list)
|
|
303
322
|
// landing_page_backend/assets/editor/main/traits/TraitAnimation.vue (name set)
|
|
@@ -400,8 +419,12 @@ export function validatePage(input) {
|
|
|
400
419
|
// variation selector-16 (FE0F), keycap (20E3), whitespace
|
|
401
420
|
/^(?:\p{Extended_Pictographic}|\p{Emoji_Component}|[\u200D\uFE0F\u20E3\s])+$/u.test(visible);
|
|
402
421
|
if (onlyEmoji) {
|
|
403
|
-
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
|
|
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.`);
|
|
404
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);
|
|
405
428
|
// Wrapped-text overflow: live text height is AUTO — text that wraps to
|
|
406
429
|
// more lines than the declared box spills DOWN and overlaps the element
|
|
407
430
|
// below (the classic "2-line card title over the card body" defect).
|
|
@@ -410,6 +433,8 @@ export function validatePage(input) {
|
|
|
410
433
|
// 1-line-tall box — slip through, while body text (16px → 22px line)
|
|
411
434
|
// keeps its old tolerance against the rough estimate.
|
|
412
435
|
for (const bp of ["desktop", "mobile"]) {
|
|
436
|
+
if (isIconGlyph)
|
|
437
|
+
break;
|
|
413
438
|
const styles = node.responsive?.[bp]?.styles;
|
|
414
439
|
const w = num(styles?.width);
|
|
415
440
|
const h = num(styles?.height);
|
|
@@ -835,7 +860,9 @@ export function validatePage(input) {
|
|
|
835
860
|
return;
|
|
836
861
|
const cpath = `${path}.children[${idx}]`;
|
|
837
862
|
const rawText = child.type === "text-block" ? child.specials?.text : undefined;
|
|
838
|
-
|
|
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) {
|
|
839
866
|
for (const bp of ["desktop", "mobile"]) {
|
|
840
867
|
if (overlapWarnings >= MAX_OVERLAP_WARNINGS)
|
|
841
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
|
-
|
|
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
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { parse } from "node-html-parser";
|
|
17
17
|
import { extractStyleBlocks, extractGoogleFonts, extractGradients, fixMojibake } from "./ingest/stylesheets.js";
|
|
18
18
|
import { extractTailwindConfig } from "./ingest/tailwind.js";
|
|
19
|
-
import { findSections, classifySection, computeSizeHint, detectWidgets, brandHints } from "./ingest/semantic.js";
|
|
19
|
+
import { findSections, classifySection, computeSizeHint, detectWidgets, detectHoverEffects, brandHints } from "./ingest/semantic.js";
|
|
20
20
|
import { parseAbsoluteCanvas, canvasRoleSections, stripCdnSizePrefix } from "./ingest/canvas.js";
|
|
21
21
|
// Re-export the public surface so existing imports of "./persistence/html-ingest.js"
|
|
22
22
|
// (parseHtml, fetchHtml, and the IngestedAst/IngestedCanvas/CanvasElement/CanvasSection
|
|
@@ -95,6 +95,9 @@ export function parseHtml(html, detail = "compact", opts = {}) {
|
|
|
95
95
|
const sections = sectionEls.map((el) => {
|
|
96
96
|
const sec = classifySection(el, detail);
|
|
97
97
|
sec.size_hint = computeSizeHint(el, sec, styleBlocks);
|
|
98
|
+
const hover = detectHoverEffects(el);
|
|
99
|
+
if (hover.length)
|
|
100
|
+
sec.hover_effects = hover;
|
|
98
101
|
if (detail === "full") {
|
|
99
102
|
const widgets = detectWidgets(el, styleBlocks);
|
|
100
103
|
if (widgets.length)
|
|
@@ -114,13 +117,18 @@ export function parseHtml(html, detail = "compact", opts = {}) {
|
|
|
114
117
|
fonts: hints.fonts.length ? hints.fonts : undefined,
|
|
115
118
|
palette: hints.palette,
|
|
116
119
|
design_tokens: hints.design_tokens,
|
|
120
|
+
// Tailwind gradient utilities (Stitch CTA/hero backgrounds) reconstructed as
|
|
121
|
+
// linear-gradient strings — surfaced in BOTH modes since they're design-critical
|
|
122
|
+
// and the Play CDN never emits the resolved CSS.
|
|
123
|
+
gradients: hints.tailwind_gradients.length ? hints.tailwind_gradients : undefined,
|
|
117
124
|
background_images: hints.background_images.length ? hints.background_images : undefined,
|
|
118
125
|
warnings: warnings.length ? warnings : undefined,
|
|
119
126
|
};
|
|
120
127
|
if (detail !== "full")
|
|
121
128
|
return base;
|
|
122
|
-
// Full mode extras.
|
|
123
|
-
const
|
|
129
|
+
// Full mode extras: merge stylesheet gradients with the Tailwind ones (deduped).
|
|
130
|
+
const styleGradients = extractGradients(styleBlocks);
|
|
131
|
+
const gradients = [...new Set([...hints.tailwind_gradients, ...styleGradients])];
|
|
124
132
|
const result = {
|
|
125
133
|
...base,
|
|
126
134
|
gradients: gradients.length ? gradients : undefined,
|
|
@@ -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
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { parse } from "node-html-parser";
|
|
5
5
|
import { extractStylesheetColors, extractStylesheetFonts, extractBackgroundImages, extractCssVarPalette, topColors, topFonts, mergeTopN, mergeTopNFonts, } from "./stylesheets.js";
|
|
6
|
-
import { resolveTailwindColors } from "./tailwind.js";
|
|
6
|
+
import { resolveTailwindColors, resolveTailwindGradients } from "./tailwind.js";
|
|
7
7
|
export const HEADING_TAGS = ["h1", "h2", "h3", "h4"];
|
|
8
8
|
// ─── section helpers ─────────────────────────────────────────────────────────
|
|
9
9
|
const BLOCK_TAGS = new Set(["div", "main", "section", "article", "header", "footer", "aside", "nav"]);
|
|
@@ -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",
|
|
@@ -239,7 +295,11 @@ function pickCtas(el) {
|
|
|
239
295
|
});
|
|
240
296
|
el.querySelectorAll("a").forEach((a) => {
|
|
241
297
|
const cls = (a.getAttribute("class") ?? "").toLowerCase();
|
|
242
|
-
|
|
298
|
+
// An <a> is a CTA when it's named like one, OR styled like a button —
|
|
299
|
+
// Tailwind/Stitch pill buttons carry NO btn/button/cta class, just utilities
|
|
300
|
+
// (`px-8 py-4 rounded-xl bg-… …`): rounded + a fill/border + padding.
|
|
301
|
+
const styledButton = /\brounded(-|\b)/.test(cls) && /(\bbg-|bg-gradient|\bborder\b|\bborder-)/.test(cls) && /\b[pmg][xytrbl]?-/.test(cls);
|
|
302
|
+
if (/(btn|button|cta)/.test(cls) || styledButton) {
|
|
243
303
|
const t = a.text.trim();
|
|
244
304
|
const href = a.getAttribute("href") ?? undefined;
|
|
245
305
|
if (t)
|
|
@@ -326,31 +386,69 @@ function findRepeatingContainer(root, maxDepth = 4) {
|
|
|
326
386
|
* text is ≤4 chars (emoji/badge) — surfaced separately so the model can map
|
|
327
387
|
* it to a Webcake svg-mask rectangle.
|
|
328
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
|
+
}
|
|
329
416
|
function detectBlocks(el) {
|
|
330
417
|
const found = findRepeatingContainer(el);
|
|
331
418
|
if (!found || found.items.length < 2)
|
|
332
419
|
return [];
|
|
333
420
|
return found.items.slice(0, 12).map((c) => {
|
|
334
421
|
const kids = elementChildren(c);
|
|
335
|
-
//
|
|
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);
|
|
336
431
|
const iconEl = kids.find((k) => {
|
|
337
432
|
const cls = (k.getAttribute("class") ?? "").toLowerCase();
|
|
338
|
-
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))
|
|
339
436
|
return true;
|
|
340
437
|
const t = k.textContent.trim();
|
|
341
438
|
return t.length > 0 && t.length <= 4; // likely a single emoji
|
|
342
439
|
});
|
|
343
|
-
const icon = iconEl?.textContent?.trim() || undefined;
|
|
440
|
+
const icon = iconFont ?? (iconEl?.textContent?.trim() || undefined);
|
|
441
|
+
const isIcon = (k) => k === iconEl || containsIcon(k);
|
|
344
442
|
// ── title ──
|
|
345
443
|
const headingEl = c.querySelector(HEADING_TAGS.join(","));
|
|
346
444
|
const titleClassEl = kids.find((k) => {
|
|
347
445
|
const cls = (k.getAttribute("class") ?? "").toLowerCase();
|
|
348
|
-
return /title|name|heading|label/.test(cls) && k
|
|
446
|
+
return /title|name|heading|label/.test(cls) && !isIcon(k);
|
|
349
447
|
});
|
|
350
448
|
const strongEl = c.querySelector("strong") || c.querySelector("b");
|
|
351
449
|
// fallback: first non-icon short-text child
|
|
352
450
|
const fallbackTitleEl = kids.find((k) => {
|
|
353
|
-
if (k
|
|
451
|
+
if (isIcon(k))
|
|
354
452
|
return false;
|
|
355
453
|
const cls = (k.getAttribute("class") ?? "").toLowerCase();
|
|
356
454
|
if (/icon|emoji|badge/.test(cls))
|
|
@@ -363,15 +461,14 @@ function detectBlocks(el) {
|
|
|
363
461
|
// ── body ──
|
|
364
462
|
// Prefer <p> tags; fall back to div children whose class contains body|desc|text|content
|
|
365
463
|
const titleText = title ?? "";
|
|
366
|
-
const iconText = icon ?? "";
|
|
367
464
|
const bodyFromP = c.querySelectorAll("p")
|
|
368
465
|
.map((p) => p.text.trim())
|
|
369
|
-
.filter((t) => t && t !== titleText && t
|
|
466
|
+
.filter((t) => t && t !== titleText && t.length > 5)
|
|
370
467
|
.join(" ")
|
|
371
468
|
.slice(0, 250);
|
|
372
469
|
const bodyFromDiv = !bodyFromP ? kids
|
|
373
470
|
.filter((k) => {
|
|
374
|
-
if (k
|
|
471
|
+
if (isIcon(k) || k === titleEl)
|
|
375
472
|
return false;
|
|
376
473
|
const cls = (k.getAttribute("class") ?? "").toLowerCase();
|
|
377
474
|
return /body|desc|text|content|copy/.test(cls);
|
|
@@ -633,11 +730,52 @@ export function brandHints(body, styleBlocks, googleFonts, tw) {
|
|
|
633
730
|
...(Object.keys(tw.fontFamily).length ? { font_family: tw.fontFamily } : {}),
|
|
634
731
|
}
|
|
635
732
|
: undefined;
|
|
733
|
+
const tailwind_gradients = tw ? resolveTailwindGradients(body, tw.colors) : [];
|
|
636
734
|
return {
|
|
637
735
|
colors,
|
|
638
736
|
fonts,
|
|
639
737
|
background_images,
|
|
640
738
|
palette: Object.keys(palette).length ? palette : undefined,
|
|
641
739
|
design_tokens: design_tokens && Object.keys(design_tokens).length ? design_tokens : undefined,
|
|
740
|
+
tailwind_gradients,
|
|
642
741
|
};
|
|
643
742
|
}
|
|
743
|
+
/**
|
|
744
|
+
* Interaction effects present in a section's hover/transition utility classes
|
|
745
|
+
* (`hover:`/`group-hover:`/`active:`), normalized to the kinds Webcake can
|
|
746
|
+
* reproduce. The static AST otherwise drops every hover, so the cloned page has
|
|
747
|
+
* none — this lets the rebuild wire the matching Webcake hover behavior.
|
|
748
|
+
*/
|
|
749
|
+
export function detectHoverEffects(el) {
|
|
750
|
+
const set = new Set();
|
|
751
|
+
for (const node of el.querySelectorAll("[class]")) {
|
|
752
|
+
const cls = node.getAttribute("class");
|
|
753
|
+
if (!cls)
|
|
754
|
+
continue;
|
|
755
|
+
for (const t of cls.split(/\s+/)) {
|
|
756
|
+
const group = t.startsWith("group-hover:");
|
|
757
|
+
const h = t.startsWith("hover:") ? t.slice(6) : group ? t.slice(12) : t.startsWith("active:") ? t.slice(7) : null;
|
|
758
|
+
if (h === null)
|
|
759
|
+
continue;
|
|
760
|
+
if (/^scale-/.test(h))
|
|
761
|
+
set.add(group ? "image-zoom" : "scale");
|
|
762
|
+
else if (/^-?translate-y/.test(h))
|
|
763
|
+
set.add("lift");
|
|
764
|
+
else if (/^-?translate-x/.test(h))
|
|
765
|
+
set.add("slide");
|
|
766
|
+
else if (/^bg-/.test(h))
|
|
767
|
+
set.add("bg-color-change");
|
|
768
|
+
else if (/^text-/.test(h))
|
|
769
|
+
set.add("text-color-change");
|
|
770
|
+
else if (/^border/.test(h))
|
|
771
|
+
set.add("border-color-change");
|
|
772
|
+
else if (/^opacity-/.test(h))
|
|
773
|
+
set.add("fade");
|
|
774
|
+
else if (h === "underline")
|
|
775
|
+
set.add("underline");
|
|
776
|
+
else if (/^shadow/.test(h))
|
|
777
|
+
set.add("shadow");
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
return [...set];
|
|
781
|
+
}
|
|
@@ -189,6 +189,49 @@ const TW_COLOR_PREFIXES = [
|
|
|
189
189
|
const TW_PREFIX_RE = new RegExp(`^(?:${TW_COLOR_PREFIXES.join("|")})-(.+)$`);
|
|
190
190
|
// Tailwind's always-available keyword colors (present even without a config entry).
|
|
191
191
|
const TW_KNOWN = { white: "#ffffff", black: "#000000" };
|
|
192
|
+
/** Tailwind gradient direction utility → CSS gradient direction keyword. */
|
|
193
|
+
const TW_GRADIENT_DIR = {
|
|
194
|
+
t: "to top", tr: "to top right", r: "to right", br: "to bottom right",
|
|
195
|
+
b: "to bottom", bl: "to bottom left", l: "to left", tl: "to top left",
|
|
196
|
+
};
|
|
197
|
+
/** Resolve a color token to a value: config token, keyword color, or an arbitrary `[#hex]`/`[rgb(...)]` value. */
|
|
198
|
+
function resolveColorToken(tok, colors) {
|
|
199
|
+
const t = tok.split("/")[0]; // drop /80 opacity modifier
|
|
200
|
+
const arb = /^\[(.+)\]$/.exec(t);
|
|
201
|
+
if (arb)
|
|
202
|
+
return arb[1].replace(/_/g, " "); // arbitrary value: bg-[#ff7f78] / from-[rgb(0_0_0)]
|
|
203
|
+
return colors[t] ?? TW_KNOWN[t];
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Reconstruct `linear-gradient(...)` strings from Tailwind gradient utilities
|
|
207
|
+
* (`bg-gradient-to-br from-primary via-x to-y`) — Stitch uses gradient CTAs and
|
|
208
|
+
* hero backgrounds heavily, and Play-CDN never emits the resolved CSS, so the
|
|
209
|
+
* gradient would otherwise be invisible to the rebuild. Stops are resolved
|
|
210
|
+
* through the same color map (config token / keyword / arbitrary [#hex]).
|
|
211
|
+
*/
|
|
212
|
+
export function resolveTailwindGradients(body, colors) {
|
|
213
|
+
const out = new Set();
|
|
214
|
+
body.querySelectorAll("[class]").forEach((el) => {
|
|
215
|
+
const cls = el.getAttribute("class");
|
|
216
|
+
if (!cls)
|
|
217
|
+
return;
|
|
218
|
+
const utils = cls.split(/\s+/).map((c) => (c.includes(":") ? c.slice(c.lastIndexOf(":") + 1) : c));
|
|
219
|
+
const dirCls = utils.find((u) => /^bg-gradient-to-(t|tr|r|br|b|bl|l|tl)$/.test(u));
|
|
220
|
+
if (!dirCls)
|
|
221
|
+
return;
|
|
222
|
+
const dir = TW_GRADIENT_DIR[dirCls.replace("bg-gradient-to-", "")];
|
|
223
|
+
const stops = [
|
|
224
|
+
utils.map((u) => /^from-(.+)/.exec(u)?.[1]).find(Boolean),
|
|
225
|
+
utils.map((u) => /^via-(.+)/.exec(u)?.[1]).find(Boolean),
|
|
226
|
+
utils.map((u) => /^to-(.+)/.exec(u)?.[1]).find(Boolean),
|
|
227
|
+
]
|
|
228
|
+
.map((t) => (t ? resolveColorToken(t, colors) : undefined))
|
|
229
|
+
.filter((v) => !!v);
|
|
230
|
+
if (stops.length >= 2)
|
|
231
|
+
out.add(`linear-gradient(${dir}, ${stops.join(", ")})`);
|
|
232
|
+
});
|
|
233
|
+
return [...out];
|
|
234
|
+
}
|
|
192
235
|
/** Rank the config colors ACTUALLY used by utility classes in the body → resolved value list (usage-weighted). */
|
|
193
236
|
export function resolveTailwindColors(body, colors) {
|
|
194
237
|
const counts = new Map();
|
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,86 @@ 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
|
+
}
|
|
202
|
+
console.log("== validate: custom CSS/class/JS escape hatches (beyond-element capability) ==");
|
|
203
|
+
{
|
|
204
|
+
const clone = () => JSON.parse(JSON.stringify(good));
|
|
205
|
+
// Proper usage: customAdvance + declarations-only custom_css + page settings.extra_css/script → valid, no escape-hatch warnings.
|
|
206
|
+
const okPage = clone();
|
|
207
|
+
okPage.page[0].children[0].specials = { text: "CTA", customAdvance: true, custom_css: "background:linear-gradient(to right,#0058bc,#0070eb);box-shadow:0 20px 40px rgba(0,0,0,.08);", custom_class: "cta-pill,glow" };
|
|
208
|
+
okPage.settings.extra_css = "#w-btn1{transition:transform .3s}#w-btn1:hover{transform:scale(1.05)}";
|
|
209
|
+
okPage.settings.extra_script = "console.log('hi')";
|
|
210
|
+
okPage.settings.bhet = "<link href='https://fonts.googleapis.com/css2?family=Inter&display=swap' rel='stylesheet'>";
|
|
211
|
+
okPage.settings.bbet = "<script src='https://widget.example.com/chat.js'></script>";
|
|
212
|
+
const okR = validatePage(okPage);
|
|
213
|
+
check("escape hatch: valid page with custom_css/class + extra_css/script + bhet/bbet passes", okR.valid, okR.errors);
|
|
214
|
+
check("escape hatch: no false warning when used correctly", !okR.warnings.some((w) => /custom_css|customAdvance/.test(w)), okR.warnings.filter((w) => /custom_css|customAdvance/.test(w)));
|
|
215
|
+
// custom_css set but customAdvance missing → silent no-op warning.
|
|
216
|
+
const noAdvance = clone();
|
|
217
|
+
noAdvance.page[0].children[0].specials = { text: "CTA", custom_css: "box-shadow:0 2px 8px rgba(0,0,0,.1);" };
|
|
218
|
+
const naR = validatePage(noAdvance);
|
|
219
|
+
check("escape hatch: warns custom_css without customAdvance", naR.warnings.some((w) => /customAdvance!==true/.test(w)), naR.warnings);
|
|
220
|
+
// custom_css containing a selector/:hover → declarations-only warning.
|
|
221
|
+
const selInCss = clone();
|
|
222
|
+
selInCss.page[0].children[0].specials = { text: "CTA", customAdvance: true, custom_css: "#w-btn1:hover{transform:scale(1.1)}" };
|
|
223
|
+
const selR = validatePage(selInCss);
|
|
224
|
+
check("escape hatch: warns selector/:hover inside custom_css", selR.warnings.some((w) => /declarations inside/.test(w)), selR.warnings);
|
|
225
|
+
check("escape hatch: declarations-only misuse does NOT block (warning, not error)", selR.valid, selR.errors);
|
|
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
|
+
}
|
|
179
260
|
console.log("== expand: hydrates sparse nodes ==");
|
|
180
261
|
const sparse = {
|
|
181
262
|
page: [
|
|
@@ -278,6 +359,31 @@ check("ingest: hero image captured", (hero?.images?.length ?? 0) > 0, hero?.imag
|
|
|
278
359
|
const form = ast.sections.find((s) => s.role === "form");
|
|
279
360
|
check("ingest: form fields captured", (form?.form_fields?.length ?? 0) >= 2, form?.form_fields);
|
|
280
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
|
+
}
|
|
281
387
|
console.log("== ingest: size_hint (desktop section heights) ==");
|
|
282
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));
|
|
283
389
|
const headerHint = ast.sections.find((s) => s.role === "header")?.size_hint;
|
|
@@ -434,6 +540,55 @@ check("tw: palette names every flattened token", twAst.palette?.["gray-900"] ===
|
|
|
434
540
|
check("tw: usage-ranked colors resolve nested + directional-border classes", (twAst.colors ?? []).includes("#2563eb") && (twAst.colors ?? []).includes("#111827") && (twAst.colors ?? []).includes("#3b82f6"), twAst.colors);
|
|
435
541
|
check("tw: design_tokens carries the resolved type scale", twAst.design_tokens?.font_size?.["lg"] === "18px" && twAst.design_tokens?.radius?.["full"] === "9999px", twAst.design_tokens);
|
|
436
542
|
check("tw: no config → extractTailwindConfig null", extractTailwindConfig("<div class='text-primary'>x</div>") === null);
|
|
543
|
+
console.log("== tailwind: gradient utilities + hover/transition effects (Stitch fidelity) ==");
|
|
544
|
+
const fxCfg = `<script id="tailwind-config">tailwind.config = { theme: { extend: { "colors": {
|
|
545
|
+
"primary": "#0058bc", "primary-container": "#0070eb", "secondary": "#fcd664", "surface": "#ffffff"
|
|
546
|
+
} } } }</script>`;
|
|
547
|
+
const fxPage = `<!DOCTYPE html><html><head>${fxCfg}</head><body>
|
|
548
|
+
<header class="bg-surface"><a class="text-primary hover:text-secondary transition-colors">Navigation menu link here</a></header>
|
|
549
|
+
<main>
|
|
550
|
+
<section class="bg-gradient-to-br from-primary to-primary-container"><h1 class="text-white">Join the affiliate program today</h1><p>Earn recurring commission from every referral you bring in.</p><a class="px-8 py-4 rounded-xl bg-gradient-to-br from-primary to-primary-container hover:scale-105 transition-transform" href="#">Get started now</a></section>
|
|
551
|
+
<section><h2 class="text-primary">Why partners choose us</h2>
|
|
552
|
+
<div class="group hover:-translate-y-1 transition-all"><img class="group-hover:scale-110" src="https://x/a.jpg"/><h3 class="group-hover:underline">Fast payouts</h3><p>Money in your account within days, not months.</p></div>
|
|
553
|
+
<div class="group hover:-translate-y-1 transition-all"><img class="group-hover:scale-110" src="https://x/b.jpg"/><h3>Real-time tracking</h3><p>Watch your referrals convert in a live dashboard.</p></div>
|
|
554
|
+
<div class="group hover:-translate-y-1 transition-all"><img class="group-hover:scale-110" src="https://x/c.jpg"/><h3>Dedicated support</h3><p>A partner manager helps you grow your revenue.</p></div>
|
|
555
|
+
</section>
|
|
556
|
+
</main>
|
|
557
|
+
<footer class="bg-primary"><a class="hover:opacity-80" href="#">Footer contact and company info link</a></footer>
|
|
558
|
+
</body></html>`;
|
|
559
|
+
const fxAst = parseHtml(fxPage, "compact");
|
|
560
|
+
check("fx: gradient utility reconstructed with resolved color stops", (fxAst.gradients ?? []).includes("linear-gradient(to bottom right, #0058bc, #0070eb)"), fxAst.gradients);
|
|
561
|
+
check("fx: gradients surfaced in COMPACT mode (design-critical)", (fxAst.gradients?.length ?? 0) >= 1, fxAst.gradients);
|
|
562
|
+
const heroFx = fxAst.sections.find((s) => s.role === "hero");
|
|
563
|
+
check("fx: hero hover scale captured", (heroFx?.hover_effects ?? []).includes("scale"), heroFx?.hover_effects);
|
|
564
|
+
const featFx = fxAst.sections.find((s) => s.role === "features");
|
|
565
|
+
check("fx: card lift captured", (featFx?.hover_effects ?? []).includes("lift"), featFx?.hover_effects);
|
|
566
|
+
check("fx: image-zoom (group-hover scale) captured", (featFx?.hover_effects ?? []).includes("image-zoom"), featFx?.hover_effects);
|
|
567
|
+
check("fx: underline (group-hover) captured", (featFx?.hover_effects ?? []).includes("underline"), featFx?.hover_effects);
|
|
568
|
+
const headFx = fxAst.sections.find((s) => s.role === "header");
|
|
569
|
+
check("fx: header hover text-color-change captured", (headFx?.hover_effects ?? []).includes("text-color-change"), headFx?.hover_effects);
|
|
570
|
+
// arbitrary-value gradient stop resolves too.
|
|
571
|
+
const arbGrad = parseHtml(`<!DOCTYPE html><html><head>${fxCfg}</head><body><main><section class="bg-gradient-to-r from-[#ff0000] to-primary"><h1>Heading text for the arbitrary gradient test case here</h1><p>Some descriptive paragraph text to clear the CSR shell threshold.</p></section></main></body></html>`, "compact");
|
|
572
|
+
check("fx: arbitrary [#hex] gradient stop resolved", (arbGrad.gradients ?? []).includes("linear-gradient(to right, #ff0000, #0058bc)"), arbGrad.gradients);
|
|
573
|
+
// no tailwind config → no gradients from this path, no hover noise on a plain page.
|
|
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
|
+
}
|
|
437
592
|
console.log("== ingest: full mode — blocks detection, gradients, images-as-objects ==");
|
|
438
593
|
const fullAst = parseHtml(stylesheetHtml, "full");
|
|
439
594
|
check("ingest: full mode palette present", fullAst.palette?.["primary"] === "#0A7C6E", fullAst.palette);
|
|
@@ -1542,6 +1697,12 @@ console.log("== rehost: external-image URL collect + rewrite (pure, offline) =="
|
|
|
1542
1697
|
}
|
|
1543
1698
|
console.log("== upload_images: local-path detector (pure, offline) ==");
|
|
1544
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(""));
|
|
1545
1706
|
// isLocalPath: recognised forms
|
|
1546
1707
|
check("localPath: absolute POSIX /…", isLocalPath("/home/user/photo.jpg"));
|
|
1547
1708
|
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.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",
|