webcake-landing-mcp 1.0.70 → 1.0.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "v": "1.0.71",
4
+ "d": "13/06/2026",
5
+ "type": "Added",
6
+ "en": "ingest_html and ingest_url now automatically convert absolute-canvas builder exports (LadiPage-family / Webcake-published HTML) into a ready-to-save…",
7
+ "vi": "ingest_html và ingest_url nay tự động chuyển đổi các bản export từ builder absolute-canvas (LadiPage-family / Webcake-published HTML) thành source…"
8
+ },
2
9
  {
3
10
  "v": "1.0.70",
4
11
  "d": "13/06/2026",
@@ -33,12 +40,5 @@
33
40
  "type": "Changed",
34
41
  "en": "Generation guide (get_generation_guide) and server instructions now prescribe a four-step image sourcing priority: (1) re-host user-supplied or…",
35
42
  "vi": "Hướng dẫn sinh trang (get_generation_guide) và instruction server nay quy định thứ tự ưu tiên bốn bước để lấy ảnh: (1) re-host ảnh do người dùng…"
36
- },
37
- {
38
- "v": "1.0.65",
39
- "d": "12/06/2026",
40
- "type": "Added",
41
- "en": "validate_page now warns when a single-line text-block label sitting on a rounded rectangle (the badge/pill pattern) is vertically or horizontally…",
42
- "vi": "validate_page nay cảnh báo khi nhãn text-block một dòng đặt trên rectangle bo góc (kiểu badge/pill) bị lệch tâm theo chiều dọc hoặc ngang: sử dụng…"
43
43
  }
44
44
  ]
