MODULE_API 1.6.0 expands the contract this book teaches against, so the book
owes two shapes and one correction. Chapter 2 gains both and the template grows
a working version of each, because a reader following a snippet has no way to
find out whether it runs.
ONE SENTENCE WAS WRONG. Chapter 2 said, of extension slots, "Only core may
declare a slot; a module may only fill one". 1.6.0 inverted exactly that: a
module declares a place on its OWN page and core fills it. That is not a stale
detail - a new game's module cannot implement Teams at all without the inverted
direction, so it is the shape the reader needs and did not have.
THE TWO SHAPES
- The inverted slot. A new "Slots go the other way too" section: why the
direction has to invert (core owns the Team, not the word for one), the
namespace rule, one slot per PLACE, the optional { core } naming which of
core's three contributions goes there, and why asking for one core does not
offer throws when almost everything else in that registry fails open.
- registerTeamProvider, in "Becoming the source of Teams". The first
registration where core calls YOU and waits, which is where every rule in it
comes from: the envelope, the ten-second budget, refusing as a normal answer,
and the one mistake worth naming - answering with an empty list because the
game is unreachable, which core reads as authoritative and acts on.
projectRoster gets its own treatment because it is the exception that fails
CLOSED. pageUrlTemplate is a footnote beside it, as intended.
WHAT THE TEMPLATE GREW
model/clans/ - the provider over two tables, with the guards that matter: an
unreachable game refuses rather than reporting no clans, an empty roster is
refused unless the game says the clan is empty (which is why the schema keeps a
member count the rows cannot supply), and the audience rule lives in one file
that both projectRoster and the module's own page consult, because a second copy
drifts in the direction that publishes what core is withholding.
Its own /clans routes, deliberately not /teams - core mounts that itself, and
the loader would refuse the collision. A clan list page and a clan page that
declares three slots for core.
12 provider tests and three registration tests, 47 server and 20 client in
total. The purge test finally proves something: two of the three tables are now
a parent and its child.
WHAT IT DOES NOT DO. Enumerate the contract. The kit teaches one path end to end
and links out; it has never mentioned three pre-Teams registrations and that is
the design, not a gap.
FOUND WHILE WRITING IT: core filled three literal uo.guild.* slot names, so the
inverted direction reached exactly one module and every other game's page came
up empty with nothing logged. Fixed in website#160 / Module-uo#15 / docs#165
before this chapter could teach it - which is what this phase is for.
The ci/core-ref.json pin moves in a later commit on this branch: checkCoreApi is
an equality against a core on main, and 1.6.0 does not reach main until the
cutover.
Co-Authored-By: Claude <noreply@anthropic.com>
138 lines
7.8 KiB
JavaScript
138 lines
7.8 KiB
JavaScript
// ── The client entry point ────────────────────────────────────────────────
|
|
//
|
|
// Core serves `dist/entry.js` from your module's directory and injects it into
|
|
// its own HTML as a same-origin `<script type="module" src>` before `</body>`.
|
|
// This file registers what the module has; core renders it. Normative:
|
|
// MODULE_API.md §3.3.
|
|
//
|
|
// **Registration is synchronous and happens at evaluation time.** Module scripts
|
|
// are deferred, so this runs after core's bundle — which is where `window.__rg`
|
|
// is published — and before core's first render. There is no subscription and no
|
|
// late registration: a module that registered asynchronously would register after
|
|
// the route table had been read, and the symptom is a page that redirects home
|
|
// with nothing logged anywhere.
|
|
//
|
|
// So everything below is a plain top-level call and every page is a STATIC
|
|
// import. Lazy-loading the routes is the natural instinct for a chunk that grows,
|
|
// and it is the one thing this seam cannot have.
|
|
|
|
import { registry, coreApiVersion } from './core.js'
|
|
|
|
import WorldStatus from './routes/public/WorldStatus.jsx'
|
|
import Clans from './routes/public/Clans.jsx'
|
|
import Clan from './routes/public/Clan.jsx'
|
|
|
|
// Your module id, exactly as `module.json` spells it. Core keys the registry by
|
|
// it and prefixes every route path with it.
|
|
const ID = 'examplegame'
|
|
|
|
// ── Routes ────────────────────────────────────────────────────────────────
|
|
//
|
|
// Paths are relative to your module's namespace and core prefixes them. Whatever
|
|
// you write here, a public route lands at `/<id>/<path>`, an admin route at
|
|
// `/admin/<id>/<path>` and a player route at `/player/<id>/<path>`. You cannot
|
|
// write the segment your routes hang under, which is the point: two modules
|
|
// installed side by side cannot collide, and an operator can see from a URL which
|
|
// module served it.
|
|
//
|
|
// So this one page is at `/examplegame/status`.
|
|
//
|
|
// **Note what is NOT here: an auth wrapper.** `gate: { roles: [...] }` is
|
|
// available and core applies it as its own `RoleGate`; supplying your own is not
|
|
// possible, because the sidebar and the route table have to agree about who may
|
|
// see what, and they only do if one thing decides.
|
|
registry.registerRoutes(ID, {
|
|
public: [
|
|
{ path: 'status', element: <WorldStatus /> },
|
|
{ path: 'clans', element: <Clans /> },
|
|
// A parameter, and the name matters twice: `useParams()` in the page reads
|
|
// `externalId`, and the server's `pageUrlTemplate` substitutes `{externalId}`
|
|
// into this same path so core's notification email can link here. Nothing
|
|
// checks those three against each other — this is the seam to get right by
|
|
// hand, and the cost of getting it wrong is mail linking at a page that 404s.
|
|
{ path: 'clans/:externalId', element: <Clan /> },
|
|
],
|
|
})
|
|
|
|
// ── Nav ───────────────────────────────────────────────────────────────────
|
|
//
|
|
// A registered row is an ORDINARY row from here on. It interleaves into core's
|
|
// own navigation, and an operator can reorder it, relabel it or hide it from the
|
|
// admin nav editor exactly as they can core's — because the interleave happens
|
|
// before the override merge, and the override layer is keyed by `to`.
|
|
//
|
|
// Three fields worth knowing before you need them:
|
|
//
|
|
// • `order` places the row among core's, which are keyed by their index. A row
|
|
// with NO order appends after them, rather than defaulting to 0 — otherwise
|
|
// "I didn't ask for a position" would mean "put me first".
|
|
// • `group` (admin sidebar) names an existing core group; an unknown name
|
|
// appends a new group at the end rather than dropping the row.
|
|
// • `icon` is a component, and core supplies no fallback. Public header rows
|
|
// carry no icons, so there is none here — but an admin or player row without
|
|
// one is the only row in its sidebar with no glyph, which reads as breakage.
|
|
// Match the nav you are landing in: the admin sidebar draws at 18px with a
|
|
// 1.6 stroke, the player portal at 16px with a 2.
|
|
registry.registerNav(ID, {
|
|
area: 'public',
|
|
items: [
|
|
{ label: 'World', to: '/examplegame/status' },
|
|
// The clan PAGE gets no nav row: rows point at pages a visitor can reach
|
|
// without knowing an id, and `/examplegame/clans/:externalId` is not one.
|
|
// `registration.test.js` checks every row against a route this module
|
|
// registered, which is the agreement that rots quietly.
|
|
{ label: 'Clans', to: '/examplegame/clans' },
|
|
],
|
|
})
|
|
|
|
// ── The inverted slot: this module DECLARES, core fills ───────────────────
|
|
//
|
|
// Everywhere else, core declares a place and a module fills it
|
|
// (`registry.registerExtension`). This is the mirror, added in MODULE_API 1.6.0
|
|
// for Teams: **a module declares a place on its own page and core fills it.**
|
|
//
|
|
// Teams are a core primitive with no core surface — core owns the tables, the
|
|
// membership sync, the access rules, the forum and the feed, and does not own the
|
|
// word "clan" — so the page is this module's and core contributes into it.
|
|
//
|
|
// Each declaration says two things: WHERE, in this module's own vocabulary, and
|
|
// WHICH of core's contributions belongs there. **Core offers a contribution and
|
|
// never names a slot** — it cannot, since it does not know what you called your
|
|
// page — so the second argument is the whole of what gets core's content onto it.
|
|
// Core's three, as of 1.6.0:
|
|
//
|
|
// `team.activity` the Team activity feed
|
|
// `team.forum` the Team forum panel
|
|
// `team.notify` the per-Team notification control
|
|
//
|
|
// Four things about these three lines:
|
|
//
|
|
// • **The name must be namespaced under this module's id**, and core enforces
|
|
// that rather than trusting it. It is what keeps two modules from claiming one
|
|
// name, and it makes the owner readable at the point of use in `Clan.jsx`.
|
|
// • **One slot per PLACE, not one per page.** A slot holds one component, so
|
|
// three contributions need three declarations — and this module then decides
|
|
// where each one sits, which is the freedom it declared them for.
|
|
// • **Asking for a contribution core does not offer THROWS here**, unlike almost
|
|
// everything else in the registry, which fails open. Core's catalogue is fixed
|
|
// at build time and your `coreApi` range has already been checked, so an
|
|
// unknown one is always a typo or a version skew — and the alternative failure
|
|
// is a page that renders empty forever with nothing logged.
|
|
// • **`{ core }` is optional.** A slot that asks for nothing stays empty, which
|
|
// is what you want for a place you intend to fill yourself.
|
|
//
|
|
// Declaring costs nothing on a core that offers none of them: core's fills are
|
|
// applied after every module chunk has evaluated, and a contribution nothing asks
|
|
// for is a no-op rather than an error. Both directions of that are silent on
|
|
// purpose — neither side may assume the other is there.
|
|
registry.declareModuleSlot(ID, 'examplegame.clan.header', { core: 'team.notify' })
|
|
registry.declareModuleSlot(ID, 'examplegame.clan.detail', { core: 'team.activity' })
|
|
registry.declareModuleSlot(ID, 'examplegame.clan.forum', { core: 'team.forum' })
|
|
|
|
// `module.json`'s `coreApi` range was checked by the loader before this file was
|
|
// ever served, so there is nothing to re-check here. Log it anyway: a mismatch
|
|
// between the core that validated your manifest and the core that published this
|
|
// global is otherwise invisible from the browser, which is where the client half
|
|
// actually fails.
|
|
console.info(`[${ID}] registered against core API ${coreApiVersion}`)
|