/** * Browser GET → traces-language JSON page. Clients still get the envelope. */ import { HTTP_FRAME_REDACTED, sensitiveHeader } from "../kernel/http-frame.ts"; import { CONSOLE_PORT } from "./types.ts"; /** One highlighted JSON span. */ export type JsonCodeTokenKind = "key" | "string" | "number" | "literal" | "punct" | "space"; /** Highlighted span. */ export interface JsonCodeToken { readonly kind: JsonCodeTokenKind; readonly text: string; } /** Inputs for {@link renderJsonCodeBlockHtml}. */ export interface JsonCodeBlockRenderOptions { readonly json: string; readonly status: number; readonly method: string; readonly path: string; readonly app: string; readonly rawHref: string; readonly prettyHref: string; /** Compact one-line JSON (`?raw=1`). Default pretty. */ readonly compact?: boolean; /** Route tree for the right-rail nav (Schema panel language). */ readonly nav?: readonly JsonCodeNavGroup[]; /** Handler elapsed time in milliseconds. */ readonly latencyMs?: number; /** Wide-event cache dimension from the invocation. */ readonly cache?: JsonCodeCache; /** Auth principal that handled the request (traces language). */ readonly auth?: JsonCodeAuth; /** * Response headers for the Fields view. Credential names are shown as * `[redacted]`. Omit `content-length` — the page replaces the body. */ readonly headers?: Readonly>; } /** Wide-event cache dimension on the browser JSON page. */ export type JsonCodeCache = "hit" | "miss" | "none"; /** * Auth mark on the browser JSON page — who handled the request. * * `public` is the intentional unauthenticated sentinel (`.public()` / * `gate.public`). `none` means no credentials and no public gate. */ export type JsonCodeAuthKind = "none" | "public" | "user" | "key" | "operator"; /** Auth principal projection for {@link renderJsonCodeBlockHtml}. */ export interface JsonCodeAuth { readonly kind: JsonCodeAuthKind; /** Subject id when known (user / key / operator). */ readonly id?: string | null; } /** * Build the auth mark from the resolved principal + optional public gate. * * @param input - Identity + whether the route is `.public()` */ export function jsonCodeAuthFrom(input: { readonly userId?: string | null; readonly apiKeyId?: string | null; readonly operatorId?: string | null; readonly publicGate?: boolean; }): JsonCodeAuth { if (input.operatorId) return { kind: "operator", id: input.operatorId }; if (input.apiKeyId) return { kind: "key", id: input.apiKeyId }; if (input.userId) return { kind: "user", id: input.userId }; if (input.publicGate) return { kind: "public" }; return { kind: "none" }; } /** One HTTP route under a {@link JsonCodeNavGroup}. */ export interface JsonCodeNavRoute { readonly method: string; readonly path: string; /** Set for static GET — parameterized GET uses Path tab values. */ readonly href: string | null; readonly current: boolean; /** `:name` segments from {@link path} (empty when static). */ readonly paramNames: readonly string[]; } /** * Collect `:param` names from an HTTP path template. * * @param path - e.g. `/notes/:id/archive` */ export function pathParamNames(path: string): string[] { const names: string[] = []; for (const seg of path.split("/")) { if (seg.startsWith(":") && seg.length > 1) names.push(seg.slice(1)); } return names; } /** * Match a pathname against a `:param` template. * * @param template - e.g. `/notes/:id` * @param pathname - e.g. `/notes/abc` */ export function matchPathTemplate( template: string, pathname: string, ): Record | null { const tParts = template.split("/"); const pParts = pathname.split("/"); if (tParts.length !== pParts.length) return null; const out: Record = {}; for (let i = 0; i < tParts.length; i += 1) { const t = tParts[i]!; const p = pParts[i]!; if (t.startsWith(":") && t.length > 1) { try { out[t.slice(1)] = decodeURIComponent(p); } catch { out[t.slice(1)] = p; } continue; } if (t !== p) return null; } return out; } /** * Fill a path template with values. Returns null when any param is empty. * * @param template - e.g. `/notes/:id` * @param values - param name → value */ export function fillPathTemplate( template: string, values: Readonly>, ): string | null { const parts = template.split("/"); const out: string[] = []; for (const seg of parts) { if (seg.startsWith(":") && seg.length > 1) { const v = (values[seg.slice(1)] ?? "").trim(); if (!v) return null; out.push(encodeURIComponent(v)); continue; } out.push(seg); } return out.join("/"); } /** First-segment group in the Routes rail. */ export interface JsonCodeNavGroup { readonly name: string; readonly routes: readonly JsonCodeNavRoute[]; } /** Binding-shaped input for {@link httpGetNavPaths} and {@link httpNavGroups}. */ export interface HttpGetNavSource { readonly trigger: { readonly kind: string; readonly method?: string; readonly path?: string; }; } const HTML_ESCAPE: Readonly> = { "&": "&", "<": "<", ">": ">", '"': """, }; /** * Escape text for an HTML text node or attribute. * * @param value - Raw text */ export function escapeHtml(value: string): string { return value.replace(/[&<>"]/g, (ch) => HTML_ESCAPE[ch] ?? ch); } /** * Pretty-print JSON when the body is valid; otherwise return the raw text. * * @param raw - Response body */ export function prettyJson(raw: string): string { return formatJson(raw, false); } /** * Compact or pretty JSON when the body is valid; otherwise the raw text. * * @param raw - Response body * @param compact - One line when true */ export function formatJson(raw: string, compact: boolean): string { try { return JSON.stringify(JSON.parse(raw) as unknown, null, compact ? undefined : 2); } catch { return raw; } } /** * Tokenize JSON for the code-block highlighter. * * @param source - Pretty-printed JSON (or raw text) */ export function tokenizeJson(source: string): JsonCodeToken[] { const tokens: JsonCodeToken[] = []; let i = 0; while (i < source.length) { const ch = source[i]!; if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") { let end = i + 1; while (end < source.length) { const next = source[end]!; if (next !== " " && next !== "\t" && next !== "\n" && next !== "\r") break; end += 1; } tokens.push({ kind: "space", text: source.slice(i, end) }); i = end; continue; } if (ch === "{" || ch === "}" || ch === "[" || ch === "]" || ch === ":" || ch === ",") { tokens.push({ kind: "punct", text: ch }); i += 1; continue; } if (ch === '"') { const end = scanJsonString(source, i); tokens.push({ kind: "string", text: source.slice(i, end) }); i = end; continue; } if (ch === "-" || (ch >= "0" && ch <= "9")) { const end = scanJsonNumber(source, i); tokens.push({ kind: "number", text: source.slice(i, end) }); i = end; continue; } if (source.startsWith("true", i) && !isJsonIdent(source[i + 4])) { tokens.push({ kind: "literal", text: "true" }); i += 4; continue; } if (source.startsWith("false", i) && !isJsonIdent(source[i + 5])) { tokens.push({ kind: "literal", text: "false" }); i += 5; continue; } if (source.startsWith("null", i) && !isJsonIdent(source[i + 4])) { tokens.push({ kind: "literal", text: "null" }); i += 4; continue; } tokens.push({ kind: "space", text: ch }); i += 1; } return markJsonKeys(tokens); } /** * Best q-value for `type/subtype` in an Accept header (star ranges count). * * @param accept - Raw Accept header * @param type - Type (`text`, `application`) * @param subtype - Subtype (`html`, `json`) */ export function acceptQuality(accept: string, type: string, subtype: string): number { let best = 0; let found = false; for (const part of accept.split(",")) { const [rangeRaw, ...params] = part.split(";").map((s) => s.trim()); const range = rangeRaw?.toLowerCase(); if (!range) continue; let q = 1; for (const param of params) { const eq = param.indexOf("="); if (eq === -1) continue; if (param.slice(0, eq).trim().toLowerCase() !== "q") continue; const parsed = Number(param.slice(eq + 1).trim()); if (Number.isFinite(parsed)) q = parsed; } if (q < 0) q = 0; if (q > 1) q = 1; const [rType, rSub] = range.split("/"); if (!rType || !rSub) continue; const exact = rType === type && rSub === subtype; const typeStar = rType === type && rSub === "*"; const star = rType === "*" && rSub === "*"; if (!exact && !typeStar && !star) continue; if (!found || q > best) best = q; found = true; } return found ? best : 0; } /** * True when `text/html` outranks `application/json` (browsers). Ties stay JSON. * * @param accept - Raw Accept header */ export function prefersHtml(accept: string | null): boolean { if (!accept?.trim()) return false; return acceptQuality(accept, "text", "html") > acceptQuality(accept, "application", "json"); } /** * Whether this GET JSON response should become the browser code block. * * @param request - Incoming request * @param response - Encoded JSON envelope */ export function shouldRenderJsonCodeBlock(request: Request, response: Response): boolean { if (request.method.toUpperCase() !== "GET") return false; const url = new URL(request.url); if (url.pathname === "/_/ready" || url.pathname.startsWith("/_/")) return false; if (url.pathname.startsWith("/_oke/")) return false; if (url.searchParams.get("format") === "json") return false; if (!prefersHtml(request.headers.get("accept"))) return false; const ct = (response.headers.get("content-type") ?? "").split(";")[0]?.trim().toLowerCase(); return ct === "application/json"; } /** * Console URL on the same host as the app request. * * @param request - Incoming request */ export function consoleUrlFromRequest(request: Request): string { const url = new URL(request.url); const host = url.hostname.includes(":") ? `[${url.hostname}]` : url.hostname; return `${url.protocol}//${host}:${CONSOLE_PORT}`; } /** * `?raw=1` href that keeps other query params. * * @param request - Incoming request */ export function rawHrefFromRequest(request: Request): string { const url = new URL(request.url); url.searchParams.set("raw", "1"); return `${url.pathname}${url.search}`; } /** * Pretty-view href — drops `raw`, keeps other query params. * * @param request - Incoming request */ export function prettyHrefFromRequest(request: Request): string { const url = new URL(request.url); url.searchParams.delete("raw"); const search = url.searchParams.toString(); return search.length > 0 ? `${url.pathname}?${search}` : url.pathname; } /** * Static GET paths from HTTP bindings — no params, no `/_/` internals. * * @param bindings - Adopted app bindings */ export function httpGetNavPaths(bindings: readonly HttpGetNavSource[]): string[] { const paths = new Set(); for (const binding of bindings) { const trigger = binding.trigger; if (trigger.kind !== "http" || trigger.method !== "GET") continue; const path = trigger.path; if (!path || path.includes(":") || path.startsWith("/_/")) continue; paths.add(path); } return [...paths].sort(compareNavPath); } /** * Group HTTP routes by first path segment for the Routes rail. * * @param bindings - Adopted app bindings * @param request - Incoming request */ export function httpNavGroups( bindings: readonly HttpGetNavSource[], request: Request, ): JsonCodeNavGroup[] { const url = new URL(request.url); const raw = url.searchParams.has("raw"); const byGroup = new Map>(); for (const binding of bindings) { const trigger = binding.trigger; if (trigger.kind !== "http") continue; const method = trigger.method; const path = trigger.path; if (!method || !path || path.startsWith("/_/")) continue; const name = navGroupName(path); const key = `${method} ${path}`; const paramNames = pathParamNames(path); const navigable = method === "GET" && paramNames.length === 0; const matched = paramNames.length > 0 ? matchPathTemplate(path, url.pathname) : null; const group = byGroup.get(name) ?? new Map(); group.set(key, { method, path, href: navigable ? (raw ? `${path}?raw=1` : path) : null, current: method === "GET" && (url.pathname === path || matched !== null), paramNames, }); byGroup.set(name, group); } return [...byGroup.entries()] .sort(([a], [b]) => compareNavPath(a === "/" ? "/" : `/${a}`, b === "/" ? "/" : `/${b}`)) .map(([name, routes]) => ({ name, routes: [...routes.values()].sort((left, right) => { const pathCmp = compareNavPath(left.path, right.path); return pathCmp !== 0 ? pathCmp : left.method.localeCompare(right.method); }), })); } function navGroupName(path: string): string { if (path === "/") return "/"; return path.split("/").filter((seg) => seg.length > 0)[0] ?? "/"; } function compareNavPath(a: string, b: string): number { if (a === "/") return -1; if (b === "/") return 1; return a.localeCompare(b); } /** * Compact latency for the browser JSON page header. * * @param ms - Elapsed milliseconds */ export function formatJsonCodeLatency(ms: number): string { if (!Number.isFinite(ms) || ms < 0) return "0μs"; if (ms < 1) return `${Math.round(ms * 1_000)}μs`; if (ms < 1_000) { const rounded = Math.round(ms * 10) / 10; return Number.isInteger(rounded) ? `${rounded}ms` : `${rounded.toFixed(1)}ms`; } const seconds = ms / 1_000; const rounded = Math.round(seconds * 10) / 10; return Number.isInteger(rounded) ? `${rounded}s` : `${rounded.toFixed(1)}s`; } /** Traces duration bands — same cutoffs as Console TraceRow. */ export type JsonCodeLatencyTone = | "fast" | "good" | "ok" | "elevated" | "warn" | "slow" | "bad" | "critical"; const LATENCY_TONE_BOUNDS = [ { tone: "fast", belowMs: 10 }, { tone: "good", belowMs: 50 }, { tone: "ok", belowMs: 100 }, { tone: "elevated", belowMs: 250 }, { tone: "warn", belowMs: 500 }, { tone: "slow", belowMs: 1_000 }, { tone: "bad", belowMs: 5_000 }, ] as const; /** * Cool→hot latency tone — same bands as Console traces. * * @param ms - Elapsed milliseconds */ export function jsonCodeLatencyTone(ms: number): JsonCodeLatencyTone { if (!Number.isFinite(ms) || ms < 0) return "fast"; for (const band of LATENCY_TONE_BOUNDS) { if (ms < band.belowMs) return band.tone; } return "critical"; } /** Scalar or container kind for one response field. */ type ResponseFieldKind = "string" | "number" | "boolean" | "null" | "object" | "array"; /** One expandable row in the response Fields view. */ interface ResponseFieldRow { readonly key: string; /** Path from the JSON root, for copy. */ readonly path: readonly (string | number)[]; readonly display: string; readonly kind: ResponseFieldKind; readonly children: readonly ResponseFieldRow[] | null; } const PREVIEW_FIELDS = 3; const PREVIEW_SCALAR_MAX = 42; const STATUS_REASON: Readonly> = { 200: "OK", 201: "Created", 202: "Accepted", 204: "No Content", 301: "Moved", 302: "Found", 304: "Not Modified", 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found", 405: "Method Not Allowed", 409: "Conflict", 415: "Unsupported Media Type", 422: "Unprocessable Content", 429: "Too Many Requests", 500: "Internal Server Error", 502: "Bad Gateway", 503: "Service Unavailable", }; const FIELD_CHEV = ``; const FIELD_COPY = ``; /** * Project a JSON value into field rows. Scalars and empty containers return null. * * @param value - Parsed response body or header map * @param path - Path from the root */ function responseFieldRows( value: unknown, path: readonly (string | number)[] = [], ): readonly ResponseFieldRow[] | null { if (Array.isArray(value)) { if (value.length === 0) return null; return value.map((item, index) => projectResponseField(String(index), item, [...path, index])); } if (value === null || typeof value !== "object") return null; const entries = Object.entries(value as Record); if (entries.length === 0) return null; return entries.map(([key, item]) => projectResponseField(key, item, [...path, key])); } /** * Short shape label for a body or container (`2 fields`, `5 items`). * * @param value - Parsed JSON value */ function responseShapeHint(value: unknown): string | null { if (Array.isArray(value)) { return `${value.length} ${value.length === 1 ? "item" : "items"}`; } if (value !== null && typeof value === "object") { const n = Object.keys(value).length; return `${n} ${n === 1 ? "field" : "fields"}`; } return null; } /** * Compact byte size of the serialized body. * * @param json - Serialized JSON text */ function responseByteLabel(json: string): string { const bytes = new TextEncoder().encode(json).length; if (bytes < 1024) return `${bytes} B`; return `${(bytes / 1024).toFixed(1)} KB`; } /** * Fields / JSON control for the response header. Same pair as Console traces. * * @param hasFields - Whether the body can open as rows * @param compact - Compact JSON is the code view */ function responseViewToggleHtml(hasFields: boolean, compact: boolean): string { if (!hasFields) return ""; const fieldsOn = !compact; const token = (view: "fields" | "json", label: string, icon: string, on: boolean) => ``; return `${token("fields", "Fields", GLYPH.list, fieldsOn)}${token("json", "JSON", GLYPH.braces, !fieldsOn)}`; } /** * Status, response headers, and an expandable body — the Console response frame. * * @param options - Envelope + headers * @param rows - Projected body rows * @param value - Parsed body * @param bodyJson - Highlighted body, shown when the header is on JSON */ function responsePaneHtml( options: JsonCodeBlockRenderOptions, rows: readonly ResponseFieldRow[], value: unknown, bodyJson: string, ): string { const tone = options.status >= 500 ? "err" : options.status >= 400 ? "warn" : "ok"; const reason = STATUS_REASON[options.status] ?? ""; const hint = [responseShapeHint(value), responseByteLabel(options.json)] .filter(Boolean) .join(" · "); const headers = jsonCodeResponseHeaders(options.headers); const headerRows = responseFieldRows(headers); const headerBlock = headerRows && headerRows.length > 0 ? `
${FIELD_CHEV}Headers${headerRows.length} ${headerRows.length === 1 ? "field" : "fields"}
    ${fieldListHtml(headerRows)}
` : ""; return `
${options.status}${reason ? `${escapeHtml(reason)}` : ""}
${headerBlock}
Body${hint ? `${escapeHtml(hint)}` : ""}
    ${fieldListHtml(rows)}
${bodyJson}
`; } /** * Redacted response headers for the Fields view. * * @param headers - Outgoing response headers, before the page replaces the body */ export function jsonCodeResponseHeaders( headers: Headers | Readonly> | undefined, ): Record { const out: Record = {}; const write = (key: string, value: string): void => { const name = key.toLowerCase(); if (name === "content-length") return; out[name] = sensitiveHeader(name) ? HTTP_FRAME_REDACTED : value; }; if (headers instanceof Headers) { headers.forEach((value, key) => { write(key, value); }); return out; } if (headers) { for (const [key, value] of Object.entries(headers)) write(key, value); } return out; } function projectResponseField( key: string, value: unknown, path: readonly (string | number)[], ): ResponseFieldRow { return { key, path, display: formatResponseField(value), kind: responseFieldKind(value), children: responseFieldRows(value, path), }; } function responseFieldKind(value: unknown): ResponseFieldKind { if (value === null) return "null"; if (Array.isArray(value)) return "array"; switch (typeof value) { case "string": return "string"; case "number": return "number"; case "boolean": return "boolean"; case "object": return "object"; default: return "string"; } } function formatResponseField(value: unknown): string { if (value === null) return "null"; if (typeof value === "string") return value; if (typeof value === "number" || typeof value === "boolean") return String(value); if (Array.isArray(value)) { if (value.length === 0) return "[]"; return `${value.length} ${value.length === 1 ? "item" : "items"}`; } if (typeof value === "object") return objectPreview(value as Record); return typeof value; } /** Up to three short scalar fields, otherwise a count. */ function objectPreview(value: Record): string { const keys = Object.keys(value); if (keys.length === 0) return "{}"; const bits: string[] = []; for (const [key, item] of Object.entries(value)) { if (bits.length >= PREVIEW_FIELDS) break; if (typeof item !== "string" && typeof item !== "number" && typeof item !== "boolean") continue; const text = typeof item === "string" ? item : String(item); if (text.length === 0 || text.length > PREVIEW_SCALAR_MAX) continue; bits.push(`${key}: ${text}`); } if (bits.length > 0) return bits.join(" · "); return `${keys.length} ${keys.length === 1 ? "field" : "fields"}`; } function fieldListHtml(rows: readonly ResponseFieldRow[]): string { return rows.map((row) => fieldRowHtml(row)).join(""); } function fieldRowHtml(row: ResponseFieldRow): string { const depth = Math.max(0, row.path.length - 1); const tone = row.kind === "string" ? " s" : row.kind === "number" ? " m" : row.kind === "boolean" ? " l" : ""; const copy = ``; const label = `${escapeHtml(row.key)}${escapeHtml(row.display)}${row.kind}`; if (row.children) { return `
  • ${FIELD_CHEV}${label}
      ${fieldListHtml(row.children)}
    ${copy}
  • `; } return `
  • ${label}
    ${copy}
  • `; } function parseJsonValue(json: string): unknown { try { return JSON.parse(json) as unknown; } catch { return undefined; } } /** * Full-bleed traces-language page for one JSON envelope. * * @param options - Envelope + chrome */ export function renderJsonCodeBlockHtml(options: JsonCodeBlockRenderOptions): string { const compact = options.compact === true; const parsed = parseJsonValue(options.json); const fieldRows = parsed === undefined ? null : responseFieldRows(parsed); const hasFields = fieldRows !== null && fieldRows.length > 0; const view = compact ? "raw" : hasFields ? "fields" : "pretty"; const code = formatJson(options.json, compact); const tokens = tokenizeJson(code); const lines = splitTokenLines(tokens); const title = `${options.method} ${options.path}`; const ok = options.status >= 200 && options.status < 300; const rows = lines .map((line, index) => { const n = index + 1; const inner = line.length === 0 ? " " : line.map(tokenHtml).join(""); return `${n}${inner}`; }) .join(""); const responseHtml = hasFields && fieldRows ? responsePaneHtml(options, fieldRows, parsed, rows) : ""; return ` ${escapeHtml(title)} · ${escapeHtml(options.app)}
    ${escapeHtml(options.app)} ${GLYPH.dot}${options.status} ${latencyHtml(options.latencyMs)} ${cacheHtml(options.cache)} ${authHtml(options.auth)}
    ${escapeHtml(options.method)}${escapeHtml(options.path)} json ${ok ? "Ready" : options.status} ${responseViewToggleHtml(hasFields, compact)}
    ${responseHtml || `
    ${rows}
    `}
    ${navHtml(options.nav)}
    `; } /** * Wrap a JSON GET response in the browser code-block page when Accept prefers HTML. * * @param request - Incoming request * @param response - Encoded envelope * @param app - Manifest app name * @param nav - Route groups for the right-rail tree * @param latencyMs - Handler elapsed time * @param cache - Wide-event cache dimension * @param auth - Auth principal that handled the request */ export async function asBrowserJsonCodeBlock( request: Request, response: Response, app: string, nav: readonly JsonCodeNavGroup[] = [], latencyMs?: number, cache: JsonCodeCache = "none", auth: JsonCodeAuth = { kind: "none" }, ): Promise { if (!shouldRenderJsonCodeBlock(request, response)) return response; const json = await response.text(); const url = new URL(request.url); const html = renderJsonCodeBlockHtml({ json, status: response.status, method: request.method.toUpperCase(), path: url.pathname, app, rawHref: rawHrefFromRequest(request), prettyHref: prettyHrefFromRequest(request), compact: url.searchParams.has("raw"), nav, latencyMs, cache, auth, headers: jsonCodeResponseHeaders(response.headers), }); const headers = new Headers(response.headers); headers.set("content-type", "text/html; charset=utf-8"); headers.delete("content-length"); const vary = headers.get("vary"); if (!vary) headers.set("vary", "Accept"); else if (!/\baccept\b/i.test(vary)) headers.set("vary", `${vary}, Accept`); return new Response(html, { status: response.status, headers }); } /** Stroke icon, 14px, currentColor. */ function glyph(body: string, size = 14): string { return ``; } const GLYPH = { clock: glyph( ``, ), dot: glyph(``, 12), eye: glyph( ``, ), plus: glyph( ``, ), pencil: glyph( ``, ), trash: glyph( ``, ), file: glyph( ``, ), sliders: glyph( ``, ), search: glyph( ``, ), braces: glyph( ``, ), cookie: glyph( ``, ), list: glyph( ``, ), key: glyph( ``, ), route: glyph( ``, ), } as const; /** Accent class for an HTTP method. Matches Console traces. */ function methodTone(method: string): string { switch (method.toUpperCase()) { case "GET": return "get"; case "QUERY": return "query"; case "POST": return "post"; case "PUT": return "put"; case "PATCH": return "patch"; case "DELETE": return "delete"; case "HEAD": return "head"; case "OPTIONS": return "options"; default: return "other"; } } /** Glyph for an HTTP method. */ function methodGlyph(method: string): string { switch (method.toUpperCase()) { case "GET": return GLYPH.eye; case "QUERY": return GLYPH.search; case "POST": return GLYPH.plus; case "PUT": case "PATCH": return GLYPH.pencil; case "DELETE": return GLYPH.trash; case "OPTIONS": return GLYPH.sliders; default: return GLYPH.file; } } const SECTION_GLYPH: Readonly> = { query: GLYPH.sliders, body: GLYPH.braces, cookies: GLYPH.cookie, headers: GLYPH.list, auth: GLYPH.key, path: GLYPH.route, }; /** Status accent: ok, redirect, client error, server error. */ function statusTone(status: number): "ok" | "info" | "warn" | "err" { if (status >= 500) return "err"; if (status >= 400) return "warn"; if (status >= 300) return "info"; return "ok"; } function latencyHtml(ms: number | undefined): string { if (ms === undefined) return ""; const tone = jsonCodeLatencyTone(ms); const label = formatJsonCodeLatency(ms); return `${GLYPH.clock}${escapeHtml(label)}`; } const CACHE_MARK: Readonly< Record > = { hit: { title: "Cache hit", text: "Hit", icon: ``, }, miss: { title: "Cache miss", text: "Miss", icon: ``, }, none: { title: "Cache not applicable", text: "None", icon: ``, }, }; function cacheHtml(cache: JsonCodeCache | undefined): string { const mark = CACHE_MARK[cache ?? "none"]; return `${mark.icon}${escapeHtml(mark.text)}`; } const AUTH_MARK: Readonly< Record > = { none: { title: "Unauthenticated", text: "None", icon: ``, }, public: { title: "Public — intentionally unauthenticated", text: "Public", icon: ``, }, user: { title: "Authenticated user", text: "User", icon: ``, }, key: { title: "API key", text: "Key", icon: ``, }, operator: { title: "Operator", text: "Operator", icon: ``, }, }; function authHtml(auth: JsonCodeAuth | undefined): string { const value = auth ?? { kind: "none" as const }; const mark = AUTH_MARK[value.kind]; const label = value.kind === "user" || value.kind === "key" || value.kind === "operator" ? value.id?.trim() || mark.text : mark.text; const title = value.id && (value.kind === "user" || value.kind === "key" || value.kind === "operator") ? `${mark.title} · ${value.id}` : mark.title; return `${mark.icon}${escapeHtml(label)}`; } function authSwitchHtml(): string { const opt = (type: string, text: string) => ``; return `${opt("none", "No")}${opt("bearer", "Bearer")}${opt("basic", "Basic")}${opt("apikey", "API")}`; } const AUTH_EYE = ``; /** Masked credential input. The value stays for this tab; a refresh clears it. */ function secretInput(name: string, placeholder: string): string { return ``; } function authEditorHtml(scope: "request" | "global"): string { const none = scope === "global" ? "No global credentials. Each request uses its own Authorization." : "No credentials on this request."; const switchRow = scope === "global" ? authSwitchHtml() : `
    ${authSwitchHtml()}
    `; return `
    ${switchRow}

    ${none}

    `; } function navHtml(nav: readonly JsonCodeNavGroup[] | undefined): string { const groups = (nav ?? []).map(navGroupHtml).join(""); const chev = ``; const play = ``; const kvSection = (kind: string, label: string) => `
    ${chev}${SECTION_GLYPH[kind] ?? ""}${label}
    `; return ` `; } function navGroupHtml(group: JsonCodeNavGroup): string { const leaves = group.routes.map(navRouteHtml).join(""); const open = group.routes.some((route) => route.current) ? " open" : ""; return `
  • ${escapeHtml(group.name)}
      ${leaves}
  • `; } function navRouteHtml(route: JsonCodeNavRoute): string { const on = route.current ? " is-on" : ""; const meta = `data-route-leaf data-method="${escapeHtml(route.method)}" data-path="${escapeHtml(route.path)}"`; const inner = `${escapeHtml(route.path)}${escapeHtml(route.method)}`; if (route.href) { return `
  • ${inner}
  • `; } if (route.paramNames.length > 0) { return `
  • `; } return `
  • `; } function isJsonIdent(ch: string | undefined): boolean { if (!ch) return false; return ( (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || (ch >= "0" && ch <= "9") || ch === "_" ); } function scanJsonString(source: string, start: number): number { let i = start + 1; while (i < source.length) { const ch = source[i]!; if (ch === "\\") { i += 2; continue; } if (ch === '"') return i + 1; i += 1; } return source.length; } function scanJsonNumber(source: string, start: number): number { let i = start; if (source[i] === "-") i += 1; while (i < source.length && source[i]! >= "0" && source[i]! <= "9") i += 1; if (source[i] === ".") { i += 1; while (i < source.length && source[i]! >= "0" && source[i]! <= "9") i += 1; } const exp = source[i]; if (exp === "e" || exp === "E") { i += 1; if (source[i] === "+" || source[i] === "-") i += 1; while (i < source.length && source[i]! >= "0" && source[i]! <= "9") i += 1; } return i; } function markJsonKeys(tokens: readonly JsonCodeToken[]): JsonCodeToken[] { const out: JsonCodeToken[] = []; for (let i = 0; i < tokens.length; i += 1) { const token = tokens[i]!; if (token.kind !== "string") { out.push(token); continue; } let j = i + 1; while (j < tokens.length && tokens[j]?.kind === "space") j += 1; out.push( tokens[j]?.kind === "punct" && tokens[j]?.text === ":" ? { kind: "key", text: token.text } : token, ); } return out; } function splitTokenLines(tokens: readonly JsonCodeToken[]): JsonCodeToken[][] { const lines: JsonCodeToken[][] = [[]]; for (const token of tokens) { if (token.kind !== "space" || !token.text.includes("\n")) { lines[lines.length - 1]!.push(token); continue; } const parts = token.text.split("\n"); for (let p = 0; p < parts.length; p += 1) { const piece = parts[p]!; if (piece.length > 0) lines[lines.length - 1]!.push({ kind: "space", text: piece }); if (p < parts.length - 1) lines.push([]); } } return lines; } function tokenHtml(token: JsonCodeToken): string { const text = escapeHtml(token.text); switch (token.kind) { case "key": return `${text}`; case "string": return `${text}`; case "number": return `${text}`; case "literal": return `${text}`; case "punct": return `${text}`; default: return text; } }