webcake-storefront-mcp 1.8.0 → 1.10.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/dist/builder/guide.js +25 -1
- package/dist/builder/page.js +10 -0
- package/dist/changelog.json +14 -14
- package/dist/db.js +15 -0
- package/dist/persistence/imageCache.js +42 -0
- package/dist/tools/builder-extras.js +76 -23
- package/dist/tools/builder.js +119 -0
- package/package.json +1 -1
package/dist/builder/guide.js
CHANGED
|
@@ -53,6 +53,28 @@ already in this shape — see the \`responsive\` field on get_page_element/updat
|
|
|
53
53
|
- Colours: prefer the site THEME variables \`var(--color_00)\`, \`var(--color_01)\`, …
|
|
54
54
|
(the published site themes them); plain hex or rgba() also work.
|
|
55
55
|
|
|
56
|
+
## Make it look DESIGNED (not plain) — do this, every page
|
|
57
|
+
A bare stack of default elements looks unfinished. Apply real styling:
|
|
58
|
+
- SECTIONS: new_section already gives ~56px top/bottom padding + a centred content column.
|
|
59
|
+
Add variety: give some sections a background (\`section_opts:{ style:{ background:"var(--color_01)" } }\`
|
|
60
|
+
or a light tint) and alternate so the page has rhythm. Increase padding (72–96px) for hero/CTA.
|
|
61
|
+
- SPACING: children are stacked with a rowGap; bump it (\`section_opts:{ rowGap: 24 }\`) for airy
|
|
62
|
+
layouts. Don't cram.
|
|
63
|
+
- TYPOGRAPHY hierarchy: h1 40–56px / fontWeight 700, h2 28–34px / 600, body 16–18px with
|
|
64
|
+
lineHeight "1.6", muted color for sub-text. Center hero text (textAlign:"center").
|
|
65
|
+
- BUTTONS HAVE NO DEFAULT COLOUR — you MUST style them or they look like plain text:
|
|
66
|
+
\`{ type:"button", opts:{ text:"Mua ngay", style:{ background:"var(--color_02)", color:"#fff",
|
|
67
|
+
borderRadius:"8px", fontWeight:"600", height:48 } } }\`.
|
|
68
|
+
- HERO: prefer a section with a background image + an overlay heading/sub/button on top
|
|
69
|
+
(set section_opts.style.background or a full-width image, then text centered), rather than a
|
|
70
|
+
small image stacked above text.
|
|
71
|
+
- PRODUCT GRID: style the cards — \`grid-product opts.config\`: { columns:3-4, image_ratio:"1/1",
|
|
72
|
+
img_object_fit:"cover", gap_column:20, gap_row:28, cardBorderRadius:12, cardBoxShadow:"0 6px 24px rgba(0,0,0,.08)",
|
|
73
|
+
productNameColor:"var(--color_00)", productPriceColor:"var(--color_02)" }.
|
|
74
|
+
- BRAND COLOURS: reuse var(--color_00) (text), and an accent (var(--color_02)/var(--color_03))
|
|
75
|
+
for buttons, prices, highlights — consistent accent = looks intentional.
|
|
76
|
+
- IMAGES: must be WebCake-CDN urls (search_images cdn_url / upload_images), else they won't show.
|
|
77
|
+
|
|
56
78
|
## Responsive breakpoints
|
|
57
79
|
The four breakpoints (largest → smallest), keyed bp1..bp4, are:
|
|
58
80
|
- \`bp1\` ≥1320px (desktop, the base) · \`bp2\` 993–1319 (laptop) · \`bp3\` 641–992 (tablet) · \`bp4\` 320–640 (mobile).
|
|
@@ -64,7 +86,9 @@ key — only bp1..bp4.)
|
|
|
64
86
|
|
|
65
87
|
## Content & data
|
|
66
88
|
- Text: \`specials.text\` (HTML allowed), \`specials.tag\` ("h1".."p").
|
|
67
|
-
- Image: \`runtime.config.src\` (URL).
|
|
89
|
+
- Image: \`runtime.config.src\` (URL). The URL MUST be a WebCake CDN url — the storefront
|
|
90
|
+
whitelists image domains, so external URLs (Pexels, random sites) won't render. Get CDN
|
|
91
|
+
urls from search_images (uploads by default → use its cdn_url) or upload_images.
|
|
68
92
|
- Form: wrap inputs in a \`form\`; set \`form.specials.type\`
|
|
69
93
|
(form_order | form_login | form_signup | form_discount | order_tracking). Each input
|
|
70
94
|
needs \`specials.field_name\`.
|
package/dist/builder/page.js
CHANGED
|
@@ -63,6 +63,7 @@ export function stackChildren(container, children, opts = {}) {
|
|
|
63
63
|
grid: `${gridCols}x${children.length || 1}`,
|
|
64
64
|
columns: opts.columns || [{ unit: "fr", value: 1 }],
|
|
65
65
|
rows: rows.length ? rows : [{ unit: "min/max", min: { unit: "px", absValue: 50 }, max: { unit: "max-c" } }],
|
|
66
|
+
rowGap: opts.rowGap ?? 16, // breathing room between stacked children
|
|
66
67
|
heightUnit: "auto",
|
|
67
68
|
};
|
|
68
69
|
children.forEach((child, i) => {
|
|
@@ -96,7 +97,16 @@ export function buildSection(childSpecs = [], sectionOpts = {}) {
|
|
|
96
97
|
columns: genGridByBp(BREAKPOINTS.bp1[0]).columns,
|
|
97
98
|
contentColStart: SECTION_CONTENT_COL_START,
|
|
98
99
|
contentColEnd: SECTION_CONTENT_COL_END,
|
|
100
|
+
rowGap: sectionOpts.rowGap,
|
|
99
101
|
});
|
|
102
|
+
// Give sections vertical breathing room by default (a plain edge-to-edge stack looks
|
|
103
|
+
// unfinished). Caller can override via sectionOpts.style.
|
|
104
|
+
section.runtime = section.runtime || {};
|
|
105
|
+
section.runtime.style = {
|
|
106
|
+
paddingTop: 56,
|
|
107
|
+
paddingBottom: 56,
|
|
108
|
+
...(sectionOpts.style || {}),
|
|
109
|
+
};
|
|
100
110
|
return section;
|
|
101
111
|
}
|
|
102
112
|
function buildFromSpec(spec) {
|
package/dist/changelog.json
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "1.10.0",
|
|
4
|
+
"d": "24/06/2026",
|
|
5
|
+
"type": "Added",
|
|
6
|
+
"en": "New scaffold_store_pages tool creates the standard storefront pages (Category, Product, Cart, Checkout, Thank-you) so navigation links resolve…",
|
|
7
|
+
"vi": "Tool mới scaffold_store_pages tạo các trang storefront tiêu chuẩn (Category, Product, Cart, Checkout, Thank-you) để các liên kết điều hướng không bị…"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"v": "1.9.0",
|
|
11
|
+
"d": "24/06/2026",
|
|
12
|
+
"type": "Added",
|
|
13
|
+
"en": "search_images now accepts an upload parameter (default true) that re-hosts each Pexels result on the WebCake CDN and adds a cdn_url field to every…",
|
|
14
|
+
"vi": "search_images nay nhận thêm tham số upload (mặc định true) để tự động tải từng kết quả Pexels lên WebCake CDN và bổ sung trường cdn_url vào mỗi ảnh,…"
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"v": "1.8.0",
|
|
4
18
|
"d": "23/06/2026",
|
|
@@ -26,19 +40,5 @@
|
|
|
26
40
|
"type": "Fixed",
|
|
27
41
|
"en": "build_page now passes the finalized page source to create_page in the initial create call (required by the backend), then sets slug and is_homepage…",
|
|
28
42
|
"vi": "build_page nay truyền source trang đã hoàn thiện vào lời gọi create_page ban đầu (bắt buộc bởi backend), sau đó đặt slug và is_homepage qua một lời…"
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
"v": "1.5.0",
|
|
32
|
-
"d": "23/06/2026",
|
|
33
|
-
"type": "Added",
|
|
34
|
-
"en": "New create_product tool creates a storefront product (simple name + price, or advanced with named attributes and per-SKU variations); accepts hosted…",
|
|
35
|
-
"vi": "Tool mới create_product tạo sản phẩm cho storefront (đơn giản với tên + giá, hoặc nâng cao với attributes và variations theo từng SKU); nhận URL ảnh…"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"v": "1.4.0",
|
|
39
|
-
"d": "23/06/2026",
|
|
40
|
-
"type": "Changed",
|
|
41
|
-
"en": "The install command's interactive wizard now presents numbered choices with ANSI colour output and a completion summary.",
|
|
42
|
-
"vi": "Trình hướng dẫn tương tác của lệnh install nay hiển thị các lựa chọn được đánh số kèm màu ANSI và thông báo tóm tắt sau khi hoàn tất."
|
|
43
43
|
}
|
|
44
44
|
]
|
package/dist/db.js
CHANGED
|
@@ -43,3 +43,18 @@ export function delConfig(key) {
|
|
|
43
43
|
export function getAllConfig() {
|
|
44
44
|
return { ...config };
|
|
45
45
|
}
|
|
46
|
+
// ── Image upload cache (source URL/path → WebCake CDN URL, per site) ──────────
|
|
47
|
+
// Re-hosting the same stock photo / external image twice wastes an upload, so we
|
|
48
|
+
// remember the CDN URL the first time. Keyed by site because CDN URLs are per-site.
|
|
49
|
+
const IMAGE_CACHE_FILE = join(CONFIG_DIR, "image-cache.json");
|
|
50
|
+
const imageCache = readJson(IMAGE_CACHE_FILE, {});
|
|
51
|
+
export function getCachedUpload(siteId, source) {
|
|
52
|
+
const k = `${siteId}::${source}`;
|
|
53
|
+
return k in imageCache ? imageCache[k] : null;
|
|
54
|
+
}
|
|
55
|
+
export function setCachedUpload(siteId, source, cdnUrl) {
|
|
56
|
+
if (!siteId || !source || !cdnUrl)
|
|
57
|
+
return;
|
|
58
|
+
imageCache[`${siteId}::${source}`] = cdnUrl;
|
|
59
|
+
writeJson(IMAGE_CACHE_FILE, imageCache);
|
|
60
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Image-upload cache (source URL/path → WebCake CDN URL), keyed per site.
|
|
2
|
+
//
|
|
3
|
+
// WHERE IT LIVES:
|
|
4
|
+
// - Remote (`serve`) with REDIS_URL set → Redis (shared across all requests/instances,
|
|
5
|
+
// survives container restarts/redeploys; this is the right place for multi-user mode).
|
|
6
|
+
// - stdio / npx / no Redis → a local JSON file (~/.webcake-storefront-mcp/image-cache.json)
|
|
7
|
+
// via db.ts. Single-user, persists on the user's machine.
|
|
8
|
+
// Redis is tried first; on any Redis miss/error we fall back to the file store, so the
|
|
9
|
+
// cache degrades gracefully and never blocks an upload.
|
|
10
|
+
import { getRedis } from "./redis.js";
|
|
11
|
+
import { getCachedUpload as fileGet, setCachedUpload as fileSet } from "../db.js";
|
|
12
|
+
const TTL_MS = 1000 * 60 * 60 * 24 * 30; // 30 days — CDN urls are effectively permanent
|
|
13
|
+
const redisKey = (siteId, source) => `imgc:${siteId}:${source}`;
|
|
14
|
+
export async function getCachedUpload(siteId, source) {
|
|
15
|
+
const redis = getRedis();
|
|
16
|
+
if (redis) {
|
|
17
|
+
try {
|
|
18
|
+
const v = await redis.get(redisKey(siteId, source));
|
|
19
|
+
if (v)
|
|
20
|
+
return v;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
/* fall through to the file store */
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return fileGet(siteId, source);
|
|
27
|
+
}
|
|
28
|
+
export async function setCachedUpload(siteId, source, cdnUrl) {
|
|
29
|
+
if (!siteId || !source || !cdnUrl)
|
|
30
|
+
return;
|
|
31
|
+
const redis = getRedis();
|
|
32
|
+
if (redis) {
|
|
33
|
+
try {
|
|
34
|
+
await redis.set(redisKey(siteId, source), cdnUrl, "PX", TTL_MS);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
/* fall through to the file store */
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
fileSet(siteId, source, cdnUrl);
|
|
42
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { resolvePreviewUrl } from "../config.js";
|
|
3
|
+
import { getCachedUpload, setCachedUpload } from "../persistence/imageCache.js";
|
|
3
4
|
import { parse as parseHtml } from "node-html-parser";
|
|
4
5
|
import { stat, readFile } from "node:fs/promises";
|
|
5
6
|
import { homedir } from "node:os";
|
|
@@ -55,14 +56,49 @@ async function toAllowedImage(buf, contentType) {
|
|
|
55
56
|
const out = await sharp(buf).jpeg({ quality: 85 }).toBuffer();
|
|
56
57
|
return { buf: out, contentType: "image/jpeg" };
|
|
57
58
|
}
|
|
59
|
+
/** Re-host one external image (http(s) URL or data: URI) on the WebCake CDN, with a
|
|
60
|
+
* per-site cache so the same source is never uploaded twice. Returns the hosted URL.
|
|
61
|
+
* The storefront only renders whitelisted (WebCake CDN) image domains, so every image
|
|
62
|
+
* used on a page/product MUST go through this — external URLs (Pexels, etc.) won't show. */
|
|
63
|
+
async function cdnUpload(api, source) {
|
|
64
|
+
const cached = await getCachedUpload(api.siteId, source);
|
|
65
|
+
if (cached)
|
|
66
|
+
return cached;
|
|
67
|
+
let buf, contentType;
|
|
68
|
+
if (source.startsWith("data:")) {
|
|
69
|
+
const m = source.match(/^data:([^;]+);base64,(.*)$/s);
|
|
70
|
+
if (!m)
|
|
71
|
+
throw new Error("Malformed data URI.");
|
|
72
|
+
contentType = m[1];
|
|
73
|
+
buf = Buffer.from(m[2], "base64");
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
({ buf, contentType } = await fetchBuffer(source));
|
|
77
|
+
}
|
|
78
|
+
const norm = await toAllowedImage(buf, contentType);
|
|
79
|
+
const dataUri = `data:${norm.contentType};base64,${norm.buf.toString("base64")}`;
|
|
80
|
+
const res = await api.uploadImageBase64({ base64: dataUri, content_type: norm.contentType });
|
|
81
|
+
const hosted = (res && res.data) || (res && res.url) || null;
|
|
82
|
+
if (!hosted)
|
|
83
|
+
throw new Error("Upload returned no URL.");
|
|
84
|
+
await setCachedUpload(api.siteId, source, hosted);
|
|
85
|
+
return hosted;
|
|
86
|
+
}
|
|
58
87
|
export function registerBuilderExtraTools(server, api, handle, opts = {}) {
|
|
59
88
|
// ── Stock images (Pexels) ──────────────────────────────────────────────────
|
|
60
|
-
server.tool("search_images", `Search stock photos (Pexels)
|
|
89
|
+
server.tool("search_images", `Search stock photos (Pexels) for a page/product. IMPORTANT: the storefront only renders
|
|
90
|
+
images served from the WebCake CDN (image domains are whitelisted) — raw Pexels URLs will
|
|
91
|
+
NOT display. By default this re-hosts each result on the WebCake CDN and returns a ready-to-use
|
|
92
|
+
cdn_url (cached, so repeats are free). Use cdn_url for image src / product images.
|
|
61
93
|
Requires the PEXELS_API_KEY environment variable.`, {
|
|
62
94
|
query: z.string().describe("Subject to search, e.g. 'coffee shop interior'"),
|
|
63
95
|
per_page: z.number().min(1).max(30).default(6).describe("How many results (default 6)"),
|
|
64
96
|
orientation: z.enum(["landscape", "portrait", "square"]).optional().describe("Preferred orientation"),
|
|
65
|
-
|
|
97
|
+
upload: z
|
|
98
|
+
.boolean()
|
|
99
|
+
.default(true)
|
|
100
|
+
.describe("Re-host each result on the WebCake CDN and return cdn_url (default true — required for the image to show). Set false to only browse Pexels URLs."),
|
|
101
|
+
}, ({ query, per_page, orientation, upload }) => handle(async () => {
|
|
66
102
|
const key = process.env.PEXELS_API_KEY;
|
|
67
103
|
if (!key)
|
|
68
104
|
return { error: "PEXELS_API_KEY env var is not set. Add it to use stock image search." };
|
|
@@ -75,7 +111,7 @@ Requires the PEXELS_API_KEY environment variable.`, {
|
|
|
75
111
|
if (!res.ok)
|
|
76
112
|
return { error: `Pexels error ${res.status}` };
|
|
77
113
|
const json = await res.json();
|
|
78
|
-
|
|
114
|
+
let photos = (json.photos || []).map((p) => ({
|
|
79
115
|
url: p.src && (p.src.large || p.src.original),
|
|
80
116
|
thumbnail: p.src && p.src.medium,
|
|
81
117
|
width: p.width,
|
|
@@ -84,10 +120,28 @@ Requires the PEXELS_API_KEY environment variable.`, {
|
|
|
84
120
|
credit: p.photographer,
|
|
85
121
|
source: p.url,
|
|
86
122
|
}));
|
|
87
|
-
|
|
123
|
+
if (upload) {
|
|
124
|
+
photos = await Promise.all(photos.map(async (ph) => {
|
|
125
|
+
try {
|
|
126
|
+
return { ...ph, cdn_url: await cdnUpload(api, ph.url) };
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
return { ...ph, cdn_url: null, upload_error: e?.message ?? String(e) };
|
|
130
|
+
}
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
query,
|
|
135
|
+
total_results: json.total_results,
|
|
136
|
+
uploaded_to_cdn: upload,
|
|
137
|
+
note: upload
|
|
138
|
+
? "Use each photo's cdn_url (WebCake-hosted) for image src / product images — NOT url (Pexels, not whitelisted)."
|
|
139
|
+
: "These are Pexels URLs and will NOT render on the storefront. Run upload_images (or upload:true) to re-host them on the CDN first.",
|
|
140
|
+
photos,
|
|
141
|
+
};
|
|
88
142
|
}));
|
|
89
143
|
// ── 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
|
|
144
|
+
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 CDN URLs go straight into an image element's specials.src / runtime.config.src, or a product/category image. This is REQUIRED for any external image (incl. Pexels search results) because the storefront only renders whitelisted WebCake-CDN image domains. Results are cached per site, so re-uploading the same source is free.
|
|
91
145
|
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
146
|
urls: z
|
|
93
147
|
.array(z.string())
|
|
@@ -124,29 +178,28 @@ Processes up to 20 entries per call in parallel; non jpeg/png/webp inputs are co
|
|
|
124
178
|
const errors = [];
|
|
125
179
|
await Promise.all(deduped.map(async (entry) => {
|
|
126
180
|
try {
|
|
127
|
-
|
|
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)) {
|
|
181
|
+
if (isLocalPath(entry)) {
|
|
136
182
|
if (!localAllowed)
|
|
137
183
|
throw new Error("Local file paths are only supported when the server runs locally (stdio). Send a public URL or data: URI instead.");
|
|
138
|
-
|
|
184
|
+
const cached = await getCachedUpload(api.siteId, entry);
|
|
185
|
+
if (cached) {
|
|
186
|
+
images[entry] = cached;
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const { buf, contentType } = await readLocalImage(entry);
|
|
190
|
+
const norm = await toAllowedImage(buf, contentType);
|
|
191
|
+
const dataUri = `data:${norm.contentType};base64,${norm.buf.toString("base64")}`;
|
|
192
|
+
const res = await api.uploadImageBase64({ base64: dataUri, content_type: norm.contentType });
|
|
193
|
+
const hosted = (res && res.data) || (res && res.url) || null;
|
|
194
|
+
if (!hosted)
|
|
195
|
+
throw new Error("Upload returned no URL.");
|
|
196
|
+
await setCachedUpload(api.siteId, entry, hosted);
|
|
197
|
+
images[entry] = hosted;
|
|
139
198
|
}
|
|
140
199
|
else {
|
|
141
|
-
(
|
|
200
|
+
// http(s) URL or data: URI — cdnUpload handles fetch/convert/upload + cache.
|
|
201
|
+
images[entry] = await cdnUpload(api, entry);
|
|
142
202
|
}
|
|
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
203
|
}
|
|
151
204
|
catch (e) {
|
|
152
205
|
errors.push({ url: entry, error: e?.message ?? String(e) });
|
package/dist/tools/builder.js
CHANGED
|
@@ -165,4 +165,123 @@ Two-step safety: dry_run=true (default) previews; dry_run=false saves.`, {
|
|
|
165
165
|
page_source_id: saved && saved.data && saved.data.id,
|
|
166
166
|
};
|
|
167
167
|
}));
|
|
168
|
+
server.tool("scaffold_store_pages", `Create the standard STOREFRONT pages a shop needs so navigation works — clicking a
|
|
169
|
+
product/category/cart from any page lands on a real page instead of a 404.
|
|
170
|
+
Creates only the ones MISSING (matched by slug): Category (collections), Product (products),
|
|
171
|
+
Cart (cart), Checkout (checkout), Thank-you (complete) — all type 'store' (enables use_store),
|
|
172
|
+
plus optional member (login/register/profile) and blog (blog/post) pages.
|
|
173
|
+
Run this right after you create products/categories. dry_run=true (default) previews.`, {
|
|
174
|
+
include_member: z.boolean().default(false).describe("Also create login/register/profile (type member, use_member)"),
|
|
175
|
+
include_blog: z.boolean().default(false).describe("Also create blog list + post pages (type blog, use_blog)"),
|
|
176
|
+
dry_run: z.boolean().default(true).describe("Preview (true) or actually create the missing pages (false)"),
|
|
177
|
+
}, ({ include_member, include_blog, dry_run }) => handle(async () => {
|
|
178
|
+
const bind = (name, field) => ({
|
|
179
|
+
id: "BINDING" + Math.random().toString(36).slice(2, 8),
|
|
180
|
+
name,
|
|
181
|
+
target: `${name}::${field}`,
|
|
182
|
+
});
|
|
183
|
+
const h1 = (text) => ({ type: "text", opts: { text, specials: { tag: "h1" }, style: { fontSize: "32px", fontWeight: "700" } } });
|
|
184
|
+
const accentBtn = (text, type = "button") => ({ type, opts: { text, style: { background: "var(--color_02)", color: "#fff", borderRadius: "8px", height: 48, fontWeight: "600" } } });
|
|
185
|
+
// Each spec: { name, slug, kind, build() -> { sections } }
|
|
186
|
+
const SPECS = [
|
|
187
|
+
{ name: "Category Page", slug: "collections", kind: "store", flag: "use_store", build: () => ({ sections: [
|
|
188
|
+
buildSection([h1("Danh mục sản phẩm"), { type: "grid-product", opts: { config: { columns: 3, image_ratio: "1/1", gap_column: 24, gap_row: 32 } } }]),
|
|
189
|
+
] }) },
|
|
190
|
+
{ name: "Product Page", slug: "products", kind: "store", flag: "use_store", build: () => ({ sections: [
|
|
191
|
+
buildSection([
|
|
192
|
+
{ type: "product-gallery", opts: {} },
|
|
193
|
+
{ type: "text-dataset", opts: { bindings: [bind("product", "product_name")], style: { fontSize: "28px", fontWeight: "700" } } },
|
|
194
|
+
{ type: "text-dataset", opts: { bindings: [bind("product", "product_price")], style: { fontSize: "22px", fontWeight: "700", color: "var(--color_02)" } } },
|
|
195
|
+
{ type: "quantity-input", opts: {} },
|
|
196
|
+
accentBtn("Thêm vào giỏ"),
|
|
197
|
+
]),
|
|
198
|
+
] }) },
|
|
199
|
+
{ name: "Cart Page", slug: "cart", kind: "store", flag: "use_store", build: () => ({ sections: [
|
|
200
|
+
buildSection([h1("Giỏ hàng"), { type: "cart-items", opts: {} }, accentBtn("Tiến hành thanh toán")]),
|
|
201
|
+
] }) },
|
|
202
|
+
{ name: "Checkout Page", slug: "checkout", kind: "store", flag: "use_store", build: () => ({ sections: [
|
|
203
|
+
buildSection([
|
|
204
|
+
h1("Thanh toán"),
|
|
205
|
+
{ type: "form", opts: { specials: { type: "form_order" } }, children: [
|
|
206
|
+
{ type: "input", opts: { specials: { field_name: "full_name", label: "Họ tên", placeholder: "Họ tên", required: true, show_label: true } } },
|
|
207
|
+
{ type: "phone-number", opts: { specials: { field_name: "phone_number", label: "Số điện thoại", required: true, show_label: true } } },
|
|
208
|
+
{ type: "address", opts: { specials: { field_name: "address", label: "Địa chỉ", show_label: true } } },
|
|
209
|
+
accentBtn("Đặt hàng", "submit-button"),
|
|
210
|
+
] },
|
|
211
|
+
]),
|
|
212
|
+
] }) },
|
|
213
|
+
{ name: "Thank You Page", slug: "complete", kind: "store", flag: "use_store", build: () => ({ sections: [
|
|
214
|
+
buildSection([h1("Cảm ơn bạn đã đặt hàng!"), { type: "order-items", opts: {} }]),
|
|
215
|
+
] }) },
|
|
216
|
+
];
|
|
217
|
+
if (include_member) {
|
|
218
|
+
SPECS.push({ name: "Login Page", slug: "login", kind: "member", flag: "use_member", build: () => ({ sections: [
|
|
219
|
+
buildSection([h1("Đăng nhập"), { type: "form", opts: { specials: { type: "form_login" } }, children: [
|
|
220
|
+
{ type: "identity", opts: { specials: { field_name: "identity", label: "Email / SĐT", required: true, show_label: true } } },
|
|
221
|
+
{ type: "password", opts: { specials: { field_name: "password", label: "Mật khẩu", required: true, show_label: true } } },
|
|
222
|
+
accentBtn("Đăng nhập", "submit-button"),
|
|
223
|
+
] }]),
|
|
224
|
+
] }) }, { name: "Register Page", slug: "register", kind: "member", flag: "use_member", build: () => ({ sections: [
|
|
225
|
+
buildSection([h1("Đăng ký"), { type: "form", opts: { specials: { type: "form_signup" } }, children: [
|
|
226
|
+
{ type: "input", opts: { specials: { field_name: "full_name", label: "Họ tên", required: true, show_label: true } } },
|
|
227
|
+
{ type: "email", opts: { specials: { field_name: "email", label: "Email", required: true, show_label: true } } },
|
|
228
|
+
{ type: "password", opts: { specials: { field_name: "password", label: "Mật khẩu", required: true, show_label: true } } },
|
|
229
|
+
accentBtn("Tạo tài khoản", "submit-button"),
|
|
230
|
+
] }]),
|
|
231
|
+
] }) }, { name: "Profile Page", slug: "profile", kind: "member", flag: "use_member", build: () => ({ sections: [
|
|
232
|
+
buildSection([h1("Tài khoản"), { type: "order-history", opts: {} }, { type: "customer-address", opts: {} }]),
|
|
233
|
+
] }) });
|
|
234
|
+
}
|
|
235
|
+
if (include_blog) {
|
|
236
|
+
SPECS.push({ name: "Blog", slug: "blog", kind: "blog", flag: "use_blog", build: () => ({ sections: [buildSection([h1("Bài viết"), { type: "post-list", opts: {} }])] }) }, { name: "Post", slug: "post", kind: "blog", flag: "use_blog", build: () => ({ sections: [buildSection([{ type: "post-overlay", opts: {} }])] }) });
|
|
237
|
+
}
|
|
238
|
+
// What already exists?
|
|
239
|
+
const pagesRes = await api.listPages();
|
|
240
|
+
const pages = (pagesRes && pagesRes.data) || pagesRes || [];
|
|
241
|
+
const existingSlugs = new Set((Array.isArray(pages) ? pages : []).map((p) => (p.slug || "").replace(/^\//, "")));
|
|
242
|
+
const missing = SPECS.filter((s) => !existingSlugs.has(s.slug));
|
|
243
|
+
const skipped = SPECS.filter((s) => existingSlugs.has(s.slug)).map((s) => s.slug);
|
|
244
|
+
if (dry_run) {
|
|
245
|
+
return {
|
|
246
|
+
dry_run: true,
|
|
247
|
+
will_create: missing.map((s) => ({ name: s.name, slug: s.slug, type: s.kind })),
|
|
248
|
+
already_exist: skipped,
|
|
249
|
+
hint: "Call again with dry_run=false to create the missing pages so product/category/cart links resolve.",
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
const created = [];
|
|
253
|
+
const errors = [];
|
|
254
|
+
const flagsEnabled = new Set();
|
|
255
|
+
for (const spec of missing) {
|
|
256
|
+
try {
|
|
257
|
+
if (!flagsEnabled.has(spec.flag)) {
|
|
258
|
+
await api.enableSiteFeature(spec.flag).catch(() => { });
|
|
259
|
+
flagsEnabled.add(spec.flag);
|
|
260
|
+
}
|
|
261
|
+
const source = spec.build();
|
|
262
|
+
const validation = validatePage(source);
|
|
263
|
+
if (!validation.valid) {
|
|
264
|
+
errors.push({ slug: spec.slug, validation });
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
finalizeForRender(source);
|
|
268
|
+
const res = await api.createPage({ name: spec.name, source, type: PAGE_TYPE_NUM[spec.kind] });
|
|
269
|
+
const pid = newPageId(res);
|
|
270
|
+
if (pid)
|
|
271
|
+
await api.updatePage(pid, { slug: spec.slug }).catch(() => { });
|
|
272
|
+
created.push({ name: spec.name, slug: spec.slug, type: spec.kind, page_id: pid });
|
|
273
|
+
}
|
|
274
|
+
catch (e) {
|
|
275
|
+
errors.push({ slug: spec.slug, error: e?.message ?? String(e) });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
success: true,
|
|
280
|
+
created,
|
|
281
|
+
already_exist: skipped,
|
|
282
|
+
...(errors.length ? { errors } : {}),
|
|
283
|
+
data_sources_enabled: [...flagsEnabled],
|
|
284
|
+
note: "Publish the site (publish_site) to take the new pages live.",
|
|
285
|
+
};
|
|
286
|
+
}));
|
|
168
287
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webcake-storefront-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.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",
|