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.
765 lines
25 KiB
765 lines
25 KiB
/**
|
|
* Morfo validation.
|
|
*
|
|
* Two layers of validation:
|
|
*
|
|
* 1. **Structural** (sium): shape, tagged union discriminants, literal enums.
|
|
* Catches type errors in the declaration.
|
|
*
|
|
* 2. **Invariant** (manual walker): cross-part references.
|
|
* - `partRef.target` must match another part's `kebab` in the same morfo.
|
|
* - `stateRef.state` must exist in the containing part's `states[]`.
|
|
* - `translationRef.key` is either an absolute idlangref (starting with
|
|
* `#?`) or a component-relative key (e.g. `'label'`) that compile.ts
|
|
* normalizes to `#?components.{kebab}.{key}`. Catalog presence is
|
|
* validated by `scripts/translations-check.ts`, not here — the morfo no
|
|
* longer carries the catalog; it lives in `src/uix/langs/components/`.
|
|
* - Every `kebab` unique across the whole morfo.
|
|
* - `MorfoCondition` discriminator values resolve (e.g. `state-equals`
|
|
* must refer to a state declared somewhere in the morfo).
|
|
* - `MorfoFocus.initial`/`return` `partRef` must match a kebab.
|
|
*
|
|
* Sium has no `lazy()` today, so recursion in `MorfoPart.parts?` is
|
|
* handled by a manual walker that calls `morfoPartSchema.decode()` for
|
|
* every sub-part. Swap to `lazy()` when sium ships it.
|
|
*
|
|
* This module validates Morfo as the component contract source of truth:
|
|
* parts, ARIA, data, focus, keyboard, and component-authored semantic
|
|
* events. It still knows nothing about eidos recipes or runtime engines.
|
|
*/
|
|
|
|
import { object, array, string, boolean, literal, union, discriminated, optional } from '$sium';
|
|
import type { Schema } from '$sium';
|
|
import { SiumValidationError } from '$sium';
|
|
import { INTENTS, type Intent } from '../intent';
|
|
import type {
|
|
Morfo,
|
|
MorfoPart,
|
|
MorfoPrimitiveValueSource,
|
|
MorfoValueSource,
|
|
MorfoCondition,
|
|
MorfoElement,
|
|
MorfoArchetype
|
|
} from './types';
|
|
import { ARCHETYPE_VOCABULARY } from './types';
|
|
import { MorfoInvariantError } from './errors';
|
|
|
|
// ── Leaf schemas ──────────────────────────────────────────────────────────
|
|
|
|
const layerSchema = union(literal('soma'), literal('sema'), literal('eidos'));
|
|
|
|
/**
|
|
* Literal union of allowed HTML elements. Keep in sync with `MorfoElement`
|
|
* in `types.ts` — TS catches mismatches at compile time.
|
|
*/
|
|
const elementSchema = union(
|
|
literal('button'),
|
|
literal('div'),
|
|
literal('span'),
|
|
literal('a'),
|
|
literal('input'),
|
|
literal('ul'),
|
|
literal('ol'),
|
|
literal('li'),
|
|
literal('tr'),
|
|
literal('td'),
|
|
literal('th'),
|
|
literal('table'),
|
|
literal('thead'),
|
|
literal('tbody'),
|
|
literal('tfoot'),
|
|
literal('header'),
|
|
literal('nav'),
|
|
literal('section'),
|
|
literal('article'),
|
|
literal('main'),
|
|
literal('aside'),
|
|
literal('footer'),
|
|
literal('img'),
|
|
literal('svg'),
|
|
literal('path'),
|
|
literal('label'),
|
|
literal('form'),
|
|
literal('fieldset'),
|
|
literal('legend'),
|
|
literal('select'),
|
|
literal('option'),
|
|
literal('textarea'),
|
|
literal('time'),
|
|
literal('none')
|
|
) as Schema<MorfoElement, MorfoElement>;
|
|
|
|
const severitySchema = union(literal('required'), literal('recommended'), literal('optional'));
|
|
|
|
const partKindSchema = union(literal('public'), literal('private'), literal('virtual'));
|
|
|
|
const archetypeSchema = union(
|
|
...(ARCHETYPE_VOCABULARY.map((archetype) => literal(archetype)) as [
|
|
ReturnType<typeof literal<string>>,
|
|
ReturnType<typeof literal<string>>,
|
|
...ReturnType<typeof literal<string>>[]
|
|
])
|
|
) as Schema<MorfoArchetype, MorfoArchetype>;
|
|
|
|
// ── MorfoValueSource (tagged union) ───────────────────────────────────────
|
|
|
|
const primitiveValueSourceSchema = discriminated('kind', [
|
|
object({ kind: literal('literal'), value: string() }),
|
|
object({ kind: literal('stateRef'), state: string() }),
|
|
object({ kind: literal('partRef'), target: string() }),
|
|
object({ kind: literal('propRef'), prop: string() }),
|
|
object({ kind: literal('translationRef'), key: string(), fallback: optional(string()) })
|
|
]) as Schema<MorfoPrimitiveValueSource, MorfoPrimitiveValueSource>;
|
|
|
|
const stringMapSchema = object({}, { unknownKeys: 'passthrough' }) as unknown as Schema<
|
|
Record<string, string>,
|
|
Record<string, string>
|
|
>;
|
|
|
|
const valueSourceSchema = discriminated('kind', [
|
|
object({ kind: literal('literal'), value: string() }),
|
|
object({ kind: literal('stateRef'), state: string() }),
|
|
object({ kind: literal('partRef'), target: string() }),
|
|
object({ kind: literal('propRef'), prop: string() }),
|
|
object({ kind: literal('translationRef'), key: string(), fallback: optional(string()) }),
|
|
object({
|
|
kind: literal('mapRef'),
|
|
source: primitiveValueSourceSchema,
|
|
map: stringMapSchema,
|
|
fallback: optional(string())
|
|
})
|
|
]) as Schema<MorfoValueSource, MorfoValueSource>;
|
|
|
|
// ── MorfoCondition (tagged union with 'always' literal + objects) ─────────
|
|
|
|
const conditionObjectSchema = discriminated('when', [
|
|
object({ when: literal('part-present'), part: string() }),
|
|
object({
|
|
when: literal('state-equals'),
|
|
state: string(),
|
|
value: string()
|
|
}),
|
|
object({ when: literal('prop-truthy'), prop: string() }),
|
|
object({ when: literal('prop-falsy'), prop: string() }),
|
|
object({ when: literal('prop-defined'), prop: string() })
|
|
]);
|
|
|
|
const conditionSchema = union(literal('always'), conditionObjectSchema) as Schema<
|
|
MorfoCondition,
|
|
MorfoCondition
|
|
>;
|
|
|
|
// ── MorfoData / MorfoAriaEntry / MorfoKeyboard ────────────────────────────
|
|
|
|
const dataEmitSchema = union(literal('presence'), literal('value'));
|
|
|
|
const dataSchema = object({
|
|
attr: string(),
|
|
values: optional(array(string())),
|
|
emit: optional(dataEmitSchema),
|
|
value: optional(valueSourceSchema),
|
|
condition: optional(conditionSchema),
|
|
severity: optional(severitySchema)
|
|
});
|
|
|
|
const ariaEntrySchema = object({
|
|
attr: string(),
|
|
value: valueSourceSchema,
|
|
condition: optional(conditionSchema),
|
|
severity: optional(severitySchema),
|
|
ariaBoolean: optional(boolean())
|
|
});
|
|
|
|
const keyboardSchema = object({
|
|
key: string(),
|
|
action: string(),
|
|
condition: optional(conditionSchema)
|
|
});
|
|
|
|
// ── Semantic events ───────────────────────────────────────────────────────
|
|
|
|
const semaIntentSchema = union(
|
|
...(INTENTS.map((intent) => literal(intent)) as [
|
|
ReturnType<typeof literal<string>>,
|
|
ReturnType<typeof literal<string>>,
|
|
...ReturnType<typeof literal<string>>[]
|
|
])
|
|
) as Schema<Intent, Intent>;
|
|
|
|
const semaTransitionalFamilySchema = union(
|
|
literal('emerge'),
|
|
literal('shift'),
|
|
literal('sustain'),
|
|
literal('delegate')
|
|
);
|
|
const semaIntentOptionalFamilySchema = union(
|
|
semaTransitionalFamilySchema,
|
|
literal('contact'),
|
|
literal('handle')
|
|
);
|
|
const semaIntentExpectedFamilySchema = union(literal('commit'), literal('signal'));
|
|
|
|
const semanticIntentSchema = object({
|
|
fromProp: optional(string()),
|
|
default: semaIntentSchema,
|
|
supported: array(semaIntentSchema)
|
|
});
|
|
|
|
const partRefSchema = object({
|
|
kind: literal('partRef'),
|
|
target: string()
|
|
});
|
|
|
|
const sequenceSchema = union(literal('pre'), literal('coincident'), literal('post'));
|
|
|
|
const persistenceSchema = union(
|
|
literal('transient'),
|
|
literal('untilAction'),
|
|
literal('untilFix'),
|
|
literal('stateBound')
|
|
);
|
|
|
|
const semaFamilySchema = union(
|
|
semaIntentOptionalFamilySchema,
|
|
semaIntentExpectedFamilySchema
|
|
);
|
|
|
|
// Polymorphism (book §5.3) is ADDITIVE on the canonical event shape:
|
|
// the morfo declares its default `family` + `intent` + `verb` as usual
|
|
// and may add `allowedFamilies` to authorize providers to override the
|
|
// family at trigger time.
|
|
const eventSemanticSchema = union(
|
|
object({
|
|
family: semaIntentOptionalFamilySchema,
|
|
target: partRefSchema,
|
|
intent: optional(union(semaIntentSchema, semanticIntentSchema)),
|
|
verb: optional(string()),
|
|
sequence: optional(sequenceSchema),
|
|
persistence: optional(persistenceSchema),
|
|
allowedFamilies: optional(array(semaFamilySchema))
|
|
}),
|
|
object({
|
|
family: semaIntentExpectedFamilySchema,
|
|
target: partRefSchema,
|
|
intent: union(semaIntentSchema, semanticIntentSchema),
|
|
verb: optional(string()),
|
|
sequence: optional(sequenceSchema),
|
|
persistence: optional(persistenceSchema),
|
|
allowedFamilies: optional(array(semaFamilySchema))
|
|
})
|
|
);
|
|
|
|
const attrWriteSchema = object({
|
|
part: object({
|
|
kind: literal('partRef'),
|
|
target: string()
|
|
}),
|
|
attr: string(),
|
|
value: string()
|
|
});
|
|
|
|
const commitSchema = object({
|
|
part: object({
|
|
kind: literal('partRef'),
|
|
target: string()
|
|
}),
|
|
attr: string(),
|
|
value: string()
|
|
});
|
|
|
|
const reducedMotionFallbackSchema = union(
|
|
literal('state'),
|
|
literal('text'),
|
|
literal('focus'),
|
|
literal('none')
|
|
);
|
|
|
|
const a11ySemanticSchema = object({
|
|
requiresPersistentTrace: optional(boolean()),
|
|
requiresLiveRegion: optional(boolean()),
|
|
requiresFocusMove: optional(boolean()),
|
|
keyboardEquivalent: optional(boolean()),
|
|
reducedMotionFallback: optional(reducedMotionFallbackSchema)
|
|
});
|
|
|
|
const eventSchema = object({
|
|
name: string(),
|
|
semantic: eventSemanticSchema,
|
|
a11ySemantic: optional(a11ySemanticSchema),
|
|
mode: optional(union(literal('blocking'), literal('advisory'))),
|
|
regime: optional(
|
|
union(literal('replace'), literal('collapse'), literal('lock'), literal('queue'))
|
|
),
|
|
scope: optional(union(literal('part'), literal('component'), literal('scene'))),
|
|
prewrite: optional(array(attrWriteSchema)),
|
|
commits: optional(commitSchema)
|
|
});
|
|
|
|
// ── MorfoFocus ────────────────────────────────────────────────────────────
|
|
|
|
const focusTargetSchema = union(
|
|
literal('first-focusable'),
|
|
literal('trigger'),
|
|
literal('previous'),
|
|
object({ partRef: string() })
|
|
);
|
|
|
|
const focusSchema = object({
|
|
initial: optional(focusTargetSchema),
|
|
trap: optional(boolean()),
|
|
return: optional(focusTargetSchema),
|
|
restore: optional(boolean())
|
|
});
|
|
|
|
// ── MorfoPart (non-recursive — `parts?` handled by manual walker) ─────────
|
|
//
|
|
// Sium lacks `lazy()`, so we validate a single part without its `parts?`
|
|
// children, then the walker recurses.
|
|
|
|
// Animation — presence-coordination structure (RFC: eidos/MOTION_SERVICE_RFC.md
|
|
// §4). Shape only; the cross-field invariant (children ⇒ surface) lives in
|
|
// `validateInvariants`. The `when` enum is validated structurally here.
|
|
const animationWhenSchema = union(literal('before'), literal('after'), literal('together'));
|
|
|
|
const animationChildrenSchema = object({
|
|
enter: optional(animationWhenSchema),
|
|
exit: optional(animationWhenSchema)
|
|
});
|
|
|
|
const animationSchema = object({
|
|
surface: optional(boolean()),
|
|
children: optional(animationChildrenSchema),
|
|
staggerChildren: optional(string())
|
|
});
|
|
|
|
const partShallowSchema = object({
|
|
name: string(),
|
|
kebab: string(),
|
|
archetype: optional(archetypeSchema),
|
|
kind: partKindSchema,
|
|
defaultElement: elementSchema,
|
|
role: optional(string()),
|
|
optional: boolean(),
|
|
supportsNesting: optional(boolean()),
|
|
states: optional(array(string())),
|
|
data: array(dataSchema),
|
|
aria: array(ariaEntrySchema),
|
|
keyboard: optional(array(keyboardSchema)),
|
|
animation: optional(animationSchema),
|
|
parts: optional(array(object({}, { unknownKeys: 'passthrough' })))
|
|
// ^ children passed through opaquely — shape is checked by the walker
|
|
});
|
|
|
|
// ── Morfo root ────────────────────────────────────────────────────────────
|
|
|
|
const semaExpressionModeSchema = union(
|
|
literal('pack'),
|
|
literal('family-default'),
|
|
literal('delegated'),
|
|
literal('none')
|
|
);
|
|
|
|
const morfoShallowSchema = object(
|
|
{
|
|
name: string(),
|
|
kebab: string(),
|
|
scope: array(layerSchema),
|
|
apg: optional(string()),
|
|
focus: optional(focusSchema),
|
|
events: optional(array(eventSchema)),
|
|
expression: optional(semaExpressionModeSchema),
|
|
translations: optional(object({}, { unknownKeys: 'passthrough' })),
|
|
parts: array(object({}, { unknownKeys: 'passthrough' }))
|
|
// ^ parts are opaque here; walker recurses with `partShallowSchema`
|
|
},
|
|
// Extension keys from downstream layers (e.g. `sema`) are tolerated so
|
|
// `satisfies MorfoWithSema`-style authoring still passes the root shape
|
|
// check. Each layer ships its own deep validator.
|
|
{ unknownKeys: 'passthrough' }
|
|
);
|
|
|
|
// ── Manual walker + invariants ────────────────────────────────────────────
|
|
|
|
/** Collect every part (recursively) into a flat list with ancestor path. */
|
|
function flattenParts(
|
|
parts: readonly MorfoPart[],
|
|
path: ReadonlyArray<string> = []
|
|
): Array<{ part: MorfoPart; path: ReadonlyArray<string> }> {
|
|
const result: Array<{ part: MorfoPart; path: ReadonlyArray<string> }> = [];
|
|
for (const p of parts) {
|
|
const nextPath = [...path, p.kebab];
|
|
result.push({ part: p, path: nextPath });
|
|
if (p.parts) {
|
|
result.push(...flattenParts(p.parts, nextPath));
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** Validate every part (recursively) against `partShallowSchema`. */
|
|
function validatePartsRecursively(parts: readonly MorfoPart[]): void {
|
|
for (const { part, path } of flattenParts(parts)) {
|
|
try {
|
|
// `decodeSync` throws SiumValidationError synchronously on shape mismatch.
|
|
partShallowSchema.decodeSync(part as never);
|
|
} catch (err) {
|
|
if (err instanceof SiumValidationError) {
|
|
throw new MorfoInvariantError(
|
|
`Part at path ${path.join('.')} failed shape validation: ${err.message}`,
|
|
path as readonly string[]
|
|
);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Classify known vocabularies so the external vocabulary-check script can use them. */
|
|
export const CANONICAL_VOCABULARIES = {
|
|
disclosure: ['open', 'closed'],
|
|
active: ['active', 'inactive'],
|
|
checked: ['checked', 'unchecked', 'indeterminate'],
|
|
toggle: ['on', 'off'],
|
|
lifecycle: ['loading', 'idle', 'success', 'error'],
|
|
selection: ['selected', 'unselected'],
|
|
orientation: ['horizontal', 'vertical']
|
|
} as const;
|
|
|
|
/** Thrown when a morfo fails invariant validation after shape passes. */
|
|
export { MorfoInvariantError } from './errors';
|
|
|
|
const PUBLIC_DATA_ATTR_RE = /^data-[a-z0-9][a-z0-9-]*$/;
|
|
const PRIVATE_DATA_ATTR_RE = /^data-_[a-z0-9][a-z0-9-]*$/;
|
|
|
|
function validateDataAttrName(
|
|
attr: string,
|
|
path: ReadonlyArray<string>,
|
|
context: string
|
|
): void {
|
|
if (!attr.startsWith('data-')) {
|
|
throw new MorfoInvariantError(`${context} "${attr}" must start with "data-"`, path);
|
|
}
|
|
if (PRIVATE_DATA_ATTR_RE.test(attr)) {
|
|
throw new MorfoInvariantError(
|
|
`${context} "${attr}" uses reserved private prefix "data-_"; private attrs must stay outside morfo`,
|
|
path
|
|
);
|
|
}
|
|
if (!PUBLIC_DATA_ATTR_RE.test(attr)) {
|
|
throw new MorfoInvariantError(
|
|
`${context} "${attr}" must be lowercase kebab-case after "data-"`,
|
|
path
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cross-reference invariants.
|
|
*
|
|
* 1. Every `kebab` unique across the whole morfo (flat namespace).
|
|
* 2. Every `partRef.target` resolves to a declared part.
|
|
* 3. Every `stateRef.state` resolves to a state declared in the SAME part.
|
|
* 4. Every `state-equals` condition's `state` exists somewhere in the part.
|
|
* 5. `focus.initial.partRef` / `focus.return.partRef` resolve to a part.
|
|
* 6. Root `scope` non-empty.
|
|
* 7. Component-relative `translationRef` keys resolve inside `morfo.translations`.
|
|
* 8. Morfo `kebab` is valid kebab-case (a-z0-9-).
|
|
*
|
|
* Not validated here (requires external sources):
|
|
* - Cross-component vocabulary consistency (needs morfo registry →
|
|
* scripts/morfo-vocabulary-check.mjs).
|
|
*/
|
|
function validateInvariants(morfo: Morfo): void {
|
|
if (morfo.scope.length === 0) {
|
|
throw new MorfoInvariantError(
|
|
`morfo "${morfo.kebab}" has empty scope — must implement at least one layer`
|
|
);
|
|
}
|
|
|
|
if (!/^[a-z][a-z0-9-]*$/.test(morfo.kebab)) {
|
|
throw new MorfoInvariantError(
|
|
`morfo kebab "${morfo.kebab}" must be kebab-case (a-z, 0-9, - only)`
|
|
);
|
|
}
|
|
|
|
const flat = flattenParts(morfo.parts);
|
|
const kebabs = new Set<string>();
|
|
for (const { part, path } of flat) {
|
|
if (kebabs.has(part.kebab)) {
|
|
throw new MorfoInvariantError(
|
|
`duplicate kebab "${part.kebab}" in morfo "${morfo.kebab}" at path ${path.join('.')}`,
|
|
path
|
|
);
|
|
}
|
|
kebabs.add(part.kebab);
|
|
}
|
|
|
|
const validateValueSource = (
|
|
value: MorfoValueSource,
|
|
part: MorfoPart,
|
|
path: ReadonlyArray<string>,
|
|
context: string
|
|
) => {
|
|
if (value.kind === 'mapRef') {
|
|
if (Object.keys(value.map).length === 0) {
|
|
throw new MorfoInvariantError(`${context}.mapRef must declare at least one mapping`, path);
|
|
}
|
|
|
|
for (const [sourceValue, mappedValue] of Object.entries(value.map)) {
|
|
if (typeof mappedValue !== 'string') {
|
|
throw new MorfoInvariantError(
|
|
`${context}.mapRef["${sourceValue}"] must resolve to a string`,
|
|
path
|
|
);
|
|
}
|
|
}
|
|
|
|
validateValueSource(value.source, part, path, `${context}.mapRef.source`);
|
|
return;
|
|
}
|
|
|
|
if (value.kind === 'partRef' && !kebabs.has(value.target)) {
|
|
throw new MorfoInvariantError(
|
|
`${context}.partRef "${value.target}" does not match any part in "${morfo.kebab}"`,
|
|
path
|
|
);
|
|
}
|
|
|
|
if (value.kind === 'stateRef') {
|
|
const states = part.states ?? [];
|
|
if (!states.includes(value.state)) {
|
|
throw new MorfoInvariantError(
|
|
`${context}.stateRef "${value.state}" is not in part.states of "${part.kebab}" (declared: ${states.join(', ') || '∅'})`,
|
|
path
|
|
);
|
|
}
|
|
}
|
|
|
|
// translationRef keys: absolute idlangrefs are passed through to runtime;
|
|
// relative keys are normalized by compile.ts to `#?components.{kebab}.{key}`.
|
|
// Catalog presence is checked by `scripts/translations-check.ts`.
|
|
// No structural assertion needed here.
|
|
};
|
|
|
|
for (const { part, path } of flat) {
|
|
for (const dataEntry of part.data) {
|
|
validateDataAttrName(dataEntry.attr, path, 'data attr');
|
|
|
|
if (dataEntry.emit && dataEntry.values && dataEntry.values.length > 0) {
|
|
throw new MorfoInvariantError(
|
|
`data[${dataEntry.attr}].emit is only valid for non-enum data attrs`,
|
|
path
|
|
);
|
|
}
|
|
const v = dataEntry.value;
|
|
if (!v) continue;
|
|
validateValueSource(v, part, path, `data[${dataEntry.attr}]`);
|
|
}
|
|
|
|
for (const ariaEntry of part.aria) {
|
|
validateValueSource(ariaEntry.value, part, path, `aria[${ariaEntry.attr}]`);
|
|
}
|
|
|
|
const checkCondition = (c: MorfoCondition | undefined, context: string) => {
|
|
if (!c || c === 'always') return;
|
|
if (c.when === 'part-present' && !kebabs.has(c.part)) {
|
|
throw new MorfoInvariantError(
|
|
`${context}: condition part-present references unknown part "${c.part}"`,
|
|
path
|
|
);
|
|
}
|
|
if (c.when === 'state-equals') {
|
|
const states = part.states ?? [];
|
|
if (!states.includes(c.state)) {
|
|
throw new MorfoInvariantError(
|
|
`${context}: condition state-equals references unknown state "${c.state}" in part "${part.kebab}"`,
|
|
path
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
for (const d of part.data) {
|
|
checkCondition(d.condition, `data[${d.attr}]`);
|
|
}
|
|
for (const a of part.aria) {
|
|
checkCondition(a.condition, `aria[${a.attr}]`);
|
|
}
|
|
for (const k of part.keyboard ?? []) {
|
|
checkCondition(k.condition, `keyboard[${k.key}]`);
|
|
}
|
|
}
|
|
|
|
// Animation (RFC: eidos/MOTION_SERVICE_RFC.md §4/§6). A part that coordinates
|
|
// child surfaces is itself a surface, so `animation.children` requires
|
|
// `animation.surface: true`. Choreography is opt-in BY DECLARATION (RFC §6):
|
|
// only a part that declares `children` awaits/staggers its descendants.
|
|
for (const { part, path } of flat) {
|
|
if (part.animation?.children && part.animation.surface !== true) {
|
|
throw new MorfoInvariantError(
|
|
`part "${part.kebab}" declares animation.children but animation.surface is not true — a part that coordinates child surfaces must itself be a surface`,
|
|
path
|
|
);
|
|
}
|
|
// staggerChildren (RFC §M9): the owner of a children-DOM cascade is itself a
|
|
// surface, and the two coordination models are mutually exclusive (Presence
|
|
// children vs DOM children — pick one).
|
|
if (part.animation?.staggerChildren !== undefined) {
|
|
if (part.animation.surface !== true) {
|
|
throw new MorfoInvariantError(
|
|
`part "${part.kebab}" declares animation.staggerChildren but animation.surface is not true — the owner of a children-DOM cascade must itself be a surface`,
|
|
path
|
|
);
|
|
}
|
|
if (part.animation.children) {
|
|
throw new MorfoInvariantError(
|
|
`part "${part.kebab}" declares both animation.children and animation.staggerChildren — Presence-children and DOM-children are different coordination models; pick one`,
|
|
path
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (morfo.focus) {
|
|
const f = morfo.focus;
|
|
if (typeof f.initial === 'object' && !kebabs.has(f.initial.partRef)) {
|
|
throw new MorfoInvariantError(
|
|
`focus.initial.partRef "${f.initial.partRef}" does not match any part`
|
|
);
|
|
}
|
|
if (typeof f.return === 'object' && !kebabs.has(f.return.partRef)) {
|
|
throw new MorfoInvariantError(
|
|
`focus.return.partRef "${f.return.partRef}" does not match any part`
|
|
);
|
|
}
|
|
}
|
|
|
|
const events = morfo.events ?? [];
|
|
const eventNames = new Set<string>();
|
|
const partByKebab = new Map(flat.map(({ part }) => [part.kebab, part] as const));
|
|
const prewriteDLAByPart = new Map<string, Set<string>>();
|
|
|
|
for (const event of events) {
|
|
if (eventNames.has(event.name)) {
|
|
throw new MorfoInvariantError(
|
|
`duplicate event name "${event.name}" in morfo "${morfo.kebab}"`
|
|
);
|
|
}
|
|
eventNames.add(event.name);
|
|
|
|
if (!kebabs.has(event.semantic.target.target)) {
|
|
throw new MorfoInvariantError(
|
|
`event "${event.name}" targets unknown part "${event.semantic.target.target}"`
|
|
);
|
|
}
|
|
|
|
if ('intent' in event.semantic && typeof event.semantic.intent === 'object') {
|
|
if (event.semantic.intent.supported.length === 0) {
|
|
throw new MorfoInvariantError(
|
|
`event "${event.name}" must declare at least one supported intent`
|
|
);
|
|
}
|
|
if (!event.semantic.intent.supported.includes(event.semantic.intent.default)) {
|
|
throw new MorfoInvariantError(
|
|
`event "${event.name}" default intent "${event.semantic.intent.default}" must be included in supported intents`
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const write of event.prewrite ?? []) {
|
|
if (!kebabs.has(write.part.target)) {
|
|
throw new MorfoInvariantError(
|
|
`event "${event.name}" prewrite targets unknown part "${write.part.target}"`
|
|
);
|
|
}
|
|
const targetPart = partByKebab.get(write.part.target);
|
|
const dataEntry = targetPart?.data.find((d) => d.attr === write.attr);
|
|
if (!dataEntry) {
|
|
throw new MorfoInvariantError(
|
|
`event "${event.name}" prewrite attr "${write.attr}" is not declared on part "${write.part.target}"`
|
|
);
|
|
}
|
|
if (dataEntry.values && !dataEntry.values.includes(write.value)) {
|
|
throw new MorfoInvariantError(
|
|
`event "${event.name}" prewrite value "${write.value}" is not declared for "${write.attr}"`
|
|
);
|
|
}
|
|
if (write.attr === 'data-last-action') {
|
|
const values = prewriteDLAByPart.get(write.part.target) ?? new Set<string>();
|
|
values.add(write.value);
|
|
prewriteDLAByPart.set(write.part.target, values);
|
|
}
|
|
}
|
|
|
|
if (event.commits) {
|
|
if (!kebabs.has(event.commits.part.target)) {
|
|
throw new MorfoInvariantError(
|
|
`event "${event.name}" commits unknown part "${event.commits.part.target}"`
|
|
);
|
|
}
|
|
if (event.commits.attr === 'data-state') {
|
|
const targetPart = partByKebab.get(event.commits.part.target);
|
|
const states = targetPart?.states ?? [];
|
|
if (!states.includes(event.commits.value)) {
|
|
throw new MorfoInvariantError(
|
|
`event "${event.name}" commits state "${event.commits.value}" not declared on part "${event.commits.part.target}"`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const part of flat.map(({ part }) => part)) {
|
|
const dataLastAction = part.data.find((entry) => entry.attr === 'data-last-action');
|
|
if (!dataLastAction?.values) continue;
|
|
|
|
const declared = new Set(dataLastAction.values);
|
|
const written = prewriteDLAByPart.get(part.kebab) ?? new Set<string>();
|
|
|
|
// Direction 1 (kept strict): any value an event prewrites must be
|
|
// declared in values[]. Prevents typos and orphan prewrites.
|
|
for (const value of written) {
|
|
if (!declared.has(value)) {
|
|
throw new MorfoInvariantError(
|
|
`part "${part.kebab}" is prewritten with data-last-action="${value}" but the attr does not declare it in values[]`
|
|
);
|
|
}
|
|
}
|
|
|
|
// Direction 2 (loosened — book §5.3 polymorphism): values declared in
|
|
// `values[]` may also be set IMPERATIVELY by the provider (e.g.
|
|
// `runtime.partRef(part)` + `dom.apply`) when a polymorphic event
|
|
// can't bind a single `prewrite` per call. Requiring every value to
|
|
// have a matching event prewrite breaks the polymorphic close
|
|
// pattern (one `close` event, multiple data-last-action values set
|
|
// by the provider). `data-last-action.values[]` remains the closed
|
|
// enum of valid values — eidos and lint still consume it.
|
|
}
|
|
}
|
|
|
|
// ── Public API ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Validate a `Morfo` value. Throws `SiumValidationError` on shape errors
|
|
* and `MorfoInvariantError` on cross-reference errors.
|
|
*
|
|
* Intended for build-time / dev-time. Run once per morfo on first load;
|
|
* results are cacheable.
|
|
*
|
|
* This validator also checks `morfo.events` when present.
|
|
*/
|
|
export function validateMorfo(morfo: unknown): Morfo {
|
|
morfoShallowSchema.decodeSync(morfo as never);
|
|
const m = morfo as Morfo;
|
|
|
|
// Sium has no lazy() today, so parts are validated via an explicit walker.
|
|
validatePartsRecursively(m.parts);
|
|
|
|
// Cross-reference invariants: kebab uniqueness, partRef/stateRef, focus.
|
|
validateInvariants(m);
|
|
|
|
return m;
|
|
}
|
|
|
|
export { morfoShallowSchema, partShallowSchema };
|