When running a personal blog, you might encounter this requirement: wanting your website to support multiple languages so that readers from different regions can easily read your content.

This is where website internationalization (also known as i18n) comes into play.

For blogs built with Hexo, implementing internationalization requires consideration not only of content translation but also template rendering and other issues.

  • This article primarily uses the hexo-generator-plus plugin.

    kiwirafe/hexo-generator-plus - GitHub

    Before starting the configuration, please ensure you have uninstalled the following plugins to avoid conflicts:

    • hexo-generator-archive
    • hexo-generator-category
    • hexo-generator-index
    • hexo-generator-tag
  • This article uses the Pug templating language.

  • For the language switching solution in the navigation bar, I have only implemented bilingual logic.

Basic Configuration

To avoid confusion:

  1. The _config.yml in the Hexo root directory will be referred to as Hexo Configuration

  2. themes/**/_config.yml will be referred to as Theme Configuration

First, we need to make some basic settings in Hexo's configuration file. These settings will determine the website's language options and URL structure.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
language: [zh, en]  # Supported language list, first one is default
new_post_name: :title.md # New article naming convention

# If you have hexo-abbrlink installed
permalink: posts/:abbrlink.html # Article permalink format
abbrlink:
rep: hex # Use hexadecimal as unique identifier

# hexo-generator-plus configuration
generator_plus:
language: [zh, en] # Generator supported languages
pagination_dir: 'page' # Pagination directory
generator: ["index", "archive", "category", "tag"] # Pages to generate

# Index generator configuration
index_generator:
per_page: 10 # These numbers and order can be customized
order_by: -date

# Archive page configuration
archive_generator:
per_page: 25
order_by: -date

# Category page configuration
category_generator:
per_page: 25
order_by: -date
enable_index_page: true # If you want a category index page

# Tag page configuration
tag_generator:
per_page: 25
order_by: name
enable_index_page: true # If you want a tag index page

Then configure the desired menu links in the theme configuration:

1
2
3
4
5
6
7
8
menu: 
home: /
archive: /archives
categories: /categories
tags: /tags
about: /about
GitHub: https://github.com/cytrogen
RSS: /atom.xml

Directory Structure

Here is the required directory structure:

1
2
3
4
5
6
7
8
9
10
11
source/
├── _posts/ # Default language blog posts
│ └── *.md # No subdirectories allowed
├── en/ # English-specific content
│ └── Same structure as source directory
├── archives/ # Archive page
│ └── index.md # layout: archive
├── categories/ # Category page
│ └── index.md # layout: category-index
└── tags/ # Tag page
└── index.md # layout: tag-index

Of course, you can choose other languages, but other language directories need to match the names in themes/**/languages/*.yml.

Please ensure each *.md file has lang: ** in its Front-Matter.

Language File Configuration

Fixed website text (such as navigation menus, button text, etc.) needs to be internationalized through language files. These files should be placed in the themes/**/languages/ directory.

Here's my examples:

  1. zh.yml:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    menu:
    home: 首页
    archive: 归档
    tags: 标签
    categories: 分类
    about: 关于
    friendlinks: 友情链接
    archive_title: 归档
    tags_title: 标签
    categories_title: 分类
    prev: 上一页
    next: 下一页
    prev_post: 上一篇
    next_post: 下一篇
    more: ...阅读全文
    translated: 翻译 · 原文地址
  2. en.yml:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    menu:
    home: HOME
    archive: ARC
    tags: TAGS
    categories: CATE
    about: ABOUT
    friendlinks: Friend Links
    archive_title: Archive
    tags_title: Tags
    categories_title: Categories
    prev: PREV
    next: NEXT
    prev_post: PREV POST
    next_post: NEXT POST
    more: ...MORE
    translated: Translate · Original Link

Template File Implementation

From here on, I'll only write about the solution used for my blog website.

Please modify according to your own theme.

Category Page Templates

