webcake-storefront-mcp 1.31.11 → 1.31.13
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/api.js +19 -0
- package/dist/changelog.json +14 -14
- package/dist/tools/appointment.js +63 -16
- package/dist/tools/articles.js +89 -2
- package/dist/tools/builder.js +39 -10
- package/dist/tools/catalog-write.js +79 -9
- package/dist/tools/multilingual.js +35 -0
- package/dist/tools/page-draft.js +3 -12
- package/dist/tools/pages.js +38 -27
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -485,6 +485,18 @@ export class WebcakeCmsApi {
|
|
|
485
485
|
timeout: 60000,
|
|
486
486
|
});
|
|
487
487
|
}
|
|
488
|
+
/** All blog categories (dashboard; includes meta_tags). */
|
|
489
|
+
listBlogCategories() {
|
|
490
|
+
return this.request("GET", `/api/v1/dashboard/site/${this.siteId}/blog/categories/all`);
|
|
491
|
+
}
|
|
492
|
+
/** Update blog categories — same command pipeline as create (name_category / description_category /
|
|
493
|
+
* image_category / set_category_custom_slug / set_category_seo …), each with data.id. */
|
|
494
|
+
updateBlogCategory(commands) {
|
|
495
|
+
return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/blog/categories/update`, {
|
|
496
|
+
body: { site_id: this.siteId, commands },
|
|
497
|
+
timeout: 60000,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
488
500
|
/** Create a blog article via the dashboard command pipeline (supports category linkage,
|
|
489
501
|
* images, summary, content). Caller generates the article id in each command's data.id. */
|
|
490
502
|
createBlogArticle(commands) {
|
|
@@ -525,6 +537,13 @@ export class WebcakeCmsApi {
|
|
|
525
537
|
timeout: 60000,
|
|
526
538
|
});
|
|
527
539
|
}
|
|
540
|
+
/** Product "Cài đặt SEO" (spec set_product_seo). The backend ALWAYS writes custom_slug into
|
|
541
|
+
* slug, so pass the product's current slug or it is wiped. */
|
|
542
|
+
setProductSeo(id, meta_tags, custom_slug) {
|
|
543
|
+
return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/products/update_product`, {
|
|
544
|
+
body: { site_id: this.siteId, id, meta_tags, custom_slug, spec: "set_product_seo" },
|
|
545
|
+
});
|
|
546
|
+
}
|
|
528
547
|
/** Delete products by id. Body: { site_id, ids }. */
|
|
529
548
|
removeProducts(ids) {
|
|
530
549
|
return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/products/remove`, {
|
package/dist/changelog.json
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "1.31.13",
|
|
4
|
+
"d": "09/10/2026",
|
|
5
|
+
"type": "Added",
|
|
6
|
+
"en": "create_product, update_product, create_product_category, update_product_category, and create_blog_category accept a new seo object to write…",
|
|
7
|
+
"vi": "create_product, update_product, create_product_category, update_product_category và create_blog_category nay nhận thêm đối tượng seo để ghi…"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"v": "1.31.12",
|
|
11
|
+
"d": "09/10/2026",
|
|
12
|
+
"type": "Added",
|
|
13
|
+
"en": "create_article and update_article accept a new seo object (page_title, meta_description, page_keywords, og_title, og_description, og_image) that…",
|
|
14
|
+
"vi": "create_article và update_article nay nhận thêm đối tượng seo (page_title, meta_description, page_keywords, og_title, og_description, og_image) để…"
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"v": "1.31.11",
|
|
4
18
|
"d": "11/09/2026",
|
|
@@ -26,19 +40,5 @@
|
|
|
26
40
|
"type": "Fixed",
|
|
27
41
|
"en": "Elements placed by new_section, new_row, and build_page that fill their grid cell (text, image, container, columns, repeaters) now receive…",
|
|
28
42
|
"vi": "Các phần tử được đặt bởi new_section, new_row và build_page mà lấp đầy ô lưới (text, image, container, columns, repeaters) nay nhận constraintX:…"
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
"v": "1.31.7",
|
|
32
|
-
"d": "26/06/2026",
|
|
33
|
-
"type": "Fixed",
|
|
34
|
-
"en": "build_page, create_page, update_page, and commit_page_draft now strip a leading / from slug before saving; a slug like /cart would previously 404 on…",
|
|
35
|
-
"vi": "build_page, create_page, update_page và commit_page_draft nay tự động loại bỏ dấu / đầu dòng khỏi slug trước khi lưu; các slug kiểu /cart trước đây…"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"v": "1.31.6",
|
|
39
|
-
"d": "26/06/2026",
|
|
40
|
-
"type": "Removed",
|
|
41
|
-
"en": "scaffold_store_pages, scaffold_global_sections, and scaffold_popup tools are removed; pages are now composed free-form from elements using…",
|
|
42
|
-
"vi": "Các tool scaffold_store_pages, scaffold_global_sections và scaffold_popup đã bị xóa; các trang nay được tổ hợp tự do từ các phần tử bằng…"
|
|
43
43
|
}
|
|
44
44
|
]
|
|
@@ -1,7 +1,37 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
// Appointment / calendar booking app (Enum.Application appointment = 6).
|
|
3
4
|
// Install first via install_app({ app: "appointment" }). Endpoints under /appointment/*.
|
|
4
5
|
// A booking calendar references a classify (service type), an employee (assignee) and an address.
|
|
6
|
+
// Working-hours shapes, as the dashboard's AppointmentConfigWeekdays / ModalConfigDay write them.
|
|
7
|
+
const WEEKDAYS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];
|
|
8
|
+
const hhmm = z.string().regex(/^\d{2}:\d{2}$/, "HH:mm");
|
|
9
|
+
const timeRanges = z
|
|
10
|
+
.array(z.object({ start_time: hhmm, end_time: hhmm }))
|
|
11
|
+
.max(3)
|
|
12
|
+
.describe('Up to 3 working ranges, e.g. [{ "start_time": "08:00", "end_time": "12:00" }]');
|
|
13
|
+
const weekdaysSchema = z
|
|
14
|
+
.array(z.object({ key: z.enum(WEEKDAYS), is_active: z.boolean().describe("false = day off"), configs: timeRanges }))
|
|
15
|
+
.max(7)
|
|
16
|
+
.describe("Fixed weekly hours (\"Thiết lập thời gian làm việc cố định\"), one entry per weekday; a weekday left out is a day off.");
|
|
17
|
+
const daysSchema = z
|
|
18
|
+
.array(z.object({ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("YYYY-MM-DD"), configs: timeRanges.describe("Ranges for that date; [] = day off") }))
|
|
19
|
+
.describe("Per-date overrides (\"Điều chỉnh thời gian làm việc cụ thể\") for leave, overtime, holidays — replace the weekly hours on that date.");
|
|
20
|
+
/** Validate + order the weekly hours monday → sunday (the booking check indexes by position), and give per-date overrides the ids the dashboard expects. */
|
|
21
|
+
export function normalizeHours(f) {
|
|
22
|
+
const out = { ...f };
|
|
23
|
+
if (f.config_weekdays) {
|
|
24
|
+
const week = weekdaysSchema.parse(f.config_weekdays);
|
|
25
|
+
out.config_weekdays = WEEKDAYS.map((k) => week.find((d) => d.key === k) ?? { key: k, is_active: false, configs: [] });
|
|
26
|
+
}
|
|
27
|
+
if (f.config_days)
|
|
28
|
+
out.config_days = daysSchema.parse(f.config_days).map((d) => ({
|
|
29
|
+
id: randomUUID(),
|
|
30
|
+
...d,
|
|
31
|
+
configs: d.configs.map((c) => ({ id: randomUUID(), ...c })),
|
|
32
|
+
}));
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
5
35
|
export function registerAppointmentTools(server, api, handle) {
|
|
6
36
|
const listShape = {
|
|
7
37
|
page: z.number().optional().describe("Page number (default 1)"),
|
|
@@ -11,25 +41,42 @@ export function registerAppointmentTools(server, api, handle) {
|
|
|
11
41
|
const unwrap = (res, key) => res?.data?.[key] ?? res?.[key] ?? res?.data ?? res;
|
|
12
42
|
// ── Calendars ──
|
|
13
43
|
server.tool("list_appointment_calendars", "List booking calendars (services with availability rules) for the current site.", listShape, (query) => handle(async () => unwrap(await api.listAppointmentCalendars(query), "appointment_calendars")));
|
|
14
|
-
server.tool("create_appointment_calendar", `Create a booking calendar.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
44
|
+
server.tool("create_appointment_calendar", `Create a booking calendar = ONE employee's (technician's) working schedule. A calendar is per
|
|
45
|
+
employee, not per service: one employee has at most one calendar per site, tied to one address.
|
|
46
|
+
To set up staff schedules: list/create_appointment_employee → list/create_appointment_address →
|
|
47
|
+
list/create_appointment_classify (services) → one calendar per employee with its own hours.
|
|
48
|
+
Same hours for many staff? Create one, then duplicate_appointment_calendars and change assignee_id.`, {
|
|
49
|
+
name: z.string().describe("Calendar title, e.g. the technician's name"),
|
|
50
|
+
assignee_id: z.string().describe("Employee id (list_appointment_employees) — one calendar per employee"),
|
|
51
|
+
appointment_address_id: z.string().describe("Address/location id (list_appointment_addresses)"),
|
|
52
|
+
appointment_classifies: z.array(z.object({ id: z.string() })).optional().describe('Services this employee takes, e.g. [{ "id": "..." }]'),
|
|
53
|
+
range_appointment: z.number().int().min(1).max(60).optional().describe("How many days ahead customers can book, 1–60 (default 7)"),
|
|
54
|
+
duration_appointment: z.number().int().optional().describe("Appointment length in minutes; also the slot step (default 30)"),
|
|
55
|
+
max_appointment_per_day_of_assignee: z.number().int().optional().describe("Max bookings per day for this employee"),
|
|
56
|
+
max_appointment_per_day_of_customer: z.number().int().optional().describe("Max bookings per day for one customer"),
|
|
57
|
+
config_weekdays: weekdaysSchema.optional().describe(`${weekdaysSchema.description} Omitted = every day 08:00–23:00, the dashboard default.`),
|
|
58
|
+
config_days: daysSchema.optional(),
|
|
23
59
|
timezone: z.number().optional().describe("Timezone offset (hours, default 0)"),
|
|
24
|
-
assignee_id: z.string().optional().describe("Employee (assignee) id"),
|
|
25
|
-
appointment_address_id: z.string().optional().describe("Address id (location)"),
|
|
26
|
-
appointment_classifies: z.array(z.record(z.any())).optional().describe('Linked classifies, e.g. [{ "id": "..." }]'),
|
|
27
60
|
google_calendar_id: z.string().optional().describe("Linked Google Calendar id"),
|
|
28
|
-
}, (fields) => handle(async () =>
|
|
29
|
-
|
|
61
|
+
}, (fields) => handle(async () => {
|
|
62
|
+
const f = normalizeHours(fields);
|
|
63
|
+
f.config_weekdays ??= WEEKDAYS.map((key) => ({
|
|
64
|
+
key,
|
|
65
|
+
is_active: true,
|
|
66
|
+
configs: [
|
|
67
|
+
{ start_time: "08:00", end_time: "11:00" },
|
|
68
|
+
{ start_time: "11:00", end_time: "18:00" },
|
|
69
|
+
{ start_time: "18:00", end_time: "23:00" },
|
|
70
|
+
],
|
|
71
|
+
}));
|
|
72
|
+
return unwrap(await api.createAppointmentCalendar(f), "appointment_calendar");
|
|
73
|
+
}));
|
|
74
|
+
server.tool("update_appointment_calendar", `Update a booking calendar (an employee's schedule). Pass \`id\` plus any fields to change, same shape
|
|
75
|
+
as create_appointment_calendar. config_weekdays / config_days REPLACE the whole list — to add one
|
|
76
|
+
day-off, read the calendar (list_appointment_calendars), append, and send the full config_days.`, {
|
|
30
77
|
id: z.string().describe("Calendar id"),
|
|
31
|
-
fields: z.record(z.any()).describe("Fields to update (name, config_weekdays, assignee_id, appointment_classifies, …)"),
|
|
32
|
-
}, ({ id, fields }) => handle(async () => unwrap(await api.updateAppointmentCalendar({ id, ...fields }), "appointment_calendar")));
|
|
78
|
+
fields: z.record(z.any()).describe("Fields to update (name, config_weekdays, config_days, assignee_id, appointment_classifies, …)"),
|
|
79
|
+
}, ({ id, fields }) => handle(async () => unwrap(await api.updateAppointmentCalendar({ id, ...normalizeHours(fields) }), "appointment_calendar")));
|
|
33
80
|
server.tool("delete_appointment_calendars", "Delete booking calendars by id.", { ids: z.array(z.string()).min(1).describe("Calendar ids") }, ({ ids }) => handle(() => api.deleteAppointmentCalendars(ids)));
|
|
34
81
|
server.tool("duplicate_appointment_calendars", "Duplicate booking calendars by id (creates copies).", { ids: z.array(z.string()).min(1).describe("Calendar ids to duplicate") }, ({ ids }) => handle(() => api.duplicateAppointmentCalendars(ids)));
|
|
35
82
|
// ── Appointments (bookings) ──
|
package/dist/tools/articles.js
CHANGED
|
@@ -36,6 +36,84 @@ function summarizeArticle(a) {
|
|
|
36
36
|
updated_at: a.updated_at,
|
|
37
37
|
};
|
|
38
38
|
}
|
|
39
|
+
/** "Cài đặt SEO" fields → the meta_tags entry each one is stored as (same shapes the dashboard's
|
|
40
|
+
* SEOBasic / SEOSocialShare / SEOAdvanced panels write; shared by articles and products). */
|
|
41
|
+
const meta = (property) => ({
|
|
42
|
+
match: (t) => t?.type !== "link" && t?.props?.property === property,
|
|
43
|
+
build: (content) => ({ type: "meta", props: { property, content } }),
|
|
44
|
+
});
|
|
45
|
+
const SEO_TAGS = {
|
|
46
|
+
page_title: meta("page_title"),
|
|
47
|
+
meta_description: meta("meta_description"),
|
|
48
|
+
page_keywords: meta("page_keywords"),
|
|
49
|
+
og_title: meta("og:title"),
|
|
50
|
+
og_description: meta("og:description"),
|
|
51
|
+
og_image: meta("og:image"),
|
|
52
|
+
og_site_name: meta("og:site_name"),
|
|
53
|
+
og_type: meta("og:type"),
|
|
54
|
+
og_url: meta("og:url"),
|
|
55
|
+
canonical: {
|
|
56
|
+
match: (t) => t?.props?.rel === "canonical",
|
|
57
|
+
build: (href) => ({ type: "link", props: { rel: "canonical", href } }),
|
|
58
|
+
},
|
|
59
|
+
robots: {
|
|
60
|
+
match: (t) => t?.props?.name === "robots",
|
|
61
|
+
build: (content) => ({ type: "meta", props: { name: "robots", content } }),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
export const seoSchema = z
|
|
65
|
+
.object({
|
|
66
|
+
page_title: z.string().optional().describe("Title tag (~60 chars)"),
|
|
67
|
+
meta_description: z.string().optional().describe("Meta description (~155 chars)"),
|
|
68
|
+
page_keywords: z.string().optional().describe("Comma-separated keywords"),
|
|
69
|
+
og_title: z.string().optional().describe("Social share title (og:title)"),
|
|
70
|
+
og_description: z.string().optional().describe("Social share description (og:description)"),
|
|
71
|
+
og_image: z.string().optional().describe("Social share image URL (og:image), must be hosted"),
|
|
72
|
+
// ── Advanced ("SEO nâng cao") ──
|
|
73
|
+
canonical: z.string().optional().describe("Canonical URL (<link rel=canonical>)"),
|
|
74
|
+
og_site_name: z.string().optional().describe("og:site_name"),
|
|
75
|
+
og_type: z.string().optional().describe("og:type, e.g. article / product"),
|
|
76
|
+
og_url: z.string().optional().describe("og:url"),
|
|
77
|
+
robots: z.string().optional().describe('Robots meta, comma+space separated, e.g. "noindex, nofollow" or "max-snippet:-1, max-image-preview:large"'),
|
|
78
|
+
structured_data: z
|
|
79
|
+
.array(z.object({ name: z.string().describe("Display name"), json: z.string().describe("JSON-LD object as a string, without <script>") }))
|
|
80
|
+
.optional()
|
|
81
|
+
.describe("Structured data markup (JSON-LD). REPLACES all existing markups; [] removes them."),
|
|
82
|
+
custom_tags: z
|
|
83
|
+
.array(z.object({ type: z.enum(["meta", "link"]).default("meta"), props: z.record(z.string()) }))
|
|
84
|
+
.optional()
|
|
85
|
+
.describe('Extra tags ("Thẻ bổ sung"), e.g. { "type": "meta", "props": { "name": "author", "content": "Togi" } }. Replaces a tag with the same name/property/rel.'),
|
|
86
|
+
remove_tags: z.array(z.string()).optional().describe('Remove tags by name/property/rel, e.g. ["author", "og:url"]'),
|
|
87
|
+
})
|
|
88
|
+
.optional()
|
|
89
|
+
.describe("SEO settings (\"Cài đặt SEO\": basic, social share, advanced). Without a meta description the site falls back to the whole body text.");
|
|
90
|
+
/** The SEO save replaces the whole meta_tags array, so merge into the existing tags: passed
|
|
91
|
+
* fields overwrite their tag, "" removes it, structured_data replaces all JSON-LD scripts,
|
|
92
|
+
* everything else (custom tags) is kept. */
|
|
93
|
+
const tagKey = (t) => t?.props?.name || t?.props?.property || t?.props?.rel || t?.props?.["http-equiv"];
|
|
94
|
+
export function mergeSeoTags(seo, existing = []) {
|
|
95
|
+
let tags = [...existing];
|
|
96
|
+
if (seo.remove_tags)
|
|
97
|
+
tags = tags.filter((t) => !seo.remove_tags.includes(tagKey(t)));
|
|
98
|
+
for (const [key, def] of Object.entries(SEO_TAGS)) {
|
|
99
|
+
const value = seo[key];
|
|
100
|
+
if (value === undefined)
|
|
101
|
+
continue;
|
|
102
|
+
tags = tags.filter((t) => !def.match(t));
|
|
103
|
+
if (value !== "")
|
|
104
|
+
tags.push(def.build(value));
|
|
105
|
+
}
|
|
106
|
+
if (seo.structured_data) {
|
|
107
|
+
tags = tags.filter((t) => t?.type !== "script");
|
|
108
|
+
for (const m of seo.structured_data)
|
|
109
|
+
tags.push({ type: "script", props: { type: "application/ld+json" }, meta: { displayName: m.name }, children: m.json });
|
|
110
|
+
}
|
|
111
|
+
for (const t of seo.custom_tags || []) {
|
|
112
|
+
tags = tags.filter((x) => tagKey(x) !== tagKey(t));
|
|
113
|
+
tags.push({ type: t.type || "meta", props: t.props });
|
|
114
|
+
}
|
|
115
|
+
return tags;
|
|
116
|
+
}
|
|
39
117
|
export function registerArticleTools(server, api, handle) {
|
|
40
118
|
server.tool("list_articles", `List blog articles (metadata only, without HTML content). Use get_article for the full content.
|
|
41
119
|
Pass term to search by title/slug. Pass category_id to list the posts filed under one blog category —
|
|
@@ -72,7 +150,8 @@ must be hosted (search_images / upload_images). The backend generates the id and
|
|
|
72
150
|
summary: z.string().optional().describe("Short summary / excerpt"),
|
|
73
151
|
images: z.array(z.string()).optional().describe("Hosted image URLs; the first is the cover image"),
|
|
74
152
|
category_ids: z.array(z.string()).optional().describe("Blog category IDs to file the post under (from create_blog_category)"),
|
|
75
|
-
|
|
153
|
+
seo: seoSchema,
|
|
154
|
+
}, ({ name, content, summary, images, category_ids, seo }) => handle(async () => {
|
|
76
155
|
const id = randomUUID();
|
|
77
156
|
const commands = [{ name: "create_article", data: { id, name } }];
|
|
78
157
|
if (summary)
|
|
@@ -83,6 +162,8 @@ must be hosted (search_images / upload_images). The backend generates the id and
|
|
|
83
162
|
commands.push({ name: "content_article", data: { id, content } });
|
|
84
163
|
if (category_ids && category_ids.length)
|
|
85
164
|
commands.push({ name: "bulk_add_category_to_article", data: { id, ids: category_ids } });
|
|
165
|
+
if (seo)
|
|
166
|
+
commands.push({ name: "set_article_seo", data: { id, meta_tags: mergeSeoTags(seo) } });
|
|
86
167
|
await api.createBlogArticle(commands);
|
|
87
168
|
return {
|
|
88
169
|
success: true,
|
|
@@ -106,7 +187,8 @@ the SAME call if you want a custom one (it is applied after the rename).`, {
|
|
|
106
187
|
tags: z.array(z.string()).optional().describe("Article TAG IDs (uuids from the blog tag list) — not free text"),
|
|
107
188
|
is_hidden: z.boolean().optional().describe("Hide from public"),
|
|
108
189
|
published_at: z.string().optional().describe("Publish date, ISO/naive datetime (render_inserted_at)"),
|
|
109
|
-
|
|
190
|
+
seo: seoSchema.describe("SEO settings to change; other existing SEO tags are kept. Pass \"\" to clear a field."),
|
|
191
|
+
}, ({ id, name, slug, content, summary, images, category_ids, remove_category_ids, tags, is_hidden, published_at, seo }) => handle(async () => {
|
|
110
192
|
const commands = [];
|
|
111
193
|
// Order matters: name_article rewrites the slug, so the custom slug must come after it.
|
|
112
194
|
if (name != null)
|
|
@@ -129,6 +211,11 @@ the SAME call if you want a custom one (it is applied after the rename).`, {
|
|
|
129
211
|
commands.push({ name: "bulk_add_category_to_article", data: { id, ids: category_ids } });
|
|
130
212
|
if (remove_category_ids && remove_category_ids.length)
|
|
131
213
|
commands.push({ name: "bulk_remove_category_to_article", data: { id, ids: remove_category_ids } });
|
|
214
|
+
if (seo) {
|
|
215
|
+
const current = await api.getArticle(id);
|
|
216
|
+
const existing = (current?.data || current)?.meta_tags || [];
|
|
217
|
+
commands.push({ name: "set_article_seo", data: { id, meta_tags: mergeSeoTags(seo, existing) } });
|
|
218
|
+
}
|
|
132
219
|
if (!commands.length)
|
|
133
220
|
throw new Error("Nothing to update — pass at least one field besides id.");
|
|
134
221
|
await api.updateBlogArticle(commands);
|
package/dist/tools/builder.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
import { BUILD_GUIDE } from "../builder/guide.js";
|
|
3
4
|
import { PAGE_SCHEMA } from "../builder/page-schema.js";
|
|
@@ -112,8 +113,44 @@ export function buildPageSeo(seo = {}) {
|
|
|
112
113
|
out.og_description = seo.og_description || seo.description;
|
|
113
114
|
if (seo.thumbnail)
|
|
114
115
|
out.og_image = seo.thumbnail;
|
|
116
|
+
// Advanced ("SEO nâng cao") — same keys the editor's SeoDrawer writes; passed through as-is.
|
|
117
|
+
for (const k of ["thumbnail_alt", "author", "canonical", "og_site_name", "og_type", "og_url", "robots", "site_markup", "site_name_markup", "hidden_meta_tags"])
|
|
118
|
+
if (seo[k] !== undefined)
|
|
119
|
+
out[k] = seo[k];
|
|
120
|
+
if (seo.markups)
|
|
121
|
+
out.markups = seo.markups.map((m) => ({ id: m.id || randomUUID(), name: m.name, code: m.code }));
|
|
115
122
|
return out;
|
|
116
123
|
}
|
|
124
|
+
/** Apply name / homepage / slug / settings / SEO to a page. The backend's update_page is a cond:
|
|
125
|
+
* is_homepage → only that; name → only the name; otherwise slug + settings, where settings is
|
|
126
|
+
* REPLACED (omitted → stored as "null", wiping page_mask/seo). So: one call per branch, and the
|
|
127
|
+
* last call always resends the page's current settings with the changes merged in. */
|
|
128
|
+
export async function applyPageMeta(api, pageId, { name, slug, is_homepage, settings, seo }) {
|
|
129
|
+
if (name != null)
|
|
130
|
+
await api.updatePage(pageId, { name });
|
|
131
|
+
if (is_homepage)
|
|
132
|
+
await api.updatePage(pageId, { is_homepage: true });
|
|
133
|
+
const seoBlock = seo ? buildPageSeo(seo) : {};
|
|
134
|
+
if (!slug && !settings && !Object.keys(seoBlock).length)
|
|
135
|
+
return;
|
|
136
|
+
// ponytail: no get-page-by-id route, so this pulls the full page list (with sources) once.
|
|
137
|
+
const res = await api.listPages();
|
|
138
|
+
const list = res?.data || res?.pages || res;
|
|
139
|
+
let cur = (Array.isArray(list) ? list : []).find((p) => p.id === pageId)?.settings;
|
|
140
|
+
if (typeof cur === "string")
|
|
141
|
+
try {
|
|
142
|
+
cur = JSON.parse(cur);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
cur = null;
|
|
146
|
+
}
|
|
147
|
+
if (!cur || typeof cur !== "object")
|
|
148
|
+
cur = {};
|
|
149
|
+
const next = { ...cur, ...(settings || {}) };
|
|
150
|
+
if (Object.keys(seoBlock).length)
|
|
151
|
+
next.seo = { ...(next.seo || {}), ...seoBlock };
|
|
152
|
+
await api.updatePage(pageId, { ...(slug ? { slug } : {}), settings: next });
|
|
153
|
+
}
|
|
117
154
|
/** Normalize a page slug to what the storefront matches on. The storefront routes by the
|
|
118
155
|
* RAW path segment (e.g. "/cart" → path ["cart"]) and looks up `page.slug == "cart"`, so a
|
|
119
156
|
* stored slug WITH a leading "/" (e.g. "/cart") never matches and the page 404s. The homepage
|
|
@@ -246,16 +283,7 @@ ONE page per site for type main (homepage) / error / maintain, and slugs are uni
|
|
|
246
283
|
return { error: "Page created but no id was returned.", created };
|
|
247
284
|
}
|
|
248
285
|
// slug / homepage / SEO are not applied at create — set them via update_page.
|
|
249
|
-
const
|
|
250
|
-
if (cleanSlug || is_homepage || (seoBlock && Object.keys(seoBlock).length)) {
|
|
251
|
-
await api
|
|
252
|
-
.updatePage(pageId, {
|
|
253
|
-
...(cleanSlug ? { slug: cleanSlug } : {}),
|
|
254
|
-
...(is_homepage ? { is_homepage: true } : {}),
|
|
255
|
-
...(seoBlock && Object.keys(seoBlock).length ? { settings: { seo: seoBlock } } : {}),
|
|
256
|
-
})
|
|
257
|
-
.catch(() => { });
|
|
258
|
-
}
|
|
286
|
+
const metaWarning = await applyPageMeta(api, pageId, { slug: cleanSlug, is_homepage, seo }).then(() => undefined, (e) => `Page saved, but slug/homepage/SEO failed: ${e?.message ?? e}`);
|
|
259
287
|
return {
|
|
260
288
|
success: true,
|
|
261
289
|
page_id: pageId,
|
|
@@ -264,6 +292,7 @@ ONE page per site for type main (homepage) / error / maintain, and slugs are uni
|
|
|
264
292
|
page_type: kind ?? null,
|
|
265
293
|
...(feature ? { data_source: { flag: feature.flag, newly_enabled: feature.changed } } : {}),
|
|
266
294
|
stats: validation.stats,
|
|
295
|
+
...(metaWarning ? { warning: metaWarning } : {}),
|
|
267
296
|
};
|
|
268
297
|
}));
|
|
269
298
|
server.tool("add_section", `Append a section to an EXISTING page's source. Reads the current source, appends your section, validates, and (when dry_run=false) saves.
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// (the response is a generic {code:success}), so we mint a UUID and return it.
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { seoSchema, mergeSeoTags } from "./articles.js";
|
|
9
10
|
const variationSpec = z.object({
|
|
10
11
|
retail_price: z.number().describe("Selling price (what the customer pays)"),
|
|
11
12
|
original_price: z.number().optional().describe("List/compare-at price (defaults to retail_price)"),
|
|
@@ -30,6 +31,27 @@ function attrKeyValue(v) {
|
|
|
30
31
|
function cartesian(lists) {
|
|
31
32
|
return lists.reduce((acc, cur) => acc.flatMap((a) => cur.map((b) => [...a, b])), [[]]);
|
|
32
33
|
}
|
|
34
|
+
/** Merge SEO fields into the product's meta_tags, resending its current slug (see setProductSeo). */
|
|
35
|
+
async function applyProductSeo(api, id, seo) {
|
|
36
|
+
const res = await api.getProduct(id);
|
|
37
|
+
const p = res?.data || res?.product || res;
|
|
38
|
+
await api.setProductSeo(id, mergeSeoTags(seo, p?.meta_tags || []), p?.slug ?? null);
|
|
39
|
+
}
|
|
40
|
+
/** Find a category's current meta_tags in a list response (flat or tree-shaped). */
|
|
41
|
+
function findMetaTags(res, id) {
|
|
42
|
+
const stack = [res];
|
|
43
|
+
while (stack.length) {
|
|
44
|
+
const x = stack.pop();
|
|
45
|
+
if (Array.isArray(x))
|
|
46
|
+
stack.push(...x);
|
|
47
|
+
else if (x && typeof x === "object") {
|
|
48
|
+
if (x.id === id && "meta_tags" in x)
|
|
49
|
+
return x.meta_tags || [];
|
|
50
|
+
stack.push(...Object.values(x).filter((v) => v && typeof v === "object"));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
33
55
|
export function registerCatalogWriteTools(server, api, handle) {
|
|
34
56
|
server.tool("create_product", `Create a product so the storefront has real merchandise (grid-product / slider-product bindings need this).
|
|
35
57
|
Simple use: pass name + price (+ images, category_ids). One default variation with the price/stock is created for you.
|
|
@@ -49,7 +71,8 @@ Images must be HOSTED URLs — get them from search_images or upload_images firs
|
|
|
49
71
|
.optional()
|
|
50
72
|
.describe("Variant axes, e.g. [{name:'Color',values:['Đen','Trắng']},{name:'Size',values:['S','M','L']}]"),
|
|
51
73
|
variations: z.array(variationSpec).optional().describe("Explicit per-SKU variations. Omit to auto-build one from price/stock/sku."),
|
|
52
|
-
|
|
74
|
+
seo: seoSchema,
|
|
75
|
+
}, ({ name, price, original_price, stock, sku, images, description, short_description, category_ids, attributes, variations, seo }) => handle(async () => {
|
|
53
76
|
let vars = variations;
|
|
54
77
|
// Enrich attribute axes with the id + keyword shape the storefront variation selector
|
|
55
78
|
// needs (a raw {name,values} declares the axis but attaches it to no variation).
|
|
@@ -123,7 +146,11 @@ Images must be HOSTED URLs — get them from search_images or upload_images firs
|
|
|
123
146
|
// Success response is { product: {...} } at the top level (not under data).
|
|
124
147
|
const prod = res?.product || res?.data?.product || res?.data?.attributes || res?.data || {};
|
|
125
148
|
const productId = prod?.id || null;
|
|
149
|
+
const warning = seo && productId
|
|
150
|
+
? await applyProductSeo(api, productId, seo).then(() => undefined, (e) => `Product created, but SEO failed: ${e?.message ?? e}`)
|
|
151
|
+
: undefined;
|
|
126
152
|
return {
|
|
153
|
+
...(warning ? { warning } : {}),
|
|
127
154
|
success: true,
|
|
128
155
|
product_id: productId,
|
|
129
156
|
name,
|
|
@@ -139,7 +166,8 @@ Returns the new category id — pass it to create_product's category_ids. Image
|
|
|
139
166
|
image: z.string().optional().describe("Hosted image URL for the category card"),
|
|
140
167
|
description: z.string().optional().describe("Category description"),
|
|
141
168
|
parent_id: z.string().optional().describe("Parent category id for a sub-category"),
|
|
142
|
-
|
|
169
|
+
seo: seoSchema,
|
|
170
|
+
}, ({ name, image, description, parent_id, seo }) => handle(async () => {
|
|
143
171
|
const id = randomUUID();
|
|
144
172
|
const commands = [
|
|
145
173
|
{ name: "create_category", data: { id, name, ...(parent_id ? { parent_id } : {}) } },
|
|
@@ -152,6 +180,8 @@ Returns the new category id — pass it to create_product's category_ids. Image
|
|
|
152
180
|
data: { id, multi_description: [{ id: randomUUID(), title: name, description }] },
|
|
153
181
|
});
|
|
154
182
|
}
|
|
183
|
+
if (seo)
|
|
184
|
+
commands.push({ name: "set_category_seo", data: { id, meta_tags: mergeSeoTags(seo) } });
|
|
155
185
|
await api.createProductCategory(commands);
|
|
156
186
|
return { success: true, category_id: id, name, ...(parent_id ? { parent_id } : {}) };
|
|
157
187
|
}));
|
|
@@ -159,16 +189,44 @@ Returns the new category id — pass it to create_product's category_ids. Image
|
|
|
159
189
|
name: z.string().describe("Blog category name"),
|
|
160
190
|
image: z.string().optional().describe("Hosted image URL"),
|
|
161
191
|
description: z.string().optional().describe("Category description"),
|
|
162
|
-
|
|
192
|
+
seo: seoSchema,
|
|
193
|
+
}, ({ name, image, description, seo }) => handle(async () => {
|
|
163
194
|
const id = randomUUID();
|
|
164
195
|
const commands = [{ name: "create_category", data: { id, name } }];
|
|
165
196
|
if (description)
|
|
166
197
|
commands.push({ name: "description_category", data: { id, description } });
|
|
167
198
|
if (image)
|
|
168
199
|
commands.push({ name: "image_category", data: { id, image } });
|
|
200
|
+
if (seo)
|
|
201
|
+
commands.push({ name: "set_category_seo", data: { id, meta_tags: mergeSeoTags(seo) } });
|
|
169
202
|
await api.createBlogCategory(commands);
|
|
170
203
|
return { success: true, category_id: id, name };
|
|
171
204
|
}));
|
|
205
|
+
server.tool("update_blog_category", "Update a blog/article category (name, description, image, slug, SEO). Pass id + fields to change.", {
|
|
206
|
+
id: z.string().describe("Blog category id"),
|
|
207
|
+
name: z.string().optional().describe("New name"),
|
|
208
|
+
description: z.string().optional().describe("New description"),
|
|
209
|
+
image: z.string().optional().describe("New hosted image URL"),
|
|
210
|
+
slug: z.string().optional().describe("New custom slug"),
|
|
211
|
+
seo: seoSchema.describe("SEO settings to change; other existing SEO tags are kept. Pass \"\" to clear a field."),
|
|
212
|
+
}, ({ id, name, description, image, slug, seo }) => handle(async () => {
|
|
213
|
+
const commands = [];
|
|
214
|
+
// name_category may rewrite the slug, so the custom slug goes after it.
|
|
215
|
+
if (name != null)
|
|
216
|
+
commands.push({ name: "name_category", data: { id, name } });
|
|
217
|
+
if (slug != null)
|
|
218
|
+
commands.push({ name: "set_category_custom_slug", data: { id, custom_slug: slug } });
|
|
219
|
+
if (description != null)
|
|
220
|
+
commands.push({ name: "description_category", data: { id, description } });
|
|
221
|
+
if (image != null)
|
|
222
|
+
commands.push({ name: "image_category", data: { id, image } });
|
|
223
|
+
if (seo)
|
|
224
|
+
commands.push({ name: "set_category_seo", data: { id, meta_tags: mergeSeoTags(seo, findMetaTags(await api.listBlogCategories(), id)) } });
|
|
225
|
+
if (!commands.length)
|
|
226
|
+
throw new Error("Nothing to update — pass at least one field besides id.");
|
|
227
|
+
await api.updateBlogCategory(commands);
|
|
228
|
+
return { success: true, category_id: id, updated: commands.map((c) => c.name) };
|
|
229
|
+
}));
|
|
172
230
|
// ── Edits: products & product categories ─────────────────────────────────────
|
|
173
231
|
server.tool("update_product", `Update an existing product. Pass product_id + only the fields to change. To change
|
|
174
232
|
price/stock, pass variations (get_product first to see the existing variation shape).
|
|
@@ -180,7 +238,8 @@ Images must be hosted CDN urls (search_images cdn_url / upload_images).`, {
|
|
|
180
238
|
category_ids: z.array(z.string()).optional().describe("Replace the product's categories"),
|
|
181
239
|
is_published: z.boolean().optional().describe("Publish/unpublish the product"),
|
|
182
240
|
variations: z.array(variationSpec).optional().describe("Replace variations (price/stock/SKU per variant). Reuse existing custom_ids from get_product to edit in place."),
|
|
183
|
-
|
|
241
|
+
seo: seoSchema.describe("SEO settings to change; other existing SEO tags and the slug are kept. Pass \"\" to clear a field."),
|
|
242
|
+
}, ({ product_id, name, description, images, category_ids, is_published, variations, seo }) => handle(async () => {
|
|
184
243
|
const productParams = { product_id };
|
|
185
244
|
if (name != null)
|
|
186
245
|
productParams.name = name;
|
|
@@ -206,8 +265,16 @@ Images must be hosted CDN urls (search_images cdn_url / upload_images).`, {
|
|
|
206
265
|
is_hidden: false,
|
|
207
266
|
}));
|
|
208
267
|
}
|
|
209
|
-
|
|
210
|
-
|
|
268
|
+
const updated = Object.keys(productParams).filter((k) => k !== "product_id");
|
|
269
|
+
if (!updated.length && !seo)
|
|
270
|
+
throw new Error("Nothing to update — pass at least one field besides product_id.");
|
|
271
|
+
if (updated.length)
|
|
272
|
+
await api.updateProduct(productParams);
|
|
273
|
+
if (seo) {
|
|
274
|
+
await applyProductSeo(api, product_id, seo);
|
|
275
|
+
updated.push("seo");
|
|
276
|
+
}
|
|
277
|
+
return { success: true, product_id, updated };
|
|
211
278
|
}));
|
|
212
279
|
server.tool("set_product_published", "Publish or unpublish one or more products quickly (without a full update).", {
|
|
213
280
|
product_ids: z.array(z.string()).describe("Product ids"),
|
|
@@ -222,13 +289,14 @@ Images must be hosted CDN urls (search_images cdn_url / upload_images).`, {
|
|
|
222
289
|
await api.removeProducts(product_ids);
|
|
223
290
|
return { success: true, deleted: product_ids };
|
|
224
291
|
}));
|
|
225
|
-
server.tool("update_product_category", "Update a product category (name, image, description,
|
|
292
|
+
server.tool("update_product_category", "Update a product category (name, image, description, visibility, SEO). Pass id + fields to change.", {
|
|
226
293
|
id: z.string().describe("Category id"),
|
|
227
294
|
name: z.string().optional().describe("New name"),
|
|
228
295
|
image: z.string().optional().describe("New hosted image URL"),
|
|
229
296
|
description: z.string().optional().describe("New description"),
|
|
230
297
|
hidden: z.boolean().optional().describe("Hide (true) or show (false) the category"),
|
|
231
|
-
|
|
298
|
+
seo: seoSchema.describe("SEO settings to change; other existing SEO tags are kept. Pass \"\" to clear a field."),
|
|
299
|
+
}, ({ id, name, image, description, hidden, seo }) => handle(async () => {
|
|
232
300
|
const commands = [];
|
|
233
301
|
if (name != null)
|
|
234
302
|
commands.push({ name: "name_category", data: { id, name } });
|
|
@@ -238,8 +306,10 @@ Images must be hosted CDN urls (search_images cdn_url / upload_images).`, {
|
|
|
238
306
|
commands.push({ name: "multi_description", data: { id, multi_description: [{ id: randomUUID(), title: name || "", description }] } });
|
|
239
307
|
if (hidden != null)
|
|
240
308
|
commands.push({ name: "set_category_visible", data: { id, is_hidden: hidden } });
|
|
309
|
+
if (seo)
|
|
310
|
+
commands.push({ name: "set_category_seo", data: { id, meta_tags: mergeSeoTags(seo, findMetaTags(await api.listCategories(), id)) } });
|
|
241
311
|
if (!commands.length)
|
|
242
|
-
throw new Error("Nothing to update — pass at least one of name/image/description/hidden.");
|
|
312
|
+
throw new Error("Nothing to update — pass at least one of name/image/description/hidden/seo.");
|
|
243
313
|
await api.updateProductCategory(commands);
|
|
244
314
|
return { success: true, category_id: id, updated: commands.map((c) => c.name) };
|
|
245
315
|
}));
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { seoSchema, mergeSeoTags } from "./articles.js";
|
|
2
3
|
// Multilingual app (Enum.Application multilingual = 5). Install first via
|
|
3
4
|
// install_app({ app: "multilingual" }), then enable languages. Each resource type
|
|
4
5
|
// (product, category, article, …) has its own translations list + save endpoint.
|
|
@@ -60,6 +61,40 @@ For most resources \`items\` is an array of translation rows. For 'site' pass th
|
|
|
60
61
|
body = { [def.field || `${resource}_translations`]: items, language_code };
|
|
61
62
|
return api.mlSaveTranslations(def.action, body);
|
|
62
63
|
}));
|
|
64
|
+
server.tool("set_translation_seo", `Set the SEO ("Cài đặt SEO") of ONE item's translation in a target language — product, article,
|
|
65
|
+
product category or blog category. Same fields as the item's own seo param; merged into that
|
|
66
|
+
translation's existing SEO tags (fields you don't pass are kept). Only meta_tags is written, so the
|
|
67
|
+
translated name/slug/content are untouched; the translation row is created if missing.`, {
|
|
68
|
+
resource: z.enum(["product", "article", "category", "article_category"]).describe("category = product category, article_category = blog category"),
|
|
69
|
+
id: z.string().describe("Item id (product / article / category id)"),
|
|
70
|
+
language_code: z.string().describe("Target language code, e.g. 'en'"),
|
|
71
|
+
seo: seoSchema.unwrap(),
|
|
72
|
+
}, ({ resource, id, language_code, seo }) => handle(async () => {
|
|
73
|
+
const path = `${resource}_translations`;
|
|
74
|
+
// The list API only filters by name (term), so scan pages to find the row by id.
|
|
75
|
+
// ponytail: up to 50 pages × 100 items; add an id filter on the backend if sites outgrow it.
|
|
76
|
+
let row;
|
|
77
|
+
for (let page = 1; page <= 50 && !row; page++) {
|
|
78
|
+
const box = unwrap(await api.mlGetTranslations(path, { language_code, page, limit: 100 }), path);
|
|
79
|
+
const rows = box?.data || [];
|
|
80
|
+
row = rows.find((r) => r.id === id);
|
|
81
|
+
if (rows.length < 100)
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
const tr = row?.[`${resource}_translation`];
|
|
85
|
+
const idKey = resource === "article_category" ? "category_id" : `${resource}_id`;
|
|
86
|
+
const meta_tags = mergeSeoTags(seo, tr?.meta_tags || []);
|
|
87
|
+
const item = { [idKey]: id, site_id: api.siteId, meta_tags };
|
|
88
|
+
// Article / category / blog-category saves read `name` unconditionally (and re-derive the
|
|
89
|
+
// translated slug from it), so resend the current translated name, else the original one.
|
|
90
|
+
if (resource !== "product") {
|
|
91
|
+
item.name = tr?.name || row?.name;
|
|
92
|
+
if (!item.name)
|
|
93
|
+
throw new Error(`${resource} ${id} not found in the ${language_code} translation list.`);
|
|
94
|
+
}
|
|
95
|
+
await api.mlSaveTranslations(SAVE[resource].action, { [path]: [item], language_code });
|
|
96
|
+
return { success: true, resource, id, language_code, meta_tags };
|
|
97
|
+
}));
|
|
63
98
|
server.tool("auto_translate", `Machine-translate a JSON blob from one language to another. \`type\` must be a valid translation
|
|
64
99
|
type (e.g. 'product', 'category'). Returns { trans, count }.`, {
|
|
65
100
|
type: z.string().describe("Translation type, e.g. 'product'"),
|
package/dist/tools/page-draft.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { PAGE_TYPE_NUM, PAGE_TYPE_FLAG, PAGE_KINDS,
|
|
2
|
+
import { PAGE_TYPE_NUM, PAGE_TYPE_FLAG, PAGE_KINDS, applyPageMeta, normalizeSlug, checkPageCreateConflict } from "./builder.js";
|
|
3
3
|
import { validatePage, finalizeForRender, reassignIds } from "../builder/page.js";
|
|
4
4
|
import { createDraft, getDraft, setDraft, appendDraftSection, listDrafts, delDraft, } from "../persistence/draft-cache.js";
|
|
5
5
|
// Friendly result when a draft is gone (disposable cache: expired ~2h or restart).
|
|
@@ -156,19 +156,10 @@ RESUMABLE: if a request fails mid-commit, the draft keeps its page_id + committe
|
|
|
156
156
|
// All sections committed → apply slug / homepage / SEO, then drop the draft.
|
|
157
157
|
// Strip a leading "/" so the storefront's bare-segment match resolves (else 404).
|
|
158
158
|
const cleanSlug = normalizeSlug(draft.meta.slug);
|
|
159
|
-
const
|
|
160
|
-
if (cleanSlug || draft.meta.is_homepage || (seoBlock && Object.keys(seoBlock).length)) {
|
|
161
|
-
await api
|
|
162
|
-
.updatePage(draft.page_id, {
|
|
163
|
-
...(cleanSlug ? { slug: cleanSlug } : {}),
|
|
164
|
-
...(draft.meta.is_homepage ? { is_homepage: true } : {}),
|
|
165
|
-
...(seoBlock && Object.keys(seoBlock).length ? { settings: { seo: seoBlock } } : {}),
|
|
166
|
-
})
|
|
167
|
-
.catch(() => { });
|
|
168
|
-
}
|
|
159
|
+
const metaWarning = await applyPageMeta(api, draft.page_id, { slug: cleanSlug, is_homepage: draft.meta.is_homepage, seo: draft.meta.seo }).then(() => undefined, (e) => `Page saved, but slug/homepage/SEO failed: ${e?.message ?? e}`);
|
|
169
160
|
const pageId = draft.page_id;
|
|
170
161
|
await delDraft(draft_id);
|
|
171
|
-
return { success: true, page_id: pageId, total_sections: total, stats: validation.stats };
|
|
162
|
+
return { success: true, page_id: pageId, total_sections: total, stats: validation.stats, ...(metaWarning ? { warning: metaWarning } : {}) };
|
|
172
163
|
}
|
|
173
164
|
catch (e) {
|
|
174
165
|
const committed = draft.committed_count ?? 0;
|
package/dist/tools/pages.js
CHANGED
|
@@ -3,7 +3,30 @@ import { CUSTOM_CODE_GUIDE } from "../guides.js";
|
|
|
3
3
|
import { getConfirmMode } from "./context.js";
|
|
4
4
|
import { normalizeEvents } from "../builder/events.js";
|
|
5
5
|
import { normalizeBindings } from "../builder/bindings.js";
|
|
6
|
-
import { PAGE_TYPE_NUM, PAGE_KINDS,
|
|
6
|
+
import { PAGE_TYPE_NUM, PAGE_KINDS, applyPageMeta, normalizeSlug, checkPageCreateConflict } from "./builder.js";
|
|
7
|
+
const pageSeoSchema = z.object({
|
|
8
|
+
title: z.string().optional(),
|
|
9
|
+
description: z.string().optional(),
|
|
10
|
+
keyword: z.string().optional(),
|
|
11
|
+
favicon: z.string().optional(),
|
|
12
|
+
thumbnail: z.string().optional().describe("Share image; also og:image"),
|
|
13
|
+
og_title: z.string().optional().describe("Defaults to title"),
|
|
14
|
+
og_description: z.string().optional().describe("Defaults to description"),
|
|
15
|
+
// ── Advanced ("SEO nâng cao") ──
|
|
16
|
+
thumbnail_alt: z.string().optional(),
|
|
17
|
+
author: z.string().optional(),
|
|
18
|
+
canonical: z.string().optional().describe("Canonical URL"),
|
|
19
|
+
og_site_name: z.string().optional(),
|
|
20
|
+
og_type: z.string().optional(),
|
|
21
|
+
og_url: z.string().optional(),
|
|
22
|
+
robots: z.string().optional().describe('Robots meta, e.g. "noindex, nofollow" or "max-snippet:-1, max-image-preview:large"'),
|
|
23
|
+
markups: z
|
|
24
|
+
.array(z.object({ name: z.string(), code: z.string().describe("JSON-LD as a string, without <script>") }))
|
|
25
|
+
.optional()
|
|
26
|
+
.describe("Structured data markups (JSON-LD). REPLACES the page's list; [] removes them."),
|
|
27
|
+
site_name_markup: z.boolean().optional().describe("Emit the site-name markup (default on)"),
|
|
28
|
+
hidden_meta_tags: z.array(z.string()).optional().describe("Meta tag keys to hide, e.g. ['og:url']"),
|
|
29
|
+
});
|
|
7
30
|
/**
|
|
8
31
|
* Page source utilities.
|
|
9
32
|
*
|
|
@@ -299,8 +322,7 @@ Examples:
|
|
|
299
322
|
slug: z.string().describe("URL slug WITHOUT a leading slash, e.g. 'about', 'collections', 'cart'. A leading '/' is stripped automatically (the storefront matches the bare path segment, so '/cart' would 404). Homepage needs no slug (pass is_homepage:true)."),
|
|
300
323
|
type: z.enum(PAGE_KINDS).optional().describe("Page kind (main/store/member/blog/custom/error/maintain). store/member/blog need their data-source flag enabled — prefer build_page which auto-enables it. main/error/maintain are one-per-site; use 'custom' for extra pages."),
|
|
301
324
|
is_homepage: z.boolean().default(false).describe("Set as homepage"),
|
|
302
|
-
seo:
|
|
303
|
-
.object({ title: z.string().optional(), description: z.string().optional(), keyword: z.string().optional(), favicon: z.string().optional(), thumbnail: z.string().optional() })
|
|
325
|
+
seo: pageSeoSchema
|
|
304
326
|
.optional()
|
|
305
327
|
.describe("SEO → settings.seo (title/description/keyword/favicon/thumbnail). Tokens {{name_page}}/{{name_site}} allowed."),
|
|
306
328
|
}, ({ name, slug, type, is_homepage, seo }) => handle(async () => {
|
|
@@ -314,37 +336,26 @@ Examples:
|
|
|
314
336
|
const created = await api.createPage({ name, ...(typeNum != null ? { type: typeNum } : {}) });
|
|
315
337
|
invalidatePageCache();
|
|
316
338
|
const pageId = (created && (created.id || created.data?.id || created.page?.id)) || null;
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
...(is_homepage ? { is_homepage: true } : {}),
|
|
323
|
-
...(seoBlock && Object.keys(seoBlock).length ? { settings: { seo: seoBlock } } : {}),
|
|
324
|
-
})
|
|
325
|
-
.catch(() => { });
|
|
326
|
-
invalidatePageCache();
|
|
327
|
-
}
|
|
328
|
-
return { success: true, page_id: pageId, name, slug: cleanSlug ?? null, type: type ?? null, raw: pageId ? undefined : created };
|
|
339
|
+
const warning = pageId
|
|
340
|
+
? await applyPageMeta(api, pageId, { slug: cleanSlug, is_homepage, seo }).then(() => undefined, (e) => `Page created, but slug/homepage/SEO failed: ${e?.message ?? e}`)
|
|
341
|
+
: undefined;
|
|
342
|
+
invalidatePageCache();
|
|
343
|
+
return { success: true, page_id: pageId, name, slug: cleanSlug ?? null, type: type ?? null, raw: pageId ? undefined : created, ...(warning ? { warning } : {}) };
|
|
329
344
|
}));
|
|
330
|
-
server.tool("update_page",
|
|
345
|
+
server.tool("update_page", `Update page properties: name, slug, homepage, SEO ("Cài đặt SEO") and settings.
|
|
346
|
+
seo / settings are MERGED into the page's current settings — fields you don't pass are kept.`, {
|
|
331
347
|
page_id: z.string().describe("Page ID"),
|
|
332
348
|
name: z.string().optional().describe("New name"),
|
|
333
349
|
slug: z.string().optional().describe("New slug WITHOUT a leading slash (e.g. 'about', 'cart'). A leading '/' is stripped automatically — '/cart' would 404 on the storefront."),
|
|
334
350
|
is_homepage: z.boolean().optional().describe("Set as homepage"),
|
|
335
|
-
|
|
336
|
-
|
|
351
|
+
seo: pageSeoSchema.optional().describe("SEO → settings.seo (title/description/keyword/favicon/thumbnail/og_*). Merged; other SEO fields kept. Tokens {{name_page}}/{{name_site}} allowed."),
|
|
352
|
+
settings: z.record(z.any()).optional().describe("Other page settings keys to merge (top-level keys replace)"),
|
|
353
|
+
}, ({ page_id, name, slug, is_homepage, seo, settings }) => handle(async () => {
|
|
337
354
|
// Normalize slug (strip leading "/") so the storefront's bare-segment match resolves.
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
if (clean)
|
|
341
|
-
params.slug = clean;
|
|
342
|
-
else
|
|
343
|
-
delete params.slug;
|
|
344
|
-
}
|
|
345
|
-
const res = await api.updatePage(page_id, params);
|
|
355
|
+
const cleanSlug = slug !== undefined ? normalizeSlug(slug) : undefined;
|
|
356
|
+
await applyPageMeta(api, page_id, { name, slug: cleanSlug, is_homepage, seo, settings });
|
|
346
357
|
invalidatePageCache();
|
|
347
|
-
return
|
|
358
|
+
return { success: true, page_id };
|
|
348
359
|
}));
|
|
349
360
|
server.tool("get_site_custom_code", `Get custom code of the site (CSS/JS). Two modes:
|
|
350
361
|
- Default: returns ALL 4 code fields (full content)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webcake-storefront-mcp",
|
|
3
|
-
"version": "1.31.
|
|
3
|
+
"version": "1.31.13",
|
|
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",
|