From 63bc3bfb1ae143d3dcb90224b9bcbb218945a13e Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 24 Jul 2026 21:45:44 -0400 Subject: [PATCH] x --- LLM_MCP_GUIDE.md | 15 + .../src/routes/pages.localization.test.ts | 72 ++++ apps/backend/src/routes/pages.ts | 329 +++++++++++++++++- apps/mcp-server/section-toolkit.test.ts | 126 +++++++ apps/mcp-server/section-toolkit.ts | 81 ++++- packages/types/index.ts | 37 ++ 6 files changed, 658 insertions(+), 2 deletions(-) create mode 100644 apps/backend/src/routes/pages.localization.test.ts diff --git a/LLM_MCP_GUIDE.md b/LLM_MCP_GUIDE.md index 2e02b95..6ad353b 100644 --- a/LLM_MCP_GUIDE.md +++ b/LLM_MCP_GUIDE.md @@ -163,6 +163,21 @@ Use one string only for URLs, slugs, IDs, and booleans/numbers as appropriate. 6. Read back the page with `get_dynamic_page_detail`. 7. If a section has auto-populated fields, trust the read-back result over the raw update payload. +## Translating Page Builder Blocks + +Translate one language and one block at a time. This keeps the context compact and prevents a full payload replacement from overwriting existing languages. + +1. Call `get_page_translation_status` with `pageId` and the target `lang`. +2. Pick one incomplete `sectionId`. +3. Call `get_page_section_translation` with that `sectionId` and target `lang`. The default fallback supplies source text for untranslated fields and lists those fields in `fallbackPaths`. +4. Translate the fields listed in `fallbackPaths`; preserve the payload's object and array shape. +5. Call `update_section_translation` with `translations` and the `updatedAt` value as `expectedUpdatedAt`. +6. Read the block again with `get_page_section_translation` and confirm `fallbackPaths` is empty. + +`update_section_translation` changes only the requested language in registered localized fields. It preserves other languages, URLs, IDs, layout configuration, and other non-translatable values. A `SECTION_VERSION_CONFLICT` response means the block changed after it was read; fetch it again before retrying. + +Use `get_page_section` with `lang=all` only when every stored language map is genuinely needed. A specific language returns resolved strings and uses fallback by default. + ## What to Expect - `PRODUCT_CAROUSEL` may return products even when you did not send them. diff --git a/apps/backend/src/routes/pages.localization.test.ts b/apps/backend/src/routes/pages.localization.test.ts new file mode 100644 index 0000000..964ee33 --- /dev/null +++ b/apps/backend/src/routes/pages.localization.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test"; +import { + mergeSectionTranslations, + transformLocalizedPaths, +} from "./pages"; + +describe("Page section localization", () => { + const paths = ["title", "items[].title", "items[].note"]; + const payload = { + title: { th: "เอกสารอ้างอิง", en: "References" }, + items: [{ + title: { th: "เทคโนโลยีไฟน์แบลงก์", en: "Fine blanking technology" }, + note: { th: "ข้อมูลทางเทคนิค" }, + url: "https://example.com/reference", + }], + variant: "numbered", + }; + + test("resolves only the requested language and reports missing nested fields", () => { + const result = transformLocalizedPaths(payload, paths, "zh", false); + + expect(result.payload).toEqual({ + title: null, + items: [{ + title: null, + note: null, + url: "https://example.com/reference", + }], + variant: "numbered", + }); + expect(result.fallbackPaths).toEqual([ + "title", + "items[0].title", + "items[0].note", + ]); + }); + + test("merges one language while preserving other languages and configuration", () => { + const result = mergeSectionTranslations(payload, { + title: "参考资料", + items: [{ + title: "精密冲裁技术", + note: "技术资料", + }], + }, paths, "zh"); + + expect(result.payload).toEqual({ + title: { th: "เอกสารอ้างอิง", en: "References", zh: "参考资料" }, + items: [{ + title: { + th: "เทคโนโลยีไฟน์แบลงก์", + en: "Fine blanking technology", + zh: "精密冲裁技术", + }, + note: { th: "ข้อมูลทางเทคนิค", zh: "技术资料" }, + url: "https://example.com/reference", + }], + variant: "numbered", + }); + expect(result.translatedPaths).toEqual([ + "title", + "items[0].title", + "items[0].note", + ]); + }); + + test("rejects an array shape that cannot be merged safely", () => { + expect(() => mergeSectionTranslations(payload, { + items: [], + }, paths, "zh")).toThrow("Translation array shape mismatch at items"); + }); +}); diff --git a/apps/backend/src/routes/pages.ts b/apps/backend/src/routes/pages.ts index 6fc4387..dda2ba0 100644 --- a/apps/backend/src/routes/pages.ts +++ b/apps/backend/src/routes/pages.ts @@ -32,6 +32,8 @@ import { HeroV2SectionPayloadSchema, UspBarSectionPayloadSchema, CustomSectionPayloadSchema, + PAGE_SECTION_LOCALIZED_PATHS, + type SectionType, } from "@homaroma/types"; import { z } from "zod"; import { adminOnly } from "../middleware/auth"; @@ -85,6 +87,130 @@ function summarizeValidationIssues(issues: z.ZodIssue[]) { })); } +function resolveLocalizedValue(value: any, lang: string, fallback: boolean) { + if (typeof value === "string") return value; + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + if (typeof value[lang] === "string" && value[lang]) return value[lang]; + if (!fallback) return null; + return value.th ?? value.en ?? Object.values(value).find((item) => typeof item === "string") ?? null; +} + +export function transformLocalizedPaths(payload: any, paths: readonly string[], lang: string, fallback: boolean) { + const result = structuredClone(payload); + const fallbackPaths: string[] = []; + + const visit = (node: any, segments: string[], displayPath: string) => { + if (!node || segments.length === 0) return; + const rawSegment = segments[0]; + if (!rawSegment) return; + const isArray = rawSegment.endsWith("[]"); + const key = isArray ? rawSegment.slice(0, -2) : rawSegment; + const next = node[key]; + if (next === undefined) return; + + if (isArray) { + if (!Array.isArray(next)) return; + next.forEach((item, index) => { + const itemPath = `${displayPath}${displayPath ? "." : ""}${key}[${index}]`; + if (segments.length === 1) { + const resolved = resolveLocalizedValue(item, lang, fallback); + if (resolved === null || !(typeof item === "object" && item && typeof item[lang] === "string" && item[lang])) { + fallbackPaths.push(itemPath); + } + next[index] = resolved; + } else { + visit(item, segments.slice(1), itemPath); + } + }); + return; + } + + const currentPath = `${displayPath}${displayPath ? "." : ""}${key}`; + if (segments.length === 1) { + if (Array.isArray(next)) { + node[key] = next.map((item, index) => { + const resolved = resolveLocalizedValue(item, lang, fallback); + if (resolved === null || !(typeof item === "object" && item && typeof item[lang] === "string" && item[lang])) { + fallbackPaths.push(`${currentPath}[${index}]`); + } + return resolved; + }); + } else { + const resolved = resolveLocalizedValue(next, lang, fallback); + if (resolved === null || !(typeof next === "object" && next && typeof next[lang] === "string" && next[lang])) { + fallbackPaths.push(currentPath); + } + node[key] = resolved; + } + return; + } + visit(next, segments.slice(1), currentPath); + }; + + paths.forEach((path) => visit(result, path.split("."), "")); + return { payload: result, fallbackPaths: [...new Set(fallbackPaths)] }; +} + +export function mergeSectionTranslations(payload: any, translations: any, paths: readonly string[], lang: string) { + const result = structuredClone(payload); + const translatedPaths: string[] = []; + + const visit = (target: any, source: any, segments: string[], displayPath: string) => { + if (!target || !source || segments.length === 0) return; + const rawSegment = segments[0]; + if (!rawSegment) return; + const isArray = rawSegment.endsWith("[]"); + const key = isArray ? rawSegment.slice(0, -2) : rawSegment; + const targetValue = target[key]; + const sourceValue = source[key]; + if (targetValue === undefined || sourceValue === undefined) return; + + if (isArray) { + if (!Array.isArray(targetValue) || !Array.isArray(sourceValue) || targetValue.length !== sourceValue.length) { + throw new Error(`Translation array shape mismatch at ${key}`); + } + targetValue.forEach((item, index) => { + const itemPath = `${displayPath}${displayPath ? "." : ""}${key}[${index}]`; + if (segments.length === 1) { + if (typeof sourceValue[index] !== "string") throw new Error(`Translation must be a string at ${itemPath}`); + targetValue[index] = typeof item === "string" + ? { th: item, [lang]: sourceValue[index] } + : { ...item, [lang]: sourceValue[index] }; + translatedPaths.push(itemPath); + } else { + visit(item, sourceValue[index], segments.slice(1), itemPath); + } + }); + return; + } + + const currentPath = `${displayPath}${displayPath ? "." : ""}${key}`; + if (segments.length === 1) { + if (Array.isArray(targetValue)) { + if (!Array.isArray(sourceValue) || targetValue.length !== sourceValue.length) { + throw new Error(`Translation array shape mismatch at ${currentPath}`); + } + target[key] = targetValue.map((item, index) => { + if (typeof sourceValue[index] !== "string") throw new Error(`Translation must be a string at ${currentPath}[${index}]`); + translatedPaths.push(`${currentPath}[${index}]`); + return typeof item === "string" ? { th: item, [lang]: sourceValue[index] } : { ...item, [lang]: sourceValue[index] }; + }); + } else { + if (typeof sourceValue !== "string") throw new Error(`Translation must be a string at ${currentPath}`); + target[key] = typeof targetValue === "string" + ? { th: targetValue, [lang]: sourceValue } + : { ...targetValue, [lang]: sourceValue }; + translatedPaths.push(currentPath); + } + return; + } + visit(targetValue, sourceValue, segments.slice(1), currentPath); + }; + + paths.forEach((path) => visit(result, translations, path.split("."), "")); + return { payload: result, translatedPaths }; +} + export const pagesRoute = new Elysia({ prefix: "/pages" }) .get("/", async ({ query }) => { const view = String((query as any)?.view || "summary").toLowerCase(); @@ -150,6 +276,8 @@ export const pagesRoute = new Elysia({ prefix: "/pages" }) }) .get("/sections/:sectionId", async ({ params: { sectionId }, query, set }) => { const lang = (query as any)?.lang || "th"; + const view = String((query as any)?.view || (lang === "all" ? "all" : "resolved")).toLowerCase(); + const fallback = String((query as any)?.fallback ?? "true").toLowerCase() !== "false"; const hydrate = String((query as any)?.hydrate ?? "true").toLowerCase() !== "false"; try { @@ -160,12 +288,89 @@ export const pagesRoute = new Elysia({ prefix: "/pages" }) set.status = 404; return { error: "Section not found" }; } - return hydrate ? await hydrateSectionPayload(section, lang) : section; + const hydrated = hydrate ? await hydrateSectionPayload(section, lang) : section; + if (view === "all" || lang === "all") return hydrated; + const paths = PAGE_SECTION_LOCALIZED_PATHS[section.type as SectionType] ?? []; + const resolved = transformLocalizedPaths(hydrated.payload, paths, lang, fallback); + return { + ...hydrated, + payload: resolved.payload, + locale: { + lang, + fallback, + missingPaths: fallback ? [] : resolved.fallbackPaths, + fallbackPaths: fallback ? resolved.fallbackPaths : [], + }, + }; } catch (err) { set.status = 500; return { error: "Failed to fetch page section" }; } }) + .get("/translation-status/:pageId", async ({ params: { pageId }, query, set }) => { + const id = Number(pageId); + const lang = String((query as any)?.lang || ""); + const supportedLangs = ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"]; + if (!Number.isInteger(id) || id <= 0) { + set.status = 400; + return { success: false, error: "Invalid page ID" }; + } + if (!supportedLangs.includes(lang)) { + set.status = 400; + return { + success: false, + error: "Unsupported language", + supportedLanguages: supportedLangs, + }; + } + + const page = await prisma.page.findUnique({ + where: { id }, + select: { + id: true, + slug: true, + sections: { + orderBy: { order: "asc" }, + select: { id: true, type: true, order: true, payload: true }, + }, + }, + }); + if (!page) { + set.status = 404; + return { success: false, error: "Page not found" }; + } + + const sections = page.sections.map((section: { + id: number; + type: string; + order: number; + payload: unknown; + }) => { + const paths = PAGE_SECTION_LOCALIZED_PATHS[section.type as SectionType] ?? []; + const { fallbackPaths } = transformLocalizedPaths(section.payload, paths, lang, false); + return { + sectionId: section.id, + type: section.type, + order: section.order, + complete: fallbackPaths.length === 0, + missingPaths: fallbackPaths, + }; + }); + const missingSections = sections.filter((section: { + complete: boolean; + }) => !section.complete); + + return { + success: true, + pageId: page.id, + pageSlug: page.slug, + lang, + complete: missingSections.length === 0, + translatedSections: sections.length - missingSections.length, + totalSections: sections.length, + missingSections, + }; + }) .post( "/newsletter", async ({ body, set }) => { @@ -195,6 +400,128 @@ export const pagesRoute = new Elysia({ prefix: "/pages" }) }, ) .use(adminOnly) + .patch( + "/sections/:sectionId/translations/:lang", + async ({ params: { sectionId, lang }, body, set }) => { + const id = Number(sectionId); + const supportedLangs = ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"]; + const input = body as { + translations?: unknown; + expectedUpdatedAt?: string; + }; + + if (!Number.isInteger(id) || id <= 0) { + set.status = 400; + return { success: false, error: "Invalid section ID", partialWrite: false }; + } + if (!supportedLangs.includes(lang)) { + set.status = 400; + return { + success: false, + error: "Unsupported language", + supportedLanguages: supportedLangs, + partialWrite: false, + }; + } + if (!input.translations || typeof input.translations !== "object") { + set.status = 400; + return { + success: false, + error: "translations must be a JSON object", + partialWrite: false, + }; + } + + const section = await prisma.pageSection.findUnique({ where: { id } }); + if (!section) { + set.status = 404; + return { success: false, error: "Section not found", partialWrite: false }; + } + if ( + input.expectedUpdatedAt && + section.updatedAt.toISOString() !== input.expectedUpdatedAt + ) { + set.status = 409; + return { + success: false, + code: "SECTION_VERSION_CONFLICT", + error: "Section changed after it was read", + sectionId: id, + expectedUpdatedAt: input.expectedUpdatedAt, + actualUpdatedAt: section.updatedAt.toISOString(), + partialWrite: false, + }; + } + + const paths = PAGE_SECTION_LOCALIZED_PATHS[section.type as SectionType] ?? []; + if (paths.length === 0) { + set.status = 400; + return { + success: false, + code: "SECTION_TRANSLATION_UNSUPPORTED", + error: `Section type ${section.type} has no registered localized fields`, + partialWrite: false, + }; + } + + let merged; + try { + merged = mergeSectionTranslations( + section.payload, + input.translations, + paths, + lang, + ); + } catch (error) { + set.status = 400; + return { + success: false, + code: "SECTION_TRANSLATION_SHAPE_MISMATCH", + error: error instanceof Error ? error.message : "Invalid translation shape", + partialWrite: false, + }; + } + if (merged.translatedPaths.length === 0) { + set.status = 400; + return { + success: false, + code: "NO_TRANSLATABLE_FIELDS", + error: "No registered localized fields were provided", + allowedPaths: paths, + partialWrite: false, + }; + } + + const validationResult = validateSectionPayload(section.type, merged.payload); + if (!validationResult.success) { + set.status = 400; + return { + success: false, + code: "SECTION_PAYLOAD_INVALID", + error: "Merged section payload failed validation", + validationIssues: summarizeValidationIssues(validationResult.issues || []), + partialWrite: false, + }; + } + + const updated = await prisma.pageSection.update({ + where: { id }, + data: { payload: validationResult.data }, + select: { id: true, type: true, updatedAt: true }, + }); + + return { + success: true, + sectionId: updated.id, + sectionType: updated.type, + lang, + translatedPaths: merged.translatedPaths, + preservedOtherLanguages: true, + updatedAt: updated.updatedAt.toISOString(), + }; + }, + { body: t.Any() }, + ) .post( "/with-section", async ({ body, set }) => { diff --git a/apps/mcp-server/section-toolkit.test.ts b/apps/mcp-server/section-toolkit.test.ts index 447756a..cc2ef10 100644 --- a/apps/mcp-server/section-toolkit.test.ts +++ b/apps/mcp-server/section-toolkit.test.ts @@ -270,4 +270,130 @@ describe("Page Builder block contracts", () => { ], }); }); + + test("get_page_section_translation requests one language with source fallback", async () => { + const calls: string[] = []; + const api = { + get: async (url: string) => { + calls.push(url); + return { + data: { + id: 41, + type: "ARTICLE_REFERENCES", + payload: { + title: "参考资料", + items: [{ title: "精密冲裁技术", note: null, url: "https://example.com" }], + }, + locale: { + lang: "zh", + fallback: true, + missingPaths: [], + fallbackPaths: ["items[0].note"], + }, + updatedAt: "2026-07-24T10:00:00.000Z", + }, + }; + }, + } as any; + + const result = await handleSectionTool(api, "get_page_section_translation", { + sectionId: 41, + lang: "zh", + }); + const response = JSON.parse(result?.content[0]?.text ?? "{}"); + + expect(calls).toEqual([ + "/pages/sections/41?lang=zh&view=resolved&fallback=true", + ]); + expect(response).toEqual({ + sectionId: 41, + sectionType: "ARTICLE_REFERENCES", + lang: "zh", + payload: { + title: "参考资料", + items: [{ title: "精密冲裁技术", note: null, url: "https://example.com" }], + }, + missingPaths: [], + fallbackPaths: ["items[0].note"], + updatedAt: "2026-07-24T10:00:00.000Z", + }); + }); + + test("update_section_translation sends only translation fields and version", async () => { + const calls: Array<{ url: string; payload: unknown }> = []; + const api = { + patch: async (url: string, payload: unknown) => { + calls.push({ url, payload }); + return { + data: { + success: true, + sectionId: 41, + sectionType: "ARTICLE_REFERENCES", + lang: "zh", + translatedPaths: ["title", "items[0].title"], + preservedOtherLanguages: true, + updatedAt: "2026-07-24T10:01:00.000Z", + }, + }; + }, + } as any; + const translations = { + title: "参考资料", + items: [{ title: "精密冲裁技术" }], + }; + + const result = await handleSectionTool(api, "update_section_translation", { + sectionId: 41, + lang: "zh", + translations, + expectedUpdatedAt: "2026-07-24T10:00:00.000Z", + }); + const response = JSON.parse(result?.content[0]?.text ?? "{}"); + + expect(calls).toEqual([{ + url: "/pages/sections/41/translations/zh", + payload: { + translations, + expectedUpdatedAt: "2026-07-24T10:00:00.000Z", + }, + }]); + expect(response.preservedOtherLanguages).toBe(true); + expect(response.translatedPaths).toEqual(["title", "items[0].title"]); + }); + + test("get_page_translation_status requests a compact language audit", async () => { + const calls: string[] = []; + const api = { + get: async (url: string) => { + calls.push(url); + return { + data: { + success: true, + pageId: 15, + lang: "zh", + complete: false, + translatedSections: 8, + totalSections: 9, + missingSections: [{ + sectionId: 41, + type: "ARTICLE_REFERENCES", + order: 9, + complete: false, + missingPaths: ["items[0].note"], + }], + }, + }; + }, + } as any; + + const result = await handleSectionTool(api, "get_page_translation_status", { + pageId: 15, + lang: "zh", + }); + const response = JSON.parse(result?.content[0]?.text ?? "{}"); + + expect(calls).toEqual(["/pages/translation-status/15?lang=zh"]); + expect(response.totalSections).toBe(9); + expect(response.missingSections[0].sectionId).toBe(41); + }); }); diff --git a/apps/mcp-server/section-toolkit.ts b/apps/mcp-server/section-toolkit.ts index 777c8d2..6926134 100644 --- a/apps/mcp-server/section-toolkit.ts +++ b/apps/mcp-server/section-toolkit.ts @@ -1264,7 +1264,7 @@ export const SECTION_TOOL_DEFINITIONS = [ }, { name: "get_page_section", - description: "Get a specific page section by ID, returning its hydrated payload resolving linked content like categories or products. Optionally pass lang to control translation.", + description: "Get a specific page section by ID. A specific lang returns localized fields as compact strings with fallback enabled; lang=all returns every stored language map.", inputSchema: { type: "object", properties: { @@ -1274,6 +1274,45 @@ export const SECTION_TOOL_DEFINITIONS = [ required: ["sectionId"] } }, + { + name: "get_page_section_translation", + description: "Read one Page Builder block in exactly one language for translation work. Localized fields become strings; fallback defaults to true so missing translations still include source text and are listed in fallbackPaths.", + inputSchema: { + type: "object", + properties: { + sectionId: { type: "number", description: "The numeric ID of the section to fetch" }, + lang: { type: "string", enum: ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"], description: "Language to return" }, + fallback: { type: "boolean", description: "Use another stored language when the requested language is missing. Default true so the LLM has source text to translate." } + }, + required: ["sectionId", "lang"] + } + }, + { + name: "update_section_translation", + description: "Patch only one language of registered localized fields in a Page Builder block. Other languages and non-translatable configuration are preserved. Pass expectedUpdatedAt from get_page_section_translation to prevent overwriting concurrent edits.", + inputSchema: { + type: "object", + properties: { + sectionId: { type: "number", description: "The numeric ID of the section to translate" }, + lang: { type: "string", enum: ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"], description: "Language being written" }, + translations: { type: "object", description: "Only translated fields, using the same object and array shape returned by the read tool" }, + expectedUpdatedAt: { type: "string", description: "Optional section updatedAt value from the read tool for optimistic concurrency" } + }, + required: ["sectionId", "lang", "translations"] + } + }, + { + name: "get_page_translation_status", + description: "Return compact translation completeness for every Page Builder block on one page, including section IDs and missing localized paths.", + inputSchema: { + type: "object", + properties: { + pageId: { type: "number", description: "The numeric ID of the page" }, + lang: { type: "string", enum: ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"], description: "Language to audit" } + }, + required: ["pageId", "lang"] + } + }, { name: "add_page_section", description: "Add one website-renderable block to an existing dynamic CMS page. Call get_dynamic_page_detail to obtain pageId, then get_page_block_schema for the selected type before composing payload. Use product detail CMS tools for product-native blocks.", @@ -1533,6 +1572,7 @@ export async function handleSectionTool(api: AxiosInstance, name: string, params "validate_page_section_payload", "create_page_with_section", "add_page_section", + "get_page_translation_status", "reorder_page_sections", "delete_dynamic_page", "get_product_detail_sections", @@ -1617,6 +1657,45 @@ export async function handleSectionTool(api: AxiosInstance, name: string, params const res = await api.get(`/pages/sections/${params.sectionId}${langParam}`); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } + case "get_page_section_translation": { + const fallback = params.fallback !== false; + const res = await api.get( + `/pages/sections/${params.sectionId}?lang=${encodeURIComponent(params.lang)}&view=resolved&fallback=${fallback}`, + ); + return { + content: [{ + type: "text", + text: JSON.stringify({ + sectionId: res.data?.id ?? params.sectionId, + sectionType: res.data?.type, + lang: params.lang, + payload: res.data?.payload, + missingPaths: res.data?.locale?.missingPaths ?? [], + fallbackPaths: res.data?.locale?.fallbackPaths ?? [], + updatedAt: res.data?.updatedAt, + }, null, 2), + }], + }; + } + case "update_section_translation": { + const res = await api.patch( + `/pages/sections/${params.sectionId}/translations/${encodeURIComponent(params.lang)}`, + { + translations: params.translations, + ...(params.expectedUpdatedAt ? { expectedUpdatedAt: params.expectedUpdatedAt } : {}), + }, + ); + return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; + } + case "get_page_translation_status": { + if (params.pageId === undefined) { + throw new Error(`Validation Error: 'pageId' is required for tool '${name}'`); + } + const res = await api.get( + `/pages/translation-status/${params.pageId}?lang=${encodeURIComponent(params.lang)}`, + ); + return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; + } case "add_page_section": { if (params.pageId === undefined) { throw new Error(`Validation Error: 'pageId' is required for tool '${name}'`); diff --git a/packages/types/index.ts b/packages/types/index.ts index 149641f..d000415 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -758,6 +758,43 @@ export const PAGE_SECTION_TYPES = [ ] as const; export type SectionType = typeof PAGE_SECTION_TYPES[number]; +export const PAGE_SECTION_LOCALIZED_PATHS: Record = { + HERO: ['tagline', 'title', 'description', 'buttonText'], + ANNOUNCEMENT_BAR: ['slides[]'], + NEWSLETTER: ['title', 'description', 'buttonText', 'placeholderText', 'privacyText'], + EDITORIAL_TILE: ['title', 'description', 'buttonText'], + EDITORIAL_CAROUSEL: ['title', 'slides[].category', 'slides[].title', 'slides[].description', 'slides[].buttonText'], + PRODUCTS: ['title', 'subtitle'], + CATEGORIES: ['title', 'subtitle'], + TEXT: ['title', 'content'], + BANNER: ['title', 'description', 'buttonText'], + HERO_ARTICLE: ['title', 'subtitle', 'author', 'category'], + RICH_TEXT: ['title', 'content'], + ARTICLE_HEADER: ['title', 'subtitle', 'category', 'author', 'imageAlt'], + ARTICLE_MEDIA: ['heading', 'body', 'imageAlt', 'imageCaption'], + ARTICLE_GALLERY: ['title', 'images[].alt', 'images[].caption'], + ARTICLE_FACTS: ['title', 'items[].title', 'items[].description'], + ARTICLE_VIDEO: ['title', 'caption'], + ARTICLE_REFERENCES: ['title', 'items[].title', 'items[].note'], + ARTICLE_TABLE: ['title', 'columns[]', 'rows[].label', 'rows[].cells[]', 'note'], + ARTICLE_DOWNLOAD: ['title', 'description', 'files[].title'], + ARTICLE_CTA: ['title', 'description', 'primaryText', 'secondaryText'], + SCENT_NOTES_BREAKDOWN: ['title', 'description', 'topNotes', 'heartNotes', 'middleNotes', 'baseNotes'], + QUOTE_CALLOUT: ['quote', 'author', 'role'], + PRODUCT_CAROUSEL: ['title', 'description'], + ACCORDION: ['title', 'categories[].name', 'categories[].questions[].q', 'categories[].questions[].a'], + CONTACT_INFO: ['title', 'address', 'hours'], + CONTACT_FORM: ['title', 'subtitle'], + HERO_V2: ['tagline', 'title', 'description', 'primaryButtonText', 'secondaryButtonText'], + USP_BAR: ['items[].title', 'items[].subtitle'], + PRODUCT_MAIN: ['title', 'subtitle'], + PRODUCT_DETAILS: ['title', 'subtitle'], + PRODUCT_SPECS: ['title', 'subtitle'], + PRODUCT_REVIEWS: ['title', 'subtitle'], + RELATED_PRODUCTS: ['title', 'subtitle'], + CUSTOM: [], +}; + export const PAGE_SECTION_PAYLOAD_SCHEMAS: Record = { HERO: HeroSectionPayloadSchema, ANNOUNCEMENT_BAR: AnnouncementBarSectionPayloadSchema,