0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2025-02-17 22:44:24 -05:00

Refactor createShikiHighlighter (#11825)

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
Bjorn Lu 2024-09-02 21:08:25 +08:00 committed by GitHub
parent edd8ae9084
commit 560ef15ad2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 234 additions and 139 deletions

View file

@ -0,0 +1,5 @@
---
'@astrojs/markdoc': patch
---
Uses latest version of `@astrojs/markdown-remark` with updated Shiki APIs

View file

@ -0,0 +1,5 @@
---
'@astrojs/markdown-remark': major
---
Updates return object of `createShikiHighlighter` as `codeToHast` and `codeToHtml` to allow generating either the hast or html string directly

View file

@ -0,0 +1,9 @@
---
'astro': major
---
Updates internal Shiki rehype plugin to highlight code blocks as hast (using Shiki's `codeToHast()` API). This allows a more direct Markdown and MDX processing, and improves the performance when building the project, but may cause issues with existing Shiki transformers.
If you are using Shiki transformers passed to `markdown.shikiConfig.transformers`, you must make sure they do not use the `postprocess` hook as it no longer runs on code blocks in `.md` and `.mdx` files. (See [the Shiki documentation on transformer hooks](https://shiki.style/guide/transformers#transformer-hooks) for more information).
Code blocks in `.mdoc` files and `<Code />` component do not use the internal Shiki rehype plugin and are unaffected.

View file

@ -111,13 +111,13 @@ const highlighter = await getCachedHighlighter({
], ],
theme, theme,
themes, themes,
defaultColor,
wrap,
transformers,
}); });
const html = await highlighter.highlight(code, typeof lang === 'string' ? lang : lang.name, { const html = await highlighter.codeToHtml(code, typeof lang === 'string' ? lang : lang.name, {
defaultColor,
wrap,
inline, inline,
transformers,
meta, meta,
attributes: rest as any, attributes: rest as any,
}); });

View file

