Skip to content

feat(fresh): restore inline .withRouteContract shorthand + codegen page-module route binding (WI-12) #181

Description

@rickylabs

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

  1. Restore .withRouteContract({...}) builder method — adds the method back to definePage() with type-state promotion.
  2. 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.
  3. Vite plugin: page-module rewriting (Form A/B/C) — adds the page-module rewriting algorithm and the pageModuleRouteBinding option.
  4. Generator: Form C (no-contract default) — emits a default bound route reference for page modules with no inline .withRouteContract and no sibling sidecar.
  5. Generator: conflict resolution — emits warning for inline+sidecar, errors for .withRoute+.withRouteContract.
  6. Tests — unit tests for typing, AST discovery, page-module rewriting, idempotency, conflict resolution, and the pageModuleRouteBinding: false option.
  7. Migration: 3 page modules to Form A, 3 to Form B — proof that both forms work end-to-end.
  8. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions