webcake-landing-mcp 1.3.0 → 1.5.0

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,18 @@
1
1
  [
2
+ {
3
+ "v": "1.5.0",
4
+ "d": "23/07/2026",
5
+ "type": "Added",
6
+ "en": "search_images now returns a sizes map on every photo (the delivered width/height in px for each src variant, accounting for retina dpr) plus a…",
7
+ "vi": "search_images nay trả về một sizes map trên mỗi ảnh (kích thước width/height thực tế theo px của từng biến thể src, có tính đến dpr cho màn hình…"
8
+ },
9
+ {
10
+ "v": "1.4.0",
11
+ "d": "23/07/2026",
12
+ "type": "Changed",
13
+ "en": "publish_page now reuses the page's currently-attached domain when custom_domain is omitted — mirroring the editor's publish modal, a plain…",
14
+ "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…"
15
+ },
2
16
  {
3
17
  "v": "1.3.0",
4
18
  "d": "17/07/2026",
@@ -26,19 +40,5 @@
26
40
  "type": "Added",
27
41
  "en": "create_page, add_section, and validate_page now apply a deterministic layout auto-fix before validating or saving: off-canvas children are pulled…",
28
42
  "vi": "create_page, add_section và validate_page nay tự động áp dụng một lần auto-fix layout xác định trước khi validate hoặc lưu: các phần tử con nằm…"
29
- },
30
- {
31
- "v": "1.0.84",
32
- "d": "17/06/2026",
33
- "type": "Added",
34
- "en": "create_page now sends a by_ai field in the create request body (default value \"mcp\") so the backend can stamp each MCP-generated page in its by_ai…",
35
- "vi": "create_page nay gửi thêm trường by_ai trong body của request tạo trang (giá trị mặc định \"mcp\") để backend có thể đánh dấu từng trang được tạo qua…"
36
- },
37
- {
38
- "v": "1.0.83",
39
- "d": "16/06/2026",
40
- "type": "Added",
41
- "en": "The server web guide (GET /) now includes a bilingual video guides section with per-app installation walkthrough cards (Claude, Codex, Antigravity);…",
42
- "vi": "Web guide của server (GET /) nay có thêm mục video hướng dẫn cài đặt song ngữ với các card theo từng ứng dụng (Claude, Codex, Antigravity); card có…"
43
43
  }
44
44
  ]
@@ -114,7 +114,7 @@ SECTION BUILD HINTS (apply to whichever sections the chosen archetype uses)
114
114
  RULES
115
115
  - Visible content goes in "specials" (text-block.specials.text, image-block.specials.src…), NEVER in "styles".
116
116
  - Colors as rgba(r,g,b,a). fontSize/borderWidth/top/left/width/height are NUMBERS (px). borderRadius is a STRING with CSS units ("8px", "50%", "16px 16px 0 0") — a bare number or unit-less string is auto-coerced to px by the server, but write the unit explicitly to avoid surprises.
