webcake-landing-mcp 1.5.0 → 1.5.2
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 +1 -1
- package/dist/domains/landing/index.js +192 -22
- package/dist/domains/landing/instructions.js +1 -1
- package/dist/domains/landing/page-schema.json +28 -28
- package/dist/domains/landing/validate.js +48 -0
- package/dist/persistence/page-version.js +198 -0
- package/dist/persistence/webcake-client.js +24 -0
- package/dist/smoke.js +241 -0
- package/dist/tools/persistence.js +178 -14
- package/package.json +1 -1
package/dist/changelog.json
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "1.5.2",
|
|
4
|
+
"d": "25/08/2026",
|
|
5
|
+
"type": "Fixed",
|
|
6
|
+
"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()…",
|
|
7
|
+
"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…"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"v": "1.5.1",
|
|
11
|
+
"d": "24/08/2026",
|
|
12
|
+
"type": "Added",
|
|
13
|
+
"en": "update_page now detects when the live page was edited outside this session (e.g. in the Webcake editor) since it was last read, and rejects the…",
|
|
14
|
+
"vi": "update_page nay phát hiện khi trang đang sống đã bị chỉnh sửa bên ngoài phiên làm việc này (ví dụ trong editor Webcake) kể từ lần đọc gần nhất, và…"
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"v": "1.5.0",
|
|
4
18
|
"d": "23/07/2026",
|
|
@@ -26,19 +40,5 @@
|
|
|
26
40
|
"type": "Changed",
|
|
27
41
|
"en": "Server instructions and get_generation_guide now restrict the visual-check step to three explicit triggers — cloning a reference to compare pixel…",
|
|
28
42
|
"vi": "Instruction của server và get_generation_guide nay giới hạn bước visual-check chỉ trong ba trường hợp cụ thể — clone trang tham chiếu để so sánh độ…"
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
"v": "1.2.0",
|
|
32
|
-
"d": "21/06/2026",
|
|
33
|
-
"type": "Added",
|
|
34
|
-
"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…",
|
|
35
|
-
"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…"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"v": "1.1.0",
|
|
39
|
-
"d": "20/06/2026",
|
|
40
|
-
"type": "Added",
|
|
41
|
-
"en": "create_page, add_section, and validate_page now apply a deterministic layout auto-fix before validating or saving: off-canvas children are pulled…",
|
|
42
|
-
"vi": "create_page, add_section và validate_page nay tự động áp dụng một lần auto-fix layout xác định trước khi validate hoặc lưu: các phần tử con nằm…"
|
|
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,7 +113,7 @@ 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). 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.
|
|
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
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.
|
|
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 {}.
|
|
@@ -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,
|
|
@@ -226,6 +281,104 @@ function normalizeMisplacedAnimation(node) {
|
|
|
226
281
|
normalizeMisplacedAnimation(child);
|
|
227
282
|
}
|
|
228
283
|
}
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// editor-value normalization: the Webcake EDITOR does not store every numeric
|
|
286
|
+
// style as a number. Confirmed write paths in landing_page_backend/assets/editor:
|
|
287
|
+
// • fontSize → "59" (main/panels/EditTextPanel.vue: updateStyle('fontSize', e.target.value))
|
|
288
|
+
// "13px" / "11px" / "70" (main/pickers/*.vue seed values)
|
|
289
|
+
// • height → "600" (main/traits/SizeAndPosition.vue handleBlur: e.target.value)
|
|
290
|
+
// • borderWidth→ "1" / "0px"
|
|
291
|
+
// • opacity → "50%" (main/traits/Layer.vue: updateStyle('opacity', val + '%'))
|
|
292
|
+
// • top/left → "unset"
|
|
293
|
+
// The published renderer copes (landing_page_build/render/build/exportCss.js
|
|
294
|
+
// parseInt()s the KEY_PREFIX_PIXEL group and string-concatenates '+ "px"' for
|
|
295
|
+
// top/left/width/height), so these pages are perfectly valid — but the MCP
|
|
296
|
+
// schema demanded numbers, so ONE manual tweak in the editor locked the page out
|
|
297
|
+
// of every MCP edit tool. That is the bug this pass removes.
|
|
298
|
+
//
|
|
299
|
+
// Fix: after every expand pass, coerce a unit-less or px-suffixed numeric string
|
|
300
|
+
// back to a number for the keys the renderer treats as bare px numbers, and turn
|
|
301
|
+
// an editor percent opacity into the 0–1 fraction. Values the coercion cannot
|
|
302
|
+
// read (`"unset"`, `"auto"`, `"100%"`) are left alone — the schema accepts them
|
|
303
|
+
// and validate.ts warns where the renderer would choke. Deterministic +
|
|
304
|
+
// idempotent, so the expand(compact(x)) == expand(x) invariant holds.
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
/** Style keys the renderer emits as bare px numbers (or parseInt()s). */
|
|
307
|
+
const NUMERIC_STYLE_KEYS = new Set(["top", "left", "width", "height", "zIndex", "fontSize", "borderWidth"]);
|
|
308
|
+
/** Per-breakpoint config keys the renderer appends "px" to / uses as numbers. */
|
|
309
|
+
const NUMERIC_CONFIG_KEYS = new Set([
|
|
310
|
+
"virtualHeight", "column", "row", "slideWidth", "iconSize", "iconTop", "linePaddingLeft",
|
|
311
|
+
"topBgImage", "leftBgImage", "widthBgImage", "heightBgImage",
|
|
312
|
+
"stickyTop", "stickyLeft", "stickyRight", "stickyBottom",
|
|
313
|
+
]);
|
|
314
|
+
/** A number written as a string, with an optional "px" suffix: "59", " 12.5 ", "13px". */
|
|
315
|
+
const NUMERIC_PX_STRING = /^\s*-?\d+(?:\.\d+)?\s*(?:px)?\s*$/i;
|
|
316
|
+
/** "59" | "13px" -> 59 | 13. Anything else (including numbers) passes through. */
|
|
317
|
+
function numFromEditorString(v) {
|
|
318
|
+
if (typeof v !== "string" || !NUMERIC_PX_STRING.test(v))
|
|
319
|
+
return v;
|
|
320
|
+
const n = parseFloat(v);
|
|
321
|
+
return Number.isFinite(n) ? n : v;
|
|
322
|
+
}
|
|
323
|
+
/** "50%" -> 0.5 (the editor's Layer trait writes percent); else numeric-string coercion. */
|
|
324
|
+
function opacityFromEditorString(v) {
|
|
325
|
+
if (typeof v !== "string")
|
|
326
|
+
return v;
|
|
327
|
+
const pct = v.trim().match(/^(-?\d+(?:\.\d+)?)\s*%$/);
|
|
328
|
+
if (pct) {
|
|
329
|
+
const n = parseFloat(pct[1]) / 100;
|
|
330
|
+
return Number.isFinite(n) ? n : v;
|
|
331
|
+
}
|
|
332
|
+
return numFromEditorString(v);
|
|
333
|
+
}
|
|
334
|
+
/** Walk a tree node and coerce editor-written numeric strings in-place (mutates). */
|
|
335
|
+
function normalizeEditorValues(node) {
|
|
336
|
+
if (!node || typeof node !== "object")
|
|
337
|
+
return;
|
|
338
|
+
for (const bp of ["desktop", "mobile"]) {
|
|
339
|
+
const rbp = node.responsive?.[bp];
|
|
340
|
+
if (!rbp || typeof rbp !== "object")
|
|
341
|
+
continue;
|
|
342
|
+
const styles = rbp.styles;
|
|
343
|
+
if (styles && typeof styles === "object") {
|
|
344
|
+
for (const key of NUMERIC_STYLE_KEYS) {
|
|
345
|
+
if (styles[key] !== undefined)
|
|
346
|
+
styles[key] = numFromEditorString(styles[key]);
|
|
347
|
+
}
|
|
348
|
+
if (styles.opacity !== undefined && styles.opacity !== null) {
|
|
349
|
+
styles.opacity = opacityFromEditorString(styles.opacity);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const config = rbp.config;
|
|
353
|
+
if (config && typeof config === "object") {
|
|
354
|
+
for (const key of NUMERIC_CONFIG_KEYS) {
|
|
355
|
+
if (config[key] !== undefined)
|
|
356
|
+
config[key] = numFromEditorString(config[key]);
|
|
357
|
+
}
|
|
358
|
+
const anim = config.animation;
|
|
359
|
+
if (anim && typeof anim === "object") {
|
|
360
|
+
for (const key of ["delay", "duration"]) {
|
|
361
|
+
if (anim[key] !== undefined && anim[key] !== null)
|
|
362
|
+
anim[key] = numFromEditorString(anim[key]);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (Array.isArray(node.children)) {
|
|
368
|
+
for (const child of node.children)
|
|
369
|
+
normalizeEditorValues(child);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/** settings.width_section carries the canvas width the coords live in — keep it numeric. */
|
|
373
|
+
function normalizeSettings(source) {
|
|
374
|
+
const ws = source?.settings?.width_section;
|
|
375
|
+
if (!ws || typeof ws !== "object")
|
|
376
|
+
return;
|
|
377
|
+
for (const bp of ["desktop", "mobile"]) {
|
|
378
|
+
if (ws[bp] !== undefined)
|
|
379
|
+
ws[bp] = numFromEditorString(ws[bp]);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
229
382
|
/** Apply all post-expand normalizations to every node in a page source. */
|
|
230
383
|
function normalizeSource(source) {
|
|
231
384
|
if (!source || typeof source !== "object")
|
|
@@ -233,13 +386,19 @@ function normalizeSource(source) {
|
|
|
233
386
|
for (const arr of ["page", "popup", "dynamic_pages"]) {
|
|
234
387
|
if (Array.isArray(source[arr])) {
|
|
235
388
|
for (const node of source[arr]) {
|
|
389
|
+
normalizeEditorValues(node);
|
|
236
390
|
normalizeMisplacedAnimation(node);
|
|
237
391
|
normalizeImageBlocks(node);
|
|
238
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);
|
|
239
397
|
normalizeBackgrounds(node);
|
|
240
398
|
}
|
|
241
399
|
}
|
|
242
400
|
}
|
|
401
|
+
normalizeSettings(source);
|
|
243
402
|
return source;
|
|
244
403
|
}
|
|
245
404
|
export const landingDomain = {
|
|
@@ -264,7 +423,18 @@ export const landingDomain = {
|
|
|
264
423
|
},
|
|
265
424
|
compact: (input) => {
|
|
266
425
|
try {
|
|
267
|
-
|
|
426
|
+
// Normalize FIRST (on a clone, so a caller's fetched tree is never mutated):
|
|
427
|
+
// a page hand-tweaked in the editor stores fontSize as "59" and height as
|
|
428
|
+
// "600", and the model should READ the canonical number it is asked to
|
|
429
|
+
// WRITE. Normalizing before compaction also lets a value that merely
|
|
430
|
+
// differs from the seed by its type ("20" vs 20) compact away.
|
|
431
|
+
const normalized = structuredClone(coercePage(input));
|
|
432
|
+
for (const arr of ["page", "popup", "dynamic_pages"]) {
|
|
433
|
+
if (Array.isArray(normalized?.[arr]))
|
|
434
|
+
normalized[arr].forEach(normalizeEditorValues);
|
|
435
|
+
}
|
|
436
|
+
normalizeSettings(normalized);
|
|
437
|
+
return compactSource(normalized, createElement);
|
|
268
438
|
}
|
|
269
439
|
catch {
|
|
270
440
|
return input; // bad JSON — return as-is
|
|
@@ -15,7 +15,7 @@ RULES (follow for every request):
|
|
|
15
15
|
- BUILD THE SOURCE IN ONE PASS — gather everything you need BEFORE assembling the source, then build the FULL tree once. BATCH the reads: when a section needs several element types (section + text-block + image-block + button + form + input), call get_element({types:[…]}) ONCE instead of one call per type — same for images, call search_images({queries:[…]}) ONCE with one query per image slot (it dedups + parallelizes and returns one best photo per query). Across MULTIPLE pages in one conversation: reusing skeletons you already fetched is correct, but call get_element for any type you have NOT already fetched in this conversation — NEVER author an unfamiliar type from memory; and if the earlier skeletons are no longer in your context (long conversation, compacted history), re-fetch before building. Do NOT interleave get_element calls between create_page previews and rebuild. create_page/update_page take the entire source as input, so each call re-ships the whole page — re-previewing repeatedly wastes the request.
|
|
16
16
|
- create_page and update_page DEFAULT to dry_run=true (a safety net for ambiguous requests). When the user's intent is clear AND validate_page already passed (no errors, warnings fixed), SKIP the dry-run and call with dry_run=false directly — saves one round-trip. Use dry_run=true only when (a) the request is ambiguous about target/content, (b) the user explicitly asks to "preview" or "xem trước", (c) this is an update_page that overwrites significant existing content, or (d) you genuinely need to inspect the redacted payload. Never loop dry-runs to "check" the source — validate_page is the validator. Do not run dry-run then dry-run again before the real write.
|
|
17
17
|
- LARGE PAGES (4+ sections) — build INCREMENTALLY to avoid the giant single create_page payload that can drop the connection: create_page with a SMALL skeleton (empty/near-empty page) to get a page_id, then call add_section once per section (each call ships ONLY that section; the backend appends it server-side and rejects duplicate ids — no whole-source get+put). Small pages can still go in one create_page pass.
|
|
18
|
-
- EDIT existing pages surgically: find_pages (locate the page by name/domain/id when you don't already have a page_id) → get_page → change ONLY what was asked → keep every other element, its id, and coordinates. For a SMALL edit, PREFER patch_page over update_page: send only the changed elements by id (ops: update/replace/remove/add) instead of re-shipping the whole tree — the MCP fetches the live source, merges, validates and saves server-side. Use update_page only when you're rewriting most of the page. Never regenerate the whole tree for a small change.
|
|
18
|
+
- EDIT existing pages surgically: find_pages (locate the page by name/domain/id when you don't already have a page_id) → get_page → change ONLY what was asked → keep every other element, its id, and coordinates. For a SMALL edit, PREFER patch_page over update_page: send only the changed elements by id (ops: update/replace/remove/add) instead of re-shipping the whole tree — the MCP fetches the live source, merges, validates and saves server-side. Use update_page only when you're rewriting most of the page. Never regenerate the whole tree for a small change. CONCURRENCY — the user may be editing the SAME page in the Webcake editor while you work: update_page replaces the whole stored tree, so a source read a while ago (or a cached draft_id, which can be hours old) would silently revert their changes. The server now checks this for you: get_page returns source_version, and update_page re-reads the page and REJECTS the save with reason:"page_changed_externally" (naming the element ids it would have deleted) when the live tree moved. When you see that, do NOT pass force:true on your own — either redo the edit with patch_page({ page_id, patches }), which merges into the live tree and can never revert anyone, or call get_page again, re-apply the change to the fresh tree, and save. Only use force:true after the user has explicitly said to discard the newer edits.
|
|
19
19
|
- FIX-AFTER-ERROR / RETRY-AFTER-TIMEOUT (don't rebuild — this is the #1 time-waster): every mutating tool (create_page, update_page, add_section, patch_page) writes the payload to a draft cache BEFORE the network call and returns a draft_id. On validation failure, timeout, or any network error, the draft is kept — retry or fix without re-sending the full JSON:
|
|
20
20
|
· create_page failed validation OR timed out → its error returns a draft_id (full source cached). Call patch_page({ draft_id, patches:[…], dry_run:false }) to fix ONLY the listed elements, or retry create_page({ draft_id, dry_run:false }) as-is after a timeout.
|
|
21
21
|
· add_section failed validation, timed out, or returned dry_run=true → response returns a draft_id (section batch cached). Call patch_page({ draft_id, patches:[…], dry_run:false }) to fix elements and append, or retry patch_page({ draft_id, dry_run:false }) (no patches) to commit as-is.
|