diff --git a/packages/slidev/node/syntax/codeblock/magic-move.test.ts b/packages/slidev/node/syntax/codeblock/magic-move.test.ts new file mode 100644 index 0000000000..8c26dd74ae --- /dev/null +++ b/packages/slidev/node/syntax/codeblock/magic-move.test.ts @@ -0,0 +1,49 @@ +import path from 'node:path' +import lz from 'lz-string' +import MarkdownExit from 'markdown-exit' +import * as shiki from 'shiki' +import { expect, it } from 'vitest' +import { MarkdownItCodeblocks } from '.' + +it('resolves snippet imports before magic move validation', async () => { + const md = MarkdownExit({ html: true }) + const userRoot = path.join(__dirname, '../../../../../test/fixtures/') + const watchFiles: Record> = {} + + md.use(MarkdownItCodeblocks, { + userRoot, + data: { + watchFiles, + slides: [{ + index: 0, + source: { filepath: path.join(userRoot, 'test.md') }, + }], + config: { lineNumbers: false }, + }, + utils: { + shiki, + shikiOptions: { theme: 'nord' }, + }, + } as any, []) + + const result = await md.renderAsync([ + '````md magic-move', + '<<< @/snippets/snippet.ts#snippet ts', + '<<< @/snippets/snippet.ts ts {1}', + '````', + ].join('\n'), { id: 'slides.md__slidev_1.md' }) + + expect(result).toContain(' + expect(steps).toHaveLength(2) + expect(steps.map(step => step.lang)).toEqual(['ts', 'ts']) + + const watched = Object.values(watchFiles) + expect(watched).toHaveLength(1) + expect(watched[0]).toEqual(new Set([0])) +}) diff --git a/packages/slidev/node/syntax/codeblock/magic-move.ts b/packages/slidev/node/syntax/codeblock/magic-move.ts index e58c0a6898..3626b2d683 100644 --- a/packages/slidev/node/syntax/codeblock/magic-move.ts +++ b/packages/slidev/node/syntax/codeblock/magic-move.ts @@ -1,11 +1,14 @@ +import type { SlideInfo } from '@slidev/types' import { defineCodeblockTransformer } from '@slidev/types' import lz from 'lz-string' import { toKeyedTokens } from 'shiki-magic-move/core' +import { resolveSnippetImport } from '../snippet' import { normalizeRangeStr } from '../utils' const RE_MAGIC_MOVE_INFO = /^(?:md|markdown) magic-move\s*(?:\[([^\]]*)\])?\s*(\{[^}]*\})?/ // eslint-disable-next-line regexp/no-super-linear-backtracking const RE_CODE_BLOCK = /^```([\w'-]+)?(?:[ \t]*|[ \t][ \w\t'-]*)(?:\[([^\]]*)\])?[ \t]*(?:\{([\w*,|-]+)\}[ \t]*(\{[^}]*\})?([^\r\n]*))?\r?\n((?:(?!^```)[\s\S])*?)^```$/gm +const RE_INNER_CODE_FENCE = /^```/ const RE_LINES_TRUE = /\blines: *true\b/ const RE_LINES_FALSE = /\blines: *false\b/ @@ -13,7 +16,32 @@ function parseLineNumbersOption(options: string) { return RE_LINES_TRUE.test(options) ? true : RE_LINES_FALSE.test(options) ? false : undefined } -export default defineCodeblockTransformer(async ({ info, fence, code, options: { data: { config }, utils: { shikiOptions, shiki } } }) => { +function resolveMagicMoveSnippetImports(code: string, userRoot: string, slide: SlideInfo, watchFiles: Record>) { + let inCodeBlock = false + + return code.split(/\r?\n/).map((line) => { + if (RE_INNER_CODE_FENCE.test(line)) { + inCodeBlock = !inCodeBlock + return line + } + + if (inCodeBlock) + return line + + const snippet = resolveSnippetImport(line, userRoot, slide) + if (!snippet) + return line + + watchFiles[snippet.src] ??= new Set() + watchFiles[snippet.src].add(slide.index) + + const info = `${snippet.lang} ${snippet.meta}`.trim() + const content = snippet.content.endsWith('\n') ? snippet.content : `${snippet.content}\n` + return `\`\`\`${info}\n${content}\`\`\`` + }).join('\n') +} + +export default defineCodeblockTransformer(async ({ info, fence, code, slide, options: { userRoot, data: { config, watchFiles }, utils: { shikiOptions, shiki } } }) => { if (fence !== 4) return const match = info.match(RE_MAGIC_MOVE_INFO) @@ -21,7 +49,8 @@ export default defineCodeblockTransformer(async ({ info, fence, code, options: { return const [, title = '', options = '{}'] = match const defaultLineNumbers = parseLineNumbersOption(options) ?? config.lineNumbers - const matches = Array.from(code.matchAll(RE_CODE_BLOCK)) + const resolvedCode = slide ? resolveMagicMoveSnippetImports(code, userRoot, slide, watchFiles) : code + const matches = Array.from(resolvedCode.matchAll(RE_CODE_BLOCK)) if (!matches.length) throw new Error('Magic Move block must contain at least one code block') diff --git a/packages/slidev/node/syntax/snippet.ts b/packages/slidev/node/syntax/snippet.ts index bf7b500440..babc6a2758 100644 --- a/packages/slidev/node/syntax/snippet.ts +++ b/packages/slidev/node/syntax/snippet.ts @@ -1,4 +1,4 @@ -import type { ResolvedSlidevOptions } from '@slidev/types' +import type { ResolvedSlidevOptions, SlideInfo } from '@slidev/types' import type { MarkdownExit } from 'markdown-exit' import fs from 'node:fs' import path from 'node:path' @@ -105,7 +105,46 @@ function findRegion(lines: Array, regionName: string) { } // eslint-disable-next-line regexp/no-super-linear-backtracking -const RE_SNIPPET_IMPORT = /^<<<[ \t]*(\S.*?)(#[\w-]+)?[ \t]*(?:[ \t](\S+?))?[ \t]*(\{.*)?$/ +export const RE_SNIPPET_IMPORT = /^<<<[ \t]*(\S.*?)(#[\w-]+)?[ \t]*(?:[ \t](\S+?))?[ \t]*(\{.*)?$/ + +export function resolveSnippetImport(lineText: string, userRoot: string, slide: SlideInfo) { + const match = lineText.trimStart().match(RE_SNIPPET_IMPORT) + if (!match) + return null + + let [, filepath = '', regionName = '', lang = '', meta = ''] = match + const dir = path.dirname(slide.source.filepath) + const src = slash( + filepath.startsWith('@/') + ? path.resolve(userRoot, filepath.slice(2)) + : path.resolve(dir, filepath), + ) + + lang = lang.trim() || path.extname(filepath).slice(1) + meta = meta.trim() + + const isAFile = fs.existsSync(src) && fs.statSync(src).isFile() + if (!isAFile) { + throw new Error(`Code snippet path not found: ${src}`) + } + + let content = fs.readFileSync(src, 'utf8') + + if (regionName) { + const lines = content.split(RE_NEWLINE) + const region = findRegion(lines, regionName.slice(1)) + if (region) { + content = dedent( + lines + .slice(region.start, region.end) + .filter(l => !(region.re.start.test(l) || region.re.end.test(l))) + .join('\n'), + ) + } + } + + return { content, filepath, lang, meta, src } +} export default function MarkdownItSnippet(md: MarkdownExit, { userRoot, data: { watchFiles, slides } }: ResolvedSlidevOptions) { md.block.ruler.before('fence', 'snippet_import', (state, startLine, _endLine, silent) => { @@ -120,8 +159,6 @@ export default function MarkdownItSnippet(md: MarkdownExit, { userRoot, data: { if (silent) return true - let [, filepath = '', regionName = '', lang = '', meta = ''] = match - const slideNo = state.env.id?.match(regexSlideSourceId) const slide = slideNo ? slides[slideNo[1] - 1] : null @@ -130,35 +167,12 @@ export default function MarkdownItSnippet(md: MarkdownExit, { userRoot, data: { return false } - const dir = path.dirname(slide.source.filepath) - const src = slash( - filepath.startsWith('@/') - ? path.resolve(userRoot, filepath.slice(2)) - : path.resolve(dir, filepath), - ) - - lang = lang.trim() || path.extname(filepath).slice(1) - meta = meta.trim() - - const isAFile = fs.existsSync(src) && fs.statSync(src).isFile() - if (!isAFile) { - throw new Error(`Code snippet path not found: ${src}`) - } + const snippet = resolveSnippetImport(lineText, userRoot, slide) + if (!snippet) + return false - let content = fs.readFileSync(src, 'utf8') - - if (regionName) { - const lines = content.split(RE_NEWLINE) - const region = findRegion(lines, regionName.slice(1)) - if (region) { - content = dedent( - lines - .slice(region.start, region.end) - .filter(l => !(region.re.start.test(l) || region.re.end.test(l))) - .join('\n'), - ) - } - } + const { content, filepath, src } = snippet + let { lang, meta } = snippet if (meta.includes('{monaco-write}')) { monacoWriterWhitelist.add(filepath)