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`.
|
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.
|
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
|
## What to Expect
|
||||||
|
|
||||||
- `PRODUCT_CAROUSEL` may return products even when you did not send them.
|
- `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,
|
HeroV2SectionPayloadSchema,
|
||||||
UspBarSectionPayloadSchema,
|
UspBarSectionPayloadSchema,
|
||||||
CustomSectionPayloadSchema,
|
CustomSectionPayloadSchema,
|
||||||
|
PAGE_SECTION_LOCALIZED_PATHS,
|
||||||
|
type SectionType,
|
||||||
} from "@homaroma/types";
|
} from "@homaroma/types";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { adminOnly } from "../middleware/auth";
|
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" })
|
export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||||
.get("/", async ({ query }) => {
|
.get("/", async ({ query }) => {
|
||||||
const view = String((query as any)?.view || "summary").toLowerCase();
|
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 }) => {
|
.get("/sections/:sectionId", async ({ params: { sectionId }, query, set }) => {
|
||||||
const lang = (query as any)?.lang || "th";
|
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 =
|
const hydrate =
|
||||||
String((query as any)?.hydrate ?? "true").toLowerCase() !== "false";
|
String((query as any)?.hydrate ?? "true").toLowerCase() !== "false";
|
||||||
try {
|
try {
|
||||||
@@ -160,12 +288,89 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
|||||||
set.status = 404;
|
set.status = 404;
|
||||||
return { error: "Section not found" };
|
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) {
|
} catch (err) {
|
||||||
set.status = 500;
|
set.status = 500;
|
||||||
return { error: "Failed to fetch page section" };
|
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(
|
.post(
|
||||||
"/newsletter",
|
"/newsletter",
|
||||||
async ({ body, set }) => {
|
async ({ body, set }) => {
|
||||||
@@ -195,6 +400,128 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.use(adminOnly)
|
.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(
|
.post(
|
||||||
"/with-section",
|
"/with-section",
|
||||||
async ({ body, set }) => {
|
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",
|
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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -1274,6 +1274,45 @@ export const SECTION_TOOL_DEFINITIONS = [
|
|||||||
required: ["sectionId"]
|
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",
|
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.",
|
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",
|
"validate_page_section_payload",
|
||||||
"create_page_with_section",
|
"create_page_with_section",
|
||||||
"add_page_section",
|
"add_page_section",
|
||||||
|
"get_page_translation_status",
|
||||||
"reorder_page_sections",
|
"reorder_page_sections",
|
||||||
"delete_dynamic_page",
|
"delete_dynamic_page",
|
||||||
"get_product_detail_sections",
|
"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}`);
|
const res = await api.get(`/pages/sections/${params.sectionId}${langParam}`);
|
||||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
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": {
|
case "add_page_section": {
|
||||||
if (params.pageId === undefined) {
|
if (params.pageId === undefined) {
|
||||||
throw new Error(`Validation Error: 'pageId' is required for tool '${name}'`);
|
throw new Error(`Validation Error: 'pageId' is required for tool '${name}'`);
|
||||||
|
|||||||
@@ -758,6 +758,43 @@ export const PAGE_SECTION_TYPES = [
|
|||||||
] as const;
|
] as const;
|
||||||
export type SectionType = typeof PAGE_SECTION_TYPES[number];
|
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> = {
|
export const PAGE_SECTION_PAYLOAD_SCHEMAS: Record<SectionType, z.ZodType> = {
|
||||||
HERO: HeroSectionPayloadSchema,
|
HERO: HeroSectionPayloadSchema,
|
||||||
ANNOUNCEMENT_BAR: AnnouncementBarSectionPayloadSchema,
|
ANNOUNCEMENT_BAR: AnnouncementBarSectionPayloadSchema,
|
||||||
|
|||||||
Reference in new issue
Block a user