Introduction

Creating a multilingual Hexo blog isn’t simple, but it’s not as hard as you might imagine! After a few days of tinkering, I’ve summarized a set of clear, understandable steps that should help you easily set up multiple languages. This tutorial focuses particularly on the Butterfly theme, but it will likely also apply to other themes. Whether you’re a Hexo beginner or have been experimenting for a while, this guide should provide some help. Let’s get started!

The method used on this site involves duplicating the entire site and placing it in the output folder. The benefit is a clearer structure, while the downside is that changes can be more cumbersome to apply.

Below is the main directory structure:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Configuration files
your_hexo_blog
├── _config.yml <-- default
├── _config-cn.yml
├── _config-fr.yml
├── _config-jp.yml

// Theme configuration files
├── _config.butterfly.yml <-- default
├── _config.butterfly-cn.yml
├── _config.butterfly-fr.yml
├── _config.butterfly-jp.yml

// Main pages folder
├── source <-- default
├── source-cn
├── source-fr
├── source-jp

// New scripts folder
├── scripts <-- scripts
├── config-debug.js
├── change_path.js

Below are some optional files to modify:

1
2
3
4
5
6
7
(basically your theme folder)
hexo-theme-butterfly/layout/includes
├── footer.pug
├── head.pug
└── third-party
└── comments
└── twikoo.pug

The tools I used are:

  • Cursor Agent (Claude 3.5 Sonnet)
  • Cursor Chat (GPT-4o)

Modifying the config files

Add new _config-(language code).yml files.

For example, for Chinese:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Site
language: zh-CN

# URL
## Set your site url here. For example, if you use GitHub Page, set url as 'https://username.github.io/project'
url: https://example.com/cn
root: /cn/

# Directory
source_dir: source-cn
public_dir: cn

# Include / Exclude file(s)
## include:/exclude: options only apply to the 'source/' folder
include:
exclude:
ignore:
- source-jp/
- source-fr/
- source/

Keep other parts unchanged. The main modifications are the basic settings.

When you run hexo s --config _config-cn.yml, you will access localhost:4000/cn/, so you need to adjust the url and root accordingly.

Modifying theme configuration

I’m using Butterfly 4.13. Although versions 5.0.0 and later made some adjustments to the configuration files, these changes should not significantly affect this tutorial. If you’re using a newer version, refer to the official documentation for any necessary adjustments.

Add _config.butterfly-(language code).yml.

For example, for Chinese:

1
2
3
4
5
6
7
8
9
10
11
# Change the Menu to Chinese
menu:
# Article[/article]
# List[/list]
# Link[/link]
# About[/overview]
文章:
中文: /tags/
中文: /archives/
中文: /analysis/
中文: /sitemap/

Keep other parts unchanged. The main modifications are basic configurations.

Copying the source folder

Duplicate the source folder and rename it to source-(language code).

Use Cursor’s Composer Agent function to maintain the structure and translate all files under source. I had 64 files total, and the translation took about 20 minutes. Occasionally, you need to confirm manually, but overall, the experience is fine.

Adding the scripts folder

Create a scripts folder in the root directory and add config-debug.js and change_path.js.

config-debug.js matches the site configuration file with the theme configuration file (though this solution might not be optimal, it works until we find a better automatic matching method for Hexo configs). change_path.js handles file paths. Both scripts were written by Claude and are usable.

config-debug.js

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
36
37
38
39
40
41
42
'use strict';

hexo.extend.filter.register('before_generate', async () => {
// Check if --config parameter is used
const configArg = process.argv.find(arg => arg.startsWith('_config-'));
if (!configArg) return;

// Extract language code
const langMatch = configArg.match(/config-([a-z]{2,})\.yml$/);
if (!langMatch) return;

const lang = langMatch[1];
const themeConfigPath = `_config.butterfly-${lang}.yml`;

// Use Promise to ensure complete loading of the configuration
await new Promise((resolve, reject) => {
try {
const yaml = require('js-yaml');
const fs = require('fs');

// Read file content
const fileContent = fs.readFileSync(themeConfigPath, 'utf8');

// Parse YAML
const themeConfig = yaml.load(fileContent);

// Validate the configuration
if (!themeConfig || typeof themeConfig !== 'object') {
throw new Error('Invalid theme config format');
}

// Apply the configuration
hexo.theme.config = themeConfig;
console.log(`Theme config loaded successfully: ${themeConfigPath}`);

resolve();
} catch (e) {
console.error(`Failed to load theme config: ${themeConfigPath}`, e);
reject(e);
}
});
});

