本站從 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 化,提升全球訪問速度。 具體可進行相關搜索。

当前页阅读量为: