diff --git a/__tests__/unit/node/markdown/plugins/highlight.test.ts b/__tests__/unit/node/markdown/plugins/highlight.test.ts index 7454dcc1..da832689 100644 --- a/__tests__/unit/node/markdown/plugins/highlight.test.ts +++ b/__tests__/unit/node/markdown/plugins/highlight.test.ts @@ -1,17 +1,15 @@ import { highlight } from 'node/markdown/plugins/highlight' describe('node/markdown/plugins/highlight', () => { - test('passes color replacements through markdown options', async () => { - const [render, dispose] = await highlight( - { light: 'github-light', dark: 'github-dark' }, - { - colorReplacements: { - 'github-light': { - '#005cc5': '#000000' - } + test('passes color replacements through shiki options', async () => { + const [render, dispose] = await highlight({ + theme: { light: 'github-light', dark: 'github-dark' }, + colorReplacements: { + 'github-light': { + '#005cc5': '#000000' } } - ) + }) try { const html = await render('const a = 1', 'js', '') @@ -23,4 +21,35 @@ describe('node/markdown/plugins/highlight', () => { dispose() } }) + + test('renders with default dual themes when no options are passed', async () => { + const [render, dispose] = await highlight() + + try { + const html = await render('const a = 1', 'js', '') + + expect(html).toContain('--shiki-light:') + expect(html).toContain('--shiki-dark:') + } finally { + dispose() + } + }) + + test('loads defaultLang for blocks with an unknown language', async () => { + const warnings: string[] = [] + const [render, dispose] = await highlight( + { defaultLang: 'python' }, + { warn: (msg) => void warnings.push(String(msg)) } + ) + + try { + const html = await render('print("hi")', 'notalang', '') + + expect(html).toContain('class="shiki') + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain("falling back to 'python'") + } finally { + dispose() + } + }) }) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 64158acf..11f7433d 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -46,17 +46,19 @@ export default defineConfig({ markdown: { math: true, - codeTransformers: [ - // We use `[!!code` and `@@include` in demo to prevent transformation, - // here we revert it back. - { - postprocess(code) { - return code - .replaceAll('[!!code', '[!code') - .replaceAll('@@include', '@include') + shiki: { + transformers: [ + // We use `[!!code` and `@@include` in demo to prevent transformation, + // here we revert it back. + { + postprocess(code) { + return code + .replaceAll('[!!code', '[!code') + .replaceAll('@@include', '@include') + } } - } - ], + ] + }, config(md) { md.use(groupIconMdPlugin) } diff --git a/docs/en/guide/markdown.md b/docs/en/guide/markdown.md index 574e9626..b8b7860c 100644 --- a/docs/en/guide/markdown.md +++ b/docs/en/guide/markdown.md @@ -489,9 +489,9 @@ export default { ``` -A [list of valid languages](https://shiki.style/languages) is available on Shiki's repository. +A [list of valid languages](https://shiki.style/languages) is available in Shiki's documentation. -You may also customize syntax highlight theme, configure language aliases, and set custom language labels in app config. Please see [`markdown` options](../reference/site-config#markdown) for more details. +Highlighting is configured through the `markdown.shiki` option in app config: pass a custom [theme](https://shiki.style/themes), preload or register [languages](https://shiki.style/guide/load-lang), define [language aliases](https://shiki.style/guide/load-lang#custom-language-aliases), or apply [transformers](https://shiki.style/guide/transformers). Options that map directly to Shiki (`langs`, `langAlias`, `transformers`, `colorReplacements`) use Shiki's own names and types. Custom language labels shown in code blocks are configured separately with `markdown.languageLabel`. See [`markdown` options](../reference/site-config#markdown) for more details. ## Line Highlighting in Code Blocks diff --git a/docs/en/reference/site-config.md b/docs/en/reference/site-config.md index 7f214127..88271d6e 100644 --- a/docs/en/reference/site-config.md +++ b/docs/en/reference/site-config.md @@ -603,6 +603,19 @@ export default { Check the [type declaration and jsdocs](https://github.com/vuejs/vitepress/blob/main/src/node/markdown/markdown.ts) for all the options available. +Syntax highlighting options are grouped under `markdown.shiki`. The ones that map directly to Shiki (`langs`, `langAlias`, `transformers`, `colorReplacements`) use [Shiki](https://shiki.style)'s own option names and types — check the [`ShikiOptions` jsdocs](https://github.com/vuejs/vitepress/blob/main/src/node/markdown/plugins/highlight.ts) for the full list: + +```js +export default { + markdown: { + shiki: { + theme: { light: 'github-light', dark: 'github-dark' }, + langAlias: { cjs: 'javascript' } + } + } +} +``` + Set `markdown.headers` to `true` or pass [`@mdit-vue/plugin-headers`](https://github.com/mdit-vue/mdit-vue/tree/main/packages/plugin-headers) options to collect headings into [`useData().page.headers`](./runtime-api#usedata). This option is disabled by default. ### vite diff --git a/src/node/markdown/markdown.ts b/src/node/markdown/markdown.ts index 84886d3d..7f533818 100644 --- a/src/node/markdown/markdown.ts +++ b/src/node/markdown/markdown.ts @@ -29,15 +29,6 @@ import { } from '@mdit/plugin-tasklist' import { MarkdownItAsync, type MarkdownItAsyncOptions } from 'markdown-it-async' import mditCjkFriendly from 'markdown-it-cjk-friendly' -import type { - BuiltinLanguage, - BuiltinTheme, - CodeToHastOptions, - Highlighter, - LanguageInput, - ShikiTransformer, - ThemeRegistrationAny -} from 'shiki' import type { Logger } from 'vite' import type { @@ -51,7 +42,10 @@ import { gitHubAlertsPlugin, type ContainerOptions } from './plugins/containers' -import { highlight as createHighlighter } from './plugins/highlight' +import { + highlight as createHighlighter, + type ShikiOptions +} from './plugins/highlight' import { imagePlugin, type Options as ImageOptions } from './plugins/image' import { includePlugin, @@ -68,18 +62,11 @@ import { import { tablePlugin } from './plugins/table' export type { Header } from '../shared' +export type { ShikiOptions, ThemeOptions } from './plugins/highlight' // not exported from @mdit/plugin-emoji, so derive it from the plugin signature type EmojiPluginOptions = NonNullable[1]> -export type ThemeOptions = - | ThemeRegistrationAny - | BuiltinTheme - | { - light: ThemeRegistrationAny | BuiltinTheme - dark: ThemeRegistrationAny | BuiltinTheme - } - // highlight is marked as any to avoid type conflicts with plugins expecting // regular markdown-it which has sync highlight function. Such plugins will fail // if they access highlight directly but currently none of the ones we use do that. @@ -118,69 +105,11 @@ export interface MarkdownOptions extends MarkdownItAsyncOptions { /* ==================== Syntax Highlighting ==================== */ /** - * Custom theme for syntax highlighting. - * - * You can also pass an object with `light` and `dark` themes to support - * dual themes. - * - * @example { theme: 'github-dark' } - * @example { theme: { light: 'github-light', dark: 'github-dark' } } - * - * You can use an existing theme. - * @see https://shiki.style/themes - * Or add your own theme. - * @see https://shiki.style/guide/load-theme - */ - theme?: ThemeOptions - /** - * Custom languages for syntax highlighting or pre-load built-in languages. - * @see https://shiki.style/languages - */ - languages?: (LanguageInput | BuiltinLanguage)[] - /** - * Custom language aliases for syntax highlighting. - * Maps custom language names to existing languages. - * Alias lookup is case-insensitive and underscores in language names are - * displayed as spaces. - * - * @example - * - * Maps `my_lang` to use Python syntax highlighting. - * ```js - * { 'my_lang': 'python' } - * ``` - * - * Usage in markdown: - * ````md - * ```My_Lang - * # This will be highlighted as Python code - * # and will show "My Lang" as the language label - * print("Hello, World!") - * ``` - * ```` - * - * @see https://shiki.style/guide/load-lang#custom-language-aliases - */ - languageAlias?: Record - /** - * Fallback language used when the specified language is not available. - */ - defaultHighlightLang?: string - /** - * Transformers applied to code blocks. - * @see https://shiki.style/guide/transformers - */ - codeTransformers?: ShikiTransformer[] - /** - * Color replacements applied during syntax highlighting. - * Accepts either a flat color map or per-theme replacements. - * @see https://shiki.style/guide/theme-colors#color-replacements - */ - colorReplacements?: CodeToHastOptions['colorReplacements'] - /** - * Configure the Shiki instance. + * Options for syntax highlighting with Shiki: theme, languages, aliases, + * transformers, color replacements, and the setup hook. + * @see https://shiki.style */ - shikiSetup?: (shiki: Highlighter) => void | Promise + shiki?: ShikiOptions | undefined /* ==================== Code Blocks ==================== */ @@ -373,7 +302,6 @@ export async function createMarkdownRenderer( publicDir ??= path.resolve(srcDir, 'public') - const theme = options.theme ?? { light: 'github-light', dark: 'github-dark' } const codeCopyButton = { tooltipText: options.codeCopyButton?.tooltipText || 'Copy code', copiedText: options.codeCopyButton?.copiedText || 'Copied' @@ -381,7 +309,7 @@ export async function createMarkdownRenderer( const [highlight, dispose] = options.highlight ? [options.highlight, () => {}] - : await createHighlighter(theme, options, logger) + : await createHighlighter(options.shiki, logger) _disposeHighlighter = dispose diff --git a/src/node/markdown/plugins/highlight.ts b/src/node/markdown/plugins/highlight.ts index 7a72d135..4e2a7eda 100644 --- a/src/node/markdown/plugins/highlight.ts +++ b/src/node/markdown/plugins/highlight.ts @@ -7,12 +7,114 @@ import { } from '@shikijs/transformers' import { customAlphabet } from 'nanoid' import c from 'picocolors' -import type { BundledLanguage, ShikiTransformer } from 'shiki' +import type { + BuiltinLanguage, + BuiltinTheme, + BundledHighlighterOptions, + BundledLanguage, + CodeToHastOptions, + Highlighter, + ShikiTransformer, + SpecialLanguage, + StringLiteralUnion, + ThemeRegistrationAny +} from 'shiki' import { createHighlighter, guessEmbeddedLanguages, isSpecialLang } from 'shiki' import type { Logger } from 'vite' -import { isShell } from '../../shared' -import type { MarkdownOptions, ThemeOptions } from '../markdown' +import { isShell, type Awaitable } from '../../shared' + +export type ThemeOptions = + | ThemeRegistrationAny + | BuiltinTheme + | { + light: ThemeRegistrationAny | BuiltinTheme + dark: ThemeRegistrationAny | BuiltinTheme + } + +type BundledShikiOptions = BundledHighlighterOptions< + BuiltinLanguage, + BuiltinTheme +> + +/** + * Shiki options for syntax highlighting in code blocks. Members that map + * directly to Shiki (`langs`, `langAlias`, `transformers`, + * `colorReplacements`) use Shiki's own option names and types; `theme`, + * `defaultLang`, and `setup` are VitePress-specific. + * @see https://shiki.style + */ +export interface ShikiOptions { + /** + * Custom theme for syntax highlighting. + * + * You can also pass an object with `light` and `dark` themes to support + * dual themes. + * + * @example { theme: 'github-dark' } + * @example { theme: { light: 'github-light', dark: 'github-dark' } } + * + * You can use an existing theme. + * @see https://shiki.style/themes + * Or add your own theme. + * @see https://shiki.style/guide/load-theme + * + * @default { light: 'github-light', dark: 'github-dark' } + */ + theme?: ThemeOptions + /** + * Custom languages for syntax highlighting or pre-load built-in languages. + * @see https://shiki.style/languages + * @see https://shiki.style/guide/load-lang + */ + langs?: BundledShikiOptions['langs'] + /** + * Custom language aliases for syntax highlighting. + * Maps custom language names to existing languages. + * Alias lookup is case-insensitive and underscores in language names are + * displayed as spaces. + * + * @example + * + * Maps `my_lang` to use Python syntax highlighting. + * ```js + * { 'my_lang': 'python' } + * ``` + * + * Usage in markdown: + * ````md + * ```My_Lang + * # This will be highlighted as Python code + * # and will show "My Lang" as the language label + * print("Hello, World!") + * ``` + * ```` + * + * @see https://shiki.style/guide/load-lang#custom-language-aliases + */ + langAlias?: BundledShikiOptions['langAlias'] + /** + * Language used for code blocks that don't specify a language, or specify + * a language that is not available. + * @default 'txt' + */ + defaultLang?: StringLiteralUnion + /** + * Transformers applied to code blocks. + * @see https://shiki.style/guide/transformers + */ + transformers?: ShikiTransformer[] + /** + * Color replacements applied during syntax highlighting. + * Accepts either a flat color map or per-theme replacements. + * @see https://shiki.style/guide/theme-colors#color-replacements + */ + colorReplacements?: CodeToHastOptions['colorReplacements'] + /** + * Configure the Shiki highlighter instance after it is created. + */ + setup?: (shiki: Highlighter) => Awaitable +} const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 10) @@ -51,19 +153,19 @@ function transformerDisableShellSymbolSelect(): ShikiTransformer { } export async function highlight( - theme: ThemeOptions, - options: MarkdownOptions, + options: ShikiOptions = {}, logger: Pick = console ): Promise< [(str: string, lang: string, attrs: string) => Promise, () => void] > { - const { - defaultHighlightLang: defaultLang = 'txt', - codeTransformers: userTransformers = [] - } = options + // `??` instead of destructuring defaults so that plain-JS configs passing + // `null` still get the defaults + const theme = options.theme ?? { light: 'github-light', dark: 'github-dark' } + const defaultLang = options.defaultLang ?? 'txt' + const userTransformers = options.transformers ?? [] const langAlias = Object.fromEntries( - Object.entries(options.languageAlias || {}) // + Object.entries(options.langAlias || {}) // .map(([k, v]) => [k.toLowerCase(), v]) ) @@ -72,11 +174,26 @@ export async function highlight( typeof theme === 'object' && 'light' in theme && 'dark' in theme ? [theme.light, theme.dark] : [theme], - langs: [...(options.languages || []), ...Object.values(langAlias)], + langs: [...(options.langs || []), ...Object.values(langAlias)], langAlias }) - await options?.shikiSetup?.(highlighter) + await options.setup?.(highlighter) + + // https://github.com/shikijs/shiki/issues/952 + const loadLanguage = async (lang: string) => { + try { + if ( + !isSpecialLang(lang) && + !highlighter.getLoadedLanguages().includes(lang) + ) { + await highlighter.loadLanguage(lang as any) + } + return true + } catch { + return false + } + } const transformers: ShikiTransformer[] = [ transformerMetaHighlight(), @@ -115,21 +232,21 @@ export async function highlight( const vPre = !vueRE.test(lang) if (!vPre) lang = lang.slice(0, -4) - try { - // https://github.com/shikijs/shiki/issues/952 - if ( - !isSpecialLang(lang) && - !highlighter.getLoadedLanguages().includes(lang) - ) { - await highlighter.loadLanguage(lang as any) - } - } catch { + if (!(await loadLanguage(lang))) { logger.warn( c.yellow( `\nThe language '${lang}' is not loaded, falling back to '${defaultLang}' for syntax highlighting.` ) ) lang = defaultLang + if (!(await loadLanguage(lang))) { + logger.warn( + c.yellow( + `\nThe default language '${lang}' is not loaded, falling back to 'txt' for syntax highlighting.` + ) + ) + lang = 'txt' + } } const mustaches = new Map()