Params Binding In Templates
DL#163 — Built-in Bindings and Field Comparison in Templates
Written for AI agents. See Log Methodology Note below for details.
Background
Route params (from [slug], [role] directory segments) are available to page components via props.slug, props.role. But nested components (headfull FS and instance-based headless) receive props from the template, not from the route directly.
Currently, to pass a route param to a nested component, the designer needs the developer to create a page.ts that maps params to ViewState, then binds ViewState to the component prop:
route param → page.ts → ViewState → template binding → component prop
This creates unnecessary coupling. Many pages exist solely to pass params through. The designer role cannot build these pages independently.
A common pattern — navigation menus with an active item — requires both URL awareness and string comparison. Today this is impossible without page.ts boilerplate.
Related Design Logs
- DL#162 — Structural headfull components (no code file)
- DL#144 — Per-route server elements (route param handling)
- DL#109 — Headless instance server rendering (instance props)
Problem
A docs site has routes like /docs/designer/[slug], /docs/developer/[slug]. A sidebar component needs to highlight the active menu item. Today this requires:
- A page contract declaring
activeRoleandactivePageas data tags - A
page.tswithwithSlowlyRenderthat mapsprops.slug→ ViewState - Template bindings from ViewState to the sidebar props
- Inside the sidebar, enum-style conditionals against literal values
The page.ts is pure boilerplate:
export const page = makeJayStackComponent<PageContract>().withSlowlyRender(async (props) =>
phaseOutput({ activeRole: 'designer', activePage: props.slug }, {}),
);
Design
1. The jay. namespace — framework-provided bindings
Expose framework-provided values under the jay. prefix in templates. The jay. namespace is consistent with the jay: tag prefix and reads naturally:
<jay:DocsSidebar activeRole="{jay.params.role}" activePage="{jay.params.slug}" />
No page.ts needed. The framework resolves jay.params.role from the URL at render time.
Why jay. and not $
- Reads naturally:
jay.url.path,jay.params.slug - Consistent with existing
jay:ComponentNametag prefix - More extensible:
jay.url,jay.params,jay.query,jay.env - Less cryptic than
$params,$url
Available jay. bindings
| Binding | Value | Phase |
|---|---|---|
jay.params.X |
Route param value from [X] segment |
slow, fast, interactive |
jay.url.path |
Full URL pathname (e.g., /docs/designer/routing) |
slow, fast, interactive |
Future extensions (separate DLs):
| Binding | Value | Phase |
|---|---|---|
jay.query.X |
Query parameter value | fast, interactive only |
jay.url.origin |
URL origin | fast, interactive only |
2. String comparison: right side is a field reference by default
For string-typed tags, the right side of ===/!== resolves as a field by default. String literals require quotes. Enum-typed tags are unchanged — the right side remains a literal variant name.
<!-- String type: bare right side = field reference -->
<a if="url === jay.url.path" class="active-link">...</a>
<a if="url === currentPath">Match two string fields</a>
<!-- String type: quoted right side = literal -->
<a if="url === '/about'">Literal match</a>
<!-- Enum type: unchanged — right side is a variant literal (no quotes needed) -->
<div if="status === active">Active</div>
<div if="type === physical">Ships to your door</div>
The compiler knows the tag type from the contract, so it can distinguish automatically. No breaking change — existing enum comparisons keep working as-is.
3. startsWith operator (^=) for partial path matching
Exact match (===) works for highlighting the current page. But navigation often needs section-level highlighting — /docs/designer should be active when the URL is /docs/designer/routing.
Syntax: ^= (starts with)
<!-- Field right side -->
<a if="jay.url.path ^= sectionUrl" class="section-active">{label}</a>
<!-- Literal right side (quoted) -->
<a if="jay.url.path ^= '/docs/designer'" class="section-active">Designer</a>
^= mirrors the CSS attribute selector [attr^=value] — familiar to designers. ^= is string-only (no enum variant). Same resolution rules: bare = field, quoted = literal.
This is a pure function (string prefix check) — no security implications.
Full operator set for string conditionals
| Operator | Meaning | Example |
|---|---|---|
=== |
Exact match (field) | if="url === jay.url.path" |
=== |
Exact match (literal) | if="url === '/about'" |
!== |
Not equal | if="url !== jay.url.path" |
^= |
Starts with | if="jay.url.path ^= sectionUrl" |
For enum tags: ===/!== right side is always a literal variant (unchanged).
4. Navigation menu pattern
The combination of jay.url.path, field comparison, ^=, and conditional class bindings enables clean active-state highlighting:
Exact page match — highlight the current page link using conditional class:
<a href="{url}" class="nav-link {url === currentPath ? active}">{label}</a>
Section match — highlight the section containing the current page:
<a href="{sectionUrl}" class="section-link {jay.url.path ^= sectionUrl ? active}">{sectionLabel}</a>
Complete sidebar example with both levels:
<!-- sidebar.jay-html — receives currentPath as prop from page -->
<nav>
<div forEach="sections" trackBy="url">
<h3>
<a href="{url}" class="section {currentPath ^= url ? active}">{label}</a>
</h3>
<!-- Page links within section — show only if section is active -->
<div if="currentPath ^= url" forEach="pages" trackBy="url">
<a href="{url}" class="page {url === currentPath ? active}">{label}</a>
</div>
</div>
</nav>
This pattern should be documented in the agent-kit designer guides as the standard way to build navigation with active state.
Phase availability
Route params and URL path are available at all phases:
- Slow (SSG): params are known at build time (from
loadParams), URL path is known from the route - Fast (SSR): params and path are part of the request URL
- Interactive: available from
window.location
Binding jay.params or jay.url.path to a slow-phase prop is safe.
Scope
jay. bindings are available in page templates only, not inside headfull component templates. Headfull components receive data via props — they shouldn't reach into the URL directly. The page passes URL data to components through prop bindings:
<!-- Page template — jay. bindings available -->
<jay:Sidebar currentPath="{jay.url.path}" />
<!-- Inside sidebar.jay-html — no jay. bindings, use props/ViewState -->
<a href="{url}" class="nav-link {url === currentPath ? active}">{label}</a>
Questions
ShouldNormalize to no trailing slash, matching route patterns.jay.url.pathinclude or exclude the trailing slash?Should the right side ofYes — same resolution rules (ViewState, forEach item).===resolve from the same scope as text bindings?Should negatedUse the parenthesized form^=use!(a ^= b)or a dedicated operator?!(a ^= b)— consistent with existing!(a && b)support.
Implementation Plan
Phase 1: Compiler — jay. binding resolution
compiler-jay-html/lib/jay-target/jay-html-compiler.ts and jay-html-compiler-hydrate.ts:
- Recognize
jay.params.Xandjay.url.pathin{binding}expressions - For server element: generate code that reads from
__jay.params.Xor__jay.url.pathon the ViewState - For hydrate: read from the same
__jayfield
compiler-jay-html/lib/jay-target/jay-html-compiler-server.ts:
- Resolve
jay.bindings in prop values for instance and headfull component props
Phase 2: Runtime — populate __jay
stack-server-runtime (slow + fast runners):
- Add
__jay: { params, url: { path } }to the ViewState before rendering
stack-client-runtime:
- Extract
__jayfrom initial ViewState, or derive fromwindow.location
Phase 3: Comparison syntax change and ^= operator
compiler-jay-html/lib/jay-target/jay-html-compiler.ts and hydrate:
- For string-typed tags: bare right side resolves as field, quoted as literal
- For enum-typed tags: unchanged — bare right side remains a variant literal
- The compiler reads tag type from the contract to decide resolution — no migration needed
- Add
^=operator: parseif="a ^= b"(field) andif="a ^= 'literal'"(string) - Generate
a.startsWith(b)in the compiled output - Support negation:
if="!(a ^= b)" - Support
^=in conditional class bindings:class="link {path ^= url ? active}"
Phase 4: Validation
- Validate
jay.params.Xreferences a param name from route segments - Validate
jay.url.pathis used correctly (no typos likejay.url.pathname) - Validate
^=operands are string-typed
Phase 5: Documentation
Agent-kit designer/jay-html-template-syntax.md:
- Document
jay.bindings and available values - Document string field comparison (bare = field, quoted = literal)
- Document
^=starts-with operator
Top-level docs/core/jay-html.md and docs/core/routing.md:
- Document
jay.built-in bindings - Document
jay.paramsandjay.url.pathavailability and phase rules
compiler-jay-html/docs/jay-html-docs.md:
- Document the conditional parsing changes (string vs enum type resolution)
- Document
^=operator compilation
New guide: designer/navigation-patterns.md:
- Document the active-menu pattern using
jay.url.path,===, and^= - Show exact-match (current page) and prefix-match (current section) patterns
- Show both static and forEach-based menu examples
- Show the two-level sidebar pattern (sections + pages)
UI kit plugin: agent-kit/designer/popover-menu.md:
- Add a section showing how to combine
popover-menuwith active-state highlighting - Example: a nav menu where dropdown items highlight the active page using
if="url==={currentPath}" - Cross-reference the framework
navigation-patterns.mdguide for thejay.url.pathand^=patterns
Trade-offs
| Choice | Pro | Con |
|---|---|---|
jay. bindings |
No page.ts needed, designer-independent, natural prefix | New binding source, compiler complexity |
| Keep page.ts passthrough | Explicit, no new concepts | Boilerplate, role boundary problem |
$ prefix |
Familiar from shell/template langs | Less readable, inconsistent with jay: tags |
Field comparison with {...} |
Consistent with binding syntax, backward compatible | Slightly more complex conditional parser |
Dedicated active attribute |
Cleaner for the menu case | Too narrow, doesn't generalize |
Examples
Before (with page.ts)
# page.jay-contract
name: Page
params:
slug: string
tags:
- tag: activePage
type: data
dataType: string
phase: slow
// page.ts
export const page = makeJayStackComponent<PageContract>().withSlowlyRender(async (props) =>
phaseOutput({ activePage: props.slug }, {}),
);
<jay:DocsSidebar activePage="{activePage}" />
After (with jay. bindings)
<!-- No page.ts, no page.jay-contract needed for param passing -->
<jay:DocsSidebar activePage="{jay.params.slug}" currentPath="{jay.url.path}" />
Active menu (inside sidebar component, using conditional class)
<div forEach="items" trackBy="url">
<a href="{url}" class="nav-link {url === currentPath ? active}">{label}</a>
</div>
Section menu with startsWith (using conditional class)
<div forEach="sections" trackBy="url">
<a href="{url}" class="section-link {currentPath ^= url ? active}">{label}</a>
<div if="currentPath ^= url" forEach="pages" trackBy="url">
<a href="{url}" class="page-link {url === currentPath ? active}">{label}</a>
</div>
</div>
Implementation Results
What was implemented
All five phases from the implementation plan, with no deviations from the design.
PEG Grammar (expression-parser.pegjs):
startsWithConditionrule with^=operatorquotedStringLiteralrule for'literal'valuesenumConditionmodified with type-aware dispatch: enum types → variant literal, string types → field referencequotedStringLiteraladded toequalityComparisonValue- Slow render:
applyStartsWithhelper,slowQuotedStringLiteral,jay.*always-runtime inslowPropertyAccess
Compiler (expression-compiler.ts):
Variables.resolveAccessormapsjay.*→__jay.*withJayStringtype
Runtime:
dev-server.ts:__jay: { params, url: { path } }injected viainjectJayBindings()at both cached and non-cached handler pathsfetch-page-handler.ts: same injection for production serveresolve-instance-props.ts:buildInstanceBindingScopeincludesjay: { params, url: { path } }so{jay.url.path}and{jay.params.slug}resolve correctly in component prop bindings (e.g.,<jay:Sidebar currentPath="{jay.url.path}" />). Without this,jay.*bindings worked in compiled template code (text, conditionals) but not in runtime prop resolution for headfull/instance components.
Documentation:
- Agent-kit:
jay-html-template-syntax.mdupdated with string comparison,^=,jay.bindings - Agent-kit: new
navigation-patterns.mdguide - Agent-kit:
INSTRUCTIONS.mdupdated with navigation guide reference - UI kit:
popover-menu.mdupdated with active-state section - Top-level:
docs/core/jay-html.md— built-in bindings section - Top-level:
docs/core/routing.md— accessing route data in templates - Compiler:
compiler-jay-html/docs/jay-html-docs.md— type-aware comparison,^=,jay.internals
Tests
10 new tests added to expression-compiler.unit.test.ts:
^=with field and quoted literal^=combined with&&- String field-to-field comparison with
===and!== - Enum comparison unchanged
- String comparison with quoted literal
jay.url.pathandjay.paramsaccessors
All 195 expression tests pass. All 682 compiler tests pass. Type checking passes.
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.