..

博客从 Hexo 迁移到 Hugo

引子

好久没更新博客了,最近正好想把博客迁移到 CloudFlare Pages 上,索性升级下。

借着迁移的机会,聊聊我折腾博客的经历。我是在 2015 年开始折腾博客,当时距离 Via 已经发布了几个月,我刚好高考结束,时间充裕,天天在弯路群闲聊,认识了不少好友,依稀记得好像是在 sgfox 的帮助下,了解了 DNS 解析和搭建网站这回事。记得第一次搭建博客时,精心挑选了域名 1year.cc,一口气续费了十年(至今还在我的阿里云账户呢),当时觉得 wordpress 太重,用了 Typecho 搭建在虚拟主机上,不过只在搭建最初几天趁着新鲜劲写过几篇文章。2018 年我开始折腾树莓派,对当时的我来说这可真是个新奇玩意,学着搞了不少东西,想着记录一下,决定认真写博客,就又从 Typecho 迁移到了 Hexo,域名也换成了现在这个,断断续续又写了些。一直在折腾,却没留下多少文字,博客就这么一直睡在网络上,像是从未搭建过。如今只剩下这些用以凑数的零碎文章,本想迁移一并删掉,后来还是决定保留做个记录吧。

搭建 Hugo 博客

搭建过程很简单,跟着官方文档一步步来就好。我用的是 macOS,以下如不做特殊说明都是在 macOS 下操作。

首先,在本机安装 hugo:

brew install hugo

接着,创建站点并配置主题:

# 新建站点到当前目录下的 blog 文件夹
hugo new site blog
# 打开 blog 文件夹
cd blog
# 从远端下载 ananke 主题
git clone --depth=1 https://github.com/theNewDynamic/gohugo-theme-ananke.git themes/ananke
# 将主题添加到配置中
echo "theme = 'ananke'" >> hugo.toml
# 启动 hugo 服务
hugo server

最后,就可以在浏览器打开 http://localhost:1313 查看了。想部署到服务器的话,使用 hugo 命令,会在 blog 文件夹的 public 目录下输出所有资源,把这个文件夹的所有内容推到服务器就好。更多的用法这里就不说了,官方文档写得很详细。

配置博客

配置主题

上面使用的主题只是为了让我们快速看到效果,要不然下载了第三方主题,发现启动报错可太有挫败感了 orz。首先去 Hugo Themes 挑选一个喜欢的主题,我使用的是 no-style-please,我觉得这主题太酷了,第一眼就爱上了。像上一步那样,下载主题到 themes 文件夹,修改 hugo.toml 配置文件中的主题选项。

配置完主题后,如果你只是写博客,那这部分下面的内容就不用看了,以下基本是从 Hexo 迁移过来导致的变更。

迁移 Hexo 文章

因为我是从 Hexo 迁移过来的,所以为了兼容原有的链接,需要做一些配置。首先,将以下配置添加到 hugo.toml 中,以修改文章链接为 /article/:slug

[permalinks]
  posts = "/article/:slug"

接着就可以迁移 markdown 文件了,主要是把 metadata 改成 hugo 格式的,像下面例子这样:

Hexo 文档的 metada:
---
title: 树莓派安装 CentOS
date: 2019-08-28 15:40
tags:
- raspi
categories:
- raspi
permalink: install-centos-on-raspi
---

Hugo 文档的 metadata:
---
title: 树莓派安装 CentOS
date: 2019-08-28T15:40:00+08:00
tags: ["raspi"]
categories: ["raspi"]
draft: false
slug: install-centos-on-raspi
---

我是借助 ChatGPT 写了个 python 脚本处理掉了:

import os
import re
import datetime

def convert_timestamp(timestamp):
    dt = datetime.datetime.strptime(timestamp, "%Y-%m-%d %H:%M")
    return dt.strftime("%Y-%m-%dT%H:%M:%S+08:00")

