- 設計書はコードの近くに置く
- Markdown で書き、Notion で読む運用
- 従来:Markdown を Block に変換する必要があった
- Markdown Content API で変換レイヤが不要になった
- CI 経由で md → Notion を実現する
- 新 API で良かった点
- 移行後も残る課題:リンクの Mapping
- 運用上の注意
- まとめ
エンジニアの関口です。GitHub 上の Markdown を Notion へ CI 同期する仕組みを作りました。 2026年2月、Notion API に Markdown Content API(Enhanced Markdown API)が追加され、従来必要だった Block 変換が不要になりました。
この記事では、なぜ Markdown を Git で管理し Notion で読む運用をしているのか、従来の Block 変換処理の課題、そして新 API で何が変わったかを整理します。
設計書はコードの近くに置く
コードの近くに設計書を置くスタイルは、AI 以前からよく言われてきました。自分も開発の前提として、リポジトリ内に README や設計メモを Markdown で置くことが多かったです。
AI エージェントがリポジトリ内のドキュメントを読んで作業するようになってから、そのスタイルとの相性の良さを改めて感じています。最近では、ドキュメントもコードと同様に Markdown で管理するのが一般的になってきました。
Markdown で書き、Notion で読む運用
ドキュメントがコードから離れると、更新漏れが多発しやすくなります。作業内容とドキュメントが一対一にならず、ドキュメントが陳腐化していく、という経験も多いです。コードと設計書を同じコミットに含めれば、変更と説明をセットで残せます。そのメリットは AI 以前から感じていました。
エンジニアや AI にとって、ドキュメントの記法は Markdown の方が親和性が高いです。リポジトリ内に Markdown で置けば、人もエージェントも同じ形式で読み書きできます。
一方で、リポジトリ内の Markdown をそのまま広く共有すると、閲覧には GitHub アカウントが前提になります。エンジニア以外の閲覧者や、社内共有のしやすさを考えると、Notion の方が向く場面もあります。
そこで、当時から自分が考えていたスタイルとして、以下を採用していました。
- 原本:Markdown を Git で管理(PR・レビュー・履歴)
- 閲覧:Notion の特定ページへ CI 経由で展開
このスタイルを実現するため、main への push をトリガーに GitHub Actions で Notion へ反映する CI を組みました。
従来:Markdown を Block に変換する必要があった
Notion API でページ本文を書く従来手段は Block API です。段落・見出し・リストなど、ブロック種別ごとの JSON を1件ずつ指定します。
GitHub の Markdown をそのまま API に渡しても Block にはなりません。Markdown を Notion の Block JSON に変換するパーサーが必要で、よく使われるのが martian です。
martian は、Markdown を Notion Block API 用の JSON に変換する npm ライブラリです。Notion 連携の実装例やブログ記事でよく参照される、事実上の定番ツールでした。Markdown Content API 登場以前は、Git 上の .md を Notion に載せるならこの手の変換レイヤがほぼ必須でした。
martian 経由の同期では、おおよそ次の流れになります。
- Markdown を Block JSON に変換
- 既存 Block を列挙・削除
- 新 Block を API 制限に合わせて分割して追加
リンクの書き換えも、Markdown 文字列と Block ツリーの両方に手を入れる必要があり、実装が重くなりがちでした。Notion へ Markdown を貼り付けるだけでは Block にならない、というのが従来の壁でした。
Markdown Content API で変換レイヤが不要になった
2026年2月、Notion API に Markdown 用の公式エンドポイントが追加されました。詳細は Working with markdown content を参照してください。
| 操作 | 概要 |
|---|---|
| 作成 | ページ作成時に markdown パラメータで本文を渡す |
| 取得 | ページ本文を Markdown として取得 |
| 更新 | 既存ページの本文を Markdown で置換・部分更新 |
API バージョン Notion-Version: 2026-03-11 が必要です。Markdown 文字列をそのまま渡せるため、martian のような変換レイヤを挟まず md → Notion が実現できます。
CI 経由で md → Notion を実現する
同期処理は TypeScript の CLI パッケージとしてリポジトリ内に置き、GitHub Actions から呼び出す構成にしました。全体の流れは次のとおりです。

