Phase 2, PR 8 of docs/website/MODULE_SYSTEM.md 2.7 - the nav half PR 7 deferred, plus the two seams 1.4 and 1.5 asked for. withModuleNav (client/src/modules/nav.js) merges an installed module's rows into core's three navs BEFORE the admin-override merge, and that ordering is the design. applyNavOverrides and buildPublicNav are keyed by `to` and drop any key their base array does not declare, so rows appended after the merge would be unorderable, unrelabellable and unhideable in Admin - Navigation. Today's UO rows are all three of those things, so appending would make the extraction a visible regression for anyone who has ever edited their nav. Merging first means a module row is an ordinary row downstream: nothing in navOverrides.js, NavEditor.jsx or the layouts knows a module exists. MOD_PATHS is gone. Moderator visibility and the redirect that confines a moderator both derive from each row's own `roles`, in the new plain-JS lib/adminNav.js (plain so the DOM-less runner can reach it). Two rows move, both toward what the server already permitted: Dashboard, whose roles had always named moderator, and My Characters, which is ungated self-service. That also fixes a defect predating the module system. The redirect was a THIRD hardcoded list - three path prefixes against MOD_PATHS' five paths - and they disagreed about /admin/houses, so a moderator who clicked Houses in their own sidebar was bounced back to Moderation. The derived allow-list is computed from the BASE nav, never the override-merged one: an override is presentation and must not move an authorization boundary either way. The feature seam (modules/features.jsx + modules/featureGate.js) resolves a row's `feature` against the provider its OWN module registered, so the namespace comes from the registration and no string carries a parsed prefix. Core registers useShardFlags under the owner id `core` - the client twin of registries.registerCore() - so the ten shard-gated header rows already run through the seam and Phase 3 deletes a registration instead of rewriting SiteHeader. Every unknown fails open: no provider, a null answer while a fetch is in flight, or a junk return all show the link, because the server is the gate and hiding a page from someone entitled to it is the worse mistake. 933 server tests (unchanged - this PR is client-only), 160 client tests (+37). routes.manifest.json unchanged at 230 routes; the OpenAPI spec regenerates byte-identical. Re-ran the MODULE_API.md 7.7 browser smoke, since this is the seam that rule exists for. A throwaway module registering nav in all three areas and a provider granting one flag and withholding another: the row lands inside core's Moderation group rather than an appended block, the withheld row does not render, a moderator reaches both /admin/houses and the module's admin page, and an admin can relabel a module row and have it persist and apply. Zero CSP reports, zero console errors. Co-Authored-By: Claude <noreply@anthropic.com>
506 lines
22 KiB
JavaScript
506 lines
22 KiB
JavaScript
// 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
|
|
// Exported for modules/nav.js, which has to answer the same question about the
|
|
// same array a moment earlier — one implementation, so the interleave and the
|
|
// merge can never disagree about which shape they are looking at.
|
|
export 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: {"<to>": {...}}, 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
|