change_path.js

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
// scripts/resource-path-handler.js
'use strict';

hexo.extend.filter.register('after_render:html', function(str, data) {
// Get the config file name from command line arguments
const configFile = process.argv.find(arg => arg.includes('config-') && arg.endsWith('.yml'));
if (!configFile) return str;

// Extract language code
const langMatch = configFile.match(/config-([a-z]{2,})\.yml$/);
if (!langMatch) return str;

const lang = langMatch[1];
if (lang === 'default') return str;

// Paths to exclude
const excludePaths = [
'/search.xml',
'/sitemap.xml',
'/robots.txt',
'/feed.xml',
'http://',
'https://',
'data:image',
'//',
'ws://',
'wss://'
];

// Process HTML static resource paths
let result = str;

// General resource path processing rule
const processPath = (match, p1, p2, p3) => {
// Check if in the exclude list
if (excludePaths.some(exclude => p2.startsWith(exclude))) {
return match;
}
// Check if the path already contains the language prefix
if (p2.startsWith(`/${lang}/`)) {
return match;
}
// Ensure the path starts with / and is not a relative path
if (p2.startsWith('/') && !p2.startsWith('//')) {
return `${p1}/${lang}${p2}${p3}`;
}
return match;
};

// Handle all possible resource references
const patterns = [
// src attribute (scripts, images, videos, audios, etc.)
{
pattern: /(src=["'])(\/[^"']+)(["'])/g
},
// href attribute (stylesheets, links, etc.)
{
pattern: /(href=["'])(\/[^"']+)(["'])/g
},
// data-url attribute
{
pattern: /(data-url=["'])(\/[^"']+)(["'])/g
},
// content attribute (meta tags)
{
pattern: /(content=["'])(\/[^"']+)(["'])/g
},
// Inline styles with URL
{
pattern: /(url\(["']?)(\/[^"')]+)(["']?\))/g
}
];

// Apply all patterns
patterns.forEach(({ pattern }) => {
result = result.replace(pattern, processPath);
});

return result;
});

// Register helper function to manually control resource paths in templates
hexo.extend.helper.register('langPath', function(path) {
if (!path || typeof path !== 'string') return path;

const configFile = process.argv.find(arg => arg.includes('config-') && arg.endsWith('.yml'));
if (!configFile) return path;

const langMatch = configFile.match(/config-([a-z]{2,})\.yml$/);
if (!langMatch) return path;

const lang = langMatch[1];
if (lang === 'default') return path;

// Check if it's an excluded path
const excludePaths = [
'/search.xml',
'/sitemap.xml',
'/robots.txt',
'/feed.xml',
'http://',
'https://',
'data:image',
'//',
'ws://',
'wss://'
];

if (excludePaths.some(exclude => path.startsWith(exclude))) {
return path;
}

// Check if path already includes the language prefix
if (path.startsWith(`/${lang}/`)) {
return path;
}

return path.startsWith('/') ? `/${lang}${path}` : path;
});

Optional Content

The following components are mainly for optimizing user experience and can be used as needed:

This is the footer component, mainly used to display copyright, ICP filings, blogrolls, etc. The language switch button can be placed in footer.pug or navbar.pug. For a better user experience, you could implement a JS script: if the user’s browser language is different and a matching language version exists, preload it. If it’s not 404, show a popup asking the user to switch languages.

head.pug

This is mainly to add language link tags for search engines. Currently, it’s only done for the homepage. In theory, you could write this into the configuration file. To save effort, I did it this way. It works as is.

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
if is_home()
- var fullUrl = config.url
- var baseUrl = fullUrl.replace(/\/(cn|jp|fr)$/, '') // Remove language suffix to get the base URL
- var currentLang = fullUrl.match(/\/(cn|jp|fr)$/) ? fullUrl.match(/\/(cn|jp|fr)$/)[1] : 'en' // Get current language

