GitLocalizeで翻訳された結果をマージするとJSON内のUnicodeエスケープシーケンスがUnicode文字になってしまうのを回避する

[GitHub Actionsアドベントカレンダー](https://qiita.com/advent-calendar/2021/github-actions)の16日目として、軽めの内容でエントリーさせていただきます。

# GitLocalizeって何？（知ってる人は読み飛ばして下さい）

今どきの多言語対応が前提になっているソフトウェアでは、画面上のボタンのラベルなどの表示文字列について、「抽象的な名前のキーと、それに対応する各言語での表示文字列のペア」の形（これを「ロケール」と呼ぶ）で言語リソースを管理するようになっています。私が主な活動領域にしている「Firefoxのアドオン」や、「Google Chromeの拡張機能」なども、そのようなソフトウェアの一例です。

例えば、[FirefoxアドオンのTree Style Tab](https://addons.mozilla.org/firefox/addon/tree-style-tab/)の[英語ロケール](https://github.com/piroor/treestyletab/blob/trunk/webextensions/_locales/en/messages.json)は

```json
{
  "extensionName": { "message": "Tree Style Tab" },
  "extensionDescription": { "message": "Show tabs like a tree." },

  "sidebarTitle": { "message": "Tree Style Tab" },
  "sidebarToggleDescription": { "message": "Toggle \"Tree Style Tab\" Sidebar" },

  "command_tabMoveUp":             { "message": "Move Current Tab Up" },
  "command_treeMoveUp":            { "message": "Move Current Tree Up" },
  "command_tabMoveDown":           { "message": "Move Current Tab Down" },
  "command_treeMoveDown":          { "message": "Move Current Tree Down" },
...
```

で、[日本語ロケール](https://github.com/piroor/treestyletab/blob/trunk/webextensions/_locales/ja/messages.json)は

```json
{
  "extensionName": { "message": "Tree Style Tab - ツリー型タブ" },
  "extensionDescription": { "message": "タブをツリー状に表示します。" },

  "sidebarTitle": { "message": "ツリー型タブ" },
  "sidebarToggleDescription": { "message": "ツリー型タブのサイドバーの表示・非表示を切り替える" },

  "command_tabMoveUp":             { "message": "現在のタブを単体で1つ上に移動" },
  "command_treeMoveUp":            { "message": "現在のタブとその配下のタブを1つ上に移動" },
  "command_tabMoveDown":           { "message": "現在のタブを単体で1つ下に移動" },
  "command_treeMoveDown":          { "message": "現在のタブとその配下のタブを1つ下に移動" },
...
```

という感じになっています。表示する際は、Firefoxアドオン用のAPIを使って `browser.i18n.getMessage('command_tabMoveUp')` のようにキーで参照すると、現在アクティブな言語のロケールから、対応する文字列が返されます。

ただ、一人で幾つもの言語を使いこなせる開発者というのはそうそういないので、多くのOSS開発プロジェクトは、まず基本として英語のロケール（と、場合によってはその人の母語のロケール。私の場合は日本語ロケール）だけ用意して、**他の言語のロケールは協力者に作ってもらう**場合が多いです。

ここでよく問題になるのが、「他の言語のロケールの提供者は、どうやって英語ロケールの変更に追従すればよいのか」「1つの言語の翻訳に複数人が関わる場合に、翻訳の質を一定に保ち、協調して作業するためには、どうすればよいか」という点です。

* 新しいキーと表示文字列のペアが英語ロケールに追加されたら、他のロケールでも対応するペアを追加（翻訳）しないといけません。また、既存のキーに対する表示文字列が英語ロケールで変更されたら、他のロケールでも対応する項目を更新する（翻訳し直す）必要があります。原始的なやり方としては、diffコマンドで差分を取るなどの方法がありますが、誰もがdiffの使い方に明るいわけではありません。
* 複数人が1つの言語を翻訳すると、原語の同じ単語に対応する訳語が、訳者によってバラバラになる場合があります。また、複数人が同時に同じ言語リソースを編集しようとして、変更がコンフリクトしてしまうこともあります。

こういった諸々の面倒さをカバーしてくれるのが、「翻訳プラットフォーム」や「ローカライズ管理ツール」と呼ばれる種類のツールやサービスです。例えば、MozillaはFirefoxなどの製品の翻訳用として[Pantoon](https://pontoon.mozilla.org/)というシステムを運用していますし、LibreOfficeは[Weblate](https://weblate.org/)というシステムを使っています。NextCloudやownCloudは[Transifex](https://www.transifex.com/)というサービスを使っています。

[GitLocalize](https://gitlocalize.com/)も、そのようなツールの1つです。GitLocalize自体の紹介は[CodeZineの紹介記事](https://codezine.jp/article/detail/14415)などをご覧下さい。

# GitLocalizeで翻訳結果を受け取るときに困ること

GitLocalizeでは、協力者に翻訳してもらった結果をGitHubのプルリクエストとして受け取ることができます。このときに地味に困るのが、JSONの中に書いたUnicodeエスケープシーケンス（例えば`\u0020`）が生のUnicode文字（`\u0020`であれば、半角スペース）に強制変換されてしまう、という点です。

[JSONの仕様では、エスケープが必要な文字がいくつか定められています](https://shinkufencer.hateblo.jp/entry/2018/08/03/235900)が、エスケープしないでいい文字でも、紛らわしい・似た文字と混同しないようにしたいなどの理由から、敢えてエスケープシーケンスの状態にしておきたい場面が度々あります。

私の場合は`\u200b`の「[ゼロ幅スペース（Zero-width Space）](https://ja.wikipedia.org/wiki/%E3%82%BC%E3%83%AD%E5%B9%85%E3%82%B9%E3%83%9A%E3%83%BC%E3%82%B9)」がそうです。Firefoxの拡張機能のロケールでは、訳文が空文字だと既定のロケール（英語）の訳文が表示されてしまう仕様なので、訳文を明示的に空にしておきたい場合には、半角スペースなり何なりのダミーの内容を入れないといけません。このとき、半角スペースが視覚的に表示されるとイヤだ、という場面で便利なのがゼロ幅スペースなのですが、これはUnicode文字の状態だと「何も表示されないのにそこに文字がある」という状態になるので、視覚的に判別ができず、誤削除などのミスの原因になってしまいます。

そういうわけで、私はゼロ幅スペースは必ずエスケープシーケンスで`\u200b`と書いておきたいのですが、GitLocalizeを通すと、これが生のUnicode文字に毎回戻ってしまいます。シェルのワンライナーなりテキストエディターの一括置換なりで簡単に直せはしますが、毎回となると非常に煩わしいです。

# Actionで自動修正＆自動コミット

以上のような動機で、GitLocalizeの翻訳結果を受け取ったときに、プルリクエストをマージした時点で任意のUnicode文字を常にエスケープシーケンスに戻してコミットし直すActionを、以下のように定義してみました（[Tree Style Tabプロジェクトで実際に使っているAction定義](https://github.com/piroor/treestyletab/blob/trunk/.github/workflows/auto-escape-special-unicode-characters-in-locales.yml)をそのまま貼ります）。

```yaml
name: auto-escape-special-unicode-characters-in-locales

on:
  pull_request:
  push:
    branches:
      - "trunk"

jobs:
  auto-escape-special-unicode-characters-in-locales:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v2
      with:
        ref: trunk
    - name: escape ZWSP unexpectedly unescaped by GitLocalize
      run: "ZWSP=$'\\u200b'; grep -r -E \"$ZWSP\" webextensions/_locales | cut -d : -f 1 | uniq | xargs sed -i -r -e \"s/$ZWSP/\\\\\\\\u200b/g\" || echo 'there is no ZWSP'"
    - uses: stefanzweifel/git-auto-commit-action@v4
      with:
        commit_message: Escape special unicode characters in locales
        file_pattern: webextensions/_locales/*/*.json
        branch: trunk
```

当初は、手元のシェルで使っていたワンライナーをそのまま書いて済ませるつもりだったのですが、なんやかやでエスケープ文字の扱いが面倒でした。

* 「生で書かれても分からないUnicode文字」を極力生で書きたくなかったので、`ZWSP=$'\u200b'`として一旦シェル変数に代入した。
  * しかしエスケープ文字の`\`は、YAMLファイルの中に単独で書くとそれ自体もまたYAMLのエスケープ文字になってしまうので、二重にしないといけない。
* JSONの中にエスケープシーケンスを置くためには、エスケープ文字の`\`をその文字として出力しないといけないので、置換後の文字列では`\\`と二重にしないといけない。
  * それをsedの置換指示の中に書くので、`\\\\`と4重にしないといけない。
      * さらにそれをYAMLファイルの中に書くので、最終的には`\\\\\\\\`と8重にしないといけない。

Unicode文字やUnicodeエスケープシーケンスが絡むActionをシェルのワンライナーベースで書きたい、という場面で参考になれば幸いです。