import { CopyButton, DownloadButton, PreviewCodeToggle } from "@mariozechner/mini-lit"; import hljs from "highlight.js"; import { html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { createRef, type Ref, ref } from "lit/directives/ref.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import type { SandboxIframe } from "../../components/SandboxedIframe.js"; import { type MessageConsumer, RUNTIME_MESSAGE_ROUTER } from "../../components/sandbox/RuntimeMessageRouter.js"; import type { SandboxRuntimeProvider } from "../../components/sandbox/SandboxRuntimeProvider.js"; import { i18n } from "../../utils/i18n.js"; import "../../components/SandboxedIframe.js"; import { ArtifactElement } from "./ArtifactElement.js"; import type { Console } from "./Console.js"; import "./Console.js"; @customElement("html-artifact") export class HtmlArtifact extends ArtifactElement { @property() override filename = ""; @property({ attribute: false }) runtimeProviders: SandboxRuntimeProvider[] = []; @property({ attribute: false }) sandboxUrlProvider?: () => string; private _content = ""; private logs: Array<{ type: "log" | "error"; text: string }> = []; // Refs for DOM elements private sandboxIframeRef: Ref = createRef(); private consoleRef: Ref = createRef(); @state() private viewMode: "preview" | "code" = "preview"; private setViewMode(mode: "preview" | "code") { this.viewMode = mode; } public getHeaderButtons() { const toggle = new PreviewCodeToggle(); toggle.mode = this.viewMode; toggle.addEventListener("mode-change", (e: Event) => { this.setViewMode((e as CustomEvent).detail); }); const copyButton = new CopyButton(); copyButton.text = this._content; copyButton.title = i18n("Copy HTML"); copyButton.showText = false; // Generate standalone HTML with all runtime code injected for download const sandbox = this.sandboxIframeRef.value; const sandboxId = `artifact-${this.filename}`; const downloadContent = sandbox?.prepareHtmlDocument(sandboxId, this._content, this.runtimeProviders || [], true) || this._content; return html`
${toggle} ${copyButton} ${DownloadButton({ content: downloadContent, filename: this.filename, mimeType: "text/html", title: i18n("Download HTML") })}
`; } override set content(value: string) { const oldValue = this._content; this._content = value; if (oldValue !== value) { // Reset logs when content changes this.logs = []; this.requestUpdate(); // Execute content in sandbox if it exists if (this.sandboxIframeRef.value && value) { this.executeContent(value); } } } private executeContent(html: string) { const sandbox = this.sandboxIframeRef.value; if (!sandbox) return; // Configure sandbox URL provider if provided (for browser extensions) if (this.sandboxUrlProvider) { sandbox.sandboxUrlProvider = this.sandboxUrlProvider; } const sandboxId = `artifact-${this.filename}`; // Create consumer for console messages const consumer: MessageConsumer = { handleMessage: async (message: any): Promise => { if (message.type === "console") { // Create new array reference for Lit reactivity this.logs = [ ...this.logs, { type: message.method === "error" ? "error" : "log", text: message.text, }, ]; this.requestUpdate(); // Re-render to show console } }, }; // Load content - this handles sandbox registration, consumer registration, and iframe creation sandbox.loadContent(sandboxId, html, this.runtimeProviders, [consumer]); } override get content(): string { return this._content; } override disconnectedCallback() { super.disconnectedCallback(); // Unregister sandbox when element is removed from DOM const sandboxId = `artifact-${this.filename}`; RUNTIME_MESSAGE_ROUTER.unregisterSandbox(sandboxId); } override firstUpdated() { // Execute initial content if (this._content && this.sandboxIframeRef.value) { this.executeContent(this._content); } } override updated(changedProperties: Map) { super.updated(changedProperties); // If we have content but haven't executed yet (e.g., during reconstruction), // execute when the iframe ref becomes available if (this._content && this.sandboxIframeRef.value && this.logs.length === 0) { this.executeContent(this._content); } } public getLogs(): string { if (this.logs.length === 0) return i18n("No logs for {filename}").replace("{filename}", this.filename); return this.logs.map((l) => `[${l.type}] ${l.text}`).join("\n"); } override render() { return html`
${ this.logs.length > 0 ? html`` : "" }
${unsafeHTML(
							hljs.highlight(this._content, { language: "html" }).value,
						)}
`; } }