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(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(); 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(); 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(); 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, };