webcake-landing-mcp 1.5.2 → 1.5.3

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.
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "v": "1.5.3",
4
+ "d": "17/09/2026",
5
+ "type": "Added",
6
+ "en": "validate_page now detects inline base64 images (data:image/…;base64,… URIs) anywhere in an element's specials, a styles background, gallery…",
7
+ "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,…"
8
+ },
2
9
  {
3
10
  "v": "1.5.2",
4
11
  "d": "25/08/2026",
@@ -33,12 +40,5 @@
33
40
  "type": "Added",
34
41
  "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
42
  "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.
@@ -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.
@@ -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,
@@ -11,11 +11,19 @@
11
11
  * no IO) and must not import a network client. This one holds the only stateful
12
12
  * piece. It imports redis.ts but NOT webcake-client.ts, so no import cycle.
13
13
  */
14
+ import { createHash } from "node:crypto";
14
15
  import { getRedis } from "./redis.js";
15
16
  const REDIS_PREFIX = "wcl:rehost:";
16
17
  // Hosted URLs are effectively permanent on the CDN; cap Redis growth with a long TTL.
17
18
  const TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
18
19
  const memory = new Map();
20
+ /**
21
+ * A source "url" can be an INLINE BASE64 image — megabytes of payload, which is
22
+ * unusable as a Redis key (and wasteful in the memory Map). Anything longer than
23
+ * a normal URL is content-addressed instead; short URLs keep their readable key.
24
+ */
25
+ const MAX_LITERAL_KEY = 256;
26
+ const keyPart = (url) => url.length <= MAX_LITERAL_KEY ? url : `sha256:${createHash("sha256").update(url).digest("hex")}`;
19
27
  /**
20
28
  * Entries are namespaced by SCOPE — `org:<id>` when the upload files an Asset
21
29
  * into that org's media collection, else `public`. The hosted URL is
@@ -23,7 +31,7 @@ const memory = new Map();
23
31
  * is per-org: sharing one key would let a second org hit the cache and skip the
24
32
  * upload, leaving the image absent from ITS collection.
25
33
  */
26
- const key = (url, scope) => `${REDIS_PREFIX}${scope}:${url}`;
34
+ export const key = (url, scope) => `${REDIS_PREFIX}${scope}:${keyPart(url)}`;
27
35
  /** Look up the hosted URL previously stored for `url` in `scope`, or null on a miss. */
28
36
  export async function rehostGet(url, scope = "public") {
29
37
  const redis = getRedis();
@@ -12,8 +12,14 @@
12
12
  * specials.src, gallery item.link, video poster img — plain-string fields), OR
13
13
  * - any http(s) URL inside a CSS `url(...)` token (covers the styles.background
14
14
  * shorthand `… url(<x>) …` — a background image regardless of extension).
15
- * Already-hosted (statics.pancake.vn), data:, and deliberate-placeholder hosts
15
+ * Already-hosted (statics.pancake.vn) and deliberate-placeholder hosts
16
16
  * (placehold.co / placeholder.com / dummyimage) are left alone.
17
+ *
18
+ * INLINE BASE64 (`data:image/<type>;base64,<payload>`) counts too, and is the
19
+ * reason a base64 image never has to reach the stored source: the bytes are
20
+ * decoded here (no fetch) and uploaded on the same collection path as a remote
21
+ * URL, then rewritten in-tree. Non-base64 data: URIs (`data:image/svg+xml;utf8,
22
+ * <svg…>`) are tiny and inline by design, so they are left alone.
17
23
  */
18
24
  /** Hosts we never re-host: our own CDN + deliberate placeholders. */
