本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

Pontus-X の公式ドキュメントを日本語に訳し、英語版と併存する形で公開しました。原典の本文は CC BY-SA 4.0 で提供されており、表示と同一ライセンスでの頒布という条件のもとで、翻訳の公開が認められていると理解しています。

作業時間の多くを費やしたのは、翻訳そのものよりも、静的サイトジェネレータ Vocs をサブパス(https://<user>.github.io/<repo>/)に配置する部分でした。本記事ではその挙動を実測とともに整理します。Pontus-X 自体の全体像については Pontus-X の公開カタログを curl で覗くPontus-X の参加者レジストリ を先に読むと入りやすいかもしれません。

公開したもの

Pontus-X ドキュメント日本語版の「概要」ページ。左サイドバーの見出しが「はじめに・概要」「はじめかた」「コミュニティとガバナンス」「EU データ法(Data Act)」「開発者向けリソース」「技術リファレンス」と日本語になっており、ページ見出しも「Pontus-X ドキュメント:分散型データサービスエコシステムの実現」と訳されている。右上に「Language / 言語」の切替メニューがある

翻訳の対象は /docs/ 配下の全 65 ページ、原文で約 3 万語です。プライバシーポリシーと Imprint(運営者情報)は法的効力を持つ文書なので訳さず、英語版へのリンクだけを残しました。

英語版と併存させる構成

今回使った Vocs 1.0.13 では、多言語化にあたる仕組みは対象外のようでした。vocs.config.tsx の型定義(node_modules/vocs/_lib/config.d.ts)を確認した限り、localei18n にあたる設定項目は見当たりません。そのため、言語切替は自前で組み立てています。

やったことは単純で、英語のページはそのまま残し、日本語を pages/ja/ 以下に置きました。Vocs のサイドバー設定はパスをキーにしたオブジェクトを受け付けるので、/ja/docs/ というキーで日本語版のサイドバーを足せます。

// vocs.config.tsx
import { sidebar } from './sidebar'
import { sidebarJa } from './sidebar.ja'

export default defineConfig({
  sidebar: { ...sidebar, ...sidebarJa },
  topNav: [
    {
      text: 'Language / 言語',
      items: [
        { text: 'English', link: '/' },
        { text: '日本語 (Japanese)', link: '/ja' },
      ],
    },
    // …
  ],
})

英語のページに一切手を入れない構成にしたのは、原典が更新されたときに git merge upstream/main がそのまま通るようにするためです。訳文は pages/ja/ にしか存在しないので、上流の変更と衝突しません。

日本語版の「ウォレットの設定」ページ。v1 と v2 の違いを説明する本文が日本語になっており、左サイドバーでは「ウォレットの設定」が選択状態になっている。手順のスクリーンショットは原典のものをそのまま使っている

サブパス配信で確認した Vocs の挙動

GitHub Pages のプロジェクトサイトは https://<user>.github.io/<repo>/ というサブパスで配信されます。Vocs にはこのための basePath 設定があるので、これを渡せば済むと考えていました。

実際には、basePath を設定したときに、プリレンダのルート照合と、参照の組み立て 2 系統の計 3 か所で挙動が分かれました。図は後者の 2 系統を示したものです。

basePath を設定したときに接頭辞が付く参照と付かない参照を示した図。MDX のページソースから 2 つのレーンに分かれ、左は「Vocs が自分で組み立てる参照」(markdown のリンク、Vocs コンポーネントの href、ロゴやアイコンなど Vocs 自前のアセット)で接頭辞が付き /pontusx-docs/docs/… として 200 を返す。右は「ページソースにそのまま書かれた参照」(markdown の画像、JSX の src 属性、生の a href、Tailwind の bg-[url()])で素通しされ /images/… のまま 404 になる。下部に、同一ページ内アンカーと Skip to content も接頭辞が付かないこと、その原因が Anchor / SkipLink が basename 除去済みの pathname からリンクを組み立てる点にあることが書かれている

以下、確認したバージョンは Vocs 1.0.13 です。

プリレンダの出力が空になる

basePath を設定してビルドすると、ビルド自体は成功しますが、出力される HTML が本文を持たない殻だけになりました。

# basePath を設定してビルドした場合
wc -c dist/ja/docs/introduction/overview/index.html
#      466 dist/ja/docs/introduction/overview/index.html
<!doctype html>
<html lang="en" data-vocs>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <script src="/pontusx-docs/initializeTheme.iife.js"></script>
    <script type="module" crossorigin src="/pontusx-docs/assets/index-DIQA6iRh.js"></script>
    <link rel="stylesheet" crossorigin href="/pontusx-docs/assets/style-CcXIX0Iw.css">
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>

<title> も OGP(Open Graph protocol)のメタタグも本文もありません。JavaScript が読み込まれれば画面は表示されるので気づきにくいのですが、クローラや SNS のカード生成からは空のページに見えます。後述の対処を入れたあとの同じページは 42,375 バイトで、本文がすべて含まれています。

原因はプリレンダの呼び出し方にありました。_lib/vite/prerender.jsbasePath を含まないルート(/ja/docs/introduction/overview)を prerender() に渡しますが、受け取る側の _lib/app/index.server.js はそれを basename: basePath を設定した <StaticRouter>location にそのまま渡します。React Router は与えられた location から basename を取り除いてルートを照合する(stripBasename)ため、basename で始まらない location はこの段階で null になり、どのルートにも一致せず結果が空になります。

// _lib/app/index.server.js(抜粋)
return _jsx(ConfigProvider, { config: config,
  children: _jsx(StaticRouter, { location: location, basename: basePath, /* … */ }) });

どのルートを描画するかの判定は、これとは別に接頭辞なしの location で行われています。したがって <StaticRouter> に渡す location にだけ basePath を前置すれば、内部で取り除かれて照合は従来どおり行われ、本文が戻ります。ビルド前に node_modules へこの 1 行を当てるスクリプトを用意しました。BASE_PATH を設定したビルドでパッチの当たる箇所が見つからない場合は、壊れた成果物をそのまま公開しないよう、ビルドをエラーで停止させています。

本文中の root-relative なパスに接頭辞が付かない

こちらのほうが影響が広い挙動でした。Vocs が接頭辞を付けるのは、Vocs 自身がリンクやアセットの URL を組み立てる経路(markdown のリンク、Vocs コンポーネントの href、設定で指定したロゴ・アイコン)に限られます。ページのソースに直接書かれた / 始まりのパスは MDX(Markdown の中に JSX を書ける形式で、Vocs のページはこれで書かれています)から HTML へそのまま出力されるため、接頭辞が付きません。

実際の見え方が次です。1 枚目が接頭辞を付ける処理を無効にしてビルドし、同じサブパスでローカル配信して再現したもの、2 枚目が現在の公開版です。

接頭辞を付ける処理を無効にしてビルドし、同じサブパスでローカル配信して再現した「Pontus-X ポータル」ページ(公開サイトの画面ではありません)。本文の「カタログ検索」「サービスの説明」という見出しの下に、画像が表示されず alt テキストの catalogue と service description だけが壊れた画像アイコンとともに並んでいる

現在の公開版の「Pontus-X ポータル」ページ。同じ見出しの下に、Pontus-X ポータルのカタログ検索画面と、サービス詳細画面のスクリーンショットが 2 枚とも正しく表示されている(縮小しているため画面内の文字までは読み取れません)

前述のプリレンダの修正だけを当て、後述の書き換えを入れていない状態のビルド出力を見ると、Vocs 自前のアセットには接頭辞が付き、本文の画像には付いていません(実際の出力から画像に関係する行を抜粋しています)。

grep -oE 'src="/[^"]*"' dist/ja/docs/introduction/overview/index.html | sort -u
src="/images/operators-icon.png"
src="/images/pontus-x-ecosystem.jpg"
src="/images/providers-icon.png"
src="/images/spaces-icon.png"
src="/pontusx-docs/icon-dark.png"
src="/pontusx-docs/icon-light.png"

/images/… は配信元に存在しないので 404 になります(別ページで参照している catalogue.png の例)。

curl -s -o /dev/null -w "%{http_code}\n" https://nakamura196.github.io/images/catalogue.png
# 404
curl -s -o /dev/null -w "%{http_code}\n" https://nakamura196.github.io/pontusx-docs/images/catalogue.png
# 200

/images/… の参照は英語版と日本語版を合わせて 150 箇所、ファイルとしては 74 種類ありました。ほかに logo-*.svg や Tailwind の bg-[url('/colosseum.svg')]、ランディングページの生の <a href="/docs/…"> も同じ扱いでした。

回避策として、MDX(Markdown の中に JSX を書ける形式で、Vocs のページはこれで書かれています)がコンパイルされる前にソースを書き換える Vite プラグインを書きました。コンパイル前に処理するのは、プリレンダされた HTML とクライアント側のバンドルの両方に接頭辞を行き渡らせるためです(後者を忘れると、ページ内リンクで遷移したときだけ画像が消えます)。

transform(code, id) {
  if (!basePath) return null
  if (!id.split('?')[0].endsWith('.mdx')) return null

  // すでに接頭辞が付いているものは対象外にする
  const ok = (path) => path.startsWith('/') && !path.startsWith(`${basePath}/`)

  const rewritten = code
    // ![alt](/images/x.png) — markdown の画像のみ。`!` を必須にして、
    // Vocs 側が接頭辞を付ける通常のリンクを対象から外す
    .replace(/(!\[[^\]]*\]\()(\/[^)\s]*)/g, (m, a, path) => (ok(path) ? `${a}${basePath}${path}` : m))
    // JSX の src="/…"
    .replace(/(\bsrc=)(["'])(\/[^"']*)\2/g, (m, a, q, path) => (ok(path) ? `${a}${q}${basePath}${path}${q}` : m))
    // <a href="/…"> — 素の a 要素だけ。Vocs コンポーネントの href は触らない
    .replace(/(<a\s+[^>]*?\bhref=)(["'])(\/[^"']*)\2/g, (m, a, q, path) => (ok(path) ? `${a}${q}${basePath}${path}${q}` : m))
    // Tailwind の url('/…')
    .replace(/(url\()(["']?)(\/[^"')]*)\2(\))/g, (m, a, q, path, z) => (ok(path) ? `${a}${q}${basePath}${path}${q}${z}` : m))

  return rewritten === code ? null : { code: rewritten, map: null }
}

書き換えの範囲を絞るのがこの処理で重要だった点です。markdown の通常のリンク [text](/docs/…)<HomePage.Button href="/docs/…"> は Vocs 側が接頭辞を付けるので、ここで一緒に書き換えると /pontusx-docs/pontusx-docs/docs/… と二重になります。<a で始まる素の要素だけを対象にし、画像は markdown の ! の有無で区別しています。src= に同様の除外条件を置いていないのは、この変換を .mdx にしか適用しておらず、Vocs のコンポーネントが src を受け取る箇所がページソース側に無いためです。

同一ページ内アンカーが接頭辞から外れる

[credentialSubject](#credentialsubject) のような同一ページ内リンクと、キーボード操作用の「Skip to content」も接頭辞が付きませんでした。こちらは useLocation().pathname が basename を取り除いた値を返すためです。

// _lib/app/components/mdx/Anchor.js(抜粋)
const { pathname } = useLocation();
if (href?.match(/^#/))
    return (_jsx("a", { /* … */ href: `${pathname}${href}` }));

見出しの横に出る のリンク(Autolink)は React Router の <Link> を使っているので接頭辞が付き、本文中の # リンクだけが素の <a> で組み立てられていて外れる、という分かれ方でした。React Router の useHref は basename を再適用してくれるので、これを噛ませて解決しています。

見落としを防ぐための確認

3 つとも「ビルドは通るが配信すると壊れる」種類なので、目視では取りこぼします。ビルド出力の srchref を総当たりして、/ 始まりの参照がすべて接頭辞付きになっているか、かつ実ファイルに解決するかを機械的に確認しました。

import pathlib, re, urllib.parse
BASE = '/pontusx-docs'
dist = pathlib.Path('dist')
for f in dist.rglob('*.html'):
    for url in re.findall(r'(?:src|href)="(/[^"]*)"', f.read_text()):
        path = url.split('#')[0].split('?')[0]
        assert path.startswith(BASE), f'接頭辞なし: {path}'
        rel = urllib.parse.unquote(path[len(BASE):]).lstrip('/')
        assert (dist/rel).is_file() or (dist/rel/'index.html').is_file(), f'解決しない: {path}'

重複を除いて 224 パスありました。この確認を先に書いておけば、画像のリンク切れは公開前に気づけたはずです。

ビルド環境の設定

Node のバージョン

Node 25 では vocs build が起動直後に落ちました。依存パッケージの @typescript/vfslocalStorage.getItem を呼びますが、手元で確認した限り、Node 25 では localStorage はグローバルに存在する一方で getItem が関数として解決されず、TypeError になるようでした。.nvmrc で Node 22 に固定しています。

TypeError: localStorage.getItem is not a function
    at Object.<anonymous> (…/node_modules/@typescript/vfs/dist/vfs.cjs.development.js:25:64)

フォークでの GitHub Pages

github-pages の Deployment environment は既定でデフォルトブランチからのデプロイしか許可しないため、ja ブランチからのデプロイが Branch "ja" is not allowed to deploy to github-pages due to environment protection rules. で止まりました。API でブランチポリシーを足しています。

echo '{"deployment_branch_policy":{"protected_branches":false,"custom_branch_policies":true}}' \
  | gh api -X PUT repos/:owner/:repo/environments/github-pages --input -
gh api -X POST repos/:owner/:repo/environments/github-pages/deployment-branch-policies -f name=ja

アクセス解析タグ

原典の vocs.config.tsx には、アクセス解析サービスの Plausible のタグが data-domain="docs.pontus-x.eu" 付きで入っています。フォークをそのままビルドすると、こちらのアクセスが原典側の統計に混ざります。basePath が設定されているときは読み込まないようにしました。フォークして公開するときに見落としやすい箇所だと思います。

訳文の確認

約 3 万語を一度書き下ろしただけの状態は信用できないので、公開後に検証エージェントを 12 体並列で走らせ、全 65 ページを原文と突き合わせました。担当を「文書のまとまり」ではなく「検証の性質」で切り、仕様表の多いページと散文の多いページを別のエージェントに割り当てています。

有効だった観点は、人間が読んで判断する種類のものではなく、機械的に照合できるものでした。

  • コードブロックが、意図的に訳したコメント行を除いてバイト単位で一致しているか
  • 仕様表の行数・行順・属性名・必須マーカー(:white_check_mark: / :ballot_box_with_check: / 空欄)が一致しているか
  • Yes/No の表が、セル単位で正しく「はい/いいえ」に写像されているか(ライフサイクル状態の表は 2 表 × 6 行 × 3 列 = 36 セル)
  • RFC 2119 のキーワード(MUST / MAY / RECOMMENDED / OPTIONAL)の強度が入れ替わっていないか
  • コントラクトアドレス・チェーン ID・ブロック番号が一致しているか

エージェントが最も重い区分に分類した指摘は 0 件、その次の区分(内容に影響しうるもの)が 17 件でした。区分はエージェント側の判断によるもので、17 件の内訳は、訳の誤りが 7 件、原文の誤りを訳文側だけで直していたものが 3 件、訳語の不統一が 5 件、構成上の問題が 2 件です。いずれも公開版に反映済みです。

訳の誤り 7 件には、たとえば「Staatsbibliothek zu Berlin」を「ベルリン州立図書館」と訳していた(正しくは国立図書館)、Compute-to-Data(データを持ち出さず、提供側の環境で計算だけを実行する方式)の説明で「データ利用者が自らのホストする安全な環境」と書いてしまい、環境をホストするのがデータオーナー側だという要点が反転して読める、といったものが含まれます。

なお、この指摘はいずれも生成AIの出力であり、人手による全文レビューの代わりにはなりません。見落としが残っている可能性は排除できていません。

原文の誤りを訳文側だけで直していた

指摘のうち考えさせられたのは、自分が原文の誤りを訳文側だけで直していた箇所でした。

  • on_chain.mdx の型名が原文では sring になっている(string の誤記)のを、訳文で string に直していた
  • data-service-offerings.mdx の一文が原文で "...and the consumer is " と途中で切れているのを、前後の文脈から補って訳していた

どちらも読みやすさの面では改善ですが、仕様表の型セルを訳文側だけ変えると原文との突合が壊れます。型セルは原文どおり sring に戻して訳注を添え、途中で切れた文のほうは補った訳をそのまま残したうえで、原文が途切れていることと補った根拠を訳注に書きました。

このとき、訳注を MDX のコメント {/* … */} で書いたところ、ビルド後の HTML に一切現れませんでした。JSX のコメントなのでレンダリング対象にならず、ソースを読む人にしか見えません。読者に見せる注記は :::info のようなコールアウトで書く必要があります。

サイドバーの「翻訳について」から開く「この日本語版について」のページ。冒頭に「公式版はあくまで英語版です」という注意書きのコールアウトがあり、その下の表に原典(deltaDAO/pontusx-docs)、原典の公開サイト、翻訳の基準コミット、翻訳版のリポジトリ、翻訳の範囲、翻訳していないもの(Privacy と Imprint)が並んでいる

原典に返したもの

翻訳の過程で見つかった原典側の不具合のうち、修正内容が推測にならないものをプルリクエストにまとめました(deltaDAO/pontusx-docs#176)。

  • リンク切れ 12 件。存在しないページへのリンクが 7 件、.mdx 拡張子が付いていて 404 になるリンクが 5 件です(いずれも本番へ実際にリクエストして確認しました。.mdx を外すと 200 が返ります)
  • ホスト名の誤り 6 件。rpc.dev.pontux-x.eu が 3 ファイルに 5 箇所ありますが、pontux-x.eu は DNS を解決しません。network-details.mdx の Aquarius のリンク先は 503 を返し、同じ行のリンクテキストと同ページの設定ブロックが使っている別のホストは 200 を返します
  • レンダリングされない markdown 3 件(開き括弧の欠落、閉じない括弧、括弧の重複)
  • タイポ 3 件

一方、修正内容が推測になる 3 件は変更せず、本文で報告するだけにしました。前述の途中で切れた文、「Bi-Monthly Ecosystem Call」という見出しと本文の「monthly」「毎月最終木曜日」が食い違っている箇所、番号が 1, 2, 4, 5 と飛んでいる手順(項目が消えたのか採番のずれなのか判断できない)です。他者のリポジトリに出す変更は、意図を推測しないと直せないものを混ぜないほうが、レビューする側も判断しやすいと考えました。

現時点で残っている制約

  • 日本語の全文検索の精度: Vocs の検索は minisearch を使っており、既定のトークナイザは空白と各種の約物(SPACE_OR_PUNCTUATION)で分割します。CJK の句読点は区切りとして扱われますが語の切れ目は取れないため、単語単位での一致がしにくい状態です。bigram のトークナイザを差し込む余地はありそうですが、設定経由でクライアント側に関数を渡せるかは未確認です。
  • <html lang> が英語のまま: Vocs は lang="en" をテンプレートに直書きしており(_lib/vite/utils/html.js)、調べた限りページ単位でも設定でも変更できませんでした。日本語版のページも lang="en" で配信されています。スクリーンリーダーの読み上げや検索エンジンの言語判定に影響しうる箇所です。
  • ランディングページの背景: /colosseum-light.svg(およびダークモード用の /colosseum.svg)を参照していますが、原典のリポジトリにファイルの実体がなく、原典のサイトでも 404 でした。装飾用の背景なので表示上の影響はありませんが、こちらでは補えません。

日本語版のランディングページ。Pontus-X のロゴの下に「分散型のデータエコノミー・ツールボックス。データと AI 製品の自己主権的な収益化を実現します。」というタグラインと、「はじめる」「Pontus-X ポータル」「オンボーディングする」「Pontus-X 公式サイト」の 4 つのボタンが並んでいる。原典で 404 になる背景の装飾画像は表示されておらず、白地のままである

原典が CC BY-SA 4.0 で提供されているおかげで、翻訳して公開するところまでを許諾の範囲内で行えました。調べた限りでは、データスペース関連の資料は英語で書かれたものが中心で、日本語で読める一次情報に近いものは限られているようです。同じ枠組みで訳せる資料は他にもありそうです。