Migrating Blog From Typecho to Hugo

1. 概述

使用Typecho部署的Blog,由于设置了禁止评论,就想,运行一个PHP语言驱动的Blog还有什么意义?于是就想玩玩静态化的Blog。

1.1. Typecho的问题

  • Typecho项目几乎处于停更状态。
  • 需要运行PHP-FPM + MySQL数据库,对内存小的VPS不友好。
  • 部署相对麻烦。当然,内嵌Apache的Docker镜像会简单点,但资源占用没下降。

1.2. Hugo的概述

  • 生成的Blog静态文件,几乎可以到处运行。例如无需购买VPS的GitHub Pages、Cloudflare Pages等。
  • 入门相对不容易。部署过才发现,难的不是Hugo本身,而是选用的模板。

1.3. 需求

  • 采用Hugo生成静态化Blog。
  • 功能包括:以文章列表填充的首页、全站搜索、文章分类、按时间归档、Tag。
  • 不需要评论功能。

2. 迁移步骤

总体步骤如下:

  • Typecho修正文章(post)格式。
  • Typecho导出文章。
  • 安装Hugo。
  • 创建Blog。
  • 选择主题(Theme)。
  • 配置Blog。
  • 生成静态文件。
  • 部署Blog。

参考:

2.1. Typecho修正文章格式

  • 就是把文章都调整为Markdown格式。
  • 这步略过。主要修正历史文章的格式。

2.2. Typecho导出文章

导出前,先了解Hugo的文章格式:

  • 详见官方说明:Docs > Content management > Archetypes
  • Markdown格式的文章,分为“前置项”(front matter)和“正文”(markup)两部分。
  • 我采用toml作为前置项的格式,正文就是以Markdown格式编写的文章正文。
  • 前置项的slug,是文章标题转为小写后,以“-”替换所有空格和符号的字符串。后续用于生成文章URL。

所使用的导出插件的项目源码:typecho-export-hugo

安装:Typecho部署目录的usr/plugins目录下,创建目录Export2Hugo(注意名称不能改,大小写也不能变),再把该项目的源码放进去。

导出:登录Typecho后台,在“控制台”菜单下会有一个“导出至Hugo”菜单,点击后执行导出。

注意:

  • 部署Hugo的Blog后,需要考虑文章的网址(Post的URL)。Typecho一般采用文章ID(整数自增ID),但是Hugo采用文章ID需要手工维护,不好玩。我改为使用文章的英文标题。
  • 根据上面的需求,修改了一下文章的导出格式。即把Action.php修改为如下内容:
<?php

class Export2Hugo_Action extends Typecho_Widget implements Widget_Interface_Do
{
  /**
   * 导出文章
   *
   * @access public
   * @return void
   */
  public function doExport() {
    $db = Typecho_Db::get();
    $prefix = $db->getPrefix();

    $sql=<<<TEXT
  select u.screenName author,url authorUrl,title,type,text,created,c.status status,password,t2.category,t1.tags,slug from {$prefix}contents c
  left join
  (select cid,CONCAT('"',group_concat(m.name SEPARATOR '","'),'"') tags from {$prefix}metas m,{$prefix}relationships r where m.mid=r.mid and m.type='tag' group by cid ) t1
  on c.cid=t1.cid
  left join
  (select cid,CONCAT('"',GROUP_CONCAT(m.name SEPARATOR '","'),'"') category from {$prefix}metas m,{$prefix}relationships r where m.mid=r.mid and m.type='category' group by cid) t2
  on c.cid=t2.cid
  left join ( select uid, screenName ,url from {$prefix}users)  as u
  on c.authorId = u.uid
  where c.type in ('post', 'page')
TEXT;
    $contents = $db->fetchAll($db->query($sql));
    
    $dir = sys_get_temp_dir()."/Export2Hugo";
    if(file_exists($dir)) {
      exec("rm -rf $dir");
    }
    mkdir($dir);

    $contentDir = $dir."/content/";
    mkdir($contentDir);
    mkdir($contentDir."/posts");

    foreach($contents as $content) {
      $title = $content["title"];
      $categories = $content["category"];
      $tags = $content["tags"];
      $slug = str_replace(' ','-',trim(preg_replace('/[^0-9a-z_]+/', ' ', strtolower($title))));
      $time = date('Y-m-d H:i:s', $content["created"]);
      $text = str_replace("<!--markdown-->", "", $content["text"]);
      $text = str_replace(array("\r\n", "\r"), "\n", $text);
      $draft = $content["status"] !== "publish" || $content["password"] ? "true" : "false";
      $hugo = <<<TMP
+++
title = "$title"
categories = [ $categories ]
tags = [ $tags ]
draft = $draft
slug = "$slug"
date = "$time"
+++

$text
TMP;
      
      $filename = date('Y-m-d-', $content["created"]) .$slug .'.md';

      if($content["type"] === "post") {
        $filename = "posts/".$filename;
      }
      file_put_contents($contentDir.$filename, $hugo);
      echo $contentDir.$filename;
    }
  
    $filename = "hugo.".date('Y-m-d').".zip";
    $outputFile = $dir."/".$filename;
    exec("cd $dir && zip -q -r $outputFile content");
    
    header("Content-Type:application/zip");
    header("Content-Disposition: attachment; filename=$filename");
    header("Content-length: " . filesize($outputFile));
    header("Pragma: no-cache"); 
    header("Expires: 0"); 

    readfile($outputFile);
  }

