/** * Typed selector builders that consume a Morfo's contract and emit * CSS selectors safe to feed to `target.matches(...)`. * * Why this exists: * * `sema/components/{name}.ts` packs (and any future eidos consumer that * builds selectors programmatically) used to write strings like * `'[data-dialog-content][data-event-family="commit"]'` by hand. That * couples the consumer to morfo's emitted attrs WITHOUT a compile-time * link — if the morfo renames the part `content` → `body`, the string * goes stale silently. Same for event names declared in the morfo. * * `semaSelector(morfo, partKebab, matchers?)` builds the selector from * the morfo's actual contract: * * - `partKebab` is typed against `morfo.parts[].kebab`. Compile error * if the part doesn't exist. * - `eventName` (when matched) is typed against `morfo.events[].name`. * - `eventFamily` / `eventIntent` are typed against the canonical * unions (`SemaFamily`, `Intent`). * * Output is a plain CSS selector string — `SemaCascadeRule` consumes it * unchanged via `target.matches()`. No runtime overhead beyond string * concatenation. * * Authors of `sema/components/*.ts` SHOULD use this helper for any * selector that targets a morfo-emitted attr. Hand-written strings are * a code smell and signal a contract drift. */ import type { Morfo, MorfoEvent } from './types' import type { Intent } from '../intent' import type { SemaFamily } from '../sema/types' import { MorfoSelectorError } from './errors' import { partMarkerAttr } from './compile' // ── Escaping ────────────────────────────────────────────────────────────── /** * Escape a value for interpolation inside a double-quoted CSS attribute * selector (`[attr="value"]`). Backslashes and double quotes are the only * characters that can break out of the quoted string; newlines are invalid * raw in CSS strings, so they go to their escaped code-point form. Own, * isomorphic implementation — `CSS.escape` is a DOM global this layer * (SSR / node tests) can't assume (MOR-1, clean-room 2026-07-10: unescaped * matchers produced selectors that THREW inside `target.matches()` during * the sema cascade). */ function escapeAttrValue(value: string): string { return value.replace(/[\\"]/g, (c) => `\\${c}`).replace(/\n/g, '\\a ') } /** Attr NAMES can't be quoted in a selector — enforce a dashed-ident shape. */ const ATTR_NAME_RE = /^[a-zA-Z][\w-]*$/ function assertAttrName(context: string, attr: string): void { if (!ATTR_NAME_RE.test(attr)) { throw new MorfoSelectorError( `semaSelector: ${context}.attr "${attr}" is not a valid attribute name` ) } } // ── Type extraction helpers ─────────────────────────────────────────────── /** Union of `kebab` literals declared on `morfo.parts`. */ export type PartKebabOf = M['parts'][number]['kebab'] /** Union of `name` literals declared on `morfo.events`. `never` if events absent. */ export type EventNameOf = M extends { events?: readonly MorfoEvent[] } ? NonNullable[number]['name'] : never /** * Union of keyboard `action` literals declared across `morfo.parts[].keyboard`. * Distributive on purpose: part literals without a `keyboard` key have no such * property in their `as const` type, so a plain indexed access would not * compile — each part is matched structurally instead. `never` when no part * declares keyboard. The `keyboard?:` in the matcher is what keeps the WIDENED * `Morfo` resolving to `string` (its `keyboard` is optional on the interface; * a required matcher made `ActionNameOf` collapse to `never` and every * generic helper lost its handlers' contextual type). */ export type ActionNameOf = M['parts'][number] extends infer P ? P extends { keyboard?: readonly { action: infer A extends string }[] | undefined } ? A : never : never /** * Union of event `name` literals that may STAMP on part `K`: the event's * canonical `target` is `K`, or `K` appears in its `allowedTargets`. This is * the type behind the part-anchored emission surface * (`SomaRuntimePart.trigger`) — an event that cannot land on a part cannot be * emitted from it, at compile time. * * `targetFallback` parts are NOT included on purpose: a fallback is the * RUNTIME's mount-state resolution, never a caller's anchor — anchoring to a * fallback part would let a provider force the degraded landing while the * primary surface is mounted. */ export type EventNameTargeting< M extends Morfo, K extends PartKebabOf > = M extends { events?: readonly MorfoEvent[] } ? NonNullable[number] extends infer E ? E extends { name: infer N extends string; semantic: infer S } ? | (S extends { target: { target: K } } ? N : never) | (S extends { allowedTargets: readonly (infer R)[] } ? R extends { target: K } ? N : never : never) : never : never : never // ── Selector builder ────────────────────────────────────────────────────── export interface SemaSelectorMatchers { /** Match against the data-event-family stamped during emit. */ eventFamily?: SemaFamily /** Match against the data-event-intent stamped during emit. */ eventIntent?: Intent /** * Match against the exact `data-event` stamped during emit. Typed * against the morfo's declared event names — compile error on * unknown names. */ eventName?: EventNameOf /** * Match an event by prefix (`[data-event^="close-"]`). String form * because TypeScript can't enforce prefix-substring relationships * across the morfo's event names; authors are responsible for * writing a real prefix. */ eventNamePrefix?: string /** * Match a state attr emitted by the morfo's `parts[].data` declaration * (`data-state`, `data-color`, `data-disabled`, `data-last-action`, * `data-nested`, …). For now `attr`/`value` are plain strings; a * future iteration will type them against the morfo's data contract. */ state?: { attr: string; value: string } /** * Match an aria attr declared by the morfo's `parts[].aria` (e.g. * `[role="alertdialog"]`). Plain strings for now. */ aria?: { attr: string; value: string } /** * Optional ancestor selector — output becomes ` `. * Useful for instance scoping (`'#delete-confirm-dialog'`) or context * scoping (`'[data-form]'`). Plain string by design — ancestors live * outside the morfo's contract. */ ancestor?: string /** * Append arbitrary CSS at the end of the selector (`:hover`, * `:focus-visible`, etc.). Escape hatch — use sparingly. */ pseudo?: string } /** * Build a CSS selector from a morfo's part contract + sema event tokens. * See module-level doc for the rationale. */ export function semaSelector( morfo: M, partKebab: PartKebabOf, matchers?: SemaSelectorMatchers ): string { const part = morfo.parts.find((p) => p.kebab === partKebab) if (!part) { throw new MorfoSelectorError( `semaSelector: morfo "${morfo.name}" has no part with kebab "${String(partKebab)}"` ) } // The marker attr — ONE source with `compileMorfo` (partMarkerAttr), so // the emission and the matcher can never drift apart (MOR-2). const marker = partMarkerAttr(morfo.kebab, part.kebab) const segments: string[] = [`[${marker}]`] if (matchers?.eventFamily) { segments.push(`[data-event-family="${escapeAttrValue(matchers.eventFamily)}"]`) } if (matchers?.eventIntent) { segments.push(`[data-event-intent="${escapeAttrValue(matchers.eventIntent)}"]`) } if (matchers?.eventName) { const events = (morfo.events ?? []) as readonly MorfoEvent[] const known = events.some((e) => e.name === matchers.eventName) if (!known) { throw new MorfoSelectorError( `semaSelector: morfo "${morfo.name}" has no event named "${String(matchers.eventName)}"` ) } segments.push(`[data-event="${escapeAttrValue(String(matchers.eventName))}"]`) } if (matchers?.eventNamePrefix) { segments.push(`[data-event^="${escapeAttrValue(matchers.eventNamePrefix)}"]`) } if (matchers?.state) { assertAttrName('state', matchers.state.attr) segments.push(`[${matchers.state.attr}="${escapeAttrValue(matchers.state.value)}"]`) } if (matchers?.aria) { assertAttrName('aria', matchers.aria.attr) segments.push(`[${matchers.aria.attr}="${escapeAttrValue(matchers.aria.value)}"]`) } if (matchers?.pseudo) { segments.push(matchers.pseudo) } const compound = segments.join('') return matchers?.ancestor ? `${matchers.ancestor} ${compound}` : compound }