Category pages need two templates: category index page and specific category page.

  1. Category list page (category-index.pug):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    extends partial/layout

    block container
    .archive
    // Title content prioritizes page.title
    // If it doesn't exist, use the i18n function __() to get categories_title translation
    h2.archive-title= page.title || __('categories_title')
    .category-list
    // Get all category data
    each category in get_categories().data
    // Calculate number of posts matching current language for each category
    - var postCount = category.posts.data ? category.posts.data.filter(post => post.lang === page.lang).length : 0
    if postCount > 0
    .category-item
    // Each category shows as a link, including category name and post count
    // url_for_lang() generates multilingual-supported URL
    - var categoryPath = category.slug || category.name
    a.post-title-link(href=url_for_lang(page.lang, 'categories/' + categoryPath))
    = category.name
    span.category-count= ` (${postCount})`
  2. Specific category page (category.pug):

    1
    2
    3
    4
    5
    6
    7
    extends partial/layout

    block container
    include mixins/post
    .archive
    h2.archive-title= page.category
    +postList()

    Here, post-related functionality is encapsulated in a series of mixins for reuse across different pages (mixins/post.pug):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    mixin postInfo(item)
    .post-info
    != full_date(item.date, 'l')
    // If post has from property and current page is home or post page
    if item.from && (is_home() || is_post())
    // Show a link indicating post translation source
    a.post-from(href=item.from target="_blank" title=item.from)!= __('translated')

    mixin posts()
    ul.home.post-list
    // Iterate through all posts
    - for (var post of page.posts.data || [])
    // Only show posts matching current page language
    - if (post.lang == page.lang)
    li.post-list-item
    article.post-block
    h2.post-title
    a.post-title-link(href= url_for(post.path))
    != post.title
    +postInfo(post)
    // If there's an excerpt, show it with "read more" link
    if post.excerpt
    .post-content
    != post.excerpt
    a.read-more(href= url_for(post.path))!= __('more')
    else
    .post-content
    != post.content

Tag Page Templates

Almost identical to category page templates:

  1. tag-index.pug:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    extends partial/layout

    block container
    include mixins/post
    .archive
    h2.archive-title= page.title || __('tags_title')
    .tag-list
    each tag in get_tags().data
    - var postCount = tag.posts.data ? tag.posts.data.filter(post => post.lang === page.lang).length : 0
    if postCount > 0
    .tag-item
    - var tagPath = tag.slug || tag.name
    a.post-title-link(href=url_for_lang(page.lang, 'tags/' + tagPath))
    = tag.name
    span.tag-count= ` (${postCount})`

    block pagination
    include mixins/paginator
    +home()

    block copyright
    include partial/copyright
  2. tag.pug:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    extends partial/layout

    block container
    include mixins/post
    .archive
    h2.archive-title= page.tag
    +postList()

    block pagination
    include mixins/paginator
    +home()

    block copyright
    include partial/copyright

Archive Page Template

Archive page only needs one archive.pug:

1
2
3
4
5
6
7
8
9
10
11
12
extends partial/layout

block container
include mixins/post
+postList()

block pagination
include mixins/paginator
+home()

block copyright
include partial/copyright

Its mixin:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
mixin postList()
.archive
// Check if there are posts
if page.posts
// Ensure post list exists and is not empty
- var posts = page.posts.data || page.posts
if posts && posts.length
// Create a years object for grouping
// Only process posts matching current page language
// Get year from post date
// Add posts to corresponding year array
- var years = {}
- for (var post of posts)
- if (post.lang == page.lang)
- var year = new Date(post.date).getFullYear()
- if (!years[year]) years[year] = []
- years[year].push(post)
// Sort years in descending order (show largest year first)
- Object.keys(years).sort((a, b) => b - a).forEach(function(year) {
h2.archive-year!= year
- years[year].forEach(function(post) {
.post-item
+postInfo(post)
a.post-title-link(href= url_for(post.path))
!= post.title
- })
- })

The navigation bar is the key interface for language switching (nav.pug).

Since my blog theme's navigation bar isn't wide, I wrote top and bottom sections to separate some links:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
ul.nav.nav-list
// Top section
div.nav-list-top
// Iterate through all keys and values in theme menu config
each path, key in theme.menu
// Exclude links to be placed at bottom, in my case GitHub and RSS
if key !== 'GitHub' && key !== 'RSS'
li.nav-list-item
// Check if external link
// If yes, open in new tab
// Add /en prefix for English pages
// Check if current page is active (for highlighting)
- var re = /^(http|https):\/\/*/gi;
- var tar = re.test(path) ? "_blank" : "_self"
- var fullPath = page.lang === 'en' ? '/en' + path : path
- var act = !re.test(path) && "/" + page.current_url === fullPath
a.nav-list-link(class={active: act} href=url_for(fullPath) target=tar)
!= __(('menu.' + key))
// Bottom section
div.nav-list-bottom
// Language switch button
li.nav-list-item.lang-switch
if page.lang == 'en'
a.nav-list-link(href=url_for('/')) 中文
else
a.nav-list-link(href=url_for('/en')) ENGLISH

Usage

After completing the above configuration, specify the language in the Front-Matter when creating new articles:

1
2
3
4
5
---
title: Article Title
date: 2024-01-01
lang: en
---

If you want to create versions of the same article in other languages, just create a new Markdown file, specify the appropriate lang, and link to the original article using the from field in the Front-Matter:

1
2
3
4
5
6
---
title: 文章标题
date: 2024-01-01
lang: zh
from: /posts/original-post.html
---