webcake-landing-mcp 1.0.81 → 1.0.83

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.0.83",
4
+ "d": "16/06/2026",
5
+ "type": "Added",
6
+ "en": "The server web guide (GET /) now includes a bilingual video guides section with per-app installation walkthrough cards (Claude, Codex, Antigravity);…",
7
+ "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ó…"
8
+ },
9
+ {
10
+ "v": "1.0.82",
11
+ "d": "16/06/2026",
12
+ "type": "Added",
13
+ "en": "render_preview now accepts a tiles parameter: pass tiles:true to receive the page as a stack of top-to-bottom horizontal band images instead of one…",
14
+ "vi": "render_preview nay nhận tham số tiles: truyền tiles:true để nhận trang dưới dạng stack các ảnh dải ngang từ trên xuống dưới thay vì một ảnh…"
15
+ },
2
16
  {
3
17
  "v": "1.0.81",
4
18
  "d": "16/06/2026",
@@ -26,19 +40,5 @@
26
40
  "type": "Added",
27
41
  "en": "The OAuth token store (clients, authorization codes, access and refresh tokens) now uses Postgres when DATABASE_URL, WEBCAKE_POSTGRES_URL, or…",
28
42
  "vi": "Kho lưu trữ OAuth token (clients, authorization code, access và refresh token) nay sử dụng Postgres khi DATABASE_URL, WEBCAKE_POSTGRES_URL, hoặc…"
29
- },
30
- {
31
- "v": "1.0.77",
32
- "d": "15/06/2026",
33
- "type": "Added",
34
- "en": "The remote serve transport now implements a spec-conformant OAuth 2.1 Authorization Server (Authorization Code + PKCE S256, Dynamic Client…",
35
- "vi": "Transport serve từ xa nay triển khai một OAuth 2.1 Authorization Server chuẩn spec (Authorization Code + PKCE S256, Dynamic Client Registration,…"
36
- },
37
- {
38
- "v": "1.0.76",
39
- "d": "15/06/2026",
40
- "type": "Added",
41
- "en": "validate_page now warns when specials.custom_css sets layout or structural CSS properties (position, top, left, right, bottom, inset, width, height,…",
42
- "vi": "validate_page nay cảnh báo khi specials.custom_css đặt các thuộc tính CSS layout hoặc cấu trúc (position, top, left, right, bottom, inset, width,…"
43
43
  }
44
44
  ]
package/dist/http.js CHANGED
@@ -21,7 +21,7 @@ import { ICON_SVG, ICON_MIME } from "./branding.js";
21
21
  import { guideHtml, ogImageSvg, normalizeLang } from "./web-guide.js";
22
22
  import { privacyHtml, termsHtml } from "./legal.js";
23
23
  import { searchPexels, resolvePexelsKey } from "./persistence/pexels-client.js";
24
- import { captureWithPlaywright, isAllowedScreenshotUrl } from "./persistence/screenshot-playwright.js";
24
+ import { captureWithPlaywright, captureTilesWithPlaywright, isAllowedScreenshotUrl } from "./persistence/screenshot-playwright.js";
25
25
  import { resolveEnv, ENVIRONMENTS, stripTrailingSlash } from "./persistence/config.js";
26
26
  import { buildConnectUrl } from "./auth/login.js";
27
27
  import { registerClient, startAuthorize, completeAuthorize, exchangeToken, resolveAccessToken, revokeToken, authServerMetadata, protectedResourceMetadata, } from "./auth/oauth-server.js";
