Skip to content

Commit

Permalink
feat: shiki magic move integration (#1336)
Browse files Browse the repository at this point in the history
  • Loading branch information
antfu committed Feb 26, 2024
1 parent 5fc5d47 commit 29a6131
Show file tree
Hide file tree
Showing 12 changed files with 464 additions and 174 deletions.
2 changes: 1 addition & 1 deletion demo/composable-vue/package.json
Expand Up @@ -8,7 +8,7 @@
},
"devDependencies": {
"@iconify-json/mdi": "^1.1.64",
"@iconify-json/ri": "^1.1.19",
"@iconify-json/ri": "^1.1.20",
"@slidev/cli": "workspace:*",
"@slidev/theme-default": "^0.25.0",
"@slidev/theme-seriph": "^0.25.0",
Expand Down
53 changes: 53 additions & 0 deletions demo/starter/slides.md
Expand Up @@ -178,6 +178,59 @@ Notes can also sync with clicks

---

# Shiki Magic Move

(this feature is still experimental)

Powered by [shiki-magic-move](https://shiki-magic-move.netlify.app/), Slidev supports animations across multiple code snippets.

Add multiple code blocks and wrap them with <code>````md magic-move</code> (four backticks) to enable the magic move. For example:

````md magic-move
```ts
const author = reactive({
name: 'John Doe',
books: [
'Vue 2 - Advanced Guide',
'Vue 3 - Basic Guide',
'Vue 4 - The Mystery'
]
})
```
```ts
export default {
data() {
return {
author: {
name: 'John Doe',
books: [
'Vue 2 - Advanced Guide',
'Vue 3 - Basic Guide',
'Vue 4 - The Mystery'
]
}
}
}
}
```
```ts
export default {
data: () => ({
author: {
name: 'John Doe',
books: [
'Vue 2 - Advanced Guide',
'Vue 3 - Basic Guide',
'Vue 4 - The Mystery'
]
}
})
}
```
````

---

# Components

<div grid="~ cols-2 gap-4">
Expand Down
48 changes: 48 additions & 0 deletions packages/client/builtin/ShikiMagicMove.vue
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { ShikiMagicMovePrecompiled } from 'shiki-magic-move/vue'
import type { KeyedTokensInfo } from 'shiki-magic-move/types'
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
import { useSlideContext } from '../context'
import { makeId } from '../logic/utils'
import 'shiki-magic-move/style.css'
const props = defineProps<{
steps: KeyedTokensInfo[]
at?: string | number
}>()
const { $clicksContext: clicks, $scale: scale } = useSlideContext()
const id = makeId()
const index = ref(0)
onUnmounted(() => {
clicks!.unregister(id)
})
onMounted(() => {
if (!clicks || clicks.disabled)
return
const { start, end, delta } = clicks.resolve(props.at || '+1', props.steps.length - 1)
clicks.register(id, { max: end, delta })
watchEffect(() => {
if (clicks.disabled)
index.value = props.steps.length - 1
else
index.value = Math.min(Math.max(0, clicks.current - start + 1), props.steps.length - 1)
})
})
</script>

<template>
<div class="slidev-code-wrapper slidev-code-magic-move">
<ShikiMagicMovePrecompiled
class="slidev-code relative shiki"
:steps="steps"
:step="index"
:options="{ globalScale: scale }"
/>
</div>
</template>
5 changes: 4 additions & 1 deletion packages/client/context.ts
@@ -1,4 +1,4 @@
import { shallowRef, toRef } from 'vue'
import { ref, shallowRef, toRef } from 'vue'
import { injectLocal, objectOmit, provideLocal } from '@vueuse/core'
import { useFixedClicks } from './composables/useClicks'
import {
Expand All @@ -9,6 +9,7 @@ import {
injectionFrontmatter,
injectionRenderContext,
injectionRoute,
injectionSlideScale,
injectionSlidevContext,
} from './constants'

Expand All @@ -26,6 +27,7 @@ export function useSlideContext() {
const $renderContext = injectLocal(injectionRenderContext)!
const $frontmatter = injectLocal(injectionFrontmatter, {})
const $route = injectLocal(injectionRoute, undefined)
const $scale = injectLocal(injectionSlideScale, ref(1))!

return {
$slidev,
Expand All @@ -36,6 +38,7 @@ export function useSlideContext() {
$route,
$renderContext,
$frontmatter,
$scale,
}
}

Expand Down
5 changes: 2 additions & 3 deletions packages/client/internals/DrawingLayer.vue
@@ -1,10 +1,9 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { injectLocal } from '@vueuse/core'
import { drauu, drawingEnabled, loadCanvas } from '../logic/drawings'
import { injectionSlideScale } from '../constants'
import { useSlideContext } from '../context'
const scale = injectLocal(injectionSlideScale)!
const scale = useSlideContext().$scale
const svg = ref<SVGSVGElement>()
onMounted(() => {
Expand Down
3 changes: 2 additions & 1 deletion packages/client/package.json
Expand Up @@ -53,8 +53,9 @@
"prettier": "^3.2.5",
"recordrtc": "^5.6.2",
"resolve": "^1.22.8",
"shiki-magic-move": "^0.1.0",
"unocss": "^0.58.5",
"vue": "^3.4.19",
"vue": "^3.4.20",
"vue-router": "^4.3.0"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/create-app/template/package.json
Expand Up @@ -11,6 +11,6 @@
"@slidev/cli": "^0.48.0-beta.12",
"@slidev/theme-default": "latest",
"@slidev/theme-seriph": "latest",
"vue": "^3.4.19"
"vue": "^3.4.20"
}
}
136 changes: 107 additions & 29 deletions packages/slidev/node/plugins/markdown.ts
Expand Up @@ -3,6 +3,7 @@ import Markdown from 'unplugin-vue-markdown/vite'
import type { Plugin } from 'vite'
import * as base64 from 'js-base64'
import { slash } from '@antfu/utils'
import { hash as getHash } from 'ohash'

// @ts-expect-error missing types
import mila from 'markdown-it-link-attributes'
Expand All @@ -15,12 +16,17 @@ import type MarkdownIt from 'markdown-it'
import { encode } from 'plantuml-encoder'
import Mdc from 'markdown-it-mdc'
import type { MarkdownItShikiOptions } from '@shikijs/markdown-it'
import type { Highlighter, ShikiTransformer } from 'shiki'
import { codeToKeyedTokens, createMagicMoveMachine } from 'shiki-magic-move/core'
import type { ResolvedSlidevOptions, SlidevPluginOptions } from '../options'
import Katex from './markdown-it-katex'
import { loadSetups } from './setupNode'
import Prism from './markdown-it-prism'
import { transformSnippet } from './transformSnippet'

let shiki: Highlighter | undefined
let shikiOptions: MarkdownItShikiOptions | undefined

export async function createMarkdownPlugin(
options: ResolvedSlidevOptions,
{ markdown: mdOptions }: SlidevPluginOptions,
Expand All @@ -31,31 +37,49 @@ export async function createMarkdownPlugin(
const entryPath = slash(entry)

if (config.highlighter === 'shiki') {
const MarkdownItShiki = await import('@shikijs/markdown-it').then(r => r.default)
const { transformerTwoslash } = await import('@shikijs/vitepress-twoslash')
const options = await loadShikiSetups(clientRoot, roots)
const plugin = await MarkdownItShiki({
...options,
transformers: [
...options.transformers || [],
transformerTwoslash({
explicitTrigger: true,
twoslashOptions: {
handbookOptions: {
noErrorValidation: true,
},
},
}),
{
pre(pre) {
this.addClassToHast(pre, 'slidev-code')
delete pre.properties.tabindex
},
postprocess(code) {
return escapeVueInCode(code)
const [
options,
{ getHighlighter, bundledLanguages },
markdownItShiki,
transformerTwoslash,
] = await Promise.all([
loadShikiSetups(clientRoot, roots),
import('shiki').then(({ getHighlighter, bundledLanguages }) => ({ bundledLanguages, getHighlighter })),
import('@shikijs/markdown-it/core').then(({ fromHighlighter }) => fromHighlighter),
import('@shikijs/vitepress-twoslash').then(({ transformerTwoslash }) => transformerTwoslash),
] as const)

shikiOptions = options
shiki = await getHighlighter({
...options as any,
langs: options.langs ?? Object.keys(bundledLanguages),
themes: 'themes' in options ? Object.values(options.themes) : [options.theme],
})

const transformers: ShikiTransformer[] = [
...options.transformers || [],
transformerTwoslash({
explicitTrigger: true,
twoslashOptions: {
handbookOptions: {
noErrorValidation: true,
},
},
],
}),
{
pre(pre) {
this.addClassToHast(pre, 'slidev-code')
delete pre.properties.tabindex
},
postprocess(code) {
return escapeVueInCode(code)
},
},
]

const plugin = markdownItShiki(shiki, {
...options,
transformers,
})
setups.push(md => md.use(plugin))
}
Expand Down Expand Up @@ -107,6 +131,9 @@ export async function createMarkdownPlugin(
? transformMarkdownMonaco
: truncateMancoMark

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

code = transformSlotSugar(code)
code = transformSnippet(code, options, id)
code = transformMermaid(code)
Expand Down Expand Up @@ -178,16 +205,67 @@ export function transformSlotSugar(md: string) {
return lines.join('\n')
}

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

/**
* Transform magic-move code blocks
*/
export function transformMagicMove(
md: string,
shiki: Highlighter | undefined,
shikiOptions: MarkdownItShikiOptions | undefined,
) {
const scripts: string[] = []

let count = 0
md = md.replace(
reMagicMoveBlock,
(full, _options = '', _attrs = '', body: string) => {
if (!shiki || !shikiOptions)
throw new Error('Shiki is required for Magic Move. You may need to set `highlighter: shiki` in your Slidev config.')

const matches = Array.from(body.matchAll(reCodeBlock))

if (!matches.length)
throw new Error('Magic Move block must contain at least one code block')
const langs = new Set(matches.map(i => i[1]))
if (langs.size > 1)
throw new Error(`Magic Move block must contain code blocks with the same language, got ${Array.from(langs).join(', ')}`)
const lang = Array.from(langs)[0] as any

const magicMove = createMagicMoveMachine(
code => codeToKeyedTokens(shiki, code, {
...shikiOptions,
lang,
}),
)

const steps = matches.map(i => magicMove.commit((i[5] || '').trimEnd()))
const id = `__magicMoveSteps_${getHash(body)}_${count++}`
scripts.push(`const ${id} = Object.freeze(${JSON.stringify(steps)})`)
return `<ShikiMagicMove :steps='${id}' />`
},
)

if (scripts.length)
md = `<script setup>\n${scripts.join('\n')}</script>\n\n${md}`
return md
}

/**
* Transform code block with wrapper
*/
export function transformHighlighter(md: string) {
return md.replace(/^```(\w+?)(?:\s*{([\d\w*,\|-]+)}\s*?({.*?})?(.*?))?\n([\s\S]+?)^```/mg, (full, lang = '', rangeStr = '', options = '', attrs = '', code: string) => {
const ranges = (rangeStr as string).split(/\|/g).map(i => i.trim())
code = code.trimEnd()
options = options.trim() || '{}'
return `\n<CodeBlockWrapper v-bind="${options}" :ranges='${JSON.stringify(ranges)}'>\n\n\`\`\`${lang}${attrs}\n${code}\n\`\`\`\n\n</CodeBlockWrapper>`
})
return md.replace(
reCodeBlock,
(full, lang = '', rangeStr = '', options = '', attrs = '', code: string) => {
const ranges = (rangeStr as string).split(/\|/g).map(i => i.trim())
code = code.trimEnd()
options = options.trim() || '{}'
return `\n<CodeBlockWrapper v-bind="${options}" :ranges='${JSON.stringify(ranges)}'>\n\n\`\`\`${lang}${attrs}\n${code}\n\`\`\`\n\n</CodeBlockWrapper>`
},
)
}

export function getCodeBlocks(md: string) {
Expand Down
9 changes: 9 additions & 0 deletions packages/slidev/node/utils.ts
Expand Up @@ -28,3 +28,12 @@ export function checkEngine(name: string, engines: { slidev?: string } = {}) {
if (engines.slidev && !satisfies(version, engines.slidev, { includePrerelease: true }))
throw new Error(`[slidev] addon "${name}" requires Slidev version range "${engines.slidev}" but found "${version}"`)
}

export function makeId(length = 5) {
const result = []
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
const charactersLength = characters.length
for (let i = 0; i < length; i++)
result.push(characters.charAt(Math.floor(Math.random() * charactersLength)))
return result.join('')
}

0 comments on commit 29a6131

Please sign in to comment.