Skip to content

使用脚本改进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,没有专门针对图片资源做额外的处理,因此在写文章的时候如果碰到需要插图,需要经历以下几步:

  1. 打开图片目录
  2. 新建与文章对应的子目录
  3. 放入图片
  4. 重命名图片
  5. 在文章中插入图片标记,类似这样![image 01](/assets/hello1/01.jpg)

其中第1步和第5步都涉及到路径的问题,而且都需要手工进入或者手工输入,当路径比较深或者文章名称比较长的时候,既不方便也容易出错。

目标

在忍受了这种不方便很久之后,本着“重复的事情一定可以用代码解决”的想法,我决定找到一种更自动化的方式来做。首先,定义一下目标:

  1. 图片存入与文章相关的路径

    因为我的图片路径是与文章相关的,因此固定将图片统一放到某个目录的方式不纳入考虑,这就排除了很多现成的解决方案。

    希望这个方案可以自动建立与文章相关联的目录并打开,以便放入图片。

  2. 自动完成重命名

  3. 自动将图片地址填入文章中预留好的位置

实现

首先,新建一个脚本放入博客目录中,以便调用:utils/assets.js

VSCode调用

当需要插图的时候,对应的文章刚好是VSCode中当前编辑文件,因此希望能将当前文件路径直接传递给脚本。这里使用了一个插件:Command Runner

这个插件可以通过VSCode配置或者package.json来定义一些自定义的命令。于是我在package.json中加了这么一段:

json
{
    "commands": {
        "assets": "./utils/assets.js ${file}"
    }
}
{
    "commands": {
        "assets": "./utils/assets.js ${file}"
    }
}

这样就可以通过VSCode直接调用脚本并且传入当前文章的信息。

command runner 1

command runner 2

接下来就是脚本的实现了。

新建图片目录

原理比较简单,首先计算一下文章对应的图片目录,然后使用fs.mkdirSync新建对应的目录。最后为了方便使用,调用一下系统的open命令,把刚新建好的目录打开。

javascript
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]);

注意:这个代码实现比较粗糙:

  1. 在计算图片目录时粗暴地使用了replace来替换,更稳妥的办法是使用相对路径来计算
  2. 目录路径未考虑windows系统,这里应该使用path.sep更好
  3. 未考虑新建目录失败的情况
  4. 未考虑没有open命令的系统,应该使用封装好的库更好

自动重命名

要想让代码为我们放进去的文件自动重命名,首先需要让代码知道“有一个文件被放进去了”,这里就需要使用到fs.watch。这个方法可以监听一个目录,当目录发生变化时触发回调,这样我们就可以在回调中完成重命名。

注意:fs.watch并不是非常好用,在macOS下放入一个文件,会触发rename事件两次,不管是事件名称还是触发次数都不太正常。更好的方法是使用chokidar这样的库来监听变化。

我这里选择使用数字编号来命名,如果目录中已经有文件存在的话,就需要先知道最新的编号是多少。

javascript
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位数字,所以默认这个问题不存在,忽略了。

接下来是监视新的文件并重命名:

javascript
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个点:

  1. setTimeout的作用,主要是因为事件会连续触发两次,希望在事件都触发完之后再处理(可能并不是必要的)
  2. try...catch的使用,也是因为事件会连续触发两次,第2次一定会失败,因此加一个try...catch,并不是为了考虑严谨

填入Markdown指定位置

在上面的代码中有一个注释的fillMd()的调用,它的作用就是将图片地址填入Markdown指定位置。

具体而言,在编写文章时,我会先留一个“洞”,也就是图片标记中的地址是空的:

![图片描述]()
![图片描述]()

在调用fillMd()时会将图片地址填入上述标记中的“洞”:

javascript
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标记,也就是只替换找到的第一处“洞”,说人话就是一次只填一个,这正是预期的工作方式。

效果

有了脚本之后改进的流程:

  1. 写文章&留好“洞”(可以写一个就往下处理一个图片,也可以全部写好,最后一起处理)
  2. 运行“assets”脚本
  3. 在自动打开的文件夹中放入图片
  4. 没有然后了

演示视频https://twitter.com/TooooooBug/status/1562273683246555142(需要翻墙)。

总结和展望

我始终认为,写代码是为了解决问题,至于写得是否完备是否漂亮都是其次的。这个例子其实也是一次“为自己写代码来解决问题”的过程,整个代码只有60行,尽管它不是很严谨,也不是很完美,但仍然算是很满意的一次实践。

如果这个工具继续做下去的话,可能会有几个方向:

  1. 做成npm包,提升易用性
  2. 适配更多的文章/图片目录规则,以适应更多的情况
  3. 替换一些工具/写法,提升代码严谨性
  4. 加入更多的功能,比如
    1. 反过来从md文本中获取图片信息,然后移动/下载保存等
    2. 加入图床自动上传和替换功能
    3. ...

不过我只是想把它当成解决自己问题的小工具,因此可能不会再继续花时间深入了,有兴趣的朋友可以参照一下做出更适合自己的工具。

附:对Twitter上一些观念的回复

  1. 可以用XXX软件:是可以的,但之所以选择Markdown来写东西就是看中了纯文本的通用性,不希望被绑在某个工具上。事实上我也从来不用任何Markdown工具的高级功能,我觉得对纯文本每个字节的掌控是非常必要的。这个脚本也是通用的,你可以手工运行它。
  2. 可以用笔记软件:我认为笔记和文章/博客写作是两种不同的用途。尽管有人喜欢all in one,把文章/资料采集/知识管理/分享/todo/OKR等都放入笔记软件,但我不喜欢,我认为笔记就是用来记录的,我自己写的笔记软件也没有任何采集/分享之类的功能,仅仅是输入、记录和搜索而已。
  3. 可以用图床类工具:个人不喜欢将图片和文章分开保存,这些松散的结构间很难建立起联系,在整理的时候会非常困难。此外图床类也会涉及到付费/CDN/防盗链等额外工作。

当然,尽管现阶段不适用于我的文章/博客的场景,但推友们的回复仍然有不少值得学习的地方,也有不少好工具,值得收藏。