Pluggable Jay-Html Validation
Design Log #145 — Pluggable Jay-HTML Validation
Written for AI agents. See Log Methodology Note below for details.
Background
Wix media URLs (https://static.wixstatic.com/media/HASH.jpg) return full-size unoptimized images. Wix supports image resize and optimization by appending parameters like /v1/fit/w_300,h_200,q_90/file.jpg. However, the wix plugins cannot apply this because they don't know the target size — that information lives in the jay-html template (width/height from styles or attributes).
Similarly, accessibility rules (images must have alt attributes, inputs must have associated labels) need to be checked at the template level, not the plugin level.
Both use cases require validation that runs against parsed jay-html files, and both need to provide agent-friendly feedback explaining how to fix issues — since AI agents are the primary authors of jay-html templates.
Problem
There is no mechanism for plugins to provide validation rules that run against jay-html files in consuming projects. The existing jay-stack validate command validates contracts, route params, ref types, headless instance props, and tag coverage — all built-in to the framework. Plugins have no extension point to add their own checks.
Questions and Answers
Q1: What data do validators need access to?
Validators operate on the already-parsed jay-html DOM tree (HTMLElement from node-html-parser). They need:
- The element tree (to walk and inspect elements, attributes, text)
- File path (for context in error messages)
- Headless imports (to know which plugins are used in this file)
- Page contract (to understand the data model)
They do not need to load external files, run compilation, or access runtime state.
Q2: How are validator handlers loaded?
Since validators work purely on parsed jay-html data, they need no special TypeScript loader. Handlers are loaded via standard import():
- NPM plugins: handler is pre-compiled JS in the package's dist
- Local plugins: handler is a relative path to the compiled JS file
Q3: Should accessibility validation be built-in or pluggable?
Pluggable — implemented as a framework-provided plugin in packages/plugins/. Projects opt in by adding the plugin. This keeps the core validation focused on structural correctness and allows accessibility rules to evolve independently.
Q4: Per-file or batch validation?
Per-file. Each validator receives one parsed jay-html file and returns findings for that file. This matches the existing validation pattern in validateJayFiles(). Cross-file validators can be added later if needed.
Q5: How does the agent consume validation feedback?
Validation findings include a suggestion field with actionable, agent-friendly instructions. When jay-stack validate runs (either by the agent or in CI), findings are printed with their suggestions. The --json output mode already exists and will include suggestion text, making it easy for agents to parse and act on.
Design
1. Validator Declaration in plugin.yaml
Plugins declare validators alongside contracts, actions, and services:
name: wix-data
validators:
- name: wix-media-optimization
handler: ./validators/media-validator
description: Ensures wix media URLs use resize parameters for performance
name: kebab-case identifier for the validation rulehandler: module path — relative path (local plugins) or export subpath (NPM plugins)description: human-readable purpose (shown in verbose output)
2. Type Definitions
New file packages/compiler/compiler-shared/lib/plugin-validators.ts:
import type { HTMLElement } from 'node-html-parser';
export interface JayHtmlValidationContext {
/** Parsed jay-html DOM tree root */
body: HTMLElement;
/** Relative file path (from project root) */
filePath: string;
/** Absolute project root path */
projectRoot: string;
}
export interface JayHtmlValidationFinding {
severity: 'error' | 'warning';
/** Human-readable problem description */
message: string;
/** Agent-friendly instruction on how to fix the issue */
suggestion: string;
/** Element context (tag name or ref) */
element?: string;
/** Which attribute is problematic */
attribute?: string;
}
/** Function signature for a validator handler module's default export */
export type JayHtmlValidatorFn = (context: JayHtmlValidationContext) => JayHtmlValidationFinding[];
3. PluginManifest Extension
Add to PluginManifest in packages/compiler/compiler-shared/lib/plugin-resolution.ts:
/** Jay-HTML validation rules provided by this plugin (DL#145) */
validators?: Array<{
/** Kebab-case rule name */
name: string;
/** Module path: relative (local) or export subpath (NPM) */
handler: string;
/** Human-readable description */
description?: string;
}>;
4. Validation Flow
Extend validateJayFiles() in packages/jay-stack/stack-cli/lib/validate.ts:
validateJayFiles()
├── [existing] Parse and validate .jay-contract files
├── [existing] Parse and validate .jay-html files
│ ├── Route param checks
│ ├── Ref type checks
│ ├── Headless instance props
│ ├── Tag coverage
│ └── Code generation test
└── [new] Plugin validator phase
├── scanPlugins() to discover all plugins
├── Filter plugins with validators
├── Load validator handlers via import()
└── For each already-parsed jay-html file:
├── Build JayHtmlValidationContext from the parsed file
├── Run each validator function
└── Collect findings into errors[] and warnings[]
Key implementation detail: the jay-html files are already parsed by the core validation phase. The plugin validator phase reuses those parsed results — no re-parsing needed. We need to retain the parsed JayHtmlSourceFile objects from the core phase and pass them to validators.
5. Handler Module Contract
A validator handler module exports a validate function:
// validators/media-validator.ts
import type { JayHtmlValidatorFn } from '@jay-framework/compiler-shared';
export const validate: JayHtmlValidatorFn = (ctx) => {
const findings = [];
// walk ctx.body, inspect elements, return findings
return findings;
};
6. Output Format
Findings appear in the standard validation output, attributed to the plugin:
⚠ src/pages/products/[slug]/page.jay-html
[wix-data/wix-media-optimization] Wix media image without resize parameters
Suggestion: Add resize parameters to the wix media URL. Change the src
from "https://static.wixstatic.com/media/HASH.jpg" to
"https://static.wixstatic.com/media/HASH.jpg/v1/fit/w_{WIDTH},h_{HEIGHT},q_80/HASH.jpg"
where WIDTH and HEIGHT match the element's rendered dimensions.
In --json mode, findings include plugin, rule, message, and suggestion fields.
7. Plugin Validator Schema Validation
Update validateSchema() in packages/jay-stack/plugin-validator/lib/validate-plugin.ts to validate the validators section:
- Each validator must have a
name(kebab-case) andhandler(non-empty string) - Handler file must exist (for local plugins)
Examples
Wix Media Validator
import type { JayHtmlValidatorFn, JayHtmlValidationFinding } from '@jay-framework/compiler-shared';
const WIX_MEDIA_PATTERN = 'static.wixstatic.com/media/';
export const validate: JayHtmlValidatorFn = (ctx) => {
const findings: JayHtmlValidationFinding[] = [];
walkElements(ctx.body, (el) => {
if (el.rawTagName !== 'img') return;
const src = el.getAttribute('src');
if (src && src.includes(WIX_MEDIA_PATTERN) && !src.includes('/v1/')) {
findings.push({
severity: 'warning',
message: 'Wix media image without resize parameters — full-size image will be served',
suggestion:
'Add resize parameters to optimize image delivery. ' +
'Change src from "https://static.wixstatic.com/media/HASH.jpg" to ' +
'"https://static.wixstatic.com/media/HASH.jpg/v1/fit/w_{WIDTH},h_{HEIGHT},q_80/HASH.jpg" ' +
"where WIDTH and HEIGHT match the rendered dimensions from the element's style. " +
'For responsive images, add a srcset attribute with multiple sizes.',
element: 'img',
attribute: 'src',
});
}
});
return findings;
};
function walkElements(el: any, visitor: (el: any) => void): void {
visitor(el);
for (const child of el.childNodes ?? []) {
if (child.nodeType === 1) walkElements(child, visitor);
}
}
Accessibility Validator (separate plugin)
import type { JayHtmlValidatorFn, JayHtmlValidationFinding } from '@jay-framework/compiler-shared';
export const validate: JayHtmlValidatorFn = (ctx) => {
const findings: JayHtmlValidationFinding[] = [];
walkElements(ctx.body, (el) => {
const tag = el.rawTagName?.toLowerCase();
// img must have alt
if (tag === 'img' && !el.getAttribute('alt')) {
findings.push({
severity: 'warning',
message: 'Image element missing alt attribute',
suggestion:
'Add an alt attribute to the <img> element. ' +
'Use descriptive text for informative images, or alt="" for decorative images.',
element: 'img',
attribute: 'alt',
});
}
// input must have associated label
if (tag === 'input' && el.getAttribute('type') !== 'hidden') {
const id = el.getAttribute('id');
if (!id || !hasLabelFor(ctx.body, id)) {
findings.push({
severity: 'warning',
message: 'Input element without associated label',
suggestion:
'Add a <label for="inputId"> element that references this input\'s id, ' +
'or wrap the input in a <label> element.',
element: 'input',
attribute: 'id',
});
}
}
});
return findings;
};
Validation Output Examples
Good (validator finds no issues):
<img
src="https://static.wixstatic.com/media/abc123.jpg/v1/fit/w_300,h_200,q_80/abc123.jpg"
alt="Product photo"
style="width: 300px; height: 200px;"
/>
Bad (validator reports warning):
<img src="https://static.wixstatic.com/media/abc123.jpg" style="width: 300px; height: 200px;" />
Implementation Plan
Phase 1: Contract tag meta and types
- Add
meta?: Record<string, string>toParsedYamlTagandContractTag - Pass
metathrough inparseTag()— no validation, opaque to framework - Create
packages/compiler/compiler-shared/lib/plugin-validators.tswithJayHtmlValidationContext,JayHtmlValidationFinding,JayHtmlValidatorFn - Add
contractandheadlessImportstoJayHtmlValidationContext - Export from
packages/compiler/compiler-shared/lib/index.ts - Add
validatorstoPluginManifestinterface
Phase 2: Validator utilities
- Create
packages/compiler/compiler-shared/lib/validator-utils.ts - Implement
parseTemplateParts— wrap PEGtemplaterule, returnTemplatePart[]instead of generated code (addtemplatePartsPEG rule or post-process existing AST) - Implement
DataScopetype andresolveBinding— walk contract tag tree by dot-separated path within a scope - Implement
walkElements— depth-first traversal that buildsDataScopeat forEach/<jay:*>boundaries and passes it to visitor callback - Implement
resolveAttributeBindings— convenience combining the above - Export from index
- Unit tests:
parseTemplatePartswith nested braces/ternaries,resolveBindingthrough sub-contracts withmeta,walkElementsscope changes at forEach and headless boundaries
Phase 3: Validation runner
- Modify
validateJayFiles()to retain parsed jay-html files after core validation - Add plugin scanning (import
scanPluginsfromstack-server-runtime) - Add validator handler loading and execution
- Build
JayHtmlValidationContextwith contract and headless imports from parsed files - Merge findings into
ValidationResult - Update output formatting to show plugin findings with suggestions
Phase 4: Plugin validator schema
- Update
validateSchema()inplugin-validatorto validate thevalidatorssection - Check handler file exists for local plugins
Phase 5: Tests
- Contract parser test: tag with
metaparses and roundtrips correctly - Validator utils tests:
parseTemplateParts,resolveBindingTagwith nested sub-contracts andmeta - Integration test: local plugin with validator, validate discovers and runs it
- Test error handling (missing handler, handler throws, etc.)
- Verify core validation unchanged
8. Contract Tag Metadata (meta)
Validators often need to know what kind of data a binding represents beyond its dataType. A string tag could be a plain label, a URL, or a Wix image URL that requires a resize suffix. The validator needs this semantic context to produce useful findings.
Add an optional meta field to contract tags — a free-form key-value map that plugins define and validators consume:
# In a wix-data plugin contract
name: ProductPage
tags:
- tag: mediaGallery
type: sub-contract
tags:
- tag: selectedMedia
type: sub-contract
tags:
- tag: url
type: data
dataType: string
meta:
vendor: wix-image
defaultTransform: w_300,h_200,q_80
- tag: alt
type: data
dataType: string
- tag: productName
type: data
dataType: string
Contract types
ParsedYamlTag gains:
meta?: Record<string, string>;
ContractTag gains:
/** Vendor/plugin metadata for validation. Opaque to the framework. */
meta?: Record<string, string>;
The parseTag() function passes meta through unchanged — the framework never interprets it, only validators do.
Validation context
JayHtmlValidationContext gains access to the parsed contract, so validators can look up tag metadata:
export interface JayHtmlValidationContext {
body: HTMLElement;
filePath: string;
projectRoot: string;
/** Parsed contract for this page (undefined if no contract) */
contract?: Contract;
/** Headless plugin imports declared in this file */
headlessImports: Array<{
pluginName: string;
contractName: string;
contract: Contract;
}>;
}
9. Validator Utilities
Walking the DOM, parsing bindings, and resolving tag types are common operations every validator needs. Providing utility functions prevents each plugin from reimplementing them (and getting them wrong).
Data context scoping problem
Inside a jay-html file, the data context changes at certain boundaries:
forEach: children bind against the iteration item type, not the page ViewState<jay:component>: children bind against the headless component's contract, not the page's- Nested forEach/headless: scopes stack — a forEach inside a
<jay:widget>has the widget's contract as parent scope
A validator that naively resolves {label} against the page contract will get the wrong answer when the element is inside <jay:test-widget> where label comes from the widget's contract.
The compiler solves this with the Variables class — a linked chain of scopes built during tree traversal. Validators need the same capability, but without the code-generation machinery.
Design: element-anchored resolution
Instead of taking a binding path + contract, resolution takes a binding path + the element it appears on. The utility walks up the DOM from the element to reconstruct the data context:
- Walk ancestors looking for
forEach=attributes and<jay:*>tags - Each one pushes a scope onto the chain (forEach → array item type, jay:component → component contract)
- Resolve the binding against the innermost matching scope
This means the framework pre-builds a DataContext for each element during the validation pass, or builds it lazily on demand by walking the ancestor chain.
Types
New file packages/compiler/compiler-shared/lib/validator-utils.ts:
import type { HTMLElement } from 'node-html-parser';
import type { ContractTag, Contract } from './contract';
/** A parsed segment of an attribute or text value */
export interface TemplatePart {
/** 'static' for literal text, 'binding' for {expression} */
kind: 'static' | 'binding';
/** The raw text: literal string for static, accessor path for binding */
value: string;
}
/** Resolved binding with contract tag info and scope context */
export interface ResolvedBinding {
/** Full accessor path as written in the template */
path: string;
/** The leaf tag from the contract, if resolved */
tag?: ContractTag;
/** Which contract the binding resolved against (page or headless component) */
sourceContract?: Contract;
}
/**
* Parse an attribute value or text content into static and binding parts.
* Uses the existing PEG template rule — handles nested braces, ternaries,
* and escaped characters correctly.
*
* Example: `"{mediaGallery.selectedMedia.url}/v1/fit/w_300/file.jpg"`
* → [{ kind: 'binding', value: 'mediaGallery.selectedMedia.url' },
* { kind: 'static', value: '/v1/fit/w_300/file.jpg' }]
*/
export function parseTemplateParts(value: string): TemplatePart[];
/**
* Resolve a binding path to its contract tag within a data scope.
*
* The scope is provided by walkElements — no need to reconstruct it.
*
* Example: resolveBinding("selectedMedia.url", scope)
* Where scope was built by walkElements when entering <jay:product-page>,
* resolves against the product-page contract.
*/
export function resolveBinding(bindingPath: string, scope: DataScope): ResolvedBinding;
/**
* Walk all elements depth-first, tracking data context through
* forEach and <jay:component> boundaries.
*
* The visitor receives both the element and its DataScope — the scope
* is pre-built by walkElements as it traverses, so validators never
* need to reconstruct it.
*/
export function walkElements(
root: HTMLElement,
ctx: JayHtmlValidationContext,
visitor: (el: HTMLElement, scope: DataScope) => void,
): void;
/**
* Find all bindings in an element's attribute value and resolve them
* against the given data scope.
*/
export function resolveAttributeBindings(attrValue: string, scope: DataScope): ResolvedBinding[];
Data context reconstruction
When resolveBinding is called, it walks up the DOM from element:
<div> ← page contract scope
<jay:product-page> ← product-page contract scope
<div forEach="mediaGallery.images"> ← iteration item scope (images[n])
<img src="{url}" /> ← resolveBinding("url", img, ctx)
</div>
</jay:product-page>
</div>
Walk up from <img>:
- Hit
forEach="mediaGallery.images"→ push scope: item type ofmediaGallery.imagesarray - Hit
<jay:product-page>→ push scope: product-page contract - Resolve
urlstarting from innermost scope (the forEach item) - Find
urltag on the array item sub-contract → return it withmeta
walkElements builds the scope as it recurses, pushing/popping at boundaries:
export interface DataScope {
contract: Contract;
/** Tags at this scope level */
tags: ContractTag[];
/** Parent scope (undefined at page root) */
parent?: DataScope;
}
function doWalk(
el: HTMLElement,
ctx: JayHtmlValidationContext,
scope: DataScope,
visitor: (el: HTMLElement, scope: DataScope) => void,
): void {
let currentScope = scope;
// <jay:component-name> switches to that component's contract
const headless = ctx.headlessImports.find((h) => `jay:${h.contractName}` === el.rawTagName);
if (headless) {
currentScope = {
contract: headless.contract,
tags: headless.contract.tags,
parent: scope,
};
}
// forEach="path" narrows to the array item type
const forEach = el.getAttribute('forEach');
if (forEach) {
const arrayTag = resolveTagPath(forEach, currentScope.tags);
if (arrayTag?.tags) {
currentScope = {
contract: currentScope.contract,
tags: arrayTag.tags,
parent: currentScope,
};
}
}
visitor(el, currentScope);
for (const child of el.childNodes ?? []) {
if (child.nodeType === 1) {
doWalk(child as HTMLElement, ctx, currentScope, visitor);
}
}
}
No ancestor-walking needed per element — the scope is threaded through the recursion and arrives ready at each visitor call.
parseTemplateParts — reuse PEG parser
The existing PEG grammar (expression-compiler.ts) already parses template expressions correctly, handling nested braces, ternaries, and escaping. parseTemplateParts wraps the PEG template rule but returns structured parts instead of generated code:
export function parseTemplateParts(value: string): TemplatePart[] {
// Use the PEG template rule to get the parsed AST,
// then extract static/binding segments from it.
// Falls back to regex for values with no bindings (pure static).
return parseParts(value, 'templateParts');
}
This requires adding a templateParts rule to the PEG grammar (or a post-parse extraction from the existing template rule's AST) that returns TemplatePart[] instead of generated JS code. The PEG grammar already distinguishes static text from {expression} — we just need a second output mode.
Usage in Wix media validator
With these utilities, the wix media validator becomes:
import type { JayHtmlValidatorFn, JayHtmlValidationFinding } from '@jay-framework/compiler-shared';
import { walkElements, parseTemplateParts, resolveBinding } from '@jay-framework/compiler-shared';
export const validate: JayHtmlValidatorFn = (ctx) => {
if (!ctx.contract) return [];
const findings: JayHtmlValidationFinding[] = [];
walkElements(ctx.body, ctx, (el, scope) => {
if (el.rawTagName !== 'img') return;
const src = el.getAttribute('src');
if (!src) return;
const parts = parseTemplateParts(src);
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part.kind !== 'binding') continue;
const resolved = resolveBinding(part.value, scope);
if (!resolved.tag?.meta?.vendor || resolved.tag.meta.vendor !== 'wix-image') continue;
// Check if the next static part has the resize suffix
const next = parts[i + 1];
if (!next || next.kind !== 'static' || !next.value.includes('/v1/')) {
const transform = resolved.tag.meta.defaultTransform || 'w_300,h_200,q_80';
findings.push({
severity: 'warning',
message: `Wix image binding {${part.value}} missing resize suffix`,
suggestion:
`Add resize parameters after the binding. Change:\n` +
` src="{${part.value}}"\n` +
`to:\n` +
` src="{${part.value}}/v1/fit/${transform}/file.jpg"`,
element: 'img',
attribute: 'src',
});
}
}
});
return findings;
};
Trade-offs
| Decision | Benefit | Cost |
|---|---|---|
| Validators get parsed DOM, not raw HTML | Consistent with existing validation, no re-parsing | Validators depend on node-html-parser types |
| Per-file validation only | Simple, matches existing pattern | No cross-file rules (can add later) |
| Accessibility as plugin, not built-in | Core stays focused, rules evolve independently | Projects must opt in |
| Standard import() for handlers | No extra dependencies, works with compiled JS | Local plugins must be compiled first |
meta on contract tags, not separate file |
Discoverable, co-located with data definition | Couples contract format to vendor concerns |
| Framework-provided validator utilities | Consistent parsing, no reimplementation | More API surface to maintain |
parseTemplateParts reuses PEG grammar |
Handles edge cases (nested braces, ternaries, escaping) | Validators depend on compiler-jay-html PEG parser |
walkElements provides DataScope in callback |
Validators never reconstruct scope; forEach/headless boundaries handled automatically | Slightly larger callback signature |
Verification Criteria
- A plugin with
validatorsin its plugin.yaml can provide jay-html validation rules jay-stack validatediscovers and runs plugin validators after core validation- Findings include actionable
suggestiontext suitable for AI agent consumption - Core validation behavior is completely unchanged
--jsonoutput includes plugin validation findingsplugin-validatorvalidates thevalidatorssection of plugin.yaml- Contract tags with
metaparse correctly and are accessible viaContractTag.meta parseTemplatePartscorrectly splits attribute values into static and binding partsresolveBindingTagwalks sub-contracts and returns the leaf tag with itsmeta- A validator can detect a wix-image binding missing a resize suffix using the utilities
Implementation Results
Post-implementation fixes (discovered via wix-media plugin)
Four issues were found when the wix-media plugin (an npm-published plugin) used the validation framework from a consuming project:
Fix 1: Validator handler loading for npm packages
File: packages/jay-stack/stack-cli/lib/validate.ts
The validator handler loading always treated handler as a file path relative to pluginPath. For npm packages, handler is an export name from the package's main module — matching the loadHandler pattern already used by setup/references in stack-server-runtime.
Change: Added plugin.isLocal branching: local plugins resolve handler as a file path; npm plugins import via plugin.packageName and look up the handler as a named export.
Fix 2: Validation context doesn't resolve link: sub-contracts
File: packages/jay-stack/stack-cli/lib/validate.ts
Contracts passed to validators had unresolved link: references — sub-contract tags with link: ./media-gallery had no tags array. Validators couldn't traverse through linked sub-contracts to reach nested tags and their meta.
Change: Added resolveLinkedTags / resolveContractLinks functions that recursively resolve all link: references inline before constructing the validation context. Uses loadLinkedContract and getLinkedContractDir from compiler-jay-html, with JAY_IMPORT_RESOLVER. Applied to both page-level contracts (via parsed.contractRef) and headless import contracts (via imp.contractPath).
Fix 3: scanPlugins missing includeDevDeps
File: packages/jay-stack/stack-cli/lib/validate.ts
Plugins listed in devDependencies were not discovered by the validate command. Validators are dev-time tools, so plugins providing only validators are typically devDependencies.
Change: Added includeDevDeps: true to the scanPlugins({ projectRoot }) call.
Fix 4: walkElements doesn't resolve headless-keyed forEach paths
File: packages/compiler/compiler-shared/lib/validator-utils.ts
walkElements handled forEach by calling resolveTagPath(forEach, currentScope.tags). When the forEach value was headless-keyed (e.g., forEach="productSearch.searchResults"), it tried to find productSearch in the current scope — which was empty when there was no page contract. The forEach scope was never entered, so bindings inside it were unresolvable.
Change: After resolveTagPath returns undefined, check if the first segment matches a headless import key. If so, resolve the remaining path against that import's contract. Added test case.
Additional: Validator output
Added pluginValidators: string[] to ValidationResult and display in the print output, so the user can see which validators were loaded and ran.
Log Methodology Note
Note: These design logs are written primarily for AI agents as part of the Design Log methodology and made accessible here for human readers. The language and structure are optimized for machine consumption — expect precise, specification-style prose rather than narrative documentation.