astro/packages/markdown/remark/src/remark-shiki.ts

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

105 lines
3.6 KiB
TypeScript
Raw Normal View History

import type * as shiki from 'shiki';
import { getHighlighter } from 'shiki';
import { visit } from 'unist-util-visit';
2023-09-07 14:28:02 +00:00
import type { RemarkPlugin, ShikiConfig } from './types.js';
/**
* 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.
2022-03-24 16:49:54 +00:00
* We make this async, so that multiple calls to parse markdown still share the same highlighter.
*/
2022-03-24 16:49:54 +00:00
const highlighterCacheAsync = new Map<string, Promise<shiki.Highlighter>>();
2023-09-07 14:28:02 +00:00
export function remarkShiki({
langs = [],
theme = 'github-dark',
wrap = false,
}: ShikiConfig = {}): ReturnType<RemarkPlugin> {
const cacheID: string = typeof theme === 'string' ? theme : theme.name;
2022-03-24 16:49:54 +00:00
let highlighterAsync = highlighterCacheAsync.get(cacheID);
if (!highlighterAsync) {
highlighterAsync = getHighlighter({ theme }).then((hl) => {
hl.setColorReplacements({
'#000001': 'var(--astro-code-color-text)',
'#000002': 'var(--astro-code-color-background)',
'#000004': 'var(--astro-code-token-constant)',
'#000005': 'var(--astro-code-token-string)',
'#000006': 'var(--astro-code-token-comment)',
'#000007': 'var(--astro-code-token-keyword)',
'#000008': 'var(--astro-code-token-parameter)',
'#000009': 'var(--astro-code-token-function)',
'#000010': 'var(--astro-code-token-string-expression)',
'#000011': 'var(--astro-code-token-punctuation)',
'#000012': 'var(--astro-code-token-link)',
});
return hl;
});
2022-03-24 16:49:54 +00:00
highlighterCacheAsync.set(cacheID, highlighterAsync);
}
2022-03-24 16:49:54 +00:00
2023-09-07 14:28:02 +00:00
let highlighter: shiki.Highlighter;
return async (tree: any) => {
// Lazily assign the highlighter as async can only happen within this function,
// and not on `remarkShiki` directly.
if (!highlighter) {
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);
}
}
2022-03-24 16:49:54 +00:00
visit(tree, 'code', (node) => {
let lang: string;
if (typeof node.lang === 'string') {
const langExists = highlighter.getLoadedLanguages().includes(node.lang);
if (langExists) {
lang = node.lang;
} else {
2022-07-18 22:59:53 +00:00
// eslint-disable-next-line no-console
console.warn(`The language "${node.lang}" doesn't exist, falling back to plaintext.`);
lang = 'plaintext';
}
} else {
lang = 'plaintext';
}
let html = highlighter.codeToHtml(node.value, { lang });
// 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".
2023-09-07 14:28:02 +00:00
html = html.replace(/<pre class="(.*?)shiki(.*?)"/, `<pre is:raw class="$1astro-code$2"`);
// Add "user-select: none;" for "+"/"-" diff symbols
if (node.lang === 'diff') {
html = html.replace(
/<span class="line"><span style="(.*?)">([\+|\-])/g,
'<span class="line"><span style="$1"><span style="user-select: none;">$2</span>'
);
}
// 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;"'
);
}
node.type = 'html';
node.value = html;
node.children = [];
});
};
2023-09-07 14:28:02 +00:00
}