Keyed Headless Component Props
Design Log #156 — Keyed Headless Component Props
Written for AI agents. See Log Methodology Note below for details.
Background
Jay Stack has three mechanisms for passing data into headless components, each covering a different case:
Route params via
<script type="application/jay-params">(DL#113) — static YAML values that flow aspageParamsto all components. Used for static route overrides like/products/ceramic-flower-vaseproviding{ slug: 'ceramic-flower-vase' }.Instance props via
<jay:component-name attrName="value">— attributes on inline instances, resolved against the page's binding context. Used for per-instance configuration.Keyed headless components via
<script type="application/jay-headless" key="name">— no props mechanism. The component receives onlypageProps(route params). Its ViewState merges into the page namespace.
Related
- DL#84 — Headless component props and repeater support
- DL#113 — Explicit route params for static overrides
- DL#124 — Contract props and params consistency
- DL#152 — Phase-aware contract props
- DL#155 — Markdown plugin (motivating use case)
Problem
Keyed headless components cannot receive per-component configuration from the page. The markdown-pages component (DL#155) needs a contentDir prop to know which directory to scan. Today, the only way to pass data is through route params (jay-params), but those are page-wide — not scoped to a specific component.
Current mechanisms side by side
jay-params instance props keyed headless
Where declared: <script> in head <jay:xxx attr="val"> <script> in head
Scope: page-wide per-instance per-component (but no props)
Format: YAML body HTML attributes —
Flows to component as: pageParams normalizedProps pageProps only
The gap: keyed headless components (key="name") have no way to receive component-specific props.
Observation: jay-params is a special case of headless props
jay-params provides static values for route param matching. But conceptually it's the same as "static props for the page component." If keyed headless components had props, jay-params would be the props for the page component itself (which is implicitly keyed).
Questions & Answers
Q1: Should we add a props attribute to <script type="application/jay-headless">, or extend jay-params to be per-component?
A1: Extend the headless script tag with a YAML body for props. This mirrors jay-params (YAML in a script tag) and jay-data (YAML contract in a script tag). The YAML body is more readable than a JSON attribute for multi-line values.
Q2: Should we consolidate jay-params into this mechanism?
A2: Yes. jay-params becomes props on the page's implicit headless component. Instead of a separate <script type="application/jay-params">, the params are declared directly in <script type="application/jay-data"> (the page's contract script tag), or we keep jay-params as sugar for backward compatibility but treat it as page-level props internally.
Q3: What about the contract's props section — how does it validate?
A3: The contract already has a props section that declares expected prop names and types. The validate command (DL#124) should check that props provided in the headless script tag match the contract's declaration.
Design
Props on keyed headless components via YAML body
Add YAML body support to <script type="application/jay-headless">:
<script
type="application/jay-headless"
plugin="@jay-framework/markdown"
contract="markdown-pages"
key="post"
>
contentDir: ./content
</script>
The YAML body contains prop values. These flow to the component's withSlowlyRender / withFastRender as part of props.
Multi-value example:
<script
type="application/jay-headless"
plugin="@jay-framework/markdown"
contract="markdown-pages"
key="post"
>
contentDir: ./content/blog
defaultAuthor: Jane Doe
dateFormat: YYYY-MM-DD
</script>
Consolidating jay-params
The page component has its own page.ts — it doesn't need external values from the template.
The only components that need template-provided values are reusable headless components — they're generic and can't hardcode per-page configuration. This includes both props (like contentDir for the markdown plugin) and params (like slug for a product page override).
jay-params (DL#113) was a page-wide mechanism, but the values it provides actually belong to a specific headless component. A static override page at /products/ceramic-flower-vase/ provides slug: ceramic-flower-vase for the wix-stores/product-page component — not for the page itself.
Before (jay-params — page-wide, no component association):
<script type="application/jay-params">
slug: ceramic-flower-vase
</script>
<script
type="application/jay-headless"
plugin="@jay-framework/wix-stores"
contract="product-page"
key="productPage"
></script>
After — values on the component that needs them:
<script
type="application/jay-headless"
plugin="@jay-framework/wix-stores"
contract="product-page"
key="productPage"
>
slug: ceramic-flower-vase
</script>
Props example — same mechanism:
<script
type="application/jay-headless"
plugin="@jay-framework/markdown"
contract="markdown-pages"
key="post"
>
contentDir: ./content
</script>
One mechanism: YAML body in the headless script tag provides static values (params and props) for that specific component. No page.ts or separate script tag needed.
Migration: jay-params validator rule
Add a jay-html validation rule that detects <script type="application/jay-params"> and tells the agent how to migrate:
⚠ <script type="application/jay-params"> is deprecated.
Move the values into the YAML body of the headless component that uses them:
<script type="application/jay-headless" plugin="..." contract="..." key="...">
slug: ceramic-flower-vase
</script>
The validator should identify which headless component on the page has a contract with matching param names, and include that in the suggestion.
Implementation: Add to the core jay-stack validation rules (not a plugin validator) since this is a framework-level concern.
Route scanner changes
The route scanner currently reads jay-params to extract inferredParams. After this change:
- Remove
jay-paramsparsing fromroute-scanner.ts - Instead, read the YAML body of
jay-headlessscript tags - Extract param values from the headless component whose contract declares matching params
Parser changes
File: packages/compiler/compiler-jay-html/lib/jay-target/jay-html-parser.ts
In parseHeadlessImports() (line 622):
- After extracting
plugin,contract, andkeyattributes, also extract the script tag's text content - If non-empty, parse as YAML →
Record<string, string> - Store as
headlessPropson theJayHeadlessImportsresult - If no text content,
headlessPropsis empty (backward compatible)
// In parseHeadlessImports:
const propsYaml = element.textContent?.trim();
let headlessProps: Record<string, string> = {};
if (propsYaml) {
headlessProps = yaml.load(propsYaml) as Record<string, string>;
}
File: packages/compiler/compiler-jay-html/lib/jay-target/jay-html-source-file.ts
Add headlessProps?: Record<string, string> to JayHeadlessImports interface.
Runtime changes
File: packages/jay-stack/stack-server-runtime/lib/slowly-changing-runner.ts
When running slow render for keyed headless components, pass headlessProps merged with pageProps:
// Currently (line ~46):
const slowResult = await comp.slowlyRender({ ...pageProps, ...pageParams });
// After:
const slowResult = await comp.slowlyRender({ ...pageProps, ...pageParams, ...headlessProps });
The headless props override page props for the same key, allowing per-component customization.
Validation changes
File: packages/jay-stack/plugin-validator/lib/check-component-contract.ts
Extend checkComponentPropsAndParams to also validate keyed headless props against the contract's props section. If the contract declares props: [{ name: contentDir, kind: required }], the headless script tag must provide contentDir.
File: packages/jay-stack/stack-cli/lib/validate.ts
In the validation pipeline, when validating headless imports, check that provided props match the contract's props declarations.
Data flow after this change
instance props keyed headless body
Where declared: <jay:xxx attr="val"> YAML body in <script jay-headless>
Scope: per-instance per-component
Format: HTML attributes YAML body
Flows to component as: normalizedProps merged into props
Validated against: contract props contract props
Both provide values to reusable headless components — instance props for inline instances, YAML body for keyed page-level components. The page component itself needs neither.
Implementation Plan
Phase 1: Parser
- Update
parseHeadlessImportsto extract YAML body from headless script tags - Add
headlessPropstoJayHeadlessImportsinterface - Pass props through to the stack-cli validation context
Phase 2: Remove jay-params
- Remove
jay-paramsparsing from route scanner - Update route scanner to read params from headless script tag YAML bodies
- Migrate existing pages that use
jay-params— move values into the relevant headless script tag
Phase 3: Runtime
- Update
slowly-changing-runner.tsto pass headless props to component - Update fast render runner similarly
- Update client hydration to receive props
Phase 4: Validation
- Update
checkComponentPropsAndParamsfor keyed headless prop validation - Update
validate.tsto check headless props against contract - Add jay-html validation rule that flags
<script type="application/jay-params">with migration instructions
Phase 5: Smoke test
- Add an example page in
examples/jay-stack/smoke-test/that uses a keyed headless component with props via YAML body - Verify props flow through to the component's slow render
- Verify the dev server renders the page correctly
Phase 6: Migration
- Update existing pages that use
jay-paramsto usejay-databody - Update agent-kit documentation
Trade-offs
| Approach | Pro | Con |
|---|---|---|
| YAML body in script tag (chosen) | Consistent with jay-params/jay-data, readable, multi-line | Requires parser change |
JSON props attribute |
Simple, single attribute | Hard to read for multi-value, escaping issues |
| Plugin setup config | No parser change | Per-plugin, not per-page; less flexible |
Verification Criteria
<script type="application/jay-headless" key="x">contentDir: ./blog</script>parses correctly- Props flow to component's
slowlyRenderas part ofpropsparameter - Empty body (no props) continues to work unchanged
- Contract
propsvalidation catches missing required props <script type="application/jay-params">produces a validation warning with migration instructions- Smoke test in
examples/jay-stack/smoke-test/demonstrates a keyed headless component receiving props via YAML body, rendering correctly in the dev server
Implementation Results
Files changed
Parser:
compiler-jay-html/lib/jay-target/jay-html-source-file.ts— addedheadlessProps?: Record<string, string>toJayHeadlessImportscompiler-jay-html/lib/jay-target/jay-html-parser.ts—parseHeadlessImports()extracts YAML body viadedentYaml, parses withjs-yaml, stores asheadlessProps. Error message includes contract name for actionability.
Runtime (dev server):
stack-server-runtime/lib/load-page-parts.ts— addedheadlessPropstoDevServerPagePartinterface; passed fromheadlessImportwhen creating partsstack-server-runtime/lib/slowly-changing-runner.ts— mergespart.headlessPropsintopartPropsfor slow renderstack-server-runtime/lib/fast-changing-runner.ts— same merge for fast render
Runtime (production server):
production-server/lib/builder/load-production-parts.ts— addedheadlessPropstoHeadlessModuleInfo,PagePartsConfig,loadProductionPageParts(),buildPagePartsConfig(), andloadPagePartsFromConfig(). Props serialize throughpage-parts.jsonand reconstruct at serve time.
Route scanner:
route-scanner/lib/route-scanner.ts— replacedparseJayParams()withparseHeadlessProps(). Reads YAML bodies from<script type="application/jay-headless">tags, merges intoinferredParams. Emits deprecation warning when<script type="application/jay-params">is detected.
Validation:
stack-cli/lib/validate.ts— replacedextractJayParams()withextractHeadlessPropsParamNames()which reads fromparsedFile.headlessImports[].headlessProps. UpdatedcheckRouteParams()signature (removedjayHtmlContentparam). Addedjay-paramsdeprecation warning in validation pipeline with pointer toagent-kit/developer/routing.md.
Tests:
compiler-jay-html/test/jay-target/parse-jay-file.unit.test.ts— 3 new tests: YAML body parsing, empty body, invalid YAML. All 670 compiler tests pass.stack-cli/test/validate.test.ts— replacedextractJayParamstests withextractHeadlessPropsParamNamestests. Updated route param warning message. Updated static override test to expect warning (deprecated fixture).route-scanner/test/route-scanner.test.ts— updated test names and fixture files to use headless script tag bodies instead of jay-params.
Smoke test:
examples/jay-stack/smoke-test/src/pages/headless-props/page.jay-html— new page with keyed headless component receivingitemId: from-propsvia YAML bodyexamples/jay-stack/smoke-test/src/pages/page.jay-html— added link to headless-props pageexamples/jay-stack/smoke-test/test/smoke.test.ts— added test in dev and production sections verifying "Widget from-props" renders
Agent-kit docs:
stack-cli/agent-kit-template/developer/routing.md— replaced jay-params section with headless component props documentationstack-cli/agent-kit-template/designer/routing.md— same
Deviations from design
- Client hydration (Phase 3, item 3) was not needed — headless props are static values consumed at slow/fast render time. The interactive phase accesses them through the existing
propsparameter from the builder chain. - Contract props validation for keyed headless (Phase 4, items 1-2) deferred — the existing
checkRouteParamscovers param availability. Full props-vs-contract validation for keyed headless can be added incrementally. - Phase 6 migration was trivial — no existing pages used
jay-paramsin code (only in agent-kit documentation, which was updated).
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.