Skip to content
Source
Sign inSign up

htmlRender.ts

468 lines21.7 KB

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]>;
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;
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 };
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 );
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]);
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)));
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`;
130​
131export function clampHtmlRenderHeight(height: number): number {
132 return Math.min(HTML_RENDER_MAX_HEIGHT, Math.max(HTML_RENDER_MIN_HEIGHT, Math.round(height)));
134​
135export interface HtmlRenderFonts {
136 readonly sans: string;
137 readonly mono: string;
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>>;
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 };
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;
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;
277​
278interface HtmlRenderImage {
279 readonly src: string;
280 readonly name: string;
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" };
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 };
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 };
356​
357export interface HtmlRenderShortcut {
358 readonly key: string;
359 readonly metaKey: boolean;
360 readonly ctrlKey: boolean;
361 readonly shiftKey: boolean;
362 readonly altKey: boolean;
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;
371​
372export function htmlRenderResult(id: string | number) {
373 return { jsonrpc: "2.0", id, result: {} } as const;
375​
376const THEME_FRAGMENT_KEY = "t3-theme";
377​
378export function htmlRenderThemeFragment(theme: HtmlRenderTheme): string {
379 return `#${THEME_FRAGMENT_KEY}=${encodeURIComponent(JSON.stringify(theme))}`;
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;
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}}`;
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("");
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}`;