// The shared HTML document shell — ONE implementation drives both the Bun/Node
// dev+prod server and the generated Workers entry, so `june build` output
// renders byte-equivalent heads to `june dev`.
import React from "react";
import type { Metadata } from "./route";
import type { RouterMode, SiteConfig } from "./config";
// The serializable slice of app config the document needs. The server feeds it
// from AppConfig; the generated worker inlines it as literals at build time.
export type DocumentConfig = {
// `lang` is the document-language FLOOR: a single-locale app sets it (default
// "en") without any i18n machinery. When `i18n` is configured the resolved
// per-request locale (ctx.locale) overrides it on ``.
site: SiteConfig;
speculationRules: string | null;
speculationDelivery: "inline" | "header";
viewTransitions: boolean | "instant" | number;
// June's built-in zero-config defaults: the baseline reset (BASE_RESET_CSS) AND the starter look
// (STARTER_CONTENT_CSS — page background, a centered 720px column, inline-code chips). The reset is
// :where()-wrapped (zero-specificity); the starter look is full-specificity AND unlayered, so it
// would override an app's own CSS (Tailwind utilities/typography included). Both default ON and are
// dropped together when `false` — set it when your CSS already ships a reset and owns the look (e.g.
// Tailwind Preflight + your own styles). Maps from JuneConfig.cssReset (auto-off when Tailwind is
// detected in app/global.css).
cssReset?: boolean;
// Opt-in client router (resolved from config.clientRouter). When not "off" the
// page is wrapped in
— the region the router swaps on soft
// navigation — and that element's presence is the runtime signal the islands
// bundle reads to start the router. "off"/absent → classic MPA navigation, zero
// added JS. The applier ("morph" default, "flight" opt-in) rides on the same
// element as data-june-router (omitted for morph → byte-identical to before).
clientRouter?: RouterMode;
// URL of the client islands runtime bundle. Set by the host (dev serves it,
// build freezes its hashed path) when the app has islands; the document then
// loads it as a deferred module so `"use client"` islands hydrate. Absent /
// null → the page ships zero client JS.
clientScript?: string | null;
// URL of the global stylesheet. Set by the host (dev serves it, build emits it
// as an asset) when `app/global.css` exists — auto-linked, no import. Absent /
// null → no stylesheet. CSS is a HUMAN-surface concern; agent projections
// (.md/.json/mcp) never carry it.
styles?: string | null;
// URL of the collected CSS-Modules stylesheet (app/**/*.module.css), when any
// exist. Linked after `styles` so component-scoped rules win over the global
// sheet. Same dev-stable / build-hashed split as `styles`.
moduleStyles?: string | null;
// WebMCP tool manifest (name/description/inputSchema) — the SAME actions the
// server exposes at /mcp. When present, the document injects a tiny script
// that registers each via navigator.modelContext.registerTool() so an
// in-browser agent can call them (each tool's execute proxies to /mcp).
webmcpTools?: Array<{ name: string; description?: string; inputSchema?: unknown }> | null;
// URL of the app's AI Catalog (ARD) — set by the host when agent discovery is
// on; the document advertises it with .
aiCatalog?: string | null;
// Public-path prefix the whole site is served under (JuneConfig.basePath), e.g.
// "/openab/docs" for a GitHub Pages project subpath. When set, the framework asset
// URLs below (favicon/styles/moduleStyles/clientScript) — which are root-absolute
// ("/_june/…") — are prefixed so they resolve under the subpath. Empty/absent =
// root deploy (unchanged). Only the static() target sets it.
basePath?: string;
// "https://" when the config names one: the public origin for
// pages whose request can't supply it (prerendered pages render against a
// placeholder host). Kept apart from site.url so a locale's own domain can
// still win over it — see publicOrigin().
deployOrigin?: string;
// The icon links, filled by the host from public/ (see favicon.ts resolveIcons):
// primary the app's own main icon (public/favicon.svg, icon.svg, …) —
// linked in place of June's letter /favicon.svg; site.icon wins
// png a 32×32 PNG (Google Search and some browsers skip SVG)
// appleTouch the iOS home-screen icon
// manifest the web app manifest (Android "Add to Home Screen")
// generated true when the set is June's own (the pipeline then serves the
// generated /manifest.webmanifest)
// June's generated set when the app has no icon of its own. Absent → only the
// favicon link.
icons?: { primary?: string; png?: string; appleTouch?: string; manifest?: string; generated?: boolean } | null;
};
// June's built-in baseline CSS reset — a minimal, Tailwind-Preflight-aligned normalize, NOT a layout
// opinion. Every rule is wrapped in :where() so it carries ZERO specificity: present as a safe baseline
// for apps with no reset, yet trivially overridden by any stylesheet (so it never fights an app's own
// styles, Tailwind Preflight included). Deliberately omits `cursor: pointer` on buttons to match
// Tailwind v4 / browser-native behavior. Injected unless `cssReset: false`.
export const BASE_RESET_CSS = `:where(*,::before,::after,::backdrop){box-sizing:border-box}
:where(html){-webkit-text-size-adjust:100%}
:where(body){margin:0}
:where(img,picture,video,canvas,svg){display:block;max-width:100%}
:where(button,input,select,textarea){font:inherit}
:where(button){color:inherit;background:none;border:0}`;
// June's zero-config "starter look": opinionated defaults — a page background, a centered 720px reading
// column, and inline-code chips — that make an app shipping NO CSS of its own look decent immediately.
// Unlike BASE_RESET_CSS these are full-specificity LAYOUT/LOOK opinions, not a :where() reset, so they
// would fight an app that brings its own styling. CSS cascade layers don't save us here: these are
// unlayered, and unlayered normal declarations outrank EVERY @layer (Tailwind utilities and the
// typography plugin included) regardless of specificity. So they're injected together with the reset
// and, like it, DEFERRED when the app brings its own system (cssReset === false — which auto-derives
// from Tailwind detection in the host). Then Tailwind Preflight + the app's CSS own the look entirely.
// The starter look's page background — also its default theme-color.
export const STARTER_BACKGROUND = "#fbfbf8";
export const STARTER_CONTENT_CSS = `body{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:${STARTER_BACKGROUND};color:#1d1d1f}
main{width:min(720px,calc(100vw - 32px));margin:72px auto}
code{background:#ecebe4;border-radius:4px;padding:2px 5px}`;
// Cross-document View Transitions: same-origin MPA navigations cross-fade
// (and pair with prerender: activation + smooth transition = SPA feel, no SPA).
//
// The browser-default cross-fade runs ~250ms. On a PRERENDERED navigation the
// page is already there, so the fade isn't masking a load — it's pure tax played
// AFTER the new page is ready, with a hazy double-exposure mid-cross-fade that
// reads as lag. We override only animation-duration on the UA cross-fade
// (author > UA, so the browser's own keyframes still drive it — the most robust
// baseline) and default to a snappy 120ms: motion as polish, not manufactured
// delay. prefers-reduced-motion always collapses to an instant cut.
const VIEW_TRANSITION_DEFAULT_MS = 120;
// Build the View Transition CSS for the resolved setting:
// true → cross-fade at the default duration
// number → cross-fade at that many ms (0 = instant cut)
// "instant" → cross-document activation with no animation (instant cut)
// false → "" (no @view-transition rule; the caller drops it entirely)
export function viewTransitionCss(opt: boolean | "instant" | number): string {
if (opt === false) return "";
const ms =
opt === true ? VIEW_TRANSITION_DEFAULT_MS : opt === "instant" ? 0 : Math.max(0, opt);
return `
@view-transition { navigation: auto; }
::view-transition-group(root),
::view-transition-old(root),
::view-transition-new(root) { animation-duration: ${ms}ms; }
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) { animation: none !important; }
}`;
}
export const PREFETCH_FALLBACK = `(function(){if(HTMLScriptElement.supports&&HTMLScriptElement.supports('speculationrules'))return;var seen=new Set();document.addEventListener('pointerover',function(e){var a=e.target&&e.target.closest&&e.target.closest('a[href]');if(!a)return;var u=new URL(a.href,location.href);if(u.origin!==location.origin||seen.has(u.pathname)||u.pathname===location.pathname)return;if(/\.(md|json)$/.test(u.pathname)||u.pathname==='/mcp')return;seen.add(u.pathname);var l=document.createElement('link');l.rel='prefetch';l.href=u.pathname+u.search;document.head.appendChild(l);},{passive:true});})();`;
// WebMCP bridge: register each declared action via navigator.modelContext so an
// in-browser agent can call it; execute() proxies to /mcp (the same dispatch the
// server MCP endpoint uses). No-op when the browser lacks the API. An
// AbortController lets a future SPA navigation unregister. Reads its tool list
// from the adjacent