@@ -350,6 +350,23 @@ async function handleRenderScreenshot(req, res) {
350
350
  return sendErr(400, { ok: false, error: allow.error });
351
351
  const fullPage = sp.get("full_page") !== "false";
352
352
  const width = sp.get("width") ? Number(sp.get("width")) : undefined;
353
+ // Tiles mode: split a tall page into horizontal bands, returned as a JSON array
354
+ // of base64 images (top→bottom) so the model reads each slice at a readable size.
355
+ if (sp.get("tiles") === "1" || sp.get("tiles") === "true") {
356
+ const bandHeight = sp.get("band_height") ? Number(sp.get("band_height")) : undefined;
357
+ const t = await captureTilesWithPlaywright(target, { width, bandHeight });
358
+ if (!t.ok)
359
+ return sendErr(t.reason === "not_installed" ? 503 : 502, { ok: false, error: t.error });
360
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store", ...cors });
361
+ return res.end(JSON.stringify({
362
+ ok: true,
363
+ mimeType: t.mimeType,
364
+ page_height: t.pageHeight,
365
+ width: t.width,
366
+ truncated: t.truncated,
367
+ tiles: t.tiles.map((b) => ({ y: b.y, height: b.height, data: b.data.toString("base64") })),
368
+ }));
369
+ }
353
370
  const r = await captureWithPlaywright(target, { fullPage, width });
354
371
  if (!r.ok) {
355
372
  // 503 when the engine is absent (caller should fall back / skip), 502 otherwise.
@@ -18,6 +18,18 @@ export function image(dataBase64, mimeType = "image/png", note) {
18
18
  }
19
19
  return { content };
20
20
  }
21
+ /**
22
+ * Like `image()` but returns SEVERAL image blocks (e.g. a tall page tiled into
23
+ * top→bottom bands), optionally followed by a text note. The model sees each band
24
+ * at a readable size instead of one over-squished image.
25
+ */
26
+ export function images(items, note) {
27
+ const content = items.map((it) => ({ type: "image", data: it.dataBase64, mimeType: it.mimeType ?? "image/png" }));
28
+ if (note !== undefined) {
29
+ content.push({ type: "text", text: typeof note === "string" ? note : JSON.stringify(note, null, 2) });
30
+ }
31
+ return { content };
32
+ }
21
33
  /**
22
34
  * Directive shipped alongside every non-empty validation-warnings list.
23
35
  * Warnings are design defects the customer WILL see (text overlapping the
@@ -132,6 +132,52 @@ export async function captureViaProxy(base, targetUrl, opts, nonce = 0) {
132
132
  return { ok: false, status: 0, via: "proxy", error: `network error calling screenshot proxy ${base}: ${e?.message ?? e}` };
133
133
  }
134
134
  }
135
+ /**
136
+ * Capture a tall page as horizontal BANDS via the self-hosted Playwright route
137
+ * (`?tiles=1`) so the model reads each slice at a readable size. Microlink can't
138
+ * tile, so this REQUIRES a proxy host (RENDER_SCREENSHOT_BASE); without one it
139
+ * returns not_supported and the caller should fall back to a single full-page shot.
140
+ * Never throws.
141
+ */
142
+ export async function captureScreenshotTiles(targetUrl, opts = {}, resolved = {}, nonce = 0) {
143
+ if (!resolved.proxyBase) {
144
+ return { ok: false, status: 0, not_supported: true, error: "tiling needs a Playwright host (RENDER_SCREENSHOT_BASE); Microlink can't tile" };
145
+ }
146
+ const q = new URLSearchParams();
147
+ q.set("url", targetUrl + (targetUrl.includes("?") ? "&" : "?") + "_=" + nonce);
148
+ q.set("tiles", "1");
149
+ q.set("full_page", "true");
150
+ if (opts.width && Number.isFinite(opts.width))
151
+ q.set("width", String(Math.round(opts.width)));
152
+ if (opts.bandHeight && Number.isFinite(opts.bandHeight))
153
+ q.set("band_height", String(Math.round(opts.bandHeight)));
154
+ const url = `${resolved.proxyBase}${SCREENSHOT_PROXY_PATH}?${q.toString()}`;
155
+ try {
156
+ const res = await fetch(url, { method: "GET", headers: { Accept: "application/json" }, signal: AbortSignal.timeout(SCREENSHOT_TIMEOUT_MS) });
157
+ const ct = (res.headers.get("content-type") ?? "").toLowerCase();
158
+ if (res.ok && ct.includes("application/json")) {
159
+ const j = await res.json();
160
+ return {
161
+ ok: true,
162
+ status: res.status,
163
+ mimeType: j.mimeType,
164
+ pageHeight: j.page_height,
165
+ width: j.width,
166
+ truncated: j.truncated === true,
167
+ tiles: (j.tiles ?? []).map((t) => ({ y: t.y, height: t.height, dataBase64: t.data })),
168
+ via: "proxy",
169
+ };
170
+ }
171
+ const body = (await res.text()).slice(0, 300);
172
+ return { ok: false, status: res.status, quota_exhausted: res.status === 429, error: `tiles proxy returned HTTP ${res.status}${body ? `: ${body}` : ""}` };
173
+ }
174
+ catch (e) {
175
+ if (e?.name === "TimeoutError" || e?.name === "AbortError") {
176
+ return { ok: false, status: 0, error: `tiles request timed out after ${SCREENSHOT_TIMEOUT_MS}ms` };
177
+ }
178
+ return { ok: false, status: 0, error: `network error calling tiles proxy ${resolved.proxyBase}: ${e?.message ?? e}` };
179
+ }
180
+ }
135
181
  /**
136
182
  * Capture a screenshot with AUTOMATIC FALLOVER between the two engines:
137
183
  * - Microlink direct (free, zero-config) and
@@ -79,8 +79,18 @@ function outputOpts() {
79
79
  const scale = Number.isFinite(s) && s > 0 && s <= 2 ? s : 1;
80
80
  return { type, quality, scale };
81
81
  }
82
- /** Screenshot a URL with Playwright. Never throws. */
83
- export async function captureWithPlaywright(url, opts = {}) {
82
+ /** Default band height (CSS px) for tiling a tall page; env-tunable. */
83
+ function resolveBandHeight() {
84
+ const b = parseInt(process.env.RENDER_SCREENSHOT_BAND_HEIGHT ?? "", 10);
85
+ return Number.isFinite(b) && b >= 200 ? b : 1400;
86
+ }
87
+ /**
88
+ * Run `fn` against a fresh browser context (viewport width + deviceScaleFactor),
89
+ * with a single self-heal retry if the cached browser died (crash, or a system
90
+ * Chrome that closed). Always closes the context. Never throws — the shared
91
+ * lifecycle for both single-shot and tiled capture.
92
+ */
93
+ async function withContext(width, scale, fn) {
84
94
  const pw = await loadPlaywright();
85
95
  if (!pw) {
86
96
  return {
@@ -89,13 +99,10 @@ export async function captureWithPlaywright(url, opts = {}) {
89
99
  error: "playwright is not installed on this host (run: npm i playwright && npx playwright install chromium)",
90
100
  };
91
101
  }
92
- // One shot, with a single self-heal retry: if the cached browser died (crash,
93
- // or a system Chrome that closed), drop it and relaunch once.
94
102
  for (let attempt = 0; attempt < 2; attempt++) {
95
103
  let browser = await getBrowser();
96
104
  if (!browser)
97
105
  return { ok: false, error: "failed to launch headless chromium (check CHROME_BIN / playwright install)" };
98
- // If the cached handle is already disconnected, force a relaunch.
99
106
  if (typeof browser.isConnected === "function" && !browser.isConnected()) {
100
107
  browserPromise = null;
101
108
  browser = await getBrowser();
@@ -104,24 +111,12 @@ export async function captureWithPlaywright(url, opts = {}) {
104
111
  }
105
112
  let ctx;
106
113
  try {
107
- const out = outputOpts();
108
- ctx = await browser.newContext({
109
- viewport: { width: opts.width && Number.isFinite(opts.width) ? Math.round(opts.width) : 1280, height: 900 },
110
- deviceScaleFactor: out.scale,
111
- });
112
- const page = await ctx.newPage();
113
- await page.goto(url, { waitUntil: "load", timeout: 30_000 });
114
- await page.waitForTimeout(1200); // let webfonts/lazy images settle
115
- const shot = await page.screenshot({
116
- fullPage: opts.fullPage !== false,
117
- type: out.type,
118
- ...(out.type === "jpeg" ? { quality: out.quality } : {}),
119
- });
120
- return { ok: true, data: Buffer.from(shot), mimeType: out.type === "png" ? "image/png" : "image/jpeg" };
114
+ ctx = await browser.newContext({ viewport: { width, height: 900 }, deviceScaleFactor: scale });
115
+ const value = await fn(ctx);
116
+ return { ok: true, value };
121
117
  }
122
118
  catch (e) {
123
119
  const msg = String(e?.message ?? e);
124
- // Browser-died errors → reset and retry once; other errors → fail now.
125
120
  const dead = /closed|disconnected|crash|Target page, context or browser/i.test(msg);
126
121
  if (dead && attempt === 0) {
127
122
  browserPromise = null;
@@ -136,6 +131,73 @@ export async function captureWithPlaywright(url, opts = {}) {
136
131
  }
137
132
  return { ok: false, error: "playwright capture failed after retry" };
138
133
  }
134
+ /** Load a page in `ctx`, wait for it to settle, return the page handle. */
135
+ async function openSettledPage(ctx, url) {
136
+ const page = await ctx.newPage();
137
+ await page.goto(url, { waitUntil: "load", timeout: 30_000 });
138
+ await page.waitForTimeout(1200); // let webfonts/lazy images settle
139
+ return page;
140
+ }
141
+ /** Screenshot a URL with Playwright (one image — full page or viewport). Never throws. */
142
+ export async function captureWithPlaywright(url, opts = {}) {
143
+ const out = outputOpts();
144
+ const width = opts.width && Number.isFinite(opts.width) ? Math.round(opts.width) : 1280;
145
+ const r = await withContext(width, out.scale, async (ctx) => {
146
+ const page = await openSettledPage(ctx, url);
147
+ return Buffer.from(await page.screenshot({
148
+ fullPage: opts.fullPage !== false,
149
+ type: out.type,
150
+ ...(out.type === "jpeg" ? { quality: out.quality } : {}),
151
+ }));
152
+ });
153
+ if (!r.ok)
154
+ return r;
155
+ return { ok: true, data: r.value, mimeType: out.type === "png" ? "image/png" : "image/jpeg" };
156
+ }
157
+ /**
158
+ * Capture a tall page as a STACK of horizontal bands (top→bottom) via Playwright
159
+ * `clip` — so the model sees each slice at a readable aspect ratio instead of one
160
+ * giant image squished (and blurred) to the vision input's long-edge cap. No image
161
+ * library needed: each band is its own screenshot. Bands are capped at `maxTiles`
162
+ * (truncated:true when the page is taller than that).
163
+ */
164
+ export async function captureTilesWithPlaywright(url, opts = {}) {
165
+ const out = outputOpts();
166
+ const width = opts.width && Number.isFinite(opts.width) ? Math.round(opts.width) : 1280;
167
+ const bandH = opts.bandHeight && opts.bandHeight >= 200 ? Math.round(opts.bandHeight) : resolveBandHeight();
168
+ const maxTiles = opts.maxTiles && opts.maxTiles > 0 ? Math.round(opts.maxTiles) : 8;
169
+ const r = await withContext(width, out.scale, async (ctx) => {
170
+ const page = await openSettledPage(ctx, url);
171
+ // String form so tsc doesn't type-check `document` against the Node libs.
172
+ const pageHeight = Math.ceil(Number(await page.evaluate("Math.max(document.documentElement.scrollHeight, document.body.scrollHeight)")) || 0);
173
+ const total = Math.max(1, Math.ceil(pageHeight / bandH));
174
+ const n = Math.min(total, maxTiles);
175
+ // `clip` is measured against the VIEWPORT, so grow the viewport to cover the
176
+ // bands we'll capture before clipping (Chromium caps a single shot at ~16384px).
177
+ const captureHeight = Math.min(pageHeight, n * bandH, 16000);
178
+ await page.setViewportSize({ width, height: Math.max(captureHeight, 1) });
179
+ await page.waitForTimeout(300); // reflow after the resize
180
+ const tiles = [];
181
+ for (let i = 0; i < n; i++) {
182
+ const y = i * bandH;
183
+ let h = Math.min(bandH, pageHeight - y);
184
+ if (y + h > captureHeight)
185
+ h = captureHeight - y; // never clip past the viewport
186
+ if (h <= 0)
187
+ break;
188
+ const shot = await page.screenshot({
189
+ clip: { x: 0, y, width, height: h },
190
+ type: out.type,
191
+ ...(out.type === "jpeg" ? { quality: out.quality } : {}),
192
+ });
193
+ tiles.push({ y, height: h, data: Buffer.from(shot) });
194
+ }
195
+ return { pageHeight, width, tiles, truncated: total > maxTiles };
196
+ });
197
+ if (!r.ok)
198
+ return r;
199
+ return { ok: true, mimeType: out.type === "png" ? "image/png" : "image/jpeg", ...r.value };
200
+ }
139
201
  // ---------------------------------------------------------------------------
140
202
  // SSRF guard — this route fetches an arbitrary `url`, so block private/loopback
141
203
  // targets (someone could otherwise screenshot internal services). Pure + exported
@@ -26,9 +26,9 @@ import { z } from "zod";
26
26
  import { promises as fs } from "node:fs";
27
27
  import { homedir } from "node:os";
28
28
  import { fileURLToPath } from "node:url";
29
- import { text, image } from "../mcp/response.js";
29
+ import { text, image, images } from "../mcp/response.js";
30
30
  import { searchPexels, searchImagesViaProxy, resolvePexelsKey, resolvePexelsProxyBase, pexelsKeyFromHeaders, } from "../persistence/pexels-client.js";
31
- import { captureScreenshot, resolveScreenshotProxyBase, screenshotProxyBaseFromHeaders, resolveMicrolinkKey, microlinkKeyFromHeaders, } from "../persistence/screenshot-client.js";
31
+ import { captureScreenshot, captureScreenshotTiles, resolveScreenshotProxyBase, screenshotProxyBaseFromHeaders, resolveMicrolinkKey, microlinkKeyFromHeaders, } from "../persistence/screenshot-client.js";
32
32
  import { uploadImageMultipart } from "../persistence/webcake-client.js";
33
33
  import { resolveIconSvg } from "../persistence/icon-client.js";
34
34
  import { configFromHeaders, ENVIRONMENTS, stripTrailingSlash } from "../persistence/config.js";
@@ -269,12 +269,13 @@ export function registerMediaTools(server, allowLocalFiles = true) {
269
269
  });
270
270
  });
271
271
  // 13c) Render a page/URL to a screenshot the model can SEE --------------------
272
- server.tool("render_preview", "Renders a PUBLIC URL to a PNG and returns it as an image so the model can SEE the result and compare it visually to the reference — the fidelity-check step of the clone loop (build → see → patch_page → re-check). Pass `page_id` to shoot a created page's preview (/preview/<id>) or `url` for any public page (e.g. the reference you're cloning). full_page defaults to true (whole scrollable page). AGENT-FIRST: if YOU already have a screenshot/browser capability (a shell + headless browser, or a screenshot tool), screenshot the preview URL YOURSELF instead — it's fresh and unlimited; use this tool only when you cannot. ENGINE: zero-config via Microlink's free tier (rate-limited ~50/day PER IP, so heavy looping can hit HTTP 429 — then this returns ok:false and you should SKIP the visual check that round, not fail); a host can set RENDER_SCREENSHOT_BASE (or the x-render-screenshot-base header) to a keyed proxy, or MICROLINK_API_KEY / x-microlink-key for a higher quota. NOTE: a no-domain preview only renders for ~10 minutes after the last publish — call this promptly after create_page/publish_page, and re-publish before re-checking a stale page.", {
272
+ server.tool("render_preview", "Renders a PUBLIC URL to a PNG and returns it as an image so the model can SEE the result and compare it visually to the reference — the fidelity-check step of the clone loop (build → see → patch_page → re-check). Pass `page_id` to shoot a created page's preview (/preview/<id>) or `url` for any public page (e.g. the reference you're cloning). full_page defaults to true (whole scrollable page). AGENT-FIRST: if YOU already have a screenshot/browser capability (a shell + headless browser, or a screenshot tool), screenshot the preview URL YOURSELF instead — it's fresh and unlimited; use this tool only when you cannot. ENGINE: zero-config via Microlink's free tier (rate-limited ~50/day PER IP, so heavy looping can hit HTTP 429 — then this returns ok:false and you should SKIP the visual check that round, not fail); a host can set RENDER_SCREENSHOT_BASE (or the x-render-screenshot-base header) to a keyed proxy, or MICROLINK_API_KEY / x-microlink-key for a higher quota. NOTE: a no-domain preview only renders for ~10 minutes after the last publish — call this promptly after create_page/publish_page, and re-publish before re-checking a stale page. TALL PAGES: pass tiles:true to get the page as a STACK of top→bottom band images (each readable at full detail) instead of one full-page image squished small — needs a self-hosted Playwright host (RENDER_SCREENSHOT_BASE); falls back to a single image otherwise.", {
273
273
  page_id: z.string().optional().describe("A created page's id — screenshots its /preview/<id> URL (built from the preview base). Provide page_id OR url."),
274
274
  url: z.string().optional().describe("Any public http(s) URL to screenshot (e.g. the reference page being cloned). Wins over page_id."),
275
275
  full_page: z.boolean().optional().describe("Capture the whole scrollable page (default true) vs just the viewport."),
276
276
  width: z.number().int().min(320).max(2560).optional().describe("Viewport width in px (default 1280; use ~960/1200 to match the page canvas, ~420 for mobile)."),
277
- }, { title: "Render Preview Screenshot", readOnlyHint: true, openWorldHint: true }, async ({ page_id, url, full_page, width }, extra) => {
277
+ tiles: z.boolean().optional().describe("Tall pages: return the page as MULTIPLE top→bottom band images (each readable in detail) instead of one squished full-page image. Requires a Playwright host (RENDER_SCREENSHOT_BASE); without one it falls back to a single image."),
278
+ }, { title: "Render Preview Screenshot", readOnlyHint: true, openWorldHint: true }, async ({ page_id, url, full_page, width, tiles }, extra) => {
278
279
  const headers = extra?.requestInfo?.headers;
279
280
  let target = url?.trim();
280
281
  if (!target && page_id) {
@@ -287,6 +288,26 @@ export function registerMediaTools(server, allowLocalFiles = true) {
287
288
  proxyBase: resolveScreenshotProxyBase(screenshotProxyBaseFromHeaders(headers)),
288
289
  microlinkKey: resolveMicrolinkKey(microlinkKeyFromHeaders(headers)),
289
290
  };
291
+ // Tiles mode (tall pages): a stack of readable top→bottom bands. Needs a
292
+ // Playwright host; if none is configured, fall through to a single image.
293
+ if (tiles === true) {
294
+ const t = await captureScreenshotTiles(target, { width }, resolved, Date.now());
295
+ if (t.ok && t.tiles && t.tiles.length) {
296
+ return images(t.tiles.map((b) => ({ dataBase64: b.dataBase64, mimeType: t.mimeType })), `Rendered ${target} as ${t.tiles.length} band(s) top→bottom (page ${t.pageHeight}px @ ${t.width}px wide${t.truncated ? ", TRUNCATED — page taller than the band cap" : ""}). Read the bands in order as one page. Compare each to the reference: section order, colors, spacing, image placement, text. For each mismatch, patch_page the element by id, re-publish, then re-check.`);
297
+ }
298
+ if (!t.not_supported) {
299
+ // A real failure (quota/network) — report it; don't silently single-shot.
300
+ return text({
301
+ ok: false,
302
+ url: target,
303
+ status: t.status,
304
+ quota_exhausted: t.quota_exhausted ?? false,
305
+ error: t.error,
306
+ hint: "Tiled screenshot failed. Retry without tiles for a single image, or skip the visual check this round.",
307
+ });
308
+ }
309
+ // not_supported → fall through to a single full-page image below.
310
+ }
290
311
  const r = await captureScreenshot(target, { fullPage: full_page !== false, width }, resolved, Date.now());
291
312
  if (!r.ok) {
292
313
  return text({
package/dist/web-guide.js CHANGED
@@ -32,6 +32,16 @@ const INSTALL_ALL_CMD = "npx -y webcake-landing-mcp install --ide all --env prod
32
32
  const GITHUB_URL = "https://github.com/vuluu2k/webcake-landing-mcp";
33
33
  const NPM_URL = "https://www.npmjs.com/package/webcake-landing-mcp";
34
34
  const DOCS_URL = `${GITHUB_URL}#readme`;
35
+ // Per-app video walkthroughs for connecting the MCP, rendered as cards in the
36
+ // guide — each card embeds the YouTube video in a 16:9 iframe. `youtube` is the
37
+ // video ID (privacy-friendly youtube-nocookie embed). Claude has a recorded
38
+ // walkthrough; Codex/Antigravity are placeholders (empty `youtube` → a matching
39
+ // "coming soon" card) until their videos are published — just fill in the ID.
40
+ const VIDEO_GUIDES = [
41
+ { app: "Claude", icon: "brain", youtube: "NY-YMbSnlOE" },
42
+ { app: "Codex", icon: "terminal", youtube: "" },
43
+ { app: "Antigravity", icon: "rocket", youtube: "" },
44
+ ];
35
45
  export const LANGS = ["vi", "en"];
36
46
  export function normalizeLang(input) {
37
47
  return input === "en" ? "en" : "vi";
@@ -247,6 +257,10 @@ const T = {
247
257
  "<b>Bấm Add</b> (hoặc lưu file) rồi chờ một chút. Khi biểu tượng Webcake chuyển xanh là dùng được.",
248
258
  ],
249
259
  m2Note: "⚠️ Link có chứa mã đăng nhập riêng của bạn — hãy coi như mật khẩu, đừng chia sẻ cho ai.",
260
+ videoH2: "Video hướng dẫn cài đặt",
261
+ videoSub: "Xem video làm theo từng bước cho đúng ứng dụng AI bạn đang dùng.",
262
+ videoCta: "Xem video",
263
+ videoSoon: "Sắp có",
250
264
  afterH2: "Kết nối xong, bạn chỉ cần nói",
251
265
  examples: [
252
266
  {
@@ -271,6 +285,14 @@ const T = {
271
285
  starBtn: "Tặng sao trên GitHub",
272
286
  footGuide: "Hướng dẫn",
273
287
  switchLabel: "English",
288
+ nav: [
289
+ { href: "#flow", label: "Cách hoạt động" },
290
+ { href: "#build", label: "Tạo được gì" },
291
+ { href: "#clone", label: "Copy trang" },
292
+ { href: "#connect", label: "Kết nối" },
293
+ { href: "#video", label: "Video" },
294
+ { href: "#faq", label: "Hỏi đáp" },
295
+ ],
274
296
  },
275
297
  en: {
276
298
  sub: "Let AI build & edit your Webcake landing pages, just by talking to it",
@@ -378,6 +400,10 @@ const T = {
378
400
  "<b>Hit Add</b> (or save the file) and wait a moment. When the Webcake icon turns green, you're good to go.",
379
401
  ],
380
402
  m2Note: "⚠️ The link contains your personal login code — treat it like a password and never share it.",
403
+ videoH2: "Video install guides",
404
+ videoSub: "Follow a step-by-step video for the AI app you use.",
405
+ videoCta: "Watch video",
406
+ videoSoon: "Coming soon",
381
407
  afterH2: "Once connected, just say",
382
408
  examples: [
383
409
  {
@@ -402,6 +428,14 @@ const T = {
402
428
  starBtn: "Star on GitHub",
403
429
  footGuide: "Docs",
404
430
  switchLabel: "Tiếng Việt",
431
+ nav: [
432
+ { href: "#flow", label: "How it works" },
433
+ { href: "#build", label: "What you build" },
434
+ { href: "#clone", label: "Clone a page" },
435
+ { href: "#connect", label: "Connect" },
436
+ { href: "#video", label: "Videos" },
437
+ { href: "#faq", label: "FAQ" },
438
+ ],
405
439
  },
406
440
  };
407
441
  const CHANGELOG = loadChangelog();
@@ -534,11 +568,11 @@ export function guideHtml(origin, lang = "vi") {
534
568
  (the toggle); [data-theme="light"] forces light even on a dark OS. */
535
569
  :root{--g:#1DB954;--g7:#178f43;--ink:#11231b;--mut:#5e6d65;--bg:#f5f9f7;--card:#ffffff;
536
570
  --line:rgba(16,40,30,.09);--shadow:0 1px 2px rgba(16,40,30,.05),0 6px 20px -12px rgba(16,40,30,.18);--code:#0e1714;
537
- --ic-fg:#178f43;--btn-hover:#178f43}
571
+ --ic-fg:#178f43;--btn-hover:#178f43;--navbg:rgba(245,249,247,.82)}
538
572
  @media(prefers-color-scheme:dark){:root:not([data-theme="light"]){--ink:#e8f0ec;--mut:#9aaba2;--bg:#0b110e;--card:#141b17;
539
- --line:rgba(255,255,255,.07);--shadow:0 1px 2px rgba(0,0,0,.3),0 8px 24px -14px rgba(0,0,0,.7);--code:#070f0b;--g7:#5ee08a;--ic-fg:#6fe79a;--btn-hover:#21c264}}
573
+ --line:rgba(255,255,255,.07);--shadow:0 1px 2px rgba(0,0,0,.3),0 8px 24px -14px rgba(0,0,0,.7);--code:#070f0b;--g7:#5ee08a;--ic-fg:#6fe79a;--btn-hover:#21c264;--navbg:rgba(11,17,14,.82)}}
540
574
  :root[data-theme="dark"]{--ink:#e8f0ec;--mut:#9aaba2;--bg:#0b110e;--card:#141b17;
541
- --line:rgba(255,255,255,.07);--shadow:0 1px 2px rgba(0,0,0,.3),0 8px 24px -14px rgba(0,0,0,.7);--code:#070f0b;--g7:#5ee08a;--ic-fg:#6fe79a;--btn-hover:#21c264}
575
+ --line:rgba(255,255,255,.07);--shadow:0 1px 2px rgba(0,0,0,.3),0 8px 24px -14px rgba(0,0,0,.7);--code:#070f0b;--g7:#5ee08a;--ic-fg:#6fe79a;--btn-hover:#21c264;--navbg:rgba(11,17,14,.82)}
542
576
  *{box-sizing:border-box}
543
577
  /* Smooth scrolling only AFTER first load — applied globally it makes the
544
578
  browser *animate* scroll-position restoration on reload, which reads as a
@@ -583,7 +617,7 @@ export function guideHtml(origin, lang = "vi") {
583
617
  color:var(--g7);background:rgba(29,185,84,.10);border:1px solid var(--line)}
584
618
  .dot{width:8px;height:8px;border-radius:50%;background:var(--g);box-shadow:0 0 0 0 rgba(29,185,84,.5);animation:pulse 2s infinite}
585
619
  @keyframes pulse{70%{box-shadow:0 0 0 7px rgba(29,185,84,0)}100%{box-shadow:0 0 0 0 rgba(29,185,84,0)}}
586
- h2{font-size:1.32rem;margin:46px 0 16px;font-weight:800;letter-spacing:-.01em}
620
+ h2{font-size:1.32rem;margin:46px 0 16px;font-weight:800;letter-spacing:-.01em;scroll-margin-top:72px}
587
621
  .ic{width:42px;height:42px;border-radius:12px;display:grid;place-items:center;flex:0 0 auto;color:var(--ic-fg);
588
622
  background:rgba(29,185,84,.11);border:1px solid var(--line);transition:transform .2s ease}
589
623
  .ic .i{width:22px;height:22px}
@@ -640,6 +674,49 @@ export function guideHtml(origin, lang = "vi") {
640
674
  .btn:hover{transform:translateY(-1px);background:var(--btn-hover)}
641
675
  .btn.ghost{background:var(--card);color:var(--ink);border:1px solid var(--line);box-shadow:none}
642
676
  .btn.ghost:hover{border-color:var(--g);background:var(--card)}
677
+ /* Video-guide cards: a 16:9 clickable poster (YouTube thumbnail + play badge)
678
+ that opens the video in the modal below, or a matching "coming soon"
679
+ placeholder — so every card lines up to the same height. */
680
+ .vid-head{display:flex;align-items:center;gap:11px;margin-bottom:14px}
681
+ .vid-head .ic{margin-bottom:0}
682
+ .vid-head h3{margin:0;font-size:1.04rem}
683
+ .vid-play,.vid-soon{aspect-ratio:16/9;border-radius:12px;overflow:hidden;width:100%}
684
+ .vid-play{position:relative;display:block;padding:0;cursor:pointer;background:#000;
685
+ border:1px solid var(--line);transition:transform .2s ease,box-shadow .2s ease}
686
+ .vid-play:hover{transform:translateY(-2px);box-shadow:0 12px 28px -14px rgba(16,40,30,.5)}
687
+ .vid-play img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;border:0}
688
+ .vid-play::after{content:"";position:absolute;inset:0;background:rgba(0,0,0,.18);transition:background .2s ease}
689
+ .vid-play:hover::after{background:rgba(0,0,0,.04)}
690
+ .vid-play .pbtn{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:54px;height:54px;
691
+ border-radius:50%;background:rgba(29,185,84,.92);display:grid;place-items:center;z-index:1;
692
+ box-shadow:0 6px 18px -4px rgba(0,0,0,.5);transition:transform .2s ease,background .2s ease}
693
+ .vid-play:hover .pbtn{transform:translate(-50%,-50%) scale(1.08);background:var(--g)}
694
+ .vid-play .pbtn svg{width:23px;height:23px;color:#fff;margin-left:3px}
695
+ .vid-soon{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;
696
+ color:var(--mut);font-size:.9rem;font-weight:600;background:rgba(29,185,84,.05);border:1px dashed var(--line)}
697
+ .vid-soon .i{width:24px;height:24px}
698
+ /* Video modal (lightbox): a single overlay reused for every card's video. */
699
+ .modal{position:fixed;inset:0;z-index:200;display:none;align-items:center;justify-content:center;
700
+ padding:24px;background:rgba(4,10,7,.74);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px)}
701
+ .modal.open{display:flex}
702
+ .modal-box{position:relative;width:min(960px,100%);aspect-ratio:16/9;background:#000;border-radius:14px;
703
+ overflow:hidden;box-shadow:0 24px 60px -20px rgba(0,0,0,.8)}
704
+ .modal-box iframe{position:absolute;inset:0;width:100%;height:100%;border:0}
705
+ .modal-x{position:absolute;top:-13px;right:-13px;width:38px;height:38px;border-radius:50%;cursor:pointer;
706
+ background:var(--g);color:#fff;border:2px solid #fff;display:grid;place-items:center;z-index:2;
707
+ box-shadow:0 4px 14px -2px rgba(0,0,0,.5)}
708
+ .modal-x svg{width:18px;height:18px}
709
+ @media(max-width:640px){.modal{padding:14px}.modal-x{top:-11px;right:-11px;width:34px;height:34px}}
710
+ /* Sticky section nav — pins to the top once you scroll past the hero so the
711
+ reader can jump to any section; horizontally scrollable on narrow screens. */
712
+ .nav{position:sticky;top:0;z-index:60;display:flex;gap:6px;align-items:center;overflow-x:auto;
713
+ margin:18px -20px 6px;padding:9px 20px;background:var(--navbg);border-bottom:1px solid var(--line);
714
+ backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);scrollbar-width:none}
715
+ .nav::-webkit-scrollbar{display:none}
716
+ .nav a{flex:0 0 auto;font-size:.84rem;font-weight:600;color:var(--mut);text-decoration:none;
717
+ padding:7px 13px;border-radius:999px;white-space:nowrap;transition:color .15s ease,background .15s ease}
718
+ .nav a:hover{color:var(--g7);background:rgba(29,185,84,.10)}
719
+ .nav a.active{color:var(--g7);background:rgba(29,185,84,.13)}
643
720
  .uses{display:grid;gap:14px;grid-template-columns:1fr 1fr;padding:0;margin:0;list-style:none}
644
721
  @media(max-width:640px){.uses{grid-template-columns:1fr}}
645
722
  .uses li{display:flex;gap:13px;padding:16px 18px;align-items:flex-start;transition:transform .2s ease,border-color .2s ease,box-shadow .2s ease}
@@ -772,7 +849,11 @@ export function guideHtml(origin, lang = "vi") {
772
849
  <a class="btn ghost" href="${GITHUB_URL}">${icon("star")} ${t.ctaStar}</a>
773
850
  </div>
774
851
 
775
- <h2 class="reveal">${t.flowH2}</h2>
852
+ <nav class="nav" aria-label="${L === "en" ? "Sections" : "Mục lục"}">
853
+ ${t.nav.map((n) => `<a href="${n.href}">${n.label}</a>`).join("\n ")}
854
+ </nav>
855
+
856
+ <h2 id="flow" class="reveal">${t.flowH2}</h2>
776
857
  <div class="glass flow reveal">
777
858
  ${t.flow
778
859
  .map((n, i) => `<div class="node"><span class="ic" style="animation-delay:${(i * 0.8).toFixed(1)}s">${icon(n.icon)}</span><b>${n.t}</b><span>${n.s}</span></div>` +
@@ -790,14 +871,14 @@ export function guideHtml(origin, lang = "vi") {
790
871
  .join("\n ")}
791
872
  </div>
792
873
 
793
- <h2 class="reveal">${t.buildH2}</h2>
874
+ <h2 id="build" class="reveal">${t.buildH2}</h2>
794
875
  <ul class="uses">
795
876
  ${t.uses
796
877
  .map((u) => `<li class="glass reveal">${tile(u.icon)}<div><b>${u.t}</b><span>${u.e}</span></div></li>`)
797
878
  .join("\n ")}
798
879
  </ul>
799
880
 
800
- <h2 class="reveal">${t.cloneH2}</h2>
881
+ <h2 id="clone" class="reveal">${t.cloneH2}</h2>
801
882
  <p class="flow-cap reveal" style="margin-bottom:16px">${t.cloneSub}</p>
802
883
  <div class="grid">
803
884
  ${t.clone
@@ -826,6 +907,14 @@ export function guideHtml(origin, lang = "vi") {
826
907
  <p class="note">${t.m2Note}</p>
827
908
  </div>
828
909
 
910
+ <h2 id="video" class="reveal">${t.videoH2}</h2>
911
+ <p class="flow-cap reveal" style="margin-bottom:16px">${t.videoSub}</p>
912
+ <div class="grid">
913
+ ${VIDEO_GUIDES.map((v) => `<div class="glass card vid reveal"><div class="vid-head">${tile(v.icon)}<h3>${v.app}</h3></div>${v.youtube
914
+ ? `<button type="button" class="vid-play" data-yt="${v.youtube}" data-title="${v.app} — ${t.videoCta}" aria-label="${v.app} — ${t.videoCta}"><img src="https://i.ytimg.com/vi/${v.youtube}/hqdefault.jpg" alt="" loading="lazy"><span class="pbtn"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg></span></button>`
915
+ : `<div class="vid-soon">${icon("clock")}<span>${t.videoSoon}</span></div>`}</div>`).join("\n ")}
916
+ </div>
917
+
829
918
  <h2 class="reveal">${t.afterH2}</h2>
830
919
  <ul class="feat">
831
920
  ${t.examples
@@ -843,7 +932,7 @@ export function guideHtml(origin, lang = "vi") {
843
932
  </div>`
844
933
  : ""}
845
934
 
846
- <h2 class="reveal">${t.faqH2}</h2>
935
+ <h2 id="faq" class="reveal">${t.faqH2}</h2>
847
936
  ${faq.map((f) => `<details class="glass reveal"><summary>${f.q}</summary><p>${f.a}</p></details>`).join("\n ")}
848
937
 
849
938
  <div class="glass star reveal">
@@ -860,6 +949,13 @@ export function guideHtml(origin, lang = "vi") {
860
949
  <a href="${selfPath === "/" ? "/health" : "/health"}">Health</a>
861
950
  </footer>
862
951
 
952
+ <div class="modal" id="vmodal" role="dialog" aria-modal="true" aria-label="${t.videoH2}">
953
+ <div class="modal-box">
954
+ <button type="button" class="modal-x" id="vclose" aria-label="Close" title="Close"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
955
+ <iframe id="vframe" title="${t.videoH2}" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
956
+ </div>
957
+ </div>
958
+
863
959
  </div>
864
960
  <script>
865
961
  (function(){
@@ -916,6 +1012,47 @@ export function guideHtml(origin, lang = "vi") {
916
1012
  // Enable smooth scrolling only after the browser has restored scroll position
917
1013
  // on (re)load — applying it globally animates that restore into a jerky scroll.
918
1014
  window.addEventListener('load',function(){requestAnimationFrame(function(){html.classList.add('smooth');});});
1015
+
1016
+ // Video lightbox — clicking a card poster opens the YouTube embed in a modal.
1017
+ // The iframe src is only set on open (no YouTube network/tracking until the
1018
+ // user clicks) and cleared on close (which stops playback). Esc, the backdrop
1019
+ // and the × button all close it; focus is restored to the trigger on close.
1020
+ var modal=document.getElementById('vmodal'),vframe=document.getElementById('vframe'),
1021
+ vclose=document.getElementById('vclose'),lastFocus=null;
1022
+ function openVideo(id,title){
1023
+ if(!modal||!vframe||!id)return;
1024
+ vframe.src='https://www.youtube-nocookie.com/embed/'+id+'?autoplay=1&rel=0';
1025
+ if(title)vframe.title=title;
1026
+ lastFocus=document.activeElement;
1027
+ modal.classList.add('open');document.body.style.overflow='hidden';
1028
+ if(vclose)vclose.focus();
1029
+ }
1030
+ function closeVideo(){
1031
+ if(!modal||!modal.classList.contains('open'))return;
1032
+ modal.classList.remove('open');vframe.src='';document.body.style.overflow='';
1033
+ if(lastFocus&&lastFocus.focus)lastFocus.focus();
1034
+ }
1035
+ document.querySelectorAll('.vid-play').forEach(function(b){
1036
+ b.addEventListener('click',function(){openVideo(b.getAttribute('data-yt'),b.getAttribute('data-title')||'');});
1037
+ });
1038
+ if(vclose)vclose.addEventListener('click',closeVideo);
1039
+ if(modal)modal.addEventListener('click',function(e){if(e.target===modal)closeVideo();});
1040
+ document.addEventListener('keydown',function(e){if(e.key==='Escape')closeVideo();});
1041
+
1042
+ // Sticky-nav scrollspy — highlight the link for the section currently on screen.
1043
+ var navLinks={};
1044
+ document.querySelectorAll('.nav a').forEach(function(a){navLinks[a.getAttribute('href').slice(1)]=a;});
1045
+ var spySecs=[].slice.call(document.querySelectorAll('h2[id]'));
1046
+ var spyTick=false;
1047
+ function spy(){
1048
+ spyTick=false;var cur=null;
1049
+ spySecs.forEach(function(s){if(s.getBoundingClientRect().top<=96)cur=s.id;});
1050
+ Object.keys(navLinks).forEach(function(id){navLinks[id].classList.toggle('active',id===cur);});
1051
+ }
1052
+ if(spySecs.length&&Object.keys(navLinks).length){
1053
+ window.addEventListener('scroll',function(){if(!spyTick){spyTick=true;requestAnimationFrame(spy);}},{passive:true});
1054
+ spy();
1055
+ }
919
1056
  })();
920
1057
  </script>
921
1058
  </body></html>`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-landing-mcp",
3
- "version": "1.0.81",
3
+ "version": "1.0.83",
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",