~james/core

core/web/src/lib/markup/highlight.ts -rw-r--r-- 2.8 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 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
const BLOCK =
	/<pre><code((?: [a-z-]+="[^"]*")*?) class="language-([^"]+)"((?: [a-z-]+="[^"]*")*)>([\s\S]*?)<\/code><\/pre>/g;

const MAX_BLOCK_CHARS = 200_000;

const ENTITIES: Record<string, string> = {
	amp: "&",
	lt: "<",
	gt: ">",
	quot: '"',
	"#39": "'",
	"#x27": "'",
	"#x2F": "/"
};

export const decodeEntities = (value: string): string =>
	value.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z#0-9]+);/g, (match, body: string) => {
		if (body.startsWith("#x") || body.startsWith("#X"))
			try {
				return String.fromCodePoint(parseInt(body.slice(2), 16));
			} catch {
				return match;
			}
		if (body.startsWith("#")) {
			const point = parseInt(body.slice(1), 10);
			return Number.isNaN(point) ? match : String.fromCodePoint(point);
		}
		return ENTITIES[body] ?? match;
	});

const INNER = /<code[^>]*>([\s\S]*)<\/code>\s*<\/pre>/;

type Highlighter = Awaited<ReturnType<typeof import("@pierre/diffs").getSharedHighlighter>>;

type Loaded = { highlighter: Highlighter; theme: string };

// importing this lazily keeps shiki out of markdown pages with no code
const loadHighlighter = async (langs: string[]): Promise<Loaded | null> => {
	try {
		const { SYNTAX_THEME_NAME, getSharedHighlighter } = await import("#lib/syntax.ts");
		const highlighter = await getSharedHighlighter({ themes: [SYNTAX_THEME_NAME], langs });
		return { highlighter, theme: SYNTAX_THEME_NAME };
	} catch {
		return null;
	}
};

const highlightInner = (loaded: Loaded, code: string, lang: string): string | null => {
	let full: string;
	try {
		full = loaded.highlighter.codeToHtml(code, {
			lang,
			theme: loaded.theme,
			defaultColor: false,
			cssVariablePrefix: "--diffs-token-"
		});
	} catch {
		return null;
	}
	return INNER.exec(full)?.[1] ?? null;
};

export const highlightCodeblocks = async (html: string): Promise<string> => {
	const found = [...html.matchAll(BLOCK)];
	if (found.length === 0) return html;

	const blocks = found.flatMap((match) => {
		const [raw, before, lang, after, escaped] = match as unknown as [
			string,
			string,
			string,
			string,
			string
		];
		const code = decodeEntities(escaped);
		return code.length > 0 && code.length <= MAX_BLOCK_CHARS
			? [{ raw, before, lang, after, code }]
			: [];
	});
	if (blocks.length === 0) return html;

	const loaded = await loadHighlighter([...new Set(blocks.map((block) => block.lang))]);
	if (!loaded) return html;

	const swapped = new Map<string, string>();
	for (const block of blocks) {
		if (swapped.has(block.raw)) continue;
		const inner = highlightInner(loaded, block.code, block.lang);
		if (inner !== null)
			swapped.set(
				block.raw,
				`<pre><code${block.before} class="language-${block.lang}"${block.after}>${inner}</code></pre>`
			);
	}
	if (swapped.size === 0) return html;
	return html.replace(BLOCK, (raw) => swapped.get(raw) ?? raw);
};