  /**
   * 绑定动作
   *
   * @access public
   * @return void
   */
  public function action() {
    $this->widget('Widget_User')->pass('administrator');
    $this->on($this->request->is('export'))->doExport();
  }
}

2.3. 安装Hugo

2.3.1. 安装概述

Linux上安装Hugo,参考官方文档:Docs > Installation > Linux

Hugo的Github发布版本,一般分为3个:

  • hugo
    • 基础版。包含Hugo的基本功能。
  • hugo_extended
    • 扩展版。在基础版上,增加了两个功能:把图片转码为WebP,把Sass转为CSS。
  • hugo_extended_withdeploy
    • 扩展部署版。在扩展版上,增加了直接部署功能,支持Google Cloud Storage、AWS S3、Azure Storage等。

由于是Golang开发的,Hugo一般就只有一个可执行文件。但是,如果用到其它插件,可能还需要用到Git、Node.js等。

安装Hugo有多种方式:

  • 从Github项目发布下载所需版本:https://github.com/gohugoio/hugo/releases
  • 操作系统自带软件库中安装。例如Debian可使用apt install hugo命令安装
  • 使用Docker部署,可用镜像:https://hub.docker.com/r/hugomods/hugo

如果只是简单使用Hugo,没涉及其它插件,建议”从Github项目发布下载“。如果需要用到Git、Node.js等,建议使用已打包的Docker镜像部署。

注意,所选用的主题(Theme),也会对Hugo版本和第三方工具有要求。例如后文会提到使用Stack主题,该主题会要求使用hugo_extended版本。

2.3.2. Docker Compose安装

参考:

使用Dockcer Compose部署的docker-compose.yaml参考如下:

services:
  hugo:
    image: hugomods/hugo:base-non-root-0.145.0
    volumes:
      - ./src:/src
      - ./hugo_cache:/tmp/hugo_cache
    ports:
      - 1313:1313
    # hugomods notice: Since 0.136.2, both of server and hugo server bind 0.0.0.0 by default.
    command: server --watch --buildDrafts --disableFastRender
    working_dir: /opt/hugo_blog/myblog

注意:

  • 这里采用hugomods的Docker镜像,不是Hugo官方出品。
  • 通过Docker Compose部署的Hugo,建议放在开发环境或者线下。生产环境或线上,没必要采用这个。
  • 示例采用非root用户的Docker镜像,其镜像内的用户ID为1000,用户组也为1000,一般对应宿主机的第一个非root用户。用起来很方便。
  • docker-compose.yaml里配置的volumes,相关宿主机目录要设置用户ID1000可读写。参考命令:sudo chown -R 1000:1000 ./src/ ./hugo_cache/

2.4. 创建Blog

Hugo创建Blog的文件目录,并把导出的Blog文章文件复制进去。

Hugo创建Blog的命令如下:

# 确定Blog所在的目录,进入该目录
cd /opt/hugo_blog
# 创建Blog,其目录为myblog,该名称可以自定义
hugo new site myblog

创建成功后,Blog存放在/opt/hugo_blog/myblog目录,进入该目录可以看到Blog的目录结构。

目录结果说明,详见:Docs > Getting started > Directory structure

myblog/
 ├─ archetypes/    # 存放创建新文章的模板文件
 │  └─ default.md  # 新文章的默认模板
 ├─ assets/        # 存放通常通过asset pipeline传递的全局资源。包括图片、CSS、Sass、JavaScript和TypeScript等。
 ├─ content/       # 存放文章和自定义页面的文件。
 ├─ data/          # 存放包含增强内容、配置、本地化和导航的数据文件(JSON,TOML,YAML或XML)。
 ├─ i18n/          # 存放用于多语言站点的翻译表。
 ├─ layouts/       # 存放将内容、数据和资源转换为完整网站的模板文件。
 ├─ static/        # 存放的文件将在建立网站时复制到`public`目录。例如:favicon.ico、robots.txt 和验证网站所有权的文件。
 ├─ themes/        # 存放一个或多个主题,每个主题拥有自己的子目录。
 └─ hugo.toml      # Blog的配置文件

