// route() — a single route definition that feeds one `load()` into multiple // content-negotiated projections. // // export default route({ // async load(ctx) { return { users: await Users.all() }; }, // view({ users }) { return ; }, // HTML / Flight // json({ users }) { return { users }; }, // data API // md({ users }) { return renderMd(users); }, // markdown // }); // // The same data, multiple representations, never drifting apart. `view` is the // React projection (named `view`, not `html`, because the framework decides // HTML-SSR vs RSC Flight by negotiation). `json` is the plain data API. `md` is // the agent-facing markdown. Capabilities live at /mcp (defineAction), not as a // route projection. import React from "react"; import type { Principal, Session } from "./context"; // "fragment" is the soft-nav/live-apply transport (Route A): the [data-june-root] // inner HTML for the SAME url, morphed in on the client. Not an agent surface — a // client-router transport, signaled by Accept, never a URL suffix. export type RenderTarget = "view" | "json" | "md" | "fragment"; // Per-route document metadata. Static metadata keeps the streaming shell; // a FUNCTION (deriving from load() data) forces an eager load — the // streams first, so dynamic metadata costs the loading-boundary for that // route. Choose per route. export type Metadata = { title?: string; description?: string; canonical?: string; robots?: string; // e.g. "noindex" openGraph?: { title?: string; description?: string; image?: string; // absolute, or root-relative (resolved against site.url) imageAlt?: string; // defaults to the og:title imageWidth?: number; // 1200 for @junejs/og cards imageHeight?: number; // 630 for @junejs/og cards type?: string; // "website" | "article" | ... }; twitter?: { card?: "summary" | "summary_large_image"; // default: large when there's an image creator?: string; // the author's handle, e.g. "@jane" }; // The page's schema.org BreadcrumbList (JSON-LD), emitted on every non-home page. // Unset → derived from the URL: the site home, each ancestor path that is itself // a page (a route matched with no params — a dynamic match's existence depends on // its data, so it is skipped rather than guessed), then this page. `false` → none. // An array → the trail after the site home, in order, ending with this page; // `path` is root-relative (the deploy basePath is added), e.g. for a product page // whose category comes from data: [{ name: "Shoes", path: "/c/shoes" }, …]. breadcrumb?: false | Array<{ name: string; path: string }>; }; export type RouteContext< TParams extends Record = Record, > = { request: Request; url: URL; params: TParams; target: RenderTarget; // The resolved request locale, set by the host-resolution step BEFORE routing // when `i18n` is configured (from the URL's domain/path, else negotiation). // Undefined when the app declares no `i18n` — single-locale apps never meet it. locale?: string; // True when the request is SPECULATIVE (Sec-Purpose: prefetch / prerender): // the page may never be seen. Skip side effects — view counters, analytics, // rate-limit consumption. (Client-side twin: defer pageviews until // !document.prerendering.) speculative?: boolean; // The authenticated principal, populated by the auth integration off the // request (undefined until @junejs/auth is wired). Routes gate on ctx.user; // the SAME principal reaches actions via ActionContext. // // ctx is IDENTITY only — db/kv/blob are NOT here. Data resources are ambient: // `import { db } from '@junejs/server'` and use it anywhere, so models/helpers // never thread ctx. See @junejs/server's scope.ts (docs/data-layer-boundary.md). user?: Principal; session?: Session; }; export type RouteCache = { ttl?: number; // seconds; omit for no expiry (tag-invalidated only) swr?: number; // extra stale-while-revalidate window, seconds (needs ttl) tags?: string[]; // a mutation calling invalidate(tag) drops this route's cache }; export type RouteDefinition = { load?: (ctx: RouteContext) => TData | Promise; view?: (data: TData, ctx: RouteContext) => React.ReactNode; // A projection function customizes the surface; `false` disables it (404 + // dropped from discovery); absent derives the default (.json → loader data, // .md → from json). json?: ((data: TData, ctx: RouteContext) => unknown | Promise) | false; md?: ((data: TData, ctx: RouteContext) => string | Promise) | false; // Response cache: cache the rendered output of GET requests, keyed by // target+URL, dropped by tag invalidation. Uses @junejs/core/cache. cache?: RouteCache; // `june build` renders this route's projections (html/md/json) to static // files served by the Workers assets layer BEFORE the worker runs (0ms). // Static routes only; opt-in because it freezes per-request behavior. prerender?: boolean; // Concrete pathnames to prerender for the static() target — lets a DYNAMIC // catch-all route (e.g. docs `[[...slug]]`) emit one HTML file per page. // FULL route pathnames relative to the site root, i18n locale prefix already // applied, WITHOUT any deploy basePath (the producer owns param + locale // expansion). Ignored by every non-static target. staticPaths?: string[] | (() => string[] | Promise); // Document metadata for the view projection (title/description/OG/...). metadata?: Metadata | ((data: TData, ctx: RouteContext) => Metadata); // How this route appears in /llms.txt. See LlmsEntry. Absent: a static route is // listed under "Pages" with its static metadata title/description; a dynamic // route (a [param] template an agent can't fetch) is not listed. `false` drops // the route from llms.txt. llms?: LlmsDeclaration; }; // One /llms.txt link. llmstxt.org asks for a CURATED file: links grouped under H2 // sections, each with a one-line description, and an "Optional" section for links // an agent can skip when context is short. June fills this from the routes: // • path the page's pathname. Required for a dynamic route (list each real // page); defaults to the route's own path for a static one. // • title link text. Default: the route's static metadata title, else the path. // • description one line after the link. Default: static metadata description. // • section the H2 it's grouped under. Default: "Pages". // • optional true → listed under "## Optional" (e.g. blog posts, changelogs). // • lastModified when the page's content last changed (a W3C date/datetime string // like "2026-09-27", or a Date) → the page's sitemap . // Absent → no ; June never substitutes the build time. // The same entries enumerate a dynamic route's pages for /sitemap.xml (at // runtime the only source: staticPaths stay build-only). So `llms = false` // keeps a STATIC route in the sitemap, but on a DYNAMIC route drops its pages // from both llms.txt and the runtime sitemap (a static() build still lists // them via staticPaths). // Each link points at the page's markdown projection (.md) unless the route // disables md, so an agent reads the page, not its HTML. export type LlmsEntry = { path?: string; title?: string; description?: string; section?: string; optional?: boolean; lastModified?: string | Date; }; export type LlmsDeclaration = | false | LlmsEntry | LlmsEntry[] | (() => LlmsEntry | LlmsEntry[] | Promise); const ROUTE_BRAND = Symbol.for("june.route"); export type BrandedRoute = RouteDefinition & { [ROUTE_BRAND]: true; }; export function route(def: RouteDefinition): BrandedRoute { return { ...def, [ROUTE_BRAND]: true }; } export function isRouteDefinition(value: unknown): value is BrandedRoute { return ( typeof value === "object" && value !== null && (value as Record)[ROUTE_BRAND] === true ); } // --- the multi-export page shape ------------------------------------------ // A page.tsx default-exports its view component and configures the other // surfaces through named exports (loader / json / md / metadata). `Loaded` // recovers the component's props type from the loader's return — the generated // `./+types` builds `Route.Data` from it. // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Loaded unknown> = Awaited>; // CANONICAL: the view receives loader data as PROPS — `view(data)` — keeping it // the same shape as the other three surfaces (.json = data, md(data), // metadata(data)). One loader, four projections, each a pure function of data. // // ESCAPE HATCH: `useLoaderData()` reads the same data from context, for DEEP // child components where prop-drilling would hurt (the standard React division: // props by default, context for cross-cutting depth). The pipeline wraps the // rendered view in this provider, so any descendant — and a default-export view // written in the Remix `const data = useLoaderData()` muscle-memory — also works. // Server-render scope: it reads during SSR; client islands still take props. // eslint-disable-next-line @typescript-eslint/no-explicit-any export const LoaderDataContext = React.createContext(undefined); // eslint-disable-next-line @typescript-eslint/no-explicit-any export function useLoaderData unknown = () => unknown>(): Loaded { return React.useContext(LoaderDataContext) as Loaded; } export type PageModule = { default?: React.ComponentType>; loader?: (ctx: RouteContext) => unknown; json?: ((data: unknown, ctx: RouteContext) => unknown) | false; md?: ((data: unknown, ctx: RouteContext) => string | Promise) | false; metadata?: Metadata | ((data: unknown, ctx: RouteContext) => Metadata); cache?: RouteCache; prerender?: boolean; staticPaths?: string[] | (() => string[] | Promise); llms?: LlmsDeclaration; }; // Adapt a page module's exports into the internal BrandedRoute the pipeline // consumes. A legacy route({}) default export passes straight through, so both // shapes coexist during the migration. Returns null for non-route modules. export function routeFromModule(mod: unknown): BrandedRoute | null { const m = (mod ?? {}) as PageModule; if (isRouteDefinition(m.default)) return m.default; // legacy route({}) const View = typeof m.default === "function" ? m.default : undefined; const hasConfig = "loader" in m || "json" in m || "md" in m || "metadata" in m || "prerender" in m || "staticPaths" in m || "llms" in m; if (!View && !hasConfig) return null; return route({ load: m.loader, view: View ? (data: unknown) => React.createElement(View, data as Record) : undefined, json: m.json, md: m.md, metadata: m.metadata, cache: m.cache, prerender: m.prerender, staticPaths: m.staticPaths, llms: m.llms, }); } // The order each requested target degrades through when a projection is absent. // An agent asking for /users.json on a route with no `json()` still gets the // view rather than a 406. // Values are only the DECLARABLE projections (view/json/md) — "fragment" is never // a route export, so it can't be a fallback target (only a fallback key). const FALLBACK: Record> = { json: ["json", "view"], view: ["view", "json"], // `md` is handled specially (auto-derived from json when md() is absent); // this entry is only the last-resort fall-through. md: ["md", "json", "view"], // a fragment is always the view rendered without the document shell; if the // client can't get one it degrades to a full view (hard navigation). fragment: ["view"], }; export function resolveProjection( def: BrandedRoute, requested: RenderTarget, ): RenderTarget { return FALLBACK[requested].find((t) => typeof def[t] === "function") ?? "view"; }