// The interleave of module nav items into core's nav. // // Phase 2, PR 8 of docs/website/MODULE_SYSTEM.md §2.7 (§1.4 states the problem); // the normative contract is docs/website/MODULE_API.md §3.3. // // **Module items join the BASE array, before anything else happens to it.** That // is the whole design of this file and the override merge next door forces it: // `applyNavOverrides` / `buildPublicNav` are keyed by `to` and drop any key the // base array does not declare (lib/navOverrides.js — deliberately, so a deleted // route cannot leave a stale row doing something unexpected later). Append // module items *after* that merge and they are unreachable to Admin → // Navigation: unorderable, unrelabellable, unhideable. Today's UO rows are all // three of those things, so appending would make the extraction a visible // regression for every operator who has ever touched their nav. // // So the pipeline gains one step at the front and nothing else changes: // // withModuleNav(NAV, area) → admin overrides → role/feature filter → rendered // // and the filter stays last, which is what keeps it the boundary an override // cannot cross (THEMING_AND_NAV.md §7). MODULE_API.md §3.3 wrote those last two // the other way round; the code is right and the contract was amended. // // The result is that a module row is, to everything downstream, an ordinary row. // Nothing in navOverrides.js, NavEditor.jsx or the layouts knows a module exists. import { navFor } from './registry.js' import { isGrouped } from '../lib/navOverrides.js' // Rows with no group of their own are collected under this key. A Symbol rather // than a string so it cannot collide with a group an admin or a module names. const UNGROUPED = Symbol('ungrouped') /** * Sort by effective position, where a row that asked for nothing keeps the index * it already had. Three tie-breaks, in this order: an explicit `order` beats a * coincidental index (the module said "third", so third), and two explicit * orders keep registration order, which `navFor` has already put in scan order. * * The same rule byOrder/place use in lib/navOverrides.js, and it has to be — an * admin who then drags that row is editing the position this produced. */ function place(entries) { return entries .map((entry, index) => ({ ...entry, index })) .sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit) || a.index - b.index) .map(({ item }) => item) } function entryFor(item, fallbackKey) { return { item, key: item.order ?? fallbackKey, explicit: item.order !== undefined } } function coreEntries(items) { return items.map((item, index) => ({ item, key: index, explicit: false })) } /** The `to`s a base nav already claims, flat or grouped. */ function claimedPaths(baseNav, grouped) { return new Set(grouped ? baseNav.flatMap((g) => g.items.map((i) => i.to)) : baseNav.map((i) => i.to)) } /** * Drop a module row whose `to` is already on the nav, and say so. * * Not a policy about where a module may link — it is that `to` is the KEY the * override layer stores under and React renders by. Two rows sharing one would * give an admin a single editor row that silently moves both, and a duplicate * key in the rendered list. Dropping the newcomer keeps core's row, which is the * one any existing override was written against. * * Fail-safe like every other read in this area: the offending row goes, its * neighbours stay. */ function withoutCollisions(items, claimed) { const out = [] for (const item of items) { if (!item || typeof item.to !== 'string' || !item.to) continue if (claimed.has(item.to)) { console.warn( `[modules] nav item "${item.to}" from module "${item.moduleId}" collides with an existing row and was dropped`, ) continue } claimed.add(item.to) out.push(item) } return out } // The flat navs — the public header and the player portal. // // No groups, so `order` is a position in the one list: core rows are keyed by // their index and a module row by the `order` it asked for. A module row with no // order appends after the coded ones, in registration order, rather than jumping // to the front on a 0 default — the same choice buildPublicNav makes for an // admin-created link. function mergeFlat(baseNav, items) { return place([...coreEntries(baseNav), ...items.map((item, i) => entryFor(item, baseNav.length + i))]) } // The grouped nav — the admin sidebar. // // `group` names an existing core group and the row lands inside it: Moderation // and System, where today's UO rows already sit (§1.4). An unknown group name // creates a group at the end rather than dropping the row — a typo must cost a // position, never a link. A row with no `group` at all lands in a trailing // untitled group, which renders as ungrouped links; core does not invent a // display title out of a module id. // // An ungrouped row is NOT folded into one of core's own untitled groups // (Dashboard's, Account's): those are furniture pinned to the top and bottom of // the sidebar, and a module page does not belong beside "Account". // // A group created here is a group as far as everything downstream is concerned, // including as a destination in Admin → Navigation's "move to section" control: // `readOverrides` builds its set of legal destinations from the base nav it is // handed, which is this one. function mergeGrouped(baseNav, items) { const titles = new Set(baseNav.map((g) => g.title).filter((t) => typeof t === 'string')) const into = new Map() // existing group title → rows const fresh = new Map() // new group title (or UNGROUPED) → rows, first-seen order for (const item of items) { const named = typeof item.group === 'string' && item.group ? item.group : null const key = named ?? UNGROUPED const bucket = named !== null && titles.has(named) ? into : fresh if (!bucket.has(key)) bucket.set(key, []) bucket.get(key).push(item) } const kept = baseNav.map((g) => { const incoming = into.get(g.title) if (!incoming) return g return { ...g, items: place([...coreEntries(g.items), ...incoming.map((item, i) => entryFor(item, g.items.length + i))]), } }) const created = [...fresh.entries()].map(([key, rows]) => { const items_ = place(rows.map((item, i) => entryFor(item, i))) return key === UNGROUPED ? { items: items_ } : { title: key, items: items_ } }) return [...kept, ...created] } /** * The base nav a layout should render: core's coded array with every installed * module's rows for this area interleaved into it. * * Returns `baseNav` ITSELF when no module registered anything for this area, so * an instance with no modules installed renders the identical array it renders * today — the same "untouched path" guarantee applyNavOverrides makes, and what * makes a `useMemo` with an empty dependency list around this call honest. * * Safe to call once per component and cache: registration completes before the * first render (main.jsx waits for DOMContentLoaded — MODULE_API.md §3.1) and * there is no unregistering, so this answer cannot change during a session. * * @param {Array} baseNav the coded NAV, flat or grouped * @param {'public'|'admin'|'player'} area * @returns {Array} a nav of the same shape */ export function withModuleNav(baseNav, area) { if (!Array.isArray(baseNav)) return [] const grouped = isGrouped(baseNav) const items = withoutCollisions(navFor(area), claimedPaths(baseNav, grouped)) if (items.length === 0) return baseNav return grouped ? mergeGrouped(baseNav, items) : mergeFlat(baseNav, items) } export default withModuleNav