Skip to content
Source
Sign inSign up

HtmlRender.ts

602 lines22.5 KB

1import type { ThreadId } from "@t3tools/contracts";
2import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
3import * as HostProcess from "@t3tools/shared/HostProcess";
4import { isNetworkPath } from "@t3tools/shared/path";
5import {
6 clampHtmlRenderHeight,
7 HTML_RENDER_COLUMN_WIDTH,
8 HTML_RENDER_MAX_TITLE_LENGTH,
9 HTML_RENDER_MEASURE_FONTS,
10 HTML_RENDER_MEASURE_WIDTHS,
11 htmlRenderTheme,
12 htmlRenderThemeFragment,
13 injectHtmlRenderBootstrap,
14 type HtmlRenderReference,
15} from "@t3tools/shared/htmlRender";
16import {
17 T3_CODE_DARK_THEME_COLORS,
18 T3_CODE_LIGHT_THEME_COLORS,
19 type ThemeAppearance,
20} from "@t3tools/shared/themePalettes";
21import * as Context from "effect/Context";
22import * as Effect from "effect/Effect";
23import * as FileSystem from "effect/FileSystem";
24import * as Layer from "effect/Layer";
25import * as Option from "effect/Option";
26import * as Path from "effect/Path";
27import * as Schema from "effect/Schema";
28import * as Semaphore from "effect/Semaphore";
29import * as Stream from "effect/Stream";
30import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
31​
32import { openMediaFile } from "../assets/MediaFile.ts";
33import { resolveAttachmentRelativePath } from "../attachmentPaths.ts";
34import { createAttachmentId } from "../attachmentStore.ts";
35import { makeIsServerInternalPath } from "../auth/serverInternalPaths.ts";
36import { resolveRootCliCommand } from "../cli/invocation.ts";
37import * as ServerConfig from "../config.ts";
38import * as HeadlessChrome from "./headlessChrome.ts";
39import * as PreviewBrowser from "../preview/PreviewBrowser.ts";
40import * as PreviewBrowserHost from "../preview/PreviewBrowserHost.ts";
41​
42const MIB = 1024 * 1024;
43const MAX_IMAGE_BYTES = 10 * MIB;
44const MAX_PAGE_BYTES = 25 * MIB;
45const HTML_FILE_EXTENSION = /\.html?$/i;
46// Like the image check, judge the bytes: a renamed .env or key does not start like a page.
47const HTML_DOCUMENT_START = /^\s*<(?:!doctype\s+html|html)[\s>]/i;
48const formatMib = (bytes: number) => `${(bytes / MIB).toFixed(1)} MiB`;
49​
50export class HtmlRenderFileError extends Schema.TaggedError<HtmlRenderFileError>()(
51 "HtmlRenderFileError",
52 { path: Schema.String, cause: Schema.optional(Schema.Defect()) },
53) {
54 override get message(): string {
55 return `${this.path} is not a readable HTML document. Pass an absolute path to a regular .html file that starts with <!doctype html> or <html>, or pass html instead.`;
56 }
58​
59export class HtmlRenderFileTooLargeError extends Schema.TaggedError<HtmlRenderFileTooLargeError>()(
60 "HtmlRenderFileTooLargeError",
61 { path: Schema.String, sizeBytes: Schema.Number },
62) {
63 override get message(): string {
64 return `${this.path} is ${formatMib(this.sizeBytes)}; a page must be at most ${formatMib(MAX_PAGE_BYTES)}.`;
65 }
67​
68export class HtmlRenderImagesNotFoundError extends Schema.TaggedError<HtmlRenderImagesNotFoundError>()(
69 "HtmlRenderImagesNotFoundError",
70 { paths: Schema.Array(Schema.String) },
71) {
72 override get message(): string {
73 return `These local images could not be read: ${this.paths.join(", ")}. Use absolute paths to existing image files, or remove them.`;
74 }
76​
77export class HtmlRenderImageTooLargeError extends Schema.TaggedError<HtmlRenderImageTooLargeError>()(
78 "HtmlRenderImageTooLargeError",
79 { path: Schema.String, sizeBytes: Schema.Number },
80) {
81 override get message(): string {
82 return `${this.path} is ${formatMib(this.sizeBytes)}; each local image must be at most ${formatMib(MAX_IMAGE_BYTES)}.`;
83 }
85​
86export class HtmlRenderPageTooLargeError extends Schema.TaggedError<HtmlRenderPageTooLargeError>()(
87 "HtmlRenderPageTooLargeError",
88 { sizeBytes: Schema.Number },
89) {
90 override get message(): string {
91 return `With its images inlined the page is ${formatMib(this.sizeBytes)}; the limit is ${formatMib(MAX_PAGE_BYTES)}. Use smaller images.`;
92 }
94​
95export class HtmlRenderViewportHeightError extends Schema.TaggedError<HtmlRenderViewportHeightError>()(
96 "HtmlRenderViewportHeightError",
97 { found: Schema.String },
98) {
99 override get message(): string {
100 return `The page uses a viewport height unit (${this.found}). The frame grows to fit the page, so a size that follows the viewport makes it grow again and again. Let content set the height, or use fixed pixel heights.`;
101 }
103​
104// Check only stylesheets and style attributes, even inside scripts; skip comments whole.
105// Consume unclosed comments, tags, or quotes through EOF to keep both patterns linear.
106const CSS_SOURCE =
107 /<!--[\s\S]*?(?:-->|$)|<style\b[^>]*(?:>|$)([\s\S]*?)(?:<\/style\s*>|$)|\sstyle\s*=\s*(?:"([^"]*)(?:"|$)|'([^']*)(?:'|$)|([^\s>]+))/gi;
108// The lookbehind skips base64 runs, such as an inlined image that happens to contain "1vh".
109const VIEWPORT_HEIGHT_UNIT = /(?<![\w.+/])(?:\d+(?:\.\d+)?|\.\d+)[sld]?vh(?![\w-])/i;
110​
111const rejectViewportHeight = (html: string) => {
112 for (const [, sheet, doubleQuoted, singleQuoted, unquoted] of html.matchAll(CSS_SOURCE)) {
113 const css = sheet ?? doubleQuoted ?? singleQuoted ?? unquoted ?? "";
114 const found = VIEWPORT_HEIGHT_UNIT.exec(css)?.[0];
115 if (found !== undefined) return Effect.fail(new HtmlRenderViewportHeightError({ found }));
116 }
117 return Effect.void;
118};
119​
120export class HtmlRenderStoreError extends Schema.TaggedError<HtmlRenderStoreError>()(
121 "HtmlRenderStoreError",
122 { cause: Schema.Defect() },
123) {
124 override get message(): string {
125 return "The HTML render could not be saved.";
126 }
128​
129export type HtmlRenderPrepareError =
130 | HtmlRenderImagesNotFoundError
131 | HtmlRenderImageTooLargeError
132 | HtmlRenderPageTooLargeError
133 | HtmlRenderViewportHeightError;
134​
135export type HtmlRenderSource = { readonly html: string } | { readonly path: string };
136​
137export interface HtmlPreview {
138 readonly png: string;
139 readonly width: number;
140 readonly contentHeight: number;
141 readonly capturedHeight: number;
142 readonly consoleMessages: ReadonlyArray<HeadlessChrome.ConsoleMessage>;
143 readonly missingImages?: ReadonlyArray<string>;
145​
146export class HtmlRender extends Context.Service<
147 HtmlRender,
148 {
149 readonly prepare: (html: string) => Effect.Effect<string, HtmlRenderPrepareError>;
150 readonly publish: (
151 input: HtmlRenderSource & {
152 readonly threadId: ThreadId;
153 readonly title: string;
154 readonly height: number;
155 },
156 ) => Effect.Effect<
157 HtmlRenderReference,
158 | HtmlRenderFileError
159 | HtmlRenderFileTooLargeError
160 | HtmlRenderPrepareError
161 | HtmlRenderStoreError
162 >;
163 readonly preview: (
164 input: HtmlRenderSource & {
165 readonly width?: number | undefined;
166 readonly appearance?: ThemeAppearance | undefined;
167 },
168 ) => Effect.Effect<
169 HtmlPreview,
170 | HtmlRenderFileError
171 | HtmlRenderFileTooLargeError
172 | Exclude<HtmlRenderPrepareError, HtmlRenderImagesNotFoundError>
173 | PreviewBrowser.PreviewBrowserInstallError
174 | PreviewBrowser.PreviewBrowserInstallingError
175 | PreviewBrowser.PreviewBrowserUnsupportedError
176 | PreviewBrowserHost.PreviewBrowserHostError
177 | HeadlessChrome.HtmlRenderBrowserError
178 >;
179 }
180>()("t3/htmlRender/HtmlRender") {}
181​
182const IMAGE_MIME_TYPES: Record<string, string> = {
183 png: "image/png",
184 jpg: "image/jpeg",
185 jpeg: "image/jpeg",
186 gif: "image/gif",
187 webp: "image/webp",
188 avif: "image/avif",
189 svg: "image/svg+xml",
190 bmp: "image/bmp",
191 ico: "image/x-icon",
192};
193const IMAGE_EXTENSIONS = Object.keys(IMAGE_MIME_TYPES).join("|");
194const ABSOLUTE_PATH = String.raw`(?:/(?!/)|[a-z]:[\\/])`;
195const LOCAL_IMAGE_PATTERN = new RegExp(
196 String.raw`(["'\x60])(${ABSOLUTE_PATH}(?:(?!\1)[^\r\n]){0,2048}?\.(?:${IMAGE_EXTENSIONS}))\1` +
197 String.raw`|url\(\s*(${ABSOLUTE_PATH}[^\s"'\x60()]{0,2048}?\.(?:${IMAGE_EXTENSIONS}))\s*\)`,
198 "gid",
199);
200​
201const findLocalImages = (html: string) =>
202 Array.from(html.matchAll(LOCAL_IMAGE_PATTERN)).flatMap((match) => {
203 const span = match.indices?.[2] ?? match.indices?.[3];
204 return span ? [{ start: span[0], end: span[1], path: html.slice(span[0], span[1]) }] : [];
205 });
206​
207// Inside a JS string literal a Windows path's backslashes are escaped.
208const filePathFor = (reference: string) =>
209 /^[a-z]:/i.test(reference) ? reference.replaceAll("\\\\", "\\") : reference;
210​
211const dataUriPrefix = (path: string) =>
212 `data:${IMAGE_MIME_TYPES[path.slice(path.lastIndexOf(".") + 1).toLowerCase()] ?? "application/octet-stream"};base64,`;
213​
214const latin1 = (bytes: Uint8Array, start: number, end: number) =>
215 String.fromCharCode(...bytes.subarray(start, end));
216​
217// Validate bytes, not filenames, so renamed files or symlinks cannot inline secrets.
218const isImageBytes = (bytes: Uint8Array) => {
219 const head = latin1(bytes, 0, 12);
220 if (
221 head.startsWith("\x89PNG") ||
222 head.startsWith("\xff\xd8\xff") ||
223 head.startsWith("GIF8") ||
224 head.startsWith("\0\0\x01\0") ||
225 (head.startsWith("BM") && head.slice(6, 10) === "\0\0\0\0") ||
226 (head.startsWith("RIFF") && head.slice(8, 12) === "WEBP") ||
227 /^ftyp(?:avif|avis|mif1)$/.test(head.slice(4, 12))
228 ) {
229 return true;
230 }
231 return hasSvgRoot(new TextDecoder().decode(bytes.subarray(0, 4096)));
232};
233​
234const after = (text: string, token: string, from: number) => {
235 const at = text.indexOf(token, from);
236 return at === -1 ? -1 : at + token.length;
237};
238​
239const hasSvgRoot = (text: string) => {
240 let at = 0;
241 while (at !== -1) {
242 while (/\s/.test(text.charAt(at))) at += 1;
243 if (text.startsWith("<?", at)) at = after(text, "?>", at + 2);
244 else if (text.startsWith("<!--", at)) at = after(text, "-->", at + 4);
245 else if (text.slice(at, at + 9).toLowerCase() === "<!doctype") at = afterDoctype(text, at + 9);
246 // XML names are case-sensitive, and only these characters can end one here.
247 else return /^<svg[ \t\r\n/>]/.test(text.slice(at, at + 5));
248 }
249 return false;
250};
251​
252const afterDoctype = (text: string, from: number) => {
253 let inSubset = false;
254 let at = from;
255 while (at !== -1 && at < text.length) {
256 const char = text[at];
257 if (char === '"' || char === "'") at = after(text, char, at + 1);
258 else if (inSubset && text.startsWith("<!--", at)) at = after(text, "-->", at + 4);
259 else if (inSubset && text.startsWith("<?", at)) at = after(text, "?>", at + 2);
260 else if (char === ">" && !inSubset) return at + 1;
261 else {
262 if (char === "[") inSubset = true;
263 else if (char === "]") inSubset = false;
264 at += 1;
265 }
266 }
267 return -1;
268};
269​
270const inlineLocalImages = Effect.fn("HtmlRender.inlineLocalImages")(function* (html: string) {
271 const fileSystem = yield* FileSystem.FileSystem;
272 const references = findLocalImages(html);
273 const files = yield* Effect.forEach(
274 [...new Set(references.map((reference) => reference.path))],
275 (path) =>
276 fileSystem.stat(filePathFor(path)).pipe(
277 Effect.map((info) => ({
278 path,
279 size: info.type === "File" ? Number(info.size) : undefined,
280 })),
281 Effect.orElseSucceed(() => ({ path, size: undefined })),
282 ),
283 { concurrency: 8 },
284 );
285 const oversized = files.find((file) => file.size !== undefined && file.size > MAX_IMAGE_BYTES);
286 if (oversized?.size !== undefined) {
287 return yield* new HtmlRenderImageTooLargeError({
288 path: oversized.path,
289 sizeBytes: oversized.size,
290 });
291 }
292 const sizes = new Map(files.map((file) => [file.path, file.size]));
293 const pageBytes = references.reduce((total, reference) => {
294 const size = sizes.get(reference.path);
295 return size === undefined
296 ? total
297 : total +
298 dataUriPrefix(reference.path).length +
299 Math.ceil(size / 3) * 4 -
300 Buffer.byteLength(reference.path);
301 }, Buffer.byteLength(html));
302 if (pageBytes > MAX_PAGE_BYTES) {
303 return yield* new HtmlRenderPageTooLargeError({ sizeBytes: pageBytes });
304 }
305 // Files can grow after stat; bound each read and stop when accumulated images cannot fit.
306 let readBytes = 0;
307 const images = yield* Effect.forEach(
308 files.filter((file) => file.size !== undefined),
309 (file) =>
310 Effect.gen(function* () {
311 const read = yield* fileSystem
312 .stream(filePathFor(file.path), { bytesToRead: MAX_IMAGE_BYTES + 1 })
313 .pipe(Stream.mkUint8Array, Effect.option);
314 if (Option.isNone(read) || !isImageBytes(read.value)) return [];
315 const bytes = read.value;
316 if (bytes.byteLength > MAX_IMAGE_BYTES) {
317 return yield* new HtmlRenderImageTooLargeError({
318 path: file.path,
319 sizeBytes: bytes.byteLength,
320 });
321 }
322 readBytes += Math.ceil(bytes.byteLength / 3) * 4;
323 if (readBytes > MAX_PAGE_BYTES) {
324 return yield* new HtmlRenderPageTooLargeError({ sizeBytes: readBytes });
325 }
326 return [{ path: file.path, bytes }];
327 }),
328 { concurrency: 4 },
329 ).pipe(Effect.map((entries) => entries.flat()));
330 const dataUris = new Map(
331 images.map(
332 (image) =>
333 [
334 image.path,
335 dataUriPrefix(image.path) + Buffer.from(image.bytes).toString("base64"),
336 ] as const,
337 ),
338 );
339 const parts: Array<string> = [];
340 let cursor = 0;
341 for (const reference of references) {
342 const dataUri = dataUris.get(reference.path);
343 if (dataUri === undefined) continue;
344 parts.push(html.slice(cursor, reference.start), dataUri);
345 cursor = reference.end;
346 }
347 parts.push(html.slice(cursor));
348 const inlined = parts.join("");
349 const inlinedBytes = Buffer.byteLength(inlined);
350 if (inlinedBytes > MAX_PAGE_BYTES) {
351 return yield* new HtmlRenderPageTooLargeError({ sizeBytes: inlinedBytes });
352 }
353 return {
354 html: inlined,
355 missing: files.filter((file) => !dataUris.has(file.path)).map((file) => file.path),
356 };
357});
358​
359const MIN_PREVIEW_WIDTH = 240;
360const MAX_PREVIEW_WIDTH = 1_600;
361const MAX_CONCURRENT_BROWSERS = 2;
362const MEASURE_TIMEOUT = "6 seconds";
363​
364const MEASURE_FRAGMENT = htmlRenderThemeFragment(
365 htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark", HTML_RENDER_MEASURE_FONTS),
366);
367​
368const make = Effect.gen(function* () {
369 const fileSystem = yield* FileSystem.FileSystem;
370 const path = yield* Path.Path;
371 const config = yield* ServerConfig.ServerConfig;
372 const isServerInternalPath = yield* makeIsServerInternalPath;
373 const previewBrowser = yield* PreviewBrowser.PreviewBrowser;
374 const mcpSessions = yield* McpProviderSessions.McpProviderSessions;
375 const services = yield* Effect.context<
376 FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
377 >();
378 const browsers = yield* Semaphore.make(MAX_CONCURRENT_BROWSERS);
379 // Keep Chrome sandboxed unless explicitly disabled; root must opt out to run.
380 const noSandbox = PreviewBrowserHost.sandboxDisabled(yield* HostProcess.Environment);
381 // sudo resets the environment, so the command has to name this server's home.
382 const setupCommand = yield* resolveRootCliCommand(PreviewBrowserHost.SETUP_SUBCOMMAND, [
383 "--base-dir",
384 config.baseDir,
385 ]);
386​
387 const launching = <A>(
388 executable: string,
389 run: (noSandbox: boolean) => Effect.Effect<A, HeadlessChrome.HtmlRenderBrowserError>,
390 ) =>
391 browsers.withPermits(1)(
392 run(noSandbox).pipe(
393 Effect.catchTags({
394 HtmlRenderBrowserError: (error) =>
395 error.output === undefined
396 ? Effect.fail(error)
397 : PreviewBrowserHost.diagnoseLaunchFailure({
398 executable,
399 setupCommand,
400 output: error.output,
401 }).pipe(
402 Effect.provideContext(services),
403 Effect.flatMap((hostError) => Effect.fail(hostError ?? error)),
404 ),
405 }),
406 ),
407 );
408​
409 const measure = Effect.fn("HtmlRender.measure")(
410 function* (html: string) {
411 const executable = yield* previewBrowser.installed;
412 if (Option.isNone(executable)) return undefined;
413 const heights = yield* launching(executable.value, (noSandbox) =>
414 HeadlessChrome.measureHtmlHeights({
415 executable: executable.value,
416 noSandbox,
417 html,
418 widths: HTML_RENDER_MEASURE_WIDTHS,
419 urlFragment: MEASURE_FRAGMENT,
420 }).pipe(Effect.provideContext(services)),
421 ).pipe(
422 Effect.timeoutOrElse({
423 duration: MEASURE_TIMEOUT,
424 orElse: () =>
425 Effect.fail(
426 new HeadlessChrome.HtmlRenderBrowserError({
427 reason: `measuring took longer than ${MEASURE_TIMEOUT}`,
428 }),
429 ),
430 }),
431 );
432 return heights.toSorted(([left], [right]) => left - right);
433 },
434 Effect.catch((cause) =>
435 Effect.logWarning("Could not measure an HTML render; publishing it without heights.", {
436 cause,
437 }).pipe(Effect.as(undefined)),
438 ),
439 );
440​
441 const readPageFile = Effect.fn("HtmlRender.readPageFile")(function* (filePath: string) {
442 const unreadable = new HtmlRenderFileError({ path: filePath });
443 if (!path.isAbsolute(filePath) || isNetworkPath(filePath)) return yield* unreadable;
444 // Check the resolved file, so a symlink named .html cannot reach another file.
445 const realPath = yield* fileSystem
446 .realPath(filePath)
447 .pipe(Effect.mapError((cause) => new HtmlRenderFileError({ path: filePath, cause })));
448 if (!HTML_FILE_EXTENSION.test(realPath) || (yield* isServerInternalPath(realPath))) {
449 return yield* unreadable;
450 }
451 // Open without following links and recheck the path, so a link or FIFO swapped in is refused.
452 const html = yield* Effect.scoped(
453 Effect.gen(function* () {
454 const file = yield* openMediaFile(realPath).pipe(
455 Effect.mapError((cause) => new HtmlRenderFileError({ path: filePath, cause })),
456 );
457 // Unlike client reads, the caller may be sandboxed: a second hard link could give any
458 // file an .html name.
459 if (file === null || file.info.nlink !== 1n) return yield* unreadable;
460 if (file.info.size > BigInt(MAX_PAGE_BYTES)) {
461 return yield* new HtmlRenderFileTooLargeError({
462 path: filePath,
463 sizeBytes: Number(file.info.size),
464 });
465 }
466 // One read may return fewer bytes, so fill up to the checked size; growth past it is ignored.
467 return yield* Effect.tryPromise({
468 try: async () => {
469 const buffer = Buffer.alloc(Number(file.info.size));
470 let filled = 0;
471 while (filled < buffer.length) {
472 const { bytesRead } = await file.handle.read(buffer, filled, buffer.length - filled);
473 if (bytesRead === 0) break;
474 filled += bytesRead;
475 }
476 return buffer.toString("utf8", 0, filled);
477 },
478 catch: (cause) => new HtmlRenderFileError({ path: filePath, cause }),
479 });
480 }),
481 );
482 return HTML_DOCUMENT_START.test(html) ? html : yield* unreadable;
483 });
484​
485 const readSource = (input: HtmlRenderSource) =>
486 "path" in input ? readPageFile(input.path) : Effect.succeed(input.html);
487​
488 // The bootstrap goes in first so its head scan never runs over inlined image data.
489 const inline = (html: string) =>
490 inlineLocalImages(injectHtmlRenderBootstrap(html)).pipe(Effect.provideContext(services));
491​
492 const prepare = Effect.fn("HtmlRender.prepare")(function* (html: string) {
493 yield* rejectViewportHeight(html);
494 const inlined = yield* inline(html);
495 if (inlined.missing.length > 0) {
496 return yield* new HtmlRenderImagesNotFoundError({ paths: inlined.missing });
497 }
498 return inlined.html;
499 });
500​
501 const publish = Effect.fn("HtmlRender.publish")(function* (
502 input: HtmlRenderSource & {
503 readonly threadId: ThreadId;
504 readonly title: string;
505 readonly height: number;
506 },
507 ) {
508 const source = yield* readSource(input);
509 const html = yield* prepare(source);
510 // The `-html` id suffix makes the asset route serve it as a sandboxed text/html document.
511 const attachmentId = createAttachmentId(input.threadId, "html");
512 const filePath =
513 attachmentId === null
514 ? null
515 : resolveAttachmentRelativePath({
516 attachmentsDir: config.attachmentsDir,
517 relativePath: `${attachmentId}.html`,
518 });
519 if (attachmentId === null || filePath === null) {
520 return yield* new HtmlRenderStoreError({ cause: new Error("Invalid thread id.") });
521 }
522 const baseReference: HtmlRenderReference = {
523 attachmentId,
524 title: input.title.trim().slice(0, HTML_RENDER_MAX_TITLE_LENGTH) || "HTML",
525 height: clampHtmlRenderHeight(input.height),
526 };
527 return yield* fileSystem.writeFileString(filePath, html).pipe(
528 Effect.mapError((cause) => new HtmlRenderStoreError({ cause })),
529 Effect.uninterruptible,
530 Effect.andThen(measure(html)),
531 Effect.andThen((heights) => {
532 const reference: HtmlRenderReference = {
533 ...baseReference,
534 ...(heights === undefined ? {} : { heights }),
535 };
536 return mcpSessions
537 .recordHtmlRender(
538 input.threadId,
539 {
540 html: source,
541 path: "path" in input ? input.path : undefined,
542 title: input.title,
543 height: input.height,
544 },
545 reference,
546 )
547 .pipe(Effect.as(reference));
548 }),
549 // An interrupted publish must leave neither an attachment nor a recoverable reference.
550 Effect.onError(() =>
551 Effect.all(
552 [
553 fileSystem.remove(filePath, { force: true }).pipe(Effect.ignore),
554 mcpSessions.takeHtmlRender(input.threadId, input, baseReference).pipe(Effect.ignore),
555 ],
556 { discard: true },
557 ),
558 ),
559 );
560 });
561​
562 const preview = Effect.fn("HtmlRender.preview")(function* (
563 input: HtmlRenderSource & {
564 readonly width?: number | undefined;
565 readonly appearance?: ThemeAppearance | undefined;
566 },
567 ) {
568 const width = Math.min(
569 MAX_PREVIEW_WIDTH,
570 Math.max(MIN_PREVIEW_WIDTH, Math.round(input.width ?? HTML_RENDER_COLUMN_WIDTH)),
571 );
572 const appearance = input.appearance ?? "dark";
573 const source = yield* readSource(input);
574 yield* rejectViewportHeight(source);
575 const inlined = yield* inline(source);
576 const executable = yield* previewBrowser.executable;
577 const theme = htmlRenderTheme(
578 appearance === "light" ? T3_CODE_LIGHT_THEME_COLORS : T3_CODE_DARK_THEME_COLORS,
579 appearance,
580 HTML_RENDER_MEASURE_FONTS,
581 );
582 const screenshot = yield* launching(executable, (noSandbox) =>
583 HeadlessChrome.captureHtmlScreenshot({
584 executable,
585 noSandbox,
586 html: inlined.html,
587 width,
588 urlFragment: htmlRenderThemeFragment(theme),
589 appearance,
590 }).pipe(Effect.provideContext(services)),
591 );
592 return {
593 ...screenshot,
594 width,
595 ...(inlined.missing.length === 0 ? {} : { missingImages: inlined.missing }),
596 } satisfies HtmlPreview;
597 });
598​
599 return HtmlRender.of({ prepare, publish, preview });
600});
601​
602export const layer = Layer.effect(HtmlRender, make);