webcake-landing-mcp 1.0.60 → 1.0.61
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 +7 -7
- package/dist/domains/landing/guide.js +2 -1
- package/dist/persistence/html-ingest.js +222 -9
- package/dist/smoke.js +43 -0
- package/dist/tools/ingest.js +3 -3
- package/package.json +1 -1
package/dist/changelog.json
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "1.0.61",
|
|
4
|
+
"d": "11/06/2026",
|
|
5
|
+
"type": "Added",
|
|
6
|
+
"en": "ingest_html and ingest_url now return a size_hint field ({ height, basis, css? }) on every AST section, providing the desktop section height in px…",
|
|
7
|
+
"vi": "ingest_html và ingest_url nay trả về trường size_hint ({ height, basis, css? }) trên mỗi section trong AST, cung cấp chiều cao desktop của section…"
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"v": "1.0.60",
|
|
4
11
|
"d": "11/06/2026",
|
|
@@ -33,12 +40,5 @@
|
|
|
33
40
|
"type": "Added",
|
|
34
41
|
"en": "The expand pipeline now automatically derives styles.background from specials.src for every image-block node; the live published renderer reads only…",
|
|
35
42
|
"vi": "Pipeline expand nay tự động tính styles.background từ specials.src cho mọi node image-block; renderer trên trang published chỉ đọc…"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"v": "1.0.55",
|
|
39
|
-
"d": "10/06/2026",
|
|
40
|
-
"type": "Fixed",
|
|
41
|
-
"en": "The install command now correctly locates claude_desktop_config.json on Windows when Claude Desktop was installed from the Microsoft Store: the…",
|
|
42
|
-
"vi": "Lệnh install nay xác định đúng đường dẫn claude_desktop_config.json trên Windows khi Claude Desktop được cài từ Microsoft Store: bản Store bị…"
|
|
43
43
|
}
|
|
44
44
|
]
|
|
@@ -154,5 +154,6 @@ EDITING an existing page
|
|
|
154
154
|
|
|
155
155
|
REFERENCE INPUT (HTML page to clone or adapt)
|
|
156
156
|
- ingest_html(html, detail:'full') / ingest_url(url, detail:'full') returns a richer AST for clone-quality rebuilds: CSS custom-property palette (design tokens by name), background_images from stylesheets (hero/CTA bg images invisible to a plain img scan), per-section blocks (repeating card/tile/step structures with title/body/image/cta), li lists, gradients, and images as { src, alt } objects. Use detail:'compact' (default) for a quick layout-only reference.
|
|
157
|
-
-
|
|
157
|
+
- 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).
|
|
158
|
+
- 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.
|
|
158
159
|
- Reference images are the user's assets — for BOTH intents (adapt AND clone), re-host every real image URL found in the AST (images, background_images, og_image) via upload_images and reuse it in the matching slot; never hotlink and never replace it with a search_images stock photo. intent='adapt' rewrites TEXT for the user's brand, not the imagery; search_images only fills slots that have no source image.`;
|
|
@@ -165,7 +165,16 @@ export function parseHtml(html, detail = "compact") {
|
|
|
165
165
|
};
|
|
166
166
|
}
|
|
167
167
|
const sectionEls = findSections(body);
|
|
168
|
-
const sections = sectionEls.map((el) =>
|
|
168
|
+
const sections = sectionEls.map((el) => {
|
|
169
|
+
const sec = classifySection(el, detail);
|
|
170
|
+
sec.size_hint = computeSizeHint(el, sec, styleBlocks);
|
|
171
|
+
if (detail === "full") {
|
|
172
|
+
const widgets = detectWidgets(el, styleBlocks);
|
|
173
|
+
if (widgets.length)
|
|
174
|
+
sec.widgets = widgets;
|
|
175
|
+
}
|
|
176
|
+
return sec;
|
|
177
|
+
});
|
|
169
178
|
// Brand hints from inline styles (both modes).
|
|
170
179
|
const styleAttrs = [];
|
|
171
180
|
body.querySelectorAll("[style]").forEach((el) => {
|
|
@@ -204,25 +213,33 @@ export function parseHtml(html, detail = "compact") {
|
|
|
204
213
|
...base,
|
|
205
214
|
gradients: gradients.length ? gradients : undefined,
|
|
206
215
|
};
|
|
207
|
-
// Size-cap:
|
|
208
|
-
|
|
209
|
-
if (
|
|
210
|
-
// Strip block bodies.
|
|
216
|
+
// Size-cap shedding order: blocks[].body → widgets[].css → lists → widgets
|
|
217
|
+
// (widget html goes last — it's the clone-fidelity payload of full mode).
|
|
218
|
+
if (JSON.stringify(result).length > FULL_SIZE_CAP) {
|
|
211
219
|
for (const sec of result.sections) {
|
|
212
|
-
if (sec.blocks)
|
|
220
|
+
if (sec.blocks)
|
|
213
221
|
for (const blk of sec.blocks)
|
|
214
222
|
delete blk.body;
|
|
223
|
+
}
|
|
224
|
+
if (JSON.stringify(result).length > FULL_SIZE_CAP) {
|
|
225
|
+
for (const sec of result.sections) {
|
|
226
|
+
if (sec.widgets)
|
|
227
|
+
for (const w of sec.widgets)
|
|
228
|
+
delete w.css;
|
|
215
229
|
}
|
|
216
230
|
}
|
|
217
|
-
|
|
218
|
-
if (s2.length > FULL_SIZE_CAP) {
|
|
219
|
-
// Truncate lists.
|
|
231
|
+
if (JSON.stringify(result).length > FULL_SIZE_CAP) {
|
|
220
232
|
for (const sec of result.sections) {
|
|
221
233
|
if (sec.lists && sec.lists.length > 5)
|
|
222
234
|
sec.lists = sec.lists.slice(0, 5);
|
|
223
235
|
}
|
|
224
236
|
result.truncated = true;
|
|
225
237
|
}
|
|
238
|
+
if (JSON.stringify(result).length > FULL_SIZE_CAP) {
|
|
239
|
+
for (const sec of result.sections)
|
|
240
|
+
delete sec.widgets;
|
|
241
|
+
result.truncated = true;
|
|
242
|
+
}
|
|
226
243
|
}
|
|
227
244
|
return result;
|
|
228
245
|
}
|
|
@@ -616,6 +633,202 @@ function pickLists(el, _blocks) {
|
|
|
616
633
|
.filter((t) => t.length > 3 && t.length < 200)
|
|
617
634
|
.slice(0, 15);
|
|
618
635
|
}
|
|
636
|
+
// ─── size hint (desktop section height) ──────────────────────────────────────
|
|
637
|
+
const SIZE_DECL_RE = /(?:^|[;{\s])(min-height|height)\s*:\s*([\d.]+)(px|vh|rem|em)\b/gi;
|
|
638
|
+
const VH_PX = 8; // 1vh ≈ 8px (~800px viewport), so a 100vh hero lands near the editor's 800px default band
|
|
639
|
+
function clamp(v, lo, hi) {
|
|
640
|
+
return Math.min(hi, Math.max(lo, v));
|
|
641
|
+
}
|
|
642
|
+
/** lines ≈ ceil(chars × fontSize × glyphFactor / width) — same model as the generation guide's text math. */
|
|
643
|
+
function textLines(chars, fontSize, width, factor = 0.55) {
|
|
644
|
+
if (chars <= 0)
|
|
645
|
+
return 0;
|
|
646
|
+
return Math.max(1, Math.ceil((chars * fontSize * factor) / width));
|
|
647
|
+
}
|
|
648
|
+
/** Parse height/min-height declarations out of a CSS declaration string. */
|
|
649
|
+
function sizeDecls(decl) {
|
|
650
|
+
const out = [];
|
|
651
|
+
SIZE_DECL_RE.lastIndex = 0;
|
|
652
|
+
let m;
|
|
653
|
+
while ((m = SIZE_DECL_RE.exec(decl)) !== null) {
|
|
654
|
+
const kind = m[1].toLowerCase();
|
|
655
|
+
const v = parseFloat(m[2]);
|
|
656
|
+
const unit = m[3].toLowerCase();
|
|
657
|
+
const px = unit === "px" ? v : unit === "vh" ? v * VH_PX : v * 16; // rem/em ≈ 16px
|
|
658
|
+
if (px >= 40 && px <= 2400)
|
|
659
|
+
out.push({ kind, px: Math.round(px), raw: `${m[2]}${unit}` });
|
|
660
|
+
}
|
|
661
|
+
return out;
|
|
662
|
+
}
|
|
663
|
+
function escapeRe(s) {
|
|
664
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Explicit height/min-height for a section element: its inline style, plus any
|
|
668
|
+
* stylesheet rule whose selector mentions the element's #id or one of its
|
|
669
|
+
* classes (whole-token match — `.hero` doesn't match `.hero-card`). Regex-level
|
|
670
|
+
* on purpose; full selector matching needs a renderer.
|
|
671
|
+
*/
|
|
672
|
+
function cssSizeDecls(el, styleBlocks) {
|
|
673
|
+
const out = [];
|
|
674
|
+
const inline = el.getAttribute("style");
|
|
675
|
+
if (inline)
|
|
676
|
+
out.push(...sizeDecls(inline));
|
|
677
|
+
const id = el.getAttribute("id");
|
|
678
|
+
const classes = (el.getAttribute("class") ?? "").trim().split(/\s+/).filter(Boolean).slice(0, 4);
|
|
679
|
+
const tokens = [...(id ? ["#" + id] : []), ...classes.map((c) => "." + c)];
|
|
680
|
+
if (tokens.length) {
|
|
681
|
+
const matchers = tokens.map((t) => new RegExp(escapeRe(t) + "(?![\\w-])"));
|
|
682
|
+
const ruleRe = /([^{}]+)\{([^{}]*)\}/g;
|
|
683
|
+
for (const css of styleBlocks) {
|
|
684
|
+
ruleRe.lastIndex = 0;
|
|
685
|
+
let m;
|
|
686
|
+
while ((m = ruleRe.exec(css)) !== null) {
|
|
687
|
+
if (matchers.some((re) => re.test(m[1])))
|
|
688
|
+
out.push(...sizeDecls(m[2]));
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return out;
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Content-volume estimate of the section's desktop height (960px canvas,
|
|
696
|
+
* ~800px content column) so the rebuilt band is proportional to the source
|
|
697
|
+
* instead of a flat 800px default.
|
|
698
|
+
*/
|
|
699
|
+
function estimateSectionHeight(el, sec) {
|
|
700
|
+
const role = sec.role;
|
|
701
|
+
if (role === "header")
|
|
702
|
+
return 72;
|
|
703
|
+
if (role === "footer") {
|
|
704
|
+
const linkRows = Math.ceil((sec.links?.length ?? 0) / 4);
|
|
705
|
+
return clamp(120 + linkRows * 32 + (sec.paragraphs?.length ?? 0) * 24, 140, 480);
|
|
706
|
+
}
|
|
707
|
+
let h = 140; // band padding (top + bottom)
|
|
708
|
+
if (sec.heading) {
|
|
709
|
+
const fs = role === "hero" ? 48 : 36;
|
|
710
|
+
h += textLines(sec.heading.length, fs, 800, 0.6) * Math.round(fs * 1.2) + 20;
|
|
711
|
+
}
|
|
712
|
+
if (sec.subheading)
|
|
713
|
+
h += textLines(sec.subheading.length, 18, 640) * 27 + 16;
|
|
714
|
+
for (const p of sec.paragraphs ?? [])
|
|
715
|
+
h += textLines(p.length, 16, 640) * 24 + 12;
|
|
716
|
+
if (sec.ctas?.length)
|
|
717
|
+
h += 76;
|
|
718
|
+
if (sec.form_fields?.length)
|
|
719
|
+
h += sec.form_fields.length * 64 + 24;
|
|
720
|
+
// Card/tile rows: full mode carries blocks; compact recounts from the DOM.
|
|
721
|
+
let cards = sec.blocks?.length ?? 0;
|
|
722
|
+
if (!cards && (role === "features" || role === "pricing" || role === "testimonials")) {
|
|
723
|
+
cards = Math.min(countFeatureBlocks(el), 12);
|
|
724
|
+
}
|
|
725
|
+
if (cards)
|
|
726
|
+
h += Math.ceil(cards / 3) * (role === "pricing" ? 420 : 260) + 24;
|
|
727
|
+
else if (sec.lists?.length)
|
|
728
|
+
h += sec.lists.length * 30;
|
|
729
|
+
const imgCount = sec.images?.length ?? 0;
|
|
730
|
+
if (role === "gallery")
|
|
731
|
+
h += Math.ceil(imgCount / 3) * 260;
|
|
732
|
+
else if (role === "hero")
|
|
733
|
+
h = Math.max(h, imgCount ? 560 : 480);
|
|
734
|
+
else if (imgCount)
|
|
735
|
+
h += 320; // a content image alongside/below the text
|
|
736
|
+
return clamp(Math.round(h / 10) * 10, 160, 1600);
|
|
737
|
+
}
|
|
738
|
+
function computeSizeHint(el, sec, styleBlocks) {
|
|
739
|
+
const estimate = estimateSectionHeight(el, sec);
|
|
740
|
+
const decls = cssSizeDecls(el, styleBlocks);
|
|
741
|
+
const fixed = decls.filter((d) => d.kind === "height").sort((a, b) => b.px - a.px)[0];
|
|
742
|
+
// An explicit height pins the band; min-height grows with content, so take the larger.
|
|
743
|
+
if (fixed)
|
|
744
|
+
return { height: fixed.px, basis: "css", css: fixed.raw };
|
|
745
|
+
const min = decls.filter((d) => d.kind === "min-height").sort((a, b) => b.px - a.px)[0];
|
|
746
|
+
if (min)
|
|
747
|
+
return { height: Math.max(min.px, estimate), basis: "css", css: min.raw };
|
|
748
|
+
return { height: estimate, basis: "estimate" };
|
|
749
|
+
}
|
|
750
|
+
// ─── full-mode: composite-widget extraction (html-box source) ────────────────
|
|
751
|
+
// Class/id keywords that mark a composite visual the guide rebuilds as ONE
|
|
752
|
+
// html-box. Conservative on purpose — generic words (card, window, slider)
|
|
753
|
+
// over-match ordinary content.
|
|
754
|
+
const WIDGET_HINT_RE = /\b(mockup|phone|device|browser|terminal|console|dashboard|chat|inbox|player)\b/i;
|
|
755
|
+
const WIDGET_HTML_CAP = 8000;
|
|
756
|
+
const WIDGET_CSS_CAP = 4000;
|
|
757
|
+
const MAX_WIDGETS_PER_SECTION = 2;
|
|
758
|
+
/** outerHTML cleaned for html-box reuse: scripts/styles stripped, whitespace collapsed. */
|
|
759
|
+
function cleanWidgetHtml(el) {
|
|
760
|
+
// Re-parse a copy so removals don't mutate the tree other pickers read.
|
|
761
|
+
const frag = parse(el.toString(), { lowerCaseTagName: true });
|
|
762
|
+
frag.querySelectorAll("script, style, noscript").forEach((n) => n.remove());
|
|
763
|
+
return frag
|
|
764
|
+
.toString()
|
|
765
|
+
.replace(/<!--[\s\S]*?-->/g, "")
|
|
766
|
+
.replace(/>\s+</g, "><")
|
|
767
|
+
.replace(/\s{2,}/g, " ")
|
|
768
|
+
.trim();
|
|
769
|
+
}
|
|
770
|
+
/** Stylesheet rules whose selector mentions a class/id used inside the widget HTML. */
|
|
771
|
+
function widgetCss(html, styleBlocks) {
|
|
772
|
+
const tokens = new Set();
|
|
773
|
+
for (const m of html.matchAll(/class="([^"]+)"/g)) {
|
|
774
|
+
for (const c of m[1].trim().split(/\s+/))
|
|
775
|
+
if (c)
|
|
776
|
+
tokens.add("." + c);
|
|
777
|
+
}
|
|
778
|
+
for (const m of html.matchAll(/id="([^"]+)"/g)) {
|
|
779
|
+
if (m[1].trim())
|
|
780
|
+
tokens.add("#" + m[1].trim());
|
|
781
|
+
}
|
|
782
|
+
if (!tokens.size)
|
|
783
|
+
return undefined;
|
|
784
|
+
const matchers = [...tokens].map((t) => new RegExp(escapeRe(t) + "(?![\\w-])"));
|
|
785
|
+
const parts = [];
|
|
786
|
+
let total = 0;
|
|
787
|
+
const ruleRe = /([^{}]+)\{([^{}]*)\}/g;
|
|
788
|
+
for (const css of styleBlocks) {
|
|
789
|
+
ruleRe.lastIndex = 0;
|
|
790
|
+
let m;
|
|
791
|
+
while ((m = ruleRe.exec(css)) !== null) {
|
|
792
|
+
const sel = m[1].trim();
|
|
793
|
+
if (!matchers.some((re) => re.test(sel)))
|
|
794
|
+
continue;
|
|
795
|
+
const rule = `${sel.replace(/\s+/g, " ")}{${m[2].trim().replace(/\s+/g, " ")}}`;
|
|
796
|
+
if (total + rule.length > WIDGET_CSS_CAP)
|
|
797
|
+
return parts.join("");
|
|
798
|
+
parts.push(rule);
|
|
799
|
+
total += rule.length;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return parts.length ? parts.join("") : undefined;
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Find composite-widget candidates inside a section: OUTERMOST descendants
|
|
806
|
+
* whose class/id matches WIDGET_HINT_RE and that have real internal structure
|
|
807
|
+
* (≥3 descendant elements). Emits the cleaned HTML + matching CSS so the
|
|
808
|
+
* model's html-box reproduces the source instead of approximating it.
|
|
809
|
+
*/
|
|
810
|
+
function detectWidgets(el, styleBlocks) {
|
|
811
|
+
const out = [];
|
|
812
|
+
const walk = (node) => {
|
|
813
|
+
for (const k of elementChildren(node)) {
|
|
814
|
+
if (out.length >= MAX_WIDGETS_PER_SECTION)
|
|
815
|
+
return;
|
|
816
|
+
const idCls = (k.getAttribute("id") ?? "") + " " + (k.getAttribute("class") ?? "");
|
|
817
|
+
const m = idCls.match(WIDGET_HINT_RE);
|
|
818
|
+
if (m && k.querySelectorAll("*").length >= 3) {
|
|
819
|
+
const html = cleanWidgetHtml(k);
|
|
820
|
+
if (html && html.length <= WIDGET_HTML_CAP) {
|
|
821
|
+
const css = widgetCss(html, styleBlocks);
|
|
822
|
+
out.push(css ? { hint: m[1].toLowerCase(), html, css } : { hint: m[1].toLowerCase(), html });
|
|
823
|
+
continue; // outermost only — don't descend into an emitted widget
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
walk(k);
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
walk(el);
|
|
830
|
+
return out;
|
|
831
|
+
}
|
|
619
832
|
// ─── color / font helpers ────────────────────────────────────────────────────
|
|
620
833
|
function elText(el) {
|
|
621
834
|
const t = el?.text?.trim();
|
package/dist/smoke.js
CHANGED
|
@@ -236,6 +236,28 @@ check("ingest: hero image captured", (hero?.images?.length ?? 0) > 0, hero?.imag
|
|
|
236
236
|
const form = ast.sections.find((s) => s.role === "form");
|
|
237
237
|
check("ingest: form fields captured", (form?.form_fields?.length ?? 0) >= 2, form?.form_fields);
|
|
238
238
|
check("ingest: form submit CTA captured", !!form?.ctas?.[0]?.text?.includes("Place"), form?.ctas);
|
|
239
|
+
console.log("== ingest: size_hint (desktop section heights) ==");
|
|
240
|
+
check("ingest: every section has a size_hint", ast.sections.every((s) => (s.size_hint?.height ?? 0) > 0), ast.sections.map((s) => s.size_hint));
|
|
241
|
+
const headerHint = ast.sections.find((s) => s.role === "header")?.size_hint;
|
|
242
|
+
check("ingest: header size_hint is a slim bar", !!headerHint && headerHint.height <= 120, headerHint);
|
|
243
|
+
const heroHint = ast.sections.find((s) => s.role === "hero")?.size_hint;
|
|
244
|
+
check("ingest: hero size_hint is a tall band", !!heroHint && heroHint.height >= 400, heroHint);
|
|
245
|
+
const footerHint = ast.sections.find((s) => s.role === "footer")?.size_hint;
|
|
246
|
+
check("ingest: footer size_hint shorter than hero", !!footerHint && !!heroHint && footerHint.height < heroHint.height, { footerHint, heroHint });
|
|
247
|
+
const cssHintHtml = `<!DOCTYPE html><html><head><style>
|
|
248
|
+
.hero { min-height: 100vh; }
|
|
249
|
+
#promo { height: 560px; }
|
|
250
|
+
</style></head><body>
|
|
251
|
+
<section class="hero"><h1>Big hero</h1><p>Tagline goes here for the hero band.</p><button>Go</button></section>
|
|
252
|
+
<section id="promo"><h2>Promo</h2><p>Limited time offer on all plans this week.</p><button>Claim</button></section>
|
|
253
|
+
<section style="min-height: 75vh"><h2>Inline</h2><p>Inline-styled band with its own height.</p></section>
|
|
254
|
+
</body></html>`;
|
|
255
|
+
const cssAst = parseHtml(cssHintHtml);
|
|
256
|
+
const cssHints = cssAst.sections.map((s) => s.size_hint);
|
|
257
|
+
check("ingest: 100vh class rule → css basis ~800px", cssHints[0]?.basis === "css" && cssHints[0]?.height === 800 && cssHints[0]?.css === "100vh", cssHints[0]);
|
|
258
|
+
check("ingest: explicit px height by #id → css basis exact", cssHints[1]?.basis === "css" && cssHints[1]?.height === 560, cssHints[1]);
|
|
259
|
+
check("ingest: inline min-height vh → css basis", cssHints[2]?.basis === "css" && cssHints[2]?.height === 600, cssHints[2]);
|
|
260
|
+
check("ingest: .hero rule does not leak into #promo", cssHints[1]?.css !== "100vh", cssHints[1]);
|
|
239
261
|
console.log("== ingest: tolerates empty/CSR-shell HTML ==");
|
|
240
262
|
const empty = parseHtml("");
|
|
241
263
|
check("ingest: empty input → warning", (empty.warnings?.length ?? 0) > 0, empty.warnings);
|
|
@@ -296,6 +318,27 @@ check("ingest: compact has no blocks", compactAst.sections.every((s) => s.blocks
|
|
|
296
318
|
check("ingest: compact has no gradients", compactAst.gradients === undefined, compactAst.gradients);
|
|
297
319
|
check("ingest: compact images are plain strings", compactAst.sections.every((s) => !s.images || s.images.every((i) => typeof i === "string")), compactAst.sections.map((s) => s.images));
|
|
298
320
|
check("ingest: default (no detail arg) is compact-compatible", parseHtml(sampleHtml).sections.every((s) => s.blocks === undefined));
|
|
321
|
+
console.log("== ingest: full mode — composite widget extraction (html-box source) ==");
|
|
322
|
+
const widgetHtml = `<!DOCTYPE html><html><head><style>
|
|
323
|
+
.phone-mockup { width: 320px; border-radius: 24px; background: #111; }
|
|
324
|
+
.chat-bubble { padding: 8px 12px; border-radius: 12px; background: #f1f1f1; }
|
|
325
|
+
.chat-bubble.right { background: #0A7C6E; color: #fff; }
|
|
326
|
+
</style></head><body>
|
|
327
|
+
<section><h1>Talk to us</h1><p>Our assistant answers around the clock for you.</p><button>Start chat</button>
|
|
328
|
+
<div class="phone-mockup"><div class="screen"><div class="chat-bubble left">Xin chào!</div><div class="chat-bubble right">Chào bạn</div><div class="chat-input">Type a message…</div></div><script>track()</script></div>
|
|
329
|
+
</section>
|
|
330
|
+
<section><h2>About</h2><p>Plain prose section with no composite widget at all.</p></section>
|
|
331
|
+
</body></html>`;
|
|
332
|
+
const widgetAst = parseHtml(widgetHtml, "full");
|
|
333
|
+
const wSec = widgetAst.sections[0];
|
|
334
|
+
check("widgets: detected on the mockup section", (wSec?.widgets?.length ?? 0) === 1, wSec?.widgets?.map((w) => w.hint));
|
|
335
|
+
const w0 = wSec?.widgets?.[0];
|
|
336
|
+
check("widgets: hint from class keyword", w0?.hint === "phone" || w0?.hint === "mockup", w0?.hint);
|
|
337
|
+
check("widgets: html keeps inner structure", !!w0?.html.includes("chat-bubble"), w0?.html);
|
|
338
|
+
check("widgets: scripts stripped from html", !(w0?.html ?? "").includes("<script"), w0?.html);
|
|
339
|
+
check("widgets: matching css rules attached", !!w0?.css?.includes(".phone-mockup") && !!w0?.css?.includes(".chat-bubble"), w0?.css);
|
|
340
|
+
check("widgets: none on plain sections", widgetAst.sections[1]?.widgets === undefined, widgetAst.sections[1]);
|
|
341
|
+
check("widgets: compact mode emits none", parseHtml(widgetHtml, "compact").sections.every((s) => s.widgets === undefined));
|
|
299
342
|
console.log("== ingest: nested-grid block detection (depth > 1) ==");
|
|
300
343
|
// section > .grid-wrapper > .card — blocks must be found even though cards are not direct children
|
|
301
344
|
const nestedGridHtml = `<!DOCTYPE html><html lang="en"><head><title>T</title></head><body>
|
package/dist/tools/ingest.js
CHANGED
|
@@ -24,9 +24,9 @@ import { parseHtml, fetchHtml } from "../persistence/html-ingest.js";
|
|
|
24
24
|
const detailParam = z
|
|
25
25
|
.enum(["compact", "full"])
|
|
26
26
|
.optional()
|
|
27
|
-
.describe("Level of detail in the returned AST. 'compact' (default) — backward-compatible ~2-5 KB shape with top colors/fonts from inline styles. 'full' — richer AST: CSS custom-property palette (design tokens by name), background_images from stylesheets, gradients, per-section blocks (repeating card/tile/step structures with title/body/image/cta), li lists, extended paragraphs,
|
|
27
|
+
.describe("Level of detail in the returned AST. 'compact' (default) — backward-compatible ~2-5 KB shape with top colors/fonts from inline styles. 'full' — richer AST: CSS custom-property palette (design tokens by name), background_images from stylesheets, gradients, per-section blocks (repeating card/tile/step structures with title/body/image/cta), li lists, extended paragraphs, images as { src, alt } objects, and per-section widgets = { hint, html, css? } — the cleaned source HTML + matching CSS of composite visuals (phone/device mockup, chat thread, dashboard, browser frame) to rebuild VERBATIM as ONE html-box (inline the css; don't re-imagine the markup). Use 'full' for clone-faithful rebuilds. Image URLs found in the result (images, background_images, og_image) are the user's assets: re-host them via upload_images and reuse them in the generated page for BOTH intents (never hotlink, never replace them with search_images stock photos).");
|
|
28
28
|
export function registerIngestTools(server) {
|
|
29
|
-
server.tool("ingest_html", "Parses an HTML string into a reference AST: title, description, og_image, language, and sections classified by role (header, hero, features, about, form, cta, gallery, testimonials, pricing, faq, footer, unknown) with headings, subheadings, paragraphs, images, ctas, links, form fields — plus top colors, fonts, CSS custom-property palette, and background_images pulled from both inline styles and <style> blocks. Returns ~2-5KB (compact) or up to ~25KB (full). Use detail:'full' for clone-faithful rebuilds — it adds per-section blocks (cards/tiles/steps), li lists, gradients,
|
|
29
|
+
server.tool("ingest_html", "Parses an HTML string into a reference AST: title, description, og_image, language, and sections classified by role (header, hero, features, about, form, cta, gallery, testimonials, pricing, faq, footer, unknown) with headings, subheadings, paragraphs, images, ctas, links, form fields, and a size_hint (desktop section height in px — from the source CSS when explicit, else a content-volume estimate; set the rebuilt section's desktop height from it) — plus top colors, fonts, CSS custom-property palette, and background_images pulled from both inline styles and <style> blocks. Returns ~2-5KB (compact) or up to ~25KB (full). Use detail:'full' for clone-faithful rebuilds — it adds per-section blocks (cards/tiles/steps), li lists, gradients, images as { src, alt } objects, and widgets (the source HTML + CSS of composite mockups, to paste into ONE html-box). Image URLs in the result (images, background_images, og_image) are the user's assets — re-host them via upload_images and reuse them for BOTH intents; use search_images only for slots with no source image.", {
|
|
30
30
|
html: z.string().describe("Raw HTML of a page or a section."),
|
|
31
31
|
intent: z
|
|
32
32
|
.enum(["adapt", "clone"])
|
|
@@ -34,7 +34,7 @@ export function registerIngestTools(server) {
|
|
|
34
34
|
.describe("How the caller intends to use the result. 'adapt' (default) — use as a layout reference and rewrite the TEXT for the user's brand (images from the reference are still re-hosted via upload_images and reused). 'clone' — keep text and images close to the original."),
|
|
35
35
|
detail: detailParam,
|
|
36
36
|
}, { title: "Ingest HTML Reference", readOnlyHint: true, openWorldHint: false }, async ({ html, intent, detail }) => text({ intent: intent ?? "adapt", ...parseHtml(html, detail ?? "compact") }));
|
|
37
|
-
server.tool("ingest_url", "Fetches a public webpage (GET, 10s timeout, 2MB cap) and parses it into the same reference AST as ingest_html. Returns a warning when the page appears client-rendered (empty <body>) so the caller can fall back to a screenshot — Claude can analyze a screenshot natively without this tool. Does not execute JavaScript; sites built with React/Vue/Next.js may return little content. Use detail:'full' for clone-faithful rebuilds — adds CSS palette, background_images, per-section blocks, lists,
|
|
37
|
+
server.tool("ingest_url", "Fetches a public webpage (GET, 10s timeout, 2MB cap) and parses it into the same reference AST as ingest_html (including per-section size_hint desktop heights). Returns a warning when the page appears client-rendered (empty <body>) so the caller can fall back to a screenshot — Claude can analyze a screenshot natively without this tool. Does not execute JavaScript; sites built with React/Vue/Next.js may return little content. Use detail:'full' for clone-faithful rebuilds — adds CSS palette, background_images, per-section blocks, lists, images as { src, alt } objects, and widgets (source HTML + CSS of composite mockups for html-box rebuilds). Image URLs in the result are the user's assets — re-host them via upload_images and reuse them for BOTH intents; use search_images only for slots with no source image.", {
|
|
38
38
|
url: z.string().describe("Public HTTP(S) URL of the page to fetch."),
|
|
39
39
|
intent: z
|
|
40
40
|
.enum(["adapt", "clone"])
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webcake-landing-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.61",
|
|
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",
|