介绍

多言語対応の Hexo ブログを構築するのは決して簡単ではありませんが、想像ほど難しくもありません!数日間格闘した末に、明確でわかりやすい手順をまとめてみました。このチュートリアルは主に Butterfly テーマを対象としていますが、他のテーマにも大抵は適用できるはずです。Hexo 初心者から、既にしばらく触っている上級者まで、このチュートリアルはきっとお役に立てるでしょう。それでは始めましょう!

本サイトの方法は、基本的に全サイトをコピーして出力用フォルダに配置する方式です。この方法の利点は構成が比較的わかりやすい点、欠点は変更がやや面倒な点です。

以下はディレクトリ構成の例です。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 配置ファイル
your_hexo_blog
├── _config.yml <-- デフォルト
├── _config-cn.yml
├── _config-fr.yml
├── _config-jp.yml

// テーマ設定ファイル
├── _config.butterfly.yml <-- デフォルト
├── _config.butterfly-cn.yml
├── _config.butterfly-fr.yml
├── _config.butterfly-jp.yml

// ページ用メインディレクトリ
├── source <-- デフォルト
├── source-cn
├── source-fr
├── source-jp

// 新しいスクリプト用フォルダ
├── scripts <-- スクリプトファイル
├── config-debug.js
├── change_path.js

以下は変更が必要なファイル(オプション)です。

1
2
3
4
5
6
7
(基本的にはテーマフォルダ内)
hexo-theme-butterfly/layout/includes
├── footer.pug
├── head.pug
└── third-party
   └── comments
   └── twikoo.pug

使用ツール:

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

configファイルの変更

_config-(言語コード).yml を新規作成してください。

中国語(zh-CN)の例:

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
## GitHub Page を使う場合は url を '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: は 'source/' フォルダにのみ適用
include:
exclude:
ignore:
- source-jp/
- source-fr/
- source/

他はそのままでOKです。ここでは主に基本的な設定を変更しました。

hexo s --config _config-cn.yml を実行した場合、localhost:4000/cn/ にアクセスすることになるので、urlroot を合わせて変更してください。

テーマ設定ファイルの変更

ここでは butterfly 4.13 バージョンを使用しています。5.0.0 以降のバージョンでは設定ファイルに若干の変更がありますが、本チュートリアルの内容への影響はさほどありません。新しいバージョンをお使いの場合は公式ドキュメントを参考にしてください。

_config.butterfly-(言語コード).yml を新規作成します。

中国語(zh-CN)の例:

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

他はそのままでOKです。ここでは主に基本設定を変更しました。

source フォルダのコピー

source フォルダをコピーし、source-(言語コード) とリネームしてください。

Cursor の Composer Agent 機能を使って、構造を保ちつつ source 以下のファイルをすべて翻訳することができます。私は合計64記事を20分ほどで翻訳しました。途中で手動で確認する必要がある場合もありますが、全体的な体験は良好でした。

scripts フォルダの追加

ルートディレクトリに scripts フォルダを追加し、config-debug.jschange_path.js を配置してください。

