返回 slidev
magic-move.ts
根目录 / packages / slidev / node / syntax / codeblock / magic-move.ts
1 import type { SlideInfo } from '@slidev/types'
2 import { toKeyedTokens } from '@shikijs/magic-move/core'
3 import { defineCodeblockTransformer } from '@slidev/types'
4 import lz from 'lz-string'
5 import { resolveSnippetImport } from '../snippet'
6 import { normalizeRangeStr } from '../utils'
7
8 const RE_MAGIC_MOVE_INFO = /^(?:md|markdown) magic-move\s*(?:\[([^\]]*)\])?\s*(\{[^}]*\})?/
9 // eslint-disable-next-line regexp/no-super-linear-backtracking
10 const RE_CODE_BLOCK = /^```([\w'-]+)?(?:[ \t]*|[ \t][ \w\t'-]*)(?:\[([^\]]*)\])?[ \t]*(?:\{([\w*,|-]+)\}[ \t]*(\{[^}]*\})?([^\r\n]*))?\r?\n((?:(?!^```)[\s\S])*?)^```$/gm
11 const RE_INNER_CODE_FENCE = /^```/
12 const RE_LINES_TRUE = /\blines: *true\b/
13 const RE_LINES_FALSE = /\blines: *false\b/
14
15 function parseLineNumbersOption(options: string) {
16 return RE_LINES_TRUE.test(options) ? true : RE_LINES_FALSE.test(options) ? false : undefined
17 }
18
19 function resolveMagicMoveSnippetImports(code: string, userRoot: string, slide: SlideInfo, watchFiles: Record<string, Set<number>>) {
20 let inCodeBlock = false
21
22 return code.split(/\r?\n/).map((line) => {
23 if (RE_INNER_CODE_FENCE.test(line)) {
24 inCodeBlock = !inCodeBlock
25 return line
26 }
27
28 if (inCodeBlock)
29 return line
30
31 const snippet = resolveSnippetImport(line, userRoot, slide)
32 if (!snippet)
33 return line
34
35 watchFiles[snippet.src] ??= new Set()
36 watchFiles[snippet.src].add(slide.index)
37
38 const info = `${snippet.lang} ${snippet.meta}`.trim()
39 const content = snippet.content.endsWith('\n') ? snippet.content : `${snippet.content}\n`
40 return `\`\`\`${info}\n${content}\`\`\``
41 }).join('\n')
42 }
43
44 export default defineCodeblockTransformer(async ({ info, fence, code, slide, options: { userRoot, data: { config, watchFiles }, utils: { shikiOptions, shiki } } }) => {
45 if (fence !== 4)
46 return
47 const match = info.match(RE_MAGIC_MOVE_INFO)
48 if (!match)
49 return
50 const [, title = '', options = '{}'] = match
51 const defaultLineNumbers = parseLineNumbersOption(options) ?? config.lineNumbers
52 const resolvedCode = slide ? resolveMagicMoveSnippetImports(code, userRoot, slide, watchFiles) : code
53 const matches = Array.from(resolvedCode.matchAll(RE_CODE_BLOCK))
54 if (!matches.length)
55 throw new Error('Magic Move block must contain at least one code block')
56
57 const ranges = matches.map(i => normalizeRangeStr(i[3]))
58 const steps = await Promise.all(matches.map(async (i) => {
59 const lang = i[1]
60 const lineNumbers = parseLineNumbersOption(i[4]) ?? defaultLineNumbers
61 const code = i[6].trimEnd()
62 const options = {
63 ...shikiOptions,
64 lang,
65 }
66 const { tokens, bg, fg, rootStyle, themeName } = await shiki.codeToTokens(code, options)
67 return {
68 ...toKeyedTokens(code, tokens, JSON.stringify([lang, 'themes' in options ? options.themes : options.theme]), lineNumbers),
69 bg,
70 fg,
71 rootStyle,
72 themeName,
73 lang,
74 }
75 }))
76 const compressed = lz.compressToBase64(JSON.stringify(steps))
77 return `<ShikiMagicMove v-bind="${options}" steps-lz="${compressed}" :title='${JSON.stringify(title)}' :step-ranges='${JSON.stringify(ranges)}' />`
78 })
79
79 lines TYPESCRIPT