webcake-landing-mcp 1.5.2 → 1.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/changelog.json +14 -14
- package/dist/domains/landing/guide.js +3 -2
- package/dist/domains/landing/index.js +13 -3
- package/dist/domains/landing/instructions.js +1 -1
- package/dist/domains/landing/page-schema.json +1 -0
- package/dist/domains/landing/validate.js +66 -0
- package/dist/persistence/html-ingest.js +217 -19
- package/dist/persistence/ingest/fonts.js +685 -0
- package/dist/persistence/ingest/semantic.js +1 -1
- package/dist/persistence/rehost-cache.js +9 -1
- package/dist/persistence/rehost.js +51 -8
- package/dist/persistence/webcake-client.js +202 -12
- package/dist/smoke.js +278 -2
- package/dist/tools/ingest.js +17 -5
- package/dist/tools/persistence.js +19 -1
- package/package.json +1 -1
package/dist/changelog.json
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "1.5.4",
|
|
4
|
+
"d": "09/10/2026",
|
|
5
|
+
"type": "Added",
|
|
6
|
+
"en": "ingest_html and ingest_url now return font_groups (the page's self-hosted @font-face fonts, already shaped for settings.fontGroups) and…",
|
|
7
|
+
"vi": "ingest_html và ingest_url nay trả về font_groups (các font @font-face tự host của trang, đã đóng gói theo đúng dạng settings.fontGroups) và…"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"v": "1.5.3",
|
|
11
|
+
"d": "17/09/2026",
|
|
12
|
+
"type": "Added",
|
|
13
|
+
"en": "validate_page now detects inline base64 images (data:image/…;base64,… URIs) anywhere in an element's specials, a styles background, gallery…",
|
|
14
|
+
"vi": "validate_page nay phát hiện ảnh base64 nhúng (URI data:image/…;base64,…) ở bất kỳ đâu trong specials của phần tử, nền styles, gallery item.link,…"
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"v": "1.5.2",
|
|
4
18
|
"d": "25/08/2026",
|
|
@@ -26,19 +40,5 @@
|
|
|
26
40
|
"type": "Changed",
|
|
27
41
|
"en": "publish_page now reuses the page's currently-attached domain when custom_domain is omitted — mirroring the editor's publish modal, a plain…",
|
|
28
42
|
"vi": "publish_page nay tự động dùng lại domain đang gắn với trang khi bỏ trống custom_domain — giống cách modal publish của editor hoạt động, gọi…"
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
"v": "1.3.0",
|
|
32
|
-
"d": "17/07/2026",
|
|
33
|
-
"type": "Added",
|
|
34
|
-
"en": "upload_images and the automatic image re-hosting on create_page, update_page, add_section, and patch_page now file uploaded images into the…",
|
|
35
|
-
"vi": "upload_images và bước tự động re-host ảnh khi lưu trong create_page, update_page, add_section, patch_page nay lưu ảnh đã upload vào bộ sưu tập…"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"v": "1.2.1",
|
|
39
|
-
"d": "21/06/2026",
|
|
40
|
-
"type": "Changed",
|
|
41
|
-
"en": "Server instructions and get_generation_guide now restrict the visual-check step to three explicit triggers — cloning a reference to compare pixel…",
|
|
42
|
-
"vi": "Instruction của server và get_generation_guide nay giới hạn bước visual-check chỉ trong ba trường hợp cụ thể — clone trang tham chiếu để so sánh độ…"
|
|
43
43
|
}
|
|
44
44
|
]
|
|
@@ -114,7 +114,7 @@ SECTION BUILD HINTS (apply to whichever sections the chosen archetype uses)
|
|
|
114
114
|
RULES
|
|
115
115
|
- Visible content goes in "specials" (text-block.specials.text, image-block.specials.src…), NEVER in "styles".
|
|
116
116
|
- Colors as rgba(r,g,b,a) — ALWAYS, and on BOTH breakpoints. The published page renders any CSS color, but the EDITOR does not read them all back: its color traits only parse the rgba() form, so a hex / rgb() / hsl() / named color shows BLACK in the trait panel and that black is SAVED the first moment a user touches the trait; and because the traits read one breakpoint at a time with no desktop→mobile fallback, a color set on only one breakpoint shows black on the other (and renders black there, since that breakpoint's CSS carries no color at all). The server repairs both slips on every save — it converts hex/rgb()/hsl()/named to rgba() and mirrors color/backgroundTxt/borderColor onto the breakpoint that lacks it — but write rgba() on both breakpoints yourself; what it cannot convert (var(--x), color-mix(…)) comes back as a warning. fontSize/borderWidth/top/left/width/height are NUMBERS (px) — always EMIT numbers. (Reading an existing page you may still meet strings: the editor saves fontSize as "59"/"13px", height as "600", opacity as "50%". The server normalizes those back to numbers on both get_page and save, so a hand-edited page is never locked out of the MCP; only a value with a unit the renderer cannot use — e.g. width:"100%" — is reported, as a warning.) borderRadius is a STRING with CSS units ("8px", "50%", "16px 16px 0 0") — a bare number or unit-less string is auto-coerced to px by the server, but write the unit explicitly to avoid surprises.
|
|
117
|
-
- IMAGES: a real landing page has images (hero/product shot, feature icons, about photo). The save AUTO-HOSTS external image URLs: any real http(s) URL you put in specials.src / a url(...) background / gallery item.link / video poster gets downloaded and re-hosted to the Webcake CDN automatically on create_page/update_page/add_section/patch_page (the response's rehost field reports the count) — so reference and web image URLs do NOT need a pre-call to upload_images. SOURCE PRIORITY: (1) images the user supplied or that exist in the reference HTML/URL (ingest AST images/background_images/og_image, canvas src/background) → put the REAL source URL straight into the slot and let the save host it; use those EXACT images, never swap them for stock photos, and carry the URL through un-altered (never drop it, never placeholder a slot that had a real source image). The ONE case you must call upload_images yourself FIRST: LOCAL FILE PATHS from the user's computer (pass the path directly in upload_images 'urls' — the save can't read local files; NEVER relay the user's local file through a third-party host like catbox or imgur), 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', 'modern office team') and put a returned URL into image-block specials.src. MATCH THE VARIANT TO THE SLOT SIZE — search_images returns a per-photo \`sizes\` map (delivered WxH per variant) + a \`size_guide\`; pick the variant whose width ≈ the element's RENDERED width (×1–2 for retina), then stop: full-bleed hero/banner (~900–1200px) → src.large (~940px) or src.large2x (~1880px); feature card/thumb (~260–360px) → src.medium (~350px); avatar/logo (~64–120px) → src.tiny (280px). NEVER stretch a small variant across a big slot — it pixelates ('vỡ ảnh'); NEVER put src.original (full-res, often several MB) or an oversized variant in a small card — it makes the page slow/heavy ('nặng trang'). If a photo's NATIVE width (photo.width) is smaller than the slot, pick a higher-res photo instead of upscaling. (avg_color helps pick a matching section background); (3) if search_images returns ok:false, is unreachable, or has NO photo that fits the slot → find a real image YOURSELF using whatever web search/fetch capability you have (the brand's own site, the product page, a free-to-use source), then re-host it via upload_images and use the returned URL; (4) a PLACEHOLDER is the LAST resort, ONLY after (2) AND (3) both failed — sized to the box: "https://placehold.co/<width>x<height>". Never jump straight from a failed search to a placeholder without trying (3). NEVER leave src empty — it renders blank on the live page. The server automatically derives styles.background from specials.src on every expand (create/update/validate) using the editor's exact format: 'center center/ cover no-repeat scroll content-box url(<src>) border-box' — you do NOT need to set styles.background manually; if you do hand-write it, it must contain url(...). A SECTION background may layer a gradient overlay over an image: 'linear-gradient(...), center center/ cover no-repeat scroll content-box url(<src>) border-box' — the server canonicalises any url() layer into that exact editor shorthand on expand (other url() formats survive the first save but get mangled to 'undefined/ undefined/ …' the moment the page is edited in the Webcake editor). gallery.media = array of OBJECTS {type:'image', link:'<real-or-placeholder-url>', linkVideo:'', typeVideo:'youtube', imageCompression:true} (NOT plain URL strings — the gallery reads item.link); video.specials.img = a poster image (real photo, else placeholder). Do NOT set a flat (no url()) styles.background on a video element — it suppresses the poster image.
|
|
117
|
+
- IMAGES: a real landing page has images (hero/product shot, feature icons, about photo). The save AUTO-HOSTS external image URLs: any real http(s) URL you put in specials.src / a url(...) background / gallery item.link / video poster gets downloaded and re-hosted to the Webcake CDN automatically on create_page/update_page/add_section/patch_page (the response's rehost field reports the count) — so reference and web image URLs do NOT need a pre-call to upload_images. SOURCE PRIORITY: (1) images the user supplied or that exist in the reference HTML/URL (ingest AST images/background_images/og_image, canvas src/background) → put the REAL source URL straight into the slot and let the save host it; use those EXACT images, never swap them for stock photos, and carry the URL through un-altered (never drop it, never placeholder a slot that had a real source image). The ONE case you must call upload_images yourself FIRST: LOCAL FILE PATHS from the user's computer (pass the path directly in upload_images 'urls' — the save can't read local files; NEVER relay the user's local file through a third-party host like catbox or imgur), 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', 'modern office team') and put a returned URL into image-block specials.src. MATCH THE VARIANT TO THE SLOT SIZE — search_images returns a per-photo \`sizes\` map (delivered WxH per variant) + a \`size_guide\`; pick the variant whose width ≈ the element's RENDERED width (×1–2 for retina), then stop: full-bleed hero/banner (~900–1200px) → src.large (~940px) or src.large2x (~1880px); feature card/thumb (~260–360px) → src.medium (~350px); avatar/logo (~64–120px) → src.tiny (280px). NEVER stretch a small variant across a big slot — it pixelates ('vỡ ảnh'); NEVER put src.original (full-res, often several MB) or an oversized variant in a small card — it makes the page slow/heavy ('nặng trang'). If a photo's NATIVE width (photo.width) is smaller than the slot, pick a higher-res photo instead of upscaling. (avg_color helps pick a matching section background); (3) if search_images returns ok:false, is unreachable, or has NO photo that fits the slot → find a real image YOURSELF using whatever web search/fetch capability you have (the brand's own site, the product page, a free-to-use source), then re-host it via upload_images and use the returned URL; (4) a PLACEHOLDER is the LAST resort, ONLY after (2) AND (3) both failed — sized to the box: "https://placehold.co/<width>x<height>". Never jump straight from a failed search to a placeholder without trying (3). NEVER leave src empty — it renders blank on the live page. BASE64: the save also handles inline 'data:image/...;base64,...' images — it decodes the payload and uploads it to the CDN like a remote URL, then rewrites the slot (specials.src, a url(...) background, gallery item.link, video img, settings favicon/og image), so base64 that arrives from a reference or a user paste needs NO manual fix and validate_page only warns about it. But do NOT AUTHOR base64 yourself: the image travels through the MCP connection inside your payload and megabytes there can drop it — put a real http(s) URL in the slot, or call upload_images (it accepts data: URIs) and use the returned statics.pancake.vn URL. Non-base64 data: URIs (data:image/svg+xml;utf8,<svg…>) are tiny and left alone. The server automatically derives styles.background from specials.src on every expand (create/update/validate) using the editor's exact format: 'center center/ cover no-repeat scroll content-box url(<src>) border-box' — you do NOT need to set styles.background manually; if you do hand-write it, it must contain url(...). A SECTION background may layer a gradient overlay over an image: 'linear-gradient(...), center center/ cover no-repeat scroll content-box url(<src>) border-box' — the server canonicalises any url() layer into that exact editor shorthand on expand (other url() formats survive the first save but get mangled to 'undefined/ undefined/ …' the moment the page is edited in the Webcake editor). gallery.media = array of OBJECTS {type:'image', link:'<real-or-placeholder-url>', linkVideo:'', typeVideo:'youtube', imageCompression:true} (NOT plain URL strings — the gallery reads item.link); video.specials.img = a poster image (real photo, else placeholder). Do NOT set a flat (no url()) styles.background on a video element — it suppresses the poster image.
|
|
118
118
|
- CONTRAST (check EVERY text element against the band it sits on, especially SATURATED / mid-tone bands like yellow, orange, teal, pink — there "light vs dark text" is not obvious, so decide by the band's luminance): light bands → near-black text (e.g. rgba(20,30,25,1)); dark bands → near-white text; a saturated/mid-tone band → whichever of near-black or near-white actually reads (for a bright yellow/amber band that means DARK text, not white/grey). NEVER use muted-grey, low-alpha (alpha < ~0.85), or near-white text on a colored band — that is exactly what makes labels look faded/sunken. Muted-grey is ONLY for secondary text on a white/very-light band. Icons and their captions follow the SAME rule as the text beside them.
|
|
119
119
|
- movable:false for section/slide/grid-item/popup; otherwise true. runtime is always {}.
|
|
120
120
|
- Every form input MUST have a unique specials.field_name.
|
|
@@ -148,7 +148,7 @@ WORKFLOW (recommended)
|
|
|
148
148
|
1. Call get_generation_guide (this) once, then new_page_skeleton for the top-level shape.
|
|
149
149
|
2. For each element type you'll use, call get_element to learn its specials & see an example.
|
|
150
150
|
3. Optionally call new_element to get a correct skeleton, then fill specials + coordinates.
|
|
151
|
-
3b. For every image the page needs (hero, product, about, feature, gallery): if the slot has a source image from the reference HTML/URL or a user-supplied URL → put that REAL URL straight into specials.src / gallery item.link and let the save auto-host it (no upload_images needed). For a user's LOCAL FILE PATH, upload_images it first (pass the path directly in upload_images 'urls'; NEVER relay the user's local file through a third-party host like catbox or imgur) and use the returned URL. Otherwise call search_images and put a returned URL into specials.src / gallery item.link. If search_images fails or has no fitting photo, find a real image yourself (web search/fetch) and put that URL in. Use placehold.co ONLY as the last resort when both search_images AND your own search failed. Never drop or placeholder a slot that had a real source image.
|
|
151
|
+
3b. For every image the page needs (hero, product, about, feature, gallery): if the slot has a source image from the reference HTML/URL or a user-supplied URL → put that REAL URL straight into specials.src / gallery item.link and let the save auto-host it (no upload_images needed). For a user's LOCAL FILE PATH, upload_images it first (pass the path directly in upload_images 'urls'; NEVER relay the user's local file through a third-party host like catbox or imgur) and use the returned URL. Otherwise call search_images and put a returned URL into specials.src / gallery item.link. If search_images fails or has no fitting photo, find a real image yourself (web search/fetch) and put that URL in. Use placehold.co ONLY as the last resort when both search_images AND your own search failed. Never drop or placeholder a slot that had a real source image. Never write a base64 data: URI into a slot yourself (it ships the whole image through the connection) — use a real URL or upload_images; a base64 URI that came in from the reference is fine to leave, the save uploads it.
|
|
152
152
|
4. Assemble { page, popup, settings, options, cartConfigs }.
|
|
153
153
|
5. Call validate_page and fix every error AND every warning — warnings are visible defects (text spilling onto the element below, off-canvas boxes, empty bands at a section's bottom, missing field_name, dead event targets), not advisory polish. Re-validate until the warning list is empty; only a demonstrably false positive may remain (tell the user which and why).
|
|
154
154
|
6. To save: call list_organizations. If the account has EXACTLY ONE organization, create_page will auto-select it — no need to ask. If there are MULTIPLE organizations, show them to the user and ask which to use (highlight is_default as the suggested default); pass the chosen organization_id to create_page. If the user explicitly wants to save without any organization, pass organization_id:"personal". Then create_page (dry_run first, then dry_run:false). Note: create_page itself enforces this — it refuses to guess between multiple orgs and returns the org list asking you to pick.
|
|
@@ -167,4 +167,5 @@ REFERENCE INPUT (HTML page to clone or adapt)
|
|
|
167
167
|
- SECTION HEIGHTS: every AST section carries size_hint = { height, basis, css? } — the section's desktop height on the 960px canvas (basis:'css' = an explicit height/min-height found in the source, css holds the raw value e.g. "100vh"; basis:'estimate' = content-volume math). Set each rebuilt section's desktop height FROM its size_hint (±15% to fit your actual element placement) instead of the 800px default, so the page's vertical rhythm tracks the source: a 72px header stays a slim bar, a 1200px pricing band stays tall. Mobile is NOT hinted — redo the height per the mobile text math (stacked content is taller).
|
|
168
168
|
- Map AST section roles to Webcake elements: hero → section (background image/overlay) + text-block H1 + text-block subheading + button; features → group per card (icon rectangle + text-block title + text-block body); stats bar → group with text-block per stat; pricing → group with text-block list + button; footer → section (dark bg) + text-block + links. When the ingested page contains a composite widget (phone/device mockup, chat thread, mini dashboard, browser frame) → rebuild it as ONE html-box, NOT as element soup. The full AST hands you the SOURCE for this: section.widgets = [{ hint, html, css? }] — the widget's cleaned outerHTML plus the stylesheet rules that style it. Build the html-box FROM that html verbatim: inline each css rule into the matching elements' style="" attributes (an html-box has no <style> scope), wrap in a root div with width:100%;height:100%;box-sizing:border-box;overflow:hidden, and size the html-box to the widget's box — do NOT re-imagine the widget's markup from the summary fields.
|
|
169
169
|
- Reference images are the user's assets — for BOTH intents (adapt AND clone), carry every real image URL found in the AST (images, background_images, og_image, canvas src/background) straight into the matching slot; the save auto-hosts it to the Webcake CDN (no upload_images needed, no hotlinking), so never replace it with a search_images stock photo or a placeholder. intent='adapt' rewrites TEXT for the user's brand, not the imagery; search_images only fills slots that have no source image.
|
|
170
|
+
- FONTS + TYPE SCALE (clone/adapt): the AST's \`font_groups\` are the page's SELF-HOSTED fonts (from its @font-face rules — the platform doesn't have them). Put them into settings.fontGroups VERBATIM and set each text element's styles.fontFamily to "'<group name>', sans-serif" (fontGeneral too when it's the body font); keep the original file URLs — every save downloads them, uploads them to the media collection and files the group into the org's Font Groups (the result's \`fonts\` report), so never skip or substitute the font. Families NOT in font_groups are Google fonts and load by name. Each section's \`text_styles\` {heading, body, cta} and the page-level \`typography\` are the source's COMPUTED type (cascade, @media, var(), clamp(), Tailwind classes): set desktop styles.fontSize = font_size, mobile styles.fontSize = mobile_font_size (else font_size, shrunk only if the mobile text math needs it), and fontWeight/fontFamily/lineHeight/letterSpacing EXACTLY — never eyeball a size the AST already gives. Any fontWeight works (500/600/800 included): the save adds the Google weight links the build would otherwise skip. An absolute-canvas \`source\` already carries all of this. For ingest_html of HTML copied from a live site, pass base_url so external stylesheets (and their fonts) are read. Custom font files belong to the source site's license — clone fonts only for pages the user owns or is allowed to copy; for a third-party ADAPT, prefer the nearest Google family over its font_groups.
|
|
170
171
|
- VERIFY THE CLONE VISUALLY (clones only — where fidelity is the whole point): for a clone/adapt build, do the VISUAL CHECK from BUILDING step 7 ONCE — screenshot the rendered preview with your own browser/screenshot capability (or render_preview as fallback), compare it side-by-side with the reference (screenshot the reference URL too if you only have HTML), patch_page the clear mismatches, and re-check ONLY if you changed something material (don't loop reflexively). For a NON-clone page, validate_page + the save-time auto-fix are the gate — skip the screenshot unless the user asks or you have a specific aesthetic doubt. Eyeballing a reference and rebuilding once typically lands ~60% similar; a targeted look-and-patch pass closes the gap when fidelity matters.`;
|
|
@@ -369,15 +369,25 @@ function normalizeEditorValues(node) {
|
|
|
369
369
|
normalizeEditorValues(child);
|
|
370
370
|
}
|
|
371
371
|
}
|
|
372
|
-
/**
|
|
372
|
+
/**
|
|
373
|
+
* settings.width_section carries the canvas width the coords live in — keep it
|
|
374
|
+
* numeric, and default it (960/420) when absent: the build host reads
|
|
375
|
+
* `settings.width_section.mobile` unguarded, so a hand-written settings object
|
|
376
|
+
* without it saves fine but crashes every publish.
|
|
377
|
+
*/
|
|
373
378
|
function normalizeSettings(source) {
|
|
374
|
-
const
|
|
375
|
-
if (!
|
|
379
|
+
const settings = source?.settings;
|
|
380
|
+
if (!settings || typeof settings !== "object")
|
|
376
381
|
return;
|
|
382
|
+
if (!settings.width_section || typeof settings.width_section !== "object")
|
|
383
|
+
settings.width_section = {};
|
|
384
|
+
const ws = settings.width_section;
|
|
377
385
|
for (const bp of ["desktop", "mobile"]) {
|
|
378
386
|
if (ws[bp] !== undefined)
|
|
379
387
|
ws[bp] = numFromEditorString(ws[bp]);
|
|
380
388
|
}
|
|
389
|
+
ws.desktop ??= CANVAS.desktopWidth;
|
|
390
|
+
ws.mobile ??= CANVAS.mobileWidth;
|
|
381
391
|
}
|
|
382
392
|
/** Apply all post-expand normalizations to every node in a page source. */
|
|
383
393
|
function normalizeSource(source) {
|
|
@@ -38,7 +38,7 @@ MODEL (essentials):
|
|
|
38
38
|
- PREMIUM CRAFT (read "sang"): generous whitespace (don't cram; ~48–72px above each band's first element, ≥16–24px between elements); clear type scale (H1 40–56 / body 16–18, big jump); ONE accent used sparingly + neutrals; snap spacing to an 8px grid; reuse the same content width / margin / card+button radius across sections.
|
|
39
39
|
- STICKY HEADER: a sticky/fixed header (config.sticky) OVERLAYS the page — it does NOT push sections below it down. Offset the first section's top content DOWN by the header height (~60–72px) so nothing hides behind it, and do NOT duplicate the shop name in both the header and the top of the hero. A non-sticky header stacks normally and needs no offset.
|
|
40
40
|
- 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).
|
|
41
|
-
- 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 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. WHERE THEY LAND: with credentials AND the settled org, every image is filed into THAT ORG's MEDIA COLLECTION (bộ sưu tập) — the same library the editor's media picker reads — so the user can re-pick it later; rehost.collection:true (and images[x].collection:true from upload_images) confirms it. This is why the org must be settled before any image work (see ORGANIZATION FIRST). With no credentials the image goes to the public CDN instead — the URL works and the page renders, but it is absent from every collection (collection:false); that is a server-setup issue to report, not something to fix by hand-uploading elsewhere. 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
|
+
- 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 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. WHERE THEY LAND: with credentials AND the settled org, every image is filed into THAT ORG's MEDIA COLLECTION (bộ sưu tập) — the same library the editor's media picker reads — so the user can re-pick it later; rehost.collection:true (and images[x].collection:true from upload_images) confirms it. This is why the org must be settled before any image work (see ORGANIZATION FIRST). With no credentials the image goes to the public CDN instead — the URL works and the page renders, but it is absent from every collection (collection:false); that is a server-setup issue to report, not something to fix by hand-uploading elsewhere. 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). DON'T AUTHOR BASE64 IMAGES. A 'data:image/...;base64,...' URI never reaches the stored source — the save DECODES it and uploads it to the CDN like any other image (it counts in the rehost report), and validate_page only WARNS, so a base64 URI that came in from a reference/user paste needs no manual fix: just save. What you must not do is EMIT base64 yourself: the whole image travels through this connection inside your payload, and megabytes there can drop it. Put a real http(s) URL in the slot, or call upload_images (it accepts data: URIs) and use the returned statics.pancake.vn URL. Ensure text contrasts with its section background.
|
|
42
42
|
|
|
43
43
|
- BEYOND ELEMENT CAPABILITY: when a reference effect can't be expressed with an element's built-in specials (hover scale/lift/zoom & transitions, gradients, glassmorphism/backdrop-blur, custom shadows, gradient/clipped text, keyframe animations outside the 9 entrance types) DON'T drop it — use the escape hatches so the page is as complete as possible: element specials.custom_css (extra DECLARATIONS in #w-<id>{…}; declarations-only) + specials.custom_class (both need specials.customAdvance:true) for per-element styling, and page settings.extra_css (a full raw stylesheet in <head> — where :hover/@keyframes/media-queries live, target #w-<element id>) + settings.extra_script (raw JS) + settings.bhet/bbet (raw HTML blocks at end of <head>/<body> — for webfont <link>s, <meta>/verification, analytics/pixels, chat widgets, third-party embeds). get_generation_guide has the recipes. SAFETY (custom is raw + global → sloppy custom BREAKS the whole UI): SCOPE every extra_css rule to "#w-<id>" or a specials.custom_class — NEVER a bare tag / "*" / a Webcake-internal class (.section-container/.rectangle-css/.text-block-css/.group-*…); keep custom_css to VISUAL props only (no position/top/left/width/height/display/float); close every bhet/bbet tag; don't restructure "#w-…" elements in extra_script. validate_page flags unscoped selectors, layout props in custom_css, unbalanced braces/tags, missing customAdvance, and CSS/JS in the wrong field — fix every such warning.
|
|
44
44
|
- PREVIEW vs PUBLISH: for review share the EDITOR url (the builder renders the raw source). The editor_url SIGNS THE BROWSER IN automatically (it routes through the builder's /transport with the account token), so it works even when the user isn't logged in — but for the same reason it must go to the PAGE OWNER ONLY, never into anything public. create_page AUTO-PUBLISHES on success (builds the rendered app + publish_html), so a fresh page's preview_url renders right away — but the preview host (preview.localhost:5800 local / staging.webcake.me staging / www.webcake.me prod) only serves it for ~10 MINUTES after the last publish, then shows "Preview page is expired". The EDIT routes (update_page/add_section/patch_page) store source only — after finishing a round of edits, run publish_page({ page_id, dry_run:false }) to rebuild the rendered app (else the preview shows the STALE pre-edit build). ONLY a custom_domain (publish_page({ page_id, custom_domain, dry_run:false })) gives a permanent public URL; without one the page has just the ephemeral preview link — say so and suggest attaching a domain the user already points at Webcake.
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"favicon": { "type": "string" },
|
|
45
45
|
"thumbnail": { "type": "string", "description": "Social/share thumbnail URL." },
|
|
46
46
|
"fontGeneral": { "type": "string", "description": "Default page font-family, e.g. \"'Roboto', sans-serif\"." },
|
|
47
|
+
"fontGroups": { "type": "array", "description": "Custom (non-Google) fonts the build renders as @font-face: [{ name: <CSS family elements use in styles.fontFamily>, fonts: [{ name: <file name WITH extension — the format is sniffed from it>, url, font_weight: thin|light|normal|medium|semibold|bold|extrabold|black, font_style: normal|italic }] }]. External font URLs are uploaded to the media collection on save.", "items": { "type": "object" } },
|
|
47
48
|
"width_section": {
|
|
48
49
|
"type": "object",
|
|
49
50
|
"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.",
|
|
@@ -126,6 +126,49 @@ function isVividColor(v) {
|
|
|
126
126
|
return false; // fully transparent
|
|
127
127
|
return Math.max(r, g, b) - Math.min(r, g, b) >= 16; // channel spread ⇒ has hue
|
|
128
128
|
}
|
|
129
|
+
// ── inline base64 image data (never STORED — auto-uploaded on save) ─────────
|
|
130
|
+
// A `data:image/…;base64,…` URI must not end up in a saved page source: the bytes
|
|
131
|
+
// would stay embedded forever, and that JSON is re-read and re-written on every
|
|
132
|
+
// later get_page / update_page / patch_page. The save no longer just refuses it —
|
|
133
|
+
// the rehost pass DECODES the payload and uploads it to the CDN like any remote
|
|
134
|
+
// image (rehost.ts `parseInlineBase64Image` → webcake-client `fetchAndHostOne`),
|
|
135
|
+
// then rewrites the URI in-tree. So this is a WARNING, not an error: blocking here
|
|
136
|
+
// would stop the very save that fixes it. What it still tells the model is "do not
|
|
137
|
+
// AUTHOR base64" — the payload travels through the MCP link before the server can
|
|
138
|
+
// do anything about it, and megabytes there can drop the connection.
|
|
139
|
+
// Matches the URI's header only (`data:image/png;base64,`), so it never fires on
|
|
140
|
+
// bare base64 payloads that are not images (e.g. spin-wheel prize lists) nor on
|
|
141
|
+
// non-base64 data: URIs (`data:image/svg+xml;utf8,<svg…>` — tiny, inline by design).
|
|
142
|
+
const BASE64_IMAGE_RE = /data:image\/[a-z0-9.+-]+\s*;(?:[^,;]*;)*\s*base64\s*,/i;
|
|
143
|
+
/** Key-path of the first string under `value` carrying inline base64 image data (undefined if none). */
|
|
144
|
+
function findBase64Image(value, path) {
|
|
145
|
+
if (typeof value === "string")
|
|
146
|
+
return BASE64_IMAGE_RE.test(value) ? path : undefined;
|
|
147
|
+
if (Array.isArray(value)) {
|
|
148
|
+
for (let i = 0; i < value.length; i++) {
|
|
149
|
+
const hit = findBase64Image(value[i], `${path}[${i}]`);
|
|
150
|
+
if (hit)
|
|
151
|
+
return hit;
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
if (value && typeof value === "object") {
|
|
156
|
+
for (const [k, v] of Object.entries(value)) {
|
|
157
|
+
const hit = findBase64Image(v, `${path}.${k}`);
|
|
158
|
+
if (hit)
|
|
159
|
+
return hit;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
/** The inline-base64-image notice, worded the same everywhere it is reported. */
|
|
165
|
+
function base64ImageNotice(where) {
|
|
166
|
+
return (`${where} carries an INLINE BASE64 IMAGE (data:image/…;base64,…). The save uploads it to the Webcake CDN and rewrites the URL ` +
|
|
167
|
+
`in-tree (it counts in the response's rehost report), so NO manual fix is needed and base64 never reaches the stored source. ` +
|
|
168
|
+
`But do not AUTHOR base64: the whole image travels through this connection in your payload (megabytes can drop it). Put a real ` +
|
|
169
|
+
`http(s) image URL in ${where} — the save hosts that too — or call upload_images (it accepts data: URIs) and use the returned ` +
|
|
170
|
+
`statics.pancake.vn URL. If the upload fails (no credentials/network), the data: URI stays and the page keeps the bloat.`);
|
|
171
|
+
}
|
|
129
172
|
/** Accept an object or a JSON string. Returns the parsed page or throws. */
|
|
130
173
|
export function coercePage(input) {
|
|
131
174
|
if (typeof input === "string")
|
|
@@ -329,6 +372,21 @@ export function validatePage(input) {
|
|
|
329
372
|
}
|
|
330
373
|
}
|
|
331
374
|
}
|
|
375
|
+
// inline base64 image data anywhere on this element (specials.src, a
|
|
376
|
+
// url(data:…) background, gallery item.link, video poster, config.svgMask…).
|
|
377
|
+
// Scans the whole node except `children` (walked on its own turn).
|
|
378
|
+
{
|
|
379
|
+
let hit;
|
|
380
|
+
for (const [k, v] of Object.entries(node)) {
|
|
381
|
+
if (k === "children")
|
|
382
|
+
continue;
|
|
383
|
+
hit = findBase64Image(v, k);
|
|
384
|
+
if (hit)
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
if (hit)
|
|
388
|
+
warnings.push(`${path} (${type}): ${base64ImageNotice(hit)}`);
|
|
389
|
+
}
|
|
332
390
|
// animation contract — checked per breakpoint
|
|
333
391
|
// Source: landing_page_build/render/build/animate.js (animatable type list)
|
|
334
392
|
// landing_page_backend/assets/editor/main/traits/TraitAnimation.vue (name set)
|
|
@@ -1198,6 +1256,14 @@ export function validatePage(input) {
|
|
|
1198
1256
|
}
|
|
1199
1257
|
}
|
|
1200
1258
|
}
|
|
1259
|
+
// ── inline base64 image data in page settings ───────────────────────────────
|
|
1260
|
+
// Same rule as on elements: favicon / og image / a url(data:…;base64,…) inside
|
|
1261
|
+
// settings.extra_css / bhet / bbet are auto-uploaded by the save's rehost pass.
|
|
1262
|
+
{
|
|
1263
|
+
const hit = findBase64Image(page?.settings, "settings");
|
|
1264
|
+
if (hit)
|
|
1265
|
+
warnings.push(base64ImageNotice(hit));
|
|
1266
|
+
}
|
|
1201
1267
|
return {
|
|
1202
1268
|
valid: errors.length === 0,
|
|
1203
1269
|
errors,
|
|
@@ -16,8 +16,10 @@
|
|
|
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, detectHoverEffects, brandHints } from "./ingest/semantic.js";
|
|
19
|
+
import { findSections, classifySection, computeSizeHint, detectWidgets, detectHoverEffects, brandHints, pickHeading } from "./ingest/semantic.js";
|
|
20
20
|
import { parseAbsoluteCanvas, canvasRoleSections, stripCdnSizePrefix } from "./ingest/canvas.js";
|
|
21
|
+
import { extractFontFaces, toFontGroups, extractTypography, parseCssRules, makeTypeResolver, resolveCssVars, rootFontPx, stripComments } from "./ingest/fonts.js";
|
|
22
|
+
import { isAllowedScreenshotUrl } from "./screenshot-playwright.js";
|
|
21
23
|
// Re-export the public surface so existing imports of "./persistence/html-ingest.js"
|
|
22
24
|
// (parseHtml, fetchHtml, and the IngestedAst/IngestedCanvas/CanvasElement/CanvasSection
|
|
23
25
|
// types) keep resolving unchanged.
|
|
@@ -26,6 +28,7 @@ export * from "./ingest/stylesheets.js";
|
|
|
26
28
|
export * from "./ingest/tailwind.js";
|
|
27
29
|
export * from "./ingest/semantic.js";
|
|
28
30
|
export * from "./ingest/canvas.js";
|
|
31
|
+
export * from "./ingest/fonts.js";
|
|
29
32
|
const FETCH_TIMEOUT_MS = 10_000;
|
|
30
33
|
const MAX_HTML_BYTES = 2_000_000; // 2MB
|
|
31
34
|
const FULL_SIZE_CAP = 25_000; // ~25 KB serialized cap for full mode
|
|
@@ -44,6 +47,12 @@ export function parseHtml(html, detail = "compact", opts = {}) {
|
|
|
44
47
|
const styleBlocks = extractStyleBlocks(html);
|
|
45
48
|
const googleFonts = extractGoogleFonts(html);
|
|
46
49
|
const tw = extractTailwindConfig(html);
|
|
50
|
+
const cssSources = orderedCss(html, opts.baseUrl, opts.extraCss ?? []);
|
|
51
|
+
const fontGroups = toFontGroups(extractFontFaces(cssSources));
|
|
52
|
+
const cssRules = resolveCssVars(cssSources.flatMap((c) => parseCssRules(c.css)));
|
|
53
|
+
const remPx = rootFontPx(cssRules);
|
|
54
|
+
const typography = extractTypography(cssRules, remPx);
|
|
55
|
+
const fontFields = { ...(fontGroups.length ? { font_groups: fontGroups } : {}), ...(typography ? { typography } : {}) };
|
|
47
56
|
const root = parse(html, { lowerCaseTagName: true });
|
|
48
57
|
const head = root.querySelector("head");
|
|
49
58
|
const title = head?.querySelector("title")?.text?.trim() || undefined;
|
|
@@ -74,6 +83,7 @@ export function parseHtml(html, detail = "compact", opts = {}) {
|
|
|
74
83
|
palette: hints.palette,
|
|
75
84
|
design_tokens: hints.design_tokens,
|
|
76
85
|
background_images: bg.length ? bg : undefined,
|
|
86
|
+
...fontFields,
|
|
77
87
|
warnings: warnings.length ? warnings : undefined,
|
|
78
88
|
};
|
|
79
89
|
}
|
|
@@ -91,10 +101,22 @@ export function parseHtml(html, detail = "compact", opts = {}) {
|
|
|
91
101
|
],
|
|
92
102
|
};
|
|
93
103
|
}
|
|
104
|
+
// Computed type per text role, so a hand-rebuild copies exact sizes/weights.
|
|
105
|
+
const resolveType = makeTypeResolver(cssRules, tw ?? undefined, remPx);
|
|
106
|
+
const textStyles = (el) => {
|
|
107
|
+
const heading = resolveType(pickHeading(el));
|
|
108
|
+
const bodyText = resolveType(el.querySelector("p") ?? undefined);
|
|
109
|
+
const cta = resolveType(el.querySelector("button") ?? el.querySelector('a[class*="btn"], a[class*="button"]') ?? undefined);
|
|
110
|
+
const out = { ...(heading ? { heading } : {}), ...(bodyText ? { body: bodyText } : {}), ...(cta ? { cta } : {}) };
|
|
111
|
+
return Object.keys(out).length ? out : undefined;
|
|
112
|
+
};
|
|
94
113
|
const sectionEls = findSections(body);
|
|
95
114
|
const sections = sectionEls.map((el) => {
|
|
96
115
|
const sec = classifySection(el, detail);
|
|
97
116
|
sec.size_hint = computeSizeHint(el, sec, styleBlocks);
|
|
117
|
+
const styles = textStyles(el);
|
|
118
|
+
if (styles)
|
|
119
|
+
sec.text_styles = styles;
|
|
98
120
|
const hover = detectHoverEffects(el);
|
|
99
121
|
if (hover.length)
|
|
100
122
|
sec.hover_effects = hover;
|
|
@@ -122,6 +144,7 @@ export function parseHtml(html, detail = "compact", opts = {}) {
|
|
|
122
144
|
// and the Play CDN never emits the resolved CSS.
|
|
123
145
|
gradients: hints.tailwind_gradients.length ? hints.tailwind_gradients : undefined,
|
|
124
146
|
background_images: hints.background_images.length ? hints.background_images : undefined,
|
|
147
|
+
...fontFields,
|
|
125
148
|
warnings: warnings.length ? warnings : undefined,
|
|
126
149
|
};
|
|
127
150
|
if (detail !== "full")
|
|
@@ -185,25 +208,11 @@ export async function fetchHtml(url, opts = {}) {
|
|
|
185
208
|
if (!/html|xml|text/i.test(ctype)) {
|
|
186
209
|
return { ok: false, status: res.status, error: `Content-Type ${ctype} is not HTML` };
|
|
187
210
|
}
|
|
188
|
-
const
|
|
189
|
-
if (
|
|
211
|
+
const buf = await readCapped(res, MAX_HTML_BYTES);
|
|
212
|
+
if (buf === "too-large")
|
|
213
|
+
return { ok: false, status: res.status, error: `Response exceeded ${MAX_HTML_BYTES} bytes` };
|
|
214
|
+
if (!buf)
|
|
190
215
|
return { ok: false, status: res.status, error: "no response body" };
|
|
191
|
-
const chunks = [];
|
|
192
|
-
let total = 0;
|
|
193
|
-
while (true) {
|
|
194
|
-
const { value, done } = await reader.read();
|
|
195
|
-
if (done)
|
|
196
|
-
break;
|
|
197
|
-
if (!value)
|
|
198
|
-
continue;
|
|
199
|
-
total += value.length;
|
|
200
|
-
if (total > MAX_HTML_BYTES) {
|
|
201
|
-
await reader.cancel().catch(() => { });
|
|
202
|
-
return { ok: false, status: res.status, error: `Response exceeded ${MAX_HTML_BYTES} bytes` };
|
|
203
|
-
}
|
|
204
|
-
chunks.push(value);
|
|
205
|
-
}
|
|
206
|
-
const buf = Buffer.concat(chunks.map((c) => Buffer.from(c)));
|
|
207
216
|
return { ok: true, status: res.status, html: buf.toString("utf-8") };
|
|
208
217
|
}
|
|
209
218
|
catch (e) {
|
|
@@ -213,3 +222,192 @@ export async function fetchHtml(url, opts = {}) {
|
|
|
213
222
|
clearTimeout(timer);
|
|
214
223
|
}
|
|
215
224
|
}
|
|
225
|
+
/** Stream a response body, giving up past `max` bytes (never buffers an unbounded body). */
|
|
226
|
+
async function readCapped(res, max) {
|
|
227
|
+
const reader = res.body?.getReader();
|
|
228
|
+
if (!reader)
|
|
229
|
+
return null;
|
|
230
|
+
const chunks = [];
|
|
231
|
+
let total = 0;
|
|
232
|
+
while (true) {
|
|
233
|
+
const { value, done } = await reader.read();
|
|
234
|
+
if (done)
|
|
235
|
+
break;
|
|
236
|
+
if (!value)
|
|
237
|
+
continue;
|
|
238
|
+
total += value.length;
|
|
239
|
+
if (total > max) {
|
|
240
|
+
await reader.cancel().catch(() => { });
|
|
241
|
+
return "too-large";
|
|
242
|
+
}
|
|
243
|
+
chunks.push(value);
|
|
244
|
+
}
|
|
245
|
+
return Buffer.concat(chunks.map((c) => Buffer.from(c)));
|
|
246
|
+
}
|
|
247
|
+
const MAX_STYLESHEETS = 12;
|
|
248
|
+
const MAX_CSS_BYTES = 1_000_000;
|
|
249
|
+
const MAX_REDIRECTS = 3;
|
|
250
|
+
/** One attribute of a raw tag (quoted or bare); `data-href` never matches `href`. */
|
|
251
|
+
function tagAttr(tag, name) {
|
|
252
|
+
const m = new RegExp(`[\\s"']${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, "i").exec(tag);
|
|
253
|
+
return m ? (m[1] ?? m[2] ?? m[3]) : undefined;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* The page's stylesheet sources in DOCUMENT order (the cascade order): each
|
|
257
|
+
* `<link rel=stylesheet>` (or `rel=preload as=style`, the async-CSS pattern) by
|
|
258
|
+
* href, each `<style>` by index. Print-only links are dropped.
|
|
259
|
+
*/
|
|
260
|
+
export function stylesheetTags(html) {
|
|
261
|
+
const out = [];
|
|
262
|
+
let styleIndex = 0;
|
|
263
|
+
for (const m of html.matchAll(/<link\b[^>]*>|<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
|
|
264
|
+
if (m[0][1].toLowerCase() === "s") {
|
|
265
|
+
out.push({ kind: "style", index: styleIndex++, css: m[1] ?? "" });
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const rel = (tagAttr(m[0], "rel") ?? "").toLowerCase();
|
|
269
|
+
const isCss = /\bstylesheet\b/.test(rel) || (/\bpreload\b/.test(rel) && (tagAttr(m[0], "as") ?? "").toLowerCase() === "style");
|
|
270
|
+
const media = (tagAttr(m[0], "media") ?? "").toLowerCase();
|
|
271
|
+
const href = tagAttr(m[0], "href");
|
|
272
|
+
if (isCss && href && !(media.includes("print") && !media.includes("screen")))
|
|
273
|
+
out.push({ kind: "link", href: href.replace(/&/g, "&") });
|
|
274
|
+
}
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
/** `@import url(x)` / `@import "x"` targets of a CSS text (comments stripped; print-only imports skipped). */
|
|
278
|
+
function cssImports(css) {
|
|
279
|
+
const out = [];
|
|
280
|
+
for (const m of stripComments(css).matchAll(/@import\s+(?:url\(\s*)?["']?([^"')\s;]+)["']?\s*\)?([^;]*);/gi)) {
|
|
281
|
+
if (!/\bprint\b/i.test(m[2]) || /\bscreen\b/i.test(m[2]))
|
|
282
|
+
out.push(m[1]);
|
|
283
|
+
}
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* GET a stylesheet the crawled page pointed us at — that URL is chosen by a
|
|
288
|
+
* third-party page, so: http(s) only, no private/loopback/link-local host (SSRF;
|
|
289
|
+
* same policy + RENDER_ALLOW_PRIVATE opt-out as the screenshot route), redirects
|
|
290
|
+
* followed MANUALLY so each hop is re-checked, CSS/text content-type only, and the
|
|
291
|
+
* body streamed with a byte cap.
|
|
292
|
+
*/
|
|
293
|
+
async function fetchCss(url) {
|
|
294
|
+
let target = url;
|
|
295
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
296
|
+
if (!isAllowedScreenshotUrl(target).ok)
|
|
297
|
+
return null;
|
|
298
|
+
let res;
|
|
299
|
+
try {
|
|
300
|
+
res = await fetch(target, {
|
|
301
|
+
redirect: "manual",
|
|
302
|
+
signal: AbortSignal.timeout(8_000),
|
|
303
|
+
headers: { "User-Agent": "Mozilla/5.0 (compatible; webcake-landing-mcp/ingest_url)", Accept: "text/css,*/*;q=0.1" },
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
if (res.status >= 300 && res.status < 400) {
|
|
310
|
+
const loc = res.headers.get("location");
|
|
311
|
+
if (!loc)
|
|
312
|
+
return null;
|
|
313
|
+
try {
|
|
314
|
+
target = new URL(loc, target).href;
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
const ctype = (res.headers.get("content-type") ?? "").toLowerCase();
|
|
322
|
+
if (!res.ok || (ctype && !/css|text\/plain|octet-stream/.test(ctype)))
|
|
323
|
+
return null;
|
|
324
|
+
try {
|
|
325
|
+
const buf = await readCapped(res, MAX_CSS_BYTES);
|
|
326
|
+
return buf && buf !== "too-large" ? buf.toString("utf-8") : null;
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Fetch the page's external stylesheets — and what they (or inline <style> blocks)
|
|
336
|
+
* `@import`, two levels deep — so `@font-face` + type rules outside the HTML are
|
|
337
|
+
* seen. Each owner's list is ordered imports-first, like the cascade. Google Fonts
|
|
338
|
+
* CSS is skipped (those families load by name). Best-effort: a failed sheet is skipped.
|
|
339
|
+
*/
|
|
340
|
+
export async function fetchStylesheets(html, pageUrl) {
|
|
341
|
+
const seen = new Set();
|
|
342
|
+
const claim = (href, base) => {
|
|
343
|
+
if (/fonts\.googleapis\.com/i.test(href) || seen.size >= MAX_STYLESHEETS)
|
|
344
|
+
return undefined;
|
|
345
|
+
try {
|
|
346
|
+
const abs = new URL(href, base).href;
|
|
347
|
+
if (!/^https?:/i.test(abs) || seen.has(abs))
|
|
348
|
+
return undefined;
|
|
349
|
+
seen.add(abs);
|
|
350
|
+
return abs;
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
// One sheet → [its imports' sheets (recursively, in order)…, itself].
|
|
357
|
+
const load = async (abs, owner, depth) => {
|
|
358
|
+
const css = await fetchCss(abs);
|
|
359
|
+
if (css === null)
|
|
360
|
+
return [];
|
|
361
|
+
const imports = depth < 2 ? cssImports(css).map((h) => claim(h, abs)).filter((h) => !!h) : [];
|
|
362
|
+
const nested = await Promise.all(imports.map((h) => load(h, owner, depth + 1)));
|
|
363
|
+
return [...nested.flat(), { owner, css, base: abs }];
|
|
364
|
+
};
|
|
365
|
+
const jobs = [];
|
|
366
|
+
for (const t of stylesheetTags(html)) {
|
|
367
|
+
if (t.kind === "link") {
|
|
368
|
+
const abs = claim(t.href, pageUrl);
|
|
369
|
+
if (abs)
|
|
370
|
+
jobs.push(load(abs, abs, 0));
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
for (const h of cssImports(t.css)) {
|
|
374
|
+
const abs = claim(h, pageUrl);
|
|
375
|
+
if (abs)
|
|
376
|
+
jobs.push(load(abs, `style:${t.index}`, 1));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return (await Promise.all(jobs)).flat();
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* All CSS of the page in cascade (document) order: for each <link> its fetched
|
|
384
|
+
* sheets, for each <style> its fetched @imports then the block itself; anything
|
|
385
|
+
* fetched but unplaced goes last.
|
|
386
|
+
*/
|
|
387
|
+
function orderedCss(html, baseUrl, extra) {
|
|
388
|
+
const out = [];
|
|
389
|
+
const used = new Set();
|
|
390
|
+
const take = (owner) => {
|
|
391
|
+
for (const e of extra)
|
|
392
|
+
if (e.owner === owner && !used.has(e)) {
|
|
393
|
+
used.add(e);
|
|
394
|
+
out.push(e);
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
for (const t of stylesheetTags(html)) {
|
|
398
|
+
if (t.kind === "link") {
|
|
399
|
+
try {
|
|
400
|
+
take(new URL(t.href, baseUrl).href);
|
|
401
|
+
}
|
|
402
|
+
catch { /* relative href without a base (ingest_html) */ }
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
take(`style:${t.index}`);
|
|
406
|
+
out.push({ css: t.css, base: baseUrl });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
for (const e of extra)
|
|
410
|
+
if (!used.has(e))
|
|
411
|
+
out.push(e);
|
|
412
|
+
return out;
|
|
413
|
+
}
|