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
46 changes: 21 additions & 25 deletions docs/custom/config-transformers.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,40 +4,36 @@

This setup function allows you to define custom transformers for the markdown content of **each slide**. This is useful when you want to add custom Markdown syntax and render custom code blocks. To start, create a `./setup/transformers.ts` file with the following content:

````ts twoslash [setup/transformers.ts]
import type { MarkdownTransformContext } from '@slidev/types'
import { defineTransformersSetup } from '@slidev/types'
```ts twoslash [setup/transformers.ts]
import { defineCodeblockTransformer, defineMarkdownTransformer, defineTransformersSetup } from '@slidev/types'
import lz from 'lz-string'

function myCodeblock(ctx: MarkdownTransformContext) {
const mySyntax = defineMarkdownTransformer((ctx) => {
console.log('index in presentation', ctx.slide.index)
ctx.s.replace(
/^```myblock *(\{[^\n]*\})?\n([\s\S]+?)\n```/gm,
(full, options = '', code = '') => {
/^\[\[\[(.*)\]\]\]/gm,
(full, content) => {
return `...`
},
)
}
})

const myCodeblock = defineCodeblockTransformer((ctx) => {
if (ctx.info.startsWith('myblock')) {
console.log('index in presentation', ctx.slide?.index)
return `<MyBlockRenderer code="${lz.compressToEncodedURIComponent(ctx.code)}" />`
}
})

export default defineTransformersSetup(() => {
return {
pre: [],
preCodeblock: [myCodeblock],
postCodeblock: [],
post: [],
// This applies before the Markdown is parsed, per slide
pre: [mySyntax],
// This applies per Markdown code block
codeblocks: [myCodeblock],
}
})
````

The return value should be the custom options for the transformers. The `pre`, `preCodeblock`, `postCodeblock`, and `post` are arrays of functions that will be called in order to transform the markdown content. The order of the transformers is:
```

1. `pre` from your project
2. `pre` from addons and themes
3. Import snippets syntax and Shiki magic move
4. `preCodeblock` from your project
5. `preCodeblock` from addons and themes
6. Built-in special code blocks like Mermaid, Monaco and PlantUML
7. `postCodeblock` from addons and themes
8. `postCodeblock` from your project
9. Other built-in transformers like code block wrapping
10. `post` from addons and themes
11. `post` from your project
> [!NOTE]
> When possible, implement `pre` transformers as markdown-it plugins for better robustness.
2 changes: 1 addition & 1 deletion packages/client/builtin/CodeBlockWrapper.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const props = defineProps({
},
title: {
type: String,
default: undefined,
default: '',
},
})

Expand Down
2 changes: 1 addition & 1 deletion packages/slidev/node/commands/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { ConfigEnv, InlineConfig } from 'vite'
import MarkdownExit from 'markdown-exit'
import { loadConfigFromFile, mergeConfig } from 'vite'
import { resolveSourceFiles } from '../resolver'
import markdownItLink from '../syntax/markdown-it/markdown-it-link'
import markdownItLink from '../syntax/link'
import { stringifyMarkdownTokens } from '../utils'
import { ViteSlidevPlugin } from '../vite'

Expand Down
3 changes: 3 additions & 0 deletions packages/slidev/node/setups/transformers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export default async function setupTransformers(roots: string[]) {
preCodeblock: [],
postCodeblock: [],
post: [],
codeblocks: [],
}
for (const r of [...returns].reverse()) {
if (r.pre)
Expand All @@ -20,6 +21,8 @@ export default async function setupTransformers(roots: string[]) {
result.postCodeblock.push(...r.postCodeblock)
if (r.post)
result.post.push(...r.post)
if (r.codeblocks)
result.codeblocks.push(...r.codeblocks)
}
return result
}
51 changes: 51 additions & 0 deletions packages/slidev/node/syntax/codeblock/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { CodeblockTransformContext, CodeblockTransformer, ResolvedSlidevOptions } from '@slidev/types'
import type { MarkdownExit } from 'markdown-exit'
import { ensureSuffix } from '@antfu/utils'
import { regexSlideSourceId } from '../../vite/common'
import magicMoveTransformer from './magic-move'
import mermaidTransformer from './mermaid'
import monacoTransformer from './monaco'
import plantUmlTransformer from './plant-uml'
import wrapperTransformer from './wrapper'

