pluralchat / src /lib /components /CodeBlock.svelte
extonlawrence's picture
Fix DOMPurify SSR issues by using dynamic imports
6e2a902
<script lang="ts">
import CopyToClipBoardBtn from "./CopyToClipBoardBtn.svelte";
import HtmlPreviewModal from "./HtmlPreviewModal.svelte";
import PlayFilledAlt from "~icons/carbon/play-filled-alt";
import EosIconsLoading from "~icons/eos-icons/loading";
import { browser } from "$app/environment";
import { onMount } from "svelte";
interface Props {
code?: string;
rawCode?: string;
loading?: boolean;
}
let { code = "", rawCode = "", loading = false }: Props = $props();
let DOMPurify: typeof import("isomorphic-dompurify").default | null = null;
let sanitizedCode = $state(code);
let previewOpen = $state(false);
function hasStrictHtml5Doctype(input: string): boolean {
if (!input) return false;
const withoutBOM = input.replace(/^\uFEFF/, "");
const trimmed = withoutBOM.trimStart();
// Strict HTML5 doctype: <!doctype html> with optional whitespace before >
return /^<!doctype\s+html\s*>/i.test(trimmed);
}
function isSvgDocument(input: string): boolean {
const trimmed = input.trimStart();
return /^(?:<\?xml[^>]*>\s*)?(?:<!doctype\s+svg[^>]*>\s*)?<svg[\s>]/i.test(trimmed);
}
let showPreview = $derived(hasStrictHtml5Doctype(rawCode) || isSvgDocument(rawCode));
onMount(async () => {
if (browser) {
const { default: purify } = await import("isomorphic-dompurify");
DOMPurify = purify;
}
});
$effect(() => {
if (DOMPurify) {
sanitizedCode = DOMPurify.sanitize(code);
} else {
sanitizedCode = code;
}
});
</script>
<div class="group relative my-4 rounded-lg">
<div class="pointer-events-none sticky top-0 w-full">
<div
class="pointer-events-auto absolute right-2 top-2 flex items-center gap-1.5 md:right-3 md:top-3"
>
{#if showPreview}
<button
class="btn h-7 gap-1 rounded-lg border px-2 text-xs shadow-sm backdrop-blur transition-none hover:border-gray-500 active:shadow-inner disabled:cursor-not-allowed disabled:opacity-60 dark:border-gray-600 dark:bg-gray-600/50 dark:hover:border-gray-500"
disabled={loading}
onclick={() => {
if (!loading) {
previewOpen = true;
}
}}
title="Preview HTML"
aria-label="Preview HTML"
>
{#if loading}
<EosIconsLoading class="size-3.5" />
{:else}
<PlayFilledAlt class="size-3.5" />
{/if}
Preview
</button>
{/if}
<CopyToClipBoardBtn
iconClassNames="size-3"
classNames="btn transition-none rounded-lg border size-7 text-sm shadow-sm dark:bg-gray-600/50 backdrop-blur dark:hover:border-gray-500 active:shadow-inner dark:border-gray-600 hover:border-gray-500"
value={rawCode}
/>
</div>
</div>
<pre class="scrollbar-custom overflow-auto px-5 font-mono transition-[height]"><code
><!-- eslint-disable svelte/no-at-html-tags -->{@html sanitizedCode}</code
></pre>
{#if previewOpen}
<HtmlPreviewModal html={rawCode} onclose={() => (previewOpen = false)} />
{/if}
</div>