@vitepress-code-preview/plugin
Version:
preview component of code and component in vitepress
296 lines (292 loc) • 12.9 kB
JavaScript
import fs from 'fs';
import path from 'path';
import container from 'markdown-it-container';
import os from 'os';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkFrontmatter from 'remark-frontmatter';
import remarkStringify from 'remark-stringify';
import hash from '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()
.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);
};
}
export { demoPreviewPlugin, viteDemoPreviewPlugin };