This commit is contained in:
Your Name committed 2026-07-24 21:45:44 -04:00
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");
});
});
+328 -1
View File
@@ -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 }) => {