export function MarkdownItCodeblocks(md: MarkdownExit, options: ResolvedSlidevOptions, extraTransformers: (CodeblockTransformer | false)[]) {
const oldFence = md.renderer.rules.fence!
md.renderer.rules.fence = async function (tokens, idx, renderOptions, env, slf) {
const token = tokens[idx]

const slideNo = env.id?.match(regexSlideSourceId)
const ctx: CodeblockTransformContext = {
info: token.info.trim(),
code: token.content,
fence: token.markup.length,
slide: slideNo ? options.data.slides[slideNo[1] - 1] : null,
options,
renderHighlighted(override) {
if (override.info != null)
token.info = override.info
if (override.code != null)
token.content = override.code
return oldFence(tokens, idx, renderOptions, env, slf)
},
}

const transformers = [
...extraTransformers,
mermaidTransformer,
plantUmlTransformer,
magicMoveTransformer,
monacoTransformer,
wrapperTransformer,
]

for (const transformer of transformers) {
if (!transformer)
continue
const res = await transformer(ctx)
if (res != null)
return ensureSuffix('\n', res)
}

throw new Error('Should not reach here')
}
}
47 changes: 47 additions & 0 deletions packages/slidev/node/syntax/codeblock/magic-move.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { defineCodeblockTransformer } from '@slidev/types'
import lz from 'lz-string'
import { toKeyedTokens } from 'shiki-magic-move/core'
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

function parseLineNumbersOption(options: string) {
return /\blines: *true\b/.test(options) ? true : /\blines: *false\b/.test(options) ? false : undefined
}

export default defineCodeblockTransformer(async ({ info, fence, code, options: { data: { config }, 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))
if (!matches.length)
throw new Error('Magic Move block must contain at least one code block')

const ranges = matches.map(i => normalizeRangeStr(i[3]))
const steps = await Promise.all(matches.map(async (i) => {
const lang = i[1]
const lineNumbers = parseLineNumbersOption(i[4]) ?? defaultLineNumbers
const code = i[6].trimEnd()
const options = {
...shikiOptions,
lang,
}
const { tokens, bg, fg, rootStyle, themeName } = await shiki.codeToTokens(code, options)
return {
...toKeyedTokens(code, tokens, JSON.stringify([lang, 'themes' in options ? options.themes : options.theme]), lineNumbers),
bg,
fg,
rootStyle,
themeName,
lang,
}
}))
const compressed = lz.compressToBase64(JSON.stringify(steps))
return `<ShikiMagicMove v-bind="${options}" steps-lz="${compressed}" :title='${JSON.stringify(title)}' :step-ranges='${JSON.stringify(ranges)}' />`
})
14 changes: 14 additions & 0 deletions packages/slidev/node/syntax/codeblock/mermaid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { defineCodeblockTransformer } from '@slidev/types'
import lz from 'lz-string'

const RE_MERMAID = /^mermaid\s*(\{[^\n]*\})?/

export default defineCodeblockTransformer(async ({ info, code }) => {
const match = info.match(RE_MERMAID)
if (!match)
return
const [, options] = match
const optionsProp = options ? `v-bind="${options}"` : ''
const encoded = lz.compressToBase64(code.trim())
return `<Mermaid ${optionsProp} code-lz="${encoded}" />`
})
32 changes: 32 additions & 0 deletions packages/slidev/node/syntax/codeblock/monaco.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { defineCodeblockTransformer } from '@slidev/types'
import lz from 'lz-string'

