Files
homaroma/apps/mcp-server/menu-tools.ts
T
2026-07-23 04:41:35 -04:00

755 lines
28 KiB
TypeScript

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);
}
function isStructuralMenuNode(value: any) {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
return MENU_NODE_VALUE_KEYS.some((key) => Object.prototype.hasOwnProperty.call(value, key));
}
function cloneMenuValue<T>(value: T): T {
const cloneFn = (globalThis as any).structuredClone;
if (typeof cloneFn === "function") {
return cloneFn(value);
}
return JSON.parse(JSON.stringify(value));
}
function getChildrenArray(node: any, isRoot?: boolean): any[] {
if (Array.isArray(node.columns)) return node.columns;
if (Array.isArray(node.links)) return node.links;
if (Array.isArray(node.items)) return node.items;
if (Array.isArray(node.children)) return node.children;
if (isRoot) {
node.columns = [];
return node.columns;
}
node.links = [];
return node.links;
}
function findNodeAndParent(tree: any[], targetId: string, parent: any = null, list: any[] = tree): { node: any; parent: any; list: any[]; index: number } | null {
for (let i = 0; i < tree.length; i++) {
const item = tree[i];
if (item && typeof item === "object") {
if (item.nodeId === targetId) {
return { node: item, parent, list: tree, index: i };
}
for (const key of Object.keys(item)) {
if (Array.isArray(item[key])) {
const res = findNodeAndParent(item[key], targetId, item, item[key]);
if (res) return res;
}
}
}
}
return null;
}
function collectMenuLinksFromTree(tree: any): Array<{ url: string; label?: any; source: "payload" }> {
const links: Array<{ url: string; label?: any; source: "payload" }> = [];
const visited = new WeakSet<object>();
const walk = (value: any) => {
if (Array.isArray(value)) {
value.forEach(walk);
return;
}
if (!value || typeof value !== "object") return;
if (visited.has(value)) return;
visited.add(value);
if (!isStructuralMenuNode(value)) return;
if (typeof value.url === "string" && value.url.trim()) {
links.push({ url: value.url, label: value.label ?? value.title, source: "payload" });
}
for (const key of MENU_NODE_ARRAY_KEYS) {
if (Array.isArray(value[key])) {
walk(value[key]);
}
}
};
walk(tree);
return links;
}
function listStructuralKeys(node: any) {
return MENU_NODE_ARRAY_KEYS.filter((key) => Array.isArray(node?.[key]));
}
function describeMenuNode(node: any) {
if (!isPlainObject(node)) return "invalid node";
if (typeof node.url === "string" && node.url.trim()) return `link ${node.url}`;
const structuralKeys = listStructuralKeys(node);
if (structuralKeys.includes("columns")) return "column container";
if (structuralKeys.length > 0) return `container (${structuralKeys.join(", ")})`;
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[] = [];
if (!isPlainObject(nodeData)) {
issues.push("nodeData must be a plain object");
return { issues, warnings };
}
const hasUrl = typeof nodeData.url === "string" && nodeData.url.trim();
const hasLabel = nodeData.label !== undefined || nodeData.title !== undefined;
const structuralKeys = listStructuralKeys(nodeData);
for (const key of ["label", "title"] as const) {
const value = nodeData[key];
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) {
if (nodeData[key] !== undefined && !Array.isArray(nodeData[key])) {
issues.push(`${key} must be an array when provided`);
}
}
if (parentId === "root" && hasUrl && structuralKeys.length === 0) {
issues.push("cannot add a leaf link directly under root; create a column container first");
}
if (hasUrl && !hasLabel) {
warnings.push("link node does not include a label/title yet");
}
if (nodeData.translations !== undefined && !isPlainObject(nodeData.translations) && !Array.isArray(nodeData.translations)) {
issues.push("translations must be an object or array when provided");
}
return { issues, warnings };
}
function validateMenuTranslations(translations: any) {
const issues: string[] = [];
const warnings: string[] = [];
if (!isPlainObject(translations)) {
issues.push("translations must be a plain object");
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 };
}
function validateMenuItems(items: any) {
const issues: string[] = [];
const warnings: string[] = [];
if (!Array.isArray(items)) {
issues.push("items must be an array");
return { issues, warnings };
}
items.forEach((item, index) => {
if (!isPlainObject(item)) {
issues.push(`items[${index}] must be a plain object`);
return;
}
if (typeof item.url !== "string" || !item.url.trim()) {
issues.push(`items[${index}].url must be a non-empty string`);
}
if (typeof item.label !== "string" || !item.label.trim()) {
warnings.push(`items[${index}] is missing a human-readable label`);
}
if (item.order !== undefined && typeof item.order !== "number") {
issues.push(`items[${index}].order must be a number when provided`);
}
if (item.columnImage !== undefined && item.columnImage !== null && typeof item.columnImage !== "string") {
issues.push(`items[${index}].columnImage must be a string or null when provided`);
}
if (item.hoverImage !== undefined && item.hoverImage !== null && typeof item.hoverImage !== "string") {
issues.push(`items[${index}].hoverImage must be a string or null when provided`);
}
if (item.translations !== undefined) {
if (!Array.isArray(item.translations)) {
issues.push(`items[${index}].translations must be an array when provided`);
} else {
item.translations.forEach((translation: any, translationIndex: number) => {
if (!isPlainObject(translation)) {
issues.push(`items[${index}].translations[${translationIndex}] must be a plain object`);
return;
}
if (typeof translation.langCode !== "string" || !translation.langCode.trim()) {
issues.push(`items[${index}].translations[${translationIndex}].langCode must be a non-empty string`);
}
if (typeof translation.label !== "string" || !translation.label.trim()) {
issues.push(`items[${index}].translations[${translationIndex}].label must be a non-empty string`);
}
});
}
}
});
return { issues, warnings };
}
type MenuTreeMutationResult = {
payload: any[];
warnings: string[];
nodeId?: string;
removedNode?: string;
};
const menuSnapshots = new Map<string, { name: string; payload: any[]; items: any[]; isActive?: boolean; createdAt: string }>();
function ensureMenuPayloadArray(value: any) {
return Array.isArray(value) ? value : [];
}
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 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}]`, parentType));
return;
}
if (!isPlainObject(value)) return;
if (!isStructuralMenuNode(value)) {
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)) {
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 {
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) {
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}`, nodeType);
}
}
};
walk(payload, "payload");
return { issues, warnings, diagnostics, summary: summarizeMenuTree(payload), supportedLocales: SUPPORTED_MENU_LOCALES };
}
function applyMenuAddNode(tree: any[], parentId: string | undefined, nodeData: any): MenuTreeMutationResult {
const payload = cloneMenuValue(ensureMenuPayloadArray(tree));
const nextNode = cloneMenuValue(nodeData);
const warnings: string[] = [];
nextNode.nodeId = crypto.randomUUID();
if (nextNode.translations) {
const labelKey = nextNode.title !== undefined ? "title" : "label";
if (typeof nextNode[labelKey] === "string") {
nextNode[labelKey] = { en: nextNode[labelKey] };
}
nextNode[labelKey] = { ...(nextNode[labelKey] || {}), ...nextNode.translations };
delete nextNode.translations;
}
if (!parentId || parentId === "root") {
payload.push(nextNode);
} else {
const found = findNodeAndParent(payload, parentId);
if (!found) {
throw new Error(`Parent node ${parentId} not found`);
}
const children = getChildrenArray(found.node, found.parent === null);
children.push(nextNode);
}
return { payload, warnings, nodeId: nextNode.nodeId };
}
function applyMenuUpdateTranslation(tree: any[], nodeId: string, translations: any): MenuTreeMutationResult {
const payload = cloneMenuValue(ensureMenuPayloadArray(tree));
const found = findNodeAndParent(payload, nodeId);
if (!found) throw new Error(`Node ${nodeId} not found`);
const labelKey = found.node.title !== undefined ? "title" : "label";
found.node[labelKey] = { ...(found.node[labelKey] || {}), ...translations };
return { payload, warnings: [], nodeId };
}
function applyMenuMoveNode(tree: any[], nodeId: string, newParentId?: string, orderIndex?: number): MenuTreeMutationResult {
const payload = cloneMenuValue(ensureMenuPayloadArray(tree));
const found = findNodeAndParent(payload, nodeId);
if (!found) throw new Error(`Node ${nodeId} not found`);
found.list.splice(found.index, 1);
let targetList = payload;
if (newParentId && newParentId !== "root") {
const newParent = findNodeAndParent(payload, newParentId);
if (!newParent) throw new Error(`New parent ${newParentId} not found`);
targetList = getChildrenArray(newParent.node);
}
if (typeof orderIndex === "number" && orderIndex >= 0) {
targetList.splice(orderIndex, 0, found.node);
} else {
targetList.push(found.node);
}
return { payload, warnings: [], nodeId };
}
function applyMenuRemoveNode(tree: any[], nodeId: string): MenuTreeMutationResult {
const payload = cloneMenuValue(ensureMenuPayloadArray(tree));
const found = findNodeAndParent(payload, nodeId);
if (!found) throw new Error(`Node ${nodeId} not found`);
found.list.splice(found.index, 1);
return { payload, warnings: [], removedNode: describeMenuNode(found.node) };
}
function normalizeMenuPayloadForWrite(payload: any) {
const cloned = cloneMenuValue(ensureMenuPayloadArray(payload));
let modified = false;
const walk = (value: any) => {
if (Array.isArray(value)) {
value.forEach(walk);
return;
}
if (!isPlainObject(value)) return;
if (!isStructuralMenuNode(value)) {
if (Object.prototype.hasOwnProperty.call(value, "nodeId")) {
delete value.nodeId;
modified = true;
}
return;
}
if (!value.nodeId) {
value.nodeId = crypto.randomUUID();
modified = true;
}
for (const key of MENU_NODE_ARRAY_KEYS) {
if (Array.isArray(value[key])) {
walk(value[key]);
}
}
};
walk(cloned);
return { payload: cloned, modified };
}
function createMenuSnapshot(menu: any) {
const snapshotId = crypto.randomUUID();
const payload = cloneMenuValue(ensureMenuPayloadArray(menu?.payload));
const items = cloneMenuValue(Array.isArray(menu?.items) ? menu.items : []);
const snapshot = {
name: menu?.name || "main",
payload,
items,
isActive: menu?.isActive,
createdAt: new Date().toISOString(),
};
menuSnapshots.set(snapshotId, snapshot);
return { snapshotId, ...snapshot };
}
function restoreMenuSnapshot(snapshotId: string) {
const snapshot = menuSnapshots.get(snapshotId);
if (!snapshot) {
throw new Error(`Snapshot ${snapshotId} not found`);
}
return cloneMenuValue(snapshot);
}
function applyBulkMenuOperations(tree: any[], operations: any[]) {
let payload = cloneMenuValue(ensureMenuPayloadArray(tree));
const warnings: string[] = [];
const applied: any[] = [];
for (const [index, operation] of operations.entries()) {
if (!isPlainObject(operation)) {
throw new Error(`operations[${index}] must be a plain object`);
}
let result: MenuTreeMutationResult;
switch (operation.type) {
case "add_node": {
const validation = validateMenuNodeData(operation.nodeData, operation.parentId);
if (validation.issues.length > 0) {
throw new Error(`operations[${index}]: ${validation.issues.join("; ")}`);
}
result = applyMenuAddNode(payload, operation.parentId, operation.nodeData);
warnings.push(...validation.warnings);
applied.push({ type: operation.type, nodeId: result.nodeId });
break;
}
case "update_translation": {
const validation = validateMenuTranslations(operation.translations);
if (validation.issues.length > 0) {
throw new Error(`operations[${index}]: ${validation.issues.join("; ")}`);
}
if (typeof operation.nodeId !== "string" || !operation.nodeId) {
throw new Error(`operations[${index}].nodeId must be a non-empty string`);
}
result = applyMenuUpdateTranslation(payload, operation.nodeId, operation.translations);
warnings.push(...validation.warnings);
applied.push({ type: operation.type, nodeId: result.nodeId });
break;
}
case "move_node": {
if (typeof operation.nodeId !== "string" || !operation.nodeId) {
throw new Error(`operations[${index}].nodeId must be a non-empty string`);
}
result = applyMenuMoveNode(payload, operation.nodeId, operation.newParentId, operation.orderIndex);
applied.push({ type: operation.type, nodeId: result.nodeId });
break;
}
case "remove_node": {
if (typeof operation.nodeId !== "string" || !operation.nodeId) {
throw new Error(`operations[${index}].nodeId must be a non-empty string`);
}
result = applyMenuRemoveNode(payload, operation.nodeId);
applied.push({ type: operation.type, removedNode: result.removedNode });
break;
}
default:
throw new Error(`operations[${index}].type must be one of add_node, update_translation, move_node, remove_node`);
}
payload = result.payload;
warnings.push(...result.warnings);
}
const normalized = normalizeMenuPayloadForWrite(payload);
return { payload: normalized.payload, warnings, applied, normalized };
}
export {
MENU_NODE_ARRAY_KEYS,
MENU_NODE_VALUE_KEYS,
SUPPORTED_MENU_LOCALES,
type MenuTreeMutationResult,
applyBulkMenuOperations,
applyMenuAddNode,
applyMenuMoveNode,
applyMenuRemoveNode,
applyMenuUpdateTranslation,
cloneMenuValue,
collectMenuLinksFromTree,
createMenuSnapshot,
describeMenuNode,
buildMenuMutationPreview,
ensureMenuPayloadArray,
findNodeAndParent,
getChildrenArray,
isPlainObject,
isStructuralMenuNode,
normalizeMenuPayloadForWrite,
restoreMenuSnapshot,
validateMenuItems,
validateMenuNodeData,
validateMenuTranslations,
validateMenuTreeSnapshot,
};