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
9export const HTML_RENDER_TOOL_NAME = "html_render";
10export const HTML_RENDER_MIN_HEIGHT = 80;
11export const HTML_RENDER_MAX_HEIGHT = 2000;
12// A client fits the frame to the live page up to this height. It only stops a
13// page that grows with its frame, since vh units are rejected at publish.
14export const HTML_RENDER_MAX_FIT_HEIGHT = 20_000;
15export const HTML_RENDER_MAX_TITLE_LENGTH = 200;
16
17export interface HtmlRenderReference {
18 readonly attachmentId: string;
19 readonly title: string;
20 readonly height: number;
21 readonly heights?: ReadonlyArray<readonly [width: number, height: number]>;
22}
23
24export const HTML_RENDER_MEASURE_WIDTHS = [320, 375, 430, 520, 640, 728, 860, 1000, 1144] as const;
25
26export const HTML_RENDER_COLUMN_WIDTH = 728;
27
28const MAX_MEASURED_HEIGHTS = 24;
29
30function readMeasuredHeights(value: unknown) {
31 if (!Array.isArray(value) || value.length === 0 || value.length > MAX_MEASURED_HEIGHTS) {
32 return undefined;
33 }
34 const heights = value.flatMap((entry) =>
35 Array.isArray(entry) &&
36 entry.length === 2 &&
37 Number.isInteger(entry[0]) &&
38 entry[0] >= 1 &&
39 entry[0] <= 10_000 &&
40 typeof entry[1] === "number" &&
41 Number.isFinite(entry[1])
42 ? [[entry[0] as number, clampHtmlRenderHeight(entry[1])] as const]
43 : [],
44 );
45 return heights.length === value.length
46 ? [...heights].sort((left, right) => left[0] - right[0])
47 : undefined;
48}
49
50export function readHtmlRenderReference(value: unknown): HtmlRenderReference | undefined {
51 if (typeof value !== "object" || value === null) return undefined;
52 const { attachmentId, title, height, heights } = value as Record<string, unknown>;
53 if (
54 typeof attachmentId !== "string" ||
55 attachmentId.length === 0 ||
56 attachmentId.length > 256 ||
57 typeof title !== "string" ||
58 typeof height !== "number" ||
59 !Number.isFinite(height)
60 ) {
61 return undefined;
62 }
63 const measured = readMeasuredHeights(heights);
64 return {
65 attachmentId,
66 title: title.trim().slice(0, HTML_RENDER_MAX_TITLE_LENGTH) || "HTML",
67 height: clampHtmlRenderHeight(height),
68 ...(measured === undefined ? {} : { heights: measured }),
69 };
70}
71
72export function htmlRenderReferencesEqual(left: HtmlRenderReference, right: HtmlRenderReference) {
73 return (
74 left.attachmentId === right.attachmentId &&
75 left.title === right.title &&
76 left.height === right.height &&
77 (left.heights ?? []).length === (right.heights ?? []).length &&
78 (left.heights ?? []).every(
79 ([width, height], index) =>
80 right.heights?.[index]?.[0] === width && right.heights[index][1] === height,
81 )
82 );
83}
84
85// Use the taller neighboring measurement because breakpoints can select either height.
86function measuredHeight(heights: NonNullable<HtmlRenderReference["heights"]>, width: number) {
87 const above = heights.findIndex(([measuredWidth]) => measuredWidth >= width);
88 const high = above === -1 ? heights.length - 1 : above;
89 const low = heights[high]![0] === width ? high : Math.max(0, high - 1);
90 return Math.max(heights[low]![1], heights[high]![1]);
91}
92
93/**
94 * A page even a few pixels taller than its frame scrolls inside it and takes
95 * the reader's scroll, so the frame fits the page. A client that measured the
96 * live page passes `contentHeight`, which wins over the agent's height: client
97 * fonts often wrap the page taller than the server or agent measured it.
98 */
99export function htmlRenderFrameHeight(
100 reference: HtmlRenderReference,
101 width: number,
102 contentHeight?: number,
103) {
104 if (contentHeight !== undefined) {
105 return Math.min(
106 HTML_RENDER_MAX_FIT_HEIGHT,
107 Math.max(HTML_RENDER_MIN_HEIGHT, Math.ceil(contentHeight)),
108 );
109 }
110 const heights = reference.heights;
111 if (heights === undefined || heights.length === 0) {
112 return clampHtmlRenderHeight(reference.height);
113 }
114 const cap =
115 measuredHeight(heights, HTML_RENDER_COLUMN_WIDTH) > reference.height
116 ? reference.height
117 : HTML_RENDER_MAX_HEIGHT;
118 return clampHtmlRenderHeight(Math.min(cap, measuredHeight(heights, width)));
119}
120
121export function htmlRenderFileName(title: string) {
122 const name = title
123 .replace(/[\\/:*?"<>|\p{Cc}]+/gu, " ")
124 .replace(/\s+/g, " ")
125 .trim()
126 .slice(0, 120)
127 .trim();
128 return `${name || "Page"}.html`;
129}
130
131export function clampHtmlRenderHeight(height: number): number {
132 return Math.min(HTML_RENDER_MAX_HEIGHT, Math.max(HTML_RENDER_MIN_HEIGHT, Math.round(height)));
133}
134
135export interface HtmlRenderFonts {
136 readonly sans: string;
137 readonly mono: string;
138}
139
140export const HTML_RENDER_DEFAULT_FONTS: HtmlRenderFonts = {
141 sans: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
142 // Concrete names only: some engines alias `ui-monospace` to the proportional UI font.
143 mono: '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace',
144};
145
146/** Arial-metric substitutes keep measured wrapping close to client system fonts. */
147export const HTML_RENDER_MEASURE_FONTS: HtmlRenderFonts = {
148 sans: '"Liberation Sans", Arimo, Arial, Helvetica, sans-serif',
149 mono: '"Liberation Mono", Cousine, Menlo, Consolas, monospace',
150};
151
152export interface HtmlRenderTheme {
153 readonly appearance: ThemeAppearance;
154 readonly variables: Readonly<Record<string, string>>;
155}
156
157const FIXED_COLORS = {
158 light: {
159 success: "#10b981",
160 successForeground: "#047857",
161 info: "#3b82f6",
162 infoForeground: "#1d4ed8",
163 chart: ["#0d9488", "#d97706", "#9333ea", "#e11d48", "#65a30d"],
164 },
165 dark: {
166 success: "#10b981",
167 successForeground: "#34d399",
168 info: "#3b82f6",
169 infoForeground: "#60a5fa",
170 chart: ["#2dd4bf", "#fbbf24", "#c084fc", "#fb7185", "#a3e635"],
171 },
172} as const;
173
174/** Uses app tokens, except --accent is the brand color rather than a neutral hover surface. */
175export function htmlRenderTheme(
176 colors: ThemeColors,
177 appearance: ThemeAppearance,
178 fonts: HtmlRenderFonts = HTML_RENDER_DEFAULT_FONTS,
179): HtmlRenderTheme {
180 const fixed = FIXED_COLORS[appearance];
181 return {
182 appearance,
183 variables: {
184 "--background": colors.canvas,
185 "--foreground": colors.text,
186 "--muted": colors.muted,
187 "--muted-foreground": colors.mutedForeground,
188 "--card": colors.surface,
189 "--card-foreground": colors.text,
190 "--popover": colors.surfaceOverlay,
191 "--popover-foreground": colors.text,
192 "--secondary": colors.secondary,
193 "--secondary-foreground": colors.secondaryForeground,
194 "--border": colors.border,
195 "--input": colors.input,
196 "--ring": colors.focus,
197 "--primary": colors.messageAction,
198 "--primary-foreground": colors.messageActionForeground,
199 "--accent": colors.accent,
200 "--accent-foreground": colors.accentForeground,
201 "--accent-surface": colors.accentSurface,
202 "--accent-surface-foreground": colors.accentSurfaceForeground,
203 "--destructive": colors.error,
204 "--destructive-foreground": colors.errorForeground,
205 "--destructive-surface": colors.errorSurface,
206 "--warning": colors.warning,
207 "--warning-foreground": colors.warningForeground,
208 "--warning-surface": colors.warningSurface,
209 "--success": fixed.success,
210 "--success-foreground": fixed.successForeground,
211 "--info": fixed.info,
212 "--info-foreground": fixed.infoForeground,
213 "--code-background": colors.codeBackground,
214 "--code-foreground": colors.codeForeground,
215 "--chart-1": colors.accent,
216 ...Object.fromEntries(fixed.chart.map((color, index) => [`--chart-${index + 2}`, color])),
217 "--radius": "0.625rem",
218 "--font-sans": fonts.sans,
219 "--font-mono": fonts.mono,
220 },
221 };
222}
223
224export const HTML_RENDER_THEME_GUIDE = [
225 "Use theme CSS variables on :root, not fixed colors:",
226 "--background, --foreground, --muted, --card, --popover, --secondary, --border, --ring, --primary, --accent, --destructive, --warning, --success, --info, --code-background",
227 "(most have a matching -foreground; accent, destructive, and warning also have -surface),",
228 "--chart-1 to --chart-6, --radius, --font-sans, --font-mono.",
229 "The base sheet sets background, text, and font.",
230].join(" ");
231
232export const HTML_RENDER_LAYOUT_GUIDE = [
233 `Frame width is reply width (${HTML_RENDER_COLUMN_WIDTH}px desktop, about 360px phones), on the thread background.`,
234 "Leave html, body, and the outer element with no background color, no outer card, border, banner title, or side padding. This wins over any rule that sets a page background.",
235 "T3 adds an overridable 8px html gutter for selection rings and shadows. Use fluid widths within it.",
236 "A box that needs its own background gets 16px padding and var(--radius) corners. Give charts fixed pixel heights.",
237 "Let content set the height. Do not use vh units or height:100% on html or body. Both are rejected or loop.",
238].join(" ");
239
240const HOST_CONTEXT_CHANGED_METHOD = "ui/notifications/host-context-changed";
241const OPEN_LINK_METHOD = "ui/open-link";
242const SIZE_CHANGED_METHOD = "ui/notifications/size-changed";
243// Custom bridge forwards app shortcuts because focused frames otherwise capture them.
244const SHORTCUTS_METHOD = "t3/notifications/shortcuts";
245const KEY_DOWN_METHOD = "t3/notifications/keydown";
246const OPEN_IMAGE_METHOD = "t3/notifications/open-image";
247
248export function readHtmlRenderContentHeight(data: unknown): number | undefined {
249 if (typeof data !== "object" || data === null) return undefined;
250 const { jsonrpc, method, params } = data as Record<string, unknown>;
251 if (jsonrpc !== "2.0" || method !== SIZE_CHANGED_METHOD) return undefined;
252 const height =
253 typeof params === "object" && params !== null
254 ? (params as { height?: unknown }).height
255 : undefined;
256 return typeof height === "number" && Number.isFinite(height) && height > 0 ? height : undefined;
257}
258
259export function readHtmlRenderLinkRequest(data: unknown):
260 | {
261 readonly id: string | number;
262 readonly url: string;
263 readonly metaKey: boolean;
264 readonly ctrlKey: boolean;
265 }
266 | undefined {
267 if (typeof data !== "object" || data === null) return undefined;
268 const { jsonrpc, id, method, params } = data as Record<string, unknown>;
269 if (jsonrpc !== "2.0" || method !== OPEN_LINK_METHOD) return undefined;
270 if (typeof id !== "string" && typeof id !== "number") return undefined;
271 if (typeof params !== "object" || params === null) return undefined;
272 const { url, metaKey, ctrlKey } = params as Record<string, unknown>;
273 return typeof url === "string" && /^https?:\/\//i.test(url)
274 ? { id, url, metaKey: metaKey === true, ctrlKey: ctrlKey === true }
275 : undefined;
276}
277
278interface HtmlRenderImage {
279 readonly src: string;
280 readonly name: string;
281}
282
283function readHtmlRenderImage(src: unknown, name: unknown): HtmlRenderImage | undefined {
284 if (typeof src !== "string" || !/^(?:https?:\/\/|data:image\/)/i.test(src)) return undefined;
285 const trimmed = typeof name === "string" ? name.trim() : "";
286 // The page sends any alt text; never end the label on half an emoji.
287 const last = trimmed.charCodeAt(HTML_RENDER_MAX_TITLE_LENGTH - 1);
288 const end = HTML_RENDER_MAX_TITLE_LENGTH - (last >= 0xd800 && last <= 0xdbff ? 1 : 0);
289 const label = trimmed.slice(0, end);
290 return { src, name: label || "Image" };
291}
292
293/**
294 * Published local images arrive as `data:image/` URLs. `images` and `index` carry the
295 * page's other images; a page bootstrapped by an older server sends only `src` and `name`.
296 */
297export function readHtmlRenderImageRequest(
298 data: unknown,
299): { readonly images: ReadonlyArray<HtmlRenderImage>; readonly index: number } | undefined {
300 if (typeof data !== "object" || data === null) return undefined;
301 const { jsonrpc, method, params } = data as Record<string, unknown>;
302 if (jsonrpc !== "2.0" || method !== OPEN_IMAGE_METHOD) return undefined;
303 if (typeof params !== "object" || params === null) return undefined;
304 const { src, name, images, index } = params as Record<string, unknown>;
305 const clicked = readHtmlRenderImage(src, name);
306 if (clicked === undefined) return undefined;
307 const gallery: HtmlRenderImage[] = [];
308 let at = -1;
309 if (Array.isArray(images)) {
310 images.forEach((entry: unknown, position) => {
311 if (typeof entry !== "object" || entry === null) return;
312 const image = readHtmlRenderImage(
313 (entry as Record<string, unknown>).src,
314 (entry as Record<string, unknown>).name,
315 );
316 if (image === undefined) return;
317 if (position === index) at = gallery.length;
318 gallery.push(image);
319 });
320 }
321 return at >= 0 && gallery[at]?.src === clicked.src
322 ? { images: gallery, index: at }
323 : { images: [clicked], index: 0 };
324}
325
326export function readHtmlRenderKeyDown(data: unknown):
327 | {
328 readonly key: string;
329 readonly code: string;
330 readonly metaKey: boolean;
331 readonly ctrlKey: boolean;
332 readonly shiftKey: boolean;
333 readonly altKey: boolean;
334 readonly repeat: boolean;
335 }
336 | undefined {
337 if (typeof data !== "object" || data === null) return undefined;
338 const { jsonrpc, method, params } = data as Record<string, unknown>;
339 if (jsonrpc !== "2.0" || method !== KEY_DOWN_METHOD) return undefined;
340 if (typeof params !== "object" || params === null) return undefined;
341 const { key, code, metaKey, ctrlKey, shiftKey, altKey, repeat } = params as Record<
342 string,
343 unknown
344 >;
345 if (typeof key !== "string" || typeof code !== "string") return undefined;
346 return {
347 key,
348 code,
349 metaKey: metaKey === true,
350 ctrlKey: ctrlKey === true,
351 shiftKey: shiftKey === true,
352 altKey: altKey === true,
353 repeat: repeat === true,
354 };
355}
356
357export interface HtmlRenderShortcut {
358 readonly key: string;
359 readonly metaKey: boolean;
360 readonly ctrlKey: boolean;
361 readonly shiftKey: boolean;
362 readonly altKey: boolean;
363}
364
365export function htmlRenderShortcutsMessage(
366 shortcuts: ReadonlyArray<HtmlRenderShortcut>,
367 blocked: ReadonlyArray<HtmlRenderShortcut>,
368) {
369 return { jsonrpc: "2.0", method: SHORTCUTS_METHOD, params: { shortcuts, blocked } } as const;
370}
371
372export function htmlRenderResult(id: string | number) {
373 return { jsonrpc: "2.0", id, result: {} } as const;
374}
375
376const THEME_FRAGMENT_KEY = "t3-theme";
377
378export function htmlRenderThemeFragment(theme: HtmlRenderTheme): string {
379 return `#${THEME_FRAGMENT_KEY}=${encodeURIComponent(JSON.stringify(theme))}`;
380}
381
382export function htmlRenderThemeMessage(theme: HtmlRenderTheme) {
383 return {
384 jsonrpc: "2.0",
385 method: HOST_CONTEXT_CHANGED_METHOD,
386 params: { theme: theme.appearance, styles: { variables: theme.variables } },
387 } as const;
388}
389
390const BASE_CSS =
391 "html{box-sizing:border-box;padding:8px;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}" +
392 "html::-webkit-scrollbar{display:none}body{margin:0}code,kbd,pre,samp{font-family:var(--font-mono)}" +
393 // Focus outlines paint inside the element, so the frame edge never clips them.
394 ":where(:focus-visible){outline:2px solid var(--ring)}:focus-visible{outline-width:2px!important;outline-offset:-2px!important}" +
395 ":where(img:not(a[href] img,button img)){cursor:zoom-in}";
396
397function rootRule(theme: HtmlRenderTheme): string {
398 const declarations = Object.entries(theme.variables)
399 .map(([name, value]) => `${name}:${value};`)
400 .join("");
401 return `:root{color-scheme:${theme.appearance};${declarations}}`;
402}
403
404// Apply overridable theme CSS before paint and strip the fragment before routing; framed navigation uses the client.
405// Forward shortcuts after page handlers; text fields retain their keys.
406const 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,o,z=function(){var r=document.documentElement,v=Math.ceil(r.scrollHeight>r.clientHeight?r.scrollHeight:r.getBoundingClientRect().height);if(v===h)return;h=v;window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(SIZE_CHANGED_METHOD)},params:{height:v}},"*");};if(window.ResizeObserver){o=new ResizeObserver(z);o.observe(document.documentElement);}document.addEventListener("DOMContentLoaded",function(){if(o&&document.body)o.observe(document.body);z();});window.addEventListener("load",z);window.addEventListener("click",function(e){var t=e.composedPath()[0],u;if(!e.isTrusted||e.defaultPrevented||e.button!==0||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||!t||t.tagName!=="IMG"||t.closest("a[href],button"))return;u=t.currentSrc||t.src;if(u)setTimeout(function(){var g=[],i=-1;if(e.defaultPrevented)return;Array.prototype.forEach.call(document.images,function(m){var v=m.currentSrc||m.src;if(!v||m.closest("a[href],button"))return;if(m===t)i=g.length;g.push({src:v,name:m.alt||m.title||""});});window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(OPEN_IMAGE_METHOD)},params:{src:u,name:t.alt||t.title||"",images:g,index:i}},"*");});});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():"",l==="+"&&!e.shiftKey&&!e.altKey&&!/^Digit/.test(c)?"":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);}})();`;
407
408function bootstrapMarkup(markup: string): string {
409 const dark = htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark");
410 const light = htmlRenderTheme(T3_CODE_LIGHT_THEME_COLORS, "light");
411 const defaultCss = `${rootRule(dark)}@media (prefers-color-scheme: light){${rootRule(light)}}${BASE_CSS}`;
412 return [
413 /<meta\s[^>]*charset/i.test(markup.slice(0, 4096)) ? "" : '<meta charset="utf-8">',
414 /<meta\s[^>]*name\s*=\s*["']?viewport/i.test(markup)
415 ? ""
416 : '<meta name="viewport" content="width=device-width, initial-scale=1">',
417 `<style id="t3-theme">${defaultCss}</style>`,
418 `<script>${BOOTSTRAP_SCRIPT}</script>`,
419 ].join("");
420}
421
422// Blank inert content without shifting offsets so it cannot receive the bootstrap.
423const blankNonMarkup = (html: string) => {
424 const scan = html.replace(
425 /<!--[\s\S]*?(?:-->|$)|<(script|style|textarea|title|xmp|iframe|noembed|noframes|noscript)\b[\s\S]*?(?:<\/\1\s*>|$)|<plaintext\b[\s\S]*$/gi,
426 (match) => " ".repeat(match.length),
427 );
428 const parts: string[] = [];
429 let depth = 0;
430 let start = 0;
431 let at = 0;
432 for (const match of scan.matchAll(/<(\/?)template(?:\s[^>]*)?\/?>/gi)) {
433 if (!match[1]) {
434 if (depth++ === 0) start = match.index;
435 } else if (depth > 0 && --depth === 0) {
436 const end = match.index + match[0].length;
437 parts.push(scan.slice(at, start), " ".repeat(end - start));
438 at = end;
439 }
440 }
441 if (depth > 0) {
442 parts.push(scan.slice(at, start), " ".repeat(scan.length - start));
443 at = scan.length;
444 }
445 parts.push(scan.slice(at));
446 return parts.join("");
447};
448
449export function injectHtmlRenderBootstrap(html: string): string {
450 const scan = blankNonMarkup(html);
451 const markup = bootstrapMarkup(scan);
452 const headOpen = /<head(?:\s(?:[^"'>]|"[^"]*"|'[^']*')*)?>/i.exec(scan);
453 if (headOpen) {
454 const at = headOpen.index + headOpen[0].length;
455 return html.slice(0, at) + markup + html.slice(at);
456 }
457 const htmlOpen = /<html(?:\s(?:[^"'>]|"[^"]*"|'[^']*')*)?>/i.exec(scan);
458 if (htmlOpen) {
459 const at = htmlOpen.index + htmlOpen[0].length;
460 return `${html.slice(0, at)}<head>${markup}</head>${html.slice(at)}`;
461 }
462 const doctype = /^\s*<!doctype[^>]*>/i.exec(html);
463 if (doctype) {
464 const at = doctype[0].length;
465 return `${html.slice(0, at)}<head>${markup}</head>${html.slice(at)}`;
466 }
467 return `<!doctype html><head>${markup}</head>${html}`;
468}