はじめに
手ぬぐい作家「つねかめ堂」(HP の作りはこちらの記事)の仕事を手伝ってもらう AI の作業場を、Cloudflare OS で立てています。お店の売上や出店の記録、Instagram の投稿とつないで、売れ筋を聞いたり告知文の案を出してもらったりしています。
使うのは自分だけではなく、お店のスタッフもです。ところが Cloudflare OS の画面(Workshop)は全部英語で、日本語に切り替える設定もありません。
そこで、ビルドした後の画面に「英語 → 日本語」の辞書を持ったスクリプトを足し、表示された英語をあとから置き換えることにしました。この記事では、その作り方と、途中で気づいた落とし穴をまとめます。Cloudflare OS に限らず、多言語対応していない Web アプリを日本語で使いたいときに応用できると思います。


上が日本語化した後、下が前です。左下の Show all (32) や、ワークスペースの名前(AI が付けたもの)が英語のままなのは、この記事で説明する仕組みのためです。
前提: 本体のコードは触りたくない
Workshop の画面は React で作られています。画面を作っている約130個の部品(コンポーネント)のあちこちに英語が直接書き込まれていて、翻訳ファイルを差し替えるような仕組みはありません。
素直にやるなら、本家のコードを自分用に複製して文言を書き換えます。でも、それは避けたい事情がありました。
- Cloudflare OS はまだ早期アクセスの段階で、本家の更新がとても速い(月に1回ほど取り込んでいて、1回で数十件の変更が入ることもある)
- 本家のコードは、中身を変えずにそのまま自分のリポジトリに取り込んでいる(git の submodule という仕組み)。少しでも書き換えると、取り込むたびに書き換えた箇所の食い違いを直すことになる
仕組み: 描かれた画面の文字を、あとから辞書で置き換える
やっていることはシンプルです。
- 「英語 → 日本語」の辞書を JSON で用意する(
ui-ja/ja.json) - デプロイのとき、画面のビルドが終わった後に、辞書を埋め込んだスクリプト
ui-ja.jsをビルドでできあがったファイルの横に置き、index.htmlにその読み込みを1行足す - ブラウザでは、画面が描かれるたびにそのスクリプトが文字を調べ、辞書にあるものを日本語に置き換える
ビルドし直せば ui-ja.js も <script> タグも消えて、元の英語の画面に戻ります。
この方式の約束事は1つだけです。画面に出た文字の全体が辞書と一致したら置き換え、それ以外には何もしない。 だから、うまくいかないときは必ず「英語のまま出る」形になり、画面が壊れることはありません。この後に出てくる工夫も、訳せない文言も、本家の更新で英語に戻る弱点も、すべてこの約束事から出てきます。
画面の変化を見張る
React の画面は、一度描いて終わりではありません。ボタンを押せばメニューが開き、チャットを送れば新しい要素が増えます。そこで、ブラウザの MutationObserver(ページの中身が変わるたびに知らせてくれる仕組み)で、ページ全体の変化を見張ります。要素が増えたらその中の文字をまとめて調べ、文字や属性だけが書き換わったらそこだけを調べます。
new MutationObserver(onChange).observe(document.documentElement, {
childList: true, // 要素が増えた
subtree: true, // ページ全体を見張る
characterData: true, // 文字が書き換わった
attributes: true, // 属性が書き換わった
attributeFilter: ["placeholder", "aria-label", "title"],
})
置き換える対象は、画面に出ている文字のほかに、3つの属性です。placeholder(入力欄の薄い案内文)、aria-label(読み上げソフト向けの名前)、title(マウスを乗せると出る説明)です。
スクリプトは、画面が描かれる前に読み込む
見張りを始めるのが遅いと、最初の画面だけ英語のまま、ということになります。
type="module" を付けたスクリプトは、ページを読み終えるまで実行が後回しにされます。Workshop の本体はこの形で読み込まれています。そこで ui-ja.js は type="module" を付けない普通のスクリプトにして、<head> の先頭近く(文字コードの宣言 <meta charset> のすぐ後ろ)に入れています。こうすると、本体が画面を描き始める前に見張りが始まります。
<meta charset="UTF-8" />
<script src="/ui-ja.js"></script>
あわせて、ページの言語を日本語(<html lang="ja">)にしています。英語のままだと、端末によっては漢字が中国語向けの字形で表示されることがあるためです。
工夫1: ユーザーの文章は書き換えない
いちばん気をつけたのは、ユーザーが書いた文章を書き換えないことです。
この画面はチャットが中心なので、AI とのやりとりの中に「Save」や「Delete」といった単語が普通に出てきます。文章の中から単語を探して置き換える方式だと、「Please delete the old items」が「Please 削除 the old items」になってしまいます。
そこで、先ほどの約束事のとおり、文字のかたまりの全体が辞書の英語とぴったり一致したときだけ置き換えます。
const lookup = (value) => dict.get(value.replace(/\s+/g, " ").trim())
空白や改行をまとめて1つにし、前後の空白を取ってから辞書を引きます。ボタンの「Save」は全体が Save なので一致し、チャットの文章は一致しません。チャットで「Save」とだけ送った場合は置き換わってしまいますが、そこは割り切りました。置き換えるときは、元の前後にあった空白は残します(消すと隣の文字とくっついてしまうため)。
さらに、入力欄の中身と、コードの表示は、一致しても触りません。入力中の文章や、書かれたとおりに読むべき文字は、画面の文言ではないからです(入力欄の薄い案内文 placeholder は画面の文言なので訳します)。
const skip = "textarea, input, [contenteditable], code, pre, script, style"
工夫2: 1つの英語には、1つの日本語
辞書は 404 語あります。1つの長い一覧だと、どこに何があるか分からなくなるので、画面ごとにグループを分けて書いています。
{
"共通": {
"Cancel": "キャンセル",
"Save": "保存",
"Remove": "外す",
"Not now": "あとで"
},
"サイドバー・ホーム": {
"Workspaces": "ワークスペース",
"Outputs": "成果物"
}
}
ただし、ブラウザ側のスクリプトには「いま、どの画面か」は分かりません。1つの英語には、アプリ全体で1つの日本語しか当てられないのです。グループ分けは書く人のためだけのもので、デプロイのときに全部を1つの辞書にまとめます。同じ英語にグループごとに違う訳を書いていたら、どちらかが黙って使われなくなるので、エラーにして止めています。
だから訳語は、その英語が出てくる全部の画面で通じるものを選ぶ必要があります。たとえば「Remove」は、本家のコードを見ると、自分のワークスペースには「Delete」、共有されたワークスペースには「Remove」と使い分けていました。「Remove」はどこで使われても、自分の一覧から外すだけで、リンクからは引き続き開ける、という意味でした。そこで「削除」ではなく「外す」にしています。
落とし穴: 書き換えた結果を、もう一度書き換えてしまう
Claude Code にコードレビューを頼んだら指摘された落とし穴です。
このスクリプトは、自分が書き換えた文字の変化も受け取ります。ふつうは問題ありません。訳した後の日本語は辞書に載っていないので、そこで止まります。
ところが、訳が辞書の英語側(キー)にも載っていると、もう一度訳されてしまいます。
"A": "B"と"B": "A"があれば、A と B を永遠に行き来して、タブが固まる"B": "C"もあれば、A を訳したつもりが C になってしまう- 英語のまま残したくて
"OK": "OK"と書いた場合も、同じ値を書き込むたびに変化の通知が来て、止まらない
今の辞書に該当するものはありませんでしたが、辞書を足していくうちにいつか踏みそうです。原則は「訳した結果が、また訳す対象になってはいけない」です。そこで、デプロイのときに、訳が辞書のキーにも載っている辞書をエラーにして出さないようにしました。
for (const japanese of Object.values(flat)) {
if (japanese in flat) throw new Error(`ui-ja/ja.json: "${japanese}" is both a translation and a key`)
}
ブラウザ側でも、書き換える前と後が同じなら書き込まないようにしています。"OK": "OK" のような場合への追加の保険です。英語のまま残したい文言は、辞書に書かなければ済みます。
iframe の中にも届ける
ここまでの話は、Workshop 本体のページのことです。ところが Workshop には、ページの中に別の文書としてはめ込まれた画面があります。iframe(ページの中に別のページをはめ込む仕組み)に HTML を文字列で直接渡し(srcdoc)、安全のために閉じ込めた(sandbox)ものです。本体のスクリプトからは、中に手が届きません。
これが2種類あり、HTML ができあがるタイミングが違うので、届け方を変えました。
| 画面 | HTML ができるとき | 届け方 | 訳すもの |
|---|---|---|---|
| Workshop 本体 | ビルドのとき | ui-ja.js をファイルで置き、読み込みを1行足す |
文字と3つの属性 |
| コンテキスト、定期実行 | ビルドのとき | デプロイのとき、HTML の中に直接書き込む | 文字と3つの属性 |
| ガジェット | ブラウザの中で | 本体のスクリプトが、iframe に HTML が入る瞬間に差し込む | 3つの属性だけ |
どちらの iframe も、ファイルを読み込むことは許されていません。ただ、HTML の中に直接書いたスクリプトは動かせます(script-src 'unsafe-inline')。そこで、ファイルを置く代わりに、スクリプトの中身を <script> に直接書き込んでいます。辞書は3つとも同じものなので、「1つの英語には、1つの日本語」は iframe の中でも変わりません。
ビルドのときに HTML ができる画面
「コンテキストとスキル」と「定期実行」の画面は、それぞれ1つの HTML ファイルとしてビルドされます。デプロイのとき、本体の index.html と同じように、その HTML の <meta charset> の後ろへスクリプトを書き込みます。
ひとつ注意があります。辞書の中に </script という文字があると、ブラウザはそこでスクリプトが終わったと読んでしまいます。書き込む前に <\/script に置き換えています。

