Phase 3's acceptance criterion 1, made real. Three things, one review: **The dead bindings.** `client/src/api/client.js` still carried ~190 lines of UO namespaces — `shard`, `atlas`, the two SSE URLs, `admin.shard/shardOps/atlas/ userShard`, the uo-link and town-crier calls, `player.shard` — with zero core consumers since slice 3 deleted the views. module-uo vendors its own bindings. The five assertions core's `apiClient.test.js` made about those URLs moved with them (Module-uo#5); the encoding test that used `governorHistory` now uses a core route. **The copy.** Core is the platform, not one game's site, so its words are game-neutral now: `About`, `Screenshots`, `Website`'s cards, `Status` (which was never about a game server at all — it reports site mode), `Wiki`, `SiteFooter`, the default hero, `brand.js`'s tagline and description, the seeded wiki categories, and two user-visible NavEditor strings that named a module's admin screen by its proper name. Which game an instance is for is the operator's to say — BRAND_* vars, the hero editor, CMS pages — and every real instance already does: `.env.uomysticmoon.example` sets both brand strings explicitly, so nothing live changes wording. Wiki page SLUGS are untouched: `seedDefault*` only inserts what is absent, so renaming one adds a duplicate page to every install. Also gone: an orphan comment block in `schema.sql` describing the spawn-atlas tables slice 1 took away, and the two settings rows core seeded for a module (`game_account_signup`, `uo_link_protocol_3_migrated`). The second was a live defect — see Module-uo#5, which takes ownership of both and repairs the one-shot migration core's ordering had disabled. **The check.** `scripts/checkModuleIdentifiers.js` + `npm run check:modules`, first step of the server-tests job because it needs no dependencies. It reads CODE, not prose — file names, import specifiers, route path literals, declared identifiers and property names — per §5.2, so core's English may still say "shard" where saying it is worth more than the word costs. Two things it gets right only because getting them wrong was tried first: it matches WHOLE WORDS (a substring pass flags `defaultImage`, which contains "ultIma", four times in this repo), and it strips comments and string bodies in one character walk (a comment contains quotes, a string contains `//`) — the `checkImports.js` lesson. It has its own 17-test suite, because a boundary check that silently stops checking is worse than none. The three §6.5 grandfathering allowlists are exempt by name, and an exemption that stops matching fails the build rather than lingering. BREAKING CHANGE: core no longer seeds `game_account_signup` or `uo_link_protocol_3_migrated`; module-uo's schema fragment does. An install running core without module-uo keeps whatever rows it already has and gains no new ones — nothing in core reads either key. Deferred to slice 5, deliberately: README.md's 48 UO mentions, including a `## Shard integration (uo-link)` section and the architecture diagram. That is documentation, which §5.2 does not cover, and it belongs with the phase-closing docs pass rather than half-done here. Co-Authored-By: Claude <noreply@anthropic.com>
507 lines
22 KiB
JavaScript
507 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 module's feature gate 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 a
|
|
* module's visibility rules must not render as a menu that opens onto nothing.
|
|
* The predicate stays the caller's, so this module still knows nothing about
|
|
* what any module gates on.
|
|
*
|
|
* 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 (feature-gated by its module), 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
|