diff --git a/__mocks__/fs.cjs b/__mocks__/fs.cjs
new file mode 100644
index 00000000..e5f40ad0
--- /dev/null
+++ b/__mocks__/fs.cjs
@@ -0,0 +1,3 @@
+const { fs } = require('memfs')
+
+module.exports = fs
diff --git a/__mocks__/fs/promises.cjs b/__mocks__/fs/promises.cjs
new file mode 100644
index 00000000..3b9d8b42
--- /dev/null
+++ b/__mocks__/fs/promises.cjs
@@ -0,0 +1,3 @@
+const { fs } = require('memfs')
+
+module.exports = fs.promises
diff --git a/package.json b/package.json
index 5a0eef7c..7ea1f105 100644
--- a/package.json
+++ b/package.json
@@ -57,6 +57,7 @@
"husky": "^9.1.6",
"lint-staged": "^15.2.10",
"markdown-it": "^14.1.0",
+ "memfs": "^4.13.0",
"rimraf": "^6.0.1",
"stylelint": "^16.10.0",
"tsconfig-vuepress": "^5.2.0",
diff --git a/plugins/plugin-md-power/__test__/__snapshots__/langReplPlugin.spec.ts.snap b/plugins/plugin-md-power/__test__/__snapshots__/langReplPlugin.spec.ts.snap
new file mode 100644
index 00000000..855f0f61
--- /dev/null
+++ b/plugins/plugin-md-power/__test__/__snapshots__/langReplPlugin.spec.ts.snap
@@ -0,0 +1,131 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`langReplPlugin > should work with custom options 1`] = `
+"
+
+
+
+
+
+
+
+"
+`;
+
+exports[`langReplPlugin > should work with custom options 2`] = `
+"export default {
+ "grammars": {
+ "kotlin": {
+ "language": "kotlin"
+ },
+ "go": {
+ "language": "go"
+ },
+ "rust": {
+ "language": "rust"
+ }
+ },
+ "theme": {
+ "light": {
+ "theme": "github-light"
+ },
+ "dark": {
+ "theme": "github-dark"
+ }
+ }
+}"
+`;
+
+exports[`langReplPlugin > should work with custom theme 1`] = `
+"
+
+
+
+
+
+
+
+"
+`;
+
+exports[`langReplPlugin > should work with custom theme 2`] = `
+"export default {
+ "grammars": {
+ "kotlin": {
+ "language": "kotlin"
+ },
+ "go": {
+ "language": "go"
+ },
+ "rust": {
+ "language": "rust"
+ }
+ }
+}"
+`;
+
+exports[`langReplPlugin > should work with default options 1`] = `
+"
::: go-repl
+
+:::
+::: go-repl#editable
+
+:::
+::: kotlin-repl
+
+:::
+::: kotlin-repl#editable
+
+:::
+::: rust-repl
+
+:::
+::: rust-repl#editable
+
+:::
+::: rust-repl title
+
+:::
+::: rust-repl#editable title
+
+:::
+"
+`;
+
+exports[`langReplPlugin > should work with default options 2`] = `
+"export default {
+ "grammars": {},
+ "theme": {
+ "light": {
+ "theme": "github-light"
+ },
+ "dark": {
+ "theme": "github-dark"
+ }
+ }
+}"
+`;
diff --git a/plugins/plugin-md-power/__test__/langReplPlugin.spec.ts b/plugins/plugin-md-power/__test__/langReplPlugin.spec.ts
new file mode 100644
index 00000000..b50caa31
--- /dev/null
+++ b/plugins/plugin-md-power/__test__/langReplPlugin.spec.ts
@@ -0,0 +1,139 @@
+import type { App } from 'vuepress/core'
+import type { ReplOptions } from '../src/shared/repl.js'
+import { dirname } from 'node:path'
+import { path } from '@vuepress/utils'
+import { resolveModule } from 'local-pkg'
+import MarkdownIt from 'markdown-it'
+import { fs, vol } from 'memfs'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { langReplPlugin } from '../src/node/container/langRepl.js'
+
+vi.mock('node:fs')
+vi.mock('node:fs/promises')
+
+beforeEach(() => vol.reset())
+
+const FENCE = '```'
+
+const themesPath = dirname(resolveModule('tm-themes', {
+ paths: [import.meta.url],
+}) || '')
+const grammarsPath = dirname(resolveModule('tm-grammars', {
+ paths: [import.meta.url],
+}) || '')
+
+async function createMarkdown(options: ReplOptions = {}) {
+ const md = new MarkdownIt()
+ md.options.highlight = (str, lang) => ``
+ const app = {
+ writeTemp: vi.fn((filepath: string, content: string) => {
+ filepath = path.join('/', filepath)
+ fs.mkdirSync(dirname(filepath), { recursive: true })
+ fs.writeFileSync(filepath, content)
+ }),
+ } as unknown as App
+
+ await langReplPlugin(app, md, options)
+
+ return { md, app }
+}
+
+function initFs() {
+ vol.fromJSON({
+ [path.join(themesPath, 'themes/github-light.json')]: '{ "theme": "github-light" }',
+ [path.join(themesPath, 'themes/github-dark.json')]: '{ "theme": "github-dark" }',
+ [path.join(grammarsPath, 'grammars/go.json')]: '{ "language": "go" }',
+ [path.join(grammarsPath, 'grammars/kotlin.json')]: '{ "language": "kotlin" }',
+ [path.join(grammarsPath, 'grammars/rust.json')]: '{ "language": "rust" }',
+ })
+}
+
+describe('langReplPlugin', () => {
+ const outputFile = '/internal/md-power/replEditorData.js'
+
+ const code = `\
+::: go-repl
+${FENCE}go
+const a = 1
+${FENCE}
+:::
+
+::: go-repl#editable
+${FENCE}go
+const a = 1
+${FENCE}
+:::
+
+::: kotlin-repl
+${FENCE}kotlin
+const a = 1
+${FENCE}
+:::
+
+::: kotlin-repl#editable
+${FENCE}kotlin
+const a = 1
+${FENCE}
+:::
+
+::: rust-repl
+${FENCE}rust
+const a = 1
+${FENCE}
+:::
+
+::: rust-repl#editable
+${FENCE}rust
+const a = 1
+${FENCE}
+:::
+
+::: rust-repl title
+${FENCE}rust
+const a = 1
+${FENCE}
+:::
+
+::: rust-repl#editable title
+${FENCE}rust
+const a = 1
+${FENCE}
+:::
+
+`
+ it('should work with default options', async () => {
+ initFs()
+ const { md, app } = await createMarkdown()
+
+ expect(md.render(code)).toMatchSnapshot()
+ expect(app.writeTemp).toBeCalledWith(outputFile.slice(1), expect.any(String))
+ expect(fs.readFileSync(outputFile, 'utf-8')).toMatchSnapshot()
+ })
+
+ it('should work with custom options', async () => {
+ initFs()
+ const { md, app } = await createMarkdown({
+ go: true,
+ kotlin: true,
+ rust: true,
+ })
+
+ expect(md.render(code)).toMatchSnapshot()
+ expect(app.writeTemp).toBeCalledWith(outputFile.slice(1), expect.any(String))
+ expect(fs.readFileSync(outputFile, 'utf-8')).toMatchSnapshot()
+ })
+
+ it('should work with custom theme', async () => {
+ initFs()
+ const { md, app } = await createMarkdown({
+ go: true,
+ kotlin: true,
+ rust: true,
+ theme: 'vitesse-light',
+ })
+
+ expect(md.render(code)).toMatchSnapshot()
+ expect(app.writeTemp).toBeCalledWith(outputFile.slice(1), expect.any(String))
+ expect(fs.readFileSync(outputFile, 'utf-8')).toMatchSnapshot()
+ })
+})
diff --git a/plugins/plugin-md-power/package.json b/plugins/plugin-md-power/package.json
index c2323299..dcd6db4d 100644
--- a/plugins/plugin-md-power/package.json
+++ b/plugins/plugin-md-power/package.json
@@ -56,6 +56,7 @@
"@vuepress/helper": "2.0.0-rc.52",
"@vueuse/core": "^11.1.0",
"image-size": "^1.1.1",
+ "local-pkg": "^0.5.0",
"markdown-it-container": "^4.0.0",
"nanoid": "^5.0.7",
"shiki": "^1.22.0",
diff --git a/plugins/plugin-md-power/src/node/container/fileTree.ts b/plugins/plugin-md-power/src/node/container/fileTree.ts
index 5cad6e6f..ab56dddc 100644
--- a/plugins/plugin-md-power/src/node/container/fileTree.ts
+++ b/plugins/plugin-md-power/src/node/container/fileTree.ts
@@ -112,8 +112,8 @@ export function resolveTreeNodeInfo(
if (!hasInline)
return undefined
- const children = inline.children?.filter(token => (token.type === 'text' && token.content) || token.tag === 'strong') || []
- const filename = children.filter(token => token.type === 'text').map(token => token.content).join(' ').split(/\s+/)[0] ?? ''
+ const children = inline.children!.filter(token => (token.type === 'text' && token.content) || token.tag === 'strong')
+ const filename = children.filter(token => token.type === 'text').map(token => token.content).join(' ').split(/\s+/)[0]
const focus = children[0]?.tag === 'strong'
const type = hasChildren || filename.endsWith('/') ? 'folder' : 'file'
const info: FileTreeNode = {
@@ -129,8 +129,6 @@ export function resolveTreeNodeInfo(
export function updateInlineToken(inline: Token, info: FileTreeNode, icon: string) {
const children = inline.children!
- if (!children || children.length === 0)
- return
const tokens: Token[] = []
const wrapperOpen = new Token('span_open', 'span', 1)
diff --git a/plugins/plugin-md-power/src/node/container/langRepl.ts b/plugins/plugin-md-power/src/node/container/langRepl.ts
index 77830128..42d98b62 100644
--- a/plugins/plugin-md-power/src/node/container/langRepl.ts
+++ b/plugins/plugin-md-power/src/node/container/langRepl.ts
@@ -2,8 +2,10 @@ import type markdownIt from 'markdown-it'
import type Token from 'markdown-it/lib/token.mjs'
import type { App } from 'vuepress/core'
import type { ReplEditorData, ReplOptions } from '../../shared/index.js'
+import { promises as fs } from 'node:fs'
+import { resolveModule } from 'local-pkg'
import container from 'markdown-it-container'
-import { fs, getDirname, path } from 'vuepress/utils'
+import { path } from 'vuepress/utils'
const RE_INFO = /^(#editable)?(.*)$/
@@ -15,7 +17,7 @@ function createReplContainer(md: markdownIt, lang: 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) ?? []
+ const [, editable, title] = info.match(RE_INFO)!
if (token.nesting === 1)
return ``
@@ -46,8 +48,8 @@ export async function langReplPlugin(app: App, md: markdownIt, {
const data: ReplEditorData = { grammars: {} } as ReplEditorData
- const themesPath = getDirname(import.meta.resolve('tm-themes'))
- const grammarsPath = getDirname(import.meta.resolve('tm-grammars'))
+ const themesPath = path.dirname(resolveModule('tm-themes')!)
+ const grammarsPath = path.dirname(resolveModule('tm-grammars')!)
const readTheme = (theme: string) => read(path.join(themesPath, 'themes', `${theme}.json`))
const readGrammar = (grammar: string) => read(path.join(grammarsPath, 'grammars', `${grammar}.json`))
diff --git a/plugins/plugin-md-power/src/node/container/npmTo.ts b/plugins/plugin-md-power/src/node/container/npmTo.ts
index 99850895..0ed23d3a 100644
--- a/plugins/plugin-md-power/src/node/container/npmTo.ts
+++ b/plugins/plugin-md-power/src/node/container/npmTo.ts
@@ -202,10 +202,7 @@ export function npmToPlugins(md: Markdown, options: NpmToOptions = {}): void {
if (tokens[idx].nesting === 1) {
const token = tokens[idx + 1]
const info = token.info.trim()
- if (
- token.type === 'fence'
- && (info.startsWith('sh') || info.startsWith('bash') || info.startsWith('shell'))
- ) {
+ if (token.type === 'fence') {
const content = token.content
token.hidden = true
token.type = 'text'
diff --git a/plugins/plugin-md-power/src/node/plugin.ts b/plugins/plugin-md-power/src/node/plugin.ts
index 45671e6a..dcf805a9 100644
--- a/plugins/plugin-md-power/src/node/plugin.ts
+++ b/plugins/plugin-md-power/src/node/plugin.ts
@@ -21,7 +21,7 @@ export function markdownPowerPlugin(
extendsBundlerOptions(bundlerOptions, app) {
if (options.repl) {
- addViteOptimizeDepsInclude(bundlerOptions, app, ['shiki/core', 'shiki/wasm'])
+ addViteOptimizeDepsInclude(bundlerOptions, app, ['shiki/core', 'shiki/wasm', 'shiki/engine/oniguruma'])
}
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2deae8a4..a22af91a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -65,6 +65,9 @@ importers:
markdown-it:
specifier: ^14.1.0
version: 14.1.0
+ memfs:
+ specifier: ^4.13.0
+ version: 4.13.0
rimraf:
specifier: ^6.0.1
version: 6.0.1
@@ -201,6 +204,9 @@ importers:
image-size:
specifier: ^1.1.1
version: 1.1.1
+ local-pkg:
+ specifier: ^0.5.0
+ version: 0.5.0
markdown-it:
specifier: ^14.0.0
version: 14.1.0
@@ -471,6 +477,9 @@ packages:
peerDependencies:
'@algolia/client-search': '>= 4.9.1 < 6'
algoliasearch: '>= 4.9.1 < 6'
+ peerDependenciesMeta:
+ '@algolia/client-search':
+ optional: true
'@algolia/cache-browser-local-storage@4.24.0':
resolution: {integrity: sha512-t63W9BnoXVrGy9iYHBgObNXqYXM3tYXCjDSHeNwnsc324r4o5UiVKUiAB4THQ5z9U5hTj6qUvwg/Ez43ZD85ww==}
@@ -1057,6 +1066,24 @@ packages:
resolution: {integrity: sha512-f5DRIOZf7wxogefH03RjMPMdBF7ADTWUMoOs9kaJo06EfwF+aFhMZMDZxHg/Xe12hptN9xoZjGso2fdjapBRIA==}
engines: {node: '>=10'}
+ '@jsonjoy.com/base64@1.1.2':
+ resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==}
+ engines: {node: '>=10.0'}
+ peerDependencies:
+ tslib: '2'
+
+ '@jsonjoy.com/json-pack@1.1.0':
+ resolution: {integrity: sha512-zlQONA+msXPPwHWZMKFVS78ewFczIll5lXiVPwFPCZUsrOKdxc2AvxU1HoNBmMRhqDZUR9HkC3UOm+6pME6Xsg==}
+ engines: {node: '>=10.0'}
+ peerDependencies:
+ tslib: '2'
+
+ '@jsonjoy.com/util@1.5.0':
+ resolution: {integrity: sha512-ojoNsrIuPI9g6o8UxhraZQSyF2ByJanAY4cTFbc8Mf2AXEF4aQRGY1dJxyJpuyav8r9FGflEt/Ff3u5Nt6YMPA==}
+ engines: {node: '>=10.0'}
+ peerDependencies:
+ tslib: '2'
+
'@kurkle/color@0.3.2':
resolution: {integrity: sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==}
@@ -3606,6 +3633,10 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ hyperdyperid@1.2.0:
+ resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==}
+ engines: {node: '>=10.18'}
+
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
@@ -4134,6 +4165,10 @@ packages:
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
+ memfs@4.13.0:
+ resolution: {integrity: sha512-dIs5KGy24fbdDhIAg0RxXpFqQp3RwL6wgSMRF9OSuphL/Uc9a4u2/SDJKPLj/zUgtOGKuHrRMrj563+IErj4Cg==}
+ engines: {node: '>= 4.0.0'}
+
meow@12.1.1:
resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==}
engines: {node: '>=16.10'}
@@ -5504,6 +5539,12 @@ packages:
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
+ thingies@1.21.0:
+ resolution: {integrity: sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==}
+ engines: {node: '>=10.18'}
+ peerDependencies:
+ tslib: ^2
+
through@2.3.8:
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
@@ -5554,6 +5595,12 @@ packages:
tr46@1.0.1:
resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==}
+ tree-dump@1.0.2:
+ resolution: {integrity: sha512-dpev9ABuLWdEubk+cIaI9cHwRNNDjkBBLXTwI4UCUFdQ5xXKqNXoK4FEciw/vxf+NQ7Cb7sGUyeUtORvHIdRXQ==}
+ engines: {node: '>=10.0'}
+ peerDependencies:
+ tslib: '2'
+
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
@@ -6070,8 +6117,9 @@ snapshots:
'@algolia/autocomplete-shared@1.9.3(@algolia/client-search@4.24.0)(algoliasearch@4.24.0)':
dependencies:
- '@algolia/client-search': 4.24.0
algoliasearch: 4.24.0
+ optionalDependencies:
+ '@algolia/client-search': 4.24.0
'@algolia/cache-browser-local-storage@4.24.0':
dependencies:
@@ -6712,6 +6760,22 @@ snapshots:
string-argv: 0.3.2
type-detect: 4.1.0
+ '@jsonjoy.com/base64@1.1.2(tslib@2.7.0)':
+ dependencies:
+ tslib: 2.7.0
+
+ '@jsonjoy.com/json-pack@1.1.0(tslib@2.7.0)':
+ dependencies:
+ '@jsonjoy.com/base64': 1.1.2(tslib@2.7.0)
+ '@jsonjoy.com/util': 1.5.0(tslib@2.7.0)
+ hyperdyperid: 1.2.0
+ thingies: 1.21.0(tslib@2.7.0)
+ tslib: 2.7.0
+
+ '@jsonjoy.com/util@1.5.0(tslib@2.7.0)':
+ dependencies:
+ tslib: 2.7.0
+
'@kurkle/color@0.3.2': {}
'@lit-labs/ssr-dom-shim@1.2.1': {}
@@ -9739,6 +9803,8 @@ snapshots:
husky@9.1.6: {}
+ hyperdyperid@1.2.0: {}
+
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
@@ -10304,6 +10370,13 @@ snapshots:
mdurl@2.0.0: {}
+ memfs@4.13.0:
+ dependencies:
+ '@jsonjoy.com/json-pack': 1.1.0(tslib@2.7.0)
+ '@jsonjoy.com/util': 1.5.0(tslib@2.7.0)
+ tree-dump: 1.0.2(tslib@2.7.0)
+ tslib: 2.7.0
+
meow@12.1.1: {}
meow@13.2.0: {}
@@ -11701,6 +11774,10 @@ snapshots:
dependencies:
any-promise: 1.3.0
+ thingies@1.21.0(tslib@2.7.0):
+ dependencies:
+ tslib: 2.7.0
+
through@2.3.8: {}
tinybench@2.9.0: {}
@@ -11740,6 +11817,10 @@ snapshots:
dependencies:
punycode: 2.3.1
+ tree-dump@1.0.2(tslib@2.7.0):
+ dependencies:
+ tslib: 2.7.0
+
tree-kill@1.2.2: {}
trim-lines@3.0.1: {}