本体の ui-ja.js の読み込みを止めて撮った画面です。左のメニュー(本体)は英語に戻っていますが、右側(iframe の中)は、自分で持っているスクリプトで日本語になっています。
ブラウザの中で HTML ができる画面
Workshop では、AI が作った小さなアプリ(ガジェット)を画面の中で動かせます。ドキュメント、シート、スライドもガジェットの一種です。ガジェットの HTML は、Workshop 本体がブラウザの中で組み立てて iframe に渡します。デプロイのときにはまだ HTML がないので、書き込む先がありません。
その HTML が本体のスクリプトの手元を通るのは、iframe の srcdoc に入れる瞬間だけです。そこで、その入れ口を包んで、入ってくる HTML に自分自身を差し込むことにしました。React は setAttribute("srcdoc", …) で入れるので、そちらを包みます(iframe.srcdoc = … の代入も同じように包んでいます)。
const inject = (html) =>
html.includes(marker) ? html : html.replace(/<head[^>]*>/i, (head) => head + tag)
const setAttribute = Element.prototype.setAttribute
Element.prototype.setAttribute = function (name, value) {
const srcdoc = this instanceof HTMLIFrameElement && String(name).toLowerCase() === "srcdoc"
return setAttribute.call(this, name, srcdoc ? inject(String(value)) : value)
}
差し込む tag は、本体で動いているのと同じ関数を文字にして、辞書と一緒に <script> で包んだものです。すでにスクリプトを持っている HTML(コンテキストや定期実行)には、目印(marker)を見て二重に入れないようにしています。
ガジェットの中では、title・aria-label・placeholder の3つの属性だけを訳し、画面の文字には触りません。ガジェットの本文は、利用者や AI が書いた内容だからです(「工夫1」と同じ考え方です)。
それでも、ドキュメントやシートのツールバーのように、本家が作った部品は日本語にしたいところです。ツールバーのボタンは、本家の共通の部品が title(マウスを乗せると出る説明)と aria-label に説明を入れているので、属性を訳すだけで届きます。ツールバーの説明を辞書に 65 語足しました。


