diff options
Diffstat (limited to 'packages/markdown/remark/src/shiki.ts')
-rw-r--r-- | packages/markdown/remark/src/shiki.ts | 301 |
1 files changed, 160 insertions, 141 deletions
diff --git a/packages/markdown/remark/src/shiki.ts b/packages/markdown/remark/src/shiki.ts index 2f06ef9a9..ed4daf527 100644 --- a/packages/markdown/remark/src/shiki.ts +++ b/packages/markdown/remark/src/shiki.ts @@ -1,173 +1,196 @@ -import type { Properties } from 'hast'; +import type { Properties, Root } from 'hast'; import { type BundledLanguage, + type HighlighterCoreOptions, + type LanguageRegistration, + type ShikiTransformer, + type ThemeRegistration, + type ThemeRegistrationRaw, createCssVariablesTheme, - getHighlighter, + createHighlighter, isSpecialLang, } from 'shiki'; -import { visit } from 'unist-util-visit'; -import type { ShikiConfig } from './types.js'; +import type { ThemePresets } from './types.js'; export interface ShikiHighlighter { - highlight( + codeToHast( code: string, lang?: string, - options?: { - inline?: boolean; - attributes?: Record<string, string>; - /** - * Raw `meta` information to be used by Shiki transformers - */ - meta?: string; - }, + options?: ShikiHighlighterHighlightOptions, + ): Promise<Root>; + codeToHtml( + code: string, + lang?: string, + options?: ShikiHighlighterHighlightOptions, ): Promise<string>; } -// TODO: Remove this special replacement in Astro 5 -const ASTRO_COLOR_REPLACEMENTS: Record<string, string> = { - '--astro-code-foreground': '--astro-code-color-text', - '--astro-code-background': '--astro-code-color-background', -}; -const COLOR_REPLACEMENT_REGEX = new RegExp( - `${Object.keys(ASTRO_COLOR_REPLACEMENTS).join('|')}`, - 'g', -); +export interface CreateShikiHighlighterOptions { + langs?: LanguageRegistration[]; + theme?: ThemePresets | ThemeRegistration | ThemeRegistrationRaw; + themes?: Record<string, ThemePresets | ThemeRegistration | ThemeRegistrationRaw>; + langAlias?: HighlighterCoreOptions['langAlias']; +} + +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>; const cssVariablesTheme = () => _cssVariablesTheme ?? - (_cssVariablesTheme = createCssVariablesTheme({ variablePrefix: '--astro-code-' })); + (_cssVariablesTheme = createCssVariablesTheme({ + variablePrefix: '--astro-code-', + })); export async function createShikiHighlighter({ langs = [], theme = 'github-dark', themes = {}, - defaultColor, - wrap = false, - transformers = [], langAlias = {}, -}: ShikiConfig = {}): Promise<ShikiHighlighter> { +}: CreateShikiHighlighterOptions = {}): Promise<ShikiHighlighter> { theme = theme === 'css-variables' ? cssVariablesTheme() : theme; - const highlighter = await getHighlighter({ + const highlighter = await createHighlighter({ langs: ['plaintext', ...langs], langAlias, themes: Object.values(themes).length ? Object.values(themes) : [theme], }); - return { - async highlight(code, lang = 'plaintext', options) { - const resolvedLang = langAlias[lang] ?? lang; - const loadedLanguages = highlighter.getLoadedLanguages(); - - if (!isSpecialLang(lang) && !loadedLanguages.includes(resolvedLang)) { - try { - await highlighter.loadLanguage(resolvedLang as BundledLanguage); - } catch (_err) { - const langStr = - lang === resolvedLang ? `"${lang}"` : `"${lang}" (aliased to "${resolvedLang}")`; - console.warn( - `[Shiki] The language ${langStr} doesn't exist, falling back to "plaintext".`, - ); - lang = 'plaintext'; - } + async function highlight( + code: string, + lang = 'plaintext', + options: ShikiHighlighterHighlightOptions, + to: 'hast' | 'html', + ) { + const resolvedLang = langAlias[lang] ?? lang; + const loadedLanguages = highlighter.getLoadedLanguages(); + + if (!isSpecialLang(lang) && !loadedLanguages.includes(resolvedLang)) { + try { + await highlighter.loadLanguage(resolvedLang as BundledLanguage); + } catch (_err) { + const langStr = + lang === resolvedLang ? `"${lang}"` : `"${lang}" (aliased to "${resolvedLang}")`; + console.warn(`[Shiki] The language ${langStr} doesn't exist, falling back to "plaintext".`); + lang = 'plaintext'; } - - const themeOptions = Object.values(themes).length ? { themes } : { theme }; - const inline = options?.inline ?? false; - - return highlighter.codeToHtml(code, { - ...themeOptions, - defaultColor, - lang, - // 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 - // they're technically not meta, nor parsed from Shiki's `parseMetaString` API. - meta: options?.meta ? { __raw: options?.meta } : undefined, - transformers: [ - { - pre(node) { - // Swap to `code` tag if inline - if (inline) { - node.tagName = 'code'; - } - - const { - class: attributesClass, - style: attributesStyle, - ...rest - } = options?.attributes ?? {}; - Object.assign(node.properties, rest); - - const classValue = - (normalizePropAsString(node.properties.class) ?? '') + - (attributesClass ? ` ${attributesClass}` : ''); - const styleValue = - (normalizePropAsString(node.properties.style) ?? '') + - (attributesStyle ? `; ${attributesStyle}` : ''); - - // Replace "shiki" class naming with "astro-code" - node.properties.class = classValue.replace(/shiki/g, 'astro-code'); - - // Add data-language attribute - node.properties.dataLanguage = lang; - - // Handle code wrapping - // if wrap=null, do nothing. - if (wrap === false) { - node.properties.style = styleValue + '; overflow-x: auto;'; - } else if (wrap === true) { - node.properties.style = - styleValue + '; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;'; - } - }, - line(node) { - // Add "user-select: none;" for "+"/"-" diff symbols. - // Transform `<span class="line"><span style="...">+ something</span></span> - // into `<span class="line"><span style="..."><span style="user-select: none;">+</span> something</span></span>` - if (resolvedLang === 'diff') { - const innerSpanNode = node.children[0]; - const innerSpanTextNode = - innerSpanNode?.type === 'element' && innerSpanNode.children?.[0]; - - if (innerSpanTextNode && innerSpanTextNode.type === 'text') { - const start = innerSpanTextNode.value[0]; - if (start === '+' || start === '-') { - innerSpanTextNode.value = innerSpanTextNode.value.slice(1); - innerSpanNode.children.unshift({ - type: 'element', - tagName: 'span', - properties: { style: 'user-select: none;' }, - children: [{ type: 'text', value: start }], - }); - } + } + + const themeOptions = Object.values(themes).length ? { themes } : { theme }; + const inline = options?.inline ?? false; + + return highlighter[to === 'html' ? 'codeToHtml' : 'codeToHast'](code, { + ...themeOptions, + defaultColor: options.defaultColor, + lang, + // 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 + // they're technically not meta, nor parsed from Shiki's `parseMetaString` API. + meta: options?.meta ? { __raw: options?.meta } : undefined, + transformers: [ + { + pre(node) { + // Swap to `code` tag if inline + if (inline) { + node.tagName = 'code'; + } + + const { + class: attributesClass, + style: attributesStyle, + ...rest + } = options?.attributes ?? {}; + Object.assign(node.properties, rest); + + const classValue = + (normalizePropAsString(node.properties.class) ?? '') + + (attributesClass ? ` ${attributesClass}` : ''); + const styleValue = + (normalizePropAsString(node.properties.style) ?? '') + + (attributesStyle ? `; ${attributesStyle}` : ''); + + // Replace "shiki" class naming with "astro-code" + node.properties.class = classValue.replace(/shiki/g, 'astro-code'); + + // Add data-language attribute + node.properties.dataLanguage = lang; + + // Handle code wrapping + // if wrap=null, do nothing. + if (options.wrap === false || options.wrap === undefined) { + node.properties.style = styleValue + '; overflow-x: auto;'; + } else if (options.wrap === true) { + node.properties.style = + styleValue + '; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;'; + } + }, + line(node) { + // Add "user-select: none;" for "+"/"-" diff symbols. + // Transform `<span class="line"><span style="...">+ something</span></span> + // into `<span class="line"><span style="..."><span style="user-select: none;">+</span> something</span></span>` + if (resolvedLang === 'diff') { + const innerSpanNode = node.children[0]; + const innerSpanTextNode = + innerSpanNode?.type === 'element' && innerSpanNode.children?.[0]; + + if (innerSpanTextNode && innerSpanTextNode.type === 'text') { + const start = innerSpanTextNode.value[0]; + if (start === '+' || start === '-') { + innerSpanTextNode.value = innerSpanTextNode.value.slice(1); + innerSpanNode.children.unshift({ + type: 'element', + tagName: 'span', + properties: { style: 'user-select: none;' }, + children: [{ type: 'text', value: start }], + }); } } - }, - code(node) { - if (inline) { - return node.children[0] as typeof node; - } - }, - root(node) { - if (Object.values(themes).length) { - return; - } - - const themeName = typeof theme === 'string' ? theme : theme.name; - if (themeName === 'css-variables') { - // Replace special color tokens to CSS variables - visit(node as any, 'element', (child) => { - if (child.properties?.style) { - child.properties.style = replaceCssVariables(child.properties.style); - } - }); - } - }, + } + }, + code(node) { + if (inline) { + return node.children[0] as typeof node; + } }, - ...transformers, - ], - }); + }, + ...(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>; }, }; } @@ -175,7 +198,3 @@ export async function createShikiHighlighter({ function normalizePropAsString(value: Properties[string]): string | null { return Array.isArray(value) ? value.join(' ') : (value as string | null); } - -function replaceCssVariables(str: string) { - return str.replace(COLOR_REPLACEMENT_REGEX, (match) => ASTRO_COLOR_REPLACEMENTS[match] || match); -} |