💭

【個人開発】色んな規約ページをi18nから一括でMarkdown出力する話

に公開

はじめに

個人開発で、プライバシーポリシーや利用規約といった法的文書を外部のAIにレビューしてもらう機会がありました。その際、複数ページにまたがる内容を一つのドキュメントにまとめる必要があったのですが、この手作業が思った以上に面倒でした。

この「地味に面倒な作業」を解決するために簡単なスクリプトを書いたので、そのアプローチを共有します。

(コーディングAIはシステムプロンプトの特性上、法的文書の繊細なニュアンスの修正が苦手なためです。また、PRDのような関連資料もまとめて渡すことで、AIの理解度を高める狙いもあります。)

抱えていた課題:手作業でのコピペ地獄

レビューのためには、英語、中国語、日本語の3言語の法的文書(プライバシーポリシー、利用規約、About Us)を提出する必要がありました。

当初のやり方は、localhost:3000でアプリを立ち上げ、ひたすら手作業でコピー&ペーストするというものでした。

  • ブラウザで「プライバシーポリシー」ページを開き、テキストをコピー&ペースト
  • 次に「利用規約」ページを開いて、同じことを繰り返す
  • 「About Us」ページも同様に…
  • 最後に、他の言語に切り替えて、1〜3のステップを繰り返す(英語、中国語、日本語の3言語の場合、この作業を3回繰り返す)

面倒くさいなぁ

解決策:i18nファイルから直接Markdownを生成する

この非効率な作業をなくすため、「情報の唯一の源泉(Single Source of Truth)であるi18nファイルから、直接ドキュメントを生成すれば良いのでは?」と考えました。

そこで、以下の機能を持つTypeScriptスクリプトを作成しました。

  • src/localesにある翻訳JSONファイルを読み込む
  • 指定された法的文書の内容を、言語ごとに抽出・整形する
  • 全ての文書をまとめた単一のMarkdownファイルを docs/ ディレクトリに一括で出力する

スクリプトの構成とデータフロー

構成はシンプルで、データの「入力」「加工」「出力」の3層に分けています。全体のデータの流れは以下の通りです。

graph TD;
    A[📄 i18nファイル<br>(en.json, zh.json, ja.json)] --> B[⚙️ フォーマット層<br>(各文書をMarkdownに加工)];
    B --> C[✍️ 出力層<br>(全文書を結合)];
    C --> D[📜 docs/legal-documents.md];

1. データソース層(入力)

TypeScriptのimport文で直接JSONファイルを読み込みます。typeofと組み合わせることで、翻訳キーの補完が効き、型安全性が保たれるのが大きなメリットです。

src/scripts/export.ts
import enTranslations from "../src/locales/en.json";
import zhTranslations from "../src/locales/zh.json";
import jaTranslations from "../src/locales/ja.json";

2. フォーマット層(加工)

この層がスクリプトの心臓部です。主な役割は、構造化されたJSONオブジェクトを受け取り、人間が読みやすい整形されたMarkdown文字列に変換することです。
各文書タイプ(プライバシーポリシー、利用規約など)に対応する、独立したフォーマット関数を用意しています。

src/scripts/export.ts
// 各関数は統一されたインターフェースを持つ
function formatPrivacyPolicy(translations: Translations, locale: Locale): string { /* ... */ }
function formatTermsOfUse(translations: Translations, locale: Locale): string { /* ... */ }
function formatAboutUs(translations: Translations, locale: Locale): string { /* ... */ }

これらの関数は、単にテキストを連結するだけではありません。以下のような、より具体的な整形処理を担当します。

  • Markdown記法の追加: JSON内の title や content といったキーを読み取り、# や ## といった見出し、- を使ったリスト形式など、適切なMarkdown記法を追加します。

  • 動的なコンテンツの生成: locale 引数に基づき、「Last updated」の日付フォーマットを英語(Month Day, Year)と中国語(YYYY年M月D日)で切り替えるなど、言語に応じた処理を行います。

  • 複雑な文字列の組み立て:最も複雑な例として、プライバシーポリシー内のサードパーティサービスに関する記述があります。ここでは、一つの文章内に複数のハイパーリンクを埋め込む必要があります。スクリプトは元の文章を動的に分割し、リンクテキスト というMarkdown記法を正しい位置に挿入するロジックを持っています。

src/scripts/export.ts
// ...
const adsenseText = sections.thirdPartyServices.adsense;
const adsenseLink = sections.thirdPartyServices.adsenseLink;
const adsenseLinkUrl = sections.thirdPartyServices.adsenseLinkUrl;

// 文字列を分割し、間にMarkdownリンクを挿入する
const parts = adsenseText.split(adsenseLink);
// ...
md += `${beforeLinkFormatted}[${adsenseLink}](${adsenseLinkUrl})${betweenLinksFormatted}[${privacyPolicyLink}](${privacyPolicyLinkUrl})${afterPrivacyLinkFormatted}\n\n`;

3. 出力層(出力)

メイン関数が各フォーマット関数を呼び出し、その結果を結合して最終的なファイルに書き込みます。Node.jsの標準モジュールであるfspathしか使っていないので、追加の依存関係はありません。

src/scripts/export.ts
import fs from "fs";
import path from "path";

function exportLegalDocs() {
  const outputFile = path.join(process.cwd(), "docs", "legal-documents.md");
  let mdContent = `# Legal Documents\n\n`;

  // 英語、中国語、日本語のドキュメントを順番に結合
  mdContent += formatPrivacyPolicy(enTranslations, "en");
  mdContent += formatTermsOfUse(enTranslations, "en");
  // ...
  mdContent += formatPrivacyPolicy(zhTranslations, "zh");
  mdContent += formatTermsOfUse(zhTranslations, "zh");
  // ...
  mdContent += formatPrivacyPolicy(jaTranslations, "ja");
  mdContent += formatTermsOfUse(jaTranslations, "ja");
  // ...

  fs.writeFileSync(outputFile, mdContent, "utf-8");
}

使い方

package.jsonにコマンドを登録し、ターミナルで実行するだけです。

package.json
{
  "scripts": {
    "export:legal": "node --loader ts-node/esm src/scripts/export.ts"
  }
}
pnpm export:legal

これで、docs/legal-documents.mdに、英語・中国語・日本語の3言語の全ページの法的文書がまとまったファイルが生成されます。

おわりに

非常に小さなスクリプトですが、開発体験を大きく改善してくれた一例として、誰かの参考になれば幸いです。

中の人について

この記事を書いている私は、以前日本でエンジニアとしてキャリアを積んできました。日本のカルチャーや環境(特にバー)が好きで、今は個人開発者として奮闘中です。

技術的な話から雑談まで、ぜひ気軽に絡んでください!皆さんと交流できることを楽しみにしています🤝。

もうすぐ最初のサービス ✨ をリリースする予定です。その時は、ぜひ触ってみて、応援していただけると最高に嬉しいです!

Discussion