// Apply an admin's stored navigation overrides to a hardcoded NAV array. // // The three navs (public header, admin sidebar, player portal) stay declared in // code; this layer only reorders, relabels and hides what is already there. // See docs/website/THEMING_AND_NAV.md §7. // // **This is presentation, never authorization.** The override can carry // `label`, `order`, `hidden` and — admin nav only — `group`, and nothing else. // It cannot introduce a `to`, and it cannot touch `roles`, `feature`, `icon` or // `end`, so the existing role/feature filters in SiteHeader and AdminLayout run // *after* this merge, unchanged, and remain the actual boundary. An override // saying `hidden: false` on a role-gated item still shows nothing to a viewer // whose role check fails: hiding is subtractive here, never additive. // // Fail-safe throughout: anything unrecognized — an unknown `to`, a non-string // label, a group that does not exist — is ignored rather than rejected, so a // stale or hand-edited settings row degrades to the code default instead of // rendering a broken nav. // Two shapes are supported, because two exist: // flat [{ to, label, ... }] — public header, player portal // grouped [{ title?, items: [{ to, label, ... }] }] — admin sidebar function isGrouped(nav) { return nav.length > 0 && nav.every((g) => g && Array.isArray(g.items)) } // A stored override entry is usable only field by field: a bad `label` must not // discard a good `order` alongside it. function cleanEntry(raw, groupTitles) { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null const out = {} if (typeof raw.label === 'string' && raw.label.trim()) out.label = raw.label.trim() if (typeof raw.order === 'number' && Number.isFinite(raw.order)) out.order = raw.order if (raw.hidden === true) out.hidden = true // `group` may only name a section the base nav already declares. Anything else // — a renamed group, a typo, an invented category — is dropped, so an item can // never land in a header that does not exist. if (typeof raw.group === 'string' && groupTitles.has(raw.group)) out.group = raw.group return out } // Sort by effective order, where an item the admin never reordered keeps its // index in the base array as its key. Two tie-breaks, in order: an explicit // order beats a coincidental index (the admin said "first", so first), and two // explicit orders stay in code order (the sort is stable). // // In practice the editor writes an order for every item in a list, the way // drag-and-drop reordering does, so ties are the stale-row case rather than the // normal one. They still have to resolve predictably. function byOrder(items) { return items .map((item, index) => ({ item, key: item.__order ?? index, explicit: item.__order !== undefined })) .sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit)) .map(({ item }) => { const { __order, ...rest } = item return rest }) } // Apply label/hidden/order to one flat list. Returns visible items only, with // the sort key parked on `__order` for byOrder to consume. function mergeItems(items, entries) { const out = [] for (const item of items) { const o = entries.get(item.to) if (o?.hidden) continue // Spread the base item first so `to`, `roles`, `feature`, `icon` and `end` // survive verbatim — the override only ever lands on `label`. out.push({ ...item, ...(o?.label ? { label: o.label } : {}), __order: o?.order }) } return out } /** * @param {Array} baseNav the hardcoded nav — the source of truth for `to`, * `roles`, `feature`, `icon` and `end` * @param {object|null} overrides the parsed settings JSON, keyed by `to`, or * null when the admin never touched this nav * @returns {Array} a new array of the same shape, or `baseNav` itself when there * is nothing to apply */ export function applyNavOverrides(baseNav, overrides) { if (!Array.isArray(baseNav)) return [] // The untouched path, and the one that matters most: no row, a malformed row, // or a row with nothing usable in it all render the nav exactly as coded. if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return baseNav const grouped = isGrouped(baseNav) const groupTitles = new Set( grouped ? baseNav.map((g) => g.title).filter((t) => typeof t === 'string') : [], ) // Keyed by `to`, and only for a `to` the base nav actually declares. An // override for a route that no longer exists is dropped here, so deleting a // route in code can never leave a dangling override that does something // unexpected later. const known = new Set( grouped ? baseNav.flatMap((g) => g.items.map((i) => i.to)) : baseNav.map((i) => i.to), ) const entries = new Map() for (const [to, raw] of Object.entries(overrides)) { if (!known.has(to)) continue const entry = cleanEntry(raw, groupTitles) if (entry && Object.keys(entry).length > 0) entries.set(to, entry) } if (entries.size === 0) return baseNav if (!grouped) return byOrder(mergeItems(baseNav, entries)) // Grouped: an item may also be moved into another *existing* titled section. // Groups keep their coded order — only membership and within-group order move. const moved = new Map() // destination title → items pulled in from elsewhere const kept = baseNav.map((g) => { const items = [] for (const item of g.items) { const o = entries.get(item.to) if (o?.group && o.group !== g.title) { if (!moved.has(o.group)) moved.set(o.group, []) moved.get(o.group).push(item) continue } items.push(item) } return { ...g, items } }) return kept .map((g) => ({ ...g, items: byOrder(mergeItems([...g.items, ...(moved.get(g.title) || [])], entries)), })) // A group whose every item was hidden must not leave an orphaned header. // AdminLayout drops empty groups again after its own role filter; doing it // here too keeps the util correct on its own. .filter((g) => g.items.length > 0) } export default applyNavOverrides