Claude Media
Claude CodeでWordPressの翻訳漏れを拾う — make-potとja.poの突き合わせ

Claude CodeでWordPressの翻訳漏れを拾う — make-potとja.poの突き合わせ

wp i18n make-potで作ったPOTとja.poを突き合わせ、text domainの不一致・直書き文字列・不正なfuzzyを見つける手順です。手元で試した出力と、claude -pで直書きを探す書き方を載せます。

WordPressプラグインの翻訳漏れは、原因が3種類に分かれます。text domainが合っていない、そもそも__()を通っていない、ja.poに訳があってもfuzzyのまま使われない、の3つです。wp i18n make-potは前の2つを、ja.poとの突き合わせは3つ目を見つける入口になります。

この記事では、わざと漏れを仕込んだ小さなプラグインでmake-potとmsgmergeを実行し、手元で得た出力をそのまま示します。直書き文字列だけはmake-potが拾わないため、Claude Codeの非対話モード(claude -p)に探させ、結果をmake-potの出力で裏取りします。

検査用のサンプルとして漏れを仕込んだプラグイン

hello-notesというプラグインを用意しました。text domainはヘッダーでhello-notesと宣言しています。漏れは次の4か所です。

<?php
/**
 * Plugin Name: Hello Notes
 * Text Domain: hello-notes
 * Domain Path: /languages
 */
 
function hn_render() {
	echo '<h2>' . esc_html__( 'Saved notes', 'hello-notes' ) . '</h2>';
	echo '<p>No notes yet.</p>';                          // 直書き
	echo '<button>' . __( 'Add note', 'hello_notes' ) . '</button>'; // domain違い
	printf( __( 'Hello %s', 'hello-notes' ), 'x' );
}
<?php
function hn_admin() {
	add_menu_page( 'Notes', __( 'Notes', 'hello-notes' ),    // 第1引数が直書き
		'manage_options', 'hn' );
	echo esc_html__( 'Delete this note?', 'hello-notes' );
	echo esc_html( sprintf( _n( '%d note', '%d notes', 3, 'hello-notes' ), 3 ) );
}

ja.poは、原文の末尾の?や,が少しずれた古い訳を含めて作りました。実際のプラグインで起きる「原文を直したのに訳が追いついていない」状態の再現です。

text domainが食い違う経路

text domainは主に2か所に現れます。プラグインヘッダーのText Domainと、各翻訳関数の最後の引数です。ハンドブックによると、ドメインはプラグインのスラッグと一致させる決まりで、WordPress 4.6以降はヘッダーのText Domainを省略してもスラッグが使われます。

食い違いは、この2か所がどちらも手で書かれているために起きます。ヘッダーはhello-notesなのに関数側がhello_notes、フォルダー名を変えたのにヘッダーが旧名のまま、といった形です。wp i18n make-potはヘッダーの値を基準にするので、ヘッダー自体が誤っていると、正しい呼び出しのほうが抽出から外れます。

make-potが拾う文字列と拾わない文字列

ヘッダーと一致しない呼び出しは、エラーにならずに抽出から外れます。次のコマンドで確かめます。

wp i18n make-pot . languages/hello-notes.pot

実行すると、Plugin file detected.に続いて次の警告が出て、POTが生成されました(出力ヘッダーはX-Generator: WP-CLI 2.5.0でした。新しい版では警告の文面が異なる場合があります)。

Warning: The string "Hello %s" contains placeholders but has no
"translators:" comment to clarify their meaning. (hello-notes.php:12)
Warning: The string "%d note" contains placeholders but has no
"translators:" comment to clarify their meaning. (includes/admin.php:5)
Success: POT file successfully generated!

生成されたmsgidを、ソース上の記述と並べると次のようになります。

