Skip to content
Merged
Show file tree
Hide file tree
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
49 changes: 49 additions & 0 deletions packages/slidev/node/syntax/codeblock/magic-move.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, Set<number>> = {}

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('<ShikiMagicMove ')
expect(result).toContain(':step-ranges=\'[[],["1"]]\'')

const encodedSteps = result.match(/steps-lz=([^ ]+)/)?.[1]?.slice(1, -1)
expect(encodedSteps).toBeTruthy()

const steps = JSON.parse(lz.decompressFromBase64(encodedSteps!)!) as Array<{ lang: string }>
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]))
})
33 changes: 31 additions & 2 deletions packages/slidev/node/syntax/codeblock/magic-move.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,56 @@
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/

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<string, Set<number>>) {
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)
if (!match)
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')

Expand Down
78 changes: 46 additions & 32 deletions packages/slidev/node/syntax/snippet.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -105,7 +105,46 @@ function findRegion(lines: Array<string>, 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) => {
Expand All @@ -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

Expand All @@ -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)
Expand Down
Loading