同期スクリプトの中身
パッケージ内の役割分担は次のとおりです。
| ファイル | 役割 |
|---|---|
sync-config.json |
リポジトリ情報と、GitHub 上の Markdown パス(source)と Notion ページ ID / URL の環境変数名の対応 |
config.ts |
sync-config.json の読み込みと環境変数解決 |
link-rewriter.ts |
Markdown 内リンクの URL 書き換え |
notion-markdown-api.ts |
Notion の Markdown Content API 呼び出し |
sync.ts |
1ページ分の読み込み・リンク変換・API 送信 |
cli.ts |
CLI エントリ(sync / --dry-run) |
実装はリポジトリ内の tools/notion-sync/src に置いています。
sync-config.json では、特定の Notion ページに対応する GitHub 上の Markdown ファイルパスと、Notion 側のページ情報を宣言します。source にはリポジトリルートからの相対パスを書きます(例:docs/step1/README.md は、その Notion ページの原本となる Markdown の場所)。Notion ページ ID や URL の実値は環境変数に置き、設定ファイルには変数名だけを書きます。
sync-config.json の例
{ "repository": { "owner": "example-org", "name": "example-repo", "branch": "main" }, "pages": [ { "source": "docs/step1/README.md", "notionPageIdEnv": "NOTION_PAGE_STEP1_ID", "notionUrlEnv": "NOTION_PAGE_STEP1_URL" }, { "source": "docs/step2/README.md", "notionPageIdEnv": "NOTION_PAGE_STEP2_ID", "notionUrlEnv": "NOTION_PAGE_STEP2_URL" } ] }
repository はリンク書き換え時に GitHub URL を組み立てるために使います。上記の例では、docs/step1/README.md が Notion 上の STEP1 ページの原本、docs/step2/README.md が STEP2 ページの原本、という対応です。
同期スクリプトが1ページ分を処理するときの流れは次のとおりです。
sync-config.jsonから同期対象の GitHub Markdown パスと環境変数名を取得するsourceで指定した Markdown ファイルを読み込む- リンク URL を Mapping に従って書き換える(同期先同士は Notion URL、それ以外は GitHub URL など)
- 環境変数から Notion ページ ID を取得し、Markdown Content API で本文を
replace_contentする
sync.ts では、読み込み → リンク変換 → API 送信を次のようにつないでいます。
sync.ts(syncPage)
async function syncPage(page: PageMapping, options: SyncOptions): Promise<SyncResult> { const sourcePath = join(options.repoRoot, page.source); const rawMarkdown = readFileSync(sourcePath, "utf-8"); const linkContext = { sourceFile: page.source, repository: options.config.repository, pages: options.config.pages, }; const markdown = rewriteMarkdownLinks(rawMarkdown, linkContext); if (options.dryRun) { // 変換後 Markdown とリンク一覧を stdout に出力 return { source: page.source, notionPageId: page.notionPageId, status: "dry-run" }; } await replacePageMarkdown({ pageId: page.notionPageId, markdown, token: options.token!, }); return { source: page.source, notionPageId: page.notionPageId, status: "synced" }; }
リンク URL の書き換え(link-rewriter.ts)
Markdown 内の [label](url) を正規表現で走査し、リンク先を次のルールで書き換えます。
#anchorやmailto:→ そのまま- 相対パス・同一リポジトリの GitHub URL → リポジトリ内パスに正規化
- 正規化したパスが
sync-config.jsonのpagesにある → 対応する Notion URL - それ以外のリポジトリ内パス → GitHub の blob / tree URL
- 外部 URL → そのまま
link-rewriter.ts(rewriteLinkTarget)
function rewriteLinkTarget(rawUrl: string, context: LinkRewriteContext): string { const { path: urlPath, fragment } = splitUrlAndFragment(rawUrl); if (urlPath.startsWith("#") || urlPath.startsWith("mailto:")) { return rawUrl; } const resolved = resolveToRepoPath(urlPath, context); if (resolved === null) { return rawUrl; } const notionUrl = findNotionUrl(resolved.path, context.pages); if (notionUrl) { return appendFragment(notionUrl, fragment); } const githubUrl = buildGitHubUrl(resolved.path, resolved.linkType, context.repository); return appendFragment(githubUrl, fragment); }
相対パスは、同期元ファイルのディレクトリを基準に解決します。
link-rewriter.ts(resolveToRepoPath)
function resolveToRepoPath(urlPath: string, context: LinkRewriteContext): ResolvedRepoPath | null { if (/^https?:\/\//i.test(urlPath)) { return resolveGitHubUrl(urlPath, context.repository); } const sourceDir = dirname(normalizeRepoPath(context.sourceFile)); const absolutePath = normalizeRepoPath(posix.normalize(posix.join(sourceDir, urlPath))); return { path: absolutePath, linkType: "blob" }; }
変換例(link-rewriter.test.ts より):
| 入力(STEP1 から) | 出力 |
|---|---|
[STEP2](../step2/README.md) |
Notion の STEP2 ページ URL |
[トップ](../README.md) |
https://github.com/.../blob/main/docs/README.md |
https://example.com/... |
変更なし |
Notion API 呼び出し(notion-markdown-api.ts)
notion-markdown-api.ts(replacePageMarkdown)
const NOTION_API_BASE = "https://api.notion.com"; const NOTION_VERSION = "2026-03-11"; export async function replacePageMarkdown(options: ReplacePageMarkdownOptions): Promise<void> { const pageId = normalizePageId(options.pageId); const response = await fetch(`${NOTION_API_BASE}/v1/pages/${pageId}/markdown`, { method: "PATCH", headers: { Authorization: `Bearer ${options.token}`, "Content-Type": "application/json", "Notion-Version": NOTION_VERSION, }, body: JSON.stringify({ type: "replace_content", replace_content: { new_str: options.markdown, }, }), }); if (!response.ok) { const body = await response.text(); throw new Error(`Notion API error (${response.status}) for page ${pageId}: ${body}`); } }
ローカルでは --dry-run オプションでリンク変換結果だけを確認し、問題なければ本番同期を実行できます。
GitHub Actions 側
CI を動かす前に、Notion 側でインテグレーションを作成し、同期先ページにコネクトを追加しておく必要があります。取得した API トークンを NOTION_API_TOKEN として GitHub Secrets に登録し、各ページの ID / URL を Variables に置きます。
CI ワークフローは、同期対象の GitHub Markdown パス(例:docs/step1/README.md)または同期スクリプトが変更されたときに main への push で起動します。手動実行(workflow_dispatch)にも対応しています。
CI 内の処理順は次のとおりです。
- リポジトリを checkout
- Node.js / pnpm のセットアップ
- 依存パッケージのインストール
- リンク書き換えのテスト実行
- 同期スクリプト実行(Notion API トークンとページ ID / URL を環境変数で渡す)
ワークフローの paths には、同期対象となる GitHub 上の Markdown パスを列挙します。該当ファイルが更新されたときだけ CI が走ります。
GitHub Actions ワークフロー例
name: Sync Docs to Notion on: push: branches: [main] paths: - 'docs/step1/README.md' - 'docs/step2/README.md' - 'tools/notion-sync/**' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' workflow_dispatch: jobs: sync: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '20' - run: corepack enable && corepack prepare pnpm@9 --activate - run: pnpm install --frozen-lockfile - run: pnpm --filter notion-sync run test - run: pnpm --filter notion-sync run sync env: NOTION_API_TOKEN: ${{ secrets.NOTION_API_TOKEN }} NOTION_PAGE_STEP1_ID: ${{ vars.NOTION_PAGE_STEP1_ID }} NOTION_PAGE_STEP1_URL: ${{ vars.NOTION_PAGE_STEP1_URL }} NOTION_PAGE_STEP2_ID: ${{ vars.NOTION_PAGE_STEP2_ID }} NOTION_PAGE_STEP2_URL: ${{ vars.NOTION_PAGE_STEP2_URL }}
martian 版では Block 変換・削除・分割追加・Block 内リンク修正が必要でした。新 API 版では Markdown 文字列の処理と Mapping だけで済み、依存パッケージも減りました。
新 API で良かった点
体感でいちばん大きかったのは、同期が従来より速くなったことです。martian 版では Block への変換、既存 Block の列挙・削除、100件単位の追加と、API 呼び出しが何段にも分かれていました。Markdown Content API では本文を1リクエストで置換できるため、CI の待ち時間が短くなりました。
移行後も残る課題:リンクの Mapping
Markdown Content API でも、リンク先の扱いは別問題として残ります。上記の同期スクリプトでも、Notion へ送る前にリンク URL の Mapping 処理を挟んでいます。
リポジトリ内の Markdown では、リンクは次のような相対パスで書かれることが多いです。
[テキスト](./path/to/doc.md)
そのまま Notion へ展開すると、Notion 上では存在しないパスを指すため 404 になる ケースがあります。
同期前に、リンク先を次のように書き換える Mapping が必要になります。
- 同期対象のページ同士 → Notion の URL
- それ以外のリポジトリ内ファイル → GitHub の blob / tree URL
- 外部 URL → そのまま
リンク数が増えるほど、この Mapping の設計とメンテナンスが負担になります。個人的には、Notion ページ ID や URL を 環境変数や設定ファイルに置いておく 運用をおすすめします。ページが増えても、Mapping の定義を一箇所に集約できます。
運用上の注意
編集は Git 側で行う
Notion 上で直接編集すると、次回 CI 同期で上書きされます。原本は Git の Markdown だけを更新してください。
Notion 側では、編集権限を参照だけにしておくと先祖返り防止につながります。Notion 上での書き換えは、次の同期で Git 側の内容に戻ります。意図しない編集が残り続ける心配も減ります。
誰がドキュメントを更新するか
ドキュメント更新は、AI やエンジニアなど コードを専門に扱う人 が担う運用になります。Markdown はリポジトリ内に置き、PR 経由で更新する流れです。
「Notion 上で書き換えたい」というニーズへの双方向連携は、別途仕組みを考える必要があります。ただ、設計書や開発向けドキュメントのように エンジニアが読むもの であれば、Git → Notion の一方通行でも回る、と考えています。
Enhanced Markdown の仕様
Notion 独自の記法(コールアウト、トグルなど)があります。GitHub 側が CommonMark ベースの Markdown なら、そのまま送って問題ないケースが多いです。仕様は Enhanced markdown format を確認してください。
未対応 Block
bookmark や embed など、Markdown 化に未対応の Block 種別があります。取得時に <unknown/> タグとして出力される場合があります。
まとめ
設計書をコードの近くに Markdown で置くスタイルは、AI 時代とも相性が良いです。ただ閲覧には GitHub アカウントが必要なので、CI で Notion へ展開する仕組みを足しました。
従来は martian などで Block 変換が必要でした。
Notion の Markdown Content API により、不要な変換を挟まず md → Notion ができるようになり、同期も速くなりました。
リンクの Mapping は引き続き必要ですが、sync-config.json と環境変数に寄せておくと運用しやすいです。
同じ課題を抱えている方の参考になれば幸いです。



www.yayoi-kk.co.jp
弥生のエンジニアに関する note 記事もご覧ください。
note.yayoi-kk.co.jp
弥生エンジニアの公式Xのフォローもよろしくお願いします!
弥生公式エンジニアX、本日リニューアル!
— 弥生株式会社エンジニア(公式) (@yayoikk_dd) 2026年5月1日
テックブログの更新情報やイベント告知を中心に、エンジニアに役立つ情報を定期的にお届けします。
皆さまと技術でつながる場として、さらにパワーアップして再スタートです。
詳細はこちらからご覧ください🚀https://t.co/1YTrNmUXxm