上が日本語化した後、下が前です。「Untitled document」や「Start writing…」はガジェットの中の文字なので、英語のままです。なお、マウスを乗せたときに出る説明はスクリーンショットに写らないため、ボタンに入っている説明の文字を同じ位置に描き足しています。
この方法は、本家のコードには触っていませんが、ブラウザに元からある setAttribute を差し替えています。ページ全体に効く変更なので、iframe の srcdoc のときだけ手を加え、それ以外はそのまま元の処理に渡しています。本家がガジェットの iframe の作り方を変えたら、ガジェットの中は英語に戻ります。
限界: 訳せないものもある
全体の一致で判定しているので、次のようなものは英語のままです。
- 数字が入る文言:
Show all (32)、3d agoなど。数字が変わるので辞書のキーにできません - いくつかの部品を組み合わせて作られる文言: 途中に太字やリンクが入っていると、文字のかたまりが分かれてしまいます(分かれたかたまりがそれぞれ決まった文字なら、前と後ろを別々に辞書に載せて訳せることもあります)
- ガジェットの本文: ツールバーなどの説明は届きますが、ガジェットの中の文字は、利用者や AI が書いた内容と区別できないので、あえて訳しません
どれも辞書と一致しなかった文字がそのまま残るだけなので、壊れることはありません。
本家の文言が変わったら気づけるようにする
この方式の弱点は、本家が文言を少し変えただけで、その部分が英語に戻ることです。たとえば「Delete workspace」が「Delete this workspace」に変われば、もう辞書と一致しません。気づかないうちに英語が増えていきます。
本体に手を入れない代わりに、辞書は本家の文言の「写し」になっています。写しは元が変われば古くなるので、取り込みのたびに元と写しを突き合わせることが、この方式を続けるための条件です。
そのための確認コマンド pnpm run ui-ja:status を作りました。取り込む前の版と後の版の画面のソースを比べて、主に次の2つを挙げます。
- 訳が効かなくなったかもしれない辞書の項目: 前の版のソースにはあったのに、新しい版では見つからなくなったもの
- 新しく増えた文言で、辞書にないもの: どのファイルに出てくるかも一緒に
どちらの版にも見つからない辞書の項目(綴りの間違いや、ソースの中で組み立てられている文言)があれば、それも別に挙げます。
一覧は、人が目で見て選ぶための材料です。見落とすよりは拾いすぎるほうがいいので、ソースから文言らしいものを大ざっぱに拾っていて、関係ない文字も混ざります。
実際に、取り込み済みの2つの版(コミットにして約100件の差)で試すと、新しく増えた文言が 46 件出てきました。この一覧を見て、日本語にしたいものだけ辞書に足します。本家の更新を取り込む手順書にも、この確認を1ステップとして入れました。
まとめ
- 多言語対応の仕組みがない Web アプリでも、ビルドした後のファイルに辞書とスクリプトを足すだけで、本体に手を入れずに日本語化できる
- 約束事は「全体が辞書と一致したときだけ置き換える」の1つ。失敗しても英語のまま出るだけで、ユーザーの文章も書き換えない
- 自分の書き換えを自分で拾うので、訳した結果が辞書にも載っていると、置き換えが止まらなくなる。辞書のチェックで防ぐ
- 手の届かない iframe の中には、HTML ができるタイミングに合わせてスクリプトを直接書き込む。ガジェットの中は説明の属性だけを訳す
- 辞書は本家の文言の写しなので、取り込みのたびに元と突き合わせる
