使用 Vibe Coding 编写一个属于自己的 VSCode 插件

在写这个博客里的内容以及类似的文章的时候,我一般使用VSCode和Typora两个软件进行编辑。

Typora的优点是可以实时预览文章内容,可以正确显示博客里引用的图片。Hexo博客会将图片存在与markdown文件同名的文件夹中[1],而Typora支持通过在YAML Front Matter里面指定typora-root-url[2]作为图片路径的前缀。

而VSCode的优点在于输入公式比较方便,可以在编辑数学公式的时候自动切换中英文[3],通过配置[4]也可以支持正确添加图片,但是图片文件的路径是相对于markdown文件所在文件夹的路径,这个Hexo的设置是不一样的。一旦修改成Hexo需要的路径之后,VSCode就无法找到正确的图片了。

这点在写新的文章的时候还不太要紧,因为可以在写完文章后统一修改图片路径。但是在修改旧的文章的时候,由于无法正确预览图片,就不太方便了。

要让VSCode实现这个功能,就必须通过插件来完成。但是由于我不懂VSCode插件的开发,甚至不熟悉Typescript的语法,这个想法一直被我放下了。最近看到了oh-my-opencode项目,于是想着能否利用vibe coding来写一个插件,满足我上面的要求。

1. 第一次尝试

去年在MiniMax M2.1刚发布的时候,限时免费了一段时间,加上赠送的代金券,我尝试用它配合Cline来日常使用,效果还可以。因此我这次打算用opencode免费提供的minimax-m2.1-free模型,配oh-my-opencode的ulw模式使用。

就在我写这个插件的第2天,Minimax发布了M2.5模型,之后第3天opencode就把免费模型替换成了新的M2.5模型。所以实际上是两个模型混着用的。

起初我把需求丢给opencode,结果确实写出了一个像模像样的插件,包括了扩展入口、一个markdown-it插件和一个yaml front matter解析器。尝试对它进行调试,它还在调试日志中告诉你「插件已成功加载」,但就是没有任何实际的作用。

尝试让它自己去debug,但是多次尝试始终未能成功。

无奈,只能手动去查看文档。

发现主要的问题出现在两个地方:

  1. 注册markdown-it插件的方法完全是错误的。
  2. 在markdown-it插件里面,获取文档内容的方法完全是错误的,致使实际上无法获取yaml front matter的内容。

这就导致了插件彻底无法工作。而且,在debug的过程中,它完全没有想到要求修改这两个地方。

也就是说,整个插件都是废的。

猜测主要原因是这两部分的语法没有问题,「表面」上的语义也没有问题,但实际上它用到的东西根本不存在。

2. 手动搭建框架

无奈,还是只能从头开始手写。

首先,使用yo code搭建项目架构。根据官方文档,VSCode使用markdown-it来解析markdown文件,因此我们只需要实现一个markdown-it插件即可。

我们先实现一个markdown-it插件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import type MarkdownIt from 'markdown-it';
import path from 'node:path';

interface ImageOptions {
imageDir?: string;
}

export function prefixifyImageURL(md: MarkdownIt, pluginOptions?:ImageOptions) {
const imageDir = pluginOptions?.imageDir || ".";

const original = md.renderer.rules.image!;
md.renderer.rules.image = (tokens, idx, options, env, self) => {
const token = tokens[idx];
const src = token?.attrGet('src');
if (src) {
token.attrSet('src', path.join(imageDir, src));
}

return original(tokens, idx, options, env, self);
};
}

核心代码是重写了图片渲染逻辑md.renderer.rules.image,在正常渲染图片之前修改了图片的src属性。

然后使用extendMarkdownIt调用插件:

1
2
3
4
5
6
7
export function activate(context: vscode.ExtensionContext) {
return {
extendMarkdownIt(md: MarkdownIt) {
return md.use(prefixifyImageURL, {imageDir: "assets"});
}
};
}

这里为了检查代码逻辑,图方便先写死了imageDir。

另外,还需要在package.json中将这个扩展注册为markdown-it插件:

1
2
3
"contributes": {
"markdown.markdownItPlugins": true
}

调试插件,终于能够正常修改图片的路径了。

3. 动态加载图片路径

接下来,要解决的问题就是如何针对每个markdown文件获取typora-root-url信息,并使用它作为图片所在目录。

我的第一个想法是把imageDir存到env中,这样只需要读取一次,然后之后直接从env中调用即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import fs from 'fs';
import matter from 'gray-matter';

function getImageDir(filePath: string): string | null {
const fileContent = fs.readFileSync(filePath, 'utf-8');
const { data } = matter(fileContent);
return data['typora-root-url'] ?? null;
}

export function prefixifyImageURL(md: MarkdownIt) {
const original = md.renderer.rules.image!;
md.renderer.rules.image = (tokens, idx, options, env, self) => {
let imageDir = env.imageDir;

if (imageDir === undefined) {
const filePath = env.currentDocument.path;
imageDir = getImageDir(filePath);
env.imageDir = imageDir;
}

if (imageDir !== null) {
const token = tokens[idx];
const src = token?.attrGet('src');
if (src) {
token.attrSet('src', path.join(imageDir, src));
}
}

return original(tokens, idx, options, env, self);
};
}