@@ -0,0 +1,656 @@
1
+ /**
2
+ * Deterministic absolute-canvas → Webcake page-source converter.
3
+ *
4
+ * Builders like LadiPage (and Webcake's own published HTML) lay the page out on
5
+ * a fixed-width absolute canvas (mobile 420 / desktop 960) — the SAME canvas the
6
+ * Webcake editor uses — so the parsed geometry transfers 1:1. `parseAbsoluteCanvas`
7
+ * (src/persistence/html-ingest.ts) turns that HTML into an `IngestedCanvas`; this
8
+ * module turns that canvas into a ready-to-save `{ page, popup, settings, … }`
9
+ * source WITHOUT a model in the loop, so a clone keeps the original's exact boxes,
10
+ * styles, images, and behaviors instead of being hand-rebuilt (and degraded).
11
+ *
12
+ * It emits the SPARSE authoring shape (responsive styles + specials + the few
13
+ * non-default configs); `create_page`/`patch_page` run `expand` over it to hydrate
14
+ * the boilerplate, then validate + auto-host images + publish. Anything that can't
15
+ * map cleanly (fixed elements, svg-less shapes, social-proof toasts) degrades
16
+ * gracefully and is reported in `notes` so the caller can patch it.
17
+ */
18
+ import { createPageSource } from "./page.js";
19
+ import { ANIMATABLE_TYPES, ANIMATION_NAMES, CANVAS } from "./vocab.js";
20
+ const MOBILE_W = CANVAS.mobileWidth;
21
+ const DESKTOP_W = CANVAS.desktopWidth;
22
+ // ─── small helpers ────────────────────────────────────────────────────────────
23
+ const cloneJ = (v) => JSON.parse(JSON.stringify(v));
24
+ function idOf(raw) {
25
+ return raw.toLowerCase();
26
+ }
27
+ function num(v) {
28
+ if (!v)
29
+ return undefined;
30
+ const m = /^(-?\d+(?:\.\d+)?)/.exec(v.trim());
31
+ return m ? Math.round(parseFloat(m[1])) : undefined;
32
+ }
33
+ function escapeHtml(s) {
34
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
35
+ }
36
+ const BG_URL_RE = /url\(\s*["']?(https?:\/\/[^"')]+)["']?\s*\)/;
37
+ /** The editor-canonical url() background shorthand its picker can re-parse. */
38
+ function canonicalBg(src) {
39
+ return `center center/ cover no-repeat scroll content-box url(${src}) border-box`;
40
+ }
41
+ /** Schema only allows these border styles — 3D styles (outset/inset/groove/ridge) degrade to solid. */
42
+ const BORDER_STYLES = new Set(["solid", "dashed", "dotted", "double", "none"]);
43
+ function normBorderStyle(v) {
44
+ return BORDER_STYLES.has(v) ? v : "solid";
45
+ }
46
+ /** "3px solid rgb(1,2,3)" → { borderWidth, borderStyle, borderColor } */
47
+ function parseBorderShorthand(v) {
48
+ const out = {};
49
+ const w = /^(\d+(?:\.\d+)?)px\b/.exec(v.trim());
50
+ if (w)
51
+ out.borderWidth = Math.round(parseFloat(w[1]));
52
+ const s = /\b(solid|dashed|dotted|double|outset|inset|groove|ridge)\b/.exec(v);
53
+ if (s)
54
+ out.borderStyle = normBorderStyle(s[1]);
55
+ const c = /(rgba?\([^)]*\)|#[0-9a-fA-F]{3,8})/.exec(v);
56
+ if (c)
57
+ out.borderColor = c[1];
58
+ return out;
59
+ }
60
+ /** Whitelisted canvas style bag → Webcake camelCase styles for one element type. */
61
+ function mapStyles(bag, type) {
62
+ const out = {};
63
+ if (!bag)
64
+ return out;
65
+ const fs = num(bag["font-size"]);
66
+ if (fs !== undefined)
67
+ out.fontSize = fs;
68
+ if (bag["color"])
69
+ out.color = bag["color"];
70
+ if (bag["text-align"])
71
+ out.textAlign = bag["text-align"];
72
+ if (bag["font-weight"])
73
+ out.fontWeight = bag["font-weight"];
74
+ if (bag["font-style"])
75
+ out.fontStyle = bag["font-style"];
76
+ if (bag["font-family"])
77
+ out.fontFamily = bag["font-family"];
78
+ if (bag["line-height"]) {
79
+ const lh = parseFloat(bag["line-height"]);
80
+ if (Number.isFinite(lh))
81
+ out.lineHeight = lh;
82
+ }
83
+ if (bag["letter-spacing"])
84
+ out.letterSpacing = bag["letter-spacing"];
85
+ if (bag["text-transform"])
86
+ out.textTransform = bag["text-transform"];
87
+ if (bag["text-shadow"])
88
+ out.textShadow = bag["text-shadow"];
89
+ if (bag["border-radius"])
90
+ out.borderRadius = bag["border-radius"];
91
+ if (bag["box-shadow"])
92
+ out.boxShadow = bag["box-shadow"];
93
+ const op = bag["opacity"] !== undefined ? parseFloat(bag["opacity"]) : NaN;
94
+ if (Number.isFinite(op) && op < 1)
95
+ out.opacity = op;
96
+ // Borders: shorthand or longhand.
97
+ if (bag["border"])
98
+ Object.assign(out, parseBorderShorthand(bag["border"]));
99
+ if (bag["border-top"] && type === "line")
100
+ Object.assign(out, parseBorderShorthand(bag["border-top"]));
101
+ if (bag["border-width"])
102
+ out.borderWidth = num(bag["border-width"]);
103
+ if (bag["border-style"])
104
+ out.borderStyle = normBorderStyle(bag["border-style"]);
105
+ if (bag["border-color"])
106
+ out.borderColor = bag["border-color"];
107
+ // background: NEVER on text-block (it activates gradient-text-fill and makes
108
+ // glyphs invisible); image-block gets it derived from src by the server.
109
+ if (type !== "text-block" && type !== "image-block") {
110
+ const bg = bag["background-color"] ?? bag["background"];
111
+ if (bg && !bg.includes("url("))
112
+ out.background = bg;
113
+ else if (bg) {
114
+ const u = BG_URL_RE.exec(bg);
115
+ if (u)
116
+ out.background = canonicalBg(u[1]);
117
+ }
118
+ }
119
+ return out;
120
+ }
121
+ function uniqueId(ctx, raw) {
122
+ let id = idOf(raw);
123
+ while (ctx.usedIds.has(id))
124
+ id = `${id}x`;
125
+ ctx.usedIds.add(id);
126
+ return id;
127
+ }
128
+ /** Build responsive.{desktop,mobile}.styles from a box + mapped styles. */
129
+ function responsiveOf(ctx, box, styles, sectionH) {
130
+ const primary = { ...styles };
131
+ if (box) {
132
+ let top = box.top;
133
+ let left = box.left;
134
+ // position:fixed floating elements (LadiPage sticky widgets): keep a sane
135
+ // in-flow box as the fallback home, then pin them to the viewport via the
136
+ // sticky config (applySticky) so they don't bake mid-page. bottom/right anchored.
137
+ if (box.fixed) {
138
+ const w = box.width ?? 100;
139
+ const h = box.height ?? 40;
140
+ const canvasW = ctx.mobileOnly ? MOBILE_W : DESKTOP_W;
141
+ if (top === undefined)
142
+ top = Math.max(0, (sectionH ?? 800) - h - (box.bottom ?? 10));
143
+ if (left === undefined)
144
+ left = Math.max(0, canvasW - w - (box.right ?? 10));
145
+ }
146
+ if (top !== undefined)
147
+ primary.top = top;
148
+ if (left !== undefined)
149
+ primary.left = left;
150
+ if (box.width !== undefined)
151
+ primary.width = box.width;
152
+ if (box.height !== undefined)
153
+ primary.height = box.height;
154
+ }
155
+ if (ctx.mobileOnly) {
156
+ return { desktop: { styles: cloneJ(primary) }, mobile: { styles: cloneJ(primary) } };
157
+ }
158
+ // Desktop source: mobile gets a simple horizontal 420/960 scale as a start.
159
+ const scale = MOBILE_W / DESKTOP_W;
160
+ const m = cloneJ(primary);
161
+ for (const k of ["left", "width"])
162
+ if (typeof m[k] === "number")
163
+ m[k] = Math.round(m[k] * scale);
164
+ return { desktop: { styles: primary }, mobile: { styles: m } };
165
+ }
166
+ function setBothConfigs(node, config) {
167
+ node.responsive.desktop.config = { ...(node.responsive.desktop.config ?? {}), ...cloneJ(config) };
168
+ node.responsive.mobile.config = { ...(node.responsive.mobile.config ?? {}), ...cloneJ(config) };
169
+ }
170
+ function mapEvents(ctx, e) {
171
+ const out = [];
172
+ let i = 0;
173
+ for (const ev of e.events ?? []) {
174
+ const id = `ev_${idOf(e.id)}_${i++}`;
175
+ if (ev.type === "popup")
176
+ out.push({ id, type: "click", action: "open_popup", target: idOf(ev.action) });
177
+ else if (ev.type === "section")
178
+ out.push({ id, type: "click", action: "scroll_to", target: idOf(ev.action) });
179
+ else if (ev.type === "link")
180
+ out.push({ id, type: "click", action: "open_link", target: ev.action, targetURL: "_blank" });
181
+ else if (ev.type === "phone")
182
+ out.push({ id, type: "click", action: "open_link", target: `tel:${ev.action}` });
183
+ else
184
+ ctx.notes.push(`${e.id}: unsupported event type '${ev.type}' skipped`);
185
+ }
186
+ if (!out.length && e.href && !e.href.startsWith("#")) {
187
+ out.push({
188
+ id: `ev_${idOf(e.id)}_href`,
189
+ type: "click",
190
+ action: "open_link",
191
+ target: e.href,
192
+ targetURL: e.href.startsWith("tel:") ? "_self" : "_blank",
193
+ });
194
+ }
195
+ return out.length ? out : undefined;
196
+ }
197
+ function applyAnimation(node, e, type) {
198
+ const name = e.animation?.["name"];
199
+ if (!name || !ANIMATABLE_TYPES.has(type) || !ANIMATION_NAMES.has(name))
200
+ return;
201
+ const secs = (v) => {
202
+ const n = v ? parseFloat(v) : NaN;
203
+ return Number.isFinite(n) ? n : undefined;
204
+ };
205
+ const anim = { name };
206
+ const delay = secs(e.animation?.["delay"]);
207
+ const duration = secs(e.animation?.["duration"]);
208
+ if (delay !== undefined)
209
+ anim.delay = delay;
210
+ if (duration !== undefined)
211
+ anim.duration = duration;
212
+ setBothConfigs(node, { animation: anim });
213
+ }
214
+ /** LadiPage sticky_position keyword → Webcake stickyPosition anchor code. */
215
+ const LADI_STICKY_TO_WEBCAKE = {
216
+ top_left: "t-l",
217
+ top_center: "t-c",
218
+ top_right: "t-r",
219
+ middle_left: "l-c",
220
+ middle_right: "r-c",
221
+ bottom_left: "b-l",
222
+ bottom_center: "b-c",
223
+ bottom_right: "b-r",
224
+ };
225
+ /**
226
+ * Pin a position:fixed element to the viewport via Webcake's sticky config
227
+ * instead of leaving it baked in-flow mid-page (where the original floating
228
+ * corner widget "jumps outside" into a random section). Webcake reads
229
+ * sticky / stickyPosition / sticky{Top,Bottom,Left,Right,Width,Height} from
230
+ * responsive.<bp>.config (docs/element-specials-reference.md §1). The parser
231
+ * already captured `sticky` (bottom_left…) and the box's bottom/right offsets.
232
+ */
233
+ function applySticky(ctx, node, e) {
234
+ if (!e.box?.fixed)
235
+ return;
236
+ const b = e.box;
237
+ const pos = LADI_STICKY_TO_WEBCAKE[e.sticky ?? ""] ?? "b-r";
238
+ const cfg = { sticky: true, stickyPosition: pos };
239
+ if (b.top !== undefined)
240
+ cfg.stickyTop = b.top;
241
+ if (b.bottom !== undefined)
242
+ cfg.stickyBottom = b.bottom;
243
+ if (b.left !== undefined)
244
+ cfg.stickyLeft = b.left;
245
+ if (b.right !== undefined)
246
+ cfg.stickyRight = b.right;
247
+ if (b.width !== undefined)
248
+ cfg.stickyWidth = b.width;
249
+ if (b.height !== undefined)
250
+ cfg.stickyHeight = b.height;
251
+ setBothConfigs(node, cfg);
252
+ ctx.notes.push(`${e.id}: position:fixed → pinned as a sticky '${pos}' element (was floating ${e.sticky ?? "bottom_right"}).`);
253
+ }
254
+ const FIELD_NAME_MAP = {
255
+ name: "full_name",
256
+ fullname: "full_name",
257
+ full_name: "full_name",
258
+ phone: "phone_number",
259
+ tel: "phone_number",
260
+ phone_number: "phone_number",
261
+ email: "email",
262
+ address: "address",
263
+ };
264
+ // ─── element conversion ──────────────────────────────────────────────────────
265
+ function convertChildren(ctx, e, sectionH) {
266
+ return (e.children ?? []).map((c) => convertElement(ctx, c, sectionH)).filter(Boolean);
267
+ }
268
+ /** Build a node, then pin it to the viewport if it was position:fixed (recurses via convertChildren). */
269
+ function convertElement(ctx, e, sectionH) {
270
+ const node = convertElementInner(ctx, e, sectionH);
271
+ if (node && e.box?.fixed)
272
+ applySticky(ctx, node, e);
273
+ return node;
274
+ }
275
+ function convertElementInner(ctx, e, sectionH) {
276
+ const t = e.type;
277
+ const style = e.style ?? {};
278
+ if (t === "notify") {
279
+ ctx.notes.push(`${e.id}: notify (social-proof toast) skipped — needs its own data source; re-add manually if wanted.`);
280
+ return null;
281
+ }
282
+ if (t === "countdown_item")
283
+ return null; // rendered by the parent countdown
284
+ // headline / paragraph / button_text → text-block
285
+ if (t === "headline" || t === "paragraph" || t === "button_text") {
286
+ const node = {
287
+ id: uniqueId(ctx, e.id),
288
+ type: "text-block",
289
+ responsive: responsiveOf(ctx, e.box, mapStyles(style, "text-block"), sectionH),
290
+ specials: { text: e.text ?? "", tag: t === "paragraph" ? "p" : "h3" },
291
+ };
292
+ const ev = mapEvents(ctx, e);
293
+ if (ev)
294
+ node.events = ev;
295
+ applyAnimation(node, e, "text-block");
296
+ return node;
297
+ }
298
+ if (t === "list") {
299
+ const items = (e.text ?? "").split("\n").filter(Boolean);
300
+ const node = {
301
+ id: uniqueId(ctx, e.id),
302
+ type: "list-paragraph",
303
+ responsive: responsiveOf(ctx, e.box, mapStyles(style, "list-paragraph"), sectionH),
304
+ specials: { text: items.map((i) => `<li>${i}</li>`).join("") },
305
+ };
306
+ setBothConfigs(node, { iconSize: 12, iconTop: 5, linePaddingLeft: 23, linePaddingBottom: 10 });
307
+ return node;
308
+ }
309
+ if (t === "image" || t === "video") {
310
+ if (!e.src && t === "video") {
311
+ ctx.notes.push(`${e.id}: video without a recoverable source skipped.`);
312
+ return null;
313
+ }
314
+ const node = {
315
+ id: uniqueId(ctx, e.id),
316
+ type: "image-block",
317
+ responsive: responsiveOf(ctx, e.box, mapStyles(style, "image-block"), sectionH),
318
+ specials: { src: e.src ?? "", imageCompression: true },
319
+ };
320
+ if (e.crop) {
321
+ const crop = {};
322
+ if (e.crop.width !== undefined)
323
+ crop.widthBgImage = e.crop.width;
324
+ if (e.crop.height !== undefined)
325
+ crop.heightBgImage = e.crop.height;
326
+ if (e.crop.top !== undefined)
327
+ crop.topBgImage = e.crop.top;
328
+ if (e.crop.left !== undefined)
329
+ crop.leftBgImage = e.crop.left;
330
+ if (Object.keys(crop).length)
331
+ setBothConfigs(node, crop);
332
+ }
333
+ const ev = mapEvents(ctx, e);
334
+ if (ev)
335
+ node.events = ev;
336
+ applyAnimation(node, e, "image-block");
337
+ return node;
338
+ }
339
+ if (t === "box") {
340
+ const node = {
341
+ id: uniqueId(ctx, e.id),
342
+ type: "rectangle",
343
+ responsive: responsiveOf(ctx, e.box, mapStyles(style, "rectangle"), sectionH),
344
+ specials: {},
345
+ };
346
+ const ev = mapEvents(ctx, e);
347
+ if (ev)
348
+ node.events = ev;
349
+ applyAnimation(node, e, "rectangle");
350
+ return node;
351
+ }
352
+ if (t === "shape") {
353
+ const styles = mapStyles(style, "rectangle");
354
+ if (!styles.background)
355
+ styles.background = style["fill"] ?? "rgba(0,0,0,1)";
356
+ const node = {
357
+ id: uniqueId(ctx, e.id),
358
+ type: "rectangle",
359
+ responsive: responsiveOf(ctx, e.box, styles, sectionH),
360
+ specials: {},
361
+ };
362
+ if (e.svg) {
363
+ // svgMask paints the silhouette; color comes from styles.background.
364
+ setBothConfigs(node, { svgMask: e.svg.replace(/"/g, "'") });
365
+ if (style["fill"]) {
366
+ node.responsive.desktop.styles.background = style["fill"];
367
+ node.responsive.mobile.styles.background = style["fill"];
368
+ }
369
+ }
370
+ else {
371
+ ctx.notes.push(`${e.id}: shape had no recoverable <svg> — converted to a plain rectangle.`);
372
+ }
373
+ const ev = mapEvents(ctx, e);
374
+ if (ev)
375
+ node.events = ev;
376
+ return node;
377
+ }
378
+ if (t === "line") {
379
+ const styles = mapStyles(style, "line");
380
+ if (styles.borderWidth === undefined) {
381
+ styles.borderWidth = 1;
382
+ styles.borderStyle = styles.borderStyle ?? "solid";
383
+ styles.borderColor = styles.borderColor ?? "rgba(208,213,221,1)";
384
+ }
385
+ return {
386
+ id: uniqueId(ctx, e.id),
387
+ type: "line",
388
+ responsive: responsiveOf(ctx, e.box, styles, sectionH),
389
+ specials: {},
390
+ };
391
+ }
392
+ if (t === "button") {
393
+ const label = (e.children ?? []).find((c) => c.type === "button_text");
394
+ const styles = mapStyles(style, "rectangle"); // background/borderRadius from the button itself
395
+ Object.assign(styles, mapStyles(label?.style, "text-block")); // typography from the label child
396
+ if (!styles.textAlign)
397
+ styles.textAlign = "center";
398
+ const node = {
399
+ id: uniqueId(ctx, e.id),
400
+ type: "button",
401
+ responsive: responsiveOf(ctx, e.box, styles, sectionH),
402
+ specials: { text: label?.text ?? e.text ?? "Button" },
403
+ };
404
+ const ev = mapEvents(ctx, e);
405
+ if (ev)
406
+ node.events = ev;
407
+ applyAnimation(node, e, "button");
408
+ return node;
409
+ }
410
+ if (t === "form") {
411
+ const node = {
412
+ id: uniqueId(ctx, e.id),
413
+ type: "form",
414
+ responsive: responsiveOf(ctx, e.box, mapStyles(style, "rectangle"), sectionH),
415
+ specials: {},
416
+ children: [],
417
+ };
418
+ const redirect = e.config?.["thankyou_value"];
419
+ if (typeof redirect === "string" && /^https?:\/\//.test(redirect)) {
420
+ node.specials.submit_success = 2;
421
+ node.specials.redirect_url = redirect;
422
+ node.specials.target_url = "_self";
423
+ }
424
+ // Inputs/buttons must be DIRECT children of the form to submit.
425
+ for (const c of e.children ?? []) {
426
+ if (c.type === "form_item") {
427
+ const rawName = c.input?.name ?? `input_${idOf(c.id)}`;
428
+ const fieldName = FIELD_NAME_MAP[rawName.toLowerCase()] ?? rawName;
429
+ const child = {
430
+ id: uniqueId(ctx, c.id),
431
+ type: "input",
432
+ responsive: responsiveOf(ctx, c.box, mapStyles(c.style, "rectangle"), sectionH),
433
+ specials: {
434
+ field_name: fieldName,
435
+ field_placeholder: c.input?.placeholder ?? "",
436
+ field_type: fieldName === "phone_number" ? "phone" : c.input?.input_type === "email" ? "email" : "text",
437
+ ...(c.input?.required ? { required: true } : {}),
438
+ },
439
+ };
440
+ node.children.push(child);
441
+ }
442
+ else {
443
+ const conv = convertElement(ctx, c, sectionH);
444
+ if (conv)
445
+ node.children.push(conv);
446
+ }
447
+ }
448
+ return node;
449
+ }
450
+ if (t === "group") {
451
+ const node = {
452
+ id: uniqueId(ctx, e.id),
453
+ type: "group",
454
+ responsive: responsiveOf(ctx, e.box, {}, sectionH),
455
+ specials: {},
456
+ children: convertChildren(ctx, e, sectionH),
457
+ };
458
+ const ev = mapEvents(ctx, e);
459
+ if (ev)
460
+ node.events = ev;
461
+ applyAnimation(node, e, "group");
462
+ return node;
463
+ }
464
+ if (t === "countdown") {
465
+ const minutes = e.config?.["countdown_minute"];
466
+ const styles = mapStyles(style, "rectangle");
467
+ if (!styles.color)
468
+ styles.color = "rgba(40,40,40,1)";
469
+ const node = {
470
+ id: uniqueId(ctx, e.id),
471
+ type: "countdown",
472
+ responsive: responsiveOf(ctx, e.box, styles, sectionH),
473
+ specials: {
474
+ type: "minute",
475
+ duration: String(typeof minutes === "number" ? minutes : 60),
476
+ language: "custom",
477
+ customTranslation: { day: "Ngày", hour: "Giờ", minute: "Phút", second: "Giây" },
478
+ showDay: true,
479
+ showHour: true,
480
+ showSecond: true,
481
+ showText: true,
482
+ repeat: true,
483
+ customize: "nothing",
484
+ customMessage: "",
485
+ dailyStart: "",
486
+ dailyEnd: "",
487
+ },
488
+ };
489
+ applyAnimation(node, e, "countdown");
490
+ return node;
491
+ }
492
+ if (t === "carousel" || t === "gallery") {
493
+ const media = (e.children ?? [])
494
+ .filter((c) => c.type === "image" && c.src)
495
+ .map((c) => ({ type: "image", link: c.src, linkVideo: "", typeVideo: "youtube", imageCompression: true }));
496
+ if (!media.length) {
497
+ ctx.notes.push(`${e.id}: carousel/gallery had no recoverable images — skipped.`);
498
+ return null;
499
+ }
500
+ const node = {
501
+ id: uniqueId(ctx, e.id),
502
+ type: "gallery",
503
+ responsive: responsiveOf(ctx, e.box, {}, sectionH),
504
+ specials: { media },
505
+ };
506
+ setBothConfigs(node, { showThumbnail: false, showNavigation: true, allowZoom: "off" });
507
+ ctx.notes.push(`${e.id}: source carousel converted to a gallery slider (${media.length} images).`);
508
+ return node;
509
+ }
510
+ if (t === "spin_wheel") {
511
+ const prizes = e.config?.["prizes"] ?? [];
512
+ let lines;
513
+ if (prizes.length) {
514
+ const nums = prizes.map((p) => parseInt(String(p.chance), 10) || 0);
515
+ const sum = nums.reduce((a, b) => a + b, 0);
516
+ if (sum !== 100) {
517
+ // percents MUST sum to 100 or the winner selection throws — fix the largest slot.
518
+ const iMax = nums.indexOf(Math.max(...nums));
519
+ nums[iMax] += 100 - sum;
520
+ ctx.notes.push(`${e.id}: spin-wheel percents summed to ${sum} — adjusted to 100.`);
521
+ }
522
+ lines = prizes.map((p, i) => `PRIZE${i + 1}|${p.label.replace(/\|/g, "/")}|${Math.max(0, nums[i])}`);
523
+ }
524
+ else {
525
+ lines = ["PRIZE1|Giải may mắn|50", "MISS|Chúc may mắn lần sau|50"];
526
+ ctx.notes.push(`${e.id}: spin-wheel had no recoverable prize list — seeded a default; edit specials.code.`);
527
+ }
528
+ // Prefer the ORIGINAL wheel-face + center-button art (the parser kept them in
529
+ // config so they don't collide); fall back to the editor default only when they
530
+ // couldn't be recovered. The original urls auto-host on save like any image.
531
+ const wheelImg = e.config?.["wheelImage"];
532
+ const btnImg = e.config?.["buttonImage"];
533
+ if (!wheelImg)
534
+ ctx.notes.push(`${e.id}: spin-wheel face image not recovered — using the editor default wheel.`);
535
+ const node = {
536
+ id: uniqueId(ctx, e.id),
537
+ type: "spin-wheel",
538
+ responsive: responsiveOf(ctx, e.box, mapStyles(style, "rectangle"), sectionH),
539
+ specials: {
540
+ background: wheelImg ?? "https://cdn.webcake.co/editor/main/pickers/spin-wheel-default.png",
541
+ backgroundBtn: btnImg ?? "https://cdn.webcake.co/editor/main/pickers/spin-wheel-btn-default.png",
542
+ spin: String(e.config?.["spinlucky_setting.max_turn"] ?? "1"),
543
+ rotate: "0",
544
+ popup: "default",
545
+ popupTurnOver: "default",
546
+ showCoupon: "yes",
547
+ code: lines.join("\n"),
548
+ message: "Chúc mừng! Bạn nhận được {{coupon_text}} (mã: {{coupon_code}}). Bạn còn {{spin_turn_left}} lượt quay.",
549
+ },
550
+ };
551
+ return node;
552
+ }
553
+ if (t === "html_code") {
554
+ if (!e.html)
555
+ return null;
556
+ return {
557
+ id: uniqueId(ctx, e.id),
558
+ type: "html-box",
559
+ responsive: responsiveOf(ctx, e.box, {}, sectionH),
560
+ specials: { html: escapeHtml(e.html) },
561
+ };
562
+ }
563
+ // Unknown/unsupported type — degrade gracefully, keep geometry.
564
+ if (e.children?.length) {
565
+ ctx.notes.push(`${e.id}: unmapped type '${t}' converted to a group.`);
566
+ return {
567
+ id: uniqueId(ctx, e.id),
568
+ type: "group",
569
+ responsive: responsiveOf(ctx, e.box, {}, sectionH),
570
+ specials: {},
571
+ children: convertChildren(ctx, e, sectionH),
572
+ };
573
+ }
574
+ if (e.text) {
575
+ ctx.notes.push(`${e.id}: unmapped type '${t}' converted to a text-block.`);
576
+ return {
577
+ id: uniqueId(ctx, e.id),
578
+ type: "text-block",
579
+ responsive: responsiveOf(ctx, e.box, mapStyles(style, "text-block"), sectionH),
580
+ specials: { text: e.text, tag: "p" },
581
+ };
582
+ }
583
+ if (e.src) {
584
+ ctx.notes.push(`${e.id}: unmapped type '${t}' converted to an image-block.`);
585
+ return {
586
+ id: uniqueId(ctx, e.id),
587
+ type: "image-block",
588
+ responsive: responsiveOf(ctx, e.box, {}, sectionH),
589
+ specials: { src: e.src, imageCompression: true },
590
+ };
591
+ }
592
+ ctx.notes.push(`${e.id}: unmapped type '${t}' with no content skipped.`);
593
+ return null;
594
+ }
595
+ function convertSection(ctx, s) {
596
+ const h = s.height ?? CANVAS.defaultSectionHeight;
597
+ const styles = { height: h };
598
+ if (s.background) {
599
+ const u = s.background["background-image"];
600
+ const color = s.background["background-color"];
601
+ if (u)
602
+ styles.background = canonicalBg(u);
603
+ else if (color)
604
+ styles.background = color;
605
+ }
606
+ return {
607
+ id: uniqueId(ctx, s.id),
608
+ type: "section",
609
+ responsive: { desktop: { styles: cloneJ(styles) }, mobile: { styles: cloneJ(styles) } },
610
+ children: s.elements.map((e) => convertElement(ctx, e, h)).filter(Boolean),
611
+ };
612
+ }
613
+ function convertPopup(ctx, p) {
614
+ const styles = mapStyles(p.style, "rectangle");
615
+ if (p.src)
616
+ styles.background = canonicalBg(p.src);
617
+ const node = {
618
+ id: uniqueId(ctx, p.id),
619
+ type: "popup",
620
+ responsive: responsiveOf(ctx, p.box, styles),
621
+ specials: { position: "center" },
622
+ children: convertChildren(ctx, p, p.box?.height),
623
+ };
624
+ if (p.config?.["show_popup_welcome_page"] === true) {
625
+ node.specials.openInPage = true;
626
+ const delay = p.config?.["delay_popup_welcome_page"];
627
+ if (typeof delay === "number")
628
+ node.specials.delayPopup = delay;
629
+ }
630
+ return node;
631
+ }
632
+ /**
633
+ * Convert a parsed absolute-canvas (LadiPage-family) into a complete, sparse
634
+ * Webcake page source. The result's `source` is ready for `create_page`
635
+ * (which expands + validates + auto-hosts images); `notes` lists every
636
+ * lossy approximation the caller should review/patch.
637
+ */
638
+ export function canvasToPageSource(canvas, meta = {}) {
639
+ const ctx = { mobileOnly: !!canvas.mobile_only, notes: [], usedIds: new Set() };
640
+ const source = createPageSource({
641
+ mobileOnly: ctx.mobileOnly,
642
+ settings: {
643
+ title: meta.title ?? "Cloned page",
644
+ description: meta.description ?? meta.title ?? "Cloned page",
645
+ },
646
+ });
647
+ source.page = canvas.sections.map((s) => convertSection(ctx, s));
648
+ source.popup = (canvas.popups ?? []).map((p) => convertPopup(ctx, p));
649
+ if (canvas.truncated) {
650
+ ctx.notes.push("the canvas payload was truncated (styles pruned) — for full typography re-ingest per section (sections:[id]) and patch the affected elements.");
651
+ }
652
+ if (!ctx.mobileOnly) {
653
+ ctx.notes.push("desktop source: the mobile breakpoint is a simple 420/960 horizontal scale — review and polish it.");
654
+ }
655
+ return { source, notes: ctx.notes };
656
+ }