x
This commit is contained in:
1 parent
f8da2cceaf
commit
63bc3bfb1a
6 files changed
+658
-2
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}'`);
|
||||
|
||||
@@ -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<SectionType, readonly string[]> = {
|
||||
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<SectionType, z.ZodType> = {
|
||||
HERO: HeroSectionPayloadSchema,
|
||||
ANNOUNCEMENT_BAR: AnnouncementBarSectionPayloadSchema,
|
||||
|
||||
Reference in new issue
Block a user