本站从 Wordpress 迁移到 Hugo

文章目录

时隔多年,本站终于进行了一次较大的更新,从 Wordpress 迁移到了 Hugo。这篇文章记录了迁移的原因和过程。

缘由

想从 Wordpress 迁移到 Hugo 已经有五六年了,但一直没有进行。想迁移的原因非常简单:

  • Wordpress 越来越臃肿。尤其是最近几年,我几乎都不想登录到 Wordpress 后台,太多的插件年久失修,也不敢乱动,甚至 Wordpress 自身升级后连新建页面都打不开了;
  • Wordpress 对 Markdown 的支持是非原生的;虽然使用插件后看似效果不错,但从数据源的角度来说,本质上它还是存储为 HTML 格式,这导致文章的结构化和批量处理效果一般。
  • 网站的访问中有相当比例来源于繁体中文使用者,因此想更好的支持繁体中文的阅读和使用。

关键步骤

从 Wordpress 备份提取 Markdown

这是看似很简单的步骤,因为有好几个插件都支持相关的功能,也有一些命令行的脚本。但是,最要命的是转换并不完美。

我使用的是基于 NodeJS 的 blog2md 脚本。 这个脚本的使用很简单,只需要在 Wordpress 后台导出备份的 .xml 文件,然后执行脚本就行了。 你的计算机需要有 Node 环境。 要注意的是,一定按照其 README 中的说明,添加 paragraph-fix 参数,否则很多换行和段落会出现问题。例如:

node index.js w your-wordpress-backup-export.xml out m paragraph-fix

但是即使你采用了上面的参数,转换出的文章很多依然是有问题的。主要表现在:

  • 部分段落丢失,即缺少换行;
  • 很多之前的代码高亮的内容出现了多余的转义,例如 * 变成了 \*] 变成了 \]。 我的理解是当初为了在 Wordpress 中存储代码高亮,相关插件本身做了转义,但在导出数据时就很痛苦了。
  • (更要命的)以 < 开头的内容丢失。可能因为和导出格式为 XML 有关,出现了语义上的冲突。这部分可能只能自己修了。

例如一个 C++ 程序:

template<typename T>
class ...

可能会变成

template typename T\>
class ...

所以,我需要写脚本来解决这个问题。

使用脚本处理多余的转义

