webcake-storefront-mcp 1.1.3 → 1.2.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.
- package/README.md +2 -2
- package/README.vi.md +2 -2
- package/dist/api.js +26 -0
- package/dist/builder/guide.js +17 -0
- package/dist/changelog.json +44 -0
- package/dist/db.js +3 -41
- package/dist/http.js +1 -1
- package/dist/index.js +1 -1
- package/dist/server.js +2 -2
- package/dist/tools/builder-extras.js +107 -24
- package/dist/tools/builder.js +37 -4
- package/dist/tools/images.js +5 -153
- package/dist/web-guide.js +68 -1
- package/package.json +2 -3
- package/dist/mongo.js +0 -102
package/README.md
CHANGED
|
@@ -149,7 +149,7 @@ Base URLs come from a **named environment** — set `WEBCAKE_ENV` (or `--env`) a
|
|
|
149
149
|
| **`prod`** (default) | `https://api.storefront.webcake.io` | `https://webcake.io` | `<site_slug>.webcake.me` |
|
|
150
150
|
|
|
151
151
|
Override a preset with `WEBCAKE_API_URL` / `WEBCAKE_APP_URL`. Optional, configured server-side:
|
|
152
|
-
`PEXELS_API_KEY` (search_images)
|
|
152
|
+
`PEXELS_API_KEY` (search_images). Token / session / site can also be set
|
|
153
153
|
in chat via `update_auth` and `switch_site` — saved to a local config file at `~/.webcake-storefront-mcp/`.
|
|
154
154
|
|
|
155
155
|
<details>
|
|
@@ -171,7 +171,7 @@ in chat via `update_auth` and `switch_site` — saved to a local config file at
|
|
|
171
171
|
| Group | Tools | Needs |
|
|
172
172
|
|-------|-------|-------|
|
|
173
173
|
| **Build a page** | `get_build_guide` · `list_elements` · `get_element` · `new_element` · `new_section` · `new_page_skeleton` · `validate_page` · `build_page` · `add_section` | catalog tools: nothing |
|
|
174
|
-
| **Media & ingest** | `search_images` (Pexels) · `
|
|
174
|
+
| **Media & ingest** | `search_images` (Pexels) · `upload_images` (CDN) · `ingest_html` · `ingest_url` (recreate a reference page) | — |
|
|
175
175
|
| **Pages & code** | `list_pages` · `get_page_source` · `search_page_elements` · `get_page_element` · `update_page_element(s)` · `create_page` · `update_page` · `update_page_source` · custom CSS/JS · page contents · global sections · `publish_site` | token + session |
|
|
176
176
|
| **Commerce** | products · orders · collections · promotions · combos | token + session |
|
|
177
177
|
| **Content & store** | blog articles · themes / site style · apps · customers · `send_mail` | token + session |
|
package/README.vi.md
CHANGED
|
@@ -148,7 +148,7 @@ URL gốc lấy theo **môi trường có tên** — đặt `WEBCAKE_ENV` (hoặ
|
|
|
148
148
|
| **`prod`** (mặc định) | `https://api.storefront.webcake.io` | `https://webcake.io` | `<site_slug>.webcake.me` |
|
|
149
149
|
|
|
150
150
|
Override bằng `WEBCAKE_API_URL` / `WEBCAKE_APP_URL`. Tuỳ chọn, đặt phía server:
|
|
151
|
-
`PEXELS_API_KEY` (search_images)
|
|
151
|
+
`PEXELS_API_KEY` (search_images). Token / session / site cũng có thể đặt
|
|
152
152
|
trong chat bằng `update_auth` và `switch_site` — lưu vào file cấu hình tại `~/.webcake-storefront-mcp/`.
|
|
153
153
|
|
|
154
154
|
<details>
|
|
@@ -170,7 +170,7 @@ trong chat bằng `update_auth` và `switch_site` — lưu vào file cấu hình
|
|
|
170
170
|
| Nhóm | Tool | Cần |
|
|
171
171
|
|-------|-------|-------|
|
|
172
172
|
| **Dựng trang** | `get_build_guide` · `list_elements` · `get_element` · `new_element` · `new_section` · `new_page_skeleton` · `validate_page` · `build_page` · `add_section` | tool catalog: không cần |
|
|
173
|
-
| **Media & ingest** | `search_images` (Pexels) · `
|
|
173
|
+
| **Media & ingest** | `search_images` (Pexels) · `upload_images` (CDN) · `ingest_html` · `ingest_url` (dựng lại trang tham khảo) | — |
|
|
174
174
|
| **Trang & code** | `list_pages` · `get_page_source` · `search_page_elements` · `get_page_element` · `update_page_element(s)` · `create_page` · `update_page` · `update_page_source` · custom CSS/JS · nội dung trang · global section · `publish_site` | token + session |
|
|
175
175
|
| **Thương mại** | sản phẩm · đơn hàng · collection · khuyến mãi · combo | token + session |
|
|
176
176
|
| **Nội dung & store** | bài blog · theme / site style · app · khách hàng · `send_mail` | token + session |
|
package/dist/api.js
CHANGED
|
@@ -208,6 +208,32 @@ export class WebcakeCmsApi {
|
|
|
208
208
|
async updateSiteSettings(newSettings) {
|
|
209
209
|
return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/update_site`, { body: { settings: newSettings }, timeout: 60000 });
|
|
210
210
|
}
|
|
211
|
+
/** Read the full site.settings object (parsed). Empty object if unset/unparseable. */
|
|
212
|
+
async getSiteSettings() {
|
|
213
|
+
const siteRes = await this.request("GET", `/api/v1/site/${this.siteId}/`, { timeout: 60000 });
|
|
214
|
+
const raw = (siteRes && siteRes.data && siteRes.data.settings) || "";
|
|
215
|
+
if (raw && typeof raw === "object")
|
|
216
|
+
return raw;
|
|
217
|
+
if (typeof raw === "string" && raw.trim()) {
|
|
218
|
+
try {
|
|
219
|
+
return JSON.parse(raw);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return {};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return {};
|
|
226
|
+
}
|
|
227
|
+
/** Ensure a site data-source flag (use_store/use_member/use_blog/use_error/use_maintain)
|
|
228
|
+
* is enabled so special pages' bindings resolve. Merges into existing settings;
|
|
229
|
+
* no-op if already on. */
|
|
230
|
+
async enableSiteFeature(flag) {
|
|
231
|
+
const settings = await this.getSiteSettings();
|
|
232
|
+
if (settings[flag] === true)
|
|
233
|
+
return { changed: false, flag };
|
|
234
|
+
await this.updateSiteSettings({ ...settings, [flag]: true });
|
|
235
|
+
return { changed: true, flag };
|
|
236
|
+
}
|
|
211
237
|
// ── Collections ──
|
|
212
238
|
listCollections(query) {
|
|
213
239
|
return this.request("GET", `/api/v1/dashboard/site/${this.siteId}/db_collections`, { query });
|
package/dist/builder/guide.js
CHANGED
|
@@ -54,6 +54,23 @@ Breakpoint widths: large_desktop 1920, desktop 1280, laptop 992, tablet 640.
|
|
|
54
54
|
- Dataset elements (text-dataset, image-dataset, grid-product...) use \`bindings\` to pull
|
|
55
55
|
product/category/blog data — leave bindings to dataset-driven pages.
|
|
56
56
|
|
|
57
|
+
## Page types & data sources (IMPORTANT for special pages)
|
|
58
|
+
A page's \`type\` decides which live data it can bind to. A SPECIAL page only works if the
|
|
59
|
+
matching site data-source flag (on site.settings) is enabled — otherwise the page renders
|
|
60
|
+
but every product/customer/blog binding resolves to NULL (an empty, broken-looking page).
|
|
61
|
+
\`build_page\` enables the right flag for you when you pass \`type\`:
|
|
62
|
+
- \`main\` — homepage / normal content. No flag needed.
|
|
63
|
+
- \`store\` — product detail, category, cart, checkout, thank-you. Needs \`use_store\`.
|
|
64
|
+
Bindings: \`product::*\`, \`cart_item::*\`.
|
|
65
|
+
- \`member\` — login, register, profile, order history. Needs \`use_member\`.
|
|
66
|
+
Bindings: \`customer_address::*\`, \`order_item::*\`.
|
|
67
|
+
- \`blog\` — blog list, article/post. Needs \`use_blog\`.
|
|
68
|
+
- \`error\` / \`maintain\` — 404 / maintenance. Need \`use_error\` / \`use_maintain\`.
|
|
69
|
+
- \`custom\` — a free page with no special data. No flag needed.
|
|
70
|
+
Rule of thumb: if the page shows products, a cart, customer/order data, or blog posts,
|
|
71
|
+
set \`type\` accordingly so the binding source is turned on. A binding name like
|
|
72
|
+
\`product::price\` REQUIRES its page to be the matching type.
|
|
73
|
+
|
|
57
74
|
## Workflow (do this every time)
|
|
58
75
|
1. Intake: confirm goal, brand, colours, sections wanted (ask 3-5 questions if unclear).
|
|
59
76
|
2. list_elements / get_element to pick the right component types.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"v": "1.2.0",
|
|
4
|
+
"d": "23/06/2026",
|
|
5
|
+
"type": "Added",
|
|
6
|
+
"en": "upload_images replaces upload_image with batch support (1–20 sources per call), parallel uploads, and a dry_run mode that previews what would be…",
|
|
7
|
+
"vi": "upload_images thay thế upload_image với khả năng xử lý hàng loạt (1–20 nguồn mỗi lần gọi), tải song song, và chế độ dry_run cho phép xem trước những…"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"v": "1.1.4",
|
|
11
|
+
"d": "23/06/2026",
|
|
12
|
+
"type": "Added",
|
|
13
|
+
"en": "The remote landing page served by the serve command now includes a \"What's new\" section that renders a version timeline loaded from a build-time…",
|
|
14
|
+
"vi": "Trang landing của lệnh serve nay có thêm mục \"Có gì mới\" hiển thị timeline lịch sử phiên bản được tải từ file changelog.json sinh ra lúc build (từ…"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"v": "1.1.3",
|
|
18
|
+
"d": "23/06/2026",
|
|
19
|
+
"type": "Changed",
|
|
20
|
+
"en": "The install command's interactive wizard now runs a three-step flow: environment selection (prod / staging / local), authentication, then IDE…",
|
|
21
|
+
"vi": "Trình hướng dẫn tương tác của lệnh install nay chạy theo 3 bước: chọn môi trường (prod / staging / local), xác thực, rồi cấu hình IDE."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"v": "1.1.2",
|
|
25
|
+
"d": "23/06/2026",
|
|
26
|
+
"type": "Fixed",
|
|
27
|
+
"en": "The server no longer crashes at startup in container environments built with npm ci --ignore-scripts; the better-sqlite3 native SQLite module has…",
|
|
28
|
+
"vi": "Server không còn bị crash khi khởi động trong môi trường container được build bằng npm ci --ignore-scripts; module SQLite native better-sqlite3 đã…"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"v": "1.1.1",
|
|
32
|
+
"d": "23/06/2026",
|
|
33
|
+
"type": "Added",
|
|
34
|
+
"en": "The serve command's OAuth token store now optionally uses Postgres (via DATABASE_URL) for durable persistence across restarts and shared state…",
|
|
35
|
+
"vi": "Kho lưu trữ token OAuth của lệnh serve nay hỗ trợ tùy chọn sử dụng Postgres (qua DATABASE_URL) để lưu token bền vững qua các lần khởi động lại và…"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"v": "1.1.0",
|
|
39
|
+
"d": "23/06/2026",
|
|
40
|
+
"type": "Added",
|
|
41
|
+
"en": "The serve (remote Streamable-HTTP) mode now embeds a full OAuth 2.1 Authorization Server at /authorize, /token, /revoke, /register, and…",
|
|
42
|
+
"vi": "Chế độ serve (remote Streamable-HTTP) nay tích hợp sẵn một Authorization Server OAuth 2.1 đầy đủ tại các endpoint /authorize, /token, /revoke,…"
|
|
43
|
+
}
|
|
44
|
+
]
|
package/dist/db.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// Tiny JSON-file persistence (no native deps) for
|
|
2
|
-
// (token / session / site / api_url / confirm_mode)
|
|
1
|
+
// Tiny JSON-file persistence (no native deps) for the saved connection config
|
|
2
|
+
// (token / session / site / api_url / confirm_mode).
|
|
3
3
|
//
|
|
4
4
|
// Stored under a stable home dir so it survives `npx` (ephemeral package cache) and
|
|
5
|
-
// container restarts.
|
|
5
|
+
// container restarts. A flat JSON file instead of SQLite — keeps the package light
|
|
6
6
|
// and works in any runtime (Alpine, Docker `--ignore-scripts`, serverless) with no
|
|
7
7
|
// native binding to build. The API is synchronous to match the call sites.
|
|
8
8
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -11,7 +11,6 @@ import { join } from "node:path";
|
|
|
11
11
|
const CONFIG_DIR = process.env.WEBCAKE_CONFIG_DIR || join(homedir(), ".webcake-storefront-mcp");
|
|
12
12
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
13
13
|
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
14
|
-
const ALT_FILE = join(CONFIG_DIR, "image-alt-cache.json");
|
|
15
14
|
function readJson(file, fallback) {
|
|
16
15
|
try {
|
|
17
16
|
return JSON.parse(readFileSync(file, "utf-8"));
|
|
@@ -44,40 +43,3 @@ export function delConfig(key) {
|
|
|
44
43
|
export function getAllConfig() {
|
|
45
44
|
return { ...config };
|
|
46
45
|
}
|
|
47
|
-
const altCache = readJson(ALT_FILE, {});
|
|
48
|
-
export function getImageAlt(urlKey) {
|
|
49
|
-
return altCache[urlKey] || null;
|
|
50
|
-
}
|
|
51
|
-
export function getImageAlts(urlKeys) {
|
|
52
|
-
const out = new Map();
|
|
53
|
-
for (const k of urlKeys) {
|
|
54
|
-
const row = altCache[k];
|
|
55
|
-
if (row)
|
|
56
|
-
out.set(k, row);
|
|
57
|
-
}
|
|
58
|
-
return out;
|
|
59
|
-
}
|
|
60
|
-
export function setImageAlt({ url_key, url, alt, source = "ai" }) {
|
|
61
|
-
altCache[url_key] = { url_key, url, alt, source, updated_at: Date.now() };
|
|
62
|
-
writeJson(ALT_FILE, altCache);
|
|
63
|
-
}
|
|
64
|
-
export function setImageAlts(items) {
|
|
65
|
-
for (const it of items) {
|
|
66
|
-
altCache[it.url_key] = {
|
|
67
|
-
url_key: it.url_key,
|
|
68
|
-
url: it.url,
|
|
69
|
-
alt: it.alt,
|
|
70
|
-
source: it.source || "ai",
|
|
71
|
-
updated_at: Date.now(),
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
writeJson(ALT_FILE, altCache);
|
|
75
|
-
}
|
|
76
|
-
export function listImageAlts(limit = 100, offset = 0) {
|
|
77
|
-
return Object.values(altCache)
|
|
78
|
-
.sort((a, b) => b.updated_at - a.updated_at)
|
|
79
|
-
.slice(offset, offset + limit);
|
|
80
|
-
}
|
|
81
|
-
export function countImageAlts() {
|
|
82
|
-
return Object.keys(altCache).length;
|
|
83
|
-
}
|
package/dist/http.js
CHANGED
|
@@ -385,7 +385,7 @@ export async function startHttpServer(port) {
|
|
|
385
385
|
transports.delete(transport.sessionId);
|
|
386
386
|
};
|
|
387
387
|
const api = apiFromRequest(req);
|
|
388
|
-
const server = createServer(api);
|
|
388
|
+
const server = createServer(api, { allowLocalFiles: false }); // remote: never read server-side files
|
|
389
389
|
await server.connect(transport);
|
|
390
390
|
await transport.handleRequest(req, res, body);
|
|
391
391
|
return;
|
package/dist/index.js
CHANGED
|
@@ -58,7 +58,7 @@ async function main() {
|
|
|
58
58
|
// no env is required to start — auth (token + session) is set via env or `login`,
|
|
59
59
|
// and the site is chosen at runtime with switch_site.
|
|
60
60
|
const api = makeApi();
|
|
61
|
-
const server = createServer(api);
|
|
61
|
+
const server = createServer(api, { allowLocalFiles: true }); // stdio = user's own machine
|
|
62
62
|
const transport = new StdioServerTransport();
|
|
63
63
|
await server.connect(transport);
|
|
64
64
|
console.error("[webcake-storefront] MCP server ready on stdio.");
|
package/dist/server.js
CHANGED
|
@@ -31,7 +31,7 @@ function makeResult(data) {
|
|
|
31
31
|
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
32
32
|
}
|
|
33
33
|
/** Build a fully-wired MCP server bound to the given API client. */
|
|
34
|
-
export function createServer(api) {
|
|
34
|
+
export function createServer(api, opts = {}) {
|
|
35
35
|
const server = new McpServer({ name: "webcake-storefront", version: "1.0.0" }, { instructions: INSTRUCTIONS });
|
|
36
36
|
const handle = async (fn) => {
|
|
37
37
|
try {
|
|
@@ -58,6 +58,6 @@ export function createServer(api) {
|
|
|
58
58
|
registerGlobalSourceTools(server, api, handle);
|
|
59
59
|
registerImageTools(server, api, handle);
|
|
60
60
|
registerBuilderTools(server, api, handle);
|
|
61
|
-
registerBuilderExtraTools(server, api, handle);
|
|
61
|
+
registerBuilderExtraTools(server, api, handle, { allowLocalFiles: opts.allowLocalFiles === true });
|
|
62
62
|
return server;
|
|
63
63
|
}
|
|
@@ -1,7 +1,41 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { resolvePreviewUrl } from "../config.js";
|
|
3
3
|
import { parse as parseHtml } from "node-html-parser";
|
|
4
|
+
import { stat, readFile } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
4
8
|
const ALLOWED_IMG = /^image\/(jpe?g|png|webp)$/;
|
|
9
|
+
const LOCAL_MAX = 200 * 1024 * 1024; // 200 MB, matches the backend multipart limit
|
|
10
|
+
/** Is this entry a LOCAL filesystem path (vs an http(s) URL or data: URI)? */
|
|
11
|
+
function isLocalPath(s) {
|
|
12
|
+
if (s.startsWith("data:") || /^https?:\/\//i.test(s))
|
|
13
|
+
return false;
|
|
14
|
+
return s.startsWith("file://") || s.startsWith("/") || s.startsWith("~") || /^[a-zA-Z]:[\\/]/.test(s);
|
|
15
|
+
}
|
|
16
|
+
/** Resolve ~ and file:// to an absolute path. */
|
|
17
|
+
function resolveLocalPath(s) {
|
|
18
|
+
if (s.startsWith("file://"))
|
|
19
|
+
return fileURLToPath(s);
|
|
20
|
+
if (s.startsWith("~"))
|
|
21
|
+
return join(homedir(), s.slice(1));
|
|
22
|
+
return s;
|
|
23
|
+
}
|
|
24
|
+
/** Read a local image file into a buffer (with a size cap) + guess its content type. */
|
|
25
|
+
async function readLocalImage(s) {
|
|
26
|
+
const p = resolveLocalPath(s);
|
|
27
|
+
const st = await stat(p);
|
|
28
|
+
if (st.size > LOCAL_MAX)
|
|
29
|
+
throw new Error(`File too large (${st.size} bytes, max ${LOCAL_MAX}).`);
|
|
30
|
+
const buf = await readFile(p);
|
|
31
|
+
const ext = (p.split(".").pop() || "").toLowerCase();
|
|
32
|
+
const contentType = ext === "png" ? "image/png"
|
|
33
|
+
: ext === "webp" ? "image/webp"
|
|
34
|
+
: ext === "gif" ? "image/gif"
|
|
35
|
+
: ext === "jpg" || ext === "jpeg" ? "image/jpeg"
|
|
36
|
+
: "application/octet-stream";
|
|
37
|
+
return { buf, contentType };
|
|
38
|
+
}
|
|
5
39
|
/** Fetch a URL into a Buffer with a size cap. */
|
|
6
40
|
async function fetchBuffer(url, maxBytes = 15 * 1024 * 1024) {
|
|
7
41
|
const res = await fetch(url, { redirect: "follow" });
|
|
@@ -21,7 +55,7 @@ async function toAllowedImage(buf, contentType) {
|
|
|
21
55
|
const out = await sharp(buf).jpeg({ quality: 85 }).toBuffer();
|
|
22
56
|
return { buf: out, contentType: "image/jpeg" };
|
|
23
57
|
}
|
|
24
|
-
export function registerBuilderExtraTools(server, api, handle) {
|
|
58
|
+
export function registerBuilderExtraTools(server, api, handle, opts = {}) {
|
|
25
59
|
// ── Stock images (Pexels) ──────────────────────────────────────────────────
|
|
26
60
|
server.tool("search_images", `Search stock photos (Pexels) to use on a page. Returns hosted image URLs you can put straight into an image element's runtime.config.src.
|
|
27
61
|
Requires the PEXELS_API_KEY environment variable.`, {
|
|
@@ -52,29 +86,78 @@ Requires the PEXELS_API_KEY environment variable.`, {
|
|
|
52
86
|
}));
|
|
53
87
|
return { query, total_results: json.total_results, photos };
|
|
54
88
|
}));
|
|
55
|
-
// ── Upload
|
|
56
|
-
server.tool("
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
89
|
+
// ── Upload images to the site CDN ───────────────────────────────────────────
|
|
90
|
+
server.tool("upload_images", `Convert external image URLs, data: URIs, or LOCAL FILE PATHS into site-hosted CDN URLs by reading/downloading each image and re-uploading it to the WebCake backend. Use this whenever the user supplies their OWN images (their URLs or files from their machine), or a page is built from a reference HTML/URL. The returned hosted URLs go straight into an image element's specials.src / runtime.config.src — same as search_images results. Stock photos from search_images are already hosted and don't need uploading.
|
|
91
|
+
Processes up to 20 entries per call in parallel; non jpeg/png/webp inputs are converted to JPEG. UPLOADS BY DEFAULT (dry_run defaults to FALSE — this touches no account data): returns an "images" map (original source → hosted URL). Pass dry_run:true to only preview the entries that WOULD be processed (local paths report whether the file exists + its size) without any network/filesystem upload. Local file paths are only permitted when the MCP server runs locally (stdio); on the remote HTTP transport they are rejected per-entry.`, {
|
|
92
|
+
urls: z
|
|
93
|
+
.array(z.string())
|
|
94
|
+
.min(1)
|
|
95
|
+
.max(20)
|
|
96
|
+
.describe("Image sources — 1–20 per call. Accepts: http(s) URLs, data:image/...;base64,... URIs, or local file paths (absolute /path, ~/path, file:// — stdio mode only)."),
|
|
97
|
+
dry_run: z
|
|
98
|
+
.boolean()
|
|
99
|
+
.default(false)
|
|
100
|
+
.describe("Default FALSE — actually reads/downloads and uploads, returning hosted URLs. Set true to only preview what would be processed."),
|
|
101
|
+
}, ({ urls, dry_run }) => handle(async () => {
|
|
102
|
+
const deduped = [...new Set(urls)];
|
|
103
|
+
const localAllowed = opts.allowLocalFiles === true;
|
|
104
|
+
if (dry_run) {
|
|
105
|
+
const entries = await Promise.all(deduped.map(async (entry) => {
|
|
106
|
+
if (entry.startsWith("data:"))
|
|
107
|
+
return { entry, kind: "data-uri" };
|
|
108
|
+
if (isLocalPath(entry)) {
|
|
109
|
+
if (!localAllowed)
|
|
110
|
+
return { entry, kind: "local", error: "Local file paths are only supported in stdio mode." };
|
|
111
|
+
try {
|
|
112
|
+
const st = await stat(resolveLocalPath(entry));
|
|
113
|
+
return { entry, kind: "local", exists: true, size: st.size };
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
return { entry, kind: "local", exists: false, error: e?.message ?? String(e) };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { entry, kind: "url" };
|
|
120
|
+
}));
|
|
121
|
+
return { dry_run: true, count: deduped.length, entries };
|
|
70
122
|
}
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
123
|
+
const images = {};
|
|
124
|
+
const errors = [];
|
|
125
|
+
await Promise.all(deduped.map(async (entry) => {
|
|
126
|
+
try {
|
|
127
|
+
let buf, contentType;
|
|
128
|
+
if (entry.startsWith("data:")) {
|
|
129
|
+
const m = entry.match(/^data:([^;]+);base64,(.*)$/s);
|
|
130
|
+
if (!m)
|
|
131
|
+
throw new Error("Malformed data URI.");
|
|
132
|
+
contentType = m[1];
|
|
133
|
+
buf = Buffer.from(m[2], "base64");
|
|
134
|
+
}
|
|
135
|
+
else if (isLocalPath(entry)) {
|
|
136
|
+
if (!localAllowed)
|
|
137
|
+
throw new Error("Local file paths are only supported when the server runs locally (stdio). Send a public URL or data: URI instead.");
|
|
138
|
+
({ buf, contentType } = await readLocalImage(entry));
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
({ buf, contentType } = await fetchBuffer(entry));
|
|
142
|
+
}
|
|
143
|
+
const norm = await toAllowedImage(buf, contentType);
|
|
144
|
+
const dataUri = `data:${norm.contentType};base64,${norm.buf.toString("base64")}`;
|
|
145
|
+
const res = await api.uploadImageBase64({ base64: dataUri, content_type: norm.contentType });
|
|
146
|
+
const hosted = (res && res.data) || (res && res.url) || null;
|
|
147
|
+
if (!hosted)
|
|
148
|
+
throw new Error("Upload returned no URL.");
|
|
149
|
+
images[entry] = hosted;
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
errors.push({ url: entry, error: e?.message ?? String(e) });
|
|
153
|
+
}
|
|
154
|
+
}));
|
|
155
|
+
return {
|
|
156
|
+
uploaded: Object.keys(images).length,
|
|
157
|
+
failed: errors.length,
|
|
158
|
+
images,
|
|
159
|
+
...(errors.length ? { errors } : {}),
|
|
160
|
+
};
|
|
78
161
|
}));
|
|
79
162
|
// ── Publish the site ────────────────────────────────────────────────────────
|
|
80
163
|
server.tool("publish_site", `Publish the whole site live — snapshots all current page sources into the live (published) version.
|
|
@@ -143,7 +226,7 @@ Two-step safety: dry_run=true (default) describes what will happen; dry_run=fals
|
|
|
143
226
|
images,
|
|
144
227
|
buttons,
|
|
145
228
|
palette: [...colors].slice(0, 24),
|
|
146
|
-
hint: "Rebuild this as BuilderX sections: map each heading group + its text/image/button into a new_section call. Generate fresh copy where useful; re-host external images with
|
|
229
|
+
hint: "Rebuild this as BuilderX sections: map each heading group + its text/image/button into a new_section call. Generate fresh copy where useful; re-host external images with upload_images if you want them on the site CDN. This is a structural blueprint, not a 1:1 clone.",
|
|
147
230
|
};
|
|
148
231
|
}
|
|
149
232
|
server.tool("ingest_html", "Parse reference HTML into a structural blueprint (title, headings, paragraphs, images, buttons, colour palette) you can rebuild as BuilderX sections with new_section. Not a 1:1 clone.", {
|
package/dist/tools/builder.js
CHANGED
|
@@ -16,6 +16,18 @@ function parseSource(src) {
|
|
|
16
16
|
function newPageId(res) {
|
|
17
17
|
return (res && res.data && res.data.id) || (res && res.id) || null;
|
|
18
18
|
}
|
|
19
|
+
// BuilderX page kinds. The numeric `type` is what the backend stores (PAGE_TYPE in
|
|
20
|
+
// builderx_spa); SPECIAL kinds also require a site-level data-source flag enabled on
|
|
21
|
+
// site.settings, otherwise components that bind to store/customer/blog data render
|
|
22
|
+
// with null bindings. build_page sets both for you.
|
|
23
|
+
const PAGE_TYPE_NUM = {
|
|
24
|
+
main: 1, store: 2, member: 3, blog: 4, custom: 5, error: 6, maintain: 7,
|
|
25
|
+
};
|
|
26
|
+
const PAGE_TYPE_FLAG = {
|
|
27
|
+
store: "use_store", member: "use_member", blog: "use_blog",
|
|
28
|
+
error: "use_error", maintain: "use_maintain",
|
|
29
|
+
};
|
|
30
|
+
const PAGE_KINDS = ["main", "store", "member", "blog", "custom", "error", "maintain"];
|
|
19
31
|
export function registerBuilderTools(server, api, handle) {
|
|
20
32
|
server.tool("get_build_guide", "Get the BuilderX page authoring guide: page shape, the grid layout model, styling, breakpoints, forms/data, and the build workflow. Read this before building or heavily editing a page.", {}, () => handle(async () => ({ guide: BUILD_GUIDE })));
|
|
21
33
|
server.tool("list_elements", "List all BuilderX element/component types you can place on a page, grouped by category with a one-line summary and whether each is a container.", {}, () => handle(async () => listElements()));
|
|
@@ -45,26 +57,45 @@ The source must be { sections: [...] } — build sections with new_section. Vali
|
|
|
45
57
|
name: z.string().describe("Page name"),
|
|
46
58
|
slug: z.string().describe("URL slug, e.g. '/landing' or '/about'"),
|
|
47
59
|
source: z.any().describe("Full page source { sections: [...] } (object or JSON string)"),
|
|
48
|
-
type: z
|
|
60
|
+
type: z
|
|
61
|
+
.enum(PAGE_KINDS)
|
|
62
|
+
.optional()
|
|
63
|
+
.describe("Page kind. SPECIAL pages need a site data-source enabled — build_page does this automatically: store→use_store (product/cart bindings), member→use_member (customer/order bindings), blog→use_blog, error→use_error, maintain→use_maintain. 'main'/'custom' need nothing. Omit for a normal content page (defaults to 'main' for the homepage)."),
|
|
49
64
|
is_homepage: z.boolean().default(false).describe("Set as the site homepage"),
|
|
50
65
|
dry_run: z.boolean().default(true).describe("Preview+validate only (true) or create+save (false)"),
|
|
51
66
|
}, ({ name, slug, source, type, is_homepage, dry_run }) => handle(async () => {
|
|
52
67
|
const parsed = parseSource(source);
|
|
53
68
|
const validation = validatePage(parsed);
|
|
69
|
+
// Resolve numeric page type + the site data-source flag a special page needs.
|
|
70
|
+
const kind = type || (is_homepage ? "main" : undefined);
|
|
71
|
+
const typeNum = kind ? PAGE_TYPE_NUM[kind] : undefined;
|
|
72
|
+
const requiredFlag = kind ? PAGE_TYPE_FLAG[kind] : undefined;
|
|
54
73
|
if (dry_run) {
|
|
55
74
|
return {
|
|
56
75
|
dry_run: true,
|
|
57
76
|
validation,
|
|
58
|
-
request: { name, slug, type, is_homepage, sections: (parsed && parsed.sections || []).length },
|
|
77
|
+
request: { name, slug, type: kind ?? null, page_type_num: typeNum ?? null, is_homepage, sections: (parsed && parsed.sections || []).length },
|
|
78
|
+
will_enable_feature: requiredFlag ?? null,
|
|
59
79
|
hint: validation.valid
|
|
60
|
-
?
|
|
80
|
+
? `Looks valid. Call again with dry_run=false to create and save the page.${requiredFlag ? ` Will also enable site.settings.${requiredFlag} so its data bindings resolve.` : ""}`
|
|
61
81
|
: "Fix the errors above before saving.",
|
|
62
82
|
};
|
|
63
83
|
}
|
|
64
84
|
if (!validation.valid) {
|
|
65
85
|
return { error: "Validation failed — not saving.", validation };
|
|
66
86
|
}
|
|
67
|
-
|
|
87
|
+
// A special page is useless if its site data-source flag is off (bindings
|
|
88
|
+
// return null). Enable it BEFORE creating the page so the page works on first load.
|
|
89
|
+
let feature = null;
|
|
90
|
+
if (requiredFlag) {
|
|
91
|
+
try {
|
|
92
|
+
feature = await api.enableSiteFeature(requiredFlag);
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
return { error: `Could not enable site.settings.${requiredFlag} (needed for a '${kind}' page). ${e?.message ?? e}` };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const created = await api.createPage({ name, slug, ...(typeNum != null ? { type: typeNum } : {}), is_homepage });
|
|
68
99
|
const pageId = newPageId(created);
|
|
69
100
|
if (!pageId) {
|
|
70
101
|
return { error: "Page created but no id was returned; cannot save source.", created };
|
|
@@ -75,6 +106,8 @@ The source must be { sections: [...] } — build sections with new_section. Vali
|
|
|
75
106
|
page_id: pageId,
|
|
76
107
|
name,
|
|
77
108
|
slug,
|
|
109
|
+
page_type: kind ?? null,
|
|
110
|
+
...(feature ? { data_source: { flag: feature.flag, newly_enabled: feature.changed } } : {}),
|
|
78
111
|
page_source_id: saved && saved.data && saved.data.id,
|
|
79
112
|
stats: validation.stats,
|
|
80
113
|
};
|
package/dist/tools/images.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { getImageAlt, setImageAlts as dbSetImageAlts, listImageAlts, countImageAlts } from "../db.js";
|
|
3
|
-
import { isMongoEnabled, mongoUpsertAlts, mongoFindAlts, mongoListAlts } from "../mongo.js";
|
|
4
2
|
const IMAGE_EXT_RE = /\.(jpe?g|png|gif|webp|svg|avif|bmp|ico)(\?[^"')\s]*)?$/i;
|
|
5
3
|
const URL_IN_CSS_RE = /url\(\s*['"]?([^'")\s]+)['"]?\s*\)/g;
|
|
6
4
|
const HTTP_URL_RE = /https?:\/\/[^\s"'<>)]+/g;
|
|
@@ -405,7 +403,6 @@ Note: global_sections are read-only via the API and are not included.`, {
|
|
|
405
403
|
const { src, alt, src_path, alt_path } = probeImagePaths(node);
|
|
406
404
|
if (only_missing_alt && alt && alt.trim())
|
|
407
405
|
return;
|
|
408
|
-
const cached = src ? getImageAlt(normalizeUrl(src)) : null;
|
|
409
406
|
out.push({
|
|
410
407
|
source_type: meta.source_type,
|
|
411
408
|
source_id: meta.source_id,
|
|
@@ -416,7 +413,6 @@ Note: global_sections are read-only via the API and are not included.`, {
|
|
|
416
413
|
alt: alt || "",
|
|
417
414
|
src_path,
|
|
418
415
|
alt_path,
|
|
419
|
-
...(cached && { cached_alt: cached.alt, cached_source: cached.source, cached_at: cached.updated_at }),
|
|
420
416
|
});
|
|
421
417
|
});
|
|
422
418
|
}
|
|
@@ -571,30 +567,11 @@ If alt_path is omitted, it is auto-detected via the same probe used by list_imag
|
|
|
571
567
|
}
|
|
572
568
|
try {
|
|
573
569
|
await saver(source);
|
|
574
|
-
// Auto-cache: save alt per src URL so re-runs can skip OCR
|
|
575
|
-
const cacheBatch = [];
|
|
576
|
-
for (const u of perItem) {
|
|
577
|
-
if (u.error || !u._src)
|
|
578
|
-
continue;
|
|
579
|
-
if (!/^https?:\/\//i.test(u._src))
|
|
580
|
-
continue;
|
|
581
|
-
cacheBatch.push({ url_key: normalizeUrl(u._src), url: u._src, alt: u.after, source: "ai" });
|
|
582
|
-
}
|
|
583
|
-
if (cacheBatch.length) {
|
|
584
|
-
try {
|
|
585
|
-
dbSetImageAlts(cacheBatch);
|
|
586
|
-
}
|
|
587
|
-
catch { /* cache best-effort */ }
|
|
588
|
-
if (isMongoEnabled()) {
|
|
589
|
-
mongoUpsertAlts(cacheBatch).catch(() => { });
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
570
|
results.push({
|
|
593
571
|
source_type,
|
|
594
572
|
source_id,
|
|
595
573
|
success: true,
|
|
596
574
|
updated: perItem.filter((u) => !u.error).length,
|
|
597
|
-
cached: cacheBatch.length,
|
|
598
575
|
updates: perItem.map(({ _src, ...rest }) => rest),
|
|
599
576
|
});
|
|
600
577
|
}
|
|
@@ -604,137 +581,21 @@ If alt_path is omitted, it is auto-detected via the same probe used by list_imag
|
|
|
604
581
|
}
|
|
605
582
|
return { dry_run, sources: results.length, results };
|
|
606
583
|
}));
|
|
607
|
-
// ── Alt cache tools ──
|
|
608
|
-
server.tool("get_cached_image_alts", `Look up cached alt descriptions for image URLs. URLs are matched by normalized form (query string stripped, lowercase). Use BEFORE running read_image/OCR — skip already-described URLs.
|
|
609
|
-
When MONGO_URI is set, misses are then checked against MongoDB and successful hits are backfilled into the local cache for fast re-lookup.`, {
|
|
610
|
-
urls: z.array(z.string()).min(1).describe("Image URLs to look up"),
|
|
611
|
-
}, ({ urls }) => handle(async () => {
|
|
612
|
-
const hits = [];
|
|
613
|
-
let misses = [];
|
|
614
|
-
const keyToUrl = new Map();
|
|
615
|
-
for (const u of urls) {
|
|
616
|
-
if (!/^https?:\/\//i.test(u)) {
|
|
617
|
-
misses.push(u);
|
|
618
|
-
continue;
|
|
619
|
-
}
|
|
620
|
-
const key = normalizeUrl(u);
|
|
621
|
-
keyToUrl.set(key, u);
|
|
622
|
-
const row = getImageAlt(key);
|
|
623
|
-
if (row)
|
|
624
|
-
hits.push({ url: u, url_key: key, alt: row.alt, source: row.source, updated_at: row.updated_at });
|
|
625
|
-
else
|
|
626
|
-
misses.push(u);
|
|
627
|
-
}
|
|
628
|
-
let mongo_hits = 0;
|
|
629
|
-
if (isMongoEnabled() && misses.length) {
|
|
630
|
-
const missKeys = misses
|
|
631
|
-
.filter((u) => /^https?:\/\//i.test(u))
|
|
632
|
-
.map((u) => normalizeUrl(u));
|
|
633
|
-
try {
|
|
634
|
-
const found = await mongoFindAlts(missKeys);
|
|
635
|
-
if (found.size) {
|
|
636
|
-
const backfill = [];
|
|
637
|
-
const stillMissing = [];
|
|
638
|
-
for (const u of misses) {
|
|
639
|
-
const k = /^https?:\/\//i.test(u) ? normalizeUrl(u) : null;
|
|
640
|
-
if (k && found.has(k)) {
|
|
641
|
-
const doc = found.get(k);
|
|
642
|
-
hits.push({ url: u, url_key: k, alt: doc.alt, source: doc.source || "mongo", updated_at: doc.updated_at, origin: "mongo" });
|
|
643
|
-
backfill.push({ url_key: k, url: doc.url || u, alt: doc.alt, source: doc.source || "mongo" });
|
|
644
|
-
mongo_hits++;
|
|
645
|
-
}
|
|
646
|
-
else {
|
|
647
|
-
stillMissing.push(u);
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
if (backfill.length) {
|
|
651
|
-
try {
|
|
652
|
-
dbSetImageAlts(backfill);
|
|
653
|
-
}
|
|
654
|
-
catch { /* best-effort */ }
|
|
655
|
-
}
|
|
656
|
-
misses = stillMissing;
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
catch { /* fall through with original misses */ }
|
|
660
|
-
}
|
|
661
|
-
return { hits_count: hits.length, miss_count: misses.length, mongo_hits, hits, misses };
|
|
662
|
-
}));
|
|
663
|
-
server.tool("save_image_alts_cache", `Manually save image URL → alt entries to the local cache. Useful for bulk import or saving descriptions generated outside the set_image_alts flow.`, {
|
|
664
|
-
items: z.array(z.object({
|
|
665
|
-
url: z.string().describe("Image URL"),
|
|
666
|
-
alt: z.string().describe("Alt/description text"),
|
|
667
|
-
source: z.string().optional().describe("Origin tag (e.g. 'ai', 'manual', 'imported'). Default 'manual'"),
|
|
668
|
-
})).min(1),
|
|
669
|
-
}, ({ items }) => handle(async () => {
|
|
670
|
-
const batch = [];
|
|
671
|
-
const skipped = [];
|
|
672
|
-
for (const it of items) {
|
|
673
|
-
if (!/^https?:\/\//i.test(it.url)) {
|
|
674
|
-
skipped.push({ url: it.url, reason: "non-http URL" });
|
|
675
|
-
continue;
|
|
676
|
-
}
|
|
677
|
-
batch.push({ url_key: normalizeUrl(it.url), url: it.url, alt: it.alt, source: it.source || "manual" });
|
|
678
|
-
}
|
|
679
|
-
if (batch.length) {
|
|
680
|
-
dbSetImageAlts(batch);
|
|
681
|
-
if (isMongoEnabled()) {
|
|
682
|
-
mongoUpsertAlts(batch).catch(() => { });
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
return { saved: batch.length, skipped, mongo: isMongoEnabled() ? "queued" : "disabled" };
|
|
686
|
-
}));
|
|
687
|
-
server.tool("list_image_alts_cache", `List entries in the alt cache, most recently updated first.`, {
|
|
688
|
-
limit: z.number().default(100).describe("Max entries (default 100)"),
|
|
689
|
-
offset: z.number().default(0).describe("Pagination offset"),
|
|
690
|
-
}, ({ limit, offset }) => handle(async () => {
|
|
691
|
-
const total = countImageAlts();
|
|
692
|
-
const rows = listImageAlts(limit, offset);
|
|
693
|
-
return { total, count: rows.length, entries: rows };
|
|
694
|
-
}));
|
|
695
|
-
// ── Mongo sync (active when MONGO_URI is set) ──
|
|
696
|
-
server.tool("sync_image_alts_to_mongo", `Push local alt cache entries up to MongoDB. Bulk upsert keyed by url_key. Use when you want to back up local-only entries to the shared central store, or after a session of heavy AI describes.
|
|
697
|
-
Requires MONGO_URI env var.`, {
|
|
698
|
-
limit: z.number().default(1000).describe("Max entries to push per call"),
|
|
699
|
-
offset: z.number().default(0).describe("Offset into local cache"),
|
|
700
|
-
}, ({ limit, offset }) => handle(async () => {
|
|
701
|
-
if (!isMongoEnabled())
|
|
702
|
-
return { error: "MONGO_URI not configured" };
|
|
703
|
-
const rows = listImageAlts(limit, offset);
|
|
704
|
-
if (!rows.length)
|
|
705
|
-
return { pushed: 0, total_local: countImageAlts() };
|
|
706
|
-
const res = await mongoUpsertAlts(rows.map((r) => ({ url_key: r.url_key, url: r.url, alt: r.alt, source: r.source })));
|
|
707
|
-
return { pushed: rows.length, ...res, total_local: countImageAlts() };
|
|
708
|
-
}));
|
|
709
|
-
server.tool("sync_image_alts_from_mongo", `Pull MongoDB alt entries down into local cache. Useful when starting on a new machine/site to warm the local cache from the central store.
|
|
710
|
-
Requires MONGO_URI env var.`, {
|
|
711
|
-
limit: z.number().default(1000).describe("Max entries to pull"),
|
|
712
|
-
offset: z.number().default(0).describe("Offset into Mongo collection"),
|
|
713
|
-
}, ({ limit, offset }) => handle(async () => {
|
|
714
|
-
if (!isMongoEnabled())
|
|
715
|
-
return { error: "MONGO_URI not configured" };
|
|
716
|
-
const { total, entries } = await mongoListAlts(limit, offset);
|
|
717
|
-
if (entries.length) {
|
|
718
|
-
dbSetImageAlts(entries.map((e) => ({ url_key: e.url_key, url: e.url, alt: e.alt, source: e.source || "mongo" })));
|
|
719
|
-
}
|
|
720
|
-
return { pulled: entries.length, total_remote: total, total_local: countImageAlts() };
|
|
721
|
-
}));
|
|
722
584
|
// ── Combo: fetch images + metadata in one call so Claude can describe + call set_image_alts once ──
|
|
723
585
|
server.tool("fetch_images_for_alt_fill", `One-shot helper for filling image_alt across the site. Returns image bytes + element metadata in a single response so Claude can describe everything in one pass, then call set_image_alts once.
|
|
724
586
|
|
|
725
587
|
Workflow:
|
|
726
588
|
1. Call this tool with scope/limit.
|
|
727
|
-
2. Tool returns each image inline with its element_id + source_type + source_id
|
|
589
|
+
2. Tool returns each image inline with its element_id + source_type + source_id.
|
|
728
590
|
3. Claude reads images, drafts an alt for each, then calls set_image_alts(items) once with the template at the end of the response.
|
|
729
591
|
|
|
730
592
|
The pre-built "items" template at the end contains placeholders — fill in "alt" and call set_image_alts.`, {
|
|
731
593
|
scope: z.enum(["all", "pages", "global_sources"]).default("all"),
|
|
732
594
|
page_id: z.string().optional(),
|
|
733
595
|
only_missing_alt: z.boolean().default(true).describe("Default true — skip elements that already have alt"),
|
|
734
|
-
skip_cached: z.boolean().default(true).describe("Skip URLs already in alt cache (Claude doesn't need to describe again)"),
|
|
735
596
|
limit: z.number().default(10).describe("Max images per call (cap 20)"),
|
|
736
597
|
max_size_mb: z.number().default(8),
|
|
737
|
-
}, async ({ scope, page_id, only_missing_alt,
|
|
598
|
+
}, async ({ scope, page_id, only_missing_alt, limit, max_size_mb }) => {
|
|
738
599
|
try {
|
|
739
600
|
const cap = Math.min(Math.max(limit, 1), 20);
|
|
740
601
|
// 1. Collect candidate elements
|
|
@@ -790,17 +651,9 @@ The pre-built "items" template at the end contains placeholders — fill in "alt
|
|
|
790
651
|
});
|
|
791
652
|
}
|
|
792
653
|
}
|
|
793
|
-
// 2.
|
|
794
|
-
const autoItems = [];
|
|
654
|
+
// 2. Take up to `cap` candidates that need a vision-generated description
|
|
795
655
|
const needVision = [];
|
|
796
656
|
for (const c of candidates) {
|
|
797
|
-
if (skip_cached) {
|
|
798
|
-
const cached = getImageAlt(normalizeUrl(c.src));
|
|
799
|
-
if (cached && cached.alt) {
|
|
800
|
-
autoItems.push({ source_type: c.source_type, source_id: c.source_id, element_id: c.element_id, alt: cached.alt });
|
|
801
|
-
continue;
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
657
|
needVision.push(c);
|
|
805
658
|
if (needVision.length >= cap)
|
|
806
659
|
break;
|
|
@@ -812,7 +665,7 @@ The pre-built "items" template at the end contains placeholders — fill in "alt
|
|
|
812
665
|
const content = [];
|
|
813
666
|
content.push({
|
|
814
667
|
type: "text",
|
|
815
|
-
text: `Fetched ${needVision.length} image(s) needing description. ${
|
|
668
|
+
text: `Fetched ${needVision.length} image(s) needing description. ${Math.max(0, candidates.length - needVision.length)} more candidate(s) not included in this batch.
|
|
816
669
|
|
|
817
670
|
For each image below, write a short alt description in the language of the site (Vietnamese unless content suggests otherwise). Focus on the SUBJECT visible — avoid generic phrases like "image of...".
|
|
818
671
|
|
|
@@ -838,9 +691,8 @@ When done, call set_image_alts with the items array. The template is at the bott
|
|
|
838
691
|
}
|
|
839
692
|
}
|
|
840
693
|
const template = {
|
|
841
|
-
auto_from_cache: autoItems,
|
|
842
694
|
to_describe: visionItems,
|
|
843
|
-
next_step: "Replace each <FILL_ALT_FOR_#N> with your description, then call set_image_alts with items =
|
|
695
|
+
next_step: "Replace each <FILL_ALT_FOR_#N> with your description, then call set_image_alts with items = to_describe.",
|
|
844
696
|
};
|
|
845
697
|
content.push({ type: "text", text: JSON.stringify(template, null, 2) });
|
|
846
698
|
return { content };
|
package/dist/web-guide.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Self-contained (inline CSS + JS, no external fonts/trackers) so it loads instantly.
|
|
16
16
|
*/
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
17
18
|
// The SPA page (on the builder app) that shows the user their personal remote
|
|
18
19
|
// connector link with login already built in — see builderx_spa McpRemoteStore.vue
|
|
19
20
|
// (/mcp-remote-store). The raw MCP endpoint itself is {ENDPOINT} = <origin>/mcp.
|
|
@@ -281,6 +282,9 @@ const T = {
|
|
|
281
282
|
promptSub: "Bạn có thể nói tự nhiên bằng tiếng Việt. Ví dụ:",
|
|
282
283
|
promptEx: "Tạo cho tôi một trang sản phẩm trên WebCake cho thương hiệu [tên thương hiệu].\nTrang cần có: ảnh sản phẩm lớn, tên và giá, mô tả ngắn, nút \"Mua ngay\".\nKiểm tra kỹ trước khi lưu, rồi xuất bản lên cửa hàng của tôi.",
|
|
283
284
|
faqH2: "Câu hỏi thường gặp",
|
|
285
|
+
newH2: "Có gì mới",
|
|
286
|
+
newBadge: "MỚI",
|
|
287
|
+
clMore: "Xem tất cả thay đổi",
|
|
284
288
|
starH2: "Thấy hữu ích? Tặng dự án một ngôi sao nhé",
|
|
285
289
|
starP: "Đây là dự án miễn phí, mã nguồn mở — mỗi ngôi sao là một lời động viên để dự án tiếp tục phát triển và giúp nhiều người tìm ra nó hơn.",
|
|
286
290
|
starBtn: "Tặng sao trên GitHub",
|
|
@@ -292,6 +296,7 @@ const T = {
|
|
|
292
296
|
{ href: "#build", label: "Tạo được gì" },
|
|
293
297
|
{ href: "#connect", label: "Kết nối" },
|
|
294
298
|
{ href: "#tools", label: "Trợ lý làm gì" },
|
|
299
|
+
{ href: "#new", label: "Có gì mới" },
|
|
295
300
|
{ href: "#faq", label: "Hỏi đáp" },
|
|
296
301
|
],
|
|
297
302
|
},
|
|
@@ -439,6 +444,9 @@ const T = {
|
|
|
439
444
|
promptSub: "You can speak naturally. For example:",
|
|
440
445
|
promptEx: "Create a product page on my WebCake store for [brand name].\nThe page should have: a large product image, name and price, a short description, and a \"Buy Now\" button.\nPlease check everything looks right before saving, then publish it to my store.",
|
|
441
446
|
faqH2: "Frequently asked questions",
|
|
447
|
+
newH2: "What's new",
|
|
448
|
+
newBadge: "NEW",
|
|
449
|
+
clMore: "See all changes",
|
|
442
450
|
starH2: "Find it useful? Give the project a star",
|
|
443
451
|
starP: "It's a free, open-source project — every star is a little encouragement to keep it growing and helps more people find it.",
|
|
444
452
|
starBtn: "Star on GitHub",
|
|
@@ -450,10 +458,38 @@ const T = {
|
|
|
450
458
|
{ href: "#build", label: "What you build" },
|
|
451
459
|
{ href: "#connect", label: "Connect" },
|
|
452
460
|
{ href: "#tools", label: "What it does" },
|
|
461
|
+
{ href: "#new", label: "What's new" },
|
|
453
462
|
{ href: "#faq", label: "FAQ" },
|
|
454
463
|
],
|
|
455
464
|
},
|
|
456
465
|
};
|
|
466
|
+
const CHANGELOG = loadChangelog();
|
|
467
|
+
function loadChangelog() {
|
|
468
|
+
try {
|
|
469
|
+
const raw = readFileSync(new URL("./changelog.json", import.meta.url), "utf8");
|
|
470
|
+
const data = JSON.parse(raw);
|
|
471
|
+
return Array.isArray(data) ? data : [];
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
return [];
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
// English Keep-a-Changelog section names → short Vietnamese tags (en uses the raw name).
|
|
478
|
+
const CL_TYPE_VI = {
|
|
479
|
+
Added: "Thêm mới",
|
|
480
|
+
Changed: "Cải tiến",
|
|
481
|
+
Fixed: "Sửa lỗi",
|
|
482
|
+
Removed: "Gỡ bỏ",
|
|
483
|
+
Deprecated: "Ngừng dùng",
|
|
484
|
+
Security: "Bảo mật",
|
|
485
|
+
Internal: "Nội bộ",
|
|
486
|
+
};
|
|
487
|
+
function clTag(type, lang) {
|
|
488
|
+
if (!type)
|
|
489
|
+
return "";
|
|
490
|
+
const label = lang === "vi" ? (CL_TYPE_VI[type] ?? "") : type;
|
|
491
|
+
return label ? ` <span class="cl-tag">${label}</span>` : "";
|
|
492
|
+
}
|
|
457
493
|
function steps(items) {
|
|
458
494
|
return items
|
|
459
495
|
.map((body, i) => `<li><span class="n">${i + 1}</span><div class="body">${body}</div></li>`)
|
|
@@ -704,6 +740,26 @@ export function guideHtml(origin, lang = "vi") {
|
|
|
704
740
|
display:flex;gap:18px;flex-wrap:wrap;align-items:center}
|
|
705
741
|
footer a{color:var(--g7);font-weight:600;text-decoration:none;display:inline-flex;align-items:center;gap:6px}
|
|
706
742
|
footer a:hover{text-decoration:underline}
|
|
743
|
+
.cl-wrap{padding:24px 26px 12px}
|
|
744
|
+
.cl{position:relative;margin:0;padding:0 0 0 24px;list-style:none}
|
|
745
|
+
.cl::before{content:"";position:absolute;left:6px;top:8px;bottom:14px;width:2px;
|
|
746
|
+
background:linear-gradient(var(--g),rgba(16,139,103,.08))}
|
|
747
|
+
.cl li{position:relative;padding:0 0 18px}
|
|
748
|
+
.cl li:last-child{padding-bottom:0}
|
|
749
|
+
.cl li::before{content:"";position:absolute;left:-24px;top:4px;width:12px;height:12px;border-radius:50%;
|
|
750
|
+
background:var(--card);border:2.5px solid var(--g);box-sizing:border-box}
|
|
751
|
+
.cl li.is-new::before{box-shadow:0 0 0 0 rgba(16,139,103,.5);animation:ring 2s infinite}
|
|
752
|
+
@keyframes ring{70%{box-shadow:0 0 0 8px rgba(16,139,103,0)}100%{box-shadow:0 0 0 0 rgba(16,139,103,0)}}
|
|
753
|
+
.cl .v{display:inline-flex;align-items:center;gap:8px;font-weight:800;font-size:.97rem;flex-wrap:wrap}
|
|
754
|
+
.cl-tag{font-size:.68rem;font-weight:700;color:var(--g7);background:rgba(16,139,103,.12);
|
|
755
|
+
border:1px solid var(--line);padding:1px 8px;border-radius:999px;margin-left:8px}
|
|
756
|
+
.cl .date{color:var(--mut);font-size:.79rem;margin-left:8px;font-weight:500}
|
|
757
|
+
.cl .t{color:var(--mut);font-size:.91rem;margin:3px 0 0;max-width:62ch}
|
|
758
|
+
.new{font-size:.64rem;font-weight:800;letter-spacing:.06em;color:#fff;background:var(--g);
|
|
759
|
+
padding:2px 7px;border-radius:999px;animation:blink 1.8s ease-in-out infinite}
|
|
760
|
+
@keyframes blink{50%{opacity:.55}}
|
|
761
|
+
.cl-more{display:inline-flex;align-items:center;gap:6px;margin-top:6px;font-size:.86rem;font-weight:600;color:var(--g7);text-decoration:none}
|
|
762
|
+
.cl-more:hover{gap:9px}
|
|
707
763
|
@media(max-width:640px){
|
|
708
764
|
.wrap{padding:30px 15px 56px}
|
|
709
765
|
header{flex-wrap:wrap;gap:12px}
|
|
@@ -714,6 +770,7 @@ export function guideHtml(origin, lang = "vi") {
|
|
|
714
770
|
.method{padding:18px 15px}
|
|
715
771
|
.card{padding:18px}
|
|
716
772
|
.tip{padding:11px 12px}
|
|
773
|
+
.cl-wrap{padding:18px 16px 10px}
|
|
717
774
|
.langsw{padding:6px 10px}
|
|
718
775
|
.uses li,.feat li{padding:14px}
|
|
719
776
|
.flow{flex-direction:column;align-items:stretch;overflow:visible;padding:16px}
|
|
@@ -751,7 +808,7 @@ export function guideHtml(origin, lang = "vi") {
|
|
|
751
808
|
</div>
|
|
752
809
|
</header>
|
|
753
810
|
|
|
754
|
-
<p class="hero-in" style="display:flex;gap:9px;flex-wrap:wrap"><span class="pill"><span class="dot"></span> ${t.running}</span><span class="pill">WebCake · StoreCake</span
|
|
811
|
+
<p class="hero-in" style="display:flex;gap:9px;flex-wrap:wrap"><span class="pill"><span class="dot"></span> ${t.running}</span><span class="pill">WebCake · StoreCake</span>${CHANGELOG[0] ? `<span class="pill">v${CHANGELOG[0].v}</span>` : ""}</p>
|
|
755
812
|
|
|
756
813
|
<p class="lead hero-in">${t.leadPre}<b class="grad">${t.leadGrad}</b>${t.leadPost}</p>
|
|
757
814
|
|
|
@@ -828,6 +885,16 @@ export function guideHtml(origin, lang = "vi") {
|
|
|
828
885
|
<li class="glass reveal">${tile("wand")} <span><pre style="background:transparent;color:inherit;border:none;padding:0;font-size:.88rem;white-space:pre-wrap">${t.promptEx}</pre></span></li>
|
|
829
886
|
</ul>
|
|
830
887
|
|
|
888
|
+
${CHANGELOG.length
|
|
889
|
+
? `<h2 id="new" class="reveal">${t.newH2}</h2>
|
|
890
|
+
<div class="glass cl-wrap reveal">
|
|
891
|
+
<ul class="cl">
|
|
892
|
+
${CHANGELOG.map((c, i) => `<li class="${i === 0 ? "is-new" : ""}"><span class="v">v${c.v}${i === 0 ? ` <span class="new">${t.newBadge}</span>` : ""}${clTag(c.type, L)}<span class="date">${c.d}</span></span><p class="t">${L === "en" ? c.en : c.vi}</p></li>`).join("\n ")}
|
|
893
|
+
</ul>
|
|
894
|
+
<a class="cl-more" href="${GITHUB_URL}/blob/main/${L === "en" ? "CHANGELOG.md" : "CHANGELOG.vi.md"}">${t.clMore} ${icon("arrow")}</a>
|
|
895
|
+
</div>`
|
|
896
|
+
: ""}
|
|
897
|
+
|
|
831
898
|
<h2 id="faq" class="reveal">${t.faqH2}</h2>
|
|
832
899
|
${faq.map((f) => `<details class="glass reveal"><summary>${f.q}</summary><p>${f.a}</p></details>`).join("\n ")}
|
|
833
900
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webcake-storefront-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "MCP server for the WebCake/StoreCake storefront builder — page CRUD, page authoring, products, orders, and more",
|
|
5
5
|
"mcpName": "io.github.vuluu2k/webcake-storefront-mcp",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"node": ">=18"
|
|
25
25
|
},
|
|
26
26
|
"scripts": {
|
|
27
|
-
"build": "tsc && node scripts/copy-assets.mjs",
|
|
27
|
+
"build": "node scripts/gen-changelog.mjs && tsc && node scripts/copy-assets.mjs",
|
|
28
28
|
"start": "node dist/index.js",
|
|
29
29
|
"serve": "node dist/index.js serve",
|
|
30
30
|
"dev": "tsc --watch",
|
|
@@ -35,7 +35,6 @@
|
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
38
|
-
"mongodb": "^6.21.0",
|
|
39
38
|
"node-html-parser": "^8.0.2",
|
|
40
39
|
"sharp": "^0.34.5",
|
|
41
40
|
"zod": "^3.25.0"
|
package/dist/mongo.js
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
// Optional MongoDB sync layer for the image alt cache.
|
|
2
|
-
// Activates when MONGO_URI env var is set. Silently no-op when absent.
|
|
3
|
-
const MONGO_URI = process.env.MONGO_URI || "";
|
|
4
|
-
const MONGO_DB = process.env.MONGO_DB || "webcake_mcp";
|
|
5
|
-
const MONGO_COLLECTION = process.env.MONGO_COLLECTION || "image_alt_cache";
|
|
6
|
-
let _client = null;
|
|
7
|
-
let _collection = null;
|
|
8
|
-
let _connecting = null;
|
|
9
|
-
async function connect() {
|
|
10
|
-
if (!MONGO_URI)
|
|
11
|
-
return null;
|
|
12
|
-
if (_collection)
|
|
13
|
-
return _collection;
|
|
14
|
-
if (_connecting)
|
|
15
|
-
return _connecting;
|
|
16
|
-
_connecting = (async () => {
|
|
17
|
-
try {
|
|
18
|
-
const { MongoClient } = await import("mongodb");
|
|
19
|
-
_client = new MongoClient(MONGO_URI, { serverSelectionTimeoutMS: 5000 });
|
|
20
|
-
await _client.connect();
|
|
21
|
-
const db = _client.db(MONGO_DB);
|
|
22
|
-
_collection = db.collection(MONGO_COLLECTION);
|
|
23
|
-
await _collection.createIndex({ url_key: 1 }, { unique: true });
|
|
24
|
-
return _collection;
|
|
25
|
-
}
|
|
26
|
-
catch (e) {
|
|
27
|
-
_connecting = null;
|
|
28
|
-
throw e;
|
|
29
|
-
}
|
|
30
|
-
})();
|
|
31
|
-
return _connecting;
|
|
32
|
-
}
|
|
33
|
-
export function isMongoEnabled() {
|
|
34
|
-
return !!MONGO_URI;
|
|
35
|
-
}
|
|
36
|
-
export async function mongoUpsertAlts(items) {
|
|
37
|
-
if (!isMongoEnabled())
|
|
38
|
-
return { ok: false, reason: "MONGO_URI not set" };
|
|
39
|
-
const col = await connect();
|
|
40
|
-
if (!col)
|
|
41
|
-
return { ok: false, reason: "no collection" };
|
|
42
|
-
if (!items.length)
|
|
43
|
-
return { ok: true, upserted: 0 };
|
|
44
|
-
const now = Date.now();
|
|
45
|
-
const ops = items.map((it) => ({
|
|
46
|
-
updateOne: {
|
|
47
|
-
filter: { url_key: it.url_key },
|
|
48
|
-
update: {
|
|
49
|
-
$set: {
|
|
50
|
-
url_key: it.url_key,
|
|
51
|
-
url: it.url,
|
|
52
|
-
alt: it.alt,
|
|
53
|
-
source: it.source || "ai",
|
|
54
|
-
updated_at: now,
|
|
55
|
-
},
|
|
56
|
-
$setOnInsert: { created_at: now },
|
|
57
|
-
},
|
|
58
|
-
upsert: true,
|
|
59
|
-
},
|
|
60
|
-
}));
|
|
61
|
-
const res = await col.bulkWrite(ops, { ordered: false });
|
|
62
|
-
return { ok: true, upserted: res.upsertedCount, modified: res.modifiedCount };
|
|
63
|
-
}
|
|
64
|
-
export async function mongoFindAlts(urlKeys) {
|
|
65
|
-
if (!isMongoEnabled() || !urlKeys.length)
|
|
66
|
-
return new Map();
|
|
67
|
-
const col = await connect();
|
|
68
|
-
if (!col)
|
|
69
|
-
return new Map();
|
|
70
|
-
const cursor = col.find({ url_key: { $in: urlKeys } });
|
|
71
|
-
const map = new Map();
|
|
72
|
-
for await (const doc of cursor) {
|
|
73
|
-
map.set(doc.url_key, doc);
|
|
74
|
-
}
|
|
75
|
-
return map;
|
|
76
|
-
}
|
|
77
|
-
export async function mongoListAlts(limit = 100, offset = 0) {
|
|
78
|
-
if (!isMongoEnabled())
|
|
79
|
-
return { total: 0, entries: [] };
|
|
80
|
-
const col = await connect();
|
|
81
|
-
if (!col)
|
|
82
|
-
return { total: 0, entries: [] };
|
|
83
|
-
const total = await col.countDocuments();
|
|
84
|
-
const entries = await col
|
|
85
|
-
.find({}, { projection: { _id: 0 } })
|
|
86
|
-
.sort({ updated_at: -1 })
|
|
87
|
-
.skip(offset)
|
|
88
|
-
.limit(limit)
|
|
89
|
-
.toArray();
|
|
90
|
-
return { total, entries };
|
|
91
|
-
}
|
|
92
|
-
export async function mongoCloseQuietly() {
|
|
93
|
-
if (_client) {
|
|
94
|
-
try {
|
|
95
|
-
await _client.close();
|
|
96
|
-
}
|
|
97
|
-
catch { /* ignore */ }
|
|
98
|
-
_client = null;
|
|
99
|
-
_collection = null;
|
|
100
|
-
_connecting = null;
|
|
101
|
-
}
|
|
102
|
-
}
|