// eslint-disable-next-line regexp/no-super-linear-backtracking
const RE_MONACO = /^([\w'-]+)?\s*\{(monaco[\w-]*)\}\s*(\{[^}]*\})?(.*)$/

export default defineCodeblockTransformer(async ({ info, code, options: { data: { config }, mode } }) => {
const match = info.match(RE_MONACO)
if (!match)
return
const [, lang = '', monaco, options] = match

const monacoEnabled = config.monaco === true || config.monaco === mode
if (!monacoEnabled) {
return
}

let encoded
let diff = ''
if (monaco === 'monaco-diff') {
const [original, modified] = code.split(/^\s*~~~\s*\n/m, 2)
encoded = lz.compressToBase64(original)
diff = modified === undefined ? '' : `diff-lz="${lz.compressToBase64(modified)}"`
}
else {
encoded = lz.compressToBase64(code)
}

const optionsProp = options ? `v-bind="${options}"` : ''
const runnable = monaco === 'monaco-run' ? 'runnable' : ''
return `<Monaco ${optionsProp} ${runnable} lang="${lang}" code-lz="${encoded}" ${diff} />`
})
15 changes: 15 additions & 0 deletions packages/slidev/node/syntax/codeblock/plant-uml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { defineCodeblockTransformer } from '@slidev/types'
import { encode as encodePlantUml } from 'plantuml-encoder'

const RE_PLANT_UML = /^plantuml\s*(\{[^\n]*\})?/

export default defineCodeblockTransformer(async ({ info, code, options: { data: { config: { plantUmlServer } } } }) => {
const match = info.match(RE_PLANT_UML)
if (!match)
return
const [, options] = match
const optionsProp = options ? `v-bind="${options}"` : ''
const encoded = encodePlantUml(code.trim())
const serverProp = plantUmlServer === undefined ? '' : ` :server=${JSON.stringify(plantUmlServer)}`
return `<PlantUml ${optionsProp} code="${encoded}"${serverProp} />`
})
12 changes: 12 additions & 0 deletions packages/slidev/node/syntax/codeblock/wrapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { defineCodeblockTransformer } from '@slidev/types'
import { escapeVueInCode, normalizeRangeStr } from '../utils'

const RE_BLOCK_INFO = /^([\w'-]+)?(?:[ \t]*|[ \t][ \w\t'-]*)(?:\[([^\]]*)\])?[ \t]*(?:\{([\d,|\-*]+)\}[ \t]*(\{[^}]*\})?([^\r\n]*))?/

export default defineCodeblockTransformer(async ({ info, renderHighlighted }) => {
const [, lang = '', title = '', rangeStr = '', options, rest = ''] = info.match(RE_BLOCK_INFO) ?? []
const ranges = normalizeRangeStr(rangeStr)
const optionsProp = options ? `v-bind="${options}"` : ''
const code = await renderHighlighted({ info: `${lang} ${rest}` })
return `<CodeBlockWrapper ${optionsProp} title=${JSON.stringify(title)} :ranges='${JSON.stringify(ranges)}'>${escapeVueInCode(code)}</CodeBlockWrapper>`
})
27 changes: 27 additions & 0 deletions packages/slidev/node/syntax/drag.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type MagicString from 'magic-string-stack'
import MarkdownExit from 'markdown-exit'
import { expect, it } from 'vitest'
import MarkdownItVDrag from './drag'

it('v-drag component', async () => {
const md = MarkdownExit({ html: true })
const map = new Map<string, MagicString>()
md.use(MarkdownItVDrag, map)

const result = await md.renderAsync('<v-drag>Content</v-drag>', { id: 'test' })

expect(result).toMatchInlineSnapshot(`
"<p><v-drag :markdownSource="[0,1,0]">Content</v-drag></p>
"
`)
})