117
- - IMAGES: a real landing page has images (hero/product shot, feature icons, about photo). The save AUTO-HOSTS external image URLs: any real http(s) URL you put in specials.src / a url(...) background / gallery item.link / video poster gets downloaded and re-hosted to the Webcake CDN automatically on create_page/update_page/add_section/patch_page (the response's rehost field reports the count) — so reference and web image URLs do NOT need a pre-call to upload_images. SOURCE PRIORITY: (1) images the user supplied or that exist in the reference HTML/URL (ingest AST images/background_images/og_image, canvas src/background) → put the REAL source URL straight into the slot and let the save host it; use those EXACT images, never swap them for stock photos, and carry the URL through un-altered (never drop it, never placeholder a slot that had a real source image). The ONE case you must call upload_images yourself FIRST: LOCAL FILE PATHS from the user's computer (pass the path directly in upload_images 'urls' — the save can't read local files; NEVER relay the user's local file through a third-party host like catbox or imgur), then use the returned URL. (2) only for slots with NO source image → call search_images with a short English subject (e.g. 'fresh coffee cup', 'modern office team') and put a returned URL into image-block specials.src — use src.large for a hero/banner, src.medium for a card/thumb (avg_color helps pick a matching section background); (3) if search_images returns ok:false, is unreachable, or has NO photo that fits the slot → find a real image YOURSELF using whatever web search/fetch capability you have (the brand's own site, the product page, a free-to-use source), then re-host it via upload_images and use the returned URL; (4) a PLACEHOLDER is the LAST resort, ONLY after (2) AND (3) both failed — sized to the box: "https://placehold.co/<width>x<height>". Never jump straight from a failed search to a placeholder without trying (3). NEVER leave src empty — it renders blank on the live page. The server automatically derives styles.background from specials.src on every expand (create/update/validate) using the editor's exact format: 'center center/ cover no-repeat scroll content-box url(<src>) border-box' — you do NOT need to set styles.background manually; if you do hand-write it, it must contain url(...). A SECTION background may layer a gradient overlay over an image: 'linear-gradient(...), center center/ cover no-repeat scroll content-box url(<src>) border-box' — the server canonicalises any url() layer into that exact editor shorthand on expand (other url() formats survive the first save but get mangled to 'undefined/ undefined/ …' the moment the page is edited in the Webcake editor). gallery.media = array of OBJECTS {type:'image', link:'<real-or-placeholder-url>', linkVideo:'', typeVideo:'youtube', imageCompression:true} (NOT plain URL strings — the gallery reads item.link); video.specials.img = a poster image (real photo, else placeholder). Do NOT set a flat (no url()) styles.background on a video element — it suppresses the poster image.
117
+ - IMAGES: a real landing page has images (hero/product shot, feature icons, about photo). The save AUTO-HOSTS external image URLs: any real http(s) URL you put in specials.src / a url(...) background / gallery item.link / video poster gets downloaded and re-hosted to the Webcake CDN automatically on create_page/update_page/add_section/patch_page (the response's rehost field reports the count) — so reference and web image URLs do NOT need a pre-call to upload_images. SOURCE PRIORITY: (1) images the user supplied or that exist in the reference HTML/URL (ingest AST images/background_images/og_image, canvas src/background) → put the REAL source URL straight into the slot and let the save host it; use those EXACT images, never swap them for stock photos, and carry the URL through un-altered (never drop it, never placeholder a slot that had a real source image). The ONE case you must call upload_images yourself FIRST: LOCAL FILE PATHS from the user's computer (pass the path directly in upload_images 'urls' — the save can't read local files; NEVER relay the user's local file through a third-party host like catbox or imgur), then use the returned URL. (2) only for slots with NO source image → call search_images with a short English subject (e.g. 'fresh coffee cup', 'modern office team') and put a returned URL into image-block specials.src. MATCH THE VARIANT TO THE SLOT SIZE — search_images returns a per-photo \`sizes\` map (delivered WxH per variant) + a \`size_guide\`; pick the variant whose width ≈ the element's RENDERED width (×1–2 for retina), then stop: full-bleed hero/banner (~900–1200px) → src.large (~940px) or src.large2x (~1880px); feature card/thumb (~260–360px) → src.medium (~350px); avatar/logo (~64–120px) → src.tiny (280px). NEVER stretch a small variant across a big slot — it pixelates ('vỡ ảnh'); NEVER put src.original (full-res, often several MB) or an oversized variant in a small card — it makes the page slow/heavy ('nặng trang'). If a photo's NATIVE width (photo.width) is smaller than the slot, pick a higher-res photo instead of upscaling. (avg_color helps pick a matching section background); (3) if search_images returns ok:false, is unreachable, or has NO photo that fits the slot → find a real image YOURSELF using whatever web search/fetch capability you have (the brand's own site, the product page, a free-to-use source), then re-host it via upload_images and use the returned URL; (4) a PLACEHOLDER is the LAST resort, ONLY after (2) AND (3) both failed — sized to the box: "https://placehold.co/<width>x<height>". Never jump straight from a failed search to a placeholder without trying (3). NEVER leave src empty — it renders blank on the live page. The server automatically derives styles.background from specials.src on every expand (create/update/validate) using the editor's exact format: 'center center/ cover no-repeat scroll content-box url(<src>) border-box' — you do NOT need to set styles.background manually; if you do hand-write it, it must contain url(...). A SECTION background may layer a gradient overlay over an image: 'linear-gradient(...), center center/ cover no-repeat scroll content-box url(<src>) border-box' — the server canonicalises any url() layer into that exact editor shorthand on expand (other url() formats survive the first save but get mangled to 'undefined/ undefined/ …' the moment the page is edited in the Webcake editor). gallery.media = array of OBJECTS {type:'image', link:'<real-or-placeholder-url>', linkVideo:'', typeVideo:'youtube', imageCompression:true} (NOT plain URL strings — the gallery reads item.link); video.specials.img = a poster image (real photo, else placeholder). Do NOT set a flat (no url()) styles.background on a video element — it suppresses the poster image.
118
118
  - CONTRAST (check EVERY text element against the band it sits on, especially SATURATED / mid-tone bands like yellow, orange, teal, pink — there "light vs dark text" is not obvious, so decide by the band's luminance): light bands → near-black text (e.g. rgba(20,30,25,1)); dark bands → near-white text; a saturated/mid-tone band → whichever of near-black or near-white actually reads (for a bright yellow/amber band that means DARK text, not white/grey). NEVER use muted-grey, low-alpha (alpha < ~0.85), or near-white text on a colored band — that is exactly what makes labels look faded/sunken. Muted-grey is ONLY for secondary text on a white/very-light band. Icons and their captions follow the SAME rule as the text beside them.
119
119
  - movable:false for section/slide/grid-item/popup; otherwise true. runtime is always {}.
120
120
  - Every form input MUST have a unique specials.field_name.
@@ -57,6 +57,54 @@ export function normalizePhoto(p) {
57
57
  src: p?.src && typeof p.src === "object" ? p.src : {},
58
58
  };
59
59
  }
60
+ /**
61
+ * Delivered pixel size of ONE Pexels src-variant URL. Pexels encodes the size in
62
+ * the query string (`w`, `h`, and `dpr` for retina): `large`/`large2x` carry
63
+ * `w`+`h` (large2x adds `dpr=2` → the file is twice as many pixels), `medium`/
64
+ * `small` carry only `h` (width follows the native aspect ratio), and `original`
65
+ * has NO query params → it is the full native resolution. `dpr` multiplies both
66
+ * sides because the served file really is that many pixels. Pure + defensive:
67
+ * an unparseable URL or a variant with no size hint falls back to native w/h.
68
+ */
69
+ export function variantPixels(url, nativeW, nativeH) {
70
+ const aspect = nativeW > 0 && nativeH > 0 ? nativeW / nativeH : 1;
71
+ let q;
72
+ try {
73
+ q = new URL(url).searchParams;
74
+ }
75
+ catch {
76
+ return { w: nativeW || 0, h: nativeH || 0 };
77
+ }
78
+ const dprRaw = parseInt(q.get("dpr") ?? "1", 10);
79
+ const dpr = Number.isFinite(dprRaw) && dprRaw > 0 ? dprRaw : 1;
80
+ const wRaw = parseInt(q.get("w") ?? "", 10);
81
+ const hRaw = parseInt(q.get("h") ?? "", 10);
82
+ const hasW = Number.isFinite(wRaw) && wRaw > 0;
83
+ const hasH = Number.isFinite(hRaw) && hRaw > 0;
84
+ if (!hasW && !hasH)
85
+ return { w: nativeW || 0, h: nativeH || 0 }; // original / no size hint
86
+ const baseW = hasW ? wRaw : Math.round(hRaw * aspect);
87
+ const baseH = hasH ? hRaw : Math.round(wRaw / aspect);
88
+ return { w: baseW * dpr, h: baseH * dpr };
89
+ }
90
+ /** Map every src variant of a photo to its delivered {w,h} in px (see variantPixels). */
91
+ export function annotateVariantSizes(photo) {
92
+ const out = {};
93
+ for (const [variant, url] of Object.entries(photo.src ?? {})) {
94
+ if (typeof url === "string" && url)
95
+ out[variant] = variantPixels(url, photo.width, photo.height);
96
+ }
97
+ return out;
98
+ }
99
+ /**
100
+ * How to pick the RIGHT variant: match the delivered width to the slot's rendered
101
+ * width — too small pixelates ("vỡ ảnh"), too big bloats the page ("nặng trang").
102
+ * Returned in search_images responses so the model chooses by size, not just topic.
103
+ */
104
+ export const IMAGE_SIZE_GUIDE = "Match the src variant's delivered WIDTH (see each photo's `sizes` map) to the slot's RENDERED width — aim for ~1–2× it (extra headroom = crisp on retina), then STOP. " +
105
+ "Rules of thumb on the 960/1200 canvas: full-bleed hero/banner (~900–1200px wide) → src.large (940px) or src.large2x (1880px, retina); half-hero / product shot (~450–650px) → src.large or src.medium; feature card / thumbnail (~260–360px) → src.medium (~350px); avatar / logo / small icon (~64–120px) → src.tiny (280px). " +
106
+ "NEVER stretch a small variant across a big slot — upscaling looks broken/pixelated ('vỡ ảnh'). NEVER drop src.original (full-res, often several MB) or an oversized variant into a small card — it makes the page slow and heavy ('nặng trang'). " +
107
+ "Also check the photo's NATIVE width/height: if a photo's native width is smaller than the slot, it cannot fill it crisply — choose a higher-resolution photo (or a smaller slot) rather than upscaling.";
60
108
  /** Build the shared query string for both the direct Pexels call and the proxy. */
61
109
  export function buildSearchQuery(params) {
62
110
  const q = new URLSearchParams();
@@ -338,6 +338,10 @@ export async function getPageSource(config, pageId) {
338
338
  page_id: d.page_id,
339
339
  name: d.name,
340
340
  organization_id: d.organization_id ?? null,
341
+ // The page's currently-attached domain, when the record carries it — lets
342
+ // publish_page reuse it instead of dropping to a domain-less preview.
343
+ custom_domain: d.custom_domain ?? null,
344
+ custom_path: d.custom_path ?? null,
341
345
  source: d.source,
342
346
  };
343
347
  }
package/dist/smoke.js CHANGED
@@ -14,7 +14,7 @@ import { parseHtml, extractTailwindConfig } from "./persistence/html-ingest.js";
14
14
  import { warningsField } from "./mcp/response.js";
15
15
  import { readConfig, resolveEnv, ENV_NAMES, configFromHeaders } from "./persistence/config.js";
16
16
  import { toEditorUrl, toEditorLoginUrl, toPreviewUrl, buildPublishRequestRedacted } from "./persistence/webcake-client.js";
17
- import { normalizePhoto, resolvePexelsKey, pexelsKeyFromHeaders, resolvePexelsProxyBase, buildSearchQuery, PEXELS_PROXY_DEFAULT } from "./persistence/pexels-client.js";
17
+ import { normalizePhoto, resolvePexelsKey, pexelsKeyFromHeaders, resolvePexelsProxyBase, buildSearchQuery, PEXELS_PROXY_DEFAULT, variantPixels, annotateVariantSizes } from "./persistence/pexels-client.js";
18
18
  import { putDraft, getDraft, updateDraft, deleteDraft } from "./persistence/draft-cache.js";
19
19
  import { buildConnectUrl, parseCallback } from "./auth/login.js";
20
20
  import { isLocalPath, resolveLocalPath, sniffMime, localContentType } from "./tools/media.js";
@@ -1143,6 +1143,25 @@ console.log("== pexels: key resolution + photo normalization (offline, no networ
1143
1143
  const q = buildSearchQuery({ query: "coffee cup", perPage: 3, orientation: "landscape" });
1144
1144
  check("buildSearchQuery encodes query + per_page + orientation", q.get("query") === "coffee cup" && q.get("per_page") === "3" && q.get("orientation") === "landscape");
1145
1145
  check("buildSearchQuery clamps per_page to 1..80", buildSearchQuery({ query: "x", perPage: 999 }).get("per_page") === "80" && buildSearchQuery({ query: "x", perPage: 0 }).get("per_page") === "1");
1146
+ // variantPixels: parse the delivered pixel size from a Pexels src-variant URL.
1147
+ const large = variantPixels("https://images.pexels.com/photos/1/x.jpeg?auto=compress&cs=tinysrgb&h=650&w=940", 4000, 2600);
1148
+ check("variantPixels: large reads w+h from query", large.w === 940 && large.h === 650, large);
1149
+ const large2x = variantPixels("https://images.pexels.com/photos/1/x.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940", 4000, 2600);
1150
+ check("variantPixels: dpr=2 doubles both sides (retina)", large2x.w === 1880 && large2x.h === 1300, large2x);
1151
+ const medium = variantPixels("https://images.pexels.com/photos/1/x.jpeg?auto=compress&cs=tinysrgb&h=350", 4000, 2000);
1152
+ check("variantPixels: h-only derives width from native aspect (2:1 → 700×350)", medium.w === 700 && medium.h === 350, medium);
1153
+ const original = variantPixels("https://images.pexels.com/photos/1/x.jpeg", 4000, 2600);
1154
+ check("variantPixels: no query → native size", original.w === 4000 && original.h === 2600, original);
1155
+ check("variantPixels: unparseable URL → native size", variantPixels("not a url", 800, 600).w === 800);
1156
+ const annotated = annotateVariantSizes(normalizePhoto({
1157
+ id: 1, width: 3840, height: 2160,
1158
+ src: {
1159
+ original: "https://images.pexels.com/photos/1/x.jpeg",
1160
+ large: "https://images.pexels.com/photos/1/x.jpeg?h=650&w=940",
1161
+ medium: "https://images.pexels.com/photos/1/x.jpeg?h=350",
1162
+ },
1163
+ }));
1164
+ check("annotateVariantSizes maps every variant to {w,h}", annotated.original.w === 3840 && annotated.large.w === 940 && annotated.medium.h === 350, annotated);
1146
1165
  }
1147
1166
  console.log("== draft-cache: page draft round-trip (create_page failure flow) ==");
1148
1167
  {
@@ -27,7 +27,7 @@ import { promises as fs } from "node:fs";
27
27
  import { homedir } from "node:os";
28
28
  import { fileURLToPath } from "node:url";
29
29
  import { text, image, images } from "../mcp/response.js";
30
- import { searchPexels, searchImagesViaProxy, resolvePexelsKey, resolvePexelsProxyBase, pexelsKeyFromHeaders, } from "../persistence/pexels-client.js";
30
+ import { searchPexels, searchImagesViaProxy, resolvePexelsKey, resolvePexelsProxyBase, pexelsKeyFromHeaders, annotateVariantSizes, IMAGE_SIZE_GUIDE, } from "../persistence/pexels-client.js";
31
31
  import { captureScreenshot, captureScreenshotTiles, resolveScreenshotProxyBase, screenshotProxyBaseFromHeaders, resolveMicrolinkKey, microlinkKeyFromHeaders, } from "../persistence/screenshot-client.js";
32
32
  import { uploadImageMultipart, uploadImagePreferCollection, resolveCollectionOrgId, listOrganizations, } from "../persistence/webcake-client.js";
33
33
  import { resolveIconSvg } from "../persistence/icon-client.js";
@@ -196,7 +196,7 @@ export function localContentType(ext, buf) {
196
196
  }
197
197
  export function registerMediaTools(server, allowLocalFiles = true) {
198
198
  // 13) Search images ---------------------------------------------------------
199
- server.tool("search_images", "Searches Pexels stock photos (see https://www.pexels.com/api/) by short English subject queries. Returns hotlinkable URLs at several sizes (src.large for heroes/banners, src.medium for cards/thumbs), `avg_color` for matching section backgrounds, plus photographer name and attribution URL. BATCH MODE: pass `queries: [...]` to fetch multiple subjects in PARALLEL — e.g. ['fresh coffee cup','barista pouring','interior cafe'] for hero + about + gallery — returns { queries: { [q]: result } } so the caller picks one image per slot in a single round-trip; default `pick='best'` returns only the top photo per query (compact, drop-in for specials.src), `pick='all'` returns the full list. `query` (single) returns the full result like before. Works out of the box via a shared hosted proxy; set PEXELS_API_KEY env or x-pexels-key header to use your own quota. ONLY for image slots with NO source image: when the user supplied images or the reference HTML/URL contains image URLs (ingest AST images/background_images/og_image), re-host THOSE via upload_images instead of searching stock photos.", {
199
+ server.tool("search_images", "Searches Pexels stock photos (see https://www.pexels.com/api/) by short English subject queries. Returns hotlinkable URLs at several sizes, `avg_color` for matching section backgrounds, plus photographer name and attribution URL. PICK BY SIZE, NOT JUST TOPIC: each photo carries a `sizes` map (delivered WxH px per variant) and the response includes a `size_guide` — match the variant width to the slot's rendered width (hero/banner → src.large ~940px or src.large2x ~1880px retina; card/thumb → src.medium ~350px; avatar → src.tiny), because a too-small variant stretched across a big slot pixelates ('vỡ ảnh') and src.original / an oversized variant in a small card bloats the page ('nặng trang'). BATCH MODE: pass `queries: [...]` to fetch multiple subjects in PARALLEL — e.g. ['fresh coffee cup','barista pouring','interior cafe'] for hero + about + gallery — returns { queries: { [q]: result } } so the caller picks one image per slot in a single round-trip; default `pick='best'` returns only the top photo per query (compact, drop-in for specials.src), `pick='all'` returns the full list. `query` (single) returns the full result like before. Works out of the box via a shared hosted proxy; set PEXELS_API_KEY env or x-pexels-key header to use your own quota. ONLY for image slots with NO source image: when the user supplied images or the reference HTML/URL contains image URLs (ingest AST images/background_images/og_image), re-host THOSE via upload_images instead of searching stock photos.", {
200
200
  query: z.string().optional().describe("Single subject query — backward-compat. Prefer `queries` when the page needs 2+ images."),
201
201
  queries: z
202
202
  .array(z.string())
@@ -225,6 +225,10 @@ export function registerMediaTools(server, allowLocalFiles = true) {
225
225
  const params = { query: q, perPage: per_page, page, orientation, size, color };
226
226
  return key ? searchPexels(key, params) : searchImagesViaProxy(base, params);
227
227
  };
228
+ // Annotate a photo with the delivered pixel size of each src variant so the
229
+ // model can match variant width to the slot's rendered width (too small →
230
+ // pixelated, too big → heavy page). Non-mutating: returns an enriched copy.
231
+ const withSizes = (p) => ({ ...p, sizes: annotateVariantSizes(p) });
228
232
  // Dedup + parallelize so two slots asking for the same subject only cost one call.
229
233
  const unique = [...new Set(list)];
230
234
  const results = await Promise.all(unique.map(runOne));
@@ -237,7 +241,7 @@ export function registerMediaTools(server, allowLocalFiles = true) {
237
241
  hint: "Couldn't fetch images — set PEXELS_API_KEY (env) or the x-pexels-key header for your own Pexels key (free at https://www.pexels.com/api/), or fall back to https://placehold.co/<width>x<height> placeholders.",
238
242
  });
239
243
  }
240
- return text(r);
244
+ return text({ ...r, photos: (r.photos ?? []).map(withSizes), size_guide: IMAGE_SIZE_GUIDE });
241
245
  }
242
246
  // Batch mode → { queries: { [q]: best-photo-or-full } }.
243
247
  const mode = pick ?? "best";
@@ -251,10 +255,10 @@ export function registerMediaTools(server, allowLocalFiles = true) {
251
255
  }
252
256
  out[q] =
253
257
  mode === "all"
254
- ? r
255
- : { ok: true, photo: r.photos?.[0] ?? null, total_results: r.total_results };
258
+ ? { ...r, photos: (r.photos ?? []).map(withSizes) }
259
+ : { ok: true, photo: r.photos?.[0] ? withSizes(r.photos[0]) : null, total_results: r.total_results };
256
260
  }
257
- return text({ queries: out });
261
+ return text({ queries: out, size_guide: IMAGE_SIZE_GUIDE });
258
262
  });
259
263
  // 13b) Resolve icon-font names to real inline SVGs --------------------------
260
264
  server.tool("get_icon_svg", "Resolves icon-font NAMES into real inline SVG markup via the public Iconify API — so a clone reproduces a reference's icons (esp. Google Stitch, which renders icons with a Material Symbols / Font Awesome CLASS, not an image). ingest_html/ingest_url surface those icons as block.icon \"ms:<name>\" (Material Symbols) / \"fa:<name>\" (Font Awesome); pass them here to get the SVG. ACCEPTS: \"ms:verified\", \"fa:chart-line\", a real Iconify id (\"mdi:home\"), or a bare name (assumed Material Symbols); underscores are normalized to hyphens, and Material Symbols resolve to the OUTLINED variant (the Stitch look) with a filled fallback. Returns { icons: { \"<ref>\": { ok, svg, iconify } } }. RENDER each svg as Webcake's native icon element — a RECTANGLE: put the svg in BOTH responsive.desktop.config.svgMask AND responsive.mobile.config.svgMask, set styles.background = the icon color, and keep the box SQUARE (width === height). The svg is only a MASK (its own fill is ignored), so the icon is BLANK without a solid styles.background; the renderer reads each breakpoint's svgMask separately (no fallback) and forces preserveAspectRatio='none' (a non-square box stretches it). No Webcake credentials needed.", {
@@ -1221,12 +1221,12 @@ export function registerPersistenceTools(server, domain) {
1221
1221
  });
1222
1222
  });
1223
1223
  // 15) Publish page (go live) -------------------------------------------------
1224
- server.tool("publish_page", "Publishes an EXISTING page LIVE via the editor's publish_html route: builds the rendered app on the Webcake build host (POST <buildBase>/render/build; prod default https://build.webcake.io, override with WEBCAKE_BUILD_BASE env / x-webcake-build-base header), then creates/updates the PagePublishedV2 record — the record ALL public serving reads. With custom_domain the page goes live at that domain (it must already point at Webcake). WITHOUT a domain there is NO permanent public URL: the returned preview link (<previewBase>/preview/<page_id>) only renders for ~10 minutes after the publish, then shows 'Preview page is expired' — tell the user to attach a domain for a lasting URL. If no build host is configured or the build fails, falls back to the LEGACY source-only publish route with a warning (saves a version; nothing goes live; the page stays blank). DEFAULTS to dry_run=true (network-free: does NOT call the build host on dry_run). Needs WEBCAKE_API_BASE + WEBCAKE_JWT.", {
1224
+ server.tool("publish_page", "Publishes an EXISTING page LIVE via the editor's publish_html route: builds the rendered app on the Webcake build host (POST <buildBase>/render/build; prod default https://build.webcake.io, override with WEBCAKE_BUILD_BASE env / x-webcake-build-base header), then creates/updates the PagePublishedV2 record — the record ALL public serving reads. With custom_domain the page goes live at that domain (it must already point at Webcake); OMIT custom_domain to reuse the page's currently-attached domain (mirrors the editor's publish modal — republishing keeps the page live at the same URL), falling back to a find_pages lookup by id. WITHOUT any domain (the page has none, or you pass custom_domain='') there is NO permanent public URL: the returned preview link (<previewBase>/preview/<page_id>) only renders for ~10 minutes after the publish, then shows 'Preview page is expired' — tell the user to attach a domain for a lasting URL. If no build host is configured or the build fails, falls back to the LEGACY source-only publish route with a warning (saves a version; nothing goes live; the page stays blank). DEFAULTS to dry_run=true (network-free: does NOT call the build host on dry_run). Needs WEBCAKE_API_BASE + WEBCAKE_JWT.", {
1225
1225
  page_id: z.string().describe("The page id to publish (must be owned by the account)."),
1226
1226
  custom_domain: z
1227
1227
  .string()
1228
1228
  .optional()
1229
- .describe("Optional custom domain to serve the page at (e.g. 'shop.example.com' — must already point at Webcake). Omit to publish without a domain (served at the preview-host URL)."),
1229
+ .describe("Optional custom domain to serve the page at (e.g. 'shop.example.com' — must already point at Webcake). OMIT to reuse the page's CURRENTLY-ATTACHED domain (so republishing keeps it live at the same URL, like the editor's publish modal); if the page has none it publishes domain-less (preview-host URL). Pass an empty string '' to force a domain-less publish even when the page has a domain."),
1230
1230
  custom_path: z.string().optional().describe("Optional path under the custom domain (e.g. 'sale')."),
1231
1231
  dry_run: z.boolean().optional().describe("Default TRUE — preview the request without sending. Does NOT call the build host. Set false to actually publish (build + publish)."),
1232
1232
  }, { title: "Publish Webcake Page", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async ({ page_id, custom_domain, custom_path, dry_run }, extra) => {
@@ -1241,11 +1241,37 @@ export function registerPersistenceTools(server, domain) {
1241
1241
  if (!res.ok || res.source == null) {
1242
1242
  return text({ published: false, reason: "page_not_found", status: res.status, error: res.error ?? "No source on this page." });
1243
1243
  }
1244
- const opts = { customDomain: custom_domain, customPath: custom_path };
1244
+ // Resolve the domain to publish to the way the editor's publish modal does:
1245
+ // an explicit arg wins, otherwise reuse the page's CURRENTLY-ATTACHED domain
1246
+ // so a plain publish_page({ page_id }) keeps the page live at the same URL
1247
+ // instead of silently dropping to an ephemeral /preview. The page record from
1248
+ // getPageSource carries it when the backend returns it; if not, fall back to a
1249
+ // find_pages lookup by id (custom_path only comes from the record). Passing
1250
+ // custom_domain:'' explicitly opts OUT (publish domain-less on purpose).
1251
+ let effectiveDomain = custom_domain ?? res.custom_domain ?? undefined;
1252
+ let effectivePath = custom_path ?? res.custom_path ?? undefined;
1253
+ let domainInherited = false;
1254
+ if (custom_domain == null && (effectiveDomain == null || effectiveDomain === "")) {
1255
+ const found = await searchPages(config, { id: page_id, limit: 1 });
1256
+ const row = found.pages?.find((p) => `${p.id}` === `${page_id}`) ?? found.pages?.[0];
1257
+ if (row?.custom_domain) {
1258
+ effectiveDomain = row.custom_domain;
1259
+ domainInherited = true;
1260
+ }
1261
+ }
1262
+ else if (custom_domain == null && effectiveDomain) {
1263
+ domainInherited = true;
1264
+ }
1265
+ // Normalize an empty-string domain to "no domain" so downstream !! checks agree.
1266
+ if (!effectiveDomain)
1267
+ effectiveDomain = undefined;
1268
+ const opts = { customDomain: effectiveDomain, customPath: effectivePath };
1245
1269
  const buildBase = config.buildBase;
1246
1270
  // Only the preview window serves a domain-less publish, and only briefly.
1247
- const previewExpiryNote = custom_domain
1248
- ? undefined
1271
+ const previewExpiryNote = effectiveDomain
1272
+ ? domainInherited
1273
+ ? `Reused the page's attached domain (${effectiveDomain}) — pass custom_domain to override, or custom_domain:'' to publish without one.`
1274
+ : undefined
1249
1275
  : "No custom_domain — the page has NO permanent public URL. The preview link only renders for ~10 minutes after the publish, then shows 'Preview page is expired'. Attach a custom_domain (already pointed at Webcake) for a lasting URL.";
1250
1276
  if (isDry) {
1251
1277
  // dry_run is network-free — do NOT call the build host.
@@ -1253,8 +1279,8 @@ export function registerPersistenceTools(server, domain) {
1253
1279
  dry_run: true,
1254
1280
  page_id,
1255
1281
  name: res.name,
1256
- would_publish_to: custom_domain
1257
- ? `https://${custom_domain}${custom_path ? `/${custom_path}` : ""}`
1282
+ would_publish_to: effectiveDomain
1283
+ ? `https://${effectiveDomain}${effectivePath ? `/${effectivePath}` : ""}`
1258
1284
  : toPreviewUrl(config, `/preview/${page_id}`),
1259
1285
  build_step: buildBase
1260
1286
  ? { would_run: true, build_host: buildBase, note: "Build host will be called on dry_run=false to produce rendered app/app_css, then the page is published live via the editor's publish_html route." }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-landing-mcp",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
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",