@ -5,7 +5,11 @@ import { unescapeHTML } from 'astro/runtime/server/index.js';
import type { AstroMarkdocConfig } from '../config.js'; import type { AstroMarkdocConfig } from '../config.js';
export default async function shiki(config?: ShikiConfig): Promise<AstroMarkdocConfig> { export default async function shiki(config?: ShikiConfig): Promise<AstroMarkdocConfig> {
const highlighter = await createShikiHighlighter(config); const highlighter = await createShikiHighlighter({
langs: config?.langs,
theme: config?.theme,
themes: config?.themes,
});
return { return {
nodes: { nodes: {
@ -16,7 +20,11 @@ export default async function shiki(config?: ShikiConfig): Promise<AstroMarkdocC
// Only the `js` part is parsed as `attributes.language` and the rest is ignored. This means // Only the `js` part is parsed as `attributes.language` and the rest is ignored. This means
// some Shiki transformers may not work correctly as it relies on the `meta`. // some Shiki transformers may not work correctly as it relies on the `meta`.
const lang = typeof attributes.language === 'string' ? attributes.language : 'plaintext'; const lang = typeof attributes.language === 'string' ? attributes.language : 'plaintext';
const html = await highlighter.highlight(attributes.content, lang); const html = await highlighter.codeToHtml(attributes.content, lang, {
wrap: config?.wrap,
defaultColor: config?.defaultColor,
transformers: config?.transformers,
});
// Use `unescapeHTML` to return `HTMLString` for Astro renderer to inline as HTML // Use `unescapeHTML` to return `HTMLString` for Astro renderer to inline as HTML
return unescapeHTML(html) as any; return unescapeHTML(html) as any;

View file

@ -4,7 +4,11 @@ import { toText } from 'hast-util-to-text';
import { removePosition } from 'unist-util-remove-position'; import { removePosition } from 'unist-util-remove-position';
import { visitParents } from 'unist-util-visit-parents'; import { visitParents } from 'unist-util-visit-parents';
type Highlighter = (code: string, language: string, options?: { meta?: string }) => Promise<string>; type Highlighter = (
code: string,
language: string,
options?: { meta?: string },
) => Promise<Root | string>;
const languagePattern = /\blanguage-(\S+)\b/; const languagePattern = /\blanguage-(\S+)\b/;
@ -73,12 +77,17 @@ export async function highlightCodeBlocks(tree: Root, highlighter: Highlighter)
for (const { node, language, grandParent, parent } of nodes) { for (const { node, language, grandParent, parent } of nodes) {
const meta = (node.data as any)?.meta ?? node.properties.metastring ?? undefined; const meta = (node.data as any)?.meta ?? node.properties.metastring ?? undefined;
const code = toText(node, { whitespace: 'pre' }); const code = toText(node, { whitespace: 'pre' });
// TODO: In Astro 5, have `highlighter()` return hast directly to skip expensive HTML parsing and serialization. const result = await highlighter(code, language, { meta });
const html = await highlighter(code, language, { meta });
// The replacement returns a root node with 1 child, the `<pr>` element replacement. let replacement: Element;
const replacement = fromHtml(html, { fragment: true }).children[0] as Element; if (typeof result === 'string') {
// We just generated this node, so any positional information is invalid. // The replacement returns a root node with 1 child, the `<pre>` element replacement.
removePosition(replacement); replacement = fromHtml(result, { fragment: true }).children[0] as Element;
// We just generated this node, so any positional information is invalid.
removePosition(replacement);
} else {
replacement = result.children[0] as Element;
}
// We replace the parent in its parent with the new `<pre>` element. // We replace the parent in its parent with the new `<pre>` element.
const index = grandParent.children.indexOf(parent); const index = grandParent.children.indexOf(parent);

View file

@ -26,7 +26,12 @@ export { rehypeHeadingIds } from './rehype-collect-headings.js';
export { remarkCollectImages } from './remark-collect-images.js'; export { remarkCollectImages } from './remark-collect-images.js';
export { rehypePrism } from './rehype-prism.js'; export { rehypePrism } from './rehype-prism.js';
export { rehypeShiki } from './rehype-shiki.js'; export { rehypeShiki } from './rehype-shiki.js';
export { createShikiHighlighter, type ShikiHighlighter } from './shiki.js'; export {
createShikiHighlighter,
type ShikiHighlighter,
type CreateShikiHighlighterOptions,
type ShikiHighlighterHighlightOptions,
} from './shiki.js';
export * from './types.js'; export * from './types.js';
export const markdownConfigDefaults: Required<AstroMarkdownOptions> = { export const markdownConfigDefaults: Required<AstroMarkdownOptions> = {

View file

@ -8,9 +8,20 @@ export const rehypeShiki: Plugin<[ShikiConfig?], Root> = (config) => {
let highlighterAsync: Promise<ShikiHighlighter> | undefined; let highlighterAsync: Promise<ShikiHighlighter> | undefined;
return async (tree) => { return async (tree) => {
highlighterAsync ??= createShikiHighlighter(config); highlighterAsync ??= createShikiHighlighter({
langs: config?.langs,
theme: config?.theme,
themes: config?.themes,
});
const highlighter = await highlighterAsync; const highlighter = await highlighterAsync;
await highlightCodeBlocks(tree, highlighter.highlight); await highlightCodeBlocks(tree, (code, language, options) => {
return highlighter.codeToHast(code, language, {
meta: options?.meta,
wrap: config?.wrap,
defaultColor: config?.defaultColor,
transformers: config?.transformers,
});
});
}; };
}; };

View file

@ -1,27 +1,65 @@
import type { Properties } from 'hast'; import type { Properties, Root } from 'hast';
import { import {
type BundledLanguage, type BundledLanguage,
type LanguageRegistration,
type ShikiTransformer,
type ThemeRegistration,
type ThemeRegistrationRaw,
createCssVariablesTheme, createCssVariablesTheme,
getHighlighter, createHighlighter,
isSpecialLang, isSpecialLang,
} from 'shiki'; } from 'shiki';
import type { ShikiConfig } from './types.js'; import type { ThemePresets } from './types.js';
export interface ShikiHighlighter { export interface ShikiHighlighter {
highlight( codeToHast(
code: string, code: string,
lang?: string, lang?: string,
options?: { options?: ShikiHighlighterHighlightOptions,
inline?: boolean; ): Promise<Root>;
attributes?: Record<string, string>; codeToHtml(
/** code: string,
* Raw `meta` information to be used by Shiki transformers lang?: string,
*/ options?: ShikiHighlighterHighlightOptions,
meta?: string;
},
): Promise<string>; ): Promise<string>;
} }
export interface CreateShikiHighlighterOptions {
langs?: LanguageRegistration[];
theme?: ThemePresets | ThemeRegistration | ThemeRegistrationRaw;
themes?: Record<string, ThemePresets | ThemeRegistration | ThemeRegistrationRaw>;
}
export interface ShikiHighlighterHighlightOptions {
/**
* Generate inline code element only, without the pre element wrapper.
*/
inline?: boolean;
/**
* Enable word wrapping.
* - true: enabled.
* - false: disabled.
* - null: All overflow styling removed. Code will overflow the element by default.
*/
wrap?: boolean | null;
/**
* Chooses a theme from the "themes" option that you've defined as the default styling theme.
*/
defaultColor?: 'light' | 'dark' | string | false;
/**
* Shiki transformers to customize the generated HTML by manipulating the hast tree.
*/
transformers?: ShikiTransformer[];
/**
* Additional attributes to be added to the root code block element.
*/
attributes?: Record<string, string>;
/**
* Raw `meta` information to be used by Shiki transformers.
*/
meta?: string;
}
let _cssVariablesTheme: ReturnType<typeof createCssVariablesTheme>; let _cssVariablesTheme: ReturnType<typeof createCssVariablesTheme>;
const cssVariablesTheme = () => const cssVariablesTheme = () =>
_cssVariablesTheme ?? _cssVariablesTheme ??
@ -31,113 +69,120 @@ export async function createShikiHighlighter({
langs = [], langs = [],
theme = 'github-dark', theme = 'github-dark',
themes = {}, themes = {},
defaultColor, }: CreateShikiHighlighterOptions = {}): Promise<ShikiHighlighter> {
wrap = false,
transformers = [],
}: ShikiConfig = {}): Promise<ShikiHighlighter> {
theme = theme === 'css-variables' ? cssVariablesTheme() : theme; theme = theme === 'css-variables' ? cssVariablesTheme() : theme;
const highlighter = await getHighlighter({ const highlighter = await createHighlighter({
langs: ['plaintext', ...langs], langs: ['plaintext', ...langs],
themes: Object.values(themes).length ? Object.values(themes) : [theme], themes: Object.values(themes).length ? Object.values(themes) : [theme],
}); });
return { async function highlight(
async highlight(code, lang = 'plaintext', options) { code: string,
const loadedLanguages = highlighter.getLoadedLanguages(); lang = 'plaintext',
options: ShikiHighlighterHighlightOptions,
to: 'hast' | 'html',
) {
const loadedLanguages = highlighter.getLoadedLanguages();
if (!isSpecialLang(lang) && !loadedLanguages.includes(lang)) { if (!isSpecialLang(lang) && !loadedLanguages.includes(lang)) {
try { try {
await highlighter.loadLanguage(lang as BundledLanguage); await highlighter.loadLanguage(lang as BundledLanguage);
} catch (_err) { } catch (_err) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn( console.warn(`[Shiki] The language "${lang}" doesn't exist, falling back to "plaintext".`);
`[Shiki] The language "${lang}" doesn't exist, falling back to "plaintext".`, lang = 'plaintext';
);
lang = 'plaintext';
}
} }
}
const themeOptions = Object.values(themes).length ? { themes } : { theme }; const themeOptions = Object.values(themes).length ? { themes } : { theme };
const inline = options?.inline ?? false; const inline = options?.inline ?? false;
return highlighter.codeToHtml(code, { return highlighter[to === 'html' ? 'codeToHtml' : 'codeToHast'](code, {
...themeOptions, ...themeOptions,
defaultColor, defaultColor: options.defaultColor,
lang, lang,
// NOTE: while we can spread `options.attributes` here so that Shiki can auto-serialize this as rendered // NOTE: while we can spread `options.attributes` here so that Shiki can auto-serialize this as rendered
// attributes on the top-level tag, it's not clear whether it is fine to pass all attributes as meta, as // attributes on the top-level tag, it's not clear whether it is fine to pass all attributes as meta, as
// they're technically not meta, nor parsed from Shiki's `parseMetaString` API. // they're technically not meta, nor parsed from Shiki's `parseMetaString` API.
meta: options?.meta ? { __raw: options?.meta } : undefined, meta: options?.meta ? { __raw: options?.meta } : undefined,
transformers: [ transformers: [
{ {
pre(node) { pre(node) {
// Swap to `code` tag if inline // Swap to `code` tag if inline
if (inline) { if (inline) {
node.tagName = 'code'; node.tagName = 'code';
} }
const { const {
class: attributesClass, class: attributesClass,
style: attributesStyle, style: attributesStyle,
...rest ...rest
} = options?.attributes ?? {}; } = options?.attributes ?? {};
Object.assign(node.properties, rest); Object.assign(node.properties, rest);
const classValue = const classValue =
(normalizePropAsString(node.properties.class) ?? '') + (normalizePropAsString(node.properties.class) ?? '') +
(attributesClass ? ` ${attributesClass}` : ''); (attributesClass ? ` ${attributesClass}` : '');
const styleValue = const styleValue =
(normalizePropAsString(node.properties.style) ?? '') + (normalizePropAsString(node.properties.style) ?? '') +
(attributesStyle ? `; ${attributesStyle}` : ''); (attributesStyle ? `; ${attributesStyle}` : '');
// Replace "shiki" class naming with "astro-code" // Replace "shiki" class naming with "astro-code"
node.properties.class = classValue.replace(/shiki/g, 'astro-code'); node.properties.class = classValue.replace(/shiki/g, 'astro-code');
// Add data-language attribute // Add data-language attribute
node.properties.dataLanguage = lang; node.properties.dataLanguage = lang;
// Handle code wrapping // Handle code wrapping
// if wrap=null, do nothing. // if wrap=null, do nothing.
if (wrap === false) { if (options.wrap === false || options.wrap === undefined) {
node.properties.style = styleValue + '; overflow-x: auto;'; node.properties.style = styleValue + '; overflow-x: auto;';
} else if (wrap === true) { } else if (options.wrap === true) {
node.properties.style = node.properties.style =
styleValue + '; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;'; styleValue + '; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;';
} }
}, },
line(node) { line(node) {
// Add "user-select: none;" for "+"/"-" diff symbols. // Add "user-select: none;" for "+"/"-" diff symbols.
// Transform `<span class="line"><span style="...">+ something</span></span> // Transform `<span class="line"><span style="...">+ something</span></span>
// into `<span class="line"><span style="..."><span style="user-select: none;">+</span> something</span></span>` // into `<span class="line"><span style="..."><span style="user-select: none;">+</span> something</span></span>`
if (lang === 'diff') { if (lang === 'diff') {
const innerSpanNode = node.children[0]; const innerSpanNode = node.children[0];
const innerSpanTextNode = const innerSpanTextNode =
innerSpanNode?.type === 'element' && innerSpanNode.children?.[0]; innerSpanNode?.type === 'element' && innerSpanNode.children?.[0];
if (innerSpanTextNode && innerSpanTextNode.type === 'text') { if (innerSpanTextNode && innerSpanTextNode.type === 'text') {
const start = innerSpanTextNode.value[0]; const start = innerSpanTextNode.value[0];
if (start === '+' || start === '-') { if (start === '+' || start === '-') {
innerSpanTextNode.value = innerSpanTextNode.value.slice(1); innerSpanTextNode.value = innerSpanTextNode.value.slice(1);
innerSpanNode.children.unshift({ innerSpanNode.children.unshift({
type: 'element', type: 'element',
tagName: 'span', tagName: 'span',
properties: { style: 'user-select: none;' }, properties: { style: 'user-select: none;' },
children: [{ type: 'text', value: start }], children: [{ type: 'text', value: start }],
}); });
}
} }
} }
}, }
code(node) {
if (inline) {
return node.children[0] as typeof node;
}
},
}, },
...transformers, code(node) {
], if (inline) {
}); return node.children[0] as typeof node;
}
},
},
...(options.transformers ?? []),
],
});
}
return {
codeToHast(code, lang, options = {}) {
return highlight(code, lang, options, 'hast') as Promise<Root>;
},
codeToHtml(code, lang, options = {}) {
return highlight(code, lang, options, 'html') as Promise<string>;
}, },
}; };
} }

View file

@ -1,15 +1,10 @@
import type * as hast from 'hast'; import type * as hast from 'hast';
import type * as mdast from 'mdast'; import type * as mdast from 'mdast';
import type { Options as RemarkRehypeOptions } from 'remark-rehype'; import type { Options as RemarkRehypeOptions } from 'remark-rehype';
import type { import type { BuiltinTheme } from 'shiki';
BuiltinTheme,
LanguageRegistration,
ShikiTransformer,
ThemeRegistration,
ThemeRegistrationRaw,
} from 'shiki';
import type * as unified from 'unified'; import type * as unified from 'unified';
import type { DataMap, VFile } from 'vfile'; import type { DataMap, VFile } from 'vfile';
import type { CreateShikiHighlighterOptions, ShikiHighlighterHighlightOptions } from './shiki.js';
export type { Node } from 'unist'; export type { Node } from 'unist';
@ -35,14 +30,9 @@ export type RemarkRehype = RemarkRehypeOptions;
export type ThemePresets = BuiltinTheme | 'css-variables'; export type ThemePresets = BuiltinTheme | 'css-variables';
export interface ShikiConfig { export interface ShikiConfig
langs?: LanguageRegistration[]; extends Pick<CreateShikiHighlighterOptions, 'langs' | 'theme' | 'themes'>,
theme?: ThemePresets | ThemeRegistration | ThemeRegistrationRaw; Pick<ShikiHighlighterHighlightOptions, 'defaultColor' | 'wrap' | 'transformers'> {}
themes?: Record<string, ThemePresets | ThemeRegistration | ThemeRegistrationRaw>;
defaultColor?: 'light' | 'dark' | string | false;
wrap?: boolean | null;
transformers?: ShikiTransformer[];
}
export interface AstroMarkdownOptions { export interface AstroMarkdownOptions {
syntaxHighlight?: 'shiki' | 'prism' | false; syntaxHighlight?: 'shiki' | 'prism' | false;

View file

@ -33,16 +33,25 @@ describe('shiki syntax highlighting', () => {
it('createShikiHighlighter works', async () => { it('createShikiHighlighter works', async () => {
const highlighter = await createShikiHighlighter(); const highlighter = await createShikiHighlighter();
const html = await highlighter.highlight('const foo = "bar";', 'js'); const html = await highlighter.codeToHtml('const foo = "bar";', 'js');
assert.match(html, /astro-code github-dark/); assert.match(html, /astro-code github-dark/);
assert.match(html, /background-color:#24292e;color:#e1e4e8;/); assert.match(html, /background-color:#24292e;color:#e1e4e8;/);
}); });
it('createShikiHighlighter works with codeToHast', async () => {
const highlighter = await createShikiHighlighter();
const hast = await highlighter.codeToHast('const foo = "bar";', 'js');
assert.match(hast.children[0].properties.class, /astro-code github-dark/);
assert.match(hast.children[0].properties.style, /background-color:#24292e;color:#e1e4e8;/);
});
it('diff +/- text has user-select: none', async () => { it('diff +/- text has user-select: none', async () => {
const highlighter = await createShikiHighlighter(); const highlighter = await createShikiHighlighter();
const html = await highlighter.highlight( const html = await highlighter.codeToHtml(
`\ `\
- const foo = "bar"; - const foo = "bar";
+ const foo = "world";`, + const foo = "world";`,
@ -57,7 +66,7 @@ describe('shiki syntax highlighting', () => {
it('renders attributes', async () => { it('renders attributes', async () => {
const highlighter = await createShikiHighlighter(); const highlighter = await createShikiHighlighter();
const html = await highlighter.highlight(`foo`, 'js', { const html = await highlighter.codeToHtml(`foo`, 'js', {
attributes: { 'data-foo': 'bar', autofocus: true }, attributes: { 'data-foo': 'bar', autofocus: true },
}); });
@ -66,7 +75,10 @@ describe('shiki syntax highlighting', () => {
}); });
it('supports transformers that reads meta', async () => { it('supports transformers that reads meta', async () => {
const highlighter = await createShikiHighlighter({ const highlighter = await createShikiHighlighter();
const html = await highlighter.codeToHtml(`foo`, 'js', {
meta: '{1,3-4}',
transformers: [ transformers: [
{ {
pre(node) { pre(node) {
@ -79,10 +91,6 @@ describe('shiki syntax highlighting', () => {
], ],
}); });
const html = await highlighter.highlight(`foo`, 'js', {
meta: '{1,3-4}',
});
assert.match(html, /data-test="\{1,3-4\}"/); assert.match(html, /data-test="\{1,3-4\}"/);
}); });