feat(theming): dropdown sections and added links in the public header
Phase 10 of docs/website/THEMING_AND_NAV.md, asked for before the edge -> main
cutover. An admin can now create dropdown sections in the public header, organise
the coded entries into them, and add links of their own.
This deliberately amends §7, which said the override layer "cannot introduce a
`to` that is not already in the hardcoded NAV array". That stays true of every
CODED entry; an admin may now also add a link, restricted to a same-origin path —
no scheme, no protocol-relative //host. A link carries no gate of its own and
needs none: the page behind it enforces its own access, so an added link
advertises a route and never grants one.
The invariant is kept structurally rather than by vigilance. Coded entries live
in an `items` map whose keys must be routes the base array declares, so that map
cannot invent a route; everything that CAN name an arbitrary path lives in
`links`, which is the one place the path rule is applied — on both the write and
the read path.
nav_public therefore grew a { items, sections, links } wrapper. A bare map still
reads as the items map, and a nav with no sections still stores one, so this
changed nothing for a nav that does not use it. Free to do now because nothing
has shipped; after the cutover it would have needed a migration.
The Public tab gets its own editor. A public section is an entry in the
top-level order that the admin created and can drag among the pills, unlike the
admin sidebar's four coded sections, where only membership moves — that is a tree
rather than a list of groups. Deleting a section returns its entries to the top
level rather than removing them, which is the one destructive act this screen
could otherwise commit.
The dropdown opens on click and never on hover, and its trigger is not a link: a
hover menu is unusable on touch, and a trigger that navigates means tapping to
open takes you somewhere instead. Escape closes and returns focus, an outside
press closes, navigating closes, and Arrow Up/Down walk the items.
pruneNav applies the shard-feature gate inside a section and drops one it leaves
empty, so a dropdown never opens onto nothing.
Also fixes a bug this surfaced in the phase 6-8 code: the save path judged "does
this route still exist?" against the palette — the base array already filtered to
what the editing admin can see — so on the public header a feature-gated row's
override could never be carried through and would have been silently reset.
Membership is now judged against the full coded nav while the rows still come
from the palette.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -263,4 +263,240 @@ export function buildNavOverrides(groups, baseNav, stored = null) {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user