Summary
Restore the inline .withRouteContract({ pathSchema?, searchSchema?, paths? }) shorthand on definePage() and extend the Vite plugin's codegen pass so that neither the sidecar form nor the inline form require the author to write the binding call. The generator inserts it.
Three forms converge on the same .generated/routes.ts output:
- Form A (inline) —
.withRouteContract({ pathSchema: ..., searchSchema: ..., paths: ... }) is hand-written by the user; generator inserts the $route: field.
- Form B (sidecar) — sibling
routes/.../<page>.route.ts is hand-written; generator inserts .withRoute(routes.<key>.$route).
- Form C (no contract) — generator inserts a default
.withRoute(routes.<key>.$route) backed by createRouteReference(routePattern).
A new Vite plugin option pageModuleRouteBinding: true | false (default true) disables the new codegen for migration safety and for users who want no tooling touching page modules.
Why this WI exists
User pain points today
- Hand-writing
.withRoute(routes.dashboard.orders.$id.$route) in every page module is tedious when routes are renamed/moved. Renaming a route directory forces manual edits across every consumer.
- The JSR package README documents the manual
bindRoutePattern(...) form as the primary example, which contradicts the playground's actual sidecar-and-codegen flow and misleads new users.
- The earlier inline
.withRouteContract({ $route, ... }) shorthand (WI-09-era) was removed because it required the user to type the $route value. The remaining critique from WI-09F (line 13: "legacy page-attached contract discovery and the typedRoutes alias were removed") was specifically about export-based contract discovery (reading export const myContract from a page module's exports) — not about the inline-form authoring shorthand.
What's been confirmed about the prior state
- The Vite plugin in
netscript only writes to .generated/manifest.ts and .generated/routes.ts. No page-module writer exists today. See packages/fresh/src/application/vite/vite.ts (no transform() hook on page modules) and packages/fresh/src/application/route/manifest.ts:478, 483 (the only two Deno.writeTextFileSync calls in the package).
- The WI-09-era
.withRouteContract({ $route, pathSchema, ... }) builder method existed from 386c412f feat(fresh): finish WI-09C route inference cleanup (2026-03-20) to 388b4161 chore: finalize generated routes migration (2026-03-23). During that window, page modules wrote the inline object and the framework bound it. Removed when the framework needed the runtime navigation object (Link, getLinkProps) that the inline form did not carry.
- The
.withRoute(boundRoute) form was introduced in 5258ea8a feat-fresh-route-references (2026-03-22). Page modules were migrated from .withRouteContract({ $route, ... }) to .withRoute(routes.<key>.$route) in 388b4161 (2026-03-23).
Rationale for restoring the shorthand
The shorthand is restored not as a duplication of sidecar discovery, but as an authoring form for pages whose contract is tightly coupled to the page's render logic. The two forms serve different authoring preferences:
- Form A (inline): contract body lives in the page module, generator fills the route binding.
- Form B (sidecar): contract body lives in sibling
<page>.route.ts, generator fills the route binding.
- Form C (no contract): generator fills a default bound route reference.
Both forms converge on the same generator output (routes.<key> tree in .generated/routes.ts).
Design
Form A — inline .withRouteContract({ pathSchema?, searchSchema?, paths? })
Hand-written by the user in routes/orders/[id].tsx:
import { z } from 'zod';
export const ordersDetailPage = definePage()
.withRouteContract({
pathSchema: z.object({ id: z.string().min(1) }),
})
.withPolicy('balanced')
...
Generator rewrites this to:
import { z } from 'zod';
import { routePatterns } from '@app/.generated/manifest.ts';
export const ordersDetailPage = definePage()
.withRouteContract({
$route: routePatterns.dashboard.orders.$id.$route,
pathSchema: z.object({ id: z.string().min(1) }),
})
.withPolicy('balanced')
...
The user types only the contract body. The generator inserts the $route: field and the import for routePatterns based on the file's path.
Form B — sidecar + .withRoute(routes.<key>.$route)
Sibling routes/orders/[id].route.ts:
import { defineRouteContract } from '@netscript/fresh/route';
import { z } from 'zod';
export default defineRouteContract({
pathSchema: z.object({ id: z.string().min(1) }),
});
Hand-written initially by the user in routes/orders/[id].tsx:
import { z } from 'zod';
export const ordersDetailPage = definePage()
.withPolicy('balanced')
...
Generator rewrites this to:
import { z } from 'zod';
import { routes } from '@app/.generated/routes.ts';
export const ordersDetailPage = definePage()
.withRoute(routes.dashboard.orders.$id.$route)
.withPolicy('balanced')
...
Form C — no contract (default route reference)
Hand-written by the user in routes/about.tsx:
export const aboutPage = definePage()
.withMeta(() => ({ title: 'About' }))
...
Generator rewrites this to:
import { routes } from '@app/.generated/routes.ts';
export const aboutPage = definePage()
.withRoute(routes.about.$route)
.withMeta(() => ({ title: 'About' }))
...
The default bound route carries no schemas; it resolves to createRouteReference(routePattern) in .generated/routes.ts.
Generator algorithm
For each page module file F (apps/<app>/routes/**/*.tsx excluding layout/island/partial/component):
1. Derive the route key from F's path:
routes/(dashboard)/dashboard/orders/[id].tsx → dashboard.orders.$id
routes/(dashboard)/dashboard/orders/index.tsx → dashboard.orders
routes/about.tsx → about
The (group) layout prefix is stripped; index.tsx has no segment suffix.
2. Check for sibling sidecar:
routes/.../[id].route.ts
3. AST-scan the page module for `.withRouteContract({...})` and `.withRoute(...)` calls:
a. If `.withRouteContract({...})` is present (with or without $route):
Form = A
Schema fields = inline `pathSchema`, `searchSchema`, `paths`
b. Else if sibling sidecar exists:
Form = B
Schema fields = none (sidecar owns them)
c. Else:
Form = C
Schema fields = none
d. If both `.withRouteContract({...})` AND sibling sidecar exist:
Form = A wins. Sidecar is left in place but a build warning is emitted:
"Page F has both inline .withRouteContract and sibling sidecar.
Inline form takes precedence. Delete the sidecar to silence this warning."
Sidecar is NOT deleted automatically — author decides.
e. If both `.withRoute(...)` AND `.withRouteContract({...})` exist:
Error build: "Page F has both .withRoute and .withRouteContract. Pick one."
4. Compute target page module content based on Form:
Form A target:
import { routePatterns } from '<alias>/.generated/manifest.ts'; // if missing
definePage().withRouteContract({
$route: routePatterns.<key>.$route,
...inlineSchemaFields // preserved as-is from source
})
Form B target:
import { routes } from '<alias>/.generated/routes.ts'; // if missing
definePage().withRoute(routes.<key>.$route)
Form C target:
import { routes } from '<alias>/.generated/routes.ts'; // if missing
definePage().withRoute(routes.<key>.$route)
5. Diff against current page module content:
If different → write file
If equal → no-op (idempotency)
6. Update .generated/routes.ts:
Form A: bindRoutePattern(<synthesizedInlineContract>, <routePattern>, {...})
Form B: bindRoutePattern(<importedSidecarContract>, <routePattern>, {...})
Form C: createRouteReference(<routePattern>)
7. If .generated/routes.ts content changed → write file
Vite plugin option
createNetScriptVitePlugin({ ..., pageModuleRouteBinding: true | false })
true (default): generator rewrites page modules per the algorithm above.
false: generator skips step 5. Only .generated/routes.ts is updated. Existing hand-written .withRoute(...) lines continue to work.
Idempotency guarantees
- Generator compares the computed target content against the current page module content. If equal, no write.
- For Form A, the
$route: field is inserted as the literal first property of the object. deno fmt may reorder it (alphabetical by default), but the AST diff sees the same set of keys.
- For Form B and C, the import line and
.withRoute(...) line have stable text representations.
- The generator writes only if content differs. Second run produces no diff.
Conflict resolution (per author decision 2026-06-26)
| Conflict |
Resolution |
Inline .withRouteContract AND sibling sidecar |
Inline wins. Sidecar stays in place. Build emits warning: "Inline form takes precedence. Delete the sidecar to silence this warning." Sidecar is NOT auto-deleted. |
.withRoute(...) AND .withRouteContract({...}) in same file |
Build error. Author must pick one. |
No inline .withRouteContract AND no sibling sidecar |
Form C: generator inserts default .withRoute(routes.<key>.$route) with createRouteReference(routePattern). |
.withRoute(...) line is hand-written and matches generator's target |
No-op (idempotent). Generator leaves the line in place. |
Cross-link
Primary files
packages/fresh/src/application/builders/define-page/builder/mod.tsx (or route-support.ts) — add withRouteContract({ pathSchema?, searchSchema?, paths? }) method back with type-state promotion
packages/fresh/src/application/route/manifest.ts — add inline-contract discovery via AST scan; emit synthesized contract into .generated/routes.ts
packages/fresh/src/application/vite/vite.ts — wire manifest writer to page-module changes; add pageModuleRouteBinding option; implement page-module rewriting
packages/fresh/src/application/route/manifest-types.ts — extend types for inline-contract synthesis
packages/fresh/builders/define-page.test.tsx — new tests for .withRouteContract({...}) typing and runtime behavior
packages/fresh/route/manifest.test.ts — new tests for inline-contract AST discovery
packages/fresh/config/vite.test.ts — new tests for pageModuleRouteBinding option (true/false) and idempotency
packages/fresh/README.md — update example to show Form A/B/C with codegen as primary
docs/site/web-layer/builders.md — document .withRouteContract({...}) alongside .withRoute(boundRoute)
.llm/frontend/wi/WI-12-definePage-route-binding-codegen.md — this WI doc
apps/playground/routes/(dashboard)/dashboard/orders/[id].tsx and similar — migrate at least 3 page modules to Form A and 3 to Form B as proof
Validation
deno task check:packages — must stay green
deno check --config deno.json apps/playground — must stay green
cd apps/playground && deno task build — must succeed
- New unit tests:
.withRouteContract({ pathSchema: ... }) type-state promotion
.withRouteContract({ searchSchema: ... }) type-state promotion
.withRouteContract({ paths: [...] }) type-state promotion
- Generator inline-contract AST discovery (positive case)
- Generator inline-contract AST discovery (negative case — non-page module with
.withRouteContract is ignored)
- Generator page-module rewriting (Form A produces expected target)
- Generator page-module rewriting (Form B produces expected target)
- Generator page-module rewriting (Form C produces expected target)
- Generator idempotency (running twice produces no diff)
- Generator conflict: inline + sidecar → warning emitted, inline wins
- Generator conflict:
.withRoute + .withRouteContract → build error
- Generator
pageModuleRouteBinding: false → page modules not rewritten
- Migration snapshot: at least 3 playground page modules migrated to Form A (inline) and 3 to Form B (sidecar), all typecheck and build successfully.
Non-goals
- Re-introducing page-module-export contract discovery (
export const myContract = defineRouteContract(...) from page-module exports). This was the genuine duplicate discovery path that WI-09F removed and stays removed.
- Changing the sidecar discovery algorithm. Sidecar discovery stays exactly as today.
- Removing deprecated builder APIs (
defineListPage, defineDetailPage, defineFormPage). Out of scope.
- Changing the
withRoute(boundRoute) API signature. Only its emitter (the page-module writer) changes.
- Auto-deleting sidecar files when inline takes precedence. Sidecar files are left in place; build emits a warning; author decides.
Recommended commits
- Restore
.withRouteContract({...}) builder method — adds the method back to definePage() with type-state promotion.
- Generator: synthesize inline contract from page module AST — adds AST scan for
.withRouteContract({ pathSchema?, searchSchema?, paths? }) calls and emits a synthesized contract into .generated/routes.ts.
- Vite plugin: page-module rewriting (Form A/B/C) — adds the page-module rewriting algorithm and the
pageModuleRouteBinding option.
- Generator: Form C (no-contract default) — emits a default bound route reference for page modules with no inline
.withRouteContract and no sibling sidecar.
- Generator: conflict resolution — emits warning for inline+sidecar, errors for
.withRoute+.withRouteContract.
- Tests — unit tests for typing, AST discovery, page-module rewriting, idempotency, conflict resolution, and the
pageModuleRouteBinding: false option.
- Migration: 3 page modules to Form A, 3 to Form B — proof that both forms work end-to-end.
- Docs — update
packages/fresh/README.md and docs/site/web-layer/builders.md with Form A/B/C examples.
Done when
.withRouteContract({ pathSchema?, searchSchema?, paths? }) builder method exists with type-state promotion.
- Vite plugin rewrites page modules per Form A/B/C algorithm.
- Generator emits a synthesized contract for Form A page modules.
- Generator emits a default bound route for Form C page modules.
- Conflict resolution emits warning for inline+sidecar, errors for
.withRoute+.withRouteContract.
pageModuleRouteBinding: false disables page-module rewriting.
- All new unit tests pass.
deno task check:packages, deno check --config deno.json apps/playground, and cd apps/playground && deno task build all stay green.
- At least 3 playground page modules migrated to Form A, 3 to Form B.
- README and docs updated with Form A/B/C examples.
- The
.withRoute(...) line in every playground page module is either generator-owned (Form A/B/C) or hand-written (Form B with pageModuleRouteBinding: false).
Summary
Restore the inline
.withRouteContract({ pathSchema?, searchSchema?, paths? })shorthand ondefinePage()and extend the Vite plugin's codegen pass so that neither the sidecar form nor the inline form require the author to write the binding call. The generator inserts it.Three forms converge on the same
.generated/routes.tsoutput:.withRouteContract({ pathSchema: ..., searchSchema: ..., paths: ... })is hand-written by the user; generator inserts the$route:field.routes/.../<page>.route.tsis hand-written; generator inserts.withRoute(routes.<key>.$route)..withRoute(routes.<key>.$route)backed bycreateRouteReference(routePattern).A new Vite plugin option
pageModuleRouteBinding: true | false(defaulttrue) disables the new codegen for migration safety and for users who want no tooling touching page modules.Why this WI exists
User pain points today
.withRoute(routes.dashboard.orders.$id.$route)in every page module is tedious when routes are renamed/moved. Renaming a route directory forces manual edits across every consumer.bindRoutePattern(...)form as the primary example, which contradicts the playground's actual sidecar-and-codegen flow and misleads new users..withRouteContract({ $route, ... })shorthand (WI-09-era) was removed because it required the user to type the$routevalue. The remaining critique from WI-09F (line 13: "legacy page-attached contract discovery and thetypedRoutesalias were removed") was specifically about export-based contract discovery (readingexport const myContractfrom a page module's exports) — not about the inline-form authoring shorthand.What's been confirmed about the prior state
netscriptonly writes to.generated/manifest.tsand.generated/routes.ts. No page-module writer exists today. Seepackages/fresh/src/application/vite/vite.ts(notransform()hook on page modules) andpackages/fresh/src/application/route/manifest.ts:478, 483(the only twoDeno.writeTextFileSynccalls in the package)..withRouteContract({ $route, pathSchema, ... })builder method existed from386c412f feat(fresh): finish WI-09C route inference cleanup(2026-03-20) to388b4161 chore: finalize generated routes migration(2026-03-23). During that window, page modules wrote the inline object and the framework bound it. Removed when the framework needed the runtime navigation object (Link, getLinkProps) that the inline form did not carry..withRoute(boundRoute)form was introduced in5258ea8a feat-fresh-route-references(2026-03-22). Page modules were migrated from.withRouteContract({ $route, ... })to.withRoute(routes.<key>.$route)in388b4161(2026-03-23).Rationale for restoring the shorthand
The shorthand is restored not as a duplication of sidecar discovery, but as an authoring form for pages whose contract is tightly coupled to the page's render logic. The two forms serve different authoring preferences:
<page>.route.ts, generator fills the route binding.Both forms converge on the same generator output (
routes.<key>tree in.generated/routes.ts).Design
Form A — inline
.withRouteContract({ pathSchema?, searchSchema?, paths? })Hand-written by the user in
routes/orders/[id].tsx:Generator rewrites this to:
The user types only the contract body. The generator inserts the
$route:field and the import forroutePatternsbased on the file's path.Form B — sidecar +
.withRoute(routes.<key>.$route)Sibling
routes/orders/[id].route.ts:Hand-written initially by the user in
routes/orders/[id].tsx:Generator rewrites this to:
Form C — no contract (default route reference)
Hand-written by the user in
routes/about.tsx:Generator rewrites this to:
The default bound route carries no schemas; it resolves to
createRouteReference(routePattern)in.generated/routes.ts.Generator algorithm
Vite plugin option
createNetScriptVitePlugin({ ..., pageModuleRouteBinding: true | false })true(default): generator rewrites page modules per the algorithm above.false: generator skips step 5. Only.generated/routes.tsis updated. Existing hand-written.withRoute(...)lines continue to work.Idempotency guarantees
$route:field is inserted as the literal first property of the object.deno fmtmay reorder it (alphabetical by default), but the AST diff sees the same set of keys..withRoute(...)line have stable text representations.Conflict resolution (per author decision 2026-06-26)
.withRouteContractAND sibling sidecar.withRoute(...)AND.withRouteContract({...})in same file.withRouteContractAND no sibling sidecar.withRoute(routes.<key>.$route)withcreateRouteReference(routePattern)..withRoute(...)line is hand-written and matches generator's targetCross-link
InferRoutePatternSegmentEmptyRecord regression. WI-12 depends on fix(route): InferRoutePatternSegment returns EmptyRecord for static segments, breaks multi-segment dynamic paths #178 because the codegen-emitted.withRoute(routes.<key>.$route)chain relies oncreateRouteReference's path-param inference being correct. Without fix(route): InferRoutePatternSegment returns EmptyRecord for static segments, breaks multi-segment dynamic paths #178, playground pages with dynamic segments would surface the sameTS2322from typedLink/href.Primary files
packages/fresh/src/application/builders/define-page/builder/mod.tsx(orroute-support.ts) — addwithRouteContract({ pathSchema?, searchSchema?, paths? })method back with type-state promotionpackages/fresh/src/application/route/manifest.ts— add inline-contract discovery via AST scan; emit synthesized contract into.generated/routes.tspackages/fresh/src/application/vite/vite.ts— wire manifest writer to page-module changes; addpageModuleRouteBindingoption; implement page-module rewritingpackages/fresh/src/application/route/manifest-types.ts— extend types for inline-contract synthesispackages/fresh/builders/define-page.test.tsx— new tests for.withRouteContract({...})typing and runtime behaviorpackages/fresh/route/manifest.test.ts— new tests for inline-contract AST discoverypackages/fresh/config/vite.test.ts— new tests forpageModuleRouteBindingoption (true/false) and idempotencypackages/fresh/README.md— update example to show Form A/B/C with codegen as primarydocs/site/web-layer/builders.md— document.withRouteContract({...})alongside.withRoute(boundRoute).llm/frontend/wi/WI-12-definePage-route-binding-codegen.md— this WI docapps/playground/routes/(dashboard)/dashboard/orders/[id].tsxand similar — migrate at least 3 page modules to Form A and 3 to Form B as proofValidation
deno task check:packages— must stay greendeno check --config deno.json apps/playground— must stay greencd apps/playground && deno task build— must succeed.withRouteContract({ pathSchema: ... })type-state promotion.withRouteContract({ searchSchema: ... })type-state promotion.withRouteContract({ paths: [...] })type-state promotion.withRouteContractis ignored).withRoute+.withRouteContract→ build errorpageModuleRouteBinding: false→ page modules not rewrittenNon-goals
export const myContract = defineRouteContract(...)from page-module exports). This was the genuine duplicate discovery path that WI-09F removed and stays removed.defineListPage,defineDetailPage,defineFormPage). Out of scope.withRoute(boundRoute)API signature. Only its emitter (the page-module writer) changes.Recommended commits
.withRouteContract({...})builder method — adds the method back todefinePage()with type-state promotion..withRouteContract({ pathSchema?, searchSchema?, paths? })calls and emits a synthesized contract into.generated/routes.ts.pageModuleRouteBindingoption..withRouteContractand no sibling sidecar..withRoute+.withRouteContract.pageModuleRouteBinding: falseoption.packages/fresh/README.mdanddocs/site/web-layer/builders.mdwith Form A/B/C examples.Done when
.withRouteContract({ pathSchema?, searchSchema?, paths? })builder method exists with type-state promotion..withRoute+.withRouteContract.pageModuleRouteBinding: falsedisables page-module rewriting.deno task check:packages,deno check --config deno.json apps/playground, andcd apps/playground && deno task buildall stay green..withRoute(...)line in every playground page module is either generator-owned (Form A/B/C) or hand-written (Form B withpageModuleRouteBinding: false).