注:网站构建(build)后,会自动创建publicresources两个目录,详见后文。

进入content目录,建立两个目录,pageposts。把上一步导出的文章文件,复制到posts目录下。在page目录下存放自定义页面(其格式跟文章的文本格式类似)。

其中pageposts两个目录的名称可以自定义,并修改配置文件的对应配置项,详见后文。

2.5. 选择主题(Theme)

选择所需主题,并安装到所创建的Blog。官方提供的主题列表:Complete List | Hugo Themes

我选择了Stack主题,相关介绍参考:

选择该主题的理由:

  • 喜欢其选用颜色和页面布局设计。
  • 自带全文搜索功能。跟Fuse.js类似,是通过浏览器端js实现的。
  • 首页能显示按年份、分类、标签归档。
  • 支持暗黑(Dark)模式。

安装主题:

  • 参考官方文档:Installation | Getting Started | Stack
  • 我选择了官方不推荐的方式,从Github发布中下载最新版,并解压到Blog的themes目录下。
  • 主题所存放的目录名称,要记住,后面配置要用。比如我命名该目录为hugo-theme-stack。即相关主题文件放在themes/hugo-theme-stack目录。

2.6. 配置Blog

配置Blog是最麻烦的,除了需要了解Hugo的配置,还要学习Stack主题的配置。

参考:

但是Stack的主题配置,文档不够清晰,建议直接参考其代码的示例或默认配置:

配置有两种组织方式,详见:Docs > Configuration > Introduction

  • 全部写在hugo.toml文件。
  • 写在config/_default/目录下,可以分文件组织配置。

我结合Stack主题,整理出来的hugo.toml如下。除了开头的9个配置项,后面基本都是Stack主题的配置。

baseURL = 'https://myblog.xxx.xxx/' # Blog正式部署的网址
languageCode = 'zh-CN' # 语言编号
title = "My-Blog" # Blog标题
copyright = 'myblog. All Rights Reserved.' # 版权信息

defaultContentLanguage = "zh-cn" # 默认语言
hasCJKLanguage = true # 是否含有亚洲文字
enableEmoji = true # 启用Emoji
enableRobotsTXT = true # 启用robots.txt文件
theme = "hugo-theme-stack" # 设置选用的主题,值为theme目录下的对应主题目录名称

[params]
  mainSections = ["posts"]
  #favicon = "/favicon.png"
  [params.contact]
    email = 'myblog@xxx.com' # Blog拥有者的Email
  [params.footer]
    since = 2009 # 版权信息的开始年份
  [params.dateFormat]
    published = "2006-01-02" # 发布日期的日期格式
    lastUpdated = "2006-01-02 15:04 MST" # 最后修改日期的日期格式
  [params.sidebar]
    subtitle = "All About Me" # Blog的子标题
    avatar.enabled = false # 显示Blog拥有者的头像
  [params.comments]
    enabled = false # 启用评论
  [params.article]
    headingAnchor = true
    math = false
    readingTime = false # 文章显示阅读时间
    toc = true 
  [params.widgets] # 首页右侧的模型设置
    [[params.widgets.homepage]]
      type = "search"
    [[params.widgets.homepage]]
      type = "archives"
      [params.widgets.homepage.params]
        limit = 5
    [[params.widgets.homepage]]
      type = "categories"
      [params.widgets.homepage.params]
        limit = 100
    #[[params.widgets.homepage]]
    #  type = "toc"
    [[params.widgets.homepage]]
      type = "tag-cloud"
      [params.widgets.homepage.params]
        limit = 50

[menus] # 菜单设置
  [[menus.main]]
    identifier = 'home' # 菜单ID
    name = 'Home' # 菜单名称
    url = "/" # 菜单点击跳转的URL
    weight = 10 # 菜单位置权重,值越小,位置越靠前
    [menus.main.params]
      icon = "home" # 菜单图标,这里使用主题所提供的
  [[menus.main]]
    identifier = 'search'
    name = '搜索'
    url = "/page/search"
    weight = 20
    [menus.main.params]
      icon = "search"
  [[menus.main]]
    identifier = 'archives'
    name = '归档'
    url = "/page/archives"
    weight = 30
    [menus.main.params]
      icon = "archives"
  [[menus.main]]
    identifier = 'categories'
    name = '分类'
    url = "/page/categories"
    weight = 40
    [menus.main.params]
      icon = "categories"
  [[menus.main]]
    identifier = 'about'
    name = '关于'
    url = '/page/about'
    weight = 100
    [menus.main.params]
      icon = "link"
 
[pagination] # 分页设置
  disableAliases = false
  pagerSize = 10
  path = 'list'

