<script lang="ts">
import { hydratable, untrack } from "svelte";
import { browser } from "$app/env";
import { codeblockToolbars } from "#lib/components/ui/codeblockToolbars.ts";
import { mermaidDiagrams } from "#lib/components/ui/mermaidDiagrams.ts";
import { renderPreview, type MarkupContext } from "#lib/markup/index.ts";
import { getSsrBudget, PLAIN_PREVIEW_CHARS, renderOnServer } from "#lib/ssr/render.ts";
interface Props {
source: string;
ctx: MarkupContext;
document?: boolean;
tag?: "article" | "div";
class?: string;
plainClass?: string;
[attribute: `data-${string}`]: string | undefined;
}
let {
source,
ctx,
document = false,
tag = "div",
class: className,
plainClass,
...attributes
}: Props = $props();
const id = $props.id();
const SERVER_RENDERED = "<!-- server rendered -->";
const budget = getSsrBudget();
const text = $derived(source);
let last: MarkupContext | undefined;
const options = $derived.by(() => {
const next = { ...ctx, collapseCode: !document };
const keys = Object.keys({ ...last, ...next }) as (keyof MarkupContext)[];
if (last && keys.every((key) => last?.[key] === next[key])) return last;
return (last = next);
});
// hydration keeps the server's dom nodes regardless of {@html}'s value, which lets page data carry only a boolean
let serverHtml: string | null = null;
const inPage = await untrack(() =>
text
? hydratable(`markup:${id}`, async () => {
if (!budget) return false;
serverHtml = await renderOnServer(budget, text.length, () => ({
kind: "markup",
source: text,
ctx: options
}));
return serverHtml !== null;
})
: false
);
const initial = browser ? (inPage ? SERVER_RENDERED : null) : serverHtml;
let rendered = $state.raw(untrack(() => ({ source: text, ctx: options, html: initial })));
const shown = $derived(
rendered.source === text && rendered.ctx === options ? rendered.html : null
);
const preview = $derived.by(() => {
if (text.length <= PLAIN_PREVIEW_CHARS) return text;
const cut = text.slice(0, PLAIN_PREVIEW_CHARS);
return `${/[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut}…`;
});
let plain: HTMLElement | undefined = $state();
$effect(() => {
if (shown !== null || !text || !plain) return;
const from = text;
const at = options;
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
observer.disconnect();
renderPreview(from, at).then(
(html) => (rendered = { source: from, ctx: at, html }),
() => undefined
);
},
{ rootMargin: "1000px 0px" }
);
observer.observe(plain);
return () => observer.disconnect();
});
</script>
{#if shown}
<svelte:element
this={tag}
class={className}
{...attributes}
use:codeblockToolbars={shown}
use:mermaidDiagrams={shown}
>
<!-- svelte-ignore hydration_html_changed -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in $lib/markup -->
{@html shown}
</svelte:element>
{:else if text}
<svelte:element this={tag} bind:this={plain} class={plainClass} {...attributes}
>{preview}</svelte:element
>
{/if}