19
25
  const SKIP_HOSTS = [
@@ -47,6 +53,32 @@ function hostOf(url) {
47
53
  function isExtensionlessImageHost(host) {
48
54
  return EXTENSIONLESS_IMAGE_HOSTS.some((h) => host === h || host.endsWith("." + h));
49
55
  }
56
+ /**
57
+ * `data:image/<type>;…base64,<payload>` — the inline form we can decode and
58
+ * upload. Anchored to the whole value so it never fires on prose that merely
59
+ * mentions a data URI, and the payload is restricted to the base64 alphabet.
60
+ */
61
+ const BASE64_IMAGE_URI_RE = /^data:(image\/[a-z0-9.+-]+)\s*;(?:[^,;]*;)*\s*base64\s*,\s*([A-Za-z0-9+/=\s]+)$/i;
62
+ /** Is this string an inline base64 image (the only data: form we re-host)? */
63
+ export function isInlineBase64Image(value) {
64
+ return typeof value === "string" && BASE64_IMAGE_URI_RE.test(value.trim());
65
+ }
66
+ /**
67
+ * Decode an inline base64 image to its bytes + content type, or null when the
68
+ * value is not one (a non-base64 data: URI, a plain URL, malformed payload).
69
+ * PURE — decoding only, no network; the upload lives in webcake-client.
70
+ */
71
+ export function parseInlineBase64Image(value) {
72
+ if (typeof value !== "string")
73
+ return null;
74
+ const m = value.trim().match(BASE64_IMAGE_URI_RE);
75
+ if (!m)
76
+ return null;
77
+ const bytes = Buffer.from(m[2].replace(/\s+/g, ""), "base64");
78
+ if (bytes.byteLength === 0)
79
+ return null; // empty/undecodable payload — nothing to upload
80
+ return { contentType: m[1].toLowerCase(), bytes };
81
+ }
50
82
  /** http(s), not already-hosted/placeholder/data, and image-looking — by extension OR a known extensionless image host. */
51
83
  export function isRehostableImageUrl(url) {
52
84
  if (typeof url !== "string")
@@ -82,15 +114,17 @@ function isRehostableBgUrl(url) {
82
114
  }
83
115
  /** Extract every re-hostable image URL contained in a single string value. */
84
116
  function urlsInString(s, out) {
85
- // 1) The whole value is itself an image URL (specials.src, gallery link, poster).
86
- if (isRehostableImageUrl(s))
117
+ // 1) The whole value is itself an image URL (specials.src, gallery link, poster)
118
+ // or an inline base64 image pasted into the same slot.
119
+ if (isRehostableImageUrl(s) || isInlineBase64Image(s))
87
120
  out.add(s.trim());
88
- // 2) `url(...)` tokens inside a CSS value (background shorthand) — any image.
121
+ // 2) `url(...)` tokens inside a CSS value (background shorthand) — any image,
122
+ // including a base64 one (the payload has no quote/`)` to end the token early).
89
123
  CSS_URL_RE.lastIndex = 0;
90
124
  let m;
91
125
  while ((m = CSS_URL_RE.exec(s)) !== null) {
92
126
  const inner = m[2]?.trim();
93
- if (inner && isRehostableBgUrl(inner))
127
+ if (inner && (isRehostableBgUrl(inner) || isInlineBase64Image(inner)))
94
128
  out.add(inner);
95
129
  }
96
130
  }
@@ -108,7 +142,7 @@ function walkStrings(value, visit) {
108
142
  walkStrings(v, visit);
109
143
  }
110
144
  }
111
- /** Distinct external image URLs anywhere in the page source (specials.src, backgrounds, gallery, posters). */
145
+ /** Distinct external image URLs — and inline base64 images — anywhere in the page source (specials.src, backgrounds, gallery, posters). */
112
146
  export function collectExternalImageUrls(source) {
113
147
  const set = new Set();
114
148
  walkStrings(source, (s) => urlsInString(s, set));
@@ -1,4 +1,17 @@
1
- import { collectExternalImageUrls, rewriteImageUrls, MAX_REHOST_PER_SAVE } from "./rehost.js";
1
+ /**
2
+ * Thin HTTP client to the Webcake backend:
3
+ * - list the account's organizations (GET /api/v1/org/organizations)
4
+ * - list / read / create / update the account's page sources (/api/v1/ai/*)
5
+ *
6
+ * A page lands in an organization when an `x-org-id` header is sent (resolved by
7
+ * the backend `:org_check` plug). Without it the page is personal (org=null).
8
+ * The build*Redacted helpers produce dry-run previews with the JWT masked.
9
+ *
10
+ * Endpoints live in the separate landing_page_backend repo
11
+ * (LandingPageWeb.V1.AiController, scope /api/v1/ai). Requires global fetch (Node 18+).
12
+ */
13
+ import { createHash } from "node:crypto";
14
+ import { collectExternalImageUrls, rewriteImageUrls, parseInlineBase64Image, MAX_REHOST_PER_SAVE } from "./rehost.js";
2
15
  import { rehostGet, rehostSet } from "./rehost-cache.js";
3
16
  /** Default fetch timeout in ms. Override via WEBCAKE_HTTP_TIMEOUT_MS env. */
4
17
  const HTTP_TIMEOUT_MS = (() => {
@@ -937,6 +950,17 @@ function rehostExtFromContentType(ct) {
937
950
  * bộ sưu tập) and degrades to the public CDN endpoint without creds/org.
938
951
  */
939
952
  async function fetchAndHostOne(config, src) {
953
+ // Inline base64 image — the bytes are already in hand, so there is nothing to
954
+ // fetch. Upload them on the same collection path as a remote URL; the caller
955
+ // then rewrites the data: URI in-tree, so base64 never reaches storage.
956
+ const inline = parseInlineBase64Image(src);
957
+ if (inline) {
958
+ if (inline.bytes.byteLength > REHOST_MAX_BYTES)
959
+ return null;
960
+ const ext = rehostExtFromContentType(inline.contentType);
961
+ const up = await uploadImagePreferCollection(config, inline.bytes, inlineImageFilename(inline.bytes, ext), inline.contentType);
962
+ return up.ok && up.url ? up.url : null;
963
+ }
940
964
  let res;
941
965
  try {
942
966
  res = await fetch(src, {
@@ -983,6 +1007,14 @@ async function fetchAndHostOne(config, src) {
983
1007
  const up = await uploadImagePreferCollection(config, Buffer.from(buf), filename, contentType);
984
1008
  return up.ok && up.url ? up.url : null;
985
1009
  }
1010
+ /**
1011
+ * Name an inline-base64 upload by its CONTENT, so the collection shows distinct
1012
+ * rows (and a re-pasted identical image is obvious) instead of a wall of
1013
+ * "inline-image.png". The source URI carries no filename to borrow.
1014
+ */
1015
+ function inlineImageFilename(bytes, ext) {
1016
+ return `inline-${createHash("sha1").update(bytes).digest("hex").slice(0, 10)}.${ext}`;
1017
+ }
986
1018
  /** Derive a human-readable collection filename from the source URL. */
987
1019
  function rehostFilename(src, ext) {
988
1020
  let stem = "";
package/dist/smoke.js CHANGED
@@ -21,7 +21,8 @@ import { isLocalPath, resolveLocalPath, sniffMime, localContentType } from "./to
21
21
  import { buildMicrolinkUrl } from "./persistence/screenshot-client.js";
22
22
  import { isPrivateHost, isAllowedScreenshotUrl } from "./persistence/screenshot-playwright.js";
23
23
  import { iconifyCandidates } from "./persistence/icon-client.js";
24
- import { collectExternalImageUrls, rewriteImageUrls, isRehostableImageUrl } from "./persistence/rehost.js";
24
+ import { collectExternalImageUrls, rewriteImageUrls, isRehostableImageUrl, isInlineBase64Image, parseInlineBase64Image } from "./persistence/rehost.js";
25
+ import { key as rehostCacheKey } from "./persistence/rehost-cache.js";
25
26
  import { sourceFingerprint, collectElementIds, decideOverwrite, rememberPageVersion, forgetPageVersion, lastKnownPageVersion, resetPageVersions, sourceCacheKey, cachePageSource, cachedPageSource, cachedPageSourceVersion, dropCachedPageSource, resetPageSourceCache, } from "./persistence/page-version.js";
26
27
  let failures = 0;
27
28
  const check = (name, cond, extra) => {
@@ -266,6 +267,55 @@ console.log("== validate: custom-code SAFETY (broad/broken custom breaks the UI)
266
267
  clean.page[0].children[0].specials = { text: "X", customAdvance: true, custom_css: "box-shadow:0 8px 24px rgba(0,0,0,.08);" };
267
268
  check("custom-safety: correctly-scoped custom triggers no safety warning", W(validatePage(clean), /UNSCOPED|unbalanced|no HTML tags|layout prop/).length === 0, validatePage(clean).warnings);
268
269
  }
270
+ console.log("== validate: inline base64 images are DETECTED (warning) — the save uploads them ==");
271
+ {
272
+ const cloneG = () => JSON.parse(JSON.stringify(good));
273
+ const W = (r, re) => r.warnings.filter((w) => re.test(w));
274
+ const B64 = /INLINE BASE64 IMAGE/;
275
+ const PNG_B64 = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==";
276
+ // specials.src on an image-block → warning naming the key; NOT blocking, because
277
+ // blocking would stop the very save whose rehost pass uploads the image.
278
+ const srcPage = cloneG();
279
+ srcPage.page[0].children.push({
280
+ id: "img1", type: "image-block",
281
+ properties: { name: "Hero", movable: true, sync: true },
282
+ responsive: {
283
+ desktop: { config: {}, styles: { top: 40, left: 80, width: 320, height: 200 } },
284
+ mobile: { config: {}, styles: { top: 40, left: 20, width: 380, height: 200 } },
285
+ },
286
+ specials: { src: PNG_B64 }, runtime: {}, events: [],
287
+ });
288
+ const srcR = validatePage(srcPage);
289
+ check("base64: specials.src data: URI warns", W(srcR, B64).length === 1, srcR.warnings);
290
+ check("base64: warning does NOT block the save (rehost fixes it)", srcR.valid, srcR.errors);
291
+ check("base64: warning names the key and says no manual fix is needed", /specials\.src/.test(W(srcR, B64)[0] ?? "") && /NO manual fix/.test(W(srcR, B64)[0] ?? ""), W(srcR, B64));
292
+ // url(data:…;base64,…) inside a per-breakpoint background → same warning.
293
+ const bgPage = cloneG();
294
+ bgPage.page[0].responsive.desktop.styles.background = `center center/ cover no-repeat scroll content-box url(${PNG_B64}) border-box`;
295
+ const bgR = validatePage(bgPage);
296
+ check("base64: url(data:…base64) background warns at the right key", W(bgR, B64).length === 1 && /responsive\.desktop\.styles\.background/.test(W(bgR, B64)[0] ?? ""), bgR.warnings);
297
+ // a popup (separate top-level array) is covered by the same walk.
298
+ const popPage = cloneG();
299
+ popPage.page[1].specials.src = PNG_B64;
300
+ check("base64: popups are covered too", W(validatePage(popPage), B64).length === 1, validatePage(popPage).warnings);
301
+ // page settings (favicon / og image / a base64 background inside extra_css).
302
+ const setPage = cloneG();
303
+ setPage.settings.favicon = PNG_B64;
304
+ check("base64: settings.favicon data: URI warns", W(validatePage(setPage), B64).length === 1, validatePage(setPage).warnings);
305
+ const cssPage = cloneG();
306
+ cssPage.settings.extra_css = `#w-btn1{background-image:url("${PNG_B64}")}`;
307
+ check("base64: base64 image inside settings.extra_css flagged", W(validatePage(cssPage), B64).length === 1, validatePage(cssPage).warnings);
308
+ // NOT flagged: a non-base64 data: URI (tiny inline svg, inline by design).
309
+ const svgPage = cloneG();
310
+ svgPage.page[0].responsive.desktop.styles.background = 'url("data:image/svg+xml;utf8,%3Csvg%3E%3C/svg%3E")';
311
+ check("base64: non-base64 data:image/svg+xml;utf8 URI is NOT flagged", W(validatePage(svgPage), B64).length === 0, validatePage(svgPage).warnings);
312
+ // NOT flagged: bare base64 payloads that are not image URIs (spin-wheel prize list).
313
+ const prizePage = cloneG();
314
+ prizePage.page[0].children[0].specials.prizes = [Buffer.from("Mất lượt|Mất lượt|0%", "utf8").toString("base64")];
315
+ check("base64: a bare base64 string (not a data:image URI) is NOT flagged", W(validatePage(prizePage), B64).length === 0, validatePage(prizePage).warnings);
316
+ // a clean page never trips the rule.
317
+ check("base64: clean page has no base64 warning", W(validatePage(cloneG()), B64).length === 0, validatePage(cloneG()).warnings);
318
+ }
269
319
  console.log("== validate: icon rendering (svg-mask needs background; font-class route is clean) ==");
270
320
  {
271
321
  const cloneG = () => JSON.parse(JSON.stringify(good));
@@ -1867,6 +1917,54 @@ console.log("== rehost: external-image URL collect + rewrite (pure, offline) =="
1867
1917
  check("rehost: Stitch googleusercontent image (no ext) IS rehostable", isRehostableImageUrl("https://lh3.googleusercontent.com/aida/AP1WRLvcUnHkKPA0hJFi2Yx2"));
1868
1918
  check("rehost: Stitch aida-public image (no ext) IS rehostable", isRehostableImageUrl("https://lh3.googleusercontent.com/aida-public/AB6AXuAh6CcIJ1kE5WUS"));
1869
1919
  check("rehost: extensionless host-recognition is HOST-gated, not blanket", !isRehostableImageUrl("https://example.com/some/path-no-ext"));
1920
+ // ── inline base64 images: decoded + uploaded like any other image ──────────
1921
+ // (`isRehostableImageUrl` stays false for data: — base64 has its own predicate,
1922
+ // so the http-only checks above keep their meaning.)
1923
+ const PNG_PIXEL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
1924
+ check("rehost/base64: inline base64 image recognised", isInlineBase64Image(PNG_PIXEL));
1925
+ check("rehost/base64: with a charset param still recognised", isInlineBase64Image("data:image/jpeg;charset=utf-8;base64,/9j/4AAQSkZJRg=="));
1926
+ check("rehost/base64: non-base64 data: URI NOT recognised", !isInlineBase64Image("data:image/svg+xml;utf8,<svg></svg>"));
1927
+ check("rehost/base64: non-image data: URI NOT recognised", !isInlineBase64Image("data:text/html;base64,PGgxPmhpPC9oMT4="));
1928
+ check("rehost/base64: a plain http URL NOT recognised", !isInlineBase64Image("https://cdn.x/p.png"));
1929
+ check("rehost/base64: prose mentioning a data URI NOT recognised", !isInlineBase64Image("use data:image/png;base64,AAAA here"));
1930
+ const decoded = parseInlineBase64Image(PNG_PIXEL);
1931
+ check("rehost/base64: decodes to PNG bytes", decoded?.contentType === "image/png" && (decoded?.bytes.length ?? 0) > 60, decoded?.contentType);
1932
+ check("rehost/base64: decoded bytes carry the PNG magic number", decoded?.bytes.subarray(0, 4).toString("hex") === "89504e47", decoded?.bytes.subarray(0, 4).toString("hex"));
1933
+ check("rehost/base64: empty payload → null (nothing to upload)", parseInlineBase64Image("data:image/png;base64,") === null);
1934
+ check("rehost/base64: a non-base64 data: URI → null", parseInlineBase64Image("data:image/svg+xml;utf8,<svg/>") === null);
1935
+ // End-to-end on a source: collected as a candidate, then rewritten to the hosted URL.
1936
+ {
1937
+ const b64Src = {
1938
+ page: [{
1939
+ id: "S", type: "section",
1940
+ responsive: { desktop: { styles: { background: `center center/ cover no-repeat scroll content-box url(${PNG_PIXEL}) border-box` } } },
1941
+ children: [
1942
+ { id: "I", type: "image", specials: { src: PNG_PIXEL } }, // same bytes → dedupes with the bg
1943
+ { id: "SH", type: "shape", specials: { svg: "data:image/svg+xml;utf8,<svg></svg>" } }, // non-base64 → skip
1944
+ ],
1945
+ }],
1946
+ settings: { favicon: "data:image/gif;base64,R0lGODlhAQABAAAAACw=" },
1947
+ };
1948
+ const b64Urls = collectExternalImageUrls(b64Src);
1949
+ check("rehost/base64: collects the inline images (src + background dedup, + settings)", b64Urls.length === 2, b64Urls.map((u) => u.slice(0, 32)));
1950
+ check("rehost/base64: the non-base64 svg data: URI is NOT collected", !b64Urls.some((u) => u.includes("svg+xml")), b64Urls.map((u) => u.slice(0, 32)));
1951
+ const hostedMap = new Map(b64Urls.map((u, n) => [u, `https://statics.pancake.vn/x/hosted-${n}.png`]));
1952
+ const out = rewriteImageUrls(b64Src, hostedMap);
1953
+ check("rehost/base64: specials.src rewritten to the hosted URL", out.page[0].children[0].specials.src === hostedMap.get(PNG_PIXEL));
1954
+ check("rehost/base64: url(...) background rewritten too", out.page[0].responsive.desktop.styles.background.includes(hostedMap.get(PNG_PIXEL)) && !out.page[0].responsive.desktop.styles.background.includes("base64"));
1955
+ check("rehost/base64: settings favicon rewritten", !String(out.settings.favicon).startsWith("data:"));
1956
+ check("rehost/base64: the untouched svg data: URI survives", out.page[0].children[1].specials.svg === "data:image/svg+xml;utf8,<svg></svg>");
1957
+ check("rehost/base64: no base64 left anywhere in the rewritten source", !JSON.stringify(out).includes(";base64,"));
1958
+ }
1959
+ // The dedup cache key: a megabyte data URI must not become a megabyte Redis key.
1960
+ {
1961
+ const shortUrl = "https://cdn.x/p.png";
1962
+ const huge = "data:image/png;base64," + "A".repeat(5000);
1963
+ check("rehost/base64: a short URL keeps its readable cache key", rehostCacheKey(shortUrl, "public").endsWith(shortUrl));
1964
+ check("rehost/base64: an oversized value is hashed into the cache key", /sha256:[0-9a-f]{64}$/.test(rehostCacheKey(huge, "public")), rehostCacheKey(huge, "public"));
1965
+ check("rehost/base64: hashed keys stay scope-namespaced", rehostCacheKey(huge, "org:7") !== rehostCacheKey(huge, "public"));
1966
+ check("rehost/base64: the same payload hashes to the same key", rehostCacheKey(huge, "public") === rehostCacheKey(huge, "public"));
1967
+ }
1870
1968
  // Collect a Stitch-shaped source (image src with no extension) end-to-end.
1871
1969
  {
1872
1970
  const stitchSrc = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-landing-mcp",
3
- "version": "1.5.2",
3
+ "version": "1.5.3",
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",