feat(plugin-md-power): 支持可编辑的代码演示

This commit is contained in:
pengzhanbo 2024-05-05 01:32:44 +08:00
parent 2127ca44a8
commit 5addb31e91
17 changed files with 570 additions and 69 deletions

View File

@ -83,7 +83,11 @@ export const theme: Theme = themePlume({
replit: true,
codeSandbox: true,
jsfiddle: true,
repl: true,
repl: {
go: true,
rust: true,
kotlin: true,
},
},
comment: {
provider: 'Giscus',

View File

@ -11,7 +11,7 @@ permalink: /guide/repl/golang/
主题提供了 Golang 代码演示,支持 在线运行 Go 代码。
::: important
该功能通过将 代码提交到 服务器 进行 编译并执行,且一次只能提交单个代码文件
该功能通过将 代码提交到 服务器 进行 编译并执行。
因此,请不要使用此功能 执行 过于复杂的代码,也不要过于频繁的进行执行请求。
:::
@ -28,7 +28,9 @@ export default defineUserConfig({
theme: plumeTheme({
plugins: {
markdownPower: {
repl: true,
repl: {
go: true,
},
},
}
})
@ -41,10 +43,26 @@ export default defineUserConfig({
使用 `::: go-repl` 容器语法 将 Go 代码块包裹起来。主题会检查代码块并添加执行按钮。
### 只读代码演示
golang 代码演示默认是只读的,不可编辑。
````md
::: go-repl
::: go-repl 自定义标题
```go
// your rust code
// your go code
```
:::
````
### 可编辑代码演示
如果需要在线编辑并执行,需要将代码块包裹在 `::: go-repl#editable` 容器语法中
````md
::: go-repl#editable 自定义标题
```go
// your go code
```
:::
````
@ -88,6 +106,44 @@ func main() {
:::
### 可编辑代码演示
**输入:**
````md
:::go-repl#editable
```go
package main
import (
"fmt"
)
func main() {
fmt.Println("Hello World")
}
```
:::
````
**输出:**
:::go-repl#editable
```go
package main
import (
"fmt"
)
func main() {
fmt.Println("Hello World")
}
```
:::
### 循环随机延迟打印
**输入:**
@ -186,3 +242,32 @@ func main() {
```
:::
### 多文件
::: go-repl
```go{10-12}
package main
import (
"play.ground/foo"
)
func main() {
foo.Bar()
}
-- go.mod --
module play.ground
-- foo/foo.go --
package foo
import "fmt"
func Bar() {
fmt.Println("This function lives in an another file!")
}
```
:::

View File

@ -28,7 +28,9 @@ export default defineUserConfig({
theme: plumeTheme({
plugins: {
markdownPower: {
repl: true,
repl: {
kotlin: true,
},
},
}
})
@ -39,10 +41,26 @@ export default defineUserConfig({
## 使用
使用 `::: kotlin-repl` 容器语法 将 Rust 代码块包裹起来。主题会检查代码块并添加执行按钮。
使用 `::: kotlin-repl` 容器语法 将 kotlin 代码块包裹起来。主题会检查代码块并添加执行按钮。
### 只读代码演示
kotlin 代码演示默认是只读的,不可编辑。
````md
::: kotlin-repl
::: kotlin-repl 自定义标题
```kotlin
// your kotlin code
```
:::
````
### 可编辑代码演示
如果需要在线编辑并执行,需要将代码块包裹在 `::: kotlin-repl#editable` 容器语法中
````md
::: kotlin-repl#editable 自定义标题
```kotlin
// your kotlin code
```
@ -98,3 +116,35 @@ fun main(args: Array<String>) {
```
:::
### 可编辑代码演示
**输入:**
````md
::: kotlin-repl#editable
```kotlin
class Contact(val id: Int, var email: String)
fun main(args: Array<String>) {
val contact = Contact(1, "mary@gmail.com")
println(contact.id)
}
```
:::
````
**输出:**
::: kotlin-repl#editable
```kotlin
class Contact(val id: Int, var email: String)
fun main(args: Array<String>) {
val contact = Contact(1, "mary@gmail.com")
println(contact.id)
}
```
:::

View File

@ -28,7 +28,9 @@ export default defineUserConfig({
theme: plumeTheme({
plugins: {
markdownPower: {
repl: true,
repl: {
rust: true,
},
},
}
})
@ -41,8 +43,24 @@ export default defineUserConfig({
使用 `::: rust-repl` 容器语法 将 Rust 代码块包裹起来。主题会检查代码块并添加执行按钮。
### 只读代码演示
rust 代码演示默认是只读的,不可编辑。
````md
::: rust-repl
::: rust-repl 自定义标题
```rust
// your rust code
```
:::
````
### 可编辑代码演示
如果需要在线编辑并执行,需要将代码块包裹在 `::: rust-repl#editable` 容器语法中
````md
::: rust-repl#editable 自定义标题
```rust
// your rust code
```
@ -56,7 +74,7 @@ export default defineUserConfig({
**输入:**
````md
::: rust-repl
::: rust-repl 打印内容
```rust
fn main() {
println!("Hello, world!");
@ -67,7 +85,7 @@ fn main() {
**输出:**
::: rust-repl
::: rust-repl 打印内容
```rust
fn main() {
@ -107,6 +125,25 @@ fn main() {
### 等待子进程执行
**输入:**
````md
::: rust-repl
```rust
use std::process::Command;
fn main() {
let mut child = Command::new("sleep").arg("5").spawn().unwrap();
let _result = child.wait().unwrap();
println!("reached end of main");
}
```
:::
````
**输出:**
::: rust-repl
```rust
@ -121,3 +158,29 @@ fn main() {
```
:::
### 可编辑的演示
**输入:**
````md
::: rust-repl#editable
```rust
fn main() {
println!("Hello, world!");
}
```
:::
````
**输出:**
::: rust-repl#editable
```rust
fn main() {
println!("Hello, world!");
}
```
:::

View File

@ -0,0 +1,143 @@
<script setup lang="ts">
import { getHighlighterCore } from 'shiki/core'
import type { HighlighterCore } from 'shiki/core'
import editorData from '@internal/md-power/replEditorData'
import { onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
import { resolveCodeInfo } from '../composables/codeRepl.js'
let highlighter: HighlighterCore | null = null
let container: HTMLPreElement | null = null
let lineNumbers: HTMLDivElement | null = null
const { grammars, theme } = editorData
const lang = ref<'go' | 'rust' | 'kotlin'>()
const editorEl = shallowRef<HTMLDivElement>()
const textAreaEl = shallowRef<HTMLTextAreaElement>()
const input = ref('')
async function init() {
highlighter = await getHighlighterCore({
themes: 'light' in theme && 'dark' in theme ? [theme.light, theme.dark] : [theme],
langs: Object.keys(grammars).map(key => grammars[key]),
loadWasm: () => import('shiki/wasm'),
})
}
function highlight() {
if (highlighter && lang.value && input.value) {
const output = highlighter.codeToHtml(input.value, {
lang: lang.value,
...('light' in theme && 'dark' in theme
? { themes: theme, defaultColor: false }
: { theme }),
})
if (container) {
container.innerHTML = output
.replace(/^<pre[^]+?>/, '')
.replace(/<\/pre>$/, '')
.replace(/(<span class="line">)(<\/span>)/g, '$1<wbr>$2')
}
if (lineNumbers) {
lineNumbers.innerHTML = output
.split('\n')
.map(() => '<div class="line-number"></div>')
.join('')
}
}
}
function updateScroll() {
container && (container.scrollLeft = textAreaEl.value?.scrollLeft || 0)
}
watch([input], highlight, { flush: 'post' })
onMounted(async () => {
if (!editorEl.value || !textAreaEl.value)
return
await init()
container = editorEl.value.querySelector('pre')
lineNumbers = editorEl.value.querySelector('.line-numbers')
const info = resolveCodeInfo(editorEl.value)
lang.value = info.lang
input.value = info.code
textAreaEl.value.addEventListener('scroll', updateScroll, { passive: false })
window.addEventListener('resize', updateScroll)
})
onUnmounted(() => {
textAreaEl.value?.removeEventListener('scroll', updateScroll)
window.removeEventListener('resize', updateScroll)
highlighter = null
container = null
lineNumbers = null
})
</script>
<template>
<div ref="editorEl" class="code-repl-editor">
<slot />
<textarea ref="textAreaEl" v-model="input" class="code-repl-input" />
</div>
</template>
<style scoped>
.code-repl-editor {
position: relative;
}
.code-repl-editor :deep(div[class*="language-"] pre) {
scrollbar-width: none;
}
.code-repl-editor:hover :deep(.copy-code-button) {
opacity: 1;
}
.code-repl-input {
position: absolute;
top: 0;
right: -1.5rem;
bottom: 0;
left: -1.5rem;
z-index: 1;
box-sizing: border-box;
display: block;
padding: 1.3rem 1.5rem;
overflow-x: auto;
font-family: var(--vp-font-family-mono);
font-size: var(--vp-code-font-size);
-webkit-hyphens: none;
hyphens: none;
color: transparent;
text-align: left;
word-break: normal;
word-wrap: normal;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
white-space: pre;
caret-color: gray;
resize: none;
background-color: transparent;
word-spacing: normal;
direction: ltr;
-webkit-font-smoothing: auto;
-moz-osx-font-smoothing: auto;
scrollbar-width: thin;
}
@media (min-width: 640px) {
.code-repl-input {
right: 0;
left: 0;
}
}
:deep(div[class*="language-"].line-numbers-mode) + .code-repl-input {
padding-left: 1rem;
margin-left: 2rem;
}
</style>

View File

@ -1,11 +1,18 @@
<script setup lang="ts">
import { shallowRef } from 'vue'
import { defineAsyncComponent, shallowRef } from 'vue'
import { useCodeRepl } from '../composables/codeRepl.js'
import IconRun from './IconRun.vue'
import Loading from './Loading.vue'
import IconConsole from './IconConsole.vue'
import IconClose from './IconClose.vue'
defineProps<{
editable?: boolean
title?: string
}>()
const Editor = defineAsyncComponent(() => import('./CodeEditor.vue'))
const replEl = shallowRef<HTMLDivElement | null>(null)
const outputEl = shallowRef<HTMLDivElement | null>(null)
const {
@ -31,10 +38,16 @@ function runCode() {
<template>
<div ref="replEl" class="code-repl">
<span v-show="loaded && finished" class="icon-run" title="Run Code" @click="runCode">
<IconRun />
</span>
<slot />
<div class="code-repl-title">
<h4>{{ title }}</h4>
<span v-show="loaded && finished" class="icon-run" title="Run Code" @click="runCode">
<IconRun />
</span>
</div>
<Editor v-if="editable">
<slot />
</Editor>
<slot v-else />
<div ref="outputEl" class="code-repl-pin" />
<div v-if="!firstRun" class="code-repl-output">
<div class="output-head">
@ -80,16 +93,43 @@ function runCode() {
margin-bottom: 16px;
}
.code-repl :deep(div[class*="language-"]) {
margin: 0 -1.5rem;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.code-repl-output {
position: relative;
top: -20px;
padding-top: 6px;
margin: 0 -1.5rem;
background-color: var(--vp-code-block-bg);
transition: background-color, var(--t-color);
transition: background-color var(--t-color);
}
@media (min-width: 768px) {
.code-repl-title {
display: flex;
align-items: center;
padding: 0 20px;
margin: 0 -1.5rem;
background-color: var(--vp-code-block-bg);
border-bottom: solid 1px var(--vp-c-divider);
transition: var(--t-color);
transition-property: background, border;
}
@media (min-width: 640px) {
.code-repl-title {
margin: 0;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}
.code-repl :deep(div[class*="language-"]) {
margin: 0;
}
.code-repl-output {
margin: 0;
border-bottom-right-radius: 6px;
@ -97,34 +137,36 @@ function runCode() {
}
}
.code-repl-title h4 {
flex: 1;
padding: 0 12px;
margin: 0;
font-size: 14px;
font-weight: 500;
line-height: 48px;
color: var(--vp-code-tab-active-text-color);
white-space: nowrap;
transition: color var(--t-color);
}
.icon-run {
position: absolute;
top: -10px;
right: 10px;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
font-size: 16px;
color: var(--vp-c-bg);
width: 24px;
height: 24px;
font-size: 12px;
color: var(--vp-c-text-3);
cursor: pointer;
background-color: var(--vp-c-brand-1);
border: solid 1px var(--vp-c-text-3);
border-radius: 100%;
transition: var(--t-color);
transition-property: color, background-color;
}
@media (min-width: 768px) {
.icon-run {
top: 60px;
right: 16px;
}
transition-property: color, border;
}
.icon-run:hover {
background-color: var(--vp-c-brand-2);
color: var(--vp-c-text-2);
border-color: var(--vp-c-text-2);
}
.code-repl-output .output-head {
@ -132,7 +174,7 @@ function runCode() {
align-items: center;
justify-content: space-between;
padding: 4px 10px 4px 20px;
border-top: solid 2px var(--vp-c-border);
border-top: solid 2px var(--vp-c-divider);
transition: border-color var(--t-color);
}

View File

@ -1,4 +1,4 @@
import { type Ref, ref } from 'vue'
import { type Ref, onMounted, ref } from 'vue'
import { http } from '../utils/http.js'
import { sleep } from '../utils/sleep.js'
import { rustExecute } from './rustRepl.js'
@ -28,20 +28,23 @@ function resolveLang(lang?: string) {
return lang ? langAlias[lang] || lang : ''
}
export function resolveCode(el: HTMLElement): string {
const clone = el.cloneNode(true) as HTMLElement
clone
.querySelectorAll(ignoredNodes.join(','))
.forEach(node => node.remove())
return clone.textContent || ''
}
export function resolveCodeInfo(el: HTMLDivElement) {
const wrapper = el.querySelector('[class*=language-]')
const wrapper = el.querySelector('div[class*=language-]')
const lang = wrapper?.className.match(RE_LANGUAGE)?.[1]
const codeEl = wrapper?.querySelector('pre code')
const codeEl = wrapper?.querySelector('pre') as HTMLElement
let code = ''
if (codeEl) {
const clone = codeEl.cloneNode(true) as HTMLElement
clone
.querySelectorAll(ignoredNodes.join(','))
.forEach(node => node.remove())
code = clone.textContent || ''
}
if (codeEl)
code = resolveCode(codeEl)
return { lang: resolveLang(lang) as Lang, code }
}
@ -57,6 +60,13 @@ export function useCodeRepl(el: Ref<HTMLDivElement | null>) {
const error = ref('') // execute error
const backendVersion = ref('')
onMounted(() => {
if (el.value) {
const info = resolveCodeInfo(el.value)
lang.value = info.lang
}
})
const executeMap: ExecuteMap = {
kotlin: executeKotlin,
go: executeGolang,

View File

@ -1,6 +1,7 @@
declare module '*.vue' {
import type { ComponentOptions } from 'vue'
import type { ReplEditorData } from '../shared/repl.js'
const comp: ComponentOptions
export default comp
declare module '@internal/md-power/replEditorData' {
const res: ReplEditorData
export default res
}

View File

@ -5,7 +5,7 @@
import type { PluginWithOptions } from 'markdown-it'
import type Token from 'markdown-it/lib/token.mjs'
import type { RuleBlock } from 'markdown-it/lib/parser_block.mjs'
import type { Markdown } from 'vuepress/markdown'
import type MarkdownIt from 'markdown-it'
import container from 'markdown-it-container'
import { customAlphabet } from 'nanoid'
import type { CanIUseMode, CanIUseOptions, CanIUseTokenMeta } from '../../shared/index.js'
@ -145,7 +145,7 @@ export const caniusePlugin: PluginWithOptions<CanIUseOptions> = (
* ```
*/
export function legacyCaniuse(
md: Markdown,
md: MarkdownIt,
{ mode = 'embed' }: CanIUseOptions = {},
): void {
const modeMap: CanIUseMode[] = ['image', 'embed']

View File

@ -1,24 +1,82 @@
import type markdownIt from 'markdown-it'
import container from 'markdown-it-container'
import type Token from 'markdown-it/lib/token.mjs'
import type { App } from 'vuepress/core'
import { fs, getDirname, path } from 'vuepress/utils'
import type { ReplEditorData, ReplOptions } from '../../shared/repl.js'
function createReplContainer(md: markdownIt, type: string) {
const RE_INFO = /^(#editable)?\s*?(.*)$/
function createReplContainer(md: markdownIt, lang: string) {
const type = `${lang}-repl`
const validate = (info: string): boolean => info.trim().startsWith(type)
const render = (tokens: Token[], index: number): string => {
const token = tokens[index]
const info = token.info.trim().slice(type.length).trim() || ''
// :::lang-repl#editable title
const [, editable, title] = info.match(RE_INFO) ?? []
if (token.nesting === 1)
return '<LanguageRepl>'
return `<CodeRepl ${editable ? 'editable' : ''} title="${title || `${lang} playground`}">`
else
return '</LanguageRepl>'
return '</CodeRepl>'
}
md.use(container, type, { validate, render })
}
export function langReplPlugin(md: markdownIt) {
createReplContainer(md, 'kotlin-repl')
createReplContainer(md, 'go-repl')
createReplContainer(md, 'rust-repl')
export async function langReplPlugin(app: App, md: markdownIt, {
theme,
go = false,
kotlin = false,
rust = false,
}: ReplOptions) {
kotlin && createReplContainer(md, 'kotlin')
go && createReplContainer(md, 'go')
rust && createReplContainer(md, 'rust')
theme ??= { light: 'github-light', dark: 'github-dark' }
const data: ReplEditorData = { grammars: {} } as ReplEditorData
const themesPath = getDirname(import.meta.resolve('tm-themes'))
const grammarsPath = getDirname(import.meta.resolve('tm-grammars'))
const readTheme = (theme: string) => read(path.join(themesPath, 'themes', `${theme}.json`))
const readGrammar = (grammar: string) => read(path.join(grammarsPath, 'grammars', `${grammar}.json`))
if (typeof theme === 'string') {
data.theme = await readTheme(theme)
}
else {
data.theme = await Promise.all([
readTheme(theme.light),
readTheme(theme.dark),
]).then(([light, dark]) => ({ light, dark }))
}
if (kotlin)
data.grammars.kotlin = await readGrammar('kotlin')
if (go)
data.grammars.go = await readGrammar('go')
if (rust)
data.grammars.rust = await readGrammar('rust')
await app.writeTemp(
'internal/md-power/replEditorData.js',
`export default ${JSON.stringify(data, null, 2)}`,
)
}
async function read(file: string): Promise<any> {
try {
const content = await fs.readFile(file, 'utf-8')
return JSON.parse(content)
}
catch {}
return undefined
}

View File

@ -1,4 +1,5 @@
import type { Plugin } from 'vuepress/core'
import type MarkdownIt from 'markdown-it'
import type { CanIUseOptions, MarkdownPowerPluginOptions } from '../shared/index.js'
import { caniusePlugin, legacyCaniuse } from './features/caniuse.js'
import { pdfPlugin } from './features/pdf.js'
@ -18,7 +19,7 @@ export function markdownPowerPlugin(options: MarkdownPowerPluginOptions = {}): P
const { initIcon, addIcon } = createIconCSSWriter(app, options.icons)
return {
name: '@vuepress-plume/plugin-md-power',
name: 'vuepress-plugin-md-power',
// clientConfigFile: path.resolve(__dirname, '../client/config.js'),
clientConfigFile: app => prepareConfigFile(app, options),
@ -29,7 +30,7 @@ export function markdownPowerPlugin(options: MarkdownPowerPluginOptions = {}): P
onInitialized: async () => await initIcon(),
extendsMarkdown(md) {
extendsMarkdown: async (md: MarkdownIt, app) => {
if (options.caniuse) {
const caniuse = options.caniuse === true ? {} : options.caniuse
// @[caniuse](feature_name)
@ -87,7 +88,7 @@ export function markdownPowerPlugin(options: MarkdownPowerPluginOptions = {}): P
}
if (options.repl)
langReplPlugin(md)
await langReplPlugin(app, md, options.repl)
},
}
}

View File

@ -47,8 +47,8 @@ export async function prepareConfigFile(app: App, options: MarkdownPowerPluginOp
}
if (options.repl) {
imports.add(`import LanguageRepl from '${CLIENT_FOLDER}components/LanguageRepl.vue'`)
enhances.add(`app.component('LanguageRepl', LanguageRepl)`)
imports.add(`import CodeRepl from '${CLIENT_FOLDER}components/CodeRepl.vue'`)
enhances.add(`app.component('CodeRepl', CodeRepl)`)
}
// enhances.add(`if (__VUEPRESS_SSR__) return`)

View File

@ -2,6 +2,7 @@ import type { CanIUseOptions } from './caniuse.js'
import type { PDFOptions } from './pdf.js'
import type { IconsOptions } from './icons.js'
import type { PlotOptions } from './plot.js'
import type { ReplOptions } from './repl.js'
export interface MarkdownPowerPluginOptions {
pdf?: boolean | PDFOptions
@ -24,7 +25,7 @@ export interface MarkdownPowerPluginOptions {
jsfiddle?: boolean
// container
repl?: boolean
repl?: false | ReplOptions
caniuse?: boolean | CanIUseOptions
}

View File

@ -0,0 +1,28 @@
import type { BuiltinTheme, ThemeRegistration } from 'shiki'
export type ThemeOptions =
| BuiltinTheme
| {
light: BuiltinTheme
dark: BuiltinTheme
}
export interface ReplOptions {
theme?: ThemeOptions
go?: boolean
kotlin?: boolean
rust?: boolean
}
export interface ReplEditorData {
grammars: {
go?: any
kotlin?: any
rust?: any
}
theme: ThemeRegistration | {
light: ThemeRegistration
dark: ThemeRegistration
}
}

View File

@ -2,7 +2,13 @@
"extends": "../tsconfig.build.json",
"compilerOptions": {
"rootDir": "./src",
"types": [
"vuepress/client-types",
"vite/client",
"webpack-env"
],
"outDir": "./lib"
},
"files": [],
"include": ["./src"]
}

View File

@ -22,6 +22,7 @@ import { sitemapPlugin } from '@vuepress/plugin-sitemap'
import { contentUpdatePlugin } from '@vuepress-plume/plugin-content-update'
import { searchPlugin } from '@vuepress-plume/plugin-search'
import { markdownPowerPlugin } from 'vuepress-plugin-md-power'
import { isObject } from '@pengzhanbo/utils'
import type {
PlumeThemeEncrypt,
PlumeThemeLocaleOptions,
@ -166,9 +167,11 @@ export function setupPlugins(
}
const shikiOption = options.shiki || options.shikiji
let shikiTheme: any = { light: 'vitesse-light', dark: 'vitesse-dark' }
if (shikiOption !== false) {
shikiTheme = shikiOption?.theme ?? shikiTheme
plugins.push(shikiPlugin({
theme: { light: 'vitesse-light', dark: 'vitesse-dark' },
theme: shikiTheme,
...(shikiOption ?? {}),
}))
}
@ -206,6 +209,9 @@ export function setupPlugins(
plugins.push(markdownPowerPlugin({
caniuse: options.caniuse,
...options.markdownPower || {},
repl: options.markdownPower?.repl
? { theme: shikiTheme, ...options.markdownPower?.repl }
: options.markdownPower?.repl,
}))
}

View File

@ -10,6 +10,9 @@
"@internal/notesData": [
"./plugins/plugin-notes-data/src/client/notesData.d.ts"
],
"@internal/md-power/replEditorData": [
"./plugins/plugin-md-power/src/client/shim.d.ts"
],
"@internal/pageComponents": ["./docs/.vuepress/.temp/internal/pageComponents.js"],
"@internal/*": ["./docs/.vuepress/.temp/internal/*"],
"@vuepress-plume/*": ["./plugins/*/src/node/index.ts"],