| 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 |