diff --git a/packages/slidev/node/setups/shiki.ts b/packages/slidev/node/setups/shiki.ts index ee1eb2d0ad..3583fbce88 100644 --- a/packages/slidev/node/setups/shiki.ts +++ b/packages/slidev/node/setups/shiki.ts @@ -1,13 +1,16 @@ import type { MarkdownItShikiOptions } from '@shikijs/markdown-it' import type { ShikiSetup } from '@slidev/types' -import type { Highlighter } from 'shiki' +import type { LanguageInput, ShorthandsBundle } from 'shiki/core' import fs from 'node:fs/promises' -import { bundledLanguages, createHighlighter } from 'shiki' +import { red } from 'kolorist' +import { bundledLanguages, bundledThemes } from 'shiki/bundle/full' +import { createdBundledHighlighter, createSingletonShorthands } from 'shiki/core' +import { createJavaScriptRegexEngine } from 'shiki/engine/javascript' import { loadSetups } from './load' let cachedRoots: string[] | undefined let cachedShiki: { - shiki: Highlighter + shiki: ShorthandsBundle shikiOptions: MarkdownItShikiOptions } | undefined @@ -15,7 +18,6 @@ export default async function setupShiki(roots: string[]) { // Here we use shallow equality because when server is restarted, the roots will be different object. if (cachedRoots === roots) return cachedShiki! - cachedShiki?.shiki.dispose() const options = await loadSetups( roots, @@ -28,7 +30,43 @@ export default async function setupShiki(roots: string[]) { }, }], ) + + const browserLanguages: any[] = [] + const nodeLanguages: Record = bundledLanguages + for (const option of options) { + const langs = option?.langs + if (Array.isArray(langs)) { + for (const lang of langs.flat()) { + if (typeof lang === 'function') { + console.error(red('[slidev] `langs` option in shiki setup cannot be array containing functions. Please use `{ name: loaderFunction }` format instead.')) + } + else if (typeof lang === 'string') { + // Name of a Shiki built-in language + // In Node environment, they can be loaded on demand without overhead, so all built-in languages are available. + // Only need to include them explicitly in browser environment. + browserLanguages.push(lang) + } + else if (lang.name) { + // Custom grammar object + browserLanguages.push(lang) + nodeLanguages[lang.name] = lang + for (const alias of lang.aliases || []) + nodeLanguages[alias] = lang + } + } + } + else if (typeof option?.langs === 'object') { + // Map from name to loader or grammar object + Object.assign(nodeLanguages, option.langs) + browserLanguages.push(...Object.values(option.langs).filter(lang => lang?.name)) + } + else { + console.error(red('[slidev] Invalid langs option in shiki setup:'), langs) + } + } + const mergedOptions = Object.assign({}, ...options) + mergedOptions.langs = browserLanguages if ('theme' in mergedOptions && 'themes' in mergedOptions) delete mergedOptions.theme @@ -50,11 +88,12 @@ export default async function setupShiki(roots: string[]) { if (mergedOptions.themes) mergedOptions.defaultColor = false - const shiki = await createHighlighter({ - ...mergedOptions, - langs: mergedOptions.langs ?? Object.keys(bundledLanguages), - themes: 'themes' in mergedOptions ? Object.values(mergedOptions.themes) : [mergedOptions.theme], + const createHighlighter = createdBundledHighlighter({ + langs: nodeLanguages, + themes: bundledThemes, + engine: createJavaScriptRegexEngine, }) + const shiki = createSingletonShorthands(createHighlighter) cachedRoots = roots return cachedShiki = { diff --git a/packages/slidev/node/syntax/markdown-it/markdown-it-shiki.ts b/packages/slidev/node/syntax/markdown-it/markdown-it-shiki.ts index 1cf530e870..965f01a5e7 100644 --- a/packages/slidev/node/syntax/markdown-it/markdown-it-shiki.ts +++ b/packages/slidev/node/syntax/markdown-it/markdown-it-shiki.ts @@ -1,21 +1,31 @@ import type { ResolvedSlidevOptions } from '@slidev/types' import type { ShikiTransformer } from 'shiki' import { isTruthy } from '@antfu/utils' -import { fromHighlighter } from '@shikijs/markdown-it/core' +import { fromAsyncCodeToHtml } from '@shikijs/markdown-it/async' import { escapeVueInCode } from '../transform/utils' -export default async function MarkdownItShiki({ data: { config }, mode, utils }: ResolvedSlidevOptions) { - const transformers = [ - ...utils.shikiOptions.transformers || [], - (config.twoslash === true || config.twoslash === mode) - && (await import('@shikijs/vitepress-twoslash')).transformerTwoslash({ +export default async function MarkdownItShiki({ data: { config }, mode, utils: { shiki, shikiOptions } }: ResolvedSlidevOptions) { + async function getTwoslashTransformer() { + const [,,{ transformerTwoslash }] = await Promise.all([ + // trigger the shiki to load the langs + shiki.codeToHast('', { lang: 'js', ...shikiOptions }), + shiki.codeToHast('', { lang: 'ts', ...shikiOptions }), + + import('@shikijs/vitepress-twoslash'), + ]) + return transformerTwoslash({ explicitTrigger: true, twoslashOptions: { handbookOptions: { noErrorValidation: true, }, }, - }), + }) + } + + const transformers = [ + ...shikiOptions.transformers || [], + (config.twoslash === true || config.twoslash === mode) && await getTwoslashTransformer(), { pre(pre) { this.addClassToHast(pre, 'slidev-code') @@ -27,8 +37,8 @@ export default async function MarkdownItShiki({ data: { config }, mode, utils }: } satisfies ShikiTransformer, ].filter(isTruthy) as ShikiTransformer[] - return fromHighlighter(utils.shiki, { - ...utils.shikiOptions, + return fromAsyncCodeToHtml(shiki.codeToHtml, { + ...shikiOptions, transformers, }) } diff --git a/packages/slidev/node/syntax/transform/magic-move.ts b/packages/slidev/node/syntax/transform/magic-move.ts index 720546aefa..ee9ab42c80 100644 --- a/packages/slidev/node/syntax/transform/magic-move.ts +++ b/packages/slidev/node/syntax/transform/magic-move.ts @@ -1,6 +1,6 @@ import type { MarkdownTransformContext } from '@slidev/types' import lz from 'lz-string' -import { codeToKeyedTokens } from 'shiki-magic-move/core' +import { toKeyedTokens } from 'shiki-magic-move/core' import { reCodeBlock } from './code-wrapper' import { normalizeRangeStr } from './utils' @@ -13,27 +13,51 @@ function parseLineNumbersOption(options: string) { /** * Transform magic-move code blocks */ -export function transformMagicMove(ctx: MarkdownTransformContext) { +export async function transformMagicMove(ctx: MarkdownTransformContext) { + const { codeToTokens } = ctx.options.utils.shiki + const replacements: [number, number, Promise][] = [] + ctx.s.replace( reMagicMoveBlock, - (full, options = '{}', _attrs = '', body: string) => { - const matches = Array.from(body.matchAll(reCodeBlock)) - - if (!matches.length) - throw new Error('Magic Move block must contain at least one code block') - - const defaultLineNumbers = parseLineNumbersOption(options) ?? ctx.options.data.config.lineNumbers - - const ranges = matches.map(i => normalizeRangeStr(i[2])) - const steps = matches.map((i) => { - const lineNumbers = parseLineNumbersOption(i[3]) ?? defaultLineNumbers - return codeToKeyedTokens(ctx.options.utils.shiki, i[5].trimEnd(), { - ...ctx.options.utils.shikiOptions, - lang: i[1] as any, - }, lineNumbers) - }) - const compressed = lz.compressToBase64(JSON.stringify(steps)) - return `` + (full, options = '{}', _attrs = '', body: string, start: number) => { + const end = start + full.length + replacements.push([start, end, worker()]) + return '' + async function worker() { + const matches = Array.from(body.matchAll(reCodeBlock)) + + if (!matches.length) + throw new Error('Magic Move block must contain at least one code block') + + const defaultLineNumbers = parseLineNumbersOption(options) ?? ctx.options.data.config.lineNumbers + + const ranges = matches.map(i => normalizeRangeStr(i[2])) + const steps = await Promise.all(matches.map(async (i) => { + const lang = i[1] + const lineNumbers = parseLineNumbersOption(i[3]) ?? defaultLineNumbers + const code = i[5].trimEnd() + const options = { + ...ctx.options.utils.shikiOptions, + lang, + } + const { tokens, bg, fg, rootStyle, themeName } = await codeToTokens(code, options) + return { + ...toKeyedTokens(code, tokens, JSON.stringify([lang, 'themes' in options ? options.themes : options.theme]), lineNumbers), + bg, + fg, + rootStyle, + themeName, + lang, + } + })) + const compressed = lz.compressToBase64(JSON.stringify(steps)) + return `` + } }, ) + + for (const [start, end, content] of replacements) { + // magic-string internally uses `overwrite` instead of `update` in the `replace` method + ctx.s.overwrite(start, end, await content) + } } diff --git a/packages/slidev/node/virtual/shiki.ts b/packages/slidev/node/virtual/shiki.ts index 413003f837..3e8fb602fe 100644 --- a/packages/slidev/node/virtual/shiki.ts +++ b/packages/slidev/node/virtual/shiki.ts @@ -7,7 +7,7 @@ export const templateShiki: VirtualModuleTemplate = { id: '/@slidev/shiki', getContent: async ({ utils }) => { const options = utils.shikiOptions - const langs = await resolveLangs(options.langs || ['markdown', 'vue', 'javascript', 'typescript', 'html', 'css']) + const langs = await resolveLangs(options.langs?.length ? options.langs : ['markdown', 'vue', 'javascript', 'typescript', 'html', 'css']) const resolvedThemeOptions = 'themes' in options ? { themes: Object.fromEntries(await Promise.all(Object.entries(options.themes) diff --git a/packages/types/src/options.ts b/packages/types/src/options.ts index a8c168ca85..973838863d 100644 --- a/packages/types/src/options.ts +++ b/packages/types/src/options.ts @@ -1,6 +1,6 @@ import type { MarkdownItShikiOptions } from '@shikijs/markdown-it' import type { KatexOptions } from 'katex' -import type { HighlighterGeneric } from 'shiki' +import type { CodeOptionsThemes, ShorthandsBundle } from 'shiki/core' import type { SlidevData } from './types' export interface RootsInfo { @@ -57,8 +57,8 @@ export interface ResolvedSlidevOptions extends RootsInfo, SlidevEntryOptions { } export interface ResolvedSlidevUtils { - shiki: HighlighterGeneric - shikiOptions: MarkdownItShikiOptions + shiki: ShorthandsBundle + shikiOptions: MarkdownItShikiOptions & CodeOptionsThemes katexOptions: KatexOptions | null indexHtml: string define: Record diff --git a/packages/types/src/setups.ts b/packages/types/src/setups.ts index 1afc02e5be..4391a63051 100644 --- a/packages/types/src/setups.ts +++ b/packages/types/src/setups.ts @@ -2,7 +2,7 @@ import type { Awaitable } from '@antfu/utils' import type { KatexOptions } from 'katex' import type { MermaidConfig } from 'mermaid' import type * as monaco from 'monaco-editor' -import type { BuiltinLanguage, BuiltinTheme, CodeOptionsMeta, CodeOptionsThemes, CodeToHastOptionsCommon, Highlighter, LanguageInput } from 'shiki' +import type { BuiltinLanguage, BuiltinTheme, CodeOptionsMeta, CodeOptionsThemes, CodeToHastOptionsCommon, LanguageInput, LanguageRegistration, MaybeArray } from 'shiki' import type { VitePluginConfig as UnoCssConfig } from 'unocss/vite' import type { App, ComputedRef, Ref } from 'vue' import type { Router, RouteRecordRaw } from 'vue-router' @@ -57,8 +57,7 @@ export type ShikiSetupReturn = & CodeOptionsThemes & CodeOptionsMeta & { - setup: (highlighter: Highlighter) => Awaitable - langs: (LanguageInput | BuiltinLanguage)[] + langs: (MaybeArray | BuiltinLanguage)[] | Record } > diff --git a/test/_tutils.ts b/test/_tutils.ts index 2972466945..c16edc86d4 100644 --- a/test/_tutils.ts +++ b/test/_tutils.ts @@ -1,8 +1,9 @@ import type { MarkdownTransformContext } from '@slidev/types' import path from 'node:path' import MagicString from 'magic-string-stack' +import * as shiki from 'shiki' -export function createTransformContext(code: string, shiki?: any): MarkdownTransformContext { +export function createTransformContext(code: string): MarkdownTransformContext { const s = new MagicString(code) return { s, diff --git a/test/transform-magic-move.test.ts b/test/transform-magic-move.test.ts index d3018b094b..e0ef9bc20f 100644 --- a/test/transform-magic-move.test.ts +++ b/test/transform-magic-move.test.ts @@ -1,4 +1,3 @@ -import { createHighlighter } from 'shiki' import { expect, it } from 'vitest' import { transformMagicMove } from '../packages/slidev/node/syntax/transform/magic-move' import { createTransformContext } from './_tutils' @@ -21,14 +20,10 @@ let message = 'Hello, Slidev!' Some text after ` - const shiki = await createHighlighter({ - themes: ['nord'], - langs: ['typescript'], - }) - const ctx = createTransformContext(code, shiki) + const ctx = createTransformContext(code) - transformMagicMove(ctx) + await transformMagicMove(ctx) expect(ctx.s.toString()) .toMatchInlineSnapshot(` @@ -61,14 +56,9 @@ console.log('Hello, Angular #2!') Some text after ` - const shiki = await createHighlighter({ - themes: ['nord'], - langs: ['angular-ts'], - }) + const ctx = createTransformContext(code) - const ctx = createTransformContext(code, shiki) - - transformMagicMove(ctx) + await transformMagicMove(ctx) expect(ctx.s.toString()) .toMatchInlineSnapshot(`