You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
717 lines
23 KiB
717 lines
23 KiB
/**
|
|
* Morfo compiler — takes a `Morfo` declaration and returns a `CompiledMorfo`
|
|
* with pre-resolved plans, dependency analysis, and frozen contracts. The
|
|
* runtime consumes the compiled form; it never walks the raw `Morfo` tree.
|
|
*
|
|
* Why:
|
|
*
|
|
* - `createAttrs(morfo)` walks `parts` recursively → cached as `parts.attrs`.
|
|
* - `findMorfoPart(parts, target)` does linear search → cached as `parts.byKebab`.
|
|
* - `resolvePartAttrs()` walks `data` and `aria`, branches on source.kind on
|
|
* every effect → split into `staticAttrs` (pre-resolved) + `dynamicAttrs[]`
|
|
* (plans with explicit `mode` + `deps`).
|
|
* - `keyboard` strings like `'Shift+Tab'` are parsed once into `KeyboardPlan`.
|
|
* - `events[]` becomes `actions.byName`/`actions.order`.
|
|
* - Contract validation reuses the compiled `dataAttrsByPart` instead of
|
|
* walking the raw tree again.
|
|
*
|
|
* Cache:
|
|
*
|
|
* `compileMorfo(morfo)` is memoised by morfo identity via `WeakMap`. Two
|
|
* compiles of the same morfo return the same `CompiledMorfo` instance.
|
|
* HMR works automatically: the module reload creates a new `Morfo` object
|
|
* identity, so the cache misses and recompiles. Production can be made
|
|
* build-time (no compile cost in the bundle) without changing this surface.
|
|
*
|
|
* No reactivity, no DOM, no Svelte. Pure data → pure data.
|
|
*/
|
|
|
|
import type {
|
|
Morfo,
|
|
MorfoA11ySemantic,
|
|
MorfoAriaEntry,
|
|
MorfoArchetype,
|
|
MorfoCondition,
|
|
MorfoData,
|
|
MorfoElement,
|
|
MorfoEvent,
|
|
MorfoEventSemantic,
|
|
MorfoFocus,
|
|
MorfoKeyboard,
|
|
MorfoPart,
|
|
MorfoPartKind,
|
|
MorfoPrimitiveValueSource,
|
|
MorfoSeverity,
|
|
MorfoValueSource
|
|
} from './types'
|
|
import type { Layer } from '../types'
|
|
import type { SemaDurationSpec } from '../sema/durations'
|
|
import type { SemaAttrWrite, SemaCommit, SemaMode, SemaRegime, SemaScope } from '../sema/types'
|
|
import { resolveMorfoSource, shouldEmitMorfoEntry, type MorfoBindings } from './resolver'
|
|
import { LANG_ID_FALLBACK_SEPARATOR, LANG_ID_PREFIX } from '$libs/langs'
|
|
import { MorfoCompileError } from './errors'
|
|
|
|
// ── Plan types ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Mode determines how the runtime applies an attr's resolved value to the DOM.
|
|
*
|
|
* - `html-presence`: presence flag (`data-disabled` etc.); truthy → `''`,
|
|
* falsy → remove.
|
|
* - `aria-boolean`: ARIA boolean attr (`aria-pressed`, `aria-expanded`);
|
|
* truthy → `'true'`, falsy → `'false'` (NEVER removed — `false` is a
|
|
* valid state distinct from absence).
|
|
* - `enum`: data-attr with a closed values set (`data-state` ∈ {`open`,
|
|
* `closed`}).
|
|
* - `string`: free-string attr (`aria-label`, `aria-roledescription`,
|
|
* `data-value` with `emit: 'value'`).
|
|
* - `data-state-derived`: enum attr whose source is a `stateRef` over a
|
|
* boolean state (the boolean → enum mapping is precomputed).
|
|
*/
|
|
export type AttrMode =
|
|
| 'html-presence'
|
|
| 'aria-boolean'
|
|
| 'enum'
|
|
| 'string'
|
|
| 'data-state-derived'
|
|
|
|
/**
|
|
* Compiled view of a `MorfoValueSource`. Same shape as the source today —
|
|
* the value of compiling is in the surrounding `AttrPlan` (which adds `mode`,
|
|
* `deps`, etc.), not in transforming the source itself.
|
|
*/
|
|
export type SourcePlan = MorfoValueSource
|
|
|
|
/**
|
|
* Compiled condition. Identical shape to `MorfoCondition` — the runtime
|
|
* evaluates it directly.
|
|
*/
|
|
export type ConditionPlan = MorfoCondition
|
|
|
|
/**
|
|
* One dynamic attribute on a part. Pre-computed enough that the runtime can
|
|
* resolve it without re-walking the raw `MorfoData` / `MorfoAriaEntry`:
|
|
*
|
|
* - `mode` is precomputed; runtime branches once on a discriminated union
|
|
* instead of on `source.kind` + `data.values.length` + `entry.value.kind`
|
|
* every effect.
|
|
* - For `data-state-derived` (boolean state → enum): `enumValues` carries
|
|
* the precomputed `[trueLabel, falseLabel]` so the runtime doesn't search
|
|
* `data.values` to invert.
|
|
*/
|
|
export interface AttrPlan {
|
|
readonly attr: string
|
|
readonly source: SourcePlan
|
|
readonly condition: ConditionPlan
|
|
readonly mode: AttrMode
|
|
readonly severity: MorfoSeverity
|
|
/** Closed value set for `enum` / `data-state-derived` modes. */
|
|
readonly enumValues?: readonly string[]
|
|
/**
|
|
* For `data-state-derived` mode: precomputed `[whenTrue, whenFalse]` from
|
|
* the `enumValues`. The runtime picks index 0 or 1 by the boolean state
|
|
* value.
|
|
*/
|
|
readonly enumPair?: readonly [string, string]
|
|
}
|
|
|
|
/** Parsed keyboard combo. `'Shift+Tab'` becomes `{ key: 'Tab', shift: true }`. */
|
|
export interface ParsedKey {
|
|
readonly key: string
|
|
readonly shift: boolean
|
|
readonly ctrl: boolean
|
|
readonly alt: boolean
|
|
readonly meta: boolean
|
|
}
|
|
|
|
export interface KeyboardPlan {
|
|
readonly raw: string
|
|
readonly key: ParsedKey
|
|
readonly action: string
|
|
readonly condition: ConditionPlan
|
|
}
|
|
|
|
/** What sources a part's dynamic plans depend on. Lets the runtime narrow effects. */
|
|
export interface SourceDeps {
|
|
readonly states: readonly string[]
|
|
readonly props: readonly string[]
|
|
readonly parts: readonly string[]
|
|
readonly needsTranslations: boolean
|
|
}
|
|
|
|
/**
|
|
* Compiled view of a single part. Replaces walking `MorfoPart` at runtime.
|
|
*
|
|
* `staticAttrs` is the slice that the runtime can apply unconditionally on
|
|
* mount (literals + `role`); `dynamicAttrs` is what the effect re-evaluates.
|
|
*
|
|
* `tree` reflects the original part hierarchy via parent/child kebabs so the
|
|
* runtime can navigate without recursing the raw morfo.
|
|
*/
|
|
export interface CompiledPart {
|
|
readonly kebab: string
|
|
readonly name: string
|
|
readonly archetype: MorfoArchetype | undefined
|
|
readonly kind: MorfoPartKind
|
|
readonly defaultElement: MorfoElement
|
|
readonly role: string | undefined
|
|
readonly optional: boolean
|
|
readonly supportsNesting: boolean
|
|
readonly states: readonly string[]
|
|
/** `'data-{component}'` for `provider`, `'data-{component}-{kebab}'` otherwise. */
|
|
readonly marker: string
|
|
/** Pre-resolved attrs (literals + `role`). Apply unconditionally on mount. */
|
|
readonly staticAttrs: Readonly<Record<string, string>>
|
|
/** Plans the runtime evaluates each tick (or on dependency change). */
|
|
readonly dynamicAttrs: readonly AttrPlan[]
|
|
readonly keyboard: readonly KeyboardPlan[]
|
|
readonly deps: SourceDeps
|
|
/** Parent kebab if nested under another part; `undefined` for top-level. */
|
|
readonly parentKebab: string | undefined
|
|
/** Direct child kebabs (one level deep). Empty for leaves. */
|
|
readonly childKebabs: readonly string[]
|
|
}
|
|
|
|
// ── Action plan ────────────────────────────────────────────────────────────
|
|
|
|
/** Compiled view of `MorfoEvent`. The runtime dispatches by `byName.get(name)`. */
|
|
export interface ActionPlan {
|
|
readonly name: string
|
|
readonly target: string // kebab
|
|
readonly semantic: MorfoEventSemantic
|
|
readonly a11ySemantic: MorfoA11ySemantic | undefined
|
|
readonly hold: SemaDurationSpec | undefined
|
|
readonly mode: SemaMode | undefined
|
|
readonly regime: SemaRegime | undefined
|
|
readonly scope: SemaScope | undefined
|
|
readonly prewrite: readonly SemaAttrWrite[]
|
|
readonly commit: SemaCommit | undefined
|
|
}
|
|
|
|
// ── Contract types ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* A single declared `data-*` attr on a part, with its allowed value set.
|
|
* Replaces the legacy per-call `flattenMorfoParts` walk on every component
|
|
* module load.
|
|
*/
|
|
export interface DataAttrContract {
|
|
readonly attr: string
|
|
readonly values?: readonly string[]
|
|
}
|
|
|
|
/**
|
|
* One CSS selector that eidos is allowed to use for this morfo. Generated
|
|
* from each `(part, data attr, value)` triple. The eidos linter consumes
|
|
* this list to flag selectors that don't match the morfo contract.
|
|
*/
|
|
export interface CssSelectorContract {
|
|
readonly part: string
|
|
readonly attr: string
|
|
readonly value?: string
|
|
/** Composed selector string, e.g. `[data-dialog-content][data-state='open']`. */
|
|
readonly selector: string
|
|
}
|
|
|
|
/** Aggregate of every source the morfo references. Enables source-level audits. */
|
|
export interface RequiredSourceContract {
|
|
readonly states: readonly string[]
|
|
readonly props: readonly string[]
|
|
readonly parts: readonly string[]
|
|
readonly translations: readonly string[]
|
|
}
|
|
|
|
// ── Compiled morfo ─────────────────────────────────────────────────────────
|
|
|
|
export interface CompiledMorfo {
|
|
/** Back-reference for debugging. Prefer the compiled views below. */
|
|
readonly raw: Morfo
|
|
readonly name: string
|
|
readonly kebab: string
|
|
readonly scope: readonly Layer[]
|
|
readonly apg: string | undefined
|
|
readonly focus: MorfoFocus | undefined
|
|
|
|
readonly parts: {
|
|
readonly byKebab: ReadonlyMap<string, CompiledPart>
|
|
/** Part kebabs in declaration order (depth-first). */
|
|
readonly order: readonly string[]
|
|
/** kebab → emitted data-attr name. Replaces `createAttrs(morfo)`. */
|
|
readonly attrs: Readonly<Record<string, string>>
|
|
}
|
|
|
|
readonly actions: {
|
|
readonly byName: ReadonlyMap<string, ActionPlan>
|
|
readonly order: readonly string[]
|
|
}
|
|
|
|
readonly contracts: {
|
|
readonly dataAttrsByPart: ReadonlyMap<string, readonly DataAttrContract[]>
|
|
readonly cssSelectors: readonly CssSelectorContract[]
|
|
readonly requiredSources: RequiredSourceContract
|
|
}
|
|
}
|
|
|
|
// ── Cache ──────────────────────────────────────────────────────────────────
|
|
|
|
const CACHE = new WeakMap<Morfo, CompiledMorfo>()
|
|
|
|
/**
|
|
* Compile a `Morfo` into its `CompiledMorfo` form. Cached by morfo identity:
|
|
* calling `compileMorfo(dialogMorfo)` twice returns the same instance.
|
|
*/
|
|
export function compileMorfo(morfo: Morfo): CompiledMorfo {
|
|
const cached = CACHE.get(morfo)
|
|
if (cached) return cached
|
|
const compiled = build(morfo)
|
|
CACHE.set(morfo, compiled)
|
|
return compiled
|
|
}
|
|
|
|
/**
|
|
* Test-only: drop the cache. Production code never needs this.
|
|
*/
|
|
export function _resetCompileCache(): void {
|
|
// WeakMap doesn't expose clear(); replace by reassigning the entries.
|
|
// Tests that need a fresh compile use a fresh `Morfo` object instead.
|
|
}
|
|
|
|
// ── Build ──────────────────────────────────────────────────────────────────
|
|
|
|
function build(morfo: Morfo): CompiledMorfo {
|
|
const partsByKebab = new Map<string, CompiledPart>()
|
|
const partOrder: string[] = []
|
|
const attrs: Record<string, string> = {}
|
|
const dataAttrsByPart = new Map<string, readonly DataAttrContract[]>()
|
|
const cssSelectors: CssSelectorContract[] = []
|
|
const stateSet = new Set<string>()
|
|
const propSet = new Set<string>()
|
|
const partRefSet = new Set<string>()
|
|
const translationSet = new Set<string>()
|
|
|
|
walkParts(
|
|
morfo.parts,
|
|
undefined,
|
|
morfo.kebab,
|
|
partsByKebab,
|
|
partOrder,
|
|
attrs,
|
|
dataAttrsByPart,
|
|
cssSelectors,
|
|
stateSet,
|
|
propSet,
|
|
partRefSet,
|
|
translationSet
|
|
)
|
|
|
|
// Actions
|
|
const actionsByName = new Map<string, ActionPlan>()
|
|
const actionOrder: string[] = []
|
|
if (morfo.events) {
|
|
for (const event of morfo.events) {
|
|
actionsByName.set(event.name, compileEvent(event))
|
|
actionOrder.push(event.name)
|
|
}
|
|
}
|
|
|
|
const compiled: CompiledMorfo = Object.freeze({
|
|
raw: morfo,
|
|
name: morfo.name,
|
|
kebab: morfo.kebab,
|
|
scope: morfo.scope,
|
|
apg: morfo.apg,
|
|
focus: morfo.focus,
|
|
parts: Object.freeze({
|
|
byKebab: partsByKebab,
|
|
order: Object.freeze([...partOrder]),
|
|
attrs: Object.freeze(attrs)
|
|
}),
|
|
actions: Object.freeze({
|
|
byName: actionsByName,
|
|
order: Object.freeze([...actionOrder])
|
|
}),
|
|
contracts: Object.freeze({
|
|
dataAttrsByPart,
|
|
cssSelectors: Object.freeze([...cssSelectors]),
|
|
requiredSources: Object.freeze({
|
|
states: Object.freeze([...stateSet].sort()),
|
|
props: Object.freeze([...propSet].sort()),
|
|
parts: Object.freeze([...partRefSet].sort()),
|
|
translations: Object.freeze([...translationSet].sort())
|
|
})
|
|
})
|
|
})
|
|
|
|
return compiled
|
|
}
|
|
|
|
function walkParts(
|
|
parts: readonly MorfoPart[],
|
|
parentKebab: string | undefined,
|
|
componentKebab: string,
|
|
partsByKebab: Map<string, CompiledPart>,
|
|
partOrder: string[],
|
|
attrs: Record<string, string>,
|
|
dataAttrsByPart: Map<string, readonly DataAttrContract[]>,
|
|
cssSelectors: CssSelectorContract[],
|
|
stateSet: Set<string>,
|
|
propSet: Set<string>,
|
|
partRefSet: Set<string>,
|
|
translationSet: Set<string>
|
|
): void {
|
|
for (const part of parts) {
|
|
if (partsByKebab.has(part.kebab)) {
|
|
throw new MorfoCompileError(
|
|
`duplicate part kebab "${part.kebab}" in "${componentKebab}" — kebabs are flat (no path namespacing) and must be unique within a morfo.`
|
|
)
|
|
}
|
|
|
|
const marker =
|
|
part.kebab === 'provider'
|
|
? `data-${componentKebab}`
|
|
: `data-${componentKebab}-${part.kebab}`
|
|
attrs[part.kebab] = marker
|
|
|
|
const staticAttrs: Record<string, string> = {}
|
|
const dynamicAttrs: AttrPlan[] = []
|
|
const partStates = new Set<string>()
|
|
const partProps = new Set<string>()
|
|
const partRefs = new Set<string>()
|
|
let needsTranslations = false
|
|
|
|
// Role goes into staticAttrs (it's a fixed string, not a dynamic source).
|
|
if (part.role) staticAttrs.role = part.role
|
|
|
|
// Data attrs
|
|
const dataContracts: DataAttrContract[] = []
|
|
for (const data of part.data) {
|
|
dataContracts.push({ attr: data.attr, values: data.values })
|
|
if (data.values && data.values.length > 0) {
|
|
for (const value of data.values) {
|
|
cssSelectors.push({
|
|
part: part.kebab,
|
|
attr: data.attr,
|
|
value,
|
|
selector: `[${marker}][${data.attr}='${value}']`
|
|
})
|
|
}
|
|
} else {
|
|
cssSelectors.push({
|
|
part: part.kebab,
|
|
attr: data.attr,
|
|
selector: `[${marker}][${data.attr}]`
|
|
})
|
|
}
|
|
|
|
if (!data.value) continue
|
|
const plan = compileDataPlan(data, componentKebab)
|
|
collectSourceDeps(plan.source, stateSet, propSet, partRefSet, translationSet)
|
|
collectSourceDeps(plan.source, partStates, partProps, partRefs, {
|
|
add: () => {
|
|
needsTranslations = true
|
|
}
|
|
} as never)
|
|
collectConditionDeps(plan.condition, partStates, partProps, partRefs)
|
|
collectConditionDeps(plan.condition, stateSet, propSet, partRefSet)
|
|
// Pre-resolve literal sources into staticAttrs when there's no
|
|
// condition gating them. This is the cheapest possible escape:
|
|
// many ARIA attrs are `{ kind: 'literal', value: 'true' }` with
|
|
// `condition: 'always'` — they belong on staticAttrs.
|
|
if (plan.condition === 'always' && plan.source.kind === 'literal') {
|
|
staticAttrs[plan.attr] = plan.source.value
|
|
} else {
|
|
dynamicAttrs.push(plan)
|
|
}
|
|
}
|
|
dataAttrsByPart.set(part.kebab, Object.freeze(dataContracts))
|
|
|
|
// ARIA attrs
|
|
for (const aria of part.aria) {
|
|
const plan = compileAriaPlan(aria, componentKebab)
|
|
collectSourceDeps(plan.source, stateSet, propSet, partRefSet, translationSet)
|
|
collectSourceDeps(plan.source, partStates, partProps, partRefs, {
|
|
add: () => {
|
|
needsTranslations = true
|
|
}
|
|
} as never)
|
|
collectConditionDeps(plan.condition, partStates, partProps, partRefs)
|
|
collectConditionDeps(plan.condition, stateSet, propSet, partRefSet)
|
|
if (plan.condition === 'always' && plan.source.kind === 'literal') {
|
|
staticAttrs[plan.attr] = plan.source.value
|
|
} else {
|
|
dynamicAttrs.push(plan)
|
|
}
|
|
}
|
|
|
|
// Keyboard
|
|
const keyboardPlans: KeyboardPlan[] = []
|
|
if (part.keyboard) {
|
|
for (const kb of part.keyboard) {
|
|
keyboardPlans.push(compileKeyboard(kb))
|
|
}
|
|
}
|
|
|
|
const compiledPart: CompiledPart = Object.freeze({
|
|
kebab: part.kebab,
|
|
name: part.name,
|
|
archetype: part.archetype,
|
|
kind: part.kind,
|
|
defaultElement: part.defaultElement,
|
|
role: part.role,
|
|
optional: part.optional,
|
|
supportsNesting: part.supportsNesting ?? false,
|
|
states: Object.freeze([...(part.states ?? [])]),
|
|
marker,
|
|
staticAttrs: Object.freeze(staticAttrs),
|
|
dynamicAttrs: Object.freeze(dynamicAttrs),
|
|
keyboard: Object.freeze(keyboardPlans),
|
|
deps: Object.freeze({
|
|
states: Object.freeze([...partStates].sort()),
|
|
props: Object.freeze([...partProps].sort()),
|
|
parts: Object.freeze([...partRefs].sort()),
|
|
needsTranslations
|
|
}),
|
|
parentKebab,
|
|
childKebabs: Object.freeze((part.parts ?? []).map((p) => p.kebab))
|
|
})
|
|
|
|
partsByKebab.set(part.kebab, compiledPart)
|
|
partOrder.push(part.kebab)
|
|
|
|
if (part.parts && part.parts.length > 0) {
|
|
walkParts(
|
|
part.parts,
|
|
part.kebab,
|
|
componentKebab,
|
|
partsByKebab,
|
|
partOrder,
|
|
attrs,
|
|
dataAttrsByPart,
|
|
cssSelectors,
|
|
stateSet,
|
|
propSet,
|
|
partRefSet,
|
|
translationSet
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
function compileDataPlan(data: MorfoData, componentKebab: string): AttrPlan {
|
|
const source = normalizeSource(data.value!, componentKebab)
|
|
const condition: ConditionPlan = data.condition ?? 'always'
|
|
const mode = pickDataMode(data, source)
|
|
const enumValues = data.values ? Object.freeze([...data.values]) : undefined
|
|
let enumPair: readonly [string, string] | undefined
|
|
if (mode === 'data-state-derived' && source.kind === 'stateRef' && enumValues) {
|
|
const trueLabel = source.state
|
|
const falseLabel = enumValues.find((v) => v !== trueLabel) ?? trueLabel
|
|
enumPair = Object.freeze([trueLabel, falseLabel] as [string, string])
|
|
}
|
|
return Object.freeze({
|
|
attr: data.attr,
|
|
source,
|
|
condition,
|
|
mode,
|
|
severity: data.severity ?? 'required',
|
|
enumValues,
|
|
enumPair
|
|
})
|
|
}
|
|
|
|
function compileAriaPlan(aria: MorfoAriaEntry, componentKebab: string): AttrPlan {
|
|
const condition: ConditionPlan = aria.condition ?? 'always'
|
|
const source = normalizeSource(aria.value, componentKebab)
|
|
const mode: AttrMode = aria.value.kind === 'stateRef' ? 'aria-boolean' : 'string'
|
|
return Object.freeze({
|
|
attr: aria.attr,
|
|
source,
|
|
condition,
|
|
mode,
|
|
severity: aria.severity ?? 'required'
|
|
})
|
|
}
|
|
|
|
function normalizeSource(source: MorfoValueSource, componentKebab: string): MorfoValueSource {
|
|
if (source.kind === 'mapRef') {
|
|
return {
|
|
...source,
|
|
source: normalizePrimitiveSource(source.source, componentKebab)
|
|
}
|
|
}
|
|
|
|
return normalizePrimitiveSource(source, componentKebab)
|
|
}
|
|
|
|
function normalizePrimitiveSource(
|
|
source: MorfoPrimitiveValueSource,
|
|
componentKebab: string
|
|
): MorfoPrimitiveValueSource {
|
|
if (source.kind !== 'translationRef') return source
|
|
|
|
return {
|
|
...source,
|
|
key: normalizeTranslationRef(source.key, source.fallback, componentKebab)
|
|
}
|
|
}
|
|
|
|
export function normalizeTranslationRef(
|
|
key: string,
|
|
fallback: string | undefined,
|
|
componentKebab: string
|
|
): string {
|
|
const absolute = key.startsWith(LANG_ID_PREFIX)
|
|
? key
|
|
: `${LANG_ID_PREFIX}components.${componentKebab}.${key}`
|
|
|
|
if (fallback === undefined || absolute.includes(LANG_ID_FALLBACK_SEPARATOR)) {
|
|
return absolute
|
|
}
|
|
|
|
return `${absolute}${LANG_ID_FALLBACK_SEPARATOR}${fallback}`
|
|
}
|
|
|
|
function pickDataMode(data: MorfoData, source: MorfoValueSource): AttrMode {
|
|
const hasEnum = data.values !== undefined && data.values.length > 0
|
|
if (!hasEnum) return data.emit === 'value' ? 'string' : 'html-presence'
|
|
if (source.kind === 'stateRef') return 'data-state-derived'
|
|
return 'enum'
|
|
}
|
|
|
|
function compileKeyboard(kb: MorfoKeyboard): KeyboardPlan {
|
|
return Object.freeze({
|
|
raw: kb.key,
|
|
key: parseKey(kb.key),
|
|
action: kb.action,
|
|
condition: kb.condition ?? 'always'
|
|
})
|
|
}
|
|
|
|
function parseKey(raw: string): ParsedKey {
|
|
// The last segment is the literal key — never trim it. `' '` (Space)
|
|
// must survive as-is. Modifier segments are trimmed individually.
|
|
const segments = raw.split('+')
|
|
const key = segments.pop() ?? ''
|
|
let shift = false
|
|
let ctrl = false
|
|
let alt = false
|
|
let meta = false
|
|
for (const mod of segments) {
|
|
const lower = mod.trim().toLowerCase()
|
|
if (lower === 'shift') shift = true
|
|
else if (lower === 'ctrl' || lower === 'control') ctrl = true
|
|
else if (lower === 'alt' || lower === 'option') alt = true
|
|
else if (lower === 'meta' || lower === 'cmd' || lower === 'command') meta = true
|
|
}
|
|
return Object.freeze({ key, shift, ctrl, alt, meta })
|
|
}
|
|
|
|
function compileEvent(event: MorfoEvent): ActionPlan {
|
|
// Doctrinal shape: target lives inside semantic. ActionPlan keeps a
|
|
// flat `target: string` for runtime dispatch convenience.
|
|
return Object.freeze({
|
|
name: event.name,
|
|
target: event.semantic.target.target,
|
|
semantic: event.semantic,
|
|
a11ySemantic: event.a11ySemantic,
|
|
hold: event.hold,
|
|
mode: event.mode,
|
|
regime: event.regime,
|
|
scope: event.scope,
|
|
prewrite: Object.freeze([...(event.prewrite ?? [])]),
|
|
commit: event.commits
|
|
})
|
|
}
|
|
|
|
// ── Source dep collection ──────────────────────────────────────────────────
|
|
|
|
interface DepSink {
|
|
add(value: string): void
|
|
}
|
|
|
|
function collectSourceDeps(
|
|
source: SourcePlan,
|
|
states: Set<string>,
|
|
props: Set<string>,
|
|
partRefs: Set<string>,
|
|
translationsOrSink: Set<string> | DepSink
|
|
): void {
|
|
if (source.kind === 'mapRef') {
|
|
collectSourceDeps(source.source, states, props, partRefs, translationsOrSink)
|
|
return
|
|
}
|
|
if (source.kind === 'stateRef') {
|
|
states.add(source.state)
|
|
return
|
|
}
|
|
if (source.kind === 'propRef') {
|
|
props.add(source.prop)
|
|
return
|
|
}
|
|
if (source.kind === 'partRef') {
|
|
partRefs.add(source.target)
|
|
return
|
|
}
|
|
if (source.kind === 'translationRef') {
|
|
// Set<string> stores keys; DepSink-style sinks just flip the boolean.
|
|
if (translationsOrSink instanceof Set) {
|
|
translationsOrSink.add(source.key)
|
|
} else {
|
|
translationsOrSink.add(source.key)
|
|
}
|
|
}
|
|
}
|
|
|
|
function collectConditionDeps(
|
|
condition: ConditionPlan,
|
|
states: Set<string>,
|
|
props: Set<string>,
|
|
partRefs: Set<string>
|
|
): void {
|
|
if (condition === 'always') return
|
|
if (condition.when === 'state-equals') states.add(condition.state)
|
|
else if (
|
|
condition.when === 'prop-truthy' ||
|
|
condition.when === 'prop-falsy' ||
|
|
condition.when === 'prop-defined'
|
|
)
|
|
props.add(condition.prop)
|
|
else if (condition.when === 'part-present') partRefs.add(condition.part)
|
|
}
|
|
|
|
// ── Plan evaluation ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Evaluate one `AttrPlan` against the given bindings.
|
|
*
|
|
* Returns the value the runtime should write to the DOM for this attr,
|
|
* or `undefined` to remove it. The mode discriminator picks the
|
|
* presentation: presence flag, ARIA boolean, enum, free string, or the
|
|
* boolean-state-derived enum case (where the source is `stateRef` over
|
|
* a boolean and the compiler precomputed the `[whenTrue, whenFalse]`
|
|
* pair so we don't search `enumValues` per tick).
|
|
*
|
|
* No source.kind / values.length / value.kind branching here — the
|
|
* compiler did it once.
|
|
*/
|
|
export function evalAttrPlan(plan: AttrPlan, bindings: MorfoBindings): unknown {
|
|
if (!shouldEmitMorfoEntry(plan.condition, bindings)) return undefined
|
|
|
|
const raw = resolveMorfoSource(plan.source, bindings)
|
|
|
|
switch (plan.mode) {
|
|
case 'html-presence':
|
|
return raw ? '' : undefined
|
|
case 'aria-boolean':
|
|
return raw ? 'true' : 'false'
|
|
case 'data-state-derived':
|
|
if (typeof raw === 'boolean' && plan.enumPair) {
|
|
return raw ? plan.enumPair[0] : plan.enumPair[1]
|
|
}
|
|
return raw
|
|
case 'enum':
|
|
case 'string':
|
|
return raw
|
|
}
|
|
}
|