ソース上の文字列POTに出たか理由
__( 'Saved notes', 'hello-notes' )POTに出たか出た理由ドメインが一致
__( 'Add note', 'hello_notes' )POTに出たか出ない理由ドメインがアンダースコア違い
'<p>No notes yet.</p>'POTに出たか出ない理由翻訳関数を通っていない
add_menu_page( 'Notes', … )の第1引数POTに出たか出ない理由同上(同じNotesは別の__()経由で出る)
__( 'Dynamic domain', $d )POTに出たか出ない理由ドメインが変数
__( 'No domain' )POTに出たか出ない理由ドメイン省略
Plugin NameヘッダーPOTに出たか出た理由Plugin Name of the pluginとして抽出

Dynamic domainとNo domainの2行は、別ファイルで試した結果です。ドメインを変数や定数にしない点は、ドメインの付け方を説明するハンドブックにも注意として書かれています。make-potはどの場合も警告を出さず、黙って落とします。つまり、生成されたPOTを眺めても「漏れていること」自体は分かりません。

なお、vendor・node_modules・.git・*.min.jsなどは常に抽出の対象外です。自作コードをvendor配下に置いている構成では、--includeを使っても翻訳漏れの検査から外れる点に注意が要ります。

text domainの不一致は、ドメイン無視の抽出との差分で拾う

make-potには--ignore-domainがあり、text domainを問わずに抽出できます。通常の抽出結果との差が、ドメインのせいで落ちた文字列です。

wp i18n make-pot . /tmp/all.pot --ignore-domain --skip-audit
diff <(grep '^msgid "' languages/hello-notes.pot | sort -u) \
     <(grep '^msgid "' /tmp/all.pot | sort -u)
2a3
> msgid "Add note"

--skip-auditは翻訳者向けコメントの不足などを調べる監査を省くオプションです。差分の確認に警告は要らないので付けています。ここで出たAdd noteがhello_notesと書いた箇所です。ドメインをhello-notesに直せば差分は消えます。

差分に出た文字列が「関数側の誤り」か「ヘッダー側の誤り」かは、--domainで切り分けられます。--domain=hello_notesを付けて抽出すると、今度はAdd noteだけが残り、Saved notesなどが落ちました。落ちた側が少数なら、その少数のほうが書き間違いです。件数が逆転していれば、ヘッダーのほうを疑います。

ドメイン名はハンドブックでスラッグと一致させ、ダッシュ区切りの小文字にする決まりです。hello_notesのようなアンダースコアは、この規則に反しています。

警告は翻訳者向けコメントの不足も教えてくれる

先の実行で出た2件の警告は、プレースホルダ(%sや%d)を含む文字列にtranslators:コメントが無いことへの指摘です。これは翻訳漏れそのものではありませんが、翻訳者が%sの中身を取り違える原因になります。

--skip-auditは自動実行向けの用途として公式に説明されています。人が見るローカル実行では外しておくと、コメント不足が見つかります。

phpcsのWordPress.WP.I18nでも、翻訳関数のtext domainを検査できます。設定の置き方はClaude CodeのWordPress規約をphpcsのhookで直させる設定で扱っています。make-potはPOTの中身、phpcsは書き方を見る役割で、見る面が違います。

直書き文字列はmake-potの対象外なので、claude -pで探す

No notes yet.のような直書きは、翻訳関数の外にあるためmake-potの入力に現れません。必要なのは「利用者に見える文字列なのに翻訳関数を通っていないもの」の探索で、機械的な正規表現より、文脈を読める相手のほうが向いた作業です。

Claude Codeの非対話モードは、-pを付けて標準入力にデータを渡せます。--json-schemaを併用すると、結果をstructured_outputフィールドの配列として受け取れます。

