webcake-landing-mcp 1.5.1 → 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,18 @@
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
+ },
9
+ {
10
+ "v": "1.5.2",
11
+ "d": "25/08/2026",
12
+ "type": "Fixed",
13
+ "en": "Colors written in any CSS notation other than the legacy rgba(r,g,b,a) form (hex, rgb(), hsl(), CSS named colors, and the modern space/slash rgba()…",
14
+ "vi": "Các giá trị màu được viết ở bất kỳ dạng CSS nào khác ngoài dạng rgba(r,g,b,a) kiểu cũ (hex, rgb(), hsl(), tên màu CSS, và cú pháp rgba() mới dùng…"
15
+ },
2
16
  {
3
17
  "v": "1.5.1",
4
18
  "d": "24/08/2026",
@@ -26,19 +40,5 @@
26
40
  "type": "Added",
27
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…",
28
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…"
29
- },
30
- {
31
- "v": "1.2.1",
32
- "d": "21/06/2026",
33
- "type": "Changed",
34
- "en": "Server instructions and get_generation_guide now restrict the visual-check step to three explicit triggers — cloning a reference to compare pixel…",
35
- "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 độ…"
36
- },
37
- {
38
- "v": "1.2.0",
39
- "d": "21/06/2026",
40
- "type": "Added",
41
- "en": "New layout tool computes exact top/left/width/height coordinates for both breakpoints so the model never hand-calculates left/top (the number-one…",
42
- "vi": "Tool mới layout tính toán chính xác tọa độ top/left/width/height cho cả hai breakpoint để model không cần tự tính left/top bằng tay (nguyên nhân…"
43
43
  }
