diff --git a/client/src/components/ShardStatusLink.jsx b/client/src/components/ShardStatusLink.jsx new file mode 100644 index 0000000..ffaea4c --- /dev/null +++ b/client/src/components/ShardStatusLink.jsx @@ -0,0 +1,24 @@ +// ── Core's fill for the `site.footer.status` extension slot ──────────────── +// +// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1; the contract is +// MODULE_API.md §3.7. +// +// This is the whole of what used to be four lines inline in SiteFooter.jsx, and +// it is a file now for one reason: `/site/shard` is a UO page, so the link goes +// when the client half goes, and core should be deleting a registration rather +// than editing its footer under extraction pressure. +// +// Note what core kept and what it handed over. Core owns the position in the row +// and the separator around it, and passes `linkStyle` so the row stays visually +// one row. The label, the destination, and the decision to render at all are +// this file's — which is exactly the division a module inherits. + +import { Link } from 'react-router-dom' + +export default function ShardStatusLink({ linkStyle }) { + return ( + + Shard Status + + ) +} diff --git a/client/src/components/SiteFooter.jsx b/client/src/components/SiteFooter.jsx index 5b94977..66f65b0 100644 --- a/client/src/components/SiteFooter.jsx +++ b/client/src/components/SiteFooter.jsx @@ -1,5 +1,14 @@ import { Link } from 'react-router-dom' import { useSite } from '../contexts/SiteContext.jsx' +import Slot from '../modules/Slot.jsx' + +const FOOTER_SLOT = 'site.footer.status' + +// Handed to the extension rather than left for it to guess. A module rendering +// its own link in this row should look like the row, and the alternative is +// every module restating core's colours and then drifting from them the next +// time this footer is themed. +const LINK_STYLE = { color: 'var(--accent)', textDecoration: 'none' } export default function SiteFooter() { const { contactEmail, siteTitle } = useSite() @@ -36,10 +45,14 @@ export default function SiteFooter() { {contactEmail} -  ·  - - Shard Status - + {/* A module's spot in the footer, and core supplies only the + position and the styling: the label, the target and whether + anything renders at all are the module's (MODULE_API.md §3.7). + The separator goes through `wrap` rather than sitting beside the + slot, so it shares the extension's fate — no module installed and + a module whose link throws both render nothing here, rather than + the second leaving a stray middot behind. */} + <> · {link}} />  ·  Admin diff --git a/client/src/main.jsx b/client/src/main.jsx index de0fa13..f1c81fa 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -3,8 +3,10 @@ import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import App from './App.jsx' import { publishSharedDependencies } from './modules/shared.js' -import { registerFeatureProvider } from './modules/registry.js' +import { declareSlot, registerExtension, registerFeatureProvider } from './modules/registry.js' import { useShardFlags } from './lib/useShardFeatures.js' +import ShardStatusLink from './components/ShardStatusLink.jsx' +import UserShardSections from './routes/admin/views/UserShardSections.jsx' import './styles/theme.css' // Publish window.__rg BEFORE rendering and before any module chunk evaluates. @@ -28,6 +30,34 @@ publishSharedDependencies() // for them by the name they will always have had. registerFeatureProvider('core', 'uo', useShardFlags) +// ── Extension slots (MODULE_API.md §3.7) ─────────────────────────────────── +// +// Declared HERE, in core's own bundle, which is what makes the ordering a fact +// rather than a hope: module chunks are deferred scripts the shell injects after +// this one (§3.1), so a module can never reach registerExtension before the slot +// it names exists. "Unknown slot" therefore always means a typo or a version +// skew, never a load-order accident — which is why that case throws. +// +// Both slots are named for a PLACE, not for a meaning. `site.footer.status` is +// the spot in the footer's info row, not a declaration that core knows what a +// game server's status is; the label, the target and whether anything renders at +// all belong to whoever fills it. A slot typed by its content would put game +// semantics back into core, which is the thing Phase 3 takes out. +declareSlot('site.footer.status') +// Deliberately the same name as the server's slot (MODULE_API.md §2.4): one +// resource, one extension point, two halves. The module with routes under +// /api/v1/admin/users/:id is the module with something to show on that page. +declareSlot('admin.users.detail') + +// And core fills both itself, under owner id `core`, with the components that +// were inline in SiteFooter.jsx and UserDetail.jsx until this slice. The page +// renders exactly what it rendered before, and the mechanism is exercised by +// core's own content from the day it lands rather than first proved by the +// change that depends on it. Slice 3 deletes these three lines and the two files +// they name, and the module registers the same two slots on its way in. +registerExtension('core', 'site.footer.status', ShardStatusLink) +registerExtension('core', 'admin.users.detail', UserShardSections) + // Render on DOMContentLoaded rather than immediately, and that is the one line // of core's boot the module system changes. // diff --git a/client/src/modules/Slot.jsx b/client/src/modules/Slot.jsx new file mode 100644 index 0000000..1ae2d92 --- /dev/null +++ b/client/src/modules/Slot.jsx @@ -0,0 +1,68 @@ +// ── — where core renders a module's content ───────────────────────── +// +// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1; the normative +// contract is docs/website/MODULE_API.md §3.7. +// +// The read side of registry.js's extension slots. Core puts one of these where a +// module may contribute to a core page, and gets back either the filling +// component with the props core passed, or nothing at all. +// +// **Nothing at all is the important half.** An instance with no module installed +// renders the identical page it renders today, which is the same untouched-path +// guarantee `withModuleNav` makes for nav — and the reason a core layout can +// place a slot without also acquiring an empty-state to design. + +import React from 'react' +import { extensionFor } from './registry.js' + +/** + * Contain a module's render failure to the module's own section. + * + * This is where the client differs from the server, deliberately. A module + * *route* that throws costs the module's own page and core does not need to care. + * An extension throws inside CORE's page — the admin's user detail, the site + * footer — and the whole reason core keeps ownership of that page is that it + * stays usable. So a slot renders nothing and logs, rather than taking the + * surrounding page down with it. + * + * A class because that is what React gives us: there is no hook form of + * componentDidCatch, and this is the only error boundary core has. + */ +class SlotBoundary extends React.Component { + constructor(props) { + super(props) + this.state = { failed: false } + } + + static getDerivedStateFromError() { + return { failed: true } + } + + componentDidCatch(error) { + // Named so the console says whose fault it is: a blank section with an + // anonymous stack is how a module bug becomes core's support ticket. + console.error(`[modules] extension in slot "${this.props.name}" threw and was dropped`, error) + } + + render() { + return this.state.failed ? null : this.props.children + } +} + +/** + * @param {string} name the slot id, declared by core in main.jsx + * @param {function} [wrap] core markup that only makes sense AROUND a rendered + * extension — a separator, a heading, a rule. Called with the extension's + * element and rendered inside the boundary, so it shares the extension's fate: + * an unfilled slot and a failed one both render nothing at all, decoration + * included. Found in a browser, because the obvious alternative — asking + * whether the slot is filled and rendering the separator alongside — is right + * about the unfilled case and leaves a stray separator behind on the failed one. + * @param {object} props everything else is handed to the filling component + */ +export default function Slot({ name, wrap, ...props }) { + const Extension = extensionFor(name) + if (!Extension) return null + const element = + return {wrap ? wrap(element) : element} +} diff --git a/client/src/modules/registry.js b/client/src/modules/registry.js index bdc7892..87d7a03 100644 --- a/client/src/modules/registry.js +++ b/client/src/modules/registry.js @@ -32,6 +32,8 @@ const routes = { public: [], admin: [], player: [] } const nav = { public: [], admin: [], player: [] } const providers = new Map() +// slot name → { Component, filledBy }. +const slots = new Map() const registered = new Set() const AREAS = ['public', 'admin', 'player'] @@ -100,6 +102,70 @@ export function registerFeatureProvider(id, namespace, hook) { registered.add(id) } +// ── Extension slots (§3.7) ───────────────────────────────────────────────── +// +// The client twin of the server's declareSlot/registerExtension, and the same +// rule in both halves: core declares a slot, ONLY core declares one, and at most +// one module fills it. Core renders `` (Slot.jsx) and gets nothing +// back when the slot is unfilled — so an instance with no module installed +// renders exactly what it renders today. +// +// A slot is named for a PLACE, never for a meaning. `site.footer.status` is a +// position in the footer and the styling that goes with it; the label, the +// target, the data and whether anything renders at all are the module's. The +// moment core types a slot by its content it has re-acquired the game semantics +// this whole extraction removes. + +/** + * @param {string} name the slot id. Core-only — deliberately not on the + * `registry` object handed to modules. + */ +export function declareSlot(name) { + if (slots.has(name)) throw new Error(`extension slot "${name}" already declared`) + slots.set(name, { Component: null, filledBy: null }) +} + +/** + * Fill a declared slot with a component. + * + * **This is the one place the client registry is not fail-open**, and the + * asymmetry is deliberate. A dropped nav row costs a link the viewer can reach + * another way; a silently dropped extension is invisible to everyone including + * its author. So an unknown slot, a non-component, and a second fill all throw — + * exactly as the server's checkExtensionShape does. + * + * A throw here is always a programming error and never a race, because + * declaration structurally precedes filling: core declares in main.jsx, inside + * its own bundle, and every module chunk is a deferred script injected after it + * (§3.1). + */ +export function registerExtension(id, slot, Component) { + const entry = slots.get(slot) + if (!entry) throw new Error(`registerExtension: unknown extension slot "${slot}"`) + if (typeof Component !== 'function') throw new Error(`registerExtension: ${slot} is not a component`) + if (entry.filledBy) throw new Error(`extension slot "${slot}" is already filled by "${entry.filledBy}"`) + entry.Component = Component + entry.filledBy = id + registered.add(id) +} + +/** + * The filling component, or null. + * + * Read by Slot.jsx and nothing else — deliberately. There is no `hasExtension` + * for a core layout to branch on, because a layout that asks whether a slot is + * filled and then renders its own decoration alongside gets the *failed* case + * wrong: the extension is filled, so the decoration renders, and the component + * then throws into the boundary leaving the decoration behind on its own. Core + * decorates through `` instead, which puts the decoration inside the + * boundary where it shares the extension's fate. (Found in a browser, with the + * footer's separator.) + * + * Undeclared and unfilled both read null: reading is fail-safe, and only writing + * is strict. + */ +export const extensionFor = (slot) => (slots.get(slot) || {}).Component || null + export const routesFor = (area) => routes[area] || [] // Sorted by the `order` a module asked for. Array#sort is stable in every engine @@ -131,6 +197,11 @@ export function _reset() { nav[area].length = 0 } providers.clear() + // Declarations go too, unlike the server's, where a slot is declared once at + // require time by the router that owns it. Core declares its slots in + // main.jsx — the one file no test loads — so on this side there is nothing + // declared at import time for a surviving declaration to protect. + slots.clear() registered.clear() } @@ -142,6 +213,7 @@ export const registry = { registerRoutes, registerNav, registerFeatureProvider, + registerExtension, routesFor, navFor, featureProviderFor, diff --git a/client/src/modules/version.js b/client/src/modules/version.js index b527fe5..466b4c9 100644 --- a/client/src/modules/version.js +++ b/client/src/modules/version.js @@ -11,9 +11,13 @@ // that the two files can drift, so a test asserts they agree // (client/test/moduleRegistry.test.js) rather than trusting a bump to remember // both. +// 1.2.0 — `registry` gained `registerExtension` and core gained extension slots +// (MODULE_API.md §3.7). The first change to window.__rg since 1.0.0, and an +// addition: a module that never fills a slot is unaffected. The server half is +// untouched and bumps anyway, for the reason below. // 1.1.0 — the server's ctx gained activity.log, users.getById, site.baseUrl and // the rate-limit factory (MODULE_API.md §2.3). Nothing on window.__rg changed, // but the two halves state ONE version: a module declares a single coreApi range // and is served one chunk, so a client that claimed 1.0.0 while the server // answered 1.1.0 would be two answers to one question. -export const MODULE_API_VERSION = '1.1.0' +export const MODULE_API_VERSION = '1.2.0' diff --git a/client/src/routes/admin/views/UserDetail.jsx b/client/src/routes/admin/views/UserDetail.jsx index cd552cf..93f9cca 100644 --- a/client/src/routes/admin/views/UserDetail.jsx +++ b/client/src/routes/admin/views/UserDetail.jsx @@ -1,17 +1,16 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useParams, Link } from 'react-router-dom' import { Loading, ErrorState } from '../../../components/PageState.jsx' import { useAsync } from '../../../lib/useAsync.js' -import { dateTime, ago } from '../../../lib/format.js' +import { dateTime } from '../../../lib/format.js' import { api } from '../../../api/client.js' -import CharacterStats from '../../../components/CharacterStats.jsx' -import GameAccounts from '../../../components/GameAccounts.jsx' -import VendorSales from '../../../components/VendorSales.jsx' +import Slot from '../../../modules/Slot.jsx' -// Admin read-only view of one user's shard (uo-link) footprint: linked game -// accounts + character rosters, currently-online characters, houses (incl. -// IDOC) and recent vendor sales — everything scoped to that user's accounts. -// Reached from the Users table's "View" action; Edit stays a separate modal. +// Admin view of one user: who they are, their security posture (trusted devices +// and MFA), and then whatever the installed module contributes about them — +// today core's own UO footprint, via the `admin.users.detail` extension slot +// (MODULE_API.md §3.7). Reached from the Users table's "View" action; Edit stays +// a separate modal. const ROLE_BADGE = { admin: 'badge-admin', @@ -28,114 +27,6 @@ function SectionTitle({ children }) { ) } -// Currently-online characters on the user's accounts, with where they are. The -// per-character Online/Offline badge lives in the roster; this adds location. -function OnlineNow({ scope }) { - const { data } = useAsync(() => scope.online(), [scope]) - if (!data) return null - return ( -
- Online now - {data.length === 0 ? ( -

No characters online right now.

- ) : ( -
    - {data.map((c) => ( -
  • - - - {c.name || '(unnamed)'} - - - {c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'} - -
  • - ))} -
- )} -
- ) -} - -// Shard "standing": city governorships held and guilds led by this user's -// accounts (both reliable current-state lookups). Renders nothing when empty. -function Standing({ scope }) { - const { data } = useAsync(() => scope.standing(), [scope]) - if (!data) return null - const govs = data.governorOf || [] - const guilds = data.guildsLed || [] - if (govs.length === 0 && guilds.length === 0) return null - return ( -
- Standing -
- {govs.map((g) => ( - - Governor of {g.city} - - ))} - {guilds.map((g) => ( - - Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name} - - ))} -
-
- ) -} - -// One house row — the many optional detail fields are gathered here so the -// Houses list stays a simple map. -function HouseRow({ house: h }) { - const location = h.region || (h.map != null ? `map ${h.map}` : 'unknown') - const coords = h.x != null ? ` · ${h.x}, ${h.y}` : '' - const owner = h.ownerAcct ? ` · ${h.ownerAcct}` : '' - const shares = h.coOwners || h.friends ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : '' - return ( -
  • -
    -
    - {h.name || 'Unnamed house'} - {h.isIdoc && IDOC} -
    -
    - {location} - {coords} - {owner} - {shares} -
    -
    -
    - {(h.decay || h.stage) ?
    {h.decay || h.stage}
    : null} - {h.price != null ?
    {Number(h.price).toLocaleString()} gp
    : null} - {h.lastRefreshed ?
    refreshed {ago(h.lastRefreshed)}
    : null} -
    -
  • - ) -} - -// Houses owned by the user's accounts, IDOC first (flagged). -function Houses({ scope }) { - const { data } = useAsync(() => scope.houses(), [scope]) - if (!data) return null - return ( -
    - Houses - {data.length === 0 ? ( -

    No houses recorded for this user’s accounts.

    - ) : ( -
      - {data.map((h) => ( - - ))} -
    - )} -
    - ) -} - // Admin security controls for one user: their trusted devices (view + revoke) and // an MFA reset for a locked-out user. Every action is audit-logged server-side. function SecurityAdmin({ userId }) { @@ -244,25 +135,8 @@ function SecurityAdmin({ userId }) { ) } -function ShardSections({ scope }) { - return ( - <> - - Linked accounts & characters - `/admin/characters/${serial}`} /> - - - - - - ) -} - export default function UserDetail() { const { id } = useParams() - // Memoize so the child components' effects (keyed on `scope`) don't refetch - // on every render. - const scope = useMemo(() => api.admin.userShard(id), [id]) const { loading, error, data: user } = useAsync(() => api.admin.getUser(id), [id]) if (loading) return @@ -296,7 +170,10 @@ export default function UserDetail() { - + {/* Whatever the installed module has to say about this user, or nothing + at all. Core's own UO sections fill it today (UserShardSections.jsx, + registered in main.jsx) — MODULE_API.md §3.7. */} + ) } diff --git a/client/src/routes/admin/views/UserShardSections.jsx b/client/src/routes/admin/views/UserShardSections.jsx new file mode 100644 index 0000000..aa0890b --- /dev/null +++ b/client/src/routes/admin/views/UserShardSections.jsx @@ -0,0 +1,163 @@ +// ── Core's fill for the `admin.users.detail` extension slot ──────────────── +// +// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1. Every section below +// is UO, and every one of them leaves core with the client half in slice 3 — +// this file exists so that when they do, core deletes a registration and a file +// instead of unpicking a page. +// +// Core registers it through the same seam a module uses +// (`registerExtension('core', …)` in main.jsx), which is the client twin of the +// server's `registries.registerCore()` and the same trick `useShardFlags` +// already uses for the feature seam. The mechanism is therefore exercised by +// core's own content from the day it lands, rather than first proved by the +// change that depends on it. +// +// The slot hands over `userId` and nothing else — deliberately, not `scope`. +// `api.admin.userShard` is a UO binding that leaves core in slice 3, so a slot +// that passed it would be handing a module something core is about to delete. +// An extension builds its own client for the routes it registered at the other +// end (MODULE_API.md §3.5), and this file does exactly what the module will. + +import { useMemo } from 'react' +import { useAsync } from '../../../lib/useAsync.js' +import { ago } from '../../../lib/format.js' +import { api } from '../../../api/client.js' +import CharacterStats from '../../../components/CharacterStats.jsx' +import GameAccounts from '../../../components/GameAccounts.jsx' +import VendorSales from '../../../components/VendorSales.jsx' + +// Its own copy, not an export from UserDetail.jsx: six lines of presentational +// furniture that is not in the §3.4 kit, so a module filling this slot would +// vendor the same thing. Core's copy stays behind with core's own security +// panel, which is the other caller. +function SectionTitle({ children }) { + return ( +
    + {children} +
    + ) +} + +// Currently-online characters on the user's accounts, with where they are. The +// per-character Online/Offline badge lives in the roster; this adds location. +function OnlineNow({ scope }) { + const { data } = useAsync(() => scope.online(), [scope]) + if (!data) return null + return ( +
    + Online now + {data.length === 0 ? ( +

    No characters online right now.

    + ) : ( +
      + {data.map((c) => ( +
    • + + + {c.name || '(unnamed)'} + + + {c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'} + +
    • + ))} +
    + )} +
    + ) +} + +// Shard "standing": city governorships held and guilds led by this user's +// accounts (both reliable current-state lookups). Renders nothing when empty. +function Standing({ scope }) { + const { data } = useAsync(() => scope.standing(), [scope]) + if (!data) return null + const govs = data.governorOf || [] + const guilds = data.guildsLed || [] + if (govs.length === 0 && guilds.length === 0) return null + return ( +
    + Standing +
    + {govs.map((g) => ( + + Governor of {g.city} + + ))} + {guilds.map((g) => ( + + Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name} + + ))} +
    +
    + ) +} + +// One house row — the many optional detail fields are gathered here so the +// Houses list stays a simple map. +function HouseRow({ house: h }) { + const location = h.region || (h.map != null ? `map ${h.map}` : 'unknown') + const coords = h.x != null ? ` · ${h.x}, ${h.y}` : '' + const owner = h.ownerAcct ? ` · ${h.ownerAcct}` : '' + const shares = h.coOwners || h.friends ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : '' + return ( +
  • +
    +
    + {h.name || 'Unnamed house'} + {h.isIdoc && IDOC} +
    +
    + {location} + {coords} + {owner} + {shares} +
    +
    +
    + {(h.decay || h.stage) ?
    {h.decay || h.stage}
    : null} + {h.price != null ?
    {Number(h.price).toLocaleString()} gp
    : null} + {h.lastRefreshed ?
    refreshed {ago(h.lastRefreshed)}
    : null} +
    +
  • + ) +} + +// Houses owned by the user's accounts, IDOC first (flagged). +function Houses({ scope }) { + const { data } = useAsync(() => scope.houses(), [scope]) + if (!data) return null + return ( +
    + Houses + {data.length === 0 ? ( +

    No houses recorded for this user’s accounts.

    + ) : ( +
      + {data.map((h) => ( + + ))} +
    + )} +
    + ) +} +export default function UserShardSections({ userId }) { + // Memoized so the child components' effects (keyed on `scope`) don't refetch + // on every render — the same reason UserDetail memoized it before this moved. + const scope = useMemo(() => api.admin.userShard(userId), [userId]) + return ( + <> + + Linked accounts & characters + `/admin/characters/${serial}`} /> + + + + + + ) +} diff --git a/client/test/moduleRegistry.test.js b/client/test/moduleRegistry.test.js index 49c6153..ffd78b1 100644 --- a/client/test/moduleRegistry.test.js +++ b/client/test/moduleRegistry.test.js @@ -162,6 +162,7 @@ test('the registry object handed to modules exposes the whole surface', () => { assert.deepEqual(Object.keys(registry).sort(), [ 'featureProviderFor', 'navFor', + 'registerExtension', 'registerFeatureProvider', 'registerNav', 'registerRoutes', diff --git a/client/test/moduleSlots.test.js b/client/test/moduleSlots.test.js new file mode 100644 index 0000000..d0540e4 --- /dev/null +++ b/client/test/moduleSlots.test.js @@ -0,0 +1,94 @@ +import { test, beforeEach } from 'node:test' +import assert from 'node:assert/strict' + +import { + registry, + declareSlot, + registerExtension, + extensionFor, + registeredIds, + _reset, +} from '../src/modules/registry.js' + +// Client extension slots (docs/website/MODULE_API.md §3.7) — the client twin of +// the server's declareSlot/registerExtension. +// +// The registry half only. `` itself renders, and there is no DOM in this +// runner, so what it does with what these functions return — including the error +// boundary — is proved by the §7.7 browser smoke instead. Everything below is a +// rule that can be stated without rendering anything, and every one of them can +// be got wrong in a way a browser check would not obviously catch. + +beforeEach(() => _reset()) + +const Fake = () => null +const Other = () => null + +test('an unfilled slot reads as nothing', () => { + // The guarantee core's layouts rest on: place a slot, install no module, and + // the page renders what it rendered before. + declareSlot('site.footer.status') + assert.equal(extensionFor('site.footer.status'), null) +}) + +test('an undeclared slot reads as nothing rather than throwing', () => { + // Reading is core's side and stays fail-safe: a typo in a layout costs that + // spot, not the page. Only WRITING is strict, which is the next test. + assert.equal(extensionFor('nope'), null) +}) + +test('a module fills a declared slot and core reads it back', () => { + declareSlot('admin.users.detail') + registerExtension('uo', 'admin.users.detail', Fake) + assert.equal(extensionFor('admin.users.detail'), Fake) + assert.deepEqual(registeredIds(), ['uo']) +}) + +test('filling an unknown slot throws, naming the slot', () => { + // This is the one place the client registry is NOT fail-open, and the reason + // is asymmetry of consequence: a dropped nav row costs a link the viewer can + // reach another way, a silently dropped extension is invisible to everyone + // including its author. Declaration structurally precedes filling (§3.1), so + // this can only ever be a typo or a version skew. + assert.throws(() => registerExtension('uo', 'site.footer.sttaus', Fake), /unknown extension slot "site\.footer\.sttaus"/) +}) + +test('a non-component fill throws', () => { + declareSlot('site.footer.status') + assert.throws(() => registerExtension('uo', 'site.footer.status', { render: true }), /is not a component/) +}) + +test('a second module cannot take a filled slot, and the first keeps it', () => { + // Matches the server's rule exactly (registries.js): first fill wins, second + // is an error. The second half of the assertion is the one that matters — a + // rejected fill must not have half-replaced the incumbent. + declareSlot('admin.users.detail') + registerExtension('uo', 'admin.users.detail', Fake) + assert.throws(() => registerExtension('other', 'admin.users.detail', Other), /already filled by "uo"/) + assert.equal(extensionFor('admin.users.detail'), Fake) +}) + +test('declaring a slot twice throws', () => { + // Core-side programming error: two owners for one position means whichever + // module registered first wins by file order. + declareSlot('site.footer.status') + assert.throws(() => declareSlot('site.footer.status'), /already declared/) +}) + +test('core fills a slot through the same seam a module uses', () => { + // The client twin of registries.registerCore(). Core is a registrant with an + // id like any other, which is what makes slice 3 a deletion: the module + // registers the same slot and core drops its line. + declareSlot('site.footer.status') + registerExtension('core', 'site.footer.status', Fake) + assert.deepEqual(registeredIds(), ['core']) +}) + +test('declareSlot and extensionFor are not on the module-facing registry', () => { + // Declaring is core's alone (§3.7), and reading who filled a slot is core's + // too — the same line featureProviders() draws. registerExtension IS on the + // object, because filling is the whole point. + assert.equal(registry.declareSlot, undefined) + assert.equal(registry.extensionFor, undefined) + assert.equal(typeof registry.registerExtension, 'function') +}) diff --git a/server/src/modules/version.js b/server/src/modules/version.js index f58c441..24b34bc 100644 --- a/server/src/modules/version.js +++ b/server/src/modules/version.js @@ -9,11 +9,18 @@ // Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and // has nothing to say about a website module) and from any module's own version. +// 1.2.0 — the CLIENT registry gained `registerExtension` and core gained client +// extension slots (MODULE_API.md §3.7): the twin of this half's declareSlot / +// registerExtension, for module content inside a core *page* rather than under a +// core route prefix. Nothing on the server changed, and this file bumps anyway — +// the two halves state ONE version, because a module declares a single `coreApi` +// range and is served one chunk (client/src/modules/version.js). +// // 1.1.0 — `ctx` gained `activity.log`, `users.getById` and `site.baseUrl`, each // because module-uo's extraction needed it and none of them could be vendored: // an admin action a module performs belongs in core's one audit log, the // extension slot needs the user its prefix names, and §2.7 forbids a module // reading core's `APP_BASE_URL` for itself. Additions only, so minor. -const MODULE_API_VERSION = '1.1.0' +const MODULE_API_VERSION = '1.2.0' module.exports = { MODULE_API_VERSION }