翻阅十几个源码仓库,只为了加一个$
为了在vitepress项目上的代码块某行上添加类似终端输入提示的$
或#
符,我翻阅了vitepress等各个文档及源码,最后新建了一个vitepress-markdown-it-repl插件,才得以实现。。。。。。
缘起
vitepress中可用markdown
语法来写文章的现代静态站点生成(SSG)框架。还支持各种主题及自定义主题,更让我觉得眼前一亮的地方是代码块支持代码随语言高亮及聚焦。有天,我想在代码块上某行加上一个$
前缀,以示该行是终端命令行。
但是翻遍文档及插件,没有找到能满足需求的解决方案。本着工匠(轴)精神,我就从源码开始深入,直到开发了一个插件才得以实现。
缘续
直接在行的前面添加$ 命令
,这种方式显然不好,因为右上角复制代码按钮,会把这个$
符号给复制出来(复制出来就不能愉快地Ctrl C
,Ctrl V
了,手动狗头)。
一开始想到的方式是在需要的行上新增一个比如叫.repl-in-line
的class,通过before
伪类加上$
,大概的CSS
就是:
.repl-in-line:before {
content: "$";
position: absolute;
display: block;
left: 10px;
color: #7f7f7f;
}
然后通过自定义主题-自定义CSS,加载这部分CSS
即可。
但是,要能够做到在代码块上加上CSS class
,只要通过写markdown-it的插件来实现。因为vitepress里用markdown-it来实现markdown语法解析,一些markdown扩展的功能就需要用markdown-it的插件实现。比如,vitepress内置的lineNumberPlugin
就是其中的一个插件,用来显示代码块左边的行号;预设了markdown-it-attrs插件用来给markdown内容转换后的html元素添加属性(如添加class,style属性);代码块的语法高亮用了shiki做转换。
这么看下来,就可以用markdown-it-attrs的方式给代码块对应行添加.repl-in-line
class就行了。
```bash {class=repl-in-line}
echo a b
```
事情没那么简单,结果渲染后的html中的<code>
元素中没有如期的class="repl-in-line"
属性。怀疑是不是因为和行语法高亮冲突了(都用{}
来配置)。修改markdown-it-attrs的配置前后缀:
export default defineConfig({
markdown: {
attrs: {
leftDelimiter: '[',
rightDelimiter: ']'
},
}
})
结果,还是没效果,看vitepress issues,查到有人提出来了。比如markdown-it-highlight lines conflict和support adding attributes to code blocks这两条。
维护人员虽然最后添加了enhancement
标签,但一时半会儿肯定是用不了的。
原因
那为啥markdown-it-attrs给代码块添加不了属性呢?
我们在vitepress仓库项目目录下,pnpm install
安装依赖。查看package.json
中的scripts
,看到文档启动命令是docs
,启动pnpm run docs
,实际上执行的命令总结下来就是先将src/client
目录和src/node
目录打包到dist
对应目录上,然后用生成的vitepress
(在dist/node/cli.js
中)命令执行vitepress dev docs
。
我们到src/node/cli.ts
中,找到命令为dev
时的代码:
if (!command || command === 'dev') {
// ...
const createDevServer = async () => {
const server = await createServer(root, argv, async () => {
// ...
})
// ...
}
createDevServer().catch((err) => {
// ...
})
}
接着createServer
来自src/node/serve.ts
:
export async function createServer(
root: string = process.cwd(),
serverOptions: ServerOptions = {},
recreateServer?: () => Promise<void>
) {
// ...
return createViteServer({
root: config.srcDir,
base: config.site.base,
cacheDir: config.cacheDir,
plugins: await createVitePressPlugin(config, false, {}, {}, recreateServer),
server: serverOptions,
customLogger: config.logger
})
}
createVitePressPlugin
又来自src/node/plugin.ts
:
export async function createVitePressPlugin(
// ...
) {
// ...
let markdownToVue: Awaited<ReturnType<typeof createMarkdownToVueRenderFn>>
// ...
const vitePressPlugin: Plugin = {
name: 'vitepress',
async configResolved(resolvedConfig) {
config = resolvedConfig
markdownToVue = await createMarkdownToVueRenderFn(
// ...
)
},
// ...
}
return [
vitePressPlugin,
// ...
]
}
总算到挂钩markdown了。createMarkdownToVueRenderFn
来自于src/node/markdownToVue.ts
:
export async function createMarkdownToVueRenderFn(
// ...
) {
const md = await createMarkdownRenderer(
srcDir,
options,
base,
siteConfig?.logger
)
// ...
const html = md.render(src, env)
// ...
}
createMarkdownRenderer
来自于src/node/markdown/markdown.ts
:
export const createMarkdownRenderer = async (
// ...
): Promise<MarkdownRenderer> => {
// ...
const md = MarkdownIt({
html: true,
linkify: true,
highlight:
options.highlight ||
(await highlight(
theme,
options.languages,
options.defaultHighlightLang,
logger
)),
...options
}) as MarkdownRenderer
}
到这里,有两条路,要么深入到MarkdownIt
里面,要么深入到highlight
。接着先深入到MarkdownIt
里面。
注意看上面提到的createMarkdownToVueRenderFn
函数里面,const html = md.render(src, env)
,可见,调用了markdown-it生成对象的render函数。我们再打开markdown-it项目。在markdown-it项目中,入口比较好找,lib/index.js
:
function MarkdownIt(presetName, options) {
// ...
this.renderer = new Renderer();
// ...
}
MarkdownIt.prototype.render = function (src, env) {
// ...
return this.renderer.render(this.parse(src, env), this.options, env);
};
Renderer
来自于lib/renderer.js
:
var default_rules = {};
// ...
default_rules.fence = function (tokens, idx, options, env, slf) {
// ...
if (highlighted.indexOf('<pre') === 0) {
return highlighted + '\n';
}
// ...
};
// ...
function Renderer() {
// ...
this.rules = assign({}, default_rules);
}
Renderer.prototype.render = function (tokens, options, env) {
var i, len, type,
result = '',
rules = this.rules;
for (i = 0, len = tokens.length; i < len; i++) {
type = tokens[i].type;
if (type === 'inline') {
result += this.renderInline(tokens[i].children, options, env);
} else if (typeof rules[type] !== 'undefined') {
result += rules[type](tokens, i, options, env, this);
} else {
result += this.renderToken(tokens, i, options, env);
}
}
return result;
};
在这里,我们发现,如果highlighted.indexOf('<pre') === 0
成立,就return highlighted + '\n';
直接返回了。也就是说,如果有highlighted
,就不走markdown-it后面的解析了,走的是highlighted
的解析。我们回到vitepress项目中的highlight
。
highlight
来自于src/node/markdown/plugins/highlight.ts
:
export async function highlight(
// ...
): Promise<(str: string, lang: string, attrs: string) => string> {
// ...
const highlighter = await getHighlighter({
themes: hasSingleTheme ? [theme] : [theme.dark, theme.light],
langs: [...BUNDLED_LANGUAGES, ...languages],
processors
})
// ...
return (str: string, lang: string, attrs: string) => {
// ...
const codeToHtml = (theme: IThemeRegistration) => {
const res =
lang === 'ansi'
? highlighter.ansiToHtml(str, {
lineOptions,
theme: getThemeName(theme)
})
: highlighter.codeToHtml(str, {
lang,
lineOptions,
theme: getThemeName(theme)
})
return fillEmptyHighlightedLine(cleanup(restoreMustache(res)))
}
// ...
}
}
getHighlighter
来自于shiki-processor
包,又切到shiki-processor项目。在文件src/highlighter.ts
找到getHighlighter
:
import { getHighlighter as getShikiHighlighter, Highlighter, HighlighterOptions as ShikiHighlighterOptions } from 'shiki'
// ...
export async function getHighlighter(options: HighlighterOptions = {}): Promise<Highlighter> {
const highlighter = await getShikiHighlighter(options)
// ...
return {
...highlighter,
codeToHtml: (str, htmlOptions) => {
// ...
const highlighted = highlighter.codeToHtml(code, {
// ...
})
return postProcess(processors, highlighted, lang)
},
}
}
getHighlighter
实际来源于shiki
包,再切到shiki项目。在文件packages/shiki/src/highlighter.ts
找到getHighlighter
:
export async function getHighlighter(options: HighlighterOptions): Promise<Highlighter> {
// ...
function codeToHtml(
// ...
) {
// ...
return renderToHtml(tokens, {
// ...
})
}
// ...
return {
// ...
codeToHtml,
// ...
}
}
renderToHtml
来自文件packages/shiki/src/highlighter.ts
:
export function renderToHtml(lines: IThemedToken[][], options: HtmlRendererOptions = {}) {
// ...
return h(
'pre',
{ className: 'shiki ' + (options.themeName || ''), style: `background-color: ${bg}` },
[
options.langId ? `<div class="language-id">${options.langId}</div>` : '',
h(
'code',
{},
lines.map((line, index) => {
// ...
const lineClasses = getLineClasses(lineOptions).join(' ')
return h(
'line',
{
className: lineClasses,
// ...
},
line.map((token, index) => {
// ...
})
)
})
)
]
)
}
在这里终于看到code
元素为啥没有属性了,就算markdown-it-attrs传进来了属性,这里也没设进去!!!
原理
如果要走markdown-it-attrs这条路线,那必须从shiki改起,再到shiki-processor,再到markdown-it,最后再到vitepress。这样下来,你才能在你的vitepress
项目使用!
有没有其他更便捷快速的路呢?
经过翻阅markdown-it的近十个插件仓库发现,vitepress内置的markdown-it插件lineNumberPlugin
(文件src/node/markdown/lineNumbers.ts
)是通过绝对定位在代码块的左边来实现的。虽然$
是想显示在代码块里面,但实现方式可以取巧!
实现原理就是:通过配置方式$(2-4,6)
,明白要在第2,3,4,6这四行开头添加$
;再生成对应行内容的html
,跟lineNumberPlugin
一样通过绝对定位在代码块的开头。
缘灭
于是,开了这么个仓库vitepress-markdown-it-repl,也打包到npm仓库中了。可在你的vitepress项目中方便快捷食用。
具体实现可看源码,不到100行即可实现。
现在,终于可以在我的vitepress项目中实现如下图的显示了!
转载自:https://juejin.cn/post/7237726397220683831