| /** | |
| * Plugin slot registry. | |
| * | |
| * Plugins can inject components into named locations in the app shell | |
| * (header-left, sidebar, backdrop, etc.) by calling | |
| * `window.__HERMES_PLUGINS__.registerSlot(pluginName, slotName, Component)` | |
| * from their JS bundle. Multiple plugins can populate the same slot β they | |
| * render stacked in registration order. | |
| * | |
| * The canonical slot names are documented in `KNOWN_SLOT_NAMES` below. The | |
| * registry accepts any string so plugin ecosystems can define their own | |
| * slots; the shell only renders `<PluginSlot name="..." />` for the slots | |
| * it knows about. | |
| */ | |
| import React, { Fragment, useEffect, useState } from "react"; | |
| /** Slot locations the built-in shell renders. Plugins declaring any of | |
| * these in their manifest's `slots` field get wired in automatically. | |
| * | |
| * - `backdrop` β rendered inside `<Backdrop />`, above the noise layer | |
| * - `header-left` β injected before the Hermes brand in the top bar | |
| * - `header-right` β injected before the theme/language switchers | |
| * - `header-banner` β injected below the top nav bar, full-width | |
| * - `sidebar` β the cockpit sidebar rail (only rendered when | |
| * `layoutVariant === "cockpit"`) | |
| * - `pre-main` β rendered above the route outlet (inside `<main>`) | |
| * - `post-main` β rendered below the route outlet (inside `<main>`) | |
| * - `footer-left` β replaces the left footer cell content | |
| * - `footer-right` β replaces the right footer cell content | |
| * - `overlay` β fixed-position layer above everything else; | |
| * useful for chrome (scanlines, vignettes) the | |
| * theme's customCSS can't achieve alone | |
| */ | |
| export const KNOWN_SLOT_NAMES = [ | |
| "backdrop", | |
| "header-left", | |
| "header-right", | |
| "header-banner", | |
| "sidebar", | |
| "pre-main", | |
| "post-main", | |
| "footer-left", | |
| "footer-right", | |
| "overlay", | |
| ] as const; | |
| export type KnownSlotName = (typeof KNOWN_SLOT_NAMES)[number]; | |
| type SlotListener = () => void; | |
| interface SlotEntry { | |
| plugin: string; | |
| component: React.ComponentType; | |
| } | |
| /** Map<slotName, SlotEntry[]>. Entries are appended in registration order. */ | |
| const _slotRegistry: Map<string, SlotEntry[]> = new Map(); | |
| const _slotListeners: Set<SlotListener> = new Set(); | |
| function _notifySlots() { | |
| for (const fn of _slotListeners) { | |
| try { | |
| fn(); | |
| } catch { | |
| /* ignore */ | |
| } | |
| } | |
| } | |
| /** Register a component for a slot. Called by plugin bundles via | |
| * `window.__HERMES_PLUGINS__.registerSlot(...)`. | |
| * | |
| * If the same (plugin, slot) pair is registered twice, the later call | |
| * replaces the earlier one β this matches how React HMR expects plugin | |
| * re-mounts to behave. */ | |
| export function registerSlot( | |
| plugin: string, | |
| slot: string, | |
| component: React.ComponentType, | |
| ): void { | |
| const existing = _slotRegistry.get(slot) ?? []; | |
| const filtered = existing.filter((e) => e.plugin !== plugin); | |
| filtered.push({ plugin, component }); | |
| _slotRegistry.set(slot, filtered); | |
| _notifySlots(); | |
| } | |
| /** Read current entries for a slot. Returns a copy so callers can't mutate | |
| * registry state. */ | |
| export function getSlotEntries(slot: string): SlotEntry[] { | |
| return (_slotRegistry.get(slot) ?? []).slice(); | |
| } | |
| /** Subscribe to registry changes. Returns an unsubscribe function. */ | |
| export function onSlotRegistered(fn: SlotListener): () => void { | |
| _slotListeners.add(fn); | |
| return () => { | |
| _slotListeners.delete(fn); | |
| }; | |
| } | |
| /** Clear a specific plugin's slot registrations. Useful for HMR / | |
| * plugin reload flows β not wired in by default. */ | |
| export function unregisterPluginSlots(plugin: string): void { | |
| let changed = false; | |
| for (const [slot, entries] of _slotRegistry.entries()) { | |
| const kept = entries.filter((e) => e.plugin !== plugin); | |
| if (kept.length !== entries.length) { | |
| changed = true; | |
| if (kept.length === 0) _slotRegistry.delete(slot); | |
| else _slotRegistry.set(slot, kept); | |
| } | |
| } | |
| if (changed) _notifySlots(); | |
| } | |
| interface PluginSlotProps { | |
| /** Slot identifier (e.g. `"sidebar"`, `"header-left"`). */ | |
| name: string; | |
| /** Optional content rendered when no plugins have claimed the slot. | |
| * Useful for built-in defaults the plugin would replace. */ | |
| fallback?: React.ReactNode; | |
| } | |
| /** Render all components registered for a given slot, stacked in order. | |
| * | |
| * Component re-renders when the slot registry changes so plugins that | |
| * arrive after initial mount show up without a manual refresh. */ | |
| export function PluginSlot({ name, fallback }: PluginSlotProps) { | |
| const [entries, setEntries] = useState<SlotEntry[]>(() => getSlotEntries(name)); | |
| useEffect(() => { | |
| // Pick up anything registered between the initial `useState` call | |
| // and the first effect tick, then subscribe for future changes. | |
| setEntries(getSlotEntries(name)); | |
| const unsub = onSlotRegistered(() => setEntries(getSlotEntries(name))); | |
| return unsub; | |
| }, [name]); | |
| if (entries.length === 0) { | |
| return fallback ? React.createElement(Fragment, null, fallback) : null; | |
| } | |
| return React.createElement( | |
| Fragment, | |
| null, | |
| ...entries.map((entry) => | |
| React.createElement(entry.component, { key: entry.plugin }), | |
| ), | |
| ); | |
| } | |