AstroサイトをVercelでデプロイするときにSSRとSSGを混在するとsitemapが作成されない件について

## 現象について

タイトルのような現象が起きてしまったので、その解消法を記録しておきます。なお、環境は以下の通りです。

- astro: 5.1.7
- @astrojs/vercel: 8.0.2
- @astrojs/sitemap: 3.2.1

はじめに Astro での SSR の運用方法について書くので現象の解決方法が知りたい方は[こちら](#解決方法)まで飛んでください。

### Astro での運用

今までは Next.js を使ってサイトを構築していましたが、Astro を見つけてからは使いやすさにこちらを使ってサイト運営をしています。
デプロイは Vercel を使っているのですが、始めは元から設定されている SSG（Static Site Generator：静的サイトジェネレーター）として運用をしていました。

ただ、どうしても GET メソッドを使いたい場面が出てきたため、一部を SSR（Sever Side Rendring：サーバーサイドレンダリング）で運用することにしました。

### Astro での SSR の設定

Astro で SSR をするためには、全体を始めからの設定である SSG のままで一部を SSR にするか、全体を SSR にして一部を SSG にするか、の選択が必要になります。（もちろんすべて SSR にすることも可能）私は前者を選びました。設定方法は公式サイトの[On-demand rendering](https://docs.astro.build/ja/guides/on-demand-rendering/)にありますが、簡単に言うと@astrojs/vercel アダプターをインストールすることで SSR を使用できるようにします。
ます、

```shell
$ npx astro add vercel
```

を実行します。

```shell
 ╭──────────────────────────────────────╮
 │ npm install @astrojs/vercel@^8.0.2   │
 ╰──────────────────────────────────────╯
 Continue?(Y/n)
```

と@astrojs/vercel アダプターをインストールするが聞かれるので yes
その上で `astro.config.mjs` に

```diff_javascript
  ╭ astro.config.mjs ──────────────────────────────────╮
  │import { defineConfig } from 'astro/config';        │
+ │import vercel from '@astrojs/vercel/serverless';    │
  │                                                    │
  │export default defineConfig({                       │
+ │  adapter: vercel(),                                │
  │});                                                 │
  ╰────────────────────────────────────────────────────╯
 Continue?(Y/n)
```

が追加されるがいいか聞かれるので yes
を入力すると@astrojs/vercel アダプターが使用できるようになり SSR が使える状態になります。

### 全体を SSG のままで一部を SSR にする場合

この場合はとても簡単です。SSR にしたいファイルに

```diff:index.astro
---
+ export const prerender = false
---
<html>
<p>このページはSSRです。</p>
<html>
```

を追加するだけで、そのページが SSR になります。

### 全体を SSR にして一部を SSG にする場合

この場合は `astro.config.mjs` に手を加えます。

```diff_javascript:astro.config.mjs
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel/serverless';

export default defineConfig({
+  output: 'server',
  adapter: vercel(),
});
```

を追加します。これで、すべてが SSR になりました。
あとは、SSG にしたいファイルに

```diff:index.astro
---
+ export const prerender = true
---
<html>
<p>このページはSSGです。</p>
<html>
```

とすればできあがります。

### サイトマップの設定

Astro でサイトマップを設定するのは非常に簡単です。@astrojs/vercel アダプターをインストールしたように[@astrojs/sitemap](https://docs.astro.build/ja/guides/integrations-guide/sitemap/)を参考に

```shell
$ npx astro add sitemap
```

を実行します。

```shell
 ╭──────────────────────────────────────╮
 │ npm install @astrojs/sitemap@^3.2.1  │
 ╰──────────────────────────────────────╯
 Continue?(Y/n)
```

と@astrojs/sitemap をインストールするが聞かれるので yes
その上で `astro.config.mjs` に

```diff_javascript
  ╭ astro.config.mjs ──────────────────────────────────╮
  │import { defineConfig } from 'astro/config';        │
  │import vercel from '@astrojs/vercel/serverless';    │
+ │import sitemap from "@astrojs/sitemap";             │
  │                                                    │
  │export default defineConfig({                       │
+ │  integrations: [sitemap()],                        │
  │  adapter: vercel(),                                │
  │});                                                 │
  ╰────────────────────────────────────────────────────╯
 Continue?(Y/n)
```

が追加されるがいいか聞かれるので yes
を入力すると下準備の完了です。

このあともう少し手を加えないといけません。引き続き `astro.config.mjs` に

```diff_shell:astro.config.mjs
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel/serverless';
import sitemap from "@astrojs/sitemap";

export default defineConfig({
+ site: 'https://<自分のサイトアドレス>',
  integrations: [sitemap()],
  adapter: vercel(),
});
```

を記入して build すると `sitemap-index.xml` と `sitemap-0.xml` が作成されます。

## Vercel ヘのデプロイ

満を持してできあがったファイルをコミットし、GitHub にプッシュして、Vercel でデプロイしました。
その後で、`https://<自分のサイトアドレス>/sitemap-index.xml` を確認してみると、「404 Not found」の表示。「はて、設定を間違えたか？」色々試しデプロイし直しますが、どうやっても sitemap が見つかりません。
ほとほと困っていたところ検索に「[@astrojs/sitemap excluded from final static output with Vercel adapter](https://github.com/withastro/adapters/issues/445)（訳：＠astrojs/sitemap は、Vercel アダプターで最終的な静的出力から除外されます。）」という内容の issue を発見。
どうにも、今の@astrojs/vercel のバージョンだと@astrojs/sitemap プラグインが、Vercel がすでに静的ファイルの出力を終えた後に sitemap を生成する設定になっているため、結果的にsitemap が静的ファイルから除外されてしまう問題があるのだそうです。

## 解決方法

さて、その解決方法ですが、[上の issue](https://github.com/withastro/adapters/issues/445)にありました。[mohdlatif さんの 2024/12/28
の書き込み](https://github.com/withastro/adapters/issues/445#issuecomment-2564233900)を参考に

まず、フォルダーのトップに `copy-files.ts` という名前で以下の内容のファイルを作成します。

```javascript:copy-files.ts
import type { AstroIntegration } from 'astro';
import { promises as fs } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

// フォーマットされたログ記録のためのユーティリティ関数
function formatLog(tag: string, message: string) {
  const timestamp = new Date().toLocaleTimeString('en-US', {
    hour: '2-digit',
    hour12: false,
    minute: '2-digit',
    second: '2-digit'
  });

  // 下の行はeslintのno-consoleエラー対策のため
  // eslint-disable-next-line no-console
  console.log(
    '\n' + // 上にスペースを追加
      `\x1b[90m${timestamp}\x1b[0m ` + // グレーのタイムスタンプ
      `[\x1b[36m${tag}\x1b[0m] ` + // シアン色のタグ
      `${message}` + // メッセージ
      '\n' // 下にスペースを追加
  );
}

// ファイルを静的フォルダに移動する
async function copyFiles(srcDir: string, destDir: string) {
  const files = await fs.readdir(srcDir);

  for (const file of files) {
    const srcPath = path.join(srcDir, file);
    const destPath = path.join(destDir, file);

    const stat = await fs.stat(srcPath);

    if (stat.isDirectory()) {
      await fs.mkdir(destPath, {recursive: true});
      await copyFiles(srcPath, destPath);
    } else {
      await fs.copyFile(srcPath, destPath);
    }
  }
}

// コピー実行フック関数
export function CopyFilesPlugin(): AstroIntegration {
  return {
    hooks: {
      'astro:build:done': async ({dir}) => {
        formatLog('copy-files', 'Copying files to .vercel/output/static');

        const distDir = fileURLToPath(dir.href);
        const staticDir = path.resolve('.vercel/output/static');

        await fs.mkdir(staticDir, {recursive: true});
        await copyFiles(distDir, staticDir);
      }
    },
    name: 'copy-files'
  };
}
```

その後 `astro.config.mjs` に以下を書き込みます

```diff_shell:astro.config.mjs
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel/serverless';
import sitemap from "@astrojs/sitemap";
+ import {CopyFilesPlugin} from './copy-files';

export default defineConfig({
  site: 'https://<自分のサイトアドレス>',
  integrations: [
    sitemap()
+   // 他のintegrationsをすべて書き終えた一番最後に記入する
+   CopyFilesPlugin()
  ],
  adapter: vercel(),
});
```

これでコミット、プッシュ、デプロイでちゃんと sitemap が表示できるようになりました。