~james/core

core/web/src/lib/components/ui/Markup.svelte -rw-r--r-- 3.1 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 16 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
<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}