上面的代码确实能够满足要求,但是有一个问题,就是对修改typora-root-url的响应非常不及时,有很大的滞后性。

于是,我又尝试了第二种方法,就是每次从YAML Front Matter里动态获取imageDir

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import frontMatter from 'markdown-it-front-matter';
import yaml from 'js-yaml';

let dynamicImageDir: any;

interface ImageOptions {
imageDir?: Function;
}

export function prefixifyImageURL(md: MarkdownIt, pluginOptions?: ImageOptions) {
const original = md.renderer.rules.image!;
md.renderer.rules.image = (tokens, idx, options, env, self) => {
const token = tokens[idx];
const src = token?.attrGet('src');
const imageDir = pluginOptions?.imageDir?.();
if (imageDir && src) {
token.attrSet('src', path.join(imageDir, src));
}

return original(tokens, idx, options, env, self);
};
}

export function activate(context: vscode.ExtensionContext) {
return {
extendMarkdownIt(md: MarkdownIt) {
return md.use(frontMatter, (fm: string) => {
const fmData = yaml.load(fm) as Record<string, any>;
const imageDir = fmData?.['typora-root-url'];
dynamicImageDir = imageDir;
}).use(prefixifyImageURL, {
imageDir: () => dynamicImageDir,
});
}
};
}

这样只要已修改typora-root-url的配置,立马就会在渲染中响应。

4. 支持图片链接的跳转和悬停预览

上面解决的是在右侧预览窗口里面的图片路径问题。另外还有一个需要适配的是在编辑界面的预览。

VSCode在鼠标指向图像链接的时候,会弹出该图像的预览。按住Ctrl单击,会跳转到对应的图片文件。我需要复现这两个功能。

这个部分让我自己写是完全不可能的,因此只能再次尝试vibe coding了。

好在,这次的结果还基本让人满意。

把需求喂给opencode之后,按住Ctrl单击的功能第一次就实现了,但是图像预览没有成功。不过让它自己进行debug,最终还是成功基本完成了这个任务。

不过,还是有一些小问题需要手动处理。

4.1. 问题一

其中一个是我提出的一个新的功能要求。上面实现的图像预览虽然成功了,但是由于有一些图像本身特别大,无法在预览框里完整显示(VSCode自带的图像预览是可以缩放到合适的大小的)。

尝试多次让opencode进行修改,都没有成功。无奈只能手动修改。

这里面最关键的点,在于VSCode到底是如何渲染markdown的图片的。

查看VSCode的源代码,从markdownRenderer.ts可以看到,它调用了parseHrefAndDimensions函数来识别图像的高度和宽度,该函数的定义位于htmlContent.ts,具体定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export function parseHrefAndDimensions(href: string): { href: string; dimensions: string[] } {
const dimensions: string[] = [];
const splitted = href.split('|').map(s => s.trim());
href = splitted[0];
const parameters = splitted[1];
if (parameters) {
const heightFromParams = /height=(\d+)/.exec(parameters);
const widthFromParams = /width=(\d+)/.exec(parameters);
const height = heightFromParams ? heightFromParams[1] : '';
const width = widthFromParams ? widthFromParams[1] : '';
const widthIsFinite = isFinite(parseInt(width));
const heightIsFinite = isFinite(parseInt(height));
if (widthIsFinite) {
dimensions.push(`width="${width}"`);
}
if (heightIsFinite) {
dimensions.push(`height="${height}"`);
}
}
return { href, dimensions };
}

从这里可以看出,VSCode接受![](a.png|height=240)这样指定图片尺寸的方式。由此,我们可以根据图片的长宽比例指定预览的宽度或高度。

4.2. 问题二

另一个问题是显示的链接下划线部分,长度和VSCode显示的不一致。尝试让opencode进行修改,但是最终还是差了1个长度,而且固执地认为算的没有问题。

奇怪的是,它在思考链中的分析其实是正确的,但是最终计算的offset值始终不对。

好在,这个错误很容易修改。

5. 总结

最终的代码我放在了vscode-markdown-hexo仓库。虽然还有一些bug,但是基本上个人使用够了。

回过头来看这次vibe coding的经历,整体的评价还是正面的,因为利用它我完成了一个之前我个人不可能完成的任务。

但是问题还是比较多的,主要是涉及到一些不是很常见的功能的时候,AI无法找到对应的代码,于是就开始瞎编了,编完了还像模像样地告诉你没有问题。

回想一下,当时应该直接把官方文档对应的页面直接扔给AI,让它根据具体的文档来写代码,应该会好一些。

另外一个藏在VSCode源代码里面的功能,这个AI不知道情有可原,因为我没有在任何公开的地方找到这个用法的说明。

下次可以尝试让AI分析VSCode的源代码,看看它能否找到这个功能的实现方法。

总体来说,和我这几年使用AI的感觉是一致的:就是AI强于逻辑,但是弱在事实核查。

说到事实核查,我不得不祭出当年GPT-3.5刚出来的时候的几张截图:

呃。。。


  1. 需要在_config.yml中启用post_asset_folder: true↩︎

  2. 参考Typora的官方文档↩︎

  3. 利用插件Shift IM for Math ↩︎

  4. 参考之前的文章:使用VSCode编辑Markdown的几个常用设置 ↩︎