44
44
  ]
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Colour canonicalisation — pure helpers, no I/O, no domain imports.
3
+ *
4
+ * WHY THIS EXISTS. The live renderer emits colour values RAW into CSS, so a hex
5
+ * / `rgb()` / `hsl()` / named colour renders perfectly on the published page.
6
+ * The Webcake EDITOR does not: its colour traits re-parse the stored string with
7
+ * hand-rolled parsers that only understand the legacy comma `rgba(r,g,b,a)`
8
+ * form, and every one of them falls back to BLACK when the parse fails.
9
+ * Confirmed read paths in landing_page_backend/assets/editor:
10
+ *
11
+ * • common.js:128 `parseBackground` scans for `url|linear-gradient|
12
+ * radial-gradient|rgba` ONLY. Note the literal "rgba" — `rgb(...)` without
13
+ * the `a` does not match either.
14
+ * • traits/Background.vue:163 — for a text-block the background trait shows
15
+ * `styles.color` (that IS the text-colour swatch: traitGroup.js:2 gives
16
+ * text-block no 'color' trait). Background.vue:264-277 `makeBg()` runs the
17
+ * value through `parseBackground` and, when nothing parses, pushes a
18
+ * hard-coded `rgba(0, 0, 0, 1)` for a text-block.
19
+ * • traits/Colour.vue:62-67 (button / form / countdown / …) — falls back to
20
+ * `'#000000'` when `mixStyle.color` is falsy, and common.js:186
21
+ * `rgbaToArray` does a blind `rgba.substr(5)`, so anything not starting with
22
+ * `rgba(` decodes to NaN.
23
+ *
24
+ * So an MCP-authored page whose text colour is `#ffffff` renders white but shows
25
+ * BLACK in the editor's trait panel — and the moment a user touches that trait
26
+ * the black is written back and the page really does turn black.
27
+ *
28
+ * Fix: canonicalise every colour-bearing style/config value to `rgba(r,g,b,a)`
29
+ * during expand (see normalizeColors in ./index.ts). Values already in the
30
+ * legacy comma-`rgba()` form are returned BYTE-IDENTICAL so a save never
31
+ * rewrites colours the editor itself wrote.
32
+ */
33
+ /** Split a CSS value into top-level (paren-aware) comma-separated layers. */
34
+ export function splitCssLayers(value) {
35
+ const layers = [];
36
+ let depth = 0;
37
+ let cur = "";
38
+ for (const ch of value) {
39
+ if (ch === "(")
40
+ depth++;
41
+ else if (ch === ")")
42
+ depth--;
43
+ if (ch === "," && depth === 0) {
44
+ layers.push(cur);
45
+ cur = "";
46
+ }
47
+ else
48
+ cur += ch;
49
+ }
50
+ layers.push(cur);
51
+ return layers.map((l) => l.trim()).filter((l) => l !== "");
52
+ }
53
+ /**
54
+ * The exact shape the editor's parsers can read: `rgba(` + three comma-separated
55
+ * numbers + an alpha. A value matching this is left untouched.
56
+ * (`rgb(…)`, the modern `rgba(255 255 255 / .5)` space syntax, hex, hsl and
57
+ * named colours all FAIL the editor's parsers and must be converted.)
58
+ */
59
+ export const EDITOR_RGBA = /^rgba\(\s*-?[\d.]+\s*,\s*-?[\d.]+\s*,\s*-?[\d.]+\s*,\s*-?[\d.]+%?\s*\)$/i;
60
+ /**
61
+ * CSS-wide keywords and `transparent` — never rewritten. `transparent` in
62
+ * particular is load-bearing: the editor's Colour trait pairs it with
63
+ * `config.colorHidden` to implement its hide/show toggle (Colour.vue:126-133),
64
+ * so converting it to `rgba(0,0,0,0)` would break that toggle.
65
+ */
66
+ const PASS_THROUGH = new Set([
67
+ "transparent", "inherit", "currentcolor", "initial", "unset", "revert", "none", "auto",
68
+ ]);
69
+ /** The CSS named colours a model realistically writes. */
70
+ const NAMED_COLORS = {
71
+ black: [0, 0, 0], white: [255, 255, 255], red: [255, 0, 0], lime: [0, 255, 0],
72
+ blue: [0, 0, 255], yellow: [255, 255, 0], cyan: [0, 255, 255], aqua: [0, 255, 255],
73
+ magenta: [255, 0, 255], fuchsia: [255, 0, 255], silver: [192, 192, 192],
74
+ gray: [128, 128, 128], grey: [128, 128, 128], maroon: [128, 0, 0], olive: [128, 128, 0],
75
+ green: [0, 128, 0], purple: [128, 0, 128], teal: [0, 128, 128], navy: [0, 0, 128],
76
+ orange: [255, 165, 0], gold: [255, 215, 0], pink: [255, 192, 203],
77
+ hotpink: [255, 105, 180], crimson: [220, 20, 60], salmon: [250, 128, 114],
78
+ coral: [255, 127, 80], tomato: [255, 99, 71], orangered: [255, 69, 0],
79
+ brown: [165, 42, 42], chocolate: [210, 105, 30], tan: [210, 180, 140],
80
+ beige: [245, 245, 220], ivory: [255, 255, 240], khaki: [240, 230, 140],
81
+ indigo: [75, 0, 130], violet: [238, 130, 238], orchid: [218, 112, 214],
82
+ plum: [221, 160, 221], lavender: [230, 230, 250], turquoise: [64, 224, 208],
83
+ skyblue: [135, 206, 235], steelblue: [70, 130, 180], royalblue: [65, 105, 225],
84
+ dodgerblue: [30, 144, 255], midnightblue: [25, 25, 112], seagreen: [46, 139, 87],
85
+ forestgreen: [34, 139, 34], darkgreen: [0, 100, 0], darkred: [139, 0, 0],
86
+ darkblue: [0, 0, 139], darkgray: [169, 169, 169], darkgrey: [169, 169, 169],
87
+ lightgray: [211, 211, 211], lightgrey: [211, 211, 211], dimgray: [105, 105, 105],
88
+ dimgrey: [105, 105, 105], slategray: [112, 128, 144], slategrey: [112, 128, 144],
89
+ whitesmoke: [245, 245, 245], snow: [255, 250, 250], linen: [250, 240, 230],
90
+ wheat: [245, 222, 179], mintcream: [245, 255, 250], aliceblue: [240, 248, 255],
91
+ ghostwhite: [248, 248, 255],
92
+ };
93
+ const clampByte = (n) => Math.max(0, Math.min(255, Math.round(n)));
94
+ /** 1 → "1", 0.5 → "0.5", 0.88 → "0.88" (never "1.000" — matches the guide's shape). */
95
+ function fmtAlpha(a) {
96
+ if (!Number.isFinite(a))
97
+ return "1";
98
+ const clamped = Math.max(0, Math.min(1, a));
99
+ const s = clamped.toFixed(3).replace(/0+$/, "").replace(/\.$/, "");
100
+ return s === "" ? "0" : s;
101
+ }
102
+ const rgba = (r, g, b, a) => `rgba(${clampByte(r)},${clampByte(g)},${clampByte(b)},${fmtAlpha(a)})`;
103
+ /** One channel of an rgb() function: a 0–255 number or a percentage. */
104
+ function channel(tok) {
105
+ return tok.endsWith("%") ? (parseFloat(tok) / 100) * 255 : parseFloat(tok);
106
+ }
107
+ /** An rgb()/hsl() alpha argument: a 0–1 number or a percentage. */
108
+ function alphaArg(tok) {
109
+ if (tok === undefined)
110
+ return 1;
111
+ return tok.endsWith("%") ? parseFloat(tok) / 100 : parseFloat(tok);
112
+ }
113
+ /** hsl → rgb (h in degrees, s/l as 0–1 fractions). */
114
+ function hslToRgb(h, s, l) {
115
+ const hh = (((h % 360) + 360) % 360) / 60;
116
+ const c = (1 - Math.abs(2 * l - 1)) * s;
117
+ const x = c * (1 - Math.abs((hh % 2) - 1));
118
+ const m = l - c / 2;
119
+ const [r, g, b] = hh < 1 ? [c, x, 0] : hh < 2 ? [x, c, 0] : hh < 3 ? [0, c, x]
120
+ : hh < 4 ? [0, x, c] : hh < 5 ? [x, 0, c] : [c, 0, x];
121
+ return [(r + m) * 255, (g + m) * 255, (b + m) * 255];
122
+ }
123
+ /**
124
+ * Canonicalise ONE colour token to the editor-readable `rgba(r,g,b,a)`.
125
+ * Returns null when the token needs no change (already editor-readable, a
126
+ * CSS-wide keyword) or cannot be understood (`var(--x)`, `color-mix(…)`, …) —
127
+ * so a caller can leave the original string exactly as it found it.
128
+ */
129
+ export function toRgba(input) {
130
+ const s = input.trim();
131
+ if (s === "")
132
+ return null;
133
+ if (EDITOR_RGBA.test(s))
134
+ return null; // already the shape every editor trait parses
135
+ const lower = s.toLowerCase();
136
+ if (PASS_THROUGH.has(lower))
137
+ return null;
138
+ const hex = lower.match(/^#([0-9a-f]+)$/);
139
+ if (hex) {
140
+ const h = hex[1];
141
+ const dup = (c) => parseInt(c + c, 16);
142
+ const pair = (i) => parseInt(h.slice(i, i + 2), 16);
143
+ if (h.length === 3)
144
+ return rgba(dup(h[0]), dup(h[1]), dup(h[2]), 1);
145
+ if (h.length === 4)
146
+ return rgba(dup(h[0]), dup(h[1]), dup(h[2]), dup(h[3]) / 255);
147
+ if (h.length === 6)
148
+ return rgba(pair(0), pair(2), pair(4), 1);
149
+ if (h.length === 8)
150
+ return rgba(pair(0), pair(2), pair(4), pair(6) / 255);
151
+ return null; // 5/7-digit hex is not valid CSS — leave it for the validator to flag
152
+ }
153
+ // rgb()/rgba() in ANY separator syntax (comma, space, slash alpha).
154
+ const rgbFn = lower.match(/^rgba?\(([^()]*)\)$/);
155
+ if (rgbFn) {
156
+ const parts = rgbFn[1].split(/[\s,/]+/).filter((p) => p !== "");
157
+ if (parts.length < 3)
158
+ return null;
159
+ const [r, g, b] = [channel(parts[0]), channel(parts[1]), channel(parts[2])];
160
+ const a = alphaArg(parts[3]);
161
+ if (![r, g, b, a].every(Number.isFinite))
162
+ return null;
163
+ return rgba(r, g, b, a);
164
+ }
165
+ const hslFn = lower.match(/^hsla?\(([^()]*)\)$/);
166
+ if (hslFn) {
167
+ const parts = hslFn[1].split(/[\s,/]+/).filter((p) => p !== "");
168
+ if (parts.length < 3)
169
+ return null;
170
+ const h = parseFloat(parts[0]); // deg/turn/rad suffixes: parseFloat takes the number, deg is the default
171
+ const sat = parseFloat(parts[1]) / 100;
172
+ const lig = parseFloat(parts[2]) / 100;
173
+ const a = alphaArg(parts[3]);
174
+ if (![h, sat, lig, a].every(Number.isFinite))
175
+ return null;
176
+ const [r, g, b] = hslToRgb(h, Math.max(0, Math.min(1, sat)), Math.max(0, Math.min(1, lig)));
177
+ return rgba(r, g, b, a);
178
+ }
179
+ const named = NAMED_COLORS[lower];
180
+ return named ? rgba(named[0], named[1], named[2], 1) : null;
181
+ }
182
+ /**
183
+ * Colour tokens INSIDE a gradient layer. The bare-word alternative catches named
184
+ * colours (`linear-gradient(180deg, white 0%, black 100%)`); gradient keywords
185
+ * (`to`, `deg`, `circle`, `closest-side`…) are not colour names, so toRgba
186
+ * returns null for them and they pass through untouched.
187
+ */
188
+ const COLOR_TOKEN = /#[0-9a-fA-F]+\b|\b(?:rgba?|hsla?)\([^()]*\)|\b[a-zA-Z]{3,20}\b/g;
189
+ /**
190
+ * Canonicalise every colour in a full style value — a bare colour, a
191
+ * comma-separated layer list, or the colour stops inside a gradient. `url()`
192
+ * layers are left alone (normalizeBackgrounds in ./index.ts owns those).
193
+ * Returns the ORIGINAL string when nothing needed changing, so the pass is
194
+ * byte-stable and idempotent.
195
+ */
196
+ export function normalizeColorValue(value) {
197
+ if (typeof value !== "string" || value.trim() === "")
198
+ return value;
199
+ let changed = false;
200
+ const out = splitCssLayers(value).map((layer) => {
201
+ if (layer.includes("url("))
202
+ return layer;
203
+ const next = /gradient\(/i.test(layer)
204
+ ? layer.replace(COLOR_TOKEN, (tok) => toRgba(tok) ?? tok)
205
+ : toRgba(layer) ?? layer;
206
+ if (next !== layer)
207
+ changed = true;
208
+ return next;
209
+ });
210
+ return changed ? out.join(", ") : value;
211
+ }
212
+ /**
213
+ * True for a style/config key whose value is a colour the editor's traits parse:
214
+ * anything named `*color*` (color, borderColor, colorBtn, iconColor, …) plus the
215
+ * three colour-bearing keys that are not spelled "color".
216
+ */
217
+ export function isColorKey(key) {
218
+ return /colou?r/i.test(key) || key === "background" || key === "backgroundTxt" || key === "overlay";
219
+ }
220
+ /**
221
+ * True when a colour value will render BLACK in the editor's trait panel: not a
222
+ * gradient/url, not a CSS-wide keyword, and not the legacy `rgba()` form the
223
+ * traits can parse. Used by the validator to warn about the values
224
+ * normalizeColorValue could not rescue (`var(--x)`, `color-mix(…)`, …).
225
+ */
226
+ export function isUnparseableByEditor(value) {
227
+ if (typeof value !== "string")
228
+ return false;
229
+ const s = value.trim();
230
+ if (s === "" || PASS_THROUGH.has(s.toLowerCase()))
231
+ return false;
232
+ if (s.includes("url(") || /gradient\(/i.test(s))
233
+ return false;
234
+ return splitCssLayers(s).some((layer) => !EDITOR_RGBA.test(layer));
235
+ }
@@ -113,8 +113,8 @@ SECTION BUILD HINTS (apply to whichever sections the chosen archetype uses)
113
113
 
114
114
  RULES
115
115
  - Visible content goes in "specials" (text-block.specials.text, image-block.specials.src…), NEVER in "styles".
116
- - Colors as rgba(r,g,b,a). 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.
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. 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.
@@ -9,6 +9,7 @@ import { autofixLayout } from "./autofix-layout.js";
9
9
  import { computeLayout } from "./layout.js";
10
10
  import { expandSource } from "../../core/expand.js";
11
11
  import { compactSource } from "../../core/compact.js";
12
+ import { splitCssLayers, normalizeColorValue, isColorKey } from "./color.js";
12
13
  /** The payload returned by the get_generation_guide tool. */
13
14
  export const guidePayload = {
14
15
  guide: GENERATION_GUIDE,
@@ -135,26 +136,6 @@ function normalizeBorderRadius(node) {
135
136
  // preserving the url. Deterministic + idempotent, so expand(compact(x)) ==
136
137
  // expand(x) still holds.
137
138
  // ---------------------------------------------------------------------------
138
- /** Split a CSS background value into top-level comma-separated layers. */
139
- function splitBackgroundLayers(bg) {
140
- const layers = [];
141
- let depth = 0;
142
- let cur = "";
143
- for (const ch of bg) {
144
- if (ch === "(")
145
- depth++;
146
- else if (ch === ")")
147
- depth--;
148
- if (ch === "," && depth === 0) {
149
- layers.push(cur);
150
- cur = "";
151
- }
152
- else
153
- cur += ch;
154
- }
155
- layers.push(cur);
156
- return layers.map((l) => l.trim()).filter((l) => l !== "");
157
- }
158
139
  /** The editor-canonical url() layer shape its splitBackground() can re-parse. */
159
140
  const CANONICAL_URL_LAYER = /^(left|center|right) (top|center|bottom)\/ (cover|contain|auto|[\d.]+(?:px|%)(?: [\d.]+(?:px|%))?) (no-repeat|repeat|repeat-x|repeat-y|space|round)(?: (scroll|fixed|local))? content-box url\(.+\)(?: border-box)?$/;
160
141
  /** Normalise one styles.background value (returns the input when no url layer). */
@@ -162,7 +143,7 @@ function normalizeBackgroundValue(bg) {
162
143
  if (typeof bg !== "string" || !bg.includes("url("))
163
144
  return bg;
164
145
  const out = [];
165
- for (const layer of splitBackgroundLayers(bg)) {
146
+ for (const layer of splitCssLayers(bg)) {
166
147
  const url = layer.match(/url\((['"]?)(.*?)\1\)/);
167
148
  if (url) {
168
149
  out.push(CANONICAL_URL_LAYER.test(layer) ? layer : imgBackground(url[2]));
@@ -191,6 +172,80 @@ function normalizeBackgrounds(node) {
191
172
  }
192
173
  }
193
174
  // ---------------------------------------------------------------------------
175
+ // colour normalization: the live renderer emits colours RAW into CSS, so a hex
176
+ // / rgb() / hsl() / named colour renders perfectly on the published page — but
177
+ // the EDITOR's colour traits re-parse the stored string and every one of them
178
+ // falls back to BLACK when the parse fails. `parseBackground` (common.js:128)
179
+ // scans for `url|linear-gradient|radial-gradient|rgba` only — note the literal
180
+ // "rgba", so even `rgb(...)` misses — and for a text-block the colour swatch IS
181
+ // that background trait (Background.vue:163 returns styles.color; :272-273
182
+ // pushes a hard-coded rgba(0,0,0,1) when nothing parsed). Colour.vue:67 does the
183
+ // same with '#000000' for button/form/countdown. So `color:"#ffffff"` renders
184
+ // white but shows BLACK in the trait panel — and one touch of that trait writes
185
+ // the black back and the page really does turn black.
186
+ //
187
+ // Second half of the same bug: the traits read `el.responsive[display].styles`
188
+ // (Trait.vue:377-380) with NO desktop→mobile fallback, so a colour set on one
189
+ // breakpoint only shows black on the other — and renders black there too, since
190
+ // the breakpoint's CSS simply carries no colour declaration.
191
+ //
192
+ // Fix: after every expand pass, (a) canonicalise every colour-bearing style/
193
+ // config value to the legacy `rgba(r,g,b,a)` form every editor parser reads, and
194
+ // (b) mirror a foreground colour set on exactly one breakpoint onto the other.
195
+ // Values already in that form are returned byte-identical, so a save never
196
+ // rewrites the colours the editor itself wrote. Deterministic + idempotent, so
197
+ // the expand(compact(x)) == expand(x) invariant holds.
198
+ // ---------------------------------------------------------------------------
199
+ /**
200
+ * Colour keys copied to the other breakpoint when only one carries them.
201
+ * Deliberately a short ALLOWLIST, not every `*color*` key: mirroring
202
+ * `-webkitTextFillColor:transparent` (the gradient-text-fill recipe) without the
203
+ * gradient `background` that goes with it would render the mobile text
204
+ * invisible, and a url()-bearing `background` can legitimately differ per
205
+ * breakpoint. These three are inert when the other breakpoint has no matching
206
+ * geometry (a borderColor with borderWidth:0 paints nothing).
207
+ */
208
+ const MIRRORED_COLOR_KEYS = ["color", "backgroundTxt", "borderColor"];
209
+ /** Walk a tree node and canonicalise + mirror every colour in-place (mutates). */
210
+ function normalizeColors(node) {
211
+ if (!node || typeof node !== "object")
212
+ return;
213
+ for (const bp of ["desktop", "mobile"]) {
214
+ const rbp = node.responsive?.[bp];
215
+ if (!rbp || typeof rbp !== "object")
216
+ continue;
217
+ for (const bag of [rbp.styles, rbp.config]) {
218
+ if (!bag || typeof bag !== "object")
219
+ continue;
220
+ for (const key of Object.keys(bag)) {
221
+ if (!isColorKey(key))
222
+ continue;
223
+ const fixed = normalizeColorValue(bag[key]);
224
+ if (fixed !== bag[key])
225
+ bag[key] = fixed;
226
+ }
227
+ }
228
+ }
229
+ const desktop = node.responsive?.desktop?.styles;
230
+ const mobile = node.responsive?.mobile?.styles;
231
+ if (desktop && typeof desktop === "object" && mobile && typeof mobile === "object") {
232
+ for (const key of MIRRORED_COLOR_KEYS) {
233
+ const dv = desktop[key];
234
+ const mv = mobile[key];
235
+ const dHas = typeof dv === "string" && dv.trim() !== "";
236
+ const mHas = typeof mv === "string" && mv.trim() !== "";
237
+ if (dHas && !mHas)
238
+ mobile[key] = dv;
239
+ else if (mHas && !dHas)
240
+ desktop[key] = mv;
241
+ }
242
+ }
243
+ if (Array.isArray(node.children)) {
244
+ for (const child of node.children)
245
+ normalizeColors(child);
246
+ }
247
+ }
248
+ // ---------------------------------------------------------------------------
194
249
  // misplaced-animation normalization: models regularly emit the animation object
195
250
  // directly under responsive.<bp> instead of responsive.<bp>.config.animation —
196
251
  // the single most common "must NOT have additional properties" schema error,
@@ -335,6 +390,10 @@ function normalizeSource(source) {
335
390
  normalizeMisplacedAnimation(node);
336
391
  normalizeImageBlocks(node);
337
392
  normalizeBorderRadius(node);
393
+ // Colours BEFORE backgrounds: the colour pass rewrites gradient stops
394
+ // and bare-colour layers, the background pass then canonicalises any
395
+ // url() layer in the same value.
396
+ normalizeColors(node);
338
397
  normalizeBackgrounds(node);
339
398
  }
340
399
  }
@@ -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.
@@ -103,9 +103,9 @@
103
103
  "height": { "type": ["number", "string", "null"], "description": "px. Section uses this as canvas height. EMIT A NUMBER (the editor saves the string \"600\" when the height box loses focus — normalized on read)." },
104
104
  "position": { "type": "string", "enum": ["absolute", "relative", "fixed", "static"] },
105
105
  "zIndex": { "type": ["number", "string", "null"], "description": "Stacking order. EMIT A NUMBER; numeric strings are normalized." },
106
- "background": { "type": "string", "description": "rgba(...) or gradient." },
106
+ "background": { "type": "string", "description": "rgba(...) or gradient. MUST use the rgba() form for every color/gradient stop — the editor's background trait only parses url()/linear-gradient()/radial-gradient()/rgba(), and falls back to BLACK on anything else (hex, rgb(), hsl(), named). Auto-converted on save; unconvertible values are warned." },
107
107
  "backgroundImage": { "type": "string", "description": "url(...)." },
108
- "color": { "type": "string", "description": "rgba(r,g,b,a)." },
108
+ "color": { "type": "string", "description": "rgba(r,g,b,a). Set it on BOTH breakpoints: the editor trait reads one breakpoint at a time with no desktop\u2192mobile fallback, and a missing/unparseable color shows (and saves back) BLACK. hex/rgb()/hsl()/named are auto-converted to rgba() on save, and a color present on only one breakpoint is mirrored onto the other." },
109
109
  "fontSize": { "type": ["number", "string"], "description": "px. EMIT A NUMBER. Read-tolerance: the editor saves \"59\" (inline text-resize box) and \"13px\" (element pickers); the renderer parseInt()s it, and the MCP normalizes both back to a number." },
110
110
  "fontFamily": { "type": "string" },
111
111
  "fontWeight": { "type": ["string", "number"] },
@@ -117,7 +117,7 @@
117
117
  "textDecoration": { "type": "string" },
118
118
  "borderStyle": { "type": "string", "enum": ["none", "solid", "dashed", "dotted", "double", "groove", "ridge", "inset", "outset", "hidden"], "description": "The full set the editor's border-style dropdown offers (Border.vue)." },
119
119
  "borderWidth": { "type": ["number", "string"], "description": "px. EMIT A NUMBER. Read-tolerance: the editor saves \"1\" / \"0px\"; parseInt()ed by the renderer and normalized here." },
120
- "borderColor": { "type": "string" },
120
+ "borderColor": { "type": "string", "description": "rgba(r,g,b,a) \u2014 same editor-parse rule as styles.color (auto-converted + mirrored across breakpoints on save)." },
121
121
  "borderRadius": { "type": ["string", "number"] },
122
122
  "boxShadow": { "type": "string" },
123
123
  "opacity": { "type": ["number", "string"], "description": "0–1. EMIT A NUMBER. Read-tolerance: the editor Layer trait saves a percent string (\"50%\") — normalized to the 0–1 fraction (0.5) before validation." },
@@ -145,7 +145,7 @@
145
145
  }
146
146
  },
147
147
  "virtualHeight": { "type": ["number", "string", "null"] },
148
- "overlay": { "type": "string", "description": "Overlay color rgba(...)." },
148
+ "overlay": { "type": "string", "description": "Overlay color rgba(...) \u2014 the editor's overlay trait only parses the rgba() form; other notations are auto-converted on save." },
149
149
  "hiddenEffect": { "type": "object", "additionalProperties": true },
150
150
  "svgMask": { "type": "string" },
151
151
  "lock": { "type": "boolean" },
@@ -11,6 +11,7 @@ import Ajv2020Module from "ajv/dist/2020.js";
11
11
  import { CONTAINER_TYPES, FIELD_TYPES } from "./elements/index.js";
12
12
  import { ANIMATABLE_TYPES, ANIMATION_NAMES } from "./vocab.js";
13
13
  import { estTextHeightPx, measureTextBlock } from "./text-metrics.js";
14
+ import { isColorKey, isUnparseableByEditor } from "./color.js";
14
15
  // ajv ships as CJS; under Node16 ESM the constructor is on `.default`.
15
16
  const Ajv2020 = Ajv2020Module.default ?? Ajv2020Module;
16
17
  // Loaded at runtime (the build copies this JSON beside the compiled validator)
@@ -125,6 +126,49 @@ function isVividColor(v) {
125
126
  return false; // fully transparent
126
127
  return Math.max(r, g, b) - Math.min(r, g, b) >= 16; // channel spread ⇒ has hue
127
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
+ }
128
172
  /** Accept an object or a JSON string. Returns the parsed page or throws. */
129
173
  export function coercePage(input) {
130
174
  if (typeof input === "string")
@@ -328,6 +372,21 @@ export function validatePage(input) {
328
372
  }
329
373
  }
330
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
+ }
331
390
  // animation contract — checked per breakpoint
332
391
  // Source: landing_page_build/render/build/animate.js (animatable type list)
333
392
  // landing_page_backend/assets/editor/main/traits/TraitAnimation.vue (name set)
@@ -395,6 +454,28 @@ export function validatePage(input) {
395
454
  `for a muted color use rgba() alpha on the color/background property instead.`);
396
455
  }
397
456
  }
457
+ // A colour the editor's traits cannot parse shows BLACK in the trait panel
458
+ // (Background.vue:272-273 / Colour.vue:67) even though the published page
459
+ // renders it correctly — and one touch of that trait writes the black back.
460
+ // landingDomain.expand already converts hex / rgb() / hsl() / named colours,
461
+ // so this fires only for values it could not rescue (var(--x), color-mix(…)).
462
+ for (const bp of ["desktop", "mobile"]) {
463
+ const rbp = node.responsive?.[bp];
464
+ if (!rbp || typeof rbp !== "object")
465
+ continue;
466
+ for (const [bag, label] of [[rbp.styles, "styles"], [rbp.config, "config"]]) {
467
+ if (!bag || typeof bag !== "object")
468
+ continue;
469
+ for (const key of Object.keys(bag)) {
470
+ if (!isColorKey(key) || !isUnparseableByEditor(bag[key]))
471
+ continue;
472
+ warnings.push(`${path} (${type ?? "?"}) [${bp}]: ${label}.${key}="${bag[key]}" is not an rgba() colour — ` +
473
+ `the editor's colour trait cannot parse it and shows BLACK, then writes that black back the first ` +
474
+ `time a user touches the trait. Use rgba(r,g,b,a): ` +
475
+ `patch_page({op:'update',id:'${node.id ?? "?"}',${label}:{${bp}:{${key}:'rgba(…)'}}}).`);
476
+ }
477
+ }
478
+ }
398
479
  // countdown.language must be a key the renderer's lang table knows (or 'custom');
399
480
  // anything else (e.g. a locale code "vi"/"en") crashes the renderer with
400
481
  // "is not iterable" when it destructures lang[language].
@@ -1175,6 +1256,14 @@ export function validatePage(input) {
1175
1256
  }
1176
1257
  }
1177
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
+ }
1178
1267
  return {
1179
1268
  valid: errors.length === 0,
1180
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));
@@ -1535,6 +1585,87 @@ console.log("== background normalization: url() layers canonicalised to the edit
1535
1585
  const reexpanded = landingDomain.expand(landingDomain.compact(expanded));
1536
1586
  check("background round-trip: expand(compact(expand(x))) deep-equals expand(x)", deepEq(reexpanded, expanded));
1537
1587
  }
1588
+ console.log("== colour normalization: editor-readable rgba() + breakpoint mirroring ==");
1589
+ {
1590
+ // A text-block is the sharpest case: traitGroup.js gives it no 'color' trait,
1591
+ // so its colour swatch IS the background trait, which falls back to a
1592
+ // hard-coded rgba(0,0,0,1) whenever parseBackground() finds nothing.
1593
+ const mkColorPage = (desktop, mobile = desktop) => ({
1594
+ page: [
1595
+ {
1596
+ id: "c_sec", type: "section",
1597
+ responsive: {
1598
+ desktop: { styles: { height: 400 } },
1599
+ mobile: { styles: { height: 400 } },
1600
+ },
1601
+ children: [
1602
+ {
1603
+ id: "c_txt", type: "text-block",
1604
+ responsive: {
1605
+ desktop: { styles: { top: 10, left: 10, width: 200, height: 40, ...desktop } },
1606
+ mobile: { styles: { top: 10, left: 10, width: 200, height: 40, ...mobile } },
1607
+ },
1608
+ specials: { text: "hello", tag: "p" },
1609
+ },
1610
+ ],
1611
+ },
1612
+ ],
1613
+ settings: { title: "t", description: "d", keywords: "k", lang: "vi" },
1614
+ });
1615
+ const txtOf = (src, bp = "desktop") => src.page[0].children[0].responsive[bp].styles;
1616
+ // 1) every notation a model realistically writes → the one form the editor parses
1617
+ const cases = [
1618
+ ["hex #ffffff", "#ffffff", "rgba(255,255,255,1)"],
1619
+ ["short hex #fff", "#fff", "rgba(255,255,255,1)"],
1620
+ ["hex+alpha #ffffff80", "#ffffff80", "rgba(255,255,255,0.502)"],
1621
+ ["rgb() (no 'a' → parseBackground misses it)", "rgb(246,4,87)", "rgba(246,4,87,1)"],
1622
+ ["modern slash syntax", "rgba(255 255 255 / 0.5)", "rgba(255,255,255,0.5)"],
1623
+ ["hsl()", "hsl(340,96%,49%)", "rgba(245,5,85,1)"],
1624
+ ["named colour", "white", "rgba(255,255,255,1)"],
1625
+ ];
1626
+ for (const [label, input, want] of cases) {
1627
+ const got = txtOf(landingDomain.expand(mkColorPage({ color: input }))).color;
1628
+ check(`colour: ${label} → ${want}`, got === want, got);
1629
+ }
1630
+ // 2) values the editor ALREADY reads are returned byte-identical (no save churn)
1631
+ for (const untouched of ["rgba(26,32,44,1)", "rgba(0, 0, 0, 1)", "rgba(13,45,58,0.88)", "transparent"]) {
1632
+ const got = txtOf(landingDomain.expand(mkColorPage({ color: untouched }))).color;
1633
+ check(`colour: "${untouched}" left byte-identical`, got === untouched, got);
1634
+ }
1635
+ // 3) idempotent — a second expand changes nothing
1636
+ const once = landingDomain.expand(mkColorPage({ color: "#1a202c" }));
1637
+ const twice = landingDomain.expand(structuredClone(once));
1638
+ check("colour: normalization is idempotent", deepEq(twice, once));
1639
+ // 4) gradient stops are rewritten in place; an all-rgba gradient is untouched
1640
+ const hexGrad = landingDomain.expand(mkColorPage({ background: "linear-gradient(180deg, #fff 0%, #000 100%)" }));
1641
+ check("colour: hex gradient stops → rgba()", txtOf(hexGrad).background === "linear-gradient(180deg, rgba(255,255,255,1) 0%, rgba(0,0,0,1) 100%)", txtOf(hexGrad).background);
1642
+ const rgbaGrad = "linear-gradient(160deg, rgba(13,45,58,0.88) 0%, rgba(10,124,110,0.75) 100%)";
1643
+ check("colour: all-rgba gradient untouched", txtOf(landingDomain.expand(mkColorPage({ background: rgbaGrad }))).background === rgbaGrad);
1644
+ // 5) a url() background layer is left for normalizeBackgrounds (no colour rewrite)
1645
+ const urlBg = landingDomain.expand(mkColorPage({ background: "url(https://x.test/a.jpg) center/cover no-repeat" }));
1646
+ check("colour: url() layer untouched by the colour pass", txtOf(urlBg).background.includes("url(https://x.test/a.jpg)"), txtOf(urlBg).background);
1647
+ // 6) a colour on ONE breakpoint is mirrored onto the other — the trait panel
1648
+ // reads one breakpoint at a time and shows black for the missing one.
1649
+ const dOnly = landingDomain.expand(mkColorPage({ color: "rgba(26,32,44,1)" }, {}));
1650
+ check("colour: desktop-only color mirrored to mobile", txtOf(dOnly, "mobile").color === "rgba(26,32,44,1)", txtOf(dOnly, "mobile"));
1651
+ const mOnly = landingDomain.expand(mkColorPage({}, { color: "#ffffff" }));
1652
+ check("colour: mobile-only color mirrored to desktop (converted)", txtOf(mOnly, "desktop").color === "rgba(255,255,255,1)", txtOf(mOnly, "desktop"));
1653
+ const bothSet = landingDomain.expand(mkColorPage({ color: "rgba(1,2,3,1)" }, { color: "rgba(4,5,6,1)" }));
1654
+ check("colour: an explicit per-breakpoint colour is never overwritten", txtOf(bothSet, "desktop").color === "rgba(1,2,3,1)" && txtOf(bothSet, "mobile").color === "rgba(4,5,6,1)");
1655
+ // 7) the gradient-text-fill key is NOT mirrored — copying it without the
1656
+ // gradient background that goes with it would blank the mobile text.
1657
+ const fill = landingDomain.expand(mkColorPage({ background: "linear-gradient(90deg, rgba(1,2,3,1) 0%, rgba(4,5,6,1) 100%)", "-webkitTextFillColor": "transparent" }, {}));
1658
+ check("colour: -webkitTextFillColor NOT mirrored", txtOf(fill, "mobile")["-webkitTextFillColor"] === undefined, txtOf(fill, "mobile"));
1659
+ // 8) round-trip invariant survives the pass
1660
+ const expandedC = landingDomain.expand(mkColorPage({ color: "#1a202c", borderColor: "rgb(1,2,3)" }, {}));
1661
+ check("colour round-trip: expand(compact(expand(x))) deep-equals expand(x)", deepEq(landingDomain.expand(landingDomain.compact(expandedC)), expandedC));
1662
+ // 9) what the pass cannot convert is reported as a warning, not silently shipped
1663
+ const varColor = landingDomain.expand(mkColorPage({ color: "var(--brand)" }));
1664
+ const varWarn = validatePage(varColor).warnings.filter((w) => /not an rgba\(\) colour/.test(w));
1665
+ check("colour: unconvertible var(--x) warned", varWarn.length > 0, validatePage(varColor).warnings);
1666
+ const okColor = landingDomain.expand(mkColorPage({ color: "#1a202c" }));
1667
+ check("colour: no warning once the value is canonical rgba()", !validatePage(okColor).warnings.some((w) => /not an rgba\(\) colour/.test(w)), validatePage(okColor).warnings);
1668
+ }
1538
1669
  console.log("== text-block styles.background warning (gradient-text-fill mode) ==");
1539
1670
  {
1540
1671
  const mkTbBgPage = (bgValue, withClip) => ({
@@ -1786,6 +1917,54 @@ console.log("== rehost: external-image URL collect + rewrite (pure, offline) =="
1786
1917
  check("rehost: Stitch googleusercontent image (no ext) IS rehostable", isRehostableImageUrl("https://lh3.googleusercontent.com/aida/AP1WRLvcUnHkKPA0hJFi2Yx2"));
1787
1918
  check("rehost: Stitch aida-public image (no ext) IS rehostable", isRehostableImageUrl("https://lh3.googleusercontent.com/aida-public/AB6AXuAh6CcIJ1kE5WUS"));
1788
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
+ }
1789
1968
  // Collect a Stitch-shaped source (image src with no extension) end-to-end.
1790
1969
  {
1791
1970
  const stitchSrc = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-landing-mcp",
3
- "version": "1.5.1",
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",