Skip to content

Commit

Permalink
feat: use magic-string to do markdown pre-transform (#1496)
Browse files Browse the repository at this point in the history
Co-authored-by: _Kerman <kermanx@qq.com>
  • Loading branch information
antfu and KermanX committed Apr 5, 2024
1 parent 27123ca commit 0ef4fdd
Show file tree
Hide file tree
Showing 19 changed files with 279 additions and 151 deletions.
10 changes: 7 additions & 3 deletions packages/slidev/node/syntax/transform/code-wrapper.ts
@@ -1,17 +1,21 @@
import type { MarkdownTransformContext } from '@slidev/types'
import { normalizeRangeStr } from './utils'

export const reCodeBlock = /^```([\w'-]+?)(?:\s*{([\d\w*,\|-]+)}\s*?({.*?})?(.*?))?\n([\s\S]+?)^```$/mg

/**
* Transform code block with wrapper
*/
export function transformCodeWrapper(md: string) {
return md.replace(
export function transformCodeWrapper(ctx: MarkdownTransformContext) {
ctx.s.replace(
reCodeBlock,
(full, lang = '', rangeStr: string = '', options = '', attrs = '', code: string) => {
(full, lang = '', rangeStr: string = '', options = '', attrs = '', code: string, index: number) => {
if (ctx.isIgnored(index))
return full
const ranges = normalizeRangeStr(rangeStr)
code = code.trimEnd()
options = options.trim() || '{}'
ctx.ignores.push([index, index + full.length])
return `\n<CodeBlockWrapper v-bind="${options}" :ranges='${JSON.stringify(ranges)}'>\n\n\`\`\`${lang}${attrs}\n${code}\n\`\`\`\n\n</CodeBlockWrapper>`
},
)
Expand Down
14 changes: 5 additions & 9 deletions packages/slidev/node/syntax/transform/in-page-css.ts
@@ -1,26 +1,22 @@
import { getCodeBlocks } from './utils'
import type { MarkdownTransformContext } from '@slidev/types'

/**
* Transform <style> in markdown to scoped style with page selector
*/
export function transformPageCSS(md: string, id: string) {
export function transformPageCSS(ctx: MarkdownTransformContext, id: string) {
const page = id.match(/(\d+)\.md$/)?.[1]
if (!page)
return md
return

const { isInsideCodeblocks } = getCodeBlocks(md)

const result = md.replace(
ctx.s.replace(
/(\n<style[^>]*?>)([\s\S]+?)(<\/style>)/g,
(full, start, css, end, index) => {
// don't replace `<style>` inside code blocks, #101
if (index < 0 || isInsideCodeblocks(index))
if (ctx.isIgnored(index))
return full
if (!start.includes('scoped'))
start = start.replace('<style', '<style scoped')
return `${start}\n${css}${end}`
},
)

return result
}
9 changes: 6 additions & 3 deletions packages/slidev/node/syntax/transform/katex-wrapper.ts
@@ -1,13 +1,16 @@
import type { MarkdownTransformContext } from '@slidev/types'

/**
* Wrapper KaTex syntax `$$...$$` for highlighting
*/
export function transformKaTexWrapper(md: string) {
return md.replace(
export function transformKaTexWrapper(ctx: MarkdownTransformContext) {
ctx.s.replace(
/^\$\$(?:\s*{([\d\w*,\|-]+)}\s*?({.*?})?\s*?)?\n([\s\S]+?)^\$\$/mg,
(full, rangeStr: string = '', options = '', code: string) => {
(full, rangeStr: string = '', options = '', code: string, index: number) => {
const ranges = !rangeStr.trim() ? [] : rangeStr.trim().split(/\|/g).map(i => i.trim())
code = code.trimEnd()
options = options.trim() || '{}'
ctx.ignores.push([index, index + full.length])
return `<KaTexBlockWrapper v-bind="${options}" :ranges='${JSON.stringify(ranges)}'>\n\n\$\$\n${code}\n\$\$\n</KaTexBlockWrapper>\n`
},
)
Expand Down
10 changes: 7 additions & 3 deletions packages/slidev/node/syntax/transform/magic-move.ts
Expand Up @@ -2,6 +2,7 @@ import type { MarkdownItShikiOptions } from '@shikijs/markdown-it'
import type { Highlighter } from 'shiki'
import { codeToKeyedTokens } from 'shiki-magic-move/core'
import lz from 'lz-string'
import type { MarkdownTransformContext } from '@slidev/types'
import { normalizeRangeStr } from './utils'
import { reCodeBlock } from './code-wrapper'

Expand All @@ -11,13 +12,13 @@ const reMagicMoveBlock = /^````(?:md|markdown) magic-move(?:[ ]*(\{.*?\})?([^\n]
* Transform magic-move code blocks
*/
export function transformMagicMove(
md: string,
ctx: MarkdownTransformContext,
shiki: Highlighter | undefined,
shikiOptions: MarkdownItShikiOptions | undefined,
) {
return md.replace(
ctx.s.replace(
reMagicMoveBlock,
(full, options = '{}', _attrs = '', body: string) => {
(full, options = '{}', _attrs = '', body: string, index: number) => {
if (!shiki || !shikiOptions)
throw new Error('Shiki is required for Magic Move. You may need to set `highlighter: shiki` in your Slidev config.')

Expand All @@ -33,6 +34,9 @@ export function transformMagicMove(
}),
)
const compressed = lz.compressToBase64(JSON.stringify(steps))

ctx.ignores.push([index, index + full.length])

return `<ShikiMagicMove v-bind="${options}" steps-lz="${compressed}" :step-ranges='${JSON.stringify(ranges)}' />`
},
)
Expand Down
17 changes: 9 additions & 8 deletions packages/slidev/node/syntax/transform/mermaid.ts
@@ -1,14 +1,15 @@
import lz from 'lz-string'
import type { MarkdownTransformContext } from '@slidev/types'

/**
* Transform Mermaid code blocks (render done on client side)
*/
export function transformMermaid(md: string): string {
return md
.replace(/^```mermaid\s*?({.*?})?\n([\s\S]+?)\n```/mg, (full, options = '', code = '') => {
code = code.trim()
options = options.trim() || '{}'
const encoded = lz.compressToBase64(code)
return `<Mermaid code-lz="${encoded}" v-bind="${options}" />`
})
export function transformMermaid(ctx: MarkdownTransformContext) {
ctx.s.replace(/^```mermaid\s*?({.*?})?\n([\s\S]+?)\n```/mg, (full, options = '', code = '', index: number) => {
code = code.trim()
options = options.trim() || '{}'
const encoded = lz.compressToBase64(code)
ctx.ignores.push([index, index + full.length])
return `<Mermaid code-lz="${encoded}" v-bind="${options}" />`
})
}
29 changes: 15 additions & 14 deletions packages/slidev/node/syntax/transform/monaco.ts
@@ -1,40 +1,45 @@
import { isTruthy } from '@antfu/utils'
import lz from 'lz-string'
import type { MarkdownTransformContext } from '@slidev/types'

export function transformMonaco(md: string, enabled = true) {
if (!enabled)
return truncateMancoMark(md)
export function transformMonaco(ctx: MarkdownTransformContext, enabled = true) {
if (!enabled) {
ctx.s.replace(/{monaco([\w:,-]*)}/g, '')
return
}

// transform monaco
md = md.replace(
ctx.s.replace(
/^```(\w+?)\s*{monaco-diff}\s*?({.*?})?\s*?\n([\s\S]+?)^~~~\s*?\n([\s\S]+?)^```/mg,
(full, lang = 'ts', options = '{}', code: string, diff: string) => {
(full, lang = 'ts', options = '{}', code: string, diff: string, index: number) => {
lang = lang.trim()
options = options.trim() || '{}'
const encoded = lz.compressToBase64(code)
const encodedDiff = lz.compressToBase64(diff)
ctx.ignores.push([index, index + full.length])
return `<Monaco code-lz="${encoded}" diff-lz="${encodedDiff}" lang="${lang}" v-bind="${options}" />`
},
)
md = md.replace(
ctx.s.replace(
/^```(\w+?)\s*{monaco}\s*?({.*?})?\s*?\n([\s\S]+?)^```/mg,
(full, lang = 'ts', options = '{}', code: string) => {
(full, lang = 'ts', options = '{}', code: string, index: number) => {
lang = lang.trim()
options = options.trim() || '{}'
const encoded = lz.compressToBase64(code)
ctx.ignores.push([index, index + full.length])
return `<Monaco code-lz="${encoded}" lang="${lang}" v-bind="${options}" />`
},
)
md = md.replace(
ctx.s.replace(
/^```(\w+?)\s*{monaco-run}\s*?({.*?})?\s*?\n([\s\S]+?)^```/mg,
(full, lang = 'ts', options = '{}', code: string) => {
(full, lang = 'ts', options = '{}', code: string, index: number) => {
lang = lang.trim()
options = options.trim() || '{}'
const encoded = lz.compressToBase64(code)
ctx.ignores.push([index, index + full.length])
return `<Monaco runnable code-lz="${encoded}" lang="${lang}" v-bind="${options}" />`
},
)
return md
}

// types auto discovery for TypeScript monaco
Expand All @@ -58,7 +63,3 @@ export function scanMonacoModules(md: string) {

return Array.from(typeModules)
}

export function truncateMancoMark(md: string) {
return md.replace(/{monaco([\w:,-]*)}/g, '')
}
15 changes: 8 additions & 7 deletions packages/slidev/node/syntax/transform/plant-uml.ts
@@ -1,10 +1,11 @@
import { encode as encodePlantUml } from 'plantuml-encoder'
import type { MarkdownTransformContext } from '@slidev/types'

export function transformPlantUml(md: string, server: string): string {
return md
.replace(/^```plantuml\s*?({.*?})?\n([\s\S]+?)\n```/mg, (full, options = '', content = '') => {
const code = encodePlantUml(content.trim())
options = options.trim() || '{}'
return `<PlantUml :code="'${code}'" :server="'${server}'" v-bind="${options}" />`
})
export function transformPlantUml(ctx: MarkdownTransformContext, server: string) {
ctx.s.replace(/^```plantuml\s*?({.*?})?\n([\s\S]+?)\n```/mg, (full, options = '', content = '', index: number) => {
const code = encodePlantUml(content.trim())
options = options.trim() || '{}'
ctx.ignores.push([index, index + full.length])
return `<PlantUml :code="'${code}'" :server="'${server}'" v-bind="${options}" />`
})
}
31 changes: 19 additions & 12 deletions packages/slidev/node/syntax/transform/slot-sugar.ts
@@ -1,25 +1,32 @@
import { getCodeBlocks } from './utils'
import type { MarkdownTransformContext } from '@slidev/types'

export function transformSlotSugar(md: string) {
const lines = md.split(/\r?\n/g)
export function transformSlotSugar(
ctx: MarkdownTransformContext,
) {
const linesWithNewline = ctx.s.original.split(/(\r?\n)/g)
const lines: string[] = []
for (let i = 0; i < linesWithNewline.length; i += 2) {
const line = linesWithNewline[i]
const newline = linesWithNewline[i + 1] || ''
lines.push(line + newline)
}

let prevSlot = false

const { isLineInsideCodeblocks } = getCodeBlocks(md)

lines.forEach((line, idx) => {
if (isLineInsideCodeblocks(idx))
let offset = 0
lines.forEach((line) => {
const start = offset
offset += line.length
if (ctx.isIgnored(start))
return

const match = line.trimEnd().match(/^::\s*([\w\.\-\:]+)\s*::$/)
const match = line.match(/^::\s*([\w\.\-\:]+)\s*::(\s*)?$/)
if (match) {
lines[idx] = `${prevSlot ? '\n\n</template>\n' : '\n'}<template v-slot:${match[1]}="slotProps">\n`
ctx.s.overwrite(start, offset - match[2].length, `${prevSlot ? '\n\n</template>\n' : '\n'}<template v-slot:${match[1]}="slotProps">\n`)
prevSlot = true
}
})

if (prevSlot)
lines[lines.length - 1] += '\n\n</template>'

return lines.join('\n')
ctx.s.append('\n\n</template>')
}
10 changes: 6 additions & 4 deletions packages/slidev/node/syntax/transform/snippet.ts
Expand Up @@ -2,7 +2,7 @@

import path from 'node:path'
import fs from 'fs-extra'
import type { ResolvedSlidevOptions } from '@slidev/types'
import type { MarkdownTransformContext, ResolvedSlidevOptions } from '@slidev/types'

function dedent(text: string): string {
const lines = text.split('\n')
Expand Down Expand Up @@ -80,14 +80,16 @@ function findRegion(lines: Array<string>, regionName: string) {
*
* captures: ['/path/to/file.extension', '#region', 'language', '{meta}']
*/
export function transformSnippet(md: string, options: ResolvedSlidevOptions, id: string) {
export function transformSnippet(ctx: MarkdownTransformContext, options: ResolvedSlidevOptions, id: string) {
const slideId = (id as string).match(/(\d+)\.md$/)?.[1]
if (!slideId)
return md
return

const data = options.data
const slideInfo = data.slides[+slideId - 1]
const dir = path.dirname(slideInfo.source?.filepath ?? options.entry ?? options.userRoot)
return md.replace(

ctx.s.replace(
/^<<< *(.+?)(#[\w-]+)? *(?: (\S+?))? *(\{.*)?$/mg,
(full, filepath = '', regionName = '', lang = '', meta = '') => {
const firstLine = `\`\`\`${lang || path.extname(filepath).slice(1)} ${meta}`
Expand Down
22 changes: 0 additions & 22 deletions packages/slidev/node/syntax/transform/utils.ts
Expand Up @@ -2,28 +2,6 @@ export function normalizeRangeStr(rangeStr = '') {
return !rangeStr.trim() ? [] : rangeStr.trim().split(/\|/g).map(i => i.trim())
}

export function getCodeBlocks(md: string) {
const codeblocks = Array
.from(md.matchAll(/^```[\s\S]*?^```/mg))
.map((m) => {
const start = m.index!
const end = m.index! + m[0].length
const startLine = md.slice(0, start).match(/\n/g)?.length || 0
const endLine = md.slice(0, end).match(/\n/g)?.length || 0
return [start, end, startLine, endLine]
})

return {
codeblocks,
isInsideCodeblocks(idx: number) {
return codeblocks.some(([s, e]) => s <= idx && idx <= e)
},
isLineInsideCodeblocks(line: number) {
return codeblocks.some(([, , s, e]) => s <= line && line <= e)
},
}
}

/**
* Escape `{{` in code block to prevent Vue interpret it, #99, #1316
*/
Expand Down
37 changes: 24 additions & 13 deletions packages/slidev/node/vite/markdown.ts
Expand Up @@ -7,14 +7,15 @@ import { taskLists as MarkdownItTaskList } from '@hedgedoc/markdown-it-plugins'
import MarkdownItMdc from 'markdown-it-mdc'
import type { MarkdownItShikiOptions } from '@shikijs/markdown-it'
import type { Highlighter, ShikiTransformer } from 'shiki'
import MagicString from 'magic-string'

// @ts-expect-error missing types
import MarkdownItAttrs from 'markdown-it-link-attributes'

// @ts-expect-error missing types
import MarkdownItFootnote from 'markdown-it-footnote'

import type { ResolvedSlidevOptions, SlidevPluginOptions } from '@slidev/types'
import type { MarkdownTransformContext, ResolvedSlidevOptions, SlidevPluginOptions } from '@slidev/types'
import MarkdownItKatex from '../syntax/markdown-it/markdown-it-katex'
import MarkdownItPrism from '../syntax/markdown-it/markdown-it-prism'

Expand Down Expand Up @@ -129,19 +130,29 @@ export async function createMarkdownPlugin(

const monacoEnabled = (config.monaco === true || config.monaco === mode)

const ctx: MarkdownTransformContext = {
s: new MagicString(code),
ignores: [],
isIgnored(index) {
return index < 0 || ctx.ignores.some(([start, end]) => start <= index && index < end)
},
}

transformSnippet(ctx, options, id)

if (config.highlighter === 'shiki')
code = transformMagicMove(code, shiki, shikiOptions)

code = transformSlotSugar(code)
code = transformSnippet(code, options, id)
code = transformMermaid(code)
code = transformPlantUml(code, config.plantUmlServer)
code = transformMonaco(code, monacoEnabled)
code = transformCodeWrapper(code)
code = transformPageCSS(code, id)
code = transformKaTexWrapper(code)

return code
transformMagicMove(ctx, shiki, shikiOptions)

transformMermaid(ctx)
transformPlantUml(ctx, config.plantUmlServer)
transformMonaco(ctx, monacoEnabled)
transformCodeWrapper(ctx)
transformKaTexWrapper(ctx)

transformPageCSS(ctx, id)
transformSlotSugar(ctx)

return ctx.s.toString()
},
},
}) as Plugin
Expand Down
1 change: 1 addition & 0 deletions packages/slidev/package.json
Expand Up @@ -81,6 +81,7 @@
"kolorist": "^1.8.0",
"local-pkg": "^0.5.0",
"lz-string": "^1.5.0",
"magic-string": "^0.30.9",
"markdown-it": "^14.1.0",
"markdown-it-footnote": "^4.0.0",
"markdown-it-link-attributes": "^4.0.1",
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/index.ts
Expand Up @@ -7,3 +7,4 @@ export * from './hmr'
export * from './code-runner'
export * from './options'
export * from './vite'
export * from './transform'

0 comments on commit 0ef4fdd

Please sign in to comment.