I recently switched from WordPress to Hugo. This post outlines the reasons behind my migration and provides a step-by-step guide on how to make the transition.

Rethinking Why Use WordPress

I began using WordPress in 2009 when static site generators were not prevalent, and WordPress was the go-to CMS for nearly everyone. However, since my blog had infrequent updates but high readership, I started reconsidering the use of WordPress. Five years ago, I decided to convert the WordPress site into static HTML and uploaded it to a CDN to accelerate access speed for users in mainland China.

The database-driven CMS, with WordPress being the most well-known, does not offer any features that cannot be achieved with static HTMLs but with a slower loading speed. Moreover, functionalities such as comments and search can now be replaced by existing solutions, such as the widely-used Disqus.

Why Choose Hugo?

There are various well-known static site generators today, including Jekyll, Hexo, and Hugo. Many articles compare these three generators. Upon a brief review of these articles, I discovered that both Hexo and Hugo are suitable options, whereas Jekyll tends to slow down as website content grows. Personally, I opted for Hugo due to my preference for the Go language; my intuition suggested it would be faster than Hexo, which is based on JavaScript.

New Hugo Theme: Cirtus Glow

While using WordPress, I developed my own theme called Logger, which is available on GitHub. Upon transitioning to Hugo, I rewrote this theme in Hugo and open-sourced it on GitLab.

The new Hugo theme, namely Citrus Glow, appears nearly identical to the old one, but it now incorporates the latest Bootstrap 5. Logger, written a decade ago, utilized Bootstrap 2 for compatibility with IE 7 (perhaps younger individuals might not be familiar with IE 7 😂). Bootstrap 5 (originating from Bootstrap 3) offers significantly improved experiences for mobile devices. Additionally, Citrus Glow, I incorporated a design with a large number of rounded corners to align with the current mainstream aesthetic.

In this post, I won’t provide a detailed tutorial on creating a theme in Hugo. I’ve compiled a list of articles I referred to while learning to write this theme.

  • Hugo Offical Doc: Directs to comprehensive documentation for Hugo. You can consult it just like flipping through a dictionary.
  • Quick Start: Offer a straightforward and quick tutorial for initiating the creation of a Hugo theme.
  • Minify Static Files: The discussion on how to merge and minify static files (CSS and JS) for a faster loading speed.
  • Shortcode: The official guide for incorporating pre-defined content with shortcodes to enhance the content presented in Markdown pages.
  • Sidebar: The GitHub source code shows how to display taxonomies (categories and tags) on the sidebar.
  • Render Hooks: Controls the HTML output of Markdown tags.
  • LaTeX Support: Hugo did not have built-in support for LaTeX in Markdown. Consequently, many bloggers opted for katex to handle LaTeX rendering. However, in my experience, I have found that MathJAX offers significantly better support for LaTeX, especially for complex equations.
  • In-site Search: How to implement an in-site search for Hugo. Through my experience, I’ve discovered that fuse.js outperforms lunr.js. Consequently, I’ve integrated fuse.js into the theme I created. For more accurate search results, you may also explore using Algolia.

How I Migrated 100 Pages from WordPress

Export WordPress Posts to Markdown

The Hugo official migration tutorial for Hugo recommends using “wordpress-to-hugo-exporter” to export posts from WordPress. It’s important to note that this plugin is not officially provided by Hugo.

The command-line interface (CLI) version of the plugin effectively exports WordPress posts as Markdown, but it retains a considerable amount of HTML within the exported Markdown. Additionally, most image URLs remain unchanged from the WordPress format. To address this issue, I developed a Python script that employs regular expressions to replace any remaining unreplaced HTML tags. To be honest, the script is effective in about 95% of cases. However, after running it, a quick review of the Markdown documents is still necessary, especially for LaTeX equations, to ensure that all areas are correctly processed.

import logging
import re
import sys

with open(sys.argv[1]) as fp:
    markdown = fp.read()

