My Hexo-based blog has been around for years, with many themes and plugins available, and it’s been quite comfortable to use. However, I recently decided to migrate it away from Hexo, for the following reasons:
- Lack of control over the blog; adding new features often requires waiting for Hexo updates or theme author updates.
- Simply wanting to tinker around (which is probably the main reason).
Ultimately, I decided to migrate to a Next.js solution. It’s a full-stack approach that can function as both a purely static site and a regular website with backend services, which perfectly aligns with my needs. Furthermore, it offers much greater control over the website compared to Hexo, though this naturally comes at the cost of more time investment.
Goals
Migration goals:
- Keep existing blog post links unchanged.
- Migrate the comment system: The original Disqus system has very poor usability within mainland China.
Backup
Backup: I used jiacai2050/blog-backup: Backup blogposts to PDF for offline storage, built with Puppeteer and ClojureScript (github.com) to back up the old blog posts as PDF files, as a memento.
Solution
Technology choices:
- Framework:
next.js
- Content generation:
contentlayer.js
- Styling:
tailwindcss
Hexo Markdown Syntax Compatibility
To ensure compatibility with some unique Hexo syntax, such as <!-- more -->
and {% asset_img me.jpg 搬家 %}
, I needed to develop remark
plugins.
<!-- more -->
Compatibility
For <!-- more -->
compatibility, I initially wrote a remark
plugin to find <!-- more -->
in Markdown files, split out the preceding part, and attach it to a custom variable (e.g., brief
), which could then be read during rendering.
However, I encountered some issues during implementation. I was getting unrendered Markdown files instead of rendered HTML. Since contentlayer
is used, the process of converting Markdown to HTML is controlled by contentlayer
, and you can add plugins there. But whether it was a plugin order issue or something else, I couldn’t get it right, so I simply abandoned this approach.
Instead, I directly added a field in contentlayer
to truncate the already rendered HTML file, taking the first 500 characters as the article’s brief
. While this offers less customization, it’s a working solution for now. The specific code is as follows:
/** @type {import('contentlayer/source-files').ComputedFields} */
const computedFields: import("contentlayer/source-files").ComputedFields = {
permalink: {
type: "string",
resolve: (doc) => {
...
},
brief: {
type: "string",
resolve: (doc) => {
// TODO: 使用 remark 插件来处理文章中 <!-- more --> 注释
const htmlContent = doc.body.html
return htmlContent.substring(0, 500)
},
},
readingTime: { type: "json", resolve: (doc) => readingTime(doc?.body?.raw) },
}
Image Inclusion Syntax Compatibility
Hexo uses many syntaxes like {% asset_img me.jpg 搬家 %}
to include images. Initially, I tried using a remark
plugin to convert these into standard Markdown syntax, then proceed with normal Markdown conversion. But in practice, there were always issues. The final output was still 
, not the expected <img />
tag.
My understanding was that I would first convert the non-standard Markdown syntax into standard syntax, and then render from standard syntax to HTML. However, I’m unsure what the problem was—perhaps plugin order, or a misunderstanding of remark
’s plugin principles—but it consistently failed to render into the final <img />
tag. The only way was to directly modify {% asset_img me.jpg 搬家 %}
to <img alt="搬家" src="/me.jpg" />
within the plugin, but I felt this wasn’t ideal, fearing it might affect other remark
plugin processing.
The original plugin code:
import { visit } from "unist-util-visit";
import fs from "fs";
import path from "path";
const targetImgDir = path.join(process.cwd(), "public/imgs");
const markdownPath = path.join(process.cwd(), "source/posts");
const isLoggingEnabled = true; // 设置为true以启用日志,为false则禁用日志
function log(message: string) {
if (isLoggingEnabled) {
console.log(message);
}
}
function handleAssetReference(
sourceFilePath: string,
imgFileName: string
): string | null {
// 获取当前Markdown文件的名称(不包括后缀)
const filenameWithoutExtension = path.basename(
sourceFilePath,
path.extname(sourceFilePath)
);
// 获取当前Markdown文件的路径
const mdDir = path.dirname(sourceFilePath);
// 根据文件名动态构建可能的图片路径
const possiblePaths = [
path.join(mdDir, filenameWithoutExtension, imgFileName),
path.join(mdDir, "../imgs", imgFileName),
];
let sourcePath: string | null = null;
for (const possiblePath of possiblePaths) {
if (fs.existsSync(possiblePath)) {
sourcePath = possiblePath;
break;
}
}
if (!sourcePath) {
log(`Image ${imgFileName} not found for file ${sourceFilePath}`);
return null;
}
// 复制图片到 public/imgs 文件夹下的相应目录
const targetPath = path.join(
targetImgDir,
filenameWithoutExtension,
imgFileName
);
fs.mkdirSync(path.dirname(targetPath), { recursive: true }); // 确保目录存在
fs.copyFileSync(sourcePath, targetPath);
log(`Copied image from ${sourcePath} to ${targetPath}`);
// 返回新的相对路径
return `/imgs/${filenameWithoutExtension}/${imgFileName}`;
}
function replaceAssetImgPlugin() {
return (tree: any, file: any) => {
visit(tree, "text", (node: any) => {
// 处理自定义 asset_img 语法
const customAssetImgPattern = /\{% asset_img (.*?)\s+(.*?) %\}/g;
node.value = node.value.replace(
customAssetImgPattern,
(_, imgFileName, imgAltText) => {
return ``;
}
);
});
// 处理标准的 markdown 图像引用
visit(tree, "image", (node: any) => {
const sourceFilePath = path.join(
markdownPath,
file.data.rawDocumentData.flattenedPath
);
const imgFileName = path.basename(node.url);
const newURL = handleAssetReference(sourceFilePath, imgFileName);
if (newURL) {
node.url = newURL;
}
});
};
}
export default replaceAssetImgPlugin;
It did a few things:
- Found all custom image reference syntaxes and parsed out the image names.
- Located the specific image files from a given directory based on their names.
- Copied the found image files to the
public/imgs/
directory, then modified the custom syntax to standard Markdown image reference syntax, and updated the image paths to the copied paths to ensurenext.js
could access these images correctly.
But this path didn’t work out. Finally, I thought, why go through all this trouble? Since I’m already migrating away from Hexo, its original non-standard syntax no longer serves a purpose, so I might as well just replace it directly. So I wrote a script that directly modified all non-standard image reference syntaxes in the Markdown files within the directory to standard syntax and replaced the image paths. This was a permanent solution. The script code is as follows:
const fs = require("fs");
const path = require("path");
// 定义目标图片文件夹路径
const targetImgDir = path.join(process.cwd(), "public/imgs");
// 定义 Markdown 文件路径
const markdownPath = path.join(process.cwd(), "source/posts");
function handleAssetReference(sourceFilePath, imgFileName) {
// 获取当前Markdown文件的名称(不包括后缀)
const filenameWithoutExtension = path.basename(
sourceFilePath,
path.extname(sourceFilePath)
);
// 获取当前Markdown文件的路径
const mdDir = path.dirname(sourceFilePath);
// 定义可能的图片路径数组
const possiblePaths = [
path.join(mdDir, filenameWithoutExtension, imgFileName),
path.join(mdDir, "../imgs", imgFileName),
];
let sourcePath = null;
// 遍历所有可能的图片路径,找到存在的图片路径
for (const possiblePath of possiblePaths) {
if (fs.existsSync(possiblePath)) {
sourcePath = possiblePath;
break;
}
}
// 如果没有找到图片路径,打印错误信息
if (!sourcePath) {
console.log(`Image ${imgFileName} not found for file ${sourceFilePath}`);
return imgFileName;
}
// 定义目标图片路径,并创建该路径的文件夹
const targetPath = path.join(
targetImgDir,
filenameWithoutExtension,
imgFileName
);
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
// 将源图片复制到目标路径
fs.copyFileSync(sourcePath, targetPath);
console.log(`Copied image from ${sourcePath} to ${targetPath}`);
// 返回新的图片URL
return `/imgs/${filenameWithoutExtension}/${imgFileName}`;
}
function processMarkdownFile(filePath) {
// 读取Markdown文件内容
let content = fs.readFileSync(filePath, "utf-8");
// 定义自定义图片引用的正则表达式
const customAssetImgPattern = /\{% asset_img (.*?)\s+(.*?) %\}/g;
// 替换Markdown文件中的自定义图片引用
content = content.replace(
customAssetImgPattern,
(_, imgFileName, imgAltText) => {
const newURL = handleAssetReference(filePath, imgFileName);
return ``;
}
);
// 将处理后的内容写回文件
fs.writeFileSync(filePath, content);
console.log(`Processed file: ${filePath}`);
}
function processMarkdownFilesInDir(dir) {
// 读取目录下的所有文件
const files = fs.readdirSync(dir);
for (const file of files) {
const filePath = path.join(dir, file);
// 如果是Markdown文件,则处理该文件
if (path.extname(filePath) === ".md") {
processMarkdownFile(filePath);
}
}
}
// 处理指定目录下的所有Markdown文件
processMarkdownFilesInDir(markdownPath);
Content Retrieval
I adopted a new solution (what a pitfall, I didn’t know it was unmaintained when I migrated, only found out right after): Getting Started – Contentlayer. It converts content into type-safe JSON files, making it easy to import
and use within the system.
Features:
- Validate file format: Standardize fields by pre-declaring required
frontMeta
fields.
// contentlayer.config.ts
import { defineDocumentType, makeSource } from "contentlayer/source-files";
export const Post = defineDocumentType(() => ({
name: "Post",
filePathPattern: `**/*.md`,
fields: {
title: { type: "string", required: true },
date: { type: "date", required: true },
},
computedFields: {
url: {
type: "string",
resolve: (post) => `/posts/${post._raw.flattenedPath}`,
},
},
}));
export default makeSource({ contentDirPath: "posts", documentTypes: [Post] });
It was quite useful. It essentially takes non-standard Markdown files, defines them with a pre-defined schema, and then generates TypeScript type definition files based on these schemas, allowing you to directly obtain type-safe document models in your code.
My final contentlayer
configuration file is as follows:
import { writeFileSync } from "fs"
import { defineDocumentType, makeSource } from "contentlayer/source-files"
import readingTime from "reading-time"
import rehypeAutolinkHeadings from "rehype-autolink-headings"
import rehypeCodeTitles from "rehype-code-titles"
import rehypeHighlight from "rehype-highlight"
import rehypePrism from "rehype-prism-plus"
import rehypeSlug from "rehype-slug"
import remarkGfm from "remark-gfm"
const root = process.cwd()
const isProduction = process.env.NODE_ENV === "production"
/**
* Count the occurrences of all tags across blog posts and write to json file
*/
function createTagCount(allBlogs) {
const tagCount: Record<string, number> = {}
// const slugger = new GithubSlugger()
allBlogs.forEach((file) => {
if (file.tags && (!isProduction || file.draft !== true)) {
file.tags.forEach((tag) => {
if (tag in tagCount) {
tagCount[tag] += 1
} else {
tagCount[tag] = 1
}
})
}
})
writeFileSync("./app/tag-data.json", JSON.stringify(tagCount))
}
/** @type {import('contentlayer/source-files').ComputedFields} */
const computedFields: import("contentlayer/source-files").ComputedFields = {
slug: {
type: "string",
// 返回当前文件所在目录的相对路径,比如 posts/2021-01-01-hello-world.md
resolve: (doc) => `/${doc?._raw?.flattenedPath}`,
},
slugAsParams: {
type: "string",
resolve: (doc) => {
// 返回当前文件所在目录的相对路径去除 posts 后的路径,比如 2021-01-01-hello-world.md
return doc?._raw?.flattenedPath?.split("/")?.slice(1)?.join("/")
},
},
permalink: {
type: "string",
resolve: (doc) => {
const date = new Date(doc.date)
const year = date.getFullYear()
// 确保月份和日期始终有两位数字
const month = (date.getMonth() + 1).toString().padStart(2, "0")
const day = date.getDate().toString().padStart(2, "0")
const slugAsParams = doc?._raw?.flattenedPath
?.split("/")
?.slice(1)
?.join("/")
return `/${year}/${month}/${day}/${slugAsParams}`
},
},
brief: {
type: "string",
resolve: (doc) => {
// TODO: 使用 remark 插件来处理文章中 <!-- more --> 注释
const htmlContent = doc.body.html
return htmlContent.substring(0, 500)
},
},
readingTime: { type: "json", resolve: (doc) => readingTime(doc?.body?.raw) },
}
export const Post = defineDocumentType(() => ({
name: "Post",
filePathPattern: `posts/**/*.md`,
contentType: "markdown",
fields: {
title: {
type: "string",
required: true,
},
description: {
type: "string",
},
date: {
type: "date",
required: true,
},
update: {
type: "date",
required: false,
},
tags: {
type: "list",
of: { type: "string" },
default: [],
},
category: {
type: "string",
required: true,
default: "",
},
image: {
type: "string",
required: false,
},
draft: {
type: "boolean",
required: false,
},
},
computedFields,
}))
export default makeSource({
contentDirPath: "./source",
documentTypes: [Post],
markdown: {
rehypePlugins: [
// 各种问题导致只能先把所有插件禁用掉了
// rehypeSlug,
// rehypeCodeTitles,
// @ts-ignore
// [rehypeHighlight],
// rehypeHighlight,
// [rehypePrism, { ignoreMissing: true }],
// [
// rehypeAutolinkHeadings,
// {
// properties: {
// className: ["anchor"],
// },
// },
// ],
],
// remarkPlugins: [remarkGfm],
},
onSuccess: async (importData) => {
const { allPosts } = await importData()
createTagCount(allPosts)
// createSearchIndex(allPosts)
},
})
With the next-contentlayer
plugin, I can directly use the post
type I defined in my next.js
application:
import { Post } from "contentlayer/generated"
export function PostList({
posts,
pageSize = 10,
currentPage = 1,
}: {
posts: Post[]
pageSize?: number
currentPage?: number
}) {
const currentPageList = getSpecificPagePosts(posts, {
page: currentPage,
pageSize,
})
……
}
Routing Structure
The overall routing structure is roughly as follows:
/[year]/[month]/[day]/[post]
/tag/
/tag/[tag]/
/tag/[tag]/page/[pageNum]
/category/
/category/[category]/
/category/[category]/page/[pageNum]
The article paths use the format /[year]/[month]/[day]/{blogName}
to maintain compatibility with Hexo’s permalinks.
Implementation idea:
If Hexo’s permalinks were used previously, then all articles will have a date
frontMeta
property. Based on this property, add a permalink
property to all posts in contentLayer
:
permalink: {
type: "string",
resolve: (doc) => {
const date = new Date(doc.date)
const year = date.getFullYear()
const month = date.getMonth() + 1
const day = date.getDate()
const slugAsParams = doc?._raw?.flattenedPath
?.split("/")
?.slice(1)
?.join("/")
console.log("permalink", `/${year}/${month}/${day}/${slugAsParams}`)
return `/${year}/${month}/${day}/${slugAsParams}`
},
},
The path returned by this property will be consistent with Hexo’s previous permalinks, similar to 2023/01/01/2022-year-end-summary/
.
Then, in your postList
, set all post jump addresses to this permalink
property:
<CardHeader>
<CardTitle className="m-0">
// 使用 permalink 属性
<Link href={post.permalink} className="no-underline">
{post.title}
</Link>
</CardTitle>
<CardDescription className="space-x-1 text-xs">
<span>{format(parseISO(post.date), "MMMM dd, yyyy")}</span>
<span>{` • `}</span>
<span>{post.readingTime.text}</span>
<span>{` • `}</span>
<span>
<Link
href={`/categories/${encodeURIComponent(
post.categories?.toLowerCase()
)}`}
className="underline underline-offset-2"
>
{post.categories}
</Link>
</span>
</CardDescription>
</CardHeader>
Then, create the corresponding routing structure: a file directory /[year]/[month]/[day]/[post]
, and create a page.jsx
in the innermost directory to render the article.
In terms of implementation, within next.js
, you need to create corresponding nested directories in the app
directory according to this routing structure. This is very cumbersome, especially since the tag
and category
related pages below are ultimately article lists similar to the homepage. However, using the directory structure above requires repeatedly referencing essentially duplicate content.
Why use this path format for pagination instead of URL parameters? It’s to enable the generation of static pages, so that after building, they can be deployed as a static blog, without requiring a server to run, similar to Hexo.
Comment System
There were three options at the time:
- giscus: https://github.com/giscus/giscus
- disqusJS: https://github.com/SukkaW/DisqusJS
- twikoo: https://github.com/twikoojs/twikoo
Ultimately, I chose giscus
. It’s relatively simple to use, and leveraging GitHub’s capabilities, the editing experience is excellent. The drawback is that you can’t comment without a GitHub account.
However, commenting itself isn’t a strict requirement, so addressing the basic functionality was the priority.
Deployment
Goal: Pushing code to the main branch of the repository should trigger automatic deployment.
Although I have my own server, I ultimately chose to deploy directly on Vercel
because it’s incredibly convenient. My blog traffic isn’t high, so Vercel
’s free tier is more than sufficient. I’ll integrate automated deployment to my own server when I have time.
Summary
I’ve been talking about refactoring my blog for almost a year, and kept putting it off. While the refactoring workload itself wasn’t huge, it required a calm mind and large blocks of time. I finally managed to complete the blog refactor, albeit barely, so I’ll give myself a pat on the back.
However, somewhat awkwardly, when I was trying to get the code highlighting feature to work, I found that the rehype-highlight
plugin always had issues. Later, when I checked contentlayer
’s documentation and issues, I discovered, well, that the repository is no longer maintained. The reason is understandable, of course, as a company that sponsored the project stopped its sponsorship. Losing financial backing, it’s reasonable for maintenance to be temporarily suspended. It just means I can’t find a solution to my problem, so I will eventually drop contentlayer
.
But when that will happen is anyone’s guess.
References
- 使用 Next.js + Hexo 重构我的博客 | Sukka’s Blog (skk.moe)
- SpectreAlan/blog-nextjs: 基于 nextjs 搭建的 SSR 个人博客 (github.com)
- 把博客从 GatsbyJS 迁移至 NextJS (kejiweixun.com)
- 從 Hexo 到 Gatsby - kpman | code
- 使用 next.js 重构博客 | liyang’s blog (liyangzone.com)
- enjidev/enji.dev: a monorepo for my personal website and projects, built with Turborepo + pnpm 📚 (github.com)
- github.com/mirsazzathossain/mirsazzathossain.me
- github.com/js-template/metablog-free
- stevenspads/next-app-router-blog: A Next.js markdown blog template for developers. It uses the new Next.js App Router. (github.com)
- shadcn/next-contentlayer: A template with Next.js 13 app dir, Contentlayer, Tailwind CSS and dark mode. (github.com)
This article was published on December 20, 2023 and last updated on December 20, 2023, 655 days ago. The content may be outdated.