// 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";
}