Engineering

プロダクトサイト群を 1 つの pnpm workspace に集約した

  • monorepo
  • pnpm
  • Cloudflare Workers
  • Astro
  • DevOps

ラビー合同会社では、複数のプロダクトサイト(LPやドキュメント)を別リポジトリで管理していました。デザインや内容は各プロダクトに合わせて違うのに、土台にしている Astro / Cloudflare Workers デプロイ / Renovate / Biome の設定はほぼ同じ。同じ作業をプロダクト数ぶん繰り返すのに飽きて、1つの pnpm workspace モノレポへ集約しました。

# pnpm-workspace.yaml(抜粋)
packages:
  - 'sites/*'
  - 'packages/*'

catalog:
  astro: ^6.1.3
  wrangler: ^4.92.0
  satori: ^0.26.0
  '@resvg/resvg-wasm': ^2.6.2
// sites/<name>/package.json(抜粋)
{
  "dependencies": {
    "astro": "catalog:",
    "@labee/docs-theme": "workspace:*",
    "@labee/ogp": "workspace:*",
    "@labee/seo": "workspace:*"
  }
}

この記事では、別リポジトリでの繰り返し作業に飽きた経緯、catalog で依存を揃える仕組み、共有パッケージで何を共通化して何を個別に残したか、仮移植のサイトを抱えたまま動かす運用、そして開発動線までを順に扱います。

別リポジトリの繰り返し作業に飽きた

集約前は1プロダクト1リポジトリで管理していました。プロダクトごとに独立してデプロイできる構成ですが、運用が走り始めると「同じ作業をプロダクト数ぶん繰り返す」状況が積み上がっていきます。

Astro のメジャーアップデートが入れば各リポジトリを順に上げます。wrangler の新バージョンが出れば各リポジトリで Renovate のPRが立ちます。@biomejs/biome の設定変更を全プロダクトに反映したいときも、PRを各リポジトリで作って各リポジトリでレビューします。1つあたりの作業は軽くても、プロダクト数で掛け算されると無視できません。

ブログRenovateでCloudflare Workers SDKのパッケージをグルーピングするCloudflare Workers SDKはモノレポで管理されていますが、Renovateのデフォルト設定ではパッケージごとに個別のPRが作られます。wrangler、@cloudflare/vite-plugin、miniflareを1つのPRにまとめるpackageRulesの設定と、monorepo presetへの提案で浮上したimmortal PRs問題を紹介します。

もう1つはばらつきです。OGP画像の生成ロジック、SEOの JSON-LD、サイドバーや TOC の実装。最初は1サイトで書いて、次のサイトに持っていくときにコピーしました。片方で改善が入っても、もう片方には反映されないまま乖離していきます。プロダクトサイト間で OGP デザインの完成度に差が出る、SEO メタの構造が微妙に違う、といった状態は外から見ると単に雑な印象として伝わります。

集約のモチベーションは「デザインは個別、基盤は共通」という分業を構造で強制したかった、という1点に集約できます。基盤層の修正コストを下げる代わりに、リポジトリ規模が膨らむ負荷を引き受ける判断です。

リポジトリ直下を sites/packages/ に分ける

リポジトリの直下は sites/packages/ の2層です。

.
├── sites/
│   ├── <site-a>/
│   ├── <site-b>/
│   └── ...
├── packages/
│   ├── docs-theme/   # ドキュメントサイト共通レイアウト
│   ├── ogp/          # OGP画像生成(satori + resvg-wasm)
│   └── seo/          # SEO / JSON-LD / sitemap / llms.txt
├── package.json
├── pnpm-workspace.yaml
├── biome.json
├── mise.toml
└── tsconfig.base.json

sites/ 配下が各プロダクトの Astro サイト、packages/ 配下が横断で使う共有パッケージです。各サイトは @labee/docs-theme@labee/ogp@labee/seo を workspace dependency として参照します。

ルートの package.json は、スクリプトを pnpm -r でフラットに全パッケージへ展開しています。

