webcake-landing-mcp 1.5.3 → 1.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "v": "1.5.4",
4
+ "d": "09/10/2026",
5
+ "type": "Added",
6
+ "en": "ingest_html and ingest_url now return font_groups (the page's self-hosted @font-face fonts, already shaped for settings.fontGroups) and…",
7
+ "vi": "ingest_html và ingest_url nay trả về font_groups (các font @font-face tự host của trang, đã đóng gói theo đúng dạng settings.fontGroups) và…"
8
+ },
2
9
  {
3
10
  "v": "1.5.3",
4
11
  "d": "17/09/2026",
@@ -33,12 +40,5 @@
33
40
  "type": "Changed",
34
41
  "en": "publish_page now reuses the page's currently-attached domain when custom_domain is omitted — mirroring the editor's publish modal, a plain…",
35
42
  "vi": "publish_page nay tự động dùng lại domain đang gắn với trang khi bỏ trống custom_domain — giống cách modal publish của editor hoạt động, gọi…"
36
- },
37
- {
38
- "v": "1.3.0",
39
- "d": "17/07/2026",
40
- "type": "Added",
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…",
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…"
43
43
  }
44
44
  ]
@@ -167,4 +167,5 @@ REFERENCE INPUT (HTML page to clone or adapt)
167
167
  - SECTION HEIGHTS: every AST section carries size_hint = { height, basis, css? } — the section's desktop height on the 960px canvas (basis:'css' = an explicit height/min-height found in the source, css holds the raw value e.g. "100vh"; basis:'estimate' = content-volume math). Set each rebuilt section's desktop height FROM its size_hint (±15% to fit your actual element placement) instead of the 800px default, so the page's vertical rhythm tracks the source: a 72px header stays a slim bar, a 1200px pricing band stays tall. Mobile is NOT hinted — redo the height per the mobile text math (stacked content is taller).
168
168
  - Map AST section roles to Webcake elements: hero → section (background image/overlay) + text-block H1 + text-block subheading + button; features → group per card (icon rectangle + text-block title + text-block body); stats bar → group with text-block per stat; pricing → group with text-block list + button; footer → section (dark bg) + text-block + links. When the ingested page contains a composite widget (phone/device mockup, chat thread, mini dashboard, browser frame) → rebuild it as ONE html-box, NOT as element soup. The full AST hands you the SOURCE for this: section.widgets = [{ hint, html, css? }] — the widget's cleaned outerHTML plus the stylesheet rules that style it. Build the html-box FROM that html verbatim: inline each css rule into the matching elements' style="" attributes (an html-box has no <style> scope), wrap in a root div with width:100%;height:100%;box-sizing:border-box;overflow:hidden, and size the html-box to the widget's box — do NOT re-imagine the widget's markup from the summary fields.
169
169
  - Reference images are the user's assets — for BOTH intents (adapt AND clone), carry every real image URL found in the AST (images, background_images, og_image, canvas src/background) straight into the matching slot; the save auto-hosts it to the Webcake CDN (no upload_images needed, no hotlinking), so never replace it with a search_images stock photo or a placeholder. intent='adapt' rewrites TEXT for the user's brand, not the imagery; search_images only fills slots that have no source image.