config-debug.js はサイト設定ファイルとテーマ設定ファイルをマッチングさせるためのものです(この方法は最適解ではないかもしれませんが、より良い Hexo の自動マッチング方法が見つかるまではこれで十分です)。change_path.js はファイルパスの処理と変更を担当します。これら二つのスクリプトは Claude が作成したもので、そのまま利用可能です。

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 () => {
// --config パラメータが使われているか確認
const configArg = process.argv.find(arg => arg.startsWith('_config-'));
if (!configArg) return;

// 言語コードを抽出
const langMatch = configArg.match(/config-([a-z]{2,})\.yml$/);
if (!langMatch) return;

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

// Promise を用いて設定が完全に読み込まれるまで待つ
await new Promise((resolve, reject) => {
try {
const yaml = require('js-yaml');
const fs = require('fs');

// ファイル読み込み
const fileContent = fs.readFileSync(themeConfigPath, 'utf8');

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

// 設定が有効か検証
if (!themeConfig || typeof themeConfig !== 'object') {
throw new Error('Invalid theme config format');
}

// 設定適用
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
// scripts/resource-path-handler.js
'use strict';

hexo.extend.filter.register('after_render:html', function(str, data) {
// コマンドライン引数から設定ファイル名を取得
const configFile = process.argv.find(arg => arg.includes('config-') && arg.endsWith('.yml'));
if (!configFile) return str;

// 言語コード抽出
const langMatch = configFile.match(/config-([a-z]{2,})\.yml$/);
if (!langMatch) return str;

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

// 除外すべきパス
const excludePaths = [
'/search.xml',
'/sitemap.xml',
'/robots.txt',
'/feed.xml',
'http://',
'https://',
'data:image',
'//',
'ws://',
'wss://'
];

let result = str;

// 汎用的なリソースパス処理関数
const processPath = (match, p1, p2, p3) => {
// 除外リストにあるかチェック
if (excludePaths.some(exclude => p2.startsWith(exclude))) {
return match;
}
// すでに言語プレフィックスが含まれているか確認
if (p2.startsWith(`/${lang}/`)) {
return match;
}
// パスが / で始まり、相対パスでないことを確認
if (p2.startsWith('/') && !p2.startsWith('//')) {
return `${p1}/${lang}${p2}${p3}`;
}
return match;
};

// 処理対象となるパターン
const patterns = [
// src属性 (スクリプト、画像、動画、音声など)
{
pattern: /(src=["'])(\/[^"']+)(["'])/g
},
// href属性 (スタイルシート、リンクなど)
{
pattern: /(href=["'])(\/[^"']+)(["'])/g
},
// data-url属性
{
pattern: /(data-url=["'])(\/[^"']+)(["'])/g
},
// content属性 (metaタグ)
{
pattern: /(content=["'])(\/[^"']+)(["'])/g
},
// インラインスタイル中のURL
{
pattern: /(url\(["']?)(\/[^"')]+)(["']?\))/g
}
];

// 全パターン適用
patterns.forEach(({ pattern }) => {
result = result.replace(pattern, processPath);
});

return result;
});

// テンプレート中で手動でリソースパスを制御するための helper 関数を登録
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;

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;
}

// すでに言語プレフィックスがあるかチェック
if (path.startsWith(`/${lang}/`)) {
return path;
}

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

オプション内容

以下のコンポーネントはユーザー体験改善用で、必要に応じて選択的に使用できます。

これはフッターコンポーネントで、著作権情報、ICP番号、フレンドリンクなどを表示するためのものです。言語切り替えボタンは footer.pug や navbar.pug に配置可能です。よりよい体験を求めるなら、JSでユーザーのブラウザ言語を判定して自動的に言語版に切り替えるか、ポップアップでユーザーに言語選択させるなど、より洗練された実装が可能です。

head.pug

ここでは多言語用の link タグを追加します。検索エンジンによる認識のためです。現在はトップページのみ対応しています。本当は設定ファイルに書いた方がいいですが、手間を省くためにこの方法を取っています。とりあえず動けばOKという感じです。

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)$/, '') // 言語サフィックスを除去して基本URLを取得
- var currentLang = fullUrl.match(/\/(cn|jp|fr)$/) ? fullUrl.match(/\/(cn|jp|fr)$/)[1] : 'en' // 現在の言語を取得

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

コメント融合のための設定です。

例:

twikoo.pug 設定を変更することで、中国語版の記事でも元記事のコメントを表示させ、コメントデータを共有できます。これにより、ユーザーはどの言語版であっても完全なコメント履歴を閲覧できます。

twikoo.pug の設定例(コピー&ペーストでOK):

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 = () => {
// 処理後のパスを取得
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
}
})()

_config.butterfly-(言語コード).ymltwikoo の設定を変更し、multi_lang_prefix_handlingtrue にしてください。

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:

まとめ

これで、多言語対応のブログを構築するためのすべての設定は完了です。

すべての言語バージョンの静的ファイルを生成するには、以下のコマンドを実行します。

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 

その後、package.json に記載して各自 hexo d を使ってサーバーにデプロイすればOKです。

以上のコマンドが完了したら、各言語バージョンを hexo d を用いてサーバーに配置すれば、多言語対応の Hexo ブログが完成します!

もし Next.js のようなフレームワークを使うなら、多言語対応はより簡単に実現できます。

参考:

Cover From Internet