Pandoc 生成静态博客的自动化脚本

项目背景

这是上一篇博客使用 Pandoc 生成静态博客的后续, 经过一天的研究和测试,将自动化脚本完成了。 这里记录下成果。

npm run build

{
  "scripts": {
    "build": "node scripts/build.js"
  }
}

在项目中新建了一个 scripts 文件夹,专门存放脚本。 在 package.json 中, 给 build.js 绑定命令,这样可以使用约定俗成的 npm run build 生成静态 html。

更加深入 Pandoc

为了配合自动化,需要继续研究下 Pandoc。

首先,对博客文章的 md 文件,使用以下 frontmatter。

---
title: Pandoc 生成静态博客的自动化脚本
date: 2023-05-11
---

这样通过 gray-matter 这个库,可以方便地提取 title 和 date,在脚本中使用。

其次,在首页中,我想修改下 ul 列表的样式,使其有一个稍微不一样的外观。

:::::{#index}

## 2023

- May 13 [修改 liveServer 的根目录](/blog/2023/live-server-settings)

:::::

在 index.md 中使用上面这样将内容包裹在一组多个英文冒号中的写法, 可以在转换的 html 中定义一个 id 为 index 的 div。

<div id="index">
  <section id="section" class="level2">
    <h2>2023</h2>
    <ul>
      <li>---</li>
    </ul>
  </section>
</div>

然后在 css 中增加下首页样式,去掉列表前的圆点,并给超链接一个左边距。

#index {
  @apply space-y-10;
}
#index ul {
  @apply list-none;
}
#index li a {
  @apply ml-4;
}

最后,为了简化命令操作, 在 Pandoc 中可以通过-d 参数指定一个本地的 yaml 文件, 保存默认配置, 这样就不需要每个命令中重复书写很长一串参数,命令可以简化为:

pandoc -d yaml文件 输入文件 -o 输出文件

本地配置文件如下:

section-divs: true
css: main.css
toc: true
variables:
  toc-title: Table of Contents
standalone: true
from: markdown+east_asian_line_breaks
include-after-body: src/sources/footer.html

shelljs

使用shelljs这个库,可以很方便在脚本中执行 shell 命令,使用起来特别简单。

shell.exec('git commit -am "Auto-commit"');

自动化脚本

先上完整代码,如果你想尝试下类似的玩法,可以直接拿去用:

const shell = require('shelljs');
const path = require('path');
const fs = require('fs');
const root = process.cwd();
const yaml = path.join(root, 'src/sources/config.yaml');
const matter = require('gray-matter');

const dirs = {
  index: [path.join(root, 'src/index.md'), path.join(root, 'dist/index.html')],
  about: [path.join(root, 'src/about.md'), path.join(root, 'dist/about.html')]
};

const commands = {
  index: `pandoc -d "${yaml}" "${dirs.index[0]}" -o "${dirs.index[1]}"`,
  about: `pandoc -d "${yaml}" "${dirs.about[0]}" -o "${dirs.about[1]}"`
};

// 生成全部博客
const blogDir = path.join(root, 'src', 'blog');
const blogFiles = [];

function traverseDirectory(dir) {
  fs.readdirSync(dir).forEach((file) => {
    const fullPath = path.join(dir, file);
    if (fs.statSync(fullPath).isDirectory()) {
      traverseDirectory(fullPath);
    } else {
      blogFiles.push(fullPath);
    }
  });
}

traverseDirectory(blogDir);

let hasNewBlog = false;
const rows = {};

for (const input of blogFiles) {
  const fileData = path.parse(input);

  if (fileData.ext !== '.md') continue;

  const { data } = matter.read(input);

  if (!data.date) {
    console.log('md文档中缺少date元信息', input);
    continue;
  }

  if (!data.title) {
    console.log('md文档中缺少title元信息', input);
    continue;
  }

  const blogFile = `${data.date.toISOString().slice(0, 10)}-${fileData.name}.html`;

  const output = path.join(root, 'dist', blogFile);

  const stat = fs.statSync(input);

  let blogMtimeMs = 0;
  if (fs.existsSync(output)) blogMtimeMs = fs.statSync(output).mtimeMs;

  if (stat.mtimeMs > blogMtimeMs) {
    shell.exec(`pandoc -d "${yaml}" "${input}" -o "${output}"`);
    console.log('已生成:', output);
    hasNewBlog = true;
  }

  //生成首页列表
  const month = data.date.toLocaleString('en-GB', { month: 'short' });
  const day = data.date.getDate();
  const year = data.date.getFullYear();
  const tag = `- ${month + ' ' + day} [${data.title}](/${blogFile})`;
  if (!rows[year]) rows[year] = [];
  rows[year].push([data.date, tag]);
}

const keys = Object.keys(rows);
keys.sort((a, b) => b - a);

const strs = [];

for (const key of keys) {
  strs.push(`## ${key}\n\n`);
  rows[key].sort((a, b) => b[0] - a[0]);
  for (const row of rows[key]) {
    strs.push(`${row[1]}\n`);
  }
  strs.push(`\n`);
}

const indexTemple = `% Dylan Zhang Blog

:::::{#index}\n\n${strs.join('')}:::::\n`;

if (hasNewBlog) {
  fs.writeFileSync(dirs.index[0], indexTemple);
  shell.exec(commands.index);
  console.log('已生成:', dirs.index[1]);
}

文章的生成流程如下:

  1. 所有的博客 md 文件保存在 src/blog 这个目录下,按照年份归档。
  2. 遍历所有 md 文件,检查其最后修改时间,并读取元数据。
  3. 检查对应的 html 文件是否已经生成,不存在直接生成。
  4. 如果已经生成过 html,检查 md 文件的最后修改时间,如果大于 html 文件,则重新生成。

首页的生成流程如下:

  1. 使用 hasNewBlog 记录是否有新文章。
  2. 如果有新文章生成,则重新生成 index.md 并转换 index.html

更进一步

这个脚本,还有几个小细节需要考虑:

  • about 等其他常规页面的生成。
  • 页面引用的 footer.html 如果有变化,所有 html 需要重新生成。

因为当前这些没有改动,暂时先不管了,等有需要时再来完善。

What I Learned

  • Pandoc 转换时可以从 yaml frontmatter 中读取标题和日期等变量, 使用 gray-matter 也可以方便在 js 中调用元数据。
  • Pandoc 中可将内容包裹在自定义的块中,指定 id 或 class 名称,方便 css 美化。
  • Pandoc 中 -d 参数使用 yaml 默认配置文件。
  • 使用 fs 遍历文件和查看文件信息。
  • 日期字符串转换的一些操作。
  • 使用 shelljs 在 node 中运行 shell 命令。