summaryrefslogtreecommitdiff
path: root/packages/markdown/remark/src/remark-shiki.ts
blob: 314a0e38e45ef2b3ffb2ff657e3cb85395fd53f5 (plain) (blame)
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
import type * as shiki from 'shiki';
import { getHighlighter } from 'shiki';
import { visit } from 'unist-util-visit';

export interface ShikiConfig {
	/**
	 * The languages loaded to Shiki.
	 * Supports all languages listed here: https://github.com/shikijs/shiki/blob/main/docs/languages.md#all-languages
	 * Instructions for loading a custom language: https://github.com/shikijs/shiki/blob/main/docs/languages.md#supporting-your-own-languages-with-shiki
	 *
	 * @default []
	 */
	langs?: shiki.ILanguageRegistration[];
	/**
	 * The styling theme.
	 * Supports all themes listed here: https://github.com/shikijs/shiki/blob/main/docs/themes.md#all-themes
	 * Instructions for loading a custom theme: https://github.com/shikijs/shiki/blob/main/docs/themes.md#loading-theme
	 *
	 * @default "github-dark"
	 */
	theme?: shiki.IThemeRegistration;
	/**
	 * Enable word wrapping.
	 *  - true: enabled.
	 *  - false: enabled.
	 *  - null: All overflow styling removed. Code will overflow the element by default.
	 *
	 * @default false
	 */
	wrap?: boolean | null;
}

/**
 * getHighlighter() is the most expensive step of Shiki. Instead of calling it on every page,
 * cache it here as much as possible. Make sure that your highlighters can be cached, state-free.
 * We make this async, so that multiple calls to parse markdown still share the same highlighter.
 */
const highlighterCacheAsync = new Map<string, Promise<shiki.Highlighter>>();

const remarkShiki = async (
	{ langs = [], theme = 'github-dark', wrap = false }: ShikiConfig,
	scopedClassName?: string | null
) => {
	const cacheID: string = typeof theme === 'string' ? theme : theme.name;
	let highlighterAsync = highlighterCacheAsync.get(cacheID);
	if (!highlighterAsync) {
		highlighterAsync = getHighlighter({ theme });
		highlighterCacheAsync.set(cacheID, highlighterAsync);
	}
	const highlighter = await highlighterAsync;

	// NOTE: There may be a performance issue here for large sites that use `lang`.
	// Since this will be called on every page load. Unclear how to fix this.
	for (const lang of langs) {
		await highlighter.loadLanguage(lang);
	}

	return () => (tree: any) => {
		visit(tree, 'code', (node) => {
			let html = highlighter!.codeToHtml(node.value, { lang: node.lang ?? 'plaintext' });

			// Q: Couldn't these regexes match on a user's inputted code blocks?
			// A: Nope! All rendered HTML is properly escaped.
			// Ex. If a user typed `<span class="line"` into a code block,
			// It would become this before hitting our regexes:
			// &lt;span class=&quot;line&quot;

			// Replace "shiki" class naming with "astro" and add "is:raw".
			html = html.replace(
				'<pre class="shiki"',
				`<pre is:raw class="astro-code${scopedClassName ? ' ' + scopedClassName : ''}"`
			);
			// Replace "shiki" css variable naming with "astro".
			html = html.replace(
				/style="(background-)?color: var\(--shiki-/g,
				'style="$1color: var(--astro-code-'
			);
			// Handle code wrapping
			// if wrap=null, do nothing.
			if (wrap === false) {
				html = html.replace(/style="(.*?)"/, 'style="$1; overflow-x: auto;"');
			} else if (wrap === true) {
				html = html.replace(
					/style="(.*?)"/,
					'style="$1; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;"'
				);
			}

			// Apply scopedClassName to all nested lines
			if (scopedClassName) {
				html = html.replace(/\<span class="line"\>/g, `<span class="line ${scopedClassName}"`);
			}

			node.type = 'html';
			node.value = html;
			node.children = [];
		});
	};
};

export default remarkShiki;