/** * lib/slot-names.ts — SSOT of the `` names scaffold-component * RENDERS, per view, consumed by BOTH sides: the six renderers * (`scaffold-component/render/{list,detail,form,home,dashboard,reconduction}.ts`) * name every slot through `slotName.(eLower, suffix)`, and the catalogue * `scaffold-extension-config` publishes comes from `slotCatalogue()`. One table, * one join formula — a drift between the pages and the catalogue is impossible * by construction, and `lib/__tests__/slot-names-drift.test.ts` still pins the * RENDERED page as the independent oracle. * * Before 2026-09-24 the catalogue listed `${module}.${entity}.list.before…` * while the pages rendered `${entity}.header.actions`, `.table.columns.*`, * `.detail.header`… — ZERO names in common, so every `` written from the * published catalogue was dead (audit B-CRIT-05). The rendered names win: they * are what client apps already target. Until lot 14 (2026-09-24) the renderers * kept 20 literal `${eLower}.…` strings tied to this table by the drift test * alone; they now import the accessor below. */ /** Slot suffixes each page kind renders, in document order. Full name = `${entityLower}.${suffix}`. */ export const SLOT_SUFFIXES_BY_VIEW = { list: ['header.actions', 'table.columns.before', 'table.columns.after'], detail: ['detail.header', 'detail.sidebar'], form: ['form.fields.before', 'form.fields.after'], home: ['home.header', 'home.kpis', 'home.kpis.after', 'home.navigation', 'home.footer'], dashboard: ['dashboard.header', 'dashboard.widgets.before', 'dashboard.widgets.after', 'dashboard.footer'], reconduction: ['reconduction.header', 'reconduction.footer'], } as const export type SlotView = keyof typeof SLOT_SUFFIXES_BY_VIEW /** A slot suffix of one view (`SlotSuffix<'list'>` = `'header.actions' | 'table.columns.before' | 'table.columns.after'`), or of any view. */ export type SlotSuffix = (typeof SLOT_SUFFIXES_BY_VIEW)[V][number] /** What a renderer calls at each `` site: `(eLower, suffix)` → the exact rendered name. */ export type SlotNamer = (entityLower: string, suffix: SlotSuffix) => string /** The views scaffold-component emits by default (`views` default): the catalogue's floor. */ export const DEFAULT_SLOT_VIEWS: readonly SlotView[] = ['list', 'detail', 'form'] const SLOT_DESCRIPTIONS: Record = { 'header.actions': 'List page header, next to the create button.', 'table.columns.before': 'List page, above the table / cards / kanban host.', 'table.columns.after': 'List page, below the table host.', 'detail.header': 'Detail page, top of the fiche.', 'detail.sidebar': 'Detail page, side column.', 'form.fields.before': 'Form page, before the scaffolded fields.', 'form.fields.after': 'Form page, after the scaffolded fields.', 'home.header': 'Hub page header.', 'home.kpis': 'Hub page KPI row (replaces the generated cards when no KPI is wired).', 'home.kpis.after': 'Hub page, after the KPI row.', 'home.navigation': 'Hub page navigation cards area.', 'home.footer': 'Hub page footer.', 'dashboard.header': 'Dashboard page header.', 'dashboard.widgets.before': 'Dashboard page, before the widget grid.', 'dashboard.widgets.after': 'Dashboard page, after the widget grid.', 'dashboard.footer': 'Dashboard page footer.', 'reconduction.header': 'Reconduction page header.', 'reconduction.footer': 'Reconduction page footer.', } /** The one join formula: `${entityLower}.${suffix}` — every exported name goes through it. */ function joinSlot(entityLower: string, suffix: string): string { return `${entityLower}.${suffix}` } /** The `eLower` every renderer derives: first character lowercased, rest verbatim (`Employee` → `employee`, `JourFerie` → `jourFerie`). */ export function entityLowerOf(entity: string): string { return entity.charAt(0).toLowerCase() + entity.slice(1) } function slotNamerFor(view: V): SlotNamer { const known: readonly string[] = SLOT_SUFFIXES_BY_VIEW[view] return (entityLower, suffix) => { if (!known.includes(suffix)) { throw new Error(`slot-names: '${suffix}' is not a slot of the ${view} view (known: ${known.join(', ')})`) } return joinSlot(entityLower, suffix) } } /** * Per-view typed accessors the RENDERERS consume at each `` * site: `slotName.list(eLower, 'header.actions')` → `'jourFerie.header.actions'`. * The suffix is typed against the view's row of `SLOT_SUFFIXES_BY_VIEW` (a * suffix the view does not render is a tsc error) and re-checked at run time * (tsx runs untyped), so a renderer cannot emit a name this table does not * list (2026-09-24, lot 14). */ export const slotName: { readonly [V in SlotView]: SlotNamer } = { list: slotNamerFor('list'), detail: slotNamerFor('detail'), form: slotNamerFor('form'), home: slotNamerFor('home'), dashboard: slotNamerFor('dashboard'), reconduction: slotNamerFor('reconduction'), } /** Exact slot names the `view` page of `entity` renders. */ export function slotNamesFor(entity: string, view: SlotView): string[] { const eLower = entityLowerOf(entity) return SLOT_SUFFIXES_BY_VIEW[view].map((s) => joinSlot(eLower, s)) } export interface SlotCatalogueEntry { name: string view: SlotView description: string } /** The catalogue an ExtensionConfig publishes for `entity`: one entry per rendered slot of the given views. */ export function slotCatalogue(entity: string, views: readonly SlotView[] = DEFAULT_SLOT_VIEWS): SlotCatalogueEntry[] { const eLower = entityLowerOf(entity) const out: SlotCatalogueEntry[] = [] for (const view of views) { for (const suffix of SLOT_SUFFIXES_BY_VIEW[view]) { out.push({ name: joinSlot(eLower, suffix), view, description: SLOT_DESCRIPTIONS[suffix] ?? '' }) } } return out }