def convert_file(input_file, output_dir):
    with open(input_file, 'r') as f:
        content = f.read()

    # 提取元数据
    metadata_pattern = re.compile(r'---(.*?)---', re.DOTALL)
    metadata_match = metadata_pattern.search(content)
    metadata_str = metadata_match.group(1)

    # 解析元数据
    metadata = {}
    key = None
    for line in metadata_str.strip().split('\n'):
        if line.strip() == '':
            continue
        if ':' in line:
            key, value = re.split(r':\s*', line, maxsplit=1)
            key = key.strip()
            value = value.strip()
            metadata[key] = value
        elif key:
            metadata[key] += line.strip()

    # 修改日期格式
    metadata['date'] = convert_timestamp(metadata['date'])
    if 'updated' in metadata:
        metadata['lastmod'] = convert_timestamp(metadata['updated'])

    # 将 tags 和 categories 转换为列表形式
    tags_str = ', '.join(f'"{tag}"' for tag in metadata.get('tags', '').split('- ') if tag).strip()
    if tags_str:
        metadata['tags'] = '[' + tags_str + ']'
    else:
        metadata.pop('tags')
    categories_str = ', '.join(f'"{category}"' for category in metadata.get('categories', '').split('- ') if category)
    if categories_str:
        metadata['categories'] = '[' + categories_str + ']'
    else:
        metadata.pop('categories')

    permalink = metadata.get('permalink', '')
    if permalink:
        metadata['slug'] = permalink

    # 添加 draft 属性
    metadata['draft'] = 'false'

    # 构建新的文件内容
    keys = ['title', 'date', 'lastmod', 'tags', 'categories', 'draft', 'slug']
    new_content = '---\n'
    for k in keys:
        if k in metadata:
            new_content += k + ': ' + metadata[k] + '\n'
    new_content += '---\n' + content[metadata_match.end():]

    # 创建输出目录
    os.makedirs(output_dir, exist_ok=True)

    # 写入新文件
    output_file = os.path.join(output_dir, os.path.basename(input_file))
    with open(output_file, 'w') as f:
        f.write(new_content)

if __name__ == "__main__":
    input_dir = "."
    output_dir = "posts_done"

    for filename in os.listdir(input_dir):
        if filename.endswith(".md"):
            input_file = os.path.join(input_dir, filename)
            convert_file(input_file, output_dir)
            print(f"Converted {filename} and saved to {output_dir}")

脚本大意就是,把当前目录下的 markdown 文件,处理 metadata 数据后,保存到当前目录下的 posts_done 文件夹中。之后就可以将 posts_done 文件夹下的所有文档,复制到 blog 目录下的 content/posts 目录,把文档对应的图片资源,一并复制到 blog 目录下的 static 目录。

添加关于页

文章处理好后,再迁移关于页。把 Hexo 博客里的关于页对应的 markdown 文件,手动处理好 metadata 后,存到 blog 目录下的 content/about.md 即可。最后使用 hugo server 启动服务,打开 http://localhost:1313/about/ 查看效果。

添加归档页

归档页(Archives)这个麻烦点,因为 Hugo 默认是不带这个页面的,需要我们手动操作。首先复制使用主题的文件夹里的 layouts/posts/single.html 到 blog 目录的 layouts/archive/single.html ,比如我这里就是:

mkdir layout/archive
cp themes/nostyleplease/layouts/posts/single.html layouts/archive/single.html

接着,修改 layouts/archive/single.html ,将 {{ .Content }} 修改为对应的帖子列表,我这里是按年分类的帖子列表,代码如下:

{{ range where .Site.Pages "Section" "posts" }}
  {{ range (.Pages.GroupByDate "2006") }}
  <h3>{{ .Key }}</h3>
  <ul>
    {{ range .Pages }}
    <li>
      <span>{{- (.Date | time.Format "01-02") }}</span>
      <a href="{{ .Permalink | relURL }}">{{ .Title }}</a>
    </li>
    {{ end }}
  </ul>
  {{ end }}
{{ end }}

之后,创建归档页面 markdown 文档 content/archives.md ,内容如下:

---
title: "Archives"
type: archive
date: 2024-03-21T23:09:18+08:00
---

接着,使用 hugo server 启动服务,打开 http://localhost:1313/archives/ 查看效果。

写文章

好在我之前的 Hexo 配置并不复杂,经过上述的配置过程,在不改动原 Hexo URL 链接的情况下,已经基本将原来的 Hexo 博客完整迁移了。接下来就要考虑如何写博客的问题了。

简单编写

如果你只想简单写写博客,没有编辑器的需求,那直接使用如下命令创建文章:

hugo new posts/post.md

将 post.md 改成你想要的文件名,只要是 md 后缀都可以的。文章文件会创建在 blog 目录下的 content/posts 下,直接用你喜欢的编辑器修改这个文件就好。

使用 Emacs 编写

Emacs 有 ox-hugo 包,可以很方便地将 org 文档存储到 blog 目录下的合适位置,还会同时处理文档的图片,非常好用。我这篇文章就是使用 org mode 编写,并且通过 ox-hugo 导出的。

ox-hugo 有两种编写文章的方式,一种是把所有文章放在一个 org 文件里,用子树的方式组织;另一种是一个 org 文件对应一篇文章。虽然官方推荐使用子树的方式组织文章,但我个人更喜欢一篇文章一个 org 文件,这样日后检索可以直接搜索文件名,修改也快很多。以下都是默认使用一个 org 文件对应一篇文章的做法实现。

首先,在 Emacs 的配置中的合适位置添加 ox-hugo 的包:

(use-package ox-hugo
  :ensure t
  :defer t
  :after ox)

之后在 org 文档开始位置添加头信息,比如我这篇文档的 metadata 就是:

#+title: 博客从 Hexo 迁移到 Hugo
#+date: <2024-03-21 Thu 23:59>
#+hugo_base_dir: ~/code/hugo/blog
#+hugo_section: posts
#+hugo_auto_set_lastmod: t
#+hugo_draft: false
#+hugo_tags: hugo
#+hugo_categories: others
#+hugo_front_matter_format: yaml
#+hugo_locale: zh
#+hugo_slug: migrate-from-hexo-to-hugo
#+options: toc:t author:nil

简单做些说明:

  • title: 文章标题,也是导出文档的文件名;
  • date: 创建日期;
  • hugo_base_dir: blog 目录;
  • hugo_section: 保存到哪个 section;
  • hugo_auto_set_lastmod: 设置为 t 即表示自动添加最后修改时间;
  • hugo_draft: true 代表文章是草稿,不会发布出来,发布时记得改为 false;
  • hugo_tags: 文章标签,空格分隔;
  • hugo_categories: 文章目录,空格分隔;
  • hugo_front_matter_format: metadata 的格式,用 yaml 就好;
  • hugo_locale: 为了避免奇怪的空格问题,设置为 zh;
  • hugo_slug: 文章的链接;
  • options: toc:t 代表输出目录,author:nil 代表不要添加作者信息。

写完文章后,使用 M-x org-hugo-export-to-md ,或是 C-c C-e H h 快捷键,就可以自动将文章转换为 markdown 文件,放在 blog 目录对应的文章,并自动处理好文章引用的图片和文件。

每次都手动编写 metadata 是很累的,我使用 Yasnippet 自动补全,模版如下:

# -*- mode: snippet -*-
# name: hugo-file-header
# key: hoh
# --
#+title: ${1:`(file-name-sans-extension (buffer-name))`}
#+date: `(format-time-string "<%Y-%m-%d %a %H:%M>")`
#+hugo_base_dir: ~/code/hugo/blog
#+hugo_section: posts
#+hugo_auto_set_lastmod: t
#+hugo_draft: true
#+hugo_tags: $2
#+hugo_categories: $3
#+hugo_front_matter_format: yaml
#+hugo_locale: zh
#+hugo_slug: $4
#+options: toc:t author:nil

$0

这样想写博客,新建 org 文件后,输入 hoh 按 tab 键就会自动使用该模版,十分方便。

部署博客到 Cloudflare Pages

以前我一直是将博客托管到自己的服务器,但这样做很麻烦,要操心证书(虽然 acme.sh 很好用但总归要先部署不是?),操心服务器续费。明明是来写博客的,却把自己变成了运维,所以这次我决定把博客部署到 Cloudflare Pages。

首先使用 hugo 命令,网站会生成所有部署资源到 blog 目录下的 public 目录,我们之后需要将这个目录的所有内容上传到 Pages。接着,在 CloudFlare Pages 创建一个项目,项目名称自己想好,这是之后的二级域名(project_name.pages.dev)。

直接上传

压缩 public 文件夹得到一个 zip 包,上传到网页后台

我没有采用这种方法,虽然可以写个脚本,自动生成网页并压缩 zip 包,自己只需要手动上传,但还是觉得可以直接用 CLI 推送上去更快。

CLI 上传

命令行上传需要先安装 Wrangler,后续上传都是通过 Wrangler 命令

# 打开博客目录
cd blog
# 安装 wrangler
npm install wrangler --save-dev

安装完成后可以通过 npx wrangler --version 检查版本。(如果想通过 CLI 创建项目,可以使用 npx wrangler pages project create 命令。)使用如下命令将 public 文件夹部署到 Pages:

npx wrangler pages deploy public/

跟着命令行提示一步步进行即可,成功后会给一个临时域名预览。

这里我写了个 bash 脚本 deploy.sh ,后续可以直接执行,不需要每次都打这么多命令,脚本内容如下:

#!/bin/bash

local=$(cd "$(dirname "$0")";pwd)
cd $local

while getopts ":d" opt; do
  case ${opt} in
    d )
      hugo && npx wrangler pages deploy public
      ;;
    \? )
      echo "Invalid option: $OPTARG" 1>&2
      exit 1
      ;;
    : )
      echo "Option -$OPTARG requires an argument." 1>&2
      exit 1
      ;;
  esac
done
shift $((OPTIND -1))

这样以后只要执行 bash deploy.sh -d 就会自动生成文件并部署到服务器了。

尾巴

虽然文章很长,折腾起来还是很快的,基本上花了一晚上就搞定了(但是写这篇文章花了差不多快一天)。

时间匆匆,回看博客里以前的文章,发现自己还是进步了很多,无论技术还是文笔。在我为写这篇文章查找资料时,检索到的资料大多是 2020~2022 年发布的,甚至还有不少 2019 年的资料,少有 2023 年写成的文章。AI 时代,知识的获取飞快,或许这篇文章你也是通过 AI 总结读完的,或许根本没有读,只扫过开头便草草关闭网页。但不管怎样,文字总是在那,沉睡在互联网,偶尔通过深埋在海底的光缆,在世间游荡。

参考资料