git ls-files '*.php' | claude -p \
  "標準入力のPHPファイルをReadで読み、画面に表示される文字列のうち \
__()系の関数を通っていないものを、ファイル・行・文字列で挙げて。\
管理画面のメニュー名、echo内のHTMLテキスト、title属性も対象。ファイルは変更しない" \
  --allowedTools "Read" \
  --output-format json \
  --json-schema '{"type":"object","properties":{"hits":{"type":"array","items":{"type":"object","properties":{"file":{"type":"string"},"line":{"type":"integer"},"text":{"type":"string"}},"required":["file","line","text"]}}},"required":["hits"]}' \
  | jq -r '.structured_output.hits[] | "\(.file):\(.line)\t\(.text)"'

ここで押さえておく点が3つあります。

  • --allowedTools "Read"だけにして、編集やBashは許可しません。探索だけの実行に書き込み権限は要らないためです
  • structured_outputは、有効な出力が得られなかった実行では含まれません。スクリプトで使う前に、フィールドの有無を確かめる必要があります
  • 上のスキーマは例です。fileとlineとtextだけに絞ると、後段で扱いやすくなります

検索結果が正しいかは、make-potの出力で確かめられます。報告されたtextがすでにPOTのmsgidにあれば、それは翻訳関数を通っている誤検出です。逆にNo notes yet.やNotesの第1引数のようにPOTに無いものが、直すべき直書き候補になります。Claudeの報告を鵜呑みにせず、POTとの突き合わせを最後の判定にします。

再発を防ぐ規約はCLAUDE.mdに置く

直した後に同じ漏れを作らせないため、規約をCLAUDE.mdに書いておきます。内容は、ハンドブックにある基本の約束に絞ります。

## 国際化
- text domainは hello-notes に固定する。変数・定数は使わない
- 利用者に表示する文字列は、必ず __() 系にドメイン付きで通す
- プレースホルダを含む文字列の直前に /* translators: */ コメントを置く
- 変更後は bash bin/i18n-check.sh を実行し、0で終わることを確かめる

translators:コメントは、先の警告でmake-potが不足を指摘したものと同じです。コメントの書式はハンドブックで、直前の最後のPHPコメントとしてtranslators:で始めると説明されています。

ja.poの突き合わせで見る未訳とfuzzy

次に、新しいPOTを古いja.poへ反映します。msgmergeは、POTにある文字列を既存の訳に当てはめ、似た文字列があればfuzzy付きで引き継ぎます。

msgmerge --previous -o /tmp/merged.po \
  languages/hello-notes-ja.po languages/hello-notes.pot
msgfmt --statistics -o /dev/null /tmp/merged.po
2 translated messages, 3 fuzzy translations, 1 untranslated message.

未訳はNotesの1件、訳済みはSaved notesと%d noteの2件、残る3件がfuzzyです。fuzzyの中身はmsgattribで取り出せます。--previousを付けておくと、引き継ぎ元の原文が#|行に残ります。

msgattrib --only-fuzzy --no-wrap /tmp/merged.po
#. Plugin Name of the plugin
#, fuzzy
#| msgid "Hello, %s"
msgid "Hello Notes"
msgstr "こんにちは、%s"
 
#: hello-notes.php:12
#, fuzzy
#| msgid "Hello, %s"
msgid "Hello %s"
msgstr "こんにちは、%s"
 
#: includes/admin.php:4
#, fuzzy
#| msgid "Delete this note"
msgid "Delete this note?"
msgstr "このメモを削除"

3件のうち2件は、句読点がずれただけで訳をそのまま使えます。残る1件のHello Notesは事情が違います。プラグイン名なのに、Hello, %sの訳(こんにちは、%s)を引き継いでいます。このままfuzzyを外すと、プラグイン名にプレースホルダの%sが混ざった訳が出荷されます。

落とし穴

fuzzyを一括で外すと、似ているだけの別物が通る

msgmergeの曖昧一致は、文字列の似かたを見ているだけで、意味は見ていません。ここではHello NotesとHello, %sが似ているとして結び付けられました。fuzzyを機械的に外す前に、1件ずつ原文と訳を読み比べる必要があります。

