Skip to content

Commit 6d1a84a

Browse files
authored
fix: resolve snippets in magic move blocks (#2590)
1 parent 6dec3f8 commit 6d1a84a

3 files changed

Lines changed: 126 additions & 34 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import path from 'node:path'
2+
import lz from 'lz-string'
3+
import MarkdownExit from 'markdown-exit'
4+
import * as shiki from 'shiki'
5+
import { expect, it } from 'vitest'
6+
import { MarkdownItCodeblocks } from '.'
7+
8+
it('resolves snippet imports before magic move validation', async () => {
9+
const md = MarkdownExit({ html: true })
10+
const userRoot = path.join(__dirname, '../../../../../test/fixtures/')
11+
const watchFiles: Record<string, Set<number>> = {}
12+
13+
md.use(MarkdownItCodeblocks, {
14+
userRoot,
15+
data: {
16+
watchFiles,
17+
slides: [{
18+
index: 0,
19+
source: { filepath: path.join(userRoot, 'test.md') },
20+
}],
21+
config: { lineNumbers: false },
22+
},
23+
utils: {
24+
shiki,
25+
shikiOptions: { theme: 'nord' },
26+
},
27+
} as any, [])
28+
29+
const result = await md.renderAsync([
30+
'````md magic-move',
31+
'<<< @/snippets/snippet.ts#snippet ts',
32+
'<<< @/snippets/snippet.ts ts {1}',
33+
'````',
34+
].join('\n'), { id: 'slides.md__slidev_1.md' })
35+
36+
expect(result).toContain('<ShikiMagicMove ')
37+
expect(result).toContain(':step-ranges=\'[[],["1"]]\'')
38+
39+
const encodedSteps = result.match(/steps-lz=([^ ]+)/)?.[1]?.slice(1, -1)
40+
expect(encodedSteps).toBeTruthy()
41+
42+
const steps = JSON.parse(lz.decompressFromBase64(encodedSteps!)!) as Array<{ lang: string }>
43+
expect(steps).toHaveLength(2)
44+
expect(steps.map(step => step.lang)).toEqual(['ts', 'ts'])
45+
46+
const watched = Object.values(watchFiles)
47+
expect(watched).toHaveLength(1)
48+
expect(watched[0]).toEqual(new Set([0]))
49+
})

packages/slidev/node/syntax/codeblock/magic-move.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,56 @@
1+
import type { SlideInfo } from '@slidev/types'
12
import { defineCodeblockTransformer } from '@slidev/types'
23
import lz from 'lz-string'
34
import { toKeyedTokens } from 'shiki-magic-move/core'
5+
import { resolveSnippetImport } from '../snippet'
46
import { normalizeRangeStr } from '../utils'
57

68
const RE_MAGIC_MOVE_INFO = /^(?:md|markdown) magic-move\s*(?:\[([^\]]*)\])?\s*(\{[^}]*\})?/
79
// eslint-disable-next-line regexp/no-super-linear-backtracking
810
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 = /^```/
912
const RE_LINES_TRUE = /\blines: *true\b/
1013
const RE_LINES_FALSE = /\blines: *false\b/
1114

1215
function parseLineNumbersOption(options: string) {
1316
return RE_LINES_TRUE.test(options) ? true : RE_LINES_FALSE.test(options) ? false : undefined
1417
}
1518

16-
export default defineCodeblockTransformer(async ({ info, fence, code, options: { data: { config }, utils: { shikiOptions, shiki } } }) => {
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 } } }) => {
1745
if (fence !== 4)
1846
return
1947
const match = info.match(RE_MAGIC_MOVE_INFO)
2048
if (!match)
2149
return
2250
const [, title = '', options = '{}'] = match
2351
const defaultLineNumbers = parseLineNumbersOption(options) ?? config.lineNumbers
24-
const matches = Array.from(code.matchAll(RE_CODE_BLOCK))
52+
const resolvedCode = slide ? resolveMagicMoveSnippetImports(code, userRoot, slide, watchFiles) : code
53+
const matches = Array.from(resolvedCode.matchAll(RE_CODE_BLOCK))
2554
if (!matches.length)
2655
throw new Error('Magic Move block must contain at least one code block')
2756

packages/slidev/node/syntax/snippet.ts

Lines changed: 46 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ResolvedSlidevOptions } from '@slidev/types'
1+
import type { ResolvedSlidevOptions, SlideInfo } from '@slidev/types'
22
import type { MarkdownExit } from 'markdown-exit'
33
import fs from 'node:fs'
44
import path from 'node:path'
@@ -105,7 +105,46 @@ function findRegion(lines: Array<string>, regionName: string) {
105105
}
106106

107107
// eslint-disable-next-line regexp/no-super-linear-backtracking
108-
const RE_SNIPPET_IMPORT = /^<<<[ \t]*(\S.*?)(#[\w-]+)?[ \t]*(?:[ \t](\S+?))?[ \t]*(\{.*)?$/
108+
export const RE_SNIPPET_IMPORT = /^<<<[ \t]*(\S.*?)(#[\w-]+)?[ \t]*(?:[ \t](\S+?))?[ \t]*(\{.*)?$/
109+
110+
export function resolveSnippetImport(lineText: string, userRoot: string, slide: SlideInfo) {
111+
const match = lineText.trimStart().match(RE_SNIPPET_IMPORT)
112+
if (!match)
113+
return null
114+
115+
let [, filepath = '', regionName = '', lang = '', meta = ''] = match
116+
const dir = path.dirname(slide.source.filepath)
117+
const src = slash(
118+
filepath.startsWith('@/')
119+
? path.resolve(userRoot, filepath.slice(2))
120+
: path.resolve(dir, filepath),
121+
)
122+
123+
lang = lang.trim() || path.extname(filepath).slice(1)
124+
meta = meta.trim()
125+
126+
const isAFile = fs.existsSync(src) && fs.statSync(src).isFile()
127+
if (!isAFile) {
128+
throw new Error(`Code snippet path not found: ${src}`)
129+
}
130+
131+
let content = fs.readFileSync(src, 'utf8')
132+
133+
if (regionName) {
134+
const lines = content.split(RE_NEWLINE)
135+
const region = findRegion(lines, regionName.slice(1))
136+
if (region) {
137+
content = dedent(
138+
lines
139+
.slice(region.start, region.end)
140+
.filter(l => !(region.re.start.test(l) || region.re.end.test(l)))
141+
.join('\n'),
142+
)
143+
}
144+
}
145+
146+
return { content, filepath, lang, meta, src }
147+
}
109148

110149
export default function MarkdownItSnippet(md: MarkdownExit, { userRoot, data: { watchFiles, slides } }: ResolvedSlidevOptions) {
111150
md.block.ruler.before('fence', 'snippet_import', (state, startLine, _endLine, silent) => {
@@ -120,8 +159,6 @@ export default function MarkdownItSnippet(md: MarkdownExit, { userRoot, data: {
120159
if (silent)
121160
return true
122161

123-
let [, filepath = '', regionName = '', lang = '', meta = ''] = match
124-
125162
const slideNo = state.env.id?.match(regexSlideSourceId)
126163
const slide = slideNo ? slides[slideNo[1] - 1] : null
127164

@@ -130,35 +167,12 @@ export default function MarkdownItSnippet(md: MarkdownExit, { userRoot, data: {
130167
return false
131168
}
132169

133-
const dir = path.dirname(slide.source.filepath)
134-
const src = slash(
135-
filepath.startsWith('@/')
136-
? path.resolve(userRoot, filepath.slice(2))
137-
: path.resolve(dir, filepath),
138-
)
139-
140-
lang = lang.trim() || path.extname(filepath).slice(1)
141-
meta = meta.trim()
142-
143-
const isAFile = fs.existsSync(src) && fs.statSync(src).isFile()
144-
if (!isAFile) {
145-
throw new Error(`Code snippet path not found: ${src}`)
146-
}
170+
const snippet = resolveSnippetImport(lineText, userRoot, slide)
171+
if (!snippet)
172+
return false
147173

148-
let content = fs.readFileSync(src, 'utf8')
149-
150-
if (regionName) {
151-
const lines = content.split(RE_NEWLINE)
152-
const region = findRegion(lines, regionName.slice(1))
153-
if (region) {
154-
content = dedent(
155-
lines
156-
.slice(region.start, region.end)
157-
.filter(l => !(region.re.start.test(l) || region.re.end.test(l)))
158-
.join('\n'),
159-
)
160-
}
161-
}
174+
const { content, filepath, src } = snippet
175+
let { lang, meta } = snippet
162176

163177
if (meta.includes('{monaco-write}')) {
164178
monacoWriterWhitelist.add(filepath)

0 commit comments

Comments
 (0)