下面是一个 NodeJS 脚本,用来批量将指定目录下的 md 文件的正文部分包裹的代码块中的多余转义进行修复。 例如,它将 \[ 修复成 [\_ 修复成 _

const fs = require('fs');
const path = require('path');

// 指定目录路径
const DIRECTORY_PATH = 'path/to/content';

// 递归地查找.md文件
function findMarkdownFiles(dirPath) {
  let filesToProcess = [];

  const files = fs.readdirSync(dirPath);

  for (const file of files) {
    const filePath = path.join(dirPath, file);
    const stat = fs.statSync(filePath);

    if (stat.isDirectory()) {
      filesToProcess = filesToProcess.concat(findMarkdownFiles(filePath));
    } else if (path.extname(filePath) === '.md') {
      filesToProcess.push(filePath);
    }
  }

  return filesToProcess;
}

// 处理.md文件中的代码块
function processMarkdownFile(filePath) {
  const content = fs.readFileSync(filePath, 'utf8');

  // 使用正则表达式匹配所有代码块
  const newContent = content.replace(/```[\s\S]*?```/g, (match) => {
    // 替换代码块中的反转义字符
    return match.replace(/\\\[/g, '[')
                .replace(/\\\]/g, ']')
                .replace(/\\\*/g, '*')
                .replace(/\\_/g, '_')
                .replace(/\\\\/g, '\\')
  });

  // 覆盖原文件
  fs.writeFileSync(filePath, newContent, 'utf8');
}

// 主函数
function main() {
  const markdownFiles = findMarkdownFiles(DIRECTORY_PATH);

  for (const filePath of markdownFiles) {
    processMarkdownFile(filePath);
  }

  console.log('所有.md文件中的代码块已处理完毕。');
}

// 运行主函数
main();

警告

  • 请修改 DIRECTORY_PATH 的值,并注意转义。
  • 这个脚本会覆盖原文件,请提前备份。而且,由于其做了批量替换,可能会影响代码正常运行。需要手工检查。

移除不必要的 TOC(文章目录)

很多 Hugo 主题使用 toc 这个 Front Matter 来标记是否展开文章目录。对于老文章, 我写了一个脚本,可以根据这个文章是否有实际的二级标题来判断是否启用 toc。 也就是说,如果一个文章的 Markdown 里没有两个以上的二级标题,它会把:

+++
title = '本站从 Wordpress 迁移到 Hugo'
date = 2024-04-01T16:38:34+08:00
draft = false
+++

修改为

+++
title = '本站从 Wordpress 迁移到 Hugo'
date = 2024-04-01T16:38:34+08:00
draft = false
toc = false
+++

这样渲染出的文件就不会有目录了,并且不需要前端使用 Javascript 动态实现。

自动翻译简体中文到繁体中文(或相反)

因为这次升级的一个目的是更好地支持简繁两种汉字的使用者,因此我使用了一个脚本, 它可以将所有 Markdown 自动进行简繁转换。

Hugo 支持两种多语言模式,我使用的是文件模式,也就是简体、繁体的文章放在一起, 繁体文章增加 .zh-hant 后缀名。

简繁转换不能简单的字字对应,因为存在很多「一对多」的问题和各地习惯用语的问题。 Opencc 是一个不错的开源工具,可以帮我们实现以词汇为单位的简繁转换,提高转换的准确率。 根据统计,本站大部分繁体用户来自台湾,因此我在简体转换繁体时,使用了台湾的惯用词。

下面的脚本使用 openccNodeJS 版本进行简繁转换。

这个脚本会把转换后的结果放在 DIRECTORY_OUTPATH 目录下,文件名增加 .zh-hant 后缀。 它只转换文章的标题和正文,不会转换文章的元数据,例如 tagurl,因此比较安全。

使用前,你需要根据自己的情况对脚本进行适当的修改,并且需要安装 opencc

npm i opencc

你需要提前修改脚本中的 DIRECTORY_PATHDIRECTORY_OUTPATH。 如果你使用 posts/xxx/index.mdposts/xxx/index.zh-hant.md 这种形式, 你可以将两个目录设置相同。

脚本内容如下:

const fs = require("fs");
const path = require("path");
const OpenCC = require("opencc");
const converter = new OpenCC("s2twp.json");
const matter = require('gray-matter');

// 指定目录路径
const DIRECTORY_PATH = "path/to/blog/content";
const DIRECTORY_OUTPATH = "path/to/blog/content-hant";

// 递归地查找.md文件
function findMarkdownFiles(dirPath) {
  let filesToProcess = [];

  const files = fs.readdirSync(dirPath);

  for (const file of files) {
    const filePath = path.join(dirPath, file);
    const stat = fs.statSync(filePath);

    if (stat.isDirectory()) {
      filesToProcess = filesToProcess.concat(findMarkdownFiles(filePath));
    } else if (path.extname(filePath) === ".md") {
      filesToProcess.push(filePath);
    }
  }

  return filesToProcess;
}

// 处理.md文件中的代码块
async function processMarkdownFile(filePath) {
  if (filePath.indexOf("zh-tw") >= 0) {
    return;
  }

// 解析Markdown文件的front matter
  const content = matter(fs.readFileSync(filePath, "utf8"));

  // 翻译正文
  content.content = await converter.convertPromise(content.content)
  // 翻译标题
  if (content.data.title && content.data.title.length > 0) {
    content.data.title = await converter.convertPromise(content.data.title)
  }

    // 构造输出文件路径
    const outFilePath = path.join(DIRECTORY_OUTPATH, path.basename(filePath, ".md") + ".zh-hant.md");

    console.log(outFilePath)
    console.log(content.content)

    // 如果文件存在则不覆盖
    if (fs.existsSync(outFilePath)) {
        return;
    }
    const newMarkdownContent = matter.stringify(content);
    fs.writeFileSync(outFilePath, newMarkdownContent)
}

// 主函数
function main() {
  const markdownFiles = findMarkdownFiles(DIRECTORY_PATH);

  for (const filePath of markdownFiles) {
    processMarkdownFile(filePath);
  }

  console.log("所有.md文件已处理完毕。");
}

// 运行主函数
main();

警告

  • 不要使用 zh-twzh-hkzh-cn 等作为语言标识。因为他们代表的是地区。作为一个小站点,你估计不太可能会为香港、台湾、新加坡各做一个不同的站点。
  • 使用 zh-hant 代表传统汉字,zh-hans 代表简体中文是明显更加通用的选择。这也方便浏览器、搜索引擎判断你的目标用户。比如,你使用了 zh-hant,香港访问者不会被排除在外。
  • 你可以访问 opencc 的 gihhub 页面,查找不同的配置文件。不同的配置文件会使用不同的惯用词,请根据你实际的需求进行修改。我使用的是 s2tw.json 文件,代表使用台湾惯用词进行简体到繁体的转换。

你也可以通过修改 s2twp.json 配置文件的名字实现繁体->简体的转换。

手工检查所有文章

虽然做了自动化处理,但很多文章依然有问题,需要手工检查一遍。

选择合适的评论系统

我选择的是 Waline,但你可以选择任何你喜欢的评论系统。唯一的建议是, 评论数据需要掌握在自己手里,因此必须选择可以备份评论数据的评论系统。 否则,一旦你的托管方式发生变化,你可能会追悔莫及。

Waline 支持多种数据源,我选择的是 sqlite,因为我有 自己的服务器,并且我希望一个节约内存的轻量化解决方案。

从 Wordpress 评论迁移到 Waline

目前网络上似乎还没有从 Wordpress 评论迁移到 Waline 的现成方案。 本文提供了一种方案,可以将 Wordpress 所有评论无损地迁移到 Waline。

首先从 Wordpress 后台,导出所有内容的 xml 文件。

然后执行我写的脚本(你需要有 NodeJS),对 xml 文件进行分析,生成可以导入 到 Waline 的导入格式(注意修改 WORDPRESS_XML_FILE 变量):

const fs = require('fs');
const xml2js = require('xml2js');
const parser = new xml2js.Parser();

// WordPress备份文件的路径
const WORDPRESS_XML_FILE = 'WordPress.2024-03-28.xml';

// 读取XML文件
fs.readFile(WORDPRESS_XML_FILE, (err, data) => {
  if (err) {
    console.error('Error reading XML file:', err);
    return;
  }

  // 解析XML数据
  parser.parseString(data, (err, result) => {
    if (err) {
      console.error('Error parsing XML data:', err);
      return;
    }

    // 初始化评论数组
    let commentsArray = [];

    // 遍历每个item(文章或页面)
    result.rss.channel[0].item.forEach((post) => {
      // 判断是否有评论
      if (post['wp:comment']) {
        // 遍历每条评论
        post['wp:comment'].forEach((comment) => {
          if (comment['wp:comment_approved'] + '' == "1" && comment['wp:comment_type'] != 'pingback') {
            let commentObj = {
                user_id: null,
                comment: comment['wp:comment_content'] ? comment['wp:comment_content'][0] : '',
                insertedAt: comment['wp:comment_date'] ? comment['wp:comment_date'][0] : '',
                ip: comment['wp:comment_author_IP'] ? comment['wp:comment_author_IP'][0] : '',
                link: comment['wp:comment_author_url'] ? comment['wp:comment_author_url'][0] : '',
                mail: comment['wp:comment_author_email'] ? comment['wp:comment_author_email'][0] : '',
                nick: comment['wp:comment_author'] ? comment['wp:comment_author'][0] : '',
                rid: comment['wp:comment_parent'] && comment['wp:comment_parent'][0] !== '0' ? parseInt(comment['wp:comment_parent'][0]) : null,
                pid: null,
                sticky: null,
                status: 'approved',
                like: null,
                ua: comment['wp:comment_agent'] ? comment['wp:comment_agent'][0] : '',
                url: (post['link'] ? post['link'][0] : '').replace("https://ceeji.net", ""),
                createdAt: comment['wp:comment_date'] ? comment['wp:comment_date'][0] : '',
                updatedAt: comment['wp:comment_date'] ? comment['wp:comment_date'][0] : '',
                objectId: parseInt(comment['wp:comment_id'][0])
            };
            commentsArray.push(commentObj);
            } else {
                console.log("跳过没批准的评论")
            }
        });
      }
    });

    // 将评论数组转换为JSON并保存到文件
    const jsonContent = JSON.stringify(commentsArray, null, 2);
    fs.writeFile('comments.json', jsonContent, 'utf8', (err) => {
      if (err) {
        console.error('Error writing JSON file:', err);
      } else {
        console.log('Comments have been exported to comments.json');
      }
    });
  });
});

最后,在 Waline 的管理后台,选择导入导出功能:

先选择导出,打开导出后的 JSON 文件,格式类似如下:

{
	"__version": "1.31.13",
	"type": "waline",
	"version": 1,
	"time": 1711963109933,
	"tables": [
		"Comment",
		"Counter",
		"Users"
	],
	"data": {
		"Comment": [] // 替换此处的数据
    }
}

把 Comment 处的内容替换为脚本生成的内容,即可进行导入。注意,导入会丢失所有现有数据, 如原来有数据的,需要手工合并或提前备份。

实现代码高亮的黑暗模式自适应

Hugo 自带的代码高亮功能,一般只能设置一个样式。如果你的主题支持自适应黑暗模式,可能会导致 在切换黑暗模式开关的时候,代码高亮不能跟着切换。

最新版本的 Hugo 使用 chroma 进行语法高亮。 首先,确定你想要使用的样式。比如我们在亮色模式下使用 monokailight 样式,在暗色模式下使用 onedark 样式。

接下来,创建对应的 .css 文件供 Hugo 使用:

hugo gen chromastyles --style=monokailight > syntax_light.css
hugo gen chromastyles --style=onedark > syntax_dark.css

将这两个文件移动到你的主题 assets 文件夹中(例如 ./themes/paper/assets/)。

接下来,确保我们的 Hugo 主题加载这些样式。 为此,编辑 ./themes/paper/layout/partials/head.html(或者负责在你的主题中添加头部部分的文件),添加以下内容:

{{ $syntax_dark_css := resources.Get "syntax_dark.css" | minify }}
{{ $syntax_light_css := resources.Get "syntax_light.css" | minify }}

<link rel="preload stylesheet" as="style" href="{{ $syntax_dark_css.Permalink }}" />
<link rel="preload stylesheet" as="style" href="{{ $syntax_light_css.Permalink }}" />

这将在使用 Hugo 构建站点时使 .css 文件可用,并允许页面加载它们。

顺序很重要:默认情况下,应用最后引用的文件中的样式,因为它会覆盖之前的样式! 为了让 Hugo 使用我们的 .css 文件作为 chroma 样式,我们需要在 Hugo 的 hugo.toml 文件中明确指定以下内容:

[markup.highlight]
    noClasses = false

确保一切正常后,检查你的代码是否已使用设置的样式进行高亮显示。当你切换到暗色模式时,目前应该还不会有任何变化。

然后你需要写动态切换应用于代码片段的 .css 文件的脚本。

./themes/papers/partials/header.html(或者负责在你的 Hugo 主题中切换模式的文件)中,找到切换模式的代码。 每个主题都不一样,你需要自己去找。

例如它可能类似这样:

const setDark = (isDark) => {
  metaTheme.setAttribute('content', isDark ? '#000' : lightBg);
  htmlClass[isDark ? 'add' : 'remove']('dark');
  localStorage.setItem('dark', isDark);
};

我们在这个函数调用中添加一个方法:

const setDark = (isDark) => {
  metaTheme.setAttribute('content', isDark ? '#000' : lightBg);
  htmlClass[isDark ? 'add' : 'remove']('dark');
  localStorage.setItem('dark', isDark);
  setSyntaxDark(isDark); // 添加的方法
};

定义 setSyntaxDark 函数和一个额外的辅助函数:

function getStyleSheet(file_name) {
  for (var i = 0; i < document.styleSheets.length; i++) {
    var sheet = document.styleSheets[i];
    if (sheet.href.includes(file_name)) {
      return sheet;
    }
  }
}

function setSyntaxDark(isDark) {
  let sheet_light = getStyleSheet("syntax_light")
  let sheet_dark = getStyleSheet("syntax_dark")

  sheet_light.disabled = isDark ? true : false
  sheet_dark.disabled = isDark ? false : true
}

随后,使用 Hugo 重新构建你的网站,确保一切正常。

简繁中文使用相同的评论数据,并自动翻译

简繁语言本就没什么大差距,不需要分开不同的评论。在 Waline 中, 你可以通过移除 url 中的语言部分使得不同语言版本共享同一个评论。

修改你的 waline 初始化代码:

init({
    el: "#xxx",
    serverURL: "xxx",
    locale: locale['{{ .Page.Lang }}'], // 注意这一行
    dark: 'html.dark', // 可实现黑暗模式自适应
    // path 中使用正则移除简繁部分的网址
    path: window.location.pathname.replace(/zh-(hans|hant)\//, '')
});

同时我们需要定义一个 locale 变量实现 waline 的本地化,例如:

const locale = {
    "zh-hans": {
        nick: '昵称',
        nickError: '昵称不能少于3个字符',
        mail: '邮箱',
        mailError: '请填写正确的邮件地址',
        link: '网址',
        optional: '可选',
        placeholder: '欢迎评论',
        sofa: '来发评论吧~',
        submit: '提交',
        like: '喜欢',
        cancelLike: '取消喜欢',
        reply: '回复',
        cancelReply: '取消回复',
        comment: '评论',
        refresh: '刷新',
        more: '加载更多...',
        preview: '预览',
        emoji: '表情',
        uploadImage: '上传图片',
        seconds: '秒前',
        minutes: '分钟前',
        hours: '小时前',
        days: '天前',
        now: '刚刚',
        uploading: '正在上传',
        login: '登录',
        logout: '退出',
        admin: '博主',
        sticky: '置顶',
        word: '字',
        wordHint: '评论字数应在 $0 到 $1 字之间!\n当前字数:$2',
        anonymous: '匿名',
        level0: '潜水',
        level1: '冒泡',
        level2: '吐槽',
        level3: '活跃',
        level4: '话痨',
        level5: '传说',
        gif: '表情包',
        gifSearchPlaceholder: '搜索表情包',
        profile: '个人资料',
        approved: '通过',
        waiting: '待审核',
        spam: '垃圾',
        unsticky: '取消置顶',
        oldest: '按倒序',
        latest: '按正序',
        hottest: '按热度',
        reactionTitle: '你认为这篇文章怎么样?'
    },
    "zh-hant": {
        nick: '暱稱',
        nickError: '暱稱不能少於3個字元',
        mail: '郵箱',
        mailError: '請填寫正確的郵件地址',
        link: '網址',
        optional: '可選',
        placeholder: '歡迎評論',
        sofa: '來發評論吧~',
        submit: '提交',
        like: '喜歡',
        cancelLike: '取消喜歡',
        reply: '回覆',
        cancelReply: '取消回覆',
        comment: '評論',
        refresh: '重新整理',
        more: '載入更多...',
        preview: '預覽',
        emoji: '表情',
        uploadImage: '上傳圖片',
        seconds: '秒前',
        minutes: '分鐘前',
        hours: '小時前',
        days: '天前',
        now: '剛剛',
        uploading: '正在上傳',
        login: '登入',
        logout: '退出',
        admin: '博主',
        sticky: '置頂',
        word: '字',
        wordHint: '評論字數應在 $0 到 $1 字之間!\n當前字數:$2',
        anonymous: '匿名',
        level0: '潛水',
        level1: '冒泡',
        level2: '吐槽',
        level3: '活躍',
        level4: '話癆',
        level5: '傳說',
        gif: '表情包',
        gifSearchPlaceholder: '搜尋表情包',
        profile: '個人資料',
        approved: '透過',
        waiting: '待稽核',
        spam: '垃圾',
        unsticky: '取消置頂',
        oldest: '按倒序',
        latest: '按正序',
        hottest: '按熱度',
        reactionTitle: '你認為這篇文章怎麼樣?'
    }
}

重定向 Feed(Rss/Atom)订阅地址

我的网站虽然是个小站,但依然有些人在订阅。为了避免他们走丢, 我需要能够保持原有的 Feed 订阅地址。如果你使用的 OSS 或其他 无服务器(Serverless)的方式进行托管,你可能需要在你的 CDN 页面使用 rewrite 来实现。

因为我是有服务器的,我使用 nginx 来实现了这一点。

Hugo 默认将 RSS/Atom 发布在根目录下的 index.xml 或类似的文件中。 找到这个文件的地址,然后你可以进行重定向:

location ~ ^/blog/feed/?$ {
    rewrite ^ /index.xml last;
}

上面的这段 nginx 配置可以把 /blog/feed 和 /blog/feed/ 指向 index.xml, 同时保持对外访问地址不变。

提示

一个小技巧:在你的 nginx 访问日志中搜索 feed,你可能可以看到有多少人订阅了你的网站。

除了解决 Feed 网址问题外,如果你和我一样通过 nginx 部署,建议你还要做两件事:

  • 迁移早期定期访问 access log,查看是否有旧网址出现了 404 错误,这不利于 SEO;
  • 在 nginx 配置文件里,设置正确的 404 页面。

图片管理

强力建议你采用下列的文件结构来存放图片:

posts /
        article /
                    index.md
                    1.jpg

这种方式管理方便,而且在 Markdown 编辑器中不会出现图片看不到的问题。

然后,你可以通过 render-image hook 实现图片的 CDN 化,提升全球访问速度。 具体可进行相关搜索。

当前页阅读量为: