UNPKG

@vitepress-code-preview/plugin

Version:

preview component of code and component in vitepress

299 lines (294 loc) 13 kB
'use strict'; var fs = require('fs'); var path = require('path'); var container = require('markdown-it-container'); var os = require('os'); var unified = require('unified'); var remarkParse = require('remark-parse'); var remarkFrontmatter = require('remark-frontmatter'); var remarkStringify = require('remark-stringify'); var hash = require('hash-sum'); // 因为一个markdown文件就相当于一个SFC组件,所以只能存在一个setup,这个正则就是用来尝试找出是否已有setup const ScriptSetupRegex = /^<script\s(.*\s)?setup(\s.*)?>([\s\S]*)<\/script>$/; /** * 将markdown文件和哈希值组合成虚拟模块名 * @param id 模块id * @param key 代码块哈希值 * @param lang 代码块所属语言 */ const combineVirtualModule = (id, key, lang) => `virtual:${path.basename(id)}.${key}.${lang}`; /** * 把markdown中的demo代码转换为组件 * @param code markdown的原始内容 * @param id 模块id * @param root docs文档根目录 */ async function markdownToComponent(code, id, root) { // 用来收集markdown中的demo代码块 const _blocks = []; // 解析markdown文件 const parsed = await unified.unified() .use(remarkParse) // 实例化parser, 用于生成 mdast .use(remarkFrontmatter) // 处理markdown的元信息 .use(() => (tree) => { let seed = 0; const scriptSetup = { index: -1, content: '' }; tree.children?.forEach((node, index) => { try { // 判断是否已经存在 script setup 标签, 注释的忽略不处理 if (node.type === 'html') { const m = node.value.trim().match(ScriptSetupRegex); if (!m) return false; scriptSetup.index = index; scriptSetup.content = m[3] ?? ''; } if (!node.children || !node.children[0] || !node.children[0].value) return false; // 判断demo容器是否为内联代码块的模式 const hasDemo = node.children[0].value.trim() === ':::demo'; const nextNodeIsCode = hasDemo && tree.children && tree.children[index + 1] && tree.children[index + 1].type === 'code'; // 下一个节点如果是内联代码块的话 if (nextNodeIsCode) { const hashKey = hash(`${id}-demo-${seed}`); _blocks.push({ lang: tree.children[index + 1].lang, code: tree.children[index + 1].value, key: hashKey, // 每个代码块的唯一key }); node.children[0].value += ` Virtual-${hashKey}`; seed++; } // 判断demo容器是否为引入文件的模式 const hasSrc = node.children[0].value.trim().match(/^:::demo\s*(src=.*)\s*:::$/); if (hasSrc) { const markdownId = path.relative(root, id); const sourceFile = hasSrc && hasSrc.length > 1 ? hasSrc[1]?.split('=')[1].trim() : ''; // 记录当前markdown使用了哪些组件 handleCacheFile(markdownId, path.join(sourceFile)); const lang = path.extname(sourceFile).slice(1); const source = fs.readFileSync(path.resolve(root, sourceFile), 'utf-8'); const hashKey = hash(`${id}-demo-${seed}`); _blocks.push({ lang, code: source, key: hashKey, }); node.children[0].value = `:::demo src=${sourceFile} Virtual-${hashKey}${os.EOL}:::`; seed++; } } catch (error) { console.error(`parse markdown error in function markdownToComponent 👇\n ${error}`); return false; } }); if (_blocks.length === 0) return; const virtualModules = _blocks .map((b) => { const moduleName = combineVirtualModule(id, b.key, b.lang); return `import Virtual${b.key} from '${moduleName}'`; }) .join(os.EOL); // 如果之前已经有一个 setup 的话,那就把虚拟模块塞进去 if (scriptSetup.index !== -1) { const node = tree.children[scriptSetup.index]; node.value = node.value.replace(ScriptSetupRegex, (m, ...args) => { return `<script ${args[0] ?? ''} setup ${args[1] ?? ''}>${os.EOL}${virtualModules}${os.EOL}${args[2] ?? ''}</script>`; }); } else { // 如果没有setup的话,就新增一个用来将虚拟模块追加到markdown tree.children?.push({ type: 'html', value: `<script setup>${os.EOL}${virtualModules}${os.EOL}</script>`, }); } }) .use(remarkStringify) // 实例化compiler, 用于将经过人为处理后的 mdast 输出为 markdown .process(code); // 执行解析 const blocks = _blocks.map((b) => { const moduleName = combineVirtualModule(id, b.key, b.lang); cacheCode.set(b.key, b.code); return { ...b, id: moduleName }; }); return { parsedCode: String(parsed), blocks }; } /** * 将markdown文件和所引用的组件关系缓存起来 * @param mdId markdown 文件 * @param file 组件 */ function handleCacheFile(mdId, file) { const prev = cacheFile.get(mdId) ?? []; const files = Array.from(new Set([...prev.filter(Boolean), file])); cacheFile.set(mdId, files); } const cacheCode = new Map(); const cacheFile = new Map(); /** * vite插件, 用来转换markdown中的demo代码 */ function viteDemoPreviewPlugin() { // 用来收集已挂载的vite插件,因为在HMR那里需要手动更新 let vitePlugin; const options = { mode: 'vitepress', root: '', }; // 用来匹配虚拟模块 const virtualModRegex = /^virtual:.*\.md\.([a-zA-Z0-9]+)\.(vue|jsx|tsx)$/; return { name: 'vite-plugin-code-preview', enforce: 'pre', async configResolved(config) { const isVitepress = config.plugins.find((p) => p.name === 'vitepress'); vitePlugin = config.plugins.find((p) => p.name === 'vite:vue'); options.mode = isVitepress ? 'vitepress' : 'vite'; options.root = path.resolve(config.root); // 提前抹平系统差异 }, // 解析虚拟模块ID,如果请求的模块ID与预期的虚拟模块ID匹配,则返回该ID,否则返回undefined resolveId(id) { if (virtualModRegex.test(id)) return id; }, // 加载虚拟模块的内容,如果请求的模块ID与预期的虚拟模块ID匹配,则生成模块内容并返回,否则返回undefined load(id) { const m = id.trim().match(virtualModRegex); if (m) { const key = m.length > 1 ? m[1] : ''; // 返回虚拟模块的源码 return cacheCode.get(key); } }, // 把markdown中的demo代码块转换成组件 async transform(code, id) { if (!id.endsWith('.md')) return; const { parsedCode } = await markdownToComponent(code, path.resolve(id), options.root); return { code: parsedCode, map: null }; }, // 自定义HMR更新 async handleHotUpdate(ctx) { const { file, server, read } = ctx; const manualUpdateRegex = /\.(md|vue|jsx|tsx)$/; if (!manualUpdateRegex.test(file)) return; // 正向更新,通过markdown文件更新内部代码块 if (file.endsWith('.md')) { const content = await read(); const { parsedCode, blocks } = await markdownToComponent(content, path.resolve(file), options.root); for (const b of blocks) { const virtualModule = server.moduleGraph.getModuleById(b.id); if (virtualModule) { await server.reloadModule(virtualModule); } } return vitePlugin.handleHotUpdate({ ...ctx, read: () => parsedCode, }); } else { // 反向更新,通过被引用的组件来更新markdown const fileName = path.relative(options.root, file); for (const [key, value] of cacheFile.entries()) { if (value.includes(fileName)) { const markdownPath = path.resolve(options.root, key); // 组合完整的markdown文件路径 const content = fs.readFileSync(markdownPath, 'utf-8'); const { parsedCode, blocks } = await markdownToComponent(content, markdownPath, options.root); for (const b of blocks) { const virtualModule = server.moduleGraph.getModuleById(b.id); if (virtualModule) { await server.reloadModule(virtualModule); } } return vitePlugin.handleHotUpdate({ ...ctx, read: () => parsedCode, }); } } } }, }; } /** * markdown插件,用来解析demo代码 * @param md * @param options */ function demoPreviewPlugin(md, options = { docRoot: '' }) { options.componentName = options.componentName || 'DemoPreview'; md.use(createDemoContainer, options); md.use(renderDemoCode); } /** * 自定义容器,也就是用:::demo ::: 包裹起来的部分 * @param md * @param options */ function createDemoContainer(md, options) { const { componentName = 'DemoPreview', docRoot } = options; md.use(container, 'demo', { validate(params) { return !!params.trim().match(/^demo\s*(.*)$/); }, render(tokens, idx) { const token = tokens[idx]; // 开始标签的 nesting 为 1,结束标签的 nesting 为 -1 if (token.nesting === 1 && token.type === 'container_demo_open') { const m = tokens[idx].info.trim().match(/^demo\s*(src=.*\s)?(Virtual-([a-zA-Z0-9]+))?$/); const virtualId = m && m.length > 2 ? m[2] : ''; const sourceFile = m && m.length > 1 ? m[1]?.split('=')[1].trim() : ''; let source = ''; let lang = ''; if (sourceFile) { lang = path.extname(sourceFile).slice(1); source = fs.readFileSync(path.resolve(docRoot, sourceFile), 'utf-8'); if (!source) throw new Error(`Incorrect source file: ${sourceFile}`); } else { lang = tokens[idx + 1].info; source = tokens[idx + 1].type === 'fence' ? tokens[idx + 1].content : ''; } // 这个componentName表示之后注册组件时所使用的组件名, virtualId 是生成的虚拟模块,会传递给容器组件的默认插槽 return `<${componentName} :isFile="${!!sourceFile}" hlSource="${encodeURIComponent(md.options.highlight?.(source, lang, '') ?? '')}" lang="${lang}" source="${encodeURIComponent(source)}"> <${virtualId?.replace('-', '')} />`; } // 结束标签 return `</${componentName}>`; }, }); } /** * 解析渲染自定义容器内部的代码块 * @param md */ function renderDemoCode(md) { // 这个 fence 就类似 ```vue ... ``` 代码块中的那个vue标识 const defaultRender = md.renderer.rules.fence; md.renderer.rules.fence = (...args) => { const [tokens, idx] = args; const token = tokens[idx]; // 判断该 fence 是否在 ::: demo 内 const prevToken = tokens[idx - 1]; const isInDemoContainer = prevToken && prevToken.nesting === 1 && prevToken.info.trim().match(/^demo\s*(.*)$/); const lang = token.info.trim(); // 如果在demo内的话就进行自定义渲染 if (isInDemoContainer) { return ` <template #highlight> <div v-pre class="example-source language-${lang}" > <span class="lang">${lang}</span> ${md.options.highlight?.(token.content, lang, '')} </div> </template>`; } return defaultRender?.(...args); }; } exports.demoPreviewPlugin = demoPreviewPlugin; exports.viteDemoPreviewPlugin = viteDemoPreviewPlugin;