webcake-landing-mcp 1.0.39 → 1.0.40
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/README.md +2 -2
- package/dist/changelog.json +7 -7
- package/dist/core/expand.js +71 -0
- package/dist/domains/landing/guide.js +1 -0
- package/dist/domains/landing/index.js +9 -0
- package/dist/domains/landing/instructions.js +6 -4
- package/dist/domains/landing/validate.js +46 -0
- package/dist/persistence/html-ingest.js +331 -0
- package/dist/persistence/webcake-client.js +66 -0
- package/dist/smoke.js +81 -0
- package/dist/tools/generation.js +8 -6
- package/dist/tools/index.js +2 -0
- package/dist/tools/ingest.js +42 -0
- package/dist/tools/media.js +50 -15
- package/dist/tools/persistence.js +123 -49
- package/dist/tools/reference.js +41 -18
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -89,7 +89,7 @@ persists it (source-only — the page opens in the editor where re-saving render
|
|
|
89
89
|
| **npx (local)** — runs on your machine | Personal daily use, full control | browser `login`, a JWT, or none (reference tools) |
|
|
90
90
|
| **Hosted URL** — use our live server, nothing to install | No Node.js, teams, the claude.ai dialog | your personal `?jwt=` link / `x-webcake-jwt` header |
|
|
91
91
|
|
|
92
|
-
The **reference + generation tools** (`get_generation_guide`, `list_elements`, `validate_page`, …) work with **zero config**; only the **persistence tools** (`create_page`, `update_page`, `add_section`, `list_pages`, `get_page`, `list_organizations`) need a token. Credentials resolve in order: **per-request header → env var → saved `auth.json`** (`login`).
|
|
92
|
+
The **reference + generation tools** (`get_generation_guide`, `list_elements`, `validate_page`, …) and the **ingest tools** (`ingest_html`, `ingest_url` — turn an existing HTML or URL into a layout anchor so the AI can recreate or adapt it) work with **zero config**; only the **persistence tools** (`create_page`, `update_page`, `add_section`, `list_pages`, `get_page`, `list_organizations`) need a token. Credentials resolve in order: **per-request header → env var → saved `auth.json`** (`login`).
|
|
93
93
|
|
|
94
94
|
> 🛠️ Prefer a shell-script installer (`install.sh`/`install.ps1`), a cloned local build, or hand-written per-IDE config? See **[docs/manual-install.md](docs/manual-install.md)**.
|
|
95
95
|
|
|
@@ -417,7 +417,7 @@ update_page({ page_id, source, dry_run: false }) # overwrite (dry_run=tr
|
|
|
417
417
|
# Build a LARGE page incrementally (avoids the giant single create_page payload
|
|
418
418
|
# that can drop the connection): small skeleton first, then one section at a time.
|
|
419
419
|
create_page({ source: smallSkeleton, dry_run: false }) # → page_id
|
|
420
|
-
add_section({ page_id, sections: heroSection, dry_run: false }) # server
|
|
420
|
+
add_section({ page_id, sections: heroSection, dry_run: false }) # backend appends server-side (no whole-source get+put)
|
|
421
421
|
add_section({ page_id, sections: [formSection, footerSection], dry_run: false })
|
|
422
422
|
```
|
|
423
423
|
|
package/dist/changelog.json
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "1.0.40",
|
|
4
|
+
"d": "09/06/2026",
|
|
5
|
+
"type": "Added",
|
|
6
|
+
"en": "New ingest_html tool parses an HTML string into a compact reference AST (~2–5KB) that classifies sections by role (header, hero, features, form,…",
|
|
7
|
+
"vi": "Công cụ ingest_html mới phân tích cú pháp một chuỗi HTML thành AST tham chiếu thu gọn (~2–5KB) phân loại các section theo vai trò (header, hero,…"
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"v": "1.0.39",
|
|
4
11
|
"d": "08/06/2026",
|
|
@@ -33,12 +40,5 @@
|
|
|
33
40
|
"type": "Changed",
|
|
34
41
|
"en": "get_generation_guide now includes an explicit workflow step (3b) directing the agent to call search_images for every image a page needs (hero,…",
|
|
35
42
|
"vi": "get_generation_guide nay bổ sung bước workflow rõ ràng (3b) yêu cầu agent gọi search_images cho mọi hình ảnh trang cần (hero, sản phẩm, giới thiệu,…"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"v": "1.0.34",
|
|
39
|
-
"d": "08/06/2026",
|
|
40
|
-
"type": "Added",
|
|
41
|
-
"en": "New search_images tool queries Pexels stock photos by short English subject and returns ready-to-hotlink URLs at several sizes; use src.large for…",
|
|
42
|
-
"vi": "Công cụ search_images mới truy vấn ảnh stock Pexels theo chủ đề tiếng Anh ngắn gọn và trả về các URL sẵn sàng hotlink ở nhiều kích thước; dùng…"
|
|
43
43
|
}
|
|
44
44
|
]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const STD_KEYS = ["id", "type", "properties", "specials", "runtime", "events", "responsive", "children"];
|
|
2
|
+
const isObj = (v) => v != null && typeof v === "object" && !Array.isArray(v);
|
|
3
|
+
/** Hydrate ONE (possibly sparse) element node against the factory default for its type. */
|
|
4
|
+
export function expandNode(input, createElement) {
|
|
5
|
+
if (!isObj(input))
|
|
6
|
+
return input;
|
|
7
|
+
const type = input.type;
|
|
8
|
+
// No type → can't seed; pass through so the validator reports the missing type.
|
|
9
|
+
if (typeof type !== "string" || type === "")
|
|
10
|
+
return input;
|
|
11
|
+
let seed;
|
|
12
|
+
try {
|
|
13
|
+
const name = isObj(input.properties) && typeof input.properties.name === "string"
|
|
14
|
+
? input.properties.name
|
|
15
|
+
: undefined;
|
|
16
|
+
seed = createElement(type, name ? { name } : undefined);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return input;
|
|
20
|
+
}
|
|
21
|
+
const out = { ...seed };
|
|
22
|
+
if (typeof input.id === "string")
|
|
23
|
+
out.id = input.id;
|
|
24
|
+
out.type = type;
|
|
25
|
+
out.properties = { ...seed.properties, ...(isObj(input.properties) ? input.properties : {}) };
|
|
26
|
+
out.specials = { ...seed.specials, ...(isObj(input.specials) ? input.specials : {}) };
|
|
27
|
+
out.runtime = isObj(input.runtime) ? input.runtime : seed.runtime;
|
|
28
|
+
out.events = Array.isArray(input.events) ? input.events : seed.events;
|
|
29
|
+
// responsive: merge config + styles per breakpoint over the seed; keep the
|
|
30
|
+
// seed breakpoint when the model omits it. (Provide BOTH breakpoints' styles
|
|
31
|
+
// for correct layout — only the boilerplate around them is defaulted here.)
|
|
32
|
+
out.responsive = { desktop: seed.responsive.desktop, mobile: seed.responsive.mobile };
|
|
33
|
+
for (const bp of ["desktop", "mobile"]) {
|
|
34
|
+
const inBp = isObj(input.responsive) ? input.responsive[bp] : undefined;
|
|
35
|
+
const seedBp = seed.responsive[bp];
|
|
36
|
+
out.responsive[bp] = isObj(inBp)
|
|
37
|
+
? {
|
|
38
|
+
...seedBp,
|
|
39
|
+
...inBp,
|
|
40
|
+
config: { ...seedBp.config, ...(isObj(inBp.config) ? inBp.config : {}) },
|
|
41
|
+
styles: { ...seedBp.styles, ...(isObj(inBp.styles) ? inBp.styles : {}) },
|
|
42
|
+
}
|
|
43
|
+
: seedBp;
|
|
44
|
+
}
|
|
45
|
+
// children: replace with the (recursively expanded) provided children; else keep the seed's.
|
|
46
|
+
if (Array.isArray(input.children)) {
|
|
47
|
+
out.children = input.children.map((c) => expandNode(c, createElement));
|
|
48
|
+
}
|
|
49
|
+
else if (Array.isArray(seed.children)) {
|
|
50
|
+
out.children = seed.children;
|
|
51
|
+
}
|
|
52
|
+
// carry over any non-standard keys the model set (future-proofing).
|
|
53
|
+
for (const k of Object.keys(input)) {
|
|
54
|
+
if (!STD_KEYS.includes(k))
|
|
55
|
+
out[k] = input[k];
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
/** Hydrate every node in a page source ({ page, popup, dynamic_pages }). */
|
|
60
|
+
export function expandSource(source, createElement) {
|
|
61
|
+
if (!isObj(source))
|
|
62
|
+
return source;
|
|
63
|
+
const out = { ...source };
|
|
64
|
+
if (Array.isArray(source.page))
|
|
65
|
+
out.page = source.page.map((s) => expandNode(s, createElement));
|
|
66
|
+
if (Array.isArray(source.popup))
|
|
67
|
+
out.popup = source.popup.map((p) => expandNode(p, createElement));
|
|
68
|
+
if (Array.isArray(source.dynamic_pages))
|
|
69
|
+
out.dynamic_pages = source.dynamic_pages.map((p) => expandNode(p, createElement));
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
@@ -23,6 +23,7 @@ ELEMENT NODE (every element)
|
|
|
23
23
|
"specials": { ...type-specific CONTENT... }, "runtime": {}, "events": [],
|
|
24
24
|
"children": [ ... ] } // children ONLY on container types
|
|
25
25
|
- Cross-cutting config keys apply to EVERY element via the per-breakpoint config (responsive.<bp>.config): sticky/stickyPosition/stickyTop/stickyBottom/stickyLeft/stickyRight/stickyWidth/stickyHeight/stickyUnpinAtSections…, animation, hide, lock. The full per-element specials reference (every renderer-read key, including the rich select/checkbox-group/radio/survey option-object schema) lives in docs/element-specials-reference.md.
|
|
26
|
+
- COMPACT AUTHORING (emit FEWER tokens): the server hydrates each element from its type's factory defaults, so you may OMIT boilerplate — \`properties\`, \`runtime\`, empty \`events\`/\`children\`, and each breakpoint's \`config\` (the default animation). Emit only id, type, the meaningful responsive.<bp>.styles for BOTH breakpoints, specials, and events when present. e.g. { "type":"text-block","id":"h1","responsive":{"desktop":{"styles":{"top":120,"left":80,"width":500,"height":70,"fontSize":48,"color":"rgba(20,30,25,1)"}},"mobile":{"styles":{"top":100,"left":20,"width":380,"height":60,"fontSize":32}}},"specials":{"text":"…"} } hydrates into the full node. A complete node still works.
|
|
26
27
|
|
|
27
28
|
COORDINATE SYSTEM (critical)
|
|
28
29
|
- Absolute-positioning canvas (NOT flexbox). Children carry top/left/width/height in px (numbers).
|
|
@@ -4,6 +4,7 @@ import { INSTRUCTIONS } from "./instructions.js";
|
|
|
4
4
|
import { LIBRARY, ELEMENT_TYPES, CONTAINER_TYPES, FIELD_TYPES, createElement } from "./elements/index.js";
|
|
5
5
|
import { createPageSource } from "./page.js";
|
|
6
6
|
import { validatePage, coercePage, pageSchema } from "./validate.js";
|
|
7
|
+
import { expandSource } from "../../core/expand.js";
|
|
7
8
|
/** The payload returned by the get_generation_guide tool. */
|
|
8
9
|
export const guidePayload = {
|
|
9
10
|
guide: GENERATION_GUIDE,
|
|
@@ -27,5 +28,13 @@ export const landingDomain = {
|
|
|
27
28
|
createPageSource,
|
|
28
29
|
validate: validatePage,
|
|
29
30
|
coerce: coercePage,
|
|
31
|
+
expand: (input) => {
|
|
32
|
+
try {
|
|
33
|
+
return expandSource(coercePage(input), createElement);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return input; // bad JSON — let validate report it
|
|
37
|
+
}
|
|
38
|
+
},
|
|
30
39
|
schema: pageSchema,
|
|
31
40
|
};
|
|
@@ -9,15 +9,17 @@ RULES (follow for every request):
|
|
|
9
9
|
- INTAKE FIRST — do this EVERY time, even for a "quick"/"test" page. Do NOT jump straight to new_page_skeleton/create_page on the same turn as the request: ask the essentials, restate an outline, get a "yes", THEN build. Ask ONE short batch (3–6, with sensible defaults so the user answers fast) enough to understand the page's PURPOSE, name, look and layout: page purpose/goal, brand/page name, what they sell + price (sales/ads pages), primary color + logo/branding, sections & layout in order, primary CTA + destination, desktop+mobile or mobile-only, which organization. CONSULT, don't interrogate: SUGGEST so the user reacts to something concrete — propose a section flow (pick the archetype matching the page type) + a look (hero treatment + color/tone), and when the user is vague offer 2–3 directions to choose from; proactively suggest sections that fit their goal (social-proof, FAQ, countdown), but ask, don't silently add. Then restate the proposed design (section flow + CTA + color/tone) and WAIT for the user's confirmation, iterating until it matches their intent, before generating. Never assume or silently placeholder the page name, product, price, or colors — ask; only placeholder a core fact when the user explicitly declines to give it.
|
|
10
10
|
- ASK for any real data the page will display — never invent it, and don't silently placeholder it. This includes: phone/hotline/Zalo, price (+ original price), address, shop/brand name, links/URLs, email, opening hours, and exact stats/social-proof numbers. If a value the page needs is missing, ASK the user for it (in intake, or pause and ask before generating). Use a clearly-labelled placeholder ONLY when the user explicitly says to skip it — then tell them exactly what to fill in.
|
|
11
11
|
- ALWAYS call validate_page and fix every error before create_page / update_page.
|
|
12
|
-
- BUILD THE SOURCE IN ONE PASS — gather everything you need
|
|
13
|
-
- create_page and update_page DEFAULT to dry_run=true.
|
|
14
|
-
- 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
|
|
12
|
+
- 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). 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.
|
|
13
|
+
- 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), 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.
|
|
14
|
+
- 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.
|
|
15
15
|
- EDIT existing pages surgically: get_page → change ONLY what was asked → keep every other element, its id, and coordinates → validate_page → update_page. Never regenerate the whole tree for a small change.
|
|
16
16
|
- Organizations: call list_organizations and ask which to use; default to the is_default org. Endpoints are owner-scoped (only the account's own pages).
|
|
17
|
+
- REFERENCE INPUT — if the user provides a layout reference, USE it as the layout anchor (don't ignore it, don't re-invent from scratch). Three input modes: (1) IMAGE/screenshot attached in chat → analyze it natively (no tool call): identify section flow (hero/features/form/cta/footer), heading hierarchy, dominant colors, font feel, then map sections to Webcake elements. (2) HTML string → call ingest_html(html) to get a compact AST. (3) URL → call ingest_url(url) for the same AST. The AST classifies sections by role and lists headings/subheadings/ctas/images/form_fields plus brand hints (colors/fonts) — use it for LAYOUT + HIERARCHY, then generate FRESH content tailored to the user's brand (don't 1:1 copy text). intent='clone' only when the user explicitly asks to mirror the original; default intent='adapt'. The reference workflow PRESERVES craft rules above (centering, page margin, premium spacing, real images) — apply them on top of the reference layout, don't bypass them.
|
|
17
18
|
|
|
18
19
|
MODEL (essentials):
|
|
19
20
|
- Top-level: { page:[sections], popup:[popups], dynamic_pages:[], settings:{}, options:{mobileOnly,versionID}, cartConfigs:{isActive:false}, svariations:[] }. Popups are a SEPARATE top-level array, NOT inside page; currency lives in settings.currency (not options). Leave dynamic_pages/svariations as [] for a static page, but keep them on edit round-trips.
|
|
20
21
|
- Element: { id, type, properties, responsive:{desktop,mobile:{config,styles}}, specials, children, runtime, events }. Absolute canvas: children carry numeric top/left/width/height (px) per breakpoint (canvas width desktop=960, mobile=420); sections own a height.
|
|
22
|
+
- COMPACT AUTHORING (emit FEWER tokens — faster, cheaper): the server HYDRATES every element from its type's factory defaults, so OMIT the boilerplate. Send only: id, type, the meaningful responsive.desktop.styles + responsive.mobile.styles (positions/sizes/colors/font — provide BOTH breakpoints), specials (text/src/field_name…), and events ONLY when the element actually has them. You may DROP: properties, runtime, empty events/children, and each breakpoint's config (animation). A full node still works (it's just overlaid on the seed). Applies to create_page, update_page, add_section, validate_page — ~halves the JSON you emit per element.
|
|
21
23
|
- CENTERING (the #1 layout defect — do the math, don't eyeball): to center a box compute left = round((canvas - width)/2) — 960 desktop, 420 mobile. textAlign:center only centers text inside the box, not the box itself. For a row of N items, center the whole row block (startLeft = round((canvas - (N*item + (N-1)*gap))/2)). Keep 0 ≤ left and left+width ≤ canvas on each breakpoint.
|
|
22
24
|
- PAGE MARGIN (one shared axis — fixes the ragged/header-misaligned look): every section AND the header use the SAME column — left edge at 80 desktop / 20 mobile, right edge at 880 / 400 (content width 800 / 380). Header: logo at left=80, CTA right edge at 880 (its left = 880 − width). Never let one band start at left=80 and the next at left=140.
|
|
23
25
|
- PREMIUM CRAFT (read "sang"): generous whitespace (don't cram; ~48–72px above each band's first element, ≥16–24px between elements); clear type scale (H1 40–56 / body 16–18, big jump); ONE accent used sparingly + neutrals; snap spacing to an 8px grid; reuse the same content width / margin / card+button radius across sections.
|
|
@@ -25,4 +27,4 @@ MODEL (essentials):
|
|
|
25
27
|
- Visible content lives in specials (text, src, field_name…), never in styles. Colors as rgba(). Animation in config.animation={name,delay,duration,repeat}. Form inputs need a unique specials.field_name (use canonical keys: full_name, phone_number, email, address, quantity).
|
|
26
28
|
- IMAGES: include them (hero/product, feature icons, about photo). PREFER REAL PHOTOS — call search_images with a short English subject (e.g. 'fresh coffee cup') and put a returned URL (src.large for a hero/banner, src.medium for a card/thumb) into the image-block specials.src; it works out of the box (a shared proxy supplies images). Only if search_images returns ok:false, FALL BACK to a PLACEHOLDER sized to the box: https://placehold.co/<width>x<height>. (gallery.media = array of OBJECTS {type:'image',link:'<url>',linkVideo:'',typeVideo:'youtube',imageCompression:true} — NOT plain strings, the gallery reads item.link; video.specials.img = poster). NEVER leave src empty (renders blank). Ensure text contrasts with its section background.
|
|
27
29
|
|
|
28
|
-
Start by calling get_generation_guide. Tools: get_generation_guide, list_elements, get_element, new_element, new_page_skeleton, get_page_schema, validate_page, search_images, list_organizations, create_page, list_pages, get_page, update_page, add_section.`;
|
|
30
|
+
Start by calling get_generation_guide. Tools: get_generation_guide, list_elements, get_element, new_element, new_page_skeleton, get_page_schema, validate_page, search_images, ingest_html, ingest_url, list_organizations, create_page, list_pages, get_page, update_page, add_section.`;
|
|
@@ -323,6 +323,52 @@ export function validatePage(input) {
|
|
|
323
323
|
const ms = sec?.responsive?.mobile?.styles ?? {};
|
|
324
324
|
checkBounds(sec, rootCanvasD, num(ds.height) ?? DEFAULT_SECTION_HEIGHT, rootCanvasM, num(ms.height) ?? DEFAULT_SECTION_HEIGHT, `page[${i}]`);
|
|
325
325
|
});
|
|
326
|
+
// 3b) Page margin axis — every band (header included) should put its
|
|
327
|
+
// left-anchored content on ONE shared left margin. A header/section that
|
|
328
|
+
// starts on a different left than the rest is the #1 "looks misaligned"
|
|
329
|
+
// defect (and exactly what eyeballing `left` produces). For each section
|
|
330
|
+
// compute its left-anchored content edge (desktop), then warn ONCE if those
|
|
331
|
+
// edges diverge. Advisory only.
|
|
332
|
+
const leftEdgeFor = (sec) => {
|
|
333
|
+
if (!sec || !Array.isArray(sec.children))
|
|
334
|
+
return undefined;
|
|
335
|
+
let edge = Infinity;
|
|
336
|
+
for (const child of sec.children) {
|
|
337
|
+
const styles = child?.responsive?.desktop?.styles;
|
|
338
|
+
if (!styles)
|
|
339
|
+
continue;
|
|
340
|
+
const left = num(styles.left);
|
|
341
|
+
const width = num(styles.width);
|
|
342
|
+
if (left == null || width == null || left < 0)
|
|
343
|
+
continue;
|
|
344
|
+
// skip full-bleed backgrounds / near-full-width media (their left is 0-ish)
|
|
345
|
+
if (width >= rootCanvasD * 0.9)
|
|
346
|
+
continue;
|
|
347
|
+
// skip horizontally-centered content (its left is dictated by centering math)
|
|
348
|
+
if (Math.abs(left - (rootCanvasD - width) / 2) <= 16)
|
|
349
|
+
continue;
|
|
350
|
+
// only LEFT-anchored content participates (right-anchored CTAs sit on the right axis)
|
|
351
|
+
if (left >= rootCanvasD * 0.4)
|
|
352
|
+
continue;
|
|
353
|
+
if (left < edge)
|
|
354
|
+
edge = left;
|
|
355
|
+
}
|
|
356
|
+
return Number.isFinite(edge) ? edge : undefined;
|
|
357
|
+
};
|
|
358
|
+
const sectionEdges = [];
|
|
359
|
+
topList.forEach((sec, i) => {
|
|
360
|
+
const e = leftEdgeFor(sec);
|
|
361
|
+
if (e != null)
|
|
362
|
+
sectionEdges.push({ i, edge: e });
|
|
363
|
+
});
|
|
364
|
+
if (sectionEdges.length >= 2) {
|
|
365
|
+
const minEdge = Math.min(...sectionEdges.map((e) => e.edge));
|
|
366
|
+
const maxEdge = Math.max(...sectionEdges.map((e) => e.edge));
|
|
367
|
+
if (maxEdge - minEdge > 48) {
|
|
368
|
+
const list = sectionEdges.map((e) => `page[${e.i}] left=${e.edge}`).join(", ");
|
|
369
|
+
warnings.push(`Sections start on different left margins (${list}). Put every band's left-anchored content (the header logo included) on ONE shared left axis — e.g. left=${minEdge} desktop — so the page reads aligned, not ragged. This is the #1 header-misalignment defect.`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
326
372
|
popups.forEach((p, i) => {
|
|
327
373
|
const ds = p?.responsive?.desktop?.styles ?? {};
|
|
328
374
|
const ms = p?.responsive?.mobile?.styles ?? {};
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML → compact reference AST.
|
|
3
|
+
*
|
|
4
|
+
* Used by the `ingest_html` and `ingest_url` tools so a model can use an existing
|
|
5
|
+
* page (HTML string or URL) as a LAYOUT REFERENCE when building a Webcake page,
|
|
6
|
+
* without having to read the full HTML token-by-token. The AST groups the page
|
|
7
|
+
* into sections classified by role (hero/features/form/cta/footer/…) and
|
|
8
|
+
* extracts headings, ctas, images, form fields, and a few brand hints
|
|
9
|
+
* (top colors + fonts). The full text is NOT preserved — the model is meant to
|
|
10
|
+
* use this as an anchor and generate fresh content for the user's brand.
|
|
11
|
+
*/
|
|
12
|
+
import { parse } from "node-html-parser";
|
|
13
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
14
|
+
const MAX_HTML_BYTES = 2_000_000; // 2MB
|
|
15
|
+
const SECTION_TAGS = ["section", "main", "article", "header", "footer", "aside"];
|
|
16
|
+
const HEADING_TAGS = ["h1", "h2", "h3", "h4"];
|
|
17
|
+
export function parseHtml(html) {
|
|
18
|
+
if (!html || typeof html !== "string" || html.trim().length === 0) {
|
|
19
|
+
return { sections: [], warnings: ["empty input"] };
|
|
20
|
+
}
|
|
21
|
+
const root = parse(html, { lowerCaseTagName: true });
|
|
22
|
+
const head = root.querySelector("head");
|
|
23
|
+
const title = head?.querySelector("title")?.text?.trim() || undefined;
|
|
24
|
+
const description = head?.querySelector('meta[name="description"]')?.getAttribute("content")?.trim() || undefined;
|
|
25
|
+
const og_image = head?.querySelector('meta[property="og:image"]')?.getAttribute("content") || undefined;
|
|
26
|
+
const language = root.querySelector("html")?.getAttribute("lang") || undefined;
|
|
27
|
+
const body = root.querySelector("body") ?? root;
|
|
28
|
+
if (!body)
|
|
29
|
+
return { title, description, og_image, language, sections: [], warnings: ["no <body>"] };
|
|
30
|
+
// CSR heuristic — empty body usually means React/Vue/Next that hasn't rendered.
|
|
31
|
+
const bodyText = body.textContent.trim();
|
|
32
|
+
if (bodyText.length < 50) {
|
|
33
|
+
return {
|
|
34
|
+
title,
|
|
35
|
+
description,
|
|
36
|
+
og_image,
|
|
37
|
+
language,
|
|
38
|
+
sections: [],
|
|
39
|
+
warnings: [
|
|
40
|
+
"page appears client-rendered (<body> is essentially empty); ask the user for a screenshot — Claude can analyze it natively without this tool",
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const sectionEls = findSections(body);
|
|
45
|
+
const sections = sectionEls.map(classifySection);
|
|
46
|
+
// Brand hints from inline styles
|
|
47
|
+
const styleAttrs = [];
|
|
48
|
+
body.querySelectorAll("[style]").forEach((el) => {
|
|
49
|
+
const s = el.getAttribute("style");
|
|
50
|
+
if (s)
|
|
51
|
+
styleAttrs.push(s);
|
|
52
|
+
});
|
|
53
|
+
const colors = topColors(styleAttrs, 5);
|
|
54
|
+
const fonts = topFonts(styleAttrs, 3);
|
|
55
|
+
return {
|
|
56
|
+
title,
|
|
57
|
+
description,
|
|
58
|
+
og_image,
|
|
59
|
+
language,
|
|
60
|
+
sections,
|
|
61
|
+
colors: colors.length ? colors : undefined,
|
|
62
|
+
fonts: fonts.length ? fonts : undefined,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function findSections(body) {
|
|
66
|
+
// 1) Prefer explicit semantic tags.
|
|
67
|
+
const explicit = body.querySelectorAll(SECTION_TAGS.join(","));
|
|
68
|
+
if (explicit.length >= 2)
|
|
69
|
+
return explicit;
|
|
70
|
+
// 2) If body has a single <main>, look inside it.
|
|
71
|
+
const main = body.querySelector("main");
|
|
72
|
+
if (main) {
|
|
73
|
+
const inside = main.querySelectorAll(SECTION_TAGS.join(","));
|
|
74
|
+
if (inside.length >= 2)
|
|
75
|
+
return inside;
|
|
76
|
+
const directDivs = elementChildren(main).filter((c) => ["div", "section", "article"].includes(c.tagName?.toLowerCase() ?? ""));
|
|
77
|
+
if (directDivs.length >= 2)
|
|
78
|
+
return directDivs;
|
|
79
|
+
}
|
|
80
|
+
// 3) Fallback to top-level block children of body.
|
|
81
|
+
const bodyBlocks = elementChildren(body).filter((c) => ["div", "main", "section", "article"].includes(c.tagName?.toLowerCase() ?? ""));
|
|
82
|
+
if (bodyBlocks.length >= 2)
|
|
83
|
+
return bodyBlocks;
|
|
84
|
+
// 4) Single section — the whole body.
|
|
85
|
+
return [body];
|
|
86
|
+
}
|
|
87
|
+
function elementChildren(el) {
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const n of el.childNodes) {
|
|
90
|
+
if (n && n.nodeType === 1)
|
|
91
|
+
out.push(n);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
function classifySection(el) {
|
|
96
|
+
const tag = el.tagName?.toLowerCase();
|
|
97
|
+
if (tag === "header")
|
|
98
|
+
return classifyHeader(el);
|
|
99
|
+
if (tag === "footer")
|
|
100
|
+
return classifyFooter(el);
|
|
101
|
+
const form = el.querySelector("form");
|
|
102
|
+
if (form)
|
|
103
|
+
return classifyForm(el, form);
|
|
104
|
+
const heading = pickHeading(el);
|
|
105
|
+
const paragraphs = pickParagraphs(el);
|
|
106
|
+
const images = pickImages(el);
|
|
107
|
+
const ctas = pickCtas(el);
|
|
108
|
+
const subheading = heading ? pickSubheading(el, heading) : undefined;
|
|
109
|
+
if (images.length >= 4) {
|
|
110
|
+
return { role: "gallery", heading: text(heading), images };
|
|
111
|
+
}
|
|
112
|
+
if (countFeatureBlocks(el) >= 3) {
|
|
113
|
+
return { role: "features", heading: text(heading), subheading, ctas: ctas.length ? ctas : undefined };
|
|
114
|
+
}
|
|
115
|
+
if (heading?.tagName?.toLowerCase() === "h1" && (images.length > 0 || ctas.length > 0)) {
|
|
116
|
+
return {
|
|
117
|
+
role: "hero",
|
|
118
|
+
heading: text(heading),
|
|
119
|
+
subheading,
|
|
120
|
+
paragraphs: paragraphs.slice(0, 1),
|
|
121
|
+
images: images.slice(0, 1),
|
|
122
|
+
ctas: ctas.slice(0, 2),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (ctas.length > 0 && paragraphs.length <= 1) {
|
|
126
|
+
return { role: "cta", heading: text(heading), subheading, ctas };
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
role: "unknown",
|
|
130
|
+
heading: text(heading),
|
|
131
|
+
subheading,
|
|
132
|
+
paragraphs: paragraphs.slice(0, 3),
|
|
133
|
+
images: images.slice(0, 3),
|
|
134
|
+
ctas: ctas.length ? ctas : undefined,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function classifyHeader(el) {
|
|
138
|
+
const heading = pickHeading(el);
|
|
139
|
+
const links = el
|
|
140
|
+
.querySelectorAll("a")
|
|
141
|
+
.map((a) => ({ text: a.text.trim(), href: a.getAttribute("href") ?? "" }))
|
|
142
|
+
.filter((l) => l.text)
|
|
143
|
+
.slice(0, 12);
|
|
144
|
+
return { role: "header", heading: text(heading), links: links.length ? links : undefined };
|
|
145
|
+
}
|
|
146
|
+
function classifyFooter(el) {
|
|
147
|
+
const links = el
|
|
148
|
+
.querySelectorAll("a")
|
|
149
|
+
.map((a) => ({ text: a.text.trim(), href: a.getAttribute("href") ?? "" }))
|
|
150
|
+
.filter((l) => l.text)
|
|
151
|
+
.slice(0, 24);
|
|
152
|
+
const paragraphs = pickParagraphs(el).slice(0, 2);
|
|
153
|
+
return { role: "footer", links: links.length ? links : undefined, paragraphs: paragraphs.length ? paragraphs : undefined };
|
|
154
|
+
}
|
|
155
|
+
function classifyForm(el, form) {
|
|
156
|
+
const heading = pickHeading(el);
|
|
157
|
+
const subheading = heading ? pickSubheading(el, heading) : undefined;
|
|
158
|
+
const inputs = form.querySelectorAll("input, textarea, select");
|
|
159
|
+
const form_fields = inputs
|
|
160
|
+
.map((inp) => {
|
|
161
|
+
const tag = inp.tagName?.toLowerCase();
|
|
162
|
+
const type = tag === "input" ? inp.getAttribute("type") ?? "text" : tag === "textarea" ? "textarea" : "select";
|
|
163
|
+
const name = inp.getAttribute("name") || undefined;
|
|
164
|
+
const id = inp.getAttribute("id");
|
|
165
|
+
let label;
|
|
166
|
+
if (id) {
|
|
167
|
+
const lbl = form.querySelector(`label[for="${id}"]`);
|
|
168
|
+
if (lbl)
|
|
169
|
+
label = lbl.text.trim();
|
|
170
|
+
}
|
|
171
|
+
if (!label) {
|
|
172
|
+
const placeholder = inp.getAttribute("placeholder");
|
|
173
|
+
if (placeholder)
|
|
174
|
+
label = placeholder;
|
|
175
|
+
}
|
|
176
|
+
return { type, name, required: inp.hasAttribute("required") || undefined, label };
|
|
177
|
+
})
|
|
178
|
+
.filter((f) => f.type !== "hidden" && f.type !== "submit" && f.type !== "button");
|
|
179
|
+
const submit = form.querySelector('button[type="submit"], input[type="submit"]') ?? form.querySelector("button");
|
|
180
|
+
const submitText = (submit?.text?.trim() || submit?.getAttribute?.("value") || "").trim() || (form_fields.length ? "Submit" : undefined);
|
|
181
|
+
const ctas = submitText ? [{ text: submitText }] : [];
|
|
182
|
+
return {
|
|
183
|
+
role: "form",
|
|
184
|
+
heading: text(heading),
|
|
185
|
+
subheading,
|
|
186
|
+
form_fields: form_fields.length ? form_fields : undefined,
|
|
187
|
+
ctas: ctas.length ? ctas : undefined,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function pickHeading(el) {
|
|
191
|
+
for (const t of HEADING_TAGS) {
|
|
192
|
+
const h = el.querySelector(t);
|
|
193
|
+
if (h)
|
|
194
|
+
return h;
|
|
195
|
+
}
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
function pickSubheading(el, heading) {
|
|
199
|
+
// Use the first paragraph that doesn't equal the heading text.
|
|
200
|
+
const headingText = heading.text.trim();
|
|
201
|
+
for (const p of el.querySelectorAll("p")) {
|
|
202
|
+
const t = p.text.trim();
|
|
203
|
+
if (t && t !== headingText && t.length >= 8 && t.length <= 240)
|
|
204
|
+
return t;
|
|
205
|
+
}
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
function pickParagraphs(el) {
|
|
209
|
+
return el
|
|
210
|
+
.querySelectorAll("p")
|
|
211
|
+
.map((p) => p.text.trim())
|
|
212
|
+
.filter((t) => t.length > 10 && t.length < 500);
|
|
213
|
+
}
|
|
214
|
+
function pickImages(el) {
|
|
215
|
+
return el
|
|
216
|
+
.querySelectorAll("img")
|
|
217
|
+
.map((img) => img.getAttribute("src") || img.getAttribute("data-src") || "")
|
|
218
|
+
.filter((s) => s && !s.startsWith("data:"))
|
|
219
|
+
.slice(0, 12);
|
|
220
|
+
}
|
|
221
|
+
function pickCtas(el) {
|
|
222
|
+
const out = [];
|
|
223
|
+
el.querySelectorAll("button").forEach((b) => {
|
|
224
|
+
const t = b.text.trim();
|
|
225
|
+
if (t)
|
|
226
|
+
out.push({ text: t });
|
|
227
|
+
});
|
|
228
|
+
el.querySelectorAll("a").forEach((a) => {
|
|
229
|
+
const cls = (a.getAttribute("class") ?? "").toLowerCase();
|
|
230
|
+
if (/(btn|button|cta)/.test(cls)) {
|
|
231
|
+
const t = a.text.trim();
|
|
232
|
+
const href = a.getAttribute("href") ?? undefined;
|
|
233
|
+
if (t)
|
|
234
|
+
out.push({ text: t, href });
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
// Dedup by text.
|
|
238
|
+
const seen = new Set();
|
|
239
|
+
return out.filter((c) => (seen.has(c.text) ? false : (seen.add(c.text), true))).slice(0, 4);
|
|
240
|
+
}
|
|
241
|
+
function countFeatureBlocks(el) {
|
|
242
|
+
let count = 0;
|
|
243
|
+
for (const c of elementChildren(el)) {
|
|
244
|
+
if (c.querySelector(HEADING_TAGS.join(",")) && c.querySelector("p"))
|
|
245
|
+
count++;
|
|
246
|
+
}
|
|
247
|
+
if (count >= 3)
|
|
248
|
+
return count;
|
|
249
|
+
// Fallback: cards or list items that look like feature blocks.
|
|
250
|
+
return el.querySelectorAll('ul > li, [class*="card"], [class*="feature"]').length;
|
|
251
|
+
}
|
|
252
|
+
function text(el) {
|
|
253
|
+
const t = el?.text?.trim();
|
|
254
|
+
return t ? t.slice(0, 240) : undefined;
|
|
255
|
+
}
|
|
256
|
+
const COLOR_RE = /(?:rgba?|hsla?)\([^)]+\)|#[0-9a-fA-F]{3,8}\b/g;
|
|
257
|
+
function topColors(styles, n) {
|
|
258
|
+
const counts = new Map();
|
|
259
|
+
for (const s of styles) {
|
|
260
|
+
const matches = s.match(COLOR_RE);
|
|
261
|
+
if (matches)
|
|
262
|
+
for (const c of matches) {
|
|
263
|
+
const k = c.toLowerCase();
|
|
264
|
+
counts.set(k, (counts.get(k) ?? 0) + 1);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, n).map(([k]) => k);
|
|
268
|
+
}
|
|
269
|
+
const FONT_RE = /font-family\s*:\s*([^;]+)/gi;
|
|
270
|
+
function topFonts(styles, n) {
|
|
271
|
+
const counts = new Map();
|
|
272
|
+
for (const s of styles) {
|
|
273
|
+
FONT_RE.lastIndex = 0;
|
|
274
|
+
let m;
|
|
275
|
+
while ((m = FONT_RE.exec(s)) !== null) {
|
|
276
|
+
const k = m[1].trim().replace(/['"]/g, "").split(",")[0].trim();
|
|
277
|
+
if (k)
|
|
278
|
+
counts.set(k, (counts.get(k) ?? 0) + 1);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, n).map(([k]) => k);
|
|
282
|
+
}
|
|
283
|
+
export async function fetchHtml(url, opts = {}) {
|
|
284
|
+
if (!/^https?:\/\//i.test(url)) {
|
|
285
|
+
return { ok: false, error: "URL must start with http:// or https://" };
|
|
286
|
+
}
|
|
287
|
+
const ctrl = new AbortController();
|
|
288
|
+
const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? FETCH_TIMEOUT_MS);
|
|
289
|
+
try {
|
|
290
|
+
const res = await fetch(url, {
|
|
291
|
+
headers: {
|
|
292
|
+
"User-Agent": opts.userAgent ?? "Mozilla/5.0 (compatible; webcake-landing-mcp/ingest_url)",
|
|
293
|
+
Accept: "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.5",
|
|
294
|
+
},
|
|
295
|
+
signal: ctrl.signal,
|
|
296
|
+
redirect: "follow",
|
|
297
|
+
});
|
|
298
|
+
if (!res.ok)
|
|
299
|
+
return { ok: false, status: res.status, error: `Server returned ${res.status}` };
|
|
300
|
+
const ctype = res.headers.get("content-type") ?? "";
|
|
301
|
+
if (!/html|xml|text/i.test(ctype)) {
|
|
302
|
+
return { ok: false, status: res.status, error: `Content-Type ${ctype} is not HTML` };
|
|
303
|
+
}
|
|
304
|
+
const reader = res.body?.getReader();
|
|
305
|
+
if (!reader)
|
|
306
|
+
return { ok: false, status: res.status, error: "no response body" };
|
|
307
|
+
const chunks = [];
|
|
308
|
+
let total = 0;
|
|
309
|
+
while (true) {
|
|
310
|
+
const { value, done } = await reader.read();
|
|
311
|
+
if (done)
|
|
312
|
+
break;
|
|
313
|
+
if (!value)
|
|
314
|
+
continue;
|
|
315
|
+
total += value.length;
|
|
316
|
+
if (total > MAX_HTML_BYTES) {
|
|
317
|
+
await reader.cancel().catch(() => { });
|
|
318
|
+
return { ok: false, status: res.status, error: `Response exceeded ${MAX_HTML_BYTES} bytes` };
|
|
319
|
+
}
|
|
320
|
+
chunks.push(value);
|
|
321
|
+
}
|
|
322
|
+
const buf = Buffer.concat(chunks.map((c) => Buffer.from(c)));
|
|
323
|
+
return { ok: true, status: res.status, html: buf.toString("utf-8") };
|
|
324
|
+
}
|
|
325
|
+
catch (e) {
|
|
326
|
+
return { ok: false, error: e?.name === "AbortError" ? "Request timed out" : e?.message ?? String(e) };
|
|
327
|
+
}
|
|
328
|
+
finally {
|
|
329
|
+
clearTimeout(timer);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
@@ -3,6 +3,7 @@ const ORGS_ENDPOINT = "/api/v1/org/organizations";
|
|
|
3
3
|
const PAGES_ENDPOINT = "/api/v1/ai/pages";
|
|
4
4
|
const PAGE_SOURCE_ENDPOINT = "/api/v1/ai/page_source";
|
|
5
5
|
const UPDATE_ENDPOINT = "/api/v1/ai/update_page_source";
|
|
6
|
+
const APPEND_ENDPOINT = "/api/v1/ai/append_section";
|
|
6
7
|
function authHeaders(config, orgId) {
|
|
7
8
|
const headers = {
|
|
8
9
|
"Content-Type": "application/json",
|
|
@@ -192,6 +193,71 @@ export function buildUpdateRequestRedacted(config, pageId, source) {
|
|
|
192
193
|
body: body.replace(config.jwt, "***JWT***").slice(0, 400) + (body.length > 400 ? `… (${body.length} bytes)` : ""),
|
|
193
194
|
};
|
|
194
195
|
}
|
|
196
|
+
/** Build (but do not send) the append-section request — for dry-run previews. */
|
|
197
|
+
export function buildAppendRequestRedacted(config, pageId, sections) {
|
|
198
|
+
const body = JSON.stringify({ page_id: pageId, sections });
|
|
199
|
+
return {
|
|
200
|
+
method: "POST",
|
|
201
|
+
url: `${config.base}${APPEND_ENDPOINT}`,
|
|
202
|
+
headers: { ...authHeaders(config), Authorization: "Bearer ***JWT***", Cookie: "jwt=***JWT***" },
|
|
203
|
+
body: body.replace(config.jwt, "***JWT***").slice(0, 400) + (body.length > 400 ? `… (${body.length} bytes)` : ""),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Append section(s) to a page server-side via the dedicated append endpoint —
|
|
208
|
+
* ships ONLY the new section(s) (no whole-source get+put). The backend reads the
|
|
209
|
+
* stored source, appends, guards duplicate ids, and saves. Returns
|
|
210
|
+
* `endpoint_missing:true` on a 404 so the caller can fall back to get+merge+put
|
|
211
|
+
* against an older backend that lacks the route.
|
|
212
|
+
*/
|
|
213
|
+
export async function appendSection(config, pageId, sections) {
|
|
214
|
+
const url = `${config.base}${APPEND_ENDPOINT}`;
|
|
215
|
+
let res;
|
|
216
|
+
try {
|
|
217
|
+
res = await fetch(url, {
|
|
218
|
+
method: "POST",
|
|
219
|
+
headers: authHeaders(config),
|
|
220
|
+
body: JSON.stringify({ page_id: pageId, sections }),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
catch (e) {
|
|
224
|
+
return { ok: false, status: 0, error: `Network error calling ${url}: ${e?.message ?? e}` };
|
|
225
|
+
}
|
|
226
|
+
// No such route on an older backend → Phoenix 404. Signal a fallback.
|
|
227
|
+
if (res.status === 404) {
|
|
228
|
+
return { ok: false, status: 404, endpoint_missing: true, error: "append_section endpoint not found on backend" };
|
|
229
|
+
}
|
|
230
|
+
const text = await res.text();
|
|
231
|
+
let json = null;
|
|
232
|
+
try {
|
|
233
|
+
json = JSON.parse(text);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
/* non-JSON */
|
|
237
|
+
}
|
|
238
|
+
const data = json?.data ?? json;
|
|
239
|
+
const pageIdOut = data?.page_id;
|
|
240
|
+
if (!res.ok || !pageIdOut) {
|
|
241
|
+
const backendMsg = json?.message ?? json?.reason ?? (json ? undefined : text.slice(0, 200));
|
|
242
|
+
return {
|
|
243
|
+
ok: false,
|
|
244
|
+
status: res.status,
|
|
245
|
+
raw: json ?? text.slice(0, 600),
|
|
246
|
+
error: `Backend returned ${res.status}${backendMsg ? `: ${backendMsg}` : ""}`,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
ok: true,
|
|
251
|
+
status: res.status,
|
|
252
|
+
page_id: pageIdOut,
|
|
253
|
+
editor_url: toEditorUrl(config, data?.editor_url),
|
|
254
|
+
preview_url: toEditorUrl(config, data?.preview_url),
|
|
255
|
+
organization_id: data?.organization_id ?? null,
|
|
256
|
+
section_count: data?.section_count,
|
|
257
|
+
sections_added: data?.sections_added,
|
|
258
|
+
raw: data,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
195
261
|
/** Overwrite an existing page's source (source-only). */
|
|
196
262
|
export async function updatePageSource(config, pageId, source) {
|
|
197
263
|
const url = `${config.base}${UPDATE_ENDPOINT}`;
|