// 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, with the sort key parked on // `__order` for byOrder to consume. // // `keepHidden` is what the admin editor needs and the site must not have: the // editor has to render a hidden row in its right place so it can be un-hidden, // while a layout must simply not render it. Same merge either way, so the two // can never disagree about where an item sits. function mergeItems(items, entries, keepHidden = false) { const out = [] for (const item of items) { const o = entries.get(item.to) if (o?.hidden && !keepHidden) 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 } : {}), ...(keepHidden ? { defaultLabel: item.label, hidden: o?.hidden === true } : {}), __order: o?.order, }) } return out } // The stored overrides, cleaned and keyed, plus the group titles the base nav // declares. Shared by the merge and the editor so both read a row the same way. function readOverrides(baseNav, overrides, grouped) { const groupTitles = new Set( grouped ? baseNav.map((g) => g.title).filter((t) => typeof t === 'string') : [], ) const entries = new Map() if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return { entries, groupTitles } // 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), ) 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) } return { entries, groupTitles } } // Move items whose override names a different existing section. Groups keep // their coded order — only membership and within-group order move. function regroup(baseNav, entries) { 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, moved } } /** * @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 { entries } = readOverrides(baseNav, overrides, grouped) 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. const { kept, moved } = regroup(baseNav, entries) 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) } // ── The admin editor's round trip ──────────────────────────────────────── // // Two functions, inverse to each other, kept in this file rather than beside the // editor screen so the thing that *writes* an override and the thing that // *applies* one can never drift: the rows the admin drags are produced by the // same merge the site renders, hidden ones included. /** * The base nav plus its stored overrides, as editable rows — always in the * grouped shape, so one editor handles both navs. * * Unlike applyNavOverrides this keeps hidden rows (marked `hidden: true`, so * they can be un-hidden) and keeps empty groups (so something can be moved back * into one). Each row carries `defaultLabel`, which is what "reset this label" * restores and what the input shows as its placeholder. * * @param {Array} baseNav the hardcoded nav, flat or grouped * @param {object|null} overrides the parsed settings JSON * @returns {Array<{title: string|null, items: Array}>} */ export function buildNavRows(baseNav, overrides) { if (!Array.isArray(baseNav) || baseNav.length === 0) return [] const grouped = isGrouped(baseNav) const { entries } = readOverrides(baseNav, overrides, grouped) if (!grouped) { return [{ title: null, items: byOrder(mergeItems(baseNav, entries, true)) }] } const { kept, moved } = regroup(baseNav, entries) return kept.map((g) => ({ ...g, title: g.title ?? null, items: byOrder(mergeItems([...g.items, ...(moved.get(g.title) || [])], entries, true)), })) } // Did the admin actually move anything? Comparing the edited sequence with the // coded one is what decides whether orders are written at all: an admin who only // renamed an item should not pin the position of every other one, or a route // added in code later would land in an arbitrary place. // // The base side is restricted to the rows the editor is actually holding: §8.1 // filters the palette to what this admin can themselves see, and an item that // their role or a shard feature kept off the screen is not a reorder. function orderMatchesBase(groups, baseNav) { const flatten = (gs) => gs.flatMap((g) => g.items.map((i) => `${g.title ?? ''}::${i.to}`)) const base = isGrouped(baseNav) ? baseNav.map((g) => ({ title: g.title ?? null, items: g.items })) : [{ title: null, items: baseNav }] const shown = new Set(groups.flatMap((g) => g.items.map((i) => i.to))) const a = flatten(groups) const b = flatten(base.map((g) => ({ ...g, items: g.items.filter((i) => shown.has(i.to)) }))) return a.length === b.length && a.every((v, i) => v === b[i]) } /** * The rows the admin has been editing, back as an overrides object to store. * Only differences from the code default are written — a field that matches the * default is absent, so the row stays a small statement of intent rather than a * snapshot of the nav. * * @param {Array} groups the editor's groups, in their current order * @param {Array} baseNav the hardcoded nav these rows came from * @param {object|null} stored the overrides as loaded, so entries for items * this admin could not see (role- or feature-gated out of their palette) are * carried through rather than silently dropped on save * @returns {object} the overrides to store — `{}` when nothing differs */ export function buildNavOverrides(groups, baseNav, stored = null) { if (!Array.isArray(groups) || !Array.isArray(baseNav)) return {} const grouped = isGrouped(baseNav) const baseItems = new Map( (grouped ? baseNav.flatMap((g) => g.items.map((i) => [i, g.title ?? null])) : baseNav.map((i) => [i, null])).map( ([item, title]) => [item.to, { label: item.label, group: title }], ), ) const out = {} // Carry through what this admin's palette never showed them. An entry for a // `to` the base nav no longer declares is NOT carried: dropping it is the // cleanup, and applyNavOverrides ignores it anyway. const shown = new Set(groups.flatMap((g) => g.items.map((i) => i.to))) if (stored && typeof stored === 'object' && !Array.isArray(stored)) { for (const [to, entry] of Object.entries(stored)) { if (!shown.has(to) && baseItems.has(to) && entry && typeof entry === 'object') out[to] = entry } } const writeOrder = !orderMatchesBase(groups, baseNav) for (const group of groups) { group.items.forEach((row, index) => { const base = baseItems.get(row.to) if (!base) return const entry = {} const label = typeof row.label === 'string' ? row.label.trim() : '' if (label && label !== base.label) entry.label = label if (row.hidden === true) entry.hidden = true if (grouped && (group.title ?? null) !== base.group && group.title) entry.group = group.title if (writeOrder) entry.order = index if (Object.keys(entry).length > 0) out[row.to] = entry }) } return out } // ── The public header: dropdown sections and added links ──────────────── // // Phase 10. The public nav is the one nav an admin can restructure rather than // only reorder: they may create dropdown **sections**, drop coded entries into // them, and add **links** of their own to pages on this site. // // The invariant §7 rests on survives, and it survives structurally rather than // by vigilance: coded entries stay keyed by a `to` the base array must declare, // so an override still cannot invent a route or touch a `roles`/`feature` gate, // while everything that CAN name an arbitrary path lives in `links` where the // path rule is applied. An added link carries no gate of its own and needs none // — the page behind it enforces its own access, so a link to somewhere the // viewer cannot reach 403s exactly as typing the URL would. // // Stored shape (server/src/utils/navOverrides.js is the writer): // { items: {"": {...}}, sections: [{id,label,order}], links: [{id,label,to,order,section}] } // A bare map is still read as the items map — unambiguous, because every item // key is a path and so can never be the string `items`. function unwrapPublic(overrides) { if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) { return { items: {}, sections: [], links: [] } } const wrapped = overrides.items && typeof overrides.items === 'object' && !Array.isArray(overrides.items) const items = wrapped ? overrides.items : overrides const sections = wrapped && Array.isArray(overrides.sections) ? overrides.sections : [] const links = wrapped && Array.isArray(overrides.links) ? overrides.links : [] return { items, sections, links } } // Forgiving, like every other read here: an entry that is not usable is dropped // and its neighbours kept. function readSections(sections) { const out = [] const seen = new Set() for (const s of sections) { if (!s || typeof s !== 'object' || typeof s.id !== 'string' || seen.has(s.id)) continue if (typeof s.label !== 'string' || !s.label.trim()) continue seen.add(s.id) out.push({ id: s.id, label: s.label.trim(), order: typeof s.order === 'number' && Number.isFinite(s.order) ? s.order : undefined }) } return out } function readLinks(links, knownSections) { const out = [] const seen = new Set() for (const l of links) { if (!l || typeof l !== 'object' || typeof l.id !== 'string' || seen.has(l.id)) continue if (typeof l.label !== 'string' || !l.label.trim()) continue // Same rule the server writes by. A stored value that would leave the origin // is dropped rather than rendered, so a hand-edited row cannot put an // off-site link in the header. if (typeof l.to !== 'string' || !l.to.startsWith('/') || l.to.startsWith('//') || /[\s<>"'\\]/.test(l.to)) continue seen.add(l.id) out.push({ id: l.id, label: l.label.trim(), to: l.to, order: typeof l.order === 'number' && Number.isFinite(l.order) ? l.order : undefined, section: typeof l.section === 'string' && knownSections.has(l.section) ? l.section : null, }) } return out } /** * The public nav as a one-level tree of `{kind: 'item' | 'link' | 'section'}`. * * @param {Array} baseNav the hardcoded public NAV — still the only source of * `to`, `feature` and `end` for a coded entry * @param {object|null} overrides the parsed nav_public row * @param {{keepHidden?: boolean}} [opts] the editor keeps hidden entries so * they can be un-hidden, and gets `defaultLabel` for the reset affordance; * the header must not render them at all * @returns {Array} */ export function buildPublicNav(baseNav, overrides, { keepHidden = false } = {}) { if (!Array.isArray(baseNav)) return [] const { items, sections: rawSections, links: rawLinks } = unwrapPublic(overrides) const sections = readSections(rawSections) const knownSections = new Set(sections.map((s) => s.id)) const links = readLinks(rawLinks, knownSections) // Coded entries, keyed by a `to` the base array declares. Anything else in the // map is dropped here, exactly as in applyNavOverrides. const known = new Set(baseNav.map((i) => i.to)) const entries = new Map() for (const [to, raw] of Object.entries(items)) { if (!known.has(to)) continue const entry = cleanEntry(raw, new Set()) if (!entry) continue if (typeof raw?.section === 'string' && knownSections.has(raw.section)) entry.section = raw.section entries.set(to, entry) } const nodes = [] baseNav.forEach((item, index) => { const o = entries.get(item.to) if (o?.hidden && !keepHidden) return nodes.push({ kind: 'item', ...item, ...(o?.label ? { label: o.label } : {}), ...(keepHidden ? { defaultLabel: item.label, hidden: o?.hidden === true } : {}), section: o?.section ?? null, __order: o?.order, __index: index, }) }) // An admin-created entity with no stored order appends after the coded ones, // in creation order, rather than jumping to the front on a 0 default. let next = baseNav.length for (const section of sections) { nodes.push({ kind: 'section', id: section.id, label: section.label, section: null, __order: section.order, __index: next++ }) } for (const link of links) { nodes.push({ kind: 'link', id: link.id, to: link.to, label: link.label, section: link.section, __order: link.order, __index: next++ }) } const place = (list) => list .map((n) => ({ n, key: n.__order ?? n.__index, explicit: n.__order !== undefined })) .sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit)) .map(({ n }) => { const { __order, __index, section, ...rest } = n return rest }) const top = place(nodes.filter((n) => n.kind === 'section' || !n.section)) return top.map((node) => node.kind === 'section' ? { ...node, items: place(nodes.filter((n) => n.section === node.id)) } : node, ) } /** * Apply the caller's visibility gate — and drop a section it leaves empty. * * Kept here rather than in SiteHeader because the empty-dropdown case is the one * with real correctness risk: a section whose every entry is hidden by shard * visibility must not render as a menu that opens onto nothing. The predicate * stays the caller's, so this module still knows nothing about shard features. * * Added links carry no gate, so they are always visible — see the note above. * * @param {Array} tree from buildPublicNav * @param {(item: object) => boolean} isVisible applied to coded items only * @returns {Array} */ export function pruneNav(tree, isVisible) { if (!Array.isArray(tree)) return [] const keep = (node) => node.kind !== 'item' || isVisible(node) return tree .map((node) => (node.kind === 'section' ? { ...node, items: (node.items || []).filter(keep) } : node)) .filter((node) => (node.kind === 'section' ? node.items.length > 0 : keep(node))) } /** * The editor's tree back as a nav_public value to store. * * Returns the **bare items map** when there are no sections and no added links, * so a nav that does not use this feature stores exactly what phases 6-8 stored. * * @param {Array} tree the editor's current tree * @param {Array} baseNav the hardcoded public NAV * @param {object|null} stored as loaded, so an entry for a feature-gated item * this admin could not see survives their save * @returns {object} `{}` when nothing differs from the code default */ export function buildPublicNavOverrides(tree, baseNav, stored = null) { if (!Array.isArray(tree) || !Array.isArray(baseNav)) return {} const baseLabels = new Map(baseNav.map((i) => [i.to, i.label])) const sections = [] const links = [] const items = {} // Flatten to (node, containerId, indexInContainer), which is all the writer // needs: a section's own position is its index in the top-level list. const placed = [] tree.forEach((node, index) => { placed.push({ node, section: null, index }) if (node.kind === 'section') (node.items || []).forEach((child, i) => placed.push({ node: child, section: node.id, index: i })) }) // Orders are written whenever this nav has any structure of its own: a section // exists only because the admin put it somewhere, so its position is never // "whatever the code says". Without sections the rule is phase 6-8's — write // orders only if the sequence actually moved. const hasStructure = tree.some((n) => n.kind === 'section' || n.kind === 'link') const shown = new Set(tree.flatMap((n) => (n.kind === 'section' ? (n.items || []) : [n])).filter((n) => n.kind === 'item').map((n) => n.to)) const sequence = tree.filter((n) => n.kind === 'item').map((n) => n.to) const baseSequence = baseNav.filter((i) => shown.has(i.to)).map((i) => i.to) const moved = sequence.length !== baseSequence.length || sequence.some((to, i) => to !== baseSequence[i]) const writeOrder = hasStructure || moved for (const { node, section, index } of placed) { if (node.kind === 'section') { sections.push({ id: node.id, label: (node.label || '').trim() || 'Section', ...(writeOrder ? { order: index } : {}) }) continue } if (node.kind === 'link') { links.push({ id: node.id, label: (node.label || '').trim() || node.to, to: node.to, ...(section ? { section } : {}), ...(writeOrder ? { order: index } : {}), }) continue } const entry = {} const label = typeof node.label === 'string' ? node.label.trim() : '' if (label && label !== baseLabels.get(node.to)) entry.label = label if (node.hidden === true) entry.hidden = true if (section) entry.section = section if (writeOrder) entry.order = index if (Object.keys(entry).length > 0) items[node.to] = entry } // Carry through an entry for a coded item this admin's palette never showed // them (shard-feature gated), so their save does not silently reset it. const { items: storedItems } = unwrapPublic(stored) for (const [to, entry] of Object.entries(storedItems)) { if (!shown.has(to) && baseLabels.has(to) && entry && typeof entry === 'object') items[to] = entry } if (sections.length === 0 && links.length === 0) return items const out = { items } if (sections.length) out.sections = sections if (links.length) out.links = links return out } export default applyNavOverrides