1import {
2 T3_CODE_DARK_THEME_COLORS,
3 T3_CODE_LIGHT_THEME_COLORS,
4 type ThemeAppearance,
5 type ThemeColors,
6} from "./themePalettes.ts";
7import { EVENT_CODE_SHORTCUT_KEYS } from "./keybindings.ts";
8
9/**
10 * Agent-authored HTML pages ("HTML renders") are self-contained documents an
11 * agent publishes into a thread with T3's `html_render` MCP tool. The server
12 * stores each one as a thread attachment with a small bootstrap injected into
13 * its head; clients show it in a sandboxed iframe (web, desktop) or WebView
14 * (mobile) and hand it the active theme as CSS custom properties.
15 */
16
17export const HTML_RENDER_TOOL_NAME = "html_render";
18export const HTML_RENDER_MIN_HEIGHT = 80;
19export const HTML_RENDER_MAX_HEIGHT = 2000;
20export const HTML_RENDER_MAX_TITLE_LENGTH = 200;
21
22/** What the `html_render` tool result carries so clients can show the page. */
23export interface HtmlRenderReference {
24 readonly attachmentId: string;
25 readonly title: string;
26 /** The agent's frame height in CSS pixels, and the cap on any measured height. */
27 readonly height: number;
28 /**
29 * `[width, contentHeight]` pairs the server measured at publish, ascending by
30 * width. Absent when the preview browser was not installed yet.
31 */
32 readonly heights?: ReadonlyArray<readonly [width: number, height: number]>;
33}
34
35/** Frame widths the server measures a page at, from phones to the wide chat setting. */
36export const HTML_RENDER_MEASURE_WIDTHS = [320, 375, 430, 520, 640, 728, 860, 1000, 1144] as const;
37
38/**
39 * The reply column's frame width at the default chat width. Agents preview at
40 * it, and it picks the measured height when a client cannot know its width.
41 */
42export const HTML_RENDER_COLUMN_WIDTH = 728;
43
44const MAX_MEASURED_HEIGHTS = 24;
45
46function readMeasuredHeights(value: unknown) {
47 if (!Array.isArray(value) || value.length === 0 || value.length > MAX_MEASURED_HEIGHTS) {
48 return undefined;
49 }
50 const heights = value.flatMap((entry) =>
51 Array.isArray(entry) &&
52 entry.length === 2 &&
53 Number.isInteger(entry[0]) &&
54 entry[0] >= 1 &&
55 entry[0] <= 10_000 &&
56 typeof entry[1] === "number" &&
57 Number.isFinite(entry[1])
58 ? [[entry[0] as number, clampHtmlRenderHeight(entry[1])] as const]
59 : [],
60 );
61 return heights.length === value.length
62 ? [...heights].sort((left, right) => left[0] - right[0])
63 : undefined;
64}
65
66export function readHtmlRenderReference(value: unknown): HtmlRenderReference | undefined {
67 if (typeof value !== "object" || value === null) return undefined;
68 const { attachmentId, title, height, heights } = value as Record<string, unknown>;
69 if (
70 typeof attachmentId !== "string" ||
71 attachmentId.length === 0 ||
72 attachmentId.length > 256 ||
73 typeof title !== "string" ||
74 typeof height !== "number" ||
75 !Number.isFinite(height)
76 ) {
77 return undefined;
78 }
79 const measured = readMeasuredHeights(heights);
80 return {
81 attachmentId,
82 title: title.trim().slice(0, HTML_RENDER_MAX_TITLE_LENGTH) || "HTML",
83 height: clampHtmlRenderHeight(height),
84 ...(measured === undefined ? {} : { heights: measured }),
85 };
86}
87
88/** Whether two references show the same page at the same sizes. */
89export function htmlRenderReferencesEqual(left: HtmlRenderReference, right: HtmlRenderReference) {
90 return (
91 left.attachmentId === right.attachmentId &&
92 left.title === right.title &&
93 left.height === right.height &&
94 (left.heights ?? []).length === (right.heights ?? []).length &&
95 (left.heights ?? []).every(
96 ([width, height], index) =>
97 right.heights?.[index]?.[0] === width && right.heights[index][1] === height,
98 )
99 );
100}
101
102// The taller of the heights measured at the nearest widths on each side. A
103// breakpoint between two measured widths can make the page as tall as either.
104function measuredHeight(heights: NonNullable<HtmlRenderReference["heights"]>, width: number) {
105 const above = heights.findIndex(([measuredWidth]) => measuredWidth >= width);
106 const high = above === -1 ? heights.length - 1 : above;
107 const low = heights[high]![0] === width ? high : Math.max(0, high - 1);
108 return Math.max(heights[low]![1], heights[high]![1]);
109}
110
111/**
112 * The frame height for a page at a frame width. It is the page's own reported
113 * `contentHeight` when the client has one, else the server's measurement for
114 * that width. A page even a few pixels taller than its frame scrolls inside it
115 * and takes the reader's scroll, so the frame fits the page. The agent's height
116 * caps it only when it is below the page's height at the column width (the
117 * agent asked for a scrolling frame) or when the page was never measured.
118 */
119export function htmlRenderFrameHeight(
120 reference: HtmlRenderReference,
121 width: number,
122 contentHeight?: number,
123) {
124 const heights = reference.heights;
125 if (heights === undefined || heights.length === 0) {
126 return clampHtmlRenderHeight(Math.min(reference.height, contentHeight ?? reference.height));
127 }
128 const cap =
129 measuredHeight(heights, HTML_RENDER_COLUMN_WIDTH) > reference.height
130 ? reference.height
131 : HTML_RENDER_MAX_HEIGHT;
132 return clampHtmlRenderHeight(Math.min(cap, contentHeight ?? measuredHeight(heights, width)));
133}
134
135/** A readable download name: the title without characters file systems reject. */
136export function htmlRenderFileName(title: string) {
137 const name = title
138 .replace(/[\\/:*?"<>|\p{Cc}]+/gu, " ")
139 .replace(/\s+/g, " ")
140 .trim()
141 .slice(0, 120)
142 .trim();
143 return `${name || "Page"}.html`;
144}
145
146export function clampHtmlRenderHeight(height: number): number {
147 return Math.min(HTML_RENDER_MAX_HEIGHT, Math.max(HTML_RENDER_MIN_HEIGHT, Math.round(height)));
148}
149
150export interface HtmlRenderFonts {
151 readonly sans: string;
152 readonly mono: string;
153}
154
155export const HTML_RENDER_DEFAULT_FONTS: HtmlRenderFonts = {
156 sans: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
157 // Concrete names only: some engines alias `ui-monospace` to the proportional UI font.
158 mono: '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace',
159};
160
161/**
162 * Fonts the server's headless browser lays pages out with. Arial-metric faces
163 * stand in for the system UI fonts readers see, so measured heights and
164 * preview screenshots wrap text close to how a client will.
165 */
166export const HTML_RENDER_MEASURE_FONTS: HtmlRenderFonts = {
167 sans: '"Liberation Sans", Arimo, Arial, Helvetica, sans-serif',
168 mono: '"Liberation Mono", Cousine, Menlo, Consolas, monospace',
169};
170
171/** The resolved theme a client hands an HTML render. */
172export interface HtmlRenderTheme {
173 readonly appearance: ThemeAppearance;
174 readonly variables: Readonly<Record<string, string>>;
175}
176
177// Roles themes cannot change (index.css keeps info/success fixed), plus a
178// categorical chart series after the theme's own accent.
179const FIXED_COLORS = {
180 light: {
181 success: "#10b981",
182 successForeground: "#047857",
183 info: "#3b82f6",
184 infoForeground: "#1d4ed8",
185 chart: ["#0d9488", "#d97706", "#9333ea", "#e11d48", "#65a30d"],
186 },
187 dark: {
188 success: "#10b981",
189 successForeground: "#34d399",
190 info: "#3b82f6",
191 infoForeground: "#60a5fa",
192 chart: ["#2dd4bf", "#fbbf24", "#c084fc", "#fb7185", "#a3e635"],
193 },
194} as const;
195
196/**
197 * Maps a theme palette to the variables HTML renders style against. Names
198 * follow the app's own tokens, except `--accent`, which is the theme's brand
199 * color here rather than the app's neutral hover surface.
200 */
201export function htmlRenderTheme(
202 colors: ThemeColors,
203 appearance: ThemeAppearance,
204 fonts: HtmlRenderFonts = HTML_RENDER_DEFAULT_FONTS,
205): HtmlRenderTheme {
206 const fixed = FIXED_COLORS[appearance];
207 return {
208 appearance,
209 variables: {
210 "--background": colors.canvas,
211 "--foreground": colors.text,
212 "--muted": colors.muted,
213 "--muted-foreground": colors.mutedForeground,
214 "--card": colors.surface,
215 "--card-foreground": colors.text,
216 "--popover": colors.surfaceOverlay,
217 "--popover-foreground": colors.text,
218 "--secondary": colors.secondary,
219 "--secondary-foreground": colors.secondaryForeground,
220 "--border": colors.border,
221 "--input": colors.input,
222 "--ring": colors.focus,
223 "--primary": colors.messageAction,
224 "--primary-foreground": colors.messageActionForeground,
225 "--accent": colors.accent,
226 "--accent-foreground": colors.accentForeground,
227 "--accent-surface": colors.accentSurface,
228 "--accent-surface-foreground": colors.accentSurfaceForeground,
229 "--destructive": colors.error,
230 "--destructive-foreground": colors.errorForeground,
231 "--destructive-surface": colors.errorSurface,
232 "--warning": colors.warning,
233 "--warning-foreground": colors.warningForeground,
234 "--warning-surface": colors.warningSurface,
235 "--success": fixed.success,
236 "--success-foreground": fixed.successForeground,
237 "--info": fixed.info,
238 "--info-foreground": fixed.infoForeground,
239 "--code-background": colors.codeBackground,
240 "--code-foreground": colors.codeForeground,
241 "--chart-1": colors.accent,
242 ...Object.fromEntries(fixed.chart.map((color, index) => [`--chart-${index + 2}`, color])),
243 "--radius": "0.625rem",
244 "--font-sans": fonts.sans,
245 "--font-mono": fonts.mono,
246 },
247 };
248}
249
250/** Agent-facing reference for the injected variables, used in tool descriptions. */
251export const HTML_RENDER_THEME_GUIDE = [
252 "T3 injects its active theme as CSS custom properties on :root, and they follow the user's theme and light/dark mode live:",
253 "--background (page background, identical to the thread around the frame), --foreground, --muted, --muted-foreground,",
254 "--card, --card-foreground, --popover, --popover-foreground, --secondary, --secondary-foreground, --border, --input, --ring,",
255 "--primary, --primary-foreground (solid buttons), --accent, --accent-foreground (brand accent), --accent-surface, --accent-surface-foreground,",
256 "--destructive, --destructive-foreground, --destructive-surface, --warning, --warning-foreground, --warning-surface,",
257 "--success, --success-foreground, --info, --info-foreground, --code-background, --code-foreground,",
258 "--chart-1 … --chart-6 (categorical series for charts), --radius, --font-sans, --font-mono.",
259 "The base stylesheet sets html background/color/font from these, body margin to 0, and hides the page's scrollbar; your own CSS overrides it.",
260].join(" ");
261
262/** Agent-facing layout rules for a page that sits inside a reply. */
263export const HTML_RENDER_LAYOUT_GUIDE = [
264 `The frame is borderless on the thread's background, as wide as the reply column (${HTML_RENDER_COLUMN_WIDTH}px on desktop by default, wider if the reader widens chat, about 360px on phones), and its left edge lines up with your reply text.`,
265 "The page sits on the thread's own background, so by default leave html, body, and the outermost element with no background color. This overrides general style preferences such as a fixed black page background.",
266 "Use a fluid width with no horizontal padding on the outermost element, and no outer card, border, or banner title: the page is part of your reply.",
267 "If a box needs its own background (a mock of a specific screen, a panel that must stand apart), give it at least 16px of padding on every side and var(--radius) corners, so content never touches its edge.",
268 "Give charts fixed pixel heights rather than heights that scale with width.",
269 "Let content set the page's height. Avoid viewport-based heights such as 100vh or height:100% on html or body; the frame grows to fit the page, so they can make it grow again and again.",
270].join(" ");
271
272// The bridge between a render and its client speaks the MCP Apps protocol
273// (JSON-RPC over postMessage), so the same host code can later drive upstream
274// MCP apps: https://github.com/modelcontextprotocol/ext-apps
275const HOST_CONTEXT_CHANGED_METHOD = "ui/notifications/host-context-changed";
276const OPEN_LINK_METHOD = "ui/open-link";
277const SIZE_CHANGED_METHOD = "ui/notifications/size-changed";
278// Not part of MCP Apps: a focused frame keeps its keys from the app, so the
279// client tells the page which chords are app shortcuts and the page passes
280// those on.
281const SHORTCUTS_METHOD = "t3/notifications/shortcuts";
282const KEY_DOWN_METHOD = "t3/notifications/keydown";
283
284/** A render's measured height and intrinsic width from its size-changed notification. */
285export function readHtmlRenderContentSize(data: unknown) {
286 if (typeof data !== "object" || data === null) return undefined;
287 const { jsonrpc, method, params } = data as Record<string, unknown>;
288 if (jsonrpc !== "2.0" || method !== SIZE_CHANGED_METHOD) return undefined;
289 if (typeof params !== "object" || params === null) return undefined;
290 const { height, width } = params as { height?: unknown; width?: unknown };
291 if (typeof height !== "number" || !Number.isFinite(height) || height <= 0) return undefined;
292 return {
293 height,
294 ...(typeof width === "number" && Number.isFinite(width) && width > 0 ? { width } : {}),
295 };
296}
297
298/**
299 * A render's `ui/open-link` request, if `data` is one with an http(s) URL. The
300 * bootstrap adds the click's Cmd/Ctrl state, so a modifier click can leave the
301 * app the way it does from a chat link.
302 */
303export function readHtmlRenderLinkRequest(data: unknown):
304 | {
305 readonly id: string | number;
306 readonly url: string;
307 readonly metaKey: boolean;
308 readonly ctrlKey: boolean;
309 }
310 | undefined {
311 if (typeof data !== "object" || data === null) return undefined;
312 const { jsonrpc, id, method, params } = data as Record<string, unknown>;
313 if (jsonrpc !== "2.0" || method !== OPEN_LINK_METHOD) return undefined;
314 if (typeof id !== "string" && typeof id !== "number") return undefined;
315 if (typeof params !== "object" || params === null) return undefined;
316 const { url, metaKey, ctrlKey } = params as Record<string, unknown>;
317 return typeof url === "string" && /^https?:\/\//i.test(url)
318 ? { id, url, metaKey: metaKey === true, ctrlKey: ctrlKey === true }
319 : undefined;
320}
321
322/** The key a framed render passed on in a `t3/notifications/keydown` notification. */
323export function readHtmlRenderKeyDown(data: unknown):
324 | {
325 readonly key: string;
326 readonly code: string;
327 readonly metaKey: boolean;
328 readonly ctrlKey: boolean;
329 readonly shiftKey: boolean;
330 readonly altKey: boolean;
331 readonly repeat: boolean;
332 }
333 | undefined {
334 if (typeof data !== "object" || data === null) return undefined;
335 const { jsonrpc, method, params } = data as Record<string, unknown>;
336 if (jsonrpc !== "2.0" || method !== KEY_DOWN_METHOD) return undefined;
337 if (typeof params !== "object" || params === null) return undefined;
338 const { key, code, metaKey, ctrlKey, shiftKey, altKey, repeat } = params as Record<
339 string,
340 unknown
341 >;
342 if (typeof key !== "string" || typeof code !== "string") return undefined;
343 return {
344 key,
345 code,
346 metaKey: metaKey === true,
347 ctrlKey: ctrlKey === true,
348 shiftKey: shiftKey === true,
349 altKey: altKey === true,
350 repeat: repeat === true,
351 };
352}
353
354/** A chord as the page sees it: `mod` already resolved to Meta or Control. */
355export interface HtmlRenderShortcut {
356 /** Lowercase key name, as in a keybinding: `k`, `[`, `enter`. */
357 readonly key: string;
358 readonly metaKey: boolean;
359 readonly ctrlKey: boolean;
360 readonly shiftKey: boolean;
361 readonly altKey: boolean;
362}
363
364/**
365 * The `t3/notifications/shortcuts` notification a client posts into a render:
366 * the chords it may pass on, and chords of other commands that the page keeps
367 * even when one also matches an allowed chord.
368 */
369export function htmlRenderShortcutsMessage(
370 shortcuts: ReadonlyArray<HtmlRenderShortcut>,
371 blocked: ReadonlyArray<HtmlRenderShortcut>,
372) {
373 return { jsonrpc: "2.0", method: SHORTCUTS_METHOD, params: { shortcuts, blocked } } as const;
374}
375
376/** The empty result a client sends back for a render's request. */
377export function htmlRenderResult(id: string | number) {
378 return { jsonrpc: "2.0", id, result: {} } as const;
379}
380
381const THEME_FRAGMENT_KEY = "t3-theme";
382
383/** URL fragment that hands a render its theme before first paint. */
384export function htmlRenderThemeFragment(theme: HtmlRenderTheme): string {
385 return `#${THEME_FRAGMENT_KEY}=${encodeURIComponent(JSON.stringify(theme))}`;
386}
387
388/** The `host-context-changed` notification a client posts into a mounted render when the theme changes. */
389export function htmlRenderThemeMessage(theme: HtmlRenderTheme) {
390 return {
391 jsonrpc: "2.0",
392 method: HOST_CONTEXT_CHANGED_METHOD,
393 params: { theme: theme.appearance, styles: { variables: theme.variables } },
394 } as const;
395}
396
397// The frame scrolls a page taller than itself, but a scrollbar inside the
398// reply reads as a box within the thread, so it stays hidden.
399const BASE_CSS =
400 "html{background:var(--background);color:var(--foreground);font-family:var(--font-sans);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:100%;scrollbar-width:none}" +
401 "html::-webkit-scrollbar{display:none}body{margin:0}code,kbd,pre,samp{font-family:var(--font-mono)}" +
402 // Focus outlines paint inside the element, so the frame edge never clips them.
403 ":where(:focus-visible){outline:2px solid var(--ring)}:focus-visible{outline-width:2px!important;outline-offset:-2px!important}";
404
405function rootRule(theme: HtmlRenderTheme): string {
406 const declarations = Object.entries(theme.variables)
407 .map(([name, value]) => `${name}:${value};`)
408 .join("");
409 return `:root{color-scheme:${theme.appearance};${declarations}}`;
410}
411
412// Runs synchronously in <head>, before the page's own styles and body, so the
413// first paint is already themed. It rewrites its own <style> element rather
414// than setting inline properties, so a page's later `:root` rules still win,
415// then drops the fragment so a page's own hash routing never sees it. A link
416// the reader clicks to another page never replaces the page inside the thread:
417// a framed page asks its client to open it, and a top-level page (mobile)
418// opens it as a new window, which the client sends to the browser. A framed
419// page also reports its content height, measured as the server measures it,
420// so its client can fit the frame to the page. A chord the client named as an
421// app shortcut is passed on rather than left to the browser, unless a text
422// field has focus or the page handled it: the deciding listener is added while
423// the key dispatches, so it runs after every listener the page has. Keys
424// match as resolveEventKeys reads them, so the client accepts what it claims.
425const BOOTSTRAP_SCRIPT = `(function(){var s=document.getElementById("t3-theme"),n=0;if(!s)return;var b=${JSON.stringify(BASE_CSS)};function a(t){if(!t||typeof t!=="object"||!t.variables||typeof t.variables!=="object")return;var c=":root{color-scheme:"+(t.appearance==="light"?"light":"dark")+";";for(var k in t.variables){if(/^--[a-z0-9-]+$/.test(k))c+=k+":"+String(t.variables[k]).replace(/[;{}<>]/g,"")+";";}s.textContent=c+"}"+b;}try{var m=/[#&]${THEME_FRAGMENT_KEY}=([^&]*)/.exec(location.hash);if(m){a(JSON.parse(decodeURIComponent(m[1])));history.replaceState(history.state,"",location.pathname+location.search);}}catch(e){}window.addEventListener("message",function(e){var d=e.data,p=d&&d.params;if(d&&d.jsonrpc==="2.0"&&d.method===${JSON.stringify(HOST_CONTEXT_CHANGED_METHOD)}&&p&&p.styles)a({appearance:p.theme,variables:p.styles.variables});});document.addEventListener("click",function(e){var l=e.isTrusted?e.composedPath().find(function(t){return t&&t.matches&&t.matches("a[href]");}):null,u;if(!l)return;try{u=new URL(l.getAttribute("href"),document.baseURI);}catch(x){return;}if(!/^https?:$/.test(u.protocol)||u.href.split("#")[0]===location.href.split("#")[0])return;if(window.parent!==window){e.preventDefault();window.parent.postMessage({jsonrpc:"2.0",id:"t3-link-"+(++n),method:${JSON.stringify(OPEN_LINK_METHOD)},params:{url:u.href,metaKey:e.metaKey,ctrlKey:e.ctrlKey}},"*");}else{l.setAttribute("target","_blank");l.setAttribute("rel","noopener");}},true);if(window.parent!==window){var h,w,o,z=function(){var r=document.documentElement,v=Math.ceil(r.scrollHeight>r.clientHeight?r.scrollHeight:r.getBoundingClientRect().height),a=r.style.getPropertyValue("width"),p=r.style.getPropertyPriority("width");r.style.setProperty("width","max-content","important");var x=Math.ceil(Math.max(r.getBoundingClientRect().width,document.body?document.body.scrollWidth:0));if(a)r.style.setProperty("width",a,p);else r.style.removeProperty("width");if(v===h&&x===w)return;h=v;w=x;window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(SIZE_CHANGED_METHOD)},params:{height:v,width:x}},"*");};if(window.ResizeObserver){o=new ResizeObserver(z);o.observe(document.documentElement);}function t(n,a){if(n.nodeType!==1)return;a(n);n.querySelectorAll("*").forEach(a);}document.addEventListener("DOMContentLoaded",function(){var b=document.body;if(b){if(o)t(b,function(n){o.observe(n);});if(window.MutationObserver){var m=new MutationObserver(function(es){if(o)es.forEach(function(e){e.removedNodes.forEach(function(n){t(n,function(v){o.unobserve(v);});});e.addedNodes.forEach(function(n){t(n,function(v){o.observe(v);});});});z();});m.observe(b,{subtree:true,childList:true,attributes:true,characterData:true});}}z();});window.addEventListener("load",z);var K=[],B=[],C=${JSON.stringify(EVENT_CODE_SHORTCUT_KEYS)};window.addEventListener("message",function(e){var d=e.data,p=d&&d.params;if(e.source===window.parent&&d&&d.jsonrpc==="2.0"&&d.method===${JSON.stringify(SHORTCUTS_METHOD)}&&p&&Array.isArray(p.shortcuts)&&Array.isArray(p.blocked)){K=p.shortcuts;B=p.blocked;}});window.addEventListener("keydown",function(e){var t=e.composedPath()[0],c=e.code||"",l=e.key.toLowerCase(),x=/^[a-z]$/.test(l)?[l]:[l,/^Key[A-Z]$/.test(c)?c.slice(3).toLowerCase():"",C.hasOwnProperty(c)?C[c]:""],f,m=function(s){return s&&s.metaKey===e.metaKey&&s.ctrlKey===e.ctrlKey&&s.shiftKey===e.shiftKey&&s.altKey===e.altKey&&x.indexOf(s.key)>-1;};if(!e.isTrusted||e.isComposing||e.getModifierState("AltGraph")&&!/^[a-z0-9]$/i.test(e.key)||t&&(t.isContentEditable||/^(?:INPUT|TEXTAREA|SELECT)$/.test(t.tagName))||!K.some(m)||B.some(m))return;f=function(v){window.removeEventListener("keydown",f);if(v!==e||e.defaultPrevented)return;e.preventDefault();window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(KEY_DOWN_METHOD)},params:{key:e.key,code:e.code,metaKey:e.metaKey,ctrlKey:e.ctrlKey,shiftKey:e.shiftKey,altKey:e.altKey,repeat:e.repeat}},"*");};window.addEventListener("keydown",f);setTimeout(function(){window.removeEventListener("keydown",f);});},true);}})();`;
426
427function bootstrapMarkup(markup: string): string {
428 const dark = htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark");
429 const light = htmlRenderTheme(T3_CODE_LIGHT_THEME_COLORS, "light");
430 // Without a client-provided theme (a direct download, the headless preview
431 // without a fragment) the page follows the OS appearance.
432 const defaultCss = `${rootRule(dark)}@media (prefers-color-scheme: light){${rootRule(light)}}${BASE_CSS}`;
433 return [
434 /<meta\s[^>]*charset/i.test(markup.slice(0, 4096)) ? "" : '<meta charset="utf-8">',
435 /<meta\s[^>]*name\s*=\s*["']?viewport/i.test(markup)
436 ? ""
437 : '<meta name="viewport" content="width=device-width, initial-scale=1">',
438 `<style id="t3-theme">${defaultCss}</style>`,
439 `<script>${BOOTSTRAP_SCRIPT}</script>`,
440 ].join("");
441}
442
443// Comments, raw text, and template contents are blanked to the same length,
444// so offsets still line up and inert tags cannot receive the bootstrap.
445const blankNonMarkup = (html: string) => {
446 const scan = html.replace(
447 /<!--[\s\S]*?(?:-->|$)|<(script|style|textarea|title|xmp|iframe|noembed|noframes|noscript)\b[\s\S]*?(?:<\/\1\s*>|$)|<plaintext\b[\s\S]*$/gi,
448 (match) => " ".repeat(match.length),
449 );
450 const parts: string[] = [];
451 let depth = 0;
452 let start = 0;
453 let at = 0;
454 for (const match of scan.matchAll(/<(\/?)template(?:\s[^>]*)?\/?>/gi)) {
455 if (!match[1]) {
456 if (depth++ === 0) start = match.index;
457 } else if (depth > 0 && --depth === 0) {
458 const end = match.index + match[0].length;
459 parts.push(scan.slice(at, start), " ".repeat(end - start));
460 at = end;
461 }
462 }
463 if (depth > 0) {
464 parts.push(scan.slice(at, start), " ".repeat(scan.length - start));
465 at = scan.length;
466 }
467 parts.push(scan.slice(at));
468 return parts.join("");
469};
470
471/**
472 * Inserts the theme bootstrap at the start of the document head, so a page's
473 * own styles and scripts come after it.
474 */
475export function injectHtmlRenderBootstrap(html: string): string {
476 const scan = blankNonMarkup(html);
477 const markup = bootstrapMarkup(scan);
478 const headOpen = /<head(?:\s[^>]*)?>/i.exec(scan);
479 if (headOpen) {
480 const at = headOpen.index + headOpen[0].length;
481 return html.slice(0, at) + markup + html.slice(at);
482 }
483 const htmlOpen = /<html(?:\s[^>]*)?>/i.exec(scan);
484 if (htmlOpen) {
485 const at = htmlOpen.index + htmlOpen[0].length;
486 return `${html.slice(0, at)}<head>${markup}</head>${html.slice(at)}`;
487 }
488 const doctype = /^\s*<!doctype[^>]*>/i.exec(html);
489 if (doctype) {
490 const at = doctype[0].length;
491 return `${html.slice(0, at)}<head>${markup}</head>${html.slice(at)}`;
492 }
493 return `<!doctype html><head>${markup}</head>${html}`;
494}