Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf: use Shiki shorthand #2026

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 47 additions & 8 deletions packages/slidev/node/setups/shiki.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
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<string, string>
shikiOptions: MarkdownItShikiOptions
} | undefined

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<ShikiSetup>(
roots,
@@ -28,7 +30,43 @@ export default async function setupShiki(roots: string[]) {
},
}],
)

const browserLanguages: any[] = []
const nodeLanguages: Record<string, LanguageInput> = 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<string, string>({
langs: nodeLanguages,
themes: bundledThemes,
engine: createJavaScriptRegexEngine,
})
const shiki = createSingletonShorthands<string, string>(createHighlighter)

cachedRoots = roots
return cachedShiki = {
28 changes: 19 additions & 9 deletions packages/slidev/node/syntax/markdown-it/markdown-it-shiki.ts
Original file line number Diff line number Diff line change
@@ -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 }),
Comment on lines +10 to +12
Copy link
Member Author

@kermanx kermanx Jan 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an ugly workaround 🥲

I tried passing lang and langAlias to transformerTwoslash, but it either complains "language not loaded" or doesn't transform the code.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to work on that to have codeToHast able to load grammars on demand - but that would requires quote some changes to the codebase to provide an async version of shiki - which I not yet have time to work on it properly 😇


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,
})
}
64 changes: 44 additions & 20 deletions packages/slidev/node/syntax/transform/magic-move.ts
Original file line number Diff line number Diff line change
@@ -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<string>][] = []

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 `<ShikiMagicMove v-bind="${options}" steps-lz="${compressed}" :step-ranges='${JSON.stringify(ranges)}' />`
(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 `<ShikiMagicMove v-bind="${options}" steps-lz="${compressed}" :step-ranges='${JSON.stringify(ranges)}' />`
}
},
)

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)
}
}
2 changes: 1 addition & 1 deletion packages/slidev/node/virtual/shiki.ts
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 3 additions & 3 deletions packages/types/src/options.ts
Original file line number Diff line number Diff line change
@@ -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<any, any>
shikiOptions: MarkdownItShikiOptions
shiki: ShorthandsBundle<string, string>
shikiOptions: MarkdownItShikiOptions & CodeOptionsThemes
katexOptions: KatexOptions | null
indexHtml: string
define: Record<string, string>
5 changes: 2 additions & 3 deletions packages/types/src/setups.ts
Original file line number Diff line number Diff line change
@@ -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<BuiltinTheme>
& CodeOptionsMeta
& {
setup: (highlighter: Highlighter) => Awaitable<void>
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that this function is never called. And I don't know how to support it because there's no longer a Highlighter

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can get the highlighter with:

  const shiki = createSingletonShorthands<string, string>(createHighlighter)
  const highlighter = await shiki.getSingletonHighlighter()

But I don't mind removing it if no one uses it

langs: (LanguageInput | BuiltinLanguage)[]
langs: (MaybeArray<LanguageRegistration> | BuiltinLanguage)[] | Record<string, LanguageInput>
}
>

3 changes: 2 additions & 1 deletion test/_tutils.ts
Original file line number Diff line number Diff line change
@@ -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,
18 changes: 4 additions & 14 deletions test/transform-magic-move.test.ts
Original file line number Diff line number Diff line change
@@ -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(`