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>
175 lines
7.5 KiB
JavaScript
175 lines
7.5 KiB
JavaScript
// 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
|