feat(guilds): name the core contribution each declared slot wants #15

Merged
whitlocktech merged 1 commits from feature/teams-slot-contributions into edge 2026-08-19 06:19:53 +00:00
2 changed files with 37 additions and 18 deletions

View File

@@ -189,10 +189,13 @@ registry.registerExtension(ID, 'player.invite.accepted', InviteGameAccountStep)
// populates, but core does not own the word "guild" and publishes no Team page of
// its own — so the page is ours and core contributes the activity feed to it.
//
// Declared under this module's own namespace, which core enforces. Core's fill is
// applied after every module chunk has evaluated, so declaring it here is early
// enough; on a core that knows nothing of Teams it simply stays empty.
registry.declareModuleSlot(ID, 'uo.guild.detail')
// Declared under this module's own namespace, which core enforces. The second
// argument is what gets core's content into the place: **core offers a
// CONTRIBUTION and never names a slot**, so this module says where each one goes
// and keeps its own word for the place. Core's fills are applied after every
// module chunk has evaluated, so declaring here is early enough; on a core that
// knows nothing of Teams the slot simply stays empty.
registry.declareModuleSlot(ID, 'uo.guild.detail', { core: 'team.activity' })
// A SECOND place on the same page, for core's Team forum (TEAMS.md Part 5). Two
// declarations rather than one, because a slot holds one component and this module
@@ -200,14 +203,14 @@ registry.declareModuleSlot(ID, 'uo.guild.detail')
// the feed reads as part of the guild's story, the forum is a room you go into.
// Neither knows the other exists, and a core that fills only one leaves the other
// empty.
registry.declareModuleSlot(ID, 'uo.guild.forum')
registry.declareModuleSlot(ID, 'uo.guild.forum', { core: 'team.forum' })
// And a THIRD, at the top of the same page, for core's per-Team notification
// control (TEAMS.md §6.3). Same reasoning as the other two and a different place:
// muting a guild is an action ON this page, so it sits with the page's heading
// rather than after its content. Core resolves whether this viewer is in the
// Team at all — this module neither knows nor asks.
registry.declareModuleSlot(ID, 'uo.guild.header')
registry.declareModuleSlot(ID, 'uo.guild.header', { core: 'team.notify' })
// `module.json`'s `coreApi` range is checked by the loader before this file is
// ever served, so there is nothing to re-check here. It is logged because a

View File

@@ -39,12 +39,18 @@ const CHUNK = path.resolve(HERE, '..', 'dist', 'entry.js')
// nothing here renders, so a named stub is enough to be imported and passed on.
const stub = (name) => Object.assign(() => null, { displayName: name })
// Core's contribution catalogue, as of MODULE_API 1.6.0. Written down rather than
// imported — this suite runs against the BUILT chunk with no core in the process
// — which means it is a claim about core that has to be re-read when core's list
// changes. That is the same trade the rest of this fake makes.
const CORE_CONTRIBUTIONS = ['team.activity', 'team.forum', 'team.notify']
function fakeRg() {
const routes = { public: [], admin: [], player: [] }
const nav = { public: [], admin: [], player: [] }
const providers = new Map()
const extensions = new Map()
const declaredSlots = new Set()
const declaredSlots = new Map()
return {
version: '1.3.0',
react,
@@ -74,13 +80,18 @@ function fakeRg() {
extensions.set(slot, { id, Component })
},
// The INVERTED direction (core API 1.6.0): this module declares a place on
// its OWN page and core fills it. Core enforces the namespace, so the fake
// does too — a chunk that declared an unnamespaced slot would pass here and
// throw in a browser.
declareModuleSlot(id, name) {
// its OWN page and core fills it. Core enforces the namespace and the
// contribution name, so the fake does too — a chunk that declared an
// unnamespaced slot, or asked for a contribution core does not offer, would
// pass here and throw in a browser.
declareModuleSlot(id, name, options = {}) {
if (!name.startsWith(`${id}.`)) throw new Error(`declareModuleSlot: "${name}" must be namespaced "${id}."`)
if (declaredSlots.has(name)) throw new Error(`extension slot "${name}" already declared`)
declaredSlots.add(name)
const wants = options.core ?? null
if (wants !== null && !CORE_CONTRIBUTIONS.includes(wants)) {
throw new Error(`declareModuleSlot: "${name}" asks for core contribution "${wants}", which core does not offer`)
}
declaredSlots.set(name, wants)
},
routesFor: (area) => routes[area],
navFor: (area) => nav[area],
@@ -209,7 +220,7 @@ it('registers under exactly one module id, matching the manifest', () => {
assert.deepEqual([...owners], [manifest.id])
})
it('declares its own guild slots, for core to fill', () => {
it('declares its own guild slots, each naming the core contribution it wants', () => {
// The inverted direction (TEAMS.md Part 3). Teams are a core primitive with no
// core page: core owns the activity feed and the forum, this module owns the
// word "guild", so this module declares the places and core puts them in.
@@ -219,10 +230,15 @@ it('declares its own guild slots, for core to fill', () => {
// away this module's ability to place them separately on its own page — and it
// does place them separately, the control above the roster and the other two
// below it.
assert.deepEqual([...registered.declaredSlots], [
'uo.guild.detail',
'uo.guild.forum',
'uo.guild.header',
//
// The second argument is what actually gets core's content here. **Core offers
// a contribution and never names a slot** — the first cut of this reached only
// this module, because core filled the literal name `uo.guild.detail` and any
// other game's page went empty with no error.
assert.deepEqual([...registered.declaredSlots.entries()], [
['uo.guild.detail', 'team.activity'],
['uo.guild.forum', 'team.forum'],
['uo.guild.header', 'team.notify'],
])
})
@@ -230,7 +246,7 @@ it('every declared slot is rendered by the page that owns it', () => {
// A slot nothing renders is a slot core fills into the void. Asserted against
// the source rather than the chunk, since the chunk is minified.
const page = fs.readFileSync(path.resolve(HERE, '..', 'src', 'routes', 'public', 'Guild.jsx'), 'utf8')
for (const name of registered.declaredSlots) {
for (const name of registered.declaredSlots.keys()) {
assert.match(page, new RegExp(`name="${name.replace(/\./g, '\.')}"`))
}
})