[permalinks]
  [permalinks.page] # 自定义文章的URL
    posts = '/post/:year-:month-:day-:slug' # 带冒号的变量,可以在文章开头找到
  [permalinks.section]
    posts = '/post/'

#[services]
#  [services.googleAnalytics]
#    ID = 'G-PPPPPPPPP' # 设置GA帐号ID,用于启用GA功能

2.7. 生成静态文件

使用hugo server可以写完文章,立马预览效果。但是需要发布时,还是重新构建一下。

# 进入Blog目录
cd /opt/hugo_blog/myblog

# 删除已构建的静态文件
rm -rf ./public

# 执行构建
hugo

注意:

  • hugo命令和hugo build命令是一样的。
  • 构建前,删除已构建的静态文件,能避免出现静态文件没更新的问题。
  • 构建时,默认取当前目录作为Blog目录,也可以使用-s参数指定Blog目录。

2.8. 部署Blog

Blog构建成功后,所有静态文件保存在public目录。把该目录部署到正式网站即可。

官方文档已列出各种可部署环境和部署说明,详见:Docs > Host and deploy

如果使用Nginx部署,注意配置Gzip压缩和浏览器缓存。参考配置:

server {
  server_name myblog.xxx.xxx;
  listen 80; 
  listen [::]:80;

  rewrite ^(.*)$  https://$host$1 permanent;
}

server {
  server_name myblog.xxx.xxx;
  #listen [::]:443 ssl ipv6only=on;  
  listen 443 ssl;
  http2 on;

  # Hugo所生成Blog的静态文件,即public目录
  root /var/www/myblog/public;
  index index.html;

  ssl_certificate /etc/letsencrypt/live/myblog.xxx.xxx/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/myblog.xxx.xxx/privkey.pem;

  gzip              on; 
  gzip_vary         on; 
  gzip_comp_level   9;  
  gzip_min_length   1k; 
  gzip_buffers      16 8k; 
  gzip_types        application/atom+xml application/javascript application/json application/ld+json application/manifest+json application/rss+xml application/vnd.geo+json application/vnd.ms-fontobject application/x-font-ttf application/x-web-app-manifest+json application/xhtml+xml application/xml font/opentype image/bmp image/svg+xml image/x-icon text/cache-manifest text/css text/plain text/vcard text/vnd.rim.location.xloc text/vtt text/x-component text/x-cross-domain-policy;
  #gzip_http_version 1.1;
  #gzip_disable      "MSIE [1-6]\.";
  
  add_header X-Frame-Options SAMEORIGIN;
  add_header Content-Security-Policy "frame-ancestors 'self';";

  location / {
    try_files $uri $uri/index.html =404;

    etag off;

    if ($request_filename ~* ^.*?\.(ico|icon|gif|jpg|jpeg|png|webp)$) {
      access_log  off;
      expires     max;
    }
    if ($request_filename ~* ^.*?\.(css|js)$){
      access_log  off;
      expires     2h;
    }
    if ($request_filename ~* ^.*?\.(html|htm|json|xml)$){
      expires     2h;
    }
    add_header  Cache-Control public;
  }
}

3. 维护

3.1. 总体规划

  • 需要分开开发环境和生产环境。
    • 一般开发环境在线下,生产环境在线上。
  • 开发环境安装Hugo。
    • 创建Blog、选择主题、写文章、构建静态文件等。
  • 生产环境只存放Hugo构建后的静态文件,即public目录。

3.2. 新增文章

概述。

  • 定义好新文章的模板,方便新建文章时自动生成想要的内容格式。
  • 文章的模板,采用了一定的自动化(例如自动取当前日期的UTC时间)。由于新建文章的命令不能传入更多的参数(例如不能传入完整的标题),还是需要新建文章后再修改。
  • 文章的文件名,需要配合Blog的管理而决定。这里定的是文件创建日期 + 文章英文标题。

先新建文章的模板,文件为archetypes/post.md,内容如下。其中[params]下的项目,是自定义的。

+++
title = "{{ replace (substr .File.ContentBaseName 11) "-" " " | title }}" 
draft = false
date = "{{ now.UTC.Format "2006-01-02T15:04:05Z" }}" 
categories = [ "" ] 
tags = [  ]   
slug = "{{ substr .File.ContentBaseName 11 }}" 
[params]
  title-cn = ""
+++

再新建文章。后面新建文章都会按模板生成初始文件。new-post-title对应该文章的标题。

hugo new content posts/$(date +%Y-%m-%d)-new-post-title.md -k post

文章的模板,相关函数和方法,参考官方说明:

3.3. 重新构建

按前面提到的命令处理。

3.4. 重新发布

public目录上传到服务器即可。自建服务器的,可以使用rsync命令同步public目录。

使用 Hugo 构建
主题 StackJimmy 设计