Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
470 changes: 470 additions & 0 deletions .cursor/plans/interactconfig_schema_validation_918ab0af.plan.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ export class PgConditionEditor extends BaseComponent {
<div class="field" style="flex: 2">
<label>Predicate</label>
<input type="text" class="pg-input" data-cond-predicate="${id}"
value="${this._escapeAttr(condition.predicate ?? '')}"
value="${this._escapeAttr(condition.predicate)}"
placeholder="${placeholder}">
</div>
</div>
Expand Down Expand Up @@ -350,7 +350,7 @@ export class PgConditionEditor extends BaseComponent {
this.store.dispatch(
updateCondition(condId, {
...current,
predicate: input.value || undefined,
predicate: input.value || '',
}),
);
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"main": "index.js",
"packageManager": "yarn@4.10.3",
"scripts": {
"build": "yarn workspaces foreach --all --topological --include 'packages/*' run build && yarn workspaces foreach --all --include 'apps/*' run build",
"build": "yarn workspaces foreach --all --topological-dev --include 'packages/*' run build && yarn workspaces foreach --all --include 'apps/*' run build",
"lint": "eslint . --max-warnings=0",
"test": "yarn workspaces foreach --all --include 'packages/*' run test",
"dev:website": "yarn workspace @wix/interact-website run dev",
Expand Down
62 changes: 62 additions & 0 deletions packages/interact-validate/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
{
"name": "@wix/interact-validate",
"version": "1.0.0",
"description": "Schema + referential + semantic validation for @wix/interact's InteractConfig, powered by zod.",
"license": "MIT",
"main": "dist/cjs/index.js",
"module": "dist/es/index.js",
"types": "dist/types/index.d.ts",
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"import": "./dist/es/index.js",
"require": "./dist/cjs/index.js"
}
},
"files": [
"dist",
"rules"
],
"sideEffects": false,
"scripts": {
"build": "rimraf dist && vite build && npm run build:types",
"build:types": "tsc -p tsconfig.build.json",
"lint": "tsc --noEmit",
"test": "vitest run",
"coverage": "vitest run --coverage"
},
"keywords": [
"animation",
"interaction",
"validation",
"zod",
"schema",
"interact",
"wix"
],
"author": {
"name": "wow!Team",
"email": "wow-dev@wix.com"
},
"repository": {
"type": "git",
"url": "git+https://github.com/wix/interact.git"
},
"bugs": {
"url": "https://github.com/wix/interact/issues"
},
"dependencies": {
"zod": "^4.0.0"
},
"peerDependencies": {
"@wix/interact": "^2.4.0"
},
"devDependencies": {
"@vitest/coverage-v8": "^4.0.14",
"@wix/interact": "^2.4.0",
"rimraf": "^6.0.1",
"typescript": "^5.9.3",
"vite": "^7.2.2",
"vitest": "^4.0.14"
}
}
153 changes: 153 additions & 0 deletions packages/interact-validate/src/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import type { InteractConfig, SequenceConfig, SequenceConfigRef, Interaction } from '@wix/interact';
import type { Effect, EffectRef } from '@wix/interact';

export type Path = (string | number)[];

export type EffectIdRef = { path: Path; effectId: string };
export type SequenceIdRef = { path: Path; sequenceId: string };
export type ConditionRef = { path: Path; conditionId: string };
export type InteractionRef = { path: Path; interaction: Interaction };

export type TriggerEffectTuple = {
trigger: string;
effect: Effect;
path: Path;
};

export type KeyframeNameRef = {
name: string;
path: Path;
};

export type ValidationContext = {
config: InteractConfig;

effectIds: Set<string>;
sequenceIds: Set<string>;
conditionIds: Set<string>;

effectIdReferences: EffectIdRef[];
sequenceIdReferences: SequenceIdRef[];
conditionReferences: ConditionRef[];
interactions: InteractionRef[];

triggerEffectTuples: TriggerEffectTuple[];
keyframeNames: KeyframeNameRef[];
};

function isEffectRef(entry: Effect | EffectRef): entry is EffectRef {
return typeof (entry as Record<string, unknown>)['effectId'] === 'string';
}

function isSequenceRef(entry: SequenceConfig | SequenceConfigRef): entry is SequenceConfigRef {
return !('effects' in entry);
}

function collectKeyframeName(effect: Effect, basePath: Path, out: KeyframeNameRef[]): void {
const ke = (effect as Record<string, unknown>)['keyframeEffect'] as { name: string } | undefined;
if (ke) {
out.push({ name: ke.name, path: [...basePath, 'keyframeEffect', 'name'] });
}
}

function walkEffect(
effect: Effect,
basePath: Path,
ctx: Pick<ValidationContext, 'conditionReferences' | 'keyframeNames'>,
): void {
effect.conditions?.forEach((c, i) =>
ctx.conditionReferences.push({ path: [...basePath, 'conditions', i], conditionId: c }),
);
collectKeyframeName(effect, basePath, ctx.keyframeNames);
}

function walkSequence(
seq: SequenceConfig,
basePath: Path,
ctx: Pick<ValidationContext, 'effectIdReferences' | 'conditionReferences' | 'keyframeNames'>,
): void {
seq.effects.forEach((entry, i) => {
const path = [...basePath, 'effects', i];
if (isEffectRef(entry)) {
ctx.effectIdReferences.push({ path: [...path, 'effectId'], effectId: entry.effectId });
} else {
walkEffect(entry, path, ctx);
}
});
seq.conditions?.forEach((c, i) =>
ctx.conditionReferences.push({ path: [...basePath, 'conditions', i], conditionId: c }),
);
}

export function buildContext(config: InteractConfig): ValidationContext {
const effectIds = new Set(Object.keys(config.effects ?? {}));
const sequenceIds = new Set(Object.keys(config.sequences ?? {}));
const conditionIds = new Set(Object.keys(config.conditions ?? {}));

const effectIdReferences: EffectIdRef[] = [];
const sequenceIdReferences: SequenceIdRef[] = [];
const conditionReferences: ConditionRef[] = [];
const interactions: InteractionRef[] = [];
const triggerEffectTuples: TriggerEffectTuple[] = [];
const keyframeNames: KeyframeNameRef[] = [];

for (const [id, effect] of Object.entries(config.effects ?? {})) {
walkEffect(effect, ['effects', id], { conditionReferences, keyframeNames });
}

for (const [id, seq] of Object.entries(config.sequences ?? {})) {
walkSequence(seq, ['sequences', id], {
effectIdReferences,
conditionReferences,
keyframeNames,
});
}

config.interactions.forEach((interaction, i) => {
const base: Path = ['interactions', i];
interactions.push({ path: base, interaction });

interaction.conditions?.forEach((c, ci) =>
conditionReferences.push({ path: [...base, 'conditions', ci], conditionId: c }),
);

if (interaction.trigger === 'animationEnd' && interaction.params) {
effectIdReferences.push({
path: [...base, 'params', 'effectId'],
effectId: (interaction.params as { effectId: string }).effectId,
});
}

interaction.effects?.forEach((entry, ei) => {
const path: Path = [...base, 'effects', ei];
if (isEffectRef(entry)) {
effectIdReferences.push({ path: [...path, 'effectId'], effectId: entry.effectId });
} else {
walkEffect(entry, path, { conditionReferences, keyframeNames });
triggerEffectTuples.push({ trigger: interaction.trigger, effect: entry, path });
}
});

interaction.sequences?.forEach((entry, si) => {
const path: Path = [...base, 'sequences', si];
if (isSequenceRef(entry)) {
sequenceIdReferences.push({ path: [...path, 'sequenceId'], sequenceId: entry.sequenceId });
} else {
walkSequence(entry, path, { effectIdReferences, conditionReferences, keyframeNames });
}
});
});

return {
config,
effectIds,
sequenceIds,
conditionIds,
effectIdReferences,
sequenceIdReferences,
conditionReferences,
interactions,
triggerEffectTuples,
keyframeNames,
};
}
24 changes: 24 additions & 0 deletions packages/interact-validate/src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export type Severity = 'error' | 'warning';

export type ValidationError = {
code: string;
message: string;
path: (string | number)[];
severity: Severity;
hint?: string;
};

export type ValidationResult = {
valid: boolean;
errors: ValidationError[];
};

export class InteractValidationError extends Error {
readonly errors: ValidationError[];

constructor(errors: ValidationError[]) {
super(`Interact config validation failed with ${errors.length} issue(s).`);
this.name = 'InteractValidationError';
this.errors = errors;
}
}
107 changes: 107 additions & 0 deletions packages/interact-validate/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { InteractConfig } from '@wix/interact';
import { buildContext } from './context';
import {
InteractValidationError,
type Severity,
type ValidationError,
type ValidationResult,
} from './errors';
import { validateSemantic } from './semantic';
import { validateStructural } from './structural';

export { InteractValidationError };
export type { Severity, ValidationError, ValidationResult } from './errors';

export { RULES, type Rule } from './rules';

// Zod schemas and sub-schemas for host-project schema composition
export {
InteractConfigSchema,
Interaction,
TriggerType,
ViewEnterParams,
PointerMoveParams,
AnimationEndParams,
TriggerParams,
SerializableEffect,
SerializableEffectRef,
SerializableEffectSource,
SerializableTimeEffect,
EffectBase,
NamedEffect,
SCRUB_FIELDS,
STATE_FIELDS,
TIME_FIELDS,
SerializableSequenceConfig,
SerializableSequenceConfigRef,
Keyframe,
LengthPercentage,
RangeOffset,
Condition,
MediaCondition,
} from './schema';
export type {
InteractConfig,
ConditionDef,
SequenceOptionsConfig,
SequenceConfig,
SequenceConfigRef,
InteractionDef,
InteractionTrigger,
Effect,
EffectRef,
} from './schema';

export type ValidateOptions = {
strict?: boolean;
severityOverrides?: Record<string, Severity | 'off'>;
max?: number;
};

function comparePath(a: (string | number)[], b: (string | number)[]): number {
const len = Math.min(a.length, b.length);
for (let i = 0; i < len; i++) {
const av = a[i]!;
const bv = b[i]!;
if (av === bv) continue;
if (typeof av === 'number' && typeof bv === 'number') return av - bv;
return String(av) < String(bv) ? -1 : 1;
}
return a.length - b.length;
}

function finalize(errors: ValidationError[], opts: ValidateOptions): ValidationResult {
let next = errors;
if (opts.strict) {
next = next.map((e) => (e.severity === 'warning' ? { ...e, severity: 'error' } : e));
}
next = [...next].sort((a, b) => comparePath(a.path, b.path));
if (opts.max !== undefined && next.length > opts.max) {
next = next.slice(0, opts.max);
}
const valid = !next.some((e) => e.severity === 'error');
return { valid, errors: next };
}

export function validateInteractConfig(
input: unknown,
options: ValidateOptions = {},
): ValidationResult {
const layer1 = validateStructural(input);
if (!layer1.ok || !layer1.parsed) {
return finalize(layer1.errors, options);
}
const ctx = buildContext(layer1.parsed);
const layer2 = validateSemantic(ctx, options.severityOverrides);
return finalize(layer2, options);
}

export function assertValidInteractConfig(
input: unknown,
options: ValidateOptions = {},
): asserts input is InteractConfig {
const result = validateInteractConfig(input, options);
if (!result.valid) {
throw new InteractValidationError(result.errors);
}
}
Loading
Loading