diff --git a/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md b/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md new file mode 100644 index 00000000..4b3c5e77 --- /dev/null +++ b/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md @@ -0,0 +1,470 @@ +--- +name: InteractConfig Schema Validation +overview: Ship schema + referential + semantic validation for `InteractConfig` as a new `@wix/interact/validate` subpath, by retargeting the existing validation harness and stripping the host project's Experience/controls layer. Uses Opus's plan as the base with Sonnet's concise todo structure and canonical type re-export pattern added. +todos: + - id: phase0-deps-wiring + content: 'Phase 0: Add zod as production dep (v4); wire validate entry in vite.config.ts and package.json exports; confirm bundle isolation (dist/es/index.js has no zod)' + status: completed + - id: phase1-schema + content: 'Phase 1: Move src/schema/ → src/validate/schema/; trim primitives (keep MediaCondition), effects (accept customEffect), sequences (accept function offsetEasing), interactions (remove id, no interactionId, discriminatedUnion); rewrite index.ts to re-export canonical types from types/config.ts' + status: completed + - id: phase2-harness-rename + content: 'Phase 2: Rename ExperienceValidationError → InteractValidationError; retarget structural.ts to InteractConfigSchema (extend mapZodCode); rename public API to validateInteractConfig / assertValidInteractConfig; export RULES, Rule type, and zod sub-schemas' + status: completed + - id: phase3-context + content: 'Phase 3: Rewrite validate/context.ts — drop elementKeys/controlIds/styleSelectors/etc.; walk InteractConfig directly; add isEffectRef/isSequenceRef predicates; collect trigger+effect tuples and definition maps for new rules' + status: completed + - id: phase4-rules-trim + content: 'Phase 4a: Delete controls/* and 6 out-of-scope referential rules; add rules/_factory.ts with referenceRule() helper; rewrite 4 ID-existence rules as one-liners' + status: completed + - id: phase4-rules-add + content: 'Phase 4b: Add 5 new semantic rules — triggerEffectCompatible (warning), numericBounds, conditionPredicateRequired, uniqueDefinitionIds, unusedDefinitions (warnings); update rules/index.ts RULES array' + status: completed + - id: phase5-tests + content: 'Phase 5: Unit tests per rule (valid config + per-code fixture); structural tests; type-parity test (expectTypeOf); bundle test (CI grep for zod)' + status: completed + - id: phase6-docs + content: 'Phase 6: README section for @wix/interact/validate; error-code table (§7); llms.txt entry' + status: pending +isProject: false +--- + +# InteractConfig Schema Validation — Implementation Plan + +> Status: proposal. Goal: ship schema + referential + semantic validation for +> `InteractConfig` as an opt-in subpath of `@wix/interact`, reusing the +> already-copied validation harness but stripping the host project's +> `Experience` / controls / styles / bindings layer. + +## 0. Guiding decisions (the "why") + +The copied code in `src/schema/` and `src/validate/` validates the **host +project's `Experience` document**, not Interact's config. Interact's real config +is `InteractConfig = { effects?, sequences?, conditions?, interactions }` +(`src/types/config.ts:46`). So the task is: **keep the harness, extract the +Interact slice, amputate everything else.** + +- **Location:** new opt-in subpath `@wix/interact/validate`. Keeps `zod` (a new + runtime dep, currently not installed) out of the default bundle — it is + imported _only_ from this subpath, so tree-shaking excludes it for consumers + who don't import it. Same package (not a separate one) so validation can never + version-skew from the config types it must track. +- **Target type:** validate `InteractConfig` directly. The host app composes its + own `Experience` schema on top if it wants one. +- **Serializable subset:** zod cannot model function fields (`customEffect`, + function `offsetEasing`). Decide (see §8) whether to (a) validate the JSON form + only, or (b) accept+skip function fields so JS-authored configs pass. + +--- + +## 1. Final-state file layout + +``` +packages/interact/src/ + validate/ + index.ts # public API: validateInteractConfig, assertValidInteractConfig, types, RULES + errors.ts # ValidationError/Result + InteractValidationError (renamed) + structural.ts # zod safeParse → ValidationError[] (kept, retargeted to InteractConfigSchema) + semantic.ts # rule runner (kept as-is) + context.ts # REWRITTEN: walks InteractConfig, drops elements/controls/styles + schema/ # MOVED here from src/schema (only the Interact slice) + index.ts + primitives.ts # trimmed + effects.ts # kept ~as-is (+ exported field-group constants) + sequences.ts # kept + interactions.ts # rewritten root → InteractConfigSchema + rules/ + index.ts # trimmed RULES list + Rule type + referenceRule factory + _factory.ts # NEW: referenceRule() helper + referential/ + effectIdsExist.ts + sequenceIdsExist.ts + animationEndEffectExists.ts + conditionsExist.ts + interactionHasEffectsOrSequences.ts + semantic/ # NEW bucket + triggerEffectCompatible.ts # NEW (the big one) + numericBounds.ts # NEW + conditionPredicateRequired.ts # NEW + uniqueDefinitionIds.ts # NEW (dup effect/sequence keys, keyframe names) + unusedDefinitions.ts # NEW (warnings) + conditions/ + validMediaQueries.ts + validate.test.ts (or __tests__/) # NEW +``` + +**Deleted entirely:** + +- `src/schema/experience.ts`, `src/schema/controls.ts` +- `src/validate/rules/controls/*` (all six) +- `src/validate/rules/referential/{controlTargetsExist,styleBindingSelectorExists,variableBindingIsCustomProperty,bindingPropertyRequired,interactionKeysExist,effectKeyExistsInElements}.ts` +- the stray empty `src/rules/` tree (copy artifact) + +> Note: I'm folding `src/schema/` under `src/validate/schema/` so the entire +> zod-dependent surface lives under one subpath dir. If you'd rather keep +> `src/schema/` top-level, that's fine — just ensure nothing outside +> `src/validate/**` imports it, or zod leaks into the main bundle. + +--- + +## 2. Phase 0 — deps & build wiring + +1. **Add zod as a regular dependency** (decision 8.4; pin v4 — the schemas use v4 + issue codes like `invalid_value`, `code:'custom'`): + - `package.json` → `dependencies: { "zod": "^4.x" }`. Run `nvm use && yarn`. + - Externalized in the bundle (step 3), so it's tree-shaken out of the + main/`react`/`web` entries and only loaded when `/validate` is imported. +2. **`exports` map** — add subpath: + ```jsonc + "./validate": { + "types": "./dist/types/validate/index.d.ts", + "import": "./dist/es/validate.js", + "require": "./dist/cjs/validate.js" + } + ``` +3. **`vite.config.ts`** — add entry + externalize zod: + ```ts + lib: { entry: { index: …, react: …, web: …, + validate: path.resolve(__dirname, 'src/validate/index.ts') } } + rollupOptions: { external: ['react', 'react-dom', 'zod'] } + ``` +4. **`tsconfig.build.json`** already emits declarations for all of `src`, so + `dist/types/validate/index.d.ts` is produced automatically. No change needed. +5. **Sanity:** `yarn build` then confirm `dist/es/validate.js` exists and that + `dist/es/index.js` does **not** contain `zod` (grep the bundle). + +--- + +## 3. Phase 1 — schema, trimmed to InteractConfig + +### `schema/primitives.ts` + +- **Keep:** `Keyframe`, `LengthPercentage`, `RangeOffset`, `Condition`, + `MediaCondition`. +- **Delete:** `ElementEntry`, `StyleRule`, `ExperienceMeta`, + `ExperienceSchemaVersion`. + +### `schema/effects.ts` + +- **Keep all of it.** Additionally **export** the field-group constants for reuse + by the new compatibility rule: + ```ts + export const SCRUB_FIELDS = […] // already defined locally + export const STATE_FIELDS = […] + export const TIME_FIELDS = ['duration','easing','iterations','alternate', + 'reversed','delay','fill','composite'] as const // NEW + ``` +- **Remove dead aliases** `SerializableScrubEffect`/`SerializableStateEffect` + (they're `= SerializableEffect` no-ops). Update the barrel accordingly. +- **Accept `customEffect`** (decision 8.1): add + `customEffect: z.custom((v) => typeof v === 'function').optional()` to + `SourceFields`, and count it as a valid third source in all three refinements + (`SerializableEffectSource`, `SerializableEffect.superRefine`, + `SerializableTimeEffect`). Opaque — no deep validation. + +### `schema/sequences.ts` + +- **Keep as-is** (`SerializableSequenceConfig`, `SerializableSequenceConfigRef`). +- **Accept function `offsetEasing`** (decision 8.1): + `offsetEasing: z.union([z.string(), z.custom((v) => typeof v === 'function')]).optional()`. + +### `schema/interactions.ts` + +- **Keep:** `TriggerType`, `ViewEnterParams`, `PointerMoveParams`, + `AnimationEndParams`, `TriggerParams`, and the four per-trigger interaction + shapes. +- **Fixes (resolve drift vs `src/types`):** + - `InteractionBase`: **remove `id`** (not on the `Interaction` type). Keep + `key: z.string().min(1)`. + - **Do NOT add effect-level `interactionId`** (decision 8.3): it is + runtime-generated (`Interact.ts:468`), never author-provided. Leaving it out + means `.strict()` rejects it if mistakenly supplied — desired. + - Convert the interaction union to `z.discriminatedUnion('trigger', […])` for + better errors + speed. +- **Replace `ExperienceInteractConfig` with the real root** (rename file export): + ```ts + export const InteractConfigSchema = z + .object({ + effects: z.record(z.string().min(1), SerializableEffect).optional(), // optional, matches type + sequences: z.record(z.string().min(1), SerializableSequenceConfig).optional(), + conditions: z.record(z.string().min(1), Condition).optional(), // primitives.Condition → includes 'container' + interactions: z.array(Interaction), + }) + .strict(); + ``` + Note the two drift fixes vs the copied version: `effects` becomes **optional**, + and `conditions` uses the full `Condition` (with `'container'`). + +### `schema/index.ts` + +- Strip all `Experience*`, `Control*`, `Binding*`, `ElementEntry`, `StyleRule`, + `ExperienceMeta` re-exports. Keep effect/sequence/interaction/primitive + exports + the new `InteractConfigSchema`. +- Re-export the canonical types from `../types/config.ts` rather than re-deriving + them via `z.infer<>`. This eliminates the naming collision (value `Condition` + and type `Condition` with the same name) and keeps a single source of truth: + ```typescript + // values + export { InteractConfigSchema, SerializableEffect, ... } from './interactions'; + // types — single source of truth + export type { InteractConfig, Effect, Condition, ... } from '../types/config'; + ``` + +--- + +## 4. Phase 2 — harness rename (mechanical) + +### `validate/errors.ts` + +- Rename `ExperienceValidationError` → `InteractValidationError`; message text + "Interact config validation failed…". Keep `Severity`, `ValidationError`, + `ValidationResult` unchanged. + +### `validate/structural.ts` + +- Point at `InteractConfigSchema` instead of `ExperienceSchema`. Keep + `mapZodCode`; **extend it** with `too_small` → `SCHEMA_TOO_SMALL`, + `invalid_enum_value`/`invalid_value` already handled. Return type `parsed?: +InteractConfig`. + +### `validate/semantic.ts` + +- **No change** (already generic over `ctx` + `RULES`). + +### `validate/index.ts` + +- Rename `validateExperience` → `validateInteractConfig`, + `assertValidExperience` → `assertValidInteractConfig` (`asserts input is +InteractConfig`). Keep `finalize`/`comparePath`/`ValidateOptions` + (`strict`/`severityOverrides`/`max`) verbatim. +- **Export** `RULES` and the `Rule` type so consumers can register custom rules. +- **Re-export the zod schemas** (decision 8.2): `InteractConfigSchema`, its + `z.infer` type, and the sub-schemas (effects/sequences/interactions/primitives) + so the host "Experience" project can compose its own schema on top. + +--- + +## 5. Phase 3 — `context.ts` rewrite + +Rewrite `buildContext(config: InteractConfig)`: + +- **Drop:** `elementKeys`, `controlIds`, `styleSelectors`, `interactionIds`, + `controlBindingReferences`, `variableBindings`, `controls`, `cssVarUsage`, + `interactionKeyReferences`, `effectKeyReferences`, and the `var()` collector. +- **Keep:** `effectIds`, `sequenceIds`, `conditionIds` (from + `config.effects/sequences/conditions` keys); reference lists + `effectIdReferences`, `sequenceIdReferences`, `conditionReferences`, and the + `interactions` list (with paths). +- Root paths change from `['interact', 'effects', id]` → `['effects', id]` (no + `interact` wrapper). +- **Extract a single `isEffectRef(entry)` predicate** and a single + `isSequenceRef(entry)` predicate (replaces the duplicated + `'effectId' in entry && !('namedEffect' in entry) && …` sniffing in + `walkSequence` and the interaction loop). Prefer deriving ref-ness from the + schema discriminant over key-presence sniffing. +- For new rules, also collect: per-interaction `(trigger, effectEntry, path)` + tuples (the compatibility rule needs trigger + effect together), and the raw + definition maps for unused/duplicate detection. + +--- + +## 6. Phase 4 — rules: trim + add + +### Keep (retargeted, content unchanged except code paths) + +| File | Code | Severity | +| ---------------------------------------------- | -------------------------------- | -------- | +| `referential/effectIdsExist` | `EFFECT_ID_NOT_FOUND` | error | +| `referential/sequenceIdsExist` | `SEQUENCE_ID_NOT_FOUND` | error | +| `referential/animationEndEffectExists` | `ANIMATION_END_EFFECT_NOT_FOUND` | error | +| `referential/conditionsExist` | `CONDITION_NOT_FOUND` | error | +| `referential/interactionHasEffectsOrSequences` | `INTERACTION_EMPTY` | error | +| `conditions/validMediaQueries` | `INVALID_MEDIA_QUERY` | warning | + +### Delete + +All `controls/*`, the four binding/style referential rules, plus +`interactionKeysExist` and `effectKeyExistsInElements` (they checked +`Experience.elements`, which doesn't exist in `InteractConfig`; `key` is a +runtime DOM identifier — see §8 for the runtime-only alternative). + +### Add `rules/_factory.ts` + +Collapse the near-identical referential rules: + +```ts +export function referenceRule(opts: { + code: string; + severity: Severity; + refs: (ctx: ValidationContext) => T[]; + has: (ctx: ValidationContext, ref: T) => boolean; + message: (ref: T) => string; + hint?: string; +}): Rule { + /* filter !has, map to ValidationError */ +} +``` + +Rewrite the four ID-existence rules as one-line `referenceRule({...})` calls. + +### New rules (see §7 for the catalogue) + +`semantic/triggerEffectCompatible`, `semantic/numericBounds`, +`semantic/conditionPredicateRequired`, `semantic/uniqueDefinitionIds`, +`semantic/unusedDefinitions`. + +### `rules/index.ts` + +New `RULES` array: the 5 kept referential + `validMediaQueries` + the 5 new +semantic rules. Keep the `Rule` type exported. + +--- + +## 7. New validation rules — catalogue + +| Code | What it checks | Severity | Notes | +| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `TRIGGER_EFFECT_INCOMPATIBLE` | Scrub fields (`SCRUB_FIELDS`) only on `viewProgress`/`pointerMove`; time fields (`TIME_FIELDS`) only on discrete triggers; state fields not on scrub triggers. | **warning** initially | The big one. Runtime silently drops bad combos (`resolvers.ts:87`). Start as warning to avoid false positives, promote to error once stable. Reuse exported field-group constants. | +| `THRESHOLD_OUT_OF_RANGE` | `viewEnter.threshold` ∈ [0,1]. | error | | +| `NEGATIVE_DURATION` etc. (`numericBounds`) | `duration`/`delay`/`iterations`/sequence `offset`/`delay` ≥ 0. | error | One rule, multiple checks. | +| `CONDITION_PREDICATE_REQUIRED` | `predicate` present for condition `type` `media`/`container`. | error | Fixes schema marking it optional. | +| `DUPLICATE_KEYFRAME_NAME` | `keyframeEffect.name` unique across effects. | warning | Part of `uniqueDefinitionIds`. | +| `UNUSED_EFFECT` / `UNUSED_SEQUENCE` / `UNUSED_CONDITION` | Defined but never referenced by any interaction. | warning | Mirror of referential rules; dead-config hygiene. | + +**Deferred / runtime-only (document, don't ship in static validator):** + +- `NAMED_EFFECT_NOT_REGISTERED` — `namedEffect.type` must be in motion's registry + (`getRegisteredEffect`). Only knowable _after_ `registerEffects`, so expose as + an optional runtime check, not part of static `validateInteractConfig`. +- `UNKNOWN_EASING` — `easing`/`offsetEasing` resolvable by motion's `getJsEasing` + or valid `cubic-bezier()`. Warning; needs the easing name list from motion. + +--- + +## 8. Resolved decisions + +All five resolved below with codebase evidence. Consequences are propagated into +the phases above. + +### 8.1 Function fields → **accept + skip (opaque)** ✅ + +`customEffect` and function-valued `offsetEasing` are **genuine authored +options**, not just type noise: + +- `resolvers.ts:74,95` — `else if (customEffect)` is a first-class effect source. +- `resolvers.ts:146` — `if (typeof offsetEasing === 'function')` branch. +- Referenced in `README.md`, `docs/guides/effects-and-animations.md`, + `test/resolvers.spec.ts`. + +A JSON-only schema would **reject valid JS-authored configs** → unacceptable. +**Resolution:** + +- `schema/effects.ts`: add `customEffect: z.custom((v) => typeof v === 'function').optional()` + to `SourceFields`, and **count it as a valid third source** in all three + refinements (`SerializableEffectSource`, `SerializableEffect.superRefine`'s + source/state checks, and `SerializableTimeEffect`'s "must have a source"). + No deep validation of the function — opaque. +- `schema/sequences.ts`: `offsetEasing: z.union([z.string(), z.custom(...)]).optional()`. +- **Defer** (not v1): an opt-in `serializableOnly` flag that emits a + `NOT_SERIALIZABLE` _warning_ for function fields, for consumers exporting + configs to JSON (e.g. AI round-tripping). Add to `ValidateOptions` later. + +### 8.2 Schema dir placement → **fold into `src/validate/schema/` + export publicly** ✅ + +Evidence: nothing outside `src/validate` imports `src/schema`; `core/`+`dom/` +import neither `zod` nor any schema. So folding is safe and keeps the entire +zod-dependent surface under the one subpath. +**Resolution:** move `src/schema/` → `src/validate/schema/`. **Additionally, +re-export `InteractConfigSchema`, its `z.infer` type, and the sub-schemas +(effects/sequences/interactions/primitives) from `src/validate/index.ts`** — the +host "Experience" project that prompted this work needs to _compose_ its own +schema on top of Interact's, so the zod schemas are part of the public `/validate` +API, not just internals. + +### 8.3 `interactionId` on interaction effects → **internal-only; keep OUT of schema** ✅ + +(Reverses the earlier "add it optional" lean.) Evidence: it is **runtime-generated, +never author-provided** — `Interact.ts:467-468`: + +```ts +const interactionId = `${source}::${target}::${effectId}::${interactionIdx}`; +effect.interactionId = interactionId; // mutated in place at runtime +``` + +It only exists on the `Effect` type because the runtime mutates the parsed +objects. Validation runs on the _authored_ config (pre-mutation), where the field +is absent. +**Resolution:** do **not** add `interactionId` to the interaction-effect schema. +`.strict()` will then correctly reject it if a user supplies it by mistake — which +is the desired behavior, since authoring it is meaningless. (This makes 8.1's +field set the only author-facing additions.) + +### 8.4 zod dependency form → **regular `dependencies`** ✅ + +Considered the optional-peer pattern the package uses for `react`/`react-dom`, +but that precedent exists because React must be a **deduped singleton** owned by +the host — zod has no such constraint. For zod the only thing peer-optional buys +is a smaller install footprint, at the cost of real DX friction (a missing-module +error if the consumer forgets to add zod). Bundle size is **not** a factor: zod is +listed in `rollupOptions.external` and is imported only from `/validate`, so it is +tree-shaken out of the main/`react`/`web` bundles regardless of dependency form. +**Resolution:** `dependencies: { "zod": "^4.x" }` (lowest friction, conventional, +already externalized in the bundle). Revisit peer-optional only if install +footprint becomes a real complaint. + +### 8.5 Element-key checks → **drop for v1; document a future runtime helper** ✅ + +`InteractConfig` has **no element registry** to check `key`/`selector` against — +they resolve against the live DOM at runtime (`Interact.ts:391`: +"Interaction … is missing a key for source element"). So static validation +**cannot** verify them; `interactionKeysExist` / `effectKeyExistsInElements` are +deleted (already in the Phase-4 delete list). +**Resolution:** drop statically for v1. **Note as future scope:** an optional +runtime helper `validateInteractConfigInDOM(config, root = document)` that checks +each `key`/`selector` resolves to ≥1 live element — shipped from `/validate` but +clearly separated from the static `validateInteractConfig`. Not in v1. + +--- + +## 9. Phase 5 — tests + +- **Unit per rule** under `src/validate/**`: a valid config (0 errors) + one + fixture per error `code`. Reuse existing fixtures where the copied tests exist; + otherwise build minimal `InteractConfig` literals. +- **Structural tests:** unknown key (`.strict`) → `SCHEMA_UNRECOGNIZED_KEYS`; + wrong type → `SCHEMA_INVALID_TYPE`; discriminated-union bad `trigger`. +- **`ValidateOptions`:** `strict` promotes warnings→errors; `severityOverrides` + `'off'` skips a rule; `max` truncates; output sorted by path. +- **Type-parity test (drift guard) — important:** with `vitest`'s `expectTypeOf`, + assert the schema and the hand-written type agree: + ```ts + expectTypeOf>().toMatchTypeOf(); + // and the reverse direction for the serializable subset + ``` + This is what prevents the schema from silently drifting from + `src/types/config.ts` (it already had: `id` field, dropped `'container'`, + string-only `offsetEasing`). +- **Bundle test (CI grep):** assert `dist/es/index.js` has no `zod` import. + +--- + +## 10. Phase 6 — docs & DX + +- README section for `@wix/interact/validate`: `validateInteractConfig(config, +opts)`, `assertValidInteractConfig`, the `ValidationError`/`code` catalogue, + `severityOverrides`/`strict`, and custom-rule registration via exported + `RULES`/`Rule`. +- Add the error-code table (§7) to docs and, if applicable, to `llms.txt`. + +--- + +## 11. Suggested commit/PR sequence + +1. Phase 0 build wiring + add zod (no behavior yet). +2. Phase 1 schema trim + drift fixes (+ schema unit tests). +3. Phases 2–3 harness rename + `context.ts` rewrite. +4. Phase 4 rule trim + `referenceRule` factory (kept rules green). +5. New semantic rules one commit each (§7), each with tests. +6. Type-parity test + bundle test + docs. + +Each step keeps `yarn lint` (tsc) and `yarn test` green. diff --git a/apps/playground/src/components/inspector/pg-condition-editor.ts b/apps/playground/src/components/inspector/pg-condition-editor.ts index fa0a1b28..49adae0a 100644 --- a/apps/playground/src/components/inspector/pg-condition-editor.ts +++ b/apps/playground/src/components/inspector/pg-condition-editor.ts @@ -236,7 +236,7 @@ export class PgConditionEditor extends BaseComponent {
@@ -350,7 +350,7 @@ export class PgConditionEditor extends BaseComponent { this.store.dispatch( updateCondition(condId, { ...current, - predicate: input.value || undefined, + predicate: input.value || '', }), ); } diff --git a/package.json b/package.json index b98ee690..2487a275 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/interact-validate/package.json b/packages/interact-validate/package.json new file mode 100644 index 00000000..83034ff2 --- /dev/null +++ b/packages/interact-validate/package.json @@ -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" + } +} diff --git a/packages/interact-validate/src/context.ts b/packages/interact-validate/src/context.ts new file mode 100644 index 00000000..7fc499a1 --- /dev/null +++ b/packages/interact-validate/src/context.ts @@ -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; + sequenceIds: Set; + conditionIds: Set; + + 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)['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)['keyframeEffect'] as { name: string } | undefined; + if (ke) { + out.push({ name: ke.name, path: [...basePath, 'keyframeEffect', 'name'] }); + } +} + +function walkEffect( + effect: Effect, + basePath: Path, + ctx: Pick, +): 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, +): 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, + }; +} diff --git a/packages/interact-validate/src/errors.ts b/packages/interact-validate/src/errors.ts new file mode 100644 index 00000000..86553965 --- /dev/null +++ b/packages/interact-validate/src/errors.ts @@ -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; + } +} diff --git a/packages/interact-validate/src/index.ts b/packages/interact-validate/src/index.ts new file mode 100644 index 00000000..23fdeca9 --- /dev/null +++ b/packages/interact-validate/src/index.ts @@ -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; + 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); + } +} diff --git a/packages/interact-validate/src/rules/_factory.ts b/packages/interact-validate/src/rules/_factory.ts new file mode 100644 index 00000000..6b2d922f --- /dev/null +++ b/packages/interact-validate/src/rules/_factory.ts @@ -0,0 +1,27 @@ +import type { Path, ValidationContext } from '../context'; +import type { Severity, ValidationError } from '../errors'; + +export function referenceRule(opts: { + code: string; + severity: Severity; + refs: (ctx: ValidationContext) => T[]; + has: (ctx: ValidationContext, ref: T) => boolean; + message: (ref: T) => string; + hint?: string; +}) { + return { + code: opts.code, + defaultSeverity: opts.severity, + run: (ctx: ValidationContext): ValidationError[] => + opts + .refs(ctx) + .filter((ref) => !opts.has(ctx, ref)) + .map((ref) => ({ + code: opts.code, + severity: opts.severity, + path: ref.path, + message: opts.message(ref), + ...(opts.hint !== undefined ? { hint: opts.hint } : {}), + })), + }; +} diff --git a/packages/interact-validate/src/rules/conditions/validMediaQueries.ts b/packages/interact-validate/src/rules/conditions/validMediaQueries.ts new file mode 100644 index 00000000..ba096a07 --- /dev/null +++ b/packages/interact-validate/src/rules/conditions/validMediaQueries.ts @@ -0,0 +1,50 @@ +import type { Rule } from '..'; +import type { ValidationError } from '../../errors'; + +// Minimal CSS media query syntactic check — accepts any non-empty string that +// contains balanced parens and only sane top-level tokens. Falls through to +// `window.matchMedia` when available for the strictest possible check. +function isValidMediaQuery(query: string): boolean { + const q = query.trim(); + if (!q) return false; + if (typeof globalThis !== 'undefined' && 'matchMedia' in globalThis) { + try { + const mql = (globalThis as { matchMedia(q: string): { media: string } }).matchMedia(q); + // Invalid queries make matchMedia return `media: ''` in most engines. + return mql.media !== '' || q === 'all'; + } catch { + return false; + } + } + let depth = 0; + for (const ch of q) { + if (ch === '(') depth++; + else if (ch === ')') { + depth--; + if (depth < 0) return false; + } + } + if (depth !== 0) return false; + return /^[A-Za-z0-9_\-:,()\s.%/<>=]+$/.test(q); +} + +export const validMediaQueries: Rule = { + code: 'INVALID_MEDIA_QUERY', + defaultSeverity: 'warning', + run: (ctx) => { + const errors: ValidationError[] = []; + for (const [id, condition] of Object.entries(ctx.config.conditions ?? {})) { + if (condition.type === 'media' && condition.predicate) { + if (!isValidMediaQuery(condition.predicate)) { + errors.push({ + code: 'INVALID_MEDIA_QUERY', + severity: 'warning', + path: ['conditions', id, 'predicate'], + message: `Invalid media query: ${JSON.stringify(condition.predicate)}.`, + }); + } + } + } + return errors; + }, +}; diff --git a/packages/interact-validate/src/rules/index.ts b/packages/interact-validate/src/rules/index.ts new file mode 100644 index 00000000..2a699395 --- /dev/null +++ b/packages/interact-validate/src/rules/index.ts @@ -0,0 +1,34 @@ +import type { Severity, ValidationError } from '../errors'; +import type { ValidationContext } from '../context'; + +import { effectIdsExist } from './referential/effectIdsExist'; +import { sequenceIdsExist } from './referential/sequenceIdsExist'; +import { animationEndEffectExists } from './referential/animationEndEffectExists'; +import { conditionsExist } from './referential/conditionsExist'; +import { interactionHasEffectsOrSequences } from './referential/interactionHasEffectsOrSequences'; + +import { validMediaQueries } from './conditions/validMediaQueries'; + +import { triggerEffectCompatible } from './semantic/triggerEffectCompatible'; +import { numericBounds } from './semantic/numericBounds'; +import { uniqueDefinitionIds } from './semantic/uniqueDefinitionIds'; +import { unusedDefinitions } from './semantic/unusedDefinitions'; + +export type Rule = { + code: string; + defaultSeverity: Severity; + run: (ctx: ValidationContext) => ValidationError[]; +}; + +export const RULES: Rule[] = [ + effectIdsExist, + sequenceIdsExist, + animationEndEffectExists, + conditionsExist, + interactionHasEffectsOrSequences, + validMediaQueries, + triggerEffectCompatible, + numericBounds, + uniqueDefinitionIds, + unusedDefinitions, +]; diff --git a/packages/interact-validate/src/rules/referential/animationEndEffectExists.ts b/packages/interact-validate/src/rules/referential/animationEndEffectExists.ts new file mode 100644 index 00000000..f9d25e52 --- /dev/null +++ b/packages/interact-validate/src/rules/referential/animationEndEffectExists.ts @@ -0,0 +1,13 @@ +import { referenceRule } from '../_factory'; + +// effectIdReferences entries whose path contains 'params' come exclusively from +// animationEnd interactions (context.ts adds them at [...base, 'params', 'effectId']). +export const animationEndEffectExists = referenceRule({ + code: 'ANIMATION_END_EFFECT_NOT_FOUND', + severity: 'error', + refs: (ctx) => ctx.effectIdReferences.filter((ref) => ref.path.includes('params')), + has: (ctx, ref) => ctx.effectIds.has(ref.effectId), + message: (ref) => + `animationEnd interaction references effect "${ref.effectId}" which is not defined.`, + hint: 'Define the effect in interact.effects or fix the params.effectId.', +}); diff --git a/packages/interact-validate/src/rules/referential/conditionsExist.ts b/packages/interact-validate/src/rules/referential/conditionsExist.ts new file mode 100644 index 00000000..b40f9e00 --- /dev/null +++ b/packages/interact-validate/src/rules/referential/conditionsExist.ts @@ -0,0 +1,11 @@ +import { referenceRule } from '../_factory'; + +export const conditionsExist = referenceRule({ + code: 'CONDITION_NOT_FOUND', + severity: 'error', + refs: (ctx) => ctx.conditionReferences, + has: (ctx, ref) => ctx.conditionIds.has(ref.conditionId), + message: (ref) => + `Condition "${ref.conditionId}" is referenced but not defined in interact.conditions.`, + hint: 'Add an entry to interact.conditions or remove the reference.', +}); diff --git a/packages/interact-validate/src/rules/referential/effectIdsExist.ts b/packages/interact-validate/src/rules/referential/effectIdsExist.ts new file mode 100644 index 00000000..66065e9c --- /dev/null +++ b/packages/interact-validate/src/rules/referential/effectIdsExist.ts @@ -0,0 +1,10 @@ +import { referenceRule } from '../_factory'; + +export const effectIdsExist = referenceRule({ + code: 'EFFECT_ID_NOT_FOUND', + severity: 'error', + refs: (ctx) => ctx.effectIdReferences.filter((ref) => !ref.path.includes('params')), + has: (ctx, ref) => ctx.effectIds.has(ref.effectId), + message: (ref) => `Effect "${ref.effectId}" is referenced but not defined in interact.effects.`, + hint: 'Add an entry to interact.effects or fix the reference.', +}); diff --git a/packages/interact-validate/src/rules/referential/interactionHasEffectsOrSequences.ts b/packages/interact-validate/src/rules/referential/interactionHasEffectsOrSequences.ts new file mode 100644 index 00000000..3c1772e0 --- /dev/null +++ b/packages/interact-validate/src/rules/referential/interactionHasEffectsOrSequences.ts @@ -0,0 +1,16 @@ +import type { Rule } from '..'; + +export const interactionHasEffectsOrSequences: Rule = { + code: 'INTERACTION_EMPTY', + defaultSeverity: 'error', + run: (ctx) => + ctx.interactions + .filter(({ interaction }) => !interaction.effects?.length && !interaction.sequences?.length) + .map(({ path }) => ({ + code: 'INTERACTION_EMPTY', + severity: 'error' as const, + path, + message: 'Interaction has neither effects nor sequences.', + hint: 'Add at least one effect or sequence to the interaction.', + })), +}; diff --git a/packages/interact-validate/src/rules/referential/sequenceIdsExist.ts b/packages/interact-validate/src/rules/referential/sequenceIdsExist.ts new file mode 100644 index 00000000..69b88bb2 --- /dev/null +++ b/packages/interact-validate/src/rules/referential/sequenceIdsExist.ts @@ -0,0 +1,11 @@ +import { referenceRule } from '../_factory'; + +export const sequenceIdsExist = referenceRule({ + code: 'SEQUENCE_ID_NOT_FOUND', + severity: 'error', + refs: (ctx) => ctx.sequenceIdReferences, + has: (ctx, ref) => ctx.sequenceIds.has(ref.sequenceId), + message: (ref) => + `Sequence "${ref.sequenceId}" is referenced but not defined in interact.sequences.`, + hint: 'Add an entry to interact.sequences or fix the reference.', +}); diff --git a/packages/interact-validate/src/rules/semantic/numericBounds.ts b/packages/interact-validate/src/rules/semantic/numericBounds.ts new file mode 100644 index 00000000..3e4450dd --- /dev/null +++ b/packages/interact-validate/src/rules/semantic/numericBounds.ts @@ -0,0 +1,120 @@ +import type { Rule } from '..'; +import type { Path } from '../../context'; +import type { ValidationError } from '../../errors'; + +function checkNonNegative( + value: number | undefined, + field: string, + code: string, + basePath: Path, + errors: ValidationError[], +): void { + if (value !== undefined && value < 0) { + errors.push({ + code, + severity: 'error', + path: [...basePath, field], + message: `"${field}" must be ≥ 0, got ${value}.`, + }); + } +} + +function checkEffectRecord( + entry: Record, + path: Path, + errors: ValidationError[], +): void { + checkNonNegative( + entry['duration'] as number | undefined, + 'duration', + 'NEGATIVE_DURATION', + path, + errors, + ); + checkNonNegative(entry['delay'] as number | undefined, 'delay', 'NEGATIVE_DELAY', path, errors); + checkNonNegative( + entry['iterations'] as number | undefined, + 'iterations', + 'NEGATIVE_ITERATIONS', + path, + errors, + ); +} + +function checkSequenceRecord( + entry: Record, + path: Path, + errors: ValidationError[], +): void { + checkNonNegative( + entry['offset'] as number | undefined, + 'offset', + 'NEGATIVE_OFFSET', + path, + errors, + ); + checkNonNegative(entry['delay'] as number | undefined, 'delay', 'NEGATIVE_DELAY', path, errors); + if (Array.isArray(entry['effects'])) { + (entry['effects'] as Record[]).forEach((e, i) => + checkEffectRecord(e, [...path, 'effects', i], errors), + ); + } +} + +export const numericBounds: Rule = { + code: 'NUMERIC_BOUNDS', + defaultSeverity: 'error', + run: (ctx): ValidationError[] => { + const errors: ValidationError[] = []; + + // Top-level effect definitions + for (const [id, effect] of Object.entries(ctx.config.effects ?? {})) { + checkEffectRecord(effect as unknown as Record, ['effects', id], errors); + } + + // Top-level sequence definitions + for (const [id, seq] of Object.entries(ctx.config.sequences ?? {})) { + checkSequenceRecord(seq as unknown as Record, ['sequences', id], errors); + } + + // Per-interaction checks + ctx.config.interactions.forEach((interaction, i) => { + const base: Path = ['interactions', i]; + const inter = interaction as unknown as Record; + + // viewEnter / pageVisible threshold ∈ [0, 1] + if ( + (interaction.trigger === 'viewEnter' || interaction.trigger === 'pageVisible') && + inter['params'] + ) { + const threshold = (inter['params'] as Record)['threshold'] as + | number + | undefined; + if (threshold !== undefined && (threshold < 0 || threshold > 1)) { + errors.push({ + code: 'THRESHOLD_OUT_OF_RANGE', + severity: 'error', + path: [...base, 'params', 'threshold'], + message: `"threshold" must be between 0 and 1, got ${threshold}.`, + }); + } + } + + // Inline effects + if (Array.isArray(inter['effects'])) { + (inter['effects'] as Record[]).forEach((e, ei) => + checkEffectRecord(e, [...base, 'effects', ei], errors), + ); + } + + // Inline sequences (and their effects) + if (Array.isArray(inter['sequences'])) { + (inter['sequences'] as Record[]).forEach((s, si) => + checkSequenceRecord(s, [...base, 'sequences', si], errors), + ); + } + }); + + return errors; + }, +}; diff --git a/packages/interact-validate/src/rules/semantic/triggerEffectCompatible.ts b/packages/interact-validate/src/rules/semantic/triggerEffectCompatible.ts new file mode 100644 index 00000000..6d3c1628 --- /dev/null +++ b/packages/interact-validate/src/rules/semantic/triggerEffectCompatible.ts @@ -0,0 +1,57 @@ +import type { Rule } from '..'; +import type { ValidationError } from '../../errors'; +import { SCRUB_FIELDS, STATE_FIELDS, TIME_FIELDS } from '../../schema/effects'; + +// viewProgress and pointerMove are scrub triggers; all others are discrete (time-based). +const SCRUB_TRIGGERS = new Set(['viewProgress', 'pointerMove']); + +export const triggerEffectCompatible: Rule = { + code: 'TRIGGER_EFFECT_INCOMPATIBLE', + defaultSeverity: 'warning', + run: (ctx): ValidationError[] => { + const errors: ValidationError[] = []; + + for (const { trigger, effect, path } of ctx.triggerEffectTuples) { + const e = effect as Record; + const isScrub = SCRUB_TRIGGERS.has(trigger); + + if (isScrub) { + for (const field of TIME_FIELDS) { + if (e[field] !== undefined) { + errors.push({ + code: 'TRIGGER_EFFECT_INCOMPATIBLE', + severity: 'warning', + path: [...path, field], + message: `"${field}" is a time-effect field and is incompatible with the "${trigger}" scrub trigger.`, + hint: 'Use scrub fields (rangeStart, rangeEnd, etc.) for viewProgress and pointerMove triggers.', + }); + } + } + for (const field of STATE_FIELDS) { + if (e[field] !== undefined) { + errors.push({ + code: 'TRIGGER_EFFECT_INCOMPATIBLE', + severity: 'warning', + path: [...path, field], + message: `"${field}" is a state-effect field and is incompatible with the "${trigger}" scrub trigger.`, + }); + } + } + } else { + for (const field of SCRUB_FIELDS) { + if (e[field] !== undefined) { + errors.push({ + code: 'TRIGGER_EFFECT_INCOMPATIBLE', + severity: 'warning', + path: [...path, field], + message: `"${field}" is a scrub-effect field and is incompatible with the "${trigger}" trigger.`, + hint: 'Scrub fields (rangeStart, rangeEnd, etc.) are only valid on viewProgress and pointerMove triggers.', + }); + } + } + } + } + + return errors; + }, +}; diff --git a/packages/interact-validate/src/rules/semantic/uniqueDefinitionIds.ts b/packages/interact-validate/src/rules/semantic/uniqueDefinitionIds.ts new file mode 100644 index 00000000..c59d5545 --- /dev/null +++ b/packages/interact-validate/src/rules/semantic/uniqueDefinitionIds.ts @@ -0,0 +1,30 @@ +import type { Rule } from '..'; +import type { ValidationError } from '../../errors'; + +// Checks that keyframeEffect.name is unique across all effects. Object-key +// uniqueness for config.effects / config.sequences is guaranteed by JSON parsers. +export const uniqueDefinitionIds: Rule = { + code: 'DUPLICATE_KEYFRAME_NAME', + defaultSeverity: 'warning', + run: (ctx): ValidationError[] => { + const errors: ValidationError[] = []; + const seen = new Map(); + + for (const { name, path } of ctx.keyframeNames) { + const first = seen.get(name); + if (first !== undefined) { + errors.push({ + code: 'DUPLICATE_KEYFRAME_NAME', + severity: 'warning', + path, + message: `Keyframe name "${name}" is already used at [${first.join(', ')}].`, + hint: 'Keyframe names must be unique across all effects.', + }); + } else { + seen.set(name, path); + } + } + + return errors; + }, +}; diff --git a/packages/interact-validate/src/rules/semantic/unusedDefinitions.ts b/packages/interact-validate/src/rules/semantic/unusedDefinitions.ts new file mode 100644 index 00000000..787e3d7b --- /dev/null +++ b/packages/interact-validate/src/rules/semantic/unusedDefinitions.ts @@ -0,0 +1,54 @@ +import type { Rule } from '..'; +import type { ValidationError } from '../../errors'; + +// Mirrors the referential rules in reverse: definitions that exist but are never +// referenced produce warnings so dead config can be cleaned up. +export const unusedDefinitions: Rule = { + code: 'UNUSED_DEFINITION', + defaultSeverity: 'warning', + run: (ctx): ValidationError[] => { + const errors: ValidationError[] = []; + + const referencedEffectIds = new Set(ctx.effectIdReferences.map((r) => r.effectId)); + const referencedSequenceIds = new Set(ctx.sequenceIdReferences.map((r) => r.sequenceId)); + const referencedConditionIds = new Set(ctx.conditionReferences.map((r) => r.conditionId)); + + for (const id of ctx.effectIds) { + if (!referencedEffectIds.has(id)) { + errors.push({ + code: 'UNUSED_EFFECT', + severity: 'warning', + path: ['effects', id], + message: `Effect "${id}" is defined but never referenced by any interaction.`, + hint: 'Remove the unused effect or reference it from an interaction.', + }); + } + } + + for (const id of ctx.sequenceIds) { + if (!referencedSequenceIds.has(id)) { + errors.push({ + code: 'UNUSED_SEQUENCE', + severity: 'warning', + path: ['sequences', id], + message: `Sequence "${id}" is defined but never referenced by any interaction.`, + hint: 'Remove the unused sequence or reference it from an interaction.', + }); + } + } + + for (const id of ctx.conditionIds) { + if (!referencedConditionIds.has(id)) { + errors.push({ + code: 'UNUSED_CONDITION', + severity: 'warning', + path: ['conditions', id], + message: `Condition "${id}" is defined but never referenced.`, + hint: 'Remove the unused condition or reference it from an interaction or effect.', + }); + } + } + + return errors; + }, +}; diff --git a/packages/interact-validate/src/schema/effects.ts b/packages/interact-validate/src/schema/effects.ts new file mode 100644 index 00000000..229271ed --- /dev/null +++ b/packages/interact-validate/src/schema/effects.ts @@ -0,0 +1,187 @@ +import { z } from 'zod'; +import { Keyframe, RangeOffset } from './primitives'; + +const TriggerType = z.enum(['once', 'repeat', 'alternate', 'state']); + +export const NamedEffect = z.object({ type: z.string().min(1) }).catchall(z.unknown()); + +const KeyframeEffectInline = z + .object({ + name: z.string().min(1), + keyframes: z.array(Keyframe).min(1), + }) + .strict(); + +export const EffectBase = z.object({ + key: z.string().optional(), + effectId: z.string().optional(), + selector: z.string().optional(), + listContainer: z.string().optional(), + listItemSelector: z.string().optional(), + conditions: z.array(z.string()).optional(), +}); + +export const SerializableEffectRef = EffectBase.extend({ + effectId: z.string().min(1), +}).strict(); + +const TimeEffectFields = { + duration: z.number().optional(), + easing: z.string().optional(), + iterations: z.number().optional(), + alternate: z.boolean().optional(), + reversed: z.boolean().optional(), + delay: z.number().optional(), + fill: z.enum(['none', 'forwards', 'backwards', 'both']).optional(), + composite: z.enum(['replace', 'add', 'accumulate']).optional(), + triggerType: TriggerType.optional(), +}; + +export const SCRUB_FIELDS = [ + 'rangeStart', + 'rangeEnd', + 'centeredToTarget', + 'transitionDuration', + 'transitionDelay', + 'transitionEasing', +] as const; + +export const STATE_FIELDS = ['stateAction', 'transition', 'transitionProperties'] as const; + +export const TIME_FIELDS = [ + 'duration', + 'easing', + 'iterations', + 'alternate', + 'reversed', + 'delay', + 'fill', + 'composite', +] as const; + +const ScrubEffectFields = { + rangeStart: RangeOffset.optional(), + rangeEnd: RangeOffset.optional(), + centeredToTarget: z.boolean().optional(), + transitionDuration: z.number().optional(), + transitionDelay: z.number().optional(), + transitionEasing: z.enum(['linear', 'hardBackOut', 'easeOut', 'elastic', 'bounce']).optional(), +}; + +const StateEffectFields = { + stateAction: z.enum(['add', 'remove', 'toggle', 'clear']).optional(), + transition: z + .object({ + duration: z.number().optional(), + delay: z.number().optional(), + easing: z.string().optional(), + styleProperties: z.array(z.object({ name: z.string(), value: z.string() })), + }) + .optional(), + transitionProperties: z + .array( + z.object({ + name: z.string(), + value: z.string(), + duration: z.number().optional(), + delay: z.number().optional(), + easing: z.string().optional(), + }), + ) + .optional(), +}; + +const SourceFields = { + namedEffect: NamedEffect.optional(), + keyframeEffect: KeyframeEffectInline.optional(), + customEffect: z + .custom<(...args: unknown[]) => unknown>((v) => typeof v === 'function') + .optional(), +}; + +export const SerializableEffectSource = z + .object(SourceFields) + .strict() + .refine( + (v) => (v.namedEffect ? 1 : 0) + (v.keyframeEffect ? 1 : 0) + (v.customEffect ? 1 : 0) === 1, + { + message: + 'Effect source must define exactly one of namedEffect, keyframeEffect, or customEffect', + }, + ); + +const EffectShape = EffectBase.extend({ + ...SourceFields, + ...TimeEffectFields, + ...ScrubEffectFields, + ...StateEffectFields, +}).strict(); + +export const SerializableEffect = EffectShape.superRefine((v, ctx) => { + const hasNamed = v.namedEffect !== undefined; + const hasKeyframe = v.keyframeEffect !== undefined; + const hasCustom = v.customEffect !== undefined; + const sourceCount = (hasNamed ? 1 : 0) + (hasKeyframe ? 1 : 0) + (hasCustom ? 1 : 0); + const hasSource = sourceCount > 0; + const hasState = + v.stateAction !== undefined || + v.transition !== undefined || + v.transitionProperties !== undefined; + + if (sourceCount > 1) { + ctx.addIssue({ + code: 'custom', + message: 'Effect must define exactly one of namedEffect, keyframeEffect, or customEffect.', + path: [], + }); + } + if (hasSource && hasState) { + ctx.addIssue({ + code: 'custom', + message: + 'Effect source fields (namedEffect, keyframeEffect, or customEffect) cannot be combined with state effect fields.', + path: [], + }); + } + if (!hasSource && !hasState) { + ctx.addIssue({ + code: 'custom', + message: + 'Effect must define an effect source (namedEffect, keyframeEffect, or customEffect) or be a state effect (stateAction / transition / transitionProperties).', + path: [], + }); + } +}); + +export const SerializableTimeEffect = SerializableEffect.superRefine((v, ctx) => { + if ( + v.namedEffect === undefined && + v.keyframeEffect === undefined && + v.customEffect === undefined + ) { + ctx.addIssue({ + code: 'custom', + message: + 'Time effect must define an effect source (namedEffect, keyframeEffect, or customEffect).', + path: [], + }); + } + for (const field of SCRUB_FIELDS) { + if ((v as Record)[field] !== undefined) { + ctx.addIssue({ + code: 'custom', + message: `"${field}" is a scrub-effect field and is not allowed on a time effect.`, + path: [field], + }); + } + } + for (const field of STATE_FIELDS) { + if ((v as Record)[field] !== undefined) { + ctx.addIssue({ + code: 'custom', + message: `"${field}" is a state-effect field and is not allowed on a time effect.`, + path: [field], + }); + } + } +}); diff --git a/packages/interact-validate/src/schema/index.ts b/packages/interact-validate/src/schema/index.ts new file mode 100644 index 00000000..928b30bc --- /dev/null +++ b/packages/interact-validate/src/schema/index.ts @@ -0,0 +1,39 @@ +// Zod schema values — for validation and host-project schema composition +export { + InteractConfigSchema, + Interaction, + TriggerType, + ViewEnterParams, + PointerMoveParams, + AnimationEndParams, + TriggerParams, +} from './interactions'; + +export { + SerializableEffect, + SerializableEffectRef, + SerializableEffectSource, + SerializableTimeEffect, + EffectBase, + NamedEffect, + SCRUB_FIELDS, + STATE_FIELDS, + TIME_FIELDS, +} from './effects'; + +export { SerializableSequenceConfig, SerializableSequenceConfigRef } from './sequences'; + +export { Keyframe, LengthPercentage, RangeOffset, Condition, MediaCondition } from './primitives'; + +// Canonical types — single source of truth, no z.infer<> re-derivation. +export type { + InteractConfig, + Condition as ConditionDef, + SequenceOptionsConfig, + SequenceConfig, + SequenceConfigRef, + Interaction as InteractionDef, + InteractionTrigger, +} from '@wix/interact'; + +export type { Effect, EffectRef } from '@wix/interact'; diff --git a/packages/interact-validate/src/schema/interactions.ts b/packages/interact-validate/src/schema/interactions.ts new file mode 100644 index 00000000..fab7d75f --- /dev/null +++ b/packages/interact-validate/src/schema/interactions.ts @@ -0,0 +1,139 @@ +import { z } from 'zod'; +import { Condition } from './primitives'; +import { SerializableEffect, SerializableEffectRef } from './effects'; +import { SerializableSequenceConfig, SerializableSequenceConfigRef } from './sequences'; + +export const TriggerType = z.enum([ + 'hover', + 'click', + 'interest', + 'activate', + 'viewEnter', + 'viewProgress', + 'pointerMove', + 'animationEnd', + 'pageVisible', +]); + +export const ViewEnterParams = z + .object({ + threshold: z.number().optional(), + inset: z.string().optional(), + useSafeViewEnter: z.boolean().optional(), + }) + .strict(); + +export const PointerMoveParams = z + .object({ + hitArea: z.enum(['root', 'self']).optional(), + axis: z.enum(['x', 'y']).optional(), + }) + .strict(); + +export const AnimationEndParams = z + .object({ + effectId: z.string().min(1), + }) + .strict(); + +export const TriggerParams = z.union([ViewEnterParams, PointerMoveParams, AnimationEndParams]); + +const InteractionBase = { + key: z.string().min(1), + selector: z.string().optional(), + listContainer: z.string().optional(), + listItemSelector: z.string().optional(), + conditions: z.array(z.string()).optional(), + effects: z.array(z.union([SerializableEffect, SerializableEffectRef])).optional(), + sequences: z + .array(z.union([SerializableSequenceConfig, SerializableSequenceConfigRef])) + .optional(), +}; + +const ViewEnterInteraction = z + .object({ + ...InteractionBase, + trigger: z.literal('viewEnter'), + params: ViewEnterParams.optional(), + }) + .strict(); + +const PageVisibleInteraction = z + .object({ + ...InteractionBase, + trigger: z.literal('pageVisible'), + params: ViewEnterParams.optional(), + }) + .strict(); + +const PointerMoveInteraction = z + .object({ + ...InteractionBase, + trigger: z.literal('pointerMove'), + params: PointerMoveParams.optional(), + }) + .strict(); + +const AnimationEndInteraction = z + .object({ + ...InteractionBase, + trigger: z.literal('animationEnd'), + params: AnimationEndParams, + }) + .strict(); + +const HoverInteraction = z + .object({ + ...InteractionBase, + trigger: z.literal('hover'), + }) + .strict(); + +const ClickInteraction = z + .object({ + ...InteractionBase, + trigger: z.literal('click'), + }) + .strict(); + +const InterestInteraction = z + .object({ + ...InteractionBase, + trigger: z.literal('interest'), + }) + .strict(); + +const ActivateInteraction = z + .object({ + ...InteractionBase, + trigger: z.literal('activate'), + }) + .strict(); + +const ViewProgressInteraction = z + .object({ + ...InteractionBase, + trigger: z.literal('viewProgress'), + }) + .strict(); + +export const Interaction = z.discriminatedUnion('trigger', [ + ViewEnterInteraction, + PageVisibleInteraction, + PointerMoveInteraction, + AnimationEndInteraction, + HoverInteraction, + ClickInteraction, + InterestInteraction, + ActivateInteraction, + ViewProgressInteraction, +]); + +export const InteractConfigSchema = z + .object({ + effects: z.record(z.string().min(1), SerializableEffect).optional(), + sequences: z.record(z.string().min(1), SerializableSequenceConfig).optional(), + conditions: z.record(z.string().min(1), Condition).optional(), + interactions: z.array(Interaction), + }) + .strict(); diff --git a/packages/interact-validate/src/schema/primitives.ts b/packages/interact-validate/src/schema/primitives.ts new file mode 100644 index 00000000..bb153ade --- /dev/null +++ b/packages/interact-validate/src/schema/primitives.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; + +export const Keyframe = z.record(z.string(), z.union([z.string(), z.number()])); + +export const LengthPercentage = z.union([ + z.object({ + value: z.number(), + unit: z.enum(['px', 'em', 'rem', 'vh', 'vw', 'vmin', 'vmax']), + }), + z.object({ + value: z.number(), + unit: z.literal('percentage'), + }), +]); + +export const RangeOffset = z + .object({ + name: z + .enum(['entry', 'exit', 'contain', 'cover', 'entry-crossing', 'exit-crossing']) + .optional(), + offset: LengthPercentage.optional(), + }) + .strict(); + +export const Condition = z + .object({ + type: z.enum(['media', 'container', 'selector']), + predicate: z.string().min(1), + }) + .strict(); + +export const MediaCondition = z + .object({ + mediaQuery: z.string().min(1), + label: z.string().optional(), + }) + .strict(); diff --git a/packages/interact-validate/src/schema/sequences.ts b/packages/interact-validate/src/schema/sequences.ts new file mode 100644 index 00000000..00f4ada5 --- /dev/null +++ b/packages/interact-validate/src/schema/sequences.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; +import { SerializableEffectRef, SerializableTimeEffect } from './effects'; + +const TriggerType = z.enum(['once', 'repeat', 'alternate', 'state']); + +export const SerializableSequenceConfig = z.object({ + effects: z.array(z.union([SerializableTimeEffect, SerializableEffectRef])), + delay: z.number().optional(), + offset: z.number().optional(), + offsetEasing: z + .union([z.string(), z.custom<(...args: unknown[]) => unknown>((v) => typeof v === 'function')]) + .optional(), + triggerType: TriggerType.optional(), + sequenceId: z.string().optional(), + conditions: z.array(z.string()).optional(), +}); + +export const SerializableSequenceConfigRef = z + .object({ + sequenceId: z.string().min(1), + delay: z.number().optional(), + offset: z.number().optional(), + offsetEasing: z + .union([ + z.string(), + z.custom<(...args: unknown[]) => unknown>((v) => typeof v === 'function'), + ]) + .optional(), + triggerType: TriggerType.optional(), + conditions: z.array(z.string()).optional(), + }) + .strict(); diff --git a/packages/interact-validate/src/semantic.ts b/packages/interact-validate/src/semantic.ts new file mode 100644 index 00000000..7d3b06a4 --- /dev/null +++ b/packages/interact-validate/src/semantic.ts @@ -0,0 +1,19 @@ +import { RULES } from './rules'; +import type { ValidationContext } from './context'; +import type { Severity, ValidationError } from './errors'; + +export function validateSemantic( + ctx: ValidationContext, + severityOverrides: Record = {}, +): ValidationError[] { + const out: ValidationError[] = []; + for (const rule of RULES) { + const override = severityOverrides[rule.code]; + if (override === 'off') continue; + const errs = rule.run(ctx); + if (!errs.length) continue; + const severity = override ?? rule.defaultSeverity; + for (const e of errs) out.push({ ...e, severity }); + } + return out; +} diff --git a/packages/interact-validate/src/structural.ts b/packages/interact-validate/src/structural.ts new file mode 100644 index 00000000..e3b4d438 --- /dev/null +++ b/packages/interact-validate/src/structural.ts @@ -0,0 +1,39 @@ +import type { ZodIssue } from 'zod'; +import { InteractConfigSchema } from './schema'; +import type { InteractConfig } from '@wix/interact'; +import type { ValidationError } from './errors'; + +function mapZodCode(issue: ZodIssue): string { + switch (issue.code) { + case 'invalid_type': + return 'SCHEMA_INVALID_TYPE'; + case 'unrecognized_keys': + return 'SCHEMA_UNRECOGNIZED_KEYS'; + case 'invalid_union': + return 'SCHEMA_INVALID_UNION'; + case 'invalid_value': + return 'SCHEMA_INVALID_LITERAL'; + case 'too_small': + return 'SCHEMA_TOO_SMALL'; + default: + return 'SCHEMA_INVALID'; + } +} + +export function validateStructural(input: unknown): { + ok: boolean; + parsed?: InteractConfig; + errors: ValidationError[]; +} { + const result = InteractConfigSchema.safeParse(input); + if (result.success) { + return { ok: true, parsed: result.data as unknown as InteractConfig, errors: [] }; + } + const errors: ValidationError[] = result.error.issues.map((issue) => ({ + code: mapZodCode(issue), + message: issue.message, + path: [...issue.path] as (string | number)[], + severity: 'error', + })); + return { ok: false, errors }; +} diff --git a/packages/interact-validate/test/rules/animationEndEffectExists.spec.ts b/packages/interact-validate/test/rules/animationEndEffectExists.spec.ts new file mode 100644 index 00000000..6b6b9a37 --- /dev/null +++ b/packages/interact-validate/test/rules/animationEndEffectExists.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { validateInteractConfig } from '../../src'; + +describe('animationEndEffectExists — ANIMATION_END_EFFECT_NOT_FOUND', () => { + it('emits no errors when the animationEnd params.effectId resolves to a defined effect', () => { + const result = validateInteractConfig({ + effects: { fade: { namedEffect: { type: 'FadeIn' } } }, + interactions: [ + { + key: 'el', + trigger: 'animationEnd', + params: { effectId: 'fade' }, + effects: [{ namedEffect: { type: 'SlideIn' } }], + }, + ], + }); + expect(result.errors.filter((e) => e.code === 'ANIMATION_END_EFFECT_NOT_FOUND')).toHaveLength( + 0, + ); + }); + + it('emits ANIMATION_END_EFFECT_NOT_FOUND when params.effectId is not defined', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'animationEnd', + params: { effectId: 'ghost' }, + effects: [{ namedEffect: { type: 'SlideIn' } }], + }, + ], + }); + const errs = result.errors.filter((e) => e.code === 'ANIMATION_END_EFFECT_NOT_FOUND'); + expect(errs).toHaveLength(1); + expect(errs[0].severity).toBe('error'); + expect(errs[0].path).toContain('params'); + }); +}); diff --git a/packages/interact-validate/test/rules/conditionsExist.spec.ts b/packages/interact-validate/test/rules/conditionsExist.spec.ts new file mode 100644 index 00000000..3faaf055 --- /dev/null +++ b/packages/interact-validate/test/rules/conditionsExist.spec.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { validateInteractConfig } from '../../src'; + +describe('conditionsExist — CONDITION_NOT_FOUND', () => { + it('emits no errors when all condition references resolve to defined conditions', () => { + const result = validateInteractConfig({ + conditions: { mq: { type: 'media', predicate: '(min-width: 768px)' } }, + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + conditions: ['mq'], + effects: [{ namedEffect: { type: 'FadeIn' } }], + }, + ], + }); + expect(result.errors.filter((e) => e.code === 'CONDITION_NOT_FOUND')).toHaveLength(0); + }); + + it('emits CONDITION_NOT_FOUND when a condition reference has no matching definition', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + conditions: ['ghost'], + effects: [{ namedEffect: { type: 'FadeIn' } }], + }, + ], + }); + const errs = result.errors.filter((e) => e.code === 'CONDITION_NOT_FOUND'); + expect(errs).toHaveLength(1); + expect(errs[0].severity).toBe('error'); + expect(errs[0].path).toContain('conditions'); + }); + + it('emits CONDITION_NOT_FOUND for a missing condition referenced on an effect', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [{ namedEffect: { type: 'FadeIn' }, conditions: ['noSuchCondition'] }], + }, + ], + }); + expect(result.errors.some((e) => e.code === 'CONDITION_NOT_FOUND')).toBe(true); + }); +}); diff --git a/packages/interact-validate/test/rules/effectIdsExist.spec.ts b/packages/interact-validate/test/rules/effectIdsExist.spec.ts new file mode 100644 index 00000000..86de1d8c --- /dev/null +++ b/packages/interact-validate/test/rules/effectIdsExist.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { validateInteractConfig } from '../../src'; + +describe('effectIdsExist — EFFECT_ID_NOT_FOUND', () => { + it('emits no errors when an effectId reference resolves to a defined effect', () => { + const result = validateInteractConfig({ + effects: { fade: { namedEffect: { type: 'FadeIn' } } }, + interactions: [{ key: 'el', trigger: 'viewEnter', effects: [{ effectId: 'fade' }] }], + }); + expect(result.errors.filter((e) => e.code === 'EFFECT_ID_NOT_FOUND')).toHaveLength(0); + }); + + it('emits EFFECT_ID_NOT_FOUND when an effectId reference has no matching definition', () => { + const result = validateInteractConfig({ + interactions: [{ key: 'el', trigger: 'viewEnter', effects: [{ effectId: 'missing' }] }], + }); + const errs = result.errors.filter((e) => e.code === 'EFFECT_ID_NOT_FOUND'); + expect(errs).toHaveLength(1); + expect(errs[0].severity).toBe('error'); + expect(errs[0].path).toContain('effectId'); + }); + + it('emits EFFECT_ID_NOT_FOUND for an effectId inside a sequence', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + sequences: [{ effects: [{ effectId: 'ghost' }] }], + }, + ], + }); + expect(result.errors.some((e) => e.code === 'EFFECT_ID_NOT_FOUND')).toBe(true); + }); +}); diff --git a/packages/interact-validate/test/rules/interactionHasEffectsOrSequences.spec.ts b/packages/interact-validate/test/rules/interactionHasEffectsOrSequences.spec.ts new file mode 100644 index 00000000..9c028e3e --- /dev/null +++ b/packages/interact-validate/test/rules/interactionHasEffectsOrSequences.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { validateInteractConfig } from '../../src'; + +describe('interactionHasEffectsOrSequences — INTERACTION_EMPTY', () => { + it('emits no errors when an interaction has at least one effect', () => { + const result = validateInteractConfig({ + interactions: [ + { key: 'el', trigger: 'viewEnter', effects: [{ namedEffect: { type: 'FadeIn' } }] }, + ], + }); + expect(result.errors.filter((e) => e.code === 'INTERACTION_EMPTY')).toHaveLength(0); + }); + + it('emits no errors when an interaction has at least one sequence', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + sequences: [{ effects: [{ namedEffect: { type: 'FadeIn' } }] }], + }, + ], + }); + expect(result.errors.filter((e) => e.code === 'INTERACTION_EMPTY')).toHaveLength(0); + }); + + it('emits INTERACTION_EMPTY when an interaction has neither effects nor sequences', () => { + const result = validateInteractConfig({ + interactions: [{ key: 'el', trigger: 'viewEnter' }], + }); + const errs = result.errors.filter((e) => e.code === 'INTERACTION_EMPTY'); + expect(errs).toHaveLength(1); + expect(errs[0].severity).toBe('error'); + expect(errs[0].path).toEqual(['interactions', 0]); + }); + + it('emits one INTERACTION_EMPTY per offending interaction', () => { + const result = validateInteractConfig({ + interactions: [ + { key: 'a', trigger: 'viewEnter' }, + { key: 'b', trigger: 'click', effects: [{ namedEffect: { type: 'FadeIn' } }] }, + { key: 'c', trigger: 'hover' }, + ], + }); + const errs = result.errors.filter((e) => e.code === 'INTERACTION_EMPTY'); + expect(errs).toHaveLength(2); + }); +}); diff --git a/packages/interact-validate/test/rules/numericBounds.spec.ts b/packages/interact-validate/test/rules/numericBounds.spec.ts new file mode 100644 index 00000000..83a338f6 --- /dev/null +++ b/packages/interact-validate/test/rules/numericBounds.spec.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest'; +import { validateInteractConfig } from '../../src'; + +describe('numericBounds', () => { + describe('valid configs', () => { + it('emits no numeric errors for non-negative effect fields', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [{ namedEffect: { type: 'FadeIn' }, duration: 400, delay: 0, iterations: 1 }], + }, + ], + }); + const numericCodes = [ + 'NEGATIVE_DURATION', + 'NEGATIVE_DELAY', + 'NEGATIVE_ITERATIONS', + 'THRESHOLD_OUT_OF_RANGE', + 'NEGATIVE_OFFSET', + ]; + expect(result.errors.filter((e) => numericCodes.includes(e.code))).toHaveLength(0); + }); + + it('emits no errors for a top-level effect with valid fields', () => { + const result = validateInteractConfig({ + effects: { fade: { namedEffect: { type: 'FadeIn' }, duration: 600, delay: 50 } }, + interactions: [{ key: 'el', trigger: 'viewEnter', effects: [{ effectId: 'fade' }] }], + }); + expect(result.errors.filter((e) => e.code === 'NEGATIVE_DURATION')).toHaveLength(0); + }); + }); + + describe('NEGATIVE_DURATION', () => { + it('emits NEGATIVE_DURATION for a negative duration on an inline effect', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [{ namedEffect: { type: 'FadeIn' }, duration: -1 }], + }, + ], + }); + const err = result.errors.find((e) => e.code === 'NEGATIVE_DURATION'); + expect(err).toBeDefined(); + expect(err?.severity).toBe('error'); + expect(err?.path).toContain('duration'); + }); + + it('emits NEGATIVE_DURATION for a negative duration in a top-level effect definition', () => { + const result = validateInteractConfig({ + effects: { fade: { namedEffect: { type: 'FadeIn' }, duration: -100 } }, + interactions: [{ key: 'el', trigger: 'viewEnter', effects: [{ effectId: 'fade' }] }], + }); + expect(result.errors.some((e) => e.code === 'NEGATIVE_DURATION')).toBe(true); + }); + }); + + describe('NEGATIVE_DELAY', () => { + it('emits NEGATIVE_DELAY for a negative delay on an inline effect', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [{ namedEffect: { type: 'FadeIn' }, delay: -50 }], + }, + ], + }); + const err = result.errors.find((e) => e.code === 'NEGATIVE_DELAY'); + expect(err).toBeDefined(); + expect(err?.path).toContain('delay'); + }); + + it('emits NEGATIVE_DELAY for a negative delay in a top-level sequence definition', () => { + const result = validateInteractConfig({ + sequences: { seq: { effects: [{ namedEffect: { type: 'FadeIn' } }], delay: -10 } }, + interactions: [{ key: 'el', trigger: 'viewEnter', sequences: [{ sequenceId: 'seq' }] }], + }); + expect(result.errors.some((e) => e.code === 'NEGATIVE_DELAY')).toBe(true); + }); + }); + + describe('NEGATIVE_ITERATIONS', () => { + it('emits NEGATIVE_ITERATIONS for a negative iterations value', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [{ namedEffect: { type: 'FadeIn' }, iterations: -2 }], + }, + ], + }); + const err = result.errors.find((e) => e.code === 'NEGATIVE_ITERATIONS'); + expect(err).toBeDefined(); + expect(err?.path).toContain('iterations'); + }); + }); + + describe('NEGATIVE_OFFSET', () => { + it('emits NEGATIVE_OFFSET for a negative offset on a top-level sequence', () => { + const result = validateInteractConfig({ + sequences: { seq: { effects: [{ namedEffect: { type: 'FadeIn' } }], offset: -5 } }, + interactions: [{ key: 'el', trigger: 'viewEnter', sequences: [{ sequenceId: 'seq' }] }], + }); + const err = result.errors.find((e) => e.code === 'NEGATIVE_OFFSET'); + expect(err).toBeDefined(); + expect(err?.path).toContain('offset'); + }); + }); + + describe('THRESHOLD_OUT_OF_RANGE', () => { + it('emits THRESHOLD_OUT_OF_RANGE for threshold > 1 on viewEnter', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + params: { threshold: 1.5 }, + effects: [{ namedEffect: { type: 'FadeIn' } }], + }, + ], + }); + const err = result.errors.find((e) => e.code === 'THRESHOLD_OUT_OF_RANGE'); + expect(err).toBeDefined(); + expect(err?.severity).toBe('error'); + expect(err?.path).toContain('threshold'); + }); + + it('emits THRESHOLD_OUT_OF_RANGE for threshold < 0 on pageVisible', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'pageVisible', + params: { threshold: -0.1 }, + effects: [{ namedEffect: { type: 'FadeIn' } }], + }, + ], + }); + expect(result.errors.some((e) => e.code === 'THRESHOLD_OUT_OF_RANGE')).toBe(true); + }); + + it('emits no errors for threshold = 0 and threshold = 1 (boundary values)', () => { + for (const threshold of [0, 1]) { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + params: { threshold }, + effects: [{ namedEffect: { type: 'FadeIn' } }], + }, + ], + }); + expect(result.errors.filter((e) => e.code === 'THRESHOLD_OUT_OF_RANGE')).toHaveLength(0); + } + }); + }); +}); diff --git a/packages/interact-validate/test/rules/sequenceIdsExist.spec.ts b/packages/interact-validate/test/rules/sequenceIdsExist.spec.ts new file mode 100644 index 00000000..faafc86d --- /dev/null +++ b/packages/interact-validate/test/rules/sequenceIdsExist.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { validateInteractConfig } from '../../src'; + +describe('sequenceIdsExist — SEQUENCE_ID_NOT_FOUND', () => { + it('emits no errors when a sequenceId reference resolves to a defined sequence', () => { + const result = validateInteractConfig({ + sequences: { seq: { effects: [{ namedEffect: { type: 'FadeIn' } }] } }, + interactions: [{ key: 'el', trigger: 'viewEnter', sequences: [{ sequenceId: 'seq' }] }], + }); + expect(result.errors.filter((e) => e.code === 'SEQUENCE_ID_NOT_FOUND')).toHaveLength(0); + }); + + it('emits SEQUENCE_ID_NOT_FOUND when a sequenceId reference has no matching definition', () => { + const result = validateInteractConfig({ + interactions: [{ key: 'el', trigger: 'viewEnter', sequences: [{ sequenceId: 'missing' }] }], + }); + const errs = result.errors.filter((e) => e.code === 'SEQUENCE_ID_NOT_FOUND'); + expect(errs).toHaveLength(1); + expect(errs[0].severity).toBe('error'); + expect(errs[0].path).toContain('sequenceId'); + }); +}); diff --git a/packages/interact-validate/test/rules/triggerEffectCompatible.spec.ts b/packages/interact-validate/test/rules/triggerEffectCompatible.spec.ts new file mode 100644 index 00000000..ddc74046 --- /dev/null +++ b/packages/interact-validate/test/rules/triggerEffectCompatible.spec.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; +import { validateInteractConfig } from '../../src'; + +describe('triggerEffectCompatible — TRIGGER_EFFECT_INCOMPATIBLE', () => { + describe('valid combinations', () => { + it('emits no errors for a time effect on a discrete trigger', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [{ namedEffect: { type: 'FadeIn' }, duration: 400, delay: 100 }], + }, + ], + }); + expect(result.errors.filter((e) => e.code === 'TRIGGER_EFFECT_INCOMPATIBLE')).toHaveLength(0); + }); + + it('emits no errors for a scrub effect on a viewProgress trigger', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewProgress', + effects: [{ namedEffect: { type: 'FadeIn' }, rangeStart: { name: 'entry' } }], + }, + ], + }); + expect(result.errors.filter((e) => e.code === 'TRIGGER_EFFECT_INCOMPATIBLE')).toHaveLength(0); + }); + + it('emits no errors for a scrub effect on a pointerMove trigger', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'pointerMove', + effects: [{ namedEffect: { type: 'ParallaxMove' }, rangeEnd: { name: 'exit' } }], + }, + ], + }); + expect(result.errors.filter((e) => e.code === 'TRIGGER_EFFECT_INCOMPATIBLE')).toHaveLength(0); + }); + }); + + describe('time fields on scrub trigger', () => { + it('emits TRIGGER_EFFECT_INCOMPATIBLE for duration on viewProgress', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewProgress', + effects: [{ namedEffect: { type: 'FadeIn' }, duration: 500 }], + }, + ], + }); + const errs = result.errors.filter((e) => e.code === 'TRIGGER_EFFECT_INCOMPATIBLE'); + expect(errs.length).toBeGreaterThan(0); + expect(errs[0].severity).toBe('warning'); + expect(errs[0].path).toContain('duration'); + }); + + it('emits TRIGGER_EFFECT_INCOMPATIBLE for delay on pointerMove', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'pointerMove', + effects: [{ namedEffect: { type: 'FadeIn' }, delay: 200 }], + }, + ], + }); + expect( + result.errors.some( + (e) => e.code === 'TRIGGER_EFFECT_INCOMPATIBLE' && e.path.includes('delay'), + ), + ).toBe(true); + }); + }); + + describe('state fields on scrub trigger', () => { + it('emits TRIGGER_EFFECT_INCOMPATIBLE for stateAction on viewProgress', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewProgress', + effects: [{ stateAction: 'add' }], + }, + ], + }); + expect( + result.errors.some( + (e) => e.code === 'TRIGGER_EFFECT_INCOMPATIBLE' && e.path.includes('stateAction'), + ), + ).toBe(true); + }); + }); + + describe('scrub fields on discrete trigger', () => { + it('emits TRIGGER_EFFECT_INCOMPATIBLE for rangeStart on viewEnter', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [{ namedEffect: { type: 'FadeIn' }, rangeStart: { name: 'entry' } }], + }, + ], + }); + const errs = result.errors.filter((e) => e.code === 'TRIGGER_EFFECT_INCOMPATIBLE'); + expect(errs.length).toBeGreaterThan(0); + expect(errs[0].path).toContain('rangeStart'); + }); + + it('emits TRIGGER_EFFECT_INCOMPATIBLE for transitionDuration on click', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'click', + effects: [{ namedEffect: { type: 'FadeIn' }, transitionDuration: 300 }], + }, + ], + }); + expect( + result.errors.some( + (e) => e.code === 'TRIGGER_EFFECT_INCOMPATIBLE' && e.path.includes('transitionDuration'), + ), + ).toBe(true); + }); + }); + + it('does not flag effectId references (only inline effects are checked)', () => { + const result = validateInteractConfig({ + effects: { fade: { namedEffect: { type: 'FadeIn' }, duration: 300 } }, + interactions: [{ key: 'el', trigger: 'viewProgress', effects: [{ effectId: 'fade' }] }], + }); + // effectId refs are not in triggerEffectTuples, so no TRIGGER_EFFECT_INCOMPATIBLE + expect(result.errors.filter((e) => e.code === 'TRIGGER_EFFECT_INCOMPATIBLE')).toHaveLength(0); + }); +}); diff --git a/packages/interact-validate/test/rules/uniqueDefinitionIds.spec.ts b/packages/interact-validate/test/rules/uniqueDefinitionIds.spec.ts new file mode 100644 index 00000000..b2cded7c --- /dev/null +++ b/packages/interact-validate/test/rules/uniqueDefinitionIds.spec.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { validateInteractConfig } from '../../src'; + +const KEYFRAME_EFFECT_A = { name: 'anim1', keyframes: [{ opacity: '0' }, { opacity: '1' }] }; +const KEYFRAME_EFFECT_B = { name: 'anim2', keyframes: [{ opacity: '0' }, { opacity: '1' }] }; + +describe('uniqueDefinitionIds — DUPLICATE_KEYFRAME_NAME', () => { + it('emits no errors when all keyframe names are unique', () => { + const result = validateInteractConfig({ + effects: { + a: { keyframeEffect: KEYFRAME_EFFECT_A }, + b: { keyframeEffect: KEYFRAME_EFFECT_B }, + }, + interactions: [ + { key: 'el', trigger: 'viewEnter', effects: [{ effectId: 'a' }, { effectId: 'b' }] }, + ], + }); + expect(result.errors.filter((e) => e.code === 'DUPLICATE_KEYFRAME_NAME')).toHaveLength(0); + }); + + it('emits no errors when effects use different source types', () => { + const result = validateInteractConfig({ + effects: { + named: { namedEffect: { type: 'FadeIn' } }, + keyframed: { keyframeEffect: KEYFRAME_EFFECT_A }, + }, + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [{ effectId: 'named' }, { effectId: 'keyframed' }], + }, + ], + }); + expect(result.errors.filter((e) => e.code === 'DUPLICATE_KEYFRAME_NAME')).toHaveLength(0); + }); + + it('emits DUPLICATE_KEYFRAME_NAME when two top-level effects share a keyframe name', () => { + const result = validateInteractConfig({ + effects: { + a: { keyframeEffect: { name: 'shared', keyframes: [{ opacity: '0' }] } }, + b: { keyframeEffect: { name: 'shared', keyframes: [{ opacity: '1' }] } }, + }, + interactions: [ + { key: 'el', trigger: 'viewEnter', effects: [{ effectId: 'a' }, { effectId: 'b' }] }, + ], + }); + const errs = result.errors.filter((e) => e.code === 'DUPLICATE_KEYFRAME_NAME'); + expect(errs).toHaveLength(1); + expect(errs[0].severity).toBe('warning'); + expect(errs[0].path).toContain('name'); + expect(errs[0].message).toContain('"shared"'); + }); + + it('emits one DUPLICATE_KEYFRAME_NAME per extra duplicate (first occurrence is the baseline)', () => { + const result = validateInteractConfig({ + effects: { + a: { keyframeEffect: { name: 'dup', keyframes: [{ opacity: '0' }] } }, + b: { keyframeEffect: { name: 'dup', keyframes: [{ opacity: '0.5' }] } }, + c: { keyframeEffect: { name: 'dup', keyframes: [{ opacity: '1' }] } }, + }, + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [{ effectId: 'a' }, { effectId: 'b' }, { effectId: 'c' }], + }, + ], + }); + const errs = result.errors.filter((e) => e.code === 'DUPLICATE_KEYFRAME_NAME'); + expect(errs).toHaveLength(2); + }); +}); diff --git a/packages/interact-validate/test/rules/unusedDefinitions.spec.ts b/packages/interact-validate/test/rules/unusedDefinitions.spec.ts new file mode 100644 index 00000000..9d1fa4be --- /dev/null +++ b/packages/interact-validate/test/rules/unusedDefinitions.spec.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { validateInteractConfig } from '../../src'; + +describe('unusedDefinitions', () => { + it('emits no errors when all definitions are referenced', () => { + const result = validateInteractConfig({ + effects: { fade: { namedEffect: { type: 'FadeIn' } } }, + sequences: { seq: { effects: [{ namedEffect: { type: 'SlideIn' } }] } }, + conditions: { mq: { type: 'media', predicate: '(min-width: 768px)' } }, + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + conditions: ['mq'], + effects: [{ effectId: 'fade' }], + sequences: [{ sequenceId: 'seq' }], + }, + ], + }); + const unusedCodes = ['UNUSED_EFFECT', 'UNUSED_SEQUENCE', 'UNUSED_CONDITION']; + expect(result.errors.filter((e) => unusedCodes.includes(e.code))).toHaveLength(0); + }); + + describe('UNUSED_EFFECT', () => { + it('emits UNUSED_EFFECT for a defined effect that no interaction references', () => { + const result = validateInteractConfig({ + effects: { fade: { namedEffect: { type: 'FadeIn' } } }, + interactions: [ + { key: 'el', trigger: 'viewEnter', effects: [{ namedEffect: { type: 'SlideIn' } }] }, + ], + }); + const err = result.errors.find((e) => e.code === 'UNUSED_EFFECT'); + expect(err).toBeDefined(); + expect(err?.severity).toBe('warning'); + expect(err?.path).toEqual(['effects', 'fade']); + }); + + it('does not emit UNUSED_EFFECT for an effect referenced via effectId', () => { + const result = validateInteractConfig({ + effects: { fade: { namedEffect: { type: 'FadeIn' } } }, + interactions: [{ key: 'el', trigger: 'viewEnter', effects: [{ effectId: 'fade' }] }], + }); + expect(result.errors.filter((e) => e.code === 'UNUSED_EFFECT')).toHaveLength(0); + }); + + it('does not emit UNUSED_EFFECT for an effect referenced inside a sequence', () => { + const result = validateInteractConfig({ + effects: { fade: { namedEffect: { type: 'FadeIn' } } }, + sequences: { seq: { effects: [{ effectId: 'fade' }] } }, + interactions: [{ key: 'el', trigger: 'viewEnter', sequences: [{ sequenceId: 'seq' }] }], + }); + expect(result.errors.filter((e) => e.code === 'UNUSED_EFFECT')).toHaveLength(0); + }); + }); + + describe('UNUSED_SEQUENCE', () => { + it('emits UNUSED_SEQUENCE for a defined sequence that no interaction references', () => { + const result = validateInteractConfig({ + sequences: { seq: { effects: [{ namedEffect: { type: 'FadeIn' } }] } }, + interactions: [ + { key: 'el', trigger: 'viewEnter', effects: [{ namedEffect: { type: 'SlideIn' } }] }, + ], + }); + const err = result.errors.find((e) => e.code === 'UNUSED_SEQUENCE'); + expect(err).toBeDefined(); + expect(err?.severity).toBe('warning'); + expect(err?.path).toEqual(['sequences', 'seq']); + }); + + it('does not emit UNUSED_SEQUENCE for a sequence referenced via sequenceId', () => { + const result = validateInteractConfig({ + sequences: { seq: { effects: [{ namedEffect: { type: 'FadeIn' } }] } }, + interactions: [{ key: 'el', trigger: 'viewEnter', sequences: [{ sequenceId: 'seq' }] }], + }); + expect(result.errors.filter((e) => e.code === 'UNUSED_SEQUENCE')).toHaveLength(0); + }); + }); + + describe('UNUSED_CONDITION', () => { + it('emits UNUSED_CONDITION for a defined condition that nothing references', () => { + const result = validateInteractConfig({ + conditions: { mq: { type: 'media', predicate: '(min-width: 768px)' } }, + interactions: [ + { key: 'el', trigger: 'viewEnter', effects: [{ namedEffect: { type: 'FadeIn' } }] }, + ], + }); + const err = result.errors.find((e) => e.code === 'UNUSED_CONDITION'); + expect(err).toBeDefined(); + expect(err?.severity).toBe('warning'); + expect(err?.path).toEqual(['conditions', 'mq']); + }); + + it('does not emit UNUSED_CONDITION when the condition is referenced by an interaction', () => { + const result = validateInteractConfig({ + conditions: { mq: { type: 'media', predicate: '(min-width: 768px)' } }, + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + conditions: ['mq'], + effects: [{ namedEffect: { type: 'FadeIn' } }], + }, + ], + }); + expect(result.errors.filter((e) => e.code === 'UNUSED_CONDITION')).toHaveLength(0); + }); + + it('does not emit UNUSED_CONDITION when the condition is referenced by an inline effect', () => { + const result = validateInteractConfig({ + conditions: { mq: { type: 'media', predicate: '(min-width: 768px)' } }, + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [{ namedEffect: { type: 'FadeIn' }, conditions: ['mq'] }], + }, + ], + }); + expect(result.errors.filter((e) => e.code === 'UNUSED_CONDITION')).toHaveLength(0); + }); + }); +}); diff --git a/packages/interact-validate/test/rules/validMediaQueries.spec.ts b/packages/interact-validate/test/rules/validMediaQueries.spec.ts new file mode 100644 index 00000000..3afea6f2 --- /dev/null +++ b/packages/interact-validate/test/rules/validMediaQueries.spec.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { validateInteractConfig } from '../../src'; + +// jsdom defines window.matchMedia but its stub returns `media: ''` for all +// queries because jsdom does not implement CSS parsing. Provide a minimal +// replacement so the rule behaves like a real browser: +// - known-valid queries get back their own string as `media` (truthy → valid) +// - everything else gets back `''` (falsy → invalid) +function stubMatchMedia(validQueries: string[]) { + vi.stubGlobal('matchMedia', (q: string) => ({ + media: validQueries.includes(q.trim()) ? q.trim() : '', + matches: false, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + })); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +// Helper: a config that references the named condition so no UNUSED_CONDITION fires. +function configWithCondition( + id: string, + predicate?: string, + type: 'media' | 'container' | 'selector' = 'media', +) { + return { + conditions: { [id]: { type, predicate: predicate || '' } }, + interactions: [ + { + key: 'el', + trigger: 'viewEnter' as const, + conditions: [id], + effects: [{ namedEffect: { type: 'FadeIn' } }], + }, + ], + }; +} + +describe('validMediaQueries — INVALID_MEDIA_QUERY', () => { + it('emits no errors for a syntactically valid media query', () => { + stubMatchMedia(['(min-width: 768px)']); + const result = validateInteractConfig(configWithCondition('mq', '(min-width: 768px)')); + expect(result.errors.filter((e) => e.code === 'INVALID_MEDIA_QUERY')).toHaveLength(0); + }); + + it('emits no errors for a condition type other than media', () => { + const result = validateInteractConfig(configWithCondition('sel', '.my-class', 'selector')); + expect(result.errors.filter((e) => e.code === 'INVALID_MEDIA_QUERY')).toHaveLength(0); + }); + + it('emits no errors when the media condition has no predicate (handled by conditionPredicateRequired)', () => { + // validMediaQueries only fires when predicate is defined; the missing-predicate + // case is owned by conditionPredicateRequired. + const result = validateInteractConfig(configWithCondition('mq')); + expect(result.errors.filter((e) => e.code === 'INVALID_MEDIA_QUERY')).toHaveLength(0); + }); + + it('emits no errors for an empty predicate string', () => { + // Empty string fails the first guard in isValidMediaQuery: `if (!q) return false` + const result = validateInteractConfig(configWithCondition('mq', '')); + expect(result.errors.filter((e) => e.code === 'INVALID_MEDIA_QUERY')).toHaveLength(0); + }); + + it('emits INVALID_MEDIA_QUERY for a query that matchMedia reports as invalid', () => { + stubMatchMedia([]); // nothing is valid → matchMedia returns media: '' for everything + const result = validateInteractConfig(configWithCondition('mq', '@bad##query')); + expect(result.errors.some((e) => e.code === 'INVALID_MEDIA_QUERY')).toBe(true); + }); +}); diff --git a/packages/interact-validate/test/structural.spec.ts b/packages/interact-validate/test/structural.spec.ts new file mode 100644 index 00000000..cf02b713 --- /dev/null +++ b/packages/interact-validate/test/structural.spec.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import { validateStructural } from '../src/structural'; + +const VALID_CONFIG = { + interactions: [ + { key: 'el', trigger: 'viewEnter', effects: [{ namedEffect: { type: 'FadeIn' } }] }, + ], + conditions: { 'condition-id': { type: 'media', predicate: '(min-width: 768px)' } }, +}; + +describe('validateStructural', () => { + it('returns ok=true and no errors for a valid config', () => { + const result = validateStructural(VALID_CONFIG); + expect(result.ok).toBe(true); + expect(result.errors).toHaveLength(0); + expect(result.parsed).toBeDefined(); + }); + + it('emits SCHEMA_INVALID_TYPE when interactions is missing', () => { + const result = validateStructural({}); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => e.code === 'SCHEMA_INVALID_TYPE')).toBe(true); + }); + + it('emits SCHEMA_INVALID_TYPE when interactions is not an array', () => { + const result = validateStructural({ interactions: 'not-an-array' }); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => e.code === 'SCHEMA_INVALID_TYPE')).toBe(true); + }); + + it('emits SCHEMA_UNRECOGNIZED_KEYS for an unknown root key', () => { + const result = validateStructural({ ...VALID_CONFIG, unknownField: true }); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => e.code === 'SCHEMA_UNRECOGNIZED_KEYS')).toBe(true); + }); + + it('emits SCHEMA_TOO_SMALL when interaction key is an empty string', () => { + const result = validateStructural({ + interactions: [ + { key: '', trigger: 'viewEnter', effects: [{ namedEffect: { type: 'FadeIn' } }] }, + ], + }); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => e.code === 'SCHEMA_TOO_SMALL')).toBe(true); + }); + + it('emits errors for an unrecognised trigger value', () => { + const result = validateStructural({ + interactions: [ + { key: 'el', trigger: 'invalidTrigger', effects: [{ namedEffect: { type: 'FadeIn' } }] }, + ], + }); + expect(result.ok).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('emits SCHEMA_INVALID when an effect defines multiple sources', () => { + const result = validateStructural({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [ + { + namedEffect: { type: 'FadeIn' }, + keyframeEffect: { name: 'k', keyframes: [{ opacity: 0 }] }, + }, + ], + }, + ], + }); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => e.code === 'SCHEMA_INVALID')).toBe(true); + }); + + it('returns the parsed config on success', () => { + const result = validateStructural(VALID_CONFIG); + expect(result.parsed).toMatchObject({ interactions: expect.any(Array) }); + }); + + it('exposes path information in errors', () => { + const result = validateStructural({ interactions: 'bad' }); + expect(result.errors[0].path).toBeDefined(); + }); + + it('emits SCHEMA_TOO_SMALL when condition predicate is an empty string', () => { + const result = validateStructural({ + interactions: [], + conditions: { + 'condition-id': { + type: 'media', + predicate: '', + }, + }, + }); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => e.code === 'SCHEMA_TOO_SMALL')).toBe(true); + }); +}); diff --git a/packages/interact-validate/test/type-parity.spec.ts b/packages/interact-validate/test/type-parity.spec.ts new file mode 100644 index 00000000..8d318ff7 --- /dev/null +++ b/packages/interact-validate/test/type-parity.spec.ts @@ -0,0 +1,54 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import type { z } from 'zod'; +import type { InteractConfig, Condition as ConditionDef } from '@wix/interact'; +import { InteractConfigSchema, Condition } from '../src/schema'; + +type InferredConfig = z.infer; +type InferredCondition = z.infer; + +// --------------------------------------------------------------------------- +// These tests are compile-time drift guards. They fail at TypeScript type- +// checking time (yarn lint / tsc --noEmit) if the zod schemas diverge from +// the hand-written types in @wix/interact. At runtime they are no-ops. +// --------------------------------------------------------------------------- + +describe('schema type parity (drift guard)', () => { + it('Condition schema type field is identical to the hand-written ConditionDef type field', () => { + expectTypeOf().toEqualTypeOf(); + }); + + it('Condition schema predicate field is identical to the hand-written ConditionDef predicate field', () => { + expectTypeOf().toEqualTypeOf(); + }); + + it('InteractConfigSchema has required interactions (mirrors InteractConfig)', () => { + type Interactions = InferredConfig['interactions']; + // interactions must be a required array + expectTypeOf().not.toEqualTypeOf(); + expectTypeOf().not.toEqualTypeOf(); + }); + + it('InteractConfigSchema has optional effects (mirrors InteractConfig)', () => { + // Both types declare effects as optional + expectTypeOf().toMatchTypeOf | undefined>(); + expectTypeOf().toMatchTypeOf | undefined>(); + }); + + it('InteractConfigSchema has optional sequences (mirrors InteractConfig)', () => { + expectTypeOf().toMatchTypeOf< + Record | undefined + >(); + expectTypeOf().toMatchTypeOf< + Record | undefined + >(); + }); + + it('InteractConfigSchema has optional conditions (mirrors InteractConfig)', () => { + expectTypeOf().toMatchTypeOf< + Record | undefined + >(); + expectTypeOf().toMatchTypeOf< + Record | undefined + >(); + }); +}); diff --git a/packages/interact-validate/test/validate.spec.ts b/packages/interact-validate/test/validate.spec.ts new file mode 100644 index 00000000..424d5867 --- /dev/null +++ b/packages/interact-validate/test/validate.spec.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; +import { validateInteractConfig, assertValidInteractConfig, InteractValidationError } from '../src'; + +const VALID_CONFIG = { + interactions: [ + { key: 'el', trigger: 'viewEnter', effects: [{ namedEffect: { type: 'FadeIn' } }] }, + ], +}; + +// Unused effects produce UNUSED_EFFECT warnings (via unusedDefinitions rule, rule.code = 'UNUSED_DEFINITION') +const CONFIG_WITH_WARNING = { + effects: { unused: { namedEffect: { type: 'FadeIn' } } }, + interactions: [ + { key: 'el', trigger: 'viewEnter', effects: [{ namedEffect: { type: 'SlideIn' } }] }, + ], +}; + +// Missing effectId reference → EFFECT_ID_NOT_FOUND (error) +const CONFIG_WITH_ERROR = { + interactions: [{ key: 'el', trigger: 'viewEnter', effects: [{ effectId: 'missing' }] }], +}; + +describe('validateInteractConfig', () => { + it('returns valid=true with no errors for a valid config', () => { + const result = validateInteractConfig(VALID_CONFIG); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('returns valid=false for a config with structural errors', () => { + const result = validateInteractConfig({ interactions: 'not-an-array' }); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('returns valid=true (warnings only) for a config with only warnings', () => { + const result = validateInteractConfig(CONFIG_WITH_WARNING); + expect(result.valid).toBe(true); + expect(result.errors.some((e) => e.severity === 'warning')).toBe(true); + }); + + it('returns valid=false for a config with semantic errors', () => { + const result = validateInteractConfig(CONFIG_WITH_ERROR); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.code === 'EFFECT_ID_NOT_FOUND')).toBe(true); + }); + + it('sorts errors by path lexicographically', () => { + const config = { + effects: { + a: { namedEffect: { type: 'Foo' } }, + b: { namedEffect: { type: 'Bar' } }, + }, + interactions: [ + { key: 'el', trigger: 'viewEnter', effects: [{ namedEffect: { type: 'Baz' } }] }, + ], + }; + const result = validateInteractConfig(config); + const unusedErrors = result.errors.filter((e) => e.code === 'UNUSED_EFFECT'); + expect(unusedErrors).toHaveLength(2); + expect(unusedErrors[0].path).toEqual(['effects', 'a']); + expect(unusedErrors[1].path).toEqual(['effects', 'b']); + }); +}); + +describe('ValidateOptions', () => { + it('strict promotes warnings to errors, making valid=false', () => { + const result = validateInteractConfig(CONFIG_WITH_WARNING, { strict: true }); + expect(result.valid).toBe(false); + expect(result.errors.every((e) => e.severity === 'error')).toBe(true); + }); + + it("severityOverrides with 'off' skips the rule entirely", () => { + // 'UNUSED_DEFINITION' is the rule.code for the unusedDefinitions rule + const result = validateInteractConfig(CONFIG_WITH_WARNING, { + severityOverrides: { UNUSED_DEFINITION: 'off' }, + }); + expect(result.errors.filter((e) => e.code === 'UNUSED_EFFECT')).toHaveLength(0); + }); + + it("severityOverrides can promote a warning to 'error'", () => { + const result = validateInteractConfig(CONFIG_WITH_WARNING, { + severityOverrides: { UNUSED_DEFINITION: 'error' }, + }); + const unusedErr = result.errors.find((e) => e.code === 'UNUSED_EFFECT'); + expect(unusedErr?.severity).toBe('error'); + }); + + it('max truncates the returned error list', () => { + const config = { + effects: { + a: { namedEffect: { type: 'Foo' } }, + b: { namedEffect: { type: 'Bar' } }, + c: { namedEffect: { type: 'Baz' } }, + }, + interactions: [ + { key: 'el', trigger: 'viewEnter', effects: [{ namedEffect: { type: 'Qux' } }] }, + ], + }; + const result = validateInteractConfig(config, { max: 1 }); + expect(result.errors).toHaveLength(1); + }); +}); + +describe('assertValidInteractConfig', () => { + it('does not throw for a valid config', () => { + expect(() => assertValidInteractConfig(VALID_CONFIG)).not.toThrow(); + }); + + it('throws InteractValidationError for an invalid config', () => { + expect(() => assertValidInteractConfig(CONFIG_WITH_ERROR)).toThrow(InteractValidationError); + }); + + it('thrown error carries the validation errors array', () => { + try { + assertValidInteractConfig(CONFIG_WITH_ERROR); + } catch (e) { + expect(e).toBeInstanceOf(InteractValidationError); + expect((e as InteractValidationError).errors.length).toBeGreaterThan(0); + expect((e as InteractValidationError).errors[0].code).toBe('EFFECT_ID_NOT_FOUND'); + } + }); + + it('narrows the type to InteractConfig after assertion', () => { + const config: unknown = VALID_CONFIG; + assertValidInteractConfig(config); + // TypeScript now knows config: InteractConfig — no runtime assertion needed, + // the fact that no throw occurred is the guarantee. + expect(config).toBeDefined(); + }); +}); diff --git a/packages/interact-validate/tsconfig.build.json b/packages/interact-validate/tsconfig.build.json new file mode 100644 index 00000000..7d8c0867 --- /dev/null +++ b/packages/interact-validate/tsconfig.build.json @@ -0,0 +1,21 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist/es", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "declarationDir": "dist/types", + "emitDeclarationOnly": true, + "noEmit": false, + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["src"], + "references": [ + { + "path": "../interact" + } + ] +} diff --git a/packages/interact-validate/tsconfig.json b/packages/interact-validate/tsconfig.json new file mode 100644 index 00000000..8b9d3a15 --- /dev/null +++ b/packages/interact-validate/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist/es", + "declarationDir": "dist/types", + "declaration": true, + "composite": true, + "baseUrl": "." + }, + "include": ["src/**/*", "src"], + "references": [ + { + "path": "../interact" + } + ] +} diff --git a/packages/interact-validate/vite.config.ts b/packages/interact-validate/vite.config.ts new file mode 100644 index 00000000..ec303df5 --- /dev/null +++ b/packages/interact-validate/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export default defineConfig({ + build: { + lib: { + entry: { + index: path.resolve(__dirname, 'src/index.ts'), + }, + formats: ['es', 'cjs'], + }, + sourcemap: true, + rollupOptions: { + external: ['zod', '@wix/interact'], + output: { + entryFileNames: '[format]/[name].js', + compact: true, + }, + }, + }, +}); diff --git a/packages/interact-validate/vitest.config.ts b/packages/interact-validate/vitest.config.ts new file mode 100644 index 00000000..647a9e54 --- /dev/null +++ b/packages/interact-validate/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + }, +}); diff --git a/packages/interact/docs/api/types.md b/packages/interact/docs/api/types.md index ba342652..64f40182 100644 --- a/packages/interact/docs/api/types.md +++ b/packages/interact/docs/api/types.md @@ -873,7 +873,7 @@ Defines conditional logic for interactions. ```typescript type Condition = { type: 'media' | 'container' | 'selector'; - predicate?: string; + predicate: string; }; ``` diff --git a/packages/interact/docs/guides/state-management.md b/packages/interact/docs/guides/state-management.md index 081a1573..37bf9692 100644 --- a/packages/interact/docs/guides/state-management.md +++ b/packages/interact/docs/guides/state-management.md @@ -405,47 +405,6 @@ observer.observe(interactElement, { }); ``` -### State-Based Conditional Logic - -Use states to control other interactions: - -```typescript -const conditionalConfig = { - conditions: { - 'menu-open': { - type: 'custom', - predicate: () => { - const element = document.querySelector('interact-element'); - return hasState('menu-open'); - }, - }, - }, - interactions: [ - // Close menu when clicking outside - { - key: 'page-body', - trigger: 'click', - conditions: ['menu-open'], - effects: [ - { - key: 'mobile-menu', - effectId: 'menu-close', - keyframeEffect: { - name: 'slide-out', - keyframes: [ - { opacity: '1', transform: 'translateX(0)' }, - { opacity: '0', transform: 'translateX(100%)' }, - ], - }, - duration: 300, - easing: 'ease-in', - }, - ], - }, - ], -}; -``` - ## State Management Patterns ### State Machine Pattern diff --git a/packages/interact/src/types/config.ts b/packages/interact/src/types/config.ts index 763a4c37..c505dcef 100644 --- a/packages/interact/src/types/config.ts +++ b/packages/interact/src/types/config.ts @@ -3,7 +3,7 @@ import type { Effect, EffectRef, EffectProperty, TimeAnimationTriggerType } from export type Condition = { type: 'media' | 'container' | 'selector'; - predicate?: string; + predicate: string; }; export type SequenceOptionsConfig = { diff --git a/packages/interact/src/types/external.ts b/packages/interact/src/types/external.ts index adefdbf0..3915e2ed 100644 --- a/packages/interact/src/types/external.ts +++ b/packages/interact/src/types/external.ts @@ -27,6 +27,7 @@ export type { // Config export type { Condition, + SequenceOptionsConfig, SequenceConfig, SequenceConfigRef, InteractionTrigger, diff --git a/yarn.lock b/yarn.lock index c3b4e422..6f0b8df1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1396,6 +1396,22 @@ __metadata: languageName: unknown linkType: soft +"@wix/interact-validate@workspace:packages/interact-validate": + version: 0.0.0-use.local + resolution: "@wix/interact-validate@workspace:packages/interact-validate" + dependencies: + "@vitest/coverage-v8": "npm:^4.0.14" + "@wix/interact": "npm:^2.4.0" + rimraf: "npm:^6.0.1" + typescript: "npm:^5.9.3" + vite: "npm:^7.2.2" + vitest: "npm:^4.0.14" + zod: "npm:^4.0.0" + peerDependencies: + "@wix/interact": ^2.4.0 + languageName: unknown + linkType: soft + "@wix/interact-website@workspace:apps/website": version: 0.0.0-use.local resolution: "@wix/interact-website@workspace:apps/website" @@ -6344,6 +6360,13 @@ __metadata: languageName: node linkType: hard +"zod@npm:^4.0.0": + version: 4.4.3 + resolution: "zod@npm:4.4.3" + checksum: 10/804b9a42aa8f35f2b3c5a8dff906291cb749115f83ee2afe3576d70b5b5c53c965365c7f4967690647a9c54af9838ff232a85ff9577a0a36c44b68bc6cdefe36 + languageName: node + linkType: hard + "zwitch@npm:^2.0.0": version: 2.0.4 resolution: "zwitch@npm:2.0.4"