This commit is contained in:
Your Name committed 2026-07-24 19:10:13 -04:00
1 parent bd8f5882e0
commit e509e3f0a2
8 files changed
+581 -12

No files matched your search

+89
View File
@@ -17,6 +17,7 @@ const websiteBlockTypes = [
"BANNER",
"HERO_ARTICLE",
"RICH_TEXT",
"ARTICLE_MEDIA",
"SCENT_NOTES_BREAKDOWN",
"QUOTE_CALLOUT",
"PRODUCT_CAROUSEL",
@@ -67,6 +68,20 @@ describe("Page Builder block contracts", () => {
});
});
test("ARTICLE_MEDIA requires imageAlt when imageUrl is provided", () => {
const result = validatePayloadAgainstMetadata("ARTICLE_MEDIA", {
body: { th: "ข้อความภาษาไทย" },
imageUrl: "/uploads/article.jpg",
});
expect(result.valid).toBe(false);
expect(result.validationIssues).toContainEqual({
path: "imageAlt",
message: "imageAlt is required when imageUrl is provided",
code: "custom",
});
});
test("create_page_with_section rejects invalid payloads without calling backend", async () => {
let postCalls = 0;
const api = {
@@ -133,4 +148,78 @@ describe("Page Builder block contracts", () => {
],
});
});
test("add_page_section rejects invalid payloads without calling backend", async () => {
let postCalls = 0;
const api = {
post: async () => {
postCalls += 1;
throw new Error("Backend must not be called");
},
} as any;
const result = await handleSectionTool(api, "add_page_section", {
pageId: 15,
type: "HERO_V2",
payload: {},
});
expect(result && "isError" in result ? result.isError : false).toBe(true);
expect(postCalls).toBe(0);
});
test("add_page_section uses the existing-page endpoint and returns compact output", async () => {
const calls: Array<{ url: string; payload: unknown }> = [];
const api = {
post: async (url: string, payload: unknown) => {
calls.push({ url, payload });
return {
data: {
success: true,
pageId: 15,
pageSlug: "fine-blanking-ddc-3-in-1",
sectionId: 36,
section: { id: 36, type: "HERO_ARTICLE", order: 2 },
},
};
},
} as any;
const payload = getPageBlockSchema("HERO_ARTICLE").recommendedPayload;
const result = await handleSectionTool(api, "add_page_section", {
pageId: 15,
pageSlug: "fine-blanking-ddc-3-in-1",
type: "HERO_ARTICLE",
payload,
order: 2,
});
const response = JSON.parse(result?.content[0]?.text ?? "{}");
expect(calls).toEqual([{
url: "/pages/15/sections",
payload: {
type: "HERO_ARTICLE",
payload,
isActive: true,
order: 2,
},
}]);
expect(response).toEqual({
success: true,
tool: "add_page_section",
pageId: 15,
pageSlug: "fine-blanking-ddc-3-in-1",
preview: {
lang: "th",
previewPath: "/th/fine-blanking-ddc-3-in-1",
previewUrl: "/th/fine-blanking-ddc-3-in-1",
},
sectionId: 36,
type: "HERO_ARTICLE",
order: 2,
warnings: [
"overlayColor is accepted by backend schema but is not used by apps/website HeroArticleSection today.",
],
});
});
});
+68 -4
View File
@@ -18,6 +18,7 @@ const WEBSITE_PAGE_SECTION_TYPE_LIST = [
"BANNER",
"HERO_ARTICLE",
"RICH_TEXT",
"ARTICLE_MEDIA",
"SCENT_NOTES_BREAKDOWN",
"QUOTE_CALLOUT",
"PRODUCT_CAROUSEL",
@@ -299,6 +300,32 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
},
},
},
ARTICLE_MEDIA: {
type: "ARTICLE_MEDIA",
requiredFields: ["body"],
optionalFields: ["heading", "imageUrl", "imageAlt", "imageCaption", "imagePosition", "width", "background"],
renderedFields: ["heading", "body", "imageUrl", "imageAlt", "imageCaption", "imagePosition", "width", "background"],
storedOnlyFields: [],
discoveryToolsNeeded: [],
warnings: [
"body is rendered as plain text with preserved line breaks; do not send HTML.",
"imageAlt is required whenever imageUrl is provided.",
],
minimalPayload: { body: localizedObjectExample },
recommendedPayload: {
heading: { th: "ความแม่นยำในทุกฟันสเตอร์", en: "Precision in every sprocket tooth" },
body: {
th: "Fine Blanking ช่วยควบคุมผิวตัดและรูปทรงของฟันสเตอร์ให้สม่ำเสมอ เพื่อการส่งกำลังที่ราบรื่นและแม่นยำ",
en: "Fine blanking controls the cut surface and tooth profile for smooth, precise power transmission.",
},
imageUrl: imageUrlExample,
imageAlt: { th: "สเตอร์ที่ผลิตด้วยเทคโนโลยี Fine Blanking", en: "A sprocket made with fine blanking technology" },
imageCaption: { th: "ผิวตัดเรียบและรูปทรงสม่ำเสมอ", en: "A smooth cut surface and consistent profile" },
imagePosition: "right",
width: "standard",
background: "default",
},
},
SCENT_NOTES_BREAKDOWN: {
type: "SCENT_NOTES_BREAKDOWN",
requiredFields: [],
@@ -524,6 +551,10 @@ const ADD_PAGE_SECTION_GUIDANCE: Record<string, { required: string[]; example: s
required: ["content"],
example: `{ "title": ${localizedStringExample}, "content": ${localizedStringExample} }`
},
ARTICLE_MEDIA: {
required: ["body"],
example: `{ "heading": ${localizedStringExample}, "body": ${localizedStringExample}, "imageUrl": "https://example.com/article.jpg", "imageAlt": ${localizedStringExample}, "imagePosition": "right" }`
},
SCENT_NOTES_BREAKDOWN: {
required: [],
example: `{ "title": ${localizedStringExample}, "topNotes": ${localizedStringListExample} }`
@@ -783,6 +814,23 @@ const SECTION_TOOL_CONFIGS = [
},
required: ["sectionId", "content"]
},
{
name: "update_article_media",
sectionType: "ARTICLE_MEDIA",
description: `Update an ARTICLE_MEDIA section. This tool performs a partial update/merge (PATCH). Omitted fields are preserved. body is localized plain text; do not send HTML. imageAlt is required when imageUrl is present. Example payload: { "sectionId": 35, "heading": ${localizedStringExample}, "body": ${localizedStringExample}, "imageUrl": "https://...", "imageAlt": ${localizedStringExample}, "imagePosition": "right" }`,
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
heading: { ...localizedStringSchema, description: `Optional localized heading, for example ${localizedStringExample}` },
body: { ...localizedStringSchema, description: `Localized plain text body, for example ${localizedStringExample}` },
imageUrl: { type: "string", description: "Optional image URL or website-relative media path" },
imageAlt: { ...localizedStringSchema, description: `Localized image alternative text, required with imageUrl` },
imageCaption: { ...localizedStringSchema, description: `Optional localized image caption` },
imagePosition: { type: "string", enum: ["left", "right", "top"], description: "Responsive image placement" },
width: { type: "string", enum: ["narrow", "standard", "wide"], description: "Content width preset" },
background: { type: "string", enum: ["default", "muted"], description: "Theme-aware background preset" },
},
required: ["sectionId"]
},
{
name: "update_scent_notes_breakdown",
sectionType: "SCENT_NOTES_BREAKDOWN",
@@ -1020,7 +1068,7 @@ export const SECTION_TOOL_DEFINITIONS = [
},
{
name: "create_page_with_section",
description: "Create or update a dynamic CMS page and add one website-renderable section in one operation. Use this when the user asks to create a page with a single block.",
description: "Create a new dynamic CMS page and its first website-renderable section atomically. This tool is create-only: if the slug already exists, use get_dynamic_page_detail to find the page ID, then use add_page_section.",
inputSchema: {
type: "object",
properties: {
@@ -1052,7 +1100,7 @@ export const SECTION_TOOL_DEFINITIONS = [
},
{
name: "add_page_section",
description: `${buildAddPageSectionDescription()}\n\nRender target guard: generic CMS pages are rendered by apps/website DynamicPageRenderer. PRODUCT_MAIN, PRODUCT_DETAILS, PRODUCT_SPECS, PRODUCT_REVIEWS, and RELATED_PRODUCTS are product detail native blocks and are rejected by default here. Use product detail CMS tools for those blocks.`,
description: `Add a new website-renderable block to an existing dynamic CMS page. Use this instead of create_page_with_section when the page or slug already exists. Call get_dynamic_page_detail first to obtain pageId, and get_page_block_schema before composing payload.\n\n${buildAddPageSectionDescription()}\n\nRender target guard: generic CMS pages are rendered by apps/website DynamicPageRenderer. PRODUCT_MAIN, PRODUCT_DETAILS, PRODUCT_SPECS, PRODUCT_REVIEWS, and RELATED_PRODUCTS are product detail native blocks and are rejected by default here. Use product detail CMS tools for those blocks.`,
inputSchema: {
type: "object",
properties: {
@@ -1398,6 +1446,20 @@ export async function handleSectionTool(api: AxiosInstance, name: string, params
throw new Error(`Validation Error: 'pageId' is required for tool '${name}'`);
}
assertWebsitePageSectionType(params.type, params.allowProductNative === true);
const validation = validatePayloadAgainstMetadata(params.type, params.payload);
if (!validation.valid) {
return {
content: [{
type: "text",
text: JSON.stringify({
success: false,
error: "Payload failed MCP preflight validation.",
validation,
}, null, 2)
}],
isError: true,
};
}
const payload: any = {
type: params.type,
payload: params.payload,
@@ -1414,8 +1476,10 @@ export async function handleSectionTool(api: AxiosInstance, name: string, params
pageId: params.pageId,
pageSlug: params.pageSlug ?? undefined,
preview: params.pageSlug ? buildCmsPreview(params.pageSlug, params.lang || defaultLang) : undefined,
renderability: buildRenderability(params.type),
section: res.data,
sectionId: res.data?.sectionId ?? res.data?.section?.id ?? res.data?.id ?? null,
type: params.type,
order: res.data?.section?.order ?? res.data?.order ?? params.order ?? null,
warnings: buildRenderability(params.type).warnings,
}, null, 2)
}]
};