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.
- package/dist/changelog.json +14 -14
- package/dist/domains/landing/color.js +235 -0
- package/dist/domains/landing/guide.js +3 -3
- package/dist/domains/landing/index.js +80 -21
- package/dist/domains/landing/instructions.js +1 -1
- package/dist/domains/landing/page-schema.json +4 -4
- package/dist/domains/landing/validate.js +89 -0
- package/dist/persistence/rehost-cache.js +9 -1
- package/dist/persistence/rehost.js +40 -6
- package/dist/persistence/webcake-client.js +33 -1
- package/dist/smoke.js +180 -1
- package/package.json +1 -1
package/dist/changelog.json
CHANGED
|
@@ -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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|