diff --git a/client/src/entry.jsx b/client/src/entry.jsx index 90209b3..3a9b142 100644 --- a/client/src/entry.jsx +++ b/client/src/entry.jsx @@ -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 diff --git a/client/test/registration.test.js b/client/test/registration.test.js index 9e41927..8a57733 100644 --- a/client/test/registration.test.js +++ b/client/test/registration.test.js @@ -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, '\.')}"`)) } })