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.
svelte-kit-vice/src/uix/morfo/compile.ts

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
}
}

Powered by TurnKey Linux.