x
This commit is contained in:
1 parent
f8da2cceaf
commit
63bc3bfb1a
6 files changed
+658
-2
No files matched your search
@@ -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 }) => {
|
||||
|
||||
Reference in new issue
Block a user