markdown = markdown.replace(
    "https://static.infinitescript.com/wordpress/wp-content/uploads", ""
markdown = markdown.replace(
    "https://infinitescript.com/wordpress/wp-content/uploads", ""
markdown = markdown.replace(" {}", "")
markdown = markdown.replace(" ", " ")
markdown = markdown.replace("&lt;", "<")

# url -> slug
attr_url = re.search(r"url: \/[0-9]{4}\/[0-9]{2}\/[A-Za-z0-9\-]+", markdown).group()
assert attr_url is not None
attr_slug = attr_url[attr_url.rfind("/") + 1 :]
markdown = markdown.replace("%s/" % attr_url, "slug: %s" % attr_slug)

# Tailing comments after heading
markdown = re.sub(" \{[#A-Za-z0-9\-]*\.wp-block-heading\}", "", markdown)

# <a>
hyperlinks = re.findall(
    r"<a [A-Za-z0-9= :#&_\"\/\.\-\?]+>[A-Za-z0-9@:_ \-\.\(\)\+]+</a>", markdown
for hl in hyperlinks:
    href = re.search(r"href=\"[A-Za-z0-9= :#&_\/\.\-\?]+\"", hl).group()
    text = re.search(r">[A-Za-z0-9@:_ \-\.\(\)\+]+</a>", hl).group()
    markdown = markdown.replace(hl, "[%s](%s)" % (text[1:-4], href[6:-1]))

# <figure>
figures = re.findall(r"<figure [A-Za-z0-9= :#&_\"\/\.\-\?]+>", markdown)
markdown = markdown.replace("</figure>", "")
for f in figures:
    markdown = markdown.replace(f, "")

# Partial converted image
md_images = re.findall(r"\[<img [A-Za-z0-9= ,:#&_\(\)\"\/\.\-\?]+>\]", markdown)
for mi in md_images:
    img_src = re.search(r"src=\"[A-Za-z0-9_\/\-\.]+\"", mi).group()
    img_alt = img_src[img_src.rfind("/") + 1 : img_src.rfind(".")]
    markdown = markdown.replace(mi, "![%s]" % img_alt)

# <img>
images = re.findall(r"<img [A-Za-z0-9= ,:#&_\(\)\"\/\.\-\?]+>", markdown)
for i in images:
    img_src = re.search(r"src=\"[A-Za-z0-9\/\-\.]+\"", i).group()
    img_alt = img_src[img_src.rfind("/") + 1 : img_src.rfind(".")]
    markdown = markdown.replace(i, "![%s](%s)" % (img_alt, img_src[5:-1]))

# <pre>
codes = re.findall(r"<pre [A-Za-z0-9:;= \"\-]+>", markdown)
markdown = markdown.replace("</pre>", "\n```")
for c in codes:
    language = re.search(r"language=\"[A-Za-z]+\"", c)
    if language is None:
        language = "plain"
        logging.warning("Unknown language: %s" % c)
        language = language.group()[10:-1]
        language = "plain" if language in ["generic", "raw"] else language

    markdown = markdown.replace(c, "```%s\n" % language)

# LaTeX
markdown = markdown.replace("\displaystyle", "")
latex = re.findall(
    r"<span class='MathJax_Preview'>[A-Za-z0-9&!_^=,;<> \[\]\|\\\(\)\{\}\+\-\*\/\.]+</span>",
if latex:
    for l in latex:
        equation = l[32:-9]
        markdown = markdown.replace(l, "$%s$" % equation.strip())
    # markdown = markdown.replace("\n$", "\n$$").replace("$\n", "$$\n")

# Dump
with open(sys.argv[1], "w") as fp:

Keep URLs Unchanged

After transitioning to Hugo, another crucial issue is ensuring that the previous URLs remain as unchanged as possible. On one hand, the new URLs of posts should stay consistent with those in WordPress. In my situation, I incorporated the following settings into the Hugo configuration.

    posts: '/:year/:month/:slug/'
    projects: '/project/:slug/'
    posts: '/blog'
    projects: '/projects'

On the other hand, static files like images and videos stored in “/wp-content/uploads” should be redirected to the new URL. Therefore, I included the following settings in Nginx with a 301 HTTP redirection.

server {
    listen       443;
    server_name  infinitescript.com;
    rewrite      ^/wordpress/wp-content/uploads/(.*) /$1 permanent;

Deployment and Loading Time

Upon transitioning to Hugo, the loading time, as indicated by Service Status, exhibited a notable decrease from 300 ms to 100 ms.

For international access, the deployment is on GitHub Pages. In the case of China, it is deployed on UpYun. The deployment on GitHub and UpYun can be handled automatically with GitHub Actions after new push.

name: Deploy Hugo site to Pages

  # Runs on pushes targeting the default branch
      - master

  # Allows you to run this workflow manually from the Actions tab

# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
  contents: read
  pages: write
  id-token: write

# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
  group: "pages"
  cancel-in-progress: false

# Default to bash
    shell: bash

  # Build job
    runs-on: ubuntu-latest
      HUGO_VERSION: 0.121.0
      - name: Install Hugo CLI
        run: |
          wget -O ${{ runner.temp }}/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb \
          && sudo dpkg -i ${{ runner.temp }}/hugo.deb                    
      - name: Install Dart Sass
        run: sudo snap install dart-sass
      - name: Checkout
        uses: actions/checkout@v4
          submodules: false
      - name: Init submodules with HTTPS
        run: |
          sed -i "s|git@github.com:|https://github.com/|" .gitmodules \
          && git submodule init \
          && git submodule update          
      - name: Init Git LFS
        run: |
          sudo apt-get install git-lfs \
          && git lfs install \
          && git lfs pull          
      - name: Setup Pages
        id: pages
        uses: actions/configure-pages@v4
      - name: Install Node.js dependencies
        run: "[[ -f package-lock.json || -f npm-shrinkwrap.json ]] && npm ci || true"
      - name: Build with Hugo
          # For maximum backward compatibility with Hugo modules
          HUGO_ENVIRONMENT: production
          HUGO_ENV: production
        run: |
          hugo \
            --gc \
            --minify \
            --baseURL "https://www.infinitescript.com/"                    
      - name: Upload artifact
        uses: actions/upload-pages-artifact@v2
          path: ./public
      - name: Deploy to UpYun
        uses: her-cat/upyun-deployer@v1.0.3
          bucket: ${{ secrets.UPYUN_BUCKET }}
          operator: ${{ secrets.UPYUN_OPERATOR_NAME }}
          password: ${{ secrets.UPYUN_OPERATOR_PWD }}
          dir: 'public'

  # Deployment job
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    needs: build
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v3