if currentLang === 'cn'
link(rel="alternate" hreflang="zh-CN" href=`${baseUrl}/cn`)
link(rel="alternate" hreflang="en" href=baseUrl)
link(rel="alternate" hreflang="ja-JP" href=`${baseUrl}/jp`)
link(rel="alternate" hreflang="fr" href=`${baseUrl}/fr`)
link(rel="alternate" hreflang="x-default" href=baseUrl)
else if currentLang === 'jp'
link(rel="alternate" hreflang="ja-JP" href=`${baseUrl}/jp`)
link(rel="alternate" hreflang="en" href=baseUrl)
link(rel="alternate" hreflang="zh-CN" href=`${baseUrl}/cn`)
link(rel="alternate" hreflang="fr" href=`${baseUrl}/fr`)
link(rel="alternate" hreflang="x-default" href=baseUrl)
else if currentLang === 'fr'
link(rel="alternate" hreflang="fr" href=`${baseUrl}/fr`)
link(rel="alternate" hreflang="en" href=baseUrl)
link(rel="alternate" hreflang="zh-CN" href=`${baseUrl}/cn`)
link(rel="alternate" hreflang="ja-JP" href=`${baseUrl}/jp`)
link(rel="alternate" hreflang="x-default" href=baseUrl)
else
link(rel="alternate" hreflang="en" href=baseUrl)
link(rel="alternate" hreflang="zh-CN" href=`${baseUrl}/cn`)
link(rel="alternate" hreflang="ja-JP" href=`${baseUrl}/jp`)
link(rel="alternate" hreflang="fr" href=`${baseUrl}/fr`)
link(rel="alternate" hreflang="x-default" href=baseUrl)

twikoo.pug

This is mainly for comment integration.

For example:

By modifying the twikoo.pug configuration, you can show the original post’s comments on the Chinese version, thus sharing comment data across languages. Visitors will see the complete comment history no matter which language version of the article they view.

Here is the twikoo.pug configuration file. Just copy and paste:

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
- const { envId, region, option } = theme.twikoo
- const { use, lazyload, count } = theme.comments
- const multi_lang = theme.twikoo && theme.twikoo.multi_lang_prefix_handling === true
- const lang_prefixes = multi_lang ? theme.twikoo.lang_prefixes : null

script.
(() => {
const getCount = () => {
const countELement = document.getElementById('twikoo-count')
if(!countELement) return
twikoo.getCommentsCount({
envId: '!{envId}',
region: '!{region}',
urls: [window.location.pathname],
includeReply: false
}).then(res => {
countELement.textContent = res[0].count
}).catch(err => {
console.error(err)
})
}

const init = () => {
// Get processed path
let path = window.location.pathname
if (!{multi_lang} && !{JSON.stringify(lang_prefixes)}) {
const prefixes = !{JSON.stringify(lang_prefixes)}
if (prefixes && prefixes.length > 0) {
const langRegex = new RegExp(`^/(${prefixes.join('|')})/`)
path = path.replace(langRegex, '/')
}
}

twikoo.init(Object.assign({
el: '#twikoo-wrap',
envId: '!{envId}',
region: '!{region}',
path: path,
onCommentLoaded: () => {
btf.loadLightbox(document.querySelectorAll('#twikoo .tk-content img:not(.tk-owo-emotion)'))
}
}, !{JSON.stringify(option)}))

!{count ? 'GLOBAL_CONFIG_SITE.isPost && getCount()' : ''}
}

const loadTwikoo = () => {
if (typeof twikoo === 'object') setTimeout(init,0)
else getScript('!{url_for(theme.asset.twikoo)}').then(init)
}

if ('!{use[0]}' === 'Twikoo' || !!{lazyload}) {
if (!{lazyload}) btf.loadComment(document.getElementById('twikoo-wrap'), loadTwikoo)
else loadTwikoo()
} else {
window.loadOtherComment = loadTwikoo
}
})()

You need to modify the twikoo configuration in each _config.butterfly-(language code).yml file. Set multi_lang_prefix_handling to true.

1
2
3
4
5
6
7
8
9
10
11
12
# Twikoo
# https://github.com/imaegoo/twikoo
twikoo:
envId:
region:
visitor: false
multi_lang_prefix_handling: true
lang_prefixes:
- cn
- fr
- jp
option:

Summary

At this point, we’ve completed all the configuration work for the multilingual blog.

To test generating static files for all language versions, run the following command:

1
hexo clean && hexo g && hexo clean  --config _config-cn.yml && hexo g --config _config-cn.yml && hexo clean  --config _config-fr.yml && hexo g --config _config-fr.yml && hexo clean  --config _config-jp.yml && hexo g --config _config-jp.yml 

You can put this in package.json and then deploy each language version to your server using hexo d as needed.

After running the above commands, simply deploy each language version of the content to the server according to its configuration file. Now you’ve successfully launched a multilingual Hexo blog!

If you use frameworks like Next.js, configuring multilingual support is even simpler…

References:

Cover From Internet