📙

Hexoで多言語対応ブログを作成するための最適なアプローチ

2024/12/12に公開

介绍

多言語対応の Hexo ブログを構築するのは、決して簡単とは言えませんが、それほど難しいものでもありません!数日間試行錯誤を重ねた結果、シンプルで実用的な手順をまとめてみました。このチュートリアルは主に Butterfly テーマを対象としていますが、他のテーマにも概ね適用可能です。Hexo をこれから始める方から、既に使い慣れた方まで、このガイドはきっと役に立つはずです。それでは早速始めましょう!

本チュートリアルで紹介する方法は、基本的にサイト全体をコピーして出力用フォルダに配置する手法を採用しています。この方法の利点は、構成が比較的直感的であること、デメリットは変更が少し手間な場合があることです。

リザルト・ショーケース:

https://blog.closex.org/posts/757625ef/
https://blog.closex.org/jp/posts/757625ef/

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

// 配置ファイル
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

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

(基本的にはテーマフォルダ内)
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)の例:

# 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)の例:

# 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

'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

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

オプション内容

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

footer.pug

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

head.pug

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

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

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

例:

  • 元記事URL: example.com/posts/666.html (コメントあり)
  • 中国語版記事URL: example.com/cn/posts/666.html (デフォルトではコメントなし。コメントは記事URLに紐づくため)

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

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

- 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 にしてください。

# Twikoo
# https://github.com/imaegoo/twikoo
twikoo:
  envId: 
  region: 
  visitor: false
  multi_lang_prefix_handling: true
  lang_prefixes:
    - cn
    - fr
    - jp
  option:

まとめ

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

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

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 コマンドでサーバーにデプロイすれば完了です。

以上の手順を終えたら、各言語バージョンのサイトを hexo d でサーバーに公開することで、多言語対応の Hexo ブログが完成します!

なお、もし Next.js のようなフレームワークを活用する場合、多言語対応はさらに簡単に構築できるでしょう。

参考:

Cover From Internet

Discussion