x
This commit is contained in:
1 parent
ae3799d8ce
commit
0341b30b6d
9 files changed
+947
-51
No files matched your search
+276
-10
@@ -2,6 +2,11 @@ import crypto from "crypto";
|
||||
|
||||
const MENU_NODE_ARRAY_KEYS = ["columns", "links", "items", "children"] as const;
|
||||
const MENU_NODE_VALUE_KEYS = ["label", "title", "url", ...MENU_NODE_ARRAY_KEYS] as const;
|
||||
const SUPPORTED_MENU_LOCALES = ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"] as const;
|
||||
const PLACEHOLDER_TEXTS = new Set(["Badge", "Card title", "CTA label", "Section title", "Card subtitle", "New Menu", "New Column", "New Link"]);
|
||||
const MAX_DESKTOP_COLUMNS = 2;
|
||||
const MAX_DESKTOP_TOP_LEVEL_LINKS = 5;
|
||||
const MAX_DESKTOP_INLINE_CHILD_LINKS = 3;
|
||||
|
||||
function isPlainObject(value: any) {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
@@ -95,6 +100,196 @@ function describeMenuNode(node: any) {
|
||||
return "menu node";
|
||||
}
|
||||
|
||||
function getMenuNodeType(node: any, parentType?: string) {
|
||||
if (!isPlainObject(node)) return "invalid";
|
||||
if (Array.isArray(node.columns)) return "root";
|
||||
if (parentType === "root") return "column";
|
||||
if (parentType === "column" || parentType === "link") return "link";
|
||||
if (Array.isArray(node.links)) return "column";
|
||||
if (typeof node.url === "string") return "link";
|
||||
return "node";
|
||||
}
|
||||
|
||||
function listLocalizedIssues(value: any, path: string, requiredBase = true) {
|
||||
const issues: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (value === undefined) {
|
||||
issues.push(`${path}: missing localized text`);
|
||||
return { issues, warnings };
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
if (!value.trim()) issues.push(`${path}: text must not be empty`);
|
||||
return { issues, warnings };
|
||||
}
|
||||
|
||||
if (!isPlainObject(value)) {
|
||||
issues.push(`${path}: text must be a string or localized object`);
|
||||
return { issues, warnings };
|
||||
}
|
||||
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!(SUPPORTED_MENU_LOCALES as readonly string[]).includes(key)) {
|
||||
issues.push(`${path}.${key}: unsupported key; allowed locale keys are ${SUPPORTED_MENU_LOCALES.join(", ")}`);
|
||||
} else if (typeof value[key] !== "string" || !value[key].trim()) {
|
||||
issues.push(`${path}.${key}: translation must be a non-empty string`);
|
||||
} else if (PLACEHOLDER_TEXTS.has(value[key].trim())) {
|
||||
warnings.push(`${path}.${key}: placeholder text "${value[key]}" should be replaced`);
|
||||
}
|
||||
}
|
||||
|
||||
if (requiredBase && !value.th && !value.en) {
|
||||
issues.push(`${path}: provide at least th or en so website fallback has a stable label`);
|
||||
}
|
||||
|
||||
for (const locale of SUPPORTED_MENU_LOCALES) {
|
||||
if (!value[locale]) {
|
||||
warnings.push(`${path}: missing ${locale} translation`);
|
||||
}
|
||||
}
|
||||
|
||||
return { issues, warnings };
|
||||
}
|
||||
|
||||
function normalizeUrlIssue(url: any, path: string) {
|
||||
if (typeof url !== "string" || !url.trim()) {
|
||||
return `${path}: url must be a non-empty string`;
|
||||
}
|
||||
const trimmed = url.trim();
|
||||
if (
|
||||
!trimmed.startsWith("/") &&
|
||||
!trimmed.startsWith("https://") &&
|
||||
!trimmed.startsWith("http://") &&
|
||||
!trimmed.startsWith("mailto:") &&
|
||||
!trimmed.startsWith("tel:")
|
||||
) {
|
||||
return `${path}: url must start with /, http(s)://, mailto:, or tel:`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getNodePath(payload: any[], nodeId?: string) {
|
||||
if (!nodeId) return null;
|
||||
const found = findNodeAndParent(payload, nodeId);
|
||||
if (!found) return null;
|
||||
const segments: string[] = [];
|
||||
let current: any = found.node;
|
||||
|
||||
const findPath = (list: any[], target: any, base: string): boolean => {
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const item = list[i];
|
||||
const currentPath = `${base}[${i}]`;
|
||||
if (item === target) {
|
||||
segments.push(currentPath);
|
||||
return true;
|
||||
}
|
||||
for (const key of MENU_NODE_ARRAY_KEYS) {
|
||||
if (Array.isArray(item?.[key]) && findPath(item[key], target, `${currentPath}.${key}`)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
findPath(payload, current, "payload");
|
||||
return segments[0] || null;
|
||||
}
|
||||
|
||||
function summarizeMenuTree(payload: any[]) {
|
||||
let totalNodes = 0;
|
||||
let rootCount = 0;
|
||||
let columnCount = 0;
|
||||
let linkCount = 0;
|
||||
let featuredCount = 0;
|
||||
let hiddenDesktopColumns = 0;
|
||||
let hiddenDesktopLinks = 0;
|
||||
let hiddenDesktopChildLinks = 0;
|
||||
|
||||
const walk = (nodes: any[], parentType?: string) => {
|
||||
nodes.forEach((node) => {
|
||||
if (!isPlainObject(node)) return;
|
||||
const type = getMenuNodeType(node, parentType);
|
||||
totalNodes += 1;
|
||||
if (type === "root") {
|
||||
rootCount += 1;
|
||||
if (isPlainObject(node.featured) && node.featured.enabled !== false) featuredCount += 1;
|
||||
const columns = Array.isArray(node.columns) ? node.columns : [];
|
||||
hiddenDesktopColumns += Math.max(columns.length - MAX_DESKTOP_COLUMNS, 0);
|
||||
walk(columns, "root");
|
||||
} else if (type === "column") {
|
||||
columnCount += 1;
|
||||
const links = Array.isArray(node.links) ? node.links : [];
|
||||
hiddenDesktopLinks += Math.max(links.length - MAX_DESKTOP_TOP_LEVEL_LINKS, 0);
|
||||
walk(links, "column");
|
||||
} else if (type === "link") {
|
||||
linkCount += 1;
|
||||
const childLinks = Array.isArray(node.links) ? node.links : [];
|
||||
hiddenDesktopChildLinks += Math.max(childLinks.length - MAX_DESKTOP_INLINE_CHILD_LINKS, 0);
|
||||
walk(childLinks, "link");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
walk(payload);
|
||||
return {
|
||||
rootCount,
|
||||
columnCount,
|
||||
linkCount,
|
||||
totalNodes,
|
||||
featuredCount,
|
||||
desktopLimits: {
|
||||
maxVisibleColumnsPerRoot: MAX_DESKTOP_COLUMNS,
|
||||
maxVisibleLinksPerColumn: MAX_DESKTOP_TOP_LEVEL_LINKS,
|
||||
maxVisibleChildLinksPerLink: MAX_DESKTOP_INLINE_CHILD_LINKS,
|
||||
hiddenColumns: hiddenDesktopColumns,
|
||||
hiddenLinks: hiddenDesktopLinks,
|
||||
hiddenChildLinks: hiddenDesktopChildLinks,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildMenuDiff(before: any, after: any, basePath = "payload", limit = 80) {
|
||||
const diff: any[] = [];
|
||||
|
||||
const walk = (a: any, b: any, path: string) => {
|
||||
if (diff.length >= limit) return;
|
||||
if (JSON.stringify(a) === JSON.stringify(b)) return;
|
||||
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
const aArr = Array.isArray(a) ? a : [];
|
||||
const bArr = Array.isArray(b) ? b : [];
|
||||
const max = Math.max(aArr.length, bArr.length);
|
||||
for (let i = 0; i < max; i++) walk(aArr[i], bArr[i], `${path}[${i}]`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlainObject(a) || isPlainObject(b)) {
|
||||
const keys = new Set([...Object.keys(isPlainObject(a) ? a : {}), ...Object.keys(isPlainObject(b) ? b : {})]);
|
||||
for (const key of keys) walk(a?.[key], b?.[key], `${path}.${key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
diff.push({ path, before: a ?? null, after: b ?? null });
|
||||
};
|
||||
|
||||
walk(before, after, basePath);
|
||||
return { entries: diff, truncated: diff.length >= limit };
|
||||
}
|
||||
|
||||
function buildMenuMutationPreview(before: any[], after: any[], changedNodeId?: string) {
|
||||
const beforePayload = ensureMenuPayloadArray(before);
|
||||
const afterPayload = ensureMenuPayloadArray(after);
|
||||
const diff = buildMenuDiff(beforePayload, afterPayload);
|
||||
const changedPath = getNodePath(afterPayload, changedNodeId) || getNodePath(beforePayload, changedNodeId);
|
||||
return {
|
||||
before: summarizeMenuTree(beforePayload),
|
||||
after: summarizeMenuTree(afterPayload),
|
||||
changedNodeId: changedNodeId || null,
|
||||
changedPath,
|
||||
diff,
|
||||
};
|
||||
}
|
||||
|
||||
function validateMenuNodeData(nodeData: any, parentId?: string | null) {
|
||||
const issues: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
@@ -110,13 +305,18 @@ function validateMenuNodeData(nodeData: any, parentId?: string | null) {
|
||||
|
||||
for (const key of ["label", "title"] as const) {
|
||||
const value = nodeData[key];
|
||||
if (value !== undefined && !isPlainObject(value) && typeof value !== "string") {
|
||||
issues.push(`${key} must be a string or localized object`);
|
||||
if (value !== undefined) {
|
||||
const textCheck = listLocalizedIssues(value, key, false);
|
||||
issues.push(...textCheck.issues);
|
||||
warnings.push(...textCheck.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
if (nodeData.url !== undefined && typeof nodeData.url !== "string") {
|
||||
issues.push("url must be a string when provided");
|
||||
} else if (nodeData.url !== undefined) {
|
||||
const urlIssue = normalizeUrlIssue(nodeData.url, "url");
|
||||
if (urlIssue) issues.push(urlIssue);
|
||||
}
|
||||
|
||||
for (const key of MENU_NODE_ARRAY_KEYS) {
|
||||
@@ -148,8 +348,14 @@ function validateMenuTranslations(translations: any) {
|
||||
return { issues, warnings };
|
||||
}
|
||||
for (const [key, value] of Object.entries(translations)) {
|
||||
if (!(SUPPORTED_MENU_LOCALES as readonly string[]).includes(key)) {
|
||||
issues.push(`unsupported locale "${key}"; allowed locale keys are ${SUPPORTED_MENU_LOCALES.join(", ")}`);
|
||||
continue;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
issues.push(`translation for "${key}" must be a string`);
|
||||
} else if (!value.trim()) {
|
||||
issues.push(`translation for "${key}" must not be empty`);
|
||||
}
|
||||
}
|
||||
return { issues, warnings };
|
||||
@@ -229,44 +435,102 @@ function ensureMenuPayloadArray(value: any) {
|
||||
function validateMenuTreeSnapshot(payload: any[]) {
|
||||
const issues: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const diagnostics: Array<{ severity: "critical" | "high" | "medium" | "low"; path: string; nodeId?: string; message: string; recommendedFix: string }> = [];
|
||||
const seenNodeIds = new Set<string>();
|
||||
|
||||
const walk = (value: any, path: string) => {
|
||||
const addIssue = (severity: "critical" | "high" | "medium" | "low", path: string, nodeId: string | undefined, message: string, recommendedFix: string) => {
|
||||
const text = `${path}: ${message}`;
|
||||
if (severity === "critical" || severity === "high") issues.push(text);
|
||||
else warnings.push(text);
|
||||
diagnostics.push({ severity, path, nodeId, message, recommendedFix });
|
||||
};
|
||||
|
||||
const walk = (value: any, path: string, parentType?: string) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((child, index) => walk(child, `${path}[${index}]`));
|
||||
value.forEach((child, index) => walk(child, `${path}[${index}]`, parentType));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPlainObject(value)) return;
|
||||
|
||||
if (!isStructuralMenuNode(value)) {
|
||||
warnings.push(`${path}: non-structural object present in menu tree`);
|
||||
addIssue("low", path, undefined, "non-structural object present in menu tree", "Remove this object or convert it into a root, column, or link node.");
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeId = typeof value.nodeId === "string" ? value.nodeId : undefined;
|
||||
const nodeType = getMenuNodeType(value, parentType);
|
||||
|
||||
if (value.nodeId && typeof value.nodeId === "string") {
|
||||
if (seenNodeIds.has(value.nodeId)) {
|
||||
issues.push(`${path}: duplicate nodeId ${value.nodeId}`);
|
||||
addIssue("critical", path, value.nodeId, `duplicate nodeId ${value.nodeId}`, "Assign a unique nodeId or let get_mega_menu_tree normalize IDs.");
|
||||
}
|
||||
seenNodeIds.add(value.nodeId);
|
||||
} else {
|
||||
warnings.push(`${path}: missing nodeId`);
|
||||
addIssue("medium", path, undefined, "missing nodeId", "Run get_mega_menu_tree once or update this node through MCP so a nodeId is generated.");
|
||||
}
|
||||
|
||||
if (nodeType === "root") {
|
||||
const labelCheck = listLocalizedIssues(value.label, `${path}.label`);
|
||||
labelCheck.issues.forEach((message) => addIssue("high", path, nodeId, message.replace(`${path}.label: `, ""), "Provide a localized root label with at least th or en."));
|
||||
labelCheck.warnings.forEach((message) => addIssue("low", path, nodeId, message.replace(`${path}.label: `, ""), "Fill missing locale labels, especially lo and it."));
|
||||
|
||||
const urlIssue = normalizeUrlIssue(value.url, `${path}.url`);
|
||||
if (urlIssue) addIssue("high", path, nodeId, urlIssue.replace(`${path}.url: `, ""), "Set a valid root URL such as /shop.");
|
||||
|
||||
const columns = Array.isArray(value.columns) ? value.columns : [];
|
||||
if (columns.length === 0 && isPlainObject(value.featured) && value.featured.enabled !== false) {
|
||||
addIssue("medium", path, nodeId, "featured card is enabled on a root item with no columns", "Disable featured.enabled or add at least one column.");
|
||||
}
|
||||
if (columns.length > MAX_DESKTOP_COLUMNS) {
|
||||
addIssue("medium", path, nodeId, `${columns.length - MAX_DESKTOP_COLUMNS} columns are hidden on desktop`, "Keep the most important columns first or reduce to two columns.");
|
||||
}
|
||||
if (isPlainObject(value.featured)) {
|
||||
for (const key of ["sectionTitle", "badge", "title", "subtitle", "ctaLabel"] as const) {
|
||||
if (value.featured[key] !== undefined) {
|
||||
const check = listLocalizedIssues(value.featured[key], `${path}.featured.${key}`, false);
|
||||
check.issues.forEach((message) => addIssue("medium", path, nodeId, message.replace(`${path}.featured.${key}: `, `featured.${key}: `), "Use only supported locale keys and non-empty strings."));
|
||||
check.warnings.forEach((message) => addIssue("low", path, nodeId, message.replace(`${path}.featured.${key}: `, `featured.${key}: `), "Fill missing featured-card locales or remove placeholder text."));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (nodeType === "column") {
|
||||
const titleCheck = listLocalizedIssues(value.title, `${path}.title`);
|
||||
titleCheck.issues.forEach((message) => addIssue("high", path, nodeId, message.replace(`${path}.title: `, ""), "Provide a localized column title with at least th or en."));
|
||||
titleCheck.warnings.forEach((message) => addIssue("low", path, nodeId, message.replace(`${path}.title: `, ""), "Fill missing locale titles, especially lo and it."));
|
||||
const links = Array.isArray(value.links) ? value.links : [];
|
||||
if (links.length === 0) {
|
||||
addIssue("medium", path, nodeId, "column has no links", "Add at least one link or remove the column.");
|
||||
}
|
||||
if (links.length > MAX_DESKTOP_TOP_LEVEL_LINKS) {
|
||||
addIssue("medium", path, nodeId, `${links.length - MAX_DESKTOP_TOP_LEVEL_LINKS} links are hidden on desktop`, "Keep the most important links in the first five positions.");
|
||||
}
|
||||
} else if (nodeType === "link") {
|
||||
const labelCheck = listLocalizedIssues(value.label, `${path}.label`);
|
||||
labelCheck.issues.forEach((message) => addIssue("high", path, nodeId, message.replace(`${path}.label: `, ""), "Provide a localized link label with at least th or en."));
|
||||
labelCheck.warnings.forEach((message) => addIssue("low", path, nodeId, message.replace(`${path}.label: `, ""), "Fill missing locale labels, especially lo and it."));
|
||||
const urlIssue = normalizeUrlIssue(value.url, `${path}.url`);
|
||||
if (urlIssue) addIssue("high", path, nodeId, urlIssue.replace(`${path}.url: `, ""), "Set a valid link URL.");
|
||||
const childLinks = Array.isArray(value.links) ? value.links : [];
|
||||
if (childLinks.length > MAX_DESKTOP_INLINE_CHILD_LINKS) {
|
||||
addIssue("medium", path, nodeId, `${childLinks.length - MAX_DESKTOP_INLINE_CHILD_LINKS} child links are collapsed behind '+ more' on desktop`, "Keep the most important child links in the first three positions.");
|
||||
}
|
||||
}
|
||||
|
||||
const hasChildren = MENU_NODE_ARRAY_KEYS.some((key) => Array.isArray(value[key]));
|
||||
if (!hasChildren && !value.url) {
|
||||
warnings.push(`${path}: container node has no children and no url`);
|
||||
addIssue("medium", path, nodeId, "container node has no children and no url", "Add children, set a url, or remove the node.");
|
||||
}
|
||||
|
||||
for (const key of MENU_NODE_ARRAY_KEYS) {
|
||||
if (Array.isArray(value[key])) {
|
||||
walk(value[key], `${path}.${key}`);
|
||||
walk(value[key], `${path}.${key}`, nodeType);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(payload, "payload");
|
||||
return { issues, warnings };
|
||||
return { issues, warnings, diagnostics, summary: summarizeMenuTree(payload), supportedLocales: SUPPORTED_MENU_LOCALES };
|
||||
}
|
||||
|
||||
function applyMenuAddNode(tree: any[], parentId: string | undefined, nodeData: any): MenuTreeMutationResult {
|
||||
@@ -464,6 +728,7 @@ function applyBulkMenuOperations(tree: any[], operations: any[]) {
|
||||
export {
|
||||
MENU_NODE_ARRAY_KEYS,
|
||||
MENU_NODE_VALUE_KEYS,
|
||||
SUPPORTED_MENU_LOCALES,
|
||||
type MenuTreeMutationResult,
|
||||
applyBulkMenuOperations,
|
||||
applyMenuAddNode,
|
||||
@@ -474,6 +739,7 @@ export {
|
||||
collectMenuLinksFromTree,
|
||||
createMenuSnapshot,
|
||||
describeMenuNode,
|
||||
buildMenuMutationPreview,
|
||||
ensureMenuPayloadArray,
|
||||
findNodeAndParent,
|
||||
getChildrenArray,
|
||||
|
||||
Reference in new issue
Block a user