使用脚本改进VSCode Markdown博客编写体验
2022年8月25日
背景
本博客用的是Hexo,最近迁移了很多以前写在别处的文章过来,在迁移过程中发现最不方便的一件事情是图片的处理。
大概的目录结构:
- source
- _posts 文章目录
- hello1.md 文章
- hello2.md 文章
- assets 图片目录
- hello1 与文章对应的子目录
- 01.jpg
- 02.jpg
- hello2 与文章对应的子目录
- 01.jpg
- 02.jpg
- source
- _posts 文章目录
- hello1.md 文章
- hello2.md 文章
- assets 图片目录
- hello1 与文章对应的子目录
- 01.jpg
- 02.jpg
- hello2 与文章对应的子目录
- 01.jpg
- 02.jpg
因为使用的是通用的工具(如VSCode)来编写Markdown,没有专门针对图片资源做额外的处理,因此在写文章的时候如果碰到需要插图,需要经历以下几步:
- 打开图片目录
- 新建与文章对应的子目录
- 放入图片
- 重命名图片
- 在文章中插入图片标记,类似这样
![image 01](/assets/hello1/01.jpg)
其中第1步和第5步都涉及到路径的问题,而且都需要手工进入或者手工输入,当路径比较深或者文章名称比较长的时候,既不方便也容易出错。
目标
在忍受了这种不方便很久之后,本着“重复的事情一定可以用代码解决”的想法,我决定找到一种更自动化的方式来做。首先,定义一下目标:
图片存入与文章相关的路径
因为我的图片路径是与文章相关的,因此固定将图片统一放到某个目录的方式不纳入考虑,这就排除了很多现成的解决方案。
希望这个方案可以自动建立与文章相关联的目录并打开,以便放入图片。
自动完成重命名
自动将图片地址填入文章中预留好的位置
实现
首先,新建一个脚本放入博客目录中,以便调用:utils/assets.js
。
VSCode调用
当需要插图的时候,对应的文章刚好是VSCode中当前编辑文件,因此希望能将当前文件路径直接传递给脚本。这里使用了一个插件:Command Runner。
这个插件可以通过VSCode配置或者package.json
来定义一些自定义的命令。于是我在package.json
中加了这么一段:
{
"commands": {
"assets": "./utils/assets.js ${file}"
}
}
{
"commands": {
"assets": "./utils/assets.js ${file}"
}
}
这样就可以通过VSCode直接调用脚本并且传入当前文章的信息。
接下来就是脚本的实现了。
新建图片目录
原理比较简单,首先计算一下文章对应的图片目录,然后使用fs.mkdirSync
新建对应的目录。最后为了方便使用,调用一下系统的open
命令,把刚新建好的目录打开。
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const mdPath = process.argv[2];
if(!mdPath) {
console.log('md file path required.');
process.exit(1);
}
const assetsPath = mdPath.replace(/\/_posts\//, '/assets/').replace(/.md$/, '');
fs.mkdirSync(assetsPath, { recursive: true });
console.log('opening ' + assetsPath);
spawn('open', [assetsPath]);
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const mdPath = process.argv[2];
if(!mdPath) {
console.log('md file path required.');
process.exit(1);
}
const assetsPath = mdPath.replace(/\/_posts\//, '/assets/').replace(/.md$/, '');
fs.mkdirSync(assetsPath, { recursive: true });
console.log('opening ' + assetsPath);
spawn('open', [assetsPath]);
注意:这个代码实现比较粗糙:
- 在计算图片目录时粗暴地使用了
replace
来替换,更稳妥的办法是使用相对路径来计算- 目录路径未考虑windows系统,这里应该使用
path.sep
更好- 未考虑新建目录失败的情况
- 未考虑没有
open
命令的系统,应该使用封装好的库更好
自动重命名
要想让代码为我们放进去的文件自动重命名,首先需要让代码知道“有一个文件被放进去了”,这里就需要使用到fs.watch
。这个方法可以监听一个目录,当目录发生变化时触发回调,这样我们就可以在回调中完成重命名。
注意:
fs.watch
并不是非常好用,在macOS下放入一个文件,会触发rename
事件两次,不管是事件名称还是触发次数都不太正常。更好的方法是使用chokidar
这样的库来监听变化。
我这里选择使用数字编号来命名,如果目录中已经有文件存在的话,就需要先知道最新的编号是多少。
let index = 0;
const files = fs.readdirSync(assetsPath);
files.forEach((filename) => {
if (/^\./.test(filename)) return;
if (!/^[\d]+\..*$/.test(filename)) return;
index = Number(filename.replace(/[^\d]/g, ''));
});
console.log('lastIndex: ' + index);
let index = 0;
const files = fs.readdirSync(assetsPath);
files.forEach((filename) => {
if (/^\./.test(filename)) return;
if (!/^[\d]+\..*$/.test(filename)) return;
index = Number(filename.replace(/[^\d]/g, ''));
});
console.log('lastIndex: ' + index);
这段代码首先将序号index
设为0
,然后对以纯数字命名的文件进行扫描并解析其数字作为最新的序号。
注:这个逻辑也不严谨,如果数字位数不同的话,顺序不是按序号排列的,可能得到错误的结果。但因为我的命令规则全部是2位数字,所以默认这个问题不存在,忽略了。
接下来是监视新的文件并重命名:
const getNewName = (filename, isPrev = false) => {
if (!filename) return filename;
const targetIndex = isPrev ? index : index + 1;
return filename.replace(/.*(\..*)$/, (targetIndex + '').padStart(2, '0') + '$1');
}
fs.watch(assetsPath, {
persistent: true,
}, (e, filename) => {
if (/^\./.test(filename)) return;
if (filename === getNewName(filename, true)) return;
setTimeout(() => {
try{
const newName = getNewName(filename);
const oldPath = path.join(assetsPath, filename);
const newPath = path.join(assetsPath, newName);
fs.renameSync(oldPath, newPath);
console.log(filename + ' renamed to ' + newName);
index++;
// fillMd(newPath);
} catch (e) {
// nothing
}
}, 500);
});
const getNewName = (filename, isPrev = false) => {
if (!filename) return filename;
const targetIndex = isPrev ? index : index + 1;
return filename.replace(/.*(\..*)$/, (targetIndex + '').padStart(2, '0') + '$1');
}
fs.watch(assetsPath, {
persistent: true,
}, (e, filename) => {
if (/^\./.test(filename)) return;
if (filename === getNewName(filename, true)) return;
setTimeout(() => {
try{
const newName = getNewName(filename);
const oldPath = path.join(assetsPath, filename);
const newPath = path.join(assetsPath, newName);
fs.renameSync(oldPath, newPath);
console.log(filename + ' renamed to ' + newName);
index++;
// fillMd(newPath);
} catch (e) {
// nothing
}
}, 500);
});
这段代码首先定义了一个getNewName()
方法,用于获取即将被重命名的文件的新文件名。这个方法接受一个isPrev
参数,它的作用是判断要获取“前一个(已生成)”的文件名,还是“下一个(即将生成)”的文件名。
因为fs.watch
对新文件和重命名的文件都触发rename
事件,因此无法区分是被放入了一个新文件,还是之前放入的文件被代码重命名了。这里要做一个判断,将文件名与“前一个(已生成)”的文件名对比,如果相同,说明是刚刚重命名过的文件。
接下来的逻辑比较好理解,就是重命名的过程了,如果成功,就将序号+1
。
值得注意的2个点:
setTimeout
的作用,主要是因为事件会连续触发两次,希望在事件都触发完之后再处理(可能并不是必要的)try...catch
的使用,也是因为事件会连续触发两次,第2次一定会失败,因此加一个try...catch
,并不是为了考虑严谨
填入Markdown指定位置
在上面的代码中有一个注释的fillMd()
的调用,它的作用就是将图片地址填入Markdown指定位置。
具体而言,在编写文章时,我会先留一个“洞”,也就是图片标记中的地址是空的:
![图片描述]()
![图片描述]()
在调用fillMd()
时会将图片地址填入上述标记中的“洞”:
const fillMd = (imagePath) => {
const mdContent = fs.readFileSync(mdPath, 'utf-8');
const imageRelativePath = imagePath.replace(/^.*\/source\/assets\//, '/assets/');
const newContent = mdContent.replace(/\!\[(.*?)\]\(\)/, '![$1](' + imageRelativePath + ')');
fs.writeFileSync(mdPath, newContent);
};
const fillMd = (imagePath) => {
const mdContent = fs.readFileSync(mdPath, 'utf-8');
const imageRelativePath = imagePath.replace(/^.*\/source\/assets\//, '/assets/');
const newContent = mdContent.replace(/\!\[(.*?)\]\(\)/, '![$1](' + imageRelativePath + ')');
fs.writeFileSync(mdPath, newContent);
};
原理也很简单,计算图片的地址并读取Markdown文件,然后通过正则表达式替换的方式将地址填进去,再将新内容写入原文件即可。注意这里的正则表达式没有加/g
标记,也就是只替换找到的第一处“洞”,说人话就是一次只填一个,这正是预期的工作方式。
效果
有了脚本之后改进的流程:
- 写文章&留好“洞”(可以写一个就往下处理一个图片,也可以全部写好,最后一起处理)
- 运行“assets”脚本
- 在自动打开的文件夹中放入图片
- 没有然后了
演示视频https://twitter.com/TooooooBug/status/1562273683246555142(需要翻墙)。
总结和展望
我始终认为,写代码是为了解决问题,至于写得是否完备是否漂亮都是其次的。这个例子其实也是一次“为自己写代码来解决问题”的过程,整个代码只有60行,尽管它不是很严谨,也不是很完美,但仍然算是很满意的一次实践。
如果这个工具继续做下去的话,可能会有几个方向:
- 做成npm包,提升易用性
- 适配更多的文章/图片目录规则,以适应更多的情况
- 替换一些工具/写法,提升代码严谨性
- 加入更多的功能,比如
- 反过来从md文本中获取图片信息,然后移动/下载保存等
- 加入图床自动上传和替换功能
- ...
不过我只是想把它当成解决自己问题的小工具,因此可能不会再继续花时间深入了,有兴趣的朋友可以参照一下做出更适合自己的工具。
附:对Twitter上一些观念的回复
- 可以用XXX软件:是可以的,但之所以选择Markdown来写东西就是看中了纯文本的通用性,不希望被绑在某个工具上。事实上我也从来不用任何Markdown工具的高级功能,我觉得对纯文本每个字节的掌控是非常必要的。这个脚本也是通用的,你可以手工运行它。
- 可以用笔记软件:我认为笔记和文章/博客写作是两种不同的用途。尽管有人喜欢all in one,把文章/资料采集/知识管理/分享/todo/OKR等都放入笔记软件,但我不喜欢,我认为笔记就是用来记录的,我自己写的笔记软件也没有任何采集/分享之类的功能,仅仅是输入、记录和搜索而已。
- 可以用图床类工具:个人不喜欢将图片和文章分开保存,这些松散的结构间很难建立起联系,在整理的时候会非常困难。此外图床类也会涉及到付费/CDN/防盗链等额外工作。
当然,尽管现阶段不适用于我的文章/博客的场景,但推友们的回复仍然有不少值得学习的地方,也有不少好工具,值得收藏。