| 1 | import { |
| 2 | T3_CODE_DARK_THEME_COLORS, |
| 3 | T3_CODE_LIGHT_THEME_COLORS, |
| 4 | type ThemeAppearance, |
| 5 | type ThemeColors, |
| 6 | } from "./themePalettes.ts"; |
| 7 | import { 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 | |
| 17 | export const HTML_RENDER_TOOL_NAME = "html_render"; |
| 18 | export const HTML_RENDER_MIN_HEIGHT = 80; |
| 19 | export const HTML_RENDER_MAX_HEIGHT = 2000; |
| 20 | export const HTML_RENDER_MAX_TITLE_LENGTH = 200; |
| 21 | |
| 22 | /** What the `html_render` tool result carries so clients can show the page. */ |
| 23 | export 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. */ |
| 36 | export 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 | */ |
| 42 | export const HTML_RENDER_COLUMN_WIDTH = 728; |
| 43 | |
| 44 | const MAX_MEASURED_HEIGHTS = 24; |
| 45 | |
| 46 | function 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 | |
| 66 | export 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. */ |
| 89 | export 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. |
| 104 | function 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 | */ |
| 119 | export 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. */ |
| 136 | export 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 | |
| 146 | export function clampHtmlRenderHeight(height: number): number { |
| 147 | return Math.min(HTML_RENDER_MAX_HEIGHT, Math.max(HTML_RENDER_MIN_HEIGHT, Math.round(height))); |
| 148 | } |
| 149 | |
| 150 | export interface HtmlRenderFonts { |
| 151 | readonly sans: string; |
| 152 | readonly mono: string; |
| 153 | } |
| 154 | |
| 155 | export 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 | */ |
| 166 | export 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. */ |
| 172 | export 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. |
| 179 | const 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 | */ |
| 201 | export 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. */ |
| 251 | export 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. */ |
| 263 | export 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 |
| 275 | const HOST_CONTEXT_CHANGED_METHOD = "ui/notifications/host-context-changed"; |
| 276 | const OPEN_LINK_METHOD = "ui/open-link"; |
| 277 | const 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. |
| 281 | const SHORTCUTS_METHOD = "t3/notifications/shortcuts"; |
| 282 | const KEY_DOWN_METHOD = "t3/notifications/keydown"; |
| 283 | |
| 284 | /** A render's measured height and intrinsic width from its size-changed notification. */ |
| 285 | export 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 | */ |
| 303 | export 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. */ |
| 323 | export 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. */ |
| 355 | export 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 | */ |
| 369 | export 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. */ |
| 377 | export function htmlRenderResult(id: string | number) { |
| 378 | return { jsonrpc: "2.0", id, result: {} } as const; |
| 379 | } |
| 380 | |
| 381 | const THEME_FRAGMENT_KEY = "t3-theme"; |
| 382 | |
| 383 | /** URL fragment that hands a render its theme before first paint. */ |
| 384 | export 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. */ |
| 389 | export 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. |
| 399 | const 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 | |
| 405 | function 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. |
| 425 | const 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 | |
| 427 | function 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. |
| 445 | const 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 | */ |
| 475 | export 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 | } |