170
+ - FONTS + TYPE SCALE (clone/adapt): the AST's \`font_groups\` are the page's SELF-HOSTED fonts (from its @font-face rules — the platform doesn't have them). Put them into settings.fontGroups VERBATIM and set each text element's styles.fontFamily to "'<group name>', sans-serif" (fontGeneral too when it's the body font); keep the original file URLs — every save downloads them, uploads them to the media collection and files the group into the org's Font Groups (the result's \`fonts\` report), so never skip or substitute the font. Families NOT in font_groups are Google fonts and load by name. Each section's \`text_styles\` {heading, body, cta} and the page-level \`typography\` are the source's COMPUTED type (cascade, @media, var(), clamp(), Tailwind classes): set desktop styles.fontSize = font_size, mobile styles.fontSize = mobile_font_size (else font_size, shrunk only if the mobile text math needs it), and fontWeight/fontFamily/lineHeight/letterSpacing EXACTLY — never eyeball a size the AST already gives. Any fontWeight works (500/600/800 included): the save adds the Google weight links the build would otherwise skip. An absolute-canvas \`source\` already carries all of this. For ingest_html of HTML copied from a live site, pass base_url so external stylesheets (and their fonts) are read. Custom font files belong to the source site's license — clone fonts only for pages the user owns or is allowed to copy; for a third-party ADAPT, prefer the nearest Google family over its font_groups.
170
171
  - VERIFY THE CLONE VISUALLY (clones only — where fidelity is the whole point): for a clone/adapt build, do the VISUAL CHECK from BUILDING step 7 ONCE — screenshot the rendered preview with your own browser/screenshot capability (or render_preview as fallback), compare it side-by-side with the reference (screenshot the reference URL too if you only have HTML), patch_page the clear mismatches, and re-check ONLY if you changed something material (don't loop reflexively). For a NON-clone page, validate_page + the save-time auto-fix are the gate — skip the screenshot unless the user asks or you have a specific aesthetic doubt. Eyeballing a reference and rebuilding once typically lands ~60% similar; a targeted look-and-patch pass closes the gap when fidelity matters.`;
@@ -369,15 +369,25 @@ function normalizeEditorValues(node) {
369
369
  normalizeEditorValues(child);
370
370
  }
371
371
  }
372
- /** settings.width_section carries the canvas width the coords live in — keep it numeric. */
372
+ /**
373
+ * settings.width_section carries the canvas width the coords live in — keep it
374
+ * numeric, and default it (960/420) when absent: the build host reads
375
+ * `settings.width_section.mobile` unguarded, so a hand-written settings object
376
+ * without it saves fine but crashes every publish.
377
+ */
373
378
  function normalizeSettings(source) {
374
- const ws = source?.settings?.width_section;
375
- if (!ws || typeof ws !== "object")
379
+ const settings = source?.settings;
380
+ if (!settings || typeof settings !== "object")
376
381
  return;
382
+ if (!settings.width_section || typeof settings.width_section !== "object")
383
+ settings.width_section = {};
384
+ const ws = settings.width_section;
377
385
  for (const bp of ["desktop", "mobile"]) {
378
386
  if (ws[bp] !== undefined)
379
387
  ws[bp] = numFromEditorString(ws[bp]);
380
388
  }
389
+ ws.desktop ??= CANVAS.desktopWidth;
390
+ ws.mobile ??= CANVAS.mobileWidth;
381
391
  }
382
392
  /** Apply all post-expand normalizations to every node in a page source. */
383
393
  function normalizeSource(source) {
@@ -44,6 +44,7 @@
44
44
  "favicon": { "type": "string" },
45
45
  "thumbnail": { "type": "string", "description": "Social/share thumbnail URL." },
46
46
  "fontGeneral": { "type": "string", "description": "Default page font-family, e.g. \"'Roboto', sans-serif\"." },
47
+ "fontGroups": { "type": "array", "description": "Custom (non-Google) fonts the build renders as @font-face: [{ name: <CSS family elements use in styles.fontFamily>, fonts: [{ name: <file name WITH extension — the format is sniffed from it>, url, font_weight: thin|light|normal|medium|semibold|bold|extrabold|black, font_style: normal|italic }] }]. External font URLs are uploaded to the media collection on save.", "items": { "type": "object" } },
47
48
  "width_section": {
48
49
  "type": "object",
49
50
  "description": "The canvas width per breakpoint (px) — the coordinate space every element's top/left/width lives in. Editor-allowed values ONLY: desktop 960 or 1200, mobile 420 or 360 (default 960/420). Choose 1200 for wide/multi-column/editorial layouts or when cloning a reference wider than 960 (e.g. Google Stitch ~1280) so columns aren't squished; 360 mobile to match a ~360–390 design. Changing it does NOT rescale existing coords.",
@@ -16,8 +16,10 @@
16
16
  import { parse } from "node-html-parser";
17
17
  import { extractStyleBlocks, extractGoogleFonts, extractGradients, fixMojibake } from "./ingest/stylesheets.js";
18
18
  import { extractTailwindConfig } from "./ingest/tailwind.js";
19
- import { findSections, classifySection, computeSizeHint, detectWidgets, detectHoverEffects, brandHints } from "./ingest/semantic.js";
19
+ import { findSections, classifySection, computeSizeHint, detectWidgets, detectHoverEffects, brandHints, pickHeading } from "./ingest/semantic.js";
20
20
  import { parseAbsoluteCanvas, canvasRoleSections, stripCdnSizePrefix } from "./ingest/canvas.js";
21
+ import { extractFontFaces, toFontGroups, extractTypography, parseCssRules, makeTypeResolver, resolveCssVars, rootFontPx, stripComments } from "./ingest/fonts.js";
22
+ import { isAllowedScreenshotUrl } from "./screenshot-playwright.js";
21
23
  // Re-export the public surface so existing imports of "./persistence/html-ingest.js"
22
24
  // (parseHtml, fetchHtml, and the IngestedAst/IngestedCanvas/CanvasElement/CanvasSection
23
25
  // types) keep resolving unchanged.
@@ -26,6 +28,7 @@ export * from "./ingest/stylesheets.js";
26
28
  export * from "./ingest/tailwind.js";
27
29
  export * from "./ingest/semantic.js";
28
30
  export * from "./ingest/canvas.js";
31
+ export * from "./ingest/fonts.js";
29
32
  const FETCH_TIMEOUT_MS = 10_000;
30
33
  const MAX_HTML_BYTES = 2_000_000; // 2MB
31
34
  const FULL_SIZE_CAP = 25_000; // ~25 KB serialized cap for full mode
@@ -44,6 +47,12 @@ export function parseHtml(html, detail = "compact", opts = {}) {
44
47
  const styleBlocks = extractStyleBlocks(html);
45
48
  const googleFonts = extractGoogleFonts(html);
46
49
  const tw = extractTailwindConfig(html);
50
+ const cssSources = orderedCss(html, opts.baseUrl, opts.extraCss ?? []);
51
+ const fontGroups = toFontGroups(extractFontFaces(cssSources));
52
+ const cssRules = resolveCssVars(cssSources.flatMap((c) => parseCssRules(c.css)));
53
+ const remPx = rootFontPx(cssRules);
54
+ const typography = extractTypography(cssRules, remPx);
55
+ const fontFields = { ...(fontGroups.length ? { font_groups: fontGroups } : {}), ...(typography ? { typography } : {}) };
47
56
  const root = parse(html, { lowerCaseTagName: true });
48
57
  const head = root.querySelector("head");
49
58
  const title = head?.querySelector("title")?.text?.trim() || undefined;
@@ -74,6 +83,7 @@ export function parseHtml(html, detail = "compact", opts = {}) {
74
83
  palette: hints.palette,
75
84
  design_tokens: hints.design_tokens,
76
85
  background_images: bg.length ? bg : undefined,
86
+ ...fontFields,
77
87
  warnings: warnings.length ? warnings : undefined,
78
88
  };
79
89
  }
@@ -91,10 +101,22 @@ export function parseHtml(html, detail = "compact", opts = {}) {
91
101
  ],
92
102
  };
93
103
  }
104
+ // Computed type per text role, so a hand-rebuild copies exact sizes/weights.
105
+ const resolveType = makeTypeResolver(cssRules, tw ?? undefined, remPx);
106
+ const textStyles = (el) => {
107
+ const heading = resolveType(pickHeading(el));
108
+ const bodyText = resolveType(el.querySelector("p") ?? undefined);
109
+ const cta = resolveType(el.querySelector("button") ?? el.querySelector('a[class*="btn"], a[class*="button"]') ?? undefined);
110
+ const out = { ...(heading ? { heading } : {}), ...(bodyText ? { body: bodyText } : {}), ...(cta ? { cta } : {}) };
111
+ return Object.keys(out).length ? out : undefined;
112
+ };
94
113
  const sectionEls = findSections(body);
95
114
  const sections = sectionEls.map((el) => {
96
115
  const sec = classifySection(el, detail);
97
116
  sec.size_hint = computeSizeHint(el, sec, styleBlocks);
117
+ const styles = textStyles(el);
118
+ if (styles)
119
+ sec.text_styles = styles;
98
120
  const hover = detectHoverEffects(el);
99
121
  if (hover.length)
100
122
  sec.hover_effects = hover;
@@ -122,6 +144,7 @@ export function parseHtml(html, detail = "compact", opts = {}) {
122
144
  // and the Play CDN never emits the resolved CSS.
123
145
  gradients: hints.tailwind_gradients.length ? hints.tailwind_gradients : undefined,
124
146
  background_images: hints.background_images.length ? hints.background_images : undefined,
147
+ ...fontFields,
125
148
  warnings: warnings.length ? warnings : undefined,
126
149
  };
127
150
  if (detail !== "full")
@@ -185,25 +208,11 @@ export async function fetchHtml(url, opts = {}) {
185
208
  if (!/html|xml|text/i.test(ctype)) {
186
209
  return { ok: false, status: res.status, error: `Content-Type ${ctype} is not HTML` };
187
210
  }
188
- const reader = res.body?.getReader();
189
- if (!reader)
211
+ const buf = await readCapped(res, MAX_HTML_BYTES);
212
+ if (buf === "too-large")
213
+ return { ok: false, status: res.status, error: `Response exceeded ${MAX_HTML_BYTES} bytes` };
214
+ if (!buf)
190
215
  return { ok: false, status: res.status, error: "no response body" };
191
- const chunks = [];
192
- let total = 0;
193
- while (true) {
194
- const { value, done } = await reader.read();
195
- if (done)
196
- break;
197
- if (!value)
198
- continue;
199
- total += value.length;
200
- if (total > MAX_HTML_BYTES) {
201
- await reader.cancel().catch(() => { });
202
- return { ok: false, status: res.status, error: `Response exceeded ${MAX_HTML_BYTES} bytes` };
203
- }
204
- chunks.push(value);
205
- }
206
- const buf = Buffer.concat(chunks.map((c) => Buffer.from(c)));
207
216
  return { ok: true, status: res.status, html: buf.toString("utf-8") };
208
217
  }
209
218
  catch (e) {
@@ -213,3 +222,192 @@ export async function fetchHtml(url, opts = {}) {
213
222
  clearTimeout(timer);
214
223
  }
215
224
  }
225
+ /** Stream a response body, giving up past `max` bytes (never buffers an unbounded body). */
226
+ async function readCapped(res, max) {
227
+ const reader = res.body?.getReader();
228
+ if (!reader)
229
+ return null;
230
+ const chunks = [];
231
+ let total = 0;
232
+ while (true) {
233
+ const { value, done } = await reader.read();
234
+ if (done)
235
+ break;
236
+ if (!value)
237
+ continue;
238
+ total += value.length;
239
+ if (total > max) {
240
+ await reader.cancel().catch(() => { });
241
+ return "too-large";
242
+ }
243
+ chunks.push(value);
244
+ }
245
+ return Buffer.concat(chunks.map((c) => Buffer.from(c)));
246
+ }
247
+ const MAX_STYLESHEETS = 12;
248
+ const MAX_CSS_BYTES = 1_000_000;
249
+ const MAX_REDIRECTS = 3;
250
+ /** One attribute of a raw tag (quoted or bare); `data-href` never matches `href`. */
251
+ function tagAttr(tag, name) {
252
+ const m = new RegExp(`[\\s"']${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, "i").exec(tag);
253
+ return m ? (m[1] ?? m[2] ?? m[3]) : undefined;
254
+ }
255
+ /**
256
+ * The page's stylesheet sources in DOCUMENT order (the cascade order): each
257
+ * `<link rel=stylesheet>` (or `rel=preload as=style`, the async-CSS pattern) by
258
+ * href, each `<style>` by index. Print-only links are dropped.
259
+ */
260
+ export function stylesheetTags(html) {
261
+ const out = [];
262
+ let styleIndex = 0;
263
+ for (const m of html.matchAll(/<link\b[^>]*>|<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
264
+ if (m[0][1].toLowerCase() === "s") {
265
+ out.push({ kind: "style", index: styleIndex++, css: m[1] ?? "" });
266
+ continue;
267
+ }
268
+ const rel = (tagAttr(m[0], "rel") ?? "").toLowerCase();
269
+ const isCss = /\bstylesheet\b/.test(rel) || (/\bpreload\b/.test(rel) && (tagAttr(m[0], "as") ?? "").toLowerCase() === "style");
270
+ const media = (tagAttr(m[0], "media") ?? "").toLowerCase();
271
+ const href = tagAttr(m[0], "href");
272
+ if (isCss && href && !(media.includes("print") && !media.includes("screen")))
273
+ out.push({ kind: "link", href: href.replace(/&amp;/g, "&") });
274
+ }
275
+ return out;
276
+ }
277
+ /** `@import url(x)` / `@import "x"` targets of a CSS text (comments stripped; print-only imports skipped). */
278
+ function cssImports(css) {
279
+ const out = [];
280
+ for (const m of stripComments(css).matchAll(/@import\s+(?:url\(\s*)?["']?([^"')\s;]+)["']?\s*\)?([^;]*);/gi)) {
281
+ if (!/\bprint\b/i.test(m[2]) || /\bscreen\b/i.test(m[2]))
282
+ out.push(m[1]);
283
+ }
284
+ return out;
285
+ }
286
+ /**
287
+ * GET a stylesheet the crawled page pointed us at — that URL is chosen by a
288
+ * third-party page, so: http(s) only, no private/loopback/link-local host (SSRF;
289
+ * same policy + RENDER_ALLOW_PRIVATE opt-out as the screenshot route), redirects
290
+ * followed MANUALLY so each hop is re-checked, CSS/text content-type only, and the
291
+ * body streamed with a byte cap.
292
+ */
293
+ async function fetchCss(url) {
294
+ let target = url;
295
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
296
+ if (!isAllowedScreenshotUrl(target).ok)
297
+ return null;
298
+ let res;
299
+ try {
300
+ res = await fetch(target, {
301
+ redirect: "manual",
302
+ signal: AbortSignal.timeout(8_000),
303
+ headers: { "User-Agent": "Mozilla/5.0 (compatible; webcake-landing-mcp/ingest_url)", Accept: "text/css,*/*;q=0.1" },
304
+ });
305
+ }
306
+ catch {
307
+ return null;
308
+ }
309
+ if (res.status >= 300 && res.status < 400) {
310
+ const loc = res.headers.get("location");
311
+ if (!loc)
312
+ return null;
313
+ try {
314
+ target = new URL(loc, target).href;
315
+ }
316
+ catch {
317
+ return null;
318
+ }
319
+ continue;
320
+ }
321
+ const ctype = (res.headers.get("content-type") ?? "").toLowerCase();
322
+ if (!res.ok || (ctype && !/css|text\/plain|octet-stream/.test(ctype)))
323
+ return null;
324
+ try {
325
+ const buf = await readCapped(res, MAX_CSS_BYTES);
326
+ return buf && buf !== "too-large" ? buf.toString("utf-8") : null;
327
+ }
328
+ catch {
329
+ return null;
330
+ }
331
+ }
332
+ return null;
333
+ }
334
+ /**
335
+ * Fetch the page's external stylesheets — and what they (or inline <style> blocks)
336
+ * `@import`, two levels deep — so `@font-face` + type rules outside the HTML are
337
+ * seen. Each owner's list is ordered imports-first, like the cascade. Google Fonts
338
+ * CSS is skipped (those families load by name). Best-effort: a failed sheet is skipped.
339
+ */
340
+ export async function fetchStylesheets(html, pageUrl) {
341
+ const seen = new Set();
342
+ const claim = (href, base) => {
343
+ if (/fonts\.googleapis\.com/i.test(href) || seen.size >= MAX_STYLESHEETS)
344
+ return undefined;
345
+ try {
346
+ const abs = new URL(href, base).href;
347
+ if (!/^https?:/i.test(abs) || seen.has(abs))
348
+ return undefined;
349
+ seen.add(abs);
350
+ return abs;
351
+ }
352
+ catch {
353
+ return undefined;
354
+ }
355
+ };
356
+ // One sheet → [its imports' sheets (recursively, in order)…, itself].
357
+ const load = async (abs, owner, depth) => {
358
+ const css = await fetchCss(abs);
359
+ if (css === null)
360
+ return [];
361
+ const imports = depth < 2 ? cssImports(css).map((h) => claim(h, abs)).filter((h) => !!h) : [];
362
+ const nested = await Promise.all(imports.map((h) => load(h, owner, depth + 1)));
363
+ return [...nested.flat(), { owner, css, base: abs }];
364
+ };
365
+ const jobs = [];
366
+ for (const t of stylesheetTags(html)) {
367
+ if (t.kind === "link") {
368
+ const abs = claim(t.href, pageUrl);
369
+ if (abs)
370
+ jobs.push(load(abs, abs, 0));
371
+ }
372
+ else {
373
+ for (const h of cssImports(t.css)) {
374
+ const abs = claim(h, pageUrl);
375
+ if (abs)
376
+ jobs.push(load(abs, `style:${t.index}`, 1));
377
+ }
378
+ }
379
+ }
380
+ return (await Promise.all(jobs)).flat();
381
+ }
382
+ /**
383
+ * All CSS of the page in cascade (document) order: for each <link> its fetched
384
+ * sheets, for each <style> its fetched @imports then the block itself; anything
385
+ * fetched but unplaced goes last.
386
+ */
387
+ function orderedCss(html, baseUrl, extra) {
388
+ const out = [];
389
+ const used = new Set();
390
+ const take = (owner) => {
391
+ for (const e of extra)
392
+ if (e.owner === owner && !used.has(e)) {
393
+ used.add(e);
394
+ out.push(e);
395
+ }
396
+ };
397
+ for (const t of stylesheetTags(html)) {
398
+ if (t.kind === "link") {
399
+ try {
400
+ take(new URL(t.href, baseUrl).href);
401
+ }
402
+ catch { /* relative href without a base (ingest_html) */ }
403
+ }
404
+ else {
405
+ take(`style:${t.index}`);
406
+ out.push({ css: t.css, base: baseUrl });
407
+ }
408
+ }
409
+ for (const e of extra)
410
+ if (!used.has(e))
411
+ out.push(e);
412
+ return out;
413
+ }