{
  "scripts": {
    "dev": "pnpm -r --parallel dev",
    "lint": "biome check",
    "lint:fix": "biome check --write",
    "format": "biome format --write",
    "typecheck": "pnpm -r typecheck",
    "build": "pnpm -r build",
    "test": "pnpm -r test"
  }
}

ルートで pnpm build を叩けば全サイトがビルドされ、pnpm typecheck を叩けば全サイトと全パッケージの型チェックが走ります。Biome は biome.json をルートに1つ置くだけで、sites/**/src/**packages/**/src/** を一度に検査できます。設定ファイルを1か所に集約できる単純さは、別リポジトリ運用と比較してそのまま運用負荷の差になります。

ツールバージョンは mise で固定します。mise.toml は最小構成です。

[tools]
node = "lts"

pnpm のバージョンは package.jsonpackageManager フィールドに pnpm@11.1.2 を書いており、Corepack が解決します。Node.js は 22以上engines で要求しています。

catalog で「Astro を上げる」を1行にする

pnpm-workspace.yamlcatalog 機能 で、Astro、wrangler、satori、resvg-wasm などの共通依存を1か所にまとめています。

packages:
  - 'sites/*'
  - 'packages/*'

allowBuilds:
  esbuild: true
  sharp: true
  workerd: true

catalog:
  astro: ^6.1.3
  '@astrojs/check': ^0.9.8
  wrangler: ^4.92.0
  satori: ^0.26.0
  '@resvg/resvg-wasm': ^2.6.2
  '@fontsource/noto-sans-jp': ^5.2.9
  vitest: 4.1.6
  jsdom: 29.1.1
  '@types/node': 25.8.0

各サイトの package.json ではバージョン番号の代わりに "catalog:" と書きます。Astro を上げたいときは pnpm-workspace.yaml の1行を書き換えるだけで、全サイトの Astro バージョンが揃います。これまで別リポジトリで個別に Renovate PR をマージしていた作業が、catalog エントリ1つの更新に置き換わります。Renovate もこの記法をサポートしているため、自動アップデートの動線は維持できています。

逆に catalog に載せると catalog エントリを更新しない限りサイト個別に依存バージョンを動かせなくなる 点は、運用上のトレードオフです。あるサイトだけ先行して新バージョンを試したい場合は、catalog から外して個別に書き戻す必要があります。「全サイトで揃えるバージョン」と「サイト個別で動かしてよいバージョン」のラインは、運用しながら継続的に切り分けています。

共有パッケージは構造だけを渡す

packages/ 配下のパッケージは、ロジックや構造だけを共通化し、見た目は各サイトに委ねる設計にしています。共有パッケージを増やしすぎると、サイトごとの自由度が下がり、結局どのプロダクトのサイトを見ても同じに見える状態になりかねません。共通化するもののラインを慎重に引いた結果、現在は docs-theme / ogp / seo の3パッケージ構成です。

@labee/docs-theme

ドキュメントサイト用のレイアウトを提供する共有パッケージです。DocsLayout.astro が grid ベースのレイアウト枠を担い、SidebarTocSidebarPaginationLocaleSwitcherDocBreadcrumb をコンポーネントとして公開しています。各プロダクトのドキュメントサイトは、自分の BaseLayout の中にこの DocsLayout を入れて使います。

色、フォント、余白といったビジュアル識別子は CSS カスタムプロパティ経由でサイト側から流し込む形にしているため、構造が同じでもブランドの印象は別物になります。共有パッケージが扱うのは構造とロジックだけで、デザインは持ち込まないという線引きです。

@labee/ogp

ビルド時に satoriresvg-wasm で1200×630のOGP画像を生成するパッケージです。createOgEndpoint を1本呼び、descriptors(slug、title、description、category、badge)を返してやれば、サイト側は1ファイルでOGPエンドポイントを実装できます。

// sites/<name>/src/pages/og/[...slug].png.ts
import { createOgEndpoint } from '@labee/ogp/endpoint'
import { getCollection } from 'astro:content'

export const { prerender, getStaticPaths, GET } = createOgEndpoint({
  descriptors: async () => {
    const docs = await getCollection('docs', ({ data }) => !data.draft)
    return docs.map((doc) => ({
      slug: doc.id,
      title: doc.data.title,
      description: doc.data.description,
      category: 'docs',
      badge: 'Documentation',
    }))
  },
})

テンプレートはサイトごとに差し替え可能で、独自テンプレートへ書き換えれば OGP の世界観をサイトの個性に寄せられます。

@labee/seo

JSON-LD のノードファクトリ、sitemap・robots・llms.txt のビルダー、<SeoMeta /> Astro コンポーネントをまとめたパッケージです。schema-dts のような網羅型ではなく、ラビーの house style に絞った最小型を採用しており、必須プロパティや @id の命名規則を コンパイル時に強制 します。

createSiteIds({ siteUrl, corpUrl }) を呼ぶと、@id のアンカー定数(https://<site>/#org のような文字列)をまとめて生成できます。各サイトで @id の文字列リテラルを書き散らかすと typo や規則のばらつきを生むため、ヘルパー側で統一しています。

3パッケージとも peerDependenciesastro を要求する形にしているため、catalog で揃った Astro バージョン1本に各サイトが集約します。共有パッケージが独自に Astro を引き込む状況は発生しません。

仮移植のサイトを抱えたまま動かす

ここまで構成を書きましたが、全サイトの移植が完了しているわけではありません。本格的なリデザインを反映済みのサイトもあれば、「仮移植」のコミットで最低限のページ構造だけが入っているサイトもあります。

仮移植の状態を隠さずに sites/ に並べているのは、共有パッケージのインターフェースを「単一サイトでの想定」ではなく「複数サイトに実際に組み込まれた状態」で揉む必要があるからです。@labee/docs-themeSidebarTocSidebar は、1サイトだけで設計するとそのサイトに寄りすぎます。仮移植であっても複数サイトの構造を入れておくと、共有パッケージの API が偏らずに済みます。

完成度に温度差がある状態でモノレポを動かす副作用として、pnpm -r build のビルド時間は全サイト分の合計まで伸びています。--parallel で緩和できますが、Cloudflare Workers のビルドは並列度を上げるほどメモリを使うため、CI環境では限度があります。1サイトの src/ だけを変更した PR で全サイトをビルドする必要はなく、変更のあったパッケージだけを対象にする仕組み(pnpm の --filter と git diff の組み合わせ)を整備しています。サイト単位デプロイも pnpm --filter <site> deploy の形で個別に動かせる構成にしており、CI 側で「どのコミットがどのサイトをデプロイ対象にするか」を判定するロジックを別途持っています。

サイトをまたいで開発するときの動線

pnpm -r --parallel dev を叩くとサイト群が同時に立ち上がります。各サイトは Astro のデフォルトポートから自動的にずれて確保されるため、共有パッケージを編集して @labee/docs-themeSidebar を直すと、参照側のサイトが 同時にホットリロードされて挙動を確認できます。共有コンポーネントの変更は1サイトだけで検証すると別サイトで壊れるパターンを見落としがちなため、同時起動でカバーする運用にしています。

型チェックも同じ理屈で、ルートの pnpm typecheckpnpm -r typecheck を展開し、共有パッケージの型変更が全サイトに波及した結果をその場で確認できます。@labee/seo の JSON-LD ノードに新しいプロパティを足したとき、参照側のサイトでプロパティが不足していると即座にエラーが出ます。リポジトリが1つになったことで、共通基盤の改善が 即時に全サイトへ展開される 動線が成立しました。

リポジトリ規模そのものは大きくなりました。複数のサイトと共有パッケージが同居するリポジトリは、初めて触る人にとって把握コストが高くなります。新しくジョインしたメンバーが特定のサイトだけを触りたいときに、無関係なサイトやパッケージのコードが視界に入る状態は、認知負荷として効いてきます。sites/<name>/README.mdpackages/<name>/README.md を整備して各ディレクトリの入口から読める状態を作ることも、モノレポ集約とセットで発生する作業です。