曖昧一致を使いたくない場合は、msgmerge --no-fuzzy-matchingで止められます。同じja.poにこれを使うと、結果は2 translated messages, 4 untranslated messages.でした。誤った訳が混ざる代わりに、未訳が増えて見える形です。どちらを選ぶかは、レビューする人手の量との釣り合いになります。

Claudeに任せるのは、fuzzyの判定案まで

fuzzyを外すことは「人が確認した」という意味になるため、外すのは、人が確認した印として人の手で行う形になります。Claude Codeには、#|行つきのfuzzy項目を渡して、項目ごとに「そのまま使える」「直すべき」「別物」の判定案と理由を出させます。ja.poは書き換えさせません。

訳を埋める側の手順(プレースホルダを壊さない、msgfmtで形式を検査する、複数形のヘッダーに従う)は、Claude Codeでpoファイルを翻訳にまとめています。未訳の1件を埋める段階では、そちらの手順がそのまま使えます。

一連の検査を1本のスクリプトにまとめる

ここまでの確認を、終了コードで結果が分かる形にします。ドメイン不一致の疑いがあれば1で終わり、統計は常に表示します。

#!/usr/bin/env bash
set -euo pipefail
POT=languages/hello-notes.pot
PO=languages/hello-notes-ja.po
TMP=$(mktemp -d)
 
wp i18n make-pot . "$POT" --skip-audit
wp i18n make-pot . "$TMP/all.pot" --ignore-domain --skip-audit
 
# text domainが合わず落ちた文字列
comm -13 <(grep '^msgid "' "$POT" | sort -u) \
         <(grep '^msgid "' "$TMP/all.pot" | sort -u) > "$TMP/stray.txt"
 
msgmerge --previous -o "$TMP/merged.po" "$PO" "$POT"
msgfmt --statistics -o /dev/null "$TMP/merged.po"
 
if [ -s "$TMP/stray.txt" ]; then
  echo "text domain不一致の疑い:"; cat "$TMP/stray.txt"; exit 1
fi

仕込んだサンプルで実行したところ、統計行の後にtext domain不一致の疑い:とmsgid "Add note"が出て、終了コードは1でした。-oで別ファイルに出力するので、実行してもja.poは変わりません。

Claude Codeにこのスクリプトを回させる場合は、権限をBash(bash bin/i18n-check.sh)のように1コマンドに絞ります。非対話モードの--allowedToolsは、Bash(git diff *)のように末尾の *で前方一致を指定できます。Bash(git diff *)と書くとgit diffで始まるコマンドが通るので、スクリプトを呼ぶ1行だけを許可する形に絞れます。

この方法で拾えない漏れ

  • JavaScript側の文字列は、make-potが抽出しても、実行時に読まれるJSON(wp i18n make-jsonで生成)が無ければ翻訳されません。JSONを作っても、wp_set_script_translationsで読み込まなければ画面には反映されません。make-jsonはPOからJSONを作るサブコマンドで、このスクリプトには含めていません
  • _e( "Your city is $city.", 'my-plugin' )のように文字列へ変数を直接埋めると、翻訳者には$cityを含む文のまま届きます。ハンドブックは、変数を埋めずプレースホルダを使うよう求めています。この型はmake-potの出力を見ただけでは気づけません
  • 英語の原文自体の誤記は、このフローでは見つかりません

まとめ

make-potが出すPOTは「拾えた文字列の一覧」であって、「拾うべき文字列の一覧」ではありません。この差を埋めるのが、--ignore-domainとの差分(ドメイン違い)、claude -pの探索とPOTでの裏取り(直書き)、msgmerge --previousでの#|確認(不正なfuzzy)の3つです。どの結果も、Claude Codeが断定した内容ではなく、make-potやmsgfmtの出力で確かめた事実を最終の根拠にします。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/headless
  2. developer.wordpress.org/cli/commands/i18n/make-pot
  3. developer.wordpress.org/plugins/internationalization/how-to-internationalize-your-…
この記事を共有:XはてブLinkedIn