Wix-Media-Plugin
Design Log 19: Wix Media Plugin
Written for AI agents. See Log Methodology Note below for details.
Status
Draft
Background
Jay Framework sites use images and media throughout pages — product images come from Wix APIs, but site-level media (logos, banners, hero images, icons) needs to be managed separately. Currently, media files sit in the project's public/ directory as static files. This means:
- Media is served from the app's origin, not Wix CDN
- No access to Wix image transformations (resize, crop, quality)
- The AI designer agent has no visibility into what media is available
- No connection between project media and Wix Media Manager
Wix provides @wix/media SDK package (files, folders, categories) for managing media programmatically, and @jay-framework/wix-utils already has URL formatting/transformation utilities.
Problem
We need a wix-media plugin that:
- Media index for agent-kit — generates a reference index of all media available in the Wix Media Manager, so the designer agent knows what images/videos exist and can use them when building pages
- Upload from public/ — uploads local media files to Wix private media, tracking what's already uploaded to avoid duplicates
- Static URL generation — makes the media index available with ready-to-use static URLs
- Usage instructions — documents how to use Wix media URLs, including transformations from
wix-utils
Questions & Answers
Q1: How should the media index be structured? Flat list or organized by folders? A: Flat list. Folders are used as tagging/metadata on each media item, not as organizational structure in the index. Wix media also has other useful metadata: display name, labels, state (private/public). Private files should be excluded from the index.
Q2: How do we track which local files have already been uploaded to avoid duplicates? Hash-based? Filename-based? A: Filename-based index file with upload status. Simple and sufficient.
Q3: Should the upload happen during setup (like wix-data schema generation) or as a separate CLI command?
A: Separate CLI command. This may require a mechanism for plugins to expose their own CLI commands to jay-stack-cli, similar to how actions are exposed. This is potentially a framework requirement.
Q4: What metadata should the index include per media item? A: Display name, labels, type, dimensions (width/height for images). Plus the derived slug and URL.
Q5: Should the index include media from Wix products/collections, or only site-level media from the Media Manager? A: Only Media Manager. Other products (stores, CMS) use the same media — their media references are already in their own contracts/data.
Q6: How should the agent reference media — by a human-readable name, by mediaId, or by a ready-to-use URL? A: By a slug/name derived from the display name. We know slugs aren't unique (only mediaId is), but the index file maps slug → mediaId → URL. The agent uses the slug to find the media, the index resolves it to a URL.
Q7: Should the upload component (headless file upload UI) be in this package?
A: No. The upload component (@wix/headless-media provides FileUploadService) should be a separate plugin that reuses the media context/service from this package. This package focuses on setup-time tooling (index, bulk upload), not runtime UI.
Q8: Should we handle private/protected file URL generation?
A: Parked for now. Private files with generateFileDownloadUrl() (temporary URLs) can be a separate plugin later.
SDK Analysis
@wix/media package (v1.0.247)
Available on npm, not currently installed. Sub-modules:
@wix/auto_sdk_media_files— file operations (list, import, delete, generate URLs)@wix/auto_sdk_media_folders— folder management (list, create, move)@wix/auto_sdk_media_enterprise-media-items— enterprise media items@wix/auto_sdk_media_enterprise-media-categories— enterprise media categories@wix/headless-media— headless UI services (gallery, upload components)
Key API Operations (from Wix docs)
Files:
listFiles(options?)— list files in a folder, with paginationimportFile(url, options?)— import file from external URL into Media ManagerbulkImportFiles(urls, options?)— bulk importgenerateFileDownloadUrl(fileId, options?)— generate temp download URLgenerateFilesDownloadUrl(fileIds)— bulk download URLs (permanent, less secure)deleteFiles(fileIds)— delete files
Folders:
listFolders(options?)— list folderscreateFolder(name, options?)— create folder- Root folders:
MEDIA_ROOT,TRASH_ROOT,VISITOR_UPLOADS_ROOT
Import notes:
- Import is via URL (not binary upload) — file must be publicly accessible
- Imported files are not immediately available (async processing)
- Triggers events:
FileDescriptorFileReady,FileDescriptorFileFailed
Existing @jay-framework/wix-utils Media Utilities
Already implemented in packages/wix-utils/lib/media.ts:
URL Parsing:
parseWixMediaUrl(url)→{ type, mediaId, fileName, originWidth, originHeight, posterUri, ... }- Handles:
wix:image://,wix:video://,wix:document://,wix:audio://
URL Formatting:
formatWixMediaUrl(id, url, resize?)→https://static.wixstatic.com/media/{mediaId}- With resize:
https://static.wixstatic.com/media/{mediaId}/v1/fit/w_{w},h_{h},q_90/file.jpg getVideoPosterUrl(url, resize?)→ poster image URLgetDocumentUrl(url)→https://static.wixstatic.com/ugd/{mediaId}getAudioUrl(url)→https://static.wixstatic.com/mp3/{mediaId}
URL format reference:
Images: https://static.wixstatic.com/media/{mediaId}
Resized: https://static.wixstatic.com/media/{mediaId}/v1/fit/w_{w},h_{h},q_{q}/file.jpg
Videos: https://static.wixstatic.com/media/{mediaId}
Docs: https://static.wixstatic.com/ugd/{mediaId}
Audio: https://static.wixstatic.com/mp3/{mediaId}
Design
Overview
The wix-media plugin is a setup-time and CLI tool, not a runtime component. It:
- Agent-kit reference (generated during
agent-kit) — scans Wix Media Manager and generates a flat media index as a reference in agent-kit, like other generated references - Upload CLI command — uploads local files from
public/to Wix Media Manager, then regenerates the index - Excludes private files from the index
Separate concerns (parked):
- Upload UI component (headless file upload) → separate plugin reusing this package's service/context
- Private file URL generation → separate plugin
Media Index (Agent-Kit Output)
Generated at agent-kit/media/MEDIA-INDEX.md. Flat list, no folder hierarchy. Folders appear as metadata (labels/tags).
# Available Media
| Folder | Slug | Display Name | Type | Dimensions | Labels | URL |
| ----------- | ----------- | ------------ | ----- | ---------- | ---------------- | ----------------------------------------- |
| Site Assets | logo | Company Logo | image | 200x80 | branding | https://static.wixstatic.com/media/abc123 |
| Banners | hero-banner | Hero Banner | image | 1920x600 | homepage, banner | https://static.wixstatic.com/media/def456 |
| Videos | promo-video | Summer Promo | video | 1280x720 | campaign | https://static.wixstatic.com/media/ghi789 |
## How to Use
### By slug in templates
Find the media by slug in the table above, use the URL in `src` attributes:
`<img src="https://static.wixstatic.com/media/abc123" alt="Company Logo" />`
### Image Transformations
URL format: `https://static.wixstatic.com/media/{mediaId}/v1/{mode}/{params}/file.{ext}`
#### Modes
**fit** — scale to fit within dimensions, preserve aspect ratio (may add padding)
`/v1/fit/w_640,h_480/file.jpg`
**fill** — scale to fill dimensions, crop from center if needed
`/v1/fill/w_640,h_480/file.jpg`
**crop** — extract a rectangle from the original image at specific coordinates
`/v1/crop/x_100,y_50,w_800,h_600/file.jpg`
#### Parameters
- `w_{width}` — target width (1–5000 px)
- `h_{height}` — target height (1–5000 px)
- `x_{x},y_{y}` — crop origin (crop mode only)
- `q_{quality}` — JPEG quality (1–100, default 90)
#### Output formats
Use the file extension to convert:
- `file.jpg` — JPEG (photos, many colors)
- `file.png` — PNG (transparency, simple graphics)
- `file.webp` — WebP (modern, better compression)
- `file.gif` — GIF (animation)
#### Common sizes
- Thumbnail: `/v1/fill/w_100,h_100/file.jpg`
- Card image: `/v1/fill/w_400,h_300/file.jpg`
- Hero: `/v1/fill/w_1920,h_600/file.jpg`
- Full width fit: `/v1/fit/w_1200,h_800/file.webp`
#### Limits
- Max dimension: 5000px per side
- WebP max: 16,383px per side
- Images are not upscaled beyond original size
- Only works with public Wix-hosted media (`static.wixstatic.com`)
Slugs are derived from display names (lowercased, hyphenated). Not guaranteed unique — if duplicates exist, append a suffix (e.g., logo-2).
Table is sorted by folder (alphabetically), then slug (alphabetically) within each folder. This gives visual grouping and keeps the table stable across regenerations.
Upload Flow (CLI Command)
Exposed as a plugin CLI command (e.g., jay media upload):
1. Scan public/ for media files (images, videos, documents)
2. Load upload index (config/.wix-media-uploads.json)
3. For each file:
a. Check if filename exists in upload index
b. If already uploaded (status: 'ready') → skip
c. If not uploaded → call importFile(publicUrl) → record as 'pending'
4. Poll/wait for pending files to become ready
5. Update index with final mediaIds and status
6. Rebuild agent-kit media index
Upload index (config/.wix-media-uploads.json):
{
"public/images/logo.png": { "mediaId": "abc123", "status": "ready" },
"public/images/banner.jpg": { "mediaId": "def456", "status": "pending" }
}
Filename-based tracking — simple, maps local path to Wix mediaId and status.
Plugin Structure
packages/wix-media/
├── lib/
│ ├── index.ts # Server exports
│ ├── setup.ts # Setup handler (validation)
│ ├── services/
│ │ ├── wix-media-service.ts # Media Manager API wrapper (list, import)
│ │ └── wix-media-service-marker.ts
│ ├── commands/
│ │ ├── upload-public.ts # CLI command: upload public/ files to Wix
│ │ └── rebuild-index.ts # CLI command: rebuild media index
│ ├── contracts/
│ │ ├── upload-public.jay-command # Command metadata + args schema
│ │ └── rebuild-index.jay-command
│ ├── upload/
│ │ └── upload-index.ts # Filename-based upload tracking
│ └── index-generator.ts # Generate MEDIA-INDEX.md and INSTRUCTIONS.md
├── plugin.yaml
├── package.json
├── tsconfig.json
└── vite.config.ts
CLI Commands
Uses the framework's makeCliCommand pattern with .jay-command metadata files.
upload-public.jay-command
name: upload-public
description: Upload local public/ files to Wix Media Manager
inputSchema:
folder?: string # Subfolder of public/ to upload (default: all)
dryRun?: boolean # List files that would be uploaded without uploading
rebuild-index.jay-command
name: rebuild-index
description: Rebuild agent-kit media index from current Wix Media Manager state
Command handler example
import { makeCliCommand } from '@jay-framework/fullstack-component';
import { WIX_MEDIA_SERVICE } from '../services/wix-media-service-marker';
import { CONSOLE_CONTEXT } from '@jay-framework/stack-server-runtime';
export const uploadPublic = makeCliCommand('upload-public')
.withServices(WIX_MEDIA_SERVICE, CONSOLE_CONTEXT)
.withHandler(async (mediaService, console, args) => {
const { projectRoot, publicFolder } = console;
// scan publicFolder, upload to Wix, regenerate index
});
Invocation
jay-stack run wix-media/upload-public --folder images --dry-run
jay-stack run wix-media/rebuild-index
plugin.yaml
name: wix-media
services:
- name: wix-media
marker: WIX_MEDIA_SERVICE
description: Wix Media Manager API for listing and uploading media
setup:
handler: setupWixMedia
references: generateMediaReferences
description: Validates Wix Media Manager access, generates agent-kit media index
commands:
- name: upload-public
command: upload-public.jay-command
- name: rebuild-index
command: rebuild-index.jay-command
Agent-Kit Instructions
Generated at agent-kit/media/INSTRUCTIONS.md:
# Using Media in Jay Framework
## Finding Media
See MEDIA-INDEX.md for all available media with ready-to-use URLs.
Look up media by slug, then use the URL from the table.
## Image URLs
### Basic usage
<img src="https://static.wixstatic.com/media/{mediaId}" alt="description" />
### Transformed (recommended for performance)
URL format: /v1/{mode}/{params}/file.{ext}
#### Modes
- **fit** — scale to fit within dimensions, preserve aspect ratio
<img src="https://static.wixstatic.com/media/{mediaId}/v1/fit/w_800,h_600/file.jpg" alt="" />
- **fill** — scale to fill dimensions exactly, crop from center
<img src="https://static.wixstatic.com/media/{mediaId}/v1/fill/w_800,h_600/file.jpg" alt="" />
- **crop** — extract a rectangle at specific coordinates
<img src="https://static.wixstatic.com/media/{mediaId}/v1/crop/x_100,y_50,w_800,h_600/file.jpg" alt="" />
#### Parameters
- w\_{width} — target width (1–5000 px)
- h\_{height} — target height (1–5000 px)
- x*{x},y*{y} — crop start position (crop mode only)
- q\_{quality} — JPEG quality (1–100)
#### Output format (set via file extension)
- file.jpg — JPEG (photos)
- file.webp — WebP (modern, smaller)
- file.png — PNG (transparency)
- file.gif — GIF (animation)
#### Common sizes
- Thumbnail: /v1/fill/w_100,h_100/file.jpg
- Card: /v1/fill/w_400,h_300/file.jpg
- Hero: /v1/fill/w_1920,h_600/file.jpg
- Full width: /v1/fit/w_1200,h_800/file.webp
## Video
Use the media URL directly in <video> tags.
Poster image: /v1/fit/w*{w},h*{h}/file.jpg appended to video mediaId.
## Documents
URL format: https://static.wixstatic.com/ugd/{mediaId}
## Audio
URL format: https://static.wixstatic.com/mp3/{mediaId}
## Limits
- Max dimension: 5000px per side
- WebP max: 16,383px per side
- Images are not upscaled beyond original size
- Transformations only work with public Wix-hosted media
Implementation Plan
Phase 1: Package Scaffolding
- Create
packages/wix-media/ - Add
@wix/mediadependency - Add
plugin.yaml, config files
Phase 2: Media Service
- Implement
WixMediaServicewrapping@wix/mediafiles/folders APIs - List all public files with pagination (filter out private/state checks)
- List folders (for metadata/tagging)
- Slug generation from display names (with dedup suffix)
Phase 3: Index Generator (agent-kit reference)
- Implement
generateMediaReferences— thereferenceshandler in plugin.yaml - Runs during
jay agent-kit(not setup) alongside other reference generators - Scans all public media from Wix Media Manager
- Generates
MEDIA-INDEX.mdwith flat table (slug, display name, type, dimensions, labels, folder, URL) - Generates
INSTRUCTIONS.mdwith usage guide and transformation reference - Output goes to
agent-kit/media/like other generated references
Phase 4: CLI Commands
upload-publiccommand — scanpublic/, filename-based tracking via.wix-media-uploads.json, import to Wix, regenerate indexrebuild-indexcommand — regenerate agent-kit media index from current Wix Media Manager state- Uses
makeCliCommand+.jay-commandmetadata pattern - Invoked via
jay-stack run wix-media/upload-public
Phase 5: Setup Integration
- Wire
setuphandler for validation (check Wix Media Manager access) - Reference generation happens in agent-kit stage, not setup
Phase 6: Example Integration
- Add to whisky-exchange or store example
- Upload existing
public/media to Wix viajay-stack run wix-media/upload-public - Run
jay agent-kitand verify MEDIA-INDEX.md is generated with correct slugs, URLs, and metadata - Update existing jay-html templates to use Wix media URLs (with transformations) instead of local
public/paths - Verify images render correctly with fit/fill/crop transformations at various sizes
Trade-offs
| Decision | Benefit | Cost |
|---|---|---|
| URL-based import (not binary) | Uses Wix's importFile API directly | Local files need to be publicly accessible during upload |
| Filename-based dedup | Simple, easy to inspect and edit | Renamed files re-upload |
| Flat index with folder as metadata | Simple for agent to search, all media in one table | Loses folder hierarchy |
| Slug-based agent references | Human-readable, agent-friendly | Not guaranteed unique, needs suffix dedup |
| Upload as CLI command (not setup) | Explicit, controlled, no accidental uploads | Extra step in workflow |
| Exclude private files | Index only has usable media | Agent can't reference private media |
| Upload UI component in separate plugin | Clean separation: tooling vs runtime | Two packages to maintain |
Design Revision: Validation-Driven Media
Motivation
The original design has components pre-formatting URLs with optimization parameters (e.g., thumbnail_50x50 uses formatWixMediaUrl(id, url, {w:50, h:50})). This hides image optimization from the designer agent — the agent sees a {thumbnail_50x50} binding and has no control over sizing, quality, or format. It also means every desired size variant needs a dedicated contract tag, which doesn't scale.
Jay Framework 0.18.4 introduces pluggable validation (DL#145). Instead of baking optimization into components, we:
- Components emit raw base URLs —
https://static.wixstatic.com/media/{id}, no resize params - The designer agent adds optimization params directly in templates —
{media.url}/v1/fill/w_400,h_300/file.webp - A validator catches when optimization is missing, pointing the agent to docs
This gives the designer full control over image sizing per usage context (hero vs. thumbnail vs. card) without needing dedicated contract tags for each.
Dev-Only Plugin
With the service removed and no runtime components, wix-media becomes a dev-only plugin:
- Validator runs during
jay-stack validate - MEDIA-INDEX.md generated during
jay-stack agent-kit - Upload CLI is a manual dev workflow
- Setup runs during
jay-stack setup
No init.ts, no client bundle, no production footprint. Listed in devDependencies.
Revised Plugin Structure
packages/wix-media/
├── lib/
│ ├── index.ts # Server exports (setup, commands, validator)
│ ├── setup.ts # Setup handler (validation) + reference generation
│ ├── validators/
│ │ └── media-validator.ts # Jay-HTML validator (DL#145)
│ ├── services/
│ │ └── wix-media-service.ts # Internal Wix Media Manager API wrapper (used by commands)
│ ├── commands/
│ │ ├── upload-public.ts # CLI command: upload public/ files to Wix
│ │ └── rebuild-index.ts # CLI command: rebuild media index
│ └── index-generator.ts # Generate MEDIA-INDEX.md
├── agent-kit/
│ └── designer/
│ └── wix-media.md # Designer docs: URL transformations reference
├── plugin.yaml
├── upload-public.jay-command
├── rebuild-index.jay-command
├── package.json
├── tsconfig.json
└── vite.config.ts
Removed from original: init.ts, index.client.ts, service marker exports.
Revised plugin.yaml
name: wix-media
validators:
- name: media-optimization
handler: validate
description: Validates Wix media URLs have optimization parameters
commands:
- name: upload-public
command: upload-public.jay-command
- name: rebuild-index
command: rebuild-index.jay-command
setup:
handler: setupWixMedia
references: generateWixMediaReferences
description: Validates Wix Media Manager access, generates agent-kit media index
No services: or init: sections.
Validator Design
Implementation
The validator exports validate: JayHtmlValidatorFn and uses framework utilities:
walkElements,resolveBindingfrom@jay-framework/compiler-sharedparseTemplatePartsfrom@jay-framework/compiler-jay-html
It walks all elements, inspecting src attributes on <img>, <video>, <source>, and poster on <video>.
Rule A: Hardcoded wix URL without optimization
<!-- ❌ ERROR -->
<img src="https://static.wixstatic.com/media/abc123" />
<!-- ✅ OK -->
<img src="https://static.wixstatic.com/media/abc123/v1/fill/w_400,h_300/file.jpg" />
Detection: static template part contains static.wixstatic.com/media/ but no /v1/ following the media ID.
Message: "Wix media URL missing image optimization parameters. See agent-kit/wix-media.md for transformation reference."
Rule B: Binding to wix-image tag without optimization
<!-- ❌ ERROR -->
<img src="{mainMedia.url}" />
<!-- ✅ OK -->
<img src="{mainMedia.url}/v1/fill/w_400,h_300/file.jpg" />
Detection: parseTemplateParts splits the attribute value → resolve each binding via resolveBinding → check if the resolved tag has meta.mediaType === 'wix-image' → check whether the static part following the binding contains /v1/.
Message: "Image binding '{path}' produces a Wix media URL but no optimization parameters are applied. See agent-kit/wix-media.md for transformation reference."
Rule C: Local image reference
<!-- ❌ ERROR -->
<img src="/images/logo.png" />
Detection: fully static src starts with / and has an image file extension (jpg, jpeg, png, gif, webp, svg, bmp, ico).
Message: "Local image reference — upload to Wix Media Manager and use a Wix media URL with optimization parameters. See agent-kit/wix-media.md."
Contract Tag Metadata
To enable Rule B, contract tags that produce wix image URLs need meta: {mediaType: wix-image}. The validator uses resolveBinding to check this metadata at build time.
Example change in media.jay-contract:
# Before
- { tag: url, type: data, dataType: string, description: Media Url }
- { tag: thumbnail_50x50, type: data, dataType: string, description: Media Thumbnail Url }
# After
- { tag: url, type: data, dataType: string, description: Media Url, meta: { mediaType: wix-image } }
# thumbnail_50x50 removed — designer controls sizing via URL params in template
Contracts to update
| Package | Contract | Tags getting meta: {mediaType: wix-image} |
Tags removed |
|---|---|---|---|
| wix-stores-v1 | media | url |
thumbnail_50x50 |
| wix-stores-v1 | product-card | mainMedia.url, thumbnail.url |
|
| wix-stores-v1 | category-page | media.mainMedia.url, media.items.url, media.items.thumbnail.url |
|
| wix-stores-v1 | category-list | imageUrl |
|
| wix-stores | media | url |
thumbnail_50x50 |
| wix-stores | (mirror v1 where applicable) |
Component Changes
Components stop producing pre-optimized URLs. They still convert wix:// protocol URLs to https://static.wixstatic.com/media/{id} base URLs (via formatWixMediaUrl(id, url) without resize params).
| Package | File | Change |
|---|---|---|
| wix-stores | components/product-page.ts | Remove resize params from formatWixMediaUrl calls, remove thumbnail_50x50 from view state |
| wix-stores | utils/product-mapper.ts | Remove {w: 300, h: 300} resize from formatWixMediaUrl call |
| wix-stores-v1 | components/product-page.ts | Remove thumbnail_50x50 from view state |
| wix-cart | contexts/cart-helpers.ts | No change needed (already no resize) |
| wix-data | components/collection-list.ts | No change needed (already no resize) |
Example Template Changes
Templates using {media.thumbnail_50x50} switch to {media.url} with optimization appended:
<!-- Before -->
<img src="{media.thumbnail_50x50}" alt="Product thumbnail" />
<!-- After -->
<img src="{media.url}/v1/fill/w_50,h_50/file.jpg" alt="Product thumbnail" />
All <img src="{...url}"> bindings need optimization params — the validator catches missing ones.
Revised Trade-offs
| Decision | Benefit | Cost |
|---|---|---|
| Validation-driven optimization | Designer controls sizing per context; no tag proliferation | Agent must learn URL transform syntax |
| Dev-only plugin | Zero production footprint; simpler deployment | Plugin not available at runtime |
Contract tag metadata (meta.mediaType) |
Validator detects wix-image bindings at build time | All image URL tags across packages need meta |
Drop pre-optimized tags (thumbnail_50x50) |
Fewer tags, designer picks dimensions | Breaking change for existing templates |
| Raw base URLs from components | Single url tag serves all size needs |
Templates are slightly more verbose |
Revised Implementation Plan
Phase 1: Validator + Dev-Only Conversion
- Create
lib/validators/media-validator.tswith Rules A, B, C - Update
plugin.yaml— addvalidators:, removeservices: - Delete
init.ts,index.client.ts - Update
index.ts— export validator, remove service marker - Simplify
vite.config.ts— remove client build target - Update
package.json— remove./clientexport, add compiler deps
Phase 2: Contract Tag Metadata + Drop Tags
- Add
meta: {mediaType: wix-image}to image URL tags (see table above) - Remove
thumbnail_50x50from media contracts - Regenerate
.jay-contract.d.tsfiles
Phase 3: Simplify Components
- Remove resize params from
formatWixMediaUrlcalls - Remove
thumbnail_50x50from view state construction - Keep base URL conversion (
wix://→static.wixstatic.com)
Phase 4: Update Examples
- Replace
{media.thumbnail_50x50}with{media.url}/v1/fill/w_50,h_50/file.jpg - Add optimization params to all
<img src="{...url}">bindings - Move
@jay-framework/wix-mediatodevDependencies - Run
jay-stack validateto verify
Tests
Unit tests in packages/wix-media/test/validators/media-validator.test.ts. Each test builds a JayHtmlValidationContext with a parsed HTML body (via node-html-parser's parse()) and a contract with appropriate tags/meta, calls validate(), and asserts on findings.
Shared test helpers
import { parse } from 'node-html-parser';
import { validate } from '../../lib/validators/media-validator.js';
import type { JayHtmlValidationContext } from '@jay-framework/compiler-shared';
function makeContext(
html: string,
contract?: JayHtmlValidationContext['contract'],
headlessImports?: JayHtmlValidationContext['headlessImports'],
): JayHtmlValidationContext {
return {
body: parse(html),
filePath: 'test/page.jay-html',
projectRoot: '/test',
contract,
headlessImports: headlessImports ?? [],
};
}
// Contract with a wix-image url tag inside a sub-contract
const mediaContract: JayHtmlValidationContext['contract'] = {
name: 'product-page',
tags: [
{
tag: 'mainMedia',
type: [4], // sub-contract
tags: [
{ tag: 'url', type: [1], meta: { mediaType: 'wix-image' } },
{ tag: 'altText', type: [1] },
],
},
{ tag: 'title', type: [1] },
],
};
Rule A: Hardcoded wix URL without optimization
it('flags hardcoded wix URL without /v1/ optimization', async () => {
const ctx = makeContext('<img src="https://static.wixstatic.com/media/abc123" alt="photo" />');
const findings = await validate(ctx);
expect(findings).toEqual([
expect.objectContaining({
severity: 'error',
message: expect.stringContaining('optimization parameters'),
element: expect.stringContaining('img'),
}),
]);
});
it('passes hardcoded wix URL with /v1/ optimization', async () => {
const ctx = makeContext(
'<img src="https://static.wixstatic.com/media/abc123/v1/fill/w_400,h_300/file.jpg" alt="photo" />',
);
const findings = await validate(ctx);
expect(findings).toEqual([]);
});
Rule B: Binding to wix-image tag without optimization
it('flags wix-image binding without optimization params in template', async () => {
const ctx = makeContext('<img src="{mainMedia.url}" alt="product" />', mediaContract);
const findings = await validate(ctx);
expect(findings).toEqual([
expect.objectContaining({
severity: 'error',
message: expect.stringContaining('mainMedia.url'),
}),
]);
});
it('passes wix-image binding with optimization params appended', async () => {
const ctx = makeContext(
'<img src="{mainMedia.url}/v1/fill/w_400,h_300/file.jpg" alt="product" />',
mediaContract,
);
const findings = await validate(ctx);
expect(findings).toEqual([]);
});
it('does not flag binding to non-wix-image tag', async () => {
const ctx = makeContext('<img src="{mainMedia.altText}" alt="product" />', mediaContract);
const findings = await validate(ctx);
// altText has no meta.mediaType — not a wix image, no error
expect(findings).toEqual([]);
});
Rule C: Local image reference
it('flags local /public/ image path', async () => {
const ctx = makeContext('<img src="/images/logo.png" alt="logo" />');
const findings = await validate(ctx);
expect(findings).toEqual([
expect.objectContaining({
severity: 'error',
message: expect.stringContaining('Local image'),
}),
]);
});
it('flags local image with various extensions', async () => {
const ctx = makeContext('<img src="/banner.webp" alt="banner" />');
const findings = await validate(ctx);
expect(findings).toHaveLength(1);
});
it('does not flag non-image local paths', async () => {
const ctx = makeContext('<a href="/about">About</a>');
const findings = await validate(ctx);
expect(findings).toEqual([]);
});
Edge cases
it('handles multiple errors in same file', async () => {
const ctx = makeContext(
`<div>
<img src="https://static.wixstatic.com/media/abc123" alt="a" />
<img src="/images/logo.png" alt="b" />
<img src="{mainMedia.url}" alt="c" />
</div>`,
mediaContract,
);
const findings = await validate(ctx);
expect(findings).toHaveLength(3);
});
it('checks video poster attribute', async () => {
const ctx = makeContext(
'<video poster="https://static.wixstatic.com/media/vid123" src="video.mp4"></video>',
);
const findings = await validate(ctx);
expect(findings).toEqual([
expect.objectContaining({
severity: 'error',
message: expect.stringContaining('optimization parameters'),
}),
]);
});
it('returns no findings for fully optimized page', async () => {
const ctx = makeContext(
`<div>
<img src="{mainMedia.url}/v1/fill/w_800,h_600/file.webp" alt="hero" />
<img src="https://static.wixstatic.com/media/abc123/v1/fit/w_100,h_100/file.jpg" alt="thumb" />
<p>No images here</p>
</div>`,
mediaContract,
);
const findings = await validate(ctx);
expect(findings).toEqual([]);
});
Framework Issue: Validator handler loading for npm packages ✅ Fixed
Problem: jay-stack-cli loads validator handlers incorrectly for npm-published plugins. The validator loading code at jay-stack-cli/dist/index.js:4277 resolves the handler as a file path relative to the plugin directory:
// Current (broken for npm packages):
const handlerPath = path.resolve(plugin.pluginPath, validatorDef.handler);
const handlerModule = await import(handlerPath);
validatorFn = handlerModule.validate;
With handler: validate in plugin.yaml and a plugin at node_modules/@jay-framework/wix-media, this resolves to node_modules/@jay-framework/wix-media/validate — which doesn't exist.
Expected: Should match the loadHandler pattern used by setup/references handlers in stack-server-runtime/dist/index.js:2956-2985:
// For npm packages (plugin.isLocal === false):
const module = await import(plugin.packageName); // e.g. import('@jay-framework/wix-media')
const validatorFn = module[validatorDef.handler]; // e.g. module.validate
// For local plugins (plugin.isLocal === true):
const handlerPath = path.resolve(plugin.pluginPath, validatorDef.handler);
const module = await import(handlerPath);
const validatorFn = module.validate;
Impact: Plugin validators declared in plugin.yaml with handler: validate (an exported function name) work for local plugins but silently fail for npm packages. The error is caught and reported as "Failed to load validator", but in practice the validator just doesn't run.
Fix location: jay-stack-cli, in the jay-stack validate command's validator loading loop. Needs the same local-vs-npm branching that loadHandler in stack-server-runtime already implements.
Workaround: Setting handler: ./dist/index.js (a relative file path) works but is fragile and inconsistent with how setup handlers are declared.
Framework Issue: Validation context doesn't resolve link: sub-contracts ✅ Fixed
Problem: The validation context passed to plugin validators contains contracts with unresolved link: references. Sub-contract tags with link: ./media-gallery have NO tags array — only the link string. This means resolveBinding() and walkElements() can't traverse through linked sub-contracts to reach nested tags.
Example: product-page contract has:
mediaGallery (link: ./media-gallery)
└→ selectedMedia (link: ./media)
└→ url (meta: {mediaType: wix-image}) ← validator can't reach this
Binding {productPage.mediaGallery.selectedMedia.url} — the validator resolves mediaGallery but finds link: './media-gallery' with no tags, so it stops. The meta: {mediaType: wix-image} on the url tag is invisible.
Where it works correctly: The tag coverage analysis in jay-stack validate DOES resolve links (it reports all 55 tags including linked ones). The resolveHeadlessInstances function at compiler-jay-html:29864-29876 also resolves links using loadLinkedContract(). Only the validation context construction skips link resolution.
Fix location: jay-stack-cli, validation context construction at line 4310-4324. Before passing parsed.contract and parsed.headlessImports[].contract to validators, resolve all link: references inline using loadLinkedContract() — replacing link: with the linked contract's tags. The importResolver is available in the validation code path.
Impact: Rule B (binding to wix-image tag) only works for contracts with inline tags. Contracts connected via link: (which is the common case for wix-stores media) are invisible to validators. Rules A and C (static URL / local image) work fine since they don't depend on contract resolution.
Framework Issue: walkElements doesn't resolve headless-keyed forEach paths ✅ Fixed
Problem: walkElements in compiler-shared handles forEach by calling resolveTagPath(forEach, currentScope.tags). When the forEach value is headless-keyed (e.g., forEach="productSearch.searchResults"), it tries to find productSearch in the current scope — which is empty (no page contract). The forEach scope is never entered, so all bindings inside it (like {thumbnail.url}) are resolved against an empty scope. The validator can't detect meta.mediaType and silently skips them.
Where it works: walkElements correctly handles <jay:componentName> elements by looking up the headless import's contract (line 1637-1647). But it does NOT apply the same headless-key lookup for forEach attributes (line 1649-1657).
Expected behavior: When resolveTagPath fails on a forEach value, walkElements should check if the first segment matches a headless import key, then resolve the remaining path against that import's contract — exactly like it does for <jay:...> elements.
Fix location: compiler-shared, doWalk function, forEach handling (~line 1649-1657). After resolveTagPath returns null, try headless key lookup:
const forEach = el.getAttribute?.('forEach');
if (forEach) {
let arrayTag = resolveTagPath(forEach, currentScope.tags);
// Headless-keyed forEach: "productSearch.searchResults"
if (!arrayTag) {
const segments = forEach.split('.');
const headless = ctx.headlessImports.find((h) => h.key === segments[0] && h.contract);
if (headless?.contract) {
arrayTag = resolveTagPath(segments.slice(1).join('.'), headless.contract.tags);
}
}
if (arrayTag?.tags) {
currentScope = { tags: arrayTag.tags, parent: currentScope };
}
}
Impact: All plugin validators that rely on walkElements + resolveBinding inside forEach scopes are broken for headless-keyed paths. This affects any validator checking bindings inside product grids, search results, cart line items, etc.
Verification Criteria
packages/wix-mediabuilds (server only, no client)packages/wix-stores-v1andpackages/wix-storesbuild with updated contracts- All validator tests pass — rules A, B, C each have positive and negative cases
jay-stack validateon examples flags unoptimized URLs, passes optimized ones- wix-media is in
devDependenciesonly — not in production bundle - No
thumbnail_50x50references remain in contracts or components
Implementation Results
Rule D: Unencoded commas in srcset URLs
Problem discovered: The HTML srcset attribute uses commas to separate image candidates per the spec. Wix media optimization parameters also use commas to separate values (w_400,h_300). When a Wix media URL appears inside srcset, the browser splits the URL at the parameter comma, breaking the image URL.
Example — broken:
<img
src="{url}/v1/fill/w_400,h_400/file.webp"
srcset="{url}/v1/fill/w_300,h_300/file.webp 300w, {url}/v1/fill/w_600,h_600/file.webp 600w"
/>
The browser parses the srcset as four entries: {url}/v1/fill/w_300, h_300/file.webp 300w, {url}/v1/fill/w_600, h_600/file.webp 600w — all broken.
Fix — encode commas as %2C inside srcset URLs:
<img
src="{url}/v1/fill/w_400,h_400/file.webp"
srcset="{url}/v1/fill/w_300%2Ch_300/file.webp 300w, {url}/v1/fill/w_600%2Ch_600/file.webp 600w"
/>
The browser decodes %2C back to , when fetching. The src attribute does not need encoding — only srcset.
Changes:
| File | Change |
|---|---|
lib/validators/media-validator.ts |
Added Rule D: detects unencoded commas in /v1/ params inside srcset attributes. Regex: /\/v1\/[^/]+\/[^/]*[a-z]_\d+,[a-z]_\d+/ matching patterns like w_400,h_300 |
test/validators/media-validator.test.ts |
Added 3 tests: flags unencoded commas in srcset, passes %2C-encoded srcset, does not flag commas in src |
agent-kit/designer/wix-media.md |
Updated Responsive Images section: all srcset examples use %2C, added explanation of why encoding is required |
Validator message: "Wix media URL in srcset has unencoded commas in parameters (e.g. w_400,h_300). The srcset attribute uses commas to separate image candidates, so commas inside URLs must be encoded as %2C (e.g. w_400%2Ch_300). See agent-kit/wix-media.md."
Updated error message for Rule C
Rule C's error message now includes the specific file path and the CLI command to upload:
Local image reference '/images/logo.png' — run `jay-stack-cli run wix-media/upload-public` to upload local images to Wix Media Manager, then use the Wix media URL with optimization parameters. See agent-kit/wix-media.md.
Validation sweep across all examples
Ran yarn validate on all 6 examples (cms, store-light, store, studio-store, whisky-exchange, whisky-store) and fixed all errors and warnings:
- Core errors: Fixed data field mismatches (cms), ref type mismatches
<div>→<button>/<input>(store, whisky-store) - A11y errors: Added
aria-labelto unlabeled inputs/selects/buttons, added missingaltattributes - SEO warnings: Added
<title>,<meta description>,<link canonical>,<main>landmarks, fixed heading hierarchy, addedwidth/height/loading/fetchpriorityto images - Wix-media errors: Applied
/v1/fill/w_WW%2Ch_HH/file.webpoptimization to all Wix media URL bindings; uploaded local static images viajay-stack-cli run wix-media/upload-publicand replaced local paths with Wix media URLs - All 6 examples pass validation (store has 0 errors after uploading local Figma-exported PNGs)
Rule E: Missing srcset on responsive images
Problem: Images ≥400px wide served without srcset force the browser to download the same large file regardless of viewport size, wasting bandwidth on mobile and smaller screens.
Detection: For each <img> with a src containing /v1/ transform parameters: extract the w_ value from the static parts. If w_ ≥ 400 and no srcset attribute is present → emit warning. Works for both fully static Wix URLs and binding+static combinations (e.g. {url}/v1/fill/w_800,h_600/file.webp).
Severity: warning (not error) — the image still works without srcset, this is a performance optimization.
Does not flag: images with w_ < 400 (thumbnails/icons), images that already have srcset, <video>/<source> elements, images without /v1/ transforms (caught by Rules A/B).
Message: "This image is ≥400px wide but has no srcset attribute. Add srcset with sizes for common breakpoints to serve appropriately sized images across devices. See agent-kit/wix-media.md for the responsive image pattern."
Changes:
| File | Change |
|---|---|
lib/validators/media-validator.ts |
Added WIDTH_PARAM_RE (/w_(\d+)/), SRCSET_MIN_WIDTH_THRESHOLD (400), and Rule E check after the existing src/srcset loops |
test/validators/media-validator.test.ts |
Added 6 tests: flags w_800, flags w_600 binding, passes with srcset, passes below threshold, skips video, flags at exactly 400. Updated 4 existing tests to filter by severity === 'error' so the new warning doesn't break them |
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.