it('v-drag directive', async () => {
const md = MarkdownExit({ html: true })
const map = new Map<string, MagicString>()
md.use(MarkdownItVDrag, map)

const result = await md.renderAsync('<div v-drag>Content</div>', { id: 'test' })

expect(result).toMatchInlineSnapshot(`"<div v-drag :markdownSource="[0,1,5]">Content</div>"`)
})
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ export default function MarkdownItVDrag(md: MarkdownExit, markdownTransformMap:
const visited = new WeakSet()
const sourceMapConsumers = new WeakMap<MagicString, SourceMapConsumer>()

function getSourceMapConsumer(id: string) {
const s = markdownTransformMap.get(id)
function getSourceMapConsumer(id: string | undefined) {
const s = id && markdownTransformMap.get(id)
if (!s)
return undefined
let smc = sourceMapConsumers.get(s)
Expand All @@ -29,7 +29,7 @@ export default function MarkdownItVDrag(md: MarkdownExit, markdownTransformMap:

const _parse = md.parse
md.parse = function (src, env) {
const smc = getSourceMapConsumer(env.id)
const smc = getSourceMapConsumer(env?.id)
const toOriginalPos = smc
? (line: number) => smc.originalPositionFor({ line: line + 1, column: 0 }).line - 1
: (line: number) => line
Expand Down
15 changes: 15 additions & 0 deletions packages/slidev/node/syntax/escape-code.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import MarkdownExit from 'markdown-exit'
import { expect, it } from 'vitest'
import MarkdownItEscapeInlineCode from './escape-code'

it('escape inline code', async () => {
const md = MarkdownExit()
md.use(MarkdownItEscapeInlineCode)

const result = await md.renderAsync('This is `inline {{code}}` test')

expect(result).toMatchInlineSnapshot(`
"<p>This is <code v-pre>inline {{code}}</code> test</p>
"
`)
})
42 changes: 42 additions & 0 deletions packages/slidev/node/syntax/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { CodeblockTransformer, ResolvedSlidevOptions } from '@slidev/types'
import type MagicString from 'magic-string'
import type MarkdownExit from 'markdown-exit'
import MarkdownItComark from '@comark/markdown-it'
import { taskLists as MarkdownItTaskList } from '@hedgedoc/markdown-it-plugins'
// @ts-expect-error missing types
import MarkdownItFootnote from 'markdown-it-footnote'
import { MarkdownItCodeblocks } from './codeblock'
import MarkdownItVDrag from './drag'
import MarkdownItEscapeInlineCode from './escape-code'
import MarkdownItKatex from './katex'
import MarkdownItLink from './link'
import MarkdownItStyleScoped from './scoped'
import MarkdownItShiki from './shiki'
import MarkdownItSlotSugar from './slot-sugar'
import MarkdownItSnippet from './snippet'

export async function useMarkdownItPlugins(
md: MarkdownExit,
options: ResolvedSlidevOptions,
markdownTransformMap: Map<string, MagicString>,
codeblockTransformers: (CodeblockTransformer | false)[],
) {
const { data: { features, config }, utils: { katexOptions } } = options

md.use(MarkdownItSnippet, options)
// @ts-expect-error @shikijs/markdown-it types expect MarkdownItAsync, but MarkdownExit is API-compatible
md.use(await MarkdownItShiki(options))
md.use(MarkdownItCodeblocks, options, codeblockTransformers)

md.use(MarkdownItLink)
md.use(MarkdownItEscapeInlineCode)
md.use(MarkdownItFootnote)
md.use(MarkdownItTaskList, { enabled: true, lineNumber: true, label: true })
if (features.katex)
md.use(MarkdownItKatex, katexOptions)
md.use(MarkdownItVDrag, markdownTransformMap)
md.use(MarkdownItSlotSugar)
if (config.comark || config.mdc)
md.use(MarkdownItComark)
md.use(MarkdownItStyleScoped)
}
Loading
Loading